diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 13516c383..808dc6e17 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -9,7 +9,10 @@ use super::{ failover_switch::FailoverSwitchManager, log_codes::fwd as log_fwd, provider_router::ProviderRouter, - providers::{get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}, + providers::{ + gemini_shadow::GeminiShadowStore, get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, + ProviderType, + }, thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget}, thinking_rectifier::{ normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, @@ -42,12 +45,15 @@ pub struct RequestForwarder { router: Arc, status: Arc>, current_providers: Arc>>, + gemini_shadow: Arc, /// 故障转移切换管理器 failover_manager: Arc, /// AppHandle,用于发射事件和更新托盘 app_handle: Option, /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) current_provider_id_at_start: String, + /// 代理会话 ID(用于 Gemini Native shadow replay) + session_id: String, /// 整流器配置 rectifier_config: RectifierConfig, /// 优化器配置 @@ -65,9 +71,11 @@ impl RequestForwarder { non_streaming_timeout: u64, status: Arc>, current_providers: Arc>>, + gemini_shadow: Arc, failover_manager: Arc, app_handle: Option, current_provider_id_at_start: String, + session_id: String, _streaming_first_byte_timeout: u64, _streaming_idle_timeout: u64, rectifier_config: RectifierConfig, @@ -78,9 +86,11 @@ impl RequestForwarder { router, status, current_providers, + gemini_shadow, failover_manager, app_handle, current_provider_id_at_start, + session_id, rectifier_config, optimizer_config, copilot_optimizer_config, @@ -877,7 +887,7 @@ impl RequestForwarder { let api_format = resolved_claude_api_format .as_deref() .unwrap_or_else(|| super::providers::get_claude_api_format(provider)); - rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot) + rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot, &mapped_body) } else { ( endpoint.to_string(), @@ -887,7 +897,13 @@ impl RequestForwarder { ) }; - let url = if is_full_url { + let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) { + super::gemini_url::resolve_gemini_native_url( + &base_url, + &effective_endpoint, + is_full_url, + ) + } else if is_full_url { append_query_to_full_url(&base_url, passthrough_query.as_deref()) } else { adapter.build_url(&base_url, &effective_endpoint) @@ -903,6 +919,8 @@ impl RequestForwarder { mapped_body, provider, api_format, + Some(&self.session_id), + Some(self.gemini_shadow.as_ref()), )? } else { adapter.transform_request(mapped_body, provider)? @@ -1019,8 +1037,11 @@ impl RequestForwarder { .ok() .and_then(|u| u.authority().map(|a| a.to_string())); + let should_send_anthropic_headers = adapter.name() == "Claude" + && matches!(resolved_claude_api_format.as_deref(), Some("anthropic")); + // 预计算 anthropic-beta 值(仅 Claude) - let anthropic_beta_value = if adapter.name() == "Claude" { + let anthropic_beta_value = if should_send_anthropic_headers { const CLAUDE_CODE_BETA: &str = "claude-code-20250219"; Some(if let Some(beta) = headers.get("anthropic-beta") { if let Ok(beta_str) = beta.to_str() { @@ -1140,8 +1161,10 @@ impl RequestForwarder { // --- anthropic-version — 透传客户端值 --- if key_str.eq_ignore_ascii_case("anthropic-version") { - saw_anthropic_version = true; - ordered_headers.append(key.clone(), value.clone()); + if should_send_anthropic_headers { + saw_anthropic_version = true; + ordered_headers.append(key.clone(), value.clone()); + } continue; } @@ -1182,7 +1205,7 @@ impl RequestForwarder { } // anthropic-version:仅在缺失时补充默认值 - if adapter.name() == "Claude" && !saw_anthropic_version { + if should_send_anthropic_headers && !saw_anthropic_version { ordered_headers.append( "anthropic-version", http::HeaderValue::from_static("2023-06-01"), @@ -1530,6 +1553,7 @@ fn rewrite_claude_transform_endpoint( endpoint: &str, api_format: &str, is_copilot: bool, + body: &Value, ) -> (String, Option) { let (path, query) = split_endpoint_and_query(endpoint); let passthrough_query = if is_claude_messages_path(path) { @@ -1542,6 +1566,32 @@ fn rewrite_claude_transform_endpoint( return (endpoint.to_string(), passthrough_query); } + if api_format == "gemini_native" { + let model = + super::providers::transform_gemini::extract_gemini_model(body).unwrap_or("unknown"); + let is_stream = body + .get("stream") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let target_path = if is_stream { + format!("/v1beta/models/{model}:streamGenerateContent") + } else { + format!("/v1beta/models/{model}:generateContent") + }; + + let rewritten_query = merge_query_params( + passthrough_query.as_deref(), + if is_stream { Some("alt=sse") } else { None }, + ); + + let rewritten = match rewritten_query.as_deref() { + Some(query) if !query.is_empty() => format!("{target_path}?{query}"), + _ => target_path, + }; + + return (rewritten, rewritten_query); + } + let target_path = if is_copilot && api_format == "openai_responses" { "/v1/responses" } else if is_copilot { @@ -1560,6 +1610,26 @@ fn rewrite_claude_transform_endpoint( (rewritten, passthrough_query) } +fn merge_query_params(base_query: Option<&str>, extra_param: Option<&str>) -> Option { + let mut params: Vec = base_query + .into_iter() + .flat_map(|query| query.split('&')) + .filter(|pair| !pair.is_empty()) + .filter(|pair| !pair.starts_with("alt=")) + .map(ToString::to_string) + .collect(); + + if let Some(extra_param) = extra_param { + params.push(extra_param.to_string()); + } + + if params.is_empty() { + None + } else { + Some(params.join("&")) + } +} + fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String { match query { Some(query) if !query.is_empty() => { @@ -1688,6 +1758,7 @@ mod tests { "/v1/messages?beta=true&foo=bar", "openai_chat", false, + &json!({ "model": "gpt-5.4" }), ); assert_eq!(endpoint, "/v1/chat/completions?foo=bar"); @@ -1700,6 +1771,7 @@ mod tests { "/claude/v1/messages?beta=true&x-id=1", "openai_responses", false, + &json!({ "model": "gpt-5.4" }), ); assert_eq!(endpoint, "/v1/responses?x-id=1"); @@ -1708,8 +1780,12 @@ mod tests { #[test] fn rewrite_claude_transform_endpoint_uses_copilot_path() { - let (endpoint, passthrough_query) = - rewrite_claude_transform_endpoint("/v1/messages?beta=true&x-id=1", "anthropic", true); + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&x-id=1", + "anthropic", + true, + &json!({ "model": "claude-sonnet-4-6" }), + ); assert_eq!(endpoint, "/chat/completions?x-id=1"); assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); @@ -1721,12 +1797,45 @@ mod tests { "/v1/messages?beta=true&x-id=1", "openai_responses", true, + &json!({ "model": "gpt-5.4" }), ); assert_eq!(endpoint, "/v1/responses?x-id=1"); assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); } + #[test] + fn rewrite_claude_transform_endpoint_maps_gemini_generate_content() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&x-id=1", + "gemini_native", + false, + &json!({ "model": "gemini-2.5-pro" }), + ); + + assert_eq!( + endpoint, + "/v1beta/models/gemini-2.5-pro:generateContent?x-id=1" + ); + assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); + } + + #[test] + fn rewrite_claude_transform_endpoint_maps_gemini_streaming() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true", + "gemini_native", + false, + &json!({ "model": "gemini-2.5-flash", "stream": true }), + ); + + assert_eq!( + endpoint, + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + assert_eq!(passthrough_query.as_deref(), Some("alt=sse")); + } + #[test] fn append_query_to_full_url_preserves_existing_query_string() { let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1")); @@ -1734,6 +1843,43 @@ mod tests { assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1"); } + #[test] + fn build_gemini_native_url_uses_origin_when_base_ends_with_v1beta() { + let url = crate::proxy::gemini_url::build_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta", + "/v1beta/models/gemini-2.5-pro:generateContent", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent" + ); + } + + #[test] + fn build_gemini_native_url_uses_origin_when_base_already_contains_models_prefix() { + let url = crate::proxy::gemini_url::build_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta/models", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn resolve_gemini_native_url_keeps_opaque_full_url_as_is() { + let url = crate::proxy::gemini_url::resolve_gemini_native_url( + "https://relay.example/custom/generate-content", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); + } + #[test] fn force_identity_for_stream_flag_requests() { let headers = HeaderMap::new(); diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index bad855a91..da2413ec4 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -218,9 +218,11 @@ impl RequestContext { non_streaming_timeout, state.status.clone(), state.current_providers.clone(), + state.gemini_shadow.clone(), state.failover_manager.clone(), state.app_handle.clone(), self.current_provider_id.clone(), + self.session_id.clone(), first_byte_timeout, idle_timeout, self.rectifier_config.clone(), diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 08816246a..a672589e4 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -15,8 +15,9 @@ use super::{ handler_context::RequestContext, providers::{ get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream, + streaming_gemini::create_anthropic_sse_stream_from_gemini, streaming_responses::create_anthropic_sse_stream_from_responses, transform, - transform_responses, + transform_gemini, transform_responses, }, response_processor::{ create_logged_passthrough_stream, process_response, read_decoded_body, @@ -157,6 +158,13 @@ async fn handle_claude_transform( dyn futures::Stream> + Send + Unpin, > = if api_format == "openai_responses" { Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream))) + } else if api_format == "gemini_native" { + Box::new(Box::pin(create_anthropic_sse_stream_from_gemini( + stream, + Some(state.gemini_shadow.clone()), + Some(ctx.provider.id.clone()), + Some(ctx.session_id.clone()), + ))) } else { Box::new(Box::pin(create_anthropic_sse_stream(stream))) }; @@ -245,6 +253,13 @@ async fn handle_claude_transform( // 根据 api_format 选择非流式转换器 let anthropic_response = if api_format == "openai_responses" { transform_responses::responses_to_anthropic(upstream_response) + } else if api_format == "gemini_native" { + transform_gemini::gemini_to_anthropic_with_shadow( + upstream_response, + Some(state.gemini_shadow.as_ref()), + Some(&ctx.provider.id), + Some(&ctx.session_id), + ) } else { transform::openai_to_anthropic(upstream_response) } diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index a14818c5d..48cec6b38 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -10,6 +10,7 @@ pub mod error; pub mod error_mapper; pub(crate) mod failover_switch; mod forwarder; +pub mod gemini_url; pub mod handler_config; pub mod handler_context; mod handlers; diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 2cd7c0172..c73ed2a52 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -6,6 +6,7 @@ //! - **anthropic** (默认): Anthropic Messages API 格式,直接透传 //! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换 //! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换 +//! - **gemini_native**: Google Gemini Native generateContent 格式,需要 Anthropic ↔ Gemini 转换 //! //! ## 认证模式 //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) @@ -28,6 +29,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str { return match api_format { "openai_chat" => "openai_chat", "openai_responses" => "openai_responses", + "gemini_native" => "gemini_native", _ => "anthropic", }; } @@ -42,6 +44,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str { return match api_format { "openai_chat" => "openai_chat", "openai_responses" => "openai_responses", + "gemini_native" => "gemini_native", _ => "anthropic", }; } @@ -66,25 +69,33 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str { } pub fn claude_api_format_needs_transform(api_format: &str) -> bool { - matches!(api_format, "openai_chat" | "openai_responses") + matches!( + api_format, + "openai_chat" | "openai_responses" | "gemini_native" + ) } pub fn transform_claude_request_for_api_format( body: serde_json::Value, provider: &Provider, api_format: &str, + session_id: Option<&str>, + shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>, ) -> Result { let cache_key = provider .meta .as_ref() - .and_then(|m| m.prompt_cache_key.as_deref()) - .unwrap_or(&provider.id); + .and_then(|m| m.prompt_cache_key.as_deref()); match api_format { - "openai_responses" => { - super::transform_responses::anthropic_to_responses(body, Some(cache_key)) - } - "openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)), + "openai_responses" => super::transform_responses::anthropic_to_responses(body, cache_key), + "openai_chat" => super::transform::anthropic_to_openai(body, cache_key), + "gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow( + body, + shadow_store, + Some(&provider.id), + session_id, + ), _ => Ok(body), } } @@ -105,6 +116,15 @@ impl ClaudeAdapter { /// - ClaudeAuth: auth_mode 为 bearer_only /// - Claude: 默认 Anthropic 官方 pub fn provider_type(&self, provider: &Provider) -> ProviderType { + if self.get_api_format(provider) == "gemini_native" { + return match self.extract_key(provider) { + Some(key) if key.starts_with("ya29.") || key.starts_with('{') => { + ProviderType::GeminiCli + } + _ => ProviderType::Gemini, + }; + } + // 检测 GitHub Copilot if self.is_github_copilot(provider) { return ProviderType::GitHubCopilot; @@ -304,14 +324,23 @@ impl ProviderAdapter for ClaudeAdapter { )); } - let strategy = match provider_type { - ProviderType::OpenRouter => AuthStrategy::Bearer, - ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth, - _ => AuthStrategy::Anthropic, - }; + let key = self.extract_key(provider)?; - self.extract_key(provider) - .map(|key| AuthInfo::new(key, strategy)) + match provider_type { + ProviderType::GeminiCli => { + if let Some(creds) = + super::gemini::GeminiAdapter::new().parse_oauth_credentials(&key) + { + Some(AuthInfo::with_access_token(key, creds.access_token)) + } else { + Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth)) + } + } + ProviderType::Gemini => Some(AuthInfo::new(key, AuthStrategy::Google)), + ProviderType::OpenRouter => Some(AuthInfo::new(key, AuthStrategy::Bearer)), + ProviderType::ClaudeAuth => Some(AuthInfo::new(key, AuthStrategy::ClaudeAuth)), + _ => Some(AuthInfo::new(key, AuthStrategy::Anthropic)), + } } fn build_url(&self, base_url: &str, endpoint: &str) -> String { @@ -347,6 +376,23 @@ impl ProviderAdapter for ClaudeAdapter { HeaderValue::from_str(&bearer).unwrap(), )] } + AuthStrategy::Google => vec![( + HeaderName::from_static("x-goog-api-key"), + HeaderValue::from_str(&auth.api_key).unwrap(), + )], + AuthStrategy::GoogleOAuth => { + let token = auth.access_token.as_ref().unwrap_or(&auth.api_key); + vec![ + ( + HeaderName::from_static("authorization"), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ), + ( + HeaderName::from_static("x-goog-api-client"), + HeaderValue::from_static("GeminiCLI/1.0"), + ), + ] + } AuthStrategy::GitHubCopilot => { // 生成请求追踪 ID let request_id = uuid::Uuid::new_v4().to_string(); @@ -402,7 +448,6 @@ impl ProviderAdapter for ClaudeAdapter { ), ] } - _ => vec![], } } @@ -418,7 +463,7 @@ impl ProviderAdapter for ClaudeAdapter { // - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换 matches!( self.get_api_format(provider), - "openai_chat" | "openai_responses" + "openai_chat" | "openai_responses" | "gemini_native" ) } @@ -427,7 +472,13 @@ impl ProviderAdapter for ClaudeAdapter { body: serde_json::Value, provider: &Provider, ) -> Result { - transform_claude_request_for_api_format(body, provider, self.get_api_format(provider)) + transform_claude_request_for_api_format( + body, + provider, + self.get_api_format(provider), + None, + None, + ) } fn transform_response(&self, body: serde_json::Value) -> Result { @@ -436,7 +487,9 @@ impl ProviderAdapter for ClaudeAdapter { // config, so we can't check api_format here. Instead we rely on the fact that // Responses API always returns "output" while Chat Completions returns "choices". // This is safe because the two formats are structurally disjoint. - if body.get("output").is_some() { + if body.get("candidates").is_some() || body.get("promptFeedback").is_some() { + super::transform_gemini::gemini_to_anthropic(body) + } else if body.get("output").is_some() { super::transform_responses::responses_to_anthropic(body) } else { super::transform::openai_to_anthropic(body) @@ -740,6 +793,24 @@ mod tests { ); assert!(adapter.needs_transform(&openai_responses_provider)); + let gemini_native_provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + assert!(adapter.needs_transform(&gemini_native_provider)); + assert_eq!( + adapter.provider_type(&gemini_native_provider), + ProviderType::Gemini + ); + // meta takes precedence over legacy settings_config fields let meta_precedence_over_settings = create_provider_with_meta( json!({ @@ -847,11 +918,106 @@ mod tests { "max_tokens": 128 }); - let transformed = - transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap(); + let transformed = transform_claude_request_for_api_format( + body, + &provider, + "openai_responses", + None, + None, + ) + .unwrap(); assert_eq!(transformed["model"], "gpt-5.4"); assert!(transformed.get("input").is_some()); assert!(transformed.get("max_output_tokens").is_some()); } + + #[test] + fn test_transform_claude_request_for_api_format_gemini_native() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + let body = json!({ + "model": "gemini-2.5-pro", + "system": "You are helpful.", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 64 + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "gemini_native", None, None) + .unwrap(); + + assert!(transformed.get("contents").is_some()); + assert_eq!( + transformed["systemInstruction"]["parts"][0]["text"], + "You are helpful." + ); + assert_eq!(transformed["generationConfig"]["maxOutputTokens"], 64); + } + + #[test] + fn test_transform_claude_request_for_api_format_openai_chat_skips_prompt_cache_key_by_default() + { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("openai_chat".to_string()), + ..Default::default() + }, + ); + let body = json!({ + "model": "gpt-5.4", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 64 + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None) + .unwrap(); + + assert!(transformed.get("prompt_cache_key").is_none()); + } + + #[test] + fn test_transform_claude_request_for_api_format_openai_chat_keeps_explicit_prompt_cache_key() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("openai_chat".to_string()), + prompt_cache_key: Some("claude-cache-route".to_string()), + ..Default::default() + }, + ); + let body = json!({ + "model": "gpt-5.4", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 64 + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None) + .unwrap(); + + assert_eq!(transformed["prompt_cache_key"], "claude-cache-route"); + } } diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 646bec414..8032bb8b1 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -17,10 +17,14 @@ mod claude; mod codex; pub mod copilot_auth; mod gemini; +pub(crate) mod gemini_schema; +pub mod gemini_shadow; pub mod models; pub mod streaming; +pub mod streaming_gemini; pub mod streaming_responses; pub mod transform; +pub mod transform_gemini; pub mod transform_responses; use crate::app_config::AppType; @@ -96,6 +100,14 @@ impl ProviderType { pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self { match app_type { AppType::Claude => { + if get_claude_api_format(provider) == "gemini_native" { + let adapter = ClaudeAdapter::new(); + return match adapter.extract_auth(provider).map(|auth| auth.strategy) { + Some(AuthStrategy::GoogleOAuth) => ProviderType::GeminiCli, + _ => ProviderType::Gemini, + }; + } + // 检测是否为 GitHub Copilot if let Some(meta) = provider.meta.as_ref() { if meta.provider_type.as_deref() == Some("github_copilot") { diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index dda690a7a..75c9dbe4d 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -684,6 +684,7 @@ mod tests { use crate::provider::ProviderMeta; use crate::proxy::failover_switch::FailoverSwitchManager; use crate::proxy::provider_router::ProviderRouter; + use crate::proxy::providers::gemini_shadow::GeminiShadowStore; use crate::proxy::types::{ProxyConfig, ProxyStatus}; use rust_decimal::Decimal; use std::collections::HashMap; @@ -720,6 +721,7 @@ mod tests { start_time: Arc::new(RwLock::new(None)), current_providers: Arc::new(RwLock::new(HashMap::new())), provider_router: Arc::new(ProviderRouter::new(db.clone())), + gemini_shadow: Arc::new(GeminiShadowStore::default()), app_handle: None, failover_manager: Arc::new(FailoverSwitchManager::new(db)), } diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 8fa37edf6..fb49107ff 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -10,7 +10,8 @@ use super::{ failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv, - provider_router::ProviderRouter, types::*, ProxyError, + provider_router::ProviderRouter, providers::gemini_shadow::GeminiShadowStore, types::*, + ProxyError, }; use crate::database::Database; use axum::{ @@ -36,6 +37,8 @@ pub struct ProxyState { pub current_providers: Arc>>, /// 共享的 ProviderRouter(持有熔断器状态,跨请求保持) pub provider_router: Arc, + /// Gemini Native shadow state,用于 thoughtSignature / tool call 回放 + pub gemini_shadow: Arc, /// AppHandle,用于发射事件和更新托盘菜单 pub app_handle: Option, /// 故障转移切换管理器 @@ -69,6 +72,7 @@ impl ProxyServer { start_time: Arc::new(RwLock::new(None)), current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())), provider_router, + gemini_shadow: Arc::new(GeminiShadowStore::default()), app_handle, failover_manager, }; diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 79f1021ed..2490dc0c7 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -12,8 +12,10 @@ use std::time::Instant; use crate::app_config::AppType; use crate::error::AppError; use crate::provider::Provider; +use crate::proxy::gemini_url::resolve_gemini_native_url; use crate::proxy::providers::copilot_auth; use crate::proxy::providers::transform::anthropic_to_openai; +use crate::proxy::providers::transform_gemini::anthropic_to_gemini; use crate::proxy::providers::transform_responses::anthropic_to_responses; use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy}; @@ -309,6 +311,8 @@ impl StreamCheckService { /// 根据供应商的 api_format 选择请求格式: /// - "anthropic" (默认): Anthropic Messages API (/v1/messages) /// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions) + /// - "openai_responses": OpenAI Responses API (/v1/responses) + /// - "gemini_native": Gemini Native streamGenerateContent #[allow(clippy::too_many_arguments)] async fn check_claude_stream( client: &Client, @@ -345,8 +349,14 @@ impl StreamCheckService { .unwrap_or(false); let is_openai_chat = effective_api_format == "openai_chat"; let is_openai_responses = effective_api_format == "openai_responses"; - let url = - Self::resolve_claude_stream_url(base, auth.strategy, effective_api_format, is_full_url); + let is_gemini_native = effective_api_format == "gemini_native"; + let url = Self::resolve_claude_stream_url( + base, + auth.strategy, + effective_api_format, + is_full_url, + model, + ); let max_tokens = if is_openai_responses { 16 } else { 1 }; @@ -360,6 +370,9 @@ impl StreamCheckService { let body = if is_openai_responses { anthropic_to_responses(anthropic_body, Some(&provider.id)) .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? + } else if is_gemini_native { + anthropic_to_gemini(anthropic_body) + .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? } else if is_openai_chat { anthropic_to_openai(anthropic_body, Some(&provider.id)) .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? @@ -395,6 +408,23 @@ impl StreamCheckService { .header("x-vscode-user-agent-library-version", "electron-fetch") .header("x-request-id", &request_id) .header("x-agent-task-id", &request_id); + } else if is_gemini_native { + request_builder = match auth.strategy { + AuthStrategy::GoogleOAuth => { + let token = auth.access_token.as_ref().unwrap_or(&auth.api_key); + request_builder + .header("authorization", format!("Bearer {token}")) + .header("x-goog-api-client", "GeminiCLI/1.0") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .header("accept-encoding", "identity") + } + _ => request_builder + .header("x-goog-api-key", &auth.api_key) + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .header("accept-encoding", "identity"), + }; } else if is_openai_chat || is_openai_responses { // OpenAI-compatible targets: Bearer auth + SSE headers only request_builder = request_builder @@ -751,7 +781,13 @@ impl StreamCheckService { auth_strategy: AuthStrategy, api_format: &str, is_full_url: bool, + model: &str, ) -> String { + if api_format == "gemini_native" { + let endpoint = format!("/v1beta/models/{model}:streamGenerateContent?alt=sse"); + return resolve_gemini_native_url(base_url, &endpoint, is_full_url); + } + if is_full_url { return base_url.to_string(); } @@ -914,6 +950,7 @@ mod tests { AuthStrategy::Bearer, "openai_chat", true, + "gpt-5.4", ); assert_eq!(url, "https://relay.example/v1/chat/completions"); @@ -926,6 +963,7 @@ mod tests { AuthStrategy::GitHubCopilot, "openai_chat", false, + "gpt-5.4", ); assert_eq!(url, "https://api.githubcopilot.com/chat/completions"); @@ -938,6 +976,7 @@ mod tests { AuthStrategy::GitHubCopilot, "openai_responses", false, + "gpt-5.4", ); assert_eq!(url, "https://api.githubcopilot.com/v1/responses"); @@ -950,6 +989,7 @@ mod tests { AuthStrategy::Bearer, "openai_chat", false, + "gpt-5.4", ); assert_eq!(url, "https://example.com/v1/chat/completions"); @@ -962,6 +1002,7 @@ mod tests { AuthStrategy::Bearer, "openai_responses", false, + "gpt-5.4", ); assert_eq!(url, "https://example.com/v1/responses"); @@ -974,11 +1015,57 @@ mod tests { AuthStrategy::Anthropic, "anthropic", false, + "claude-sonnet-4-6", ); assert_eq!(url, "https://api.anthropic.com/v1/messages"); } + #[test] + fn test_resolve_claude_stream_url_for_gemini_native() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://generativelanguage.googleapis.com", + AuthStrategy::Google, + "gemini_native", + false, + "gemini-2.5-flash", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn test_resolve_claude_stream_url_for_gemini_native_full_url_openai_compat_base() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + AuthStrategy::Google, + "gemini_native", + true, + "gemini-2.5-flash", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn test_resolve_claude_stream_url_for_gemini_native_opaque_full_url() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://relay.example/custom/generate-content", + AuthStrategy::Google, + "gemini_native", + true, + "gemini-2.5-flash", + ); + + assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); + } + #[test] fn test_resolve_codex_stream_urls_for_full_url_mode() { let urls = StreamCheckService::resolve_codex_stream_urls(