feat: add Gemini CLI OAuth provider

- Add GeminiProvider with OAuth token refresh support
- Add Gemini credential management commands
- Update Providers page with tabs for Kiro/Gemini
- Auto-detect credential file changes for both providers
- Add Gemini models to /v1/models endpoint
- OAuth credentials must be set via GEMINI_OAUTH_CLIENT_ID/SECRET env vars
This commit is contained in:
coso
2025-12-14 03:05:56 +08:00
parent 6d53cccc42
commit d41939ef42
5 changed files with 518 additions and 1 deletions
+152
View File
@@ -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<i64>,
is_valid: bool,
creds_path: String,
}
#[tauri::command]
async fn get_gemini_credentials(state: tauri::State<'_, AppState>) -> Result<GeminiCredentialStatus, String> {
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<String, String> {
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<String, String> {
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<Vec<EnvVariable>, 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<String, String> {
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<CheckResult, String> {
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<Vec<logger::LogEntry>, 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,
+314
View File
@@ -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<String> {
std::env::var("GEMINI_OAUTH_CLIENT_ID").ok()
}
fn get_oauth_client_secret() -> Option<String> {
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<String>,
pub refresh_token: Option<String>,
pub token_type: Option<String>,
pub expiry_date: Option<i64>,
pub scope: Option<String>,
}
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<GeminiPart>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeminiPart {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
#[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<GeminiContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_instruction: Option<GeminiContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_config: Option<GeminiGenerationConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GeminiGenerationConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeminiResponse {
pub candidates: Option<Vec<GeminiCandidate>>,
#[serde(rename = "usageMetadata")]
pub usage_metadata: Option<GeminiUsageMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GeminiCandidate {
pub content: Option<GeminiContent>,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GeminiUsageMetadata {
pub prompt_token_count: Option<i32>,
pub candidates_token_count: Option<i32>,
pub total_token_count: Option<i32>,
}
pub struct GeminiProvider {
pub credentials: GeminiCredentials,
pub project_id: Option<String>,
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<dyn Error + Send + Sync>> {
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<dyn Error + Send + Sync>> {
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<String, Box<dyn Error + Send + Sync>> {
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(&params)
.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<serde_json::Value, Box<dyn Error + Send + Sync>> {
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<String, Box<dyn Error + Send + Sync>> {
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)
}
}
+2
View File
@@ -1,3 +1,5 @@
pub mod kiro;
pub mod gemini;
pub use kiro::KiroProvider;
pub use gemini::GeminiProvider;
+14 -1
View File
@@ -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<std::time::Instant>,
pub kiro_provider: KiroProvider,
pub gemini_provider: GeminiProvider,
shutdown_tx: Option<oneshot::Sender<()>>,
}
@@ -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"}
]
}))
}
+36
View File
@@ -145,3 +145,39 @@ export interface CheckResult {
export async function checkAndReloadCredentials(lastHash: string): Promise<CheckResult> {
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<GeminiCredentialStatus> {
return invoke("get_gemini_credentials");
}
export async function reloadGeminiCredentials(): Promise<string> {
return invoke("reload_gemini_credentials");
}
export async function refreshGeminiToken(): Promise<string> {
return invoke("refresh_gemini_token");
}
export async function getGeminiEnvVariables(): Promise<EnvVariable[]> {
return invoke("get_gemini_env_variables");
}
export async function getGeminiTokenFileHash(): Promise<string> {
return invoke("get_gemini_token_file_hash");
}
export async function checkAndReloadGeminiCredentials(lastHash: string): Promise<CheckResult> {
return invoke("check_and_reload_gemini_credentials", { last_hash: lastHash });
}