@helix-agents/llm-vercel
Vercel AI SDK adapter for the Helix Agents framework. Provides LLM integration using the Vercel AI SDK's streamText function.
Installation
npm install @helix-agents/llm-vercel ai @ai-sdk/openaiai is a peer dependency, imported the first time an adapter calls the model (not when you import @helix-agents/llm-vercel), so importing the package builds none of ai's schemas at startup. If ai cannot be loaded, that first call fails through the adapter's error path, and the next call tries again. See Cloudflare Workers: Startup Cost.
VercelAIAdapter
Main adapter class implementing the LLMAdapter interface.
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
const llmAdapter = new VercelAIAdapter();Options
interface VercelAIAdapterOptions {
logger?: Logger;
telemetry?: TelemetrySettings; // passed to the AI SDK as experimental_telemetry
/**
* Override or extend the built-in model capability table (proxy aliases,
* models newer than the table). Return a resolution to replace the built-in
* one (recorded as source 'override'), or undefined to keep it.
*/
capabilities?: CapabilitiesResolver;
/**
* Opt-in deadline (ms) for the provider to answer a model call (DI-76): the
* model's `doStream` must resolve within it, else the attempt ends
* `provider_unreachable` (retried once). No default. Model objects only.
*/
connectTimeoutMs?: number;
}
type CapabilitiesResolver = (
config: LLMConfig,
builtIn: CapabilityResolution
) => CapabilityResolution | undefined;resolveCapabilities / checkOutputFormat
llmAdapter.resolveCapabilities(config); // CapabilityResolution for config.model
llmAdapter.checkOutputFormat(format, config); // { ok: true } | { ok: false, reason }resolveCapabilities applies the capabilities override over the built-in table (Anthropic models; every other provider is legacy). checkOutputFormat runs the same conversion as a native-output call: it converts an LLMOutputFormat schema with z.toJSONSchema for every model (refusing what cannot convert, such as z.date()), and for Anthropic models also normalises it to the native structured-output subset, reporting a construct the subset cannot represent. See Vercel adapter → Model capabilities.
Capability exports
import {
PROFILES, // Record<ProfileName, ModelCapabilities>
type ProfileName, // 'legacy' | 'anthropic-structured' | 'anthropic-5.5'
ANTHROPIC_MODEL_TABLE, // ReadonlyArray<[idPrefix, ProfileName]>, first match wins
resolveAnthropicCapabilities, // (normalisedModelId: string) => CapabilityResolution
resolveVercelCapabilities, // (config, override?) => CapabilityResolution
normalizeModelIdentity, // (model) => { provider, modelId, family: 'anthropic' | 'other' }
normalizeAnthropicOutputSchema, // (jsonSchema) => { ok: true, schema } | { ok: false, reason }
type CapabilitiesResolver,
type AnthropicSchemaResult,
type JSONSchema,
} from '@helix-agents/llm-vercel';| Profile | forcedToolChoice | nativeStructuredOutput | historyBoundThinking |
|---|---|---|---|
legacy | true | false | false |
anthropic-structured | true | true | false |
anthropic-5.5 | false | true | true |
@ai-sdk/anthropic is an optional peer dependency (>=3.0.38), needed only for Anthropic models.
generateStep
Generate a single agent step with the LLM.
const result = await llmAdapter.generateStep({
messages, // Conversation history
tools, // Available tools
llmConfig, // Model configuration
outputSchema, // Optional: for structured output
}, {
// Optional callbacks for streaming
onTextDelta: async (delta) => { ... },
onThinking: async (content, isComplete) => { ... },
onToolStart: async (id, name, args) => { ... },
onToolEnd: async (id, result) => { ... },
});Parameters:
interface LLMGenerateInput<TOutput> {
messages: Message[];
tools: LLMTool[];
llmConfig: LLMConfig;
outputSchema?: ZodType<TOutput>;
}
interface LLMStreamCallbacks {
onTextDelta?: (delta: string) => Promise<void>;
onThinking?: (content: string, isComplete: boolean) => Promise<void>;
onToolStart?: (id: string, name: string, args: unknown) => Promise<void>;
onToolEnd?: (id: string, result: unknown) => Promise<void>;
}Returns: StepResult
formatAssistantMessage
Format assistant message plan to internal Message format.
const message = llmAdapter.formatAssistantMessage({
content: 'Hello!',
toolCalls: [],
subAgentCalls: [],
thinking: { type: 'text', text: 'thinking...' },
});Strict (constrained decoding) tools
When a tool sets strict: true (including the auto-injected __finish__ tool when llmConfig.strictOutput is enabled), VercelAIAdapter forwards that flag onto the AI SDK tool spec. From there, ai-core copies it to the provider, which applies constrained decoding subject to model capability — the Anthropic provider gates on its per-model supportsStructuredOutput table, while OpenAI maps it to strict function calling. On a model or provider that doesn't support constrained decoding the flag is simply ignored and the request is sent without strict, so enabling it is a safe no-op rather than an error.
LLMConfig
Model configuration passed to the adapter.
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
const config: LLMConfig = {
// Required: Model from AI SDK
model: openai('gpt-6-luna'),
// Optional: Temperature (0-2). Non-reasoning models only: reasoning models such as
// gpt-6-luna ignore it, and Claude 5.5 models reject non-default values.
temperature: 0.7,
// Optional: Max output tokens
maxOutputTokens: 4096,
// Optional: Prompt caching (opt-in; use a helper matching your provider)
// import { anthropicCache } from '@helix-agents/core';
cache: anthropicCache({ ttl: '1h' }),
// Optional: Provider-specific options
providerOptions: {
// For OpenAI reasoning models
openai: {
reasoningEffort: 'medium',
},
// For Anthropic with extended thinking
anthropic: {
thinking: {
type: 'adaptive', // Claude 5.5 models: depth is set by `effort`, not a token budget
},
},
},
};Supported Providers
Any provider supported by Vercel AI SDK:
// OpenAI
import { openai } from '@ai-sdk/openai';
const model = openai('gpt-6-luna');
// Anthropic
import { anthropic } from '@ai-sdk/anthropic';
const model = anthropic('claude-sonnet-5-5');
// Google
import { google } from '@ai-sdk/google';
const model = google('gemini-1.5-pro');
// Azure OpenAI
import { azure } from '@ai-sdk/azure';
const model = azure('gpt-6-luna');
// Cohere
import { cohere } from '@ai-sdk/cohere';
const model = cohere('command-r-plus');Thinking/Reasoning Support
Anthropic Extended Thinking
const agent = defineAgent({
llmConfig: {
model: anthropic('claude-sonnet-5-5'),
providerOptions: {
anthropic: {
thinking: {
type: 'adaptive', // Claude 5.5 models: depth is set by `effort`, not a token budget
},
},
},
},
});Thinking content is:
- Streamed via
onThinkingcallback - Included in
StepResult.thinking - Stored in
AssistantMessage.thinking - Recorded block by block, with each block's signature, in the ordered
StepResult.parts/AssistantMessage.parts, which the adapter replays verbatim (see Preserved thinking)
OpenAI o-series Reasoning
const agent = defineAgent({
llmConfig: {
model: openai('gpt-6-luna'),
providerOptions: {
openai: {
reasoningEffort: 'high', // 'low' | 'medium' | 'high'
},
},
},
});Chunk Mapping Utilities
Convert Vercel AI SDK chunks to Helix stream chunks.
import {
mapVercelChunkToStreamChunk,
isTextContentChunk,
isToolChunk,
isCompletionChunk,
} from '@helix-agents/llm-vercel';
// Check chunk type
if (isTextContentChunk(vercelChunk)) {
const helixChunk = mapVercelChunkToStreamChunk(vercelChunk, {
agentId: 'run-123',
timestamp: Date.now(),
});
}Usage Example
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { defineAgent, defineTool } from '@helix-agents/core';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
// Define agent
const MyAgent = defineAgent({
name: 'my-agent',
systemPrompt: 'You are a helpful assistant.',
outputSchema: z.object({ response: z.string() }),
tools: [
defineTool({
name: 'search',
description: 'Search for information',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ results: z.array(z.string()) }),
execute: async ({ query }) => ({ results: [`Result for: ${query}`] }),
}),
],
llmConfig: {
model: openai('gpt-6-luna'),
maxOutputTokens: 4096,
},
});
// Create executor with Vercel adapter
const executor = new JSAgentExecutor(
new InMemoryStateStore(),
new InMemoryStreamManager(),
new VercelAIAdapter()
);
// Execute
const handle = await executor.execute(MyAgent, 'Search for TypeScript tutorials');
const result = await handle.result();Error Handling
The adapter classifies Vercel AI SDK errors into typed HelixError instances.
mapVercelError
Convert Vercel AI SDK errors to typed HelixError:
import { mapVercelError, mapStatusCodeToErrorCode } from '@helix-agents/llm-vercel';
const helixError = mapVercelError(vercelError);
// Returns HelixError with code, category, retryable, statusCodeHandles ApiCallError (status code mapping), RetryError (extracts last error), connection drops (below), and generic errors.
mapStatusCodeToErrorCode
Map HTTP status codes to error codes:
import { mapStatusCodeToErrorCode } from '@helix-agents/llm-vercel';
mapStatusCodeToErrorCode(429); // 'provider_rate_limited'
mapStatusCodeToErrorCode(503); // 'provider_overloaded'
mapStatusCodeToErrorCode(401); // 'provider_auth_error'| Status Code | ErrorCode |
|---|---|
| 401, 403 | provider_auth_error |
| 429 | provider_rate_limited |
| 408 | provider_timeout |
| 400, 422 | provider_invalid_request |
| 503, 529 | provider_overloaded |
| Other 5xx | provider_error |
isConnectionDropError
Whether an error is a dropped connection to the provider, recognised by the runtime's structured error, never its message: workerd's plain Error with own retryable: true and not overloaded (Network connection lost.), or undici's TypeError whose cause code is UND_ERR_SOCKET (a close) or ECONNRESET (a reset) (fetch failed, terminated). A HelixError or another Error subclass is never one. mapVercelError maps one to provider_error, retryable: true.
import { isConnectionDropError } from '@helix-agents/llm-vercel';
isConnectionDropError(error); // booleanconnectionDropRetryDelayMs
generateStep retries a connection drop that happens before any model output, up to llmConfig.maxRetries (default 3, 0 disables it), waiting connectionDropRetryDelayMs(retry) before retry retry + 1: 2000 ms times 2 to the power of retry, uncapped — the AI SDK's own policy. See Error handling: connection drops.
import { connectionDropRetryDelayMs } from '@helix-agents/llm-vercel';
connectionDropRetryDelayMs(0); // 2000
connectionDropRetryDelayMs(2); // 8000Provider unreachable (DI-76) and connectTimeoutMs
On Cloudflare Workers, set
connectTimeoutMs. Without it, an unreachable provider holds a model call for workerd's own 75 s connect timeout before it fails (workerd masks it asinternal error; reference = …withoverloaded: true, and that attempt is not retried).new VercelAIAdapter({ connectTimeoutMs: 15_000 })aborts a call whose provider has not started answering by then. There is no default because a proxy or gateway that buffers the upstream answer before it sends its own headers would fail every long call: pick a value above your provider's (or proxy's) time to first byte.
A provider call that fails before it produced anything maps to provider_unreachable (category provider, retryable: false), with the person-safe message The model provider could not be reached. and the raw error as its cause (its workerd overloaded / remote flags reach ErrorDetail.flags). generateStep retries it once (min(maxRetries, 1), 2 s), never when the cause is overloaded. Recognition: on a model object, a plain Error / TypeError that the model's own doStream rejected with (the adapter wraps the model per call); on a gateway string id, which it cannot wrap, a plain Error / TypeError before any model output (residual: a framework TypeError inside streamText reads as one there). See Error handling: provider unreachable.
Error Flow
When the LLM adapter encounters an error, the runtime's onError callback receives the classified HelixError and writes an ErrorChunk to the stream with code and recoverable fields. See Error Handling Guide for the complete flow.
Stop Reason Mapping
The adapter normalizes finish reasons to StopReason:
| Provider Reason | Helix StopReason |
|---|---|
stop | end_turn |
end_turn | end_turn |
tool-calls | tool_use |
tool_use | tool_use |
length | max_tokens |
max_tokens | max_tokens |
content-filter | content_filter |
| Other | unknown |
A filtered or truncated reply completes the run on Temporal and DBOS (DI-26)
max_tokens, content_filter, error and unknown are error stop reasons, and this adapter returns them on a final text step (shouldStop: true), including when the reply has no text. It never returns refusal. For an agent without outputSchema, JS and both Cloudflare runtimes then fail the run; Temporal and DBOS end it completed with no error. Observed live for content_filter on both and for max_tokens on Temporal. See DI-26 in the conformance findings.