@dbos-inc/vercel-ai 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +67 -0
- package/dist/internal.js.map +1 -0
- package/dist/mcp.d.ts +17 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +102 -0
- package/dist/mcp.js.map +1 -0
- package/dist/middleware.d.ts +13 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +496 -0
- package/dist/middleware.js.map +1 -0
- package/package.json +64 -0
- package/src/index.ts +2 -0
- package/src/internal.ts +66 -0
- package/src/mcp.ts +140 -0
- package/src/middleware.ts +520 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { DBOS, StepConfig } from '@dbos-inc/dbos-sdk';
|
|
2
|
+
import type { ToolSet } from 'ai' with { 'resolution-mode': 'import' };
|
|
3
|
+
import { assertNotInTransaction, isInWorkflowFunction, restoreAISDKErrorIdentity, withErrorClassification } from './internal';
|
|
4
|
+
|
|
5
|
+
// Structural type for an MCP client (e.g. from @ai-sdk/mcp) — deliberately loose: the AI SDK ecosystem
|
|
6
|
+
// exact-pins @ai-sdk/provider-utils, so precise Tool types fail to match across skewed copies.
|
|
7
|
+
export interface MCPClientLike {
|
|
8
|
+
tools(options?: unknown): Promise<Record<string, unknown>>;
|
|
9
|
+
close?(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Duck-typed view of a client tool; every field is verified at runtime before use.
|
|
13
|
+
interface MCPToolLike {
|
|
14
|
+
description?: unknown;
|
|
15
|
+
title?: unknown;
|
|
16
|
+
metadata?: ToolSet[string]['metadata'];
|
|
17
|
+
_meta?: unknown;
|
|
18
|
+
toModelOutput?: unknown;
|
|
19
|
+
inputSchema?: unknown;
|
|
20
|
+
execute?: (input: unknown, options: unknown) => unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DurableMCPToolsOptions extends StepConfig {
|
|
24
|
+
/** Forwarded to client.tools() on listing and on each call (e.g. { schemas } for subsetting and output schemas). */
|
|
25
|
+
toolOptions?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DurableToolDef {
|
|
29
|
+
description?: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
metadata?: ToolSet[string]['metadata'];
|
|
32
|
+
meta?: unknown;
|
|
33
|
+
convertsOutput: boolean;
|
|
34
|
+
inputJsonSchema: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type ToolModelOutput = Awaited<ReturnType<NonNullable<ToolSet[string]['toModelOutput']>>>;
|
|
38
|
+
|
|
39
|
+
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
40
|
+
return typeof (value as AsyncIterable<unknown> | null | undefined)?.[Symbol.asyncIterator] === 'function';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Mirror of @ai-sdk/mcp's toModelOutput: MCP content becomes model content (text stays text, images become files).
|
|
44
|
+
function mcpToolOutput(output: unknown): ToolModelOutput {
|
|
45
|
+
const result = output as { content?: unknown };
|
|
46
|
+
if (result === null || typeof result !== 'object' || !Array.isArray(result.content)) {
|
|
47
|
+
return { type: 'json', value: output } as ToolModelOutput;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
type: 'content',
|
|
51
|
+
value: result.content.map((part: { type?: string; text?: string; data?: string; mimeType?: string }) => {
|
|
52
|
+
if (part.type === 'text' && typeof part.text === 'string') {
|
|
53
|
+
return { type: 'text' as const, text: part.text };
|
|
54
|
+
}
|
|
55
|
+
if (part.type === 'image' && part.data !== undefined && part.mimeType !== undefined) {
|
|
56
|
+
return { type: 'file' as const, mediaType: part.mimeType, data: { type: 'data' as const, data: part.data } };
|
|
57
|
+
}
|
|
58
|
+
return { type: 'text' as const, text: JSON.stringify(part) };
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wraps an MCP client so its tool listing and each tool call run as durable DBOS steps: the tool
|
|
65
|
+
* list (JSON schemas) is checkpointed so recovery needs no live connection, and each tool call is
|
|
66
|
+
* checkpointed so a recovered workflow replays results instead of re-invoking the tool.
|
|
67
|
+
*/
|
|
68
|
+
export async function durableMCPTools(client: MCPClientLike, options: DurableMCPToolsOptions = {}): Promise<ToolSet> {
|
|
69
|
+
const { toolOptions, ...stepOptions } = options;
|
|
70
|
+
const stepConfig = withErrorClassification(stepOptions);
|
|
71
|
+
const { asSchema, dynamicTool, jsonSchema } = await import('ai');
|
|
72
|
+
const run = <T>(name: string, fn: () => Promise<T>, config: StepConfig = stepConfig): Promise<T> => {
|
|
73
|
+
assertNotInTransaction(name);
|
|
74
|
+
if (!isInWorkflowFunction()) return fn();
|
|
75
|
+
// Restore the AI SDK error identity a replay revival strips, so the SDK's retry/catch logic behaves the same.
|
|
76
|
+
return DBOS.runStep(fn, { ...config, name }).catch((error: unknown) => {
|
|
77
|
+
throw restoreAISDKErrorIdentity(error);
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Checkpoint the tool list as plain JSON schemas, so replay reconstructs tools without the live client.
|
|
82
|
+
const listed = await run('mcp.listTools', async () => {
|
|
83
|
+
const tools = await client.tools(toolOptions);
|
|
84
|
+
const defs: Record<string, DurableToolDef> = {};
|
|
85
|
+
for (const [name, rawTool] of Object.entries(tools)) {
|
|
86
|
+
const tool = rawTool as MCPToolLike;
|
|
87
|
+
defs[name] = {
|
|
88
|
+
description: typeof tool.description === 'string' ? tool.description : undefined,
|
|
89
|
+
title: typeof tool.title === 'string' ? tool.title : undefined,
|
|
90
|
+
metadata: tool.metadata,
|
|
91
|
+
meta: tool._meta,
|
|
92
|
+
convertsOutput: typeof tool.toModelOutput === 'function',
|
|
93
|
+
// Await: a Schema's jsonSchema may be a Promise, which would otherwise checkpoint as {} and yield an empty schema.
|
|
94
|
+
inputJsonSchema: await asSchema(tool.inputSchema as Parameters<typeof asSchema>[0]).jsonSchema,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return defs;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const durable: ToolSet = {};
|
|
101
|
+
for (const [name, def] of Object.entries(listed)) {
|
|
102
|
+
const reconstructed = dynamicTool({
|
|
103
|
+
description: def.description,
|
|
104
|
+
title: def.title,
|
|
105
|
+
metadata: def.metadata,
|
|
106
|
+
inputSchema: jsonSchema(def.inputJsonSchema as Parameters<typeof jsonSchema>[0]),
|
|
107
|
+
// MCP clients convert results via a pure toModelOutput; reapply an equivalent so results reach the model as content, not raw JSON.
|
|
108
|
+
toModelOutput: def.convertsOutput ? ({ output }) => mcpToolOutput(output) : undefined,
|
|
109
|
+
// Re-fetch the live tool inside the step (its execute closure can't be checkpointed); replay returns the recorded result.
|
|
110
|
+
execute: (input: unknown, execOptions) => {
|
|
111
|
+
const signal = (execOptions as { abortSignal?: AbortSignal } | undefined)?.abortSignal;
|
|
112
|
+
// An aborted consumer is done with this call, whatever the failure looks like; a retry would re-run a cancelled side effect.
|
|
113
|
+
const callConfig: StepConfig = {
|
|
114
|
+
...stepConfig,
|
|
115
|
+
shouldRetry: async (error: unknown) =>
|
|
116
|
+
!signal?.aborted && (stepConfig.shouldRetry ? await stepConfig.shouldRetry(error) : true),
|
|
117
|
+
};
|
|
118
|
+
return run(
|
|
119
|
+
`mcp.tool.${name}`,
|
|
120
|
+
async () => {
|
|
121
|
+
const tool = (await client.tools(toolOptions))[name] as MCPToolLike | undefined;
|
|
122
|
+
if (typeof tool?.execute !== 'function') throw new Error(`MCP tool "${name}" is not executable.`);
|
|
123
|
+
const output = await tool.execute(input, execOptions);
|
|
124
|
+
// A streaming execute can't checkpoint mid-flight; drain it and record the final value (the last yield).
|
|
125
|
+
if (isAsyncIterable(output)) {
|
|
126
|
+
let last: unknown;
|
|
127
|
+
for await (last of output);
|
|
128
|
+
return last;
|
|
129
|
+
}
|
|
130
|
+
return output;
|
|
131
|
+
},
|
|
132
|
+
callConfig,
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
// @ai-sdk/mcp spreads the MCP _meta onto the tool object; preserve it for consumers that read it.
|
|
137
|
+
durable[name] = def.meta === undefined ? reconstructed : Object.assign(reconstructed, { _meta: def.meta });
|
|
138
|
+
}
|
|
139
|
+
return durable;
|
|
140
|
+
}
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { DBOS, StepConfig } from '@dbos-inc/dbos-sdk';
|
|
3
|
+
// Public signatures use ai's middleware aliases: ai is the single peer instance, so the types always
|
|
4
|
+
// match the consumer's wrap* calls. @ai-sdk/provider (dev-only) never appears in the published types —
|
|
5
|
+
// the AI SDK ecosystem exact-pins it, and duplicate copies don't unify.
|
|
6
|
+
import type { EmbeddingModelMiddleware, ImageModelMiddleware, LanguageModelMiddleware } from 'ai' with { 'resolution-mode': 'import' };
|
|
7
|
+
import type {
|
|
8
|
+
EmbeddingModelV4,
|
|
9
|
+
ImageModelV4,
|
|
10
|
+
ImageModelV4Result,
|
|
11
|
+
LanguageModelV4,
|
|
12
|
+
LanguageModelV4Content,
|
|
13
|
+
LanguageModelV4FinishReason,
|
|
14
|
+
LanguageModelV4GenerateResult,
|
|
15
|
+
LanguageModelV4Reasoning,
|
|
16
|
+
LanguageModelV4ResponseMetadata,
|
|
17
|
+
LanguageModelV4StreamPart,
|
|
18
|
+
LanguageModelV4Text,
|
|
19
|
+
LanguageModelV4Usage,
|
|
20
|
+
SharedV4ProviderMetadata,
|
|
21
|
+
SharedV4Warning,
|
|
22
|
+
} from '@ai-sdk/provider' with { 'resolution-mode': 'import' };
|
|
23
|
+
import { assertNotInTransaction, isInWorkflowFunction, restoreAISDKErrorIdentity, withErrorClassification } from './internal';
|
|
24
|
+
|
|
25
|
+
// In-flight durable model calls per workflow; concurrent calls have a nondeterministic DBOS step order on replay, so we reject them.
|
|
26
|
+
const inflightModelCalls = new Map<string, number>();
|
|
27
|
+
|
|
28
|
+
function enterDurableModelCall(operation: 'generate' | 'stream' | 'embed'): string {
|
|
29
|
+
const workflowID = DBOS.workflowID!;
|
|
30
|
+
const inflight = inflightModelCalls.get(workflowID) ?? 0;
|
|
31
|
+
if (inflight > 0) {
|
|
32
|
+
// embedMany parallelizes its batches; maxParallelCalls: 1 serializes them deterministically. Other callers use child workflows.
|
|
33
|
+
const remedy =
|
|
34
|
+
operation === 'embed'
|
|
35
|
+
? 'pass maxParallelCalls: 1 to embedMany, or run each call in its own child workflow with DBOS.startWorkflow'
|
|
36
|
+
: 'run each call in its own child workflow with DBOS.startWorkflow';
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Concurrent durable model calls in workflow "${workflowID}" are not supported because their step order is nondeterministic on replay; ${remedy}.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
inflightModelCalls.set(workflowID, inflight + 1);
|
|
42
|
+
return workflowID;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function exitDurableModelCall(workflowID: string): void {
|
|
46
|
+
const inflight = (inflightModelCalls.get(workflowID) ?? 1) - 1;
|
|
47
|
+
if (inflight > 0) {
|
|
48
|
+
inflightModelCalls.set(workflowID, inflight);
|
|
49
|
+
} else {
|
|
50
|
+
inflightModelCalls.delete(workflowID);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** AI SDK language-model middleware that runs each model call as a durable, checkpointed DBOS step (replayed on recovery); outside a workflow it calls the model directly. */
|
|
55
|
+
export function durableCalls(options: StepConfig = {}): LanguageModelMiddleware {
|
|
56
|
+
const stepConfig = withErrorClassification(options);
|
|
57
|
+
return {
|
|
58
|
+
specificationVersion: 'v4',
|
|
59
|
+
|
|
60
|
+
wrapGenerate: async ({ doGenerate, model }) => {
|
|
61
|
+
assertNotInTransaction('generate');
|
|
62
|
+
if (!isInWorkflowFunction()) {
|
|
63
|
+
return await doGenerate();
|
|
64
|
+
}
|
|
65
|
+
const workflowID = enterDurableModelCall('generate');
|
|
66
|
+
try {
|
|
67
|
+
return await DBOS.runStep(async () => ensureResponseMetadata(encodeBinaryContent(await doGenerate())), {
|
|
68
|
+
...stepConfig,
|
|
69
|
+
name: stepConfig.name ?? stepName(model, 'generate'),
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Restore the AI SDK error identity a replay revival strips, so the SDK's retry/catch logic behaves the same.
|
|
73
|
+
throw restoreAISDKErrorIdentity(error);
|
|
74
|
+
} finally {
|
|
75
|
+
exitDurableModelCall(workflowID);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
wrapStream: async ({ doStream, params, model }) => {
|
|
80
|
+
assertNotInTransaction('stream');
|
|
81
|
+
if (!isInWorkflowFunction()) {
|
|
82
|
+
return await doStream();
|
|
83
|
+
}
|
|
84
|
+
const workflowID = enterDurableModelCall('stream');
|
|
85
|
+
// An aborted consumer is done with this call, like a cancelled one: post-abort failures must checkpoint as a (partial) success, or replay would fail where the live run ended gracefully.
|
|
86
|
+
const aborted = () => params.abortSignal?.aborted === true;
|
|
87
|
+
|
|
88
|
+
// Free the concurrency guard as soon as the consumer detaches (abort/cancel), not only when the step
|
|
89
|
+
// settles: this call's step is already sequenced, so a sequential follow-up in the same workflow is
|
|
90
|
+
// deterministic on replay and must not be rejected as concurrent while this step drains in the background.
|
|
91
|
+
let guardReleased = false;
|
|
92
|
+
const releaseGuard = () => {
|
|
93
|
+
if (guardReleased) return;
|
|
94
|
+
guardReleased = true;
|
|
95
|
+
params.abortSignal?.removeEventListener('abort', releaseGuard);
|
|
96
|
+
exitDurableModelCall(workflowID);
|
|
97
|
+
};
|
|
98
|
+
params.abortSignal?.addEventListener('abort', releaseGuard, { once: true });
|
|
99
|
+
|
|
100
|
+
let executed = false;
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
let emittedLive = false;
|
|
103
|
+
let controller!: ReadableStreamDefaultController<LanguageModelV4StreamPart>;
|
|
104
|
+
let step!: Promise<LanguageModelV4GenerateResult>;
|
|
105
|
+
const stream = new ReadableStream<LanguageModelV4StreamPart>({
|
|
106
|
+
start(c) {
|
|
107
|
+
controller = c;
|
|
108
|
+
},
|
|
109
|
+
// Await the step so an early cancel still blocks until the model result is checkpointed.
|
|
110
|
+
async cancel() {
|
|
111
|
+
cancelled = true;
|
|
112
|
+
releaseGuard();
|
|
113
|
+
// Post-cancel failures normally checkpoint as a success; anything else (e.g. a failed checkpoint write) is only visible here.
|
|
114
|
+
await step.catch((error: unknown) =>
|
|
115
|
+
DBOS.logger.warn(`Durable model call step failed after consumer cancel: ${String(error)}`),
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const emit = (part: LanguageModelV4StreamPart) => {
|
|
120
|
+
if (!cancelled) {
|
|
121
|
+
controller.enqueue(part);
|
|
122
|
+
// stream-start and response-metadata merge idempotently downstream, so they alone don't preclude a retry.
|
|
123
|
+
if (part.type !== 'stream-start' && part.type !== 'response-metadata') emittedLive = true;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Once any output part has streamed live, a retry would re-stream from scratch and duplicate output, so stop retrying.
|
|
128
|
+
const streamStepConfig: StepConfig = {
|
|
129
|
+
...stepConfig,
|
|
130
|
+
shouldRetry: async (error: unknown) =>
|
|
131
|
+
!emittedLive && (stepConfig.shouldRetry ? await stepConfig.shouldRetry(error) : true),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
step = DBOS.runStep(
|
|
136
|
+
async () => {
|
|
137
|
+
executed = true;
|
|
138
|
+
const accumulator = new StreamAccumulator();
|
|
139
|
+
// A timed-out attempt is abandoned by DBOS (its outcome is discarded) but keeps running; stop it so it can't emit alongside a retry.
|
|
140
|
+
const timeoutSignal = DBOS.stepStatus?.timeoutSignal;
|
|
141
|
+
let streamResult: Awaited<ReturnType<typeof doStream>> | undefined;
|
|
142
|
+
let reader: ReadableStreamDefaultReader<LanguageModelV4StreamPart> | undefined;
|
|
143
|
+
let sawFinish = false;
|
|
144
|
+
const abandon = () => void reader?.cancel().catch(() => {});
|
|
145
|
+
timeoutSignal?.addEventListener('abort', abandon, { once: true });
|
|
146
|
+
// A consumer abort detaches this call; stop draining now so the checkpoint lands promptly and matches what streamed.
|
|
147
|
+
params.abortSignal?.addEventListener('abort', abandon, { once: true });
|
|
148
|
+
try {
|
|
149
|
+
streamResult = await doStream();
|
|
150
|
+
reader = streamResult.stream.getReader();
|
|
151
|
+
for (;;) {
|
|
152
|
+
const { done, value: part } = await reader.read();
|
|
153
|
+
if (timeoutSignal?.aborted) throw (timeoutSignal.reason ?? new Error('step attempt timed out'));
|
|
154
|
+
if (done) break;
|
|
155
|
+
if (part.type === 'error') {
|
|
156
|
+
// A cancelled or aborted consumer abandoned this call; don't let a late failure become the step outcome, or replay would fail where the live run succeeded.
|
|
157
|
+
if (cancelled || aborted()) break;
|
|
158
|
+
throw toStepError(part.error);
|
|
159
|
+
}
|
|
160
|
+
// Stream deltas live but withhold 'finish' until the checkpoint is durable: the AI SDK runs tool calls (and their durable steps) on 'finish', which must not checkpoint before this model step.
|
|
161
|
+
if (part.type === 'finish') sawFinish = true;
|
|
162
|
+
else emit(part);
|
|
163
|
+
accumulator.add(part);
|
|
164
|
+
}
|
|
165
|
+
// No terminal part and no output: fail (retryably) like the AI SDK's NoOutputGeneratedError, instead of checkpointing a permanent empty success.
|
|
166
|
+
if (!sawFinish && !accumulator.hasContent && !cancelled && !aborted()) {
|
|
167
|
+
throw new Error('Model stream ended without a finish part or any output.');
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Same rule for stream-level failures (doStream or a read rejecting) after a cancel or abort.
|
|
171
|
+
if (!cancelled && !aborted()) throw error;
|
|
172
|
+
} finally {
|
|
173
|
+
timeoutSignal?.removeEventListener('abort', abandon);
|
|
174
|
+
params.abortSignal?.removeEventListener('abort', abandon);
|
|
175
|
+
// Tear down the provider stream on early exits (error part, post-cancel break); a no-op after a clean drain.
|
|
176
|
+
void reader?.cancel().catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
// Give the response a durable id/timestamp when the provider sent none, and emit it live (before the
|
|
179
|
+
// withheld 'finish') so the SDK sees the same values live and on replay instead of a fresh fallback.
|
|
180
|
+
// Skip the live emit for a timed-out (abandoned) attempt so it can't interleave with its retry.
|
|
181
|
+
const responseMetadataPart = accumulator.fillResponseMetadata(randomUUID(), new Date());
|
|
182
|
+
if (responseMetadataPart && !timeoutSignal?.aborted) emit(responseMetadataPart);
|
|
183
|
+
return encodeBinaryContent(accumulator.result(streamResult?.request, streamResult?.response));
|
|
184
|
+
},
|
|
185
|
+
{ ...streamStepConfig, name: stepConfig.name ?? stepName(model, 'stream') },
|
|
186
|
+
);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// runStep can throw synchronously (e.g. a shutdown race); don't leak the guard entry.
|
|
189
|
+
releaseGuard();
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Drive the returned stream from the settled step: a live run emits only the withheld 'finish' (deltas already
|
|
194
|
+
// streamed); a recovered run synthesizes the whole stream from the checkpoint. Either way consumers finish
|
|
195
|
+
// only after the result is durable.
|
|
196
|
+
void step
|
|
197
|
+
.then(
|
|
198
|
+
(recorded) => {
|
|
199
|
+
if (cancelled) return;
|
|
200
|
+
if (executed) {
|
|
201
|
+
emit({
|
|
202
|
+
type: 'finish',
|
|
203
|
+
finishReason: recorded.finishReason,
|
|
204
|
+
usage: recorded.usage,
|
|
205
|
+
providerMetadata: recorded.providerMetadata,
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
for (const part of replayParts(recorded)) emit(part);
|
|
209
|
+
}
|
|
210
|
+
controller.close();
|
|
211
|
+
},
|
|
212
|
+
(error: unknown) => {
|
|
213
|
+
if (!cancelled) controller.error(restoreAISDKErrorIdentity(error));
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
.finally(releaseGuard);
|
|
217
|
+
|
|
218
|
+
return { stream };
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** AI SDK embedding-model middleware that runs each embedding call as a durable DBOS step, like {@link durableCalls}. */
|
|
224
|
+
export function durableEmbeddingCalls(options: StepConfig = {}): EmbeddingModelMiddleware {
|
|
225
|
+
const stepConfig = withErrorClassification(options);
|
|
226
|
+
return {
|
|
227
|
+
specificationVersion: 'v4',
|
|
228
|
+
wrapEmbed: async ({ doEmbed, model }) => {
|
|
229
|
+
assertNotInTransaction('embed');
|
|
230
|
+
if (!isInWorkflowFunction()) {
|
|
231
|
+
return await doEmbed();
|
|
232
|
+
}
|
|
233
|
+
const workflowID = enterDurableModelCall('embed');
|
|
234
|
+
try {
|
|
235
|
+
return await DBOS.runStep(async () => doEmbed(), {
|
|
236
|
+
...stepConfig,
|
|
237
|
+
name: stepConfig.name ?? stepName(model, 'embed'),
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw restoreAISDKErrorIdentity(error);
|
|
241
|
+
} finally {
|
|
242
|
+
exitDurableModelCall(workflowID);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* AI SDK image-model middleware that runs each image generation as a durable DBOS step, like {@link durableCalls}.
|
|
250
|
+
* No concurrency guard: generateImage splits `n > maxImagesPerCall` into batches it dispatches synchronously (no
|
|
251
|
+
* await before doGenerate), so their step order is deterministic on replay — unlike embedMany's parallel batches.
|
|
252
|
+
*/
|
|
253
|
+
export function durableImageCalls(options: StepConfig = {}): ImageModelMiddleware {
|
|
254
|
+
const stepConfig = withErrorClassification(options);
|
|
255
|
+
return {
|
|
256
|
+
specificationVersion: 'v4',
|
|
257
|
+
wrapGenerate: async ({ doGenerate, model }) => {
|
|
258
|
+
assertNotInTransaction('generateImage');
|
|
259
|
+
if (!isInWorkflowFunction()) {
|
|
260
|
+
return await doGenerate();
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
return await DBOS.runStep(async () => encodeImageResult(await doGenerate()), {
|
|
264
|
+
...stepConfig,
|
|
265
|
+
name: stepConfig.name ?? stepName(model, 'image'),
|
|
266
|
+
});
|
|
267
|
+
} catch (error) {
|
|
268
|
+
throw restoreAISDKErrorIdentity(error);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Convert generated image bytes (Uint8Array) to base64 (spec-allowed) to keep checkpoints compact. */
|
|
275
|
+
function encodeImageResult(result: ImageModelV4Result): ImageModelV4Result {
|
|
276
|
+
const images = result.images as (string | Uint8Array)[];
|
|
277
|
+
if (images.every((image) => typeof image === 'string')) {
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
...result,
|
|
282
|
+
images: images.map((image) => (typeof image === 'string' ? image : Buffer.from(image).toString('base64'))),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function stepName(model: LanguageModelV4 | EmbeddingModelV4 | ImageModelV4, operation: string): string {
|
|
287
|
+
return `${model.provider}.${model.modelId}.${operation}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Normalize an error-part payload to an Error, preserving the payload (JSON message, cause, isRetryable). */
|
|
291
|
+
function toStepError(error: unknown): Error {
|
|
292
|
+
if (error instanceof Error) return error;
|
|
293
|
+
let message: string;
|
|
294
|
+
try {
|
|
295
|
+
message = typeof error === 'string' ? error : (JSON.stringify(error) ?? String(error));
|
|
296
|
+
} catch {
|
|
297
|
+
message = String(error);
|
|
298
|
+
}
|
|
299
|
+
const result = new Error(message, { cause: error });
|
|
300
|
+
// Carry the payload's name so an abort/timeout error-part is classified terminal (isAbortError reads .name), not retried.
|
|
301
|
+
const name = (error as { name?: unknown } | null | undefined)?.name;
|
|
302
|
+
if (typeof name === 'string') result.name = name;
|
|
303
|
+
const isRetryable = (error as { isRetryable?: unknown } | null | undefined)?.isRetryable;
|
|
304
|
+
return isRetryable === undefined ? result : Object.assign(result, { isRetryable });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// generateText fills response.id/timestamp with generateId()/new Date() OUTSIDE this step when the provider omits
|
|
308
|
+
// them, so they'd differ on every replay. Populate them here (checkpointed once) so that fallback never runs.
|
|
309
|
+
function ensureResponseMetadata(result: LanguageModelV4GenerateResult): LanguageModelV4GenerateResult {
|
|
310
|
+
const response = result.response;
|
|
311
|
+
// Match the AI SDK's `?? generateId()` fallback (nullish, not just undefined): a null id would regenerate too.
|
|
312
|
+
if (response?.id != null && response?.timestamp != null) {
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
...result,
|
|
317
|
+
response: { ...response, id: response?.id ?? randomUUID(), timestamp: response?.timestamp ?? new Date() },
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Convert generated-file bytes (Uint8Array) to base64 (spec-allowed) to keep checkpoints compact. */
|
|
322
|
+
function encodeBinaryContent(result: LanguageModelV4GenerateResult): LanguageModelV4GenerateResult {
|
|
323
|
+
const content = result.content.map(encodeBinaryPart);
|
|
324
|
+
return content.some((part, i) => part !== result.content[i]) ? { ...result, content } : result;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function encodeBinaryPart(part: LanguageModelV4Content): LanguageModelV4Content {
|
|
328
|
+
if (
|
|
329
|
+
(part.type === 'file' || part.type === 'reasoning-file') &&
|
|
330
|
+
part.data.type === 'data' &&
|
|
331
|
+
part.data.data instanceof Uint8Array
|
|
332
|
+
) {
|
|
333
|
+
return { ...part, data: { type: 'data', data: Buffer.from(part.data.data).toString('base64') } };
|
|
334
|
+
}
|
|
335
|
+
return part;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Assembles stream parts into a LanguageModelV4GenerateResult so a stream can be checkpointed as one step result. */
|
|
339
|
+
class StreamAccumulator {
|
|
340
|
+
private readonly content: LanguageModelV4Content[] = [];
|
|
341
|
+
private finishReason: LanguageModelV4FinishReason = { unified: 'other', raw: undefined };
|
|
342
|
+
private usage: LanguageModelV4Usage = {
|
|
343
|
+
inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
|
344
|
+
outputTokens: { total: undefined, text: undefined, reasoning: undefined },
|
|
345
|
+
};
|
|
346
|
+
private readonly warnings: SharedV4Warning[] = [];
|
|
347
|
+
private providerMetadata?: SharedV4ProviderMetadata;
|
|
348
|
+
private responseMetadata?: LanguageModelV4ResponseMetadata;
|
|
349
|
+
// Text/reasoning content objects (also in content), appended at -start and mutated in place so content keeps arrival order.
|
|
350
|
+
private readonly textBlocks = new Map<string, LanguageModelV4Text>();
|
|
351
|
+
private readonly reasoningBlocks = new Map<string, LanguageModelV4Reasoning>();
|
|
352
|
+
|
|
353
|
+
add(part: LanguageModelV4StreamPart): void {
|
|
354
|
+
switch (part.type) {
|
|
355
|
+
case 'stream-start':
|
|
356
|
+
this.warnings.push(...part.warnings);
|
|
357
|
+
break;
|
|
358
|
+
case 'text-start': {
|
|
359
|
+
const block: LanguageModelV4Text = { type: 'text', text: '', providerMetadata: part.providerMetadata };
|
|
360
|
+
this.textBlocks.set(part.id, block);
|
|
361
|
+
this.content.push(block);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case 'text-delta': {
|
|
365
|
+
const block = this.getOrCreateText(part.id);
|
|
366
|
+
block.text += part.delta;
|
|
367
|
+
if (part.providerMetadata) block.providerMetadata = part.providerMetadata;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case 'text-end': {
|
|
371
|
+
const block = this.textBlocks.get(part.id);
|
|
372
|
+
if (block && part.providerMetadata) block.providerMetadata = part.providerMetadata;
|
|
373
|
+
this.textBlocks.delete(part.id);
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case 'reasoning-start': {
|
|
377
|
+
const block: LanguageModelV4Reasoning = {
|
|
378
|
+
type: 'reasoning',
|
|
379
|
+
text: '',
|
|
380
|
+
providerMetadata: part.providerMetadata,
|
|
381
|
+
};
|
|
382
|
+
this.reasoningBlocks.set(part.id, block);
|
|
383
|
+
this.content.push(block);
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case 'reasoning-delta': {
|
|
387
|
+
const block = this.getOrCreateReasoning(part.id);
|
|
388
|
+
block.text += part.delta;
|
|
389
|
+
if (part.providerMetadata) block.providerMetadata = part.providerMetadata;
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case 'reasoning-end': {
|
|
393
|
+
const block = this.reasoningBlocks.get(part.id);
|
|
394
|
+
if (block && part.providerMetadata) block.providerMetadata = part.providerMetadata;
|
|
395
|
+
this.reasoningBlocks.delete(part.id);
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
case 'response-metadata':
|
|
399
|
+
// Merge per-field like the AI SDK: later parts override only the fields they carry.
|
|
400
|
+
this.responseMetadata = {
|
|
401
|
+
id: part.id ?? this.responseMetadata?.id,
|
|
402
|
+
timestamp: part.timestamp ?? this.responseMetadata?.timestamp,
|
|
403
|
+
modelId: part.modelId ?? this.responseMetadata?.modelId,
|
|
404
|
+
};
|
|
405
|
+
break;
|
|
406
|
+
case 'finish':
|
|
407
|
+
this.finishReason = part.finishReason;
|
|
408
|
+
this.usage = part.usage;
|
|
409
|
+
this.providerMetadata = part.providerMetadata;
|
|
410
|
+
break;
|
|
411
|
+
case 'tool-input-start':
|
|
412
|
+
case 'tool-input-delta':
|
|
413
|
+
case 'tool-input-end':
|
|
414
|
+
case 'raw':
|
|
415
|
+
case 'error':
|
|
416
|
+
// Transient parts; the tool-call part carries the complete input.
|
|
417
|
+
break;
|
|
418
|
+
default:
|
|
419
|
+
// Complete content parts: tool-call, tool-result, tool-approval-request, file, reasoning-file, source, custom.
|
|
420
|
+
this.content.push(part);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
get hasContent(): boolean {
|
|
426
|
+
return this.content.length > 0;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Fill any missing response id/timestamp so the checkpoint carries them; returns the response-metadata part to
|
|
430
|
+
// emit live (or undefined if the provider already supplied both). replayParts re-emits it from the checkpoint.
|
|
431
|
+
fillResponseMetadata(id: string, timestamp: Date): LanguageModelV4StreamPart | undefined {
|
|
432
|
+
// Nullish check to match the AI SDK's `?? generateId()` fallback (a null id/timestamp would regenerate too).
|
|
433
|
+
if (this.responseMetadata?.id != null && this.responseMetadata?.timestamp != null) {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
this.responseMetadata = {
|
|
437
|
+
id: this.responseMetadata?.id ?? id,
|
|
438
|
+
timestamp: this.responseMetadata?.timestamp ?? timestamp,
|
|
439
|
+
modelId: this.responseMetadata?.modelId,
|
|
440
|
+
};
|
|
441
|
+
return { type: 'response-metadata', ...this.responseMetadata };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// If a delta arrives with no preceding start, create the block in arrival position rather than dropping the text.
|
|
445
|
+
private getOrCreateText(id: string): LanguageModelV4Text {
|
|
446
|
+
let block = this.textBlocks.get(id);
|
|
447
|
+
if (!block) {
|
|
448
|
+
block = { type: 'text', text: '' };
|
|
449
|
+
this.textBlocks.set(id, block);
|
|
450
|
+
this.content.push(block);
|
|
451
|
+
}
|
|
452
|
+
return block;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private getOrCreateReasoning(id: string): LanguageModelV4Reasoning {
|
|
456
|
+
let block = this.reasoningBlocks.get(id);
|
|
457
|
+
if (!block) {
|
|
458
|
+
block = { type: 'reasoning', text: '' };
|
|
459
|
+
this.reasoningBlocks.set(id, block);
|
|
460
|
+
this.content.push(block);
|
|
461
|
+
}
|
|
462
|
+
return block;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
result(
|
|
466
|
+
request?: { body?: unknown },
|
|
467
|
+
response?: { headers?: Record<string, string> },
|
|
468
|
+
): LanguageModelV4GenerateResult {
|
|
469
|
+
return {
|
|
470
|
+
content: this.content,
|
|
471
|
+
finishReason: this.finishReason,
|
|
472
|
+
usage: this.usage,
|
|
473
|
+
warnings: this.warnings,
|
|
474
|
+
providerMetadata: this.providerMetadata,
|
|
475
|
+
request,
|
|
476
|
+
response: this.responseMetadata ? { ...this.responseMetadata, ...response } : response,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Synthesizes a stream from a checkpointed result on recovery; text/reasoning come back as one delta per block. */
|
|
482
|
+
function* replayParts(result: LanguageModelV4GenerateResult): Generator<LanguageModelV4StreamPart> {
|
|
483
|
+
yield { type: 'stream-start', warnings: result.warnings ?? [] };
|
|
484
|
+
if (result.response?.id !== undefined || result.response?.timestamp !== undefined || result.response?.modelId !== undefined) {
|
|
485
|
+
yield {
|
|
486
|
+
type: 'response-metadata',
|
|
487
|
+
id: result.response.id,
|
|
488
|
+
timestamp: result.response.timestamp,
|
|
489
|
+
modelId: result.response.modelId,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
let blockIndex = 0;
|
|
493
|
+
for (const part of result.content) {
|
|
494
|
+
const id = `replay-${blockIndex++}`;
|
|
495
|
+
if (part.type === 'text') {
|
|
496
|
+
yield { type: 'text-start', id, providerMetadata: part.providerMetadata };
|
|
497
|
+
if (part.text.length > 0) yield { type: 'text-delta', id, delta: part.text };
|
|
498
|
+
yield { type: 'text-end', id, providerMetadata: part.providerMetadata };
|
|
499
|
+
} else if (part.type === 'reasoning') {
|
|
500
|
+
yield { type: 'reasoning-start', id, providerMetadata: part.providerMetadata };
|
|
501
|
+
if (part.text.length > 0) yield { type: 'reasoning-delta', id, delta: part.text };
|
|
502
|
+
yield { type: 'reasoning-end', id, providerMetadata: part.providerMetadata };
|
|
503
|
+
} else if (part.type === 'tool-call') {
|
|
504
|
+
// Re-synthesize the tool-input grammar: consumer tool callbacks (onInputStart/onInputAvailable) are keyed off tool-input-start.
|
|
505
|
+
const { toolCallId, toolName, providerExecuted, dynamic } = part;
|
|
506
|
+
yield { type: 'tool-input-start', id: toolCallId, toolName, providerExecuted, dynamic };
|
|
507
|
+
if (part.input.length > 0) yield { type: 'tool-input-delta', id: toolCallId, delta: part.input };
|
|
508
|
+
yield { type: 'tool-input-end', id: toolCallId };
|
|
509
|
+
yield part;
|
|
510
|
+
} else {
|
|
511
|
+
yield part;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
yield {
|
|
515
|
+
type: 'finish',
|
|
516
|
+
finishReason: result.finishReason,
|
|
517
|
+
usage: result.usage,
|
|
518
|
+
providerMetadata: result.providerMetadata,
|
|
519
|
+
};
|
|
520
|
+
}
|