-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix(agentrouter): support openai-chat client identity and framing #2796
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string>, | ||
| ): Record<string, string> { | ||
| 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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"); | ||
| }); | ||
|
Comment on lines
+981
to
+1001
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win Add an idempotence regression. These tests apply framing once to an unframed message. They do not test an already framed message or repeated processing. A regression that prepends the preamble twice would pass. Add assertions for both translated and passthrough paths that the first user message contains exactly one preamble after repeated processing. As per path instructions, behavior changes in 🤖 Prompt for AI AgentsSource: Path instructions |
||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Apply the shared default header policy to Anthropic requests.
src/adapters/anthropic.tsLine 31 imports the framing helpers but notagentRouterDefaultHeaders. Lines 991-1010 then build Anthropic headers without the defaultoriginator. Therefore, an Anthropic provider targeting AgentRouter with no operator override does not sendoriginator: codex_cli_rs.Import
agentRouterDefaultHeadersand spread its result into the header object beforeObject.assign(headers, provider.headers). This preserves case-insensitive operator overrides and applies the required AgentRouter fingerprint.Proposed fix
🤖 Prompt for AI Agents