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)
This commit is contained in:
Jason
2026-04-05 11:39:29 +08:00
parent 8d38f0ee4f
commit bfdac2a22a
16 changed files with 761 additions and 98 deletions
+9
View File
@@ -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<SubscriptionQuota, String> {
crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await
}
+2
View File
@@ -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::*;
+77 -19
View File
@@ -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<crate::provider::UsageResult, String> {
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<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())
+1
View File
@@ -229,6 +229,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
user_id: request.usage_user_id.clone(),
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
auto_query_interval: request.usage_auto_interval,
coding_plan_provider: None,
};
Ok(Some(ProviderMeta {
+1
View File
@@ -892,6 +892,7 @@ pub fn run() {
commands::testUsageScript,
// subscription quota
commands::get_subscription_quota,
commands::get_coding_plan_quota,
// New MCP via config.json (SSOT)
commands::get_mcp_config,
commands::upsert_mcp_server_in_config,
+4
View File
@@ -106,6 +106,10 @@ pub struct UsageScript {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "autoQueryInterval")]
pub auto_query_interval: Option<u64>,
/// Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax"
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "codingPlanProvider")]
pub coding_plan_provider: Option<String>,
}
/// 用量数据
+425
View File
@@ -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<CodingPlanProvider> {
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<String> {
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<SubscriptionQuota, String> {
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)
}
+1
View File
@@ -1,3 +1,4 @@
pub mod coding_plan;
pub mod config;
pub mod env_checker;
pub mod env_manager;
+223 -72
View File
@@ -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<string, string> = {
[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<UsageScriptModalProps> = ({
provider,
appId,
@@ -164,18 +192,39 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const [script, setScript] = useState<UsageScript>(() => {
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<UsageScriptModalProps> = ({
}
// 优先使用保存的 templateType
if (existingScript?.templateType) {
return existingScript.templateType;
return existingScript.templateType as string;
}
// 向后兼容:根据字段推断模板类型
// 检测 NEW_API 模板(有 accessToken 或 userId
@@ -247,7 +296,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
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<UsageScriptModalProps> = ({
};
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<UsageScriptModalProps> = ({
| "general"
| "newapi"
| "github_copilot"
| "token_plan"
| undefined,
};
onSave(scriptWithTemplate);
@@ -306,6 +363,45 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleTest = async () => {
setTesting(true);
try {
// Coding Plan 模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
const config = provider.settingsConfig as Record<string, any>;
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<UsageScriptModalProps> = ({
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<UsageScriptModalProps> = ({
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<UsageScriptModalProps> = ({
.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<UsageScriptModalProps> = ({
</div>
)}
{/* Coding Plan 模式:供应商选择 */}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && (
<div className="space-y-3 border-t border-white/10 pt-3">
<p className="text-sm text-muted-foreground">
{t("usageScript.tokenPlanHint")}
</p>
<div className="flex gap-2 flex-wrap">
{TOKEN_PLAN_PROVIDERS.map((cp) => (
<Button
key={cp.id}
type="button"
variant={
script.codingPlanProvider === cp.id
? "default"
: "outline"
}
size="sm"
className={cn(
"rounded-lg border",
script.codingPlanProvider === cp.id
? "shadow-sm"
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
onClick={() =>
setScript({
...script,
codingPlanProvider: cp.id,
})
}
>
{cp.label}
</Button>
))}
</div>
</div>
)}
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
@@ -860,40 +1009,42 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
{/* 提取器代码 - Copilot 模板不需要 */}
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
{t("usageScript.extractorCode")}
</Label>
<div className="text-xs text-muted-foreground">
{t("usageScript.extractorHint")}
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
{t("usageScript.extractorCode")}
</Label>
<div className="text-xs text-muted-foreground">
{t("usageScript.extractorHint")}
</div>
</div>
<JsonEditor
id="usage-code"
value={script.code || ""}
onChange={(value) =>
setScript((prev) => ({ ...prev, code: value }))
}
height={480}
language="javascript"
showMinimap={false}
/>
</div>
<JsonEditor
id="usage-code"
value={script.code || ""}
onChange={(value) =>
setScript((prev) => ({ ...prev, code: value }))
}
height={480}
language="javascript"
showMinimap={false}
/>
</div>
)}
)}
{/* 帮助信息 - Copilot 模板不需要 */}
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
<h4 className="font-medium mb-2">
{t("usageScript.scriptHelp")}
</h4>
<div className="space-y-3 text-xs">
<div>
<strong>{t("usageScript.configFormat")}</strong>
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
{`({
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
<h4 className="font-medium mb-2">
{t("usageScript.scriptHelp")}
</h4>
<div className="space-y-3 text-xs">
<div>
<strong>{t("usageScript.configFormat")}</strong>
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
{`({
request: {
url: "{{baseUrl}}/api/usage",
method: "POST",
@@ -910,39 +1061,39 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
};
}
})`}
</pre>
</div>
</pre>
</div>
<div>
<strong>{t("usageScript.extractorFormat")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>{t("usageScript.fieldIsValid")}</li>
<li>{t("usageScript.fieldInvalidMessage")}</li>
<li>{t("usageScript.fieldRemaining")}</li>
<li>{t("usageScript.fieldUnit")}</li>
<li>{t("usageScript.fieldPlanName")}</li>
<li>{t("usageScript.fieldTotal")}</li>
<li>{t("usageScript.fieldUsed")}</li>
<li>{t("usageScript.fieldExtra")}</li>
</ul>
</div>
<div>
<strong>{t("usageScript.extractorFormat")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>{t("usageScript.fieldIsValid")}</li>
<li>{t("usageScript.fieldInvalidMessage")}</li>
<li>{t("usageScript.fieldRemaining")}</li>
<li>{t("usageScript.fieldUnit")}</li>
<li>{t("usageScript.fieldPlanName")}</li>
<li>{t("usageScript.fieldTotal")}</li>
<li>{t("usageScript.fieldUsed")}</li>
<li>{t("usageScript.fieldExtra")}</li>
</ul>
</div>
<div className="text-muted-foreground">
<strong>{t("usageScript.tips")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
{t("usageScript.tip1", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</li>
<li>{t("usageScript.tip2")}</li>
<li>{t("usageScript.tip3")}</li>
</ul>
<div className="text-muted-foreground">
<strong>{t("usageScript.tips")}</strong>
<ul className="mt-1 space-y-0.5 ml-2">
<li>
{t("usageScript.tip1", {
apiKey: "{{apiKey}}",
baseUrl: "{{baseUrl}}",
})}
</li>
<li>{t("usageScript.tip2")}</li>
<li>{t("usageScript.tip3")}</li>
</ul>
</div>
</div>
</div>
</div>
)}
)}
</div>
)}
+5 -7
View File
@@ -343,9 +343,7 @@ export function ProviderCard({
</div>
</div>
<div
className="flex items-center ml-auto min-w-0 gap-3"
>
<div className="flex items-center ml-auto min-w-0 gap-3">
<div className="ml-auto">
<div className="flex items-center gap-1">
{isOfficial ? (
@@ -393,9 +391,7 @@ export function ProviderCard({
</div>
</div>
<div
className="flex items-center gap-1.5 flex-shrink-0 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-opacity duration-200"
>
<div className="flex items-center gap-1.5 flex-shrink-0 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-opacity duration-200">
<ProviderActions
appId={appId}
isCurrent={isCurrent}
@@ -406,7 +402,9 @@ export function ProviderCard({
onSwitch={() => 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)
}
+1
View File
@@ -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];
+2
View File
@@ -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",
+2
View File
@@ -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": "認証情報",
+2
View File
@@ -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": "凭证配置",
+5
View File
@@ -4,4 +4,9 @@ import type { SubscriptionQuota } from "@/types/subscription";
export const subscriptionApi = {
getQuota: (tool: string): Promise<SubscriptionQuota> =>
invoke("get_subscription_quota", { tool }),
getCodingPlanQuota: (
baseUrl: string,
apiKey: string,
): Promise<SubscriptionQuota> =>
invoke("get_coding_plan_quota", { baseUrl, apiKey }),
};
+1
View File
@@ -62,6 +62,7 @@ export interface UsageScript {
baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用)
accessToken?: string; // 访问令牌(NewAPI 模板使用)
userId?: string; // 用户IDNewAPI 模板使用)
codingPlanProvider?: string; // Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax"
autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用)
autoIntervalMinutes?: number; // 自动查询间隔(分钟)- 别名字段
request?: {