diff --git a/.changeset/fix-tuple-schema-coercion.md b/.changeset/fix-tuple-schema-coercion.md new file mode 100644 index 0000000000..c9137f99ce --- /dev/null +++ b/.changeset/fix-tuple-schema-coercion.md @@ -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. diff --git a/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts b/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts index 92ad2f8697..4f29be7156 100644 --- a/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts +++ b/packages/openai-base/src/adapters/chat-completions-tool-converter.test.ts @@ -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 = { @@ -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', diff --git a/packages/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index 7732b1ffac..a2467b3758 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -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 @@ -112,6 +114,7 @@ const STRICT_UNSUPPORTED_KEYWORDS: ReadonlyArray = [ 'oneOf', 'allOf', 'not', + 'prefixItems', '$ref', '$defs', 'definitions', @@ -141,7 +144,7 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray = [ * 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. @@ -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 || []) @@ -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 = [] + 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 + }) + if (itemMaps.some((map) => Object.keys(map).length > 0)) { + nullWideningMap.items = itemMaps + } + } 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)) { diff --git a/packages/openai-base/tests/tuple-schema-coercion.test.ts b/packages/openai-base/tests/tuple-schema-coercion.test.ts new file mode 100644 index 0000000000..5819ee91d1 --- /dev/null +++ b/packages/openai-base/tests/tuple-schema-coercion.test.ts @@ -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) + }) +})