Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/domain/models/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,40 @@ const PROXY_API_KEY_ENV = "KLEIS_API_KEY";
const MODELS_DEV_URL = "https://models.dev/api.json";
const MODELS_DEV_CACHE_TTL_MS = 5 * 60 * 1000;
// Match OpenCode's ChatGPT OAuth model gate.
// https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/packages/opencode/src/plugin/openai/codex.ts
// https://github.com/anomalyco/opencode/blob/4a57013cf8cb163f58638273fd9da8538cd33cb7/packages/opencode/src/plugin/openai/codex.ts#L276-L315
const CODEX_ALLOWED_OPENAI_MODEL_IDS = new Set([
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.5",
]);
const CODEX_DISALLOWED_OPENAI_MODEL_IDS = new Set(["gpt-5.5-pro"]);
const CODEX_DISALLOWED_OPENAI_MODEL_IDS = new Set(["gpt-5.5-pro", "gpt-5.6"]);
const CODEX_DYNAMIC_GPT_VERSION_THRESHOLD = 5.4;

// ChatGPT Codex limits are smaller than the public API limits. Match OpenCode
// OAuth metadata so clients compact before the backend runs out of output room:
// https://github.com/anomalyco/opencode/blob/4a57013cf8cb163f58638273fd9da8538cd33cb7/packages/opencode/src/plugin/openai/codex.ts#L293-L312
const CODEX_MODEL_LIMIT_OVERRIDES: Record<string, JsonObject> = {
// gpt-5.5 temporarily has a restricted context window for Codex plans.
// Match OpenCode's Codex OAuth metadata so clients reserve the same budget:
// https://github.com/anomalyco/opencode/blob/537666149b5682f6f0d39d2d9f4059b3d339cc07/packages/opencode/src/plugin/openai/codex.ts#L384-L388
"gpt-5.5": {
context: 400_000,
input: 272_000,
output: 128_000,
},
"gpt-5.6-luna": {
context: 500_000,
input: 372_000,
output: 128_000,
},
"gpt-5.6-sol": {
context: 500_000,
input: 372_000,
output: 128_000,
},
"gpt-5.6-terra": {
context: 500_000,
input: 372_000,
output: 128_000,
},
};

const modelScopeRouteByCanonicalProvider = new Map<string, ModelScopeRoute>(
Expand Down
2 changes: 1 addition & 1 deletion src/http/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const proxyErrorResponse = (message: string, type = "proxy_error") => ({
},
});

const CODEX_SSE_HEADER_TIMEOUT_MS = 60_000;
const CODEX_SSE_HEADER_TIMEOUT_MS = 5 * 60 * 1000;
const CODEX_WEBSOCKET_ENABLED =
process.env.CODEX_WEBSOCKET_ENABLED?.trim().toLowerCase() === "true";

Expand Down
4 changes: 3 additions & 1 deletion src/providers/proxies/codex-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,15 @@ export const applyCodexSessionHeaders = (
): void => {
clearCodexSessionHeaders(headers);
headers.set("session-id", sessionId);
headers.set("x-client-request-id", sessionId);
headers.set("x-session-affinity", sessionId);
headers.set("x-session-id", sessionId);
};

export const clearCodexSessionHeaders = (headers: Headers): void => {
headers.delete("session_id");
headers.delete("session-id");
headers.delete("x-session-affinity");
headers.delete("x-session-id");
headers.delete("x-client-request-id");
};

Expand Down
47 changes: 39 additions & 8 deletions src/providers/proxies/openai-sse-passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,46 @@ type OpenAiSsePassthroughInput = {
keepAliveIntervalMs?: number;
};

const readSseTerminalAnomaly = (payload: unknown): string | null => {
type SseTerminalAnomaly = Record<string, string | number | boolean>;

const readSseTerminalAnomaly = (
payload: unknown
): SseTerminalAnomaly | null => {
if (!isObjectRecord(payload)) {
return null;
}

if (payload.type === "response.incomplete") {
return "response.incomplete";
const response = isObjectRecord(payload.response) ? payload.response : null;
const incompleteDetails = isObjectRecord(response?.incomplete_details)
? response.incomplete_details
: null;
return {
terminalAnomaly: "response.incomplete",
...(typeof response?.status === "string"
? { responseStatus: response.status }
: {}),
...(typeof incompleteDetails?.reason === "string"
? { incompleteReason: incompleteDetails.reason }
: {}),
};
}
if (payload.type === "response.failed" || payload.type === "error") {
return String(payload.type);
const response = isObjectRecord(payload.response) ? payload.response : null;
const nestedError = isObjectRecord(response?.error) ? response.error : null;
const error = isObjectRecord(payload.error) ? payload.error : nestedError;
const errorCode = error?.code ?? payload.code;
const errorMessage = error?.message ?? payload.message;
const errorParam = error?.param ?? payload.param;
return {
terminalAnomaly: String(payload.type),
...(typeof response?.status === "string"
? { responseStatus: response.status }
: {}),
...(typeof errorCode === "string" ? { errorCode } : {}),
...(typeof errorMessage === "string" ? { errorMessage } : {}),
...(typeof errorParam === "string" ? { errorParam } : {}),
};
}

return null;
Expand All @@ -40,7 +70,7 @@ const readLatestUsageFromSse = (
state: {
eventDataLines: string[];
latestUsage: TokenUsage | null;
terminalAnomaly: string | null;
terminalAnomaly: SseTerminalAnomaly | null;
},
extractUsage: SseUsageExtractor
): string => {
Expand Down Expand Up @@ -114,7 +144,7 @@ export const createOpenAiSseUsagePassthrough = (
const usageState = {
eventDataLines: [] as string[],
latestUsage: null as TokenUsage | null,
terminalAnomaly: null as string | null,
terminalAnomaly: null as SseTerminalAnomaly | null,
};
let pendingText = "";
let bytes = 0;
Expand Down Expand Up @@ -181,9 +211,10 @@ export const createOpenAiSseUsagePassthrough = (
input.onTokenUsage?.(usageState.latestUsage);
}
if (usageState.terminalAnomaly) {
logStreamAnomaly("openai_sse_terminal_anomaly", {
terminalAnomaly: usageState.terminalAnomaly,
});
logStreamAnomaly(
"openai_sse_terminal_anomaly",
usageState.terminalAnomaly
);
}
closed = true;
clearKeepAlive?.();
Expand Down
24 changes: 20 additions & 4 deletions tests/domain/models-dev-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ describe("models registry contract", () => {
);
expect(kleis.models?.["gpt-5.5"]?.id).toBe("gpt-5.5");
expect(kleis.models?.["gpt-5.5-pro"]).toBeUndefined();
expect(kleis.models?.["gpt-5.6"]?.id).toBe("gpt-5.6");
expect(kleis.models?.["gpt-5.6"]).toBeUndefined();
expect(kleis.models?.["gpt-5.6-luna"]?.id).toBe("gpt-5.6-luna");
expect(kleis.models?.["openai/gpt-5.3-codex"]).toBeUndefined();
expect(kleis.models?.["github-copilot/gpt-5"]?.id).toBe(
Expand Down Expand Up @@ -208,6 +208,23 @@ describe("models registry contract", () => {
});
});

test("overrides Codex gpt-5.6 variant limits to match OpenCode OAuth", () => {
const registry = buildProxyModelsRegistry({
upstreamRegistry: upstreamRegistry as unknown as Record<string, unknown>,
baseOrigin: "https://kleis.example/",
configuredProviders: ["codex"],
});

const kleis = registry.kleis as {
models?: Record<string, { limit?: unknown }>;
};
expect(kleis.models?.["gpt-5.6-luna"]?.limit).toEqual({
context: 500_000,
input: 372_000,
output: 128_000,
});
});

test("appends to existing kleis provider without replacing entries", () => {
const registry = buildProxyModelsRegistry({
upstreamRegistry: {
Expand Down Expand Up @@ -325,7 +342,7 @@ describe("models registry contract", () => {
configuredProviders: ["codex", "claude", "copilot"],
apiKeyScopes: {
providerScopes: ["codex", "copilot"],
modelScopes: ["openai/gpt-5.6", "gpt-5-mini"],
modelScopes: ["openai/gpt-5.6-luna", "gpt-5-mini"],
accountProviderScopes: null,
},
});
Expand Down Expand Up @@ -366,7 +383,7 @@ describe("models registry contract", () => {
};
expect(Object.keys(kleis.models ?? {}).sort()).toEqual([
"github-copilot/gpt-5-mini",
"gpt-5.6",
"gpt-5.6-luna",
]);
});

Expand Down Expand Up @@ -404,7 +421,6 @@ describe("models registry contract", () => {
expect(Object.keys(kleis.models ?? {})).toEqual([
"gpt-5.3-codex-spark",
"gpt-5.5",
"gpt-5.6",
"gpt-5.6-luna",
]);
});
Expand Down
93 changes: 87 additions & 6 deletions tests/providers/proxy-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,10 @@ describe("proxy contract: codex", () => {
});

test("applies auth, account-id, and endpoint from metadata", () => {
const headers = new Headers();
const headers = new Headers({
originator: "custom-client",
"user-agent": "opencode/1.18.15 ai-sdk/provider-utils/4.0.38",
});
const bodyJson = {
model: "gpt-5-codex",
instructions: "Keep responses concise",
Expand Down Expand Up @@ -302,7 +305,7 @@ describe("proxy contract: codex", () => {
expect(headers.get("authorization")).toBe("Bearer codex-access");
expect(headers.get(CODEX_ACCOUNT_ID_HEADER)).toBe("acct-meta");
expect(headers.get("content-type")).toBe("application/json");
expect(headers.get("originator")).toBe(CODEX_ORIGINATOR);
expect(headers.get("originator")).toBe("custom-client");
expect(headers.get("User-Agent")).toBe(CODEX_USER_AGENT);
expect(result.upstreamUrl).toBe(CODEX_RESPONSE_ENDPOINT);
expect(JSON.parse(result.bodyText)).toEqual({ ...bodyJson, store: false });
Expand Down Expand Up @@ -330,6 +333,8 @@ describe("proxy contract: codex", () => {
});

expect(headers.get(CODEX_ACCOUNT_ID_HEADER)).toBe("acct-fallback");
expect(headers.get("originator")).toBe(CODEX_ORIGINATOR);
expect(headers.get("User-Agent")).toBe(CODEX_USER_AGENT);
const transformed = JSON.parse(result.bodyText) as {
instructions?: string;
};
Expand Down Expand Up @@ -376,6 +381,7 @@ describe("proxy contract: codex", () => {
session_id: "raw-session-underscore",
"session-id": "raw-session-header",
"x-session-affinity": "raw-session-affinity",
"x-session-id": "raw-x-session-id",
"x-client-request-id": "raw-request-id",
});
const bodyJson = {
Expand Down Expand Up @@ -404,9 +410,10 @@ describe("proxy contract: codex", () => {
};
expect(transformed.prompt_cache_key).toBe("kleis_derived_session");
expect(headers.get("session_id")).toBeNull();
expect(headers.get("x-session-affinity")).toBeNull();
expect(headers.get("x-session-affinity")).toBe("kleis_derived_session");
expect(headers.get("x-session-id")).toBe("kleis_derived_session");
expect(headers.get("session-id")).toBe("kleis_derived_session");
expect(headers.get("x-client-request-id")).toBe("kleis_derived_session");
expect(headers.get("x-client-request-id")).toBeNull();
});

test("does not use x-client-request-id as codex session affinity", () => {
Expand Down Expand Up @@ -719,6 +726,77 @@ describe("proxy contract: codex", () => {
expect(sseText).toContain(": kleis-keepalive");
});

test("logs OpenAI terminal failure details", async () => {
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (message?: unknown): void => {
warnings.push(String(message));
};
try {
const response = createOpenAiSseUsagePassthrough({
response: createSseResponse([
{
type: "response.failed",
response: {
status: "failed",
error: {
code: "context_length_exceeded",
message: "Input exceeded the model context window",
param: "input",
},
},
},
]),
extractUsage: () => null,
});
await response.text();
} finally {
console.warn = originalWarn;
}

expect(warnings).toHaveLength(1);
expect(JSON.parse(warnings[0] ?? "{}")).toMatchObject({
event: "openai_sse_terminal_anomaly",
terminalAnomaly: "response.failed",
responseStatus: "failed",
errorCode: "context_length_exceeded",
errorMessage: "Input exceeded the model context window",
errorParam: "input",
});
});

test("logs OpenAI incomplete reasons", async () => {
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (message?: unknown): void => {
warnings.push(String(message));
};
try {
const response = createOpenAiSseUsagePassthrough({
response: createSseResponse([
{
type: "response.incomplete",
response: {
status: "incomplete",
incomplete_details: { reason: "max_output_tokens" },
},
},
]),
extractUsage: () => null,
});
await response.text();
} finally {
console.warn = originalWarn;
}

expect(JSON.parse(warnings[0] ?? "{}")).toMatchObject({
event: "openai_sse_terminal_anomaly",
terminalAnomaly: "response.incomplete",
responseStatus: "incomplete",
incompleteReason: "max_output_tokens",
});
});

test("routes compaction turns over HTTP instead of WebSocket", async () => {
const sentBodies: unknown[] = [];
const sockets = installManualCodexWebSocketMock(sentBodies);
Expand Down Expand Up @@ -906,11 +984,14 @@ describe("proxy contract: codex", () => {
expect(lowerHeaderEntries.authorization).toBe("Bearer codex-access");
expect(lowerHeaderEntries["content-length"]).toBeUndefined();
expect(lowerHeaderEntries.session_id).toBeUndefined();
expect(lowerHeaderEntries["x-session-affinity"]).toBeUndefined();
expect(lowerHeaderEntries["session-id"]).toMatch(/^kleis_/);
expect(lowerHeaderEntries["x-client-request-id"]).toBe(
expect(lowerHeaderEntries["x-session-affinity"]).toBe(
lowerHeaderEntries["session-id"]
);
expect(lowerHeaderEntries["x-session-id"]).toBe(
lowerHeaderEntries["session-id"]
);
expect(lowerHeaderEntries["x-client-request-id"]).toBeUndefined();
});

test("uses cached delta for raw response item replay", async () => {
Expand Down