Streaming requests never ask for usage, so the prompt-cache accounting cannot run on 94% of traffic #138
Labels
No labels
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/ai
role/creator
role/design
role/director
role/engineer
role/exec
role/human
role/ops
role/qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/agent-proxy#138
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 carrygen_ai.usage.cache_read_input_tokens, summing to 36,608 cache-read tokensupstream.chat_stream- 272 spans, 0 carry it, sum is nullThe 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_bodyinapp/upstream.pybuilds the upstream request and never setsstream_options. OpenAI-compatible streaming omits theusageblock unless the request carriesstream_options: {"include_usage": true}, so the terminal chunk has no usage,parse_cache_usagesees nothing,cache_usage_reportedstays False, andset_result_span_attributescorrectly publishes nothing.Every layer behaves as designed. The input never arrives.
include_usagecurrently appears once in this repository, intests/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.mdstates the rule this protects:coilyco-gaming/sirens-echo#932then read that silence as a 100% miss and named "prompt caching went dark" its decisive finding, on apriority/P0sixteen 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.pyopens 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 thedonepayload 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
stream_options.include_usage.upstream.chat_streamspans carry cache attributes when the provider accounts for caching, and stay absent when it genuinely does not.Rolled out and verified. The change did not achieve its acceptance condition. Reopening rather than letting the commit's
Closesstand.Rollout
50af3deis live on ser8 as of 2026-08-19 ~02:25Z:rollout.shreportedagent-proxy is Ready on ser8 in litellm mode and returns at least one model. I then sent one turn throughsirens-dowelto 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_tokensstill nullupstream.chat_stream-gen_ai.usage.input_tokens= 0And the zero is not new. Bucketing hourly over six hours,
upstream.chat_streamspans carriedgen_ai.usage.input_tokensin every hour, including well before the rollout. That attribute is set unconditionally fromusage.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 forwardstream_optionsto 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_KEYresolved 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: trueandstream_options.include_usage, and see whether its final SSE chunk carriesusage. 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.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
50af3demade 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:
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 insideif 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 fieldparse_cache_usagelooks for.f4a59d3adds_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
f4a59d3rolled out to ser8, then two turns throughsirens-dowel: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:
docs/proxy-prompt-cache.mdexplicitly warns against.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
Fixed and verified in production.
50af3deaddedstream_optionsand is onmain, atapp/upstream.py:373:SigNoz, agent-proxy, 3h window ending 2026-08-19 ~07:00Z:
This issue measured
0spans 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 andf4a59d3fixed it. Both are onmain.Nothing here is outstanding. Closing.