From 6ef8d2f56a00c735508d662faaad3cf4f4cc27d7 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 16 Apr 2026 21:20:51 +0800 Subject: [PATCH] fix(proxy/gemini): trim API key before provider-type detection and OAuth parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leading whitespace on a copied oauth_creds.json (e.g. trailing newline when the user copies the file content as-is) would slip past the `starts_with("ya29.") || starts_with('{')` prefix check in `ClaudeAdapter::provider_type`, causing the provider to be misclassified as raw-API-key Gemini and fall back to `x-goog-api-key` with the raw JSON as the key — which upstream rejects with 401. The frontend's `handleApiKeyChange` already trims on keystrokes but deep-link imports, the JSON editor, and live-config backfill all bypass that path. Trim at every backend extraction point so the coverage is uniform: - `ClaudeAdapter::extract_key` (5 env / fallback branches) gets `.map(str::trim)` before `.filter(|s| !s.is_empty())` so that whitespace-only values are also treated as missing. - `GeminiAdapter::extract_key_raw` gets the same chain (including the `.filter` it was missing before). - `GeminiAdapter::parse_oauth_credentials` gets a defensive `let key = key.trim();` at the entry as a belt-and-suspenders guard. Adds two regression tests covering JSON and bare `ya29.` keys with leading newline/space. --- src-tauri/src/proxy/providers/claude.rs | 59 +++++++++++++++++++++++++ src-tauri/src/proxy/providers/gemini.rs | 14 +++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 5fd49ddc0..604c9ebfe 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -297,6 +297,7 @@ impl ClaudeAdapter { if let Some(key) = env .get("ANTHROPIC_AUTH_TOKEN") .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN"); @@ -305,6 +306,7 @@ impl ClaudeAdapter { if let Some(key) = env .get("ANTHROPIC_API_KEY") .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 ANTHROPIC_API_KEY"); @@ -314,6 +316,7 @@ impl ClaudeAdapter { if let Some(key) = env .get("OPENROUTER_API_KEY") .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 OPENROUTER_API_KEY"); @@ -323,6 +326,7 @@ impl ClaudeAdapter { if let Some(key) = env .get("OPENAI_API_KEY") .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 OPENAI_API_KEY"); @@ -336,6 +340,7 @@ impl ClaudeAdapter { .get("apiKey") .or_else(|| provider.settings_config.get("api_key")) .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 apiKey/api_key"); @@ -858,6 +863,60 @@ mod tests { assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); } + /// 回归:从 oauth_creds.json 复制时常带前导换行/空格。未 trim 时 + /// `starts_with('{')` 会落空,导致误分类为 `ProviderType::Gemini`,再 + /// 以 raw JSON 当 `x-goog-api-key` 发出去触发 401。trim 应在 provider + /// 类型判定和 OAuth 解析前统一生效。 + #[test] + fn test_extract_auth_gemini_cli_json_with_leading_whitespace_classifies_correctly() { + let adapter = ClaudeAdapter::new(); + let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#; + let key_with_whitespace = format!("\n {valid_json}\n"); + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": key_with_whitespace + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + + assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.access_token.as_deref(), Some("ya29.valid")); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + } + + /// 回归:裸 `ya29.` access_token 若带前导换行,也应被 trim 后识别为 + /// Gemini CLI OAuth,避免前导空白把 `starts_with("ya29.")` 检查顶穿。 + #[test] + fn test_extract_auth_gemini_cli_access_token_with_leading_newline_classifies_correctly() { + let adapter = ClaudeAdapter::new(); + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": "\nya29.raw-token-value\n" + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + + assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.access_token.as_deref(), Some("ya29.raw-token-value")); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + } + #[test] fn test_provider_type_detection() { let adapter = ClaudeAdapter::new(); diff --git a/src-tauri/src/proxy/providers/gemini.rs b/src-tauri/src/proxy/providers/gemini.rs index e0e88feb8..7de152a97 100644 --- a/src-tauri/src/proxy/providers/gemini.rs +++ b/src-tauri/src/proxy/providers/gemini.rs @@ -70,6 +70,11 @@ impl GeminiAdapter { /// 解析 OAuth 凭证 pub fn parse_oauth_credentials(&self, key: &str) -> Option { + // 防御性 trim:前端在 input 事件中会 trim,但 JSON 编辑器 / deeplink + // 导入 / live 回填等路径会绕过。带前导换行的 oauth_creds.json 粘贴 + // 是常见场景,此处统一兜底。 + let key = key.trim(); + // 直接是 access_token if key.starts_with("ya29.") { return Some(OAuthCredentials { @@ -120,7 +125,12 @@ impl GeminiAdapter { fn extract_key_raw(&self, provider: &Provider) -> Option { if let Some(env) = provider.settings_config.get("env") { // 使用 GEMINI_API_KEY - if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { + if let Some(key) = env + .get("GEMINI_API_KEY") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { return Some(key.to_string()); } } @@ -131,6 +141,8 @@ impl GeminiAdapter { .get("apiKey") .or_else(|| provider.settings_config.get("api_key")) .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) { return Some(key.to_string()); }