From 604daecafcb87be9aab6509e78476278adfbcd4a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Jul 2026 18:40:16 +0800 Subject: [PATCH] fix(anthropic): price fallback serving models --- .../providers/anthropic-server-fallback.ts | 56 ++++++++++++++++++- packages/ai/src/providers/anthropic.ts | 21 ++++++- .../transports/anthropic-transport-stream.ts | 21 ++++++- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/providers/anthropic-server-fallback.ts b/packages/ai/src/providers/anthropic-server-fallback.ts index e509c7829694..49011ecd5d22 100644 --- a/packages/ai/src/providers/anthropic-server-fallback.ts +++ b/packages/ai/src/providers/anthropic-server-fallback.ts @@ -1,4 +1,4 @@ -import type { AssistantMessageDiagnostic } from "../types.js"; +import type { AssistantMessageDiagnostic, Model } from "../types.js"; /** Anthropic beta that re-serves safety refusals on an allowed fallback model. */ export const ANTHROPIC_SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01"; @@ -6,19 +6,69 @@ export const ANTHROPIC_SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07 /** Let Anthropic select the recommended model for each refusal category. */ export const ANTHROPIC_SERVER_SIDE_FALLBACKS = "default" as const; -// Fallback-served turns bill at the serving model's rates. -export const CLAUDE_OPUS_48_FALLBACK_MODEL_COST = { +// Anthropic's current default routes serve fallback output on Opus 5 or 4.8, +// which share the same standard and fast-mode rates. +export const CLAUDE_OPUS_FALLBACK_MODEL_COST = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, } as const; +const CLAUDE_OPUS_FAST_FALLBACK_MODEL_COST = { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, +} as const; + export type AnthropicFallbackBoundary = { fromModel: string | null; toModel: string | null; }; +function isModelCostEqual(left: Model["cost"], right: Model["cost"]): boolean { + return ( + left.input === right.input && + left.output === right.output && + left.cacheRead === right.cacheRead && + left.cacheWrite === right.cacheWrite + ); +} + +function normalizeModelId(modelId: string | null): string | null { + const normalized = modelId?.trim().toLowerCase(); + return normalized || null; +} + +function isClaudeOpusFallbackModel(modelId: string): boolean { + return /^claude-opus-(?:5|4-8)$/.test(modelId); +} + +/** Resolve billed rates from the serving model reported by Anthropic's fallback stream. */ +export function resolveAnthropicFallbackServingModelCost(params: { + requestedModelId: string; + servingModelId: string | null; + requestedCost: Model["cost"]; +}): Model["cost"] { + const requestedModelId = normalizeModelId(params.requestedModelId); + const servingModelId = normalizeModelId(params.servingModelId); + if ( + !servingModelId || + servingModelId === requestedModelId || + !isClaudeOpusFallbackModel(servingModelId) + ) { + return params.requestedCost; + } + if ( + requestedModelId === "claude-opus-5" && + isModelCostEqual(params.requestedCost, CLAUDE_OPUS_FAST_FALLBACK_MODEL_COST) + ) { + return CLAUDE_OPUS_FAST_FALLBACK_MODEL_COST; + } + return CLAUDE_OPUS_FALLBACK_MODEL_COST; +} + function readBoundaryModel(value: unknown): string | null { if (!value || typeof value !== "object") { return null; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a37c0016bf12..edef5f6e64dd 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -68,9 +68,9 @@ import { applyAnthropicRefusal } from "./anthropic-refusal.js"; import { ANTHROPIC_SERVER_SIDE_FALLBACK_BETA, ANTHROPIC_SERVER_SIDE_FALLBACKS, - CLAUDE_OPUS_48_FALLBACK_MODEL_COST, applyAnthropicFallbackBoundary, readAnthropicFallbackBoundary, + resolveAnthropicFallbackServingModelCost, } from "./anthropic-server-fallback.js"; import { ANTHROPIC_OMITTED_REASONING_TEXT, @@ -492,6 +492,16 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti if (event.type === "message_start") { output.responseId = event.message.id; output.responseModel = event.message.model; + if (refusalBuffer) { + costModel = { + ...model, + cost: resolveAnthropicFallbackServingModelCost({ + requestedModelId: model.id, + servingModelId: event.message.model, + requestedCost: model.cost, + }), + }; + } const promptUsage = readAnthropicPromptUsageSnapshot(event.message.usage); const messageStartPromptTokens = promptUsage ? promptUsage.input + promptUsage.cacheRead + promptUsage.cacheWrite @@ -560,7 +570,14 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti // Cost intentionally mirrors top-level usage (serving attempt at // serving-model rates). A mid-stream decline's billed partial is // only in usage.iterations and is not folded in here. - costModel = { ...model, cost: CLAUDE_OPUS_48_FALLBACK_MODEL_COST }; + costModel = { + ...model, + cost: resolveAnthropicFallbackServingModelCost({ + requestedModelId: model.id, + servingModelId: fallbackBoundary.toModel, + requestedCost: model.cost, + }), + }; calculateCost(costModel, output.usage); eventSink.push({ type: "start", partial: output }); for (const [i, block] of blocks.entries()) { diff --git a/packages/ai/src/transports/anthropic-transport-stream.ts b/packages/ai/src/transports/anthropic-transport-stream.ts index ea15e23b6da2..338ca2c444ff 100644 --- a/packages/ai/src/transports/anthropic-transport-stream.ts +++ b/packages/ai/src/transports/anthropic-transport-stream.ts @@ -27,7 +27,6 @@ import { ANTHROPIC_OMITTED_REASONING_TEXT, ANTHROPIC_SERVER_SIDE_FALLBACK_BETA, ANTHROPIC_SERVER_SIDE_FALLBACKS, - CLAUDE_OPUS_48_FALLBACK_MODEL_COST, applyClaudeRequestContract, applyAnthropicFallbackBoundary, defaultsClaudeAdaptiveThinking, @@ -46,6 +45,7 @@ import { readAnthropicPromptUsageSnapshot, readAnthropicUsageTokenCount, readLastAnthropicIterationUsage, + resolveAnthropicFallbackServingModelCost, supportsClaudeAdaptiveThinking, supportsClaudeNativeMaxEffort, supportsClaudeNativeXhighEffort, @@ -1442,6 +1442,16 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn { const usage = message?.usage ?? {}; output.responseId = typeof message?.id === "string" ? message.id : undefined; output.responseModel = typeof message?.model === "string" ? message.model : undefined; + if (refusalBuffer) { + costModel = { + ...model, + cost: resolveAnthropicFallbackServingModelCost({ + requestedModelId: model.id, + servingModelId: output.responseModel ?? null, + requestedCost: model.cost, + }), + }; + } const promptUsage = readAnthropicPromptUsageSnapshot(usage); const messageStartPromptTokens = promptUsage ? promptUsage.input + promptUsage.cacheRead + promptUsage.cacheWrite @@ -1516,7 +1526,14 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn { // Cost intentionally mirrors top-level usage (serving attempt at // serving-model rates). A mid-stream decline's billed partial is // only in usage.iterations and is not folded in here. - costModel = { ...model, cost: CLAUDE_OPUS_48_FALLBACK_MODEL_COST }; + costModel = { + ...model, + cost: resolveAnthropicFallbackServingModelCost({ + requestedModelId: model.id, + servingModelId: fallbackBoundary.toModel, + requestedCost: model.cost, + }), + }; calculateCost(costModel, output.usage); eventSink.push({ type: "start", partial: output as never }); for (const [i, block] of output.content.entries()) {