@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.
- package/README.md +2 -2
- package/dist/esm/adapters/chat-completions-text.d.ts +18 -7
- package/dist/esm/adapters/chat-completions-text.js +86 -89
- 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 +9 -1
- package/dist/esm/adapters/responses-text.js +41 -6
- 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 +128 -120
- package/src/adapters/chat-completions-tool-converter.test.ts +64 -0
- package/src/adapters/responses-text.ts +81 -8
- 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 { 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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1140
|
-
// (including auth headers) which we must never surface to user loggers.
|
|
1141
|
-
const errorPayload = toRunErrorPayload(
|
|
1168
|
+
yield* this.handleChatStreamError(
|
|
1142
1169
|
error,
|
|
1143
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|