refactor(claude-desktop): lock route IDs to sonnet/opus/haiku roles

Adapt to Claude Desktop 1.6259.1+ fail-all validation which only
accepts claude-(sonnet|opus|haiku)-* route IDs. Branded model names
(DeepSeek, Kimi, GLM, etc.) now live in a new labelOverride field
instead of being embedded in route IDs.

- Backend auto-repairs legacy unsafe routes to the next free
  sonnet/opus/haiku slot instead of erroring
- Frontend swaps the free-form route input for a role dropdown plus
  menu display name field
- Add CLAUDE_DESKTOP_ROLE_ROUTE_IDS as the single source of truth
  for role-to-route mapping; presets and form both consume it
- Drop the dead displayName alias on ClaudeDesktopModelRoute and the
  ineffective /v1/models display_name injection (UI ignores it)
- Update i18n (en/ja/zh) and form focus test for the new fields
This commit is contained in:
Jason
2026-05-13 15:22:23 +08:00
parent edf28b6422
commit 84bac6dce6
10 changed files with 493 additions and 233 deletions
@@ -51,8 +51,10 @@ import type {
} from "@/types";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import {
CLAUDE_DESKTOP_ROLE_ROUTE_IDS,
claudeDesktopProviderPresets,
type ClaudeDesktopProviderPreset,
type ClaudeDesktopRoleId,
} from "@/config/claudeDesktopProviderPresets";
import {
fetchModelsForConfig,
@@ -104,14 +106,56 @@ type RouteRow = {
rowId: string;
route: string;
model: string;
labelOverride: string;
supports1m: boolean;
};
type RouteRowValues = Omit<RouteRow, "rowId">;
type RouteRole = ClaudeDesktopRoleId;
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 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,
@@ -130,42 +174,30 @@ function clonePlainRecord(value: unknown): Record<string, unknown> {
return { ...(value as Record<string, unknown>) };
}
function desktopRouteInputValue(route: string) {
const trimmed = route.trim();
const lower = trimmed.toLowerCase();
if (lower.startsWith(CLAUDE_ROUTE_PREFIX)) {
return trimmed.slice(CLAUDE_ROUTE_PREFIX.length);
}
if (lower.startsWith(ANTHROPIC_CLAUDE_ROUTE_PREFIX)) {
return trimmed.slice(ANTHROPIC_CLAUDE_ROUTE_PREFIX.length);
}
return trimmed;
function routeRoleFromId(route: string): RouteRole {
const normalized = route.trim().toLowerCase();
if (normalized.includes("opus")) return "opus";
if (normalized.includes("haiku")) return "haiku";
return "sonnet";
}
function normalizeDesktopRouteSuffix(value: string) {
const trimmed = value.trim();
const lower = trimmed.toLowerCase();
if (lower.startsWith(CLAUDE_ROUTE_PREFIX)) {
return trimmed.slice(CLAUDE_ROUTE_PREFIX.length);
function routeIdForRole(role: RouteRole, usedRoutes: Set<string>) {
const baseRoute = ROLE_ROUTE_IDS[role];
if (!usedRoutes.has(baseRoute)) return baseRoute;
let index = 2;
while (usedRoutes.has(`${baseRoute}-r${index}`)) {
index += 1;
}
if (lower.startsWith(ANTHROPIC_CLAUDE_ROUTE_PREFIX)) {
return trimmed.slice(ANTHROPIC_CLAUDE_ROUTE_PREFIX.length);
}
return trimmed;
return `${baseRoute}-r${index}`;
}
function desktopRouteIdFromInput(value: string) {
const suffix = normalizeDesktopRouteSuffix(value).replace(/[/\\\s]+/g, "-");
if (!suffix) return "";
return `${CLAUDE_ROUTE_PREFIX}${suffix}`;
}
function desktopRouteIdFromModel(model: string) {
const suffix = model
.trim()
.replace(/^models\//i, "")
.replace(/[/\\\s]+/g, "-");
return desktopRouteIdFromInput(suffix);
function fallbackCatalogRouteId(usedRoutes: Set<string>) {
const role = ROLE_ORDER.find((candidate) => {
const route = ROLE_ROUTE_IDS[candidate];
return !usedRoutes.has(route);
});
return routeIdForRole(role ?? "sonnet", usedRoutes);
}
function createRouteRow(row: RouteRowValues): RouteRow {
@@ -178,21 +210,42 @@ function createRouteRow(row: RouteRowValues): RouteRow {
function initialRouteRows(
routes: Record<string, ClaudeDesktopModelRoute> | undefined,
): RouteRow[] {
return Object.entries(routes ?? {}).map(([route, value]) =>
createRouteRow({
route,
model: value.model ?? "",
supports1m: value.supports1m ?? false,
}),
const usedRoutes = new Set(
Object.keys(routes ?? {}).filter((route) => isClaudeSafeRoute(route)),
);
return Object.entries(routes ?? {}).map(([route, value]) => {
const routeId = isClaudeSafeRoute(route)
? route
: fallbackCatalogRouteId(usedRoutes);
usedRoutes.add(routeId);
return createRouteRow({
route: routeId,
model: value.model ?? "",
labelOverride:
value.labelOverride ??
(!isClaudeSafeRoute(route) ? value.model || route : ""),
supports1m: value.supports1m ?? false,
});
});
}
function isClaudeSafeRoute(route: string) {
const normalized = route.trim().toLowerCase();
if (normalized.includes(LEGACY_ONE_M_MARKER)) return false;
return [CLAUDE_ROUTE_PREFIX, ANTHROPIC_CLAUDE_ROUTE_PREFIX].some(
(prefix) =>
normalized.startsWith(prefix) && normalized.length > prefix.length,
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))
);
}
@@ -204,6 +257,7 @@ function defaultRouteRows(
createRouteRow({
route: route.routeId,
model: index === 0 ? defaultModel : "",
labelOverride: "",
supports1m: route.supports1m,
}),
);
@@ -219,13 +273,16 @@ function nextRouteRow(current: RouteRow[], defaults: RouteRow[]): RouteRow {
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: "",
route: routeIdForRole("sonnet", usedRoutes),
model: "",
labelOverride: "",
supports1m: true,
});
}
@@ -399,6 +456,7 @@ export function ClaudeDesktopProviderForm({
createRouteRow({
route: r.routeId,
model: r.upstreamModel,
labelOverride: r.labelOverride ?? "",
supports1m: r.supports1m,
}),
),
@@ -444,6 +502,19 @@ 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) {
@@ -555,6 +626,7 @@ export function ClaudeDesktopProviderForm({
...route,
route: route.route.trim(),
model: route.model.trim(),
labelOverride: route.labelOverride.trim(),
}))
.filter((route) => route.route || route.model);
@@ -614,6 +686,8 @@ export function ClaudeDesktopProviderForm({
>((acc, route) => {
acc[route.route] = {
model: route.model || route.route,
labelOverride:
route.labelOverride || (mode === "proxy" ? route.model : undefined),
supports1m: route.supports1m || undefined,
};
return acc;
@@ -863,15 +937,20 @@ export function ClaudeDesktopProviderForm({
<p className="text-xs leading-relaxed text-muted-foreground">
{t("claudeDesktop.routeMapHint", {
defaultValue:
"左侧决定 Claude Desktop 模型菜单里的可见模型 ID1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。",
"选择模型角色后,CC Switch 会自动生成 Claude Desktop 兼容路由;菜单显示名可以写 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
})}
</p>
</div>
<div className="hidden grid-cols-[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_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<span>
{t("claudeDesktop.routeModelLabel", {
defaultValue: "Desktop 显示模型",
defaultValue: "模型角色",
})}
</span>
<span>
{t("claudeDesktop.labelOverrideLabel", {
defaultValue: "菜单显示名",
})}
</span>
<span>
@@ -889,25 +968,44 @@ export function ClaudeDesktopProviderForm({
{routes.map((route, index) => (
<div
key={route.rowId}
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_116px_36px]"
className="grid grid-cols-1 gap-2 md:grid-cols-[140px_1fr_1fr_116px_36px]"
>
<div className="flex">
<span className="inline-flex h-9 items-center rounded-l-md border border-r-0 border-input bg-muted px-3 text-sm text-muted-foreground">
{CLAUDE_ROUTE_PREFIX}
</span>
<Input
value={desktopRouteInputValue(route.route)}
onChange={(event) =>
updateRoute(index, {
route: desktopRouteIdFromInput(
event.target.value,
),
})
}
placeholder="gpt-5.5"
className="rounded-l-none"
/>
</div>
<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,
})
}
placeholder="DeepSeek V4 Pro"
/>
<div className="flex gap-1">
<Input
value={route.model}
@@ -923,8 +1021,13 @@ export function ClaudeDesktopProviderForm({
onSelect={(id) =>
updateRoute(index, {
model: id,
labelOverride: route.labelOverride || id,
route:
route.route || desktopRouteIdFromModel(id),
route.route ||
routeIdForRole(
"sonnet",
new Set(routes.map((row) => row.route)),
),
})
}
/>
@@ -1006,6 +1109,7 @@ export function ClaudeDesktopProviderForm({
createRouteRow({
route: "",
model: "",
labelOverride: "",
supports1m: false,
}),
]),
+36 -27
View File
@@ -20,9 +20,23 @@ export type ClaudeDesktopApiFormat =
export interface ClaudeDesktopRoutePreset {
routeId: string;
upstreamModel: string;
labelOverride?: string;
supports1m: boolean;
}
/**
* Claude Desktop 3P fail-all 校验只接受 `claude-(sonnet|opus|haiku)-*` 形式的
* routeId1.6259.1+,实测 2026-05-13)。所有预设工厂、表单角色下拉、
* 后端 `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。
*/
export const CLAUDE_DESKTOP_ROLE_ROUTE_IDS = {
sonnet: "claude-sonnet-4-6",
opus: "claude-opus-4-7",
haiku: "claude-haiku-4-5",
} as const;
export type ClaudeDesktopRoleId = keyof typeof CLAUDE_DESKTOP_ROLE_ROUTE_IDS;
export interface ClaudeDesktopProviderPreset {
name: string;
nameKey?: string;
@@ -49,18 +63,18 @@ export interface ClaudeDesktopProviderPreset {
const passthroughRoutes = (supports1m = false): ClaudeDesktopRoutePreset[] => [
{
routeId: "claude-sonnet-4-6",
upstreamModel: "claude-sonnet-4-6",
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet,
upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet,
supports1m,
},
{
routeId: "claude-opus-4-7",
upstreamModel: "claude-opus-4-7",
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus,
upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus,
supports1m,
},
{
routeId: "claude-haiku-4-5",
upstreamModel: "claude-haiku-4-5",
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku,
upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku,
supports1m,
},
];
@@ -72,35 +86,25 @@ const mappedRoutes = (
supports1m = false,
): ClaudeDesktopRoutePreset[] => [
{
routeId: "claude-sonnet-4-6",
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet,
upstreamModel: sonnet,
supports1m,
},
{
routeId: "claude-opus-4-7",
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus,
upstreamModel: opus,
supports1m,
},
{
routeId: "claude-haiku-4-5",
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku,
upstreamModel: haiku,
supports1m,
},
];
const routeIdFromModel = (model: string) => {
const trimmed = model.trim();
const lower = trimmed.toLowerCase();
if (lower.startsWith("claude-") || lower.startsWith("anthropic/claude-")) {
return trimmed;
}
return `claude-${trimmed.replace(/^models\//i, "").replace(/[/\\\s]+/g, "-")}`;
};
/**
* 非 Claude 上游模型用此工厂:用 claude-* route ID 承载品牌化模型名,
* 让 Claude Desktop 菜单能从 ID 看到接近上游模型的名称
* 非 Claude 上游模型用此工厂:route ID 使Claude Desktop 能通过校验的
* Sonnet/Opus/Haiku 路由,真实品牌名只写入 labelOverride 和 upstreamModel
*/
const brandedRoutes = (
sonnet: string,
@@ -108,18 +112,23 @@ const brandedRoutes = (
haiku: string,
supports1m = false,
): ClaudeDesktopRoutePreset[] => {
const seen = new Set<string>();
return [sonnet, opus, haiku]
.map((upstreamModel) => ({
routeId: routeIdFromModel(upstreamModel),
const seenUpstream = new Set<string>();
return [
{ routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet, upstreamModel: sonnet },
{ routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus, upstreamModel: opus },
{ routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku, upstreamModel: haiku },
]
.map(({ routeId, upstreamModel }) => ({
routeId,
upstreamModel,
labelOverride: upstreamModel,
supports1m,
}))
.filter((route) => {
if (seen.has(route.routeId)) {
if (seenUpstream.has(route.upstreamModel)) {
return false;
}
seen.add(route.routeId);
seenUpstream.add(route.upstreamModel);
return true;
});
};
+7 -3
View File
@@ -182,8 +182,12 @@
"modelMappingOffHint": "Use this when the provider already exposes and accepts claude-* / anthropic/claude-* model IDs through Anthropic Messages. Claude Desktop connects to the provider directly.",
"modelMappingOnHint": "Claude Desktop currently restricts model IDs. If your provider offers non-Claude models, enable this switch and keep local routing running while in use.",
"routeMapTitle": "Model mapping",
"routeMapHint": "The left field controls the Claude Desktop-visible model ID. 1M only declares 1M context support to Claude Desktop; requested model IDs are sent upstream exactly as entered on the right.",
"routeModelLabel": "Desktop display model",
"routeMapHint": "Choose a model role and CC Switch will generate a Claude Desktop-compatible route automatically. Use the menu display name for branded models such as DeepSeek or Kimi; requested model IDs are sent upstream exactly as entered.",
"routeModelLabel": "Model role",
"routeRoleSonnet": "Sonnet",
"routeRoleOpus": "Opus",
"routeRoleHaiku": "Haiku",
"labelOverrideLabel": "Menu display name",
"upstreamModelLabel": "Requested model",
"supports1mLabel": "Declare 1M",
"supports1mShort": "1M",
@@ -193,7 +197,7 @@
"directModelInvalid": "Direct models must use claude-* / anthropic/claude-* model IDs",
"addModel": "Add model",
"addRoute": "Add model",
"routeInvalid": "Please enter both the Desktop display model and requested model",
"routeInvalid": "Choose a model role and enter the requested model",
"routesRequired": "At least one model mapping is required",
"statusTitle": "Claude Desktop configuration needs attention",
"statusUnsupported": "This platform does not support writing Claude Desktop 3P configuration yet.",
+7 -3
View File
@@ -182,8 +182,12 @@
"modelMappingOffHint": "プロバイダーが Anthropic Messages で claude-* / anthropic/claude-* のモデル ID を公開し、そのまま受け付ける場合に使います。Claude Desktop はプロバイダーへ直接接続します。",
"modelMappingOnHint": "Claude Desktop は現在モデル ID を制限しています。お使いのプロバイダーが Claude 系列以外のモデルを提供する場合、このスイッチを有効にし、使用中はローカルルーティングを起動したままにしてください。",
"routeMapTitle": "モデルマッピング",
"routeMapHint": "左側は Claude Desktop のモデルメニューに表示されるモデル ID です。1M は Claude Desktop に 1M コンテキスト対応を宣言するだけで、上流へのリクエストモデル ID は右側の入力どおりに送信されます。",
"routeModelLabel": "Desktop 表示モデル",
"routeMapHint": "モデル役割を選ぶと、CC Switch が Claude Desktop 互換ルートを自動生成します。DeepSeek や Kimi などのブランド名はメニュー表示名に入れ、実際のリクエストモデルは右側の入力どおりに送信されます。",
"routeModelLabel": "モデル役割",
"routeRoleSonnet": "Sonnet",
"routeRoleOpus": "Opus",
"routeRoleHaiku": "Haiku",
"labelOverrideLabel": "メニュー表示名",
"upstreamModelLabel": "リクエストモデル",
"supports1mLabel": "1M 対応を宣言",
"supports1mShort": "1M",
@@ -193,7 +197,7 @@
"directModelInvalid": "直結モデルは claude-* / anthropic/claude-* のモデル ID を使う必要があります",
"addModel": "モデルを追加",
"addRoute": "モデルを追加",
"routeInvalid": "Desktop 表示モデルとリクエストモデルを入力してください",
"routeInvalid": "モデル役割を選択し、リクエストモデルを入力してください",
"routesRequired": "少なくとも 1 つのモデルマッピングが必要です",
"statusTitle": "Claude Desktop 設定の確認が必要です",
"statusUnsupported": "このプラットフォームでは Claude Desktop 3P 設定の書き込みはまだサポートされていません。",
+7 -3
View File
@@ -182,8 +182,12 @@
"modelMappingOffHint": "适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。",
"modelMappingOnHint": "Claude Desktop 目前对模型 ID 进行了限制,如果您的供应商提供的模型不是 Claude 系列模型,则需要打开本开关,并在使用过程中保持本地路由开启。",
"routeMapTitle": "模型映射",
"routeMapHint": "左侧决定 Claude Desktop 模型菜单里的可见模型 ID1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。",
"routeModelLabel": "Desktop 显示模型",
"routeMapHint": "选择模型角色后,CC Switch 会自动生成 Claude Desktop 兼容路由;菜单显示名可以写 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
"routeModelLabel": "模型角色",
"routeRoleSonnet": "Sonnet",
"routeRoleOpus": "Opus",
"routeRoleHaiku": "Haiku",
"labelOverrideLabel": "菜单显示名",
"upstreamModelLabel": "实际请求模型",
"supports1mLabel": "声明支持 1M",
"supports1mShort": "1M",
@@ -193,7 +197,7 @@
"directModelInvalid": "直连模型必须使用 claude-* / anthropic/claude-* 模型名",
"addModel": "添加模型",
"addRoute": "添加模型",
"routeInvalid": "请填写 Desktop 显示模型和实际请求模型",
"routeInvalid": "请选择模型角色并填写实际请求模型",
"routesRequired": "至少填写一个模型映射",
"statusTitle": "Claude Desktop 配置需要检查",
"statusUnsupported": "当前平台暂不支持 Claude Desktop 3P 配置写入。",
+1 -2
View File
@@ -133,8 +133,7 @@ export interface AuthBinding {
export interface ClaudeDesktopModelRoute {
model: string;
/** @deprecated Claude Desktop ignores this in the model menu; kept only to read old configs. */
displayName?: string;
labelOverride?: string;
supports1m?: boolean;
}