mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +08:00
75d78da920
Extract shared URL utilities to handle query strings and fragments correctly. Add race condition fix for URL preview, improve proxy toggle error feedback, and support Codex chat format proxy endpoint.
425 lines
13 KiB
Rust
425 lines
13 KiB
Rust
//! Gemini (Google) Provider Adapter
|
||
//!
|
||
//! 支持 API Key 和 OAuth 两种认证方式
|
||
//!
|
||
//! ## 认证模式
|
||
//! - **Gemini**: API Key 认证 (x-goog-api-key)
|
||
//! - **GeminiCli**: OAuth Bearer 认证 (用于 Gemini CLI)
|
||
|
||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||
use crate::provider::Provider;
|
||
use crate::proxy::error::ProxyError;
|
||
use crate::proxy::url_utils::split_url_suffix;
|
||
use reqwest::RequestBuilder;
|
||
|
||
/// Gemini 适配器
|
||
pub struct GeminiAdapter;
|
||
|
||
/// OAuth 凭证结构
|
||
#[derive(Debug, Clone)]
|
||
#[allow(dead_code)]
|
||
pub struct OAuthCredentials {
|
||
pub access_token: String,
|
||
pub refresh_token: Option<String>,
|
||
pub client_id: Option<String>,
|
||
pub client_secret: Option<String>,
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
impl OAuthCredentials {
|
||
/// 检查是否需要刷新 token(有 refresh_token 但没有有效的 access_token)
|
||
pub fn needs_refresh(&self) -> bool {
|
||
self.refresh_token.is_some() && self.access_token.is_empty()
|
||
}
|
||
|
||
/// 检查是否可以刷新 token
|
||
pub fn can_refresh(&self) -> bool {
|
||
self.refresh_token.is_some() && self.client_id.is_some() && self.client_secret.is_some()
|
||
}
|
||
}
|
||
|
||
impl GeminiAdapter {
|
||
pub fn new() -> Self {
|
||
Self
|
||
}
|
||
|
||
/// 获取供应商类型
|
||
///
|
||
/// 根据 API Key 格式检测:
|
||
/// - GeminiCli: access_token (ya29. 开头) 或 JSON 格式凭证
|
||
/// - Gemini: 普通 API Key
|
||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||
if let Some(key) = self.extract_key_raw(provider) {
|
||
// OAuth access_token 以 ya29. 开头
|
||
if key.starts_with("ya29.") {
|
||
return ProviderType::GeminiCli;
|
||
}
|
||
// JSON 格式的 OAuth 凭证
|
||
if key.starts_with('{') {
|
||
return ProviderType::GeminiCli;
|
||
}
|
||
}
|
||
ProviderType::Gemini
|
||
}
|
||
|
||
/// 检测认证类型
|
||
pub fn detect_auth_type(&self, provider: &Provider) -> AuthStrategy {
|
||
match self.provider_type(provider) {
|
||
ProviderType::GeminiCli => AuthStrategy::GoogleOAuth,
|
||
_ => AuthStrategy::Google,
|
||
}
|
||
}
|
||
|
||
/// 解析 OAuth 凭证
|
||
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
|
||
// 直接是 access_token
|
||
if key.starts_with("ya29.") {
|
||
return Some(OAuthCredentials {
|
||
access_token: key.to_string(),
|
||
refresh_token: None,
|
||
client_id: None,
|
||
client_secret: None,
|
||
});
|
||
}
|
||
|
||
// JSON 格式
|
||
if key.starts_with('{') {
|
||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(key) {
|
||
let access_token = json
|
||
.get("access_token")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string())
|
||
.unwrap_or_default();
|
||
let refresh_token = json
|
||
.get("refresh_token")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
let client_id = json
|
||
.get("client_id")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
let client_secret = json
|
||
.get("client_secret")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
|
||
// 如果有 access_token 或 refresh_token,返回凭证
|
||
if !access_token.is_empty() || refresh_token.is_some() {
|
||
return Some(OAuthCredentials {
|
||
access_token,
|
||
refresh_token,
|
||
client_id,
|
||
client_secret,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// 从 Provider 配置中提取原始 API Key
|
||
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
|
||
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()) {
|
||
return Some(key.to_string());
|
||
}
|
||
}
|
||
|
||
// 尝试直接获取
|
||
if let Some(key) = provider
|
||
.settings_config
|
||
.get("apiKey")
|
||
.or_else(|| provider.settings_config.get("api_key"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Some(key.to_string());
|
||
}
|
||
|
||
None
|
||
}
|
||
}
|
||
|
||
impl Default for GeminiAdapter {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl ProviderAdapter for GeminiAdapter {
|
||
fn name(&self) -> &'static str {
|
||
"Gemini"
|
||
}
|
||
|
||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||
// 从 env 中获取
|
||
if let Some(env) = provider.settings_config.get("env") {
|
||
if let Some(url) = env.get("GOOGLE_GEMINI_BASE_URL").and_then(|v| v.as_str()) {
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
}
|
||
|
||
// 尝试直接获取
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("base_url")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("baseURL")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
Err(ProxyError::ConfigError(
|
||
"Gemini Provider 缺少 base_url 配置".to_string(),
|
||
))
|
||
}
|
||
|
||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||
let key = self.extract_key_raw(provider)?;
|
||
let strategy = self.detect_auth_type(provider);
|
||
|
||
match strategy {
|
||
AuthStrategy::GoogleOAuth => {
|
||
// 解析 OAuth 凭证
|
||
if let Some(creds) = self.parse_oauth_credentials(&key) {
|
||
Some(AuthInfo::with_access_token(key, creds.access_token))
|
||
} else {
|
||
// 回退到普通 API Key
|
||
Some(AuthInfo::new(key, AuthStrategy::Google))
|
||
}
|
||
}
|
||
_ => Some(AuthInfo::new(key, AuthStrategy::Google)),
|
||
}
|
||
}
|
||
|
||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||
let (base, suffix) = split_url_suffix(base_url);
|
||
let base_trimmed = base.trim_end_matches('/');
|
||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||
|
||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||
|
||
// 处理 /v1beta 路径去重
|
||
let version_patterns = ["/v1beta", "/v1"];
|
||
for pattern in &version_patterns {
|
||
let duplicate = format!("{pattern}{pattern}");
|
||
if url.contains(&duplicate) {
|
||
url = url.replace(&duplicate, pattern);
|
||
}
|
||
}
|
||
|
||
format!("{url}{suffix}")
|
||
}
|
||
|
||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||
match auth.strategy {
|
||
// OAuth Bearer 认证
|
||
AuthStrategy::GoogleOAuth => {
|
||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||
request
|
||
.header("Authorization", format!("Bearer {token}"))
|
||
.header("x-goog-api-client", "GeminiCLI/1.0")
|
||
}
|
||
// API Key 认证
|
||
_ => request.header("x-goog-api-key", &auth.api_key),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
fn create_provider(config: serde_json::Value) -> Provider {
|
||
Provider {
|
||
id: "test".to_string(),
|
||
name: "Test Gemini".to_string(),
|
||
settings_config: config,
|
||
website_url: None,
|
||
category: Some("gemini".to_string()),
|
||
created_at: None,
|
||
sort_index: None,
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_base_url_from_env() {
|
||
let adapter = GeminiAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com/v1beta"
|
||
}
|
||
}));
|
||
|
||
let url = adapter.extract_base_url(&provider).unwrap();
|
||
assert_eq!(url, "https://generativelanguage.googleapis.com/v1beta");
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_api_key() {
|
||
let adapter = GeminiAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "AIza-test-key-12345678"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "AIza-test-key-12345678");
|
||
assert_eq!(auth.strategy, AuthStrategy::Google);
|
||
assert!(auth.access_token.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_oauth_access_token() {
|
||
let adapter = GeminiAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "ya29.test-access-token-12345"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||
assert_eq!(
|
||
auth.access_token,
|
||
Some("ya29.test-access-token-12345".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_oauth_json() {
|
||
let adapter = GeminiAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test-token\",\"refresh_token\":\"1//refresh\"}"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||
assert_eq!(auth.access_token, Some("ya29.test-token".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_provider_type_detection() {
|
||
let adapter = GeminiAdapter::new();
|
||
|
||
// API Key
|
||
let api_key_provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "AIza-test-key"
|
||
}
|
||
}));
|
||
assert_eq!(
|
||
adapter.provider_type(&api_key_provider),
|
||
ProviderType::Gemini
|
||
);
|
||
|
||
// OAuth access_token
|
||
let oauth_provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "ya29.test-token"
|
||
}
|
||
}));
|
||
assert_eq!(
|
||
adapter.provider_type(&oauth_provider),
|
||
ProviderType::GeminiCli
|
||
);
|
||
|
||
// OAuth JSON
|
||
let oauth_json_provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\"}"
|
||
}
|
||
}));
|
||
assert_eq!(
|
||
adapter.provider_type(&oauth_json_provider),
|
||
ProviderType::GeminiCli
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_fallback() {
|
||
let adapter = GeminiAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "AIza-fallback-key"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "AIza-fallback-key");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_dedup() {
|
||
let adapter = GeminiAdapter::new();
|
||
// 模拟 base_url 已包含 /v1beta,endpoint 也包含 /v1beta
|
||
let url = adapter.build_url(
|
||
"https://generativelanguage.googleapis.com/v1beta",
|
||
"/v1beta/models/gemini-pro:generateContent",
|
||
);
|
||
assert_eq!(
|
||
url,
|
||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_normal() {
|
||
let adapter = GeminiAdapter::new();
|
||
let url = adapter.build_url(
|
||
"https://generativelanguage.googleapis.com/v1beta",
|
||
"/models/gemini-pro:generateContent",
|
||
);
|
||
assert_eq!(
|
||
url,
|
||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_oauth_credentials_direct_token() {
|
||
let adapter = GeminiAdapter::new();
|
||
let creds = adapter
|
||
.parse_oauth_credentials("ya29.test-access-token")
|
||
.unwrap();
|
||
assert_eq!(creds.access_token, "ya29.test-access-token");
|
||
assert!(creds.refresh_token.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_oauth_credentials_json() {
|
||
let adapter = GeminiAdapter::new();
|
||
let creds = adapter
|
||
.parse_oauth_credentials(
|
||
"{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//refresh\"}",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(creds.access_token, "ya29.test");
|
||
assert_eq!(creds.refresh_token, Some("1//refresh".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_oauth_credentials_invalid() {
|
||
let adapter = GeminiAdapter::new();
|
||
assert!(adapter.parse_oauth_credentials("AIza-api-key").is_none());
|
||
assert!(adapter.parse_oauth_credentials("invalid-json{").is_none());
|
||
}
|
||
}
|