Skip to content

Commit 43b51f2

Browse files
fix(openai-base): keep tuple items arrays during strict conversion (#1210)
coerceStrictSchema spread draft-07 items arrays into numeric-keyed objects, so OpenAI never saw per-position constraints. Map each tuple position instead. Send prefixItems tools with strict: false. OpenAI strict mode rejects that keyword. Closes #1208 Co-authored-by: Alem Tuzlak <t.zlak@hotmail.com>
1 parent a7e0798 commit 43b51f2

4 files changed

Lines changed: 197 additions & 14 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/openai-base': patch
3+
---
4+
5+
Preserve draft-07 tuple `items` arrays during strict schema conversion.
6+
7+
Send tools that use `prefixItems` with `strict: false`. OpenAI strict mode rejects that keyword.

packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ describe('chat-completions tool converter', () => {
1717
expect(out.function.strict).toBe(false)
1818
expect(out.function.parameters).toEqual(booleanSchemaTool.inputSchema)
1919
})
20+
21+
it('keeps draft-07 tuple items as an array in strict mode', () => {
22+
const out = convertFunctionToolToChatCompletionsFormat(bboxTupleTool)
23+
24+
expect(out.function.strict).toBe(true)
25+
expect(out.function.parameters).toMatchObject({
26+
properties: {
27+
bbox: {
28+
type: 'array',
29+
items: bboxItems,
30+
},
31+
},
32+
})
33+
})
34+
35+
it('falls back from strict mode when prefixItems is present', () => {
36+
const out = convertFunctionToolToChatCompletionsFormat(prefixItemsTool)
37+
38+
expect(out.function.strict).toBe(false)
39+
expect(out.function.parameters).toEqual(prefixItemsTool.inputSchema)
40+
})
2041
})
2142

2243
const booleanSchemaInput = {
@@ -32,6 +53,43 @@ const booleanSchemaTool = {
3253
inputSchema: booleanSchemaInput,
3354
} satisfies Tool
3455

56+
const bboxItems = [
57+
{ type: 'number', minimum: -180 },
58+
{ type: 'number', minimum: -90 },
59+
{ type: 'number', maximum: 180 },
60+
{ type: 'number', maximum: 90 },
61+
]
62+
63+
const bboxTupleTool: Tool = {
64+
name: 'set_bbox',
65+
description: 'Set a bounding box',
66+
inputSchema: {
67+
type: 'object',
68+
properties: {
69+
bbox: {
70+
type: 'array',
71+
items: bboxItems,
72+
},
73+
},
74+
required: ['bbox'],
75+
},
76+
}
77+
78+
const prefixItemsTool: Tool = {
79+
name: 'set_pair',
80+
description: 'Set a prefix-item pair',
81+
inputSchema: {
82+
type: 'object',
83+
properties: {
84+
pair: {
85+
type: 'array',
86+
prefixItems: [{ type: 'string' }, { type: 'number' }],
87+
},
88+
},
89+
required: ['pair'],
90+
},
91+
}
92+
3593
const anyOfOptionalVariantTool: Tool = {
3694
name: 'store_variant',
3795
description: 'Store a union variant',

packages/openai-base/src/utils/schema-converter.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export function makeStructuredOutputCompatibleWithMap(
104104
* emit these.
105105
*
106106
* - `oneOf` / `allOf` / `not` — combinator keywords strict mode rejects
107+
* - `prefixItems` — 2020-12 tuple keyword. openai-node's strict transform
108+
* rejects it, so we send those tools with `strict: false` instead
107109
* - `$ref` / `$defs` / `definitions` — references and definition pools whose
108110
* object subschemas escape the `additionalProperties: false` normalization
109111
* strict mode requires
@@ -112,6 +114,7 @@ const STRICT_UNSUPPORTED_KEYWORDS: ReadonlyArray<string> = [
112114
'oneOf',
113115
'allOf',
114116
'not',
117+
'prefixItems',
115118
'$ref',
116119
'$defs',
117120
'definitions',
@@ -141,7 +144,7 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray<string> = [
141144
* sent with `strict: false`. Two ways that happens:
142145
*
143146
* 1. It uses a JSON-Schema keyword outside OpenAI's strict subset anywhere in
144-
* the tree (`oneOf`/`allOf`/`not`/`$ref`/`$defs`).
147+
* the tree (`oneOf`/`allOf`/`not`/`prefixItems`/`$ref`/`$defs`).
145148
* 2. It contains a *typeless* schema node — a property/items/anyOf entry with
146149
* no `type` (nor `enum`/`const`/combinator), e.g. the `{}` that `z.any()`
147150
* produces. Strict mode rejects typeless schemas.
@@ -331,15 +334,10 @@ function coerceStrictSchema(
331334
prop = nested.schema
332335
childMap = nested.nullWideningMap
333336
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
334-
} else if (isSchemaObject(prop) && prop.type === 'array' && prop.items) {
335-
const nested = coerceStrictSchema(prop.items, prop.items.required || [])
336-
prop = {
337-
...prop,
338-
items: nested.schema,
339-
}
337+
} else if (isSchemaObject(prop) && prop.type === 'array') {
338+
const nested = coerceStrictSchema(prop, [])
339+
prop = nested.schema
340340
childMap = nested.nullWideningMap
341-
? { items: nested.nullWideningMap }
342-
: undefined
343341
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
344342
} else if (isSchemaObject(prop) && prop.anyOf) {
345343
const nested = coerceStrictSchema(prop, prop.required || [])
@@ -411,12 +409,32 @@ function coerceStrictSchema(
411409
}
412410

413411
if (result.type === 'array' && result.items) {
414-
const nested = coerceStrictSchema(result.items, result.items.required || [])
415-
result.items = nested.schema
416-
if (nested.nullWideningMap) {
417-
nullWideningMap.items = nested.nullWideningMap
412+
if (Array.isArray(result.items)) {
413+
const itemMaps: Array<NullWideningMap> = []
414+
result.items = result.items.map((item) => {
415+
if (!isSchemaObject(item)) {
416+
itemMaps.push({})
417+
return item
418+
}
419+
const nested = coerceStrictSchema(item, item.required || [])
420+
itemMaps.push(nested.nullWideningMap ?? {})
421+
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
422+
return nested.schema
423+
})
424+
if (itemMaps.some((map) => Object.keys(map).length > 0)) {
425+
nullWideningMap.items = itemMaps
426+
}
427+
} else {
428+
const nested = coerceStrictSchema(
429+
result.items,
430+
result.items.required || [],
431+
)
432+
result.items = nested.schema
433+
if (nested.nullWideningMap) {
434+
nullWideningMap.items = nested.nullWideningMap
435+
}
436+
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
418437
}
419-
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
420438
}
421439

422440
if (result.anyOf && Array.isArray(result.anyOf)) {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
isStrictModeCompatible,
4+
makeStructuredOutputCompatible,
5+
makeStructuredOutputCompatibleWithMap,
6+
} from '../src/utils/schema-converter'
7+
8+
const bboxItems = [
9+
{ type: 'number', minimum: -180 },
10+
{ type: 'number', minimum: -90 },
11+
{ type: 'number', maximum: 180 },
12+
{ type: 'number', maximum: 90 },
13+
]
14+
15+
describe('draft-07 tuple items', () => {
16+
it('keeps per-position bbox constraints instead of spreading items into a numeric-keyed object', () => {
17+
const result = makeStructuredOutputCompatible({
18+
type: 'object',
19+
properties: {
20+
bbox: {
21+
type: 'array',
22+
items: bboxItems,
23+
additionalItems: false,
24+
},
25+
},
26+
required: ['bbox'],
27+
})
28+
29+
const items = result.properties.bbox.items
30+
expect(Array.isArray(items)).toBe(true)
31+
expect(items).toEqual(bboxItems)
32+
expect(result.properties.bbox.additionalItems).toBe(false)
33+
})
34+
35+
it('keeps a top-level tuple items array as an array', () => {
36+
const result = makeStructuredOutputCompatible({
37+
type: 'array',
38+
items: bboxItems,
39+
})
40+
41+
expect(Array.isArray(result.items)).toBe(true)
42+
expect(result.items).toEqual(bboxItems)
43+
})
44+
45+
it('keeps boolean tuple entries and aligns null-widening maps by index', () => {
46+
const { schema, nullWideningMap } = makeStructuredOutputCompatibleWithMap({
47+
type: 'array',
48+
items: [
49+
false,
50+
{
51+
type: 'object',
52+
properties: { label: { type: 'string' } },
53+
required: [],
54+
},
55+
],
56+
})
57+
58+
expect(schema.items[0]).toBe(false)
59+
expect(schema.items[1].additionalProperties).toBe(false)
60+
expect(nullWideningMap).toEqual({
61+
items: [{}, { properties: { label: { widened: true } } }],
62+
})
63+
})
64+
65+
it('still uses a single items map for a homogeneous array', () => {
66+
const { nullWideningMap } = makeStructuredOutputCompatibleWithMap({
67+
type: 'object',
68+
properties: {
69+
list: {
70+
type: 'array',
71+
items: {
72+
type: 'object',
73+
properties: { label: { type: 'string' } },
74+
required: [],
75+
},
76+
},
77+
},
78+
required: ['list'],
79+
})
80+
81+
expect(nullWideningMap).toEqual({
82+
properties: {
83+
list: {
84+
items: { properties: { label: { widened: true } } },
85+
},
86+
},
87+
})
88+
})
89+
})
90+
91+
describe('prefixItems strict gate', () => {
92+
it('rejects prefixItems so OpenAI tools fall back to strict: false', () => {
93+
expect(
94+
isStrictModeCompatible({
95+
type: 'array',
96+
prefixItems: [{ type: 'string' }],
97+
}),
98+
).toBe(false)
99+
})
100+
})

0 commit comments

Comments
 (0)