diff --git a/tests/integration/authorization/test_audio_model_access.py b/tests/integration/authorization/test_audio_model_access.py new file mode 100644 index 00000000000..c8275332b4f --- /dev/null +++ b/tests/integration/authorization/test_audio_model_access.py @@ -0,0 +1,104 @@ +import json +import mimetypes +from email.parser import BytesParser +from email.policy import HTTP +from typing import Final + +import pytest + +from integration._support.client import Gateway +from integration._support.wire import Reply, Request, wire_server + +_WAV_BYTES: Final = ( + b"RIFF\x30\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00" + b"\x40\x1f\x00\x00\x40\x1f\x00\x00\x01\x00\x08\x00data\x08\x00\x00\x00" + b"\x00\x01\x02\x03\x04\x05\x06\x07" +) +_WAV_CONTENT_TYPE: Final = mimetypes.guess_type("a.wav")[0] or "application/octet-stream" +_SPEECH_BYTES: Final = bytes(range(256)) * 32 + + +def _transcription_model(request: Request) -> str: + envelope: Final = f"content-type: {request.headers['content-type']}\r\n\r\n".encode() + request.body + parsed: Final = BytesParser(policy=HTTP).parsebytes(envelope) + assert parsed.is_multipart(), request.headers["content-type"] + fields: Final = { + part.get_param("name", header="content-disposition"): part.get_payload(decode=True).decode() + for part in parsed.iter_parts() + if part.get_filename() is None + } + return fields["model"] + + +@pytest.mark.parametrize("path", ("/v1/audio/transcriptions", "/audio/transcriptions")) +def test_audio_transcription_enforces_key_model_access(gateway: Gateway, path: str) -> None: + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + assert _transcription_model(request) == "whisper-1" + return Reply(body=json.dumps({"text": "hello world"}).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + allowed: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + other: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + key: Final = scenario.key(models=[allowed]) + denied: Final = gateway.request_multipart( + path, {"model": other}, {"file": ("a.wav", _WAV_BYTES, _WAV_CONTENT_TYPE)}, key=key + ) + assert denied.status_code == 403, denied.text + error: Final = denied.json()["error"] + assert error["type"] == "key_model_access_denied" and error["param"] == "model", denied.text + assert wire.drain() == () + granted: Final = gateway.request_multipart( + path, {"model": allowed}, {"file": ("a.wav", _WAV_BYTES, _WAV_CONTENT_TYPE)}, key=key + ) + assert granted.status_code == 200, granted.text + assert [(request.method, request.target) for request in wire.drain()] == [ + ("POST", "/v1/audio/transcriptions") + ] + + +@pytest.mark.parametrize("path", ("/v1/audio/speech", "/audio/speech")) +def test_audio_speech_enforces_key_model_access(gateway: Gateway, path: str) -> None: + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/speech" + assert request.headers["authorization"] == "Bearer synthetic-openai-key" + assert json.loads(request.body) == { + "model": "gpt-4o-mini-tts", + "input": "hello", + "voice": "alloy", + } + return Reply(body=_SPEECH_BYTES, content_type="audio/wav") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + allowed: Final = scenario.model( + model="openai/gpt-4o-mini-tts", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + other: Final = scenario.model( + model="openai/gpt-4o-mini-tts", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + key: Final = scenario.key(models=[allowed]) + denied: Final = gateway.request( + "POST", + path, + {"model": other, "input": "hello", "voice": "alloy"}, + key=key, + ) + assert denied.status_code == 403, denied.text + error: Final = denied.json()["error"] + assert error["type"] == "key_model_access_denied" and error["param"] == "model", denied.text + assert wire.drain() == () + granted: Final = gateway.request( + "POST", + path, + {"model": allowed, "input": "hello", "voice": "alloy"}, + key=key, + ) + assert granted.status_code == 200, granted.text + assert granted.content == _SPEECH_BYTES + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/speech")] diff --git a/tests/integration/compatibility/test_audio_speech_wire.py b/tests/integration/compatibility/test_audio_speech_wire.py new file mode 100644 index 00000000000..91295cc1688 --- /dev/null +++ b/tests/integration/compatibility/test_audio_speech_wire.py @@ -0,0 +1,184 @@ +import json +from typing import Final + +import httpx +import pytest +from integration._support.client import Gateway +from integration._support.wire import Reply, Request, wire_server +from openai import OpenAI + +_SPEECH_BYTES: Final = bytes(range(256)) * 32 +_SPEECH_REQUEST: Final = { + "model": "gpt-4o-mini-tts", + "input": "hello", + "voice": "alloy", + "response_format": "wav", + "speed": 1.25, + "instructions": "cheerful", +} +_FORMAT_MEDIA_TYPES: Final = { + "wav": "audio/wav", + "flac": "audio/flac", + "pcm": "audio/pcm", + "opus": "audio/opus", +} + + +def _sdk(gateway: Gateway, path: str, api_key: str) -> OpenAI: + return OpenAI( + base_url=str(gateway.client.base_url).rstrip("/") + path, + api_key=api_key, + max_retries=0, + http_client=httpx.Client(trust_env=False), + ) + + +@pytest.mark.parametrize("base_path", ("/v1", "")) +def test_audio_speech_forwards_the_full_json_body_and_streams_the_audio(gateway: Gateway, base_path: str) -> None: + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/speech" + assert request.headers["authorization"] == "Bearer synthetic-openai-key" + body: Final = json.loads(request.body) + assert body == _SPEECH_REQUEST + assert type(body["speed"]) is float + return Reply(body=_SPEECH_BYTES, content_type="audio/wav") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4o-mini-tts", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + key: Final = scenario.key(models=[model]) + with _sdk(gateway, base_path, key) as sdk: + response: Final = sdk.audio.speech.with_raw_response.create( + model=model, + input="hello", + voice="alloy", + response_format="wav", + speed=1.25, + instructions="cheerful", + ) + assert response.status_code == 200, response.read() + assert response.content == _SPEECH_BYTES + assert response.headers["content-type"] == "audio/wav" + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/speech")] + + +def test_audio_speech_streams_chunked_audio_through_intact(gateway: Gateway) -> None: + audio: Final = bytes(index % 256 for index in range(64 * 1024)) + chunks: Final = tuple(audio[start : start + 4096] for start in range(0, len(audio), 4096)) + assert len(chunks) == 16 + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/speech" + return Reply(chunks=chunks, content_type="audio/wav") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4o-mini-tts", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + with _sdk(gateway, "/v1", gateway.key) as sdk: + response: Final = sdk.audio.speech.with_raw_response.create( + model=model, + input="hello", + voice="alloy", + response_format="mp3", + ) + assert response.status_code == 200, response.read() + assert response.content == audio + assert response.headers["content-type"] == "audio/wav" + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/speech")] + + +@pytest.mark.parametrize("response_format,media_type", tuple(_FORMAT_MEDIA_TYPES.items())) +def test_audio_speech_derives_the_media_type_from_the_requested_format( + gateway: Gateway, response_format: str, media_type: str +) -> None: + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/speech" + return Reply(body=_SPEECH_BYTES, content_type="application/octet-stream") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4o-mini-tts", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + with _sdk(gateway, "/v1", gateway.key) as sdk: + response: Final = sdk.audio.speech.with_raw_response.create( + model=model, + input="hello", + voice="alloy", + response_format=response_format, + ) + assert response.status_code == 200, response.read() + assert response.headers["content-type"] == media_type + assert response.content == _SPEECH_BYTES + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/speech")] + + +def test_audio_speech_forwards_stream_format_sse(gateway: Gateway) -> None: + pytest.skip( + "BUG: speech stream_format='sse' is dropped from the upstream JSON body, which arrives as " + "{input, model, voice}, and the SSE reply is relabelled audio/mpeg" + ) + sse_body: Final = ( + b'data: {"type":"speech.audio.delta","audio":"aGVsbG8="}\n\ndata: {"type":"speech.audio.done"}\n\n' + ) + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/speech" + assert json.loads(request.body) == { + "model": "gpt-4o-mini-tts", + "input": "hello", + "voice": "alloy", + "stream_format": "sse", + } + return Reply(chunks=(sse_body,), content_type="text/event-stream") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4o-mini-tts", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + with _sdk(gateway, "/v1", gateway.key) as sdk: + response: Final = sdk.audio.speech.with_raw_response.create( + model=model, + input="hello", + voice="alloy", + stream_format="sse", + ) + assert response.status_code == 200, response.read() + assert response.headers["content-type"].startswith("text/event-stream"), response.read() + assert response.content == sse_body + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/speech")] + + +def test_audio_speech_forwards_object_form_voice(gateway: Gateway) -> None: + pytest.skip( + "BUG: speech voice={'id': 'voice_123'} is rejected with 400 \"'voice' is required to be passed " + 'as a string for OpenAI TTS" and never reaches the upstream' + ) + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/speech" + assert json.loads(request.body) == { + "model": "gpt-4o-mini-tts", + "input": "hello", + "voice": {"id": "voice_123"}, + } + return Reply(body=_SPEECH_BYTES, content_type="audio/mpeg") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4o-mini-tts", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + response: Final = gateway.request( + "POST", + "/v1/audio/speech", + {"model": model, "input": "hello", "voice": {"id": "voice_123"}}, + ) + assert response.status_code == 200, response.text + assert response.content == _SPEECH_BYTES + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/speech")] diff --git a/tests/integration/compatibility/test_audio_transcriptions_wire.py b/tests/integration/compatibility/test_audio_transcriptions_wire.py new file mode 100644 index 00000000000..7845e3d2423 --- /dev/null +++ b/tests/integration/compatibility/test_audio_transcriptions_wire.py @@ -0,0 +1,673 @@ +import json +import math +import mimetypes +import uuid +from email.message import Message +from email.parser import BytesParser +from email.policy import HTTP +from typing import Final, cast + +import httpx +import pytest +from integration._support.client import Gateway, eventually +from integration._support.database import read_rows +from integration._support.wire import Reply, Request, wire_server +from openai import OpenAI +from pydantic import BaseModel, ConfigDict + +_WAV_BYTES: Final = ( + b"RIFF\x30\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00" + b"\x40\x1f\x00\x00\x40\x1f\x00\x00\x01\x00\x08\x00data\x08\x00\x00\x00" + b"\x00\x01\x02\x03\x04\x05\x06\x07" +) +_WAV_CONTENT_TYPE: Final = mimetypes.guess_type("a.wav")[0] or "application/octet-stream" +_VERBOSE_TEXT_PARTS: Final = ( + ("include[]", "logprobs"), + ("language", "en"), + ("model", "whisper-1"), + ("prompt", "hi"), + ("response_format", "verbose_json"), + ("temperature", "0.2"), + ("timestamp_granularities[]", "word"), + ("timestamp_granularities[]", "segment"), +) +_VERBOSE_TRANSCRIPT: Final = { + "task": "transcribe", + "language": "english", + "duration": 1.5, + "text": "hello world", + "words": [ + {"word": "hello", "start": 0.0, "end": 0.5}, + {"word": "world", "start": 0.6, "end": 1.0}, + ], + "segments": [ + { + "id": 0, + "seek": 0, + "start": 0.0, + "end": 1.0, + "text": "hello world", + "tokens": [1, 2], + "temperature": 0.2, + "avg_logprob": -0.1, + "compression_ratio": 1.0, + "no_speech_prob": 0.01, + } + ], + "usage": { + "type": "tokens", + "input_tokens": 12, + "output_tokens": 4, + "total_tokens": 16, + "input_token_details": {"text_tokens": 2, "audio_tokens": 10}, + }, +} +_JSON_TRANSCRIPT: Final = {"text": "hello world", "usage": _VERBOSE_TRANSCRIPT["usage"]} +_PLAIN_TRANSCRIPTS: Final = { + "text": "hello world\n", + "srt": "1\n00:00:00,000 --> 00:00:01,000\nhello world\n", + "vtt": "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nhello world\n", +} + + +class _Word(BaseModel): + model_config = ConfigDict(extra="forbid") + word: str + start: float + end: float + + +class _Segment(BaseModel): + model_config = ConfigDict(extra="forbid") + id: int + seek: int + start: float + end: float + text: str + tokens: tuple[int, ...] + temperature: float + avg_logprob: float + compression_ratio: float + no_speech_prob: float + + +class _InputTokenDetails(BaseModel): + model_config = ConfigDict(extra="forbid") + text_tokens: int + audio_tokens: int + + +class _Usage(BaseModel): + model_config = ConfigDict(extra="forbid") + type: str + input_tokens: int + output_tokens: int + total_tokens: int + input_token_details: _InputTokenDetails + + +class _VerboseTranscript(BaseModel): + model_config = ConfigDict(extra="forbid") + task: str + language: str + duration: float + text: str + words: tuple[_Word, ...] + segments: tuple[_Segment, ...] + usage: _Usage + + +def _multipart_parts(request: Request) -> tuple[Message, ...]: + envelope: Final = f"content-type: {request.headers['content-type']}\r\n\r\n".encode() + request.body + parsed: Final = BytesParser(policy=HTTP).parsebytes(envelope) + assert parsed.is_multipart(), request.headers["content-type"] + return tuple(parsed.iter_parts()) + + +def _text_parts(parts: tuple[Message, ...]) -> tuple[tuple[str, str], ...]: + return tuple( + sorted( + ( + (part.get_param("name", header="content-disposition"), part.get_payload(decode=True).decode()) + for part in parts + if part.get_filename() is None + ), + key=lambda field: field[0], + ) + ) + + +def _file_parts(parts: tuple[Message, ...]) -> tuple[tuple[str, str, str, bytes], ...]: + return tuple( + sorted( + ( + ( + part.get_param("name", header="content-disposition"), + part.get_filename(), + part.get_content_type(), + part.get_payload(decode=True), + ) + for part in parts + if part.get_filename() is not None + ), + key=lambda field: field[0], + ) + ) + + +def _sdk(gateway: Gateway, path: str, api_key: str) -> OpenAI: + return OpenAI( + base_url=str(gateway.client.base_url).rstrip("/") + path, + api_key=api_key, + max_retries=0, + http_client=httpx.Client(trust_env=False), + ) + + +def test_audio_transcription_verbose_json_forwards_every_form_field_and_returns_the_transcript( + gateway: Gateway, +) -> None: + call_id: Final = f"audio-transcription-{uuid.uuid4().hex}" + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + assert request.headers["authorization"] == "Bearer synthetic-openai-key" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == _VERBOSE_TEXT_PARTS + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply(body=json.dumps(_VERBOSE_TRANSCRIPT).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", + api_base=f"{wire.url}/v1", + api_key="synthetic-openai-key", + input_cost_per_token=0.000001, + input_cost_per_audio_token=0.000003, + output_cost_per_token=0.000002, + ) + with _sdk(gateway, "/v1", gateway.key) as sdk: + response: Final = sdk.audio.transcriptions.with_raw_response.create( + model=model, + file=("a.wav", wav, _WAV_CONTENT_TYPE), + temperature=0.2, + language="en", + prompt="hi", + response_format="verbose_json", + timestamp_granularities=["word", "segment"], + include=["logprobs"], + extra_headers={"x-litellm-call-id": call_id}, + ) + assert response.status_code == 200, response.text + assert json.loads(response.content) == _VERBOSE_TRANSCRIPT, response.text + _VerboseTranscript.model_validate_json(response.content) + assert response.headers["x-litellm-call-id"] == call_id, response.text + usage: Final = cast("dict[str, object]", _VERBOSE_TRANSCRIPT["usage"]) + token_details: Final = cast("dict[str, int]", usage["input_token_details"]) + assert math.isclose( + float(response.headers["x-litellm-response-cost"]), + token_details["audio_tokens"] * 0.000003 + + token_details["text_tokens"] * 0.000001 + + cast(int, usage["output_tokens"]) * 0.000002, + rel_tol=1e-9, + ), response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/transcriptions")] + + +def test_audio_transcription_reaches_upstream_identically_on_prefixed_and_unprefixed_routes( + gateway: Gateway, +) -> None: + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + assert request.headers["authorization"] == "Bearer synthetic-openai-key" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == _VERBOSE_TEXT_PARTS + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply(body=json.dumps(_VERBOSE_TRANSCRIPT).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + prefixed: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + unprefixed: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + key: Final = scenario.key(models=[prefixed, unprefixed]) + + def transcribe(base_path: str, model: str) -> dict[str, object]: + with _sdk(gateway, base_path, key) as sdk: + return json.loads( + sdk.audio.transcriptions.with_raw_response.create( + model=model, + file=("a.wav", wav, _WAV_CONTENT_TYPE), + temperature=0.2, + language="en", + prompt="hi", + response_format="verbose_json", + timestamp_granularities=["word", "segment"], + include=["logprobs"], + ).content + ) + + bodies: Final = tuple( + transcribe(base_path, model) for base_path, model in (("/v1", prefixed), ("", unprefixed)) + ) + assert bodies[0] == bodies[1] == _VERBOSE_TRANSCRIPT + assert [(request.method, request.target) for request in wire.drain()] == [ + ("POST", "/v1/audio/transcriptions"), + ("POST", "/v1/audio/transcriptions"), + ] + + +def test_audio_transcription_json_format_returns_text_and_usage(gateway: Gateway) -> None: + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == (("model", "whisper-1"), ("response_format", "json")) + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply(body=json.dumps(_JSON_TRANSCRIPT).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + with _sdk(gateway, "/v1", gateway.key) as sdk: + response: Final = sdk.audio.transcriptions.with_raw_response.create( + model=model, + file=("a.wav", wav, _WAV_CONTENT_TYPE), + response_format="json", + ) + assert response.status_code == 200, response.text + assert json.loads(response.content) == _JSON_TRANSCRIPT, response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/transcriptions")] + + +def test_audio_transcription_forwards_nested_and_repeated_bracket_fields_verbatim(gateway: Gateway) -> None: + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + assert request.headers["authorization"] == "Bearer synthetic-openai-key" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == ( + ("chunking_strategy[prefix_padding_ms]", "300"), + ("chunking_strategy[silence_duration_ms]", "500"), + ("chunking_strategy[threshold]", "0.5"), + ("chunking_strategy[type]", "server_vad"), + ("known_speaker_names[]", "alice"), + ("known_speaker_names[]", "bob"), + ("known_speaker_references[]", "data:audio/wav;base64,AAAA"), + ("known_speaker_references[]", "data:audio/wav;base64,BBBB"), + ("model", "gpt-4o-transcribe"), + ("response_format", "json"), + ) + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply(body=json.dumps(_JSON_TRANSCRIPT).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4o-transcribe", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + known_speaker_names: Final = ("alice", "bob") + known_speaker_references: Final = ( + "data:audio/wav;base64,AAAA", + "data:audio/wav;base64,BBBB", + ) + with _sdk(gateway, "/v1", gateway.key) as sdk: + response: Final = sdk.audio.transcriptions.with_raw_response.create( + model=model, + file=("a.wav", wav, _WAV_CONTENT_TYPE), + response_format="json", + chunking_strategy={ + "type": "server_vad", + "prefix_padding_ms": 300, + "silence_duration_ms": 500, + "threshold": 0.5, + }, + known_speaker_names=known_speaker_names, + known_speaker_references=known_speaker_references, + ) + assert response.status_code == 200, response.text + assert json.loads(response.content) == _JSON_TRANSCRIPT, response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/transcriptions")] + + +def test_audio_transcription_stream_returns_server_sent_events(gateway: Gateway) -> None: + pytest.skip( + "BUG: stream=true transcriptions reach the upstream but its SSE body comes back as application/json " + '{"text": "data: ..."}, so OpenAI SDK stream=True callers get no transcript events' + ) + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == (("model", "gpt-4o-transcribe"), ("stream", "true")) + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply( + chunks=( + b'data: {"type":"transcript.text.delta","delta":"hello"}\n\n', + b'data: {"type":"transcript.text.delta","delta":" world"}\n\n', + b'data: {"type":"transcript.text.done","text":"hello world",' + b'"usage":{"type":"tokens","input_tokens":12,"output_tokens":4,"total_tokens":16}}\n\n', + ), + content_type="text/event-stream", + ) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4o-transcribe", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + with _sdk(gateway, "/v1", gateway.key) as sdk: + with sdk.audio.transcriptions.with_streaming_response.create( + model=model, + file=("a.wav", wav, _WAV_CONTENT_TYPE), + stream=True, + ) as raw: + assert raw.headers["content-type"].startswith("text/event-stream") + events: Final = tuple( + event.to_dict() + for event in sdk.audio.transcriptions.create( + model=model, + file=("a.wav", wav, _WAV_CONTENT_TYPE), + stream=True, + ) + ) + assert events == ( + {"type": "transcript.text.delta", "delta": "hello"}, + {"type": "transcript.text.delta", "delta": " world"}, + { + "type": "transcript.text.done", + "text": "hello world", + "usage": { + "type": "tokens", + "input_tokens": 12, + "output_tokens": 4, + "total_tokens": 16, + }, + }, + ) + assert [(request.method, request.target) for request in wire.drain()] == [ + ("POST", "/v1/audio/transcriptions"), + ("POST", "/v1/audio/transcriptions"), + ] + + +def test_audio_transcription_applies_json_string_metadata_without_forwarding_it(gateway: Gateway) -> None: + tag: Final = f"audio-metadata-{uuid.uuid4().hex}" + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + assert request.headers["authorization"] == "Bearer synthetic-openai-key" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == (("model", "whisper-1"), ("response_format", "json")) + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply(body=json.dumps(_JSON_TRANSCRIPT).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + response: Final = gateway.request_multipart( + "/v1/audio/transcriptions", + { + "model": model, + "response_format": "json", + "metadata": json.dumps({"tags": [tag]}), + }, + {"file": ("a.wav", wav, _WAV_CONTENT_TYPE)}, + ) + assert response.status_code == 200, response.text + assert response.json() == _JSON_TRANSCRIPT, response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/transcriptions")] + rows: Final = eventually( + lambda: read_rows( + 'SELECT request_tags FROM "LiteLLM_SpendLogs" WHERE request_id = %s', + (response.headers["x-litellm-call-id"],), + ), + lambda values: len(values) == 1, + seconds=70, + ) + request_tags: Final = rows[0]["request_tags"] + assert isinstance(request_tags, list), rows + assert tuple( + value for value in request_tags if isinstance(value, str) and not value.startswith("User-Agent: ") + ) == (tag,), rows + + +@pytest.mark.parametrize("response_format", ("text", "srt", "vtt")) +def test_audio_transcription_forwards_plain_response_format_to_upstream(gateway: Gateway, response_format: str) -> None: + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == (("model", "whisper-1"), ("response_format", response_format)) + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply(body=_PLAIN_TRANSCRIPTS[response_format].encode(), content_type="text/plain") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + with _sdk(gateway, "/v1", gateway.key) as sdk: + response: Final = sdk.audio.transcriptions.with_raw_response.create( + model=model, + file=("a.wav", wav, _WAV_CONTENT_TYPE), + response_format=response_format, + ) + assert response.status_code == 200, response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/transcriptions")] + + +@pytest.mark.parametrize("response_format", ("text", "srt", "vtt")) +def test_audio_transcription_plain_response_format_returns_the_raw_transcript( + gateway: Gateway, response_format: str +) -> None: + pytest.skip( + "BUG: response_format=text/srt/vtt transcriptions return application/json " + '{"text": ...} instead of the raw transcript, so the OpenAI SDK returns a JSON string as the transcript' + ) + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + return Reply(body=_PLAIN_TRANSCRIPTS[response_format].encode(), content_type="text/plain") + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + with _sdk(gateway, "/v1", gateway.key) as sdk: + response: Final = sdk.audio.transcriptions.with_raw_response.create( + model=model, + file=("a.wav", wav, _WAV_CONTENT_TYPE), + response_format=response_format, + ) + assert response.headers["content-type"].startswith("text/plain"), response.text + transcript: Final = sdk.audio.transcriptions.create( + model=model, + file=("a.wav", wav, _WAV_CONTENT_TYPE), + response_format=response_format, + ) + assert transcript == _PLAIN_TRANSCRIPTS[response_format] + + +def test_audio_transcription_applies_json_string_litellm_metadata_without_forwarding_it( + gateway: Gateway, +) -> None: + tag: Final = f"audio-metadata-{uuid.uuid4().hex}" + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + assert request.headers["authorization"] == "Bearer synthetic-openai-key" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == (("model", "whisper-1"), ("response_format", "json")) + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply(body=json.dumps(_JSON_TRANSCRIPT).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + response: Final = gateway.request_multipart( + "/v1/audio/transcriptions", + { + "model": model, + "response_format": "json", + "litellm_metadata": json.dumps({"tags": [tag]}), + }, + {"file": ("a.wav", wav, _WAV_CONTENT_TYPE)}, + ) + assert response.status_code == 200, response.text + assert response.json() == _JSON_TRANSCRIPT, response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/transcriptions")] + rows: Final = eventually( + lambda: read_rows( + 'SELECT request_tags FROM "LiteLLM_SpendLogs" WHERE request_id = %s', + (response.headers["x-litellm-call-id"],), + ), + lambda values: len(values) == 1, + seconds=70, + ) + request_tags: Final = rows[0]["request_tags"] + assert isinstance(request_tags, list), rows + assert tuple( + value for value in request_tags if isinstance(value, str) and not value.startswith("User-Agent: ") + ) == (tag,), rows + + +@pytest.mark.parametrize("metadata_field", ("metadata", "litellm_metadata")) +def test_audio_transcription_consumes_bracketed_form_metadata_tags(gateway: Gateway, metadata_field: str) -> None: + pytest.skip( + "BUG: multipart metadata[tags]/litellm_metadata[tags] on /v1/audio/transcriptions reaches the upstream " + "as an extra text part and the tag never lands in LiteLLM_SpendLogs.request_tags" + ) + tag: Final = f"audio-metadata-{uuid.uuid4().hex}" + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == (("model", "whisper-1"), ("response_format", "json")) + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply(body=json.dumps(_JSON_TRANSCRIPT).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + response: Final = gateway.request_multipart( + "/v1/audio/transcriptions", + { + "model": model, + "response_format": "json", + f"{metadata_field}[tags]": tag, + }, + {"file": ("a.wav", wav, _WAV_CONTENT_TYPE)}, + ) + assert response.status_code == 200, response.text + assert response.json() == _JSON_TRANSCRIPT, response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/transcriptions")] + rows: Final = eventually( + lambda: read_rows( + 'SELECT request_tags FROM "LiteLLM_SpendLogs" WHERE request_id = %s', + (response.headers["x-litellm-call-id"],), + ), + lambda values: len(values) == 1, + seconds=70, + ) + request_tags: Final = rows[0]["request_tags"] + assert isinstance(request_tags, list), rows + assert tuple( + value for value in request_tags if isinstance(value, str) and not value.startswith("User-Agent: ") + ) == (tag,), rows + + +def test_audio_transcription_preserves_the_client_file_content_type(gateway: Gateway) -> None: + pytest.skip( + "BUG: the proxy discards the client's file part content type and re-derives it from the filename, " + "so file=('recording', ..., 'audio/wav') reaches the upstream as application/octet-stream" + ) + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + parts: Final = _multipart_parts(request) + assert _file_parts(parts) == (("file", "recording", "audio/wav", wav),) + return Reply(body=json.dumps(_JSON_TRANSCRIPT).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + response: Final = gateway.request_multipart( + "/v1/audio/transcriptions", + {"model": model, "response_format": "json"}, + {"file": ("recording", wav, "audio/wav")}, + ) + assert response.status_code == 200, response.text + assert response.json() == _JSON_TRANSCRIPT, response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/transcriptions")] + + +def test_audio_transcription_bills_audio_tokens_at_the_input_rate_when_no_audio_rate_is_set( + gateway: Gateway, +) -> None: + pytest.skip( + "BUG: with only input_cost_per_token set, transcription audio tokens are billed at 0, so " + "x-litellm-response-cost is 1e-05 instead of 2e-05 for 10 audio, 2 text and 4 output tokens" + ) + wav: Final = _WAV_BYTES + uuid.uuid4().bytes + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/transcriptions" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == (("model", "whisper-1"), ("response_format", "json")) + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, wav),) + return Reply(body=json.dumps(_JSON_TRANSCRIPT).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", + api_base=f"{wire.url}/v1", + api_key="synthetic-openai-key", + input_cost_per_token=0.000001, + output_cost_per_token=0.000002, + ) + response: Final = gateway.request_multipart( + "/v1/audio/transcriptions", + {"model": model, "response_format": "json"}, + {"file": ("a.wav", wav, _WAV_CONTENT_TYPE)}, + ) + assert response.status_code == 200, response.text + assert response.json() == _JSON_TRANSCRIPT, response.text + usage: Final = cast("dict[str, object]", _JSON_TRANSCRIPT["usage"]) + token_details: Final = cast("dict[str, int]", usage["input_token_details"]) + assert math.isclose( + float(response.headers["x-litellm-response-cost"]), + token_details["audio_tokens"] * 0.000001 + + token_details["text_tokens"] * 0.000001 + + cast(int, usage["output_tokens"]) * 0.000002, + rel_tol=1e-9, + ), response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/transcriptions")] diff --git a/tests/integration/compatibility/test_audio_translations_wire.py b/tests/integration/compatibility/test_audio_translations_wire.py new file mode 100644 index 00000000000..7e1f716de9b --- /dev/null +++ b/tests/integration/compatibility/test_audio_translations_wire.py @@ -0,0 +1,105 @@ +import json +import mimetypes +from email.message import Message +from email.parser import BytesParser +from email.policy import HTTP +from typing import Final + +import httpx +import pytest +from integration._support.client import Gateway +from integration._support.wire import Reply, Request, wire_server +from openai import OpenAI + +_WAV_BYTES: Final = ( + b"RIFF\x30\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00" + b"\x40\x1f\x00\x00\x40\x1f\x00\x00\x01\x00\x08\x00data\x08\x00\x00\x00" + b"\x00\x01\x02\x03\x04\x05\x06\x07" +) +_WAV_CONTENT_TYPE: Final = mimetypes.guess_type("a.wav")[0] or "application/octet-stream" + + +def _multipart_parts(request: Request) -> tuple[Message, ...]: + envelope: Final = f"content-type: {request.headers['content-type']}\r\n\r\n".encode() + request.body + parsed: Final = BytesParser(policy=HTTP).parsebytes(envelope) + assert parsed.is_multipart(), request.headers["content-type"] + return tuple(parsed.iter_parts()) + + +def _text_parts(parts: tuple[Message, ...]) -> tuple[tuple[str, str], ...]: + return tuple( + sorted( + ( + (part.get_param("name", header="content-disposition"), part.get_payload(decode=True).decode()) + for part in parts + if part.get_filename() is None + ), + key=lambda field: field[0], + ) + ) + + +def _file_parts(parts: tuple[Message, ...]) -> tuple[tuple[str, str, str, bytes], ...]: + return tuple( + sorted( + ( + ( + part.get_param("name", header="content-disposition"), + part.get_filename(), + part.get_content_type(), + part.get_payload(decode=True), + ) + for part in parts + if part.get_filename() is not None + ), + key=lambda field: field[0], + ) + ) + + +def _sdk(gateway: Gateway, path: str, api_key: str) -> OpenAI: + return OpenAI( + base_url=str(gateway.client.base_url).rstrip("/") + path, + api_key=api_key, + max_retries=0, + http_client=httpx.Client(trust_env=False), + ) + + +@pytest.mark.parametrize("base_path", ("/v1", "")) +def test_audio_translation_reaches_upstream_on_prefixed_and_unprefixed_routes(gateway: Gateway, base_path: str) -> None: + pytest.skip( + "BUG: the proxy registers no /v1/audio/translations or /audio/translations route, so OpenAI SDK " + "translations.create gets 404 Not Found and never reaches the provider" + ) + + def respond(request: Request) -> Reply: + assert request.method == "POST" + assert request.target == "/v1/audio/translations" + assert request.headers["authorization"] == "Bearer synthetic-openai-key" + parts: Final = _multipart_parts(request) + assert _text_parts(parts) == ( + ("model", "whisper-1"), + ("prompt", "hi"), + ("response_format", "json"), + ("temperature", "0.2"), + ) + assert _file_parts(parts) == (("file", "a.wav", _WAV_CONTENT_TYPE, _WAV_BYTES),) + return Reply(body=json.dumps({"text": "hello world"}).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/whisper-1", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key" + ) + key: Final = scenario.key(models=[model]) + with _sdk(gateway, base_path, key) as sdk: + response: Final = sdk.audio.translations.with_raw_response.create( + model=model, + file=("a.wav", _WAV_BYTES, _WAV_CONTENT_TYPE), + prompt="hi", + response_format="json", + temperature=0.2, + ) + assert response.status_code == 200, response.text + assert json.loads(response.content) == {"text": "hello world"}, response.text + assert [(request.method, request.target) for request in wire.drain()] == [("POST", "/v1/audio/translations")] diff --git a/tests/integration/configuration/test_audio_transcription_model_resolution.py b/tests/integration/configuration/test_audio_transcription_model_resolution.py new file mode 100644 index 00000000000..f2828d63cf1 --- /dev/null +++ b/tests/integration/configuration/test_audio_transcription_model_resolution.py @@ -0,0 +1,156 @@ +import json +import mimetypes +import uuid +from email.message import Message +from email.parser import BytesParser +from email.policy import HTTP +from pathlib import Path +from typing import Final + +import pytest +from pydantic import JsonValue + +from tests.integration._support.client import Gateway +from tests.integration._support.process import owned_proxy +from tests.integration._support.wire import Reply, Request, wire_server + +_WAV_BYTES: Final = ( + b"RIFF\x30\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00" + b"\x40\x1f\x00\x00\x40\x1f\x00\x00\x01\x00\x08\x00data\x08\x00\x00\x00" + b"\x00\x01\x02\x03\x04\x05\x06\x07" +) +_WAV_CONTENT_TYPE: Final = mimetypes.guess_type("a.wav")[0] or "application/octet-stream" +_JSON_TRANSCRIPT: Final = { + "text": "hello world", + "usage": { + "type": "tokens", + "input_tokens": 12, + "output_tokens": 4, + "total_tokens": 16, + "input_token_details": {"text_tokens": 2, "audio_tokens": 10}, + }, +} + + +def _write_config(directory: Path, upstream_url: str, stt: str, moderation: str) -> Path: + config: Final = directory / f"audio_transcription_{uuid.uuid4().hex}.yaml" + litellm_params: Final[dict[str, JsonValue]] = { + "api_base": f"{upstream_url}/v1", + "api_key": "synthetic-openai-key", + } + config.write_text( + json.dumps( + { + "model_list": [ + {"model_name": stt, "litellm_params": {**litellm_params, "model": "openai/whisper-1"}}, + { + "model_name": moderation, + "litellm_params": {**litellm_params, "model": "openai/omni-moderation-latest"}, + }, + ], + "general_settings": { + "master_key": "os.environ/LITELLM_MASTER_KEY", + "database_url": "os.environ/DATABASE_URL", + "store_model_in_db": True, + "disable_spend_logs": False, + "proxy_batch_write_at": 1, + "proxy_batch_polling_interval": 1, + "moderation_model": moderation, + }, + "litellm_settings": { + "enable_redis_auth_cache": True, + "cache": True, + "cache_params": { + "type": "redis", + "host": "os.environ/REDIS_HOST", + "port": "os.environ/REDIS_PORT", + }, + }, + "router_settings": {"disable_cooldowns": True}, + } + ) + ) + return config + + +def _multipart_parts(request: Request) -> tuple[Message, ...]: + envelope: Final = f"content-type: {request.headers['content-type']}\r\n\r\n".encode() + request.body + parsed: Final = BytesParser(policy=HTTP).parsebytes(envelope) + assert parsed.is_multipart(), request.headers["content-type"] + return tuple(parsed.iter_parts()) + + +def _text_parts(parts: tuple[Message, ...]) -> tuple[tuple[str, str], ...]: + return tuple( + sorted( + ( + (part.get_param("name", header="content-disposition"), part.get_payload(decode=True).decode()) + for part in parts + if part.get_filename() is None + ), + key=lambda field: field[0], + ) + ) + + +def _file_parts(parts: tuple[Message, ...]) -> tuple[tuple[str, str, str, bytes], ...]: + return tuple( + sorted( + ( + ( + part.get_param("name", header="content-disposition"), + part.get_filename(), + part.get_content_type(), + part.get_payload(decode=True), + ) + for part in parts + if part.get_filename() is not None + ), + key=lambda field: field[0], + ) + ) + + +_TRANSCRIPTIONS: Final = "/v1/audio/transcriptions" + + +@pytest.mark.timeout(240) +def test_audio_transcription_routes_to_the_requested_model_not_the_moderation_model( + gateway: Gateway, tmp_path: Path +) -> None: + pytest.skip( + "BUG: general_settings.moderation_model overrides the model of every /audio/transcriptions request, " + "so the upstream receives the moderation deployment's model" + ) + stt: Final = f"integration-stt-{uuid.uuid4().hex}" + moderation: Final = f"integration-moderation-{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.target != _TRANSCRIPTIONS: + return Reply(body=b'{"object":"list","data":[]}') + return Reply(body=json.dumps(_JSON_TRANSCRIPT).encode()) + + with wire_server(respond) as wire: + config: Final = _write_config(tmp_path, wire.url, stt, moderation) + with owned_proxy(gateway, tmp_path, {"STORE_MODEL_IN_DB": "False"}, config=config) as candidate: + response: Final = candidate.request_multipart( + _TRANSCRIPTIONS, + {"model": stt, "response_format": "json"}, + {"file": ("a.wav", _WAV_BYTES, _WAV_CONTENT_TYPE)}, + ) + assert response.status_code == 200, response.text + assert response.json() == _JSON_TRANSCRIPT, response.text + calls: Final = wire.drain() + assert tuple((request.method, request.target) for request in calls if request.target != "/v1/models") == ( + ("POST", _TRANSCRIPTIONS), + ), response.text + assert tuple( + _text_parts(_multipart_parts(request)) + for request in calls + if request.target == _TRANSCRIPTIONS + ) == ((("model", "whisper-1"), ("response_format", "json")),), response.text + assert tuple( + _file_parts(_multipart_parts(request)) + for request in calls + if request.target == _TRANSCRIPTIONS + ) == ((("file", "a.wav", _WAV_CONTENT_TYPE, _WAV_BYTES),),), response.text