-
Notifications
You must be signed in to change notification settings - Fork 854
fix(anthropic): frame the opening turn so AgentRouter stops blocking non-English #2162
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
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../src/adapters/anthropic"; | ||
| import { withTestTranslatorBudget } from "./helpers/translator-budget"; | ||
| import type { OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../src/types"; | ||
|
|
||
| const createAnthropicAdapter = (...args: Parameters<typeof createAnthropicAdapterProduction>) => | ||
| withTestTranslatorBudget(createAnthropicAdapterProduction(...args)); | ||
|
|
||
| const PREAMBLE = "[Instruction: Process the user request below and respond in the appropriate language.]"; | ||
| const PORTUGUESE = "responda apenas: OK"; | ||
|
|
||
| interface TextBlock { type: string; text?: string } | ||
| interface WireMessage { role: string; content: string | TextBlock[] } | ||
| interface WireBody { messages: WireMessage[] } | ||
|
|
||
| function providerAt(baseUrl: string): OcxProviderConfig { | ||
| return { adapter: "anthropic", baseUrl, apiKey: "key", authMode: "key" }; | ||
| } | ||
|
|
||
| function requestWith(messages: OcxMessage[]): OcxParsedRequest { | ||
| return { | ||
| modelId: "claude-opus-4-6", | ||
| stream: false, | ||
| context: { messages, tools: [] }, | ||
| options: {}, | ||
| }; | ||
| } | ||
|
|
||
| async function bodyFor(baseUrl: string, messages: OcxMessage[]): Promise<WireBody> { | ||
| const req = await createAnthropicAdapter(providerAt(baseUrl)).buildRequest(requestWith(messages)); | ||
| return JSON.parse(req.body as string) as WireBody; | ||
| } | ||
|
|
||
| /** | ||
| * The adapter also stamps `cache_control` onto the trailing block, which is orthogonal to this | ||
| * fix. Comparing the text sequence keeps these assertions about the framing and stops them from | ||
| * going red the next time the cache policy is tuned. | ||
| */ | ||
| function texts(message: WireMessage | undefined): (string | undefined)[] { | ||
| if (!message) throw new Error("expected a message at that index"); | ||
| if (typeof message.content === "string") return [message.content]; | ||
| return message.content.map(block => block.text); | ||
| } | ||
|
|
||
| // AgentRouter answers 400 content-blocked when the first user message is not in English | ||
| // (#2074) while the identical English request returns 200. The gateway reads the opening user | ||
| // content, so the framing has to live in that turn -- an Anthropic `system` string never | ||
| // reaches the filter. Absorbed from #2082 by @yzxcj797. | ||
| describe("AgentRouter language framing", () => { | ||
| test("frames the first user turn without touching the user's own text", async () => { | ||
| const body = await bodyFor("https://agentrouter.org/v1", [{ role: "user", content: PORTUGUESE }]); | ||
| // The framing is its OWN block. Splicing it into the user's string would make every | ||
| // downstream reader -- logs, retries, an upstream that echoes the turn -- attribute a | ||
| // sentence to the user that they never typed. | ||
| expect(texts(body.messages[0])).toEqual([PREAMBLE, PORTUGUESE]); | ||
| }); | ||
|
|
||
| test("an existing block array keeps every original block, in order, after the preamble", async () => { | ||
| const body = await bodyFor("https://agentrouter.org/v1", [ | ||
| { role: "user", content: [{ type: "text", text: "primeiro" }, { type: "text", text: "segundo" }] }, | ||
| ]); | ||
| expect(texts(body.messages[0])).toEqual([PREAMBLE, "primeiro", "segundo"]); | ||
| }); | ||
|
|
||
| test("only the first user turn is framed", async () => { | ||
| const body = await bodyFor("https://agentrouter.org/v1", [ | ||
| { role: "user", content: PORTUGUESE }, | ||
| { role: "assistant", content: "OK" }, | ||
| { role: "user", content: "e agora?" }, | ||
| ]); | ||
| expect(texts(body.messages[0])).toEqual([PREAMBLE, PORTUGUESE]); | ||
| // Index-free on purpose: the adapter may coalesce adjacent turns, so the invariant is | ||
| // "exactly one preamble, on the opening turn", not "the preamble is absent at index 2". | ||
| const userTurns = body.messages.filter(m => m.role === "user"); | ||
| expect(texts(userTurns.at(-1))).toEqual(["e agora?"]); | ||
| expect(JSON.stringify(body.messages).split(PREAMBLE)).toHaveLength(2); | ||
| }); | ||
|
|
||
| test("direct Anthropic is untouched", async () => { | ||
| const body = await bodyFor("https://api.anthropic.com/v1", [{ role: "user", content: PORTUGUESE }]); | ||
| expect(texts(body.messages[0])).toEqual([PORTUGUESE]); | ||
| expect(JSON.stringify(body)).not.toContain(PREAMBLE); | ||
| }); | ||
|
|
||
| // A hostname.includes("agentrouter") test would match both of these, quietly injecting an | ||
| // instruction block into a destination that never asked for one. | ||
| test.each([ | ||
| "https://notagentrouter.example/v1", | ||
| "https://agentrouter.org.attacker.example/v1", | ||
| ])("a lookalike host is not treated as AgentRouter: %s", async baseUrl => { | ||
| const body = await bodyFor(baseUrl, [{ role: "user", content: PORTUGUESE }]); | ||
| expect(texts(body.messages[0])).toEqual([PORTUGUESE]); | ||
| expect(JSON.stringify(body)).not.toContain(PREAMBLE); | ||
| }); | ||
|
|
||
| test("a real AgentRouter subdomain is still AgentRouter", async () => { | ||
| const body = await bodyFor("https://api.agentrouter.org/v1", [{ role: "user", content: PORTUGUESE }]); | ||
| expect(texts(body.messages[0])).toEqual([PREAMBLE, PORTUGUESE]); | ||
| }); | ||
|
|
||
| test("building twice yields exactly one preamble each time", async () => { | ||
| const adapter = createAnthropicAdapter(providerAt("https://agentrouter.org/v1")); | ||
| for (const attempt of [0, 1]) { | ||
| const req = await adapter.buildRequest(requestWith([{ role: "user", content: PORTUGUESE }])); | ||
| const body = JSON.parse(req.body as string) as WireBody; | ||
| expect(texts(body.messages[0]).filter(t => t === PREAMBLE)).toHaveLength(1); | ||
| expect(attempt).toBeLessThan(2); | ||
| } | ||
| }); | ||
|
|
||
| // Idempotence keyed on a substring would let a user who quotes the marker mid-prompt | ||
| // suppress their own framing, which is the failure the workaround exists to prevent. | ||
| test("a user quoting the marker later still gets the leading preamble", async () => { | ||
| const quoted = `o servidor respondeu ${PREAMBLE} e falhou`; | ||
| const body = await bodyFor("https://agentrouter.org/v1", [{ role: "user", content: quoted }]); | ||
| expect(texts(body.messages[0])).toEqual([PREAMBLE, quoted]); | ||
| }); | ||
|
|
||
| // An assistant-only request is synthesized into a "(continue)" user turn upstream of this | ||
| // code, so the framing lands on that synthetic turn rather than on nothing. Pinned because it | ||
| // is the one case where the preamble is attached to text the user did not send. | ||
| test("an assistant-only request frames the synthesized continue turn", async () => { | ||
| const body = await bodyFor("https://agentrouter.org/v1", [{ role: "assistant", content: "só isso" }]); | ||
| expect(texts(body.messages[0])).toEqual([PREAMBLE, "(continue)"]); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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 | 🟡 Minor | ⚡ Quick win
Exercise the required framing branches with valid wire shapes.
The idempotence test rebuilds fresh unframed input on each attempt. It does not execute the exact leading-block check.
The structured-content test covers only text blocks. Add an image-only content fixture to cover content without a text part.
The assistant fixtures use strings.
messagesToAnthropicFormatiterates assistant content parts, so these fixtures do not test preservation of an assistant tail before the synthesized"(continue)"user turn. Use[{ type: "text", text: "OK" }]and assert that the final user message contains[PREAMBLE, "(continue)"].Also applies to: 65-76, 101-125
🤖 Prompt for AI Agents