Custom ModelProvider
Implement the ModelProvider SPI by hand for a gateway or client aiSdkModel doesn't cover — stream text deltas to the sink, translate the endpoint's tool-call events, and never execute a tool yourself.
Reach for this when you're not going through the Vercel AI SDK — a bespoke internal gateway, a
provider aiSdkModel doesn't wrap, or a raw HTTP client you already trust. A ModelProvider is a
one-method adapter: given a system prompt, message history, and tool definitions, run exactly one
model turn, stream the text as it arrives, and hand back what the model asked for. Nothing about the
loop, the tool registry, or durability leaks into this interface — you only translate one turn.
The contract
From model-provider.ts and token-stream-sink.ts:
interface ModelTurnArgs {
system: string;
messages: ModelMessage[];
tools: ToolDefinition[];
/** The model writes streamed text deltas here as it generates them. */
sink: SinkWriter;
abortSignal?: AbortSignal;
}
/** The outcome of ONE assistant turn. The loop — not the model — drives tool execution. */
interface ModelTurnResult {
text: string;
toolCalls: ToolCallRequest[];
usage: MessageUsage;
/** Wins over the module's configured modelId when set — keeps cost labels honest. */
modelId?: string;
/** A gateway's ACTUAL reported spend for the turn; omit it and the read-model estimates instead. */
costUsd?: number;
}
interface SinkWriter {
write(chunk: Uint8Array): void | Promise<void>;
end(): void | Promise<void>;
}
/**
* Contract: `runTurn` performs exactly one model turn, streaming deltas to `args.sink`, and
* returns the assembled text + requested tool calls + usage. It MUST NOT execute tools — the
* agent loop runs each as a (durable) step for replay-safety.
*/
interface ModelProvider {
runTurn(args: ModelTurnArgs): Promise<ModelTurnResult>;
}Never execute a tool inside runTurn
Tools are handed to your provider as ToolDefinition[] — name, kind, description, input schema,
no handler. When the model asks to call one, translate that into a ToolCallRequest ({ id, name, input }) and return it in toolCalls. Do not look up a handler and run it here — the agent
loop validates the input, checks authorization, and executes the tool as its own (durable-safe)
step. A provider that executes tools breaks replay: a crashed-and-resumed durable turn would run
the side effect twice.
A minimal streaming implementation
This adapts a hypothetical streaming HTTP completions endpoint — the shape you'd see behind an internal gateway. Swap the fetch/parsing block for your real client; the sink and return-shape plumbing around it stays the same regardless of what you're calling.
// src/model/raw-http-model.ts
import type {
ModelProvider,
ModelTurnArgs,
ModelTurnResult,
ToolCallRequest,
} from '@dudousxd/nestjs-agent';
type StreamEvent =
| { type: 'text-delta'; delta: string }
| { type: 'tool-call'; id: string; name: string; input: unknown }
| { type: 'usage'; inputTokens: number; outputTokens: number };
/** Hand-rolled ModelProvider over a raw streaming completions endpoint (NDJSON-over-SSE). */
export function rawHttpModel(endpoint: string, apiKey: string): ModelProvider {
return {
async runTurn(args: ModelTurnArgs): Promise<ModelTurnResult> {
const response = await fetch(endpoint, {
method: 'POST',
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
body: JSON.stringify({
system: args.system,
messages: args.messages,
tools: args.tools.map((tool) => ({ name: tool.name, description: tool.description })),
}),
...(args.abortSignal ? { signal: args.abortSignal } : {}),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let text = '';
let inputTokens = 0;
let outputTokens = 0;
const toolCalls: ToolCallRequest[] = [];
while (reader) {
const { value, done } = await reader.read();
if (done) {
break;
}
for (const line of decoder.decode(value).split('\n')) {
if (!line.startsWith('data:')) {
continue;
}
const event = JSON.parse(line.slice(5)) as StreamEvent;
if (event.type === 'text-delta') {
text += event.delta;
// Stream to the live data plane — this is NOT tool execution, just token transport.
await args.sink.write(encoder.encode(event.delta));
} else if (event.type === 'tool-call') {
// Translate the endpoint's tool-call event into a request for the loop to run.
toolCalls.push({ id: event.id, name: event.name, input: event.input });
} else {
inputTokens = event.inputTokens;
outputTokens = event.outputTokens;
}
}
}
return { text, toolCalls, usage: { inputTokens, outputTokens } };
},
};
}Wire it in exactly where any ModelProvider goes:
AgentModule.forRoot({
model: rawHttpModel(process.env.LLM_ENDPOINT ?? '', process.env.LLM_API_KEY ?? ''),
// ...store, actorResolver, etc.
});Reported cost, when your gateway has it
If your endpoint's usage event carries a real dollar figure (a gateway's actual spend, not an
estimate), set it on the result as costUsd — the governance read-model uses a reported cost
verbatim instead of estimating from tokens. Direct providers that only report tokens simply omit
it. See Cost & Governance.
Related
aiSdkModelpackage — the shipped adapter doing exactly this against the Vercel AI SDK'sstreamText, worth reading even if you don't use it: it shows how to mapToolDefinition[]into a real tool-parameter JSON schema and extract a gateway's reported cost.- Architecture — why
ModelProvideris one of the SPI seams, and how it fits alongsideAgentStore,TokenStreamSink, and the rest. - Offline testing —
FakeModelProvider, the scripted in-memoryModelProviderused across the test suite, is a second worked example of this SPI.
Recipes
Copy-pasteable cookbook for @dudousxd/nestjs-agent — a hand-rolled ModelProvider, flipping the inline runner to durable, DI-powered functional tools, a billing export off the governance read-model, and exercising the whole loop offline.
Inline → durable
Durable is the production default — dispatchedSteps routes every model/tool call as a checkpointed, worker-served step the moment durable is true. This recipe covers getting to durable: true, and the dispatchedSteps: false escape hatch for a simple in-process case.