just-another-sdk

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 Completions

Everything 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 not

Reads 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 finish chunk carrying a complete ModelResponse — the same object generate() 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-delta exists so a provider can report them, but the runtime never forwards fragments to user code. The complete, parsed call belongs in the finish chunk.

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 · 296ms

Only 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.

Next

On this page