[Bug] 400 "text content blocks must be non-empty": empty text block from a custom ANTHROPIC_BASE_URL is persisted and replayed forever — deterministic repro

Status Open
Reported on v2.1.238
Maintainer reply None cached
Activity 0 comments · opened Aug 21, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Claude Code writes whatever content blocks an endpoint streams straight into the session transcript, including a text block with an empty string. On the next turn that block is replayed to api.anthropic.com as part of messages, the API rejects it, and because the offending row is on disk the session can never make another request:

API Error: 400 messages: text content blocks must be non-empty

There is plenty of prior art for this error (#60313, #62370, #59294, #60837, #56443, #49171 and a dozen more), all closed as duplicate/not-planned/stale, and every one of them says some version of "I cannot reproduce this on demand". The author of #60313 — the issue the rest were auto-closed against — wrote exactly that, and it's now closed and locked with the bug still live.

So here is a reproducer that fires every single time.

Any Anthropic-compatible endpoint behind ANTHROPIC_BASE_URL that opens content block index 0 as {"type": "text", "text": ""} before a tool call will poison the transcript on the first tool-calling turn. LiteLLM's /v1/messages adapter does this unconditionally — it emits the leading text block before it has consumed a single upstream chunk, so a turn that goes straight to a tool call gets a text block with no deltas (BerriAI/litellm#34692, open, two unmerged PRs). LiteLLM fronts a large share of the self-hosted and third-party setups people point Claude Code at, so this is not an exotic path.

The part that makes it nasty is that nothing complains at the time. While you stay on the proxy the malformed rows sit in the transcript unnoticed — the proxy does not validate its own output. The 400 only fires later, when the session is continued against the real Anthropic API, and it then fires on every subsequent turn forever. That gap is, I suspect, why the existing reports read as "it just started happening for no reason".

What went wrong here, from my transcript:

| row | model | content |
|---|---|---|
| 195 | claude-opus-5 | normal turn |
| 204 | minimaxai/minimax-m3 (via proxy) | {"type": "text", "text": ""} |
| 205 | minimaxai/minimax-m3 | tool_use(Read) |
| 209 | minimaxai/minimax-m3 (via proxy) | {"type": "text", "text": ""} |
| 210 | minimaxai/minimax-m3 | tool_use(Edit) |
| 220 | — | user message, now back on api.anthropic.com |
| 224 | <synthetic> | API Error: 400 messages: text content blocks must be non-empty |

Rows 224, 230, 242 and 248 are the same 400, one per attempt, four minutes apart. Nothing in the UI edits, drops or forks past the bad rows, so the session is finished.

Worth separating the two halves. The empty block is LiteLLM's bug and it's reported there. The part I think belongs here is that Claude Code accepts a content block into its own transcript that the Anthropic API will refuse to accept back, and never checks that invariant on the way out. That's also what makes the image-only-paste reports in #50010 and #54314 the same bug wearing a different hat — different source of the empty block, identical dead end.

What Should Happen?

Skip zero-length and whitespace-only text blocks when building the messages array from the transcript. It's a client-side filter over a payload Claude Code fully controls, it needs no server change, and it retires this whole family of reports regardless of who wrote the empty block — proxy, image paste, or a future one nobody has hit yet.

Dropping them at write time as well would be nice, but the read-side filter is the one that also rescues the transcripts already on disk.

If a 400 does get through, some recovery path other than "abandon the session" would help — the existing reports are unanimous that losing hours of accumulated context is the actual damage, not the failed request.

Error Messages/Logs

The block as the proxy streams it, index 0, no deltas, ahead of the real content:

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": ""}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: content_block_start
data: {"type": "content_block_start", "index": 2, "content_block": {"type": "tool_use", "id": "chatcmpl-tool-b02ea0fe", "name": "get_weather", "input": {}}}
...
event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"input_tokens": 134, "output_tokens": 35}}

The same block after Claude Code has persisted it, from ~/.claude/projects/<project>/<session>.jsonl:

{"type":"assistant","uuid":"f5539cb6","message":{"model":"minimaxai/minimax-m3","role":"assistant",
 "stop_reason":"tool_use","content":[{"type":"text","text":""}]}}
{"type":"assistant","uuid":"daf9f882","parentUuid":"f5539cb6","message":{"model":"minimaxai/minimax-m3",
 "role":"assistant","content":[{"type":"tool_use","name":"Read","input":{...}}]}}

And what every turn returns afterwards:

{"type":"assistant","message":{"model":"<synthetic>","content":[{"type":"text",
 "text":"API Error: 400 messages: text content blocks must be non-empty"}]},
 "isApiErrorMessage":true,"apiErrorStatus":400,"requestId":"req_011CeFw89eqafqGjyt49iV3g"}

Steps to Reproduce

Needs a LiteLLM proxy and any OpenAI-compatible tool-calling model behind it. The upstream is irrelevant — I hit it on nvidia_nim, #34692 hit it on ollama_chat.

  1. config.yaml:

```yaml
model_list:

  • model_name: probe

litellm_params:
model: ollama_chat/qwen3:8b # or any tool-capable OpenAI-shaped model
api_base: http://localhost:11434
```

  1. litellm --config config.yaml (serves on :4000).
  1. Confirm the proxy emits the empty leading block — index 0 is {"type": "text", "text": ""} with no real deltas:

``bash
curl -sN http://127.0.0.1:4000/v1/messages \
-H 'content-type: application/json' -H 'x-api-key: sk-1234' \
-H 'anthropic-version: 2023-06-01' \
-d '{"model":"probe","max_tokens":128,"stream":true,
"messages":[{"role":"user","content":"What is the weather in Istanbul? Use the get_weather tool."}],
"tools":[{"name":"get_weather","description":"Get the current weather for a city",
"input_schema":{"type":"object","properties":{"city":{"type":"string"}},
"required":["city"]}}]}'
``

  1. Point Claude Code at it and start a session that makes at least one tool call:

``bash
ANTHROPIC_BASE_URL=http://127.0.0.1:4000 ANTHROPIC_AUTH_TOKEN=sk-1234 \
ANTHROPIC_DEFAULT_OPUS_MODEL=probe claude
``

Ask for something that reads a file. Any tool call will do.

  1. Confirm the poison landed:

``bash
grep -c '{"type":"text","text":""}' ~/.claude/projects/*/<session-id>.jsonl
``

  1. Restart Claude Code with ANTHROPIC_BASE_URL unset so it talks to api.anthropic.com, resume that session (claude --resume, or reopen it in the VS Code extension) and send anything.
  1. 400 on that turn and on every turn after it. Restarting the client does not clear it; the bad rows are on disk.

The transcript I hit this on went the other direction — Anthropic first, proxy in the middle, Anthropic again — but the order doesn't matter. All that matters is that an empty text block is in the history when a request goes to the Anthropic API.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.238 (VS Code extension; CLI on the same machine is 2.1.220, both affected)

Platform

Anthropic API

Operating System

Linux

Terminal/Shell

Other

Additional Information

  • Ubuntu 22.04.5, Node v24.15.0, LiteLLM v1.93.0
  • Upstream side of this is BerriAI/litellm#34692 — open since July, PRs #34694 and #34718 unmerged. Even once that lands, every transcript already carrying an empty block stays bricked, and nothing stops the next proxy from doing the same thing.
  • Non-streaming responses through the same proxy are clean, no empty block at all. Claude Code streams, so it always takes the broken path.
  • Related and, I'd argue, the same root problem from a different source: #50010 and #54314 (image-only paste), #59294 (resumed sessions), #55369 and #57668 (cache_control on empty text blocks).

View original on GitHub ↗