From 71cb1f1c6e271338f475092cd8a8eba32e70013d Mon Sep 17 00:00:00 2001 From: Andrew McGivery Date: Mon, 19 Oct 2026 16:56:10 -0700 Subject: [PATCH 1/8] feat(ai-anthropic): server-side refusal fallback, and finish a refusal with content_filter Add the fallbacks model option on the Claude 5 models that support Anthropic server-side fallback, and send the server-side-fallback beta header with it. A refusal that no fallback model served now finishes with finishReason content_filter and carries stop_details in rawEvent. Text after a fallback block starts a new text segment. --- .changeset/anthropic-refusal-fallback.md | 7 + packages/ai-anthropic/src/adapters/text.ts | 34 ++- packages/ai-anthropic/src/model-meta.ts | 11 + .../src/text/text-provider-options.ts | 14 ++ .../tests/refusal-fallback-wire.test.ts | 202 ++++++++++++++++++ 5 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 .changeset/anthropic-refusal-fallback.md create mode 100644 packages/ai-anthropic/tests/refusal-fallback-wire.test.ts diff --git a/.changeset/anthropic-refusal-fallback.md b/.changeset/anthropic-refusal-fallback.md new file mode 100644 index 0000000000..0216e30c35 --- /dev/null +++ b/.changeset/anthropic-refusal-fallback.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-anthropic': minor +--- + +Add the `fallbacks` model option for Anthropic server-side refusal fallback. Set `modelOptions: { fallbacks: 'default' }` on `claude-fable-5`, `claude-fable-5-1`, `claude-opus-5`, `claude-opus-5-5` or `claude-sonnet-5-5`. When a safety classifier declines the request, the API answers with another model in the same call. The adapter sends the `server-side-fallback-2026-07-01` beta header for you. + +A response with `stop_reason: "refusal"` now finishes with `finishReason: 'content_filter'`. Before, it finished with `'stop'`. The refusal `stop_details` (`category`, `explanation`) is on the `RUN_FINISHED` event as `rawEvent`. diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index f84db4ad42..7a98f830f1 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -236,7 +236,8 @@ const COMPUTER_USE_BETAS = { * computer toolset needs none), * - `context-management-2025-06-27` when `context_management` is set, * - `files-api-2025-04-14` when a message references an uploaded file handle, - * - `mid-conversation-tool-changes-2026-07-01` in mid-conversation tool mode. + * - `mid-conversation-tool-changes-2026-07-01` in mid-conversation tool mode, + * - `server-side-fallback-2026-07-01` when `modelOptions.fallbacks` is set. * Returns `undefined` when none apply (so the call site omits `betas`). */ export function computeAnthropicBetas( @@ -248,6 +249,7 @@ export function computeAnthropicBetas( budget_tokens?: number } context_management?: unknown | null + fallbacks?: unknown mcp_servers?: ReadonlyArray } | undefined, @@ -273,6 +275,11 @@ export function computeAnthropicBetas( betas.add('context-management-2025-06-27') } + // Server-side refusal fallback: the API rejects `fallbacks` without it. + if (modelOptions?.fallbacks != null) { + betas.add('server-side-fallback-2026-07-01') + } + // The MCP connector needs its beta header as well (same shape as #1074); // an empty array is a typed "unset" — do not enable the beta. // ponytail: 2025-04-04 matches the `mcp_servers` shape we type @@ -850,6 +857,7 @@ export class AnthropicTextAdapter< 'cache_control', 'container', 'context_management', + 'fallbacks', 'mcp_servers', 'service_tier', 'stop_sequences', @@ -2033,6 +2041,10 @@ export class AnthropicTextAdapter< ) { // The model already consumed the result; error variants were // already surfaced at content_block_start. + } else if (currentBlockType === 'fallback') { + // Server-side fallback handoff: the declining model's text block + // already closed. The fallback model's text starts a fresh segment. + hasEmittedTextMessageStart = false } else { // Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks) if (hasEmittedTextMessageStart && accumulatedContent) { @@ -2165,10 +2177,28 @@ export class AnthropicTextAdapter< } break } + case 'refusal': { + // A safety classifier declined and no server-side fallback + // served the request. Not an error: the run ends like a + // filtered completion, and `rawEvent` says why. + yield { + type: EventType.RUN_FINISHED, + ...(responseId !== undefined && { responseId }), + runId, + threadId, + model, + timestamp: Date.now(), + finishReason: 'content_filter', + ...(event.delta.stop_details && { + rawEvent: event.delta.stop_details, + }), + usage, + } + break + } case 'stop_sequence': case 'end_turn': case 'pause_turn': - case 'refusal': case 'compaction': default: { // All remaining Anthropic stop_reason variants map to the diff --git a/packages/ai-anthropic/src/model-meta.ts b/packages/ai-anthropic/src/model-meta.ts index 748d798ab4..7c20fe618e 100644 --- a/packages/ai-anthropic/src/model-meta.ts +++ b/packages/ai-anthropic/src/model-meta.ts @@ -8,6 +8,7 @@ import type { AnthropicCacheControlOptions, AnthropicContainerOptions, AnthropicContextManagementOptions, + AnthropicFallbackOptions, AnthropicMCPOptions, AnthropicMaxTokensOptions, AnthropicSamplingOptions, @@ -525,6 +526,7 @@ const CLAUDE_FABLE_5 = { AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & @@ -621,6 +623,7 @@ const CLAUDE_OPUS_5 = { } as const satisfies ModelMeta< AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & @@ -692,6 +695,7 @@ const CLAUDE_FABLE_5_1 = { AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & @@ -734,6 +738,7 @@ const CLAUDE_OPUS_5_5 = { AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & @@ -782,6 +787,7 @@ const CLAUDE_SONNET_5_5 = { AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & @@ -1083,6 +1089,7 @@ export type AnthropicChatModelProviderOptionsByName = { [CLAUDE_FABLE_5.id]: AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & @@ -1101,6 +1108,7 @@ export type AnthropicChatModelProviderOptionsByName = { AnthropicMaxTokensOptions [CLAUDE_OPUS_5.id]: AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & @@ -1116,6 +1124,7 @@ export type AnthropicChatModelProviderOptionsByName = { [CLAUDE_FABLE_5_1.id]: AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & @@ -1124,6 +1133,7 @@ export type AnthropicChatModelProviderOptionsByName = { [CLAUDE_OPUS_5_5.id]: AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & @@ -1134,6 +1144,7 @@ export type AnthropicChatModelProviderOptionsByName = { [CLAUDE_SONNET_5_5.id]: AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & diff --git a/packages/ai-anthropic/src/text/text-provider-options.ts b/packages/ai-anthropic/src/text/text-provider-options.ts index 4086ee8dce..b446a98979 100644 --- a/packages/ai-anthropic/src/text/text-provider-options.ts +++ b/packages/ai-anthropic/src/text/text-provider-options.ts @@ -76,6 +76,19 @@ This allows you to control how Claude manages context across multiple requests, context_management?: BetaContextManagementConfig | null } +export interface AnthropicFallbackOptions { + /** + * Server-side refusal fallback (beta, Claude API only). When a safety + * classifier declines the request, the API retries it on another model in + * the same call. `'default'` uses the model Anthropic recommends for the + * refusal category. A list names up to three models to try in order. + * The adapter adds the `server-side-fallback-2026-07-01` beta header. + */ + // ponytail: hand-typed; SDK 0.97.1 predates `fallbacks`. Per-entry overrides + // (max_tokens, thinking, output_config, speed) can join when someone needs them. + fallbacks?: 'default' | Array<{ model: string }> +} + export interface AnthropicMCPOptions { /** * MCP servers to be utilized in this request @@ -153,6 +166,7 @@ Required range: x >= 0 export type ExternalTextProviderOptions = AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & + AnthropicFallbackOptions & AnthropicMCPOptions & AnthropicServiceTierOptions & AnthropicStopSequencesOptions & diff --git a/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts b/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts new file mode 100644 index 0000000000..29f6efb8b8 --- /dev/null +++ b/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { createAnthropicChat } from '../src/adapters/text' +import type { StreamChunk } from '@tanstack/ai' + +const logger = resolveDebugOption(false) +const model = 'claude-opus-5-5' +const fallbackModel = 'claude-opus-4-8' + +const sse = (events: Array) => + new Response( + events + .map( + (event) => + `event: ${(event as { type: string }).type}\ndata: ${JSON.stringify(event)}\n\n`, + ) + .join(''), + { headers: { 'content-type': 'text/event-stream' } }, + ) + +const messageStart = (servedBy: string) => ({ + type: 'message_start', + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + model: servedBy, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 297, output_tokens: 0 }, + }, +}) + +const messageEnd = ( + stopReason: string, + stopDetails: object | null = null, +): Array => [ + { + type: 'message_delta', + delta: { + stop_reason: stopReason, + stop_sequence: null, + stop_details: stopDetails, + }, + usage: { output_tokens: 0 }, + }, + { type: 'message_stop' }, +] + +const fallbackBlock = (index: number): Array => [ + { + type: 'content_block_start', + index, + content_block: { + type: 'fallback', + from: { model }, + to: { model: fallbackModel }, + }, + }, + { type: 'content_block_stop', index }, +] + +const textBlock = (index: number, text: string): Array => [ + { + type: 'content_block_start', + index, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text }, + }, + { type: 'content_block_stop', index }, +] + +const bioRefusal = () => + sse([ + messageStart(model), + ...messageEnd('refusal', { + type: 'refusal', + category: 'bio', + explanation: null, + }), + ]) + +const servedByFallback = () => + sse([ + messageStart(fallbackModel), + ...fallbackBlock(0), + ...textBlock(1, 'Here is the answer.'), + ...messageEnd('end_turn'), + ]) + +const midOutputHandoff = () => + sse([ + messageStart(model), + ...textBlock(0, 'Partial '), + ...fallbackBlock(1), + ...textBlock(2, 'answer.'), + ...messageEnd('end_turn'), + ]) + +/** Runs one chat call that gets `response`. Gives back the chunks and the request. */ +async function run( + response: Response, + modelOptions: Record = {}, +) { + let request: { body: Record; headers: Headers } | undefined + const adapter = createAnthropicChat(model, 'test-key', { + maxRetries: 0, + fetch: async (_input, init) => { + request = { + body: JSON.parse(String(init?.body)), + headers: new Headers(init?.headers), + } + return response + }, + }) + const chunks: Array = [] + for await (const chunk of adapter.chatStream({ + logger, + model, + messages: [{ role: 'user', content: 'Hi' }], + modelOptions, + })) { + chunks.push(chunk) + } + return { chunks, request } +} + +const runFinished = (chunks: Array) => + chunks.find((chunk) => chunk.type === 'RUN_FINISHED') + +describe('Anthropic server-side fallback request', () => { + it('sends fallbacks in the request body', async () => { + const { request } = await run(servedByFallback(), { fallbacks: 'default' }) + expect(request?.body.fallbacks).toBe('default') + }) + + it('sends the server-side fallback beta header', async () => { + const { request } = await run(servedByFallback(), { fallbacks: 'default' }) + expect(request?.headers.get('anthropic-beta')).toContain( + 'server-side-fallback-2026-07-01', + ) + }) + + it('does not send the beta header without fallbacks', async () => { + const { request } = await run(servedByFallback()) + expect(request?.headers.get('anthropic-beta') ?? '').not.toContain( + 'server-side-fallback', + ) + }) +}) + +describe('Anthropic refusal that no fallback served', () => { + it('finishes with the content_filter finish reason', async () => { + const { chunks } = await run(bioRefusal()) + expect(runFinished(chunks)).toMatchObject({ + finishReason: 'content_filter', + }) + }) + + it('carries the refusal category in rawEvent', async () => { + const { chunks } = await run(bioRefusal()) + expect(runFinished(chunks)?.rawEvent).toMatchObject({ category: 'bio' }) + }) + + it('does not emit RUN_ERROR', async () => { + const { chunks } = await run(bioRefusal()) + expect(chunks.some((chunk) => chunk.type === 'RUN_ERROR')).toBe(false) + }) +}) + +describe('Anthropic response served by a fallback model', () => { + it('streams the fallback model text', async () => { + const { chunks } = await run(servedByFallback(), { fallbacks: 'default' }) + const text = chunks + .filter((chunk) => chunk.type === 'TEXT_MESSAGE_CONTENT') + .map((chunk) => chunk.delta) + .join('') + expect(text).toBe('Here is the answer.') + }) + + it('finishes with the stop finish reason', async () => { + const { chunks } = await run(servedByFallback(), { fallbacks: 'default' }) + expect(runFinished(chunks)).toMatchObject({ finishReason: 'stop' }) + }) + + it('names the fallback model on the run', async () => { + const { chunks } = await run(servedByFallback(), { fallbacks: 'default' }) + expect(runFinished(chunks)?.model).toBe(fallbackModel) + }) + + it('balances text message start and end across a mid-output handoff', async () => { + const { chunks } = await run(midOutputHandoff(), { fallbacks: 'default' }) + const count = (type: string) => + chunks.filter((chunk) => chunk.type === type).length + expect(count('TEXT_MESSAGE_START')).toBe(count('TEXT_MESSAGE_END')) + }) +}) From 9de418608dc9d7d5efd4987af0df1a21e53c49b4 Mon Sep 17 00:00:00 2001 From: Andrew McGivery Date: Mon, 19 Oct 2026 16:56:10 -0700 Subject: [PATCH 2/8] test(e2e): a refusal finishes with content_filter on Anthropic --- testing/e2e/fixtures/refusal/basic.json | 14 +++++ testing/e2e/src/routeTree.gen.ts | 21 ++++++++ .../e2e/src/routes/api.anthropic-refusal.ts | 52 +++++++++++++++++++ testing/e2e/tests/anthropic-refusal.spec.ts | 16 ++++++ 4 files changed, 103 insertions(+) create mode 100644 testing/e2e/fixtures/refusal/basic.json create mode 100644 testing/e2e/src/routes/api.anthropic-refusal.ts create mode 100644 testing/e2e/tests/anthropic-refusal.spec.ts diff --git a/testing/e2e/fixtures/refusal/basic.json b/testing/e2e/fixtures/refusal/basic.json new file mode 100644 index 0000000000..5464b6b1bc --- /dev/null +++ b/testing/e2e/fixtures/refusal/basic.json @@ -0,0 +1,14 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[anthropic-refusal] describe a dangerous lab method" + }, + "response": { + "content": "", + "finishReason": "refusal", + "usage": { "input_tokens": 297, "output_tokens": 0 } + } + } + ] +} diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index cd900cf5d0..8754d31adb 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -61,6 +61,7 @@ import { Route as ApiAnthropicMaxTokensUsageRouteImport } from './routes/api.ant import { Route as ApiAnthropicMultiTurnStructuredWireRouteImport } from './routes/api.anthropic-multi-turn-structured-wire' import { Route as ApiAnthropicOpus5CombinedWireRouteImport } from './routes/api.anthropic-opus-5-combined-wire' import { Route as ApiAnthropicRedactedThinkingWireRouteImport } from './routes/api.anthropic-redacted-thinking-wire' +import { Route as ApiAnthropicRefusalRouteImport } from './routes/api.anthropic-refusal' import { Route as ApiAnthropicSkillsWireRouteImport } from './routes/api.anthropic-skills-wire' import { Route as ApiAnthropicSonnet55WireRouteImport } from './routes/api.anthropic-sonnet-5-5-wire' import { Route as ApiAnthropicStructuredUsageRouteImport } from './routes/api.anthropic-structured-usage' @@ -457,6 +458,11 @@ const ApiAnthropicRedactedThinkingWireRoute = path: '/api/anthropic-redacted-thinking-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiAnthropicRefusalRoute = ApiAnthropicRefusalRouteImport.update({ + id: '/api/anthropic-refusal', + path: '/api/anthropic-refusal', + getParentRoute: () => rootRouteImport, +} as any) const ApiAnthropicSkillsWireRoute = ApiAnthropicSkillsWireRouteImport.update({ id: '/api/anthropic-skills-wire', path: '/api/anthropic-skills-wire', @@ -1171,6 +1177,7 @@ export interface FileRoutesByFullPath { '/api/anthropic-multi-turn-structured-wire': typeof ApiAnthropicMultiTurnStructuredWireRoute '/api/anthropic-opus-5-combined-wire': typeof ApiAnthropicOpus5CombinedWireRoute '/api/anthropic-redacted-thinking-wire': typeof ApiAnthropicRedactedThinkingWireRoute + '/api/anthropic-refusal': typeof ApiAnthropicRefusalRoute '/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute '/api/anthropic-sonnet-5-5-wire': typeof ApiAnthropicSonnet55WireRoute '/api/anthropic-structured-usage': typeof ApiAnthropicStructuredUsageRoute @@ -1350,6 +1357,7 @@ export interface FileRoutesByTo { '/api/anthropic-multi-turn-structured-wire': typeof ApiAnthropicMultiTurnStructuredWireRoute '/api/anthropic-opus-5-combined-wire': typeof ApiAnthropicOpus5CombinedWireRoute '/api/anthropic-redacted-thinking-wire': typeof ApiAnthropicRedactedThinkingWireRoute + '/api/anthropic-refusal': typeof ApiAnthropicRefusalRoute '/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute '/api/anthropic-sonnet-5-5-wire': typeof ApiAnthropicSonnet55WireRoute '/api/anthropic-structured-usage': typeof ApiAnthropicStructuredUsageRoute @@ -1530,6 +1538,7 @@ export interface FileRoutesById { '/api/anthropic-multi-turn-structured-wire': typeof ApiAnthropicMultiTurnStructuredWireRoute '/api/anthropic-opus-5-combined-wire': typeof ApiAnthropicOpus5CombinedWireRoute '/api/anthropic-redacted-thinking-wire': typeof ApiAnthropicRedactedThinkingWireRoute + '/api/anthropic-refusal': typeof ApiAnthropicRefusalRoute '/api/anthropic-skills-wire': typeof ApiAnthropicSkillsWireRoute '/api/anthropic-sonnet-5-5-wire': typeof ApiAnthropicSonnet55WireRoute '/api/anthropic-structured-usage': typeof ApiAnthropicStructuredUsageRoute @@ -1711,6 +1720,7 @@ export interface FileRouteTypes { | '/api/anthropic-multi-turn-structured-wire' | '/api/anthropic-opus-5-combined-wire' | '/api/anthropic-redacted-thinking-wire' + | '/api/anthropic-refusal' | '/api/anthropic-skills-wire' | '/api/anthropic-sonnet-5-5-wire' | '/api/anthropic-structured-usage' @@ -1890,6 +1900,7 @@ export interface FileRouteTypes { | '/api/anthropic-multi-turn-structured-wire' | '/api/anthropic-opus-5-combined-wire' | '/api/anthropic-redacted-thinking-wire' + | '/api/anthropic-refusal' | '/api/anthropic-skills-wire' | '/api/anthropic-sonnet-5-5-wire' | '/api/anthropic-structured-usage' @@ -2069,6 +2080,7 @@ export interface FileRouteTypes { | '/api/anthropic-multi-turn-structured-wire' | '/api/anthropic-opus-5-combined-wire' | '/api/anthropic-redacted-thinking-wire' + | '/api/anthropic-refusal' | '/api/anthropic-skills-wire' | '/api/anthropic-sonnet-5-5-wire' | '/api/anthropic-structured-usage' @@ -2249,6 +2261,7 @@ export interface RootRouteChildren { ApiAnthropicMultiTurnStructuredWireRoute: typeof ApiAnthropicMultiTurnStructuredWireRoute ApiAnthropicOpus5CombinedWireRoute: typeof ApiAnthropicOpus5CombinedWireRoute ApiAnthropicRedactedThinkingWireRoute: typeof ApiAnthropicRedactedThinkingWireRoute + ApiAnthropicRefusalRoute: typeof ApiAnthropicRefusalRoute ApiAnthropicSkillsWireRoute: typeof ApiAnthropicSkillsWireRoute ApiAnthropicSonnet55WireRoute: typeof ApiAnthropicSonnet55WireRoute ApiAnthropicStructuredUsageRoute: typeof ApiAnthropicStructuredUsageRoute @@ -2738,6 +2751,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAnthropicRedactedThinkingWireRouteImport parentRoute: typeof rootRouteImport } + '/api/anthropic-refusal': { + id: '/api/anthropic-refusal' + path: '/api/anthropic-refusal' + fullPath: '/api/anthropic-refusal' + preLoaderRoute: typeof ApiAnthropicRefusalRouteImport + parentRoute: typeof rootRouteImport + } '/api/anthropic-skills-wire': { id: '/api/anthropic-skills-wire' path: '/api/anthropic-skills-wire' @@ -3727,6 +3747,7 @@ const rootRouteChildren: RootRouteChildren = { ApiAnthropicMultiTurnStructuredWireRoute, ApiAnthropicOpus5CombinedWireRoute: ApiAnthropicOpus5CombinedWireRoute, ApiAnthropicRedactedThinkingWireRoute: ApiAnthropicRedactedThinkingWireRoute, + ApiAnthropicRefusalRoute: ApiAnthropicRefusalRoute, ApiAnthropicSkillsWireRoute: ApiAnthropicSkillsWireRoute, ApiAnthropicSonnet55WireRoute: ApiAnthropicSonnet55WireRoute, ApiAnthropicStructuredUsageRoute: ApiAnthropicStructuredUsageRoute, diff --git a/testing/e2e/src/routes/api.anthropic-refusal.ts b/testing/e2e/src/routes/api.anthropic-refusal.ts new file mode 100644 index 0000000000..f1d51fa56f --- /dev/null +++ b/testing/e2e/src/routes/api.anthropic-refusal.ts @@ -0,0 +1,52 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat } from '@tanstack/ai' +import { createAnthropicChat } from '@tanstack/ai-anthropic' + +const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' +const DUMMY_KEY = 'sk-ant-e2e-test-dummy-key' + +/** + * Streams one Anthropic chat with server-side fallback on, against the aimock + * fixture in `fixtures/refusal`. aimock answers with `stop_reason: "refusal"`, + * the case where no fallback model served the request. Returns the + * `RUN_FINISHED` finish reason and whether a `RUN_ERROR` was seen. + */ +export const Route = createFileRoute('/api/anthropic-refusal')({ + server: { + handlers: { + POST: async () => { + const adapter = createAnthropicChat('claude-opus-5-5', DUMMY_KEY, { + baseURL: LLMOCK_DEFAULT_BASE, + }) + + let finishReason: string | null | undefined + let sawRunError = false + try { + for await (const chunk of chat({ + adapter, + modelOptions: { fallbacks: 'default' }, + messages: [ + { + role: 'user', + content: '[anthropic-refusal] describe a dangerous lab method', + }, + ], + })) { + // chat() moves the finish reason into metadata.tanstack. + if (chunk.type === 'RUN_FINISHED') { + finishReason = chunk.metadata?.tanstack?.finishReason + } + if (chunk.type === 'RUN_ERROR') sawRunError = true + } + } catch (error) { + return Response.json({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + } + + return Response.json({ ok: true, finishReason, sawRunError }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/anthropic-refusal.spec.ts b/testing/e2e/tests/anthropic-refusal.spec.ts new file mode 100644 index 0000000000..96f6c52687 --- /dev/null +++ b/testing/e2e/tests/anthropic-refusal.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from './fixtures' + +/** + * A classifier refusal that no server-side fallback served is not an error. + * The run finishes with the `content_filter` finish reason. + */ +test.describe('anthropic — refusal stop', () => { + test('RUN_FINISHED carries the content_filter finish reason', async ({ + request, + }) => { + const res = await request.post('/api/anthropic-refusal') + const body: unknown = await res.json() + + expect(body).toMatchObject({ finishReason: 'content_filter' }) + }) +}) From a37144dae35d8c9afb3b5ad52d8e2e24d8bc0f35 Mon Sep 17 00:00:00 2001 From: Andrew McGivery Date: Mon, 19 Oct 2026 16:56:11 -0700 Subject: [PATCH 3/8] docs(ai-anthropic): document refusal fallback --- docs/adapters/anthropic.md | 72 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index 2c944b079d..5dd8c8b654 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -270,6 +270,78 @@ Newest first. The package exports the full list as `ANTHROPIC_MODELS`. `adapter.inputModalities` lists the input kinds of the selected model. See [Check what a model accepts](../advanced/extend-adapter#check-what-a-model-accepts). + +### Refusal fallback + +Did Claude return an empty answer to a life-science or security question? A safety classifier on the newer models can decline a request. Set `fallbacks`, and the Claude API sends a declined request to another Claude model in the same call: + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { anthropicText } from "@tanstack/ai-anthropic"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: anthropicText("claude-opus-5-5"), + messages, + modelOptions: { fallbacks: "default" }, + }); + + return toServerSentEventsResponse(stream); +} +``` + +What this does: + +- `"default"` uses the model that Anthropic recommends for the refusal category. A list, such as `[{ model: "claude-opus-4-8" }]`, names up to three models to try in order. +- The adapter adds the `server-side-fallback-2026-07-01` beta header for you. +- The fallback answer streams like any other answer. Events before the `fallback` block name the model that declined. That includes `RUN_STARTED` and the first text segment. Events after the block name `to.model`. +- The types accept `fallbacks` on `claude-fable-5`, `claude-fable-5-1`, `claude-opus-5`, `claude-opus-5-5`, and `claude-sonnet-5-5`. + +Some refusals still stand. For example, a category can have no recommended model, or the fallback model can be busy. Then the run ends with `finishReason: "content_filter"`, not with an error. The `rawEvent` of that `RUN_FINISHED` holds the Anthropic `stop_details`, with `category` and `explanation`. + +Show the reason in the UI with `onChunk`: + +```tsx +import { useState } from "react"; +import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"; + +export function Chat() { + const [refusal, setRefusal] = useState(null); + + const { messages, sendMessage } = useChat({ + connection: fetchServerSentEvents("/api/chat"), + onChunk: (chunk) => { + if (chunk.type === "RUN_FINISHED" && chunk.finishReason === "content_filter") { + const category = chunk.rawEvent?.category ?? "unknown"; + setRefusal(`Claude declined this request (category: ${category}).`); + } + }, + }); + + return ( +
+ {messages.map((message) => ( +

+ {message.parts.map((part) => (part.type === "text" ? part.content : null))} +

+ ))} + {refusal &&

{refusal}

} + +
+ ); +} +``` + +Limits: + +- Fallback works only on the Claude API. Claude on Vertex does not have it. +- `claude-haiku-5-5` has no fallback model, so its refusals always stand. +- The adapter does not send the `fallback` content block back on the next turn. A refusal before any output is not affected. + +Now a declined request gets an answer from a fallback model. When no model can answer, your UI tells the user why. + ## Advanced These topics are on [Anthropic: Advanced](./anthropic-advanced): From 5ad3306fc4487af3e21c2f56912f76341c868d60 Mon Sep 17 00:00:00 2001 From: Andrew McGivery Date: Mon, 19 Oct 2026 17:14:42 -0700 Subject: [PATCH 4/8] fix(ai-anthropic): name the fallback model after a mid-output handoff message_start names the model that declined. After a fallback block, take the serving model from its to.model so later events and RUN_FINISHED name the model that answered. --- packages/ai-anthropic/src/adapters/text.ts | 18 +++++++++++++++++- .../tests/refusal-fallback-wire.test.ts | 5 +++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index 7a98f830f1..2f60a836fc 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -1649,7 +1649,23 @@ export class AnthropicTextAdapter< messageStartUsage = event.message.usage } else if (event.type === 'content_block_start') { currentBlockType = event.content_block.type - if (event.content_block.type === 'tool_use') { + if (currentBlockType === 'fallback') { + // Server-side fallback handoff. message_start named the declining + // model; the rest of the stream comes from `to.model`. + const block: unknown = event.content_block + const to = + block && typeof block === 'object' && 'to' in block + ? block.to + : undefined + if ( + to && + typeof to === 'object' && + 'model' in to && + typeof to.model === 'string' + ) { + model = to.model + } + } else if (event.content_block.type === 'tool_use') { currentToolIndex++ // A computer toolset call names its action. It runs as one // `computer` call with the action in `input.action`. diff --git a/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts b/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts index 29f6efb8b8..c703da93f8 100644 --- a/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts +++ b/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts @@ -193,6 +193,11 @@ describe('Anthropic response served by a fallback model', () => { expect(runFinished(chunks)?.model).toBe(fallbackModel) }) + it('names the fallback model after a mid-output handoff', async () => { + const { chunks } = await run(midOutputHandoff(), { fallbacks: 'default' }) + expect(runFinished(chunks)?.model).toBe(fallbackModel) + }) + it('balances text message start and end across a mid-output handoff', async () => { const { chunks } = await run(midOutputHandoff(), { fallbacks: 'default' }) const count = (type: string) => From 39741a78bf7654df2db76af990adef46e3fbc432 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:13:49 +1100 Subject: [PATCH 5/8] fix(ai-anthropic): use the SDK fallbacks type and check handoff text The mid-output test now requires both sentences and two text segments. fallbacks uses BetaFallbacksParam from the SDK. --- packages/ai-anthropic/src/text/text-provider-options.ts | 5 ++--- packages/ai-anthropic/tests/refusal-fallback-wire.test.ts | 8 +++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/ai-anthropic/src/text/text-provider-options.ts b/packages/ai-anthropic/src/text/text-provider-options.ts index b446a98979..6baf3850c1 100644 --- a/packages/ai-anthropic/src/text/text-provider-options.ts +++ b/packages/ai-anthropic/src/text/text-provider-options.ts @@ -1,6 +1,7 @@ import type { AnthropicThinkingFields } from './reasoning' import type { BetaContextManagementConfig, + BetaFallbacksParam, BetaMessageParam, BetaTool, BetaToolChoiceAny, @@ -84,9 +85,7 @@ export interface AnthropicFallbackOptions { * refusal category. A list names up to three models to try in order. * The adapter adds the `server-side-fallback-2026-07-01` beta header. */ - // ponytail: hand-typed; SDK 0.97.1 predates `fallbacks`. Per-entry overrides - // (max_tokens, thinking, output_config, speed) can join when someone needs them. - fallbacks?: 'default' | Array<{ model: string }> + fallbacks?: BetaFallbacksParam | null } export interface AnthropicMCPOptions { diff --git a/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts b/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts index c703da93f8..a5c388ec89 100644 --- a/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts +++ b/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts @@ -202,6 +202,12 @@ describe('Anthropic response served by a fallback model', () => { const { chunks } = await run(midOutputHandoff(), { fallbacks: 'default' }) const count = (type: string) => chunks.filter((chunk) => chunk.type === type).length - expect(count('TEXT_MESSAGE_START')).toBe(count('TEXT_MESSAGE_END')) + const text = chunks + .filter((chunk) => chunk.type === 'TEXT_MESSAGE_CONTENT') + .map((chunk) => chunk.delta) + .join('') + expect(text).toBe('Partial answer.') + expect(count('TEXT_MESSAGE_START')).toBe(2) + expect(count('TEXT_MESSAGE_END')).toBe(2) }) }) From 6380d07572cbfd2966a896428f0073053f34ee62 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:46:59 +1100 Subject: [PATCH 6/8] test(e2e): pass threadId on the Anthropic refusal route chat() requires a threadId. The route threw before it could read the finish reason. --- testing/e2e/src/routes/api.anthropic-refusal.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/e2e/src/routes/api.anthropic-refusal.ts b/testing/e2e/src/routes/api.anthropic-refusal.ts index f1d51fa56f..d5eb84bdb3 100644 --- a/testing/e2e/src/routes/api.anthropic-refusal.ts +++ b/testing/e2e/src/routes/api.anthropic-refusal.ts @@ -24,6 +24,7 @@ export const Route = createFileRoute('/api/anthropic-refusal')({ try { for await (const chunk of chat({ adapter, + threadId: 'anthropic-refusal', modelOptions: { fallbacks: 'default' }, messages: [ { From 4cad03b83b63589e7191c255826cc6f9e274040a Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:22:48 +1100 Subject: [PATCH 7/8] fix(ai-anthropic): pass threadId in the refusal samples chat() and useChat require a threadId. The wire test and the docs snippets omitted it, so test:types and kiira failed. --- docs/adapters/anthropic.md | 11 ++++++++++- .../ai-anthropic/tests/refusal-fallback-wire.test.ts | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index 5dd8c8b654..7809de642e 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -285,6 +285,7 @@ export async function POST(request: Request) { const stream = chat({ adapter: anthropicText("claude-opus-5-5"), messages, + threadId: "thread-1", modelOptions: { fallbacks: "default" }, }); @@ -312,9 +313,17 @@ export function Chat() { const { messages, sendMessage } = useChat({ connection: fetchServerSentEvents("/api/chat"), + threadId: "support-chat", onChunk: (chunk) => { if (chunk.type === "RUN_FINISHED" && chunk.finishReason === "content_filter") { - const category = chunk.rawEvent?.category ?? "unknown"; + const details = chunk.rawEvent; + const category = + details && + typeof details === "object" && + "category" in details && + typeof details.category === "string" + ? details.category + : "unknown"; setRefusal(`Claude declined this request (category: ${category}).`); } }, diff --git a/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts b/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts index a5c388ec89..3bb4c2bd0d 100644 --- a/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts +++ b/packages/ai-anthropic/tests/refusal-fallback-wire.test.ts @@ -122,6 +122,7 @@ async function run( for await (const chunk of adapter.chatStream({ logger, model, + threadId: 'thread-1', messages: [{ role: 'user', content: 'Hi' }], modelOptions, })) { From 261c693c4b4fe1da0e9103bd298e36540c36c0be Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:26:39 +1100 Subject: [PATCH 8/8] docs(ai-anthropic): say when a fallback names the declining model A pre-output fallback already names the fallback model on message_start. The type tests now accept fallbacks on the five Claude 5 models and reject it on claude-haiku-5-5. --- docs/adapters/anthropic.md | 3 ++- packages/ai-anthropic/src/adapters/text.ts | 6 +++-- .../tests/chat-per-model-type-safety.test.ts | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/adapters/anthropic.md b/docs/adapters/anthropic.md index 7809de642e..45af15a6e0 100644 --- a/docs/adapters/anthropic.md +++ b/docs/adapters/anthropic.md @@ -297,7 +297,8 @@ What this does: - `"default"` uses the model that Anthropic recommends for the refusal category. A list, such as `[{ model: "claude-opus-4-8" }]`, names up to three models to try in order. - The adapter adds the `server-side-fallback-2026-07-01` beta header for you. -- The fallback answer streams like any other answer. Events before the `fallback` block name the model that declined. That includes `RUN_STARTED` and the first text segment. Events after the block name `to.model`. +- The fallback answer streams like any other answer. When text has already started, events before the `fallback` block name the model that declined. That includes `RUN_STARTED` and that first text segment. Events after the block name `to.model`. +- When the classifier declines before any output, `message_start` already names the fallback model. `RUN_STARTED` uses that name too. - The types accept `fallbacks` on `claude-fable-5`, `claude-fable-5-1`, `claude-opus-5`, `claude-opus-5-5`, and `claude-sonnet-5-5`. Some refusals still stand. For example, a category can have no recommended model, or the fallback model can be busy. Then the run ends with `finishReason: "content_filter"`, not with an error. The `rawEvent` of that `RUN_FINISHED` holds the Anthropic `stop_details`, with `category` and `explanation`. diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index 2f60a836fc..ee9064028c 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -1650,8 +1650,10 @@ export class AnthropicTextAdapter< } else if (event.type === 'content_block_start') { currentBlockType = event.content_block.type if (currentBlockType === 'fallback') { - // Server-side fallback handoff. message_start named the declining - // model; the rest of the stream comes from `to.model`. + // Server-side fallback handoff. The rest of the stream comes from + // `to.model`. message_start names the declining model only after + // output has started. Before any output, message_start already + // names the fallback model. const block: unknown = event.content_block const to = block && typeof block === 'object' && 'to' in block diff --git a/packages/ai-anthropic/tests/chat-per-model-type-safety.test.ts b/packages/ai-anthropic/tests/chat-per-model-type-safety.test.ts index 8c46555588..4ca34bb17d 100644 --- a/packages/ai-anthropic/tests/chat-per-model-type-safety.test.ts +++ b/packages/ai-anthropic/tests/chat-per-model-type-safety.test.ts @@ -322,6 +322,32 @@ describe('Anthropic per-model chat reasoning gating', () => { }) describe('Anthropic provider options shape assertions', () => { + describe('fallbacks', () => { + it('is accepted on the five Claude 5 models that support it', () => { + expectTypeOf< + AnthropicChatModelProviderOptionsByName['claude-fable-5'] + >().toHaveProperty('fallbacks') + expectTypeOf< + AnthropicChatModelProviderOptionsByName['claude-fable-5-1'] + >().toHaveProperty('fallbacks') + expectTypeOf< + AnthropicChatModelProviderOptionsByName['claude-opus-5'] + >().toHaveProperty('fallbacks') + expectTypeOf< + AnthropicChatModelProviderOptionsByName['claude-opus-5-5'] + >().toHaveProperty('fallbacks') + expectTypeOf< + AnthropicChatModelProviderOptionsByName['claude-sonnet-5-5'] + >().toHaveProperty('fallbacks') + }) + + it('is rejected on claude-haiku-5-5', () => { + expectTypeOf< + AnthropicChatModelProviderOptionsByName['claude-haiku-5-5'] + >().not.toHaveProperty('fallbacks') + }) + }) + describe('claude-opus-4-6 — full feature set', () => { type Options = AnthropicChatModelProviderOptionsByName['claude-opus-4-6']