Repository navigation
feat(ai-anthropic): server-side refusal fallback, and refusals finish with content_filter #1835
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
71cb1f1
9de4186
a37144d
5ad3306
39741a7
6380d07
4cad03b
261c693
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<unknown> | ||
| } | ||
| | 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', | ||
|
|
@@ -1641,7 +1649,25 @@ 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. 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 | ||
| ? 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`. | ||
|
|
@@ -2033,6 +2059,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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Give each text segment its own message identity. After a mid-output handoff, this reset causes another 🤖 Prompt for AI Agents |
||
| } else { | ||
| // Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks) | ||
| if (hasEmittedTextMessageStart && accumulatedContent) { | ||
|
|
@@ -2165,10 +2195,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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Read the finish reason from
metadata.tanstack.For the documented
chat()stream,RUN_FINISHEDcarries the finish reason atchunk.metadata?.tanstack?.finishReason. The adjacent client example checkschunk.finishReason, so a refusal does not trigger the alert. Change the condition to read the metadata field. The suppliedtesting/e2e/src/routes/api.anthropic-refusal.tshandler uses that field too. (tanstack.com)🤖 Prompt for AI Agents