dsh-cliproxyapi-pro 1.7.0 → 1.9.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/quota.js CHANGED
@@ -16,8 +16,39 @@ export const DEFAULT_QUOTA_TIMEOUT_MS = 15_000;
16
16
  export const DEFAULT_QUOTA_CONCURRENCY = 4;
17
17
  export const MAX_QUOTA_RESPONSE_BYTES = 4 * 1024 * 1024;
18
18
 
19
+ // Consecutive management 401/403 answers after which automatic refresh stops
20
+ // (CPA bans the client address for 30 minutes after 5 failed attempts).
21
+ export const AUTH_FAILURE_LIMIT = 3;
22
+ export const QUOTA_AUTH_STATE_VERSION = 1;
23
+
19
24
  const MANAGEMENT_KEY_REF = "CLI_PROXY_MANAGEMENT_KEY";
20
25
  const STATUS_MESSAGE_MAX = 160;
26
+ const AUTH_ERROR_CODES = ["unauthorized", "forbidden"];
27
+ const AUTH_ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
28
+
29
+ /**
30
+ * The persisted management-key failure counts (`quota-state.json`):
31
+ * `{ version: 1, accounts: { [id]: { authFailures, errorCode, errorStatus?, failedAt } } }`.
32
+ * Never holds a key. Invalid entries are dropped; accounts without failures are not kept.
33
+ */
34
+ export function normalizeQuotaAuthState(raw) {
35
+ const accounts = {};
36
+ const input = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw.accounts : undefined;
37
+ if (input !== null && typeof input === "object" && !Array.isArray(input)) {
38
+ for (const [id, entry] of Object.entries(input)) {
39
+ if (!AUTH_ACCOUNT_ID.test(id) || entry === null || typeof entry !== "object") continue;
40
+ const failures = Number.isInteger(entry.authFailures) && entry.authFailures > 0 ? Math.min(entry.authFailures, 1000) : 0;
41
+ if (failures === 0) continue;
42
+ accounts[id] = {
43
+ authFailures: failures,
44
+ errorCode: AUTH_ERROR_CODES.includes(entry.errorCode) ? entry.errorCode : "unauthorized",
45
+ ...(Number.isInteger(entry.errorStatus) ? { errorStatus: entry.errorStatus } : {}),
46
+ ...(typeof entry.failedAt === "string" && entry.failedAt.length <= 40 ? { failedAt: entry.failedAt } : {}),
47
+ };
48
+ }
49
+ }
50
+ return { version: QUOTA_AUTH_STATE_VERSION, accounts };
51
+ }
21
52
 
22
53
  const UPSTREAM = {
23
54
  codexUsage: "https://chatgpt.com/backend-api/wham/usage",
@@ -1053,14 +1084,53 @@ export function createQuotaController(deps) {
1053
1084
  const agPreferred = new Map();
1054
1085
  let disposed = false;
1055
1086
 
1087
+ // Consecutive management 401/403 counts survive reloads through
1088
+ // `deps.authStore` ({ read(), write(state) }); without one they live in memory.
1089
+ const readAuth = () => {
1090
+ try {
1091
+ return normalizeQuotaAuthState(deps.authStore?.read?.());
1092
+ } catch {
1093
+ return normalizeQuotaAuthState(undefined);
1094
+ }
1095
+ };
1096
+ const writeAuthEntry = (id, entry) => {
1097
+ if (deps.authStore?.write === undefined) return;
1098
+ try {
1099
+ const next = readAuth();
1100
+ if (entry === undefined) {
1101
+ if (next.accounts[id] === undefined) return;
1102
+ delete next.accounts[id];
1103
+ } else {
1104
+ next.accounts[id] = entry;
1105
+ }
1106
+ deps.authStore.write(normalizeQuotaAuthState(next));
1107
+ } catch (error) {
1108
+ log("warn", `dsh-cliproxyapi: could not save the quota key failure count: ${error instanceof Error ? error.message : String(error)}`);
1109
+ }
1110
+ };
1111
+
1056
1112
  const stateFor = (id) => {
1057
1113
  let st = states.get(id);
1058
1114
  if (st === undefined) {
1059
- st = { gen: 0, inflight: null, result: null, data: null, lockedOut: false, lastAttemptAt: undefined };
1115
+ const saved = readAuth().accounts[id];
1116
+ st = {
1117
+ gen: 0,
1118
+ inflight: null,
1119
+ result: saved === undefined ? null : { state: "error", errorCode: saved.errorCode, ...(saved.errorStatus === undefined ? {} : { errorStatus: saved.errorStatus }) },
1120
+ data: null,
1121
+ authFailures: saved?.authFailures ?? 0,
1122
+ lastAttemptAt: undefined,
1123
+ };
1060
1124
  states.set(id, st);
1061
1125
  }
1062
1126
  return st;
1063
1127
  };
1128
+ const authPaused = (st) => st.authFailures >= AUTH_FAILURE_LIMIT;
1129
+ /** Forget an account's failure count (a new key, a cleared key, a removed account). */
1130
+ const resetAuth = (id, st = states.get(id)) => {
1131
+ if (st !== undefined) st.authFailures = 0;
1132
+ writeAuthEntry(id, undefined);
1133
+ };
1064
1134
 
1065
1135
  const log = (level, message) => {
1066
1136
  try {
@@ -1479,15 +1549,30 @@ export function createQuotaController(deps) {
1479
1549
  const fetchedAt = new Date(now()).toISOString();
1480
1550
  st.data = { credentials: result.credentials, fetchedAt };
1481
1551
  st.result = { state: "ok" };
1482
- st.lockedOut = false;
1552
+ if (st.authFailures > 0) resetAuth(account.id, st);
1483
1553
  }
1484
1554
  } catch (error) {
1485
1555
  if (!current()) return;
1486
1556
  const code = error instanceof QuotaError && error.code !== "aborted" ? error.code : error?.code === "aborted" ? null : "network";
1487
1557
  if (code === null) return;
1488
1558
  st.result = { state: "error", errorCode: code, ...(error?.status === undefined ? {} : { errorStatus: error.status }) };
1489
- if (code === "unauthorized" || code === "forbidden") st.lockedOut = true;
1490
- log("warn", `dsh-cliproxyapi: quota refresh failed for account ${account.id}: ${code}${error?.status === undefined ? "" : ` (${error.status})`}`);
1559
+ // Only a rejected key counts; network errors neither count nor reset.
1560
+ if (AUTH_ERROR_CODES.includes(code)) {
1561
+ st.authFailures += 1;
1562
+ writeAuthEntry(account.id, {
1563
+ authFailures: st.authFailures,
1564
+ errorCode: code,
1565
+ ...(error?.status === undefined ? {} : { errorStatus: error.status }),
1566
+ failedAt: new Date(now()).toISOString(),
1567
+ });
1568
+ }
1569
+ log(
1570
+ "warn",
1571
+ `dsh-cliproxyapi: quota refresh failed for account ${account.id}: ${code}${error?.status === undefined ? "" : ` (${error.status})`}` +
1572
+ (AUTH_ERROR_CODES.includes(code)
1573
+ ? ` — management key rejected ${st.authFailures}/${AUTH_FAILURE_LIMIT}${authPaused(st) ? "; automatic refresh stopped" : ""}`
1574
+ : "")
1575
+ );
1491
1576
  } finally {
1492
1577
  root.signal.removeEventListener("abort", onRootAbort);
1493
1578
  if (st.gen === gen) {
@@ -1532,6 +1617,9 @@ export function createQuotaController(deps) {
1532
1617
  ...(state === "error" ? { errorCode: st.result.errorCode, ...(st.result.errorStatus === undefined ? {} : { errorStatus: st.result.errorStatus }) } : {}),
1533
1618
  ...(configured && st.data !== null ? { fetchedAt: st.data.fetchedAt } : {}),
1534
1619
  refreshing,
1620
+ ...(configured && st.authFailures > 0
1621
+ ? { authFailures: st.authFailures, authFailureLimit: AUTH_FAILURE_LIMIT, authPaused: authPaused(st) }
1622
+ : {}),
1535
1623
  credentials: configured && st.data !== null ? structuredClone(st.data.credentials) : [],
1536
1624
  pickerModels,
1537
1625
  };
@@ -1552,6 +1640,8 @@ export function createQuotaController(deps) {
1552
1640
  if (!known.has(id)) {
1553
1641
  cancelInflight(st);
1554
1642
  states.delete(id);
1643
+ // An empty list may be a failed read; keep the stored count then.
1644
+ if (accounts.length > 0) resetAuth(id);
1555
1645
  }
1556
1646
  }
1557
1647
  return Promise.all(accounts.map(async (account) => {
@@ -1568,7 +1658,8 @@ export function createQuotaController(deps) {
1568
1658
  for (const { account, keyConfigured: configured } of prepared) {
1569
1659
  if (account.enabled !== true || !configured) continue;
1570
1660
  const st = stateFor(account.id);
1571
- if (st.inflight !== null || st.lockedOut) continue;
1661
+ // A key rejected AUTH_FAILURE_LIMIT times in a row is not tried in the background again.
1662
+ if (st.inflight !== null || authPaused(st)) continue;
1572
1663
  if (st.lastAttemptAt !== undefined && at - st.lastAttemptAt < ttlMs) continue;
1573
1664
  startRefresh(account);
1574
1665
  }
@@ -1582,8 +1673,8 @@ export function createQuotaController(deps) {
1582
1673
  if (accountId !== undefined && accountId !== null && accountId !== "") {
1583
1674
  const account = findAccount(accountId);
1584
1675
  if (!disposed) {
1585
- const st = stateFor(account.id);
1586
- st.lockedOut = false;
1676
+ // A manual refresh tries once even when paused; the count is kept,
1677
+ // so another rejection pauses again at once (a success resets it).
1587
1678
  if (await keyConfigured(managementKeyRef(account.id))) {
1588
1679
  const running = startRefresh(account);
1589
1680
  if (wait) await running;
@@ -1593,9 +1684,9 @@ export function createQuotaController(deps) {
1593
1684
  }
1594
1685
  const prepared = await prepare();
1595
1686
  if (!disposed) {
1596
- // Refresh-all never retries a locked-out account; only refresh(id) or saveKey clears a lockout.
1687
+ // Refresh-all never retries a paused account; only refresh(id) tries it once, and a new key resets it.
1597
1688
  await Promise.all(prepared
1598
- .filter(({ account, keyConfigured: configured }) => account.enabled === true && configured && !stateFor(account.id).lockedOut)
1689
+ .filter(({ account, keyConfigured: configured }) => account.enabled === true && configured && !authPaused(stateFor(account.id)))
1599
1690
  .map(({ account }) => (wait ? startRefresh(account) : (startRefresh(account), undefined))));
1600
1691
  }
1601
1692
  return buildSnapshot(prepared);
@@ -1611,7 +1702,7 @@ export function createQuotaController(deps) {
1611
1702
  await deps.unsetKey(ref);
1612
1703
  st.data = null;
1613
1704
  st.result = null;
1614
- st.lockedOut = false;
1705
+ resetAuth(account.id, st);
1615
1706
  st.lastAttemptAt = undefined;
1616
1707
  return buildSnapshot();
1617
1708
  }
@@ -1621,16 +1712,27 @@ export function createQuotaController(deps) {
1621
1712
  cancelInflight(st);
1622
1713
  await deps.setKey(ref, value);
1623
1714
  st.result = null;
1624
- st.lockedOut = false;
1715
+ resetAuth(account.id, st);
1625
1716
  }
1626
1717
  if (!disposed && (await keyConfigured(ref))) {
1627
- st.lockedOut = false;
1628
1718
  const running = startRefresh(account);
1629
1719
  if (wait) await running;
1630
1720
  }
1631
1721
  return buildSnapshot();
1632
1722
  },
1633
1723
 
1724
+ /**
1725
+ * A credential reference changed outside saveKey (e.g. the DSH credentials
1726
+ * page): when it is an account's management key, forget its failure count.
1727
+ * @returns the account id, or undefined for any other reference.
1728
+ */
1729
+ keyChanged(ref) {
1730
+ const account = listAccounts().find((a) => managementKeyRef(a.id) === ref);
1731
+ if (account === undefined) return undefined;
1732
+ resetAuth(account.id);
1733
+ return account.id;
1734
+ },
1735
+
1634
1736
  dispose() {
1635
1737
  if (disposed) return;
1636
1738
  disposed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cliproxyapi-pro",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Auto-discovery CLIProxyAPI (CPA) provider and management UI for DeepSeek Harness — multiple accounts, dynamic model sync, capability detection, and visual settings.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -41,6 +41,7 @@
41
41
  "lib/client.js",
42
42
  "lib/updater.js",
43
43
  "lib/quota.js",
44
+ "lib/media.js",
44
45
  "locale/*.json",
45
46
  "cordis.patch.yml",
46
47
  "icon.svg",
@@ -70,7 +71,7 @@
70
71
  },
71
72
  "scripts": {
72
73
  "test": "node --test tests/*.test.mjs",
73
- "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/updater.js && node --check lib/quota.js && node --test tests/*.test.mjs",
74
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/updater.js && node --check lib/quota.js && node --check lib/media.js && node --test tests/*.test.mjs",
74
75
  "pack:check": "npm pack --dry-run"
75
76
  }
76
77
  }