Skip to content
Open
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
19 changes: 19 additions & 0 deletions .changeset/structured-output-undo-null-widening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@tanstack/ai-utils': minor
'@tanstack/ai': minor
'@tanstack/openai-base': minor
'@tanstack/ai-openrouter': patch
---

Fix structured output validation rejecting `null` for optional fields, across both stream modes and every adapter.

Strict-mode structured output widens optional fields to `required` + nullable, so the provider returns `null` for an absent optional. Validating that `null` against the original schema then failed, because `.optional()` means `T | undefined`, not `T | null` — surfacing as a `StandardSchemaValidationError` (e.g. `Invalid type: Expected string but received null`).

The engine now undoes the widening as a single, schema-aware step the moment the structured output is captured, so the fix applies uniformly:

- The strict-conversion pass records a `NullWideningMap` marking exactly the positions where it added `null`, so the response can be un-widened precisely — no re-deriving or guessing which nulls were synthetic.
- `@tanstack/ai-utils` adds `undoNullWidening(value, map)` — a counterpart to `transformNullsToUndefined` that strips only the nulls the widening pass synthesized, preserving the ones a `.nullable()`/`.nullish()` field genuinely allows.
- The engine applies this via a new `finalStructuredOutput.normalize` hook the instant the result is captured, so **both** the `Promise<T>` result **and** the streaming `structured-output.complete` event carry the un-widened object. Previously only the `Promise<T>` path was corrected, and only for adapters that preserved provider nulls.
- `@tanstack/openai-base` adapters (and the OpenAI/Grok/Groq adapters built on them) no longer blind-strip every `null` from structured output via `transformStructuredOutput` — that default is now a passthrough. The blind strip masked the validation bug but also destroyed genuine `.nullable()` nulls; precise un-widening in the engine fixes both. The `transformStructuredOutput` hook remains for provider-specific reshaping.

Adapters that already preserve provider nulls (`@tanstack/ai-openrouter`, Anthropic, Gemini, Ollama) now get correct un-widening on their streaming structured output too, not just `Promise<T>`.
3 changes: 2 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@
{
"label": "Overview",
"to": "structured-outputs/overview",
"addedAt": "2026-05-19"
"addedAt": "2026-05-19",
"updatedAt": "2026-06-10"
},
{
"label": "One-Shot Extraction",
Expand Down
4 changes: 3 additions & 1 deletion docs/structured-outputs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ Pick the journey that matches what you're building. The four guides under "Struc

The streaming and multi-turn paths both build on `useChat({ outputSchema })`. The "with tools" path layers on top of either. Pick the one that describes your shipping shape — start there, follow the cross-links when you need a piece of another story.

> **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 })`), validation is deliberately deferred to the consumer — the engine forwards the adapter-emitted `structured-output.complete` event verbatim, and consumers read the validated object from the `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.
> **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.
Comment on lines +89 to +91

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


## Middleware integration

Expand Down
12 changes: 5 additions & 7 deletions packages/ai-openrouter/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
toRunErrorPayload,
toRunErrorRawEvent,
} from '@tanstack/ai/adapter-internals'
import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
import { generateId } from '@tanstack/ai-utils'
import { extractRequestOptions } from '../internal/request-options'
import { makeStructuredOutputCompatible } from '../internal/schema-converter'
import { convertFunctionToolToResponsesFormat } from '../internal/responses-tool-converter'
Expand Down Expand Up @@ -697,14 +697,12 @@ export class OpenRouterResponsesTextAdapter<

/**
* OpenRouter routes through a wide variety of upstream providers; some
* return `null` as a distinct sentinel rather than collapsing it to absent.
* Stripping nulls would erase that distinction, so we passthrough.
*
* `transformNullsToUndefined` is imported for parity with the other
* provider adapters but intentionally not invoked here.
* return `null` as a distinct sentinel rather than collapsing it to absent,
* so we passthrough and let the engine un-widen strict-mode nulls precisely.
* Matches the base adapters' default — kept as an explicit override because
* OpenRouter extends `BaseTextAdapter` directly, not the OpenAI base.
*/
protected transformStructuredOutput(parsed: unknown): unknown {
void transformNullsToUndefined
return parsed
}

Expand Down
12 changes: 5 additions & 7 deletions packages/ai-openrouter/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
toRunErrorPayload,
toRunErrorRawEvent,
} from '@tanstack/ai/adapter-internals'
import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
import { generateId } from '@tanstack/ai-utils'
import { extractRequestOptions } from '../internal/request-options'
import { makeStructuredOutputCompatible } from '../internal/schema-converter'
import { convertToolsToProviderFormat } from '../tools'
Expand Down Expand Up @@ -624,14 +624,12 @@ export class OpenRouterTextAdapter<
* Final shaping pass applied to parsed structured-output JSON before it is
* returned to the caller. OpenRouter routes through a wide variety of
* upstream providers; some return `null` as a distinct sentinel ("the field
* exists, the value is null") rather than collapsing it to absent. Stripping
* nulls would erase that distinction, so we passthrough.
*
* `transformNullsToUndefined` is imported for parity with the other
* provider adapters but intentionally not invoked here.
* exists, the value is null") rather than collapsing it to absent, so we
* passthrough and let the engine un-widen strict-mode nulls precisely. This
* now matches the base adapters' default — kept as an explicit override
* because OpenRouter extends `BaseTextAdapter` directly, not the OpenAI base.
*/
protected transformStructuredOutput(parsed: unknown): unknown {
void transformNullsToUndefined
return parsed
}

Expand Down
8 changes: 7 additions & 1 deletion packages/ai-openrouter/tests/openrouter-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1249,7 +1249,13 @@ describe('OpenRouter structured output', () => {
outputSchema,
})

expect(result).toEqual({ name: 'Alice', age: 30, nickname: null })
// `nickname` was optional, so strict-mode widening made it `required` +
// nullable and the provider returned `null` for the absent value. The
// engine un-widens that synthesized null before returning, so the optional
// field reads back as absent — matching `.optional()` semantics — rather
// than leaking the synthetic `null` through.
expect(result).toEqual({ name: 'Alice', age: 30 })
expect('nickname' in (result as object)).toBe(false)

// The structured-output streaming call carries the strict-transformed schema.
const structuredCall = mockSend.mock.calls.find(
Expand Down
3 changes: 2 additions & 1 deletion packages/ai-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { generateId } from './id'
export { getApiKeyFromEnv } from './env'
export { transformNullsToUndefined } from './transforms'
export { transformNullsToUndefined, undoNullWidening } from './transforms'
export type { NullWideningMap } from './transforms'
export { arrayBufferToBase64, base64ToArrayBuffer } from './base64'
78 changes: 78 additions & 0 deletions packages/ai-utils/src/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
* therefore become `{}`; arbitrary class instances become a plain-object
* snapshot of just their own enumerable string properties. Don't pass
* non-JSON values.
*
* Schema-blind: strips EVERY null, including ones a `.nullable()` field
* legitimately allows. When the original schema is available, prefer
* {@link undoNullWidening}, which only strips the nulls strict-mode widening
* synthesized.
*/
export function transformNullsToUndefined<T>(obj: T): T {
if (obj === null) {
Expand All @@ -44,3 +49,76 @@ export function transformNullsToUndefined<T>(obj: T): T {
}
return result as T
}

/**
* Records exactly where strict-mode null-widening synthesized a `null`, so
* {@link undoNullWidening} can strip those nulls and leave every other one
* untouched. Built by the widening pass itself as it walks the schema (see
* `convertSchemaForStructuredOutput` in `@tanstack/ai`), so it can never drift
* from what was actually widened — no value-shape guessing required.
*
* - `widened`: the widening pass added `null` to THIS position's type (an
* optional field promoted to `required` + nullable). A `null` here is
* synthetic → strip it. Positions a `.nullable()`/`.nullish()` field already
* allowed carry no `widened` mark, so their nulls survive.
* - `properties` / `items`: descend into a nested object / array to reach
* widened positions deeper in the tree. Only objects and arrays the widener
* actually recursed into appear here.
*/
export type NullWideningMap = {
widened?: boolean
properties?: Record<string, NullWideningMap>
items?: NullWideningMap | Array<NullWideningMap>
}

function walk(value: unknown, map: NullWideningMap | undefined): unknown {
if (value === null) {
// Strip only nulls the widening pass synthesized (marked `widened`); keep
// every genuine `.nullable()`/`.nullish()` null and every null the map
// doesn't describe.
return map?.widened ? undefined : null
}
if (typeof value !== 'object' || !map) return value

if (Array.isArray(value)) {
const { items } = map
if (!items) return value
// Tuple maps (`items: [a, b, …]`) describe each position separately;
// a single `items` map applies to every element.
return Array.isArray(items)
? value.map((item, index) => walk(item, items[index]))
: value.map((item) => walk(item, items))
}

const { properties } = map
if (!properties) return value
const result: Record<string, unknown> = {}
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
const next = walk(child, properties[key])
// A synthesized null collapsed to undefined → omit the key so the field
// reads as absent (`key in result === false`), matching how `.optional()`
// treats absence.
if (next === undefined) continue
result[key] = next
}
return result
}

/**
* Inverse of strict-mode null-widening for structured output.
*
* To satisfy OpenAI-style strict schemas, optional fields are widened to
* `required` with `null` added to their type, so the provider returns `null`
* for an absent optional. Validating that `null` against the ORIGINAL schema
* fails, because `.optional()` means `T | undefined`, not `T | null`.
*
* Unlike {@link transformNullsToUndefined}, this consults a {@link
* NullWideningMap} recorded by the widening pass and drops ONLY the nulls that
* pass actually synthesized. Nulls a `.nullable()`/`.nullish()` field genuinely
* allows are preserved, so both `optional` and `nullable` fields round-trip
* correctly. With no map, the value is returned untouched.
*/
export function undoNullWidening<T>(value: T, map?: NullWideningMap): T {
if (!map) return value
return walk(value, map) as T
}
109 changes: 107 additions & 2 deletions packages/ai-utils/tests/transforms.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { transformNullsToUndefined } from '../src/transforms'
import { describe, expect, it } from 'vitest'
import { transformNullsToUndefined, undoNullWidening } from '../src/transforms'
import type { NullWideningMap } from '../src/transforms'

describe('transformNullsToUndefined', () => {
it('should convert null values to undefined', () => {
Expand Down Expand Up @@ -49,3 +50,107 @@ describe('transformNullsToUndefined', () => {
expect(result).toEqual({ a: { b: { c: { e: 'keep' } } } })
})
})

describe('undoNullWidening', () => {
// The widening pass records a map of the nulls it synthesized. For an object
// with one optional field (`opt`) and one nullable field (`nul`), only `opt`
// is widened — so only `opt` is marked:
// req: string (required) -> not widened, absent from the map
// opt: optional(string) -> widened to `required` + null
// nul: nullable(string) -> already allowed null, not widened
const map: NullWideningMap = {
properties: {
opt: { widened: true },
},
}

it('drops a synthesized null on a widened field (key becomes absent)', () => {
const result = undoNullWidening({ req: 'a', opt: null }, map)
expect(result).toEqual({ req: 'a' })
expect('opt' in (result as object)).toBe(false)
})

it('keeps a genuine null on a field the widener did not touch', () => {
const result = undoNullWidening({ req: 'a', nul: null }, map)
expect(result).toEqual({ req: 'a', nul: null })
})

it('handles widened and genuine nulls in the same object', () => {
const result = undoNullWidening({ req: 'a', opt: null, nul: null }, map)
expect(result).toEqual({ req: 'a', nul: null })
})

it('leaves present values untouched', () => {
const result = undoNullWidening({ req: 'a', opt: 'b', nul: 'c' }, map)
expect(result).toEqual({ req: 'a', opt: 'b', nul: 'c' })
})

it('descends into a widened object to drop its inner synthesized null', () => {
// `obj` is itself optional (so it may come back null) AND has an inner
// optional `note`. The map marks both the object and the nested field.
const nested: NullWideningMap = {
properties: {
obj: {
widened: true,
properties: { note: { widened: true } },
},
},
}
// obj is present (kept), but its optional `note` came back null.
const result = undoNullWidening({ obj: { inner: 'x', note: null } }, nested)
expect(result).toEqual({ obj: { inner: 'x' } })

// …and when the whole object comes back null, the key drops out.
expect(undoNullWidening({ obj: null }, nested)).toEqual({})
})

it('strips synthesized nulls inside array items', () => {
const arrMap: NullWideningMap = {
properties: {
items: {
items: { properties: { label: { widened: true } } },
},
},
}
const result = undoNullWidening(
{
items: [
{ id: '1', label: null },
{ id: '2', label: 'two' },
],
},
arrMap,
)
expect(result).toEqual({ items: [{ id: '1' }, { id: '2', label: 'two' }] })
})

it('applies tuple-style item maps per index', () => {
// [ { name }, { note? } ] — only the second position has a widened field.
const tupleMap: NullWideningMap = {
properties: {
pair: {
items: [{}, { properties: { note: { widened: true } } }],
},
},
}
const result = undoNullWidening(
{ pair: [{ name: 'Ada' }, { note: null }] },
tupleMap,
)
// The synthesized null in the second tuple position is dropped using that
// position's map, not the first's.
expect(result).toEqual({ pair: [{ name: 'Ada' }, {}] })
})

it('returns the value untouched when no map is supplied', () => {
const value = { a: null, b: 1 }
expect(undoNullWidening(value)).toBe(value)
})

it('leaves nulls under positions the map does not describe', () => {
// `extra` carries no map entry — the widener never synthesized a null
// there, so it is preserved.
const result = undoNullWidening({ req: 'a', extra: null }, map)
expect(result).toEqual({ req: 'a', extra: null })
})
})
1 change: 1 addition & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"@ag-ui/core": "^0.0.52",
"@standard-schema/spec": "^1.1.0",
"@tanstack/ai-event-client": "workspace:*",
"@tanstack/ai-utils": "workspace:*",
"partial-json": "^0.1.7"
},
"peerDependencies": {
Expand Down
Loading