diff --git a/README.md b/README.md index 193efad..2eaf81f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ print(response.choices[0].message.content) - **Embeddings**: Text embedding generation - **Vision/Image**: Analyze images with multimodal models - **Audio I/O**: Audio input and output support +- **Audio API**: Text to speech, transcription and translation (`client.audio`) - **Video**: Video content analysis - **Web Search**: Real-time web search integration - **Context Compression**: Compress long conversation history to save tokens @@ -154,6 +155,31 @@ response = client.chat.completions.create( print(response.choices[0].message.audio.transcript) ``` +### Speech, Transcription and Translation + +```python +# Text to speech +speech = client.audio.speech.create( + model="gpt-4o-mini-tts", + input="Hello from Apertis!", + voice="alloy", +) +speech.write_to_file("hello.mp3") + +# Speech to text: pass a path, an open file, bytes, or (filename, bytes) +transcript = client.audio.transcriptions.create(file="meeting.mp3", model="whisper-1") +print(transcript.text) + +# Subtitles come back as plain text +srt = client.audio.transcriptions.create( + file="meeting.mp3", model="whisper-1", response_format="srt" +) + +# Translate speech into English text +translation = client.audio.translations.create(file="bonjour.m4a", model="whisper-1") +print(translation.text) +``` + ### Web Search Enable real-time web search for a non-free model by appending `:web` to its model ID. @@ -581,6 +607,8 @@ the `publish` confirmation value. ## Changelog ### Unreleased +- Add `client.audio`: `speech.create()` (binary audio with `write_to_file()`), + `transcriptions.create()` and `translations.create()` (multipart uploads). - Add `stream`, `thinking` and `extra_body` to `messages.create()`, with typed stream events and `thinking` / `redacted_thinking` content blocks. - Streamed requests that fail with an HTTP error now raise `APIError` (previously diff --git a/apertis/_base_client.py b/apertis/_base_client.py index d28b1aa..a5401f6 100644 --- a/apertis/_base_client.py +++ b/apertis/_base_client.py @@ -71,10 +71,20 @@ def request( json: Optional[Dict[str, Any]] = None, headers: Optional[Mapping[str, str]] = None, stream: bool = False, + data: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = None, ) -> httpx.Response: - """Make an HTTP request.""" + """Make an HTTP request. + + With `files`, the body is multipart/form-data built from `data` and `files`; + file contents must already be bytes so a retry resends them unchanged. + """ url = self._build_url(path) request_headers = self._build_headers(headers) + if files is not None: + # httpx sets the multipart content type with its boundary. + for key in [k for k in request_headers if k.lower() == "content-type"]: + del request_headers[key] retries = 0 last_exception: Optional[Exception] = None @@ -85,6 +95,8 @@ def request( method, url, json=json, + data=data, + files=files, headers=request_headers, extensions={"stream": stream} if stream else None, ) @@ -196,10 +208,20 @@ async def request( json: Optional[Dict[str, Any]] = None, headers: Optional[Mapping[str, str]] = None, stream: bool = False, + data: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = None, ) -> httpx.Response: - """Make an HTTP request.""" + """Make an HTTP request. + + With `files`, the body is multipart/form-data built from `data` and `files`; + file contents must already be bytes so a retry resends them unchanged. + """ url = self._build_url(path) request_headers = self._build_headers(headers) + if files is not None: + # httpx sets the multipart content type with its boundary. + for key in [k for k in request_headers if k.lower() == "content-type"]: + del request_headers[key] retries = 0 last_exception: Optional[Exception] = None @@ -210,6 +232,8 @@ async def request( method, url, json=json, + data=data, + files=files, headers=request_headers, extensions={"stream": stream} if stream else None, ) diff --git a/apertis/_client.py b/apertis/_client.py index dab0f08..d5f3615 100644 --- a/apertis/_client.py +++ b/apertis/_client.py @@ -5,6 +5,7 @@ from typing import Mapping from apertis._base_client import AsyncClient, SyncClient +from apertis.resources.audio import AsyncAudio, Audio from apertis.resources.chat import AsyncChat, Chat from apertis.resources.embeddings import AsyncEmbeddings, Embeddings from apertis.resources.models import AsyncModels, Models @@ -58,6 +59,7 @@ def __init__( self.responses = Responses(self._client) self.messages = Messages(self._client) self.rerank = Rerank(self._client) + self.audio = Audio(self._client) def close(self) -> None: """Close the underlying HTTP client.""" @@ -115,6 +117,7 @@ def __init__( self.responses = AsyncResponses(self._client) self.messages = AsyncMessages(self._client) self.rerank = AsyncRerank(self._client) + self.audio = AsyncAudio(self._client) async def close(self) -> None: """Close the underlying HTTP client.""" diff --git a/apertis/resources/audio.py b/apertis/resources/audio.py new file mode 100644 index 0000000..a6e025c --- /dev/null +++ b/apertis/resources/audio.py @@ -0,0 +1,346 @@ +"""Audio resource: speech, transcriptions and translations.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import IO, TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union + +from apertis.types.audio import Transcription, Translation + +if TYPE_CHECKING: + import httpx + + from apertis._base_client import AsyncClient, SyncClient + +# A path, an open binary file, raw bytes, or a (filename, bytes[, content_type]) tuple. +FileContent = Union[bytes, IO[bytes]] +FileTypes = Union[ + str, + "os.PathLike[str]", + bytes, + IO[bytes], + Tuple[str, FileContent], + Tuple[str, FileContent, str], +] +AudioResponseFormat = Literal["json", "text", "srt", "verbose_json", "vtt"] +_TEXT_FORMATS = {"text", "srt", "vtt"} + + +class BinaryResponseContent: + """Binary body of an API response, such as generated speech.""" + + def __init__(self, response: httpx.Response) -> None: + self.response = response + + @property + def content(self) -> bytes: + return self.response.content + + def write_to_file(self, file: Union[str, os.PathLike[str]]) -> None: + """Write the bytes to `file`.""" + Path(file).write_bytes(self.content) + + +def _read_file(file: FileTypes) -> Tuple[Any, ...]: + """Return an httpx file tuple, reading the file once so retries resend the same bytes.""" + if isinstance(file, tuple): + name, content, *content_type = file + return (name, content if isinstance(content, bytes) else content.read(), *content_type) + if isinstance(file, bytes): + return ("upload", file) + if isinstance(file, (str, os.PathLike)): + path = Path(file) + return (path.name, path.read_bytes()) + file_name = getattr(file, "name", None) + return (os.path.basename(file_name) if isinstance(file_name, str) else "upload", file.read()) + + +def _form_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (dict, list)): + return json.dumps(value) + return str(value) + + +def _form(fields: Dict[str, Any]) -> Dict[str, Any]: + """Multipart form fields: drop None, keep `name[]` lists as repeated fields, JSON-encode objects.""" + form: Dict[str, Any] = {} + for key, value in fields.items(): + if value is None: + continue + if key.endswith("[]") and isinstance(value, list): + form[key] = [_form_value(v) for v in value] + else: + form[key] = _form_value(value) + return form + + +def _speech_body( + model: str, + input: str, + voice: str, + instructions: Optional[str], + response_format: Optional[str], + speed: Optional[float], + extra_body: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + body: Dict[str, Any] = {"model": model, "input": input, "voice": voice} + if instructions is not None: + body["instructions"] = instructions + if response_format is not None: + body["response_format"] = response_format + if speed is not None: + body["speed"] = speed + if extra_body: + body.update(extra_body) + return body + + +def _parse_text_result( + response: httpx.Response, response_format: Optional[str], model: Any +) -> Any: + if response_format in _TEXT_FORMATS: + return response.text + return model.model_validate(response.json()) + + +class Speech: + """Text to speech (POST /audio/speech).""" + + def __init__(self, client: SyncClient) -> None: + self._client = client + + def create( + self, + *, + model: str, + input: str, + voice: str, + instructions: Optional[str] = None, + response_format: Optional[Literal["mp3", "opus", "aac", "flac", "wav", "pcm"]] = None, + speed: Optional[float] = None, + extra_body: Optional[Dict[str, Any]] = None, + ) -> BinaryResponseContent: + """Generate audio from text. + + Args: + model: TTS model ID (e.g., "gpt-4o-mini-tts"). + input: Text to speak. + voice: Voice name (e.g., "alloy"). + instructions: Style instructions, for models that support them. + response_format: Audio format; the API default is mp3. + speed: Playback speed, 0.25 to 4.0. + extra_body: Extra fields merged into the request body. + + Returns: + BinaryResponseContent; use `.content` or `.write_to_file(path)`. + """ + body = _speech_body(model, input, voice, instructions, response_format, speed, extra_body) + return BinaryResponseContent(self._client.request("POST", "/audio/speech", json=body)) + + +class Transcriptions: + """Speech to text (POST /audio/transcriptions).""" + + def __init__(self, client: SyncClient) -> None: + self._client = client + + def create( + self, + *, + file: FileTypes, + model: str, + language: Optional[str] = None, + prompt: Optional[str] = None, + response_format: Optional[AudioResponseFormat] = None, + temperature: Optional[float] = None, + timestamp_granularities: Optional[List[Literal["word", "segment"]]] = None, + extra_body: Optional[Dict[str, Any]] = None, + ) -> Union[Transcription, str]: + """Transcribe audio. + + Args: + file: Path, open binary file, bytes, or (filename, bytes). The filename's + extension tells the API the audio format. + model: Transcription model ID (e.g., "whisper-1"). + language: ISO-639-1 language of the audio. + prompt: Text to guide the style or continue a previous segment. + response_format: json (default), verbose_json, text, srt or vtt. + temperature: Sampling temperature, 0 to 1. + timestamp_granularities: "word" and/or "segment"; needs verbose_json. + extra_body: Extra form fields. + + Returns: + Transcription for json / verbose_json, otherwise the response text. + """ + data = _form( + { + "model": model, + "language": language, + "prompt": prompt, + "response_format": response_format, + "temperature": temperature, + "timestamp_granularities[]": timestamp_granularities, + **(extra_body or {}), + } + ) + response = self._client.request( + "POST", "/audio/transcriptions", data=data, files={"file": _read_file(file)} + ) + result: Union[Transcription, str] = _parse_text_result( + response, response_format, Transcription + ) + return result + + +class Translations: + """Speech to English text (POST /audio/translations).""" + + def __init__(self, client: SyncClient) -> None: + self._client = client + + def create( + self, + *, + file: FileTypes, + model: str, + prompt: Optional[str] = None, + response_format: Optional[AudioResponseFormat] = None, + temperature: Optional[float] = None, + extra_body: Optional[Dict[str, Any]] = None, + ) -> Union[Translation, str]: + """Translate audio into English text. See Transcriptions.create() for the arguments.""" + data = _form( + { + "model": model, + "prompt": prompt, + "response_format": response_format, + "temperature": temperature, + **(extra_body or {}), + } + ) + response = self._client.request( + "POST", "/audio/translations", data=data, files={"file": _read_file(file)} + ) + result: Union[Translation, str] = _parse_text_result( + response, response_format, Translation + ) + return result + + +class Audio: + """Audio resource: `speech`, `transcriptions`, `translations`.""" + + def __init__(self, client: SyncClient) -> None: + self.speech = Speech(client) + self.transcriptions = Transcriptions(client) + self.translations = Translations(client) + + +class AsyncSpeech: + """Asynchronous text to speech.""" + + def __init__(self, client: AsyncClient) -> None: + self._client = client + + async def create( + self, + *, + model: str, + input: str, + voice: str, + instructions: Optional[str] = None, + response_format: Optional[Literal["mp3", "opus", "aac", "flac", "wav", "pcm"]] = None, + speed: Optional[float] = None, + extra_body: Optional[Dict[str, Any]] = None, + ) -> BinaryResponseContent: + """Generate audio from text. See Speech.create().""" + body = _speech_body(model, input, voice, instructions, response_format, speed, extra_body) + response = await self._client.request("POST", "/audio/speech", json=body) + return BinaryResponseContent(response) + + +class AsyncTranscriptions: + """Asynchronous speech to text.""" + + def __init__(self, client: AsyncClient) -> None: + self._client = client + + async def create( + self, + *, + file: FileTypes, + model: str, + language: Optional[str] = None, + prompt: Optional[str] = None, + response_format: Optional[AudioResponseFormat] = None, + temperature: Optional[float] = None, + timestamp_granularities: Optional[List[Literal["word", "segment"]]] = None, + extra_body: Optional[Dict[str, Any]] = None, + ) -> Union[Transcription, str]: + """Transcribe audio. See Transcriptions.create().""" + data = _form( + { + "model": model, + "language": language, + "prompt": prompt, + "response_format": response_format, + "temperature": temperature, + "timestamp_granularities[]": timestamp_granularities, + **(extra_body or {}), + } + ) + response = await self._client.request( + "POST", "/audio/transcriptions", data=data, files={"file": _read_file(file)} + ) + result: Union[Transcription, str] = _parse_text_result( + response, response_format, Transcription + ) + return result + + +class AsyncTranslations: + """Asynchronous speech to English text.""" + + def __init__(self, client: AsyncClient) -> None: + self._client = client + + async def create( + self, + *, + file: FileTypes, + model: str, + prompt: Optional[str] = None, + response_format: Optional[AudioResponseFormat] = None, + temperature: Optional[float] = None, + extra_body: Optional[Dict[str, Any]] = None, + ) -> Union[Translation, str]: + """Translate audio into English text. See Transcriptions.create().""" + data = _form( + { + "model": model, + "prompt": prompt, + "response_format": response_format, + "temperature": temperature, + **(extra_body or {}), + } + ) + response = await self._client.request( + "POST", "/audio/translations", data=data, files={"file": _read_file(file)} + ) + result: Union[Translation, str] = _parse_text_result( + response, response_format, Translation + ) + return result + + +class AsyncAudio: + """Asynchronous audio resource.""" + + def __init__(self, client: AsyncClient) -> None: + self.speech = AsyncSpeech(client) + self.transcriptions = AsyncTranscriptions(client) + self.translations = AsyncTranslations(client) diff --git a/apertis/types/__init__.py b/apertis/types/__init__.py index ca7b915..7772b49 100644 --- a/apertis/types/__init__.py +++ b/apertis/types/__init__.py @@ -38,6 +38,10 @@ GeminiThinkingConfig, StreamOptions, ) +from apertis.types.audio import ( + Transcription, + Translation, +) from apertis.types.embeddings import ( Embedding, EmbeddingResponse, @@ -87,6 +91,9 @@ ) __all__ = [ + # Audio + "Transcription", + "Translation", # Chat - Response types "ChatCompletion", "ChatCompletionChunk", diff --git a/apertis/types/audio.py b/apertis/types/audio.py new file mode 100644 index 0000000..fc1bfd1 --- /dev/null +++ b/apertis/types/audio.py @@ -0,0 +1,21 @@ +"""Audio API type definitions.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class Transcription(BaseModel): + """Transcription result. verbose_json fields (segments, words, ...) are kept as extras.""" + + model_config = ConfigDict(extra="allow") + + text: str + + +class Translation(BaseModel): + """Translation result. verbose_json fields are kept as extras.""" + + model_config = ConfigDict(extra="allow") + + text: str diff --git a/openspec/changes/add-audio-resource/.openspec.yaml b/openspec/changes/add-audio-resource/.openspec.yaml new file mode 100644 index 0000000..a26662f --- /dev/null +++ b/openspec/changes/add-audio-resource/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-07 diff --git a/openspec/changes/add-audio-resource/proposal.md b/openspec/changes/add-audio-resource/proposal.md new file mode 100644 index 0000000..617659c --- /dev/null +++ b/openspec/changes/add-audio-resource/proposal.md @@ -0,0 +1,19 @@ +## Why + +The gateway serves `POST /v1/audio/speech`, `/v1/audio/transcriptions` and +`/v1/audio/translations`, but the SDK has no audio resource (issue #5), so callers use +the OpenAI SDK instead. + +## What Changes + +- `client.audio.speech.create()` returns the binary audio with a `write_to_file()` helper. +- `client.audio.transcriptions.create()` and `client.audio.translations.create()` upload + the file as multipart form data and return a typed result, or plain text for the + text formats. +- The HTTP client can send multipart requests: it omits the JSON content type and + reads the file once, so a retry resends the same bytes. + +## Impact + +- New `apertis/resources/audio.py` and `apertis/types/audio.py`; `apertis/_base_client.py`, + `apertis/_client.py`, exports, tests and README. Existing JSON requests are unchanged. diff --git a/openspec/changes/add-audio-resource/specs/audio-resource/spec.md b/openspec/changes/add-audio-resource/specs/audio-resource/spec.md new file mode 100644 index 0000000..d8e229d --- /dev/null +++ b/openspec/changes/add-audio-resource/specs/audio-resource/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Text to speech +`client.audio.speech.create` SHALL post `model`, `input`, `voice` and any given optional +fields as JSON to `/audio/speech` on sync and async clients, and return an object +exposing the raw audio bytes and a `write_to_file(path)` helper. + +#### Scenario: Save speech +- **WHEN** a caller creates speech and calls `write_to_file` +- **THEN** the file holds exactly the bytes the API returned + +### Requirement: Transcription and translation uploads +`client.audio.transcriptions.create` and `client.audio.translations.create` SHALL send +the audio file and fields as `multipart/form-data` to `/audio/transcriptions` and +`/audio/translations`, accepting a path, an open binary file, raw bytes, or a +`(filename, bytes)` tuple. They SHALL return a typed object with `text` for the +`json` and `verbose_json` formats, keeping any extra fields, and a `str` for the +`text`, `srt` and `vtt` formats. + +#### Scenario: Upload from a path +- **WHEN** a caller passes a file path +- **THEN** the request is multipart with the file under `file` and its base name as the + filename, and the response `text` is returned + +#### Scenario: Retry resends the file +- **WHEN** the first attempt gets a retryable error +- **THEN** the retry sends the same file bytes + +#### Scenario: Subtitle format +- **WHEN** `response_format="srt"` +- **THEN** the call returns the response body as a string diff --git a/openspec/changes/add-audio-resource/tasks.md b/openspec/changes/add-audio-resource/tasks.md new file mode 100644 index 0000000..2b798e4 --- /dev/null +++ b/openspec/changes/add-audio-resource/tasks.md @@ -0,0 +1,6 @@ +## 1. Audio resource + +- [x] 1.1 Tests first: speech bytes and write_to_file, transcription from path / tuple, retry, srt text, translation, async +- [x] 1.2 Multipart support in the HTTP client +- [x] 1.3 Audio resource and types; wire into the clients and exports +- [x] 1.4 README and changelog diff --git a/tests/test_audio.py b/tests/test_audio.py new file mode 100644 index 0000000..ca5ba43 --- /dev/null +++ b/tests/test_audio.py @@ -0,0 +1,202 @@ +"""Tests for the audio resource (speech, transcriptions, translations).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import respx + +from apertis import Apertis, AsyncApertis +from apertis.types.audio import Transcription, Translation + +BASE = "https://api.apertis.ai/v1" +AUDIO = b"ID3\x00fake-mp3-bytes" + + +def _multipart(request: httpx.Request) -> bytes: + assert request.headers["content-type"].startswith("multipart/form-data; boundary=") + return request.content + + +class TestSpeech: + @respx.mock + def test_speech_returns_bytes_and_writes_file(self, client: Apertis, tmp_path: Path) -> None: + route = respx.post(f"{BASE}/audio/speech").mock( + return_value=httpx.Response(200, content=AUDIO, headers={"content-type": "audio/mpeg"}) + ) + + speech = client.audio.speech.create( + model="gpt-4o-mini-tts", input="Hello", voice="alloy", response_format="mp3", speed=1.25 + ) + + body = json.loads(route.calls[0].request.content) + assert body == { + "model": "gpt-4o-mini-tts", + "input": "Hello", + "voice": "alloy", + "response_format": "mp3", + "speed": 1.25, + } + assert speech.content == AUDIO + out = tmp_path / "hello.mp3" + speech.write_to_file(out) + assert out.read_bytes() == AUDIO + + @respx.mock + async def test_async_speech(self, async_client: AsyncApertis) -> None: + respx.post(f"{BASE}/audio/speech").mock(return_value=httpx.Response(200, content=AUDIO)) + + speech = await async_client.audio.speech.create( + model="gpt-4o-mini-tts", input="Hello", voice="alloy" + ) + + assert speech.content == AUDIO + + +class TestTranscriptions: + @respx.mock + def test_transcription_from_path(self, client: Apertis, tmp_path: Path) -> None: + audio_file = tmp_path / "meeting.mp3" + audio_file.write_bytes(AUDIO) + route = respx.post(f"{BASE}/audio/transcriptions").mock( + return_value=httpx.Response(200, json={"text": "Hello there.", "language": "en"}) + ) + + result = client.audio.transcriptions.create( + file=audio_file, + model="whisper-1", + language="en", + temperature=0, + timestamp_granularities=["word", "segment"], + ) + + content = _multipart(route.calls[0].request) + assert b'name="file"; filename="meeting.mp3"' in content + assert AUDIO in content + assert b'name="model"\r\n\r\nwhisper-1' in content + assert b'name="language"\r\n\r\nen' in content + assert b'name="temperature"\r\n\r\n0' in content + assert content.count(b'name="timestamp_granularities[]"') == 2 + assert isinstance(result, Transcription) + assert result.text == "Hello there." + # verbose_json fields the SDK does not model are kept. + assert result.model_dump()["language"] == "en" + + @respx.mock + def test_retry_resends_file_bytes(self, client: Apertis) -> None: + route = respx.post(f"{BASE}/audio/transcriptions").mock( + side_effect=[ + httpx.Response(503, json={"error": {"message": "busy"}}), + httpx.Response(200, json={"text": "ok"}), + ] + ) + + with open(__file__, "rb") as f: + source = f.read() + f.seek(0) + result = client.audio.transcriptions.create(file=f, model="whisper-1") + + assert result.text == "ok" + assert len(route.calls) == 2 + for call in route.calls: + assert source in _multipart(call.request) + + @respx.mock + def test_srt_returns_text(self, client: Apertis) -> None: + srt = "1\n00:00:00,000 --> 00:00:01,000\nHello\n" + route = respx.post(f"{BASE}/audio/transcriptions").mock( + return_value=httpx.Response(200, text=srt, headers={"content-type": "text/plain"}) + ) + + result = client.audio.transcriptions.create( + file=("clip.wav", AUDIO), model="whisper-1", response_format="srt" + ) + + assert result == srt + content = _multipart(route.calls[0].request) + assert b'filename="clip.wav"' in content + assert b'name="response_format"\r\n\r\nsrt' in content + + @respx.mock + async def test_async_transcription_from_bytes(self, async_client: AsyncApertis) -> None: + route = respx.post(f"{BASE}/audio/transcriptions").mock( + return_value=httpx.Response(200, json={"text": "async ok"}) + ) + + result = await async_client.audio.transcriptions.create(file=AUDIO, model="whisper-1") + + assert result.text == "async ok" + assert AUDIO in _multipart(route.calls[0].request) + + +class TestTranslations: + @respx.mock + def test_translation(self, client: Apertis) -> None: + route = respx.post(f"{BASE}/audio/translations").mock( + return_value=httpx.Response(200, json={"text": "Good morning."}) + ) + + result = client.audio.translations.create( + file=("bonjour.m4a", AUDIO), model="whisper-1", prompt="greeting" + ) + + assert isinstance(result, Translation) + assert result.text == "Good morning." + content = _multipart(route.calls[0].request) + assert b'name="prompt"\r\n\r\ngreeting' in content + + @respx.mock + async def test_async_translation_text(self, async_client: AsyncApertis) -> None: + respx.post(f"{BASE}/audio/translations").mock( + return_value=httpx.Response(200, text="Good morning.") + ) + + result = await async_client.audio.translations.create( + file=("bonjour.m4a", AUDIO), model="whisper-1", response_format="text" + ) + + assert result == "Good morning." + + +class TestMultipartEdgeCases: + @respx.mock + def test_lowercase_default_content_type_is_dropped(self) -> None: + route = respx.post(f"{BASE}/audio/transcriptions").mock( + return_value=httpx.Response(200, json={"text": "ok"}) + ) + + with Apertis(default_headers={"content-type": "application/json"}) as client: + client.audio.transcriptions.create(file=("a.mp3", AUDIO), model="whisper-1") + + _multipart(route.calls[0].request) + + @respx.mock + def test_form_values_bool_and_object(self, client: Apertis) -> None: + route = respx.post(f"{BASE}/audio/transcriptions").mock( + return_value=httpx.Response(200, json={"text": "ok"}) + ) + + client.audio.transcriptions.create( + file=("a.mp3", AUDIO, "audio/mpeg"), + model="whisper-1", + extra_body={"diarize": True, "chunking_strategy": {"type": "auto"}}, + ) + + content = _multipart(route.calls[0].request) + assert b'name="diarize"\r\n\r\ntrue' in content + assert b'name="chunking_strategy"\r\n\r\n{"type": "auto"}' in content + assert b"Content-Type: audio/mpeg" in content + + @respx.mock + def test_file_object_without_str_name(self, client: Apertis, tmp_path: Path) -> None: + import os + + respx.post(f"{BASE}/audio/transcriptions").mock( + return_value=httpx.Response(200, json={"text": "ok"}) + ) + path = tmp_path / "a.mp3" + path.write_bytes(AUDIO) + with os.fdopen(os.open(path, os.O_RDONLY), "rb") as f: + assert client.audio.transcriptions.create(file=f, model="whisper-1").text == "ok"