mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-03 14:36:44 +08:00
refactor(services): split monolithic provider.rs into modular structure
Split the 1446-line services/provider.rs into 5 focused modules: - gemini_auth.rs (250 lines): Gemini authentication type detection - PackyCode, Google OAuth, and generic provider detection - Security flag management for different auth types - live.rs (300 lines): Live configuration operations - LiveSnapshot backup/restore - Reading and writing live config files - Sync current provider to live config - usage.rs (150 lines): Usage script execution - Query and test usage scripts - Format usage results - Validate usage script configuration - endpoints.rs (80 lines): Custom endpoints management - CRUD operations for provider custom endpoints - Last-used timestamp tracking - mod.rs (650 lines): Core provider CRUD and service facade - ProviderService struct with all public methods - Provider add/update/delete/switch operations - Claude model key normalization - Credential extraction and validation All 107 tests pass. This improves maintainability by: - Separating concerns into cohesive modules - Making Gemini-specific logic easier to find and modify - Reducing cognitive load when working on specific features
This commit is contained in:
File diff suppressed because it is too large
Load Diff
98
src-tauri/src/services/provider/endpoints.rs
Normal file
98
src-tauri/src/services/provider/endpoints.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! Custom endpoints management
|
||||
//!
|
||||
//! Handles CRUD operations for provider custom endpoints.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::settings::CustomEndpoint;
|
||||
use crate::store::AppState;
|
||||
|
||||
/// Get custom endpoints list for a provider
|
||||
pub fn get_custom_endpoints(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<Vec<CustomEndpoint>, AppError> {
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let Some(provider) = providers.get(provider_id) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let Some(meta) = provider.meta.as_ref() else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if meta.custom_endpoints.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
|
||||
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Add a custom endpoint to a provider
|
||||
pub fn add_custom_endpoint(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
url: String,
|
||||
) -> Result<(), AppError> {
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
if normalized.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"provider.endpoint.url_required",
|
||||
"URL 不能为空",
|
||||
"URL cannot be empty",
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.add_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a custom endpoint from a provider
|
||||
pub fn remove_custom_endpoint(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
url: String,
|
||||
) -> Result<(), AppError> {
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
state
|
||||
.db
|
||||
.remove_custom_endpoint(app_type.as_str(), provider_id, &normalized)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update endpoint last used timestamp
|
||||
pub fn update_endpoint_last_used(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
url: String,
|
||||
) -> Result<(), AppError> {
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
|
||||
// Get provider, update last_used, save back
|
||||
let mut providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get_mut(provider_id) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
|
||||
endpoint.last_used = Some(now_millis());
|
||||
state.db.save_provider(app_type.as_str(), provider)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current timestamp in milliseconds
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
287
src-tauri/src/services/provider/gemini_auth.rs
Normal file
287
src-tauri/src/services/provider/gemini_auth.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
//! Gemini authentication type detection
|
||||
//!
|
||||
//! Detects whether a Gemini provider uses PackyCode API Key, Google OAuth, or generic API Key.
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::settings;
|
||||
|
||||
/// Gemini authentication type enumeration
|
||||
///
|
||||
/// Used to optimize performance by avoiding repeated provider type detection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum GeminiAuthType {
|
||||
/// PackyCode provider (uses API Key)
|
||||
Packycode,
|
||||
/// Google Official (uses OAuth)
|
||||
GoogleOfficial,
|
||||
/// Generic Gemini provider (uses API Key)
|
||||
Generic,
|
||||
}
|
||||
|
||||
// Authentication type constants
|
||||
const PACKYCODE_SECURITY_SELECTED_TYPE: &str = "gemini-api-key";
|
||||
const GOOGLE_OAUTH_SECURITY_SELECTED_TYPE: &str = "oauth-personal";
|
||||
|
||||
// Partner Promotion Key constants
|
||||
const PACKYCODE_PARTNER_KEY: &str = "packycode";
|
||||
const GOOGLE_OFFICIAL_PARTNER_KEY: &str = "google-official";
|
||||
|
||||
// PackyCode keyword constants
|
||||
const PACKYCODE_KEYWORDS: [&str; 3] = ["packycode", "packyapi", "packy"];
|
||||
|
||||
/// Detect Gemini provider authentication type
|
||||
///
|
||||
/// One-time detection to avoid repeated calls to `is_packycode_gemini` and `is_google_official_gemini`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `GeminiAuthType::GoogleOfficial`: Google official, uses OAuth
|
||||
/// - `GeminiAuthType::Packycode`: PackyCode provider, uses API Key
|
||||
/// - `GeminiAuthType::Generic`: Other generic providers, uses API Key
|
||||
pub(crate) fn detect_gemini_auth_type(provider: &Provider) -> GeminiAuthType {
|
||||
// Priority 1: Check partner_promotion_key (most reliable)
|
||||
if let Some(key) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.partner_promotion_key.as_deref())
|
||||
{
|
||||
if key.eq_ignore_ascii_case(GOOGLE_OFFICIAL_PARTNER_KEY) {
|
||||
return GeminiAuthType::GoogleOfficial;
|
||||
}
|
||||
if key.eq_ignore_ascii_case(PACKYCODE_PARTNER_KEY) {
|
||||
return GeminiAuthType::Packycode;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Check Google Official (name matching)
|
||||
let name_lower = provider.name.to_ascii_lowercase();
|
||||
if name_lower == "google" || name_lower.starts_with("google ") {
|
||||
return GeminiAuthType::GoogleOfficial;
|
||||
}
|
||||
|
||||
// Priority 3: Check PackyCode keywords
|
||||
if contains_packycode_keyword(&provider.name) {
|
||||
return GeminiAuthType::Packycode;
|
||||
}
|
||||
|
||||
if let Some(site) = provider.website_url.as_deref() {
|
||||
if contains_packycode_keyword(site) {
|
||||
return GeminiAuthType::Packycode;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(base_url) = provider
|
||||
.settings_config
|
||||
.pointer("/env/GOOGLE_GEMINI_BASE_URL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if contains_packycode_keyword(base_url) {
|
||||
return GeminiAuthType::Packycode;
|
||||
}
|
||||
}
|
||||
|
||||
GeminiAuthType::Generic
|
||||
}
|
||||
|
||||
/// Check if string contains PackyCode related keywords (case-insensitive)
|
||||
///
|
||||
/// Keyword list: ["packycode", "packyapi", "packy"]
|
||||
fn contains_packycode_keyword(value: &str) -> bool {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
PACKYCODE_KEYWORDS
|
||||
.iter()
|
||||
.any(|keyword| lower.contains(keyword))
|
||||
}
|
||||
|
||||
/// Detect if provider is PackyCode Gemini (uses API Key authentication)
|
||||
///
|
||||
/// PackyCode is an official partner requiring special security configuration.
|
||||
///
|
||||
/// # Detection Rules (priority from high to low)
|
||||
///
|
||||
/// 1. **Partner Promotion Key** (most reliable):
|
||||
/// - `provider.meta.partner_promotion_key == "packycode"`
|
||||
///
|
||||
/// 2. **Provider name**:
|
||||
/// - Name contains "packycode", "packyapi" or "packy" (case-insensitive)
|
||||
///
|
||||
/// 3. **Website URL**:
|
||||
/// - `provider.website_url` contains keywords
|
||||
///
|
||||
/// 4. **Base URL**:
|
||||
/// - `settings_config.env.GOOGLE_GEMINI_BASE_URL` contains keywords
|
||||
///
|
||||
/// # Why multiple detection methods
|
||||
///
|
||||
/// - Users may manually create providers without `partner_promotion_key`
|
||||
/// - Meta fields may be modified after copying from presets
|
||||
/// - Ensure all PackyCode providers get correct security flags
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn is_packycode_gemini(provider: &Provider) -> bool {
|
||||
// Strategy 1: Check partner_promotion_key (most reliable)
|
||||
if provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.partner_promotion_key.as_deref())
|
||||
.is_some_and(|key| key.eq_ignore_ascii_case(PACKYCODE_PARTNER_KEY))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strategy 2: Check provider name
|
||||
if contains_packycode_keyword(&provider.name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strategy 3: Check website URL
|
||||
if let Some(site) = provider.website_url.as_deref() {
|
||||
if contains_packycode_keyword(site) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Check Base URL
|
||||
if let Some(base_url) = provider
|
||||
.settings_config
|
||||
.pointer("/env/GOOGLE_GEMINI_BASE_URL")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if contains_packycode_keyword(base_url) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Detect if provider is Google Official Gemini (uses OAuth authentication)
|
||||
///
|
||||
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
|
||||
///
|
||||
/// # Detection Rules (priority from high to low)
|
||||
///
|
||||
/// 1. **Partner Promotion Key** (most reliable):
|
||||
/// - `provider.meta.partner_promotion_key == "google-official"`
|
||||
///
|
||||
/// 2. **Provider name**:
|
||||
/// - Name equals "google" (case-insensitive)
|
||||
/// - Or name starts with "google " (e.g., "Google Official")
|
||||
///
|
||||
/// # OAuth vs API Key
|
||||
///
|
||||
/// - **OAuth mode**: `security.auth.selectedType = "oauth-personal"`
|
||||
/// - User needs to login via browser with Google account
|
||||
/// - No API Key needed in `.env` file
|
||||
///
|
||||
/// - **API Key mode**: `security.auth.selectedType = "gemini-api-key"`
|
||||
/// - Used for third-party relay services (like PackyCode)
|
||||
/// - Requires `GEMINI_API_KEY` in `.env` file
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn is_google_official_gemini(provider: &Provider) -> bool {
|
||||
// Strategy 1: Check partner_promotion_key (most reliable)
|
||||
if provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.partner_promotion_key.as_deref())
|
||||
.is_some_and(|key| key.eq_ignore_ascii_case(GOOGLE_OFFICIAL_PARTNER_KEY))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strategy 2: Check name matching (fallback)
|
||||
let name_lower = provider.name.to_ascii_lowercase();
|
||||
name_lower == "google" || name_lower.starts_with("google ")
|
||||
}
|
||||
|
||||
/// Ensure PackyCode Gemini provider security flag is correctly set
|
||||
///
|
||||
/// PackyCode is an official partner using API Key authentication mode.
|
||||
///
|
||||
/// # Why write to two settings.json files
|
||||
///
|
||||
/// 1. **`~/.cc-switch/settings.json`** (application-level config):
|
||||
/// - CC-Switch application global settings
|
||||
/// - Ensures app knows current authentication type
|
||||
/// - Used for UI display and other app logic
|
||||
///
|
||||
/// 2. **`~/.gemini/settings.json`** (Gemini client config):
|
||||
/// - Configuration file read by Gemini CLI client
|
||||
/// - Directly affects Gemini client authentication behavior
|
||||
/// - Ensures Gemini uses correct authentication method to connect API
|
||||
///
|
||||
/// # Value set
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "security": {
|
||||
/// "auth": {
|
||||
/// "selectedType": "gemini-api-key"
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Error handling
|
||||
///
|
||||
/// If provider is not PackyCode, function returns `Ok(())` immediately without any operation.
|
||||
pub(crate) fn ensure_packycode_security_flag(provider: &Provider) -> Result<(), AppError> {
|
||||
if !is_packycode_gemini(provider) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Write to application-level settings.json (~/.cc-switch/settings.json)
|
||||
settings::ensure_security_auth_selected_type(PACKYCODE_SECURITY_SELECTED_TYPE)?;
|
||||
|
||||
// Write to Gemini directory settings.json (~/.gemini/settings.json)
|
||||
use crate::gemini_config::write_packycode_settings;
|
||||
write_packycode_settings()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure Google Official Gemini provider security flag is correctly set (OAuth mode)
|
||||
///
|
||||
/// Google Official Gemini uses OAuth personal authentication, no API Key needed.
|
||||
///
|
||||
/// # Why write to two settings.json files
|
||||
///
|
||||
/// Same as `ensure_packycode_security_flag`, need to configure both app-level and client-level settings.
|
||||
///
|
||||
/// # Value set
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "security": {
|
||||
/// "auth": {
|
||||
/// "selectedType": "oauth-personal"
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # OAuth authentication flow
|
||||
///
|
||||
/// 1. User switches to Google Official provider
|
||||
/// 2. CC-Switch sets `selectedType = "oauth-personal"`
|
||||
/// 3. User's first use of Gemini CLI will auto-open browser for OAuth login
|
||||
/// 4. After successful login, credentials saved in Gemini credential store
|
||||
/// 5. Subsequent requests auto-use saved credentials
|
||||
///
|
||||
/// # Error handling
|
||||
///
|
||||
/// If provider is not Google Official, function returns `Ok(())` immediately without any operation.
|
||||
pub(crate) fn ensure_google_oauth_security_flag(provider: &Provider) -> Result<(), AppError> {
|
||||
if !is_google_official_gemini(provider) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Write to application-level settings.json (~/.cc-switch/settings.json)
|
||||
settings::ensure_security_auth_selected_type(GOOGLE_OAUTH_SECURITY_SELECTED_TYPE)?;
|
||||
|
||||
// Write to Gemini directory settings.json (~/.gemini/settings.json)
|
||||
use crate::gemini_config::write_google_oauth_settings;
|
||||
write_google_oauth_settings()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
385
src-tauri/src/services/provider/live.rs
Normal file
385
src-tauri/src/services/provider/live.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
//! Live configuration operations
|
||||
//!
|
||||
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
||||
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::mcp::McpService;
|
||||
use crate::store::AppState;
|
||||
|
||||
use super::gemini_auth::{
|
||||
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
|
||||
GeminiAuthType,
|
||||
};
|
||||
use super::normalize_claude_models_in_value;
|
||||
|
||||
/// Live configuration snapshot for backup/restore
|
||||
#[derive(Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) enum LiveSnapshot {
|
||||
Claude {
|
||||
settings: Option<Value>,
|
||||
},
|
||||
Codex {
|
||||
auth: Option<Value>,
|
||||
config: Option<String>,
|
||||
},
|
||||
Gemini {
|
||||
env: Option<HashMap<String, String>>,
|
||||
config: Option<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LiveSnapshot {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn restore(&self) -> Result<(), AppError> {
|
||||
match self {
|
||||
LiveSnapshot::Claude { settings } => {
|
||||
let path = get_claude_settings_path();
|
||||
if let Some(value) = settings {
|
||||
write_json_file(&path, value)?;
|
||||
} else if path.exists() {
|
||||
delete_file(&path)?;
|
||||
}
|
||||
}
|
||||
LiveSnapshot::Codex { auth, config } => {
|
||||
let auth_path = get_codex_auth_path();
|
||||
let config_path = get_codex_config_path();
|
||||
if let Some(value) = auth {
|
||||
write_json_file(&auth_path, value)?;
|
||||
} else if auth_path.exists() {
|
||||
delete_file(&auth_path)?;
|
||||
}
|
||||
|
||||
if let Some(text) = config {
|
||||
crate::config::write_text_file(&config_path, text)?;
|
||||
} else if config_path.exists() {
|
||||
delete_file(&config_path)?;
|
||||
}
|
||||
}
|
||||
LiveSnapshot::Gemini { env, .. } => {
|
||||
use crate::gemini_config::{
|
||||
get_gemini_env_path, get_gemini_settings_path, write_gemini_env_atomic,
|
||||
};
|
||||
let path = get_gemini_env_path();
|
||||
if let Some(env_map) = env {
|
||||
write_gemini_env_atomic(env_map)?;
|
||||
} else if path.exists() {
|
||||
delete_file(&path)?;
|
||||
}
|
||||
|
||||
let settings_path = get_gemini_settings_path();
|
||||
match self {
|
||||
LiveSnapshot::Gemini {
|
||||
config: Some(cfg), ..
|
||||
} => {
|
||||
write_json_file(&settings_path, cfg)?;
|
||||
}
|
||||
LiveSnapshot::Gemini { config: None, .. } if settings_path.exists() => {
|
||||
delete_file(&settings_path)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write live configuration snapshot for a provider
|
||||
pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let path = get_claude_settings_path();
|
||||
write_json_file(&path, &provider.settings_config)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let obj = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string())
|
||||
})?;
|
||||
let auth = obj.get("auth").ok_or_else(|| {
|
||||
AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string())
|
||||
})?;
|
||||
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
|
||||
})?;
|
||||
|
||||
let auth_path = get_codex_auth_path();
|
||||
write_json_file(&auth_path, auth)?;
|
||||
let config_path = get_codex_config_path();
|
||||
std::fs::write(&config_path, config_str)
|
||||
.map_err(|e| AppError::io(&config_path, e))?;
|
||||
}
|
||||
AppType::Gemini => {
|
||||
use crate::gemini_config::{
|
||||
get_gemini_settings_path, json_to_env, write_gemini_env_atomic,
|
||||
};
|
||||
|
||||
// Extract env and config from provider settings
|
||||
let env_value = provider.settings_config.get("env");
|
||||
let config_value = provider.settings_config.get("config");
|
||||
|
||||
// Write env file
|
||||
if let Some(env) = env_value {
|
||||
let env_map = json_to_env(env)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
|
||||
// Write settings file
|
||||
if let Some(config) = config_value {
|
||||
let settings_path = get_gemini_settings_path();
|
||||
write_json_file(&settings_path, config)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider from database to live configuration
|
||||
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
|
||||
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
let current_id = match state.db.get_current_provider(app_type.as_str())? {
|
||||
Some(id) => id,
|
||||
None => continue,
|
||||
};
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if let Some(provider) = providers.get(¤t_id) {
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
} else {
|
||||
log::warn!(
|
||||
"无法同步 live 配置: 当前供应商 {} ({}) 未找到",
|
||||
current_id,
|
||||
app_type.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// MCP sync
|
||||
McpService::sync_all_enabled(state)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read current live settings for an app type
|
||||
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
match app_type {
|
||||
AppType::Codex => {
|
||||
let auth_path = get_codex_auth_path();
|
||||
if !auth_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"codex.auth.missing",
|
||||
"Codex 配置文件不存在:缺少 auth.json",
|
||||
"Codex configuration missing: auth.json not found",
|
||||
));
|
||||
}
|
||||
let auth: Value = read_json_file(&auth_path)?;
|
||||
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
Ok(json!({ "auth": auth, "config": cfg_text }))
|
||||
}
|
||||
AppType::Claude => {
|
||||
let path = get_claude_settings_path();
|
||||
if !path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"claude.live.missing",
|
||||
"Claude Code 配置文件不存在",
|
||||
"Claude settings file is missing",
|
||||
));
|
||||
}
|
||||
read_json_file(&path)
|
||||
}
|
||||
AppType::Gemini => {
|
||||
use crate::gemini_config::{
|
||||
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
|
||||
};
|
||||
|
||||
// Read .env file (environment variables)
|
||||
let env_path = get_gemini_env_path();
|
||||
if !env_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"gemini.env.missing",
|
||||
"Gemini .env 文件不存在",
|
||||
"Gemini .env file not found",
|
||||
));
|
||||
}
|
||||
|
||||
let env_map = read_gemini_env()?;
|
||||
let env_json = env_to_json(&env_map);
|
||||
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
|
||||
|
||||
// Read settings.json file (MCP config etc.)
|
||||
let settings_path = get_gemini_settings_path();
|
||||
let config_obj = if settings_path.exists() {
|
||||
read_json_file(&settings_path)?
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
|
||||
// Return complete structure: { "env": {...}, "config": {...} }
|
||||
Ok(json!({
|
||||
"env": env_obj,
|
||||
"config": config_obj
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Import default configuration from live files
|
||||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> {
|
||||
{
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
if !providers.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => {
|
||||
let auth_path = get_codex_auth_path();
|
||||
if !auth_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"codex.live.missing",
|
||||
"Codex 配置文件不存在",
|
||||
"Codex configuration file is missing",
|
||||
));
|
||||
}
|
||||
let auth: Value = read_json_file(&auth_path)?;
|
||||
let config_str = crate::codex_config::read_and_validate_codex_config_text()?;
|
||||
json!({ "auth": auth, "config": config_str })
|
||||
}
|
||||
AppType::Claude => {
|
||||
let settings_path = get_claude_settings_path();
|
||||
if !settings_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"claude.live.missing",
|
||||
"Claude Code 配置文件不存在",
|
||||
"Claude settings file is missing",
|
||||
));
|
||||
}
|
||||
let mut v = read_json_file::<Value>(&settings_path)?;
|
||||
let _ = normalize_claude_models_in_value(&mut v);
|
||||
v
|
||||
}
|
||||
AppType::Gemini => {
|
||||
use crate::gemini_config::{
|
||||
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
|
||||
};
|
||||
|
||||
// Read .env file (environment variables)
|
||||
let env_path = get_gemini_env_path();
|
||||
if !env_path.exists() {
|
||||
return Err(AppError::localized(
|
||||
"gemini.live.missing",
|
||||
"Gemini 配置文件不存在",
|
||||
"Gemini configuration file is missing",
|
||||
));
|
||||
}
|
||||
|
||||
let env_map = read_gemini_env()?;
|
||||
let env_json = env_to_json(&env_map);
|
||||
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
|
||||
|
||||
// Read settings.json file (MCP config etc.)
|
||||
let settings_path = get_gemini_settings_path();
|
||||
let config_obj = if settings_path.exists() {
|
||||
read_json_file(&settings_path)?
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
|
||||
// Return complete structure: { "env": {...}, "config": {...} }
|
||||
json!({
|
||||
"env": env_obj,
|
||||
"config": config_obj
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
"default".to_string(),
|
||||
"default".to_string(),
|
||||
settings_config,
|
||||
None,
|
||||
);
|
||||
provider.category = Some("custom".to_string());
|
||||
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write Gemini live configuration with authentication handling
|
||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{
|
||||
get_gemini_settings_path, json_to_env, validate_gemini_settings_strict,
|
||||
write_gemini_env_atomic,
|
||||
};
|
||||
|
||||
// One-time auth type detection to avoid repeated detection
|
||||
let auth_type = detect_gemini_auth_type(provider);
|
||||
|
||||
let mut env_map = json_to_env(&provider.settings_config)?;
|
||||
|
||||
// Prepare config to write to ~/.gemini/settings.json (preserve existing file content when absent)
|
||||
let mut config_to_write = if let Some(config_value) = provider.settings_config.get("config") {
|
||||
if config_value.is_null() {
|
||||
Some(json!({}))
|
||||
} else if config_value.is_object() {
|
||||
Some(config_value.clone())
|
||||
} else {
|
||||
return Err(AppError::localized(
|
||||
"gemini.validation.invalid_config",
|
||||
"Gemini 配置格式错误: config 必须是对象或 null",
|
||||
"Gemini config invalid: config must be an object or null",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if config_to_write.is_none() {
|
||||
let settings_path = get_gemini_settings_path();
|
||||
if settings_path.exists() {
|
||||
config_to_write = Some(read_json_file(&settings_path)?);
|
||||
}
|
||||
}
|
||||
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => {
|
||||
// Google official uses OAuth, clear env
|
||||
env_map.clear();
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
GeminiAuthType::Packycode => {
|
||||
// PackyCode provider, uses API Key (strict validation on switch)
|
||||
validate_gemini_settings_strict(&provider.settings_config)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
GeminiAuthType::Generic => {
|
||||
// Generic provider, uses API Key (strict validation on switch)
|
||||
validate_gemini_settings_strict(&provider.settings_config)?;
|
||||
write_gemini_env_atomic(&env_map)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(config_value) = config_to_write {
|
||||
let settings_path = get_gemini_settings_path();
|
||||
write_json_file(&settings_path, &config_value)?;
|
||||
}
|
||||
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
|
||||
GeminiAuthType::Packycode => ensure_packycode_security_flag(provider)?,
|
||||
GeminiAuthType::Generic => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
647
src-tauri/src/services/provider/mod.rs
Normal file
647
src-tauri/src/services/provider/mod.rs
Normal file
@@ -0,0 +1,647 @@
|
||||
//! Provider service module
|
||||
//!
|
||||
//! Handles provider CRUD operations, switching, and configuration management.
|
||||
|
||||
mod endpoints;
|
||||
mod gemini_auth;
|
||||
mod live;
|
||||
mod usage;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config::write_codex_live_atomic;
|
||||
use crate::config::{get_claude_settings_path, write_json_file};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{Provider, UsageResult};
|
||||
use crate::services::mcp::McpService;
|
||||
use crate::settings::CustomEndpoint;
|
||||
use crate::store::AppState;
|
||||
|
||||
// Re-export sub-module functions for external access
|
||||
pub use live::{import_default_config, read_live_settings, sync_current_from_db};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
pub(crate) use live::write_live_snapshot;
|
||||
|
||||
// Internal re-exports
|
||||
use gemini_auth::{
|
||||
detect_gemini_auth_type, ensure_google_oauth_security_flag, ensure_packycode_security_flag,
|
||||
GeminiAuthType,
|
||||
};
|
||||
use live::write_gemini_live;
|
||||
use usage::validate_usage_script;
|
||||
|
||||
/// Provider business logic service
|
||||
pub struct ProviderService;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn validate_provider_settings_rejects_missing_auth() {
|
||||
let provider = Provider::with_id(
|
||||
"codex".into(),
|
||||
"Codex".into(),
|
||||
json!({ "config": "base_url = \"https://example.com\"" }),
|
||||
None,
|
||||
);
|
||||
let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider)
|
||||
.expect_err("missing auth should be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("auth"),
|
||||
"expected auth error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_credentials_returns_expected_values() {
|
||||
let provider = Provider::with_id(
|
||||
"claude".into(),
|
||||
"Claude".into(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "token",
|
||||
"ANTHROPIC_BASE_URL": "https://claude.example"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let (api_key, base_url) =
|
||||
ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap();
|
||||
assert_eq!(api_key, "token");
|
||||
assert_eq!(base_url, "https://claude.example");
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderService {
|
||||
fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {
|
||||
if matches!(app_type, AppType::Claude) {
|
||||
let mut v = provider.settings_config.clone();
|
||||
if normalize_claude_models_in_value(&mut v) {
|
||||
provider.settings_config = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all providers for an app type
|
||||
pub fn list(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||
state.db.get_all_providers(app_type.as_str())
|
||||
}
|
||||
|
||||
/// Get current provider ID
|
||||
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_current_provider(app_type.as_str())
|
||||
.map(|opt| opt.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Add a new provider
|
||||
pub fn add(state: &AppState, app_type: AppType, provider: Provider) -> Result<bool, AppError> {
|
||||
let mut provider = provider;
|
||||
// Normalize Claude model keys
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
// Check if sync is needed (if this is current provider, or no current provider)
|
||||
let current = state.db.get_current_provider(app_type.as_str())?;
|
||||
if current.is_none() {
|
||||
// No current provider, set as current and sync
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Update a provider
|
||||
pub fn update(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider: Provider,
|
||||
) -> Result<bool, AppError> {
|
||||
let mut provider = provider;
|
||||
// Normalize Claude model keys
|
||||
Self::normalize_provider_if_claude(&app_type, &mut provider);
|
||||
Self::validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// Check if this is current provider
|
||||
let current_id = state.db.get_current_provider(app_type.as_str())?;
|
||||
let is_current = current_id.as_deref() == Some(provider.id.as_str());
|
||||
|
||||
// Save to database
|
||||
state.db.save_provider(app_type.as_str(), &provider)?;
|
||||
|
||||
if is_current {
|
||||
write_live_snapshot(&app_type, &provider)?;
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Delete a provider
|
||||
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
let current = state.db.get_current_provider(app_type.as_str())?;
|
||||
if current.as_deref() == Some(id) {
|
||||
return Err(AppError::Message(
|
||||
"无法删除当前正在使用的供应商".to_string(),
|
||||
));
|
||||
}
|
||||
state.db.delete_provider(app_type.as_str(), id)
|
||||
}
|
||||
|
||||
/// Switch to a provider
|
||||
///
|
||||
/// Switch flow:
|
||||
/// 1. Validate target provider exists
|
||||
/// 2. **Backfill mechanism**: Backfill current live config to current provider, protect user manual modifications
|
||||
/// 3. Set new current provider
|
||||
/// 4. Write target provider config to live files
|
||||
/// 5. Gemini additional security flag handling
|
||||
/// 6. Sync MCP configuration
|
||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
|
||||
// Check if provider exists
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers
|
||||
.get(id)
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||
|
||||
// Backfill: Backfill current live config to current provider
|
||||
if let Some(current_id) = state.db.get_current_provider(app_type.as_str())? {
|
||||
if current_id != id {
|
||||
// Only backfill when switching to a different provider
|
||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||
current_provider.settings_config = live_config;
|
||||
// Ignore backfill failure, don't affect switch flow
|
||||
let _ = state.db.save_provider(app_type.as_str(), ¤t_provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set current
|
||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||
|
||||
// Sync to live
|
||||
write_live_snapshot(&app_type, provider)?;
|
||||
|
||||
// Gemini needs additional security flag handling (PackyCode or Google OAuth)
|
||||
if matches!(app_type, AppType::Gemini) {
|
||||
let auth_type = detect_gemini_auth_type(provider);
|
||||
match auth_type {
|
||||
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
|
||||
GeminiAuthType::Packycode => ensure_packycode_security_flag(provider)?,
|
||||
GeminiAuthType::Generic => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync MCP
|
||||
McpService::sync_all_enabled(state)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync current provider from database to live configuration (re-export)
|
||||
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
|
||||
sync_current_from_db(state)
|
||||
}
|
||||
|
||||
/// Import default configuration from live files (re-export)
|
||||
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<(), AppError> {
|
||||
import_default_config(state, app_type)
|
||||
}
|
||||
|
||||
/// Read current live settings (re-export)
|
||||
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
read_live_settings(app_type)
|
||||
}
|
||||
|
||||
/// Get custom endpoints list (re-export)
|
||||
pub fn get_custom_endpoints(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<Vec<CustomEndpoint>, AppError> {
|
||||
endpoints::get_custom_endpoints(state, app_type, provider_id)
|
||||
}
|
||||
|
||||
/// Add custom endpoint (re-export)
|
||||
pub fn add_custom_endpoint(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
url: String,
|
||||
) -> Result<(), AppError> {
|
||||
endpoints::add_custom_endpoint(state, app_type, provider_id, url)
|
||||
}
|
||||
|
||||
/// Remove custom endpoint (re-export)
|
||||
pub fn remove_custom_endpoint(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
url: String,
|
||||
) -> Result<(), AppError> {
|
||||
endpoints::remove_custom_endpoint(state, app_type, provider_id, url)
|
||||
}
|
||||
|
||||
/// Update endpoint last used timestamp (re-export)
|
||||
pub fn update_endpoint_last_used(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
url: String,
|
||||
) -> Result<(), AppError> {
|
||||
endpoints::update_endpoint_last_used(state, app_type, provider_id, url)
|
||||
}
|
||||
|
||||
/// Update provider sort order
|
||||
pub fn update_sort_order(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
updates: Vec<ProviderSortUpdate>,
|
||||
) -> Result<bool, AppError> {
|
||||
let mut providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
for update in updates {
|
||||
if let Some(provider) = providers.get_mut(&update.id) {
|
||||
provider.sort_index = Some(update.sort_index);
|
||||
state.db.save_provider(app_type.as_str(), provider)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Query provider usage (re-export)
|
||||
pub async fn query_usage(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
usage::query_usage(state, app_type, provider_id).await
|
||||
}
|
||||
|
||||
/// Test usage script (re-export)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn test_usage_script(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
script_code: &str,
|
||||
timeout: u64,
|
||||
api_key: Option<&str>,
|
||||
base_url: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
usage::test_usage_script(
|
||||
state,
|
||||
app_type,
|
||||
provider_id,
|
||||
script_code,
|
||||
timeout,
|
||||
api_key,
|
||||
base_url,
|
||||
access_token,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn write_codex_live(provider: &Provider) -> Result<(), AppError> {
|
||||
let settings = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
|
||||
let auth = settings
|
||||
.get("auth")
|
||||
.ok_or_else(|| AppError::Config(format!("供应商 {} 缺少 auth 配置", provider.id)))?;
|
||||
if !auth.is_object() {
|
||||
return Err(AppError::Config(format!(
|
||||
"供应商 {} 的 auth 必须是对象",
|
||||
provider.id
|
||||
)));
|
||||
}
|
||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||
|
||||
write_codex_live_atomic(auth, cfg_text)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn write_claude_live(provider: &Provider) -> Result<(), AppError> {
|
||||
let settings_path = get_claude_settings_path();
|
||||
let mut content = provider.settings_config.clone();
|
||||
let _ = normalize_claude_models_in_value(&mut content);
|
||||
write_json_file(&settings_path, &content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
write_gemini_live(provider)
|
||||
}
|
||||
|
||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err(AppError::localized(
|
||||
"provider.claude.settings.not_object",
|
||||
"Claude 配置必须是 JSON 对象",
|
||||
"Claude configuration must be a JSON object",
|
||||
));
|
||||
}
|
||||
}
|
||||
AppType::Codex => {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.codex.settings.not_object",
|
||||
"Codex 配置必须是 JSON 对象",
|
||||
"Codex configuration must be a JSON object",
|
||||
)
|
||||
})?;
|
||||
|
||||
let auth = settings.get("auth").ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.codex.auth.missing",
|
||||
format!("供应商 {} 缺少 auth 配置", provider.id),
|
||||
format!("Provider {} is missing auth configuration", provider.id),
|
||||
)
|
||||
})?;
|
||||
if !auth.is_object() {
|
||||
return Err(AppError::localized(
|
||||
"provider.codex.auth.not_object",
|
||||
format!("供应商 {} 的 auth 配置必须是 JSON 对象", provider.id),
|
||||
format!(
|
||||
"Provider {} auth configuration must be a JSON object",
|
||||
provider.id
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(config_value) = settings.get("config") {
|
||||
if !(config_value.is_string() || config_value.is_null()) {
|
||||
return Err(AppError::localized(
|
||||
"provider.codex.config.invalid_type",
|
||||
"Codex config 字段必须是字符串",
|
||||
"Codex config field must be a string",
|
||||
));
|
||||
}
|
||||
if let Some(cfg_text) = config_value.as_str() {
|
||||
crate::codex_config::validate_config_toml(cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
AppType::Gemini => {
|
||||
use crate::gemini_config::validate_gemini_settings;
|
||||
validate_gemini_settings(&provider.settings_config)?
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and clean UsageScript configuration (common for all app types)
|
||||
if let Some(meta) = &provider.meta {
|
||||
if let Some(usage_script) = &meta.usage_script {
|
||||
validate_usage_script(usage_script)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn extract_credentials(
|
||||
provider: &Provider,
|
||||
app_type: &AppType,
|
||||
) -> Result<(String, String), AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
let env = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.claude.env.missing",
|
||||
"配置格式错误: 缺少 env",
|
||||
"Invalid configuration: missing env section",
|
||||
)
|
||||
})?;
|
||||
|
||||
let api_key = env
|
||||
.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.claude.api_key.missing",
|
||||
"缺少 API Key",
|
||||
"API key is missing",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let base_url = env
|
||||
.get("ANTHROPIC_BASE_URL")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.claude.base_url.missing",
|
||||
"缺少 ANTHROPIC_BASE_URL 配置",
|
||||
"Missing ANTHROPIC_BASE_URL configuration",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
.get("auth")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.codex.auth.missing",
|
||||
"配置格式错误: 缺少 auth",
|
||||
"Invalid configuration: missing auth section",
|
||||
)
|
||||
})?;
|
||||
|
||||
let api_key = auth
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.codex.api_key.missing",
|
||||
"缺少 API Key",
|
||||
"API key is missing",
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let config_toml = provider
|
||||
.settings_config
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let base_url = if config_toml.contains("base_url") {
|
||||
let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| {
|
||||
AppError::localized(
|
||||
"provider.regex_init_failed",
|
||||
format!("正则初始化失败: {e}"),
|
||||
format!("Failed to initialize regex: {e}"),
|
||||
)
|
||||
})?;
|
||||
re.captures(config_toml)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.codex.base_url.invalid",
|
||||
"config.toml 中 base_url 格式错误",
|
||||
"base_url in config.toml has invalid format",
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
return Err(AppError::localized(
|
||||
"provider.codex.base_url.missing",
|
||||
"config.toml 中缺少 base_url 配置",
|
||||
"base_url is missing from config.toml",
|
||||
));
|
||||
};
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::Gemini => {
|
||||
use crate::gemini_config::json_to_env;
|
||||
|
||||
let env_map = json_to_env(&provider.settings_config)?;
|
||||
|
||||
let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"gemini.missing_api_key",
|
||||
"缺少 GEMINI_API_KEY",
|
||||
"Missing GEMINI_API_KEY",
|
||||
)
|
||||
})?;
|
||||
|
||||
let base_url = env_map
|
||||
.get("GOOGLE_GEMINI_BASE_URL")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn app_not_found(app_type: &AppType) -> AppError {
|
||||
AppError::localized(
|
||||
"provider.app_not_found",
|
||||
format!("应用类型不存在: {app_type:?}"),
|
||||
format!("App type not found: {app_type:?}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize Claude model keys in a JSON value
|
||||
///
|
||||
/// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key.
|
||||
pub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
|
||||
let mut changed = false;
|
||||
let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
Some(obj) => obj,
|
||||
None => return changed,
|
||||
};
|
||||
|
||||
let model = env
|
||||
.get("ANTHROPIC_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let small_fast = env
|
||||
.get("ANTHROPIC_SMALL_FAST_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let current_haiku = env
|
||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let current_sonnet = env
|
||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let current_opus = env
|
||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let target_haiku = current_haiku
|
||||
.or_else(|| small_fast.clone())
|
||||
.or_else(|| model.clone());
|
||||
let target_sonnet = current_sonnet
|
||||
.or_else(|| model.clone())
|
||||
.or_else(|| small_fast.clone());
|
||||
let target_opus = current_opus
|
||||
.or_else(|| model.clone())
|
||||
.or_else(|| small_fast.clone());
|
||||
|
||||
if env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").is_none() {
|
||||
if let Some(v) = target_haiku {
|
||||
env.insert(
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
|
||||
Value::String(v),
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").is_none() {
|
||||
if let Some(v) = target_sonnet {
|
||||
env.insert(
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
|
||||
Value::String(v),
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if env.get("ANTHROPIC_DEFAULT_OPUS_MODEL").is_none() {
|
||||
if let Some(v) = target_opus {
|
||||
env.insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), Value::String(v));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if env.remove("ANTHROPIC_SMALL_FAST_MODEL").is_some() {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProviderSortUpdate {
|
||||
pub id: String,
|
||||
#[serde(rename = "sortIndex")]
|
||||
pub sort_index: usize,
|
||||
}
|
||||
182
src-tauri/src/services/provider/usage.rs
Normal file
182
src-tauri/src/services/provider/usage.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
//! Usage script execution
|
||||
//!
|
||||
//! Handles executing and formatting usage query results.
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{UsageData, UsageResult, UsageScript};
|
||||
use crate::settings;
|
||||
use crate::store::AppState;
|
||||
use crate::usage_script;
|
||||
|
||||
/// Execute usage script and format result (private helper method)
|
||||
pub(crate) async fn execute_and_format_usage_result(
|
||||
script_code: &str,
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
timeout: u64,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
match usage_script::execute_usage_script(
|
||||
script_code,
|
||||
api_key,
|
||||
base_url,
|
||||
timeout,
|
||||
access_token,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
let usage_list: Vec<UsageData> = if data.is_array() {
|
||||
serde_json::from_value(data).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.data_format_error",
|
||||
format!("数据格式错误: {e}"),
|
||||
format!("Data format error: {e}"),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
let single: UsageData = serde_json::from_value(data).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage_script.data_format_error",
|
||||
format!("数据格式错误: {e}"),
|
||||
format!("Data format error: {e}"),
|
||||
)
|
||||
})?;
|
||||
vec![single]
|
||||
};
|
||||
|
||||
Ok(UsageResult {
|
||||
success: true,
|
||||
data: Some(usage_list),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
let msg = match err {
|
||||
AppError::Localized { zh, en, .. } => {
|
||||
if lang == "en" {
|
||||
en
|
||||
} else {
|
||||
zh
|
||||
}
|
||||
}
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
Ok(UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(msg),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query provider usage (using saved script configuration)
|
||||
pub async fn query_usage(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
let (script_code, timeout, api_key, base_url, access_token, user_id) = {
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.not_found",
|
||||
format!("供应商不存在: {provider_id}"),
|
||||
format!("Provider not found: {provider_id}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let usage_script = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.usage_script.as_ref())
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.usage.script.missing",
|
||||
"未配置用量查询脚本",
|
||||
"Usage script is not configured",
|
||||
)
|
||||
})?;
|
||||
if !usage_script.enabled {
|
||||
return Err(AppError::localized(
|
||||
"provider.usage.disabled",
|
||||
"用量查询未启用",
|
||||
"Usage query is disabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Get credentials directly from UsageScript, no longer extract from provider config
|
||||
(
|
||||
usage_script.code.clone(),
|
||||
usage_script.timeout.unwrap_or(10),
|
||||
usage_script.api_key.clone().unwrap_or_default(),
|
||||
usage_script.base_url.clone().unwrap_or_default(),
|
||||
usage_script.access_token.clone(),
|
||||
usage_script.user_id.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
execute_and_format_usage_result(
|
||||
&script_code,
|
||||
&api_key,
|
||||
&base_url,
|
||||
timeout,
|
||||
access_token.as_deref(),
|
||||
user_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Test usage script (using temporary script content, not saved)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn test_usage_script(
|
||||
_state: &AppState,
|
||||
_app_type: AppType,
|
||||
_provider_id: &str,
|
||||
script_code: &str,
|
||||
timeout: u64,
|
||||
api_key: Option<&str>,
|
||||
base_url: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
// Use provided credential parameters directly for testing
|
||||
execute_and_format_usage_result(
|
||||
script_code,
|
||||
api_key.unwrap_or(""),
|
||||
base_url.unwrap_or(""),
|
||||
timeout,
|
||||
access_token,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Validate UsageScript configuration (boundary checks)
|
||||
pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError> {
|
||||
// Validate auto query interval (0-1440 minutes, max 24 hours)
|
||||
if let Some(interval) = script.auto_query_interval {
|
||||
if interval > 1440 {
|
||||
return Err(AppError::localized(
|
||||
"usage_script.interval_too_large",
|
||||
format!(
|
||||
"自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"
|
||||
),
|
||||
format!(
|
||||
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user