fix(anthropic): price fallback serving models

This commit is contained in:
Vincent Koc
2026-07-25 18:40:16 +08:00
parent 90b75a1e63
commit 604daecafc
3 changed files with 91 additions and 7 deletions
@@ -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;
+19 -2
View File
@@ -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()) {
@@ -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()) {