Skip to content

Commit 79f78c7

Browse files
authored
Merge pull request #349 from code-yeongyu/feat/server-fallback-receipt-abort
feat(ai,coding-agent): abort server-side fallback receipts and reroute through client chains
2 parents 0e903e6 + 4dd9fe1 commit 79f78c7

20 files changed

Lines changed: 717 additions & 5 deletions

packages/agent/src/agent.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export interface AgentOptions {
119119
timeoutMs?: number;
120120
maxRetryDelayMs?: number;
121121
toolExecution?: ToolExecutionMode;
122+
abortServerSideFallback?: boolean;
122123
}
123124

124125
class PendingMessageQueue {
@@ -219,6 +220,8 @@ export class Agent {
219220
public maxRetryDelayMs?: number;
220221
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
221222
public toolExecution: ToolExecutionMode;
223+
/** Forwarded to the stream function; providers without server-side fallback ignore it. */
224+
public abortServerSideFallback?: boolean;
222225

223226
constructor(options: AgentOptions) {
224227
// Older compiled consumers may omit options or streamFn even though the current API requires them.
@@ -242,6 +245,7 @@ export class Agent {
242245
this.timeoutMs = runtimeOptions.timeoutMs;
243246
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
244247
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
248+
this.abortServerSideFallback = runtimeOptions.abortServerSideFallback;
245249
}
246250

247251
/**
@@ -495,6 +499,7 @@ export class Agent {
495499
thinkingBudgets: this.thinkingBudgets,
496500
timeoutMs: this.timeoutMs,
497501
maxRetryDelayMs: this.maxRetryDelayMs,
502+
abortServerSideFallback: this.abortServerSideFallback,
498503
toolExecution: this.toolExecution,
499504
beforeToolCall: this.beforeToolCall,
500505
afterToolCall: this.afterToolCall,

packages/ai/src/api/anthropic-messages.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,20 @@ import type {
3131
ToolResultMessage,
3232
} from "../types.ts";
3333
import { isVideoMimeType } from "../types.ts";
34+
import { combineAbortSignals } from "../utils/abort-signals.ts";
3435
import { splitDeferredTools } from "../utils/deferred-tools.ts";
3536
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
3637
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
3738
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
3839
import { getProviderEnvValue } from "../utils/provider-env.ts";
3940
import { retryProviderRequest } from "../utils/provider-retry.ts";
4041
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
42+
import {
43+
applyServerFallbackAbort,
44+
parseServerFallbackReceipt,
45+
parseStickyFallbackReceipt,
46+
type ServerFallbackReceipt,
47+
} from "../utils/server-fallback-receipt.ts";
4148
import { isForcedToolChoiceUnsupportedError, omitToolChoiceParam } from "../utils/tool-choice-fallback.ts";
4249

4350
import { resolveCloudflareBaseUrl } from "./cloudflare.ts";
@@ -945,6 +952,10 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
945952
timestamp: Date.now(),
946953
};
947954

955+
const serverFallbackAbort = new AbortController();
956+
let serverFallbackReceipt: ServerFallbackReceipt | undefined;
957+
const combinedAbort = combineAbortSignals([options?.signal, serverFallbackAbort.signal]);
958+
const requestSignal = combinedAbort.signal;
948959
try {
949960
let client: Anthropic;
950961
let isOAuth: boolean;
@@ -1004,7 +1015,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
10041015
const payloadRequestMetadata = extractPayloadRequestMetadata(params);
10051016
params = payloadRequestMetadata.params;
10061017
const requestOptions = {
1007-
...(options?.signal ? { signal: options.signal } : {}),
1018+
...(requestSignal ? { signal: requestSignal } : {}),
10081019
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
10091020
maxRetries: 0,
10101021
...(payloadRequestMetadata.headers ? { headers: payloadRequestMetadata.headers } : {}),
@@ -1023,7 +1034,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
10231034
throw error;
10241035
}
10251036
};
1026-
const { response } = await retryProviderRequest(
1037+
const { params: sentParams, response } = await retryProviderRequest(
10271038
async () => {
10281039
try {
10291040
return await createRequest();
@@ -1039,7 +1050,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
10391050
{
10401051
maxRetries: options?.maxRetries,
10411052
maxRetryDelayMs: options?.maxRetryDelayMs,
1042-
signal: options?.signal,
1053+
signal: requestSignal,
10431054
},
10441055
);
10451056
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
@@ -1052,7 +1063,7 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
10521063
| (ProviderNativeContent & { partialJson?: string; index?: number });
10531064
const blocks = output.content as Block[];
10541065

1055-
for await (const event of iterateAnthropicEvents(response, options?.signal)) {
1066+
for await (const event of iterateAnthropicEvents(response, requestSignal)) {
10561067
if (event.type === "message_start") {
10571068
output.responseId = event.message.id;
10581069
// Capture initial token usage from message_start event
@@ -1066,7 +1077,25 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
10661077
output.usage.totalTokens =
10671078
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
10681079
calculateCost(model, output.usage);
1080+
const stickyReceipt =
1081+
options?.abortServerSideFallback === true
1082+
? parseStickyFallbackReceipt(event.message.usage, sentParams.model, event.message.model)
1083+
: undefined;
1084+
if (stickyReceipt !== undefined) {
1085+
serverFallbackReceipt = stickyReceipt;
1086+
serverFallbackAbort.abort();
1087+
break;
1088+
}
10691089
} else if (event.type === "content_block_start") {
1090+
const receipt =
1091+
options?.abortServerSideFallback === true
1092+
? parseServerFallbackReceipt(event.content_block)
1093+
: undefined;
1094+
if (receipt !== undefined) {
1095+
serverFallbackReceipt = receipt;
1096+
serverFallbackAbort.abort();
1097+
break;
1098+
}
10701099
if (event.content_block.type === "text") {
10711100
const block: Block = {
10721101
type: "text",
@@ -1252,13 +1281,19 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
12521281
throw new Error("Request was aborted");
12531282
}
12541283

1284+
if (serverFallbackReceipt !== undefined) {
1285+
applyServerFallbackAbort(output, serverFallbackReceipt);
1286+
}
1287+
12551288
if (output.stopReason === "aborted" || output.stopReason === "error") {
12561289
throw new Error(output.errorMessage || "An unknown error occurred");
12571290
}
12581291

1292+
combinedAbort.cleanup();
12591293
stream.push({ type: "done", reason: output.stopReason, message: output });
12601294
stream.end();
12611295
} catch (error) {
1296+
combinedAbort.cleanup();
12621297
for (const block of output.content) {
12631298
delete (block as { index?: number }).index;
12641299
// An aborted stream never reaches content_block_stop; keep whatever

packages/ai/src/api/simple-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export function buildBaseOptions(
145145
temperature: options?.temperature,
146146
maxTokens: clampMaxTokensToContext(model, context, options?.maxTokens ?? model.maxTokens),
147147
signal: options?.signal,
148+
abortServerSideFallback: options?.abortServerSideFallback,
148149
apiKey: apiKey || options?.apiKey,
149150
transport: options?.transport,
150151
cacheRetention: options?.cacheRetention ?? model.cacheRetention,

packages/ai/src/changes.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,3 +622,20 @@
622622
### Expected merge conflict zones
623623

624624
- LOW: `src/types.ts` `ThinkingContent` interface.
625+
626+
## Client abort on Anthropic server-side fallback receipts (2026-07-25)
627+
628+
### What changed
629+
630+
- `utils/server-fallback-receipt.ts`: new module parsing Anthropic's `fallback` content block and the `fallback_message` entry in `usage.iterations`, plus the refusal-shaped rewrite applied to an aborted turn.
631+
- `types.ts`: `StreamOptions.abortServerSideFallback` (opt-in), inherited by `SimpleStreamOptions` and `AnthropicOptions`; `api/simple-options.ts` forwards it through `buildBaseOptions`.
632+
- `api/anthropic-messages.ts`: a provider-local `AbortController`, merged with the caller signal through `combineAbortSignals`, is passed to the request and the SSE iterator. A receipt block or a `fallback_message` usage entry aborts it and finalizes the turn as `{stopReason:"error", stopDetails:{type:"refusal"}}` with empty content plus `server_fallback_aborted` and `billing_incomplete_after_client_abort` diagnostics. A caller abort is checked first and always wins.
633+
634+
### Why the extension system couldn't handle this
635+
636+
Detection has to happen inside the Anthropic SSE loop while the stream is still open; nothing outside the provider can stop reading a response mid-flight.
637+
638+
### Expected merge conflict zones
639+
640+
- MEDIUM: `api/anthropic-messages.ts` streaming event loop and request-option construction.
641+
- LOW: `types.ts` `StreamOptions`, `api/simple-options.ts` `buildBaseOptions` field list, `index.ts` export list.

packages/ai/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export * from "./utils/json-parse.ts";
6060
export { extractOpenAiCodexAccountId } from "./utils/openai-codex-auth.ts";
6161
export * from "./utils/overflow.ts";
6262
export * from "./utils/retry.ts";
63+
export * from "./utils/server-fallback-receipt.ts";
6364
export * from "./utils/stop-details.ts";
6465
export { contentText } from "./utils/text.ts";
6566
export * from "./utils/tool-pair-repair.ts";

packages/ai/src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,15 @@ export interface StreamOptions {
126126
temperature?: number;
127127
maxTokens?: number;
128128
signal?: AbortSignal;
129+
/**
130+
* Abort the request when the provider reports that a safety classifier
131+
* declined the requested model and a substitute model served the turn
132+
* instead (Anthropic `server-side-fallback-*` betas). The substitute's output
133+
* is billed but was never requested, so aborting keeps model selection with
134+
* the caller. Providers without server-side fallback ignore this.
135+
* Default: undefined (honor the substituted response).
136+
*/
137+
abortServerSideFallback?: boolean;
129138
apiKey?: string;
130139
/**
131140
* Preferred transport for providers that support multiple transports.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { AssistantMessage } from "../types.ts";
2+
import { appendAssistantMessageDiagnostic } from "./diagnostics.ts";
3+
4+
export const SERVER_FALLBACK_ABORTED_DIAGNOSTIC = "server_fallback_aborted";
5+
export const BILLING_INCOMPLETE_DIAGNOSTIC = "billing_incomplete_after_client_abort";
6+
7+
export interface ServerFallbackReceipt {
8+
readonly from: string;
9+
readonly to: string;
10+
}
11+
12+
function isRecord(value: unknown): value is Record<string, unknown> {
13+
return typeof value === "object" && value !== null && !Array.isArray(value);
14+
}
15+
16+
function readModel(value: unknown): string | undefined {
17+
if (!isRecord(value)) return undefined;
18+
return typeof value.model === "string" && value.model.length > 0 ? value.model : undefined;
19+
}
20+
21+
/**
22+
* Anthropic marks a classifier handoff with `{type:"fallback", from:{model}, to:{model}}`
23+
* (`server-side-fallback-*` betas). Returns undefined for anything else, so an
24+
* unrelated or malformed provider-native block falls through to normal handling.
25+
*/
26+
export function parseServerFallbackReceipt(block: unknown): ServerFallbackReceipt | undefined {
27+
if (!isRecord(block) || block.type !== "fallback") return undefined;
28+
const from = readModel(block.from);
29+
const to = readModel(block.to);
30+
return from !== undefined && to !== undefined ? { from, to } : undefined;
31+
}
32+
33+
/**
34+
* Sticky routing serves later turns of a fallen-back conversation from the
35+
* substitute model with no `fallback` block at all; a `fallback_message` entry
36+
* in `usage.iterations` is the documented signal. A served-model string
37+
* comparison is deliberately NOT used: gateways and Bedrock-style endpoints
38+
* rewrite model ids, so a mismatch is not evidence of a fallback.
39+
*/
40+
export function parseStickyFallbackReceipt(
41+
usage: unknown,
42+
requestedModel: string,
43+
servedModel?: string,
44+
): ServerFallbackReceipt | undefined {
45+
if (!isRecord(usage) || !Array.isArray(usage.iterations)) return undefined;
46+
for (let index = usage.iterations.length - 1; index >= 0; index--) {
47+
const entry: unknown = usage.iterations[index];
48+
if (!isRecord(entry) || entry.type !== "fallback_message") continue;
49+
const entryModel = typeof entry.model === "string" && entry.model.length > 0 ? entry.model : undefined;
50+
const to = entryModel ?? servedModel;
51+
return to !== undefined ? { from: requestedModel, to } : undefined;
52+
}
53+
return undefined;
54+
}
55+
56+
export function serverFallbackRefusalExplanation(receipt: ServerFallbackReceipt): string {
57+
return `Server-side fallback (${receipt.from} -> ${receipt.to}) aborted by client policy`;
58+
}
59+
60+
/**
61+
* Rewrites an aborted turn into the same shape a pre-output classifier refusal
62+
* has, so `isClassifierRefusal()` routes it through the caller's fallback chain.
63+
* Content is dropped: the substitute model's partial output was never requested,
64+
* and replaying a half-turn containing a fallback marker breaks Anthropic replay.
65+
*/
66+
export function applyServerFallbackAbort(message: AssistantMessage, receipt: ServerFallbackReceipt): void {
67+
const explanation = serverFallbackRefusalExplanation(receipt);
68+
message.content = [];
69+
message.stopReason = "error";
70+
message.stopDetails = { type: "refusal", explanation };
71+
message.errorMessage = explanation;
72+
appendAssistantMessageDiagnostic(message, {
73+
type: SERVER_FALLBACK_ABORTED_DIAGNOSTIC,
74+
timestamp: Date.now(),
75+
details: { from: receipt.from, to: receipt.to },
76+
});
77+
appendAssistantMessageDiagnostic(message, {
78+
type: BILLING_INCOMPLETE_DIAGNOSTIC,
79+
timestamp: Date.now(),
80+
details: { reason: "per-attempt usage does not arrive after a client abort" },
81+
});
82+
}

0 commit comments

Comments
 (0)