Skip to content

[P1] streaming chat commits 0 tokens — TPM caps & cost telemetry blind for streaming #108

Description

@moonming

Summary

In the streaming chat handler, reservation.commit_tokens(0) is called immediately after the upstream stream is initiated, and the response is built with prompt_tokens: None, completion_tokens: None, total_tokens: None. The current code's own comment acknowledges this: "Streaming path doesn't compute cost yet (no token totals until the stream completes upstream). Phase 2 wires…"

Net effect: TPM caps and per-request cost telemetry are silently disabled for all streaming traffic. In production, streaming is the majority of LLM traffic.

Severity

🚨 HIGH — TPM enforcement and cost reporting are blind for streaming. Customers see "everything green" even when they've burned through their budget.

Location

crates/aisix-proxy/src/chat.rs:383 (verified on origin/main):

let upstream = bridge
    .chat_stream(req, &ctx)
    .await
    .map_err(|e| with_model(ProxyError::Bridge(e)))?;
reservation.commit_tokens(0);   // ← streaming path commits ZERO tokens
let sse_stream = build_sse_stream(upstream, now);
let response = Sse::new(sse_stream)
    .keep_alive(KeepAlive::new().interval(Duration::from_secs(15)));
return Ok(Success {
    response: response.into_response(),
    provider: format!("{provider:?}").to_lowercase(),
    model_id: model_id.clone(),
    prompt_tokens: None,
    completion_tokens: None,
    total_tokens: None,
    // Streaming path doesn't compute cost yet (no token totals
    // until the stream completes upstream). Phase 2 wires…

For comparison, non-streaming path (chat.rs:621) is correct:

reservation.commit_tokens(total);   // ← actual token count

And cache-hit path (chat.rs:472) is correct by design:

reservation.commit_tokens(0);   // cache hit — by design, no upstream tokens consumed

So 2 of 3 paths in the same function are right. Only the streaming success path is broken.

Why this is risky

  1. TPM cap not enforced for streaming: a customer with TPM=10k can run continuous streaming and the limiter never sees it
  2. Cost reporting shows $0 for streaming: dashboard cost graphs miss the majority of usage
  3. Cache-hit-saved metrics are also 0: because total tokens is 0, "saved tokens" derivation is also 0
  4. Stripe billing under-charges: if Stripe charges based on Cloud-side aggregated cost, customers underpay for streaming

Recommended fix

Wire the SSE stream completion to commit actual tokens:

let upstream = bridge.chat_stream(req, &ctx).await...?;
let sse_stream = build_sse_stream_with_token_callback(upstream, now, {
    let reservation = reservation.clone();
    move |total_tokens| {
        reservation.commit_tokens(total_tokens);
    }
});

build_sse_stream already parses each SSE event — at the final data: [DONE] event (or end-of-stream), invoke the callback with the accumulated usage.total_tokens from the upstream's final usage frame.

For OpenAI: usage info comes in the last SSE event when stream_options: {include_usage: true} is set (which the gateway already sets unconditionally — see related issue #TBD on Anthropic Responses API breakage).

For Anthropic: message_delta events carry usage; final message_stop has the totals.

For providers that don't include usage in stream: estimate from completion tokens by tokenizer (cheaper than a non-streaming follow-up call).

Test case

test_streaming_chat_commits_actual_total_tokens_at_stream_end
test_streaming_chat_emits_nonzero_cost_telemetry
test_streaming_tpm_cap_enforced_after_total_tokens_exceed_limit
test_streaming_partial_response_on_client_disconnect_commits_partial_tokens

Source

Static scan, May 2026 (verified on origin/main:crates/aisix-proxy/src/chat.rs:383). Phase 2 work explicitly tagged in code comment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-value differentiatorbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions