@tanstack/ai 0.45.1 → 0.46.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.
@@ -25,6 +25,7 @@ import {
25
25
  import { normalizeToolResult } from '../../utilities/tool-result'
26
26
  import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
27
27
  import { LazyToolManager } from './tools/lazy-tool-manager'
28
+ import { assertUniqueToolNames } from './tools/unique-tool-names'
28
29
  import {
29
30
  MiddlewareAbortError,
30
31
  ToolCallManager,
@@ -42,7 +43,11 @@ import {
42
43
  } from './tools/approval-schema'
43
44
  import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies'
44
45
  import { isCancelRequestedReason } from './cancel'
45
- import { convertMessagesToModelMessages, generateMessageId } from './messages'
46
+ import {
47
+ convertMessagesToModelMessages,
48
+ generateMessageId,
49
+ modelMessageToUIMessage,
50
+ } from './messages'
46
51
  import { MiddlewareRunner } from './middleware/compose'
47
52
  import { getRunDetached } from './middleware/run-store'
48
53
  import { publishRunDetachedSignal } from '../../delivery-detach'
@@ -738,6 +743,7 @@ class TextEngine<
738
743
  []
739
744
  private currentThinkingContent = ''
740
745
  private currentThinkingSignature = ''
746
+ private hasSeenReasoningEvents = false
741
747
  private eventOptions?: Record<string, unknown> | undefined
742
748
  private eventToolNames?: Array<string>
743
749
  private finishedEvent: RunFinishedEvent | null = null
@@ -841,6 +847,7 @@ class TextEngine<
841
847
  this.messages = convertMessagesToModelMessages(config.params.messages)
842
848
 
843
849
  // Initialize lazy tool manager after messages are converted (needs message history for scanning)
850
+ assertUniqueToolNames(config.params.tools || [])
844
851
  this.lazyToolManager = new LazyToolManager(
845
852
  config.params.tools || [],
846
853
  this.messages,
@@ -1152,6 +1159,7 @@ class TextEngine<
1152
1159
  duration: Date.now() - this.streamStartTime,
1153
1160
  })
1154
1161
  } else {
1162
+ this.addTerminalReasoningMessage()
1155
1163
  this.terminalHookCalled = true
1156
1164
  await this.middlewareRunner.runOnFinish(this.middlewareCtx, {
1157
1165
  finishReason: this.lastFinishReason,
@@ -1282,6 +1290,7 @@ class TextEngine<
1282
1290
  this.accumulatedThinking = []
1283
1291
  this.currentThinkingContent = ''
1284
1292
  this.currentThinkingSignature = ''
1293
+ this.hasSeenReasoningEvents = false
1285
1294
  this.finishedEvent = null
1286
1295
  this.streamedToolErrorResults.clear()
1287
1296
 
@@ -1533,16 +1542,19 @@ class TextEngine<
1533
1542
  this.handleStepFinishedEvent(chunk)
1534
1543
  break
1535
1544
 
1545
+ case 'REASONING_MESSAGE_CONTENT':
1546
+ this.handleReasoningMessageContentEvent(chunk)
1547
+ break
1548
+
1536
1549
  case 'TOOL_CALL_RESULT':
1537
1550
  // Tool result is already added to messages in buildToolResultChunks
1538
1551
  break
1539
1552
 
1540
1553
  case 'REASONING_START':
1541
1554
  case 'REASONING_MESSAGE_START':
1542
- case 'REASONING_MESSAGE_CONTENT':
1543
1555
  case 'REASONING_MESSAGE_END':
1544
1556
  case 'REASONING_END':
1545
- // Reasoning events are handled by StreamProcessor
1557
+ // No special handling needed
1546
1558
  break
1547
1559
 
1548
1560
  default:
@@ -1651,14 +1663,29 @@ class TextEngine<
1651
1663
  private handleStepFinishedEvent(
1652
1664
  chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
1653
1665
  ): void {
1654
- if (chunk.delta) {
1655
- this.currentThinkingContent += chunk.delta
1666
+ if (!this.hasSeenReasoningEvents) {
1667
+ if (chunk.delta) {
1668
+ this.currentThinkingContent += chunk.delta
1669
+ } else if (chunk.content) {
1670
+ if (chunk.content.startsWith(this.currentThinkingContent)) {
1671
+ this.currentThinkingContent = chunk.content
1672
+ } else if (!this.currentThinkingContent.startsWith(chunk.content)) {
1673
+ this.currentThinkingContent += chunk.content
1674
+ }
1675
+ }
1656
1676
  }
1657
1677
  if (chunk.signature) {
1658
1678
  this.currentThinkingSignature = chunk.signature
1659
1679
  }
1660
1680
  }
1661
1681
 
1682
+ private handleReasoningMessageContentEvent(
1683
+ chunk: Extract<StreamChunk, { type: 'REASONING_MESSAGE_CONTENT' }>,
1684
+ ): void {
1685
+ this.hasSeenReasoningEvents = true
1686
+ this.currentThinkingContent += chunk.delta
1687
+ }
1688
+
1662
1689
  /**
1663
1690
  * Tools available for execution this turn. The discovery tool is dropped
1664
1691
  * from the advertised set (`this.tools`) once every lazy tool is discovered,
@@ -2065,6 +2092,30 @@ class TextEngine<
2065
2092
  this.middlewareCtx.messages = this.messages
2066
2093
  }
2067
2094
 
2095
+ private addTerminalReasoningMessage(): void {
2096
+ this.finalizeCurrentThinkingStep()
2097
+ if (this.accumulatedThinking.length === 0) return
2098
+
2099
+ const messages = this.middlewareCtx.messages
2100
+ const alreadyPresent = messages.some(
2101
+ (message) =>
2102
+ message.role === 'assistant' && message.id === this.currentMessageId,
2103
+ )
2104
+ if (alreadyPresent) return
2105
+
2106
+ this.messages = [
2107
+ ...messages,
2108
+ {
2109
+ role: 'assistant',
2110
+ content: this.accumulatedContent || null,
2111
+ id: this.currentMessageId ?? undefined,
2112
+ createdAt: this.currentMessageCreatedAt ?? undefined,
2113
+ thinking: this.accumulatedThinking,
2114
+ },
2115
+ ]
2116
+ this.middlewareCtx.messages = this.messages
2117
+ }
2118
+
2068
2119
  /**
2069
2120
  * Extract client state (approvals and client tool results) from original messages.
2070
2121
  * This is called in the constructor BEFORE converting to ModelMessage format,
@@ -2254,12 +2305,18 @@ class TextEngine<
2254
2305
  : message.content === null
2255
2306
  ? undefined
2256
2307
  : JSON.stringify(message.content)
2308
+ const id =
2309
+ message.id ||
2310
+ `snapshot_${this.runIdOverride ?? this.requestId}_${index}`
2311
+ const parts =
2312
+ message.role === 'assistant' && message.thinking?.length
2313
+ ? modelMessageToUIMessage(message, id).parts
2314
+ : undefined
2257
2315
  return {
2258
- id:
2259
- message.id ||
2260
- `snapshot_${this.runIdOverride ?? this.requestId}_${index}`,
2316
+ id,
2261
2317
  role: message.role,
2262
2318
  ...(content !== undefined ? { content } : {}),
2319
+ ...(parts ? { parts } : {}),
2263
2320
  ...('toolCalls' in message && message.toolCalls
2264
2321
  ? { toolCalls: message.toolCalls }
2265
2322
  : {}),
@@ -3573,6 +3630,7 @@ class TextEngine<
3573
3630
  this.applyResumeToolState(config.resumeToolState)
3574
3631
  this.messages = config.messages
3575
3632
  this.systemPrompts = config.systemPrompts
3633
+ assertUniqueToolNames(config.tools)
3576
3634
  this.tools = config.tools
3577
3635
  this.params = {
3578
3636
  ...this.params,
@@ -3765,6 +3823,9 @@ export function chat<
3765
3823
  >,
3766
3824
  ): TextActivityResult<TSchema, TStream, TTools> {
3767
3825
  validateCapabilities(options.middleware ?? [], options.adapter)
3826
+ if (options.tools) {
3827
+ assertUniqueToolNames(options.tools)
3828
+ }
3768
3829
 
3769
3830
  const { outputSchema, stream } = options
3770
3831
 
@@ -0,0 +1,73 @@
1
+ import type { Tool } from '../../../types'
2
+
3
+ /**
4
+ * Thrown when `chat({ tools })` (or a provider converter) receives two tools
5
+ * with the same public `name`.
6
+ *
7
+ * The common case is a provider-native factory (`webSearchTool()`) next to an
8
+ * ordinary function that reused the reserved name (`web_search`). Providers
9
+ * reject that pair, so we fail before the request is built.
10
+ */
11
+ export class DuplicateToolNameError extends Error {
12
+ readonly toolName: string
13
+
14
+ constructor(toolName: string, message: string) {
15
+ super(message)
16
+ this.name = 'DuplicateToolNameError'
17
+ this.toolName = toolName
18
+ }
19
+ }
20
+
21
+ function isProviderNativeTool(tool: Tool): boolean {
22
+ const kind = tool.metadata?.['__kind']
23
+ return typeof kind === 'string' && kind.length > 0
24
+ }
25
+
26
+ function nativeAndCustomMessage(toolName: string) {
27
+ return [
28
+ `Cannot pass two tools named "${toolName}" in the same chat() call.`,
29
+ `One is the provider-native tool from a factory (for example webSearchTool()).`,
30
+ `The other is your own function with the same public name.`,
31
+ `Tool names in one tools array must be unique.`,
32
+ `Keep the factory for hosted search, or keep your function and give it a different name.`,
33
+ ].join(' ')
34
+ }
35
+
36
+ function duplicateNameMessage(toolName: string) {
37
+ return [
38
+ `Cannot pass two tools named "${toolName}" in the same chat() call.`,
39
+ `Tool names in one tools array must be unique.`,
40
+ ].join(' ')
41
+ }
42
+
43
+ /**
44
+ * Throws {@link DuplicateToolNameError} when two tools share a public name.
45
+ *
46
+ * The native-vs-custom message fires when one of the colliding tools carries
47
+ * adapter `metadata.__kind` (set by a provider factory) and another does not.
48
+ */
49
+ export function assertUniqueToolNames(tools: ReadonlyArray<Tool>): void {
50
+ const byName = new Map<string, Array<Tool>>()
51
+ for (const tool of tools) {
52
+ const group = byName.get(tool.name)
53
+ if (group) {
54
+ group.push(tool)
55
+ } else {
56
+ byName.set(tool.name, [tool])
57
+ }
58
+ }
59
+
60
+ for (const [name, group] of byName) {
61
+ if (group.length < 2) {
62
+ continue
63
+ }
64
+ const hasNative = group.some(isProviderNativeTool)
65
+ const hasCustom = group.some((tool) => !isProviderNativeTool(tool))
66
+ throw new DuplicateToolNameError(
67
+ name,
68
+ hasNative && hasCustom
69
+ ? nativeAndCustomMessage(name)
70
+ : duplicateNameMessage(name),
71
+ )
72
+ }
73
+ }
@@ -25,6 +25,10 @@ export {
25
25
  PendingTurnCapability,
26
26
  providePendingTurn,
27
27
  } from './activities/chat/middleware/pending-turn'
28
+ export {
29
+ assertUniqueToolNames,
30
+ DuplicateToolNameError,
31
+ } from './activities/chat/tools/unique-tool-names'
28
32
  export {
29
33
  appendOutputSchemaInstruction,
30
34
  parseJsonFromAssistantText,
package/src/index.ts CHANGED
@@ -103,6 +103,7 @@ export type {
103
103
 
104
104
  // MCP error classes (value exports — usable with instanceof)
105
105
  export { MCPDuplicateToolNameError } from './activities/chat/mcp/manager'
106
+ export { DuplicateToolNameError } from './activities/chat/tools/unique-tool-names'
106
107
 
107
108
  // Schema conversion (Standard JSON Schema compliant)
108
109
  export {
@@ -138,6 +139,22 @@ export type {
138
139
  UpsertableStreamDurability,
139
140
  } from './stream-durability'
140
141
 
142
+ // WebSocket transport utilities
143
+ export {
144
+ toWebSocketStream,
145
+ toWebSocketResponse,
146
+ resumeWebSocketStream,
147
+ resumeWebSocketResponse,
148
+ encodeWsFrame,
149
+ decodeWsFrame,
150
+ } from './stream-to-websocket'
151
+ export type {
152
+ WebSocketLike,
153
+ WsRunContext,
154
+ WebSocketStreamInit,
155
+ InboundFrame,
156
+ } from './stream-to-websocket'
157
+
141
158
  // Tool call management
142
159
  export { ToolCallManager } from './activities/chat/tools/tool-calls'
143
160
 
@@ -12,8 +12,8 @@ import type { TokenUsage } from '../types'
12
12
  * `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are de-facto extensions
13
13
  * consumed by backends like PostHog (which otherwise re-derive cost from their
14
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.
15
+ * semconv or de-facto convention (`billed`, `costDetails`, and the deprecated
16
+ * `durationSeconds`/`unitsBilled`) are TanStack-namespaced.
17
17
  *
18
18
  * Shared by `otelMiddleware` across every activity (chat and the media
19
19
  * activities) so usage lands identically whichever activity produced the span.
@@ -30,6 +30,16 @@ export function usageAttributes(
30
30
  'gen_ai.usage.input_tokens': usage.promptTokens,
31
31
  'gen_ai.usage.output_tokens': usage.completionTokens,
32
32
  }
33
+ // The self-describing billed quantity: the unit rides along as a string
34
+ // attribute so backends can label/aggregate non-token usage without
35
+ // out-of-band knowledge of the provider.
36
+ if (usage.billed !== undefined) {
37
+ const quantity = firstNumber(usage.billed.quantity)
38
+ if (quantity !== undefined) {
39
+ attrs['tanstack.ai.usage.billed_quantity'] = quantity
40
+ attrs['tanstack.ai.usage.billed_unit'] = usage.billed.unit
41
+ }
42
+ }
33
43
  const optional: Array<[key: string, value: unknown]> = [
34
44
  ['gen_ai.usage.total_tokens', usage.totalTokens],
35
45
  ['gen_ai.usage.cost', usage.cost],
@@ -78,7 +78,7 @@ function combineFailures(
78
78
  )
79
79
  }
80
80
 
81
- function runErrorChunk(
81
+ export function runErrorChunk(
82
82
  error: unknown,
83
83
  ): Extract<StreamChunk, { type: 'RUN_ERROR' }> {
84
84
  const payload = toRunErrorPayload(error)
@@ -366,7 +366,7 @@ export const RUN_ACCEPTED_EVENT = 'run.accepted'
366
366
  * The returned `getId` maps each forwarded chunk to the exact opaque offset
367
367
  * returned by the durability adapter for the SSE `id:` line.
368
368
  */
369
- function durableStreamSource<TOffset extends string>(
369
+ export function durableStreamSource<TOffset extends string>(
370
370
  stream: AsyncIterable<StreamChunk>,
371
371
  durability: StreamDurability<TOffset>,
372
372
  options: {