@tanstack/openai-base 0.10.2 → 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.
@@ -27,8 +27,7 @@ import type {
27
27
  DefaultMessageMetadataByModality,
28
28
  Modality,
29
29
  ModelMessage,
30
- RunFinishedEvent,
31
- StreamChunk,
30
+ AdapterYieldChunk,
32
31
  TextOptions,
33
32
  } from '@tanstack/ai'
34
33
 
@@ -71,7 +70,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
71
70
 
72
71
  async *chatStream(
73
72
  options: TextOptions<TProviderOptions>,
74
- ): AsyncIterable<StreamChunk> {
73
+ ): AsyncIterable<AdapterYieldChunk> {
75
74
  // AG-UI lifecycle tracking (mutable state object for ESLint compatibility)
76
75
  const aguiState = {
77
76
  runId: generateId(this.name),
@@ -112,7 +111,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
112
111
  options: TextOptions,
113
112
  aguiState: ChatStreamState,
114
113
  source: 'chatStream' | 'processStreamChunks',
115
- ): AsyncIterable<StreamChunk> {
114
+ ): AsyncIterable<AdapterYieldChunk> {
116
115
  // Narrow before logging: raw SDK errors can carry request metadata
117
116
  // (including auth headers) which we must never surface to user loggers.
118
117
  const errorPayload = toRunErrorPayload(
@@ -187,6 +186,8 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
187
186
 
188
187
  yield {
189
188
  type: EventType.RUN_ERROR,
189
+ runId: aguiState.runId,
190
+ threadId: aguiState.threadId,
190
191
  model: options.model,
191
192
  timestamp: Date.now(),
192
193
  message: errorPayload.message,
@@ -322,7 +323,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
322
323
  */
323
324
  async *structuredOutputStream(
324
325
  options: StructuredOutputOptions<TProviderOptions>,
325
- ): AsyncIterable<StreamChunk> {
326
+ ): AsyncIterable<AdapterYieldChunk> {
326
327
  const { chatOptions, outputSchema } = options
327
328
  const requestParams = this.mapOptionsToRequest(chatOptions)
328
329
 
@@ -351,7 +352,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
351
352
 
352
353
  const closeReasoningLifecycle = function* (this: {
353
354
  name: string
354
- }): Generator<StreamChunk> {
355
+ }): Generator<AdapterYieldChunk> {
355
356
  if (reasoningMessageId && !hasClosedReasoning) {
356
357
  hasClosedReasoning = true
357
358
  yield {
@@ -376,6 +377,9 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
376
377
  content: accumulatedReasoning,
377
378
  }
378
379
  }
380
+ reasoningMessageId = undefined
381
+ stepId = undefined
382
+ hasClosedReasoning = false
379
383
  }
380
384
  }.bind(this)
381
385
 
@@ -705,7 +709,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
705
709
  stream: AsyncIterable<ChatCompletionChunk>,
706
710
  options: TextOptions,
707
711
  aguiState: ChatStreamState,
708
- ): AsyncIterable<StreamChunk> {
712
+ ): AsyncIterable<AdapterYieldChunk> {
709
713
  const normalizeToolInput = createToolInputNormalizer(
710
714
  options.tools,
711
715
  (schema, required) =>
@@ -1140,7 +1144,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1140
1144
  // tool results that would never arrive. OpenAI's legacy
1141
1145
  // `function_call` value (from the v1 function-calling API) is
1142
1146
  // normalized to `tool_calls` — semantically the same termination.
1143
- const finishReason: NonNullable<RunFinishedEvent['finishReason']> =
1147
+ const finishReason: NonNullable<AdapterYieldChunk['finishReason']> =
1144
1148
  emittedAnyToolCallEnd
1145
1149
  ? 'tool_calls'
1146
1150
  : pendingFinishReason === 'tool_calls'
@@ -1427,9 +1431,9 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1427
1431
  * Handles backward compatibility with string content.
1428
1432
  */
1429
1433
  protected normalizeContent(
1430
- content: string | null | Array<ContentPart>,
1434
+ content: string | null | undefined | Array<ContentPart>,
1431
1435
  ): Array<ContentPart> {
1432
- if (content === null) {
1436
+ if (content === null || content === undefined) {
1433
1437
  return []
1434
1438
  }
1435
1439
  if (typeof content === 'string') {
@@ -1442,9 +1446,12 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
1442
1446
  * Extracts text content from a content value that may be string, null, or ContentPart array.
1443
1447
  */
1444
1448
  protected extractTextContent(
1445
- content: string | null | Array<ContentPart>,
1449
+ content: string | null | undefined | Array<ContentPart>,
1446
1450
  ): string {
1447
- if (content === null) {
1451
+ // Tool-call-only assistant turns (e.g. an approval resume replaying the
1452
+ // pending call) carry no text and arrive as `null` or `undefined`; both
1453
+ // must collapse to '' rather than crash on `.filter`.
1454
+ if (content === null || content === undefined) {
1448
1455
  return ''
1449
1456
  }
1450
1457
  if (typeof content === 'string') {
@@ -29,7 +29,7 @@ import type {
29
29
  DefaultMessageMetadataByModality,
30
30
  Modality,
31
31
  ModelMessage,
32
- StreamChunk,
32
+ AdapterYieldChunk,
33
33
  TextOptions,
34
34
  } from '@tanstack/ai'
35
35
 
@@ -91,7 +91,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
91
91
 
92
92
  async *chatStream(
93
93
  options: TextOptions<TProviderOptions>,
94
- ): AsyncIterable<StreamChunk> {
94
+ ): AsyncIterable<AdapterYieldChunk> {
95
95
  // Key streamed state by output item ID because argument deltas reference
96
96
  // `item_id`. The state separately retains `call_id`, which is the public
97
97
  // tool-call ID and the correlation key for function_call_output.
@@ -300,7 +300,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
300
300
  */
301
301
  async *structuredOutputStream(
302
302
  options: StructuredOutputOptions<TProviderOptions>,
303
- ): AsyncIterable<StreamChunk> {
303
+ ): AsyncIterable<AdapterYieldChunk> {
304
304
  const { chatOptions, outputSchema } = options
305
305
  const requestParams = this.mapOptionsToRequest(chatOptions)
306
306
 
@@ -327,7 +327,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
327
327
 
328
328
  const closeReasoning = function* (this: {
329
329
  name: string
330
- }): Generator<StreamChunk> {
330
+ }): Generator<AdapterYieldChunk> {
331
331
  if (reasoningMessageId && !hasClosedReasoning) {
332
332
  hasClosedReasoning = true
333
333
  yield {
@@ -352,12 +352,15 @@ export abstract class OpenAIBaseResponsesTextAdapter<
352
352
  content: accumulatedReasoning,
353
353
  }
354
354
  }
355
+ reasoningMessageId = undefined
356
+ stepId = undefined
357
+ hasClosedReasoning = false
355
358
  }
356
359
  }.bind(this)
357
360
 
358
361
  const openReasoning = function* (this: {
359
362
  name: string
360
- }): Generator<StreamChunk> {
363
+ }): Generator<AdapterYieldChunk> {
361
364
  if (reasoningMessageId) return
362
365
  reasoningMessageId = generateId(this.name)
363
366
  stepId = generateId(this.name)
@@ -798,7 +801,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
798
801
  messageId: string
799
802
  hasEmittedRunStarted: boolean
800
803
  },
801
- ): AsyncIterable<StreamChunk> {
804
+ ): AsyncIterable<AdapterYieldChunk> {
802
805
  const normalizeToolInput = createToolInputNormalizer(
803
806
  options.tools,
804
807
  (schema, required) =>
@@ -817,12 +820,95 @@ export abstract class OpenAIBaseResponsesTextAdapter<
817
820
  // AG-UI lifecycle tracking
818
821
  let stepId: string | null = null
819
822
  let hasEmittedTextMessageStart = false
820
- let hasEmittedStepStarted = false
823
+ let reasoningMessageId: string | undefined
824
+ let hasClosedReasoning = false
821
825
  // Track whether we've emitted a terminal RUN_FINISHED so the
822
826
  // end-of-stream fallback below knows to synthesise one when the upstream
823
827
  // cuts off without a response.completed event.
824
828
  let runFinishedEmitted = false
825
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
+
826
912
  try {
827
913
  for await (const chunk of stream) {
828
914
  options.logger.provider(`provider=${this.name} type=${chunk.type}`, {
@@ -847,7 +933,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
847
933
  type: string
848
934
  text?: string
849
935
  refusal?: string
850
- }): StreamChunk => {
936
+ }): AdapterYieldChunk => {
851
937
  if (contentPart.type === 'output_text') {
852
938
  accumulatedContent += contentPart.text || ''
853
939
  return {
@@ -860,25 +946,6 @@ export abstract class OpenAIBaseResponsesTextAdapter<
860
946
  }
861
947
  }
862
948
 
863
- if (contentPart.type === 'reasoning_text') {
864
- accumulatedReasoning += contentPart.text || ''
865
- // Cache the fallback stepId rather than generating a fresh one
866
- // on every call — otherwise multiple reasoning chunks arriving
867
- // before STEP_STARTED was emitted (e.g. via response.content_part.done
868
- // alone) would each get a different stepId and break correlation.
869
- if (!stepId) {
870
- stepId = generateId(this.name)
871
- }
872
- return {
873
- type: EventType.STEP_FINISHED,
874
- stepName: stepId,
875
- stepId,
876
- model: model || options.model,
877
- timestamp: Date.now(),
878
- delta: contentPart.text || '',
879
- content: accumulatedReasoning,
880
- }
881
- }
882
949
  // Either a real refusal or an unknown content_part type. Surface
883
950
  // the part type in the error so unknown parts are debuggable
884
951
  // instead of being misreported as "Unknown refusal".
@@ -913,7 +980,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
913
980
  hasStreamedContentDeltas = false
914
981
  hasStreamedReasoningDeltas = false
915
982
  hasEmittedTextMessageStart = false
916
- hasEmittedStepStarted = false
983
+ reasoningMessageId = undefined
984
+ hasClosedReasoning = false
985
+ stepId = null
917
986
  accumulatedContent = ''
918
987
  accumulatedReasoning = ''
919
988
  }
@@ -928,6 +997,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
928
997
  chunk.type === 'response.failed' ||
929
998
  chunk.type === 'response.incomplete'
930
999
  ) {
1000
+ yield* closeReasoning()
931
1001
  if (hasEmittedTextMessageStart) {
932
1002
  yield {
933
1003
  type: EventType.TEXT_MESSAGE_END,
@@ -983,6 +1053,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
983
1053
  : ''
984
1054
 
985
1055
  if (textDelta) {
1056
+ yield* closeReasoning()
986
1057
  // Emit TEXT_MESSAGE_START on first text content
987
1058
  if (!hasEmittedTextMessageStart) {
988
1059
  hasEmittedTextMessageStart = true
@@ -1011,80 +1082,22 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1011
1082
  // Handle reasoning deltas (token-by-token thinking/reasoning streaming)
1012
1083
  // response.reasoning_text.delta provides incremental reasoning updates
1013
1084
  if (chunk.type === 'response.reasoning_text.delta' && chunk.delta) {
1014
- // Delta can be an array of strings or a single string
1015
1085
  const reasoningDelta = Array.isArray(chunk.delta)
1016
1086
  ? chunk.delta.join('')
1017
1087
  : typeof chunk.delta === 'string'
1018
1088
  ? chunk.delta
1019
1089
  : ''
1020
-
1021
- if (reasoningDelta) {
1022
- // Emit STEP_STARTED on first reasoning content
1023
- if (!hasEmittedStepStarted) {
1024
- hasEmittedStepStarted = true
1025
- stepId = generateId(this.name)
1026
- yield {
1027
- type: EventType.STEP_STARTED,
1028
- stepName: stepId,
1029
- stepId,
1030
- model: model || options.model,
1031
- timestamp: Date.now(),
1032
- stepType: 'thinking',
1033
- }
1034
- }
1035
-
1036
- accumulatedReasoning += reasoningDelta
1037
- hasStreamedReasoningDeltas = true
1038
- const fallbackStepId = stepId || generateId(this.name)
1039
- yield {
1040
- type: EventType.STEP_FINISHED,
1041
- stepName: fallbackStepId,
1042
- stepId: fallbackStepId,
1043
- model: model || options.model,
1044
- timestamp: Date.now(),
1045
- delta: reasoningDelta,
1046
- content: accumulatedReasoning,
1047
- }
1048
- }
1090
+ yield* emitReasoningDelta(reasoningDelta)
1049
1091
  }
1050
1092
 
1051
1093
  // Handle reasoning summary deltas (when using reasoning.summary option)
1052
- // response.reasoning_summary_text.delta provides incremental summary updates
1053
1094
  if (
1054
1095
  chunk.type === 'response.reasoning_summary_text.delta' &&
1055
1096
  chunk.delta
1056
1097
  ) {
1057
1098
  const summaryDelta =
1058
1099
  typeof chunk.delta === 'string' ? chunk.delta : ''
1059
-
1060
- if (summaryDelta) {
1061
- // Emit STEP_STARTED on first reasoning content
1062
- if (!hasEmittedStepStarted) {
1063
- hasEmittedStepStarted = true
1064
- stepId = generateId(this.name)
1065
- yield {
1066
- type: EventType.STEP_STARTED,
1067
- stepName: stepId,
1068
- stepId,
1069
- model: model || options.model,
1070
- timestamp: Date.now(),
1071
- stepType: 'thinking',
1072
- }
1073
- }
1074
-
1075
- accumulatedReasoning += summaryDelta
1076
- hasStreamedReasoningDeltas = true
1077
- const fallbackStepId = stepId || generateId(this.name)
1078
- yield {
1079
- type: EventType.STEP_FINISHED,
1080
- stepName: fallbackStepId,
1081
- stepId: fallbackStepId,
1082
- model: model || options.model,
1083
- timestamp: Date.now(),
1084
- delta: summaryDelta,
1085
- content: accumulatedReasoning,
1086
- }
1087
- }
1100
+ yield* emitReasoningDelta(summaryDelta)
1088
1101
  }
1089
1102
 
1090
1103
  // handle content_part added events for text, reasoning and refusals
@@ -1101,11 +1114,15 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1101
1114
  ) {
1102
1115
  continue
1103
1116
  }
1104
- // Emit TEXT_MESSAGE_START if this is text content
1117
+ if (contentPart.type === 'reasoning_text') {
1118
+ yield* emitReasoningDelta(contentPart.text || '')
1119
+ continue
1120
+ }
1105
1121
  if (
1106
1122
  contentPart.type === 'output_text' &&
1107
1123
  !hasEmittedTextMessageStart
1108
1124
  ) {
1125
+ yield* closeReasoning()
1109
1126
  hasEmittedTextMessageStart = true
1110
1127
  yield {
1111
1128
  type: EventType.TEXT_MESSAGE_START,
@@ -1115,27 +1132,12 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1115
1132
  role: 'assistant',
1116
1133
  }
1117
1134
  }
1118
- // Emit STEP_STARTED if this is reasoning content
1119
- if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) {
1120
- hasEmittedStepStarted = true
1121
- stepId = generateId(this.name)
1122
- yield {
1123
- type: EventType.STEP_STARTED,
1124
- stepName: stepId,
1125
- stepId,
1126
- model: model || options.model,
1127
- timestamp: Date.now(),
1128
- stepType: 'thinking',
1129
- }
1130
- }
1131
1135
  // Mark whichever stream we just emitted into so a subsequent
1132
1136
  // `content_part.done` doesn't duplicate the same text. Without
1133
1137
  // this flag, an `added` event carrying the full text followed by
1134
1138
  // a matching `done` event would emit TEXT_MESSAGE_CONTENT twice.
1135
1139
  if (contentPart.type === 'output_text') {
1136
1140
  hasStreamedContentDeltas = true
1137
- } else if (contentPart.type === 'reasoning_text') {
1138
- hasStreamedReasoningDeltas = true
1139
1141
  }
1140
1142
  const partChunk = handleContentPart(contentPart)
1141
1143
  yield partChunk
@@ -1172,10 +1174,15 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1172
1174
  // without a start and never see an end. Emit the lifecycle opener
1173
1175
  // for whichever stream this content_part belongs to before yielding
1174
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
+ }
1175
1181
  if (
1176
1182
  contentPart.type === 'output_text' &&
1177
1183
  !hasEmittedTextMessageStart
1178
1184
  ) {
1185
+ yield* closeReasoning()
1179
1186
  hasEmittedTextMessageStart = true
1180
1187
  yield {
1181
1188
  type: EventType.TEXT_MESSAGE_START,
@@ -1184,20 +1191,6 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1184
1191
  timestamp: Date.now(),
1185
1192
  role: 'assistant',
1186
1193
  }
1187
- } else if (
1188
- contentPart.type === 'reasoning_text' &&
1189
- !hasEmittedStepStarted
1190
- ) {
1191
- hasEmittedStepStarted = true
1192
- stepId = generateId(this.name)
1193
- yield {
1194
- type: EventType.STEP_STARTED,
1195
- stepName: stepId,
1196
- stepId,
1197
- model: model || options.model,
1198
- timestamp: Date.now(),
1199
- stepType: 'thinking',
1200
- }
1201
1194
  }
1202
1195
 
1203
1196
  // Only emit if we haven't been streaming deltas (e.g., for non-streaming responses)
@@ -1573,6 +1566,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1573
1566
  }
1574
1567
  }
1575
1568
 
1569
+ yield* closeReasoning()
1576
1570
  // Emit TEXT_MESSAGE_END if we had text content
1577
1571
  if (hasEmittedTextMessageStart) {
1578
1572
  yield {
@@ -1655,6 +1649,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1655
1649
  // mirrors the chat-completions adapter's behavior so consumers always
1656
1650
  // see a terminal event for every started run.
1657
1651
  if (!runFinishedEmitted && aguiState.hasEmittedRunStarted) {
1652
+ yield* closeReasoning()
1658
1653
  if (hasEmittedTextMessageStart) {
1659
1654
  yield {
1660
1655
  type: EventType.TEXT_MESSAGE_END,
@@ -2035,9 +2030,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
2035
2030
  * Handles backward compatibility with string content.
2036
2031
  */
2037
2032
  protected normalizeContent(
2038
- content: string | null | Array<ContentPart>,
2033
+ content: string | null | undefined | Array<ContentPart>,
2039
2034
  ): Array<ContentPart> {
2040
- if (content === null) {
2035
+ if (content === null || content === undefined) {
2041
2036
  return []
2042
2037
  }
2043
2038
  if (typeof content === 'string') {
@@ -2050,9 +2045,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
2050
2045
  * Extracts text content from a content value that may be string, null, or ContentPart array.
2051
2046
  */
2052
2047
  protected extractTextContent(
2053
- content: string | null | Array<ContentPart>,
2048
+ content: string | null | undefined | Array<ContentPart>,
2054
2049
  ): string {
2055
- if (content === null) {
2050
+ if (content === null || content === undefined) {
2056
2051
  return ''
2057
2052
  }
2058
2053
  if (typeof content === 'string') {