Skip to content
Merged
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
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`.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ docs/superpowers
# Only .claude.settings.json should be committed
.claude/settings.local.json
.claude/worktrees/*
.claude/scheduled_tasks.lock
solo.yml

# Agent scratch output (gap-analysis reports, triage notes — generated locally)
Expand Down
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
33 changes: 33 additions & 0 deletions packages/ai-anthropic/tests/anthropic-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1295,3 +1295,36 @@ describe('Anthropic stream processing', () => {
})
})
})

describe('Anthropic adapter error handling', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("forwards the SDK error's `.error` body as RUN_ERROR.rawEvent", async () => {
const providerBody = { type: 'rate_limit_error', message: 'slow down' }
mocks.betaMessagesCreate.mockRejectedValueOnce(
Object.assign(new Error('429'), {
status: 429,
error: providerBody,
}),
)

const adapter = createAdapter('claude-3-7-sonnet')
const chunks: StreamChunk[] = []
for await (const chunk of adapter.chatStream({
model: 'claude-3-7-sonnet',
messages: [{ role: 'user', content: 'hi' }],
logger: { request: () => {}, errors: () => {} } as any,
})) {
chunks.push(chunk)
}

const runError = chunks.find((c) => c.type === 'RUN_ERROR')
expect(runError).toBeDefined()
if (runError?.type === 'RUN_ERROR') {
expect(runError.rawEvent).toEqual(providerBody)
expect(runError.code).toBe('429')
}
})
})
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
30 changes: 30 additions & 0 deletions packages/ai-gemini/tests/gemini-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,3 +906,33 @@ describe('GeminiAdapter through AI', () => {
expect(result.summary).toBe(summaryText)
})
})

describe('Gemini adapter error handling', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("forwards the provider's structured error body as RUN_ERROR.rawEvent", async () => {
const providerBody = { error: { code: 429, message: 'quota' } }
mocks.generateContentStreamSpy.mockRejectedValueOnce(
Object.assign(new Error('429'), providerBody),
)

const adapter = createTextAdapter()
const chunks: StreamChunk[] = []
for await (const chunk of adapter.chatStream({
model: 'gemini-2.5-pro',
messages: [{ role: 'user', content: 'hi' }],
logger: { request: () => {}, errors: () => {} } as any,
})) {
chunks.push(chunk)
}

const runError = chunks.find((c) => c.type === 'RUN_ERROR')
expect(runError).toBeDefined()
if (runError?.type === 'RUN_ERROR') {
// toRunErrorRawEvent picks up the `.error` object when present
expect(runError.rawEvent).toEqual(providerBody.error)
}
})
})
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
Loading
Loading