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
+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>
)}