Skip to content

Commit d0b318d

Browse files
authored
Merge branch 'main' into feat/extract-openai-base-and-ai-utils
2 parents da20ffb + d6a2588 commit d0b318d

52 files changed

Lines changed: 4164 additions & 267 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@tanstack/ai-isolate-cloudflare': patch
3+
---
4+
5+
fix(ai-isolate-cloudflare): accumulate `toolResults` across rounds in the driver round-trip
6+
7+
The Cloudflare isolate driver was wiping `toolResults` between rounds. `wrap-code` uses sequential `tc_<idx>` ids that are re-derived every round when the Worker re-executes user code, so prior-round results must remain in the cache. With the wipe, multi-tool programs (e.g. `await A(); await B();`) would ping-pong between `{tc_0}` and `{tc_1}` and exhaust `maxToolRounds`, surfacing as `MaxRoundsExceeded`.
8+
9+
Single-tool code worked because only one cache entry was ever needed in a given round. Existing tests covered single-round flows only and did not exercise real `wrap-code` ids end-to-end, so the regression slipped through.
10+
11+
Added a `tc_<idx>`-shaped regression test that fails on the prior implementation and passes with the merge.

.changeset/otel-middleware.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@tanstack/ai': minor
3+
---
4+
5+
**OpenTelemetry middleware.** `otelMiddleware({ tracer, meter?, captureContent?, redact?, ... })` emits GenAI-semantic-convention traces and metrics for every `chat()` call.
6+
7+
- Root span per `chat()` + child span per agent-loop iteration (named `chat <model> #<iteration>`) + grandchild span per tool call.
8+
- `gen_ai.client.operation.duration` (seconds) recorded **once per `chat()` call**; `gen_ai.client.token.usage` (tokens) recorded **per iteration** (one input + one output record). Metric attributes are kept low-cardinality — `gen_ai.response.model` and `gen_ai.response.id` are intentionally excluded.
9+
- `captureContent: true` attaches prompt/completion content as `gen_ai.{user,system,assistant,tool}.message` and `gen_ai.choice` span events. Redactor failures fail closed to a `"[redaction_failed]"` sentinel — raw content never leaks. Assistant text is capped at `maxContentLength` (default 100 000).
10+
- Four extension points for custom attributes, names, span-options, and end-of-span callbacks. Thrown callbacks are caught and logged to `console.warn` with a label so failures remain diagnosable.
11+
- `@opentelemetry/api` is an optional peer dependency. The middleware is exported from the dedicated subpath `@tanstack/ai/middlewares/otel` so that importing `@tanstack/ai/middlewares` does not eagerly require OTel.
12+
13+
See `docs/advanced/otel.md` for the full guide.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@tanstack/ai': minor
3+
'@tanstack/ai-anthropic': patch
4+
'@tanstack/ai-client': minor
5+
---
6+
7+
**Fix thinking blocks getting merged across steps and lost on turn 2+ of Anthropic tool loops.**
8+
9+
Each thinking step emitted by the adapter now produces its own `ThinkingPart` on the `UIMessage` instead of being merged into a single part, and thinking content + Anthropic signatures are preserved in server-side message history so multi-turn tool flows with extended thinking work correctly.
10+
11+
This includes a public callback signature change: `StreamProcessorEvents.onThinkingUpdate` now receives `(messageId, stepId, content)` instead of `(messageId, content)`. `ChatClient` has been updated to handle the new `stepId` argument internally, but consumers implementing `StreamProcessorEvents` directly need to add the new parameter.
12+
13+
`@tanstack/ai`:
14+
15+
- `ThinkingPart` gains optional `stepId` and `signature` fields.
16+
- `ModelMessage` gains an optional `thinking?: Array<{ content; signature? }>` field so prior thinking can be replayed in subsequent turns.
17+
- `StepFinishedEvent` gains an optional `signature` field for provider-supplied thinking signatures.
18+
- `StreamProcessor` tracks thinking per-step via `stepId` and keeps step ordering. `getState().thinking` / `getResult().thinking` concatenate step contents in order.
19+
- The `onThinkingUpdate` callback on `StreamProcessorEvents` now receives `(messageId, stepId, content)` — consumers implementing it directly must add the `stepId` parameter.
20+
- `TextEngine` accumulates thinking + signatures per iteration and includes them in assistant messages with tool calls so the next turn can replay them.
21+
22+
`@tanstack/ai-anthropic`:
23+
24+
- Captures `signature_delta` stream events and emits the final `STEP_FINISHED` with the signature on `content_block_stop`.
25+
- Includes thinking blocks with signatures in `formatMessages` for multi-turn history.
26+
- Passes `betas: ['interleaved-thinking-2025-05-14']` to the `beta.messages.create` call site when a thinking budget is configured. The beta flag is scoped to the streaming path only, so `structuredOutput` (which uses the non-beta `messages.create` endpoint) is unaffected.
27+
28+
`@tanstack/ai-client`:
29+
30+
- `ChatClient`'s internal `onThinkingUpdate` wiring is updated for the new `stepId` parameter.

.changeset/tricky-wings-sniff.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai-client': patch
3+
---
4+
5+
Fixes a race condition in ChatClient.streamResponse() where this.abortController.signal could reference a stale or null controller by the time it is passed to this.connection.connect()

.changeset/worker-loader-port.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@tanstack/ai-isolate-cloudflare': minor
3+
---
4+
5+
Port the Cloudflare worker driver from `unsafe_eval` to `worker_loader` (Dynamic Workers).
6+
7+
Cloudflare gates the `unsafe_eval` binding for all customer prod accounts; the previous driver was unusable in production and broken in `wrangler dev` on current Wrangler 4.x. The supported replacement is the `worker_loader` binding (GA-beta'd 2026-03-24).
8+
9+
**Breaking:** the worker now requires the `LOADER` binding instead of `UNSAFE_EVAL`. Update your `wrangler.toml`:
10+
11+
```toml
12+
# before
13+
[[unsafe.bindings]]
14+
name = "UNSAFE_EVAL"
15+
type = "unsafe_eval"
16+
17+
# after
18+
[[worker_loaders]]
19+
binding = "LOADER"
20+
```
21+
22+
The HTTP tool-callback protocol and public driver API are unchanged. Workers Paid plan is required for any edge usage (deploy or `wrangler dev --remote`); local `wrangler dev` works on the Free plan.
23+
24+
Closes #522.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ stats.html
4646
.tsup
4747
.vinxi
4848
temp
49+
.wrangler
4950

5051
vite.config.js.timestamp-*
5152
vite.config.ts.timestamp-*

docs/advanced/otel.md

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
---
2+
title: OpenTelemetry
3+
id: otel
4+
order: 4
5+
description: "Emit vendor-neutral OpenTelemetry traces and metrics from every TanStack AI chat() call, following the OTel GenAI semantic conventions."
6+
keywords:
7+
- tanstack ai
8+
- opentelemetry
9+
- otel
10+
- observability
11+
- tracing
12+
- metrics
13+
- gen_ai
14+
- semantic conventions
15+
---
16+
17+
The `otelMiddleware` factory wires TanStack AI into your existing OpenTelemetry setup. Every `chat()` call produces a root span, one child span per agent-loop iteration, and one grandchild span per tool call — all with [GenAI semantic-convention attributes](https://opentelemetry.io/docs/specs/semconv/gen-ai/). It also records GenAI token and duration histograms when a `Meter` is provided.
18+
19+
## Setup
20+
21+
Install `@opentelemetry/api` — it's an optional peer dependency of `@tanstack/ai`:
22+
23+
```bash
24+
pnpm add @opentelemetry/api
25+
```
26+
27+
Wire up your OTel SDK however you already do (e.g. `@opentelemetry/sdk-node`). Then pass a `Tracer` (and optionally a `Meter`) into the middleware. The OTel middleware lives on its own subpath — importing it never affects users who don't need OTel:
28+
29+
```ts
30+
import { chat } from '@tanstack/ai'
31+
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
32+
import { openaiText } from '@tanstack/ai-openai/adapters'
33+
import { trace, metrics } from '@opentelemetry/api'
34+
35+
const otel = otelMiddleware({
36+
tracer: trace.getTracer('my-app'),
37+
meter: metrics.getMeter('my-app'),
38+
})
39+
40+
const result = await chat({
41+
adapter: openaiText('gpt-4o'),
42+
messages: [{ role: 'user', content: 'hi' }],
43+
middleware: [otel],
44+
stream: false,
45+
})
46+
```
47+
48+
## What gets emitted
49+
50+
### Spans
51+
52+
```text
53+
chat gpt-4o (root, kind: INTERNAL)
54+
├── chat gpt-4o #0 (iteration, kind: CLIENT)
55+
│ ├── execute_tool get_weather
56+
│ └── execute_tool get_time
57+
└── chat gpt-4o #1 (iteration, kind: CLIENT)
58+
```
59+
60+
Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the same chat are easy to pick apart in trace viewers.
61+
62+
### Attribute reference
63+
64+
| Level | Attribute | Value |
65+
| --- | --- | --- |
66+
| root / iteration | `gen_ai.system` | `openai`, `anthropic`, ... |
67+
| iteration | `gen_ai.operation.name` | `chat` |
68+
| root / iteration | `gen_ai.request.model` | requested model |
69+
| iteration | `gen_ai.response.model` | actual model |
70+
| iteration | `gen_ai.request.temperature` | from config |
71+
| iteration | `gen_ai.request.top_p` | from config |
72+
| iteration | `gen_ai.request.max_tokens` | from config |
73+
| iteration | `gen_ai.usage.input_tokens` | per iteration |
74+
| iteration | `gen_ai.usage.output_tokens` | per iteration |
75+
| iteration | `gen_ai.response.finish_reasons` | `[stop]`, `[tool_calls]`, ... |
76+
| root | `gen_ai.usage.input_tokens` | rolled up |
77+
| root | `gen_ai.usage.output_tokens` | rolled up |
78+
| root | `tanstack.ai.iterations` | iteration count |
79+
| tool | `gen_ai.tool.name` | tool name |
80+
| tool | `gen_ai.tool.call.id` | tool call id |
81+
| tool | `gen_ai.tool.type` | `function` |
82+
| tool | `tanstack.ai.tool.outcome` | `success` / `error` |
83+
84+
### Metrics
85+
86+
Two GenAI-standard histograms:
87+
88+
- `gen_ai.client.operation.duration` (seconds) — recorded **once per `chat()` call**, covering all agent-loop iterations and tool execution. On error or abort the record carries an `error.type` attribute (the thrown error's `name`, or `"cancelled"` for aborts).
89+
- `gen_ai.client.token.usage` (tokens) — recorded **once per iteration** (two records: input and output), tagged with `gen_ai.token.type`.
90+
91+
Both `gen_ai.response.id` and `gen_ai.response.model` are deliberately excluded from metric attributes to keep cardinality low (per-request custom-model names and request IDs would blow up the series set).
92+
93+
## Privacy: capturing prompts and completions
94+
95+
By default, only metadata lands on spans. To record prompt and completion content, set `captureContent: true`. Content is captured as OTel span events following the GenAI convention:
96+
97+
- `gen_ai.user.message`, `gen_ai.system.message`, `gen_ai.assistant.message`, `gen_ai.tool.message`, `gen_ai.choice`
98+
99+
Pass a `redact` function to strip PII before anything is recorded:
100+
101+
```ts
102+
otelMiddleware({
103+
tracer,
104+
captureContent: true,
105+
redact: (text) => text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]'),
106+
})
107+
```
108+
109+
If `redact` throws, the middleware writes the literal sentinel `"[redaction_failed]"` into the span event and logs a warning — it never falls back to the raw content. This is the load-bearing invariant for users who ship traces to third-party backends: a broken redactor should shut off capture, not leak prompts.
110+
111+
Accumulated assistant text (the `gen_ai.choice` event) is capped at `maxContentLength` characters (default `100 000`); longer completions are truncated with a trailing `"…"` marker.
112+
113+
Multimodal content (images, audio, video, documents) is represented as placeholder strings (`[image]`, `[audio]`, ...) to preserve message order without dumping binary data onto spans. Use `onSpanEnd` if you need richer multimodal capture.
114+
115+
Prompt/system/user message events fire from `onConfig` at the start of every iteration, which means the full conversation history (as the adapter will re-send it) is re-emitted on each iteration span. This mirrors what the provider actually sees on the wire.
116+
117+
## Extension points
118+
119+
All four extensions are optional. Each wraps user code in try/catch — a thrown callback becomes a log line, never a broken chat.
120+
121+
### `spanNameFormatter(info)`
122+
123+
Override default span names. `info.kind` is `'chat' | 'iteration' | 'tool'`.
124+
125+
```ts
126+
otelMiddleware({
127+
tracer,
128+
spanNameFormatter: (info) =>
129+
info.kind === 'tool' ? `tool:${info.toolName}` : `chat:${info.ctx.model}`,
130+
})
131+
```
132+
133+
### `attributeEnricher(info)`
134+
135+
Add custom attributes to every span. Fires once per span.
136+
137+
```ts
138+
otelMiddleware({
139+
tracer,
140+
attributeEnricher: () => ({
141+
'tenant.id': getCurrentTenant(),
142+
}),
143+
})
144+
```
145+
146+
### `onBeforeSpanStart(info, options)`
147+
148+
Mutate `SpanOptions` immediately before `tracer.startSpan(...)`. Useful for adding links, custom start times, or extra default attributes.
149+
150+
### `onSpanEnd(info, span)`
151+
152+
Fires just before every `span.end()`. Common uses: record custom events, emit per-tool metrics via your own `Meter`.
153+
154+
```ts
155+
const toolDuration = meter.createHistogram('tool.duration')
156+
otelMiddleware({
157+
tracer,
158+
onSpanEnd: (info, span) => {
159+
if (info.kind === 'tool') {
160+
// span is still recording; read timestamps from your own store if needed
161+
toolDuration.record(1, { 'tool.name': info.toolName })
162+
}
163+
},
164+
})
165+
```
166+
167+
## Related
168+
169+
- [Middleware](./middleware) — the lifecycle this middleware hooks into
170+
- [Debug Logging](./debug-logging) — quick console-output diagnostics, complementary to OTel
171+
- [Observability](./observability) — TanStack AI's built-in event client

docs/config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@
175175
"label": "Debug Logging",
176176
"to": "advanced/debug-logging"
177177
},
178+
{
179+
"label": "OpenTelemetry",
180+
"to": "advanced/otel"
181+
},
178182
{
179183
"label": "Observability",
180184
"to": "advanced/observability"

knip.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"packages/react-ai": {
2323
"ignore": []
2424
},
25+
"packages/typescript/ai": {
26+
"ignoreDependencies": ["@opentelemetry/api"]
27+
},
2528
"packages/typescript/ai-anthropic": {
2629
"ignore": ["src/tools/**"]
2730
},

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
"test:types": "nx affected --targets=test:types --exclude=examples/**",
3434
"test:knip": "knip",
3535
"test:docs": "node scripts/verify-links.ts",
36+
"test:e2e": "pnpm --filter @tanstack/ai-e2e test:e2e",
37+
"test:e2e:ui": "pnpm --filter @tanstack/ai-e2e test:e2e:ui",
3638
"build": "nx affected --skip-nx-cache --targets=build --exclude=examples/**",
3739
"build:all": "nx run-many --targets=build --exclude=examples/**",
3840
"watch": "pnpm run build:all && env NX_DAEMON=true nx watch --all -- pnpm run build:all",

0 commit comments

Comments
 (0)