Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 a typed OpenAI-compatible `ImagesResponse`.

### 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.json["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`.
Expand Down
8 changes: 5 additions & 3 deletions sdk-endpoints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@ 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
Expand Down
2 changes: 2 additions & 0 deletions src/otari/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
ModerationResponse,
OtariClientOptions,
RerankResponse,
TranscriptionResult,
)

try:
Expand Down Expand Up @@ -84,6 +85,7 @@
"OtariError",
"RateLimitError",
"RerankResponse",
"TranscriptionResult",
"UnsupportedCapabilityError",
"UpstreamProviderError",
]
114 changes: 114 additions & 0 deletions src/otari/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -60,13 +62,15 @@
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
from otari.types import (
BatchResult,
CreateBatchParams,
ListBatchesOptions,
TranscriptionResult,
)


Expand Down Expand Up @@ -126,6 +130,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
Expand Down Expand Up @@ -301,6 +306,93 @@ 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,
) -> ImagesResponse:
"""Generate images from a text prompt.

Returns the gateway's OpenAI-compatible
:class:`~otari._client.models.images_response.ImagesResponse`.

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("ImagesResponse", 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,
) -> TranscriptionResult:
"""Transcribe audio to text.

``file`` is the raw audio bytes uploaded as multipart form data. Returns
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:
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``).
"""
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 TranscriptionResult(json=response.json())
return TranscriptionResult(text=response.text)

# -- Models -------------------------------------------------------------

async def list_models(self) -> list[ModelObject]:
Expand Down Expand Up @@ -378,6 +470,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}"
Expand Down
112 changes: 112 additions & 0 deletions src/otari/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -60,13 +62,15 @@
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
from otari.types import (
BatchResult,
CreateBatchParams,
ListBatchesOptions,
TranscriptionResult,
)


Expand Down Expand Up @@ -132,6 +136,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
Expand Down Expand Up @@ -327,6 +332,91 @@ 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,
) -> ImagesResponse:
"""Generate images from a text prompt.

Returns the gateway's OpenAI-compatible
:class:`~otari._client.models.images_response.ImagesResponse`.

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("ImagesResponse", 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,
) -> TranscriptionResult:
"""Transcribe audio to text.

``file`` is the raw audio bytes uploaded as multipart form data. Returns
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:
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``).
"""
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 TranscriptionResult(json=response.json())
return TranscriptionResult(text=response.text)

# -- Models -------------------------------------------------------------

def list_models(self) -> list[ModelObject]:
Expand Down Expand Up @@ -404,6 +494,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.

Expand Down
Loading
Loading