Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-strict-schema-optional-composites.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Loading