otari 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otari/__init__.py +85 -0
- otari/client.py +645 -0
- otari/errors.py +159 -0
- otari/py.typed +0 -0
- otari/types.py +106 -0
- otari-0.0.1.dist-info/METADATA +304 -0
- otari-0.0.1.dist-info/RECORD +8 -0
- otari-0.0.1.dist-info/WHEEL +4 -0
otari/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""otari - Python client for the otari gateway.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
from otari import OtariClient
|
|
6
|
+
|
|
7
|
+
client = OtariClient(
|
|
8
|
+
api_base="http://localhost:8000",
|
|
9
|
+
platform_token="your-token-here",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
response = await client.completion(
|
|
13
|
+
model="openai:gpt-4o-mini",
|
|
14
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
15
|
+
)
|
|
16
|
+
print(response.choices[0].message.content)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
20
|
+
|
|
21
|
+
from otari.client import OtariClient
|
|
22
|
+
from otari.errors import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
BatchNotCompleteError,
|
|
25
|
+
GatewayTimeoutError,
|
|
26
|
+
InsufficientFundsError,
|
|
27
|
+
ModelNotFoundError,
|
|
28
|
+
OtariError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
UnsupportedCapabilityError,
|
|
31
|
+
UpstreamProviderError,
|
|
32
|
+
)
|
|
33
|
+
from otari.types import (
|
|
34
|
+
BatchRequestItem,
|
|
35
|
+
BatchResult,
|
|
36
|
+
BatchResultError,
|
|
37
|
+
BatchResultItem,
|
|
38
|
+
ChatCompletion,
|
|
39
|
+
ChatCompletionChunk,
|
|
40
|
+
ChatCompletionMessageParam,
|
|
41
|
+
CreateBatchParams,
|
|
42
|
+
CreateEmbeddingResponse,
|
|
43
|
+
EmbeddingCreateParams,
|
|
44
|
+
ListBatchesOptions,
|
|
45
|
+
Model,
|
|
46
|
+
OtariClientOptions,
|
|
47
|
+
Response,
|
|
48
|
+
ResponseStreamEvent,
|
|
49
|
+
Stream,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
__version__ = version("otari")
|
|
54
|
+
except PackageNotFoundError:
|
|
55
|
+
__version__ = "0.0.0-dev"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"AuthenticationError",
|
|
60
|
+
"BatchNotCompleteError",
|
|
61
|
+
"BatchRequestItem",
|
|
62
|
+
"BatchResult",
|
|
63
|
+
"BatchResultError",
|
|
64
|
+
"BatchResultItem",
|
|
65
|
+
"ChatCompletion",
|
|
66
|
+
"ChatCompletionChunk",
|
|
67
|
+
"ChatCompletionMessageParam",
|
|
68
|
+
"CreateBatchParams",
|
|
69
|
+
"CreateEmbeddingResponse",
|
|
70
|
+
"EmbeddingCreateParams",
|
|
71
|
+
"GatewayTimeoutError",
|
|
72
|
+
"InsufficientFundsError",
|
|
73
|
+
"ListBatchesOptions",
|
|
74
|
+
"Model",
|
|
75
|
+
"ModelNotFoundError",
|
|
76
|
+
"OtariClient",
|
|
77
|
+
"OtariClientOptions",
|
|
78
|
+
"OtariError",
|
|
79
|
+
"RateLimitError",
|
|
80
|
+
"Response",
|
|
81
|
+
"ResponseStreamEvent",
|
|
82
|
+
"Stream",
|
|
83
|
+
"UnsupportedCapabilityError",
|
|
84
|
+
"UpstreamProviderError",
|
|
85
|
+
]
|
otari/client.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""OtariClient: Python client for the otari gateway.
|
|
2
|
+
|
|
3
|
+
Wraps the OpenAI Python SDK (``AsyncOpenAI``), adding gateway-specific
|
|
4
|
+
auth handling and error mapping for platform mode. Extracted from the
|
|
5
|
+
``GatewayProvider`` in `any-llm <https://github.com/mozilla-ai/any-llm>`_.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from otari import OtariClient
|
|
10
|
+
|
|
11
|
+
client = OtariClient(
|
|
12
|
+
api_base="http://localhost:8000",
|
|
13
|
+
platform_token="tk_xxx",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
response = await client.completion(
|
|
17
|
+
model="openai:gpt-4o-mini",
|
|
18
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
19
|
+
)
|
|
20
|
+
print(response.choices[0].message.content)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import urllib.parse
|
|
28
|
+
from typing import TYPE_CHECKING, Any, overload
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
import openai
|
|
32
|
+
from openai import AsyncOpenAI
|
|
33
|
+
|
|
34
|
+
from otari.errors import (
|
|
35
|
+
AuthenticationError,
|
|
36
|
+
BatchNotCompleteError,
|
|
37
|
+
GatewayTimeoutError,
|
|
38
|
+
InsufficientFundsError,
|
|
39
|
+
ModelNotFoundError,
|
|
40
|
+
OtariError,
|
|
41
|
+
RateLimitError,
|
|
42
|
+
UnsupportedCapabilityError,
|
|
43
|
+
UpstreamProviderError,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from openai import AsyncStream
|
|
48
|
+
from openai.types import CreateEmbeddingResponse, Model
|
|
49
|
+
from openai.types.chat import (
|
|
50
|
+
ChatCompletion,
|
|
51
|
+
ChatCompletionChunk,
|
|
52
|
+
)
|
|
53
|
+
from openai.types.responses import (
|
|
54
|
+
Response,
|
|
55
|
+
ResponseStreamEvent,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
from otari.types import (
|
|
59
|
+
BatchResult,
|
|
60
|
+
CreateBatchParams,
|
|
61
|
+
ListBatchesOptions,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
PROVIDER_NAME = "gateway"
|
|
65
|
+
GATEWAY_HEADER_NAME = "Otari-Key"
|
|
66
|
+
|
|
67
|
+
# Locked phrasing used by the gateway to signal that the selected
|
|
68
|
+
# provider does not support a moderation request.
|
|
69
|
+
_UNSUPPORTED_MODERATION_RE = re.compile(r"does not support (?:multimodal )?moderation")
|
|
70
|
+
|
|
71
|
+
_ENV_API_BASE = "GATEWAY_API_BASE"
|
|
72
|
+
_ENV_API_KEY = "GATEWAY_API_KEY"
|
|
73
|
+
_ENV_PLATFORM_TOKEN = "GATEWAY_PLATFORM_TOKEN" # noqa: S105
|
|
74
|
+
|
|
75
|
+
_STATUS_TO_ERROR: dict[int, type[AuthenticationError] | type[ModelNotFoundError]] = {
|
|
76
|
+
401: AuthenticationError,
|
|
77
|
+
403: AuthenticationError,
|
|
78
|
+
404: ModelNotFoundError,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OtariClient:
|
|
83
|
+
"""Client for the otari gateway.
|
|
84
|
+
|
|
85
|
+
Supports two authentication modes (mirroring the TypeScript SDK and
|
|
86
|
+
the Python ``GatewayProvider``):
|
|
87
|
+
|
|
88
|
+
- **Platform mode**: A Bearer token is sent in the standard Authorization
|
|
89
|
+
header. Errors are mapped to typed otari exceptions.
|
|
90
|
+
- **Non-platform mode**: An API key is sent via a custom ``Otari-Key``
|
|
91
|
+
header. Errors from the OpenAI SDK pass through unmodified.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
api_base: Base URL of the gateway (e.g. ``"http://localhost:8000"``).
|
|
95
|
+
Falls back to the ``GATEWAY_API_BASE`` environment variable.
|
|
96
|
+
api_key: API key for non-platform mode.
|
|
97
|
+
Falls back to ``GATEWAY_API_KEY`` env var.
|
|
98
|
+
platform_token: Platform token for platform mode.
|
|
99
|
+
Falls back to ``GATEWAY_PLATFORM_TOKEN`` env var.
|
|
100
|
+
default_headers: Additional default headers to send with every request.
|
|
101
|
+
openai_options: Extra keyword arguments forwarded to the underlying
|
|
102
|
+
``AsyncOpenAI`` constructor.
|
|
103
|
+
|
|
104
|
+
Example::
|
|
105
|
+
|
|
106
|
+
client = OtariClient(
|
|
107
|
+
api_base="http://localhost:8000",
|
|
108
|
+
platform_token="tk_xxx",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
response = await client.completion(
|
|
112
|
+
model="openai:gpt-4o-mini",
|
|
113
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
114
|
+
)
|
|
115
|
+
print(response.choices[0].message.content)
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
openai: AsyncOpenAI
|
|
119
|
+
"""The underlying OpenAI client instance."""
|
|
120
|
+
|
|
121
|
+
platform_mode: bool
|
|
122
|
+
"""Whether the client is operating in platform mode."""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
api_base: str | None = None,
|
|
127
|
+
*,
|
|
128
|
+
api_key: str | None = None,
|
|
129
|
+
platform_token: str | None = None,
|
|
130
|
+
default_headers: dict[str, str] | None = None,
|
|
131
|
+
openai_options: dict[str, Any] | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
raw_base = api_base or os.environ.get(_ENV_API_BASE)
|
|
134
|
+
|
|
135
|
+
if not raw_base:
|
|
136
|
+
msg = (
|
|
137
|
+
"api_base is required for the gateway client. "
|
|
138
|
+
f"Pass it as api_base or set the {_ENV_API_BASE} environment variable."
|
|
139
|
+
)
|
|
140
|
+
raise ValueError(msg)
|
|
141
|
+
|
|
142
|
+
# Ensure the base URL includes /v1 since the gateway expects
|
|
143
|
+
# OpenAI-compatible paths like /v1/chat/completions.
|
|
144
|
+
cleaned = raw_base.rstrip("/")
|
|
145
|
+
api_base_url = cleaned if cleaned.endswith("/v1") else f"{cleaned}/v1"
|
|
146
|
+
|
|
147
|
+
self._base_url = api_base_url
|
|
148
|
+
|
|
149
|
+
resolved_platform_token = platform_token or os.environ.get(_ENV_PLATFORM_TOKEN)
|
|
150
|
+
resolved_api_key = api_key or os.environ.get(_ENV_API_KEY, "")
|
|
151
|
+
|
|
152
|
+
headers: dict[str, str] = {**(default_headers or {})}
|
|
153
|
+
extra_kwargs: dict[str, Any] = {**(openai_options or {})}
|
|
154
|
+
|
|
155
|
+
# Auth resolution (same logic as TS SDK / Python GatewayProvider):
|
|
156
|
+
# 1. Explicit platform_token -> platform mode
|
|
157
|
+
# 2. GATEWAY_PLATFORM_TOKEN env + no api_key option -> platform mode
|
|
158
|
+
# 3. Otherwise -> non-platform mode
|
|
159
|
+
if resolved_platform_token and not api_key:
|
|
160
|
+
self.platform_mode = True
|
|
161
|
+
self._platform_token: str | None = resolved_platform_token
|
|
162
|
+
self._api_key: str | None = None
|
|
163
|
+
self.openai = AsyncOpenAI(
|
|
164
|
+
api_key=resolved_platform_token,
|
|
165
|
+
base_url=api_base_url,
|
|
166
|
+
default_headers=headers or None,
|
|
167
|
+
**extra_kwargs,
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
self.platform_mode = False
|
|
171
|
+
self._platform_token = None
|
|
172
|
+
self._api_key = resolved_api_key or None
|
|
173
|
+
if resolved_api_key:
|
|
174
|
+
headers[GATEWAY_HEADER_NAME] = f"Bearer {resolved_api_key}"
|
|
175
|
+
# In non-platform mode we still need to pass *some* API key to the
|
|
176
|
+
# OpenAI client (it validates the field).
|
|
177
|
+
self.openai = AsyncOpenAI(
|
|
178
|
+
api_key=resolved_api_key or "unused",
|
|
179
|
+
base_url=api_base_url,
|
|
180
|
+
default_headers=headers or None,
|
|
181
|
+
**extra_kwargs,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Store auth headers for batch/raw HTTP calls.
|
|
185
|
+
self._auth_headers: dict[str, str] = {}
|
|
186
|
+
if resolved_platform_token and not api_key:
|
|
187
|
+
self._auth_headers["Authorization"] = f"Bearer {resolved_platform_token}"
|
|
188
|
+
elif resolved_api_key:
|
|
189
|
+
self._auth_headers[GATEWAY_HEADER_NAME] = f"Bearer {resolved_api_key}"
|
|
190
|
+
if default_headers:
|
|
191
|
+
self._auth_headers.update(default_headers)
|
|
192
|
+
|
|
193
|
+
# httpx client for raw HTTP calls (batch, etc.)
|
|
194
|
+
self._http = httpx.AsyncClient()
|
|
195
|
+
|
|
196
|
+
# -- Chat completions ---------------------------------------------------
|
|
197
|
+
|
|
198
|
+
@overload
|
|
199
|
+
async def completion(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
model: str,
|
|
203
|
+
messages: list[dict[str, Any]],
|
|
204
|
+
stream: None = ...,
|
|
205
|
+
**kwargs: Any,
|
|
206
|
+
) -> ChatCompletion: ...
|
|
207
|
+
|
|
208
|
+
@overload
|
|
209
|
+
async def completion(
|
|
210
|
+
self,
|
|
211
|
+
*,
|
|
212
|
+
model: str,
|
|
213
|
+
messages: list[dict[str, Any]],
|
|
214
|
+
stream: bool = ...,
|
|
215
|
+
**kwargs: Any,
|
|
216
|
+
) -> ChatCompletion | AsyncStream[ChatCompletionChunk]: ...
|
|
217
|
+
|
|
218
|
+
async def completion(
|
|
219
|
+
self,
|
|
220
|
+
*,
|
|
221
|
+
model: str,
|
|
222
|
+
messages: list[dict[str, Any]],
|
|
223
|
+
stream: bool | None = None,
|
|
224
|
+
**kwargs: Any,
|
|
225
|
+
) -> Any:
|
|
226
|
+
"""Create a chat completion.
|
|
227
|
+
|
|
228
|
+
When ``stream=True`` is set, returns an async iterable of chunks.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
model: Model identifier (e.g. ``"openai:gpt-4o-mini"``).
|
|
232
|
+
messages: List of message dicts with ``role`` and ``content``.
|
|
233
|
+
stream: Whether to stream the response.
|
|
234
|
+
**kwargs: Additional parameters forwarded to the OpenAI API.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
A ``ChatCompletion`` or an async stream of ``ChatCompletionChunk``.
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
params: dict[str, Any] = {"model": model, "messages": messages, **kwargs}
|
|
241
|
+
if stream is not None:
|
|
242
|
+
params["stream"] = stream
|
|
243
|
+
return await self.openai.chat.completions.create(**params)
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
self._handle_error(exc)
|
|
246
|
+
raise
|
|
247
|
+
|
|
248
|
+
# -- Responses API ------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
async def response(
|
|
251
|
+
self,
|
|
252
|
+
*,
|
|
253
|
+
model: str,
|
|
254
|
+
input: Any, # noqa: A002
|
|
255
|
+
stream: bool | None = None,
|
|
256
|
+
**kwargs: Any,
|
|
257
|
+
) -> Response | AsyncStream[ResponseStreamEvent]:
|
|
258
|
+
"""Create a response using the OpenAI Responses API.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
model: Model identifier (e.g. ``"openai:gpt-4o-mini"``).
|
|
262
|
+
input: The input for the response.
|
|
263
|
+
stream: Whether to stream the response.
|
|
264
|
+
**kwargs: Additional parameters forwarded to the OpenAI API.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
A ``Response`` or an async stream of ``ResponseStreamEvent``.
|
|
268
|
+
"""
|
|
269
|
+
try:
|
|
270
|
+
params: dict[str, Any] = {"model": model, "input": input, **kwargs}
|
|
271
|
+
if stream is not None:
|
|
272
|
+
params["stream"] = stream
|
|
273
|
+
result: Response | AsyncStream[ResponseStreamEvent] = await self.openai.responses.create(**params)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
self._handle_error(exc)
|
|
276
|
+
raise
|
|
277
|
+
else:
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
# -- Embeddings ---------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
async def embedding(
|
|
283
|
+
self,
|
|
284
|
+
*,
|
|
285
|
+
model: str,
|
|
286
|
+
input: str | list[str], # noqa: A002
|
|
287
|
+
**kwargs: Any,
|
|
288
|
+
) -> CreateEmbeddingResponse:
|
|
289
|
+
"""Create embeddings for the given input.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
model: Model identifier (e.g. ``"openai:text-embedding-3-small"``).
|
|
293
|
+
input: Text or list of texts to embed.
|
|
294
|
+
**kwargs: Additional parameters forwarded to the OpenAI API.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
An ``CreateEmbeddingResponse``.
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
return await self.openai.embeddings.create(model=model, input=input, **kwargs)
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
self._handle_error(exc)
|
|
303
|
+
raise
|
|
304
|
+
|
|
305
|
+
# -- Models -------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
async def list_models(self) -> list[Model]:
|
|
308
|
+
"""List available models from the gateway.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
A list of ``Model`` objects.
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
page = await self.openai.models.list()
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
self._handle_error(exc)
|
|
317
|
+
raise
|
|
318
|
+
else:
|
|
319
|
+
return [model async for model in page]
|
|
320
|
+
|
|
321
|
+
# -- Batch operations ---------------------------------------------------
|
|
322
|
+
|
|
323
|
+
async def create_batch(self, params: CreateBatchParams) -> dict[str, Any]:
|
|
324
|
+
"""Create a batch job.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
params: Batch creation parameters including model and requests array.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
The created batch object.
|
|
331
|
+
"""
|
|
332
|
+
return await self._batch_request("POST", "/batches", body=dict(params))
|
|
333
|
+
|
|
334
|
+
async def retrieve_batch(self, batch_id: str, provider: str) -> dict[str, Any]:
|
|
335
|
+
"""Retrieve the status of a batch job.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
batch_id: The ID of the batch to retrieve.
|
|
339
|
+
provider: The provider name (e.g. ``"openai"``).
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
The batch object with current status.
|
|
343
|
+
"""
|
|
344
|
+
encoded_id = httpx.URL(f"/batches/{batch_id}").raw_path.decode()
|
|
345
|
+
return await self._batch_request(
|
|
346
|
+
"GET",
|
|
347
|
+
f"{encoded_id}?provider={_url_encode(provider)}",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
async def cancel_batch(self, batch_id: str, provider: str) -> dict[str, Any]:
|
|
351
|
+
"""Cancel a batch job.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
batch_id: The ID of the batch to cancel.
|
|
355
|
+
provider: The provider name (e.g. ``"openai"``).
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
The batch object with updated status.
|
|
359
|
+
"""
|
|
360
|
+
encoded_id = httpx.URL(f"/batches/{batch_id}").raw_path.decode()
|
|
361
|
+
return await self._batch_request(
|
|
362
|
+
"POST",
|
|
363
|
+
f"{encoded_id}/cancel?provider={_url_encode(provider)}",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
async def list_batches(
|
|
367
|
+
self,
|
|
368
|
+
provider: str,
|
|
369
|
+
options: ListBatchesOptions | None = None,
|
|
370
|
+
) -> list[dict[str, Any]]:
|
|
371
|
+
"""List batch jobs for a provider.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
provider: The provider name (e.g. ``"openai"``).
|
|
375
|
+
options: Optional pagination parameters.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
List of batch objects.
|
|
379
|
+
"""
|
|
380
|
+
params_parts = [f"provider={_url_encode(provider)}"]
|
|
381
|
+
if options:
|
|
382
|
+
if "after" in options:
|
|
383
|
+
params_parts.append(f"after={_url_encode(options['after'])}")
|
|
384
|
+
if "limit" in options:
|
|
385
|
+
params_parts.append(f"limit={options['limit']}")
|
|
386
|
+
query = "&".join(params_parts)
|
|
387
|
+
response = await self._batch_request("GET", f"/batches?{query}")
|
|
388
|
+
data: list[dict[str, Any]] = response.get("data", [])
|
|
389
|
+
return data
|
|
390
|
+
|
|
391
|
+
async def retrieve_batch_results(
|
|
392
|
+
self,
|
|
393
|
+
batch_id: str,
|
|
394
|
+
provider: str,
|
|
395
|
+
) -> BatchResult:
|
|
396
|
+
"""Retrieve the results of a completed batch job.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
batch_id: The ID of the batch.
|
|
400
|
+
provider: The provider name (e.g. ``"openai"``).
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
The batch results containing per-request outcomes.
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
BatchNotCompleteError: If the batch is not yet complete.
|
|
407
|
+
"""
|
|
408
|
+
from otari.types import BatchResult as BatchResultType # noqa: PLC0415
|
|
409
|
+
from otari.types import BatchResultItem # noqa: PLC0415
|
|
410
|
+
|
|
411
|
+
encoded_id = httpx.URL(f"/batches/{batch_id}").raw_path.decode()
|
|
412
|
+
data = await self._batch_request(
|
|
413
|
+
"GET",
|
|
414
|
+
f"{encoded_id}/results?provider={_url_encode(provider)}",
|
|
415
|
+
)
|
|
416
|
+
items = [
|
|
417
|
+
BatchResultItem(
|
|
418
|
+
custom_id=entry["custom_id"],
|
|
419
|
+
result=entry.get("result"),
|
|
420
|
+
error=entry.get("error"),
|
|
421
|
+
)
|
|
422
|
+
for entry in data.get("results", [])
|
|
423
|
+
]
|
|
424
|
+
return BatchResultType(results=items)
|
|
425
|
+
|
|
426
|
+
# -- Error handling -----------------------------------------------------
|
|
427
|
+
|
|
428
|
+
def _handle_error(self, error: Exception) -> None:
|
|
429
|
+
"""Convert ``openai.APIStatusError`` to typed otari exceptions.
|
|
430
|
+
|
|
431
|
+
Most mappings only apply in platform mode; in non-platform mode the
|
|
432
|
+
original error propagates unchanged. The one exception is
|
|
433
|
+
:class:`UnsupportedCapabilityError`, which surfaces in both modes.
|
|
434
|
+
"""
|
|
435
|
+
if not isinstance(error, openai.APIStatusError):
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
status = error.status_code
|
|
439
|
+
headers = error.response.headers
|
|
440
|
+
correlation_id = headers.get("x-correlation-id")
|
|
441
|
+
retry_after = headers.get("retry-after")
|
|
442
|
+
|
|
443
|
+
detail = str(getattr(error, "message", str(error)))
|
|
444
|
+
if correlation_id:
|
|
445
|
+
detail = f"{detail} (correlation_id={correlation_id})"
|
|
446
|
+
|
|
447
|
+
# Unsupported-capability is surfaced regardless of mode.
|
|
448
|
+
if status == 400 and _UNSUPPORTED_MODERATION_RE.search(detail):
|
|
449
|
+
provider = _parse_unsupported_provider(detail)
|
|
450
|
+
capability = "multimodal_moderation" if "multimodal" in detail else "moderation"
|
|
451
|
+
raise UnsupportedCapabilityError(
|
|
452
|
+
detail,
|
|
453
|
+
status_code=status,
|
|
454
|
+
original_error=error,
|
|
455
|
+
provider_name=PROVIDER_NAME,
|
|
456
|
+
provider=provider,
|
|
457
|
+
capability=capability,
|
|
458
|
+
) from error
|
|
459
|
+
|
|
460
|
+
# The rest of the mappings only apply in platform mode.
|
|
461
|
+
if not self.platform_mode:
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
if (error_cls := _STATUS_TO_ERROR.get(status)) is not None:
|
|
465
|
+
raise error_cls(
|
|
466
|
+
detail,
|
|
467
|
+
status_code=status,
|
|
468
|
+
original_error=error,
|
|
469
|
+
provider_name=PROVIDER_NAME,
|
|
470
|
+
) from error
|
|
471
|
+
|
|
472
|
+
if status == 402:
|
|
473
|
+
raise InsufficientFundsError(
|
|
474
|
+
detail,
|
|
475
|
+
status_code=status,
|
|
476
|
+
original_error=error,
|
|
477
|
+
provider_name=PROVIDER_NAME,
|
|
478
|
+
) from error
|
|
479
|
+
|
|
480
|
+
if status == 429:
|
|
481
|
+
raise RateLimitError(
|
|
482
|
+
detail,
|
|
483
|
+
status_code=status,
|
|
484
|
+
original_error=error,
|
|
485
|
+
provider_name=PROVIDER_NAME,
|
|
486
|
+
retry_after=retry_after,
|
|
487
|
+
) from error
|
|
488
|
+
|
|
489
|
+
if status == 502:
|
|
490
|
+
raise UpstreamProviderError(
|
|
491
|
+
detail,
|
|
492
|
+
status_code=status,
|
|
493
|
+
original_error=error,
|
|
494
|
+
provider_name=PROVIDER_NAME,
|
|
495
|
+
) from error
|
|
496
|
+
|
|
497
|
+
if status == 504:
|
|
498
|
+
raise GatewayTimeoutError(
|
|
499
|
+
detail,
|
|
500
|
+
status_code=status,
|
|
501
|
+
original_error=error,
|
|
502
|
+
provider_name=PROVIDER_NAME,
|
|
503
|
+
) from error
|
|
504
|
+
|
|
505
|
+
# Unrecognized status: let the original error propagate.
|
|
506
|
+
|
|
507
|
+
# -- Batch HTTP helpers -------------------------------------------------
|
|
508
|
+
|
|
509
|
+
async def _batch_request(
|
|
510
|
+
self,
|
|
511
|
+
method: str,
|
|
512
|
+
path: str,
|
|
513
|
+
*,
|
|
514
|
+
body: dict[str, Any] | None = None,
|
|
515
|
+
) -> dict[str, Any]:
|
|
516
|
+
"""Make a direct HTTP request for batch operations.
|
|
517
|
+
|
|
518
|
+
Unlike completion/embedding which use ``self.openai``, batch methods
|
|
519
|
+
use direct HTTP because the gateway batch API has a custom JSON format.
|
|
520
|
+
"""
|
|
521
|
+
url = f"{self._base_url}{path}"
|
|
522
|
+
headers = {
|
|
523
|
+
"Content-Type": "application/json",
|
|
524
|
+
**self._auth_headers,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
response = await self._http.request(
|
|
528
|
+
method,
|
|
529
|
+
url,
|
|
530
|
+
headers=headers,
|
|
531
|
+
json=body if body is not None else None,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
if not response.is_success:
|
|
535
|
+
await self._handle_batch_error(response)
|
|
536
|
+
|
|
537
|
+
result: dict[str, Any] = response.json()
|
|
538
|
+
return result
|
|
539
|
+
|
|
540
|
+
async def _handle_batch_error(self, response: httpx.Response) -> None:
|
|
541
|
+
"""Map batch HTTP errors to typed SDK errors."""
|
|
542
|
+
try:
|
|
543
|
+
data = response.json()
|
|
544
|
+
detail = data.get("detail", response.reason_phrase)
|
|
545
|
+
except Exception:
|
|
546
|
+
detail = response.reason_phrase or ""
|
|
547
|
+
|
|
548
|
+
message = detail if isinstance(detail, str) else (response.reason_phrase or "")
|
|
549
|
+
correlation_id = response.headers.get("x-correlation-id")
|
|
550
|
+
full_message = f"{message} (correlation_id={correlation_id})" if correlation_id else message
|
|
551
|
+
|
|
552
|
+
status = response.status_code
|
|
553
|
+
|
|
554
|
+
if status in (401, 403):
|
|
555
|
+
raise AuthenticationError(
|
|
556
|
+
full_message,
|
|
557
|
+
status_code=status,
|
|
558
|
+
provider_name=PROVIDER_NAME,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
if status == 404:
|
|
562
|
+
msg = (
|
|
563
|
+
full_message
|
|
564
|
+
if "not found" in full_message.lower()
|
|
565
|
+
else f"This gateway does not support batch operations. Upgrade your gateway. ({full_message})"
|
|
566
|
+
)
|
|
567
|
+
raise OtariError(msg, status_code=404, provider_name=PROVIDER_NAME)
|
|
568
|
+
|
|
569
|
+
if status == 409:
|
|
570
|
+
raise BatchNotCompleteError(
|
|
571
|
+
full_message,
|
|
572
|
+
status_code=409,
|
|
573
|
+
provider_name=PROVIDER_NAME,
|
|
574
|
+
batch_id=_extract_batch_id(message),
|
|
575
|
+
batch_status=_extract_status(message),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if status == 422:
|
|
579
|
+
raise OtariError(full_message, status_code=422, provider_name=PROVIDER_NAME)
|
|
580
|
+
|
|
581
|
+
if status == 429:
|
|
582
|
+
raise RateLimitError(
|
|
583
|
+
full_message,
|
|
584
|
+
status_code=429,
|
|
585
|
+
provider_name=PROVIDER_NAME,
|
|
586
|
+
retry_after=response.headers.get("retry-after"),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
if status == 502:
|
|
590
|
+
raise UpstreamProviderError(
|
|
591
|
+
full_message,
|
|
592
|
+
status_code=502,
|
|
593
|
+
provider_name=PROVIDER_NAME,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
if status == 504:
|
|
597
|
+
raise GatewayTimeoutError(
|
|
598
|
+
full_message,
|
|
599
|
+
status_code=504,
|
|
600
|
+
provider_name=PROVIDER_NAME,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
raise OtariError(full_message, status_code=status, provider_name=PROVIDER_NAME)
|
|
604
|
+
|
|
605
|
+
# -- Cleanup ------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
async def close(self) -> None:
|
|
608
|
+
"""Close the underlying HTTP clients."""
|
|
609
|
+
await self._http.aclose()
|
|
610
|
+
await self.openai.close()
|
|
611
|
+
|
|
612
|
+
async def __aenter__(self) -> OtariClient:
|
|
613
|
+
return self
|
|
614
|
+
|
|
615
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
616
|
+
await self.close()
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
# Module-level helpers
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _parse_unsupported_provider(detail: str) -> str:
|
|
625
|
+
"""Parse the provider name from a gateway 400 detail string.
|
|
626
|
+
|
|
627
|
+
Example: ``"Provider anthropic does not support moderation"``
|
|
628
|
+
"""
|
|
629
|
+
match = re.search(r"Provider\s+(\S+)\s+does not", detail)
|
|
630
|
+
return match.group(1) if match else "unknown"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _extract_batch_id(message: str) -> str | None:
|
|
634
|
+
match = re.search(r"Batch '([^']+)'", message)
|
|
635
|
+
return match.group(1) if match else None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _extract_status(message: str) -> str | None:
|
|
639
|
+
match = re.search(r"status: (\w+)", message)
|
|
640
|
+
return match.group(1) if match else None
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _url_encode(value: str) -> str:
|
|
644
|
+
"""Percent-encode a single URL component."""
|
|
645
|
+
return urllib.parse.quote(value, safe="")
|
otari/errors.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Exception hierarchy for otari gateway errors.
|
|
2
|
+
|
|
3
|
+
Mirrors the TypeScript SDK's exception classes. In platform mode,
|
|
4
|
+
OpenAI ``APIStatusError`` status codes are mapped to these typed errors
|
|
5
|
+
so callers can handle specific failure modes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OtariError(Exception):
|
|
12
|
+
"""Base exception for all otari errors.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
message: Human-readable error message.
|
|
16
|
+
status_code: HTTP status code from the gateway, if available.
|
|
17
|
+
original_error: The original SDK exception that triggered this error.
|
|
18
|
+
provider_name: Name of the provider that raised the error.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
default_message: str = "An error occurred"
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str | None = None,
|
|
26
|
+
*,
|
|
27
|
+
status_code: int | None = None,
|
|
28
|
+
original_error: Exception | None = None,
|
|
29
|
+
provider_name: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.message = message or self.default_message
|
|
32
|
+
super().__init__(self.message)
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.original_error = original_error
|
|
35
|
+
self.provider_name = provider_name
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
if self.provider_name:
|
|
39
|
+
return f"[{self.provider_name}] {self.message}"
|
|
40
|
+
return self.message
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthenticationError(OtariError):
|
|
44
|
+
"""Raised when authentication with the gateway fails (HTTP 401, 403)."""
|
|
45
|
+
|
|
46
|
+
default_message = "Authentication failed"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ModelNotFoundError(OtariError):
|
|
50
|
+
"""Raised when the requested model is not found (HTTP 404)."""
|
|
51
|
+
|
|
52
|
+
default_message = "Model not found"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InsufficientFundsError(OtariError):
|
|
56
|
+
"""Raised when the user's budget or credits are exhausted (HTTP 402)."""
|
|
57
|
+
|
|
58
|
+
default_message = "Insufficient funds or budget exceeded"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RateLimitError(OtariError):
|
|
62
|
+
"""Raised when the API rate limit is exceeded (HTTP 429).
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
retry_after: Value of the ``Retry-After`` header, when the server
|
|
66
|
+
provides one. May be a number of seconds or an HTTP-date string.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
default_message = "Rate limit exceeded"
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
message: str | None = None,
|
|
74
|
+
*,
|
|
75
|
+
status_code: int | None = None,
|
|
76
|
+
original_error: Exception | None = None,
|
|
77
|
+
provider_name: str | None = None,
|
|
78
|
+
retry_after: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
super().__init__(
|
|
81
|
+
message,
|
|
82
|
+
status_code=status_code,
|
|
83
|
+
original_error=original_error,
|
|
84
|
+
provider_name=provider_name,
|
|
85
|
+
)
|
|
86
|
+
self.retry_after = retry_after
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class UpstreamProviderError(OtariError):
|
|
90
|
+
"""Raised when the upstream provider is unreachable or errors (HTTP 502)."""
|
|
91
|
+
|
|
92
|
+
default_message = "Upstream provider error"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class GatewayTimeoutError(OtariError):
|
|
96
|
+
"""Raised when the gateway times out waiting for the upstream provider (HTTP 504)."""
|
|
97
|
+
|
|
98
|
+
default_message = "Gateway timeout waiting for upstream provider"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class BatchNotCompleteError(OtariError):
|
|
102
|
+
"""Raised when attempting to retrieve results for a batch that is not yet complete (HTTP 409).
|
|
103
|
+
|
|
104
|
+
Attributes:
|
|
105
|
+
batch_id: The ID of the batch.
|
|
106
|
+
batch_status: The current status of the batch.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
default_message = "Batch is not yet complete"
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
message: str | None = None,
|
|
114
|
+
*,
|
|
115
|
+
status_code: int | None = None,
|
|
116
|
+
original_error: Exception | None = None,
|
|
117
|
+
provider_name: str | None = None,
|
|
118
|
+
batch_id: str | None = None,
|
|
119
|
+
batch_status: str | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
super().__init__(
|
|
122
|
+
message,
|
|
123
|
+
status_code=status_code,
|
|
124
|
+
original_error=original_error,
|
|
125
|
+
provider_name=provider_name,
|
|
126
|
+
)
|
|
127
|
+
self.batch_id = batch_id
|
|
128
|
+
self.batch_status = batch_status
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class UnsupportedCapabilityError(OtariError):
|
|
132
|
+
"""Raised when the gateway reports that the selected provider does not
|
|
133
|
+
support a requested capability (e.g. moderation).
|
|
134
|
+
|
|
135
|
+
Attributes:
|
|
136
|
+
capability: Capability that was requested (e.g. ``"moderation"``).
|
|
137
|
+
provider: Provider name reported by the gateway (e.g. ``"anthropic"``).
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
default_message = "The selected provider does not support this capability"
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
message: str | None = None,
|
|
145
|
+
*,
|
|
146
|
+
status_code: int | None = None,
|
|
147
|
+
original_error: Exception | None = None,
|
|
148
|
+
provider_name: str | None = None,
|
|
149
|
+
capability: str = "",
|
|
150
|
+
provider: str = "",
|
|
151
|
+
) -> None:
|
|
152
|
+
super().__init__(
|
|
153
|
+
message,
|
|
154
|
+
status_code=status_code,
|
|
155
|
+
original_error=original_error,
|
|
156
|
+
provider_name=provider_name,
|
|
157
|
+
)
|
|
158
|
+
self.capability = capability
|
|
159
|
+
self.provider = provider
|
otari/py.typed
ADDED
|
File without changes
|
otari/types.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Configuration and type re-exports for the otari gateway client.
|
|
2
|
+
|
|
3
|
+
Re-exports OpenAI SDK types so consumers don't need to import ``openai`` directly.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, TypedDict
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Re-export OpenAI types that callers interact with directly.
|
|
13
|
+
# These use explicit `as` aliases to make the re-exports public per PEP 484.
|
|
14
|
+
# The TC002 / PLC0414 warnings are intentionally suppressed because these
|
|
15
|
+
# imports exist solely for re-export.
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
from openai import Stream as Stream # noqa: PLC0414
|
|
18
|
+
from openai.types import CreateEmbeddingResponse as CreateEmbeddingResponse # noqa: PLC0414
|
|
19
|
+
from openai.types import EmbeddingCreateParams as EmbeddingCreateParams # noqa: PLC0414
|
|
20
|
+
from openai.types import Model as Model # noqa: PLC0414
|
|
21
|
+
from openai.types.chat import ChatCompletion as ChatCompletion # noqa: PLC0414, TC002
|
|
22
|
+
from openai.types.chat import ChatCompletionChunk as ChatCompletionChunk # noqa: PLC0414
|
|
23
|
+
from openai.types.chat import ChatCompletionMessageParam as ChatCompletionMessageParam # noqa: PLC0414
|
|
24
|
+
from openai.types.responses import Response as Response # noqa: PLC0414
|
|
25
|
+
from openai.types.responses import ResponseStreamEvent as ResponseStreamEvent # noqa: PLC0414
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Client options
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OtariClientOptions(TypedDict, total=False):
|
|
33
|
+
"""Options for constructing an :class:`~otari.client.OtariClient`.
|
|
34
|
+
|
|
35
|
+
Auth resolution order (mirrors the TypeScript SDK / Python GatewayProvider):
|
|
36
|
+
1. Explicit ``platform_token`` -> platform mode (Bearer token in Authorization header)
|
|
37
|
+
2. ``GATEWAY_PLATFORM_TOKEN`` env var (when no ``api_key``) -> platform mode
|
|
38
|
+
3. ``api_key`` or ``GATEWAY_API_KEY`` env var -> non-platform mode (``Otari-Key`` header)
|
|
39
|
+
4. No credentials -> non-platform mode, no auth header
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
api_base: str
|
|
43
|
+
"""Base URL of the gateway (e.g. ``"http://localhost:8000"``)."""
|
|
44
|
+
|
|
45
|
+
api_key: str
|
|
46
|
+
"""API key for non-platform mode. Sent via ``Otari-Key: Bearer <key>``."""
|
|
47
|
+
|
|
48
|
+
platform_token: str
|
|
49
|
+
"""Platform token for platform mode. Sent as Bearer in the Authorization header."""
|
|
50
|
+
|
|
51
|
+
default_headers: dict[str, str]
|
|
52
|
+
"""Additional default headers to send with every request."""
|
|
53
|
+
|
|
54
|
+
openai_options: dict[str, Any]
|
|
55
|
+
"""Extra options forwarded to the underlying ``AsyncOpenAI`` constructor."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Batch types
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BatchRequestItem(TypedDict):
|
|
64
|
+
"""A single request within a batch."""
|
|
65
|
+
|
|
66
|
+
custom_id: str
|
|
67
|
+
body: dict[str, Any]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CreateBatchParams(TypedDict, total=False):
|
|
71
|
+
"""Parameters for creating a batch job."""
|
|
72
|
+
|
|
73
|
+
model: str
|
|
74
|
+
requests: list[BatchRequestItem]
|
|
75
|
+
completion_window: str
|
|
76
|
+
metadata: dict[str, str]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ListBatchesOptions(TypedDict, total=False):
|
|
80
|
+
"""Pagination options for listing batches."""
|
|
81
|
+
|
|
82
|
+
after: str
|
|
83
|
+
limit: int
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BatchResultError(TypedDict):
|
|
87
|
+
"""Error information for a failed batch request."""
|
|
88
|
+
|
|
89
|
+
code: str
|
|
90
|
+
message: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class BatchResultItem:
|
|
95
|
+
"""Result of a single request within a batch."""
|
|
96
|
+
|
|
97
|
+
custom_id: str
|
|
98
|
+
result: ChatCompletion | None = None
|
|
99
|
+
error: BatchResultError | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class BatchResult:
|
|
104
|
+
"""Aggregated results of a completed batch job."""
|
|
105
|
+
|
|
106
|
+
results: list[BatchResultItem] = field(default_factory=list)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: otari
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python client for the otari gateway
|
|
5
|
+
Project-URL: Homepage, https://github.com/mozilla-ai/otari-sdk-python
|
|
6
|
+
Project-URL: Documentation, https://mozilla-ai.github.io/otari/
|
|
7
|
+
Project-URL: Repository, https://github.com/mozilla-ai/otari-sdk-python
|
|
8
|
+
Project-URL: Issues, https://github.com/mozilla-ai/otari-sdk-python/issues
|
|
9
|
+
Author-email: Mozilla AI <ai-engineering@mozilla.com>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: httpx>=0.25.0
|
|
22
|
+
Requires-Dist: openai>=1.99.3
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<picture>
|
|
32
|
+
<img src="https://raw.githubusercontent.com/mozilla-ai/otari/refs/heads/main/docs/public/images/otari-logo-mark.png" width="20%" alt="Project logo"/>
|
|
33
|
+
</picture>
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
<div align="center">
|
|
37
|
+
|
|
38
|
+
# otari (Python)
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
[](https://pypi.org/project/otari/)
|
|
42
|
+
<a href="https://discord.gg/4gf3zXrQUc">
|
|
43
|
+
<img src="https://img.shields.io/static/v1?label=Chat%20on&message=Discord&color=blue&logo=Discord&style=flat-square" alt="Discord">
|
|
44
|
+
</a>
|
|
45
|
+
|
|
46
|
+
**Python client for [otari-gateway](https://github.com/mozilla-ai/otari).**
|
|
47
|
+
Communicate with any LLM provider through the gateway using a single, typed interface.
|
|
48
|
+
|
|
49
|
+
[TypeScript SDK](https://github.com/mozilla-ai/otari-sdk-ts) | [Documentation](https://mozilla-ai.github.io/otari/) | [Platform (Beta)](https://otari.ai/)
|
|
50
|
+
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
## Quickstart
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from otari import OtariClient
|
|
57
|
+
|
|
58
|
+
client = OtariClient(
|
|
59
|
+
api_base="http://localhost:8000",
|
|
60
|
+
platform_token="your-token-here",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
response = await client.completion(
|
|
64
|
+
model="openai:gpt-4o-mini",
|
|
65
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
print(response.choices[0].message.content)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**That's it!** Change the model string to switch between LLM providers through the gateway.
|
|
72
|
+
|
|
73
|
+
## Installation
|
|
74
|
+
|
|
75
|
+
### Requirements
|
|
76
|
+
|
|
77
|
+
- Python 3.11 or newer
|
|
78
|
+
- A running [otari-gateway](https://mozilla-ai.github.io/otari/gateway/overview/) instance
|
|
79
|
+
|
|
80
|
+
### Install
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install otari
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Setting Up Credentials
|
|
87
|
+
|
|
88
|
+
Set environment variables for your gateway:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
export GATEWAY_API_BASE="http://localhost:8000"
|
|
92
|
+
export GATEWAY_PLATFORM_TOKEN="your-token-here"
|
|
93
|
+
# or for non-platform mode:
|
|
94
|
+
export GATEWAY_API_KEY="your-key-here"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Alternatively, pass credentials directly when creating the client (see [Usage](#usage) examples).
|
|
98
|
+
|
|
99
|
+
## otari-gateway
|
|
100
|
+
|
|
101
|
+
This Python SDK is a client for [otari-gateway](https://github.com/mozilla-ai/otari), an **optional** FastAPI-based proxy server that adds enterprise-grade features on top of the core library:
|
|
102
|
+
|
|
103
|
+
- **Budget Management** - Enforce spending limits with automatic daily, weekly, or monthly resets
|
|
104
|
+
- **API Key Management** - Issue, revoke, and monitor virtual API keys without exposing provider credentials
|
|
105
|
+
- **Usage Analytics** - Track every request with full token counts, costs, and metadata
|
|
106
|
+
- **Multi-tenant Support** - Manage access and budgets across users and teams
|
|
107
|
+
|
|
108
|
+
The gateway sits between your applications and LLM providers, exposing an OpenAI-compatible API that works with any supported provider.
|
|
109
|
+
|
|
110
|
+
### Quick Start
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
docker run \
|
|
114
|
+
-e GATEWAY_MASTER_KEY="your-secure-master-key" \
|
|
115
|
+
-e OPENAI_API_KEY="your-api-key" \
|
|
116
|
+
-p 8000:8000 \
|
|
117
|
+
ghcr.io/mozilla-ai/otari/gateway:latest
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
> **Note:** You can use a specific release version instead of `latest` (e.g., `1.2.0`). See [available versions](https://github.com/orgs/mozilla-ai/packages/container/package/otari%2Fgateway).
|
|
121
|
+
|
|
122
|
+
### Managed Platform (Beta)
|
|
123
|
+
|
|
124
|
+
Prefer a hosted experience? The [otari platform](https://otari.ai/) provides a managed control plane for keys, usage tracking, and cost visibility across providers, while still building on the same `otari` interfaces.
|
|
125
|
+
|
|
126
|
+
## Usage
|
|
127
|
+
|
|
128
|
+
### Authentication Modes
|
|
129
|
+
|
|
130
|
+
The client supports two authentication modes, matching the TypeScript SDK:
|
|
131
|
+
|
|
132
|
+
#### Platform Mode (Recommended)
|
|
133
|
+
|
|
134
|
+
Uses a Bearer token in the standard Authorization header:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
client = OtariClient(
|
|
138
|
+
api_base="http://localhost:8000",
|
|
139
|
+
platform_token="tk_your_platform_token",
|
|
140
|
+
)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### Non-Platform Mode
|
|
144
|
+
|
|
145
|
+
Sends the API key via a custom `Otari-Key` header:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
client = OtariClient(
|
|
149
|
+
api_base="http://localhost:8000",
|
|
150
|
+
api_key="your-api-key",
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### Auto-Detection from Environment Variables
|
|
155
|
+
|
|
156
|
+
When no explicit credentials are provided, the client reads from environment variables:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
# Uses GATEWAY_API_BASE, GATEWAY_PLATFORM_TOKEN, or GATEWAY_API_KEY
|
|
160
|
+
client = OtariClient()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Chat Completions
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
response = await client.completion(
|
|
167
|
+
model="openai:gpt-4o-mini",
|
|
168
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
print(response.choices[0].message.content)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Streaming
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
stream = await client.completion(
|
|
178
|
+
model="openai:gpt-4o-mini",
|
|
179
|
+
messages=[{"role": "user", "content": "Tell me a story."}],
|
|
180
|
+
stream=True,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async for chunk in stream:
|
|
184
|
+
content = chunk.choices[0].delta.content
|
|
185
|
+
if content:
|
|
186
|
+
print(content, end="", flush=True)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Responses API
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
response = await client.response(
|
|
193
|
+
model="openai:gpt-4o-mini",
|
|
194
|
+
input="Summarize this in one sentence.",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
print(response.output_text)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Embeddings
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
result = await client.embedding(
|
|
204
|
+
model="openai:text-embedding-3-small",
|
|
205
|
+
input="Hello world",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
print(result.data[0].embedding)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Listing Models
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
models = await client.list_models()
|
|
215
|
+
for model in models:
|
|
216
|
+
print(model.id)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Error Handling
|
|
220
|
+
|
|
221
|
+
In platform mode, HTTP errors are mapped to typed exceptions:
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from otari import OtariClient, AuthenticationError, RateLimitError
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
response = await client.completion(
|
|
228
|
+
model="openai:gpt-4o-mini",
|
|
229
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
230
|
+
)
|
|
231
|
+
except AuthenticationError as e:
|
|
232
|
+
print(f"Invalid credentials: {e.message}")
|
|
233
|
+
except RateLimitError as e:
|
|
234
|
+
print(f"Rate limited, retry after: {e.retry_after}")
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
| HTTP Status | Error Class | Description |
|
|
238
|
+
|------------|-------------|-------------|
|
|
239
|
+
| 400 (capability) | `UnsupportedCapabilityError` | Selected provider does not support the requested capability |
|
|
240
|
+
| 401, 403 | `AuthenticationError` | Invalid or missing credentials |
|
|
241
|
+
| 402 | `InsufficientFundsError` | Budget or credits exhausted |
|
|
242
|
+
| 404 | `ModelNotFoundError` | Model not found or unavailable |
|
|
243
|
+
| 429 | `RateLimitError` | Rate limit exceeded (includes `retry_after`) |
|
|
244
|
+
| 502 | `UpstreamProviderError` | Upstream provider unreachable |
|
|
245
|
+
| 504 | `GatewayTimeoutError` | Gateway timed out waiting for provider |
|
|
246
|
+
|
|
247
|
+
`UnsupportedCapabilityError` surfaces in both platform and non-platform modes; the other mappings are platform-mode only.
|
|
248
|
+
|
|
249
|
+
### Context Manager
|
|
250
|
+
|
|
251
|
+
The client supports async context manager for automatic cleanup:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
async with OtariClient(api_base="http://localhost:8000") as client:
|
|
255
|
+
response = await client.completion(
|
|
256
|
+
model="openai:gpt-4o-mini",
|
|
257
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
258
|
+
)
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Why choose `otari`?
|
|
262
|
+
|
|
263
|
+
- **Simple, unified interface** - Single client for all providers through the gateway, switch models with just a string change
|
|
264
|
+
- **Developer friendly** - Full type hints for better IDE support and clear, actionable error messages
|
|
265
|
+
- **Leverages the OpenAI SDK** - Built on the official OpenAI Python SDK for maximum compatibility
|
|
266
|
+
- **Async-first** - Built on `AsyncOpenAI` for modern async Python applications
|
|
267
|
+
- **Stays framework-agnostic** so it can be used across different projects and use cases
|
|
268
|
+
- **Battle-tested** - Powers our own production tools ([any-agent](https://github.com/mozilla-ai/any-agent))
|
|
269
|
+
|
|
270
|
+
## Development
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
# Create a virtual environment
|
|
274
|
+
python -m venv .venv
|
|
275
|
+
source .venv/bin/activate
|
|
276
|
+
|
|
277
|
+
# Install with dev dependencies
|
|
278
|
+
pip install -e ".[dev]"
|
|
279
|
+
|
|
280
|
+
# Run unit tests
|
|
281
|
+
pytest tests/
|
|
282
|
+
|
|
283
|
+
# Lint
|
|
284
|
+
ruff check src/ tests/
|
|
285
|
+
|
|
286
|
+
# Type-check
|
|
287
|
+
mypy src/
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Documentation
|
|
291
|
+
|
|
292
|
+
- **[Full Documentation](https://mozilla-ai.github.io/otari/)** - Complete guides and API reference
|
|
293
|
+
- **[Supported Providers](https://mozilla-ai.github.io/otari/providers/)** - List of all supported LLM providers
|
|
294
|
+
- **[Gateway Documentation](https://mozilla-ai.github.io/otari/gateway/overview/)** - Gateway setup and deployment
|
|
295
|
+
- **[TypeScript SDK](https://github.com/mozilla-ai/otari-sdk-ts)** - The TypeScript SDK for Node.js applications
|
|
296
|
+
- **[otari Platform (Beta)](https://otari.ai/)** - Hosted control plane for key management, usage tracking, and cost visibility
|
|
297
|
+
|
|
298
|
+
## Contributing
|
|
299
|
+
|
|
300
|
+
We welcome contributions from developers of all skill levels! Please see the [Contributing Guide](https://github.com/mozilla-ai/otari/blob/main/CONTRIBUTING.md) or open an issue to discuss changes.
|
|
301
|
+
|
|
302
|
+
## License
|
|
303
|
+
|
|
304
|
+
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
otari/__init__.py,sha256=luzKKUh6Ay3Wx_PPfA7oCn2aZoblwgmuC4yz8iOXjDs,1889
|
|
2
|
+
otari/client.py,sha256=k5ePI3N2_-kKiO5To7MuNM09w0j9IlUvBHhYLaMNQHY,21437
|
|
3
|
+
otari/errors.py,sha256=FihxwKzQ8W7FDHbDOC3kz3fupMCqwoRo3b7v6BqoRTo,4792
|
|
4
|
+
otari/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
otari/types.py,sha256=E9eqo_i5GPYW6Vpp5SKO3eI0tgqIqvuc6B3m-3dWnXQ,3761
|
|
6
|
+
otari-0.0.1.dist-info/METADATA,sha256=FstVSf2M8-a7lXgvZPTOun4fCD4KRn36K3G5RNOl-Rc,9504
|
|
7
|
+
otari-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
otari-0.0.1.dist-info/RECORD,,
|