diff --git a/README.md b/README.md index d529e56..f672d87 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 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`. diff --git a/sdk-endpoints.txt b/sdk-endpoints.txt index f5669fa..aadf669 100644 --- a/sdk-endpoints.txt +++ b/sdk-endpoints.txt @@ -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 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 992a0dd..c37f101 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 @@ -60,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 @@ -67,6 +70,7 @@ BatchResult, CreateBatchParams, ListBatchesOptions, + TranscriptionResult, ) @@ -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 @@ -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]: @@ -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}" diff --git a/src/otari/client.py b/src/otari/client.py index 1b3f995..7d73ab1 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 @@ -60,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 @@ -67,6 +70,7 @@ BatchResult, CreateBatchParams, ListBatchesOptions, + TranscriptionResult, ) @@ -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 @@ -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]: @@ -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. 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 d7c1245..cdfb9fb 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,52 @@ async def test_streaming_error_maps(self) -> None: _ = [chunk async for chunk in stream] +class TestImages: + 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.created == 1 + assert result.data[0].url == "https://example.com/image.png" + 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.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 + + 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..9b70716 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,83 @@ 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_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.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" + 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.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 + 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.text == "hello" + assert result.json is None + + # --------------------------------------------------------------------------- # Control-plane accessor # ---------------------------------------------------------------------------