Skip to content

fix(ai): nullable wrap for optional nested objects/arrays in strict schema#484

Merged
AlemTuzlak merged 1 commit intoTanStack:mainfrom
tombeckenham:fix/strict-schema-optional-composites
Apr 21, 2026
Merged

fix(ai): nullable wrap for optional nested objects/arrays in strict schema#484
AlemTuzlak merged 1 commit intoTanStack:mainfrom
tombeckenham:fix/strict-schema-optional-composites

Conversation

@tombeckenham
Copy link
Copy Markdown
Contributor

@tombeckenham tombeckenham commented Apr 20, 2026

Summary

  • Marks optional nested objects and arrays as type: ['object','null'] / ['array','null'] when convertSchemaToJsonSchema runs with forStructuredOutput: true, so OpenAI-style strict json_schema providers stop rejecting valid Zod/ArkType schemas.
  • Extends the existing OpenRouter regression test to cover the array case, adds a new case for optional nested objects.

Fixes #483. Split out of #463 since it's a pre-existing core bug exposed by the OpenRouter structured-output work that landed in #312.

Test plan

  • pnpm --filter @tanstack/ai-openrouter test:lib — 40/40 pass
  • pnpm --filter @tanstack/ai test:lib — 652/652 pass
  • pnpm --filter @tanstack/ai test:types

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed handling of optional nested composite schemas in structured output to properly serialize with nullable types, preventing strict JSON Schema providers from rejecting them.
  • Tests

    • Added tests for optional array and nested object nullability.
  • Chores

    • Added changeset marking patch release.

…hema

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 TanStack#483

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

📝 Walkthrough

Walkthrough

The pull request fixes a bug in makeStructuredOutputCompatible where optional nested composite schemas (objects and arrays) were not being marked as nullable when generating JSON schemas for strict json_schema providers. The fix ensures these optional composites receive the same nullable treatment (['object','null'] / ['array','null']) as optional primitives.

Changes

Cohort / File(s) Summary
Changeset Documentation
.changeset/fix-strict-schema-optional-composites.md
Documents the patch release fixing nullable handling for optional nested composite schemas in forStructuredOutput.
Test Coverage
packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts
Extended schema transformation assertions to verify optional arrays and nested objects are now correctly marked as nullable (['array','null'], ['object','null']) in strict mode.
Core Implementation
packages/typescript/ai/src/activities/chat/tools/schema-converter.ts
Modified makeStructuredOutputCompatible to compute transformed schemas for nested objects/arrays first, then conditionally wrap optional composites with nullable union types.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

A rabbit hops through schemas bright,
Where nulls now dance in arrays tight,
Optional objects, nested deep,
Finally marked for keepsake's keep! 🐰
No more strict rejections here,
Your composites are crystal clear. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix: making optional nested objects/arrays nullable in strict schema mode.
Description check ✅ Passed The PR description covers changes made, references linked issues, and includes test results, though checklist items are not marked as complete.
Linked Issues check ✅ Passed Changes implement the fix for #483 by wrapping optional nested objects/arrays as nullable composites and adding regression tests for both array and object cases.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing #483: schema converter fix, test updates, and a changeset file—no out-of-scope changes detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Apr 20, 2026

View your CI Pipeline Execution ↗ for commit 4572ecd

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 5m 2s View ↗
nx run-many --targets=build --exclude=examples/** ✅ Succeeded 1m 40s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-20 21:18:17 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 20, 2026

Open in StackBlitz

@tanstack/ai

npm i https://pkg.pr.new/@tanstack/ai@484

@tanstack/ai-anthropic

npm i https://pkg.pr.new/@tanstack/ai-anthropic@484

@tanstack/ai-client

npm i https://pkg.pr.new/@tanstack/ai-client@484

@tanstack/ai-code-mode

npm i https://pkg.pr.new/@tanstack/ai-code-mode@484

@tanstack/ai-code-mode-skills

npm i https://pkg.pr.new/@tanstack/ai-code-mode-skills@484

@tanstack/ai-devtools-core

npm i https://pkg.pr.new/@tanstack/ai-devtools-core@484

@tanstack/ai-elevenlabs

npm i https://pkg.pr.new/@tanstack/ai-elevenlabs@484

@tanstack/ai-event-client

npm i https://pkg.pr.new/@tanstack/ai-event-client@484

@tanstack/ai-fal

npm i https://pkg.pr.new/@tanstack/ai-fal@484

@tanstack/ai-gemini

npm i https://pkg.pr.new/@tanstack/ai-gemini@484

@tanstack/ai-grok

npm i https://pkg.pr.new/@tanstack/ai-grok@484

@tanstack/ai-groq

npm i https://pkg.pr.new/@tanstack/ai-groq@484

@tanstack/ai-isolate-cloudflare

npm i https://pkg.pr.new/@tanstack/ai-isolate-cloudflare@484

@tanstack/ai-isolate-node

npm i https://pkg.pr.new/@tanstack/ai-isolate-node@484

@tanstack/ai-isolate-quickjs

npm i https://pkg.pr.new/@tanstack/ai-isolate-quickjs@484

@tanstack/ai-ollama

npm i https://pkg.pr.new/@tanstack/ai-ollama@484

@tanstack/ai-openai

npm i https://pkg.pr.new/@tanstack/ai-openai@484

@tanstack/ai-openrouter

npm i https://pkg.pr.new/@tanstack/ai-openrouter@484

@tanstack/ai-preact

npm i https://pkg.pr.new/@tanstack/ai-preact@484

@tanstack/ai-react

npm i https://pkg.pr.new/@tanstack/ai-react@484

@tanstack/ai-react-ui

npm i https://pkg.pr.new/@tanstack/ai-react-ui@484

@tanstack/ai-solid

npm i https://pkg.pr.new/@tanstack/ai-solid@484

@tanstack/ai-solid-ui

npm i https://pkg.pr.new/@tanstack/ai-solid-ui@484

@tanstack/ai-svelte

npm i https://pkg.pr.new/@tanstack/ai-svelte@484

@tanstack/ai-vue

npm i https://pkg.pr.new/@tanstack/ai-vue@484

@tanstack/ai-vue-ui

npm i https://pkg.pr.new/@tanstack/ai-vue-ui@484

@tanstack/preact-ai-devtools

npm i https://pkg.pr.new/@tanstack/preact-ai-devtools@484

@tanstack/react-ai-devtools

npm i https://pkg.pr.new/@tanstack/react-ai-devtools@484

@tanstack/solid-ai-devtools

npm i https://pkg.pr.new/@tanstack/solid-ai-devtools@484

commit: 4572ecd

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/typescript/ai/src/activities/chat/tools/schema-converter.ts (1)

60-128: Consider aligning the sibling ai-openai implementation.

packages/typescript/ai-openai/src/utils/schema-converter.ts (makeOpenAIStructuredOutputCompatible) has the same structural bug this PR fixes in core — optional nested objects/arrays are recursed into but never get the null union, while still being force-listed in required. Since OpenAI is the strictest validator this change targets, it's worth applying the same two-step (recurse → wrap) there in a follow-up to keep the two converters behaviorally consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai/src/activities/chat/tools/schema-converter.ts` around
lines 60 - 128, The ai-openai converter's makeOpenAIStructuredOutputCompatible
has the same bug: when a nested object/array property is optional it is recursed
into but not wrapped with a null union, yet still gets added to required; update
makeOpenAIStructuredOutputCompatible to follow the two-step pattern (first
recursively transform nested prop/items, then—if the property was optional—wrap
the transformed result by adding 'null' to its type array or making type
['object','null'] / ['array','null']), and ensure the function sets the parent's
required to allPropertyNames and additionalProperties=false like the core
converter.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/typescript/ai/src/activities/chat/tools/schema-converter.ts`:
- Around line 60-128: The ai-openai converter's
makeOpenAIStructuredOutputCompatible has the same bug: when a nested
object/array property is optional it is recursed into but not wrapped with a
null union, yet still gets added to required; update
makeOpenAIStructuredOutputCompatible to follow the two-step pattern (first
recursively transform nested prop/items, then—if the property was optional—wrap
the transformed result by adding 'null' to its type array or making type
['object','null'] / ['array','null']), and ensure the function sets the parent's
required to allPropertyNames and additionalProperties=false like the core
converter.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1d1b0430-21e4-42e3-8110-8102a1ac06c6

📥 Commits

Reviewing files that changed from the base of the PR and between 3681c9e and 4572ecd.

📒 Files selected for processing (3)
  • .changeset/fix-strict-schema-optional-composites.md
  • packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts
  • packages/typescript/ai/src/activities/chat/tools/schema-converter.ts

@AlemTuzlak AlemTuzlak merged commit 633a3d9 into TanStack:main Apr 21, 2026
8 checks passed
@github-actions github-actions Bot mentioned this pull request Apr 21, 2026
AlemTuzlak added a commit that referenced this pull request Apr 22, 2026
PR #484 on main added a new structuredOutput test that didn't pass a
logger in chatOptions. After merging main into this branch, the new
required logger field on TextOptions (from the debug-logging PR) tripped
the typecheck. Pass the silent testLogger already defined at the top of
the file to match the pattern used by every other test in this file.
AlemTuzlak added a commit that referenced this pull request Apr 22, 2026
* feat(ai): add Logger, DebugCategories, DebugConfig, DebugOption types

* fix(ai): relocate logger type tests to tests/ for vitest discovery

* docs(ai): add JSDoc to logger types and tighten type tests

* feat(ai): add ConsoleLogger default logger implementation

* feat(ai): add InternalLogger with per-category filtering and prefix

* feat(ai): add resolveDebugOption normalizing DebugOption to InternalLogger

* feat(ai): export Logger types publicly and InternalLogger via /adapter-internals subpath

* feat(ai): thread InternalLogger through TextEngine, MiddlewareRunner, and TextOptions

* feat(ai-openai): emit request/provider/errors logs via InternalLogger in text adapter

* feat(ai-anthropic): emit request/provider/errors logs via InternalLogger in text adapter

* feat(ai-gemini): emit request/provider/errors logs via InternalLogger in text adapter

* feat(ai-grok): emit request/provider/errors logs via InternalLogger in text adapter

* feat(ai-groq): emit request/provider/errors logs via InternalLogger in text adapter

* feat(ai-ollama): emit request/provider/errors logs via InternalLogger in text adapter

* feat(ai-openrouter): emit request/provider/errors logs via InternalLogger in text adapter

* fix(ai): remove redundant top-level logger from StructuredOutputOptions and normalize provider log key

* feat(ai-openai): emit logging events in summarize adapter

* feat(ai-anthropic): emit logging events in summarize adapter

* feat(ai-gemini): emit logging events in summarize adapter

* feat(ai-grok): emit logging events in summarize adapter

* feat(ai-ollama): emit logging events in summarize adapter

* feat(ai-openrouter): emit logging events in summarize adapter

* feat(ai): resolve debug option and thread InternalLogger through summarize()

* feat(ai-openai): emit logging events in image adapter

* feat(ai-gemini): emit logging events in image adapter

* feat(ai-grok): emit logging events in image adapter

* feat(ai-openrouter): emit logging events in image adapter

* feat(ai-fal): emit logging events in image adapter

* feat(ai): resolve debug option and thread InternalLogger through generateImage()

* feat(ai-openai): emit logging events in video adapter

* feat(ai-fal): emit logging events in video adapter

* feat(ai): resolve debug option and thread InternalLogger through generateVideo()

* feat(ai-openai): emit logging events in tts adapter

* feat(ai-gemini): emit logging events in tts adapter

* feat(ai): resolve debug option and thread InternalLogger through generateSpeech()

* feat(ai-openai): emit logging events in transcription adapter

* feat(ai): resolve debug option and thread InternalLogger through generateTranscription()

* feat(ai-openai): emit logging events in realtime adapter

* feat(ai-elevenlabs): emit logging events in realtime adapter

* test(ai): add integration tests for debug logging across activities

* fix(ai): remove redundant nullish coalescing on non-nullable result fields

* test(e2e): debug logging emits expected prefixes for chat()

* docs(advanced): add debug logging guide

* docs(observability): cross-link to debug logging guide

* docs(middleware): cross-link to debug logging guide

* docs(nav): add Debug Logging entry to Advanced section

* fix(ai): migrate remaining console.warn calls in realtime adapters to logger.errors

* docs(advanced): fix stray comment syntax in debug-logging guide

* ci: apply automated fixes

* fix: resolve eslint and knip failures from debug logging PR

- Remove unnecessary optional chains and nullish coalescing on required messages array across adapter log lines
- Remove unnecessary fallback on non-nullable chunk/event types
- Reorder imports in elevenlabs realtime types to satisfy import/first
- Delete unused packages/typescript/ai/src/logger/index.ts barrel (public surface is re-exported from src/index.ts)

* ci: apply automated fixes

* fix(ai): suppress debug logs for internal devtools middleware

The devtools middleware is injected automatically by chat() and is
already excluded from aiEventClient instrumentation via
shouldSkipInstrumentation. Its per-hook logger.middleware / logger.config
calls were still firing though, flooding the [tanstack-ai:middleware]
category with internal plumbing. Move those calls inside the same
skip gate so debug output only reflects user-provided middleware.

* feat(ai): pretty-print deeply nested meta in ConsoleLogger on Node

Debug logs surface raw provider chunks whose nested structures
(usage, output, reasoning, tools, response payloads) were being
truncated to [Object] / [Array] because Node's default console
formatting stops at depth 2. ConsoleLogger now lazily loads
node:util and runs meta through inspect({ depth: null }) on Node so
the entire structure renders. Browsers still get the raw object for
interactive DevTools inspection.

* ci: apply automated fixes

* refactor(ai): use console.dir with depth:null instead of util.inspect

console.dir is the purpose-built native API for depth-unlimited object
inspection. It takes the same {depth, colors} options natively on Node
and is a no-op/interactive-tree in browsers, so we get the expanded
output in both environments without any dynamic import dance around
node:util.

* feat(ai): prefix each debug category with an emoji marker

Makes it trivial to visually scan dense streaming logs — each category
tag is now bracketed by its own emoji on both sides, e.g.
'📨 [tanstack-ai:output] 📨 ...'. Mapping: request=📤, provider=📥,
output=📨, middleware=🧩, tools=🔧, agentLoop=🔁, config=⚙️, errors=❌.

Tests that asserted on the raw tag via startsWith were switched to
includes so they remain robust to prefix changes.

* ci: apply automated fixes

* test: remove debug logging e2e spec

Library-level unit tests in the @tanstack/ai test suite already cover
the debug logging behaviour (logger wiring, category resolution,
console.dir formatting, emoji prefixing). An e2e round-trip added no
independent coverage, so drop the spec, its API route, its fixture,
and the now-stale routeTree entry.

* chore: add changesets for debug logging

- @tanstack/ai: minor — new debug option on every activity, Logger /
  ConsoleLogger / DebugOption public surface, @tanstack/ai/adapter-internals
  subpath, emoji-prefixed category tags, console.dir-based meta formatting
- All provider adapters: patch — wire adapters through the InternalLogger
  so request/provider/errors flow through the structured logger; drop
  leftover console.* calls in adapter catch blocks

* ci: apply automated fixes

* fix(ai): swallow user-logger exceptions and ship debug-logging skill

Wrap the user-supplied Logger calls inside InternalLogger.emit in a
try/catch so an exception from the injected logger never masks the real
error that triggered the log call (e.g. a provider SDK failure inside
the chat stream).

Also ship a new ai-core/debug-logging skill under packages/typescript/ai/skills/
so agents can discover how to toggle debug logging on/off, narrow it per
category, and pipe it into a custom logger.

* docs(ai): document Logger try/catch guarantee in debug-logging guide

* docs(ai): fix DebugOption type and add realtime note in debug-logging skill

- Show actual `DebugOption = boolean | DebugConfig` type; describe the
  omitted-field behavior as a resolution rule rather than a type-arm.
- Replace the misleading "when any flag is set" comment; flags default to
  true whenever a DebugConfig is passed.
- Acknowledge that provider realtime session adapters (openaiRealtime,
  elevenlabsRealtime) also accept the same debug option.

* test(ai-openrouter): add missing logger field to structuredOutput test

PR #484 on main added a new structuredOutput test that didn't pass a
logger in chatOptions. After merging main into this branch, the new
required logger field on TextOptions (from the debug-logging PR) tripped
the typecheck. Pass the silent testLogger already defined at the top of
the file to match the pattern used by every other test in this file.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants