dsh-subusage 0.8.3 → 0.10.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/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, parseVolcSeatAfp, parseVolcSeatCoding, parseVolcSeatIds, volcError, volcSignature } from "./volcengine.js";
10
12
 
11
13
  const name = "dsh-subusage";
12
14
  const inject = ["llm"];
@@ -18,13 +20,70 @@ 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
- "minimax": { label: "MiniMax", envName: "MINIMAX_API_KEY", usageUrl: "https://api.minimax.io/v1/token_plan/remains" },
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
+ // 火山方舟:方舟路由由两种方式写入——官方 CLI(arkcli helper,见下)或官方插件
39
+ // @volcengine/ark-plan-api(legacy 段)。**provider id 必须与写入方逐字一致**,
40
+ // 否则选中方舟模型时药丸不会出现。额度读取走管控面 OpenAPI,凭据是 IAM 的 AK/SK 配对
41
+ // (HMAC-SHA256 签名),与这些路由的推理 API Key 是两套:拿推理 Key 去查额度会被上游拒绝
42
+ // ("GetAFPUsage requires Volcengine Ark SSO STS")。
43
+ // 7 条 volc 路由共用一组 AK/SK(settings.volc):个人版各自固定一个 Action,
44
+ // 企业版/团队版走 ListSeatInfos → GetSeatAFPUsage / GetSeatInfoUsage 两步。
45
+ // ── arkcli(官方 CLI)安装的路由 ──────────────────────────────────────────
46
+ // 官方 CLI 的 `arkcli helper configure deepseek-harness` 把 provider 写成
47
+ // `arkcli-<planType>`(本机实测:`arkcli-agent-plan`),凭据引用是
48
+ // `ARKCLI_<PLAN>_API_KEY`。这是目前使用人数最多的安装方式,故默认开启
49
+ // (没配 AK/SK 时会被「没有检测到 API 的默认隐藏」收起)。
50
+ // 注意:路由(数据面,模型访问)与额度查询(管控面,需 IAM AK/SK)是两码事。
51
+ "arkcli-agent-plan": { label: "ARK Agent Plan (arkcli)", envName: "ARKCLI_AGENT_PLAN_API_KEY", credentialKind: "volc", volcAction: "GetAFPUsage", volcHost: "open.volcengineapi.com", volcRegion: "cn-beijing" },
52
+ "arkcli-coding-plan": { label: "ARK Coding Plan (arkcli)", envName: "ARKCLI_CODING_PLAN_API_KEY", credentialKind: "volc", volcAction: "GetCodingPlanUsage", volcHost: "open.volcengineapi.com", volcRegion: "cn-beijing" },
53
+ // 企业版/团队版:额度挂在席位上,先 ListSeatInfos 取 SeatID 再查该席位(volcSeat 决定用哪对 Action)。
54
+ "arkcli-agent-plan-team": { label: "ARK Agent Plan Team (arkcli)", envName: "ARKCLI_AGENT_PLAN_TEAM_API_KEY", credentialKind: "volc", volcSeat: "agent", volcScene: "agent_plan_enterprise", volcHost: "open.volcengineapi.com", volcRegion: "cn-beijing", defaultEnabled: false },
55
+ "arkcli-coding-plan-team": { label: "ARK Coding Plan Team (arkcli)", envName: "ARKCLI_CODING_PLAN_TEAM_API_KEY", credentialKind: "volc", volcSeat: "coding", volcScene: "coding_plan_enterprise", volcHost: "open.volcengineapi.com", volcRegion: "cn-beijing", defaultEnabled: false },
56
+ // ── 旧插件 @volcengine/ark-plan-api 注册的路由(保留兼容)─────────────────
57
+ // 两条路线的 provider id 不同,故并存;装旧插件的用户仍会走这几条。
58
+ "ark-coding-plan-cn": { label: "Ark Coding Plan (CN, legacy plugin)", envName: "ARK_CODING_PLAN_CN_API_KEY", credentialKind: "volc", volcAction: "GetCodingPlanUsage", volcHost: "open.volcengineapi.com", volcRegion: "cn-beijing", defaultEnabled: false },
59
+ "ark-agent-plan-cn": { label: "Ark Agent Plan (CN, legacy plugin)", envName: "ARK_AGENT_PLAN_CN_API_KEY", credentialKind: "volc", volcAction: "GetAFPUsage", volcHost: "open.volcengineapi.com", volcRegion: "cn-beijing", defaultEnabled: false },
60
+ // BytePlus(海外)Coding Plan:同一套 AK/SK,但走 BytePlus 管控面与 ap-southeast-1 区域。
61
+ "ark-coding-plan-byteplus": { label: "Ark Coding Plan (BytePlus, legacy plugin)", envName: "ARK_CODING_PLAN_BYTEPLUS_API_KEY", credentialKind: "volc", volcAction: "GetCodingPlanUsage", volcHost: "ark.ap-southeast-1.byteplusapi.com", volcRegion: "ap-southeast-1", defaultEnabled: false },
62
+ // DeepSeek 官方:余额型(不是订阅窗口),与推理**同一把** API Key,端点与响应见官方文档
63
+ // https://api-docs.deepseek.com/zh-cn/api/get-user-balance
64
+ "deepseek": { label: "DeepSeek", envName: "DEEPSEEK_API_KEY" },
65
+ // OpenRouter:key 限额与用量走 /key(任意 key 可用),账户余额走 /credits(**只对
66
+ // management / provisioning key 开放**,普通推理 key 会被拒 → 静默降级为只显示限额)。
67
+ "openrouter": { label: "OpenRouter", envName: "OPENROUTER_API_KEY", defaultEnabled: false },
68
+ // 以下六家都是**余额型**(单体 GET + 一个余额数字),与推理**同一把** Key;默认关闭,
69
+ // 没配 Key 时由「没有检测到 API 的默认隐藏」收起。
70
+ "novita": { label: "Novita AI", envName: "NOVITA_API_KEY", defaultEnabled: false },
71
+ "hyperbolic": { label: "Hyperbolic", envName: "HYPERBOLIC_API_KEY", defaultEnabled: false },
72
+ "deepinfra": { label: "DeepInfra", envName: "DEEPINFRA_API_KEY", defaultEnabled: false },
73
+ "chutes": { label: "Chutes", envName: "CHUTES_API_KEY", defaultEnabled: false },
74
+ // Ollama Cloud 的鉴权是**裸 Authorization**(不加 Bearer),照抄别家实现时必须注意。
75
+ "ollama-cloud": { label: "Ollama Cloud", envName: "OLLAMA_API_KEY", defaultEnabled: false },
76
+ "vercel-ai-gateway": { label: "Vercel AI Gateway", envName: "AI_GATEWAY_API_KEY", defaultEnabled: false },
77
+ // ZenMux 的额度端点只认 Management API Key;变量名单独区分以免误填推理 key。
78
+ "zenmux": { label: "ZenMux", envName: "ZENMUX_MANAGEMENT_API_KEY", defaultEnabled: false },
79
+ // LiteLLM 是自建网关:除虚拟 Key 外还要配自己的 proxy 地址(settings.litellm.baseUrl)。
80
+ "litellm": { label: "LiteLLM", envName: "LITELLM_API_KEY", defaultEnabled: false }
26
81
  };
27
82
  const IDS = Object.keys(PROVIDERS);
83
+ // 火山方舟额度接口的凭据是 IAM Access Key:AK 与 SK 必须同源配对,跨来源拼接只会得到 401。
84
+ // 变量名沿用火山官方 SDK 习惯;与方舟推理 Key(ARK_*_API_KEY)无关。
85
+ const VOLC_ACCESS_KEY_ENV = "VOLC_ACCESSKEY";
86
+ const VOLC_SECRET_KEY_ENV = "VOLC_SECRETKEY";
28
87
  // 与 @mars-sea/dsh-commandcode-provider 一致的 CLI 版本标识(其常量 COMMAND_CODE_CLI_VERSION)。
29
88
  const COMMAND_CODE_CLI_VERSION = "1.73.0";
30
89
  const COMMAND_CODE_API_BASE = "https://api.commandcode.ai";
@@ -53,6 +112,65 @@ function commandCodeAuthFileKey(io) {
53
112
  return str(parsed.apiKey) ?? str(parsed.commandcode) ?? fromRecord(parsed.commandcode) ?? fromRecord(parsed["command-code"]);
54
113
  } catch { return undefined; }
55
114
  }
115
+ // SuperGrok / X Premium 订阅(dsh-grok-kit 的 xai-oauth 路由):用量走官方 Grok CLI 的计费
116
+ // 代理(转发后端 GetGrokCreditsConfig)。凭据是 dsh-grok-kit / Grok CLI 共享登录文件里的
117
+ // OAuth Bearer(短命轮换),只读不写、不自行刷新,避免与两端的 refresh-token 轮换互相顶掉。
118
+ const GROK_API_BASE = "https://cli-chat-proxy.grok.com/v1";
119
+ // 官方 GrokComConfig.token_header 默认值:必须是该字符串,nginx 鉴权子请求才路由到 OAuth。
120
+ const GROK_TOKEN_AUTH = "xai-grok-cli";
121
+ const GROK_CLIENT_VERSION = "1.0.13";
122
+ const GROK_AUTH_FILE = join(homedir(), ".grok", "auth.json");
123
+ // dsh-grok-kit 旧版私存路径(其 resolveXaiOAuthStorePath 的回退分支)。
124
+ const XAI_OAUTH_AUTH_FILE = join(homedir(), ".dsh", ".xai-oauth-auth.json");
125
+ // Bearer 的 JWT payload 里有 sub(= user_id);登录文件缺 user_id 时兜底取它。
126
+ function bearerSubject(key) {
127
+ try {
128
+ const payload = key.split(".")[1]?.replace(/-/g, "+").replace(/_/g, "/");
129
+ if (!payload) return undefined;
130
+ const claims = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
131
+ return typeof claims?.sub === "string" && claims.sub.trim() ? claims.sub.trim() : undefined;
132
+ } catch { return undefined; }
133
+ }
134
+ // 兼容两种登录文件形态:Grok CLI auth.json(槽位 map,槽位名含签发方)与 dsh 信封
135
+ // ({ version, credential: { access, accountId } })。槽位优先 auth.x.ai,其次旧签发方。
136
+ function grokAuthFileCredentials(io) {
137
+ const str = v => typeof v === "string" && v.trim() ? v.trim() : undefined;
138
+ const read = path => {
139
+ try {
140
+ const parsed = JSON.parse(io.readFileSync(path, "utf8"));
141
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
142
+ const envelope = parsed.credential && typeof parsed.credential === "object" && !Array.isArray(parsed.credential) ? parsed.credential : undefined;
143
+ const pick = (record, userId) => {
144
+ const key = str(record.key) ?? str(record.access) ?? str(record.access_token);
145
+ return key ? { key, userId: str(record.user_id) ?? str(record.principal_id) ?? userId ?? bearerSubject(key) } : undefined;
146
+ };
147
+ if (envelope) return pick(envelope, str(envelope.accountId));
148
+ const slots = Object.entries(parsed).filter(([, v]) => v && typeof v === "object" && !Array.isArray(v) && (str(v.key) ?? str(v.access) ?? str(v.access_token)));
149
+ const find = test => slots.find(([slotName]) => test(slotName))?.[1];
150
+ return pick(find(slotName => slotName.includes("auth.x.ai")) ?? find(slotName => slotName.includes("accounts.x.ai")) ?? slots[0]?.[1] ?? {});
151
+ } catch { return undefined; }
152
+ };
153
+ return read(GROK_AUTH_FILE) ?? read(XAI_OAUTH_AUTH_FILE);
154
+ }
155
+ // OpenAI Codex / ChatGPT 订阅额度:走 ChatGPT 后端私有接口,凭据是 Codex CLI 的 OAuth 登录文件
156
+ // (`$CODEX_HOME/auth.json`,默认 `~/.codex/auth.json`),**与 OPENAI_API_KEY 是两套**:
157
+ // `auth_mode` 不是 `chatgpt` 时(API Key 模式)取不到订阅额度,此时按未配置处理而不是拿 API Key 冒充。
158
+ // 与 Grok 一样只读:token 由 Codex CLI 自己轮换,第三端刷新会互相顶掉。
159
+ const CODEX_AUTH_DIR = join(homedir(), ".codex");
160
+ const CODEX_API_BASE = "https://chatgpt.com/backend-api";
161
+ export function codexAuthFileCredentials(io, env) {
162
+ const home = typeof env?.CODEX_HOME === "string" && env.CODEX_HOME.trim() ? env.CODEX_HOME.trim() : CODEX_AUTH_DIR;
163
+ try {
164
+ const parsed = JSON.parse(io.readFileSync(join(home, "auth.json"), "utf8"));
165
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
166
+ if (parsed.auth_mode !== "chatgpt") return undefined;
167
+ const tokens = parsed.tokens && typeof parsed.tokens === "object" && !Array.isArray(parsed.tokens) ? parsed.tokens : {};
168
+ const key = typeof tokens.access_token === "string" && tokens.access_token.trim() ? tokens.access_token.trim() : undefined;
169
+ if (!key) return undefined;
170
+ const accountId = typeof tokens.account_id === "string" && tokens.account_id.trim() ? tokens.account_id.trim() : undefined;
171
+ return { key, accountId };
172
+ } catch { return undefined; }
173
+ }
56
174
  const CONFIG_PATH = join(homedir(), ".dsh", "dsh-subusage.json");
57
175
  // 官方 MiMo 会话 Cookie 自签发起 24 小时有效;从凭据写入时刻起计时,手动导入与自动登录同源。
58
176
  const MIMO_COOKIE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -69,12 +187,14 @@ function redactMimo(value, cookie) {
69
187
  return { ...value, ...(Array.isArray(value.extras) ? { extras: value.extras.map(extra => extra.kind === "plan" ? { ...extra, value: sanitize(extra.value) } : extra) } : {}) };
70
188
  }
71
189
  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])) } };
190
+ 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
191
  }
74
192
  function parseStored(value) {
75
193
  const s = defaultSettings();
76
194
  if (!value || typeof value !== "object" || Array.isArray(value)) throw failure("subusage/config", "设置文件结构无效");
77
195
  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 : "" };
196
+ 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];
197
+ if (value.litellm && typeof value.litellm === "object" && !Array.isArray(value.litellm) && typeof value.litellm.baseUrl === "string") s.litellm.baseUrl = value.litellm.baseUrl;
78
198
  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
199
  if (typeof value.visibility?.hideWithoutApi === "boolean") s.visibility.hideWithoutApi = value.visibility.hideWithoutApi;
80
200
  for (const id of IDS) {
@@ -86,7 +206,7 @@ function parseStored(value) {
86
206
  return s;
87
207
  }
88
208
  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 } } };
209
+ 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
210
  }
91
211
  function invalid(message) { return new RemoteError("subusage/invalid-request", message, { retryable: false, retainPrevious: false }); }
92
212
  function parseUpdate(update) {
@@ -112,6 +232,8 @@ function parseSettings(value) {
112
232
  if (value.expectedRevision !== undefined) { if (typeof value.expectedRevision !== "string") throw invalid("Invalid revision"); out.expectedRevision = value.expectedRevision; }
113
233
  if (value.keyMode !== undefined) { if (!["inherit", "manual"].includes(value.keyMode)) throw invalid("Invalid key mode"); out.keyMode = value.keyMode; }
114
234
  for (const field of ["keyUpdate", "cookieUpdate"]) if (value[field] !== undefined) out[field] = parseUpdate(value[field]);
235
+ // 火山方舟的凭据是 AK/SK 配对,只能走 volc 补丁;单值 keyUpdate 会让 AK 与 SK 失去配对来源。
236
+ if (PROVIDERS[value.providerId]?.credentialKind === "volc" && (value.keyUpdate !== undefined || value.cookieUpdate !== undefined)) throw invalid("Volcengine credentials use the volc patch");
115
237
  if (PROVIDERS[value.providerId]?.managedByPlugin && (out.keyMode !== undefined || out.keyUpdate !== undefined || out.cookieUpdate !== undefined)) throw invalid("Credentials are managed by the provider plugin");
116
238
  if (out.keyUpdate?.action === "replace" && /[\x00-\x1f\x7f]/.test(value.keyUpdate.value)) throw invalid("Invalid API key");
117
239
  if (out.cookieUpdate && out.providerId !== "xiaomi-token-plan-cn") throw invalid("Cookie patch is MiMo-only");
@@ -130,6 +252,22 @@ function parseSettings(value) {
130
252
  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
253
  out.zai = { type: value.zai.type, organization: value.zai.organization.trim(), project: value.zai.project.trim() };
132
254
  }
255
+ if (value.litellm !== undefined) {
256
+ // 端点地址不是秘密,可以原样回显;这里只做形状校验,真正的合法性在请求前用 URL 解析把关。
257
+ 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");
258
+ if (/[\x00-\x1f\x7f]/.test(value.litellm.baseUrl)) throw invalid("Invalid LiteLLM base URL");
259
+ out.litellm = { baseUrl: value.litellm.baseUrl.trim() };
260
+ }
261
+ if (value.volc !== undefined) {
262
+ const volc = value.volc;
263
+ 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");
264
+ // 每个字段各自 keep/replace/clear:SK 不回显,空串不能当清空,否则只改 AK 会把已存的 SK 一起抹掉。
265
+ for (const [field, target] of [["accessKeyId", "accessKeyUpdate"], ["secretAccessKey", "secretKeyUpdate"]]) {
266
+ if (volc[field] === undefined) continue;
267
+ out[target] = parseUpdate(volc[field]);
268
+ if (out[target].action === "replace" && /[\x00-\x1f\x7f]/.test(volc[field].value)) throw invalid("Invalid Volcengine credential");
269
+ }
270
+ }
133
271
  return out;
134
272
  }
135
273
  function parseQuery(value) {
@@ -145,9 +283,20 @@ function parseQuery(value) {
145
283
  function failure(code, message, retryable = false, retryAfterMs) {
146
284
  return new RemoteError(code, message, { retryable, retainPrevious: retryable, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
147
285
  }
148
- async function fetchJson(fetcher, url, headers, lifetimeSignal) {
286
+ // 火山方舟把拒绝原因放在 ResponseMetadata.Error 业务信封里:401 是签名/凭据(服务端不知道你是谁),
287
+ // 403 是授权(身份已确认但不能做:IAM 权限不足、套餐未开通、欠费),两者对用户的行动项不同。
288
+ function volcFailure(action, error) {
289
+ const detail = error.message ? ` · ${error.message}` : "";
290
+ 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`);
291
+ if (/^(AccessDenied|OperationDenied|AccountOverdueError)/.test(error.code)) return failure("subusage/auth", `火山方舟 ${action}:${error.code}${detail}。请确认该 AK/SK 有方舟读权限,且账号已订阅对应套餐`);
292
+ if (error.code === "InvalidActionOrVersion" || error.code === "PathNotFound") return failure("subusage/response", `火山方舟 ${action}:接口不存在(${error.code}),该 Action 可能已变更`);
293
+ if (error.code === "FlowLimitExceeded") return failure("subusage/rate-limit", `火山方舟 ${action}:触发限流`, true);
294
+ if (/InternalError|InternalServiceError/.test(error.code)) return failure("subusage/http", `火山方舟 ${action}:服务端错误(${error.code})`, true);
295
+ return failure("subusage/response", `火山方舟 ${action}:${error.code}${detail}`);
296
+ }
297
+ async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
149
298
  let response;
150
- try { response = await fetcher(url, { headers: { accept: "application/json", "user-agent": "dsh-subusage/0.8.3", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
299
+ try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent": "dsh-subusage/0.10.0", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
151
300
  catch { throw failure("subusage/network", "网络请求失败或超时", true); }
152
301
  if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
153
302
  if (!response.ok) {
@@ -257,7 +406,8 @@ export function normalizeSynthetic(body) {
257
406
  return { windows: [windowRow("rolling", row.requests / row.limit * 100, row.renewsAt, undefined, { used: row.requests, limit: row.limit, unit: "requests" })], extras: [], coverage: "complete" };
258
407
  }
259
408
  // https://docs.nano-gpt.com/api-reference/endpoint/subscription-usage
260
- export function normalizeNanoGpt(body) {
409
+ // 第二个参数是 /api/check-balance 的可选余额响应:余额是**增量**,拿不到就不显示,不影响配额。
410
+ export function normalizeNanoGpt(body, balanceBody) {
261
411
  if (typeof body?.active !== "boolean" || !["active", "grace", "inactive"].includes(body.state)) throw failure("subusage/response", "Invalid NanoGPT subscription response");
262
412
  const windows = []; let partial = false;
263
413
  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]]) {
@@ -267,7 +417,10 @@ export function normalizeNanoGpt(body) {
267
417
  const reset = numeric(row.resetAt) && Number.isFinite(new Date(row.resetAt).getTime()) ? new Date(row.resetAt).toISOString() : undefined;
268
418
  windows.push(windowRow(kind, row.percentUsed * 100, reset, undefined, { used: row.used, limit, unit: "tokens" }));
269
419
  }
270
- return { windows: cascadeRateLimited(windows), extras: [{ kind: "plan", value: body.state }], coverage: windows.length && !partial ? "complete" : "partial" };
420
+ const extras = [{ kind: "plan", value: body.state }];
421
+ const usd = typeof balanceBody?.usd_balance === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(balanceBody.usd_balance.trim()) ? balanceBody.usd_balance.trim() : undefined;
422
+ if (usd) extras.push({ kind: "balance", value: `${usd} USD` });
423
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length && !partial ? "complete" : "partial" };
271
424
  }
272
425
  // 官方 CLI 的计数消歧规则:有 remaining_percent 时按它校准;旧响应 usage_count 是剩余。
273
426
  // https://github.com/MiniMax-AI/cli/blob/main/src/utils/quota.ts
@@ -353,6 +506,242 @@ export function normalizeCommandCode(body, subscriptionBody) {
353
506
  cascadeRateLimited(windows.filter(w => w.kind !== "month"));
354
507
  return { windows, extras, coverage: hasMonthlyBalance && monthlyCap !== undefined ? "complete" : "partial" };
355
508
  }
509
+ // SuperGrok / X Premium 统一用量池:GetGrokCreditsConfig 响应(官方 grok CLI 的
510
+ // /v1/billing?format=credits)。creditUsagePercent 是“已用”百分比(0-100),剩余由
511
+ // client 取补;新形态用 creditUsagePercent + currentPeriod(周/月),旧形态回退
512
+ // monthlyLimit/used(美分)+ 月账期。无绝对额度字段时不编造明细。
513
+ export function normalizeSuperGrok(body, settingsBody) {
514
+ const config = body?.config && typeof body.config === "object" && !Array.isArray(body.config) ? body.config : {};
515
+ const period = config.currentPeriod && typeof config.currentPeriod === "object" && !Array.isArray(config.currentPeriod) ? config.currentPeriod : undefined;
516
+ const cent = row => row && typeof row === "object" && typeof row.val === "number" && Number.isFinite(row.val) ? row.val : undefined;
517
+ let percent;
518
+ if (numeric(config.creditUsagePercent)) percent = config.creditUsagePercent;
519
+ else {
520
+ // 旧形态按美分绝对值折算比例;{}(proto3 零值)解码为 0,无上限(limit<=0)不折算。
521
+ const limit = cent(config.monthlyLimit), used = cent(config.used) ?? 0;
522
+ if (limit !== undefined && limit > 0) percent = used / limit * 100;
523
+ }
524
+ if (percent === undefined) throw failure("subusage/response", "Invalid SuperGrok billing response");
525
+ // 周期类型是 proto 枚举名;未知/缺失类型不猜窗口名,按通用订阅池展示;旧形态按月账期。
526
+ const kind = period?.type === "USAGE_PERIOD_TYPE_WEEKLY" ? "week" : period?.type === "USAGE_PERIOD_TYPE_MONTHLY" ? "month" : period ? "sub"
527
+ : config.billingPeriodStart !== undefined || config.billingPeriodEnd !== undefined ? "month" : "sub";
528
+ const windows = [windowRow(kind, percent, period?.end ?? config.billingPeriodEnd)];
529
+ const extras = [];
530
+ const plan = typeof settingsBody?.subscription_tier_display === "string" && settingsBody.subscription_tier_display.trim() ? settingsBody.subscription_tier_display.trim()
531
+ : typeof body?.subscription_tier === "string" && body.subscription_tier.trim() ? body.subscription_tier.trim() : "";
532
+ if (plan) extras.push({ kind: "plan", value: plan });
533
+ // prepaidBalance(已购加量余额)以美分计价;只合并真正报告过的值,未报告不冒充 0。
534
+ const prepaid = cent(config.prepaidBalance);
535
+ if (prepaid !== undefined) extras.push({ kind: "balance", value: `${(prepaid / 100).toFixed(2)} USD` });
536
+ return { windows, extras, coverage: "complete" };
537
+ }
538
+ // SiliconFlow(硅基流动):**余额型** provider —— 接口返回账户余额而不是订阅窗口。
539
+ // 官方 OpenAPI 仓库 siliconflow/siliconcloud 的 openapi.yaml:GET https://api.siliconflow.cn/v1/user/info
540
+ // (bearerAuth,与推理同一把 Key),返回 data.balance / chargeBalance / totalBalance / status。
541
+ // 没有上限也就没有百分比:不把余额硬凑成一个窗口,只产出 extras,由客户端的余额兜底文案显示。
542
+ export function normalizeSiliconFlow(body) {
543
+ const data = body?.data;
544
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw failure("subusage/response", "Invalid SiliconFlow user info response");
545
+ const amount = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.round(value * 100) / 100 : undefined;
546
+ const total = amount(data.totalBalance) ?? amount(data.balance);
547
+ if (total === undefined) throw failure("subusage/response", "SiliconFlow 响应没有可用余额");
548
+ // 响应不带货币字段;该平台以人民币计价,所以按 CNY 显示,不猜测成其它货币。
549
+ return { windows: [], extras: [{ kind: "balance", value: `${total} CNY` }], coverage: "complete" };
550
+ }
551
+ // Codex / ChatGPT 订阅用量:rate_limit.primary_window 与 secondary_window 各含 used_percent 与
552
+ // resets_at。免费档的次窗口是 30 天,所以有 window_seconds 时按它判窗口名,缺失时按位置回退 5h / 7d。
553
+ // credits 只在真正报告了可用余额时显示:has_credits=false、unlimited=true、balance="0" 都不显示,
554
+ // 不把「无额度」画成 0。
555
+ // https://raw.githubusercontent.com/steipete/CodexBar/main/docs/codex.md
556
+ export function normalizeCodexUsage(body) {
557
+ const windows = [];
558
+ const windowKind = (row, fallback) => {
559
+ const seconds = row?.window_seconds ?? row?.limit_window_seconds;
560
+ if (!Number.isFinite(seconds) || seconds <= 0) return fallback;
561
+ return seconds >= 2592000 ? "month" : seconds >= 604800 ? "week" : seconds >= 86400 ? "day" : "5h";
562
+ };
563
+ const push = (row, fallback) => {
564
+ if (!row || typeof row !== "object" || Array.isArray(row)) return;
565
+ if (!numeric(row.used_percent)) throw failure("subusage/response", "Invalid Codex used_percent");
566
+ // 实测(2026-10-09):resets_at 是 **Unix 秒数**,不是 ISO 串——两种都要认,否则重置时间会静默丢失。
567
+ const raw = row.resets_at ?? row.reset_at;
568
+ const reset = typeof raw === "string" && Number.isFinite(Date.parse(raw)) ? new Date(Date.parse(raw)).toISOString()
569
+ : numeric(raw) && raw > 0 ? new Date(raw * 1000).toISOString()
570
+ : undefined;
571
+ windows.push(windowRow(windowKind(row, fallback), row.used_percent, reset));
572
+ };
573
+ const limits = body?.rate_limit && typeof body.rate_limit === "object" && !Array.isArray(body.rate_limit) ? body.rate_limit : {};
574
+ push(limits.primary_window, "5h");
575
+ push(limits.secondary_window, "7d");
576
+ // HTTP 200 但没有任何窗口 = 该账号没有可读的订阅额度,不是 0% 用量。
577
+ if (!windows.length) return { windows: [], extras: [{ kind: "plan", value: "未检测到订阅额度窗口" }], coverage: "partial" };
578
+ const extras = [];
579
+ // 实测:plan_type 给套餐档位(如 "plus"),值得显示。
580
+ const plan = typeof body?.plan_type === "string" && body.plan_type.trim() ? body.plan_type.trim() : "";
581
+ if (plan) extras.push({ kind: "plan", value: plan });
582
+ const credits = body?.credits && typeof body.credits === "object" && !Array.isArray(body.credits) ? body.credits : {};
583
+ const balance = typeof credits.balance === "string" && credits.balance.trim() ? credits.balance.trim() : typeof credits.balance === "number" ? String(credits.balance) : "";
584
+ if (credits.has_credits === true && credits.unlimited !== true && balance && balance !== "0") extras.push({ kind: "balance", value: balance });
585
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length >= 2 ? "complete" : "partial" };
586
+ }
587
+ // DeepSeek 官方余额(余额型):GET https://api.deepseek.com/user/balance,Bearer 与推理同一把 Key。
588
+ // 响应 { is_available, balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }] },
589
+ // 金额是**字符串十进制**。余额没有上限也就没有百分比:不产出窗口,只产出 extras。
590
+ export function normalizeDeepseekBalance(body) {
591
+ if (typeof body?.is_available !== "boolean") throw failure("subusage/response", "Invalid DeepSeek balance response");
592
+ const infos = Array.isArray(body.balance_infos) ? body.balance_infos : [];
593
+ const row = infos.find(item => item && typeof item.currency === "string" && typeof item.total_balance === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(item.total_balance));
594
+ if (!row) throw failure("subusage/response", "DeepSeek 响应没有可用的余额信息");
595
+ const currency = /^[A-Z]{3}$/.test(row.currency) ? row.currency : "";
596
+ const extras = [{ kind: "balance", value: `${row.total_balance} ${currency}`.trim() }];
597
+ // 余额不可用是行动项(去充值),不是 0 元余额的展示问题。
598
+ if (body.is_available === false) extras.push({ kind: "plan", value: "余额不足,请充值" });
599
+ return { windows: [], extras, coverage: "complete" };
600
+ }
601
+ // OpenRouter:/key 给 key 级限额与日/周/月用量(任意 key 可读),/credits 给账户余额
602
+ // (只对 management / provisioning key 开放)。余额拿不到时**静默降级**为只显示限额,
603
+ // 不因为余额端点 403 就把整个条目判失败。
604
+ // https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key
605
+ export function normalizeOpenRouter(keyBody, creditBody) {
606
+ const data = keyBody?.data;
607
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw failure("subusage/response", "Invalid OpenRouter key response");
608
+ const num = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
609
+ const windows = [];
610
+ const limit = num(data.limit), remaining = num(data.limit_remaining);
611
+ if (limit !== undefined && limit > 0 && remaining !== undefined) {
612
+ const kind = data.limit_reset === "monthly" ? "month" : data.limit_reset === "weekly" ? "week" : data.limit_reset === "daily" ? "day" : "period";
613
+ 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" }));
614
+ }
615
+ // 免费档没有 key 限额,但有免费模型的每日请求上限:它同样是一个真实窗口。
616
+ const free = data.free_model_daily_requests;
617
+ const freeLimit = num(free?.limit), freeUsed = num(free?.used);
618
+ if (freeLimit !== undefined && freeLimit > 0 && freeUsed !== undefined) {
619
+ windows.push(windowRow("day", Math.min(100, freeUsed / freeLimit * 100), undefined, undefined, { used: freeUsed, limit: freeLimit, unit: "requests" }));
620
+ }
621
+ const extras = [];
622
+ const credits = creditBody?.data;
623
+ const total = num(credits?.total_credits), spent = num(credits?.total_usage);
624
+ if (total !== undefined && spent !== undefined) extras.push({ kind: "balance", value: `${Math.round(Math.max(0, total - spent) * 100) / 100} USD` });
625
+ if (data.is_free_tier === true) extras.push({ kind: "plan", value: "Free tier" });
626
+ if (!windows.length && !extras.length) throw failure("subusage/response", "OpenRouter 响应没有可用的限额或余额");
627
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
628
+ }
629
+ // ── 余额型聚合商(各家单位与形状都不同,逐个显式换算,不做通用折算)────────────────
630
+ /** Novita:金额单位是 1/10000 USD(10000 = $1.00)。 */
631
+ export function normalizeNovitaBalance(body) {
632
+ const num = value => typeof value === "number" && Number.isFinite(value) ? value : undefined;
633
+ const available = num(body?.availableBalance);
634
+ if (available === undefined) throw failure("subusage/response", "Invalid Novita balance response");
635
+ return { windows: [], extras: [{ kind: "balance", value: `${Math.round(available / 100) / 100} USD` }], coverage: "complete" };
636
+ }
637
+ /** Hyperbolic:balanceCents 是美分。 */
638
+ export function normalizeHyperbolicBalance(body) {
639
+ const cents = typeof body?.balanceCents === "number" && Number.isFinite(body.balanceCents) ? body.balanceCents : undefined;
640
+ if (cents === undefined) throw failure("subusage/response", "Invalid Hyperbolic balance response");
641
+ return { windows: [], extras: [{ kind: "balance", value: `${Math.round(cents) / 100} USD` }], coverage: "complete" };
642
+ }
643
+ /**
644
+ * DeepInfra:预付资金以**负的** stripe_balance 表示,可用金额要取负;正值代表欠款。
645
+ * 欠款是行动项,单独提示而不是显示成负余额。
646
+ */
647
+ export function normalizeDeepinfraBalance(body) {
648
+ const raw = typeof body?.stripe_balance === "number" && Number.isFinite(body.stripe_balance) ? body.stripe_balance : undefined;
649
+ if (raw === undefined) throw failure("subusage/response", "Invalid DeepInfra balance response");
650
+ const available = Math.round(-raw * 100) / 100;
651
+ const extras = [{ kind: "balance", value: `${Math.max(0, available)} USD` }];
652
+ if (available < 0) extras.push({ kind: "plan", value: `欠款 ${Math.abs(available)} USD` });
653
+ return { windows: [], extras, coverage: "complete" };
654
+ }
655
+ /** Chutes:{quota, used} 直接给绝对量;重置时刻接口不给,故不显示(不编造次日 00:00 UTC)。 */
656
+ export function normalizeChutesQuota(body) {
657
+ const num = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
658
+ const quota = num(body?.quota), used = num(body?.used);
659
+ if (quota === undefined || used === undefined) throw failure("subusage/response", "Invalid Chutes quota response");
660
+ if (quota <= 0) throw failure("subusage/response", "Chutes 配额为 0,无法计算比例");
661
+ return { windows: [windowRow("period", Math.min(100, used / quota * 100), undefined, undefined, { used, limit: quota, unit: "quota" })], extras: [], coverage: "complete" };
662
+ }
663
+ /** Ollama Cloud:limits.{session,weekly,monthly}.usage 是 **0–1 小数**;任一层缺失就跳过该窗口。 */
664
+ export function normalizeOllamaCloudUsage(body) {
665
+ const limits = body?.limits && typeof body.limits === "object" && !Array.isArray(body.limits) ? body.limits : {};
666
+ const windows = [];
667
+ for (const [field, kind] of [["session", "period"], ["weekly", "week"], ["monthly", "month"]]) {
668
+ const usage = limits[field]?.usage;
669
+ if (usage === undefined || usage === null) continue;
670
+ if (typeof usage !== "number" || !Number.isFinite(usage) || usage < 0) throw failure("subusage/response", "Invalid Ollama Cloud usage");
671
+ windows.push(windowRow(kind, Math.min(100, usage * 100)));
672
+ }
673
+ if (!windows.length) throw failure("subusage/response", "Ollama Cloud 响应没有可用的额度窗口");
674
+ return { windows: cascadeRateLimited(windows), extras: [], coverage: windows.length >= 3 ? "complete" : "partial" };
675
+ }
676
+ /** Vercel AI Gateway:balance / total_used 是十进制字符串;没有限额也没有重置,只显示金额。 */
677
+ export function normalizeVercelCredits(body) {
678
+ const decimal = value => typeof value === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim()) ? value.trim() : typeof value === "number" && Number.isFinite(value) ? String(value) : undefined;
679
+ const balance = decimal(body?.balance) ?? decimal(body?.data?.balance);
680
+ if (balance === undefined) throw failure("subusage/response", "Invalid Vercel AI Gateway credits response");
681
+ return { windows: [], extras: [{ kind: "balance", value: `${balance} USD` }], coverage: "complete" };
682
+ }
683
+ // ZenMux:订阅配额走 subscription/detail(`quota_5_hour.usage_percentage` 是 **0–1 小数**),
684
+ // PAYG 余额走 payg/balance。两个端点都**只认 Management API Key**(推理 key 不适用),
685
+ // 因此继承变量单独命名,避免用户把推理 key 填进来。
686
+ // https://docs.zenmux.ai/api/platform/subscription-detail
687
+ export function normalizeZenmux(subscriptionBody, balanceBody) {
688
+ const num = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
689
+ const windows = [], extras = [];
690
+ const data = subscriptionBody?.data && typeof subscriptionBody.data === "object" && !Array.isArray(subscriptionBody.data) ? subscriptionBody.data : {};
691
+ const quota = data.quota_5_hour && typeof data.quota_5_hour === "object" ? data.quota_5_hour : {};
692
+ const usage = num(quota.usage_percentage);
693
+ if (usage !== undefined) {
694
+ const detail = num(quota.used_flows) !== undefined && num(quota.max_flows) !== undefined ? { used: quota.used_flows, limit: quota.max_flows, unit: "flows" } : undefined;
695
+ windows.push(windowRow("5h", Math.min(100, usage * 100), typeof quota.resets_at === "string" ? quota.resets_at : undefined, undefined, detail));
696
+ }
697
+ const tier = data.plan && typeof data.plan.tier === "string" ? data.plan.tier.trim() : "";
698
+ if (tier) extras.push({ kind: "plan", value: tier });
699
+ const balance = balanceBody?.data && typeof balanceBody.data === "object" && !Array.isArray(balanceBody.data) ? balanceBody.data : {};
700
+ const total = num(balance.total_credits);
701
+ if (total !== undefined) {
702
+ const currency = typeof balance.currency === "string" && /^[A-Za-z]{3}$/.test(balance.currency) ? balance.currency.toUpperCase() : "";
703
+ extras.push({ kind: "balance", value: `${Math.round(total * 100) / 100} ${currency}`.trim() });
704
+ }
705
+ if (!windows.length && !extras.length) throw failure("subusage/response", "ZenMux 响应没有可用的配额或余额");
706
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
707
+ }
708
+ /**
709
+ * LiteLLM 的管理端点在 **proxy 根**(不是 /v1):去掉尾部斜杠与常见的 /v1 后缀,
710
+ * 否则会拼成 /v1/key/info 而 404。只接受 http(s),其它协议一律视为未配置。
711
+ */
712
+ export function litellmRoot(baseUrl) {
713
+ const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
714
+ if (!raw) return "";
715
+ let url;
716
+ try { url = new URL(raw); } catch { return ""; }
717
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "";
718
+ const path = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "");
719
+ return `${url.origin}${path}`;
720
+ }
721
+ /**
722
+ * LiteLLM:`/key/info` 给 key 自身的预算与花费,`/user/info`|`/team/info` 给更完整的视图。
723
+ * 有预算上限才画进度;**没有上限时不编造百分比**,只如实显示已用金额。
724
+ * 接口不给重置时刻时不显示重置(别把金额渲染成倒计时)。
725
+ * https://docs.litellm.ai/docs/proxy/virtual_keys
726
+ */
727
+ export function normalizeLitellm(keyBody, budgetBody) {
728
+ const num = value => typeof value === "number" && Number.isFinite(value) ? value : undefined;
729
+ const pick = value => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
730
+ // 真实响应是 `{ key, info: {...} }`;也接受直接把字段放在顶层。
731
+ const keyInfo = pick(keyBody?.info) ?? pick(keyBody);
732
+ if (!keyInfo) throw failure("subusage/response", "Invalid LiteLLM key info response");
733
+ const scope = pick(budgetBody) ?? {};
734
+ const spend = num(scope.spend) ?? num(keyInfo.spend);
735
+ const maxBudget = num(scope.max_budget) ?? num(keyInfo.max_budget);
736
+ const resetAt = typeof scope.budget_reset_at === "string" ? scope.budget_reset_at : typeof keyInfo.budget_reset_at === "string" ? keyInfo.budget_reset_at : undefined;
737
+ const round = value => Math.round(value * 100) / 100;
738
+ if (spend !== undefined && maxBudget !== undefined && maxBudget > 0) {
739
+ return { windows: [windowRow("period", Math.min(100, spend / maxBudget * 100), resetAt, undefined, { used: round(spend), limit: round(maxBudget), unit: "USD" })], extras: [], coverage: "complete" };
740
+ }
741
+ // 没有预算上限:没有百分比可算,如实给金额。
742
+ if (spend !== undefined) return { windows: [], extras: [{ kind: "spend", value: `${round(spend)} USD` }], coverage: "complete" };
743
+ throw failure("subusage/response", "LiteLLM 响应没有可用的预算或花费");
744
+ }
356
745
  function detectConfigured(ctx) {
357
746
  try { const ids = new Set(ctx.llm.listProviders().map(p => p.id)); return Object.fromEntries(IDS.map(id => [id, ids.has(id)])); }
358
747
  catch { return Object.fromEntries(IDS.map(id => [id, false])); }
@@ -385,11 +774,18 @@ export class SubUsageService extends TypertRemoteService {
385
774
  this.now = options.now ?? Date.now;
386
775
  this.credentials = options.resolveCredentials ?? (async id => await ctx.get("credentials")?.resolve(credentialRef(PROVIDERS[id].envName)));
387
776
  this.environment = options.resolveEnvironment ?? (id => launchEnvironmentOf(ctx).get(PROVIDERS[id].envName));
777
+ // 火山方舟的 AK/SK 不是路由的推理 Key,按固定变量名各自解析,两端都命中才算配对。
778
+ this.credentialByName = options.resolveCredentialByName ?? (async refName => await ctx.get("credentials")?.resolve(credentialRef(refName)));
779
+ this.environmentByName = options.resolveEnvironmentByName ?? (refName => launchEnvironmentOf(ctx).get(refName));
388
780
  // 额外账户按凭据引用名(apiKeyEnv)解析,与提供方插件 slots()/resolveRef 同一通道;
389
781
  // 默认账户仍走上面的 envName 链(凭据服务 → 启动环境 → ~/.commandcode/auth.json)。
390
782
  this.accountCredentials = options.resolveAccountCredentials ?? (async ref => await ctx.get("credentials")?.resolve(credentialRef(ref)));
391
783
  this.accountEnvironment = options.resolveAccountEnvironment ?? (ref => launchEnvironmentOf(ctx).get(ref));
392
784
  this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
785
+ // SuperGrok 只读共享登录文件(~/.grok/auth.json 等)取 OAuth Bearer;可注入仅供隔离测试。
786
+ this.grokAuth = options.resolveGrokAuth ?? (async () => grokAuthFileCredentials(this.io));
787
+ // Codex 只读共享登录文件($CODEX_HOME/auth.json 或 ~/.codex/auth.json)取 OAuth access token。
788
+ this.codexAuth = options.resolveCodexAuth ?? (async () => codexAuthFileCredentials(this.io, { CODEX_HOME: (await this.environmentByName("CODEX_HOME"))?.value }));
393
789
  this.cache = new Map(); this.inflight = new Map(); this.generations = new Map(); this.fingerprints = new Map(); this.disposed = false; this.controller = new AbortController();
394
790
  this.login = new MimoLogin({
395
791
  error: failure,
@@ -413,6 +809,18 @@ export class SubUsageService extends TypertRemoteService {
413
809
  result(settings, entries) { return { updatedAt: new Date(this.now()).toISOString(), settings: publicSettings(settings), configured: detectConfigured(this.ctx), entries }; }
414
810
  async identity(id, s, account) {
415
811
  if (id === "xiaomi-token-plan-cn") return { key: s.xiaomi.cookie.trim(), source: s.xiaomi.cookie.trim() ? "cookie" : "none" };
812
+ if (PROVIDERS[id].credentialKind === "volc") return await this.volcIdentity(id, s);
813
+ if (id === "xai-oauth") {
814
+ // SuperGrok 只认共享登录文件里的 OAuth Bearer:token 短命轮换,凭据服务/环境变量副本
815
+ // 必然过期顶掉新 token,故不走通用 Key 链;user_id 随行给 x-userid 请求头。
816
+ const cred = await this.grokAuth();
817
+ return cred?.key ? { key: cred.key, source: "auth-file", ...(cred.userId ? { userId: cred.userId } : {}) } : { key: undefined, source: "none" };
818
+ }
819
+ if (id === "openai-codex") {
820
+ // 同上:只读 Codex CLI 的 OAuth 登录文件,token 由 Codex CLI 轮换,第三端刷新会互相顶掉。
821
+ const cred = await this.codexAuth();
822
+ return cred?.key ? { key: cred.key, source: "auth-file", ...(cred.accountId ? { accountId: cred.accountId } : {}) } : { key: undefined, source: "none" };
823
+ }
416
824
  if (!PROVIDERS[id].managedByPlugin && s.keyModes[id] === "manual") return { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
417
825
  if (id === "commandcode" && account && account !== "default") {
418
826
  // 额外账户:id 即凭据引用名(apiKeyEnv)。引用名不合法(不可能来自提供方
@@ -435,11 +843,71 @@ export class SubUsageService extends TypertRemoteService {
435
843
  }
436
844
  return PROVIDERS[id].managedByPlugin ? { key: undefined, source: "none" } : { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
437
845
  }
846
+ // 火山方舟额度接口是 IAM Access Key 鉴权:AK 与 SK 必须**同源配对**。跨来源拼接
847
+ // (例如拿路由的推理 Key 去配凭据库里的 SK)只会得到 401,且报错看不出根因。
848
+ async volcIdentity(id, s) {
849
+ const ak = s.volc.accessKeyId.trim(), sk = s.volc.secretAccessKey.trim();
850
+ if (s.keyModes[id] === "manual") return ak && sk ? { key: ak, secret: sk, source: "manual" } : { key: undefined, secret: undefined, source: "none" };
851
+ const credentialAk = await this.credentialByName(VOLC_ACCESS_KEY_ENV), credentialSk = await this.credentialByName(VOLC_SECRET_KEY_ENV);
852
+ 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" };
853
+ const envAk = await this.environmentByName(VOLC_ACCESS_KEY_ENV), envSk = await this.environmentByName(VOLC_SECRET_KEY_ENV);
854
+ 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" };
855
+ return ak && sk ? { key: ak, secret: sk, source: "manual" } : { key: undefined, secret: undefined, source: "none" };
856
+ }
438
857
  async fetchProvider(id, identity, settings, signal = this.controller.signal) {
439
- const get = (url, headers) => fetchJson(this.fetcher, url, headers, signal);
858
+ const get = (url, headers, init) => fetchJson(this.fetcher, url, headers, signal, init);
859
+ // 火山方舟额度走管控面 OpenAPI:POST 根路径 + Action/Version 在 query,鉴权是 AK/SK 签名。
860
+ if (PROVIDERS[id].credentialKind === "volc") {
861
+ const spec = PROVIDERS[id];
862
+ const call = async (action, payload) => {
863
+ const body = payload === undefined ? "{}" : JSON.stringify(payload);
864
+ const signed = volcSignature({ accessKeyId: identity.key, secretAccessKey: identity.secret, action, host: spec.volcHost, region: spec.volcRegion, body });
865
+ const response = await get(signed.url, signed.headers, { method: "POST", body });
866
+ const apiError = volcError(response);
867
+ if (apiError) throw volcFailure(action, apiError);
868
+ return response;
869
+ };
870
+ const toWindows = parsed => cascadeRateLimited(parsed.windows.map(w => windowRow(w.kind, w.percent, w.resetsAt, w.percent >= 100 ? "rate-limited" : undefined, w.detail)));
871
+ // 企业版/团队版:额度挂在席位上,先取 SeatID 再查该席位。
872
+ if (spec.volcSeat) {
873
+ const seatIds = parseVolcSeatIds(await call("ListSeatInfos", { Filter: {}, Scene: spec.volcScene, PageNum: 1, PageSize: 100 }));
874
+ if (!seatIds.length) return { windows: [], extras: [{ kind: "plan", value: "该账号下没有可读的席位(或当前身份无权查看)" }], coverage: "partial" };
875
+ const seatId = seatIds[0];
876
+ // 多个席位时只看第一个,但如实说明总数,不假装看全了。
877
+ const seatNote = seatIds.length > 1 ? `${seatId}(共 ${seatIds.length} 个席位)` : seatId;
878
+ let parsed;
879
+ try {
880
+ parsed = spec.volcSeat === "agent"
881
+ ? parseVolcSeatAfp(await call("GetSeatAFPUsage", { SeatIDs: [seatId] }))
882
+ // GetSeatInfoUsage **确实有 Scene 参数**(官方契约 + ark-cli 源码一致):Coding Plan 企业版传空字符串。
883
+ // 传错值(如 "coding_plan")会**静默返回空 SeatID 而不报错**,所以这里必须精确。
884
+ : parseVolcSeatCoding(await call("GetSeatInfoUsage", { SeatID: seatId, Scene: "" }));
885
+ } catch (error) { if (error instanceof RemoteError) throw error; throw failure("subusage/response", `火山方舟 ${spec.volcSeat === "agent" ? "GetSeatAFPUsage" : "GetSeatInfoUsage"} 的额度结构无法解析`); }
886
+ const extras = [];
887
+ if (parsed.plan) extras.push({ kind: "plan", value: parsed.plan });
888
+ // 与个人版口径一致:没有窗口时说清「本周期没有可读额度」,而不是只给一个 partial 状态。
889
+ extras.push({ kind: "account", value: parsed.windows.length ? `席位 ${seatNote}` : `席位 ${seatNote}(本周期没有可读的额度窗口)` });
890
+ return { windows: toWindows(parsed), extras, coverage: parsed.windows.length ? "complete" : "partial" };
891
+ }
892
+ const body = await call(spec.volcAction);
893
+ let parsed;
894
+ try { parsed = spec.volcAction === "GetAFPUsage" ? parseVolcAgentPlan(body) : parseVolcCodingPlan(body); }
895
+ catch { throw failure("subusage/response", `火山方舟 ${spec.volcAction} 的额度结构无法解析`); }
896
+ const windows = toWindows(parsed);
897
+ const extras = [];
898
+ if (parsed.plan) extras.push({ kind: "plan", value: parsed.plan });
899
+ // HTTP 200 但窗口为空是「未订阅」,不是 0% 用量:显式说明,不画零额度的假象。
900
+ else if (!windows.length) extras.push({ kind: "plan", value: parsed.subscribed ? "本周期暂无窗口数据" : "未检测到该套餐订阅" });
901
+ return { windows, extras, coverage: windows.length ? "complete" : "partial" };
902
+ }
440
903
  const headers = { authorization: `Bearer ${identity.key}` };
441
904
  if (id === "synthetic") return normalizeSynthetic(await get("https://api.synthetic.new/v2/quotas", headers));
442
- if (id === "nanogpt") return normalizeNanoGpt(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));
905
+ if (id === "nanogpt") {
906
+ 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);
907
+ // 余额走独立端点,且鉴权头是 x-api-key(不是 Bearer);拿不到就静默降级,不影响配额展示。
908
+ const balance = await get("https://nano-gpt.com/api/check-balance", { "x-api-key": identity.key }, { method: "POST", body: "{}" }).catch(() => undefined);
909
+ return normalizeNanoGpt(body, balance);
910
+ }
443
911
  if (id === "zai-coding") {
444
912
  const out = normalizeZai(await get("https://api.z.ai/api/monitor/usage/quota/limit", { authorization: identity.key }));
445
913
  return { windows: out.windows, extras: out.level ? [{ kind: "plan", value: out.level }] : [] };
@@ -455,6 +923,42 @@ export class SubUsageService extends TypertRemoteService {
455
923
  }
456
924
  if (id === "opencode-go") return normalizeOpencodeGo(await get("https://opencode.ai/zen/go/v1/usage", headers));
457
925
  if (id === "minimax" || id === "minimax-cn") return normalizeMinimax(await get(PROVIDERS[id].usageUrl, headers));
926
+ if (id === "siliconflow") return normalizeSiliconFlow(await get("https://api.siliconflow.cn/v1/user/info", headers));
927
+ if (id === "deepseek") return normalizeDeepseekBalance(await get("https://api.deepseek.com/user/balance", headers));
928
+ if (id === "openrouter") {
929
+ const keyBody = await get("https://openrouter.ai/api/v1/key", headers);
930
+ // 余额端点对普通推理 key 会 403;这一次失败不影响 key 限额与用量的展示。
931
+ const creditBody = await get("https://openrouter.ai/api/v1/credits", headers).catch(() => undefined);
932
+ return normalizeOpenRouter(keyBody, creditBody);
933
+ }
934
+ // 余额型聚合商:单体 GET,单位换算在各 normalize 里显式做。
935
+ if (id === "novita") return normalizeNovitaBalance(await get("https://api.novita.ai/openapi/v1/billing/balance/detail", headers));
936
+ if (id === "hyperbolic") return normalizeHyperbolicBalance(await get("https://api.hyperbolic.ai/v2/customer/balance", headers));
937
+ if (id === "deepinfra") return normalizeDeepinfraBalance(await get("https://api.deepinfra.com/payment/checklist?compute_owed=true", headers));
938
+ if (id === "chutes") return normalizeChutesQuota(await get("https://api.chutes.ai/users/me/quota_usage/me", headers));
939
+ // Ollama Cloud 的鉴权是裸 Authorization:这里显式覆盖掉通用 Bearer。
940
+ if (id === "ollama-cloud") return normalizeOllamaCloudUsage(await get("https://ollama.com/api/usage", { authorization: identity.key }));
941
+ if (id === "vercel-ai-gateway") return normalizeVercelCredits(await get("https://ai-gateway.vercel.sh/v1/credits", headers));
942
+ if (id === "zenmux") {
943
+ // 订阅配额与 PAYG 余额各拿各的:任一失败不影响另一个。
944
+ const [subscription, balance] = await Promise.all([
945
+ get("https://zenmux.ai/api/v1/management/subscription/detail", headers).catch(() => undefined),
946
+ get("https://zenmux.ai/api/v1/management/payg/balance", headers).catch(() => undefined)
947
+ ]);
948
+ return normalizeZenmux(subscription, balance);
949
+ }
950
+ if (id === "litellm") {
951
+ // 地址是用户自己的 proxy;缺地址算「凭据不完整」,与缺 Key 一样给出可行动的指引。
952
+ const root = litellmRoot(settings.litellm?.baseUrl);
953
+ if (!root) throw failure("subusage/credentials", "未配置 LiteLLM 代理地址:在 设置 → 订阅用量 → LiteLLM 的「连接与凭据」里填写");
954
+ const keyInfo = await get(`${root}/key/info`, headers);
955
+ const own = keyInfo?.info && typeof keyInfo.info === "object" && !Array.isArray(keyInfo.info) ? keyInfo.info : {};
956
+ // 更完整的预算视图在 user/team info;拿不到就退回 key 自身的 info。
957
+ const scoped = typeof own.team_id === "string" && own.team_id.trim() ? `/team/info?team_id=${encodeURIComponent(own.team_id.trim())}`
958
+ : typeof own.user_id === "string" && own.user_id.trim() ? `/user/info?user_id=${encodeURIComponent(own.user_id.trim())}` : "";
959
+ const budget = scoped ? await get(`${root}${scoped}`, headers).catch(() => undefined) : undefined;
960
+ return normalizeLitellm(keyInfo, budget);
961
+ }
458
962
  if (id === "commandcode") {
459
963
  // 与提供方插件同一凭据来源,但直连计费接口并行拉取:其 report() 会对每个账户
460
964
  // 串行 whoami + 三个端点(60 秒预算),作为药丸刷新来源太慢。仅并行读取余额与套餐/账期;请求头对齐其 accountHeaders。
@@ -465,6 +969,21 @@ export class SubUsageService extends TypertRemoteService {
465
969
  ]);
466
970
  return normalizeCommandCode(credits, subscription);
467
971
  }
972
+ if (id === "xai-oauth") {
973
+ // 直连官方 Grok CLI 计费代理;X-XAI-Token-Auth 的取值是 token_header 字符串(xai-grok-cli)
974
+ // 而非布尔,x-userid 取登录文件。套餐名走 /v1/settings(subscription_tier_display),
975
+ // 失败不阻塞用量(回退 billing 响应里的 subscription_tier)。
976
+ const grokHeaders = { ...headers, "x-xai-token-auth": GROK_TOKEN_AUTH, "x-grok-client-version": GROK_CLIENT_VERSION, ...(identity.userId ? { "x-userid": identity.userId } : {}) };
977
+ const [billing, settings] = await Promise.all([
978
+ get(`${GROK_API_BASE}/billing?format=credits`, grokHeaders),
979
+ get(`${GROK_API_BASE}/settings`, grokHeaders).catch(() => undefined)
980
+ ]);
981
+ return normalizeSuperGrok(billing, settings);
982
+ }
983
+ if (id === "openai-codex") {
984
+ // ChatGPT 后端私有接口:Bearer 是 Codex CLI 的 OAuth access token,不是 OPENAI_API_KEY。
985
+ return normalizeCodexUsage(await get(`${CODEX_API_BASE}/wham/usage`, headers));
986
+ }
468
987
  const base = "https://platform.xiaomimimo.com/api/v1";
469
988
  const h = { cookie: identity.key, origin: "https://platform.xiaomimimo.com", referer: "https://platform.xiaomimimo.com/#/console/balance", "x-timezone": "UTC+08:00" };
470
989
  let usageError;
@@ -494,7 +1013,7 @@ export class SubUsageService extends TypertRemoteService {
494
1013
  }
495
1014
  // fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
496
1015
  // 空串与 "default" 同为默认账户,归一后共享缓存。
497
- 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]);
1016
+ 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]);
498
1017
  if (this.fingerprints.has(id) && this.fingerprints.get(id) !== fingerprint) this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
499
1018
  this.fingerprints.set(id, fingerprint);
500
1019
  const generation = this.generations.get(id) ?? 0;
@@ -508,7 +1027,7 @@ export class SubUsageService extends TypertRemoteService {
508
1027
  if (previous && (now < previous.retryAt || !resetDue && !force && now - previous.at < TTL)) return previous.entry;
509
1028
  const promise = (async () => {
510
1029
  const base = this.base(id, identity.source, settings, account); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
511
- 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" };
1030
+ 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" };
512
1031
  else try {
513
1032
  const data = await this.fetchProvider(id, identity, settings);
514
1033
  const usageError = data.usageError; delete data.usageError;
@@ -530,6 +1049,30 @@ export class SubUsageService extends TypertRemoteService {
530
1049
  this.inflight.set(id, { fingerprint, generation, promise });
531
1050
  try { return await promise; } finally { if (this.inflight.get(id)?.promise === promise) this.inflight.delete(id); }
532
1051
  }
1052
+ // 对外只读额度视图:供其他插件(ctx.get("subUsage").quota(...))与 subusage_quota 工具使用。
1053
+ // **刻意不返回 settings,也不返回 keySource / apiDetected**:第三方消费者只需要额度,
1054
+ // 不需要知道本机配了哪些凭据,更不该拿到任何与 Key 相关的字段。
1055
+ async quota(request = {}) {
1056
+ if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
1057
+ const wanted = Array.isArray(request?.providerIds) && request.providerIds.length ? request.providerIds.filter(id => IDS.includes(id)) : [...IDS];
1058
+ const settings = this.load();
1059
+ const force = request?.force === true;
1060
+ 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: "读取用量失败" }))));
1061
+ return {
1062
+ updatedAt: new Date(this.now()).toISOString(),
1063
+ providers: entries.map(entry => ({
1064
+ providerId: entry.providerId,
1065
+ label: entry.label,
1066
+ state: entry.state,
1067
+ windows: Array.isArray(entry.windows) ? entry.windows : [],
1068
+ extras: Array.isArray(entry.extras) ? entry.extras : [],
1069
+ coverage: entry.coverage,
1070
+ freshness: entry.freshness,
1071
+ lastSuccessAt: entry.lastSuccessAt,
1072
+ ...(entry.error ? { error: entry.error } : {})
1073
+ }))
1074
+ };
1075
+ }
533
1076
  async read(request) { return this.refresh(request && request.providerIds ? request : { providerIds: IDS, force: false }); }
534
1077
  async refresh(request) {
535
1078
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
@@ -539,8 +1082,7 @@ export class SubUsageService extends TypertRemoteService {
539
1082
  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: "读取期间配置已改变,请刷新" })));
540
1083
  return this.result(latest, entries);
541
1084
  }
542
- startMimoLogin(request) { return this.login.start(parseLoginStart(request)); }
543
- getMimoLoginStatus() { return this.login.status(); }
1085
+ startMimoLogin(request) { return this.login.start(parseLoginStart(request)); } getMimoLoginStatus() { return this.login.status(); }
544
1086
  cancelMimoLogin(request) { return this.login.cancel(parseLoginCancel(request)); }
545
1087
  mimoMaterial(s) { const id = "xiaomi-token-plan-cn"; return hash([s.xiaomi.cookie, s.keys[id] ?? "", s.keyModes[id]]); }
546
1088
  commitMimoLogin(cookie, captured, verified, observedExpiresAt) {
@@ -552,7 +1094,9 @@ export class SubUsageService extends TypertRemoteService {
552
1094
  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 } : {}) };
553
1095
  // 响应内容也不得回显本次 Cookie(服务端字段属于不可信输入)。
554
1096
  const safeEntry = redactMimo(entry, cookie);
555
- const fingerprint = hash([cookie, "cookie", stored.keyModes[id], null, null]);
1097
+ // 指纹段必须与 provider() 的五段顺序逐位对齐:MiMo 没有 secret,第二段固定空串。
1098
+ // 少一段会让登录提交后的首次刷新认不出同一份缓存,重复拉取。
1099
+ const fingerprint = hash([cookie, "", "cookie", stored.keyModes[id], null, null]);
556
1100
  this.fingerprints.set(id, fingerprint); this.cache.set(id, { fingerprint, entry: safeEntry, at: this.now(), retryAt: usageError ? this.now() + 15000 : 0, failures: 0 });
557
1101
  this.loginResultMaterial = this.mimoMaterial(stored);
558
1102
  return this.result(stored, [safeEntry]);
@@ -563,7 +1107,9 @@ export class SubUsageService extends TypertRemoteService {
563
1107
  const patch = parseSettings(settings); const stored = this.load();
564
1108
  if (patch.expectedRevision !== undefined && patch.expectedRevision !== hash(stored)) throw failure("subusage/revision-conflict", "设置已被其他窗口修改,请重新读取");
565
1109
  const id = patch.providerId;
566
- const affected = patch.visibility ? Object.keys(patch.visibility.providers ?? {}).filter(key => stored.visibility.providers[key] !== patch.visibility.providers[key]) : [id];
1110
+ // 火山方舟两个 provider 共用一组 AK/SK:改凭据要让所有用它的条目失效,不能只失效当前标签页。
1111
+ const sharedVolc = patch.accessKeyUpdate !== undefined || patch.secretKeyUpdate !== undefined;
1112
+ 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];
567
1113
  if (patch.visibility) {
568
1114
  if (patch.visibility.hideWithoutApi !== undefined) stored.visibility.hideWithoutApi = patch.visibility.hideWithoutApi;
569
1115
  Object.assign(stored.visibility.providers, patch.visibility.providers);
@@ -580,6 +1126,12 @@ export class SubUsageService extends TypertRemoteService {
580
1126
  stored.xiaomi.expiresAt = Math.min(at + MIMO_COOKIE_TTL_MS, Number.isSafeInteger(cookieCap) && cookieCap > at ? cookieCap : Number.POSITIVE_INFINITY);
581
1127
  }
582
1128
  if (patch.zai) stored.zai = patch.zai;
1129
+ if (patch.litellm) stored.litellm = patch.litellm;
1130
+ // volc 用「置空字符串」而不是 delete:这两个字段在内存里必须始终存在(publicSettings 会读它们),
1131
+ // 空串与 parseStored 的默认值同义 = 未配置。
1132
+ const updateVolc = (key, op) => { if (op?.action === "replace") stored.volc[key] = op.value; else if (op?.action === "clear") stored.volc[key] = ""; };
1133
+ updateVolc("accessKeyId", patch.accessKeyUpdate);
1134
+ updateVolc("secretAccessKey", patch.secretKeyUpdate);
583
1135
  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); }
584
1136
  catch { throw failure("subusage/config", "无法保存订阅设置"); }
585
1137
  for (const key of affected) { this.generations.set(key, (this.generations.get(key) ?? 0) + 1); this.cache.delete(key); this.inflight.delete(key); }
@@ -590,6 +1142,76 @@ export class SubUsageService extends TypertRemoteService {
590
1142
  function registerRemotes(ctx) {
591
1143
  ctx.inject(["typert"], scope => { scope.effect(() => scope.typert.register({ package: subUsageRemote.package, face: "host", schemas: [], model: { services: [], events: [], objects: [] }, invocations: subUsageRemote.descriptors })); });
592
1144
  }
593
- function apply(ctx) { registerRemotes(ctx); ctx.plugin(SubUsageService, {}); ctx.logger.info("dsh-subusage: usage service ready"); }
1145
+ function apply(ctx) { registerRemotes(ctx); ctx.plugin(SubUsageService, {}); registerQuotaTool(ctx); ctx.logger.info("dsh-subusage: usage service ready"); }
1146
+ /** 工具结果的人读文本:一行一个 provider,先给窗口再给余额,异常与未配置直说。 */
1147
+ function formatQuotaText(value) {
1148
+ const providers = Array.isArray(value?.providers) ? value.providers : [];
1149
+ if (!providers.length) return "没有可读的提供商额度。";
1150
+ return providers.map(entry => {
1151
+ const head = `${entry.label || entry.providerId} [${entry.state}]`;
1152
+ if (entry.state === "no-key" || entry.state === "no-cookie") return `${head}:未配置凭据`;
1153
+ if (entry.state === "disabled") return `${head}:检测已关闭`;
1154
+ if (entry.state !== "ok") return `${head}:${entry.error || "读取失败"}`;
1155
+ const parts = [];
1156
+ for (const window of entry.windows || []) {
1157
+ parts.push(`${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
1158
+ }
1159
+ for (const extra of entry.extras || []) parts.push(`${extra.kind}: ${extra.value}`);
1160
+ if (entry.freshness === "stale") parts.push("缓存数据");
1161
+ return `${head}:${parts.length ? parts.join(";") : "暂无额度数据"}`;
1162
+ }).join("\n");
1163
+ }
1164
+ /**
1165
+ * 注册 subusage_quota 工具:模型可以直接问「额度还剩多少」,不必自己逐个厂商请求。
1166
+ * 工具只读:不写设置、不碰凭据,数据来自与设置页同一份缓存(TTL 60 秒)。
1167
+ */
1168
+ function registerQuotaTool(ctx) {
1169
+ ctx.inject(["tools"], toolCtx => {
1170
+ toolCtx.tools.register(defineTool({
1171
+ name: "subusage_quota",
1172
+ 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.",
1173
+ parameters: {
1174
+ providers: { type: "array", items: { type: "string" }, description: "Optional provider ids to limit the query, e.g. [\"deepseek\", \"openrouter\"]. Omit to read every provider." },
1175
+ 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." }
1176
+ },
1177
+ output: {
1178
+ schema: {
1179
+ type: "object",
1180
+ additionalProperties: false,
1181
+ properties: {
1182
+ updatedAt: { type: "string", required: true, description: "ISO timestamp of this reading." },
1183
+ providers: {
1184
+ type: "array",
1185
+ required: true,
1186
+ description: "One entry per provider.",
1187
+ items: {
1188
+ type: "object",
1189
+ additionalProperties: false,
1190
+ properties: {
1191
+ providerId: { type: "string", required: true },
1192
+ label: { type: "string", required: true },
1193
+ state: { type: "string", required: true, description: "ok | no-key | no-cookie | error | disabled" },
1194
+ windows: { type: "array", required: true, description: "Quota windows; percent is the USED percentage (0-100)." },
1195
+ extras: { type: "array", required: true, description: "Balances and plan names (kind/value)." },
1196
+ coverage: { type: "string", description: "complete | partial" },
1197
+ freshness: { type: "string", description: "fresh | stale" },
1198
+ lastSuccessAt: { type: "string" },
1199
+ error: { type: "string", description: "Failure reason when state is error." }
1200
+ }
1201
+ }
1202
+ }
1203
+ }
1204
+ },
1205
+ render: (_args, value) => [{ type: "text", text: formatQuotaText(value) }]
1206
+ },
1207
+ execute: async (args, exec) => {
1208
+ exec?.signal?.throwIfAborted?.();
1209
+ const service = toolCtx.get("subUsage");
1210
+ if (!service || typeof service.quota !== "function") throw new Error("dsh-subusage 服务尚未就绪");
1211
+ return await service.quota({ providerIds: args?.providers, force: args?.refresh === true });
1212
+ }
1213
+ }));
1214
+ });
1215
+ }
594
1216
  export { apply, inject, name };
595
1217
  export default { name, inject, apply };