Skip to content

Strict structured output: optional nested objects and arrays produce invalid schemas #483

Description

@tombeckenham

Summary

makeStructuredOutputCompatible in packages/typescript/ai/src/activities/chat/tools/schema-converter.ts adds every property to required[] under forStructuredOutput: true, but forgets to mark optional nested objects and arrays as nullable. OpenAI-style strict json_schema providers (currently reached via @tanstack/ai-openrouter structuredOutput, with more adapters to follow) then reject the request with 400 invalid schema.

Reproduction

const outputSchema = z.object({
  title: z.string(),
  description: z.string().optional(), // ✅ serialized as ['string', 'null']
  tags: z.array(z.string()).optional(), // ❌ stays as 'array', but required[] lists it
})

After convertSchemaToJsonSchema(outputSchema, { forStructuredOutput: true }), tags.type is 'array', yet required now includes 'tags' — a strict json_schema violation. Same thing happens for any z.object({...}).optional() nested field.

Root cause

In the per-property loop of makeStructuredOutputCompatible, the three branches are mutually exclusive:

if (prop.type === 'object' && prop.properties) { /* recurse */ }
else if (prop.type === 'array' && prop.items) { /* recurse */ }
else if (wasOptional) { /* THIS is the only branch that adds 'null' */ }

Optional composites take one of the first two branches and never reach the nullability wrap, while the following result.required = allPropertyNames still forces them into required.

Fix

In the object / array branches, when wasOptional, wrap the transformed sub-schema as type: ['object', 'null'] / type: ['array', 'null'] — the same treatment primitives already get. Fix lives entirely in schema-converter.ts; regression coverage belongs in ai-openrouter/tests/openrouter-adapter.test.ts.

Found during review of PR #463.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions