mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
bfdac2a22a
Add a new "Token Plan" template type in the usage query panel that natively queries quota/usage from Chinese coding plan providers (Kimi For Coding, Zhipu GLM, MiniMax) without requiring custom scripts. - Rust backend: new coding_plan service with provider-specific API queries (Kimi /v1/usages, Zhipu /api/monitor/usage/quota/limit, MiniMax /coding_plan/remains) normalized into UsageResult - Frontend: Token Plan template in UsageScriptModal with auto-detection of provider based on ANTHROPIC_BASE_URL pattern matching - Follows the same pattern as GitHub Copilot template (dedicated API path in queryProviderUsage, no JS script needed)
476 lines
15 KiB
Rust
476 lines
15 KiB
Rust
use indexmap::IndexMap;
|
|
use tauri::State;
|
|
|
|
use crate::app_config::AppType;
|
|
use crate::commands::copilot::CopilotAuthState;
|
|
use crate::error::AppError;
|
|
use crate::provider::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 COPILOT_UNIT_PREMIUM: &str = "requests";
|
|
|
|
/// 获取所有供应商
|
|
#[tauri::command]
|
|
pub fn get_providers(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
) -> Result<IndexMap<String, Provider>, 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<String, String> {
|
|
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<bool>,
|
|
) -> Result<bool, String> {
|
|
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<String>,
|
|
) -> Result<bool, String> {
|
|
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<bool, String> {
|
|
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<bool, String> {
|
|
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<SwitchResult, AppError> {
|
|
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<SwitchResult, AppError> {
|
|
switch_provider_internal(state, app_type, id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn switch_provider(
|
|
state: State<'_, AppState>,
|
|
app: String,
|
|
id: String,
|
|
) -> Result<SwitchResult, String> {
|
|
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<bool, AppError> {
|
|
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<bool, AppError> {
|
|
import_default_config_internal(state, app_type)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<bool, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
import_default_config_internal(&state, app_type).map_err(Into::into)
|
|
}
|
|
|
|
#[allow(non_snake_case)]
|
|
#[tauri::command]
|
|
pub async fn queryProviderUsage(
|
|
state: State<'_, AppState>,
|
|
copilot_state: State<'_, CopilotAuthState>,
|
|
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
|
app: String,
|
|
) -> Result<crate::provider::UsageResult, String> {
|
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
|
|
|
// 从数据库读取供应商信息,检查特殊模板类型
|
|
let providers = state
|
|
.db
|
|
.get_all_providers(app_type.as_str())
|
|
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
|
let provider = providers.get(&providerId);
|
|
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<crate::provider::UsageData> = 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,
|
|
});
|
|
}
|
|
|
|
// ── 通用 JS 脚本路径 ──
|
|
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
|
.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<u64>,
|
|
#[allow(non_snake_case)] apiKey: Option<String>,
|
|
#[allow(non_snake_case)] baseUrl: Option<String>,
|
|
#[allow(non_snake_case)] accessToken: Option<String>,
|
|
#[allow(non_snake_case)] userId: Option<String>,
|
|
#[allow(non_snake_case)] templateType: Option<String>,
|
|
) -> Result<crate::provider::UsageResult, String> {
|
|
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<serde_json::Value, String> {
|
|
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<String>,
|
|
#[allow(non_snake_case)] timeoutSecs: Option<u64>,
|
|
) -> Result<Vec<EndpointLatency>, 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<Vec<crate::settings::CustomEndpoint>, 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<ProviderSortUpdate>,
|
|
) -> Result<bool, String> {
|
|
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, Emitter};
|
|
|
|
#[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<HashMap<String, UniversalProvider>, 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<Option<UniversalProvider>, 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<bool, String> {
|
|
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<bool, String> {
|
|
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<bool, String> {
|
|
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<usize, String> {
|
|
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<Vec<String>, String> {
|
|
crate::opencode_config::get_providers()
|
|
.map(|providers| providers.keys().cloned().collect())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
// ============================================================================
|
|
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
|
|
// ============================================================================
|