Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-tuple-schema-coercion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/openai-base': patch
---

Preserve draft-07 tuple `items` arrays during strict schema conversion.

Send tools that use `prefixItems` with `strict: false`. OpenAI strict mode rejects that keyword.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ describe('chat-completions tool converter', () => {
expect(out.function.strict).toBe(false)
expect(out.function.parameters).toEqual(booleanSchemaTool.inputSchema)
})

it('keeps draft-07 tuple items as an array in strict mode', () => {
const out = convertFunctionToolToChatCompletionsFormat(bboxTupleTool)

expect(out.function.strict).toBe(true)
expect(out.function.parameters).toMatchObject({
properties: {
bbox: {
type: 'array',
items: bboxItems,
},
},
})
})

it('falls back from strict mode when prefixItems is present', () => {
const out = convertFunctionToolToChatCompletionsFormat(prefixItemsTool)

expect(out.function.strict).toBe(false)
expect(out.function.parameters).toEqual(prefixItemsTool.inputSchema)
})
})

const booleanSchemaInput = {
Expand All @@ -32,6 +53,43 @@ const booleanSchemaTool = {
inputSchema: booleanSchemaInput,
} satisfies Tool

const bboxItems = [
{ type: 'number', minimum: -180 },
{ type: 'number', minimum: -90 },
{ type: 'number', maximum: 180 },
{ type: 'number', maximum: 90 },
]

const bboxTupleTool: Tool = {
name: 'set_bbox',
description: 'Set a bounding box',
inputSchema: {
type: 'object',
properties: {
bbox: {
type: 'array',
items: bboxItems,
},
},
required: ['bbox'],
},
}

const prefixItemsTool: Tool = {
name: 'set_pair',
description: 'Set a prefix-item pair',
inputSchema: {
type: 'object',
properties: {
pair: {
type: 'array',
prefixItems: [{ type: 'string' }, { type: 'number' }],
},
},
required: ['pair'],
},
}

const anyOfOptionalVariantTool: Tool = {
name: 'store_variant',
description: 'Store a union variant',
Expand Down
46 changes: 32 additions & 14 deletions packages/openai-base/src/utils/schema-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export function makeStructuredOutputCompatibleWithMap(
* emit these.
*
* - `oneOf` / `allOf` / `not` — combinator keywords strict mode rejects
* - `prefixItems` — 2020-12 tuple keyword. openai-node's strict transform
* rejects it, so we send those tools with `strict: false` instead
* - `$ref` / `$defs` / `definitions` — references and definition pools whose
* object subschemas escape the `additionalProperties: false` normalization
* strict mode requires
Expand All @@ -112,6 +114,7 @@ const STRICT_UNSUPPORTED_KEYWORDS: ReadonlyArray<string> = [
'oneOf',
'allOf',
'not',
'prefixItems',
'$ref',
'$defs',
'definitions',
Expand Down Expand Up @@ -141,7 +144,7 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray<string> = [
* sent with `strict: false`. Two ways that happens:
*
* 1. It uses a JSON-Schema keyword outside OpenAI's strict subset anywhere in
* the tree (`oneOf`/`allOf`/`not`/`$ref`/`$defs`).
* the tree (`oneOf`/`allOf`/`not`/`prefixItems`/`$ref`/`$defs`).
* 2. It contains a *typeless* schema node — a property/items/anyOf entry with
* no `type` (nor `enum`/`const`/combinator), e.g. the `{}` that `z.any()`
* produces. Strict mode rejects typeless schemas.
Expand Down Expand Up @@ -331,15 +334,10 @@ function coerceStrictSchema(
prop = nested.schema
childMap = nested.nullWideningMap
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
} else if (isSchemaObject(prop) && prop.type === 'array' && prop.items) {
const nested = coerceStrictSchema(prop.items, prop.items.required || [])
prop = {
...prop,
items: nested.schema,
}
} else if (isSchemaObject(prop) && prop.type === 'array') {
const nested = coerceStrictSchema(prop, [])
prop = nested.schema
childMap = nested.nullWideningMap
? { items: nested.nullWideningMap }
: undefined
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
} else if (isSchemaObject(prop) && prop.anyOf) {
const nested = coerceStrictSchema(prop, prop.required || [])
Expand Down Expand Up @@ -411,12 +409,32 @@ function coerceStrictSchema(
}

if (result.type === 'array' && result.items) {
const nested = coerceStrictSchema(result.items, result.items.required || [])
result.items = nested.schema
if (nested.nullWideningMap) {
nullWideningMap.items = nested.nullWideningMap
if (Array.isArray(result.items)) {
const itemMaps: Array<NullWideningMap> = []
result.items = result.items.map((item) => {
if (!isSchemaObject(item)) {
itemMaps.push({})
return item
}
const nested = coerceStrictSchema(item, item.required || [])
itemMaps.push(nested.nullWideningMap ?? {})
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
return nested.schema
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (itemMaps.some((map) => Object.keys(map).length > 0)) {
nullWideningMap.items = itemMaps
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
const nested = coerceStrictSchema(
result.items,
result.items.required || [],
)
result.items = nested.schema
if (nested.nullWideningMap) {
nullWideningMap.items = nested.nullWideningMap
}
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
}
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
}

if (result.anyOf && Array.isArray(result.anyOf)) {
Expand Down
100 changes: 100 additions & 0 deletions packages/openai-base/tests/tuple-schema-coercion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
isStrictModeCompatible,
makeStructuredOutputCompatible,
makeStructuredOutputCompatibleWithMap,
} from '../src/utils/schema-converter'

const bboxItems = [
{ type: 'number', minimum: -180 },
{ type: 'number', minimum: -90 },
{ type: 'number', maximum: 180 },
{ type: 'number', maximum: 90 },
]

describe('draft-07 tuple items', () => {
it('keeps per-position bbox constraints instead of spreading items into a numeric-keyed object', () => {
const result = makeStructuredOutputCompatible({
type: 'object',
properties: {
bbox: {
type: 'array',
items: bboxItems,
additionalItems: false,
},
},
required: ['bbox'],
})

const items = result.properties.bbox.items
expect(Array.isArray(items)).toBe(true)
expect(items).toEqual(bboxItems)
expect(result.properties.bbox.additionalItems).toBe(false)
})

it('keeps a top-level tuple items array as an array', () => {
const result = makeStructuredOutputCompatible({
type: 'array',
items: bboxItems,
})

expect(Array.isArray(result.items)).toBe(true)
expect(result.items).toEqual(bboxItems)
})

it('keeps boolean tuple entries and aligns null-widening maps by index', () => {
const { schema, nullWideningMap } = makeStructuredOutputCompatibleWithMap({
type: 'array',
items: [
false,
{
type: 'object',
properties: { label: { type: 'string' } },
required: [],
},
],
})

expect(schema.items[0]).toBe(false)
expect(schema.items[1].additionalProperties).toBe(false)
expect(nullWideningMap).toEqual({
items: [{}, { properties: { label: { widened: true } } }],
})
})

it('still uses a single items map for a homogeneous array', () => {
const { nullWideningMap } = makeStructuredOutputCompatibleWithMap({
type: 'object',
properties: {
list: {
type: 'array',
items: {
type: 'object',
properties: { label: { type: 'string' } },
required: [],
},
},
},
required: ['list'],
})

expect(nullWideningMap).toEqual({
properties: {
list: {
items: { properties: { label: { widened: true } } },
},
},
})
})
})

describe('prefixItems strict gate', () => {
it('rejects prefixItems so OpenAI tools fall back to strict: false', () => {
expect(
isStrictModeCompatible({
type: 'array',
prefixItems: [{ type: 'string' }],
}),
).toBe(false)
})
})
Loading