Place explicit prompt-cache breakpoints on the OpenAI-family legs - #60
Merged
Merged
Conversation
On the Responses legs the cache never covered the conversation body. The provider's default mode puts its breakpoint on the latest message, and this stack's latest message is a status row carrying a clock that changes every call, so the entry each request wrote ended inside bytes the next request no longer sent. Measured on gpt-5.6-terra over three growing eight-round requests: every step read about 1,642 tokens, the system text and the tool list and none of the body, and rewrote nine and then ten thousand tokens of byte-identical conversation at the 1.25x cache-write premium. The payload builder now asks for explicit mode and puts a breakpoint on every durable input item. Marking every item rather than the tail is what makes the rule work: a breakpoint is matched only while it is still present in the request being sent, so a single marker moved forward one row per request leaves nothing behind and reads zero — measured, and it cost more than sending nothing. Marking every item makes the marked set a function of each row's own position from the start of the list, so the row that was last on the previous request is marked by construction and the rule needs no memory. The same three requests then read 10,620 and 12,109 of a 13,717-token prompt, with a 1,489-token write per step. Two gates gate it. The model, because anything before the GPT-5.6 family answers either field with a 400. The provider, because the factory routes every unrecognized provider to this adapter and a third-party endpoint behind a 5.6-shaped name would otherwise receive fields only OpenAI's and Azure's surfaces have been measured to take; for that check the factory now stamps the requested provider on each adapter it builds, which is the only layer that knows it. prompt_cache_options is set by the branch that placed the marker and only then: explicit mode with no breakpoint is the documented way to turn caching off, and measures as exactly that. A caller's contribution stays one flag. CACHE_EXEMPT_KEY moves to base now that both families read it, re-exported from anthropic where it was defined. The Anthropic legs keep their tail rule, the OAuth twin is untouched — it speaks to a backend neither provider page covers — and payloads for every pre-5.6 model, both OAuth legs, both Anthropic legs and Bedrock are byte-identical to before, checked by rebuilding them from the previous commit and diffing. The one wire-shape change: a tool result's output stops being a string and becomes a one-part input_text list, because a breakpoint can only ride a content block. The text the model reads is unchanged.
rezaho
force-pushed
the
s349-openai-cache-markers
branch
from
September 11, 2026 14:02
21058a3 to
1c8a438
Compare
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.
What was wrong
On the Responses legs the prompt cache never covered the conversation body. The provider's default mode places its breakpoint on the latest message, and this stack's latest message is a per-request status row whose clock changes on every call, so the entry each request wrote ended inside bytes the next request no longer sent.
Measured on
gpt-5.6-terra, three requests of an agent turn's shape growing by one tool round each: every step read about 1,642 tokens — the system text and the tool list and none of the body — and rewrote nine and then ten thousand tokens of byte-identical conversation at the 1.25x cache-write premium. Sending the third request again byte for byte read 13,714 of 13,717, so the cache itself was reachable and within its lifetime the whole time.What this does
OpenAIAdapter.format_request_payloadnow asks for explicit cache mode and puts aprompt_cache_breakpointon every durable input item — system, developer and user messages, and function-call outputs.Marking every item rather than the tail is the whole rule, and the tail rule is the trap. A breakpoint is matched only while it is still present in the request being sent, so a single marker moved forward one row per request leaves nothing behind for the next request to match: measured at 0 tokens read on every request, costing more than sending nothing at all. Marking every item makes the marked set a function of each row's own position from the start of the list, so the row that happened to be last on the previous request is marked by construction and the rule needs no memory of what an earlier request sent. The same three requests then read 10,620 and 12,109 of a 13,717-token prompt with a 1,489-token write per step.
The provider's own limits are satisfied by construction rather than by arithmetic: at most four new cache writes per request (in explicit mode the latest four breakpoints, and a breakpoint covers everything before it, so the newest one writes the whole new prefix), and reads consider the latest fifty breakpoints (the newest marker is always within a handful of the end).
Two gates, both load-bearing
400. Agpt-<major>[.<minor>]name is parsed as a number pair and compared, not sorted as a string —gpt-5must not read as later thangpt-5.6.ProviderAdapterFactorynow stamps the requested provider onto each adapter it builds — it is the only layer that knows which provider was asked for, since several providers share one adapter class and the fallback hands unknown ones to this class outright.BaseAPIModelwas doing the same assignment one line later for the sync adapter; that duplicate is removed.prompt_cache_optionsis set by the branch that placed a marker, and only then. Explicit mode with no breakpoint is the documented way to turn caching off and measures as exactly that — nothing written, nothing read, the whole prompt at plain input price — so mode and placement are one decision in one place.What does not change
A caller's contribution to caching stays one flag.
CACHE_EXEMPT_KEYmoves tobasenow that both adapter families read it, re-exported fromanthropicwhere it was defined, so every existing import keeps working. A row carrying it is skipped wherever it sits, keeps the shape it arrived in, and its flag never reaches the wire.The Anthropic family keeps its tail rule unchanged. The OpenAI OAuth twin is untouched: it subclasses the base adapter rather than this one and speaks to a backend neither provider page documents.
Payloads for every pre-5.6 model, both OAuth legs, both Anthropic legs and Bedrock are byte-identical to the previous commit — checked by rebuilding the same fourteen payloads from that commit's code and diffing them key by key, not by inspection.
The one wire-shape change: a tool result's
outputstops being a string and becomes a one-partinput_textlist, because a breakpoint can only ride a content block. The text the model reads is unchanged, and a test pins that by flattening every item to its text and comparing against the pre-generation build.Tests
tests/models/test_prompt_cache_key.pybecomestest_openai_prompt_cache.py: the file now covers the caller's routing key and the markers, which are one feature from a caller's side, and its six-adapter fixture gained a model-name parameter so the existing key assertions run on both sides of the generation gate. 361 tests there, plus the Anthropic-family no-leak assertions intest_prompt_cache_breakpoint.pyand the generation gate replacing the old "no cache field is ever sent" test on the Azure leg.