@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.
@@ -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 { makeStructuredOutputCompatibleWithMap } from '../utils/schema-converter'
15
+ import {
16
+ makeStructuredOutputCompatibleWithMap,
17
+ warnStrictFallback,
18
+ } from '../utils/schema-converter'
16
19
  import { createToolInputNormalizer } from '../utils/tool-input-normalizer'
17
- import type { StructuredOutputCompatibility } from '../utils/schema-converter'
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
- constructor(model: TModel, name: string, client: OpenAI) {
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
- continue
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 = chunk.response.output
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
- if (Array.isArray(chunk.response.output)) {
1655
- for (const item of chunk.response.output) {
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 chunk.response.output.entries()) {
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
- const shellOutputs = hostedShellCallIds(chunk.response.output)
1770
- for (const [outputIndex, item] of chunk.response.output.entries()) {
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 = chunk.response.output.some((item) => {
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
- // Synthetic terminal RUN_FINISHED if the stream ended without a
1869
- // response.completed event (e.g. truncated upstream connection). This
1870
- // mirrors the chat-completions adapter's behavior so consumers always
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
- // Omit `usage` entirely (vs `usage: undefined`) — the synthetic
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.RUN_FINISHED,
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
- finishReason: toolCallMetadata.size > 0 ? 'tool_calls' : 'stop',
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
- // Add the assistant's text message if there is content
2147
- if (message.content) {
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 cached prompt tokens and reasoning/audio detail tokens when
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
- const promptDetails = usage.prompt_tokens_details
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
- ...(promptDetails?.cached_tokens
39
- ? { cachedTokens: promptDetails.cached_tokens }
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
- !containsStrictUnsupportedKeyword(schema) &&
166
- !containsTypelessSchema(schema) &&
167
- !containsOpenObject(schema) &&
168
- !containsUntrackableAnyOfWidening(schema)
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 containsStrictUnsupportedKeyword(node: unknown): boolean {
277
+ function findStrictUnsupportedKeyword(node: unknown): string | undefined {
223
278
  if (Array.isArray(node)) {
224
- return node.some(containsStrictUnsupportedKeyword)
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 false
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 true
229
- if (containsStrictUnsupportedKeyword(value)) return true
287
+ if (STRICT_UNSUPPORTED_KEYWORDS.includes(key)) return key
288
+ const found = findStrictUnsupportedKeyword(value)
289
+ if (found !== undefined) return found
230
290
  }
231
- return false
291
+ return undefined
232
292
  }
233
293
 
234
294
  /** A schema-position node that declares no type and so 400s strict mode. */