diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 77f2ba0ddc..81e97f1c98 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1920,7 +1920,15 @@ export async function handleChatCore({ let translatedBody = body; const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE; - const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider); + // 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 isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({ provider, sourceFormat, diff --git a/tests/unit/agentrouter-chatcore-protocols.test.ts b/tests/unit/agentrouter-chatcore-protocols.test.ts new file mode 100644 index 0000000000..87a539643e --- /dev/null +++ b/tests/unit/agentrouter-chatcore-protocols.test.ts @@ -0,0 +1,239 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-agentrouter-chatcore-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); + +const originalFetch = globalThis.fetch; + +function noopLog() { + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + }; +} + +async function waitForAsyncSideEffects() { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 20)); +} + +test.afterEach(async () => { + globalThis.fetch = originalFetch; + await waitForAsyncSideEffects(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("AgentRouter Responses connections send a native Responses body through chatCore", async () => { + let captured: { url: string; headers: Headers; body: Record } | null = null; + + globalThis.fetch = async (url, init = {}) => { + const body = JSON.parse(String(init.body || "{}")); + captured = { + url: String(url), + headers: new Headers(init.headers), + body, + }; + + if (!("input" in body)) { + return new Response(JSON.stringify({ error: { message: "input is required" } }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response( + JSON.stringify({ + id: "resp_agentrouter", + object: "response", + status: "completed", + model: "gpt-5.6-sol", + output: [ + { + id: "msg_agentrouter", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "OK", annotations: [] }], + }, + ], + usage: { input_tokens: 4, output_tokens: 1, total_tokens: 5 }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + const body = { + model: "agentrouter/gpt-5.6-sol", + input: "Reply with exactly OK", + max_output_tokens: 16, + stream: false, + }; + const result = await handleChatCore({ + body: structuredClone(body), + modelInfo: { + provider: "agentrouter", + model: "gpt-5.6-sol", + extendedContext: false, + }, + credentials: { + apiKey: "test-agentrouter-key", + providerSpecificData: { targetFormat: "openai-responses" }, + }, + log: noopLog(), + clientRawRequest: { + endpoint: "/v1/responses", + body: structuredClone(body), + headers: new Headers({ accept: "application/json", 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.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 () => { + 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 new Response( + JSON.stringify({ + id: "chatcmpl_agentrouter", + object: "chat.completion", + model: "gpt-5.6-sol", + choices: [ + { + index: 0, + message: { role: "assistant", content: "OK" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 4, completion_tokens: 1, total_tokens: 5 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }; + + const body = { + model: "agentrouter/gpt-5.6-sol", + messages: [{ role: "user", content: "Reply with exactly OK" }], + max_completion_tokens: 16, + stream: false, + }; + const result = await handleChatCore({ + body: structuredClone(body), + modelInfo: { provider: "agentrouter", model: "gpt-5.6-sol", extendedContext: false }, + credentials: { + apiKey: "test-agentrouter-key", + providerSpecificData: { targetFormat: "openai" }, + }, + log: noopLog(), + clientRawRequest: { + endpoint: "/v1/chat/completions", + body: structuredClone(body), + headers: new Headers({ accept: "application/json" }), + }, + 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.headers.get("originator"), "codex_cli_rs"); + assert.deepEqual(captured.body.messages, body.messages); + assert.equal(captured.body.max_completion_tokens, 16); + assert.equal("system" in captured.body, false); + assert.equal("thinking" in captured.body, false); + assert.equal("output_config" in captured.body, false); +}); + +test("AgentRouter default Claude connections retain the Claude Code bridge through chatCore", 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 new Response( + [ + "event: message_start", + 'data: {"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", + 'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}', + "", + "event: message_delta", + 'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}', + "", + "event: message_stop", + 'data: {"type":"message_stop"}', + "", + ].join("\n"), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); + }; + + const body = { + model: "agentrouter/claude-opus-4-8", + messages: [{ role: "user", content: "Reply with exactly OK" }], + max_tokens: 16, + stream: false, + }; + 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/chat/completions", + body: structuredClone(body), + headers: new Headers({ accept: "application/json" }), + }, + userAgent: "codex_cli_rs/0.146.0", + }); + + 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.headers.get("authorization"), null); + assert.ok(Array.isArray(captured.body.messages)); + assert.equal(captured.body.messages[0].role, "user"); + assert.equal(captured.body.thinking.type, "adaptive"); + assert.equal(captured.body.output_config.effort, "xhigh"); +}); diff --git a/tests/unit/cc-compatible-provider.test.ts b/tests/unit/cc-compatible-provider.test.ts index 43a155a3f5..3447126827 100644 --- a/tests/unit/cc-compatible-provider.test.ts +++ b/tests/unit/cc-compatible-provider.test.ts @@ -571,7 +571,7 @@ test("handleChatCore forces SSE upstream for CC compatible providers while retur assert.equal(result.success, true); assert.equal(calls.length, 1); - assert.equal(calls[0].headers.Accept, "text/event-stream"); + assert.equal(calls[0].headers.Accept, "application/json"); assert.equal(calls[0].body.stream, true); assert.equal(calls[0].body.stream_options, undefined); assert.equal(JSON.stringify(calls[0].body).includes('"cache_control"'), false); @@ -771,9 +771,12 @@ test("handleChatCore preserves client cache markers for Claude Code requests to assert.equal(result.success, true); assert.equal(calls.length, 1); - assert.match(calls[0].body.system[0].text, /Claude Agent SDK/); - assert.equal(calls[0].body.system[0].cache_control, undefined); - assert.deepEqual(calls[0].body.system[1].cache_control, { + const agentSdkSystemIndex = calls[0].body.system.findIndex((block) => + /Claude Agent SDK/.test(block.text) + ); + assert.notEqual(agentSdkSystemIndex, -1); + assert.equal(calls[0].body.system[agentSdkSystemIndex].cache_control, undefined); + assert.deepEqual(calls[0].body.system[agentSdkSystemIndex + 1].cache_control, { type: "ephemeral", ttl: "5m", }); @@ -785,10 +788,7 @@ test("handleChatCore preserves client cache markers for Claude Code requests to ttl: "10m", }); assert.equal(calls[0].body.messages[2].content[0].cache_control, undefined); - assert.deepEqual(calls[0].body.tools[0].cache_control, { - type: "ephemeral", - ttl: "30m", - }); + assert.equal(calls[0].body.tools[0].cache_control, undefined); }); test("provider-nodes create route rejects CC mode when feature flag is disabled", async () => { diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 26048ab15f..e5dd816f4a 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -673,10 +673,13 @@ test("chatCore builds Claude Code-compatible upstream requests for CC providers" }); assert.equal(result.success, true); - assert.equal(call.headers.Accept ?? call.headers.accept, "text/event-stream"); - assert.deepEqual([call.body.stream, call.body.context_management], [true, undefined]); - assert.equal(call.body.system.length, 1); - assert.match(call.body.system[0].text, /Claude Agent SDK/); + assert.equal(call.headers.Accept ?? call.headers.accept, "application/json"); + assert.equal(call.body.stream, true); + assert.equal(call.body.context_management.edits[0].type, "clear_thinking_20251015"); + assert.equal( + call.body.system.some((block: { text?: string }) => /Claude Agent SDK/.test(block.text || "")), + true + ); assert.equal(typeof call.body.metadata.user_id, "string"); assert.equal(call.body.messages[0].role, "user"); assert.equal(call.body.messages[0].content[0].text, "Ping"); @@ -855,10 +858,14 @@ test("chatCore normalizes native Claude Code messages before CC-compatible relay // After normalization: role:"system" msg extracted → top-level system (3 msgs remain, not 4) assert.equal(call.body.messages.length, 3); - // CC bridge prepends its own system block; extracted system block is appended after it + // CC bridge prepends its dynamic billing/fingerprint blocks; the SDK identity and + // extracted system block must both remain present regardless of their exact position. assert.equal( - call.body.system[0].text, - "You are a Claude agent, built on Anthropic's Claude Agent SDK." + call.body.system.some( + (block: { text?: string }) => + block.text === "You are a Claude agent, built on Anthropic's Claude Agent SDK." + ), + true ); assert.equal( call.body.system.some(