Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
79eed91
chore: add tuple schema changeset
mikemikimike Aug 23, 2026
5956ba2
fix(openai-base): preserve tuple schema items
mikemikimike Aug 23, 2026
bc95af4
test(openai-base): cover tuple schema coercion
mikemikimike Aug 23, 2026
738a7dc
fix(openai-base): handle nested tuple items
mikemikimike Aug 23, 2026
bc22c0c
test(openai-base): cover nested tuples and prefix items
mikemikimike Aug 23, 2026
ff3affa
refactor(openai-base): unify array schema recursion
mikemikimike Aug 23, 2026
cbe3764
fix(openai-base): preserve tuple coercion metadata
mikemikimike Aug 23, 2026
fb89703
fix: format changeset frontmatter
mikemikimike Aug 23, 2026
f4d96b2
test(openai-base): preserve tuple widening metadata
mikemikimike Aug 23, 2026
36d32a3
fix(ai-utils): support prefix item widening maps
mikemikimike Aug 23, 2026
1e05eee
fix(openai-base): preserve boolean and separate tuple maps
mikemikimike Aug 23, 2026
f351ff8
test(openai-base): cover boolean and mixed tuple schemas
mikemikimike Aug 23, 2026
2b06893
fix(ai-utils): use valid type declaration newlines
mikemikimike Aug 23, 2026
eb29086
fix(ai-utils): replace literal newline token
mikemikimike Aug 23, 2026
3553b04
fix(ai): preserve all tuple producer metadata
mikemikimike Aug 23, 2026
b73c2d0
chore: include affected packages in changeset
mikemikimike Aug 23, 2026
58bb454
fix: preserve tuple metadata through schema conversion
mikemikimike Aug 23, 2026
1d20b60
test: keep tuple regression coverage focused
mikemikimike Aug 23, 2026
0d4c09d
fix: keep tuple widening maps positional
mikemikimike Aug 23, 2026
1821b80
test: assert tuple metadata stays indexed
mikemikimike Aug 23, 2026
aa6dace
test: cover boolean tuple map alignment
mikemikimike Aug 23, 2026
0a9f9d1
fix: preserve homogeneous array widening maps
mikemikimike Aug 23, 2026
eaa6dfc
fix: fall back from prefix item strict schemas
mikemikimike Aug 23, 2026
21b6496
test(openai-base): narrow tuple metadata map
mikemikimike Aug 26, 2026
25234cf
ci: apply automated fixes
autofix-ci[bot] Aug 26, 2026
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-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/ai': patch
'@tanstack/ai-utils': patch
'@tanstack/openai-base': patch
---

Preserve positional tuple schemas and their null-widening metadata during strict output conversion.
15 changes: 8 additions & 7 deletions packages/ai-utils/src/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export type NullWideningMap = {
widened?: boolean
properties?: Record<string, NullWideningMap>
items?: NullWideningMap | Array<NullWideningMap>
prefixItems?: Array<NullWideningMap>
}

function walk(value: unknown, map: NullWideningMap | undefined): unknown {
Expand All @@ -81,13 +82,13 @@ function walk(value: unknown, map: NullWideningMap | undefined): unknown {
if (typeof value !== 'object' || !map) return value

if (Array.isArray(value)) {
const { items } = map
if (!items) return value
// Tuple maps (`items: [a, b, …]`) describe each position separately;
// a single `items` map applies to every element.
return Array.isArray(items)
? value.map((item, index) => walk(item, items[index]))
: value.map((item) => walk(item, items))
const { items, prefixItems } = map
if (!items && !prefixItems) return value
return value.map((item, index) => {
const prefixMap = prefixItems?.[index]
const itemMap = prefixMap ?? (Array.isArray(items) ? items[index] : items)
return walk(item, itemMap)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const { properties } = map
Expand Down
91 changes: 74 additions & 17 deletions packages/ai/src/activities/chat/tools/schema-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,20 +145,51 @@ function makeStructuredOutputCompatible(
: nested.schema
widenedHere = wasOptional
childMap = nested.nullWidening
} else if (prop.type === 'array' && prop.items) {
const items = Array.isArray(prop.items) ? prop.items[0] : prop.items
const nestedItems = items
? makeStructuredOutputCompatible(items, items.required || [])
: undefined
} else if (prop.type === 'array') {
const itemSchemas = prop.items
? Array.isArray(prop.items)
? prop.items
: [prop.items]
: []
const nestedItems = itemSchemas.map((item) =>
typeof item === 'object' && item !== null
? makeStructuredOutputCompatible(item, item.required || [])
: undefined,
)
properties[propName] = {
...prop,
items: nestedItems ? nestedItems.schema : prop.items,
...(prop.items
? {
items: Array.isArray(prop.items)
? nestedItems.map(
(nested, index) => nested?.schema ?? itemSchemas[index],
)
: (nestedItems[0]?.schema ?? prop.items),
}
: {}),
...(wasOptional ? { type: ['array', 'null'] } : {}),
}
widenedHere = wasOptional
childMap = nestedItems?.nullWidening
? { items: nestedItems.nullWidening }
const itemMaps = nestedItems.map((nested) => nested?.nullWidening ?? {})
childMap = itemMaps.some((itemMap) => Object.keys(itemMap).length > 0)
? { items: Array.isArray(prop.items) ? itemMaps : itemMaps[0] }
: undefined
if (Array.isArray(prop.prefixItems)) {
const prefixItems = prop.prefixItems.map((item) =>
typeof item === 'object' && item !== null
? makeStructuredOutputCompatible(item, item.required || [])
: undefined,
)
properties[propName].prefixItems = prefixItems.map(
(nested, index) => nested?.schema ?? prop.prefixItems[index],
)
const prefixMaps = prefixItems.map(
(nested) => nested?.nullWidening ?? {},
)
if (prefixMaps.some((itemMap) => Object.keys(itemMap).length > 0)) {
childMap = { ...(childMap ?? {}), prefixItems: prefixMaps }
}
}
} else if (wasOptional) {
// Make optional fields nullable by adding null to the type. Mark
// `widenedHere` only where we actually add `null`; a field already
Expand Down Expand Up @@ -188,16 +219,42 @@ function makeStructuredOutputCompatible(
if (Object.keys(propertyMaps).length > 0) map.properties = propertyMaps
}

// Handle array types with object items
if (result.type === 'array' && result.items) {
const items = Array.isArray(result.items) ? result.items[0] : result.items
if (items) {
const nestedItems = makeStructuredOutputCompatible(
items,
items.required || [],
// Handle array item and prefix-item schemas recursively.
if (result.type === 'array') {
if (result.items) {
const itemSchemas = Array.isArray(result.items)
? result.items
: [result.items]
const nestedItems = itemSchemas.map((item) =>
typeof item === 'object' && item !== null
? makeStructuredOutputCompatible(item, item.required || [])
: undefined,
)
result.items = Array.isArray(result.items)
? nestedItems.map(
(nested, index) => nested?.schema ?? itemSchemas[index],
)
: (nestedItems[0]?.schema ?? result.items)
const itemMaps = nestedItems.map((nested) => nested?.nullWidening ?? {})
if (itemMaps.some((itemMap) => Object.keys(itemMap).length > 0)) {
map.items = Array.isArray(result.items) ? itemMaps : itemMaps[0]
}
}
if (Array.isArray(result.prefixItems)) {
const prefixSchemas = result.prefixItems.map((item) =>
typeof item === 'object' && item !== null
? makeStructuredOutputCompatible(item, item.required || [])
: undefined,
)
result.prefixItems = prefixSchemas.map(
(nested, index) => nested?.schema ?? result.prefixItems[index],
)
result.items = nestedItems.schema
if (nestedItems.nullWidening) map.items = nestedItems.nullWidening
const prefixMaps = prefixSchemas.map(
(nested) => nested?.nullWidening ?? {},
)
if (prefixMaps.some((itemMap) => Object.keys(itemMap).length > 0)) {
map.prefixItems = prefixMaps
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,20 @@ describe('convertSchemaForStructuredOutput β†’ undoNullWidening round trip', ()
expect('meta' in result).toBe(false)
})

it('un-widens every item in a homogeneous array property', () => {
const outputSchema = z.object({
items: z.array(z.object({ label: z.string().optional() })),
})

const { nullWideningMap } = convertSchemaForStructuredOutput(outputSchema)
expect(
undoNullWidening(
{ items: [{ label: null }, { label: null }, { label: 'kept' }] },
nullWideningMap,
),
).toEqual({ items: [{}, {}, { label: 'kept' }] })
})

it('keeps a genuine `.nullable()` null inside array items', () => {
// The widener does NOT touch `note` (it's `.nullable()`, not `.optional()`),
// so its null must survive even though it sits inside an array item β€” the
Expand Down
60 changes: 47 additions & 13 deletions packages/openai-base/src/utils/schema-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function makeStructuredOutputCompatibleWithMap(
* emit these.
*
* - `oneOf` / `allOf` / `not` β€” combinator keywords strict mode rejects
* - `prefixItems` β€” tuple keyword unsupported by OpenAI strict outputs
* - `$ref` / `$defs` / `definitions` β€” references and definition pools whose
* object subschemas escape the `additionalProperties: false` normalization
* strict mode requires
Expand All @@ -115,6 +116,7 @@ const STRICT_UNSUPPORTED_KEYWORDS: ReadonlyArray<string> = [
'$ref',
'$defs',
'definitions',
'prefixItems',
]

/**
Expand Down Expand Up @@ -331,15 +333,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 +408,49 @@ 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
}
}

if (Array.isArray(result.prefixItems)) {
const itemMaps: Array<NullWideningMap> = []
result.prefixItems = result.prefixItems.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
})
if (itemMaps.some((map) => Object.keys(map).length > 0)) {
nullWideningMap.prefixItems = itemMaps
}
hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
}

if (result.anyOf && Array.isArray(result.anyOf)) {
Expand Down
106 changes: 106 additions & 0 deletions packages/openai-base/tests/schema-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,113 @@ describe('makeStructuredOutputCompatible', () => {
})
})

it('preserves positional tuple item schemas', () => {
const items = [
{ type: 'number', minimum: -180 },
{ type: 'number', minimum: -90 },
]
const schema = { type: 'array', items }

const result: any = makeStructuredOutputCompatible(schema)

expect(result.items).toEqual(items)
})

it('preserves tuple items nested in object properties', () => {
const schema = {
type: 'object',
properties: {
bbox: { type: 'array', items: [{ type: 'number' }, { type: 'number' }] },
},
required: ['bbox'],
}

const result: any = makeStructuredOutputCompatible(schema)

expect(result.properties.bbox.items).toEqual(schema.properties.bbox.items)
})

it('preserves boolean tuple schemas and positional metadata', () => {
const schema = {
type: 'array',
items: [
false,
{
type: 'object',
properties: { item: { type: 'string' } },
required: [],
},
],
prefixItems: [
false,
{
type: 'object',
properties: { prefix: { type: 'string' } },
required: [],
},
],
additionalItems: false,
}

const { nullWideningMap } = makeStructuredOutputCompatibleWithMap(schema)
const result: any = makeStructuredOutputCompatible(schema)

expect(result.items[0]).toBe(false)
expect(result.items[1].additionalProperties).toBe(false)
expect(result.prefixItems[0]).toBe(false)
expect(result.prefixItems[1].additionalProperties).toBe(false)
expect(
Array.isArray(nullWideningMap?.items) && nullWideningMap.items[1],
).toEqual({
properties: { item: { widened: true } },
})
expect(nullWideningMap?.prefixItems?.[1]).toEqual({
properties: { prefix: { widened: true } },
})
expect(result.additionalItems).toBe(false)
})

it('preserves separate metadata for items and prefixItems', () => {
const schema = {
type: 'array',
items: [
{
type: 'object',
properties: { item: { type: 'string' } },
required: [],
},
],
prefixItems: [
{
type: 'object',
properties: { prefix: { type: 'string' } },
required: [],
},
],
}

const { nullWideningMap } = makeStructuredOutputCompatibleWithMap(schema)

expect(nullWideningMap?.items).toEqual([
{ properties: { item: { widened: true } } },
])
expect(nullWideningMap?.prefixItems).toEqual([
{ properties: { prefix: { widened: true } } },
])
const result: any = makeStructuredOutputCompatible(schema)
expect(result.prefixItems[0].additionalProperties).toBe(false)
})

describe('isStrictModeCompatible', () => {
it('rejects prefixItems for OpenAI strict outputs', () => {
expect(
isStrictModeCompatible({
type: 'array',
prefixItems: [{ type: 'string' }],
}),
).toBe(false)
})

it('returns true for a plain object schema in the strict subset', () => {
expect(
isStrictModeCompatible({
Expand Down
Loading
Loading