Adapter Interface
Provider adapter abstraction for building AI provider integrations.
import { defineProviderRuntime } from '@crux/core/adapter'
import type {
CruxAdapter,
AdapterGenerateOptions,
AdapterStreamOptions,
AdapterGenerateResult,
AdapterResponse,
CallArgs,
StreamHandle,
ToolResultEntry,
SingleTurnRuntimeContract,
LoopOwnedRuntimeContract,
NativeTranscriptCodec,
} from '@crux/core/adapter'
import { transcriptCodecConformance } from '@crux/core/adapter/testing'Overview
The adapter package provides the public authoring surface for AI provider adapters. defineProviderRuntime() binds a stable runtime id to either turn, where Crux owns the provider step loop, or loop, where the SDK owns the loop and Crux steers policy around it. Both branches compile into the same execution engine and share the same policy modules (validation retry, tool instrumentation, approvals) plus the same per-call Safety session, so behavior does not diverge between runtimes.
Built-in adapters are built on this abstraction: @crux/openai, @crux/anthropic, and @crux/google export single-turn provider runtimes, while @crux/ai exports a loop-owned provider runtime for the Vercel AI SDK. Native provider packages own a NativeTranscriptCodec for provider message conversion, assistant text/tool-call extraction, and provider-specific tool-round appends. Core injects transcript-produced providerMessages into request builders and composes transcript.readAssistant(raw) with response-level metadata. Core owns the canonical Message[], CallArgs, AdapterResponse, ToolResultEntry, and small tool-output helpers those codecs share.
Which contract do I implement?
| Your SDK… | Runtime branch | Example |
|---|---|---|
| Exposes text/structured/stream chat calls and leaves tool execution to Crux | turn | @crux/openai, @crux/anthropic, @crux/google |
Runs its own multi-step tool loop (generateText with stopWhen, native tool execution) | loop | @crux/ai |
| Needs a bespoke non-chat boundary | core IR directly | custom provider |
Never implement more than one generation runtime for one SDK. In either branch, core owns the policy layer — prompt resolution, fallback()/router()/cascade() routing, validation retry, constraints, guardrails, the tool-approval protocol, instrumentation, timeouts, and observability. A provider runtime implements mechanics only, and should delegate to SDK-native capabilities wherever one exists.
Under the hood, turn compiles to AdapterSpec: core calls call() once per loop turn and appendToolRound() formats results. loop.bind() compiles to ExecutorSpec: the SDK owns the loop and core steers each step through a StepObserver:
run()runs the SDK loop, awaitingobserver.onStepFinish(step)after every step and applying the returnedStepDirectivebefore the next one —continue,stop, oramend(swap system prompt/active tools mid-loop, optionallyrefundStepso bookkeeping steps likeLoadSkilldon't consume budget).attemptStructured()makes exactly ONE structured-output attempt and returnsinvalidas a value instead of throwing — core owns the corrective-retry loop.stream()returns the SDK's stream result untouched plus a typedcompletion(). Whenrequest.safetyis set (aSafetyStream— text streams with streaming guardrails or constraints), the spec must drive it: feed every outgoing text delta, forwardemitcontent, swallowholds, surface a thrownGuardrailBlockedErroras a stream error, and callfinish()at end-of-stream, emitting the seal'spendingtail. With the AI SDK this is oneexperimental_transform.- Tool-approval needs surface as the
suspendedoutcome; core mints approval ids/tokens and owns resume.
Test provider runtimes through providerRuntime.create(scriptedClient) or package-level createX() factories. The scripted client should own only the remote SDK seam, while assertions inspect public adapter behavior and provider request bodies:
const adapter = myProviderRuntime.create(scriptedClient)
const result = await adapter.generate(myPrompt, { model: 'model-id', input })
expect(result.text).toBe('ok')
expect(scriptedClient.calls[0].messages).toEqual(expectedProviderMessages)Test executors with fakeExecutor() (a scripted in-memory reference implementation) and prove contract fidelity with executorSpecConformance():
import { executorAdapter, fakeExecutor, executorSpecConformance } from '@crux/core/adapter'
// Policy tests, zero SDK:
const fake = fakeExecutor({ structured: ['not json', '{"ok":true}'] })
const executor = executorAdapter(fake.spec)(fake.client)
// Contract suite for custom executor authors:
const violations = await executorSpecConformance(mySpec, myHarness)
expect(violations).toEqual([])defineProviderRuntime({ turn })
Compile a raw chat SDK provider spec into a Crux adapter runtime.
import { defineProviderRuntime } from '@crux/core/adapter'
export const myProviderRuntime = defineProviderRuntime({
id: 'my-provider',
turn: {
bind: (client) => ({
call: (request) => client.chat.create(request),
stream: (request) => client.chat.stream(request),
}),
request: buildRequest,
transcript: myTranscript,
response: {
meta: responseMeta,
text: structuredTextOverride,
},
stream: {
request: (request) => ({ ...request, stream: true }),
textDelta: (chunk) => chunk.delta?.text,
},
settings: mapSettings,
outputSchema: wrapOutputSchema,
},
})
export const createMyProvider = myProviderRuntime.createThe provider runtime owns provider wire facts: request bodies, SDK method binding, transcript conversion, assistant-turn extraction, response metadata extraction, stream delta extraction, settings/schema mapping, and provider-specific dependencies. Single-turn specs should use transcript: NativeTranscriptCodec plus response.meta(raw); response.text(raw, assistant) is only needed when the SDK exposes parsed structured output separately from assistant message text. Crux owns prompt resolution, tool execution, default canonical tool-round appends, validation retry, safety, memory capture, and observability.
request(args, ctx) receives the usual canonical CallArgs plus args.providerMessages, already produced by transcript.fromMessages(args.messages). Request builders should use providerMessages rather than re-running public fromMessages() wrappers.
Test transcript codecs directly with transcriptCodecConformance():
import { transcriptCodecConformance } from '@crux/core/adapter/testing'
const violations = transcriptCodecConformance({
name: 'my-provider transcript',
transcript: myTranscript,
canonicalMessages,
providerMessages,
decodedMessages,
rawAssistant,
assistant: { text: 'Checking.', toolCalls: [{ id: 'call_1', name: 'lookup', args: {} }] },
})
expect(violations).toEqual([])Provider packages may expose GenerateTextFn and GenerateObjectFn helpers when useful, but those helpers should stay explicitly smaller than adapter generate(): no prompt resolution, tool loop, safety, cassettes, memory capture, or instrumentation.
Internal IR: adapter(spec)
Create a core-driven adapter from a compiled AdapterSpec. Provider packages should implement defineProviderRuntime({ turn }); use this only when testing core adapter internals or building a bespoke runtime compiler.
| Parameter | Type | Description |
|---|---|---|
spec | AdapterSpec<TClient, TRawResponse, TRawStream, TExtra> | Provider-specific adapter specification |
Returns: (client: TClient) => CruxAdapter<TClient, TRawResponse, TRawStream, TExtra>
import { adapter } from '@crux/core/adapter'
const createMyAdapter = adapter({
providerId: 'my-provider',
async call(client, args) {
const response = await client.chat({
model: args.model,
system: args.system,
messages: args.messages,
...args.settings,
})
return {
raw: response,
extracted: {
text: response.content,
toolCalls: undefined,
usage: {
inputTokens: response.usage.input,
outputTokens: response.usage.output,
totalTokens: response.usage.input + response.usage.output,
},
finishReason: response.stop_reason,
responseId: response.id,
actualModelId: response.model,
},
}
},
async stream(client, args) {
const stream = await client.chatStream({ model: args.model, messages: args.messages })
return {
rawStream: stream,
extractTextDelta: (chunk) => chunk.delta?.text,
completion: async () => undefined,
}
},
appendToolRound(messages, response, results) {
return [
...messages,
{ role: 'assistant', content: response.text },
...results.map((r) => ({ role: 'tool' as const, content: r.content })),
]
},
mapSettings(settings) {
return {
temperature: settings.temperature,
max_tokens: settings.maxTokens,
top_p: settings.topP,
}
},
})
// Create an adapter instance
const adapter = createMyAdapter(myClient)
const result = await adapter.generate(myPrompt, { model: 'my-model', input: { topic: 'AI' } })AdapterSpec<TClient, TRawResponse, TRawStream, TExtra>
The core-step execution IR produced by the turn provider-runtime branch.
| Type Parameter | Description |
|---|---|
TClient | The provider's SDK client type |
TRawResponse | The provider's raw API response type |
TRawStream | The provider's raw stream type |
TExtra | Provider-specific options (e.g., tool_choice for Anthropic) |
| Method | Signature | Description |
|---|---|---|
providerId | string | Provider identifier for adaptation matching (e.g., 'anthropic', 'openai') |
call | (client, args: CallArgs<TExtra>) => Promise<{ raw, extracted }> | Execute a non-streaming API call |
stream | (client, args: CallArgs<TExtra>) => Promise<StreamHandle<TRawStream>> | Execute a streaming API call |
appendToolRound | (messages, response, results) => Message[] | Format tool results for the next tool loop turn |
mapSettings | (settings: GenerationSettings) => Record<string, unknown> | Map canonical settings to provider-native field names |
sanitizeToolSchema? | (schema) => Record<string, unknown> | Optional. Post-process JSON Schema output for this provider. |
wrapOutputSchema? | (schema: ZodType) => Record<string, unknown> | Optional. Convert structured output schema to provider-native params. |
CruxAdapter<TClient, TRawResponse, TRawStream, TExtra>
The adapter interface returned by the factory.
| Property / Method | Type | Description |
|---|---|---|
providerId | string | Provider identifier from the spec |
generate(prompt, opts) | Promise<AdapterGenerateResult<TRawResponse>> | Execute a prompt (non-streaming) with automatic tool loop |
stream(prompt, opts) | Promise<StreamHandle<TRawStream>> | Execute a prompt (streaming) |
parallel | function | Run multiple agents concurrently |
pipeline | function | Chain agents sequentially |
consensus | function | Run agents and pick a winner via voting |
swarm | function | Run a swarm of agents with peer-to-peer routing |
CallArgs<TExtra>
Canonical args assembled by the adapter from prompt resolution. Passed to spec.call() and spec.stream().
| Field | Type | Description |
|---|---|---|
model | string | Model identifier |
system | string | undefined | System message |
systemBlocks | readonly SystemBlock[] | undefined | System message blocks with provider-level caching hints. Joining block.text with \n\n produces system. |
messages | Message[] | Conversation messages |
settings | Record<string, unknown> | Mapped generation settings |
schema | ZodType | undefined | Output schema (for structured output) |
schemaParams | Record<string, unknown> | undefined | Provider-native schema params |
tools | Array<{ name, description, parameters, execute }> | undefined | Tool definitions |
extra | TExtra | Provider-specific options |
AdapterResponse
Canonical response extracted by the adapter from the provider's raw response. Used by the tool loop to determine if another iteration is needed.
| Field | Type | Description |
|---|---|---|
text | string | Extracted text |
toolCalls | Array<{ id, name, args }> | undefined | Tool call requests |
usage | { inputTokens, outputTokens, totalTokens } | Token usage |
finishReason | string | undefined | Why generation stopped |
responseId | string | undefined | Provider response ID |
actualModelId | string | undefined | Actual model used (may differ from requested) |
StreamHandle<TRawStream>
Stream handle returned by the adapter's stream method.
| Field | Type | Description |
|---|---|---|
rawStream | TRawStream & AsyncIterable<unknown> | The raw SDK stream |
extractTextDelta | (chunk: unknown) => string | undefined | Extract text delta from a stream chunk |
completion | () => Promise<TraceMeta | undefined> | Get completion metadata after stream ends |
AdapterGenerateOptions<TExtra>
Options for adapter generate() calls.
| Field | Type | Description |
|---|---|---|
model | string | Model identifier |
input | Record<string, unknown>? | Input for the prompt |
provider | string? | Provider identifier for adaptation matching |
tokenBudget | number? | Token budget for system message |
maxSteps | number? | Maximum tool loop iterations. Default: 10. |
settings | GenerationSettings? | Additional generation settings (highest precedence) |
extra | TExtra? | Provider-specific options |
messages | Message[]? | Additional messages to prepend (e.g., conversation history) |
validationRetry | ValidationRetryOptions? | Retry structured output on Zod validation failure |
AdapterGenerateResult<TRawResponse>
| Field | Type | Description |
|---|---|---|
raw | TRawResponse | The raw SDK response |
text | string | Extracted text |
_meta | TraceMeta | Normalized metadata (usage, finish reason, tool calls, etc.) |
steps | number | Number of tool loop iterations performed |
ToolResultEntry
Tool result to feed back into the next tool loop turn.
| Field | Type | Description |
|---|---|---|
toolCallId | string | The tool call ID this result corresponds to |
name | string | Tool name |
content | string | Serialized result content |
isError | boolean? | Whether this result represents an error |
Internal IR: executorAdapter(spec)
Create an SDK-loop adapter from a compiled ExecutorSpec. Provider packages should implement defineProviderRuntime({ loop }); use this only when testing executor internals or building a bespoke runtime compiler.
| Parameter | Type | Description |
|---|---|---|
spec | ExecutorSpec<TClient, TModel, TRawResponse, TRawStream> | Executor specification |
generate() accepts ExecutorGenerateOptions: model (plain or routing wrapper), input, tools, toolMiddleware, messages, maxSteps, settings, tokenBudget, timeoutMs, validationRetry, constraints, guardrails, observer, activeTools, and a spec-specific extra passthrough. The result carries raw (the SDK's own result object), text, object, _meta, steps, messages, and pendingApprovals when suspended on tool approval.
ExecutorSpec
The SDK-loop execution IR produced by the loop provider-runtime branch.
| Method | Description |
|---|---|
executorId | Executor identifier for observability (e.g. 'ai-sdk') |
describeModel(model) | Extract ModelInfo from an SDK model reference |
mapSettings(settings, model) | Map canonical settings to SDK option names |
runLoop(client, request) | Multi-step text + tools; SDK owns the loop, core steers via request.observer |
attemptStructured(client, request) | ONE structured attempt; returns invalid as a value, never throws on schema failure |
runStream(client, request) | Streaming; returns the SDK stream result untouched + typed completion(). Must drive request.safety when present (streaming guardrails) |
replayStream?(cached) | Optional semantic-cache stream replay |
Types
import type {
// Factory and result types (core-driven loop)
CruxAdapter,
AdapterGenerateOptions,
AdapterStreamOptions,
AdapterGenerateResult,
// Spec interfaces
AdapterSpec,
ExecutorSpec,
// Executor contract types (SDK-driven loop)
ExecutorRequest,
StructuredRequest,
ExecutorStep,
StepDirective,
StepObserver,
ExecutorOutcome,
StructuredAttempt,
ExecutorStreamHandle,
ExecutorStreamMeta,
CruxExecutor,
ExecutorGenerateOptions,
ExecutorGenerateResult,
PendingToolApproval,
ApprovalRequestInfo,
// Canonical types
AdapterResponse,
CallArgs,
StreamHandle,
ToolResultEntry,
StatusDelta,
} from '@crux/core/adapter'Related
- Guide: Building custom adapters
- Reference: Prompts