diff --git a/package.json b/package.json index f4e8bbd5c9..178d6a9c7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.24.2", + "version": "2.25.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index fd141ccfb8..3075d72287 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -816,6 +816,53 @@ function normalizeAnthropicInputSchema(schema: unknown): Record return normalized; } +/** + * AgentRouter's gateway applies a language filter to the FIRST user message + * content and hard-fails non-English prompts with 400 content-blocked + * (#2074). Prepending an explicit English instruction frame lets the filter + * pass while the model still answers in the user's language — the frame says + * "respond in the appropriate language", it does not force English output. + */ +export const AGR_PREAMBLE_MARKER = + "[Instruction: Process the user request below and respond in the appropriate language.]"; + +export function isAgentRouterBaseUrl(baseUrl: string): boolean { + try { + return new URL(baseUrl).hostname.includes("agentrouter"); + } catch { + return false; + } +} + +/** + * Prepend the AgentRouter language frame to the first user message, once. + * Idempotent: a message already carrying the marker is left untouched (retries + * and replays must not stack frames). + */ +export function applyAgrLanguagePreamble(messages: unknown[]): void { + const firstUser = messages.find( + m => typeof m === "object" && m !== null && (m as { role?: string }).role === "user", + ) as { content?: unknown } | undefined; + if (!firstUser) return; + + if (typeof firstUser.content === "string") { + if (!firstUser.content.includes(AGR_PREAMBLE_MARKER)) { + firstUser.content = `${AGR_PREAMBLE_MARKER}\n\n${firstUser.content}`; + } + } else if (Array.isArray(firstUser.content)) { + const textPart = firstUser.content.find( + p => typeof p === "object" && p !== null && (p as { type?: string }).type === "text", + ) as { text?: string } | undefined; + if (textPart && typeof textPart.text === "string") { + if (!textPart.text.includes(AGR_PREAMBLE_MARKER)) { + textPart.text = `${AGR_PREAMBLE_MARKER}\n\n${textPart.text}`; + } + } else { + firstUser.content.unshift({ type: "text", text: AGR_PREAMBLE_MARKER }); + } + } +} + export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long"): ProviderAdapter { const isOAuth = provider.authMode === "oauth"; const toolNames = buildToolNameTransforms(provider); @@ -833,6 +880,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } const { system, messages } = messagesToAnthropicFormat(parsed, toolNames); + if (isAgentRouterBaseUrl(provider.baseUrl)) { + applyAgrLanguagePreamble(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-agr-preamble.test.ts b/tests/anthropic-agr-preamble.test.ts new file mode 100644 index 0000000000..f6d5341b61 --- /dev/null +++ b/tests/anthropic-agr-preamble.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { + AGR_PREAMBLE_MARKER, + applyAgrLanguagePreamble, + isAgentRouterBaseUrl, +} from "../src/adapters/anthropic"; + +describe("AgentRouter language preamble (#2074)", () => { + test("detects AgentRouter base URLs by hostname", () => { + expect(isAgentRouterBaseUrl("https://agentrouter.org")).toBe(true); + expect(isAgentRouterBaseUrl("https://agentrouter.org/v1")).toBe(true); + expect(isAgentRouterBaseUrl("https://api.anthropic.com")).toBe(false); + expect(isAgentRouterBaseUrl("not a url")).toBe(false); + }); + + test("prepends the frame to a string first user message", () => { + const messages = [ + { role: "user", content: "responda apenas: OK" }, + { role: "assistant", content: "OK" }, + ]; + applyAgrLanguagePreamble(messages); + const first = messages[0] as { content: string }; + expect(first.content.startsWith(AGR_PREAMBLE_MARKER)).toBe(true); + expect(first.content).toContain("responda apenas: OK"); + }); + + test("prepends into the first text part of structured content", () => { + const messages = [ + { role: "user", content: [{ type: "image", source: {} }, { type: "text", text: "hola" }] }, + ]; + applyAgrLanguagePreamble(messages); + const parts = (messages[0] as { content: Array<{ type: string; text?: string }> }).content; + const text = parts.find(p => p.type === "text"); + expect(text?.text?.startsWith(AGR_PREAMBLE_MARKER)).toBe(true); + expect(text?.text).toContain("hola"); + }); + + test("inserts a text part when the first message has none", () => { + const messages = [{ role: "user", content: [{ type: "image", source: {} }] }]; + applyAgrLanguagePreamble(messages); + const parts = (messages[0] as { content: Array<{ type: string; text?: string }> }).content; + expect(parts[0]?.type).toBe("text"); + expect(parts[0]?.text).toContain(AGR_PREAMBLE_MARKER); + }); + + test("is idempotent — replays do not stack frames", () => { + const messages = [{ role: "user", content: "bonjour" }]; + applyAgrLanguagePreamble(messages); + applyAgrLanguagePreamble(messages); + const content = (messages[0] as { content: string }).content; + expect(content.split(AGR_PREAMBLE_MARKER).length - 1).toBe(1); + }); + + test("no user message is a no-op", () => { + const messages = [{ role: "assistant", content: "hi" }]; + applyAgrLanguagePreamble(messages); + expect(messages).toEqual([{ role: "assistant", content: "hi" }]); + }); +});