dsh-client-ui-cpa-quota 0.4.2 → 0.6.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 what each one actually serves, using CPA's own per-auth model registry first, then the model name and the DSH provider id as fallbacks. Only when a model matches no registry entry and no family signal can it be pinned down does the tooltip list the remaining usable accounts — and it says so when it does.
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
+ - **悬浮卡片显示了全部账号?** 卡片按每个账号「实际服务什么模型」过滤,优先读 CPA 的每账号模型注册表(auth-files/models),模型名和 DSH 提供方 id 只作兜底。仅当某个模型在注册表里无记录、且无法从名称判断归属时,才会列出剩余可用账号,并在卡片里注明。
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;
@@ -80,10 +83,13 @@ window.__ModuleLoader__.load({
80
83
  anthropic: "Claude",
81
84
  kimi: "Kimi",
82
85
  qwen: "Qwen",
86
+ glm: "GLM",
87
+ deepseek: "DeepSeek",
83
88
  xai: "xAI / Grok",
84
89
  grok: "xAI / Grok",
85
90
  iflow: "iFlow",
86
91
  vertex: "Vertex",
92
+ interactions: "Interactions API",
87
93
  aistudio: "AI Studio",
88
94
  "openai-compatibility": "OpenAI-compatible",
89
95
  "openai-compatible": "OpenAI-compatible",
@@ -170,6 +176,7 @@ window.__ModuleLoader__.load({
170
176
  if (provider === "anthropic" || provider === "claude-code") return "claude";
171
177
  if (provider === "grok" || provider === "x-ai" || provider === "x.ai") return "xai";
172
178
  if (provider === "gemini-cli" || provider === "gemini") return provider;
179
+ if (provider === "zai" || provider === "zhipu" || provider === "chatglm") return "glm";
173
180
  return provider;
174
181
  };
175
182
 
@@ -457,13 +464,34 @@ window.__ModuleLoader__.load({
457
464
  }
458
465
  }
459
466
 
467
+ /**
468
+ * The ChatGPT account id for a Codex auth file. The management API
469
+ * exposes `id_token` as an ALREADY-PARSED claims object (not a JWT
470
+ * string), and CPA also accepts `tokens.account_id` in newer builds —
471
+ * all shapes are handled here.
472
+ */
473
+ function claimsOf(value) {
474
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
475
+ if (typeof value === "string") return jwtClaims(value);
476
+ return null;
477
+ }
478
+
460
479
  function codexAccountId(entry) {
461
- const candidates = [entry.id_token, getPath(entry, ["metadata", "id_token"]), getPath(entry, ["attributes", "id_token"])];
480
+ const direct = firstText(
481
+ entry.chatgpt_account_id,
482
+ entry.account_id,
483
+ getPath(entry, ["tokens", "account_id"]),
484
+ getPath(entry, ["tokens", "chatgpt_account_id"]),
485
+ getPath(entry, ["metadata", "account_id"]),
486
+ getPath(entry, ["attributes", "account_id"]),
487
+ );
488
+ if (direct !== "") return direct;
489
+ const candidates = [entry.id_token, getPath(entry, ["tokens", "id_token"]), getPath(entry, ["metadata", "id_token"]), getPath(entry, ["attributes", "id_token"])];
462
490
  for (const candidate of candidates) {
463
- const claims = jwtClaims(candidate);
491
+ const claims = claimsOf(candidate);
464
492
  if (claims === null) continue;
465
- const direct = text(claims.chatgpt_account_id);
466
- if (direct !== "") return direct;
493
+ const directClaim = text(claims.chatgpt_account_id);
494
+ if (directClaim !== "") return directClaim;
467
495
  const auth = claims["https://api.openai.com/auth"];
468
496
  if (auth !== null && typeof auth === "object") {
469
497
  const nestedId = text(auth.chatgpt_account_id);
@@ -474,8 +502,8 @@ window.__ModuleLoader__.load({
474
502
  }
475
503
 
476
504
  function codexPlanFromEntry(entry) {
477
- const candidates = [entry.id_token, getPath(entry, ["metadata", "id_token"]), getPath(entry, ["attributes", "id_token"])]
478
- .map(jwtClaims)
505
+ const candidates = [entry.id_token, getPath(entry, ["tokens", "id_token"]), getPath(entry, ["metadata", "id_token"]), getPath(entry, ["attributes", "id_token"])]
506
+ .map(claimsOf)
479
507
  .filter((claims) => claims !== null);
480
508
  return extractPlan(...candidates);
481
509
  }
@@ -620,8 +648,12 @@ window.__ModuleLoader__.load({
620
648
  * @returns {'cpa'|'no'|'unknown'|'unreachable'}
621
649
  */
622
650
  async function probeCpa(base) {
651
+ // A hung probe never yields a verdict and the ring stays missing, so
652
+ // bound the request like every other network call.
653
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
654
+ const timer = controller === null ? 0 : window.setTimeout(() => controller.abort(), 10_000);
623
655
  try {
624
- const response = await fetch(`${base}/v0/management/usage-statistics-enabled`, { method: "GET" });
656
+ const response = await fetch(`${base}/v0/management/usage-statistics-enabled`, { method: "GET", ...(controller === null ? {} : { signal: controller.signal }) });
625
657
  const body = await response.text();
626
658
  if (response.status === 401) return /management|unauthor/i.test(body) ? "cpa" : "unknown";
627
659
  if (response.status === 404) return "no";
@@ -631,6 +663,8 @@ window.__ModuleLoader__.load({
631
663
  return "unknown";
632
664
  } catch {
633
665
  return "unreachable";
666
+ } finally {
667
+ if (timer !== 0) window.clearTimeout(timer);
634
668
  }
635
669
  }
636
670
 
@@ -639,9 +673,16 @@ window.__ModuleLoader__.load({
639
673
  const targets = [];
640
674
  for (const [key, info] of providerBases) {
641
675
  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;
676
+ if (!force && hit !== undefined) {
677
+ // Confirmed verdicts (cpa / a plain 404) keep the long cache; a
678
+ // transient network failure or ambiguous answer must retry
679
+ // quickly — caching it for an hour left rings missing after a
680
+ // single hiccup.
681
+ const ttl = hit.cpa === true || hit.verdict === "no" ? PROBE_TTL : PROBE_RETRY_TTL;
682
+ if (Date.now() - hit.at < ttl) {
683
+ discoveredByKey.set(key, { ...hit, baseURL: info.baseURL });
684
+ continue;
685
+ }
645
686
  }
646
687
  targets.push([key, info]);
647
688
  }
@@ -1337,6 +1378,49 @@ window.__ModuleLoader__.load({
1337
1378
  * trying it first spares the other accounts a 5-endpoint walk. */
1338
1379
  const agEndpointByBase = new Map();
1339
1380
 
1381
+ /**
1382
+ * Per-account served-model registry: CPA exposes the models each auth
1383
+ * file routes (`GET /v0/management/auth-files/models?name=…`) — the same
1384
+ * table its own router uses. Cached per account so refreshes stay cheap;
1385
+ * a failed lookup stays uncached and retries next refresh.
1386
+ */
1387
+ const ACCOUNT_MODELS_TTL = 10 * 60 * 1000;
1388
+ const accountModelsByKey = new Map();
1389
+
1390
+ async function loadAccountModelList(instance, entry) {
1391
+ // Key by the auth FILE NAME — it is stable across CPA's index
1392
+ // reshuffles; auth_index moves when accounts are added or removed.
1393
+ const name = firstText(entry.name, entry.id);
1394
+ if (name === "") return null;
1395
+ const key = `${baseKey(instance.baseURL)}\u0000${name}`;
1396
+ const hit = accountModelsByKey.get(key);
1397
+ if (hit !== undefined && Date.now() - hit.at < ACCOUNT_MODELS_TTL) return hit.ids;
1398
+ try {
1399
+ const payload = await fetchJSON(`${instance.base}/v0/management/auth-files/models?name=${encodeURIComponent(name)}`, {
1400
+ headers: { Authorization: `Bearer ${instance.managementKey}` },
1401
+ });
1402
+ const rows = Array.isArray(payload?.models) ? payload.models : [];
1403
+ const ids = rows
1404
+ .map((m) => (typeof m === "string" ? text(m) : firstText(m?.id, m?.name, m?.display_name)))
1405
+ .filter((id) => id !== "");
1406
+ const out = ids.length > 0 ? ids : null;
1407
+ if (out !== null) accountModelsByKey.set(key, { at: Date.now(), ids: out });
1408
+ return out;
1409
+ } catch {
1410
+ return null;
1411
+ }
1412
+ }
1413
+
1414
+ /** Loose match between a declared model id and the selected one: exact,
1415
+ * same series (effort suffixes stripped), or a pure extension either way. */
1416
+ function modelListMatches(listedId, modelId) {
1417
+ const a = text(listedId).toLowerCase();
1418
+ const b = text(modelId).toLowerCase();
1419
+ if (a === "" || b === "") return false;
1420
+ if (a === b || seriesKey(a) === seriesKey(b)) return true;
1421
+ return b.startsWith(`${a}-`) || a.startsWith(`${b}-`);
1422
+ }
1423
+
1340
1424
  async function queryAccount(instance, entry) {
1341
1425
  const provider = normalizeProvider(firstText(entry.provider, entry.type));
1342
1426
  const authIndex = firstText(entry.auth_index, entry.authIndex);
@@ -1345,6 +1429,7 @@ window.__ModuleLoader__.load({
1345
1429
  name: firstText(entry.label, entry.email, entry.account_email, entry.name, entry.id, authIndex, "unknown"),
1346
1430
  provider,
1347
1431
  authIndex,
1432
+ modelList: null,
1348
1433
  status: firstText(entry.status),
1349
1434
  statusMessage: firstText(entry.status_message, entry.statusMessage),
1350
1435
  disabled: boolOf(entry.disabled),
@@ -1354,6 +1439,10 @@ window.__ModuleLoader__.load({
1354
1439
  windows: [],
1355
1440
  error: "",
1356
1441
  };
1442
+ // Which models CPA routes to this account — the authoritative filter
1443
+ // signal. Fetched with the account's own management key; a failure
1444
+ // just means the account falls back to family-name filtering.
1445
+ account.modelList = await loadAccountModelList(instance, entry);
1357
1446
  const rememberPlan = () => {
1358
1447
  if (planKey !== "" && account.plan !== "") planByAccountKey.set(planKey, account.plan);
1359
1448
  };
@@ -1374,21 +1463,21 @@ window.__ModuleLoader__.load({
1374
1463
  }
1375
1464
  try {
1376
1465
  if (provider === "codex") {
1466
+ // No id found is not fatal: some CPA setups bind the account
1467
+ // server-side, so try without the Chatgpt-Account-Id header
1468
+ // and let an upstream rejection surface as the account error.
1377
1469
  const accountID = codexAccountId(entry);
1378
- if (accountID === "") {
1379
- account.error = ZH ? "缺少 chatgpt_account_id" : "missing chatgpt_account_id";
1380
- return account;
1381
- }
1470
+ const usageHeaders = {
1471
+ Authorization: "Bearer $TOKEN$",
1472
+ "Content-Type": "application/json",
1473
+ "User-Agent": "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal",
1474
+ };
1475
+ if (accountID !== "") usageHeaders["Chatgpt-Account-Id"] = accountID;
1382
1476
  const body = unwrapApiCall(await apiCall(instance, {
1383
1477
  auth_index: authIndex,
1384
1478
  method: "GET",
1385
1479
  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
- },
1480
+ header: usageHeaders,
1392
1481
  }));
1393
1482
  account.windows = parseCodexWindows(body);
1394
1483
  account.plan = extractPlan(body) || account.plan;
@@ -1613,12 +1702,35 @@ window.__ModuleLoader__.load({
1613
1702
  if (lower === "") return "";
1614
1703
  if (/(?:grok|xai)/.test(lower)) return "xai";
1615
1704
  if (/(?:kimi|moonshot)/.test(lower)) return "kimi";
1705
+ if (/(?:qwen|qwq)/.test(lower)) return "qwen";
1706
+ if (/(?:iflow)/.test(lower)) return "iflow";
1707
+ if (/(?:glm|chatglm|zhipu)/.test(lower)) return "glm";
1708
+ if (/(?:deepseek)/.test(lower)) return "deepseek";
1616
1709
  if (/(?:gemini|gemma)/.test(lower)) return "gemini";
1617
1710
  if (/(?:claude|sonnet|opus|haiku|fable)/.test(lower)) return "claude";
1618
1711
  if (/(?:gpt|openai|codex)/.test(lower) || /(?:^|[-_:/.])o[1-4](?:$|[-_:/.])/.test(lower)) return "gpt";
1619
1712
  return "";
1620
1713
  }
1621
1714
 
1715
+ /**
1716
+ * Second signal for the account filter: the DSH provider id that serves
1717
+ * the model. Custom route names ("team-claude", "fast-tier") reveal
1718
+ * nothing on their own, but the provider usually does.
1719
+ */
1720
+ function providerFamilyFromId(providerId) {
1721
+ const p = text(providerId).toLowerCase();
1722
+ if (/(?:anthropic|claude)/.test(p)) return "claude";
1723
+ if (/(?:gemini)/.test(p)) return "gemini";
1724
+ if (/(?:codex|openai|gpt)/.test(p)) return "gpt";
1725
+ if (/(?:kimi|moonshot)/.test(p)) return "kimi";
1726
+ if (/(?:grok|xai)/.test(p)) return "xai";
1727
+ if (/(?:qwen)/.test(p)) return "qwen";
1728
+ if (/(?:iflow)/.test(p)) return "iflow";
1729
+ if (/(?:deepseek)/.test(p)) return "deepseek";
1730
+ if (/(?:glm|zai|zhipu)/.test(p)) return "glm";
1731
+ return "";
1732
+ }
1733
+
1622
1734
  function modelSpecificWindows(account, modelId) {
1623
1735
  const lower = text(modelId).toLowerCase();
1624
1736
  if (lower === "") return [];
@@ -1648,25 +1760,58 @@ window.__ModuleLoader__.load({
1648
1760
  case "openai": return family === "gpt";
1649
1761
  case "kimi": return family === "kimi";
1650
1762
  case "xai": return family === "xai";
1763
+ case "qwen": return family === "qwen";
1764
+ case "iflow": return family === "iflow";
1765
+ case "glm": return family === "glm";
1766
+ case "deepseek": return family === "deepseek";
1767
+ case "vertex": return family === "gemini" || family === "claude";
1768
+ case "interactions": return ["gemini", "claude", "gpt"].includes(family);
1651
1769
  default: return false;
1652
1770
  }
1653
1771
  }
1654
1772
 
1655
- /** Accounts whose provider/model rows match the selected model. */
1656
- function accountsForModel(accounts, modelId) {
1773
+ /**
1774
+ * Accounts whose served models match the selected one, in three tiers:
1775
+ * 1. accounts that DECLARE their model list (CPA's per-auth routing
1776
+ * registry) and list this model — authoritative, wins outright, and
1777
+ * a declared list without the model excludes that account from every
1778
+ * fallback below;
1779
+ * 2. family heuristics over the remaining accounts — the model name
1780
+ * first, then the DSH provider id that serves it;
1781
+ * 3. when no family can be determined, CPA's routing decision is
1782
+ * unknowable from the client — every usable account is listed and
1783
+ * the tooltip says why.
1784
+ */
1785
+ function accountsForModel(accounts, modelId, providerHint = "") {
1657
1786
  if (!Array.isArray(accounts)) return [];
1658
- const family = modelFamily(modelId);
1659
- if (text(modelId) === "" || family === "") return accounts;
1660
- const specific = accounts.filter((account) => modelSpecificWindows(account, modelId).length > 0);
1661
- 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
- });
1787
+ if (text(modelId) === "") return accounts;
1788
+ let family = modelFamily(modelId);
1789
+ if (family === "") family = providerFamilyFromId(providerHint);
1790
+
1791
+ const listed = accounts.filter((account) => Array.isArray(account.modelList) && account.modelList.length > 0);
1792
+ const exact = listed.filter((account) => account.modelList.some((id) => modelListMatches(id, modelId)));
1793
+ if (exact.length > 0) return exact;
1794
+ const unlisted = accounts.filter((account) => !(Array.isArray(account.modelList) && account.modelList.length > 0));
1795
+ const usable = (list) => list.filter((account) => !account.disabled && !account.unavailable);
1796
+
1797
+ if (family === "") return usable(unlisted);
1798
+ // Cross-channel models (Claude served by both Antigravity and Claude
1799
+ // OAuth, for example) are load-balanced by CPA between the channels,
1800
+ // so specific-window accounts and generic family matches union — the
1801
+ // provider badge tells the user which channel each account is on.
1802
+ const specific = usable(unlisted).filter((account) => modelSpecificWindows(account, modelId).length > 0);
1803
+ const generic = usable(unlisted).filter((account) => {
1804
+ if (!providerMatchesFamily(account.provider, family)) return false;
1805
+ const rows = Array.isArray(account.windows) ? account.windows : [];
1806
+ // An account with model/prefix-tagged rows for another family must not
1807
+ // fall through as a generic provider account for this model.
1808
+ const hasTaggedRows = rows.some((row) => row.modelId !== null || Array.isArray(row.modelIds) || Array.isArray(row.prefixes));
1809
+ return !hasTaggedRows;
1810
+ });
1811
+ if (specific.length > 0 || generic.length > 0) return [...specific, ...generic];
1812
+ // Family known but nothing serves it: the honest answer is "no
1813
+ // matching account", not a list of guesses.
1814
+ return [];
1670
1815
  }
1671
1816
 
1672
1817
  function modelRowsForAccount(account, modelId) {
@@ -1728,12 +1873,12 @@ window.__ModuleLoader__.load({
1728
1873
  });
1729
1874
  }
1730
1875
 
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);
1876
+ /** Ring level for one instance given the current model. */
1877
+ function levelFor(state, modelId, providerHint = "") {
1878
+ if (state === undefined) return { level: "pending", remaining: null };
1879
+ if (state.state === "nokey") return { level: "pending", remaining: null };
1880
+ if (state.state === "error") return { level: "error", remaining: null };
1881
+ const matchingAccounts = accountsForModel(state.accounts, modelId, providerHint);
1737
1882
  const rows = [];
1738
1883
  for (const account of matchingAccounts) {
1739
1884
  if (account.disabled || account.unavailable) continue;
@@ -2464,6 +2609,7 @@ body[data-ds-dark-theme] .cpa-q-select{color-scheme:dark}
2464
2609
  function showTooltip(dot) {
2465
2610
  const key = dot.getAttribute("data-cpa-base") ?? "";
2466
2611
  const modelId = dot.getAttribute("data-cpa-model") ?? "";
2612
+ const providerHint = dot.getAttribute("data-cpa-provider") ?? "";
2467
2613
  const instance = instanceByKey.get(key);
2468
2614
  const state = quotaByKey.get(key);
2469
2615
  if (instance === undefined) return;
@@ -2515,12 +2661,17 @@ body[data-ds-dark-theme] .cpa-q-select{color-scheme:dark}
2515
2661
  } else if (state.accounts.length === 0) {
2516
2662
  tip.appendChild(el("div", "cpa-q-row", ZH ? "该实例没有账号(auth-files 为空)" : "no auth files on this instance"));
2517
2663
  } 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
- }
2664
+ const matchingAccounts = accountsForModel(state.accounts, modelId, providerHint);
2665
+ if (matchingAccounts.length === 0) {
2666
+ tip.appendChild(el("div", "cpa-q-row", ZH ? "该模型没有匹配的厂商额度" : "no matching provider quota for this model"));
2667
+ } else {
2668
+ if (modelFamily(modelId) === "" && providerFamilyFromId(providerHint) === "") {
2669
+ tip.appendChild(el("div", "cpa-q-hint", ZH
2670
+ ? "无法从模型名识别账号归属,以下为该实例全部可用账号"
2671
+ : "model family unknown — listing every usable account on this instance"));
2672
+ }
2673
+ for (const account of matchingAccounts) tip.appendChild(accountSection(account, modelId));
2674
+ }
2524
2675
  if (state.stale && state.lastError) {
2525
2676
  const hint = el("div", "cpa-q-hint");
2526
2677
  hint.textContent = ZH ? "暂时无法刷新,显示上次成功数据" : "refresh temporarily unavailable; showing the last good snapshot";
@@ -2612,7 +2763,7 @@ body[data-ds-dark-theme] .cpa-q-select{color-scheme:dark}
2612
2763
  function applyDotState(ring) {
2613
2764
  const key = ring.getAttribute("data-cpa-base") ?? "";
2614
2765
  const modelId = ring.getAttribute("data-cpa-model") ?? "";
2615
- const agg = levelFor(quotaByKey.get(key), modelId);
2766
+ const agg = levelFor(quotaByKey.get(key), modelId, ring.getAttribute("data-cpa-provider") ?? "");
2616
2767
  ring.setAttribute("data-cpa-level", agg.level);
2617
2768
  const arc = ring.querySelector(".cpa-q-arc");
2618
2769
  if (arc !== null) {
@@ -2805,9 +2956,11 @@ body[data-ds-dark-theme] .cpa-q-select{color-scheme:dark}
2805
2956
  if (ring.getAttribute("data-cpa-model") !== modelId) {
2806
2957
  ring.setAttribute("data-cpa-model", modelId);
2807
2958
  ring.setAttribute("data-cpa-base", key);
2959
+ ring.setAttribute("data-cpa-provider", model.providerId ?? "");
2808
2960
  if (tipAnchor === ring) showTooltip(ring);
2809
2961
  }
2810
2962
  ring.setAttribute("data-cpa-base", key);
2963
+ ring.setAttribute("data-cpa-provider", model.providerId ?? "");
2811
2964
  placeRing(ring, button);
2812
2965
  applyDotState(ring);
2813
2966
  }
@@ -2890,8 +3043,10 @@ body[data-ds-dark-theme] .cpa-q-select{color-scheme:dark}
2890
3043
  styleTag.remove();
2891
3044
 
2892
3045
  quotaByKey.clear();
2893
- planByAccountKey.clear();
2894
- discoveredByKey.clear();
3046
+ planByAccountKey.clear();
3047
+ discoveredByKey.clear();
3048
+ accountModelsByKey.clear();
3049
+ agEndpointByBase.clear();
2895
3050
  }, "ui-cpa-quota: dot, tooltip, card, observer, timers");
2896
3051
  }
2897
3052
  //#endregion
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.6.0",
5
5
  "author": "wkscc310",
6
6
  "repository": {
7
7
  "type": "git",