Skip to content

Prompt Caching für Anthropic-Modelle über OpenRouter - #70

Merged
webmatze merged 1 commit into
mainfrom
feat/openrouter-prompt-caching
Aug 26, 2026
Merged

Prompt Caching für Anthropic-Modelle über OpenRouter#70
webmatze merged 1 commit into
mainfrom
feat/openrouter-prompt-caching

Conversation

@webmatze

@webmatze webmatze commented Aug 26, 2026

Copy link
Copy Markdown
Owner

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: 0 bei ~4000 Token Tool-Definitionen, sowohl am Tool-Objekt als auch verschachtelt in function). 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 in prompt_tokens hinein — smiths prompt_tokens ist 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.read ist bewusst opt-in — OpenAI meldet unter demselben Schlüssel ein eigenes cached_tokens zu 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] cache funktioniert ohne Schema-Änderung — Config#cache_for liest provider-generisch. Default true, wie bei Anthropic.

Verifikation

  • crystal spec: 878 Beispiele, 0 Fehler
  • crystal tool format --check: sauber
  • 15 neue Beispiele in spec/smith/llm/prompt_caching_spec.cr, u. a. die Nicht-Doppelzählung (prompt_tokens: 4199 + cached_tokens: 4187prompt_tokens: 12), Byte-Identität auf Nicht-Anthropic-Routen und bei cache = false, und beide Streaming-Lesarten

Gegen 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):

Route Pfad prompt_tokens cache_read_tokens
anthropic/claude-haiku-4.5 Streaming (Default) 332 7469
anthropic/claude-haiku-4.5 --no-stream 332 7469
openai/gpt-4.1-nano Streaming 4088 0

Die 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 > 0 steht 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

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>
@webmatze
webmatze merged commit fd6e25f into main Aug 26, 2026
@webmatze
webmatze deleted the feat/openrouter-prompt-caching branch August 26, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prompt Caching für Anthropic-Modelle über OpenRouter aktivieren

1 participant