dsh-client-ui-cpa-quota 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -180,6 +180,7 @@ Point the plugin's manual instance input at the mock to see a full five-account
180
180
  - **Stale numbers?** Numbers refresh on the configured interval; a failed refresh keeps the last good snapshot and says so on the card.
181
181
  - **Where are my keys stored?** In this browser's `localStorage` only. Quota requests go straight from the browser to each instance; the DSH host never sees your keys.
182
182
  - **Switching browsers?** Use **Export config / Import config** on the card to move instances and keys between browsers. The exported JSON is plaintext — store it carefully.
183
+ - **Tooltip shows every account?** The tooltip filters accounts by the selected model's family — from the model name first, then the DSH provider id. If a custom route name reveals neither, the plugin cannot know which account CPA will route the request to, so it lists every usable account and says so in the tooltip.
183
184
 
184
185
  ## Community
185
186
 
package/README.zh.md CHANGED
@@ -180,6 +180,7 @@ node tests/mock-cpa.mjs # 可选:在 http://127.0.0.1:8317 起一个假 CP
180
180
  - **数字不新鲜?** 按设定间隔自动刷新;刷新失败会保留上次成功数据并在卡片上说明。
181
181
  - **密钥存在哪?** 只存当前浏览器的 `localStorage`。额度请求由浏览器直达各实例,DSH 宿主完全接触不到你的密钥。
182
182
  - **换浏览器?** 用卡片里的「导出配置 / 导入配置」在浏览器之间迁移实例与密钥。导出的 JSON 是明文,注意保管。
183
+ - **悬浮卡片显示了全部账号?** 卡片按当前模型的「家族」过滤账号——先看模型名,识别不出再看 DSH 提供方 id。自定义路由名两者都看不出时,插件无法判断 CPA 会把请求路由给哪个账号,于是列出全部可用账号并在卡片里注明。
183
184
 
184
185
  ## 社区
185
186
 
package/lib/client.js CHANGED
@@ -39,6 +39,9 @@ window.__ModuleLoader__.load({
39
39
  const EV_UPDATED = "dsh-cpa-quota:updated";
40
40
  const EV_REFRESH = "dsh-cpa-quota:refresh-requested";
41
41
  const PROBE_TTL = 60 * 60 * 1000;
42
+ /** Unreachable/unknown verdicts retry on this shorter clock so one
43
+ * transient network failure never hides a ring for an hour. */
44
+ const PROBE_RETRY_TTL = 2 * 60 * 1000;
42
45
  const W5H = 5 * 60 * 60;
43
46
  const W7D = 7 * 24 * 60 * 60;
44
47
  const W28D = 28 * 24 * 60 * 60;
@@ -170,6 +173,7 @@ window.__ModuleLoader__.load({
170
173
  if (provider === "anthropic" || provider === "claude-code") return "claude";
171
174
  if (provider === "grok" || provider === "x-ai" || provider === "x.ai") return "xai";
172
175
  if (provider === "gemini-cli" || provider === "gemini") return provider;
176
+ if (provider === "zai" || provider === "zhipu" || provider === "chatglm") return "glm";
173
177
  return provider;
174
178
  };
175
179
 
@@ -457,13 +461,29 @@ window.__ModuleLoader__.load({
457
461
  }
458
462
  }
459
463
 
464
+ /**
465
+ * The ChatGPT account id for a Codex auth file. CPA stores it in more
466
+ * than one place depending on version: `tokens.account_id` is the
467
+ * canonical field in current builds, older ones only carry the JWT
468
+ * `id_token`, and metadata/attributes copies appear on the management
469
+ * API projection.
470
+ */
460
471
  function codexAccountId(entry) {
461
- const candidates = [entry.id_token, getPath(entry, ["metadata", "id_token"]), getPath(entry, ["attributes", "id_token"])];
472
+ const direct = firstText(
473
+ entry.chatgpt_account_id,
474
+ entry.account_id,
475
+ getPath(entry, ["tokens", "account_id"]),
476
+ getPath(entry, ["tokens", "chatgpt_account_id"]),
477
+ getPath(entry, ["metadata", "account_id"]),
478
+ getPath(entry, ["attributes", "account_id"]),
479
+ );
480
+ if (direct !== "") return direct;
481
+ const candidates = [entry.id_token, getPath(entry, ["tokens", "id_token"]), getPath(entry, ["metadata", "id_token"]), getPath(entry, ["attributes", "id_token"])];
462
482
  for (const candidate of candidates) {
463
483
  const claims = jwtClaims(candidate);
464
484
  if (claims === null) continue;
465
- const direct = text(claims.chatgpt_account_id);
466
- if (direct !== "") return direct;
485
+ const directClaim = text(claims.chatgpt_account_id);
486
+ if (directClaim !== "") return directClaim;
467
487
  const auth = claims["https://api.openai.com/auth"];
468
488
  if (auth !== null && typeof auth === "object") {
469
489
  const nestedId = text(auth.chatgpt_account_id);
@@ -474,7 +494,7 @@ window.__ModuleLoader__.load({
474
494
  }
475
495
 
476
496
  function codexPlanFromEntry(entry) {
477
- const candidates = [entry.id_token, getPath(entry, ["metadata", "id_token"]), getPath(entry, ["attributes", "id_token"])]
497
+ const candidates = [entry.id_token, getPath(entry, ["tokens", "id_token"]), getPath(entry, ["metadata", "id_token"]), getPath(entry, ["attributes", "id_token"])]
478
498
  .map(jwtClaims)
479
499
  .filter((claims) => claims !== null);
480
500
  return extractPlan(...candidates);
@@ -620,8 +640,12 @@ window.__ModuleLoader__.load({
620
640
  * @returns {'cpa'|'no'|'unknown'|'unreachable'}
621
641
  */
622
642
  async function probeCpa(base) {
643
+ // A hung probe never yields a verdict and the ring stays missing, so
644
+ // bound the request like every other network call.
645
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
646
+ const timer = controller === null ? 0 : window.setTimeout(() => controller.abort(), 10_000);
623
647
  try {
624
- const response = await fetch(`${base}/v0/management/usage-statistics-enabled`, { method: "GET" });
648
+ const response = await fetch(`${base}/v0/management/usage-statistics-enabled`, { method: "GET", ...(controller === null ? {} : { signal: controller.signal }) });
625
649
  const body = await response.text();
626
650
  if (response.status === 401) return /management|unauthor/i.test(body) ? "cpa" : "unknown";
627
651
  if (response.status === 404) return "no";
@@ -631,6 +655,8 @@ window.__ModuleLoader__.load({
631
655
  return "unknown";
632
656
  } catch {
633
657
  return "unreachable";
658
+ } finally {
659
+ if (timer !== 0) window.clearTimeout(timer);
634
660
  }
635
661
  }
636
662
 
@@ -639,9 +665,16 @@ window.__ModuleLoader__.load({
639
665
  const targets = [];
640
666
  for (const [key, info] of providerBases) {
641
667
  const hit = cache[key];
642
- if (!force && hit !== undefined && Date.now() - hit.at < PROBE_TTL) {
643
- discoveredByKey.set(key, { ...hit, baseURL: info.baseURL });
644
- continue;
668
+ if (!force && hit !== undefined) {
669
+ // Confirmed verdicts (cpa / a plain 404) keep the long cache; a
670
+ // transient network failure or ambiguous answer must retry
671
+ // quickly — caching it for an hour left rings missing after a
672
+ // single hiccup.
673
+ const ttl = hit.cpa === true || hit.verdict === "no" ? PROBE_TTL : PROBE_RETRY_TTL;
674
+ if (Date.now() - hit.at < ttl) {
675
+ discoveredByKey.set(key, { ...hit, baseURL: info.baseURL });
676
+ continue;
677
+ }
645
678
  }
646
679
  targets.push([key, info]);
647
680
  }
@@ -1374,21 +1407,21 @@ window.__ModuleLoader__.load({
1374
1407
  }
1375
1408
  try {
1376
1409
  if (provider === "codex") {
1410
+ // No id found is not fatal: some CPA setups bind the account
1411
+ // server-side, so try without the Chatgpt-Account-Id header
1412
+ // and let an upstream rejection surface as the account error.
1377
1413
  const accountID = codexAccountId(entry);
1378
- if (accountID === "") {
1379
- account.error = ZH ? "缺少 chatgpt_account_id" : "missing chatgpt_account_id";
1380
- return account;
1381
- }
1414
+ const usageHeaders = {
1415
+ Authorization: "Bearer $TOKEN$",
1416
+ "Content-Type": "application/json",
1417
+ "User-Agent": "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal",
1418
+ };
1419
+ if (accountID !== "") usageHeaders["Chatgpt-Account-Id"] = accountID;
1382
1420
  const body = unwrapApiCall(await apiCall(instance, {
1383
1421
  auth_index: authIndex,
1384
1422
  method: "GET",
1385
1423
  url: UPSTREAM.codexUsage,
1386
- header: {
1387
- Authorization: "Bearer $TOKEN$",
1388
- "Content-Type": "application/json",
1389
- "User-Agent": "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal",
1390
- "Chatgpt-Account-Id": accountID,
1391
- },
1424
+ header: usageHeaders,
1392
1425
  }));
1393
1426
  account.windows = parseCodexWindows(body);
1394
1427
  account.plan = extractPlan(body) || account.plan;
@@ -1613,12 +1646,35 @@ window.__ModuleLoader__.load({
1613
1646
  if (lower === "") return "";
1614
1647
  if (/(?:grok|xai)/.test(lower)) return "xai";
1615
1648
  if (/(?:kimi|moonshot)/.test(lower)) return "kimi";
1649
+ if (/(?:qwen|qwq)/.test(lower)) return "qwen";
1650
+ if (/(?:iflow)/.test(lower)) return "iflow";
1651
+ if (/(?:glm|chatglm|zhipu)/.test(lower)) return "glm";
1652
+ if (/(?:deepseek)/.test(lower)) return "deepseek";
1616
1653
  if (/(?:gemini|gemma)/.test(lower)) return "gemini";
1617
1654
  if (/(?:claude|sonnet|opus|haiku|fable)/.test(lower)) return "claude";
1618
1655
  if (/(?:gpt|openai|codex)/.test(lower) || /(?:^|[-_:/.])o[1-4](?:$|[-_:/.])/.test(lower)) return "gpt";
1619
1656
  return "";
1620
1657
  }
1621
1658
 
1659
+ /**
1660
+ * Second signal for the account filter: the DSH provider id that serves
1661
+ * the model. Custom route names ("team-claude", "fast-tier") reveal
1662
+ * nothing on their own, but the provider usually does.
1663
+ */
1664
+ function providerFamilyFromId(providerId) {
1665
+ const p = text(providerId).toLowerCase();
1666
+ if (/(?:anthropic|claude)/.test(p)) return "claude";
1667
+ if (/(?:gemini)/.test(p)) return "gemini";
1668
+ if (/(?:codex|openai|gpt)/.test(p)) return "gpt";
1669
+ if (/(?:kimi|moonshot)/.test(p)) return "kimi";
1670
+ if (/(?:grok|xai)/.test(p)) return "xai";
1671
+ if (/(?:qwen)/.test(p)) return "qwen";
1672
+ if (/(?:iflow)/.test(p)) return "iflow";
1673
+ if (/(?:deepseek)/.test(p)) return "deepseek";
1674
+ if (/(?:glm|zai|zhipu)/.test(p)) return "glm";
1675
+ return "";
1676
+ }
1677
+
1622
1678
  function modelSpecificWindows(account, modelId) {
1623
1679
  const lower = text(modelId).toLowerCase();
1624
1680
  if (lower === "") return [];
@@ -1648,25 +1704,37 @@ window.__ModuleLoader__.load({
1648
1704
  case "openai": return family === "gpt";
1649
1705
  case "kimi": return family === "kimi";
1650
1706
  case "xai": return family === "xai";
1707
+ case "qwen": return family === "qwen";
1708
+ case "iflow": return family === "iflow";
1709
+ case "glm": return family === "glm";
1710
+ case "deepseek": return family === "deepseek";
1651
1711
  default: return false;
1652
1712
  }
1653
1713
  }
1654
1714
 
1655
- /** Accounts whose provider/model rows match the selected model. */
1656
- function accountsForModel(accounts, modelId) {
1715
+ /**
1716
+ * Accounts whose provider/model rows match the selected model. Two
1717
+ * signals: the model name's family, then the DSH provider id that serves
1718
+ * it (custom route names reveal nothing on their own). When neither
1719
+ * yields a family, CPA's routing decision is unknowable from the client,
1720
+ * so every usable account is listed and the tooltip says why.
1721
+ */
1722
+ function accountsForModel(accounts, modelId, providerHint = "") {
1657
1723
  if (!Array.isArray(accounts)) return [];
1658
- const family = modelFamily(modelId);
1659
- if (text(modelId) === "" || family === "") return accounts;
1724
+ if (text(modelId) === "") return accounts;
1725
+ let family = modelFamily(modelId);
1726
+ if (family === "") family = providerFamilyFromId(providerHint);
1727
+ if (family === "") return accounts.filter((account) => !account.disabled && !account.unavailable);
1660
1728
  const specific = accounts.filter((account) => modelSpecificWindows(account, modelId).length > 0);
1661
1729
  if (specific.length > 0) return specific;
1662
- return accounts.filter((account) => {
1663
- if (!providerMatchesFamily(account.provider, family)) return false;
1664
- const rows = Array.isArray(account.windows) ? account.windows : [];
1665
- // An account with model/prefix-tagged rows for another family must not
1666
- // fall through as a generic provider account for this model.
1667
- const hasTaggedRows = rows.some((row) => row.modelId !== null || Array.isArray(row.modelIds) || Array.isArray(row.prefixes));
1668
- return !hasTaggedRows;
1669
- });
1730
+ return accounts.filter((account) => {
1731
+ if (!providerMatchesFamily(account.provider, family)) return false;
1732
+ const rows = Array.isArray(account.windows) ? account.windows : [];
1733
+ // An account with model/prefix-tagged rows for another family must not
1734
+ // fall through as a generic provider account for this model.
1735
+ const hasTaggedRows = rows.some((row) => row.modelId !== null || Array.isArray(row.modelIds) || Array.isArray(row.prefixes));
1736
+ return !hasTaggedRows;
1737
+ });
1670
1738
  }
1671
1739
 
1672
1740
  function modelRowsForAccount(account, modelId) {
@@ -1728,12 +1796,12 @@ window.__ModuleLoader__.load({
1728
1796
  });
1729
1797
  }
1730
1798
 
1731
- /** Ring level for one instance given the current model. */
1732
- function levelFor(state, modelId) {
1733
- if (state === undefined) return { level: "pending", remaining: null };
1734
- if (state.state === "nokey") return { level: "pending", remaining: null };
1735
- if (state.state === "error") return { level: "error", remaining: null };
1736
- const matchingAccounts = accountsForModel(state.accounts, modelId);
1799
+ /** Ring level for one instance given the current model. */
1800
+ function levelFor(state, modelId, providerHint = "") {
1801
+ if (state === undefined) return { level: "pending", remaining: null };
1802
+ if (state.state === "nokey") return { level: "pending", remaining: null };
1803
+ if (state.state === "error") return { level: "error", remaining: null };
1804
+ const matchingAccounts = accountsForModel(state.accounts, modelId, providerHint);
1737
1805
  const rows = [];
1738
1806
  for (const account of matchingAccounts) {
1739
1807
  if (account.disabled || account.unavailable) continue;
@@ -2464,6 +2532,7 @@ body[data-ds-dark-theme] .cpa-q-select{color-scheme:dark}
2464
2532
  function showTooltip(dot) {
2465
2533
  const key = dot.getAttribute("data-cpa-base") ?? "";
2466
2534
  const modelId = dot.getAttribute("data-cpa-model") ?? "";
2535
+ const providerHint = dot.getAttribute("data-cpa-provider") ?? "";
2467
2536
  const instance = instanceByKey.get(key);
2468
2537
  const state = quotaByKey.get(key);
2469
2538
  if (instance === undefined) return;
@@ -2515,12 +2584,17 @@ body[data-ds-dark-theme] .cpa-q-select{color-scheme:dark}
2515
2584
  } else if (state.accounts.length === 0) {
2516
2585
  tip.appendChild(el("div", "cpa-q-row", ZH ? "该实例没有账号(auth-files 为空)" : "no auth files on this instance"));
2517
2586
  } else {
2518
- const matchingAccounts = accountsForModel(state.accounts, modelId);
2519
- if (matchingAccounts.length === 0) {
2520
- tip.appendChild(el("div", "cpa-q-row", ZH ? "该模型没有匹配的厂商额度" : "no matching provider quota for this model"));
2521
- } else {
2522
- for (const account of matchingAccounts) tip.appendChild(accountSection(account, modelId));
2523
- }
2587
+ const matchingAccounts = accountsForModel(state.accounts, modelId, providerHint);
2588
+ if (matchingAccounts.length === 0) {
2589
+ tip.appendChild(el("div", "cpa-q-row", ZH ? "该模型没有匹配的厂商额度" : "no matching provider quota for this model"));
2590
+ } else {
2591
+ if (modelFamily(modelId) === "" && providerFamilyFromId(providerHint) === "") {
2592
+ tip.appendChild(el("div", "cpa-q-hint", ZH
2593
+ ? "无法从模型名识别账号归属,以下为该实例全部可用账号"
2594
+ : "model family unknown — listing every usable account on this instance"));
2595
+ }
2596
+ for (const account of matchingAccounts) tip.appendChild(accountSection(account, modelId));
2597
+ }
2524
2598
  if (state.stale && state.lastError) {
2525
2599
  const hint = el("div", "cpa-q-hint");
2526
2600
  hint.textContent = ZH ? "暂时无法刷新,显示上次成功数据" : "refresh temporarily unavailable; showing the last good snapshot";
@@ -2612,7 +2686,7 @@ body[data-ds-dark-theme] .cpa-q-select{color-scheme:dark}
2612
2686
  function applyDotState(ring) {
2613
2687
  const key = ring.getAttribute("data-cpa-base") ?? "";
2614
2688
  const modelId = ring.getAttribute("data-cpa-model") ?? "";
2615
- const agg = levelFor(quotaByKey.get(key), modelId);
2689
+ const agg = levelFor(quotaByKey.get(key), modelId, ring.getAttribute("data-cpa-provider") ?? "");
2616
2690
  ring.setAttribute("data-cpa-level", agg.level);
2617
2691
  const arc = ring.querySelector(".cpa-q-arc");
2618
2692
  if (arc !== null) {
@@ -2805,9 +2879,11 @@ body[data-ds-dark-theme] .cpa-q-select{color-scheme:dark}
2805
2879
  if (ring.getAttribute("data-cpa-model") !== modelId) {
2806
2880
  ring.setAttribute("data-cpa-model", modelId);
2807
2881
  ring.setAttribute("data-cpa-base", key);
2882
+ ring.setAttribute("data-cpa-provider", model.providerId ?? "");
2808
2883
  if (tipAnchor === ring) showTooltip(ring);
2809
2884
  }
2810
2885
  ring.setAttribute("data-cpa-base", key);
2886
+ ring.setAttribute("data-cpa-provider", model.providerId ?? "");
2811
2887
  placeRing(ring, button);
2812
2888
  applyDotState(ring);
2813
2889
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-client-ui-cpa-quota",
3
3
  "description": "CliProxyAPI quota indicator: a native-styled ring left of the composer model name; hover shows per-account provider quotas, subscription plans, and status for Codex, Claude, Antigravity, Gemini CLI, Kimi, xAI/Grok, and other CPA auth providers. Targets the current dsh plugin contract (settings-namespace-keyed plugin cards).",
4
- "version": "0.4.2",
4
+ "version": "0.5.0",
5
5
  "author": "wkscc310",
6
6
  "repository": {
7
7
  "type": "git",