Skip to content

Commit 483c1dc

Browse files
njbrakeclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 77c822a commit 483c1dc

6 files changed

Lines changed: 392 additions & 3 deletions

File tree

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,42 @@ for item in result.results:
230230
print(item.index, item.relevance_score)
231231
```
232232

233+
### Image generation
234+
235+
```python
236+
result = client.image_generation(
237+
model="openai:dall-e-3",
238+
prompt="A watercolor fox in a misty forest",
239+
)
240+
241+
print(result["data"][0]["url"])
242+
```
243+
244+
The gateway returns the OpenAI-compatible image payload as a dict.
245+
246+
### Audio
247+
248+
Text to speech returns the raw audio bytes:
249+
250+
```python
251+
audio = client.speech(
252+
model="openai:tts-1",
253+
input="Hello from otari.",
254+
voice="alloy",
255+
)
256+
Path("speech.mp3").write_bytes(audio)
257+
```
258+
259+
Transcription uploads audio bytes and returns the parsed response:
260+
261+
```python
262+
result = client.transcription(
263+
model="openai:whisper-1",
264+
file=Path("speech.mp3").read_bytes(),
265+
)
266+
print(result["text"])
267+
```
268+
233269
### Batch operations
234270

235271
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`.

sdk-endpoints.txt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,17 @@ GET /v1/pricing/{model_key}/history
5454
DELETE /v1/pricing/{model_key}
5555
# Control plane: usage
5656
GET /v1/usage
57+
# Images
58+
POST /v1/images/generations
59+
# Audio
60+
POST /v1/audio/speech
61+
POST /v1/audio/transcriptions
5762

5863
[excluded]
59-
POST /v1/audio/speech # binary, not yet wrapped
60-
POST /v1/audio/transcriptions # binary, not yet wrapped
61-
POST /v1/images/generations # binary, not yet wrapped
6264
GET /v1/models/{model_id} # redundant, list_models covers discovery
65+
# Files API: added to the gateway spec, not yet wrapped by any SDK shell.
66+
POST /v1/files # not yet wrapped
67+
GET /v1/files # not yet wrapped
68+
GET /v1/files/{file_id} # not yet wrapped
69+
GET /v1/files/{file_id}/content # not yet wrapped
70+
DELETE /v1/files/{file_id} # not yet wrapped

src/otari/async_client.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from otari._client.api.batches_api import BatchesApi
3737
from otari._client.api.chat_api import ChatApi
3838
from otari._client.api.embeddings_api import EmbeddingsApi
39+
from otari._client.api.images_api import ImagesApi
3940
from otari._client.api.messages_api import MessagesApi
4041
from otari._client.api.models_api import ModelsApi
4142
from otari._client.api.moderations_api import ModerationsApi
@@ -46,6 +47,7 @@
4647
from otari._client.models.count_tokens_request import CountTokensRequest
4748
from otari._client.models.create_batch_request import CreateBatchRequest
4849
from otari._client.models.embedding_request import EmbeddingRequest
50+
from otari._client.models.image_generation_request import ImageGenerationRequest
4951
from otari._client.models.messages_request import MessagesRequest
5052
from otari._client.models.moderation_request import ModerationRequest
5153
from otari._client.models.rerank_request import RerankRequest
@@ -126,6 +128,7 @@ def __init__(
126128
self._rerank = RerankApi(self._api)
127129
self._messages = MessagesApi(self._api)
128130
self._models = ModelsApi(self._api)
131+
self._images = ImagesApi(self._api)
129132
self._batches = BatchesApi(self._api)
130133

131134
@cached_property
@@ -301,6 +304,91 @@ async def rerank(
301304
result = await self._call(lambda: self._rerank.create_rerank_v1_rerank_post(request))
302305
return cast("RerankResponse", result)
303306

307+
# -- Images -------------------------------------------------------------
308+
309+
async def image_generation(
310+
self,
311+
*,
312+
model: str,
313+
prompt: str,
314+
**kwargs: Any,
315+
) -> dict[str, Any]:
316+
"""Generate images from a text prompt.
317+
318+
Returns the gateway's OpenAI-compatible image payload as a dict
319+
(``{"created": ..., "data": [...]}``). The generated core models this
320+
response as an opaque object, so the parsed JSON is returned unchanged.
321+
322+
Args:
323+
model: Model identifier (e.g. ``"openai:dall-e-3"``).
324+
prompt: Text prompt describing the desired image(s).
325+
**kwargs: Additional parameters (``n``, ``size``, ``quality``,
326+
``response_format``, ``style``, ``user``).
327+
"""
328+
request = build_request(
329+
ImageGenerationRequest, {"model": model, "prompt": prompt, **kwargs}
330+
)
331+
result = await self._call(
332+
lambda: self._images.create_image_v1_images_generations_post(request)
333+
)
334+
return cast("dict[str, Any]", result)
335+
336+
# -- Audio --------------------------------------------------------------
337+
338+
async def speech(
339+
self,
340+
*,
341+
model: str,
342+
input: str, # noqa: A002
343+
voice: str,
344+
**kwargs: Any,
345+
) -> bytes:
346+
"""Synthesize speech (text-to-speech), returning raw audio bytes.
347+
348+
The gateway returns binary audio (``audio/mpeg`` by default) with no
349+
JSON response model, so this posts over httpx and returns the raw
350+
``bytes``.
351+
352+
Args:
353+
model: Model identifier (e.g. ``"openai:tts-1"``).
354+
input: Text to synthesize.
355+
voice: Voice to use (e.g. ``"alloy"``).
356+
**kwargs: Additional parameters (``response_format``, ``speed``,
357+
``instructions``, ``user``).
358+
"""
359+
body = {"model": model, "input": input, "voice": voice, **kwargs}
360+
response = await self._post("/audio/speech", json=body)
361+
return response.content
362+
363+
async def transcription(
364+
self,
365+
*,
366+
model: str,
367+
file: bytes,
368+
filename: str = "audio",
369+
**kwargs: Any,
370+
) -> Any:
371+
"""Transcribe audio to text.
372+
373+
``file`` is the raw audio bytes uploaded as multipart form data. Returns
374+
the parsed JSON (a dict) for JSON response formats, or the raw text for
375+
``text`` / ``srt`` / ``vtt`` formats.
376+
377+
Args:
378+
model: Model identifier (e.g. ``"openai:whisper-1"``).
379+
file: Raw audio bytes to transcribe.
380+
filename: Filename for the multipart upload (some providers infer
381+
the audio format from its extension).
382+
**kwargs: Additional parameters (``language``, ``prompt``,
383+
``response_format``, ``temperature``, ``user``).
384+
"""
385+
data = {"model": model, **{key: str(value) for key, value in kwargs.items()}}
386+
files = {"file": (filename, file)}
387+
response = await self._post("/audio/transcriptions", data=data, files=files)
388+
if "application/json" in response.headers.get("content-type", ""):
389+
return response.json()
390+
return response.text
391+
304392
# -- Models -------------------------------------------------------------
305393

306394
async def list_models(self) -> list[ModelObject]:
@@ -378,6 +466,28 @@ async def _call(self, fn: Callable[[], Any]) -> Any:
378466
except ApiException as exc:
379467
raise self._map_api_exception(exc) from exc
380468

469+
async def _post(
470+
self,
471+
path: str,
472+
*,
473+
json: dict[str, Any] | None = None,
474+
data: dict[str, Any] | None = None,
475+
files: dict[str, Any] | None = None,
476+
) -> httpx.Response:
477+
"""Issue a non-streaming raw httpx POST, mapping error responses.
478+
479+
Audio endpoints (binary speech, multipart transcription) do not fit the
480+
generated JSON core, so they post directly over httpx and reuse the same
481+
error mapping as the streaming shim.
482+
"""
483+
url = f"{self._base_url}{path}"
484+
response = await self._http.post(
485+
url, headers=self._default_headers, json=json, data=data, files=files
486+
)
487+
if response.status_code >= 400:
488+
raise self._map_streaming_response(response, response.content)
489+
return response
490+
381491
async def _stream(self, path: str, body: dict[str, Any], kind: Any) -> AsyncIterator[Any]:
382492
"""Open a raw async streaming POST and yield parsed SSE chunks."""
383493
url = f"{self._base_url}{path}"

src/otari/client.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from otari._client.api.batches_api import BatchesApi
3737
from otari._client.api.chat_api import ChatApi
3838
from otari._client.api.embeddings_api import EmbeddingsApi
39+
from otari._client.api.images_api import ImagesApi
3940
from otari._client.api.messages_api import MessagesApi
4041
from otari._client.api.models_api import ModelsApi
4142
from otari._client.api.moderations_api import ModerationsApi
@@ -46,6 +47,7 @@
4647
from otari._client.models.count_tokens_request import CountTokensRequest
4748
from otari._client.models.create_batch_request import CreateBatchRequest
4849
from otari._client.models.embedding_request import EmbeddingRequest
50+
from otari._client.models.image_generation_request import ImageGenerationRequest
4951
from otari._client.models.messages_request import MessagesRequest
5052
from otari._client.models.moderation_request import ModerationRequest
5153
from otari._client.models.rerank_request import RerankRequest
@@ -132,6 +134,7 @@ def __init__(
132134
self._rerank = RerankApi(self._api)
133135
self._messages = MessagesApi(self._api)
134136
self._models = ModelsApi(self._api)
137+
self._images = ImagesApi(self._api)
135138
self._batches = BatchesApi(self._api)
136139

137140
@cached_property
@@ -327,6 +330,89 @@ def rerank(
327330
result = self._call(lambda: self._rerank.create_rerank_v1_rerank_post(request))
328331
return cast("RerankResponse", result)
329332

333+
# -- Images -------------------------------------------------------------
334+
335+
def image_generation(
336+
self,
337+
*,
338+
model: str,
339+
prompt: str,
340+
**kwargs: Any,
341+
) -> dict[str, Any]:
342+
"""Generate images from a text prompt.
343+
344+
Returns the gateway's OpenAI-compatible image payload as a dict
345+
(``{"created": ..., "data": [...]}``). The generated core models this
346+
response as an opaque object, so the parsed JSON is returned unchanged.
347+
348+
Args:
349+
model: Model identifier (e.g. ``"openai:dall-e-3"``).
350+
prompt: Text prompt describing the desired image(s).
351+
**kwargs: Additional parameters (``n``, ``size``, ``quality``,
352+
``response_format``, ``style``, ``user``).
353+
"""
354+
request = build_request(
355+
ImageGenerationRequest, {"model": model, "prompt": prompt, **kwargs}
356+
)
357+
result = self._call(lambda: self._images.create_image_v1_images_generations_post(request))
358+
return cast("dict[str, Any]", result)
359+
360+
# -- Audio --------------------------------------------------------------
361+
362+
def speech(
363+
self,
364+
*,
365+
model: str,
366+
input: str, # noqa: A002
367+
voice: str,
368+
**kwargs: Any,
369+
) -> bytes:
370+
"""Synthesize speech (text-to-speech), returning raw audio bytes.
371+
372+
The gateway returns binary audio (``audio/mpeg`` by default) with no
373+
JSON response model, so this posts over httpx and returns the raw
374+
``bytes``.
375+
376+
Args:
377+
model: Model identifier (e.g. ``"openai:tts-1"``).
378+
input: Text to synthesize.
379+
voice: Voice to use (e.g. ``"alloy"``).
380+
**kwargs: Additional parameters (``response_format``, ``speed``,
381+
``instructions``, ``user``).
382+
"""
383+
body = {"model": model, "input": input, "voice": voice, **kwargs}
384+
response = self._post("/audio/speech", json=body)
385+
return response.content
386+
387+
def transcription(
388+
self,
389+
*,
390+
model: str,
391+
file: bytes,
392+
filename: str = "audio",
393+
**kwargs: Any,
394+
) -> Any:
395+
"""Transcribe audio to text.
396+
397+
``file`` is the raw audio bytes uploaded as multipart form data. Returns
398+
the parsed JSON (a dict) for JSON response formats, or the raw text for
399+
``text`` / ``srt`` / ``vtt`` formats.
400+
401+
Args:
402+
model: Model identifier (e.g. ``"openai:whisper-1"``).
403+
file: Raw audio bytes to transcribe.
404+
filename: Filename for the multipart upload (some providers infer
405+
the audio format from its extension).
406+
**kwargs: Additional parameters (``language``, ``prompt``,
407+
``response_format``, ``temperature``, ``user``).
408+
"""
409+
data = {"model": model, **{key: str(value) for key, value in kwargs.items()}}
410+
files = {"file": (filename, file)}
411+
response = self._post("/audio/transcriptions", data=data, files=files)
412+
if "application/json" in response.headers.get("content-type", ""):
413+
return response.json()
414+
return response.text
415+
330416
# -- Models -------------------------------------------------------------
331417

332418
def list_models(self) -> list[ModelObject]:
@@ -404,6 +490,28 @@ def _call(self, fn: Callable[[], Any]) -> Any:
404490
except ApiException as exc:
405491
raise self._map_api_exception(exc) from exc
406492

493+
def _post(
494+
self,
495+
path: str,
496+
*,
497+
json: dict[str, Any] | None = None,
498+
data: dict[str, Any] | None = None,
499+
files: dict[str, Any] | None = None,
500+
) -> httpx.Response:
501+
"""Issue a non-streaming raw httpx POST, mapping error responses.
502+
503+
Audio endpoints (binary speech, multipart transcription) do not fit the
504+
generated JSON core, so they post directly over httpx and reuse the same
505+
error mapping as the streaming shim.
506+
"""
507+
url = f"{self._base_url}{path}"
508+
response = self._http.post(
509+
url, headers=self._default_headers, json=json, data=data, files=files
510+
)
511+
if response.status_code >= 400:
512+
raise self._map_streaming_response(response, response.content)
513+
return response
514+
407515
def _stream(self, path: str, body: dict[str, Any], kind: Any) -> Iterator[Any]:
408516
"""Open a raw streaming POST and yield parsed SSE chunks.
409517

0 commit comments

Comments
 (0)