From 057e8575c3138f4e90d31e23feccbad60511a2fe Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 09:24:46 +0900 Subject: [PATCH 1/2] fix(anthropic): frame the opening turn so AgentRouter stops blocking non-English AgentRouter answers 400 content-blocked when the first user message is not in English (#2074) while the identical English request returns 200. The gateway inspects the opening user content, so an Anthropic system string never reaches the filter -- the framing has to sit in that turn. Two corrections on top of @yzxcj797's #2082. The host test was hostname.includes("agentrouter"), which also matches notagentrouter.example and agentrouter.org.attacker.example. A prompt mutation keyed on a provider's identity has to be keyed on that identity exactly, so this matches agentrouter.org or a real subdomain of it. The original spliced the marker into the user's own string. That edits what the user wrote: logs, retries, and any upstream echo then show a sentence the user never typed as if they had. The framing is now its own leading text block, so the original text survives byte-for-byte. Idempotence is keyed on the leading block being exactly the marker rather than a substring test, so a user who quotes the marker later in their prompt does not suppress their own framing. --- src/adapters/anthropic.ts | 59 ++++++++ ...ropic-agentrouter-language-framing.test.ts | 126 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 tests/anthropic-agentrouter-language-framing.test.ts diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index e012a78198..626a45f286 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -594,6 +594,63 @@ function orphanToolResultText(msg: OcxToolResultMessage): string { return `[tool_result without adjacent tool_use: ${label}]\n${content}`; } +/** + * AgentRouter answers 400 `content-blocked` when the first user message is not in English + * (#2074), while the same request in English returns 200. The gateway is inspecting the opening + * 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 }, @@ -833,6 +890,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } const { system, messages } = messagesToAnthropicFormat(parsed, toolNames); + // Before image normalization, so the framing block is present for every downstream pass. + if (isAgentRouterEndpoint(provider.baseUrl)) applyAgentRouterLanguageFraming(messages); // Primary image layer: resize/re-encode to fit Anthropic limits without dropping // (anthropic-image-normalize.ts); the guard below remains the deterministic backstop. // imageTierBias > 0 = upstream-413 tightened retry (030): start every image one tier lower. diff --git a/tests/anthropic-agentrouter-language-framing.test.ts b/tests/anthropic-agentrouter-language-framing.test.ts new file mode 100644 index 0000000000..09782a6835 --- /dev/null +++ b/tests/anthropic-agentrouter-language-framing.test.ts @@ -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) => + 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 { + 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)"]); + }); +}); From 4b9814c11489db02e7ca382c97e24f85e8e917e8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 09:34:36 +0900 Subject: [PATCH 2/2] docs(devlog): record wp17 and plan wp18 --- .../080_residual_dispositions.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/devlog/_plan/260820_bug_pr_backlog_consolidation/080_residual_dispositions.md b/devlog/_plan/260820_bug_pr_backlog_consolidation/080_residual_dispositions.md index 460885e19f..3aa59cc031 100644 --- a/devlog/_plan/260820_bug_pr_backlog_consolidation/080_residual_dispositions.md +++ b/devlog/_plan/260820_bug_pr_backlog_consolidation/080_residual_dispositions.md @@ -214,3 +214,61 @@ clear keeps registry static headers" now asserts the two-header set. That is the ## wp17-wp19 — the three that need new code Recorded here as each is decided; each is its own PABCD cycle. + +### wp17 — #2082 @yzxcj797: AgentRouter language framing + +**PR #2162**, branch `codex/absorb-agentrouter-language-framing`, base `dev`. #2082 closed +(`issuecomment-5349709140`). Fixes #2074. + +The diagnosis was the contributor's and it was correct: AgentRouter answers 400 +`content-blocked` on a non-English first user message while the same request in English +returns 200, and the filter reads that turn, so an Anthropic `system` string cannot reach it. + +Two corrections. + +**Host predicate.** `hostname.includes("agentrouter")` also matches `notagentrouter.example` +and `agentrouter.org.attacker.example`. This is a prompt mutation keyed on a provider's +identity, so the key has to be that identity exactly — otherwise an unrelated destination +quietly receives an injected instruction block. Now `agentrouter.org` or a real subdomain. + +**Where the marker goes.** The original spliced it into the user's string: +`firstUser.content = \`\${MARKER}\\n\\n\${firstUser.content}\``. That edits what the user +wrote, and every downstream reader then attributes a sentence to them that they never typed — +the hidden user-turn mutation named in #1804. The framing is now its own leading text block. +It still adds content to the user turn, which is unavoidable against a filter that reads the +first user message, but additive-and-visible is a different risk class than a silent rewrite. + +Idempotence is keyed on the LEADING block being exactly the marker, not a substring test: a +user who quotes the marker mid-prompt must not suppress their own framing. + +Evidence: 10 regressions; reverting only the adapter fails 7. The 3 that stay green are the +lookalike-host and direct-Anthropic cases — green on unpatched `dev` precisely because `dev` +frames nobody, which is what makes them guards against the substring predicate rather than +restatements of it. Full suite 13529 pass / 10 skip / 0 fail; typecheck and privacy clean. + +`CONFLICTING` was an inherited `package.json` bump alone; the Anthropic hunks merge cleanly. +No version change in the replacement. + +### wp18 — #2027 @yzxcj797: OpenCode Go quota, planned + +The investigation moved the answer here too. The real issue is #1924: sibling rows +(`opencode-go-2` … `-5`) show no quota in the dashboard and no rows in +`ocx provider quota --refresh --json`, because dispatch gates on the literal provider NAME at +`src/providers/quota.ts:2087`. + +The contributor's fix swaps that for a base-URL comparison. Closer, but it does not check the +adapter, so a row pointed at the canonical URL with a different adapter would be probed. + +The repository already has the exact predicate: `registryEntryForProviderDestination` +(`registry.ts:2678`) identifies a renamed fixed key provider by normalized endpoint + adapter ++ auth mode, and is already the convention for renamed rows +(`opencode-zen-rate-limit.ts:28-43`, `derive.ts:398-425`). + +Rejected alternative, recorded: `providerMatchesRegistryTransport("opencode-go", provider)` +would need `preserveCustomDestination: true` on the registry entry, which also changes ROUTING +for a same-named custom row (`router.ts:269-274` vs `:320-336`). That may be worth doing, but +not as a side effect of a quota fix. + +The defensive canonical-URL check inside `fetchOpenCodeGoQuota` (`quota.ts:485-494`) stays: it +is what stops an API key being sent to a non-canonical host, and it should not depend on the +dispatch predicate being correct.