Providers
Anthropic, Gemini, OpenAI, OpenRouter, local models, and writing your own.
A provider binds the SDK to a model. Every provider that ships with the package
is a plain fetch wrapper — no vendor SDK, no dependency, which is why the
package installs with an empty dependency tree and runs unchanged on Node, Bun,
Deno, and edge runtimes.
Three vendors have native transports, speaking their own API directly rather than through a translation layer:
import { anthropic, google, openai } from 'just-another-sdk/providers'
anthropic('claude-opus-5') // the Messages API
google('gemini-2.5-pro') // generateContent
openai('gpt-5') // Chat CompletionsEverything else rides the OpenAI-compatible transport — OpenRouter for hundreds
of models behind one key, or compatible() for any endpoint that speaks the
OpenAI shape.
Anthropic
Claude, natively, over the Messages API.
import { anthropic } from 'just-another-sdk/providers'
anthropic('claude-opus-5')
anthropic('claude-sonnet-5')Reads ANTHROPIC_API_KEY from the environment by default.
Prop
Type
Three things worth knowing.
max_tokens is required by the API but optional on the agent, so the
provider supplies 4096 when you do not. Set maxOutputTokens on the agent, or
maxOutputTokens on the provider, if your answers are longer than that.
Recent Claude models reject temperature with a 400 — Opus 5, Opus 4.8,
Opus 4.7, and Fable 5 among them. The provider passes it through when you set it,
because second-guessing the request is not a provider's job, so remove it with
the escape hatch if a model refuses it:
anthropic('claude-opus-5', { defaultBody: { temperature: undefined } })Structured output is opt-in. output_config.format is native but model-gated,
so an older Claude rejects it. Left off, outputSchema still works — the runtime
instructs, validates, and repairs, exactly as it does for any provider without
native support. Turn it on when you know the model accepts it:
anthropic('claude-opus-5', { structuredOutputs: true })defaultBody reaches anything else the API takes:
anthropic('claude-opus-5', {
defaultBody: {
thinking: { type: 'adaptive' },
output_config: { effort: 'high' },
},
})Gemini
Gemini, natively, over generateContent. Exported as both google and gemini.
import { google } from 'just-another-sdk/providers'
google('gemini-2.5-pro')
google('gemini-2.5-flash')
google('tunedModels/my-tuned-model') // a bare id is prefixed with `models/`; this is notReads GEMINI_API_KEY, then GOOGLE_API_KEY.
Prop
Type
The key is sent as the x-goog-api-key header, never as a ?key= query
parameter — a credential in a URL ends up in error messages, proxy access logs,
and referrers.
Tool schemas are rewritten before they are sent. Gemini accepts only an
OpenAPI 3.0 subset of JSON Schema: no $schema, no additionalProperties, no
$defs, no const, no oneOf. Zod emits the first of those and the SDK's own
empty-object schema emits the second, so without a translation pass every tool
would be rejected. The pass inlines $refs, rewrites const to a single-member
enum, merges allOf, renames oneOf to anyOf, and drops the rest. It never
throws — anything unrepresentable degrades to a looser schema.
Structured output is skipped when the agent has tools. Gemini does not
reliably support responseSchema alongside functionDeclarations; depending on
the model it either fails the request or quietly stops calling functions. With
tools present, outputSchema falls back to the runtime's instruct-and-repair
path. Without tools, it maps to responseMimeType and a sanitized
responseSchema.
A 429 carries Google's RetryInfo, and the provider reads it. When the delay it
asks for exceeds maxRetryDelayMs, the retry policy stops retrying and moves to
the next provider in your fallbacks chain — which is the right outcome, and
better than burning the whole retry budget on short back-offs that immediately
rate-limit again.
OpenRouter
One key reaches hundreds of models across every major vendor, which makes swapping models a one-string change.
import { openrouter } from 'just-another-sdk/providers'
openrouter('anthropic/claude-opus-5')
openrouter('openai/gpt-5')
openrouter('google/gemini-2.5-pro')
openrouter('meta-llama/llama-4-maverick')Reads OPENROUTER_API_KEY from the environment by default.
Prop
Type
openrouter('anthropic/claude-opus-5', {
referer: 'https://myapp.com',
title: 'My App',
only: ['anthropic'], // do not route this to a third-party host
})Model ids are vendor/model, exactly as listed at
openrouter.ai/models.
OpenAI
import { openai } from 'just-another-sdk/providers'
openai('gpt-5')
openai('gpt-5-mini', { organization: 'org_…', project: 'proj_…' })Reads OPENAI_API_KEY.
Anything OpenAI-compatible
compatible() points the same transport at any endpoint exposing
POST /chat/completions — Groq, Together, Fireworks, DeepSeek, xAI, Ollama,
vLLM, LM Studio, or your own gateway.
import { compatible } from 'just-another-sdk/providers'
// Groq
compatible('llama-3.3-70b-versatile', {
baseUrl: 'https://api.groq.com/openai/v1',
apiKey: process.env.GROQ_API_KEY,
providerId: 'groq', // appears in traces
})
// Ollama, running locally — no key needed
compatible('llama3.1', { baseUrl: 'http://localhost:11434/v1' })
// Your own gateway
compatible('internal-model', {
baseUrl: 'https://llm.internal.acme.com/v1',
apiKey: process.env.GATEWAY_TOKEN,
providerId: 'acme-gateway',
})Local models are the cheapest way to develop against a tool-calling loop.
Point compatible() at Ollama, iterate until the logic is right, then switch
the one line to a hosted model.
Swapping models
Because the provider is just a config value, an A/B test or a tier downgrade is a
clone():
const premium = agent
const standard = agent.clone({
model: openrouter('anthropic/claude-haiku-4-5'),
})
const result = await (user.isPro ? premium : standard).run(prompt)Writing a provider
Implement one method. This is the entire contract:
import type { ModelProvider } from 'just-another-sdk'
export function myProvider(modelId: string): ModelProvider {
return {
providerId: 'mine',
modelId,
async generate(request, options) {
const response = await fetch('https://api.example.com/v1/generate', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.MY_KEY}` },
body: JSON.stringify(translateRequest(request)),
signal: options?.signal,
})
if (!response.ok) throw translateError(response)
return translateResponse(await response.json())
},
}
}A provider is responsible for exactly three things: translating the request into its vendor's wire format, making the call, and translating the response and any errors back. It must not retry, must not manage conversation state, and must not know that agents or tools-as-functions exist — that keeps the agent loop provider-agnostic and makes a new vendor a single self-contained file.
What you receive
interface ModelRequest {
messages: ModelMessage[] // conversation so far, excluding the system message
system?: string // hoisted instructions
tools?: ToolDefinition[] // { name, description, parameters }
toolChoice?: ToolChoice
maxOutputTokens?: number
temperature?: number
stopSequences?: string[]
responseFormat?: ResponseFormat
metadata?: Record<string, string>
providerOptions?: Record<string, unknown> // vendor-specific escape hatch
}What you return
interface ModelResponse {
content: (TextPart | ToolCallPart)[]
finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'other'
usage: { inputTokens: number; outputTokens: number; totalTokens: number }
modelId: string // the model that actually served the request
raw?: unknown // untouched provider payload, for debugging
}Streaming is optional
stream() is the only optional method on the contract. Implement it to emit
tokens as they arrive; omit it and agent.stream() still
works, delivering the whole answer as a single text.delta.
interface ModelProvider {
generate(request, options?): Promise<ModelResponse>
stream?(request, options?): AsyncIterable<ModelStreamChunk>
}
type ModelStreamChunk =
| { type: 'text-delta'; text: string }
| {
type: 'tool-call-delta'
toolCallId: string
toolName?: string
inputDelta: string
}
| { type: 'finish'; response: ModelResponse }Two obligations if you do implement it:
- End with a
finishchunk carrying a completeModelResponse— the same objectgenerate()would have returned. The runtime tolerates its absence by reconstructing one, but the reconstruction has no usage figures. - Reassemble tool calls yourself. Vendors fragment arguments across chunks;
tool-call-deltaexists so a provider can report them, but the runtime never forwards fragments to user code. The complete, parsed call belongs in thefinishchunk.
Timeouts apply to the whole stream, not to time-to-first-byte.
If you are wrapping an SSE endpoint, text/event-stream framing is already
solved — the OpenAI-compatible transport uses a vendor-neutral parser that
handles chunk-boundary splits, keep-alive comments, and multi-line data fields.
Translating errors
Map vendor failures onto the SDK's error types so callers can handle them uniformly, and never put credentials in a message:
import {
AuthenticationError,
RateLimitError,
ProviderError,
} from 'just-another-sdk'
if (response.status === 401) throw new AuthenticationError('…')
if (response.status === 429) throw new RateLimitError('…', { retryAfterMs })
throw new ProviderError(`… returned ${response.status}`, {
status: response.status,
})Falling over
A provider list is only as useful as what happens when one of them is down. Pass
fallbacks and the run continues on the next provider — same run id, same usage
total, same transcript:
new Agent({
name: 'assistant',
model: anthropic('claude-opus-5'),
fallbacks: [google('gemini-2.5-pro'), openai('gpt-5')],
})▶ run_msbtg147_qsa69f assistant · claude-opus-5
⟳ retry 1/2 · provider_error · waiting 181ms
⇄ fallback → gemini-2.5-pro · after provider_error
↳ get_weather {"city":"Paris"}
→ {"city":"Paris","tempC":18,"summary":"clear"} 1ms
✔ finish · 2 turns · 20 in / 10 out · 296msOnly retryable failures fall over: a rate limit, a timeout, a network error, a
5xx — Anthropic's 529 overload and Gemini's RESOURCE_EXHAUSTED included. A bad
API key or a malformed request fails immediately, because trying the same broken
request against another vendor only wastes time.