fix(ai): nullable wrap for optional nested objects/arrays in strict schema#484
Conversation
…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>
📝 WalkthroughWalkthroughThe pull request fixes a bug in Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
View your CI Pipeline Execution ↗ for commit 4572ecd
☁️ Nx Cloud last updated this comment at |
@tanstack/ai
@tanstack/ai-anthropic
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-devtools-core
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-openrouter
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/typescript/ai/src/activities/chat/tools/schema-converter.ts (1)
60-128: Consider aligning the siblingai-openaiimplementation.
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 inrequired. 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
📒 Files selected for processing (3)
.changeset/fix-strict-schema-optional-composites.mdpackages/typescript/ai-openrouter/tests/openrouter-adapter.test.tspackages/typescript/ai/src/activities/chat/tools/schema-converter.ts
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.
* 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>
Summary
type: ['object','null']/['array','null']whenconvertSchemaToJsonSchemaruns withforStructuredOutput: true, so OpenAI-style strictjson_schemaproviders stop rejecting valid Zod/ArkType schemas.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 passpnpm --filter @tanstack/ai test:lib— 652/652 passpnpm --filter @tanstack/ai test:types🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests
Chores