From f9fbefd02c38e38e3b118f5efc4f2e275eb0e746 Mon Sep 17 00:00:00 2001 From: Ethan Glover Date: Sat, 27 Jun 2026 15:29:05 -0500 Subject: [PATCH 1/4] fix(ai): accept boolean in items property of JSONSchema interface Effect's JSONSchema.make() produces JSON Schema objects where items can be false (valid per JSON Schema Draft-07+). The JSONSchema interface currently restricts items to JSONSchema | Array, causing a type assignability error under exactOptionalPropertyTypes: true. This change widens items to boolean | JSONSchema | Array, consistent with how additionalProperties and additionalItems are already typed. Co-authored-by: Junie --- packages/ai/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 01d086bc9daebdfeadc7821a922953225221998b Mon Sep 17 00:00:00 2001 From: Ethan Glover Date: Sat, 27 Jun 2026 15:39:36 -0500 Subject: [PATCH 2/4] fix(schema-converter): handle boolean items in structured output conversion Co-authored-by: Junie --- .../src/internal/schema-converter.ts | 20 +++++++++++------- .../activities/chat/tools/schema-converter.ts | 4 ++-- .../openai-base/src/utils/schema-converter.ts | 8 +++++-- .../tests/schema-converter.test.ts | 21 +++++++++++++++++++ 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/ai-openrouter/src/internal/schema-converter.ts b/packages/ai-openrouter/src/internal/schema-converter.ts index 45630f4d1..e51ade1ba 100644 --- a/packages/ai-openrouter/src/internal/schema-converter.ts +++ b/packages/ai-openrouter/src/internal/schema-converter.ts @@ -33,10 +33,12 @@ export function makeStructuredOutputCompatible( } else if (prop.type === 'array' && prop.items) { prop = { ...prop, - items: makeStructuredOutputCompatible( - prop.items, - prop.items.required || [], - ), + items: typeof prop.items === 'object' && prop.items !== null + ? makeStructuredOutputCompatible( + prop.items, + prop.items.required || [], + ) + : prop.items, } } else if (prop.anyOf) { prop = makeStructuredOutputCompatible(prop, prop.required || []) @@ -69,10 +71,12 @@ export function makeStructuredOutputCompatible( } if (result.type === 'array' && result.items) { - result.items = makeStructuredOutputCompatible( - result.items, - result.items.required || [], - ) + result.items = typeof result.items === 'object' && result.items !== null + ? makeStructuredOutputCompatible( + result.items, + result.items.required || [], + ) + : result.items } 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..9cf2f35ca 100644 --- a/packages/ai/src/activities/chat/tools/schema-converter.ts +++ b/packages/ai/src/activities/chat/tools/schema-converter.ts @@ -147,7 +147,7 @@ function makeStructuredOutputCompatible( childMap = nested.nullWidening } else if (prop.type === 'array' && prop.items) { const items = Array.isArray(prop.items) ? prop.items[0] : prop.items - const nestedItems = items + const nestedItems = items && typeof items === 'object' ? makeStructuredOutputCompatible(items, items.required || []) : undefined properties[propName] = { @@ -191,7 +191,7 @@ 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 (items && typeof items === 'object') { const nestedItems = makeStructuredOutputCompatible( items, items.required || [], diff --git a/packages/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index 0541788a5..4b1718eef 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -206,7 +206,9 @@ function coerceStrictSchema( } else if (prop.type === 'array' && prop.items) { prop = { ...prop, - items: coerceStrictSchema(prop.items, prop.items.required || []), + items: typeof prop.items === 'object' && prop.items !== null + ? coerceStrictSchema(prop.items, prop.items.required || []) + : prop.items, } } else if (prop.anyOf) { prop = coerceStrictSchema(prop, prop.required || []) @@ -239,7 +241,9 @@ function coerceStrictSchema( } if (result.type === 'array' && result.items) { - result.items = coerceStrictSchema(result.items, result.items.required || []) + result.items = typeof result.items === 'object' && result.items !== null + ? coerceStrictSchema(result.items, result.items.required || []) + : result.items } if (result.anyOf && Array.isArray(result.anyOf)) { diff --git a/packages/openai-base/tests/schema-converter.test.ts b/packages/openai-base/tests/schema-converter.test.ts index b7cf600f1..e63dc02c5 100644 --- a/packages/openai-base/tests/schema-converter.test.ts +++ b/packages/openai-base/tests/schema-converter.test.ts @@ -336,6 +336,27 @@ 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) + }) }) describe('isStrictModeCompatible', () => { From 6b153859852922c5e0fd2a4fdda50d4e41edbd77 Mon Sep 17 00:00:00 2001 From: Ethan Glover Date: Sat, 27 Jun 2026 16:05:07 -0500 Subject: [PATCH 3/4] fix(ai): add recursion guards and tuple handling for schema-converter array items Co-authored-by: Junie --- .../src/internal/schema-converter.ts | 38 +++++++++---- .../activities/chat/tools/schema-converter.ts | 57 +++++++++++++------ .../openai-base/src/utils/schema-converter.ts | 26 +++++++-- .../tests/schema-converter.test.ts | 35 ++++++++++++ 4 files changed, 122 insertions(+), 34 deletions(-) diff --git a/packages/ai-openrouter/src/internal/schema-converter.ts b/packages/ai-openrouter/src/internal/schema-converter.ts index e51ade1ba..1d6c3c55c 100644 --- a/packages/ai-openrouter/src/internal/schema-converter.ts +++ b/packages/ai-openrouter/src/internal/schema-converter.ts @@ -31,14 +31,22 @@ export function makeStructuredOutputCompatible( if (prop.type === 'object' && prop.properties) { prop = makeStructuredOutputCompatible(prop, prop.required || []) } else if (prop.type === 'array' && prop.items) { + let coercedItems = prop.items + if (Array.isArray(prop.items)) { + coercedItems = prop.items.map((item) => + typeof item === 'object' && item !== null + ? makeStructuredOutputCompatible(item, item.required || []) + : item, + ) + } else if (typeof prop.items === 'object' && prop.items !== null) { + coercedItems = makeStructuredOutputCompatible( + prop.items, + prop.items.required || [], + ) + } prop = { ...prop, - items: typeof prop.items === 'object' && prop.items !== null - ? makeStructuredOutputCompatible( - prop.items, - prop.items.required || [], - ) - : prop.items, + items: coercedItems, } } else if (prop.anyOf) { prop = makeStructuredOutputCompatible(prop, prop.required || []) @@ -71,12 +79,18 @@ export function makeStructuredOutputCompatible( } if (result.type === 'array' && result.items) { - result.items = typeof result.items === 'object' && result.items !== null - ? makeStructuredOutputCompatible( - result.items, - result.items.required || [], - ) - : result.items + if (Array.isArray(result.items)) { + result.items = result.items.map((item) => + typeof item === 'object' && item !== null + ? makeStructuredOutputCompatible(item, item.required || []) + : item, + ) + } else if (typeof result.items === 'object' && result.items !== null) { + result.items = 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 9cf2f35ca..ee8d668af 100644 --- a/packages/ai/src/activities/chat/tools/schema-converter.ts +++ b/packages/ai/src/activities/chat/tools/schema-converter.ts @@ -146,19 +146,36 @@ 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 && typeof items === 'object' - ? 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) => + makeStructuredOutputCompatible(item, item.required || []), + ) + 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 +207,19 @@ 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 && typeof items === 'object') { + if (Array.isArray(result.items)) { + const nestedItemsList = result.items.map((item) => + makeStructuredOutputCompatible(item, item.required || []), + ) + 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/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index 4b1718eef..0b1529a3d 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -204,11 +204,19 @@ function coerceStrictSchema( if (prop.type === 'object' && prop.properties) { prop = coerceStrictSchema(prop, prop.required || []) } else if (prop.type === 'array' && prop.items) { + let coercedItems = prop.items + if (Array.isArray(prop.items)) { + coercedItems = prop.items.map((item) => + typeof item === 'object' && item !== null + ? coerceStrictSchema(item, item.required || []) + : item, + ) + } else if (typeof prop.items === 'object' && prop.items !== null) { + coercedItems = coerceStrictSchema(prop.items, prop.items.required || []) + } prop = { ...prop, - items: typeof prop.items === 'object' && prop.items !== null - ? coerceStrictSchema(prop.items, prop.items.required || []) - : prop.items, + items: coercedItems, } } else if (prop.anyOf) { prop = coerceStrictSchema(prop, prop.required || []) @@ -241,9 +249,15 @@ function coerceStrictSchema( } if (result.type === 'array' && result.items) { - result.items = typeof result.items === 'object' && result.items !== null - ? coerceStrictSchema(result.items, result.items.required || []) - : result.items + if (Array.isArray(result.items)) { + result.items = result.items.map((item) => + typeof item === 'object' && item !== null + ? coerceStrictSchema(item, item.required || []) + : item, + ) + } else if (typeof result.items === 'object' && result.items !== null) { + result.items = coerceStrictSchema(result.items, result.items.required || []) + } } if (result.anyOf && Array.isArray(result.anyOf)) { diff --git a/packages/openai-base/tests/schema-converter.test.ts b/packages/openai-base/tests/schema-converter.test.ts index e63dc02c5..19a7eed88 100644 --- a/packages/openai-base/tests/schema-converter.test.ts +++ b/packages/openai-base/tests/schema-converter.test.ts @@ -356,6 +356,41 @@ describe('makeStructuredOutputCompatible', () => { 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) }) }) From b1dc9430e946a54efbf278534c212d5f67c9ebb9 Mon Sep 17 00:00:00 2001 From: Ethan Glover Date: Sat, 27 Jun 2026 16:48:09 -0500 Subject: [PATCH 4/4] refactor: extract shared makeStructuredOutputCompatible to @tanstack/ai-utils - Move the core structured-output schema converter into @tanstack/ai-utils so all three consumers (openai-base, ai-openrouter, ai) share one implementation. - Simplify boolean items guards to use ypeof !== 'boolean' inline checks. - Remove duplicate ai-openrouter/src/internal/schema-converter.ts barrel file and import directly from @tanstack/ai-utils. - Add test coverage for boolean and tuple-style items in both openai-base and ai. Reduces total code across the three packages by ~90 lines. --- .../src/adapters/responses-text.ts | 3 +- packages/ai-openrouter/src/adapters/text.ts | 3 +- .../src/internal/responses-tool-converter.ts | 2 +- packages/ai-utils/src/index.ts | 1 + .../src}/schema-converter.ts | 49 ++++----- .../activities/chat/tools/schema-converter.ts | 8 +- ...ructured-output-null-normalization.test.ts | 60 +++++++++++ .../openai-base/src/utils/schema-converter.ts | 100 +----------------- .../tests/schema-converter.test.ts | 80 ++++++++++++++ 9 files changed, 173 insertions(+), 133 deletions(-) rename packages/{ai-openrouter/src/internal => ai-utils/src}/schema-converter.ts (74%) 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 74% rename from packages/ai-openrouter/src/internal/schema-converter.ts rename to packages/ai-utils/src/schema-converter.ts index 1d6c3c55c..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,22 +30,17 @@ export function makeStructuredOutputCompatible( if (prop.type === 'object' && prop.properties) { prop = makeStructuredOutputCompatible(prop, prop.required || []) } else if (prop.type === 'array' && prop.items) { - let coercedItems = prop.items - if (Array.isArray(prop.items)) { - coercedItems = prop.items.map((item) => - typeof item === 'object' && item !== null - ? makeStructuredOutputCompatible(item, item.required || []) - : item, - ) - } else if (typeof prop.items === 'object' && prop.items !== null) { - coercedItems = makeStructuredOutputCompatible( - prop.items, - prop.items.required || [], - ) - } - prop = { - ...prop, - items: coercedItems, + 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 || []) @@ -79,17 +73,14 @@ export function makeStructuredOutputCompatible( } if (result.type === 'array' && result.items) { - if (Array.isArray(result.items)) { - result.items = result.items.map((item) => - typeof item === 'object' && item !== null - ? makeStructuredOutputCompatible(item, item.required || []) - : item, - ) - } else if (typeof result.items === 'object' && result.items !== null) { - 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 || []) } } diff --git a/packages/ai/src/activities/chat/tools/schema-converter.ts b/packages/ai/src/activities/chat/tools/schema-converter.ts index ee8d668af..c759d8670 100644 --- a/packages/ai/src/activities/chat/tools/schema-converter.ts +++ b/packages/ai/src/activities/chat/tools/schema-converter.ts @@ -148,7 +148,9 @@ function makeStructuredOutputCompatible( } else if (prop.type === 'array' && prop.items) { if (Array.isArray(prop.items)) { const nestedItemsList = prop.items.map((item) => - makeStructuredOutputCompatible(item, item.required || []), + 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 ?? {}) @@ -209,7 +211,9 @@ function makeStructuredOutputCompatible( if (result.type === 'array' && result.items) { if (Array.isArray(result.items)) { const nestedItemsList = result.items.map((item) => - makeStructuredOutputCompatible(item, item.required || []), + 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 ?? {}) 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 0b1529a3d..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,99 +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) { - let coercedItems = prop.items - if (Array.isArray(prop.items)) { - coercedItems = prop.items.map((item) => - typeof item === 'object' && item !== null - ? coerceStrictSchema(item, item.required || []) - : item, - ) - } else if (typeof prop.items === 'object' && prop.items !== null) { - coercedItems = coerceStrictSchema(prop.items, prop.items.required || []) - } - prop = { - ...prop, - items: coercedItems, - } - } 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) { - if (Array.isArray(result.items)) { - result.items = result.items.map((item) => - typeof item === 'object' && item !== null - ? coerceStrictSchema(item, item.required || []) - : item, - ) - } else if (typeof result.items === 'object' && result.items !== null) { - 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 19a7eed88..504edfb41 100644 --- a/packages/openai-base/tests/schema-converter.test.ts +++ b/packages/openai-base/tests/schema-converter.test.ts @@ -392,6 +392,86 @@ describe('makeStructuredOutputCompatible', () => { 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', () => {