Skip to content

fix(openai): read TTS response by content-type, not model name - #6930

Open
abidullahdev wants to merge 1 commit into
livekit:mainfrom
abidullahdev:fix/openai-tts-content-type-parser
Open

fix(openai): read TTS response by content-type, not model name#6930
abidullahdev wants to merge 1 commit into
livekit:mainfrom
abidullahdev:fix/openai-tts-content-type-parser

Conversation

@abidullahdev

Copy link
Copy Markdown

Pointing the OpenAI TTS plugin at an OpenAI-compatible endpoint produces no audio. The request succeeds, the provider bills it, and the turn fails with no audio frames were pushed. This has been reported twice on the forum without a root cause, most recently yesterday, so I dug into it.

What happens

synthesize() picks the response parser from the model name:

AUDIO_STREAM_MODELS = {"tts-1", "tts-1-hd"}

if self._opts.model in AUDIO_STREAM_MODELS:
    return AudioChunkedStream(...)
return SSEChunkedStream(...)

Anything outside those two names gets SSEChunkedStream, which sends stream_format="sse" and reads only lines beginning with data: . But stream_format is an OpenAI extension. A compatible server ignores the unknown field and replies with the audio bytes of response_format, so no line ever matches and push() is never called.

pushed_duration() is then 0, which raises APIError("no audio frames were pushed"). That error is retryable and max_retry defaults to 3, so the provider is called four times per utterance. Every call returns a valid 200 with real audio in the body, which is why this reads as a provider problem rather than a plugin one.

The fix

Parse the body according to the response Content-Type instead of the model name. text/event-stream is read as SSE, anything else as raw audio bytes. Since both branches now share one request and one error path, the two near-identical stream classes collapse into a single ChunkedStream.

The request side is untouched: tts-1/tts-1-hd still ask for stream_format="audio", everything else still asks for "sse". Existing OpenAI users send byte-identical requests and take the same parsing path as before. The only behavioural difference is that a non-SSE response is now decoded rather than discarded.

@darryncampbell — on the forum I said I'd omit stream_format for unrecognised models. I dropped that idea while writing this. gpt-4o-mini-tts-2025-12-15 isn't in either allowlist, and omitting the field would route it to the raw-audio branch, which is the only branch that doesn't call _set_token_usage(). Usage metrics would silently fall to zero for it and for every future model snapshot, with nothing to flag it. Content-Type alone fixes the reported bug and doesn't rot as the model catalogue grows. Happy to add the request-side change separately if you'd still like it.

AudioChunkedStream and SSEChunkedStream are gone rather than aliased. Neither was in __all__ and nothing in the repo imports them.

Reproducing

from livekit.plugins.openai import TTS

tts = TTS(
    base_url="https://api.deepinfra.com/v1",
    api_key="<key>",
    model="hexgrad/Kokoro-82M",
    voice="af_bella",
)

Any compatible server does the same: DeepInfra, Kokoro-FastAPI, vLLM, LM Studio, Speaches. curl against the same endpoint returns valid audio.

Tests

tests/test_plugin_openai_tts.py drives the public TTS API over httpx.MockTransport. Three of the six fail on main with the exact error from the reports. Serving the repo's own tests/long.mp3 as audio/mpeg decodes to 46.6s of audio with this change and raises no audio frames were pushed without it.

Prior reports

@abidullahdev
abidullahdev requested a review from a team as a code owner August 20, 2026 20:27
@CLAassistant

CLAassistant commented Aug 20, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@longcw longcw left a comment

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.

looks good to me! something nit:

input_tokens=input_tokens,
output_tokens=output_tokens,
)
media_type = stream.headers.get("content-type", "").split(";")[0].strip().lower()

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.

nit: maybe move the output_emitter.initialize() here and initialize with read mine_type, e.g.

media_type = stream.headers.get("content-type", "").split(";")[0].strip().lower()
# a server that ignored response_format still declares what it sent
mime_type = (
    media_type if media_type in DECODABLE_CONTENT_TYPES else f"audio/{self._opts.response_format}"
)
output_emitter.initialize(..., mime_type=mime_type)

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