Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 39 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
<p align="center">
<picture>
<img src="https://raw.githubusercontent.com/mozilla-ai/otari/refs/heads/main/docs/public/images/otari-logo-mark.png" width="20%" alt="Project logo"/>
</picture>
<img src="assets/otari-logo.svg" width="320" alt="otari logo"/>
</p>

<div align="center">
Expand All @@ -23,12 +21,13 @@ Communicate with any LLM provider through the gateway using a single, typed inte

## Quickstart

Generate an API token at [otari.ai/organization-settings/api-tokens](https://otari.ai/organization-settings/api-tokens), then add a provider key (e.g. OpenAI) at [otari.ai/organization-settings/provider-keys](https://otari.ai/organization-settings/provider-keys) so the gateway can route requests to that provider. Then use the client:

```python
from otari import OtariClient

client = OtariClient(
api_base="http://localhost:8000",
platform_token="your-token-here",
platform_token="tk_your_api_token",
)

response = await client.completion(
Expand All @@ -39,7 +38,24 @@ response = await client.completion(
print(response.choices[0].message.content)
```

**That's it!** Change the model string to switch between LLM providers through the gateway.
**That's it!** With no `api_base`, the client defaults to the hosted gateway at `https://api.otari.ai`. Change the model string to switch between LLM providers through the gateway.

Prefer to keep secrets out of code? Set `OTARI_AI_TOKEN` in your environment and `OtariClient()` picks up the token automatically.

## Self-hosting the gateway

Prefer to run the gateway yourself instead of using the hosted otari.ai? Follow the setup in the [otari gateway repo](https://github.com/mozilla-ai/otari), then point the SDK at it:

```python
client = OtariClient(
api_base="http://localhost:8000", # or wherever you host the gateway
api_key="your-gateway-api-key",
)
```

The SDK sends `api_key` via the custom `Otari-Key: Bearer …` header. Env: `GATEWAY_API_BASE` + `GATEWAY_API_KEY`.

Make sure your gateway has provider keys configured (e.g. OpenAI) so it can route requests upstream — see the [otari gateway repo](https://github.com/mozilla-ai/otari) for setup.

## Installation

Expand All @@ -56,12 +72,18 @@ pip install otari

### Setting Up Credentials

Set environment variables for your gateway:
For the hosted gateway, set your platform token (no `api_base` needed — it defaults to `https://api.otari.ai`):

```bash
export OTARI_AI_TOKEN="tk_your_api_token"
```

`GATEWAY_PLATFORM_TOKEN` is kept as a legacy alias for `OTARI_AI_TOKEN`; the canonical name takes precedence when both are set.

For a self-hosted gateway, set the base URL and an API key instead:

```bash
export GATEWAY_API_BASE="http://localhost:8000"
export GATEWAY_PLATFORM_TOKEN="your-token-here"
# or for non-platform mode:
export GATEWAY_API_KEY="your-key-here"
```

Expand Down Expand Up @@ -102,18 +124,17 @@ The client supports two authentication modes, matching the TypeScript SDK:

#### Platform Mode (Recommended)

Uses a Bearer token in the standard Authorization header:
Uses a Bearer token in the standard Authorization header. On the hosted platform, generate an API token at [otari.ai/organization-settings/api-tokens](https://otari.ai/organization-settings/api-tokens) and add a provider key (e.g. OpenAI) at [otari.ai/organization-settings/provider-keys](https://otari.ai/organization-settings/provider-keys) so the gateway can route requests to that provider. With no `api_base`, the client defaults to the hosted gateway at `https://api.otari.ai`:

```python
client = OtariClient(
api_base="http://localhost:8000",
platform_token="tk_your_platform_token",
platform_token="tk_your_api_token",
)
```

#### Non-Platform Mode
#### Non-Platform Mode (Self-Hosted)

Sends the API key via a custom `Otari-Key` header:
Sends the API key via a custom `Otari-Key` header. This targets a self-hosted gateway, so an explicit `api_base` is required:

```python
client = OtariClient(
Expand All @@ -127,7 +148,9 @@ client = OtariClient(
When no explicit credentials are provided, the client reads from environment variables:

```python
# Uses GATEWAY_API_BASE, GATEWAY_PLATFORM_TOKEN, or GATEWAY_API_KEY
# Platform mode: OTARI_AI_TOKEN (or legacy GATEWAY_PLATFORM_TOKEN),
# defaulting to the hosted gateway.
# Self-hosted: GATEWAY_API_BASE + GATEWAY_API_KEY.
client = OtariClient()
```

Expand Down Expand Up @@ -210,7 +233,7 @@ except RateLimitError as e:
| 400 (capability) | `UnsupportedCapabilityError` | Selected provider does not support the requested capability |
| 401, 403 | `AuthenticationError` | Invalid or missing credentials |
| 402 | `InsufficientFundsError` | Budget or credits exhausted |
| 404 | `ModelNotFoundError` | Model not found or unavailable |
| 404 | `ModelNotFoundError` | Model not found, or no provider key configured for the requested provider. The exception's `message` carries the gateway's detail. |
| 429 | `RateLimitError` | Rate limit exceeded (includes `retry_after`) |
| 502 | `UpstreamProviderError` | Upstream provider unreachable |
| 504 | `GatewayTimeoutError` | Gateway timed out waiting for provider |
Expand Down
3 changes: 3 additions & 0 deletions assets/otari-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 34 additions & 8 deletions src/otari/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,13 @@
# provider does not support a moderation request.
_UNSUPPORTED_MODERATION_RE = re.compile(r"does not support (?:multimodal )?moderation")

_DEFAULT_PLATFORM_API_BASE = "https://api.otari.ai"

_ENV_API_BASE = "GATEWAY_API_BASE"
_ENV_API_KEY = "GATEWAY_API_KEY"
_ENV_PLATFORM_TOKEN = "GATEWAY_PLATFORM_TOKEN" # noqa: S105
# Matches the gateway server's own alias chain (OTARI_AI_TOKEN preferred).
_ENV_PLATFORM_TOKEN = "OTARI_AI_TOKEN" # noqa: S105
_ENV_PLATFORM_TOKEN_LEGACY = "GATEWAY_PLATFORM_TOKEN" # noqa: S105

_STATUS_TO_ERROR: dict[int, type[AuthenticationError] | type[ModelNotFoundError]] = {
401: AuthenticationError,
Expand All @@ -92,11 +96,14 @@ class OtariClient:

Args:
api_base: Base URL of the gateway (e.g. ``"http://localhost:8000"``).
Falls back to the ``GATEWAY_API_BASE`` environment variable.
Falls back to the ``GATEWAY_API_BASE`` environment variable. In
platform mode it defaults to the hosted gateway at
``https://api.otari.ai`` when neither is supplied.
api_key: API key for non-platform mode.
Falls back to ``GATEWAY_API_KEY`` env var.
platform_token: Platform token for platform mode.
Falls back to ``GATEWAY_PLATFORM_TOKEN`` env var.
Falls back to the canonical ``OTARI_AI_TOKEN`` env var (or the
legacy ``GATEWAY_PLATFORM_TOKEN`` alias).
default_headers: Additional default headers to send with every request.
openai_options: Extra keyword arguments forwarded to the underlying
``AsyncOpenAI`` constructor.
Expand Down Expand Up @@ -130,7 +137,28 @@ def __init__(
default_headers: dict[str, str] | None = None,
openai_options: dict[str, Any] | None = None,
) -> None:
raw_base = api_base or os.environ.get(_ENV_API_BASE)
# Canonical OTARI_AI_TOKEN wins over the legacy GATEWAY_PLATFORM_TOKEN.
resolved_platform_token = (
platform_token
or os.environ.get(_ENV_PLATFORM_TOKEN)
or os.environ.get(_ENV_PLATFORM_TOKEN_LEGACY)
)
resolved_api_key = api_key or os.environ.get(_ENV_API_KEY, "")

# Platform mode activates when a platform token is available and the
# caller hasn't explicitly passed an api_key (which forces non-platform
# mode). Mirrors the TS SDK's `!options.apiKey` check.
will_use_platform_mode = bool(resolved_platform_token) and not api_key

# In platform mode, fall back to the hosted otari.ai gateway so that
# ``OtariClient(platform_token=...)`` works with no further setup. For
# self-hosted gateways the caller must supply api_base — we have no way
# to know where they've hosted it.
raw_base = (
api_base
or os.environ.get(_ENV_API_BASE)
or (_DEFAULT_PLATFORM_API_BASE if will_use_platform_mode else None)
)

if not raw_base:
msg = (
Expand All @@ -146,15 +174,13 @@ def __init__(

self._base_url = api_base_url

resolved_platform_token = platform_token or os.environ.get(_ENV_PLATFORM_TOKEN)
resolved_api_key = api_key or os.environ.get(_ENV_API_KEY, "")

headers: dict[str, str] = {**(default_headers or {})}
extra_kwargs: dict[str, Any] = {**(openai_options or {})}

# Auth resolution (same logic as TS SDK / Python GatewayProvider):
# 1. Explicit platform_token -> platform mode
# 2. GATEWAY_PLATFORM_TOKEN env + no api_key option -> platform mode
# 2. OTARI_AI_TOKEN (or legacy GATEWAY_PLATFORM_TOKEN) env + no api_key
# option -> platform mode
# 3. Otherwise -> non-platform mode
if resolved_platform_token and not api_key:
self.platform_mode = True
Expand Down
15 changes: 12 additions & 3 deletions src/otari/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,28 @@ class OtariClientOptions(TypedDict, total=False):

Auth resolution order (mirrors the TypeScript SDK / Python GatewayProvider):
1. Explicit ``platform_token`` -> platform mode (Bearer token in Authorization header)
2. ``GATEWAY_PLATFORM_TOKEN`` env var (when no ``api_key``) -> platform mode
2. ``OTARI_AI_TOKEN`` (or legacy ``GATEWAY_PLATFORM_TOKEN``) env var
(when no ``api_key``) -> platform mode
3. ``api_key`` or ``GATEWAY_API_KEY`` env var -> non-platform mode (``Otari-Key`` header)
4. No credentials -> non-platform mode, no auth header

In platform mode, ``api_base`` defaults to the hosted gateway at
``https://api.otari.ai`` when neither the option nor ``GATEWAY_API_BASE``
is set.
"""

api_base: str
"""Base URL of the gateway (e.g. ``"http://localhost:8000"``)."""
"""Base URL of the gateway (e.g. ``"http://localhost:8000"``).

Defaults to ``https://api.otari.ai`` in platform mode."""

api_key: str
"""API key for non-platform mode. Sent via ``Otari-Key: Bearer <key>``."""

platform_token: str
"""Platform token for platform mode. Sent as Bearer in the Authorization header."""
"""Platform token for platform mode. Sent as Bearer in the Authorization header.

Falls back to ``OTARI_AI_TOKEN`` (or legacy ``GATEWAY_PLATFORM_TOKEN``)."""

default_headers: dict[str, str]
"""Additional default headers to send with every request."""
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,55 @@ def test_does_not_activate_when_api_key_provided(self, monkeypatch: pytest.Monke
assert client.platform_mode is False


class TestHostedDefault:
"""Hosted-gateway default + OTARI_AI_TOKEN precedence (parity with TS SDK)."""

@staticmethod
def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
for name in ("GATEWAY_API_BASE", "OTARI_AI_TOKEN", "GATEWAY_PLATFORM_TOKEN"):
monkeypatch.delenv(name, raising=False)

def test_platform_token_uses_hosted_default_base(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._clear_env(monkeypatch)
client = OtariClient(platform_token="tk_x") # noqa: S106
assert client.platform_mode is True
assert str(client.openai.base_url).rstrip("/") == "https://api.otari.ai/v1"

def test_otari_ai_token_env_uses_hosted_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._clear_env(monkeypatch)
monkeypatch.setenv("OTARI_AI_TOKEN", "tk_env")
client = OtariClient()
assert client.platform_mode is True
assert str(client.openai.base_url).rstrip("/") == "https://api.otari.ai/v1"

def test_api_key_only_no_base_still_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._clear_env(monkeypatch)
with pytest.raises(ValueError, match="api_base is required"):
OtariClient(api_key="k")

def test_legacy_platform_token_env_uses_hosted_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._clear_env(monkeypatch)
monkeypatch.setenv("GATEWAY_PLATFORM_TOKEN", "tk_legacy")
client = OtariClient()
assert client.platform_mode is True
assert str(client.openai.base_url).rstrip("/") == "https://api.otari.ai/v1"

def test_canonical_token_takes_precedence_over_legacy(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._clear_env(monkeypatch)
monkeypatch.setenv("OTARI_AI_TOKEN", "tk_canonical")
monkeypatch.setenv("GATEWAY_PLATFORM_TOKEN", "tk_legacy")
client = OtariClient()
assert client.platform_mode is True
assert client.openai.api_key == "tk_canonical"
assert client._auth_headers["Authorization"] == "Bearer tk_canonical"

def test_explicit_api_base_overrides_hosted_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._clear_env(monkeypatch)
client = OtariClient(api_base="http://localhost:8000", platform_token="tk_x") # noqa: S106
assert client.platform_mode is True
assert str(client.openai.base_url).rstrip("/") == "http://localhost:8000/v1"


class TestNonPlatformMode:
def test_is_default_without_platform_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GATEWAY_PLATFORM_TOKEN", raising=False)
Expand Down