@crux/ai
Vercel AI SDK adapter — generate, stream, dense embedding helpers, reranking helpers, and the SdkGateway test seam.
import { generate, stream, embedding, reranker, aiSdkProviderRuntime } from '@crux/ai'
import { generateObjectFn, generateTextFn, createCruxAi, CruxAIError } from '@crux/ai'
import { resolve } from '@crux/ai/agent'For usage examples and detailed walkthrough, see the Execution guide.
Architecture
@crux/ai exports aiSdkProviderRuntime, a loop-owned provider runtime for the Vercel AI SDK. Core owns all policy (prompt resolution, fallback()/router()/cascade() routing, validation retry, the Safety session for constraints/guardrails, the tool-approval protocol, instrumentation, timeouts); the adapter delegates mechanics to AI SDK natives (stopWhen, prepareStep, experimental_repairText, native needsApproval, abortSignal, experimental_transform for streaming guardrails). The bound runtime also exposes AI SDK embedding and reranking extensions through the same SdkGateway seam.
The only module that calls AI SDK functions is the SdkGateway — inject a scripted one via createCruxAi to test without module mocks.
Internally, @crux/ai keeps AI SDK request planning and result projection in a private call-plan codec. The executor asks the codec for a generateText, generateObject, streamText, streamObject, or replay plan, invokes the selected SdkGateway method, then decodes or attaches the raw SDK result. This is not a public extension API; it exists to keep the gateway seam stable and the adapter behavior testable.
Cross-adapter parity: switching a prompt between @crux/ai and a native adapter (@crux/openai, @crux/anthropic, @crux/google) changes nothing observable except model behavior itself — same default tool-loop budget (maxSteps: 10), same corrective-retry messages, same approval suspend/resume protocol, same canonical tool.call / tool.args / raw and model-facing tool.result telemetry, same routing and audit metadata. This is enforced by a cross-dialect parity suite in @crux/core. Note this differs from the raw AI SDK's own generateText default (stopWhen: stepCountIs(1)): Crux adapters loop tools by default; pass maxSteps: 1 for single-step behavior.
Hallucinated tool calls are part of that parity: where the raw AI SDK throws NoSuchToolError/InvalidToolInputError and aborts the loop, @crux/ai uses the SDK's native experimental_repairToolCall to feed the model the same error tool result core-driven adapters produce ({"error":"Tool \"x\" not found"}), so the model self-corrects and the loop continues. The SDK's tool-error taxonomy never reaches your code.
@crux/ai/agent
Use @crux/ai/agent for AI SDK-compatible agent frameworks that own their own model loop, such as Convex Agent or Mastra. It composes instructions through the normal core prompt pipeline, then wraps the model with AI SDK middleware when Crux execution hooks are installed.
import { resolve } from '@crux/ai/agent'
const { instructions, model } = await resolve(chatPrompt, {
model: languageModel,
input: { mode: 'support' },
tools: Object.keys(tools),
})The returned model reports generate/stream traces, stream progress, tool timing estimates, provider metadata cost, and parent-child links back to the prompt-resolution trace.
generate(prompt, options)
Execute a prompt using the Vercel AI SDK.
| Field | Type | Description |
|---|---|---|
prompt | Prompt | The prompt to execute |
options.model | LanguageModel | FallbackModel<LanguageModel> | AnyRouterModel<LanguageModel> | CascadeModel<LanguageModel> | AI SDK model or a fallback(), router(), or cascade() wrapper |
options.input | MergedInput<TOwnInput, TContexts> | Input values — merged across the prompt's own schema and every context's input schema |
options.tools | ToolSet? | Additional tools to merge at call time |
options.toolMiddleware | ToolMiddleware | readonly ToolMiddleware[]? | Call-site tool wrappers. See Tool Middleware. |
options.toolChoice | ToolChoice<ToolSet>? | Tool choice strategy |
options.stopWhen | StopCondition<ToolSet> | StopCondition<ToolSet>[]? | Custom stop condition(s), replacing the maxSteps budget (e.g. hasToolCall(name)) |
options.maxSteps | number? | Maximum tool-loop steps, identical across all Crux adapters. Enforced natively via AI SDK stopWhen. Default: 10 |
options.activeTools | string[]? | Restrict available tools |
options.messages | ModelMessage[]? | Explicit AI SDK message history. Used for approval resume and advanced chat-loop control. |
options.tokenBudget | number? | Token budget for context dropping |
options.timeoutMs | number? | Hard timeout for the provider call. Crux passes an AbortSignal to the AI SDK and closes the generation span with AbortError if the call does not settle. |
options.validationRetry | ValidationRetryOptions? | Retry structured output on validation failure |
options.constraints | Constraint[]? | Per-call semantic constraints (highest precedence in the safety merge) |
options.constraintMaxRetries | number? | Shared cap on total constraint retries across all constraints |
options.guardrails | Guardrail[]? | Per-call guardrails (highest precedence in the safety merge) |
Returns:
- With
outputschema:GenerateObjectResult<T>—.object(typed) - Without
output:GenerateTextResult—.text
stream(prompt, options)
Stream a prompt execution. Same options as generate(), except cascade() is not supported (cascade needs full results for tier evaluation). Streaming guardrails (stream: { buffer } / onChunk) execute automatically on text streams — mounted through the AI SDK's experimental_transform, so holds, mid-stream transforms, and blocks reach textStream consumers; constraints run report-only at end-of-stream and audits land on the completion meta.
| Field | Type | Description |
|---|---|---|
prompt | Prompt | The prompt to execute |
options.model | LanguageModel | FallbackModel<LanguageModel> | AnyRouterModel<LanguageModel> | AI SDK model or fallback() / router() wrapper |
options.input | MergedInput<TOwnInput, TContexts> | Input values — merged across the prompt's own schema and every context's input schema |
options.tools | ToolSet? | Additional tools to merge at call time |
options.toolMiddleware | ToolMiddleware | readonly ToolMiddleware[]? | Call-site tool wrappers. See Tool Middleware. |
options.toolChoice | ToolChoice<ToolSet>? | Tool choice strategy |
options.stopWhen | StopCondition<ToolSet> | StopCondition<ToolSet>[]? | Custom stop condition(s), replacing the maxSteps budget |
options.maxSteps | number? | Maximum tool-loop steps (default 10), identical across all Crux adapters |
options.activeTools | string[]? | Restrict available tools |
options.messages | ModelMessage[]? | Explicit AI SDK message history. Used for approval resume and advanced chat-loop control. |
options.tokenBudget | number? | Token budget for context dropping |
Returns:
- With
output:ObjectStreamResult<T>—.partialObjectStream - Without
output:TextStreamResult—.textStream
const result = await stream(editDraft, {
model: openai('gpt-4o'),
input: { instruction: 'Fix the intro' },
})
for await (const partial of result.partialObjectStream) {
console.log(partial)
}Stream results additionally carry a typed completion promise:
const result = await stream(chatPrompt, { model, input: { message } })
for await (const delta of result.textStream) process.stdout.write(delta)
const meta = await result.completion
// { usage, cost, finishReason, text, streaming: { ttftMs, tokensPerSecond, totalChunks } }createCruxAi(options?)
Build a @crux/ai instance bound to a specific SdkGateway. The package-level exports are an instance created with the live gateway; reach for this when you need the test seam.
| Field | Type | Description |
|---|---|---|
options.gateway | SdkGateway? | The AI SDK gateway to execute against. Defaults to liveSdkGateway() |
Returns: CruxAi — { generate, stream, generateTextFn, generateObjectFn, embedding, reranker }
import { createCruxAi } from '@crux/ai'
const ai = createCruxAi({ gateway: myScriptedGateway })
const result = await ai.generate(myPrompt, { model, input })CruxAIError
Coded error wrapper. generate()/stream() propagate underlying errors unchanged (existing ValidationExhaustedError/AggregateError handling keeps working); use CruxAIError.classify(error) at your boundary for a stable, machine-readable code.
| Member | Type | Description |
|---|---|---|
code | 'timeout' | 'validation_exhausted' | 'provider' | 'aborted' | Stable failure category |
classify | (error: unknown) => CruxAIError (static) | Classify any error, preserving cause |
generateObjectFn
Pre-bound GenerateObjectFn for @crux/core APIs (judges, extraction). Pass model per-call.
Signature: GenerateObjectFn — (opts: { model, system?, prompt, schema }) => Promise<{ object: T }>
generateObjectFn shares the same AI SDK structured-attempt mechanics used by generate() for prompts with an output schema: provider-specific schema sanitation, core-backed experimental_repairText, and router/cascade model resolution all happen before the helper returns { object }.
const judge = llmJudge({ generate: generateObjectFn, model, ... })For a GenerateObjectFn that also runs through full adapter prompt execution semantics, use createGenerateObjectFnFromGenerate(generate) from @crux/core/compaction.
generateTextFn
Pre-bound GenerateTextFn for @crux/core APIs (summarization, compaction).
Signature: GenerateTextFn — (opts: { model, system?, prompt }) => Promise<{ text: string }>
reranker(config)
Create a retrieval reranker backed by AI SDK rerank().
const docsReranker = reranker({
name: 'docs-reranker',
model: openai.reranking('gpt-4.1-mini'),
topN: 5,
})Use it with retriever({ rerank }) when you want model-based reordering after raw retrieval and before prompt injection or tool output.
| Field | Type | Description |
|---|---|---|
name | string | Stable reranker identifier |
model | RerankingModel | AI SDK reranking model |
topN | number? | Limit reranked output |
maxRetries | number? | Retry budget for the rerank call |
document | (hit) => string? | Optional serializer for the text sent to the reranker |
embedding(config)
Create a dense Crux embedding backed by AI SDK embedMany().
const docsEmbedding = embedding({
name: 'docs-embedding',
model: openai.textEmbeddingModel('text-embedding-3-small'),
dimensions: 1536,
maxInputTokens: 8192,
})Use it with memory, indexers, or retrievers when you want the AI SDK model registry and provider abstraction, but still want a first-class Crux embedding object with batching and telemetry.
| Field | Type | Description |
|---|---|---|
name | string | Stable embedding identifier |
model | EmbeddingModel | AI SDK embedding model |
dimensions | number | Output vector dimensionality |
maxInputTokens | number | Per-input token ceiling |
batch.maxSize | number? | Top-level Crux batch size. Defaults to 100. |
batch.concurrency | number? | Top-level Crux batch concurrency. Defaults to 1. |
maxRetries | number? | Retry budget for AI SDK embedding calls |
maxParallelCalls | number? | Internal AI SDK split-call concurrency. Defaults to 1. |
headers | Record<string, string>? | Optional request headers |
providerOptions | Record<string, unknown>? | Provider-specific AI SDK options |
Tool approvals
When a tool with needsApproval (set directly or via approval middleware) fires, generation suspends: the result has _meta.finishReason === 'tool_approval_required', pendingApprovals carries the minted requests (with anti-forgery tokens), and messages ends with the approval-request message. Persist the messages, collect a decision, append a tool-approval-response, and call generate() again.
import { appendToolApprovalResponse } from '@crux/core/tool-middleware'
const first = await generate(assistant, { model, input, tools })
if (first.pendingApprovals?.length) {
const approval = first.pendingApprovals[0]
const resumed = await generate(assistant, {
model,
input,
tools,
messages: appendToolApprovalResponse(first.messages, {
approvalId: approval.approvalId,
approved: true,
approvalToken: approval.approvalToken,
}),
})
}The approved tool executes during resume, its result is replayed into the loop as a normal tool round, and generation continues. Denied tools produce an execution-denied output the model can reason about.
What is not exported
AI SDK helpers (tool, stepCountIs, hasToolCall) and AI SDK types are no longer re-exported — import them from 'ai' directly. Agent compositions come from @crux/core/agent or from executorAdapter(). The legacy toMessages/fromMessages/createAIExecutor exports were removed (RFC use-crux/crux#28).
Types
import type {
AIGenerateOptions,
AIEmbeddingConfig,
AIRerankerConfig,
CruxAi,
CruxAiOptions,
CruxAIErrorCode,
CruxStreamExtensions,
SdkGateway,
TextStreamResult,
ObjectStreamResult,
GenerateReturn,
StreamReturn,
} from '@crux/ai'Related
- Guide: Execution
- Reference: Retrieval
- Reference: Prompts
- Cookbook: Streaming with tools