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.ProviderVLLM vllm chat-vllm (not released yet) Config.Token or VLLM_API_KEY, or none
chat.ProviderClaudeLocal claude-local chat-anthropic 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.

Every hosted provider except Bedrock honours 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 ✓ ✓ ✓ JSON Schema ✓ ✓
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.

Four consequences worth knowing:

Sampling controls no longer work on two of the three defaults. claude-opus-5-5 rejects temperature, and gpt-6.1-sol rejects both temperature and top_p, as OpenAI's models have since gpt-5.6. 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.

Claude's default is not Anthropic's most capable model. Claude Fable 5.1 ranks above claude-opus-5-5, but Anthropic serves Fable only to organisations that keep 30-day data retention. A zero-data-retention organisation would get a 400 from a default it never chose, and a default has to work for every caller before it can be judged on capability. Fable also costs twice as much, which weighed in the choice, but the rule above would let capability outweigh that on its own. If the retention requirement is lifted, the choice is worth making again. See spec 0025 D4.

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.8-flash is the most capable Gemini available at GA, and the gap is real. A gemini-pro-latest alias is listed, but it is a moving pointer with no versioned GA Pro model behind it, which is exactly what a default must not follow. Defaulting onto a preview would trade a known capability gap for an availability risk that has already materialised twice.

OpenAI's sol line is a judgement the rule inherited. The three gpt-5.6 variants were indistinguishable in OpenAI's models API (same creation date, same owner), so sol was chosen by hand. gpt-6.1 ships as sol alone, so gpt-6.1-sol is both the newest generation and the same line, and needed no second judgement.

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.

Concurrent use

A conversational client is safe to share between goroutines from these versions (spec 0026):

Provider module Safe for concurrent use from
chat-anthropic (claude) v0.16.0
chat-openai (openai, openai-compatible) v0.16.0
chat-gemini (gemini, Vertex) v0.16.0
chat-bedrock (bedrock) v0.3.0

Each needs chat v0.27.0 or later. The client serves one operation at a time, in the order they arrive:

  • Calls queued behind a running one are combined. Consecutive queued Chat and StreamChat calls go upstream as one request. Their prompts are parts of one user turn in arrival order, and every caller gets the reply. Each streaming caller receives every event.
  • Ask runs alone, since a structured answer can't be shared.
  • Add, AddCached and Restore, and so /clear, wait their turn. Nothing a running call commits can undo them.
  • An edit (ApplyPolicyNow, so /compact and /undo) waits by default. chat.WhenBusy makes it refuse or merge instead. See Edits that meet a running call.
  • A caller whose context ends while queued is withdrawn, and its prompt is never sent.

Two caveats. The local-CLI providers (claude-local, codex-local, agy-local) keep their transcripts in the CLI and make no such guarantee. Stateless clients were already safe and are unchanged. From chat v0.29.0 the fallback composite from chat.NewFallback is safe whenever all its members are; see Provider fallback.

An operation must not call back into the client that owns it, for example a stream callback that calls Chat on the same client. It would wait behind itself.

Claude: thinking, refusals and progress

From chat-anthropic v0.15.0, which follows Claude Opus 5.5's always-on thinking and broader safety classifiers (spec 0025):

  • Thinking lives within one call. A tool loop replays each step's thinking to the next, so the model keeps the reasoning behind its tool calls. The stored history never holds it. Anthropic binds a thinking block to the exact conversation that produced it, so replaying one after any earlier turn changes is rejected. The client strips them when the turn is saved, and before a step whose HistoryPolicy edited the history. Truncation, compaction, SetTools and Restore therefore never meet one.
  • A refusal is a *chat.RefusedError, which matches chat.ErrRefused on every provider, and a reply cut at MaxTokens is a *chat.TruncatedError. Either way the call takes back its question, so a refused prompt is not re-sent with every later request. What each provider reports as a refusal is in the errors reference. On a model that thinks before answering, the thinking can use the whole budget, so Partial may be empty.
  • StreamChat reports progress. On claude-opus-5-5 and the Fable models, the notes the model writes between tool calls arrive as EventProgress. The client asks for them only from those models, because older ones reject the setting.
  • An unset Effort means medium on claude-opus-5-5, where it meant high on claude-opus-5. Anthropic measures 5.5 at medium above 5 at high, so the client does not pin it.

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 --tools "": none of the CLI's tools, no settings files and no machine-configured MCP servers. The model sees only your own tools, and the CLI's structured-output tool for Ask. Before chat-anthropic v0.16.2 its file tools could still read inside the working directory. chatanthropic.WithUnrestrictedCLI() opts back in.

claude is the only one of the three local CLIs that can be told to give up its tools, 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. The server comes from chat-mcptools, which a program links with a blank import, so a program that never offers tools doesn't carry the MCP SDK. 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 refused --restricted --strict-mcp-config --tools ""
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.

Only claude-local stops a file being read inside the working directory, from chat-anthropic v0.16.2. For the other two, place that directory deliberately: a program that runs one of them 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. The conformance suite holds each adapter to them: a claim of support for an interface the client does not implement fails its capabilities_match_the_client case.

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