fix(copilot): 修复 GitHub Copilot 认证和代理问题 (#1854)

* fix(copilot): 修复 GitHub Copilot 400 认证错误

问题:使用 GitHub Copilot provider 时报错 400 bad request

根因:与 copilot-api 项目对比发现多处差异

修复内容:
- 更新版本号 0.26.7 到 0.38.2
- 更新 API 版本 2025-04-01 到 2025-10-01
- 添加缺失的关键 headers
- 修正 openai-intent 值
- 添加动态 API endpoint 支持
- 同步更新 stream_check.rs headers

Closes #1777

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: flush stream after write_all in hyper_client proxy

Add explicit flush() calls after write_all() for TLS stream, plain TCP
stream, and CONNECT tunnel requests to ensure buffered data is sent
immediately, preventing connection hangs in Copilot auth header flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 修复登录时的剪切板在mac与linux端可能没复制验证码

* fix: flush stream after write_all in hyper_client proxy

Add explicit flush() calls after write_all() for TLS stream, plain TCP
stream, and CONNECT tunnel requests to ensure buffered data is sent
immediately, preventing connection hangs in Copilot auth header flow.

* 修复登录时的剪切板在mac与linux端可能没复制验证码

* 1、修复不同类型的个人商业等不同类型的copilot账号问题
2、将验证码复制改为异步操作

* fix: address PR review comments for Copilot auth                                                      │
│                                                                                                                      │
│ - Fix clipboard blocking by using spawn_blocking for arboard ops                                                     │
│ - Implement dynamic endpoint routing for enterprise Copilot users                                                    │
│ - Add api_endpoints cache cleanup in remove_account() and clear_auth()                                               │
│ - Change API endpoint log level from info to debug                                                                   │
│ - Fix clear_auth() to continue cleanup even if file deletion fails                                                   │
│ - Add 9 unit tests for Copilot detection and api_endpoints cachin

* style: fix cargo fmt formatting

* Fix Copilot dynamic endpoint handling

* fix: restore clear_auth() memory-first cleanup order and fix cache leaks

- Restore clear_auth() to clean memory state before deleting the storage
  file. The previous order (file deletion first) caused a regression where
  users could get stuck in a "cannot log out" state if file removal failed.

- Add missing copilot_models.clear() in clear_auth() — this cache was
  cleaned in remove_account() but never in the full clear path.

- Add endpoint_locks cleanup in both remove_account() and clear_auth()
  to prevent minor in-process memory leaks.

- Update test to assert the correct behavior: memory should be cleaned
  even when file deletion fails.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: 周梦泽 <mengze.zhou@dafeng-tech.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Zhou Mengze
2026-04-04 22:52:23 +08:00
committed by GitHub
parent e4fe2763cd
commit de49f6fbbe
13 changed files with 907 additions and 25 deletions
+140 -1
View File
@@ -747,7 +747,7 @@ impl RequestForwarder {
adapter: &dyn ProviderAdapter,
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
// 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?;
let mut base_url = adapter.extract_base_url(provider)?;
let is_full_url = provider
.meta
@@ -770,6 +770,36 @@ impl RequestForwarder {
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
// GitHub Copilot 动态 endpoint 路由
// 从 CopilotAuthManager 获取缓存的 API endpoint(支持企业版等非默认 endpoint)
if is_copilot && !is_full_url {
if let Some(app_handle) = &self.app_handle {
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth = copilot_state.0.read().await;
// 从 provider.meta 获取关联的 GitHub 账号 ID
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
let dynamic_endpoint = match &account_id {
Some(id) => copilot_auth.get_api_endpoint(id).await,
None => copilot_auth.get_default_api_endpoint().await,
};
// 只在动态 endpoint 与当前 base_url 不同时替换
if dynamic_endpoint != base_url {
log::debug!(
"[Copilot] 使用动态 API endpoint: {} (原: {})",
dynamic_endpoint,
base_url
);
base_url = dynamic_endpoint;
}
}
}
let resolved_claude_api_format = if adapter.name() == "Claude" {
Some(
self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
@@ -893,6 +923,12 @@ impl RequestForwarder {
"copilot-integration-id",
"x-github-api-version",
"openai-intent",
// 新增 headers
"x-initiator",
"x-interaction-type",
"x-vscode-user-agent-library-version",
"x-request-id",
"x-agent-task-id",
]
} else {
&[]
@@ -1663,4 +1699,107 @@ mod tests {
&headers
));
}
// ==================== Copilot 动态 endpoint 路由相关测试 ====================
/// 验证 is_copilot 检测逻辑:通过 provider_type 判断
#[test]
fn copilot_detection_via_provider_type() {
use crate::provider::{Provider, ProviderMeta};
let provider = Provider {
id: "test".to_string(),
name: "Test Copilot".to_string(),
settings_config: serde_json::json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
};
let is_copilot = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot");
assert!(is_copilot, "应该通过 provider_type 检测为 Copilot");
}
/// 验证 is_copilot 检测逻辑:通过 base_url 判断
#[test]
fn copilot_detection_via_base_url() {
let base_url = "https://api.githubcopilot.com";
let is_copilot = base_url.contains("githubcopilot.com");
assert!(is_copilot, "应该通过 base_url 检测为 Copilot");
let non_copilot_url = "https://api.anthropic.com";
let is_not_copilot = non_copilot_url.contains("githubcopilot.com");
assert!(!is_not_copilot, "非 Copilot URL 不应被检测为 Copilot");
}
/// 验证企业版 endpoint(不包含 githubcopilot.com)场景下 is_copilot 仍然正确
#[test]
fn copilot_detection_for_enterprise_endpoint() {
use crate::provider::{Provider, ProviderMeta};
// 企业版场景:provider_type 是 github_copilot,但 base_url 可能是企业内部域名
let provider = Provider {
id: "enterprise".to_string(),
name: "Enterprise Copilot".to_string(),
settings_config: serde_json::json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
};
let enterprise_base_url = "https://copilot-api.corp.example.com";
// is_copilot 应该通过 provider_type 检测成功,即使 base_url 不包含 githubcopilot.com
let is_copilot = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot")
|| enterprise_base_url.contains("githubcopilot.com");
assert!(
is_copilot,
"企业版 Copilot 应该通过 provider_type 被正确检测"
);
}
/// 验证动态 endpoint 替换条件
#[test]
fn dynamic_endpoint_replacement_conditions() {
// 条件:is_copilot && !is_full_url
let test_cases = [
(true, false, true, "Copilot + 非 full_url 应该替换"),
(true, true, false, "Copilot + full_url 不应替换"),
(false, false, false, "非 Copilot 不应替换"),
(false, true, false, "非 Copilot + full_url 不应替换"),
];
for (is_copilot, is_full_url, should_replace, desc) in test_cases {
let will_replace = is_copilot && !is_full_url;
assert_eq!(will_replace, should_replace, "{desc}");
}
}
}