@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
@@ -8,6 +8,9 @@ import {
8
8
  MAX_TOKENS_KEYS,
9
9
  NESTED_MAX_TOKENS_KEY,
10
10
  } from '../utilities/sampling-keys'
11
+ import { firstNumber } from '../utilities/numbers'
12
+ import { errorMessage, errorTypeName } from '../utilities/errors'
13
+ import { usageAttributes } from './usage-attributes'
11
14
  import type {
12
15
  AttributeValue,
13
16
  Exception,
@@ -20,7 +23,11 @@ import type {
20
23
  ChatMiddleware,
21
24
  ChatMiddlewareContext,
22
25
  } from '../activities/chat/middleware/types'
23
- import type { TokenUsage } from '../types'
26
+ import type {
27
+ GenerationActivity,
28
+ GenerationMiddleware,
29
+ GenerationMiddlewareContext,
30
+ } from '../activities/middleware/types'
24
31
 
25
32
  /**
26
33
  * Scope (role) of an OTel span emitted by this middleware.
@@ -28,8 +35,10 @@ import type { TokenUsage } from '../types'
28
35
  * - `chat` — the root span for a single `chat()` call
29
36
  * - `iteration` — one per agent-loop iteration (one model call)
30
37
  * - `tool` — one per tool execution inside an iteration
38
+ * - `generation` — the single span for a media activity call
39
+ * (`generateImage`, `generateVideo`, `generateSpeech`, …)
31
40
  */
32
- export type OtelSpanScope = 'chat' | 'iteration' | 'tool'
41
+ export type OtelSpanScope = 'chat' | 'iteration' | 'tool' | 'generation'
33
42
 
34
43
  /**
35
44
  * Alias retained for backwards compatibility. Prefer {@link OtelSpanScope}.
@@ -57,7 +66,24 @@ export type OtelSpanInfo<TScope extends OtelSpanScope = OtelSpanScope> =
57
66
  toolName: string
58
67
  toolCallId: string
59
68
  }
60
- : never
69
+ : TScope extends 'generation'
70
+ ? { kind: 'generation'; ctx: GenerationMiddlewareContext }
71
+ : never
72
+
73
+ /**
74
+ * `gen_ai.operation.name` per activity. Chat uses the GenAI semconv value;
75
+ * media operations have no semconv entry yet, so these are the de-facto names
76
+ * consumed by GenAI backends (PostHog, Langfuse, …). Documented in
77
+ * `docs/advanced/otel.md`.
78
+ */
79
+ const OPERATION_NAME: Record<GenerationActivity, string> = {
80
+ chat: 'chat',
81
+ image: 'image_generation',
82
+ video: 'video_generation',
83
+ audio: 'audio_generation',
84
+ tts: 'text_to_speech',
85
+ transcription: 'transcription',
86
+ }
61
87
 
62
88
  export interface OtelMiddlewareOptions {
63
89
  /** OTel `Tracer` used to start root, iteration, and tool spans. */
@@ -106,6 +132,12 @@ interface RequestState {
106
132
  assistantTextBuffer: string
107
133
  assistantTextBufferTruncated: boolean
108
134
  startTime: number
135
+ /**
136
+ * Finish reason from the most recent `RUN_FINISHED` chunk. Captured in
137
+ * `onChunk` so `onFinish` can stamp it on the root span without reading it
138
+ * from the (base-shaped) finish info, which doesn't carry it.
139
+ */
140
+ lastFinishReason: string | null
109
141
  }
110
142
 
111
143
  const stateByCtx = new WeakMap<ChatMiddlewareContext, RequestState>()
@@ -167,91 +199,6 @@ function messageEventName(role: string): string {
167
199
  }
168
200
  }
169
201
 
170
- /**
171
- * Return the first candidate that is a finite `number`, or `undefined`. Used to
172
- * pick a sampling attribute from among the several provider-native spellings.
173
- */
174
- function firstNumber(...candidates: Array<unknown>): number | undefined {
175
- for (const candidate of candidates) {
176
- if (typeof candidate === 'number' && Number.isFinite(candidate)) {
177
- return candidate
178
- }
179
- }
180
- return undefined
181
- }
182
-
183
- /**
184
- * Build the full set of `gen_ai.usage.*` span attributes from a `TokenUsage`.
185
- *
186
- * Beyond input/output tokens, this emits provider-reported cost, total tokens,
187
- * cache and reasoning breakdowns, and duration-based billing — every field is
188
- * guarded so spans stay clean when a provider doesn't report it. Cache and
189
- * reasoning use the official GenAI semconv names; `gen_ai.usage.cost` and
190
- * `gen_ai.usage.total_tokens` are de-facto extensions consumed by backends
191
- * like PostHog (which otherwise re-derive cost from their own price tables,
192
- * losing cache discounts and gateway markup). Fields with no semconv or
193
- * de-facto convention (`costDetails`, `durationSeconds`) are
194
- * TanStack-namespaced. Deliberately not emitted: `unitsBilled`,
195
- * `providerUsageDetails`, and the per-modality token breakdowns — those are
196
- * media-oriented; media-activity observability is tracked in #720.
197
- */
198
- function usageAttributes(usage: TokenUsage): Record<string, AttributeValue> {
199
- const attrs: Record<string, AttributeValue> = {
200
- 'gen_ai.usage.input_tokens': usage.promptTokens,
201
- 'gen_ai.usage.output_tokens': usage.completionTokens,
202
- }
203
- const optional: Array<[key: string, value: unknown]> = [
204
- ['gen_ai.usage.total_tokens', usage.totalTokens],
205
- ['gen_ai.usage.cost', usage.cost],
206
- [
207
- 'gen_ai.usage.cache_read.input_tokens',
208
- usage.promptTokensDetails?.cachedTokens,
209
- ],
210
- [
211
- 'gen_ai.usage.cache_creation.input_tokens',
212
- usage.promptTokensDetails?.cacheWriteTokens,
213
- ],
214
- [
215
- 'gen_ai.usage.reasoning.output_tokens',
216
- usage.completionTokensDetails?.reasoningTokens,
217
- ],
218
- ['tanstack.ai.usage.duration_seconds', usage.durationSeconds],
219
- ['tanstack.ai.usage.upstream_cost', usage.costDetails?.upstreamCost],
220
- [
221
- 'tanstack.ai.usage.upstream_input_cost',
222
- usage.costDetails?.upstreamInputCost,
223
- ],
224
- [
225
- 'tanstack.ai.usage.upstream_output_cost',
226
- usage.costDetails?.upstreamOutputCost,
227
- ],
228
- ]
229
- for (const [key, value] of optional) {
230
- const num = firstNumber(value)
231
- if (num !== undefined) attrs[key] = num
232
- }
233
- return attrs
234
- }
235
-
236
- function errorMessage(err: unknown): string | undefined {
237
- if (err instanceof Error) return err.message
238
- if (typeof err === 'string') return err
239
- if (err && typeof err === 'object' && 'message' in err) {
240
- const m = (err as { message?: unknown }).message
241
- if (typeof m === 'string') return m
242
- }
243
- return undefined
244
- }
245
-
246
- function errorTypeName(err: unknown): string {
247
- if (err instanceof Error) return err.name || 'Error'
248
- if (err && typeof err === 'object' && 'name' in err) {
249
- const n = (err as { name?: unknown }).name
250
- if (typeof n === 'string') return n
251
- }
252
- return 'Error'
253
- }
254
-
255
202
  function safeCall<T>(label: string, fn: () => T): T | undefined {
256
203
  try {
257
204
  return fn()
@@ -264,7 +211,9 @@ function safeCall<T>(label: string, fn: () => T): T | undefined {
264
211
  }
265
212
  }
266
213
 
267
- export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
214
+ export function otelMiddleware(
215
+ options: OtelMiddlewareOptions,
216
+ ): GenerationMiddleware & ChatMiddleware {
268
217
  const {
269
218
  tracer,
270
219
  meter,
@@ -333,20 +282,91 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
333
282
  state.currentIterationSpan = null
334
283
  }
335
284
 
285
+ // --- Media activities -----------------------------------------------------
286
+ // Media calls (image/video/audio/tts/transcription) are single request →
287
+ // response, so they get exactly one CLIENT span — opened in `onStart` and
288
+ // closed in the terminal hook. Keyed by the per-call context object, which is
289
+ // distinct from the chat state map above so the two paths never collide.
290
+ const mediaSpans = new WeakMap<GenerationMiddlewareContext, Span>()
291
+
292
+ const recordMediaDuration = (
293
+ ctx: GenerationMiddlewareContext,
294
+ durationMs: number,
295
+ errorType?: string,
296
+ ): void => {
297
+ if (!durationHistogram) return
298
+ durationHistogram.record(durationMs / 1000, {
299
+ 'gen_ai.system': ctx.provider,
300
+ 'gen_ai.operation.name': OPERATION_NAME[ctx.activity],
301
+ 'gen_ai.request.model': ctx.model,
302
+ ...(errorType ? { 'error.type': errorType } : {}),
303
+ })
304
+ }
305
+
306
+ const startMediaSpan = (ctx: GenerationMiddlewareContext): void => {
307
+ safeCall('otel.onStart', () => {
308
+ const operationName = OPERATION_NAME[ctx.activity]
309
+ const info: OtelSpanInfo<'generation'> = { kind: 'generation', ctx }
310
+ const name =
311
+ safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ??
312
+ `${operationName} ${ctx.model}`
313
+ const baseOptions: SpanOptions = {
314
+ kind: SpanKind.CLIENT,
315
+ attributes: {
316
+ 'gen_ai.system': ctx.provider,
317
+ 'gen_ai.operation.name': operationName,
318
+ 'gen_ai.request.model': ctx.model,
319
+ },
320
+ }
321
+ const spanOptions =
322
+ safeCall('otel.onBeforeSpanStart', () =>
323
+ onBeforeSpanStart?.(info, baseOptions),
324
+ ) ?? baseOptions
325
+ const span = tracer.startSpan(name, spanOptions)
326
+ const enriched = safeCall('otel.attributeEnricher', () =>
327
+ attributeEnricher?.(info),
328
+ )
329
+ if (enriched) span.setAttributes(enriched)
330
+ mediaSpans.set(ctx, span)
331
+ })
332
+ }
333
+
334
+ const endMediaSpan = (
335
+ ctx: GenerationMiddlewareContext,
336
+ finalize: (span: Span) => void,
337
+ ): void => {
338
+ const span = mediaSpans.get(ctx)
339
+ mediaSpans.delete(ctx)
340
+ if (!span) return
341
+ finalize(span)
342
+ safeCall('otel.onSpanEnd', () =>
343
+ onSpanEnd?.({ kind: 'generation', ctx }, span),
344
+ )
345
+ span.end()
346
+ }
347
+
336
348
  return {
337
349
  name: 'otel',
338
350
 
339
351
  onStart(ctx) {
352
+ // Media activities get one CLIENT span; chat builds the root/iteration
353
+ // tree below. The cast is sound: the chat runner only ever passes a
354
+ // ChatMiddlewareContext, which `activity: 'chat'` narrows to at runtime.
355
+ if (ctx.activity !== 'chat') {
356
+ startMediaSpan(ctx)
357
+ return
358
+ }
359
+ const chatCtx = ctx as ChatMiddlewareContext
340
360
  safeCall('otel.onStart', () => {
341
- const info: OtelSpanInfo<'chat'> = { kind: 'chat', ctx }
361
+ const info: OtelSpanInfo<'chat'> = { kind: 'chat', ctx: chatCtx }
342
362
  const name =
343
363
  safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ??
344
- `chat ${ctx.model}`
364
+ `chat ${chatCtx.model}`
345
365
  const baseOptions: SpanOptions = {
346
366
  kind: SpanKind.INTERNAL,
347
367
  attributes: {
348
- 'gen_ai.system': ctx.provider,
349
- 'gen_ai.request.model': ctx.model,
368
+ 'gen_ai.system': chatCtx.provider,
369
+ 'gen_ai.request.model': chatCtx.model,
350
370
  // NOTE: `gen_ai.operation.name` is deliberately NOT set on the
351
371
  // root span. The root represents a `chat()` invocation that may
352
372
  // span multiple model calls; only iteration spans correspond to
@@ -366,7 +386,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
366
386
  )
367
387
  if (enriched) rootSpan.setAttributes(enriched)
368
388
 
369
- stateByCtx.set(ctx, {
389
+ stateByCtx.set(chatCtx, {
370
390
  rootSpan,
371
391
  currentIterationSpan: null,
372
392
  toolSpans: new Map(),
@@ -374,6 +394,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
374
394
  assistantTextBuffer: '',
375
395
  assistantTextBufferTruncated: false,
376
396
  startTime: Date.now(),
397
+ lastFinishReason: null,
377
398
  })
378
399
  })
379
400
  },
@@ -562,6 +583,9 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
562
583
  }
563
584
 
564
585
  if (chunk.type !== 'RUN_FINISHED') return
586
+ // Capture for the root-span finish_reasons attribute set in onFinish,
587
+ // which receives base-shaped info without a finishReason field.
588
+ if (chunk.finishReason) state.lastFinishReason = chunk.finishReason
565
589
  const span = state.currentIterationSpan
566
590
  if (!span) return
567
591
 
@@ -611,8 +635,19 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
611
635
  },
612
636
 
613
637
  onUsage(ctx, usage) {
638
+ if (ctx.activity !== 'chat') {
639
+ // Media: stamp usage on the single span. No token histogram — media
640
+ // unit billing lands as span attributes via usageAttributes, matching
641
+ // prior media behavior and avoiding chat-shaped token metrics.
642
+ safeCall('otel.onUsage', () => {
643
+ const span = mediaSpans.get(ctx)
644
+ if (span) span.setAttributes(usageAttributes(usage))
645
+ })
646
+ return
647
+ }
648
+ const chatCtx = ctx as ChatMiddlewareContext
614
649
  safeCall('otel.onUsage', () => {
615
- const state = stateByCtx.get(ctx)
650
+ const state = stateByCtx.get(chatCtx)
616
651
  if (!state) return
617
652
 
618
653
  // Always record the token histogram — metrics don't depend on having
@@ -620,9 +655,9 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
620
655
  // adapter emits `onUsage` outside the iteration window.
621
656
  if (tokenHistogram) {
622
657
  const metricAttrs = {
623
- 'gen_ai.system': ctx.provider,
658
+ 'gen_ai.system': chatCtx.provider,
624
659
  'gen_ai.operation.name': 'chat',
625
- 'gen_ai.request.model': ctx.model,
660
+ 'gen_ai.request.model': chatCtx.model,
626
661
  }
627
662
  tokenHistogram.record(usage.promptTokens, {
628
663
  ...metricAttrs,
@@ -773,8 +808,23 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
773
808
  },
774
809
 
775
810
  onError(ctx, info) {
811
+ if (ctx.activity !== 'chat') {
812
+ safeCall('otel.onError', () => {
813
+ const message = errorMessage(info.error)
814
+ endMediaSpan(ctx, (span) => {
815
+ span.recordException(info.error as Exception)
816
+ span.setStatus({
817
+ code: SpanStatusCode.ERROR,
818
+ ...(message !== undefined ? { message } : {}),
819
+ })
820
+ })
821
+ recordMediaDuration(ctx, info.duration, errorTypeName(info.error))
822
+ })
823
+ return
824
+ }
825
+ const chatCtx = ctx as ChatMiddlewareContext
776
826
  safeCall('otel.onError', () => {
777
- const state = stateByCtx.get(ctx)
827
+ const state = stateByCtx.get(chatCtx)
778
828
  if (!state) return
779
829
 
780
830
  const errType = errorTypeName(info.error)
@@ -794,7 +844,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
794
844
  onSpanEnd?.(
795
845
  {
796
846
  kind: 'iteration',
797
- ctx,
847
+ ctx: chatCtx,
798
848
  iteration: state.iterationCount - 1,
799
849
  },
800
850
  iterationSpan,
@@ -812,7 +862,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
812
862
  onSpanEnd?.(
813
863
  {
814
864
  kind: 'tool',
815
- ctx,
865
+ ctx: chatCtx,
816
866
  toolCallId: id,
817
867
  toolName,
818
868
  iteration: state.iterationCount - 1,
@@ -832,24 +882,39 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
832
882
 
833
883
  if (durationHistogram) {
834
884
  durationHistogram.record(info.duration / 1000, {
835
- 'gen_ai.system': ctx.provider,
885
+ 'gen_ai.system': chatCtx.provider,
836
886
  'gen_ai.operation.name': 'chat',
837
- 'gen_ai.request.model': ctx.model,
887
+ 'gen_ai.request.model': chatCtx.model,
838
888
  'error.type': errType,
839
889
  })
840
890
  }
841
891
 
842
892
  safeCall('otel.onSpanEnd', () =>
843
- onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan),
893
+ onSpanEnd?.({ kind: 'chat', ctx: chatCtx }, state.rootSpan),
844
894
  )
845
895
  state.rootSpan.end()
846
- stateByCtx.delete(ctx)
896
+ stateByCtx.delete(chatCtx)
847
897
  })
848
898
  },
849
899
 
850
900
  onAbort(ctx, info) {
901
+ if (ctx.activity !== 'chat') {
902
+ // Media abandonment (e.g. a video stream dropped before completion).
903
+ safeCall('otel.onAbort', () => {
904
+ endMediaSpan(ctx, (span) => {
905
+ span.setAttribute('tanstack.ai.completion.reason', 'cancelled')
906
+ span.setStatus({
907
+ code: SpanStatusCode.ERROR,
908
+ message: info.reason ?? 'cancelled',
909
+ })
910
+ })
911
+ recordMediaDuration(ctx, info.duration, 'cancelled')
912
+ })
913
+ return
914
+ }
915
+ const chatCtx = ctx as ChatMiddlewareContext
851
916
  safeCall('otel.onAbort', () => {
852
- const state = stateByCtx.get(ctx)
917
+ const state = stateByCtx.get(chatCtx)
853
918
  if (!state) return
854
919
 
855
920
  const closeCancelled = (span: Span): void => {
@@ -867,7 +932,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
867
932
  onSpanEnd?.(
868
933
  {
869
934
  kind: 'iteration',
870
- ctx,
935
+ ctx: chatCtx,
871
936
  iteration: state.iterationCount - 1,
872
937
  },
873
938
  iterationSpan,
@@ -883,7 +948,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
883
948
  onSpanEnd?.(
884
949
  {
885
950
  kind: 'tool',
886
- ctx,
951
+ ctx: chatCtx,
887
952
  toolCallId: id,
888
953
  toolName,
889
954
  iteration: state.iterationCount - 1,
@@ -898,24 +963,34 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
898
963
 
899
964
  if (durationHistogram) {
900
965
  durationHistogram.record(info.duration / 1000, {
901
- 'gen_ai.system': ctx.provider,
966
+ 'gen_ai.system': chatCtx.provider,
902
967
  'gen_ai.operation.name': 'chat',
903
- 'gen_ai.request.model': ctx.model,
968
+ 'gen_ai.request.model': chatCtx.model,
904
969
  'error.type': 'cancelled',
905
970
  })
906
971
  }
907
972
 
908
973
  safeCall('otel.onSpanEnd', () =>
909
- onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan),
974
+ onSpanEnd?.({ kind: 'chat', ctx: chatCtx }, state.rootSpan),
910
975
  )
911
976
  state.rootSpan.end()
912
- stateByCtx.delete(ctx)
977
+ stateByCtx.delete(chatCtx)
913
978
  })
914
979
  },
915
980
 
916
981
  onFinish(ctx, info) {
982
+ if (ctx.activity !== 'chat') {
983
+ safeCall('otel.onFinish', () => {
984
+ endMediaSpan(ctx, (span) => {
985
+ if (info.usage) span.setAttributes(usageAttributes(info.usage))
986
+ })
987
+ recordMediaDuration(ctx, info.duration)
988
+ })
989
+ return
990
+ }
991
+ const chatCtx = ctx as ChatMiddlewareContext
917
992
  safeCall('otel.onFinish', () => {
918
- const state = stateByCtx.get(ctx)
993
+ const state = stateByCtx.get(chatCtx)
919
994
  if (!state) return
920
995
 
921
996
  // Close any tool spans that never received `onAfterToolCall` (adapter
@@ -928,7 +1003,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
928
1003
  onSpanEnd?.(
929
1004
  {
930
1005
  kind: 'tool',
931
- ctx,
1006
+ ctx: chatCtx,
932
1007
  toolCallId: id,
933
1008
  toolName,
934
1009
  iteration: state.iterationCount - 1,
@@ -942,22 +1017,22 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
942
1017
 
943
1018
  // The final iteration's span is still open because we keep it open
944
1019
  // through tool execution and `onUsage`. Close it now.
945
- closeIterationSpan(state, ctx)
1020
+ closeIterationSpan(state, chatCtx)
946
1021
 
947
1022
  if (durationHistogram) {
948
1023
  durationHistogram.record(info.duration / 1000, {
949
- 'gen_ai.system': ctx.provider,
1024
+ 'gen_ai.system': chatCtx.provider,
950
1025
  'gen_ai.operation.name': 'chat',
951
- 'gen_ai.request.model': ctx.model,
1026
+ 'gen_ai.request.model': chatCtx.model,
952
1027
  })
953
1028
  }
954
1029
 
955
1030
  if (info.usage) {
956
1031
  state.rootSpan.setAttributes(usageAttributes(info.usage))
957
1032
  }
958
- if (info.finishReason) {
1033
+ if (state.lastFinishReason) {
959
1034
  state.rootSpan.setAttribute('gen_ai.response.finish_reasons', [
960
- info.finishReason,
1035
+ state.lastFinishReason,
961
1036
  ])
962
1037
  }
963
1038
  state.rootSpan.setAttribute(
@@ -966,10 +1041,10 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
966
1041
  )
967
1042
 
968
1043
  safeCall('otel.onSpanEnd', () =>
969
- onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan),
1044
+ onSpanEnd?.({ kind: 'chat', ctx: chatCtx }, state.rootSpan),
970
1045
  )
971
1046
  state.rootSpan.end()
972
- stateByCtx.delete(ctx)
1047
+ stateByCtx.delete(chatCtx)
973
1048
  })
974
1049
  },
975
1050
  }
@@ -0,0 +1,65 @@
1
+ import { firstNumber } from '../utilities/numbers'
2
+ import type { AttributeValue } from '@opentelemetry/api'
3
+ import type { TokenUsage } from '../types'
4
+
5
+ /**
6
+ * Build the full set of `gen_ai.usage.*` span attributes from a `TokenUsage`.
7
+ *
8
+ * Beyond input/output tokens, this emits provider-reported cost, total tokens,
9
+ * cache and reasoning breakdowns, duration-based billing, and media unit counts
10
+ * — every field is guarded so spans stay clean when a provider doesn't report
11
+ * it. Cache and reasoning use the official GenAI semconv names;
12
+ * `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are de-facto extensions
13
+ * consumed by backends like PostHog (which otherwise re-derive cost from their
14
+ * own price tables, losing cache discounts and gateway markup). Fields with no
15
+ * semconv or de-facto convention (`costDetails`, `durationSeconds`,
16
+ * `unitsBilled`) are TanStack-namespaced.
17
+ *
18
+ * Shared by `otelMiddleware` across every activity (chat and the media
19
+ * activities) so usage lands identically whichever activity produced the span.
20
+ *
21
+ * Deliberately not emitted: `providerUsageDetails` (a provider-shaped bag,
22
+ * unsafe to spread onto spans) and the per-modality token breakdowns
23
+ * (`promptTokensDetails.audioTokens`, etc.) — those can balloon the attribute
24
+ * set and have no agreed convention yet.
25
+ */
26
+ export function usageAttributes(
27
+ usage: TokenUsage,
28
+ ): Record<string, AttributeValue> {
29
+ const attrs: Record<string, AttributeValue> = {
30
+ 'gen_ai.usage.input_tokens': usage.promptTokens,
31
+ 'gen_ai.usage.output_tokens': usage.completionTokens,
32
+ }
33
+ const optional: Array<[key: string, value: unknown]> = [
34
+ ['gen_ai.usage.total_tokens', usage.totalTokens],
35
+ ['gen_ai.usage.cost', usage.cost],
36
+ [
37
+ 'gen_ai.usage.cache_read.input_tokens',
38
+ usage.promptTokensDetails?.cachedTokens,
39
+ ],
40
+ [
41
+ 'gen_ai.usage.cache_creation.input_tokens',
42
+ usage.promptTokensDetails?.cacheWriteTokens,
43
+ ],
44
+ [
45
+ 'gen_ai.usage.reasoning.output_tokens',
46
+ usage.completionTokensDetails?.reasoningTokens,
47
+ ],
48
+ ['tanstack.ai.usage.duration_seconds', usage.durationSeconds],
49
+ ['tanstack.ai.usage.units_billed', usage.unitsBilled],
50
+ ['tanstack.ai.usage.upstream_cost', usage.costDetails?.upstreamCost],
51
+ [
52
+ 'tanstack.ai.usage.upstream_input_cost',
53
+ usage.costDetails?.upstreamInputCost,
54
+ ],
55
+ [
56
+ 'tanstack.ai.usage.upstream_output_cost',
57
+ usage.costDetails?.upstreamOutputCost,
58
+ ],
59
+ ]
60
+ for (const [key, value] of optional) {
61
+ const num = firstNumber(value)
62
+ if (num !== undefined) attrs[key] = num
63
+ }
64
+ return attrs
65
+ }
package/src/types.ts CHANGED
@@ -820,14 +820,14 @@ export interface TextOptions<
820
820
  systemPrompts?: Array<SystemPrompt>
821
821
  agentLoopStrategy?: AgentLoopStrategy
822
822
  /**
823
- * Additional metadata to attach to the request.
824
- * Can be used for tracking, debugging, or passing custom information.
825
- * Structure and constraints vary by provider.
823
+ * Observability metadata attached to this call. Surfaced to middleware,
824
+ * devtools, and the event client; values may be arbitrarily structured
825
+ * (objects, arrays). Adapters never forward this field onto the provider
826
+ * wire request.
826
827
  *
827
- * Provider usage:
828
- * - OpenAI: `metadata` (Record<string, string>) - max 16 key-value pairs, keys max 64 chars, values max 512 chars
829
- * - Anthropic: `metadata` (Record<string, any>) - includes optional user_id (max 256 chars)
830
- * - Gemini: Not directly available in TextProviderOptions
828
+ * To send provider-side request metadata, use the provider's
829
+ * `modelOptions` field instead, where the provider supports one (e.g.
830
+ * OpenAI's and OpenRouter's `metadata` are both Record<string, string>).
831
831
  */
832
832
  metadata?: Record<string, any> | undefined
833
833
  modelOptions?: TProviderOptionsForModel
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Best-effort extraction of a human-readable message from an unknown thrown
3
+ * value, returning `undefined` when none can be found.
4
+ *
5
+ * Used by `otelMiddleware` so error reporting stays identical across chat and
6
+ * media spans.
7
+ */
8
+ export function errorMessage(err: unknown): string | undefined {
9
+ if (err instanceof Error) return err.message
10
+ if (typeof err === 'string') return err
11
+ if (err && typeof err === 'object' && 'message' in err) {
12
+ const m = (err as { message?: unknown }).message
13
+ if (typeof m === 'string') return m
14
+ }
15
+ return undefined
16
+ }
17
+
18
+ /**
19
+ * Best-effort extraction of an error's type name (used for the `error.type`
20
+ * metric attribute), falling back to `'Error'` when no name is available.
21
+ */
22
+ export function errorTypeName(err: unknown): string {
23
+ if (err instanceof Error) return err.name || 'Error'
24
+ if (err && typeof err === 'object' && 'name' in err) {
25
+ const n = (err as { name?: unknown }).name
26
+ if (typeof n === 'string') return n
27
+ }
28
+ return 'Error'
29
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Return the first candidate that is a finite `number`, or `undefined`.
3
+ *
4
+ * Handy for picking a value from among several possible spellings/sources where
5
+ * only some are populated — e.g. the provider-native sampling option names read
6
+ * by the OTel middleware, or the optional numeric fields on `TokenUsage`.
7
+ */
8
+ export function firstNumber(...candidates: Array<unknown>): number | undefined {
9
+ for (const candidate of candidates) {
10
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
11
+ return candidate
12
+ }
13
+ }
14
+ return undefined
15
+ }