Prompt Caching für Anthropic-Modelle über OpenRouter - #70
Merged
Conversation
Prompt caching has been Anthropic-only since it landed, and the README said so plainly: OpenRouter accepts the same syntax, but that was left for later. Later is a long time to pay full price for the same system prompt every turn, and OpenRouter is how a good share of people reach Claude at all — one key, one bill, no separate contract. They were the ones paying it. So a model id starting with `anthropic/` now gets the markers. That prefix is the whole capability test: no metadata call, no model table, nothing to keep in sync. Anything else is sent the payload it has always been sent, byte for byte, whatever the cache flag says — the marker also forces `content` from a string into an array, and there is no reason to reshape a request nobody will read that way. Two breakpoints, not the three the direct route uses. The tool definitions get none, because OpenRouter drops a marker there without saying so — measured at roughly four thousand tokens of tool definitions, on the tool object and nested in `function` alike: cache_write_tokens zero, HTTP 200, no complaint. They are cached regardless, since Anthropic builds its prefix in the order tools → system → messages and the system marker reaches back over them. The rolling transcript breakpoint is set on user turns only; when it would land on a tool result it is skipped, because the array form of a `role: "tool"` message has not been tried on this route and being wrong there does not fail one request, it fails every request of the session for as long as that message stays in the transcript. What is given up is small — a marker on a short block is discarded anyway, and a tool result often is one. The usage numbers needed more care than the payload. OpenRouter reports the counters under OpenAI's names, one level down in prompt_tokens_details, and counts the cached tokens *inside* prompt_tokens — where smith's prompt_tokens is the uncached remainder and the cost model adds the cache counters on top of it. Passing the number through as it arrives would have billed the cached prefix twice and quietly turned a saving into a fictional overspend. It is subtracted back out, clamped at zero, on the streaming path as much as the other one: streaming is the default, and usage that is only right when you turn the default off is not right. What is shared between the two providers is the policy — which message to mark, and what a marker looks like. Not the placement: Anthropic speaks the Messages schema and OpenRouter the OpenAI one, and the same breakpoint lands in a differently shaped object in each. A helper that knew both would serve neither. Closes #61. Co-Authored-By: Claude Opus 5 <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.
Setzt #61 um — nach der Spezifikation aus dem Triage-Kommentar, der den Issue-Text an zwei Stellen korrigiert.
Was sich ändert
Ein OpenRouter-Request an ein Modell mit Präfix
anthropic/trägt jetzt Cache-Breakpoints. Das Präfix ist der ganze Test — kein Metadaten-Call, keine Modelltabelle. Jedes andere Modell bekommt byte-identisch die Payload von vorher, unabhängig vom Cache-Flag.Zwei Breakpoints statt drei. Die Tool-Definitionen bekommen keinen — der Triage-Kommentar hat an echten Requests gemessen, dass OpenRouter ihn dort kommentarlos verwirft (
cache_write_tokens: 0bei ~4000 Token Tool-Definitionen, sowohl am Tool-Objekt als auch verschachtelt infunction). Gecacht werden sie trotzdem: Anthropic baut das Präfix in der Reihenfolge tools → system → messages, der System-Marker deckt sie mit ab.Der rollierende Transkript-Breakpoint wird nur auf User-Turns gesetzt. Landet er auf einem Tool-Result, entfällt er. Das ist die eine bewusste Abweichung vom Triage-Text und der Grund steht im Code: die Array-Form einer
role: "tool"-Nachricht ist auf dieser Route ungetestet, und falsch geraten scheitert nicht ein Request, sondern jeder Request der Session, solange die Nachricht im Transkript steht. Der Verlust ist gering — der Triage-Kommentar hat selbst gemessen, dass ein Marker auf einem kurzen Block verworfen wird, und ein Tool-Result ist oft einer.Usage: die Doppelabrechnung
Die eigentliche Falle. OpenRouter meldet die Zähler unter OpenAI-Namen (
prompt_tokens_details.cache_write_tokens/.cached_tokens) und rechnet die gecachten Token inprompt_tokenshinein — smithsprompt_tokensist dagegen der ungecachte Rest, und das Kostenmodell addiert die Cache-Zähler obendrauf. Durchreichen hätte das Präfix zweimal berechnet und aus einer Ersparnis eine erfundene Mehrausgabe gemacht. Wird jetzt herausgerechnet, auf 0 geklemmt, und auf dem Streaming-Pfad genauso wie auf dem anderen: Streaming ist der Default, und Usage, die nur ohne Default stimmt, stimmt nicht.Der Flag am
OpenAIStream.readist bewusst opt-in — OpenAI meldet unter demselben Schlüssel ein eigenescached_tokenszu einem anderen Preis, das als Anthropic-Cache-Read zu lesen wäre falsch.Geteilt und nicht geteilt
src/smith/llm/anthropic_caching.cr(neu) hält die Politik: welche Nachricht markiert wird, und wie ein Marker aussieht. Nicht die Platzierung — Anthropic spricht das Messages-Schema, OpenRouter das OpenAI-Schema, derselbe Breakpoint landet in einem anders geformten Objekt. Ein Helper, der beide kennt, würde keinem von beiden dienen. Die bestehenden Anthropic-Caching-Tests sind unverändert und grün, das ist der Regressionsschutz für das Herausziehen.[providers.openrouter] cachefunktioniert ohne Schema-Änderung —Config#cache_forliest provider-generisch. Defaulttrue, wie bei Anthropic.Verifikation
crystal spec: 878 Beispiele, 0 Fehlercrystal tool format --check: sauberspec/smith/llm/prompt_caching_spec.cr, u. a. die Nicht-Doppelzählung (prompt_tokens: 4199+cached_tokens: 4187→prompt_tokens: 12), Byte-Identität auf Nicht-Anthropic-Routen und beicache = false, und beide Streaming-LesartenGegen die Live-API gegengeprüft
smith run --model="anthropic/claude-haiku-4.5", echter Request über OpenRouter, mit dem vollen System-Prompt dieses Repos (Skills + MCP-Tools, ~7800 Token):prompt_tokenscache_read_tokensanthropic/claude-haiku-4.5anthropic/claude-haiku-4.5--no-streamopenai/gpt-4.1-nanoDie Marker kommen also an, OpenRouter meldet die Details zurück, und die Rückrechnung stimmt: 332 + 7469 = 7801 — dieselbe Prompt-Größe, die vor der Änderung als glatte 7800 in einer einzigen Zahl stand. Beide Pfade liefern identische Zahlen, die Nicht-Anthropic-Route bleibt unangetastet. Der abgerechnete Anteil fällt von 7800 vollen Input-Token auf 332 volle plus 7469 zu 0,1x, also grob Faktor 7 auf dem Prefix.
Ein
cache_creation_tokens > 0steht nicht in der Tabelle, weil der Prefix beim ersten Lauf schon geschrieben war — der Zähler wird von derselben Codestelle gelesen und ist unit-getestet.Closes #61.
🤖 Generated with Claude Code