@tanstack/ai-client 0.26.0 → 0.29.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/byok/client.d.ts +52 -0
- package/dist/esm/byok/client.js +300 -0
- package/dist/esm/byok/client.js.map +1 -0
- package/dist/esm/byok/passkey.d.ts +43 -0
- package/dist/esm/byok/passkey.js +251 -0
- package/dist/esm/byok/passkey.js.map +1 -0
- package/dist/esm/byok/resolve.d.ts +8 -0
- package/dist/esm/byok/resolve.js +20 -0
- package/dist/esm/byok/resolve.js.map +1 -0
- package/dist/esm/byok/storage.d.ts +15 -0
- package/dist/esm/byok/storage.js +16 -0
- package/dist/esm/byok/storage.js.map +1 -0
- package/dist/esm/byok.d.ts +8 -0
- package/dist/esm/byok.js +5 -0
- package/dist/esm/chat-client.d.ts +19 -3
- package/dist/esm/chat-client.js +82 -42
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +2 -0
- package/dist/esm/connection-adapters.js +35 -10
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/generation-client.d.ts +3 -1
- package/dist/esm/generation-client.js +23 -4
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-types.d.ts +14 -0
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/interrupt-manager.d.ts +1 -0
- package/dist/esm/interrupt-manager.js +15 -6
- package/dist/esm/interrupt-manager.js.map +1 -1
- package/dist/esm/message-date-normalizer.d.ts +3 -0
- package/dist/esm/message-date-normalizer.js +35 -0
- package/dist/esm/message-date-normalizer.js.map +1 -0
- package/dist/esm/sse-parser.js +8 -1
- package/dist/esm/sse-parser.js.map +1 -1
- package/dist/esm/storage-adapters.js +13 -1
- package/dist/esm/storage-adapters.js.map +1 -1
- package/dist/esm/types.d.ts +34 -3
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +3 -1
- package/dist/esm/video-generation-client.js +25 -6
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +7 -3
- package/src/byok/client.ts +360 -0
- package/src/byok/passkey.ts +399 -0
- package/src/byok/resolve.ts +30 -0
- package/src/byok/storage.ts +29 -0
- package/src/byok.ts +20 -0
- package/src/chat-client.ts +165 -72
- package/src/connection-adapters.ts +46 -14
- package/src/generation-client.ts +48 -4
- package/src/generation-types.ts +16 -0
- package/src/interrupt-manager.ts +20 -6
- package/src/message-date-normalizer.ts +29 -0
- package/src/sse-parser.ts +10 -0
- package/src/storage-adapters.ts +13 -2
- package/src/types.ts +36 -3
- package/src/video-generation-client.ts +45 -4
package/src/chat-client.ts
CHANGED
|
@@ -9,6 +9,15 @@ import {
|
|
|
9
9
|
restoreInboundChunk,
|
|
10
10
|
tanstackMetadata,
|
|
11
11
|
} from '@tanstack/ai/client'
|
|
12
|
+
import {
|
|
13
|
+
ByokBlockedError,
|
|
14
|
+
ByokMissingError,
|
|
15
|
+
ByokUnresolvedProviderError,
|
|
16
|
+
} from '@tanstack/ai/byok'
|
|
17
|
+
import {
|
|
18
|
+
prepareResolvedByokHeaders,
|
|
19
|
+
resolveByokProviderId,
|
|
20
|
+
} from './byok/resolve'
|
|
12
21
|
import { createNoOpChatDevtoolsBridge } from './devtools-noop'
|
|
13
22
|
import {
|
|
14
23
|
fetcherToConnectionAdapter,
|
|
@@ -17,6 +26,7 @@ import {
|
|
|
17
26
|
} from './connection-adapters'
|
|
18
27
|
import { ChatPersistor } from './client-persistor'
|
|
19
28
|
import { ClearedStreamTracker } from './cleared-stream-tracker'
|
|
29
|
+
import { normalizeMessagesDates } from './message-date-normalizer'
|
|
20
30
|
import { InterruptManager } from './interrupt-manager'
|
|
21
31
|
import type {
|
|
22
32
|
AnyClientTool,
|
|
@@ -27,6 +37,7 @@ import type {
|
|
|
27
37
|
RunAgentResumeItem,
|
|
28
38
|
StreamChunk,
|
|
29
39
|
} from '@tanstack/ai/client'
|
|
40
|
+
import type { ByokClient } from './byok'
|
|
30
41
|
import type {
|
|
31
42
|
ChatHydrationResult,
|
|
32
43
|
ConnectionAdapter,
|
|
@@ -97,6 +108,8 @@ type ChatClientUpdateOptionsWithoutContext<
|
|
|
97
108
|
/** @deprecated Use `forwardedProps` instead. */
|
|
98
109
|
body?: Record<string, any>
|
|
99
110
|
forwardedProps?: Record<string, any>
|
|
111
|
+
byok?: ByokClient
|
|
112
|
+
byokProvider?: () => string | undefined
|
|
100
113
|
tools?: TTools
|
|
101
114
|
interrupts?: TInterrupts
|
|
102
115
|
queue?: QueueOption
|
|
@@ -348,6 +361,8 @@ export class ChatClient<
|
|
|
348
361
|
// merged on every send, with `forwardedProps` winning on key collision.
|
|
349
362
|
private bodyOption: Record<string, any> = {}
|
|
350
363
|
private forwardedPropsOption: Record<string, any> = {}
|
|
364
|
+
private byok: ByokClient | undefined
|
|
365
|
+
private byokProvider: (() => string | undefined) | undefined
|
|
351
366
|
private context: TContext | undefined = undefined
|
|
352
367
|
private pendingMessageBody: Record<string, any> | undefined = undefined
|
|
353
368
|
private queueConfig: NormalizedQueueConfig
|
|
@@ -403,6 +418,11 @@ export class ChatClient<
|
|
|
403
418
|
private processingResolve: (() => void) | null = null
|
|
404
419
|
private errorReportedGeneration: number | null = null
|
|
405
420
|
private streamGeneration = 0
|
|
421
|
+
private continuationGeneration = 0
|
|
422
|
+
// Generation of the run that opened the current stream. Public
|
|
423
|
+
// `addToolResult` must use this, not the live counter: `stop()` increments
|
|
424
|
+
// the live counter, so a post-stop call would otherwise look current.
|
|
425
|
+
private streamContinuationGeneration = 0
|
|
406
426
|
// Tracks whether a queued checkForContinuation was skipped because
|
|
407
427
|
// continuationPending was true (chained approval scenario)
|
|
408
428
|
private continuationSkipped = false
|
|
@@ -494,6 +514,8 @@ export class ChatClient<
|
|
|
494
514
|
// winning on key collision.
|
|
495
515
|
this.bodyOption = options.body || {}
|
|
496
516
|
this.forwardedPropsOption = options.forwardedProps || {}
|
|
517
|
+
this.byok = options.byok
|
|
518
|
+
this.byokProvider = options.byokProvider
|
|
497
519
|
this.context = options.context
|
|
498
520
|
this.queueConfig = normalizeQueueOption(options.queue)
|
|
499
521
|
this.connection = normalizeConnectionAdapter(resolveTransport(options))
|
|
@@ -721,6 +743,7 @@ export class ChatClient<
|
|
|
721
743
|
const clientTool = clientTools.get(args.toolName)
|
|
722
744
|
const executeFunc = clientTool?.execute
|
|
723
745
|
if (executeFunc) {
|
|
746
|
+
const continuationGeneration = this.continuationGeneration
|
|
724
747
|
// Capture the run context at execution-start so a tool whose
|
|
725
748
|
// result lands AFTER the originating run finishes still reports
|
|
726
749
|
// back against the originating run, not whatever run is
|
|
@@ -747,6 +770,7 @@ export class ChatClient<
|
|
|
747
770
|
state: 'output-available',
|
|
748
771
|
},
|
|
749
772
|
clientTool,
|
|
773
|
+
continuationGeneration,
|
|
750
774
|
runEventContext,
|
|
751
775
|
)
|
|
752
776
|
} catch (error: any) {
|
|
@@ -759,6 +783,7 @@ export class ChatClient<
|
|
|
759
783
|
errorText: error.message,
|
|
760
784
|
},
|
|
761
785
|
clientTool,
|
|
786
|
+
continuationGeneration,
|
|
762
787
|
runEventContext,
|
|
763
788
|
)
|
|
764
789
|
} finally {
|
|
@@ -1001,7 +1026,7 @@ export class ChatClient<
|
|
|
1001
1026
|
// A send may have started while the fetch was in flight — don't stomp it.
|
|
1002
1027
|
if (this.isLoading || this.abortController) return
|
|
1003
1028
|
if (result.messages.length > 0) {
|
|
1004
|
-
this.processor.setMessages(result.messages)
|
|
1029
|
+
this.processor.setMessages(normalizeMessagesDates(result.messages))
|
|
1005
1030
|
}
|
|
1006
1031
|
if (result.interrupts && result.interrupts.pending.length > 0) {
|
|
1007
1032
|
// Pending interrupt = the thread is paused awaiting a human decision, so
|
|
@@ -1288,6 +1313,21 @@ export class ChatClient<
|
|
|
1288
1313
|
): Promise<boolean> {
|
|
1289
1314
|
const target = state ?? this.lastResume
|
|
1290
1315
|
if (!target) return Promise.resolve(false)
|
|
1316
|
+
return this.resumeInterruptsUnsafeForGeneration(
|
|
1317
|
+
resume,
|
|
1318
|
+
target,
|
|
1319
|
+
this.continuationGeneration,
|
|
1320
|
+
)
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
private resumeInterruptsUnsafeForGeneration(
|
|
1324
|
+
resume: Array<RunAgentResumeItem>,
|
|
1325
|
+
target: ChatResumeState,
|
|
1326
|
+
continuationGeneration: number,
|
|
1327
|
+
): Promise<boolean> {
|
|
1328
|
+
if (continuationGeneration !== this.continuationGeneration) {
|
|
1329
|
+
return Promise.resolve(false)
|
|
1330
|
+
}
|
|
1291
1331
|
// Auto-executed client tools resolve during the parent stream's
|
|
1292
1332
|
// `pendingToolExecutions` wait — while `isLoading` is still true.
|
|
1293
1333
|
// Defer the child continuation until that stream settles so we do not
|
|
@@ -1296,7 +1336,13 @@ export class ChatClient<
|
|
|
1296
1336
|
return new Promise<boolean>((resolve, reject) => {
|
|
1297
1337
|
this.queuePostStreamAction(async () => {
|
|
1298
1338
|
try {
|
|
1299
|
-
resolve(
|
|
1339
|
+
resolve(
|
|
1340
|
+
await this.resumeInterruptsUnsafeForGeneration(
|
|
1341
|
+
resume,
|
|
1342
|
+
target,
|
|
1343
|
+
continuationGeneration,
|
|
1344
|
+
),
|
|
1345
|
+
)
|
|
1300
1346
|
} catch (error) {
|
|
1301
1347
|
reject(error)
|
|
1302
1348
|
}
|
|
@@ -1320,6 +1366,7 @@ export class ChatClient<
|
|
|
1320
1366
|
private async submitInterruptBatch(
|
|
1321
1367
|
submission: InterruptManagerSubmission,
|
|
1322
1368
|
): Promise<void> {
|
|
1369
|
+
const continuationGeneration = this.continuationGeneration
|
|
1323
1370
|
this.activeInterruptSubmission = submission
|
|
1324
1371
|
this.interruptSubmissionFailure = undefined
|
|
1325
1372
|
// Reflect approval decisions in the local message tree immediately so a
|
|
@@ -1331,15 +1378,21 @@ export class ChatClient<
|
|
|
1331
1378
|
const approvalId = resolution.interruptId
|
|
1332
1379
|
this.processor.addToolApprovalResponse(approvalId, approved)
|
|
1333
1380
|
}
|
|
1334
|
-
const resumed = await this.
|
|
1381
|
+
const resumed = await this.resumeInterruptsUnsafeForGeneration(
|
|
1335
1382
|
[...submission.resolutions],
|
|
1336
1383
|
{
|
|
1337
1384
|
threadId: submission.threadId,
|
|
1338
1385
|
runId: submission.interruptedRunId,
|
|
1339
1386
|
},
|
|
1387
|
+
continuationGeneration,
|
|
1340
1388
|
).finally(() => {
|
|
1341
|
-
this
|
|
1389
|
+
// Only clear if this resume still owns the client: `stop()` may have
|
|
1390
|
+
// invalidated it while the submission was settling.
|
|
1391
|
+
if (this.activeInterruptSubmission === submission) {
|
|
1392
|
+
this.activeInterruptSubmission = undefined
|
|
1393
|
+
}
|
|
1342
1394
|
})
|
|
1395
|
+
if (continuationGeneration !== this.continuationGeneration) return
|
|
1343
1396
|
const failure = this.takeInterruptSubmissionFailure()
|
|
1344
1397
|
if (failure !== undefined) {
|
|
1345
1398
|
throw { errors: failure.errors }
|
|
@@ -1662,6 +1715,7 @@ export class ChatClient<
|
|
|
1662
1715
|
// persisted pointer with the provider id — so a SECOND reload would
|
|
1663
1716
|
// `joinRun` an id the log isn't keyed by and never re-attach.
|
|
1664
1717
|
this.lastResume = { threadId: this.threadId, runId }
|
|
1718
|
+
this.streamContinuationGeneration = this.continuationGeneration
|
|
1665
1719
|
this.setIsLoading(true)
|
|
1666
1720
|
this.setStatus('streaming')
|
|
1667
1721
|
void (async () => {
|
|
@@ -1882,8 +1936,12 @@ export class ChatClient<
|
|
|
1882
1936
|
* - A MultimodalContent object with content array and optional custom ID
|
|
1883
1937
|
* @param body - Optional body parameters to merge with the client's base body for this request.
|
|
1884
1938
|
* Uses shallow merge with per-message body taking priority.
|
|
1885
|
-
* @param sendOptions - Per-call overrides
|
|
1886
|
-
*
|
|
1939
|
+
* @param sendOptions - Per-call overrides. `{ whenBusy }` overrides the
|
|
1940
|
+
* queue policy for this one send. `{ body }`
|
|
1941
|
+
* shallow-merges with `body` and with the chat-level
|
|
1942
|
+
* `body` / `forwardedProps`. `sendOptions.body` wins
|
|
1943
|
+
* on key collisions. Framework hooks forward this
|
|
1944
|
+
* object as their second argument.
|
|
1887
1945
|
*
|
|
1888
1946
|
* @example
|
|
1889
1947
|
* ```ts
|
|
@@ -1893,9 +1951,13 @@ export class ChatClient<
|
|
|
1893
1951
|
* // Text message with custom body params
|
|
1894
1952
|
* await client.sendMessage('Hello!', { temperature: 0.7 })
|
|
1895
1953
|
*
|
|
1896
|
-
* // Per-call whenBusy override
|
|
1954
|
+
* // Per-call whenBusy override
|
|
1897
1955
|
* await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
|
|
1898
1956
|
*
|
|
1957
|
+
* // Per-call body via options. Same effect as the positional arg.
|
|
1958
|
+
* // This is the shape the framework hooks (`useChat`, `injectChat`) forward.
|
|
1959
|
+
* await client.sendMessage('Hello!', undefined, { body: { temperature: 0.7 } })
|
|
1960
|
+
*
|
|
1899
1961
|
* // Multimodal message with image
|
|
1900
1962
|
* await client.sendMessage({
|
|
1901
1963
|
* content: [
|
|
@@ -1933,13 +1995,15 @@ export class ChatClient<
|
|
|
1933
1995
|
)
|
|
1934
1996
|
}
|
|
1935
1997
|
|
|
1998
|
+
const resolvedBody = { ...body, ...sendOptions?.body }
|
|
1999
|
+
|
|
1936
2000
|
if (this.isSendBusy()) {
|
|
1937
2001
|
const { action, id } = this.decideWhenBusy(content, sendOptions)
|
|
1938
2002
|
if (action === 'drop') {
|
|
1939
2003
|
return
|
|
1940
2004
|
}
|
|
1941
2005
|
if (action === 'queue') {
|
|
1942
|
-
this.enqueueMessage(content,
|
|
2006
|
+
this.enqueueMessage(content, resolvedBody, id)
|
|
1943
2007
|
return
|
|
1944
2008
|
}
|
|
1945
2009
|
// 'interrupt': abort the current stream, then send now.
|
|
@@ -1956,7 +2020,7 @@ export class ChatClient<
|
|
|
1956
2020
|
}
|
|
1957
2021
|
|
|
1958
2022
|
try {
|
|
1959
|
-
await this.deliverMessage(content,
|
|
2023
|
+
await this.deliverMessage(content, resolvedBody)
|
|
1960
2024
|
} finally {
|
|
1961
2025
|
this.sendInFlight = false
|
|
1962
2026
|
}
|
|
@@ -2137,6 +2201,7 @@ export class ChatClient<
|
|
|
2137
2201
|
|
|
2138
2202
|
// Track generation so a superseded stream's cleanup doesn't clobber the new one
|
|
2139
2203
|
const generation = ++this.streamGeneration
|
|
2204
|
+
this.streamContinuationGeneration = this.continuationGeneration
|
|
2140
2205
|
// Native interrupt continuation is a fresh child run. The interrupted run
|
|
2141
2206
|
// is carried as parentRunId and the complete resolution batch as resume.
|
|
2142
2207
|
const resumeThreadId = this.pendingResumeThreadId
|
|
@@ -2191,7 +2256,7 @@ export class ChatClient<
|
|
|
2191
2256
|
// order (later spreads win):
|
|
2192
2257
|
// 1. Legacy `body` option (deprecated).
|
|
2193
2258
|
// 2. Canonical `forwardedProps` option (wins over `body`).
|
|
2194
|
-
// 3. Per-
|
|
2259
|
+
// 3. Per-call body (`pendingMessageBody`: positional + sendOptions.body).
|
|
2195
2260
|
// The AG-UI standard `threadId` is sent at the wire's top level for
|
|
2196
2261
|
// run/conversation correlation, so we no longer auto-emit a separate
|
|
2197
2262
|
// `conversationId` here — `chat({ threadId })` server-side covers the
|
|
@@ -2231,6 +2296,15 @@ export class ChatClient<
|
|
|
2231
2296
|
// AG-UI servers consuming `RunAgentInput.tools[].parameters` expect
|
|
2232
2297
|
// JSON Schema; sending a Standard Schema instance directly would
|
|
2233
2298
|
// serialize to an unusable shape.
|
|
2299
|
+
let byokHeaders: Record<string, string> | undefined
|
|
2300
|
+
if (this.byok) {
|
|
2301
|
+
const provider = resolveByokProviderId(
|
|
2302
|
+
this.byokProvider,
|
|
2303
|
+
mergedBody.provider,
|
|
2304
|
+
)
|
|
2305
|
+
byokHeaders = await prepareResolvedByokHeaders(this.byok, provider)
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2234
2308
|
const runContext = {
|
|
2235
2309
|
threadId: resumeThreadId ?? this.threadId,
|
|
2236
2310
|
runId,
|
|
@@ -2246,6 +2320,7 @@ export class ChatClient<
|
|
|
2246
2320
|
})),
|
|
2247
2321
|
forwardedProps: { ...mergedBody },
|
|
2248
2322
|
...(resumeItems ? { resume: resumeItems } : {}),
|
|
2323
|
+
...(byokHeaders ? { headers: byokHeaders } : {}),
|
|
2249
2324
|
}
|
|
2250
2325
|
this.devtoolsBridge.beginRun(runContext.runId, runContext.threadId)
|
|
2251
2326
|
activeDevtoolsRunId = runContext.runId
|
|
@@ -2301,32 +2376,45 @@ export class ChatClient<
|
|
|
2301
2376
|
// Finalize (idempotent — may already be done by RUN_FINISHED handler)
|
|
2302
2377
|
this.processor.finalizeStream()
|
|
2303
2378
|
streamCompletedSuccessfully = true
|
|
2304
|
-
} catch (err) {
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
}
|
|
2315
|
-
return false
|
|
2379
|
+
} catch (err: unknown) {
|
|
2380
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
2381
|
+
if (error.name === 'AbortError') {
|
|
2382
|
+
if (activeDevtoolsRunId) {
|
|
2383
|
+
this.devtoolsBridge.emitRunLifecycle(
|
|
2384
|
+
'run:cancelled',
|
|
2385
|
+
activeDevtoolsRunId,
|
|
2386
|
+
'cancelled',
|
|
2387
|
+
)
|
|
2388
|
+
runTerminalEventEmitted = true
|
|
2316
2389
|
}
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2390
|
+
return false
|
|
2391
|
+
}
|
|
2392
|
+
if (error instanceof ByokMissingError) {
|
|
2393
|
+
this.byok?.request(error.provider, 'missing')
|
|
2394
|
+
}
|
|
2395
|
+
if (error instanceof ByokBlockedError && error.reason === 'locked') {
|
|
2396
|
+
this.byok?.request(error.provider, 'locked')
|
|
2397
|
+
}
|
|
2398
|
+
if (generation === this.streamGeneration) {
|
|
2399
|
+
this.reportStreamError(error)
|
|
2400
|
+
if (activeDevtoolsRunId) {
|
|
2401
|
+
this.devtoolsBridge.emitRunLifecycle(
|
|
2402
|
+
'run:errored',
|
|
2403
|
+
activeDevtoolsRunId,
|
|
2404
|
+
'errored',
|
|
2405
|
+
{ error: error.message },
|
|
2406
|
+
)
|
|
2407
|
+
runTerminalEventEmitted = true
|
|
2328
2408
|
}
|
|
2329
2409
|
}
|
|
2410
|
+
if (
|
|
2411
|
+
generation === this.streamGeneration &&
|
|
2412
|
+
(error instanceof ByokMissingError ||
|
|
2413
|
+
error instanceof ByokBlockedError ||
|
|
2414
|
+
error instanceof ByokUnresolvedProviderError)
|
|
2415
|
+
) {
|
|
2416
|
+
throw error
|
|
2417
|
+
}
|
|
2330
2418
|
} finally {
|
|
2331
2419
|
// Only clean up if this is still the active stream.
|
|
2332
2420
|
// A superseded stream (e.g. reload() started a new one) must not
|
|
@@ -2361,43 +2449,18 @@ export class ChatClient<
|
|
|
2361
2449
|
// Drain any actions that were queued while the stream was in progress
|
|
2362
2450
|
await this.drainPostStreamActions()
|
|
2363
2451
|
|
|
2364
|
-
// Continue conversation if the stream ended with a tool result (server tool completed)
|
|
2365
|
-
// but ONLY if the model indicated it wants to continue (finishReason !== 'stop').
|
|
2366
|
-
// When finishReason is 'stop', the model is done — don't re-send.
|
|
2367
2452
|
if (streamCompletedSuccessfully) {
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
)
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
} catch (error) {
|
|
2380
|
-
console.error('Failed to continue flow after tool result:', error)
|
|
2381
|
-
// Continuation failed without starting a new stream — don't
|
|
2382
|
-
// leave queued user messages stranded forever. (isLoading is
|
|
2383
|
-
// already false in this finally block.)
|
|
2384
|
-
await this.drainQueue()
|
|
2385
|
-
}
|
|
2386
|
-
} else {
|
|
2387
|
-
if (this.status !== 'ready') {
|
|
2388
|
-
// Terminal run, but onStreamEnd never fired: the processor had
|
|
2389
|
-
// no assistant message to emit it for (e.g. a bare
|
|
2390
|
-
// RUN_FINISHED{stop}, #421). The normal path already set
|
|
2391
|
-
// 'ready', so this is a no-op.
|
|
2392
|
-
this.setStatus('ready')
|
|
2393
|
-
}
|
|
2394
|
-
// Auto-send queued messages once the run fully settles. When a
|
|
2395
|
-
// continuation runs instead (tool-result branch above), that
|
|
2396
|
-
// continuation's own finally drains the queue. Skip if a drain
|
|
2397
|
-
// loop is already walking the queue (avoids nested re-entry).
|
|
2398
|
-
if (!this.messageQueueDraining) {
|
|
2399
|
-
await this.drainQueue()
|
|
2400
|
-
}
|
|
2453
|
+
if (this.status !== 'ready') {
|
|
2454
|
+
// Terminal run, but onStreamEnd never fired: the processor had
|
|
2455
|
+
// no assistant message to emit it for (e.g. a bare
|
|
2456
|
+
// RUN_FINISHED{stop}, #421). The normal path already set
|
|
2457
|
+
// 'ready', so this is a no-op.
|
|
2458
|
+
this.setStatus('ready')
|
|
2459
|
+
}
|
|
2460
|
+
// Auto-send queued messages once the run fully settles. Skip if a
|
|
2461
|
+
// drain loop is already walking the queue (avoids nested re-entry).
|
|
2462
|
+
if (!this.messageQueueDraining) {
|
|
2463
|
+
await this.drainQueue()
|
|
2401
2464
|
}
|
|
2402
2465
|
} else {
|
|
2403
2466
|
// Error/abort settle for the active generation: don't strand or
|
|
@@ -2482,9 +2545,14 @@ export class ChatClient<
|
|
|
2482
2545
|
* Stop the current stream
|
|
2483
2546
|
*/
|
|
2484
2547
|
stop(): void {
|
|
2548
|
+
// Invalidate deferred work from the stopped continuation.
|
|
2549
|
+
this.continuationGeneration++
|
|
2485
2550
|
const hadLocalStream = this.abortController !== null
|
|
2486
2551
|
this.cancelInFlightStream({ setReadyStatus: true })
|
|
2487
2552
|
this.discardPendingSends()
|
|
2553
|
+
this.lastResume = null
|
|
2554
|
+
this.activeInterruptSubmission = undefined
|
|
2555
|
+
this.interruptManager.reset()
|
|
2488
2556
|
if (hadLocalStream) {
|
|
2489
2557
|
this.resetSessionGenerating()
|
|
2490
2558
|
}
|
|
@@ -2528,12 +2596,17 @@ export class ChatClient<
|
|
|
2528
2596
|
*/
|
|
2529
2597
|
async addToolResult(result: ClientToolResult): Promise<void> {
|
|
2530
2598
|
const clientTool = this.clientToolsRef.current.get(result.tool)
|
|
2531
|
-
await this.addToolResultForClientTool(
|
|
2599
|
+
await this.addToolResultForClientTool(
|
|
2600
|
+
result,
|
|
2601
|
+
clientTool,
|
|
2602
|
+
this.streamContinuationGeneration,
|
|
2603
|
+
)
|
|
2532
2604
|
}
|
|
2533
2605
|
|
|
2534
2606
|
private async addToolResultForClientTool(
|
|
2535
2607
|
result: ClientToolResult,
|
|
2536
2608
|
clientTool: AnyClientTool | undefined,
|
|
2609
|
+
continuationGeneration: number,
|
|
2537
2610
|
context?: ChatClientRunEventContext,
|
|
2538
2611
|
): Promise<void> {
|
|
2539
2612
|
if (clientTool && result.state !== 'output-error') {
|
|
@@ -2560,6 +2633,8 @@ export class ChatClient<
|
|
|
2560
2633
|
context,
|
|
2561
2634
|
)
|
|
2562
2635
|
|
|
2636
|
+
if (continuationGeneration !== this.continuationGeneration) return
|
|
2637
|
+
|
|
2563
2638
|
// Always update local message state so the tool-call part is terminal in
|
|
2564
2639
|
// the UI even when the AG-UI interrupt path owns server continuation.
|
|
2565
2640
|
this.processor.addToolResult(
|
|
@@ -2585,7 +2660,11 @@ export class ChatClient<
|
|
|
2585
2660
|
|
|
2586
2661
|
// If stream is in progress, queue continuation check for after it ends
|
|
2587
2662
|
if (this.isLoading) {
|
|
2588
|
-
this.queuePostStreamAction(() =>
|
|
2663
|
+
this.queuePostStreamAction(() =>
|
|
2664
|
+
continuationGeneration === this.continuationGeneration
|
|
2665
|
+
? this.checkForContinuation()
|
|
2666
|
+
: Promise.resolve(),
|
|
2667
|
+
)
|
|
2589
2668
|
return
|
|
2590
2669
|
}
|
|
2591
2670
|
|
|
@@ -2665,7 +2744,11 @@ export class ChatClient<
|
|
|
2665
2744
|
* Queue an action to be executed after the current stream ends
|
|
2666
2745
|
*/
|
|
2667
2746
|
private queuePostStreamAction(action: () => Promise<void>): void {
|
|
2668
|
-
this.
|
|
2747
|
+
const continuationGeneration = this.continuationGeneration
|
|
2748
|
+
this.postStreamActions.push(async () => {
|
|
2749
|
+
if (continuationGeneration !== this.continuationGeneration) return
|
|
2750
|
+
await action()
|
|
2751
|
+
})
|
|
2669
2752
|
}
|
|
2670
2753
|
|
|
2671
2754
|
/**
|
|
@@ -2688,6 +2771,10 @@ export class ChatClient<
|
|
|
2688
2771
|
* Check if we should continue the flow and do so if needed
|
|
2689
2772
|
*/
|
|
2690
2773
|
private async checkForContinuation(): Promise<void> {
|
|
2774
|
+
// stop() bumps continuationGeneration without opening a new stream.
|
|
2775
|
+
if (this.streamContinuationGeneration !== this.continuationGeneration) {
|
|
2776
|
+
return
|
|
2777
|
+
}
|
|
2691
2778
|
if (this.hasPendingInterrupts()) return
|
|
2692
2779
|
|
|
2693
2780
|
// Prevent duplicate continuation attempts
|
|
@@ -2979,6 +3066,12 @@ export class ChatClient<
|
|
|
2979
3066
|
if (options.forwardedProps !== undefined) {
|
|
2980
3067
|
this.forwardedPropsOption = options.forwardedProps
|
|
2981
3068
|
}
|
|
3069
|
+
if (options.byok !== undefined) {
|
|
3070
|
+
this.byok = options.byok
|
|
3071
|
+
}
|
|
3072
|
+
if (options.byokProvider !== undefined) {
|
|
3073
|
+
this.byokProvider = options.byokProvider
|
|
3074
|
+
}
|
|
2982
3075
|
if ('context' in options) {
|
|
2983
3076
|
this.context = options.context
|
|
2984
3077
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
uiMessagesToWire,
|
|
7
7
|
withTanstackMetadata,
|
|
8
8
|
} from '@tanstack/ai/client'
|
|
9
|
+
import { ByokMissingError, isByokMissingBody } from '@tanstack/ai/byok'
|
|
9
10
|
import {
|
|
10
11
|
createResponseStreamTextDecoder,
|
|
11
12
|
getResponseStreamReader,
|
|
@@ -19,6 +20,7 @@ import type {
|
|
|
19
20
|
UIMessage,
|
|
20
21
|
} from '@tanstack/ai/client'
|
|
21
22
|
import type { ChatFetcher, ChatPendingInterrupt } from './types'
|
|
23
|
+
import { normalizeMessagesDates } from './message-date-normalizer'
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Associates connect-wrapped chunks with the run they were produced under.
|
|
@@ -498,12 +500,35 @@ async function* linesToNdjsonEvents(
|
|
|
498
500
|
}
|
|
499
501
|
}
|
|
500
502
|
|
|
501
|
-
function assertResponseOk(response: Response): void {
|
|
502
|
-
if (
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
503
|
+
async function assertResponseOk(response: Response): Promise<void> {
|
|
504
|
+
if (response.ok) return
|
|
505
|
+
if (response.status === 401) {
|
|
506
|
+
const body: unknown = await response
|
|
507
|
+
.clone()
|
|
508
|
+
.json()
|
|
509
|
+
.catch(() => null)
|
|
510
|
+
if (isByokMissingBody(body)) {
|
|
511
|
+
throw new ByokMissingError(body.error.provider)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
throw new Error(
|
|
515
|
+
`HTTP error! status: ${response.status} ${response.statusText}`,
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function errorFromXhrStatus(xhr: XMLHttpRequest): Error {
|
|
520
|
+
if (xhr.status === 401) {
|
|
521
|
+
let parsed: unknown = null
|
|
522
|
+
try {
|
|
523
|
+
parsed = JSON.parse(xhr.responseText)
|
|
524
|
+
} catch {
|
|
525
|
+
parsed = null
|
|
526
|
+
}
|
|
527
|
+
if (isByokMissingBody(parsed)) {
|
|
528
|
+
return new ByokMissingError(parsed.error.provider)
|
|
529
|
+
}
|
|
506
530
|
}
|
|
531
|
+
return new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`)
|
|
507
532
|
}
|
|
508
533
|
|
|
509
534
|
/**
|
|
@@ -525,7 +550,7 @@ async function fetchThreadHydration(
|
|
|
525
550
|
headers: { Accept: 'application/json', ...headers },
|
|
526
551
|
credentials,
|
|
527
552
|
})
|
|
528
|
-
assertResponseOk(response)
|
|
553
|
+
await assertResponseOk(response)
|
|
529
554
|
const data = (await response.json()) as {
|
|
530
555
|
messages?: Array<UIMessage>
|
|
531
556
|
activeRun?: { runId?: unknown } | null
|
|
@@ -549,7 +574,9 @@ async function fetchThreadHydration(
|
|
|
549
574
|
}
|
|
550
575
|
: null
|
|
551
576
|
return {
|
|
552
|
-
messages: Array.isArray(data.messages)
|
|
577
|
+
messages: Array.isArray(data.messages)
|
|
578
|
+
? normalizeMessagesDates(data.messages)
|
|
579
|
+
: [],
|
|
553
580
|
activeRun,
|
|
554
581
|
interrupts,
|
|
555
582
|
}
|
|
@@ -574,7 +601,7 @@ async function fetchGenerationHydration(
|
|
|
574
601
|
headers: { Accept: 'application/json', ...headers },
|
|
575
602
|
credentials,
|
|
576
603
|
})
|
|
577
|
-
assertResponseOk(response)
|
|
604
|
+
await assertResponseOk(response)
|
|
578
605
|
const raw: unknown = await response.json()
|
|
579
606
|
// A 200 carrying `null` is a legitimate hydration miss — the server has no
|
|
580
607
|
// record for this thread — and reading `.activeRun` off `null` would throw.
|
|
@@ -609,7 +636,7 @@ async function* responseToSSEEvents(
|
|
|
609
636
|
abortSignal?: AbortSignal,
|
|
610
637
|
fallbackIds?: { threadId?: string; runId?: string },
|
|
611
638
|
): AsyncGenerator<StreamEvent> {
|
|
612
|
-
assertResponseOk(response)
|
|
639
|
+
await assertResponseOk(response)
|
|
613
640
|
const reader = getResponseStreamReader(response)
|
|
614
641
|
yield* linesToSSEEvents(readStreamLines(reader, abortSignal), fallbackIds)
|
|
615
642
|
}
|
|
@@ -619,7 +646,7 @@ async function* responseToNdjsonEvents(
|
|
|
619
646
|
response: Response,
|
|
620
647
|
abortSignal?: AbortSignal,
|
|
621
648
|
): AsyncGenerator<StreamEvent> {
|
|
622
|
-
assertResponseOk(response)
|
|
649
|
+
await assertResponseOk(response)
|
|
623
650
|
const reader = getResponseStreamReader(response)
|
|
624
651
|
yield* linesToNdjsonEvents(readStreamLines(reader, abortSignal))
|
|
625
652
|
}
|
|
@@ -809,6 +836,8 @@ export interface RunAgentInputContext {
|
|
|
809
836
|
}>
|
|
810
837
|
/** Arbitrary user-controlled passthrough data. */
|
|
811
838
|
forwardedProps?: Record<string, unknown>
|
|
839
|
+
/** Extra request headers for this run (e.g. BYOK keys). POST only. */
|
|
840
|
+
headers?: Record<string, string>
|
|
812
841
|
}
|
|
813
842
|
|
|
814
843
|
export interface ConnectConnectionAdapter {
|
|
@@ -1198,7 +1227,7 @@ function buildRunAgentInputBody(
|
|
|
1198
1227
|
): Record<string, unknown> {
|
|
1199
1228
|
// Precedence (later spreads win): static adapter `body` is the base,
|
|
1200
1229
|
// overridden by `runContext.forwardedProps`, overridden by per-message `data`.
|
|
1201
|
-
const wireMessages = uiMessagesToWire(messages
|
|
1230
|
+
const wireMessages = uiMessagesToWire(messages)
|
|
1202
1231
|
const forwardedProps = {
|
|
1203
1232
|
...options.body,
|
|
1204
1233
|
...(runContext?.forwardedProps ?? {}),
|
|
@@ -1272,6 +1301,7 @@ export function fetchServerSentEvents(
|
|
|
1272
1301
|
const requestHeaders: Record<string, string> = {
|
|
1273
1302
|
'Content-Type': 'application/json',
|
|
1274
1303
|
...mergeHeaders(resolvedOptions.headers),
|
|
1304
|
+
...mergeHeaders(runContext?.headers),
|
|
1275
1305
|
...runIdHeader(runContext?.runId),
|
|
1276
1306
|
}
|
|
1277
1307
|
|
|
@@ -1443,6 +1473,7 @@ export function fetchHttpStream(
|
|
|
1443
1473
|
const requestHeaders: Record<string, string> = {
|
|
1444
1474
|
'Content-Type': 'application/json',
|
|
1445
1475
|
...mergeHeaders(resolvedOptions.headers),
|
|
1476
|
+
...mergeHeaders(runContext?.headers),
|
|
1446
1477
|
...runIdHeader(runContext?.runId),
|
|
1447
1478
|
}
|
|
1448
1479
|
|
|
@@ -1596,7 +1627,7 @@ function readXhrLines(
|
|
|
1596
1627
|
|
|
1597
1628
|
const enqueueDelta = () => {
|
|
1598
1629
|
if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
|
|
1599
|
-
error =
|
|
1630
|
+
error = errorFromXhrStatus(xhr)
|
|
1600
1631
|
done = true
|
|
1601
1632
|
return
|
|
1602
1633
|
}
|
|
@@ -1625,7 +1656,7 @@ function readXhrLines(
|
|
|
1625
1656
|
// is an error, but status 0 here is not — treat the trailing buffer as a
|
|
1626
1657
|
// truncation check instead of fabricating a bogus "status: 0" error.
|
|
1627
1658
|
if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
|
|
1628
|
-
error =
|
|
1659
|
+
error = errorFromXhrStatus(xhr)
|
|
1629
1660
|
} else if (buffer.trim() && !aborted) {
|
|
1630
1661
|
error = new StreamTruncatedError()
|
|
1631
1662
|
}
|
|
@@ -1721,6 +1752,7 @@ function createConfiguredXhrRequest(
|
|
|
1721
1752
|
const requestHeaders: Record<string, string> = {
|
|
1722
1753
|
'Content-Type': 'application/json',
|
|
1723
1754
|
...mergeHeaders(options.headers),
|
|
1755
|
+
...mergeHeaders(method === 'POST' ? runContext?.headers : undefined),
|
|
1724
1756
|
// Client-chosen run id for durability (POST only; the GET join carries it
|
|
1725
1757
|
// in the query instead).
|
|
1726
1758
|
...(method === 'POST' ? runIdHeader(runContext?.runId) : {}),
|
|
@@ -2494,7 +2526,7 @@ export function fetcherToConnectionAdapter(
|
|
|
2494
2526
|
? { resume: runContext.resume }
|
|
2495
2527
|
: {}),
|
|
2496
2528
|
},
|
|
2497
|
-
{ signal: abortSignal },
|
|
2529
|
+
{ signal: abortSignal, headers: runContext.headers },
|
|
2498
2530
|
)
|
|
2499
2531
|
if (result instanceof Response) {
|
|
2500
2532
|
yield* responseToSSEChunks(result, abortSignal)
|