@tanstack/ai 0.20.0 → 0.21.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 (98) hide show
  1. package/dist/esm/activities/chat/adapter.js +3 -1
  2. package/dist/esm/activities/chat/adapter.js.map +1 -1
  3. package/dist/esm/activities/chat/index.d.ts +4 -4
  4. package/dist/esm/activities/chat/index.js +403 -276
  5. package/dist/esm/activities/chat/index.js.map +1 -1
  6. package/dist/esm/activities/chat/messages.js +1 -1
  7. package/dist/esm/activities/chat/messages.js.map +1 -1
  8. package/dist/esm/activities/chat/middleware/compose.d.ts +10 -1
  9. package/dist/esm/activities/chat/middleware/compose.js +57 -0
  10. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  11. package/dist/esm/activities/chat/middleware/index.d.ts +1 -1
  12. package/dist/esm/activities/chat/middleware/types.d.ts +40 -8
  13. package/dist/esm/activities/chat/stream/processor.d.ts +8 -8
  14. package/dist/esm/activities/chat/stream/processor.js +29 -21
  15. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  16. package/dist/esm/activities/chat/stream/strategies.d.ts +3 -3
  17. package/dist/esm/activities/chat/stream/strategies.js +4 -4
  18. package/dist/esm/activities/chat/stream/strategies.js.map +1 -1
  19. package/dist/esm/activities/chat/tools/lazy-tool-manager.js +5 -0
  20. package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
  21. package/dist/esm/activities/chat/tools/schema-converter.d.ts +13 -2
  22. package/dist/esm/activities/chat/tools/schema-converter.js +47 -37
  23. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  24. package/dist/esm/activities/chat/tools/tool-calls.d.ts +2 -2
  25. package/dist/esm/activities/chat/tools/tool-calls.js +17 -9
  26. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  27. package/dist/esm/activities/chat/tools/tool-definition.js +1 -1
  28. package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
  29. package/dist/esm/activities/generateAudio/adapter.js +3 -1
  30. package/dist/esm/activities/generateAudio/adapter.js.map +1 -1
  31. package/dist/esm/activities/generateAudio/index.d.ts +5 -1
  32. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  33. package/dist/esm/activities/generateImage/adapter.js +3 -1
  34. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  35. package/dist/esm/activities/generateImage/index.js +5 -0
  36. package/dist/esm/activities/generateImage/index.js.map +1 -1
  37. package/dist/esm/activities/generateSpeech/adapter.js +3 -1
  38. package/dist/esm/activities/generateSpeech/adapter.js.map +1 -1
  39. package/dist/esm/activities/generateTranscription/adapter.js +3 -1
  40. package/dist/esm/activities/generateTranscription/adapter.js.map +1 -1
  41. package/dist/esm/activities/generateVideo/adapter.js +3 -1
  42. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  43. package/dist/esm/activities/stream-generation-result.js +6 -2
  44. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  45. package/dist/esm/activities/summarize/adapter.js +3 -1
  46. package/dist/esm/activities/summarize/adapter.js.map +1 -1
  47. package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +1 -1
  48. package/dist/esm/activities/summarize/chat-stream-summarize.js +5 -0
  49. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  50. package/dist/esm/activities/summarize/index.js.map +1 -1
  51. package/dist/esm/extend-adapter.js.map +1 -1
  52. package/dist/esm/index.d.ts +3 -2
  53. package/dist/esm/index.js +4 -1
  54. package/dist/esm/index.js.map +1 -1
  55. package/dist/esm/logger/internal-logger.js +2 -0
  56. package/dist/esm/logger/internal-logger.js.map +1 -1
  57. package/dist/esm/middlewares/content-guard.js +5 -4
  58. package/dist/esm/middlewares/content-guard.js.map +1 -1
  59. package/dist/esm/middlewares/otel.js +25 -18
  60. package/dist/esm/middlewares/otel.js.map +1 -1
  61. package/dist/esm/realtime/index.d.ts +1 -1
  62. package/dist/esm/realtime/index.js.map +1 -1
  63. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  64. package/dist/esm/tools/provider-tool.d.ts +9 -0
  65. package/dist/esm/tools/provider-tool.js +7 -0
  66. package/dist/esm/tools/provider-tool.js.map +1 -0
  67. package/dist/esm/types.d.ts +6 -6
  68. package/dist/esm/utilities/ag-ui-wire.js +4 -1
  69. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  70. package/dist/esm/utilities/chat-params.js.map +1 -1
  71. package/package.json +2 -2
  72. package/skills/ai-core/middleware/SKILL.md +124 -18
  73. package/skills/ai-core/structured-outputs/SKILL.md +13 -0
  74. package/src/activities/chat/index.ts +685 -394
  75. package/src/activities/chat/messages.ts +3 -2
  76. package/src/activities/chat/middleware/compose.ts +65 -5
  77. package/src/activities/chat/middleware/index.ts +1 -0
  78. package/src/activities/chat/middleware/types.ts +64 -11
  79. package/src/activities/chat/stream/processor.ts +21 -21
  80. package/src/activities/chat/stream/strategies.ts +3 -3
  81. package/src/activities/chat/tools/schema-converter.ts +98 -58
  82. package/src/activities/chat/tools/tool-calls.ts +18 -11
  83. package/src/activities/chat/tools/tool-definition.ts +6 -1
  84. package/src/activities/generateAudio/index.ts +5 -4
  85. package/src/activities/generateImage/index.ts +8 -3
  86. package/src/activities/stream-generation-result.ts +11 -2
  87. package/src/activities/summarize/chat-stream-summarize.ts +5 -2
  88. package/src/activities/summarize/index.ts +2 -2
  89. package/src/extend-adapter.ts +1 -1
  90. package/src/index.ts +6 -1
  91. package/src/middlewares/content-guard.ts +12 -4
  92. package/src/middlewares/otel.ts +32 -24
  93. package/src/realtime/index.ts +1 -1
  94. package/src/strip-to-spec-middleware.ts +1 -4
  95. package/src/tools/provider-tool.ts +14 -0
  96. package/src/types.ts +12 -8
  97. package/src/utilities/ag-ui-wire.ts +4 -3
  98. package/src/utilities/chat-params.ts +2 -2
@@ -114,7 +114,7 @@ export function convertMessagesToModelMessages(
114
114
  modelMessages.push({
115
115
  role: 'system' as ModelMessage['role'],
116
116
  content: (msg as { content: string }).content,
117
- } as ModelMessage)
117
+ })
118
118
  continue
119
119
  }
120
120
 
@@ -463,12 +463,13 @@ export function modelMessagesToUIMessages(
463
463
  if (msg.role === 'tool') {
464
464
  // Tool result - merge into the last assistant message if possible
465
465
  if (
466
+ msg.toolCallId !== undefined &&
466
467
  currentAssistantMessage &&
467
468
  currentAssistantMessage.role === 'assistant'
468
469
  ) {
469
470
  currentAssistantMessage.parts.push({
470
471
  type: 'tool-result',
471
- toolCallId: msg.toolCallId!,
472
+ toolCallId: msg.toolCallId,
472
473
  content: getTextContent(msg.content),
473
474
  state: 'complete',
474
475
  })
@@ -11,6 +11,7 @@ import type {
11
11
  ErrorInfo,
12
12
  FinishInfo,
13
13
  IterationInfo,
14
+ StructuredOutputMiddlewareConfig,
14
15
  ToolCallHookContext,
15
16
  ToolPhaseCompleteInfo,
16
17
  UsageInfo,
@@ -71,7 +72,7 @@ export class MiddlewareRunner {
71
72
  current = { ...current, ...result }
72
73
  if (!skip) {
73
74
  this.logger.config(
74
- `middleware=${mw.name ?? 'unnamed'} keys=${Object.keys(result as object).join(',')}`,
75
+ `middleware=${mw.name ?? 'unnamed'} keys=${Object.keys(result).join(',')}`,
75
76
  {
76
77
  middleware: mw.name ?? 'unnamed',
77
78
  changes: result,
@@ -94,7 +95,66 @@ export class MiddlewareRunner {
94
95
  ...base,
95
96
  middlewareName: mw.name || 'unnamed',
96
97
  iteration: ctx.iteration,
97
- changes: result as Record<string, unknown>,
98
+ changes: result,
99
+ })
100
+ }
101
+ }
102
+ }
103
+ }
104
+ return current
105
+ }
106
+
107
+ /**
108
+ * Pipe config through all middleware onStructuredOutputConfig hooks in order.
109
+ * Each middleware receives the merged config from previous middleware.
110
+ * Partial returns are shallow-merged with the current config.
111
+ *
112
+ * Called once at the structured-output boundary, before runOnConfig at the
113
+ * same boundary (which receives a ChatMiddlewareConfig view, no outputSchema).
114
+ */
115
+ async runOnStructuredOutputConfig(
116
+ ctx: ChatMiddlewareContext,
117
+ config: StructuredOutputMiddlewareConfig,
118
+ ): Promise<StructuredOutputMiddlewareConfig> {
119
+ let current = config
120
+ for (const mw of this.middlewares) {
121
+ if (mw.onStructuredOutputConfig) {
122
+ const skip = shouldSkipInstrumentation(mw)
123
+ const start = Date.now()
124
+ const result = await mw.onStructuredOutputConfig(ctx, current)
125
+ const hasTransform = result !== undefined && result !== null
126
+ if (hasTransform) {
127
+ current = { ...current, ...result }
128
+ if (!skip) {
129
+ this.logger.config(
130
+ `middleware=${mw.name ?? 'unnamed'} keys=${Object.keys(result).join(',')}`,
131
+ {
132
+ middleware: mw.name ?? 'unnamed',
133
+ changes: result,
134
+ },
135
+ )
136
+ }
137
+ }
138
+ if (!skip) {
139
+ const base = instrumentCtx(ctx)
140
+ aiEventClient.emit('middleware:hook:executed', {
141
+ ...base,
142
+ middlewareName: mw.name || 'unnamed',
143
+ hookName: 'onStructuredOutputConfig',
144
+ iteration: ctx.iteration,
145
+ duration: Date.now() - start,
146
+ hasTransform,
147
+ })
148
+ if (hasTransform) {
149
+ aiEventClient.emit('middleware:config:transformed', {
150
+ ...base,
151
+ middlewareName: mw.name || 'unnamed',
152
+ iteration: ctx.iteration,
153
+ // `result` is `Partial<StructuredOutputMiddlewareConfig>` —
154
+ // Object.fromEntries(Object.entries(result)) yields the
155
+ // structural `Record<string, unknown>` the event emitter wants
156
+ // without an `as` cast.
157
+ changes: Object.fromEntries(Object.entries(result)),
98
158
  })
99
159
  }
100
160
  }
@@ -152,7 +212,7 @@ export class MiddlewareRunner {
152
212
  const nextChunks: Array<StreamChunk> = []
153
213
  for (const c of chunks) {
154
214
  // Cast: @ag-ui/core Zod passthrough types prevent direct `.type` access
155
- const chunkType = (c as StreamChunk & { type: string }).type
215
+ const chunkType = c.type
156
216
  if (!skip) {
157
217
  this.logger.middleware(
158
218
  `hook=onChunk middleware=${mw.name ?? 'unnamed'} in=${chunkType}`,
@@ -188,7 +248,7 @@ export class MiddlewareRunner {
188
248
  nextChunks.push(...result)
189
249
  if (!skip) {
190
250
  this.logger.middleware(
191
- `hook=onChunk middleware=${mw.name ?? 'unnamed'} in=${chunkType} out=[${result.map((r: StreamChunk) => (r as StreamChunk & { type: string }).type).join(',')}]`,
251
+ `hook=onChunk middleware=${mw.name ?? 'unnamed'} in=${chunkType} out=[${result.map((r: StreamChunk) => r.type).join(',')}]`,
192
252
  {
193
253
  middleware: mw.name ?? 'unnamed',
194
254
  hook: 'onChunk',
@@ -209,7 +269,7 @@ export class MiddlewareRunner {
209
269
  nextChunks.push(result)
210
270
  if (!skip) {
211
271
  this.logger.middleware(
212
- `hook=onChunk middleware=${mw.name ?? 'unnamed'} in=${chunkType} out=${(result as StreamChunk & { type: string }).type}`,
272
+ `hook=onChunk middleware=${mw.name ?? 'unnamed'} in=${chunkType} out=${result.type}`,
213
273
  {
214
274
  middleware: mw.name ?? 'unnamed',
215
275
  hook: 'onChunk',
@@ -3,6 +3,7 @@ export type {
3
3
  ChatMiddlewareContext,
4
4
  ChatMiddlewarePhase,
5
5
  ChatMiddlewareConfig,
6
+ StructuredOutputMiddlewareConfig,
6
7
  ToolCallHookContext,
7
8
  BeforeToolCallDecision,
8
9
  AfterToolCallInfo,
@@ -1,4 +1,10 @@
1
- import type { ModelMessage, StreamChunk, Tool, ToolCall } from '../../../types'
1
+ import type {
2
+ JSONSchema,
3
+ ModelMessage,
4
+ StreamChunk,
5
+ Tool,
6
+ ToolCall,
7
+ } from '../../../types'
2
8
  import type { SystemPrompt } from '../../../system-prompts'
3
9
 
4
10
  // ===========================
@@ -12,6 +18,8 @@ import type { SystemPrompt } from '../../../system-prompts'
12
18
  * - 'modelStream': During model streaming
13
19
  * - 'beforeTools': Before tool execution phase
14
20
  * - 'afterTools': After tool execution phase
21
+ * - 'structuredOutput': During the final structured-output adapter call (set
22
+ * for chunks from adapter.structuredOutputStream or the synthesized fallback)
15
23
  */
16
24
  export type ChatMiddlewarePhase =
17
25
  | 'init'
@@ -19,6 +27,7 @@ export type ChatMiddlewarePhase =
19
27
  | 'modelStream'
20
28
  | 'beforeTools'
21
29
  | 'afterTools'
30
+ | 'structuredOutput'
22
31
 
23
32
  /**
24
33
  * Stable context object passed to all middleware hooks.
@@ -79,9 +88,9 @@ export interface ChatMiddlewareContext {
79
88
  /** Names of configured tools, if any */
80
89
  toolNames?: Array<string>
81
90
  /** Flattened generation options (temperature, topP, maxTokens, metadata) */
82
- options?: Record<string, unknown>
91
+ options?: Record<string, unknown> | undefined
83
92
  /** Provider-specific model options */
84
- modelOptions?: Record<string, unknown>
93
+ modelOptions?: Record<string, unknown> | undefined
85
94
 
86
95
  // --- Computed info ---
87
96
 
@@ -121,8 +130,26 @@ export interface ChatMiddlewareConfig {
121
130
  temperature?: number
122
131
  topP?: number
123
132
  maxTokens?: number
124
- metadata?: Record<string, unknown>
125
- modelOptions?: Record<string, unknown>
133
+ metadata?: Record<string, unknown> | undefined
134
+ modelOptions?: Record<string, unknown> | undefined
135
+ }
136
+
137
+ /**
138
+ * Config passed to onStructuredOutputConfig.
139
+ *
140
+ * Mirrors ChatMiddlewareConfig minus `tools` (the final structured-output call
141
+ * is a single typed-response request, not an agentic loop — tools cannot be
142
+ * forwarded to it), plus the `outputSchema` being sent to the provider.
143
+ * Middleware may transform the schema (e.g., inject $defs, strip
144
+ * vendor-incompatible keywords) by returning a partial that includes
145
+ * `outputSchema`.
146
+ */
147
+ export interface StructuredOutputMiddlewareConfig extends Omit<
148
+ ChatMiddlewareConfig,
149
+ 'tools'
150
+ > {
151
+ /** JSON Schema being sent to the provider for structured output. */
152
+ outputSchema: JSONSchema
126
153
  }
127
154
 
128
155
  // ===========================
@@ -257,11 +284,13 @@ export interface FinishInfo {
257
284
  /** Final accumulated text content */
258
285
  content: string
259
286
  /** Final usage totals, if available */
260
- usage?: {
261
- promptTokens: number
262
- completionTokens: number
263
- totalTokens: number
264
- }
287
+ usage?:
288
+ | {
289
+ promptTokens: number
290
+ completionTokens: number
291
+ totalTokens: number
292
+ }
293
+ | undefined
265
294
  }
266
295
 
267
296
  /**
@@ -335,7 +364,31 @@ export interface ChatMiddleware {
335
364
  | void
336
365
  | null
337
366
  | Partial<ChatMiddlewareConfig>
338
- | Promise<void | Partial<ChatMiddlewareConfig>>
367
+ | Promise<void | null | Partial<ChatMiddlewareConfig>>
368
+
369
+ /**
370
+ * Called at the start of the final structured-output call (when the chat
371
+ * was invoked with outputSchema). Pipes through middleware in order, like
372
+ * onConfig, but with access to the JSON Schema being sent to the provider.
373
+ *
374
+ * Return a partial to shallow-merge into the current config, or void to
375
+ * pass through.
376
+ *
377
+ * Fires BEFORE onConfig at the structured-output boundary. onConfig also
378
+ * re-fires at the same boundary with ctx.phase === 'structuredOutput',
379
+ * receiving the post-onStructuredOutputConfig view of the config (minus
380
+ * outputSchema). Use onConfig for general-purpose transforms that apply
381
+ * to every adapter call; use this hook when you need to transform the
382
+ * outputSchema or apply structured-output-specific behavior.
383
+ */
384
+ onStructuredOutputConfig?: (
385
+ ctx: ChatMiddlewareContext,
386
+ config: StructuredOutputMiddlewareConfig,
387
+ ) =>
388
+ | void
389
+ | null
390
+ | Partial<StructuredOutputMiddlewareConfig>
391
+ | Promise<void | null | Partial<StructuredOutputMiddlewareConfig>>
339
392
 
340
393
  /**
341
394
  * Called when the chat run starts (after initial onConfig).
@@ -133,25 +133,25 @@ export interface StreamProcessorOptions {
133
133
  * @see docs/chat-architecture.md#adapter-contract — What this class expects from adapters
134
134
  */
135
135
  export class StreamProcessor {
136
- private chunkStrategy: ChunkStrategy
137
- private events: StreamProcessorEvents
138
- private jsonParser: { parse: (jsonString: string) => any }
136
+ private readonly chunkStrategy: ChunkStrategy
137
+ private readonly events: StreamProcessorEvents
138
+ private readonly jsonParser: { parse: (jsonString: string) => any }
139
139
  private recordingEnabled: boolean
140
140
 
141
141
  // Message state
142
142
  private messages: Array<UIMessage> = []
143
143
 
144
144
  // Per-message stream state
145
- private messageStates: Map<string, MessageStreamState> = new Map()
146
- private activeMessageIds: Set<string> = new Set()
147
- private toolCallToMessage: Map<string, string> = new Map()
145
+ private readonly messageStates: Map<string, MessageStreamState> = new Map()
146
+ private readonly activeMessageIds: Set<string> = new Set()
147
+ private readonly toolCallToMessage: Map<string, string> = new Map()
148
148
  private pendingManualMessageId: string | null = null
149
149
  private pendingThinkingStepId: string | null = null
150
150
 
151
- private structuredMessageIds: Set<string> = new Set()
151
+ private readonly structuredMessageIds: Set<string> = new Set()
152
152
 
153
153
  // Run tracking (for concurrent run safety)
154
- private activeRuns = new Set<string>()
154
+ private readonly activeRuns = new Set<string>()
155
155
 
156
156
  // Shared stream state
157
157
  private finishReason: string | null = null
@@ -216,7 +216,7 @@ export class StreamProcessor {
216
216
  ? [{ type: 'text', content }]
217
217
  : content.map((part) => {
218
218
  // ContentPart types (text, image, audio, video, document) are compatible with MessagePart
219
- return part as MessagePart
219
+ return part
220
220
  })
221
221
 
222
222
  const userMessage: UIMessage = {
@@ -479,7 +479,8 @@ export class StreamProcessor {
479
479
 
480
480
  // Cast needed: @ag-ui/core Zod passthrough types add `& { [k: string]: unknown }`
481
481
  // which prevents TypeScript from narrowing the `type` discriminant in switch.
482
- const c = chunk as StreamChunk & { type: string }
482
+ const c = chunk
483
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- AG-UI EventType enum members vs string-literal case labels; default branch handles untraced events.
483
484
  switch (c.type) {
484
485
  // AG-UI Events
485
486
  case 'TEXT_MESSAGE_START':
@@ -645,10 +646,9 @@ export class StreamProcessor {
645
646
  * Used as fallback for events that don't include a messageId.
646
647
  */
647
648
  private getActiveAssistantMessageId(): string | null {
648
- // Set iteration is insertion-order; convert to array and search from the end
649
- const ids = Array.from(this.activeMessageIds)
650
- for (let i = ids.length - 1; i >= 0; i--) {
651
- const id = ids[i]!
649
+ // Set iteration is insertion-order; reverse-iterate to search from the end
650
+ const ids = Array.from(this.activeMessageIds).reverse()
651
+ for (const id of ids) {
652
652
  const state = this.messageStates.get(id)
653
653
  if (state && state.role === 'assistant') {
654
654
  return id
@@ -679,8 +679,8 @@ export class StreamProcessor {
679
679
  // Try active assistant message
680
680
  const activeId = this.getActiveAssistantMessageId()
681
681
  if (activeId) {
682
- const state = this.getMessageState(activeId)!
683
- return { messageId: activeId, state }
682
+ const state = this.getMessageState(activeId)
683
+ if (state) return { messageId: activeId, state }
684
684
  }
685
685
 
686
686
  // Check if a message with preferredId already exists (reconnect/resume case).
@@ -781,10 +781,10 @@ export class StreamProcessor {
781
781
  const existingMsg = this.messages.find((m) => m.id === messageId)
782
782
  if (existingMsg) {
783
783
  this.activeMessageIds.add(messageId)
784
- if (!this.messageStates.has(messageId)) {
784
+ const existingState = this.messageStates.get(messageId)
785
+ if (!existingState) {
785
786
  this.createMessageState(messageId, uiRole)
786
787
  } else {
787
- const existingState = this.messageStates.get(messageId)!
788
788
  // If tool calls happened since last text, this TEXT_MESSAGE_START
789
789
  // signals a new text segment — reset segment accumulation
790
790
  if (existingState.hasToolCallsSinceTextStart) {
@@ -845,7 +845,7 @@ export class StreamProcessor {
845
845
  ): void {
846
846
  this.resetStreamState()
847
847
  // AG-UI Message[] is compatible with UIMessage[] at runtime
848
- this.messages = [...chunk.messages] as unknown as Array<UIMessage>
848
+ this.messages = [...chunk.messages] as Array<UIMessage>
849
849
  this.emitMessagesChange()
850
850
  }
851
851
 
@@ -1365,7 +1365,7 @@ export class StreamProcessor {
1365
1365
  state.currentThinkingStepId = stepId
1366
1366
  }
1367
1367
 
1368
- const previous = state.thinkingSteps.get(stepId)!
1368
+ const previous = state.thinkingSteps.get(stepId) ?? ''
1369
1369
  let nextThinking = previous
1370
1370
 
1371
1371
  // Prefer delta over content
@@ -1900,7 +1900,7 @@ export function createReplayStream(
1900
1900
  recording: ChunkRecording,
1901
1901
  ): AsyncIterable<StreamChunk> {
1902
1902
  return {
1903
- // eslint-disable-next-line @typescript-eslint/require-await
1903
+ // eslint-disable-next-line @typescript-eslint/require-await -- async generator required by AsyncIterable contract; body has no await
1904
1904
  async *[Symbol.asyncIterator]() {
1905
1905
  for (const { chunk } of recording.chunks) {
1906
1906
  yield chunk
@@ -20,7 +20,7 @@ export class ImmediateStrategy implements ChunkStrategy {
20
20
  * Useful for natural text flow in UI
21
21
  */
22
22
  export class PunctuationStrategy implements ChunkStrategy {
23
- private punctuation = /[.,!?;:\n]/
23
+ private readonly punctuation = /[.,!?;:\n]/
24
24
 
25
25
  shouldEmit(chunk: string, _accumulated: string): boolean {
26
26
  return this.punctuation.test(chunk)
@@ -34,7 +34,7 @@ export class PunctuationStrategy implements ChunkStrategy {
34
34
  export class BatchStrategy implements ChunkStrategy {
35
35
  private chunkCount = 0
36
36
 
37
- constructor(private batchSize: number = 5) {}
37
+ constructor(private readonly batchSize: number = 5) {}
38
38
 
39
39
  shouldEmit(_chunk: string, _accumulated: string): boolean {
40
40
  this.chunkCount++
@@ -66,7 +66,7 @@ export class WordBoundaryStrategy implements ChunkStrategy {
66
66
  * Emits if ANY strategy says to emit
67
67
  */
68
68
  export class CompositeStrategy implements ChunkStrategy {
69
- constructor(private strategies: Array<ChunkStrategy>) {}
69
+ constructor(private readonly strategies: Array<ChunkStrategy>) {}
70
70
 
71
71
  shouldEmit(chunk: string, accumulated: string): boolean {
72
72
  return this.strategies.some((s) => s.shouldEmit(chunk, accumulated))
@@ -1,11 +1,29 @@
1
- /* eslint-disable @typescript-eslint/no-unnecessary-condition */
2
-
3
1
  import type {
4
2
  StandardJSONSchemaV1,
5
3
  StandardSchemaV1,
6
4
  } from '@standard-schema/spec'
7
5
  import type { JSONSchema, SchemaInput } from '../../../types'
8
6
 
7
+ /**
8
+ * Build a JSONSchema object from any plain key/value source. The `JSONSchema`
9
+ * interface's `[key: string]: any` index signature makes every property
10
+ * assignable through bracket access without a type cast — copying keys here
11
+ * lets us narrow either `Record<string, unknown>` (returned by
12
+ * `~standard.jsonSchema.input()`) or a `JSONSchema` (from the SchemaInput
13
+ * pass-through arm) into the typed view used by the rest of this module.
14
+ *
15
+ * Accepts `object` so callers don't need a cast when narrowing from union
16
+ * types like `SchemaInput`.
17
+ */
18
+ function toJsonSchema(obj: object): JSONSchema {
19
+ const result: JSONSchema = {}
20
+ for (const [key, value] of Object.entries(obj)) {
21
+ if (key === '$schema') continue // not needed by LLM providers
22
+ result[key] = value
23
+ }
24
+ return result
25
+ }
26
+
9
27
  /**
10
28
  * Check if a value is a Standard JSON Schema compliant schema.
11
29
  * Standard JSON Schema compliant libraries (Zod v4+, ArkType, Valibot with toStandardJsonSchema, etc.)
@@ -19,6 +37,7 @@ export function isStandardJSONSchema(
19
37
  schema !== null &&
20
38
  '~standard' in schema &&
21
39
  typeof (schema as StandardJSONSchemaV1)['~standard'] === 'object' &&
40
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for caller-provided unknown; type assertion narrows but doesn't validate the wire payload
22
41
  (schema as StandardJSONSchemaV1)['~standard'].version === 1 &&
23
42
  typeof (schema as StandardJSONSchemaV1)['~standard'].jsonSchema ===
24
43
  'object' &&
@@ -37,7 +56,6 @@ export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 {
37
56
  schema !== null &&
38
57
  '~standard' in schema &&
39
58
  typeof schema['~standard'] === 'object' &&
40
- schema !== null &&
41
59
  schema['~standard'] !== null &&
42
60
  'version' in schema['~standard'] &&
43
61
  schema['~standard'].version === 1 &&
@@ -58,19 +76,20 @@ export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 {
58
76
  * @returns Transformed schema compatible with OpenAI structured output
59
77
  */
60
78
  function makeStructuredOutputCompatible(
61
- schema: Record<string, any>,
79
+ schema: JSONSchema,
62
80
  originalRequired: Array<string> = [],
63
- ): Record<string, any> {
64
- const result = { ...schema }
81
+ ): JSONSchema {
82
+ const result: JSONSchema = { ...schema }
65
83
 
66
84
  // Handle object types
67
85
  if (result.type === 'object' && result.properties) {
68
- const properties = { ...result.properties }
86
+ const properties: Record<string, JSONSchema> = { ...result.properties }
69
87
  const allPropertyNames = Object.keys(properties)
70
88
 
71
89
  // Transform each property
72
90
  for (const propName of allPropertyNames) {
73
91
  const prop = properties[propName]
92
+ if (!prop) continue
74
93
  const wasOptional = !originalRequired.includes(propName)
75
94
 
76
95
  // Recursively transform nested objects/arrays
@@ -83,12 +102,12 @@ function makeStructuredOutputCompatible(
83
102
  ? { ...transformed, type: ['object', 'null'] }
84
103
  : transformed
85
104
  } else if (prop.type === 'array' && prop.items) {
86
- const transformed = {
105
+ const items = Array.isArray(prop.items) ? prop.items[0] : prop.items
106
+ const transformed: JSONSchema = {
87
107
  ...prop,
88
- items: makeStructuredOutputCompatible(
89
- prop.items,
90
- prop.items.required || [],
91
- ),
108
+ items: items
109
+ ? makeStructuredOutputCompatible(items, items.required || [])
110
+ : prop.items,
92
111
  }
93
112
  properties[propName] = wasOptional
94
113
  ? { ...transformed, type: ['array', 'null'] }
@@ -118,10 +137,10 @@ function makeStructuredOutputCompatible(
118
137
 
119
138
  // Handle array types with object items
120
139
  if (result.type === 'array' && result.items) {
121
- result.items = makeStructuredOutputCompatible(
122
- result.items,
123
- result.items.required || [],
124
- )
140
+ const items = Array.isArray(result.items) ? result.items[0] : result.items
141
+ if (items) {
142
+ result.items = makeStructuredOutputCompatible(items, items.required || [])
143
+ }
125
144
  }
126
145
 
127
146
  return result
@@ -216,42 +235,33 @@ export function convertSchemaToJsonSchema(
216
235
  target: 'draft-07',
217
236
  })
218
237
 
219
- let result = jsonSchema
220
-
221
- if (typeof result === 'object' && '$schema' in result) {
222
- // Remove $schema property as it's not needed for LLM providers
223
- const { $schema, ...rest } = result
224
- result = rest
225
- }
238
+ // Rebuild structurally so the typed JSONSchema view is acquired without
239
+ // a `Record<string, unknown> as JSONSchema` cast; `toJsonSchema()` also
240
+ // drops the `$schema` key which LLM providers don't need.
241
+ let result: JSONSchema = toJsonSchema(jsonSchema)
226
242
 
227
243
  // Ensure object schemas always have type: "object"
244
+ // If it has properties (even empty), it should be an object type
245
+ if ('properties' in result && !result.type) {
246
+ result.type = 'object'
247
+ }
228
248
 
229
- if (typeof result === 'object') {
230
- // If it has properties (even empty), it should be an object type
231
- if ('properties' in result && !result.type) {
232
- result.type = 'object'
233
- }
234
-
235
- // Ensure properties exists for object types (even if empty)
236
- if (result.type === 'object' && !('properties' in result)) {
237
- result.properties = {}
238
- }
249
+ // Ensure properties exists for object types (even if empty)
250
+ if (result.type === 'object' && !('properties' in result)) {
251
+ result.properties = {}
252
+ }
239
253
 
240
- // Ensure required exists for object types (even if empty array)
241
- if (result.type === 'object' && !('required' in result)) {
242
- result.required = []
243
- }
254
+ // Ensure required exists for object types (even if empty array)
255
+ if (result.type === 'object' && !('required' in result)) {
256
+ result.required = []
257
+ }
244
258
 
245
- // Apply structured output transformation if requested
246
- if (forStructuredOutput) {
247
- result = makeStructuredOutputCompatible(
248
- result,
249
- (result.required as Array<string>) || [],
250
- )
251
- }
259
+ // Apply structured output transformation if requested
260
+ if (forStructuredOutput) {
261
+ result = makeStructuredOutputCompatible(result, result.required || [])
252
262
  }
253
263
 
254
- return result as JSONSchema
264
+ return result
255
265
  }
256
266
 
257
267
  // Detect Standard Schema validators (Zod, ArkType, Valibot, …) that don't
@@ -271,14 +281,25 @@ export function convertSchemaToJsonSchema(
271
281
  // If it's not a Standard JSON Schema, assume it's already a JSONSchema and pass through
272
282
  // Still apply structured output transformation if requested
273
283
 
274
- if (forStructuredOutput && typeof schema === 'object') {
275
- return makeStructuredOutputCompatible(
276
- schema as Record<string, any>,
277
- ((schema as JSONSchema).required as Array<string>) || [],
278
- ) as JSONSchema
284
+ // At this branch, `schema` is the plain `JSONSchema` arm of `SchemaInput`
285
+ // (the two `~standard` arms were handled above). When no transformation
286
+ // is requested we pass the schema through by reference to preserve
287
+ // identity for callers that compare via `===`.
288
+ if (typeof schema !== 'object') {
289
+ // The SchemaInput union is object-shaped on every arm; if we ever hit a
290
+ // non-object here, propagate it untouched and let the downstream
291
+ // provider error loudly rather than silently widen.
292
+ return schema
293
+ }
294
+
295
+ if (forStructuredOutput) {
296
+ // Build a typed view structurally so we don't need a SchemaInput→JSONSchema
297
+ // cast on the transformation path.
298
+ const typedView = toJsonSchema(schema)
299
+ return makeStructuredOutputCompatible(typedView, typedView.required || [])
279
300
  }
280
301
 
281
- return schema as JSONSchema
302
+ return schema
282
303
  }
283
304
 
284
305
  /**
@@ -293,7 +314,10 @@ export async function validateWithStandardSchema<T>(
293
314
  data: unknown,
294
315
  ): Promise<
295
316
  | { success: true; data: T }
296
- | { success: false; issues: Array<{ message: string; path?: Array<string> }> }
317
+ | {
318
+ success: false
319
+ issues: Array<{ message: string; path?: Array<string> | undefined }>
320
+ }
297
321
  > {
298
322
  if (!isStandardSchema(schema)) {
299
323
  // If it's not a Standard Schema, just return the data as-is
@@ -315,6 +339,25 @@ export async function validateWithStandardSchema<T>(
315
339
  }
316
340
  }
317
341
 
342
+ /**
343
+ * Error thrown when Standard Schema validation fails. Carries the original
344
+ * `issues` array so consumers (middleware `onError`, callers catching from
345
+ * `chat({ outputSchema })`) can programmatically inspect each failure.
346
+ */
347
+ export class StandardSchemaValidationError extends Error {
348
+ override readonly name = 'StandardSchemaValidationError'
349
+ readonly issues: ReadonlyArray<StandardSchemaV1.Issue>
350
+
351
+ constructor(issues: ReadonlyArray<StandardSchemaV1.Issue>) {
352
+ super(
353
+ `Validation failed: ${issues
354
+ .map((i) => i.message || 'Validation failed')
355
+ .join(', ')}`,
356
+ )
357
+ this.issues = issues
358
+ }
359
+ }
360
+
318
361
  /**
319
362
  * Synchronously validates data against a Standard Schema compliant schema.
320
363
  * Note: Some Standard Schema implementations may only support async validation.
@@ -323,7 +366,8 @@ export async function validateWithStandardSchema<T>(
323
366
  * @param schema - Standard Schema compliant schema
324
367
  * @param data - Data to validate
325
368
  * @returns Parsed/validated data
326
- * @throws Error if validation fails or if the schema only supports async validation
369
+ * @throws StandardSchemaValidationError if validation fails; Error if the
370
+ * schema only supports async validation.
327
371
  */
328
372
  export function parseWithStandardSchema<T>(schema: unknown, data: unknown): T {
329
373
  if (!isStandardSchema(schema)) {
@@ -344,9 +388,5 @@ export function parseWithStandardSchema<T>(schema: unknown, data: unknown): T {
344
388
  return result.value as T
345
389
  }
346
390
 
347
- // invalid validation, throw error with all issues
348
- const errorMessages = result.issues
349
- .map((issue) => issue.message || 'Validation failed')
350
- .join(', ')
351
- throw new Error(`Validation failed: ${errorMessages}`)
391
+ throw new StandardSchemaValidationError(result.issues)
352
392
  }