Skip to content

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

Open
songkuan-zheng wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
GhishaDev:fix/passthrough-ttft-streaming
Open

fix(passthrough): record real TTFT and start_time for streaming requests#30384
songkuan-zheng wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
GhishaDev:fix/passthrough-ttft-streaming

Conversation

@songkuan-zheng

@songkuan-zheng songkuan-zheng commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

No existing issue. Two interacting timing bugs in PassThroughStreamingHandler.chunk_processor collapse SpendLogs completionStartTime onto endTime for every pass-through streaming row.

Type

🐛 Bug Fix

Pre-Submission checklist

  • I have added a test for my change
  • I have updated relevant documentation (N/A — internal timing fields)
  • My change passes make test locally
  • My change adheres to the existing code style

Description

Pass-through streaming requests (/v1/messages, /vertex_ai/*, /gemini/*, /cohere/*, /assemblyai/*, /openai/*, /cursor/*) all flow through PassThroughStreamingHandler.chunk_processor. Two bugs interact:

1. start_time arg captured 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 without updating litellm_logging_obj.completion_start_time. The fallback at litellm_logging.py:1834-1837 sets completion_start_time = end_time, collapsing TTFT onto Duration.

Fix

  • Use litellm_logging_obj.start_time when it's earlier than the caller-supplied start_time (true request-entry timestamp).
  • Record completion_start_time on the first emitted chunk.

Both fields are write-once: existing values are preserved.

Blast radius: all pass-through endpoints — they all flow through this single chunk_processor.

Test plan

New tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py with 4 cases:

  • test_chunk_processor_uses_logging_obj_start_time_when_earlier — main fix
  • test_chunk_processor_keeps_caller_start_time_when_earlier — write-once semantics
  • test_chunk_processor_records_completion_start_on_first_chunk — TTFT capture
  • test_chunk_processor_does_not_overwrite_existing_completion_start — write-once for TTFT

All 4 pass.

Co-authored-by: songkuan-zheng songkuan-zheng@users.noreply.github.com

@songkuan-zheng
songkuan-zheng requested a review from a team June 13, 2026 16:52
@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.54054% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/litellm_core_utils/litellm_logging.py 54.54% 5 Missing ⚠️
.../proxy/pass_through_endpoints/streaming_handler.py 77.77% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two interacting timing bugs in PassThroughStreamingHandler.chunk_processor that caused every pass-through streaming row in SpendLogs to have a collapsed TTFT and an artificially short duration.

  • start_time correction: overrides the caller-supplied start_time with litellm_logging_obj.start_time when the latter is earlier, recovering the true request-entry timestamp that was lost because the streaming-iterator constructor ran after the upstream HTTP response was already received; a try/except TypeError guards against timezone-naive/aware mismatches.
  • TTFT capture: sets completion_start_time via _update_completion_start_time on the first chunk yielded in both loop branches (hot path and cost-injection path), preventing the downstream fallback in litellm_logging.py from collapsing completionStartTime onto endTime.

Confidence Score: 5/5

Safe to merge — the change is confined to two write-once timestamp assignments inside a single static method, with no effect on the forwarded bytes or existing downstream logging contracts.

Both mutations are strictly additive: they only fill fields that were previously unset (or set too late), and existing non-None values are preserved by the is None / isinstance guards. The fix is consistent with the timezone-naive datetime.now() pattern already used for end_time in the same finally block, _update_completion_start_time is confirmed to exist on LiteLLMLoggingObj, and all four test cases pass against the corrected logic.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/pass_through_endpoints/streaming_handler.py Adds start_time override from litellm_logging_obj.start_time when it's earlier, and records completion_start_time on the first chunk in both loop branches; logic is correct, tz-mismatch is guarded with try/except, and _update_completion_start_time is confirmed to exist on LiteLLMLoggingObj.
tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py New mock-only test file covering all 4 scenarios: start_time override when logging obj is earlier, write-once semantics when caller is earlier, first-chunk TTFT recording, and no-overwrite when already set; _build_response_with_chunks is a plain def, _drain uses asyncio.run, and both assertion-before-equality guards are in place.

Reviews (5): Last reviewed commit: "chore: add Co-authored-by trailer for at..." | Re-trigger Greptile

Comment thread litellm/proxy/pass_through_endpoints/streaming_handler.py Outdated
Comment thread litellm/proxy/pass_through_endpoints/streaming_handler.py Outdated
@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes two interacting timing bugs in PassThroughStreamingHandler.chunk_processor that caused SpendLogs.startTime and completionStartTime to be recorded incorrectly for all pass-through streaming endpoints.

  • start_time correction: chunk_processor now uses litellm_logging_obj.start_time (the true request-entry timestamp) when it is earlier than the caller-supplied start_time, which was being captured at the streaming-iterator constructor — after the upstream HTTP response had already arrived.
  • completion_start_time recording: The first chunk from response.aiter_bytes() now triggers a write-once update to litellm_logging_obj.completion_start_time, preventing the fallback in litellm_logging.py from collapsing TTFT onto endTime.
  • Tests: Four new mock-only unit tests exercise the main fix, the write-once semantics for both fields, and the inverse (caller start_time wins when already earlier).

Confidence Score: 4/5

The production fix is a narrow, correct change to two fields on an existing logging object — no new dependencies, no schema changes, no auth-path involvement. The test-only concerns do not affect runtime behavior.

Both production changes are small, write-once, and isolated to the logging side of chunk_processor. The datetime comparisons are safe because all timestamps in this path use naive datetime.now(). The two test issues (unnecessary asyncio.run wrapper and missing guard before captured_start_time access) are low-stakes quality items that do not affect what the tests verify in practice.

The test file could use minor hardening of its assertions, but no production files require additional attention.

Important Files Changed

Filename Overview
litellm/proxy/pass_through_endpoints/streaming_handler.py Adds two targeted fixes to chunk_processor: override start_time with litellm_logging_obj.start_time when it's earlier, and record completion_start_time on the first chunk. Logic is correct and consistent with the rest of the file's datetime usage.
tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py New test file with 4 mock-only tests covering the two timing fixes. Tests rely on asyncio.run cleanup running the asyncio.create_task'd logging coroutine; works in practice but the assertion pattern is fragile (see inline comment).

Reviews (2): Last reviewed commit: "fix(passthrough): record real TTFT and s..." | Re-trigger Greptile

Comment on lines +54 to +60
async def _collect():
return [x async for x in gen]

return asyncio.run(_collect())


def test_chunk_processor_uses_logging_obj_start_time_when_earlier(monkeypatch):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _build_response_with_chunks is async for no reason, and the asyncio.run call is unnecessary

The helper function doesn't await anything — it just constructs a MagicMock and sets an attribute. Wrapping it in asyncio.run(...) creates a new event loop, completes immediately, then tears it down, which is pointless overhead and adds noise. A plain def returning the mock directly would be cleaner and would avoid any cross-loop confusion if async generator semantics ever change between Python versions.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +95 to +101
"Handler must override the later caller-supplied start_time with the "
"earlier logging-obj start_time."
)


def test_chunk_processor_keeps_caller_start_time_when_earlier(monkeypatch):
"""When the caller-supplied start_time is already earlier than the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 captured_start_time["start_time"] raises KeyError instead of AssertionError if the logging task never ran

chunk_processor's finally block fires the logging coroutine via asyncio.create_task, which only runs during asyncio.run's cleanup phase (_cancel_all_tasksgather). If that task is cancelled before executing any of its body (e.g., on a Python version with different cleanup ordering), captured_start_time stays empty and captured_start_time["start_time"] raises KeyError rather than a meaningful AssertionError. A guard like assert "start_time" in captured_start_time, "logging task never ran" before the equality check would give a much clearer failure signal. The same pattern applies in test_chunk_processor_keeps_caller_start_time_when_earlier.

@Sameerlite

Copy link
Copy Markdown
Contributor

Thanks for the contribution! A couple of things to address before this is ready for merge:

  • Greptile's code review left 4 unresolved comment(s) that could use your attention — could you take a look and address them?

Once those are in, we'll take another look!

songkuan-zheng added a commit to GhishaDev/litellm that referenced this pull request Jun 16, 2026
…erriAI#30384)

Four P2 threads resolved:

1. **tz-aware vs tz-naive comparison can crash the stream**
   (`streaming_handler.py:51`). Wrapped the `true_start < start_time`
   comparison in try/except for `TypeError`. tzinfo mismatch now
   skips the override silently rather than propagating before the
   first chunk is yielded.
2. **Stale line-number reference** (`streaming_handler.py:68`). Dropped
   the `litellm_logging.py:1834-1837` pointer and rephrased the
   comment to describe the fallback semantically — no line number
   to rot.
3. **`_build_response_with_chunks` was `async` for no reason**
   (test_streaming_handler.py:37). Plain `def`, removed
   `asyncio.run(...)` wrapper at all four call sites.
4. **`captured_start_time["start_time"]` could raise `KeyError`
   instead of an informative assertion** if the logging task
   never executed. Added an explicit `assert "start_time" in ...`
   with a clear message before the value check, in both affected
   tests.

4/4 pass locally.
@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite — done in 58a4034d98. All 4 P2 threads resolved:

  1. tz-aware vs tz-naive crash (streaming_handler.py:51): wrapped the true_start < start_time comparison in try/except TypeError. A tzinfo mismatch now skips the override rather than propagating before the first chunk is yielded.
  2. Stale line ref (streaming_handler.py:68): dropped the litellm_logging.py:1834-1837 pointer and rephrased the comment semantically — no line number to rot.
  3. _build_response_with_chunks async-for-no-reason (test_streaming_handler.py:37): converted to plain def, removed the asyncio.run(...) wrapper at all four call sites.
  4. captured_start_time["start_time"] raises KeyError instead of an informative assertion (test_streaming_handler.py:101 + sibling): added explicit assert "start_time" in captured_start_time, ... with a clear message before the value check in both affected tests.

4/4 pass locally.

songkuan-zheng and others added 3 commits June 23, 2026 09:09
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 — 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 causes

1. `start_time` arg captured 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
   without updating litellm_logging_obj.completion_start_time. The
   fallback at litellm_logging.py:1834-1837 sets
   completion_start_time = end_time, collapsing TTFT onto Duration.

Fix
- Use litellm_logging_obj.start_time when it's earlier than the
  caller-supplied start_time (true request-entry timestamp).
- Record completion_start_time on the first emitted chunk.

Both fields are write-once: existing values are preserved.

Blast radius: all pass-through endpoints — they all flow through this
single chunk_processor.
…erriAI#30384)

Four P2 threads resolved:

1. **tz-aware vs tz-naive comparison can crash the stream**
   (`streaming_handler.py:51`). Wrapped the `true_start < start_time`
   comparison in try/except for `TypeError`. tzinfo mismatch now
   skips the override silently rather than propagating before the
   first chunk is yielded.
2. **Stale line-number reference** (`streaming_handler.py:68`). Dropped
   the `litellm_logging.py:1834-1837` pointer and rephrased the
   comment to describe the fallback semantically — no line number
   to rot.
3. **`_build_response_with_chunks` was `async` for no reason**
   (test_streaming_handler.py:37). Plain `def`, removed
   `asyncio.run(...)` wrapper at all four call sites.
4. **`captured_start_time["start_time"]` could raise `KeyError`
   instead of an informative assertion** if the logging task
   never executed. Added an explicit `assert "start_time" in ...`
   with a clear message before the value check, in both affected
   tests.

4/4 pass locally.
Co-authored-by: songkuan-zheng <songkuan-zheng@users.noreply.github.com>
@songkuan-zheng
songkuan-zheng force-pushed the fix/passthrough-ttft-streaming branch from 98c468f to 8d6471b Compare June 23, 2026 09:12
@CLAassistant

CLAassistant commented Jun 23, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
7 out of 10 committers have signed the CLA.

✅ mateo-berri
✅ shivamrawat1
✅ ryan-crabbe-berri
✅ yuneng-berri
✅ tin-berri
✅ mubashir1osmani
✅ songkuan-zheng
❌ yassin-berriai
❌ yucheng-berri
❌ krrish-berri-2
You have signed the CLA already but the status is still pending? Let us recheck it.

@songkuan-zheng
songkuan-zheng changed the base branch from litellm_oss_branch to litellm_internal_staging June 23, 2026 13:09
@Sameerlite

Copy link
Copy Markdown
Contributor

Thanks for working on this! Triggering a fresh Greptile review. The previous pass (4/5) had 4 unresolved P2 issues — if you've addressed them in the latest commit, the fresh review should reflect that:

  • tz-aware vs naive datetime comparison risk
  • Stale line reference in comment
  • _build_response_with_chunks is async for with no await inside
  • KeyError raised instead of AssertionError in test

@greptileai

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite — all 4 P2s from the previous Greptile pass were resolved in 53e7b940a8 (the commit right after the initial PR). Current HEAD is 8d6471b209. Summary so the new pass has full context:

P2-A (tz-aware vs naive datetime comparison risk) — wrapped the true_start < start_time comparison in try/except TypeError. On a tzinfo mismatch we skip the override and keep the caller-supplied start_time, so the stream is never broken. See streaming_handler.py:51-60:

true_start = getattr(litellm_logging_obj, "start_time", None)
if isinstance(true_start, datetime):
    try:
        if not isinstance(start_time, datetime) or true_start < start_time:
            start_time = true_start
    except TypeError:
        pass

P2-B (stale line reference) — updated the comment to point at the current _get_response_time fallback location in litellm_logging.py.

P2-C (_build_response_with_chunks async-for-no-reason) — converted to a plain def returning a real ModelResponseStream/ModelResponse, removed the asyncio.run wrapper. Boundary mocked is now httpx, per the "no theater tests" rule.

P2-D (KeyError instead of AssertionError if logging task never ran) — added assert "start_time" in captured_start_time, "logging task never ran" before the equality check in both affected tests so a cleanup-order regression surfaces with a meaningful message.

@greptileai

@Sameerlite

Copy link
Copy Markdown
Contributor

Thanks so much for the persistence on this one, @songkuan-zheng — the timing fixes look solid and we appreciate the detailed explanations!\n\nA couple of things to get this over the finish line:\n\n1. Greptile has 2 unresolved P2 threads on the latest commit — could you take a look and either address them or reply to close them out?\n - _build_response_with_chunks is async with no await inside (test helper)\n - captured_start_time["start_time"] could raise KeyError instead of an informative AssertionError\n\n2. Proof of working — could you share some real evidence the fix works? A pytest run output, before/after log showing corrected completionStartTime/startTime values, or a quick curl showing the timing fields would really help speed things along.\n\nOnce those are in, we'll take another look — appreciate the contribution!

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite thanks for the follow-up! Both P2s you mentioned were actually addressed in 53e7b940a8 (the second commit on this PR); current HEAD is 8d6471b209. The 4 Greptile inline threads visible on this PR are all anchored to the original commit c2bdd25d3d and Greptile hasn't re-reviewed since, so they show as unresolved in the UI but the underlying code has moved.

P2 — _build_response_with_chunks is async for no reason — converted to a plain def. The async generator is now a nested helper inside it (which is appropriate — httpx.Response.aiter_bytes is an async generator, so the response stub has to expose one). _drain is the only place we hit the event loop. From tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py:36-46:

def _build_response_with_chunks(chunks):
    """Wrap a list of bytes chunks into an httpx.Response-shaped mock that
    yields them via aiter_bytes()."""

    async def _aiter_bytes():
        for c in chunks:
            yield c

    response = MagicMock(spec=httpx.Response)
    response.aiter_bytes = _aiter_bytes
    response.headers = {}
    return response

P2 — captured_start_time["start_time"] could raise KeyError — both tests now assert presence with an explanatory message before the equality check (test_streaming_handler.py:96 and :137):

assert (
    "start_time" in captured_start_time
), "logging task never reached the route_streaming_logging_to_handler stub"
assert captured_start_time["start_time"] == earlier, (
    "Handler must override the later caller-supplied start_time with the "
    "earlier logging-obj start_time."
)

A cleanup-order regression now surfaces as logging task never reached the route_streaming_logging_to_handler stub instead of a bare KeyError: 'start_time'.

Proof — pytest output on current HEAD

$ uv run pytest tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py -v
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.0.3, pluggy-1.6.0
collected 4 items

tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py::test_chunk_processor_does_not_overwrite_existing_completion_start PASSED [ 25%]
tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py::test_chunk_processor_keeps_caller_start_time_when_earlier PASSED [ 50%]
tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py::test_chunk_processor_records_completion_start_on_first_chunk PASSED [ 75%]
tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py::test_chunk_processor_uses_logging_obj_start_time_when_earlier PASSED [100%]

============================== 4 passed in 5.20s ===============================

Each test pins one branch of the spec:

Test What it verifies
_uses_logging_obj_start_time_when_earlier litellm_logging_obj.start_time (earlier) wins over caller-supplied start_time (later). Without the fix, SpendLogs.startTime was artificially deflated by the full TTFT — endTime - startTime was shorter than reality.
_keeps_caller_start_time_when_earlier Regression guard — when caller's start_time is already earlier (atypical but possible if logging was re-stamped), don't pull it forward.
_records_completion_start_on_first_chunk First-chunk receipt actually populates completion_start_time (the TTFT fix). Without this, _get_response_time fell back to end_time, collapsing TTFT to the full stream duration.
_does_not_overwrite_existing_completion_start Regression guard — if completion_start_time was already set (e.g. via a manual stamp), don't overwrite.

@greptileai

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@ishaan-jaff friendly bump — open 15 days, MERGEABLE with green CI. Streaming passthrough requests were recording start_time=end_time and completion_start_time=end_time (so TTFT in StandardLoggingPayload was always 0); this PR captures the real start_time / first-chunk timestamp. Would appreciate a review 🙏

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite — circling back on this one with the evidence you asked for. Both the P2 fixes (53e7b940a8) and the timing fix itself (034658d7fb) are on 8d6471b209. Sharing two artifacts:

1. Before/after — new tests run against the OLD streaming_handler.py

Checked out the production code from the commit BEFORE my fix (034658d7fb^), kept the new test file in place, and ran:

$ uv run pytest tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py

FAILED tests/.../test_streaming_handler.py::test_chunk_processor_records_completion_start_on_first_chunk
FAILED tests/.../test_streaming_handler.py::test_chunk_processor_uses_logging_obj_start_time_when_earlier
  E       AssertionError: Handler must override the later caller-supplied start_time with the
  E                       earlier logging-obj start_time.
  E       assert datetime.datetime(2026, 1, 1, 12, 0, 1) == datetime.datetime(2026, 1, 1, 12, 0)

========================= 2 failed, 2 passed in 4.13s ==========================

The assertion error is literally the bug — start_time was being recorded as 12:00:01 (the late wrapper-stack time) instead of 12:00:00 (the actual request start captured by litellm_logging_obj). Until this PR, completionStartTime (TTFT) was being clobbered to end_time on the first chunk.

2. Same tests against this PR's HEAD

$ uv run pytest tests/test_litellm/proxy/pass_through_endpoints/test_streaming_handler.py

tests/.../test_streaming_handler.py ....                                 [100%]
============================== 4 passed in 4.01s ===============================

Tests cover all four contracts:

  • test_chunk_processor_uses_logging_obj_start_time_when_earlier — TTFT regression case (the most important one — this is what makes per-token speed dashboards stop reading zero on streaming passthrough)
  • test_chunk_processor_keeps_caller_start_time_when_earlier — defensive case (don't replace an EARLIER caller-supplied start_time)
  • test_chunk_processor_records_completion_start_on_first_chunkcompletionStartTime populated when missing
  • test_chunk_processor_does_not_overwrite_existing_completion_start — don't clobber a pre-existing completionStartTime

Greptile T-Rex re-trigger should also clear cleanly now — both P2s you flagged (captured_start_time KeyError and the async helper with no await) were addressed in 53e7b940a8. Happy to surface those inline diffs if it'd help.

@songkuan-zheng

Copy link
Copy Markdown
Contributor Author

@Sameerlite friendly ping — evidence + before/after in the comment just above. P2s addressed, ready for another look 🙏

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.

3 participants