Skip to content

Commit 31de22b

Browse files
DrewHooclaudetombeckenhamautofix-ci[bot]
authored
fix(ai): undo strict-mode null-widening before structured-output validation (#732)
* fix(ai): undo strict-mode null-widening before structured-output validation 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> * fix(ai-utils): handle tuple items and ambiguous anyOf branches in undoNullWidening Addresses CodeRabbit review on #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> * refactor(ai): record null-widening map at conversion time instead of 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> * fix(ai): un-widen structured-output nulls in the engine for both stream 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> * test(ai): cover native-combined + streaming-rewrite null normalization; 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> * test(openai-base): assert structuredOutputStream passes provider nulls through Addresses CodeRabbit review (PR #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> * ci: apply automated fixes * test(react-native-smoke): register @tanstack/ai-utils in resolution configs 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> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 6caac6b commit 31de22b

26 files changed

Lines changed: 882 additions & 177 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@tanstack/ai-utils': minor
3+
'@tanstack/ai': minor
4+
'@tanstack/openai-base': minor
5+
'@tanstack/ai-openrouter': patch
6+
---
7+
8+
Fix structured output validation rejecting `null` for optional fields, across both stream modes and every adapter.
9+
10+
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`).
11+
12+
The engine now undoes the widening as a single, schema-aware step the moment the structured output is captured, so the fix applies uniformly:
13+
14+
- 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.
15+
- `@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.
16+
- 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.
17+
- `@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.
18+
19+
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>`.

docs/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@
172172
{
173173
"label": "Overview",
174174
"to": "structured-outputs/overview",
175-
"addedAt": "2026-05-19"
175+
"addedAt": "2026-05-19",
176+
"updatedAt": "2026-06-10"
176177
},
177178
{
178179
"label": "One-Shot Extraction",

docs/structured-outputs/overview.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ Pick the journey that matches what you're building. The four guides under "Struc
8686

8787
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.
8888

89-
> **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.
89+
> **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.
90+
>
91+
> 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.
9092
9193
## Middleware integration
9294

packages/ai-openrouter/src/adapters/responses-text.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
toRunErrorPayload,
66
toRunErrorRawEvent,
77
} from '@tanstack/ai/adapter-internals'
8-
import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
8+
import { generateId } from '@tanstack/ai-utils'
99
import { extractRequestOptions } from '../internal/request-options'
1010
import { makeStructuredOutputCompatible } from '../internal/schema-converter'
1111
import { convertFunctionToolToResponsesFormat } from '../internal/responses-tool-converter'
@@ -697,14 +697,12 @@ export class OpenRouterResponsesTextAdapter<
697697

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

packages/ai-openrouter/src/adapters/text.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
toRunErrorPayload,
66
toRunErrorRawEvent,
77
} from '@tanstack/ai/adapter-internals'
8-
import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
8+
import { generateId } from '@tanstack/ai-utils'
99
import { extractRequestOptions } from '../internal/request-options'
1010
import { makeStructuredOutputCompatible } from '../internal/schema-converter'
1111
import { convertToolsToProviderFormat } from '../tools'
@@ -624,14 +624,12 @@ export class OpenRouterTextAdapter<
624624
* Final shaping pass applied to parsed structured-output JSON before it is
625625
* returned to the caller. OpenRouter routes through a wide variety of
626626
* upstream providers; some return `null` as a distinct sentinel ("the field
627-
* exists, the value is null") rather than collapsing it to absent. Stripping
628-
* nulls would erase that distinction, so we passthrough.
629-
*
630-
* `transformNullsToUndefined` is imported for parity with the other
631-
* provider adapters but intentionally not invoked here.
627+
* exists, the value is null") rather than collapsing it to absent, so we
628+
* passthrough and let the engine un-widen strict-mode nulls precisely. This
629+
* now matches the base adapters' default — kept as an explicit override
630+
* because OpenRouter extends `BaseTextAdapter` directly, not the OpenAI base.
632631
*/
633632
protected transformStructuredOutput(parsed: unknown): unknown {
634-
void transformNullsToUndefined
635633
return parsed
636634
}
637635

packages/ai-openrouter/tests/openrouter-adapter.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1249,7 +1249,13 @@ describe('OpenRouter structured output', () => {
12491249
outputSchema,
12501250
})
12511251

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

12541260
// The structured-output streaming call carries the strict-transformed schema.
12551261
const structuredCall = mockSend.mock.calls.find(

packages/ai-utils/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { generateId } from './id'
22
export { getApiKeyFromEnv } from './env'
3-
export { transformNullsToUndefined } from './transforms'
3+
export { transformNullsToUndefined, undoNullWidening } from './transforms'
4+
export type { NullWideningMap } from './transforms'
45
export { arrayBufferToBase64, base64ToArrayBuffer } from './base64'

packages/ai-utils/src/transforms.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
* therefore become `{}`; arbitrary class instances become a plain-object
2222
* snapshot of just their own enumerable string properties. Don't pass
2323
* non-JSON values.
24+
*
25+
* Schema-blind: strips EVERY null, including ones a `.nullable()` field
26+
* legitimately allows. When the original schema is available, prefer
27+
* {@link undoNullWidening}, which only strips the nulls strict-mode widening
28+
* synthesized.
2429
*/
2530
export function transformNullsToUndefined<T>(obj: T): T {
2631
if (obj === null) {
@@ -44,3 +49,76 @@ export function transformNullsToUndefined<T>(obj: T): T {
4449
}
4550
return result as T
4651
}
52+
53+
/**
54+
* Records exactly where strict-mode null-widening synthesized a `null`, so
55+
* {@link undoNullWidening} can strip those nulls and leave every other one
56+
* untouched. Built by the widening pass itself as it walks the schema (see
57+
* `convertSchemaForStructuredOutput` in `@tanstack/ai`), so it can never drift
58+
* from what was actually widened — no value-shape guessing required.
59+
*
60+
* - `widened`: the widening pass added `null` to THIS position's type (an
61+
* optional field promoted to `required` + nullable). A `null` here is
62+
* synthetic → strip it. Positions a `.nullable()`/`.nullish()` field already
63+
* allowed carry no `widened` mark, so their nulls survive.
64+
* - `properties` / `items`: descend into a nested object / array to reach
65+
* widened positions deeper in the tree. Only objects and arrays the widener
66+
* actually recursed into appear here.
67+
*/
68+
export type NullWideningMap = {
69+
widened?: boolean
70+
properties?: Record<string, NullWideningMap>
71+
items?: NullWideningMap | Array<NullWideningMap>
72+
}
73+
74+
function walk(value: unknown, map: NullWideningMap | undefined): unknown {
75+
if (value === null) {
76+
// Strip only nulls the widening pass synthesized (marked `widened`); keep
77+
// every genuine `.nullable()`/`.nullish()` null and every null the map
78+
// doesn't describe.
79+
return map?.widened ? undefined : null
80+
}
81+
if (typeof value !== 'object' || !map) return value
82+
83+
if (Array.isArray(value)) {
84+
const { items } = map
85+
if (!items) return value
86+
// Tuple maps (`items: [a, b, …]`) describe each position separately;
87+
// a single `items` map applies to every element.
88+
return Array.isArray(items)
89+
? value.map((item, index) => walk(item, items[index]))
90+
: value.map((item) => walk(item, items))
91+
}
92+
93+
const { properties } = map
94+
if (!properties) return value
95+
const result: Record<string, unknown> = {}
96+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
97+
const next = walk(child, properties[key])
98+
// A synthesized null collapsed to undefined → omit the key so the field
99+
// reads as absent (`key in result === false`), matching how `.optional()`
100+
// treats absence.
101+
if (next === undefined) continue
102+
result[key] = next
103+
}
104+
return result
105+
}
106+
107+
/**
108+
* Inverse of strict-mode null-widening for structured output.
109+
*
110+
* To satisfy OpenAI-style strict schemas, optional fields are widened to
111+
* `required` with `null` added to their type, so the provider returns `null`
112+
* for an absent optional. Validating that `null` against the ORIGINAL schema
113+
* fails, because `.optional()` means `T | undefined`, not `T | null`.
114+
*
115+
* Unlike {@link transformNullsToUndefined}, this consults a {@link
116+
* NullWideningMap} recorded by the widening pass and drops ONLY the nulls that
117+
* pass actually synthesized. Nulls a `.nullable()`/`.nullish()` field genuinely
118+
* allows are preserved, so both `optional` and `nullable` fields round-trip
119+
* correctly. With no map, the value is returned untouched.
120+
*/
121+
export function undoNullWidening<T>(value: T, map?: NullWideningMap): T {
122+
if (!map) return value
123+
return walk(value, map) as T
124+
}

packages/ai-utils/tests/transforms.test.ts

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { describe, it, expect } from 'vitest'
2-
import { transformNullsToUndefined } from '../src/transforms'
1+
import { describe, expect, it } from 'vitest'
2+
import { transformNullsToUndefined, undoNullWidening } from '../src/transforms'
3+
import type { NullWideningMap } from '../src/transforms'
34

45
describe('transformNullsToUndefined', () => {
56
it('should convert null values to undefined', () => {
@@ -49,3 +50,107 @@ describe('transformNullsToUndefined', () => {
4950
expect(result).toEqual({ a: { b: { c: { e: 'keep' } } } })
5051
})
5152
})
53+
54+
describe('undoNullWidening', () => {
55+
// The widening pass records a map of the nulls it synthesized. For an object
56+
// with one optional field (`opt`) and one nullable field (`nul`), only `opt`
57+
// is widened — so only `opt` is marked:
58+
// req: string (required) -> not widened, absent from the map
59+
// opt: optional(string) -> widened to `required` + null
60+
// nul: nullable(string) -> already allowed null, not widened
61+
const map: NullWideningMap = {
62+
properties: {
63+
opt: { widened: true },
64+
},
65+
}
66+
67+
it('drops a synthesized null on a widened field (key becomes absent)', () => {
68+
const result = undoNullWidening({ req: 'a', opt: null }, map)
69+
expect(result).toEqual({ req: 'a' })
70+
expect('opt' in (result as object)).toBe(false)
71+
})
72+
73+
it('keeps a genuine null on a field the widener did not touch', () => {
74+
const result = undoNullWidening({ req: 'a', nul: null }, map)
75+
expect(result).toEqual({ req: 'a', nul: null })
76+
})
77+
78+
it('handles widened and genuine nulls in the same object', () => {
79+
const result = undoNullWidening({ req: 'a', opt: null, nul: null }, map)
80+
expect(result).toEqual({ req: 'a', nul: null })
81+
})
82+
83+
it('leaves present values untouched', () => {
84+
const result = undoNullWidening({ req: 'a', opt: 'b', nul: 'c' }, map)
85+
expect(result).toEqual({ req: 'a', opt: 'b', nul: 'c' })
86+
})
87+
88+
it('descends into a widened object to drop its inner synthesized null', () => {
89+
// `obj` is itself optional (so it may come back null) AND has an inner
90+
// optional `note`. The map marks both the object and the nested field.
91+
const nested: NullWideningMap = {
92+
properties: {
93+
obj: {
94+
widened: true,
95+
properties: { note: { widened: true } },
96+
},
97+
},
98+
}
99+
// obj is present (kept), but its optional `note` came back null.
100+
const result = undoNullWidening({ obj: { inner: 'x', note: null } }, nested)
101+
expect(result).toEqual({ obj: { inner: 'x' } })
102+
103+
// …and when the whole object comes back null, the key drops out.
104+
expect(undoNullWidening({ obj: null }, nested)).toEqual({})
105+
})
106+
107+
it('strips synthesized nulls inside array items', () => {
108+
const arrMap: NullWideningMap = {
109+
properties: {
110+
items: {
111+
items: { properties: { label: { widened: true } } },
112+
},
113+
},
114+
}
115+
const result = undoNullWidening(
116+
{
117+
items: [
118+
{ id: '1', label: null },
119+
{ id: '2', label: 'two' },
120+
],
121+
},
122+
arrMap,
123+
)
124+
expect(result).toEqual({ items: [{ id: '1' }, { id: '2', label: 'two' }] })
125+
})
126+
127+
it('applies tuple-style item maps per index', () => {
128+
// [ { name }, { note? } ] — only the second position has a widened field.
129+
const tupleMap: NullWideningMap = {
130+
properties: {
131+
pair: {
132+
items: [{}, { properties: { note: { widened: true } } }],
133+
},
134+
},
135+
}
136+
const result = undoNullWidening(
137+
{ pair: [{ name: 'Ada' }, { note: null }] },
138+
tupleMap,
139+
)
140+
// The synthesized null in the second tuple position is dropped using that
141+
// position's map, not the first's.
142+
expect(result).toEqual({ pair: [{ name: 'Ada' }, {}] })
143+
})
144+
145+
it('returns the value untouched when no map is supplied', () => {
146+
const value = { a: null, b: 1 }
147+
expect(undoNullWidening(value)).toBe(value)
148+
})
149+
150+
it('leaves nulls under positions the map does not describe', () => {
151+
// `extra` carries no map entry — the widener never synthesized a null
152+
// there, so it is preserved.
153+
const result = undoNullWidening({ req: 'a', extra: null }, map)
154+
expect(result).toEqual({ req: 'a', extra: null })
155+
})
156+
})

packages/ai/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"@ag-ui/core": "^0.0.52",
8989
"@standard-schema/spec": "^1.1.0",
9090
"@tanstack/ai-event-client": "workspace:*",
91+
"@tanstack/ai-utils": "workspace:*",
9192
"partial-json": "^0.1.7"
9293
},
9394
"peerDependencies": {

0 commit comments

Comments
 (0)