diff --git a/.changeset/fix-strict-schema-optional-composites.md b/.changeset/fix-strict-schema-optional-composites.md new file mode 100644 index 000000000..9cc97c2b9 --- /dev/null +++ b/.changeset/fix-strict-schema-optional-composites.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': patch +--- + +fix(ai): make optional nested objects and arrays nullable under `forStructuredOutput`. Previously `makeStructuredOutputCompatible` recursed into optional composites and skipped the `'null'`-wrap, but still added them to `required[]`, producing a schema that OpenAI-style strict `json_schema` providers reject. Any schema with an optional `z.object({...}).optional()` or `z.array(...).optional()` field now serializes as `type: ['object','null']` / `['array','null']` and passes strict validation. diff --git a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts index 25dae0daf..1c7ac4ed9 100644 --- a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts @@ -964,8 +964,11 @@ describe('OpenRouter structured output', () => { // Root object: all props required, additionalProperties: false expect(sentSchema.additionalProperties).toBe(false) expect(sentSchema.required).toEqual(['title', 'description', 'tags']) - // Optional field is made nullable + // Optional primitive is made nullable expect(sentSchema.properties.description.type).toEqual(['string', 'null']) + // Optional array must also be made nullable (strict mode requires every + // required property to be nullable if it was originally optional) + expect(sentSchema.properties.tags.type).toEqual(['array', 'null']) // Nested array items: same transformation applied recursively expect(sentSchema.properties.tags.items.additionalProperties).toBe(false) expect(sentSchema.properties.tags.items.required).toEqual([ @@ -978,6 +981,46 @@ describe('OpenRouter structured output', () => { ]) }) + it('makes optional nested objects nullable under strict mode', async () => { + const nonStreamResponse = { + choices: [{ message: { content: '{"id":"x","meta":null}' } }], + } + setupMockSdkClient([], nonStreamResponse) + const adapter = createAdapter() + + const outputSchema = { + type: 'object', + properties: { + id: { type: 'string' }, + meta: { + type: 'object', + properties: { + createdAt: { type: 'string' }, + }, + required: ['createdAt'], + }, + }, + required: ['id'], + } + + await adapter.structuredOutput({ + chatOptions: { + model: 'openai/gpt-4o-mini', + messages: [{ role: 'user', content: 'Generate' }], + }, + outputSchema, + }) + + const [rawParams] = mockSend.mock.calls[0]! + const sentSchema = rawParams.chatRequest.responseFormat.jsonSchema.schema + + expect(sentSchema.required).toEqual(['id', 'meta']) + expect(sentSchema.properties.meta.type).toEqual(['object', 'null']) + // Inner object still strict-compatible + expect(sentSchema.properties.meta.additionalProperties).toBe(false) + expect(sentSchema.properties.meta.required).toEqual(['createdAt']) + }) + it('flows through core chat() entrypoint with strict transformation', async () => { // End-to-end via chat(): schema converted by the core, then made // strict-compatible by the adapter before the SDK call. diff --git a/packages/typescript/ai/src/activities/chat/tools/schema-converter.ts b/packages/typescript/ai/src/activities/chat/tools/schema-converter.ts index 735ef79ab..8f9d481eb 100644 --- a/packages/typescript/ai/src/activities/chat/tools/schema-converter.ts +++ b/packages/typescript/ai/src/activities/chat/tools/schema-converter.ts @@ -75,18 +75,24 @@ function makeStructuredOutputCompatible( // Recursively transform nested objects/arrays if (prop.type === 'object' && prop.properties) { - properties[propName] = makeStructuredOutputCompatible( + const transformed = makeStructuredOutputCompatible( prop, prop.required || [], ) + properties[propName] = wasOptional + ? { ...transformed, type: ['object', 'null'] } + : transformed } else if (prop.type === 'array' && prop.items) { - properties[propName] = { + const transformed = { ...prop, items: makeStructuredOutputCompatible( prop.items, prop.items.required || [], ), } + properties[propName] = wasOptional + ? { ...transformed, type: ['array', 'null'] } + : transformed } else if (wasOptional) { // Make optional fields nullable by adding null to the type if (prop.type && !Array.isArray(prop.type)) {