diff --git a/README.md b/README.md index 607892c..b8352e5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@

- - Project logo - + otari logo

@@ -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)