use indexmap::IndexMap; use tauri::{Emitter, State}; use crate::app_config::AppType; use crate::commands::copilot::CopilotAuthState; use crate::error::AppError; use crate::provider::{ClaudeDesktopMode, Provider}; use crate::services::{ EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult, }; use crate::store::AppState; use std::str::FromStr; // 常量定义 const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot"; const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan"; const TEMPLATE_TYPE_BALANCE: &str = "balance"; const COPILOT_UNIT_PREMIUM: &str = "requests"; /// 获取所有供应商 #[tauri::command] pub fn get_providers( state: State<'_, AppState>, app: String, ) -> Result, String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string()) } #[tauri::command] pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string()) } #[tauri::command] pub fn add_provider( state: State<'_, AppState>, app: String, provider: Provider, #[allow(non_snake_case)] addToLive: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::add(state.inner(), app_type, provider, addToLive.unwrap_or(true)) .map_err(|e| e.to_string()) } #[tauri::command] pub fn update_provider( state: State<'_, AppState>, app: String, provider: Provider, #[allow(non_snake_case)] originalId: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::update(state.inner(), app_type, originalId.as_deref(), provider) .map_err(|e| e.to_string()) } #[tauri::command] pub fn delete_provider( state: State<'_, AppState>, app: String, id: String, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::delete(state.inner(), app_type, &id) .map(|_| true) .map_err(|e| e.to_string()) } #[tauri::command] pub fn remove_provider_from_live_config( state: tauri::State<'_, AppState>, app: String, id: String, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::remove_from_live_config(state.inner(), app_type, &id) .map(|_| true) .map_err(|e| e.to_string()) } fn switch_provider_internal( state: &AppState, app_type: AppType, id: &str, ) -> Result { ProviderService::switch(state, app_type, id) } #[cfg_attr(not(feature = "test-hooks"), doc(hidden))] pub fn switch_provider_test_hook( state: &AppState, app_type: AppType, id: &str, ) -> Result { switch_provider_internal(state, app_type, id) } #[tauri::command] pub fn switch_provider( state: State<'_, AppState>, app: String, id: String, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; switch_provider_internal(&state, app_type, &id).map_err(|e| e.to_string()) } fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result { let imported = ProviderService::import_default_config(state, app_type.clone())?; if imported { // Extract common config snippet (mirrors old startup logic in lib.rs) if state .db .should_auto_extract_config_snippet(app_type.as_str())? { match ProviderService::extract_common_config_snippet(state, app_type.clone()) { Ok(snippet) if !snippet.is_empty() && snippet != "{}" => { let _ = state .db .set_config_snippet(app_type.as_str(), Some(snippet)); let _ = state .db .set_config_snippet_cleared(app_type.as_str(), false); } _ => {} } } ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?; } Ok(imported) } #[cfg_attr(not(feature = "test-hooks"), doc(hidden))] pub fn import_default_config_test_hook( state: &AppState, app_type: AppType, ) -> Result { import_default_config_internal(state, app_type) } #[tauri::command] pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; import_default_config_internal(&state, app_type).map_err(Into::into) } #[tauri::command] pub async fn get_claude_desktop_status( state: State<'_, AppState>, ) -> Result { let proxy_running = state.proxy_service.is_running().await; crate::claude_desktop_config::get_status(state.db.as_ref(), proxy_running) .map_err(|e| e.to_string()) } #[tauri::command] pub fn get_claude_desktop_default_routes( ) -> Vec { crate::claude_desktop_config::default_proxy_routes() } #[tauri::command] pub fn import_claude_desktop_providers_from_claude( state: State<'_, AppState>, ) -> Result { let claude_providers = state .db .get_all_providers(AppType::Claude.as_str()) .map_err(|e| e.to_string())?; let existing_ids = state .db .get_provider_ids(AppType::ClaudeDesktop.as_str()) .map_err(|e| e.to_string())?; let mut imported = 0usize; for provider in claude_providers.values() { if existing_ids.contains(&provider.id) { continue; } let mut desktop_provider = provider.clone(); desktop_provider.in_failover_queue = false; let meta = desktop_provider.meta.get_or_insert_with(Default::default); if crate::claude_desktop_config::is_compatible_direct_provider(provider) && claude_provider_models_are_claude_safe(provider) { meta.claude_desktop_mode = Some(ClaudeDesktopMode::Direct); } else if let Some(routes) = suggested_claude_desktop_routes(provider) { meta.claude_desktop_mode = Some(ClaudeDesktopMode::Proxy); meta.claude_desktop_model_routes = routes; } else { continue; } state .db .save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider) .map_err(|e| e.to_string())?; imported += 1; } Ok(imported) } fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool { let Some(env) = provider .settings_config .get("env") .and_then(|value| value.as_object()) else { return true; }; [ "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", ] .into_iter() .filter_map(|key| env.get(key).and_then(|value| value.as_str())) .map(str::trim) .filter(|value| !value.is_empty()) .all(crate::claude_desktop_config::is_claude_safe_model_id) } fn suggested_claude_desktop_routes( provider: &Provider, ) -> Option> { let env = provider .settings_config .get("env") .and_then(|value| value.as_object())?; let mut routes = std::collections::HashMap::new(); let supports_1m = !matches!( provider .meta .as_ref() .and_then(|meta| meta.provider_type.as_deref()), Some("github_copilot") | Some("codex_oauth") ); fn add_route( routes: &mut std::collections::HashMap, env: &serde_json::Map, route_id: &str, env_key: &str, supports_1m: bool, ) { if let Some(model) = env .get(env_key) .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) { routes.insert( route_id.to_string(), crate::provider::ClaudeDesktopModelRoute { model: model.to_string(), supports_1m: Some(supports_1m), }, ); } } for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES { add_route(&mut routes, env, spec.route_id, spec.env_key, supports_1m); } let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0]; if !routes.contains_key(primary_route.route_id) { add_route( &mut routes, env, primary_route.route_id, "ANTHROPIC_MODEL", supports_1m, ); } (!routes.is_empty()).then_some(routes) } #[allow(non_snake_case)] #[tauri::command] pub async fn queryProviderUsage( app_handle: tauri::AppHandle, state: State<'_, AppState>, copilot_state: State<'_, CopilotAuthState>, #[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端 app: String, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; // inner 可能以两种形式失败: // 1) 返回 Ok(UsageResult { success: false, .. }) —— 业务失败(401、脚本报错等) // 2) 返回 Err(String) —— RPC/DB/Copilot fetch_usage 等 transport 层失败 // 两种都要把"失败"写进 UsageCache 并刷新托盘,让 format_script_summary 的 // success 守卫生效、suffix 自然消失,避免旧 success 快照长期滞留。 // 同时保持原始 Err 返回给前端 React Query 的 onError 回调,不吞错误。 let inner = query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await; let snapshot = match &inner { Ok(r) => r.clone(), Err(err_msg) => crate::provider::UsageResult { success: false, data: None, error: Some(err_msg.clone()), }, }; let payload = serde_json::json!({ "kind": "script", "appType": app_type.as_str(), "providerId": &providerId, "data": &snapshot, }); if let Err(e) = app_handle.emit("usage-cache-updated", payload) { log::error!("emit usage-cache-updated (script) 失败: {e}"); } state.usage_cache.put_script(app_type, providerId, snapshot); crate::tray::schedule_tray_refresh(&app_handle); inner } async fn query_provider_usage_inner( state: &AppState, copilot_state: &CopilotAuthState, app_type: AppType, provider_id: &str, ) -> Result { // 从数据库读取供应商信息,检查特殊模板类型 let providers = state .db .get_all_providers(app_type.as_str()) .map_err(|e| format!("Failed to get providers: {e}"))?; let provider = providers.get(provider_id); let usage_script = provider .and_then(|p| p.meta.as_ref()) .and_then(|m| m.usage_script.as_ref()); let template_type = usage_script .and_then(|s| s.template_type.as_deref()) .unwrap_or(""); // ── GitHub Copilot 专用路径 ── if template_type == TEMPLATE_TYPE_GITHUB_COPILOT { let copilot_account_id = provider .and_then(|p| p.meta.as_ref()) .and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT)); let auth_manager = copilot_state.0.read().await; let usage = match copilot_account_id.as_deref() { Some(account_id) => auth_manager .fetch_usage_for_account(account_id) .await .map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?, None => auth_manager .fetch_usage() .await .map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?, }; let premium = &usage.quota_snapshots.premium_interactions; let used = premium.entitlement - premium.remaining; return Ok(crate::provider::UsageResult { success: true, data: Some(vec![crate::provider::UsageData { plan_name: Some(usage.copilot_plan), remaining: Some(premium.remaining as f64), total: Some(premium.entitlement as f64), used: Some(used as f64), unit: Some(COPILOT_UNIT_PREMIUM.to_string()), is_valid: Some(true), invalid_message: None, extra: Some(format!("Reset: {}", usage.quota_reset_date)), }]), error: None, }); } // ── Coding Plan 专用路径 ── if template_type == TEMPLATE_TYPE_TOKEN_PLAN { // 从供应商配置中提取 API Key 和 Base URL let settings_config = provider .map(|p| &p.settings_config) .cloned() .unwrap_or_default(); let env = settings_config.get("env"); let base_url = env .and_then(|e| e.get("ANTHROPIC_BASE_URL")) .and_then(|v| v.as_str()) .unwrap_or(""); let api_key = env .and_then(|e| { e.get("ANTHROPIC_AUTH_TOKEN") .or_else(|| e.get("ANTHROPIC_API_KEY")) }) .and_then(|v| v.as_str()) .unwrap_or(""); let quota = crate::services::coding_plan::get_coding_plan_quota(base_url, api_key) .await .map_err(|e| format!("Failed to query coding plan: {e}"))?; // 将 SubscriptionQuota 转换为 UsageResult if !quota.success { return Ok(crate::provider::UsageResult { success: false, data: None, error: quota.error, }); } let data: Vec = quota .tiers .iter() .map(|tier| { let total = 100.0; let used = tier.utilization; let remaining = total - used; crate::provider::UsageData { plan_name: Some(tier.name.clone()), remaining: Some(remaining), total: Some(total), used: Some(used), unit: Some("%".to_string()), is_valid: Some(true), invalid_message: None, extra: tier.resets_at.clone(), } }) .collect(); return Ok(crate::provider::UsageResult { success: true, data: if data.is_empty() { None } else { Some(data) }, error: None, }); } // ── 官方余额查询路径 ── if template_type == TEMPLATE_TYPE_BALANCE { let settings_config = provider .map(|p| &p.settings_config) .cloned() .unwrap_or_default(); let env = settings_config.get("env"); let base_url = env .and_then(|e| e.get("ANTHROPIC_BASE_URL")) .and_then(|v| v.as_str()) .unwrap_or(""); let api_key = env .and_then(|e| { e.get("ANTHROPIC_AUTH_TOKEN") .or_else(|| e.get("ANTHROPIC_API_KEY")) }) .and_then(|v| v.as_str()) .unwrap_or(""); return crate::services::balance::get_balance(base_url, api_key) .await .map_err(|e| format!("Failed to query balance: {e}")); } // ── 通用 JS 脚本路径 ── ProviderService::query_usage(state, app_type, provider_id) .await .map_err(|e| e.to_string()) } #[allow(non_snake_case)] #[allow(clippy::too_many_arguments)] #[tauri::command] pub async fn testUsageScript( state: State<'_, AppState>, #[allow(non_snake_case)] providerId: String, app: String, #[allow(non_snake_case)] scriptCode: String, timeout: Option, #[allow(non_snake_case)] apiKey: Option, #[allow(non_snake_case)] baseUrl: Option, #[allow(non_snake_case)] accessToken: Option, #[allow(non_snake_case)] userId: Option, #[allow(non_snake_case)] templateType: Option, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::test_usage_script( state.inner(), app_type, &providerId, &scriptCode, timeout.unwrap_or(10), apiKey.as_deref(), baseUrl.as_deref(), accessToken.as_deref(), userId.as_deref(), templateType.as_deref(), ) .await .map_err(|e| e.to_string()) } #[tauri::command] pub fn read_live_provider_settings(app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::read_live_settings(app_type).map_err(|e| e.to_string()) } #[tauri::command] pub async fn test_api_endpoints( urls: Vec, #[allow(non_snake_case)] timeoutSecs: Option, ) -> Result, String> { SpeedtestService::test_endpoints(urls, timeoutSecs) .await .map_err(|e| e.to_string()) } #[tauri::command] pub fn get_custom_endpoints( state: State<'_, AppState>, app: String, #[allow(non_snake_case)] providerId: String, ) -> Result, String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::get_custom_endpoints(state.inner(), app_type, &providerId) .map_err(|e| e.to_string()) } #[tauri::command] pub fn add_custom_endpoint( state: State<'_, AppState>, app: String, #[allow(non_snake_case)] providerId: String, url: String, ) -> Result<(), String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::add_custom_endpoint(state.inner(), app_type, &providerId, url) .map_err(|e| e.to_string()) } #[tauri::command] pub fn remove_custom_endpoint( state: State<'_, AppState>, app: String, #[allow(non_snake_case)] providerId: String, url: String, ) -> Result<(), String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::remove_custom_endpoint(state.inner(), app_type, &providerId, url) .map_err(|e| e.to_string()) } #[tauri::command] pub fn update_endpoint_last_used( state: State<'_, AppState>, app: String, #[allow(non_snake_case)] providerId: String, url: String, ) -> Result<(), String> { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::update_endpoint_last_used(state.inner(), app_type, &providerId, url) .map_err(|e| e.to_string()) } #[tauri::command] pub fn update_providers_sort_order( state: State<'_, AppState>, app: String, updates: Vec, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string()) } use crate::provider::UniversalProvider; use std::collections::HashMap; use tauri::AppHandle; #[derive(Clone, serde::Serialize)] pub struct UniversalProviderSyncedEvent { pub action: String, pub id: String, } fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) { let _ = app.emit( "universal-provider-synced", UniversalProviderSyncedEvent { action: action.to_string(), id: id.to_string(), }, ); } #[tauri::command] pub fn get_universal_providers( state: State<'_, AppState>, ) -> Result, String> { ProviderService::list_universal(state.inner()).map_err(|e| e.to_string()) } #[tauri::command] pub fn get_universal_provider( state: State<'_, AppState>, id: String, ) -> Result, String> { ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string()) } #[tauri::command] pub fn upsert_universal_provider( app: AppHandle, state: State<'_, AppState>, provider: UniversalProvider, ) -> Result { let id = provider.id.clone(); let result = ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?; emit_universal_provider_synced(&app, "upsert", &id); Ok(result) } #[tauri::command] pub fn delete_universal_provider( app: AppHandle, state: State<'_, AppState>, id: String, ) -> Result { let result = ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?; emit_universal_provider_synced(&app, "delete", &id); Ok(result) } #[tauri::command] pub fn sync_universal_provider( app: AppHandle, state: State<'_, AppState>, id: String, ) -> Result { let result = ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?; emit_universal_provider_synced(&app, "sync", &id); Ok(result) } #[tauri::command] pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result { crate::services::provider::import_opencode_providers_from_live(state.inner()) .map_err(|e| e.to_string()) } #[tauri::command] pub fn get_opencode_live_provider_ids() -> Result, String> { crate::opencode_config::get_providers() .map(|providers| providers.keys().cloned().collect()) .map_err(|e| e.to_string()) } // ============================================================================ // OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs // ============================================================================