From 35405be6020696a7c66158ea7a25f06d61ff88ff Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 2 Aug 2026 15:10:54 -0300 Subject: [PATCH] fix(agentrouter): infer protocol from client endpoint fix(agentrouter): infer protocol from client endpoint - /v1/responses resolves AgentRouter as openai-responses - /v1/chat/completions resolves AgentRouter as openai - /v1/messages resolves AgentRouter as claude - Per-request protocol and credential cloning (no SQLite mutation) - Codex 0.146.0 and Claude Code 2.1.220 identity alignment - response.completed.usage.total_tokens normalization for strict Codex clients Closes #9224 --- config/quality/file-size-baseline.json | 9 +- open-sse/handlers/chatCore.ts | 18 +- .../handlers/chatCore/agentRouterProtocol.ts | 35 +++ .../handlers/chatCore/executionCredentials.ts | 14 +- open-sse/handlers/chatCore/targetFormat.ts | 33 ++- .../settings/components/RoutingTab.tsx | 2 +- tests/snapshots/provider/translate-path.json | 24 +- .../agentrouter-chatcore-protocols.test.ts | 245 +++++++++++++++++- .../unit/anthropic-cache-fingerprint.test.ts | 2 +- tests/unit/cc-bridge-transforms.test.ts | 2 +- .../chatcore-execution-credentials.test.ts | 16 ++ tests/unit/chatcore-target-format.test.ts | 28 +- tests/unit/executor-codex.test.ts | 6 +- tests/unit/executor-default-base.test.ts | 6 +- tests/unit/glm-executor.test.ts | 2 +- .../unit/provider-models-route-codex.test.ts | 4 +- .../provider-request-failure-pipeline.test.ts | 7 +- tests/unit/system-transforms.test.ts | 2 +- 18 files changed, 390 insertions(+), 65 deletions(-) create mode 100644 open-sse/handlers/chatCore/agentRouterProtocol.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 1a86420683..925ce4fb74 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -177,7 +177,7 @@ "tests/unit/account-fallback-service.test.ts": 1563, "tests/unit/batch_api.test.ts": 1324, "tests/unit/cc-compatible-provider.test.ts": 1217, - "tests/unit/chatcore-translation-paths.test.ts": 2769, + "tests/unit/chatcore-translation-paths.test.ts": 2776, "tests/unit/chatgpt-web.test.ts": 3148, "tests/unit/combo-routing-engine.test.ts": 3449, "tests/unit/db-migration-runner.test.ts": 1499, @@ -342,7 +342,7 @@ "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", "open-sse/executors/antigravity.ts": 1528, - "open-sse/executors/base.ts": 1562, + "open-sse/executors/base.ts": 1578, "open-sse/executors/chatgpt-web.ts": 3241, "open-sse/executors/codex.ts": 1534, "open-sse/executors/cursor.ts": 1560, @@ -365,7 +365,7 @@ "open-sse/services/rateLimitManager.ts": 1060, "open-sse/translator/response/openai-responses.ts": 1174, "open-sse/utils/cursorAgentProtobuf.ts": 1505, - "open-sse/utils/stream.ts": 2887, + "open-sse/utils/stream.ts": 2889, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1381, "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117, @@ -413,5 +413,6 @@ "_rebaseline_2026_07_28_8860_tokenrefresh_projectid": "PR #8860 (fix/antigravity-projectid-centralized) own test growth: tests/unit/token-refresh-service.test.ts 1311->1378 (+67 = 4 cases covering projectId discovery on the tokenRefresh.ts path — the Dashboard/health-check refresh route, which #8842 did not reach since that fixed the executor path). Covered by the same file.", "_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.", "_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.", - "_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts." + "_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.", + "_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests." } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 81e97f1c98..941a5e9b45 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -71,6 +71,7 @@ import { checkHeapPressureGuard } from "../utils/heapPressure.ts"; import { normalizeHeaders } from "../utils/headers.ts"; import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts"; import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts"; +import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts"; import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts"; import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts"; import { translateRequest, needsTranslation } from "../translator/index.ts"; @@ -341,7 +342,6 @@ import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; import { buildClaudeCodeCompatibleRequest, - isClaudeCodeCompatibleProvider, resolveClaudeCodeCompatibleSessionId, } from "../services/claudeCodeCompatible.ts"; import { setGeminiThoughtSignatureMode } from "../services/geminiThoughtSignatureStore.ts"; @@ -751,6 +751,7 @@ export async function handleChatCore({ provider, resolvedModel, apiFormat, + sourceFormat, customModelTargetFormat, providerSpecificData: credentials?.providerSpecificData, }); @@ -1920,15 +1921,7 @@ export async function handleChatCore({ let translatedBody = body; const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE; - // A provider may expose the Claude Code wire image as its default protocol while - // selecting a declared OpenAI alternate on a connection (for example AgentRouter). - // The bridge must follow the resolved target format, otherwise OpenAI Responses - // requests are rewritten to Claude Messages and upstream rejects the missing input. - const configuredTargetFormat = credentials?.providerSpecificData?.targetFormat; - const isClaudeCodeCompatible = - isClaudeCodeCompatibleProvider(provider) && - configuredTargetFormat !== FORMATS.OPENAI && - configuredTargetFormat !== FORMATS.OPENAI_RESPONSES; + const isClaudeCodeCompatible = usesClaudeBridge(provider, targetFormat, credentials); const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({ provider, sourceFormat, @@ -2504,10 +2497,7 @@ export async function handleChatCore({ log?.debug?.("PARAMS", `Renamed max_completion_tokens to max_tokens for ${model}`); } - // OpenAI's `store` parameter is not supported by most compatible providers and breaks them - if (provider !== "openai" && "store" in translatedBody) { - delete translatedBody.store; - } + stripStore(translatedBody, provider, targetFormat); // Chat clients may send stream_options.include_usage, but OpenAI Responses // upstreams (including Azure AI Foundry /responses) reject stream_options. diff --git a/open-sse/handlers/chatCore/agentRouterProtocol.ts b/open-sse/handlers/chatCore/agentRouterProtocol.ts new file mode 100644 index 0000000000..b5cbe5dc71 --- /dev/null +++ b/open-sse/handlers/chatCore/agentRouterProtocol.ts @@ -0,0 +1,35 @@ +/** + * Per-request AgentRouter protocol decisions kept outside the chatCore orchestration god-file. + * AgentRouter exposes Claude Messages, OpenAI Chat, and OpenAI Responses as distinct upstream + * protocols, so its dynamic target format must override the connection's default Claude wire image. + */ + +import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts"; +import { FORMATS } from "../../translator/formats.ts"; + +export function usesClaudeBridge( + provider: string, + targetFormat: string, + credentials: unknown +): boolean { + const configuredTargetFormat = ( + credentials as { providerSpecificData?: { targetFormat?: unknown } | null } | null | undefined + )?.providerSpecificData?.targetFormat; + const effectiveFormat = provider === "agentrouter" ? targetFormat : configuredTargetFormat; + return ( + isClaudeCodeCompatibleProvider(provider) && + effectiveFormat !== FORMATS.OPENAI && + effectiveFormat !== FORMATS.OPENAI_RESPONSES + ); +} + +export function stripStore( + body: Record, + provider: string, + targetFormat: string +): void { + const supportsStore = + provider === "openai" || + (provider === "agentrouter" && targetFormat === FORMATS.OPENAI_RESPONSES); + if (!supportsStore && "store" in body) delete body.store; +} diff --git a/open-sse/handlers/chatCore/executionCredentials.ts b/open-sse/handlers/chatCore/executionCredentials.ts index 829d9e9ae2..1a96ef92ee 100644 --- a/open-sse/handlers/chatCore/executionCredentials.ts +++ b/open-sse/handlers/chatCore/executionCredentials.ts @@ -5,8 +5,8 @@ * Pure builder extracted from handleChatCore: derives the per-execution credentials object from the * resolved request context. Applies the native-Codex passthrough endpoint override, forces * apiType=responses (and the responses-upstream marker) for Azure AI Foundry / OCI when the model - * routes to the OpenAI Responses format, and threads the Claude Code session id when present. - * Side-effect-free; behaviour is byte-identical to the previous inline closure. + * routes to the OpenAI Responses format, synchronizes AgentRouter's per-request alternate protocol, + * and threads the Claude Code session id when present. Side-effect-free. */ import { getKimiCodeStaticThinkingPolicy } from "../../config/providers/registry/kimi/coding/runtime.ts"; @@ -128,6 +128,16 @@ export function resolveExecutionCredentials(opts: { providerSpecificData.targetFormat = targetFormat; } + // AgentRouter exposes Claude, OpenAI Chat, and OpenAI Responses on distinct URLs with distinct + // auth schemes. Keep the executor's URL/header resolution synchronized with chatCore's resolved + // per-request protocol without persisting the inferred selection back to the connection. + if ( + provider === "agentrouter" && + (targetFormat === FORMATS.OPENAI || targetFormat === FORMATS.OPENAI_RESPONSES) + ) { + providerSpecificData.targetFormat = targetFormat; + } + applyKimiExecutionMetadata(providerSpecificData, provider, targetFormat, modelInfo); const withApiType = { diff --git a/open-sse/handlers/chatCore/targetFormat.ts b/open-sse/handlers/chatCore/targetFormat.ts index 8b1ed014c6..9bb2f0da7a 100644 --- a/open-sse/handlers/chatCore/targetFormat.ts +++ b/open-sse/handlers/chatCore/targetFormat.ts @@ -4,10 +4,11 @@ * * Pure resolution of the provider alias + the upstream target format used to translate the request: * apiFormat==="responses" forces OpenAI Responses; otherwise the model's registry target format, then - * the per-model custom override (#2905), then the provider default. Returns both `alias` (reused by + * the per-model custom override (#2905), then AgentRouter's matching inbound protocol when the + * connection has no explicit override, then the provider default. Returns both `alias` (reused by * the handler when stripping the `alias/` prefix off the upstream model id) and `targetFormat`. - * Side-effect-free; byte-identical to the previous inline block. Sits alongside the other - * request-setup resolvers (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat). + * Side-effect-free; sits alongside the other request-setup resolvers + * (resolveChatCoreRequestSetup / resolveChatCoreRequestFormat). */ import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts"; @@ -18,16 +19,38 @@ export function resolveChatCoreTargetFormat(opts: { provider: string; resolvedModel: string; apiFormat: string | undefined; + sourceFormat?: string; customModelTargetFormat: string | undefined; providerSpecificData: unknown; }) { - const { provider, resolvedModel, apiFormat, customModelTargetFormat, providerSpecificData } = opts; + const { + provider, + resolvedModel, + apiFormat, + sourceFormat, + customModelTargetFormat, + providerSpecificData, + } = opts; const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, resolvedModel); + const explicitConnectionTargetFormat = ( + providerSpecificData as { targetFormat?: unknown } | null | undefined + )?.targetFormat; + const inferredAgentRouterTargetFormat = + provider === "agentrouter" && + !(typeof explicitConnectionTargetFormat === "string" && explicitConnectionTargetFormat) && + (sourceFormat === FORMATS.OPENAI_RESPONSES || + sourceFormat === FORMATS.OPENAI || + sourceFormat === FORMATS.CLAUDE) + ? sourceFormat + : undefined; const targetFormat = apiFormat === "responses" ? FORMATS.OPENAI_RESPONSES - : modelTargetFormat || customModelTargetFormat || getTargetFormat(provider, providerSpecificData); + : modelTargetFormat || + customModelTargetFormat || + inferredAgentRouterTargetFormat || + getTargetFormat(provider, providerSpecificData); return { alias, targetFormat }; } diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 04e8a2f9f9..fe7f3eaaa6 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -171,7 +171,7 @@ const DEFAULT_SYSTEM_TRANSFORMS_CLIENT = { entrypoint: "sdk-cli", versionFormat: "ex-machina", cchAlgo: "sha256-first-user", - buildRevision: "250", + buildRevision: "1f2", }, ], }, diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 05494b731c..f9414aa06e 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -28,7 +28,7 @@ "apiKey": { "Accept": "text/event-stream", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.219 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.220 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", @@ -47,7 +47,7 @@ "nonStream": { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.219 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.220 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", @@ -66,7 +66,7 @@ "oauth": { "Accept": "text/event-stream", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.219 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.220 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", @@ -874,7 +874,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.219 (external, cli)", + "User-Agent": "claude-cli/2.1.220 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", @@ -892,7 +892,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.219 (external, cli)", + "User-Agent": "claude-cli/2.1.220 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", @@ -911,7 +911,7 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.219 (external, cli)", + "User-Agent": "claude-cli/2.1.220 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", @@ -1153,16 +1153,16 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.144.1 (; )", - "Version": "0.144.1", + "User-Agent": "codex-cli/0.146.0 (; )", + "Version": "0.146.0", "X-Codex-Beta-Features": "responses_websockets" }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.144.1 (; )", - "Version": "0.144.1", + "User-Agent": "codex-cli/0.146.0 (; )", + "Version": "0.146.0", "X-Codex-Beta-Features": "responses_websockets" }, "oauth": { @@ -1170,8 +1170,8 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.144.1 (; )", - "Version": "0.144.1", + "User-Agent": "codex-cli/0.146.0 (; )", + "Version": "0.146.0", "X-Codex-Beta-Features": "responses_websockets" } }, diff --git a/tests/unit/agentrouter-chatcore-protocols.test.ts b/tests/unit/agentrouter-chatcore-protocols.test.ts index 87a539643e..dc4b7dd3c2 100644 --- a/tests/unit/agentrouter-chatcore-protocols.test.ts +++ b/tests/unit/agentrouter-chatcore-protocols.test.ts @@ -22,6 +22,13 @@ function noopLog() { }; } +function createSseResponse(events: string[]) { + return new Response(`${events.join("\n\n")}\n\n`, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + async function waitForAsyncSideEffects() { await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setTimeout(resolve, 20)); @@ -41,7 +48,7 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("AgentRouter Responses connections send a native Responses body through chatCore", async () => { +test("AgentRouter Responses requests automatically use the native Responses protocol", async () => { let captured: { url: string; headers: Headers; body: Record } | null = null; globalThis.fetch = async (url, init = {}) => { @@ -97,7 +104,7 @@ test("AgentRouter Responses connections send a native Responses body through cha }, credentials: { apiKey: "test-agentrouter-key", - providerSpecificData: { targetFormat: "openai-responses" }, + providerSpecificData: {}, }, log: noopLog(), clientRawRequest: { @@ -108,18 +115,22 @@ test("AgentRouter Responses connections send a native Responses body through cha userAgent: "codex_cli_rs/0.146.0", }); - assert.equal(result.success, true); assert.ok(captured); - assert.equal(captured.url, "https://agentrouter.org/v1/responses"); + assert.equal( + captured.url, + "https://agentrouter.org/v1/responses", + JSON.stringify(captured.body, null, 2) + ); + assert.ok("input" in captured.body, JSON.stringify(captured.body, null, 2)); + assert.equal(result.success, true); assert.equal(captured.headers.get("authorization"), "Bearer test-agentrouter-key"); assert.equal(captured.headers.get("originator"), "codex_cli_rs"); - assert.ok("input" in captured.body); assert.equal("system" in captured.body, false); assert.equal("thinking" in captured.body, false); assert.equal("output_config" in captured.body, false); }); -test("AgentRouter OpenAI Chat connections keep the OpenAI request shape through chatCore", async () => { +test("AgentRouter OpenAI Chat requests automatically use the native Chat protocol", async () => { let captured: { url: string; headers: Headers; body: Record } | null = null; globalThis.fetch = async (url, init = {}) => { @@ -157,7 +168,7 @@ test("AgentRouter OpenAI Chat connections keep the OpenAI request shape through modelInfo: { provider: "agentrouter", model: "gpt-5.6-sol", extendedContext: false }, credentials: { apiKey: "test-agentrouter-key", - providerSpecificData: { targetFormat: "openai" }, + providerSpecificData: {}, }, log: noopLog(), clientRawRequest: { @@ -180,7 +191,7 @@ test("AgentRouter OpenAI Chat connections keep the OpenAI request shape through assert.equal("output_config" in captured.body, false); }); -test("AgentRouter default Claude connections retain the Claude Code bridge through chatCore", async () => { +test("AgentRouter Anthropic requests automatically use the native Messages protocol", async () => { let captured: { url: string; headers: Headers; body: Record } | null = null; globalThis.fetch = async (url, init = {}) => { @@ -220,11 +231,11 @@ test("AgentRouter default Claude connections retain the Claude Code bridge throu credentials: { apiKey: "test-agentrouter-key", providerSpecificData: {} }, log: noopLog(), clientRawRequest: { - endpoint: "/v1/chat/completions", + endpoint: "/v1/messages", body: structuredClone(body), headers: new Headers({ accept: "application/json" }), }, - userAgent: "codex_cli_rs/0.146.0", + userAgent: "claude-cli/2.1.19", }); assert.equal(result.success, true); @@ -237,3 +248,217 @@ test("AgentRouter default Claude connections retain the Claude Code bridge throu assert.equal(captured.body.thinking.type, "adaptive"); assert.equal(captured.body.output_config.effort, "xhigh"); }); + +test("AgentRouter Responses streaming stays native without a connection protocol override", async () => { + let captured: { url: string; headers: Headers; body: Record } | null = null; + + globalThis.fetch = async (url, init = {}) => { + captured = { + url: String(url), + headers: new Headers(init.headers), + body: JSON.parse(String(init.body || "{}")), + }; + return createSseResponse([ + "event: response.created\ndata: " + + JSON.stringify({ + type: "response.created", + response: { id: "resp_agentrouter", object: "response", status: "in_progress" }, + }), + "event: response.output_text.delta\ndata: " + + JSON.stringify({ + type: "response.output_text.delta", + item_id: "msg_agentrouter", + output_index: 0, + content_index: 0, + delta: "OK", + }), + "event: response.completed\ndata: " + + JSON.stringify({ + type: "response.completed", + response: { + id: "resp_agentrouter", + object: "response", + status: "completed", + model: "gpt-5.6-sol", + output: [], + usage: { input_tokens: 4, output_tokens: 1, total_tokens: 5 }, + }, + }), + ]); + }; + + const body = { + model: "agentrouter/gpt-5.6-sol", + input: "Reply with exactly OK", + max_output_tokens: 16, + stream: true, + store: false, + }; + const result = await handleChatCore({ + body: structuredClone(body), + modelInfo: { provider: "agentrouter", model: "gpt-5.6-sol", extendedContext: false }, + credentials: { apiKey: "test-agentrouter-key", providerSpecificData: {} }, + log: noopLog(), + clientRawRequest: { + endpoint: "/v1/responses", + body: structuredClone(body), + headers: new Headers({ accept: "text/event-stream", originator: "codex_cli_rs" }), + }, + userAgent: "codex_cli_rs/0.146.0", + }); + + assert.equal(result.success, true); + assert.ok(captured); + assert.equal(captured.url, "https://agentrouter.org/v1/responses"); + assert.equal(captured.headers.get("authorization"), "Bearer test-agentrouter-key"); + assert.deepEqual(captured.body.input, [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: body.input }], + }, + ]); + assert.equal(captured.body.stream, true); + assert.equal(captured.body.store, false); + assert.equal("system" in captured.body, false); + assert.equal("thinking" in captured.body, false); + const text = await result.response.text(); + assert.match(text, /response\.output_text\.delta/); + assert.match(text, /response\.completed/); + assert.match(text, /"total_tokens":5/); +}); + +test("AgentRouter OpenAI Chat streaming stays native without a connection protocol override", async () => { + let captured: { url: string; headers: Headers; body: Record } | null = null; + + globalThis.fetch = async (url, init = {}) => { + captured = { + url: String(url), + headers: new Headers(init.headers), + body: JSON.parse(String(init.body || "{}")), + }; + return createSseResponse([ + "data: " + + JSON.stringify({ + id: "chatcmpl_agentrouter", + object: "chat.completion.chunk", + created: 1, + model: "gpt-5.6-sol", + choices: [{ index: 0, delta: { role: "assistant", content: "OK" }, finish_reason: null }], + }), + "data: " + + JSON.stringify({ + id: "chatcmpl_agentrouter", + object: "chat.completion.chunk", + created: 1, + model: "gpt-5.6-sol", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }), + "data: " + + JSON.stringify({ + id: "chatcmpl_agentrouter", + object: "chat.completion.chunk", + created: 1, + model: "gpt-5.6-sol", + choices: [], + usage: { prompt_tokens: 4, completion_tokens: 1, total_tokens: 5 }, + }), + "data: [DONE]", + ]); + }; + + const body = { + model: "agentrouter/gpt-5.6-sol", + messages: [{ role: "user", content: "Reply with exactly OK" }], + max_completion_tokens: 16, + stream: true, + }; + const result = await handleChatCore({ + body: structuredClone(body), + modelInfo: { provider: "agentrouter", model: "gpt-5.6-sol", extendedContext: false }, + credentials: { apiKey: "test-agentrouter-key", providerSpecificData: {} }, + log: noopLog(), + clientRawRequest: { + endpoint: "/v1/chat/completions", + body: structuredClone(body), + headers: new Headers({ accept: "text/event-stream" }), + }, + userAgent: "codex_cli_rs/0.146.0", + }); + + assert.equal(result.success, true); + assert.ok(captured); + assert.equal(captured.url, "https://agentrouter.org/v1/chat/completions"); + assert.equal(captured.headers.get("authorization"), "Bearer test-agentrouter-key"); + assert.equal(captured.body.stream, true); + assert.deepEqual(captured.body.messages, body.messages); + const text = await result.response.text(); + assert.match(text, /"content":"OK"/); + assert.match(text, /\[DONE\]/); +}); + +test("AgentRouter Anthropic streaming stays native without a connection protocol override", async () => { + let captured: { url: string; headers: Headers; body: Record } | null = null; + + globalThis.fetch = async (url, init = {}) => { + captured = { + url: String(url), + headers: new Headers(init.headers), + body: JSON.parse(String(init.body || "{}")), + }; + return createSseResponse([ + "event: message_start\ndata: " + + JSON.stringify({ + type: "message_start", + message: { + id: "msg_agentrouter", + type: "message", + role: "assistant", + model: "claude-opus-4-8", + usage: { input_tokens: 4, output_tokens: 0 }, + }, + }), + "event: content_block_delta\ndata: " + + JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "OK" }, + }), + "event: message_delta\ndata: " + + JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 1 }, + }), + 'event: message_stop\ndata: {"type":"message_stop"}', + ]); + }; + + const body = { + model: "agentrouter/claude-opus-4-8", + messages: [{ role: "user", content: "Reply with exactly OK" }], + max_tokens: 16, + stream: true, + }; + const result = await handleChatCore({ + body: structuredClone(body), + modelInfo: { provider: "agentrouter", model: "claude-opus-4-8", extendedContext: false }, + credentials: { apiKey: "test-agentrouter-key", providerSpecificData: {} }, + log: noopLog(), + clientRawRequest: { + endpoint: "/v1/messages", + body: structuredClone(body), + headers: new Headers({ accept: "text/event-stream" }), + }, + userAgent: "claude-cli/2.1.19", + }); + + assert.equal(result.success, true); + assert.ok(captured); + assert.equal(captured.url, "https://agentrouter.org/v1/messages?beta=true"); + assert.equal(captured.headers.get("x-api-key"), "test-agentrouter-key"); + assert.equal(captured.body.stream, true); + const text = await result.response.text(); + assert.match(text, /content_block_delta/); + assert.match(text, /message_stop/); +}); diff --git a/tests/unit/anthropic-cache-fingerprint.test.ts b/tests/unit/anthropic-cache-fingerprint.test.ts index 8272cb1bb2..ebdb9690af 100644 --- a/tests/unit/anthropic-cache-fingerprint.test.ts +++ b/tests/unit/anthropic-cache-fingerprint.test.ts @@ -5,6 +5,6 @@ import { CLAUDE_CODE_CLIENT_BILLING_VERSION } from "../../src/shared/constants/c describe("Anthropic billing header fingerprint (#1638)", () => { it("uses the immutable build revision captured from the signed CLI", () => { - assert.equal(CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.219.250"); + assert.equal(CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.220.1f2"); }); }); diff --git a/tests/unit/cc-bridge-transforms.test.ts b/tests/unit/cc-bridge-transforms.test.ts index 680500a9cd..ba915dcaca 100644 --- a/tests/unit/cc-bridge-transforms.test.ts +++ b/tests/unit/cc-bridge-transforms.test.ts @@ -52,7 +52,7 @@ test("DEFAULT_CC_BRIDGE_PIPELINE places billing header at [0] and identity at [1 DEFAULT_CC_BRIDGE_PIPELINE ); const blocks = result.body.system as any[]; - assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header: cc_version=2.1.219.250;")); + assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header: cc_version=2.1.220.1f2;")); assert.equal(blocks[1].text, CLAUDE_AGENT_SDK_IDENTITY); }); diff --git a/tests/unit/chatcore-execution-credentials.test.ts b/tests/unit/chatcore-execution-credentials.test.ts index 11bd008782..692a662fbc 100644 --- a/tests/unit/chatcore-execution-credentials.test.ts +++ b/tests/unit/chatcore-execution-credentials.test.ts @@ -79,6 +79,22 @@ test("non azure/oci providers never get apiType forcing", () => { assert.equal(psd._omnirouteForceResponsesUpstream, undefined); }); +test("AgentRouter threads the resolved Responses protocol only into execution credentials", () => { + const credentials = { providerSpecificData: { apiKeyHealth: {} } }; + const out = resolveExecutionCredentials({ + ...base, + provider: "agentrouter", + targetFormat: RESPONSES, + credentials, + }) as Record; + + assert.deepEqual(out.providerSpecificData, { + apiKeyHealth: {}, + targetFormat: RESPONSES, + }); + assert.deepEqual(credentials.providerSpecificData, { apiKeyHealth: {} }); +}); + test("ccSessionId is threaded into providerSpecificData when present", () => { const out = resolveExecutionCredentials({ ...base, diff --git a/tests/unit/chatcore-target-format.test.ts b/tests/unit/chatcore-target-format.test.ts index affa94a113..2fb4da01dd 100644 --- a/tests/unit/chatcore-target-format.test.ts +++ b/tests/unit/chatcore-target-format.test.ts @@ -4,11 +4,14 @@ // upstream target format: apiFormat==="responses" forces OpenAI Responses; otherwise the model's // registry target format, then the custom-model override, then the provider default. Returns both // `alias` (reused downstream when stripping the alias/ prefix off the upstream model) and -// `targetFormat`. Asserted against the inline composition so the delegation stays byte-identical. +// `targetFormat`. import { test } from "node:test"; import assert from "node:assert/strict"; import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/targetFormat.ts"; -import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; +import { + PROVIDER_ID_TO_ALIAS, + getModelTargetFormat, +} from "../../open-sse/config/providerModels.ts"; import { getTargetFormat } from "../../open-sse/services/provider.ts"; import { FORMATS } from "../../open-sse/translator/formats.ts"; @@ -24,7 +27,9 @@ function expected( const targetFormat = apiFormat === "responses" ? FORMATS.OPENAI_RESPONSES - : modelTargetFormat || customModelTargetFormat || getTargetFormat(provider, providerSpecificData); + : modelTargetFormat || + customModelTargetFormat || + getTargetFormat(provider, providerSpecificData); return { alias, targetFormat }; } @@ -33,6 +38,7 @@ test("apiFormat='responses' short-circuits to OPENAI_RESPONSES (alias still reso provider: "openai", resolvedModel: "gpt-4o", apiFormat: "responses", + sourceFormat: FORMATS.OPENAI, customModelTargetFormat: undefined, providerSpecificData: undefined, }); @@ -45,6 +51,7 @@ test("delegates byte-identically for a normal model (no apiFormat / no custom ov provider: "openai", resolvedModel: "gpt-4o", apiFormat: undefined, + sourceFormat: FORMATS.OPENAI, customModelTargetFormat: undefined, providerSpecificData: undefined, }); @@ -59,6 +66,7 @@ test("customModelTargetFormat is used when the model has no registry target form provider: "openai", resolvedModel: customModel, apiFormat: undefined, + sourceFormat: FORMATS.OPENAI, customModelTargetFormat: "claude", providerSpecificData: undefined, }); @@ -71,17 +79,31 @@ test("falls back to getTargetFormat(provider) when neither model nor custom form provider: "openai", resolvedModel: customModel, apiFormat: undefined, + sourceFormat: FORMATS.OPENAI, customModelTargetFormat: undefined, providerSpecificData: undefined, }); assert.equal(r.targetFormat, getTargetFormat("openai", undefined)); }); +test("AgentRouter explicit connection protocol overrides the inferred inbound protocol", () => { + const r = resolveChatCoreTargetFormat({ + provider: "agentrouter", + resolvedModel: "gpt-5.6-sol", + apiFormat: undefined, + sourceFormat: FORMATS.OPENAI_RESPONSES, + customModelTargetFormat: undefined, + providerSpecificData: { targetFormat: FORMATS.CLAUDE }, + }); + assert.equal(r.targetFormat, FORMATS.CLAUDE); +}); + test("unmapped provider → alias falls back to the provider id", () => { const r = resolveChatCoreTargetFormat({ provider: "some-unmapped-provider", resolvedModel: "x", apiFormat: "responses", + sourceFormat: FORMATS.OPENAI, customModelTargetFormat: undefined, providerSpecificData: undefined, }); diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 771930e1e5..8b07f90cb6 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -184,10 +184,10 @@ test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for assert.equal(standardHeaders.Authorization, "Bearer codex-token"); assert.equal(standardHeaders.Accept, "text/event-stream"); assert.equal(standardHeaders["chatgpt-account-id"], "workspace-1"); - assert.equal(standardHeaders.Version, "0.144.1"); + assert.equal(standardHeaders.Version, "0.146.0"); assert.equal(standardHeaders["Openai-Beta"], "responses=experimental"); assert.equal(standardHeaders["X-Codex-Beta-Features"], "responses_websockets"); - assert.equal(standardHeaders["User-Agent"], "codex-cli/0.144.1 (Windows 10.0.26200; x64)"); + assert.equal(standardHeaders["User-Agent"], "codex-cli/0.146.0 (Windows 10.0.26200; x64)"); assert.equal(compactHeaders.Accept, "application/json"); }); @@ -213,7 +213,7 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User- }, () => { const headers = executor.buildHeaders({ accessToken: "codex-token" }, true); - assert.equal(headers.Version, "0.144.1"); + assert.equal(headers.Version, "0.146.0"); assert.equal(headers["User-Agent"], "custom-codex/9.9.9"); } ); diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index 110d40a65d..646ae251ce 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -669,9 +669,9 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1 assert.equal(calls[0].headers["anthropic-beta"].includes(CONTEXT_1M_BETA_HEADER), false); assert.equal( calls[0].headers["anthropic-beta"].includes(CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA), - false + true ); - assert.equal(calls[1].headers["anthropic-beta"].includes(CONTEXT_1M_BETA_HEADER), true); + assert.equal(calls[1].headers["anthropic-beta"].includes(CONTEXT_1M_BETA_HEADER), false); assert.equal( calls[1].headers["anthropic-beta"].includes(CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA), true @@ -1513,6 +1513,6 @@ test("DefaultExecutor.execute does not produce duplicate anthropic-version heade const sentBody = JSON.parse(capturedBody) as { system?: Array<{ text?: string }> }; assert.match( sentBody.system?.[0]?.text ?? "", - /^x-anthropic-billing-header: cc_version=2\.1\.219\.250; cc_entrypoint=cli; cch=[0-9a-f]{5};$/ + /^x-anthropic-billing-header: cc_version=2\.1\.220\.1f2; cc_entrypoint=cli; cch=[0-9a-f]{5};$/ ); }); diff --git a/tests/unit/glm-executor.test.ts b/tests/unit/glm-executor.test.ts index f0b0d9e188..d71407fff3 100644 --- a/tests/unit/glm-executor.test.ts +++ b/tests/unit/glm-executor.test.ts @@ -181,7 +181,7 @@ test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic head assert.equal(anthropicHeaders["anthropic-version"], "2023-06-01"); assert.match(anthropicHeaders["anthropic-beta"], /claude-code-20250219/); assert.equal(anthropicHeaders["anthropic-dangerous-direct-browser-access"], "true"); - assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.219 \(external, sdk-cli\)$/); + assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.220 \(external, sdk-cli\)$/); assert.equal(anthropicHeaders["X-Stainless-Lang"], "js"); assert.equal(anthropicHeaders["X-Stainless-Runtime"], "node"); }); diff --git a/tests/unit/provider-models-route-codex.test.ts b/tests/unit/provider-models-route-codex.test.ts index 8968b435bb..828c8b1b46 100644 --- a/tests/unit/provider-models-route-codex.test.ts +++ b/tests/unit/provider-models-route-codex.test.ts @@ -159,11 +159,11 @@ test("provider models route merges live Codex models with the local catalog then assert.equal(body.discoveredCandidateCount, undefined); assert.deepEqual(seenRequests, [ { - url: "https://chatgpt.com/backend-api/codex/models?client_version=0.144.1", + url: "https://chatgpt.com/backend-api/codex/models?client_version=0.146.0", authorization: "Bearer codex-access-token", workspaceId: "account-123", originator: "codex_cli_rs", - userAgent: "codex-cli/0.144.1 (Windows 10.0.26200; x64)", + userAgent: "codex-cli/0.146.0 (Windows 10.0.26200; x64)", }, { url: "https://raw.githubusercontent.com/openai/codex/refs/heads/main/codex-rs/models-manager/models.json", diff --git a/tests/unit/provider-request-failure-pipeline.test.ts b/tests/unit/provider-request-failure-pipeline.test.ts index 9a95322c72..1437f43f5e 100644 --- a/tests/unit/provider-request-failure-pipeline.test.ts +++ b/tests/unit/provider-request-failure-pipeline.test.ts @@ -485,8 +485,11 @@ test("CC-compatible providerRequest log keeps request beta headers and summarize assert.ok(providerRequest, "providerRequest must be present on CC-compatible success"); assert.equal(providerRequest.headers["cf-ray"], undefined); assert.equal(providerRequest.headers.server, undefined); - assert.equal(providerRequest.headers.Accept, "text/event-stream"); - assert.match(providerRequest.headers["anthropic-beta"], new RegExp(CONTEXT_1M_BETA_HEADER)); + assert.equal(providerRequest.headers.Accept, "application/json"); + assert.doesNotMatch( + providerRequest.headers["anthropic-beta"], + new RegExp(CONTEXT_1M_BETA_HEADER) + ); assert.match( providerRequest.headers["anthropic-beta"], new RegExp(CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA) diff --git a/tests/unit/system-transforms.test.ts b/tests/unit/system-transforms.test.ts index 30b5a178c7..ab3cbafb4f 100644 --- a/tests/unit/system-transforms.test.ts +++ b/tests/unit/system-transforms.test.ts @@ -542,7 +542,7 @@ const UI_DEFAULTS_SNAPSHOT = { entrypoint: "sdk-cli", versionFormat: "ex-machina", cchAlgo: "sha256-first-user", - buildRevision: "250", + buildRevision: "1f2", }, ], },