Skip to content

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

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)

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