Streaming requests never ask for usage, so the prompt-cache accounting cannot run on 94% of traffic #138

Closed
opened 2026-08-19 00:55:12 +00:00 by coilyco-ops · 3 comments
Member

The proxy's prompt-cache accounting is correct and never runs on the streaming path, because the provider is never asked for the usage block it derives from. 94% of traffic is therefore unmeasured, and the silence has already been misread once as a regression.

Measurement

agent-proxy spans, 24h to 2026-08-19 ~00:40Z:

  • upstream.chat - 18 spans, 18 carry gen_ai.usage.cache_read_input_tokens, summing to 36,608 cache-read tokens
  • upstream.chat_stream - 272 spans, 0 carry it, sum is null

The attribute is absent, not present-and-zero, and the split correlates with the code path rather than with time, load, model, or caller.

Why

_chat_body in app/upstream.py builds the upstream request and never sets stream_options. OpenAI-compatible streaming omits the usage block unless the request carries stream_options: {"include_usage": true}, so the terminal chunk has no usage, parse_cache_usage sees nothing, cache_usage_reported stays False, and set_result_span_attributes correctly publishes nothing.

Every layer behaves as designed. The input never arrives.

include_usage currently appears once in this repository, in tests/test_api.py, where it is a client posting to the proxy rather than the proxy calling upstream.

Why it matters beyond tidiness

docs/proxy-prompt-cache.md states the rule this protects:

A provider that reports nothing is an unmeasured route, not a cache miss. [...] publishing that silence as a 100% miss would invent a regression it never had.

coilyco-gaming/sirens-echo#932 then read that silence as a 100% miss and named "prompt caching went dark" its decisive finding, on a priority/P0 sixteen hours before a live demo. The design anticipated the failure mode, and the absent data still produced the wrong conclusion, because absence and zero are indistinguishable to anyone reading the spans.

This repository's own tests/test_prompt_cache.py opens by describing the same shape from issue #101: a prefix "concluded it was uncached" that the proxy "could neither confirm nor refute". That was fixed for the non-streaming path. The streaming path still cannot answer.

The change

Send stream_options: {"include_usage": true} on OpenAI-dialect streaming requests. Ollama is excluded: it accounts for its own stream in the done payload and does not take the OpenAI-only key.

No routing, roster, model, or response-shape change. It turns an unmeasured route into a measured one and the existing accounting does the rest.

Acceptance

  • An OpenAI-dialect streaming request carries stream_options.include_usage.
  • A non-streaming request does not.
  • An Ollama streaming request does not.
  • After rollout, upstream.chat_stream spans carry cache attributes when the provider accounts for caching, and stay absent when it genuinely does not.
The proxy's prompt-cache accounting is correct and never runs on the streaming path, because the provider is never asked for the usage block it derives from. 94% of traffic is therefore unmeasured, and the silence has already been misread once as a regression. ## Measurement agent-proxy spans, 24h to 2026-08-19 ~00:40Z: * `upstream.chat` - **18** spans, **18** carry `gen_ai.usage.cache_read_input_tokens`, summing to **36,608** cache-read tokens * `upstream.chat_stream` - **272** spans, **0** carry it, sum is **null** The attribute is **absent**, not present-and-zero, and the split correlates with the code path rather than with time, load, model, or caller. ## Why `_chat_body` in `app/upstream.py` builds the upstream request and never sets `stream_options`. OpenAI-compatible streaming omits the `usage` block unless the request carries `stream_options: {"include_usage": true}`, so the terminal chunk has no usage, `parse_cache_usage` sees nothing, `cache_usage_reported` stays False, and `set_result_span_attributes` correctly publishes nothing. Every layer behaves as designed. The input never arrives. `include_usage` currently appears once in this repository, in `tests/test_api.py`, where it is a client posting to the proxy rather than the proxy calling upstream. ## Why it matters beyond tidiness `docs/proxy-prompt-cache.md` states the rule this protects: > A provider that reports nothing is an unmeasured route, not a cache miss. [...] publishing that silence as a 100% miss would invent a regression it never had. `coilyco-gaming/sirens-echo#932` then read that silence as a 100% miss and named "prompt caching went dark" its decisive finding, on a `priority/P0` sixteen hours before a live demo. The design anticipated the failure mode, and the absent data still produced the wrong conclusion, because absence and zero are indistinguishable to anyone reading the spans. This repository's own `tests/test_prompt_cache.py` opens by describing the same shape from issue #101: a prefix "concluded it was uncached" that the proxy "could neither confirm nor refute". That was fixed for the non-streaming path. The streaming path still cannot answer. ## The change Send `stream_options: {"include_usage": true}` on OpenAI-dialect streaming requests. Ollama is excluded: it accounts for its own stream in the `done` payload and does not take the OpenAI-only key. No routing, roster, model, or response-shape change. It turns an unmeasured route into a measured one and the existing accounting does the rest. ## Acceptance * An OpenAI-dialect streaming request carries `stream_options.include_usage`. * A non-streaming request does not. * An Ollama streaming request does not. * After rollout, `upstream.chat_stream` spans carry cache attributes when the provider accounts for caching, and stay absent when it genuinely does not.
Author
Member

Rolled out and verified. The change did not achieve its acceptance condition. Reopening rather than letting the commit's Closes stand.

Rollout

50af3de is live on ser8 as of 2026-08-19 ~02:25Z:

deploy image : agent-proxy:50af3de467714a25c9d2d65241ded7f7eb03abec
pod          : agent-proxy-58c9dfd9bd-wgdkl 1/1 Running
transport    : ser8:8080/v1/models -> 200 in 0.013s

rollout.sh reported agent-proxy is Ready on ser8 in litellm mode and returns at least one model. I then sent one turn through sirens-dowel to generate a streaming call.

The acceptance condition is not met

Spans in the window after the rollout:

  • upstream.chat_stream - gen_ai.usage.cache_read_input_tokens still null
  • upstream.chat_stream - gen_ai.usage.input_tokens = 0

And the zero is not new. Bucketing hourly over six hours, upstream.chat_stream spans carried gen_ai.usage.input_tokens in every hour, including well before the rollout. That attribute is set unconditionally from usage.get("prompt_tokens", 0), so present-and-zero is what an absent usage block looks like, and it looked identical before and after.

So no usage block is arriving from the upstream despite the request now carrying stream_options: {"include_usage": true}. The route remains unmeasured and the change produced no observable difference.

What that means for the reasoning in the body above

The diagnosis of why the attributes were absent still holds: the accounting derives from a usage block the streaming path was never given. What is now refuted is that agent-proxy was the only thing withholding it. Sending the option was necessary and is not sufficient.

Where I would look next, marked as inference

The deployment runs mode: litellm, so agent-proxy is not talking to the provider directly. The next hop is LiteLLM, which may not forward stream_options to the upstream, or may not emit the terminal usage chunk of its own stream.

I could not confirm this. A direct LiteLLM probe from ser8 failed on two counts: the secret key name I guessed for LITELLM_API_KEY resolved empty, and LiteLLM's port 4000 is not reachable on ser8's localhost or on its ClusterIP from the host. So the LiteLLM hop is a hypothesis and nothing more.

The discriminating test, for whoever picks this up: call LiteLLM directly with stream: true and stream_options.include_usage, and see whether its final SSE chunk carries usage. If it does not, the gap is LiteLLM's and this change is a correct prerequisite waiting on it. If it does, the gap is back inside agent-proxy's stream normalizer.

Keep the change

It is correct on its own terms, costs nothing, and is a prerequisite either way. Three tests cover it and the first fails without it. Nothing here argues for reverting, only against believing the route is now measured.

Related: coilyco-gaming/sirens-echo#932, whose withdrawn cache finding is still withdrawn, and #1002, whose latency work does not depend on this.

**Rolled out and verified. The change did not achieve its acceptance condition. Reopening rather than letting the commit's `Closes` stand.** ## Rollout `50af3de` is live on ser8 as of 2026-08-19 ~02:25Z: ``` deploy image : agent-proxy:50af3de467714a25c9d2d65241ded7f7eb03abec pod : agent-proxy-58c9dfd9bd-wgdkl 1/1 Running transport : ser8:8080/v1/models -> 200 in 0.013s ``` `rollout.sh` reported `agent-proxy is Ready on ser8 in litellm mode and returns at least one model`. I then sent one turn through `sirens-dowel` to generate a streaming call. ## The acceptance condition is not met Spans in the window after the rollout: * `upstream.chat_stream` - `gen_ai.usage.cache_read_input_tokens` still **null** * `upstream.chat_stream` - `gen_ai.usage.input_tokens` = **0** And the zero is not new. Bucketing hourly over six hours, `upstream.chat_stream` spans carried `gen_ai.usage.input_tokens` in **every** hour, including well before the rollout. That attribute is set unconditionally from `usage.get("prompt_tokens", 0)`, so present-and-zero is what an **absent usage block** looks like, and it looked identical before and after. So no usage block is arriving from the upstream despite the request now carrying `stream_options: {"include_usage": true}`. The route remains unmeasured and the change produced no observable difference. ## What that means for the reasoning in the body above The diagnosis of **why** the attributes were absent still holds: the accounting derives from a usage block the streaming path was never given. What is now refuted is that **agent-proxy was the only thing withholding it.** Sending the option was necessary and is not sufficient. ## Where I would look next, marked as inference The deployment runs `mode: litellm`, so agent-proxy is not talking to the provider directly. The next hop is LiteLLM, which may not forward `stream_options` to the upstream, or may not emit the terminal usage chunk of its own stream. **I could not confirm this.** A direct LiteLLM probe from ser8 failed on two counts: the secret key name I guessed for `LITELLM_API_KEY` resolved empty, and LiteLLM's port 4000 is not reachable on ser8's localhost or on its ClusterIP from the host. So the LiteLLM hop is a hypothesis and nothing more. The discriminating test, for whoever picks this up: call LiteLLM directly with `stream: true` and `stream_options.include_usage`, and see whether its final SSE chunk carries `usage`. If it does not, the gap is LiteLLM's and this change is a correct prerequisite waiting on it. If it does, the gap is back inside agent-proxy's stream normalizer. ## Keep the change It is correct on its own terms, costs nothing, and is a prerequisite either way. Three tests cover it and the first fails without it. Nothing here argues for reverting, only against believing the route is now measured. Related: `coilyco-gaming/sirens-echo#932`, whose withdrawn cache finding is still withdrawn, and `#1002`, whose latency work does not depend on this.
Author
Member

Closed. The streaming route reports, and prompt caching was working the whole time. Olaf (ops seat), 2026-08-19 ~03:15Z.

The actual root cause, which was not what this issue first said

50af3de made the provider send usage and changed nothing, because the gap was in two places and I had only found one.

Captured from LiteLLM on ser8 rather than reasoned about, the real chunk order is:

{"choices":[{"delta":{"content":"ok"}}]}
{"choices":[{"delta":{},"finish_reason":"stop"}]}
{"choices":[{"delta":{}}],"usage":{"prompt_tokens":85,...,
    "prompt_tokens_details":{"cached_tokens":0}}}

The usage arrives in a trailing chunk of its own, after the chunk that ends the message, with no finish_reason. The normalizer read usage only inside if choice.get("finish_reason"), so the branch that read usage never saw it and the branch that saw it never read usage.

LiteLLM was answering correctly the entire time, including prompt_tokens_details.cached_tokens, the exact field parse_cache_usage looks for.

f4a59d3 adds _fold_stream_usage, which carries that trailing chunk onto the terminal result while leaving a stream that sends no usage chunk untouched, so a provider that accounts for nothing still reports absence rather than a fabricated zero.

Verified live

f4a59d3 rolled out to ser8, then two turns through sirens-dowel:

upstream.chat_stream   cache_read_input_tokens : 38,912
upstream.chat_stream   input_tokens            : 164,839
                       hit rate                :  23.6%

Before this, across 272 streaming spans, that attribute was null.

What this settles for sirens-echo#932

That issue's decisive finding was "prompt caching went dark", built on zero cache-read spans across the burst. It was wrong twice over:

  1. The zero was an artifact. The streaming path could not report, so absence was read as a 100% miss, which docs/proxy-prompt-cache.md explicitly warns against.
  2. Caching was working. The first real measurement of the streaming route shows a 23.6% hit rate on the fixed prefix, not zero.

So the 116 KB prefix was not being re-paid in full on every call, and the lever #932 called "the single biggest one on the 32.9s median" was never there. #1002 carries the surviving latency work, and my load test on #1010 already pointed at queue wait rather than model time.

Honest caveats

  • Two turns is a small sample, on a lane whose MCP roster was cut from 11 servers to 6 earlier tonight, so the prefix is smaller than the one #932 measured. The 23.6% is a real measurement of the route, not a stable characterisation of it.
  • 23.6% is not obviously good. It is now a number that can be argued about, which is the whole point.
  • Nothing here touched the queue-wait finding, which is unaffected.
**Closed. The streaming route reports, and prompt caching was working the whole time.** Olaf (ops seat), 2026-08-19 ~03:15Z. ## The actual root cause, which was not what this issue first said `50af3de` made the provider send usage and changed nothing, because the gap was in two places and I had only found one. Captured from LiteLLM on ser8 rather than reasoned about, the real chunk order is: ``` {"choices":[{"delta":{"content":"ok"}}]} {"choices":[{"delta":{},"finish_reason":"stop"}]} {"choices":[{"delta":{}}],"usage":{"prompt_tokens":85,..., "prompt_tokens_details":{"cached_tokens":0}}} ``` **The usage arrives in a trailing chunk of its own, after the chunk that ends the message, with no `finish_reason`.** The normalizer read usage only inside `if choice.get("finish_reason")`, so the branch that read usage never saw it and the branch that saw it never read usage. LiteLLM was answering correctly the entire time, including `prompt_tokens_details.cached_tokens`, the exact field `parse_cache_usage` looks for. `f4a59d3` adds `_fold_stream_usage`, which carries that trailing chunk onto the terminal result while leaving a stream that sends no usage chunk untouched, so a provider that accounts for nothing still reports absence rather than a fabricated zero. ## Verified live `f4a59d3` rolled out to ser8, then two turns through `sirens-dowel`: ``` upstream.chat_stream cache_read_input_tokens : 38,912 upstream.chat_stream input_tokens : 164,839 hit rate : 23.6% ``` Before this, across 272 streaming spans, that attribute was **null**. ## What this settles for sirens-echo#932 That issue's decisive finding was "prompt caching went dark", built on zero cache-read spans across the burst. It was wrong twice over: 1. The zero was an artifact. The streaming path could not report, so absence was read as a 100% miss, which `docs/proxy-prompt-cache.md` explicitly warns against. 2. **Caching was working.** The first real measurement of the streaming route shows a 23.6% hit rate on the fixed prefix, not zero. So the 116 KB prefix was not being re-paid in full on every call, and the lever #932 called "the single biggest one on the 32.9s median" was never there. #1002 carries the surviving latency work, and my load test on #1010 already pointed at queue wait rather than model time. ## Honest caveats * Two turns is a small sample, on a lane whose MCP roster was cut from 11 servers to 6 earlier tonight, so the prefix is smaller than the one #932 measured. The 23.6% is a real measurement of the route, not a stable characterisation of it. * 23.6% is not obviously good. It is now a number that can be argued about, which is the whole point. * Nothing here touched the queue-wait finding, which is unaffected.
Author
Member

Fixed and verified in production. 50af3de added stream_options and is on main, at app/upstream.py:373:

if stream:
    body["stream_options"] = {"include_usage": True}

SigNoz, agent-proxy, 3h window ending 2026-08-19 ~07:00Z:

upstream.chat_stream   max gen_ai.usage.input_tokens             66,812
upstream.chat_stream   sum gen_ai.usage.cache_read_input_tokens 178,688

This issue measured 0 spans carrying the cache attribute on 272 streaming spans. It is now present, and its streaming total is more than ten times the non-streaming path's 15,616 - which is what the 94/6 traffic split predicts, so the number is the right shape and not just non-zero.

Worth noting the diagnosis in this issue was correct but only half the cause. Asking for usage was necessary and not sufficient: the normalizer then read usage only inside if choice.get("finish_reason"), and the usage arrives in a chunk of its own after that one. #139 caught the remainder and f4a59d3 fixed it. Both are on main.

Nothing here is outstanding. Closing.

**Fixed and verified in production.** `50af3de` added `stream_options` and is on `main`, at `app/upstream.py:373`: ```python if stream: body["stream_options"] = {"include_usage": True} ``` SigNoz, agent-proxy, 3h window ending 2026-08-19 ~07:00Z: ``` upstream.chat_stream max gen_ai.usage.input_tokens 66,812 upstream.chat_stream sum gen_ai.usage.cache_read_input_tokens 178,688 ``` This issue measured `0` spans carrying the cache attribute on 272 streaming spans. It is now present, and its streaming total is more than ten times the non-streaming path's 15,616 - which is what the 94/6 traffic split predicts, so the number is the right shape and not just non-zero. Worth noting the diagnosis in this issue was correct but only half the cause. Asking for usage was necessary and not sufficient: the normalizer then read usage only inside `if choice.get("finish_reason")`, and the usage arrives in a chunk of its own *after* that one. #139 caught the remainder and `f4a59d3` fixed it. Both are on `main`. Nothing here is outstanding. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/agent-proxy#138
No description provided.