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
+67 -1
View File
@@ -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 参数不应影响模型映射