From bfdac2a22ac6039aa5e28825552809644c1f1ef5 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 11:39:29 +0800 Subject: [PATCH] feat: add Token Plan quota query for Kimi, Zhipu GLM, and MiniMax 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) --- src-tauri/src/commands/coding_plan.rs | 9 + src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/provider.rs | 96 ++++- src-tauri/src/deeplink/provider.rs | 1 + src-tauri/src/lib.rs | 1 + src-tauri/src/provider.rs | 4 + src-tauri/src/services/coding_plan.rs | 425 ++++++++++++++++++++++ src-tauri/src/services/mod.rs | 1 + src/components/UsageScriptModal.tsx | 295 +++++++++++---- src/components/providers/ProviderCard.tsx | 12 +- src/config/constants.ts | 1 + src/i18n/locales/en.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/zh.json | 2 + src/lib/api/subscription.ts | 5 + src/types.ts | 1 + 16 files changed, 761 insertions(+), 98 deletions(-) create mode 100644 src-tauri/src/commands/coding_plan.rs create mode 100644 src-tauri/src/services/coding_plan.rs diff --git a/src-tauri/src/commands/coding_plan.rs b/src-tauri/src/commands/coding_plan.rs new file mode 100644 index 000000000..0654cd8e8 --- /dev/null +++ b/src-tauri/src/commands/coding_plan.rs @@ -0,0 +1,9 @@ +use crate::services::subscription::SubscriptionQuota; + +#[tauri::command] +pub async fn get_coding_plan_quota( + base_url: String, + api_key: String, +) -> Result { + crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fed30fe30..4261b3da2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] mod auth; +mod coding_plan; mod config; mod copilot; mod deeplink; @@ -30,6 +31,7 @@ mod webdav_sync; mod workspace; pub use auth::*; +pub use coding_plan::*; pub use config::*; pub use copilot::*; pub use deeplink::*; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 8f4f07790..d505efb92 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -13,6 +13,7 @@ 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"; /// 获取所有供应商 @@ -158,29 +159,25 @@ pub async fn queryProviderUsage( ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - // 检查是否为 GitHub Copilot 模板类型,并解析绑定账号 - let (is_copilot_template, copilot_account_id) = { - let providers = state - .db - .get_all_providers(app_type.as_str()) - .map_err(|e| format!("Failed to get providers: {e}"))?; + // 从数据库读取供应商信息,检查特殊模板类型 + 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(""); - let provider = providers.get(&providerId); - let is_copilot = provider - .and_then(|p| p.meta.as_ref()) - .and_then(|m| m.usage_script.as_ref()) - .and_then(|s| s.template_type.as_ref()) - .map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT) - .unwrap_or(false); - let account_id = provider + // ── 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)); - (is_copilot, account_id) - }; - - if is_copilot_template { - // 使用 Copilot 专用 API let auth_manager = copilot_state.0.read().await; let usage = match copilot_account_id.as_deref() { Some(account_id) => auth_manager @@ -211,6 +208,67 @@ pub async fn queryProviderUsage( }); } + // ── 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, + }); + } + + // ── 通用 JS 脚本路径 ── ProviderService::query_usage(state.inner(), app_type, &providerId) .await .map_err(|e| e.to_string()) diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index d94189481..87e07282f 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -229,6 +229,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, + /// Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax") + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "codingPlanProvider")] + pub coding_plan_provider: Option, } /// 用量数据 diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs new file mode 100644 index 000000000..ae051d51f --- /dev/null +++ b/src-tauri/src/services/coding_plan.rs @@ -0,0 +1,425 @@ +//! 国产 Token Plan 额度查询服务 +//! +//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。 +//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。 + +use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// ── 供应商检测 ────────────────────────────────────────────── + +enum CodingPlanProvider { + Kimi, + ZhipuCn, + ZhipuEn, + MiniMaxCn, + MiniMaxEn, +} + +fn detect_provider(base_url: &str) -> Option { + let url = base_url.to_lowercase(); + if url.contains("api.kimi.com/coding") { + Some(CodingPlanProvider::Kimi) + } else if url.contains("open.bigmodel.cn") || url.contains("bigmodel.cn") { + Some(CodingPlanProvider::ZhipuCn) + } else if url.contains("api.z.ai") { + Some(CodingPlanProvider::ZhipuEn) + } else if url.contains("api.minimaxi.com") { + Some(CodingPlanProvider::MiniMaxCn) + } else if url.contains("api.minimax.io") { + Some(CodingPlanProvider::MiniMaxEn) + } else { + None + } +} + +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn millis_to_iso8601(ms: i64) -> Option { + let secs = ms / 1000; + let nsecs = ((ms % 1000) * 1_000_000) as u32; + chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339()) +} + +fn make_error(msg: String) -> SubscriptionQuota { + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: false, + tiers: vec![], + extra_usage: None, + error: Some(msg), + queried_at: Some(now_millis()), + } +} + +// ── Kimi For Coding ───────────────────────────────────────── + +async fn query_kimi(api_key: &str) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.kimi.com/coding/v1/usages") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Expired, + credential_message: Some("Invalid API key".to_string()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(format!("Authentication failed (HTTP {status})")), + queried_at: Some(now_millis()), + }; + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let mut tiers = Vec::new(); + + // 总体用量(周限额) + if let Some(usage) = body.get("usage") { + let used = usage.get("used").and_then(|v| v.as_f64()).unwrap_or(0.0); + let limit = usage.get("limit").and_then(|v| v.as_f64()).unwrap_or(1.0); + let resets_at = usage + .get("reset_at") + .or_else(|| usage.get("resetAt")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let utilization = if limit > 0.0 { + (used / limit) * 100.0 + } else { + 0.0 + }; + tiers.push(QuotaTier { + name: "weekly_limit".to_string(), + utilization, + resets_at, + }); + } + + // 会话限额(5 小时窗口) + if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + if let Some(detail) = limit_item.get("detail") { + let used = detail.get("used").and_then(|v| v.as_f64()).unwrap_or(0.0); + let limit = detail.get("limit").and_then(|v| v.as_f64()).unwrap_or(1.0); + let resets_at = detail + .get("reset_at") + .or_else(|| detail.get("resetAt")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let utilization = if limit > 0.0 { + (used / limit) * 100.0 + } else { + 0.0 + }; + tiers.push(QuotaTier { + name: "session_limit".to_string(), + utilization, + resets_at, + }); + } + } + } + + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + +// ── 智谱 GLM ──────────────────────────────────────────────── + +async fn query_zhipu(api_key: &str) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + // 统一走 api.z.ai 国际站(中国站 bigmodel.cn 有反爬机制) + let resp = client + .get("https://api.z.ai/api/monitor/usage/quota/limit") + .header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀 + .header("Content-Type", "application/json") + .header("Accept-Language", "en-US,en") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Expired, + credential_message: Some("Invalid API key".to_string()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(format!("Authentication failed (HTTP {status})")), + queried_at: Some(now_millis()), + }; + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + // 检查业务级别错误 + if body.get("success").and_then(|v| v.as_bool()) == Some(false) { + let msg = body + .get("msg") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return make_error(format!("API error: {msg}")); + } + + let data = match body.get("data") { + Some(d) => d, + None => return make_error("Missing 'data' field in response".to_string()), + }; + + let mut tiers = Vec::new(); + + if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + let limit_type = limit_item + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let percentage = limit_item + .get("percentage") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let next_reset = limit_item + .get("nextResetTime") + .and_then(|v| v.as_i64()) + .and_then(millis_to_iso8601); + + let tier_name = match limit_type { + "TOKENS_LIMIT" => "tokens_limit", + "TIME_LIMIT" => "mcp_limit", + _ => continue, + }; + + tiers.push(QuotaTier { + name: tier_name.to_string(), + utilization: percentage, + resets_at: next_reset, + }); + } + } + + // 套餐等级存入 credential_message + let level = data + .get("level") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: level, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + +// ── MiniMax ───────────────────────────────────────────────── + +async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + let api_domain = if is_cn { + "api.minimaxi.com" + } else { + "api.minimax.io" + }; + let url = format!("https://{api_domain}/v1/api/openplatform/coding_plan/remains"); + + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Content-Type", "application/json") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Expired, + credential_message: Some("Invalid API key".to_string()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(format!("Authentication failed (HTTP {status})")), + queried_at: Some(now_millis()), + }; + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + // 检查业务级别错误 + if let Some(base_resp) = body.get("base_resp") { + let status_code = base_resp + .get("status_code") + .and_then(|v| v.as_i64()) + .unwrap_or(-1); + if status_code != 0 { + let msg = base_resp + .get("status_msg") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return make_error(format!("API error (code {status_code}): {msg}")); + } + } + + let mut tiers = Vec::new(); + + if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) { + for item in model_remains { + let model_name = item + .get("model_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let total = item + .get("current_interval_total_count") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + // 注意:current_interval_usage_count 名字有误导,实际是"剩余量" + let remaining = item + .get("current_interval_usage_count") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let end_time = item.get("end_time").and_then(|v| v.as_i64()); + + let utilization = if total > 0.0 { + ((total - remaining) / total) * 100.0 + } else { + 0.0 + }; + + tiers.push(QuotaTier { + name: model_name.to_string(), + utilization, + resets_at: end_time.and_then(millis_to_iso8601), + }); + } + } + + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + +// ── 公开入口 ──────────────────────────────────────────────── + +pub async fn get_coding_plan_quota( + base_url: &str, + api_key: &str, +) -> Result { + if api_key.trim().is_empty() { + return Ok(SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::NotFound, + credential_message: None, + success: false, + tiers: vec![], + extra_usage: None, + error: None, + queried_at: None, + }); + } + + let provider = match detect_provider(base_url) { + Some(p) => p, + None => { + return Ok(SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::NotFound, + credential_message: None, + success: false, + tiers: vec![], + extra_usage: None, + error: None, + queried_at: None, + }) + } + }; + + let quota = match provider { + CodingPlanProvider::Kimi => query_kimi(api_key).await, + CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => query_zhipu(api_key).await, + CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await, + CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await, + }; + + Ok(quota) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index a7c75e162..ce5cc4dd0 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod coding_plan; pub mod config; pub mod env_checker; pub mod env_manager; diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index c9b850151..131deb267 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -95,6 +95,9 @@ const generatePresetTemplates = ( // GitHub Copilot 模板不需要脚本,使用专用 API [TEMPLATE_TYPES.GITHUB_COPILOT]: "", + + // Coding Plan 模板不需要脚本,使用专用 Rust 查询 + [TEMPLATE_TYPES.TOKEN_PLAN]: "", }); // 模板名称国际化键映射 @@ -103,8 +106,33 @@ const TEMPLATE_NAME_KEYS: Record = { [TEMPLATE_TYPES.GENERAL]: "usageScript.templateGeneral", [TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI", [TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot", + [TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan", }; +/** Coding Plan 供应商选项 */ +const TOKEN_PLAN_PROVIDERS = [ + { id: "kimi", label: "Kimi For Coding", pattern: /api\.kimi\.com\/coding/i }, + { + id: "zhipu", + label: "Zhipu GLM (智谱)", + pattern: /bigmodel\.cn|api\.z\.ai/i, + }, + { + id: "minimax", + label: "MiniMax", + pattern: /api\.minimaxi?\.com|api\.minimax\.io/i, + }, +] as const; + +/** 根据 Base URL 自动检测 Coding Plan 供应商 */ +function detectTokenPlanProvider(baseUrl: string | undefined): string | null { + if (!baseUrl) return null; + for (const cp of TOKEN_PLAN_PROVIDERS) { + if (cp.pattern.test(baseUrl)) return cp.id; + } + return null; +} + const UsageScriptModal: React.FC = ({ provider, appId, @@ -164,18 +192,39 @@ const UsageScriptModal: React.FC = ({ const [script, setScript] = useState(() => { const savedScript = provider.meta?.usage_script; - const defaultScript = { + if (savedScript) { + // 已有配置:如果是 coding_plan 但没有 codingPlanProvider,自动检测填充 + if ( + savedScript.templateType === TEMPLATE_TYPES.TOKEN_PLAN && + !savedScript.codingPlanProvider + ) { + return { + ...savedScript, + codingPlanProvider: + detectTokenPlanProvider(providerCredentials.baseUrl) || "kimi", + }; + } + return savedScript; + } + + // 新配置:如果 URL 匹配 Coding Plan,自动初始化 + const autoDetected = detectTokenPlanProvider(providerCredentials.baseUrl); + if (autoDetected) { + return { + enabled: false, + language: "javascript" as const, + code: "", + timeout: 10, + codingPlanProvider: autoDetected, + }; + } + + return { enabled: false, language: "javascript" as const, code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL], timeout: 10, }; - - if (!savedScript) { - return defaultScript; - } - - return savedScript; }); const [testing, setTesting] = useState(false); @@ -236,7 +285,7 @@ const UsageScriptModal: React.FC = ({ } // 优先使用保存的 templateType if (existingScript?.templateType) { - return existingScript.templateType; + return existingScript.templateType as string; } // 向后兼容:根据字段推断模板类型 // 检测 NEW_API 模板(有 accessToken 或 userId) @@ -247,7 +296,11 @@ const UsageScriptModal: React.FC = ({ if (existingScript?.apiKey || existingScript?.baseUrl) { return TEMPLATE_TYPES.GENERAL; } - // 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致) + // 新配置:如果 URL 匹配 Coding Plan 供应商,自动选择 Coding Plan 模板 + if (detectTokenPlanProvider(providerCredentials.baseUrl)) { + return TEMPLATE_TYPES.TOKEN_PLAN; + } + // 默认使用 GENERAL(与默认代码模板一致) return TEMPLATE_TYPES.GENERAL; }, ); @@ -278,8 +331,11 @@ const UsageScriptModal: React.FC = ({ }; const handleSave = () => { - // Copilot 模板不需要脚本验证 - if (selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT) { + // Copilot 和 Coding Plan 模板不需要脚本验证 + if ( + selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && + selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN + ) { if (script.enabled && !script.code.trim()) { toast.error(t("usageScript.scriptEmpty")); return; @@ -297,6 +353,7 @@ const UsageScriptModal: React.FC = ({ | "general" | "newapi" | "github_copilot" + | "token_plan" | undefined, }; onSave(scriptWithTemplate); @@ -306,6 +363,45 @@ const UsageScriptModal: React.FC = ({ const handleTest = async () => { setTesting(true); try { + // Coding Plan 模板使用专用 API + if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) { + const config = provider.settingsConfig as Record; + const baseUrl: string = config?.env?.ANTHROPIC_BASE_URL ?? ""; + const apiKey: string = + config?.env?.ANTHROPIC_AUTH_TOKEN ?? + config?.env?.ANTHROPIC_API_KEY ?? + ""; + const { subscriptionApi } = await import("@/lib/api/subscription"); + const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey); + if (quota.success && quota.tiers.length > 0) { + const summary = quota.tiers + .map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`) + .join(", "); + toast.success(`${t("usageScript.testSuccess")}${summary}`, { + duration: 3000, + closeButton: true, + }); + // 将结果转换为 UsageResult 格式更新缓存 + const usageData = quota.tiers.map((tier) => ({ + planName: tier.name, + remaining: 100 - tier.utilization, + total: 100, + used: tier.utilization, + unit: "%", + })); + queryClient.setQueryData(["usage", provider.id, appId], { + success: true, + data: usageData, + }); + } else { + toast.error( + `${t("usageScript.testFailed")}: ${quota.error || t("endpointTest.noResult")}`, + { duration: 5000 }, + ); + } + return; + } + // Copilot 模板使用专用 API if (selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT) { const accountId = resolveManagedAccountId( @@ -410,7 +506,7 @@ const UsageScriptModal: React.FC = ({ const handleUsePreset = (presetName: string) => { const preset = PRESET_TEMPLATES[presetName]; - if (preset) { + if (preset !== undefined) { if (presetName === TEMPLATE_TYPES.CUSTOM) { // 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换 // 这样可以避免同源检查导致的问题 @@ -447,6 +543,21 @@ const UsageScriptModal: React.FC = ({ accessToken: undefined, userId: undefined, }); + } else if (presetName === TEMPLATE_TYPES.TOKEN_PLAN) { + // Coding Plan 模板不需要脚本,使用 Rust 原生查询 + const autoDetected = detectTokenPlanProvider( + providerCredentials.baseUrl, + ); + setScript({ + ...script, + code: "", + apiKey: undefined, + baseUrl: undefined, + accessToken: undefined, + userId: undefined, + codingPlanProvider: + script.codingPlanProvider || autoDetected || "kimi", + }); } setSelectedTemplate(presetName); } @@ -529,10 +640,11 @@ const UsageScriptModal: React.FC = ({ .filter((name) => { const isCopilotProvider = provider.meta?.providerType === "github_copilot"; - // Copilot 供应商只显示 copilot 模板,其他供应商不显示 copilot 模板 + // Copilot 供应商只显示 copilot 模板 if (isCopilotProvider) { return name === TEMPLATE_TYPES.GITHUB_COPILOT; } + // 非 Copilot 供应商不显示 copilot 模板 return name !== TEMPLATE_TYPES.GITHUB_COPILOT; }) .map((name) => { @@ -634,6 +746,43 @@ const UsageScriptModal: React.FC = ({ )} + {/* Coding Plan 模式:供应商选择 */} + {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && ( +
+

+ {t("usageScript.tokenPlanHint")} +

+
+ {TOKEN_PLAN_PROVIDERS.map((cp) => ( + + ))} +
+
+ )} + {/* 凭证配置 */} {shouldShowCredentialsConfig && (
@@ -860,40 +1009,42 @@ const UsageScriptModal: React.FC = ({
{/* 提取器代码 - Copilot 模板不需要 */} - {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && ( -
-
- -
- {t("usageScript.extractorHint")} + {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && + selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && ( +
+
+ +
+ {t("usageScript.extractorHint")} +
+ + setScript((prev) => ({ ...prev, code: value })) + } + height={480} + language="javascript" + showMinimap={false} + />
- - setScript((prev) => ({ ...prev, code: value })) - } - height={480} - language="javascript" - showMinimap={false} - /> -
- )} + )} {/* 帮助信息 - Copilot 模板不需要 */} - {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && ( -
-

- {t("usageScript.scriptHelp")} -

-
-
- {t("usageScript.configFormat")} -
-                    {`({
+          {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
+            selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
+              
+

+ {t("usageScript.scriptHelp")} +

+
+
+ {t("usageScript.configFormat")} +
+                      {`({
   request: {
     url: "{{baseUrl}}/api/usage",
     method: "POST",
@@ -910,39 +1061,39 @@ const UsageScriptModal: React.FC = ({
     };
   }
 })`}
-                  
-
+
+
-
- {t("usageScript.extractorFormat")} -
    -
  • {t("usageScript.fieldIsValid")}
  • -
  • {t("usageScript.fieldInvalidMessage")}
  • -
  • {t("usageScript.fieldRemaining")}
  • -
  • {t("usageScript.fieldUnit")}
  • -
  • {t("usageScript.fieldPlanName")}
  • -
  • {t("usageScript.fieldTotal")}
  • -
  • {t("usageScript.fieldUsed")}
  • -
  • {t("usageScript.fieldExtra")}
  • -
-
+
+ {t("usageScript.extractorFormat")} +
    +
  • {t("usageScript.fieldIsValid")}
  • +
  • {t("usageScript.fieldInvalidMessage")}
  • +
  • {t("usageScript.fieldRemaining")}
  • +
  • {t("usageScript.fieldUnit")}
  • +
  • {t("usageScript.fieldPlanName")}
  • +
  • {t("usageScript.fieldTotal")}
  • +
  • {t("usageScript.fieldUsed")}
  • +
  • {t("usageScript.fieldExtra")}
  • +
+
-
- {t("usageScript.tips")} -
    -
  • - {t("usageScript.tip1", { - apiKey: "{{apiKey}}", - baseUrl: "{{baseUrl}}", - })} -
  • -
  • {t("usageScript.tip2")}
  • -
  • {t("usageScript.tip3")}
  • -
+
+ {t("usageScript.tips")} +
    +
  • + {t("usageScript.tip1", { + apiKey: "{{apiKey}}", + baseUrl: "{{baseUrl}}", + })} +
  • +
  • {t("usageScript.tip2")}
  • +
  • {t("usageScript.tip3")}
  • +
+
-
- )} + )}
)} diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 90750b743..c58d23e77 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -343,9 +343,7 @@ export function ProviderCard({
-
+
{isOfficial ? ( @@ -393,9 +391,7 @@ export function ProviderCard({
-
+
onSwitch(provider)} onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} - onTest={onTest && !isOfficial ? () => onTest(provider) : undefined} + onTest={ + onTest && !isOfficial ? () => onTest(provider) : undefined + } onConfigureUsage={ isOfficial ? undefined : () => onConfigureUsage(provider) } diff --git a/src/config/constants.ts b/src/config/constants.ts index 07d2a686c..de9db8a68 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -9,6 +9,7 @@ export const TEMPLATE_TYPES = { GENERAL: "general", NEW_API: "newapi", GITHUB_COPILOT: "github_copilot", + TOKEN_PLAN: "token_plan", } as const; export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES]; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d5a15b69e..455b33dd3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1090,7 +1090,9 @@ "templateGeneral": "General", "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", + "templateTokenPlan": "Token Plan", "copilotAutoAuth": "Auto OAuth authentication, no manual credentials needed", + "tokenPlanHint": "Automatically uses the provider's API Key and Base URL to query Token Plan quota", "resetDate": "Reset date", "premiumRequests": "Premium Requests", "credentialsConfig": "Credentials", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2babea0ff..77a05fa6d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1090,7 +1090,9 @@ "templateGeneral": "General", "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", + "templateTokenPlan": "Token Plan", "copilotAutoAuth": "OAuth 認証を自動使用、手動設定不要", + "tokenPlanHint": "プロバイダーのAPI KeyとBase URLを使用してToken Planクォータを自動クエリ", "resetDate": "リセット日", "premiumRequests": "Premium リクエスト", "credentialsConfig": "認証情報", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ea08109a8..a073f547d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1090,7 +1090,9 @@ "templateGeneral": "通用模板", "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", + "templateTokenPlan": "Token Plan", "copilotAutoAuth": "自动使用 OAuth 认证,无需手动配置凭证", + "tokenPlanHint": "自动使用供应商的 API Key 和 Base URL 查询 Token Plan 额度", "resetDate": "重置日期", "premiumRequests": "Premium 请求", "credentialsConfig": "凭证配置", diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index df7eaba43..9c572f0ec 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -4,4 +4,9 @@ import type { SubscriptionQuota } from "@/types/subscription"; export const subscriptionApi = { getQuota: (tool: string): Promise => invoke("get_subscription_quota", { tool }), + getCodingPlanQuota: ( + baseUrl: string, + apiKey: string, + ): Promise => + invoke("get_coding_plan_quota", { baseUrl, apiKey }), }; diff --git a/src/types.ts b/src/types.ts index e6a7f358a..868a05cc6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,6 +62,7 @@ export interface UsageScript { baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用) accessToken?: string; // 访问令牌(NewAPI 模板使用) userId?: string; // 用户ID(NewAPI 模板使用) + codingPlanProvider?: string; // Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax") autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用) autoIntervalMinutes?: number; // 自动查询间隔(分钟)- 别名字段 request?: {