Skip to content

Providers & the per-provider module pattern

This page is the provider reference: the constants, the capability matrix, the provider→module map, per-provider notes, multimodal support, and how provider-neutral token usage maps onto each vendor's counts.

Provider constants

Constant String Module (blank-import to activate) API key
chat.ProviderClaude claude chat-anthropic ANTHROPIC_API_KEY
chat.ProviderOpenAI openai chat-openai OPENAI_API_KEY
chat.ProviderOpenAICompatible openai-compatible chat-openai backend-dependent (Token)
chat.ProviderGemini gemini chat-gemini GEMINI_API_KEY
chat.ProviderGeminiVertex gemini-vertex chat-gemini application default credentials
chat.ProviderClaudeLocal claude-local core (no import) none (local claude CLI)

When Config.Provider is empty and the AI_PROVIDER environment variable is unset, the default provider is ProviderClaude. The single chat-openai import registers both openai and openai-compatible.

Prompt caching differs by provider too. claude and gemini implement chat.CachingChatClient with real explicit control; openai implements it but caches automatically anyway; claude-local does not implement it at all, so the type assertion tells a caller the truth. Every provider silently declines to cache content below a per-model minimum. See Cache a large stable prompt.

Generation controls (Temperature, TopP, Effort) are the one axis where support is per model rather than per provider. claude-sonnet-4-5 accepts temperature and refuses effort, while claude-opus-5 does the exact reverse. A provider with no such concept at all fails at construction; a model refusing one it structurally supports yields chat.ErrModelRejectedParameter when the request is made. See Control sampling & reasoning effort.

All five honour Config.Stateless (they implement chat.StatelessCapable), so any of them can serve a batch of independent calls from one client. A provider module older than the flag does not, and chat.New returns an error rather than building a client that ignores it. See Process a batch of documents.

The per-provider modules are thin SDK adapters, too tightly coupled to the core to warrant their own documentation sites. Each ships a detailed README, and all provider documentation lives here on the core docs site.

Version compatibility

A provider module requires the core version it was built and tested against, and names it in its own go.mod. That is the whole compatibility statement.

gitlab.com/phpboyscout/go/chat-anthropic v0.9.1
  └── requires gitlab.com/phpboyscout/go/chat v0.10.1   <- the version that matters

Do not compare the four version numbers. They move independently: each module releases when that module changes, so a provider's own version says nothing about which core it needs. Read its go.mod instead. That is the statement, and Go enforces it.

The four used to be described as released in lockstep at a shared minor. That was never what protected anyone, and it drifted the moment the dependency bumps were automated. See spec 0012.

How to install a matched pair

Ask for the provider module and let it bring the core with it. Each provider's go.mod requires its matching core, so this can only ever give you a pair that was built and tested together:

go get gitlab.com/phpboyscout/go/chat-anthropic
go mod tidy   # promotes the core to a direct requirement at the chosen version

Asking for gitlab.com/phpboyscout/go/chat@latest separately is the way to end up broken. It direct-requires a core that may be a minor ahead of every released provider, and Go's minimum-version selection then keeps that newer core.

What a mismatch actually does

Two different failures, depending on which side is ahead.

An older provider, newer core: a compile error. The provider-authoring API (ResolveAPIKey, UsageTracker, DispatchToolExecution, ValidateMediaSet, the shape of Config) may change in a minor release while the module is pre-1.0. v0.8.0 typing Config.ResponseSchema as *jsonschema.Schema is an example: a provider that still asserts cfg.ResponseSchema.(*jsonschema.Schema) now fails to build, because a type assertion on a non-interface type is illegal:

invalid operation: cfg.ResponseSchema (variable of type *jsonschema.Schema) is not an interface

That error, or an "imported and not used" for jsonschema beside it, means the minors have diverged, and nothing about your own code is wrong.

A provider missing a capability marker: a construction error. Several minors have added a marker interface the core type-asserts, and an older provider simply does not implement it. The core then refuses to build a client that asks for the missing capability, rather than accepting the request and quietly not honouring it:

Added in Marker A too-old provider means
v0.2.0 StatelessCapable Config.Stateless is a construction error
v0.3.0 SamplingCapable, EffortCapable Config.Temperature/TopP/Effort are construction errors
v0.6.0 the capability gate over CachingChatClient Config.CacheTTL is a construction error

This class is loud, at construction, and narrow. Everything else keeps working, so it only bites callers using the newer feature. That is the deliberate pattern: a silently ignored control cannot be told apart from one that does nothing.

Go's minimum-version selection (MVS) makes one direction safe and the other the trap:

  • You can never end up with an older core than a provider requires. Each provider requires its matching core, and MVS raises the core to satisfy it.
  • You can be dragged to a newer core than a provider was built for: if your module (or any other dependency) directly requires a newer chat than the provider modules do, MVS selects that newer core.

So ask for the provider and let it pull the core. That is the one habit worth keeping: it is what makes the safe direction the default, and it does not depend on anyone comparing version numbers.

Capability matrix

Provider Tool calling Parallel tools Structured output Streaming Persistence
Claude ✓ tool-based
OpenAI ✓ JSON Schema
OpenAI-compatible ✓ JSON Schema
Gemini ✓ JSON Schema
Claude Local --json-schema

Feature-detect the optional capabilities (streaming, persistence) with a type assertion rather than assuming them, so a claude-local configuration degrades gracefully.

That table is per provider. Capability is really per model. Two models from one vendor routinely disagree (claude-sonnet-4-5 accepts temperature and refuses effort, claude-opus-5 does the reverse) and the difference does not follow from a version number. Since v0.6.0 each provider module ships a measured table and the core exposes it:

info := chat.CapabilitiesFor(chat.ProviderOpenAI, "gpt-5.4")
info.Capabilities.Support(chat.CapSampling)   // Yes / No / Unknown

Support is three-valued because two of the five providers publish no capability data at all, so "nobody could say" is a real and common answer rather than an edge case. See Check what a model supports for the task, and spec 0006 for why the vocabulary is closed.

Default models

When Config.Model is empty, each provider uses its default. The values, with the constants that carry them, are in Defaults and limits: stated once there so they cannot drift apart from the code or from each other.

This page covers why those particular models.

How these are chosen

One rule, applied identically to every provider:

The default is the most capable model a provider makes generally available that supports the module's baseline capabilities.

"Most capable" rather than a named tier, because tier names are vendor nomenclature that does not generalise. OpenAI's current generation ships as sol/terra/luna, which is not a tier ordering at all. "Generally available" excludes preview and experimental models: a default must not point at something a vendor may withdraw. Baseline capabilities are structured outputs, tool use and multimodal input, all of which this module exposes.

The defaults favour capability over cost. A default is what you get having expressed no preference, and the reason to keep it current is improved capability and reasoning. If cost matters, set Config.Model. That is what the field is for.

Three consequences worth knowing:

Sampling controls no longer work on two of the three defaults. claude-opus-5 deprecates temperature, and gpt-5.6-sol permits only its default of 1 and rejects top_p outright, where the previous OpenAI default gpt-5.4 accepted both. Only Gemini's default still takes them. This is the generational handover from sampling to reasoning controls, and it is the cost of staying current: use Config.Effort, which every provider supports, or set Config.Model to an older model.

You will not discover this in production. Because each provider module ships a measured table of what its models accept, chat.New drops the setting and names it in the error it returns, while still handing back a working client. That is why this default change waited on the capability work rather than shipping ahead of it: raising the default without it would have moved a working configuration to a request-time failure. See Control sampling & reasoning effort.

Gemini's default is a step below the other two. Google ships no generally-available Pro-tier model: gemini-3.1-pro-preview is preview-only, and both gemini-3-pro-preview and gemini-2.5-pro have been withdrawn. So gemini-3.7-flash is the most capable Gemini available at GA, and the gap is real. Defaulting onto a preview would trade a known capability gap for an availability risk that has already materialised twice.

gpt-5.6-sol is a judgement, not a derivation. The three gpt-5.6 variants are indistinguishable in OpenAI's models API (same creation date, same owner) so the rule cannot pick between them. sol is a recorded choice, revisited if the variants turn out to be specialised rather than peers.

Defaults move as vendors release, and a move ships in a minor with the change called out in the changelog so you can see what shifted underneath you. Pin Config.Model if you would rather they did not.

Claude Local

ProviderClaudeLocal routes requests through a locally installed, pre-authenticated claude CLI binary instead of the Anthropic API, which is valuable where outbound HTTPS to api.anthropic.com is blocked but the binary is permitted. It ships in the core, needs no blank import and no API key, and maintains multi-turn continuity via session IDs captured from the CLI's JSON output (passed back with --resume). Its trade-offs: no tool calling (SetTools returns an error; MCP tools are planned), no streaming, no persistence, and no guaranteed token usage. Setup steps are in Choose & configure a provider.

OpenAI-compatible endpoints

ProviderOpenAICompatible targets any backend that speaks the OpenAI API: Ollama, Groq, Fireworks AI, Together AI, LM Studio, vLLM, and others. Both BaseURL and Model are required (model names are backend-specific, so there is no default). Token chunking falls back to the cl100k_base encoding for unrecognised model names, so non-OpenAI model names are handled gracefully.

Multimodal input

Add, Ask, Chat, and StreamChat take a trailing variadic of chat.Media: images (and, on Gemini, PDF and audio/video) sent alongside the text prompt. A text-only call passes no media and is unchanged. Each attachment's type is sniffed from its bytes (never a caller-supplied filename), cross-checked against any declared MIMEType, allowlisted, and checked against the selected provider's support before any network call; disguised or unsupported content is rejected with ErrMediaRejected or ErrMediaUnsupported. Support is per provider:

Provider Images PDF Audio / video
Gemini
Claude
OpenAI
Claude Local

Token usage & cost

Every provider surfaces token usage in a provider-neutral chat.Usage struct: you never touch a vendor SDK's usage type. Read it two ways:

  • ChatClient.Usage() returns the cumulative total across every provider round-trip made by that client since construction.
  • Config.UsageObserver is an opt-in func(Usage) fired once per round-trip (synchronously, so keep it fast), for emitting a metric or event.

A single Chat/Ask/StreamChat may make several round-trips (a ReAct loop makes one per step) and usage is summed across the whole call, which is the figure you want for cost accounting. Each provider module maps its vendor's counts onto the neutral shape:

Provider Source Mapping
Claude Message.Usage (and the streaming message_delta) input_tokensInputTokens, output_tokensOutputTokens, cache_read_input_tokensCachedTokens; TotalTokens computed.
OpenAI / compatible ChatCompletion.Usage prompt_tokensInputTokens, completion_tokensOutputTokens, total_tokensTotalTokens, plus cached/reasoning detail. Streaming opts into the final usage chunk automatically.
Gemini GenerateContentResponse.UsageMetadata promptTokenCountInputTokens, candidatesTokenCountOutputTokens, totalTokenCountTotalTokens, cached/thoughts mapped.
Claude Local optional usage block of the CLI JSON output Surfaced when the binary reports it; otherwise Usage{Known: false}. Do not rely on claude-local for cost accounting.

A freshly-constructed client, and any provider that reports nothing for a call, returns a zero-valued Usage with Known == false. Always check Known before treating counts as authoritative.

Extending the set

Any package can register a new provider (and, for fallback, an HTTP-status extractor) from init(). See Register a custom provider. The registry is the seam that keeps the core provider-agnostic while letting the provider set grow.