feat(audio): stream OpenAI TTS through the proxy to cut time-to-first-audio - #33976
feat(audio): stream OpenAI TTS through the proxy to cut time-to-first-audio#33976TheCodeWrangler wants to merge 2 commits into
Conversation
Greptile SummaryThis PR adds incremental streaming for the proxy's
Confidence Score: 5/5Safe to merge — the change is additive, the SDK contract is unchanged, non-OpenAI providers are unaffected, and cost tracking is correctly wired before the stream is consumed. The streaming path is only activated by the proxy via stream_audio=True; litellm.speech() default is unchanged. The with_streaming_response context-manager lifecycle is handled correctly. Cost is computed from input characters before the iterator is touched, so budget enforcement is not bypassed. All added tests are mock-only and cover both content-type passthrough paths and the no-content-type fallback. Files Needing Attention: No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/types/llms/openai.py | Adds SpeechStreamingResponse NamedTuple with stream_iterator and headers fields; clean, minimal type definition that threads through the async/sync handler correctly |
| litellm/llms/openai/openai.py | Adds async_audio_speech_streaming and audio_speech_streaming that open upstream with with_streaming_response, read headers eagerly (load-bearing for content-type pass-through), and forward bytes via a generator whose finally closes the context manager on both happy and error paths |
| litellm/proxy/proxy_server.py | Sets stream_audio=True unconditionally for all speech requests (only reaches the OpenAI handler), then routes SpeechStreamingResponse directly into FastAPI StreamingResponse with provider's content-type; falls back to application/octet-stream when header is absent |
| litellm/litellm_core_utils/litellm_logging.py | Adds SpeechStreamingResponse to _is_recognized_call_type_for_logging so _process_hidden_params_and_response_cost runs and records cost from input characters before the stream is consumed — closing the budget-bypass gap correctly |
| litellm/main.py | Adds stream_format and stream_audio parameters to speech(); stream_audio is only forwarded to the OpenAI handler branch, not Azure/ElevenLabs/Vertex, preserving buffered behavior for all non-OpenAI providers |
| tests/test_litellm/litellm_core_utils/test_litellm_logging.py | New regression test proves SpeechStreamingResponse triggers cost logging without consuming the stream iterator, and that computed cost matches input_cost_per_character x character_count |
| tests/test_litellm/llms/openai/speech/test_openai_speech_streaming.py | Comprehensive mock-only tests covering async/sync streaming paths, error-path CM cleanup, content-type pass-through, stream_audio forwarding for openai-compatible providers, and SDK back-compat (buffered path unchanged) |
| tests/test_litellm/proxy/proxy_server/test_routes_audio.py | New proxy route tests cover SSE streaming, provider-ignored stream_format content-type pass-through, missing content-type fallback to application/octet-stream, and assertion that stream_audio (not stream) is set in the request data |
Reviews (7): Last reviewed commit: bd339f6 | Re-trigger Greptile
4722c78 to
2119812
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
PR overviewThis PR adds streaming support for OpenAI text-to-speech responses through the LiteLLM proxy to reduce time-to-first-audio. The changes affect the proxy server path that returns TTS audio as a streaming response. One security issue remains open after one prior issue was addressed. The remaining concern is that TTS streaming requests can release parallel-request limit slots too early, allowing an authenticated client to hold many long-lived streams and exceed configured concurrency limits. This creates a concrete resource-exhaustion risk against proxy and provider connections until stream lifecycle cleanup is tied to completion, cancellation, or failure. Open issues (1)
Fixed/addressed: 1 · PR risk: 6/10 |
299557a to
5b9dba2
Compare
5b9dba2 to
7286b36
Compare
2281aff to
11b49d9
Compare
11b49d9 to
5e46b43
Compare
| # returned (text/event-stream for speech.audio.delta frames, or audio/* when the | ||
| # provider ignored stream_format), so nothing is mislabeled. | ||
| if isinstance(response, SpeechStreamingResponse): | ||
| return StreamingResponse( |
There was a problem hiding this comment.
Medium: Parallel-request limit bypass
litellm.aspeech dispatches success callbacks before this iterator is consumed, and both parallel-request limiters release their max_parallel_requests slot from those callbacks. An authenticated client can therefore open many long-lived TTS streams after each request's headers arrive, exceeding its configured concurrency limit and consuming unbounded proxy and provider connections. Treat SpeechStreamingResponse as a streaming lifecycle and release the slot only after iterator completion, cancellation, or failure, while retaining the required TTS cost accounting.
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
|
@greptileai please re-review the latest head |
d56fbc5 to
12817b5
Compare
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
|
@mateo-berri would you be open to reviewing this when you get a chance? You own most of the recent audio/speech handler changes so you're the natural reviewer It makes the proxy I originally targeted |
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
12817b5 to
c29dfce
Compare
|
Rebased onto current litellm_internal_staging and re-targeted the PR base there; force-pushed a clean two-commit history. @greptileai please re-review the new head |
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
c29dfce to
09c26ed
Compare
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
09c26ed to
bd339f6
Compare
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
bd339f6 to
1bf0b6b
Compare
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
519dd32 to
ec2f663
Compare
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
|
Friendly bump on this one. It has been open a few weeks now, is rebased onto @mateo-berri you own most of the recent audio/speech handler work so you seem like the natural reviewer, though I am happy for anyone on the team to pick it up. It makes the proxy |
|
Rebased fresh onto the latest litellm_internal_staging, and it is green and mergeable. Small, isolated change: streams OpenAI TTS through the proxy so time-to-first-audio is the first chunk instead of full generation, fixing #33974. @yuneng-jiang could you take a look when you get a chance? The only red check is osv-scan, which flags the pre-existing h2 and js-yaml advisories in the base lockfiles, nothing from this PR touches dependencies |
…-audio The proxy /v1/audio/speech awaited the full clip (HttpxBinaryResponseContent) before sending anything, so time-to-first-audio equaled full generation time even though OpenAI streams /v1/audio/speech over chunked transfer for every request. A client reading incrementally (with_streaming_response) got no benefit through litellm. The OpenAI handler can now open the upstream with with_streaming_response and return a SpeechStreamingResponse whose iterator forwards the provider bytes as they arrive. The proxy asks for this on every speech request and forwards the frames labeled with the provider's actual content-type: audio/* for a normal request, or text/event-stream when the caller sets stream_format="sse" (OpenAI's speech.audio.delta frames). No hardcoded model list and no payload difference from calling OpenAI directly; a model that ignores stream_format (e.g. tts-1) just streams a correctly-labeled audio clip. Applies to any openai-compatible provider routed through the OpenAI handler (hosted_vllm, etc); other providers keep returning a buffered response. The streaming toggle is an internal stream_audio flag set by the proxy, kept distinct from "stream" so it does not trip _is_streaming_request and skip cost tracking. litellm.speech()/aspeech() still return the buffered HttpxBinaryResponseContent by default (stream_audio defaults False), so the SDK contract is unchanged. SpeechStreamingResponse is recognized in the success-logging path so streaming TTS records response_cost from the input characters exactly like the buffered path, closing a budget-bypass gap. Fixes BerriAI#33974
…ith no Content-Type Greptile review of BerriAI#33976: the proxy's streaming media_type fell back to text/event-stream when the provider omitted Content-Type, which would mislabel raw audio bytes as SSE for openai-compatible backends that stream without the header. Fall back to application/octet-stream instead; the provider's header is still forwarded verbatim when present (OpenAI's text/event-stream for speech.audio.delta, or audio/* when stream_format is ignored). Adds a proxy test pinning the no-Content-Type fallback.
ec2f663 to
262b877
Compare
Relevant issues
Fixes #33974
Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Captured end-to-end against a live proxy hitting the real OpenAI API (gpt-4o-mini-tts). The key metric is time-to-first-byte (TTFB) vs total: streaming means first audio arrives well before generation finishes
Plain request, no stream_format (the common case, matching OpenAI's with_streaming_response examples). Before, at 1ebf2a7 the proxy buffered the whole clip; after 7286b36 it streams incrementally, matching a direct call to OpenAI
stream_format="sse" request, gpt-4o-mini-tts; frames arrive incrementally as text/event-stream
A model that ignores stream_format (tts-1) is not mislabeled; the proxy forwards the provider's content-type
Type
🆕 New Feature
Changes
The proxy /v1/audio/speech awaited the full clip (HttpxBinaryResponseContent) before sending anything, so time-to-first-audio equaled full-generation time. OpenAI's /v1/audio/speech actually streams over chunked transfer for every request (verified: gpt-4o-mini-tts pcm returns first byte at ~0.55s of a ~2.9s clip), so a client reading incrementally got no benefit through litellm
The OpenAI handler can now open the upstream with with_streaming_response and return a SpeechStreamingResponse whose iterator forwards the provider bytes as they arrive. The proxy asks for this on every speech request and forwards the frames labeled with the provider's actual content-type: audio/* for a normal request, or text/event-stream when the caller sets stream_format="sse" (OpenAI's speech.audio.delta frames). There is no hardcoded model list and no payload difference from calling OpenAI directly; a model that ignores stream_format (e.g. tts-1) just streams a correctly-labeled audio clip. This applies to any openai-compatible provider routed through the OpenAI handler (hosted_vllm, etc); other providers keep returning a buffered response
The streaming toggle is an internal stream_audio flag set by the proxy, deliberately kept distinct from "stream" so it does not trip _is_streaming_request and skip cost tracking. litellm.speech()/aspeech() still return the buffered HttpxBinaryResponseContent by default (stream_audio defaults False), so the SDK contract is unchanged. SpeechStreamingResponse is recognized in the success-logging path so streaming TTS records response_cost from the input characters exactly like the buffered path, closing a budget-bypass gap
Final Attestation