@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.
- package/dist/esm/activities/chat/index.js +24 -3
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/middleware/types.d.ts +7 -0
- package/dist/esm/activities/chat/tools/lazy-tool-manager.d.ts +25 -1
- package/dist/esm/activities/chat/tools/lazy-tool-manager.js +26 -2
- package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
- package/dist/esm/activities/generateAudio/index.d.ts +7 -0
- package/dist/esm/activities/generateAudio/index.js +26 -1
- package/dist/esm/activities/generateAudio/index.js.map +1 -1
- package/dist/esm/activities/generateImage/adapter.d.ts +8 -4
- package/dist/esm/activities/generateImage/adapter.js.map +1 -1
- package/dist/esm/activities/generateImage/index.d.ts +26 -3
- package/dist/esm/activities/generateImage/index.js +38 -2
- package/dist/esm/activities/generateImage/index.js.map +1 -1
- package/dist/esm/activities/generateSpeech/index.d.ts +7 -0
- package/dist/esm/activities/generateSpeech/index.js +26 -1
- package/dist/esm/activities/generateSpeech/index.js.map +1 -1
- package/dist/esm/activities/generateTranscription/index.d.ts +7 -0
- package/dist/esm/activities/generateTranscription/index.js +26 -1
- package/dist/esm/activities/generateTranscription/index.js.map +1 -1
- package/dist/esm/activities/generateVideo/adapter.d.ts +65 -6
- package/dist/esm/activities/generateVideo/adapter.js +14 -0
- package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
- package/dist/esm/activities/generateVideo/index.d.ts +40 -5
- package/dist/esm/activities/generateVideo/index.js +52 -2
- package/dist/esm/activities/generateVideo/index.js.map +1 -1
- package/dist/esm/activities/generateVideo/snap.d.ts +14 -0
- package/dist/esm/activities/generateVideo/snap.js +54 -0
- package/dist/esm/activities/generateVideo/snap.js.map +1 -0
- package/dist/esm/activities/index.d.ts +3 -2
- package/dist/esm/activities/index.js +2 -0
- package/dist/esm/activities/index.js.map +1 -1
- package/dist/esm/activities/middleware/index.d.ts +2 -0
- package/dist/esm/activities/middleware/run.d.ts +20 -0
- package/dist/esm/activities/middleware/run.js +42 -0
- package/dist/esm/activities/middleware/run.js.map +1 -0
- package/dist/esm/activities/middleware/types.d.ts +118 -0
- package/dist/esm/client.d.ts +1 -1
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/middlewares/otel.d.ts +8 -2
- package/dist/esm/middlewares/otel.js +145 -95
- package/dist/esm/middlewares/otel.js.map +1 -1
- package/dist/esm/middlewares/usage-attributes.d.ts +24 -0
- package/dist/esm/middlewares/usage-attributes.js +43 -0
- package/dist/esm/middlewares/usage-attributes.js.map +1 -0
- package/dist/esm/types.d.ts +103 -14
- package/dist/esm/utilities/errors.d.ts +13 -0
- package/dist/esm/utilities/errors.js +22 -0
- package/dist/esm/utilities/errors.js.map +1 -0
- package/dist/esm/utilities/media-prompt.d.ts +35 -0
- package/dist/esm/utilities/media-prompt.js +43 -0
- package/dist/esm/utilities/media-prompt.js.map +1 -0
- package/dist/esm/utilities/numbers.d.ts +8 -0
- package/dist/esm/utilities/numbers.js +12 -0
- package/dist/esm/utilities/numbers.js.map +1 -0
- package/package.json +2 -2
- package/skills/ai-core/media-generation/SKILL.md +173 -3
- package/src/activities/chat/index.ts +32 -4
- package/src/activities/chat/middleware/types.ts +7 -0
- package/src/activities/chat/tools/lazy-tool-manager.ts +46 -4
- package/src/activities/generateAudio/index.ts +42 -1
- package/src/activities/generateImage/adapter.ts +16 -3
- package/src/activities/generateImage/index.ts +90 -5
- package/src/activities/generateSpeech/index.ts +42 -1
- package/src/activities/generateTranscription/index.ts +42 -1
- package/src/activities/generateVideo/adapter.ts +80 -4
- package/src/activities/generateVideo/index.ts +141 -6
- package/src/activities/generateVideo/snap.ts +100 -0
- package/src/activities/index.ts +4 -0
- package/src/activities/middleware/index.ts +20 -0
- package/src/activities/middleware/run.ts +88 -0
- package/src/activities/middleware/types.ts +173 -0
- package/src/client.ts +4 -0
- package/src/index.ts +23 -0
- package/src/middlewares/otel.ts +195 -120
- package/src/middlewares/usage-attributes.ts +65 -0
- package/src/types.ts +126 -13
- package/src/utilities/errors.ts +29 -0
- package/src/utilities/media-prompt.ts +86 -0
- 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
|
-
/**
|
|
88
|
-
|
|
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
|
-
/**
|
|
92
|
-
|
|
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
|
+
}
|
package/src/activities/index.ts
CHANGED
|
@@ -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>
|