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 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
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)
41
+ [![PyPI](https://img.shields.io/pypi/v/otari)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any