Skip to content

[Feature / Bug]: Combo fallback is bypassed on HTTP 200 content_filter / safety refusals (Anthropic ToS blocks) #4470

Description

@lucaspeyrin

9Router fails to trigger model combo fallback when upstream providers return HTTP 200 with safety refusals (finish_reason: "content_filter" / stop_reason: "refusal"), causing automated workflows to abort fatally instead of failing over to subsequent combo targets.

To solve this, 9Router's combo execution loop needs to evaluate response completion metadata (finish_reason === 'content_filter' / provider refusal payloads) alongside HTTP status codes when determining candidate failure.


Related Issues

In decolua/9router

Upstream Ecosystem References

  • anthropics/claude-code#95670: [Bug] Safeguard blocks firing on all requests with identical prompt templates (reasoning_extraction / duplicate model outputs).
  • simonw/llm-anthropic#90: Handle Fable refusals: Claude refused this request (reasoning_extraction / Terms of Service).

Problem Description

When using a fallback combo (e.g., genius configured with [cc/claude-sonnet-5-5(high), cx/gpt-6-astra(high)]), 9Router currently checks whether upstream returns HTTP 4xx (e.g. 429) or 5xx. If so, it logs [COMBO] Trying model 2/N and fails over.

However, providers like Anthropic return heuristic safeguard blocks (such as multi-agent burst detection tripping reasoning_extraction) as an HTTP 200 OK payload containing:

{
  "choices": [
    {
      "finish_reason": "content_filter",
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "This request was blocked as it seems to violate Anthropic's Terms of Service restrictions on reverse engineering or duplicating model outputs. To learn more, visit https://www.anthropic.com/legal/commercial-terms."
      }
    }
  ]
}

Or native Anthropic Messages API:

{
  "type": "message",
  "stop_reason": "refusal",
  "stop_details": { "type": "content_filter", "category": "reasoning_extraction" },
  "content": [{ "type": "text", "text": "This request was blocked as it seems to violate Anthropic's Terms of Service..." }]
}

Because the HTTP status is 200, 9Router logs:

[COMBO] Model cc/claude-sonnet-5-5 succeeded

It immediately flushes this refusal downstream. Client agents (OpenCode, Claude Code, Cursor, Cline) treat the response as a terminal provider refusal and crash or stop the session, completely ignoring the second model in the combo (cx/gpt-6-astra).


Steps to Reproduce

  1. Define a 9Router fallback combo with two distinct providers:
    {
      "id": "test-fallback",
      "name": "Test Fallback",
      "strategy": "fallback",
      "models": [
        "cc/claude-sonnet-5-5",
        "cx/gpt-6-astra"
      ]
    }
  2. Send a request to http://localhost:20128/v1/chat/completions pointing to model test-fallback.
  3. Simulate or trigger an upstream Anthropic content/ToS filter response (HTTP 200 with finish_reason: "content_filter").
  4. Observed Behavior: 9Router marks cc/claude-sonnet-5-5 as succeeded, forwards the content filter message to the caller, and never attempts cx/gpt-6-astra.
  5. Expected Behavior: 9Router detects finish_reason: "content_filter" / stop_reason: "refusal", marks candidate 1 as failed, and falls back to candidate 2 (cx/gpt-6-astra).

Root Cause Analysis

In 9Router's proxy layer:

  1. Candidate success is evaluated solely on response.ok (HTTP status code 200-299).
  2. Neither the non-streaming JSON body nor the opening chunks of a streaming SSE response are inspected for provider-level refusal finish reasons.
  3. The combo router lacks an evaluation hook to treat content_filter / refusal as a retriable failure condition.

Proposed Solution

1. Non-Streaming Requests

In the combo execution handler, after parsing the upstream JSON body:

const isContentFilter = 
  data?.choices?.[0]?.finish_reason === "content_filter" ||
  data?.stop_reason === "refusal" ||
  data?.stop_details?.type === "content_filter";

if (isContentFilter && combo.fallbackOnContentFilter !== false) {
  logger.warn(`[COMBO] Model ${model} returned content_filter/refusal. Failing over to next candidate.`);
  // Trigger next candidate in combo loop instead of returning 200 to client
  continue;
}

2. Streaming (SSE) Requests

Anthropic and OpenAI send content_filter either:

  • In the initial response payload before text generation starts, or
  • As an immediate single-event block (message_start followed directly by message_delta with stop_reason: "refusal" and 0 tokens of generated code).

Implementation:

  • Buffer the stream until the first substantive delta or terminal stop reason event before piping headers to the downstream client.
  • If the stream terminates immediately with finish_reason: "content_filter" and zero valid completion output, abort the stream upstream without flushing to client, increment combo candidate index, and route to the next provider.

3. Combo Configuration Setting (Opt-in / Configurable)

Add a flag to combo options so users can control whether content filters trigger fallback:

{
  "id": "genius",
  "strategy": "fallback",
  "fallbackOnContentFilter": true,
  "models": ["cc/claude-sonnet-5-5(high)", "cx/gpt-6-astra(high)"]
}

Environment

  • 9Router Version: v0.5.65 / v0.5.91
  • Clients Tested: OpenCode CLI, Antigravity
  • Affected Models: cc/* (Claude Code OAuth proxy) failing over to cx/* (Codex)

Activity

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions