@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.
- package/README.md +2 -2
- package/dist/esm/adapters/chat-completions-text.d.ts +15 -7
- package/dist/esm/adapters/chat-completions-text.js +23 -7
- package/dist/esm/adapters/chat-completions-text.js.map +1 -1
- package/dist/esm/adapters/chat-completions-tool-converter.test.d.ts +1 -0
- package/dist/esm/adapters/responses-text.d.ts +15 -7
- package/dist/esm/adapters/responses-text.js +141 -105
- package/dist/esm/adapters/responses-text.js.map +1 -1
- package/dist/esm/adapters/responses-tool-converter.test.d.ts +1 -0
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/tools/function-tool.test.d.ts +1 -0
- package/dist/esm/utils/schema-converter.d.ts +14 -0
- package/dist/esm/utils/schema-converter.js +106 -18
- package/dist/esm/utils/schema-converter.js.map +1 -1
- package/dist/esm/utils/tool-input-normalizer.d.ts +12 -0
- package/dist/esm/utils/tool-input-normalizer.js +36 -0
- package/dist/esm/utils/tool-input-normalizer.js.map +1 -0
- package/dist/esm/utils/tool-input-normalizer.test.d.ts +1 -0
- package/package.json +3 -3
- package/src/adapters/chat-completions-text.ts +51 -18
- package/src/adapters/chat-completions-tool-converter.test.ts +64 -0
- package/src/adapters/responses-text.ts +198 -130
- package/src/adapters/responses-tool-converter.test.ts +69 -0
- package/src/index.ts +4 -1
- package/src/tools/function-tool.test.ts +62 -0
- package/src/utils/schema-converter.ts +165 -20
- package/src/utils/tool-input-normalizer.test.ts +146 -0
- 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 {
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
|
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
|
|
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<
|
|
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
|
|
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
|
-
}):
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|