Provider Adapters - joeylking/agent-runtime GitHub Wiki

Provider Adapters

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]

Three nested modules

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.

The shared contract

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.

Naming

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 included

The HTTP client: no redirects, bounded bodies

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

Keys: plain http only to loopback

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.

Where each adapter reads its configuration

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

Keys stay out of output

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.

Stop reasons

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.

Failure classification

The adapters classify failures exactly as the accounting caller expects, and providers holds the shared implementation:

  • a TransientError is 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 read Retry-After from those responses, in seconds or as an HTTP date, into TransientError.RetryAfter (and StatusError.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 ServedError carries 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.

Usage checks

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.

What an adapter does not do

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.

Ollama

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.

OpenAI-compatible endpoints

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.

Anthropic

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 under Name(model), unless Config.Name is 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.APIKey or ANTHROPIC_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 MaxTokens or 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.

The providers helper package

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.

Testing

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.

Developing inside the repository

./... 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 ./...)
done

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

Next

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.

⚠️ **GitHub.com Fallback** ⚠️