Skip to content

fix(ai): undo strict-mode null-widening before structured-output validation#732

Open
DrewHoo wants to merge 8 commits into
TanStack:mainfrom
DrewHoo:fix/structured-output-schema-aware-null-normalization
Open

fix(ai): undo strict-mode null-widening before structured-output validation#732
DrewHoo wants to merge 8 commits into
TanStack:mainfrom
DrewHoo:fix/structured-output-schema-aware-null-normalization

Conversation

@DrewHoo

@DrewHoo DrewHoo commented Jun 9, 2026

Copy link
Copy Markdown

Summary

Structured output rejects a valid provider response when the schema has an optional field. To satisfy OpenAI-style strict schemas, optional fields are widened to required + nullable, so the provider returns null for an absent optional — but that null is then validated against the original schema, where .optional() means T | undefined, not T | null. Validation throws.

This adds a schema-aware undoNullWidening to @tanstack/ai-utils and runs it on the structured-output result before Standard Schema validation, so the synthesized nulls are dropped while genuine .nullable()/.nullish() nulls are preserved.

Fixes #731

Reproduction

import * as v from 'valibot'
import { toStandardJsonSchema } from '@valibot/to-json-schema'

const schema = toStandardJsonSchema(
  v.object({ title: v.string(), note: v.optional(v.string()) }),
)
// chat({ outputSchema: schema, ... }) with @tanstack/ai-openrouter
// → StandardSchemaValidationError: Invalid type: Expected string but received null

The outbound schema sent to the provider widens note to { "type": ["string", "null"] } and adds it to required, so the model returns { "title": "…", "note": null }. The original Valibot schema rejects that null.

Root cause

convertSchemaToJsonSchema(schema, { forStructuredOutput: true }) widens optional → required + nullable (correct, and required for strict mode). The round trip just never undoes it: the response is validated against the un-widened original via parseWithStandardSchema, which rejects the null the widening told the model to send.

Fix

  • @tanstack/ai-utils gains undoNullWidening(value, schema) — a schema-aware counterpart to transformNullsToUndefined. It consults the original (un-widened) JSON Schema and drops a null only where the schema disallows one (a synthesized widening), preserving nulls a .nullable()/.nullish() field genuinely allows, and leaving nulls under shapes the schema doesn't describe untouched.
  • @tanstack/ai runs it inside the structured-output validate callback (activities/chat), before parseWithStandardSchema, using convertSchemaToJsonSchema(outputSchema) (no forStructuredOutput) as the nullability source of truth.

Why core, not the adapter

By the time the schema reaches the adapter it's already been widened (forStructuredOutput: true), so the adapter can no longer tell a synthesized null from a genuine one. Only the activity layer still holds the original schema, so the normalization has to live there. This also fixes it for every adapter on the validated path at once, rather than per-adapter.

Tests

  • @tanstack/ai-utils: unit coverage for undoNullWidening — optional vs. nullable, mixed, nested nullable objects (via anyOf), array items, schemaless passthrough.
  • @tanstack/ai: end-to-end chat({ outputSchema }) regression — a provider null on an optional field now resolves (key absent), and a null on a .nullable() field is preserved.
  • Full @tanstack/ai suite (1005 tests) + tsc + eslint pass.

Out of scope / follow-up

There's a symmetric latent issue in the @tanstack/openai-base adapters: they blind-strip all nulls (transformNullsToUndefined), so a genuinely .nullable() required field that returns null is stripped and then fails validation as missing — the inverse of the bug fixed here.

field passthrough adapters (e.g. OpenRouter) blind-strip adapters (openai-base)
optional (T | undefined) ❌ before this PR → ✅
nullable required (T | null)

Unifying that is intentionally not in this PR: the openai-base null-strip runs inside structuredOutputStream (shared by the non-streaming Promise path and the streamed path, which re-emits the adapter chunk), so fixing it cleanly means either threading the original schema down to the adapters or normalizing the streaming chunk pipeline — a larger, more invasive change. Happy to follow up with that if you'd prefer a single unified pass; undoNullWidening is exported and ready to be reused there.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Engine now removes synthesized nulls so optional fields are omitted (not null), preventing validation errors.
  • New Features

    • Added a schema-aware null-normalization utility and flow that tracks and strips only synthesized nulls while preserving genuine nullable values.
    • Adapters default to preserving provider nulls; engine applies consistent un-widening for both promise and streaming outputs.
  • Documentation

    • Clarified streaming vs non-streaming validation and normalization behavior.
  • Tests

    • Expanded unit and e2e tests for optional vs nullable handling, arrays/tuples, streaming, and nested cases.

…dation

Optional fields are widened to required+nullable for strict structured output, so providers return `null` for an absent optional. Validating that `null` against the original schema failed (`.optional()` is `T | undefined`, not `T | null`), surfacing as a StandardSchemaValidationError — most visibly through @tanstack/ai-openrouter, whose adapter preserves provider nulls.

Add `undoNullWidening(value, schema)` to @tanstack/ai-utils: a schema-aware counterpart to `transformNullsToUndefined` that drops only synthesized nulls (those the original JSON Schema disallows) while preserving the ones a `.nullable()`/`.nullish()` field genuinely allows. The chat activity runs it on the structured-output result before Standard Schema validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds undoNullWidening and NullWideningMap to ai-utils, emit null-widening maps from schema conversion, undo synthesized nulls in chat structured-output finalization (promise and streaming), update adapters/tests/docs and add ai-utils dependency.

Changes

Structured Output Null Normalization

Layer / File(s) Summary
undoNullWidening utility implementation
packages/ai-utils/src/transforms.ts, packages/ai-utils/src/index.ts
Adds NullWideningMap type and undoNullWidening<T>(value, map?) which walks JSON-shaped values and removes only synthesized nulls recorded by strict-mode widening; clarifies transformNullsToUndefined JSDoc and re-exports new API.
ai-utils unit tests
packages/ai-utils/tests/transforms.test.ts
Adds tests for undoNullWidening: synthesized-null removal, preservation of genuine nullable null, mixed-object handling, nested unwinding, array item and tuple handling, identity when no map provided, and preservation for unmapped properties.
Schema conversion records null-widening
packages/ai/src/activities/chat/tools/schema-converter.ts
Refactors structured-output widening to return { jsonSchema, nullWideningMap }, adds toTypedJsonSchema, propagates nested NullWideningMap for objects/arrays/tuples, and exposes convertSchemaForStructuredOutput.
Chat engine integration
packages/ai/src/activities/chat/index.ts, packages/ai/package.json
Use convertSchemaForStructuredOutput to obtain jsonSchema and nullWideningMap; build finalStructuredOutput.normalize calling undoNullWidening(data, nullWideningMap) and apply it before storage/validation and when emitting structured-output.complete (both Promise and streaming paths); add @tanstack/ai-utils dependency.
Adapters and adapter tests
packages/openai-base/..., packages/ai-openrouter/..., tests
Adapters no longer default to schema-blind null-to-undefined stripping; they passthrough provider nulls and rely on engine normalization; adapter tests updated to reflect provider null passthrough while engine unwidening yields omitted optional fields.
Docs, changeset, and e2e tests
.changeset/*, docs/structured-outputs/overview.md, testing/e2e/...
Changeset documents behavior and version bumps; docs clarify normalization vs streaming validation; e2e fixture/schema/tests assert optional field appears absent (not null) in streamed structured-output.complete payloads.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • TanStack/ai#609: Modifies structured-output streaming/completion handling in packages/ai/src/activities/chat/index.ts.
  • TanStack/ai#600: Changes finalStructuredOutput/structured-output finalization flow in chat engine.

Suggested reviewers

  • tombeckenham
  • AlemTuzlak
  • jherr

Poem

🐰 I nudged a null back to where it came,
Synthesized shadows lost their name.
Optional hops away, nullable stays,
Streams and promises now sing the same ways.
Hooray — tidy payloads prance today!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: undoing strict-mode null-widening before validation of structured output.
Description check ✅ Passed The description comprehensively covers the problem, root cause, fix, testing approach, and scope. It directly references the issue (#731) and includes reproduction, detailed rationale for the solution design, and test coverage summary.
Docstring Coverage ✅ Passed Docstring coverage is 92.31% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ai-utils/src/transforms.ts`:
- Around line 84-95: resolveSchema currently returns the first
object/array-shaped variant from schema.anyOf/schema.oneOf which can be wrong
when multiple non-null branches match; change the logic in resolveSchema to
collect all non-null matching variants (using the same shape-check: array vs
object heuristics), and only return a variant when exactly one non-null match is
found, otherwise return the original schema to avoid destructive rewrites that
strip valid null branches; reference the resolveSchema function and the local
variables variants, match, isArray when making this change.
- Around line 110-115: The array handling currently applies resolved.items[0] to
every element; change the mapping to pick the schema per index when
resolved.items is an array: compute itemSchemaForIndex =
Array.isArray(resolved.items) ? resolved.items[i] ||
resolved.items[resolved.items.length - 1] || resolved.additionalItems ||
resolved.items[0] : resolved.items, then call value.map((item, i) => walk(item,
itemSchemaForIndex)); update the block around resolveSchema, resolved, and walk
to use the index-aware schema selection so tuple-style schemas are handled
correctly.

In `@packages/ai-utils/tests/transforms.test.ts`:
- Around line 2-6: The import order violates ESLint import/order: combine the
type and value imports into a single statement or place the value imports before
the type import so types don't come first; specifically adjust the imports
involving JsonSchemaNode, transformNullsToUndefined, and undoNullWidening so
they are imported in one consolidated line or with transformNullsToUndefined and
undoNullWidening imported before the type JsonSchemaNode to satisfy the linter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dd2ec226-feb0-42be-b3f6-5e7d26a784d9

📥 Commits

Reviewing files that changed from the base of the PR and between e8ce0e1 and 16af8c9.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • .changeset/structured-output-undo-null-widening.md
  • packages/ai-utils/src/index.ts
  • packages/ai-utils/src/transforms.ts
  • packages/ai-utils/tests/transforms.test.ts
  • packages/ai/package.json
  • packages/ai/src/activities/chat/index.ts
  • packages/ai/tests/chat-structured-output-null-normalization.test.ts

Comment thread packages/ai-utils/src/transforms.ts Outdated
Comment thread packages/ai-utils/src/transforms.ts Outdated
Comment thread packages/ai-utils/tests/transforms.test.ts Outdated
…oNullWidening

Addresses CodeRabbit review on TanStack#732:

- resolveSchema now descends only when exactly one non-null anyOf/oneOf branch matches the value's shape; ambiguous unions keep the original schema rather than risk stripping a null a sibling branch allows.

- Array walking applies tuple-style `items: [a, b, …]` schemas per index instead of always using the first.

Adds coverage for both and fixes the test's import order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@DrewHoo

DrewHoo commented Jun 9, 2026

Copy link
Copy Markdown
Author

Thanks @coderabbitai — all three addressed in 3f44678:

  • resolveSchema ambiguous branches: now resolves only when exactly one non-null anyOf/oneOf branch matches the value's shape; ambiguous unions keep the original schema and stop descending, rather than risk stripping a null a sibling branch allows.
  • Tuple items: array walking applies items: [a, b, …] per index instead of always using the first.
  • Test import order: fixed — used a separate top-level import type rather than the suggested inline type specifier, since the repo's import/consistent-type-specifier-style rule requires the separate form.

Added unit coverage for both the tuple and ambiguous-union cases.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@DrewHoo: Thanks for the thorough follow-up! All three fixes look well-considered — especially the conservative approach to ambiguous union resolution (keeping the original schema and stopping descent rather than guessing) which avoids incorrectly stripping nulls that a sibling branch permits. The tuple-aware index walking and the additional unit coverage for both new cases are great additions.

(ノ✧▽✧)ノ 🐇

@tombeckenham tombeckenham self-assigned this Jun 9, 2026
@tombeckenham tombeckenham self-requested a review June 9, 2026 22:28
@nx-cloud

nx-cloud Bot commented Jun 10, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 49f7a48

Command Status Duration Result
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 1m 14s View ↗

☁️ Nx Cloud last updated this comment at 2026-06-10 07:37:30 UTC

@pkg-pr-new

pkg-pr-new Bot commented Jun 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

@tanstack/ai

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

@tanstack/ai-anthropic

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

@tanstack/ai-client

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

@tanstack/ai-code-mode

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

@tanstack/ai-code-mode-skills

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

@tanstack/ai-devtools-core

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

@tanstack/ai-elevenlabs

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

@tanstack/ai-event-client

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

@tanstack/ai-fal

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

@tanstack/ai-gemini

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

@tanstack/ai-grok

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

@tanstack/ai-groq

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

@tanstack/ai-isolate-cloudflare

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

@tanstack/ai-isolate-node

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

@tanstack/ai-isolate-quickjs

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

@tanstack/ai-mcp

npm i https://pkg.pr.new/@tanstack/ai-mcp@732

@tanstack/ai-ollama

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

@tanstack/ai-openai

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

@tanstack/ai-openrouter

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

@tanstack/ai-preact

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

@tanstack/ai-react

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

@tanstack/ai-react-ui

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

@tanstack/ai-solid

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

@tanstack/ai-solid-ui

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

@tanstack/ai-svelte

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

@tanstack/ai-utils

npm i https://pkg.pr.new/@tanstack/ai-utils@732

@tanstack/ai-vue

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

@tanstack/ai-vue-ui

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

@tanstack/openai-base

npm i https://pkg.pr.new/@tanstack/openai-base@732

@tanstack/preact-ai-devtools

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

@tanstack/react-ai-devtools

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

@tanstack/solid-ai-devtools

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

commit: 49f7a48

…re-deriving it

Replace the schema-guessing `undoNullWidening` — which reverse-engineered which
nulls strict-mode widening synthesized by pattern-matching response values
against the un-widened schema's anyOf branches, and bailed on ambiguity — with a
precise map recorded by the widening pass itself.

`makeStructuredOutputCompatible` now returns the strict schema plus a
`NullWideningMap` marking exactly the positions where it added `null`. The new
`convertSchemaForStructuredOutput` exposes both, and the chat activity threads
that map into `undoNullWidening`. This drops `resolveSchema`/`allowsNull` branch
guessing, preserves `.nullish()` nulls by construction, and closes the
ambiguous-union gap where synthesized nulls were previously left in place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tombeckenham

Copy link
Copy Markdown
Contributor

Pushed a refactor of the null-normalization internals (behaviour unchanged; all unit + integration tests green).

What changed: the previous approach re-derived which nulls were synthetic at validation time by walking the response value against the un-widened schema and pattern-matching anyOf/oneOf branches (resolveSchema), bailing to "leave the null" whenever a branch was ambiguous. That reconstruction was the awkward part — the widening pass already knows exactly what it widened, so the inverse can be recorded rather than guessed.

  • makeStructuredOutputCompatible now returns { schema, nullWidening }, where nullWidening is a NullWideningMap marking exactly the positions where it added null (and only there — a field already typed nullable via .nullish() is left unmarked).
  • New convertSchemaForStructuredOutput(schema) exposes both; the chat activity threads the map straight into undoNullWidening, replacing the second (un-widened) convertSchemaToJsonSchema call.
  • undoNullWidening(value, map) now consumes the map instead of a schema. resolveSchema/allowsNull and the JsonSchemaNode type are gone.

Why it's better:

  • Exact, not guessed — the map is what the widener did; no value-vs-branch matching.
  • Drift-proof — the record lives inside the widening function, so the two can't fall out of sync.
  • Closes a real gap — the old ambiguity bailout (a union of object types) silently left synthesized nulls in place; with the map there's nothing to disambiguate. .nullish() nulls are now preserved by construction.

The ai-utils tests were rewritten to drive maps directly; the chat-structured-output-null-normalization.test.ts behavioural test is unchanged and still passes.

…am modes and every adapter

Strict-mode structured output widens optional fields to `required` + nullable,
so providers return `null` for an absent optional. That `null` fails validation
against the original `.optional()` schema (`T | undefined`, not `T | null`).

Previously only the Promise<T> path un-widened, and only for adapters that
preserved provider nulls (OpenRouter). The OpenAI-family adapters instead
blind-stripped every null via `transformStructuredOutput`, which masked the bug
but also destroyed genuine `.nullable()` nulls — and the streaming path didn't
un-widen at all.

Move un-widening into the engine, the one layer that holds the schema's
null-widening map:

- Add `finalStructuredOutput.normalize`, applied the instant the structured
  output is captured, so it flows to BOTH the streaming
  `structured-output.complete` event and the Promise<T> result (plus the
  native-combined harvest path). Both activity callers now pass it via
  `convertSchemaForStructuredOutput`; streaming switches off the map-less
  `convertSchemaToJsonSchema`. Validation runs on already-normalized data.
- openai-base `transformStructuredOutput` default is now a passthrough — the
  blind null-strip is gone (the engine un-widens precisely instead). Fixes the
  responses-text streaming path that bypassed the hook. OpenAI/Grok/Groq inherit
  this; OpenRouter's now-redundant override is simplified and its dead
  `transformNullsToUndefined` imports dropped.

Genuine `.nullable()` nulls now survive on every adapter and both directions;
synthesized optional nulls are dropped everywhere.

Tests: streaming normalization + a converter→undo round-trip (closing the
untested map-production gap); adapter passthrough tests updated; e2e gains an
optional field returning `null` asserted un-widened across all 5 streaming
providers (real regression guard for OpenRouter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@tombeckenham tombeckenham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for finding this @DrewHoo. This pointed me to an adjust bug in the open-ai base as well.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/ai/tests/chat-structured-output-null-normalization.test.ts (1)

1-1: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Move this unit test next to the source module it verifies.

This file is under packages/ai/tests/, but the repository rule requires *.test.ts files to be colocated with source.
As per coding guidelines, "**/*.test.ts: Place unit tests alongside source code in *.test.ts files`."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/tests/chat-structured-output-null-normalization.test.ts` at line
1, Move the test file chat-structured-output-null-normalization.test.ts out of
packages/ai/tests/ and colocate it with the source module it verifies (place it
in the same directory as the module implementing the structured output
normalization). Update any relative imports inside the test to use the new path
and ensure the test name remains unchanged; if the source module exports a
function or class used in the test (e.g., the structured output normalization
function), import it with the correct relative path so the test runs from its
new location.

Source: Coding guidelines

🧹 Nitpick comments (1)
packages/openai-base/tests/chat-completions-text.test.ts (1)

726-766: ⚡ Quick win

Add a direct structuredOutputStream() passthrough regression here too.

This new assertion only exercises structuredOutput(), but the same behavior change also landed in the adapter streaming path. One base-level stream test here would catch a regression where structuredOutputStream() still strips null instead of relying on engine normalization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/openai-base/tests/chat-completions-text.test.ts` around lines 726 -
766, Add a sibling streaming test that mirrors the non-streaming case to ensure
structuredOutputStream() also passes provider nulls through unchanged: reuse
setupMockSdkClient and TestChatCompletionsAdapter with a streaming response
equivalent to nonStreamResponse, call adapter.structuredOutputStream(...) with
the same chatOptions and outputSchema used in the existing test, collect the
streamed result and assert the emitted data contains name === 'Alice' and
nickname === null; this prevents regressions where structuredOutputStream()
would strip nulls while structuredOutput() does not.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/structured-outputs/overview.md`:
- Around line 89-91: Add paired server- and client-side code snippets
demonstrating the behavior described: show a server endpoint (using the engine's
server API that emits structured-output.complete and performs
normalization/widening) that returns a validated object according to the
discussed schema, and a matching client usage example that calls chat({
outputSchema }) for non-streaming and chat({ outputSchema, stream:true }) or
useChat({ outputSchema }) for streaming, showing how to read value.object from
the structured-output.complete event or call
parseWithStandardSchema/parsePartialJSON. Reference the documented symbols in
the snippets (chat({ outputSchema }), useChat({ outputSchema }),
structured-output.complete, value.object, parseWithStandardSchema,
parsePartialJSON) so readers can see both sides and how the server
normalization/un-widening maps to the client consumption.

---

Outside diff comments:
In `@packages/ai/tests/chat-structured-output-null-normalization.test.ts`:
- Line 1: Move the test file chat-structured-output-null-normalization.test.ts
out of packages/ai/tests/ and colocate it with the source module it verifies
(place it in the same directory as the module implementing the structured output
normalization). Update any relative imports inside the test to use the new path
and ensure the test name remains unchanged; if the source module exports a
function or class used in the test (e.g., the structured output normalization
function), import it with the correct relative path so the test runs from its
new location.

---

Nitpick comments:
In `@packages/openai-base/tests/chat-completions-text.test.ts`:
- Around line 726-766: Add a sibling streaming test that mirrors the
non-streaming case to ensure structuredOutputStream() also passes provider nulls
through unchanged: reuse setupMockSdkClient and TestChatCompletionsAdapter with
a streaming response equivalent to nonStreamResponse, call
adapter.structuredOutputStream(...) with the same chatOptions and outputSchema
used in the existing test, collect the streamed result and assert the emitted
data contains name === 'Alice' and nickname === null; this prevents regressions
where structuredOutputStream() would strip nulls while structuredOutput() does
not.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a00759bd-cbca-4340-bd2a-eafb21c3b2b7

📥 Commits

Reviewing files that changed from the base of the PR and between 95e6295 and c6dedf9.

📒 Files selected for processing (15)
  • .changeset/structured-output-undo-null-widening.md
  • docs/config.json
  • docs/structured-outputs/overview.md
  • packages/ai-openrouter/src/adapters/responses-text.ts
  • packages/ai-openrouter/src/adapters/text.ts
  • packages/ai-openrouter/tests/openrouter-adapter.test.ts
  • packages/ai/src/activities/chat/index.ts
  • packages/ai/tests/chat-structured-output-null-normalization.test.ts
  • packages/openai-base/src/adapters/chat-completions-text.ts
  • packages/openai-base/src/adapters/responses-text.ts
  • packages/openai-base/tests/chat-completions-text.test.ts
  • packages/openai-base/tests/responses-text.test.ts
  • testing/e2e/fixtures/structured-output-stream/basic.json
  • testing/e2e/src/lib/schemas.ts
  • testing/e2e/tests/structured-output-stream.spec.ts
✅ Files skipped from review due to trivial changes (4)
  • testing/e2e/fixtures/structured-output-stream/basic.json
  • docs/config.json
  • packages/ai-openrouter/src/adapters/responses-text.ts
  • packages/ai-openrouter/src/adapters/text.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/structured-output-undo-null-widening.md

Comment on lines +89 to +91
> **Note:** Server-side validation is **path-dependent**. For the non-streaming agentic path (`await chat({ outputSchema })`), the engine runs Standard Schema validation inside the finalization step and routes failures through `onError` (the awaited promise rejects). For the streaming path (`chat({ outputSchema, stream: true })`), Standard Schema _validation_ is deliberately deferred to the consumer — consumers read the object from the `structured-output.complete` event's `value.object` field (or call `parseWithStandardSchema` themselves on the raw text). The schema you pass to `useChat({ outputSchema })` on the client is used for TypeScript inference and (in `useChat`) for client-side `parsePartialJSON`-based progressive parsing — the typed-object guarantee comes from the server-side path you pick.
>
> On **both** paths the engine normalizes the captured object before it reaches you: to satisfy strict providers, optional fields are widened to `required` + nullable, so the provider returns `null` for an absent optional. The engine undoes exactly that widening — an `.optional()` field that came back `null` reads back as **absent** (matching `T | undefined`), while a genuine `.nullable()` field's `null` is **preserved**. So `value.object` (streaming) and the awaited result (non-streaming) both carry the un-widened shape your schema describes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add paired server and client snippets in this section.

This note spans both server-side and client-side behavior but doesn’t include both code sides in the updated section.
As per coding guidelines, "Show both server and client sides of the coin when a doc spans both; include snippets for both the server endpoint AND client consumption."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/structured-outputs/overview.md` around lines 89 - 91, Add paired server-
and client-side code snippets demonstrating the behavior described: show a
server endpoint (using the engine's server API that emits
structured-output.complete and performs normalization/widening) that returns a
validated object according to the discussed schema, and a matching client usage
example that calls chat({ outputSchema }) for non-streaming and chat({
outputSchema, stream:true }) or useChat({ outputSchema }) for streaming, showing
how to read value.object from the structured-output.complete event or call
parseWithStandardSchema/parsePartialJSON. Reference the documented symbols in
the snippets (chat({ outputSchema }), useChat({ outputSchema }),
structured-output.complete, value.object, parseWithStandardSchema,
parsePartialJSON) so readers can see both sides and how the server
normalization/un-widening maps to the client consumption.

Source: Coding guidelines

tombeckenham and others added 3 commits June 10, 2026 17:14
…n; fix comment

Follow-up to the engine-level un-widening commit, addressing review gaps:

- Fix an inaccurate inline comment: the `structured-output.complete` event's
  value is `{ object, raw, reasoning? }` — it carries no `messageId` (that's on
  `structured-output.start`). The outbound-chunk rewrite preserves `raw`, not
  `messageId`.
- Add native-combined mode coverage (the `harvestCombinedStructuredOutput`
  capture site was untested): both the harvested Promise<T> result and the
  synthesized streaming complete event must un-widen.
- Add a streaming-rewrite test asserting the engine replaces only `object`
  (un-widened) while spreading the event's sibling `raw`/`reasoning` through
  untouched — guards the `{ ...value, object }` contract.
- Add a round-trip case proving a genuine `.nullable()` null inside an array
  item survives (the spot the array/tuple handling could wrongly strip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s through

Addresses CodeRabbit review (PR TanStack#732): the non-streaming passthrough assertion
had no streaming sibling. Adds a `structuredOutputStream()` case emitting a
provider `null` and asserting the terminal `structured-output.complete` object
preserves it — guarding against the stream path regressing to a blind null-strip
while the non-stream path relies on engine-level un-widening.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@DrewHoo

DrewHoo commented Jun 10, 2026

Copy link
Copy Markdown
Author

Thanks for finding this @DrewHoo. This pointed me to an adjust bug in the open-ai base as well.

@tombeckenham thanks for the update and thanks for taking a look! i'm working on the ci failure now

…onfigs

The chat activity and schema-converter now import @tanstack/ai-utils, so the React Native smoke graph reaches it. The smoke fixture resolves workspace packages to source via explicit per-tool mappings, so add @tanstack/ai-utils (mirroring @tanstack/ai-event-client) to the tsconfig paths, metro packageEntryPoints, the esbuild alias map, and the import-surface walker. Fixes the TS2307 'Cannot find module @tanstack/ai-utils' in the smoke typecheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@DrewHoo DrewHoo requested a review from a team as a code owner June 10, 2026 14:29
@DrewHoo

DrewHoo commented Jun 10, 2026

Copy link
Copy Markdown
Author

Fixed the react-native-smoke CI failure in 0c1be6c. The smoke fixture resolves workspace packages to source via explicit per-tool mappings; since @tanstack/ai now pulls in @tanstack/ai-utils (chat activity + schema-converter), I registered it — mirroring @tanstack/ai-event-client — in the tsconfig paths, metro packageEntryPoints, the esbuild alias map, and the import-surface walker.

pnpm --filter @tanstack/ai-react-native-smoke smoke now passes locally end-to-end (typecheck, import surface (80 visits), expo export, esbuild).

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.

@tanstack/ai-openrouter: optional structured output fields are rewritten as nullable but rejected by original schema validation

2 participants