Skip to content

feat(models): prompt-cache observability + an unconditional cacheable prefix on every Anthropic request - #49

Merged
rezaho merged 13 commits into
mainfrom
s235-prompt-cache-observability-and-tail-breakpoint
Aug 2, 2026
Merged

feat(models): prompt-cache observability + an unconditional cacheable prefix on every Anthropic request#49
rezaho merged 13 commits into
mainfrom
s235-prompt-cache-observability-and-tail-breakpoint

Conversation

@rezaho

@rezaho rezaho commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Every Anthropic request now writes a cacheable prefix, and cache usage is visible on the harmonized response. Measured against the live API (Claude Opus 5 on Bedrock, and the OAuth leg): an ordinary multi-step agent turn reads 13.6K tokens from cache and costs 57% less; a conversation-fold request reads 99% of its prompt.

What changes

UsageInfo gains cache_read_input_tokens + cache_creation_input_tokens, plus a full_prompt_tokens sum. This matters beyond reporting: input_tokens is the uncached remainder only, so any consumer treating it as the whole prompt silently under-counts by up to ~10x once caching is active. Both Anthropic harmonizers now populate the fields; every other provider harmonizes to None and is unaffected.

A cache_control marker on the tail of every Anthropic request (mark_conversation_tail_for_cache), on both the api-key and OAuth legs. Adapter-owned and unconditional rather than a caller opt-in: only the payload builder knows the rendered block layout, and this matches the one pre-existing cache_control site. Idempotent, never more than one tail marker, and a request whose last content is a plain string has it promoted to a one-block list.

The api-key leg's system moves to array form so it can carry a marker. No marker is placed there yet — that is correct only once the caller's system prompt is byte-stable across turns.

The OAuth stream reader now merges usage across events instead of discarding message_start and assigning at message_delta. Latent in most cases (the delta repeats the input-side figures), but message_start is the only event carrying the cache-TTL breakdown, and a stream ending without a message_delta previously harmonized with no usage at all.

Notes for review

  • The one behavioral rule worth knowing: a cache entry is readable only by a request that CONTAINS it. A request that stops short of the entry's position reaches nothing, however byte-identical its prefix is as far as it goes. Verified with containment as the only variable: full list read=16350 write=29, same request minus 6 messages read=0 write=14544.
  • Bedrock inherits the api-key payload builder, so it gets both changes by inheritance.
  • This branch also carries 12 earlier unmerged commits from prior sessions (durable HITL, escalate-to-user, deferred tool loading, the Claude 5 payload shape, transport-error classification). It fast-forwards cleanly onto main.
  • Supersedes fix(models): an empty Anthropic end_turn is a silent turn, not an API error #48 — that PR's commit (c0b2b6a) is contained here.

Tests

tests/models/201 passed, 39 skipped, including a new test_prompt_cache_breakpoint.py (504 lines) covering marker placement, idempotency, the 4-marker ceiling, the string-to-block promotion, and cache-usage harmonization on both legs plus a no-cache provider.

Full suite: 1,449 passed, 53 skipped, 4 failed. All 4 failures reproduce identically at origin/main in a clean worktree (doc-generation drift, a storage key assertion, a topology constructor check, and a Windows file-lock test) — pre-existing, untouched by this branch.

rezaho added 13 commits June 24, 2026 15:46
…R-011)

Two additive, optional, keyword-only params on Orchestra.resume_session so a
consumer that did not execute() the run in this process can resume a snapshot
cleanly:

- canonical_topology: binds topology_graph internally via a new private
  _build_topology_graph (the analyze + legacy-shim + validate core extracted
  from execute(); the single source, so the resumed graph is equivalent to
  execute's). Bound before the RESUME_NO_TOPOLOGY guard + digest check.
- on_bus_rebuilt: invoked once after the resume bus rebuild AND after the
  topology/digest preconditions pass (never on a failed-precondition resume),
  letting the consumer re-attach custom EventBus subscribers the rebuild drops.

Both default to today's behavior; execute() refactored to call the helper
(behavior-preserving). 6 new tests; full pause/resume suite green.
…FW17/ADR-011)

A deterministic completing-resume integration test: a real on-disk paused
snapshot (gated-runtime + pause_session) resumed via
resume_session(canonical_topology=) with a stub Agent (overrides _run ->
return_final_response, no LLM) registered in the AgentRegistry. Verifies the
resume binds the topology, drives the real agent through RealRuntime to a
terminal OrchestraResult, and that a subscriber attached inside on_bus_rebuilt
receives a BranchCompletedEvent from the resumed dispatch — closing the AC-1
terminal + AC-4 real-event coverage the empty-state tests left open.
…tes FW17)

resume_session rebuilds self.event_bus and _wire_event_bus re-creates the
listener set (TraceCollector / StatusManager / AGGUITranslator) on it, but the
REUSED step_executor (which emits LLMCallEvent — the event per-run cost is
computed from) and _user_node_handler still held the prior bus. So a consumer
re-attached via resume_session(on_bus_rebuilt=...) — FW17's motivating per-run
cost adapter — received no events from the resumed dispatch, and post-resume LLM
spend went unbilled. FW17's own resume test missed it (stub agent makes no LLM
call; on_bus_rebuilt was only exercised against orchestrator-level events). A
real-model pause->resume surfaced it.

Fix: _wire_event_bus also re-points step_executor.event_bus and
_user_node_handler.event_bus to self.event_bus, guarded (they are created after
this call in __init__, so the guards no-op there and bind the fresh bus at
construction; on resume they exist and get re-pointed).

Framework pause/resume suite (34) + full suite (1295 passed, same 3 pre-existing
failures) green. ADR-011 completion note + CHANGELOG updated.
…DR-012)

Make the ask_user/UserNode wait durable: a workflow reaching a durable user
interaction snapshots to disk and Orchestra.execute() returns paused-awaiting-user
(metadata[paused]+[awaiting_user]; WorkflowResult.error="awaiting_user", a second
pause sentinel) WITHOUT an in-memory wait or the 300s timeout. After an arbitrary
wait and/or a full restart, resume_session(session_id, user_response=...) restores
the suspended branch, injects the response via the existing
resume_branch_with_user_response seam, and continues to terminal. SYNC ask_user is
unchanged (durable=False default; the durable path bypasses _drive/handler entirely).

- pending_user_interaction scalar, held apart from the FIFO sibling deque (which
  would mis-dispatch it); a 5-tuple live / Optional[UserInteractionState] on-disk so
  durable siblings serialize FIFO durably rather than silently downgrading to SYNC.
- _snapshot_and_write extracted from pause_session; execute()/resume_session
  self-write on the awaiting-user exit (inside the try, before the finally pop).
- resume_session gains user_response (additive, keyword-only, rebased onto FW17's
  canonical_topology/on_bus_rebuilt) + AC-5 arg-validation.
- durable flag on enqueue_user_interaction (impl + DetNodeContext protocol) and
  UserNode, declarable in a workflow spec via a USER node's metadata[durable] (legacy
  shim read, forward-compatible to the v0.4 generic det-node path).
- Fix a latent SYNC-seam gap: resume_branch_with_user_response now de-candidates the
  terminated suspended branch (via _unregister), so its barrier can fire. Surfaced by
  the first orchestrator-level UserNode->resume->terminal test (Session 03 had none).

ADR-012 (approved) + CHANGELOG + 19 deterministic tests + 1 gated live OAuth test.
Framework suite: 1459 passed, 52 skipped, same 4 pre-existing failures + 2 readline
collection errors (no new failures).
…I-strict-schema fix

Joins the two framework lines that forked off 44f42bf: the OpenAI strict
structured-output fix (5c8681c) and the FW17 resume-ergonomics + FW16 durable
HITL suspend/resume line (ffd19e0). Disjoint file sets (models/adapters vs
coordination), conflict-free.
…DR-013)

A granted agent emits escalate_to_user(prompt) to durably suspend a run for
human input WITHOUT a topology User node — the dynamic counterpart to ask_user.
Scoped via a per-agent can_escalate grant (default off) gating BOTH the schema
offer and validation; a new ESCALATE_USER step-kind routes through the
orchestrator's _interpret directly into framework 16's durable seam
(enqueue_user_interaction(..., durable=True)) with resume_agent = the emitting
agent, so resume_session(user_response=...) re-runs that agent. The FW16 durable
suspend/resume machinery is reused unchanged; the deferred general
control-directive primitive (pause/redirect/fail) is left unbuilt.

26 deterministic tests + 2 gated live-OAuth tests (a real model emits the
directive and resumes to terminal). Full framework suite 1491 passed / 54
skipped; the 3 failures + 2 readline collection errors are pre-existing
(Windows/worktree), verified on the baseline.
…ng-user prompt (S62)

Completes FW18/ADR-013's wire-mirror and result-report so a consumer (Spren S62
browser re-auth) can grant + surface escalation through a serialized workflow:

- AgentSpec.can_escalate round-trips AgentSpec<->Agent, mirroring bidirectional_peers
  across the field, agent_to_pydantic (live->spec), and both pydantic_to_agents ctor
  paths. A workflow agent hydrated from a spec can now carry the grant. Additive,
  backward-compatible (optional, default False; absent key tolerated under extra=forbid).
- Orchestra.execute and resume_session now carry the pending interaction's prompt on
  the awaiting-user result metadata (awaiting_user_prompt, both exits), so a consumer
  renders the re-auth reason without parsing the snapshot.

Round-trip tests added (tests/agents/test_serialize.py); ADR-013 wire-mirror addendum
+ CHANGELOG entry. Framework stays Spren-agnostic (SP-018).
…r tool-search / defer_loading)

A per-tool `defer_loading: true` flag (top-level on the tool dict) marks a tool for on-demand
discovery: the provider's tool-search built-in finds it when needed and the definition rides the
message tail, so the deferred tool's schema stays out of the billed/cached request prefix and the
prompt cache survives a mid-conversation load. The flag rides the existing `tools` array — no
arun/run signature change (a separate kwarg would be warn-dropped by OpenAI's allowlist).

Per-provider translation (DP-006):
- Anthropic (api-key + OAuth): maps defer_loading onto the Anthropic tool + auto-adds the
  tool_search_tool_regex_20251119 server tool.
- OpenAI Responses (api-key + OAuth/Codex): maps it onto the flat function tool + auto-adds
  the {"type":"tool_search"} built-in.
- OpenRouter: strips the flag before its verbatim forward (+ warns) so it can't 400 the wire.
- Google: warns + eager fallback (its rebuild already drops the flag).
- local.py: untouched (separate base, tools unsupported).

Additive / zero-regression: with nothing deferred every adapter's payload is byte-identical to
before (no defer_loading key, no search tool, no header change — the Tool Search Tool is GA, no
anthropic-beta header needed). Response side unchanged: the discovered tool's tool_use already
surfaces through every harmonizer and the API re-expands tool_reference from the tools= defs, so
the search->load->use round-trip is coherent without carrying the server-search blocks (verified
live on OAuth). Tests: tests/models/test_deferred_tool_loading.py (per-adapter native shape, the
nothing-deferred identity guarantee, openrouter-strip/google-warn leak-prevention, and the
cache-prefix-stable-across-a-discovery-round-trip proof).
… error

A model that runs to natural completion and produces no content blocks is
returning a success: "I have nothing to say." Both Anthropic adapters raised
a typed ModelAPIError on it instead, and the classifier pinned that error
non-retryable — so every silent turn died terminally.

This is a behaviour callers explicitly ask for. An agent instructed to stay
quiet when it has nothing to report (a heartbeat that found no work, an inbox
check that found no mail) obeys, ends the turn with zero content blocks and
stop_reason 'end_turn', and the provider bills it as a successful response.
Confirmed on the wire: message_start (content []) -> message_delta
(stop_reason end_turn, output_tokens 2) -> message_stop, no content_block_start.

The raise was a symptom. The root is that HarmonizedResponse could not
represent the outcome: its validator rejects content=None with no tool_calls,
the adapters normalize empty text to None, and the empty-output branch had only
two arms (deterministic truncation -> placeholder; everything else -> raise).
The typed error was added to replace an UNKNOWN ValidationError, which improved
the error's quality while cementing a success as an error class.

The validator already blesses the shape this needs: it checks `content is None`
specifically, because an empty string is a valid response from some providers.
The API-key adapter already uses that escape for thinking-only responses. So the
empty-output branch gains its correct third arm — end_turn harmonizes to
content="" — beside the two that exist, rather than a parallel path.

Refusal and a stream that closed with no terminal at all keep raising: those are
genuine anomalies, and that distinction is the value the typed-error work added.

The test asserting empty end_turn raises is INVERTED, not removed: it encoded
the wrong contract. Its replacement pins the silent turn, and a twin covers the
API-key adapter so one provider keeps one behaviour. The now-unreachable
end_turn classification arm is deleted.
A connect/DNS/timeout/reset error raised by the HTTP client (httpx,
httpcore, aiohttp, or stdlib) carries no HTTP status and no provider
error body, so it fell through every branch of
ModelAPIError.from_provider_response and kept the UNKNOWN,
non-retryable default. The dead 'mark timeout/network as retryable'
block never fired because nothing ever assigned NETWORK_ERROR.

Consequence: on a network-flaky host a transient blip (e.g. a DNS
hiccup, '[Errno 11001] getaddrinfo failed') was classified terminal,
so the turn was permanently dropped instead of retried by the existing
backoff ladder. Rate limits and 5xx retried; a dropped packet did not
— inverted.

Classify statusless transport exceptions by walking the raised
exception's MRO by class name (one table across all HTTP libs, no new
imports — httpx is not a declared dep). Timeouts -> TIMEOUT, connect/
network/reset -> NETWORK_ERROR, both flowing through the pre-existing
retryable block. Fires only when status_code is None and we are still
UNKNOWN, so it never overrides a real provider verdict. Our-side/config
transport faults (LocalProtocolError, UnsupportedProtocol) are
deliberately excluded and stay non-retryable.

Inverts test_true_connection_error_no_status (it asserted the buggy
'stays unknown'); message text is still preserved verbatim.
Claude Opus 5 and Sonnet 5 reject `temperature` and a fixed
`thinking.budget_tokens`. Both are hard 400s, not ignored fields, so the
previous payload made these models unusable on their first turn. Opus 4.7/4.8
reject `temperature` too, and the OAuth adapter set it unconditionally — that
leg was already failing for them before the Claude 5 line existed.

Capability now keys off the model, not the spelling: one predicate pair behind
a normalizer that collapses the `anthropic/`, `anthropic.` and `us.anthropic.`
prefixes, so the same model resolves identically whether it arrives from the
first-party API, OpenRouter, or Bedrock. Thinking becomes `{type: "adaptive"}`
for that family, with depth steered by `output_config.effort` (a positive
budget keeps its "thinking on" meaning). `ModelConfig` also accepted only
`minimal..high`, which rejected `xhigh`/`max` at construction and left the
effort path unreachable for the two tiers that matter most on these models.

Add a `bedrock` provider for Claude on Amazon Bedrock. The
`bedrock-mantle.<region>.api.aws` endpoint speaks the standard Messages API and
returns real SSE, so this is a thin subclass of the Anthropic adapter rather
than a parallel one — message conversion, tool handling, stream accumulation
and harmonization are all inherited. Its deltas are bearer auth, the mandatory
`anthropic.` id prefix, and no `output_config.format`/`strict` support, which
degrades a schema request to the prompt fallback carrying the real schema
instead of putting an illegal field on the wire.

`metadata.model` is what cost meters price on, and Bedrock echoes a *bare* id
for a request made with a prefixed one — trusting the echo prices the whole
provider at zero with no warning at the meter. `report_model_id` keeps the
requested spelling where the echo would not round-trip, while first-party
adapters still prefer the echo so an alias resolves to its concrete snapshot.

Every shape here was measured against the live endpoints, both legs.
…cache usage is observable

Nothing in this codebase placed a prompt-cache marker over conversation content, so
every call re-paid full input price on the entire conversation. Measured before the
change on a four-step turn: 4,263 input tokens, zero cache reads. After: 8 uncached
tokens and 4,162 read from cache — $0.0230 to $0.0051 on that shape.

The marker goes on the last content block of the last message, on every request —
the platform's multi-turn caching pattern. A breakpoint reads any entry written at or
before it, so marking the growing tail each time both reads the previous request's
entry and extends it. It also satisfies the 20-block lookback by construction: a
per-request tail marker is always a handful of blocks behind the last one, whereas a
marker placed once silently stops matching in an agentic turn that appends several
blocks per step.

Adapter-owned and unconditional rather than a caller opt-in. Caching is prefix-match
arithmetic over the rendered payload, and only the payload builder knows where block
boundaries land — a caller cannot place this correctly even if it wanted to, and a
caller that forgets silently pays full price forever. This matches the one existing
cache_control site (the OAuth adapter's static prefix block) and `defer_loading`, the
nearest analogous feature, which deliberately took no new request parameter either.
The accepted cost is a ~25% write premium on a large genuinely-one-shot call; a
prompt under the model's cacheable minimum writes nothing and costs nothing.

The api-key leg's `system` moves from a bare string to the array form. Same bytes to
the model, but the string form makes the system tier structurally unable to carry a
marker. No marker is placed there yet: on that leg the system content is the caller's
per-turn prompt, which for the caller this ships for changes every turn, so a marker
would write a fresh entry per call and read none.

Observability, because none of the above is verifiable otherwise: `UsageInfo` gains
`cache_read_input_tokens` and `cache_creation_input_tokens`, and both Anthropic
harmonizers populate them from the raw usage dict they already receive. `total_tokens`
keeps its established meaning; the cache-aware reading is the new
`full_prompt_tokens` property. `prompt_tokens` alone is the UNCACHED REMAINDER, so a
consumer that sizes a conversation, prices a call, or bounds prompt growth on it
under-measures by up to ~10x once a cached prefix exists — silently, since nothing
errors. Providers reporting no cache figures harmonize to None and are unaffected.

The OAuth adapter's hand-rolled stream reader now MERGES usage across events instead
of discarding `message_start` and assigning at `message_delta`. Both twins. The
observable defect this fixes is narrower than expected — a live pre-change probe
returned `prompt_tokens=27`, because that endpoint's `message_delta` carries the whole
usage dict — but `message_start` is the only event carrying the cache-TTL breakdown,
`service_tier` and `inference_geo`, and a stream ending without a `message_delta`
harmonized with no usage at all.

Verified live on three endpoints: the write/read pair round-trips on the OAuth leg
(1,219 written then read on claude-sonnet-4-6) and on Bedrock (1,564 on
anthropic.claude-opus-5), and a non-streaming request reads an entry a streaming
request wrote, in both directions.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@rezaho
rezaho merged commit 603761a into main Aug 2, 2026
1 check passed
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.

1 participant