Skip to content

Vercel AI SDK Adapter ​

The Vercel AI SDK adapter (@helix-agents/llm-vercel) connects Helix Agents to any LLM provider supported by the Vercel AI SDK. This is the recommended adapter for most applications.

When to Use ​

Good fit:

  • Production applications
  • Multiple provider support needed
  • Streaming responses required
  • Using OpenAI, Anthropic, Google, or other major providers

Not ideal for:

  • Unit testing (use MockLLMAdapter instead)
  • Custom/private LLM APIs not in Vercel AI SDK

Installation ​

bash
npm install @helix-agents/llm-vercel ai

Also install provider packages for your chosen models:

bash
# OpenAI
npm install @ai-sdk/openai

# Anthropic
npm install @ai-sdk/anthropic

# Google
npm install @ai-sdk/google

Basic Usage ​

typescript
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { openai } from '@ai-sdk/openai';

// Create adapter
const adapter = new VercelAIAdapter();

// Create executor
const executor = new JSAgentExecutor(
  new InMemoryStateStore(),
  new InMemoryStreamManager(),
  adapter
);

// Define agent with Vercel AI SDK model
const agent = defineAgent({
  name: 'assistant',
  systemPrompt: 'You are a helpful assistant.',
  llmConfig: {
    model: openai('gpt-6-luna'),
  },
});

Supported Providers ​

The Vercel AI SDK supports many providers:

ProviderPackageExample Model
OpenAI@ai-sdk/openaiopenai('gpt-6-luna')
Anthropic@ai-sdk/anthropicanthropic('claude-sonnet-5-5')
Google@ai-sdk/googlegoogle('gemini-1.5-pro')
Cohere@ai-sdk/coherecohere('command-r-plus')
Mistral@ai-sdk/mistralmistral('mistral-large-latest')
Amazon Bedrock@ai-sdk/amazon-bedrockVarious models
Azure OpenAI@ai-sdk/azureAzure-hosted models

See the Vercel AI SDK documentation for the full list.

Using a LiteLLM / OpenAI-compatible proxy ​

The AI SDK providers read their key and endpoint from the environment, so pointing an agent at a proxy (LiteLLM, an AI gateway, any server that speaks the provider's API) needs no code change:

ProviderKeyEndpoint (optional)Default endpoint
@ai-sdk/openaiOPENAI_API_KEYOPENAI_BASE_URLhttps://api.openai.com/v1
@ai-sdk/anthropicANTHROPIC_API_KEYANTHROPIC_BASE_URLhttps://api.anthropic.com/v1

Set the endpoint to the proxy's base URL including its version path (for LiteLLM, https://<your-proxy>/v1), and the key to one the proxy issues. Leave the endpoint unset to call the provider directly. The endpoint is read once, when the provider is created (for the default openai / anthropic instances, when their module loads); the key is read on each request. Pass them explicitly when process.env does not hold them when the provider is created, or when different agents use different proxies:

typescript
import { createOpenAI } from '@ai-sdk/openai';

const viaProxy = createOpenAI({
  baseURL: process.env.OPENAI_BASE_URL, // e.g. https://litellm.example.com/v1
  apiKey: process.env.OPENAI_API_KEY,
});

const agent = defineAgent({
  name: 'assistant',
  systemPrompt: 'You are a helpful assistant.',
  llmConfig: { model: viaProxy('gpt-6-luna') },
});
  • Model names. Request the model under the name the proxy serves. When the proxy exposes the provider's own model ids, nothing else changes. When it serves aliases, Helix sees the alias and cannot look up the real model's capabilities: map it with capabilities.
  • Endpoints. openai(…) calls the Responses API (/v1/responses); use openai.chat(…) for a proxy that only serves Chat Completions.
  • Cloudflare Workers. Bindings are not in process.env when a module loads, so the default openai instance keeps https://api.openai.com/v1 even if you copy env into process.env later, and would then send the proxy's key to OpenAI. Create the provider from the env binding (createOpenAI({ baseURL: env.OPENAI_BASE_URL, apiKey: env.OPENAI_API_KEY })), or give it a fetch that reads the base URL per request, as examples/research-assistant-cloudflare-do/src/openai-provider.ts does.
  • Secrets. Keep the key and the endpoint out of the repository: a gitignored .env file, .dev.vars for wrangler dev, wrangler secret put in production, or your secrets manager (infisical run -- <command>, op run -- <command>, …).

Configuration ​

Model Configuration ​

typescript
const agent = defineAgent({
  name: 'my-agent',
  systemPrompt: 'You are a helpful assistant.',
  llmConfig: {
    // Required: The model to use
    model: openai('gpt-6-luna'),

    // Generation parameters. Sampling parameters (temperature, topP, topK, penalties)
    // are for non-reasoning models: reasoning models such as gpt-6-luna ignore them,
    // and Claude 5.5 models reject non-default values.
    temperature: 0.7, // 0-2, higher = more creative
    maxOutputTokens: 4096, // Maximum tokens to generate
    topP: 0.95, // Nucleus sampling
    topK: 40, // Top-k sampling

    // Penalties
    presencePenalty: 0, // Reduce repetition of topics
    frequencyPenalty: 0, // Reduce repetition of tokens

    // Control
    stopSequences: ['END'], // Stop generation at these sequences
    seed: 12345, // For deterministic outputs

    // Reliability
    maxRetries: 3, // Retry on transient failures

    // import { anthropicCache } from '@helix-agents/core';
    // Prompt caching is opt-in. Supply a provider-specific strategy from
    // `@helix-agents/core` that matches your model (here: Anthropic).
    cache: anthropicCache({ ttl: '1h' }),
  },
});

Provider-Specific Options ​

Important: Reasoning features require AI SDK provider packages v3+:

  • @ai-sdk/openai@^3.0.0
  • @ai-sdk/anthropic@^3.0.0

Earlier v2.x versions use specificationVersion: "v2" which triggers compatibility mode in AI SDK v6, stripping reasoning features.

Enable features specific to certain providers:

typescript
// OpenAI o-series reasoning
const agent = defineAgent({
  name: 'reasoning-agent',
  systemPrompt: 'Solve complex problems step by step.',
  llmConfig: {
    model: openai('gpt-6-luna'),
    providerOptions: {
      openai: {
        reasoningSummary: 'detailed',
        reasoningEffort: 'high',
      },
    },
  },
});

// Anthropic extended thinking
const agent = defineAgent({
  name: 'thinking-agent',
  systemPrompt: 'Think through problems carefully.',
  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
        },
      },
    },
  },
});

Dynamic Configuration ​

Override LLM config based on agent state:

typescript
const agent = defineAgent({
  name: 'adaptive-agent',
  stateSchema: z.object({
    complexity: z.enum(['simple', 'complex']),
    stepCount: z.number(),
  }),
  llmConfig: {
    model: openai('gpt-6-luna'),
  },
  llmConfigOverride: (customState, stepCount) => {
    // Use more powerful model for complex tasks
    if (customState.complexity === 'complex') {
      return {
        model: openai('gpt-6.1-sol'),
        maxOutputTokens: 8192,
      };
    }

    // Think less per step once the work is under way
    if (stepCount > 5) {
      return { providerOptions: { openai: { reasoningEffort: 'low' } } };
    }

    return {};
  },
});

Prompt Caching ​

Prompt caching reduces cost and latency by reusing cached prompt prefixes across LLM calls. It is opt-in and provider-specific: you choose a cache strategy that matches your model and set it on llmConfig.cache. When cache is unset, no caching is applied. The framework performs no provider detection — pick the helper that matches your model.

typescript
import { anthropicCache } from '@helix-agents/core';

const agent = defineAgent({
  name: 'cached-agent',
  systemPrompt: 'You are a helpful assistant with detailed instructions...',
  llmConfig: {
    model: anthropic('claude-sonnet-5-5'),
    cache: anthropicCache({ ttl: '1h' }),
  },
});

Shipped strategies ​

HelperProviderWhat it does
anthropicCache({ ttl })Anthropic (Claude)Places cache_control markers on the last system message (whose cached prefix also covers the tool definitions) and a contiguous run covering the latest turn's tool-result batch. ttl is '<N>m' / '<N>h' (default '1h'), passed through to the provider for validation.
openaiCache()OpenAI (GPT-4o, o-series, …)Sets providerOptions.openai.promptCacheKey from the session ID, giving repeated requests in a session cache affinity.
xaiCache()xAI / GrokSets the x-grok-conv-id header from the session ID for conversation-level cache routing.

Google / Gemini needs no helper — it uses implicit prefix caching server-side, so there is nothing to annotate. There is intentionally no googleCache().

Each helper only does its provider's thing; the framework never detects providers, so the helper you choose must match the model you configured.

How anthropicCache places breakpoints ​

anthropicCache({ ttl }) marks, up to Anthropic's four-breakpoint limit:

  1. The last leading system message — the deep, stable anchor. Because the provider renders tools → system → messages, this breakpoint's cached prefix already includes every tool definition, so tools are cached without spending a dedicated breakpoint on them.
  2. A contiguous run covering the latest turn's trailing tool-result batch, from its first block. When an assistant turn emits parallel tool calls, the AI SDK groups the results into a single Anthropic user turn with multiple tool_result blocks. Marking only the last block makes Anthropic write nothing (cache_creation_input_tokens = 0) for the whole request — so every result of the batch must be covered, not just the last. A batch up to three wide is covered in full (the newest result is marked, so the next step reads it); a wider fan-out anchors the write at the batch's first blocks.

The strategy also skips past any interior tool-result run the budget window would only partially cover, so a later, narrow turn never strands an earlier batch behind a marked block. One residual remains: Anthropic only looks back ~20 content blocks from a breakpoint to find a prior cache entry, so an extremely wide single turn (more than ~20 blocks) can still push older history out of read range. Narrow fan-outs (1–3 parallel tools) cache with no residual.

Composing strategies ​

cache also accepts an array of strategies, applied in order — useful for layering a provider strategy with your own:

typescript
llmConfig: {
  model: anthropic('claude-sonnet-5-5'),
  cache: [anthropicCache(), myCustomStrategy],
}

Writing a custom strategy ​

A CacheStrategy is a pure function (CacheRequest) => CacheResult. It can annotate messages/tools (returned in messages / tools) or add request-level options (providerOptions / headers). The CacheStrategy, CacheRequest, and CacheResult types — and applyCacheStrategies, the provider-agnostic folder the runtimes use — are exported from @helix-agents/core:

typescript
import type { CacheStrategy } from '@helix-agents/core';

const tagConversation: CacheStrategy = ({ context }) => ({
  headers: { 'x-my-conv-id': context.sessionId },
});

Cache Token Tracking ​

Cache hit/miss metrics flow through the standard token usage pipeline:

typescript
// In afterLLMCall hook
hooks: {
  afterLLMCall: (payload, ctx) => {
    if (payload.usage) {
      console.log(`Prompt tokens: ${payload.usage.promptTokens}`);
      console.log(`Cached tokens: ${payload.usage.cachedTokens}`);      // Cache hits
      console.log(`Cache writes: ${payload.usage.cacheWriteTokens}`);   // New cache entries
    }
  },
}

Cache tokens also appear in:

  • Usage tracking: The TokenCounts rollup includes cached and cacheWrite fields
  • Langfuse tracing: Mapped to cache_read_input_tokens and cache_creation_input_tokens

No step_end chunks with this adapter (RM-48)

The step_end stream chunk type has cachedTokens and cacheWriteTokens usage fields, but this adapter never reports step end: onStepEnd never fires (RM-48). Every runtime writes its own step_start as each LLM attempt's first chunk, whatever the adapter; step_end is written only when the adapter reports the step's end — on every runtime, DBOS included — so with this adapter no step_end reaches the stream. Per-step usage (cache tokens included) still reaches the StepResult, afterLLMCall, the usage rollup and Langfuse above, just not the stream. See RM-48 and RM-49 in the conformance findings.

Streaming ​

The adapter supports real-time streaming:

typescript
// Streaming happens automatically in execute()
const handle = await executor.execute(agent, 'Research AI agents');

// Get the stream
const stream = await handle.stream();
if (stream) {
  for await (const chunk of stream) {
    switch (chunk.type) {
      case 'text_delta':
        process.stdout.write(chunk.delta);
        break;
      case 'thinking':
        console.log('[Thinking]', chunk.content);
        break;
      case 'tool_start':
        console.log(`[Tool: ${chunk.toolName}]`);
        break;
    }
  }
}

Chunk Mapping ​

The adapter maps Vercel AI SDK stream parts to framework chunks:

Vercel AI SDKFrameworkNotes
text-deltatext_deltaGenerated text tokens
reasoning-deltathinkingReasoning/thinking content
tool-input-starttool_startTool call begins
tool-calltool_startComplete tool call
tool-resulttool_endTool result
errorerrorGeneration error

Thinking/Reasoning Content ​

Both Anthropic and OpenAI support reasoning features:

Anthropic Extended Thinking ​

typescript
const agent = defineAgent({
  name: 'claude-thinker',
  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
          display: 'summarized', // readable thinking text (needs @ai-sdk/anthropic 3.0.61+)
        },
      },
    },
  },
});

// Thinking content streams via 'thinking' chunks
for await (const chunk of stream) {
  if (chunk.type === 'thinking') {
    console.log('[Claude thinking...]', chunk.content);
  }
}

On Claude 5.5 models (and Fable / Mythos 5.x) thinking display defaults to omitted: thinking happens and is billed, but its blocks stream with empty text, so thinking chunks carry no content. Ask for display: 'summarized' to get a readable summary. @ai-sdk/anthropic sends display from 3.0.61; earlier versions drop the key, and the text stays empty.

OpenAI Reasoning ​

typescript
const agent = defineAgent({
  name: 'openai-reasoner',
  llmConfig: {
    model: openai('gpt-6-luna'),
    providerOptions: {
      openai: {
        reasoningSummary: 'detailed', // or 'concise'
        reasoningEffort: 'high', // or 'medium', 'low'
      },
    },
  },
});

reasoningEffort drives latency and cost. On a short tool loop with one logic puzzle (two model calls), GPT-6 Luna took 74–180 s at 'high' and 22–64 s at 'medium' (measured 2026-10-19). At 'low' it skipped reasoning in 2 of 3 runs, and so returned no summary. Pick the lowest effort that still reasons on your task, and size request and step timeouts for it. On trivial prompts, reasoning models (and Claude's adaptive thinking) may not reason at all, so they produce no thinking content.

Model capabilities ​

The adapter tells the framework what the configured model supports (resolveCapabilities), and the framework picks the forced-completion strategy from it: forced_tool (a named tool choice) when the model supports forced tool choice, else native_output (native structured output). Model knowledge lives only in this package: core never looks at a model id.

Profiles ​

ProfileforcedToolChoicenativeStructuredOutputhistoryBoundThinkingStrategy
legacytruefalsefalseforced_tool
anthropic-structuredtruetruefalseforced_tool
anthropic-5.5falsetruetruenative_output

The Anthropic table (ANTHROPIC_MODEL_TABLE, exported):

ProfileModel ids
anthropic-5.5claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5, claude-fable-5-1, claude-mythos-5-1
anthropic-structuredclaude-opus-5, claude-sonnet-5, claude-fable-5, claude-mythos-5, claude-mythos-preview, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-opus-4-5, claude-sonnet-4-6, claude-sonnet-4-5, claude-haiku-4-5
legacyclaude-opus-4-1, claude-opus-4-0, claude-opus-4, claude-sonnet-4-0, claude-sonnet-4, claude-3-7-sonnet, claude-3-5-sonnet, claude-3-5-haiku, claude-3-opus, claude-3-sonnet, claude-3-haiku

nativeStructuredOutput only matters when forcedToolChoice is false, so every anthropic-structured model sends the same forced request as before. Every model from another provider (OpenAI, Google, Mistral, …) resolves to legacy with source: 'default': no change to its forced requests. Independently of the profile, reasoning replay changed on every model: see Preserved thinking.

Model identity. The resolver reads the model's provider and id (model.provider / model.modelId, or the gateway string) and normalises the id before matching:

  • a gateway prefix (anthropic/claude-sonnet-5-5, as a string or an object model id) is dropped;
  • a Bedrock id loses its anthropic. prefix with any region prefix (us., eu., apac., global., us-gov.) and its version suffix (-v1:0, -v1:0:200k);
  • a Vertex @20251101 and an API -20251001 date suffix are dropped;
  • an OpenRouter variant (:beta, :thinking) is dropped;
  • dotted versions (claude-sonnet-4.5, as AI Gateway spells them) become dashed.

A model is treated as Anthropic when its normalised id starts with claude- or its provider id contains anthropic. A custom-named @ai-sdk/anthropic provider is recognised only when its name contains anthropic: use createAnthropic({ name: 'anthropic-litellm', baseURL }), not { name: 'litellm' }. Under a name without it, an alias id is other (legacy): no early refusal, no Anthropic schema normalisation or structuredOutputMode, and a 5.5-generation backend rejects every forced call: without an override with a capabilities hint (the call is forced_tool), and even with an override (the native_output request then lacks structuredOutputMode, so @ai-sdk/anthropic falls back to sending the format as a forced json tool, and no hint is added). The providerOptionsName getter is not a signal, because non-Anthropic models (@ai-sdk/openai's completion model, @ai-sdk/openai-compatible, @ai-sdk/deepseek) have it too. A proxy reached through a non-Anthropic provider (for example @ai-sdk/openai-compatible) with an alias id resolves to legacy; map it with an override. A row matches its exact id or that id followed by a date or other non-version suffix; an id whose remainder starts with another version number (claude-opus-5-6, claude-sonnet-4-7) is a different model and does not inherit a neighbouring row.

Unknown Anthropic models are unresolved. An Anthropic model with no row (a model released after your version of this package, a newer point release, or a proxy alias such as sonnet-latest served through an @ai-sdk/anthropic provider whose name contains anthropic) resolves to unresolved. An agent with outputSchema on such a model is refused before its run starts with framework_completion_strategy_unavailable, because the framework cannot know whether a forced call would work (see Unknown models). An agent without outputSchema (or with maxCompletionRetries: 0, which never makes a forced call) is never refused and runs as before. Upgrade this package, or declare the model with an override.

Overriding capabilities ​

new VercelAIAdapter({ capabilities }) takes a resolver that runs whenever the framework resolves capabilities (see below for which runtimes). It receives the call's LLMConfig and the built-in resolution; return a CapabilityResolution to replace it (a resolved one is recorded with source: 'override'), or undefined to keep the built-in one.

A LiteLLM (or any proxy) alias hides the real model from the adapter, so map the alias yourself:

typescript
import { createAnthropic } from '@ai-sdk/anthropic';
import { defineAgent, modelIdOf } from '@helix-agents/core';
import { VercelAIAdapter, resolveAnthropicCapabilities } from '@helix-agents/llm-vercel';
import { z } from 'zod';

// LiteLLM serves Anthropic models under its own aliases.
const litellm = createAnthropic({
  baseURL: process.env.ANTHROPIC_BASE_URL, // e.g. https://litellm.example.com/v1
  apiKey: process.env.ANTHROPIC_API_KEY,
});

// alias → the real Anthropic model id behind it
const LITELLM_ALIASES: Record<string, string> = {
  'sonnet-latest': 'claude-sonnet-5-5',
  'opus-stable': 'claude-opus-5',
};

const adapter = new VercelAIAdapter({
  capabilities: (config) => {
    const alias = modelIdOf(config.model);
    const real = alias ? LITELLM_ALIASES[alias] : undefined;
    // Delegate to the built-in table for the real id; anything else falls through.
    return real ? resolveAnthropicCapabilities(real) : undefined;
  },
});

const reviewer = defineAgent({
  name: 'reviewer',
  systemPrompt: 'Review the draft.',
  outputSchema: z.object({ verdict: z.enum(['approve', 'revise']), notes: z.string() }),
  llmConfig: { model: litellm('sonnet-latest') }, // anthropic-5.5 → native_output
});

A model newer than the table, before a release adds it, is declared with one of the exported PROFILES:

typescript
import { PROFILES } from '@helix-agents/llm-vercel';

const adapter = new VercelAIAdapter({
  capabilities: (config) =>
    modelIdOf(config.model) === 'claude-opus-5-6'
      ? {
          status: 'resolved',
          capabilities: PROFILES['anthropic-5.5'],
          profile: 'anthropic-5.5',
          source: 'override',
        }
      : undefined,
});

Keep the resolver synchronous and pure (no I/O): on runtime-js, the CF DO, CF Workflows and Temporal it runs on every LLM call (on CF Workflows inside the step.do body, on Temporal inside the runLLMStep activity). DBOS does not call it yet (support is planned), so an override has no effect there until then. A resolver that throws is treated as unresolved for that call. The same resolver can declare that a non-Anthropic model supports native structured output (nativeStructuredOutput: true, forcedToolChoice: false); its forced calls then use the AI SDK's structured output for that provider, with the schema converted unchanged.

resolveVercelCapabilities(config, override?), normalizeModelIdentity(model) and resolveAnthropicCapabilities(id) (which takes an already normalised id) are exported for custom resolvers.

Native structured output (native_output) ​

For a native_output forced call the framework sends the completion tool's input schema as outputFormat. The adapter:

  • converts it with z.toJSONSchema (input shape) and, for Anthropic models, normalises it to the subset the API accepts: numeric bounds (minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf), array maxItems, an array minItems above 1 and a pattern that uses lookaround (from z.email(), z.hostname(), z.iso.duration() or a custom regex; logged once with its path) are dropped, oneOf becomes anyOf, every object gets additionalProperties: false, and the root $schema is dropped. The framework still validates the answer against the full Zod schema, so a dropped constraint is enforced as a recoverable miss;
  • refuses what it cannot represent with framework_completion_schema_unsupported (through checkOutputFormat at run entry, and again if such a call is made): z.record (additionalProperties: <schema> or true), tuples (prefixItems, array-form items), allOf of object schemas (.and()), and schemas z.toJSONSchema cannot convert (z.date(); this one on every model family, including a non-Anthropic model declared native-only through the capabilities override);
  • passes it to streamText as output: Output.object(...), and for every Anthropic-family model (Bedrock and Vertex included) sets providerOptions.anthropic.structuredOutputMode: 'outputFormat'. For an @ai-sdk/anthropic-backed model with a custom provider name it also sets it under that provider's own options key (which @ai-sdk/anthropic merges over anthropic); other providers get no extra key, and whether they read anthropic.structuredOutputMode at all is up to them. Without it, @ai-sdk/anthropic sends structured output for a model it does not know as a hidden json tool with a forced tool choice, which the 5.5 generation rejects;
  • raises the call's maxOutputTokens to at least minOutputTokens (16,000): these models think on every call, and the provider's default for an unknown model is 4096.

The answer is read from the response text (result.output is never read); core parses it and turns it into the completion-tool call.

Provider version. @ai-sdk/anthropic is an optional peer dependency, >=3.0.38. 3.0.38 sends native structured output as the beta output_format field, which the 5.5 generation accepts (verified live, 2026-10-04); moving to the GA output_config.format is a follow-up for when the provider sends it. The adapter does not check the installed version at runtime: the peer-dependency range is the floor.

Only @ai-sdk/anthropic is verified for the 5.5 generation

A 5.5-generation model reached through @ai-sdk/amazon-bedrock, @ai-sdk/google-vertex, the AI Gateway or an OpenRouter provider resolves to anthropic-5.5 like the direct API, but whether those providers send Output.object as native structured output (rather than as a forced json tool, which the model rejects) has not been verified (FU-COMPLETION-BEDROCK-VERTEX-NATIVE-OUTPUT). If a forced call there fails with a provider_invalid_request about tool_choice, route the model through @ai-sdk/anthropic (directly or behind a proxy such as LiteLLM).

Preserved thinking (ordered parts) ​

For a response that contains reasoning, the adapter records the response's ordered parts on the step result (one reasoning part per thinking block with that block's own provider metadata, empty and redacted blocks included; the non-empty text parts; a reference for each tool call), and it replays an assistant message that has parts exactly in that order. An Anthropic block's signature (providerOptions.anthropic.signature) or redacted data (providerOptions.anthropic.redactedData) goes back with it; another provider's reasoning metadata (for example OpenAI's encrypted reasoning) is not sent back, because those APIs reject it. A tool call the parts do not reference is appended after them, so no call is lost. A message without parts (older rows, other adapters) replays as before. This applies to every model, not only the 5.5 generation: before, the blocks were merged into one and empty and redacted blocks were dropped, so in a thinking session with several, empty or redacted blocks the bodies of later requests change. If a response's content has a recognised part type in an unexpected shape, the adapter records no parts for that step and logs a warning, rather than persisting a partial list. See Preserved thinking.

Message Conversion ​

The adapter converts framework messages to Vercel AI SDK format:

Framework → Vercel AI SDK ​

typescript
// Framework format
const messages: Message[] = [
  { role: 'system', content: 'You are helpful.' },
  { role: 'user', content: 'Hello' },
  {
    role: 'assistant',
    content: 'I will search for that.',
    toolCalls: [{ id: 'tc1', name: 'search', arguments: { q: 'test' } }],
  },
  {
    role: 'tool',
    toolCallId: 'tc1',
    toolName: 'search',
    content: JSON.stringify({ results: [] }),
  },
];

// Automatically converted to Vercel AI SDK ModelMessage[]

The conversion handles:

  • System, user, and assistant messages
  • Tool calls in assistant messages
  • Tool results in tool messages
  • Mixed text + tool call content

The adapter also coerces non-object tool-call inputs to objects — at ingestion, in stream-chunk mapping, and during message conversion (which heals any string arguments already persisted in durable stores before this guarantee existed). This complements the runtime-agnostic coercion in core's planStepProcessing(); together they ensure a tool input is never replayed as a non-object tool_use.input (which the provider rejects). Structured output is left to core's schema-aware repair, so non-object outputSchemas are preserved. See Robust tool inputs.

Tool Conversion ​

Framework tools (with Zod schemas) are converted to Vercel AI SDK tools:

typescript
// Framework tool
const searchTool = defineTool({
  name: 'search',
  description: 'Search the web',
  inputSchema: z.object({
    query: z.string(),
    limit: z.number().optional(),
  }),
  execute: async (input, ctx) => {
    // ...
  },
});

// Automatically converted to Vercel AI SDK tool format
// The Zod schema is passed directly (AI SDK 5.x supports Zod)

Error Handling ​

The adapter handles errors gracefully:

typescript
const adapter = new VercelAIAdapter({
  logger: console, // Optional: log warnings
});

// Errors are returned as ErrorStepResult, not thrown
const result = await adapter.generateStep(input);

if (result.type === 'error') {
  console.error('LLM error:', result.error.message);
  // Framework handles this appropriately
}

Retry Configuration ​

Configure retries for transient failures:

typescript
const agent = defineAgent({
  llmConfig: {
    model: openai('gpt-6-luna'),
    maxRetries: 5, // Retry up to 5 times on transient errors
  },
});

maxRetries (default 3, 0 disables retries) covers two cases, each with its own budget and the same exponential backoff (2 s, factor 2):

  • Retryable provider responses (429, 5xx, …): retried by the AI SDK, which also honours a Retry-After header.
  • A dropped connection before any model output: retried by the adapter, on every runtime (on DBOS always 3 times: MA-27). The AI SDK retries Node's fetch failed on its own but not workerd's Network connection lost., nor any drop after the response started (Node's terminated); the adapter covers those, so Workers match Node. A retried attempt streamed nothing, so the run never sees it; each retry is logged as a warning.

A drop after the model started streaming is not retried here (its output already reached the stream); see Error handling: connection drops.

Logger Integration ​

Pass a custom logger for debug output:

typescript
import { VercelAIAdapter } from '@helix-agents/llm-vercel';

const logger = {
  debug: (msg: string) => console.debug(`[DEBUG] ${msg}`),
  info: (msg: string) => console.info(`[INFO] ${msg}`),
  warn: (msg: string) => console.warn(`[WARN] ${msg}`),
  error: (msg: string) => console.error(`[ERROR] ${msg}`),
};

const adapter = new VercelAIAdapter({ logger });

Complete Example ​

typescript
import { defineAgent, defineTool } from '@helix-agents/core';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

// Create adapter
const adapter = new VercelAIAdapter();

// Define tool
const searchTool = defineTool({
  name: 'web_search',
  description: 'Search the web for information',
  inputSchema: z.object({
    query: z.string().describe('Search query'),
  }),
  outputSchema: z.object({
    results: z.array(z.string()),
  }),
  execute: async (input) => {
    // Simulate search
    return { results: [`Result for: ${input.query}`] };
  },
});

// Define agent
const ResearchAgent = defineAgent({
  name: 'researcher',
  description: 'Researches topics using web search',
  systemPrompt: `You are a research assistant.
Use the web_search tool to find information.
Summarize your findings clearly.`,
  tools: [searchTool],
  outputSchema: z.object({
    summary: z.string(),
    sources: z.array(z.string()),
  }),
  llmConfig: {
    model: openai('gpt-6-luna'),
    maxOutputTokens: 2048,
  },
});

// Create executor
const executor = new JSAgentExecutor(
  new InMemoryStateStore(),
  new InMemoryStreamManager(),
  adapter
);

// Execute
async function main() {
  const handle = await executor.execute(
    ResearchAgent,
    'What are the latest developments in AI agents?'
  );

  // Stream output
  const stream = await handle.stream();
  if (stream) {
    for await (const chunk of stream) {
      if (chunk.type === 'text_delta') {
        process.stdout.write(chunk.delta);
      }
    }
  }

  // Get result
  const result = await handle.result();
  console.log('\n\nResult:', result.output);
}

main();

Limitations ​

Model-Specific Features ​

Not all features work with all models:

  • Thinking/reasoning: Only Anthropic Claude and OpenAI o-series
  • Tool calling: Most models, but check provider docs
  • JSON mode: Provider-specific implementation
  • Forced completion on the 5.5 generation: needs the native_output strategy, which the DBOS runtime does not use yet (see Finishing Agents)

Token Counting ​

The adapter doesn't provide token counting. Use provider SDKs directly for token estimation.

Image/Multimodal ​

The framework supports file uploads (images, PDFs, etc.) via the files field in AgentInput. Files are converted to ContentPart[] alongside the text message and passed to the LLM. This works across all runtimes (JS, Temporal, Cloudflare).

typescript
await executor.execute(
  agent,
  {
    message: 'Describe this image',
    files: [
      {
        data: base64EncodedData,
        mediaType: 'image/png',
        filename: 'screenshot.png', // optional
      },
    ],
  },
  { sessionId }
);

Next Steps ​

Released under the MIT License.