Skip to content
Open
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
105 changes: 102 additions & 3 deletions src/otari/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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(
Expand All @@ -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):
Expand Down
105 changes: 102 additions & 3 deletions src/otari/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -424,6 +428,89 @@ def transcription(
return TranscriptionResult(json=response.json())
return TranscriptionResult(text=response.text)

# -- Files --------------------------------------------------------------

def upload_file(
Comment thread
HareeshBahuleyan marked this conversation as resolved.
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]:
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
Loading
Loading