From 4f8a79c273141715350f8ace93d6ca33fe6d4310 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 13 Jun 2026 19:11:59 +0800 Subject: [PATCH] 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. --- src-tauri/src/claude_desktop_config.rs | 168 +++++++++++++++++- src-tauri/src/proxy/model_mapper.rs | 68 ++++++- .../forms/ClaudeDesktopProviderForm.tsx | 28 +-- .../providers/forms/ClaudeFormFields.tsx | 23 ++- .../providers/forms/ProviderForm.tsx | 4 + .../providers/forms/hooks/useModelState.ts | 42 ++++- src/config/claudeDesktopProviderPresets.ts | 9 +- src/i18n/locales/en.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/zh-TW.json | 4 +- src/i18n/locales/zh.json | 4 +- .../ClaudeDesktopProviderForm.test.tsx | 24 ++- tests/components/ClaudeFormFields.test.tsx | 2 + 13 files changed, 343 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 88c68dcf0..0824aedf1 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -60,6 +60,16 @@ pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[ env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL", supports_1m: true, }, + // fable 置于末尾:next_catalog_safe_route_id 给非安全品牌 route 借用合法 + // 角色名时仍按 sonnet→opus→haiku 顺序分配(向后兼容既有 catalog),不会把 + // 无关品牌模型借用成 fable 顶配档名。UI 行序由前端 ROLE_ORDER 独立控制为 + // Sonnet/Opus/Fable/Haiku(所有 proxy 路径都经 normalizeProxyRows 重排), + // 与此处物理顺序无关。 + ClaudeDesktopDefaultRoute { + route_id: "claude-fable-5", + env_key: "ANTHROPIC_DEFAULT_FABLE_MODEL", + supports_1m: true, + }, ]; #[derive(Debug, Clone)] @@ -238,11 +248,16 @@ pub fn is_claude_safe_model_id(model: &str) -> bool { // 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值 // (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。 - ["sonnet-", "opus-", "haiku-"].iter().any(|prefix| { - route_tail - .strip_prefix(prefix) - .is_some_and(|rest| !rest.is_empty()) - }) + // Claude Desktop 1.12603.1+ 的 fail-all validator 角色白名单已纳入 fable + // (app.asar 内 ["sonnet","opus","haiku","fable","mythos"]),故 claude-fable-* + // 可安全写入 profile。mythos 官方未公开发布,暂不暴露给用户。 + ["sonnet-", "opus-", "haiku-", "fable-"] + .iter() + .any(|prefix| { + route_tail + .strip_prefix(prefix) + .is_some_and(|rest| !rest.is_empty()) + }) } fn inference_model_json(spec: &InferenceModelSpec) -> Value { @@ -693,8 +708,8 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result Result bool { ) } -/// 按角色关键词(opus / haiku / sonnet)归类一个 Claude 模型名/route_id。 +/// 按角色关键词(opus / haiku / fable / sonnet)归类一个 Claude 模型名/route_id。 /// 仅在命中明确角色词时返回 Some,未知模型返回 None(不回落,保持精确报错语义)。 -/// 与前端 `routeRoleFromId` 同序(opus → haiku → sonnet)。 +/// 与前端 `routeRoleFromId` 同序(opus → haiku → fable → sonnet)。 fn claude_role_keyword(model: &str) -> Option<&'static str> { let normalized = model.to_ascii_lowercase(); if normalized.contains("opus") { Some("opus") } else if normalized.contains("haiku") { Some("haiku") + } else if normalized.contains("fable") { + Some("fable") } else if normalized.contains("sonnet") { Some("sonnet") } else { @@ -1650,6 +1679,127 @@ mod tests { assert!(err.to_string().contains("gpt-5")); } + #[test] + fn claude_desktop_proxy_maps_fable_to_opus_tier() { + // issue #4026/#4049:老用户只配 Sonnet/Opus/Haiku 三档、未显式配置 + // fable 档时,fable 请求按官方分类器降级方向回落到 opus 档兜底。 + let mut provider = proxy_provider("proxy"); + provider + .meta + .as_mut() + .expect("meta") + .claude_desktop_model_routes = std::collections::HashMap::from([ + ( + "claude-opus-4-8".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-opus".to_string(), + label_override: None, + supports_1m: Some(true), + }, + ), + ( + "claude-sonnet-4-6".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-sonnet".to_string(), + label_override: None, + supports_1m: Some(true), + }, + ), + ]); + + let mapped = map_proxy_request_model( + json!({"model": "claude-fable-5", "messages": []}), + &provider, + ) + .expect("fable should fall back to the opus tier"); + assert_eq!(mapped["model"], json!("upstream-opus")); + + // 带 [1m] 标记与日期后缀的形态也应命中同一回落。 + let mapped_one_m = map_proxy_request_model( + json!({"model": "claude-fable-5[1m]", "messages": []}), + &provider, + ) + .expect("fable with [1m] marker should fall back to the opus tier"); + assert_eq!(mapped_one_m["model"], json!("upstream-opus")); + + let mapped_dated = map_proxy_request_model( + json!({"model": "claude-fable-5-20260609", "messages": []}), + &provider, + ) + .expect("dated fable alias should fall back to the opus tier"); + assert_eq!(mapped_dated["model"], json!("upstream-opus")); + } + + #[test] + fn claude_desktop_proxy_fable_without_opus_route_still_errors() { + // 没有 opus 档可回落时保持精确报错语义,不静默落到其他档。 + let mut provider = proxy_provider("proxy"); + provider + .meta + .as_mut() + .expect("meta") + .claude_desktop_model_routes = std::collections::HashMap::from([( + "claude-sonnet-4-6".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-sonnet".to_string(), + label_override: None, + supports_1m: Some(true), + }, + )]); + + let err = map_proxy_request_model( + json!({"model": "claude-fable-5", "messages": []}), + &provider, + ) + .expect_err("fable without an opus route should fail"); + assert!(err.to_string().contains("claude-fable-5")); + } + + #[test] + fn claude_desktop_proxy_maps_fable_to_dedicated_route() { + // Desktop 1.12603.1+ fail-all 校验已放行 claude-fable-5,用户可显式配置 + // 独立 fable 档;此时 fable 请求精确命中 fable 档,不再降级到 opus。 + let mut provider = proxy_provider("proxy"); + provider + .meta + .as_mut() + .expect("meta") + .claude_desktop_model_routes = std::collections::HashMap::from([ + ( + "claude-opus-4-8".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-opus".to_string(), + label_override: None, + supports_1m: Some(true), + }, + ), + ( + "claude-fable-5".to_string(), + ClaudeDesktopModelRoute { + model: "upstream-fable".to_string(), + label_override: None, + supports_1m: Some(true), + }, + ), + ]); + + // 精确匹配优先命中 fable 档 + let mapped = map_proxy_request_model( + json!({"model": "claude-fable-5", "messages": []}), + &provider, + ) + .expect("explicit fable route should match"); + assert_eq!(mapped["model"], json!("upstream-fable")); + + // 带日期后缀经角色关键词回落仍归 fable 档,而非降级 opus + let mapped_dated = map_proxy_request_model( + json!({"model": "claude-fable-5-20260609", "messages": []}), + &provider, + ) + .expect("dated fable alias should map via fable role keyword"); + assert_eq!(mapped_dated["model"], json!("upstream-fable")); + } + #[test] fn claude_desktop_proxy_accepts_opus_4_7_4_8_alias_during_rollout() { let mut provider = proxy_provider("proxy"); diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs index e7652cc97..b2a18826e 100644 --- a/src-tauri/src/proxy/model_mapper.rs +++ b/src-tauri/src/proxy/model_mapper.rs @@ -11,6 +11,7 @@ pub struct ModelMapping { pub haiku_model: Option, pub sonnet_model: Option, pub opus_model: Option, + pub fable_model: Option, pub default_model: Option, } @@ -35,6 +36,11 @@ impl ModelMapping { .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(String::from), + fable_model: env + .and_then(|e| e.get("ANTHROPIC_DEFAULT_FABLE_MODEL")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from), default_model: env .and_then(|e| e.get("ANTHROPIC_MODEL")) .and_then(|v| v.as_str()) @@ -48,6 +54,7 @@ impl ModelMapping { self.haiku_model.is_some() || self.sonnet_model.is_some() || self.opus_model.is_some() + || self.fable_model.is_some() || self.default_model.is_some() } @@ -56,6 +63,16 @@ impl ModelMapping { let model_lower = original_model.to_lowercase(); // 1. 按模型类型匹配 + if model_lower.contains("fable") { + if let Some(ref m) = self.fable_model { + return m.clone(); + } + // 未单独配置 fable 档时归入 opus 档,与 Claude Code 官方 + // 分类器降级方向一致(fable→opus),避免落到 default 失去层级。 + if let Some(ref m) = self.opus_model { + return m.clone(); + } + } if model_lower.contains("haiku") { if let Some(ref m) = self.haiku_model { return m.clone(); @@ -154,7 +171,8 @@ mod tests { "ANTHROPIC_MODEL": "default-model", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped", "ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped" + "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped", + "ANTHROPIC_DEFAULT_FABLE_MODEL": "fable-mapped" } }), website_url: None, @@ -214,6 +232,54 @@ mod tests { assert_eq!(mapped, Some("opus-mapped".to_string())); } + #[test] + fn test_fable_mapping() { + let provider = create_provider_with_mapping(); + let body = json!({"model": "claude-fable-5"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "fable-mapped"); + assert_eq!(mapped, Some("fable-mapped".to_string())); + } + + #[test] + fn test_fable_with_one_m_suffix_mapping() { + // Claude Code 实际会发 claude-fable-5[1m] 形态(issue #3980) + let provider = create_provider_with_mapping(); + let body = json!({"model": "claude-fable-5[1m]"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "fable-mapped"); + assert_eq!(mapped, Some("fable-mapped".to_string())); + } + + #[test] + fn test_fable_falls_back_to_opus_when_unset() { + let mut provider = create_provider_with_mapping(); + provider.settings_config = json!({ + "env": { + "ANTHROPIC_MODEL": "default-model", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped" + } + }); + let body = json!({"model": "claude-fable-5"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "opus-mapped"); + assert_eq!(mapped, Some("opus-mapped".to_string())); + } + + #[test] + fn test_fable_falls_back_to_default_without_opus() { + let mut provider = create_provider_with_mapping(); + provider.settings_config = json!({ + "env": { + "ANTHROPIC_MODEL": "default-model" + } + }); + let body = json!({"model": "claude-fable-5"}); + let (result, _, mapped) = apply_model_mapping(body, &provider); + assert_eq!(result["model"], "default-model"); + assert_eq!(mapped, Some("default-model".to_string())); + } + #[test] fn test_thinking_does_not_affect_model_mapping() { // Issue #2081: thinking 参数不应影响模型映射 diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index e8807fc91..64f4b1ae5 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -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 | undefined, @@ -138,8 +138,10 @@ function clonePlainRecord(value: unknown): Record { 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 (
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({

{t("providerForm.fallbackModelHint", { defaultValue: - "仅在 Claude Code 请求没有明确落到 Sonnet、Opus 或 Haiku 角色时使用;通常可以留空。", + "用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。", })}

diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 8bafb5d3c..95240a4a2 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -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} diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 2a8a33d19..302a4c8da 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -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, }; } diff --git a/src/config/claudeDesktopProviderPresets.ts b/src/config/claudeDesktopProviderPresets.ts index 4146cda77..f92acf723 100644 --- a/src/config/claudeDesktopProviderPresets.ts +++ b/src/config/claudeDesktopProviderPresets.ts @@ -25,13 +25,16 @@ export interface ClaudeDesktopRoutePreset { } /** - * Claude Desktop 3P fail-all 校验只接受 `claude-(sonnet|opus|haiku)-*` 形式的 - * routeId(1.6259.1+,实测 2026-05-13)。所有预设工厂、表单角色下拉、 - * 后端 `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。 + * Claude Desktop 3P fail-all 校验接受的角色名。Desktop 1.12603.1+ 起白名单 + * 纳入 fable(app.asar 内 ["sonnet","opus","haiku","fable","mythos"],实测 + * 2026-06-13);此前 1.6259.1 仅接受 sonnet/opus/haiku。mythos 官方未公开 + * 发布,暂不暴露给用户。所有预设工厂、表单角色下拉、后端 + * `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。 */ export const CLAUDE_DESKTOP_ROLE_ROUTE_IDS = { sonnet: "claude-sonnet-4-6", opus: "claude-opus-4-8", + fable: "claude-fable-5", haiku: "claude-haiku-4-5", } as const; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9afe4c699..8481814cb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -194,6 +194,7 @@ "routeModelLabel": "Model role", "routeRoleSonnet": "Sonnet", "routeRoleOpus": "Opus", + "routeRoleFable": "Fable", "routeRoleHaiku": "Haiku", "labelOverrideLabel": "Menu display name", "upstreamModelLabel": "Requested model", @@ -1093,6 +1094,7 @@ "modelRoleLabel": "Model role", "modelRoleSonnet": "Sonnet", "modelRoleOpus": "Opus", + "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", "modelDisplayNameLabel": "Display name", "modelDisplayNamePlaceholder": "e.g. DeepSeek V4 Pro", @@ -1100,7 +1102,7 @@ "modelOneMHeader": "Declare 1M", "modelOneMLabel": "1M", "fallbackModelLabel": "Default fallback model", - "fallbackModelHint": "Used only when a Claude Code request does not clearly map to Sonnet, Opus, or Haiku. Usually safe to leave blank.", + "fallbackModelHint": "A fallback for requests that don't clearly map to Sonnet, Opus, Fable, or Haiku. Recommended for third-party/relay endpoints—otherwise such requests (including Haiku background subtasks) are forwarded under their original Claude model name and may fail if the upstream doesn't host it. Safe to leave blank for official endpoints.", "quickSetModels": "Quick Set", "quickSetSuccess": "Model name applied to all roles", "advancedOptionsToggle": "Advanced Options", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cebf6bf25..48eceb5e3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -194,6 +194,7 @@ "routeModelLabel": "モデル役割", "routeRoleSonnet": "Sonnet", "routeRoleOpus": "Opus", + "routeRoleFable": "Fable", "routeRoleHaiku": "Haiku", "labelOverrideLabel": "メニュー表示名", "upstreamModelLabel": "リクエストモデル", @@ -1093,6 +1094,7 @@ "modelRoleLabel": "モデル役割", "modelRoleSonnet": "Sonnet", "modelRoleOpus": "Opus", + "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", "modelDisplayNameLabel": "表示名", "modelDisplayNamePlaceholder": "例: DeepSeek V4 Pro", @@ -1100,7 +1102,7 @@ "modelOneMHeader": "1M 対応を宣言", "modelOneMLabel": "1M", "fallbackModelLabel": "既定フォールバックモデル", - "fallbackModelHint": "Claude Code のリクエストが Sonnet、Opus、Haiku のいずれにも明確に対応しない場合のみ使われます。通常は空欄で構いません。", + "fallbackModelHint": "Sonnet・Opus・Fable・Haiku のいずれにも明確に対応しないリクエストのフォールバックです。サードパーティ/中継エンドポイントでは設定を推奨します。設定しないと、これらのリクエスト(Haiku のバックグラウンドサブタスクを含む)が元の Claude モデル名のまま上流へ転送され、上流に該当モデルが無い場合は失敗することがあります。公式エンドポイントでは空欄で構いません。", "quickSetModels": "一括設定", "quickSetSuccess": "モデル名をすべての役割に適用しました", "advancedOptionsToggle": "高級オプション", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 66caee2a7..957b442a2 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -194,6 +194,7 @@ "routeModelLabel": "模型角色", "routeRoleSonnet": "Sonnet", "routeRoleOpus": "Opus", + "routeRoleFable": "Fable", "routeRoleHaiku": "Haiku", "labelOverrideLabel": "選單顯示名稱", "upstreamModelLabel": "實際請求模型", @@ -1065,6 +1066,7 @@ "modelRoleLabel": "模型角色", "modelRoleSonnet": "Sonnet", "modelRoleOpus": "Opus", + "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", "modelDisplayNameLabel": "顯示名稱", "modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro", @@ -1072,7 +1074,7 @@ "modelOneMHeader": "聲明支援 1M", "modelOneMLabel": "1M", "fallbackModelLabel": "備用模型", - "fallbackModelHint": "僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。", + "fallbackModelHint": "用於未明確落到 Sonnet、Opus、Fable、Haiku 角色的請求。使用第三方/中轉端點時建議填寫:否則這些請求(含 Haiku 背景子任務)會以原始 Claude 模型名透傳給上游,可能因上游無此模型而報錯。官方端點可留空。", "quickSetModels": "一鍵設定", "quickSetSuccess": "已將模型名稱套用至所有角色", "advancedOptionsToggle": "進階選項", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7f7689f78..3fdf71975 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -194,6 +194,7 @@ "routeModelLabel": "模型角色", "routeRoleSonnet": "Sonnet", "routeRoleOpus": "Opus", + "routeRoleFable": "Fable", "routeRoleHaiku": "Haiku", "labelOverrideLabel": "菜单显示名", "upstreamModelLabel": "实际请求模型", @@ -1093,6 +1094,7 @@ "modelRoleLabel": "模型角色", "modelRoleSonnet": "Sonnet", "modelRoleOpus": "Opus", + "modelRoleFable": "Fable", "modelRoleHaiku": "Haiku", "modelDisplayNameLabel": "显示名称", "modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro", @@ -1100,7 +1102,7 @@ "modelOneMHeader": "声明支持 1M", "modelOneMLabel": "1M", "fallbackModelLabel": "默认兜底模型", - "fallbackModelHint": "仅在 Claude Code 请求没有明确落到 Sonnet、Opus 或 Haiku 角色时使用;通常可以留空。", + "fallbackModelHint": "用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。", "quickSetModels": "一键设置", "quickSetSuccess": "已将模型名称应用到所有角色", "advancedOptionsToggle": "高级选项", diff --git a/tests/components/ClaudeDesktopProviderForm.test.tsx b/tests/components/ClaudeDesktopProviderForm.test.tsx index 43427b002..c8b118516 100644 --- a/tests/components/ClaudeDesktopProviderForm.test.tsx +++ b/tests/components/ClaudeDesktopProviderForm.test.tsx @@ -97,7 +97,7 @@ describe("ClaudeDesktopProviderForm", () => { expect(document.activeElement).toBe(currentInput); }); - it("代理模式始终渲染 Sonnet / Opus / Haiku 三档(即使只配了一档)", () => { + it("代理模式始终渲染 Sonnet / Opus / Fable / Haiku 四档(即使只配了一档)", () => { renderForm({ name: "Proxy Provider", settingsConfig: { @@ -114,13 +114,13 @@ describe("ClaudeDesktopProviderForm", () => { }, }); - // 固定三档:每档各一个「菜单显示名」输入框,无论初始只配了几档。 - expect(screen.getAllByPlaceholderText("DeepSeek V4 Pro")).toHaveLength(3); + // 固定四档:每档各一个「菜单显示名」输入框,无论初始只配了几档。 + expect(screen.getAllByPlaceholderText("DeepSeek V4 Pro")).toHaveLength(4); }); - it("代理模式初始无路由且默认路由未就绪时不渲染空三档", () => { + it("代理模式初始无路由且默认路由未就绪时不渲染空四档", () => { // mock 的 getClaudeDesktopDefaultRoutes 返回 [],模拟默认路由尚未就绪。 - // 修复前:normalizeProxyRows([]) 会渲染 3 条空行并把 routes.length 撑到 3, + // 修复前:normalizeProxyRows([]) 会渲染空行并把 routes.length 撑起来, // 永久挡住 seed effect 的默认路由回填。修复后应保持空、等待 seed。 renderForm({ name: "Proxy Provider", @@ -139,7 +139,7 @@ describe("ClaudeDesktopProviderForm", () => { expect(screen.queryAllByPlaceholderText("DeepSeek V4 Pro")).toHaveLength(0); }); - it("保存模型映射时补齐固定三档并把留空档回填为 Sonnet 模型", async () => { + it("保存模型映射时补齐固定四档并把留空档回填为 Sonnet 模型", async () => { const onSubmit = vi.fn(); renderForm( { @@ -166,19 +166,25 @@ describe("ClaudeDesktopProviderForm", () => { await waitFor(() => expect(onSubmit).toHaveBeenCalled()); const submitted = onSubmit.mock.calls[0][0]; - // claude-old 迁移到 Sonnet;留空的 Opus / Haiku 回填为 Sonnet 的上游模型, - // 保证落库三档齐全,子 agent 调用的 Haiku 始终可解析。 + // claude-old 迁移到 Sonnet;留空的 Opus / Fable / Haiku 回填为 Sonnet 的 + // 上游模型,保证落库四档齐全,子 agent 调用的各档始终可解析。 expect(submitted.meta.claudeDesktopModelRoutes).toMatchObject({ "claude-sonnet-4-6": { model: "upstream-old", labelOverride: "upstream-old", }, "claude-opus-4-8": { model: "upstream-old" }, + "claude-fable-5": { model: "upstream-old" }, "claude-haiku-4-5": { model: "upstream-old" }, }); expect( Object.keys(submitted.meta.claudeDesktopModelRoutes).sort(), - ).toEqual(["claude-haiku-4-5", "claude-opus-4-8", "claude-sonnet-4-6"]); + ).toEqual([ + "claude-fable-5", + "claude-haiku-4-5", + "claude-opus-4-8", + "claude-sonnet-4-6", + ]); }); it("回填空档时继承 Sonnet 的 1M 声明", async () => { diff --git a/tests/components/ClaudeFormFields.test.tsx b/tests/components/ClaudeFormFields.test.tsx index 336339260..260462ba7 100644 --- a/tests/components/ClaudeFormFields.test.tsx +++ b/tests/components/ClaudeFormFields.test.tsx @@ -83,6 +83,8 @@ const renderCopilotForm = (overrides: Partial = {}) => { defaultSonnetModelName: "Claude Sonnet", defaultOpusModel: "", defaultOpusModelName: "", + defaultFableModel: "", + defaultFableModelName: "", onModelChange: vi.fn(), speedTestEndpoints: [], apiFormat: "anthropic",