From 0a70023b67bbf9a9ce7c6b0930b94487f97f2e95 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 27 Aug 2026 14:23:59 +0200 Subject: [PATCH] fix(ai): preserve tuple items during structured output conversion The converter kept only items[0] for draft-07 tuples, so later positions were dropped. Map every tuple position and keep a single widening map for homogeneous arrays so undoNullWidening still applies to every element. --- .changeset/ai-tuple-item-maps.md | 7 ++ .../activities/chat/tools/schema-converter.ts | 47 ++++++++----- .../tests/tuple-array-null-widening.test.ts | 67 +++++++++++++++++++ 3 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 .changeset/ai-tuple-item-maps.md create mode 100644 packages/ai/tests/tuple-array-null-widening.test.ts diff --git a/.changeset/ai-tuple-item-maps.md b/.changeset/ai-tuple-item-maps.md new file mode 100644 index 0000000000..ff6ec5c913 --- /dev/null +++ b/.changeset/ai-tuple-item-maps.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai': patch +--- + +Preserve draft-07 tuple `items` arrays during structured-output conversion. + +Keep a single widening map for homogeneous arrays so `undoNullWidening` applies it to every element. diff --git a/packages/ai/src/activities/chat/tools/schema-converter.ts b/packages/ai/src/activities/chat/tools/schema-converter.ts index cda434bd1d..35310b9070 100644 --- a/packages/ai/src/activities/chat/tools/schema-converter.ts +++ b/packages/ai/src/activities/chat/tools/schema-converter.ts @@ -99,6 +99,26 @@ function pruneMap(map: NullWideningMap): NullWideningMap | undefined { return Object.keys(map).length > 0 ? map : undefined } +function coerceArrayItems(items: JSONSchema | Array): { + schema: JSONSchema | Array + itemMap: NullWideningMap | Array | undefined +} { + if (Array.isArray(items)) { + const nested = items.map((item) => + makeStructuredOutputCompatible(item, item.required || []), + ) + const itemMaps = nested.map((entry) => entry.nullWidening ?? {}) + return { + schema: nested.map((entry) => entry.schema), + itemMap: itemMaps.some((entry) => Object.keys(entry).length > 0) + ? itemMaps + : undefined, + } + } + const nested = makeStructuredOutputCompatible(items, items.required || []) + return { schema: nested.schema, itemMap: nested.nullWidening } +} + /** * Transform a JSON schema to be compatible with OpenAI's structured output requirements. * OpenAI requires: @@ -146,18 +166,15 @@ function makeStructuredOutputCompatible( 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 + const nestedItems = coerceArrayItems(prop.items) properties[propName] = { ...prop, - items: nestedItems ? nestedItems.schema : prop.items, + items: nestedItems.schema, ...(wasOptional ? { type: ['array', 'null'] } : {}), } widenedHere = wasOptional - childMap = nestedItems?.nullWidening - ? { items: nestedItems.nullWidening } + childMap = nestedItems.itemMap + ? { items: nestedItems.itemMap } : undefined } else if (wasOptional) { // Make optional fields nullable by adding null to the type. Mark @@ -188,17 +205,13 @@ function makeStructuredOutputCompatible( if (Object.keys(propertyMaps).length > 0) map.properties = propertyMaps } - // Handle array types with object items + // Handle array item schemas. A tuple (`items: [a, b, …]`) keeps every + // position. A homogeneous schema stays a single items map so + // `undoNullWidening` applies it to every element. 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 || [], - ) - result.items = nestedItems.schema - if (nestedItems.nullWidening) map.items = nestedItems.nullWidening - } + const nestedItems = coerceArrayItems(result.items) + result.items = nestedItems.schema + if (nestedItems.itemMap) map.items = nestedItems.itemMap } return { schema: result, nullWidening: pruneMap(map) } diff --git a/packages/ai/tests/tuple-array-null-widening.test.ts b/packages/ai/tests/tuple-array-null-widening.test.ts new file mode 100644 index 0000000000..e75653b021 --- /dev/null +++ b/packages/ai/tests/tuple-array-null-widening.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { undoNullWidening } from '@tanstack/ai-utils' +import { convertSchemaForStructuredOutput } from '../src/activities/chat/tools/schema-converter' + +const bboxItems = [ + { type: 'number', minimum: -180 }, + { type: 'number', minimum: -90 }, + { type: 'number', maximum: 180 }, + { type: 'number', maximum: 90 }, +] + +describe('structured-output tuple items', () => { + it('keeps every positional schema on an array property', () => { + const { jsonSchema } = convertSchemaForStructuredOutput({ + type: 'object', + properties: { + bbox: { type: 'array', items: bboxItems }, + }, + required: ['bbox'], + }) + + expect(jsonSchema?.properties?.bbox?.items).toEqual(bboxItems) + }) + + it('records a positional map for a tuple of optional objects', () => { + const { jsonSchema, nullWideningMap } = convertSchemaForStructuredOutput({ + type: 'array', + items: [ + { + type: 'object', + properties: { west: { type: 'string' } }, + required: [], + }, + { + type: 'object', + properties: { east: { type: 'string' } }, + required: [], + }, + ], + }) + + expect(Array.isArray(jsonSchema?.items)).toBe(true) + expect(nullWideningMap).toEqual({ + items: [ + { properties: { west: { widened: true } } }, + { properties: { east: { widened: true } } }, + ], + }) + }) +}) + +describe('structured-output homogeneous array maps', () => { + it('un-widens every element, not only index 0', () => { + const outputSchema = z.object({ + list: z.array(z.object({ a: z.string().optional() })), + }) + + const { nullWideningMap } = convertSchemaForStructuredOutput(outputSchema) + expect( + undoNullWidening( + { list: [{ a: null }, { a: null }, { a: null }] }, + nullWideningMap, + ), + ).toEqual({ list: [{}, {}, {}] }) + }) +})