Skip to content

[Regression] RequestContentBlock union missing ThinkingBlock since v0.2.0 — every Anthropic /claude/v1/messages with thinking blocks returns 422 #71

Description

@jleechan2015

Bug Description

Starting with v0.2.0, the RequestContentBlock Pydantic discriminated-union in ccproxy/llms/models/anthropic.py no longer includes ThinkingBlock or RedactedThinkingBlock. Every Anthropic /claude/v1/messages request whose body contains one or more type: "thinking" (or type: "redacted_thinking") content blocks is rejected with HTTP 422 at the route's typed-parameter validation step (FastAPI), before the function body ever runs.

v0.1.x accepted thinking blocks in requests because (a) the route handler used await request.body() + raw passthrough to the proxy service (no Pydantic body validation at the route) and (b) MessageContentBlock explicitly listed ThinkingContentBlock in its union.

The 0.2.x refactor renamed the block types (TextContentBlockTextBlock, etc.) and moved them to a new module — but ThinkingBlock was dropped from RequestContentBlock while still being accepted in ResponseContentBlock. This is internally inconsistent: ccproxy knows about thinking blocks in the response shape but rejects them in the request shape. With extra="forbid" on Message and CreateMessageRequest, any type: "thinking" in the inbound body is a hard 422.

The Anthropic API itself accepts thinking blocks in multi-turn requests — clients are required to send them back so the model can continue a thinking trace across turns. So this isn't a case of Claude Code sending a malformed payload; ccproxy is rejecting an otherwise-valid Anthropic-format request.

Error

{
  "type": "error",
  "error": {
    "message": "body -> messages -> 1 -> content -> str: Input should be a valid string; body -> messages -> 1 -> content -> list[tagged-union[TextBlock,ImageBlock,ToolUseBlock,ToolResultBlock]] -> 0: Input tag 'thinking' found using 'type' does not match any of the expected tags: 'text', 'image', 'tool_use', 'tool_result'; body -> messages -> 3 -> content -> ..."
  }
}

The list[tagged-union[TextBlock,ImageBlock,ToolUseBlock,ToolResultBlock]] syntax + Input tag 'thinking' found using 'type' does not match are Pydantic v2's discriminated-union error format. Anthropic's actual 422 errors use a different shape (e.g. messages.0.content.0.type: Unexpected value 'thinking'). The response also carries server: ccproxy and x-request-id: <uuid> (added by api/app.py:ServerHeaderMiddleware and api/middleware/request_id.py:RequestIDMiddleware), which confirms it was generated by ccproxy and not forwarded from Anthropic.

Root cause

File: ccproxy/llms/models/anthropic.py:221

RequestContentBlock = Annotated[
    TextBlock | ImageBlock | ToolUseBlock | ToolResultBlock, Field(discriminator="type")
]

ResponseContentBlock = Annotated[
    TextBlock | ToolUseBlock | ThinkingBlock | RedactedThinkingBlock,
    Field(discriminator="type"),
]

RequestContentBlock is the 4-type subset; ResponseContentBlock is the 6-type superset including ThinkingBlock | RedactedThinkingBlock. ThinkingBlock is fully defined at line 183 and RedactedThinkingBlock at line 212 of the same file. They're just not in the request union.

The route handler at ccproxy/plugins/claude_api/routes.py:65-67 annotates the body as anthropic_models.CreateMessageRequest, which causes FastAPI to run Pydantic validation against this union before the function body executes. In v0.1.7 (ccproxy/api/routes/proxy.py), the route used await request.body() + raw passthrough and never validated body shape against this union — so thinking blocks passed through.

Affected versions

Reproducible in 0.2.6 and 0.2.7. ccproxy/llms/models/anthropic.py is unchanged across 0.2.0 → 0.2.7. Every 0.2.x release has this bug. 0.2.8 / 0.2.9 / 0.2.10 do not touch the request schema (verified via git log v0.2.7..v0.2.10 — 7 commits cover Codex/Responses API and beta-header normalization; zero mention of thinking, RequestContentBlock, or discriminator unions).

Reproduction

# Requires running ccproxy on :8000 with the claude_api plugin enabled.
# The /claude prefix is what llm-inspector rewrites to when no --upstream override is set;
# direct curl needs the full path.

curl -sS http://127.0.0.1:8000/claude/v1/messages \
  -X POST \
  -H 'content-type: application/json' \
  -H 'anthropic-version: 2023-06-01' \
  -H 'anthropic-beta: interleaved-thinking-2025-05-14,redact-thinking-2026-02-12' \
  -d '{
    "model": "claude-haiku-4-5",
    "max_tokens": 100,
    "messages": [
      {"role": "user", "content": "What is 2+2?"},
      {"role": "assistant", "content": [
        {"type": "thinking", "thinking": "Simple arithmetic.", "signature": "fake"},
        {"type": "text", "text": "Let me think."}
      ]},
      {"role": "user", "content": "Just the number."}
    ]
  }'

Result: HTTP 422 with the error above. The signature "fake" is intentionally invalid — the point is that the request should reach Anthropic (which would then reject it with a different, signature-related 400). The ccproxy-side 422 means the request never leaves ccproxy.

After applying the suggested fix below, the same curl returns HTTP 400 with an Anthropic-format error (messages.1.content.0: Invalid \signature` in `thinking` block`), proving the request is now flowing through ccproxy into Anthropic.

Suggested fix

Add ThinkingBlock and RedactedThinkingBlock to RequestContentBlock. This mirrors what ResponseContentBlock already accepts.

File: ccproxy/llms/models/anthropic.py:221

RequestContentBlock = Annotated[
    TextBlock
    | ImageBlock
    | ToolUseBlock
    | ToolResultBlock
    | ThinkingBlock
    | RedactedThinkingBlock,
    Field(discriminator="type"),
]

Both block types are already defined in the same file (lines 183 and 212) — no new model classes are needed. Anthropic's public request schema documents both thinking and redacted_thinking as valid messages[*].content[*].type values.

Environment

  • ccproxy-api: 0.2.7 (also hit on 0.2.6)
  • Python: 3.11
  • OS: macOS 24.5.0 (Apple Silicon)
  • Upstream: https://api.anthropic.com via OAuth (Claude Code Max plan)
  • Client: Claude Code 2.1.185 (and 2.1.193 sdk-cli), Anthropic-format requests through llm-inspector :9000 → ccproxy :8000
  • Command: ccproxy serve --port 8000 (default plugins)

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