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:
Jason
2026-04-24 22:58:58 +08:00
parent c002688a84
commit fcd83ee30d
4 changed files with 427 additions and 2 deletions
+50
View File
@@ -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");