diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index 7659366eb..4c1764174 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -5,9 +5,8 @@ import { toRunErrorPayload, toRunErrorRawEvent, } from '@tanstack/ai/adapter-internals' -import { generateId } from '@tanstack/ai-utils' +import { generateId, makeStructuredOutputCompatible } from '@tanstack/ai-utils' import { extractRequestOptions } from '../internal/request-options' -import { makeStructuredOutputCompatible } from '../internal/schema-converter' import { convertFunctionToolToResponsesFormat } from '../internal/responses-tool-converter' import { isWebSearchTool } from '../tools/web-search-tool' import { isWebFetchTool } from '../tools/web-fetch-tool' diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 09df05b35..03f0d4ede 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -5,9 +5,8 @@ import { toRunErrorPayload, toRunErrorRawEvent, } from '@tanstack/ai/adapter-internals' -import { generateId } from '@tanstack/ai-utils' +import { generateId, makeStructuredOutputCompatible } from '@tanstack/ai-utils' import { extractRequestOptions } from '../internal/request-options' -import { makeStructuredOutputCompatible } from '../internal/schema-converter' import { convertToolsToProviderFormat } from '../tools' import { getOpenRouterApiKeyFromEnv } from '../utils' import { buildOpenRouterUsage } from '../usage' diff --git a/packages/ai-openrouter/src/internal/responses-tool-converter.ts b/packages/ai-openrouter/src/internal/responses-tool-converter.ts index 5df88fa41..50f3bbf91 100644 --- a/packages/ai-openrouter/src/internal/responses-tool-converter.ts +++ b/packages/ai-openrouter/src/internal/responses-tool-converter.ts @@ -1,4 +1,4 @@ -import { makeStructuredOutputCompatible } from './schema-converter' +import { makeStructuredOutputCompatible } from '@tanstack/ai-utils' import type { JSONSchema, Tool } from '@tanstack/ai' /** diff --git a/packages/ai-utils/src/index.ts b/packages/ai-utils/src/index.ts index 843d5eb37..34547aeeb 100644 --- a/packages/ai-utils/src/index.ts +++ b/packages/ai-utils/src/index.ts @@ -3,3 +3,4 @@ export { getApiKeyFromEnv } from './env' export { transformNullsToUndefined, undoNullWidening } from './transforms' export type { NullWideningMap } from './transforms' export { arrayBufferToBase64, base64ToArrayBuffer } from './base64' +export { makeStructuredOutputCompatible } from './schema-converter' diff --git a/packages/ai-openrouter/src/internal/schema-converter.ts b/packages/ai-utils/src/schema-converter.ts similarity index 77% rename from packages/ai-openrouter/src/internal/schema-converter.ts rename to packages/ai-utils/src/schema-converter.ts index 45630f4d1..3bfd58d5c 100644 --- a/packages/ai-openrouter/src/internal/schema-converter.ts +++ b/packages/ai-utils/src/schema-converter.ts @@ -1,7 +1,6 @@ /** * Transform a JSON schema to be compatible with OpenAI-style structured output requirements. - * The base requirements (which OpenRouter inherits because it routes to upstream OpenAI-compatible - * structured-output backends) are: + * The base requirements are: * - All properties must be in the `required` array * - Optional fields should have null added to their type union * - additionalProperties must be false for objects @@ -31,12 +30,17 @@ export function makeStructuredOutputCompatible( if (prop.type === 'object' && prop.properties) { prop = makeStructuredOutputCompatible(prop, prop.required || []) } else if (prop.type === 'array' && prop.items) { - prop = { - ...prop, - items: makeStructuredOutputCompatible( - prop.items, - prop.items.required || [], - ), + if (typeof prop.items !== 'boolean') { + prop = { + ...prop, + items: Array.isArray(prop.items) + ? prop.items.map((item: any) => + typeof item !== 'boolean' + ? makeStructuredOutputCompatible(item, item.required || []) + : item, + ) + : makeStructuredOutputCompatible(prop.items, prop.items.required || []), + } } } else if (prop.anyOf) { prop = makeStructuredOutputCompatible(prop, prop.required || []) @@ -69,10 +73,15 @@ export function makeStructuredOutputCompatible( } if (result.type === 'array' && result.items) { - result.items = makeStructuredOutputCompatible( - result.items, - result.items.required || [], - ) + if (typeof result.items !== 'boolean') { + result.items = Array.isArray(result.items) + ? result.items.map((item: any) => + typeof item !== 'boolean' + ? makeStructuredOutputCompatible(item, item.required || []) + : item, + ) + : makeStructuredOutputCompatible(result.items, result.items.required || []) + } } if (result.anyOf && Array.isArray(result.anyOf)) { diff --git a/packages/ai/src/activities/chat/tools/schema-converter.ts b/packages/ai/src/activities/chat/tools/schema-converter.ts index cda434bd1..c759d8670 100644 --- a/packages/ai/src/activities/chat/tools/schema-converter.ts +++ b/packages/ai/src/activities/chat/tools/schema-converter.ts @@ -146,19 +146,38 @@ 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 - properties[propName] = { - ...prop, - items: nestedItems ? nestedItems.schema : prop.items, - ...(wasOptional ? { type: ['array', 'null'] } : {}), + if (Array.isArray(prop.items)) { + const nestedItemsList = prop.items.map((item) => + typeof item !== 'boolean' + ? makeStructuredOutputCompatible(item, item.required || []) + : { schema: item, nullWidening: {} }, + ) + const schemas = nestedItemsList.map((n) => n.schema) + const maps: Array = nestedItemsList.map((n) => n.nullWidening ?? {}) + properties[propName] = { + ...prop, + items: schemas, + ...(wasOptional ? { type: ['array', 'null'] } : {}), + } + widenedHere = wasOptional + childMap = maps.some((m) => Object.keys(m).length > 0) + ? { items: maps } + : undefined + } else { + const items = prop.items + const nestedItems = typeof items !== 'boolean' + ? makeStructuredOutputCompatible(items, items.required || []) + : undefined + properties[propName] = { + ...prop, + items: nestedItems ? nestedItems.schema : prop.items, + ...(wasOptional ? { type: ['array', 'null'] } : {}), + } + widenedHere = wasOptional + childMap = nestedItems?.nullWidening + ? { items: nestedItems.nullWidening } + : undefined } - widenedHere = wasOptional - childMap = nestedItems?.nullWidening - ? { items: nestedItems.nullWidening } - : undefined } else if (wasOptional) { // Make optional fields nullable by adding null to the type. Mark // `widenedHere` only where we actually add `null`; a field already @@ -190,11 +209,21 @@ function makeStructuredOutputCompatible( // 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) { + if (Array.isArray(result.items)) { + const nestedItemsList = result.items.map((item) => + typeof item !== 'boolean' + ? makeStructuredOutputCompatible(item, item.required || []) + : { schema: item, nullWidening: {} }, + ) + result.items = nestedItemsList.map((n) => n.schema) + const maps: Array = nestedItemsList.map((n) => n.nullWidening ?? {}) + if (maps.some((m) => Object.keys(m).length > 0)) { + map.items = maps + } + } else if (typeof result.items !== 'boolean') { const nestedItems = makeStructuredOutputCompatible( - items, - items.required || [], + result.items, + result.items.required || [], ) result.items = nestedItems.schema if (nestedItems.nullWidening) map.items = nestedItems.nullWidening diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index bbc3bb94c..657182c09 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -70,7 +70,7 @@ export type ToolOutputState = 'output-available' | 'output-error' export interface JSONSchema { type?: string | Array properties?: Record - items?: JSONSchema | Array + items?: boolean | JSONSchema | Array required?: Array enum?: Array const?: unknown diff --git a/packages/ai/tests/chat-structured-output-null-normalization.test.ts b/packages/ai/tests/chat-structured-output-null-normalization.test.ts index 7d5a372bf..82bd2b598 100644 --- a/packages/ai/tests/chat-structured-output-null-normalization.test.ts +++ b/packages/ai/tests/chat-structured-output-null-normalization.test.ts @@ -293,6 +293,66 @@ describe('convertSchemaForStructuredOutput → undoNullWidening round trip', () expect('meta' in result).toBe(false) }) + it('handles a required array property with boolean items: false without crashing', () => { + // All required + no optional fields → nullWideningMap is undefined (pruned). + // The important thing is that it doesn't crash and preserves the boolean items. + const { jsonSchema } = convertSchemaForStructuredOutput({ + type: 'object', + properties: { tags: { type: 'array', items: false } }, + required: ['tags'], + }) + expect(jsonSchema).toBeDefined() + expect(jsonSchema!.properties?.tags).toBeDefined() + }) + + it('handles an optional array property with boolean items: false', () => { + const schema = { + type: 'object', + properties: { tags: { type: 'array', items: false } }, + required: [], + } as const + const { nullWideningMap } = convertSchemaForStructuredOutput(schema) + // The outer field is widened (optional → nullable), but items (boolean) is untouched + expect(nullWideningMap).toBeDefined() + const tagsMap = nullWideningMap!.properties?.tags as Record | undefined + expect(tagsMap).toBeDefined() + // widened: true because the field itself was made nullable + expect((tagsMap as Record).widened).toBe(true) + }) + + it('handles a required array property with items: true', () => { + // All required → nullWideningMap is undefined; just check no crash. + const { jsonSchema } = convertSchemaForStructuredOutput({ + type: 'object', + properties: { tags: { type: 'array', items: true } }, + required: ['tags'], + }) + expect(jsonSchema).toBeDefined() + }) + + it('handles tuple items containing a boolean element', () => { + const schema = { + type: 'object', + properties: { + mixed: { + type: 'array', + items: [ + false, + { type: 'object', properties: { val: { type: 'string' } }, required: ['val'] }, + ], + }, + }, + required: ['mixed'], + } as const + const { jsonSchema } = + convertSchemaForStructuredOutput(schema) + // Boolean items should be preserved + expect(jsonSchema!.properties?.mixed.items).toBeInstanceOf(Array) + const items = jsonSchema!.properties?.mixed.items as Array + expect(items[0]).toBe(false) + expect((items[1] as Record).additionalProperties).toBe(false) + }) + 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 diff --git a/packages/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index 0541788a5..63dbb3ea5 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -6,6 +6,8 @@ * the unsupported ones before sending. See: * https://platform.openai.com/docs/guides/structured-outputs#supported-properties */ +import { makeStructuredOutputCompatible as coerceCore } from '@tanstack/ai-utils' + const SUPPORTED_STRING_FORMATS = new Set([ 'date-time', 'time', @@ -61,7 +63,7 @@ export function makeStructuredOutputCompatible( schema: Record, originalRequired?: Array, ): Record { - return stripUnsupportedFormats(coerceStrictSchema(schema, originalRequired)) + return stripUnsupportedFormats(coerceCore(schema, originalRequired)) } /** @@ -178,81 +180,3 @@ function containsTypelessSchema(node: unknown): boolean { ) } -/** - * Strict-mode structural rewrite (required widening, nullability, - * additionalProperties). Kept private so the public entry point can apply the - * format-stripping pass exactly once over the fully-rewritten tree. - */ -function coerceStrictSchema( - schema: Record, - originalRequired?: Array, -): Record { - const result = { ...schema } - const required = - originalRequired ?? - (Array.isArray(result['required']) ? result['required'] : []) - - if (result.type === 'object' && result.properties) { - const properties = { ...result.properties } - const allPropertyNames = Object.keys(properties) - - for (const propName of allPropertyNames) { - let prop = properties[propName] - const wasOptional = !required.includes(propName) - - // Step 1: Recurse into nested structures - if (prop.type === 'object' && prop.properties) { - prop = coerceStrictSchema(prop, prop.required || []) - } else if (prop.type === 'array' && prop.items) { - prop = { - ...prop, - items: coerceStrictSchema(prop.items, prop.items.required || []), - } - } else if (prop.anyOf) { - prop = coerceStrictSchema(prop, prop.required || []) - } else if (prop.oneOf) { - throw new Error( - 'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types', - ) - } - - // Step 2: Apply null-widening for optional properties (after recursion) - if (wasOptional) { - if (prop.anyOf) { - // For anyOf, add a null variant if not already present - if (!prop.anyOf.some((v: any) => v.type === 'null')) { - prop = { ...prop, anyOf: [...prop.anyOf, { type: 'null' }] } - } - } else if (prop.type && !Array.isArray(prop.type)) { - prop = { ...prop, type: [prop.type, 'null'] } - } else if (Array.isArray(prop.type) && !prop.type.includes('null')) { - prop = { ...prop, type: [...prop.type, 'null'] } - } - } - - properties[propName] = prop - } - - result.properties = properties - result.required = allPropertyNames - result.additionalProperties = false - } - - if (result.type === 'array' && result.items) { - result.items = coerceStrictSchema(result.items, result.items.required || []) - } - - if (result.anyOf && Array.isArray(result.anyOf)) { - result.anyOf = result.anyOf.map((variant) => - coerceStrictSchema(variant, variant.required || []), - ) - } - - if (result.oneOf) { - throw new Error( - 'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types', - ) - } - - return result -} diff --git a/packages/openai-base/tests/schema-converter.test.ts b/packages/openai-base/tests/schema-converter.test.ts index b7cf600f1..504edfb41 100644 --- a/packages/openai-base/tests/schema-converter.test.ts +++ b/packages/openai-base/tests/schema-converter.test.ts @@ -336,6 +336,142 @@ describe('makeStructuredOutputCompatible', () => { // Original definition is untouched — the strip pass returns a fresh tree. expect(schema.properties.data.format).toBe('uri') }) + + it('should handle boolean items without crashing or recursing', () => { + const schema = { + type: 'object', + properties: { + list: { + type: 'array', + items: false, + }, + listTrue: { + type: 'array', + items: true, + }, + }, + required: ['list', 'listTrue'], + } + + const result: any = makeStructuredOutputCompatible(schema, ['list', 'listTrue']) + expect(result.properties.list.items).toBe(false) + expect(result.properties.listTrue.items).toBe(true) + + // Direct top-level array schema with boolean items + const topLevelArrayFalse = { + type: 'array', + items: false, + } + const resultFalse: any = makeStructuredOutputCompatible(topLevelArrayFalse) + expect(resultFalse.items).toBe(false) + + const topLevelArrayTrue = { + type: 'array', + items: true, + } + const resultTrue: any = makeStructuredOutputCompatible(topLevelArrayTrue) + expect(resultTrue.items).toBe(true) + + // Tuple-style arrays + const tupleSchema = { + type: 'object', + properties: { + tupleField: { + type: 'array', + items: [ + { type: 'string' }, + { type: 'object', properties: { nestedVal: { type: 'number' } } }, + ], + }, + }, + required: ['tupleField'], + } + const resultTuple: any = makeStructuredOutputCompatible(tupleSchema, ['tupleField']) + expect(resultTuple.properties.tupleField.items).toBeInstanceOf(Array) + expect(resultTuple.properties.tupleField.items[0].type).toBe('string') + expect(resultTuple.properties.tupleField.items[1].properties).toBeDefined() + expect(resultTuple.properties.tupleField.items[1].additionalProperties).toBe(false) + }) + + it('should handle tuple items with mixed boolean and object elements', () => { + const schema = { + type: 'object', + properties: { + mixed: { + type: 'array', + items: [ + false, + { type: 'object', properties: { val: { type: 'number' } }, required: ['val'] }, + true, + { type: 'string' }, + ], + }, + }, + required: ['mixed'], + } + const result: any = makeStructuredOutputCompatible(schema, ['mixed']) + expect(result.properties.mixed.items).toBeInstanceOf(Array) + expect(result.properties.mixed.items[0]).toBe(false) + expect(result.properties.mixed.items[1].additionalProperties).toBe(false) + expect(result.properties.mixed.items[1].properties.val.type).toBe('number') + expect(result.properties.mixed.items[2]).toBe(true) + expect(result.properties.mixed.items[3].type).toBe('string') + }) + + it('should handle tuple items where all elements are boolean', () => { + const schema = { + type: 'object', + properties: { + allBool: { + type: 'array', + items: [false, true, false], + }, + }, + required: ['allBool'], + } + const result: any = makeStructuredOutputCompatible(schema, ['allBool']) + expect(result.properties.allBool.items).toBeInstanceOf(Array) + expect(result.properties.allBool.items).toEqual([false, true, false]) + }) + + it('should handle top-level array with tuple items containing booleans', () => { + const topLevelTuple = { + type: 'array', + items: [false, { type: 'object', properties: { x: { type: 'string' } } }, true], + } + const result: any = makeStructuredOutputCompatible(topLevelTuple) + expect(result.items).toBeInstanceOf(Array) + expect(result.items[0]).toBe(false) + expect(result.items[1].additionalProperties).toBe(false) + expect(result.items[2]).toBe(true) + }) + + it('should handle top-level array with single object items', () => { + const topLevelArray = { + type: 'array', + items: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + } + const result: any = makeStructuredOutputCompatible(topLevelArray) + expect(result.items.additionalProperties).toBe(false) + expect(result.items.required).toEqual(['id']) + }) + + it('should handle optional array with boolean items without crashing on null-widening', () => { + const schema = { + type: 'object', + properties: { + optFalseItems: { type: 'array', items: false }, + optTrueItems: { type: 'array', items: true }, + }, + required: [], + } + const result: any = makeStructuredOutputCompatible(schema, []) + // Optional fields should have type widened to include null + expect(result.properties.optFalseItems.type).toEqual(['array', 'null']) + expect(result.properties.optFalseItems.items).toBe(false) + expect(result.properties.optTrueItems.type).toEqual(['array', 'null']) + expect(result.properties.optTrueItems.items).toBe(true) + }) }) describe('isStrictModeCompatible', () => {