Skip to content

Commit b0bffc1

Browse files
committed
fix(openai-chat): read ogg, opus, flac, aac, aiff, webm and mpeg input_audio as their true media types
request_from_openai_chat refused every input_audio format but wav and mp3, before any provider was chosen, so a DSPy ogg/opus recording bound for Gemini failed at ingest although Gemini takes audio/ogg natively. Each format now reads as its true media type; an unknown format is still malformed, and the Chat Completions and Anthropic builders still refuse audio at send (MAP-10). The same change as the lm15-dev/lm15-python fix, on the line DSPy vendors; CONTRACT_PIN stays at b721ce4, whose MAP-12 rule 4 still lists wav and mp3. Signed-off-by: Jonas Dreyøe Herfort <jdreyoe@gmail.com>
1 parent 55fce2e commit b0bffc1

3 files changed

Lines changed: 38 additions & 3 deletions

File tree

‎docs/migrating-from-openai-chat.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ A key with no verdict at all is refused. Malformed input — a wrong JSON type,
501501
- The first row, when `system` or `developer`, becomes `Request.system`; a later `system` / `developer` row becomes a `developer` message at that position.
502502
- Consecutive `tool` rows become **one** tool message with one `ToolResultPart` per row (`tool_call_id` → `id`, `name` → `name`).
503503
- An assistant row's parts come out in a fixed order: `reasoning_content` as a `ThinkingPart`, then `content`, then `refusal`, then `tool_calls` with `arguments` parsed. `content: null` with nothing else is one empty text part (a message is never empty).
504-
- `text` → `TextPart`; `image_url` → `ImagePart` (a data URI becomes inline data; a URL stays a URL); `input_audio` → `AudioPart`; `file` → `DocumentPart`; `refusal` → `RefusalPart`.
504+
- `text` → `TextPart`; `image_url` → `ImagePart` (a data URI becomes inline data; a URL stays a URL); `input_audio` → `AudioPart` (its `format` read as the media type: `wav`, `mp3`/`mpeg`, `ogg`, `opus`, `flac`, `aac`, `aiff`, `webm`; the send raises where the wire cannot carry audio); `file` → `DocumentPart`; `refusal` → `RefusalPart`.
505505
- A `prompt_cache_breakpoint` on the system row is `prefix="stable"`; on the last text block of message *N* it is `prefix_until_index=N`.
506506

507507
### Where the round trip is not exact

‎lm15/providers/openai_chat.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,13 @@ def _response_format_to_chat(format_config: dict[str, Any]) -> dict[str, Any]:
246246

247247
_INGEST_GROQ_BUILTIN_INVERSE: dict[str, str] = {wire: name for name, wire in _GROQ_BUILTIN_MAP.items()}
248248

249-
_INGEST_AUDIO_MEDIA_TYPES: dict[str, str] = {"wav": "audio/wav", "mp3": "audio/mpeg"}
249+
# MAP-12 rule 4: OpenAI's server takes wav and mp3, Gemini's any audio type,
250+
# and DSPy writes the MIME subtype (mpeg for .mp3). Each format reads as its
251+
# true media type; a builder with no audio slot raises at send (MAP-10).
252+
_INGEST_AUDIO_MEDIA_TYPES: dict[str, str] = {
253+
"wav": "audio/wav", "mp3": "audio/mpeg", "mpeg": "audio/mpeg", "ogg": "audio/ogg", "opus": "audio/opus",
254+
"flac": "audio/flac", "aac": "audio/aac", "aiff": "audio/aiff", "webm": "audio/webm",
255+
}
250256

251257

252258
def _ingest_unsupported(provider: str, what: str, why: str) -> UnsupportedFeatureError:

‎tests/test_openai_chat_ingest.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
from lm15 import serde
2020
from lm15.compat import OpenAIChatCompat
2121
from lm15.errors import UnsupportedFeatureError
22-
from lm15.providers import OpenAIChatLM
22+
from lm15.providers import GeminiLM, OpenAIChatLM
2323
from lm15.providers.openai_chat import request_from_openai_chat
2424
from lm15.types import (
25+
AudioPart,
2526
CacheConfig,
2627
Config,
2728
FunctionTool,
@@ -241,6 +242,7 @@ def test_breakpoints_map_to_cache_config() -> None:
241242
({"model": "m", "messages": USER, "top_logprobs": 3}, ValueError),
242243
({"model": "m", "messages": USER, "tool_choice": {"type": "function", "function": {"name": "ghost"}}}, ValueError), # INV-031
243244
({"model": "m", "messages": [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": "data:image/png,notbase64"}}]}]}, ValueError),
245+
({"model": "m", "messages": [{"role": "user", "content": [{"type": "input_audio", "input_audio": {"data": "QUJD", "format": "midi"}}]}]}, ValueError),
244246
({"model": "m", "messages": [{"role": "user", "content": [{"type": "text", "text": "a", "prompt_cache_breakpoint": {"mode": "explicit"}}, {"type": "text", "text": "b"}]}]}, ValueError), # not last
245247
([], TypeError),
246248
])
@@ -258,6 +260,33 @@ def test_vet_op_refuses_a_non_chat_provider() -> None:
258260
assert out == {"canonical_request": {"model": "gpt-5-mini", "messages": [{"role": "user", "parts": [{"type": "text", "text": "Hi"}]}]}}
259261

260262

263+
# ─── input_audio formats (rule 4) ────────────────────────────────────
264+
265+
def _audio_body(fmt: str) -> dict:
266+
return {"model": "gemini-3.8-flash", "messages": [{"role": "user", "content": [
267+
{"type": "text", "text": "Transcribe."},
268+
{"type": "input_audio", "input_audio": {"data": "T2dnUw==", "format": fmt}},
269+
]}]}
270+
271+
272+
@pytest.mark.parametrize("fmt, media_type", [
273+
("wav", "audio/wav"), ("mp3", "audio/mpeg"), ("mpeg", "audio/mpeg"), ("ogg", "audio/ogg"),
274+
("opus", "audio/opus"), ("flac", "audio/flac"), ("aac", "audio/aac"), ("aiff", "audio/aiff"), ("webm", "audio/webm"),
275+
])
276+
def test_input_audio_reads_its_true_media_type(fmt, media_type) -> None:
277+
assert request_from_openai_chat(_audio_body(fmt)) == Request(model="gemini-3.8-flash", messages=(
278+
Message.user((TextPart("Transcribe."), AudioPart(media_type=media_type, data="T2dnUw=="))),
279+
))
280+
281+
282+
def test_ogg_audio_reaches_gemini_inline_and_the_chat_wire_refuses_it() -> None:
283+
req = request_from_openai_chat(_audio_body("ogg"))
284+
sent = json.loads(GeminiLM(api_key="k").build_request(req, stream=False).body)
285+
assert sent["contents"][0]["parts"][1] == {"inlineData": {"mimeType": "audio/ogg", "data": "T2dnUw=="}}
286+
with pytest.raises(UnsupportedFeatureError):
287+
OpenAIChatLM(api_key="k").build_request(req, stream=False)
288+
289+
261290
# ─── message objects dumped back into history (MAP-12 addendum) ──────
262291

263292
SDK_MESSAGE = {"content": "Ok! How can I help?", "refusal": None, "role": "assistant", "annotations": [], "audio": None, "function_call": None, "tool_calls": None}

0 commit comments

Comments
 (0)