fix(agent-runtime): call Anthropic's native Messages API so prompt caching works - #889
Merged
Merged
Conversation
…ching works Orgs on the Anthropic provider preset were hitting https://api.anthropic.com/v1/chat/completions — the OpenAI SDK compatibility layer, which does not support prompt caching and silently ignores unsupported fields. Every cache_control marker the runtime emitted was dropped, so the hit rate was zero and each request re-processed the whole system prompt and tool catalog at full price. The compat layer also returns an empty usage.prompt_tokens_details, so nothing surfaced that this was happening. anthropicNativeProvider posts to /messages with x-api-key + anthropic-version, and defaultProvider routes api.anthropic.com to it while every other backend keeps the OpenAI-compatible provider. Three breakpoints per request: tool catalog and system blocks at a 1h TTL since both are shared org-wide, and the newest turn at the default 5m so each tool-loop iteration reads the previous one's prefix. The per-conversation context moves out of the cached prefix. conv used to append the conversationId to the end of the system prompt, which made the whole prefix vary per conversation. It now travels as AgentConfig.volatileSystemPrompt, emitted as a trailing block flagged ChatMessage.volatile, and the breakpoint lands after the last non-volatile block. This helps the OpenRouter path too, whose breakpoint was carrying the conversation id along with it. Assistant turns round-trip their raw content blocks through providerContentBlocks so thinking blocks keep their signatures, and breakpoints only ever attach to text and tool_result blocks. Verified against the live API on claude-haiku-4-5: first turn wrote 7817 cached tokens, the tool-result follow-up read all 7817 and wrote 105, and a second conversation still read 7467 of the shared prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Anthropic emailed us that Threll.ai's prompt cache hit rate is low, estimating up to 59% of direct API spend recoverable. It was zero, and the cause was the transport.
The bug
Orgs on the Anthropic provider preset were hitting
https://api.anthropic.com/v1/chat/completions— Anthropic's OpenAI SDK compatibility layer, which does not support prompt caching and silently ignores unsupported fields. The runtime has emittedcache_controlmarkers since the caching work landed, andisAnthropicCompatibleBackend()explicitly turned them on forapi.anthropic.com— every one of them was dropped on the floor.It was invisible from our side too: the compat layer returns
usage.prompt_tokens_detailsas always empty, andruntime.tsonly accumulatedprompt_tokens/completion_tokens, so we had no cache counters anywhere. OpenRouter with ananthropic/*model was the only path where caching actually worked, which is why this went unnoticed — that's the default preset.The fix
anthropicNativeProviderposts to/messageswithx-api-key+anthropic-version, translating the runtime's OpenAI-shapedChatMessageboth ways: system messages hoisted intosystemblocks,tool_calls→tool_use,role:'tool'→tool_result(parallel results merged into one user message so the model isn't trained out of parallel calls),stop_reason→ the runtime's finish reasons.defaultProviderroutesapi.anthropic.comthere and leaves every other backend — OpenRouter, OpenAI, self-hosted — on the compat provider. Dispatch is per-request on base URL, so the metering wrapper inrunner.service.tsis unchanged.Three cache breakpoints per request (max is 4):
The per-conversation context moves out of the cached prefix.
convappendedYou are replying in conversationId: <id>to the end of the system prompt, which made the entire prefix vary per conversation and never share an entry. It now travels asAgentConfig.volatileSystemPrompt, is emitted as a trailing block flaggedChatMessage.volatile(as is the history-truncation note), and the breakpoint lands after the last non-volatile block. This helps the OpenRouter path too, whose breakpoint sits on the first system block and was carrying the conversation id with it.Two things surfaced while building that weren't in the original diagnosis:
max_tokensis required on the native API and nothing in the conversation path sets it — defaults to 8192.thinkingblocks from an assistant turn makes the follow-up carrying tool results fail. Assistant turns now round-trip their raw blocks viaChatMessage.providerContentBlocksand are echoed byte-for-byte; breakpoints only ever attach totext/tool_resultblocks, never to a block we echo verbatim.Also drops
api.anthropic.comfrom the compat provider's cache auto-detection — the source of the false confidence — and moves the shared rate-limit retry andProviderErrorclassification intoproviders/transport.tsso both providers use one implementation.Verified against the live API
claude-haiku-4-5, real key:Turn 1 writes the prefix (previously always 0). Turn 2 reads all 7817 and writes only the 105-token incremental turn — the tool-loop win. Turn 3, on a different conversationId, still reads 7467 of the shared prefix, which is the volatile-block split working; before it, that read would have been 0. The tool round-trip is confirmed end-to-end (Haiku called
kb_search, thetool_resultwas accepted, the reply used it correctly).Four shapes the native API validates strictly and the compat layer tolerated — history opening with an assistant turn, assistant-only history, empty message bodies, no system blocks — all accepted.
Automated: 514 agent-runtime + 73 agent-host tests, typecheck and lint clean. 27 new tests cover request shape, headers, breakpoint placement and TTLs, the stable/volatile boundary, the thinking echo, parallel tool-result merging, assistant-first history, stop-reason and usage mapping, and 401 →
provider_auth.Not verified
The thinking-block echo never triggered live. Haiku 4.5 doesn't think by default; Sonnet 5 declined on a trivial task; Opus 5 returned no thinking blocks even on a reasoning-heavy prompt. With our request shape — no
thinkingparameter, sodisplaydefaults to omitted — the API returns none at all. SoproviderContentBlocksis verified to round-trip what the API actually returns (text+tool_use, byte-for-byte), but the thinking case is defensive rather than exercised. It only becomes load-bearing if we opt intothinking: {display: 'summarized'}, which nothing inAgentConfigcan currently request.Follow-ups (not in this PR)
ProviderUsagenow carriescache_read_input_tokens/cache_creation_input_tokens, but nothing reads them. Threading them intoAgentReplyand per-org usage is what would let us confirm the hit rate from the dashboard instead of the Console.response_format;audit.tsalready skipped it andparseVerdicthandles prose-wrapped JSON). Structured outputs would be a genuine upgrade there.🤖 Generated with Claude Code