mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-23 22:21:00 +08:00
feat(tray): show coding-plan usage for Kimi / Zhipu / MiniMax
dc04165f surfaced tray usage badges for Claude/Codex/Gemini official
OAuth only. Chinese coding-plan providers already expose 5h + weekly
windows through coding_plan::get_coding_plan_quota, but two gaps kept
the tray from rendering them.
- format_script_summary read only data.first(), truncating the tier-
flattened UsageResult to a single window. Detect plan_name matching
TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT and emit the "🟢 h12% w80%" layout
used by format_subscription_summary; worst utilization drives the
emoji. Copilot / balance / custom scripts keep the legacy single-
bucket output via fallback.
- usage_script previously required manual activation through
UsageScriptModal. Auto-inject meta.usage_script on Claude provider
creation when ANTHROPIC_BASE_URL matches a known coding plan, so the
tray lights up without the user opening the modal. Does not overwrite
existing usage_script on update.
Extract the URL route table out of UsageScriptModal into a shared
codingPlanProviders module so the modal, the creation hook, and the
Rust coding_plan::detect_provider mirror all agree on one list.
Add TIER_WEEKLY_LIMIT alongside TIER_FIVE_HOUR and a createUsageScript()
factory to collapse the duplicated default fields across four call
sites and drop the remaining stringly-typed tier names.
This commit is contained in:
@@ -297,6 +297,10 @@ pub const TIER_SEVEN_DAY: &str = "seven_day";
|
||||
pub const TIER_SEVEN_DAY_OPUS: &str = "seven_day_opus";
|
||||
pub const TIER_SEVEN_DAY_SONNET: &str = "seven_day_sonnet";
|
||||
|
||||
/// Coding Plan(Kimi / MiniMax)的周窗口 tier 名。与 `coding_plan::query_*`
|
||||
/// 写入、tray 渲染、commands::provider 扁平化三处共用同一标识。
|
||||
pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
|
||||
|
||||
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
|
||||
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
|
||||
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
|
||||
|
||||
+151
-8
@@ -172,17 +172,59 @@ fn format_subscription_summary(
|
||||
Some(format!("{emoji} {body}"))
|
||||
}
|
||||
|
||||
fn tier_pct(data: &crate::provider::UsageData) -> Option<f64> {
|
||||
match (data.used, data.total) {
|
||||
(Some(used), Some(total)) if total > 0.0 => Some(used / total * 100.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_script_summary(result: &crate::provider::UsageResult) -> Option<String> {
|
||||
use crate::services::subscription::{TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
|
||||
|
||||
if !result.success {
|
||||
return None;
|
||||
}
|
||||
let data = result.data.as_ref()?.first()?;
|
||||
let pct = match (data.used, data.total) {
|
||||
(Some(used), Some(total)) if total > 0.0 => used / total * 100.0,
|
||||
_ => return None,
|
||||
};
|
||||
let data = result.data.as_ref()?;
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// commands::provider 的 token_plan 分支把 SubscriptionQuota 的每个 tier
|
||||
// 扁平化为一条 UsageData(plan_name 承载 tier 名),所以这里按 plan_name
|
||||
// 识别双桶形态,其余 usage 结果(Copilot / balance / 自定义脚本)走 fallback。
|
||||
const TOKEN_PLAN_LABELS: &[(&str, &str)] = &[(TIER_FIVE_HOUR, "h"), (TIER_WEEKLY_LIMIT, "w")];
|
||||
|
||||
let mut parts: Vec<(&'static str, f64)> = Vec::new();
|
||||
for &(tier_name, label) in TOKEN_PLAN_LABELS {
|
||||
let Some(d) = data
|
||||
.iter()
|
||||
.find(|d| d.plan_name.as_deref() == Some(tier_name))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(u) = tier_pct(d) {
|
||||
parts.push((label, u));
|
||||
}
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
let worst = parts
|
||||
.iter()
|
||||
.map(|(_, u)| *u)
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let emoji = emoji_for_utilization(worst);
|
||||
let body = parts
|
||||
.iter()
|
||||
.map(|(label, u)| format!("{label}{}%", u.round() as i64))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
return Some(format!("{emoji} {body}"));
|
||||
}
|
||||
|
||||
let first = data.first()?;
|
||||
let pct = tier_pct(first)?;
|
||||
let emoji = emoji_for_utilization(pct);
|
||||
let plan = data.plan_name.as_deref().unwrap_or("");
|
||||
let plan = first.plan_name.as_deref().unwrap_or("");
|
||||
let rounded = pct.round() as i64;
|
||||
if plan.is_empty() {
|
||||
Some(format!("{} {}%", emoji, rounded))
|
||||
@@ -805,8 +847,11 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_subscription_summary, TRAY_ID};
|
||||
use crate::services::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
|
||||
use super::{format_script_summary, format_subscription_summary, TRAY_ID};
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn tray_id_is_unique_to_app() {
|
||||
@@ -934,4 +979,102 @@ mod tests {
|
||||
let quota = make_quota("gemini", true, vec![tier("some_future_tier", 80.0)]);
|
||||
assert!(format_subscription_summary("a).is_none());
|
||||
}
|
||||
|
||||
fn usage_data(plan_name: Option<&str>, utilization: f64) -> UsageData {
|
||||
UsageData {
|
||||
plan_name: plan_name.map(String::from),
|
||||
extra: None,
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
total: Some(100.0),
|
||||
used: Some(utilization),
|
||||
remaining: Some(100.0 - utilization),
|
||||
unit: Some("%".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_result(success: bool, data: Vec<UsageData>) -> UsageResult {
|
||||
UsageResult {
|
||||
success,
|
||||
data: if data.is_empty() { None } else { Some(data) },
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_two_tiers() {
|
||||
let r = usage_result(
|
||||
true,
|
||||
vec![
|
||||
usage_data(Some(TIER_FIVE_HOUR), 12.0),
|
||||
usage_data(Some(TIER_WEEKLY_LIMIT), 80.0),
|
||||
],
|
||||
);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("h12%"), "expected h12% in {s}");
|
||||
assert!(s.contains("w80%"), "expected w80% in {s}");
|
||||
assert!(s.starts_with("\u{1F7E0}"), "expected orange emoji in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_worst_drives_emoji() {
|
||||
let r = usage_result(
|
||||
true,
|
||||
vec![
|
||||
usage_data(Some(TIER_FIVE_HOUR), 20.0),
|
||||
usage_data(Some(TIER_WEEKLY_LIMIT), 95.0),
|
||||
],
|
||||
);
|
||||
let s = format_script_summary(&r).unwrap();
|
||||
assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_five_hour_only() {
|
||||
let r = usage_result(true, vec![usage_data(Some(TIER_FIVE_HOUR), 8.0)]);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("h8%"), "expected h8% in {s}");
|
||||
assert!(
|
||||
!s.contains("plan_name"),
|
||||
"plan_name should not leak into label: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_token_plan_weekly_only() {
|
||||
let r = usage_result(true, vec![usage_data(Some(TIER_WEEKLY_LIMIT), 50.0)]);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("w50%"), "expected w50% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_single_bucket_fallback_with_plan_name() {
|
||||
let r = usage_result(true, vec![usage_data(Some("Copilot Pro"), 40.0)]);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("Copilot Pro"), "expected plan name in {s}");
|
||||
assert!(s.contains("40%"), "expected 40% in {s}");
|
||||
assert!(
|
||||
!s.contains("h40%"),
|
||||
"must not relabel non-token-plan data as h: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_single_bucket_fallback_without_plan_name() {
|
||||
let r = usage_result(true, vec![usage_data(None, 15.0)]);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert_eq!(s, "\u{1F7E2} 15%", "expected emoji + pct only, got {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_failure_returns_none() {
|
||||
let r = usage_result(false, vec![usage_data(Some(TIER_FIVE_HOUR), 12.0)]);
|
||||
assert!(format_script_summary(&r).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_empty_data_returns_none() {
|
||||
let r = usage_result(true, vec![]);
|
||||
assert!(format_script_summary(&r).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Play, Wand2, Eye, EyeOff, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Provider, UsageScript, UsageData } from "@/types";
|
||||
import { Provider, UsageScript, UsageData, createUsageScript } from "@/types";
|
||||
import { usageApi, settingsApi, type AppId } from "@/lib/api";
|
||||
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
|
||||
import { useSettingsQuery } from "@/lib/query";
|
||||
@@ -21,6 +21,10 @@ import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TEMPLATE_TYPES, PROVIDER_TYPES } from "@/config/constants";
|
||||
import {
|
||||
CODING_PLAN_PROVIDERS,
|
||||
detectCodingPlanProvider,
|
||||
} from "@/config/codingPlanProviders";
|
||||
|
||||
interface UsageScriptModalProps {
|
||||
provider: Provider;
|
||||
@@ -114,21 +118,6 @@ const TEMPLATE_NAME_KEYS: Record<string, string> = {
|
||||
[TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance",
|
||||
};
|
||||
|
||||
/** 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;
|
||||
|
||||
/** 官方余额查询供应商检测 */
|
||||
const BALANCE_PROVIDERS = [
|
||||
{ id: "deepseek", label: "DeepSeek", pattern: /api\.deepseek\.com/i },
|
||||
@@ -148,15 +137,6 @@ function detectBalanceProvider(baseUrl: string | undefined): boolean {
|
||||
return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl));
|
||||
}
|
||||
|
||||
/** 根据 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,
|
||||
@@ -237,43 +217,24 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
return {
|
||||
...savedScript,
|
||||
codingPlanProvider:
|
||||
detectTokenPlanProvider(providerCredentials.baseUrl) || "kimi",
|
||||
detectCodingPlanProvider(providerCredentials.baseUrl) || "kimi",
|
||||
};
|
||||
}
|
||||
return savedScript;
|
||||
}
|
||||
|
||||
// 新配置:如果 URL 匹配 Coding Plan,自动初始化
|
||||
const autoDetected = detectTokenPlanProvider(providerCredentials.baseUrl);
|
||||
const autoDetected = detectCodingPlanProvider(providerCredentials.baseUrl);
|
||||
if (autoDetected) {
|
||||
return {
|
||||
enabled: false,
|
||||
language: "javascript" as const,
|
||||
code: "",
|
||||
timeout: 10,
|
||||
autoQueryInterval: 5,
|
||||
codingPlanProvider: autoDetected,
|
||||
};
|
||||
return createUsageScript({ codingPlanProvider: autoDetected });
|
||||
}
|
||||
|
||||
// 新配置:如果 URL 匹配官方余额查询供应商,自动初始化
|
||||
if (detectBalanceProvider(providerCredentials.baseUrl)) {
|
||||
return {
|
||||
enabled: false,
|
||||
language: "javascript" as const,
|
||||
code: "",
|
||||
timeout: 10,
|
||||
autoQueryInterval: 5,
|
||||
};
|
||||
return createUsageScript();
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
language: "javascript" as const,
|
||||
return createUsageScript({
|
||||
code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL],
|
||||
timeout: 10,
|
||||
autoQueryInterval: 5,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
@@ -346,7 +307,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
return TEMPLATE_TYPES.GENERAL;
|
||||
}
|
||||
// 新配置:如果 URL 匹配 Coding Plan 供应商,自动选择 Coding Plan 模板
|
||||
if (detectTokenPlanProvider(providerCredentials.baseUrl)) {
|
||||
if (detectCodingPlanProvider(providerCredentials.baseUrl)) {
|
||||
return TEMPLATE_TYPES.TOKEN_PLAN;
|
||||
}
|
||||
// 新配置:如果 URL 匹配官方余额查询供应商,自动选择 Balance 模板
|
||||
@@ -623,7 +584,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
});
|
||||
} else if (presetName === TEMPLATE_TYPES.TOKEN_PLAN) {
|
||||
// Coding Plan 模板不需要脚本,使用 Rust 原生查询
|
||||
const autoDetected = detectTokenPlanProvider(
|
||||
const autoDetected = detectCodingPlanProvider(
|
||||
providerCredentials.baseUrl,
|
||||
);
|
||||
setScript({
|
||||
@@ -862,7 +823,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
{t("usageScript.tokenPlanHint")}
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{TOKEN_PLAN_PROVIDERS.map((cp) => (
|
||||
{CODING_PLAN_PROVIDERS.map((cp) => (
|
||||
<Button
|
||||
key={cp.id}
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Coding Plan 供应商的 base_url 路由表。
|
||||
*
|
||||
* 与后端 `src-tauri/src/services/coding_plan.rs::detect_provider` 保持一致:
|
||||
* 后端靠 `url.contains(...)` 做子串判断,前端这里用 RegExp 做同效匹配。
|
||||
* 新增供应商时改这一处即可(UsageScriptModal 下拉 + useProviderActions
|
||||
* 新建自动注入 + 托盘识别全部复用)。
|
||||
*/
|
||||
import { createUsageScript } from "@/types";
|
||||
import { TEMPLATE_TYPES } from "@/config/constants";
|
||||
|
||||
export interface CodingPlanProviderEntry {
|
||||
/** 与后端 QuotaTier 的 `codingPlanProvider` 取值对齐 */
|
||||
id: "kimi" | "zhipu" | "minimax";
|
||||
/** UsageScriptModal 下拉显示用 */
|
||||
label: string;
|
||||
/** base_url 匹配规则 */
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
export const CODING_PLAN_PROVIDERS: readonly CodingPlanProviderEntry[] = [
|
||||
{ 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 供应商;未命中返回 null */
|
||||
export function detectCodingPlanProvider(
|
||||
baseUrl: string | undefined | null,
|
||||
): CodingPlanProviderEntry["id"] | null {
|
||||
if (!baseUrl) return null;
|
||||
for (const cp of CODING_PLAN_PROVIDERS) {
|
||||
if (cp.pattern.test(baseUrl)) return cp.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建 Claude 供应商时,若 `ANTHROPIC_BASE_URL` 命中 Coding Plan 路由表,
|
||||
* 自动把 `meta.usage_script` 标记为 token_plan 并启用。
|
||||
*
|
||||
* - 仅在 `meta.usage_script` 完全缺失时注入,不覆盖用户/UsageScriptModal 已有配置
|
||||
* - 仅对 Claude app 生效:后端 `commands/provider.rs` 的 token_plan 分支只处理 Claude
|
||||
* supplier 的 `settings_config.env.ANTHROPIC_BASE_URL`
|
||||
* - code 置空:Rust 端走专用 `coding_plan::get_coding_plan_quota`,不执行 JS 脚本
|
||||
*/
|
||||
export function injectCodingPlanUsageScript<
|
||||
T extends {
|
||||
settingsConfig?: Record<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
},
|
||||
>(appId: string, provider: T): T {
|
||||
if (appId !== "claude") return provider;
|
||||
if (provider.meta?.usage_script) return provider;
|
||||
|
||||
const baseUrl = provider.settingsConfig?.env?.ANTHROPIC_BASE_URL;
|
||||
const codingPlanProvider = detectCodingPlanProvider(
|
||||
typeof baseUrl === "string" ? baseUrl : null,
|
||||
);
|
||||
if (!codingPlanProvider) return provider;
|
||||
|
||||
return {
|
||||
...provider,
|
||||
meta: {
|
||||
...(provider.meta ?? {}),
|
||||
usage_script: createUsageScript({
|
||||
enabled: true,
|
||||
templateType: TEMPLATE_TYPES.TOKEN_PLAN,
|
||||
codingPlanProvider,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
OpenClawDefaultModel,
|
||||
} from "@/types";
|
||||
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
|
||||
import { injectCodingPlanUsageScript } from "@/config/codingPlanProviders";
|
||||
import {
|
||||
useAddProviderMutation,
|
||||
useUpdateProviderMutation,
|
||||
@@ -72,7 +73,8 @@ export function useProviderActions(
|
||||
addToLive?: boolean;
|
||||
},
|
||||
) => {
|
||||
await addProviderMutation.mutateAsync(provider);
|
||||
const enhanced = injectCodingPlanUsageScript(activeApp, provider);
|
||||
await addProviderMutation.mutateAsync(enhanced);
|
||||
|
||||
// OpenClaw: register models to allowlist after adding provider
|
||||
if (activeApp === "openclaw" && provider.suggestedDefaults) {
|
||||
|
||||
@@ -74,6 +74,20 @@ export interface UsageScript {
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_USAGE_SCRIPT: UsageScript = {
|
||||
enabled: false,
|
||||
language: "javascript",
|
||||
code: "",
|
||||
timeout: 10,
|
||||
autoQueryInterval: 5,
|
||||
};
|
||||
|
||||
export function createUsageScript(
|
||||
overrides?: Partial<UsageScript>,
|
||||
): UsageScript {
|
||||
return { ...DEFAULT_USAGE_SCRIPT, ...overrides };
|
||||
}
|
||||
|
||||
// 单个套餐用量数据
|
||||
export interface UsageData {
|
||||
planName?: string; // 套餐名称(可选)
|
||||
|
||||
Reference in New Issue
Block a user