DEV Community

Seven
Seven

Posted on AI-assisted

Anthropic Messages is a different protocol, not a flag — and shims leak

The failure has a shape. Your Claude-based agent connects to the new endpoint, answers the first question well, executes a tool, comes back, executes another — and then somewhere around step seven it stops calling tools and starts narrating that it would call a tool. Or it loses the thread of a long file edit. Or your prompt-cache hit rate, which you were counting on, is quietly zero and nothing in the response says so.

What usually happened is that the endpoint doesn't speak Anthropic's Messages protocol. It speaks OpenAI chat completions and translates. Disclosure: I work on daoxe, a gateway that implements /v1/messages alongside the OpenAI protocol — which is exactly why I've spent an unreasonable amount of time in the gap between the two. The tests at the end are written so you can point them at any endpoint, including mine.

To be fair to shims: if all you do is single-turn chat with text in and text out, a translation layer is genuinely fine, and it's how a lot of tooling gets broad model coverage cheaply. The argument below is about agents.

The two protocols disagree about what a message is

In OpenAI's chat completions, an assistant turn is content: string | null plus a separate tool_calls array. In Anthropic Messages, an assistant turn is an ordered array of typed content blocks: text, tool_use, thinking, image, and others, interleaved in the order the model produced them.

That ordering is real information. "Let me check the config file" → tool_use → "and also the lockfile" → tool_use is one assistant turn with four blocks. Flatten it into a string plus a call array and you've thrown away which text belonged before which call. On a single turn nobody notices. Fifteen turns into an agent loop, the transcript the model sees is subtly not the transcript it wrote, and behaviour drifts.

tool_use and tool_result are shaped differently on purpose

Three concrete mismatches:

  • input is a JSON object. Anthropic's tool_use block carries input: {...}. OpenAI carries function.arguments as a JSON-encoded string. A shim has to serialise and deserialise on every hop. Usually that round-trips fine; when it doesn't — key ordering, float formatting, non-ASCII escaping — the bytes change, and byte-identical prefixes are exactly what prompt caching depends on.
  • tool_result belongs to a user turn. In Anthropic's model, the tool output comes back as a tool_result content block inside a user message, referencing tool_use_id. OpenAI uses a dedicated role: "tool" message. Mapping between them is mechanical but lossy in one direction: Anthropic's tool_result has an is_error: true flag, and OpenAI's has nowhere to put it. Through a shim, a failed tool call arrives as an ordinary string that happens to start with "Error:", and the model has to infer from prose what the protocol was willing to tell it directly.
  • Multiple parallel tool_use blocks in one assistant turn, each needing its own tool_result block in the following user turn, in an order the API validates. Shims that queue calls one at a time serialise what should have been parallel.

Streaming is a typed event protocol

Anthropic's SSE stream isn't a sequence of interchangeable chunks. It's message_start, then per block content_block_start / content_block_delta / content_block_stop carrying an index, then message_delta (which is where stop_reason and the final output token count live), then message_stop, with ping and error events possible anywhere.

The delta types matter too: text_delta for prose, input_json_delta for tool arguments streaming in as partial JSON, thinking_delta and signature_delta for extended thinking. The Anthropic SDK's streaming helpers accumulate these into typed objects for you. A shim re-wrapping OpenAI chunks can usually produce plausible text_delta events; what it typically cannot produce faithfully is incremental input_json_delta, so tool arguments show up all at once at the end. If you render tool calls live in a UI, that's a visible regression. If you gate on partial arguments, it's a broken feature.

system is a parameter, and that's where caching lives

Anthropic takes the system prompt as a top-level system field, either a string or an array of content blocks. The array form is the important one, because that's where you attach cache_control: {"type": "ephemeral"} to mark a cacheable prefix.

There is no place in OpenAI's message list to express that. So a shim converts your system blocks into a {"role": "system"} message and the cache markers evaporate. Nothing errors. Your requests just cost what they cost and take as long as they take, and usage.cache_read_input_tokens — the field that would have told you — isn't in an OpenAI-shaped response at all. For a coding agent that resends a large system prompt and file context on every turn, this is the single most expensive silent difference in this article.

stop_reason has six values

Anthropic returns end_turn, max_tokens, stop_sequence, tool_use, pause_turn, or refusal. OpenAI returns stop, length, tool_calls, or content_filter.

Agent loops branch on stop_reason == "tool_use". Through a shim that maps everything into OpenAI's smaller vocabulary and back, you can end up with end_turn on a turn that actually contained a tool call, at which point the loop exits and the agent "decides" to stop working. pause_turn and refusal have no OpenAI equivalent whatsoever and get mapped to something approximate.

Thinking blocks are signed

With extended thinking enabled, the response contains thinking blocks with a signature. On subsequent turns of a tool-use loop you must pass those blocks back verbatim, signature included. A shim that flattens thinking into text, or drops it as "internal", breaks that contract — you'll get an error on the next turn, or thinking will be silently disabled, and the model you're paying reasoning tokens for will stop reasoning.

One thing my captures taught me: the signature field is itself part of what an implementation can quietly not produce. On my gateway's /v1/messages, the thinking block came back with its text but no signature key at all (free-group capture, 2026-09-16; signals not proof). The shape looks right; the contract that depends on the shape is not fully honoured. If you build anything that replays thinking blocks, check for the field's presence before you trust the round trip.

What it costs you

Protocol feature Through a shim How you notice
Ordered content blocks Flattened Drift in long agent transcripts
is_error on tool_result Lost Model retries a tool that can't succeed
input_json_delta Batched to the end Tool args appear all at once in the UI
cache_control Dropped Nothing — no error, no cache field, higher cost
stop_reason: tool_use Approximated Agent loop exits early
thinking + signature Stripped Next-turn error, or thinking silently off
max_tokens enforced Varies A gateway can pass shape tests and still not enforce this one

Five tests

Test 1 is the one-liner, with a caveat I earned by running it. Anthropic's API documents max_tokens as required, and a strict native endpoint returns a 400 when you omit it. But a 200 is not a shim confession: enforcement varies across implementations — my own gateway's /v1/messages path answered the request happily without max_tokens (free-tier capture from 2026-09-16, signals not proof). So read this one one-sidedly: a 400 with an Anthropic-shaped error body is a strong native signal; a 200 sends you to tests 2–5.

BASE=https://api.daoxe.com          # note: no /v1 suffix here
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/v1/messages" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{"model":"<exact-id>","messages":[{"role":"user","content":"hi"}]}'
# 400 + Anthropic error shape: strong native signal
# 200: inconclusive — enforcement varies; run the rest
Enter fullscreen mode Exit fullscreen mode

Test 2 — the error envelope. Anthropic's shape is {"type":"error","error":{"type":"invalid_request_error","message":"..."}}. An OpenAI-shaped {"error":{"code":...}} is a tell.

curl -s "$BASE/v1/messages" -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" -H "content-type: application/json" \
  -d '{"model":"<exact-id>","max_tokens":16,"messages":[],"nonsense_field":1}' | jq .
Enter fullscreen mode Exit fullscreen mode

The envelope is the better tell than any single field inside it. On my gateway the Messages path returned the right outer envelope but leaked its internals ("type":"new_api_error", not the Messages error vocabulary), while the chat-completions path returned the OpenAI-shaped code field — one request each, and you can see which door you came in.

Test 3 — tool_use fidelity. Check that stop_reason is tool_use and that the block's input is an object, not a string.

curl -s "$BASE/v1/messages" -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" -H "content-type: application/json" \
  -d '{
    "model":"<exact-id>","max_tokens":256,
    "tools":[{"name":"get_weather","description":"Current weather",
      "input_schema":{"type":"object","properties":{"city":{"type":"string"}},
      "required":["city"]}}],
    "tool_choice":{"type":"tool","name":"get_weather"},
    "messages":[{"role":"user","content":"Weather in Osaka?"}]
  }' | jq '{stop_reason, input_type: (.content[] | select(.type=="tool_use") | .input | type)}'
# want: {"stop_reason":"tool_use","input_type":"object"}
Enter fullscreen mode Exit fullscreen mode

The input_type half is the one that usually separates the two worlds — a string where an object belongs is a translation scar. The stop_reason half is the stricter criterion, and don't assume any particular endpoint passes it: on my gateway's /v1/messages, forced tool_choice produced a proper tool_use block with an object input, but stop_reason: "end_turn" (free-group capture, 2026-09-16; signals, not proof). That is exactly the failure described above, arriving through the right-shaped door.

Test 4 — streaming event names. Grep the raw stream for the typed events.

curl -sN "$BASE/v1/messages" -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" -H "content-type: application/json" \
  -d '{"model":"<exact-id>","max_tokens":64,"stream":true,
       "messages":[{"role":"user","content":"Count to five."}]}' \
  | grep -o '^event: .*' | sort -u
# want at least: message_start, content_block_start, content_block_delta,
#                content_block_stop, message_delta, message_stop
Enter fullscreen mode Exit fullscreen mode

Of the five, this one drew the cleanest line when I ran both paths of my gateway on the same day: the /v1/messages side emitted all six event names; the /v1/chat/completions side emitted zero event: lines — plain data: chunks, because that's what the OpenAI wire format is. (Free-group captures, 2026-09-16; signals not proof.) A shim can fake most of the field names one response at a time; re-typing a whole event protocol under every chunk is work almost nobody does.

Test 5 — prompt caching. Send a long system prompt as a block array with cache_control, twice, and read the usage fields. (Providers enforce a minimum cacheable prefix length, so make the system text genuinely long — a few thousand tokens.)

REQ='{"model":"<exact-id>","max_tokens":16,
 "system":[{"type":"text","text":"<several-thousand-tokens-of-stable-context>",
            "cache_control":{"type":"ephemeral"}}],
 "messages":[{"role":"user","content":"ok"}]}'
for i in 1 2; do
  curl -s "$BASE/v1/messages" -H "x-api-key: $ANTHROPIC_API_KEY" \
    -H "anthropic-version: 2023-06-01" -H "content-type: application/json" \
    -d "$REQ" | jq '.usage | {cache_creation_input_tokens, cache_read_input_tokens}'
done
# an OpenAI-shaped response has no cache fields at all — that absence is the tell
# if the fields are present, what you have learned is that the *schema* is Anthropic's.
# You have not learned that caching works. Keep reading.
Enter fullscreen mode Exit fullscreen mode

That last line is a correction I have to make to my own draft. The expected pattern is creation > 0 on the first call and read > 0 on the second. I ran this twice against my gateway's /v1/messages path (free-tier capture, 2026-09-16) and got creation=0, read=5472 on pass one, and creation=0, read=3355 on pass two. A non-zero read count before any creation count is not prompt caching — it looks like an upstream implicit cache being echoed into Anthropic-shaped field names. Which is a more interesting failure than the easy one: an endpoint can present the correct field names, and even plausible-looking numbers, and still not honour cache_control at all. The presence of a field tells you about the schema. It tells you nothing about the semantics, and it is exactly the kind of signal an agent has no way to sanity-check at runtime. So the honest version of this test is: absence of the fields proves a shim; presence proves nothing — verify actual caching against your bill or the provider's own docs.

Read the battery as signal, not as a quiz score. Positive tells are stronger than negative ones: a tool_use block whose input is an object, all six stream event names, an Anthropic error envelope — each one is hard to fake and easy to check. Absences and 200s are weaker than they look: an endpoint can emit the right field names, as mine's caching counters and missing thinking signature show. The tests that catch a shim are the ones that look for what's not there — no event: lines, no cache fields, a string where an object belongs — because those are the parts a translation layer has to actively rebuild to hide.

Claude Code against a custom endpoint

Claude Code reads the base URL and credential from the environment:

export ANTHROPIC_BASE_URL=https://api.daoxe.com
export ANTHROPIC_AUTH_TOKEN=sk-...     # sent as Authorization: Bearer
# or: export ANTHROPIC_API_KEY=sk-...  # sent as x-api-key
export ANTHROPIC_MODEL=<exact-id-from-the-catalogue>
claude
Enter fullscreen mode Exit fullscreen mode

Two notes. The base URL has no /v1 suffix — Claude Code appends /v1/messages itself, and a doubled /v1/v1/messages is the most common first-run mistake. And Claude Code uses a second small model for background work; the environment variable that names it has been renamed across releases, so check claude config in your version rather than copying a variable name from a blog post.

The honest version

A translation shim is a reasonable engineering decision for text chat, and if that's your workload you will never see any of this. The moment you're running a tool loop, streaming tool arguments to a UI, relying on cached prefixes, or using extended thinking, the protocol stops being an implementation detail — every one of those features lives in a field that has no home on the other side of the translation.

Run the five tests against whatever you're about to build on. The whole battery is a few minutes of curl, and every one of the degradations above is a runtime bug you'd otherwise discover in production, three months later, with no error message pointing at the protocol.

Top comments (0)