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
+16
View File
@@ -34,6 +34,22 @@ pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String>
Ok(true)
}
#[tauri::command]
pub async fn copy_text_to_clipboard(text: String) -> Result<bool, String> {
// Use spawn_blocking to avoid blocking the async runtime
// Clipboard access can block on some platforms and may have thread/loop constraints
tokio::task::spawn_blocking(move || {
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("访问系统剪贴板失败: {e}"))?;
clipboard
.set_text(text)
.map_err(|e| format!("写入系统剪贴板失败: {e}"))?;
Ok(true)
})
.await
.map_err(|e| format!("剪贴板任务执行失败: {e}"))?
}
/// 检查更新
#[tauri::command]
pub async fn check_for_updates(handle: AppHandle) -> Result<bool, String> {
+127 -12
View File
@@ -26,6 +26,7 @@ pub async fn stream_check_provider(
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?;
let claude_api_format_override = resolve_claude_api_format_override(
&app_type,
provider,
@@ -39,6 +40,7 @@ pub async fn stream_check_provider(
provider,
&config,
auth_override,
base_url_override,
claude_api_format_override,
)
.await?;
@@ -87,6 +89,8 @@ pub async fn stream_check_all_providers(
}
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
let base_url_override =
resolve_copilot_base_url_override(&provider, &copilot_state).await?;
let claude_api_format_override = resolve_claude_api_format_override(
&app_type,
&provider,
@@ -108,6 +112,7 @@ pub async fn stream_check_all_providers(
&provider,
&config,
auth_override,
base_url_override,
claude_api_format_override,
)
.await
@@ -151,17 +156,7 @@ async fn resolve_copilot_auth_override(
provider: &crate::provider::Provider,
copilot_state: &State<'_, CopilotAuthState>,
) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {
let is_copilot = provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref())
== Some("github_copilot")
|| provider
.settings_config
.pointer("/env/ANTHROPIC_BASE_URL")
.and_then(|value| value.as_str())
.map(|url| url.contains("githubcopilot.com"))
.unwrap_or(false);
let is_copilot = is_copilot_provider(provider);
if !is_copilot {
return Ok(None);
@@ -171,7 +166,7 @@ async fn resolve_copilot_auth_override(
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.github_account_id.clone());
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
let token = match account_id.as_deref() {
Some(id) => auth_manager
@@ -190,6 +185,49 @@ async fn resolve_copilot_auth_override(
)))
}
async fn resolve_copilot_base_url_override(
provider: &crate::provider::Provider,
copilot_state: &State<'_, CopilotAuthState>,
) -> Result<Option<String>, AppError> {
let is_copilot = is_copilot_provider(provider);
let is_full_url = provider
.meta
.as_ref()
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);
if !is_copilot || is_full_url {
return Ok(None);
}
let auth_manager = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
let endpoint = match account_id.as_deref() {
Some(id) => auth_manager.get_api_endpoint(id).await,
None => auth_manager.get_default_api_endpoint().await,
};
Ok(Some(endpoint))
}
fn is_copilot_provider(provider: &crate::provider::Provider) -> bool {
provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref())
== Some("github_copilot")
|| provider
.settings_config
.pointer("/env/ANTHROPIC_BASE_URL")
.and_then(|value| value.as_str())
.map(|url| url.contains("githubcopilot.com"))
.unwrap_or(false)
}
async fn resolve_claude_api_format_override(
app_type: &AppType,
provider: &crate::provider::Provider,
@@ -237,3 +275,80 @@ async fn resolve_claude_api_format_override(
Ok(Some(api_format.to_string()))
}
#[cfg(test)]
mod tests {
use super::is_copilot_provider;
use crate::provider::{Provider, ProviderMeta};
use serde_json::json;
#[test]
fn copilot_provider_detection_accepts_provider_type_or_base_url() {
let typed_provider = Provider {
id: "p1".to_string(),
name: "typed".to_string(),
settings_config: 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,
};
assert!(is_copilot_provider(&typed_provider));
let url_provider = Provider {
id: "p2".to_string(),
name: "url".to_string(),
settings_config: json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
};
assert!(is_copilot_provider(&url_provider));
}
#[test]
fn copilot_full_url_metadata_is_available_for_override_guard() {
let provider = Provider {
id: "p3".to_string(),
name: "relay".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
is_full_url: Some(true),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
};
assert!(is_copilot_provider(&provider));
assert_eq!(
provider.meta.as_ref().and_then(|meta| meta.is_full_url),
Some(true)
);
}
}