dsh-subusage 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -8
- package/lib/client.js +163 -18
- package/lib/index.js +622 -21
- package/lib/volcengine.js +153 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// dsh-subusage Host:秘密仅保存在 Host,远端只暴露 provider 补丁和公开设置。
|
|
2
2
|
import { RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
3
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
4
|
import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
|
|
4
5
|
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
5
6
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -7,6 +8,7 @@ import { createHash } from "node:crypto";
|
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import { dirname, join } from "node:path";
|
|
9
10
|
import { MimoLogin } from "./mimo-login.js";
|
|
11
|
+
import { parseVolcAgentPlan, parseVolcCodingPlan, volcError, volcSignature } from "./volcengine.js";
|
|
10
12
|
|
|
11
13
|
const name = "dsh-subusage";
|
|
12
14
|
const inject = ["llm"];
|
|
@@ -18,13 +20,54 @@ const PROVIDERS = {
|
|
|
18
20
|
"kimi-coding": { label: "Kimi", envName: "KIMI_CODING_API_KEY" },
|
|
19
21
|
"xiaomi-token-plan-cn": { label: "MiMo", envName: "XIAOMI_TOKEN_PLAN_CN_API_KEY" },
|
|
20
22
|
"opencode-go": { label: "OpenCode Go", envName: "OPENCODE_API_KEY" },
|
|
21
|
-
|
|
23
|
+
// MiniMax 国际版默认关闭(中国版保持默认开启)。
|
|
24
|
+
"minimax": { label: "MiniMax", envName: "MINIMAX_API_KEY", usageUrl: "https://api.minimax.io/v1/token_plan/remains", defaultEnabled: false },
|
|
22
25
|
"minimax-cn": { label: "MiniMax CN", envName: "MINIMAX_CN_API_KEY", usageUrl: "https://api.minimaxi.com/v1/token_plan/remains" },
|
|
26
|
+
// SiliconFlow(硅基流动):余额型接口,鉴权与推理是**同一把** API Key(Bearer)。
|
|
27
|
+
// 官方 OpenAPI 仓库 siliconflow/siliconcloud 的 openapi.yaml 列出 GET /v1/user/info(bearerAuth)。
|
|
28
|
+
"siliconflow": { label: "SiliconFlow", envName: "SILICONFLOW_API_KEY", defaultEnabled: false },
|
|
23
29
|
// Commandcode 路由由另一个插件(@mars-sea/dsh-commandcode-provider)注册:凭据、多账户与
|
|
24
30
|
// API 地址都在那边管理。本插件不保存其凭据,只沿用同一凭据来源链自取 Key 后直连计费接口。
|
|
25
|
-
"commandcode": { label: "Command Code", envName: "COMMANDCODE_API_KEY", managedByPlugin: true }
|
|
31
|
+
"commandcode": { label: "Command Code", envName: "COMMANDCODE_API_KEY", managedByPlugin: true },
|
|
32
|
+
// SuperGrok / X Premium 订阅路由由 dsh-grok-kit(xai-oauth)注册,登录是 OAuth:凭据只认
|
|
33
|
+
// 与 Grok CLI 共享的登录文件,API Key(XAI_API_KEY)路线取不到订阅周池,故不设继承变量。
|
|
34
|
+
"xai-oauth": { label: "SuperGrok", envName: "", managedByPlugin: true },
|
|
35
|
+
// OpenAI Codex / ChatGPT 订阅:路由由 DSH 内置的 openai-codex(或 dsh-codex-connect 等插件)提供,
|
|
36
|
+
// 额度凭据只认 Codex CLI 的 OAuth 登录文件,不提供本地凭据编辑。
|
|
37
|
+
"openai-codex": { label: "Codex (ChatGPT)", envName: "", managedByPlugin: true },
|
|
38
|
+
// 火山方舟:模型路由由**官方插件** @volcengine/ark-plan-api 注册,provider id 必须与它 // 逐字一致,否则选中方舟模型时药丸不会出现。额度读取走管控面 OpenAPI,凭据是 IAM 的
|
|
39
|
+
// AK/SK 配对(HMAC-SHA256 签名),与这些路由的推理 API Key(ARK_*_PLAN_CN_API_KEY)是
|
|
40
|
+
// 两套凭据:拿推理 Key 去查额度会被上游拒绝("GetAFPUsage requires Volcengine Ark SSO STS")。
|
|
41
|
+
// 两个 provider 共用一组 AK/SK(settings.volc),各自固定一个 Action。
|
|
42
|
+
"ark-coding-plan-cn": { label: "Ark Coding Plan (CN)", envName: "ARK_CODING_PLAN_CN_API_KEY", credentialKind: "volc", volcAction: "GetCodingPlanUsage", volcHost: "open.volcengineapi.com", volcRegion: "cn-beijing", defaultEnabled: false },
|
|
43
|
+
"ark-agent-plan-cn": { label: "Ark Agent Plan (CN)", envName: "ARK_AGENT_PLAN_CN_API_KEY", credentialKind: "volc", volcAction: "GetAFPUsage", volcHost: "open.volcengineapi.com", volcRegion: "cn-beijing", defaultEnabled: false },
|
|
44
|
+
// BytePlus(海外)Coding Plan:同一套 AK/SK,但走 BytePlus 管控面与 ap-southeast-1 区域。
|
|
45
|
+
"ark-coding-plan-byteplus": { label: "Ark Coding Plan (BytePlus)", envName: "ARK_CODING_PLAN_BYTEPLUS_API_KEY", credentialKind: "volc", volcAction: "GetCodingPlanUsage", volcHost: "ark.ap-southeast-1.byteplusapi.com", volcRegion: "ap-southeast-1", defaultEnabled: false },
|
|
46
|
+
// DeepSeek 官方:余额型(不是订阅窗口),与推理**同一把** API Key,端点与响应见官方文档
|
|
47
|
+
// https://api-docs.deepseek.com/zh-cn/api/get-user-balance
|
|
48
|
+
"deepseek": { label: "DeepSeek", envName: "DEEPSEEK_API_KEY" },
|
|
49
|
+
// OpenRouter:key 限额与用量走 /key(任意 key 可用),账户余额走 /credits(**只对
|
|
50
|
+
// management / provisioning key 开放**,普通推理 key 会被拒 → 静默降级为只显示限额)。
|
|
51
|
+
"openrouter": { label: "OpenRouter", envName: "OPENROUTER_API_KEY", defaultEnabled: false },
|
|
52
|
+
// 以下六家都是**余额型**(单体 GET + 一个余额数字),与推理**同一把** Key;默认关闭,
|
|
53
|
+
// 没配 Key 时由「没有检测到 API 的默认隐藏」收起。
|
|
54
|
+
"novita": { label: "Novita AI", envName: "NOVITA_API_KEY", defaultEnabled: false },
|
|
55
|
+
"hyperbolic": { label: "Hyperbolic", envName: "HYPERBOLIC_API_KEY", defaultEnabled: false },
|
|
56
|
+
"deepinfra": { label: "DeepInfra", envName: "DEEPINFRA_API_KEY", defaultEnabled: false },
|
|
57
|
+
"chutes": { label: "Chutes", envName: "CHUTES_API_KEY", defaultEnabled: false },
|
|
58
|
+
// Ollama Cloud 的鉴权是**裸 Authorization**(不加 Bearer),照抄别家实现时必须注意。
|
|
59
|
+
"ollama-cloud": { label: "Ollama Cloud", envName: "OLLAMA_API_KEY", defaultEnabled: false },
|
|
60
|
+
"vercel-ai-gateway": { label: "Vercel AI Gateway", envName: "AI_GATEWAY_API_KEY", defaultEnabled: false },
|
|
61
|
+
// ZenMux 的额度端点只认 Management API Key;变量名单独区分以免误填推理 key。
|
|
62
|
+
"zenmux": { label: "ZenMux", envName: "ZENMUX_MANAGEMENT_API_KEY", defaultEnabled: false },
|
|
63
|
+
// LiteLLM 是自建网关:除虚拟 Key 外还要配自己的 proxy 地址(settings.litellm.baseUrl)。
|
|
64
|
+
"litellm": { label: "LiteLLM", envName: "LITELLM_API_KEY", defaultEnabled: false }
|
|
26
65
|
};
|
|
27
66
|
const IDS = Object.keys(PROVIDERS);
|
|
67
|
+
// 火山方舟额度接口的凭据是 IAM Access Key:AK 与 SK 必须同源配对,跨来源拼接只会得到 401。
|
|
68
|
+
// 变量名沿用火山官方 SDK 习惯;与方舟推理 Key(ARK_*_API_KEY)无关。
|
|
69
|
+
const VOLC_ACCESS_KEY_ENV = "VOLC_ACCESSKEY";
|
|
70
|
+
const VOLC_SECRET_KEY_ENV = "VOLC_SECRETKEY";
|
|
28
71
|
// 与 @mars-sea/dsh-commandcode-provider 一致的 CLI 版本标识(其常量 COMMAND_CODE_CLI_VERSION)。
|
|
29
72
|
const COMMAND_CODE_CLI_VERSION = "1.73.0";
|
|
30
73
|
const COMMAND_CODE_API_BASE = "https://api.commandcode.ai";
|
|
@@ -53,6 +96,65 @@ function commandCodeAuthFileKey(io) {
|
|
|
53
96
|
return str(parsed.apiKey) ?? str(parsed.commandcode) ?? fromRecord(parsed.commandcode) ?? fromRecord(parsed["command-code"]);
|
|
54
97
|
} catch { return undefined; }
|
|
55
98
|
}
|
|
99
|
+
// SuperGrok / X Premium 订阅(dsh-grok-kit 的 xai-oauth 路由):用量走官方 Grok CLI 的计费
|
|
100
|
+
// 代理(转发后端 GetGrokCreditsConfig)。凭据是 dsh-grok-kit / Grok CLI 共享登录文件里的
|
|
101
|
+
// OAuth Bearer(短命轮换),只读不写、不自行刷新,避免与两端的 refresh-token 轮换互相顶掉。
|
|
102
|
+
const GROK_API_BASE = "https://cli-chat-proxy.grok.com/v1";
|
|
103
|
+
// 官方 GrokComConfig.token_header 默认值:必须是该字符串,nginx 鉴权子请求才路由到 OAuth。
|
|
104
|
+
const GROK_TOKEN_AUTH = "xai-grok-cli";
|
|
105
|
+
const GROK_CLIENT_VERSION = "1.0.13";
|
|
106
|
+
const GROK_AUTH_FILE = join(homedir(), ".grok", "auth.json");
|
|
107
|
+
// dsh-grok-kit 旧版私存路径(其 resolveXaiOAuthStorePath 的回退分支)。
|
|
108
|
+
const XAI_OAUTH_AUTH_FILE = join(homedir(), ".dsh", ".xai-oauth-auth.json");
|
|
109
|
+
// Bearer 的 JWT payload 里有 sub(= user_id);登录文件缺 user_id 时兜底取它。
|
|
110
|
+
function bearerSubject(key) {
|
|
111
|
+
try {
|
|
112
|
+
const payload = key.split(".")[1]?.replace(/-/g, "+").replace(/_/g, "/");
|
|
113
|
+
if (!payload) return undefined;
|
|
114
|
+
const claims = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
|
115
|
+
return typeof claims?.sub === "string" && claims.sub.trim() ? claims.sub.trim() : undefined;
|
|
116
|
+
} catch { return undefined; }
|
|
117
|
+
}
|
|
118
|
+
// 兼容两种登录文件形态:Grok CLI auth.json(槽位 map,槽位名含签发方)与 dsh 信封
|
|
119
|
+
// ({ version, credential: { access, accountId } })。槽位优先 auth.x.ai,其次旧签发方。
|
|
120
|
+
function grokAuthFileCredentials(io) {
|
|
121
|
+
const str = v => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
122
|
+
const read = path => {
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(io.readFileSync(path, "utf8"));
|
|
125
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
126
|
+
const envelope = parsed.credential && typeof parsed.credential === "object" && !Array.isArray(parsed.credential) ? parsed.credential : undefined;
|
|
127
|
+
const pick = (record, userId) => {
|
|
128
|
+
const key = str(record.key) ?? str(record.access) ?? str(record.access_token);
|
|
129
|
+
return key ? { key, userId: str(record.user_id) ?? str(record.principal_id) ?? userId ?? bearerSubject(key) } : undefined;
|
|
130
|
+
};
|
|
131
|
+
if (envelope) return pick(envelope, str(envelope.accountId));
|
|
132
|
+
const slots = Object.entries(parsed).filter(([, v]) => v && typeof v === "object" && !Array.isArray(v) && (str(v.key) ?? str(v.access) ?? str(v.access_token)));
|
|
133
|
+
const find = test => slots.find(([slotName]) => test(slotName))?.[1];
|
|
134
|
+
return pick(find(slotName => slotName.includes("auth.x.ai")) ?? find(slotName => slotName.includes("accounts.x.ai")) ?? slots[0]?.[1] ?? {});
|
|
135
|
+
} catch { return undefined; }
|
|
136
|
+
};
|
|
137
|
+
return read(GROK_AUTH_FILE) ?? read(XAI_OAUTH_AUTH_FILE);
|
|
138
|
+
}
|
|
139
|
+
// OpenAI Codex / ChatGPT 订阅额度:走 ChatGPT 后端私有接口,凭据是 Codex CLI 的 OAuth 登录文件
|
|
140
|
+
// (`$CODEX_HOME/auth.json`,默认 `~/.codex/auth.json`),**与 OPENAI_API_KEY 是两套**:
|
|
141
|
+
// `auth_mode` 不是 `chatgpt` 时(API Key 模式)取不到订阅额度,此时按未配置处理而不是拿 API Key 冒充。
|
|
142
|
+
// 与 Grok 一样只读:token 由 Codex CLI 自己轮换,第三端刷新会互相顶掉。
|
|
143
|
+
const CODEX_AUTH_DIR = join(homedir(), ".codex");
|
|
144
|
+
const CODEX_API_BASE = "https://chatgpt.com/backend-api";
|
|
145
|
+
export function codexAuthFileCredentials(io, env) {
|
|
146
|
+
const home = typeof env?.CODEX_HOME === "string" && env.CODEX_HOME.trim() ? env.CODEX_HOME.trim() : CODEX_AUTH_DIR;
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(io.readFileSync(join(home, "auth.json"), "utf8"));
|
|
149
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
150
|
+
if (parsed.auth_mode !== "chatgpt") return undefined;
|
|
151
|
+
const tokens = parsed.tokens && typeof parsed.tokens === "object" && !Array.isArray(parsed.tokens) ? parsed.tokens : {};
|
|
152
|
+
const key = typeof tokens.access_token === "string" && tokens.access_token.trim() ? tokens.access_token.trim() : undefined;
|
|
153
|
+
if (!key) return undefined;
|
|
154
|
+
const accountId = typeof tokens.account_id === "string" && tokens.account_id.trim() ? tokens.account_id.trim() : undefined;
|
|
155
|
+
return { key, accountId };
|
|
156
|
+
} catch { return undefined; }
|
|
157
|
+
}
|
|
56
158
|
const CONFIG_PATH = join(homedir(), ".dsh", "dsh-subusage.json");
|
|
57
159
|
// 官方 MiMo 会话 Cookie 自签发起 24 小时有效;从凭据写入时刻起计时,手动导入与自动登录同源。
|
|
58
160
|
const MIMO_COOKIE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -69,12 +171,14 @@ function redactMimo(value, cookie) {
|
|
|
69
171
|
return { ...value, ...(Array.isArray(value.extras) ? { extras: value.extras.map(extra => extra.kind === "plan" ? { ...extra, value: sanitize(extra.value) } : extra) } : {}) };
|
|
70
172
|
}
|
|
71
173
|
function defaultSettings() {
|
|
72
|
-
return { zai: { type: 1, organization: "", project: "" }, xiaomi: { cookie: "", loginAt: 0, expiresAt: 0 }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].defaultEnabled !== false])) } };
|
|
174
|
+
return { zai: { type: 1, organization: "", project: "" }, volc: { accessKeyId: "", secretAccessKey: "" }, litellm: { baseUrl: "" }, xiaomi: { cookie: "", loginAt: 0, expiresAt: 0 }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].defaultEnabled !== false])) } };
|
|
73
175
|
}
|
|
74
176
|
function parseStored(value) {
|
|
75
177
|
const s = defaultSettings();
|
|
76
178
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw failure("subusage/config", "设置文件结构无效");
|
|
77
179
|
if (value.zai && typeof value.zai === "object") s.zai = { type: value.zai.type === 2 ? 2 : 1, organization: typeof value.zai.organization === "string" ? value.zai.organization : "", project: typeof value.zai.project === "string" ? value.zai.project : "" };
|
|
180
|
+
if (value.volc && typeof value.volc === "object" && !Array.isArray(value.volc)) for (const field of ["accessKeyId", "secretAccessKey"]) if (typeof value.volc[field] === "string") s.volc[field] = value.volc[field];
|
|
181
|
+
if (value.litellm && typeof value.litellm === "object" && !Array.isArray(value.litellm) && typeof value.litellm.baseUrl === "string") s.litellm.baseUrl = value.litellm.baseUrl;
|
|
78
182
|
if (value.xiaomi && typeof value.xiaomi === "object") { if (typeof value.xiaomi.cookie === "string") s.xiaomi.cookie = value.xiaomi.cookie; for (const field of ["loginAt", "expiresAt"]) if (Number.isSafeInteger(value.xiaomi[field]) && value.xiaomi[field] > 0) s.xiaomi[field] = value.xiaomi[field]; }
|
|
79
183
|
if (typeof value.visibility?.hideWithoutApi === "boolean") s.visibility.hideWithoutApi = value.visibility.hideWithoutApi;
|
|
80
184
|
for (const id of IDS) {
|
|
@@ -86,7 +190,7 @@ function parseStored(value) {
|
|
|
86
190
|
return s;
|
|
87
191
|
}
|
|
88
192
|
function publicSettings(s) {
|
|
89
|
-
return { revision: hash(s), zai: { ...s.zai }, xiaomi: { hasCookie: !!s.xiaomi.cookie.trim(), loginAt: isoStamp(s.xiaomi.loginAt), expiresAt: isoStamp(s.xiaomi.expiresAt) }, hasKeys: Object.fromEntries(IDS.map(id => [id, !!s.keys[id]?.trim()])), keyModes: { ...s.keyModes }, visibility: { hideWithoutApi: s.visibility.hideWithoutApi, providers: { ...s.visibility.providers } } };
|
|
193
|
+
return { revision: hash(s), zai: { ...s.zai }, volc: { hasAccessKeyId: !!s.volc.accessKeyId?.trim(), hasSecretAccessKey: !!s.volc.secretAccessKey?.trim() }, litellm: { baseUrl: s.litellm.baseUrl }, xiaomi: { hasCookie: !!s.xiaomi.cookie.trim(), loginAt: isoStamp(s.xiaomi.loginAt), expiresAt: isoStamp(s.xiaomi.expiresAt) }, hasKeys: Object.fromEntries(IDS.map(id => [id, !!s.keys[id]?.trim()])), keyModes: { ...s.keyModes }, visibility: { hideWithoutApi: s.visibility.hideWithoutApi, providers: { ...s.visibility.providers } } };
|
|
90
194
|
}
|
|
91
195
|
function invalid(message) { return new RemoteError("subusage/invalid-request", message, { retryable: false, retainPrevious: false }); }
|
|
92
196
|
function parseUpdate(update) {
|
|
@@ -112,6 +216,8 @@ function parseSettings(value) {
|
|
|
112
216
|
if (value.expectedRevision !== undefined) { if (typeof value.expectedRevision !== "string") throw invalid("Invalid revision"); out.expectedRevision = value.expectedRevision; }
|
|
113
217
|
if (value.keyMode !== undefined) { if (!["inherit", "manual"].includes(value.keyMode)) throw invalid("Invalid key mode"); out.keyMode = value.keyMode; }
|
|
114
218
|
for (const field of ["keyUpdate", "cookieUpdate"]) if (value[field] !== undefined) out[field] = parseUpdate(value[field]);
|
|
219
|
+
// 火山方舟的凭据是 AK/SK 配对,只能走 volc 补丁;单值 keyUpdate 会让 AK 与 SK 失去配对来源。
|
|
220
|
+
if (PROVIDERS[value.providerId]?.credentialKind === "volc" && (value.keyUpdate !== undefined || value.cookieUpdate !== undefined)) throw invalid("Volcengine credentials use the volc patch");
|
|
115
221
|
if (PROVIDERS[value.providerId]?.managedByPlugin && (out.keyMode !== undefined || out.keyUpdate !== undefined || out.cookieUpdate !== undefined)) throw invalid("Credentials are managed by the provider plugin");
|
|
116
222
|
if (out.keyUpdate?.action === "replace" && /[\x00-\x1f\x7f]/.test(value.keyUpdate.value)) throw invalid("Invalid API key");
|
|
117
223
|
if (out.cookieUpdate && out.providerId !== "xiaomi-token-plan-cn") throw invalid("Cookie patch is MiMo-only");
|
|
@@ -130,6 +236,22 @@ function parseSettings(value) {
|
|
|
130
236
|
if (out.providerId !== "zai-coding-cn" || !value.zai || ![1, 2].includes(value.zai.type) || typeof value.zai.organization !== "string" || typeof value.zai.project !== "string") throw invalid("Invalid Z.ai patch");
|
|
131
237
|
out.zai = { type: value.zai.type, organization: value.zai.organization.trim(), project: value.zai.project.trim() };
|
|
132
238
|
}
|
|
239
|
+
if (value.litellm !== undefined) {
|
|
240
|
+
// 端点地址不是秘密,可以原样回显;这里只做形状校验,真正的合法性在请求前用 URL 解析把关。
|
|
241
|
+
if (out.providerId !== "litellm" || !value.litellm || typeof value.litellm !== "object" || Array.isArray(value.litellm) || typeof value.litellm.baseUrl !== "string" || Object.keys(value.litellm).some(key => key !== "baseUrl")) throw invalid("Invalid LiteLLM patch");
|
|
242
|
+
if (/[\x00-\x1f\x7f]/.test(value.litellm.baseUrl)) throw invalid("Invalid LiteLLM base URL");
|
|
243
|
+
out.litellm = { baseUrl: value.litellm.baseUrl.trim() };
|
|
244
|
+
}
|
|
245
|
+
if (value.volc !== undefined) {
|
|
246
|
+
const volc = value.volc;
|
|
247
|
+
if (PROVIDERS[out.providerId]?.credentialKind !== "volc" || !volc || typeof volc !== "object" || Array.isArray(volc) || !Object.keys(volc).length || Object.keys(volc).some(key => !["accessKeyId", "secretAccessKey"].includes(key))) throw invalid("Invalid Volcengine patch");
|
|
248
|
+
// 每个字段各自 keep/replace/clear:SK 不回显,空串不能当清空,否则只改 AK 会把已存的 SK 一起抹掉。
|
|
249
|
+
for (const [field, target] of [["accessKeyId", "accessKeyUpdate"], ["secretAccessKey", "secretKeyUpdate"]]) {
|
|
250
|
+
if (volc[field] === undefined) continue;
|
|
251
|
+
out[target] = parseUpdate(volc[field]);
|
|
252
|
+
if (out[target].action === "replace" && /[\x00-\x1f\x7f]/.test(volc[field].value)) throw invalid("Invalid Volcengine credential");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
133
255
|
return out;
|
|
134
256
|
}
|
|
135
257
|
function parseQuery(value) {
|
|
@@ -145,9 +267,20 @@ function parseQuery(value) {
|
|
|
145
267
|
function failure(code, message, retryable = false, retryAfterMs) {
|
|
146
268
|
return new RemoteError(code, message, { retryable, retainPrevious: retryable, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
|
|
147
269
|
}
|
|
148
|
-
|
|
270
|
+
// 火山方舟把拒绝原因放在 ResponseMetadata.Error 业务信封里:401 是签名/凭据(服务端不知道你是谁),
|
|
271
|
+
// 403 是授权(身份已确认但不能做:IAM 权限不足、套餐未开通、欠费),两者对用户的行动项不同。
|
|
272
|
+
function volcFailure(action, error) {
|
|
273
|
+
const detail = error.message ? ` · ${error.message}` : "";
|
|
274
|
+
if (/^(AuthenticationError|SignatureDoesNotMatch|InvalidAccessKey|InvalidSecretToken|InvalidCredential|InvalidAuthorization|MissingRequestInfo|InvalidTimestamp)$/.test(error.code)) return failure("subusage/auth", `火山方舟 ${action}:凭据或签名无效(${error.code})${detail}。额度查询要 IAM Access Key 的 AK/SK,不是推理用的方舟 API Key`);
|
|
275
|
+
if (/^(AccessDenied|OperationDenied|AccountOverdueError)/.test(error.code)) return failure("subusage/auth", `火山方舟 ${action}:${error.code}${detail}。请确认该 AK/SK 有方舟读权限,且账号已订阅对应套餐`);
|
|
276
|
+
if (error.code === "InvalidActionOrVersion" || error.code === "PathNotFound") return failure("subusage/response", `火山方舟 ${action}:接口不存在(${error.code}),该 Action 可能已变更`);
|
|
277
|
+
if (error.code === "FlowLimitExceeded") return failure("subusage/rate-limit", `火山方舟 ${action}:触发限流`, true);
|
|
278
|
+
if (/InternalError|InternalServiceError/.test(error.code)) return failure("subusage/http", `火山方舟 ${action}:服务端错误(${error.code})`, true);
|
|
279
|
+
return failure("subusage/response", `火山方舟 ${action}:${error.code}${detail}`);
|
|
280
|
+
}
|
|
281
|
+
async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
|
|
149
282
|
let response;
|
|
150
|
-
try { response = await fetcher(url, { headers: { accept: "application/json", "user-agent": "dsh-subusage/0.
|
|
283
|
+
try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent": "dsh-subusage/0.9.0", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
|
|
151
284
|
catch { throw failure("subusage/network", "网络请求失败或超时", true); }
|
|
152
285
|
if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
|
|
153
286
|
if (!response.ok) {
|
|
@@ -185,10 +318,32 @@ function cascadeRateLimited(windows) {
|
|
|
185
318
|
return windows;
|
|
186
319
|
}
|
|
187
320
|
export function normalizeKimi(body) {
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
321
|
+
// 上游 /coding/v1/usages 有两代形态:新版 usages 比例池(limit_5h / limit_7d /
|
|
322
|
+
// limit_month_total),旧版顶层 usage + limits[](绝对 limit / used / remaining)。
|
|
323
|
+
// 只有 300 分钟窗口在所有形态都下发,因此没有哪个窗口名可以当必需字段:现行账户
|
|
324
|
+
// 下发 5h + 月池而省略 limit_7d,旧账户则完全没有 usages。不推断未知字段。
|
|
325
|
+
const pools = [body?.usages, body?.usage, body?.limits].filter(v => v && typeof v === "object" && !Array.isArray(v));
|
|
326
|
+
const windows = [];
|
|
327
|
+
// limit_month_code 是 month_total 的 Code 份额而非独立预算,不作为独立窗口。
|
|
328
|
+
for (const [field, kind] of [["limit_5h", "5h"], ["limit_7d", "7d"], ["limit_month_total", "month"]]) {
|
|
329
|
+
const row = pools.map(pool => pool[field]).find(v => v !== undefined);
|
|
330
|
+
if (row === undefined) continue;
|
|
331
|
+
if (!numeric(row.used_ratio)) throw failure("subusage/response", "Invalid Kimi used_ratio");
|
|
332
|
+
windows.push(windowRow(kind, row.used_ratio * 100, row.reset_time));
|
|
333
|
+
}
|
|
334
|
+
// 无比例池时退回绝对值:limits[] 的 300 分钟项是 5 小时窗口,顶层 usage 是周额度。
|
|
335
|
+
if (!windows.length) {
|
|
336
|
+
const absolute = (row, kind) => {
|
|
337
|
+
const limit = Number(row?.limit), remaining = Number(row?.remaining);
|
|
338
|
+
if (!Number.isFinite(limit) || limit <= 0 || !Number.isFinite(remaining)) return;
|
|
339
|
+
windows.push(windowRow(kind, (limit - remaining) / limit * 100, row.resetTime));
|
|
340
|
+
};
|
|
341
|
+
const burst = (Array.isArray(body?.limits) ? body.limits : []).find(row => row?.window?.duration === 300 && row.window.timeUnit === "TIME_UNIT_MINUTE");
|
|
342
|
+
if (burst) absolute(burst.detail, "5h");
|
|
343
|
+
if (body?.usage && typeof body.usage === "object") absolute(body.usage, "week");
|
|
344
|
+
}
|
|
345
|
+
if (!windows.length) throw failure("subusage/response", "Invalid Kimi usage response");
|
|
346
|
+
return cascadeRateLimited(windows);
|
|
192
347
|
}
|
|
193
348
|
export function normalizeZai(body) {
|
|
194
349
|
const data = body?.data;
|
|
@@ -235,7 +390,8 @@ export function normalizeSynthetic(body) {
|
|
|
235
390
|
return { windows: [windowRow("rolling", row.requests / row.limit * 100, row.renewsAt, undefined, { used: row.requests, limit: row.limit, unit: "requests" })], extras: [], coverage: "complete" };
|
|
236
391
|
}
|
|
237
392
|
// https://docs.nano-gpt.com/api-reference/endpoint/subscription-usage
|
|
238
|
-
|
|
393
|
+
// 第二个参数是 /api/check-balance 的可选余额响应:余额是**增量**,拿不到就不显示,不影响配额。
|
|
394
|
+
export function normalizeNanoGpt(body, balanceBody) {
|
|
239
395
|
if (typeof body?.active !== "boolean" || !["active", "grace", "inactive"].includes(body.state)) throw failure("subusage/response", "Invalid NanoGPT subscription response");
|
|
240
396
|
const windows = []; let partial = false;
|
|
241
397
|
if (body.active && body.state !== "inactive") for (const [field, kind, limit] of [["dailyInputTokens", "day", body.limits?.dailyInputTokens], ["weeklyInputTokens", "week", body.limits?.weeklyInputTokens], ["tokens", "period", body.tokenLimits?.total]]) {
|
|
@@ -245,7 +401,10 @@ export function normalizeNanoGpt(body) {
|
|
|
245
401
|
const reset = numeric(row.resetAt) && Number.isFinite(new Date(row.resetAt).getTime()) ? new Date(row.resetAt).toISOString() : undefined;
|
|
246
402
|
windows.push(windowRow(kind, row.percentUsed * 100, reset, undefined, { used: row.used, limit, unit: "tokens" }));
|
|
247
403
|
}
|
|
248
|
-
|
|
404
|
+
const extras = [{ kind: "plan", value: body.state }];
|
|
405
|
+
const usd = typeof balanceBody?.usd_balance === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(balanceBody.usd_balance.trim()) ? balanceBody.usd_balance.trim() : undefined;
|
|
406
|
+
if (usd) extras.push({ kind: "balance", value: `${usd} USD` });
|
|
407
|
+
return { windows: cascadeRateLimited(windows), extras, coverage: windows.length && !partial ? "complete" : "partial" };
|
|
249
408
|
}
|
|
250
409
|
// 官方 CLI 的计数消歧规则:有 remaining_percent 时按它校准;旧响应 usage_count 是剩余。
|
|
251
410
|
// https://github.com/MiniMax-AI/cli/blob/main/src/utils/quota.ts
|
|
@@ -331,6 +490,242 @@ export function normalizeCommandCode(body, subscriptionBody) {
|
|
|
331
490
|
cascadeRateLimited(windows.filter(w => w.kind !== "month"));
|
|
332
491
|
return { windows, extras, coverage: hasMonthlyBalance && monthlyCap !== undefined ? "complete" : "partial" };
|
|
333
492
|
}
|
|
493
|
+
// SuperGrok / X Premium 统一用量池:GetGrokCreditsConfig 响应(官方 grok CLI 的
|
|
494
|
+
// /v1/billing?format=credits)。creditUsagePercent 是“已用”百分比(0-100),剩余由
|
|
495
|
+
// client 取补;新形态用 creditUsagePercent + currentPeriod(周/月),旧形态回退
|
|
496
|
+
// monthlyLimit/used(美分)+ 月账期。无绝对额度字段时不编造明细。
|
|
497
|
+
export function normalizeSuperGrok(body, settingsBody) {
|
|
498
|
+
const config = body?.config && typeof body.config === "object" && !Array.isArray(body.config) ? body.config : {};
|
|
499
|
+
const period = config.currentPeriod && typeof config.currentPeriod === "object" && !Array.isArray(config.currentPeriod) ? config.currentPeriod : undefined;
|
|
500
|
+
const cent = row => row && typeof row === "object" && typeof row.val === "number" && Number.isFinite(row.val) ? row.val : undefined;
|
|
501
|
+
let percent;
|
|
502
|
+
if (numeric(config.creditUsagePercent)) percent = config.creditUsagePercent;
|
|
503
|
+
else {
|
|
504
|
+
// 旧形态按美分绝对值折算比例;{}(proto3 零值)解码为 0,无上限(limit<=0)不折算。
|
|
505
|
+
const limit = cent(config.monthlyLimit), used = cent(config.used) ?? 0;
|
|
506
|
+
if (limit !== undefined && limit > 0) percent = used / limit * 100;
|
|
507
|
+
}
|
|
508
|
+
if (percent === undefined) throw failure("subusage/response", "Invalid SuperGrok billing response");
|
|
509
|
+
// 周期类型是 proto 枚举名;未知/缺失类型不猜窗口名,按通用订阅池展示;旧形态按月账期。
|
|
510
|
+
const kind = period?.type === "USAGE_PERIOD_TYPE_WEEKLY" ? "week" : period?.type === "USAGE_PERIOD_TYPE_MONTHLY" ? "month" : period ? "sub"
|
|
511
|
+
: config.billingPeriodStart !== undefined || config.billingPeriodEnd !== undefined ? "month" : "sub";
|
|
512
|
+
const windows = [windowRow(kind, percent, period?.end ?? config.billingPeriodEnd)];
|
|
513
|
+
const extras = [];
|
|
514
|
+
const plan = typeof settingsBody?.subscription_tier_display === "string" && settingsBody.subscription_tier_display.trim() ? settingsBody.subscription_tier_display.trim()
|
|
515
|
+
: typeof body?.subscription_tier === "string" && body.subscription_tier.trim() ? body.subscription_tier.trim() : "";
|
|
516
|
+
if (plan) extras.push({ kind: "plan", value: plan });
|
|
517
|
+
// prepaidBalance(已购加量余额)以美分计价;只合并真正报告过的值,未报告不冒充 0。
|
|
518
|
+
const prepaid = cent(config.prepaidBalance);
|
|
519
|
+
if (prepaid !== undefined) extras.push({ kind: "balance", value: `${(prepaid / 100).toFixed(2)} USD` });
|
|
520
|
+
return { windows, extras, coverage: "complete" };
|
|
521
|
+
}
|
|
522
|
+
// SiliconFlow(硅基流动):**余额型** provider —— 接口返回账户余额而不是订阅窗口。
|
|
523
|
+
// 官方 OpenAPI 仓库 siliconflow/siliconcloud 的 openapi.yaml:GET https://api.siliconflow.cn/v1/user/info
|
|
524
|
+
// (bearerAuth,与推理同一把 Key),返回 data.balance / chargeBalance / totalBalance / status。
|
|
525
|
+
// 没有上限也就没有百分比:不把余额硬凑成一个窗口,只产出 extras,由客户端的余额兜底文案显示。
|
|
526
|
+
export function normalizeSiliconFlow(body) {
|
|
527
|
+
const data = body?.data;
|
|
528
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw failure("subusage/response", "Invalid SiliconFlow user info response");
|
|
529
|
+
const amount = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.round(value * 100) / 100 : undefined;
|
|
530
|
+
const total = amount(data.totalBalance) ?? amount(data.balance);
|
|
531
|
+
if (total === undefined) throw failure("subusage/response", "SiliconFlow 响应没有可用余额");
|
|
532
|
+
// 响应不带货币字段;该平台以人民币计价,所以按 CNY 显示,不猜测成其它货币。
|
|
533
|
+
return { windows: [], extras: [{ kind: "balance", value: `${total} CNY` }], coverage: "complete" };
|
|
534
|
+
}
|
|
535
|
+
// Codex / ChatGPT 订阅用量:rate_limit.primary_window 与 secondary_window 各含 used_percent 与
|
|
536
|
+
// resets_at。免费档的次窗口是 30 天,所以有 window_seconds 时按它判窗口名,缺失时按位置回退 5h / 7d。
|
|
537
|
+
// credits 只在真正报告了可用余额时显示:has_credits=false、unlimited=true、balance="0" 都不显示,
|
|
538
|
+
// 不把「无额度」画成 0。
|
|
539
|
+
// https://raw.githubusercontent.com/steipete/CodexBar/main/docs/codex.md
|
|
540
|
+
export function normalizeCodexUsage(body) {
|
|
541
|
+
const windows = [];
|
|
542
|
+
const windowKind = (row, fallback) => {
|
|
543
|
+
const seconds = row?.window_seconds ?? row?.limit_window_seconds;
|
|
544
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return fallback;
|
|
545
|
+
return seconds >= 2592000 ? "month" : seconds >= 604800 ? "week" : seconds >= 86400 ? "day" : "5h";
|
|
546
|
+
};
|
|
547
|
+
const push = (row, fallback) => {
|
|
548
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return;
|
|
549
|
+
if (!numeric(row.used_percent)) throw failure("subusage/response", "Invalid Codex used_percent");
|
|
550
|
+
// 实测(2026-10-09):resets_at 是 **Unix 秒数**,不是 ISO 串——两种都要认,否则重置时间会静默丢失。
|
|
551
|
+
const raw = row.resets_at ?? row.reset_at;
|
|
552
|
+
const reset = typeof raw === "string" && Number.isFinite(Date.parse(raw)) ? new Date(Date.parse(raw)).toISOString()
|
|
553
|
+
: numeric(raw) && raw > 0 ? new Date(raw * 1000).toISOString()
|
|
554
|
+
: undefined;
|
|
555
|
+
windows.push(windowRow(windowKind(row, fallback), row.used_percent, reset));
|
|
556
|
+
};
|
|
557
|
+
const limits = body?.rate_limit && typeof body.rate_limit === "object" && !Array.isArray(body.rate_limit) ? body.rate_limit : {};
|
|
558
|
+
push(limits.primary_window, "5h");
|
|
559
|
+
push(limits.secondary_window, "7d");
|
|
560
|
+
// HTTP 200 但没有任何窗口 = 该账号没有可读的订阅额度,不是 0% 用量。
|
|
561
|
+
if (!windows.length) return { windows: [], extras: [{ kind: "plan", value: "未检测到订阅额度窗口" }], coverage: "partial" };
|
|
562
|
+
const extras = [];
|
|
563
|
+
// 实测:plan_type 给套餐档位(如 "plus"),值得显示。
|
|
564
|
+
const plan = typeof body?.plan_type === "string" && body.plan_type.trim() ? body.plan_type.trim() : "";
|
|
565
|
+
if (plan) extras.push({ kind: "plan", value: plan });
|
|
566
|
+
const credits = body?.credits && typeof body.credits === "object" && !Array.isArray(body.credits) ? body.credits : {};
|
|
567
|
+
const balance = typeof credits.balance === "string" && credits.balance.trim() ? credits.balance.trim() : typeof credits.balance === "number" ? String(credits.balance) : "";
|
|
568
|
+
if (credits.has_credits === true && credits.unlimited !== true && balance && balance !== "0") extras.push({ kind: "balance", value: balance });
|
|
569
|
+
return { windows: cascadeRateLimited(windows), extras, coverage: windows.length >= 2 ? "complete" : "partial" };
|
|
570
|
+
}
|
|
571
|
+
// DeepSeek 官方余额(余额型):GET https://api.deepseek.com/user/balance,Bearer 与推理同一把 Key。
|
|
572
|
+
// 响应 { is_available, balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }] },
|
|
573
|
+
// 金额是**字符串十进制**。余额没有上限也就没有百分比:不产出窗口,只产出 extras。
|
|
574
|
+
export function normalizeDeepseekBalance(body) {
|
|
575
|
+
if (typeof body?.is_available !== "boolean") throw failure("subusage/response", "Invalid DeepSeek balance response");
|
|
576
|
+
const infos = Array.isArray(body.balance_infos) ? body.balance_infos : [];
|
|
577
|
+
const row = infos.find(item => item && typeof item.currency === "string" && typeof item.total_balance === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(item.total_balance));
|
|
578
|
+
if (!row) throw failure("subusage/response", "DeepSeek 响应没有可用的余额信息");
|
|
579
|
+
const currency = /^[A-Z]{3}$/.test(row.currency) ? row.currency : "";
|
|
580
|
+
const extras = [{ kind: "balance", value: `${row.total_balance} ${currency}`.trim() }];
|
|
581
|
+
// 余额不可用是行动项(去充值),不是 0 元余额的展示问题。
|
|
582
|
+
if (body.is_available === false) extras.push({ kind: "plan", value: "余额不足,请充值" });
|
|
583
|
+
return { windows: [], extras, coverage: "complete" };
|
|
584
|
+
}
|
|
585
|
+
// OpenRouter:/key 给 key 级限额与日/周/月用量(任意 key 可读),/credits 给账户余额
|
|
586
|
+
// (只对 management / provisioning key 开放)。余额拿不到时**静默降级**为只显示限额,
|
|
587
|
+
// 不因为余额端点 403 就把整个条目判失败。
|
|
588
|
+
// https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key
|
|
589
|
+
export function normalizeOpenRouter(keyBody, creditBody) {
|
|
590
|
+
const data = keyBody?.data;
|
|
591
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw failure("subusage/response", "Invalid OpenRouter key response");
|
|
592
|
+
const num = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
593
|
+
const windows = [];
|
|
594
|
+
const limit = num(data.limit), remaining = num(data.limit_remaining);
|
|
595
|
+
if (limit !== undefined && limit > 0 && remaining !== undefined) {
|
|
596
|
+
const kind = data.limit_reset === "monthly" ? "month" : data.limit_reset === "weekly" ? "week" : data.limit_reset === "daily" ? "day" : "period";
|
|
597
|
+
windows.push(windowRow(kind, Math.max(0, limit - remaining) / limit * 100, undefined, undefined, { used: Math.round(Math.max(0, limit - remaining) * 100) / 100, limit, unit: "credits" }));
|
|
598
|
+
}
|
|
599
|
+
// 免费档没有 key 限额,但有免费模型的每日请求上限:它同样是一个真实窗口。
|
|
600
|
+
const free = data.free_model_daily_requests;
|
|
601
|
+
const freeLimit = num(free?.limit), freeUsed = num(free?.used);
|
|
602
|
+
if (freeLimit !== undefined && freeLimit > 0 && freeUsed !== undefined) {
|
|
603
|
+
windows.push(windowRow("day", Math.min(100, freeUsed / freeLimit * 100), undefined, undefined, { used: freeUsed, limit: freeLimit, unit: "requests" }));
|
|
604
|
+
}
|
|
605
|
+
const extras = [];
|
|
606
|
+
const credits = creditBody?.data;
|
|
607
|
+
const total = num(credits?.total_credits), spent = num(credits?.total_usage);
|
|
608
|
+
if (total !== undefined && spent !== undefined) extras.push({ kind: "balance", value: `${Math.round(Math.max(0, total - spent) * 100) / 100} USD` });
|
|
609
|
+
if (data.is_free_tier === true) extras.push({ kind: "plan", value: "Free tier" });
|
|
610
|
+
if (!windows.length && !extras.length) throw failure("subusage/response", "OpenRouter 响应没有可用的限额或余额");
|
|
611
|
+
return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
|
|
612
|
+
}
|
|
613
|
+
// ── 余额型聚合商(各家单位与形状都不同,逐个显式换算,不做通用折算)────────────────
|
|
614
|
+
/** Novita:金额单位是 1/10000 USD(10000 = $1.00)。 */
|
|
615
|
+
export function normalizeNovitaBalance(body) {
|
|
616
|
+
const num = value => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
617
|
+
const available = num(body?.availableBalance);
|
|
618
|
+
if (available === undefined) throw failure("subusage/response", "Invalid Novita balance response");
|
|
619
|
+
return { windows: [], extras: [{ kind: "balance", value: `${Math.round(available / 100) / 100} USD` }], coverage: "complete" };
|
|
620
|
+
}
|
|
621
|
+
/** Hyperbolic:balanceCents 是美分。 */
|
|
622
|
+
export function normalizeHyperbolicBalance(body) {
|
|
623
|
+
const cents = typeof body?.balanceCents === "number" && Number.isFinite(body.balanceCents) ? body.balanceCents : undefined;
|
|
624
|
+
if (cents === undefined) throw failure("subusage/response", "Invalid Hyperbolic balance response");
|
|
625
|
+
return { windows: [], extras: [{ kind: "balance", value: `${Math.round(cents) / 100} USD` }], coverage: "complete" };
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* DeepInfra:预付资金以**负的** stripe_balance 表示,可用金额要取负;正值代表欠款。
|
|
629
|
+
* 欠款是行动项,单独提示而不是显示成负余额。
|
|
630
|
+
*/
|
|
631
|
+
export function normalizeDeepinfraBalance(body) {
|
|
632
|
+
const raw = typeof body?.stripe_balance === "number" && Number.isFinite(body.stripe_balance) ? body.stripe_balance : undefined;
|
|
633
|
+
if (raw === undefined) throw failure("subusage/response", "Invalid DeepInfra balance response");
|
|
634
|
+
const available = Math.round(-raw * 100) / 100;
|
|
635
|
+
const extras = [{ kind: "balance", value: `${Math.max(0, available)} USD` }];
|
|
636
|
+
if (available < 0) extras.push({ kind: "plan", value: `欠款 ${Math.abs(available)} USD` });
|
|
637
|
+
return { windows: [], extras, coverage: "complete" };
|
|
638
|
+
}
|
|
639
|
+
/** Chutes:{quota, used} 直接给绝对量;重置时刻接口不给,故不显示(不编造次日 00:00 UTC)。 */
|
|
640
|
+
export function normalizeChutesQuota(body) {
|
|
641
|
+
const num = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
642
|
+
const quota = num(body?.quota), used = num(body?.used);
|
|
643
|
+
if (quota === undefined || used === undefined) throw failure("subusage/response", "Invalid Chutes quota response");
|
|
644
|
+
if (quota <= 0) throw failure("subusage/response", "Chutes 配额为 0,无法计算比例");
|
|
645
|
+
return { windows: [windowRow("period", Math.min(100, used / quota * 100), undefined, undefined, { used, limit: quota, unit: "quota" })], extras: [], coverage: "complete" };
|
|
646
|
+
}
|
|
647
|
+
/** Ollama Cloud:limits.{session,weekly,monthly}.usage 是 **0–1 小数**;任一层缺失就跳过该窗口。 */
|
|
648
|
+
export function normalizeOllamaCloudUsage(body) {
|
|
649
|
+
const limits = body?.limits && typeof body.limits === "object" && !Array.isArray(body.limits) ? body.limits : {};
|
|
650
|
+
const windows = [];
|
|
651
|
+
for (const [field, kind] of [["session", "period"], ["weekly", "week"], ["monthly", "month"]]) {
|
|
652
|
+
const usage = limits[field]?.usage;
|
|
653
|
+
if (usage === undefined || usage === null) continue;
|
|
654
|
+
if (typeof usage !== "number" || !Number.isFinite(usage) || usage < 0) throw failure("subusage/response", "Invalid Ollama Cloud usage");
|
|
655
|
+
windows.push(windowRow(kind, Math.min(100, usage * 100)));
|
|
656
|
+
}
|
|
657
|
+
if (!windows.length) throw failure("subusage/response", "Ollama Cloud 响应没有可用的额度窗口");
|
|
658
|
+
return { windows: cascadeRateLimited(windows), extras: [], coverage: windows.length >= 3 ? "complete" : "partial" };
|
|
659
|
+
}
|
|
660
|
+
/** Vercel AI Gateway:balance / total_used 是十进制字符串;没有限额也没有重置,只显示金额。 */
|
|
661
|
+
export function normalizeVercelCredits(body) {
|
|
662
|
+
const decimal = value => typeof value === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim()) ? value.trim() : typeof value === "number" && Number.isFinite(value) ? String(value) : undefined;
|
|
663
|
+
const balance = decimal(body?.balance) ?? decimal(body?.data?.balance);
|
|
664
|
+
if (balance === undefined) throw failure("subusage/response", "Invalid Vercel AI Gateway credits response");
|
|
665
|
+
return { windows: [], extras: [{ kind: "balance", value: `${balance} USD` }], coverage: "complete" };
|
|
666
|
+
}
|
|
667
|
+
// ZenMux:订阅配额走 subscription/detail(`quota_5_hour.usage_percentage` 是 **0–1 小数**),
|
|
668
|
+
// PAYG 余额走 payg/balance。两个端点都**只认 Management API Key**(推理 key 不适用),
|
|
669
|
+
// 因此继承变量单独命名,避免用户把推理 key 填进来。
|
|
670
|
+
// https://docs.zenmux.ai/api/platform/subscription-detail
|
|
671
|
+
export function normalizeZenmux(subscriptionBody, balanceBody) {
|
|
672
|
+
const num = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
673
|
+
const windows = [], extras = [];
|
|
674
|
+
const data = subscriptionBody?.data && typeof subscriptionBody.data === "object" && !Array.isArray(subscriptionBody.data) ? subscriptionBody.data : {};
|
|
675
|
+
const quota = data.quota_5_hour && typeof data.quota_5_hour === "object" ? data.quota_5_hour : {};
|
|
676
|
+
const usage = num(quota.usage_percentage);
|
|
677
|
+
if (usage !== undefined) {
|
|
678
|
+
const detail = num(quota.used_flows) !== undefined && num(quota.max_flows) !== undefined ? { used: quota.used_flows, limit: quota.max_flows, unit: "flows" } : undefined;
|
|
679
|
+
windows.push(windowRow("5h", Math.min(100, usage * 100), typeof quota.resets_at === "string" ? quota.resets_at : undefined, undefined, detail));
|
|
680
|
+
}
|
|
681
|
+
const tier = data.plan && typeof data.plan.tier === "string" ? data.plan.tier.trim() : "";
|
|
682
|
+
if (tier) extras.push({ kind: "plan", value: tier });
|
|
683
|
+
const balance = balanceBody?.data && typeof balanceBody.data === "object" && !Array.isArray(balanceBody.data) ? balanceBody.data : {};
|
|
684
|
+
const total = num(balance.total_credits);
|
|
685
|
+
if (total !== undefined) {
|
|
686
|
+
const currency = typeof balance.currency === "string" && /^[A-Za-z]{3}$/.test(balance.currency) ? balance.currency.toUpperCase() : "";
|
|
687
|
+
extras.push({ kind: "balance", value: `${Math.round(total * 100) / 100} ${currency}`.trim() });
|
|
688
|
+
}
|
|
689
|
+
if (!windows.length && !extras.length) throw failure("subusage/response", "ZenMux 响应没有可用的配额或余额");
|
|
690
|
+
return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* LiteLLM 的管理端点在 **proxy 根**(不是 /v1):去掉尾部斜杠与常见的 /v1 后缀,
|
|
694
|
+
* 否则会拼成 /v1/key/info 而 404。只接受 http(s),其它协议一律视为未配置。
|
|
695
|
+
*/
|
|
696
|
+
export function litellmRoot(baseUrl) {
|
|
697
|
+
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
|
|
698
|
+
if (!raw) return "";
|
|
699
|
+
let url;
|
|
700
|
+
try { url = new URL(raw); } catch { return ""; }
|
|
701
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
|
702
|
+
const path = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
703
|
+
return `${url.origin}${path}`;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* LiteLLM:`/key/info` 给 key 自身的预算与花费,`/user/info`|`/team/info` 给更完整的视图。
|
|
707
|
+
* 有预算上限才画进度;**没有上限时不编造百分比**,只如实显示已用金额。
|
|
708
|
+
* 接口不给重置时刻时不显示重置(别把金额渲染成倒计时)。
|
|
709
|
+
* https://docs.litellm.ai/docs/proxy/virtual_keys
|
|
710
|
+
*/
|
|
711
|
+
export function normalizeLitellm(keyBody, budgetBody) {
|
|
712
|
+
const num = value => typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
713
|
+
const pick = value => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
714
|
+
// 真实响应是 `{ key, info: {...} }`;也接受直接把字段放在顶层。
|
|
715
|
+
const keyInfo = pick(keyBody?.info) ?? pick(keyBody);
|
|
716
|
+
if (!keyInfo) throw failure("subusage/response", "Invalid LiteLLM key info response");
|
|
717
|
+
const scope = pick(budgetBody) ?? {};
|
|
718
|
+
const spend = num(scope.spend) ?? num(keyInfo.spend);
|
|
719
|
+
const maxBudget = num(scope.max_budget) ?? num(keyInfo.max_budget);
|
|
720
|
+
const resetAt = typeof scope.budget_reset_at === "string" ? scope.budget_reset_at : typeof keyInfo.budget_reset_at === "string" ? keyInfo.budget_reset_at : undefined;
|
|
721
|
+
const round = value => Math.round(value * 100) / 100;
|
|
722
|
+
if (spend !== undefined && maxBudget !== undefined && maxBudget > 0) {
|
|
723
|
+
return { windows: [windowRow("period", Math.min(100, spend / maxBudget * 100), resetAt, undefined, { used: round(spend), limit: round(maxBudget), unit: "USD" })], extras: [], coverage: "complete" };
|
|
724
|
+
}
|
|
725
|
+
// 没有预算上限:没有百分比可算,如实给金额。
|
|
726
|
+
if (spend !== undefined) return { windows: [], extras: [{ kind: "spend", value: `${round(spend)} USD` }], coverage: "complete" };
|
|
727
|
+
throw failure("subusage/response", "LiteLLM 响应没有可用的预算或花费");
|
|
728
|
+
}
|
|
334
729
|
function detectConfigured(ctx) {
|
|
335
730
|
try { const ids = new Set(ctx.llm.listProviders().map(p => p.id)); return Object.fromEntries(IDS.map(id => [id, ids.has(id)])); }
|
|
336
731
|
catch { return Object.fromEntries(IDS.map(id => [id, false])); }
|
|
@@ -363,11 +758,18 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
363
758
|
this.now = options.now ?? Date.now;
|
|
364
759
|
this.credentials = options.resolveCredentials ?? (async id => await ctx.get("credentials")?.resolve(credentialRef(PROVIDERS[id].envName)));
|
|
365
760
|
this.environment = options.resolveEnvironment ?? (id => launchEnvironmentOf(ctx).get(PROVIDERS[id].envName));
|
|
761
|
+
// 火山方舟的 AK/SK 不是路由的推理 Key,按固定变量名各自解析,两端都命中才算配对。
|
|
762
|
+
this.credentialByName = options.resolveCredentialByName ?? (async refName => await ctx.get("credentials")?.resolve(credentialRef(refName)));
|
|
763
|
+
this.environmentByName = options.resolveEnvironmentByName ?? (refName => launchEnvironmentOf(ctx).get(refName));
|
|
366
764
|
// 额外账户按凭据引用名(apiKeyEnv)解析,与提供方插件 slots()/resolveRef 同一通道;
|
|
367
765
|
// 默认账户仍走上面的 envName 链(凭据服务 → 启动环境 → ~/.commandcode/auth.json)。
|
|
368
766
|
this.accountCredentials = options.resolveAccountCredentials ?? (async ref => await ctx.get("credentials")?.resolve(credentialRef(ref)));
|
|
369
767
|
this.accountEnvironment = options.resolveAccountEnvironment ?? (ref => launchEnvironmentOf(ctx).get(ref));
|
|
370
768
|
this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
|
|
769
|
+
// SuperGrok 只读共享登录文件(~/.grok/auth.json 等)取 OAuth Bearer;可注入仅供隔离测试。
|
|
770
|
+
this.grokAuth = options.resolveGrokAuth ?? (async () => grokAuthFileCredentials(this.io));
|
|
771
|
+
// Codex 只读共享登录文件($CODEX_HOME/auth.json 或 ~/.codex/auth.json)取 OAuth access token。
|
|
772
|
+
this.codexAuth = options.resolveCodexAuth ?? (async () => codexAuthFileCredentials(this.io, { CODEX_HOME: (await this.environmentByName("CODEX_HOME"))?.value }));
|
|
371
773
|
this.cache = new Map(); this.inflight = new Map(); this.generations = new Map(); this.fingerprints = new Map(); this.disposed = false; this.controller = new AbortController();
|
|
372
774
|
this.login = new MimoLogin({
|
|
373
775
|
error: failure,
|
|
@@ -391,6 +793,18 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
391
793
|
result(settings, entries) { return { updatedAt: new Date(this.now()).toISOString(), settings: publicSettings(settings), configured: detectConfigured(this.ctx), entries }; }
|
|
392
794
|
async identity(id, s, account) {
|
|
393
795
|
if (id === "xiaomi-token-plan-cn") return { key: s.xiaomi.cookie.trim(), source: s.xiaomi.cookie.trim() ? "cookie" : "none" };
|
|
796
|
+
if (PROVIDERS[id].credentialKind === "volc") return await this.volcIdentity(id, s);
|
|
797
|
+
if (id === "xai-oauth") {
|
|
798
|
+
// SuperGrok 只认共享登录文件里的 OAuth Bearer:token 短命轮换,凭据服务/环境变量副本
|
|
799
|
+
// 必然过期顶掉新 token,故不走通用 Key 链;user_id 随行给 x-userid 请求头。
|
|
800
|
+
const cred = await this.grokAuth();
|
|
801
|
+
return cred?.key ? { key: cred.key, source: "auth-file", ...(cred.userId ? { userId: cred.userId } : {}) } : { key: undefined, source: "none" };
|
|
802
|
+
}
|
|
803
|
+
if (id === "openai-codex") {
|
|
804
|
+
// 同上:只读 Codex CLI 的 OAuth 登录文件,token 由 Codex CLI 轮换,第三端刷新会互相顶掉。
|
|
805
|
+
const cred = await this.codexAuth();
|
|
806
|
+
return cred?.key ? { key: cred.key, source: "auth-file", ...(cred.accountId ? { accountId: cred.accountId } : {}) } : { key: undefined, source: "none" };
|
|
807
|
+
}
|
|
394
808
|
if (!PROVIDERS[id].managedByPlugin && s.keyModes[id] === "manual") return { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
|
|
395
809
|
if (id === "commandcode" && account && account !== "default") {
|
|
396
810
|
// 额外账户:id 即凭据引用名(apiKeyEnv)。引用名不合法(不可能来自提供方
|
|
@@ -413,11 +827,44 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
413
827
|
}
|
|
414
828
|
return PROVIDERS[id].managedByPlugin ? { key: undefined, source: "none" } : { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
|
|
415
829
|
}
|
|
830
|
+
// 火山方舟额度接口是 IAM Access Key 鉴权:AK 与 SK 必须**同源配对**。跨来源拼接
|
|
831
|
+
// (例如拿路由的推理 Key 去配凭据库里的 SK)只会得到 401,且报错看不出根因。
|
|
832
|
+
async volcIdentity(id, s) {
|
|
833
|
+
const ak = s.volc.accessKeyId.trim(), sk = s.volc.secretAccessKey.trim();
|
|
834
|
+
if (s.keyModes[id] === "manual") return ak && sk ? { key: ak, secret: sk, source: "manual" } : { key: undefined, secret: undefined, source: "none" };
|
|
835
|
+
const credentialAk = await this.credentialByName(VOLC_ACCESS_KEY_ENV), credentialSk = await this.credentialByName(VOLC_SECRET_KEY_ENV);
|
|
836
|
+
if (typeof credentialAk?.value === "string" && credentialAk.value.trim() && typeof credentialSk?.value === "string" && credentialSk.value.trim()) return { key: credentialAk.value.trim(), secret: credentialSk.value.trim(), source: "credentials" };
|
|
837
|
+
const envAk = await this.environmentByName(VOLC_ACCESS_KEY_ENV), envSk = await this.environmentByName(VOLC_SECRET_KEY_ENV);
|
|
838
|
+
if (typeof envAk?.value === "string" && envAk.value.trim() && typeof envSk?.value === "string" && envSk.value.trim()) return { key: envAk.value.trim(), secret: envSk.value.trim(), source: "env" };
|
|
839
|
+
return ak && sk ? { key: ak, secret: sk, source: "manual" } : { key: undefined, secret: undefined, source: "none" };
|
|
840
|
+
}
|
|
416
841
|
async fetchProvider(id, identity, settings, signal = this.controller.signal) {
|
|
417
|
-
const get = (url, headers) => fetchJson(this.fetcher, url, headers, signal);
|
|
842
|
+
const get = (url, headers, init) => fetchJson(this.fetcher, url, headers, signal, init);
|
|
843
|
+
// 火山方舟额度走管控面 OpenAPI:POST 根路径 + Action/Version 在 query,鉴权是 AK/SK 签名。
|
|
844
|
+
if (PROVIDERS[id].credentialKind === "volc") {
|
|
845
|
+
const spec = PROVIDERS[id];
|
|
846
|
+
const signed = volcSignature({ accessKeyId: identity.key, secretAccessKey: identity.secret, action: spec.volcAction, host: spec.volcHost, region: spec.volcRegion });
|
|
847
|
+
const body = await get(signed.url, signed.headers, { method: "POST", body: "{}" });
|
|
848
|
+
const apiError = volcError(body);
|
|
849
|
+
if (apiError) throw volcFailure(spec.volcAction, apiError);
|
|
850
|
+
let parsed;
|
|
851
|
+
try { parsed = spec.volcAction === "GetAFPUsage" ? parseVolcAgentPlan(body) : parseVolcCodingPlan(body); }
|
|
852
|
+
catch { throw failure("subusage/response", `火山方舟 ${spec.volcAction} 的额度结构无法解析`); }
|
|
853
|
+
const windows = parsed.windows.map(w => windowRow(w.kind, w.percent, w.resetsAt, w.percent >= 100 ? "rate-limited" : undefined, w.detail));
|
|
854
|
+
const extras = [];
|
|
855
|
+
if (parsed.plan) extras.push({ kind: "plan", value: parsed.plan });
|
|
856
|
+
// HTTP 200 但窗口为空是「未订阅」,不是 0% 用量:显式说明,不画零额度的假象。
|
|
857
|
+
else if (!windows.length) extras.push({ kind: "plan", value: parsed.subscribed ? "本周期暂无窗口数据" : "未检测到该套餐订阅" });
|
|
858
|
+
return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
|
|
859
|
+
}
|
|
418
860
|
const headers = { authorization: `Bearer ${identity.key}` };
|
|
419
861
|
if (id === "synthetic") return normalizeSynthetic(await get("https://api.synthetic.new/v2/quotas", headers));
|
|
420
|
-
if (id === "nanogpt")
|
|
862
|
+
if (id === "nanogpt") {
|
|
863
|
+
const body = await get(identity.key.startsWith("sk-nano-mgmt-") ? "https://nano-gpt.com/api/management/v1/subscription/usage" : "https://api.nano-gpt.com/api/subscription/v1/usage", headers);
|
|
864
|
+
// 余额走独立端点,且鉴权头是 x-api-key(不是 Bearer);拿不到就静默降级,不影响配额展示。
|
|
865
|
+
const balance = await get("https://nano-gpt.com/api/check-balance", { "x-api-key": identity.key }, { method: "POST", body: "{}" }).catch(() => undefined);
|
|
866
|
+
return normalizeNanoGpt(body, balance);
|
|
867
|
+
}
|
|
421
868
|
if (id === "zai-coding") {
|
|
422
869
|
const out = normalizeZai(await get("https://api.z.ai/api/monitor/usage/quota/limit", { authorization: identity.key }));
|
|
423
870
|
return { windows: out.windows, extras: out.level ? [{ kind: "plan", value: out.level }] : [] };
|
|
@@ -433,6 +880,42 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
433
880
|
}
|
|
434
881
|
if (id === "opencode-go") return normalizeOpencodeGo(await get("https://opencode.ai/zen/go/v1/usage", headers));
|
|
435
882
|
if (id === "minimax" || id === "minimax-cn") return normalizeMinimax(await get(PROVIDERS[id].usageUrl, headers));
|
|
883
|
+
if (id === "siliconflow") return normalizeSiliconFlow(await get("https://api.siliconflow.cn/v1/user/info", headers));
|
|
884
|
+
if (id === "deepseek") return normalizeDeepseekBalance(await get("https://api.deepseek.com/user/balance", headers));
|
|
885
|
+
if (id === "openrouter") {
|
|
886
|
+
const keyBody = await get("https://openrouter.ai/api/v1/key", headers);
|
|
887
|
+
// 余额端点对普通推理 key 会 403;这一次失败不影响 key 限额与用量的展示。
|
|
888
|
+
const creditBody = await get("https://openrouter.ai/api/v1/credits", headers).catch(() => undefined);
|
|
889
|
+
return normalizeOpenRouter(keyBody, creditBody);
|
|
890
|
+
}
|
|
891
|
+
// 余额型聚合商:单体 GET,单位换算在各 normalize 里显式做。
|
|
892
|
+
if (id === "novita") return normalizeNovitaBalance(await get("https://api.novita.ai/openapi/v1/billing/balance/detail", headers));
|
|
893
|
+
if (id === "hyperbolic") return normalizeHyperbolicBalance(await get("https://api.hyperbolic.ai/v2/customer/balance", headers));
|
|
894
|
+
if (id === "deepinfra") return normalizeDeepinfraBalance(await get("https://api.deepinfra.com/payment/checklist?compute_owed=true", headers));
|
|
895
|
+
if (id === "chutes") return normalizeChutesQuota(await get("https://api.chutes.ai/users/me/quota_usage/me", headers));
|
|
896
|
+
// Ollama Cloud 的鉴权是裸 Authorization:这里显式覆盖掉通用 Bearer。
|
|
897
|
+
if (id === "ollama-cloud") return normalizeOllamaCloudUsage(await get("https://ollama.com/api/usage", { authorization: identity.key }));
|
|
898
|
+
if (id === "vercel-ai-gateway") return normalizeVercelCredits(await get("https://ai-gateway.vercel.sh/v1/credits", headers));
|
|
899
|
+
if (id === "zenmux") {
|
|
900
|
+
// 订阅配额与 PAYG 余额各拿各的:任一失败不影响另一个。
|
|
901
|
+
const [subscription, balance] = await Promise.all([
|
|
902
|
+
get("https://zenmux.ai/api/v1/management/subscription/detail", headers).catch(() => undefined),
|
|
903
|
+
get("https://zenmux.ai/api/v1/management/payg/balance", headers).catch(() => undefined)
|
|
904
|
+
]);
|
|
905
|
+
return normalizeZenmux(subscription, balance);
|
|
906
|
+
}
|
|
907
|
+
if (id === "litellm") {
|
|
908
|
+
// 地址是用户自己的 proxy;缺地址算「凭据不完整」,与缺 Key 一样给出可行动的指引。
|
|
909
|
+
const root = litellmRoot(settings.litellm?.baseUrl);
|
|
910
|
+
if (!root) throw failure("subusage/credentials", "未配置 LiteLLM 代理地址:在 设置 → 订阅用量 → LiteLLM 的「连接与凭据」里填写");
|
|
911
|
+
const keyInfo = await get(`${root}/key/info`, headers);
|
|
912
|
+
const own = keyInfo?.info && typeof keyInfo.info === "object" && !Array.isArray(keyInfo.info) ? keyInfo.info : {};
|
|
913
|
+
// 更完整的预算视图在 user/team info;拿不到就退回 key 自身的 info。
|
|
914
|
+
const scoped = typeof own.team_id === "string" && own.team_id.trim() ? `/team/info?team_id=${encodeURIComponent(own.team_id.trim())}`
|
|
915
|
+
: typeof own.user_id === "string" && own.user_id.trim() ? `/user/info?user_id=${encodeURIComponent(own.user_id.trim())}` : "";
|
|
916
|
+
const budget = scoped ? await get(`${root}${scoped}`, headers).catch(() => undefined) : undefined;
|
|
917
|
+
return normalizeLitellm(keyInfo, budget);
|
|
918
|
+
}
|
|
436
919
|
if (id === "commandcode") {
|
|
437
920
|
// 与提供方插件同一凭据来源,但直连计费接口并行拉取:其 report() 会对每个账户
|
|
438
921
|
// 串行 whoami + 三个端点(60 秒预算),作为药丸刷新来源太慢。仅并行读取余额与套餐/账期;请求头对齐其 accountHeaders。
|
|
@@ -443,6 +926,21 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
443
926
|
]);
|
|
444
927
|
return normalizeCommandCode(credits, subscription);
|
|
445
928
|
}
|
|
929
|
+
if (id === "xai-oauth") {
|
|
930
|
+
// 直连官方 Grok CLI 计费代理;X-XAI-Token-Auth 的取值是 token_header 字符串(xai-grok-cli)
|
|
931
|
+
// 而非布尔,x-userid 取登录文件。套餐名走 /v1/settings(subscription_tier_display),
|
|
932
|
+
// 失败不阻塞用量(回退 billing 响应里的 subscription_tier)。
|
|
933
|
+
const grokHeaders = { ...headers, "x-xai-token-auth": GROK_TOKEN_AUTH, "x-grok-client-version": GROK_CLIENT_VERSION, ...(identity.userId ? { "x-userid": identity.userId } : {}) };
|
|
934
|
+
const [billing, settings] = await Promise.all([
|
|
935
|
+
get(`${GROK_API_BASE}/billing?format=credits`, grokHeaders),
|
|
936
|
+
get(`${GROK_API_BASE}/settings`, grokHeaders).catch(() => undefined)
|
|
937
|
+
]);
|
|
938
|
+
return normalizeSuperGrok(billing, settings);
|
|
939
|
+
}
|
|
940
|
+
if (id === "openai-codex") {
|
|
941
|
+
// ChatGPT 后端私有接口:Bearer 是 Codex CLI 的 OAuth access token,不是 OPENAI_API_KEY。
|
|
942
|
+
return normalizeCodexUsage(await get(`${CODEX_API_BASE}/wham/usage`, headers));
|
|
943
|
+
}
|
|
446
944
|
const base = "https://platform.xiaomimimo.com/api/v1";
|
|
447
945
|
const h = { cookie: identity.key, origin: "https://platform.xiaomimimo.com", referer: "https://platform.xiaomimimo.com/#/console/balance", "x-timezone": "UTC+08:00" };
|
|
448
946
|
let usageError;
|
|
@@ -472,7 +970,7 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
472
970
|
}
|
|
473
971
|
// fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
|
|
474
972
|
// 空串与 "default" 同为默认账户,归一后共享缓存。
|
|
475
|
-
const fingerprint = hash([identity.key ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null, id === "commandcode" ? (account && account !== "default" ? account : "default") : null]);
|
|
973
|
+
const fingerprint = hash([identity.key ?? "", identity.secret ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null, id === "commandcode" ? (account && account !== "default" ? account : "default") : null]);
|
|
476
974
|
if (this.fingerprints.has(id) && this.fingerprints.get(id) !== fingerprint) this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
|
|
477
975
|
this.fingerprints.set(id, fingerprint);
|
|
478
976
|
const generation = this.generations.get(id) ?? 0;
|
|
@@ -486,7 +984,7 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
486
984
|
if (previous && (now < previous.retryAt || !resetDue && !force && now - previous.at < TTL)) return previous.entry;
|
|
487
985
|
const promise = (async () => {
|
|
488
986
|
const base = this.base(id, identity.source, settings, account); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
|
|
489
|
-
if (!identity.key) entry = { ...base, apiDetected: false, state: id === "xiaomi-token-plan-cn" ? "no-cookie" : "no-key", error: id === "xiaomi-token-plan-cn" ? "需登录或粘贴 Cookie" : id === "commandcode" ? (account && account !== "default" ? `未检测到账户 ${account} 的 Key:确认提供方插件已保存该账户的凭据(凭据服务或启动环境中无引用 ${account})` : "未检测到 Key:可在 设置 → Command Code 登录、运行 cmd login,或使用 COMMANDCODE_API_KEY(凭据服务/启动环境)") : "未配置 API Key", errorCode: "subusage/no-key" };
|
|
987
|
+
if (!identity.key) entry = { ...base, apiDetected: false, state: id === "xiaomi-token-plan-cn" ? "no-cookie" : "no-key", error: id === "xiaomi-token-plan-cn" ? "需登录或粘贴 Cookie" : id === "commandcode" ? (account && account !== "default" ? `未检测到账户 ${account} 的 Key:确认提供方插件已保存该账户的凭据(凭据服务或启动环境中无引用 ${account})` : "未检测到 Key:可在 设置 → Command Code 登录、运行 cmd login,或使用 COMMANDCODE_API_KEY(凭据服务/启动环境)") : id === "xai-oauth" ? "未检测到 Grok 登录:可在 设置 → Grok Kit(xAI OAuth)登录、运行 grok login(共享登录文件 ~/.grok/auth.json)" : id === "openai-codex" ? "未检测到 Codex 订阅登录:运行 codex login 完成 ChatGPT 登录(登录文件需为 $CODEX_HOME/auth.json 或 ~/.codex/auth.json,且 auth_mode 为 chatgpt;API Key 模式取不到订阅额度)" : PROVIDERS[id].credentialKind === "volc" ? "未配置火山方舟 AK/SK:额度查询要 IAM Access Key(VOLC_ACCESSKEY / VOLC_SECRETKEY,或在本页填写),不是推理用的方舟 API Key" : "未配置 API Key", errorCode: "subusage/no-key" };
|
|
490
988
|
else try {
|
|
491
989
|
const data = await this.fetchProvider(id, identity, settings);
|
|
492
990
|
const usageError = data.usageError; delete data.usageError;
|
|
@@ -508,6 +1006,30 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
508
1006
|
this.inflight.set(id, { fingerprint, generation, promise });
|
|
509
1007
|
try { return await promise; } finally { if (this.inflight.get(id)?.promise === promise) this.inflight.delete(id); }
|
|
510
1008
|
}
|
|
1009
|
+
// 对外只读额度视图:供其他插件(ctx.get("subUsage").quota(...))与 subusage_quota 工具使用。
|
|
1010
|
+
// **刻意不返回 settings,也不返回 keySource / apiDetected**:第三方消费者只需要额度,
|
|
1011
|
+
// 不需要知道本机配了哪些凭据,更不该拿到任何与 Key 相关的字段。
|
|
1012
|
+
async quota(request = {}) {
|
|
1013
|
+
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
1014
|
+
const wanted = Array.isArray(request?.providerIds) && request.providerIds.length ? request.providerIds.filter(id => IDS.includes(id)) : [...IDS];
|
|
1015
|
+
const settings = this.load();
|
|
1016
|
+
const force = request?.force === true;
|
|
1017
|
+
const entries = await Promise.all(wanted.map(id => this.provider(id, settings, force).catch(() => ({ providerId: id, label: PROVIDERS[id].label, state: "error", errorCode: "subusage/internal", error: "读取用量失败" }))));
|
|
1018
|
+
return {
|
|
1019
|
+
updatedAt: new Date(this.now()).toISOString(),
|
|
1020
|
+
providers: entries.map(entry => ({
|
|
1021
|
+
providerId: entry.providerId,
|
|
1022
|
+
label: entry.label,
|
|
1023
|
+
state: entry.state,
|
|
1024
|
+
windows: Array.isArray(entry.windows) ? entry.windows : [],
|
|
1025
|
+
extras: Array.isArray(entry.extras) ? entry.extras : [],
|
|
1026
|
+
coverage: entry.coverage,
|
|
1027
|
+
freshness: entry.freshness,
|
|
1028
|
+
lastSuccessAt: entry.lastSuccessAt,
|
|
1029
|
+
...(entry.error ? { error: entry.error } : {})
|
|
1030
|
+
}))
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
511
1033
|
async read(request) { return this.refresh(request && request.providerIds ? request : { providerIds: IDS, force: false }); }
|
|
512
1034
|
async refresh(request) {
|
|
513
1035
|
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
@@ -517,8 +1039,7 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
517
1039
|
if (hash(latest) !== hash(settings)) return this.result(latest, query.providerIds.map(id => ({ ...this.base(id, "none", latest, query.commandCodeAccount), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" })));
|
|
518
1040
|
return this.result(latest, entries);
|
|
519
1041
|
}
|
|
520
|
-
startMimoLogin(request) { return this.login.start(parseLoginStart(request)); }
|
|
521
|
-
getMimoLoginStatus() { return this.login.status(); }
|
|
1042
|
+
startMimoLogin(request) { return this.login.start(parseLoginStart(request)); } getMimoLoginStatus() { return this.login.status(); }
|
|
522
1043
|
cancelMimoLogin(request) { return this.login.cancel(parseLoginCancel(request)); }
|
|
523
1044
|
mimoMaterial(s) { const id = "xiaomi-token-plan-cn"; return hash([s.xiaomi.cookie, s.keys[id] ?? "", s.keyModes[id]]); }
|
|
524
1045
|
commitMimoLogin(cookie, captured, verified, observedExpiresAt) {
|
|
@@ -530,7 +1051,9 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
530
1051
|
const entry = { ...this.base(id, "cookie", stored), ...data, state: "ok", coverage: data.coverage ?? "partial", freshness: "fresh", lastSuccessAt: new Date(this.now()).toISOString(), ...(usageError ? { errorCode: usageError.code, error: "额度暂时无法读取", retryable: usageError.details?.retryable === true } : {}) };
|
|
531
1052
|
// 响应内容也不得回显本次 Cookie(服务端字段属于不可信输入)。
|
|
532
1053
|
const safeEntry = redactMimo(entry, cookie);
|
|
533
|
-
|
|
1054
|
+
// 指纹段必须与 provider() 的五段顺序逐位对齐:MiMo 没有 secret,第二段固定空串。
|
|
1055
|
+
// 少一段会让登录提交后的首次刷新认不出同一份缓存,重复拉取。
|
|
1056
|
+
const fingerprint = hash([cookie, "", "cookie", stored.keyModes[id], null, null]);
|
|
534
1057
|
this.fingerprints.set(id, fingerprint); this.cache.set(id, { fingerprint, entry: safeEntry, at: this.now(), retryAt: usageError ? this.now() + 15000 : 0, failures: 0 });
|
|
535
1058
|
this.loginResultMaterial = this.mimoMaterial(stored);
|
|
536
1059
|
return this.result(stored, [safeEntry]);
|
|
@@ -541,7 +1064,9 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
541
1064
|
const patch = parseSettings(settings); const stored = this.load();
|
|
542
1065
|
if (patch.expectedRevision !== undefined && patch.expectedRevision !== hash(stored)) throw failure("subusage/revision-conflict", "设置已被其他窗口修改,请重新读取");
|
|
543
1066
|
const id = patch.providerId;
|
|
544
|
-
|
|
1067
|
+
// 火山方舟两个 provider 共用一组 AK/SK:改凭据要让所有用它的条目失效,不能只失效当前标签页。
|
|
1068
|
+
const sharedVolc = patch.accessKeyUpdate !== undefined || patch.secretKeyUpdate !== undefined;
|
|
1069
|
+
const affected = patch.visibility ? Object.keys(patch.visibility.providers ?? {}).filter(key => stored.visibility.providers[key] !== patch.visibility.providers[key]) : sharedVolc ? IDS.filter(key => PROVIDERS[key].credentialKind === "volc") : [id];
|
|
545
1070
|
if (patch.visibility) {
|
|
546
1071
|
if (patch.visibility.hideWithoutApi !== undefined) stored.visibility.hideWithoutApi = patch.visibility.hideWithoutApi;
|
|
547
1072
|
Object.assign(stored.visibility.providers, patch.visibility.providers);
|
|
@@ -558,6 +1083,12 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
558
1083
|
stored.xiaomi.expiresAt = Math.min(at + MIMO_COOKIE_TTL_MS, Number.isSafeInteger(cookieCap) && cookieCap > at ? cookieCap : Number.POSITIVE_INFINITY);
|
|
559
1084
|
}
|
|
560
1085
|
if (patch.zai) stored.zai = patch.zai;
|
|
1086
|
+
if (patch.litellm) stored.litellm = patch.litellm;
|
|
1087
|
+
// volc 用「置空字符串」而不是 delete:这两个字段在内存里必须始终存在(publicSettings 会读它们),
|
|
1088
|
+
// 空串与 parseStored 的默认值同义 = 未配置。
|
|
1089
|
+
const updateVolc = (key, op) => { if (op?.action === "replace") stored.volc[key] = op.value; else if (op?.action === "clear") stored.volc[key] = ""; };
|
|
1090
|
+
updateVolc("accessKeyId", patch.accessKeyUpdate);
|
|
1091
|
+
updateVolc("secretAccessKey", patch.secretKeyUpdate);
|
|
561
1092
|
try { this.io.mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); this.io.writeFileSync(`${this.path}.tmp`, JSON.stringify(stored, null, 2), { encoding: "utf8", mode: 0o600 }); this.io.chmodSync(`${this.path}.tmp`, 0o600); this.io.renameSync(`${this.path}.tmp`, this.path); }
|
|
562
1093
|
catch { throw failure("subusage/config", "无法保存订阅设置"); }
|
|
563
1094
|
for (const key of affected) { this.generations.set(key, (this.generations.get(key) ?? 0) + 1); this.cache.delete(key); this.inflight.delete(key); }
|
|
@@ -568,6 +1099,76 @@ export class SubUsageService extends TypertRemoteService {
|
|
|
568
1099
|
function registerRemotes(ctx) {
|
|
569
1100
|
ctx.inject(["typert"], scope => { scope.effect(() => scope.typert.register({ package: subUsageRemote.package, face: "host", schemas: [], model: { services: [], events: [], objects: [] }, invocations: subUsageRemote.descriptors })); });
|
|
570
1101
|
}
|
|
571
|
-
function apply(ctx) { registerRemotes(ctx); ctx.plugin(SubUsageService, {}); ctx.logger.info("dsh-subusage: usage service ready"); }
|
|
1102
|
+
function apply(ctx) { registerRemotes(ctx); ctx.plugin(SubUsageService, {}); registerQuotaTool(ctx); ctx.logger.info("dsh-subusage: usage service ready"); }
|
|
1103
|
+
/** 工具结果的人读文本:一行一个 provider,先给窗口再给余额,异常与未配置直说。 */
|
|
1104
|
+
function formatQuotaText(value) {
|
|
1105
|
+
const providers = Array.isArray(value?.providers) ? value.providers : [];
|
|
1106
|
+
if (!providers.length) return "没有可读的提供商额度。";
|
|
1107
|
+
return providers.map(entry => {
|
|
1108
|
+
const head = `${entry.label || entry.providerId} [${entry.state}]`;
|
|
1109
|
+
if (entry.state === "no-key" || entry.state === "no-cookie") return `${head}:未配置凭据`;
|
|
1110
|
+
if (entry.state === "disabled") return `${head}:检测已关闭`;
|
|
1111
|
+
if (entry.state !== "ok") return `${head}:${entry.error || "读取失败"}`;
|
|
1112
|
+
const parts = [];
|
|
1113
|
+
for (const window of entry.windows || []) {
|
|
1114
|
+
parts.push(`${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
|
|
1115
|
+
}
|
|
1116
|
+
for (const extra of entry.extras || []) parts.push(`${extra.kind}: ${extra.value}`);
|
|
1117
|
+
if (entry.freshness === "stale") parts.push("缓存数据");
|
|
1118
|
+
return `${head}:${parts.length ? parts.join(";") : "暂无额度数据"}`;
|
|
1119
|
+
}).join("\n");
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* 注册 subusage_quota 工具:模型可以直接问「额度还剩多少」,不必自己逐个厂商请求。
|
|
1123
|
+
* 工具只读:不写设置、不碰凭据,数据来自与设置页同一份缓存(TTL 60 秒)。
|
|
1124
|
+
*/
|
|
1125
|
+
function registerQuotaTool(ctx) {
|
|
1126
|
+
ctx.inject(["tools"], toolCtx => {
|
|
1127
|
+
toolCtx.tools.register(defineTool({
|
|
1128
|
+
name: "subusage_quota",
|
|
1129
|
+
description: "Read the remaining quota and balance of the AI subscriptions configured on this machine (DeepSeek, Z.ai, Kimi, MiMo, OpenCode Go, Command Code, Codex, SuperGrok, MiniMax, Volcengine Ark, OpenRouter, SiliconFlow, ...). Read-only: it never returns credentials and never changes settings. Results may come from a cache up to 60 seconds old; pass refresh=true only when the user explicitly needs up-to-date numbers.",
|
|
1130
|
+
parameters: {
|
|
1131
|
+
providers: { type: "array", items: { type: "string" }, description: "Optional provider ids to limit the query, e.g. [\"deepseek\", \"openrouter\"]. Omit to read every provider." },
|
|
1132
|
+
refresh: { type: "boolean", description: "Force a fresh fetch instead of the up-to-60s cache. Use only when the user asks for the latest numbers." }
|
|
1133
|
+
},
|
|
1134
|
+
output: {
|
|
1135
|
+
schema: {
|
|
1136
|
+
type: "object",
|
|
1137
|
+
additionalProperties: false,
|
|
1138
|
+
properties: {
|
|
1139
|
+
updatedAt: { type: "string", required: true, description: "ISO timestamp of this reading." },
|
|
1140
|
+
providers: {
|
|
1141
|
+
type: "array",
|
|
1142
|
+
required: true,
|
|
1143
|
+
description: "One entry per provider.",
|
|
1144
|
+
items: {
|
|
1145
|
+
type: "object",
|
|
1146
|
+
additionalProperties: false,
|
|
1147
|
+
properties: {
|
|
1148
|
+
providerId: { type: "string", required: true },
|
|
1149
|
+
label: { type: "string", required: true },
|
|
1150
|
+
state: { type: "string", required: true, description: "ok | no-key | no-cookie | error | disabled" },
|
|
1151
|
+
windows: { type: "array", required: true, description: "Quota windows; percent is the USED percentage (0-100)." },
|
|
1152
|
+
extras: { type: "array", required: true, description: "Balances and plan names (kind/value)." },
|
|
1153
|
+
coverage: { type: "string", description: "complete | partial" },
|
|
1154
|
+
freshness: { type: "string", description: "fresh | stale" },
|
|
1155
|
+
lastSuccessAt: { type: "string" },
|
|
1156
|
+
error: { type: "string", description: "Failure reason when state is error." }
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
render: (_args, value) => [{ type: "text", text: formatQuotaText(value) }]
|
|
1163
|
+
},
|
|
1164
|
+
execute: async (args, exec) => {
|
|
1165
|
+
exec?.signal?.throwIfAborted?.();
|
|
1166
|
+
const service = toolCtx.get("subUsage");
|
|
1167
|
+
if (!service || typeof service.quota !== "function") throw new Error("dsh-subusage 服务尚未就绪");
|
|
1168
|
+
return await service.quota({ providerIds: args?.providers, force: args?.refresh === true });
|
|
1169
|
+
}
|
|
1170
|
+
}));
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
572
1173
|
export { apply, inject, name };
|
|
573
1174
|
export default { name, inject, apply };
|