diff --git a/.changeset/runerror-raw-event.md b/.changeset/runerror-raw-event.md new file mode 100644 index 000000000..fb7667492 --- /dev/null +++ b/.changeset/runerror-raw-event.md @@ -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`. diff --git a/.gitignore b/.gitignore index c05210e1b..6678fb779 100644 --- a/.gitignore +++ b/.gitignore @@ -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) diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index f6b5b9d2d..536fb2521 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -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' @@ -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', @@ -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), @@ -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, @@ -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), diff --git a/packages/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/ai-anthropic/tests/anthropic-adapter.test.ts index aad33ee67..972d247c7 100644 --- a/packages/ai-anthropic/tests/anthropic-adapter.test.ts +++ b/packages/ai-anthropic/tests/anthropic-adapter.test.ts @@ -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') + } + }) +}) diff --git a/packages/ai-gemini/src/adapters/text.ts b/packages/ai-gemini/src/adapters/text.ts index 8f9ee636a..2b8cc2869 100644 --- a/packages/ai-gemini/src/adapters/text.ts +++ b/packages/ai-gemini/src/adapters/text.ts @@ -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 { @@ -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', @@ -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 diff --git a/packages/ai-gemini/tests/gemini-adapter.test.ts b/packages/ai-gemini/tests/gemini-adapter.test.ts index 5e022ee81..c903dc8dd 100644 --- a/packages/ai-gemini/tests/gemini-adapter.test.ts +++ b/packages/ai-gemini/tests/gemini-adapter.test.ts @@ -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) + } + }) +}) diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index edea58f92..598ddd1e6 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -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' @@ -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) { @@ -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, @@ -518,6 +523,7 @@ 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, @@ -525,6 +531,9 @@ export class OpenRouterResponsesTextAdapter< 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 }), @@ -657,6 +666,7 @@ export class OpenRouterResponsesTextAdapter< ) const resolvedCode = isAbort ? 'aborted' : errorPayload.code + const rawEvent = isAbort ? undefined : toRunErrorRawEvent(error) yield { type: EventType.RUN_ERROR, runId: aguiState.runId, @@ -664,6 +674,7 @@ export class OpenRouterResponsesTextAdapter< timestamp, message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), + ...(rawEvent !== undefined && { rawEvent }), error: { message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), @@ -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 }), @@ -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`, @@ -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 }), diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index ee5a3a5c2..1342aa415 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -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' @@ -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) { @@ -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, @@ -583,6 +590,7 @@ export class OpenRouterTextAdapter< ) const resolvedCode = isAbort ? 'aborted' : errorPayload.code + const rawEvent = isAbort ? undefined : toRunErrorRawEvent(error) yield { type: EventType.RUN_ERROR, runId: aguiState.runId, @@ -590,6 +598,7 @@ export class OpenRouterTextAdapter< timestamp, message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), + ...(rawEvent !== undefined && { rawEvent }), error: { message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), @@ -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 }, ) } @@ -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 }), diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index 02a5edb15..587900a35 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -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, + error: errorBody, + }, + ] + + setupMockSdkClient(streamChunks) + const adapter = createAdapter() + const chunks: Array = [] + 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 diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index 4e1ac11ca..2198d1a49 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -687,10 +687,42 @@ describe('OpenRouter responses adapter — stream event bridge', () => { expect(err).toBeDefined() expect(err.error.message).toBe('kaboom') expect(err.error.code).toBe('server_error') + // The provider's structured error body is forwarded as rawEvent for + // in-band response.failed events (see responses-text adapter). + expect(err.rawEvent).toEqual({ message: 'kaboom', code: 'server_error' }) // RUN_ERROR is terminal — no synthetic RUN_FINISHED should follow. expect(chunks.find((c) => c.type === 'RUN_FINISHED')).toBeUndefined() }) + it("forwards the SDK error's `.error` body as RUN_ERROR.rawEvent on outer catch", async () => { + const providerBody = { + message: 'Provider returned error', + code: 502, + metadata: { provider_name: 'openai', raw: 'overloaded' }, + } + mockSend = vi.fn().mockRejectedValue( + Object.assign(new Error('OpenRouter error'), { + error: providerBody, + }), + ) + + const adapter = createAdapter() + const chunks: Array = [] + for await (const c of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + })) { + chunks.push(c) + } + + const runError = chunks.find((c) => c.type === 'RUN_ERROR') + expect(runError).toBeDefined() + if (runError?.type === 'RUN_ERROR') { + expect(runError.rawEvent).toEqual(providerBody) + } + }) + it('stringifies non-string error.code on top-level error events', async () => { setupMockSdkClient([ { diff --git a/packages/ai/src/activities/chat/stream/processor.ts b/packages/ai/src/activities/chat/stream/processor.ts index 83ad0f92a..7b07133f7 100644 --- a/packages/ai/src/activities/chat/stream/processor.ts +++ b/packages/ai/src/activities/chat/stream/processor.ts @@ -1294,7 +1294,10 @@ export class StreamProcessor { chunk: Extract, ): void { this.hasError = true - const runId = (chunk as any).runId as string | undefined + const runId = + 'runId' in chunk && typeof chunk.runId === 'string' + ? chunk.runId + : undefined if (runId) { this.activeRuns.delete(runId) } else { @@ -1325,7 +1328,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) } /** diff --git a/packages/ai/src/activities/error-payload.ts b/packages/ai/src/activities/error-payload.ts index 3ab7d101d..42b322b01 100644 --- a/packages/ai/src/activities/error-payload.ts +++ b/packages/ai/src/activities/error-payload.ts @@ -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 +} diff --git a/packages/ai/src/adapter-internals.ts b/packages/ai/src/adapter-internals.ts index e8a5ac27c..f45fafc12 100644 --- a/packages/ai/src/adapter-internals.ts +++ b/packages/ai/src/adapter-internals.ts @@ -6,4 +6,7 @@ export type { ResolvedCategories } from './logger/internal-logger' export { InternalLogger } from './logger/internal-logger' export type { Logger } from './logger/types' export { resolveDebugOption } from './logger/resolve' -export { toRunErrorPayload } from './activities/error-payload' +export { + toRunErrorPayload, + toRunErrorRawEvent, +} from './activities/error-payload' diff --git a/packages/ai/tests/error-payload.test.ts b/packages/ai/tests/error-payload.test.ts index 75d6bab96..fb5655fdc 100644 --- a/packages/ai/tests/error-payload.test.ts +++ b/packages/ai/tests/error-payload.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { toRunErrorPayload } from '../src/activities/error-payload' +import { + toRunErrorPayload, + toRunErrorRawEvent, +} from '../src/activities/error-payload' describe('toRunErrorPayload', () => { it('narrows an Error instance, extracting message and code', () => { @@ -130,3 +133,57 @@ describe('toRunErrorPayload', () => { }) }) }) + +describe('toRunErrorRawEvent', () => { + it('forwards an explicit `rawEvent` attached to the error', () => { + const providerBody = { provider_name: 'anthropic', raw: { foo: 'bar' } } + const err = Object.assign(new Error('Provider returned error'), { + code: 502, + rawEvent: providerBody, + }) + expect(toRunErrorRawEvent(err)).toBe(providerBody) + }) + + it("forwards an SDK APIError's object-valued `error` response body", () => { + const body = { type: 'rate_limit_error', message: 'slow down', code: 429 } + const err = Object.assign(new Error('429'), { status: 429, error: body }) + expect(toRunErrorRawEvent(err)).toBe(body) + }) + + it('forwards `metadata` when no richer body is present', () => { + const metadata = { provider_name: 'openai', raw: 'overloaded' } + expect(toRunErrorRawEvent({ message: 'boom', metadata })).toBe(metadata) + }) + + it('prefers `rawEvent` over `error` over `metadata`', () => { + const raw = { winner: true } + const err = { + rawEvent: raw, + error: { loser: true }, + metadata: { loser: true }, + } + expect(toRunErrorRawEvent(err)).toBe(raw) + }) + + it('ignores a string-valued `error` field (not a structured body)', () => { + expect(toRunErrorRawEvent({ message: 'x', error: 'just a string' })).toBe( + undefined, + ) + }) + + it('returns undefined for plain errors, strings, and nullish values', () => { + expect(toRunErrorRawEvent(new Error('plain'))).toBe(undefined) + expect(toRunErrorRawEvent('string error')).toBe(undefined) + expect(toRunErrorRawEvent(null)).toBe(undefined) + expect(toRunErrorRawEvent(undefined)).toBe(undefined) + }) + + it('never returns the raw exception object itself (no header leakage)', () => { + const err = Object.assign(new Error('leaky'), { + request: { headers: { authorization: 'Bearer secret' } }, + }) + const raw = toRunErrorRawEvent(err) + expect(raw).toBe(undefined) + expect(raw).not.toBe(err) + }) +}) diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index 48cc42d52..bc6580520 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -1792,6 +1792,32 @@ describe('StreamProcessor', () => { expect(events.onError.mock.calls[0]![0].message).toBe('API rate limited') }) + it('attaches code and rawEvent from RUN_ERROR chunk to the Error passed to onError', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + processor.prepareAssistantMessage() + + const rawBody = { provider_name: 'openai', raw: { reason: 'quota' } } + const errorChunk = chunk(EventType.RUN_ERROR, { + message: 'Rate limited', + runId: 'run-1', + code: 'rate_limit', + rawEvent: rawBody, + error: { message: 'Rate limited' }, + }) + processor.processChunk(errorChunk) + + expect(events.onError).toHaveBeenCalledTimes(1) + const err = events.onError.mock.calls[0]![0] as Error & { + code?: string + rawEvent?: unknown + } + expect(err).toBeInstanceOf(Error) + expect(err.message).toBe('Rate limited') + expect(err.code).toBe('rate_limit') + expect(err.rawEvent).toEqual(rawBody) + }) + it('onTextUpdate should fire for each text emission', () => { const events = spyEvents() const processor = new StreamProcessor({ events }) diff --git a/packages/openai-base/src/adapters/chat-completions-text.ts b/packages/openai-base/src/adapters/chat-completions-text.ts index 064f92b9d..17583e966 100644 --- a/packages/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/openai-base/src/adapters/chat-completions-text.ts @@ -1,6 +1,9 @@ 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 '../utils/request-options' import { makeStructuredOutputCompatible } from '../utils/schema-converter' @@ -96,6 +99,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< error, `${this.name}.chatStream failed`, ) + const rawEvent = toRunErrorRawEvent(error) // Emit RUN_STARTED if not yet emitted if (!aguiState.hasEmittedRunStarted) { @@ -120,6 +124,10 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< timestamp: Date.now(), message: errorPayload.message, code: errorPayload.code, + // Forward the provider's structured error body so consumers can recover + // the upstream detail the `{ message, code }` payload drops. Omitted + // when the error carried no provider body (see toRunErrorRawEvent). + ...(rawEvent !== undefined && { rawEvent }), error: { message: errorPayload.message, code: errorPayload.code, @@ -528,6 +536,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< // `exactOptionalPropertyTypes`: AG-UI's `RunErrorEvent.code` is `string?` // (absent vs explicit `undefined` matter). const resolvedCode = isAbort ? 'aborted' : errorPayload.code + const rawEvent = isAbort ? undefined : toRunErrorRawEvent(error) yield { type: EventType.RUN_ERROR, runId: aguiState.runId, @@ -535,6 +544,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< timestamp, message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), + ...(rawEvent !== undefined && { rawEvent }), error: { message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), @@ -1069,19 +1079,22 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< 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 with conditional `code` spread (see chatStream's - // catch block for the rationale). + // catch block for the rationale). `rawEvent` carries the provider's + // structured error body 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 }), diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index 43b3d9138..3097e6d81 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -1,6 +1,9 @@ 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 '../utils/request-options' import { makeStructuredOutputCompatible } from '../utils/schema-converter' @@ -119,6 +122,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< error, `${this.name}.chatStream failed`, ) + const rawEvent = toRunErrorRawEvent(error) // Emit RUN_STARTED if not yet emitted if (!aguiState.hasEmittedRunStarted) { @@ -143,6 +147,9 @@ export abstract class OpenAIBaseResponsesTextAdapter< timestamp: Date.now(), message: errorPayload.message, code: errorPayload.code, + // Forward the provider's structured error body when present (see + // toRunErrorRawEvent); omitted otherwise. + ...(rawEvent !== undefined && { rawEvent }), error: { message: errorPayload.message, code: errorPayload.code, @@ -619,6 +626,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< // Conditional `code` spread keeps the wire shape spec-compliant under // `exactOptionalPropertyTypes` (see chatStream catch). const resolvedCode = isAbort ? 'aborted' : errorPayload.code + const rawEvent = isAbort ? undefined : toRunErrorRawEvent(error) yield { type: EventType.RUN_ERROR, runId: aguiState.runId, @@ -626,6 +634,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< timestamp, message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), + ...(rawEvent !== undefined && { rawEvent }), error: { message: errorPayload.message, ...(resolvedCode !== undefined && { code: resolvedCode }), @@ -1570,18 +1579,21 @@ export abstract class OpenAIBaseResponsesTextAdapter< 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 with conditional `code` spread (see chatStream - // catch for the rationale). + // catch for the rationale). `rawEvent` carries the provider's structured + // error body 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 }), diff --git a/packages/openai-base/tests/chat-completions-text.test.ts b/packages/openai-base/tests/chat-completions-text.test.ts index 3345c9f9c..656dd876b 100644 --- a/packages/openai-base/tests/chat-completions-text.test.ts +++ b/packages/openai-base/tests/chat-completions-text.test.ts @@ -624,6 +624,56 @@ describe('OpenAIBaseChatCompletionsTextAdapter', () => { expect(chunks[1].error!.message).toBe('API key invalid') } }) + + it("forwards the SDK error's `.error` body as RUN_ERROR.rawEvent", async () => { + const providerBody = { + type: 'insufficient_quota', + message: 'You exceeded your current quota', + code: 'insufficient_quota', + } + mockCreate = vi.fn().mockRejectedValue( + Object.assign(new Error('429 You exceeded your current quota'), { + status: 429, + error: providerBody, + }), + ) + + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + 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) + } + }) + + it('omits rawEvent when the error carries no provider body', async () => { + mockCreate = vi.fn().mockRejectedValue(new Error('network down')) + + const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model') + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + const runError = chunks.find((c) => c.type === 'RUN_ERROR') + expect(runError).toBeDefined() + if (runError?.type === 'RUN_ERROR') { + expect(runError).not.toHaveProperty('rawEvent') + } + }) }) describe('structured output', () => { diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index 7430f6947..bf6b5f4e9 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -1613,6 +1613,56 @@ describe('OpenAIBaseResponsesTextAdapter', () => { expect(errorChunk.error!.code).toBe('rate_limit') } }) + + it("forwards the SDK error's `.error` body as RUN_ERROR.rawEvent", async () => { + const providerBody = { + type: 'insufficient_quota', + message: 'You exceeded your current quota', + code: 'insufficient_quota', + } + mockResponsesCreate = vi.fn().mockRejectedValue( + Object.assign(new Error('429 You exceeded your current quota'), { + status: 429, + error: providerBody, + }), + ) + + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + 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) + } + }) + + it('omits rawEvent when the error carries no provider body', async () => { + mockResponsesCreate = vi.fn().mockRejectedValue(new Error('network down')) + + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk) + } + + const runError = chunks.find((c) => c.type === 'RUN_ERROR') + expect(runError).toBeDefined() + if (runError?.type === 'RUN_ERROR') { + expect(runError).not.toHaveProperty('rawEvent') + } + }) }) describe('structured output', () => { diff --git a/testing/e2e/src/routes/api.tools-test.ts b/testing/e2e/src/routes/api.tools-test.ts index 968e02c61..ca44dfe59 100644 --- a/testing/e2e/src/routes/api.tools-test.ts +++ b/testing/e2e/src/routes/api.tools-test.ts @@ -189,6 +189,14 @@ export const Route = createFileRoute('/api/tools-test')({ type: EventType.RUN_ERROR, message: 'Test error: Something went wrong during generation', timestamp: Date.now(), + code: 'provider_error', + // Mirrors a provider's structured error body forwarded by the + // adapters as `rawEvent`. Asserts it survives SSE transport, + // the strip-to-spec middleware, and reaches the consumer. + rawEvent: { + provider_name: 'test-provider', + raw: { reason: 'upstream overloaded' }, + }, } })() return toServerSentEventsResponse(errorStream, { abortController }) diff --git a/testing/e2e/src/routes/tools-test.tsx b/testing/e2e/src/routes/tools-test.tsx index d4de65a9a..ad4f58cf0 100644 --- a/testing/e2e/src/routes/tools-test.tsx +++ b/testing/e2e/src/routes/tools-test.tsx @@ -689,6 +689,11 @@ function ToolsTestPage() { } data-has-error={(!!error).toString()} data-error-message={error?.message || ''} + data-error-raw-event={ + error && 'rawEvent' in error + ? JSON.stringify((error as { rawEvent?: unknown }).rawEvent) + : '' + } /> {/* Event log as JSON for easy parsing in tests */} diff --git a/testing/e2e/tests/error-handling.spec.ts b/testing/e2e/tests/error-handling.spec.ts index 89c187a69..2779d0e72 100644 --- a/testing/e2e/tests/error-handling.spec.ts +++ b/testing/e2e/tests/error-handling.spec.ts @@ -28,6 +28,33 @@ test.describe('Error Handling', () => { await expect(page.locator('#error-display')).toBeVisible() }) + test('RUN_ERROR rawEvent survives transport and reaches the consumer', async ({ + page, + testId, + aimockPort, + }) => { + await selectScenario(page, 'error', testId, aimockPort) + await runTest(page) + + await page.waitForFunction( + () => + document + .querySelector('#test-metadata') + ?.getAttribute('data-has-error') === 'true', + { timeout: 10000 }, + ) + + // The provider's structured error body forwarded as RUN_ERROR.rawEvent must + // survive SSE transport + the strip-to-spec middleware and be recoverable + // on the consumer-facing error. + const metadata = await getMetadata(page) + expect(metadata.errorRawEvent).not.toBe('') + expect(JSON.parse(metadata.errorRawEvent)).toEqual({ + provider_name: 'test-provider', + raw: { reason: 'upstream overloaded' }, + }) + }) + // Fixture loaded from fixtures/error/basic.json (static, shared across workers) test('aimock error fixture returns error to client', async ({ page, diff --git a/testing/e2e/tests/tools-test/helpers.ts b/testing/e2e/tests/tools-test/helpers.ts index 2039fa122..a1d8ea111 100644 --- a/testing/e2e/tests/tools-test/helpers.ts +++ b/testing/e2e/tests/tools-test/helpers.ts @@ -239,6 +239,7 @@ export async function getMetadata(page: Page): Promise> { approvalDeniedCount: el.getAttribute('data-approval-denied-count') || '', hasError: el.getAttribute('data-has-error') || '', errorMessage: el.getAttribute('data-error-message') || '', + errorRawEvent: el.getAttribute('data-error-raw-event') || '', } }) }