tokentracker-cli 1.5.2 → 1.5.4

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.
Files changed (134) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-B5kmt0VD.js → AchievementBadge-CKNb_cwg.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-C0FlkOo0.js → AchievementsPage-u9P8DXJI.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-CRcp80Pp.js → AchievementsSection-BSCUJ4DR.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-DAFjGbKW.js → BadgeDetailModal-Bg7cE3Zq.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-CO0bbimB.js → BotAnimated-C7D2WbQA.js} +1 -1
  6. package/dashboard/dist/assets/{Card-Xhr5exz8.js → Card-B4IqAhai.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-DsQ7FmsI.js → CloudCheckoutPage-P75pH9Ym.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-DkSexu5V.js → CloudPage-DDEZfmZg.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-Bm0huQdj.js → CommandPalette-CER_AklA.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-BsL8GN8Q.js → CommunityStatsModal-Cbb74eib.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-_WPp292g.js → ConfirmModal-Q1ZQdZHt.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-ktzn68KM.js → Controls-DaGOfcoz.js} +1 -1
  13. package/dashboard/dist/assets/DashboardPage-CXI7RmxO.js +1 -0
  14. package/dashboard/dist/assets/{DateRangePopover-DWr4zIzF.js → DateRangePopover-DSS1Ira_.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-CBa8Mk47.js → DevicePage-6zPLYydh.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-CZ5jzgtb.js → DialogClose-DdCQ36LK.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-DztHQvj-.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-Dr0N6q15.js → DialogTitle-BjP217Y1.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-sYqgrO6-.js → ElectricLogo-Dhq8PM4h.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-Qq-hcrZW.js → FadeIn-CGNbED4E.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-B77zSv7i.js → HeaderGithubStar-DwqEFzb6.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-lRDaUmoW.js → HoverTooltip-CQZTgyQy.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-B6AvHZjA.js → IpCheckPage-Bz09HXLn.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-DgzOjf3x.js → LandingPage-DC-lZsYR.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-BtjjtDMC.js → LeaderboardPage-D8MpLM_t.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-B2wNTUTU.js → LeaderboardProBadge-pdXgokhE.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-DJJckwHh.js → LeaderboardProfileModal-nHZLiE0m.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-DJdxByl2.js → LeaderboardProfilePage-BKv3oIAW.js} +1 -1
  29. package/dashboard/dist/assets/LimitsPage-TmRTgSCb.js +6 -0
  30. package/dashboard/dist/assets/{LocalOnlyNotice-B2lVqf_H.js → LocalOnlyNotice-aF01udyR.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-i0gg1yby.js → LoginCard-BaroD0Ct.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-B-loLNZM.js → LoginModal-0jgyMGVM.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-D_swI75j.js → LoginPage-BUx8U58i.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-DDpKvpjG.js → PetPage-CpOjtJqg.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-Gja4MyEK.js → PopoverPopup-BYjfer-M.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-DOPj7tAZ.js → ProviderIcon-NtYa2h0u.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-BVADLk89.js → ResetPasswordPage-CYUg7TR1.js} +1 -1
  38. package/dashboard/dist/assets/{Select-D-38tN-E.js → Select-CYP95o5y.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-jN61ukxk.js → SelectItemText-BCDfusOM.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-k7AkGpl1.js → SelfHostPage-BmVc2rIT.js} +1 -1
  41. package/dashboard/dist/assets/ServiceStatusPage-Dyp8NwIn.js +1 -0
  42. package/dashboard/dist/assets/SessionsPage-tGEj6Ztz.js +3 -0
  43. package/dashboard/dist/assets/{SettingsPage-DfnMCAuA.js → SettingsPage-CCMbq5wc.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-YyIHIaHN.js → SkillsPage-C4O4fbaQ.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-BLcvZuQ9.js → TokenGalaxy-D2d738xr.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup-3E8kyVzz.js → TooltipPopup-BJfFYesG.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-ByKAdXSF.js → TrendMonitor-CElakFSU.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-BgEVIIzL.js → WidgetsPage-Cvd1LbJm.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-HjbX8QaA.js → WrappedPage-wRC1tE_a.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-Br-KAXh6.css → __vite-browser-external-CMBWGkOq.css} +1 -1
  51. package/dashboard/dist/assets/__vite-browser-external-V4TPLCJG.js +45 -0
  52. package/dashboard/dist/assets/{agent-logos-BVF2siPQ.js → agent-logos-Ba57HF3w.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-left-CYO24PGP.js → arrow-left-B1bKnxXL.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-right-Xg9PZYhw.js → arrow-right-DrOlKzX5.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-B7ah6Qv-.js → arrow-up-right-hlYpo3yw.js} +1 -1
  56. package/dashboard/dist/assets/{calendar-days-BV_iG938.js → calendar-days-CSY6SEsI.js} +1 -1
  57. package/dashboard/dist/assets/{check--EZH8RTp.js → check-xrP1gLw5.js} +1 -1
  58. package/dashboard/dist/assets/{chevron-down-BzGTlk0-.js → chevron-down-6-zT5n9d.js} +1 -1
  59. package/dashboard/dist/assets/{cloud-checkout-BgyxPHEh.js → cloud-checkout-BZymZD4K.js} +1 -1
  60. package/dashboard/dist/assets/{composite-C0KrmLEK.js → composite-C9izWafa.js} +1 -1
  61. package/dashboard/dist/assets/{copy-BtucSK2U.js → copy-Dkas5wZX.js} +1 -1
  62. package/dashboard/dist/assets/{download-CWHq03Y2.js → download-CagGRs0_.js} +1 -1
  63. package/dashboard/dist/assets/{external-link-DCTXF5VZ.js → external-link-DRl10hxd.js} +1 -1
  64. package/dashboard/dist/assets/{flame-BE5TsqVF.js → flame-CkvmsTFB.js} +1 -1
  65. package/dashboard/dist/assets/{format-tokens-ThNIMWnZ.js → format-tokens-BtwhO3mV.js} +1 -1
  66. package/dashboard/dist/assets/{gift-DgaN9s4-.js → gift-YzjXkMQx.js} +1 -1
  67. package/dashboard/dist/assets/{icons-B4r5EiDC.js → icons-CHhKYs0u.js} +1 -1
  68. package/dashboard/dist/assets/{index-DqCtgU0G.js → index-B0MQZTrM.js} +1 -1
  69. package/dashboard/dist/assets/{index-fNKH4U7j.js → index-C2DK8av-.js} +1 -1
  70. package/dashboard/dist/assets/{index-CKhcCLNC.js → index-CJknN9Jd.js} +1 -1
  71. package/dashboard/dist/assets/{index-DFF9xo6f.js → index-DojzoMqd.js} +1 -1
  72. package/dashboard/dist/assets/{info-B4YzRR4y.js → info-Torep-Ti.js} +1 -1
  73. package/dashboard/dist/assets/{link-2-DzbUbaBw.js → link-2-DE5Yu-z6.js} +1 -1
  74. package/dashboard/dist/assets/{loader-circle-B9c7D-7v.js → loader-circle-BL8LxD9n.js} +1 -1
  75. package/dashboard/dist/assets/{local-usage-transfer-btuk_b0Y.js → local-usage-transfer-8X1InWix.js} +1 -1
  76. package/dashboard/dist/assets/{modifiers.esm-BjrU-Xz7.js → modifiers.esm-D60-6UQa.js} +1 -1
  77. package/dashboard/dist/assets/{monitor-smartphone-Bwn4tb4q.js → monitor-smartphone-C-IFPjqk.js} +1 -1
  78. package/dashboard/dist/assets/{play-DIFOwGIQ.js → play-n6uWrdH9.js} +1 -1
  79. package/dashboard/dist/assets/{plus-BpBqnwVg.js → plus-B2f6TaNj.js} +1 -1
  80. package/dashboard/dist/assets/{provider-display-DSrs4u7T.js → provider-display-DWqCEcsP.js} +1 -1
  81. package/dashboard/dist/assets/{react-BgG7_MY0.js → react-B-zfi_j6.js} +1 -1
  82. package/dashboard/dist/assets/{safePolygon-BUGWIDOM.js → safePolygon-C2PsvI-v.js} +1 -1
  83. package/dashboard/dist/assets/{search-aHrmBmtZ.js → search-DZOaTLF9.js} +1 -1
  84. package/dashboard/dist/assets/{server-DOEvwlEU.js → server-DIp4Qfsv.js} +1 -1
  85. package/dashboard/dist/assets/{shield-check-BPTnLhsT.js → shield-check-Cs2M8-iT.js} +1 -1
  86. package/dashboard/dist/assets/{skills-api-C052Mwez.js → skills-api-CoYy6Tov.js} +1 -1
  87. package/dashboard/dist/assets/{sortable.esm-DaZKhjhu.js → sortable.esm-B5_hjIfs.js} +1 -1
  88. package/dashboard/dist/assets/{store-Cq30Pofw.js → store-BLXqtJzb.js} +1 -1
  89. package/dashboard/dist/assets/{terminal-CoSDSkE7.js → terminal-CEMKX3ba.js} +1 -1
  90. package/dashboard/dist/assets/{trash-BAmt-kPj.js → trash-DOWYaXjK.js} +1 -1
  91. package/dashboard/dist/assets/{use-community-stats-Bxq0kBhm.js → use-community-stats-BjRI1Bzz.js} +1 -1
  92. package/dashboard/dist/assets/{use-latest-request-guard-DmIQDlvk.js → use-latest-request-guard-T7L5qBU6.js} +1 -1
  93. package/dashboard/dist/assets/use-limits-display-prefs-vNIrCPZA.js +1 -0
  94. package/dashboard/dist/assets/{use-native-settings-s_CIC8Bb.js → use-native-settings-Cg5Zb4q_.js} +1 -1
  95. package/dashboard/dist/assets/{use-ordered-list-BHhykAbt.js → use-ordered-list-BF-wIvmG.js} +1 -1
  96. package/dashboard/dist/assets/use-reduced-motion-B9K5mIx4.js +1 -0
  97. package/dashboard/dist/assets/{use-transform-CVeMFlS6.js → use-transform-CbJ1IcNc.js} +1 -1
  98. package/dashboard/dist/assets/use-usage-limits-Dgg3jpC4.js +1 -0
  99. package/dashboard/dist/assets/{useCurrency-DilqL2Iv.js → useCurrency-3oBHWfSe.js} +1 -1
  100. package/dashboard/dist/assets/{usePositioner-BcnySw8a.js → usePositioner-D0YrIW0A.js} +1 -1
  101. package/dashboard/dist/assets/{useTokenFormat-CvKW46WP.js → useTokenFormat-DZAHWE_R.js} +1 -1
  102. package/dashboard/dist/assets/{zap-D7VRNTwk.js → zap-kLub6Jiv.js} +1 -1
  103. package/dashboard/dist/index.html +2 -2
  104. package/dashboard/dist/ip-check.html +2 -2
  105. package/dashboard/dist/leaderboard.html +2 -2
  106. package/dashboard/dist/share.html +2 -2
  107. package/package.json +1 -1
  108. package/src/commands/serve.js +25 -15
  109. package/src/lib/accept-encoding.js +59 -0
  110. package/src/lib/cursor-config.js +3 -1
  111. package/src/lib/deepseek-limits.js +280 -0
  112. package/src/lib/deepseek-status.js +157 -0
  113. package/src/lib/grok-limits.js +40 -1
  114. package/src/lib/local-api.js +248 -10
  115. package/src/lib/opencode-status.js +88 -0
  116. package/src/lib/pricing/index.js +182 -79
  117. package/src/lib/pricing/seed-snapshot.json +1 -1
  118. package/src/lib/provider-api-key-store.js +2 -0
  119. package/src/lib/provider-status.js +27 -2
  120. package/src/lib/qoder-limits.js +17 -2
  121. package/src/lib/session-analytics.js +194 -29
  122. package/src/lib/static-server.js +74 -2
  123. package/src/lib/usage-limits.js +714 -53
  124. package/src/lib/workbuddy-limits.js +291 -26
  125. package/src/lib/zcode-limits.js +143 -35
  126. package/dashboard/dist/assets/DashboardPage-CdNXi3Im.js +0 -1
  127. package/dashboard/dist/assets/DialogDescription-kCZ4gN09.js +0 -1
  128. package/dashboard/dist/assets/LimitsPage-gSBHSBi_.js +0 -6
  129. package/dashboard/dist/assets/ServiceStatusPage-D_Fa_pYz.js +0 -1
  130. package/dashboard/dist/assets/SessionsPage-BymccEEz.js +0 -3
  131. package/dashboard/dist/assets/__vite-browser-external-D_cMeuqI.js +0 -45
  132. package/dashboard/dist/assets/use-limits-display-prefs-CyAn1fme.js +0 -1
  133. package/dashboard/dist/assets/use-reduced-motion-BmFyFE3S.js +0 -1
  134. package/dashboard/dist/assets/use-usage-limits-DRZMI20u.js +0 -1
@@ -0,0 +1,59 @@
1
+ // Pick a content encoding from an Accept-Encoding header. Keep this shared
2
+ // between the static file server and the JSON API so both agree on q-values,
3
+ // explicit identity exclusions and the "*" wildcard.
4
+ function parseAcceptEncoding(header) {
5
+ const values = String(header || "").toLowerCase().split(",");
6
+ const explicit = new Map();
7
+ let wildcard = null;
8
+ for (const raw of values) {
9
+ const parts = raw.trim().split(";");
10
+ const name = parts.shift()?.trim();
11
+ if (!name) continue;
12
+ let quality = 1;
13
+ for (const part of parts) {
14
+ const [key, value] = part.trim().split("=");
15
+ if (key === "q") {
16
+ const parsed = Number.parseFloat(value);
17
+ quality = Number.isFinite(parsed) ? parsed : 0;
18
+ }
19
+ }
20
+ const normalized = name === "x-gzip" ? "gzip" : name;
21
+ if (normalized !== "br" && normalized !== "gzip" && normalized !== "identity" && normalized !== "*") continue;
22
+ if (normalized === "*") {
23
+ wildcard = Math.max(wildcard ?? 0, quality);
24
+ continue;
25
+ }
26
+ explicit.set(normalized, Math.max(explicit.get(normalized) ?? 0, quality));
27
+ }
28
+ return { explicit, wildcard };
29
+ }
30
+
31
+ function selectEncoding({ explicit, wildcard }) {
32
+ let best = null;
33
+ let bestQuality = 0;
34
+ for (const name of ["br", "gzip"]) {
35
+ const quality = explicit.has(name) ? explicit.get(name) : wildcard;
36
+ if (quality !== null && quality > bestQuality) {
37
+ best = name;
38
+ bestQuality = quality;
39
+ }
40
+ }
41
+ return best;
42
+ }
43
+
44
+ function acceptsIdentity({ explicit, wildcard }) {
45
+ if (explicit.has("identity")) return explicit.get("identity") > 0;
46
+ if (wildcard !== null) return wildcard > 0;
47
+ return true;
48
+ }
49
+
50
+ function pickEncoding(header) {
51
+ return selectEncoding(parseAcceptEncoding(header));
52
+ }
53
+
54
+ function negotiateEncoding(header) {
55
+ const parsed = parseAcceptEncoding(header);
56
+ return { encoding: selectEncoding(parsed), identityAllowed: acceptsIdentity(parsed) };
57
+ }
58
+
59
+ module.exports = { pickEncoding, negotiateEncoding };
@@ -242,7 +242,9 @@ function fetchCursorUsageSummary({ cookie, timeoutMs = 30000, fetchImpl = fetch
242
242
  signal: AbortSignal.timeout(timeoutMs),
243
243
  }).then(async (res) => {
244
244
  if (res.status === 401 || res.status === 403) {
245
- throw new Error("Cursor session expired — re-login in Cursor to refresh");
245
+ const error = new Error("Cursor session expired — re-login in Cursor to refresh");
246
+ error.code = "AUTH_EXPIRED";
247
+ throw error;
246
248
  }
247
249
  if (!res.ok) {
248
250
  throw new Error(`Cursor API returned ${res.status}`);
@@ -0,0 +1,280 @@
1
+ "use strict";
2
+
3
+ // DeepSeek (platform.deepseek.com, pay-as-you-go) balance monitoring.
4
+ //
5
+ // DeepSeek has no quota windows at all: it is a prepaid API platform, and the
6
+ // only documented programmatic data source is the balance endpoint
7
+ // (GET https://api.deepseek.com/user/balance, Bearer API key). It returns
8
+ // `is_available` plus `balance_infos[]` entries whose numeric fields are all
9
+ // STRINGS ("110.02"), and more than one currency (CNY + USD) can be present
10
+ // at the same time. Concurrency rate limits (flash 2500 / v4-pro 500, 429)
11
+ // are not quota windows, so nothing here produces a window bar — the panel
12
+ // renders a balance card instead (issue #960).
13
+ //
14
+ // The API key comes from DEEPSEEK_API_KEY when set, otherwise from the key
15
+ // saved in the Limits panel (provider-api-key-store.js). The stored key exists
16
+ // so users never need a machine-wide `launchctl setenv`, which reaches every
17
+ // launchd app on macOS and not just TokenTracker (#837).
18
+ //
19
+ // Balance only decreases between top-ups and there is no reset instant to
20
+ // check, so the last-good disk-cache fallback (0600 JSON, written only on a
21
+ // live success) is gated by a pure TTL — the same "unknown reset" rule the
22
+ // ark providers use — and by a fingerprint of the API key that produced it,
23
+ // so a swapped key can never serve the previous account's balance.
24
+
25
+ const crypto = require("node:crypto");
26
+ const fs = require("node:fs");
27
+ const os = require("node:os");
28
+ const path = require("node:path");
29
+ const { readStoredApiKey } = require("./provider-api-key-store");
30
+
31
+ const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
32
+ const DEEPSEEK_LIMITS_CACHE_FILE = "deepseek-limits-cache.json";
33
+ const DEEPSEEK_ENV_NAME = "DEEPSEEK_API_KEY";
34
+ const DEEPSEEK_REQUEST_TIMEOUT_MS = 10_000;
35
+ const DEEPSEEK_BALANCE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
36
+
37
+ function readDeepseekApiKey({ home, trackerDir, env = process.env } = {}) {
38
+ const envKey =
39
+ env && typeof env === "object" && typeof env[DEEPSEEK_ENV_NAME] === "string"
40
+ ? env[DEEPSEEK_ENV_NAME].trim()
41
+ : "";
42
+ if (envKey) return { apiKey: envKey, source: "env" };
43
+ const stored = readStoredApiKey({ provider: "deepseek", trackerDir, home });
44
+ return stored ? { apiKey: stored, source: "stored" } : null;
45
+ }
46
+
47
+ // Balance payloads are strings; a malformed or negative entry must drop out
48
+ // rather than coerce into a misleading 0 ("" and " " both coerce to 0).
49
+ function balanceNumber(value) {
50
+ if (typeof value === "string") {
51
+ const trimmed = value.trim();
52
+ if (!trimmed) return null;
53
+ const n = Number(trimmed);
54
+ return Number.isFinite(n) && n >= 0 ? n : null;
55
+ }
56
+ if (typeof value !== "number") return null;
57
+ return Number.isFinite(value) && value >= 0 ? value : null;
58
+ }
59
+
60
+ /**
61
+ * Normalize the GET /user/balance payload into one row per currency, API
62
+ * order preserved (the panel renders each currency side by side; picking a
63
+ * single "primary" currency is an owner decision, not a parser concern).
64
+ * Throws when the payload shape is unusable so the caller can fall back to
65
+ * the disk cache.
66
+ */
67
+ function normalizeDeepseekBalanceResponse(body) {
68
+ if (!body || typeof body !== "object") {
69
+ throw new Error("DeepSeek balance response is not an object.");
70
+ }
71
+ if (typeof body.is_available !== "boolean") {
72
+ throw new Error("DeepSeek balance response is missing is_available.");
73
+ }
74
+ const entries = Array.isArray(body.balance_infos) ? body.balance_infos : [];
75
+ const balances = [];
76
+ for (const entry of entries) {
77
+ const currency = typeof entry?.currency === "string" ? entry.currency.trim() : "";
78
+ const total = balanceNumber(entry?.total_balance);
79
+ if (!currency || total === null) continue;
80
+ balances.push({
81
+ currency,
82
+ total,
83
+ granted: balanceNumber(entry?.granted_balance) ?? 0,
84
+ topped_up: balanceNumber(entry?.topped_up_balance) ?? 0,
85
+ });
86
+ }
87
+ if (balances.length === 0) {
88
+ throw new Error("DeepSeek balance response contains no usable balance entries.");
89
+ }
90
+ return balances;
91
+ }
92
+
93
+ // The fingerprint labels a credential, so it must come from a memory-hard
94
+ // KDF rather than a bare fast hash (CodeQL flags sha256 over key-store/env
95
+ // credentials; the ark apiKey-prefix label hit the same alert before #950).
96
+ // Memoized per key: scrypt is intentionally slow.
97
+ const DEEPSEEK_FINGERPRINT_SALT = "tokentracker/deepseek-limits-cache/v1";
98
+ let fingerprintMemo = null;
99
+
100
+ function keyFingerprint(apiKey) {
101
+ const key = String(apiKey);
102
+ if (fingerprintMemo?.key === key) return fingerprintMemo.hex;
103
+ const hex = crypto
104
+ .scryptSync(key, DEEPSEEK_FINGERPRINT_SALT, 16, {
105
+ N: 2 ** 15,
106
+ r: 8,
107
+ p: 1,
108
+ maxmem: 128 * 2 ** 15 * 8 * 2,
109
+ })
110
+ .toString("hex");
111
+ fingerprintMemo = { key, hex };
112
+ return hex;
113
+ }
114
+
115
+ function resolveDeepseekLimitsCachePath({ trackerDir, home } = {}) {
116
+ const dir =
117
+ typeof trackerDir === "string" && trackerDir.trim()
118
+ ? trackerDir.trim()
119
+ : path.join(home || os.homedir(), ".tokentracker", "tracker");
120
+ return path.join(dir, DEEPSEEK_LIMITS_CACHE_FILE);
121
+ }
122
+
123
+ /**
124
+ * Last-good balance snapshot. `keyFingerprint` (when given) must match the
125
+ * key that produced the cache — a different key is a different account, and
126
+ * its balance would be a wrong answer, not a stale one.
127
+ */
128
+ function readDeepseekLimitsCache({ trackerDir, home, nowMs = Date.now(), keyFingerprint: expectedFingerprint } = {}) {
129
+ const cachePath = resolveDeepseekLimitsCachePath({ trackerDir, home });
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(fs.readFileSync(cachePath, "utf8"))?.deepseek;
133
+ } catch (_error) {
134
+ return null;
135
+ }
136
+ const cachedAtMs = Date.parse(parsed?.cached_at || "");
137
+ if (!Number.isFinite(cachedAtMs)) return null;
138
+ if (cachedAtMs > nowMs + 60_000) return null;
139
+ // Pure TTL: balances have no reset instant, so freshness decays with age
140
+ // alone (ark's unknown-reset rule).
141
+ if (nowMs - cachedAtMs > DEEPSEEK_BALANCE_CACHE_TTL_MS) return null;
142
+ if (expectedFingerprint && parsed?.key_fingerprint !== expectedFingerprint) return null;
143
+ const balances = Array.isArray(parsed?.balances)
144
+ ? parsed.balances.filter(
145
+ (entry) =>
146
+ entry &&
147
+ typeof entry.currency === "string" &&
148
+ entry.currency.trim() &&
149
+ balanceNumber(entry.total) !== null,
150
+ )
151
+ : [];
152
+ if (balances.length === 0) return null;
153
+ return {
154
+ configured: true,
155
+ error: null,
156
+ is_available: parsed.is_available === true,
157
+ balances,
158
+ source: "disk-cache",
159
+ stale: true,
160
+ cached_at: parsed.cached_at,
161
+ };
162
+ }
163
+
164
+ function writeDeepseekLimitsCache(limits, { trackerDir, home, nowMs = Date.now(), keyFingerprint: fingerprint } = {}) {
165
+ if (!limits?.configured || limits.error || !Array.isArray(limits.balances) || limits.balances.length === 0) return;
166
+ const cachePath = resolveDeepseekLimitsCachePath({ trackerDir, home });
167
+ const payload = {
168
+ deepseek: {
169
+ is_available: limits.is_available === true,
170
+ balances: limits.balances,
171
+ key_fingerprint: fingerprint || null,
172
+ cached_at: new Date(nowMs).toISOString(),
173
+ },
174
+ };
175
+ try {
176
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
177
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
178
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
179
+ fs.renameSync(tmpPath, cachePath);
180
+ } catch (_error) {}
181
+ }
182
+
183
+ /**
184
+ * One live GET /user/balance read. Returns {ok, is_available, balances} on a
185
+ * usable payload, {auth_error: true, error} when the key itself is rejected
186
+ * (actionable — must not be masked by stale cache), {error} otherwise.
187
+ */
188
+ async function fetchDeepseekBalance({ apiKey, fetchImpl, timeoutMs = DEEPSEEK_REQUEST_TIMEOUT_MS } = {}) {
189
+ let response;
190
+ try {
191
+ response = await fetchImpl(DEEPSEEK_BALANCE_URL, {
192
+ method: "GET",
193
+ headers: {
194
+ Authorization: `Bearer ${apiKey}`,
195
+ Accept: "application/json",
196
+ },
197
+ });
198
+ } catch (err) {
199
+ return { error: `DeepSeek balance request failed: ${err?.message || err}` };
200
+ }
201
+ if (response.status === 401 || response.status === 403) {
202
+ return {
203
+ auth_error: true,
204
+ error: `DeepSeek API key was rejected (${response.status}). Check the key in the DeepSeek platform console.`,
205
+ };
206
+ }
207
+ if (!response.ok) {
208
+ return { error: `DeepSeek balance API returned ${response.status}` };
209
+ }
210
+ let body;
211
+ try {
212
+ body = JSON.parse(await response.text());
213
+ } catch (err) {
214
+ return { error: `Could not parse DeepSeek balance response: ${err?.message || err}` };
215
+ }
216
+ try {
217
+ return { ok: true, is_available: body.is_available === true, balances: normalizeDeepseekBalanceResponse(body) };
218
+ } catch (err) {
219
+ return { error: err?.message || "DeepSeek balance response could not be parsed." };
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Fetch the DeepSeek balance card. `persistCache: false` lets the caller
225
+ * suppress the disk write while its aggregate cache generation is stale
226
+ * (a provider-key save mid-fetch must not republish pre-save data, #837).
227
+ */
228
+ async function fetchDeepseekLimits({
229
+ home = os.homedir(),
230
+ trackerDir,
231
+ env = process.env,
232
+ fetchImpl = fetch,
233
+ nowMs = Date.now(),
234
+ timeoutMs = DEEPSEEK_REQUEST_TIMEOUT_MS,
235
+ persistCache = true,
236
+ } = {}) {
237
+ const credential = readDeepseekApiKey({ home, trackerDir, env });
238
+ if (!credential) return { configured: false };
239
+ const fingerprint = keyFingerprint(credential.apiKey);
240
+
241
+ const live = await fetchDeepseekBalance({ apiKey: credential.apiKey, fetchImpl, timeoutMs });
242
+ if (live.ok) {
243
+ const result = {
244
+ configured: true,
245
+ error: null,
246
+ is_available: live.is_available,
247
+ balances: live.balances,
248
+ credential_source: credential.source,
249
+ source: "provider-api",
250
+ stale: false,
251
+ cached_at: new Date(nowMs).toISOString(),
252
+ };
253
+ if (persistCache) {
254
+ writeDeepseekLimitsCache(result, { trackerDir, home, nowMs, keyFingerprint: fingerprint });
255
+ }
256
+ return result;
257
+ }
258
+ if (live.auth_error) {
259
+ return { configured: true, error: live.error, auth_error: true, credential_source: credential.source };
260
+ }
261
+ const cached = readDeepseekLimitsCache({ trackerDir, home, nowMs, keyFingerprint: fingerprint });
262
+ if (cached) return { ...cached, credential_source: credential.source };
263
+ return { configured: true, error: live.error, credential_source: credential.source };
264
+ }
265
+
266
+ module.exports = {
267
+ DEEPSEEK_BALANCE_URL,
268
+ DEEPSEEK_LIMITS_CACHE_FILE,
269
+ DEEPSEEK_ENV_NAME,
270
+ DEEPSEEK_BALANCE_CACHE_TTL_MS,
271
+ balanceNumber,
272
+ fetchDeepseekBalance,
273
+ fetchDeepseekLimits,
274
+ keyFingerprint,
275
+ normalizeDeepseekBalanceResponse,
276
+ readDeepseekApiKey,
277
+ readDeepseekLimitsCache,
278
+ resolveDeepseekLimitsCachePath,
279
+ writeDeepseekLimitsCache,
280
+ };
@@ -0,0 +1,157 @@
1
+ // DeepSeek service-status probe.
2
+ //
3
+ // status.deepseek.com is a Flashcat(快猫)-hosted Next.js app. Unlike Statuspage.io
4
+ // it exposes no public JSON API and sends no CORS headers, so a browser cannot
5
+ // read it directly. The page state is, however, inlined in the server render as
6
+ // React Server Component "flight" data: the client script pushes JSON chunks via
7
+ // self.__next_f.push([1, "<json string>"]). We concatenate those chunks and read
8
+ // the single machine-readable signal — the live `active_changes` array.
9
+ //
10
+ // Semantics (Flashcat change status -> Statuspage.io indicator):
11
+ // operational -> none
12
+ // degraded / degraded_performance / maintenance -> minor
13
+ // partial_outage -> major
14
+ // major_outage / full_outage -> critical
15
+ // An empty active_changes array means everything is operational. Anything we
16
+ // cannot parse throws, so the caller falls back instead of inventing "all good".
17
+
18
+ const DEEPSEEK_STATUS_URL = "https://status.deepseek.com/";
19
+
20
+ const RSC_PUSH_RE = /self\.__next_f\.push\(\[1,("(?:[^"\\]|\\.)*")\]\)/g;
21
+
22
+ const CHANGE_STATUS_TO_INDICATOR = {
23
+ operational: "none",
24
+ degraded: "minor",
25
+ degraded_performance: "minor",
26
+ maintenance: "minor",
27
+ under_maintenance: "minor",
28
+ partial_outage: "major",
29
+ major_outage: "critical",
30
+ full_outage: "critical",
31
+ outage: "critical",
32
+ };
33
+
34
+ const INDICATOR_RANK = { none: 0, minor: 1, major: 2, critical: 3 };
35
+
36
+ function extractFlight(html) {
37
+ if (typeof html !== "string" || html.length === 0) {
38
+ throw new Error("DeepSeek status: expected an HTML document");
39
+ }
40
+ let flight = "";
41
+ let chunks = 0;
42
+ RSC_PUSH_RE.lastIndex = 0;
43
+ let match;
44
+ while ((match = RSC_PUSH_RE.exec(html))) {
45
+ try {
46
+ flight += JSON.parse(match[1]);
47
+ } catch (_error) {
48
+ throw new Error("DeepSeek status: malformed RSC chunk");
49
+ }
50
+ chunks += 1;
51
+ }
52
+ if (chunks === 0) {
53
+ throw new Error("DeepSeek status: no RSC data found");
54
+ }
55
+ return flight;
56
+ }
57
+
58
+ // Return the balanced JSON array that starts at `start` (a '[' in flight).
59
+ function sliceJsonArray(flight, start) {
60
+ let depth = 0;
61
+ let inString = false;
62
+ let escaped = false;
63
+ for (let i = start; i < flight.length; i += 1) {
64
+ const ch = flight[i];
65
+ if (inString) {
66
+ if (escaped) escaped = false;
67
+ else if (ch === "\\") escaped = true;
68
+ else if (ch === '"') inString = false;
69
+ continue;
70
+ }
71
+ if (ch === '"') inString = true;
72
+ else if (ch === "[") depth += 1;
73
+ else if (ch === "]") {
74
+ depth -= 1;
75
+ if (depth === 0) return flight.slice(start, i + 1);
76
+ }
77
+ }
78
+ throw new Error("DeepSeek status: unterminated active_changes array");
79
+ }
80
+
81
+ function readUpdatedAt(flight) {
82
+ const match = /"initialDataUpdatedAt":(\d{10,})/.exec(flight);
83
+ if (!match) return undefined;
84
+ const ms = Number(match[1]);
85
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
86
+ }
87
+
88
+ /**
89
+ * Parse the status.deepseek.com HTML into a Statuspage.io-shaped payload:
90
+ * { status: { indicator, description }, page: { updated_at } }
91
+ * Throws when the page structure is not understood, so callers fall back
92
+ * instead of reporting a false "operational".
93
+ */
94
+ function parseDeepSeekStatusPage(html) {
95
+ const flight = extractFlight(html);
96
+ const marker = '"active_changes":';
97
+ const markerIndex = flight.indexOf(marker);
98
+ if (markerIndex === -1) {
99
+ throw new Error("DeepSeek status: active_changes not found");
100
+ }
101
+ // The value must be an array. If upstream ever reports null / an object /
102
+ // anything else, refuse to guess a healthier-looking array further along the
103
+ // document — throwing keeps the caller on its last-good / "unknown" path
104
+ // instead of inventing a false "operational".
105
+ let arrayStart = markerIndex + marker.length;
106
+ while (arrayStart < flight.length && /\s/.test(flight[arrayStart])) arrayStart += 1;
107
+ if (flight[arrayStart] !== "[") {
108
+ throw new Error("DeepSeek status: active_changes is not an array");
109
+ }
110
+ let changes;
111
+ try {
112
+ changes = JSON.parse(sliceJsonArray(flight, arrayStart));
113
+ } catch (error) {
114
+ throw new Error("DeepSeek status: cannot parse active_changes (" + error.message + ")");
115
+ }
116
+ if (!Array.isArray(changes)) {
117
+ throw new Error("DeepSeek status: active_changes is not an array");
118
+ }
119
+
120
+ const updatedAt = readUpdatedAt(flight);
121
+ if (changes.length === 0) {
122
+ return { status: { indicator: "none", description: "" }, page: { updated_at: updatedAt } };
123
+ }
124
+
125
+ let worstIndicator = "none";
126
+ let worstRank = -1;
127
+ let descriptions = [];
128
+ for (const change of changes) {
129
+ if (!change || typeof change !== "object") continue;
130
+ const rawStatus = String(change.status || change.change_status || change.impact || "").toLowerCase();
131
+ const indicator = CHANGE_STATUS_TO_INDICATOR[rawStatus] || "minor";
132
+ const rank = INDICATOR_RANK[indicator];
133
+ const description = typeof change.name === "string" ? change.name
134
+ : typeof change.title === "string" ? change.title
135
+ : typeof change.description === "string" ? change.description
136
+ : "";
137
+ if (rank > worstRank) {
138
+ worstRank = rank;
139
+ worstIndicator = indicator;
140
+ descriptions = description ? [description] : [];
141
+ } else if (rank === worstRank && description) {
142
+ descriptions.push(description);
143
+ }
144
+ }
145
+ // A non-empty array with no readable change is still "something is wrong".
146
+ if (worstRank < 0) worstIndicator = "minor";
147
+
148
+ return {
149
+ status: { indicator: worstIndicator, description: descriptions.join("; ") },
150
+ page: { updated_at: updatedAt },
151
+ };
152
+ }
153
+
154
+ module.exports = {
155
+ parseDeepSeekStatusPage,
156
+ DEEPSEEK_STATUS_URL,
157
+ };
@@ -264,6 +264,29 @@ function grokEntryAccessToken(entry) {
264
264
  return entry && typeof entry.key === "string" ? entry.key.trim() : "";
265
265
  }
266
266
 
267
+ // Non-secret account id for cache partitioning: the OIDC `sub` claim inside the
268
+ // access token, read with an unverified decode (only "who is this token for" —
269
+ // never trusted for auth). Opaque tokens return null.
270
+ function grokTokenSubject(token) {
271
+ if (typeof token !== "string" || !token.trim()) return null;
272
+ try {
273
+ const parts = token.split(".");
274
+ if (parts.length !== 3) return null;
275
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
276
+ const subject = payload?.sub;
277
+ return typeof subject === "string" && subject.trim() ? subject.trim() : null;
278
+ } catch (_error) {
279
+ return null;
280
+ }
281
+ }
282
+
283
+ // Errors that tell the user to re-login. They are actionable and must surface
284
+ // instead of being masked by the stale disk-cache fallback (opencode-go
285
+ // auth_error precedent).
286
+ function isGrokAuthErrorCode(error) {
287
+ return error?.code === "GROK_AUTH_REQUIRED" || error?.code === "GROK_REAUTH_REQUIRED";
288
+ }
289
+
267
290
  /**
268
291
  * Public xAI OIDC client refresh (auth method "none").
269
292
  * Matches Grok Build CLI's refresh against https://auth.x.ai/oauth2/token.
@@ -776,6 +799,10 @@ async function fetchGrokLimits({ home, env, fetchImpl = fetch, timeoutMs, nowMs
776
799
  return {
777
800
  configured: true,
778
801
  error: error?.message || "Unknown error",
802
+ // A rejected refresh is actionable (re-login) and must not be hidden
803
+ // behind the stale disk-cache fallback. Internal marker — stripped
804
+ // before the payload is served.
805
+ ...(isGrokAuthErrorCode(error) ? { auth_error: true } : {}),
779
806
  };
780
807
  }
781
808
 
@@ -786,13 +813,16 @@ async function fetchGrokLimits({ home, env, fetchImpl = fetch, timeoutMs, nowMs
786
813
  return {
787
814
  configured: true,
788
815
  error: resolved.error?.message || grokAuthError().message,
816
+ ...(isGrokAuthErrorCode(resolved.error) ? { auth_error: true } : {}),
789
817
  };
790
818
  }
791
819
 
792
820
  const startedAtMs = Date.now();
821
+ // Hoisted so the catch below can still derive the cache identity from the
822
+ // token the failing attempt used.
823
+ let accessToken = resolved.accessToken;
793
824
  try {
794
825
  let body;
795
- let accessToken = resolved.accessToken;
796
826
  try {
797
827
  body = await fetchGrokBilling(resolved.accessToken, { fetchImpl, env, timeoutMs });
798
828
  } catch (error) {
@@ -831,11 +861,19 @@ async function fetchGrokLimits({ home, env, fetchImpl = fetch, timeoutMs, nowMs
831
861
  configured: true,
832
862
  error: null,
833
863
  ...limits,
864
+ // Internal cache-partition key (stripped before the payload is served).
865
+ // An opaque token yields null → the cache write/read is refused, so an
866
+ // unattributable login can never pin another account's bars (no token
867
+ // fingerprint fallback: credentials must not enter even a label hash —
868
+ // CodeQL js/insufficient-password-hash precedent).
869
+ cache_identity: grokTokenSubject(accessToken),
834
870
  };
835
871
  } catch (error) {
836
872
  return {
837
873
  configured: true,
838
874
  error: error?.message || "Unknown error",
875
+ ...(isGrokAuthErrorCode(error) ? { auth_error: true } : {}),
876
+ cache_identity: grokTokenSubject(accessToken),
839
877
  };
840
878
  }
841
879
  }
@@ -849,6 +887,7 @@ module.exports = {
849
887
  loadGrokAuthEntry,
850
888
  readGrokAccessToken,
851
889
  isGrokAccessTokenExpired,
890
+ grokTokenSubject,
852
891
  resolveGrokOidcClientId,
853
892
  resolveGrokOidcIssuer,
854
893
  resolveGrokTokenEndpoint,