Custom provider
Implement the LLMProvider interface to wrap any LLM API. Two methods (complete, stream) — that's the whole contract.
Your team uses an internal model serving cluster, or a provider not yet shipped as a built-in adapter. You don't need agentfootprint to support it explicitly — you just implement two methods. The library treats your custom provider identically to
anthropic()oropenai().
The contract
import type { LLMProvider, LLMRequest, LLMResponse, LLMChunk, LLMCallHooks } from 'agentfootprint';
interface LLMProvider {
readonly name: string;
/** Which roles your wire carries INSIDE the messages array. Optional. */
readonly carriesInMessages?: readonly ('system' | 'user' | 'assistant')[];
complete(req: LLMRequest, hooks?: LLMCallHooks): Promise<LLMResponse>;
stream?(req: LLMRequest, hooks?: LLMCallHooks): AsyncIterable<LLMChunk>;
}That's the whole interface. complete is required; stream and carriesInMessages are
optional (the library falls back to complete if stream is absent).
Wrappers must preserve the request witness
Pass the optional hooks argument to the inner provider in both complete
and stream. Forward the whole object, or spread it with { ...hooks } when
adding a callback. Rebuilding only its named properties loses the library's
private symbol channel. Do not depend on that channel's name or invoke it yourself.
With findings enabled, admission uses the request that produced the accepted
answer. A leaf that sends its input unchanged needs no extra preparation report.
But a wrapper that trims history, rewrites results or otherwise changes the
request must report the actual changed request before delegating through
hooks?.onReplayPrepared?.({ provider: name, model: actual.model, request: actual, omissions: [] }), then forward all hooks to the inner provider. Empty omissions
means no thinking-retirement claim; never invent omission metadata. If the inner
provider prepares the request again, its report must reach the caller too.
Without that report the caller can only inspect the pre-transform request. It could admit a citation to a result your wrapper removed, which the model never saw. Hook forwarding alone does not describe a wrapper's own transformation. Reports belong to the live attempt and must precede transport; do not retain or reuse callbacks after that attempt settles. The built-in retry and fallback decorators implement these boundaries.
Copy the Node-only provider-wrapper conformance helper
into your tests and call await assertProviderRequestForwarding(inner => myWrapper(inner)).
It is example source, not an npm export. The factory must use only the supplied
scripted leaf, without keys, environment files or a live transport. It checks an
unchanged or reported transformed request before transport, symbol-hook and replay
forwarding, and the wrapper's separate complete/stream paths. Its returned list
names only the methods exercised; a complete-only wrapper does not pass a stream
test. Disable cache shortcuts for this fixture so the supplied leaf is reached.
This is one synthetic request, not a proof for every transformation, retry, fallback, cancellation or concurrency path. Add your own fixtures for those branches; an identity check never proves the cited assertion true.
carriesInMessages — declare what your wire can carry
Wires disagree about one thing in a way nothing else can detect: whether a
role: 'system' message inside the messages array survives. The Anthropic-family
adapters drop it (system rides a separate top-level field); the OpenAI-family adapters
carry it. A slot: 'messages' injection with that role would therefore arrive on one
provider and vanish on another, and nothing in the recording would tell the two apart.
So each adapter states what it carries, and the engine refuses at run start what a provider cannot deliver — naming your provider and its roles — rather than recording a message as injected and dropping it.
The element type is exported as WireRole ('system' | 'user' | 'assistant') if you
want to name it in your own signatures.
If you omit the field, you are treated as carrying DEFAULT_CARRIES_IN_MESSAGES —
['user', 'assistant'], the floor every known wire supports, exported so you can assert
against it in your own tests. That is deliberately the conservative reading: a missing
declaration is not permission. Declare the field if your wire carries more:
class MyCustomProvider implements LLMProvider {
readonly name = 'my-custom';
// My API takes system messages inline, like OpenAI's does.
readonly carriesInMessages = ['system', 'user', 'assistant'] as const;
// …
}'tool' is not a member of the list and never will be: a tool message answers a
specific tool call, so it is never something an injection can author.
If you write a wrapper, forward the field. A wrapper that rebuilds the provider
object and drops it silently narrows everything underneath to the floor — a wrapped
OpenAI provider would start refusing system-role delivery it can perfectly well do.
The three shipped decorators forward it; withFallback publishes the intersection of
its two providers, because a role only one of them carries is a role the call might drop.
A minimal custom provider
class MyCustomProvider implements LLMProvider {
readonly name = 'my-custom';
private apiKey: string;
constructor(opts: { apiKey: string }) {
this.apiKey = opts.apiKey;
}
async complete(req: LLMRequest): Promise<LLMResponse> {
const res = await fetch('https://my-llm-api.example.com/v1/chat', {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.apiKey}` },
body: JSON.stringify(toMyApiFormat(req)),
});
const json = await res.json();
return fromMyApiFormat(json);
}
}The two converters (toMyApiFormat, fromMyApiFormat) are the only API-specific code. The agent loop, recorders, memory, skills — all work unchanged.
Stream implementation
For UI-streaming support:
async *stream(req: LLMRequest): AsyncIterable<LLMChunk> {
const res = await fetch('https://my-llm-api.example.com/v1/stream', { ... });
const reader = res.body!.getReader();
let tokenIndex = 0;
let accumulated = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = new TextDecoder().decode(value);
accumulated += text;
yield { tokenIndex: tokenIndex++, content: text, done: false };
}
// Final chunk MUST carry the authoritative LLMResponse
yield {
tokenIndex,
content: '',
done: true,
response: {
content: accumulated,
toolCalls: [], // populate from API's terminal payload
usage: { input: 0, output: 0 },
stopReason: 'stop',
},
};
}The agent reads response off the final chunk to drive ReAct decisioning. UI consumers read intermediate content chunks for token streaming. One round-trip, two consumers.
AbortSignal support
The agent puts the run's signal on every request as req.signal. It fires when the caller aborts the run or the run's timeoutMs passes. Hand it to your transport, so a cancelled run cancels the HTTP request itself and stops paying for tokens nobody will read:
async complete(req: LLMRequest): Promise<LLMResponse> {
const res = await fetch(url, {
signal: req.signal,
// ...
});
// ...
}An SDK usually takes it as a per-request option (client.create(params, { signal: req.signal })). In stream(), close the SDK stream or cancel the response body when the signal fires. If your provider ignores the signal, the agent still stops waiting for it and never records an answer that arrives after the abort. The remote call keeps running until it finishes, though, and you are billed for it. When the call fails because of the abort, throw req.signal.reason. The decorators (withRetry, withFallback, withCircuitBreaker) never retry, fall back from or count a cancelled call, whatever the error is called.
The library's resilience decorators (withRetry, withFallback) wrap your custom provider transparently — no special integration needed.
Wrapping with resilience
Drop into the standard composition:
import { withRetry } from 'agentfootprint/resilience';
const provider = withRetry(new MyCustomProvider({ apiKey: '...' }), {
maxAttempts: 5,
});withRetry classifies retryability by HTTP status: it reads err.status (or err.statusCode) off the thrown error, skips AbortError and 4xx client errors — except 429 Too Many Requests, which IS retried — and retries 5xx, network errors, and unknown shapes — unless the error declares retryable: false, which it never retries. So if your provider throws errors that should be retryable, surface the upstream HTTP status on the error:
async complete(req: LLMRequest): Promise<LLMResponse> {
const res = await fetch(url, { signal: req.signal /* ... */ });
if (!res.ok) {
const err = new Error(`${res.status} from upstream`) as Error & { status: number };
err.status = res.status; // withRetry reads this to decide retryability
throw err;
}
// ...
}Need a custom rule (e.g. retry a specific provider-defined code)? Pass shouldRetry:
withRetry(new MyCustomProvider({ apiKey: '...' }), {
shouldRetry: (err, attempt) => (err as { status?: number }).status === 503,
});What to normalize — and where the built-in providers still differ
Your fromMyApiFormat converter has one job beyond copying text: map your vendor's
vocabulary onto the shared one, the same way the built-in adapters do. The agent loop
reads only the normalized fields, so code that works on anthropic() works on yours.
| Field | The shared vocabulary | How the built-in adapters get there |
|---|---|---|
stopReason | 'stop', 'tool_use', 'max_tokens', 'content_filter' | Anthropic end_turn → 'stop'; OpenAI tool_calls → 'tool_use' and length → 'max_tokens'; Bedrock guardrail_intervened → 'content_filter'; Gemini SAFETY → 'content_filter'. A reason with no match is passed through unchanged, so a vendor-specific reason reaches you instead of being guessed at |
usage | { input, output, cacheRead?, cacheWrite?, thinking? } | The optional fields appear only when the vendor reports them — leave a field out rather than filling it with 0 |
toolCalls | { id, name, args }[], possibly several per reply | The agent runs every call in the reply within one iteration. On Anthropic, parallelToolCalls: false caps it at one (see Anthropic) |
A few differences are not normalized away, because they are real:
- Extended thinking.
Agent.create(...).thinking({ budget })putsthinking: { budget }on every request — the intent, not a wire shape. Anthropic sends the shape each model takes (a budget, or adaptive thinking with none — see Anthropic), Gemini sends its thinking budget, Ollama turns thinking on; OpenAI ignores it, because its reasoning is picked by the model id. If your API has no such knob, ignorereq.thinking. If your models disagree, declarethinkingMode(model)('budget','adaptive'or'none') so the agent refuses a model that cannot think at build — and forward it from any wrapper you write. - Forced tool choice. Keep
carriesForcedToolChoiceboolean. If only some models support it, addsupportsForcedToolChoice: (model) => boolean; absence preserves the wire flag's behavior. CallforcedToolChoiceFor(provider, model)fromagentfootprint/providersto read both declarations. Resolve your adapter's shorthand inside the predicate and forward it unbound from wrappers. A fallback pair requires both sides to support the requested model. Enforce the same restriction in direct provider calls, before sending, withUnsupportedToolChoiceError. - System messages inside
messages. Some wires carry them, some drop them — that is whatcarriesInMessages(above) declares. - Model ids. They are the vendor's. A Bedrock id is not an Anthropic API id, and on Azure OpenAI or Foundry the
modelis your deployment name. Switching providers usually means switching themodelstring too.
If your application code starts branching on which provider is attached, stop and write against the normalized fields instead.
Picking a built-in provider from config — createProvider()
When the provider is chosen at runtime — an env var, a feature flag, a tenant setting —
createProvider builds any built-in provider from one tagged object. kind picks the
adapter; the rest of the object is that factory's own options:
import { createProvider, type CreateProviderOptions } from 'agentfootprint/providers';
const provider = createProvider({
kind: process.env.LLM_PROVIDER ?? 'mock', // 'anthropic', 'openai', 'bedrock', …
apiKey: process.env.LLM_API_KEY,
defaultModel: process.env.LLM_MODEL,
} as CreateProviderOptions);The kinds are mock, anthropic, openai, ollama, foundry, foundry-local,
bedrock, gemini, browser-anthropic, browser-openai and invoke-model-gateway
(your gateway) (the type is exported as
ProviderKind). An unknown kind throws, naming it.
Gotchas:
- Custom providers don't go through it — pass your instance to
Agent.create({ provider })directly. - No Azure kind. Call
azureOpenai()(orbrowserAzureOpenai()) directly. - The
as CreateProviderOptionscast is the price of a kind read from a string: TypeScript can't check that the options match a kind it only learns at runtime. With a literalkind: 'anthropic'you get full checking and need no cast. - Credentials already in the environment?
providerFromEnv()(main barrel) reads them and picks the provider itself — see Ollama and Microsoft Foundry for how it decides.
Anti-patterns
- Don't bake retry logic into
complete. LetwithRetryhandle it; your provider should be a thin SDK wrapper. - Don't swallow the upstream HTTP status. Surface it on the thrown error (
err.status/err.statusCode) sowithRetry's default classification works (4xx terminal vs 5xx/429 retryable). - Don't omit usage from the response — recorders depend on it for cost tracking.
Next steps
- Anthropic — reference adapter implementation
- OpenAI — reference adapter implementation
- Resilience — your custom provider gets retry + fallback for free
Ollama
ollama() — run a real model on your laptop. Free, no API key, no vendor SDK. The middle rung between mock() and a paid API, with the same agent code on all three.
Prompt caching
Stable context — steering, facts, an active skill — is cached for you on providers that support it, per a policy you can override on each injection. On by default; one flag turns it off.
