Skip to content

Commit ae0636b

Browse files
khaledosmanclaude
andauthored
Align with modernized TS SDK: hosted-gateway default, OTARI_AI_TOKEN, logo (#1)
* docs: add otari logo; clarify API token and provider key setup Mirrors the otari-sdk-ts changes for consistency across SDKs: - Add the otari logo (seal + wordmark) to the README header - Note where to generate an API token and add a provider key on the hosted platform in the Platform Mode section - Clarify that a 404 can mean no provider key is configured, and that the exception message carries the gateway's detail Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: default to hosted gateway; add OTARI_AI_TOKEN env var Brings the Python SDK to parity with the modernized otari-sdk-ts: - In platform mode, default api_base to https://api.otari.ai when none is given, so OtariClient(platform_token=...) works with no further setup. API-key (self-hosted) mode still requires an explicit base URL. - Read the canonical OTARI_AI_TOKEN env var for the platform token, keeping GATEWAY_PLATFORM_TOKEN as a legacy alias (canonical wins). - Rewrite the README to be hosted-first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 85c1d3d commit ae0636b

5 files changed

Lines changed: 137 additions & 27 deletions

File tree

README.md

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
<p align="center">
2-
<picture>
3-
<img src="https://raw.githubusercontent.com/mozilla-ai/otari/refs/heads/main/docs/public/images/otari-logo-mark.png" width="20%" alt="Project logo"/>
4-
</picture>
2+
<img src="assets/otari-logo.svg" width="320" alt="otari logo"/>
53
</p>
64

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

2422
## Quickstart
2523

24+
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:
25+
2626
```python
2727
from otari import OtariClient
2828

2929
client = OtariClient(
30-
api_base="http://localhost:8000",
31-
platform_token="your-token-here",
30+
platform_token="tk_your_api_token",
3231
)
3332

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

42-
**That's it!** Change the model string to switch between LLM providers through the gateway.
41+
**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.
42+
43+
Prefer to keep secrets out of code? Set `OTARI_AI_TOKEN` in your environment and `OtariClient()` picks up the token automatically.
44+
45+
## Self-hosting the gateway
46+
47+
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:
48+
49+
```python
50+
client = OtariClient(
51+
api_base="http://localhost:8000", # or wherever you host the gateway
52+
api_key="your-gateway-api-key",
53+
)
54+
```
55+
56+
The SDK sends `api_key` via the custom `Otari-Key: Bearer …` header. Env: `GATEWAY_API_BASE` + `GATEWAY_API_KEY`.
57+
58+
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.
4359

4460
## Installation
4561

@@ -56,12 +72,18 @@ pip install otari
5672

5773
### Setting Up Credentials
5874

59-
Set environment variables for your gateway:
75+
For the hosted gateway, set your platform token (no `api_base` needed — it defaults to `https://api.otari.ai`):
76+
77+
```bash
78+
export OTARI_AI_TOKEN="tk_your_api_token"
79+
```
80+
81+
`GATEWAY_PLATFORM_TOKEN` is kept as a legacy alias for `OTARI_AI_TOKEN`; the canonical name takes precedence when both are set.
82+
83+
For a self-hosted gateway, set the base URL and an API key instead:
6084

6185
```bash
6286
export GATEWAY_API_BASE="http://localhost:8000"
63-
export GATEWAY_PLATFORM_TOKEN="your-token-here"
64-
# or for non-platform mode:
6587
export GATEWAY_API_KEY="your-key-here"
6688
```
6789

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

103125
#### Platform Mode (Recommended)
104126

105-
Uses a Bearer token in the standard Authorization header:
127+
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`:
106128

107129
```python
108130
client = OtariClient(
109-
api_base="http://localhost:8000",
110-
platform_token="tk_your_platform_token",
131+
platform_token="tk_your_api_token",
111132
)
112133
```
113134

114-
#### Non-Platform Mode
135+
#### Non-Platform Mode (Self-Hosted)
115136

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

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

129150
```python
130-
# Uses GATEWAY_API_BASE, GATEWAY_PLATFORM_TOKEN, or GATEWAY_API_KEY
151+
# Platform mode: OTARI_AI_TOKEN (or legacy GATEWAY_PLATFORM_TOKEN),
152+
# defaulting to the hosted gateway.
153+
# Self-hosted: GATEWAY_API_BASE + GATEWAY_API_KEY.
131154
client = OtariClient()
132155
```
133156

@@ -210,7 +233,7 @@ except RateLimitError as e:
210233
| 400 (capability) | `UnsupportedCapabilityError` | Selected provider does not support the requested capability |
211234
| 401, 403 | `AuthenticationError` | Invalid or missing credentials |
212235
| 402 | `InsufficientFundsError` | Budget or credits exhausted |
213-
| 404 | `ModelNotFoundError` | Model not found or unavailable |
236+
| 404 | `ModelNotFoundError` | Model not found, or no provider key configured for the requested provider. The exception's `message` carries the gateway's detail. |
214237
| 429 | `RateLimitError` | Rate limit exceeded (includes `retry_after`) |
215238
| 502 | `UpstreamProviderError` | Upstream provider unreachable |
216239
| 504 | `GatewayTimeoutError` | Gateway timed out waiting for provider |

assets/otari-logo.svg

Lines changed: 3 additions & 0 deletions
Loading

src/otari/client.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,13 @@
6868
# provider does not support a moderation request.
6969
_UNSUPPORTED_MODERATION_RE = re.compile(r"does not support (?:multimodal )?moderation")
7070

71+
_DEFAULT_PLATFORM_API_BASE = "https://api.otari.ai"
72+
7173
_ENV_API_BASE = "GATEWAY_API_BASE"
7274
_ENV_API_KEY = "GATEWAY_API_KEY"
73-
_ENV_PLATFORM_TOKEN = "GATEWAY_PLATFORM_TOKEN" # noqa: S105
75+
# Matches the gateway server's own alias chain (OTARI_AI_TOKEN preferred).
76+
_ENV_PLATFORM_TOKEN = "OTARI_AI_TOKEN" # noqa: S105
77+
_ENV_PLATFORM_TOKEN_LEGACY = "GATEWAY_PLATFORM_TOKEN" # noqa: S105
7478

7579
_STATUS_TO_ERROR: dict[int, type[AuthenticationError] | type[ModelNotFoundError]] = {
7680
401: AuthenticationError,
@@ -92,11 +96,14 @@ class OtariClient:
9296
9397
Args:
9498
api_base: Base URL of the gateway (e.g. ``"http://localhost:8000"``).
95-
Falls back to the ``GATEWAY_API_BASE`` environment variable.
99+
Falls back to the ``GATEWAY_API_BASE`` environment variable. In
100+
platform mode it defaults to the hosted gateway at
101+
``https://api.otari.ai`` when neither is supplied.
96102
api_key: API key for non-platform mode.
97103
Falls back to ``GATEWAY_API_KEY`` env var.
98104
platform_token: Platform token for platform mode.
99-
Falls back to ``GATEWAY_PLATFORM_TOKEN`` env var.
105+
Falls back to the canonical ``OTARI_AI_TOKEN`` env var (or the
106+
legacy ``GATEWAY_PLATFORM_TOKEN`` alias).
100107
default_headers: Additional default headers to send with every request.
101108
openai_options: Extra keyword arguments forwarded to the underlying
102109
``AsyncOpenAI`` constructor.
@@ -130,7 +137,28 @@ def __init__(
130137
default_headers: dict[str, str] | None = None,
131138
openai_options: dict[str, Any] | None = None,
132139
) -> None:
133-
raw_base = api_base or os.environ.get(_ENV_API_BASE)
140+
# Canonical OTARI_AI_TOKEN wins over the legacy GATEWAY_PLATFORM_TOKEN.
141+
resolved_platform_token = (
142+
platform_token
143+
or os.environ.get(_ENV_PLATFORM_TOKEN)
144+
or os.environ.get(_ENV_PLATFORM_TOKEN_LEGACY)
145+
)
146+
resolved_api_key = api_key or os.environ.get(_ENV_API_KEY, "")
147+
148+
# Platform mode activates when a platform token is available and the
149+
# caller hasn't explicitly passed an api_key (which forces non-platform
150+
# mode). Mirrors the TS SDK's `!options.apiKey` check.
151+
will_use_platform_mode = bool(resolved_platform_token) and not api_key
152+
153+
# In platform mode, fall back to the hosted otari.ai gateway so that
154+
# ``OtariClient(platform_token=...)`` works with no further setup. For
155+
# self-hosted gateways the caller must supply api_base — we have no way
156+
# to know where they've hosted it.
157+
raw_base = (
158+
api_base
159+
or os.environ.get(_ENV_API_BASE)
160+
or (_DEFAULT_PLATFORM_API_BASE if will_use_platform_mode else None)
161+
)
134162

135163
if not raw_base:
136164
msg = (
@@ -146,15 +174,13 @@ def __init__(
146174

147175
self._base_url = api_base_url
148176

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-
152177
headers: dict[str, str] = {**(default_headers or {})}
153178
extra_kwargs: dict[str, Any] = {**(openai_options or {})}
154179

155180
# Auth resolution (same logic as TS SDK / Python GatewayProvider):
156181
# 1. Explicit platform_token -> platform mode
157-
# 2. GATEWAY_PLATFORM_TOKEN env + no api_key option -> platform mode
182+
# 2. OTARI_AI_TOKEN (or legacy GATEWAY_PLATFORM_TOKEN) env + no api_key
183+
# option -> platform mode
158184
# 3. Otherwise -> non-platform mode
159185
if resolved_platform_token and not api_key:
160186
self.platform_mode = True

src/otari/types.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,28 @@ class OtariClientOptions(TypedDict, total=False):
3434
3535
Auth resolution order (mirrors the TypeScript SDK / Python GatewayProvider):
3636
1. Explicit ``platform_token`` -> platform mode (Bearer token in Authorization header)
37-
2. ``GATEWAY_PLATFORM_TOKEN`` env var (when no ``api_key``) -> platform mode
37+
2. ``OTARI_AI_TOKEN`` (or legacy ``GATEWAY_PLATFORM_TOKEN``) env var
38+
(when no ``api_key``) -> platform mode
3839
3. ``api_key`` or ``GATEWAY_API_KEY`` env var -> non-platform mode (``Otari-Key`` header)
3940
4. No credentials -> non-platform mode, no auth header
41+
42+
In platform mode, ``api_base`` defaults to the hosted gateway at
43+
``https://api.otari.ai`` when neither the option nor ``GATEWAY_API_BASE``
44+
is set.
4045
"""
4146

4247
api_base: str
43-
"""Base URL of the gateway (e.g. ``"http://localhost:8000"``)."""
48+
"""Base URL of the gateway (e.g. ``"http://localhost:8000"``).
49+
50+
Defaults to ``https://api.otari.ai`` in platform mode."""
4451

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

4855
platform_token: str
49-
"""Platform token for platform mode. Sent as Bearer in the Authorization header."""
56+
"""Platform token for platform mode. Sent as Bearer in the Authorization header.
57+
58+
Falls back to ``OTARI_AI_TOKEN`` (or legacy ``GATEWAY_PLATFORM_TOKEN``)."""
5059

5160
default_headers: dict[str, str]
5261
"""Additional default headers to send with every request."""

tests/unit/test_client.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,55 @@ def test_does_not_activate_when_api_key_provided(self, monkeypatch: pytest.Monke
9696
assert client.platform_mode is False
9797

9898

99+
class TestHostedDefault:
100+
"""Hosted-gateway default + OTARI_AI_TOKEN precedence (parity with TS SDK)."""
101+
102+
@staticmethod
103+
def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
104+
for name in ("GATEWAY_API_BASE", "OTARI_AI_TOKEN", "GATEWAY_PLATFORM_TOKEN"):
105+
monkeypatch.delenv(name, raising=False)
106+
107+
def test_platform_token_uses_hosted_default_base(self, monkeypatch: pytest.MonkeyPatch) -> None:
108+
self._clear_env(monkeypatch)
109+
client = OtariClient(platform_token="tk_x") # noqa: S106
110+
assert client.platform_mode is True
111+
assert str(client.openai.base_url).rstrip("/") == "https://api.otari.ai/v1"
112+
113+
def test_otari_ai_token_env_uses_hosted_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
114+
self._clear_env(monkeypatch)
115+
monkeypatch.setenv("OTARI_AI_TOKEN", "tk_env")
116+
client = OtariClient()
117+
assert client.platform_mode is True
118+
assert str(client.openai.base_url).rstrip("/") == "https://api.otari.ai/v1"
119+
120+
def test_api_key_only_no_base_still_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
121+
self._clear_env(monkeypatch)
122+
with pytest.raises(ValueError, match="api_base is required"):
123+
OtariClient(api_key="k")
124+
125+
def test_legacy_platform_token_env_uses_hosted_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
126+
self._clear_env(monkeypatch)
127+
monkeypatch.setenv("GATEWAY_PLATFORM_TOKEN", "tk_legacy")
128+
client = OtariClient()
129+
assert client.platform_mode is True
130+
assert str(client.openai.base_url).rstrip("/") == "https://api.otari.ai/v1"
131+
132+
def test_canonical_token_takes_precedence_over_legacy(self, monkeypatch: pytest.MonkeyPatch) -> None:
133+
self._clear_env(monkeypatch)
134+
monkeypatch.setenv("OTARI_AI_TOKEN", "tk_canonical")
135+
monkeypatch.setenv("GATEWAY_PLATFORM_TOKEN", "tk_legacy")
136+
client = OtariClient()
137+
assert client.platform_mode is True
138+
assert client.openai.api_key == "tk_canonical"
139+
assert client._auth_headers["Authorization"] == "Bearer tk_canonical"
140+
141+
def test_explicit_api_base_overrides_hosted_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
142+
self._clear_env(monkeypatch)
143+
client = OtariClient(api_base="http://localhost:8000", platform_token="tk_x") # noqa: S106
144+
assert client.platform_mode is True
145+
assert str(client.openai.base_url).rstrip("/") == "http://localhost:8000/v1"
146+
147+
99148
class TestNonPlatformMode:
100149
def test_is_default_without_platform_token(self, monkeypatch: pytest.MonkeyPatch) -> None:
101150
monkeypatch.delenv("GATEWAY_PLATFORM_TOKEN", raising=False)

0 commit comments

Comments
 (0)