From 006d3ddc2cae7aecfbf6a5f5d8b8785deb69dd93 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 22 Oct 2026 12:07:50 +0200 Subject: [PATCH] feat(ai): put the provider error type and request id on RUN_ERROR Add toRunErrorDetails to read errorType and requestId from an SDK error. The Anthropic and openai-base text adapters spread them on RUN_ERROR, and chat() moves them to metadata.tanstack. code stays the HTTP status. --- .changeset/run-error-provider-details.md | 7 ++ docs/chat/stream-events.md | 47 ++++++++++ docs/protocol/metadata.md | 2 + packages/ai-anthropic/src/adapters/text.ts | 3 + .../tests/retry-after-wire.test.ts | 79 +++++++++++++++++ packages/ai/src/activities/error-payload.ts | 73 +++++++++++++--- packages/ai/src/adapter-internals.ts | 1 + packages/ai/src/types.ts | 16 ++++ packages/ai/tests/run-error-details.test.ts | 85 +++++++++++++++++++ .../src/adapters/chat-completions-text.ts | 2 + .../src/adapters/responses-text.ts | 3 + .../tests/retry-after-wire.test.ts | 8 ++ 12 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 .changeset/run-error-provider-details.md create mode 100644 packages/ai/tests/run-error-details.test.ts diff --git a/.changeset/run-error-provider-details.md b/.changeset/run-error-provider-details.md new file mode 100644 index 0000000000..6f88d237ec --- /dev/null +++ b/.changeset/run-error-provider-details.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-anthropic': minor +'@tanstack/openai-base': minor +--- + +Put the provider error type and the request id on `RUN_ERROR`. The Anthropic adapter and the adapters built on `@tanstack/openai-base` set `errorType` (for example `overloaded_error`) and `requestId` (from the SDK error or the `request-id` / `x-request-id` header). `chat()` moves both to `metadata.tanstack`, so the client gets them too. `code` stays the HTTP status. Adapter authors can read both values with `toRunErrorDetails` from `@tanstack/ai/adapter-internals`. diff --git a/docs/chat/stream-events.md b/docs/chat/stream-events.md index f48d250b11..60fdd562bc 100644 --- a/docs/chat/stream-events.md +++ b/docs/chat/stream-events.md @@ -340,6 +340,53 @@ Limits: On a serverless route, a long wait keeps the request open. If your host has a short time limit, keep `maxWaitMs` low. Then retry the longer wait times from the client. +### Read the provider error type and request id + +You got a `RUN_ERROR` and want to know why the provider failed, or you want to send the request id to the provider support. Read `metadata.tanstack` on the `RUN_ERROR`: + +- `errorType`: the error type from the provider, for example `overloaded_error` or `rate_limit_exceeded`. A string of at most 128 characters. +- `requestId`: the id of the failed request, from the SDK error or the `request-id` / `x-request-id` header. + +`code` stays the HTTP status, for example `"529"`. The Anthropic adapter and the adapters built on `@tanstack/openai-base` set both fields when the provider sends them. + +On the server: + +```typescript +import { chat } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; + +const stream = chat({ + adapter: anthropicText("claude-sonnet-5-5"), + messages: [{ role: "user", content: "Hello!" }], + threadId: "thread-1", +}); + +for await (const chunk of stream) { + if (chunk.type === "RUN_ERROR") { + const tanstack = chunk.metadata?.tanstack; + console.log(chunk.code, tanstack?.errorType, tanstack?.requestId); + // "529" "overloaded_error" "req_011..." + } +} +``` + +On the client: + +```typescript +import { useChat, fetchServerSentEvents } from "@tanstack/ai-react"; + +const { messages } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", + onChunk: (chunk) => { + if (chunk.type === "RUN_ERROR") { + const tanstack = chunk.metadata?.tanstack; + console.log(tanstack?.errorType, tanstack?.requestId); + } + }, +}); +``` + ### Read the provider response identity You want to find a reply in the logs of the provider, or see which model really answered. Read `metadata.tanstack` on `RUN_FINISHED`: diff --git a/docs/protocol/metadata.md b/docs/protocol/metadata.md index 738393355c..415c89e9a0 100644 --- a/docs/protocol/metadata.md +++ b/docs/protocol/metadata.md @@ -119,6 +119,8 @@ An AG-UI `RUN_ERROR` has `message` and an optional `code` at the top level. Put For a rate limit, put the wait time in `metadata.tanstack.retryAfterMs`, in milliseconds. Clients read it from there. See [Rate limits](../chat/stream-events#rate-limits). +Put the provider error type in `metadata.tanstack.errorType` and the request id in `metadata.tanstack.requestId`. See [Read the provider error type and request id](../chat/stream-events#read-the-provider-error-type-and-request-id). + ### Fields for other features When you use one of these features, add its field: diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index ead7dac87c..872bb41746 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -10,6 +10,7 @@ import { orderedAssistantBlocks, splitMidConversationChanges, toRetryAfterMs, + toRunErrorDetails, toRunErrorRawEvent, transformMessagesForReplay, } from '@tanstack/ai/adapter-internals' @@ -683,6 +684,7 @@ export class AnthropicTextAdapter< // `{ type, message }`) when present; never the raw exception object. ...(rawEvent !== undefined && { rawEvent }), ...(retryAfterMs !== undefined && { retryAfterMs }), + ...toRunErrorDetails(error), error: { message: err.message || 'Unknown error occurred', code, @@ -2259,6 +2261,7 @@ export class AnthropicTextAdapter< // Forward the Anthropic SDK error's `.error` response body when present. ...(rawEvent !== undefined && { rawEvent }), ...(retryAfterMs !== undefined && { retryAfterMs }), + ...toRunErrorDetails(error), error: { message: err.message || 'Unknown error occurred', code, diff --git a/packages/ai-anthropic/tests/retry-after-wire.test.ts b/packages/ai-anthropic/tests/retry-after-wire.test.ts index 3b9595f909..fefcb8d962 100644 --- a/packages/ai-anthropic/tests/retry-after-wire.test.ts +++ b/packages/ai-anthropic/tests/retry-after-wire.test.ts @@ -61,3 +61,82 @@ describe('Anthropic adapter on a 429', () => { expect(retryAfterMs).toBe(7000) }) }) + +const overloadedBody = { + type: 'error', + error: { type: 'overloaded_error', message: 'Overloaded' }, +} + +/** Runs one call through `fetch`. Gives back its RUN_ERROR. */ +async function runErrorWith(fetch: () => Promise) { + const chunks: Array = [] + for await (const chunk of createAnthropicChat(model, 'test-key', { + maxRetries: 0, + fetch, + }).chatStream({ + threadId: 'thread-1', + logger, + model, + messages: [{ role: 'user', content: 'Hi' }], + })) { + chunks.push(chunk) + } + return chunks.find((chunk) => chunk.type === 'RUN_ERROR') +} + +describe('Anthropic adapter on an overloaded error', () => { + it('puts the error type and request id on a 529 RUN_ERROR', async () => { + const runError = await runErrorWith(async () => + Response.json(overloadedBody, { + status: 529, + headers: { 'request-id': 'req_011' }, + }), + ) + expect(runError).toMatchObject({ + code: '529', + errorType: 'overloaded_error', + requestId: 'req_011', + }) + }) + + it('puts them on a RUN_ERROR from a stream error event', async () => { + const runError = await runErrorWith( + async () => + new Response( + `event: error\ndata: ${JSON.stringify(overloadedBody)}\n\n`, + { + headers: { + 'content-type': 'text/event-stream', + 'request-id': 'req_012', + }, + }, + ), + ) + expect(runError).toMatchObject({ + errorType: 'overloaded_error', + requestId: 'req_012', + }) + }) + + it('chat() moves them to metadata.tanstack', async () => { + let tanstack: unknown + for await (const chunk of chat({ + threadId: 'thread-1', + adapter: createAnthropicChat(model, 'test-key', { + maxRetries: 0, + fetch: async () => + Response.json(overloadedBody, { + status: 529, + headers: { 'request-id': 'req_011' }, + }), + }), + messages: [{ role: 'user', content: 'Hi' }], + })) { + if (chunk.type === 'RUN_ERROR') tanstack = chunk.metadata?.tanstack + } + expect(tanstack).toMatchObject({ + errorType: 'overloaded_error', + requestId: 'req_011', + }) + }) +}) diff --git a/packages/ai/src/activities/error-payload.ts b/packages/ai/src/activities/error-payload.ts index 41d83d28d7..d39c7b7d68 100644 --- a/packages/ai/src/activities/error-payload.ts +++ b/packages/ai/src/activities/error-payload.ts @@ -110,7 +110,8 @@ export function toRunErrorPayload( * * 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: + * auth headers. (A request id is not a secret: `toRunErrorDetails` puts it on + * the event as `requestId`.) The recognized sources, in priority order: * * - `error.rawEvent` — a provider body an adapter attached explicitly (e.g. the * OpenRouter mid-stream `chunk.error`). @@ -146,6 +147,66 @@ export function toRunErrorRawEvent(error: unknown): unknown { return undefined } +/** Read one response header from the `headers` that an SDK error carries. */ +function responseHeader(error: object, name: string): string | undefined { + const headers: unknown = Reflect.get(error, 'headers') + if (headers instanceof Headers) return headers.get(name) ?? undefined + if (headers && typeof headers === 'object' && name in headers) { + const value: unknown = Reflect.get(headers, name) + return typeof value === 'string' ? value : undefined + } + return undefined +} + +/** A non-empty string of at most 128 characters, else `undefined`. */ +function shortString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' && value.length <= 128 + ? value + : undefined +} + +function field(value: unknown, key: string): unknown { + return value && typeof value === 'object' + ? Reflect.get(value, key) + : undefined +} + +/** + * Read the provider's error type and request id from a thrown value, to put + * on a `RUN_ERROR` as `errorType` and `requestId`. Only keys that are present + * are set, so you can spread the result into the event: + * + * yield { type: EventType.RUN_ERROR, ..., ...toRunErrorDetails(error) } + * + * - `errorType` is the SDK error's `type` (OpenAI and Anthropic set it from + * the body), else the body's `error.type` (an Anthropic stream error event), + * else the body's `type`. For example `overloaded_error`. + * - `requestId` is the SDK error's `requestID` or `request_id`, else the + * `request-id` or `x-request-id` response header. + * + * Each value must be a string of at most 128 characters. + */ +export function toRunErrorDetails(error: unknown): { + errorType?: string + requestId?: string +} { + if (!error || typeof error !== 'object') return {} + const body = field(error, 'error') + const errorType = + shortString(field(error, 'type')) ?? + shortString(field(field(body, 'error'), 'type')) ?? + shortString(field(body, 'type')) + const requestId = + shortString(field(error, 'requestID')) ?? + shortString(field(error, 'request_id')) ?? + shortString(responseHeader(error, 'request-id')) ?? + shortString(responseHeader(error, 'x-request-id')) + return { + ...(errorType !== undefined && { errorType }), + ...(requestId !== undefined && { requestId }), + } +} + /** * Read how long a provider asks you to wait before a retry, from the response * headers that an SDK error carries. It reads `retry-after-ms` first, then @@ -159,15 +220,7 @@ export function toRetryAfterMs(error: unknown): number | undefined { if (!error || typeof error !== 'object' || !('headers' in error)) { return undefined } - const { headers } = error - const get = (name: string): string | null | undefined => { - if (headers instanceof Headers) return headers.get(name) - if (headers && typeof headers === 'object' && name in headers) { - const value: unknown = Reflect.get(headers, name) - return typeof value === 'string' ? value : undefined - } - return undefined - } + const get = (name: string) => responseHeader(error, name) const ms = Number(get('retry-after-ms') || NaN) if (Number.isFinite(ms) && ms >= 0) return ms const value = get('retry-after') diff --git a/packages/ai/src/adapter-internals.ts b/packages/ai/src/adapter-internals.ts index fc0cf8d563..6ba90c3df8 100644 --- a/packages/ai/src/adapter-internals.ts +++ b/packages/ai/src/adapter-internals.ts @@ -15,6 +15,7 @@ export { resolveDebugOption } from './logger/resolve' export { toRunErrorPayload, toRunErrorRawEvent, + toRunErrorDetails, toRetryAfterMs, } from './activities/error-payload' export { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d82c8b8740..f30e08bc8a 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -770,6 +770,10 @@ export interface TanStackRunMetadata { index?: number /** On a `RUN_ERROR`. See `RunErrorEvent.retryAfterMs`. */ retryAfterMs?: number + /** On a `RUN_ERROR`. See `RunErrorEvent.errorType`. */ + errorType?: string + /** On a `RUN_ERROR`. See `RunErrorEvent.requestId`. */ + requestId?: string state?: ToolOutputState /** Parsed `TOOL_CALL_END` input. Spec `TOOL_CALL_END` has no top-level `input`. */ input?: unknown @@ -1640,6 +1644,18 @@ export interface RunErrorEvent extends Pick< * `chat()` moves it to `metadata.tanstack.retryAfterMs`. */ retryAfterMs?: number + /** + * The provider's error type, for example `overloaded_error` or + * `rate_limit_exceeded`. `code` stays the HTTP status. A string of at most + * 128 characters. `chat()` moves it to `metadata.tanstack.errorType`. + */ + errorType?: string + /** + * The provider's request id, from the SDK error or the `request-id` / + * `x-request-id` header. Give it to the provider's support. + * `chat()` moves it to `metadata.tanstack.requestId`. + */ + requestId?: string metadata?: { tanstack?: TanStackRunMetadata } & Record } diff --git a/packages/ai/tests/run-error-details.test.ts b/packages/ai/tests/run-error-details.test.ts new file mode 100644 index 0000000000..3b9a758abe --- /dev/null +++ b/packages/ai/tests/run-error-details.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { toRunErrorDetails } from '../src/adapter-internals' + +/** + * Built like `@anthropic-ai/sdk` builds its `APIError`: `type` from the + * body's `error.type`, `requestID` from the `request-id` header, and the + * whole body on `error`. + */ +function anthropicError(errorType: string, requestId: string) { + const headers = new Headers({ 'request-id': requestId }) + return Object.assign(new Error('529 Overloaded'), { + status: 529, + headers, + requestID: headers.get('request-id'), + error: { type: 'error', error: { type: errorType, message: 'Overloaded' } }, + type: errorType, + }) +} + +/** + * Built like `openai` builds its `APIError`: the body's `error` object on + * `error`, its `type` on `type`, and `requestID` from `x-request-id`. + */ +function openaiError(errorType: string, requestId: string) { + const headers = new Headers({ 'x-request-id': requestId }) + const body = { message: 'Rate limit reached', type: errorType } + return Object.assign(new Error('429 Rate limit reached'), { + status: 429, + headers, + requestID: headers.get('x-request-id'), + error: body, + type: body.type, + }) +} + +describe('toRunErrorDetails', () => { + it('reads the Anthropic error type and request id', () => { + expect( + toRunErrorDetails(anthropicError('overloaded_error', 'req_011')), + ).toEqual({ errorType: 'overloaded_error', requestId: 'req_011' }) + }) + + it('reads an Anthropic stream error, which has no `type` of its own', () => { + // The SDK throws `new APIError(undefined, body, undefined, headers)` on a + // stream `error` event, so `type` is null and the body is the envelope. + const error = Object.assign(new Error('Overloaded'), { + type: null, + headers: new Headers({ 'request-id': 'req_012' }), + error: { + type: 'error', + error: { type: 'overloaded_error', message: 'Overloaded' }, + }, + }) + expect(toRunErrorDetails(error)).toEqual({ + errorType: 'overloaded_error', + requestId: 'req_012', + }) + }) + + it('reads the OpenAI error type and x-request-id', () => { + expect( + toRunErrorDetails(openaiError('rate_limit_exceeded', 'req_abc')), + ).toEqual({ errorType: 'rate_limit_exceeded', requestId: 'req_abc' }) + }) + + it('reads a request_id field and a plain header object', () => { + expect(toRunErrorDetails({ request_id: 'req_1' })).toEqual({ + requestId: 'req_1', + }) + expect(toRunErrorDetails({ headers: { 'x-request-id': 'req_2' } })).toEqual( + { requestId: 'req_2' }, + ) + }) + + it('drops a value that is not a short string', () => { + expect(toRunErrorDetails({ type: 'x'.repeat(500) })).toEqual({}) + expect(toRunErrorDetails({ type: 42, requestID: {} })).toEqual({}) + }) + + it('gives an empty object when nothing is present', () => { + expect(toRunErrorDetails(new Error('boom'))).toEqual({}) + expect(toRunErrorDetails('boom')).toEqual({}) + expect(toRunErrorDetails(undefined)).toEqual({}) + }) +}) diff --git a/packages/openai-base/src/adapters/chat-completions-text.ts b/packages/openai-base/src/adapters/chat-completions-text.ts index 5e970bd842..6cad6dc60c 100644 --- a/packages/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/openai-base/src/adapters/chat-completions-text.ts @@ -12,6 +12,7 @@ import { resolveReasoning, toRunErrorPayload, toRetryAfterMs, + toRunErrorDetails, toRunErrorRawEvent, transformMessagesForReplay, } from '@tanstack/ai/adapter-internals' @@ -286,6 +287,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< ...(errorPayload.code !== undefined && { code: errorPayload.code }), ...(rawEvent !== undefined && { rawEvent }), ...(retryAfterMs !== undefined && { retryAfterMs }), + ...toRunErrorDetails(error), 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 ffd008b1f1..7cc7b93958 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -14,6 +14,7 @@ import { splitMidConversationChanges, toRunErrorPayload, toRetryAfterMs, + toRunErrorDetails, toRunErrorRawEvent, transformMessagesForReplay, } from '@tanstack/ai/adapter-internals' @@ -628,6 +629,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< // toRunErrorRawEvent); omitted otherwise. ...(rawEvent !== undefined && { rawEvent }), ...(retryAfterMs !== undefined && { retryAfterMs }), + ...toRunErrorDetails(error), error: { message: errorPayload.message, code: errorPayload.code, @@ -2592,6 +2594,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< ...(errorPayload.code !== undefined && { code: errorPayload.code }), ...(rawEvent !== undefined && { rawEvent }), ...(retryAfterMs !== undefined && { retryAfterMs }), + ...toRunErrorDetails(error), error: { message: errorPayload.message, ...(errorPayload.code !== undefined && { code: errorPayload.code }), diff --git a/packages/openai-base/tests/retry-after-wire.test.ts b/packages/openai-base/tests/retry-after-wire.test.ts index f286be8ccb..6e3b7c3a77 100644 --- a/packages/openai-base/tests/retry-after-wire.test.ts +++ b/packages/openai-base/tests/retry-after-wire.test.ts @@ -57,4 +57,12 @@ describe.each(apis)('$api adapter on a 429', ({ adapter }) => { await runError({ 'retry-after-ms': '1500', 'retry-after': '7' }), ).toMatchObject({ retryAfterMs: 1500 }) }) + + it('puts the error type and x-request-id on the RUN_ERROR', async () => { + expect(await runError({ 'x-request-id': 'req_abc' })).toMatchObject({ + code: '429', + errorType: 'rate_limit', + requestId: 'req_abc', + }) + }) })