@tanstack/openai-base 0.10.16 → 0.12.1

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.
@@ -0,0 +1,355 @@
1
+ import type { ResponseInputItem } from 'openai/resources/responses/responses'
2
+
3
+ /**
4
+ * OpenAI Responses tools the app must run.
5
+ * A container `shell` runs on the provider. `apply_patch`, `local_shell`,
6
+ * and `shell` with `environment.type: "local"` (or no environment) do not.
7
+ */
8
+ export type OpenAIUserToolName = 'shell' | 'apply_patch' | 'local_shell'
9
+
10
+ export interface OpenAIUserExecutedCall {
11
+ name: OpenAIUserToolName
12
+ callId: string
13
+ itemId?: string
14
+ input: Record<string, unknown>
15
+ maxOutputLength?: number | null
16
+ }
17
+
18
+ interface ToolCallLike {
19
+ id: string
20
+ function: { name: string; arguments: string }
21
+ metadata?: unknown
22
+ }
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
26
+ }
27
+
28
+ function stringList(value: unknown): Array<string> | null {
29
+ if (
30
+ !Array.isArray(value) ||
31
+ !value.every((entry) => typeof entry === 'string')
32
+ ) {
33
+ return null
34
+ }
35
+ return value
36
+ }
37
+
38
+ function stringEnv(value: unknown): Record<string, string> {
39
+ if (!isRecord(value)) return {}
40
+ const env: Record<string, string> = {}
41
+ for (const [key, entry] of Object.entries(value)) {
42
+ if (typeof entry === 'string') env[key] = entry
43
+ }
44
+ return env
45
+ }
46
+
47
+ function nullableNumber(value: unknown): number | null {
48
+ return typeof value === 'number' || value === null ? value : null
49
+ }
50
+
51
+ /**
52
+ * Hosted shell calls already include a `shell_call_output` in the same
53
+ * response. Those call ids must not pause the app for another run.
54
+ */
55
+ export function hostedShellCallIds(
56
+ output: ReadonlyArray<unknown>,
57
+ ): Set<string> {
58
+ const ids = new Set<string>()
59
+ for (const item of output) {
60
+ if (
61
+ isRecord(item) &&
62
+ item.type === 'shell_call_output' &&
63
+ typeof item.call_id === 'string'
64
+ ) {
65
+ ids.add(item.call_id)
66
+ }
67
+ }
68
+ return ids
69
+ }
70
+
71
+ export function readUserToolName(metadata: unknown): OpenAIUserToolName | null {
72
+ if (!isRecord(metadata)) return null
73
+ const name = metadata.openaiUserTool
74
+ if (name === 'shell' || name === 'apply_patch' || name === 'local_shell') {
75
+ return name
76
+ }
77
+ return null
78
+ }
79
+
80
+ function readItemId(metadata: unknown): string | undefined {
81
+ if (!isRecord(metadata)) return undefined
82
+ return typeof metadata.itemId === 'string' && metadata.itemId.length > 0
83
+ ? metadata.itemId
84
+ : undefined
85
+ }
86
+
87
+ function readMaxOutputLength(metadata: unknown): number | null | undefined {
88
+ if (!isRecord(metadata)) return undefined
89
+ const value = metadata.maxOutputLength
90
+ return typeof value === 'number' || value === null ? value : undefined
91
+ }
92
+
93
+ function applyPatchOperation(
94
+ value: unknown,
95
+ ):
96
+ | { type: 'create_file'; path: string; diff: string }
97
+ | { type: 'update_file'; path: string; diff: string }
98
+ | { type: 'delete_file'; path: string }
99
+ | null {
100
+ if (!isRecord(value) || typeof value.path !== 'string') return null
101
+ if (value.type === 'delete_file') {
102
+ return { type: 'delete_file', path: value.path }
103
+ }
104
+ if (
105
+ (value.type === 'create_file' || value.type === 'update_file') &&
106
+ typeof value.diff === 'string'
107
+ ) {
108
+ return { type: value.type, path: value.path, diff: value.diff }
109
+ }
110
+ return null
111
+ }
112
+
113
+ /**
114
+ * Read a user-run Responses output item.
115
+ * `bareShell` is true only once the full response is known. A shell call
116
+ * with no environment waits for that pass, so a hosted call that later
117
+ * carries `shell_call_output` is not asked of the app.
118
+ */
119
+ export function readUserExecutedCall(
120
+ item: unknown,
121
+ options: { bareShell: boolean },
122
+ ): OpenAIUserExecutedCall | null {
123
+ if (!isRecord(item) || typeof item.type !== 'string') return null
124
+ const callId = typeof item.call_id === 'string' ? item.call_id : ''
125
+ const itemId = typeof item.id === 'string' ? item.id : undefined
126
+
127
+ if (item.type === 'apply_patch_call') {
128
+ const operation = applyPatchOperation(item.operation)
129
+ if (!callId || !operation) return null
130
+ return {
131
+ name: 'apply_patch',
132
+ callId,
133
+ ...(itemId ? { itemId } : {}),
134
+ input: { operation },
135
+ }
136
+ }
137
+
138
+ if (item.type === 'local_shell_call') {
139
+ if (!isRecord(item.action)) return null
140
+ const command = stringList(item.action.command)
141
+ if (!callId || !command || command.length === 0) return null
142
+ return {
143
+ name: 'local_shell',
144
+ callId,
145
+ ...(itemId ? { itemId } : {}),
146
+ input: item.action,
147
+ }
148
+ }
149
+
150
+ if (item.type !== 'shell_call') return null
151
+ const environment = item.environment
152
+ if (isRecord(environment)) {
153
+ if (environment.type !== 'local') return null
154
+ } else if (!options.bareShell) {
155
+ return null
156
+ }
157
+ if (!callId || !isRecord(item.action)) return null
158
+ const commands = stringList(item.action.commands)
159
+ if (!commands || commands.length === 0) return null
160
+ const maxOutputLength = nullableNumber(item.action.max_output_length)
161
+ return {
162
+ name: 'shell',
163
+ callId,
164
+ ...(itemId ? { itemId } : {}),
165
+ input: {
166
+ commands,
167
+ max_output_length: maxOutputLength,
168
+ timeout_ms: nullableNumber(item.action.timeout_ms),
169
+ },
170
+ maxOutputLength,
171
+ }
172
+ }
173
+
174
+ function parseArguments(argumentsString: string): Record<string, unknown> {
175
+ try {
176
+ const parsed: unknown = JSON.parse(argumentsString)
177
+ return isRecord(parsed) ? parsed : {}
178
+ } catch {
179
+ return {}
180
+ }
181
+ }
182
+
183
+ function parseContent(content: unknown): unknown {
184
+ if (typeof content !== 'string') return content
185
+ try {
186
+ return JSON.parse(content) as unknown
187
+ } catch {
188
+ return content
189
+ }
190
+ }
191
+
192
+ /** Replay a user-run tool call as the Responses input item OpenAI expects. */
193
+ export function userToolRequestItem(
194
+ toolCall: ToolCallLike,
195
+ ): ResponseInputItem | null {
196
+ const name = readUserToolName(toolCall.metadata)
197
+ if (!name) return null
198
+ const args = parseArguments(toolCall.function.arguments)
199
+ const itemId = readItemId(toolCall.metadata)
200
+
201
+ if (name === 'apply_patch') {
202
+ const operation = applyPatchOperation(args.operation)
203
+ if (!operation) return null
204
+ return {
205
+ type: 'apply_patch_call',
206
+ call_id: toolCall.id,
207
+ status: 'completed',
208
+ operation,
209
+ ...(itemId ? { id: itemId } : {}),
210
+ }
211
+ }
212
+
213
+ if (name === 'local_shell') {
214
+ const command = stringList(args.command)
215
+ if (!command) return null
216
+ return {
217
+ type: 'local_shell_call',
218
+ id: itemId ?? toolCall.id,
219
+ call_id: toolCall.id,
220
+ status: 'completed',
221
+ action: {
222
+ type: 'exec',
223
+ command,
224
+ env: stringEnv(args.env),
225
+ timeout_ms: nullableNumber(args.timeout_ms),
226
+ ...(typeof args.user === 'string' || args.user === null
227
+ ? { user: args.user }
228
+ : {}),
229
+ ...(typeof args.working_directory === 'string' ||
230
+ args.working_directory === null
231
+ ? { working_directory: args.working_directory }
232
+ : {}),
233
+ },
234
+ }
235
+ }
236
+
237
+ const commands = stringList(args.commands)
238
+ if (!commands) return null
239
+ return {
240
+ type: 'shell_call',
241
+ call_id: toolCall.id,
242
+ status: 'completed',
243
+ action: {
244
+ commands,
245
+ max_output_length: nullableNumber(args.max_output_length),
246
+ timeout_ms: nullableNumber(args.timeout_ms),
247
+ },
248
+ ...(itemId ? { id: itemId } : {}),
249
+ }
250
+ }
251
+
252
+ type ShellOutcome = { type: 'exit'; exit_code: number } | { type: 'timeout' }
253
+
254
+ function shellOutcome(value: unknown): ShellOutcome {
255
+ if (isRecord(value) && value.type === 'timeout') return { type: 'timeout' }
256
+ const exitCode =
257
+ isRecord(value) && typeof value.exit_code === 'number' ? value.exit_code : 0
258
+ return { type: 'exit', exit_code: exitCode }
259
+ }
260
+
261
+ function shellEntry(value: unknown): {
262
+ stdout: string
263
+ stderr: string
264
+ outcome: ShellOutcome
265
+ } {
266
+ if (typeof value === 'string') {
267
+ return {
268
+ stdout: value,
269
+ stderr: '',
270
+ outcome: { type: 'exit', exit_code: 0 },
271
+ }
272
+ }
273
+ const record = isRecord(value) ? value : {}
274
+ return {
275
+ stdout: typeof record.stdout === 'string' ? record.stdout : '',
276
+ stderr: typeof record.stderr === 'string' ? record.stderr : '',
277
+ outcome: shellOutcome(record.outcome),
278
+ }
279
+ }
280
+
281
+ function shellOutputList(content: unknown): Array<{
282
+ stdout: string
283
+ stderr: string
284
+ outcome: ShellOutcome
285
+ }> {
286
+ const parsed = parseContent(content)
287
+ if (isRecord(parsed) && Array.isArray(parsed.output)) {
288
+ return parsed.output.map((entry) => shellEntry(entry))
289
+ }
290
+ if (isRecord(parsed) && ('stdout' in parsed || 'outcome' in parsed)) {
291
+ return [shellEntry(parsed)]
292
+ }
293
+ if (typeof parsed === 'string') return [shellEntry(parsed)]
294
+ return [shellEntry(JSON.stringify(parsed ?? ''))]
295
+ }
296
+
297
+ /** Replay the app's tool result as the matching Responses output item. */
298
+ export function userToolResultItem(
299
+ toolCall: ToolCallLike,
300
+ content: unknown,
301
+ ): ResponseInputItem | null {
302
+ const name = readUserToolName(toolCall.metadata)
303
+ if (!name) return null
304
+ const parsed = parseContent(content)
305
+
306
+ if (name === 'apply_patch') {
307
+ const record = isRecord(parsed) ? parsed : {}
308
+ const failed =
309
+ record.status === 'failed' ||
310
+ (record.status !== 'completed' && typeof record.error === 'string')
311
+ const output =
312
+ typeof record.output === 'string'
313
+ ? record.output
314
+ : typeof record.error === 'string'
315
+ ? record.error
316
+ : typeof parsed === 'string'
317
+ ? parsed
318
+ : undefined
319
+ return {
320
+ type: 'apply_patch_call_output',
321
+ call_id: toolCall.id,
322
+ status: failed ? 'failed' : 'completed',
323
+ ...(output !== undefined ? { output } : {}),
324
+ }
325
+ }
326
+
327
+ if (name === 'local_shell') {
328
+ const output =
329
+ typeof parsed === 'string'
330
+ ? parsed
331
+ : isRecord(parsed) && typeof parsed.output === 'string'
332
+ ? parsed.output
333
+ : JSON.stringify(parsed ?? '')
334
+ return {
335
+ type: 'local_shell_call_output',
336
+ id: toolCall.id,
337
+ output,
338
+ status: 'completed',
339
+ }
340
+ }
341
+
342
+ const record = isRecord(parsed) ? parsed : {}
343
+ const fromResult = nullableNumber(record.max_output_length)
344
+ const fromCall = readMaxOutputLength(toolCall.metadata)
345
+ const maxOutputLength =
346
+ record.max_output_length !== undefined ? fromResult : fromCall
347
+ return {
348
+ type: 'shell_call_output',
349
+ call_id: toolCall.id,
350
+ output: shellOutputList(content),
351
+ ...(maxOutputLength !== undefined
352
+ ? { max_output_length: maxOutputLength }
353
+ : {}),
354
+ }
355
+ }
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. */