@tanstack/openai-base 0.11.1 → 0.12.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/dist/esm/adapters/chat-completions-text.d.ts +4 -2
- package/dist/esm/adapters/chat-completions-text.js +6 -2
- package/dist/esm/adapters/chat-completions-text.js.map +1 -1
- package/dist/esm/adapters/responses-text.d.ts +12 -5
- package/dist/esm/adapters/responses-text.js +155 -15
- package/dist/esm/adapters/responses-text.js.map +1 -1
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/usage.d.ts +2 -2
- package/dist/esm/usage.js +5 -3
- package/dist/esm/usage.js.map +1 -1
- package/dist/esm/utils/schema-converter.d.ts +22 -0
- package/dist/esm/utils/schema-converter.js +41 -8
- package/dist/esm/utils/schema-converter.js.map +1 -1
- package/package.json +4 -3
- package/src/adapters/chat-completions-text.ts +21 -3
- package/src/adapters/responses-text.ts +276 -31
- package/src/index.ts +2 -0
- package/src/usage.ts +16 -5
- package/src/utils/schema-converter.ts +72 -12
|
@@ -12,9 +12,15 @@ import {
|
|
|
12
12
|
} from '@tanstack/ai/adapter-internals'
|
|
13
13
|
import { generateId } from '@tanstack/ai-utils'
|
|
14
14
|
import { extractRequestOptions } from '../utils/request-options'
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
makeStructuredOutputCompatibleWithMap,
|
|
17
|
+
warnStrictFallback,
|
|
18
|
+
} from '../utils/schema-converter'
|
|
16
19
|
import { createToolInputNormalizer } from '../utils/tool-input-normalizer'
|
|
17
|
-
import type {
|
|
20
|
+
import type {
|
|
21
|
+
OpenAIBaseTextAdapterOptions,
|
|
22
|
+
StructuredOutputCompatibility,
|
|
23
|
+
} from '../utils/schema-converter'
|
|
18
24
|
import { buildResponsesUsage } from '../usage'
|
|
19
25
|
import { convertToolsToResponsesFormat } from './responses-tool-converter'
|
|
20
26
|
import {
|
|
@@ -33,8 +39,11 @@ import type {
|
|
|
33
39
|
Response,
|
|
34
40
|
ResponseCreateParams,
|
|
35
41
|
ResponseFunctionCallOutputItem,
|
|
42
|
+
ResponseFunctionWebSearch,
|
|
36
43
|
ResponseInput,
|
|
37
44
|
ResponseInputContent,
|
|
45
|
+
ResponseOutputMessage,
|
|
46
|
+
ResponseOutputText,
|
|
38
47
|
ResponseStreamEvent,
|
|
39
48
|
} from 'openai/resources/responses/responses'
|
|
40
49
|
import type {
|
|
@@ -43,6 +52,8 @@ import type {
|
|
|
43
52
|
Modality,
|
|
44
53
|
ModelMessage,
|
|
45
54
|
AdapterYieldChunk,
|
|
55
|
+
ProviderExecutedToolMetadata,
|
|
56
|
+
ProviderExecutedToolSource,
|
|
46
57
|
TextOptions,
|
|
47
58
|
} from '@tanstack/ai'
|
|
48
59
|
|
|
@@ -54,6 +65,75 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
54
65
|
return typeof value === 'object' && value !== null
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
function readURLCitation(
|
|
69
|
+
value: unknown,
|
|
70
|
+
): ResponseOutputText.URLCitation | undefined {
|
|
71
|
+
if (!isRecord(value) || value.type !== 'url_citation') return undefined
|
|
72
|
+
if (
|
|
73
|
+
typeof value.url !== 'string' ||
|
|
74
|
+
typeof value.title !== 'string' ||
|
|
75
|
+
typeof value.start_index !== 'number' ||
|
|
76
|
+
typeof value.end_index !== 'number'
|
|
77
|
+
) {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
type: 'url_citation',
|
|
82
|
+
url: value.url,
|
|
83
|
+
title: value.title,
|
|
84
|
+
start_index: value.start_index,
|
|
85
|
+
end_index: value.end_index,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readWebSearchCall(
|
|
90
|
+
value: unknown,
|
|
91
|
+
): ResponseFunctionWebSearch | undefined {
|
|
92
|
+
if (!isRecord(value) || value.type !== 'web_search_call') return undefined
|
|
93
|
+
if (
|
|
94
|
+
typeof value.id !== 'string' ||
|
|
95
|
+
typeof value.status !== 'string' ||
|
|
96
|
+
!isRecord(value.action) ||
|
|
97
|
+
typeof value.action.type !== 'string'
|
|
98
|
+
) {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
// oxlint-disable-next-line eslint-js/no-restricted-syntax -- the runtime guard above validates the stable fields while the SDK union preserves provider-specific action fields
|
|
102
|
+
return value as unknown as ResponseFunctionWebSearch
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function collectWebSearchSources(
|
|
106
|
+
item: ResponseFunctionWebSearch,
|
|
107
|
+
citations: ReadonlyArray<ResponseOutputText.URLCitation>,
|
|
108
|
+
): Array<ProviderExecutedToolSource> {
|
|
109
|
+
const sources = new Map<string, ProviderExecutedToolSource>()
|
|
110
|
+
const add = (url: unknown, title?: unknown) => {
|
|
111
|
+
if (typeof url !== 'string' || url.length === 0) return
|
|
112
|
+
const existing = sources.get(url)
|
|
113
|
+
if (existing) {
|
|
114
|
+
if (!existing.title && typeof title === 'string' && title.length > 0) {
|
|
115
|
+
existing.title = title
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
sources.set(url, {
|
|
120
|
+
url,
|
|
121
|
+
...(typeof title === 'string' && title.length > 0 ? { title } : {}),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (item.action.type === 'search') {
|
|
126
|
+
for (const source of item.action.sources ?? []) {
|
|
127
|
+
add(source.url)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const citation of citations) {
|
|
131
|
+
add(citation.url, citation.title)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return [...sources.values()]
|
|
135
|
+
}
|
|
136
|
+
|
|
57
137
|
function packResponsesReasoningSignature(
|
|
58
138
|
id: string | undefined,
|
|
59
139
|
encryptedContent: string | undefined,
|
|
@@ -110,12 +190,17 @@ function readReasoningItem(
|
|
|
110
190
|
* TanStack AI uses `call_id` as the canonical tool-call ID and carries the
|
|
111
191
|
* item ID here so stateless follow-up requests can replay both values.
|
|
112
192
|
*/
|
|
113
|
-
export interface OpenAIResponsesToolCallMetadata {
|
|
193
|
+
export interface OpenAIResponsesToolCallMetadata extends ProviderExecutedToolMetadata {
|
|
114
194
|
itemId?: string
|
|
115
195
|
/** Set for shell, local_shell, and apply_patch calls the app must run. */
|
|
116
196
|
openaiUserTool?: OpenAIUserToolName
|
|
117
197
|
/** Shell `action.max_output_length`, echoed on `shell_call_output`. */
|
|
118
198
|
maxOutputLength?: number | null
|
|
199
|
+
openai?: {
|
|
200
|
+
webSearchCall: ResponseFunctionWebSearch
|
|
201
|
+
urlCitations: Array<ResponseOutputText.URLCitation>
|
|
202
|
+
assistantMessage?: ResponseOutputMessage
|
|
203
|
+
}
|
|
119
204
|
}
|
|
120
205
|
|
|
121
206
|
interface StreamedFunctionCallMetadata {
|
|
@@ -162,10 +247,19 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
162
247
|
readonly name: string
|
|
163
248
|
protected client: OpenAI
|
|
164
249
|
|
|
165
|
-
|
|
250
|
+
/** See {@link OpenAIBaseTextAdapterOptions.strictFallbackWarning}. */
|
|
251
|
+
protected readonly strictFallbackWarning: boolean
|
|
252
|
+
|
|
253
|
+
constructor(
|
|
254
|
+
model: TModel,
|
|
255
|
+
name: string,
|
|
256
|
+
client: OpenAI,
|
|
257
|
+
options: OpenAIBaseTextAdapterOptions = {},
|
|
258
|
+
) {
|
|
166
259
|
super({}, model)
|
|
167
260
|
this.name = name
|
|
168
261
|
this.client = client
|
|
262
|
+
this.strictFallbackWarning = options.strictFallbackWarning ?? true
|
|
169
263
|
}
|
|
170
264
|
|
|
171
265
|
async *chatStream(
|
|
@@ -404,6 +498,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
404
498
|
let hasClosedReasoning = false
|
|
405
499
|
let model: string = chatOptions.model
|
|
406
500
|
let usage: OpenAI.Responses.Response['usage'] | undefined
|
|
501
|
+
let responseCompleted = false
|
|
407
502
|
|
|
408
503
|
const closeReasoning = function* (this: {
|
|
409
504
|
name: string
|
|
@@ -600,10 +695,12 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
600
695
|
}
|
|
601
696
|
|
|
602
697
|
if (chunk.type === 'response.completed') {
|
|
698
|
+
responseCompleted = true
|
|
603
699
|
const response = chunk.response
|
|
604
700
|
if (response.usage) usage = response.usage
|
|
605
701
|
if (response.model) model = response.model
|
|
606
|
-
|
|
702
|
+
// Terminal event: do not wait for the HTTP body to close (#1445).
|
|
703
|
+
break
|
|
607
704
|
}
|
|
608
705
|
|
|
609
706
|
if (chunk.type === 'response.failed') {
|
|
@@ -641,6 +738,20 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
641
738
|
}
|
|
642
739
|
}
|
|
643
740
|
|
|
741
|
+
if (!responseCompleted) {
|
|
742
|
+
const message = 'Response stream ended before response.completed'
|
|
743
|
+
yield {
|
|
744
|
+
type: EventType.RUN_ERROR,
|
|
745
|
+
runId: aguiState.runId,
|
|
746
|
+
model,
|
|
747
|
+
timestamp: Date.now(),
|
|
748
|
+
message,
|
|
749
|
+
code: 'incomplete-stream',
|
|
750
|
+
error: { message, code: 'incomplete-stream' },
|
|
751
|
+
}
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
|
|
644
755
|
if (accumulatedContent.length === 0) {
|
|
645
756
|
yield {
|
|
646
757
|
type: EventType.RUN_ERROR,
|
|
@@ -914,9 +1025,93 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
914
1025
|
// cuts off without a response.completed event.
|
|
915
1026
|
let runFinishedEmitted = false
|
|
916
1027
|
|
|
1028
|
+
const providerWebSearchCalls = new Map<
|
|
1029
|
+
string,
|
|
1030
|
+
{
|
|
1031
|
+
item: ResponseFunctionWebSearch
|
|
1032
|
+
index: number
|
|
1033
|
+
started: boolean
|
|
1034
|
+
}
|
|
1035
|
+
>()
|
|
1036
|
+
const webSearchCitations: Array<ResponseOutputText.URLCitation> = []
|
|
1037
|
+
|
|
917
1038
|
const adapterName = this.name
|
|
918
1039
|
const emitModel = () => model || options.model
|
|
919
1040
|
|
|
1041
|
+
const recordProviderWebSearchCall = (value: unknown, index: number) => {
|
|
1042
|
+
const item = readWebSearchCall(value)
|
|
1043
|
+
if (!item) return
|
|
1044
|
+
const existing = providerWebSearchCalls.get(item.id)
|
|
1045
|
+
if (existing) {
|
|
1046
|
+
existing.item = item
|
|
1047
|
+
existing.index = index
|
|
1048
|
+
} else {
|
|
1049
|
+
providerWebSearchCalls.set(item.id, {
|
|
1050
|
+
item,
|
|
1051
|
+
index,
|
|
1052
|
+
started: false,
|
|
1053
|
+
})
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const emitProviderWebSearchCalls = function* (
|
|
1058
|
+
assistantMessage?: ResponseOutputMessage,
|
|
1059
|
+
completedOnly = false,
|
|
1060
|
+
): Generator<AdapterYieldChunk> {
|
|
1061
|
+
for (const entry of providerWebSearchCalls.values()) {
|
|
1062
|
+
if (
|
|
1063
|
+
entry.started ||
|
|
1064
|
+
(completedOnly && entry.item.status !== 'completed')
|
|
1065
|
+
) {
|
|
1066
|
+
continue
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Citations belong to the whole response. Give a call only the
|
|
1070
|
+
// citations whose URL is in its own action.sources.
|
|
1071
|
+
// ponytail: exact URL match; normalize URLs if the two ever differ.
|
|
1072
|
+
const callUrls = new Set(
|
|
1073
|
+
entry.item.action.type === 'search'
|
|
1074
|
+
? (entry.item.action.sources ?? []).map((source) => source.url)
|
|
1075
|
+
: [],
|
|
1076
|
+
)
|
|
1077
|
+
const citations = webSearchCitations.filter((citation) =>
|
|
1078
|
+
callUrls.has(citation.url),
|
|
1079
|
+
)
|
|
1080
|
+
const metadata: OpenAIResponsesToolCallMetadata = {
|
|
1081
|
+
itemId: entry.item.id,
|
|
1082
|
+
providerExecuted: true,
|
|
1083
|
+
sources: collectWebSearchSources(entry.item, citations),
|
|
1084
|
+
openai: {
|
|
1085
|
+
webSearchCall: entry.item,
|
|
1086
|
+
urlCitations: citations,
|
|
1087
|
+
...(assistantMessage ? { assistantMessage } : {}),
|
|
1088
|
+
},
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
entry.started = true
|
|
1092
|
+
yield {
|
|
1093
|
+
type: EventType.TOOL_CALL_START,
|
|
1094
|
+
toolCallId: entry.item.id,
|
|
1095
|
+
toolCallName: 'web_search',
|
|
1096
|
+
toolName: 'web_search',
|
|
1097
|
+
parentMessageId: aguiState.messageId,
|
|
1098
|
+
model: emitModel(),
|
|
1099
|
+
timestamp: Date.now(),
|
|
1100
|
+
index: entry.index,
|
|
1101
|
+
metadata,
|
|
1102
|
+
}
|
|
1103
|
+
yield {
|
|
1104
|
+
type: EventType.TOOL_CALL_END,
|
|
1105
|
+
toolCallId: entry.item.id,
|
|
1106
|
+
toolCallName: 'web_search',
|
|
1107
|
+
toolName: 'web_search',
|
|
1108
|
+
model: emitModel(),
|
|
1109
|
+
timestamp: Date.now(),
|
|
1110
|
+
input: entry.item.action,
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
920
1115
|
const openReasoning = function* (): Generator<AdapterYieldChunk> {
|
|
921
1116
|
if (reasoningMessageId) return
|
|
922
1117
|
reasoningMessageId = generateId(adapterName)
|
|
@@ -1151,6 +1346,14 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1151
1346
|
chunk.type === 'response.failed' ||
|
|
1152
1347
|
chunk.type === 'response.incomplete'
|
|
1153
1348
|
) {
|
|
1349
|
+
if (chunk.type === 'response.incomplete') {
|
|
1350
|
+
for (const [index, item] of (
|
|
1351
|
+
chunk.response.output ?? []
|
|
1352
|
+
).entries()) {
|
|
1353
|
+
recordProviderWebSearchCall(item, index)
|
|
1354
|
+
}
|
|
1355
|
+
yield* emitProviderWebSearchCalls(undefined, true)
|
|
1356
|
+
}
|
|
1154
1357
|
yield* closeReasoning()
|
|
1155
1358
|
if (hasEmittedTextMessageStart) {
|
|
1156
1359
|
yield {
|
|
@@ -1363,6 +1566,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1363
1566
|
// handle output_item.added to capture function call metadata (name)
|
|
1364
1567
|
if (chunk.type === 'response.output_item.added') {
|
|
1365
1568
|
const item = chunk.item
|
|
1569
|
+
if (item.type === 'web_search_call') {
|
|
1570
|
+
recordProviderWebSearchCall(item, chunk.output_index)
|
|
1571
|
+
}
|
|
1366
1572
|
if (item.type === 'reasoning') {
|
|
1367
1573
|
captureReasoningItem(item)
|
|
1368
1574
|
yield* openReasoning()
|
|
@@ -1532,6 +1738,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1532
1738
|
// whose START + END therefore never fired).
|
|
1533
1739
|
if (chunk.type === 'response.output_item.done') {
|
|
1534
1740
|
const item = chunk.item
|
|
1741
|
+
if (item.type === 'web_search_call') {
|
|
1742
|
+
recordProviderWebSearchCall(item, chunk.output_index)
|
|
1743
|
+
}
|
|
1535
1744
|
if (item.type === 'reasoning') {
|
|
1536
1745
|
captureReasoningItem(item)
|
|
1537
1746
|
yield* openReasoning()
|
|
@@ -1616,12 +1825,29 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1616
1825
|
yield* userToolChunks(item, chunk.output_index, false)
|
|
1617
1826
|
}
|
|
1618
1827
|
|
|
1828
|
+
if (chunk.type === 'response.output_text.annotation.added') {
|
|
1829
|
+
const citation = readURLCitation(chunk.annotation)
|
|
1830
|
+
if (citation) webSearchCitations.push(citation)
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1619
1833
|
if (chunk.type === 'response.completed') {
|
|
1834
|
+
const responseOutput = Array.isArray(chunk.response.output)
|
|
1835
|
+
? chunk.response.output
|
|
1836
|
+
: []
|
|
1837
|
+
const assistantMessage = responseOutput.find(
|
|
1838
|
+
(item): item is ResponseOutputMessage => item.type === 'message',
|
|
1839
|
+
)
|
|
1840
|
+
for (const [index, item] of responseOutput.entries()) {
|
|
1841
|
+
if (item.type === 'web_search_call') {
|
|
1842
|
+
recordProviderWebSearchCall(item, index)
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1620
1846
|
// Some Responses API streams, notably reasoning-model responses,
|
|
1621
1847
|
// can omit text deltas and carry the successful final text only in
|
|
1622
1848
|
// response.completed.output. Recover that text so consumers never
|
|
1623
1849
|
// observe an empty result for a successful response.
|
|
1624
|
-
const completedText =
|
|
1850
|
+
const completedText = responseOutput
|
|
1625
1851
|
.flatMap((item) => (item.type === 'message' ? item.content : []))
|
|
1626
1852
|
.filter((part) => part.type === 'output_text')
|
|
1627
1853
|
.map((part) => part.text)
|
|
@@ -1651,10 +1877,8 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1651
1877
|
}
|
|
1652
1878
|
}
|
|
1653
1879
|
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
captureReasoningItem(item)
|
|
1657
|
-
}
|
|
1880
|
+
for (const item of responseOutput) {
|
|
1881
|
+
captureReasoningItem(item)
|
|
1658
1882
|
}
|
|
1659
1883
|
// output_text already closed the streamed reasoning item. A second
|
|
1660
1884
|
// openReasoning() would emit an empty thinking part. Attach the
|
|
@@ -1690,7 +1914,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1690
1914
|
// be silently dropped from the AG-UI stream while `hasFunctionCalls`
|
|
1691
1915
|
// below still routes the run's finishReason to 'tool_calls' —
|
|
1692
1916
|
// leaving consumers waiting for tool results they never saw start.
|
|
1693
|
-
for (const [outputIndex, item] of
|
|
1917
|
+
for (const [outputIndex, item] of responseOutput.entries()) {
|
|
1694
1918
|
if (item.type !== 'function_call' || !item.id) continue
|
|
1695
1919
|
const metadata = toolCallMetadata.get(item.id) ?? {
|
|
1696
1920
|
callId: item.call_id || item.id,
|
|
@@ -1766,8 +1990,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1766
1990
|
}
|
|
1767
1991
|
}
|
|
1768
1992
|
|
|
1769
|
-
|
|
1770
|
-
|
|
1993
|
+
yield* emitProviderWebSearchCalls(assistantMessage)
|
|
1994
|
+
|
|
1995
|
+
const shellOutputs = hostedShellCallIds(responseOutput)
|
|
1996
|
+
for (const [outputIndex, item] of responseOutput.entries()) {
|
|
1771
1997
|
if (
|
|
1772
1998
|
isRecord(item) &&
|
|
1773
1999
|
item.type === 'shell_call' &&
|
|
@@ -1798,7 +2024,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1798
2024
|
// The Responses API's incomplete_details.reason ('max_output_tokens'
|
|
1799
2025
|
// | 'content_filter') maps to the AG-UI finishReason vocabulary:
|
|
1800
2026
|
// max_output_tokens → 'length', content_filter → 'content_filter'.
|
|
1801
|
-
const hasFunctionCalls =
|
|
2027
|
+
const hasFunctionCalls = responseOutput.some((item) => {
|
|
1802
2028
|
if (!isRecord(item)) return false
|
|
1803
2029
|
if (item.type === 'function_call') return true
|
|
1804
2030
|
if (
|
|
@@ -1837,6 +2063,8 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1837
2063
|
finishReason,
|
|
1838
2064
|
}
|
|
1839
2065
|
runFinishedEmitted = true
|
|
2066
|
+
// Terminal event: do not wait for the HTTP body to close (#1445).
|
|
2067
|
+
return
|
|
1840
2068
|
}
|
|
1841
2069
|
|
|
1842
2070
|
if (chunk.type === 'error') {
|
|
@@ -1865,11 +2093,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1865
2093
|
}
|
|
1866
2094
|
}
|
|
1867
2095
|
|
|
1868
|
-
//
|
|
1869
|
-
//
|
|
1870
|
-
//
|
|
1871
|
-
// see a terminal event for every started run.
|
|
2096
|
+
// The stream ended without a terminal event (e.g. a truncated
|
|
2097
|
+
// connection). Completion was never confirmed, so this is not a
|
|
2098
|
+
// successful stop (#1447). The partial text was already emitted.
|
|
1872
2099
|
if (!runFinishedEmitted && aguiState.hasEmittedRunStarted) {
|
|
2100
|
+
yield* emitProviderWebSearchCalls(undefined, true)
|
|
1873
2101
|
yield* closeReasoning()
|
|
1874
2102
|
if (hasEmittedTextMessageStart) {
|
|
1875
2103
|
yield {
|
|
@@ -1879,17 +2107,14 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1879
2107
|
timestamp: Date.now(),
|
|
1880
2108
|
}
|
|
1881
2109
|
}
|
|
1882
|
-
|
|
1883
|
-
// RUN_FINISHED for truncated streams has no usage data, and AG-UI's
|
|
1884
|
-
// `RunFinishedEvent.usage` is optional without `| undefined` under
|
|
1885
|
-
// `exactOptionalPropertyTypes`.
|
|
2110
|
+
const message = 'Response stream ended before response.completed'
|
|
1886
2111
|
yield {
|
|
1887
|
-
type: EventType.
|
|
1888
|
-
runId: aguiState.runId,
|
|
1889
|
-
threadId: aguiState.threadId,
|
|
2112
|
+
type: EventType.RUN_ERROR,
|
|
1890
2113
|
model: model || options.model,
|
|
1891
2114
|
timestamp: Date.now(),
|
|
1892
|
-
|
|
2115
|
+
message,
|
|
2116
|
+
code: 'incomplete-stream',
|
|
2117
|
+
error: { message, code: 'incomplete-stream' },
|
|
1893
2118
|
}
|
|
1894
2119
|
}
|
|
1895
2120
|
} catch (error: unknown) {
|
|
@@ -1931,6 +2156,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
1931
2156
|
): Omit<ResponseCreateParams, 'stream'> {
|
|
1932
2157
|
const input = this.convertMessagesToInput(options.messages)
|
|
1933
2158
|
|
|
2159
|
+
if (this.strictFallbackWarning) {
|
|
2160
|
+
warnStrictFallback(options.tools, options.logger)
|
|
2161
|
+
}
|
|
1934
2162
|
const tools = options.tools
|
|
1935
2163
|
? convertToolsToResponsesFormat(
|
|
1936
2164
|
options.tools,
|
|
@@ -2107,8 +2335,25 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
2107
2335
|
|
|
2108
2336
|
// If the assistant message has tool calls, add them as FunctionToolCall objects
|
|
2109
2337
|
// Responses API expects arguments as a string (JSON string)
|
|
2338
|
+
let rawAssistantMessage: ResponseOutputMessage | undefined
|
|
2110
2339
|
if (message.toolCalls && message.toolCalls.length > 0) {
|
|
2111
2340
|
for (const toolCall of message.toolCalls) {
|
|
2341
|
+
const metadata = toolCall.metadata as
|
|
2342
|
+
| OpenAIResponsesToolCallMetadata
|
|
2343
|
+
| undefined
|
|
2344
|
+
if (metadata?.providerExecuted) {
|
|
2345
|
+
const webSearchCall = metadata.openai?.webSearchCall
|
|
2346
|
+
// The raw ws_/msg_ items carry ids that must pair with their
|
|
2347
|
+
// reasoning item, the same as function calls above. When they
|
|
2348
|
+
// cannot pair, skip them and send the plain message with no id.
|
|
2349
|
+
if (webSearchCall && canPairReasoning) {
|
|
2350
|
+
result.push(webSearchCall)
|
|
2351
|
+
rawAssistantMessage ??= metadata.openai?.assistantMessage
|
|
2352
|
+
}
|
|
2353
|
+
continue
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// Keep arguments as string for Responses API
|
|
2112
2357
|
const argumentsString =
|
|
2113
2358
|
typeof toolCall.function.arguments === 'string'
|
|
2114
2359
|
? toolCall.function.arguments
|
|
@@ -2129,9 +2374,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
2129
2374
|
result.push(userItem)
|
|
2130
2375
|
continue
|
|
2131
2376
|
}
|
|
2132
|
-
const itemId =
|
|
2133
|
-
toolCall.metadata as OpenAIResponsesToolCallMetadata | undefined
|
|
2134
|
-
)?.itemId
|
|
2377
|
+
const itemId = metadata?.itemId
|
|
2135
2378
|
|
|
2136
2379
|
result.push({
|
|
2137
2380
|
type: 'function_call',
|
|
@@ -2143,8 +2386,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
|
|
|
2143
2386
|
}
|
|
2144
2387
|
}
|
|
2145
2388
|
|
|
2146
|
-
|
|
2147
|
-
|
|
2389
|
+
if (rawAssistantMessage) {
|
|
2390
|
+
result.push(rawAssistantMessage)
|
|
2391
|
+
} else if (message.content) {
|
|
2392
|
+
// Add the assistant's text message if there is content
|
|
2148
2393
|
const contentStr = this.extractTextContent(message.content)
|
|
2149
2394
|
if (contentStr) {
|
|
2150
2395
|
result.push({
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export {
|
|
2
2
|
makeStructuredOutputCompatible,
|
|
3
3
|
makeStructuredOutputCompatibleWithMap,
|
|
4
|
+
warnStrictFallback,
|
|
4
5
|
} from './utils/schema-converter'
|
|
6
|
+
export type { OpenAIBaseTextAdapterOptions } from './utils/schema-converter'
|
|
5
7
|
export {
|
|
6
8
|
buildChatCompletionsUsage,
|
|
7
9
|
buildResponsesUsage,
|
package/src/usage.ts
CHANGED
|
@@ -8,8 +8,8 @@ import type OpenAI from 'openai'
|
|
|
8
8
|
*
|
|
9
9
|
* Shared by every provider that routes through
|
|
10
10
|
* {@link OpenAIBaseChatCompletionsTextAdapter} (OpenAI Chat Completions, Grok,
|
|
11
|
-
* Groq). Surfaces
|
|
12
|
-
* the provider reports them. Returns `undefined` when the provider reported no
|
|
11
|
+
* Groq). Surfaces cache read/write prompt tokens and reasoning/audio detail
|
|
12
|
+
* tokens when the provider reports them. Returns `undefined` when the provider reported no
|
|
13
13
|
* usage object, so callers omit the field rather than fabricating zeroed totals.
|
|
14
14
|
*/
|
|
15
15
|
export function buildChatCompletionsUsage(
|
|
@@ -33,10 +33,21 @@ export function buildChatCompletionsUsage(
|
|
|
33
33
|
: {}),
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// Moonshot (Kimi) also reports `cache_write_tokens` under
|
|
37
|
+
// `prompt_tokens_details`, and `cached_tokens` at the root of `usage`.
|
|
38
|
+
// The OpenAI SDK types have neither field.
|
|
39
|
+
const promptDetails = usage.prompt_tokens_details as
|
|
40
|
+
| (OpenAI.Completions.CompletionUsage.PromptTokensDetails & {
|
|
41
|
+
cache_write_tokens?: number
|
|
42
|
+
})
|
|
43
|
+
| undefined
|
|
44
|
+
const cachedTokens =
|
|
45
|
+
promptDetails?.cached_tokens ||
|
|
46
|
+
(usage as { cached_tokens?: number }).cached_tokens
|
|
37
47
|
const promptTokensDetails = {
|
|
38
|
-
...(
|
|
39
|
-
|
|
48
|
+
...(cachedTokens ? { cachedTokens } : {}),
|
|
49
|
+
...(promptDetails?.cache_write_tokens
|
|
50
|
+
? { cacheWriteTokens: promptDetails.cache_write_tokens }
|
|
40
51
|
: {}),
|
|
41
52
|
...(promptDetails?.audio_tokens
|
|
42
53
|
? { audioTokens: promptDetails.audio_tokens }
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { NullWideningMap } from '@tanstack/ai-utils'
|
|
2
|
+
import type { Tool } from '@tanstack/ai'
|
|
3
|
+
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* String `format` values accepted by OpenAI's strict Structured Outputs subset.
|
|
@@ -161,12 +163,65 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray<string> = [
|
|
|
161
163
|
* verdict that 400s the whole request.
|
|
162
164
|
*/
|
|
163
165
|
export function isStrictModeCompatible(schema: unknown): boolean {
|
|
164
|
-
return (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
return strictModeFallbackReason(schema) === undefined
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Why `schema` must be sent with `strict: false`, or `undefined` when it can be
|
|
171
|
+
* strict. Runs the same checks as `isStrictModeCompatible`, in the same order.
|
|
172
|
+
*/
|
|
173
|
+
export function strictModeFallbackReason(schema: unknown): string | undefined {
|
|
174
|
+
const keyword = findStrictUnsupportedKeyword(schema)
|
|
175
|
+
if (keyword !== undefined) {
|
|
176
|
+
return `schema uses ${keyword}, which strict mode does not support`
|
|
177
|
+
}
|
|
178
|
+
if (containsTypelessSchema(schema)) {
|
|
179
|
+
return 'schema has a node with no type (for example z.any() or z.unknown())'
|
|
180
|
+
}
|
|
181
|
+
if (containsOpenObject(schema)) {
|
|
182
|
+
return 'schema has an open object (for example z.record())'
|
|
183
|
+
}
|
|
184
|
+
if (containsUntrackableAnyOfWidening(schema)) {
|
|
185
|
+
return 'schema has an optional field inside an anyOf variant'
|
|
186
|
+
}
|
|
187
|
+
return undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Options that every `openai-base` text adapter accepts in its config. */
|
|
191
|
+
export interface OpenAIBaseTextAdapterOptions {
|
|
192
|
+
/**
|
|
193
|
+
* In development, warn once per tool that is sent with `strict: false`
|
|
194
|
+
* because its schema cannot be strict. Set to `false` to turn the warning
|
|
195
|
+
* off. It never runs when `NODE_ENV` is `production`. Default: `true`.
|
|
196
|
+
*/
|
|
197
|
+
strictFallbackWarning?: boolean
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ponytail: keyed on the Tool object, so a tool defined once warns once per
|
|
201
|
+
// process. Tools rebuilt per request (e.g. from MCP) warn once per request.
|
|
202
|
+
const warnedStrictFallback = new WeakSet<Tool>()
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Warn once per tool that is sent with `strict: false` because its schema
|
|
206
|
+
* cannot be strict. The tool still works, but the model is not held to the
|
|
207
|
+
* schema, so the developer must know (#1213).
|
|
208
|
+
*/
|
|
209
|
+
export function warnStrictFallback(
|
|
210
|
+
tools: Array<Tool> | undefined,
|
|
211
|
+
logger: InternalLogger,
|
|
212
|
+
): void {
|
|
213
|
+
// Development only. `process` is absent on some runtimes (e.g. Workers).
|
|
214
|
+
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'production')
|
|
215
|
+
return
|
|
216
|
+
for (const tool of tools ?? []) {
|
|
217
|
+
if (!tool.inputSchema || warnedStrictFallback.has(tool)) continue
|
|
218
|
+
const reason = strictModeFallbackReason(tool.inputSchema)
|
|
219
|
+
if (reason === undefined) continue
|
|
220
|
+
warnedStrictFallback.add(tool)
|
|
221
|
+
logger.warn(`tool "${tool.name}" sent with strict: false: ${reason}`, {
|
|
222
|
+
tool: tool.name,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
170
225
|
}
|
|
171
226
|
|
|
172
227
|
/**
|
|
@@ -219,16 +274,21 @@ function containsOpenObject(node: unknown): boolean {
|
|
|
219
274
|
return Object.values(schema).some(containsOpenObject)
|
|
220
275
|
}
|
|
221
276
|
|
|
222
|
-
function
|
|
277
|
+
function findStrictUnsupportedKeyword(node: unknown): string | undefined {
|
|
223
278
|
if (Array.isArray(node)) {
|
|
224
|
-
|
|
279
|
+
for (const item of node) {
|
|
280
|
+
const found = findStrictUnsupportedKeyword(item)
|
|
281
|
+
if (found !== undefined) return found
|
|
282
|
+
}
|
|
283
|
+
return undefined
|
|
225
284
|
}
|
|
226
|
-
if (node === null || typeof node !== 'object') return
|
|
285
|
+
if (node === null || typeof node !== 'object') return undefined
|
|
227
286
|
for (const [key, value] of Object.entries(node)) {
|
|
228
|
-
if (STRICT_UNSUPPORTED_KEYWORDS.includes(key)) return
|
|
229
|
-
|
|
287
|
+
if (STRICT_UNSUPPORTED_KEYWORDS.includes(key)) return key
|
|
288
|
+
const found = findStrictUnsupportedKeyword(value)
|
|
289
|
+
if (found !== undefined) return found
|
|
230
290
|
}
|
|
231
|
-
return
|
|
291
|
+
return undefined
|
|
232
292
|
}
|
|
233
293
|
|
|
234
294
|
/** A schema-position node that declares no type and so 400s strict mode. */
|