Skip to content

Commit c3fb35a

Browse files
committed
Add synchronous client alongside async
Previously the SDK was async-only. This makes OtariClient synchronous (built on openai.OpenAI + httpx.Client) and adds AsyncOtariClient for the async API (openai.AsyncOpenAI + httpx.AsyncClient). Shared, transport-agnostic logic (auth-mode resolution, URL normalization, header building, and error mapping) is extracted into _BaseOtariClient so the two implementations cannot drift. BREAKING CHANGE: OtariClient is now synchronous. Async callers must switch to AsyncOtariClient. - src/otari/_base.py: new shared base class + module helpers - src/otari/client.py: sync OtariClient - src/otari/async_client.py: AsyncOtariClient (former async behavior) - types/__init__: export AsyncOtariClient and AsyncStream - tests: split into sync (test_client.py) and async (test_async_client.py) - examples: sync quickstart.py + async quickstart_async.py - README: sync-default samples, Async usage section, migration note
1 parent ae0636b commit c3fb35a

10 files changed

Lines changed: 1893 additions & 528 deletions

File tree

README.md

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ client = OtariClient(
3030
platform_token="tk_your_api_token",
3131
)
3232

33-
response = await client.completion(
33+
response = client.completion(
3434
model="openai:gpt-4o-mini",
3535
messages=[{"role": "user", "content": "Hello!"}],
3636
)
@@ -40,6 +40,8 @@ print(response.choices[0].message.content)
4040

4141
**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.
4242

43+
Prefer async? Use `AsyncOtariClient`, which exposes the same API with `await` (see [Async usage](#async-usage)).
44+
4345
Prefer to keep secrets out of code? Set `OTARI_AI_TOKEN` in your environment and `OtariClient()` picks up the token automatically.
4446

4547
## Self-hosting the gateway
@@ -118,6 +120,8 @@ Prefer a hosted experience? The [otari platform](https://otari.ai/) provides a m
118120

119121
## Usage
120122

123+
> **Migrating from a previous version?** `OtariClient` is now **synchronous** — call its methods directly (no `await`). For asynchronous code, switch to `AsyncOtariClient`, which keeps the previous `await`-based API. See [Async usage](#async-usage).
124+
121125
### Authentication Modes
122126

123127
The client supports two authentication modes, matching the TypeScript SDK:
@@ -157,7 +161,7 @@ client = OtariClient()
157161
### Chat Completions
158162

159163
```python
160-
response = await client.completion(
164+
response = client.completion(
161165
model="openai:gpt-4o-mini",
162166
messages=[{"role": "user", "content": "Hello!"}],
163167
)
@@ -168,13 +172,13 @@ print(response.choices[0].message.content)
168172
### Streaming
169173

170174
```python
171-
stream = await client.completion(
175+
stream = client.completion(
172176
model="openai:gpt-4o-mini",
173177
messages=[{"role": "user", "content": "Tell me a story."}],
174178
stream=True,
175179
)
176180

177-
async for chunk in stream:
181+
for chunk in stream:
178182
content = chunk.choices[0].delta.content
179183
if content:
180184
print(content, end="", flush=True)
@@ -183,7 +187,7 @@ async for chunk in stream:
183187
### Responses API
184188

185189
```python
186-
response = await client.response(
190+
response = client.response(
187191
model="openai:gpt-4o-mini",
188192
input="Summarize this in one sentence.",
189193
)
@@ -194,7 +198,7 @@ print(response.output_text)
194198
### Embeddings
195199

196200
```python
197-
result = await client.embedding(
201+
result = client.embedding(
198202
model="openai:text-embedding-3-small",
199203
input="Hello world",
200204
)
@@ -205,11 +209,43 @@ print(result.data[0].embedding)
205209
### Listing Models
206210

207211
```python
208-
models = await client.list_models()
212+
models = client.list_models()
209213
for model in models:
210214
print(model.id)
211215
```
212216

217+
### Async usage
218+
219+
Every method on `OtariClient` has an asynchronous counterpart on `AsyncOtariClient`. It accepts the same constructor arguments and exposes the same methods, but they are coroutines you `await` (and streams are async iterables):
220+
221+
```python
222+
import asyncio
223+
224+
from otari import AsyncOtariClient
225+
226+
227+
async def main() -> None:
228+
async with AsyncOtariClient(platform_token="tk_your_api_token") as client:
229+
response = await client.completion(
230+
model="openai:gpt-4o-mini",
231+
messages=[{"role": "user", "content": "Hello!"}],
232+
)
233+
print(response.choices[0].message.content)
234+
235+
stream = await client.completion(
236+
model="openai:gpt-4o-mini",
237+
messages=[{"role": "user", "content": "Tell me a story."}],
238+
stream=True,
239+
)
240+
async for chunk in stream:
241+
content = chunk.choices[0].delta.content
242+
if content:
243+
print(content, end="", flush=True)
244+
245+
246+
asyncio.run(main())
247+
```
248+
213249
### Error Handling
214250

215251
In platform mode, HTTP errors are mapped to typed exceptions:
@@ -218,7 +254,7 @@ In platform mode, HTTP errors are mapped to typed exceptions:
218254
from otari import OtariClient, AuthenticationError, RateLimitError
219255

220256
try:
221-
response = await client.completion(
257+
response = client.completion(
222258
model="openai:gpt-4o-mini",
223259
messages=[{"role": "user", "content": "Hello!"}],
224260
)
@@ -242,10 +278,20 @@ except RateLimitError as e:
242278

243279
### Context Manager
244280

245-
The client supports async context manager for automatic cleanup:
281+
The client supports a context manager for automatic cleanup:
282+
283+
```python
284+
with OtariClient(api_base="http://localhost:8000") as client:
285+
response = client.completion(
286+
model="openai:gpt-4o-mini",
287+
messages=[{"role": "user", "content": "Hello!"}],
288+
)
289+
```
290+
291+
`AsyncOtariClient` supports the async equivalent:
246292

247293
```python
248-
async with OtariClient(api_base="http://localhost:8000") as client:
294+
async with AsyncOtariClient(api_base="http://localhost:8000") as client:
249295
response = await client.completion(
250296
model="openai:gpt-4o-mini",
251297
messages=[{"role": "user", "content": "Hello!"}],
@@ -257,7 +303,7 @@ async with OtariClient(api_base="http://localhost:8000") as client:
257303
- **Simple, unified interface** - Single client for all providers through the gateway, switch models with just a string change
258304
- **Developer friendly** - Full type hints for better IDE support and clear, actionable error messages
259305
- **Leverages the OpenAI SDK** - Built on the official OpenAI Python SDK for maximum compatibility
260-
- **Async-first** - Built on `AsyncOpenAI` for modern async Python applications
306+
- **Sync and async** - Use the synchronous `OtariClient` or the asynchronous `AsyncOtariClient`, both with the same typed interface
261307
- **Stays framework-agnostic** so it can be used across different projects and use cases
262308
- **Battle-tested** - Powers our own production tools ([any-agent](https://github.com/mozilla-ai/any-agent))
263309

examples/quickstart.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Quick smoke test for the otari Python SDK (synchronous client)."""
2+
3+
import os
4+
5+
from otari import OtariClient, OtariError
6+
7+
8+
def main() -> None:
9+
client = OtariClient(
10+
api_base=os.environ.get("OTARI_API_BASE", "http://localhost:8100"),
11+
platform_token=os.environ["OTARI_PLATFORM_TOKEN"],
12+
)
13+
14+
with client:
15+
# -- Chat completion --
16+
print("=== Chat Completion ===")
17+
response = client.completion(
18+
model="openai:gpt-4o-mini",
19+
messages=[{"role": "user", "content": "Say hello in three languages, one per line."}],
20+
)
21+
print(response.choices[0].message.content)
22+
print()
23+
24+
# -- Streaming --
25+
print("=== Streaming ===")
26+
stream = client.completion(
27+
model="openai:gpt-4o-mini",
28+
messages=[{"role": "user", "content": "Count from 1 to 5."}],
29+
stream=True,
30+
)
31+
for chunk in stream:
32+
if chunk.choices:
33+
delta = chunk.choices[0].delta.content
34+
if delta:
35+
print(delta, end="", flush=True)
36+
print("\n")
37+
38+
# -- Embeddings --
39+
print("=== Embeddings ===")
40+
try:
41+
emb = client.embedding(
42+
model="openai:text-embedding-3-small",
43+
input="The quick brown fox jumps over the lazy dog.",
44+
)
45+
vec = emb.data[0].embedding
46+
print(f"Dimension: {len(vec)}, first 5 values: {vec[:5]}")
47+
except OtariError as e:
48+
print(f"Skipped ({e.message})")
49+
print()
50+
51+
# -- List models --
52+
print("=== Models ===")
53+
try:
54+
models = client.list_models()
55+
for m in models[:10]:
56+
print(f" {m.id}")
57+
if len(models) > 10:
58+
print(f" ... and {len(models) - 10} more")
59+
except OtariError as e:
60+
print(f"Skipped ({e.message})")
61+
print()
62+
63+
# -- Responses API --
64+
print("=== Responses API ===")
65+
try:
66+
resp = client.response(
67+
model="openai:gpt-4o-mini",
68+
input="What is 2 + 2? Answer with just the number.",
69+
)
70+
print(resp.output_text)
71+
except OtariError as e:
72+
print(f"Skipped ({e.message})")
73+
74+
print("\nDone.")
75+
76+
77+
if __name__ == "__main__":
78+
main()

examples/quickstart_async.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Quick smoke test for the otari Python SDK (asynchronous client)."""
2+
3+
import asyncio
4+
import os
5+
6+
from otari import AsyncOtariClient, OtariError
7+
8+
9+
async def main() -> None:
10+
client = AsyncOtariClient(
11+
api_base=os.environ.get("OTARI_API_BASE", "http://localhost:8100"),
12+
platform_token=os.environ["OTARI_PLATFORM_TOKEN"],
13+
)
14+
15+
async with client:
16+
# -- Chat completion --
17+
print("=== Chat Completion ===")
18+
response = await client.completion(
19+
model="openai:gpt-4o-mini",
20+
messages=[{"role": "user", "content": "Say hello in three languages, one per line."}],
21+
)
22+
print(response.choices[0].message.content)
23+
print()
24+
25+
# -- Streaming --
26+
print("=== Streaming ===")
27+
stream = await client.completion(
28+
model="openai:gpt-4o-mini",
29+
messages=[{"role": "user", "content": "Count from 1 to 5."}],
30+
stream=True,
31+
)
32+
async for chunk in stream:
33+
if chunk.choices:
34+
delta = chunk.choices[0].delta.content
35+
if delta:
36+
print(delta, end="", flush=True)
37+
print("\n")
38+
39+
# -- Embeddings --
40+
print("=== Embeddings ===")
41+
try:
42+
emb = await client.embedding(
43+
model="openai:text-embedding-3-small",
44+
input="The quick brown fox jumps over the lazy dog.",
45+
)
46+
vec = emb.data[0].embedding
47+
print(f"Dimension: {len(vec)}, first 5 values: {vec[:5]}")
48+
except OtariError as e:
49+
print(f"Skipped ({e.message})")
50+
print()
51+
52+
# -- List models --
53+
print("=== Models ===")
54+
try:
55+
models = await client.list_models()
56+
for m in models[:10]:
57+
print(f" {m.id}")
58+
if len(models) > 10:
59+
print(f" ... and {len(models) - 10} more")
60+
except OtariError as e:
61+
print(f"Skipped ({e.message})")
62+
print()
63+
64+
# -- Responses API --
65+
print("=== Responses API ===")
66+
try:
67+
resp = await client.response(
68+
model="openai:gpt-4o-mini",
69+
input="What is 2 + 2? Answer with just the number.",
70+
)
71+
print(resp.output_text)
72+
except OtariError as e:
73+
print(f"Skipped ({e.message})")
74+
75+
print("\nDone.")
76+
77+
78+
if __name__ == "__main__":
79+
asyncio.run(main())

src/otari/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from importlib.metadata import PackageNotFoundError, version
2020

21+
from otari.async_client import AsyncOtariClient
2122
from otari.client import OtariClient
2223
from otari.errors import (
2324
AuthenticationError,
@@ -31,6 +32,7 @@
3132
UpstreamProviderError,
3233
)
3334
from otari.types import (
35+
AsyncStream,
3436
BatchRequestItem,
3537
BatchResult,
3638
BatchResultError,
@@ -56,6 +58,8 @@
5658

5759

5860
__all__ = [
61+
"AsyncOtariClient",
62+
"AsyncStream",
5963
"AuthenticationError",
6064
"BatchNotCompleteError",
6165
"BatchRequestItem",

0 commit comments

Comments
 (0)