Skip to content

Adapters

What It Is

Adapters put provider-specific LLM transports behind the shared LlmAdapterInterface. The same DAG node can call Anthropic, Gemini, Ollama, or WebLLM without depending on a provider SDK.

Use this page when choosing an adapter, implementing a provider package, streaming token chunks, routing concurrent model output, or composing a cascade across multiple backends.

How It Works

chat() is the buffered call. chatStream() emits chunks into a sink and still resolves to the same final response shape. Registries select adapters by descriptor and capability; cascades try multiple registered adapters in order.

Provider packages extend BaseAdapter, implement performChat, and optionally override performChatStream for real per-token streaming. Pattern nodes and agent loops depend on the contract, not on provider packages.

Diagrams, Examples, and Outputs

Adapters show up in the ReAct and LLM examples rather than as standalone DAG shapes. These pages connect the contract to runnable behavior:

What It Lets You Do

The adapters reference lets applications pick, implement, or compose LLM provider backends behind the shared LlmAdapterInterface.

@studnicky/dagonizer/adapter

An adapter is a port/adapter plugin that wires one LLM provider's transport (HTTP, an in-browser API, a local engine) behind the single LlmAdapterInterface contract. The engine, the agent pattern-tier nodes (CallModelNode and friends), and the registry/cascade selection machinery all depend only on this interface — never on a provider SDK. Provider packages (@studnicky/dagonizer-adapter-anthropic, -gemini-api, -gemini-nano, -ollama, -web-llm) each extend BaseAdapter, implement one abstract method (performChat), and optionally override one more (performChatStream) to unlock real per-token streaming.

Code Samples

The code below covers adapter base classes, registries, cascades, streaming chunks, routed sinks, SSE parsing, provider capabilities, and export paths.

Import

ts
import {
  
BaseAdapter
,
LlmAdapterCascade
,
LlmAdapterRegistry
,
RoutingStreamSink
,
SseLineParser
,
ChatStreamChunk
,
} from '@studnicky/dagonizer/adapter'; import type {
AdapterCapabilitiesType
,
BaseAdapterOptionsType
,
ChatRequestType
,
ChatResponseType
,
ChatStreamChunkType
,
LlmAdapterInterface,
RoutedChatStreamChunkType
,
} from '@studnicky/dagonizer/adapter'; import type {
StreamSinkInterface
} from '@studnicky/dagonizer/contracts';

LlmAdapterInterface

The contract every adapter implements — see Reference: Contracts for the full interface listing. Two call shapes:

  • chat(request) — buffered. Resolves once with the complete ChatResponseType.
  • chatStream(request, sink) — resolves with the same ChatResponseType, and additionally pushes ChatStreamChunkType ({ delta }) values to sink as the response is produced. The sink is a pure observation channel: the returned response is authoritative regardless of whether anything is listening on sink.

Both calls are abort+timeout bounded by the adapter's configured timeoutMs (default 60_000, from BaseAdapterOptionsType). chat() is retried by the adapter's RetryPolicy on retryable classifications (NETWORK, TIMEOUT, QUOTA_EXHAUSTED); chatStream() is single-attempt — retrying a partially-emitted stream would re-push deltas already delivered to the sink, so a mid-stream failure surfaces to the caller instead of silently replaying.

Lifecycle rounds out the contract: connect()/disconnect() bring up and tear down per-session state (a model download, a websocket handshake — most adapters no-op), and probe() is a fast, non-throwing availability check a cascade uses to skip an adapter that cannot currently serve a request.

BaseAdapter

BaseAdapter is the abstract base every concrete adapter extends. It owns:

  • chat() — wraps the abstract performChat(request) in the shared abort+timeout race (withDeadline) and the retry/classification envelope.
  • chatStream() — wraps the (overridable) performChatStream(request, sink) in the same abort+timeout race, without the retry wrapper.
  • performChatStream default (buffered) — calls this.chat() and pushes exactly one chunk carrying the complete response text (empty string for a pure tool-call response). An adapter that never overrides this method still satisfies the full streaming contract; it just never emits more than one chunk.
  • pushChunk(sink, chunk) — pushes one chunk and swallows a rejection; a dead or misbehaving sink must never fail an otherwise-valid generation. A healthy sink's back-pressure (an awaited, slow-resolving push()) is still honored — only a rejection is swallowed.
  • systemPrompt — an application-supplied default system message injected as the leading turn of any request that carries none of its own. Never overrides an explicit system message and never produces a second one.
  • timeoutMs — the per-request hard abort+timeout ceiling (60_000 ms default). An expired deadline classifies as TIMEOUT, which a cascade treats as retryable-elsewhere: it falls through to the next preference instead of hanging the whole call.
  • circuitBreaker — optional substrate CircuitBreaker instance wrapping the whole logical chat() call. An open circuit rejects before retry or rate limiting.
  • tokenBucket — optional substrate TokenBucket instance gating the logical chat() call before the retry loop, so retries do not multiply quota consumption.
  • timing — optional substrate Timing sink receiving adapter.chat.start, adapter.chat.complete, adapter.chat.error, and matching adapter.chatStream.* events for logical provider calls.

See Execution tuning for when to combine adapter timeouts, retries, token buckets, circuit breakers, timing, and outer retry policies.

ts
class 
MyAdapter
extends
BaseAdapter
{
protected async
performChat
(
request
:
ChatRequestType
):
Promise
<
ChatResponseType
> {
// call the provider, decode its response into ChatResponseType throw new
Error
('not implemented in this snippet');
} // performChatStream is optional — BaseAdapter's buffered default applies // until this is overridden with real per-token streaming. }

How each adapter streams

Every adapter uses the buffered performChatStream default for any tool-bearing request (request.tools.length > 0): partial tool-call JSON is unsafe to parse mid-stream, so tool turns never stream token-by-token, in any adapter. For tool-less requests:

AdapterPackageStreaming transport
Anthropic@studnicky/dagonizer-adapter-anthropicPOSTs /v1/messages with stream: true; drains the SSE body through SseLineParser, dispatching on each frame's type (message_start, content_block_deltatext_delta, message_delta, message_stop).
Gemini API@studnicky/dagonizer-adapter-gemini-apiPOSTs streamGenerateContent?alt=sse; drains the SSE body through SseLineParser, decoding each frame's GeminiResponseBodyType candidate.
Ollama (and the Groq/Cerebras/Mistral/OpenRouter presets)@studnicky/dagonizer-adapter-ollama / OpenAiCompatibleAdapterPOSTs /v1/chat/completions with stream: true; drains the SSE body through SseLineParser, reading delta.content off each OpenAiStreamChunkType frame.
Gemini Nano@studnicky/dagonizer-adapter-gemini-nanoIn-browser window.LanguageModel session's promptStreaming() async iterable — no network transport; mode-locked (cumulative vs. incremental chunking) is auto-detected once per session from the first two non-empty chunks.
WebLLM@studnicky/dagonizer-adapter-web-llmThe @mlc-ai/web-llm engine's own OpenAI-shaped chat-completion stream, opened through the same setup the buffered performChat path shares.

SseLineParser.linesOf(stream) is the one shared isomorphic SSE framer (Web Streams + TextDecoder, no node:* imports) every fetch-based streaming adapter drains its provider's response body through — it decodes event:/data: lines into SseFrameType frames, joining multi-line data: payloads with \n per spec.

ChatStreamChunk

ts
const 
chunk
:
ChatStreamChunkType
= {
delta
: 'Hello' };

One incremental delta — the text fragment produced since the previous chunk. A streaming adapter yields a sequence of these; concatenating delta values in emission order reconstructs the full response text (which the resolved ChatResponseType already carries in full, independent of the sink).

Routed streaming: one shared sink, many concurrent runs

An adapter's sink argument only ever sees plain ChatStreamChunkType ({ delta }) values — adapters have no notion of concurrent runs or which node/DAG invoked them. CallModelNode (in @studnicky/dagonizer/patterns) bridges that gap:

  • CallModelNode's constructor accepts options: { sink?: StreamSinkInterface<RoutedChatStreamChunkType> }, bound once per node instance.
  • During each execute(batch, context) call, the node wraps this.sink in a fresh RoutingStreamSink (RoutingStreamSink.of(downstream, routeKey, source)) and hands that wrapper to adapter.chatStream(request, wrapper).
  • routeKey comes from CallModelNode.routeKey(state)'' by default (a single unrouted stream); a subclass streaming concurrent conversations on a shared node instance overrides it to read a per-run id (session, conversation) from state.
  • Each plain { delta } chunk the adapter pushes becomes a self-describing RoutedChatStreamChunkType ({ routeKey, delta, source: { dagName, nodeName } }) at the downstream sink. One shared sink — for example a StreamChannel feeding a routing DAG that scatters by routeKey — demultiplexes concurrent runs correctly without a per-run node instance or dispatcher.
ts
import { 
CallModelNode
} from '@studnicky/dagonizer/patterns';
import type { LlmAdapterInterface } from '@studnicky/dagonizer/adapter'; import type {
ChatRequestType
,
ChatResponseType
,
RoutedChatStreamChunkType
} from '@studnicky/dagonizer/adapter';
import type {
NodeContextType
} from '@studnicky/dagonizer/entities';
import type {
StreamSinkInterface
} from '@studnicky/dagonizer/contracts';
import {
NodeStateBase
} from '@studnicky/dagonizer';
class
ChatState
extends
NodeStateBase
{}
class
MyCallModelNode
extends
CallModelNode
<
ChatState
> {
readonly '@id' = 'urn:noocodec:node:call-model'; readonly
name
= 'call-model';
constructor(
llm
: LlmAdapterInterface,
options
: {
sink
?:
StreamSinkInterface
<
RoutedChatStreamChunkType
> } = {}) {
super(
llm
,
options
);
} protected override
routeKey
(
_state
:
ChatState
): string {
return 'conversation-1'; } protected
getRequest
(
_state
:
ChatState
,
ctx
:
NodeContextType
):
ChatRequestType
{
return { 'messages': [], 'tools': [], 'toolChoice': { 'type': 'auto' }, 'outputSchema': { 'variant': 'none' }, 'maxTokens': 256, 'temperature': 0, 'signal':
ctx
.
signal
,
}; } protected
storeResponse
(
_state
:
ChatState
,
_response
:
ChatResponseType
,
_ctx
:
NodeContextType
): void { /* write to state */ }
} const
sink
:
StreamSinkInterface
<
RoutedChatStreamChunkType
> = {
async
push
(
chunk
) {
process
.
stdout
.
write
(`[${
chunk
.
routeKey
}] ${
chunk
.
delta
}`); },
};

See ReAct agent: live token streaming and ReAct agent: routing concurrent streams for the full walkthrough, and the react-agent-memory example for a complete working setup.

Cascades: probe-until-available

LlmAdapterRegistry is a process-local map of (provider, model) → zero-arg adapter factory; the factory is invoked fresh on every resolve() call so each caller gets its own retry state and session lifecycle. LlmAdapterCascade walks an ordered preference list against a registry, probe()-ing each resolved adapter in turn, and returns the first one whose probe resolves true. When every preference is exhausted it throws LlmError(NO_ADAPTER_AVAILABLE) with a summary of what was tried. LlmAdapterCascade.create(catalogue) assembles a registry + cascade pair directly from a data-shaped provider catalogue (no switch over provider names required):

ts
const 
catalogue
:
CatalogueEntryType
[] = [
{
descriptor
: { 'provider': 'ollama', 'model':
selectedModel
, 'capabilities': { 'toolUse': 'partial', 'structuredOutput': true, 'jsonMode': true } },
factory
:
myAdapterFactory
,
}, ]; const
cascade
=
LlmAdapterCascade
.
create
(
catalogue
);
const
adapter
= await
cascade
.
select
(); // probes in catalogue order

See Example 24: LLM Adapter for a complete async-discovery walkthrough across Ollama and Groq.

API / export table

All exports below ship through @studnicky/dagonizer/adapter unless noted.

ExportKindPurpose
LlmAdapterInterfacetype (./contracts)The transport contract every adapter implements.
BaseAdapterclassAbstract base: retry, classification, abort+timeout, buffered performChatStream default.
BaseAdapterOptionsTypetypesystemPrompt, timeoutMs, circuitBreaker, tokenBucket, timing, plus BaseAdapterCoreOptionsType.
ChatRequestType / ChatResponseTypetypeRequest/response envelopes passed to chat() / chatStream().
ChatStreamChunkType / ChatStreamChunktype / classOne { delta } streamed fragment, with ChatStreamChunk.create(...) as its value factory.
RoutedChatStreamChunkType / RoutedChatStreamChunktype / class{ routeKey, delta, source } — a chunk stamped for demultiplexing, with RoutedChatStreamChunk.create(...) as its value factory.
RoutingStreamSinkclassPer-execution decorator that stamps plain chunks with routeKey + source.
StreamSinkInterface<T>type (./contracts)Push-side, back-pressured sink contract chunks are delivered to.
SseLineParser / SseFrameTypeclass / typeShared isomorphic SSE framer used by every fetch-based streaming adapter.
LlmAdapterRegistryclass(provider, model) → adapter factory map.
LlmAdapterCascadeclassPreference-ordered probe-until-available selector.
LlmAdapterCascade / CatalogueEntryTypeclass / typeData-driven registry+cascade assembly via LlmAdapterCascade.create(catalogue).
LlmError / Classifications / LlmErrorReasonTypeclass / const / typeError classification (NETWORK, TIMEOUT, QUOTA_EXHAUSTED, SCHEMA_VIOLATION, CONFIGURATION, MODEL_NOT_FOUND, NO_ADAPTER_AVAILABLE, …).
CallModelNodeclass (./patterns)Agent-loop node base: reads a request, calls adapter.chatStream through a routed sink, writes the response to state.

Details for Nerds

Streaming adapters still resolve to a complete ChatResponseType. The sink is an observation channel for incremental chunks, not an alternate return path.

Tool-bearing requests use buffered behavior when partial tool-call JSON would be unsafe to parse mid-stream. A sink rejection is swallowed so UI or SSE delivery failure does not fail an otherwise valid model call.

Watched over by the Order of Dagon.