tokentracker-cli 1.5.3 → 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.
- package/dashboard/dist/assets/{AchievementBadge-uuDGO9Mq.js → AchievementBadge-CKNb_cwg.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-BCTVT7Jo.js → AchievementsPage-u9P8DXJI.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-B6fJSJSr.js → AchievementsSection-BSCUJ4DR.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-FjUN__9G.js → BadgeDetailModal-Bg7cE3Zq.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-B-KqY_Wr.js → BotAnimated-C7D2WbQA.js} +1 -1
- package/dashboard/dist/assets/{Card-DS8T8piP.js → Card-B4IqAhai.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-_0UjsFPu.js → CloudCheckoutPage-P75pH9Ym.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-BstgVBaV.js → CloudPage-DDEZfmZg.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-DtVsYZgc.js → CommandPalette-CER_AklA.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-DTpEJqV3.js → CommunityStatsModal-Cbb74eib.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-Lfdh2vr7.js → ConfirmModal-Q1ZQdZHt.js} +1 -1
- package/dashboard/dist/assets/{Controls-DpwQ7pkq.js → Controls-DaGOfcoz.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-CXI7RmxO.js +1 -0
- package/dashboard/dist/assets/{DateRangePopover-CrWgrzT2.js → DateRangePopover-DSS1Ira_.js} +1 -1
- package/dashboard/dist/assets/{DevicePage-DbK5KWJS.js → DevicePage-6zPLYydh.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-Dd2B_o03.js → DialogClose-DdCQ36LK.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-DztHQvj-.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-3JQBVha3.js → DialogTitle-BjP217Y1.js} +1 -1
- package/dashboard/dist/assets/{ElectricLogo-LYAqG8pG.js → ElectricLogo-Dhq8PM4h.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-DhDvUor7.js → FadeIn-CGNbED4E.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-9f1XmgYC.js → HeaderGithubStar-DwqEFzb6.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-BXhHAkP7.js → HoverTooltip-CQZTgyQy.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-ChPdDeMl.js → IpCheckPage-Bz09HXLn.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-DnJ7Y9zw.js → LandingPage-DC-lZsYR.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardPage-BF9isLzu.js → LeaderboardPage-D8MpLM_t.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProBadge-BM4JKgkC.js → LeaderboardProBadge-pdXgokhE.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardProfileModal-DfZABOpN.js → LeaderboardProfileModal-nHZLiE0m.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-BaZ38iT5.js → LeaderboardProfilePage-BKv3oIAW.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-TmRTgSCb.js +6 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-DvhlYoqQ.js → LocalOnlyNotice-aF01udyR.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-g_Gbnzhs.js → LoginCard-BaroD0Ct.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-DkJrI6b7.js → LoginModal-0jgyMGVM.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-CqCKSo0n.js → LoginPage-BUx8U58i.js} +1 -1
- package/dashboard/dist/assets/{PetPage-DbF7ROsj.js → PetPage-CpOjtJqg.js} +2 -2
- package/dashboard/dist/assets/{PopoverPopup-4WuH41ZT.js → PopoverPopup-BYjfer-M.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-6j0A2KD9.js → ProviderIcon-NtYa2h0u.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-DBd-iwuM.js → ResetPasswordPage-CYUg7TR1.js} +1 -1
- package/dashboard/dist/assets/{Select-BBEpLeiG.js → Select-CYP95o5y.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-Cn_U_rrO.js → SelectItemText-BCDfusOM.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-2WZc6Rml.js → SelfHostPage-BmVc2rIT.js} +1 -1
- package/dashboard/dist/assets/ServiceStatusPage-Dyp8NwIn.js +1 -0
- package/dashboard/dist/assets/SessionsPage-tGEj6Ztz.js +3 -0
- package/dashboard/dist/assets/{SettingsPage-rXNr96i-.js → SettingsPage-CCMbq5wc.js} +3 -3
- package/dashboard/dist/assets/{SkillsPage-CudTvbZL.js → SkillsPage-C4O4fbaQ.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-BRKrbTsJ.js → TokenGalaxy-D2d738xr.js} +1 -1
- package/dashboard/dist/assets/{TooltipPopup-CsLKXhWC.js → TooltipPopup-BJfFYesG.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-8onUTbkk.js → TrendMonitor-CElakFSU.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-CcwE0lz8.js → WidgetsPage-Cvd1LbJm.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-Cj9LLYqP.js → WrappedPage-wRC1tE_a.js} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-CMBWGkOq.css +1 -0
- package/dashboard/dist/assets/__vite-browser-external-V4TPLCJG.js +45 -0
- package/dashboard/dist/assets/{agent-logos-ChBA1nAH.js → agent-logos-Ba57HF3w.js} +1 -1
- package/dashboard/dist/assets/{arrow-left-Bb5mfoPc.js → arrow-left-B1bKnxXL.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-D1Ti2XUI.js → arrow-right-DrOlKzX5.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-B_CjA_y8.js → arrow-up-right-hlYpo3yw.js} +1 -1
- package/dashboard/dist/assets/{calendar-days-DdfrAqie.js → calendar-days-CSY6SEsI.js} +1 -1
- package/dashboard/dist/assets/{check-29zGw1MK.js → check-xrP1gLw5.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-DAzlFX7g.js → chevron-down-6-zT5n9d.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-C2SeCjFM.js → cloud-checkout-BZymZD4K.js} +1 -1
- package/dashboard/dist/assets/{composite-DIf735Hq.js → composite-C9izWafa.js} +1 -1
- package/dashboard/dist/assets/{copy-BOuOM0HQ.js → copy-Dkas5wZX.js} +1 -1
- package/dashboard/dist/assets/{download-50tQRpGe.js → download-CagGRs0_.js} +1 -1
- package/dashboard/dist/assets/{external-link-BhlQClVe.js → external-link-DRl10hxd.js} +1 -1
- package/dashboard/dist/assets/{flame-CeGfA-0L.js → flame-CkvmsTFB.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-Cq7YiAkJ.js → format-tokens-BtwhO3mV.js} +1 -1
- package/dashboard/dist/assets/{gift-Do-UFhiT.js → gift-YzjXkMQx.js} +1 -1
- package/dashboard/dist/assets/{icons-C6EZ_HmP.js → icons-CHhKYs0u.js} +1 -1
- package/dashboard/dist/assets/{index-BWhbBAd9.js → index-B0MQZTrM.js} +1 -1
- package/dashboard/dist/assets/{index-D9kwYWLX.js → index-C2DK8av-.js} +1 -1
- package/dashboard/dist/assets/{index-BFrbVqsm.js → index-CJknN9Jd.js} +1 -1
- package/dashboard/dist/assets/{index-c0crpe9Z.js → index-DojzoMqd.js} +1 -1
- package/dashboard/dist/assets/{info-B4MXmsNE.js → info-Torep-Ti.js} +1 -1
- package/dashboard/dist/assets/{link-2-DuWCslzl.js → link-2-DE5Yu-z6.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-C2I3NZAO.js → loader-circle-BL8LxD9n.js} +1 -1
- package/dashboard/dist/assets/{local-usage-transfer-BQasvsUF.js → local-usage-transfer-8X1InWix.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-DbeNS6FT.js → modifiers.esm-D60-6UQa.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-C6Y4ktsc.js → monitor-smartphone-C-IFPjqk.js} +1 -1
- package/dashboard/dist/assets/{play-CwgcDJC2.js → play-n6uWrdH9.js} +1 -1
- package/dashboard/dist/assets/{plus-B796wsUO.js → plus-B2f6TaNj.js} +1 -1
- package/dashboard/dist/assets/{provider-display-Cw26RFDm.js → provider-display-DWqCEcsP.js} +1 -1
- package/dashboard/dist/assets/{react-d7Bk1-_K.js → react-B-zfi_j6.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-CDE0dNv_.js → safePolygon-C2PsvI-v.js} +1 -1
- package/dashboard/dist/assets/{search-DtKvsdRs.js → search-DZOaTLF9.js} +1 -1
- package/dashboard/dist/assets/{server-gAURHUyz.js → server-DIp4Qfsv.js} +1 -1
- package/dashboard/dist/assets/{shield-check-DFVdDTwh.js → shield-check-Cs2M8-iT.js} +1 -1
- package/dashboard/dist/assets/{skills-api-2rdPUrJI.js → skills-api-CoYy6Tov.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-DFlfwnJr.js → sortable.esm-B5_hjIfs.js} +1 -1
- package/dashboard/dist/assets/{store-BKPCk_de.js → store-BLXqtJzb.js} +1 -1
- package/dashboard/dist/assets/{terminal-BtWFakvS.js → terminal-CEMKX3ba.js} +1 -1
- package/dashboard/dist/assets/{trash-bpr9zTAM.js → trash-DOWYaXjK.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DMnGgLne.js → use-community-stats-BjRI1Bzz.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-BSBlJJHe.js → use-latest-request-guard-T7L5qBU6.js} +1 -1
- package/dashboard/dist/assets/use-limits-display-prefs-vNIrCPZA.js +1 -0
- package/dashboard/dist/assets/{use-native-settings-OthqiF6g.js → use-native-settings-Cg5Zb4q_.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-DpAtr0R-.js → use-ordered-list-BF-wIvmG.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-B9K5mIx4.js +1 -0
- package/dashboard/dist/assets/{use-transform-BRz9dz_2.js → use-transform-CbJ1IcNc.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-CwmhudAX.js → use-usage-limits-Dgg3jpC4.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-CAA-tQ27.js → useCurrency-3oBHWfSe.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-Cj2DPmwM.js → usePositioner-D0YrIW0A.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-Bf5-f7Hx.js → useTokenFormat-DZAHWE_R.js} +1 -1
- package/dashboard/dist/assets/{zap-B8Fg6PaC.js → zap-kLub6Jiv.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/ip-check.html +2 -2
- package/dashboard/dist/leaderboard.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +1 -1
- package/src/commands/serve.js +3 -2
- package/src/lib/accept-encoding.js +59 -0
- package/src/lib/cursor-config.js +3 -1
- package/src/lib/deepseek-limits.js +280 -0
- package/src/lib/grok-limits.js +40 -1
- package/src/lib/local-api.js +229 -31
- package/src/lib/opencode-status.js +88 -0
- package/src/lib/pricing/index.js +182 -79
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/provider-api-key-store.js +2 -0
- package/src/lib/provider-status.js +17 -5
- package/src/lib/session-analytics.js +194 -29
- package/src/lib/static-server.js +74 -2
- package/src/lib/usage-limits.js +535 -16
- package/src/lib/workbuddy-limits.js +106 -14
- package/dashboard/dist/assets/DashboardPage-DUFWejkE.js +0 -1
- package/dashboard/dist/assets/DialogDescription-CLy0arbt.js +0 -1
- package/dashboard/dist/assets/LimitsPage-C7LbG3LX.js +0 -6
- package/dashboard/dist/assets/ServiceStatusPage-B-g4MDl0.js +0 -1
- package/dashboard/dist/assets/SessionsPage-CWS0iQ1L.js +0 -3
- package/dashboard/dist/assets/__vite-browser-external-07djSGmy.js +0 -45
- package/dashboard/dist/assets/__vite-browser-external-BDldtsD2.css +0 -1
- package/dashboard/dist/assets/use-limits-display-prefs-eYlvnUKn.js +0 -1
- package/dashboard/dist/assets/use-reduced-motion-DF0grwHs.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 };
|
package/src/lib/cursor-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/src/lib/grok-limits.js
CHANGED
|
@@ -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,
|