@@ -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(
@@ -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
@@ -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"
```
@@ -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(
@@ -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()
```
@@ -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 |
diff --git a/assets/otari-logo.svg b/assets/otari-logo.svg
new file mode 100644
index 0000000..b586fe6
--- /dev/null
+++ b/assets/otari-logo.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/otari/client.py b/src/otari/client.py
index 735fea5..b98e4bb 100644
--- a/src/otari/client.py
+++ b/src/otari/client.py
@@ -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,
@@ -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.
@@ -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 = (
@@ -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
diff --git a/src/otari/types.py b/src/otari/types.py
index 807eb39..ef686fd 100644
--- a/src/otari/types.py
+++ b/src/otari/types.py
@@ -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 ``."""
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."""
diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py
index efd9954..c9405eb 100644
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -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)