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
7 changes: 7 additions & 0 deletions .changeset/run-error-provider-details.md
Original file line number Diff line number Diff line change
@@ -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`.
47 changes: 47 additions & 0 deletions docs/chat/stream-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
2 changes: 2 additions & 0 deletions docs/protocol/metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions packages/ai-anthropic/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
orderedAssistantBlocks,
splitMidConversationChanges,
toRetryAfterMs,
toRunErrorDetails,
toRunErrorRawEvent,
transformMessagesForReplay,
} from '@tanstack/ai/adapter-internals'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions packages/ai-anthropic/tests/retry-after-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>) {
const chunks: Array<StreamChunk> = []
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',
})
})
})
73 changes: 63 additions & 10 deletions packages/ai/src/activities/error-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand All @@ -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')
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/adapter-internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export { resolveDebugOption } from './logger/resolve'
export {
toRunErrorPayload,
toRunErrorRawEvent,
toRunErrorDetails,
toRetryAfterMs,
} from './activities/error-payload'
export {
Expand Down
16 changes: 16 additions & 0 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, any>
}

Expand Down
85 changes: 85 additions & 0 deletions packages/ai/tests/run-error-details.test.ts
Original file line number Diff line number Diff line change
@@ -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({})
})
})
Loading
Loading