@tanstack/ai-client 0.20.0 → 0.22.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.
@@ -4,6 +4,7 @@ import type {
4
4
  AudioVisualization,
5
5
  RealtimeMessage,
6
6
  RealtimeMode,
7
+ RealtimeSessionConfig,
7
8
  RealtimeStatus,
8
9
  RealtimeToken,
9
10
  } from '@tanstack/ai/client'
@@ -101,6 +102,7 @@ export class RealtimeClient {
101
102
  this.clientTools.size > 0
102
103
  ? Array.from(this.clientTools.values())
103
104
  : undefined
105
+
104
106
  this.connection = await this.options.adapter.connect(
105
107
  this.token,
106
108
  toolsList,
@@ -282,6 +284,38 @@ export class RealtimeClient {
282
284
  return this.connection?.getAudioVisualization() ?? null
283
285
  }
284
286
 
287
+ /**
288
+ * Update the session configuration.
289
+ * This applies changes to the active connection and persists them for future reconnections.
290
+ */
291
+ updateSession(config: Partial<RealtimeSessionConfig>): void {
292
+ // Persist type-compatible fields so future (re)connections use the updated
293
+ // config. `tools` is intentionally excluded: a session's tool configs are
294
+ // serialized descriptors, whereas `options.tools` holds executable client
295
+ // tools tracked separately via `clientTools`.
296
+ const o = this.options
297
+ if (config.instructions !== undefined) o.instructions = config.instructions
298
+ if (config.voice !== undefined) o.voice = config.voice
299
+ if (config.vadMode !== undefined) o.vadMode = config.vadMode
300
+ if (config.outputModalities !== undefined) {
301
+ o.outputModalities = config.outputModalities
302
+ }
303
+ if (config.temperature !== undefined) o.temperature = config.temperature
304
+ if (config.maxOutputTokens !== undefined) {
305
+ o.maxOutputTokens = config.maxOutputTokens
306
+ }
307
+ if (config.semanticEagerness !== undefined) {
308
+ o.semanticEagerness = config.semanticEagerness
309
+ }
310
+ if (config.providerOptions !== undefined) {
311
+ o.providerOptions = config.providerOptions
312
+ }
313
+
314
+ if (this.connection) {
315
+ this.applySessionConfig()
316
+ }
317
+ }
318
+
285
319
  // ============================================================================
286
320
  // State Subscription
287
321
  // ============================================================================
@@ -353,6 +387,7 @@ export class RealtimeClient {
353
387
  try {
354
388
  this.token = await this.options.getToken()
355
389
  this.scheduleTokenRefresh()
390
+ this.connection?.updateToken?.(this.token)
356
391
  // Note: Some providers may require reconnection with new token
357
392
  // This is handled by the adapter implementation
358
393
  } catch (error) {
@@ -487,6 +522,18 @@ export class RealtimeClient {
487
522
  this.options.onError?.(error)
488
523
  }),
489
524
  )
525
+
526
+ this.unsubscribers.push(
527
+ this.connection.on('go_away', ({ timeLeft }) => {
528
+ this.options.onGoAway?.(timeLeft)
529
+ }),
530
+ )
531
+
532
+ this.unsubscribers.push(
533
+ this.connection.on('usage', (usage) => {
534
+ this.options.onUsage?.(usage)
535
+ }),
536
+ )
490
537
  }
491
538
 
492
539
  private applySessionConfig(): void {
@@ -513,18 +560,22 @@ export class RealtimeClient {
513
560
  semanticEagerness
514
561
  if (!hasConfig) return
515
562
 
516
- // `RealtimeToolConfig.inputSchema` is `Record<string, any>` (no
517
- // `undefined` under `exactOptionalPropertyTypes`). Conditionally spread
518
- // it so we don't pass `undefined` when the tool has no input schema.
563
+ // `RealtimeToolConfig.inputSchema`/`outputSchema` are `Record<string, any>`
564
+ // (no `undefined` under `exactOptionalPropertyTypes`). Conditionally spread
565
+ // each so we don't pass `undefined` when the tool has no such schema.
519
566
  const toolsConfig = tools
520
567
  ? Array.from(this.clientTools.values()).map((t) => {
521
568
  const inputSchema = t.inputSchema
522
569
  ? convertSchemaToJsonSchema(t.inputSchema)
523
570
  : undefined
571
+ const outputSchema = t.outputSchema
572
+ ? convertSchemaToJsonSchema(t.outputSchema)
573
+ : undefined
524
574
  return {
525
575
  name: t.name,
526
576
  description: t.description,
527
577
  ...(inputSchema ? { inputSchema } : {}),
578
+ ...(outputSchema ? { outputSchema } : {}),
528
579
  }
529
580
  })
530
581
  : undefined
@@ -6,6 +6,7 @@ import type {
6
6
  RealtimeStatus,
7
7
  RealtimeToken,
8
8
  } from '@tanstack/ai/client'
9
+ import type { UsageInfo } from '@tanstack/ai'
9
10
 
10
11
  // The realtime adapter contract lives in `@tanstack/ai` (the shared layer both
11
12
  // providers and this client depend on) so provider packages don't need to
@@ -82,6 +83,11 @@ export interface RealtimeClientOptions {
82
83
  */
83
84
  semanticEagerness?: 'low' | 'medium' | 'high'
84
85
 
86
+ /**
87
+ * Provider-specific options
88
+ */
89
+ providerOptions?: Record<string, unknown>
90
+
85
91
  // Callbacks
86
92
  onStatusChange?: (status: RealtimeStatus) => void
87
93
  onModeChange?: (mode: RealtimeMode) => void
@@ -90,6 +96,8 @@ export interface RealtimeClientOptions {
90
96
  onConnect?: () => void
91
97
  onDisconnect?: () => void
92
98
  onInterrupted?: () => void
99
+ onUsage?: (usage: UsageInfo) => void
100
+ onGoAway?: (timeLeft?: string) => void
93
101
  }
94
102
 
95
103
  // ============================================================================
package/src/types.ts CHANGED
@@ -144,6 +144,88 @@ export interface MultimodalContent {
144
144
  id?: string
145
145
  }
146
146
 
147
+ /**
148
+ * Action taken when `sendMessage` is called while the client is busy
149
+ * (streaming, claiming a send, or draining the queue).
150
+ * - `queue`: hold the message; it auto-sends when the current run settles
151
+ * **successfully**.
152
+ * - `drop`: ignore the send (promise still resolves; does not throw).
153
+ * - `interrupt`: abort the current stream and send immediately. Unlike
154
+ * `stop()`, does **not** flush already-queued messages — they still drain
155
+ * after the interrupting send settles successfully.
156
+ */
157
+ export type WhenBusy = 'queue' | 'drop' | 'interrupt'
158
+
159
+ /**
160
+ * Why the client is busy when a {@link QueueStrategy} runs.
161
+ * - `streaming` — an LLM stream is active (`isLoading`).
162
+ * - `sendInFlight` — a send has claimed the client but is not yet loading.
163
+ * - `draining` — the queue drain loop is delivering pending messages.
164
+ */
165
+ export type QueueBusyReason = 'streaming' | 'sendInFlight' | 'draining'
166
+
167
+ /**
168
+ * A user message held in the send queue while a stream is active.
169
+ * Rendered separately from `messages`; cancellable via `cancelQueued(id)`
170
+ * until it drains.
171
+ */
172
+ export interface QueuedMessage {
173
+ id: string
174
+ content: string | MultimodalContent
175
+ createdAt: number
176
+ }
177
+
178
+ /**
179
+ * Declarative queue policy.
180
+ */
181
+ export interface QueueConfig {
182
+ /**
183
+ * Action when the client is busy (streaming, claiming a send, or draining).
184
+ * Default `'queue'`.
185
+ */
186
+ whenBusy?: WhenBusy
187
+ /**
188
+ * How queued items leave the queue.
189
+ * - `'fifo'`: one at a time, in order (default).
190
+ * - `'batch'`: merge all queued items into one send when the run settles
191
+ * successfully.
192
+ */
193
+ drain?: 'fifo' | 'batch'
194
+ /** Max queued items. Unlimited when omitted. `0` means never queue. */
195
+ maxSize?: number
196
+ /**
197
+ * Behavior when `maxSize` is reached. Default `'reject'`.
198
+ * `'reject'` silently discards the new send (does not throw);
199
+ * `'drop-oldest'` evicts the oldest queued item to make room.
200
+ * Only meaningful when `maxSize` is set.
201
+ */
202
+ onOverflow?: 'reject' | 'drop-oldest'
203
+ }
204
+
205
+ /**
206
+ * Escape hatch: decide the action for a single send. Drain stays FIFO for the
207
+ * function form (no `batch` via function). Per-call `sendOptions.whenBusy`
208
+ * overrides the strategy for that send.
209
+ *
210
+ * Actions match {@link WhenBusy}: `'queue' | 'drop' | 'interrupt'`. Concurrent
211
+ * streams are not supported. `pending.id` is the id that will be stored if the
212
+ * action is `'queue'` (safe to pass to `cancelQueued`).
213
+ */
214
+ export type QueueStrategy = (ctx: {
215
+ pending: QueuedMessage
216
+ busyReason: QueueBusyReason
217
+ queued: ReadonlyArray<QueuedMessage>
218
+ }) => { action: WhenBusy }
219
+
220
+ /** A `WhenBusy` shorthand, a full config, or a strategy function. */
221
+ export type QueueOption = WhenBusy | QueueConfig | QueueStrategy
222
+
223
+ /** Per-call overrides for `sendMessage`. */
224
+ export interface SendMessageOptions {
225
+ /** Overrides the configured `whenBusy` for this one send. */
226
+ whenBusy?: WhenBusy
227
+ }
228
+
147
229
  /**
148
230
  * Message parts - building blocks of UIMessage
149
231
  */
@@ -166,15 +248,27 @@ type ToolCallPartForTool<T> = T extends AnyClientTool
166
248
  /** Parsed tool input (typed from inputSchema) */
167
249
  input?: InferToolInput<T>
168
250
  state: ToolCallState
169
- /** Approval metadata if tool requires user approval */
170
- approval?: {
171
- id: string // Unique approval ID
172
- needsApproval: boolean // Always true if present
173
- approved?: boolean // User's decision (undefined until responded)
174
- }
175
251
  /** Tool execution output (for client tools or after approval) */
176
252
  output?: InferToolOutput<T>
177
- }
253
+ } & (NonNullable<T['needsApproval']> extends true
254
+ ? {
255
+ /**
256
+ * Approval metadata — present only on tools defined with
257
+ * `needsApproval: true`. Populated once the call reaches
258
+ * `state: 'approval-requested'`. `needsApproval` is an optional
259
+ * property on the tool, so we index into it (rather than
260
+ * `T extends { needsApproval: true }`, which an optional property
261
+ * never satisfies) and strip `undefined` before comparing to `true`.
262
+ */
263
+ approval?: {
264
+ id: string // Unique approval ID
265
+ needsApproval: boolean // Always true if present
266
+ approved?: boolean // User's decision (undefined until responded)
267
+ }
268
+ }
269
+ : // Tools without `needsApproval: true` never carry an approval field.
270
+ // `& unknown` is a no-op intersection (adds nothing).
271
+ unknown)
178
272
  : never
179
273
 
180
274
  /**
@@ -480,6 +574,23 @@ export interface ChatClientBaseOptions<
480
574
  */
481
575
  onSessionGeneratingChange?: (isGenerating: boolean) => void
482
576
 
577
+ /**
578
+ * Policy for messages sent while the client is busy (streaming, claiming
579
+ * a send, or draining the queue). Accepts a `WhenBusy` string, a
580
+ * `QueueConfig`, or a `QueueStrategy` function.
581
+ * Default: `{ whenBusy: 'queue', drain: 'fifo' }`.
582
+ * Queued items auto-send only after a **successful** settle; they are
583
+ * discarded on error/abort, `stop()`, `clear()`, `unsubscribe()`, and
584
+ * `reload()`.
585
+ */
586
+ queue?: QueueOption
587
+
588
+ /**
589
+ * Callback when the pending send queue changes (enqueue, cancel, drain,
590
+ * or flush).
591
+ */
592
+ onQueueChange?: (queue: Array<QueuedMessage>) => void
593
+
483
594
  /**
484
595
  * Callback when a custom event is received from a server-side tool.
485
596
  * Custom events are emitted by tools using `context.emitCustomEvent()` during execution.