v0.1.0GitHubnpm
Concepts

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.

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

ClassidWire APIReasoningExplicit cache control
AnthropicProvideranthropicAnthropic Messagesthinking, effortYes — cache_control breakpoints
OpenAIResponsesProvideropenai-responsesOpenAI Responsesreasoning.effort, reasoning.summaryNo (automatic prefix cache)
OpenAIChatCompletionsProvideropenai-chatOpenAI Chat CompletionsNone on the wireNo (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

OptionTypeDefaultNotes
apiKeystringANTHROPIC_API_KEYPassed through to the Anthropic SDK; its own env lookup applies if omitted.
baseURLstringAnthropic defaultFor proxies or gateways that speak the Anthropic Messages protocol.
defaultHeadersRecord<string, string>—Merged into every request. Useful for routing or auth headers.
thinkingThinkingConfig—Provider-wide extended-thinking default. Per-run RunOptions.thinking wins.
effortEffortLevel—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.

OptionTypeDefaultNotes
apiKeystringOPENAI_API_KEYPassed to the OpenAI SDK.
baseURLstringOpenAI defaultFew OpenAI-compatible endpoints implement the Responses API; use OpenAIChatCompletionsProvider for those.
defaultHeadersRecord<string, string>—Merged into every request.
reasoningOpenAIResponsesReasoningOption—Either a bare effort string ("low" … "xhigh", plus "none" and "minimal") or an object with effort, summary, previousResponseId, encryptedContent.
storebooleanAPI 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

OptionTypeDefaultNotes
apiKeystringOPENAI_API_KEYPassed to the OpenAI SDK.
baseURLstringOpenAI defaultOverride to target any OpenAI-compatible endpoint — Ollama, vLLM, Groq, Together, LM Studio, DeepSeek, OpenRouter.
defaultHeadersRecord<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.

providers.ts
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.

AxisResponsesChat Completions
ReasoningFirst-class. Streams reasoning summary deltas as thinking_delta events.No reasoning surface in the wire format.
Tool shapeFunction 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 promptSingle top-level instructions string.First message with role: "system".
Resume across turnsStores response id in provider_metadata and sends previous_response_id by default.Always full-history replay on every turn.
Tool result with imageImage 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.

KnobSourceDefault
maxRetriesAgentOptions.maxRetries → request max_retries5 (so up to 6 total attempts)
baseDelayMsRetryOptions on withRetry1000
maxDelayMsRetryOptions30000
jitterRetryOptions (±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 / shapeMapped toRetryable
401, 403AuthErrorNo
429RateLimitError (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 lengthContextLengthErrorNo (engine triggers compaction)
Other 4xxProviderError (retryable: false)No
5xxProviderError (retryable: true)Yes
Network / no status / err.name === "AbortError" (without a user abort)ProviderError (retryable: true) / AbortErrorYes / —
User AbortSignalAbortErrorNever (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.

MemberSignatureNotes
idreadonly stringStable id used in logs and config.
contextWindow(model: ModelId) => numberCompaction 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.

my-provider.ts
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 the ToolRegistry, applies permission rules, and feeds the tool_result back 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_assistant event are also outside the provider surface.