Skip to content

feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) - #3463

Merged
tlongwell-block merged 2 commits into
mainfrom
buzz-agent/prompt-caching-and-cache-accounting
Jul 29, 2026
Merged

feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough)#3463
tlongwell-block merged 2 commits into
mainfrom
buzz-agent/prompt-caching-and-cache-accounting

Conversation

@atishpatel

@atishpatel atishpatel commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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×).

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:

  1. Request and surface Anthropic prompt caching. buzz never sent a cache_control breakpoint, so on the Databricks Anthropic route cache_read_input_tokens was structurally always 0 and the ~10× cache-read discount was never claimed. This teaches anthropic_body() to mark the cacheable prefix, and plumbs the cache split end-to-end so accounting can price it.
  2. Pass proxy + TLS-trust env into MCP tool subprocesses, so agent tools on a proxy-only host stop reporting a live network as offline.

Why the caching gap matters

The Anthropic Messages API does not cache unless the request carries a cache_control breakpoint, 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 single cache_control marker:

Run input_tokens cache_creation cache_read latency
No cache_control, two byte-identical calls 121,625 0 0 ~9.3 s
With one marker — cold (write) 4 121,625 0 9.3 s
With one marker — warm (read) 4 0 121,625 4.5 s

One 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:

Condition Route Input tokens Cache reads Cost Cost if uncached Discount
luna (gpt-5-6) OpenAI 20,320,818 17.7M (87.0%) $6.96 $22.87 3.28×
sol (gpt-5-6) OpenAI 22,312,290 19.2M (85.9%) $37.07 $123.35 3.33×
opus (claude-opus-5) Anthropic 12,459,822 0 (0.0%) $81.31 $81.31 1.00×

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 — a 60% 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×).

Metric before (4a955a858) after (3bef1f6a)
Cache reads as % of input 0.0% 78.7% (still climbing toward the ~86% steady state)
cost_usd_no_cache_discount / cost_usd 1.00× 2.18× (tracking the projected ~2.5×)
Trials with a fatal 429 (same -n 20) 63% 15–19%

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--n A/B (neither arm hitting a 429), which the BUZZ_AGENT_PROMPT_CACHING opt-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 ephemeral cache_control breakpoints, gated by BUZZ_AGENT_PROMPT_CACHING (default on, =0 to opt out):

  • Static prefix — marker on the system block. Prefix order is tools → 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, not self.history).
  • Rolling tail + leapfrog — marker on the last block of the last two messages. The append-only history re-reads the prior turn's prefix from cache; marking two messages (not one) keeps consecutive breakpoints inside Anthropic's 20-block lookback window even as tool parallelism rises, avoiding a silent full-price miss.

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_tokens at the full rate can't tell a route that's caching from one that isn't — the total looks right either way. So:

  • LlmResponse gains cached_input_tokens (a subset of input_tokens, never an addition); parse_anthropic / parse_openai / parse_responses each populate it.
  • A usage_first() helper reads the cache count wherever a provider hides 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. Reading only flat keys is exactly why the OpenAI route's nested cached_tokens had also been going unclaimed: prompt_tokens is already inclusive, so the total looked correct while the discount silently went unreported.
  • The per-turn/per-session accumulators and the goose usage_update payload now carry accumulatedCachedInputTokens; buzz-acp deserializes it (serde default 0 for goose, which doesn't send it) and logs cached=<n>.

3. Fix a Databricks MLflow-route double-count (llm.rs)

The Databricks MLflow route reports the flat Anthropic-spelled cache_read_input_tokens alongside an already-inclusive prompt_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 reads prompt_tokens alone. Verified on a live databricks-glm-5-2 response where prompt_tokens + completion == total proves inclusivity. (Anthropic's native route genuinely excludes the cache fields and is still summed — the two never collide, because claude* models route to the Anthropic path.)

4. Proxy + TLS-trust passthrough into MCP tools (mcp.rs) — independent fix

buzz-agent env_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 of HTTP(S)_PROXY/NO_PROXY/ALL_PROXY (curl/git read lowercase; Go/Python read uppercase; libcurl ignores uppercase HTTP_PROXY) plus SSL_CERT_FILE/SSL_CERT_DIR for 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-acpall 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_tokens fix; wire deserialization of accumulatedCachedInputTokens; and the proxy/TLS passthrough allowlist.
  • Pre-push lefthook suite green (branch-skew, rust-tests, test, desktop-check/test/tauri).

Relationship to the benchmark branch

These are the non-benchmarks/ changes from benchmark/harness-accounting-and-solo, lifted onto a clean base off main so they can merge independently.

🤖 Generated with Claude Code

atishpatel and others added 2 commits July 28, 2026 23:18
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>
@atishpatel
atishpatel requested a review from a team as a code owner July 29, 2026 04:20

@tlongwell-block tlongwell-block left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@atishpatel atishpatel changed the title feat(agent): request and surface Anthropic prompt caching (+ MCP proxy/TLS passthrough) feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) Jul 29, 2026
@tlongwell-block
tlongwell-block merged commit c405ad1 into main Jul 29, 2026
32 checks passed
@tlongwell-block
tlongwell-block deleted the buzz-agent/prompt-caching-and-cache-accounting branch July 29, 2026 13:36
joahg added a commit to joahg/buzz that referenced this pull request Jul 29, 2026
…-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>
atishpatel added a commit that referenced this pull request Jul 29, 2026
…ounting-and-solo

* origin/main:
  feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) (#3463)
  Fix mobile attachment and gallery polish (#3370)

Signed-off-by: Atish Patel <atish@squareup.com>
tlongwell-block pushed a commit that referenced this pull request Jul 29, 2026
* 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>
atishpatel added a commit that referenced this pull request Jul 29, 2026
…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>
wesbillman added a commit that referenced this pull request Jul 29, 2026
## 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>
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.

2 participants