@tanstack/openai-base 0.10.1 → 0.10.3

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 +15 -7
  3. package/dist/esm/adapters/chat-completions-text.js +23 -7
  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 +15 -7
  7. package/dist/esm/adapters/responses-text.js +141 -105
  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 +51 -18
  22. package/src/adapters/chat-completions-tool-converter.test.ts +64 -0
  23. package/src/adapters/responses-text.ts +198 -130
  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 { buildResponsesUsage } from '../usage'
11
13
  import { convertToolsToResponsesFormat } from './responses-tool-converter'
12
14
  import type OpenAI from 'openai'
@@ -27,7 +29,7 @@ import type {
27
29
  DefaultMessageMetadataByModality,
28
30
  Modality,
29
31
  ModelMessage,
30
- StreamChunk,
32
+ AdapterYieldChunk,
31
33
  TextOptions,
32
34
  } from '@tanstack/ai'
33
35
 
@@ -89,7 +91,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
89
91
 
90
92
  async *chatStream(
91
93
  options: TextOptions<TProviderOptions>,
92
- ): AsyncIterable<StreamChunk> {
94
+ ): AsyncIterable<AdapterYieldChunk> {
93
95
  // Key streamed state by output item ID because argument deltas reference
94
96
  // `item_id`. The state separately retains `call_id`, which is the public
95
97
  // tool-call ID and the correlation key for function_call_output.
@@ -298,7 +300,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
298
300
  */
299
301
  async *structuredOutputStream(
300
302
  options: StructuredOutputOptions<TProviderOptions>,
301
- ): AsyncIterable<StreamChunk> {
303
+ ): AsyncIterable<AdapterYieldChunk> {
302
304
  const { chatOptions, outputSchema } = options
303
305
  const requestParams = this.mapOptionsToRequest(chatOptions)
304
306
 
@@ -325,7 +327,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
325
327
 
326
328
  const closeReasoning = function* (this: {
327
329
  name: string
328
- }): Generator<StreamChunk> {
330
+ }): Generator<AdapterYieldChunk> {
329
331
  if (reasoningMessageId && !hasClosedReasoning) {
330
332
  hasClosedReasoning = true
331
333
  yield {
@@ -350,12 +352,15 @@ export abstract class OpenAIBaseResponsesTextAdapter<
350
352
  content: accumulatedReasoning,
351
353
  }
352
354
  }
355
+ reasoningMessageId = undefined
356
+ stepId = undefined
357
+ hasClosedReasoning = false
353
358
  }
354
359
  }.bind(this)
355
360
 
356
361
  const openReasoning = function* (this: {
357
362
  name: string
358
- }): Generator<StreamChunk> {
363
+ }): Generator<AdapterYieldChunk> {
359
364
  if (reasoningMessageId) return
360
365
  reasoningMessageId = generateId(this.name)
361
366
  stepId = generateId(this.name)
@@ -674,15 +679,29 @@ export abstract class OpenAIBaseResponsesTextAdapter<
674
679
  )
675
680
  }
676
681
 
682
+ /**
683
+ * Strict conversion plus the inverse null-widening map for this request.
684
+ * Override this when schema conversion changes, so tool-input undo matches
685
+ * the wire schema.
686
+ */
687
+ protected makeStructuredOutputCompatibleWithMap(
688
+ schema: Record<string, any>,
689
+ originalRequired?: Array<string>,
690
+ ): StructuredOutputCompatibility {
691
+ return makeStructuredOutputCompatibleWithMap(schema, originalRequired)
692
+ }
693
+
677
694
  /**
678
695
  * Applies provider-specific transformations for structured output compatibility.
679
- * Override this in subclasses to handle provider-specific quirks.
696
+ * Override `makeStructuredOutputCompatibleWithMap` when you need the inverse map
697
+ * to match the wire schema.
680
698
  */
681
699
  protected makeStructuredOutputCompatible(
682
700
  schema: Record<string, any>,
683
701
  originalRequired?: Array<string>,
684
702
  ): Record<string, any> {
685
- return makeStructuredOutputCompatible(schema, originalRequired)
703
+ return this.makeStructuredOutputCompatibleWithMap(schema, originalRequired)
704
+ .schema
686
705
  }
687
706
 
688
707
  /**
@@ -782,7 +801,12 @@ export abstract class OpenAIBaseResponsesTextAdapter<
782
801
  messageId: string
783
802
  hasEmittedRunStarted: boolean
784
803
  },
785
- ): AsyncIterable<StreamChunk> {
804
+ ): AsyncIterable<AdapterYieldChunk> {
805
+ const normalizeToolInput = createToolInputNormalizer(
806
+ options.tools,
807
+ (schema, required) =>
808
+ this.makeStructuredOutputCompatibleWithMap(schema, required),
809
+ )
786
810
  let accumulatedContent = ''
787
811
  let accumulatedReasoning = ''
788
812
 
@@ -796,12 +820,95 @@ export abstract class OpenAIBaseResponsesTextAdapter<
796
820
  // AG-UI lifecycle tracking
797
821
  let stepId: string | null = null
798
822
  let hasEmittedTextMessageStart = false
799
- let hasEmittedStepStarted = false
823
+ let reasoningMessageId: string | undefined
824
+ let hasClosedReasoning = false
800
825
  // Track whether we've emitted a terminal RUN_FINISHED so the
801
826
  // end-of-stream fallback below knows to synthesise one when the upstream
802
827
  // cuts off without a response.completed event.
803
828
  let runFinishedEmitted = false
804
829
 
830
+ const adapterName = this.name
831
+ const emitModel = () => model || options.model
832
+
833
+ const openReasoning = function* (): Generator<AdapterYieldChunk> {
834
+ if (reasoningMessageId) return
835
+ reasoningMessageId = generateId(adapterName)
836
+ stepId = generateId(adapterName)
837
+ const timestamp = Date.now()
838
+ const currentModel = emitModel()
839
+ yield {
840
+ type: EventType.REASONING_START,
841
+ messageId: reasoningMessageId,
842
+ model: currentModel,
843
+ timestamp,
844
+ }
845
+ yield {
846
+ type: EventType.REASONING_MESSAGE_START,
847
+ messageId: reasoningMessageId,
848
+ role: 'reasoning' as const,
849
+ model: currentModel,
850
+ timestamp,
851
+ }
852
+ yield {
853
+ type: EventType.STEP_STARTED,
854
+ stepName: stepId,
855
+ stepId,
856
+ model: currentModel,
857
+ timestamp,
858
+ stepType: 'thinking',
859
+ }
860
+ }
861
+
862
+ const closeReasoning = function* (): Generator<AdapterYieldChunk> {
863
+ if (!reasoningMessageId || hasClosedReasoning) return
864
+ hasClosedReasoning = true
865
+ const timestamp = Date.now()
866
+ const currentModel = emitModel()
867
+ yield {
868
+ type: EventType.REASONING_MESSAGE_END,
869
+ messageId: reasoningMessageId,
870
+ model: currentModel,
871
+ timestamp,
872
+ }
873
+ yield {
874
+ type: EventType.REASONING_END,
875
+ messageId: reasoningMessageId,
876
+ model: currentModel,
877
+ timestamp,
878
+ }
879
+ if (stepId) {
880
+ yield {
881
+ type: EventType.STEP_FINISHED,
882
+ stepName: stepId,
883
+ stepId,
884
+ model: currentModel,
885
+ timestamp,
886
+ content: accumulatedReasoning,
887
+ }
888
+ }
889
+ reasoningMessageId = undefined
890
+ stepId = null
891
+ hasClosedReasoning = false
892
+ accumulatedReasoning = ''
893
+ }
894
+
895
+ const emitReasoningDelta = function* (
896
+ text: string,
897
+ ): Generator<AdapterYieldChunk> {
898
+ if (!text) return
899
+ yield* openReasoning()
900
+ if (!reasoningMessageId) return
901
+ accumulatedReasoning += text
902
+ hasStreamedReasoningDeltas = true
903
+ yield {
904
+ type: EventType.REASONING_MESSAGE_CONTENT,
905
+ messageId: reasoningMessageId,
906
+ delta: text,
907
+ model: emitModel(),
908
+ timestamp: Date.now(),
909
+ }
910
+ }
911
+
805
912
  try {
806
913
  for await (const chunk of stream) {
807
914
  options.logger.provider(`provider=${this.name} type=${chunk.type}`, {
@@ -826,7 +933,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
826
933
  type: string
827
934
  text?: string
828
935
  refusal?: string
829
- }): StreamChunk => {
936
+ }): AdapterYieldChunk => {
830
937
  if (contentPart.type === 'output_text') {
831
938
  accumulatedContent += contentPart.text || ''
832
939
  return {
@@ -839,25 +946,6 @@ export abstract class OpenAIBaseResponsesTextAdapter<
839
946
  }
840
947
  }
841
948
 
842
- if (contentPart.type === 'reasoning_text') {
843
- accumulatedReasoning += contentPart.text || ''
844
- // Cache the fallback stepId rather than generating a fresh one
845
- // on every call — otherwise multiple reasoning chunks arriving
846
- // before STEP_STARTED was emitted (e.g. via response.content_part.done
847
- // alone) would each get a different stepId and break correlation.
848
- if (!stepId) {
849
- stepId = generateId(this.name)
850
- }
851
- return {
852
- type: EventType.STEP_FINISHED,
853
- stepName: stepId,
854
- stepId,
855
- model: model || options.model,
856
- timestamp: Date.now(),
857
- delta: contentPart.text || '',
858
- content: accumulatedReasoning,
859
- }
860
- }
861
949
  // Either a real refusal or an unknown content_part type. Surface
862
950
  // the part type in the error so unknown parts are debuggable
863
951
  // instead of being misreported as "Unknown refusal".
@@ -892,7 +980,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
892
980
  hasStreamedContentDeltas = false
893
981
  hasStreamedReasoningDeltas = false
894
982
  hasEmittedTextMessageStart = false
895
- hasEmittedStepStarted = false
983
+ reasoningMessageId = undefined
984
+ hasClosedReasoning = false
985
+ stepId = null
896
986
  accumulatedContent = ''
897
987
  accumulatedReasoning = ''
898
988
  }
@@ -907,6 +997,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
907
997
  chunk.type === 'response.failed' ||
908
998
  chunk.type === 'response.incomplete'
909
999
  ) {
1000
+ yield* closeReasoning()
910
1001
  if (hasEmittedTextMessageStart) {
911
1002
  yield {
912
1003
  type: EventType.TEXT_MESSAGE_END,
@@ -962,6 +1053,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
962
1053
  : ''
963
1054
 
964
1055
  if (textDelta) {
1056
+ yield* closeReasoning()
965
1057
  // Emit TEXT_MESSAGE_START on first text content
966
1058
  if (!hasEmittedTextMessageStart) {
967
1059
  hasEmittedTextMessageStart = true
@@ -990,90 +1082,47 @@ export abstract class OpenAIBaseResponsesTextAdapter<
990
1082
  // Handle reasoning deltas (token-by-token thinking/reasoning streaming)
991
1083
  // response.reasoning_text.delta provides incremental reasoning updates
992
1084
  if (chunk.type === 'response.reasoning_text.delta' && chunk.delta) {
993
- // Delta can be an array of strings or a single string
994
1085
  const reasoningDelta = Array.isArray(chunk.delta)
995
1086
  ? chunk.delta.join('')
996
1087
  : typeof chunk.delta === 'string'
997
1088
  ? chunk.delta
998
1089
  : ''
999
-
1000
- if (reasoningDelta) {
1001
- // Emit STEP_STARTED on first reasoning content
1002
- if (!hasEmittedStepStarted) {
1003
- hasEmittedStepStarted = true
1004
- stepId = generateId(this.name)
1005
- yield {
1006
- type: EventType.STEP_STARTED,
1007
- stepName: stepId,
1008
- stepId,
1009
- model: model || options.model,
1010
- timestamp: Date.now(),
1011
- stepType: 'thinking',
1012
- }
1013
- }
1014
-
1015
- accumulatedReasoning += reasoningDelta
1016
- hasStreamedReasoningDeltas = true
1017
- const fallbackStepId = stepId || generateId(this.name)
1018
- yield {
1019
- type: EventType.STEP_FINISHED,
1020
- stepName: fallbackStepId,
1021
- stepId: fallbackStepId,
1022
- model: model || options.model,
1023
- timestamp: Date.now(),
1024
- delta: reasoningDelta,
1025
- content: accumulatedReasoning,
1026
- }
1027
- }
1090
+ yield* emitReasoningDelta(reasoningDelta)
1028
1091
  }
1029
1092
 
1030
1093
  // Handle reasoning summary deltas (when using reasoning.summary option)
1031
- // response.reasoning_summary_text.delta provides incremental summary updates
1032
1094
  if (
1033
1095
  chunk.type === 'response.reasoning_summary_text.delta' &&
1034
1096
  chunk.delta
1035
1097
  ) {
1036
1098
  const summaryDelta =
1037
1099
  typeof chunk.delta === 'string' ? chunk.delta : ''
1038
-
1039
- if (summaryDelta) {
1040
- // Emit STEP_STARTED on first reasoning content
1041
- if (!hasEmittedStepStarted) {
1042
- hasEmittedStepStarted = true
1043
- stepId = generateId(this.name)
1044
- yield {
1045
- type: EventType.STEP_STARTED,
1046
- stepName: stepId,
1047
- stepId,
1048
- model: model || options.model,
1049
- timestamp: Date.now(),
1050
- stepType: 'thinking',
1051
- }
1052
- }
1053
-
1054
- accumulatedReasoning += summaryDelta
1055
- hasStreamedReasoningDeltas = true
1056
- const fallbackStepId = stepId || generateId(this.name)
1057
- yield {
1058
- type: EventType.STEP_FINISHED,
1059
- stepName: fallbackStepId,
1060
- stepId: fallbackStepId,
1061
- model: model || options.model,
1062
- timestamp: Date.now(),
1063
- delta: summaryDelta,
1064
- content: accumulatedReasoning,
1065
- }
1066
- }
1100
+ yield* emitReasoningDelta(summaryDelta)
1067
1101
  }
1068
1102
 
1069
1103
  // handle content_part added events for text, reasoning and refusals
1070
1104
  if (chunk.type === 'response.content_part.added') {
1071
1105
  const contentPart = chunk.part
1072
- // Emit TEXT_MESSAGE_START if this is text content
1106
+ // The Responses API can announce a text part with an empty
1107
+ // placeholder before putting the actual text only on the completed
1108
+ // response. An empty placeholder is not streamed content and must
1109
+ // not suppress the completion backstop below.
1110
+ if (
1111
+ (contentPart.type === 'output_text' ||
1112
+ contentPart.type === 'reasoning_text') &&
1113
+ !contentPart.text
1114
+ ) {
1115
+ continue
1116
+ }
1117
+ if (contentPart.type === 'reasoning_text') {
1118
+ yield* emitReasoningDelta(contentPart.text || '')
1119
+ continue
1120
+ }
1073
1121
  if (
1074
1122
  contentPart.type === 'output_text' &&
1075
1123
  !hasEmittedTextMessageStart
1076
1124
  ) {
1125
+ yield* closeReasoning()
1077
1126
  hasEmittedTextMessageStart = true
1078
1127
  yield {
1079
1128
  type: EventType.TEXT_MESSAGE_START,
@@ -1083,27 +1132,12 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1083
1132
  role: 'assistant',
1084
1133
  }
1085
1134
  }
1086
- // Emit STEP_STARTED if this is reasoning content
1087
- if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) {
1088
- hasEmittedStepStarted = true
1089
- stepId = generateId(this.name)
1090
- yield {
1091
- type: EventType.STEP_STARTED,
1092
- stepName: stepId,
1093
- stepId,
1094
- model: model || options.model,
1095
- timestamp: Date.now(),
1096
- stepType: 'thinking',
1097
- }
1098
- }
1099
1135
  // Mark whichever stream we just emitted into so a subsequent
1100
1136
  // `content_part.done` doesn't duplicate the same text. Without
1101
1137
  // this flag, an `added` event carrying the full text followed by
1102
1138
  // a matching `done` event would emit TEXT_MESSAGE_CONTENT twice.
1103
1139
  if (contentPart.type === 'output_text') {
1104
1140
  hasStreamedContentDeltas = true
1105
- } else if (contentPart.type === 'reasoning_text') {
1106
- hasStreamedReasoningDeltas = true
1107
1141
  }
1108
1142
  const partChunk = handleContentPart(contentPart)
1109
1143
  yield partChunk
@@ -1140,10 +1174,15 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1140
1174
  // without a start and never see an end. Emit the lifecycle opener
1141
1175
  // for whichever stream this content_part belongs to before yielding
1142
1176
  // the CONTENT chunk; the post-loop block emits the matching END.
1177
+ if (contentPart.type === 'reasoning_text') {
1178
+ yield* emitReasoningDelta(contentPart.text || '')
1179
+ continue
1180
+ }
1143
1181
  if (
1144
1182
  contentPart.type === 'output_text' &&
1145
1183
  !hasEmittedTextMessageStart
1146
1184
  ) {
1185
+ yield* closeReasoning()
1147
1186
  hasEmittedTextMessageStart = true
1148
1187
  yield {
1149
1188
  type: EventType.TEXT_MESSAGE_START,
@@ -1152,20 +1191,6 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1152
1191
  timestamp: Date.now(),
1153
1192
  role: 'assistant',
1154
1193
  }
1155
- } else if (
1156
- contentPart.type === 'reasoning_text' &&
1157
- !hasEmittedStepStarted
1158
- ) {
1159
- hasEmittedStepStarted = true
1160
- stepId = generateId(this.name)
1161
- yield {
1162
- type: EventType.STEP_STARTED,
1163
- stepName: stepId,
1164
- stepId,
1165
- model: model || options.model,
1166
- timestamp: Date.now(),
1167
- stepType: 'thinking',
1168
- }
1169
1194
  }
1170
1195
 
1171
1196
  // Only emit if we haven't been streaming deltas (e.g., for non-streaming responses)
@@ -1303,7 +1328,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1303
1328
  if (chunk.arguments) {
1304
1329
  try {
1305
1330
  const parsed = JSON.parse(chunk.arguments)
1306
- parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
1331
+ parsedInput = normalizeToolInput(
1332
+ name,
1333
+ parsed && typeof parsed === 'object' ? parsed : {},
1334
+ )
1307
1335
  } catch (parseError) {
1308
1336
  options.logger.errors(
1309
1337
  `${this.name}.processStreamChunks tool-args JSON parse failed`,
@@ -1383,8 +1411,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1383
1411
  if (rawArgs) {
1384
1412
  try {
1385
1413
  const parsed = JSON.parse(rawArgs)
1386
- parsedInput =
1387
- parsed && typeof parsed === 'object' ? parsed : {}
1414
+ parsedInput = normalizeToolInput(
1415
+ name,
1416
+ parsed && typeof parsed === 'object' ? parsed : {},
1417
+ )
1388
1418
  } catch (parseError) {
1389
1419
  options.logger.errors(
1390
1420
  `${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`,
@@ -1419,6 +1449,40 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1419
1449
  }
1420
1450
 
1421
1451
  if (chunk.type === 'response.completed') {
1452
+ // Some Responses API streams, notably reasoning-model responses,
1453
+ // can omit text deltas and carry the successful final text only in
1454
+ // response.completed.output. Recover that text so consumers never
1455
+ // observe an empty result for a successful response.
1456
+ const completedText = chunk.response.output
1457
+ .flatMap((item) => (item.type === 'message' ? item.content : []))
1458
+ .filter((part) => part.type === 'output_text')
1459
+ .map((part) => part.text)
1460
+ .join('')
1461
+
1462
+ if (accumulatedContent.length === 0 && completedText.length > 0) {
1463
+ if (!hasEmittedTextMessageStart) {
1464
+ hasEmittedTextMessageStart = true
1465
+ yield {
1466
+ type: EventType.TEXT_MESSAGE_START,
1467
+ messageId: aguiState.messageId,
1468
+ model: model || options.model,
1469
+ timestamp: Date.now(),
1470
+ role: 'assistant',
1471
+ }
1472
+ }
1473
+
1474
+ accumulatedContent = completedText
1475
+ hasStreamedContentDeltas = true
1476
+ yield {
1477
+ type: EventType.TEXT_MESSAGE_CONTENT,
1478
+ messageId: aguiState.messageId,
1479
+ model: model || options.model,
1480
+ timestamp: Date.now(),
1481
+ delta: completedText,
1482
+ content: accumulatedContent,
1483
+ }
1484
+ }
1485
+
1422
1486
  // Final backstop for function_call lifecycle: if a function_call
1423
1487
  // appears in `response.output[]` but was never matched by an
1424
1488
  // output_item.added/done with a name, recover the missing START
@@ -1466,8 +1530,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1466
1530
  if (rawArgs) {
1467
1531
  try {
1468
1532
  const parsed = JSON.parse(rawArgs)
1469
- parsedInput =
1470
- parsed && typeof parsed === 'object' ? parsed : {}
1533
+ parsedInput = normalizeToolInput(
1534
+ name,
1535
+ parsed && typeof parsed === 'object' ? parsed : {},
1536
+ )
1471
1537
  } catch (parseError) {
1472
1538
  options.logger.errors(
1473
1539
  `${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`,
@@ -1500,6 +1566,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1500
1566
  }
1501
1567
  }
1502
1568
 
1569
+ yield* closeReasoning()
1503
1570
  // Emit TEXT_MESSAGE_END if we had text content
1504
1571
  if (hasEmittedTextMessageStart) {
1505
1572
  yield {
@@ -1582,6 +1649,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1582
1649
  // mirrors the chat-completions adapter's behavior so consumers always
1583
1650
  // see a terminal event for every started run.
1584
1651
  if (!runFinishedEmitted && aguiState.hasEmittedRunStarted) {
1652
+ yield* closeReasoning()
1585
1653
  if (hasEmittedTextMessageStart) {
1586
1654
  yield {
1587
1655
  type: EventType.TEXT_MESSAGE_END,
@@ -1962,9 +2030,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1962
2030
  * Handles backward compatibility with string content.
1963
2031
  */
1964
2032
  protected normalizeContent(
1965
- content: string | null | Array<ContentPart>,
2033
+ content: string | null | undefined | Array<ContentPart>,
1966
2034
  ): Array<ContentPart> {
1967
- if (content === null) {
2035
+ if (content === null || content === undefined) {
1968
2036
  return []
1969
2037
  }
1970
2038
  if (typeof content === 'string') {
@@ -1977,9 +2045,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1977
2045
  * Extracts text content from a content value that may be string, null, or ContentPart array.
1978
2046
  */
1979
2047
  protected extractTextContent(
1980
- content: string | null | Array<ContentPart>,
2048
+ content: string | null | undefined | Array<ContentPart>,
1981
2049
  ): string {
1982
- if (content === null) {
2050
+ if (content === null || content === undefined) {
1983
2051
  return ''
1984
2052
  }
1985
2053
  if (typeof content === 'string') {
@@ -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
+ }