@tanstack/openai-base 0.10.1 → 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 +9 -1
- package/dist/esm/adapters/chat-completions-text.js +16 -5
- 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 +32 -6
- 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 { 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
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { NullWideningMap } from '@tanstack/ai-utils'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* String `format` values accepted by OpenAI's strict Structured Outputs subset.
|
|
3
5
|
* Any other format (e.g. "uri", "uri-reference", "regex") causes the API to
|
|
@@ -61,7 +63,35 @@ export function makeStructuredOutputCompatible(
|
|
|
61
63
|
schema: Record<string, any>,
|
|
62
64
|
originalRequired?: Array<string>,
|
|
63
65
|
): Record<string, any> {
|
|
64
|
-
return
|
|
66
|
+
return makeStructuredOutputCompatibleWithMap(schema, originalRequired).schema
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface StructuredOutputCompatibility {
|
|
70
|
+
schema: Record<string, any>
|
|
71
|
+
nullWideningMap: NullWideningMap | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface CoercedStrictSchema extends StructuredOutputCompatibility {
|
|
75
|
+
hasUntrackableAnyOfWidening: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Strict-schema conversion plus an exact map of the nullability introduced by
|
|
80
|
+
* that conversion. Consumers can pass provider output through
|
|
81
|
+
* `undoNullWidening` before validating it against the original schema.
|
|
82
|
+
*/
|
|
83
|
+
export function makeStructuredOutputCompatibleWithMap(
|
|
84
|
+
schema: Record<string, any>,
|
|
85
|
+
originalRequired?: Array<string>,
|
|
86
|
+
): StructuredOutputCompatibility {
|
|
87
|
+
const { schema: strictSchema, nullWideningMap } = coerceStrictSchema(
|
|
88
|
+
schema,
|
|
89
|
+
originalRequired,
|
|
90
|
+
)
|
|
91
|
+
return {
|
|
92
|
+
schema: stripUnsupportedFormats(strictSchema),
|
|
93
|
+
nullWideningMap,
|
|
94
|
+
}
|
|
65
95
|
}
|
|
66
96
|
|
|
67
97
|
/**
|
|
@@ -118,6 +148,9 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray<string> = [
|
|
|
118
148
|
* 3. It contains an open object schema. OpenAI strict mode requires objects to
|
|
119
149
|
* set `additionalProperties: false`, which would change the semantics of a
|
|
120
150
|
* free-form map rather than merely normalizing it.
|
|
151
|
+
* 4. An `anyOf` variant itself needs null widening. The inverse map is
|
|
152
|
+
* intentionally schema-blind, so it cannot select a variant without risking
|
|
153
|
+
* removal of a genuine nullable value accepted by another variant.
|
|
121
154
|
*
|
|
122
155
|
* Conservative by design: for (1) keywords are matched as object keys, so a
|
|
123
156
|
* property literally named e.g. `oneOf` also trips it. That only costs that one
|
|
@@ -128,10 +161,24 @@ export function isStrictModeCompatible(schema: unknown): boolean {
|
|
|
128
161
|
return (
|
|
129
162
|
!containsStrictUnsupportedKeyword(schema) &&
|
|
130
163
|
!containsTypelessSchema(schema) &&
|
|
131
|
-
!containsOpenObject(schema)
|
|
164
|
+
!containsOpenObject(schema) &&
|
|
165
|
+
!containsUntrackableAnyOfWidening(schema)
|
|
132
166
|
)
|
|
133
167
|
}
|
|
134
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Reports strict conversions whose synthesized nulls cannot be represented by
|
|
171
|
+
* the schema-blind inverse map. Optional `anyOf` wrappers remain supported:
|
|
172
|
+
* only widening introduced inside one of their variants triggers fallback.
|
|
173
|
+
*/
|
|
174
|
+
function containsUntrackableAnyOfWidening(schema: unknown): boolean {
|
|
175
|
+
if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
return coerceStrictSchema(schema as Record<string, any>)
|
|
179
|
+
.hasUntrackableAnyOfWidening
|
|
180
|
+
}
|
|
181
|
+
|
|
135
182
|
/**
|
|
136
183
|
* Reports object schemas that cannot be closed without changing their input
|
|
137
184
|
* semantics. Objects with `properties` and no explicit
|
|
@@ -184,8 +231,10 @@ function containsStrictUnsupportedKeyword(node: unknown): boolean {
|
|
|
184
231
|
/** A schema-position node that declares no type and so 400s strict mode. */
|
|
185
232
|
function isTypelessSchema(node: unknown): boolean {
|
|
186
233
|
if (node === null || typeof node !== 'object' || Array.isArray(node)) {
|
|
187
|
-
//
|
|
188
|
-
|
|
234
|
+
// JSON Schema permits bare boolean nodes; malformed inputs may contain
|
|
235
|
+
// other primitives. OpenAI's strict subset requires a declared type, so
|
|
236
|
+
// preserve the containing tool by sending it in non-strict mode.
|
|
237
|
+
return true
|
|
189
238
|
}
|
|
190
239
|
return !TYPE_INDICATOR_KEYWORDS.some((key) => key in node)
|
|
191
240
|
}
|
|
@@ -225,11 +274,42 @@ function containsTypelessSchema(node: unknown): boolean {
|
|
|
225
274
|
* additionalProperties). Kept private so the public entry point can apply the
|
|
226
275
|
* format-stripping pass exactly once over the fully-rewritten tree.
|
|
227
276
|
*/
|
|
277
|
+
function pruneMap(map: NullWideningMap): NullWideningMap | undefined {
|
|
278
|
+
return Object.keys(map).length > 0 ? map : undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isSchemaObject(schema: unknown): schema is Record<string, any> {
|
|
282
|
+
return typeof schema === 'object' && schema !== null && !Array.isArray(schema)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Whether every active JSON Schema constraint at this node admits null. */
|
|
286
|
+
function acceptsNull(schema: unknown): boolean {
|
|
287
|
+
if (schema === true) return true
|
|
288
|
+
if (!isSchemaObject(schema)) return false
|
|
289
|
+
|
|
290
|
+
if ('const' in schema && schema.const !== null) return false
|
|
291
|
+
if (Array.isArray(schema.enum) && !schema.enum.includes(null)) return false
|
|
292
|
+
|
|
293
|
+
if (typeof schema.type === 'string' && schema.type !== 'null') return false
|
|
294
|
+
if (Array.isArray(schema.type) && !schema.type.includes('null')) return false
|
|
295
|
+
|
|
296
|
+
if (
|
|
297
|
+
Array.isArray(schema.anyOf) &&
|
|
298
|
+
!schema.anyOf.some((variant: unknown) => acceptsNull(variant))
|
|
299
|
+
) {
|
|
300
|
+
return false
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return true
|
|
304
|
+
}
|
|
305
|
+
|
|
228
306
|
function coerceStrictSchema(
|
|
229
307
|
schema: Record<string, any>,
|
|
230
308
|
originalRequired?: Array<string>,
|
|
231
|
-
):
|
|
309
|
+
): CoercedStrictSchema {
|
|
232
310
|
const result = { ...schema }
|
|
311
|
+
const nullWideningMap: NullWideningMap = {}
|
|
312
|
+
let hasUntrackableAnyOfWidening = false
|
|
233
313
|
const required =
|
|
234
314
|
originalRequired ??
|
|
235
315
|
(Array.isArray(result['required']) ? result['required'] : [])
|
|
@@ -237,22 +317,36 @@ function coerceStrictSchema(
|
|
|
237
317
|
if (result.type === 'object' && result.properties) {
|
|
238
318
|
const properties = { ...result.properties }
|
|
239
319
|
const allPropertyNames = Object.keys(properties)
|
|
320
|
+
const propertyMaps: Record<string, NullWideningMap> = {}
|
|
240
321
|
|
|
241
322
|
for (const propName of allPropertyNames) {
|
|
242
323
|
let prop = properties[propName]
|
|
243
324
|
const wasOptional = !required.includes(propName)
|
|
325
|
+
let childMap: NullWideningMap | undefined
|
|
326
|
+
let widenedHere = false
|
|
244
327
|
|
|
245
328
|
// Step 1: Recurse into nested structures
|
|
246
|
-
if (prop.type === 'object' && prop.properties) {
|
|
247
|
-
|
|
248
|
-
|
|
329
|
+
if (isSchemaObject(prop) && prop.type === 'object' && prop.properties) {
|
|
330
|
+
const nested = coerceStrictSchema(prop, prop.required || [])
|
|
331
|
+
prop = nested.schema
|
|
332
|
+
childMap = nested.nullWideningMap
|
|
333
|
+
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
|
|
334
|
+
} else if (isSchemaObject(prop) && prop.type === 'array' && prop.items) {
|
|
335
|
+
const nested = coerceStrictSchema(prop.items, prop.items.required || [])
|
|
249
336
|
prop = {
|
|
250
337
|
...prop,
|
|
251
|
-
items:
|
|
338
|
+
items: nested.schema,
|
|
252
339
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
340
|
+
childMap = nested.nullWideningMap
|
|
341
|
+
? { items: nested.nullWideningMap }
|
|
342
|
+
: undefined
|
|
343
|
+
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
|
|
344
|
+
} else if (isSchemaObject(prop) && prop.anyOf) {
|
|
345
|
+
const nested = coerceStrictSchema(prop, prop.required || [])
|
|
346
|
+
prop = nested.schema
|
|
347
|
+
childMap = nested.nullWideningMap
|
|
348
|
+
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
|
|
349
|
+
} else if (isSchemaObject(prop) && prop.oneOf) {
|
|
256
350
|
throw new Error(
|
|
257
351
|
'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types',
|
|
258
352
|
)
|
|
@@ -260,34 +354,81 @@ function coerceStrictSchema(
|
|
|
260
354
|
|
|
261
355
|
// Step 2: Apply null-widening for optional properties (after recursion)
|
|
262
356
|
if (wasOptional) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
357
|
+
const originallyAcceptedNull = acceptsNull(prop)
|
|
358
|
+
|
|
359
|
+
// `type: [..., 'null']` alone does not make null valid when an enum or
|
|
360
|
+
// const still excludes it; strict decoding would be forced to emit the
|
|
361
|
+
// original literal instead of the synthetic omission marker.
|
|
362
|
+
if (isSchemaObject(prop) && 'const' in prop && prop.const !== null) {
|
|
363
|
+
const { const: constValue, ...withoutConst } = prop
|
|
364
|
+
prop = { ...withoutConst, enum: [constValue, null] }
|
|
365
|
+
} else if (
|
|
366
|
+
isSchemaObject(prop) &&
|
|
367
|
+
Array.isArray(prop.enum) &&
|
|
368
|
+
!prop.enum.includes(null)
|
|
369
|
+
) {
|
|
370
|
+
prop = { ...prop, enum: [...prop.enum, null] }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (isSchemaObject(prop) && prop.anyOf) {
|
|
374
|
+
// A genuine null branch can use type, enum, or const. Only add a
|
|
375
|
+
// provider omission marker when the original union rejected null.
|
|
376
|
+
if (!acceptsNull(prop)) {
|
|
266
377
|
prop = { ...prop, anyOf: [...prop.anyOf, { type: 'null' }] }
|
|
267
378
|
}
|
|
268
|
-
} else if (
|
|
379
|
+
} else if (
|
|
380
|
+
isSchemaObject(prop) &&
|
|
381
|
+
prop.type &&
|
|
382
|
+
!Array.isArray(prop.type)
|
|
383
|
+
) {
|
|
269
384
|
prop = { ...prop, type: [prop.type, 'null'] }
|
|
270
|
-
} else if (
|
|
385
|
+
} else if (
|
|
386
|
+
isSchemaObject(prop) &&
|
|
387
|
+
Array.isArray(prop.type) &&
|
|
388
|
+
!prop.type.includes('null')
|
|
389
|
+
) {
|
|
271
390
|
prop = { ...prop, type: [...prop.type, 'null'] }
|
|
272
391
|
}
|
|
392
|
+
|
|
393
|
+
widenedHere = !originallyAcceptedNull && acceptsNull(prop)
|
|
273
394
|
}
|
|
274
395
|
|
|
275
396
|
properties[propName] = prop
|
|
397
|
+
if (childMap || widenedHere) {
|
|
398
|
+
propertyMaps[propName] = {
|
|
399
|
+
...(childMap ?? {}),
|
|
400
|
+
...(widenedHere ? { widened: true } : {}),
|
|
401
|
+
}
|
|
402
|
+
}
|
|
276
403
|
}
|
|
277
404
|
|
|
278
405
|
result.properties = properties
|
|
279
406
|
result.required = allPropertyNames
|
|
280
407
|
result.additionalProperties = false
|
|
408
|
+
if (Object.keys(propertyMaps).length > 0) {
|
|
409
|
+
nullWideningMap.properties = propertyMaps
|
|
410
|
+
}
|
|
281
411
|
}
|
|
282
412
|
|
|
283
413
|
if (result.type === 'array' && result.items) {
|
|
284
|
-
|
|
414
|
+
const nested = coerceStrictSchema(result.items, result.items.required || [])
|
|
415
|
+
result.items = nested.schema
|
|
416
|
+
if (nested.nullWideningMap) {
|
|
417
|
+
nullWideningMap.items = nested.nullWideningMap
|
|
418
|
+
}
|
|
419
|
+
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
|
|
285
420
|
}
|
|
286
421
|
|
|
287
422
|
if (result.anyOf && Array.isArray(result.anyOf)) {
|
|
288
|
-
|
|
423
|
+
const variants = result.anyOf.map((variant) =>
|
|
289
424
|
coerceStrictSchema(variant, variant.required || []),
|
|
290
425
|
)
|
|
426
|
+
result.anyOf = variants.map((variant) => variant.schema)
|
|
427
|
+
hasUntrackableAnyOfWidening ||= variants.some(
|
|
428
|
+
(variant) =>
|
|
429
|
+
variant.nullWideningMap !== undefined ||
|
|
430
|
+
variant.hasUntrackableAnyOfWidening,
|
|
431
|
+
)
|
|
291
432
|
}
|
|
292
433
|
|
|
293
434
|
if (result.oneOf) {
|
|
@@ -296,5 +437,9 @@ function coerceStrictSchema(
|
|
|
296
437
|
)
|
|
297
438
|
}
|
|
298
439
|
|
|
299
|
-
return
|
|
440
|
+
return {
|
|
441
|
+
schema: result,
|
|
442
|
+
nullWideningMap: pruneMap(nullWideningMap),
|
|
443
|
+
hasUntrackableAnyOfWidening,
|
|
444
|
+
}
|
|
300
445
|
}
|