Skip to content

fix(passthrough): record real TTFT and start_time for streaming requests - #5

Merged
songkuan-zheng merged 1 commit into
ship/v1.83.10from
fix/passthrough-ttft-streaming
May 18, 2026
Merged

fix(passthrough): record real TTFT and start_time for streaming requests#5
songkuan-zheng merged 1 commit into
ship/v1.83.10from
fix/passthrough-ttft-streaming

Conversation

@songkuan-zheng

@songkuan-zheng songkuan-zheng commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Streaming requests through any pass-through endpoint (/v1/messages,
/vertex_ai/*, /gemini/*, /cohere/*, …) logged completionStartTime
within ~1 ms of endTime in spend_logs — collapsing the streaming
phase to roughly zero and letting TTFT (time-to-first-token) effectively
soak up the entire request duration. Reported via the Anthropic
/v1/messages path; the root cause is in shared code so the fix
covers every pass-through endpoint.

Two interacting bugs

Both live in litellm/proxy/pass_through_endpoints/streaming_handler.py's
PassThroughStreamingHandler.chunk_processor:

Bug 1 — start_time arg is captured too late

The caller's start_time originates in
BaseAnthropicMessagesStreamingIterator.__init__, which runs after
the upstream HTTP response has already been received. So
SpendLogs.startTime reflects "moment we started reading the stream",
not "moment the client request entered the proxy". The real TTFT window
is silently subtracted from Duration.

Bug 2 — First-chunk arrival never recorded

The chunk loop yielded bytes to the client and collected them for
logging, but never noted when the first byte arrived. With
litellm_logging_obj.completion_start_time left as None, the fallback
at litellm_logging.py:1834-1837 sets it to end_time
completionStartTime lands within ~1 ms of endTime (clock resolution
noise, not literally identical), and streaming_phase rounds to 0.

The bugs hide each other

State Duration TTFT streaming_phase
Bug present 6528 ms 6527 ms 1 ms
Bug 2 fixed only 7698 ms 14 ms 7684 ms
Both fixed 8496 ms 2373 ms 6123 ms
Control (/v1/chat/completions, same model+prompt) 8143 ms 2071 ms 6072 ms

Numbers are from an Anthropic claude-sonnet-4-6 ~200-word streamed
completion against the e2e proxy. After the fix the pass-through path
is within ~5% of the transform path on the same upstream model — the
two should report the same streaming behavior because the underlying
HTTP request is the same.

Fix

At the top of chunk_processor's try block:

# Bug 1 fix: use the true request-entry timestamp
true_start = getattr(litellm_logging_obj, "start_time", None)
if isinstance(true_start, datetime) and (
    not isinstance(start_time, datetime) or true_start < start_time
):
    start_time = true_start

Inside the async for chunk in response.aiter_bytes(): loop, before
yielding to the client:

# Bug 2 fix: record first-chunk arrival
if litellm_logging_obj.completion_start_time is None:
    litellm_logging_obj._update_completion_start_time(
        completion_start_time=datetime.now()
    )

What changes

File Change
litellm/proxy/pass_through_endpoints/streaming_handler.py 13 lines added inside chunk_processor: start_time override + first-chunk recording
e2e/cases/13_passthrough_streaming_ttft.md New regression runbook
e2e/cases/data/13_passthrough_streaming_ttft.sh Real-provider fixture: 200-word stream → poll spend_logs up to 30s → assert streaming_phase > 1s, ttft > 300ms, ttft < duration/2
e2e/cases/README.md Index updated

Test plan

  • Case 13 GREEN on this branch
  • Cases 10, 11, 12 still GREEN (no regression in adjacent guards)
  • Black 24.10.0 formatting clean
  • Verified parity vs /v1/chat/completions transform path on same model

Blast radius

The shared chunk_processor is the streaming hot path for every
pass-through endpoint:

  • /v1/messages, /anthropic/* (Anthropic)
  • /vertex_ai/* (Vertex AI, both regular and live)
  • /gemini/* (Google AI Studio)
  • /cohere/* (Cohere)
  • /openai/* (OpenAI pass-through)
  • /assemblyai/* (AssemblyAI)
  • /cursor/* (Cursor)

The user noticed it via Anthropic. The same fix improves TTFT
observability for all the others in the same release.

Out of scope

  • The litellm.completion() / acompletion path (used by
    /v1/chat/completions) is unaffected. Its CustomStreamWrapper
    at litellm_core_utils/streaming_handler.py:1856 already calls
    _update_completion_start_time correctly.
  • The fallback at litellm_logging.py:1834-1837 is left in place — it
    still protects callers who fail to set completion_start_time for
    non-streaming reasons. Now nobody in this code path needs to rely on
    it.

@songkuan-zheng
songkuan-zheng force-pushed the fix/passthrough-ttft-streaming branch from 92da12f to f4e41ec Compare May 18, 2026 09:53
Pass-through streaming requests (/v1/messages, /vertex_ai/*, /gemini/*,
/cohere/*, /assemblyai/*, /openai/*, /cursor/*) all share
PassThroughStreamingHandler.chunk_processor, which had two timing bugs
that interacted to collapse spend_logs.completionStartTime onto
spend_logs.endTime (off by ~1ms of clock resolution, not literally
identical) — making the streaming phase (endTime - completionStartTime)
round to roughly zero and TTFT effectively soak up the entire request
duration for every pass-through streaming row.

Root cause

1. `start_time` arg too late. The caller's start_time originates in
   BaseAnthropicMessagesStreamingIterator.__init__, which runs AFTER
   the upstream HTTP response has already been received. SpendLogs.
   startTime therefore reflects "moment we started reading the
   stream", not "moment the client request entered the proxy" — the
   real TTFT window is silently subtracted from Duration.

2. First-chunk arrival never recorded. The chunk loop yielded bytes
   to the client and collected them for logging, but never noted when
   the first byte arrived. With litellm_logging_obj.completion_start_time
   left as None, the fallback at litellm_logging.py:1834-1837 sets it
   to end_time — completionStartTime lands within ~1ms of endTime and
   streaming_phase rounds to 0.

Both bugs hide each other. Fixing only #2 gives TTFT close to 0 with
Duration deflated by ~TTFT. Fixing only #1 leaves completionStartTime
still pinned to endTime. Both must be fixed for the math to be correct.

Fix

In chunk_processor, at the top of the try block:
  - Override start_time with litellm_logging_obj.start_time when the
    latter is an earlier datetime — that's the true request-entry
    timestamp set in common_request_processing.base_process_llm_request.
  - On the first chunk yielded by response.aiter_bytes(), call
    litellm_logging_obj._update_completion_start_time(datetime.now())
    to populate the field that downstream payload builders look for.

Verified end-to-end (Anthropic claude-sonnet-4-6, 200-word stream):

  Before fix:  Duration=6528ms TTFT=6527ms streaming_phase=1ms
  After fix:   Duration=8496ms TTFT=2373ms streaming_phase=6123ms
  Control:     /v1/chat/completions (same model+prompt)
               Duration=8143ms TTFT=2071ms streaming_phase=6072ms

Test plan
  - New e2e case 13 (`13_passthrough_streaming_ttft.md` + data/13_*.sh)
    sends a real ~200-word streamed completion through /v1/messages,
    polls spend_logs for up to 30s, asserts:
      * streaming_phase_ms > 1000   (catches bug #2 regression)
      * ttft_ms > 300              (catches bug #1 regression)
      * ttft_ms < duration_ms / 2  (catches either regression)
    Plus a soft parity check vs /v1/chat/completions for the same
    upstream model.
  - Cost ~$0.005 per case run.
  - GREEN against the fix; was RED before (streaming_phase=1ms,
    ttft=6527ms) — assertions 1 and 3 fired.

Blast radius: all pass-through endpoints — they all flow through this
single chunk_processor. User noticed the bug via Anthropic; the same
fix improves TTFT observability for Vertex AI, Gemini, Cohere, and
the other pass-through providers in the same release.
@songkuan-zheng
songkuan-zheng force-pushed the fix/passthrough-ttft-streaming branch from f4e41ec to 1f54c13 Compare May 18, 2026 09:57
@songkuan-zheng
songkuan-zheng merged commit e0fcd50 into ship/v1.83.10 May 18, 2026
1 check passed
@songkuan-zheng
songkuan-zheng deleted the fix/passthrough-ttft-streaming branch May 18, 2026 10:01
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.

1 participant