Providers
A provider is a thin translator between the engine’s normalized ProviderRequest and a model API’s wire format. The SDK ships three implementations; all of them extend the same BaseProvider and stream ProviderStreamEvents the loop can assemble into messages.
import { AnthropicProvider } from "@skawld/agent-sdk/providers"; const provider = new AnthropicProvider({ // optional — falls back to ANTHROPIC_API_KEY apiKey: process.env.ANTHROPIC_API_KEY, });
#Built-ins
All three are exported from @skawld/agent-sdk/providers along with their option types.
| Class | id | Wire API | Reasoning | Explicit cache control |
|---|---|---|---|---|
AnthropicProvider | anthropic | Anthropic Messages | thinking, effort | Yes — cache_control breakpoints |
OpenAIResponsesProvider | openai-responses | OpenAI Responses | reasoning.effort, reasoning.summary | No (automatic prefix cache) |
OpenAIChatCompletionsProvider | openai-chat | OpenAI Chat Completions | None on the wire | No (automatic prefix cache) |
The engine always streams: every provider implements only stream(req): AsyncIterable<ProviderStreamEvent> plus a contextWindow(model) lookup used by compaction. Non-streaming responses don’t exist at the provider layer.
#AnthropicProvider options
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | ANTHROPIC_API_KEY | Passed through to the Anthropic SDK; its own env lookup applies if omitted. |
baseURL | string | Anthropic default | For proxies or gateways that speak the Anthropic Messages protocol. |
defaultHeaders | Record<string, string> | — | Merged into every request. Useful for routing or auth headers. |
thinking | ThinkingConfig | — | Provider-wide extended-thinking default. Per-run RunOptions.thinking wins. |
effort | EffortLevel | — | Maps to output_config.effort. Values: "low", "medium", "high", "xhigh", "max". |
When no max_output_tokens is set on the request, the Anthropic provider falls back to 32768 because the Messages API requires max_tokens. Set AgentOptions.maxOutputTokens (or per-run RunOptions.maxOutputTokens) to raise that cap. Known context windows: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5 all 200k; unknown models default to 200k.
Anthropic is the only built-in that consumes cache_ttl,
thinking, and effort from the request. The
OpenAI providers silently ignore those fields — they cache automatically
and have their own reasoning surface.
#OpenAIResponsesProvider options
Extends OpenAIChatProviderOptions, so it accepts the same transport options plus reasoning controls.
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | OPENAI_API_KEY | Passed to the OpenAI SDK. |
baseURL | string | OpenAI default | Few OpenAI-compatible endpoints implement the Responses API; use OpenAIChatCompletionsProvider for those. |
defaultHeaders | Record<string, string> | — | Merged into every request. |
reasoning | OpenAIResponsesReasoningOption | — | Either a bare effort string ("low" … "xhigh", plus "none" and "minimal") or an object with effort, summary, previousResponseId, encryptedContent. |
store | boolean | API default (true) | Set false for stateless replay. When false, the provider stops resuming via previous_response_id and includes encrypted reasoning automatically if reasoning is configured. |
When store is left at its default and the conversation already carries an OpenAI response id (via message.provider_metadata.openai_responses.response_id), the provider sends only the new turn and references the prior response with previous_response_id. Set reasoning: { previousResponseId: "disabled" } to force full-history replay even when ids are available.
#OpenAIChatCompletionsProvider options
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | OPENAI_API_KEY | Passed to the OpenAI SDK. |
baseURL | string | OpenAI default | Override to target any OpenAI-compatible endpoint — Ollama, vLLM, Groq, Together, LM Studio, DeepSeek, OpenRouter. |
defaultHeaders | Record<string, string> | — | Used to send Authorization headers, tenant ids, etc. |
contextWindowOverride | (model: ModelId) => number | undefined | — | Tells compaction the real window for non-OpenAI models. Returning undefined falls back to the built-in table, then to 128_000. |
Built-in context windows known to both OpenAI providers: gpt-5 400k, gpt-4.1 1M, gpt-4o 128k, o1 200k. Unknown models default to 128k.
#OpenAI-compatible endpoints
Any server that implements the Chat Completions wire format can be driven by OpenAIChatCompletionsProvider. The provider sends a streaming request with stream_options.include_usage set so the server returns prompt/completion token counts; if your target ignores that hint, usage will simply be zero and compaction will treat the window as untouched.
import { OpenAIChatCompletionsProvider } from "@skawld/agent-sdk/providers"; // Local Ollama. Ollama exposes /v1 on port 11434 by default. const ollama = new OpenAIChatCompletionsProvider({ apiKey: "ollama", baseURL: "http://localhost:11434/v1", contextWindowOverride: (model) => model.startsWith("llama3.1") ? 128_000 : undefined, }); // Groq. Standard OPENAI_API_KEY env var is not used here. const groq = new OpenAIChatCompletionsProvider({ apiKey: process.env.GROQ_API_KEY, baseURL: "https://api.groq.com/openai/v1", contextWindowOverride: () => 131_072, });
Two practical points. First, set contextWindowOverride — the compaction pass needs a real number to decide when to summarize, and the default 128k will be wrong for most non-OpenAI models. Second, tool-using models served on OpenAI-compatible endpoints vary: some emit tool calls with a stable id immediately, others stream id and name piecemeal. The provider buffers either shape, but if a backend never produces an id the stream will silently drop tool calls.
#Responses vs Chat Completions
Both providers target OpenAI models. They differ in what the wire protocol can carry and how the SDK reconstructs state across turns.
| Axis | Responses | Chat Completions |
|---|---|---|
| Reasoning | First-class. Streams reasoning summary deltas as thinking_delta events. | No reasoning surface in the wire format. |
| Tool shape | Function tools live at the top level. Call ids come back as call_id, normalized to id. | Nested under function.{name, arguments} on assistant messages. |
| System prompt | Single top-level instructions string. | First message with role: "system". |
| Resume across turns | Stores response id in provider_metadata and sends previous_response_id by default. | Always full-history replay on every turn. |
| Tool result with image | Image rides inline in function_call_output.output. | Tool message holds a text stub; image is attached in a follow-up user message. |
Output token cap (max_output_tokens unset) | Omitted; API default applies. | Omitted; API default applies. |
Pick Responses for reasoning models (o1, gpt-5) — it’s the only OpenAI surface that streams reasoning summaries and lets the model carry hidden state via previous_response_id. Pick Chat Completions when you need OpenAI-compatible third-party servers (Ollama, Groq, vLLM) or when you specifically want stateless full-history replay every turn.
#Retry behaviour
Every provider wraps its wire stream in withRetryableStream. Retries only happen before any event has been yielded to the engine — once a delta lands, the assembly is committed and a mid-stream failure propagates immediately. The message_start sentinel is deliberately excluded from commit so a connection that dies before any real content can still be retried.
| Knob | Source | Default |
|---|---|---|
maxRetries | AgentOptions.maxRetries → request max_retries | 5 (so up to 6 total attempts) |
baseDelayMs | RetryOptions on withRetry | 1000 |
maxDelayMs | RetryOptions | 30000 |
jitter | RetryOptions (±fraction of delay) | 0.2 |
Backoff is min(base · 2^attempt, max) ± jitter. When the upstream sends a Retry-After header, the rate-limit error carries retry_after_seconds and that value is used verbatim (capped to maxDelayMs) instead of the exponential schedule.
#Error classification
| HTTP / shape | Mapped to | Retryable |
|---|---|---|
| 401, 403 | AuthError | No |
| 429 | RateLimitError (with retry_after_seconds) | Yes |
400 matching context_length_exceeded, maximum context, too long, max_tokens, prompt is too long, too many tokens, reduce the length | ContextLengthError | No (engine triggers compaction) |
| Other 4xx | ProviderError (retryable: false) | No |
| 5xx | ProviderError (retryable: true) | Yes |
Network / no status / err.name === "AbortError" (without a user abort) | ProviderError (retryable: true) / AbortError | Yes / — |
User AbortSignal | AbortError | Never (short-circuits backoff) |
ContextLengthError is not retryable at the provider layer
— retrying the same payload would just fail the same way. The engine
catches it one level up and runs the compaction pass before the next
attempt.
#Custom providers
Subclass BaseProvider to drive any backend — Bedrock, Vertex, a private gateway, a local llama.cpp server with a non-standard wire format. The contract is small.
| Member | Signature | Notes |
|---|---|---|
id | readonly string | Stable id used in logs and config. |
contextWindow | (model: ModelId) => number | Compaction consults this every turn. |
stream | (req: ProviderRequest) => AsyncIterable<ProviderStreamEvent> | Yields a single message_start, any number of deltas, then exactly one message_end. |
A valid stream must always yield message_start first and end with one message_end carrying a StopReason and Usage. Between them, emit text_delta and thinking_delta at will. Tool calls are bracketed: tool_use_start with a stable id and name, then any number of tool_use_input_delta chunks of JSON, then tool_use_end with the same id. The engine parses the concatenated JSON when the block closes — partial JSON between deltas is fine.
import { BaseProvider, type ProviderRequest, type ProviderStreamEvent, } from "@skawld/agent-sdk/providers"; import type { ModelId } from "@skawld/agent-sdk"; export class MyProvider extends BaseProvider { readonly id = "my-provider"; contextWindow(_model: ModelId): number { return 128_000; } async *stream(req: ProviderRequest): AsyncIterable<ProviderStreamEvent> { yield { type: "message_start", model: req.model }; // 1. Translate req.system / req.tools / req.messages into your wire shape. // 2. Open a streaming HTTP call, honouring req.signal for cancellation. // 3. For each chunk, yield a normalized event: // // yield { type: "text_delta", text: chunk.text }; // // yield { type: "tool_use_start", id, name }; // yield { type: "tool_use_input_delta", id, json_delta }; // yield { type: "tool_use_end", id }; // // 4. On any retryable failure, throw a ProviderError with retryable:true // BEFORE the first non-message_start event, so withRetryableStream can // try again. After the first delta, failures propagate to the caller. yield { type: "message_end", stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0 }, }; } }
Wrap your inner stream with withRetryableStream from @skawld/agent-sdk/providers if you want the same retry semantics as the built-ins. Throw subclasses of SkawldError (AuthError, RateLimitError, ContextLengthError, ProviderError) so the engine’s typed error handling kicks in — anything else surfaces as an unmapped exception.
#What providers don’t do
A provider only translates messages and tool schemas into wire format, and translates wire events back into ProviderStreamEvents. Everything else is the engine’s job:
- Tool execution lives in the loop, not the provider. The provider emits
tool_use_start/input_delta/end; the loop assembles the call, runs it through theToolRegistry, applies permission rules, and feeds thetool_resultback on the next turn. - Permission prompts, allowlist checks, and the YOLO/acceptEdits modes are all engine concerns. See Tools.
- Session storage, message persistence, and skill loading happen outside the provider. See Sessions.
- Compaction is engine-driven. The provider exposes
contextWindow(model)so compaction knows when to fire; it does not summarize or trim history itself. - Subagent spawning, event emission to consumers, and the
partial_assistantevent are also outside the provider surface.