fix(proxy): bill partial usage when a streaming request is cancelled - #30630
Conversation
|
@jingyu-lin this implements the SpendLogs side you root-caused on #30522. On a mid-stream disconnect it assembles partial usage from the received chunks via stream_chunk_builder in the shielded cleanup and dispatches success logging, de-duped by has_dispatched_final_stream_success and gated to the cancellation path so it does not double-log with the exception/failure path. Since you have the production-validated version, your review would be very welcome, especially on edge cases I cannot exercise in unit tests (real SpendLogs row, cost accuracy, multi-retry aborts). @Sameerlite this is the follow-up I mentioned on #30522, kept as a separate focused PR. #30522 releases the budget reservation on cancel; this one records the partial spend so the provider tokens stop being unbilled. With both in, the cost callback fires on cancel and the reservation reconciles to real partial cost. Happy to adjust scope or fold differently if you prefer. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR adds partial-usage billing on mid-stream client disconnect by extending
Confidence Score: 5/5Safe to merge — the new billing path runs inside an existing shielded CancelScope, errors are fully swallowed, and the upstream connection is released before any logging callbacks run. The implementation correctly gates partial billing on a confirmed client disconnect, assembles usage via the same stream_chunk_builder path used for normal completion, preserves the guardrail routing for guarded streams, and de-dupes through the existing has_dispatched_final_stream_success flag. All new code paths are covered by purpose-built unit tests, including ordering, error swallowing, and the deferred-guardrail branch. No existing test assertions were weakened. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/proxy/common_request_processing.py | Adds _bill_partial_stream_on_disconnect (76 lines) and wires it into _finalize_streaming_generator_cleanup; ordering, de-dup, guardrail routing, and error-swallowing are all handled correctly. |
| tests/test_litellm/proxy/test_common_request_processing.py | Seven new unit tests cover every branch of the new billing helper; all other changes are Black-driven line-length reformatting with no assertion logic altered. |
Reviews (5): Last reviewed commit: "fix(proxy): close upstream stream before..." | Re-trigger Greptile
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
640920e to
602241e
Compare
|
Addressed all three in 602241e: veria (post-call guardrails bypassed): the partial response now routes through _on_deferred_stream_complete when it is set, which runs _run_deferred_stream_guardrails (the same audit-only post-call guardrail path normal completion uses) before logging. It only dispatches success directly when no deferred guardrail closure is set. Added a test asserting a guarded stream goes through the deferred path and does not dispatch success directly; removing the routing fails it. greptile (redundant dedup guard): kept as an optimization to skip the stream_chunk_builder assembly, with a comment clarifying dispatch_success_handlers is the authoritative de-dup via has_dispatched_final_stream_success. greptile (start_time=None): added an inline comment noting None falls back to self.start_time (original request start) and end_time=None to now, which is the intended behavior for a partial record. |
602241e to
da09576
Compare
|
Thanks for the PR! A couple of things to get this over the finish line:
Triggering Greptile for a code review in the meantime: |
da09576 to
131cf4f
Compare
|
Good catch. Fixed in 131cf4f: the dispatch (both the deferred-guardrail path and the direct dispatch_success_handlers path) is now wrapped in a try/except that logs and continues, so a logging or callback failure can no longer escape _bill_partial_stream_on_disconnect, exit the shielded scope, and skip response.aclose(). Added a regression test asserting a raising dispatch_success_handlers does not propagate out of the helper. |
|
Thanks @Sameerlite. Here is a before/after on this branch. The repro drives the exact streaming-cleanup path ( resp = await litellm.acompletion(model=..., stream=True, max_tokens=4000, messages=[...])
i = 0
async for _ in resp: # client reads a few chunks...
i += 1
if i >= 6:
break # ...then disconnects mid-stream
# exactly what the proxy now runs in the shielded cleanup on disconnect:
await ProxyBaseLLMRequestProcessing._bill_partial_stream_on_disconnect(
resp, {"litellm_logging_obj": resp.logging_obj}
)A CustomLogger capturing So the tokens the provider already generated (and billed us for) now produce a SpendLogs entry on a cancelled stream, whereas before they were silently dropped ( Equivalent curl-level repro for reference: start a streaming |
|
Thanks for the contribution! A couple of things to get this ready:
Once that's in, we'll take another look — appreciate the work on this! 🙏 |
On a mid-stream client disconnect the stream never reaches normal completion: CancelledError / GeneratorExit are BaseException, so neither the success nor the failure logging path runs, and the assembled-response success logging that writes SpendLogs never fires. The tokens already produced upstream are billed by the provider but never recorded on the proxy, so spend undercounts by roughly the abort rate; the gap is invisible in SpendLogs and only shows up against provider invoices. In the shielded streaming cleanup, when a client disconnect is recorded, assemble the partial usage from the chunks received so far via stream_chunk_builder and dispatch success logging for it. dispatch_success_handlers de-dupes via has_dispatched_final_stream_success, so it is a no-op when normal completion already logged, and it only runs on the cancellation path (the exception path sets stream_completed and already emits a failure log). Reported and root-caused in production by @jingyu-lin. Complements BerriAI#30522, which releases the budget reservation on the same cancellation path.
131cf4f to
88f533c
Compare
…connect Release the provider connection before running partial-usage success logging on a client disconnect, so slow or external success callbacks can no longer keep the upstream stream held open. aclose() only closes completion_stream and leaves response.chunks intact, so the partial billing still assembles usage from the chunks already received.
|
Addressed in a0d1dd8. veria (upstream stream held open during disconnect billing): the cleanup now calls response.aclose() before _bill_partial_stream_on_disconnect, so the provider connection is released first and the partial-usage success logging (including any post-call guardrails and external success callbacks) runs off the path that holds the stream. aclose() only closes completion_stream and leaves response.chunks intact, so the partial billing still assembles usage from the chunks already received. Added a regression test asserting the cleanup awaits aclose before the billing dispatch; reverting the order fails it. This also subsumes the earlier 131cf4f reasoning: the billing dispatch is still wrapped so a logging/callback failure cannot propagate out of the shielded cleanup, and now even a failure there cannot skip aclose since aclose already ran. |
|
The fix in a0d1dd8 looks correct and well-reasoned. The ordering is sound: The key invariants hold:
The regression test asserting |
|
@Sameerlite the merge conflict is resolved (rebased onto litellm_internal_staging), and the upstream-stream-held-open concern veria raised is fixed in a0d1dd8. Cleanup now closes the upstream response before the partial-usage billing runs, so post-call guardrails and async success callbacks can no longer hold the provider connection open on a disconnect. aclose() only closes completion_stream and leaves response.chunks intact, so the partial usage is still assembled from the chunks already received. Added a regression test that fails if the close-before-bill order is reverted. This should be ready for another look whenever you have a moment While you're here, I have a few other PRs that have been sitting in the review queue for a while; would you mind taking a look when you get a chance:
Thanks for taking the time |
|
Tested this against a request routed through a Root cause: async def __anext__(self):
return await self._async_generator.__anext__()The inherited The actual chunk history accumulates on Repro: configured a model group with a fallback, started a streaming request against it, disconnected after ~6 chunks. Added a log right before the early-return: chunks = getattr(response, "chunks", None)
verbose_proxy_logger.info(
"billing_debug: response_type=%s chunks_len=%s",
type(response).__name__,
len(chunks) if chunks else chunks,
)Output: So any disconnect on a request that went through a fallback-enabled model group still drops the partial usage from SpendLogs — same symptom this PR fixes for the non-fallback path. Possible fix: have Happy to test against a patch if useful — not sure if this should block this PR or be tracked as a fast-follow. |
|
@WellMaxWang confirmed, and thanks for root-causing it. I checked against One detail worth flagging before anyone reaches for the quick fix: appending to Your option 1 (expose the underlying stream and read its chunks) is correct for the common case, but a mid-stream fallback switch ( So this is a real gap, but the usage-correct fix lives inside the router fallback machinery ( @Sameerlite flagging this for your call: I'd keep it out of this PR and take the fallback case as a fast-follow, since this PR already fixes the dominant non-fallback path and the usage-correct fix lives in the router fallback machinery. Happy to do it either way. |
|
Thanks for the contribution! Kicking off a Greptile code review on this one. |
6990ad2
into
BerriAI:litellm_oss_staging_230626
…eams A request routed through a Router model group with fallbacks comes back as FallbackStreamWrapper, whose __anext__ delegates straight to the inner generator and never runs the inherited CustomStreamWrapper.__anext__ that records chunks. Its self.chunks stays the empty list set in __init__, so _bill_partial_stream_on_disconnect reads an empty list and no-ops, and the partial usage for an aborted fallback stream is dropped from SpendLogs; the same symptom the non-fallback path already fixes. The usage-bearing chunks live on the underlying stream the wrapper delegates to. FallbackStreamWrapper now keeps that as _underlying_stream (the original model_response, repointed to the fallback stream on a mid-stream MidStreamFallbackError switch), and the billing helper falls back to it when the wrapper's own chunks are empty. Reading the underlying stream is also what makes the usage correct: CustomStreamWrapper stores a copy with usage into self.chunks but strips usage off the chunk it yields, so the wrapper only ever sees usage-stripped chunks. Reported and root-caused by @WellMaxWang on BerriAI#30630.
|
Deployed the latest commit and confirmed:
Checked Downstream effect: Given the usage-accounting caveat you raised for the chat-completions wrapper (mid-stream provider switch moves the live state to a different underlying object), I'd guess the same caveat applies here for Letting you decide whether this rides along with the fast-follow for the chat-completions fallback case or gets tracked separately — flagging it now since it's the same root pattern and you're already in this code. Happy to help repro/verify either way. |
…30630) * fix(proxy): bill partial usage when a streaming request is cancelled On a mid-stream client disconnect the stream never reaches normal completion: CancelledError / GeneratorExit are BaseException, so neither the success nor the failure logging path runs, and the assembled-response success logging that writes SpendLogs never fires. The tokens already produced upstream are billed by the provider but never recorded on the proxy, so spend undercounts by roughly the abort rate; the gap is invisible in SpendLogs and only shows up against provider invoices. In the shielded streaming cleanup, when a client disconnect is recorded, assemble the partial usage from the chunks received so far via stream_chunk_builder and dispatch success logging for it. dispatch_success_handlers de-dupes via has_dispatched_final_stream_success, so it is a no-op when normal completion already logged, and it only runs on the cancellation path (the exception path sets stream_completed and already emits a failure log). Reported and root-caused in production by @jingyu-lin. Complements #30522, which releases the budget reservation on the same cancellation path. * fix(proxy): close upstream stream before billing partial usage on disconnect Release the provider connection before running partial-usage success logging on a client disconnect, so slow or external success callbacks can no longer keep the upstream stream held open. aclose() only closes completion_stream and leaves response.chunks intact, so the partial billing still assembles usage from the chunks already received. --------- Co-authored-by: Bytechoreographer <Bytechoreographer@users.noreply.github.com>
…30630) * fix(proxy): bill partial usage when a streaming request is cancelled On a mid-stream client disconnect the stream never reaches normal completion: CancelledError / GeneratorExit are BaseException, so neither the success nor the failure logging path runs, and the assembled-response success logging that writes SpendLogs never fires. The tokens already produced upstream are billed by the provider but never recorded on the proxy, so spend undercounts by roughly the abort rate; the gap is invisible in SpendLogs and only shows up against provider invoices. In the shielded streaming cleanup, when a client disconnect is recorded, assemble the partial usage from the chunks received so far via stream_chunk_builder and dispatch success logging for it. dispatch_success_handlers de-dupes via has_dispatched_final_stream_success, so it is a no-op when normal completion already logged, and it only runs on the cancellation path (the exception path sets stream_completed and already emits a failure log). Reported and root-caused in production by @jingyu-lin. Complements #30522, which releases the budget reservation on the same cancellation path. * fix(proxy): close upstream stream before billing partial usage on disconnect Release the provider connection before running partial-usage success logging on a client disconnect, so slow or external success callbacks can no longer keep the upstream stream held open. aclose() only closes completion_stream and leaves response.chunks intact, so the partial billing still assembles usage from the chunks already received. --------- Co-authored-by: Bytechoreographer <Bytechoreographer@users.noreply.github.com>
Relevant issues
Follow-up to #30522. Raised by @jingyu-lin on that PR: when a streaming request is aborted mid-flight, the tokens already generated upstream are never written to SpendLogs.
Pre-Submission checklist
make test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewType
🐛 Bug Fix
Changes
On a mid-stream client disconnect the stream never reaches normal completion. CancelledError / GeneratorExit are BaseException, so neither the success nor the failure logging branch in the streaming generator runs, and the assembled-response success logging that builds usage via
stream_chunk_builderand writes SpendLogs never fires. The result is that the tokens already produced upstream are billed by the provider (Azure / OpenAI / Google) but never recorded on the proxy. Spend undercounts by roughly the abort rate, and the gap is invisible in SpendLogs; it only shows up when reconciling against provider invoices.This builds on the existing shielded streaming cleanup (
_finalize_streaming_generator_cleanup). When a client disconnect is recorded, it now assembles the partial usage from the chunks received so far (stream_chunk_builderoverresponse.chunks, the same builder normal completion uses) and dispatches success logging for it.dispatch_success_handlersalready de-dupes viahas_dispatched_final_stream_success, so this is a no-op when normal completion already logged. It only runs on the cancellation path; the exception path (e.g. provider timeout) setsstream_completedand already emits a failure log, so there is no double-logging.The billing runs after
response.aclose(), so the upstream provider connection is released before the partial-usage logging (and any post-call guardrails or external success callbacks) runs; a disconnecting client cannot keep the upstream stream held open while those callbacks finish.aclose()only closescompletion_streamand leavesresponse.chunksintact, so the partial usage is still assembled from the chunks already received.This is the billing-side half of the same root cause that #30522 addresses on the budget-reservation side. With this in place, the cost callback also fires on cancellation, so the reservation reconciles to the real partial cost rather than an estimate.
Scope / what is not covered here
The exception/timeout path still emits a failure log without partial usage (a separate, smaller gap). This PR targets the cancellation path, which is the dominant abort case.
Tests
tests/test_litellm/proxy/test_common_request_processing.py: dispatches the assembled partial usage on disconnect; is a no-op when normal completion already logged (has_dispatched_final_stream_success); is a no-op when no chunks were received; routes through the deferred guardrail path when post-call guardrails are active; swallows builder and dispatch failures so they cannot escape the shielded cleanup; closes the upstream stream before the billing dispatch. Removing the dispatch fails the first test, and reverting the close-before-bill order fails the ordering test.Screenshots / Proof of Fix
Live proxy run to be added (recommend validating SpendLogs against a real aborted stream).