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:
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
chatthan 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
outputConfigfield outright, soAskdoes 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
Asksupports 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
modelfield in the body; - an
api-versionquery parameter is required on every request; - authentication is an
api-keyheader or an Entra ID token, notAuthorization: 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:
- 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.
- 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.
- 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 | 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.UsageObserveris an opt-infunc(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_tokens→InputTokens, output_tokens→OutputTokens, cache_read_input_tokens→CachedTokens; TotalTokens computed. |
| OpenAI / compatible | ChatCompletion.Usage |
prompt_tokens→InputTokens, completion_tokens→OutputTokens, total_tokens→TotalTokens, plus cached/reasoning detail. Streaming opts into the final usage chunk automatically. |
| Gemini | GenerateContentResponse.UsageMetadata |
promptTokenCount→InputTokens, candidatesTokenCount→OutputTokens, totalTokenCount→TotalTokens, 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_tokens→InputTokens, cached_input_tokens→CachedTokens, output_tokens→OutputTokens. 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_tokens→ReasoningTokens, cache_read_tokens→CachedTokens 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.