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 (TextContentBlock → TextBlock, 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)
Bug Description
Starting with v0.2.0, the
RequestContentBlockPydantic discriminated-union inccproxy/llms/models/anthropic.pyno longer includesThinkingBlockorRedactedThinkingBlock. Every Anthropic/claude/v1/messagesrequest whose body contains one or moretype: "thinking"(ortype: "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)MessageContentBlockexplicitly listedThinkingContentBlockin its union.The 0.2.x refactor renamed the block types (
TextContentBlock→TextBlock, etc.) and moved them to a new module — butThinkingBlockwas dropped fromRequestContentBlockwhile still being accepted inResponseContentBlock. This is internally inconsistent: ccproxy knows about thinking blocks in the response shape but rejects them in the request shape. Withextra="forbid"onMessageandCreateMessageRequest, anytype: "thinking"in the inbound body is a hard 422.The Anthropic API itself accepts
thinkingblocks 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 matchare 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 carriesserver: ccproxyandx-request-id: <uuid>(added byapi/app.py:ServerHeaderMiddlewareandapi/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:221RequestContentBlockis the 4-type subset;ResponseContentBlockis the 6-type superset includingThinkingBlock | RedactedThinkingBlock.ThinkingBlockis fully defined at line 183 andRedactedThinkingBlockat 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-67annotates the body asanthropic_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 usedawait 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.pyis 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 viagit 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
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
ThinkingBlockandRedactedThinkingBlocktoRequestContentBlock. This mirrors whatResponseContentBlockalready accepts.File:
ccproxy/llms/models/anthropic.py:221Both 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
thinkingandredacted_thinkingas validmessages[*].content[*].typevalues.Environment
https://api.anthropic.comvia OAuth (Claude Code Max plan)ccproxy serve --port 8000(default plugins)