feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) - #3463
Conversation
buzz talks to the Databricks Anthropic gateway with the correct endpoint and wire format, but never asked for caching. The Anthropic Messages API does not cache unless a request carries a cache_control breakpoint, and the Databricks gateway (a third-party surface, like Bedrock/Vertex) does not auto-cache. So cache_read_input_tokens came back 0 on every call and the ~10x cache-read discount was never claimed. Verified live 2026-07-28: two byte-identical calls without cache_control both read 0; adding one marker moved 121,625 tokens to a 0.1x cache read and halved latency. anthropic_body() now emits ephemeral cache_control breakpoints, gated by BUZZ_AGENT_PROMPT_CACHING (default on): - static prefix: cache_control on the system block, which (prefix order being tools -> system -> messages) caches tools + system together - rolling tail + leapfrog: cache_control on the last block of the last TWO messages, so the append-only history re-reads the prior turn's prefix from cache while keeping consecutive breakpoints inside Anthropic's 20-block lookback window even as tool parallelism rises An empty system prompt stays a bare string (Anthropic rejects empty text blocks). Below-threshold prefixes are silently not cached, so the flag is safe on by default; BUZZ_AGENT_PROMPT_CACHING=0 restores the previous behaviour. Also plumbs the cache split end-to-end so accounting can price it: - LlmResponse gains cached_input_tokens (a subset of input_tokens, never an addition); parse_anthropic/parse_openai/parse_responses each populate it - usage_first() reads the cache count wherever a provider puts it: flat cache_read_input_tokens (Anthropic), prompt_tokens_details.cached_tokens (OpenAI chat), input_tokens_details.cached_tokens (Responses) — taking the first present value, never a sum, since these are alternate spellings of one number and Databricks reports several at once - fixes a Databricks MLflow double-count: prompt_tokens is already inclusive of the flat cache_read_input_tokens it also reports, so openai_chat_input_tokens now reads prompt_tokens alone instead of summing (verified on databricks-glm-5-2, where prompt_tokens + completion == total) - the per-turn/per-session accumulators and the goose usage_update payload carry accumulatedCachedInputTokens; buzz-acp deserializes it (serde default 0 for goose, which does not send it) and logs cached=<n> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Atish Patel <atish@squareup.com>
buzz-agent env_clear()s the environment of each MCP child, and the passthrough allowlist carried no proxy or TLS-trust variables. On a host whose only route out is a CONNECT proxy this does not degrade the tools, it blinds them: apt, curl, pip and git connect directly instead, the egress firewall resets the socket, and the agent reports "Connection reset by peer" and concludes the environment has no network — indistinguishable in the transcript from a task that is genuinely offline. Add both spellings of the proxy vars (HTTP_PROXY/HTTPS_PROXY/NO_PROXY/ALL_PROXY and lowercase) to PASSTHROUGH_ENV: curl and git read lowercase, most Go/Python tooling reads uppercase, and libcurl deliberately ignores uppercase HTTP_PROXY, so keeping only one form silently breaks half the toolchain. Also pass SSL_CERT_FILE/SSL_CERT_DIR: a TLS-terminating proxy presents its own CA, and an image whose trust store lacks it fails every https fetch with a verification error — the same class of failure, the parent configured correctly and the child unable to see it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Atish Patel <atish@squareup.com>
tlongwell-block
left a comment
There was a problem hiding this comment.
Reviewed at cbf2ae1.
The cache markers are placed at the stable tools/system prefix and rolling last-two-message tails, remain within Anthropic’s four-breakpoint budget, and preserve the empty-system and opt-out paths. Cache-read accounting stays an explicit subset of inclusive input across Anthropic, Chat Completions, Responses, session accumulation, and ACP wire deserialization. The Databricks inclusive prompt-token correction is supported by a regression fixture, and the MCP proxy/TLS additions remain constrained to the existing env allowlist.
Local verification at that exact SHA: cargo fmt --all -- --check; cargo test -p buzz-agent -p buzz-acp; cargo clippy -p buzz-agent -p buzz-acp --all-targets -- -D warnings. All passed. No blocking findings.
…-style * origin/main: feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) (block#3463) Fix mobile attachment and gallery polish (block#3370) Signed-off-by: Joah Gerstenberg <joah@squareup.com>
* origin/main: feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) (#3463) Fix mobile attachment and gallery polish (#3370) fix(acp): per-runtime env defaults at spawn — isolate Hermes from configured MCP startup (#3420) fix(relay): avoid subscription lock inversion (#3413) feat: add explicit entry for claude-opus-5 in model config (#2831) fix(desktop): clear stale thread new-message pill (#3411) fix(ci): ratchet file sizes against the base tree (#3352) chore(ci): bump desktop smoke E2E timeout to 30 minutes (#3409) release(chart): publish 0.1.7 (#3393) feat(acp): steer claude-code and codex agents via _session/steering (#3007) feat(desktop): apply WebKit rendering workarounds at startup on Linux (#3271) fix(desktop): stabilize flaky DM expansion E2E ordering assertions (#2004) docs(contributing): document the Linux system libraries just ci requires (#3396) fix(desktop): paint community rail full height (#3382) fix(acp): disable goose cron scheduler in managed agent children (#3144) feat(desktop): add custom harness inline from agent dialogs (#3252) chore(compose): remove stale typesense env vars (#3332) feat(desktop): refine agent catalog sharing (#2439) fix(desktop): keep drafts out of the Inbox All view (#3217) docs: restructure DCO guidance into scannable subsection (#3337) Unify mobile loading spinners (#3314) fix(desktop): restore the inbox icon in the sidebar (#3341) fix(desktop): gate codex-acp on a minimum supported version (#3254) feat(cli): add users set-status command for NIP-38 profile status (#3253) fix(composer): scope multiline block formatting (#3246) feat(chart): add relay pod extension points (#3322) Refine mobile attachment picking (#3313) Polish mobile message and search layouts (#3121) Add mobile message image galleries (#3312) chore(release): release Buzz Desktop version 0.5.0 (#3213) Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…wire (#3538) ## Summary Databricks v2 chooses the gateway wire format — OpenAI Responses, Anthropic Messages, or MLflow chat — purely from substrings in the endpoint name. There is no family field on the endpoint to key off, so the substring set *is* the routing contract. The matcher only recognised `gpt-5`/`gpt5` and `claude`, which makes correct billing depend on every Claude endpoint happening to be named with the literal string "claude". ## Why this matters Getting a Claude model onto the Anthropic Messages route is exactly what lets buzz attach the `cache_control` breakpoint (the fix in #3463). If a Claude endpoint's catalog name omits "claude" — an alias, a bare `opus-5`, a `goose-opus-5` — it silently falls through to the MLflow (OpenAI-wire) path, where Anthropic prompt caching is **structurally impossible**. The result is the same failure #3463 fixed: 0% cache reads, the full ~10x read discount lost, and no error — a naming convention quietly holding up a billing-correctness invariant. ## What changed `databricks_v2_route_for_model` (`crates/buzz-agent/src/llm.rs`) now matches broader, case-insensitive marker sets: - **Claude → Anthropic Messages:** `claude`, `opus`, `sonnet`, `haiku`, `mythos`, `fable` — the Claude family names and release code names, so a Claude endpoint reaches the cache-capable route regardless of how it's named. - **GPT → OpenAI Responses:** the `gpt` family (now `gpt` on its own, not just `gpt-5`) plus the GPT-5 launch code names `sol`, `luna`, `terra`. OpenAI markers are evaluated first, preserving the prior `gpt-5`-first precedence for any name that could carry both. Names matching neither set still fall through to the MLflow chat route. ## Testing - `cargo fmt`, `cargo clippy -p buzz-agent --all-targets -- -D warnings` — clean. - `cargo test -p buzz-agent` — all green (299 lib + integration suites, 0 failures). The `databricks_v2_routes_by_model_family` test was expanded to cover each new marker, the GPT-5 code names, case-insensitivity, and the unchanged MLflow fallback (including `gemini`). ## Relationship to #3463 #3463 taught the Anthropic path to request caching; this makes sure Claude models actually land on that path. Follow-up still open: surfacing `cache_creation_input_tokens` end-to-end so a persistent `reads == 0 && writes == 0` reveals a disabled cache regardless of which wire a model takes — happy to do that next. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Atish Patel <atish@squareup.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Buzz Desktop release v0.5.1 ### Changes since v0.5.0: - perf(desktop): move observer-feed archive and decrypt commands off main thread ([#3415](#3415)) ([`294c8c821`](294c8c8)) - fix(desktop): preserve shared agent fidelity ([#3553](#3553)) ([`f7a3988ba`](f7a3988)) - feat(agent): route Claude/GPT model families to their native gateway wire ([#3538](#3538)) ([`6438dedf8`](6438ded)) - Refine community invite limits ([#3529](#3529)) ([`24d90d128`](24d90d1)) - feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) ([#3463](#3463)) ([`c405ad1d4`](c405ad1)) - feat: add explicit entry for claude-opus-5 in model config ([#2831](#2831)) ([`90e058ebf`](90e058e)) - fix(desktop): clear stale thread new-message pill ([#3411](#3411)) ([`55a3ed7b9`](55a3ed7)) - fix(ci): ratchet file sizes against the base tree ([#3352](#3352)) ([`9227bdf58`](9227bdf)) - feat(desktop): apply WebKit rendering workarounds at startup on Linux ([#3271](#3271)) ([`3ece4461d`](3ece446)) - fix(desktop): stabilize flaky DM expansion E2E ordering assertions ([#2004](#2004)) ([`913d564ce`](913d564)) - fix(desktop): paint community rail full height ([#3382](#3382)) ([`1d3b810ad`](1d3b810)) - feat(desktop): add custom harness inline from agent dialogs ([#3252](#3252)) ([`b0503d80c`](b0503d8)) - feat(desktop): refine agent catalog sharing ([#2439](#2439)) ([`a35771fc4`](a35771f)) - fix(desktop): keep drafts out of the Inbox All view ([#3217](#3217)) ([`3afa129ee`](3afa129)) - fix(desktop): restore the inbox icon in the sidebar ([#3341](#3341)) ([`00ede2e7a`](00ede2e)) - fix(desktop): gate codex-acp on a minimum supported version ([#3254](#3254)) ([`4e3998f36`](4e3998f)) - feat(cli): add users set-status command for NIP-38 profile status ([#3253](#3253)) ([`60158fce3`](60158fc)) - fix(composer): scope multiline block formatting ([#3246](#3246)) ([`5457c947a`](5457c94)) **To release:** merge this PR. The tag and build will happen automatically. Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Summary
Two independent, self-contained fixes to
buzz-agent/buzz-acp, split out of the benchmark branch so they can land while the harness work continues:cache_controlbreakpoint, so on the Databricks Anthropic routecache_read_input_tokenswas structurally always 0 and the ~10× cache-read discount was never claimed. This teachesanthropic_body()to mark the cacheable prefix, and plumbs the cache split end-to-end so accounting can price it.Why the caching gap matters
The Anthropic Messages API does not cache unless the request carries a
cache_controlbreakpoint, and the Databricks AI Gateway — a third-party proxy in front of the model, in the same category as Bedrock/Vertex — does not auto-cache (only the first-party Anthropic API and Claude-on-AWS do zero-config caching). So every request was billed cold.Measured live against the Databricks gateway (
databricks-claude-opus-5, 2026-07-28), the same call with and without a singlecache_controlmarker:input_tokenscache_creationcache_readcache_control, two byte-identical callsOne marker moved 121,625 tokens from full-price input to a 0.1× cache read and roughly halved latency (a clean, isolated ~2.07× prefill speedup on this single-threaded microbenchmark). The gateway honours
cache_control; buzz simply never sent it.At fleet scale this was a real budget item. Across matched Terminal-Bench solo sweeps (89 tasks,
-n 20, before the fix), the two OpenAI-route models independently landed at ~86–87% cache reads — the expected shape for an agentic loop, where system + tools + append-only history repeat every turn — while the Anthropic route returned a hard 0% on every receipt:gpt-5-6)gpt-5-6)claude-opus-5)Applying luna's measured 87% read rate to the opus token counts at list prices (
input $5/M,cached_input $0.5/M,output $25/M) puts the opus run at ~$32.53 vs the $81.31 actually paid — a60% overspend on those 49 trials ($89 on a full sweep). That is an upper bound (it prices every cached token at the 0.1× read rate and ignores the 1.25× write premium), and the opus discount is structurally smaller than luna/sol's because opus emits ~3.5× more uncacheable output per trial, which sets a floor on what caching can recover.There is also a plausible second-order effect: Databricks appears to meter its per-minute rate limit on uncached input tokens, so the missing cache also cost rate-limit headroom — the opus endpoint lost 63% of its trials to fatal 429s while running alone at one-third of a GPT endpoint's raw throughput. This is a hypothesis, not a proven mechanism (the only zero-cache condition is also the only Anthropic endpoint), but it is the reading that explains the throttling with one rule instead of two.
Post-fix results (provisional — first trials of an in-flight re-run)
On 8 tasks matched by name across the two runs, cost fell $8.36 → $1.77 (4.71×) and wall-clock 12,423 s → 1,085 s (11.45×).
4a955a858)3bef1f6a)cost_usd_no_cache_discount / cost_usd-n 20)To be clear about attribution: ~2× of that is the clean prefill saving from caching itself; the rest is second-order — cached requests burn far less rate-limit budget, so they stall less and redo less destroyed work. The 11.45× is a system-level result specific to this throttled workspace, not a caching benchmark. Quality held (7/8 solved in each run). A controlled low-
-nA/B (neither arm hitting a 429), which theBUZZ_AGENT_PROMPT_CACHINGopt-out exists to enable, is still owed before this becomes a published claim.What changed
1. Request caching (
llm.rs,config.rs)anthropic_body()emits ephemeralcache_controlbreakpoints, gated byBUZZ_AGENT_PROMPT_CACHING(default on,=0to opt out):systemblock. Prefix order istools → system → messages, so this single marker caches tools + system together. Byte-identical on every turn of a run, and survives a context handoff (system/tools come from cfg/mcp, notself.history).An empty system prompt stays a bare string (Anthropic rejects empty text blocks), and below-threshold prefixes are silently not cached, so the flag is safe on by default.
2. Surface the cache split end-to-end — the plumbing (
types.rs,llm.rs,agent.rs,lib.rs,usage.rs,acp.rs)This is the part that makes gaps like the one above visible instead of silent. A consumer that prices all of
input_tokensat the full rate can't tell a route that's caching from one that isn't — the total looks right either way. So:LlmResponsegainscached_input_tokens(a subset ofinput_tokens, never an addition);parse_anthropic/parse_openai/parse_responseseach populate it.usage_first()helper reads the cache count wherever a provider hides it — flatcache_read_input_tokens(Anthropic),prompt_tokens_details.cached_tokens(OpenAI chat),input_tokens_details.cached_tokens(Responses) — taking the first present value, never a sum. Reading only flat keys is exactly why the OpenAI route's nestedcached_tokenshad also been going unclaimed:prompt_tokensis already inclusive, so the total looked correct while the discount silently went unreported.usage_updatepayload now carryaccumulatedCachedInputTokens;buzz-acpdeserializes it (serdedefault0for goose, which doesn't send it) and logscached=<n>.3. Fix a Databricks MLflow-route double-count (
llm.rs)The Databricks MLflow route reports the flat Anthropic-spelled
cache_read_input_tokensalongside an already-inclusiveprompt_tokens, so the old code summed them and nearly doubled the count — inflating both the context-budget gate and cost.openai_chat_input_tokens()now readsprompt_tokensalone. Verified on a livedatabricks-glm-5-2response whereprompt_tokens + completion == totalproves inclusivity. (Anthropic's native route genuinely excludes the cache fields and is still summed — the two never collide, becauseclaude*models route to the Anthropic path.)4. Proxy + TLS-trust passthrough into MCP tools (
mcp.rs) — independent fixbuzz-agentenv_clear()s each MCP child, and the allowlist carried no proxy/TLS vars. On a proxy-only host that doesn't degrade the tools, it blinds them: apt, curl, pip, git connect directly, the egress firewall resets the socket, and the agent reports "Connection reset by peer" — indistinguishable from a genuinely offline task. Adds both spellings ofHTTP(S)_PROXY/NO_PROXY/ALL_PROXY(curl/git read lowercase; Go/Python read uppercase; libcurl ignores uppercaseHTTP_PROXY) plusSSL_CERT_FILE/SSL_CERT_DIRfor TLS-terminating proxies that present their own CA.Testing
cargo fmt --all -- --check,cargo clippy -p buzz-agent -p buzz-acp --all-targets -- -D warnings— clean.cargo test -p buzz-agent -p buzz-acp— all green (632 + 299 lib tests plus integration suites, 0 failures). New tests cover: the three breakpoints and the disabled/empty-system/single-message edge cases; nested-vs-flat cache parsing for all three routes; the Databricks inclusive-prompt_tokensfix; wire deserialization ofaccumulatedCachedInputTokens; and the proxy/TLS passthrough allowlist.Relationship to the benchmark branch
These are the non-
benchmarks/changes frombenchmark/harness-accounting-and-solo, lifted onto a clean base offmainso they can merge independently.🤖 Generated with Claude Code