Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9ecbad3
feat(ai): streaming structured output (chat outputSchema + stream:true)
tombeckenham May 12, 2026
6578e3c
feat(openai-base): centralised structuredOutputStream + isAbortError …
tombeckenham May 12, 2026
b2543b1
fix(openai-base): tighten structuredOutputStream conditionals for eslint
tombeckenham May 12, 2026
8042ca7
refactor: drop \`as unknown as\` from streaming structured-output paths
tombeckenham May 12, 2026
eb543cd
fix(openai-base): align structuredOutputStream with #545 asChunk cleanup
tombeckenham May 12, 2026
1c6c244
ci: apply automated fixes
autofix-ci[bot] May 13, 2026
4923d89
fix: align structured streaming with 543 openai-base + port to ai-ope…
tombeckenham May 13, 2026
4c7540f
feat(ai-openrouter): structuredOutputStream for Responses (beta) adapter
tombeckenham May 13, 2026
ced7391
feat: openai Chat Completions adapter + summarize streaming fix + exa…
tombeckenham May 13, 2026
7d151dd
ci: apply automated fixes
autofix-ci[bot] May 13, 2026
b7ca8e2
refactor(ai-openrouter): drop casts and `satisfies StreamChunk` from …
tombeckenham May 14, 2026
37c02c3
Removed satisfies StreamChunk
tombeckenham May 14, 2026
c643483
refactor(ai): drop `as unknown as` casts in chat() dispatch
tombeckenham May 14, 2026
d5daa73
ci: apply automated fixes
autofix-ci[bot] May 14, 2026
2c9a13d
docs(ai): document streaming structured output in skill + chat docs
tombeckenham May 14, 2026
8e69fe7
ci: apply automated fixes
autofix-ci[bot] May 14, 2026
682b585
feat(ai): tag custom events in StructuredOutputStream + debug-log chu…
AlemTuzlak May 14, 2026
9ecda00
chore: consolidate streaming-structured-output changesets into one
AlemTuzlak May 14, 2026
d61320d
chore: scaffold .agent/self-learning pile with build-before-examples …
AlemTuzlak May 14, 2026
8b7d2f4
ci: apply automated fixes
autofix-ci[bot] May 14, 2026
d9049b5
docs: streaming structured output with tools + OpenAI Chat Completion…
AlemTuzlak May 14, 2026
c48df29
docs(chat/structured-outputs): lead with client+server flow, demote m…
AlemTuzlak May 14, 2026
22cd3c1
feat(ai-react): useChat managed partial/final for structured-output s…
AlemTuzlak May 14, 2026
c491042
ci: apply automated fixes
autofix-ci[bot] May 14, 2026
9f6de40
feat(ai-vue, ai-solid, ai-svelte): mirror useChat outputSchema/partia…
AlemTuzlak May 14, 2026
67625e2
docs: structured-outputs cross-framework + rendering reasoning/tool-c…
AlemTuzlak May 14, 2026
6c06ef2
ci: apply automated fixes
autofix-ci[bot] May 14, 2026
1097b54
docs(structured-outputs): fix 'with tools that may pause' to use real…
AlemTuzlak May 14, 2026
603cd6b
test: cover useChat({outputSchema}) runtime + runStreamingStructuredO…
AlemTuzlak May 14, 2026
527f893
ci: apply automated fixes
autofix-ci[bot] May 14, 2026
d763372
test(openai-base): cover structuredOutputStream on both base adapters
AlemTuzlak May 14, 2026
05e5a68
ci: apply automated fixes
autofix-ci[bot] May 14, 2026
af1e6ef
fix(ci): list @standard-schema/spec as devDep on framework packages
AlemTuzlak May 14, 2026
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
2 changes: 2 additions & 0 deletions .agent/self-learning/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
fallback-counts.json
*.bak
10 changes: 10 additions & 0 deletions .agent/self-learning/INDEX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Lessons Index

Read this index every turn. Each entry below is a routing condition.
If a `Use when ...` condition matches the current task, read the full lesson file.

<!-- LESSONS:START -->
<!-- Auto-managed by self-improve plugin. Manual edits preserved between markers. -->

- [build-before-running-examples](lessons/2026-05-14-build-before-running-examples.md) β€” Use when starting any tanstack/ai example dev server β€” build workspace packages first
<!-- LESSONS:END -->
15 changes: 15 additions & 0 deletions .agent/self-learning/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Self-improve plugin behavior knobs. Edit and commit per repo.
correction_detection:
enabled: true
regex_strictness: loose # loose | strict
coupling_detection:
enabled: true
regex_strictness: loose
enforcement:
pre_push_block: true # false = warn only, do not block push
curation:
default_interval_days: 30
promotion:
auto_suggest_global: true
skill_improve_threshold: 3
skills_repo: ~/.claude/skills
4 changes: 4 additions & 0 deletions .agent/self-learning/coupling.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "./coupling.schema.json",
"couplings": []
}
3 changes: 3 additions & 0 deletions .agent/self-learning/curation-state.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
last_curated: 2026-05-14
next_nag: 2026-06-13
default_interval_days: 30
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
name: build-before-running-examples
description: Use when starting any tanstack/ai example dev server β€” build workspace packages first
tags: [monorepo, examples, dev-workflow, build]
scope: repo
source:
type: auto-captured
created: 2026-05-14T13:05:00Z
related_skill: null
related: []
---

# Build Workspace Packages Before Running Examples

**Rule:** Run `pnpm -w run build:all` from the repo root before starting any example dev server (`examples/ts-react-chat`, `ts-solid-chat`, `ts-vue-chat`, `ts-svelte-chat`, `vanilla-chat`, `php-slim`, `python-fastapi`, `ts-group-chat`).

**Why:** "this was a mistake by you, you should always build packages inside of this repo before you run the examples" β€” examples import workspace packages (`@tanstack/ai`, `@tanstack/react-ai-devtools`, `@tanstack/ai-devtools-core`, etc.) via `workspace:*` and resolve through each package's `exports` field pointing at `dist/`. If `dist/` is missing for any package β€” including transitive ones β€” vite's dep-scan fails and SSR returns a 500. Fixing the first missing package one at a time wastes round-trips: I tried `pnpm --filter @tanstack/react-ai-devtools build`, hit a missing `@tanstack/ai-devtools-core`, etc. The cure is one command up front.

**How to apply:** Before any `pnpm --filter "<example-name>" dev` (or running an example via its own directory), run `pnpm -w run build:all` from the worktree root. Nx caches the build so re-runs are cheap. Skip only if the user has just explicitly said the workspace is freshly built.
Empty file.
35 changes: 0 additions & 35 deletions .changeset/decouple-openrouter-collapse-openai-base.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/openrouter-narrow-stream-chunk-types.md

This file was deleted.

95 changes: 95 additions & 0 deletions .changeset/streaming-structured-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
'@tanstack/ai': minor
'@tanstack/openai-base': minor
'@tanstack/ai-openai': minor
'@tanstack/ai-grok': minor
'@tanstack/ai-groq': minor
'@tanstack/ai-openrouter': minor
'@tanstack/ai-react': minor
'@tanstack/ai-vue': minor
'@tanstack/ai-solid': minor
'@tanstack/ai-svelte': minor
'@tanstack/ai-anthropic': patch
'@tanstack/ai-gemini': patch
'@tanstack/ai-ollama': patch
---

Streaming structured output across the OpenAI-compatible providers, an OpenAI Chat Completions sibling adapter, a summarize-subsystem unification, and the decoupling of `@tanstack/ai-openrouter` from the shared OpenAI base.

## Core β€” `@tanstack/ai`

- New `chat({ outputSchema, stream: true })` overload returning `StructuredOutputStream<InferSchemaType<TSchema>>`. The stream yields raw JSON deltas via `TEXT_MESSAGE_CONTENT` plus a terminal `CUSTOM` `structured-output.complete` event whose `value.object` is typed against the caller's schema with no helper or cast required.
- `StructuredOutputStream<T>` is a discriminated union over three tagged `CUSTOM` variants β€” `structured-output.complete<T>`, `approval-requested`, and `tool-input-available` (new `ApprovalRequestedEvent` / `ToolInputAvailableEvent` interfaces exported from `@tanstack/ai`). Narrowing on `chunk.type === 'CUSTOM' && chunk.name === '<literal>'` resolves `chunk.value` to the exact shape per variant. The bare `CustomEvent` (with `value: any`) is deliberately excluded to keep the narrow from collapsing to `any`; user-emitted events via the `emitCustomEvent` context API still flow at runtime and are documented as a small residual gap.
- Activity-layer hardening: always-finalise after the stream loop (no silent hangs on missing `finishReason`), typed `RUN_ERROR` on empty content, mid-stream provider errors terminate cleanly, schema-validation failures carry `runId / model / timestamp`.
- `fallbackStructuredOutputStream` in the activity layer is the single source of truth for adapters that don't implement `structuredOutputStream` natively; `BaseTextAdapter` no longer ships a default.
- `ChatStreamSummarizeAdapter.summarizeStream` accumulates summary text and emits a terminal `CUSTOM` `generation:result` event before the final `RUN_FINISHED`. Fixes `useSummarize` never populating `result` over streaming connections (the client only sets `result` on that specific CUSTOM event).
- `SummarizationOptions` is now generic in `TProviderOptions` and `modelOptions` is plumbed through end-to-end (previously silently dropped by `runSummarize` / `runStreamingSummarize`).

## Framework hooks β€” `@tanstack/ai-react`, `@tanstack/ai-vue`, `@tanstack/ai-solid`, `@tanstack/ai-svelte`

`useChat` (React/Vue/Solid) and `createChat` (Svelte) now accept an `outputSchema` option mirroring `chat({ outputSchema })` on the server. When supplied, the hook's return adds two managed reactive fields:

- `partial` β€” the live progressive object, typed `DeepPartial<InferSchemaType<typeof outputSchema>>`. Updated from `TEXT_MESSAGE_CONTENT` deltas via `parsePartialJSON`. Resets on every new run.
- `final` β€” the validated terminal payload from the `structured-output.complete` event, typed `InferSchemaType<typeof outputSchema> | null`. `null` until the run completes.

Both fields are typed against the schema with no helper or cast β€” each hook is generic on `TSchema` and conditionally adds the fields to the return type. Without `outputSchema`, the return type is unchanged. Works the same for streaming and non-streaming endpoints β€” for non-streaming, `partial` stays `{}` and `final` snaps when the single terminal event arrives. Reasoning text and tool calls aren't surfaced as separate hook fields β€” they're already on `messages[…].parts` (as `ThinkingPart`, `ToolCallPart`, `ToolResultPart`), same as a normal chat. When `outputSchema` is set, the assistant's `TextPart` contains the raw JSON the model produced; filter `text` parts out of your message renderer and let the structured view (driven by `partial` / `final`) replace it.

Reactivity primitive per framework:

| Framework | `partial` type | `final` type |
| ------------------------------ | ------------------------------------------------------- | ------------------------------------------------ |
| React (`@tanstack/ai-react`) | `DeepPartial<T>` (plain state) | `T \| null` (plain state) |
| Vue (`@tanstack/ai-vue`) | `Readonly<ShallowRef<DeepPartial<T>>>` | `Readonly<ShallowRef<T \| null>>` |
| Solid (`@tanstack/ai-solid`) | `Accessor<DeepPartial<T>>` | `Accessor<T \| null>` |
| Svelte (`@tanstack/ai-svelte`) | `readonly partial: DeepPartial<T>` (rune-backed getter) | `readonly final: T \| null` (rune-backed getter) |

`DeepPartial<T>` is exported from each framework package for callers who want to annotate handlers explicitly.

## Base β€” `@tanstack/openai-base`

- Package renamed from `@tanstack/ai-openai-compatible` (which remains published for pinned lockfiles but receives no further updates). Imports change:

```diff
- import { OpenAICompatibleChatCompletionsTextAdapter } from '@tanstack/ai-openai-compatible'
+ import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base'
- import { OpenAICompatibleResponsesTextAdapter } from '@tanstack/ai-openai-compatible'
+ import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base'
```

- Centralised `structuredOutputStream` on both bases. Chat Completions uses `response_format: { type: 'json_schema', strict: true }` + `stream: true`; Responses uses `text.format: { type: 'json_schema', strict: true }` + `stream: true`. Subclasses (`ai-openai`, `ai-grok`, `ai-groq`) inherit it; OpenRouter implements its own (see below).
- Base now adopts the `openai` SDK directly and imports types from `openai/resources/...`. The previously-vendored ~720 LOC of wire-format types (`ChatCompletion`, `ResponseStreamEvent`, etc.) is removed; consumers that imported wire types from the package should import them from the openai SDK instead. The abstract `callChatCompletion*` / `callResponse*` hooks are gone β€” the base constructor now takes a pre-built `OpenAI` client (`new OpenAIBaseChatCompletionsTextAdapter(model, name, openaiClient)`) and calls `client.chat.completions.create` / `client.responses.create` itself.
- New protected `isAbortError(error)` hook duck-types abort detection so `RUN_ERROR { code: 'aborted' }` is emitted consistently across SDK error types β€” subclasses with proprietary error classes (e.g. `@openrouter/sdk`'s `RequestAbortedError`) override.
- Per-chunk `logger.provider(...)` debug logging now fires inside `structuredOutputStream` loops, matching the existing pattern in `chatStream` for end-to-end introspection in debug mode.

The other extension hooks (`extractReasoning`, `extractTextFromResponse`, `processStreamChunks`, `makeStructuredOutputCompatible`, `transformStructuredOutput`, `mapOptionsToRequest`, `convertMessage`) remain. Groq's `processStreamChunks` and `makeStructuredOutputCompatible` overrides (for `x_groq.usage` promotion and Groq's structured-output schema quirks) are unchanged.

## Provider adapters

| Adapter | API | Reasoning surface |
| ---------------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- |
| `@tanstack/ai-openai` `openaiText` | Responses | `response.reasoning_text.delta` + `response.reasoning_summary_text.delta` (requires `reasoning.summary: 'auto'`) |
| `@tanstack/ai-openai` `openaiChatCompletions` (new) | Chat Completions | reasoning emitted silently β€” Chat Completions has no `reasoning.summary` opt-in |
| `@tanstack/ai-grok` `grokText` | Chat Completions | `delta.reasoning_content` (DeepSeek convention; not typed by OpenAI SDK) |
| `@tanstack/ai-groq` `groqText` | Chat Completions | `delta.reasoning` (requires `reasoning_format: 'parsed'`; not typed by groq-sdk) |
| `@tanstack/ai-openrouter` `openRouterText` | Chat Completions | `delta.reasoningDetails` (camelCase) |
| `@tanstack/ai-openrouter` `openRouterResponsesText` (beta) | Responses (beta) | `response.reasoning_text.delta` + `response.reasoning_summary_text.delta` via `normalizeStreamEvent` |

All six emit the contractual `REASONING_*` lifecycle (`REASONING_START` β†’ `REASONING_MESSAGE_START` β†’ `REASONING_MESSAGE_CONTENT` deltas β†’ `REASONING_MESSAGE_END` β†’ `REASONING_END`) and close it before `TEXT_MESSAGE_START`. Accumulated reasoning is also surfaced on `structured-output.complete.value.reasoning` for consumers that only subscribe to the terminal event. OpenRouter SDK's proprietary `RequestAbortedError` is mapped (alongside DOM `AbortError`) to `code: 'aborted'` in the two openrouter adapters.

`@tanstack/ai-openai` also exports a new `OpenAIChatCompletionsTextAdapter` / `openaiChatCompletions` / `createOpenaiChatCompletions` factory β€” a sibling to the existing Responses adapter for callers who want the older `/v1/chat/completions` wire format against the OpenAI SDK.

## Decouple `@tanstack/ai-openrouter` from the OpenAI base

OpenRouter ships its own SDK (`@openrouter/sdk`) with a camelCase shape, so inheriting from the OpenAI-shaped base forced a snake_case ↔ camelCase round-trip on every request and stream event. ai-openrouter now extends `BaseTextAdapter` directly and inlines its own stream processors (`OpenRouterTextAdapter` for chat-completions, `OpenRouterResponsesTextAdapter` for the Responses beta), reading OpenRouter's camelCase types natively. The `@tanstack/openai-base` and `openai` dependencies are removed from ai-openrouter; only `@openrouter/sdk`, `@tanstack/ai`, and `@tanstack/ai-utils` remain. The ~300 LOC of inbound/outbound shape converters (`toOpenRouterRequest`, `toChatCompletion`, `adaptOpenRouterStreamChunks`, `toSnakeResponseResult`, …) are gone. Internal: duck-typed `as { ... }` casts on stream chunks in `OpenRouterResponsesTextAdapter` are replaced with direct narrowing via the SDK's discriminated unions.

Public OpenRouter API is unchanged: `openRouterText`, `openRouterResponsesText`, `createOpenRouterText`, `createOpenRouterResponsesText`, the OpenRouter tool factories, provider routing surface (`provider`, `models`, `plugins`, `variant`, `transforms`), app attribution headers (`httpReferer`, `appTitle`), `:variant` model suffixing, `RequestAbortedError` propagation, and the OpenRouter-specific structured-output null-preservation all behave the same.

`ai-ollama` remains on `BaseTextAdapter` directly β€” its native API uses a different wire format from Chat Completions and was never on the shared base.

## Summarize subsystem

Anthropic, Gemini, Ollama, and OpenRouter previously each shipped a bespoke 200–300 LOC summarize adapter. They now construct a `ChatStreamSummarizeAdapter` (formerly `ChatStreamWrapperAdapter`, renamed and exported from `@tanstack/ai/activities`) wrapping their own text adapter, matching the existing OpenAI/Grok pattern. Removes ~600 LOC of duplicated logic across the six providers and ensures behavioural parity.

Bespoke `*SummarizeProviderOptions` interfaces (e.g. `OpenAISummarizeProviderOptions`, `AnthropicSummarizeProviderOptions`, `GeminiSummarizeProviderOptions`, `OllamaSummarizeProviderOptions`, `OpenRouterSummarizeProviderOptions`) are removed from the provider packages' public exports. Consumers who imported them should switch to inferring the type from the adapter (`InferTextProviderOptions<typeof adapter>`) or remove the explicit annotation (it'll be inferred from the adapter argument).

`SummarizeAdapter` interface methods are now generic in `TProviderOptions`. `summarize` and `summarizeStream` previously took `SummarizationOptions` (defaulted, so `modelOptions` was effectively `Record<string, any>` regardless of the adapter's typed shape). They now take `SummarizationOptions<TProviderOptions>`. Source-compatible for callers that didn't specify the generic; type-tighter for implementers and downstream consumers. `SummarizationOptions`, `SummarizeAdapter`, `BaseSummarizeAdapter`, and `ChatStreamSummarizeAdapter` previously had a mixed `Record<string, any>` / `Record<string, unknown>` / `object` set of defaults for `TProviderOptions`; they now uniformly default to `Record<string, unknown>`.
Loading
Loading