Skip to content

[Bug]: AgentRouter 400 content-blocked on non-English first messages; add language preamble for AGR providers #2074

Closed

Description

@rrmlima

Summary

AgentRouter-backed providers (AGR-OAI, AGR-CLA, or any provider whose baseUrl contains agentrouter) reject non-English first user messages with HTTP 400 content-blocked. This is a regional/language filter applied by the AgentRouter gateway to the first user message content.

Affected users: anyone routing to AgentRouter with Portuguese, Spanish, or other non-English prompts — the 400 surfaces mid-session as a hard failure, and it correlates with the non-English first prompt (see #1804, which closed #1689 as research).

The fix is to prepend an explicit English instruction frame to the first user message when the provider is AgentRouter, so the boundary filter passes while preserving the original request and the model's output language.

Reproduction

# 1. Configure an AgentRouter provider:
#    providers: { "AGR-CLA": { adapter: "anthropic", baseUrl: "https://agentrouter.org", ... } }

# 2. Send a Portuguese first message:
curl -X POST http://127.0.0.1:10100/v1/messages \
  -H "Content-Type: application/json" \
  -H "x-api-key: $OPENCODEX_API_AUTH_TOKEN" \
  -d '{"model":"claude-opus-4-8","messages":[{"role":"user","content":"responda apenas: OK"}]}'
# → 400 content-blocked (only when the FIRST user message is non-English)

# 3. Same request with an English first message:
# → 200

Root cause

The Anthropic adapter builds messages with messagesToAnthropicFormat(parsed, toolNames) and sends them verbatim. AgentRouter runs a language/regional filter on the first user message content; a non-English first prompt trips it.

Proposed diff

src/adapters/anthropic.ts — add a small idempotent helper and call it when the provider is AgentRouter:

const AGR_PREAMBLE_MARKER =
  "[Instruction: Process the user request below and respond in the appropriate language.]";

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}\n\n` });
    }
  }
}

And at the message-build site:

const { system, messages } = messagesToAnthropicFormat(parsed, toolNames);
const isAgr =
  (typeof provider.name === "string" && provider.name.startsWith("AGR")) ||
  (typeof provider.baseUrl === "string" && provider.baseUrl.includes("agentrouter"));
if (isAgr) {
  applyAgrLanguagePreamble(messages);
}

Note: provider.name can be undefined at runtime for custom providers, so the reliable signal is the baseUrl containing agentrouter (or the configured name starting with AGR).

Alternatives considered

  1. Do nothing — non-English AgentRouter users keep hitting 400; workaround is forcing English first prompts, which degrades UX for PT/ES.
  2. Client-side only — users prepend the frame manually in their prompts; fragile, not enforceable.
  3. Provider-level config flag (e.g. agrLanguagePreamble: true) — more flexible but adds config surface for what is a single, safe, idempotent transform scoped to AgentRouter destinations.

Test matrix

Case Expected
First user message in PT via AGR-CLA 200, response in PT
First user message in ES via AGR-CLA 200, response in ES
First user message in EN via AGR-CLA 200, no double preamble (idempotent)
Non-AGR provider (anthropic direct) Unchanged — no preamble
Multi-turn session (subsequent messages non-English) Unchanged — only first message gets the frame
Content as text-part array (Claude Code format) Frame prepended to the text part
Re-injection after upstream retry No duplicate marker (idempotent includes check)

Activity

  1. coderabbitai commented on Aug 18, 2026

    @coderabbitai
    Contributor
    ⚠️ Possible Duplicate Issue(s)
    🔗 Related PRs

    #626 - [WRONG BRANCH] fix: stabilize cache inputs [open]
    #757 - feat: route GPT-5.6 Pro through standard ChatGPT [open]
    #889 - fix(providers): Copilot mixed-wire routing, DeepSeek service_tier + reasoning replay, Claude 4.6/4.7 1M windows [open]
    #896 - fix(anthropic): complete AgentRouter streams that end before terminal frames [open]
    #1859 - fix(chat): preserve OpenRouter routing in native passthrough [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. yzxcj797 commented on Aug 19, 2026

    @yzxcj797
    Contributor

    Implementing the proposed diff with three small hardenings:

    • the preamble helper is idempotent (marker check before prepend — retries/replays must not stack frames), handles string content, structured content (first text part), and content with no text part (inserts one), matching the shape the adapter emits;
    • detection is isAgentRouterBaseUrl on the provider's hostname, so any configured AgentRouter-backed provider (AGR-OAI, AGR-CLA, or a custom baseUrl containing agentrouter) gets the frame — not just the registry preset;
    • applied at the single request-build site (messagesToAnthropicFormat output), so the raw request and the tool-call id allocation are untouched.

    Tests cover hostname detection, all three content shapes, idempotence, and the no-user-message no-op. Note the OpenAI adapter path doesn't need this — per the issue's reproduction the filter is on the Anthropic-format gateway; if OAI-format AgentRouter routing shows the same 400, the same helper can be wired there in a follow-up.

    (Couldn't run the bun suite locally on this Windows checkout; relying on CI.)

  3. lidge-jun commented on Aug 19, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 50 / 80

    AgentRouter(baseUrl에 agentrouter, 또는 AGR-* 이름)로 나가는 첫 사용자 메시지가 영어가 아니면 게이트웨이가 HTTP 400 content-blocked를 준다. 포르투갈어/스페인어 첫 프롬프트에서 세션이 끊긴다. 영어 첫 문장이면 200이다. 기본 native Codex 경로가 아니라 옵트인 프로바이더 호환이라 50이다. 매일 쓰는 AGR 사용자에게는 재현되고, 영어 한 줄을 앞에 다는 우회가 있다.

    src/adapters/anthropic.ts의 buildRequest()는 messagesToAnthropicFormat(parsed, toolNames) 결과를 그대로 보낸다. 그 직후는 이미지 정규화/enforceAnthropicImageLimits/toolsToAnthropicFormat뿐이다. 언어 프리앰블은 없다. src/providers/free-directory.ts의 agentrouter 항목은 adapter: "anthropic", baseUrl: "https://agentrouter.org"다. AGR-CLA 재현과 맞다.

    제안된 applyAgrLanguagePreamble()는 이 파일의 메시지 빌드 지점에 붙는 형태다. provider.name은 커스텀 프로바이더에서 undefined일 수 있으니, 리포터 말대로 baseUrl에 agentrouter가 있는지가 믿을 신호다. 마커를 includes로 보면 재시도와 영어 첫 메시지에서 이중 삽입을 피한다. 첫 user 메시지에만 넣는 조건도 재현(첫 메시지만 필터)과 같다.

    AGR-OAI처럼 OpenAI 호환 어댑터를 쓰는 이름은 anthropic 헬퍼만으로 안 닫힌다. openai-chat 경로에 같은 함수를 있다고 지어 넣지는 않는다. AgentRouter로 가는 어댑터마다 첫 user 텍스트에 같은 마커를 달지 확인해야 한다. 직접 Anthropic(api.anthropic.com)에는 넣으면 안 된다. 테스트 표의 Non-AGR 행이 그 가드다.

    프리앰블은 필터 우회이지 모델 출력 언어를 영어로 고정하면 안 된다. 마커 문구가 “respond in the appropriate language”인 이유가 그것이다. 프롬프트/토큰을 로그에 남기지 말고, 400 content-blocked만 진단에 남긴다.

    해결방안

    src/adapters/anthropic.ts의 messagesToAnthropicFormat() 직후, AgentRouter일 때만 첫 user 메시지에 영어 instruction frame을 붙인다. 판별은 provider.baseUrl에 agentrouter 포함(필요하면 이름 AGR 접두). 문자열 content와 text-part 배열 둘 다 다루고, 마커가 있으면 no-op. 직접 Anthropic OAuth/키 경로와 멀티턴 후속 메시지는 그대로 둔다. AGR-OAI가 다른 어댑터면 그 buildRequest에도 같은 마커를 재사용하되, 없는 함수를 새로 지어 호출하지는 않는다. 회귀는 PT/ES 첫 메시지 200, EN 첫 메시지 이중 프리앰블 없음, non-AGR 불변이다.

    이 댓글은 grok-bot이 작성했습니다

  4. lidge-jun commented on Aug 20, 2026

    @lidge-jun
    Owner

    Fixed on dev by #2162, merged as 087c3c3.

    Verified present on the branch rather than assumed: git log origin/dev shows the merge commit above. These PRs target dev rather than main, so GitHub does not auto-close the linked issue — this closure is manual.

    It ships in the next release cut from dev. Reopen if you still see it after updating.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions