@tanstack/ai 0.31.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 (83) 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/adapter.d.ts +8 -4
  11. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  12. package/dist/esm/activities/generateImage/index.d.ts +26 -3
  13. package/dist/esm/activities/generateImage/index.js +38 -2
  14. package/dist/esm/activities/generateImage/index.js.map +1 -1
  15. package/dist/esm/activities/generateSpeech/index.d.ts +7 -0
  16. package/dist/esm/activities/generateSpeech/index.js +26 -1
  17. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  18. package/dist/esm/activities/generateTranscription/index.d.ts +7 -0
  19. package/dist/esm/activities/generateTranscription/index.js +26 -1
  20. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  21. package/dist/esm/activities/generateVideo/adapter.d.ts +65 -6
  22. package/dist/esm/activities/generateVideo/adapter.js +14 -0
  23. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  24. package/dist/esm/activities/generateVideo/index.d.ts +40 -5
  25. package/dist/esm/activities/generateVideo/index.js +52 -2
  26. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  27. package/dist/esm/activities/generateVideo/snap.d.ts +14 -0
  28. package/dist/esm/activities/generateVideo/snap.js +54 -0
  29. package/dist/esm/activities/generateVideo/snap.js.map +1 -0
  30. package/dist/esm/activities/index.d.ts +3 -2
  31. package/dist/esm/activities/index.js +2 -0
  32. package/dist/esm/activities/index.js.map +1 -1
  33. package/dist/esm/activities/middleware/index.d.ts +2 -0
  34. package/dist/esm/activities/middleware/run.d.ts +20 -0
  35. package/dist/esm/activities/middleware/run.js +42 -0
  36. package/dist/esm/activities/middleware/run.js.map +1 -0
  37. package/dist/esm/activities/middleware/types.d.ts +118 -0
  38. package/dist/esm/client.d.ts +1 -1
  39. package/dist/esm/client.js.map +1 -1
  40. package/dist/esm/index.d.ts +4 -0
  41. package/dist/esm/index.js +4 -0
  42. package/dist/esm/index.js.map +1 -1
  43. package/dist/esm/middlewares/otel.d.ts +8 -2
  44. package/dist/esm/middlewares/otel.js +145 -95
  45. package/dist/esm/middlewares/otel.js.map +1 -1
  46. package/dist/esm/middlewares/usage-attributes.d.ts +24 -0
  47. package/dist/esm/middlewares/usage-attributes.js +43 -0
  48. package/dist/esm/middlewares/usage-attributes.js.map +1 -0
  49. package/dist/esm/types.d.ts +103 -14
  50. package/dist/esm/utilities/errors.d.ts +13 -0
  51. package/dist/esm/utilities/errors.js +22 -0
  52. package/dist/esm/utilities/errors.js.map +1 -0
  53. package/dist/esm/utilities/media-prompt.d.ts +35 -0
  54. package/dist/esm/utilities/media-prompt.js +43 -0
  55. package/dist/esm/utilities/media-prompt.js.map +1 -0
  56. package/dist/esm/utilities/numbers.d.ts +8 -0
  57. package/dist/esm/utilities/numbers.js +12 -0
  58. package/dist/esm/utilities/numbers.js.map +1 -0
  59. package/package.json +2 -2
  60. package/skills/ai-core/media-generation/SKILL.md +173 -3
  61. package/src/activities/chat/index.ts +32 -4
  62. package/src/activities/chat/middleware/types.ts +7 -0
  63. package/src/activities/chat/tools/lazy-tool-manager.ts +46 -4
  64. package/src/activities/generateAudio/index.ts +42 -1
  65. package/src/activities/generateImage/adapter.ts +16 -3
  66. package/src/activities/generateImage/index.ts +90 -5
  67. package/src/activities/generateSpeech/index.ts +42 -1
  68. package/src/activities/generateTranscription/index.ts +42 -1
  69. package/src/activities/generateVideo/adapter.ts +80 -4
  70. package/src/activities/generateVideo/index.ts +141 -6
  71. package/src/activities/generateVideo/snap.ts +100 -0
  72. package/src/activities/index.ts +4 -0
  73. package/src/activities/middleware/index.ts +20 -0
  74. package/src/activities/middleware/run.ts +88 -0
  75. package/src/activities/middleware/types.ts +173 -0
  76. package/src/client.ts +4 -0
  77. package/src/index.ts +23 -0
  78. package/src/middlewares/otel.ts +195 -120
  79. package/src/middlewares/usage-attributes.ts +65 -0
  80. package/src/types.ts +126 -13
  81. package/src/utilities/errors.ts +29 -0
  82. package/src/utilities/media-prompt.ts +86 -0
  83. package/src/utilities/numbers.ts +15 -0
@@ -10,10 +10,21 @@
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 {
26
+ MediaPrompt,
27
+ MediaPromptFor,
17
28
  StreamChunk,
18
29
  TokenUsage,
19
30
  VideoJobResult,
@@ -50,6 +61,40 @@ export type VideoSizeForAdapter<TAdapter> =
50
61
  : string
51
62
  : string
52
63
 
64
+ /**
65
+ * Extract the prompt type a model accepts from a VideoAdapter via ~types.
66
+ * Mirrors `ImagePromptForModel`: models in the adapter's input-modality map
67
+ * get a `prompt` narrowed to text + their supported part types; adapters
68
+ * without a map fall back to the full MediaPrompt.
69
+ */
70
+ export type VideoPromptForAdapter<TAdapter> =
71
+ TAdapter extends VideoAdapter<infer TModel, any, any, any, infer ModsByName>
72
+ ? string extends keyof ModsByName
73
+ ? MediaPrompt
74
+ : TModel extends keyof ModsByName
75
+ ? MediaPromptFor<ModsByName[TModel][number]>
76
+ : MediaPrompt
77
+ : MediaPrompt
78
+
79
+ /**
80
+ * Extract the duration type for a VideoAdapter's model via ~types.
81
+ * Mirrors `VideoSizeForAdapter`. Falls back to `number` for adapters that
82
+ * haven't declared per-model duration constraints.
83
+ */
84
+ export type VideoDurationForAdapter<TAdapter> =
85
+ TAdapter extends VideoAdapter<
86
+ infer TModel,
87
+ any,
88
+ any,
89
+ any,
90
+ any,
91
+ infer TDurationMap
92
+ >
93
+ ? TModel extends keyof TDurationMap
94
+ ? TDurationMap[TModel]
95
+ : number
96
+ : number
97
+
53
98
  // ===========================
54
99
  // Activity Options Types
55
100
 
@@ -84,12 +129,25 @@ export type VideoCreateOptions<
84
129
  > = VideoActivityBaseOptions<TAdapter> & {
85
130
  /** Request type - create a new job (default if not specified) */
86
131
  request?: 'create'
87
- /** Text description of the desired video */
88
- prompt: string
132
+ /**
133
+ * Description of the desired video. Either a plain string, or — for models
134
+ * that support image-conditioned generation — an ordered array of content
135
+ * parts interleaving text with image inputs. Image parts may carry
136
+ * `metadata.role` (`'start_frame' | 'end_frame' | 'reference' |
137
+ * 'character'`) to disambiguate intent; positional fallback otherwise. The
138
+ * accepted part types are narrowed per model via the adapter's
139
+ * input-modality map.
140
+ */
141
+ prompt: VideoPromptForAdapter<TAdapter>
89
142
  /** Video size — format depends on the provider (e.g., "16:9", "1280x720") */
90
143
  size?: VideoSizeForAdapter<TAdapter>
91
- /** Video duration in seconds */
92
- duration?: number
144
+ /**
145
+ * Video duration in seconds. Adapters that declare a per-model duration
146
+ * map narrow this to the model's valid union (e.g. `4 | 6 | 8` for Veo 3).
147
+ * Pass `adapter.snapDuration(seconds)` to coerce raw seconds to a valid
148
+ * value.
149
+ */
150
+ duration?: VideoDurationForAdapter<TAdapter>
93
151
  /**
94
152
  * Whether to stream the video generation lifecycle.
95
153
  * When true, returns an AsyncIterable<StreamChunk> that handles the full
@@ -111,6 +169,14 @@ export type VideoCreateOptions<
111
169
  * control and/or a custom `Logger`.
112
170
  */
113
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>
114
180
  } & ({} extends VideoProviderOptions<TAdapter>
115
181
  ? {
116
182
  /** Provider-specific options for video generation */ modelOptions?: VideoProviderOptions<TAdapter>
@@ -250,14 +316,27 @@ export function generateVideo<
250
316
  async function runCreateVideoJob<
251
317
  TAdapter extends VideoAdapter<string, any, any, any>,
252
318
  >(options: VideoCreateOptions<TAdapter, boolean>): Promise<VideoJobResult> {
253
- const { adapter, prompt, size, duration, modelOptions } = options
319
+ const { adapter, prompt, size, duration, modelOptions, middleware } = options
254
320
  const model = adapter.model
321
+ const requestId = createId('video')
322
+ const startTime = Date.now()
255
323
  const logger: InternalLogger = resolveDebugOption(options.debug)
256
324
  const providerName =
257
325
  (adapter as { name?: string; provider?: string }).provider ??
258
326
  (adapter as { name?: string }).name ??
259
327
  'unknown'
260
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
+
261
340
  logger.request(`activity=generateVideo provider=${providerName}`, {
262
341
  provider: providerName,
263
342
  model,
@@ -276,8 +355,17 @@ async function runCreateVideoJob<
276
355
  jobId: result.jobId,
277
356
  model: result.model,
278
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
+ })
279
363
  return result
280
364
  } catch (error) {
365
+ await runGenerationError(middleware, mwCtx, {
366
+ error,
367
+ duration: Date.now() - startTime,
368
+ })
281
369
  logger.errors('generateVideo activity failed', {
282
370
  error,
283
371
  source: 'generateVideo',
@@ -297,9 +385,11 @@ function sleep(ms: number): Promise<void> {
297
385
  async function* runStreamingVideoGeneration<
298
386
  TAdapter extends VideoAdapter<string, any, any, any>,
299
387
  >(options: VideoCreateOptions<TAdapter, true>): AsyncIterable<StreamChunk> {
300
- const { adapter, prompt, size, duration, modelOptions } = options
388
+ const { adapter, prompt, size, duration, modelOptions, middleware } = options
301
389
  const model = adapter.model
302
390
  const runId = options.runId ?? createId('run')
391
+ const requestId = createId('video')
392
+ const obsStartTime = Date.now()
303
393
  const pollingInterval = options.pollingInterval ?? 2000
304
394
  const maxDuration = options.maxDuration ?? 600_000
305
395
  const logger: InternalLogger = resolveDebugOption(options.debug)
@@ -317,6 +407,17 @@ async function* runStreamingVideoGeneration<
317
407
  timestamp: Date.now(),
318
408
  } as StreamChunk
319
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
+
320
421
  logger.request(
321
422
  `activity=generateVideo provider=${providerName} stream=true`,
322
423
  {
@@ -325,6 +426,9 @@ async function* runStreamingVideoGeneration<
325
426
  },
326
427
  )
327
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
328
432
  try {
329
433
  // Create the video generation job
330
434
  const jobResult = await adapter.createVideoJob({
@@ -373,6 +477,18 @@ async function* runStreamingVideoGeneration<
373
477
  },
374
478
  )
375
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
+
376
492
  yield {
377
493
  type: 'CUSTOM',
378
494
  name: 'generation:result',
@@ -404,6 +520,14 @@ async function* runStreamingVideoGeneration<
404
520
  throw new Error('Video generation timed out')
405
521
  } catch (error: unknown) {
406
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
+ })
407
531
  logger.errors('generateVideo activity failed', {
408
532
  message: payload.message,
409
533
  code: payload.code,
@@ -418,6 +542,17 @@ async function* runStreamingVideoGeneration<
418
542
  error: payload,
419
543
  timestamp: Date.now(),
420
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
+ }
421
556
  }
422
557
  }
423
558
 
@@ -0,0 +1,100 @@
1
+ import type { DurationOptions } from './adapter'
2
+
3
+ /**
4
+ * Extract a numeric seconds value from a `DurationOptions` entry. Returns
5
+ * `null` for entries that don't parse as a number — e.g. `'auto'`.
6
+ *
7
+ * Handles the keyword-with-unit form FAL uses for Luma/Veo (`'8s'`, `'9s'`)
8
+ * by stripping a trailing `s`. Pure-numeric strings (`'5'`, `'10'`) parse via
9
+ * Number(). Numbers pass through.
10
+ */
11
+ function entryToSeconds(entry: string | number): number | null {
12
+ if (typeof entry === 'number') {
13
+ return Number.isFinite(entry) ? entry : null
14
+ }
15
+ const stripped = entry.endsWith('s') ? entry.slice(0, -1) : entry
16
+ const parsed = Number(stripped)
17
+ return Number.isFinite(parsed) ? parsed : null
18
+ }
19
+
20
+ /**
21
+ * Snap a raw seconds value to the closest valid duration for a model's
22
+ * `DurationOptions`.
23
+ *
24
+ * - `none` → `undefined`
25
+ * - `discrete` → closest numeric-parseable entry; if none parse,
26
+ * returns `values[0]` (keyword-only models like 'auto')
27
+ * - `range` → clamped to [min, max] and rounded to `step` (default 1)
28
+ * - `mixed` → closest of (discrete numerics ∪ range values)
29
+ *
30
+ * @experimental Video generation is an experimental feature and may change.
31
+ */
32
+ export function snapToDurationOption<T extends string | number | undefined>(
33
+ seconds: number,
34
+ options: DurationOptions<T>,
35
+ ): T | undefined {
36
+ switch (options.kind) {
37
+ case 'none':
38
+ return undefined
39
+
40
+ case 'discrete': {
41
+ return pickClosestDiscrete(seconds, options.values)
42
+ }
43
+
44
+ case 'range': {
45
+ const step = options.step ?? 1
46
+ const clamped = Math.min(options.max, Math.max(options.min, seconds))
47
+ const snapped =
48
+ Math.round((clamped - options.min) / step) * step + options.min
49
+ return Math.min(options.max, Math.max(options.min, snapped)) as T
50
+ }
51
+
52
+ case 'mixed': {
53
+ const discreteCandidate = pickClosestDiscrete(seconds, options.values)
54
+ if (!options.range) return discreteCandidate
55
+
56
+ const { min, max, step = 1 } = options.range
57
+ const clamped = Math.min(max, Math.max(min, seconds))
58
+ const rangeValue = Math.min(
59
+ max,
60
+ Math.max(min, Math.round((clamped - min) / step) * step + min),
61
+ )
62
+
63
+ // Compare distance; range value is numeric, discrete may have non-numeric
64
+ // first-entry fallback (return distance Infinity for non-numerics).
65
+ const discreteSeconds =
66
+ typeof discreteCandidate === 'number'
67
+ ? discreteCandidate
68
+ : discreteCandidate !== undefined
69
+ ? (entryToSeconds(discreteCandidate) ?? Infinity)
70
+ : Infinity
71
+
72
+ return Math.abs(discreteSeconds - seconds) <=
73
+ Math.abs(rangeValue - seconds)
74
+ ? discreteCandidate
75
+ : (rangeValue as T)
76
+ }
77
+ }
78
+ }
79
+
80
+ function pickClosestDiscrete<T extends string | number>(
81
+ seconds: number,
82
+ values: ReadonlyArray<T>,
83
+ ): T | undefined {
84
+ if (values.length === 0) return undefined
85
+
86
+ let best: T | undefined
87
+ let bestDistance = Infinity
88
+ for (const value of values) {
89
+ const v = entryToSeconds(value)
90
+ if (v === null) continue
91
+ const distance = Math.abs(v - seconds)
92
+ if (distance < bestDistance) {
93
+ bestDistance = distance
94
+ best = value
95
+ }
96
+ }
97
+
98
+ // Keyword-only set (no numeric-parseable entries) — fall back to first entry.
99
+ return best ?? values[0]
100
+ }
@@ -119,6 +119,7 @@ export {
119
119
  type VideoCreateOptions,
120
120
  type VideoStatusOptions,
121
121
  type VideoUrlOptions,
122
+ type VideoDurationForAdapter,
122
123
  } from './generateVideo/index'
123
124
 
124
125
  export {
@@ -126,8 +127,11 @@ export {
126
127
  type VideoAdapter,
127
128
  type VideoAdapterConfig,
128
129
  type AnyVideoAdapter,
130
+ type DurationOptions,
129
131
  } from './generateVideo/adapter'
130
132
 
133
+ export { snapToDurationOption } from './generateVideo/snap'
134
+
131
135
  // ===========================
132
136
  // TTS Activity
133
137
  // ===========================
@@ -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
+ }
@@ -0,0 +1,173 @@
1
+ import type { TokenUsage } from '../../types'
2
+
3
+ // ===========================
4
+ // Generation middleware
5
+ // ===========================
6
+ //
7
+ // The base, activity-agnostic middleware contract. Every activity — chat and
8
+ // the media activities — runs middleware that satisfies this shape. `chat()`
9
+ // accepts the richer `ChatMiddleware` superset (it adds config/chunk/tool
10
+ // hooks and capability primitives on top of these lifecycle hooks); media
11
+ // activities accept `GenerationMiddleware` directly.
12
+ //
13
+ // The relationship is intentionally STRUCTURAL, not nominal: `ChatMiddleware`
14
+ // does not `extends GenerationMiddleware`. Chat hooks use function-property
15
+ // syntax, so under `strictFunctionTypes` a narrowed-context subtype would be
16
+ // rejected; declaring it via inheritance would force method syntax and reopen
17
+ // a bivariance hole (a chat hook reading `ctx.messages` slotted where only a
18
+ // base context exists). Instead, the base context/info types are SUPERTYPES
19
+ // (fewer fields) and the chat context/info types are SUBTYPES (more fields),
20
+ // so a single value whose lifecycle hooks are authored against the base — like
21
+ // `otelMiddleware()` — satisfies `GenerationMiddleware & ChatMiddleware` by
22
+ // contravariance, while an arbitrary `ChatMiddleware` is NOT assignable to
23
+ // `GenerationMiddleware`.
24
+
25
+ /**
26
+ * The activity an observability event describes.
27
+ *
28
+ * Mirrors the public surface a caller reaches for: `'chat'` for `chat()`, and
29
+ * the media kinds for the `generate*` activities. `'tts'` matches the speech
30
+ * adapter's kind (the public discriminator avoids inventing a parallel
31
+ * `'speech'`/`'text'` vocabulary). `otelMiddleware` maps each to its
32
+ * `gen_ai.operation.name`.
33
+ */
34
+ export type GenerationActivity =
35
+ | 'chat'
36
+ | 'image'
37
+ | 'video'
38
+ | 'audio'
39
+ | 'tts'
40
+ | 'transcription'
41
+
42
+ /**
43
+ * Stable context passed to every {@link GenerationMiddleware} hook. Created
44
+ * once per activity call and shared across the hooks of that call.
45
+ *
46
+ * Carries only fields every activity can honor. `ChatMiddlewareContext`
47
+ * structurally includes all of these plus chat-only state (messages,
48
+ * iteration, capabilities, …), which is why a chat middleware that reads those
49
+ * extra fields is not assignable to `GenerationMiddleware`.
50
+ */
51
+ export interface GenerationMiddlewareContext<TContext = unknown> {
52
+ /**
53
+ * Stable id correlating the `onStart` / `onFinish` / `onError` / `onAbort`
54
+ * hooks of a single activity call.
55
+ */
56
+ requestId: string
57
+ /** Which activity this call is. Discriminates media from chat. */
58
+ activity: GenerationActivity
59
+ /** Provider/adapter name (e.g. `"openai"`). Emitted as `gen_ai.system`. */
60
+ provider: string
61
+ /** Model id. Emitted as `gen_ai.request.model`. */
62
+ model: string
63
+ /**
64
+ * Provider-specific options passed to the activity, if any. Typed `unknown`
65
+ * because each activity's options are strongly typed per model; a supertype
66
+ * of `ChatMiddlewareContext`'s `modelOptions`.
67
+ */
68
+ modelOptions?: unknown
69
+ /** Where the call originates. Always `'server'` for media activities. */
70
+ source: 'client' | 'server'
71
+ /** Generate a unique id with the given prefix. */
72
+ createId: (prefix: string) => string
73
+ /** Runtime context provided by the activity options, if any. */
74
+ context: TContext
75
+ }
76
+
77
+ // ===========================
78
+ // Hook payloads
79
+ // ===========================
80
+
81
+ /**
82
+ * Token usage passed to {@link GenerationMiddleware.onUsage}. Kept as an
83
+ * interface extending `TokenUsage` to preserve declaration merging for this
84
+ * publicly exported type.
85
+ */
86
+ export interface GenerationUsageInfo extends TokenUsage {}
87
+
88
+ /** Information passed to {@link GenerationMiddleware.onFinish}. */
89
+ export interface GenerationFinishInfo {
90
+ /** Wall-clock duration of the activity call, in milliseconds. */
91
+ duration: number
92
+ /** Unified usage, when the provider reported it. */
93
+ usage?: TokenUsage | undefined
94
+ }
95
+
96
+ /** Information passed to {@link GenerationMiddleware.onAbort}. */
97
+ export interface GenerationAbortInfo {
98
+ /** The reason for the abort, if provided. */
99
+ reason?: string
100
+ /** Wall-clock duration until the abort, in milliseconds. */
101
+ duration: number
102
+ }
103
+
104
+ /** Information passed to {@link GenerationMiddleware.onError}. */
105
+ export interface GenerationErrorInfo {
106
+ /** The thrown value (typically an `Error`). */
107
+ error: unknown
108
+ /** Wall-clock duration until the failure, in milliseconds. */
109
+ duration: number
110
+ }
111
+
112
+ // ===========================
113
+ // Middleware interface
114
+ // ===========================
115
+
116
+ /**
117
+ * Activity-agnostic, observe-only middleware.
118
+ *
119
+ * A thin lifecycle observer registerable on any activity via its `middleware`
120
+ * option. Unlike `ChatMiddleware` (which can also rewrite config, chunks, and
121
+ * tool calls), these hooks only observe — the right fit for the single
122
+ * request → response shape of media activities. Pass `otelMiddleware()` for
123
+ * OpenTelemetry, or implement the hooks directly for a custom backend.
124
+ *
125
+ * Hooks are awaited in registration order. A hook that throws PROPAGATES and
126
+ * fails the activity — matching `chat()` middleware semantics. Keep them cheap;
127
+ * they run inline with the request.
128
+ *
129
+ * Exactly one of `onFinish` / `onAbort` / `onError` fires per call.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * import { generateImage } from '@tanstack/ai'
134
+ * import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
135
+ * import { openaiImage } from '@tanstack/ai-openai'
136
+ * import { trace } from '@opentelemetry/api'
137
+ *
138
+ * await generateImage({
139
+ * adapter: openaiImage('gpt-image-1'),
140
+ * prompt: 'A serene mountain landscape at sunset',
141
+ * middleware: [otelMiddleware({ tracer: trace.getTracer('my-app') })],
142
+ * })
143
+ * ```
144
+ */
145
+ export interface GenerationMiddleware<TContext = unknown> {
146
+ /** Optional name, surfaced in diagnostics. */
147
+ name?: string
148
+ /** Called before the adapter request begins. */
149
+ onStart?: (ctx: GenerationMiddlewareContext<TContext>) => void | Promise<void>
150
+ /** Called when the provider reports usage, before `onFinish`. */
151
+ onUsage?: (
152
+ ctx: GenerationMiddlewareContext<TContext>,
153
+ usage: GenerationUsageInfo,
154
+ ) => void | Promise<void>
155
+ /** Called after the activity completes successfully. */
156
+ onFinish?: (
157
+ ctx: GenerationMiddlewareContext<TContext>,
158
+ info: GenerationFinishInfo,
159
+ ) => void | Promise<void>
160
+ /** Called when the activity is aborted (e.g. an abandoned stream). */
161
+ onAbort?: (
162
+ ctx: GenerationMiddlewareContext<TContext>,
163
+ info: GenerationAbortInfo,
164
+ ) => void | Promise<void>
165
+ /** Called when the activity throws before completing. */
166
+ onError?: (
167
+ ctx: GenerationMiddlewareContext<TContext>,
168
+ info: GenerationErrorInfo,
169
+ ) => void | Promise<void>
170
+ }
171
+
172
+ /** A `GenerationMiddleware` with a permissive context — for use as a constraint. */
173
+ export type AnyGenerationMiddleware = GenerationMiddleware<any>
package/src/client.ts CHANGED
@@ -97,6 +97,10 @@ export type {
97
97
  CustomEvent,
98
98
  DocumentPart,
99
99
  ImagePart,
100
+ MediaInputMetadata,
101
+ MediaInputRole,
102
+ MediaPrompt,
103
+ MediaPromptPart,
100
104
  MessagePart,
101
105
  ModelMessage,
102
106
  RunErrorEvent,