mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +08:00
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:
@@ -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<V
|
||||
.or_else(|| {
|
||||
// 角色关键词回落:Claude Desktop 的部分调用(如子 agent)会请求带发布
|
||||
// 日期后缀的完整官方名(claude-haiku-4-5-20251001),与 manifest 暴露的
|
||||
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/sonnet 归类
|
||||
// 到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
|
||||
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/fable/sonnet
|
||||
// 归类到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
|
||||
// 匹配前已剥离本地 [1m] 标记;这里仍只对 Claude Desktop 认可的
|
||||
// 安全模型名回落,避免非 Claude route 被误映射。
|
||||
if !is_claude_safe_model_id(requested) {
|
||||
@@ -704,6 +719,18 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
routes
|
||||
.iter()
|
||||
.find(|route| claude_role_keyword(&route.route_id) == Some(role))
|
||||
// 老用户只配了 Sonnet/Opus/Haiku 三档时,fable 请求降级到 opus 档,
|
||||
// 与官方安全分类器的降级方向一致,避免 route_unknown 硬错误。
|
||||
// 用户一旦显式配置 fable 档,上面的精确角色匹配会优先命中。
|
||||
.or_else(|| {
|
||||
(role == "fable")
|
||||
.then(|| {
|
||||
routes
|
||||
.iter()
|
||||
.find(|route| claude_role_keyword(&route.route_id) == Some("opus"))
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
.map(|route| route.upstream_model.clone())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
@@ -754,15 +781,17 @@ fn is_compatible_opus_route_alias(route_id: &str, requested: &str) -> 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");
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct ModelMapping {
|
||||
pub haiku_model: Option<String>,
|
||||
pub sonnet_model: Option<String>,
|
||||
pub opus_model: Option<String>,
|
||||
pub fable_model: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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 参数不应影响模型映射
|
||||
|
||||
Reference in New Issue
Block a user