Align Claude Desktop model mapping with Claude Code three-role tiers

Claude Desktop's 3P validation only accepts claude-{sonnet,opus,haiku}-*
role IDs, so providers must map every tier. Bring the Desktop mapping flow
in line with Claude Code and fix the fallout that broke sub-agent Haiku calls.

- Proxy form: replace the dynamic route list with fixed Sonnet/Opus/Haiku
  tiers; blank tiers backfill from the first filled tier (Sonnet first) on
  submit and inherit its supports1m flag
- Backend: add a role-keyword fallback in map_proxy_request_model so dated
  official names (e.g. claude-haiku-4-5-20251001) resolve to the right tier,
  guarded by is_claude_safe_model_id so [1m]-suffixed IDs stay rejected
- Tighten is_claude_safe_model_id / isClaudeSafeRoute to reject degenerate
  role IDs like "claude-sonnet-"
- Fix the seed-effect race where normalizing empty routes to three blank
  tiers blocked the default-route backfill
- Sync switch hints, placeholders, and the zh/en/ja/zh-TW locales to the
  three-role-ID rule
- Update zh/en/ja user manual (2.1, 2.6), calling out legacy Claude IDs
  (claude-3-5-sonnet-...) as a rejected example

Tests: 282 frontend + 34 backend claude_desktop; typecheck, clippy, fmt clean.
This commit is contained in:
Jason
2026-05-29 22:27:47 +08:00
parent 058c9fb8e5
commit 94cc3d103a
13 changed files with 574 additions and 363 deletions
@@ -118,44 +118,6 @@ 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 NON_ANTHROPIC_ROUTE_MARKERS = [
"ark-code",
"astron",
"command-r",
"deepseek",
"doubao",
"gemini",
"gemma",
"glm",
"gpt",
"grok",
"hermes",
"hy3",
"kimi",
"lfm",
"llama",
"longcat",
"mimo",
"minimax",
"mistral",
"mixtral",
"moonshot",
"nemotron",
"openai",
"qianfan",
"qwen",
"stepfun",
"seed-",
"hunyuan",
"nova-",
"ernie",
"codex",
"abab",
"jamba",
"arctic",
"solar",
"mercury",
];
function envString(
settingsConfig: Record<string, unknown> | undefined,
@@ -231,21 +193,37 @@ function initialRouteRows(
});
}
// Proxy 模式对齐 Claude Code:固定 Sonnet / Opus / Haiku 三档。
// 把任意来源的 route 行按角色归类到固定三槽(缺档留空),保证 UI 永远三行、
// 用户不会漏配 Haiku 导致子 agent 找不到模型。
function normalizeProxyRows(rows: RouteRow[]): RouteRow[] {
return ROLE_ORDER.map((role) => {
const match = rows.find(
(row) => row.route.trim() && routeRoleFromId(row.route) === role,
);
return createRouteRow({
route: ROLE_ROUTE_IDS[role],
model: match?.model ?? "",
labelOverride: match?.labelOverride ?? "",
supports1m: match?.supports1m ?? false,
});
});
}
function isClaudeSafeRoute(route: string) {
const normalized = route.trim().toLowerCase();
if (normalized.includes(LEGACY_ONE_M_MARKER)) return false;
const hasAllowedShape =
[CLAUDE_ROUTE_PREFIX, ANTHROPIC_CLAUDE_ROUTE_PREFIX].some(
(prefix) =>
normalized.startsWith(prefix) && normalized.length > prefix.length,
) ||
["sonnet", "opus", "haiku"].includes(normalized) ||
normalized.startsWith("sonnet-") ||
normalized.startsWith("opus-") ||
normalized.startsWith("haiku-");
return (
hasAllowedShape &&
!NON_ANTHROPIC_ROUTE_MARKERS.some((marker) => normalized.includes(marker))
const routeTail = normalized.startsWith(ANTHROPIC_CLAUDE_ROUTE_PREFIX)
? normalized.slice(ANTHROPIC_CLAUDE_ROUTE_PREFIX.length)
: normalized.startsWith(CLAUDE_ROUTE_PREFIX)
? normalized.slice(CLAUDE_ROUTE_PREFIX.length)
: "";
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
return ["sonnet-", "opus-", "haiku-"].some(
(prefix) =>
routeTail.startsWith(prefix) && routeTail.length > prefix.length,
);
}
@@ -263,30 +241,6 @@ function defaultRouteRows(
);
}
function nextRouteRow(current: RouteRow[], defaults: RouteRow[]): RouteRow {
const defaultRow =
defaults.find(
(route) => !current.some((existing) => existing.route === route.route),
) ?? null;
if (defaultRow) {
return createRouteRow({
route: defaultRow.route,
model: defaultRow.model,
labelOverride: defaultRow.labelOverride,
supports1m: defaultRow.supports1m,
});
}
const usedRoutes = new Set(current.map((route) => route.route));
return createRouteRow({
route: routeIdForRole("sonnet", usedRoutes),
model: "",
labelOverride: "",
supports1m: true,
});
}
export function ClaudeDesktopProviderForm({
submitLabel,
onSubmit,
@@ -334,9 +288,15 @@ export function ClaudeDesktopProviderForm({
providerType?: string;
requiresOAuth?: boolean;
} | null>(null);
const [routes, setRoutes] = useState<RouteRow[]>(() =>
initialRouteRows(initialData?.meta?.claudeDesktopModelRoutes),
);
const [routes, setRoutes] = useState<RouteRow[]>(() => {
const rows = initialRouteRows(initialData?.meta?.claudeDesktopModelRoutes);
// proxy 模式归一化成固定三档;但初始无任何 route 时保持空数组,交给 seed
// effect 用默认路由回填(默认 1M 声明、ANTHROPIC_MODEL 预填),避免过早
// normalize 成空三档把 routes.length 撑到 3、永久挡住 seed。
return initialMode === "proxy" && rows.length > 0
? normalizeProxyRows(rows)
: rows;
});
const didSeedDefaultRoutes = useRef(
Object.keys(initialData?.meta?.claudeDesktopModelRoutes ?? {}).length > 0,
);
@@ -435,13 +395,15 @@ export function ClaudeDesktopProviderForm({
setMode(preset.mode);
if (preset.mode === "proxy" && preset.modelRoutes) {
setRoutes(
preset.modelRoutes.map((r) =>
createRouteRow({
route: r.routeId,
model: r.upstreamModel,
labelOverride: r.labelOverride ?? "",
supports1m: r.supports1m,
}),
normalizeProxyRows(
preset.modelRoutes.map((r) =>
createRouteRow({
route: r.routeId,
model: r.upstreamModel,
labelOverride: r.labelOverride ?? "",
supports1m: r.supports1m,
}),
),
),
);
} else {
@@ -485,28 +447,26 @@ export function ClaudeDesktopProviderForm({
);
};
const updateRouteRole = (index: number, role: RouteRole) => {
setRoutes((current) => {
const usedRoutes = new Set(
current
.filter((_, i) => i !== index)
.map((row) => row.route)
.filter(Boolean),
);
const route = routeIdForRole(role, usedRoutes);
return current.map((row, i) => (i === index ? { ...row, route } : row));
});
};
const handleModelMappingChange = (checked: boolean) => {
setMode(checked ? "proxy" : "direct");
if (checked) {
// 切到 proxy:归一化成固定 Sonnet / Opus / Haiku 三档;
// 若当前无路由则以后端默认路由作为来源(保留 Sonnet 默认模型)。
setRoutes((current) => {
if (current.length > 0 || defaultProxyRouteRows.length === 0) {
// 默认路由(默认 1M 声明、ANTHROPIC_MODEL 预填)异步加载完成前,若当前
// 无路由则保持空数组,交给 seed effect 在加载后回填;不要过早 normalize
// 成空三档(会把 routes.length 撑到 3、永久挡住 seed)。
if (current.length === 0 && defaultProxyRouteRows.length === 0) {
return current;
}
didSeedDefaultRoutes.current = true;
return defaultProxyRouteRows;
const useDefaults =
current.length === 0 && defaultProxyRouteRows.length > 0;
if (useDefaults) {
didSeedDefaultRoutes.current = true;
}
return normalizeProxyRows(
useDefaults ? defaultProxyRouteRows : current,
);
});
}
};
@@ -522,7 +482,7 @@ export function ClaudeDesktopProviderForm({
}
didSeedDefaultRoutes.current = true;
setRoutes(defaultProxyRouteRows);
setRoutes(normalizeProxyRows(defaultProxyRouteRows));
}, [defaultProxyRouteRows, mode, routes.length]);
const handleFetchModels = async () => {
@@ -614,19 +574,11 @@ export function ClaudeDesktopProviderForm({
.filter((route) => route.route || route.model);
if (mode === "proxy") {
const invalid = routeEntries.find(
(route) =>
!route.route || !route.model || !isClaudeSafeRoute(route.route),
);
if (invalid) {
toast.error(
t("claudeDesktop.routeInvalid", {
defaultValue: "请填写 Desktop 显示模型和实际请求模型",
}),
);
return;
}
if (routeEntries.length === 0) {
// 固定三档(Sonnet / Opus / Haiku),route_id 由 UI 生成、恒合法,
// 因此只要求至少填一个实际请求模型;留空档继承第一个已填档(Sonnet 优先),
// 对齐 Claude Code 的兜底,保证落库三档齐全、子 agent 不会找不到 Haiku。
const primary = routeEntries.find((route) => route.model);
if (!primary) {
toast.error(
t("claudeDesktop.routesRequired", {
defaultValue: "至少填写一个模型映射",
@@ -634,6 +586,19 @@ export function ClaudeDesktopProviderForm({
);
return;
}
for (const route of routeEntries) {
if (!route.model) {
route.model = primary.model;
if (!route.labelOverride) {
route.labelOverride = primary.labelOverride || primary.model;
}
// 回填的是同一个上游模型,1M 能力声明应与 primary 一致,
// 避免同模型在不同档声明不同 1M(除非该档用户已显式勾选)。
if (!route.supports1m) {
route.supports1m = primary.supports1m;
}
}
}
} else {
const invalid = routeEntries.find(
(route) => !route.route || !isClaudeSafeRoute(route.route),
@@ -642,7 +607,7 @@ export function ClaudeDesktopProviderForm({
toast.error(
t("claudeDesktop.directModelInvalid", {
defaultValue:
"直连模型必须使用 claude-* / anthropic/claude-* 模型名",
"直连模型必须使用 Claude Desktop 可识别的 Sonnet / Opus / Haiku 模型名",
}),
);
return;
@@ -668,7 +633,7 @@ export function ClaudeDesktopProviderForm({
Record<string, ClaudeDesktopModelRoute>
>((acc, route) => {
acc[route.route] = {
model: route.model || route.route,
model: mode === "direct" ? route.route : route.model || route.route,
labelOverride:
route.labelOverride || (mode === "proxy" ? route.model : undefined),
supports1m: route.supports1m || undefined,
@@ -838,11 +803,11 @@ export function ClaudeDesktopProviderForm({
{needsModelMapping
? t("claudeDesktop.modelMappingOnHint", {
defaultValue:
"Claude Desktop 目前对模型 ID 进行了限制,如果您的供应商提供的模型不是 Claude 系列模型,则需要打开本开关,并在使用过程中保持本地路由开启。",
"Claude Desktop 只接受 claude-sonnet-* / claude-opus-* / claude-haiku-* 三档角色 ID。开启后 CC Switch 会把这三档映射到供应商的实际模型,并在使用期间保持本地路由开启。",
})
: t("claudeDesktop.modelMappingOffHint", {
defaultValue:
"适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。",
"仅当供应商直接接受 Claude Desktop 可识别的三档角色 IDclaude-sonnet-* / claude-opus-* / claude-haiku-*)时才适用直连;其他模型名(含 claude-3-5-sonnet-… 等旧式 ID)请打开此开关走映射。",
})}
</p>
</div>
@@ -905,26 +870,35 @@ export function ClaudeDesktopProviderForm({
defaultValue: "模型映射",
})}
</Label>
{renderActionButtons(
() =>
setRoutes((current) => [
...current,
nextRouteRow(current, defaultProxyRouteRows),
]),
t("claudeDesktop.addRoute", {
defaultValue: "添加模型",
}),
{!usesManagedOAuth && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFetchModels}
disabled={isFetchingModels}
className="h-7 gap-1"
>
{isFetchingModels ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("providerForm.fetchModels", {
defaultValue: "获取模型",
})}
</Button>
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("claudeDesktop.routeMapHint", {
defaultValue:
"选择模型角色后,CC Switch 会自动生成 Claude Desktop 兼容路由;菜单显示名可写 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
"为 Sonnet、Opus、Haiku 三档分别填写实际请求模型;菜单显示名可写 DeepSeek、Kimi 等品牌名。留空的档会自动沿用 Sonnet(或第一个已填档)的模型,确保子 agent 调用的 Haiku 始终可用。",
})}
</p>
</div>
<div className="hidden grid-cols-[140px_1fr_1fr_116px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<div className="hidden grid-cols-[140px_1fr_1fr_116px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<span>
{t("claudeDesktop.routeModelLabel", {
defaultValue: "模型角色",
@@ -945,101 +919,75 @@ export function ClaudeDesktopProviderForm({
defaultValue: "声明支持 1M",
})}
</span>
<span />
</div>
{routes.map((route, index) => (
<div
key={route.rowId}
className="grid grid-cols-1 gap-2 md:grid-cols-[140px_1fr_1fr_116px_36px]"
>
<Select
value={routeRoleFromId(route.route)}
onValueChange={(value) =>
updateRouteRole(index, value as RouteRole)
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sonnet">
{t("claudeDesktop.routeRoleSonnet", {
defaultValue: "Sonnet",
})}
</SelectItem>
<SelectItem value="opus">
{t("claudeDesktop.routeRoleOpus", {
defaultValue: "Opus",
})}
</SelectItem>
<SelectItem value="haiku">
{t("claudeDesktop.routeRoleHaiku", {
defaultValue: "Haiku",
})}
</SelectItem>
</SelectContent>
</Select>
<Input
value={route.labelOverride}
onChange={(event) =>
updateRoute(index, {
labelOverride: event.target.value,
{routes.map((route, index) => {
const role = routeRoleFromId(route.route);
const roleLabel =
role === "opus"
? t("claudeDesktop.routeRoleOpus", {
defaultValue: "Opus",
})
}
placeholder="DeepSeek V4 Pro"
/>
<div className="flex gap-1">
: role === "haiku"
? t("claudeDesktop.routeRoleHaiku", {
defaultValue: "Haiku",
})
: t("claudeDesktop.routeRoleSonnet", {
defaultValue: "Sonnet",
});
return (
<div
key={route.rowId}
className="grid grid-cols-1 gap-2 md:grid-cols-[140px_1fr_1fr_116px]"
>
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm font-medium text-muted-foreground">
{roleLabel}
</div>
<Input
value={route.model}
value={route.labelOverride}
onChange={(event) =>
updateRoute(index, { model: event.target.value })
updateRoute(index, {
labelOverride: event.target.value,
})
}
placeholder="kimi-k2 / deepseek-chat"
className="flex-1"
placeholder="DeepSeek V4 Pro"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) =>
<div className="flex gap-1">
<Input
value={route.model}
onChange={(event) =>
updateRoute(index, { model: event.target.value })
}
placeholder="kimi-k2 / deepseek-chat"
className="flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) =>
updateRoute(index, {
model: id,
labelOverride: route.labelOverride || id,
})
}
/>
)}
</div>
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
<Checkbox
checked={route.supports1m}
onCheckedChange={(checked) =>
updateRoute(index, {
model: id,
labelOverride: route.labelOverride || id,
route:
route.route ||
routeIdForRole(
"sonnet",
new Set(routes.map((row) => row.route)),
),
supports1m: checked === true,
})
}
/>
)}
{t("claudeDesktop.supports1mShort", {
defaultValue: "1M",
})}
</label>
</div>
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
<Checkbox
checked={route.supports1m}
onCheckedChange={(checked) =>
updateRoute(index, { supports1m: checked === true })
}
/>
{t("claudeDesktop.supports1mShort", {
defaultValue: "1M",
})}
</label>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
setRoutes((current) =>
current.filter((_, i) => i !== index),
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
);
})}
</div>
</div>
)}
@@ -1081,7 +1029,7 @@ export function ClaudeDesktopProviderForm({
<p className="flex-1 text-xs leading-relaxed text-muted-foreground">
{t("claudeDesktop.directModelListHint", {
defaultValue:
"仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;这些模型名会原样发送给供应商。",
"仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 Sonnet / Opus / Haiku 模型名时填写;勾选 1M 会向 Claude Desktop 声明支持 1M 上下文。",
})}
</p>
{renderActionButtons(
@@ -1116,7 +1064,7 @@ export function ClaudeDesktopProviderForm({
route: event.target.value,
})
}
placeholder="claude-deepseek-chat"
placeholder="claude-sonnet-4-6"
className="flex-1"
/>
{fetchedModels.length > 0 && (