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.ProviderBedrock bedrock chat-bedrock the AWS credential chain
chat.ProviderAzureOpenAI azure-openai chat-openai-azure Config.Token as an api-key, or an Entra ID credential
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
Gemini (Vertex) ✓ JSON Schema
Bedrock per model per model
Azure OpenAI ✓ JSON Schema
Claude Local ✓ MCP --json-schema
Codex Local ✓ MCP --output-schema ✓ images
Agy 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.

Bedrock says "per model" for two columns, and means it. Every other row describes one vendor's API. Bedrock serves many vendors behind one, so tool support and structured output vary by which model you select rather than by the provider. Ask Capabilities() rather than the table: it consults a per-model answer, and a model that cannot honour a setting refuses at construction rather than failing mid-request.

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, signed-in 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 is registered by the chat-anthropic import alongside ProviderClaude, takes no API key, and maintains multi-turn continuity via session IDs captured from the CLI's JSON output (passed back with --resume).

The subprocess receives PATH and HOME only. The CLI prefers an ANTHROPIC_API_KEY in its environment over its own stored login, so inheriting the host's environment would let an unrelated variable decide which account paid. chatanthropic.WithClaudeEnvironment names anything else the CLI needs.

The CLI is an agentic tool with tools of its own, so this provider runs it with --restricted --strict-mcp-config: no command execution, no WebFetch, no settings files and no machine-configured MCP servers. Its file tools still read inside the working directory, which is the residual a caller has to place deliberately. chatanthropic.WithUnrestrictedCLI() opts back in.

claude is the only one of the three local CLIs that can be told to give up command execution, which is why the section below is a table rather than a sentence.

Tools you register do work, and take the only route available across a process boundary: each call publishes the handlers on a loopback MCP server, points the CLI at it, and takes it down again when the call returns. Dispatch is the core's, so a tool behaves as it would anywhere else. Spec 0009 has the reasoning, including why the server's life is one call and why that keeps Close() off ChatClient.

Its trade-offs: no streaming, no persistence, and no guaranteed token usage. Setup steps are in Choose & configure a provider.

What a local-CLI provider can and cannot promise

Three providers drive a CLI in a subprocess: claude-local, codex-local and agy-local. Each CLI is an agentic tool that ships tools of its own, aimed at the machine running your program. Every one of these providers passes the strongest restraint its CLI offers, and that is not the same restraint.

Measured with one prompt asking for both a shell command and a file read:

Provider shell execution file read in the working directory what is passed
claude-local refused allowed --restricted --strict-mcp-config
codex-local allowed allowed --sandbox read-only, plus your own config
agy-local allowed allowed --sandbox --disable-slash-commands

Two consequences worth taking seriously.

A prompt reaching codex-local or agy-local can run commands on the host, as you. Their sandboxes restrict what can be written, not what can be run. If the prompt text comes from somewhere you do not control, either use the vendor's API provider instead or accept that the model can act.

None of the three stops a file being read inside the working directory. Place that directory deliberately: a program that runs one of these from a checkout of your secrets has handed them to the model whatever else is set.

Each provider offers an option that removes even this much (WithUnrestrictedCLI, WithIgnoredUserConfig, WithUnrestrictedAgy); those are for callers who own the prompt and want the CLI to behave as it does in a terminal.

codex-local differs from the other two in one further way: it honours your own codex configuration, including the permissions set through the CLI's /permissions command. Discarding it would have run with codex's defaults instead, which is the opposite of hardening.

OpenAI-compatible endpoints

ProviderOpenAICompatible targets any backend that speaks the OpenAI API: Ollama, xAI, 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.

Bedrock is a marketplace, not a vendor

Every other provider here is one company's API, so a capability is a property of the provider. Bedrock is a storefront for many vendors' models behind a single API, and that changes what a provider name can promise.

The wire protocol is uniform: the Converse API takes the same request shape for every model family, which is why this module speaks it rather than the per-model InvokeModel payloads. What is not uniform is what a model does with that request, and no listing reports it. Measured in eu-west-2 on 2026-08-31:

  • No Amazon-owned model supports structured output. The Nova family rejects the outputConfig field outright, so Ask does not work on any of them.
  • No model at all supports Config.Effort. The field exists on the API, and every model tested refused it.
  • Only the Nova family supports prompt caching, and nothing that supports Ask supports caching. A caller wanting both cannot have them on one model.

That is why the Bedrock default is not an Amazon model despite Bedrock being an AWS service: the default-model rule requires structured output, tool use and multimodal input, and no Amazon model meets it.

It is also why the capability table for Bedrock is measured rather than read. A probe sends a real request and parses the reply, because accepting a request and honouring it are different things there: one model accepts a structured-output request and returns prose with JSON inside it, which a probe checking only for refusal would record as capable.

Addressing is by region, and a profile can leave it

Bedrock addresses by region rather than by endpoint, so Config.Location selects it and there is no BaseURL to set.

Config.Model takes any form Bedrock accepts: a model ID, an inference profile ID, or an ARN. The module passes it through untouched, because the mapping between them is AWS's and changes.

Worth knowing before choosing: a cross-region inference profile such as eu.anthropic... routes across a whole region set, not the one you configured. An organisation restricting requests to a single region will refuse it, and the error names a region you never set. Bare on-demand model IDs stay put.

Azure hides the model behind a deployment

Azure OpenAI serves OpenAI's models, so chat-openai-azure implements no protocol at all. It is the only module in the family that delegates: it resolves three things and hands them to chat-openai, which does the rest.

Those three are what stop Azure being reachable as openai-compatible, despite speaking the same protocol:

  • the model is addressed as a deployment name in the request path, not a model field in the body;
  • an api-version query parameter is required on every request;
  • authentication is an api-key header or an Entra ID token, not Authorization: Bearer.

Point BaseURL at an Azure endpoint under openai-compatible and you get 404s that do not explain themselves.

Config.Model is the deployment, not the model

This is the part that surprises people. On Azure you create a deployment of a model and give it a name, and that name is what the API addresses. It is frequently not the model's name: my-gpt4-prod is a perfectly ordinary deployment of gpt-4o.

So Config.Model carries the deployment name, the SDK substitutes it into the path, and this module neither parses nor validates it. There is no default, because a deployment is named by whoever created it.

Which is why capability lookup answers Unknown

chat-openai's capability table is keyed on OpenAI's model names, and a deployment name resolves to none of them, so nothing model-specific can be looked up.

So from chat-openai v0.12.1, Azure and openai-compatible both report SupportUnknown for everything model-dependent: tools, structured output, multimodal, sampling, effort and caching. What the adapter itself implements, streaming and statelessness and persistence, is still reported as supported, because that is a fact about our code rather than about your deployment.

Unknown proceeds untouched, so nothing is refused by this. What you lose is being told in advance that a deployment cannot do something, and what you gain is not being promised something nobody checked.

Before v0.12.1 both reported SupportYes for those, which was an adapter-level claim standing where an endpoint-level answer belongs. It produced a contradiction worth remembering: CapMultimodal said yes while the core refused every attachment sent to the provider.

Azure does return the real model in its response, and it arrives too late to help: capability lookup is a pure function of the model string, resolved at construction before any request has been sent.

api-version is required, and deliberately has no default

Azure's api-versions are dated strings, and which models exist varies between them. A default shipped in this family would be correct on the day it was written and silently wrong later, surfacing as a missing model rather than as a version problem. So Config.APIVersion is refused when empty, and the error names the field.

Why a separate module rather than a flag on chat-openai

The Azure SDK's azcore is 33 packages. Folding Azure into chat-openai would put them in the dependency graph of everyone talking to OpenAI, whether or not they ever address a deployment. chat-openai gained one seam instead, WithExternalAuth, and chat-openai-azure carries the Azure SDK alone.

It depends on azcore, the credential interface, and deliberately not on azidentity, the credential implementations, which is a further 48 packages. A caller wanting ambient credential discovery brings azureclient themselves and passes what it yields.

Where capability answers come from

Capabilities() answers two different kinds of question, and knowing which is which explains most of what it says.

Adapter-level capabilities describe this family's own code: whether the module streams, whether it can run statelessly, whether it can persist a conversation. These are known with certainty, because we wrote them, and they do not vary by model or endpoint.

Model-level capabilities describe whatever is on the other end of the wire: tools, structured output, multimodal input, sampling, reasoning effort, caching. None of these are knowable from our code alone, so each comes from a source, and where no source answers the honest reply is SupportUnknown.

There are three sources, in order:

  1. What you declared. A caller who has pointed at their own endpoint knows more about it than any registry, and is trusted rather than validated.
  2. What a probe measured. Where a backend has a fixed catalogue and can be asked, the adapters probe it and generate a table. A probe that cannot reach a conclusion records unknown rather than assuming support.
  3. What models.dev reports. A community-maintained, MIT-licensed catalogue covering every provider this family ships, read when a matrix is generated and never at runtime. It is the same source LangChain generates its model profiles from.

SupportUnknown is not a failure state. It proceeds untouched, so a capability nobody could confirm never refuses a request that would have worked. The three values exist precisely so that "we do not know" is sayable, rather than being rounded to a yes or a no.

Two things no registry answers, and which stay measured: caching, which models.dev carries no field for, and the adapter-level trio above, which are ours to know.

The probes did not go away when models.dev arrived. Where an adapter measures something models.dev also reports, regeneration compares the two and records every disagreement in the generated file's header, so a conflict between what we saw and what the registry says is reviewed rather than resolved unseen. The first such comparison found our own probe had been reporting support it never measured, which is the reason both sources are kept.

Maintainers regenerating a matrix want Regenerating capability data. The reasoning is spec 0020.

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
Gemini (Vertex)
Claude
OpenAI
Bedrock
Azure OpenAI
OpenAI-compatible
Claude Local
Codex Local
Agy Local

openai-compatible accepts no media, because the endpoint is whatever a caller pointed BaseURL at and the wire protocol carries no way to ask what it supports. That is the conservative reading rather than a statement about any particular endpoint, and a caller pointing at a vision-capable one currently cannot use it.

Bedrock is a row about the API rather than about any one model. Converse carries image and document blocks for every model family, but whether a given model reads an image is the model's business, so Capabilities() answers that and this table does not.

Azure accepts what OpenAI accepts, because it is OpenAI's wire shape at a different address. Note the contrast with the openai-compatible row above: both are caller-owned endpoints, but Azure is a known vendor whose media support is not in question, where a compatible endpoint could be anything.

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.
Codex Local the turn.completed event of the --json stream input_tokensInputTokens, cached_input_tokensCachedTokens, output_tokensOutputTokens. Absent if the stream ends early, which costs the usage and not the reply.
Agy Local the usage object of the JSON reply The fullest of the three: input, output, thinking_tokensReasoningTokens, cache_read_tokensCachedTokens and a reported total. It is the only local-CLI provider that answers with Known: true.

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.