Skip to content

Stream adapters collapse provider errors to {message, code} and never populate AG-UI rawEvent — upstream error detail is unrecoverable #672

@tombeckenham

Description

@tombeckenham

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:

{
  "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.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.tstoRunErrorPayload() 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

  1. In the OpenRouter adapter rethrow, preserve chunk.error.metadata on the thrown error (alongside code).
  2. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions