@nest-native/ai-sdk
v0.5.0
Published
Decorator-first NestJS streaming primitive for the Vercel AI SDK that preserves the full Nest enhancer pipeline
Maintainers
Readme
@nest-native/ai-sdk
[!NOTE] Status:
0.2.0(0.x). Functional and fully tested (100% coverage), and usable today — but the public API may still change before1.0, so pin a version (per semver,0.xminor releases can include breaking changes). See the support policy.@AiStreamstreams AI SDK results on both Express and Fastify while keeping the Nest enhancer pipeline intact,@AiAbortSignalcancels the AI SDK call when the client disconnects mid-stream,@AiContextinjects request-scoped context ({ request, response, signal }) so an AI SDK tool'sexecuteclosure can reach the current request mid-stream, and pre-stream vs in-stream errors are mapped correctly (HTTP errors vs documented stream error frames). Samples coverstreamText,streamObject, the v5 generative-UI equivalent ofstreamUI(custom data parts viacreateUIMessageStream), and request-scoped tool context. A migration guide ports the official AI SDK NestJS cookbook recipe to@AiStream, and full documentation is published.
What This Is
@nest-native/ai-sdk is a community NestJS integration that will make Vercel AI
SDK streaming responses feel like a first-class Nest primitive — replacing the
"raw @Res() + manual piping" pattern with a decorator that keeps the Nest
enhancer pipeline (guards, pipes, interceptors, filters) intact.
The headline goal: a pre-stream guard rejection returns an HTTP error, not an
SSE error frame, and a client disconnect propagates an AbortSignal to the AI
SDK call.
Compatibility
| Runtime | Supported line |
| --- | --- |
| Node.js | >=22 (required by ai@7) |
| NestJS | 11.x |
| Vercel AI SDK (ai) | ^7 (tracks the current major; older majors not supported) |
| HTTP adapter | Express and Fastify (parity shipped and tested) |
The published package has no runtime dependencies. The Vercel AI SDK and the
NestJS packages are declared as peerDependencies, so applications install only
the ecosystems they actually use.
Installation
npm i @nest-native/ai-sdk aiRequired peers:
npm i @nestjs/common @nestjs/core reflect-metadata rxjsUsage
Register the module once, then decorate a handler with @AiStream. The handler
returns an AI SDK stream result (for example from streamText); the decorator
wires it to the active HTTP adapter's response.
import { Module } from '@nestjs/common';
import { AiModule } from '@nest-native/ai-sdk';
@Module({
imports: [
AiModule.forRoot({
defaultHeaders: { 'x-powered-by': 'nest-native-ai-sdk' },
}),
],
})
export class AppModule {}import { AiStream } from '@nest-native/ai-sdk';
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { streamText } from 'ai';
@Controller('chat')
export class ChatController {
@Post()
@AiStream()
@UseGuards(ApiKeyGuard)
chat(@Body() body: ChatDto) {
// ApiKeyGuard runs BEFORE the stream opens, so a rejection is an HTTP
// 401/403 — never an SSE error frame.
return streamText({ model, prompt: body.prompt });
}
}@AiStream serializes to the AI SDK UI message protocol by default
(pipeUIMessageStreamToResponse()), the format @ai-sdk/react's useChat
consumes. Pass format: 'text' for a plain text delta stream, or set headers
/ status per route:
@Post('text')
@AiStream({ format: 'text', headers: { 'x-stream': 'text' } })
chatText(@Body() body: ChatDto) {
return streamText({ model, prompt: body.prompt });
}Method-level headers merge over the module's defaultHeaders (method keys
win). Guards, pipes, and exception filters all run ahead of the stream, so
pre-stream rejections surface as ordinary HTTP responses.
Stream types
@AiStream serializes any AI SDK result that exposes a pipe*ToResponse
method, so the three v5 streaming shapes all work through the same decorator:
streamText— the defaultui-messageformat (pipeUIMessageStreamToResponse()). Seesample/00-showcase.streamObject— streams a structured object as partial-JSON text deltas. Its result exposes onlypipeTextStreamToResponse, so serve it with@AiStream({ format: 'text' }). Seesample/04-stream-object.streamUI(v5 equivalent) —ai/rsc'sstreamUIwas removed in v5. The supported replacement is a UI message stream with customdata-*parts built viacreateUIMessageStream; wrap the returnedReadableStreamso it exposespipeUIMessageStreamToResponseand serve it with the default@AiStream(). Seesample/05-stream-ui.
Express and Fastify
The same handler works on either adapter — @AiStream writes to the underlying
Node ServerResponse (Express exposes it directly; Fastify exposes it via
reply.raw), so you do not touch the raw response yourself. The only difference
is the adapter you pass to NestFactory.create:
import { FastifyAdapter } from '@nestjs/platform-fastify';
const app = await NestFactory.create(AppModule, new FastifyAdapter());See sample/01-fastify-parity for
the same controller streaming identically on both adapters.
Cancel on disconnect with @AiAbortSignal
When a client disconnects mid-stream you usually want to stop generating — the
tokens go nowhere, but the provider keeps billing. @AiAbortSignal() injects an
AbortSignal derived from the client's connection; forward it into your AI SDK
call and a disconnect tears the upstream model request down immediately:
import { AiAbortSignal, AiStream } from '@nest-native/ai-sdk';
import { Body, Controller, Post } from '@nestjs/common';
import { streamText } from 'ai';
@Controller('chat')
export class ChatController {
@Post()
@AiStream()
chat(@Body() body: ChatDto, @AiAbortSignal() signal: AbortSignal) {
return streamText({ model, prompt: body.prompt, abortSignal: signal });
}
}The signal is derived from the underlying Node response, so it works identically
on Express and Fastify, and it is memoized per request — declaring
@AiAbortSignal() more than once yields the same signal. See
sample/02-abort-signal for a smoke
test that disconnects mid-stream and asserts the model call is cancelled on both
adapters.
Request-scoped tool context with @AiContext
An AI SDK tool's execute closure runs inside the stream, after the handler
returns, so it cannot use ordinary Nest parameter decorators to reach the current
request. @AiContext() injects a request-scoped AiExecutionContext —
{ request, response, signal } — that you capture in the handler and close over,
giving a tool request-scoped access (auth/headers a guard attached) plus the
client-disconnect signal:
import { AiContext, AiExecutionContext, AiStream } from '@nest-native/ai-sdk';
import { Controller, Post, UseGuards } from '@nestjs/common';
import { jsonSchema, streamText, tool } from 'ai';
@UseGuards(ApiKeyGuard) // attaches request.user pre-stream
@Controller('chat')
export class ChatController {
@Post()
@AiStream()
chat(@AiContext() ctx: AiExecutionContext) {
const request = ctx.request as { user?: { id: string } };
return streamText({
model,
prompt: 'who am I?',
tools: {
whoami: tool({
description: 'Return the authenticated caller.',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: async () => ({ user: request.user }),
}),
},
});
}
}The context is minimal by design — request, response, and the
client-disconnect signal (the same one @AiAbortSignal() resolves). It is not
a broad context bus. Works identically on Express and Fastify and is memoized per
request. See
sample/07-tool-context for a smoke
test where a tool's execute reads the guard-attached user via @AiContext on
both adapters.
Error mapping: pre-stream vs in-stream
@AiStream has a two-sided error model that mirrors when the failure happens
relative to the first byte:
- Pre-stream errors — thrown by a guard, a pipe, or the handler before it
returns a stream — flow through the Nest enhancer pipeline and become ordinary
HTTP errors (401 / 403 / 400 / 429 / …). The stream is never opened. This is
the headline differentiator over the raw
@Res()cookbook recipe, where an auth failure leaks into the SSE body instead. - In-stream errors — thrown during stream production, after the first byte
— can no longer be HTTP errors: the status and headers are already on the wire.
Instead the AI SDK emits a documented error frame inside the stream
protocol, never a partial-then-broken response. Use
onErrorto map the thrown error to that frame's message:
@Post()
@AiStream({ onError: () => 'The model is temporarily unavailable.' })
chat(@Body() body: ChatDto) {
return streamText({ model, prompt: body.prompt });
}onError can also be set module-wide via AiModule.forRoot({ onError }); a
method-level mapper overrides the module default. When neither is set, the AI
SDK's secret-safe default ('An error occurred.') is used, so raw provider
errors — which may contain credentials — never reach the client. Only ever map
to vetted, non-sensitive messages.
The
textformat has no error frame:pipeTextStreamToResponseaccepts only status/headers and silently drops non-text events, soonErroris ignored forformat: 'text'. Use the defaultui-messageformat when you need in-stream error reporting.Response-transform-style interceptors (e.g. a classic
ResponseTransformInterceptorthat rewrites the final body) are incompatible with streaming and are not auto-skipped — do not apply them to@AiStreamroutes.
See sample/03-error-mapping for both
paths exercised on Express and Fastify.
Testing
The @nest-native/ai-sdk/testing entrypoint ships deterministic, fully offline
AI SDK v4 mock language models — no provider, no API keys — so streaming
handlers can be exercised end-to-end in tests:
import { createMockLanguageModel } from '@nest-native/ai-sdk/testing';
import { streamText } from 'ai';
const result = streamText({
model: createMockLanguageModel({ text: 'You said: ping' }),
prompt: 'ping',
});createMockLanguageModel streams text as v4 protocol chunks (word deltas for
a string, one delta per element for a string[]), fails mid-stream with the
documented in-stream error frame when error is set, and — with
respectAbortSignal: true and a chunkDelayInMs — honors doStream's abort
signal the way a real provider does, exposing capturedSignal() / started()
/ settled() observers so a test can prove a client disconnect cancelled the
model call. createToolCallingModel(toolName) emits a single tool call so a
tool execute closure runs. The entrypoint adds no runtime dependencies
("dependencies": {} stays). Full reference:
testing documentation.
Migrating from the official cookbook
Already using the official AI SDK NestJS recipe (raw @Res() +
pipe*ToResponse)? The migration guide ports every recipe
in ai-sdk.dev/cookbook/api-servers/nest
to @AiStream one-for-one, and sample/06-migration
ships the before/after side by side with a smoke test proving the streams are
byte-identical.
Links
- Documentation: nest-native.github.io/ai-sdk
- Source and issues: github.com/nest-native/ai-sdk
- Migration guide: MIGRATION.md
- Changelog: CHANGELOG.md
- Vercel AI SDK: ai-sdk.dev
- The nest-native family: @nest-native/drizzle, @nest-native/trpc
