Go client for otari, the open-source core that powers otari.ai. Communicate with any LLM provider through otari using a single, typed interface.
Python SDK | TypeScript SDK | Documentation | Platform (Beta)
New to otari? The otari repo explains what it is and why you’d use it.
go get github.com/mozilla-ai/otari-sdk-go/otariGenerate an API token at otari.ai/organization-settings/api-tokens, then add a provider key (e.g. OpenAI) at otari.ai/organization-settings/provider-keys so the gateway can route requests to that provider. Then use the client:
package main
import (
"context"
"fmt"
"log"
"github.com/mozilla-ai/otari-sdk-go/otari"
)
func main() {
client, err := otari.New(
otari.WithAPIKey("tk_your_api_token"),
otari.WithPlatformMode(),
)
if err != nil {
log.Fatal(err)
}
resp, err := client.Completion(context.Background(), otari.CompletionParams{
Model: "openai:gpt-4o-mini",
Messages: []otari.Message{{Role: otari.RoleUser, Content: "Hello!"}},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Choices[0].Message.ContentString())
}That's it: the client defaults to the hosted gateway at https://api.otari.ai, so you don't need to set a base URL. Change the model string to switch between LLM providers through the gateway.
go get github.com/mozilla-ai/otari-sdk-go/otariFor the hosted platform, set the platform-token environment variable:
export OTARI_AI_TOKEN="tk_your_api_token"
# GATEWAY_PLATFORM_TOKEN is still accepted as a legacy alias.For a self-hosted gateway, set the base URL and gateway API key:
export GATEWAY_API_BASE="http://localhost:8000"
export GATEWAY_API_KEY="your-key-here"Alternatively, pass credentials directly when creating the client (see Authentication).
The client supports two authentication modes, matching the Python and TypeScript SDKs.
Platform mode (recommended) uses a platform token as standard Authorization: Bearer auth. On the hosted platform, generate an API token at otari.ai/organization-settings/api-tokens and add a provider key (e.g. OpenAI) at otari.ai/organization-settings/provider-keys so the gateway can route requests to that provider. The base URL defaults to the hosted gateway at https://api.otari.ai, so it can be omitted:
client, err := otari.New(
otari.WithAPIKey("tk_your_api_token"),
otari.WithPlatformMode(),
)The token can also be supplied via the OTARI_AI_TOKEN environment variable (legacy alias GATEWAY_PLATFORM_TOKEN), in which case otari.New(otari.WithPlatformMode()) picks it up automatically.
Self-hosted mode sends the API key via the custom Otari-Key: Bearer … header and requires a base URL. Run the gateway yourself following the setup in the otari repo, make sure it has provider keys configured, then point the SDK at it:
client, err := otari.New(
otari.WithBaseURL("http://localhost:8000"), // or wherever you host the gateway
otari.WithOtariKey("your-gateway-api-key"),
)The base URL and key can also come from the GATEWAY_API_BASE and GATEWAY_API_KEY environment variables. When no explicit credentials are provided, otari.New() resolves the mode from the environment: a platform token (OTARI_AI_TOKEN / GATEWAY_PLATFORM_TOKEN) selects platform mode against the hosted gateway, otherwise GATEWAY_API_BASE + GATEWAY_API_KEY select self-hosted mode.
resp, err := client.Completion(ctx, otari.CompletionParams{
Model: "openai:gpt-4o-mini",
Messages: []otari.Message{{Role: otari.RoleUser, Content: "Hello!"}},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Choices[0].Message.ContentString())Streaming methods return a channel of typed chunks and a buffered error channel. Drain the chunk channel, then check the error channel once it closes.
chunks, errs := client.CompletionStream(ctx, otari.CompletionParams{
Model: "openai:gpt-4o-mini",
Messages: []otari.Message{{Role: otari.RoleUser, Content: "Tell me a story."}},
})
for chunk := range chunks {
if len(chunk.Choices) > 0 {
fmt.Print(chunk.Choices[0].Delta.Content)
}
}
if err := <-errs; err != nil {
log.Fatal(err)
}The OpenAI-style Responses API is exposed via Response (and ResponseStream). The gateway returns an opaque payload, so the decoded JSON is delivered as a map[string]any. Input accepts any JSON-serializable value (a string, a message list, and so on); Extra forwards additional /responses fields verbatim.
resp, err := client.Response(ctx, otari.ResponseParams{
Model: "openai:gpt-4o-mini",
Input: "Write a haiku about Go.",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp["output_text"])Streaming delivers each event as a raw decoded JSON map:
events, errs := client.ResponseStream(ctx, otari.ResponseParams{
Model: "openai:gpt-4o-mini",
Input: "Write a haiku about Go.",
})
for event := range events {
fmt.Println(event["type"])
}
if err := <-errs; err != nil {
log.Fatal(err)
}The Anthropic-shaped Messages API is exposed via Message (and MessageStream). MaxTokens is required by the gateway. The response is opaque, so the decoded JSON is delivered as a map[string]any; Extra forwards additional /messages fields (system, tools, thinking, and so on) verbatim.
resp, err := client.Message(ctx, otari.MessageParams{
Model: "anthropic:claude-3-5-sonnet",
MaxTokens: 1024,
Messages: []map[string]any{
{"role": "user", "content": "Hello!"},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp["content"])Streaming delivers each event as a raw decoded JSON map:
events, errs := client.MessageStream(ctx, otari.MessageParams{
Model: "anthropic:claude-3-5-sonnet",
MaxTokens: 1024,
Messages: []map[string]any{
{"role": "user", "content": "Tell me a story."},
},
})
for event := range events {
fmt.Println(event["type"])
}
if err := <-errs; err != nil {
log.Fatal(err)
}result, err := client.Embedding(ctx, otari.EmbeddingParams{
Model: "openai:text-embedding-3-small",
Input: "Hello world",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(result.Data[0].Embedding)models, err := client.ListModels(ctx)
if err != nil {
log.Fatal(err)
}
for _, m := range models.Data {
fmt.Println(m.ID)
}result, err := client.Moderation(ctx, otari.ModerationParams{
Model: "openai:omni-moderation-latest",
Input: "some content to check",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Flagged: %v\n", result.Results[0].Flagged)result, err := client.Rerank(ctx, otari.RerankParams{
Model: "cohere:rerank-v3.5",
Query: "What is machine learning?",
Documents: []string{"ML is a subset of AI", "The weather is nice"},
})
if err != nil {
log.Fatal(err)
}
for _, r := range result.Results {
fmt.Printf("doc[%d] score=%.3f\n", r.Index, r.RelevanceScore)
}Create a batch, poll it with RetrieveBatch, cancel it with CancelBatch, enumerate jobs with ListBatches, and fetch results with RetrieveBatchResults. Each lookup takes the batch ID and the upstream provider.
// Create a batch.
batch, err := client.CreateBatch(ctx, otari.CreateBatchParams{
Model: "openai:gpt-4o-mini",
Requests: []otari.BatchRequestItem{
{CustomID: "req-1", Body: map[string]any{"messages": []any{
map[string]any{"role": "user", "content": "Hello"},
}}},
},
CompletionWindow: "24h",
})
if err != nil {
log.Fatal(err)
}
// Check status later.
status, err := client.RetrieveBatch(ctx, batch.ID, batch.Provider)
if err != nil {
log.Fatal(err)
}
fmt.Println(status.Status)
// Retrieve results once complete.
results, err := client.RetrieveBatchResults(ctx, batch.ID, batch.Provider)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d results\n", len(results.Results))All errors are typed. Use errors.Is for sentinel checks and errors.As for typed details:
import "errors"
resp, err := client.Completion(ctx, params)
if err != nil {
switch {
case errors.Is(err, otari.ErrAuthentication):
log.Fatal("Invalid credentials")
case errors.Is(err, otari.ErrRateLimit):
log.Fatal("Rate limited, try again later")
case errors.Is(err, otari.ErrInsufficientFunds):
log.Fatal("Budget exceeded")
default:
log.Fatal(err)
}
}| HTTP Status | Error Type | Sentinel | Description |
|---|---|---|---|
| 400 (capability) | *UnsupportedCapabilityError |
ErrUnsupported |
Provider does not support the requested capability |
| 401, 403 | *AuthenticationError |
ErrAuthentication |
Invalid or missing credentials |
| 402 | *InsufficientFundsError |
ErrInsufficientFunds |
Budget or credits exhausted |
| 404 | *ModelNotFoundError |
ErrModelNotFound |
Model not found, or no provider key configured for the requested provider; the error carries the gateway's detail |
| 409 | *BatchNotCompleteError |
ErrBatchNotComplete |
Batch not yet finished (includes BatchID, Status) |
| 429 | *RateLimitError |
ErrRateLimit |
Rate limit exceeded |
| 502 | *UpstreamProviderError |
ErrUpstreamProvider |
Upstream provider unreachable |
| 504 | *TimeoutError |
ErrTimeout |
Gateway timed out waiting for provider |
otari-sdk-go/
├── otari/ # Main package (hand-written ergonomic shell)
│ ├── client.go # Client struct, constructor, chat/messages/responses methods
│ ├── config.go # Functional options (WithBaseURL, WithAPIKey, WithPlatformMode, ...)
│ ├── streaming.go # Hand-written SSE decoder (channel-based streaming)
│ ├── errors.go # Typed error hierarchy with sentinel errors
│ ├── types.go # Request/response types
│ ├── batch.go # Batch API methods
│ ├── moderation.go # Content moderation
│ ├── rerank.go # Document reranking
│ ├── control_plane.go # Control-plane API (keys/users/budgets/pricing/usage)
│ ├── conversion.go # Type conversions
│ ├── request.go # HTTP request helpers
│ ├── transport.go # HTTP transport helpers
│ ├── otari.go # Package documentation
│ ├── client/ # Generated OpenAPI core (do not hand-edit; regenerated from the spec)
│ └── *_test.go # Unit, integration, and endpoint-coverage tests
├── examples/
│ └── quickstart/ # Quick smoke test
├── sdk-endpoints.txt # Endpoint-coverage manifest (generated; edit it in otari)
├── go.mod
├── go.sum
└── README.md
# Run tests
go test ./...
# Run tests with verbose output
go test -v ./...
# Build
go build ./...
# Vet
go vet ./...- Full Documentation - Complete guides and API reference
- Supported Providers - List of all supported LLM providers
- Gateway Documentation - Gateway setup and deployment
- Python SDK - The Python SDK
- TypeScript SDK - The TypeScript SDK for Node.js applications
- otari Platform (Beta) - Hosted control plane for key management, usage tracking, and cost visibility
We welcome contributions from developers of all skill levels! Please see the Contributing Guide or open an issue to discuss changes.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.