From dd0ae0ce139ea8b7943c7f04a57c1a0f2fd20065 Mon Sep 17 00:00:00 2001 From: Hareesh Date: Tue, 8 Sep 2026 16:10:27 +0200 Subject: [PATCH] feat: add public Files API methods --- README.md | 20 +++++ src/otari/async_client.py | 105 +++++++++++++++++++++++++- src/otari/client.py | 105 +++++++++++++++++++++++++- tests/unit/test_async_client.py | 80 ++++++++++++++++++++ tests/unit/test_client.py | 126 ++++++++++++++++++++++++++++++++ 5 files changed, 430 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b3d034c..1c5524c 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,26 @@ result = client.transcription( print(result.json["text"]) ``` +### Files + +The Files API is currently available on standalone Otari gateways. Upload a +file, inspect or list its metadata, download its bytes, and delete it: + +```python +uploaded = client.upload_file( + file=Path("report.pdf").read_bytes(), + filename="report.pdf", + content_type="application/pdf", +) + +files = client.list_files(purpose="user_data") +metadata = client.retrieve_file(uploaded["id"]) +Path(metadata["filename"]).write_bytes(client.download_file(uploaded["id"])) +client.delete_file(uploaded["id"]) +``` + +The asynchronous client exposes the same methods as coroutines. + ### 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/src/otari/async_client.py b/src/otari/async_client.py index b594936..56b28f6 100644 --- a/src/otari/async_client.py +++ b/src/otari/async_client.py @@ -28,6 +28,7 @@ import asyncio from functools import cached_property from typing import TYPE_CHECKING, Any, Literal, cast, overload +from urllib.parse import quote import httpx @@ -36,6 +37,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.files_api import FilesApi from otari._client.api.images_api import ImagesApi from otari._client.api.messages_api import MessagesApi from otari._client.api.models_api import ModelsApi @@ -58,6 +60,7 @@ if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable + from uuid import UUID from otari._client.models.chat_completion import ChatCompletion from otari._client.models.chat_completion_chunk import ChatCompletionChunk @@ -128,6 +131,7 @@ def __init__( self._chat = ChatApi(self._api) self._responses = ResponsesApi(self._api) self._embeddings = EmbeddingsApi(self._api) + self._files = FilesApi(self._api) self._moderations = ModerationsApi(self._api) self._rerank = RerankApi(self._api) self._messages = MessagesApi(self._api) @@ -400,6 +404,89 @@ async def transcription( return TranscriptionResult(json=response.json()) return TranscriptionResult(text=response.text) + # -- Files -------------------------------------------------------------- + + async def upload_file( + self, + *, + file: bytes, + filename: str, + purpose: str = "user_data", + content_type: str | None = None, + user: str | None = None, + ) -> dict[str, Any]: + """Upload a file to a standalone Otari gateway. + + Args: + file: Raw file bytes. + filename: Filename sent in the multipart upload. + purpose: Caller-defined purpose, defaulting to ``"user_data"``. + content_type: Optional MIME type for the multipart file part. + user: User override accepted by the gateway's master-key flow. + """ + data = {"purpose": purpose} + if user is not None: + data["user"] = user + file_part = ( + (filename, file, content_type) if content_type is not None else (filename, file) + ) + response = await self._post("/files", data=data, files={"file": file_part}) + return cast("dict[str, Any]", response.json()) + + async def list_files( + self, + *, + purpose: str | None = None, + user: str | None = None, + workspace_id: UUID | None = None, + ) -> list[dict[str, Any]]: + """List files visible to the authenticated standalone gateway user.""" + result = await self._call( + lambda: self._files.list_files_v1_files_get( + user=user, + purpose=purpose, + workspace_id=workspace_id, + ) + ) + data = result.get("data", []) if isinstance(result, dict) else [] + return cast("list[dict[str, Any]]", data) + + async def retrieve_file( + self, + file_id: str, + *, + user: str | None = None, + ) -> dict[str, Any]: + """Retrieve metadata for a file from a standalone gateway.""" + result = await self._call( + lambda: self._files.get_file_v1_files_file_id_get(file_id, user=user) + ) + return cast("dict[str, Any]", result) + + async def download_file( + self, + file_id: str, + *, + user: str | None = None, + ) -> bytes: + """Download raw file bytes from a standalone gateway.""" + params = {"user": user} if user is not None else None + encoded_file_id = quote(file_id, safe="") + response = await self._get(f"/files/{encoded_file_id}/content", params=params) + return response.content + + async def delete_file( + self, + file_id: str, + *, + user: str | None = None, + ) -> dict[str, Any]: + """Delete a file from a standalone gateway.""" + result = await self._call( + lambda: self._files.delete_file_v1_files_file_id_delete(file_id, user=user) + ) + return cast("dict[str, Any]", result) + # -- Models ------------------------------------------------------------- async def list_models(self) -> list[ModelObject]: @@ -498,9 +585,8 @@ async def _post( ) -> 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. + Binary and multipart endpoints that do not fit the generated core use + raw httpx while retaining the SDK's typed error mapping. """ url = f"{self._base_url}{path}" response = await self._http.post( @@ -510,6 +596,19 @@ async def _post( raise self._map_streaming_response(response, response.content) return response + async def _get( + self, + path: str, + *, + params: dict[str, str] | None = None, + ) -> httpx.Response: + """Issue a non-streaming raw httpx GET, mapping error responses.""" + url = f"{self._base_url}{path}" + response = await self._http.get(url, headers=self._default_headers, params=params) + 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.""" async for chunk in self._iter_stream(path, body, kind): diff --git a/src/otari/client.py b/src/otari/client.py index 5a10349..c32b57c 100644 --- a/src/otari/client.py +++ b/src/otari/client.py @@ -28,6 +28,7 @@ from functools import cached_property from typing import TYPE_CHECKING, Any, Literal, cast, overload +from urllib.parse import quote import httpx @@ -36,6 +37,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.files_api import FilesApi from otari._client.api.images_api import ImagesApi from otari._client.api.messages_api import MessagesApi from otari._client.api.models_api import ModelsApi @@ -58,6 +60,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator + from uuid import UUID from otari._client.models.chat_completion import ChatCompletion from otari._client.models.chat_completion_chunk import ChatCompletionChunk @@ -134,6 +137,7 @@ def __init__( self._chat = ChatApi(self._api) self._responses = ResponsesApi(self._api) self._embeddings = EmbeddingsApi(self._api) + self._files = FilesApi(self._api) self._moderations = ModerationsApi(self._api) self._rerank = RerankApi(self._api) self._messages = MessagesApi(self._api) @@ -424,6 +428,89 @@ def transcription( return TranscriptionResult(json=response.json()) return TranscriptionResult(text=response.text) + # -- Files -------------------------------------------------------------- + + def upload_file( + self, + *, + file: bytes, + filename: str, + purpose: str = "user_data", + content_type: str | None = None, + user: str | None = None, + ) -> dict[str, Any]: + """Upload a file to a standalone Otari gateway. + + Args: + file: Raw file bytes. + filename: Filename sent in the multipart upload. + purpose: Caller-defined purpose, defaulting to ``"user_data"``. + content_type: Optional MIME type for the multipart file part. + user: User override accepted by the gateway's master-key flow. + """ + data = {"purpose": purpose} + if user is not None: + data["user"] = user + file_part = ( + (filename, file, content_type) if content_type is not None else (filename, file) + ) + response = self._post("/files", data=data, files={"file": file_part}) + return cast("dict[str, Any]", response.json()) + + def list_files( + self, + *, + purpose: str | None = None, + user: str | None = None, + workspace_id: UUID | None = None, + ) -> list[dict[str, Any]]: + """List files visible to the authenticated standalone gateway user.""" + result = self._call( + lambda: self._files.list_files_v1_files_get( + user=user, + purpose=purpose, + workspace_id=workspace_id, + ) + ) + data = result.get("data", []) if isinstance(result, dict) else [] + return cast("list[dict[str, Any]]", data) + + def retrieve_file( + self, + file_id: str, + *, + user: str | None = None, + ) -> dict[str, Any]: + """Retrieve metadata for a file from a standalone gateway.""" + result = self._call( + lambda: self._files.get_file_v1_files_file_id_get(file_id, user=user) + ) + return cast("dict[str, Any]", result) + + def download_file( + self, + file_id: str, + *, + user: str | None = None, + ) -> bytes: + """Download raw file bytes from a standalone gateway.""" + params = {"user": user} if user is not None else None + encoded_file_id = quote(file_id, safe="") + response = self._get(f"/files/{encoded_file_id}/content", params=params) + return response.content + + def delete_file( + self, + file_id: str, + *, + user: str | None = None, + ) -> dict[str, Any]: + """Delete a file from a standalone gateway.""" + result = self._call( + lambda: self._files.delete_file_v1_files_file_id_delete(file_id, user=user) + ) + return cast("dict[str, Any]", result) + # -- Models ------------------------------------------------------------- def list_models(self) -> list[ModelObject]: @@ -519,9 +606,8 @@ def _post( ) -> 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. + Binary and multipart endpoints that do not fit the generated core use + raw httpx while retaining the SDK's typed error mapping. """ url = f"{self._base_url}{path}" response = self._http.post( @@ -531,6 +617,19 @@ def _post( raise self._map_streaming_response(response, response.content) return response + def _get( + self, + path: str, + *, + params: dict[str, str] | None = None, + ) -> httpx.Response: + """Issue a non-streaming raw httpx GET, mapping error responses.""" + url = f"{self._base_url}{path}" + response = self._http.get(url, headers=self._default_headers, params=params) + 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.""" yield from self._iter_stream(path, body, kind) diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index 116ba2e..412ff06 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -31,6 +31,9 @@ CHAT_RESPONSE, COUNT_TOKENS_RESPONSE, EMBEDDING_RESPONSE, + FILE_DELETE_RESPONSE, + FILE_LIST_RESPONSE, + FILE_OBJECT, IMAGE_RESPONSE, MESSAGE_RESPONSE, MODELS_RESPONSE, @@ -308,6 +311,83 @@ async def test_transcription_returns_json(self) -> None: assert b'name="file"' in request.content +class TestFiles: + @respx.mock + async def test_upload_file_sends_multipart_bytes(self) -> None: + route = respx.post("http://localhost:8000/v1/files").mock( + return_value=httpx.Response(200, json=FILE_OBJECT) + ) + client = AsyncOtariClient( + api_base="http://localhost:8000", api_key="vk" + ) + + result = await client.upload_file( + file=b"PDF-CONTENT", + filename="report.pdf", + content_type="application/pdf", + ) + + assert result == FILE_OBJECT + request = route.calls.last.request + assert request.headers["otari-key"] == "Bearer vk" + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="file"; filename="report.pdf"' in request.content + assert b"Content-Type: application/pdf" in request.content + assert b"PDF-CONTENT" in request.content + + async def test_list_files_returns_data(self, mock_rest: Any) -> None: + mock = mock_rest(status=200, body=FILE_LIST_RESPONSE) + client = AsyncOtariClient( + api_base="http://localhost:8000", api_key="vk" + ) + + result = await client.list_files(purpose="user_data") + + assert result == [FILE_OBJECT] + assert mock.last.url.endswith("/v1/files?purpose=user_data") + + async def test_retrieve_file_returns_metadata(self, mock_rest: Any) -> None: + mock = mock_rest(status=200, body=FILE_OBJECT) + client = AsyncOtariClient( + api_base="http://localhost:8000", api_key="vk" + ) + + result = await client.retrieve_file("file-abc123") + + assert result == FILE_OBJECT + assert mock.last.url.endswith("/v1/files/file-abc123") + + @respx.mock + async def test_download_file_returns_raw_bytes(self) -> None: + route = respx.get("http://localhost:8000/v1/files/file-abc123/content").mock( + return_value=httpx.Response( + 200, + headers={"content-type": "application/pdf"}, + content=b"PDF-CONTENT", + ) + ) + client = AsyncOtariClient( + api_base="http://localhost:8000", api_key="vk" + ) + + result = await client.download_file("file-abc123") + + assert result == b"PDF-CONTENT" + assert route.calls.last.request.headers["otari-key"] == "Bearer vk" + + async def test_delete_file_returns_confirmation(self, mock_rest: Any) -> None: + mock = mock_rest(status=200, body=FILE_DELETE_RESPONSE) + client = AsyncOtariClient( + api_base="http://localhost:8000", api_key="vk" + ) + + result = await client.delete_file("file-abc123") + + assert result == FILE_DELETE_RESPONSE + assert mock.last.method == "DELETE" + assert mock.last.url.endswith("/v1/files/file-abc123") + + 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 f5640cb..f361436 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -10,6 +10,7 @@ from __future__ import annotations from typing import Any +from uuid import UUID import httpx import pytest @@ -92,6 +93,27 @@ TRANSCRIPTION_RESPONSE: dict[str, Any] = {"text": "hello world"} +FILE_OBJECT: dict[str, Any] = { + "id": "file-abc123", + "object": "file", + "bytes": 12, + "created_at": 1, + "expires_at": None, + "filename": "report.pdf", + "purpose": "user_data", +} + +FILE_LIST_RESPONSE: dict[str, Any] = { + "object": "list", + "data": [FILE_OBJECT], +} + +FILE_DELETE_RESPONSE: dict[str, Any] = { + "id": "file-abc123", + "object": "file", + "deleted": True, +} + def _sse(*events: str) -> bytes: """Build a ``text/event-stream`` body from JSON event strings + the DONE sentinel.""" @@ -565,6 +587,110 @@ def test_transcription_returns_text(self) -> None: assert result.json is None +class TestFiles: + @respx.mock + def test_upload_file_sends_multipart_bytes(self) -> None: + route = respx.post("http://localhost:8000/v1/files").mock( + return_value=httpx.Response(200, json=FILE_OBJECT) + ) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + + result = client.upload_file( + file=b"PDF-CONTENT", + filename="report.pdf", + content_type="application/pdf", + user="user-123", + ) + + assert result == FILE_OBJECT + request = route.calls.last.request + assert request.headers["otari-key"] == "Bearer vk" + assert request.headers["content-type"].startswith("multipart/form-data") + assert b'name="file"; filename="report.pdf"' in request.content + assert b"Content-Type: application/pdf" in request.content + assert b"PDF-CONTENT" in request.content + assert b'name="purpose"' in request.content + assert b"user_data" in request.content + assert b'name="user"' in request.content + assert b"user-123" in request.content + + def test_list_files_returns_data_and_forwards_filters(self, mock_rest: Any) -> None: + mock = mock_rest(status=200, body=FILE_LIST_RESPONSE) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + + result = client.list_files( + purpose="user_data", + user="user-123", + workspace_id=UUID("00000000-0000-0000-0000-000000000123"), + ) + + assert result == [FILE_OBJECT] + assert mock.last.method == "GET" + assert mock.last.url.endswith( + "/v1/files?user=user-123&purpose=user_data&" + "workspace_id=00000000-0000-0000-0000-000000000123" + ) + + def test_retrieve_file_returns_metadata(self, mock_rest: Any) -> None: + mock = mock_rest(status=200, body=FILE_OBJECT) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + + result = client.retrieve_file("file-abc123", user="user-123") + + assert result == FILE_OBJECT + assert mock.last.url.endswith("/v1/files/file-abc123?user=user-123") + + @respx.mock + def test_download_file_returns_raw_bytes(self) -> None: + route = respx.get("http://localhost:8000/v1/files/file-abc123/content").mock( + return_value=httpx.Response( + 200, + headers={"content-type": "application/pdf"}, + content=b"PDF-CONTENT", + ) + ) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + + result = client.download_file("file-abc123", user="user-123") + + assert result == b"PDF-CONTENT" + request = route.calls.last.request + assert request.headers["otari-key"] == "Bearer vk" + assert request.url.params["user"] == "user-123" + + def test_delete_file_returns_confirmation(self, mock_rest: Any) -> None: + mock = mock_rest(status=200, body=FILE_DELETE_RESPONSE) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + + result = client.delete_file("file-abc123", user="user-123") + + assert result == FILE_DELETE_RESPONSE + assert mock.last.method == "DELETE" + assert mock.last.url.endswith("/v1/files/file-abc123?user=user-123") + + @respx.mock + def test_download_file_encodes_the_file_id_as_one_path_segment(self) -> None: + route = respx.get( + "http://localhost:8000/v1/files/file%2F..%2Fsecret/content" + ).mock(return_value=httpx.Response(200, content=b"FILE")) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + + assert client.download_file("file/../secret") == b"FILE" + assert route.calls.last.request.url.raw_path == ( + b"/v1/files/file%2F..%2Fsecret/content" + ) + + @respx.mock + def test_download_file_maps_errors(self) -> None: + respx.get("http://localhost:8000/v1/files/missing/content").mock( + return_value=httpx.Response(404, json={"detail": "File not found"}) + ) + client = OtariClient(api_base="http://localhost:8000", api_key="vk") + + with pytest.raises(ModelNotFoundError, match="File not found"): + client.download_file("missing") + + # --------------------------------------------------------------------------- # Control-plane accessor # ---------------------------------------------------------------------------