Summary
When a streaming chat call fails, the RUN_ERROR event that reaches consumers contains only an opaque headline (e.g. OpenRouter's "Provider returned error") with no way to recover the underlying provider detail. The adapters discard the provider's structured error body before the AG-UI event is constructed, and no adapter populates the AG-UI rawEvent field, even though RunErrorEvent is explicitly designed to carry it.
This makes production failures very hard to diagnose: the only signal is "Provider returned error", and the actual upstream cause (provider name + the upstream model's error body — rate limit, overload, BYOK key rejected, etc.) is gone by the time it leaves the adapter.
Repro
Stream any chat where the upstream provider returns a mid-stream error (easiest via OpenRouter anthropic/* when the routed provider rejects the request). The emitted RUN_ERROR event is:
OpenRouter actually returned error.metadata containing provider_name and the raw upstream body — none of which survive.
Root cause
The loss happens in two stacked places, and the second affects every adapter.
1. Provider metadata is dropped at the adapter rethrow. packages/ai-openrouter/src/adapters/text.ts (~L697):
if (chunk.error) {
throw Object.assign(
new Error(chunk.error.message || 'OpenRouter stream error'),
{ code: chunk.error.code }, // <- chunk.error.metadata (provider_name, raw) discarded
)
}
2. The shared payload helper collapses everything to { message, code }. packages/ai/src/activities/error-payload.ts — toRunErrorPayload() returns { message: string; code: string | undefined } and nothing else, by design ("Never leaks the full error…").
3. No adapter sets rawEvent on the RUN_ERROR event. The canonical construction (packages/openai-base/src/adapters/chat-completions-text.ts, ~L113) is:
yield {
type: EventType.RUN_ERROR,
model: options.model,
timestamp: Date.now(),
message: errorPayload.message,
code: errorPayload.code,
error: { message: errorPayload.message, code: errorPayload.code },
}
I checked all text adapters on main — the pattern is identical and rawEvent is never populated in any of them:
packages/openai-base/src/adapters/chat-completions-text.ts (shared by openai, grok, groq, ollama, openrouter chat path)
packages/openai-base/src/adapters/responses-text.ts
packages/ai-openrouter/src/adapters/text.ts and responses-text.ts
packages/ai-anthropic/src/adapters/text.ts
packages/ai-gemini/src/adapters/text.ts
So this is systemic, not an OpenRouter-specific bug.
Why this should be easy/acceptable
AG-UI already provides the home for this. RunErrorEventSchema in @ag-ui/core:
{ type: RUN_ERROR, message: string, code?: string, timestamp?: number, rawEvent?: any } // mode: "passthrough"
rawEvent is purpose-built for the raw provider event, and the schema is passthrough (adapters already exploit this — runId/model/error ride along today). So no protocol change is needed — adapters just need to populate rawEvent (or a structured metadata) on the RUN_ERROR they already emit. This leaves the { message, code } contract of toRunErrorPayload untouched.
Important constraint (from your own comments)
chat-completions-text.ts notes: "raw SDK errors can carry request metadata (including auth headers) which we must never surface to user loggers." That's a valid concern, so the fix should attach the provider's structured error body (e.g. OpenRouter's chunk.error.metadata / the upstream model error JSON) to rawEvent, not the raw SDK exception object. The mid-stream chunk.error payload is provider-shaped data, not the SDK's request/headers, so it's safe to forward.
Proposed fix
- In the OpenRouter adapter rethrow, preserve
chunk.error.metadata on the thrown error (alongside code).
- Add an optional
rawEvent/metadata field to what flows into the RUN_ERROR construction (without changing toRunErrorPayload's { message, code } return), and set rawEvent on the emitted RUN_ERROR events across adapters when the provider supplied a structured error body.
Happy to open a PR if the approach sounds right — wanted to confirm direction (populate rawEvent vs. a dedicated metadata field) and the security boundary (provider error body only, never the raw SDK error) before doing so.
Environment
@tanstack/ai 0.17 (also verified the relevant code paths are unchanged on main / latest 0.23.x)
@tanstack/ai-openrouter 0.9 (latest 0.10)
Summary
When a streaming chat call fails, the
RUN_ERRORevent that reaches consumers contains only an opaque headline (e.g. OpenRouter's"Provider returned error") with no way to recover the underlying provider detail. The adapters discard the provider's structured error body before the AG-UI event is constructed, and no adapter populates the AG-UIrawEventfield, even thoughRunErrorEventis explicitly designed to carry it.This makes production failures very hard to diagnose: the only signal is
"Provider returned error", and the actual upstream cause (provider name + the upstream model's error body — rate limit, overload, BYOK key rejected, etc.) is gone by the time it leaves the adapter.Repro
Stream any chat where the upstream provider returns a mid-stream error (easiest via OpenRouter
anthropic/*when the routed provider rejects the request). The emittedRUN_ERRORevent is:{ "type": "RUN_ERROR", "runId": "openrouter-…", "model": "anthropic/claude-sonnet-4.6", "timestamp": 1780273924961, "message": "Provider returned error", "error": { "message": "Provider returned error" } }OpenRouter actually returned
error.metadatacontainingprovider_nameand therawupstream body — none of which survive.Root cause
The loss happens in two stacked places, and the second affects every adapter.
1. Provider metadata is dropped at the adapter rethrow.
packages/ai-openrouter/src/adapters/text.ts(~L697):2. The shared payload helper collapses everything to
{ message, code }.packages/ai/src/activities/error-payload.ts—toRunErrorPayload()returns{ message: string; code: string | undefined }and nothing else, by design ("Never leaks the full error…").3. No adapter sets
rawEventon the RUN_ERROR event. The canonical construction (packages/openai-base/src/adapters/chat-completions-text.ts, ~L113) is:I checked all text adapters on
main— the pattern is identical andrawEventis never populated in any of them:packages/openai-base/src/adapters/chat-completions-text.ts(shared by openai, grok, groq, ollama, openrouter chat path)packages/openai-base/src/adapters/responses-text.tspackages/ai-openrouter/src/adapters/text.tsandresponses-text.tspackages/ai-anthropic/src/adapters/text.tspackages/ai-gemini/src/adapters/text.tsSo this is systemic, not an OpenRouter-specific bug.
Why this should be easy/acceptable
AG-UI already provides the home for this.
RunErrorEventSchemain@ag-ui/core:rawEventis purpose-built for the raw provider event, and the schema ispassthrough(adapters already exploit this —runId/model/errorride along today). So no protocol change is needed — adapters just need to populaterawEvent(or a structuredmetadata) on the RUN_ERROR they already emit. This leaves the{ message, code }contract oftoRunErrorPayloaduntouched.Important constraint (from your own comments)
chat-completions-text.tsnotes: "raw SDK errors can carry request metadata (including auth headers) which we must never surface to user loggers." That's a valid concern, so the fix should attach the provider's structured error body (e.g. OpenRouter'schunk.error.metadata/ the upstream model error JSON) torawEvent, not the raw SDK exception object. The mid-streamchunk.errorpayload is provider-shaped data, not the SDK's request/headers, so it's safe to forward.Proposed fix
chunk.error.metadataon the thrown error (alongsidecode).rawEvent/metadatafield to what flows into the RUN_ERROR construction (without changingtoRunErrorPayload's{ message, code }return), and setrawEventon the emittedRUN_ERRORevents across adapters when the provider supplied a structured error body.Happy to open a PR if the approach sounds right — wanted to confirm direction (populate
rawEventvs. a dedicatedmetadatafield) and the security boundary (provider error body only, never the raw SDK error) before doing so.Environment
@tanstack/ai0.17 (also verified the relevant code paths are unchanged onmain/ latest 0.23.x)@tanstack/ai-openrouter0.9 (latest 0.10)