From 483c1dc117d2f678032a214737257cb433dd33f1 Mon Sep 17 00:00:00 2001 From: njbrake Date: Tue, 16 Jun 2026 15:03:51 +0000 Subject: [PATCH 1/3] feat: add image generation and audio (speech/transcription) methods Expose image_generation, speech, and transcription on both OtariClient and AsyncOtariClient so callers (notably any-llm's otari provider) can route image generation and audio through the public client surface instead of reaching into the generated otari._client internals. image_generation goes through the generated ImagesApi (typed request, opaque JSON object response returned as a dict). speech and transcription do not fit the generated JSON core (binary audio out, multipart upload in), so they post over the existing httpx client via a small _post helper that reuses the same error mapping as the streaming shim: speech returns raw bytes, transcription returns the parsed JSON dict (or text for text/srt/vtt formats). Move the three endpoints from [excluded] to [covered] in sdk-endpoints.txt. Also defer the gateway's newly added /v1/files endpoints under [excluded] (not yet wrapped by any SDK) so the endpoint-coverage drift gate passes; wrapping the files API is tracked separately. Closes mozilla-ai/otari#138 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 36 +++++++++++ sdk-endpoints.txt | 14 +++- src/otari/async_client.py | 110 ++++++++++++++++++++++++++++++++ src/otari/client.py | 108 +++++++++++++++++++++++++++++++ tests/unit/test_async_client.py | 46 +++++++++++++ tests/unit/test_client.py | 81 +++++++++++++++++++++++ 6 files changed, 392 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d529e56..60b3892 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,42 @@ for item in result.results: print(item.index, item.relevance_score) ``` +### Image generation + +```python +result = client.image_generation( + model="openai:dall-e-3", + prompt="A watercolor fox in a misty forest", +) + +print(result["data"][0]["url"]) +``` + +The gateway returns the OpenAI-compatible image payload as a dict. + +### Audio + +Text to speech returns the raw audio bytes: + +```python +audio = client.speech( + model="openai:tts-1", + input="Hello from otari.", + voice="alloy", +) +Path("speech.mp3").write_bytes(audio) +``` + +Transcription uploads audio bytes and returns the parsed response: + +```python +result = client.transcription( + model="openai:whisper-1", + file=Path("speech.mp3").read_bytes(), +) +print(result["text"]) +``` + ### Batch operations Submit many requests as a single batch job, poll for status, then fetch results once the batch completes. Batch endpoints are scoped to a `provider`. diff --git a/sdk-endpoints.txt b/sdk-endpoints.txt index acf2b2a..aadf669 100644 --- a/sdk-endpoints.txt +++ b/sdk-endpoints.txt @@ -54,9 +54,17 @@ GET /v1/pricing/{model_key}/history DELETE /v1/pricing/{model_key} # Control plane: usage GET /v1/usage +# Images +POST /v1/images/generations +# Audio +POST /v1/audio/speech +POST /v1/audio/transcriptions [excluded] -POST /v1/audio/speech # binary, not yet wrapped -POST /v1/audio/transcriptions # binary, not yet wrapped -POST /v1/images/generations # binary, not yet wrapped GET /v1/models/{model_id} # redundant, list_models covers discovery +# Files API: added to the gateway spec, not yet wrapped by any SDK shell. +POST /v1/files # not yet wrapped +GET /v1/files # not yet wrapped +GET /v1/files/{file_id} # not yet wrapped +GET /v1/files/{file_id}/content # not yet wrapped +DELETE /v1/files/{file_id} # not yet wrapped diff --git a/src/otari/async_client.py b/src/otari/async_client.py index 992a0dd..6695316 100644 --- a/src/otari/async_client.py +++ b/src/otari/async_client.py @@ -36,6 +36,7 @@ from otari._client.api.batches_api import BatchesApi from otari._client.api.chat_api import ChatApi from otari._client.api.embeddings_api import EmbeddingsApi +from otari._client.api.images_api import ImagesApi from otari._client.api.messages_api import MessagesApi from otari._client.api.models_api import ModelsApi from otari._client.api.moderations_api import ModerationsApi @@ -46,6 +47,7 @@ from otari._client.models.count_tokens_request import CountTokensRequest from otari._client.models.create_batch_request import CreateBatchRequest from otari._client.models.embedding_request import EmbeddingRequest +from otari._client.models.image_generation_request import ImageGenerationRequest from otari._client.models.messages_request import MessagesRequest from otari._client.models.moderation_request import ModerationRequest from otari._client.models.rerank_request import RerankRequest @@ -126,6 +128,7 @@ def __init__( self._rerank = RerankApi(self._api) self._messages = MessagesApi(self._api) self._models = ModelsApi(self._api) + self._images = ImagesApi(self._api) self._batches = BatchesApi(self._api) @cached_property @@ -301,6 +304,91 @@ async def rerank( result = await self._call(lambda: self._rerank.create_rerank_v1_rerank_post(request)) return cast("RerankResponse", result) + # -- Images ------------------------------------------------------------- + + async def image_generation( + self, + *, + model: str, + prompt: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Generate images from a text prompt. + + Returns the gateway's OpenAI-compatible image payload as a dict + (``{"created": ..., "data": [...]}``). The generated core models this + response as an opaque object, so the parsed JSON is returned unchanged. + + Args: + model: Model identifier (e.g. ``"openai:dall-e-3"``). + prompt: Text prompt describing the desired image(s). + **kwargs: Additional parameters (``n``, ``size``, ``quality``, + ``response_format``, ``style``, ``user``). + """ + request = build_request( + ImageGenerationRequest, {"model": model, "prompt": prompt, **kwargs} + ) + result = await self._call( + lambda: self._images.create_image_v1_images_generations_post(request) + ) + return cast("dict[str, Any]", result) + + # -- Audio -------------------------------------------------------------- + + async def speech( + self, + *, + model: str, + input: str, # noqa: A002 + voice: str, + **kwargs: Any, + ) -> bytes: + """Synthesize speech (text-to-speech), returning raw audio bytes. + + The gateway returns binary audio (``audio/mpeg`` by default) with no + JSON response model, so this posts over httpx and returns the raw + ``bytes``. + + Args: + model: Model identifier (e.g. ``"openai:tts-1"``). + input: Text to synthesize. + voice: Voice to use (e.g. ``"alloy"``). + **kwargs: Additional parameters (``response_format``, ``speed``, + ``instructions``, ``user``). + """ + body = {"model": model, "input": input, "voice": voice, **kwargs} + response = await self._post("/audio/speech", json=body) + return response.content + + async def transcription( + self, + *, + model: str, + file: bytes, + filename: str = "audio", + **kwargs: Any, + ) -> Any: + """Transcribe audio to text. + + ``file`` is the raw audio bytes uploaded as multipart form data. Returns + the parsed JSON (a dict) for JSON response formats, or the raw text for + ``text`` / ``srt`` / ``vtt`` formats. + + Args: + model: Model identifier (e.g. ``"openai:whisper-1"``). + file: Raw audio bytes to transcribe. + filename: Filename for the multipart upload (some providers infer + the audio format from its extension). + **kwargs: Additional parameters (``language``, ``prompt``, + ``response_format``, ``temperature``, ``user``). + """ + data = {"model": model, **{key: str(value) for key, value in kwargs.items()}} + files = {"file": (filename, file)} + response = await self._post("/audio/transcriptions", data=data, files=files) + if "application/json" in response.headers.get("content-type", ""): + return response.json() + return response.text + # -- Models ------------------------------------------------------------- async def list_models(self) -> list[ModelObject]: @@ -378,6 +466,28 @@ async def _call(self, fn: Callable[[], Any]) -> Any: except ApiException as exc: raise self._map_api_exception(exc) from exc + async def _post( + self, + path: str, + *, + json: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, + ) -> httpx.Response: + """Issue a non-streaming raw httpx POST, mapping error responses. + + Audio endpoints (binary speech, multipart transcription) do not fit the + generated JSON core, so they post directly over httpx and reuse the same + error mapping as the streaming shim. + """ + url = f"{self._base_url}{path}" + response = await self._http.post( + url, headers=self._default_headers, json=json, data=data, files=files + ) + if response.status_code >= 400: + raise self._map_streaming_response(response, response.content) + return response + async def _stream(self, path: str, body: dict[str, Any], kind: Any) -> AsyncIterator[Any]: """Open a raw async streaming POST and yield parsed SSE chunks.""" url = f"{self._base_url}{path}" diff --git a/src/otari/client.py b/src/otari/client.py index 1b3f995..35bad69 100644 --- a/src/otari/client.py +++ b/src/otari/client.py @@ -36,6 +36,7 @@ from otari._client.api.batches_api import BatchesApi from otari._client.api.chat_api import ChatApi from otari._client.api.embeddings_api import EmbeddingsApi +from otari._client.api.images_api import ImagesApi from otari._client.api.messages_api import MessagesApi from otari._client.api.models_api import ModelsApi from otari._client.api.moderations_api import ModerationsApi @@ -46,6 +47,7 @@ from otari._client.models.count_tokens_request import CountTokensRequest from otari._client.models.create_batch_request import CreateBatchRequest from otari._client.models.embedding_request import EmbeddingRequest +from otari._client.models.image_generation_request import ImageGenerationRequest from otari._client.models.messages_request import MessagesRequest from otari._client.models.moderation_request import ModerationRequest from otari._client.models.rerank_request import RerankRequest @@ -132,6 +134,7 @@ def __init__( self._rerank = RerankApi(self._api) self._messages = MessagesApi(self._api) self._models = ModelsApi(self._api) + self._images = ImagesApi(self._api) self._batches = BatchesApi(self._api) @cached_property @@ -327,6 +330,89 @@ def rerank( result = self._call(lambda: self._rerank.create_rerank_v1_rerank_post(request)) return cast("RerankResponse", result) + # -- Images ------------------------------------------------------------- + + def image_generation( + self, + *, + model: str, + prompt: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Generate images from a text prompt. + + Returns the gateway's OpenAI-compatible image payload as a dict + (``{"created": ..., "data": [...]}``). The generated core models this + response as an opaque object, so the parsed JSON is returned unchanged. + + Args: + model: Model identifier (e.g. ``"openai:dall-e-3"``). + prompt: Text prompt describing the desired image(s). + **kwargs: Additional parameters (``n``, ``size``, ``quality``, + ``response_format``, ``style``, ``user``). + """ + request = build_request( + ImageGenerationRequest, {"model": model, "prompt": prompt, **kwargs} + ) + result = self._call(lambda: self._images.create_image_v1_images_generations_post(request)) + return cast("dict[str, Any]", result) + + # -- Audio -------------------------------------------------------------- + + def speech( + self, + *, + model: str, + input: str, # noqa: A002 + voice: str, + **kwargs: Any, + ) -> bytes: + """Synthesize speech (text-to-speech), returning raw audio bytes. + + The gateway returns binary audio (``audio/mpeg`` by default) with no + JSON response model, so this posts over httpx and returns the raw + ``bytes``. + + Args: + model: Model identifier (e.g. ``"openai:tts-1"``). + input: Text to synthesize. + voice: Voice to use (e.g. ``"alloy"``). + **kwargs: Additional parameters (``response_format``, ``speed``, + ``instructions``, ``user``). + """ + body = {"model": model, "input": input, "voice": voice, **kwargs} + response = self._post("/audio/speech", json=body) + return response.content + + def transcription( + self, + *, + model: str, + file: bytes, + filename: str = "audio", + **kwargs: Any, + ) -> Any: + """Transcribe audio to text. + + ``file`` is the raw audio bytes uploaded as multipart form data. Returns + the parsed JSON (a dict) for JSON response formats, or the raw text for + ``text`` / ``srt`` / ``vtt`` formats. + + Args: + model: Model identifier (e.g. ``"openai:whisper-1"``). + file: Raw audio bytes to transcribe. + filename: Filename for the multipart upload (some providers infer + the audio format from its extension). + **kwargs: Additional parameters (``language``, ``prompt``, + ``response_format``, ``temperature``, ``user``). + """ + data = {"model": model, **{key: str(value) for key, value in kwargs.items()}} + files = {"file": (filename, file)} + response = self._post("/audio/transcriptions", data=data, files=files) + if "application/json" in response.headers.get("content-type", ""): + return response.json() + return response.text + # -- Models ------------------------------------------------------------- def list_models(self) -> list[ModelObject]: @@ -404,6 +490,28 @@ def _call(self, fn: Callable[[], Any]) -> Any: except ApiException as exc: raise self._map_api_exception(exc) from exc + def _post( + self, + path: str, + *, + json: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, + ) -> httpx.Response: + """Issue a non-streaming raw httpx POST, mapping error responses. + + Audio endpoints (binary speech, multipart transcription) do not fit the + generated JSON core, so they post directly over httpx and reuse the same + error mapping as the streaming shim. + """ + url = f"{self._base_url}{path}" + response = self._http.post( + url, headers=self._default_headers, json=json, data=data, files=files + ) + if response.status_code >= 400: + raise self._map_streaming_response(response, response.content) + return response + def _stream(self, path: str, body: dict[str, Any], kind: Any) -> Iterator[Any]: """Open a raw streaming POST and yield parsed SSE chunks. diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index d7c1245..4e0f2d0 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -30,9 +30,11 @@ CHAT_RESPONSE, COUNT_TOKENS_RESPONSE, EMBEDDING_RESPONSE, + IMAGE_RESPONSE, MESSAGE_RESPONSE, MODELS_RESPONSE, RERANK_RESPONSE, + TRANSCRIPTION_RESPONSE, _sse, ) @@ -168,6 +170,50 @@ async def test_streaming_error_maps(self) -> None: _ = [chunk async for chunk in stream] +class TestImages: + async def test_image_generation_returns_dict(self, mock_rest: Any) -> None: + mock = mock_rest(status=200, body=IMAGE_RESPONSE) + client = AsyncOtariClient(api_base="http://localhost:8000", api_key="vk") + result = await client.image_generation(model="openai:dall-e-3", prompt="a cat") + assert result == IMAGE_RESPONSE + assert mock.last.url.endswith("/v1/images/generations") + + +class TestAudio: + @respx.mock + async def test_speech_returns_bytes(self) -> None: + route = respx.post("http://localhost:8000/v1/audio/speech").mock( + return_value=httpx.Response( + 200, headers={"content-type": "audio/mpeg"}, content=b"AUDIO" + ) + ) + client = AsyncOtariClient(api_base="http://localhost:8000", api_key="vk") + audio = await client.speech(model="openai:tts-1", input="hi", voice="alloy") + assert audio == b"AUDIO" + assert route.calls.last.request.headers["otari-key"] == "Bearer vk" + + @respx.mock + async def test_speech_maps_errors(self) -> None: + respx.post("http://localhost:8000/v1/audio/speech").mock( + return_value=httpx.Response(429, json={"detail": "slow down"}) + ) + client = AsyncOtariClient(api_base="http://localhost:8000", api_key="vk") + with pytest.raises(RateLimitError): + await client.speech(model="m", input="hi", voice="alloy") + + @respx.mock + async def test_transcription_returns_json(self) -> None: + route = respx.post("http://localhost:8000/v1/audio/transcriptions").mock( + return_value=httpx.Response(200, json=TRANSCRIPTION_RESPONSE) + ) + client = AsyncOtariClient(api_base="http://localhost:8000", api_key="vk") + result = await client.transcription(model="openai:whisper-1", file=b"\x00\x01") + assert result == TRANSCRIPTION_RESPONSE + request = route.calls.last.request + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="file"' in request.content + + class TestControlPlane: def test_requires_admin_credential(self) -> None: client = AsyncOtariClient(api_base="http://localhost:8000", api_key="vk") diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index bf0027a..7469ee5 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -77,6 +77,13 @@ "data": [{"id": "openai:gpt-4o", "object": "model", "created": 1, "owned_by": "openai"}], } +IMAGE_RESPONSE: dict[str, Any] = { + "created": 1, + "data": [{"url": "https://example.com/image.png"}], +} + +TRANSCRIPTION_RESPONSE: dict[str, Any] = {"text": "hello world"} + def _sse(*events: str) -> bytes: """Build a ``text/event-stream`` body from JSON event strings + the DONE sentinel.""" @@ -372,6 +379,80 @@ def test_platform_mode_streaming_sends_bearer(self) -> None: assert route.calls.last.request.headers["authorization"] == "Bearer tk" +# --------------------------------------------------------------------------- +# Images (generated core) + audio (raw httpx) +# --------------------------------------------------------------------------- + + +class TestImages: + def test_image_generation_returns_dict(self, mock_rest: Any) -> None: + mock = mock_rest(status=200, body=IMAGE_RESPONSE) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + result = client.image_generation(model="openai:dall-e-3", prompt="a cat") + assert result == IMAGE_RESPONSE + assert mock.last.url.endswith("/v1/images/generations") + body = mock.last.json_body + assert body["model"] == "openai:dall-e-3" + assert body["prompt"] == "a cat" + + def test_image_generation_maps_errors(self, mock_rest: Any) -> None: + mock_rest(status=402, body={"detail": "no funds"}, reason="err") + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + with pytest.raises(InsufficientFundsError): + client.image_generation(model="m", prompt="x") + + +class TestAudio: + @respx.mock + def test_speech_returns_bytes(self) -> None: + route = respx.post("http://localhost:8000/v1/audio/speech").mock( + return_value=httpx.Response( + 200, headers={"content-type": "audio/mpeg"}, content=b"AUDIO" + ) + ) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + audio = client.speech(model="openai:tts-1", input="hi", voice="alloy") + assert audio == b"AUDIO" + request = route.calls.last.request + assert request.headers["otari-key"] == "Bearer vk" + assert request.headers["content-type"] == "application/json" + + @respx.mock + def test_speech_maps_errors(self) -> None: + respx.post("http://localhost:8000/v1/audio/speech").mock( + return_value=httpx.Response(429, json={"detail": "slow down"}) + ) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + with pytest.raises(RateLimitError): + client.speech(model="m", input="hi", voice="alloy") + + @respx.mock + def test_transcription_returns_json(self) -> None: + route = respx.post("http://localhost:8000/v1/audio/transcriptions").mock( + return_value=httpx.Response(200, json=TRANSCRIPTION_RESPONSE) + ) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + result = client.transcription(model="openai:whisper-1", file=b"\x00\x01") + assert result == TRANSCRIPTION_RESPONSE + request = route.calls.last.request + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="model"' in request.content + assert b'name="file"' in request.content + + @respx.mock + def test_transcription_returns_text(self) -> None: + respx.post("http://localhost:8000/v1/audio/transcriptions").mock( + return_value=httpx.Response( + 200, headers={"content-type": "text/plain"}, content=b"hello" + ) + ) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + result = client.transcription( + model="m", file=b"\x00", response_format="text" + ) + assert result == "hello" + + # --------------------------------------------------------------------------- # Control-plane accessor # --------------------------------------------------------------------------- From ed7800f72898d09c6f223f7c115ff83260fdde44 Mon Sep 17 00:00:00 2001 From: njbrake Date: Tue, 16 Jun 2026 15:59:11 +0000 Subject: [PATCH 2/3] refactor: return a structured TranscriptionResult from transcription Replace the loosely typed Any return (a dict or a str depending on the response format) with a TranscriptionResult dataclass exposing two fields: json (set for json / verbose_json formats) and text (set for text / srt / vtt formats). This gives the transcription method a single explicit return type and keeps it uniform with the other otari SDKs. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- src/otari/__init__.py | 2 ++ src/otari/async_client.py | 12 ++++++++---- src/otari/client.py | 12 ++++++++---- src/otari/types.py | 21 +++++++++++++++++++++ tests/unit/test_async_client.py | 3 ++- tests/unit/test_client.py | 6 ++++-- 7 files changed, 46 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 60b3892..628499b 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ result = client.transcription( model="openai:whisper-1", file=Path("speech.mp3").read_bytes(), ) -print(result["text"]) +print(result.json["text"]) ``` ### Batch operations diff --git a/src/otari/__init__.py b/src/otari/__init__.py index 7c492c6..2e9970f 100644 --- a/src/otari/__init__.py +++ b/src/otari/__init__.py @@ -51,6 +51,7 @@ ModerationResponse, OtariClientOptions, RerankResponse, + TranscriptionResult, ) try: @@ -84,6 +85,7 @@ "OtariError", "RateLimitError", "RerankResponse", + "TranscriptionResult", "UnsupportedCapabilityError", "UpstreamProviderError", ] diff --git a/src/otari/async_client.py b/src/otari/async_client.py index 6695316..056d73e 100644 --- a/src/otari/async_client.py +++ b/src/otari/async_client.py @@ -69,6 +69,7 @@ BatchResult, CreateBatchParams, ListBatchesOptions, + TranscriptionResult, ) @@ -367,11 +368,12 @@ async def transcription( file: bytes, filename: str = "audio", **kwargs: Any, - ) -> Any: + ) -> TranscriptionResult: """Transcribe audio to text. ``file`` is the raw audio bytes uploaded as multipart form data. Returns - the parsed JSON (a dict) for JSON response formats, or the raw text for + a :class:`~otari.types.TranscriptionResult` whose ``json`` field is set + for JSON response formats and whose ``text`` field is set for the ``text`` / ``srt`` / ``vtt`` formats. Args: @@ -382,12 +384,14 @@ async def transcription( **kwargs: Additional parameters (``language``, ``prompt``, ``response_format``, ``temperature``, ``user``). """ + from otari.types import TranscriptionResult # noqa: PLC0415 + data = {"model": model, **{key: str(value) for key, value in kwargs.items()}} files = {"file": (filename, file)} response = await self._post("/audio/transcriptions", data=data, files=files) if "application/json" in response.headers.get("content-type", ""): - return response.json() - return response.text + return TranscriptionResult(json=response.json()) + return TranscriptionResult(text=response.text) # -- Models ------------------------------------------------------------- diff --git a/src/otari/client.py b/src/otari/client.py index 35bad69..3f86a84 100644 --- a/src/otari/client.py +++ b/src/otari/client.py @@ -69,6 +69,7 @@ BatchResult, CreateBatchParams, ListBatchesOptions, + TranscriptionResult, ) @@ -391,11 +392,12 @@ def transcription( file: bytes, filename: str = "audio", **kwargs: Any, - ) -> Any: + ) -> TranscriptionResult: """Transcribe audio to text. ``file`` is the raw audio bytes uploaded as multipart form data. Returns - the parsed JSON (a dict) for JSON response formats, or the raw text for + a :class:`~otari.types.TranscriptionResult` whose ``json`` field is set + for JSON response formats and whose ``text`` field is set for the ``text`` / ``srt`` / ``vtt`` formats. Args: @@ -406,12 +408,14 @@ def transcription( **kwargs: Additional parameters (``language``, ``prompt``, ``response_format``, ``temperature``, ``user``). """ + from otari.types import TranscriptionResult # noqa: PLC0415 + data = {"model": model, **{key: str(value) for key, value in kwargs.items()}} files = {"file": (filename, file)} response = self._post("/audio/transcriptions", data=data, files=files) if "application/json" in response.headers.get("content-type", ""): - return response.json() - return response.text + return TranscriptionResult(json=response.json()) + return TranscriptionResult(text=response.text) # -- Models ------------------------------------------------------------- diff --git a/src/otari/types.py b/src/otari/types.py index 15b1a02..722fd79 100644 --- a/src/otari/types.py +++ b/src/otari/types.py @@ -109,3 +109,24 @@ class BatchResult: """Aggregated results of a completed batch job.""" results: list[BatchResultItem] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Audio types +# --------------------------------------------------------------------------- + + +@dataclass +class TranscriptionResult: + """Result of an audio transcription request. + + Exactly one field is populated, chosen by the gateway response's content + type: ``json`` for the default ``json`` / ``verbose_json`` formats, ``text`` + for the plain ``text`` / ``srt`` / ``vtt`` formats. + """ + + json: dict[str, Any] | None = None + """Parsed JSON response, for ``json`` / ``verbose_json`` formats.""" + + text: str | None = None + """Raw text response, for ``text`` / ``srt`` / ``vtt`` formats.""" diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index 4e0f2d0..b1bcb5a 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -208,7 +208,8 @@ async def test_transcription_returns_json(self) -> None: ) client = AsyncOtariClient(api_base="http://localhost:8000", api_key="vk") result = await client.transcription(model="openai:whisper-1", file=b"\x00\x01") - assert result == TRANSCRIPTION_RESPONSE + assert result.json == TRANSCRIPTION_RESPONSE + assert result.text is None request = route.calls.last.request assert request.headers["content-type"].startswith("multipart/form-data") assert b'name="file"' in request.content diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 7469ee5..e49a872 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -433,7 +433,8 @@ def test_transcription_returns_json(self) -> None: ) client = OtariClient(api_base="http://localhost:8000", api_key="vk") result = client.transcription(model="openai:whisper-1", file=b"\x00\x01") - assert result == TRANSCRIPTION_RESPONSE + assert result.json == TRANSCRIPTION_RESPONSE + assert result.text is None request = route.calls.last.request assert request.headers["content-type"].startswith("multipart/form-data") assert b'name="model"' in request.content @@ -450,7 +451,8 @@ def test_transcription_returns_text(self) -> None: result = client.transcription( model="m", file=b"\x00", response_format="text" ) - assert result == "hello" + assert result.text == "hello" + assert result.json is None # --------------------------------------------------------------------------- From 8ee185f9414988f0f7f4a4e220c990a16b013769 Mon Sep 17 00:00:00 2001 From: njbrake Date: Tue, 16 Jun 2026 18:23:46 +0000 Subject: [PATCH 3/3] feat: return the typed ImagesResponse from image_generation Now that the regenerated core types the /v1/images/generations response (gateway enrich_spec injects any-llm's ImagesResponse), return the typed model from both OtariClient.image_generation and AsyncOtariClient.image_generation instead of an untyped dict, matching embedding / rerank / moderation. Updates tests and the README example to use attribute access. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++-- src/otari/async_client.py | 10 +++++----- src/otari/client.py | 10 +++++----- tests/unit/test_async_client.py | 5 +++-- tests/unit/test_client.py | 5 +++-- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 628499b..f672d87 100644 --- a/README.md +++ b/README.md @@ -238,10 +238,10 @@ result = client.image_generation( prompt="A watercolor fox in a misty forest", ) -print(result["data"][0]["url"]) +print(result.data[0].url) ``` -The gateway returns the OpenAI-compatible image payload as a dict. +The gateway returns a typed OpenAI-compatible `ImagesResponse`. ### Audio diff --git a/src/otari/async_client.py b/src/otari/async_client.py index 056d73e..c37f101 100644 --- a/src/otari/async_client.py +++ b/src/otari/async_client.py @@ -62,6 +62,7 @@ from otari._client.models.chat_completion_chunk import ChatCompletionChunk from otari._client.models.count_tokens_response import CountTokensResponse from otari._client.models.create_embedding_response import CreateEmbeddingResponse + from otari._client.models.images_response import ImagesResponse from otari._client.models.model_object import ModelObject from otari._client.models.moderation_response import ModerationResponse from otari._client.models.rerank_response import RerankResponse @@ -313,12 +314,11 @@ async def image_generation( model: str, prompt: str, **kwargs: Any, - ) -> dict[str, Any]: + ) -> ImagesResponse: """Generate images from a text prompt. - Returns the gateway's OpenAI-compatible image payload as a dict - (``{"created": ..., "data": [...]}``). The generated core models this - response as an opaque object, so the parsed JSON is returned unchanged. + Returns the gateway's OpenAI-compatible + :class:`~otari._client.models.images_response.ImagesResponse`. Args: model: Model identifier (e.g. ``"openai:dall-e-3"``). @@ -332,7 +332,7 @@ async def image_generation( result = await self._call( lambda: self._images.create_image_v1_images_generations_post(request) ) - return cast("dict[str, Any]", result) + return cast("ImagesResponse", result) # -- Audio -------------------------------------------------------------- diff --git a/src/otari/client.py b/src/otari/client.py index 3f86a84..7d73ab1 100644 --- a/src/otari/client.py +++ b/src/otari/client.py @@ -62,6 +62,7 @@ from otari._client.models.chat_completion_chunk import ChatCompletionChunk from otari._client.models.count_tokens_response import CountTokensResponse from otari._client.models.create_embedding_response import CreateEmbeddingResponse + from otari._client.models.images_response import ImagesResponse from otari._client.models.model_object import ModelObject from otari._client.models.moderation_response import ModerationResponse from otari._client.models.rerank_response import RerankResponse @@ -339,12 +340,11 @@ def image_generation( model: str, prompt: str, **kwargs: Any, - ) -> dict[str, Any]: + ) -> ImagesResponse: """Generate images from a text prompt. - Returns the gateway's OpenAI-compatible image payload as a dict - (``{"created": ..., "data": [...]}``). The generated core models this - response as an opaque object, so the parsed JSON is returned unchanged. + Returns the gateway's OpenAI-compatible + :class:`~otari._client.models.images_response.ImagesResponse`. Args: model: Model identifier (e.g. ``"openai:dall-e-3"``). @@ -356,7 +356,7 @@ def image_generation( ImageGenerationRequest, {"model": model, "prompt": prompt, **kwargs} ) result = self._call(lambda: self._images.create_image_v1_images_generations_post(request)) - return cast("dict[str, Any]", result) + return cast("ImagesResponse", result) # -- Audio -------------------------------------------------------------- diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index b1bcb5a..cdfb9fb 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -171,11 +171,12 @@ async def test_streaming_error_maps(self) -> None: class TestImages: - async def test_image_generation_returns_dict(self, mock_rest: Any) -> None: + async def test_image_generation_returns_typed(self, mock_rest: Any) -> None: mock = mock_rest(status=200, body=IMAGE_RESPONSE) client = AsyncOtariClient(api_base="http://localhost:8000", api_key="vk") result = await client.image_generation(model="openai:dall-e-3", prompt="a cat") - assert result == IMAGE_RESPONSE + assert result.created == 1 + assert result.data[0].url == "https://example.com/image.png" assert mock.last.url.endswith("/v1/images/generations") diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e49a872..9b70716 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -385,11 +385,12 @@ def test_platform_mode_streaming_sends_bearer(self) -> None: class TestImages: - def test_image_generation_returns_dict(self, mock_rest: Any) -> None: + def test_image_generation_returns_typed(self, mock_rest: Any) -> None: mock = mock_rest(status=200, body=IMAGE_RESPONSE) client = OtariClient(api_base="http://localhost:8000", api_key="vk") result = client.image_generation(model="openai:dall-e-3", prompt="a cat") - assert result == IMAGE_RESPONSE + assert result.created == 1 + assert result.data[0].url == "https://example.com/image.png" assert mock.last.url.endswith("/v1/images/generations") body = mock.last.json_body assert body["model"] == "openai:dall-e-3"