@dbos-inc/vercel-ai 0.3.7 → 0.5.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbos-inc/vercel-ai",
3
- "version": "0.3.7",
3
+ "version": "0.5.4",
4
4
  "description": "DBOS durable execution integration for the Vercel AI SDK",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -44,14 +44,14 @@
44
44
  "agent"
45
45
  ],
46
46
  "peerDependencies": {
47
- "@dbos-inc/dbos-sdk": "^4.21.0 || ^5.0.0",
47
+ "@dbos-inc/dbos-sdk": "^4.27.0 || ^5.0.0",
48
48
  "ai": "^7.0.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@ai-sdk/gateway": "^4.0.0",
52
52
  "@ai-sdk/provider": "^4.0.0",
53
53
  "@arethetypeswrong/cli": "^0.18.0",
54
- "@dbos-inc/dbos-sdk": "^5.0.2",
54
+ "@dbos-inc/dbos-sdk": "^5.2.11",
55
55
  "@types/node": "^24.0.0",
56
56
  "@types/pg": "^8.20.0",
57
57
  "ai": "^7.0.0",
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { DBOS, Error as DBOSErrors, StatusString } from '@dbos-inc/dbos-sdk';
3
3
  import type { UIMessageChunk } from 'ai' with { 'resolution-mode': 'import' };
4
4
  import type { LanguageModelV4FinishReason, LanguageModelV4StreamPart } from '@ai-sdk/provider' with { 'resolution-mode': 'import' };
@@ -10,7 +10,7 @@ export type DurableStreamOptions = string | { key: string; maxBatchParts?: numbe
10
10
  export type DurableStreamRecord =
11
11
  | { kind: 'model'; step: number; attempt: string; parts: LanguageModelV4StreamPart[] }
12
12
  | { kind: 'model-end'; step: number; attempt: string; finishReason?: LanguageModelV4FinishReason; aborted?: true }
13
- | { kind: 'tool'; step: number; attempt: number; toolCallId: string; output?: unknown; errorText?: string }
13
+ | { kind: 'tool'; step: number; attempt: number; toolCallId: string; output?: unknown; errorText?: string; chunks?: UIMessageChunk[] }
14
14
  | { kind: 'ui'; step?: number; attempt?: number; chunks: UIMessageChunk[] }
15
15
  | { kind: 'end'; finishReason: string };
16
16
 
@@ -30,17 +30,24 @@ function stepInfo(): { step: number; attempt: number } {
30
30
  return { step: DBOS.stepID ?? -1, attempt: DBOS.stepStatus?.currentAttempt ?? 1 };
31
31
  }
32
32
 
33
- // Parts the reader can render; framing, metadata and terminal parts are recorded elsewhere or not at all.
33
+ // Only the parts toUIChunk renders; anything else would be written and never read.
34
34
  function isContentPart(part: LanguageModelV4StreamPart): boolean {
35
35
  switch (part.type) {
36
- case 'stream-start':
37
- case 'response-metadata':
38
- case 'finish':
39
- case 'error':
40
- case 'raw':
41
- return false;
42
- default:
36
+ case 'text-start':
37
+ case 'text-delta':
38
+ case 'text-end':
39
+ case 'reasoning-start':
40
+ case 'reasoning-delta':
41
+ case 'reasoning-end':
42
+ case 'tool-input-start':
43
+ case 'tool-input-delta':
44
+ case 'tool-call':
45
+ case 'tool-result':
46
+ case 'source':
47
+ case 'file':
43
48
  return true;
49
+ default:
50
+ return false;
44
51
  }
45
52
  }
46
53
 
@@ -116,9 +123,14 @@ export class ModelStreamWriter {
116
123
  }
117
124
  }
118
125
 
119
- /** Records a tool call's outcome from inside its step. */
120
- export function writeToolRecord(key: string, toolCallId: string, outcome: { output: unknown } | { errorText: string }): Promise<void> {
121
- const record: DurableStreamRecord = { kind: 'tool', ...stepInfo(), toolCallId, ...outcome };
126
+ /** Records a tool call's outcome, with the message chunks it wrote, from inside its step. */
127
+ export function writeToolRecord(
128
+ key: string,
129
+ toolCallId: string,
130
+ outcome: { output: unknown } | { errorText: string },
131
+ chunks: UIMessageChunk[] = [],
132
+ ): Promise<void> {
133
+ const record: DurableStreamRecord = { kind: 'tool', ...stepInfo(), toolCallId, ...outcome, ...(chunks.length > 0 && { chunks }) };
122
134
  return DBOS.writeStream(key, record);
123
135
  }
124
136
 
@@ -151,7 +163,10 @@ export interface ReadDurableStreamOptions {
151
163
  key: string;
152
164
  /** Id for the `start` chunk; omitted on a resume (`offset` > 0). */
153
165
  messageId?: string;
154
- /** Number of records already consumed, from the last `data-dbos-offset` chunk. */
166
+ /**
167
+ * Number of records already consumed, from the last `data-dbos-offset` chunk. A resume from mid-text can't be applied
168
+ * to a partial message by the AI SDK (it has no record of the open part); to rebuild a message, read from 0 instead.
169
+ */
155
170
  offset?: number;
156
171
  /** Defaults to `DBOS`; pass a `DBOSClient` to read from a process that has not launched DBOS. */
157
172
  client?: DurableStreamSource;
@@ -183,11 +198,13 @@ async function* uiChunks(options: ReadDurableStreamOptions): AsyncGenerator<UIMe
183
198
  const client: DurableStreamSource = options.client ?? DBOS;
184
199
  const state: ReaderState = {
185
200
  offset: options.offset ?? 0,
186
- resumed: (options.offset ?? 0) > 0,
187
201
  openParts: new Map(),
202
+ detachedParts: new Set(),
203
+ toolChunksSent: new Map(),
188
204
  ended: false,
189
205
  };
190
206
  if (state.offset === 0) yield { type: 'start', messageId: options.messageId };
207
+ else await restoreOpenCall(client, workflowID, key, state);
191
208
  const emit = (record: DurableStreamRecord) => emitRecord(state, record, { sendReasoning, sendSources, onError });
192
209
 
193
210
  // Phase 1: everything already stored, one value per query until an offset is empty; a superseded attempt is skipped whole.
@@ -201,12 +218,16 @@ async function* uiChunks(options: ReadDurableStreamOptions): AsyncGenerator<UIMe
201
218
  }
202
219
  }
203
220
  const finalAttempt = new Map<number, string>();
204
- for (const record of history) {
221
+ // A re-executed tool call writes another record; the last one matches its checkpoint, so only its chunks (if any) are shown.
222
+ const finalToolRecord = new Map<string, number>();
223
+ history.forEach((record, index) => {
205
224
  if (record.kind === 'model' || record.kind === 'model-end') finalAttempt.set(record.step, record.attempt);
206
- }
207
- for (const record of history) {
225
+ if (record.kind === 'tool') finalToolRecord.set(toolKey(record), index);
226
+ });
227
+ for (const [index, record] of history.entries()) {
208
228
  const stale = (record.kind === 'model' || record.kind === 'model-end') && finalAttempt.get(record.step) !== record.attempt;
209
- yield* stale ? skipRecord(state) : emit(record);
229
+ const staleChunks = record.kind === 'tool' && record.chunks !== undefined && finalToolRecord.get(toolKey(record)) !== index;
230
+ yield* stale ? skipRecord(state) : emit(staleChunks ? { ...record, chunks: undefined } : record);
210
231
  if (state.ended) return;
211
232
  }
212
233
 
@@ -233,15 +254,23 @@ async function* uiChunks(options: ReadDurableStreamOptions): AsyncGenerator<UIMe
233
254
 
234
255
  interface ReaderState {
235
256
  offset: number;
236
- resumed: boolean;
237
257
  openStep?: number;
238
258
  openAttempt?: string;
239
259
  // Text/reasoning parts of the open attempt that have started but not ended, by UI part id.
240
260
  openParts: Map<string, 'text' | 'reasoning'>;
261
+ // Open parts from before a resume: the client's resumed stream no longer tracks them, so they are never ended.
262
+ detachedParts: Set<string>;
263
+ // Digest of the chunks emitted for each tool call (by toolKey).
264
+ toolChunksSent: Map<string, string>;
241
265
  finishReason?: string;
242
266
  ended: boolean;
243
267
  }
244
268
 
269
+ // A step's function id survives re-execution, and with it tell apart calls that reuse a provider's tool call id.
270
+ function toolKey(record: Extract<DurableStreamRecord, { kind: 'tool' }>): string {
271
+ return `${record.step}:${record.toolCallId}`;
272
+ }
273
+
245
274
  function offsetChunk(state: ReaderState): UIMessageChunk {
246
275
  return { type: 'data-dbos-offset', data: { offset: state.offset }, transient: true } as UIMessageChunk;
247
276
  }
@@ -256,17 +285,90 @@ function* closeStep(state: ReaderState): Generator<UIMessageChunk> {
256
285
  state.openStep = undefined;
257
286
  state.openAttempt = undefined;
258
287
  state.openParts.clear();
288
+ state.detachedParts.clear();
289
+ }
290
+
291
+ // Records outside the open model call a resume reads past before giving up; a reconnect mid-call finds it at once.
292
+ const RESTORE_SCAN_LIMIT = 100;
293
+
294
+ /**
295
+ * On resume, rebuild the model call that was open at `offset` by reading back to its start, so the reader continues
296
+ * exactly as an uninterrupted one would: a re-executed attempt is superseded, and step boundaries fall in the same place.
297
+ * Past RESTORE_SCAN_LIMIT unrelated records it gives up, and the resumed stream only lacks that step's `finish-step`.
298
+ */
299
+ async function restoreOpenCall(client: DurableStreamSource, workflowID: string, key: string, state: ReaderState): Promise<void> {
300
+ let step: number | undefined;
301
+ let attempt: string | undefined;
302
+ // Set once an earlier step's record is reached: the open call is fully read, and only a finish reason is still wanted.
303
+ let callRead = false;
304
+ let budget = RESTORE_SCAN_LIMIT;
305
+ const ended = new Set<string>();
306
+ for (let index = state.offset - 1; index >= 0; index--) {
307
+ let record: DurableStreamRecord;
308
+ try {
309
+ record = await client.readStreamOffset<DurableStreamRecord>(workflowID, key, index, { timeoutSeconds: 0 });
310
+ } catch (error) {
311
+ // A real offset never passes the stored records, so a gap means there is nothing to restore.
312
+ if (DBOSErrors.isStreamTimeoutError(error)) break;
313
+ throw error;
314
+ }
315
+ if (record.kind === 'end') break;
316
+ const model = record.kind === 'model' || record.kind === 'model-end' ? record : undefined;
317
+ if (model && step === undefined) {
318
+ step = model.step;
319
+ attempt = model.attempt;
320
+ }
321
+ if (model && !callRead && model.step === step) {
322
+ if (model.attempt === attempt) restorePart(state, ended, model);
323
+ continue;
324
+ }
325
+ if (model) callRead = true;
326
+ // As in an uninterrupted read, the finish reason carries over from the latest earlier call that ended.
327
+ if (callRead && (state.finishReason !== undefined || model?.kind === 'model-end')) {
328
+ if (model?.kind === 'model-end') state.finishReason ??= finishReasonOf(model);
329
+ break;
330
+ }
331
+ if (--budget === 0) break;
332
+ }
333
+ if (step === undefined) return;
334
+ state.openStep = step;
335
+ state.openAttempt = attempt;
336
+ }
337
+
338
+ function finishReasonOf(record: Extract<DurableStreamRecord, { kind: 'model-end' }>): string | undefined {
339
+ return record.aborted ? 'other' : record.finishReason?.unified;
340
+ }
341
+
342
+ // One record of the open call, read backward: a part is open if its start comes before (i.e. is read after) any end.
343
+ function restorePart(state: ReaderState, ended: Set<string>, record: Extract<DurableStreamRecord, { kind: 'model' | 'model-end' }>): void {
344
+ if (record.kind === 'model-end') {
345
+ state.finishReason ??= finishReasonOf(record);
346
+ return;
347
+ }
348
+ for (const part of [...record.parts].reverse()) {
349
+ const kind = part.type === 'text-start' || part.type === 'text-end' ? 'text' : part.type === 'reasoning-start' || part.type === 'reasoning-end' ? 'reasoning' : undefined;
350
+ if (kind === undefined || !('id' in part)) continue;
351
+ const id = `${record.attempt}:${part.id}`;
352
+ if (part.type.endsWith('-end')) ended.add(id);
353
+ else if (!ended.has(id)) {
354
+ state.openParts.set(id, kind);
355
+ state.detachedParts.add(id);
356
+ }
357
+ }
259
358
  }
260
359
 
261
360
  // A live re-execution of the open step: end the stale attempt's parts and tell the client which ones to discard.
262
361
  function* supersede(state: ReaderState, attempt: string): Generator<UIMessageChunk> {
263
- for (const [id, kind] of state.openParts) yield { type: kind === 'text' ? 'text-end' : 'reasoning-end', id };
362
+ for (const [id, kind] of state.openParts) {
363
+ if (!state.detachedParts.has(id)) yield { type: kind === 'text' ? 'text-end' : 'reasoning-end', id };
364
+ }
264
365
  yield {
265
366
  type: 'data-dbos-superseded',
266
367
  data: { attempt: state.openAttempt, parts: [...state.openParts.keys()] },
267
368
  transient: true,
268
369
  } as UIMessageChunk;
269
370
  state.openParts.clear();
371
+ state.detachedParts.clear();
270
372
  state.openAttempt = attempt;
271
373
  }
272
374
 
@@ -280,8 +382,7 @@ function* emitRecord(
280
382
  case 'model': {
281
383
  if (state.openStep !== record.step) {
282
384
  yield* closeStep(state);
283
- if (!state.resumed) yield { type: 'start-step' };
284
- state.resumed = false;
385
+ yield { type: 'start-step' };
285
386
  state.openStep = record.step;
286
387
  state.openAttempt = record.attempt;
287
388
  } else if (state.openAttempt !== record.attempt) {
@@ -300,14 +401,27 @@ function* emitRecord(
300
401
  }
301
402
  case 'model-end':
302
403
  // The stream outlives the call: the workflow may run more calls, so only its end (or closeDurableStream) ends the turn.
303
- if (record.attempt === state.openAttempt) state.finishReason = record.aborted ? 'other' : record.finishReason?.unified;
404
+ if (record.attempt === state.openAttempt) state.finishReason = finishReasonOf(record);
304
405
  break;
305
- case 'tool':
406
+ case 'tool': {
407
+ const key = toolKey(record);
408
+ const chunks = record.chunks ?? [];
409
+ const sent = state.toolChunksSent.get(key);
410
+ // A live re-execution that wrote the same chunks changes nothing; different ones (or none) replace what the client has.
411
+ if (chunks.length > 0 || sent !== undefined) {
412
+ const digest = createHash('sha256').update(JSON.stringify(chunks)).digest('base64');
413
+ if (sent !== digest) {
414
+ if (sent !== undefined) yield { type: 'data-dbos-tool-superseded', data: { toolCallId: record.toolCallId }, transient: true } as UIMessageChunk;
415
+ state.toolChunksSent.set(key, digest);
416
+ yield* chunks;
417
+ }
418
+ }
306
419
  // Local tool errors are masked like the AI SDK does; provider-executed ones (in model records) pass through verbatim.
307
420
  yield record.errorText !== undefined
308
421
  ? { type: 'tool-output-error', toolCallId: record.toolCallId, errorText: filter.onError(new Error(record.errorText)) }
309
422
  : { type: 'tool-output-available', toolCallId: record.toolCallId, output: record.output };
310
423
  break;
424
+ }
311
425
  case 'ui':
312
426
  yield* record.chunks;
313
427
  break;
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { durableCalls, DurableCallsOptions, durableEmbeddingCalls, durableImageCalls } from './middleware';
2
2
  export { durableMCPTools, DurableMCPToolsOptions, MCPClientLike } from './mcp';
3
- export { durableTools, DurableToolsOptions } from './tools';
3
+ export { durableTools, DurableToolsOptions, toolWriter } from './tools';
4
4
  export {
5
5
  closeDurableStream,
6
6
  DurableStreamOptions,
package/src/internal.ts CHANGED
@@ -21,6 +21,31 @@ export function runDurableStep<T>(name: string, fn: () => Promise<T>, config: St
21
21
  });
22
22
  }
23
23
 
24
+ // DBOS 5.1+ fires stepStatus.cancelSignal when the step's workflow is cancelled; older versions have none.
25
+ export function stepCancelSignal(): AbortSignal | undefined {
26
+ return (DBOS.stepStatus as { cancelSignal?: AbortSignal } | undefined)?.cancelSignal;
27
+ }
28
+
29
+ /**
30
+ * DBOS discards a timed-out attempt's result, so a stream record of it would contradict the checkpoint. Returns the
31
+ * outcome to record instead: the timeout when the step ends with it, `null` when a retry follows, `undefined` if not timed out.
32
+ */
33
+ export async function timedOutOutcome(shouldRetry: StepConfig['shouldRetry']): Promise<{ errorText: string } | null | undefined> {
34
+ const status = DBOS.stepStatus;
35
+ if (status?.timeoutSignal?.aborted !== true) return undefined;
36
+ const reason: unknown = status.timeoutSignal.reason;
37
+ const lastAttempt = status.currentAttempt === undefined || status.currentAttempt >= (status.maxAttempts ?? 1);
38
+ // DBOS asks the step's shouldRetry about this same error (the signal's reason) before retrying; a throw ends the step.
39
+ const retried = !lastAttempt && (await Promise.resolve(shouldRetry ? shouldRetry(reason) : true).catch(() => false));
40
+ return retried ? null : { errorText: reason instanceof Error ? reason.message : 'The step timed out.' };
41
+ }
42
+
43
+ // Fires when any given signal does; a lone signal is returned as-is.
44
+ export function anySignal(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined {
45
+ const defined = signals.filter((s): s is AbortSignal => s !== undefined);
46
+ return defined.length <= 1 ? defined[0] : AbortSignal.any(defined);
47
+ }
48
+
24
49
  export function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
25
50
  return typeof (value as AsyncIterable<unknown> | null | undefined)?.[Symbol.asyncIterator] === 'function';
26
51
  }
package/src/mcp.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { StepConfig } from '@dbos-inc/dbos-sdk';
1
+ import { DBOS, StepConfig } from '@dbos-inc/dbos-sdk';
2
2
  import type { ToolSet } from 'ai' with { 'resolution-mode': 'import' };
3
- import { isAsyncIterable, runDurableStep, withErrorClassification } from './internal';
3
+ import { anySignal, isAsyncIterable, runDurableStep, stepCancelSignal, timedOutOutcome, withErrorClassification } from './internal';
4
4
  import { writeToolRecord } from './durable-stream';
5
5
 
6
6
  // Structural type for an MCP client (e.g. from @ai-sdk/mcp) — deliberately loose: the AI SDK ecosystem
@@ -112,11 +112,20 @@ export async function durableMCPTools(client: MCPClientLike, options: DurableMCP
112
112
  return run(
113
113
  `mcp.tool.${name}.${toolCallId ?? 'call'}`,
114
114
  async () => {
115
+ // Stop the call when the attempt times out or the workflow is cancelled, as well as on the caller's abort.
116
+ const cancelSignal = stepCancelSignal();
117
+ const record = async (outcome: { output: unknown } | { errorText: string }) => {
118
+ if (!durableStream || !toolCallId) return;
119
+ const timeout = await timedOutOutcome(callConfig.shouldRetry);
120
+ if (timeout === null) return;
121
+ await writeToolRecord(durableStream, toolCallId, timeout ?? outcome);
122
+ };
123
+ const abortSignal = anySignal(signal, DBOS.stepStatus?.timeoutSignal, cancelSignal);
115
124
  let output: unknown;
116
125
  try {
117
126
  const tool = (await client.tools(toolOptions))[name] as MCPToolLike | undefined;
118
127
  if (typeof tool?.execute !== 'function') throw new Error(`MCP tool "${name}" is not executable.`);
119
- output = await tool.execute(input, execOptions);
128
+ output = await tool.execute(input, abortSignal === signal ? execOptions : { ...execOptions, abortSignal });
120
129
  // A streaming execute can't checkpoint mid-flight; drain it and record the final value (the last yield).
121
130
  if (isAsyncIterable(output)) {
122
131
  let last: unknown;
@@ -124,12 +133,10 @@ export async function durableMCPTools(client: MCPClientLike, options: DurableMCP
124
133
  output = last;
125
134
  }
126
135
  } catch (error) {
127
- if (durableStream && toolCallId) {
128
- await writeToolRecord(durableStream, toolCallId, { errorText: error instanceof Error ? error.message : String(error) });
129
- }
136
+ if (!cancelSignal?.aborted) await record({ errorText: error instanceof Error ? error.message : String(error) });
130
137
  throw error;
131
138
  }
132
- if (durableStream && toolCallId) await writeToolRecord(durableStream, toolCallId, { output });
139
+ await record({ output });
133
140
  return output;
134
141
  },
135
142
  callConfig,
package/src/middleware.ts CHANGED
@@ -20,12 +20,14 @@ import type {
20
20
  SharedV4ProviderMetadata,
21
21
  SharedV4Warning,
22
22
  } from '@ai-sdk/provider' with { 'resolution-mode': 'import' };
23
- import { assertNotInTransaction, isInWorkflowFunction, restoreAISDKErrorIdentity, withErrorClassification } from './internal';
23
+ import { anySignal, assertNotInTransaction, isInWorkflowFunction, restoreAISDKErrorIdentity, stepCancelSignal, withErrorClassification } from './internal';
24
24
  import { type DurableStreamOptions, ModelStreamWriter, resolveDurableStream } from './durable-stream';
25
25
 
26
26
  export interface DurableCallsOptions extends StepConfig {
27
27
  /** Write each streamed model call's parts to this durable stream from inside its step (see readDurableStream). */
28
28
  durableStream?: DurableStreamOptions;
29
+ /** Checkpoint a generate call's raw provider request/response bodies; set to match generateText's `include` (both default false). */
30
+ include?: { requestBody?: boolean; responseBody?: boolean };
29
31
  }
30
32
 
31
33
  // In-flight durable model calls per workflow; concurrent calls have a nondeterministic DBOS step order on replay, so we reject them.
@@ -58,7 +60,7 @@ function exitDurableModelCall(workflowID: string): void {
58
60
 
59
61
  /** 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. */
60
62
  export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMiddleware {
61
- const { durableStream, ...stepOptions } = options;
63
+ const { durableStream, include, ...stepOptions } = options;
62
64
  const stepConfig = withErrorClassification(stepOptions);
63
65
  const streamConfig = resolveDurableStream(durableStream);
64
66
  return {
@@ -73,7 +75,10 @@ export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMi
73
75
  try {
74
76
  return await DBOS.runStep(
75
77
  async () => {
76
- const result = ensureResponseMetadata(encodeBinaryContent(await doGenerate()));
78
+ // The provider call also stops when the attempt times out or the workflow is cancelled.
79
+ const signal = anySignal(params.abortSignal, DBOS.stepStatus?.timeoutSignal, stepCancelSignal());
80
+ const raw = signal === params.abortSignal ? await doGenerate() : await model.doGenerate({ ...params, abortSignal: signal });
81
+ const result = ensureResponseMetadata(encodeBinaryContent(omitBodies(raw, include)));
77
82
  // A non-streaming call writes its whole output at once, so the stream holds every call the loop makes, not only streamed ones.
78
83
  if (streamConfig) {
79
84
  const streamWriter = new ModelStreamWriter(streamConfig);
@@ -155,22 +160,26 @@ export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMi
155
160
  const accumulator = new StreamAccumulator();
156
161
  // 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.
157
162
  const timeoutSignal = DBOS.stepStatus?.timeoutSignal;
158
- let streamResult: Awaited<ReturnType<typeof doStream>> | undefined;
163
+ // A workflow cancel is not a consumer abort: it must fail the attempt so nothing is checkpointed and a resume re-runs the call.
164
+ const cancelSignal = stepCancelSignal();
165
+ const cancelledWorkflow = () => cancelSignal?.aborted === true;
166
+ const providerSignal = anySignal(abortSignal, timeoutSignal, cancelSignal);
159
167
  let reader: ReadableStreamDefaultReader<LanguageModelV4StreamPart> | undefined;
160
168
  let sawFinish = false;
161
169
  const abandon = () => void reader?.cancel().catch(() => {});
162
170
  timeoutSignal?.addEventListener('abort', abandon, { once: true });
171
+ cancelSignal?.addEventListener('abort', abandon, { once: true });
163
172
  // A consumer abort tears the provider call down too; the attempt is then recorded as aborted below.
164
173
  abortSignal?.addEventListener('abort', abandon, { once: true });
165
174
  // Step-scope stream writes: cheap, replay-safe, and never duplicated since a retry is refused once content has streamed.
166
175
  const streamWriter = streamConfig ? new ModelStreamWriter(streamConfig) : undefined;
167
176
  try {
168
- streamResult = await doStream();
169
- reader = streamResult.stream.getReader();
177
+ const result = providerSignal === abortSignal ? await doStream() : await model.doStream({ ...params, abortSignal: providerSignal });
178
+ reader = result.stream.getReader();
170
179
  for (;;) {
171
180
  const { done, value: part } = await reader.read();
172
181
  if (timeoutSignal?.aborted) throw (timeoutSignal.reason ?? new Error('step attempt timed out'));
173
- if (done || aborted()) break;
182
+ if (done || aborted() || cancelledWorkflow()) break;
174
183
  if (part.type === 'error') {
175
184
  // A cancelled consumer abandoned this call; don't let a late failure become the step outcome, or replay would fail where the live run succeeded.
176
185
  if (cancelled) break;
@@ -183,21 +192,26 @@ export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMi
183
192
  accumulator.add(part);
184
193
  }
185
194
  // No terminal part and no output: fail (retryably) like the AI SDK's NoOutputGeneratedError, instead of checkpointing a permanent empty success.
186
- if (!sawFinish && !accumulator.hasContent && !cancelled && !aborted()) {
195
+ if (!sawFinish && !accumulator.hasContent && !cancelled && !aborted() && !cancelledWorkflow()) {
187
196
  throw new Error('Model stream ended without a finish part or any output.');
188
197
  }
189
198
  } catch (error) {
190
- // Same rule for stream-level failures (doStream or a read rejecting) after a cancel; after an abort, the abort is the outcome.
191
- if (!cancelled && !aborted()) {
199
+ // Same rule for stream-level failures (doStream or a read rejecting) after a cancel; after an abort or a workflow cancel, that is the outcome.
200
+ if (!cancelled && !aborted() && !cancelledWorkflow()) {
192
201
  await streamWriter?.abandon();
193
202
  throw error;
194
203
  }
195
204
  } finally {
196
205
  timeoutSignal?.removeEventListener('abort', abandon);
206
+ cancelSignal?.removeEventListener('abort', abandon);
197
207
  abortSignal?.removeEventListener('abort', abandon);
198
208
  // Tear down the provider stream on early exits (error part, post-cancel break); a no-op after a clean drain.
199
209
  void reader?.cancel().catch(() => {});
200
210
  }
211
+ if (cancelledWorkflow()) {
212
+ await streamWriter?.abandon();
213
+ throw cancelSignal!.reason;
214
+ }
201
215
  // Record the abort as the step's failure; replay rethrows it, so the workflow must catch aborts it means to survive.
202
216
  if (aborted()) {
203
217
  await streamWriter?.end({ aborted: true });
@@ -208,7 +222,7 @@ export function durableCalls(options: DurableCallsOptions = {}): LanguageModelMi
208
222
  // Skip the live emit for a timed-out (abandoned) attempt so it can't interleave with its retry.
209
223
  const responseMetadataPart = accumulator.fillResponseMetadata(randomUUID(), new Date());
210
224
  if (responseMetadataPart && !timeoutSignal?.aborted) emit(responseMetadataPart);
211
- const recorded = encodeBinaryContent(accumulator.result(streamResult?.request, streamResult?.response));
225
+ const recorded = encodeBinaryContent(accumulator.result());
212
226
  // Every stream write lands before the checkpoint, so a reader that sees the next step has seen all of this one.
213
227
  await streamWriter?.end({ finishReason: recorded.finishReason });
214
228
  return recorded;
@@ -272,14 +286,17 @@ export function durableEmbeddingCalls(options: StepConfig = {}): EmbeddingModelM
272
286
  then: (onfulfilled, onrejected) =>
273
287
  Promise.resolve(isInWorkflowFunction() ? false : model.supportsParallelCalls).then(onfulfilled, onrejected),
274
288
  }),
275
- wrapEmbed: async ({ doEmbed, model }) => {
289
+ wrapEmbed: async ({ doEmbed, params, model }) => {
276
290
  assertNotInTransaction('embed');
277
291
  if (!isInWorkflowFunction()) {
278
292
  return await doEmbed();
279
293
  }
280
294
  const workflowID = await enterDurableModelCall();
281
295
  try {
282
- return await DBOS.runStep(async () => doEmbed(), {
296
+ return await DBOS.runStep(async () => {
297
+ const signal = anySignal(params.abortSignal, DBOS.stepStatus?.timeoutSignal, stepCancelSignal());
298
+ return signal === params.abortSignal ? doEmbed() : model.doEmbed({ ...params, abortSignal: signal });
299
+ }, {
283
300
  ...stepConfig,
284
301
  name: stepConfig.name ?? stepName(model, 'embed'),
285
302
  });
@@ -301,13 +318,16 @@ export function durableImageCalls(options: StepConfig = {}): ImageModelMiddlewar
301
318
  const stepConfig = withErrorClassification(options);
302
319
  return {
303
320
  specificationVersion: 'v4',
304
- wrapGenerate: async ({ doGenerate, model }) => {
321
+ wrapGenerate: async ({ doGenerate, params, model }) => {
305
322
  assertNotInTransaction('generateImage');
306
323
  if (!isInWorkflowFunction()) {
307
324
  return await doGenerate();
308
325
  }
309
326
  try {
310
- return await DBOS.runStep(async () => encodeImageResult(await doGenerate()), {
327
+ return await DBOS.runStep(async () => {
328
+ const signal = anySignal(params.abortSignal, DBOS.stepStatus?.timeoutSignal, stepCancelSignal());
329
+ return encodeImageResult(signal === params.abortSignal ? await doGenerate() : await model.doGenerate({ ...params, abortSignal: signal }));
330
+ }, {
311
331
  ...stepConfig,
312
332
  name: stepConfig.name ?? stepName(model, 'image'),
313
333
  });
@@ -371,6 +391,21 @@ function ensureResponseMetadata(result: LanguageModelV4GenerateResult): Language
371
391
  };
372
392
  }
373
393
 
394
+ // generateText discards the raw bodies unless `include` asks for them, and the request body repeats the whole prompt.
395
+ function omitBodies(
396
+ result: LanguageModelV4GenerateResult,
397
+ include: DurableCallsOptions['include'],
398
+ ): LanguageModelV4GenerateResult {
399
+ const dropRequest = !include?.requestBody && result.request?.body !== undefined;
400
+ const dropResponse = !include?.responseBody && result.response?.body !== undefined;
401
+ if (!dropRequest && !dropResponse) return result;
402
+ return {
403
+ ...result,
404
+ request: dropRequest ? { ...result.request, body: undefined } : result.request,
405
+ response: dropResponse ? { ...result.response, body: undefined } : result.response,
406
+ };
407
+ }
408
+
374
409
  /** Convert generated-file bytes (Uint8Array) to base64 (spec-allowed) to keep checkpoints compact. */
375
410
  function encodeBinaryContent(result: LanguageModelV4GenerateResult): LanguageModelV4GenerateResult {
376
411
  const content = result.content.map(encodeBinaryPart);
@@ -515,18 +550,15 @@ class StreamAccumulator {
515
550
  return block;
516
551
  }
517
552
 
518
- result(
519
- request?: { body?: unknown },
520
- response?: { headers?: Record<string, string> },
521
- ): LanguageModelV4GenerateResult {
553
+ // Omits the provider's request/response (wrapStream returns only the stream): the request body repeats the whole prompt.
554
+ result(): LanguageModelV4GenerateResult {
522
555
  return {
523
556
  content: this.content,
524
557
  finishReason: this.finishReason,
525
558
  usage: this.usage,
526
559
  warnings: this.warnings,
527
560
  providerMetadata: this.providerMetadata,
528
- request,
529
- response: this.responseMetadata ? { ...this.responseMetadata, ...response } : response,
561
+ response: this.responseMetadata,
530
562
  };
531
563
  }
532
564
  }