Files
CC-Switch/src/components/UsageScriptModal.tsx
T
Jason 15d5dbe065 feat: add Grok official subscription quota query
Add SuperGrok subscription usage display, following the existing
Claude Code / Codex official-subscription pattern (protocol ported
from steipete/CodexBar):

- New subscription_grok service: reads Grok CLI credentials from
  ~/.grok/auth.json, calls the grok.com GrokBuildBilling gRPC-web
  endpoint, and parses the response via heuristic protobuf scanning
  (used percent, reset time, zero-usage special case)
- Transient failures (network errors, HTTP 408, gRPC deadline/
  cancelled) propagate as Err so the frontend retries and keeps the
  last good value; auth failures map to Expired with a re-login hint
- Tier naming by reset distance: weekly limit, monthly, or a new
  "credits" tier (i18n added for zh/en/ja/zh-TW; tray shows "c")
- New get_xai_oauth_quota command: xai_oauth providers (managed
  SuperGrok OAuth accounts) query the same billing endpoint with
  their bound account token; ProviderCard auto-renders the quota
  footer for them and hides the usage-script entry, and the tray /
  usage-script path routes xai_oauth providers to the managed
  account instead of the host app's CLI credentials
- UsageScriptModal: drop the config-content heuristic for official
  detection; category === "official" is the single source of truth

Claude-Session: https://claude.ai/code/session_01LSNvhEfuoJHaQLZcYQgBU5
2026-07-23 11:13:07 +08:00

1628 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from "react";
import { Play, Wand2, Eye, EyeOff, Save, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
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";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { extractErrorMessage } from "@/utils/errorUtils";
import { useDarkMode } from "@/hooks/useDarkMode";
import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
} from "@/utils/providerConfigUtils";
import { parseGrokBuildConfig } from "@/utils/grokBuildConfig";
import JsonEditor from "./JsonEditor";
import * as prettier from "prettier/standalone";
import * as parserBabel from "prettier/parser-babel";
import * as pluginEstree from "prettier/plugins/estree";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
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";
import { formatUsageDataSummary } from "@/utils/usageDisplay";
/**
* 火山引擎账号级 AccessKey 的密钥管理页(IAM)。
* 用量查询走控制面 OpenAPI,需要 AK/SK 签名,与推理 API Key 是两套凭据,
* 直接给用户一个可点击的直达地址,省得在控制台里翻菜单。
*/
const VOLCENGINE_KEY_CONSOLE_URL =
"https://console.volcengine.com/iam/keymanage";
// 智谱团队套餐用量页(组织 ID / 项目 ID 在此页 URL 或管理后台可见)
const ZHIPU_TEAM_USAGE_URL = "https://bigmodel.cn/coding-plan/team/usage-stats";
interface UsageScriptModalProps {
provider: Provider;
appId: AppId;
isOpen: boolean;
onClose: () => void;
onSave: (script: UsageScript) => void;
}
// 生成预设模板的函数(支持国际化)
const generatePresetTemplates = (
t: (key: string) => string,
): Record<string, string> => ({
[TEMPLATE_TYPES.CUSTOM]: `({
request: {
url: "",
method: "GET",
headers: {}
},
extractor: function(response) {
return {
remaining: 0,
unit: "USD"
};
}
})`,
[TEMPLATE_TYPES.GENERAL]: `({
request: {
url: "{{baseUrl}}/user/balance",
method: "GET",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
}
},
extractor: function(response) {
return {
isValid: response.is_active || true,
remaining: response.balance,
unit: "USD"
};
}
})`,
[TEMPLATE_TYPES.NEW_API]: `({
request: {
url: "{{baseUrl}}/api/user/self",
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer {{accessToken}}",
"User-Agent": "cc-switch/1.0",
"New-Api-User": "{{userId}}"
},
},
extractor: function (response) {
if (response.success && response.data) {
return {
planName: response.data.group || "${t("usageScript.defaultPlan")}",
remaining: response.data.quota / 500000,
used: response.data.used_quota / 500000,
total: (response.data.quota + response.data.used_quota) / 500000,
unit: "USD",
};
}
return {
isValid: false,
invalidMessage: response.message || "${t("usageScript.queryFailedMessage")}"
};
},
})`,
// GitHub Copilot 模板不需要脚本,使用专用 API
[TEMPLATE_TYPES.GITHUB_COPILOT]: "",
// Coding Plan 模板不需要脚本,使用专用 Rust 查询
[TEMPLATE_TYPES.TOKEN_PLAN]: "",
// 官方余额查询模板不需要脚本,使用专用 Rust 查询
[TEMPLATE_TYPES.BALANCE]: "",
// 官方订阅额度查询不需要脚本,使用 CLI/OAuth 凭据调用官方 API
[TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION]: "",
});
// 模板名称国际化键映射
const TEMPLATE_NAME_KEYS: Record<string, string> = {
[TEMPLATE_TYPES.CUSTOM]: "usageScript.templateCustom",
[TEMPLATE_TYPES.GENERAL]: "usageScript.templateGeneral",
[TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI",
[TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot",
[TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan",
[TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance",
[TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION]:
"usageScript.templateOfficialSubscription",
};
/** 官方余额查询供应商检测 */
const BALANCE_PROVIDERS = [
{ id: "deepseek", label: "DeepSeek", pattern: /api\.deepseek\.com/i },
{ id: "stepfun", label: "StepFun", pattern: /api\.stepfun\.(ai|com)/i },
{
id: "siliconflow",
label: "SiliconFlow",
pattern: /api\.siliconflow\.(cn|com)/i,
},
{ id: "openrouter", label: "OpenRouter", pattern: /openrouter\.ai/i },
{ id: "novita", label: "Novita AI", pattern: /api\.novita\.ai/i },
] as const;
/** 根据 Base URL 自动检测余额查询供应商 */
function detectBalanceProvider(baseUrl: string | undefined): boolean {
if (!baseUrl) return false;
return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl));
}
function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) {
if (!["claude", "codex", "gemini", "grokbuild"].includes(appId)) return false;
if (provider.category === "official") return true;
const config = provider.settingsConfig as Record<string, any>;
if (appId === "claude") {
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === "");
}
if (appId === "codex") {
const apiKey = config?.auth?.OPENAI_API_KEY;
const bearerToken =
typeof config?.config === "string"
? extractCodexExperimentalBearerToken(config.config)
: undefined;
return (
!bearerToken &&
(!apiKey || (typeof apiKey === "string" && apiKey.trim() === ""))
);
}
if (appId === "gemini") {
const env = config?.env || {};
const apiKey = env.GEMINI_API_KEY;
const baseUrl = env.GOOGLE_GEMINI_BASE_URL;
return (
(!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) &&
(!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""))
);
}
// grokbuild 不做配置启发式,只认上方的 category === "official":官方态判定
// 在后端是 TOML 解析(grok_config::is_official_live_config),正则无法忠实
// 镜像(引号键/inline table/非法 TOML 均会误判为官方),误判会让本组件的
// state 初始化丢弃已保存的非官方脚本。claude/codex/gemini 的启发式建立在
// 已解析的 JSON 字段上是精确的,不受此限。官方判定以 category 为 SSOT 的
// 理由见 ProviderCard 中的注释。
return false;
}
const NATIVE_USAGE_TEMPLATES = new Set<string>([
TEMPLATE_TYPES.GITHUB_COPILOT,
TEMPLATE_TYPES.TOKEN_PLAN,
TEMPLATE_TYPES.BALANCE,
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION,
]);
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
provider,
appId,
isOpen,
onClose,
onSave,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: settingsData } = useSettingsQuery();
const [showUsageConfirm, setShowUsageConfirm] = useState(false);
const isDarkMode = useDarkMode();
// 生成带国际化的预设模板
const PRESET_TEMPLATES = generatePresetTemplates(t);
// 从 provider 的 settingsConfig 中提取 API Key 和 Base URL
const getProviderCredentials = (): {
apiKey: string | undefined;
baseUrl: string | undefined;
} => {
const trimTrailingSlash = (url: string | undefined) =>
typeof url === "string" ? url.replace(/\/+$/, "") : url;
const raw = ((): {
apiKey: string | undefined;
baseUrl: string | undefined;
} => {
try {
const config = provider.settingsConfig;
if (!config) return { apiKey: undefined, baseUrl: undefined };
// 处理不同应用的配置格式
if (appId === "claude" || appId === "claude-desktop") {
// Claude / Claude Desktop: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
// Key fallbacks mirror the backend resolver (Provider::resolve_usage_credentials).
const env = (config as any).env || {};
return {
apiKey:
env.ANTHROPIC_AUTH_TOKEN ||
env.ANTHROPIC_API_KEY ||
env.OPENROUTER_API_KEY ||
env.GOOGLE_API_KEY,
baseUrl: env.ANTHROPIC_BASE_URL,
};
} else if (appId === "codex") {
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
const auth = (config as any).auth || {};
const configToml = (config as any).config || "";
const apiKey =
typeof auth.OPENAI_API_KEY === "string" &&
auth.OPENAI_API_KEY.trim()
? auth.OPENAI_API_KEY
: extractCodexExperimentalBearerToken(configToml);
return {
apiKey,
baseUrl: extractCodexBaseUrl(configToml),
};
} else if (appId === "gemini") {
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
// Key fallback mirrors the backend resolver (Provider::resolve_usage_credentials).
const env = (config as any).env || {};
return {
apiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
} else if (appId === "grokbuild") {
const grokConfig = parseGrokBuildConfig(
(config as any).config,
provider.name,
);
return {
apiKey: grokConfig.apiKey,
baseUrl: grokConfig.baseUrl,
};
} else if (appId === "hermes") {
// Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml
return {
apiKey: (config as any).api_key,
baseUrl: (config as any).base_url,
};
} else if (appId === "openclaw") {
// OpenClaw: settingsConfig 顶层扁平(camelCase,对应 openclaw.json
return {
apiKey: (config as any).apiKey,
baseUrl: (config as any).baseUrl,
};
} else if (appId === "opencode") {
// OpenCode (OMO): 凭据嵌在 options.{baseURL, apiKey}SDK options 对象)
const options = (config as any).options || {};
return {
apiKey: options.apiKey,
baseUrl: options.baseURL,
};
}
return { apiKey: undefined, baseUrl: undefined };
} catch (error) {
console.error("Failed to extract provider credentials:", error);
return { apiKey: undefined, baseUrl: undefined };
}
})();
// Trim the trailing slash to mirror the backend resolver
// (Provider::resolve_usage_credentials), so `{{baseUrl}}/path` never
// produces a double slash regardless of which path runs the query.
return { apiKey: raw.apiKey, baseUrl: trimTrailingSlash(raw.baseUrl) };
};
const providerCredentials = getProviderCredentials();
const isOfficialSubscription = isOfficialSubscriptionProvider(
provider,
appId,
);
const [script, setScript] = useState<UsageScript>(() => {
const savedScript = provider.meta?.usage_script;
if (savedScript) {
const normalizedScript = createUsageScript(savedScript);
if (
isOfficialSubscription &&
normalizedScript.templateType !== TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION
) {
return createUsageScript();
}
// 已有配置:如果是 coding_plan 但没有 codingPlanProvider,自动检测填充
if (
normalizedScript.templateType === TEMPLATE_TYPES.TOKEN_PLAN &&
!normalizedScript.codingPlanProvider
) {
return {
...normalizedScript,
codingPlanProvider:
detectCodingPlanProvider(providerCredentials.baseUrl) || "kimi",
};
}
return normalizedScript;
}
const autoDetected = detectCodingPlanProvider(providerCredentials.baseUrl);
if (autoDetected) {
return createUsageScript({ codingPlanProvider: autoDetected });
}
if (detectBalanceProvider(providerCredentials.baseUrl)) {
return createUsageScript();
}
if (isOfficialSubscription) {
return createUsageScript();
}
return createUsageScript({
code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL],
});
});
const [testing, setTesting] = useState(false);
// {{apiKey}}/{{baseUrl}} 实际注入值,镜像后端 resolve_script_credentials 的
// 优先级:脚本配置中的显式非空值优先(旧配置可能携带),否则回退供应商凭据。
const effectiveScriptCredentials = {
apiKey: script.apiKey?.trim() || providerCredentials.apiKey,
baseUrl:
script.baseUrl?.trim().replace(/\/+$/, "") || providerCredentials.baseUrl,
};
// 🔧 失焦时的验证(严格)- 仅确保有效整数
const validateTimeout = (value: string): number => {
const num = Number(value);
if (isNaN(num) || value.trim() === "") {
return 10;
}
if (!Number.isInteger(num)) {
toast.warning(
t("usageScript.timeoutMustBeInteger") || "超时时间必须为整数",
);
}
if (num < 0) {
toast.error(
t("usageScript.timeoutCannotBeNegative") || "超时时间不能为负数",
);
return 10;
}
return Math.floor(num);
};
// 🔧 失焦时的验证(严格)- 自动查询间隔
const validateAndClampInterval = (value: string): number => {
const num = Number(value);
if (isNaN(num) || value.trim() === "") {
return 0;
}
if (!Number.isInteger(num)) {
toast.warning(
t("usageScript.intervalMustBeInteger") || "自动查询间隔必须为整数",
);
}
if (num < 0) {
toast.error(
t("usageScript.intervalCannotBeNegative") || "自动查询间隔不能为负数",
);
return 0;
}
const clamped = Math.max(0, Math.min(1440, Math.floor(num)));
if (clamped !== num && num > 0) {
toast.info(
t("usageScript.intervalAdjusted", { value: clamped }) ||
`自动查询间隔已调整为 ${clamped} 分钟`,
);
}
return clamped;
};
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
const existingScript = provider.meta?.usage_script;
// Copilot 供应商默认使用 Copilot 模板
if (provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT) {
return TEMPLATE_TYPES.GITHUB_COPILOT;
}
// 优先使用保存的 templateType
if (
existingScript?.templateType &&
(!isOfficialSubscription ||
existingScript.templateType === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION)
) {
return existingScript.templateType as string;
}
// 官方 CLI/OAuth 供应商默认使用官方订阅额度模板,但开关默认关闭
if (isOfficialSubscription) {
return TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
}
// 向后兼容:根据字段推断模板类型
// 检测 NEW_API 模板(有 accessToken 或 userId
if (existingScript?.accessToken || existingScript?.userId) {
return TEMPLATE_TYPES.NEW_API;
}
// 检测 GENERAL 模板(有 apiKey 或 baseUrl
if (existingScript?.apiKey || existingScript?.baseUrl) {
return TEMPLATE_TYPES.GENERAL;
}
// 新配置:如果 URL 匹配 Coding Plan 供应商,自动选择 Coding Plan 模板
if (detectCodingPlanProvider(providerCredentials.baseUrl)) {
return TEMPLATE_TYPES.TOKEN_PLAN;
}
// 新配置:如果 URL 匹配官方余额查询供应商,自动选择 Balance 模板
if (detectBalanceProvider(providerCredentials.baseUrl)) {
return TEMPLATE_TYPES.BALANCE;
}
// 默认使用 GENERAL(与默认代码模板一致)
return TEMPLATE_TYPES.GENERAL;
},
);
const [showApiKey, setShowApiKey] = useState(false);
const [showAccessToken, setShowAccessToken] = useState(false);
const handleEnableToggle = (checked: boolean) => {
if (checked && !settingsData?.usageConfirmed) {
setShowUsageConfirm(true);
} else {
setScript({ ...script, enabled: checked });
}
};
const handleUsageConfirm = async () => {
setShowUsageConfirm(false);
try {
if (settingsData) {
const { webdavSync: _, ...rest } = settingsData;
await settingsApi.save({ ...rest, usageConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
} catch (error) {
console.error("Failed to save usage confirmed:", error);
}
setScript({ ...script, enabled: true });
};
const handleSave = () => {
// 专用模板不需要脚本验证
if (!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "")) {
if (script.enabled && !script.code.trim()) {
toast.error(t("usageScript.scriptEmpty"));
return;
}
if (script.enabled && !script.code.includes("return")) {
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
return;
}
}
// 保存时记录当前选择的模板类型
const scriptWithTemplate = {
...script,
templateType: selectedTemplate as
| "custom"
| "general"
| "newapi"
| "github_copilot"
| "token_plan"
| "balance"
| "official_subscription"
| undefined,
};
onSave(scriptWithTemplate);
onClose();
};
const handleTest = async () => {
setTesting(true);
try {
// 官方订阅额度模板使用 CLI/OAuth 凭据和官方 API
if (selectedTemplate === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION) {
const { subscriptionApi } = await import("@/lib/api/subscription");
const quota = await subscriptionApi.getQuota(appId);
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,
});
queryClient.setQueryData(["subscription", "quota", appId], quota);
} else {
toast.error(
`${t("usageScript.testFailed")}: ${quota.error || t("endpointTest.noResult")}`,
{ duration: 5000 },
);
}
return;
}
// 官方余额查询模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.BALANCE) {
const baseUrl = providerCredentials.baseUrl ?? "";
const apiKey = providerCredentials.apiKey ?? "";
const { subscriptionApi } = await import("@/lib/api/subscription");
const result = await subscriptionApi.getBalance(baseUrl, apiKey);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
.map((d) =>
formatUsageDataSummary(d, {
invalid: t("usage.invalid"),
remaining: t("usage.remaining"),
used: t("usage.used"),
}),
)
.join(", ");
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
duration: 3000,
closeButton: true,
});
queryClient.setQueryData(["usage", provider.id, appId], result);
} else {
toast.error(
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
{ duration: 5000 },
);
}
return;
}
// Coding Plan 模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
// ZenMux 手填 baseUrl/apiKey;火山是 native 供应商,baseUrl 走推理配置,
// 另用账号 AK/SK 签名查询控制面用量。
const isZenMux = script.codingPlanProvider === "zenmux";
const isVolcengine = script.codingPlanProvider === "volcengine";
const isZhipuTeam = script.codingPlanProvider === "zhipu_team";
const baseUrl = isZenMux
? (script.baseUrl ?? "")
: (providerCredentials.baseUrl ?? "");
const apiKey = isZenMux
? (script.apiKey ?? "")
: (providerCredentials.apiKey ?? "");
const { subscriptionApi } = await import("@/lib/api/subscription");
const quota = await subscriptionApi.getCodingPlanQuota(
baseUrl,
apiKey,
isVolcengine ? script.accessKeyId : undefined,
isVolcengine ? script.secretAccessKey : undefined,
isZhipuTeam ? script.codingPlanProvider : undefined,
isZhipuTeam ? script.teamOrganizationId : undefined,
isZhipuTeam ? script.teamProjectId : undefined,
);
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(
provider.meta,
PROVIDER_TYPES.GITHUB_COPILOT,
);
const usage = accountId
? await copilotGetUsageForAccount(accountId)
: await copilotGetUsage();
const premium = usage.quota_snapshots.premium_interactions;
const used = premium.entitlement - premium.remaining;
const summary = `[${usage.copilot_plan}] ${t("usage.remaining")} ${premium.remaining}/${premium.entitlement} (${t("usageScript.resetDate")}: ${usage.quota_reset_date})`;
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
duration: 3000,
closeButton: true,
});
// 更新缓存
queryClient.setQueryData(["usage", provider.id, appId], {
success: true,
data: [
{
planName: usage.copilot_plan,
remaining: premium.remaining,
total: premium.entitlement,
used: used,
unit: t("usageScript.premiumRequests"),
},
],
});
return;
}
const result = await usageApi.testScript(
provider.id,
appId,
script.code,
script.timeout,
script.apiKey,
script.baseUrl,
script.accessToken,
script.userId,
selectedTemplate as "custom" | "general" | "newapi" | undefined,
);
if (result.success && result.data && result.data.length > 0) {
const summary = result.data
.map((plan: UsageData) =>
formatUsageDataSummary(plan, {
invalid: t("usage.invalid"),
remaining: t("usage.remaining"),
used: t("usage.used"),
}),
)
.join(", ");
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
duration: 3000,
closeButton: true,
});
// 🔧 测试成功后,更新主界面列表的用量查询缓存
queryClient.setQueryData(["usage", provider.id, appId], result);
} else {
toast.error(
`${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`,
{
duration: 5000,
},
);
}
} catch (error: any) {
// 后端命令 Err(String) 时 invoke 以裸字符串 reject(如瞬时网络失败),
// 直接读 .message 会得到 undefined。
toast.error(
`${t("usageScript.testFailed")}: ${extractErrorMessage(error) || t("common.unknown")}`,
{
duration: 5000,
},
);
} finally {
setTesting(false);
}
};
const handleFormat = async () => {
try {
const formatted = await prettier.format(script.code, {
parser: "babel",
plugins: [parserBabel as any, pluginEstree as any],
semi: true,
singleQuote: false,
tabWidth: 2,
printWidth: 80,
});
setScript({ ...script, code: formatted.trim() });
toast.success(t("usageScript.formatSuccess"), {
duration: 1000,
closeButton: true,
});
} catch (error: any) {
toast.error(
`${t("usageScript.formatFailed")}: ${error?.message || t("jsonEditor.invalidJson")}`,
{
duration: 3000,
},
);
}
};
const handleUsePreset = (presetName: string) => {
const preset = PRESET_TEMPLATES[presetName];
if (preset !== undefined) {
if (presetName === TEMPLATE_TYPES.CUSTOM) {
// 自定义模板没有凭证输入框:清空显式覆盖值后,测试与真实查询
// 都会在后端回退到供应商配置(Provider::resolve_usage_credentials),
// 与下方“支持的变量”区域展示的 {{apiKey}}/{{baseUrl}} 取值一致。
setScript({
...script,
code: preset,
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
userId: undefined,
});
} else if (presetName === TEMPLATE_TYPES.GENERAL) {
setScript({
...script,
code: preset,
accessToken: undefined,
userId: undefined,
});
} else if (presetName === TEMPLATE_TYPES.NEW_API) {
setScript({
...script,
code: preset,
apiKey: undefined,
});
} else if (presetName === TEMPLATE_TYPES.GITHUB_COPILOT) {
// Copilot 模板不需要脚本和凭证,使用专用 API
setScript({
...script,
code: "",
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
userId: undefined,
});
} else if (presetName === TEMPLATE_TYPES.TOKEN_PLAN) {
// Coding Plan 模板不需要脚本,使用 Rust 原生查询
const autoDetected = detectCodingPlanProvider(
providerCredentials.baseUrl,
);
const provider = script.codingPlanProvider || autoDetected || "kimi";
// ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;智谱团队保留组织/项目 ID;其余清除。
const isZenMux = provider === "zenmux";
const isVolcengine = provider === "volcengine";
const isZhipuTeam = provider === "zhipu_team";
setScript({
...script,
code: "",
apiKey: isZenMux ? script.apiKey : undefined,
baseUrl: isZenMux ? script.baseUrl : undefined,
accessToken: undefined,
userId: undefined,
accessKeyId: isVolcengine ? script.accessKeyId : undefined,
secretAccessKey: isVolcengine ? script.secretAccessKey : undefined,
teamOrganizationId: isZhipuTeam
? script.teamOrganizationId
: undefined,
teamProjectId: isZhipuTeam ? script.teamProjectId : undefined,
codingPlanProvider: provider,
});
} else if (presetName === TEMPLATE_TYPES.BALANCE) {
// 官方余额查询模板不需要脚本,使用 Rust 原生查询
setScript({
...script,
code: "",
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
userId: undefined,
});
} else if (presetName === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION) {
// 官方订阅额度查询不需要脚本,使用 CLI/OAuth 凭据
setScript({
...script,
code: "",
apiKey: undefined,
baseUrl: undefined,
accessToken: undefined,
userId: undefined,
});
}
setSelectedTemplate(presetName);
}
};
const shouldShowCredentialsConfig =
selectedTemplate === TEMPLATE_TYPES.GENERAL ||
selectedTemplate === TEMPLATE_TYPES.NEW_API ||
(selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
script.codingPlanProvider === "zenmux");
const footer = (
<>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={handleTest}
disabled={!script.enabled || testing}
>
<Play size={14} className="mr-1" />
{testing ? t("usageScript.testing") : t("usageScript.testScript")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={
!script.enabled ||
NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "")
}
title={t("usageScript.format")}
>
<Wand2 size={14} className="mr-1" />
{t("usageScript.format")}
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={onClose}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Save size={16} className="mr-2" />
{t("usageScript.saveConfig")}
</Button>
</div>
</>
);
return (
<FullScreenPanel
isOpen={isOpen}
title={`${t("usageScript.title")} - ${provider.name}`}
onClose={onClose}
footer={footer}
>
<div className="glass rounded-xl border border-white/10 px-6 py-4 flex items-center justify-between gap-4">
<p className="text-base font-medium leading-none text-foreground">
{t("usageScript.enableUsageQuery")}
</p>
<Switch
checked={script.enabled}
onCheckedChange={handleEnableToggle}
aria-label={t("usageScript.enableUsageQuery")}
/>
</div>
{script.enabled && (
<div className="space-y-6">
{/* 预设模板选择 */}
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<Label className="text-base font-medium">
{t("usageScript.presetTemplate")}
</Label>
<div className="flex gap-2 flex-wrap">
{Object.keys(PRESET_TEMPLATES)
.filter((name) => {
const isCopilotProvider =
provider.meta?.providerType === "github_copilot";
// Copilot 供应商只显示 copilot 模板
if (isCopilotProvider) {
return name === TEMPLATE_TYPES.GITHUB_COPILOT;
}
// 官方 CLI/OAuth 供应商只显示官方订阅额度模板
if (isOfficialSubscription) {
return name === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
}
// 非 Copilot 供应商不显示 copilot 模板
return (
name !== TEMPLATE_TYPES.GITHUB_COPILOT &&
name !== TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION
);
})
.map((name) => {
const isSelected = selectedTemplate === name;
return (
<Button
key={name}
type="button"
variant={isSelected ? "default" : "outline"}
size="sm"
className={cn(
"rounded-lg border",
isSelected
? "shadow-sm"
: "bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
onClick={() => handleUsePreset(name)}
>
{t(TEMPLATE_NAME_KEYS[name])}
</Button>
);
})}
</div>
{/* 自定义模式:变量提示和具体值 */}
{selectedTemplate === TEMPLATE_TYPES.CUSTOM && (
<div className="space-y-2 border-t border-white/10 pt-3">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.supportedVariables")}
</h4>
<div className="space-y-1 text-xs">
{/* baseUrl */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{baseUrl}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{effectiveScriptCredentials.baseUrl ? (
<code className="text-foreground/70 break-all font-mono">
{effectiveScriptCredentials.baseUrl}
</code>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
{/* apiKey */}
<div className="flex items-center gap-2 py-1">
<code className="text-emerald-500 dark:text-emerald-400 font-mono shrink-0">
{"{{apiKey}}"}
</code>
<span className="text-muted-foreground/50">=</span>
{effectiveScriptCredentials.apiKey ? (
<>
{showApiKey ? (
<code className="text-foreground/70 break-all font-mono">
{effectiveScriptCredentials.apiKey}
</code>
) : (
<code className="text-foreground/70 font-mono">
</code>
)}
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="text-muted-foreground hover:text-foreground transition-colors ml-1"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={12} />
) : (
<Eye size={12} />
)}
</button>
</>
) : (
<span className="text-muted-foreground/50 italic">
{t("common.notSet") || "未设置"}
</span>
)}
</div>
</div>
</div>
)}
{/* Copilot 模式:自动认证提示 */}
{selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT && (
<div className="space-y-2 border-t border-white/10 pt-3">
<p className="text-sm text-muted-foreground">
{t("usageScript.copilotAutoAuth")}
</p>
</div>
)}
{/* 官方余额查询模式:自动提示 */}
{selectedTemplate === TEMPLATE_TYPES.BALANCE && (
<div className="space-y-3 border-t border-white/10 pt-3">
<p className="text-sm text-muted-foreground">
{t("usageScript.balanceHint")}
</p>
<div className="flex gap-2 flex-wrap">
{BALANCE_PROVIDERS.filter((bp) =>
bp.pattern.test(providerCredentials.baseUrl || ""),
).map((bp) => (
<span
key={bp.id}
className="inline-flex items-center px-2.5 py-1 rounded-md bg-primary/10 text-primary text-xs font-medium"
>
{bp.label}
</span>
))}
</div>
</div>
)}
{/* 官方订阅额度模式:自动提示 */}
{selectedTemplate === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION && (
<div className="space-y-2 border-t border-white/10 pt-3">
<p className="text-sm text-muted-foreground">
{t("usageScript.officialSubscriptionHint")}
</p>
</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">
{CODING_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">
<div className="flex items-start justify-between">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
<p className="text-xs text-muted-foreground">
{t("usageScript.credentialsHint")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{selectedTemplate === TEMPLATE_TYPES.GENERAL && (
<>
<div className="space-y-2">
<Label htmlFor="usage-api-key">
API Key{" "}
<span className="text-xs text-muted-foreground font-normal">
({t("usageScript.optional")})
</span>
</Label>
<div className="relative">
<Input
id="usage-api-key"
type={showApiKey ? "text" : "password"}
value={script.apiKey || ""}
onChange={(e) =>
setScript({ ...script, apiKey: e.target.value })
}
placeholder={t("usageScript.apiKeyPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
{script.apiKey && (
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usage-base-url">
{t("usageScript.baseUrl")}{" "}
<span className="text-xs text-muted-foreground font-normal">
({t("usageScript.optional")})
</span>
</Label>
<Input
id="usage-base-url"
type="text"
value={script.baseUrl || ""}
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder={t("usageScript.baseUrlPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
</div>
</>
)}
{selectedTemplate === TEMPLATE_TYPES.NEW_API && (
<>
<div className="space-y-2">
<Label htmlFor="usage-newapi-base-url">
{t("usageScript.baseUrl")}
</Label>
<Input
id="usage-newapi-base-url"
type="text"
value={script.baseUrl || ""}
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder="https://api.newapi.com"
autoComplete="off"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-access-token">
{t("usageScript.accessToken")}
</Label>
<div className="relative">
<Input
id="usage-access-token"
type={showAccessToken ? "text" : "password"}
value={script.accessToken || ""}
onChange={(e) =>
setScript({
...script,
accessToken: e.target.value,
})
}
placeholder={t(
"usageScript.accessTokenPlaceholder",
)}
autoComplete="off"
className="border-white/10"
/>
{script.accessToken && (
<button
type="button"
onClick={() =>
setShowAccessToken(!showAccessToken)
}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showAccessToken
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showAccessToken ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="usage-user-id">
{t("usageScript.userId")}
</Label>
<Input
id="usage-user-id"
type="text"
value={script.userId || ""}
onChange={(e) =>
setScript({ ...script, userId: e.target.value })
}
placeholder={t("usageScript.userIdPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
</div>
</>
)}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
script.codingPlanProvider === "zenmux" && (
<>
<div className="space-y-2">
<Label htmlFor="usage-zenmux-base-url">
{t("usageScript.baseUrl")}
</Label>
<Input
id="usage-zenmux-base-url"
type="text"
value={script.baseUrl || ""}
onChange={(e) =>
setScript({ ...script, baseUrl: e.target.value })
}
placeholder="https://api.zenmux.com/v1/..."
autoComplete="off"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-zenmux-api-key">API Key</Label>
<div className="relative">
<Input
id="usage-zenmux-api-key"
type={showApiKey ? "text" : "password"}
value={script.apiKey || ""}
onChange={(e) =>
setScript({
...script,
apiKey: e.target.value,
})
}
placeholder="sk-..."
autoComplete="off"
className="border-white/10"
/>
{script.apiKey && (
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
</>
)}
</div>
</div>
)}
{/* 火山方舟:控制面用量查询需账号 AK/SK(与推理 Key 是两套凭据) */}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
script.codingPlanProvider === "volcengine" && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{t("usageScript.volcengineAkSkHint")}
</p>
<p className="text-xs text-muted-foreground mt-1.5">
{t("usageScript.volcengineKeyConsoleLink")}{" "}
<button
type="button"
onClick={() =>
settingsApi.openExternal(VOLCENGINE_KEY_CONSOLE_URL)
}
className="inline-flex items-center gap-1 text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors break-all align-baseline underline-offset-2 hover:underline"
>
{VOLCENGINE_KEY_CONSOLE_URL}
<ExternalLink size={12} className="shrink-0" />
</button>
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="usage-volcengine-ak">
{t("usageScript.accessKeyId")}
</Label>
<Input
id="usage-volcengine-ak"
type="text"
value={script.accessKeyId || ""}
onChange={(e) =>
setScript({
...script,
accessKeyId: e.target.value,
})
}
placeholder="AKLT..."
autoComplete="off"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-volcengine-sk">
{t("usageScript.secretAccessKey")}
</Label>
<div className="relative">
<Input
id="usage-volcengine-sk"
type={showApiKey ? "text" : "password"}
value={script.secretAccessKey || ""}
onChange={(e) =>
setScript({
...script,
secretAccessKey: e.target.value,
})
}
placeholder="••••••••"
autoComplete="off"
className="border-white/10"
/>
{script.secretAccessKey && (
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
</div>
</div>
)}
{/* 智谱团队套餐:需组织 ID + 项目 ID(api_key 沿用供应商推理凭据) */}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
script.codingPlanProvider === "zhipu_team" && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{t("usageScript.zhipuTeamHint")}
</p>
<p className="text-xs text-muted-foreground mt-1.5">
{t("usageScript.zhipuTeamConsoleLink")}{" "}
<button
type="button"
onClick={() =>
settingsApi.openExternal(ZHIPU_TEAM_USAGE_URL)
}
className="inline-flex items-center gap-1 text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors break-all align-baseline underline-offset-2 hover:underline"
>
{ZHIPU_TEAM_USAGE_URL}
<ExternalLink size={12} className="shrink-0" />
</button>
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="usage-zhipu-team-org">
{t("usageScript.organizationId")}
</Label>
<Input
id="usage-zhipu-team-org"
type="text"
value={script.teamOrganizationId || ""}
onChange={(e) =>
setScript({
...script,
teamOrganizationId: e.target.value,
})
}
placeholder={t("usageScript.organizationIdPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-zhipu-team-project">
{t("usageScript.projectId")}
</Label>
<Input
id="usage-zhipu-team-project"
type="text"
value={script.teamProjectId || ""}
onChange={(e) =>
setScript({
...script,
teamProjectId: e.target.value,
})
}
placeholder={t("usageScript.projectIdPlaceholder")}
autoComplete="off"
className="border-white/10"
/>
</div>
</div>
</div>
)}
{/* 通用配置(始终显示) */}
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
{/* 超时时间 */}
<div className="space-y-2">
<Label htmlFor="usage-timeout">
{t("usageScript.timeoutSeconds")}
</Label>
<Input
id="usage-timeout"
type="number"
min={0}
value={script.timeout ?? 10}
onChange={(e) =>
setScript({
...script,
timeout:
e.target.value === ""
? ("" as unknown as number)
: Number(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
timeout: validateTimeout(e.target.value),
})
}
className="border-white/10"
/>
</div>
{/* 自动查询间隔 */}
<div className="space-y-2">
<Label htmlFor="usage-interval">
{t("usageScript.autoIntervalMinutes")}
</Label>
<Input
id="usage-interval"
type="number"
min={0}
max={1440}
value={
script.autoQueryInterval ?? script.autoIntervalMinutes ?? 5
}
onChange={(e) =>
setScript({
...script,
autoQueryInterval:
e.target.value === ""
? ("" as unknown as number)
: Number(e.target.value),
})
}
onBlur={(e) =>
setScript({
...script,
autoQueryInterval: validateAndClampInterval(
e.target.value,
),
})
}
className="border-white/10"
/>
</div>
</div>
</div>
{/* 提取器代码 - 专用模板不需要 */}
{!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "") && (
<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}
darkMode={isDarkMode}
/>
</div>
)}
{/* 帮助信息 - 专用模板不需要 */}
{!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "") && (
<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",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
}
},
extractor: function(response) {
return {
isValid: !response.error,
remaining: response.balance,
unit: "USD"
};
}
})`}
</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 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>
)}
<ConfirmDialog
isOpen={showUsageConfirm}
variant="info"
title={t("confirm.usage.title")}
message={t("confirm.usage.message")}
confirmText={t("confirm.usage.confirm")}
onConfirm={() => void handleUsageConfirm()}
onCancel={() => setShowUsageConfirm(false)}
/>
</FullScreenPanel>
);
};
export default UsageScriptModal;