Skip to content

Commit c558a03

Browse files
njbrakeclaude
andauthored
feat: add image generation and audio (speech/transcription) methods (#16)
* 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> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 65c7215 commit c558a03

8 files changed

Lines changed: 422 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 a typed OpenAI-compatible `ImagesResponse`.
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.json["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: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ 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
6365
# Files API: added to the gateway spec, not yet wrapped by any SDK shell.
6466
POST /v1/files # not yet wrapped

src/otari/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
ModerationResponse,
5252
OtariClientOptions,
5353
RerankResponse,
54+
TranscriptionResult,
5455
)
5556

5657
try:
@@ -84,6 +85,7 @@
8485
"OtariError",
8586
"RateLimitError",
8687
"RerankResponse",
88+
"TranscriptionResult",
8789
"UnsupportedCapabilityError",
8890
"UpstreamProviderError",
8991
]

src/otari/async_client.py

Lines changed: 114 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
@@ -60,13 +62,15 @@
6062
from otari._client.models.chat_completion_chunk import ChatCompletionChunk
6163
from otari._client.models.count_tokens_response import CountTokensResponse
6264
from otari._client.models.create_embedding_response import CreateEmbeddingResponse
65+
from otari._client.models.images_response import ImagesResponse
6366
from otari._client.models.model_object import ModelObject
6467
from otari._client.models.moderation_response import ModerationResponse
6568
from otari._client.models.rerank_response import RerankResponse
6669
from otari.types import (
6770
BatchResult,
6871
CreateBatchParams,
6972
ListBatchesOptions,
73+
TranscriptionResult,
7074
)
7175

7276

@@ -126,6 +130,7 @@ def __init__(
126130
self._rerank = RerankApi(self._api)
127131
self._messages = MessagesApi(self._api)
128132
self._models = ModelsApi(self._api)
133+
self._images = ImagesApi(self._api)
129134
self._batches = BatchesApi(self._api)
130135

131136
@cached_property
@@ -301,6 +306,93 @@ async def rerank(
301306
result = await self._call(lambda: self._rerank.create_rerank_v1_rerank_post(request))
302307
return cast("RerankResponse", result)
303308

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

306398
async def list_models(self) -> list[ModelObject]:
@@ -378,6 +470,28 @@ async def _call(self, fn: Callable[[], Any]) -> Any:
378470
except ApiException as exc:
379471
raise self._map_api_exception(exc) from exc
380472

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

src/otari/client.py

Lines changed: 112 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
@@ -60,13 +62,15 @@
6062
from otari._client.models.chat_completion_chunk import ChatCompletionChunk
6163
from otari._client.models.count_tokens_response import CountTokensResponse
6264
from otari._client.models.create_embedding_response import CreateEmbeddingResponse
65+
from otari._client.models.images_response import ImagesResponse
6366
from otari._client.models.model_object import ModelObject
6467
from otari._client.models.moderation_response import ModerationResponse
6568
from otari._client.models.rerank_response import RerankResponse
6669
from otari.types import (
6770
BatchResult,
6871
CreateBatchParams,
6972
ListBatchesOptions,
73+
TranscriptionResult,
7074
)
7175

7276

@@ -132,6 +136,7 @@ def __init__(
132136
self._rerank = RerankApi(self._api)
133137
self._messages = MessagesApi(self._api)
134138
self._models = ModelsApi(self._api)
139+
self._images = ImagesApi(self._api)
135140
self._batches = BatchesApi(self._api)
136141

137142
@cached_property
@@ -327,6 +332,91 @@ def rerank(
327332
result = self._call(lambda: self._rerank.create_rerank_v1_rerank_post(request))
328333
return cast("RerankResponse", result)
329334

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

332422
def list_models(self) -> list[ModelObject]:
@@ -404,6 +494,28 @@ def _call(self, fn: Callable[[], Any]) -> Any:
404494
except ApiException as exc:
405495
raise self._map_api_exception(exc) from exc
406496

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

0 commit comments

Comments
 (0)