mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(copilot): resolve Claude model IDs against live /models list
Copilot upstream returns model_not_supported when the client sends dash-form Claude IDs (claude-sonnet-4-6, claude-sonnet-4-6[1m]) while /models only accepts dot form (claude-sonnet-4.6, -1m suffix). - Add copilot_model_map: syntax normalize (dash->dot, [1m]->-1m) plus live /models exact match and family-version fallback, reusing the existing 5 min auth cache. Returns None when the whole family is absent so upstream surfaces an explicit error instead of silently switching families. - Wire into forwarder Copilot hook; runs before anthropic_to_openai conversion. - Default Opus slot in the Copilot preset maps to Sonnet 4.6: Pro dropped all Opus on 2026-04-20 and Pro+ bills Opus 4.7 at 7.5x. Users who want real Opus can switch manually in the UI. Refs: https://github.com/farion1231/cc-switch/issues/2016
This commit is contained in:
@@ -790,6 +790,13 @@ impl RequestForwarder {
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
if is_copilot {
|
||||
mapped_body =
|
||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
|
||||
.await;
|
||||
}
|
||||
|
||||
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
|
||||
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
|
||||
//
|
||||
@@ -1485,6 +1492,49 @@ impl RequestForwarder {
|
||||
"openai_chat".to_string()
|
||||
}
|
||||
|
||||
/// 用 Copilot live `/models` 列表确认 model ID 真实可用,找不到时按 family 降级。
|
||||
/// 命中缓存后是同步的;首次请求或 5 min 缓存过期后会触发一次 HTTP。
|
||||
async fn apply_copilot_live_model_resolution(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
body: &mut serde_json::Value,
|
||||
) {
|
||||
let Some(model_id) = body.get("model").and_then(|v| v.as_str()) else {
|
||||
return;
|
||||
};
|
||||
let model_id = model_id.to_string();
|
||||
|
||||
let Some(app_handle) = &self.app_handle else {
|
||||
return;
|
||||
};
|
||||
let copilot_state = app_handle.state::<CopilotAuthState>();
|
||||
let copilot_auth = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.managed_account_id_for("github_copilot"));
|
||||
|
||||
let models_result = match account_id.as_deref() {
|
||||
Some(id) => copilot_auth.fetch_models_for_account(id).await,
|
||||
None => copilot_auth.fetch_models().await,
|
||||
};
|
||||
|
||||
let models = match models_result {
|
||||
Ok(m) => m,
|
||||
Err(err) => {
|
||||
log::debug!("[Copilot] live model list unavailable, skip resolution: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(resolved) =
|
||||
super::providers::copilot_model_map::resolve_against_models(&model_id, &models)
|
||||
{
|
||||
log::info!("[Copilot] live-model resolve: {model_id} → {resolved}");
|
||||
body["model"] = serde_json::Value::String(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
|
||||
let Some(app_handle) = &self.app_handle else {
|
||||
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! GitHub Copilot 模型 ID 归一化与 live-list 解析
|
||||
//!
|
||||
//! Copilot upstream 仅接受 dot 形式的 Claude 4.x 模型 ID(如 `claude-sonnet-4.6`),
|
||||
//! 而 Claude Code 客户端发出 dash 形式(如 `claude-sonnet-4-6`、`claude-sonnet-4-6[1m]`)。
|
||||
//! 不归一化会触发上游 400 `model_not_supported`。
|
||||
//!
|
||||
//! 仅做语法归一化不够:账号订阅级别可能不开放某个具体模型。
|
||||
//! `resolve_against_models` 用 `/models` live 列表做精确匹配,找不到时
|
||||
//! 按 family(haiku/sonnet/opus)+ 最高版本号 fallback。
|
||||
|
||||
use super::copilot_auth::CopilotModel;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 归一化客户端 model ID 为 Copilot upstream 接受的形式。
|
||||
/// 返回 `None` 表示无需变换(已归一化、非 Claude 4.x 系列、或空输入)。
|
||||
pub(super) fn normalize_to_copilot_id(client_id: &str) -> Option<String> {
|
||||
let trimmed = client_id.trim();
|
||||
let bytes = trimmed.as_bytes();
|
||||
|
||||
if bytes.len() < 8 || !bytes[..7].eq_ignore_ascii_case(b"claude-") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let has_one_m_bracket = ends_with_ascii_ci(bytes, b"[1m]");
|
||||
|
||||
// Fast path: 已含点 + 不带 [1m] → 已归一化(绝大多数请求走这里)
|
||||
if trimmed.contains('.') && !has_one_m_bracket {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (base, has_1m_suffix) = split_one_m_suffix(trimmed);
|
||||
let stripped = strip_trailing_date(base);
|
||||
let dotted = dashes_to_dot_in_last_version(stripped);
|
||||
|
||||
if dotted.is_none() && !has_1m_suffix {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut candidate = dotted.unwrap_or_else(|| stripped.to_string());
|
||||
if has_1m_suffix {
|
||||
candidate.push_str("-1m");
|
||||
}
|
||||
(candidate != trimmed).then_some(candidate)
|
||||
}
|
||||
|
||||
/// 在请求体中应用 model ID 归一化。
|
||||
pub fn apply_copilot_model_normalization(mut body: Value) -> Value {
|
||||
let Some(orig) = body.get("model").and_then(|v| v.as_str()) else {
|
||||
return body;
|
||||
};
|
||||
if let Some(normalized) = normalize_to_copilot_id(orig) {
|
||||
log::debug!("[CopilotNormalizer] {orig} → {normalized}");
|
||||
body["model"] = Value::String(normalized);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn ends_with_ascii_ci(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.len() >= needle.len()
|
||||
&& haystack[haystack.len() - needle.len()..].eq_ignore_ascii_case(needle)
|
||||
}
|
||||
|
||||
fn split_one_m_suffix(id: &str) -> (&str, bool) {
|
||||
let bytes = id.as_bytes();
|
||||
if ends_with_ascii_ci(bytes, b"[1m]") {
|
||||
return (&id[..bytes.len() - 4], true);
|
||||
}
|
||||
if ends_with_ascii_ci(bytes, b"-1m") {
|
||||
return (&id[..bytes.len() - 3], true);
|
||||
}
|
||||
(id, false)
|
||||
}
|
||||
|
||||
fn strip_trailing_date(id: &str) -> &str {
|
||||
let Some(last_dash) = id.rfind('-') else {
|
||||
return id;
|
||||
};
|
||||
let suffix = &id[last_dash + 1..];
|
||||
if suffix.len() == 8 && suffix.bytes().all(|b| b.is_ascii_digit()) {
|
||||
&id[..last_dash]
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 `…-X-Y`(X、Y 都是纯数字的末两段)变成 `…-X.Y`。
|
||||
/// 返回 `None` 表示模式不匹配(保守策略避免误伤 `claude-3-5-sonnet` 等历史 ID)。
|
||||
fn dashes_to_dot_in_last_version(id: &str) -> Option<String> {
|
||||
let last_dash = id.rfind('-')?;
|
||||
let last_segment = &id[last_dash + 1..];
|
||||
if last_segment.is_empty() || !last_segment.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let head = &id[..last_dash];
|
||||
let prev_dash = head.rfind('-')?;
|
||||
let prev_segment = &head[prev_dash + 1..];
|
||||
if prev_segment.is_empty() || !prev_segment.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{head}.{last_segment}"))
|
||||
}
|
||||
|
||||
/// 用 Copilot live 模型列表确认/降级 model ID。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 先做语法归一化(dash→dot、`[1m]`→`-1m`)
|
||||
/// 2. 在 `models` 中精确匹配;找到则使用归一化后的 ID
|
||||
/// 3. 找不到时按 family(haiku/sonnet/opus)取最高版本号 fallback
|
||||
/// (优先保留 `-1m` 标志;都没有则取 base 版)
|
||||
///
|
||||
/// 返回 `None` 表示无需变换或无可降级的 family 候选(保留原 ID 让上游决定,
|
||||
/// 让用户拿到明确的 `model_not_supported` 而非被静默替换)。
|
||||
pub fn resolve_against_models(client_id: &str, models: &[CopilotModel]) -> Option<String> {
|
||||
let normalized = normalize_to_copilot_id(client_id);
|
||||
let target = normalized.as_deref().unwrap_or(client_id);
|
||||
|
||||
if models.iter().any(|m| m.id.eq_ignore_ascii_case(target)) {
|
||||
return normalized.filter(|s| s != client_id);
|
||||
}
|
||||
|
||||
let fallback = family_fallback(target, models)?;
|
||||
if fallback.eq_ignore_ascii_case(client_id) {
|
||||
None
|
||||
} else {
|
||||
Some(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_family(id: &str) -> Option<&'static str> {
|
||||
let lower = id.to_ascii_lowercase();
|
||||
if lower.contains("haiku") {
|
||||
Some("haiku")
|
||||
} else if lower.contains("sonnet") {
|
||||
Some("sonnet")
|
||||
} else if lower.contains("opus") {
|
||||
Some("opus")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取 family 后第一段 `MAJOR.MINOR` 版本号。
|
||||
/// 例:`claude-sonnet-4.6` → (4, 6);`claude-sonnet-4.6-1m` → (4, 6)。
|
||||
fn extract_major_minor(id: &str) -> Option<(u32, u32)> {
|
||||
let lower = id.to_ascii_lowercase();
|
||||
let family = detect_family(&lower)?;
|
||||
let after = &lower[lower.find(family)? + family.len()..];
|
||||
let after = after.strip_prefix('-')?;
|
||||
let segment = after.split(['-', '[', ' ']).next()?;
|
||||
let mut parts = segment.split('.');
|
||||
let major: u32 = parts.next()?.parse().ok()?;
|
||||
let minor: u32 = parts.next().unwrap_or("0").parse().ok()?;
|
||||
Some((major, minor))
|
||||
}
|
||||
|
||||
fn family_fallback(target: &str, models: &[CopilotModel]) -> Option<String> {
|
||||
let family = detect_family(target)?;
|
||||
let want_1m = target.ends_with("-1m");
|
||||
|
||||
let pick_best = |require_1m: bool| -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let lower = m.id.to_ascii_lowercase();
|
||||
lower.contains(family) && lower.ends_with("-1m") == require_1m
|
||||
})
|
||||
.filter_map(|m| extract_major_minor(&m.id).map(|v| (m, v)))
|
||||
.max_by_key(|(_, v)| *v)
|
||||
.map(|(m, _)| m.id.clone())
|
||||
};
|
||||
|
||||
if want_1m {
|
||||
pick_best(true).or_else(|| pick_best(false))
|
||||
} else {
|
||||
pick_best(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn dashes_to_dot_basic() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4-6"),
|
||||
Some("claude-sonnet-4.6".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-opus-4-6"),
|
||||
Some("claude-opus-4.6".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-haiku-4-5"),
|
||||
Some("claude-haiku-4.5".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_m_bracket_to_dash() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4-6[1m]"),
|
||||
Some("claude-sonnet-4.6-1m".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-opus-4-6[1m]"),
|
||||
Some("claude-opus-4.6-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_m_bracket_on_already_dotted() {
|
||||
// claude-sonnet-4.6[1m] 走非 fast-path 分支(has_one_m_bracket=true),
|
||||
// 应被改写为 -1m 形式
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4.6[1m]"),
|
||||
Some("claude-sonnet-4.6-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_suffix_stripped() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-haiku-4-5-20251001"),
|
||||
Some("claude-haiku-4.5".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4-5-20250929"),
|
||||
Some("claude-sonnet-4.5".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_copilot_format_returns_none() {
|
||||
assert_eq!(normalize_to_copilot_id("claude-sonnet-4.6"), None);
|
||||
assert_eq!(normalize_to_copilot_id("claude-opus-4.6-1m"), None);
|
||||
assert_eq!(normalize_to_copilot_id("claude-haiku-4.5"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_claude_models_untouched() {
|
||||
assert_eq!(normalize_to_copilot_id("gpt-5"), None);
|
||||
assert_eq!(normalize_to_copilot_id("gpt-4o-mini"), None);
|
||||
assert_eq!(normalize_to_copilot_id("o3"), None);
|
||||
assert_eq!(normalize_to_copilot_id(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_three_part_versions_untouched() {
|
||||
assert_eq!(normalize_to_copilot_id("claude-3-5-sonnet"), None);
|
||||
assert_eq!(normalize_to_copilot_id("claude-3-5-sonnet-20241022"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_on_prefix_and_suffix() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("Claude-Sonnet-4-6"),
|
||||
Some("Claude-Sonnet-4.6".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4-6[1M]"),
|
||||
Some("claude-sonnet-4.6-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_one_m_with_date_combined() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-haiku-4-5-20251001[1m]"),
|
||||
Some("claude-haiku-4.5-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rewrites_body() {
|
||||
let body = json!({"model": "claude-sonnet-4-6", "max_tokens": 1024});
|
||||
let out = apply_copilot_model_normalization(body);
|
||||
assert_eq!(out["model"], "claude-sonnet-4.6");
|
||||
assert_eq!(out["max_tokens"], 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_no_change_when_already_normalized() {
|
||||
let body = json!({"model": "claude-sonnet-4.6"});
|
||||
let out = apply_copilot_model_normalization(body);
|
||||
assert_eq!(out["model"], "claude-sonnet-4.6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_handles_missing_model() {
|
||||
let body = json!({"messages": []});
|
||||
let out = apply_copilot_model_normalization(body);
|
||||
assert!(out.get("model").is_none());
|
||||
}
|
||||
|
||||
fn model(id: &str) -> CopilotModel {
|
||||
CopilotModel {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
vendor: "anthropic".to_string(),
|
||||
model_picker_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_exact_match_after_normalize() {
|
||||
let models = vec![
|
||||
model("claude-sonnet-4.6"),
|
||||
model("claude-opus-4.6"),
|
||||
model("claude-haiku-4.5"),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_against_models("claude-sonnet-4-6", &models),
|
||||
Some("claude-sonnet-4.6".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_already_valid() {
|
||||
let models = vec![model("claude-sonnet-4.6")];
|
||||
assert_eq!(resolve_against_models("claude-sonnet-4.6", &models), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_back_to_highest_family_version() {
|
||||
// 用户请求 opus 4.7 但 Copilot 账号只有 opus 4.6
|
||||
let models = vec![
|
||||
model("claude-opus-4.5"),
|
||||
model("claude-opus-4.6"),
|
||||
model("claude-sonnet-4.6"),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_against_models("claude-opus-4.7", &models),
|
||||
Some("claude-opus-4.6".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_1m_when_requested() {
|
||||
let models = vec![
|
||||
model("claude-sonnet-4.6"),
|
||||
model("claude-sonnet-4.6-1m"),
|
||||
model("claude-opus-4.6"),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_against_models("claude-sonnet-4-6[1m]", &models),
|
||||
Some("claude-sonnet-4.6-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_back_to_base_when_1m_unavailable() {
|
||||
// 账号没开 -1m 变体时降级到 base
|
||||
let models = vec![model("claude-sonnet-4.6")];
|
||||
assert_eq!(
|
||||
resolve_against_models("claude-sonnet-4-6[1m]", &models),
|
||||
Some("claude-sonnet-4.6".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_family_absent() {
|
||||
// 账号完全没有 opus 时不做强行替换,让上游报错
|
||||
let models = vec![model("claude-sonnet-4.6"), model("claude-haiku-4.5")];
|
||||
assert_eq!(resolve_against_models("claude-opus-4.6", &models), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_handles_non_claude_target() {
|
||||
let models = vec![model("claude-sonnet-4.6")];
|
||||
assert_eq!(resolve_against_models("gpt-5", &models), None);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ mod claude;
|
||||
mod codex;
|
||||
pub mod codex_oauth_auth;
|
||||
pub mod copilot_auth;
|
||||
pub mod copilot_model_map;
|
||||
mod gemini;
|
||||
pub(crate) mod gemini_schema;
|
||||
pub mod gemini_shadow;
|
||||
|
||||
@@ -809,10 +809,10 @@ export const providerPresets: ProviderPreset[] = [
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.githubcopilot.com",
|
||||
ANTHROPIC_MODEL: "claude-opus-4.7",
|
||||
ANTHROPIC_MODEL: "claude-sonnet-4.6",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "claude-haiku-4.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-4.7",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-sonnet-4.6",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
|
||||
Reference in New Issue
Block a user