diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index e019a323b..c4968d776 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -67,7 +67,30 @@ impl Provider { } pub fn is_codex_oauth(&self) -> bool { - self.meta.as_ref().and_then(|m| m.provider_type.as_deref()) == Some("codex_oauth") + self.provider_type() == Some("codex_oauth") + } + + pub fn is_github_copilot(&self) -> bool { + self.provider_type() == Some("github_copilot") + || self.claude_base_url_contains("githubcopilot.com") + } + + pub fn uses_managed_account_auth(&self) -> bool { + self.is_github_copilot() + || self.is_codex_oauth() + || self.claude_base_url_contains("chatgpt.com/backend-api/codex") + } + + fn provider_type(&self) -> Option<&str> { + self.meta.as_ref().and_then(|m| m.provider_type.as_deref()) + } + + fn claude_base_url_contains(&self, needle: &str) -> bool { + self.settings_config + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(|value| value.as_str()) + .map(|base_url| base_url.contains(needle)) + .unwrap_or(false) } pub fn codex_fast_mode_enabled(&self) -> bool { @@ -810,6 +833,53 @@ mod tests { assert!(!provider.in_failover_queue); } + #[test] + fn provider_managed_account_auth_detection_uses_type_or_known_endpoint() { + let mut copilot = Provider::with_id( + "copilot".to_string(), + "Copilot".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com" + } + }), + None, + ); + assert!(copilot.is_github_copilot()); + assert!(copilot.uses_managed_account_auth()); + + let mut codex = Provider::with_id( + "codex".to_string(), + "Codex".to_string(), + json!({ "env": {} }), + None, + ); + codex.meta = Some(ProviderMeta { + provider_type: Some("codex_oauth".to_string()), + ..Default::default() + }); + assert!(codex.is_codex_oauth()); + assert!(codex.uses_managed_account_auth()); + + let codex_endpoint = Provider::with_id( + "codex-endpoint".to_string(), + "Codex Endpoint".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex" + } + }), + None, + ); + assert!(codex_endpoint.uses_managed_account_auth()); + + copilot.meta = Some(ProviderMeta { + provider_type: Some("github_copilot".to_string()), + ..Default::default() + }); + assert!(copilot.is_github_copilot()); + } + #[test] fn provider_manager_get_all_providers_returns_map() { let mut manager = ProviderManager::default(); diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 3c28637e8..d9dabfd76 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -32,6 +32,8 @@ use std::sync::Arc; use tauri::Manager; use tokio::sync::RwLock; +const PROXY_AUTH_PLACEHOLDER: &str = "PROXY_MANAGED"; + pub struct ForwardResult { pub response: ProxyResponse, pub provider: Provider, @@ -1562,6 +1564,8 @@ impl RequestForwarder { ); } + reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?; + // 输出请求信息日志 let tag = adapter.name(); let request_model = filtered_body @@ -2153,6 +2157,43 @@ fn build_codex_oauth_session_headers( headers } +fn reject_proxy_placeholder_for_managed_account_upstream( + url: &str, + headers: &http::HeaderMap, +) -> Result<(), ProxyError> { + if !is_managed_account_upstream_url(url) || !headers_contain_proxy_placeholder(headers) { + return Ok(()); + } + + Err(ProxyError::AuthError( + "Managed account proxy auth was not resolved; PROXY_MANAGED must not be sent upstream" + .to_string(), + )) +} + +fn is_managed_account_upstream_url(url: &str) -> bool { + let Ok(uri) = url.parse::() else { + return false; + }; + + let Some(host) = uri.host().map(str::to_ascii_lowercase) else { + return false; + }; + + host == "githubcopilot.com" + || host.ends_with(".githubcopilot.com") + || (host == "chatgpt.com" && uri.path().starts_with("/backend-api/codex")) +} + +fn headers_contain_proxy_placeholder(headers: &http::HeaderMap) -> bool { + headers.values().any(|value| { + value + .to_str() + .map(|value| value.contains(PROXY_AUTH_PLACEHOLDER)) + .unwrap_or(false) + }) +} + fn should_preserve_exact_header_case( adapter_name: &str, provider: &Provider, @@ -2595,6 +2636,61 @@ mod tests { ); } + #[test] + fn managed_account_upstream_rejects_proxy_managed_placeholder_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer PROXY_MANAGED"), + ); + + let err = reject_proxy_placeholder_for_managed_account_upstream( + "https://api.githubcopilot.com/chat/completions", + &headers, + ) + .expect_err("placeholder should be rejected before upstream"); + + assert!(matches!( + err, + ProxyError::AuthError(message) if message.contains("PROXY_MANAGED") + )); + } + + #[test] + fn codex_oauth_upstream_rejects_proxy_managed_placeholder_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer PROXY_MANAGED"), + ); + + let err = reject_proxy_placeholder_for_managed_account_upstream( + "https://chatgpt.com/backend-api/codex/responses", + &headers, + ) + .expect_err("placeholder should be rejected before upstream"); + + assert!(matches!( + err, + ProxyError::AuthError(message) if message.contains("PROXY_MANAGED") + )); + } + + #[test] + fn non_managed_upstream_allows_proxy_managed_placeholder_guard() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer PROXY_MANAGED"), + ); + + reject_proxy_placeholder_for_managed_account_upstream( + "https://api.example.com/v1/messages", + &headers, + ) + .expect("guard is scoped to managed-account upstreams"); + } + #[test] fn exact_header_case_preserved_for_native_claude_only() { let provider = test_provider_with_type(None); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index bcb446588..2d91c805e 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -45,6 +45,12 @@ const CLAUDE_TAKEOVER_OPUS_MODEL: &str = "claude-opus-4-7"; // 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。 const CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = "[1M]"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClaudeTakeoverAuthPolicy { + PreserveExistingOrAuthToken, + ManagedAccount, +} + #[derive(Clone)] pub struct ProxyService { db: Arc, @@ -69,7 +75,34 @@ impl ProxyService { } } + #[cfg(test)] fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) { + Self::apply_claude_takeover_fields_with_policy( + config, + proxy_url, + ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken, + ); + } + + fn apply_claude_takeover_fields_for_provider( + config: &mut Value, + proxy_url: &str, + provider: &Provider, + ) { + let auth_policy = if provider.uses_managed_account_auth() { + ClaudeTakeoverAuthPolicy::ManagedAccount + } else { + ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken + }; + + Self::apply_claude_takeover_fields_with_policy(config, proxy_url, auth_policy); + } + + fn apply_claude_takeover_fields_with_policy( + config: &mut Value, + proxy_url: &str, + auth_policy: ClaudeTakeoverAuthPolicy, + ) { // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。 let takeover_model_fields = Self::build_claude_takeover_model_fields(config); @@ -105,19 +138,32 @@ impl ProxyService { "OPENAI_API_KEY", ]; - let mut replaced_any = false; - for key in token_keys { - if env.contains_key(key) { - env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); - replaced_any = true; - } - } + match auth_policy { + ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => { + let mut replaced_any = false; + for key in token_keys { + if env.contains_key(key) { + env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER)); + replaced_any = true; + } + } - if !replaced_any { - env.insert( - "ANTHROPIC_AUTH_TOKEN".to_string(), - json!(PROXY_TOKEN_PLACEHOLDER), - ); + if !replaced_any { + env.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + json!(PROXY_TOKEN_PLACEHOLDER), + ); + } + } + ClaudeTakeoverAuthPolicy::ManagedAccount => { + for key in token_keys { + env.remove(key); + } + env.insert( + "ANTHROPIC_API_KEY".to_string(), + json!(PROXY_TOKEN_PLACEHOLDER), + ); + } } } @@ -228,11 +274,32 @@ impl ProxyService { .map_err(|e| format!("构建 claude 有效配置失败: {e}"))?; let (proxy_url, _) = self.build_proxy_urls().await?; - Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url); + Self::apply_claude_takeover_fields_for_provider( + &mut effective_settings, + &proxy_url, + provider, + ); self.write_claude_live(&effective_settings)?; Ok(()) } + fn get_current_provider_for_app(&self, app_type: &AppType) -> Result, String> { + let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type) + .map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))? + else { + return Ok(None); + }; + + self.db + .get_provider_by_id(¤t_id, app_type.as_str()) + .map_err(|e| format!("读取 {app_type:?} 当前供应商失败: {e}")) + } + + fn require_current_provider_for_app(&self, app_type: &AppType) -> Result { + self.get_current_provider_for_app(app_type)? + .ok_or_else(|| format!("{app_type:?} 当前供应商不存在,无法接管 Live 配置")) + } + /// 设置 AppHandle(在应用初始化时调用) pub fn set_app_handle(&self, handle: tauri::AppHandle) { futures::executor::block_on(async { @@ -1007,7 +1074,12 @@ impl ProxyService { // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) if let Ok(mut live_config) = self.read_claude_live() { - Self::apply_claude_takeover_fields(&mut live_config, &proxy_url); + let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?; + Self::apply_claude_takeover_fields_for_provider( + &mut live_config, + &proxy_url, + &claude_provider, + ); self.write_claude_live(&live_config)?; log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } @@ -1057,7 +1129,12 @@ impl ProxyService { match app_type { AppType::Claude => { let mut live_config = self.read_claude_live()?; - Self::apply_claude_takeover_fields(&mut live_config, &proxy_url); + let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?; + Self::apply_claude_takeover_fields_for_provider( + &mut live_config, + &proxy_url, + &claude_provider, + ); self.write_claude_live(&live_config)?; log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } @@ -1107,7 +1184,23 @@ impl ProxyService { match app_type { AppType::Claude => { if let Ok(mut live_config) = self.read_claude_live() { - Self::apply_claude_takeover_fields(&mut live_config, &proxy_url); + let claude_provider = self + .get_current_provider_for_app(&AppType::Claude) + .ok() + .flatten(); + if let Some(provider) = claude_provider.as_ref() { + Self::apply_claude_takeover_fields_for_provider( + &mut live_config, + &proxy_url, + provider, + ); + } else { + Self::apply_claude_takeover_fields_with_policy( + &mut live_config, + &proxy_url, + ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken, + ); + } let _ = self.write_claude_live(&live_config); } } @@ -2083,6 +2176,73 @@ mod tests { } } + #[test] + fn managed_account_claude_takeover_uses_api_key_placeholder() { + let mut provider = Provider::with_id( + "copilot".to_string(), + "GitHub Copilot".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com", + "ANTHROPIC_MODEL": "claude-haiku-4.5" + } + }), + None, + ); + provider.meta = Some(ProviderMeta { + provider_type: Some("github_copilot".to_string()), + ..Default::default() + }); + + let mut live_config = provider.settings_config.clone(); + ProxyService::apply_claude_takeover_fields_for_provider( + &mut live_config, + "http://127.0.0.1:15721", + &provider, + ); + + let env = live_config + .get("env") + .and_then(|value| value.as_object()) + .expect("env should exist"); + assert_eq!( + env.get("ANTHROPIC_API_KEY") + .and_then(|value| value.as_str()), + Some(PROXY_TOKEN_PLACEHOLDER) + ); + assert!( + env.get("ANTHROPIC_AUTH_TOKEN").is_none(), + "managed OAuth providers should avoid Claude Auth Token login semantics" + ); + } + + #[test] + fn normal_claude_takeover_without_token_keeps_auth_token_fallback() { + let mut live_config = json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com", + "ANTHROPIC_MODEL": "claude-haiku-4.5" + } + }); + + ProxyService::apply_claude_takeover_fields(&mut live_config, "http://127.0.0.1:15721"); + + assert_eq!( + live_config + .get("env") + .and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN")) + .and_then(|value| value.as_str()), + Some(PROXY_TOKEN_PLACEHOLDER) + ); + assert!( + live_config + .get("env") + .and_then(|env| env.get("ANTHROPIC_API_KEY")) + .is_none(), + "non-managed providers should retain the legacy fallback behavior" + ); + } + #[test] fn update_toml_base_url_updates_active_model_provider_base_url() { let input = r#"