Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
30 changes: 30 additions & 0 deletions .changeset/runerror-raw-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@tanstack/ai': minor
'@tanstack/openai-base': minor
'@tanstack/ai-openrouter': minor
'@tanstack/ai-anthropic': minor
'@tanstack/ai-gemini': minor
---

Populate AG-UI `rawEvent` on `RUN_ERROR` events with the provider's structured error body.

Previously, when a streaming chat call failed the `RUN_ERROR` event carried only an
opaque `{ message, code }` headline (e.g. `"Provider returned error"`), and no adapter
populated AG-UI's purpose-built `rawEvent` field — so the upstream provider detail was
unrecoverable.

Adapters now forward the provider's **structured error body** (e.g. an SDK `APIError`'s
parsed `.error` response body, or OpenRouter's mid-stream `chunk.error`) as `rawEvent`
on the `RUN_ERROR` event. The new `toRunErrorRawEvent` helper extracts only known
provider-body fields — never the raw SDK exception object, which can carry request
metadata such as auth headers. The `{ message, code }` contract of `toRunErrorPayload`
is unchanged.

The error surfaced to consumers via the `ChatClient` / `useChat` `error` (and the
`onError` callback) now also carries `code` and `rawEvent` when present, so the upstream
cause is recoverable in application code.

> Note: the OpenRouter SDK parses each in-band stream chunk's `error` through a strict
> schema (`{ code, message }`), so provider `metadata` survives only on pre-stream HTTP
> errors (rate-limit / overload / BYOK rejection), whose typed error class exposes the
> full body via `.error`.
8 changes: 8 additions & 0 deletions packages/ai-anthropic/src/adapters/text.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { convertToolsToProviderFormat } from '../tools/tool-converter'
import { validateTextProviderOptions } from '../text/text-provider-options'
Expand Down Expand Up @@ -181,6 +182,7 @@ export class AnthropicTextAdapter<
)
} catch (error: unknown) {
const err = error as Error & { status?: number; code?: string }
const rawEvent = toRunErrorRawEvent(error)
logger.errors('anthropic.chatStream fatal', {
error,
source: 'anthropic.chatStream',
Expand All @@ -191,6 +193,9 @@ export class AnthropicTextAdapter<
timestamp: Date.now(),
message: err.message || 'Unknown error occurred',
code: err.code || String(err.status),
// Forward the Anthropic SDK error's `.error` response body (e.g.
// `{ type, message }`) when present; never the raw exception object.
...(rawEvent !== undefined && { rawEvent }),
error: {
message: err.message || 'Unknown error occurred',
code: err.code || String(err.status),
Expand Down Expand Up @@ -1149,6 +1154,7 @@ export class AnthropicTextAdapter<
}
} catch (error: unknown) {
const err = error as Error & { status?: number; code?: string }
const rawEvent = toRunErrorRawEvent(error)

logger.errors('anthropic.processAnthropicStream fatal', {
error,
Expand All @@ -1160,6 +1166,8 @@ export class AnthropicTextAdapter<
timestamp: Date.now(),
message: err.message || 'Unknown error occurred',
code: err.code || String(err.status),
// Forward the Anthropic SDK error's `.error` response body when present.
...(rawEvent !== undefined && { rawEvent }),
error: {
message: err.message || 'Unknown error occurred',
code: err.code || String(err.status),
Expand Down
5 changes: 5 additions & 0 deletions packages/ai-gemini/src/adapters/text.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FinishReason } from '@google/genai'
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { convertToolsToProviderFormat } from '../tools/tool-converter'
import {
Expand Down Expand Up @@ -133,6 +134,7 @@ export class GeminiTextAdapter<

yield* this.processStreamChunks(result, options, logger)
} catch (error) {
const rawEvent = toRunErrorRawEvent(error)
logger.errors('gemini.chatStream fatal', {
error,
source: 'gemini.chatStream',
Expand All @@ -145,6 +147,9 @@ export class GeminiTextAdapter<
error instanceof Error
? error.message
: 'An unknown error occurred during the chat stream.',
// Forward the provider's structured error body when present (see
// toRunErrorRawEvent); omitted otherwise.
...(rawEvent !== undefined && { rawEvent }),
error: {
message:
error instanceof Error
Expand Down
17 changes: 16 additions & 1 deletion packages/ai-openrouter/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { OpenRouter } from '@openrouter/sdk'
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
import {
toRunErrorPayload,
toRunErrorRawEvent,
} from '@tanstack/ai/adapter-internals'
import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
import { extractRequestOptions } from '../internal/request-options'
import { makeStructuredOutputCompatible } from '../internal/schema-converter'
Expand Down Expand Up @@ -157,6 +160,7 @@ export class OpenRouterResponsesTextAdapter<
error,
`${this.name}.chatStream failed`,
)
const rawEvent = toRunErrorRawEvent(error)

// Emit RUN_STARTED if not yet emitted
if (!aguiState.hasEmittedRunStarted) {
Expand All @@ -177,6 +181,7 @@ export class OpenRouterResponsesTextAdapter<
timestamp: Date.now(),
message: errorPayload.message,
code: errorPayload.code,
...(rawEvent !== undefined && { rawEvent }),
error: {
message: errorPayload.message,
code: errorPayload.code,
Expand Down Expand Up @@ -518,13 +523,17 @@ export class OpenRouterResponsesTextAdapter<
const code =
normalizeCode(chunk.response?.error?.code) ??
(chunk.response?.incompleteDetails ? 'incomplete' : undefined)
const rawError = chunk.response?.error
yield {
type: EventType.RUN_ERROR,
runId: aguiState.runId,
model,
timestamp,
message,
...(code !== undefined && { code }),
// Forward the provider's structured error body when the failure
// carried one, so consumers can recover the upstream detail.
...(rawError != null && { rawEvent: rawError }),
error: {
message,
...(code !== undefined && { code }),
Expand Down Expand Up @@ -657,13 +666,15 @@ export class OpenRouterResponsesTextAdapter<
)

const resolvedCode = isAbort ? 'aborted' : errorPayload.code
const rawEvent = isAbort ? undefined : toRunErrorRawEvent(error)
yield {
type: EventType.RUN_ERROR,
runId: aguiState.runId,
model,
timestamp,
message: errorPayload.message,
...(resolvedCode !== undefined && { code: resolvedCode }),
...(rawEvent !== undefined && { rawEvent }),
error: {
message: errorPayload.message,
...(resolvedCode !== undefined && { code: resolvedCode }),
Expand Down Expand Up @@ -925,12 +936,14 @@ export class OpenRouterResponsesTextAdapter<
normalizeCode(chunk.response?.error?.code) ??
(chunk.response?.incompleteDetails ? 'incomplete' : undefined) ??
undefined
const rawError = chunk.response?.error
yield {
type: EventType.RUN_ERROR,
model,
timestamp: Date.now(),
message: errorMessage,
...(errorCode !== undefined && { code: errorCode }),
...(rawError != null && { rawEvent: rawError }),
error: {
message: errorMessage,
...(errorCode !== undefined && { code: errorCode }),
Expand Down Expand Up @@ -1485,6 +1498,7 @@ export class OpenRouterResponsesTextAdapter<
error,
`${this.name}.processStreamChunks failed`,
)
const rawEvent = toRunErrorRawEvent(error)
options.logger.errors(`${this.name}.processStreamChunks fatal`, {
error: errorPayload,
source: `${this.name}.processStreamChunks`,
Expand All @@ -1495,6 +1509,7 @@ export class OpenRouterResponsesTextAdapter<
timestamp: Date.now(),
message: errorPayload.message,
...(errorPayload.code !== undefined && { code: errorPayload.code }),
...(rawEvent !== undefined && { rawEvent }),
error: {
message: errorPayload.message,
...(errorPayload.code !== undefined && { code: errorPayload.code }),
Expand Down
28 changes: 24 additions & 4 deletions packages/ai-openrouter/src/adapters/text.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { OpenRouter } from '@openrouter/sdk'
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
import {
toRunErrorPayload,
toRunErrorRawEvent,
} from '@tanstack/ai/adapter-internals'
import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils'
import { extractRequestOptions } from '../internal/request-options'
import { makeStructuredOutputCompatible } from '../internal/schema-converter'
Expand Down Expand Up @@ -150,6 +153,7 @@ export class OpenRouterTextAdapter<
error,
`${this.name}.chatStream failed`,
)
const rawEvent = toRunErrorRawEvent(error)

// Emit RUN_STARTED if not yet emitted
if (!aguiState.hasEmittedRunStarted) {
Expand All @@ -164,13 +168,16 @@ export class OpenRouterTextAdapter<
}
}

// Emit AG-UI RUN_ERROR
// Emit AG-UI RUN_ERROR. `rawEvent` carries the provider's structured
// error body (e.g. a pre-stream typed error's `.error` with provider
// metadata) when present; omitted otherwise.
yield {
type: EventType.RUN_ERROR,
model: options.model,
timestamp: Date.now(),
message: errorPayload.message,
code: errorPayload.code,
...(rawEvent !== undefined && { rawEvent }),
error: {
message: errorPayload.message,
code: errorPayload.code,
Expand Down Expand Up @@ -583,13 +590,15 @@ export class OpenRouterTextAdapter<
)

const resolvedCode = isAbort ? 'aborted' : errorPayload.code
const rawEvent = isAbort ? undefined : toRunErrorRawEvent(error)
yield {
type: EventType.RUN_ERROR,
runId: aguiState.runId,
model: lastModel || chatOptions.model,
timestamp,
message: errorPayload.message,
...(resolvedCode !== undefined && { code: resolvedCode }),
...(rawEvent !== undefined && { rawEvent }),
error: {
message: errorPayload.message,
...(resolvedCode !== undefined && { code: resolvedCode }),
Expand Down Expand Up @@ -695,9 +704,16 @@ export class OpenRouterTextAdapter<
// chunks may carry an `error` field (provider-side failures that
// happen mid-stream rather than as an SDK throw).
if (chunk.error) {
// Preserve the provider's structured error body on the thrown error
// so the RUN_ERROR catch can forward it as `rawEvent`. NOTE: the
// OpenRouter SDK parses each stream chunk's `error` through a strict
// schema (`{ code, message }`), so any `error.metadata` the gateway
// sent in-band is already stripped here — only pre-stream HTTP errors
// (caught in the outer catch) retain `error.metadata` via their typed
// error class's `.error` body.
throw Object.assign(
new Error(chunk.error.message || 'OpenRouter stream error'),
{ code: chunk.error.code },
{ code: chunk.error.code, rawEvent: chunk.error },
)
}

Expand Down Expand Up @@ -1091,18 +1107,22 @@ export class OpenRouterTextAdapter<
error,
`${this.name}.processStreamChunks failed`,
)
const rawEvent = toRunErrorRawEvent(error)
options.logger.errors(`${this.name}.processStreamChunks fatal`, {
error: errorPayload,
source: `${this.name}.processStreamChunks`,
})

// Emit AG-UI RUN_ERROR
// Emit AG-UI RUN_ERROR. `rawEvent` carries the provider's structured
// error body (e.g. the mid-stream `chunk.error` rethrown above) when
// present.
yield {
type: EventType.RUN_ERROR,
model: options.model,
timestamp: Date.now(),
message: errorPayload.message,
...(errorPayload.code !== undefined && { code: errorPayload.code }),
...(rawEvent !== undefined && { rawEvent }),
error: {
message: errorPayload.message,
...(errorPayload.code !== undefined && { code: errorPayload.code }),
Expand Down
37 changes: 37 additions & 0 deletions packages/ai-openrouter/tests/openrouter-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,43 @@ describe('OpenRouter AG-UI event emission', () => {
}
})

it('forwards the inline error body as RUN_ERROR.rawEvent', async () => {
// The mid-stream rethrow attaches the parsed `chunk.error` to the thrown
// error so the catch can surface it as `rawEvent`. Note: the real SDK
// strips unknown keys from in-band stream errors to `{ code, message }`;
// richer provider `metadata` survives only on pre-stream HTTP errors.
const errorBody = {
message: 'Provider returned error',
code: 502,
metadata: { provider_name: 'anthropic', raw: 'upstream overloaded' },
}
const streamChunks = [
{
id: 'chatcmpl-err',
model: 'anthropic/claude-sonnet-4.6',
choices: [] as Array<unknown>,
error: errorBody,
},
]

setupMockSdkClient(streamChunks)
const adapter = createAdapter()
const chunks: Array<StreamChunk> = []
for await (const chunk of adapter.chatStream({
model: 'anthropic/claude-sonnet-4.6',
messages: [{ role: 'user', content: 'Hello' }],
logger: testLogger,
})) {
chunks.push(chunk)
}

const runErrorChunk = chunks.find((c) => c.type === 'RUN_ERROR')
expect(runErrorChunk).toBeDefined()
if (runErrorChunk?.type === 'RUN_ERROR') {
expect(runErrorChunk.rawEvent).toEqual(errorBody)
}
})

it('drops object-shaped error.code rather than shipping "[object Object]"', async () => {
// A misbehaving upstream sending an object as `error.code` previously
// surfaced as `code: "[object Object]"` in RUN_ERROR because the chunk
Expand Down
14 changes: 13 additions & 1 deletion packages/ai/src/activities/chat/stream/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1325,7 +1325,19 @@ export class StreamProcessor {
this.emitMessagesChange()
}

this.events.onError?.(new Error(errorMessage))
// Attach the provider's structured error body (`rawEvent`) and `code` to
// the surfaced Error so consumers can recover the upstream detail that the
// RUN_ERROR's `message` alone discards. Both are optional and added only
// when present, keeping the Error backward compatible.
const error = new Error(errorMessage)
const code = chunk.code ?? chunk.error?.code
if (code !== undefined) {
Object.assign(error, { code })
}
if (chunk.rawEvent !== undefined) {
Object.assign(error, { rawEvent: chunk.rawEvent })
}
this.events.onError?.(error)
}

/**
Expand Down
44 changes: 44 additions & 0 deletions packages/ai/src/activities/error-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,47 @@ export function toRunErrorPayload(
}
return { message: fallbackMessage, code: undefined }
}

/**
* Extract the provider's *structured error body* from a thrown value, to attach
* as the AG-UI `rawEvent` on a RUN_ERROR event. This is the recoverable upstream
* detail (provider name, the upstream model's error JSON, rate-limit/overload
* codes, etc.) that `toRunErrorPayload`'s `{ message, code }` deliberately drops.
*
* Security boundary: only known provider-response-body fields are forwarded —
* never the raw SDK exception object, which can carry request metadata such as
* auth headers or request ids. The recognized sources, in priority order:
*
* - `error.rawEvent` — a provider body an adapter attached explicitly (e.g. the
* OpenRouter mid-stream `chunk.error`).
* - `error.error` (object) — the parsed provider response body exposed by SDK
* `APIError` instances (OpenAI/Anthropic `{ type, message, code, param }`,
* OpenRouter typed errors whose `.error` carries `.metadata`). This is
* provider-shaped data, distinct from `.headers` / `.request_id`.
* - `error.metadata` — OpenRouter's `provider_name` + raw upstream body, when
* surfaced directly on the thrown error.
*
* Returns `undefined` when no structured provider body is present, so callers
* omit the field entirely rather than setting it to `null`:
*
* const rawEvent = toRunErrorRawEvent(error)
* yield { type: EventType.RUN_ERROR, ..., ...(rawEvent !== undefined && { rawEvent }) }
*/
export function toRunErrorRawEvent(error: unknown): unknown {
if (!error || typeof error !== 'object') return undefined
const e = error as {
rawEvent?: unknown
error?: unknown
metadata?: unknown
}
if (e.rawEvent !== undefined && e.rawEvent !== null) return e.rawEvent
if (
e.error !== undefined &&
e.error !== null &&
typeof e.error === 'object'
) {
return e.error
}
if (e.metadata !== undefined && e.metadata !== null) return e.metadata
return undefined
}
Loading
Loading