|
36 | 36 | from otari._client.api.batches_api import BatchesApi |
37 | 37 | from otari._client.api.chat_api import ChatApi |
38 | 38 | from otari._client.api.embeddings_api import EmbeddingsApi |
| 39 | +from otari._client.api.images_api import ImagesApi |
39 | 40 | from otari._client.api.messages_api import MessagesApi |
40 | 41 | from otari._client.api.models_api import ModelsApi |
41 | 42 | from otari._client.api.moderations_api import ModerationsApi |
|
46 | 47 | from otari._client.models.count_tokens_request import CountTokensRequest |
47 | 48 | from otari._client.models.create_batch_request import CreateBatchRequest |
48 | 49 | from otari._client.models.embedding_request import EmbeddingRequest |
| 50 | +from otari._client.models.image_generation_request import ImageGenerationRequest |
49 | 51 | from otari._client.models.messages_request import MessagesRequest |
50 | 52 | from otari._client.models.moderation_request import ModerationRequest |
51 | 53 | from otari._client.models.rerank_request import RerankRequest |
|
60 | 62 | from otari._client.models.chat_completion_chunk import ChatCompletionChunk |
61 | 63 | from otari._client.models.count_tokens_response import CountTokensResponse |
62 | 64 | from otari._client.models.create_embedding_response import CreateEmbeddingResponse |
| 65 | + from otari._client.models.images_response import ImagesResponse |
63 | 66 | from otari._client.models.model_object import ModelObject |
64 | 67 | from otari._client.models.moderation_response import ModerationResponse |
65 | 68 | from otari._client.models.rerank_response import RerankResponse |
66 | 69 | from otari.types import ( |
67 | 70 | BatchResult, |
68 | 71 | CreateBatchParams, |
69 | 72 | ListBatchesOptions, |
| 73 | + TranscriptionResult, |
70 | 74 | ) |
71 | 75 |
|
72 | 76 |
|
@@ -126,6 +130,7 @@ def __init__( |
126 | 130 | self._rerank = RerankApi(self._api) |
127 | 131 | self._messages = MessagesApi(self._api) |
128 | 132 | self._models = ModelsApi(self._api) |
| 133 | + self._images = ImagesApi(self._api) |
129 | 134 | self._batches = BatchesApi(self._api) |
130 | 135 |
|
131 | 136 | @cached_property |
@@ -301,6 +306,93 @@ async def rerank( |
301 | 306 | result = await self._call(lambda: self._rerank.create_rerank_v1_rerank_post(request)) |
302 | 307 | return cast("RerankResponse", result) |
303 | 308 |
|
| 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 | + |
304 | 396 | # -- Models ------------------------------------------------------------- |
305 | 397 |
|
306 | 398 | async def list_models(self) -> list[ModelObject]: |
@@ -378,6 +470,28 @@ async def _call(self, fn: Callable[[], Any]) -> Any: |
378 | 470 | except ApiException as exc: |
379 | 471 | raise self._map_api_exception(exc) from exc |
380 | 472 |
|
| 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 | + |
381 | 495 | async def _stream(self, path: str, body: dict[str, Any], kind: Any) -> AsyncIterator[Any]: |
382 | 496 | """Open a raw async streaming POST and yield parsed SSE chunks.""" |
383 | 497 | url = f"{self._base_url}{path}" |
|
0 commit comments