@tanstack/openai-base 0.10.0 → 0.10.2

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 (29) hide show
  1. package/README.md +2 -2
  2. package/dist/esm/adapters/chat-completions-text.d.ts +18 -7
  3. package/dist/esm/adapters/chat-completions-text.js +86 -89
  4. package/dist/esm/adapters/chat-completions-text.js.map +1 -1
  5. package/dist/esm/adapters/chat-completions-tool-converter.test.d.ts +1 -0
  6. package/dist/esm/adapters/responses-text.d.ts +9 -1
  7. package/dist/esm/adapters/responses-text.js +41 -6
  8. package/dist/esm/adapters/responses-text.js.map +1 -1
  9. package/dist/esm/adapters/responses-tool-converter.test.d.ts +1 -0
  10. package/dist/esm/index.d.ts +1 -1
  11. package/dist/esm/index.js +2 -2
  12. package/dist/esm/tools/function-tool.test.d.ts +1 -0
  13. package/dist/esm/utils/schema-converter.d.ts +14 -0
  14. package/dist/esm/utils/schema-converter.js +106 -18
  15. package/dist/esm/utils/schema-converter.js.map +1 -1
  16. package/dist/esm/utils/tool-input-normalizer.d.ts +12 -0
  17. package/dist/esm/utils/tool-input-normalizer.js +36 -0
  18. package/dist/esm/utils/tool-input-normalizer.js.map +1 -0
  19. package/dist/esm/utils/tool-input-normalizer.test.d.ts +1 -0
  20. package/package.json +3 -3
  21. package/src/adapters/chat-completions-text.ts +128 -120
  22. package/src/adapters/chat-completions-tool-converter.test.ts +64 -0
  23. package/src/adapters/responses-text.ts +81 -8
  24. package/src/adapters/responses-tool-converter.test.ts +69 -0
  25. package/src/index.ts +4 -1
  26. package/src/tools/function-tool.test.ts +62 -0
  27. package/src/utils/schema-converter.ts +165 -20
  28. package/src/utils/tool-input-normalizer.test.ts +146 -0
  29. package/src/utils/tool-input-normalizer.ts +55 -0
@@ -6,7 +6,9 @@ import {
6
6
  } from '@tanstack/ai/adapter-internals'
7
7
  import { generateId } from '@tanstack/ai-utils'
8
8
  import { extractRequestOptions } from '../utils/request-options'
9
- import { makeStructuredOutputCompatible } from '../utils/schema-converter'
9
+ import { makeStructuredOutputCompatibleWithMap } from '../utils/schema-converter'
10
+ import { createToolInputNormalizer } from '../utils/tool-input-normalizer'
11
+ import type { StructuredOutputCompatibility } from '../utils/schema-converter'
10
12
  import { buildChatCompletionsUsage } from '../usage'
11
13
  import { convertToolsToChatCompletionsFormat } from './chat-completions-tool-converter'
12
14
  import type OpenAI from 'openai'
@@ -30,6 +32,13 @@ import type {
30
32
  TextOptions,
31
33
  } from '@tanstack/ai'
32
34
 
35
+ type ChatStreamState = {
36
+ runId: string
37
+ threadId: string
38
+ messageId: string
39
+ hasEmittedRunStarted: boolean
40
+ }
41
+
33
42
  /**
34
43
  * Shared implementation of the OpenAI Chat Completions API. Holds the
35
44
  * stream-accumulator + AG-UI lifecycle logic and calls the OpenAI SDK
@@ -94,98 +103,99 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
94
103
 
95
104
  yield* this.processStreamChunks(stream, options, aguiState)
96
105
  } catch (error: unknown) {
97
- // Narrow before logging: raw SDK errors can carry request metadata
98
- // (including auth headers) which we must never surface to user loggers.
99
- const errorPayload = toRunErrorPayload(
100
- error,
101
- `${this.name}.chatStream failed`,
102
- )
103
- const rawEvent = toRunErrorRawEvent(error)
106
+ yield* this.handleChatStreamError(error, options, aguiState, 'chatStream')
107
+ }
108
+ }
104
109
 
105
- // Emit RUN_STARTED if not yet emitted
106
- if (!aguiState.hasEmittedRunStarted) {
107
- aguiState.hasEmittedRunStarted = true
108
- yield {
109
- type: EventType.RUN_STARTED,
110
- runId: aguiState.runId,
111
- threadId: aguiState.threadId,
112
- model: options.model,
113
- timestamp: Date.now(),
114
- parentRunId: options.parentRunId,
115
- }
116
- }
110
+ private async *handleChatStreamError(
111
+ error: unknown,
112
+ options: TextOptions,
113
+ aguiState: ChatStreamState,
114
+ source: 'chatStream' | 'processStreamChunks',
115
+ ): AsyncIterable<StreamChunk> {
116
+ // Narrow before logging: raw SDK errors can carry request metadata
117
+ // (including auth headers) which we must never surface to user loggers.
118
+ const errorPayload = toRunErrorPayload(
119
+ error,
120
+ `${this.name}.${source} failed`,
121
+ )
122
+ const rawEvent = toRunErrorRawEvent(error)
117
123
 
118
- const rejectedToolCall = this.extractRejectedToolCall(
119
- rawEvent,
120
- errorPayload.message,
121
- )
122
- if (rejectedToolCall) {
123
- const toolCallId = generateId(this.name)
124
- yield {
125
- type: EventType.TOOL_CALL_START,
126
- toolCallId,
127
- toolCallName: rejectedToolCall.toolName,
128
- toolName: rejectedToolCall.toolName,
129
- parentMessageId: aguiState.messageId,
130
- model: options.model,
131
- timestamp: Date.now(),
132
- }
133
- yield {
134
- type: EventType.TOOL_CALL_ARGS,
135
- toolCallId,
136
- delta: rejectedToolCall.arguments,
137
- args: rejectedToolCall.arguments,
138
- model: options.model,
139
- timestamp: Date.now(),
140
- }
141
- yield {
142
- type: EventType.TOOL_CALL_END,
143
- toolCallId,
144
- toolCallName: rejectedToolCall.toolName,
145
- toolName: rejectedToolCall.toolName,
146
- ...(rejectedToolCall.input !== undefined && {
147
- input: rejectedToolCall.input,
148
- }),
149
- result: JSON.stringify({ error: rejectedToolCall.error }),
150
- state: 'output-error',
151
- model: options.model,
152
- timestamp: Date.now(),
153
- }
154
- yield {
155
- type: EventType.RUN_FINISHED,
156
- runId: aguiState.runId,
157
- threadId: aguiState.threadId,
158
- model: options.model,
159
- timestamp: Date.now(),
160
- finishReason: 'tool_calls',
161
- }
162
- return
124
+ if (!aguiState.hasEmittedRunStarted) {
125
+ aguiState.hasEmittedRunStarted = true
126
+ yield {
127
+ type: EventType.RUN_STARTED,
128
+ runId: aguiState.runId,
129
+ threadId: aguiState.threadId,
130
+ model: options.model,
131
+ timestamp: Date.now(),
132
+ parentRunId: options.parentRunId,
163
133
  }
134
+ }
164
135
 
165
- // Emit AG-UI RUN_ERROR. Conditional `code` spread keeps the wire
166
- // shape spec-compliant under `exactOptionalPropertyTypes`: AG-UI's
167
- // `RunErrorEvent.code` is `string?` (absent vs explicit `undefined`
168
- // matter), so we omit the key when there's no code.
136
+ const rejectedToolCall = this.extractRejectedToolCall(
137
+ rawEvent,
138
+ errorPayload.message,
139
+ )
140
+ if (rejectedToolCall) {
141
+ const toolCallId = generateId(this.name)
169
142
  yield {
170
- type: EventType.RUN_ERROR,
143
+ type: EventType.TOOL_CALL_START,
144
+ toolCallId,
145
+ toolCallName: rejectedToolCall.toolName,
146
+ toolName: rejectedToolCall.toolName,
147
+ parentMessageId: aguiState.messageId,
171
148
  model: options.model,
172
149
  timestamp: Date.now(),
173
- message: errorPayload.message,
174
- code: errorPayload.code,
175
- // Forward the provider's structured error body so consumers can recover
176
- // the upstream detail the `{ message, code }` payload drops. Omitted
177
- // when the error carried no provider body (see toRunErrorRawEvent).
178
- ...(rawEvent !== undefined && { rawEvent }),
179
- error: {
180
- message: errorPayload.message,
181
- code: errorPayload.code,
182
- },
183
150
  }
151
+ yield {
152
+ type: EventType.TOOL_CALL_ARGS,
153
+ toolCallId,
154
+ delta: rejectedToolCall.arguments,
155
+ args: rejectedToolCall.arguments,
156
+ model: options.model,
157
+ timestamp: Date.now(),
158
+ }
159
+ yield {
160
+ type: EventType.TOOL_CALL_END,
161
+ toolCallId,
162
+ toolCallName: rejectedToolCall.toolName,
163
+ toolName: rejectedToolCall.toolName,
164
+ ...(rejectedToolCall.input !== undefined && {
165
+ input: rejectedToolCall.input,
166
+ }),
167
+ result: JSON.stringify({ error: rejectedToolCall.error }),
168
+ state: 'output-error',
169
+ model: options.model,
170
+ timestamp: Date.now(),
171
+ }
172
+ yield {
173
+ type: EventType.RUN_FINISHED,
174
+ runId: aguiState.runId,
175
+ threadId: aguiState.threadId,
176
+ model: options.model,
177
+ timestamp: Date.now(),
178
+ finishReason: 'tool_calls',
179
+ }
180
+ return
181
+ }
184
182
 
185
- options.logger.errors(`${this.name}.chatStream fatal`, {
186
- error: errorPayload,
187
- source: `${this.name}.chatStream`,
188
- })
183
+ options.logger.errors(`${this.name}.${source} fatal`, {
184
+ error: errorPayload,
185
+ source: `${this.name}.${source}`,
186
+ })
187
+
188
+ yield {
189
+ type: EventType.RUN_ERROR,
190
+ model: options.model,
191
+ timestamp: Date.now(),
192
+ message: errorPayload.message,
193
+ ...(errorPayload.code !== undefined && { code: errorPayload.code }),
194
+ ...(rawEvent !== undefined && { rawEvent }),
195
+ error: {
196
+ message: errorPayload.message,
197
+ ...(errorPayload.code !== undefined && { code: errorPayload.code }),
198
+ },
189
199
  }
190
200
  }
191
201
 
@@ -636,15 +646,29 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
636
646
  )
637
647
  }
638
648
 
649
+ /**
650
+ * Strict conversion plus the inverse null-widening map for this request.
651
+ * Override this when schema conversion changes, so tool-input undo matches
652
+ * the wire schema.
653
+ */
654
+ protected makeStructuredOutputCompatibleWithMap(
655
+ schema: Record<string, any>,
656
+ originalRequired?: Array<string>,
657
+ ): StructuredOutputCompatibility {
658
+ return makeStructuredOutputCompatibleWithMap(schema, originalRequired)
659
+ }
660
+
639
661
  /**
640
662
  * Applies provider-specific transformations for structured output compatibility.
641
- * Override this in subclasses to handle provider-specific quirks.
663
+ * Override `makeStructuredOutputCompatibleWithMap` when you need the inverse map
664
+ * to match the wire schema.
642
665
  */
643
666
  protected makeStructuredOutputCompatible(
644
667
  schema: Record<string, any>,
645
668
  originalRequired?: Array<string>,
646
669
  ): Record<string, any> {
647
- return makeStructuredOutputCompatible(schema, originalRequired)
670
+ return this.makeStructuredOutputCompatibleWithMap(schema, originalRequired)
671
+ .schema
648
672
  }
649
673
 
650
674
  /**
@@ -680,13 +704,13 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
680
704
  protected async *processStreamChunks(
681
705
  stream: AsyncIterable<ChatCompletionChunk>,
682
706
  options: TextOptions,
683
- aguiState: {
684
- runId: string
685
- threadId: string
686
- messageId: string
687
- hasEmittedRunStarted: boolean
688
- },
707
+ aguiState: ChatStreamState,
689
708
  ): AsyncIterable<StreamChunk> {
709
+ const normalizeToolInput = createToolInputNormalizer(
710
+ options.tools,
711
+ (schema, required) =>
712
+ this.makeStructuredOutputCompatibleWithMap(schema, required),
713
+ )
690
714
  let accumulatedContent = ''
691
715
  let hasEmittedTextMessageStart = false
692
716
  let lastModel: string | undefined
@@ -955,8 +979,10 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
955
979
  if (toolCall.arguments) {
956
980
  try {
957
981
  const parsed: unknown = JSON.parse(toolCall.arguments)
958
- parsedInput =
959
- parsed && typeof parsed === 'object' ? parsed : {}
982
+ parsedInput = normalizeToolInput(
983
+ toolCall.name,
984
+ parsed && typeof parsed === 'object' ? parsed : {},
985
+ )
960
986
  } catch (parseError) {
961
987
  options.logger.errors(
962
988
  `${this.name}.processStreamChunks tool-args JSON parse failed`,
@@ -1027,7 +1053,10 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1027
1053
  if (toolCall.arguments) {
1028
1054
  try {
1029
1055
  const parsed: unknown = JSON.parse(toolCall.arguments)
1030
- parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
1056
+ parsedInput = normalizeToolInput(
1057
+ toolCall.name,
1058
+ parsed && typeof parsed === 'object' ? parsed : {},
1059
+ )
1031
1060
  } catch (parseError) {
1032
1061
  // Mirror the finish_reason path's logger call — a truncated
1033
1062
  // stream emitting malformed tool-call JSON would otherwise
@@ -1136,33 +1165,12 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1136
1165
  }
1137
1166
  }
1138
1167
  } catch (error: unknown) {
1139
- // Narrow before logging: raw SDK errors can carry request metadata
1140
- // (including auth headers) which we must never surface to user loggers.
1141
- const errorPayload = toRunErrorPayload(
1168
+ yield* this.handleChatStreamError(
1142
1169
  error,
1143
- `${this.name}.processStreamChunks failed`,
1170
+ options,
1171
+ aguiState,
1172
+ 'processStreamChunks',
1144
1173
  )
1145
- const rawEvent = toRunErrorRawEvent(error)
1146
- options.logger.errors(`${this.name}.processStreamChunks fatal`, {
1147
- error: errorPayload,
1148
- source: `${this.name}.processStreamChunks`,
1149
- })
1150
-
1151
- // Emit AG-UI RUN_ERROR with conditional `code` spread (see chatStream's
1152
- // catch block for the rationale). `rawEvent` carries the provider's
1153
- // structured error body when present.
1154
- yield {
1155
- type: EventType.RUN_ERROR,
1156
- model: options.model,
1157
- timestamp: Date.now(),
1158
- message: errorPayload.message,
1159
- ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1160
- ...(rawEvent !== undefined && { rawEvent }),
1161
- error: {
1162
- message: errorPayload.message,
1163
- ...(errorPayload.code !== undefined && { code: errorPayload.code }),
1164
- },
1165
- }
1166
1174
  }
1167
1175
  }
1168
1176
 
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convertFunctionToolToChatCompletionsFormat } from './chat-completions-tool-converter'
3
+ import type { Tool } from '@tanstack/ai'
4
+
5
+ describe('chat-completions tool converter', () => {
6
+ it('falls back from strict mode when an anyOf variant needs null widening', () => {
7
+ const out = convertFunctionToolToChatCompletionsFormat(
8
+ anyOfOptionalVariantTool,
9
+ )
10
+
11
+ expect(out.function.strict).toBe(false)
12
+ })
13
+
14
+ it('falls back from strict mode for boolean schema nodes', () => {
15
+ const out = convertFunctionToolToChatCompletionsFormat(booleanSchemaTool)
16
+
17
+ expect(out.function.strict).toBe(false)
18
+ expect(out.function.parameters).toEqual(booleanSchemaTool.inputSchema)
19
+ })
20
+ })
21
+
22
+ const booleanSchemaInput = {
23
+ type: 'object',
24
+ properties: {},
25
+ required: [],
26
+ }
27
+ Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true)
28
+
29
+ const booleanSchemaTool = {
30
+ name: 'accept_anything',
31
+ description: 'Accept any value',
32
+ inputSchema: booleanSchemaInput,
33
+ } satisfies Tool
34
+
35
+ const anyOfOptionalVariantTool: Tool = {
36
+ name: 'store_variant',
37
+ description: 'Store a union variant',
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ value: {
42
+ anyOf: [
43
+ {
44
+ type: 'object',
45
+ properties: {
46
+ kind: { const: 'optional' },
47
+ note: { type: 'string' },
48
+ },
49
+ required: ['kind'],
50
+ },
51
+ {
52
+ type: 'object',
53
+ properties: {
54
+ kind: { const: 'nullable' },
55
+ note: { type: ['string', 'null'] },
56
+ },
57
+ required: ['kind', 'note'],
58
+ },
59
+ ],
60
+ },
61
+ },
62
+ required: ['value'],
63
+ },
64
+ }
@@ -6,7 +6,9 @@ import {
6
6
  } from '@tanstack/ai/adapter-internals'
7
7
  import { generateId } from '@tanstack/ai-utils'
8
8
  import { extractRequestOptions } from '../utils/request-options'
9
- import { makeStructuredOutputCompatible } from '../utils/schema-converter'
9
+ import { makeStructuredOutputCompatibleWithMap } from '../utils/schema-converter'
10
+ import { createToolInputNormalizer } from '../utils/tool-input-normalizer'
11
+ import type { StructuredOutputCompatibility } from '../utils/schema-converter'
10
12
  import { buildResponsesUsage } from '../usage'
11
13
  import { convertToolsToResponsesFormat } from './responses-tool-converter'
12
14
  import type OpenAI from 'openai'
@@ -674,15 +676,29 @@ export abstract class OpenAIBaseResponsesTextAdapter<
674
676
  )
675
677
  }
676
678
 
679
+ /**
680
+ * Strict conversion plus the inverse null-widening map for this request.
681
+ * Override this when schema conversion changes, so tool-input undo matches
682
+ * the wire schema.
683
+ */
684
+ protected makeStructuredOutputCompatibleWithMap(
685
+ schema: Record<string, any>,
686
+ originalRequired?: Array<string>,
687
+ ): StructuredOutputCompatibility {
688
+ return makeStructuredOutputCompatibleWithMap(schema, originalRequired)
689
+ }
690
+
677
691
  /**
678
692
  * Applies provider-specific transformations for structured output compatibility.
679
- * Override this in subclasses to handle provider-specific quirks.
693
+ * Override `makeStructuredOutputCompatibleWithMap` when you need the inverse map
694
+ * to match the wire schema.
680
695
  */
681
696
  protected makeStructuredOutputCompatible(
682
697
  schema: Record<string, any>,
683
698
  originalRequired?: Array<string>,
684
699
  ): Record<string, any> {
685
- return makeStructuredOutputCompatible(schema, originalRequired)
700
+ return this.makeStructuredOutputCompatibleWithMap(schema, originalRequired)
701
+ .schema
686
702
  }
687
703
 
688
704
  /**
@@ -783,6 +799,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
783
799
  hasEmittedRunStarted: boolean
784
800
  },
785
801
  ): AsyncIterable<StreamChunk> {
802
+ const normalizeToolInput = createToolInputNormalizer(
803
+ options.tools,
804
+ (schema, required) =>
805
+ this.makeStructuredOutputCompatibleWithMap(schema, required),
806
+ )
786
807
  let accumulatedContent = ''
787
808
  let accumulatedReasoning = ''
788
809
 
@@ -1069,6 +1090,17 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1069
1090
  // handle content_part added events for text, reasoning and refusals
1070
1091
  if (chunk.type === 'response.content_part.added') {
1071
1092
  const contentPart = chunk.part
1093
+ // The Responses API can announce a text part with an empty
1094
+ // placeholder before putting the actual text only on the completed
1095
+ // response. An empty placeholder is not streamed content and must
1096
+ // not suppress the completion backstop below.
1097
+ if (
1098
+ (contentPart.type === 'output_text' ||
1099
+ contentPart.type === 'reasoning_text') &&
1100
+ !contentPart.text
1101
+ ) {
1102
+ continue
1103
+ }
1072
1104
  // Emit TEXT_MESSAGE_START if this is text content
1073
1105
  if (
1074
1106
  contentPart.type === 'output_text' &&
@@ -1303,7 +1335,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1303
1335
  if (chunk.arguments) {
1304
1336
  try {
1305
1337
  const parsed = JSON.parse(chunk.arguments)
1306
- parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
1338
+ parsedInput = normalizeToolInput(
1339
+ name,
1340
+ parsed && typeof parsed === 'object' ? parsed : {},
1341
+ )
1307
1342
  } catch (parseError) {
1308
1343
  options.logger.errors(
1309
1344
  `${this.name}.processStreamChunks tool-args JSON parse failed`,
@@ -1383,8 +1418,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1383
1418
  if (rawArgs) {
1384
1419
  try {
1385
1420
  const parsed = JSON.parse(rawArgs)
1386
- parsedInput =
1387
- parsed && typeof parsed === 'object' ? parsed : {}
1421
+ parsedInput = normalizeToolInput(
1422
+ name,
1423
+ parsed && typeof parsed === 'object' ? parsed : {},
1424
+ )
1388
1425
  } catch (parseError) {
1389
1426
  options.logger.errors(
1390
1427
  `${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`,
@@ -1419,6 +1456,40 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1419
1456
  }
1420
1457
 
1421
1458
  if (chunk.type === 'response.completed') {
1459
+ // Some Responses API streams, notably reasoning-model responses,
1460
+ // can omit text deltas and carry the successful final text only in
1461
+ // response.completed.output. Recover that text so consumers never
1462
+ // observe an empty result for a successful response.
1463
+ const completedText = chunk.response.output
1464
+ .flatMap((item) => (item.type === 'message' ? item.content : []))
1465
+ .filter((part) => part.type === 'output_text')
1466
+ .map((part) => part.text)
1467
+ .join('')
1468
+
1469
+ if (accumulatedContent.length === 0 && completedText.length > 0) {
1470
+ if (!hasEmittedTextMessageStart) {
1471
+ hasEmittedTextMessageStart = true
1472
+ yield {
1473
+ type: EventType.TEXT_MESSAGE_START,
1474
+ messageId: aguiState.messageId,
1475
+ model: model || options.model,
1476
+ timestamp: Date.now(),
1477
+ role: 'assistant',
1478
+ }
1479
+ }
1480
+
1481
+ accumulatedContent = completedText
1482
+ hasStreamedContentDeltas = true
1483
+ yield {
1484
+ type: EventType.TEXT_MESSAGE_CONTENT,
1485
+ messageId: aguiState.messageId,
1486
+ model: model || options.model,
1487
+ timestamp: Date.now(),
1488
+ delta: completedText,
1489
+ content: accumulatedContent,
1490
+ }
1491
+ }
1492
+
1422
1493
  // Final backstop for function_call lifecycle: if a function_call
1423
1494
  // appears in `response.output[]` but was never matched by an
1424
1495
  // output_item.added/done with a name, recover the missing START
@@ -1466,8 +1537,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1466
1537
  if (rawArgs) {
1467
1538
  try {
1468
1539
  const parsed = JSON.parse(rawArgs)
1469
- parsedInput =
1470
- parsed && typeof parsed === 'object' ? parsed : {}
1540
+ parsedInput = normalizeToolInput(
1541
+ name,
1542
+ parsed && typeof parsed === 'object' ? parsed : {},
1543
+ )
1471
1544
  } catch (parseError) {
1472
1545
  options.logger.errors(
1473
1546
  `${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`,
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convertFunctionToolToResponsesFormat } from './responses-tool-converter'
3
+ import type { Tool } from '@tanstack/ai'
4
+
5
+ describe('responses tool converter', () => {
6
+ it('falls back from strict mode when an anyOf variant needs null widening', () => {
7
+ const out = convertFunctionToolToResponsesFormat(anyOfOptionalVariantTool)
8
+
9
+ expect(out.strict).toBe(false)
10
+ expect(out.parameters).toMatchObject({
11
+ properties: {
12
+ value: {
13
+ anyOf: [{ required: ['kind'] }, { required: ['kind', 'note'] }],
14
+ },
15
+ },
16
+ })
17
+ })
18
+
19
+ it('falls back from strict mode for boolean schema nodes', () => {
20
+ const out = convertFunctionToolToResponsesFormat(booleanSchemaTool)
21
+
22
+ expect(out.strict).toBe(false)
23
+ expect(out.parameters).toEqual(booleanSchemaTool.inputSchema)
24
+ })
25
+ })
26
+
27
+ const booleanSchemaInput = {
28
+ type: 'object',
29
+ properties: {},
30
+ required: [],
31
+ }
32
+ Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true)
33
+
34
+ const booleanSchemaTool = {
35
+ name: 'accept_anything',
36
+ description: 'Accept any value',
37
+ inputSchema: booleanSchemaInput,
38
+ } satisfies Tool
39
+
40
+ const anyOfOptionalVariantTool: Tool = {
41
+ name: 'store_variant',
42
+ description: 'Store a union variant',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ value: {
47
+ anyOf: [
48
+ {
49
+ type: 'object',
50
+ properties: {
51
+ kind: { const: 'optional' },
52
+ note: { type: 'string' },
53
+ },
54
+ required: ['kind'],
55
+ },
56
+ {
57
+ type: 'object',
58
+ properties: {
59
+ kind: { const: 'nullable' },
60
+ note: { type: ['string', 'null'] },
61
+ },
62
+ required: ['kind', 'note'],
63
+ },
64
+ ],
65
+ },
66
+ },
67
+ required: ['value'],
68
+ },
69
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
- export { makeStructuredOutputCompatible } from './utils/schema-converter'
1
+ export {
2
+ makeStructuredOutputCompatible,
3
+ makeStructuredOutputCompatibleWithMap,
4
+ } from './utils/schema-converter'
2
5
  export {
3
6
  buildChatCompletionsUsage,
4
7
  buildResponsesUsage,
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convertFunctionToolToAdapterFormat } from './function-tool'
3
+ import type { Tool } from '@tanstack/ai'
4
+
5
+ describe('function-tool adapter converter', () => {
6
+ it('falls back from strict mode when an anyOf variant needs null widening', () => {
7
+ const out = convertFunctionToolToAdapterFormat(anyOfOptionalVariantTool)
8
+
9
+ expect(out.strict).toBe(false)
10
+ })
11
+
12
+ it('falls back from strict mode for boolean schema nodes', () => {
13
+ const out = convertFunctionToolToAdapterFormat(booleanSchemaTool)
14
+
15
+ expect(out.strict).toBe(false)
16
+ expect(out.parameters).toEqual(booleanSchemaTool.inputSchema)
17
+ })
18
+ })
19
+
20
+ const booleanSchemaInput = {
21
+ type: 'object',
22
+ properties: {},
23
+ required: [],
24
+ }
25
+ Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true)
26
+
27
+ const booleanSchemaTool = {
28
+ name: 'accept_anything',
29
+ description: 'Accept any value',
30
+ inputSchema: booleanSchemaInput,
31
+ } satisfies Tool
32
+
33
+ const anyOfOptionalVariantTool: Tool = {
34
+ name: 'store_variant',
35
+ description: 'Store a union variant',
36
+ inputSchema: {
37
+ type: 'object',
38
+ properties: {
39
+ value: {
40
+ anyOf: [
41
+ {
42
+ type: 'object',
43
+ properties: {
44
+ kind: { const: 'optional' },
45
+ note: { type: 'string' },
46
+ },
47
+ required: ['kind'],
48
+ },
49
+ {
50
+ type: 'object',
51
+ properties: {
52
+ kind: { const: 'nullable' },
53
+ note: { type: ['string', 'null'] },
54
+ },
55
+ required: ['kind', 'note'],
56
+ },
57
+ ],
58
+ },
59
+ },
60
+ required: ['value'],
61
+ },
62
+ }