From 4572ecd278dc6b55aa86591903877a41f67099c8 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:07:54 +1000 Subject: [PATCH] fix(ai): mark optional nested objects/arrays nullable under strict schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeStructuredOutputCompatible adds every property to required[] under forStructuredOutput: true, but optional nested objects/arrays were taking the recursive branches and never reaching the 'null'-wrap — producing a schema that OpenAI-style strict json_schema providers reject. Wrap transformed composites as type: ['object', 'null'] / ['array', 'null'] when wasOptional. Extends the OpenRouter regression test with the previously-untested array case and a new nested-object case. Fixes #483 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fix-strict-schema-optional-composites.md | 5 +++ .../tests/openrouter-adapter.test.ts | 45 ++++++++++++++++++- .../activities/chat/tools/schema-converter.ts | 10 ++++- 3 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-strict-schema-optional-composites.md 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)) {