dsh-subusage 0.11.0 → 0.11.2

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
@@ -11,6 +11,7 @@ import { MimoLogin } from "./mimo-login.js";
11
11
  import { parseVolcAgentPlan, parseVolcCodingPlan, parseVolcSeatAfp, parseVolcSeatCoding, parseVolcSeatIds, volcError, volcSignature } from "./volcengine.js";
12
12
 
13
13
  const name = "dsh-subusage";
14
+ const VERSION = "0.11.2";
14
15
  const inject = ["llm"];
15
16
  const PROVIDERS = {
16
17
  "zai-coding-cn": { label: "Z.ai", envName: "ZAI_CODING_CN_API_KEY" },
@@ -41,7 +42,7 @@ const PROVIDERS = {
41
42
  // 否则选中方舟模型时药丸不会出现。额度读取走管控面 OpenAPI,凭据是 IAM 的 AK/SK 配对
42
43
  // (HMAC-SHA256 签名),与这些路由的推理 API Key 是两套:拿推理 Key 去查额度会被上游拒绝
43
44
  // ("GetAFPUsage requires Volcengine Ark SSO STS")。
44
- // 7 条 volc 路由共用一组 AK/SK(settings.volc):个人版各自固定一个 Action,
45
+ // 4 条 volc 路由共用一组 AK/SK(settings.volc):个人版各自固定一个 Action,
45
46
  // 企业版/团队版走 ListSeatInfos → GetSeatAFPUsage / GetSeatInfoUsage 两步。
46
47
  // ── arkcli(官方 CLI)安装的路由 ──────────────────────────────────────────
47
48
  // 官方 CLI 的 `arkcli helper configure deepseek-harness` 把 provider 写成
@@ -87,7 +88,7 @@ const IDS = Object.keys(PROVIDERS);
87
88
  const ARK_IDS = IDS.filter(id => PROVIDERS[id].credentialKind === "volc");
88
89
  const ARK_DEFAULT_ON = ARK_IDS.some(id => PROVIDERS[id].defaultEnabled !== false);
89
90
  /**
90
- * 某条路由当前是否启用。Ark 以**组开关为唯一真源**——7 条路由共用一组 IAM AK/SK、一般用户只
91
+ * 某条路由当前是否启用。Ark 以**组开关为唯一真源**——4 条路由共用一组 IAM AK/SK、一般用户只
91
92
  * 持有一个套餐,所以设置页只给一个开关。这里必须与 Client 的 `providerEnabled` 同语义:
92
93
  * 两端口径一旦分叉,界面按组开关显示这条路由,Host 却按逐条值回 `disabled`,
93
94
  * 用户拿到的就是一张写着「检测已关闭」的空卡片(这正是 Coding Plan 查不到用量的原因)。
@@ -118,7 +119,7 @@ const PROVIDER_ALIASES = {
118
119
  function normalizeProviderKey(value) { return String(value ?? "").trim().toLowerCase().replace(/[\s._-]+/g, ""); }
119
120
  /**
120
121
  * 归一化索引:id 与别名都进同一张表,因此「精确 id」只是这张表的一个特例。
121
- * 一个键可以指向多条路由(`zai` → 中国版 + 国际版,`ark` → 7 条)。
122
+ * 一个键可以指向多条路由(`zai` → 中国版 + 国际版,`ark` → 4 条)。
122
123
  */
123
124
  const PROVIDER_LOOKUP = (() => {
124
125
  const map = new Map();
@@ -139,7 +140,7 @@ const PROVIDER_LOOKUP = (() => {
139
140
  * 1. 认不出的原串**原样返回**而不是丢掉——调用方要能拿到「这个名字不认识,可用的是……」才能
140
141
  * 自我纠正;静默过滤只会让它以为本机没有额度。
141
142
  * 2. 一个名字命中多条路由时**全部返回**,各自带着真实状态(没启用的会如实标 disabled)。
142
- * 例如 `zai` 同时是中国版与国际版、`ark` 是 7 条路由:宁可多给几条数据让调用方自己判断,
143
+ * 例如 `zai` 同时是中国版与国际版、`ark` 是 4 条路由:宁可多给几条数据让调用方自己判断,
143
144
  * 也不替它猜一条,更不能返回空。
144
145
  * @param requested 调用方给的原始名字列表。
145
146
  */
@@ -192,7 +193,8 @@ function commandCodeAuthFileKey(io) {
192
193
  }
193
194
  // SuperGrok / X Premium 订阅(dsh-grok-kit 的 xai-oauth 路由):用量走官方 Grok CLI 的计费
194
195
  // 代理(转发后端 GetGrokCreditsConfig)。凭据是 dsh-grok-kit / Grok CLI 共享登录文件里的
195
- // OAuth Bearer(短命轮换),只读不写、不自行刷新,避免与两端的 refresh-token 轮换互相顶掉。
196
+ // OAuth Bearer(短命轮换):平时只读;token 过期时借 dsh-grok-kit 的官方会话链(getAuth)经同一
197
+ // 把写锁续约并写回同一文件,绝不自行裸 POST 刷新——后者才会顶掉两端的 refresh-token 轮换。
196
198
  const GROK_API_BASE = "https://cli-chat-proxy.grok.com/v1";
197
199
  // 官方 GrokComConfig.token_header 默认值:必须是该字符串,nginx 鉴权子请求才路由到 OAuth。
198
200
  const GROK_TOKEN_AUTH = "xai-grok-cli";
@@ -200,6 +202,21 @@ const GROK_CLIENT_VERSION = "1.0.13";
200
202
  const GROK_AUTH_FILE = join(homedir(), ".grok", "auth.json");
201
203
  // dsh-grok-kit 旧版私存路径(其 resolveXaiOAuthStorePath 的回退分支)。
202
204
  const XAI_OAUTH_AUTH_FILE = join(homedir(), ".dsh", ".xai-oauth-auth.json");
205
+ // dsh-grok-kit 是兄弟插件而非本插件依赖:动态加载,解析不到(未安装 / 开发期 link 布局)即降级。
206
+ // 0.2.0 起导出 XaiOAuthSession;其 models.getAuth("xai") 与 grok-kit 聊天/搜索走同一条官方 OAuth
207
+ // 刷新链——共享文件 store、写锁内二次检查与 compare-and-write,多个消费者并发也只刷新一次,
208
+ // 借它刷新不会顶掉 refresh-token 轮换(自己裸 POST 才会)。只缓存成功解析:失败(未安装、
209
+ // 旧版本)下次过期时重试,用户中途安装/升级 grok-kit 后无需重启 DSH。
210
+ let grokKitModule;
211
+ function loadGrokKit() {
212
+ if (grokKitModule) return Promise.resolve(grokKitModule);
213
+ return import("dsh-grok-kit")
214
+ .then(mod => {
215
+ if (mod && typeof mod.XaiOAuthSession === "function") grokKitModule = mod;
216
+ return grokKitModule;
217
+ })
218
+ .catch(() => undefined);
219
+ }
203
220
  // Bearer 的 JWT payload 里有 sub(= user_id);登录文件缺 user_id 时兜底取它。
204
221
  function bearerSubject(key) {
205
222
  try {
@@ -414,10 +431,13 @@ function volcFailure(action, error) {
414
431
  }
415
432
  async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
416
433
  let response;
417
- 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.10", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
434
+ try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent": `dsh-subusage/${VERSION}`, ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
418
435
  catch { throw failure("subusage/network", "网络请求失败或超时", true); }
419
- if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
420
- if (!response.ok) {
436
+ // 3xx(redirect: "manual" 下的域名迁移/跳转)不是鉴权失败,与 401/403 分开映射,
437
+ // 避免用户被误导去重登录;两个条件都加显式括号,不依赖 &&/|| 优先级。
438
+ if ((response.status >= 300 && response.status < 400)) throw failure("subusage/response", "服务端返回了意外的重定向响应(HTTP 3xx)");
439
+ if ((response.status === 401 || response.status === 403)) throw failure("subusage/auth", "登录或凭据已失效");
440
+ if (!response.ok && !(init.notSubscribed404 === true && response.status === 404)) {
421
441
  const retry = response.status === 408 || response.status === 429 || response.status >= 500;
422
442
  const raw = response.headers?.get?.("retry-after");
423
443
  const retryAfterMs = raw ? (/^\d+(\.\d+)?$/.test(raw) ? Number(raw) * 1000 : Math.max(0, Date.parse(raw) - Date.now())) : undefined;
@@ -433,7 +453,8 @@ async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
433
453
  } catch (e) { if (e instanceof RemoteError) throw e; throw failure("subusage/network", "读取响应失败", true); }
434
454
  let body;
435
455
  try { body = JSON.parse(text); } catch { throw failure("subusage/response", "响应不是合法 JSON"); }
436
- if ([401, 403, "401", "403"].includes(body?.code)) throw failure("subusage/auth", "登录或凭据已失效");
456
+ if (response.status === 404 && init.notSubscribed404 === true && !/^(NotFound\.BillingType|OperationDenied\.NotSubscribed)$/.test(body?.ResponseMetadata?.Error?.Code ?? "")) throw failure("subusage/http", "HTTP 404");
457
+ if ([401, 403, "401", "403"].includes(body?.code)) throw failure("subusage/auth", "登录或凭据已失效");
437
458
  return body;
438
459
  }
439
460
  function numeric(v) { return typeof v === "number" && Number.isFinite(v) && v >= 0; }
@@ -446,7 +467,10 @@ const RANK = { month: 0, sub: 0, period: 0, week: 1, "7d": 1, day: 2, "5h": 2, r
446
467
  function cascadeRateLimited(windows) {
447
468
  const roots = windows.filter(w => w.status === "rate-limited");
448
469
  for (const w of windows) {
449
- const blockers = roots.filter(root => (RANK[root.kind] ?? 3) < (RANK[w.kind] ?? 3));
470
+ const blockers = roots.filter(root => {
471
+ const sourceScope = root.detail?.scope ?? "all", targetScope = w.detail?.scope ?? "all";
472
+ return (sourceScope === "all" || sourceScope === targetScope) && (RANK[root.kind] ?? 3) < (RANK[w.kind] ?? 3);
473
+ });
450
474
  if (blockers.length) { if (w.status !== "rate-limited") w.cascade = true; w.status = "rate-limited"; w.detail = { ...w.detail, blockedBy: [...new Set(blockers.map(root => root.kind))] }; }
451
475
  }
452
476
  return windows;
@@ -538,7 +562,12 @@ export function normalizeNanoGpt(body, balanceBody) {
538
562
  const extras = [{ kind: "plan", value: body.state }];
539
563
  const usd = typeof balanceBody?.usd_balance === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(balanceBody.usd_balance.trim()) ? balanceBody.usd_balance.trim() : undefined;
540
564
  if (usd) extras.push({ kind: "balance", value: `${usd} USD` });
541
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length && !partial ? "complete" : "partial" };
565
+ const routing = body?.routing;
566
+ const mode = routing?.recommendedMode;
567
+ const availability = mode === "unavailable" ? { availability: "blocked", availabilityReason: "routing-policy" }
568
+ : mode === "subscription" ? { availability: routing.subscriptionQuotaAvailable === true ? "included" : "unknown", availabilityReason: "routing-advice" }
569
+ : mode === "paygo" ? { availability: usd !== undefined && Number.isFinite(Number(usd)) && Number(usd) > 0 && routing.paidSpendPolicyAllowsBalance !== false ? "paygo" : "unknown", availabilityReason: "routing-advice" } : {};
570
+ return { windows: cascadeRateLimited(windows), extras, coverage: (windows.length || usd !== undefined || mode === "unavailable") && !partial ? "complete" : "partial", ...availability };
542
571
  }
543
572
  // 官方 CLI 的计数消歧规则:有 remaining_percent 时按它校准;旧响应 usage_count 是剩余。
544
573
  // https://github.com/MiniMax-AI/cli/blob/main/src/utils/quota.ts
@@ -582,7 +611,8 @@ export function normalizeMinimax(body) {
582
611
  const reset = numeric(endTime) && endTime > 0 && Number.isFinite(new Date(endTime).getTime()) ? new Date(endTime).toISOString() : undefined;
583
612
  windows.push(windowRow(kind, usedPercent, reset, undefined, detail));
584
613
  }
585
- return { windows: cascadeRateLimited(windows), extras: [], coverage: windows.length === 2 ? "complete" : "partial" };
614
+ const unlimited = !windows.length && row.current_interval_status === 3 && row.current_weekly_status === 3;
615
+ return { windows: cascadeRateLimited(windows), extras: unlimited ? [{ kind: "quota-status", value: "unlimited" }] : [], coverage: windows.length || [row.current_interval_status, row.current_weekly_status].includes(3) ? "complete" : "partial" };
586
616
  }
587
617
  export function normalizeCommandCode(body, subscriptionBody) {
588
618
  // 输入是 Command Code 计费接口(/alpha/billing/credits)的原始响应体。
@@ -622,7 +652,11 @@ export function normalizeCommandCode(body, subscriptionBody) {
622
652
  if (reported.length) extras.push({ kind: "balance", value: `${Math.round(reported.reduce((sum, v) => sum + Math.max(0, v), 0) * 100) / 100} credits` });
623
653
  // 月池耗尽并不证明已购/赠送池或短窗口也不可用;只级联接口的周/5小时限制。
624
654
  cascadeRateLimited(windows.filter(w => w.kind !== "month"));
625
- return { windows, extras, coverage: hasMonthlyBalance && monthlyCap !== undefined ? "complete" : "partial" };
655
+ const exhausted = windows.some(w => w.status === "rate-limited");
656
+ const purchased = credits.purchasedCredits;
657
+ const availability = exhausted && numeric(purchased) && purchased > 0 ? { availability: "paygo", availabilityReason: "paid-credits" }
658
+ : exhausted ? { availability: "blocked", availabilityReason: "quota-exhausted" } : {};
659
+ return { windows, extras, coverage: hasMonthlyBalance || windows.length || reported.length ? "complete" : "partial", ...availability };
626
660
  }
627
661
  // SuperGrok / X Premium 统一用量池:GetGrokCreditsConfig 响应(官方 grok CLI 的
628
662
  // /v1/billing?format=credits)。creditUsagePercent 是“已用”百分比(0-100),剩余由
@@ -668,11 +702,14 @@ export function normalizeSiliconFlow(body) {
668
702
  }
669
703
  // Codex / ChatGPT 订阅用量:rate_limit.primary_window 与 secondary_window 各含 used_percent 与
670
704
  // resets_at。免费档的次窗口是 30 天,所以有 window_seconds 时按它判窗口名,缺失时按位置回退 5h / 7d。
671
- // credits 只在真正报告了可用余额时显示:has_credits=false、unlimited=true、balance="0" 都不显示,
705
+ // credits 只在真正报告了可用余额时显示:has_credits=false、balance="0" 不画余额;unlimited 单独保留状态,
672
706
  // 不把「无额度」画成 0。
673
707
  // https://raw.githubusercontent.com/steipete/CodexBar/main/docs/codex.md
674
708
  export function normalizeCodexUsage(body) {
675
709
  const windows = [];
710
+ const plan = typeof body?.plan_type === "string" ? body.plan_type.trim() : "";
711
+ // 当前 Pro 三档无短窗;未知档位的缺失时长不按主窗口位置猜成 5h。
712
+ const weeklyOnly = ["pro", "prolite", "promax"].includes(plan.toLowerCase());
676
713
  const windowKind = (row, fallback) => {
677
714
  const seconds = row?.window_seconds ?? row?.limit_window_seconds;
678
715
  if (!Number.isFinite(seconds) || seconds <= 0) return fallback;
@@ -689,18 +726,35 @@ export function normalizeCodexUsage(body) {
689
726
  windows.push(windowRow(windowKind(row, fallback), row.used_percent, reset));
690
727
  };
691
728
  const limits = body?.rate_limit && typeof body.rate_limit === "object" && !Array.isArray(body.rate_limit) ? body.rate_limit : {};
692
- push(limits.primary_window, "5h");
693
- push(limits.secondary_window, "7d");
694
- // HTTP 200 但没有任何窗口 = 该账号没有可读的订阅额度,不是 0% 用量。
695
- if (!windows.length) return { windows: [], extras: [{ kind: "plan", value: "未检测到订阅额度窗口" }], coverage: "partial" };
729
+ const positional = !plan || ["plus", "team", "business"].includes(plan.toLowerCase());
730
+ push(limits.primary_window, weeklyOnly ? "week" : positional ? "5h" : "rolling");
731
+ push(limits.secondary_window, weeklyOnly ? "week" : positional ? "7d" : "rolling");
696
732
  const extras = [];
697
- // 实测:plan_type 给套餐档位(如 "plus"),值得显示。
698
- const plan = typeof body?.plan_type === "string" && body.plan_type.trim() ? body.plan_type.trim() : "";
699
733
  if (plan) extras.push({ kind: "plan", value: plan });
700
734
  const credits = body?.credits && typeof body.credits === "object" && !Array.isArray(body.credits) ? body.credits : {};
701
735
  const balance = typeof credits.balance === "string" && credits.balance.trim() ? credits.balance.trim() : typeof credits.balance === "number" ? String(credits.balance) : "";
702
- if (credits.has_credits === true && credits.unlimited !== true && balance && balance !== "0") extras.push({ kind: "balance", value: balance });
703
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length >= 2 ? "complete" : "partial" };
736
+ const creditBalance = balance && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(balance) && Number.isFinite(Number(balance)) ? Number(balance) : undefined;
737
+ if (credits.has_credits === true && credits.unlimited !== true && creditBalance !== undefined && creditBalance > 0) extras.push({ kind: "balance", value: balance });
738
+ if (credits.has_credits === true && credits.unlimited === true) extras.push({ kind: "credit-status", value: "unlimited" });
739
+ const control = body?.spend_control;
740
+ const member = control?.individual_limit;
741
+ const memberLimit = Number(member?.limit), memberUsed = member?.used == null ? NaN : Number(member.used);
742
+ if (member && Number.isFinite(memberLimit) && memberLimit > 0 && Number.isFinite(memberUsed) && memberUsed >= 0) {
743
+ const reset = numeric(member.reset_at) && member.reset_at > 0 ? new Date(member.reset_at * 1000).toISOString() : undefined;
744
+ windows.push({ ...windowRow("period", memberUsed / memberLimit * 100, reset, undefined, { used: memberUsed, limit: memberLimit, unit: "credits", scope: "member-budget" }), groupLabel: "Member budget" });
745
+ }
746
+ const reached = body?.rate_limit_reached_type?.kind;
747
+ const restricted = control?.reached === true || ["workspace_owner_credits_depleted", "workspace_member_credits_depleted", "workspace_owner_usage_limit_reached", "workspace_member_usage_limit_reached"].includes(reached);
748
+ const exhausted = windows.some(w => w.status === "rate-limited") || limits.limit_reached === true || limits.allowed === false;
749
+ const creditFallback = credits.has_credits === true && (credits.unlimited === true || creditBalance > 0);
750
+ const availability = restricted ? { availability: "blocked", availabilityReason: "spend-control" }
751
+ : exhausted && creditFallback ? { availability: "unknown", availabilityReason: "credit-eligibility" }
752
+ : exhausted ? { availability: "blocked", availabilityReason: "quota-exhausted" } : {};
753
+ if (!windows.length && !extras.some(e => e.kind !== "plan") && !restricted) extras.push({ kind: "note", value: "未检测到订阅额度窗口" });
754
+ const reported = windows.length || extras.some(e => e.kind !== "plan" && e.kind !== "note") || restricted;
755
+ // 额外特性池未按所选模型映射前,不声称已完整覆盖它们。
756
+ const partial = Array.isArray(body?.additional_rate_limits) && body.additional_rate_limits.length > 0;
757
+ return { windows: cascadeRateLimited(windows), extras, coverage: reported && !partial ? "complete" : "partial", ...availability };
704
758
  }
705
759
  // DeepSeek 官方余额(余额型):GET https://api.deepseek.com/user/balance,Bearer 与推理同一把 Key。
706
760
  // 响应 { is_available, balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }] },
@@ -741,14 +795,17 @@ export function normalizeOpenRouter(keyBody, creditBody) {
741
795
  const total = num(credits?.total_credits), spent = num(credits?.total_usage);
742
796
  if (total !== undefined && spent !== undefined) extras.push({ kind: "balance", value: `${Math.round(Math.max(0, total - spent) * 100) / 100} USD` });
743
797
  if (data.is_free_tier === true) extras.push({ kind: "plan", value: "Free tier" });
744
- if (!windows.length && !extras.length) throw failure("subusage/response", "OpenRouter 响应没有可用的限额或余额");
745
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
798
+ if (!windows.length && num(data.usage) !== undefined) extras.push({ kind: "spend", value: `${Math.round(data.usage * 100) / 100} USD` });
799
+ if (!windows.length && !extras.length) throw failure("subusage/response", "OpenRouter 响应没有可用的限额、余额或用量");
800
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length || extras.some(e => e.kind !== "plan") ? "complete" : "partial" };
746
801
  }
747
802
  // ── 余额型聚合商(各家单位与形状都不同,逐个显式换算,不做通用折算)────────────────
748
803
  /** Novita:金额单位是 1/10000 USD(10000 = $1.00)。 */
749
804
  export function normalizeNovitaBalance(body) {
750
- const num = value => typeof value === "number" && Number.isFinite(value) ? value : undefined;
751
- const available = num(body?.availableBalance);
805
+ // 官方金额为十进制字符串;不接受空串、指数或十六进制等非金额写法。
806
+ const raw = body?.availableBalance;
807
+ const available = typeof raw === "number" && Number.isFinite(raw) ? raw
808
+ : typeof raw === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(raw.trim()) && Number.isFinite(Number(raw)) ? Number(raw) : undefined;
752
809
  if (available === undefined) throw failure("subusage/response", "Invalid Novita balance response");
753
810
  return { windows: [], extras: [{ kind: "balance", value: `${Math.round(available / 100) / 100} USD` }], coverage: "complete" };
754
811
  }
@@ -789,7 +846,7 @@ export function normalizeOllamaCloudUsage(body) {
789
846
  windows.push(windowRow(kind, Math.min(100, usage * 100)));
790
847
  }
791
848
  if (!windows.length) throw failure("subusage/response", "Ollama Cloud 响应没有可用的额度窗口");
792
- return { windows: cascadeRateLimited(windows), extras: [], coverage: windows.length >= 3 ? "complete" : "partial" };
849
+ return { windows: cascadeRateLimited(windows), extras: [], coverage: "complete" };
793
850
  }
794
851
  /** Vercel AI Gateway:balance / total_used 是十进制字符串;没有限额也没有重置,只显示金额。 */
795
852
  export function normalizeVercelCredits(body) {
@@ -806,12 +863,14 @@ export function normalizeZenmux(subscriptionBody, balanceBody) {
806
863
  const num = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
807
864
  const windows = [], extras = [];
808
865
  const data = subscriptionBody?.data && typeof subscriptionBody.data === "object" && !Array.isArray(subscriptionBody.data) ? subscriptionBody.data : {};
809
- const quota = data.quota_5_hour && typeof data.quota_5_hour === "object" ? data.quota_5_hour : {};
810
- const usage = num(quota.usage_percentage);
811
- if (usage !== undefined) {
866
+ for (const [field, kind] of [["quota_5_hour", "5h"], ["quota_7_day", "week"]]) {
867
+ const quota = data[field];
868
+ if (quota === undefined || quota === null) continue;
869
+ if (typeof quota !== "object" || Array.isArray(quota) || num(quota.usage_percentage) === undefined) throw failure("subusage/response", "Invalid ZenMux quota window");
812
870
  const detail = num(quota.used_flows) !== undefined && num(quota.max_flows) !== undefined ? { used: quota.used_flows, limit: quota.max_flows, unit: "flows" } : undefined;
813
- windows.push(windowRow("5h", Math.min(100, usage * 100), typeof quota.resets_at === "string" ? quota.resets_at : undefined, undefined, detail));
871
+ windows.push(windowRow(kind, quota.usage_percentage * 100, typeof quota.resets_at === "string" ? quota.resets_at : undefined, undefined, detail));
814
872
  }
873
+ // quota_monthly 只有上限,未报告实时用量;不从短窗拼造月百分比。
815
874
  const tier = data.plan && typeof data.plan.tier === "string" ? data.plan.tier.trim() : "";
816
875
  if (tier) extras.push({ kind: "plan", value: tier });
817
876
  const balance = balanceBody?.data && typeof balanceBody.data === "object" && !Array.isArray(balanceBody.data) ? balanceBody.data : {};
@@ -821,7 +880,7 @@ export function normalizeZenmux(subscriptionBody, balanceBody) {
821
880
  extras.push({ kind: "balance", value: `${Math.round(total * 100) / 100} ${currency}`.trim() });
822
881
  }
823
882
  if (!windows.length && !extras.length) throw failure("subusage/response", "ZenMux 响应没有可用的配额或余额");
824
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
883
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length || total !== undefined ? "complete" : "partial", ...(["suspended", "banned"].includes(data.account_status) ? { availability: "blocked", availabilityReason: "account-restricted" } : {}) };
825
884
  }
826
885
  /**
827
886
  * LiteLLM 的管理端点在 **proxy 根**(不是 /v1):去掉尾部斜杠与常见的 /v1 后缀,
@@ -838,7 +897,7 @@ export function litellmRoot(baseUrl) {
838
897
  }
839
898
  /**
840
899
  * LiteLLM:`/key/info` 给 key 自身的预算与花费,`/user/info`|`/team/info` 给更完整的视图。
841
- * 有预算上限才画进度;**没有上限时不编造百分比**,只如实显示已用金额。
900
+ * 各预算同时生效,分别保留;**没有上限时不编造百分比**,只如实显示已用金额。
842
901
  * 接口不给重置时刻时不显示重置(别把金额渲染成倒计时)。
843
902
  * https://docs.litellm.ai/docs/proxy/virtual_keys
844
903
  */
@@ -848,17 +907,25 @@ export function normalizeLitellm(keyBody, budgetBody) {
848
907
  // 真实响应是 `{ key, info: {...} }`;也接受直接把字段放在顶层。
849
908
  const keyInfo = pick(keyBody?.info) ?? pick(keyBody);
850
909
  if (!keyInfo) throw failure("subusage/response", "Invalid LiteLLM key info response");
851
- const scope = pick(budgetBody) ?? {};
852
- const spend = num(scope.spend) ?? num(keyInfo.spend);
853
- const maxBudget = num(scope.max_budget) ?? num(keyInfo.max_budget);
854
- const resetAt = typeof scope.budget_reset_at === "string" ? scope.budget_reset_at : typeof keyInfo.budget_reset_at === "string" ? keyInfo.budget_reset_at : undefined;
910
+ const scopes = [["key", keyInfo]];
911
+ for (const body of Array.isArray(budgetBody) ? budgetBody : [budgetBody]) {
912
+ if (pick(body?.team_info)) scopes.push(["team", body.team_info]);
913
+ else if (pick(body?.user_info)) scopes.push(["user", body.user_info]);
914
+ else if (pick(body)) scopes.push(["scope", body]);
915
+ }
916
+ const windows = [], extras = [];
855
917
  const round = value => Math.round(value * 100) / 100;
856
- if (spend !== undefined && maxBudget !== undefined && maxBudget > 0) {
857
- return { windows: [windowRow("period", Math.min(100, spend / maxBudget * 100), resetAt, undefined, { used: round(spend), limit: round(maxBudget), unit: "USD" })], extras: [], coverage: "complete" };
918
+ for (const [scope, row] of scopes) {
919
+ const spend = num(row.spend), maxBudget = num(row.max_budget);
920
+ if (spend === undefined || spend < 0) continue;
921
+ if (maxBudget !== undefined && maxBudget > 0) {
922
+ const resetAt = typeof row.budget_reset_at === "string" ? row.budget_reset_at : undefined;
923
+ windows.push({ ...windowRow("period", spend / maxBudget * 100, resetAt, undefined, { used: round(spend), limit: round(maxBudget), unit: "USD" }), ...(scopes.length > 1 ? { budgetScope: scope } : {}) });
924
+ } else extras.push({ kind: scope === "key" ? "spend" : `${scope}-spend`, value: `${round(spend)} USD` });
858
925
  }
859
- // 没有预算上限:没有百分比可算,如实给金额。
860
- if (spend !== undefined) return { windows: [], extras: [{ kind: "spend", value: `${round(spend)} USD` }], coverage: "complete" };
861
- throw failure("subusage/response", "LiteLLM 响应没有可用的预算或花费");
926
+ if (!windows.length && !extras.length) throw failure("subusage/response", "LiteLLM 响应没有可用的预算或花费");
927
+ // 各预算同时约束,不用更宽松的上级覆盖 Key。
928
+ return { windows, extras, coverage: "complete" };
862
929
  }
863
930
  function detectConfigured(ctx) {
864
931
  try { const ids = new Set(ctx.llm.listProviders().map(p => p.id)); return Object.fromEntries(IDS.map(id => [id, ids.has(id)])); }
@@ -927,6 +994,8 @@ export class SubUsageService extends TypertRemoteService {
927
994
  this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
928
995
  // SuperGrok 只读共享登录文件(~/.grok/auth.json 等)取 OAuth Bearer;可注入仅供隔离测试。
929
996
  this.grokAuth = options.resolveGrokAuth ?? (async () => grokAuthFileCredentials(this.io));
997
+ // 过期时借 dsh-grok-kit 的官方会话链刷新同一登录文件;解析不到兄弟插件时返回 undefined 并降级。
998
+ this.resolveGrokKit = options.resolveGrokKit ?? loadGrokKit;
930
999
  // Codex 只读共享登录文件($CODEX_HOME/auth.json 或 ~/.codex/auth.json)取 OAuth access token。
931
1000
  this.codexAuth = options.resolveCodexAuth ?? (async () => codexAuthFileCredentials(this.io, { CODEX_HOME: (await this.environmentByName("CODEX_HOME"))?.value }));
932
1001
  this.cache = new Map(); this.inflight = new Map(); this.generations = new Map(); this.fingerprints = new Map(); this.disposed = false; this.controller = new AbortController();
@@ -998,6 +1067,33 @@ export class SubUsageService extends TypertRemoteService {
998
1067
  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" };
999
1068
  return ak && sk ? { key: ak, secret: sk, source: "manual" } : { key: undefined, secret: undefined, source: "none" };
1000
1069
  }
1070
+ /**
1071
+ * Grok 登录文件的 access token 过期时,借 dsh-grok-kit 的官方会话链续约:getAuth("xai")
1072
+ * 在共享凭据 store 的写锁内二次检查并刷新,新 token 由官方链写回同一个 ~/.grok/auth.json,
1073
+ * 与 grok-kit 聊天/搜索/Grok CLI 是同一套锁协议,不会顶掉 refresh-token 轮换。
1074
+ * 不直接信任 getAuth 返回值:无论刷新由本进程还是并发的另一进程完成,一律重读登录文件拿新 token 与新过期时间。
1075
+ * 回到 { renewed:true, identity };兄弟插件不可用时 unavailable:true;刷新尝试失败时带 refreshError。
1076
+ */
1077
+ async renewGrokIdentity(identity, signal) {
1078
+ if (!identity.expiresAt || Date.parse(identity.expiresAt) > this.now()) return { renewed: false, unavailable: true, identity };
1079
+ let kit;
1080
+ try { kit = await this.resolveGrokKit(); } catch { kit = undefined; }
1081
+ if (!kit?.XaiOAuthSession) return { renewed: false, unavailable: true, identity };
1082
+ let session;
1083
+ let refreshError;
1084
+ try {
1085
+ session = new kit.XaiOAuthSession();
1086
+ // pi-ai 内部有 15s 刷新超时与 2s 文件锁预算;外层再兜 20s 总预算,避免挂死药丸/工具刷新。
1087
+ const deadline = AbortSignal.timeout(20000);
1088
+ await session.models.getAuth("xai", { signal: signal ? AbortSignal.any([signal, deadline]) : deadline });
1089
+ } catch (error) { refreshError = error; }
1090
+ finally { try { session?.dispose?.(); } catch { /* 会话只持有进程内模型缓存,dispose 失败无后果 */ } }
1091
+ const cred = await this.grokAuth();
1092
+ if (cred?.key && cred.key !== identity.key && (!cred.expiresAt || Date.parse(cred.expiresAt) > this.now())) {
1093
+ return { renewed: true, identity: { key: cred.key, source: "auth-file", ...(cred.userId ? { userId: cred.userId } : {}), ...(cred.expiresAt ? { expiresAt: cred.expiresAt } : {}) } };
1094
+ }
1095
+ return { renewed: false, unavailable: false, refreshError, identity };
1096
+ }
1001
1097
  async fetchProvider(id, identity, settings, signal = this.controller.signal) {
1002
1098
  const get = (url, headers, init) => fetchJson(this.fetcher, url, headers, signal, init);
1003
1099
  // 火山方舟额度走管控面 OpenAPI:POST 根路径 + Action/Version 在 query,鉴权是 AK/SK 签名。
@@ -1006,7 +1102,7 @@ export class SubUsageService extends TypertRemoteService {
1006
1102
  const call = async (action, payload) => {
1007
1103
  const body = payload === undefined ? "{}" : JSON.stringify(payload);
1008
1104
  const signed = volcSignature({ accessKeyId: identity.key, secretAccessKey: identity.secret, action, host: spec.volcHost, region: spec.volcRegion, body });
1009
- const response = await get(signed.url, signed.headers, { method: "POST", body });
1105
+ const response = await get(signed.url, signed.headers, { method: "POST", body, notSubscribed404: true });
1010
1106
  const apiError = volcError(response);
1011
1107
  // 「这个账号没有这份套餐」不是故障,是答案:实测没有 BytePlus Coding Plan 的账号会返回
1012
1108
  // HTTP 404 `NotFound.BillingType`(coding plan config is not exist),`OperationDenied.NotSubscribed`
@@ -1051,7 +1147,7 @@ export class SubUsageService extends TypertRemoteService {
1051
1147
  // HTTP 200 但窗口为空是「未订阅」,不是 0% 用量:显式说明,不画零额度的假象。
1052
1148
  else if (!windows.length) extras.push({ kind: "plan", value: parsed.subscribed ? "本周期暂无窗口数据" : "未检测到该套餐订阅" });
1053
1149
  // 订阅状态要**结构化**带出去,不能只写进 extras 文案:同一组 AK/SK 能查到名下所有套餐,
1054
- // 界面上若把 7 条路由一律列出来,用户根本分不清哪个是哪个。前端据此收起没订阅的那些。
1150
+ // 界面上若把 4 条路由一律列出来,用户根本分不清哪个是哪个。前端据此收起没订阅的那些。
1055
1151
  return { windows, extras, coverage: windows.length ? "complete" : "partial", subscribed: parsed.subscribed === true };
1056
1152
  }
1057
1153
  const headers = { authorization: `Bearer ${identity.key}` };
@@ -1108,10 +1204,14 @@ export class SubUsageService extends TypertRemoteService {
1108
1204
  const keyInfo = await get(`${root}/key/info`, headers);
1109
1205
  const own = keyInfo?.info && typeof keyInfo.info === "object" && !Array.isArray(keyInfo.info) ? keyInfo.info : {};
1110
1206
  // 更完整的预算视图在 user/team info;拿不到就退回 key 自身的 info。
1111
- const scoped = typeof own.team_id === "string" && own.team_id.trim() ? `/team/info?team_id=${encodeURIComponent(own.team_id.trim())}`
1112
- : typeof own.user_id === "string" && own.user_id.trim() ? `/user/info?user_id=${encodeURIComponent(own.user_id.trim())}` : "";
1113
- const budget = scoped ? await get(`${root}${scoped}`, headers).catch(() => undefined) : undefined;
1114
- return normalizeLitellm(keyInfo, budget);
1207
+ const scopes = [["team", own.team_id], ["user", own.user_id]].filter(([, value]) => typeof value === "string" && value.trim());
1208
+ const budgets = await Promise.all(scopes.map(async ([scope, value]) => {
1209
+ const body = await get(`${root}/${scope}/info?${scope}_id=${encodeURIComponent(value.trim())}`, headers).catch(() => undefined);
1210
+ return body ? body[`${scope}_info`] ? body : { [`${scope}_info`]: body } : undefined;
1211
+ }));
1212
+ const data = normalizeLitellm(keyInfo, budgets);
1213
+ if (budgets.some(body => body === undefined)) data.coverage = "partial";
1214
+ return data;
1115
1215
  }
1116
1216
  if (id === "commandcode") {
1117
1217
  // 与提供方插件同一凭据来源,但直连计费接口并行拉取:其 report() 会对每个账户
@@ -1121,20 +1221,30 @@ export class SubUsageService extends TypertRemoteService {
1121
1221
  get(`${COMMAND_CODE_API_BASE}/alpha/billing/credits`, billingHeaders),
1122
1222
  get(`${COMMAND_CODE_API_BASE}/alpha/billing/subscriptions`, billingHeaders).catch(() => undefined)
1123
1223
  ]);
1124
- return normalizeCommandCode(credits, subscription);
1224
+ const data = normalizeCommandCode(credits, subscription);
1225
+ if (subscription === undefined) data.coverage = "partial";
1226
+ return data;
1125
1227
  }
1126
1228
  if (id === "xai-oauth") {
1127
- // 登录文件里的 access token 短命(实测 ~24 小时)。本插件**刻意不代刷**——refresh-token 轮换由
1128
- // Grok CLI / dsh-grok-kit 各自的锁协议管理,第三端刷新会把它们的轮换顶掉。但「过期」这件事必须
1129
- // 说清楚:不发这一枪的话,用户看到的是上游 401 转成的「登录或凭据已失效」,而他那边的 Grok Kit
1130
- // 明明写着「已登录」——两边对不上,只会让人以为插件坏了。
1131
- if (identity.expiresAt && Date.parse(identity.expiresAt) <= this.now()) {
1132
- throw failure("subusage/auth", `Grok 登录文件的 access token 已于 ${new Date(identity.expiresAt).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} 过期。用一次 Grok CLI,或在 设置 → xAI Grok 里重新登录,都会刷新同一个文件(${GROK_AUTH_FILE});本插件不代刷,以免与它们的 refresh-token 轮换互相顶掉。`);
1229
+ // 登录文件里的 access token 短命(实测 6 小时量级),而 grok-kit 的自动刷新是懒触发:
1230
+ // 只有它自己聊天/搜索遇到过期才刷,没有后台定时器。本插件每分钟读一次用量,是最常发现
1231
+ // 过期的一方——若只读不刷,就会出现「Grok Kit 显示已登录、用量卡片显示已过期」的割裂。
1232
+ // 过期时借 grok-kit 的官方会话链续约(同一文件、同一把写锁、compare-and-write,不顶轮换);
1233
+ // 兄弟插件不可用或刷新失败时,才退回可行动的手动提示。
1234
+ let grok = identity;
1235
+ if (grok.expiresAt && Date.parse(grok.expiresAt) <= this.now()) {
1236
+ const outcome = await this.renewGrokIdentity(grok, signal);
1237
+ if (outcome.renewed) grok = outcome.identity;
1238
+ else if (outcome.unavailable) throw failure("subusage/auth", `Grok 登录文件的 access token 已于 ${new Date(identity.expiresAt).toLocaleString("zh-CN")} 过期,但本机未能加载 dsh-grok-kit(≥0.2.0)的自动刷新链。用一次 Grok CLI(登录后发一条消息),或在 设置 → xAI Grok 里重新登录,都会刷新同一个文件(${GROK_AUTH_FILE})。`);
1239
+ else {
1240
+ const reason = outcome.refreshError?.message ? String(outcome.refreshError.message).slice(0, 200) : "登录刷新链未返回新凭据";
1241
+ throw failure("subusage/auth", `Grok 登录已于 ${new Date(identity.expiresAt).toLocaleString("zh-CN")} 过期,尝试经 dsh-grok-kit 自动刷新失败:${reason}。通常需要重新登录:在 设置 → xAI Grok 重新登录,或运行 grok login(同一登录文件 ${GROK_AUTH_FILE})。`);
1242
+ }
1133
1243
  }
1134
1244
  // 直连官方 Grok CLI 计费代理;X-XAI-Token-Auth 的取值是 token_header 字符串(xai-grok-cli)
1135
1245
  // 而非布尔,x-userid 取登录文件。套餐名走 /v1/settings(subscription_tier_display),
1136
1246
  // 失败不阻塞用量(回退 billing 响应里的 subscription_tier)。
1137
- const grokHeaders = { ...headers, "x-xai-token-auth": GROK_TOKEN_AUTH, "x-grok-client-version": GROK_CLIENT_VERSION, ...(identity.userId ? { "x-userid": identity.userId } : {}) };
1247
+ const grokHeaders = { authorization: `Bearer ${grok.key}`, "x-xai-token-auth": GROK_TOKEN_AUTH, "x-grok-client-version": GROK_CLIENT_VERSION, ...(grok.userId ? { "x-userid": grok.userId } : {}) };
1138
1248
  const [billing, settings] = await Promise.all([
1139
1249
  get(`${GROK_API_BASE}/billing?format=credits`, grokHeaders),
1140
1250
  get(`${GROK_API_BASE}/settings`, grokHeaders).catch(() => undefined)
@@ -1143,7 +1253,7 @@ export class SubUsageService extends TypertRemoteService {
1143
1253
  }
1144
1254
  if (id === "openai-codex") {
1145
1255
  // ChatGPT 后端私有接口:Bearer 是 Codex CLI 的 OAuth access token,不是 OPENAI_API_KEY。
1146
- return normalizeCodexUsage(await get(`${CODEX_API_BASE}/wham/usage`, headers));
1256
+ return normalizeCodexUsage(await get(`${CODEX_API_BASE}/wham/usage`, { ...headers, ...(identity.accountId ? { "ChatGPT-Account-Id": identity.accountId } : {}) }));
1147
1257
  }
1148
1258
  const base = "https://platform.xiaomimimo.com/api/v1";
1149
1259
  const h = { cookie: identity.key, origin: "https://platform.xiaomimimo.com", referer: "https://platform.xiaomimimo.com/#/console/balance", "x-timezone": "UTC+08:00" };
@@ -1191,6 +1301,54 @@ export class SubUsageService extends TypertRemoteService {
1191
1301
  return { mode: activeId === "" ? "auto" : "fixed", activeId, accounts: accounts.map(account => ({ ...account, active: account.id === activeId })) };
1192
1302
  } catch { return null; }
1193
1303
  }
1304
+ /**
1305
+ * 自动轮换模式下的**轮换池健康摘要**:UI 通道(read/refresh)默认只回顶层 Key 单账户,
1306
+ * 用户看到的是乐观数字,但实际请求会落到池中任意账户——默认账户健康不代表池健康
1307
+ * (其余账户可能已全部 5h 限额)。这里复用各账户自己的缓存并行读一遍,把摘要挂到默认
1308
+ * 账户 entry 上,Client 仅在 auto 模式渲染。固定模式(activeId 非空)返回 null。
1309
+ *
1310
+ * 业务口径(不能简单取「池中最紧张账户」):各账户是**独立配额**,轮换器会避开已限额的
1311
+ * 账户,所以单账户限额不是立即失败信号。只有「所有有凭据的账户都限额」才是池耗尽:
1312
+ * healthy = ok 且没有 rate-limited 窗口
1313
+ * blocked = ok 但至少一个窗口 rate-limited(容量降级,仍可能成功)
1314
+ * unusable = no-key/no-cookie/disabled/error(读不到或没凭据,不计入有效容量)
1315
+ * 池里没有任何有凭据的账户时 exhausted=false(那是「未配置」,由默认 entry 的 no-key 表达)。
1316
+ */
1317
+ async commandCodePool(settings, force, defaultEntry) {
1318
+ const plan = this.commandCodeAccountPlan();
1319
+ if (!plan || plan.mode !== "auto") return null;
1320
+ // 默认账户那条 entry 已由调用方读过(避免重复一发),其余账户各自命中 provider() 的 TTL/
1321
+ // inflight 缓存,因此每分钟的自动刷新不会真的打 N 次计费接口。
1322
+ const readOne = async (account) => {
1323
+ if (account.id === "default") return defaultEntry;
1324
+ try { return await this.provider("commandcode", settings, force, account.id); }
1325
+ catch { return { ...this.base("commandcode", "none", settings, account.id), state: "error", errorCode: "subusage/internal", error: "读取用量失败", windows: [], extras: [] }; }
1326
+ };
1327
+ const entries = await Promise.all(plan.accounts.map(account => readOne(account)));
1328
+ const accounts = plan.accounts.map((account, index) => {
1329
+ const entry = entries[index];
1330
+ const windows = Array.isArray(entry?.windows) ? entry.windows : [];
1331
+ const ok = entry?.state === "ok";
1332
+ const paid = entry?.availability === "paygo";
1333
+ const limited = entry?.availability === "blocked" || windows.some(w => w.status === "rate-limited");
1334
+ const known = paid || entry?.coverage !== "partial" && windows.length > 0;
1335
+ return {
1336
+ id: account.id, label: account.label, active: account.active === true,
1337
+ state: entry?.state ?? "error", errorCode: entry?.errorCode, windows,
1338
+ // usable:读到了且没有任何限额窗口;blocked:读到了但有窗口限额(容量降级,仍可能成功)。
1339
+ usable: ok && (paid || known && !limited),
1340
+ ...(ok && !known && !limited ? { availability: "unknown" } : entry?.availability ? { availability: entry.availability } : {}),
1341
+ blocked: ok && !paid && limited
1342
+ };
1343
+ });
1344
+ // 「有效容量」= usable + blocked(真的有凭据且读到了额度的账户);no-key/error/disabled 不算。
1345
+ const capable = accounts.filter(a => a.usable || a.blocked);
1346
+ const available = accounts.filter(a => a.usable).length;
1347
+ const blockedCount = accounts.filter(a => a.blocked).length;
1348
+ const unusableCount = accounts.length - available - blockedCount;
1349
+ const exhausted = capable.length > 0 && capable.every(a => a.blocked) && !accounts.some(a => a.state === "error" || a.availability === "unknown");
1350
+ return { mode: "auto", total: accounts.length, available, blockedCount, unusableCount, exhausted, accounts };
1351
+ }
1194
1352
  /** 按 id 失效一家 provider 的**全部账户**缓存:凭据/开关变化不能只影响默认账户那一条。 */
1195
1353
  invalidateProvider(id) {
1196
1354
  const keys = new Set([id]);
@@ -1229,7 +1387,7 @@ export class SubUsageService extends TypertRemoteService {
1229
1387
  }
1230
1388
  // fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
1231
1389
  // 空串与 "default" 同为默认账户,归一后共享缓存。
1232
- 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]);
1390
+ 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") : id === "openai-codex" ? identity.accountId ?? null : null]);
1233
1391
  if (this.fingerprints.has(cacheKey) && this.fingerprints.get(cacheKey) !== fingerprint) this.generations.set(cacheKey, (this.generations.get(cacheKey) ?? 0) + 1);
1234
1392
  this.fingerprints.set(cacheKey, fingerprint);
1235
1393
  const generation = this.generations.get(cacheKey) ?? 0;
@@ -1314,6 +1472,8 @@ export class SubUsageService extends TypertRemoteService {
1314
1472
  // 只输出真正有值的字段:视图里带 `undefined` 的键会被调用方的 schema 校验判成型别错误
1315
1473
  // (DSH 就会),而且「字段缺席」本来就该表示「这家没报告」,不是「报告了空」。
1316
1474
  ...(typeof entry.coverage === "string" ? { coverage: entry.coverage } : {}),
1475
+ ...(typeof entry.availability === "string" ? { availability: entry.availability } : {}),
1476
+ ...(typeof entry.availabilityReason === "string" ? { availabilityReason: entry.availabilityReason } : {}),
1317
1477
  ...(typeof entry.freshness === "string" ? { freshness: entry.freshness } : {}),
1318
1478
  ...(typeof entry.lastSuccessAt === "string" ? { lastSuccessAt: entry.lastSuccessAt } : {}),
1319
1479
  // 对外视图里不能出现内部凭据引用名:no-key 文案嵌了它,这里换成账户标签
@@ -1333,6 +1493,16 @@ export class SubUsageService extends TypertRemoteService {
1333
1493
  const unknownEntries = (query.unknown ?? []).map(id => ({ providerId: id, label: id, state: "error", errorCode: "subusage/unknown-provider", error: "本机运行中的 Host 版本较旧,不认识该提供商;重启 DSH 后生效", apiDetected: null, coverage: "partial", freshness: "unknown", keySource: "none", retryable: false, retainPrevious: false, windows: [], extras: [] }));
1334
1494
  const latest = this.load();
1335
1495
  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: "读取期间配置已改变,请刷新" })), ...unknownEntries]);
1496
+ // Command Code 自动轮换:把轮换池健康摘要挂到默认账户(顶层 Key)entry 上。
1497
+ // 仅当本次查询的就是默认账户(commandCodeAccount 为空)时——查某个额外账户时不挂,
1498
+ // 那条 entry 代表的是被点名的账户本身。提供方插件未运行 / 固定账户时 pool 为 null,原样返回。
1499
+ if (query.providerIds.includes("commandcode") && (!query.commandCodeAccount || query.commandCodeAccount === "default")) {
1500
+ const ccIndex = entries.findIndex(e => e && e.providerId === "commandcode");
1501
+ if (ccIndex >= 0) {
1502
+ const pool = await this.commandCodePool(latest, query.force, entries[ccIndex]);
1503
+ if (pool) entries[ccIndex] = { ...entries[ccIndex], accountPool: pool };
1504
+ }
1505
+ }
1336
1506
  return this.result(latest, [...entries, ...unknownEntries]);
1337
1507
  }
1338
1508
  startMimoLogin(request) { return this.login.start(parseLoginStart(request)); } getMimoLoginStatus() { return this.login.status(); }
@@ -1424,7 +1594,8 @@ function formatQuotaText(value) {
1424
1594
  if (entry.state !== "ok") return `${head}:${entry.error || "读取失败"}`;
1425
1595
  const parts = [];
1426
1596
  for (const window of entry.windows || []) {
1427
- parts.push(`${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
1597
+ const scope = window.budgetScope ? `${window.budgetScope} 预算` : ({ text: "文本/向量", "daily-capabilities": "日额度适用能力", all: "全部能力共享" }[window.detail?.scope] || window.groupLabel);
1598
+ parts.push(`${scope ? scope + " · " : ""}${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
1428
1599
  }
1429
1600
  // 账户名已经在标题行里了;再打一遍只会把额度挤出视野。
1430
1601
  // **只对多账户行跳过**(顶层 account 字段 = Command Code 的账户标注):火山方舟的席位标注
@@ -1432,7 +1603,9 @@ function formatQuotaText(value) {
1432
1603
  // 信息,跟着一起丢就等于把额度说成不属于任何席位。
1433
1604
  const skipAccountExtra = typeof entry.account === "string" && entry.account !== "";
1434
1605
  for (const extra of entry.extras || []) { if (skipAccountExtra && extra.kind === "account") continue; parts.push(`${extra.kind}: ${extra.value}`); }
1435
- if (entry.freshness === "stale") parts.push("缓存数据");
1606
+ const advice = { included: "接口仍允许订阅续用(基础额度可能已用尽)", paygo: "包含额度用尽时可按量续用,实际余额与服务端为准", blocked: "账户/预算受限或包含额度已用尽", unknown: "续用条件待确认,不保证可调用" };
1607
+ if (advice[entry.availability]) parts.push(advice[entry.availability]);
1608
+ if (entry.freshness === "stale") parts.push("缓存数据");
1436
1609
  return `${head}:${parts.length ? parts.join(";") : "暂无额度数据"}`;
1437
1610
  });
1438
1611
  // 一条额度都没读到时,末尾补一句汇总原因:模型据此才能告诉用户下一步做什么。
@@ -1467,7 +1640,8 @@ function hasQuotaData(entry) {
1467
1640
  if (!entry) return false;
1468
1641
  if (entry.state === "error") return true;
1469
1642
  if (entry.state !== "ok") return false;
1470
- if (Array.isArray(entry.windows) && entry.windows.length) return true;
1643
+ if (typeof entry.availability === "string") return true;
1644
+ if (Array.isArray(entry.windows) && entry.windows.length) return true;
1471
1645
  const metadata = typeof entry.account === "string" && entry.account !== "" ? ["account", "plan"] : ["plan"];
1472
1646
  return (Array.isArray(entry.extras) ? entry.extras : []).some(item => !metadata.includes(item?.kind));
1473
1647
  }
@@ -1531,7 +1705,9 @@ function registerQuotaTool(ctx) {
1531
1705
  value: { type: "string", required: true }
1532
1706
  } }
1533
1707
  },
1534
- coverage: { type: "string", description: "complete | partial" },
1708
+ coverage: { type: "string", description: "complete | partial — completeness of applicable reported quota facts, not a count of windows." },
1709
+ availability: { type: "string", description: "Optional advisory: included | paygo | blocked | unknown. Window exhaustion alone need not block paid usage; absent means no separate billing-route evidence." },
1710
+ availabilityReason: { type: "string", description: "Machine-readable basis for availability; no credentials or raw upstream objects." },
1535
1711
  freshness: { type: "string", description: "fresh | stale" },
1536
1712
  lastSuccessAt: { type: "string" },
1537
1713
  account: { type: "string", description: "Account label this entry reports (Command Code only, when the provider has several accounts)." },
@@ -1565,5 +1741,7 @@ function registerQuotaTool(ctx) {
1565
1741
  }));
1566
1742
  });
1567
1743
  }
1568
- export { apply, inject, name };
1744
+ // IDS / PROVIDERS 仅供双端契约测试(test-contract.mjs)核对 Client 的 PROVIDER_META/PROVIDER_ORDER,
1745
+ // 防止新增厂商只改一端:漏改 Host 药丸取不到数、漏改 Client 则设置页与药丸缺项,且都不会报错。
1746
+ export { apply, inject, name, IDS, PROVIDERS };
1569
1747
  export default { name, inject, apply };