Provider Adapters - joeylking/agent-runtime GitHub Wiki
The runtime's model interface is two methods, and for a long time no adapter shipped with it: each consumer wrote its own. Two consumers later the same adapters existed twice, with the same classification bugs fixed twice. They are now in the repository, one nested module each, at v0.2.0 and requiring core v0.3.0.
go get github.com/joeylking/agent-runtime/providers/[email protected]
go get github.com/joeylking/agent-runtime/providers/[email protected]
go get github.com/joeylking/agent-runtime/providers/[email protected]| Module | Reaches | Dependencies |
|---|---|---|
providers/ollama |
a local Ollama server over its native /api/chat
|
the standard library and the core |
providers/openai |
any OpenAI-compatible /chat/completions endpoint, including Ollama's /v1
|
the standard library and the core |
providers/anthropic |
the Claude Messages API through the official SDK |
github.com/anthropics/anthropic-sdk-go and the core |
Each has its own go.mod. A consumer imports the one provider it uses, and a vulnerability or a breaking change in a provider SDK never reaches the core module, which still depends on nothing but a JSON Schema validator and SQLite. The price of that is a directory-prefixed tag per module and a workspace when working inside the repository; see the workspace note below.
Every adapter is built the same way, so a consumer swaps one for another without touching the agent:
m, err := ollama.New(ollama.Config{Model: "qwen3:30b-a3b"})
m, err := openai.New(openai.Config{Model: "gpt-4.1-mini"})
m, err := anthropic.New(anthropic.Config{Model: "claude-opus-5"})Config.Model is the provider's own model id and is required; New refuses an empty one. Config.HTTPClient overrides the client, which is how a consumer adds a proxy or a transport. Everything else is per provider.
Name() reports <provider>:<model>: ollama:qwen3:30b-a3b, openai:gpt-4.1-mini, anthropic:claude-opus-5. The prefix matters because the name is a key in two places: the price table the driver charges against, and the directory a recording is written to. One prefixed namespace lets a consumer hold several providers in one price table and one recording directory without collisions, and lets an operator read a run's model calls and see which provider served them.
ollama.Name(model), openai.Name(model), and anthropic.Name(model) return that default name for a model id, for a consumer that keys a price table or a recording directory before it builds the adapter.
Config.Name overrides it. That exists for a consumer whose recordings or price table already use another name; Casework's do. Its recordings are keyed by the model name together with a canonical hash of the request, so adopting the prefixed name would turn every recorded response into a miss and every replayed session into a divergence. An override keeps the extracted adapter byte-faithful to what was already measured. Setting it is otherwise discouraged: anthropic.Prices() is keyed by the default name, so an override on a paid model means pricing it by hand. ollama.Free and openai.Free take the built adapters themselves and key on whatever each reports, so an override on a local model costs nothing extra:
local, _ := ollama.New(ollama.Config{Model: "qwen3:30b-a3b"})
prices := ollama.Free(local) // keyed by local.Name(), override includedEvery adapter builds its client with providers.Client: a copy of the consumer's client (never modified) or a new one, never the shared http.DefaultClient, whose transport refuses a response body over 16 MiB (providers.MaxBodyBytes) and which follows no redirect.
Redirects are refused because the key rides in a header, and net/http forwards every header but Authorization to another host, and Authorization too to the same host name on another port or from https to http. A followed redirect could hand the key and the whole prompt to whatever host the endpoint named. A 3xx is returned as a permanent StatusError whose body names only the host the Location header pointed to, not its path or query, which may carry what the endpoint was given. A consumer whose client has a CheckRedirect of its own keeps it, and where its redirects send the key is then its responsibility.
With no client configured, a request falls back to providers.DefaultTimeout, 15 minutes, as a backstop for a context with no deadline. The caller's context is what bounds a request; the accounting caller gives each attempt ModelConfig.CallTimeout.
The two adapters that carry a key, OpenAI and Anthropic, refuse to send one over plain http:// to any host but exactly localhost or a literal loopback address (providers.Loopback). Not LOCALHOST, not localhost., not a name under .localhost, and not a numeric form such as 127.1 that only some resolvers read as an address. Then, because localhost is the resolver's answer and not a promise, and a proxy would carry the key off the machine, each such connection is checked as it is made: one whose dialled address is not loopback is refused with providers.ErrNotLoopback (providers.LoopbackOnly). That check works on an *http.Transport, cloned and never modified; a consumer's own RoundTripper is left as given.
providers.CheckEndpoint also refuses a base URL that is not http or https, has no host, or carries user information.
| Adapter | Key | Endpoint | Nothing else |
|---|---|---|---|
| Ollama | none |
Config.Host, then OLLAMA_HOST, then 127.0.0.1:11434
|
reads no other variable |
| OpenAI |
Config.APIKey, then OPENAI_API_KEY only when the endpoint is OpenAI's default
|
Config.BaseURL, then https://api.openai.com/v1
|
the environment's key is OpenAI's and is never sent to another server |
| Anthropic |
Config.APIKey, then ANTHROPIC_API_KEY; New fails without one |
Config.BaseURL, then https://api.anthropic.com/
|
the SDK's own environment chain (base URL, auth token, profiles, custom headers) is switched off |
The OpenAI and Anthropic Config and Model print with fmt (%v, %+v, %#v), marshal to JSON, and log through log/slog with the key redacted. A Model holds no copy of the key as a plain string. An Anthropic SDK error holds the outbound request, key header included, so no SDK error leaves the adapter: it is converted to a providers.StatusError built from the status, the body, and the Retry-After header.
The one limit: a Config kept in an unexported field of the consumer's own struct and printed with %v shows the key, because fmt cannot call a method on an unexported field. Keep a Config out of print there, or out of such a field.
A response's StopReason is one of four words when the provider reports one of them, whatever it called it; render exports them as constants (render.StopToolUse, StopEndTurn, StopMaxTokens, StopRefusal):
| Word | Means |
|---|---|
tool_use |
the model asked for a tool |
end_turn |
the model finished normally |
max_tokens |
the reply hit the output cap |
refusal |
the provider declined |
A reason none of the adapters recognize is passed through as the provider wrote it rather than reported as a normal end of turn, because a consumer reading a recorded response should see what the server actually said. One such reason has meaning downstream: model_context_window_exceeded (render.StopContextWindowExceeded), on which render.Decide fails the run.
max_tokens and refusal are the two that change the response. A truncated reply may carry a half-written tool call and a refused one may carry a stale block, so the tool uses are dropped and the usage is kept. Dropping the calls means a partial request can never reach schema validation, let alone the policy; keeping the usage means the attempt is charged as the provider charged it. What the agent does with such a response is render.Decide's business: a refusal fails the run, a truncation becomes a recorded pseudo decision kind and a nudge.
A reply may carry several tool uses even where parallel calls are switched off. Every one is returned; render.Decide executes the first and records the names of the others in the decision's reason.
The adapters classify failures exactly as the accounting caller expects, and providers holds the shared implementation:
- a
TransientErroris retried with backoff, if the consumer configured retries, each attempt its own row. Retryable statuses are 408, 409, 425, 429, and every 5xx (which includes Anthropic's 529 overloaded), and a refused or reset connection. All three adapters readRetry-Afterfrom those responses, in seconds or as an HTTP date, intoTransientError.RetryAfter(andStatusError.RetryAfter); a negative value or one over an hour is treated as absent. See Limits; - a plain error is permanent and ends the run. That includes a failure no retry can cure, even when it happens in transport: the caller's own cancellation, a host that does not resolve, a certificate that does not verify, a malformed URL, a body over 16 MiB, a connection refused for not being loopback, and any other non-2xx;
- a timeout, or a body cut off mid-read, returned bare, is neither. The caller records the attempt as ambiguous and charges it at the conservative estimate, because a request that timed out after dispatch, or whose answer was cut off, may have been received, served, and billed. Wrapping it as transient would retry work that may already have happened and understate the bill; reporting it as permanent would end a run over a slow connection. Returning it bare hands the ambiguity to the one component that accounts for it. Every adapter does this, the Anthropic path included, which used to retry a cut-off body for free;
- a
ServedErrorcarries the usage of a reply the provider served but the adapter could not use: one that does not decode, carries an error field or no choice, or has a tool call whose arguments are not a JSON object. It is charged and not retried.
Each adapter reads every token count with providers.UsageCount: absent or null is zero, and anything but a non-negative JSON integer that fits an int is refused. The assembled usage then passes providers.CheckUsage, which refuses a negative count and more cached input than input. A refused count makes the reply a ServedError with zero usage wrapping ErrUnusableUsage, and the accounting caller charges its own conservative estimate instead of a number it cannot believe. See Models and Replay.
No retries. The accounting caller owns them, so every attempt gets a model_calls row and counts against MaxModelCalls; an adapter that retried internally would hide attempts from the audit log and from the limits. The Anthropic adapter switches the SDK's own retries off with option.WithMaxRetries(0) for exactly this reason. No streaming, because the runtime decides one step at a time from a complete response.
Every response carries the provider's bytes in Raw, which is what makes recording faithful and what Casework feeds back into a later request. See Models and Replay for why those bytes are stored as a JSON string.
The free provider, and the one to develop against: nothing reaches beyond the configured host, and a model it serves costs nothing.
m, err := ollama.New(ollama.Config{Model: "qwen3:30b-a3b", Think: true, NumCtx: 65536})
prices := ollama.Free(m)| Field | Behaviour |
|---|---|
Host |
the server, with or without an http or https scheme. Empty means OLLAMA_HOST, then 127.0.0.1:11434. http:// is prefixed when no scheme is given and a trailing slash is trimmed; any other scheme, user information, a query, or a fragment is refused by New. The same resolution is exported as ollama.Host(string), because a consumer that probes the server before a run must get the answer the adapter will use; it returns the empty string for a host New would refuse. |
Think |
leaves the model's thinking mode on. Off by default, because tool-calling models are steadier without it; but see examples/live, which turns it on so the server routes the model's narration to its own channel instead of leaving a tool call written out as prose. |
NumCtx |
the requested context window, default 32768 (ollama.DefaultNumCtx). Ollama's own default is far smaller than a tool-using agent's rendered context, and a server that silently truncates the prompt is worse than one that refuses it. |
Requests set temperature to 0, num_ctx from the config, and num_predict from the request's output cap. The system prompt becomes a system message; a tool result becomes a role: "tool" message carrying tool_name. A tool call that arrives without an id gets one from providers.SynthesizeToolUseID, and empty arguments become {}; arguments that are not a JSON object make the reply a ServedError.
Ollama reports done_reason. length becomes max_tokens with the tool uses dropped; a response carrying tool calls is tool_use; stop and an empty reason become end_turn. A 200 response with a non-empty error field is the server refusing the request (a context length it cannot serve, for instance) and is returned as a ServedError rather than an empty turn. Usage is prompt_eval_count and eval_count. Ollama's chat API has no switch for parallel tool calls.
One adapter for the shape OpenAI documents and most local servers implement.
m, err := openai.New(openai.Config{Model: "qwen3:30b-a3b", BaseURL: "http://127.0.0.1:11434/v1"})
prices := openai.Free(m)BaseURL defaults to https://api.openai.com/v1 and has /chat/completions appended. APIKey falls back to OPENAI_API_KEY only when the base URL is the default; a configured BaseURL gets no key from the environment. An empty key is allowed, since a local server needs none, and no Authorization header is sent when there is none to send. A key over http:// goes only to loopback, as above.
Requests carry temperature 0, max_tokens from the output cap, function definitions built from each tool's input schema, and, when there are tools, tool_choice: "auto" with parallel_tool_calls: false. Parallel calls are refused up front rather than discarded afterwards, because the runtime executes one call per step; a server may ignore the switch. An assistant turn carries tool_calls; a tool result is its own role: "tool" message keyed by tool_call_id, which must follow the assistant message carrying the call it answers.
Arguments arrive as a JSON-encoded string. The adapter checks that the string is a JSON object before it becomes arguments, so nothing but JSON reaches schema validation and the audit log; anything else makes the reply a ServedError naming the tool and the offending text.
Usage comes from prompt_tokens, completion_tokens, and prompt_tokens_details.cached_tokens. finish_reason maps tool_calls to tool_use, stop to end_turn, length to max_tokens, and content_filter to refusal; some servers answer a tool call with no finish reason at all, which is read as tool_use when calls are present and end_turn when they are not. A 200 body carrying an error object, and a response with no choice, are both ServedErrors.
There is no price table here. The endpoint is whatever the consumer configured, so a paid one is priced by the consumer that chose it; openai.Free(...) records local ones as free.
The paid provider. A consumer runs it with the price table and a cost limit, and nothing in the repository's tests or CI reaches the API.
m, err := anthropic.New(anthropic.Config{Model: "claude-opus-5", Strict: true})
prices := anthropic.Prices()| Field | Behaviour |
|---|---|
APIKey |
empty means ANTHROPIC_API_KEY; no other credential source is read |
BaseURL |
overrides the endpoint, which is how the tests point the adapter at an httptest fake. Empty means anthropic.DefaultBaseURL; ANTHROPIC_BASE_URL is not read |
Effort |
the output_config effort; empty leaves the API default |
MaxTokens |
caps output when a request does not; zero means 4096 (anthropic.DefaultMaxTokens), because the API requires a value |
Strict |
opt in to strict tool use (see below) |
RawAssistantBlock |
the content block type that carries a provider message verbatim; empty means anthropic.raw_assistant
|
New refuses, before any request is made:
- an empty model id;
- a model
anthropic.Prices()does not list underName(model), unlessConfig.Nameis set, in which case the consumer prices that name itself. A paid model with no price would charge nothing against a cost limit; - no key in
Config.APIKeyorANTHROPIC_API_KEY; - a base URL that is not http or https, carries user information, or is plain
http://to a host that is not loopback; - an output cap, from
MaxTokensor the default, too large for the SDK to send without streaming, which it would refuse the same way on every retry.
The SDK's retries are off, so the accounting caller sees and records every attempt. SDK errors are converted to providers.StatusError and classified on the status the SDK reports, carrying Retry-After; an error with no HTTP response is classified as a transport failure.
Parallel tool use is disabled whenever tools are sent, with ToolChoiceAutoParam{DisableParallelToolUse: true}, for the same reason as the OpenAI adapter: one call per step.
StrictSubset is opt-in. Strict tool use rejects a set of JSON Schema keywords with a 400 (numeric bounds, string length bounds, maxItems, pattern, uniqueItems), so Strict sends a reduced copy of each schema: those keywords removed, a minItems above 1 removed, additionalProperties: false on every object, and strict: true on the tool. The reduction walks the schema's structure: keys under properties and $defs are treated as names, so a property called pattern or maximum is kept, and values such as enum, const, and default are copied untouched. Top-level keywords are carried so a $ref still resolves. A schema strict mode cannot express at all, such as oneOf, a recursive $ref, or a schema-valued additionalProperties, fails the request rather than being silently narrowed. No constraint is lost, because the runtime keeps validating the model's arguments against the full schema; the reduction only affects what the provider is told. StrictSubset is exported so a consumer that shows an operator the schema it sent applies the same transform. Without Strict, the whole schema goes through, with the fields the SDK does not model passed as extra fields. Output for casework's twelve schemas was unchanged by the v0.3 rewrite.
The raw assistant round trip. An assistant message consisting of exactly one block of type RawAssistantBlock, whose text is a provider message as JSON, is unmarshalled back into an SDK Message and replayed with ToParam(). That is how a signed thinking block survives a continuation: the signature covers bytes the adapter must not rebuild, and a synthesized turn would break it. Model.RawAssistantBlock() reports the configured type, because a consumer's renderer needs it to build such a turn; Casework's renderer is the worked example. A synthesized turn instead sends an empty tool input as {}, drops empty text blocks (the API rejects one, which a renderer's nudge can produce when the model returned nothing at all), and substitutes (no output) rather than send a message with no blocks.
Usage folding. agentrt.Usage has three counters and the provider reports more numbers than that, so:
InputTokens = input + cache reads + ceil(five-minute cache writes × 1.25) + one-hour cache writes × 2
CachedInputTokens = cache reads
OutputTokens = output
Cache reads are reported separately and priced at the cached rate. Cache writes have no counter of their own, so they are folded into input at the multiple each class is billed at: 1.25 for a five-minute write, rounded up, and 2 for a one-hour write. A write the response does not break down by duration is charged at 1.25. The accounting therefore never understates a bill, which is what a cost limit needs. A folded count too large to hold is refused as unusable usage rather than wrapped.
The price table is anthropic.Prices(): per-million-token prices in USD micros, read from the Claude API reference on 2026-09-29 and keyed by the default anthropic:<model> name. Cache reads are a tenth of input, except on Claude Fable 5.1 and Claude Mythos 5.1, where they are a fortieth, and Claude Opus 5.5, where they are a twentieth. It is a dated snapshot, not a live lookup: a price that moved is a code change with a date on it, which is auditable, rather than a number that quietly differs between two runs of the same recording. A model absent from the table has no price, which providers.PriceFor treats as a refusal rather than as free, and which New refuses unless Config.Name is set.
In the core module, standard library only. An adapter for a provider none of the three cover is mostly these calls.
| Helper | What it does |
|---|---|
Client(c) |
the adapter's client: a copy of c or a new one with DefaultTimeout, bodies capped at MaxBodyBytes, no redirects unless c has a CheckRedirect, never http.DefaultClient
|
RefuseRedirects |
the CheckRedirect that follows nothing |
LimitResponses(rt) |
wraps a transport so no body over MaxBodyBytes can be read, for a body read by code the adapter does not own, such as an SDK |
CheckEndpoint(provider, base, key) |
parses a base URL; refuses a non-http(s) URL, one without a host, one with user information, and a key over plain http to a host Loopback does not accept |
Loopback(host) |
exactly localhost or a literal loopback IP |
LoopbackOnly(c) |
a copy of c whose connections are refused, with ErrNotLoopback, unless the dialled address is loopback |
ClassifyTransport(err) |
a timeout, a cut-off body, and the caller's cancellation are returned bare; an unresolvable host, a certificate failure, a malformed URL, an oversized body, and a non-loopback refusal are permanent; any other transport error becomes a TransientError
|
ClassifyResponse(provider, resp, body) |
nil for 2xx; a 3xx as a permanent StatusError naming the redirect's host; otherwise a StatusError with a bounded body excerpt and RetryAfter, wrapped as transient when the status is retryable |
ClassifyStatus(provider, status, body) |
the same judgement without headers |
ClassifyAPIError(status, err) |
the same for an SDK that yields only a status code; status 0 means no HTTP response, so it is a transport failure |
Transient(status) |
408, 409, 425, 429, and every 5xx |
RetryAfter(header) |
a Retry-After in seconds or as a date; zero when absent, negative, or over an hour |
ReadBody(r) |
reads at most MaxBodyBytes (16 MiB) and refuses more with ErrBodyTooLarge, so a provider answering with an unbounded stream cannot exhaust the process and an oversized reply is never mistaken for a malformed one |
DrainClose(body) |
discards a bounded remainder and closes, so the connection is reused |
UsageCount(field, raw), CheckUsage(u)
|
the usage checks above; both wrap ErrUnusableUsage
|
SynthesizeToolUseID(i) |
names the ith tool use for a provider that returns no id; ids only have to be unique within a request |
Truncate(s, n) |
cuts provider or model text an error message repeats |
PriceFor(prices, name) |
the price recorded for a name, or an error wrapping ErrNoPrice
|
Free(names...) |
zero-price entries for local models |
Merge(tables...) |
one table from several; later tables win |
ParseDollars(s) |
5, 4.41, or $0.50 into micros, rounded to the nearest micro; negatives, NaN, and infinities refused |
PriceFor returning an error for an unknown name is the point of the package. agentrt.PriceTable charges nothing for a name it does not hold, which is correct for a local model and silently defeats a cost limit for a paid one. So local models are recorded with Free, a paid model runs only with a known price, and "free" and "unpriced" stay distinguishable. A consumer checks it once at startup:
local, _ := ollama.New(ollama.Config{Model: "qwen3:30b-a3b"})
prices := providers.Merge(ollama.Free(local), anthropic.Prices())
if _, err := providers.PriceFor(prices, model.Name()); err != nil {
return err // a paid model with no price never gets to run
}StatusError carries the provider, the status, a body excerpt trimmed to 2048 bytes, and RetryAfter as fields, so a caller acts on it without matching strings, and never the request, so no credential rides along with it.
No test in this repository reaches a provider. The Anthropic adapter runs against an httptest fake, which is what Config.BaseURL is for (on 127.0.0.1, which is loopback); each local adapter has one round trip behind the live build tag, which skips unless a server answers and is excluded from CI. In a consumer's own tests the adapter is not the thing to mock: record once with replay.Recorder and run the suite against replay.Replayer with the provider address pointed at a closed port.
./... in the root module does not reach a nested module, and each nested module pins a released core, so working on the adapters needs a workspace, which is also what CI builds:
go work init . ./providers/ollama ./providers/anthropic ./providers/openai ./mcp ./bench ./export/otel ./proxy ./examples/live ./examples/mcp
for m in providers/ollama providers/anthropic providers/openai mcp examples/live examples/mcp; do
(cd "$m" && go vet ./... && go test -race -count=1 ./...)
doneThe workspace makes each nested module resolve the in-tree core rather than its pinned version, which is the only way a change to the core and a change to an adapter are testable together. go.work is not committed. Because the workspace hides the pin, the release check builds each nested module without it before a tag; see Releasing.
Rendering Model Context is the other half of a model-driven agent. Models and Replay covers the interface these adapters implement, the accounting caller that wraps them, and recording. Writing an Agent shows the whole shape.