@tanstack/ai 0.32.0 → 0.33.0

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.
Files changed (60) hide show
  1. package/dist/esm/activities/chat/index.js +24 -3
  2. package/dist/esm/activities/chat/index.js.map +1 -1
  3. package/dist/esm/activities/chat/middleware/types.d.ts +7 -0
  4. package/dist/esm/activities/chat/tools/lazy-tool-manager.d.ts +25 -1
  5. package/dist/esm/activities/chat/tools/lazy-tool-manager.js +26 -2
  6. package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
  7. package/dist/esm/activities/generateAudio/index.d.ts +7 -0
  8. package/dist/esm/activities/generateAudio/index.js +26 -1
  9. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  10. package/dist/esm/activities/generateImage/index.d.ts +7 -0
  11. package/dist/esm/activities/generateImage/index.js +26 -1
  12. package/dist/esm/activities/generateImage/index.js.map +1 -1
  13. package/dist/esm/activities/generateSpeech/index.d.ts +7 -0
  14. package/dist/esm/activities/generateSpeech/index.js +26 -1
  15. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  16. package/dist/esm/activities/generateTranscription/index.d.ts +7 -0
  17. package/dist/esm/activities/generateTranscription/index.js +26 -1
  18. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  19. package/dist/esm/activities/generateVideo/index.d.ts +9 -0
  20. package/dist/esm/activities/generateVideo/index.js +52 -2
  21. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  22. package/dist/esm/activities/middleware/index.d.ts +2 -0
  23. package/dist/esm/activities/middleware/run.d.ts +20 -0
  24. package/dist/esm/activities/middleware/run.js +42 -0
  25. package/dist/esm/activities/middleware/run.js.map +1 -0
  26. package/dist/esm/activities/middleware/types.d.ts +118 -0
  27. package/dist/esm/index.d.ts +2 -0
  28. package/dist/esm/index.js +2 -0
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/middlewares/otel.d.ts +8 -2
  31. package/dist/esm/middlewares/otel.js +145 -95
  32. package/dist/esm/middlewares/otel.js.map +1 -1
  33. package/dist/esm/middlewares/usage-attributes.d.ts +24 -0
  34. package/dist/esm/middlewares/usage-attributes.js +43 -0
  35. package/dist/esm/middlewares/usage-attributes.js.map +1 -0
  36. package/dist/esm/types.d.ts +7 -7
  37. package/dist/esm/utilities/errors.d.ts +13 -0
  38. package/dist/esm/utilities/errors.js +22 -0
  39. package/dist/esm/utilities/errors.js.map +1 -0
  40. package/dist/esm/utilities/numbers.d.ts +8 -0
  41. package/dist/esm/utilities/numbers.js +12 -0
  42. package/dist/esm/utilities/numbers.js.map +1 -0
  43. package/package.json +2 -2
  44. package/src/activities/chat/index.ts +32 -4
  45. package/src/activities/chat/middleware/types.ts +7 -0
  46. package/src/activities/chat/tools/lazy-tool-manager.ts +46 -4
  47. package/src/activities/generateAudio/index.ts +42 -1
  48. package/src/activities/generateImage/index.ts +42 -1
  49. package/src/activities/generateSpeech/index.ts +42 -1
  50. package/src/activities/generateTranscription/index.ts +42 -1
  51. package/src/activities/generateVideo/index.ts +88 -2
  52. package/src/activities/middleware/index.ts +20 -0
  53. package/src/activities/middleware/run.ts +88 -0
  54. package/src/activities/middleware/types.ts +173 -0
  55. package/src/index.ts +19 -0
  56. package/src/middlewares/otel.ts +195 -120
  57. package/src/middlewares/usage-attributes.ts +65 -0
  58. package/src/types.ts +7 -7
  59. package/src/utilities/errors.ts +29 -0
  60. package/src/utilities/numbers.ts +15 -0
@@ -8,8 +8,16 @@
8
8
  import { aiEventClient } from '@tanstack/ai-event-client'
9
9
  import { streamGenerationResult } from '../stream-generation-result.js'
10
10
  import { resolveDebugOption } from '../../logger/resolve'
11
+ import {
12
+ createGenerationContext,
13
+ runGenerationError,
14
+ runGenerationFinish,
15
+ runGenerationStart,
16
+ runGenerationUsage,
17
+ } from '../middleware'
11
18
  import type { InternalLogger } from '../../logger/internal-logger'
12
19
  import type { DebugOption } from '../../logger/types'
20
+ import type { GenerationMiddleware } from '../middleware'
13
21
  import type { AudioAdapter } from './adapter'
14
22
  import type { AudioGenerationResult, StreamChunk } from '../../types'
15
23
 
@@ -70,6 +78,12 @@ export interface AudioActivityOptions<
70
78
  * control and/or a custom `Logger`.
71
79
  */
72
80
  debug?: DebugOption
81
+ /**
82
+ * Observe-only middleware notified on start, usage, success, and error. Pass
83
+ * `otelMiddleware()` to emit OpenTelemetry spans, or implement the
84
+ * `GenerationMiddleware` contract for a custom backend.
85
+ */
86
+ middleware?: Array<GenerationMiddleware>
73
87
  }
74
88
 
75
89
  // ===========================
@@ -135,7 +149,13 @@ async function runGenerateAudio<
135
149
  >(
136
150
  options: AudioActivityOptions<TAdapter, boolean>,
137
151
  ): Promise<AudioGenerationResult> {
138
- const { adapter, stream: _stream, debug: _debug, ...rest } = options
152
+ const {
153
+ adapter,
154
+ stream: _stream,
155
+ debug: _debug,
156
+ middleware,
157
+ ...rest
158
+ } = options
139
159
  const model = adapter.model
140
160
  const requestId = createId('audio')
141
161
  const startTime = Date.now()
@@ -145,6 +165,17 @@ async function runGenerateAudio<
145
165
  (adapter as { name?: string }).name ??
146
166
  'unknown'
147
167
 
168
+ const mwCtx = createGenerationContext({
169
+ requestId,
170
+ activity: 'audio',
171
+ provider: adapter.name,
172
+ model,
173
+ modelOptions: rest.modelOptions,
174
+ createId,
175
+ })
176
+
177
+ await runGenerationStart(middleware, mwCtx)
178
+
148
179
  aiEventClient.emit('audio:request:started', {
149
180
  requestId,
150
181
  provider: adapter.name,
@@ -189,6 +220,12 @@ async function runGenerateAudio<
189
220
  audioDuration: result.audio.duration,
190
221
  })
191
222
 
223
+ if (result.usage) await runGenerationUsage(middleware, mwCtx, result.usage)
224
+ await runGenerationFinish(middleware, mwCtx, {
225
+ duration: elapsedMs,
226
+ usage: result.usage,
227
+ })
228
+
192
229
  return result
193
230
  } catch (error) {
194
231
  const elapsedMs = Date.now() - startTime
@@ -202,6 +239,10 @@ async function runGenerateAudio<
202
239
  modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
203
240
  timestamp: Date.now(),
204
241
  })
242
+ await runGenerationError(middleware, mwCtx, {
243
+ error,
244
+ duration: elapsedMs,
245
+ })
205
246
  logger.errors('generateAudio activity failed', {
206
247
  error,
207
248
  source: 'generateAudio',
@@ -8,9 +8,17 @@
8
8
  import { aiEventClient } from '@tanstack/ai-event-client'
9
9
  import { streamGenerationResult } from '../stream-generation-result.js'
10
10
  import { resolveDebugOption } from '../../logger/resolve'
11
+ import {
12
+ createGenerationContext,
13
+ runGenerationError,
14
+ runGenerationFinish,
15
+ runGenerationStart,
16
+ runGenerationUsage,
17
+ } from '../middleware'
11
18
  import { resolveMediaPrompt } from '../../utilities/media-prompt'
12
19
  import type { InternalLogger } from '../../logger/internal-logger'
13
20
  import type { DebugOption } from '../../logger/types'
21
+ import type { GenerationMiddleware } from '../middleware'
14
22
  import type { ImageAdapter } from './adapter'
15
23
  import type {
16
24
  ImageGenerationResult,
@@ -123,6 +131,12 @@ export type ImageActivityOptions<
123
131
  * control and/or a custom `Logger`.
124
132
  */
125
133
  debug?: DebugOption
134
+ /**
135
+ * Observe-only middleware notified on start, usage, success, and error. Pass
136
+ * `otelMiddleware()` to emit OpenTelemetry spans, or implement the
137
+ * `GenerationMiddleware` contract for a custom backend.
138
+ */
139
+ middleware?: Array<GenerationMiddleware>
126
140
  } & ({} extends ImageProviderOptionsForModel<TAdapter, TAdapter['model']>
127
141
  ? {
128
142
  /** Provider-specific options for image generation */ modelOptions?: ImageProviderOptionsForModel<
@@ -228,12 +242,29 @@ async function runGenerateImage<
228
242
  >(
229
243
  options: ImageActivityOptions<TAdapter, boolean>,
230
244
  ): Promise<ImageGenerationResult> {
231
- const { adapter, stream: _stream, debug: _debug, ...rest } = options
245
+ const {
246
+ adapter,
247
+ stream: _stream,
248
+ debug: _debug,
249
+ middleware,
250
+ ...rest
251
+ } = options
232
252
  const model = adapter.model
233
253
  const requestId = createId('image')
234
254
  const startTime = Date.now()
235
255
  const logger: InternalLogger = resolveDebugOption(options.debug)
236
256
 
257
+ const mwCtx = createGenerationContext({
258
+ requestId,
259
+ activity: 'image',
260
+ provider: adapter.name,
261
+ model,
262
+ modelOptions: rest.modelOptions,
263
+ createId,
264
+ })
265
+
266
+ await runGenerationStart(middleware, mwCtx)
267
+
237
268
  // Devtools events carry the flattened prompt text plus media-part counts —
238
269
  // the wire payload stays `prompt: string` regardless of the prompt shape.
239
270
  const resolved = resolveMediaPrompt(rest.prompt)
@@ -299,8 +330,18 @@ async function runGenerateImage<
299
330
  count: result.images.length,
300
331
  })
301
332
 
333
+ if (result.usage) await runGenerationUsage(middleware, mwCtx, result.usage)
334
+ await runGenerationFinish(middleware, mwCtx, {
335
+ duration,
336
+ usage: result.usage,
337
+ })
338
+
302
339
  return result
303
340
  } catch (error) {
341
+ await runGenerationError(middleware, mwCtx, {
342
+ error,
343
+ duration: Date.now() - startTime,
344
+ })
304
345
  logger.errors('generateImage activity failed', {
305
346
  error,
306
347
  source: 'generateImage',
@@ -8,8 +8,16 @@
8
8
  import { aiEventClient } from '@tanstack/ai-event-client'
9
9
  import { streamGenerationResult } from '../stream-generation-result.js'
10
10
  import { resolveDebugOption } from '../../logger/resolve'
11
+ import {
12
+ createGenerationContext,
13
+ runGenerationError,
14
+ runGenerationFinish,
15
+ runGenerationStart,
16
+ runGenerationUsage,
17
+ } from '../middleware'
11
18
  import type { InternalLogger } from '../../logger/internal-logger'
12
19
  import type { DebugOption } from '../../logger/types'
20
+ import type { GenerationMiddleware } from '../middleware'
13
21
  import type { TTSAdapter } from './adapter'
14
22
  import type { StreamChunk, TTSResult } from '../../types'
15
23
 
@@ -73,6 +81,12 @@ export interface TTSActivityOptions<
73
81
  * control and/or a custom `Logger`.
74
82
  */
75
83
  debug?: DebugOption
84
+ /**
85
+ * Observe-only middleware notified on start, usage, success, and error. Pass
86
+ * `otelMiddleware()` to emit OpenTelemetry spans, or implement the
87
+ * `GenerationMiddleware` contract for a custom backend.
88
+ */
89
+ middleware?: Array<GenerationMiddleware>
76
90
  }
77
91
 
78
92
  // ===========================
@@ -143,7 +157,13 @@ export function generateSpeech<
143
157
  async function runGenerateSpeech<
144
158
  TAdapter extends TTSAdapter<string, TTSProviderOptions<TAdapter>>,
145
159
  >(options: TTSActivityOptions<TAdapter, boolean>): Promise<TTSResult> {
146
- const { adapter, stream: _stream, debug: _debug, ...rest } = options
160
+ const {
161
+ adapter,
162
+ stream: _stream,
163
+ debug: _debug,
164
+ middleware,
165
+ ...rest
166
+ } = options
147
167
  const model = adapter.model
148
168
  const requestId = createId('speech')
149
169
  const startTime = Date.now()
@@ -153,6 +173,17 @@ async function runGenerateSpeech<
153
173
  (adapter as { name?: string }).name ??
154
174
  'unknown'
155
175
 
176
+ const mwCtx = createGenerationContext({
177
+ requestId,
178
+ activity: 'tts',
179
+ provider: adapter.name,
180
+ model,
181
+ modelOptions: rest.modelOptions,
182
+ createId,
183
+ })
184
+
185
+ await runGenerationStart(middleware, mwCtx)
186
+
156
187
  aiEventClient.emit('speech:request:started', {
157
188
  requestId,
158
189
  provider: adapter.name,
@@ -202,6 +233,12 @@ async function runGenerateSpeech<
202
233
  contentType: result.contentType,
203
234
  })
204
235
 
236
+ if (result.usage) await runGenerationUsage(middleware, mwCtx, result.usage)
237
+ await runGenerationFinish(middleware, mwCtx, {
238
+ duration,
239
+ usage: result.usage,
240
+ })
241
+
205
242
  return result
206
243
  } catch (error) {
207
244
  const duration = Date.now() - startTime
@@ -215,6 +252,10 @@ async function runGenerateSpeech<
215
252
  modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
216
253
  timestamp: Date.now(),
217
254
  })
255
+ await runGenerationError(middleware, mwCtx, {
256
+ error,
257
+ duration,
258
+ })
218
259
  logger.errors('generateSpeech activity failed', {
219
260
  error,
220
261
  source: 'generateSpeech',
@@ -8,8 +8,16 @@
8
8
  import { aiEventClient } from '@tanstack/ai-event-client'
9
9
  import { streamGenerationResult } from '../stream-generation-result.js'
10
10
  import { resolveDebugOption } from '../../logger/resolve'
11
+ import {
12
+ createGenerationContext,
13
+ runGenerationError,
14
+ runGenerationFinish,
15
+ runGenerationStart,
16
+ runGenerationUsage,
17
+ } from '../middleware'
11
18
  import type { InternalLogger } from '../../logger/internal-logger'
12
19
  import type { DebugOption } from '../../logger/types'
20
+ import type { GenerationMiddleware } from '../middleware'
13
21
  import type { TranscriptionAdapter } from './adapter'
14
22
  import type { StreamChunk, TranscriptionResult } from '../../types'
15
23
 
@@ -76,6 +84,12 @@ export interface TranscriptionActivityOptions<
76
84
  * control and/or a custom `Logger`.
77
85
  */
78
86
  debug?: DebugOption
87
+ /**
88
+ * Observe-only middleware notified on start, usage, success, and error. Pass
89
+ * `otelMiddleware()` to emit OpenTelemetry spans, or implement the
90
+ * `GenerationMiddleware` contract for a custom backend.
91
+ */
92
+ middleware?: Array<GenerationMiddleware>
79
93
  }
80
94
 
81
95
  // ===========================
@@ -174,7 +188,13 @@ async function runGenerateTranscription<
174
188
  >(
175
189
  options: TranscriptionActivityOptions<TAdapter, boolean>,
176
190
  ): Promise<TranscriptionResult> {
177
- const { adapter, stream: _stream, debug: _debug, ...rest } = options
191
+ const {
192
+ adapter,
193
+ stream: _stream,
194
+ debug: _debug,
195
+ middleware,
196
+ ...rest
197
+ } = options
178
198
  const model = adapter.model
179
199
  const requestId = createId('transcription')
180
200
  const startTime = Date.now()
@@ -184,6 +204,17 @@ async function runGenerateTranscription<
184
204
  (adapter as { name?: string }).name ??
185
205
  'unknown'
186
206
 
207
+ const mwCtx = createGenerationContext({
208
+ requestId,
209
+ activity: 'transcription',
210
+ provider: adapter.name,
211
+ model,
212
+ modelOptions: rest.modelOptions,
213
+ createId,
214
+ })
215
+
216
+ await runGenerationStart(middleware, mwCtx)
217
+
187
218
  aiEventClient.emit('transcription:request:started', {
188
219
  requestId,
189
220
  provider: adapter.name,
@@ -220,6 +251,12 @@ async function runGenerateTranscription<
220
251
  { hasText: !!result.text },
221
252
  )
222
253
 
254
+ if (result.usage) await runGenerationUsage(middleware, mwCtx, result.usage)
255
+ await runGenerationFinish(middleware, mwCtx, {
256
+ duration,
257
+ usage: result.usage,
258
+ })
259
+
223
260
  return result
224
261
  } catch (error) {
225
262
  const duration = Date.now() - startTime
@@ -233,6 +270,10 @@ async function runGenerateTranscription<
233
270
  modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
234
271
  timestamp: Date.now(),
235
272
  })
273
+ await runGenerationError(middleware, mwCtx, {
274
+ error,
275
+ duration,
276
+ })
236
277
  logger.errors('generateTranscription activity failed', {
237
278
  error,
238
279
  source: 'generateTranscription',
@@ -10,8 +10,17 @@
10
10
  import { aiEventClient } from '@tanstack/ai-event-client'
11
11
  import { toRunErrorPayload } from '../error-payload'
12
12
  import { resolveDebugOption } from '../../logger/resolve'
13
+ import {
14
+ createGenerationContext,
15
+ runGenerationAbort,
16
+ runGenerationError,
17
+ runGenerationFinish,
18
+ runGenerationStart,
19
+ runGenerationUsage,
20
+ } from '../middleware'
13
21
  import type { InternalLogger } from '../../logger/internal-logger'
14
22
  import type { DebugOption } from '../../logger/types'
23
+ import type { GenerationMiddleware } from '../middleware'
15
24
  import type { VideoAdapter } from './adapter'
16
25
  import type {
17
26
  MediaPrompt,
@@ -160,6 +169,14 @@ export type VideoCreateOptions<
160
169
  * control and/or a custom `Logger`.
161
170
  */
162
171
  debug?: DebugOption
172
+ /**
173
+ * Observe-only middleware notified on start, usage, success, and error. Pass
174
+ * `otelMiddleware()` to emit OpenTelemetry spans, or implement the
175
+ * `GenerationMiddleware` contract for a custom backend. In streaming mode the
176
+ * span covers the full create→poll→complete lifecycle; in non-streaming mode
177
+ * it covers job submission. An abandoned stream fires `onAbort`.
178
+ */
179
+ middleware?: Array<GenerationMiddleware>
163
180
  } & ({} extends VideoProviderOptions<TAdapter>
164
181
  ? {
165
182
  /** Provider-specific options for video generation */ modelOptions?: VideoProviderOptions<TAdapter>
@@ -299,14 +316,27 @@ export function generateVideo<
299
316
  async function runCreateVideoJob<
300
317
  TAdapter extends VideoAdapter<string, any, any, any>,
301
318
  >(options: VideoCreateOptions<TAdapter, boolean>): Promise<VideoJobResult> {
302
- const { adapter, prompt, size, duration, modelOptions } = options
319
+ const { adapter, prompt, size, duration, modelOptions, middleware } = options
303
320
  const model = adapter.model
321
+ const requestId = createId('video')
322
+ const startTime = Date.now()
304
323
  const logger: InternalLogger = resolveDebugOption(options.debug)
305
324
  const providerName =
306
325
  (adapter as { name?: string; provider?: string }).provider ??
307
326
  (adapter as { name?: string }).name ??
308
327
  'unknown'
309
328
 
329
+ const mwCtx = createGenerationContext({
330
+ requestId,
331
+ activity: 'video',
332
+ provider: adapter.name,
333
+ model,
334
+ modelOptions,
335
+ createId,
336
+ })
337
+
338
+ await runGenerationStart(middleware, mwCtx)
339
+
310
340
  logger.request(`activity=generateVideo provider=${providerName}`, {
311
341
  provider: providerName,
312
342
  model,
@@ -325,8 +355,17 @@ async function runCreateVideoJob<
325
355
  jobId: result.jobId,
326
356
  model: result.model,
327
357
  })
358
+ // Non-streaming create only submits the job; usage isn't known until the
359
+ // job completes via polling, so the span covers submission only.
360
+ await runGenerationFinish(middleware, mwCtx, {
361
+ duration: Date.now() - startTime,
362
+ })
328
363
  return result
329
364
  } catch (error) {
365
+ await runGenerationError(middleware, mwCtx, {
366
+ error,
367
+ duration: Date.now() - startTime,
368
+ })
330
369
  logger.errors('generateVideo activity failed', {
331
370
  error,
332
371
  source: 'generateVideo',
@@ -346,9 +385,11 @@ function sleep(ms: number): Promise<void> {
346
385
  async function* runStreamingVideoGeneration<
347
386
  TAdapter extends VideoAdapter<string, any, any, any>,
348
387
  >(options: VideoCreateOptions<TAdapter, true>): AsyncIterable<StreamChunk> {
349
- const { adapter, prompt, size, duration, modelOptions } = options
388
+ const { adapter, prompt, size, duration, modelOptions, middleware } = options
350
389
  const model = adapter.model
351
390
  const runId = options.runId ?? createId('run')
391
+ const requestId = createId('video')
392
+ const obsStartTime = Date.now()
352
393
  const pollingInterval = options.pollingInterval ?? 2000
353
394
  const maxDuration = options.maxDuration ?? 600_000
354
395
  const logger: InternalLogger = resolveDebugOption(options.debug)
@@ -366,6 +407,17 @@ async function* runStreamingVideoGeneration<
366
407
  timestamp: Date.now(),
367
408
  } as StreamChunk
368
409
 
410
+ const mwCtx = createGenerationContext({
411
+ requestId,
412
+ activity: 'video',
413
+ provider: adapter.name,
414
+ model,
415
+ modelOptions,
416
+ createId,
417
+ })
418
+
419
+ await runGenerationStart(middleware, mwCtx)
420
+
369
421
  logger.request(
370
422
  `activity=generateVideo provider=${providerName} stream=true`,
371
423
  {
@@ -374,6 +426,9 @@ async function* runStreamingVideoGeneration<
374
426
  },
375
427
  )
376
428
 
429
+ // Tracks whether a terminal observer event (finish/error) has already fired,
430
+ // so the `finally` below can fire one on abandonment without double-firing.
431
+ let settled = false
377
432
  try {
378
433
  // Create the video generation job
379
434
  const jobResult = await adapter.createVideoJob({
@@ -422,6 +477,18 @@ async function* runStreamingVideoGeneration<
422
477
  },
423
478
  )
424
479
 
480
+ // Fire finish before yielding the terminal chunks: the generation has
481
+ // succeeded, so a consumer that stops reading after `generation:result`
482
+ // (without pulling `RUN_FINISHED`) must not trip the abandonment path in
483
+ // `finally`, which would otherwise report a spurious cancellation.
484
+ if (urlResult.usage)
485
+ await runGenerationUsage(middleware, mwCtx, urlResult.usage)
486
+ await runGenerationFinish(middleware, mwCtx, {
487
+ duration: Date.now() - obsStartTime,
488
+ usage: urlResult.usage,
489
+ })
490
+ settled = true
491
+
425
492
  yield {
426
493
  type: 'CUSTOM',
427
494
  name: 'generation:result',
@@ -453,6 +520,14 @@ async function* runStreamingVideoGeneration<
453
520
  throw new Error('Video generation timed out')
454
521
  } catch (error: unknown) {
455
522
  const payload = toRunErrorPayload(error, 'Video generation failed')
523
+ // Mark settled before firing onError: if a user error-hook throws, the
524
+ // `finally` below must still not double-fire onAbort over the same op
525
+ // (which would mask the original error and end the span twice).
526
+ settled = true
527
+ await runGenerationError(middleware, mwCtx, {
528
+ error,
529
+ duration: Date.now() - obsStartTime,
530
+ })
456
531
  logger.errors('generateVideo activity failed', {
457
532
  message: payload.message,
458
533
  code: payload.code,
@@ -467,6 +542,17 @@ async function* runStreamingVideoGeneration<
467
542
  error: payload,
468
543
  timestamp: Date.now(),
469
544
  } as StreamChunk
545
+ } finally {
546
+ if (!settled) {
547
+ // The consumer abandoned the stream (broke the `for await` loop or
548
+ // disconnected) before completion, so the generator is being unwound at
549
+ // a `yield` without reaching finish/error. Fire `onAbort` — a cancel, not
550
+ // an error — so otelMiddleware ends its span instead of leaking it.
551
+ await runGenerationAbort(middleware, mwCtx, {
552
+ reason: 'Video generation stream abandoned before completion',
553
+ duration: Date.now() - obsStartTime,
554
+ })
555
+ }
470
556
  }
471
557
  }
472
558
 
@@ -0,0 +1,20 @@
1
+ // Base, activity-agnostic middleware shared by chat and the media activities.
2
+ // The `ChatMiddleware` superset lives at `../chat/middleware`.
3
+ export type {
4
+ GenerationActivity,
5
+ GenerationMiddleware,
6
+ GenerationMiddlewareContext,
7
+ GenerationUsageInfo,
8
+ GenerationFinishInfo,
9
+ GenerationAbortInfo,
10
+ GenerationErrorInfo,
11
+ AnyGenerationMiddleware,
12
+ } from './types'
13
+ export {
14
+ createGenerationContext,
15
+ runGenerationStart,
16
+ runGenerationUsage,
17
+ runGenerationFinish,
18
+ runGenerationAbort,
19
+ runGenerationError,
20
+ } from './run'
@@ -0,0 +1,88 @@
1
+ import type {
2
+ GenerationAbortInfo,
3
+ GenerationErrorInfo,
4
+ GenerationFinishInfo,
5
+ GenerationMiddleware,
6
+ GenerationMiddlewareContext,
7
+ GenerationUsageInfo,
8
+ } from './types'
9
+
10
+ /**
11
+ * Build the stable context for a single media-activity call.
12
+ *
13
+ * Media activities are always server-side and carry no user runtime context,
14
+ * so `source` is fixed to `'server'` and `context` to `undefined`.
15
+ */
16
+ export function createGenerationContext(args: {
17
+ requestId: string
18
+ activity: GenerationMiddlewareContext['activity']
19
+ provider: string
20
+ model: string
21
+ modelOptions?: unknown
22
+ createId: (prefix: string) => string
23
+ }): GenerationMiddlewareContext {
24
+ return {
25
+ requestId: args.requestId,
26
+ activity: args.activity,
27
+ provider: args.provider,
28
+ model: args.model,
29
+ modelOptions: args.modelOptions,
30
+ source: 'server',
31
+ createId: args.createId,
32
+ context: undefined,
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Run a single lifecycle hook across each middleware in registration order,
38
+ * awaiting each. Exceptions PROPAGATE (matching `chat()` middleware) — a
39
+ * broken middleware fails the activity rather than being silently swallowed.
40
+ */
41
+ async function run(
42
+ middleware: ReadonlyArray<GenerationMiddleware> | undefined,
43
+ invoke: (mw: GenerationMiddleware) => void | Promise<void>,
44
+ ): Promise<void> {
45
+ if (!middleware || middleware.length === 0) return
46
+ for (const mw of middleware) {
47
+ await invoke(mw)
48
+ }
49
+ }
50
+
51
+ export function runGenerationStart(
52
+ middleware: ReadonlyArray<GenerationMiddleware> | undefined,
53
+ ctx: GenerationMiddlewareContext,
54
+ ): Promise<void> {
55
+ return run(middleware, (mw) => mw.onStart?.(ctx))
56
+ }
57
+
58
+ export function runGenerationUsage(
59
+ middleware: ReadonlyArray<GenerationMiddleware> | undefined,
60
+ ctx: GenerationMiddlewareContext,
61
+ usage: GenerationUsageInfo,
62
+ ): Promise<void> {
63
+ return run(middleware, (mw) => mw.onUsage?.(ctx, usage))
64
+ }
65
+
66
+ export function runGenerationFinish(
67
+ middleware: ReadonlyArray<GenerationMiddleware> | undefined,
68
+ ctx: GenerationMiddlewareContext,
69
+ info: GenerationFinishInfo,
70
+ ): Promise<void> {
71
+ return run(middleware, (mw) => mw.onFinish?.(ctx, info))
72
+ }
73
+
74
+ export function runGenerationAbort(
75
+ middleware: ReadonlyArray<GenerationMiddleware> | undefined,
76
+ ctx: GenerationMiddlewareContext,
77
+ info: GenerationAbortInfo,
78
+ ): Promise<void> {
79
+ return run(middleware, (mw) => mw.onAbort?.(ctx, info))
80
+ }
81
+
82
+ export function runGenerationError(
83
+ middleware: ReadonlyArray<GenerationMiddleware> | undefined,
84
+ ctx: GenerationMiddlewareContext,
85
+ info: GenerationErrorInfo,
86
+ ): Promise<void> {
87
+ return run(middleware, (mw) => mw.onError?.(ctx, info))
88
+ }