feat: Add content_audio_file() for audio input, and fix a Gemini audio-response crash - #346
Open
cpsievert wants to merge 8 commits into
Open
feat: Add content_audio_file() for audio input, and fix a Gemini audio-response crash#346cpsievert wants to merge 8 commits into
content_audio_file() for audio input, and fix a Gemini audio-response crash#346cpsievert wants to merge 8 commits into
Conversation
…line Gemini TTS/native-audio responses return inline_data with an audio/* mime type (e.g. "audio/pcm;rate=24000"). That mime type was being cast directly into ContentImageInline.image_content_type, which only accepts the four image literals -- raising a pydantic ValidationError today, and previously (per the type: ignore) silently mislabeling audio as an image content type that then gets replayed as an image on the next turn. Only build ContentImageInline when the mime type is actually one of the accepted image types; anything else (audio, video, ...) is left unmodeled for now rather than misrepresented.
Introduces audio as its own content type/helper, mirroring the
existing ContentPDF/ContentImageInline pattern rather than a generic
catch-all file type -- chat providers model audio input very
differently from documents (e.g. OpenAI's input_audio part carries
only {data, format}, no filename/mime_type/url).
ContentAudio.mime_type is a plain str rather than a Literal: Gemini's
own SDK/tests use provider-generated MIME types like
"audio/pcm;rate=16000" for audio it produces, which fall outside the
six formats (wav/mp3/aiff/aac/ogg/flac) chatlas validates for
user-supplied files via content_audio_file(). Restricting the field
itself to that Literal would repeat the exact bug just fixed in
_provider_google.py (a narrow input-only Literal reused to describe
provider-echoed output).
- Input: ContentAudio -> Blob(inline_data) in _as_part_type, same shape already used for ContentPDF (Gemini accepts wav/mp3/aiff/aac/ogg/flac inline). - Output: audio/* inline_data (e.g. Gemini TTS/native-audio) is now represented as ContentAudio instead of being silently dropped. - Round-trip: sending a previously-received ContentAudio back to Gemini (e.g. "audio/pcm;rate=24000") was verified live against the API -- it does not reject non-standard mime types on the way in, so no extra guard is needed for replaying model-generated audio. Adds assert_audio_local() to conftest.py (shared with the OpenAICompletions test) plus a VCR cassette recorded against the live Gemini API with a tiny synthesized tone.wav fixture.
- Input: ContentAudio -> {"type": "input_audio", "input_audio": {data,
format}}. Chat Completions only accepts wav/mp3 (unlike Gemini's six
formats), so unsupported mime types raise a clear ValueError instead
of an opaque 400 from the API.
- Output: message.audio.transcript (gpt-audio's spoken responses) is
now surfaced as ContentText. The audio bytes themselves
(message.audio.data) are intentionally NOT represented yet:
ChatCompletionAudio carries no format/mime type at all -- only the
request-side `audio: {voice, format}` param specifies that, which
chatlas doesn't yet expose (see CHANGELOG). Without it there's no
reliable mime type to attach to the bytes, so surfacing the
transcript is the safe, immediately useful subset.
Recorded a VCR cassette against gpt-audio-mini (the default
ChatOpenAICompletions model doesn't accept input_audio).
Anthropic has no audio input at all; the OpenAI Responses API doesn't support it either (only the Chat Completions API does, via ChatOpenAICompletions). Add explicit ContentAudio branches so passing audio to either raises a NotImplementedError pointing at a provider that does support it, rather than falling through to the generic "Unknown content type" ValueError.
Add an audio-input example + provider support caveat to docs/get-started/chat.qmd, register content_audio_file()/ContentAudio/ AudioContentTypes in the quartodoc reference nav, and record the Gemini mislabel fix, audio input, and OpenAI transcript passthrough in CHANGELOG.md.
# Conflicts: # CHANGELOG.md # chatlas/__init__.py # chatlas/_content.py # chatlas/_provider_openai_completions.py # docs/_quarto.yml # docs/_sidebar.yml # docs/get-started/chat.qmd # tests/conftest.py # tests/test_provider_anthropic.py # tests/test_provider_google.py # tests/test_provider_openai.py # tests/test_provider_openai_completions.py
content_audio_file() for audio input, and fix a Gemini audio-response crash
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You can now hand an audio file to a chat the same way you already hand it an image or a PDF:
Previously there was no inline audio path at all. The closest workaround was the Files API:
That works on Gemini, but it's the wrong tool for a short clip. It's a two-step round trip that returns a provider-scoped id rather than data — so it expires, it isn't replayable on another provider, and a turn containing one can't be handed to a different chat. It's also undiscoverable as an audio feature, and it does nothing for
ChatOpenAICompletions(), where audio has to arrive as aninput_audiomessage part rather than a file reference.Meeting summarization, call analysis, and voice notes now work directly. Gemini accepts up to 9.5 hours of audio per prompt at 32 tokens/second.
What each provider accepts
Audio support is narrower and more uneven than image support, so
content_audio_file()sends what the provider can take and raises a specific, actionable error otherwise — rather than letting the API reject the request.ChatGoogle()/ChatVertex()ChatOpenAICompletions()ChatOpenAI()ChatAnthropic().wav,.mp3gpt-audio-mini).aiff,.aac,.ogg,.flacContentAudioThe wav/mp3 ceiling on Chat Completions is OpenAI's, not ours: its
input_audiopart accepts exactly those two formats, against Gemini's six.It also fixes a crash
ChatGoogle()raisedpydantic.ValidationErroron any response containing audio._as_turn()passed whateverinline_data.mime_typeGemini returned straight intoContentImageInline.image_content_type, which is an image-onlyLiteral. Gemini's TTS and native-audio responses return things likeaudio/pcm;rate=24000, so construction failed outright. A# type: ignorehid it from the type checker while pydantic still enforced the literal at runtime — which is exactly how it shipped. Reachable today viakwargs=, no new feature required.That fix is isolated as the first commit (
379b22a) and stands on its own if you'd rather take it separately.Notes for review
ContentAudio.mime_typeis a plainstr, not aLiteral. This is deliberate: aLiteralis what caused the crash above, because a provider's output MIME types aren't drawn from the same set as its accepted input formats, and Gemini's carry parameters (;rate=24000) that would break exact matching anyway. Input validation lives incontent_audio_file()'s signature, where typos are caught statically, while the model field stays honest about what a provider actually sent.Audio output is represented for Gemini only. OpenAI's
ChatCompletionAudiocarries{id, data, expires_at, transcript}with no format field — the format is only ever specified on the request, which chatlas doesn't expose yet, so there's no reliable MIME type to attach to the bytes. Those are deferred (noted in the CHANGELOG). Thetranscriptis surfaced as text, though, sinceChatCompletionMessage.contentisNonewhen audio is requested and the turn would otherwise come back completely empty.Requesting audio output is intentionally not here. The
modalities/voice/formatsubmit params are a parameter surface, not a content-model change, with their own questions about streaming and turn rendering.Round-trip was checked against the live API. Gemini accepts a replayed
audio/pcm;rate=24000as input, so a conversation that produced audio survives its next turn.Verified after merging
main:pyrightclean,ruffclean, 203 tests passing across the content and provider-dispatch suites, plus two live-recorded VCR cassettes (Gemini inline audio, andgpt-audio-miniaudio input). The fixture is a generated 3.2 KB / 200 ms tone rather than a real recording, so the assertion is just "tone, not speech".ellmer parity
New capability, not a port — ellmer has no audio content of any kind today:
content_image_*andcontent_pdf_*are the only file inputs; theContentclasses are Text, Image, ToolRequest, ToolResult, Json, Uploaded, Thinking, PDF.google_upload()has an audio MIME table (R/provider-google-upload.R:202-208— mp3, wav, ogg, m4a, flac, aac). That's the analogue ofchat.files.upload(), not inline audio.If ellmer wants parity:
ContentAudio+content_audio_file(). An S7 class alongsideContentPDFcarryingdataandmime_type, with the same per-provideras_json()dispatch:inlineDatafor Gemini, aninput_audiopart for OpenAI, and a "use Gemini or OpenAI" error for Anthropic. Note the OpenAI half is easier there than here:ellmer::chat_openai()is already the Completions API, which is the one that takes audio, whereaschatlas.ChatOpenAI()defaults to Responses and can't.google_upload()'s MIME table is a starting point, but it lacksaiffand includesm4a, which Gemini's inline-audio docs don't list.match.arg()enum — same reasoning as the crash above. Validate the input extension incontent_audio_file(); leave the field itself permissive so a provider-generatedaudio/pcm;rate=24000round-trips.No ellmer issue tracks audio input (searched audio, wav, mp3), so that part would be new.
Landing order
Part of a three-PR set (documents, audio, video). All three touch
ContentTypeEnum,ContentUnion, andcreate_content(). #345 has since landed and this branch is merged up to currentmain, so there's nothing left to sequence here — #347 (video) is the remaining sibling.