dsh-subusage 0.11.1 → 0.12.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
@@ -11,7 +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.1";
14
+ const VERSION = "0.12.0";
15
15
  const inject = ["llm"];
16
16
  const PROVIDERS = {
17
17
  "zai-coding-cn": { label: "Z.ai", envName: "ZAI_CODING_CN_API_KEY" },
@@ -64,7 +64,8 @@ const PROVIDERS = {
64
64
  // 国内那两个 id 药丸仍认:Client 的 ROUTE_PROVIDER_ALIAS 把它们归并到 arkcli 路由。
65
65
  // DeepSeek 官方:余额型(不是订阅窗口),与推理**同一把** API Key,端点与响应见官方文档
66
66
  // https://api-docs.deepseek.com/zh-cn/api/get-user-balance
67
- "deepseek": { label: "DeepSeek", envName: "DEEPSEEK_API_KEY" },
67
+ "deepseek": { label: "DeepSeek API", envName: "DEEPSEEK_API_KEY" },
68
+ "deepseek-account": { label: "DeepSeek 账号", envName: "", managedByPlugin: true },
68
69
  // OpenRouter:key 限额与用量走 /key(任意 key 可用),账户余额走 /credits(**只对
69
70
  // management / provisioning key 开放**,普通推理 key 会被拒 → 静默降级为只显示限额)。
70
71
  "openrouter": { label: "OpenRouter", envName: "OPENROUTER_API_KEY", defaultEnabled: false },
@@ -112,6 +113,7 @@ const PROVIDER_ALIASES = {
112
113
  minimax: ["minimax-cn", "minimax"], "海螺": ["minimax-cn", "minimax"],
113
114
  commandcode: ["commandcode"], "command-code": ["commandcode"], "command code": ["commandcode"],
114
115
  supergrok: ["xai-oauth"], grok: ["xai-oauth"], xai: ["xai-oauth"],
116
+ "deepseek-official": ["deepseek-account"], "deepseek api": ["deepseek"], "deepseek 账号": ["deepseek-account"],
115
117
  codex: ["openai-codex"], chatgpt: ["openai-codex"], gpt: ["openai-codex"], openai: ["openai-codex"],
116
118
  ark: ARK_IDS, volcengine: ARK_IDS, volc: ARK_IDS, "火山": ARK_IDS, "方舟": ARK_IDS, "火山方舟": ARK_IDS, doubao: ARK_IDS, "豆包": ARK_IDS
117
119
  };
@@ -437,7 +439,7 @@ async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
437
439
  // 避免用户被误导去重登录;两个条件都加显式括号,不依赖 &&/|| 优先级。
438
440
  if ((response.status >= 300 && response.status < 400)) throw failure("subusage/response", "服务端返回了意外的重定向响应(HTTP 3xx)");
439
441
  if ((response.status === 401 || response.status === 403)) throw failure("subusage/auth", "登录或凭据已失效");
440
- if (!response.ok) {
442
+ if (!response.ok && !(init.notSubscribed404 === true && response.status === 404)) {
441
443
  const retry = response.status === 408 || response.status === 429 || response.status >= 500;
442
444
  const raw = response.headers?.get?.("retry-after");
443
445
  const retryAfterMs = raw ? (/^\d+(\.\d+)?$/.test(raw) ? Number(raw) * 1000 : Math.max(0, Date.parse(raw) - Date.now())) : undefined;
@@ -453,7 +455,8 @@ async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
453
455
  } catch (e) { if (e instanceof RemoteError) throw e; throw failure("subusage/network", "读取响应失败", true); }
454
456
  let body;
455
457
  try { body = JSON.parse(text); } catch { throw failure("subusage/response", "响应不是合法 JSON"); }
456
- if ([401, 403, "401", "403"].includes(body?.code)) throw failure("subusage/auth", "登录或凭据已失效");
458
+ if (response.status === 404 && init.notSubscribed404 === true && !/^(NotFound\.BillingType|OperationDenied\.NotSubscribed)$/.test(body?.ResponseMetadata?.Error?.Code ?? "")) throw failure("subusage/http", "HTTP 404");
459
+ if ([401, 403, "401", "403"].includes(body?.code)) throw failure("subusage/auth", "登录或凭据已失效");
457
460
  return body;
458
461
  }
459
462
  function numeric(v) { return typeof v === "number" && Number.isFinite(v) && v >= 0; }
@@ -466,7 +469,10 @@ const RANK = { month: 0, sub: 0, period: 0, week: 1, "7d": 1, day: 2, "5h": 2, r
466
469
  function cascadeRateLimited(windows) {
467
470
  const roots = windows.filter(w => w.status === "rate-limited");
468
471
  for (const w of windows) {
469
- const blockers = roots.filter(root => (RANK[root.kind] ?? 3) < (RANK[w.kind] ?? 3));
472
+ const blockers = roots.filter(root => {
473
+ const sourceScope = root.detail?.scope ?? "all", targetScope = w.detail?.scope ?? "all";
474
+ return (sourceScope === "all" || sourceScope === targetScope) && (RANK[root.kind] ?? 3) < (RANK[w.kind] ?? 3);
475
+ });
470
476
  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))] }; }
471
477
  }
472
478
  return windows;
@@ -514,18 +520,25 @@ export function normalizeZai(body) {
514
520
  return { windows: cascadeRateLimited(windows), level: typeof data.level === "string" ? data.level : undefined };
515
521
  }
516
522
  export function normalizeMimo(balBody, detailBody, usageBody) {
517
- const bal = balBody?.data;
518
- if (!bal || typeof bal.balance !== "string" || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(bal.balance) || !Number.isFinite(Number(bal.balance))) throw failure("subusage/response", "Invalid MiMo balance response");
523
+ const rawBal = balBody?.data;
524
+ const bal = rawBal && typeof rawBal.balance === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(rawBal.balance) && Number.isFinite(Number(rawBal.balance)) ? rawBal : undefined;
519
525
  const data = usageBody?.data ?? {}; const detail = detailBody?.data ?? {};
520
526
  const pick = (group, name) => Array.isArray(group?.items) ? group.items.find(i => i?.name === name) : undefined;
521
- const pool = pick(data.usage, "plan_total_token") ?? pick(data.monthUsage, "month_total_token");
522
- const poolDetail = numeric(pool?.used) && numeric(pool?.limit) ? { used: pool.used, limit: pool.limit, unit: "credits" } : undefined;
523
- const windows = pool && numeric(pool.percent) ? [windowRow("sub", pool.percent * 100, detail.currentPeriodEnd, undefined, poolDetail)] : [];
524
- const currency = typeof bal.currency === "string" && /^[A-Z]{3}$/.test(bal.currency) ? bal.currency : "";
525
- const extras = [{ kind: "balance", value: `${bal.balance} ${currency}`.trim() }];
527
+ const pools = [pick(data.usage, "plan_total_token"), pick(data.monthUsage, "month_total_token")].filter(Boolean);
528
+ const pool = pools.find(row => row.limit !== 0) ?? pools[0];
529
+ const groups = ["usage", "monthUsage"].filter(key => Object.hasOwn(data, key)).map(key => data[key]);
530
+ const emptyGroups = groups.length > 0 && groups.every(group => group === null || Array.isArray(group?.items) && group.items.length === 0);
531
+ const detailReadable = detailBody?.data === null || detailBody?.data && typeof detailBody.data === "object" && !Array.isArray(detailBody.data);
532
+ const noSubscription = detailReadable && [undefined, 0].includes(detailBody?.code) && [undefined, 0].includes(usageBody?.code) && !detail.planName && !detail.planCode && (emptyGroups || pools.length > 0 && pools.every(row => row.limit === 0 && row.used === 0 && row.percent === 0));
533
+ const poolDetail = numeric(pool?.used) && numeric(pool?.limit) && pool.limit > 0 ? { used: pool.used, limit: pool.limit, unit: "credits" } : undefined;
534
+ const windows = pool && pool.limit !== 0 && numeric(pool.percent) ? [windowRow("sub", pool.percent * 100, detail.currentPeriodEnd, undefined, poolDetail)] : [];
535
+ const currency = typeof bal?.currency === "string" && /^[A-Z]{3}$/.test(bal.currency) ? bal.currency : "";
536
+ const extras = bal ? [{ kind: "balance", value: `${bal.balance} ${currency}`.trim() }] : [];
537
+ if (!bal && !windows.length) throw failure("subusage/response", "MiMo 没有可读的余额或套餐用量");
538
+ if (!bal) extras.push({ kind: "note", value: "余额暂不可读;套餐用量已更新" });
526
539
  const plan = detail.planName || detail.planCode;
527
540
  if (typeof plan === "string") extras.push({ kind: "plan", value: plan });
528
- return { windows, extras, balanceOk: true, coverage: windows.length ? "complete" : "partial" };
541
+ return { windows, extras, balanceOk: !!bal, ...(windows.length ? { subscribed: true } : noSubscription ? { subscribed: false } : {}), coverage: windows.length || noSubscription && !!bal ? "complete" : "partial" };
529
542
  }
530
543
  export function normalizeOpencodeGo(body) {
531
544
  const src = body?.usage ?? body;
@@ -558,7 +571,12 @@ export function normalizeNanoGpt(body, balanceBody) {
558
571
  const extras = [{ kind: "plan", value: body.state }];
559
572
  const usd = typeof balanceBody?.usd_balance === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(balanceBody.usd_balance.trim()) ? balanceBody.usd_balance.trim() : undefined;
560
573
  if (usd) extras.push({ kind: "balance", value: `${usd} USD` });
561
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length && !partial ? "complete" : "partial" };
574
+ const routing = body?.routing;
575
+ const mode = routing?.recommendedMode;
576
+ const availability = mode === "unavailable" ? { availability: "blocked", availabilityReason: "routing-policy" }
577
+ : mode === "subscription" ? { availability: routing.subscriptionQuotaAvailable === true ? "included" : "unknown", availabilityReason: "routing-advice" }
578
+ : mode === "paygo" ? { availability: usd !== undefined && Number.isFinite(Number(usd)) && Number(usd) > 0 && routing.paidSpendPolicyAllowsBalance !== false ? "paygo" : "unknown", availabilityReason: "routing-advice" } : {};
579
+ return { windows: cascadeRateLimited(windows), extras, coverage: (windows.length || usd !== undefined || mode === "unavailable") && !partial ? "complete" : "partial", ...availability };
562
580
  }
563
581
  // 官方 CLI 的计数消歧规则:有 remaining_percent 时按它校准;旧响应 usage_count 是剩余。
564
582
  // https://github.com/MiniMax-AI/cli/blob/main/src/utils/quota.ts
@@ -602,7 +620,8 @@ export function normalizeMinimax(body) {
602
620
  const reset = numeric(endTime) && endTime > 0 && Number.isFinite(new Date(endTime).getTime()) ? new Date(endTime).toISOString() : undefined;
603
621
  windows.push(windowRow(kind, usedPercent, reset, undefined, detail));
604
622
  }
605
- return { windows: cascadeRateLimited(windows), extras: [], coverage: windows.length === 2 ? "complete" : "partial" };
623
+ const unlimited = !windows.length && row.current_interval_status === 3 && row.current_weekly_status === 3;
624
+ 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" };
606
625
  }
607
626
  export function normalizeCommandCode(body, subscriptionBody) {
608
627
  // 输入是 Command Code 计费接口(/alpha/billing/credits)的原始响应体。
@@ -642,7 +661,11 @@ export function normalizeCommandCode(body, subscriptionBody) {
642
661
  if (reported.length) extras.push({ kind: "balance", value: `${Math.round(reported.reduce((sum, v) => sum + Math.max(0, v), 0) * 100) / 100} credits` });
643
662
  // 月池耗尽并不证明已购/赠送池或短窗口也不可用;只级联接口的周/5小时限制。
644
663
  cascadeRateLimited(windows.filter(w => w.kind !== "month"));
645
- return { windows, extras, coverage: hasMonthlyBalance && monthlyCap !== undefined ? "complete" : "partial" };
664
+ const exhausted = windows.some(w => w.status === "rate-limited");
665
+ const purchased = credits.purchasedCredits;
666
+ const availability = exhausted && numeric(purchased) && purchased > 0 ? { availability: "paygo", availabilityReason: "paid-credits" }
667
+ : exhausted ? { availability: "blocked", availabilityReason: "quota-exhausted" } : {};
668
+ return { windows, extras, coverage: hasMonthlyBalance || windows.length || reported.length ? "complete" : "partial", ...availability };
646
669
  }
647
670
  // SuperGrok / X Premium 统一用量池:GetGrokCreditsConfig 响应(官方 grok CLI 的
648
671
  // /v1/billing?format=credits)。creditUsagePercent 是“已用”百分比(0-100),剩余由
@@ -688,11 +711,14 @@ export function normalizeSiliconFlow(body) {
688
711
  }
689
712
  // Codex / ChatGPT 订阅用量:rate_limit.primary_window 与 secondary_window 各含 used_percent 与
690
713
  // resets_at。免费档的次窗口是 30 天,所以有 window_seconds 时按它判窗口名,缺失时按位置回退 5h / 7d。
691
- // credits 只在真正报告了可用余额时显示:has_credits=false、unlimited=true、balance="0" 都不显示,
714
+ // credits 只在真正报告了可用余额时显示:has_credits=false、balance="0" 不画余额;unlimited 单独保留状态,
692
715
  // 不把「无额度」画成 0。
693
716
  // https://raw.githubusercontent.com/steipete/CodexBar/main/docs/codex.md
694
717
  export function normalizeCodexUsage(body) {
695
718
  const windows = [];
719
+ const plan = typeof body?.plan_type === "string" ? body.plan_type.trim() : "";
720
+ // 当前 Pro 三档无短窗;未知档位的缺失时长不按主窗口位置猜成 5h。
721
+ const weeklyOnly = ["pro", "prolite", "promax"].includes(plan.toLowerCase());
696
722
  const windowKind = (row, fallback) => {
697
723
  const seconds = row?.window_seconds ?? row?.limit_window_seconds;
698
724
  if (!Number.isFinite(seconds) || seconds <= 0) return fallback;
@@ -709,22 +735,48 @@ export function normalizeCodexUsage(body) {
709
735
  windows.push(windowRow(windowKind(row, fallback), row.used_percent, reset));
710
736
  };
711
737
  const limits = body?.rate_limit && typeof body.rate_limit === "object" && !Array.isArray(body.rate_limit) ? body.rate_limit : {};
712
- push(limits.primary_window, "5h");
713
- push(limits.secondary_window, "7d");
714
- // HTTP 200 但没有任何窗口 = 该账号没有可读的订阅额度,不是 0% 用量。
715
- if (!windows.length) return { windows: [], extras: [{ kind: "plan", value: "未检测到订阅额度窗口" }], coverage: "partial" };
738
+ const positional = !plan || ["plus", "team", "business"].includes(plan.toLowerCase());
739
+ push(limits.primary_window, weeklyOnly ? "week" : positional ? "5h" : "rolling");
740
+ push(limits.secondary_window, weeklyOnly ? "week" : positional ? "7d" : "rolling");
716
741
  const extras = [];
717
- // 实测:plan_type 给套餐档位(如 "plus"),值得显示。
718
- const plan = typeof body?.plan_type === "string" && body.plan_type.trim() ? body.plan_type.trim() : "";
719
742
  if (plan) extras.push({ kind: "plan", value: plan });
720
743
  const credits = body?.credits && typeof body.credits === "object" && !Array.isArray(body.credits) ? body.credits : {};
721
744
  const balance = typeof credits.balance === "string" && credits.balance.trim() ? credits.balance.trim() : typeof credits.balance === "number" ? String(credits.balance) : "";
722
- if (credits.has_credits === true && credits.unlimited !== true && balance && balance !== "0") extras.push({ kind: "balance", value: balance });
723
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length >= 2 ? "complete" : "partial" };
745
+ const creditBalance = balance && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(balance) && Number.isFinite(Number(balance)) ? Number(balance) : undefined;
746
+ if (credits.has_credits === true && credits.unlimited !== true && creditBalance !== undefined) extras.push({ kind: "balance", value: balance });
747
+ if (credits.has_credits === true && credits.unlimited === true) extras.push({ kind: "credit-status", value: "unlimited" });
748
+ const control = body?.spend_control;
749
+ const member = control?.individual_limit;
750
+ const memberLimit = Number(member?.limit), memberUsed = member?.used == null ? NaN : Number(member.used);
751
+ if (member && Number.isFinite(memberLimit) && memberLimit > 0 && Number.isFinite(memberUsed) && memberUsed >= 0) {
752
+ const reset = numeric(member.reset_at) && member.reset_at > 0 ? new Date(member.reset_at * 1000).toISOString() : undefined;
753
+ windows.push({ ...windowRow("period", memberUsed / memberLimit * 100, reset, undefined, { used: memberUsed, limit: memberLimit, unit: "credits", scope: "member-budget" }), groupLabel: "Member budget" });
754
+ }
755
+ const reached = body?.rate_limit_reached_type?.kind;
756
+ 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);
757
+ const exhausted = windows.some(w => w.status === "rate-limited") || limits.limit_reached === true || limits.allowed === false;
758
+ const creditFallback = credits.has_credits === true && (credits.unlimited === true || creditBalance > 0);
759
+ const availability = restricted ? { availability: "blocked", availabilityReason: "spend-control" }
760
+ : exhausted && creditFallback ? { availability: "unknown", availabilityReason: "credit-eligibility" }
761
+ : exhausted ? { availability: "blocked", availabilityReason: "quota-exhausted" } : {};
762
+ if (!windows.length && !extras.some(e => e.kind !== "plan") && !restricted) extras.push({ kind: "note", value: "未检测到订阅额度窗口" });
763
+ const reported = windows.length || extras.some(e => e.kind !== "plan" && e.kind !== "note") || restricted;
764
+ // 额外特性池未按所选模型映射前,不声称已完整覆盖它们。
765
+ const partial = Array.isArray(body?.additional_rate_limits) && body.additional_rate_limits.length > 0;
766
+ return { windows: cascadeRateLimited(windows), extras, coverage: reported && !partial ? "complete" : "partial", ...availability };
724
767
  }
725
768
  // DeepSeek 官方余额(余额型):GET https://api.deepseek.com/user/balance,Bearer 与推理同一把 Key。
726
769
  // 响应 { is_available, balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }] },
727
770
  // 金额是**字符串十进制**。余额没有上限也就没有百分比:不产出窗口,只产出 extras。
771
+ // DSH 账号服务已完成鉴权与余额响应投影;本插件不读取账号 grant/token。
772
+ export function normalizeDeepseekAccountBalance(body) {
773
+ const decimal = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
774
+ if (body?.status !== "ready" || !Array.isArray(body.value)) throw failure("subusage/response", "本机 DeepSeek 账号余额读取失败,请检查 DSH 的账号与余额页面", true);
775
+ const wallets = body.value.filter(row => ["CNY", "USD"].includes(row?.currency) && typeof row.balance === "string" && decimal.test(row.balance));
776
+ if (!wallets.length || wallets.length !== body.value.length) throw failure("subusage/response", "本机 DeepSeek 账号没有有效的充值余额信息");
777
+ const row = wallets.find(row => row.currency === "CNY") ?? wallets[0];
778
+ return { windows: [], extras: [{ kind: "balance", value: `${row.balance} ${row.currency}` }], coverage: "complete" };
779
+ }
728
780
  export function normalizeDeepseekBalance(body) {
729
781
  if (typeof body?.is_available !== "boolean") throw failure("subusage/response", "Invalid DeepSeek balance response");
730
782
  const infos = Array.isArray(body.balance_infos) ? body.balance_infos : [];
@@ -761,14 +813,17 @@ export function normalizeOpenRouter(keyBody, creditBody) {
761
813
  const total = num(credits?.total_credits), spent = num(credits?.total_usage);
762
814
  if (total !== undefined && spent !== undefined) extras.push({ kind: "balance", value: `${Math.round(Math.max(0, total - spent) * 100) / 100} USD` });
763
815
  if (data.is_free_tier === true) extras.push({ kind: "plan", value: "Free tier" });
764
- if (!windows.length && !extras.length) throw failure("subusage/response", "OpenRouter 响应没有可用的限额或余额");
765
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
816
+ if (!windows.length && num(data.usage) !== undefined) extras.push({ kind: "spend", value: `${Math.round(data.usage * 100) / 100} USD` });
817
+ if (!windows.length && !extras.length) throw failure("subusage/response", "OpenRouter 响应没有可用的限额、余额或用量");
818
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length || extras.some(e => e.kind !== "plan") ? "complete" : "partial" };
766
819
  }
767
820
  // ── 余额型聚合商(各家单位与形状都不同,逐个显式换算,不做通用折算)────────────────
768
821
  /** Novita:金额单位是 1/10000 USD(10000 = $1.00)。 */
769
822
  export function normalizeNovitaBalance(body) {
770
- const num = value => typeof value === "number" && Number.isFinite(value) ? value : undefined;
771
- const available = num(body?.availableBalance);
823
+ // 官方金额为十进制字符串;不接受空串、指数或十六进制等非金额写法。
824
+ const raw = body?.availableBalance;
825
+ const available = typeof raw === "number" && Number.isFinite(raw) ? raw
826
+ : typeof raw === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(raw.trim()) && Number.isFinite(Number(raw)) ? Number(raw) : undefined;
772
827
  if (available === undefined) throw failure("subusage/response", "Invalid Novita balance response");
773
828
  return { windows: [], extras: [{ kind: "balance", value: `${Math.round(available / 100) / 100} USD` }], coverage: "complete" };
774
829
  }
@@ -809,7 +864,7 @@ export function normalizeOllamaCloudUsage(body) {
809
864
  windows.push(windowRow(kind, Math.min(100, usage * 100)));
810
865
  }
811
866
  if (!windows.length) throw failure("subusage/response", "Ollama Cloud 响应没有可用的额度窗口");
812
- return { windows: cascadeRateLimited(windows), extras: [], coverage: windows.length >= 3 ? "complete" : "partial" };
867
+ return { windows: cascadeRateLimited(windows), extras: [], coverage: "complete" };
813
868
  }
814
869
  /** Vercel AI Gateway:balance / total_used 是十进制字符串;没有限额也没有重置,只显示金额。 */
815
870
  export function normalizeVercelCredits(body) {
@@ -826,12 +881,14 @@ export function normalizeZenmux(subscriptionBody, balanceBody) {
826
881
  const num = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
827
882
  const windows = [], extras = [];
828
883
  const data = subscriptionBody?.data && typeof subscriptionBody.data === "object" && !Array.isArray(subscriptionBody.data) ? subscriptionBody.data : {};
829
- const quota = data.quota_5_hour && typeof data.quota_5_hour === "object" ? data.quota_5_hour : {};
830
- const usage = num(quota.usage_percentage);
831
- if (usage !== undefined) {
884
+ for (const [field, kind] of [["quota_5_hour", "5h"], ["quota_7_day", "week"]]) {
885
+ const quota = data[field];
886
+ if (quota === undefined || quota === null) continue;
887
+ if (typeof quota !== "object" || Array.isArray(quota) || num(quota.usage_percentage) === undefined) throw failure("subusage/response", "Invalid ZenMux quota window");
832
888
  const detail = num(quota.used_flows) !== undefined && num(quota.max_flows) !== undefined ? { used: quota.used_flows, limit: quota.max_flows, unit: "flows" } : undefined;
833
- windows.push(windowRow("5h", Math.min(100, usage * 100), typeof quota.resets_at === "string" ? quota.resets_at : undefined, undefined, detail));
889
+ windows.push(windowRow(kind, quota.usage_percentage * 100, typeof quota.resets_at === "string" ? quota.resets_at : undefined, undefined, detail));
834
890
  }
891
+ // quota_monthly 只有上限,未报告实时用量;不从短窗拼造月百分比。
835
892
  const tier = data.plan && typeof data.plan.tier === "string" ? data.plan.tier.trim() : "";
836
893
  if (tier) extras.push({ kind: "plan", value: tier });
837
894
  const balance = balanceBody?.data && typeof balanceBody.data === "object" && !Array.isArray(balanceBody.data) ? balanceBody.data : {};
@@ -841,7 +898,7 @@ export function normalizeZenmux(subscriptionBody, balanceBody) {
841
898
  extras.push({ kind: "balance", value: `${Math.round(total * 100) / 100} ${currency}`.trim() });
842
899
  }
843
900
  if (!windows.length && !extras.length) throw failure("subusage/response", "ZenMux 响应没有可用的配额或余额");
844
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
901
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length || total !== undefined ? "complete" : "partial", ...(["suspended", "banned"].includes(data.account_status) ? { availability: "blocked", availabilityReason: "account-restricted" } : {}) };
845
902
  }
846
903
  /**
847
904
  * LiteLLM 的管理端点在 **proxy 根**(不是 /v1):去掉尾部斜杠与常见的 /v1 后缀,
@@ -858,7 +915,7 @@ export function litellmRoot(baseUrl) {
858
915
  }
859
916
  /**
860
917
  * LiteLLM:`/key/info` 给 key 自身的预算与花费,`/user/info`|`/team/info` 给更完整的视图。
861
- * 有预算上限才画进度;**没有上限时不编造百分比**,只如实显示已用金额。
918
+ * 各预算同时生效,分别保留;**没有上限时不编造百分比**,只如实显示已用金额。
862
919
  * 接口不给重置时刻时不显示重置(别把金额渲染成倒计时)。
863
920
  * https://docs.litellm.ai/docs/proxy/virtual_keys
864
921
  */
@@ -868,20 +925,28 @@ export function normalizeLitellm(keyBody, budgetBody) {
868
925
  // 真实响应是 `{ key, info: {...} }`;也接受直接把字段放在顶层。
869
926
  const keyInfo = pick(keyBody?.info) ?? pick(keyBody);
870
927
  if (!keyInfo) throw failure("subusage/response", "Invalid LiteLLM key info response");
871
- const scope = pick(budgetBody) ?? {};
872
- const spend = num(scope.spend) ?? num(keyInfo.spend);
873
- const maxBudget = num(scope.max_budget) ?? num(keyInfo.max_budget);
874
- const resetAt = typeof scope.budget_reset_at === "string" ? scope.budget_reset_at : typeof keyInfo.budget_reset_at === "string" ? keyInfo.budget_reset_at : undefined;
928
+ const scopes = [["key", keyInfo]];
929
+ for (const body of Array.isArray(budgetBody) ? budgetBody : [budgetBody]) {
930
+ if (pick(body?.team_info)) scopes.push(["team", body.team_info]);
931
+ else if (pick(body?.user_info)) scopes.push(["user", body.user_info]);
932
+ else if (pick(body)) scopes.push(["scope", body]);
933
+ }
934
+ const windows = [], extras = [];
875
935
  const round = value => Math.round(value * 100) / 100;
876
- if (spend !== undefined && maxBudget !== undefined && maxBudget > 0) {
877
- return { windows: [windowRow("period", Math.min(100, spend / maxBudget * 100), resetAt, undefined, { used: round(spend), limit: round(maxBudget), unit: "USD" })], extras: [], coverage: "complete" };
936
+ for (const [scope, row] of scopes) {
937
+ const spend = num(row.spend), maxBudget = num(row.max_budget);
938
+ if (spend === undefined || spend < 0) continue;
939
+ if (maxBudget !== undefined && maxBudget > 0) {
940
+ const resetAt = typeof row.budget_reset_at === "string" ? row.budget_reset_at : undefined;
941
+ windows.push({ ...windowRow("period", spend / maxBudget * 100, resetAt, undefined, { used: round(spend), limit: round(maxBudget), unit: "USD" }), ...(scopes.length > 1 ? { budgetScope: scope } : {}) });
942
+ } else extras.push({ kind: scope === "key" ? "spend" : `${scope}-spend`, value: `${round(spend)} USD` });
878
943
  }
879
- // 没有预算上限:没有百分比可算,如实给金额。
880
- if (spend !== undefined) return { windows: [], extras: [{ kind: "spend", value: `${round(spend)} USD` }], coverage: "complete" };
881
- throw failure("subusage/response", "LiteLLM 响应没有可用的预算或花费");
944
+ if (!windows.length && !extras.length) throw failure("subusage/response", "LiteLLM 响应没有可用的预算或花费");
945
+ // 各预算同时约束,不用更宽松的上级覆盖 Key。
946
+ return { windows, extras, coverage: "complete" };
882
947
  }
883
948
  function detectConfigured(ctx) {
884
- try { const ids = new Set(ctx.llm.listProviders().map(p => p.id)); return Object.fromEntries(IDS.map(id => [id, ids.has(id)])); }
949
+ try { const ids = new Set(ctx.llm.listProviders().map(p => p.id)); return Object.fromEntries(IDS.map(id => [id, ids.has(id) || id === "deepseek-account" && ids.has("deepseek-official")])); }
885
950
  catch { return undefined; }
886
951
  }
887
952
  const codec = (type, parse) => ({ mode: "strict", typeSymbol: `dsh-subusage#${type}`, schema: { parse }, create: () => ({ parse }) });
@@ -930,6 +995,8 @@ export class SubUsageService extends TypertRemoteService {
930
995
  // identity 解析(凭据服务/登录文件读取)与 HTTP 同量级的超时,默认 10s(对齐 fetchJson 的
931
996
  // AbortSignal.timeout);经 options 注入以便测试用极短值,不必真等 10 秒。
932
997
  this.identityTimeoutMs = options.identityTimeoutMs ?? 10000;
998
+ this.accountBalanceTimeoutMs = options.accountBalanceTimeoutMs ?? 10000;
999
+ this.deepseekAccount = options.resolveDeepseekAccount ?? (() => ctx.get?.("deepseekAccount"));
933
1000
  this.credentials = options.resolveCredentials ?? (async id => await ctx.get("credentials")?.resolve(credentialRef(PROVIDERS[id].envName)));
934
1001
  this.environment = options.resolveEnvironment ?? (id => launchEnvironmentOf(ctx).get(PROVIDERS[id].envName));
935
1002
  // 火山方舟的 AK/SK 不是路由的推理 Key,按固定变量名各自解析,两端都命中才算配对。
@@ -988,7 +1055,12 @@ export class SubUsageService extends TypertRemoteService {
988
1055
  return cred?.key ? { key: cred.key, source: "auth-file", ...(cred.accountId ? { accountId: cred.accountId } : {}) } : { key: undefined, source: "none" };
989
1056
  }
990
1057
  if (!PROVIDERS[id].managedByPlugin && s.keyModes[id] === "manual") return { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
991
- if (id === "commandcode" && account && account !== "default") {
1058
+ if (id === "deepseek-account") {
1059
+ const service = this.deepseekAccount();
1060
+ const ready = typeof service?.getState === "function" && typeof service?.getBalance === "function" && (await service.getState())?.status === "credential-stored";
1061
+ return { key: undefined, source: ready ? "account-service" : "none" };
1062
+ }
1063
+ if (id === "commandcode" && account && account !== "default") {
992
1064
  // 额外账户:id 即凭据引用名(apiKeyEnv)。引用名不合法(不可能来自提供方
993
1065
  // 插件的 slots,但 client 输入不可信)按未配置处理,不让 credentialRef 抛错。
994
1066
  if (!isCredentialRefName(account)) return { key: undefined, source: "none" };
@@ -1055,7 +1127,7 @@ export class SubUsageService extends TypertRemoteService {
1055
1127
  const call = async (action, payload) => {
1056
1128
  const body = payload === undefined ? "{}" : JSON.stringify(payload);
1057
1129
  const signed = volcSignature({ accessKeyId: identity.key, secretAccessKey: identity.secret, action, host: spec.volcHost, region: spec.volcRegion, body });
1058
- const response = await get(signed.url, signed.headers, { method: "POST", body });
1130
+ const response = await get(signed.url, signed.headers, { method: "POST", body, notSubscribed404: true });
1059
1131
  const apiError = volcError(response);
1060
1132
  // 「这个账号没有这份套餐」不是故障,是答案:实测没有 BytePlus Coding Plan 的账号会返回
1061
1133
  // HTTP 404 `NotFound.BillingType`(coding plan config is not exist),`OperationDenied.NotSubscribed`
@@ -1128,6 +1200,13 @@ export class SubUsageService extends TypertRemoteService {
1128
1200
  if (id === "minimax" || id === "minimax-cn") return normalizeMinimax(await get(PROVIDERS[id].usageUrl, headers));
1129
1201
  if (id === "siliconflow") return normalizeSiliconFlow(await get("https://api.siliconflow.cn/v1/user/info", headers));
1130
1202
  if (id === "deepseek") return normalizeDeepseekBalance(await get("https://api.deepseek.com/user/balance", headers));
1203
+ if (id === "deepseek-account") {
1204
+ const service = this.deepseekAccount();
1205
+ if (typeof service?.getBalance !== "function") throw failure("subusage/response", "本机 DeepSeek 账号余额服务不可用", true);
1206
+ const body = await withTimeout(service.getBalance({ version: VERSION, locale: "zh-CN", timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 }), this.accountBalanceTimeoutMs, () => failure("subusage/network", "本机 DeepSeek 账号余额读取超时", true));
1207
+ if (body === null) throw failure("subusage/auth", "本机 DeepSeek 账号未登录或登录已失效,请在 DSH 账号与余额页面确认");
1208
+ return normalizeDeepseekAccountBalance(body);
1209
+ }
1131
1210
  if (id === "openrouter") {
1132
1211
  const keyBody = await get("https://openrouter.ai/api/v1/key", headers);
1133
1212
  // 余额端点对普通推理 key 会 403;这一次失败不影响 key 限额与用量的展示。
@@ -1148,7 +1227,12 @@ export class SubUsageService extends TypertRemoteService {
1148
1227
  get("https://zenmux.ai/api/v1/management/subscription/detail", headers).catch(() => undefined),
1149
1228
  get("https://zenmux.ai/api/v1/management/payg/balance", headers).catch(() => undefined)
1150
1229
  ]);
1151
- return normalizeZenmux(subscription, balance);
1230
+ const data = normalizeZenmux(subscription, balance);
1231
+ if (subscription === undefined) {
1232
+ data.coverage = "partial";
1233
+ data.extras.push({ kind: "note", value: "余额已读取;套餐状态暂不可确认" });
1234
+ }
1235
+ return data;
1152
1236
  }
1153
1237
  if (id === "litellm") {
1154
1238
  // 地址是用户自己的 proxy;缺地址算「凭据不完整」,与缺 Key 一样给出可行动的指引。
@@ -1157,10 +1241,14 @@ export class SubUsageService extends TypertRemoteService {
1157
1241
  const keyInfo = await get(`${root}/key/info`, headers);
1158
1242
  const own = keyInfo?.info && typeof keyInfo.info === "object" && !Array.isArray(keyInfo.info) ? keyInfo.info : {};
1159
1243
  // 更完整的预算视图在 user/team info;拿不到就退回 key 自身的 info。
1160
- const scoped = typeof own.team_id === "string" && own.team_id.trim() ? `/team/info?team_id=${encodeURIComponent(own.team_id.trim())}`
1161
- : typeof own.user_id === "string" && own.user_id.trim() ? `/user/info?user_id=${encodeURIComponent(own.user_id.trim())}` : "";
1162
- const budget = scoped ? await get(`${root}${scoped}`, headers).catch(() => undefined) : undefined;
1163
- return normalizeLitellm(keyInfo, budget);
1244
+ const scopes = [["team", own.team_id], ["user", own.user_id]].filter(([, value]) => typeof value === "string" && value.trim());
1245
+ const budgets = await Promise.all(scopes.map(async ([scope, value]) => {
1246
+ const body = await get(`${root}/${scope}/info?${scope}_id=${encodeURIComponent(value.trim())}`, headers).catch(() => undefined);
1247
+ return body ? body[`${scope}_info`] ? body : { [`${scope}_info`]: body } : undefined;
1248
+ }));
1249
+ const data = normalizeLitellm(keyInfo, budgets);
1250
+ if (budgets.some(body => body === undefined)) data.coverage = "partial";
1251
+ return data;
1164
1252
  }
1165
1253
  if (id === "commandcode") {
1166
1254
  // 与提供方插件同一凭据来源,但直连计费接口并行拉取:其 report() 会对每个账户
@@ -1170,7 +1258,9 @@ export class SubUsageService extends TypertRemoteService {
1170
1258
  get(`${COMMAND_CODE_API_BASE}/alpha/billing/credits`, billingHeaders),
1171
1259
  get(`${COMMAND_CODE_API_BASE}/alpha/billing/subscriptions`, billingHeaders).catch(() => undefined)
1172
1260
  ]);
1173
- return normalizeCommandCode(credits, subscription);
1261
+ const data = normalizeCommandCode(credits, subscription);
1262
+ if (subscription === undefined) data.coverage = "partial";
1263
+ return data;
1174
1264
  }
1175
1265
  if (id === "xai-oauth") {
1176
1266
  // 登录文件里的 access token 短命(实测 6 小时量级),而 grok-kit 的自动刷新是懒触发:
@@ -1200,12 +1290,12 @@ export class SubUsageService extends TypertRemoteService {
1200
1290
  }
1201
1291
  if (id === "openai-codex") {
1202
1292
  // ChatGPT 后端私有接口:Bearer 是 Codex CLI 的 OAuth access token,不是 OPENAI_API_KEY。
1203
- return normalizeCodexUsage(await get(`${CODEX_API_BASE}/wham/usage`, headers));
1293
+ return normalizeCodexUsage(await get(`${CODEX_API_BASE}/wham/usage`, { ...headers, ...(identity.accountId ? { "ChatGPT-Account-Id": identity.accountId } : {}) }));
1204
1294
  }
1205
1295
  const base = "https://platform.xiaomimimo.com/api/v1";
1206
1296
  const h = { cookie: identity.key, origin: "https://platform.xiaomimimo.com", referer: "https://platform.xiaomimimo.com/#/console/balance", "x-timezone": "UTC+08:00" };
1207
1297
  let usageError;
1208
- const [bal, detail, usage] = await Promise.all([get(`${base}/balance`, h), get(`${base}/tokenPlan/detail`, h).catch(e => { if (e.code === "subusage/auth") throw e; return undefined; }), get(`${base}/tokenPlan/usage`, h).catch(e => { if (e.code === "subusage/auth") throw e; usageError = e; return undefined; })]);
1298
+ const [bal, detail, usage] = await Promise.all([get(`${base}/balance`, h).catch(e => { if (e.code === "subusage/auth") throw e; return undefined; }), get(`${base}/tokenPlan/detail`, h).catch(e => { if (e.code === "subusage/auth") throw e; return undefined; }), get(`${base}/tokenPlan/usage`, h).catch(e => { if (e.code === "subusage/auth") throw e; usageError = e; return undefined; })]);
1209
1299
  // 普通刷新与登录验证都经过这里:服务端字段不可信,缓存前去除 Cookie 回显。
1210
1300
  const data = redactMimo(normalizeMimo(bal, detail, usage), identity.key);
1211
1301
  if (usageError) data.usageError = usageError;
@@ -1276,12 +1366,16 @@ export class SubUsageService extends TypertRemoteService {
1276
1366
  const entry = entries[index];
1277
1367
  const windows = Array.isArray(entry?.windows) ? entry.windows : [];
1278
1368
  const ok = entry?.state === "ok";
1369
+ const paid = entry?.availability === "paygo";
1370
+ const limited = entry?.availability === "blocked" || windows.some(w => w.status === "rate-limited");
1371
+ const known = paid || entry?.coverage !== "partial" && windows.length > 0;
1279
1372
  return {
1280
1373
  id: account.id, label: account.label, active: account.active === true,
1281
1374
  state: entry?.state ?? "error", errorCode: entry?.errorCode, windows,
1282
1375
  // usable:读到了且没有任何限额窗口;blocked:读到了但有窗口限额(容量降级,仍可能成功)。
1283
- usable: ok && !windows.some(w => w.status === "rate-limited"),
1284
- blocked: ok && windows.some(w => w.status === "rate-limited")
1376
+ usable: ok && (paid || known && !limited),
1377
+ ...(ok && !known && !limited ? { availability: "unknown" } : entry?.availability ? { availability: entry.availability } : {}),
1378
+ blocked: ok && !paid && limited
1285
1379
  };
1286
1380
  });
1287
1381
  // 「有效容量」= usable + blocked(真的有凭据且读到了额度的账户);no-key/error/disabled 不算。
@@ -1289,7 +1383,7 @@ export class SubUsageService extends TypertRemoteService {
1289
1383
  const available = accounts.filter(a => a.usable).length;
1290
1384
  const blockedCount = accounts.filter(a => a.blocked).length;
1291
1385
  const unusableCount = accounts.length - available - blockedCount;
1292
- const exhausted = capable.length > 0 && capable.every(a => a.blocked);
1386
+ const exhausted = capable.length > 0 && capable.every(a => a.blocked) && !accounts.some(a => a.state === "error" || a.availability === "unknown");
1293
1387
  return { mode: "auto", total: accounts.length, available, blockedCount, unusableCount, exhausted, accounts };
1294
1388
  }
1295
1389
  /** 按 id 失效一家 provider 的**全部账户**缓存:凭据/开关变化不能只影响默认账户那一条。 */
@@ -1326,17 +1420,18 @@ export class SubUsageService extends TypertRemoteService {
1326
1420
  if (startedGeneration !== (this.generations.get(cacheKey) ?? 0)) return this.provider(id, this.load(), force, account);
1327
1421
  if (!providerEnabled(settings, id)) {
1328
1422
  this.cache.delete(cacheKey); this.inflight.delete(cacheKey);
1329
- return { ...this.base(id, identity.source, settings, account), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
1423
+ return { ...this.base(id, identity.source, settings, account), state: "disabled", apiDetected: !!identity.key || identity.source === "account-service", windows: [], extras: [] };
1330
1424
  }
1331
1425
  // fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
1332
1426
  // 空串与 "default" 同为默认账户,归一后共享缓存。
1333
- 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]);
1427
+ 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]);
1334
1428
  if (this.fingerprints.has(cacheKey) && this.fingerprints.get(cacheKey) !== fingerprint) this.generations.set(cacheKey, (this.generations.get(cacheKey) ?? 0) + 1);
1335
1429
  this.fingerprints.set(cacheKey, fingerprint);
1336
1430
  const generation = this.generations.get(cacheKey) ?? 0;
1337
1431
  const cached = this.cache.get(cacheKey);
1338
1432
  if (cached && cached.fingerprint !== fingerprint) this.cache.delete(cacheKey);
1339
- const previous = cached?.fingerprint === fingerprint ? cached : undefined;
1433
+ // 账号服务不提供会话指纹,也不读取其 token:每次查询复用其实时余额链,不跨账号复用本插件缓存。
1434
+ const previous = identity.source !== "account-service" && cached?.fingerprint === fingerprint ? cached : undefined;
1340
1435
  const active = this.inflight.get(cacheKey);
1341
1436
  if (active?.fingerprint === fingerprint && active.generation === generation) return active.promise;
1342
1437
  const now = this.now();
@@ -1344,7 +1439,7 @@ export class SubUsageService extends TypertRemoteService {
1344
1439
  if (previous && (now < previous.retryAt || !resetDue && !force && now - previous.at < TTL)) return previous.entry;
1345
1440
  const promise = (async () => {
1346
1441
  const base = this.base(id, identity.source, settings, account); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
1347
- 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" };
1442
+ if (!identity.key && identity.source !== "account-service") 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 === "deepseek-account" ? "未检测到本机 DeepSeek 账号登录:请在 DSH 设置 → 账号与余额登录;此入口不使用 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" };
1348
1443
  else try {
1349
1444
  const data = await this.fetchProvider(id, identity, settings);
1350
1445
  const usageError = data.usageError; delete data.usageError;
@@ -1415,6 +1510,8 @@ export class SubUsageService extends TypertRemoteService {
1415
1510
  // 只输出真正有值的字段:视图里带 `undefined` 的键会被调用方的 schema 校验判成型别错误
1416
1511
  // (DSH 就会),而且「字段缺席」本来就该表示「这家没报告」,不是「报告了空」。
1417
1512
  ...(typeof entry.coverage === "string" ? { coverage: entry.coverage } : {}),
1513
+ ...(typeof entry.availability === "string" ? { availability: entry.availability } : {}),
1514
+ ...(typeof entry.availabilityReason === "string" ? { availabilityReason: entry.availabilityReason } : {}),
1418
1515
  ...(typeof entry.freshness === "string" ? { freshness: entry.freshness } : {}),
1419
1516
  ...(typeof entry.lastSuccessAt === "string" ? { lastSuccessAt: entry.lastSuccessAt } : {}),
1420
1517
  // 对外视图里不能出现内部凭据引用名:no-key 文案嵌了它,这里换成账户标签
@@ -1535,7 +1632,8 @@ function formatQuotaText(value) {
1535
1632
  if (entry.state !== "ok") return `${head}:${entry.error || "读取失败"}`;
1536
1633
  const parts = [];
1537
1634
  for (const window of entry.windows || []) {
1538
- parts.push(`${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
1635
+ const scope = window.budgetScope ? `${window.budgetScope} 预算` : ({ text: "文本/向量", "daily-capabilities": "日额度适用能力", all: "全部能力共享" }[window.detail?.scope] || window.groupLabel);
1636
+ parts.push(`${scope ? scope + " · " : ""}${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
1539
1637
  }
1540
1638
  // 账户名已经在标题行里了;再打一遍只会把额度挤出视野。
1541
1639
  // **只对多账户行跳过**(顶层 account 字段 = Command Code 的账户标注):火山方舟的席位标注
@@ -1543,7 +1641,9 @@ function formatQuotaText(value) {
1543
1641
  // 信息,跟着一起丢就等于把额度说成不属于任何席位。
1544
1642
  const skipAccountExtra = typeof entry.account === "string" && entry.account !== "";
1545
1643
  for (const extra of entry.extras || []) { if (skipAccountExtra && extra.kind === "account") continue; parts.push(`${extra.kind}: ${extra.value}`); }
1546
- if (entry.freshness === "stale") parts.push("缓存数据");
1644
+ const advice = { included: "接口仍允许订阅续用(基础额度可能已用尽)", paygo: "包含额度用尽时可按量续用,实际余额与服务端为准", blocked: "账户/预算受限或包含额度已用尽", unknown: "续用条件待确认,不保证可调用" };
1645
+ if (advice[entry.availability]) parts.push(advice[entry.availability]);
1646
+ if (entry.freshness === "stale") parts.push("缓存数据");
1547
1647
  return `${head}:${parts.length ? parts.join(";") : "暂无额度数据"}`;
1548
1648
  });
1549
1649
  // 一条额度都没读到时,末尾补一句汇总原因:模型据此才能告诉用户下一步做什么。
@@ -1578,7 +1678,8 @@ function hasQuotaData(entry) {
1578
1678
  if (!entry) return false;
1579
1679
  if (entry.state === "error") return true;
1580
1680
  if (entry.state !== "ok") return false;
1581
- if (Array.isArray(entry.windows) && entry.windows.length) return true;
1681
+ if (typeof entry.availability === "string") return true;
1682
+ if (Array.isArray(entry.windows) && entry.windows.length) return true;
1582
1683
  const metadata = typeof entry.account === "string" && entry.account !== "" ? ["account", "plan"] : ["plan"];
1583
1684
  return (Array.isArray(entry.extras) ? entry.extras : []).some(item => !metadata.includes(item?.kind));
1584
1685
  }
@@ -1642,7 +1743,9 @@ function registerQuotaTool(ctx) {
1642
1743
  value: { type: "string", required: true }
1643
1744
  } }
1644
1745
  },
1645
- coverage: { type: "string", description: "complete | partial" },
1746
+ coverage: { type: "string", description: "complete | partial — completeness of applicable reported quota facts, not a count of windows." },
1747
+ 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." },
1748
+ availabilityReason: { type: "string", description: "Machine-readable basis for availability; no credentials or raw upstream objects." },
1646
1749
  freshness: { type: "string", description: "fresh | stale" },
1647
1750
  lastSuccessAt: { type: "string" },
1648
1751
  account: { type: "string", description: "Account label this entry reports (Command Code only, when the provider has several accounts)." },
package/lib/volcengine.js CHANGED
@@ -157,7 +157,7 @@ export function parseVolcAgentPlan(body) {
157
157
  const quota = amount(row.Quota), used = amount(row.Used);
158
158
  if (quota === undefined || used === undefined) throw new Error("Volcengine AFP window is invalid");
159
159
  if (quota <= 0) continue;
160
- windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(row.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP" } });
160
+ windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(row.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP", scope: kind === "month" ? "all" : kind === "day" ? "daily-capabilities" : "text" } });
161
161
  }
162
162
  const plan = typeof result.PlanType === "string" && result.PlanType.trim() ? result.PlanType.trim() : undefined;
163
163
  return { subscribed: windows.length > 0 || reported, windows, plan };
@@ -188,7 +188,7 @@ export function parseVolcSeatAfp(body) {
188
188
  const quota = amount(window.Quota), used = amount(window.Used);
189
189
  if (quota === undefined || used === undefined) throw new Error("Volcengine seat AFP window is invalid");
190
190
  if (quota <= 0) continue;
191
- windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(window.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP" } });
191
+ windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(window.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP", scope: kind === "month" ? "all" : kind === "day" ? "daily-capabilities" : "text" } });
192
192
  }
193
193
  const plan = typeof row.PlanType === "string" && row.PlanType.trim() ? row.PlanType.trim() : undefined;
194
194
  const seatId = typeof row.SeatID === "string" && row.SeatID.trim() ? row.SeatID.trim() : undefined;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "displayName": "订阅用量显示",
5
- "description": "显示 DeepSeek 官方余额、火山方舟(arkcli 官方 CLI 路由:Agent / Coding Plan 个人版与企业版席位,设置页合并为一组)、Z.ai(中国与国际)、Kimi、MiMo、OpenCode Go、Command Code、SuperGrok、Codex(ChatGPT 订阅,默认关闭)、MiniMax(中国)、Synthetic、NanoGPT、SiliconFlow、OpenRouter、Novita、Hyperbolic、DeepInfra、Chutes、Ollama Cloud、Vercel AI Gateway、ZenMux、LiteLLM 的订阅余量与余额;共享刷新与凭据管理,默认关闭 17 家。",
5
+ "description": "显示独立 DeepSeek API Key 与 DSH 本机账号余额(账号入口默认开启)、火山方舟(arkcli 官方 CLI 路由:Agent / Coding Plan 个人版与企业版席位,设置页合并为一组)、Z.ai(中国与国际)、Kimi、MiMo、OpenCode Go、Command Code、SuperGrok、Codex(ChatGPT 订阅,默认关闭)、MiniMax(中国)、Synthetic、NanoGPT、SiliconFlow、OpenRouter、Novita、Hyperbolic、DeepInfra、Chutes、Ollama Cloud、Vercel AI Gateway、ZenMux、LiteLLM 的订阅余量与余额;共享刷新与凭据管理,默认关闭 17 家。",
6
6
  "keywords": [
7
7
  "dsh",
8
8
  "dsh-plugin",