From 2328c16c766e9ef8f95b02fcf4e9f206f25b2f55 Mon Sep 17 00:00:00 2001 From: rrmlima Date: Thu, 27 Aug 2026 21:19:33 -0300 Subject: [PATCH] fix(agentrouter): support openai-chat client identity and framing Share AgentRouter host and language framing policy across adapters, add a stable Codex originator for openai-chat routes, and preserve case-insensitive operator header overrides. Co-authored-by: CommandCodeBot --- src/adapters/agentrouter.ts | 46 +++++++++++++++++++++++++ src/adapters/anthropic.ts | 52 +---------------------------- src/adapters/openai-chat.ts | 10 ++++-- tests/openai-chat-hardening.test.ts | 47 ++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 54 deletions(-) create mode 100644 src/adapters/agentrouter.ts diff --git a/src/adapters/agentrouter.ts b/src/adapters/agentrouter.ts new file mode 100644 index 00000000000..8a0c1524aeb --- /dev/null +++ b/src/adapters/agentrouter.ts @@ -0,0 +1,46 @@ +export const AGENTROUTER_LANGUAGE_PREAMBLE = + "[Instruction: Process the user request below and respond in the appropriate language.]"; + +export function isAgentRouterEndpoint(baseUrl: string): boolean { + try { + const { hostname } = new URL(baseUrl); + return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org"); + } catch { + return false; + } +} + +export function agentRouterDefaultHeaders( + baseUrl: string, + configuredHeaders?: Record, +): Record { + if (!isAgentRouterEndpoint(baseUrl)) return {}; + const hasOriginator = Object.keys(configuredHeaders ?? {}).some(name => name.toLowerCase() === "originator"); + return hasOriginator ? {} : { originator: "codex_cli_rs" }; +} + +export function applyAgentRouterLanguageFraming(messages: unknown[]): void { + const firstUser = messages.find( + (message): message is { role: string; content: unknown } => + typeof message === "object" && message !== null && (message as { role?: unknown }).role === "user", + ); + if (!firstUser) return; + const preamble = { type: "text", text: AGENTROUTER_LANGUAGE_PREAMBLE }; + if (typeof firstUser.content === "string") { + firstUser.content = firstUser.content === "" + ? [preamble] + : [preamble, { type: "text", text: firstUser.content }]; + return; + } + if (!Array.isArray(firstUser.content)) return; + const [head] = firstUser.content as { type?: unknown; text?: unknown }[]; + if (head?.type === "text" && head.text === AGENTROUTER_LANGUAGE_PREAMBLE) return; + (firstUser.content as unknown[]).unshift(preamble); +} + +export function frameAgentRouterMessages(baseUrl: string, messages: unknown): unknown { + if (!isAgentRouterEndpoint(baseUrl) || !Array.isArray(messages)) return messages; + const copy = structuredClone(messages) as unknown[]; + applyAgentRouterLanguageFraming(copy); + return copy; +} diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index b8312eb453d..de4c7df1508 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -28,6 +28,7 @@ import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { decodeServerSentEvents } from "../lib/sse-decoder"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget"; import { isReasoningEffortOmitted, modelRecordValue } from "../reasoning-effort"; +import { applyAgentRouterLanguageFraming, isAgentRouterEndpoint } from "./agentrouter"; /** Map a user content part to an Anthropic content block (text or image source). */ function toAnthropicContentPart(p: OcxContentPart): unknown { @@ -647,57 +648,6 @@ function orphanToolResultText(msg: OcxToolResultMessage): string { * user content, so an Anthropic `system` string cannot reach it — the framing has to sit in the * first user turn. */ -const AGENTROUTER_LANGUAGE_PREAMBLE = - "[Instruction: Process the user request below and respond in the appropriate language.]"; - -/** - * Exact host match, not a substring. - * - * A `hostname.includes("agentrouter")` test also matches `notagentrouter.example` and - * `agentrouter.org.attacker.example`, which would let an unrelated destination silently - * receive an injected instruction block. A prompt mutation keyed on a provider's identity - * must be keyed on that identity exactly. - */ -function isAgentRouterEndpoint(baseUrl: string): boolean { - try { - const { hostname } = new URL(baseUrl); - return hostname === "agentrouter.org" || hostname.endsWith(".agentrouter.org"); - } catch { - return false; - } -} - -/** - * Prepend the framing as its OWN text block instead of splicing it into the user's string. - * - * The distinction matters: rewriting `content` to `${marker}\n\n${original}` edits what the - * user wrote, and every downstream consumer — logs, retries, an upstream that echoes the turn — - * then sees a sentence the user never typed as if they had. A separate leading block carries the - * same signal to the filter while the original text survives byte-for-byte. - * - * Only the first user turn is framed, because only the first is what the gateway rejects. - */ -function applyAgentRouterLanguageFraming(messages: unknown[]): void { - const firstUser = messages.find( - (m): m is { role: string; content: unknown } => - typeof m === "object" && m !== null && (m as { role?: unknown }).role === "user", - ); - if (!firstUser) return; - const preamble = { type: "text", text: AGENTROUTER_LANGUAGE_PREAMBLE }; - if (typeof firstUser.content === "string") { - firstUser.content = firstUser.content === "" - ? [preamble] - : [preamble, { type: "text", text: firstUser.content }]; - return; - } - if (!Array.isArray(firstUser.content)) return; - // Idempotence is keyed on the LEADING block being exactly the marker. A substring test would - // let a user who quotes the marker later in their own prompt suppress the framing entirely. - const [head] = firstUser.content as { type?: unknown; text?: unknown }[]; - if (head?.type === "text" && head.text === AGENTROUTER_LANGUAGE_PREAMBLE) return; - (firstUser.content as unknown[]).unshift(preamble); -} - function messagesToAnthropicFormat( parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }, diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index f2a36e0490f..1f4cb2aa74f 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -26,6 +26,7 @@ import { } from "../providers/fastwire"; import { openaiChatCompletionsUrl } from "./openai-chat-url"; import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; +import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter"; import { isXaiSchemaTarget, lookupLocalJsonPointer, @@ -87,7 +88,10 @@ function openAIChatTransport(provider: OcxProviderConfig): { if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) { throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`); } - const headers: Record = { "Content-Type": "application/json" }; + const headers: Record = { + "Content-Type": "application/json", + ...agentRouterDefaultHeaders(provider.baseUrl, provider.headers), + }; if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential }; @@ -111,7 +115,7 @@ export function buildOpenAIChatPassthroughRequest( const body: Record = { model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId, - messages: rawBody.messages, + messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages), stream, }; for (const field of CHAT_PASSTHROUGH_FIELDS) { @@ -1379,7 +1383,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd buildRequest(parsed: OcxParsedRequest) { const { url, headers, hasCredential } = openAIChatTransport(provider); - const messages = messagesToChatFormat(parsed, provider); + const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider)); const tools = toolsToChatFormatForProvider(parsed, provider); const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider); diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index e3ead6e28dc..02892ae1df4 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -953,3 +953,50 @@ test("tool-call deltas emit heartbeats so a long buffering phase is not read as expect(visible.at(-1)).toMatchObject({ type: "done" }); }); }); + +describe("AgentRouter openai-chat compatibility", () => { + const preamble = "[Instruction: Process the user request below and respond in the appropriate language.]"; + + test("adds a stable Codex originator while preserving operator header precedence", async () => { + const automatic = await createOpenAIChatAdapter(provider({ baseUrl: "https://agentrouter.org/v1" })).buildRequest(parsed()); + expect(automatic.headers.originator).toBe("codex_cli_rs"); + + const overridden = await createOpenAIChatAdapter(provider({ + baseUrl: "https://agentrouter.org/v1", + headers: { Originator: "operator-client" }, + })).buildRequest(parsed()); + expect(overridden.headers.Originator).toBe("operator-client"); + expect(overridden.headers.originator).toBeUndefined(); + }); + + test.each([ + "https://notagentrouter.example/v1", + "https://agentrouter.org.attacker.example/v1", + ])("does not add compatibility behavior to a lookalike host: %s", async baseUrl => { + const request = await createOpenAIChatAdapter(provider({ baseUrl })).buildRequest(parsed()); + expect(request.headers.originator).toBeUndefined(); + expect(request.body).not.toContain(preamble); + }); + + test("frames translated chat without changing the original parsed request", async () => { + const source = parsed(); + source.context.messages[0]!.content = "responda somente: OK"; + const request = await createOpenAIChatAdapter(provider({ baseUrl: "https://agentrouter.org/v1" })).buildRequest(source); + const body = JSON.parse(request.body as string) as { messages: { content: { text: string }[] }[] }; + expect(body.messages[0]?.content.map(part => part.text)).toEqual([preamble, "responda somente: OK"]); + expect(source.context.messages[0]?.content).toBe("responda somente: OK"); + }); + + test("frames passthrough chat without mutating the caller body", () => { + const rawBody = { messages: [{ role: "user", content: "responda somente: OK" }] }; + const request = buildOpenAIChatPassthroughRequest( + provider({ baseUrl: "https://agentrouter.org/v1" }), + rawBody, + "test-model", + false, + ); + const body = JSON.parse(request.body as string) as { messages: { content: { text: string }[] }[] }; + expect(body.messages[0]?.content.map(part => part.text)).toEqual([preamble, "responda somente: OK"]); + expect(rawBody.messages[0]?.content).toBe("responda somente: OK"); + }); +});