@tanstack/ai-client 0.11.8 → 0.13.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 (39) hide show
  1. package/dist/esm/chat-client.d.ts +17 -2
  2. package/dist/esm/chat-client.js +223 -37
  3. package/dist/esm/chat-client.js.map +1 -1
  4. package/dist/esm/connection-adapters.d.ts +20 -2
  5. package/dist/esm/connection-adapters.js +154 -48
  6. package/dist/esm/connection-adapters.js.map +1 -1
  7. package/dist/esm/devtools-noop.d.ts +60 -0
  8. package/dist/esm/devtools-noop.js +135 -0
  9. package/dist/esm/devtools-noop.js.map +1 -0
  10. package/dist/esm/devtools.d.ts +276 -0
  11. package/dist/esm/devtools.js +1178 -0
  12. package/dist/esm/devtools.js.map +1 -0
  13. package/dist/esm/events.d.ts +50 -7
  14. package/dist/esm/events.js +65 -20
  15. package/dist/esm/events.js.map +1 -1
  16. package/dist/esm/generation-client.d.ts +14 -0
  17. package/dist/esm/generation-client.js +135 -5
  18. package/dist/esm/generation-client.js.map +1 -1
  19. package/dist/esm/generation-types.d.ts +15 -1
  20. package/dist/esm/generation-types.js.map +1 -1
  21. package/dist/esm/index.d.ts +3 -2
  22. package/dist/esm/index.js +4 -1
  23. package/dist/esm/index.js.map +1 -1
  24. package/dist/esm/types.d.ts +78 -10
  25. package/dist/esm/types.js.map +1 -1
  26. package/dist/esm/video-generation-client.d.ts +15 -0
  27. package/dist/esm/video-generation-client.js +147 -8
  28. package/dist/esm/video-generation-client.js.map +1 -1
  29. package/package.json +8 -4
  30. package/src/chat-client.ts +285 -39
  31. package/src/connection-adapters.ts +226 -61
  32. package/src/devtools-noop.ts +205 -0
  33. package/src/devtools.ts +1866 -0
  34. package/src/events.ts +142 -11
  35. package/src/generation-client.ts +176 -9
  36. package/src/generation-types.ts +23 -4
  37. package/src/index.ts +15 -1
  38. package/src/types.ts +98 -12
  39. package/src/video-generation-client.ts +186 -11
@@ -1,10 +1,49 @@
1
1
  import { EventType, uiMessagesToWire } from '@tanstack/ai'
2
- import type { ModelMessage, StreamChunk, UIMessage } from '@tanstack/ai'
2
+ import type {
3
+ ModelMessage,
4
+ RunErrorEvent,
5
+ RunFinishedEvent,
6
+ StreamChunk,
7
+ UIMessage,
8
+ } from '@tanstack/ai'
9
+ import type { ChatFetcher } from './types'
10
+
11
+ /**
12
+ * Thrown when an SSE/HTTP stream ends with a non-empty unterminated buffer.
13
+ * Indicates the connection was cut mid-line (server crash, dropped TCP, proxy
14
+ * timeout) so the partial content cannot be safely parsed.
15
+ */
16
+ export class StreamTruncatedError extends Error {
17
+ constructor() {
18
+ super(
19
+ 'Stream ended with unterminated trailing data — connection was likely cut short.',
20
+ )
21
+ this.name = 'StreamTruncatedError'
22
+ }
23
+ }
3
24
 
4
25
  function generateRunId(prefix: string): string {
5
26
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
6
27
  }
7
28
 
29
+ /**
30
+ * Asserts an id is present when synthesizing a terminal event. The chat
31
+ * client always supplies `runContext.threadId` / `runContext.runId`, so an
32
+ * absent id at this layer indicates the adapter was wired up by a caller
33
+ * that bypassed that contract — surface it rather than fabricating one.
34
+ */
35
+ function requireSyntheticId(
36
+ value: string | undefined,
37
+ field: 'threadId' | 'runId',
38
+ ): string {
39
+ if (!value) {
40
+ throw new Error(
41
+ `Cannot synthesize terminal event: ${field} not supplied via runContext and not observed in the upstream stream.`,
42
+ )
43
+ }
44
+ return value
45
+ }
46
+
8
47
  /**
9
48
  * Merge custom headers into request headers
10
49
  */
@@ -52,15 +91,83 @@ async function* readStreamLines(
52
91
  }
53
92
  }
54
93
 
55
- // Process any remaining data in the buffer
56
- if (buffer.trim()) {
57
- yield buffer
94
+ // A non-empty trailing buffer means the connection was cut mid-line.
95
+ // Surface this as an error so the chat client transitions to 'error'
96
+ // state instead of silently presenting a partial stream as success.
97
+ // Skip when the consumer aborted — a user-initiated stop() interrupting
98
+ // mid-line is expected, not a truncation bug.
99
+ if (buffer.trim() && !abortSignal?.aborted) {
100
+ throw new StreamTruncatedError()
58
101
  }
59
102
  } finally {
60
103
  reader.releaseLock()
61
104
  }
62
105
  }
63
106
 
107
+ /**
108
+ * Yield StreamChunks parsed from an SSE Response body.
109
+ *
110
+ * Accepts either `data: {...}` lines or bare JSON lines. Skips comments
111
+ * starting with `:` (proxies and CDNs inject these as keepalives) and the
112
+ * `event:` / `id:` / `retry:` SSE control fields. A `[DONE]` sentinel is
113
+ * treated as a terminal event: a synthesized RUN_FINISHED is yielded using
114
+ * the most recent upstream `threadId` / `runId`, ensuring the consumer sees
115
+ * a clean terminal event with real correlation ids.
116
+ *
117
+ * A JSON parse failure throws — the consumer surfaces it as an error.
118
+ */
119
+ async function* responseToSSEChunks(
120
+ response: Response,
121
+ abortSignal?: AbortSignal,
122
+ ): AsyncGenerator<StreamChunk> {
123
+ if (!response.ok) {
124
+ throw new Error(
125
+ `HTTP error! status: ${response.status} ${response.statusText}`,
126
+ )
127
+ }
128
+ const reader = response.body?.getReader()
129
+ if (!reader) {
130
+ throw new Error('Response body is not readable')
131
+ }
132
+ let lastThreadId: string | undefined
133
+ let lastRunId: string | undefined
134
+ let lastModel: string | undefined
135
+ for await (const line of readStreamLines(reader, abortSignal)) {
136
+ if (
137
+ line.startsWith(':') ||
138
+ line.startsWith('event:') ||
139
+ line.startsWith('id:') ||
140
+ line.startsWith('retry:')
141
+ ) {
142
+ continue
143
+ }
144
+ const data = line.startsWith('data: ') ? line.slice(6) : line
145
+ if (data === '[DONE]') {
146
+ const synthetic: RunFinishedEvent = {
147
+ type: EventType.RUN_FINISHED,
148
+ threadId: lastThreadId ?? '',
149
+ runId: lastRunId ?? '',
150
+ model: lastModel ?? '',
151
+ timestamp: Date.now(),
152
+ finishReason: 'stop',
153
+ }
154
+ yield synthetic
155
+ return
156
+ }
157
+ const chunk = JSON.parse(data) as StreamChunk
158
+ if ('threadId' in chunk && typeof chunk.threadId === 'string') {
159
+ lastThreadId = chunk.threadId
160
+ }
161
+ if ('runId' in chunk && typeof chunk.runId === 'string') {
162
+ lastRunId = chunk.runId
163
+ }
164
+ if ('model' in chunk && typeof chunk.model === 'string') {
165
+ lastModel = chunk.model
166
+ }
167
+ yield chunk
168
+ }
169
+ }
170
+
64
171
  /**
65
172
  * Per-send context provided by the chat client to the connection adapter.
66
173
  * The adapter combines this with serialized messages to build a full
@@ -196,6 +303,8 @@ export function normalizeConnectionAdapter(
196
303
  },
197
304
  async send(messages, data, abortSignal, runContext) {
198
305
  let hasTerminalEvent = false
306
+ let upstreamThreadId: string | undefined
307
+ let upstreamRunId: string | undefined
199
308
  try {
200
309
  const stream = connection.connect(
201
310
  messages,
@@ -204,6 +313,12 @@ export function normalizeConnectionAdapter(
204
313
  runContext,
205
314
  )
206
315
  for await (const chunk of stream) {
316
+ if ('threadId' in chunk && typeof chunk.threadId === 'string') {
317
+ upstreamThreadId = chunk.threadId
318
+ }
319
+ if ('runId' in chunk && typeof chunk.runId === 'string') {
320
+ upstreamRunId = chunk.runId
321
+ }
207
322
  if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
208
323
  hasTerminalEvent = true
209
324
  }
@@ -214,31 +329,40 @@ export function normalizeConnectionAdapter(
214
329
  // synthesize RUN_FINISHED so request-scoped consumers can complete.
215
330
  // Reuse the caller's threadId/runId so client-side activeRunIds tracking matches.
216
331
  if (!abortSignal?.aborted && !hasTerminalEvent) {
217
- push({
332
+ const synthetic: RunFinishedEvent = {
218
333
  type: EventType.RUN_FINISHED,
219
- threadId: runContext?.threadId ?? `thread-${Date.now()}`,
220
- runId: runContext?.runId ?? `run-${Date.now()}`,
334
+ threadId: requireSyntheticId(
335
+ upstreamThreadId ?? runContext?.threadId,
336
+ 'threadId',
337
+ ),
338
+ runId: requireSyntheticId(
339
+ upstreamRunId ?? runContext?.runId,
340
+ 'runId',
341
+ ),
221
342
  model: 'connect-wrapper',
222
343
  timestamp: Date.now(),
223
344
  finishReason: 'stop',
224
- })
345
+ }
346
+ push(synthetic)
225
347
  }
226
348
  } catch (err) {
227
349
  if (!abortSignal?.aborted && !hasTerminalEvent) {
228
- push({
350
+ const message =
351
+ err instanceof Error ? err.message : 'Unknown error in connect()'
352
+ const synthetic: RunErrorEvent = {
229
353
  type: EventType.RUN_ERROR,
230
- threadId: runContext?.threadId ?? `thread-${Date.now()}`,
231
- runId: runContext?.runId ?? `run-${Date.now()}`,
354
+ threadId: requireSyntheticId(
355
+ upstreamThreadId ?? runContext?.threadId,
356
+ 'threadId',
357
+ ),
358
+ runId: requireSyntheticId(
359
+ upstreamRunId ?? runContext?.runId,
360
+ 'runId',
361
+ ),
232
362
  timestamp: Date.now(),
233
- message:
234
- err instanceof Error ? err.message : 'Unknown error in connect()',
235
- error: {
236
- message:
237
- err instanceof Error
238
- ? err.message
239
- : 'Unknown error in connect()',
240
- },
241
- })
363
+ message,
364
+ }
365
+ push(synthetic)
242
366
  }
243
367
  throw err
244
368
  }
@@ -358,37 +482,7 @@ export function fetchServerSentEvents(
358
482
  ...(signal ? { signal } : {}),
359
483
  })
360
484
 
361
- if (!response.ok) {
362
- throw new Error(
363
- `HTTP error! status: ${response.status} ${response.statusText}`,
364
- )
365
- }
366
-
367
- // Parse Server-Sent Events format
368
- const reader = response.body?.getReader()
369
- if (!reader) {
370
- throw new Error('Response body is not readable')
371
- }
372
-
373
- for await (const line of readStreamLines(reader, abortSignal)) {
374
- // Handle Server-Sent Events format
375
- const data = line.startsWith('data: ') ? line.slice(6) : line
376
-
377
- if (data === '[DONE]') {
378
- console.warn(
379
- '[@tanstack/ai-client] Received [DONE] sentinel. This is deprecated — upgrade your @tanstack/ai server package. RUN_FINISHED is the stream terminator.',
380
- )
381
- continue
382
- }
383
-
384
- try {
385
- const parsed: StreamChunk = JSON.parse(data)
386
- yield parsed
387
- } catch (parseError) {
388
- // Skip non-JSON lines or malformed chunks
389
- console.warn('Failed to parse SSE chunk:', data)
390
- }
391
- }
485
+ yield* responseToSSEChunks(response, abortSignal)
392
486
  },
393
487
  }
394
488
  }
@@ -507,12 +601,7 @@ export function fetchHttpStream(
507
601
  }
508
602
 
509
603
  for await (const line of readStreamLines(reader, abortSignal)) {
510
- try {
511
- const parsed: StreamChunk = JSON.parse(line)
512
- yield parsed
513
- } catch (parseError) {
514
- console.warn('Failed to parse HTTP stream chunk:', line)
515
- }
604
+ yield JSON.parse(line) as StreamChunk
516
605
  }
517
606
  },
518
607
  }
@@ -536,17 +625,92 @@ export function stream(
536
625
  streamFactory: (
537
626
  messages: Array<UIMessage> | Array<ModelMessage>,
538
627
  data?: Record<string, any>,
628
+ abortSignal?: AbortSignal,
539
629
  ) => AsyncIterable<StreamChunk>,
540
630
  ): ConnectConnectionAdapter {
541
631
  return {
542
- async *connect(messages, data, _abortSignal, _runContext) {
632
+ async *connect(messages, data, abortSignal) {
543
633
  // Pass messages as-is (UIMessages with parts preserved)
544
634
  // Server-side chat() handles conversion to ModelMessages
545
- yield* streamFactory(messages, data)
635
+ yield* streamFactory(messages, data, abortSignal)
636
+ },
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Wrap a `ChatFetcher` as a `ConnectConnectionAdapter` so the chat client can
642
+ * consume it through the same `subscribe`/`send` plumbing used for SSE /
643
+ * HTTP-stream / RPC connections. May return either a `Response` (parsed as
644
+ * SSE) or an `AsyncIterable<StreamChunk>` (yielded directly).
645
+ *
646
+ * @internal
647
+ */
648
+ export function fetcherToConnectionAdapter(
649
+ fetcher: ChatFetcher,
650
+ ): ConnectConnectionAdapter {
651
+ return {
652
+ async *connect(messages, data, abortSignal, runContext) {
653
+ if (!abortSignal) {
654
+ throw new Error(
655
+ 'fetcherToConnectionAdapter requires an AbortSignal — the chat client always supplies one.',
656
+ )
657
+ }
658
+ if (!runContext) {
659
+ throw new Error(
660
+ 'fetcherToConnectionAdapter requires a RunAgentInputContext — the chat client always supplies one.',
661
+ )
662
+ }
663
+ const uiMessages = messages as Array<UIMessage>
664
+ const result = await fetcher(
665
+ {
666
+ messages: uiMessages,
667
+ data,
668
+ threadId: runContext.threadId,
669
+ runId: runContext.runId,
670
+ },
671
+ { signal: abortSignal },
672
+ )
673
+ if (result instanceof Response) {
674
+ yield* responseToSSEChunks(result, abortSignal)
675
+ } else {
676
+ yield* abortableIterable(result, abortSignal)
677
+ }
546
678
  },
547
679
  }
548
680
  }
549
681
 
682
+ /**
683
+ * Wrap an AsyncIterable so iteration aborts when `signal` fires. Without
684
+ * this, a fetcher that returns a generator ignoring its signal would leave
685
+ * the for-await loop hanging until the iterable naturally ends.
686
+ */
687
+ async function* abortableIterable<T>(
688
+ iterable: AsyncIterable<T>,
689
+ signal: AbortSignal,
690
+ ): AsyncGenerator<T> {
691
+ if (signal.aborted) return
692
+ const iterator = iterable[Symbol.asyncIterator]()
693
+ const abortPromise = new Promise<{ done: true; value: undefined }>(
694
+ (resolve) => {
695
+ signal.addEventListener(
696
+ 'abort',
697
+ () => resolve({ done: true, value: undefined }),
698
+ { once: true },
699
+ )
700
+ },
701
+ )
702
+ try {
703
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
704
+ while (true) {
705
+ const result = await Promise.race([iterator.next(), abortPromise])
706
+ if (result.done) return
707
+ yield result.value
708
+ }
709
+ } finally {
710
+ await iterator.return?.()
711
+ }
712
+ }
713
+
550
714
  /**
551
715
  * Create an RPC stream connection adapter (for RPC-based streaming like Cap'n Web RPC)
552
716
  *
@@ -567,13 +731,14 @@ export function rpcStream(
567
731
  rpcCall: (
568
732
  messages: Array<UIMessage> | Array<ModelMessage>,
569
733
  data?: Record<string, any>,
734
+ abortSignal?: AbortSignal,
570
735
  ) => AsyncIterable<StreamChunk>,
571
736
  ): ConnectConnectionAdapter {
572
737
  return {
573
- async *connect(messages, data, _abortSignal, _runContext) {
738
+ async *connect(messages, data, abortSignal) {
574
739
  // Pass messages as-is (UIMessages with parts preserved)
575
740
  // Server-side chat() handles conversion to ModelMessages
576
- yield* rpcCall(messages, data)
741
+ yield* rpcCall(messages, data, abortSignal)
577
742
  },
578
743
  }
579
744
  }
@@ -0,0 +1,205 @@
1
+ // No-op devtools bridge implementations + factories. The chat / generation /
2
+ // video clients import the real bridge classes as types only and accept a
3
+ // factory in options; when no factory is supplied they fall back to the
4
+ // no-op factories here, which never touch `aiEventClient` or any of the
5
+ // heavy preview/fixture machinery in `./devtools`. This keeps `./devtools`
6
+ // outside the main-entry import graph — consumers opt into functional
7
+ // devtools via `@tanstack/ai-client/devtools` (see `package.json#exports`).
8
+ import { ChatClientEventEmitter } from './events'
9
+ import type {
10
+ AIDevtoolsToolFixture,
11
+ ChatDevtoolsBridge,
12
+ ChatDevtoolsBridgeOptions,
13
+ GenerationDevtoolsBridge,
14
+ GenerationDevtoolsBridgeOptions,
15
+ VideoDevtoolsBridge,
16
+ VideoDevtoolsBridgeOptions,
17
+ } from './devtools'
18
+ import type { StreamChunk } from '@tanstack/ai'
19
+ import type {
20
+ ChatClientEventContext,
21
+ ChatClientRunEventContext,
22
+ } from './events'
23
+
24
+ export type ChatDevtoolsBridgeFactory = (
25
+ options: ChatDevtoolsBridgeOptions,
26
+ ) => ChatDevtoolsBridge
27
+
28
+ export type GenerationDevtoolsBridgeFactory = <TOutput>(
29
+ options: GenerationDevtoolsBridgeOptions<TOutput>,
30
+ ) => GenerationDevtoolsBridge<TOutput>
31
+
32
+ export type VideoDevtoolsBridgeFactory = <TOutput>(
33
+ options: VideoDevtoolsBridgeOptions<TOutput>,
34
+ ) => VideoDevtoolsBridge<TOutput>
35
+
36
+ // ===========================================================================
37
+ // No-op event emitter — extends the abstract base so it satisfies the type
38
+ // without dragging in any of the event-bus runtime cost.
39
+ // ===========================================================================
40
+
41
+ class NoOpChatClientEventEmitter extends ChatClientEventEmitter {
42
+ protected emitEvent(): void {
43
+ // intentionally empty
44
+ }
45
+ }
46
+
47
+ // ===========================================================================
48
+ // No-op bridges. Methods exist to satisfy the structural shape of the real
49
+ // classes; every emit/record call short-circuits.
50
+ // ===========================================================================
51
+
52
+ export class NoOpChatDevtoolsBridge {
53
+ readonly events: ChatClientEventEmitter
54
+
55
+ constructor(options: ChatDevtoolsBridgeOptions) {
56
+ this.events = new NoOpChatClientEventEmitter(options.clientId)
57
+ }
58
+
59
+ // base bridge surface
60
+ emitRegistered(): void {}
61
+ emitUpdated(): void {}
62
+ emitSnapshot(): void {}
63
+ emitToolsRegistered(): void {}
64
+ emitRunLifecycle(
65
+ _eventType: unknown,
66
+ _runId: string,
67
+ _status: unknown,
68
+ _options?: { error?: string },
69
+ ): void {}
70
+ deactivate(): void {}
71
+ supersede(): void {}
72
+ dispose(): void {}
73
+
74
+ // chat-specific surface
75
+ setCurrentStreamId(_streamId: string | null): void {}
76
+ getCurrentStreamId(): string | null {
77
+ return null
78
+ }
79
+ getLastStreamId(): string | null {
80
+ return null
81
+ }
82
+ resolveStreamId(): string {
83
+ return ''
84
+ }
85
+ observeChunk(_chunk: StreamChunk): void {}
86
+ beginRun(_runId: string, _threadId: string): void {}
87
+ getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
88
+ return undefined
89
+ }
90
+ getCurrentOrLastRunEventContext(): ChatClientRunEventContext | undefined {
91
+ return undefined
92
+ }
93
+ findToolCallContext(toolCallId: string): ChatClientEventContext {
94
+ return { toolCallId }
95
+ }
96
+ async applyFixture(_fixture: AIDevtoolsToolFixture): Promise<void> {
97
+ // intentionally empty
98
+ }
99
+ }
100
+
101
+ export class NoOpGenerationDevtoolsBridge<TOutput> {
102
+ constructor(_options: GenerationDevtoolsBridgeOptions<TOutput>) {}
103
+
104
+ // base bridge surface
105
+ emitRegistered(): void {}
106
+ emitUpdated(): void {}
107
+ emitSnapshot(): void {}
108
+ emitToolsRegistered(): void {}
109
+ emitRunLifecycle(): void {}
110
+ deactivate(): void {}
111
+ supersede(): void {}
112
+ dispose(): void {}
113
+
114
+ // generation-specific surface
115
+ beginRun(_input: unknown): string {
116
+ // Real factories supply a stable id; the no-op still returns a
117
+ // unique value because the generation client passes this run id to
118
+ // the adapter's RunAgentInputContext.
119
+ return `noop-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
120
+ }
121
+ ensureRunStarted(_runId: string): void {}
122
+ finishRun(
123
+ _runId: string,
124
+ _eventType: 'run:completed' | 'run:errored' | 'run:cancelled',
125
+ _status: 'completed' | 'errored' | 'cancelled',
126
+ _error?: string,
127
+ ): void {}
128
+ getActiveRunId(): string | null {
129
+ return null
130
+ }
131
+ resetRuns(): void {}
132
+ recordResultChange(): void {}
133
+ recordLoadingChange(): void {}
134
+ recordErrorChange(_error: Error | undefined): void {}
135
+ recordStatusChange(): void {}
136
+ recordProgressChange(): void {}
137
+ emitState(): void {}
138
+ }
139
+
140
+ export class NoOpVideoDevtoolsBridge<
141
+ TOutput,
142
+ > extends NoOpGenerationDevtoolsBridge<TOutput> {
143
+ constructor(options: VideoDevtoolsBridgeOptions<TOutput>) {
144
+ super(options)
145
+ }
146
+
147
+ recordJobIdChange(): void {}
148
+ recordVideoStatusChange(): void {}
149
+ }
150
+
151
+ // Compile-time parity checks. If a public method is added to the real
152
+ // bridge class without a matching stub on the no-op, the corresponding
153
+ // `Exclude<...>` will resolve to a non-`never` union and the `as never`
154
+ // assignment below will fail to typecheck — surfacing the drift at build
155
+ // time instead of as a runtime TypeError later.
156
+ type _ChatBridgeMissing = Exclude<
157
+ keyof ChatDevtoolsBridge,
158
+ keyof NoOpChatDevtoolsBridge
159
+ >
160
+ type _GenerationBridgeMissing = Exclude<
161
+ keyof GenerationDevtoolsBridge<unknown>,
162
+ keyof NoOpGenerationDevtoolsBridge<unknown>
163
+ >
164
+ type _VideoBridgeMissing = Exclude<
165
+ keyof VideoDevtoolsBridge<unknown>,
166
+ keyof NoOpVideoDevtoolsBridge<unknown>
167
+ >
168
+ const _chatBridgeParity: _ChatBridgeMissing = undefined as never
169
+ const _generationBridgeParity: _GenerationBridgeMissing = undefined as never
170
+ const _videoBridgeParity: _VideoBridgeMissing = undefined as never
171
+ void _chatBridgeParity
172
+ void _generationBridgeParity
173
+ void _videoBridgeParity
174
+
175
+ // ===========================================================================
176
+ // Factories — these are what the clients call when no real factory was
177
+ // supplied in options.
178
+ // ===========================================================================
179
+
180
+ // Casts use `unknown` because the no-op classes don't `extend` the real bridge
181
+ // (that would pull the real implementation into the main-entry import graph).
182
+ // Structural parity is enforced by the `_*BridgeMissing` checks above.
183
+
184
+ export const createNoOpChatDevtoolsBridge: ChatDevtoolsBridgeFactory = (
185
+ options,
186
+ ) =>
187
+ // eslint-disable-next-line no-restricted-syntax -- see comment above
188
+ new NoOpChatDevtoolsBridge(options) as unknown as ChatDevtoolsBridge
189
+
190
+ export const createNoOpGenerationDevtoolsBridge: GenerationDevtoolsBridgeFactory =
191
+ <TOutput>(options: GenerationDevtoolsBridgeOptions<TOutput>) =>
192
+ // eslint-disable-next-line no-restricted-syntax -- see comment above
193
+ new NoOpGenerationDevtoolsBridge<TOutput>(
194
+ options,
195
+ ) as unknown as GenerationDevtoolsBridge<TOutput>
196
+
197
+ export const createNoOpVideoDevtoolsBridge: VideoDevtoolsBridgeFactory = <
198
+ TOutput,
199
+ >(
200
+ options: VideoDevtoolsBridgeOptions<TOutput>,
201
+ ) =>
202
+ // eslint-disable-next-line no-restricted-syntax -- see comment above
203
+ new NoOpVideoDevtoolsBridge<TOutput>(
204
+ options,
205
+ ) as unknown as VideoDevtoolsBridge<TOutput>