mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,10 @@ async fn send_raw_request(
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
tls_stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Flush failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(tls_stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
@@ -368,6 +372,10 @@ async fn send_raw_request(
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Flush failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
@@ -441,6 +449,10 @@ async fn connect_via_proxy(
|
||||
.write_all(connect_req.as_bytes())
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT write failed: {e}")))?;
|
||||
stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT flush failed: {e}")))?;
|
||||
|
||||
// Read the proxy's response status line
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
|
||||
@@ -348,6 +348,8 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
)]
|
||||
}
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
@@ -373,9 +375,30 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderName::from_static("x-github-api-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_API_VERSION),
|
||||
),
|
||||
// 26-04-01新增的copilot关键 headers
|
||||
(
|
||||
HeaderName::from_static("openai-intent"),
|
||||
HeaderValue::from_static("conversation-panel"),
|
||||
HeaderValue::from_static("conversation-agent"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-initiator"),
|
||||
HeaderValue::from_static("user"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-interaction-type"),
|
||||
HeaderValue::from_static("conversation-agent"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-vscode-user-agent-library-version"),
|
||||
HeaderValue::from_static("electron-fetch"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-request-id"),
|
||||
HeaderValue::from_str(&request_id).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-agent-task-id"),
|
||||
HeaderValue::from_str(&request_id).unwrap(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -46,15 +46,18 @@ const TOKEN_REFRESH_BUFFER_SECONDS: i64 = 60;
|
||||
const COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
|
||||
|
||||
/// Copilot API Header 常量
|
||||
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.96.0";
|
||||
pub const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
|
||||
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
|
||||
pub const COPILOT_API_VERSION: &str = "2025-04-01";
|
||||
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.110.1";
|
||||
pub const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.38.2";
|
||||
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.38.2";
|
||||
pub const COPILOT_API_VERSION: &str = "2025-10-01";
|
||||
pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
|
||||
|
||||
/// Copilot 使用量 API URL
|
||||
const COPILOT_USAGE_URL: &str = "https://api.github.com/copilot_internal/user";
|
||||
|
||||
/// 默认 Copilot API 端点
|
||||
const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com";
|
||||
|
||||
/// Copilot 使用量响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CopilotUsageResponse {
|
||||
@@ -64,6 +67,19 @@ pub struct CopilotUsageResponse {
|
||||
pub quota_reset_date: String,
|
||||
/// 配额快照
|
||||
pub quota_snapshots: QuotaSnapshots,
|
||||
/// API 端点信息 (用于动态获取 API URL)
|
||||
#[serde(default)]
|
||||
pub endpoints: Option<CopilotEndpoints>,
|
||||
}
|
||||
|
||||
/// Copilot API 端点信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CopilotEndpoints {
|
||||
/// API 端点 URL
|
||||
pub api: String,
|
||||
/// Telemetry 端点 URL
|
||||
#[serde(default)]
|
||||
pub telemetry: Option<String>,
|
||||
}
|
||||
|
||||
/// 配额快照
|
||||
@@ -312,6 +328,10 @@ pub struct CopilotAuthManager {
|
||||
copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,
|
||||
/// Copilot Models 缓存(key = GitHub user ID,仅进程内复用)
|
||||
copilot_models: Arc<RwLock<HashMap<String, Vec<CopilotModel>>>>,
|
||||
/// Copilot API 端点缓存(key = GitHub user ID,从 /copilot_internal/user 获取)
|
||||
api_endpoints: Arc<RwLock<HashMap<String, String>>>,
|
||||
/// 每个账号的端点拉取锁,避免并发拉取重复打 GitHub API
|
||||
endpoint_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||
/// HTTP 客户端
|
||||
http_client: Client,
|
||||
/// 存储路径
|
||||
@@ -333,6 +353,8 @@ impl CopilotAuthManager {
|
||||
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
||||
api_endpoints: Arc::new(RwLock::new(HashMap::new())),
|
||||
endpoint_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_client: Client::new(),
|
||||
storage_path,
|
||||
pending_migration: Arc::new(RwLock::new(None)),
|
||||
@@ -386,6 +408,15 @@ impl CopilotAuthManager {
|
||||
let mut refresh_locks = self.refresh_locks.write().await;
|
||||
refresh_locks.remove(account_id);
|
||||
}
|
||||
// 清理 API 端点缓存
|
||||
{
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.remove(account_id);
|
||||
}
|
||||
{
|
||||
let mut endpoint_locks = self.endpoint_locks.write().await;
|
||||
endpoint_locks.remove(account_id);
|
||||
}
|
||||
|
||||
{
|
||||
let accounts = self.accounts.read().await;
|
||||
@@ -775,6 +806,14 @@ impl CopilotAuthManager {
|
||||
.await
|
||||
.map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;
|
||||
|
||||
// 存储动态 API 端点(如果有)
|
||||
if let Some(ref endpoints) = usage.endpoints {
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.insert(account_id.to_string(), endpoints.api.clone());
|
||||
// 使用 debug 级别避免在日志中暴露企业内部域名
|
||||
log::debug!("[CopilotAuth] 账号 {account_id} 已保存动态 API 端点");
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[CopilotAuth] 获取使用量成功,计划: {}, 重置日期: {}",
|
||||
usage.copilot_plan,
|
||||
@@ -794,6 +833,118 @@ impl CopilotAuthManager {
|
||||
|
||||
// ==================== 状态查询 ====================
|
||||
|
||||
/// 获取指定账号的 API 端点(缓存命中直接返回,未命中则从 API 惰性拉取)
|
||||
pub async fn get_api_endpoint(&self, account_id: &str) -> String {
|
||||
let _ = self.ensure_migration_complete().await;
|
||||
|
||||
{
|
||||
let endpoints = self.api_endpoints.read().await;
|
||||
if let Some(endpoint) = endpoints.get(account_id) {
|
||||
return endpoint.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// 用锁串行化同一账号的并发拉取,避免对 GitHub API 的重复请求
|
||||
let lock = self.get_endpoint_lock(account_id).await;
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
// 持锁后二次检查:可能已由其他请求填充
|
||||
{
|
||||
let endpoints = self.api_endpoints.read().await;
|
||||
if let Some(endpoint) = endpoints.get(account_id) {
|
||||
return endpoint.clone();
|
||||
}
|
||||
}
|
||||
|
||||
match self.fetch_and_cache_endpoint(account_id).await {
|
||||
Ok(endpoint) => endpoint,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"[CopilotAuth] 获取账号 {account_id} 动态 API 端点失败: {e},使用默认值"
|
||||
);
|
||||
DEFAULT_COPILOT_API_ENDPOINT.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取默认账号的 API 端点
|
||||
pub async fn get_default_api_endpoint(&self) -> String {
|
||||
let _ = self.ensure_migration_complete().await;
|
||||
|
||||
match self.resolve_default_account_id().await {
|
||||
Some(id) => self.get_api_endpoint(&id).await,
|
||||
None => DEFAULT_COPILOT_API_ENDPOINT.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_and_cache_endpoint(&self, account_id: &str) -> Result<String, CopilotAuthError> {
|
||||
let github_token = {
|
||||
let accounts = self.accounts.read().await;
|
||||
accounts
|
||||
.get(account_id)
|
||||
.map(|a| a.github_token.clone())
|
||||
.ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?
|
||||
};
|
||||
|
||||
log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点");
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(COPILOT_USAGE_URL)
|
||||
.header("Authorization", format!("token {github_token}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("editor-version", COPILOT_EDITOR_VERSION)
|
||||
.header("editor-plugin-version", COPILOT_PLUGIN_VERSION)
|
||||
.header("user-agent", COPILOT_USER_AGENT)
|
||||
.header("x-github-api-version", COPILOT_API_VERSION)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(CopilotAuthError::GitHubTokenInvalid);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(CopilotAuthError::CopilotTokenFetchFailed(format!(
|
||||
"获取 API 端点失败: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let usage: CopilotUsageResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CopilotAuthError::ParseError(e.to_string()))?;
|
||||
|
||||
let endpoint = match usage.endpoints {
|
||||
Some(endpoints) => endpoints.api.clone(),
|
||||
None => DEFAULT_COPILOT_API_ENDPOINT.to_string(),
|
||||
};
|
||||
|
||||
// 缓存端点(包括默认值),避免重复请求
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.insert(account_id.to_string(), endpoint.clone());
|
||||
log::debug!("[CopilotAuth] 账号 {account_id} 已缓存 API 端点");
|
||||
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
async fn get_endpoint_lock(&self, account_id: &str) -> Arc<Mutex<()>> {
|
||||
{
|
||||
let locks = self.endpoint_locks.read().await;
|
||||
if let Some(lock) = locks.get(account_id) {
|
||||
return Arc::clone(lock);
|
||||
}
|
||||
}
|
||||
|
||||
let mut locks = self.endpoint_locks.write().await;
|
||||
Arc::clone(
|
||||
locks
|
||||
.entry(account_id.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(()))),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取认证状态(支持多账号)
|
||||
pub async fn get_status(&self) -> CopilotAuthStatus {
|
||||
// 确保迁移完成
|
||||
@@ -838,6 +989,7 @@ impl CopilotAuthManager {
|
||||
pub async fn clear_auth(&self) -> Result<(), CopilotAuthError> {
|
||||
log::info!("[CopilotAuth] 清除所有认证");
|
||||
|
||||
// 先清理内存状态,确保即使文件删除失败用户也能看到已登出
|
||||
{
|
||||
let mut accounts = self.accounts.write().await;
|
||||
accounts.clear();
|
||||
@@ -851,12 +1003,25 @@ impl CopilotAuthManager {
|
||||
let mut tokens = self.copilot_tokens.write().await;
|
||||
tokens.clear();
|
||||
}
|
||||
{
|
||||
let mut models = self.copilot_models.write().await;
|
||||
models.clear();
|
||||
}
|
||||
{
|
||||
let mut refresh_locks = self.refresh_locks.write().await;
|
||||
refresh_locks.clear();
|
||||
}
|
||||
// 清理 API 端点缓存
|
||||
{
|
||||
let mut api_endpoints = self.api_endpoints.write().await;
|
||||
api_endpoints.clear();
|
||||
}
|
||||
{
|
||||
let mut endpoint_locks = self.endpoint_locks.write().await;
|
||||
endpoint_locks.clear();
|
||||
}
|
||||
|
||||
// 删除存储文件
|
||||
// 最后删除存储文件
|
||||
if self.storage_path.exists() {
|
||||
std::fs::remove_file(&self.storage_path)?;
|
||||
}
|
||||
@@ -1414,4 +1579,242 @@ mod tests {
|
||||
let default_vendor = manager.get_model_vendor("claude-sonnet-4").await.unwrap();
|
||||
assert_eq!(default_vendor.as_deref(), Some("Anthropic"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_returns_cached_value() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 手动设置 api_endpoints 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, "https://copilot-api.enterprise.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_returns_default_when_not_cached() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let endpoint = manager.get_api_endpoint("99999").await;
|
||||
assert_eq!(endpoint, "https://api.githubcopilot.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_default_api_endpoint_uses_default_account() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 设置默认账号
|
||||
{
|
||||
let mut default_account_id = manager.default_account_id.write().await;
|
||||
*default_account_id = Some("12345".to_string());
|
||||
}
|
||||
// 添加账号数据
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
// 设置 API endpoint 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.corp.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let endpoint = manager.get_default_api_endpoint().await;
|
||||
assert_eq!(endpoint, "https://copilot-api.corp.example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_account_clears_api_endpoint_cache() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 添加账号数据
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
// 设置 API endpoint 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// 确认缓存存在
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(api_endpoints.contains_key("12345"));
|
||||
}
|
||||
|
||||
// 移除账号
|
||||
manager.remove_account("12345").await.unwrap();
|
||||
|
||||
// 确认缓存已清理
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(!api_endpoints.contains_key("12345"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_auth_clears_all_api_endpoint_cache() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// 添加多个账号的 API endpoint 缓存
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise1.example.com".to_string(),
|
||||
);
|
||||
api_endpoints.insert(
|
||||
"67890".to_string(),
|
||||
"https://copilot-api.enterprise2.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// 确认缓存存在
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert_eq!(api_endpoints.len(), 2);
|
||||
}
|
||||
|
||||
// 清除所有认证
|
||||
manager.clear_auth().await.unwrap();
|
||||
|
||||
// 确认缓存已清空
|
||||
{
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(api_endpoints.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_auth_cleans_memory_even_when_file_removal_fails() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
// Create a directory at storage_path so remove_file fails
|
||||
std::fs::create_dir_all(&manager.storage_path).unwrap();
|
||||
|
||||
{
|
||||
let mut accounts = manager.accounts.write().await;
|
||||
accounts.insert(
|
||||
"12345".to_string(),
|
||||
GitHubAccountData {
|
||||
github_token: "gho_test".to_string(),
|
||||
user: GitHubUser {
|
||||
login: "alice".to_string(),
|
||||
id: 12345,
|
||||
avatar_url: None,
|
||||
},
|
||||
authenticated_at: 1700000000,
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut default_account_id = manager.default_account_id.write().await;
|
||||
*default_account_id = Some("12345".to_string());
|
||||
}
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert(
|
||||
"12345".to_string(),
|
||||
"https://copilot-api.enterprise.example.com".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let result = manager.clear_auth().await;
|
||||
// Should still return an error for the file deletion failure
|
||||
assert!(result.is_err());
|
||||
|
||||
// But memory state should already be cleaned
|
||||
let accounts = manager.accounts.read().await;
|
||||
assert!(accounts.is_empty());
|
||||
drop(accounts);
|
||||
|
||||
let default_account_id = manager.default_account_id.read().await;
|
||||
assert!(default_account_id.is_none());
|
||||
drop(default_account_id);
|
||||
|
||||
let api_endpoints = manager.api_endpoints.read().await;
|
||||
assert!(api_endpoints.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_cache_hit_skips_fetch() {
|
||||
// 缓存命中时应直接返回,不发起网络请求
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let enterprise_endpoint = "https://copilot-api.enterprise.example.com".to_string();
|
||||
{
|
||||
let mut api_endpoints = manager.api_endpoints.write().await;
|
||||
api_endpoints.insert("12345".to_string(), enterprise_endpoint.clone());
|
||||
}
|
||||
|
||||
// 即使没有账号数据,缓存命中也应直接返回
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, enterprise_endpoint);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_api_endpoint_returns_default_for_unknown_account() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let endpoint = manager.get_api_endpoint("12345").await;
|
||||
assert_eq!(endpoint, DEFAULT_COPILOT_API_ENDPOINT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_and_cache_endpoint_requires_account() {
|
||||
// 账号不存在时 fetch_and_cache_endpoint 应返回 AccountNotFound 错误
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let result = manager.fetch_and_cache_endpoint("nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
CopilotAuthError::AccountNotFound(id) => assert_eq!(id, "nonexistent"),
|
||||
other => panic!("期望 AccountNotFound 错误,实际: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user