Skip to content

Commit 633a3d9

Browse files
tombeckenhamclaude
andauthored
fix(ai): nullable wrap for optional nested objects/arrays in strict schema (#484)
fix(ai): mark optional nested objects/arrays nullable under strict schema 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) <noreply@anthropic.com>
1 parent 3681c9e commit 633a3d9

3 files changed

Lines changed: 57 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai': patch
3+
---
4+
5+
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.

packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -964,8 +964,11 @@ describe('OpenRouter structured output', () => {
964964
// Root object: all props required, additionalProperties: false
965965
expect(sentSchema.additionalProperties).toBe(false)
966966
expect(sentSchema.required).toEqual(['title', 'description', 'tags'])
967-
// Optional field is made nullable
967+
// Optional primitive is made nullable
968968
expect(sentSchema.properties.description.type).toEqual(['string', 'null'])
969+
// Optional array must also be made nullable (strict mode requires every
970+
// required property to be nullable if it was originally optional)
971+
expect(sentSchema.properties.tags.type).toEqual(['array', 'null'])
969972
// Nested array items: same transformation applied recursively
970973
expect(sentSchema.properties.tags.items.additionalProperties).toBe(false)
971974
expect(sentSchema.properties.tags.items.required).toEqual([
@@ -978,6 +981,46 @@ describe('OpenRouter structured output', () => {
978981
])
979982
})
980983

984+
it('makes optional nested objects nullable under strict mode', async () => {
985+
const nonStreamResponse = {
986+
choices: [{ message: { content: '{"id":"x","meta":null}' } }],
987+
}
988+
setupMockSdkClient([], nonStreamResponse)
989+
const adapter = createAdapter()
990+
991+
const outputSchema = {
992+
type: 'object',
993+
properties: {
994+
id: { type: 'string' },
995+
meta: {
996+
type: 'object',
997+
properties: {
998+
createdAt: { type: 'string' },
999+
},
1000+
required: ['createdAt'],
1001+
},
1002+
},
1003+
required: ['id'],
1004+
}
1005+
1006+
await adapter.structuredOutput({
1007+
chatOptions: {
1008+
model: 'openai/gpt-4o-mini',
1009+
messages: [{ role: 'user', content: 'Generate' }],
1010+
},
1011+
outputSchema,
1012+
})
1013+
1014+
const [rawParams] = mockSend.mock.calls[0]!
1015+
const sentSchema = rawParams.chatRequest.responseFormat.jsonSchema.schema
1016+
1017+
expect(sentSchema.required).toEqual(['id', 'meta'])
1018+
expect(sentSchema.properties.meta.type).toEqual(['object', 'null'])
1019+
// Inner object still strict-compatible
1020+
expect(sentSchema.properties.meta.additionalProperties).toBe(false)
1021+
expect(sentSchema.properties.meta.required).toEqual(['createdAt'])
1022+
})
1023+
9811024
it('flows through core chat() entrypoint with strict transformation', async () => {
9821025
// End-to-end via chat(): schema converted by the core, then made
9831026
// strict-compatible by the adapter before the SDK call.

packages/typescript/ai/src/activities/chat/tools/schema-converter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,24 @@ function makeStructuredOutputCompatible(
7575

7676
// Recursively transform nested objects/arrays
7777
if (prop.type === 'object' && prop.properties) {
78-
properties[propName] = makeStructuredOutputCompatible(
78+
const transformed = makeStructuredOutputCompatible(
7979
prop,
8080
prop.required || [],
8181
)
82+
properties[propName] = wasOptional
83+
? { ...transformed, type: ['object', 'null'] }
84+
: transformed
8285
} else if (prop.type === 'array' && prop.items) {
83-
properties[propName] = {
86+
const transformed = {
8487
...prop,
8588
items: makeStructuredOutputCompatible(
8689
prop.items,
8790
prop.items.required || [],
8891
),
8992
}
93+
properties[propName] = wasOptional
94+
? { ...transformed, type: ['array', 'null'] }
95+
: transformed
9096
} else if (wasOptional) {
9197
// Make optional fields nullable by adding null to the type
9298
if (prop.type && !Array.isArray(prop.type)) {

0 commit comments

Comments
 (0)