feat: add Claude Fable 5 model mapping across Claude Code and Desktop

- Wire claude-fable-5 as a fourth tier on both proxy paths, with a
  fable -> opus -> default fallback mirroring the official downgrade.
- Whitelist the fable- prefix for the Desktop 1.12603.1+ validator.
- Clarify fallbackModelHint (zh/en/ja/zh-TW): a blank tier on
  third-party endpoints forwards the literal model name and 404s.

Refs #3980, #4026, #4049.
This commit is contained in:
Jason
2026-06-13 19:11:59 +08:00
parent 6e519a7496
commit 4f8a79c273
13 changed files with 343 additions and 41 deletions
@@ -117,7 +117,7 @@ const CLAUDE_ROUTE_PREFIX = "claude-";
const ANTHROPIC_CLAUDE_ROUTE_PREFIX = "anthropic/claude-";
const LEGACY_ONE_M_MARKER = "[1m]";
const ROLE_ROUTE_IDS = CLAUDE_DESKTOP_ROLE_ROUTE_IDS;
const ROLE_ORDER: RouteRole[] = ["sonnet", "opus", "haiku"];
const ROLE_ORDER: RouteRole[] = ["sonnet", "opus", "fable", "haiku"];
function envString(
settingsConfig: Record<string, unknown> | undefined,
@@ -138,8 +138,10 @@ function clonePlainRecord(value: unknown): Record<string, unknown> {
function routeRoleFromId(route: string): RouteRole {
const normalized = route.trim().toLowerCase();
// 与后端 claude_role_keyword 同序(opus → haiku → fable → sonnet)。
if (normalized.includes("opus")) return "opus";
if (normalized.includes("haiku")) return "haiku";
if (normalized.includes("fable")) return "fable";
return "sonnet";
}
@@ -193,9 +195,10 @@ function initialRouteRows(
});
}
// Proxy 模式对齐 Claude Code:固定 Sonnet / Opus / Haiku 档。
// 把任意来源的 route 行按角色归类到固定槽(缺档留空),保证 UI 永远行、
// 用户不会漏配 Haiku 导致子 agent 找不到模型。
// Proxy 模式对齐 Claude Code:固定 Sonnet / Opus / Fable / Haiku 档。
// 把任意来源的 route 行按角色归类到固定槽(缺档留空),保证 UI 永远行、
// 用户不会漏配某档导致子 agent 找不到模型。
// fable 自 Desktop 1.12603.1+ 起被 fail-all 校验放行,可作为独立档位。)
function normalizeProxyRows(rows: RouteRow[]): RouteRow[] {
return ROLE_ORDER.map((role) => {
const match = rows.find(
@@ -221,7 +224,8 @@ function isClaudeSafeRoute(route: string) {
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
return ["sonnet-", "opus-", "haiku-"].some(
// 与后端 is_claude_safe_model_id 镜像;fable 自 Desktop 1.12603.1+ 起被校验放行。
return ["sonnet-", "opus-", "haiku-", "fable-"].some(
(prefix) =>
routeTail.startsWith(prefix) && routeTail.length > prefix.length,
);
@@ -574,9 +578,9 @@ export function ClaudeDesktopProviderForm({
.filter((route) => route.route || route.model);
if (mode === "proxy") {
// 固定档(Sonnet / Opus / Haiku),route_id 由 UI 生成、恒合法,
// 固定档(Sonnet / Opus / Fable / Haiku),route_id 由 UI 生成、恒合法,
// 因此只要求至少填一个实际请求模型;留空档继承第一个已填档(Sonnet 优先),
// 对齐 Claude Code 的兜底,保证落库档齐全、子 agent 不会找不到 Haiku
// 对齐 Claude Code 的兜底,保证落库档齐全、子 agent 不会找不到模型
const primary = routeEntries.find((route) => route.model);
if (!primary) {
toast.error(
@@ -931,9 +935,13 @@ export function ClaudeDesktopProviderForm({
? t("claudeDesktop.routeRoleHaiku", {
defaultValue: "Haiku",
})
: t("claudeDesktop.routeRoleSonnet", {
defaultValue: "Sonnet",
});
: role === "fable"
? t("claudeDesktop.routeRoleFable", {
defaultValue: "Fable",
})
: t("claudeDesktop.routeRoleSonnet", {
defaultValue: "Sonnet",
});
return (
<div
key={route.rowId}
@@ -123,6 +123,8 @@ interface ClaudeFormFieldsProps {
defaultSonnetModelName: string;
defaultOpusModel: string;
defaultOpusModelName: string;
defaultFableModel: string;
defaultFableModelName: string;
onModelChange: (field: ClaudeModelEnvField, value: string) => void;
// Speed Test Endpoints
@@ -187,6 +189,8 @@ export function ClaudeFormFields({
defaultSonnetModelName,
defaultOpusModel,
defaultOpusModelName,
defaultFableModel,
defaultFableModelName,
onModelChange,
speedTestEndpoints,
apiFormat,
@@ -204,6 +208,7 @@ export function ClaudeFormFields({
defaultHaikuModel ||
defaultSonnetModel ||
defaultOpusModel ||
defaultFableModel ||
apiFormat !== "anthropic" ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
customUserAgent
@@ -495,7 +500,7 @@ export function ClaudeFormFields({
};
type ModelRoleRow = {
role: "sonnet" | "opus" | "haiku";
role: "sonnet" | "opus" | "fable" | "haiku";
label: string;
model: string;
displayName: string;
@@ -526,6 +531,16 @@ export function ClaudeFormFields({
inputId: "claudeDefaultOpusModel",
supportsOneM: true,
},
{
role: "fable",
label: t("providerForm.modelRoleFable", { defaultValue: "Fable" }),
model: defaultFableModel,
displayName: defaultFableModelName,
modelField: "ANTHROPIC_DEFAULT_FABLE_MODEL",
displayNameField: "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
inputId: "claudeDefaultFableModel",
supportsOneM: true,
},
{
role: "haiku",
label: t("providerForm.modelRoleHaiku", { defaultValue: "Haiku" }),
@@ -786,6 +801,7 @@ export function ClaudeFormFields({
claudeModel ||
defaultSonnetModel ||
defaultOpusModel ||
defaultFableModel ||
defaultHaikuModel;
if (value) {
for (const row of modelRoleRows) {
@@ -809,7 +825,8 @@ export function ClaudeFormFields({
!claudeModel &&
!defaultHaikuModel &&
!defaultSonnetModel &&
!defaultOpusModel
!defaultOpusModel &&
!defaultFableModel
}
className="h-7 gap-1"
>
@@ -936,7 +953,7 @@ export function ClaudeFormFields({
<p className="text-xs text-muted-foreground">
{t("providerForm.fallbackModelHint", {
defaultValue:
"仅在 Claude Code 请求没有明确落到 Sonnet、OpusHaiku 角色时使用;通常可以留空。",
"用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。",
})}
</p>
</div>
@@ -449,6 +449,8 @@ function ProviderFormFull({
defaultSonnetModelName,
defaultOpusModel,
defaultOpusModelName,
defaultFableModel,
defaultFableModelName,
handleModelChange,
} = useModelState({
settingsConfig: form.getValues("settingsConfig"),
@@ -2009,6 +2011,8 @@ function ProviderFormFull({
defaultSonnetModelName={defaultSonnetModelName}
defaultOpusModel={defaultOpusModel}
defaultOpusModelName={defaultOpusModelName}
defaultFableModel={defaultFableModel}
defaultFableModelName={defaultFableModelName}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
apiFormat={localApiFormat}
@@ -12,7 +12,9 @@ export type ClaudeModelEnvField =
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
| "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"
| "ANTHROPIC_DEFAULT_OPUS_MODEL"
| "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME";
| "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"
| "ANTHROPIC_DEFAULT_FABLE_MODEL"
| "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME";
export const CLAUDE_ONE_M_MARKER = "[1M]";
@@ -69,8 +71,28 @@ function parseModelsFromConfig(settingsConfig: string) {
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME === "string"
? env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME
: stripClaudeOneMMarker(opus);
// 回填链镜像运行时映射链(fable → opus → default),保证 UI 展示
// 与代理实际转发的模型一致。
const fable =
typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL === "string"
? env.ANTHROPIC_DEFAULT_FABLE_MODEL
: opus;
const fableName =
typeof env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME === "string"
? env.ANTHROPIC_DEFAULT_FABLE_MODEL_NAME
: stripClaudeOneMMarker(fable);
return { model, haiku, haikuName, sonnet, sonnetName, opus, opusName };
return {
model,
haiku,
haikuName,
sonnet,
sonnetName,
opus,
opusName,
fable,
fableName,
};
} catch {
return {
model: "",
@@ -80,6 +102,8 @@ function parseModelsFromConfig(settingsConfig: string) {
sonnetName: "",
opus: "",
opusName: "",
fable: "",
fableName: "",
};
}
}
@@ -106,6 +130,10 @@ export function useModelState({
const [defaultOpusModelName, setDefaultOpusModelName] = useState(
initial.opusName,
);
const [defaultFableModel, setDefaultFableModel] = useState(initial.fable);
const [defaultFableModelName, setDefaultFableModelName] = useState(
initial.fableName,
);
const isUserEditingRef = useRef(false);
const lastConfigRef = useRef(settingsConfig);
@@ -134,6 +162,8 @@ export function useModelState({
setDefaultSonnetModelName(parsed.sonnetName);
setDefaultOpusModel(parsed.opus);
setDefaultOpusModelName(parsed.opusName);
setDefaultFableModel(parsed.fable);
setDefaultFableModelName(parsed.fableName);
}, [settingsConfig]);
const handleModelChange = useCallback(
@@ -152,6 +182,10 @@ export function useModelState({
if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL") setDefaultOpusModel(value);
if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME")
setDefaultOpusModelName(value);
if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL")
setDefaultFableModel(value);
if (field === "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME")
setDefaultFableModelName(value);
try {
const currentConfig = latestConfigRef.current
@@ -195,6 +229,10 @@ export function useModelState({
setDefaultOpusModel,
defaultOpusModelName,
setDefaultOpusModelName,
defaultFableModel,
setDefaultFableModel,
defaultFableModelName,
setDefaultFableModelName,
handleModelChange,
};
}