diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55dbad6ca..7ad4bad98 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -240,6 +240,149 @@ struct CheckResult { reloaded: bool, } +// ============ Gemini Provider Commands ============ + +#[derive(serde::Serialize)] +struct GeminiCredentialStatus { + loaded: bool, + has_access_token: bool, + has_refresh_token: bool, + expiry_date: Option, + is_valid: bool, + creds_path: String, +} + +#[tauri::command] +async fn get_gemini_credentials(state: tauri::State<'_, AppState>) -> Result { + let s = state.read().await; + let creds = &s.gemini_provider.credentials; + let path = providers::gemini::GeminiProvider::default_creds_path(); + + Ok(GeminiCredentialStatus { + loaded: creds.access_token.is_some() || creds.refresh_token.is_some(), + has_access_token: creds.access_token.is_some(), + has_refresh_token: creds.refresh_token.is_some(), + expiry_date: creds.expiry_date, + is_valid: s.gemini_provider.is_token_valid(), + creds_path: path.to_string_lossy().to_string(), + }) +} + +#[tauri::command] +async fn reload_gemini_credentials(state: tauri::State<'_, AppState>, logs: tauri::State<'_, LogState>) -> Result { + let mut s = state.write().await; + logs.write().await.add("info", "[Gemini] 正在加载凭证..."); + s.gemini_provider.load_credentials().map_err(|e| e.to_string())?; + logs.write().await.add("info", "[Gemini] 凭证加载成功"); + Ok("Gemini credentials reloaded".to_string()) +} + +#[tauri::command] +async fn refresh_gemini_token(state: tauri::State<'_, AppState>, logs: tauri::State<'_, LogState>) -> Result { + let mut s = state.write().await; + logs.write().await.add("info", "[Gemini] 正在刷新 Token..."); + let result = s.gemini_provider.refresh_token().await.map_err(|e| e.to_string()); + match &result { + Ok(_) => logs.write().await.add("info", "[Gemini] Token 刷新成功"), + Err(e) => logs.write().await.add("error", &format!("[Gemini] Token 刷新失败: {}", e)), + } + result +} + +#[tauri::command] +async fn get_gemini_env_variables(state: tauri::State<'_, AppState>) -> Result, String> { + let s = state.read().await; + let creds = &s.gemini_provider.credentials; + let mut vars = Vec::new(); + + if let Some(token) = &creds.access_token { + vars.push(EnvVariable { + key: "GEMINI_ACCESS_TOKEN".to_string(), + value: token.clone(), + masked: mask_token(token), + }); + } + if let Some(token) = &creds.refresh_token { + vars.push(EnvVariable { + key: "GEMINI_REFRESH_TOKEN".to_string(), + value: token.clone(), + masked: mask_token(token), + }); + } + if let Some(expiry) = creds.expiry_date { + let expiry_str = expiry.to_string(); + vars.push(EnvVariable { + key: "GEMINI_EXPIRY_DATE".to_string(), + value: expiry_str.clone(), + masked: expiry_str, + }); + } + + Ok(vars) +} + +#[tauri::command] +async fn get_gemini_token_file_hash() -> Result { + let path = providers::gemini::GeminiProvider::default_creds_path(); + if !path.exists() { + return Ok("".to_string()); + } + + let content = std::fs::read(&path).map_err(|e| e.to_string())?; + let hash = format!("{:x}", md5::compute(&content)); + Ok(hash) +} + +#[tauri::command] +async fn check_and_reload_gemini_credentials( + state: tauri::State<'_, AppState>, + logs: tauri::State<'_, LogState>, + last_hash: String, +) -> Result { + let path = providers::gemini::GeminiProvider::default_creds_path(); + + if !path.exists() { + return Ok(CheckResult { + changed: false, + new_hash: "".to_string(), + reloaded: false, + }); + } + + let content = std::fs::read(&path).map_err(|e| e.to_string())?; + let new_hash = format!("{:x}", md5::compute(&content)); + + if !last_hash.is_empty() && new_hash != last_hash { + logs.write().await.add("info", "[Gemini][自动检测] 凭证文件已变化,正在重新加载..."); + + let mut s = state.write().await; + match s.gemini_provider.load_credentials() { + Ok(_) => { + logs.write().await.add("info", "[Gemini][自动检测] 凭证重新加载成功"); + Ok(CheckResult { + changed: true, + new_hash, + reloaded: true, + }) + } + Err(e) => { + logs.write().await.add("error", &format!("[Gemini][自动检测] 凭证重新加载失败: {}", e)); + Ok(CheckResult { + changed: true, + new_hash, + reloaded: false, + }) + } + } + } else { + Ok(CheckResult { + changed: false, + new_hash, + reloaded: false, + }) + } +} + #[tauri::command] async fn get_logs(logs: tauri::State<'_, LogState>) -> Result, String> { Ok(logs.read().await.get_logs()) @@ -324,12 +467,21 @@ pub fn run() { get_server_status, get_config, save_config, + // Kiro commands refresh_kiro_token, reload_credentials, get_kiro_credentials, get_env_variables, get_token_file_hash, check_and_reload_credentials, + // Gemini commands + get_gemini_credentials, + reload_gemini_credentials, + refresh_gemini_token, + get_gemini_env_variables, + get_gemini_token_file_hash, + check_and_reload_gemini_credentials, + // Common get_logs, clear_logs, test_api, diff --git a/src-tauri/src/providers/gemini.rs b/src-tauri/src/providers/gemini.rs new file mode 100644 index 000000000..6e38da191 --- /dev/null +++ b/src-tauri/src/providers/gemini.rs @@ -0,0 +1,314 @@ +//! Gemini CLI OAuth Provider +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::path::PathBuf; +use reqwest::Client; + +// Constants +const CODE_ASSIST_ENDPOINT: &str = "https://cloudcode-pa.googleapis.com"; +const CODE_ASSIST_API_VERSION: &str = "v1internal"; +const CREDENTIALS_DIR: &str = ".gemini"; +const CREDENTIALS_FILE: &str = "oauth_creds.json"; + +// OAuth credentials - loaded from environment variables +// Set GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET +// These are the same as Gemini CLI uses (public OAuth app credentials) +fn get_oauth_client_id() -> Option { + std::env::var("GEMINI_OAUTH_CLIENT_ID").ok() +} + +fn get_oauth_client_secret() -> Option { + std::env::var("GEMINI_OAUTH_CLIENT_SECRET").ok() +} + +pub const GEMINI_MODELS: &[&str] = &[ + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-2.5-pro", + "gemini-2.5-pro-preview-06-05", + "gemini-2.5-flash-preview-09-2025", + "gemini-3-pro-preview", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiCredentials { + pub access_token: Option, + pub refresh_token: Option, + pub token_type: Option, + pub expiry_date: Option, + pub scope: Option, +} + +impl Default for GeminiCredentials { + fn default() -> Self { + Self { + access_token: None, + refresh_token: None, + token_type: Some("Bearer".to_string()), + expiry_date: None, + scope: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiContent { + pub role: String, + pub parts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiPart { + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GeminiRequest { + pub model: String, + pub project: String, + pub request: GeminiRequestBody, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GeminiRequestBody { + pub contents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_instruction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub generation_config: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GeminiGenerationConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_k: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiResponse { + pub candidates: Option>, + #[serde(rename = "usageMetadata")] + pub usage_metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GeminiCandidate { + pub content: Option, + pub finish_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GeminiUsageMetadata { + pub prompt_token_count: Option, + pub candidates_token_count: Option, + pub total_token_count: Option, +} + +pub struct GeminiProvider { + pub credentials: GeminiCredentials, + pub project_id: Option, + pub client: Client, +} + +impl GeminiProvider { + pub fn new() -> Self { + Self { + credentials: GeminiCredentials::default(), + project_id: None, + client: Client::new(), + } + } + + pub fn default_creds_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(CREDENTIALS_DIR) + .join(CREDENTIALS_FILE) + } + + pub fn load_credentials(&mut self) -> Result<(), Box> { + let path = Self::default_creds_path(); + + if path.exists() { + let content = std::fs::read_to_string(&path)?; + let creds: GeminiCredentials = serde_json::from_str(&content)?; + self.credentials = creds; + } + + Ok(()) + } + + pub fn save_credentials(&self) -> Result<(), Box> { + let path = Self::default_creds_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(&self.credentials)?; + std::fs::write(&path, content)?; + Ok(()) + } + + pub fn is_token_valid(&self) -> bool { + if self.credentials.access_token.is_none() { + return false; + } + if let Some(expiry) = self.credentials.expiry_date { + let now = chrono::Utc::now().timestamp_millis(); + // Token valid if more than 5 minutes until expiry + return expiry > now + 300_000; + } + true + } + + pub async fn refresh_token(&mut self) -> Result> { + let refresh_token = self.credentials.refresh_token.as_ref() + .ok_or("No refresh token available")?; + + let client_id = get_oauth_client_id() + .ok_or("GEMINI_OAUTH_CLIENT_ID not set")?; + let client_secret = get_oauth_client_secret() + .ok_or("GEMINI_OAUTH_CLIENT_SECRET not set")?; + + let params = [ + ("client_id", client_id.as_str()), + ("client_secret", client_secret.as_str()), + ("refresh_token", refresh_token.as_str()), + ("grant_type", "refresh_token"), + ]; + + let resp = self.client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token refresh failed: {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + + let new_token = data["access_token"] + .as_str() + .ok_or("No access token in response")?; + + self.credentials.access_token = Some(new_token.to_string()); + + if let Some(expires_in) = data["expires_in"].as_i64() { + self.credentials.expiry_date = Some( + chrono::Utc::now().timestamp_millis() + expires_in * 1000 + ); + } + + // Save refreshed credentials + self.save_credentials()?; + + Ok(new_token.to_string()) + } + + pub fn get_api_url(&self, action: &str) -> String { + format!("{}/{}:{}", CODE_ASSIST_ENDPOINT, CODE_ASSIST_API_VERSION, action) + } + + pub async fn call_api( + &self, + action: &str, + body: &serde_json::Value, + ) -> Result> { + let token = self.credentials.access_token.as_ref() + .ok_or("No access token")?; + + let url = self.get_api_url(action); + + let resp = self.client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json") + .json(body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("API call failed: {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + Ok(data) + } + + pub async fn discover_project(&mut self) -> Result> { + if let Some(ref project_id) = self.project_id { + return Ok(project_id.clone()); + } + + let body = serde_json::json!({ + "cloudaicompanionProject": "", + "metadata": { + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + "duetProject": "" + } + }); + + let resp = self.call_api("loadCodeAssist", &body).await?; + + if let Some(project) = resp["cloudaicompanionProject"].as_str() { + if !project.is_empty() { + self.project_id = Some(project.to_string()); + return Ok(project.to_string()); + } + } + + // Need to onboard + let onboard_body = serde_json::json!({ + "tierId": "free-tier", + "cloudaicompanionProject": "", + "metadata": { + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + "duetProject": "" + } + }); + + let mut lro_resp = self.call_api("onboardUser", &onboard_body).await?; + + // Poll until done + for _ in 0..30 { + if lro_resp["done"].as_bool().unwrap_or(false) { + break; + } + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + lro_resp = self.call_api("onboardUser", &onboard_body).await?; + } + + let project_id = lro_resp["response"]["cloudaicompanionProject"]["id"] + .as_str() + .unwrap_or("") + .to_string(); + + if project_id.is_empty() { + return Err("Failed to discover project ID".into()); + } + + self.project_id = Some(project_id.clone()); + Ok(project_id) + } +} diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 01e080c86..86d8340c2 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -1,3 +1,5 @@ pub mod kiro; +pub mod gemini; pub use kiro::KiroProvider; +pub use gemini::GeminiProvider; diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 0667e73fa..2552ecc7f 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -4,6 +4,7 @@ use crate::models::openai::*; use crate::models::anthropic::*; use crate::converter::anthropic_to_openai::convert_anthropic_to_openai; use crate::providers::kiro::KiroProvider; +use crate::providers::gemini::GeminiProvider; use crate::logger::LogStore; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -31,6 +32,7 @@ pub struct ServerState { pub requests: u64, pub start_time: Option, pub kiro_provider: KiroProvider, + pub gemini_provider: GeminiProvider, shutdown_tx: Option>, } @@ -39,12 +41,16 @@ impl ServerState { let mut kiro = KiroProvider::new(); let _ = kiro.load_credentials(); + let mut gemini = GeminiProvider::new(); + let _ = gemini.load_credentials(); + Self { config, running: false, requests: 0, start_time: None, kiro_provider: kiro, + gemini_provider: gemini, shutdown_tx: None, } } @@ -158,10 +164,17 @@ async fn models() -> impl IntoResponse { Json(serde_json::json!({ "object": "list", "data": [ + // Kiro/Claude models {"id": "claude-sonnet-4-5", "object": "model", "owned_by": "anthropic"}, {"id": "claude-sonnet-4-5-20250929", "object": "model", "owned_by": "anthropic"}, {"id": "claude-3-7-sonnet-20250219", "object": "model", "owned_by": "anthropic"}, - {"id": "claude-3-5-sonnet-latest", "object": "model", "owned_by": "anthropic"} + {"id": "claude-3-5-sonnet-latest", "object": "model", "owned_by": "anthropic"}, + // Gemini models + {"id": "gemini-2.5-flash", "object": "model", "owned_by": "google"}, + {"id": "gemini-2.5-flash-lite", "object": "model", "owned_by": "google"}, + {"id": "gemini-2.5-pro", "object": "model", "owned_by": "google"}, + {"id": "gemini-2.5-pro-preview-06-05", "object": "model", "owned_by": "google"}, + {"id": "gemini-3-pro-preview", "object": "model", "owned_by": "google"} ] })) } diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index 2e7e73ac4..eeec3e458 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -145,3 +145,39 @@ export interface CheckResult { export async function checkAndReloadCredentials(lastHash: string): Promise { return invoke("check_and_reload_credentials", { last_hash: lastHash }); } + + +// ============ Gemini Provider ============ + +export interface GeminiCredentialStatus { + loaded: boolean; + has_access_token: boolean; + has_refresh_token: boolean; + expiry_date: number | null; + is_valid: boolean; + creds_path: string; +} + +export async function getGeminiCredentials(): Promise { + return invoke("get_gemini_credentials"); +} + +export async function reloadGeminiCredentials(): Promise { + return invoke("reload_gemini_credentials"); +} + +export async function refreshGeminiToken(): Promise { + return invoke("refresh_gemini_token"); +} + +export async function getGeminiEnvVariables(): Promise { + return invoke("get_gemini_env_variables"); +} + +export async function getGeminiTokenFileHash(): Promise { + return invoke("get_gemini_token_file_hash"); +} + +export async function checkAndReloadGeminiCredentials(lastHash: string): Promise { + return invoke("check_and_reload_gemini_credentials", { last_hash: lastHash }); +}