@tanstack/ai 0.32.0 → 0.34.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 (64) hide show
  1. package/dist/esm/activities/chat/index.js +47 -20
  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/chat/tools/schema-converter.d.ts +13 -0
  8. package/dist/esm/activities/chat/tools/schema-converter.js +61 -33
  9. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  10. package/dist/esm/activities/generateAudio/index.d.ts +7 -0
  11. package/dist/esm/activities/generateAudio/index.js +26 -1
  12. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  13. package/dist/esm/activities/generateImage/index.d.ts +7 -0
  14. package/dist/esm/activities/generateImage/index.js +26 -1
  15. package/dist/esm/activities/generateImage/index.js.map +1 -1
  16. package/dist/esm/activities/generateSpeech/index.d.ts +7 -0
  17. package/dist/esm/activities/generateSpeech/index.js +26 -1
  18. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  19. package/dist/esm/activities/generateTranscription/index.d.ts +7 -0
  20. package/dist/esm/activities/generateTranscription/index.js +26 -1
  21. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  22. package/dist/esm/activities/generateVideo/index.d.ts +9 -0
  23. package/dist/esm/activities/generateVideo/index.js +52 -2
  24. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  25. package/dist/esm/activities/middleware/index.d.ts +2 -0
  26. package/dist/esm/activities/middleware/run.d.ts +20 -0
  27. package/dist/esm/activities/middleware/run.js +42 -0
  28. package/dist/esm/activities/middleware/run.js.map +1 -0
  29. package/dist/esm/activities/middleware/types.d.ts +118 -0
  30. package/dist/esm/index.d.ts +2 -0
  31. package/dist/esm/index.js +2 -0
  32. package/dist/esm/index.js.map +1 -1
  33. package/dist/esm/middlewares/otel.d.ts +8 -2
  34. package/dist/esm/middlewares/otel.js +145 -95
  35. package/dist/esm/middlewares/otel.js.map +1 -1
  36. package/dist/esm/middlewares/usage-attributes.d.ts +24 -0
  37. package/dist/esm/middlewares/usage-attributes.js +43 -0
  38. package/dist/esm/middlewares/usage-attributes.js.map +1 -0
  39. package/dist/esm/types.d.ts +7 -7
  40. package/dist/esm/utilities/errors.d.ts +13 -0
  41. package/dist/esm/utilities/errors.js +22 -0
  42. package/dist/esm/utilities/errors.js.map +1 -0
  43. package/dist/esm/utilities/numbers.d.ts +8 -0
  44. package/dist/esm/utilities/numbers.js +12 -0
  45. package/dist/esm/utilities/numbers.js.map +1 -0
  46. package/package.json +3 -2
  47. package/src/activities/chat/index.ts +125 -35
  48. package/src/activities/chat/middleware/types.ts +7 -0
  49. package/src/activities/chat/tools/lazy-tool-manager.ts +46 -4
  50. package/src/activities/chat/tools/schema-converter.ts +146 -93
  51. package/src/activities/generateAudio/index.ts +42 -1
  52. package/src/activities/generateImage/index.ts +42 -1
  53. package/src/activities/generateSpeech/index.ts +42 -1
  54. package/src/activities/generateTranscription/index.ts +42 -1
  55. package/src/activities/generateVideo/index.ts +88 -2
  56. package/src/activities/middleware/index.ts +20 -0
  57. package/src/activities/middleware/run.ts +88 -0
  58. package/src/activities/middleware/types.ts +173 -0
  59. package/src/index.ts +19 -0
  60. package/src/middlewares/otel.ts +195 -120
  61. package/src/middlewares/usage-attributes.ts +65 -0
  62. package/src/types.ts +7 -7
  63. package/src/utilities/errors.ts +29 -0
  64. package/src/utilities/numbers.ts +15 -0
@@ -2,6 +2,7 @@ import type {
2
2
  StandardJSONSchemaV1,
3
3
  StandardSchemaV1,
4
4
  } from '@standard-schema/spec'
5
+ import type { NullWideningMap } from '@tanstack/ai-utils'
5
6
  import type { JSONSchema, SchemaInput } from '../../../types'
6
7
 
7
8
  /**
@@ -82,6 +83,22 @@ export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 {
82
83
  )
83
84
  }
84
85
 
86
+ /**
87
+ * Result of {@link makeStructuredOutputCompatible}: the strict-ready schema plus
88
+ * a {@link NullWideningMap} recording every position where a `null` was
89
+ * synthesized, so the response can be un-widened before validation without
90
+ * re-deriving (or guessing) which nulls were synthetic.
91
+ */
92
+ interface StructuredOutputConversion {
93
+ schema: JSONSchema
94
+ nullWidening: NullWideningMap | undefined
95
+ }
96
+
97
+ /** Drop an empty map to `undefined` so leaf/no-op subtrees don't litter it. */
98
+ function pruneMap(map: NullWideningMap): NullWideningMap | undefined {
99
+ return Object.keys(map).length > 0 ? map : undefined
100
+ }
101
+
85
102
  /**
86
103
  * Transform a JSON schema to be compatible with OpenAI's structured output requirements.
87
104
  * OpenAI requires:
@@ -89,59 +106,76 @@ export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 {
89
106
  * - Optional fields should have null added to their type union
90
107
  * - additionalProperties must be false for objects
91
108
  *
109
+ * Alongside the transformed schema it returns a {@link NullWideningMap} marking
110
+ * exactly the positions where `null` was added, so `undoNullWidening` can strip
111
+ * those synthesized nulls (and only those) from the provider's response.
112
+ *
92
113
  * @param schema - JSON schema to transform
93
114
  * @param originalRequired - Original required array (to know which fields were optional)
94
- * @returns Transformed schema compatible with OpenAI structured output
115
+ * @returns Transformed schema + the null-widening map for the round trip
95
116
  */
96
117
  function makeStructuredOutputCompatible(
97
118
  schema: JSONSchema,
98
119
  originalRequired: Array<string> = [],
99
- ): JSONSchema {
120
+ ): StructuredOutputConversion {
100
121
  const result: JSONSchema = { ...schema }
122
+ const map: NullWideningMap = {}
101
123
 
102
124
  // Handle object types
103
125
  if (result.type === 'object' && result.properties) {
104
126
  const properties: Record<string, JSONSchema> = { ...result.properties }
105
127
  const allPropertyNames = Object.keys(properties)
128
+ const propertyMaps: Record<string, NullWideningMap> = {}
106
129
 
107
130
  // Transform each property
108
131
  for (const propName of allPropertyNames) {
109
132
  const prop = properties[propName]
110
133
  if (!prop) continue
111
134
  const wasOptional = !originalRequired.includes(propName)
135
+ // `null` synthesized AT this property (the field itself can come back null).
136
+ let widenedHere = false
137
+ // Map describing widened positions INSIDE this property.
138
+ let childMap: NullWideningMap | undefined
112
139
 
113
140
  // Recursively transform nested objects/arrays
114
141
  if (prop.type === 'object' && prop.properties) {
115
- const transformed = makeStructuredOutputCompatible(
116
- prop,
117
- prop.required || [],
118
- )
142
+ const nested = makeStructuredOutputCompatible(prop, prop.required || [])
119
143
  properties[propName] = wasOptional
120
- ? { ...transformed, type: ['object', 'null'] }
121
- : transformed
144
+ ? { ...nested.schema, type: ['object', 'null'] }
145
+ : nested.schema
146
+ widenedHere = wasOptional
147
+ childMap = nested.nullWidening
122
148
  } else if (prop.type === 'array' && prop.items) {
123
149
  const items = Array.isArray(prop.items) ? prop.items[0] : prop.items
124
- const transformed: JSONSchema = {
150
+ const nestedItems = items
151
+ ? makeStructuredOutputCompatible(items, items.required || [])
152
+ : undefined
153
+ properties[propName] = {
125
154
  ...prop,
126
- items: items
127
- ? makeStructuredOutputCompatible(items, items.required || [])
128
- : prop.items,
155
+ items: nestedItems ? nestedItems.schema : prop.items,
156
+ ...(wasOptional ? { type: ['array', 'null'] } : {}),
129
157
  }
130
- properties[propName] = wasOptional
131
- ? { ...transformed, type: ['array', 'null'] }
132
- : transformed
158
+ widenedHere = wasOptional
159
+ childMap = nestedItems?.nullWidening
160
+ ? { items: nestedItems.nullWidening }
161
+ : undefined
133
162
  } else if (wasOptional) {
134
- // Make optional fields nullable by adding null to the type
163
+ // Make optional fields nullable by adding null to the type. Mark
164
+ // `widenedHere` only where we actually add `null`; a field already
165
+ // typed nullable (`.nullish()`) is left as-is and keeps its null.
135
166
  if (prop.type && !Array.isArray(prop.type)) {
136
- properties[propName] = {
137
- ...prop,
138
- type: [prop.type, 'null'],
139
- }
167
+ properties[propName] = { ...prop, type: [prop.type, 'null'] }
168
+ widenedHere = true
140
169
  } else if (Array.isArray(prop.type) && !prop.type.includes('null')) {
141
- properties[propName] = {
142
- ...prop,
143
- type: [...prop.type, 'null'],
144
- }
170
+ properties[propName] = { ...prop, type: [...prop.type, 'null'] }
171
+ widenedHere = true
172
+ }
173
+ }
174
+
175
+ if (widenedHere || childMap) {
176
+ propertyMaps[propName] = {
177
+ ...(childMap ?? {}),
178
+ ...(widenedHere ? { widened: true } : {}),
145
179
  }
146
180
  }
147
181
  }
@@ -151,17 +185,23 @@ function makeStructuredOutputCompatible(
151
185
  result.required = allPropertyNames
152
186
  // additionalProperties must be false
153
187
  result.additionalProperties = false
188
+ if (Object.keys(propertyMaps).length > 0) map.properties = propertyMaps
154
189
  }
155
190
 
156
191
  // Handle array types with object items
157
192
  if (result.type === 'array' && result.items) {
158
193
  const items = Array.isArray(result.items) ? result.items[0] : result.items
159
194
  if (items) {
160
- result.items = makeStructuredOutputCompatible(items, items.required || [])
195
+ const nestedItems = makeStructuredOutputCompatible(
196
+ items,
197
+ items.required || [],
198
+ )
199
+ result.items = nestedItems.schema
200
+ if (nestedItems.nullWidening) map.items = nestedItems.nullWidening
161
201
  }
162
202
  }
163
203
 
164
- return result
204
+ return { schema: result, nullWidening: pruneMap(map) }
165
205
  }
166
206
 
167
207
  /**
@@ -179,6 +219,48 @@ export interface ConvertSchemaOptions {
179
219
  forStructuredOutput?: boolean
180
220
  }
181
221
 
222
+ /**
223
+ * Normalize any supported schema input to a typed, UN-widened `JSONSchema` —
224
+ * the shared first half of conversion, before any structured-output widening.
225
+ *
226
+ * - Standard JSON Schemas are rebuilt structurally (dropping `$schema`, which
227
+ * LLM providers ignore) and given the explicit `type`/`properties`/`required`
228
+ * defaults object shapes need downstream.
229
+ * - Plain `JSONSchema` inputs are rebuilt into the typed view; non-object inputs
230
+ * are surfaced untouched (they can't be widened).
231
+ * - Standard Schema validators lacking a `~standard.jsonSchema` converter throw
232
+ * with actionable guidance, rather than shipping `{ '~standard': … }` to the
233
+ * provider and producing an opaque downstream error.
234
+ */
235
+ function toTypedJsonSchema(schema: SchemaInput): JSONSchema | undefined {
236
+ if (isStandardJSONSchema(schema)) {
237
+ const jsonSchema = schema['~standard'].jsonSchema.input({
238
+ target: 'draft-07',
239
+ })
240
+ const result: JSONSchema = toJsonSchema(jsonSchema)
241
+ if ('properties' in result && !result.type) result.type = 'object'
242
+ if (result.type === 'object' && !('properties' in result)) {
243
+ result.properties = {}
244
+ }
245
+ if (result.type === 'object' && !('required' in result)) {
246
+ result.required = []
247
+ }
248
+ return result
249
+ }
250
+
251
+ if (isStandardSchema(schema)) {
252
+ throw new Error(
253
+ 'Schema is a Standard Schema validator but does not expose a JSON Schema ' +
254
+ 'converter on `~standard.jsonSchema`. Use Zod v4.2+, ArkType v2.1.28+, ' +
255
+ 'or wrap a Valibot schema with `toStandardJsonSchema()` from ' +
256
+ '`@valibot/to-json-schema` before passing it as `outputSchema`.',
257
+ )
258
+ }
259
+
260
+ if (typeof schema !== 'object') return schema
261
+ return toJsonSchema(schema)
262
+ }
263
+
182
264
  /**
183
265
  * Converts a Standard JSON Schema compliant schema or plain JSONSchema to JSON Schema format
184
266
  * compatible with LLM providers.
@@ -247,77 +329,48 @@ export function convertSchemaToJsonSchema(
247
329
 
248
330
  const { forStructuredOutput = false } = options
249
331
 
250
- // If it's a Standard JSON Schema compliant schema, use the standard interface
251
- if (isStandardJSONSchema(schema)) {
252
- const jsonSchema = schema['~standard'].jsonSchema.input({
253
- target: 'draft-07',
254
- })
255
-
256
- // Rebuild structurally so the typed JSONSchema view is acquired without
257
- // a `Record<string, unknown> as JSONSchema` cast; `toJsonSchema()` also
258
- // drops the `$schema` key which LLM providers don't need.
259
- let result: JSONSchema = toJsonSchema(jsonSchema)
260
-
261
- // Ensure object schemas always have type: "object"
262
- // If it has properties (even empty), it should be an object type
263
- if ('properties' in result && !result.type) {
264
- result.type = 'object'
265
- }
266
-
267
- // Ensure properties exists for object types (even if empty)
268
- if (result.type === 'object' && !('properties' in result)) {
269
- result.properties = {}
270
- }
271
-
272
- // Ensure required exists for object types (even if empty array)
273
- if (result.type === 'object' && !('required' in result)) {
274
- result.required = []
275
- }
276
-
277
- // Apply structured output transformation if requested
278
- if (forStructuredOutput) {
279
- result = makeStructuredOutputCompatible(result, result.required || [])
280
- }
281
-
282
- return result
283
- }
284
-
285
- // Detect Standard Schema validators (Zod, ArkType, Valibot, …) that don't
286
- // expose a `~standard.jsonSchema` converter. These would otherwise fall
287
- // through to the JSONSchema pass-through below and ship `{ '~standard': … }`
288
- // straight to the LLM provider, producing an opaque downstream error. Fail
289
- // fast with actionable guidance instead.
290
- if (isStandardSchema(schema)) {
291
- throw new Error(
292
- 'Schema is a Standard Schema validator but does not expose a JSON Schema ' +
293
- 'converter on `~standard.jsonSchema`. Use Zod v4.2+, ArkType v2.1.28+, ' +
294
- 'or wrap a Valibot schema with `toStandardJsonSchema()` from ' +
295
- '`@valibot/to-json-schema` before passing it as `outputSchema`.',
296
- )
297
- }
298
-
299
- // If it's not a Standard JSON Schema, assume it's already a JSONSchema and pass through
300
- // Still apply structured output transformation if requested
301
-
302
- // At this branch, `schema` is the plain `JSONSchema` arm of `SchemaInput`
303
- // (the two `~standard` arms were handled above). When no transformation
304
- // is requested we pass the schema through by reference to preserve
305
- // identity for callers that compare via `===`.
306
- if (typeof schema !== 'object') {
307
- // The SchemaInput union is object-shaped on every arm; if we ever hit a
308
- // non-object here, propagate it untouched and let the downstream
309
- // provider error loudly rather than silently widen.
332
+ // Plain-JSONSchema passthrough: with no widening requested, return the schema
333
+ // by reference so callers comparing via `===` keep identity. Only the widening
334
+ // path needs the rebuilt, normalized view from `toTypedJsonSchema`.
335
+ if (
336
+ !forStructuredOutput &&
337
+ !isStandardJSONSchema(schema) &&
338
+ !isStandardSchema(schema)
339
+ ) {
310
340
  return schema
311
341
  }
312
342
 
313
- if (forStructuredOutput) {
314
- // Build a typed view structurally so we don't need a SchemaInput→JSONSchema
315
- // cast on the transformation path.
316
- const typedView = toJsonSchema(schema)
317
- return makeStructuredOutputCompatible(typedView, typedView.required || [])
318
- }
343
+ const base = toTypedJsonSchema(schema)
344
+ // Non-object inputs can't be widened; surface them untouched.
345
+ if (!base || typeof base !== 'object') return base
346
+ if (!forStructuredOutput) return base
347
+ return makeStructuredOutputCompatible(base, base.required || []).schema
348
+ }
319
349
 
320
- return schema
350
+ /**
351
+ * Convert a schema for structured output AND capture the {@link NullWideningMap}
352
+ * recording every `null` the strict-mode widening synthesized. The map lets the
353
+ * caller undo that widening on the provider's response (via `undoNullWidening`)
354
+ * before validating against the original schema — optional fields read back as
355
+ * absent while genuine `.nullable()` nulls survive. The map is `undefined` when
356
+ * the schema isn't a widenable object or when no field needed widening.
357
+ */
358
+ export function convertSchemaForStructuredOutput(
359
+ schema: SchemaInput | undefined,
360
+ ): {
361
+ jsonSchema: JSONSchema | undefined
362
+ nullWideningMap: NullWideningMap | undefined
363
+ } {
364
+ if (!schema) return { jsonSchema: undefined, nullWideningMap: undefined }
365
+ const base = toTypedJsonSchema(schema)
366
+ if (!base || typeof base !== 'object') {
367
+ return { jsonSchema: base, nullWideningMap: undefined }
368
+ }
369
+ const { schema: jsonSchema, nullWidening } = makeStructuredOutputCompatible(
370
+ base,
371
+ base.required || [],
372
+ )
373
+ return { jsonSchema, nullWideningMap: nullWidening }
321
374
  }
322
375
 
323
376
  /**
@@ -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',