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 +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +119 -43
- package/package.json +1 -1
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
|
|
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
|
|
466
|
-
if (
|
|
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
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
-
/**
|
|
1656
|
-
|
|
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
|
-
|
|
1659
|
-
|
|
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
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
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
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
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
|
+
"version": "0.5.0",
|
|
5
5
|
"author": "wkscc310",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|