tokentracker-cli 0.94.1 → 0.95.1
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.de.md +9 -7
- package/README.ja.md +8 -6
- package/README.ko.md +8 -6
- package/README.md +23 -10
- package/README.zh-CN.md +8 -6
- package/dashboard/dist/assets/{AchievementBadge-DnmgEa4q.js → AchievementBadge-Cyju9OnL.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-B7YTbAuK.js → AchievementsPage-Bh2dQR-I.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-CwgT5sqH.js → AchievementsSection-DSFAM0Zp.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-Dets6jzW.js → BadgeDetailModal-CgVF0VI0.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-5KwUvC9F.js → BotAnimated-DI-rTxI9.js} +1 -1
- package/dashboard/dist/assets/{Card-jf9w-4-N.js → Card-DAlV-PSI.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-CGxDVQ0c.js → ClawdAnimated-oOYKgcpq.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-B1bUUf32.js → CommandPalette-bYE5uD63.js} +1 -1
- package/dashboard/dist/assets/CommunityStatsModal-DCx0sQjk.js +1 -0
- package/dashboard/dist/assets/{ConfirmModal-BR-aIRH9.js → ConfirmModal-DrYf9m15.js} +1 -1
- package/dashboard/dist/assets/{Controls-CbHsJAqV.js → Controls-DAift9Z4.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-CLswnOil.js +1 -0
- package/dashboard/dist/assets/{DevicePage-DDlicLLn.js → DevicePage-uWLgMC4Z.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-B89uLXqp.js → DialogClose-NitVrdVC.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-CQuJCP4L.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-jOyycYKF.js → DialogTitle-CA_ZstJL.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-BEND42ze.js → FadeIn-CIkYDamA.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-Dacl1WGS.js → HeaderGithubStar-CYTJNLpL.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-B2Mh2LTi.js → HoverTooltip-jh3sI8NC.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-CPEuRhiU.js → IpCheckPage-Brpe-N7B.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-BYWNR_dx.js → LandingPage-D4PjaNcN.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-B9_5LDVj.js → LeaderboardAvatar-D9dc6frq.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-_Hl1NaIa.js → LeaderboardPage-CEuqopiC.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-Cydp7CyA.js → LeaderboardProfileModal-TeYdXeIA.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-Dz0ecujs.js → LeaderboardProfilePage-DmtuwJkL.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-CmdT2qOZ.js → LimitsPage-BJW3teYA.js} +2 -2
- package/dashboard/dist/assets/{LocalOnlyNotice-DhuPGUkw.js → LocalOnlyNotice-V8_KFKOD.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-DQtHaX_5.js → LoginCard-ud0lCY_u.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-DvpPRnYV.js → LoginModal-Bnemx4UD.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-DOjfmdwb.js → LoginPage-v89BHssa.js} +1 -1
- package/dashboard/dist/assets/{PetPage-Dd6U9fh2.js → PetPage-Bg9kEi7o.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-B1V01u10.js → PopoverPopup-CCHVwh0w.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-BLRe55c7.js → ProviderIcon-BecpRylD.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-BJ95vy1S.js → ResetPasswordPage-CsUtFeUr.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-DacS_m9M.js → SegmentedControl-3rBqbzW_.js} +1 -1
- package/dashboard/dist/assets/{Select-bQDO5BEA.js → Select-DZ2ll7Qv.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-CUGrs9CY.js → SelectItemText-Dr1W47W6.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-CsERvmVC.js → ServiceStatusPage-Cc1LXdAh.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-0VvqEfPx.js → SessionsPage-ytOFN1h9.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-9EHh2HfD.js → SettingsPage-2wYQ2ly6.js} +2 -2
- package/dashboard/dist/assets/SkillsPage-BEDAwBTN.js +1 -0
- package/dashboard/dist/assets/{TokenGalaxy-DSKTHQSs.js → TokenGalaxy-DxG0dBeG.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-DP4mQLbq.js → ToolbarRootContext-DG1pr5UP.js} +1 -1
- package/dashboard/dist/assets/TrendMonitor-CeuEQ0XC.js +19 -0
- package/dashboard/dist/assets/{WidgetsPage-Cr6Wg9QX.js → WidgetsPage-ALDlZ5aR.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-DqQvRcnA.js → WrappedPage-BF59ouR-.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-BHz2N7Ih.css → __vite-browser-external-CCMdjCX2.css} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-DXtgoDkV.js +45 -0
- package/dashboard/dist/assets/{agent-logos-BjIh_mqf.js → agent-logos-D6Ya1qw1.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-BIhbN0NO.js → arrow-up-right-DUR69elJ.js} +1 -1
- package/dashboard/dist/assets/{check-U-wNzPvd.js → check-9_1xp5h5.js} +1 -1
- package/dashboard/dist/assets/{composite-BR91CxNl.js → composite-CEzovOpb.js} +1 -1
- package/dashboard/dist/assets/{copy-Bw3hXBCu.js → copy-DEoLQqJE.js} +1 -1
- package/dashboard/dist/assets/{download-WOqcQzvK.js → download-BNjfcJ1g.js} +1 -1
- package/dashboard/dist/assets/{external-link-BZtf6WfS.js → external-link-fV_WJ5f_.js} +1 -1
- package/dashboard/dist/assets/{flame-B5kp2VI9.js → flame-DqpuFJ14.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-BOCnuMuJ.js → format-tokens-CatddjmK.js} +1 -1
- package/dashboard/dist/assets/{icons-CA3FiIa_.js → icons-CBT1KI7V.js} +1 -1
- package/dashboard/dist/assets/{index-DjWC63Bs.js → index-BK3SzL7v.js} +1 -1
- package/dashboard/dist/assets/{index-DQaanK52.js → index-_2LEUZTl.js} +1 -1
- package/dashboard/dist/assets/{index-mEG0HZ-a.js → index-tK7s2SeO.js} +1 -1
- package/dashboard/dist/assets/{info-Djer9i3-.js → info-DBotcfT9.js} +1 -1
- package/dashboard/dist/assets/{limits-providers-F-RnNFAb.js → limits-providers-CjMU709b.js} +1 -1
- package/dashboard/dist/assets/{link-2-DV4ocD0l.js → link-2-Wc54x2fh.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-DJK72c-o.js → loader-circle-nRhGIddE.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-BwBA_lMm.js → maximize-2-Dw3_GuKW.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-B3sl4qyZ.js → modifiers.esm-rTZ5BUxp.js} +1 -1
- package/dashboard/dist/assets/module-xpSFyNBK.js +3 -0
- package/dashboard/dist/assets/{monitor-smartphone-BUTpM4ng.js → monitor-smartphone-CNVAqV_N.js} +1 -1
- package/dashboard/dist/assets/{plus-BA9RP5L6.js → plus-B5oPOXhp.js} +1 -1
- package/dashboard/dist/assets/{provider-display-DqsJzGSr.js → provider-display-DhAUWfC6.js} +2 -2
- package/dashboard/dist/assets/{react-BYw5AVSz.js → react-BKACWrsB.js} +1 -1
- package/dashboard/dist/assets/{search-BzcnFWVD.js → search-DgddmV09.js} +1 -1
- package/dashboard/dist/assets/{skills-api-vAME1P7G.js → skills-api-BT1uiqeq.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-BpEiRqqI.js → sortable.esm-UCoNfZ6z.js} +1 -1
- package/dashboard/dist/assets/{store-GeT5fhRX.js → store-ZswY8Voz.js} +1 -1
- package/dashboard/dist/assets/{terminal-CZCMYQ8K.js → terminal-BtQyotJS.js} +1 -1
- package/dashboard/dist/assets/{trash-2-DzlWEwKO.js → trash-2-Dc_NLbWs.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DOvJfj-i.js → use-community-stats-yOKag_ou.js} +1 -1
- package/dashboard/dist/assets/use-latest-request-guard-BEiszvlI.js +1 -0
- package/dashboard/dist/assets/{use-limits-display-prefs-BmLs4HyN.js → use-limits-display-prefs-C-l_Mxvy.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-BgozDSSV.js → use-ordered-list-CKjRaO6K.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-BlOkflqU.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-HP1ckVkS.js → use-session-efficiency-pref-DPI_--W2.js} +1 -1
- package/dashboard/dist/assets/{use-transform-B4VSogOx.js → use-transform-BQUem5OA.js} +1 -1
- package/dashboard/dist/assets/use-usage-limits-lvkMBnNR.js +1 -0
- package/dashboard/dist/assets/{useCurrency-CSuzCBAL.js → useCurrency-C5xhS1QV.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-ClnL9OFe.js → useTokenFormat-FBMikfEq.js} +1 -1
- package/dashboard/dist/assets/{zap-C_T9CCiS.js → zap-BsCNYelz.js} +1 -1
- package/dashboard/dist/brand-logos/lmstudio.svg +12 -0
- package/dashboard/dist/brand-logos/unsloth.svg +1 -0
- package/dashboard/dist/index.html +16 -12
- package/dashboard/dist/ip-check.html +2 -2
- package/dashboard/dist/leaderboard.html +2 -2
- package/dashboard/dist/llms.txt +4 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +2 -2
- package/src/commands/device-login.js +5 -10
- package/src/commands/init.js +28 -1
- package/src/commands/serve.js +6 -0
- package/src/commands/status.js +75 -8
- package/src/commands/sync.js +189 -18
- package/src/lib/cloud-account.js +102 -29
- package/src/lib/fs.js +30 -0
- package/src/lib/local-api.js +99 -13
- package/src/lib/pricing/index.js +5 -0
- package/src/lib/pricing/matcher.js +15 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +1418 -28
- package/src/lib/usage-limits.js +507 -55
- package/src/lib/zcode-limits.js +65 -8
- package/dashboard/dist/assets/CommunityStatsModal-DRDpZycV.js +0 -1
- package/dashboard/dist/assets/DashboardPage-B5XMQoxy.js +0 -1
- package/dashboard/dist/assets/DialogDescription-DiVbwvTR.js +0 -1
- package/dashboard/dist/assets/SkillsPage-B1AcLX41.js +0 -1
- package/dashboard/dist/assets/TrendMonitor-rabtcE5w.js +0 -19
- package/dashboard/dist/assets/__vite-browser-external-C8KwSyTz.js +0 -45
- package/dashboard/dist/assets/chevron-right-Du-QVNj4.js +0 -1
- package/dashboard/dist/assets/module-D2D5r9gC.js +0 -3
- package/dashboard/dist/assets/use-reduced-motion-Bc3RFJdg.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-DNNhD2H6.js +0 -1
package/src/lib/cloud-account.js
CHANGED
|
@@ -73,8 +73,27 @@ function decodeJwtExpMs(token) {
|
|
|
73
73
|
// The popover polls frequently, so caching avoids hammering /api/auth/refresh.
|
|
74
74
|
let tokenCache = { cacheKey: null, accessToken: null, expMs: 0 };
|
|
75
75
|
|
|
76
|
+
// In-flight refresh de-duplication ("single flight"), keyed the same way as
|
|
77
|
+
// `tokenCache`. A full popover refresh fires six account reads concurrently; on
|
|
78
|
+
// a cold cache every one of them used to POST /api/auth/refresh with the SAME
|
|
79
|
+
// refresh token. When the backend rotates refresh tokens, the losers of that
|
|
80
|
+
// race present an already-consumed token and fail — which is exactly the
|
|
81
|
+
// intermittent "Activity silently dropped to this-machine data" symptom.
|
|
82
|
+
const mintInflight = new Map();
|
|
83
|
+
|
|
76
84
|
function __resetCloudAccountCacheForTests() {
|
|
77
85
|
tokenCache = { cacheKey: null, accessToken: null, expMs: 0 };
|
|
86
|
+
mintInflight.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Failure classes surfaced to local-api so it can tell an intentional local
|
|
90
|
+
// view ("signed out", "cloud sync off") apart from a temporary cloud outage.
|
|
91
|
+
class AccountAuthError extends Error {
|
|
92
|
+
constructor(code, message) {
|
|
93
|
+
super(message || code);
|
|
94
|
+
this.name = "AccountAuthError";
|
|
95
|
+
this.code = code;
|
|
96
|
+
}
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
function csrfTokenFromRefreshPayload(data) {
|
|
@@ -87,30 +106,11 @@ function csrfTokenFromRefreshPayload(data) {
|
|
|
87
106
|
}
|
|
88
107
|
|
|
89
108
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
109
|
+
* Perform the actual refresh POST. Throws a classified `AccountAuthError` on
|
|
110
|
+
* every failure so callers can distinguish a transient outage (timeout,
|
|
111
|
+
* offline, rotated-token rejection) from an intentionally local view.
|
|
93
112
|
*/
|
|
94
|
-
async function
|
|
95
|
-
baseUrl,
|
|
96
|
-
anonKey,
|
|
97
|
-
refreshToken,
|
|
98
|
-
fetchImpl = fetch,
|
|
99
|
-
now = Date.now,
|
|
100
|
-
skewMs = 60_000,
|
|
101
|
-
timeoutMs,
|
|
102
|
-
} = {}) {
|
|
103
|
-
if (!refreshToken) return null;
|
|
104
|
-
const root = String(baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
105
|
-
const cacheKey = `${root}\0${refreshToken}`;
|
|
106
|
-
if (
|
|
107
|
-
tokenCache.cacheKey === cacheKey &&
|
|
108
|
-
tokenCache.accessToken &&
|
|
109
|
-
tokenCache.expMs - skewMs > now()
|
|
110
|
-
) {
|
|
111
|
-
return { accessToken: tokenCache.accessToken, refreshToken: null, csrfToken: null };
|
|
112
|
-
}
|
|
113
|
-
|
|
113
|
+
async function performMint({ root, cacheKey, anonKey, refreshToken, fetchImpl, now, timeoutMs }) {
|
|
114
114
|
const headers = { "Content-Type": "application/json", Accept: "application/json" };
|
|
115
115
|
if (anonKey) headers.apikey = anonKey;
|
|
116
116
|
|
|
@@ -132,24 +132,37 @@ async function mintAccessToken({
|
|
|
132
132
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
133
133
|
signal,
|
|
134
134
|
});
|
|
135
|
-
} catch {
|
|
136
|
-
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const aborted = e && (e.name === "AbortError" || e.name === "TimeoutError");
|
|
137
|
+
throw new AccountAuthError(
|
|
138
|
+
aborted ? "auth_timeout" : "auth_network",
|
|
139
|
+
e?.message || "token refresh failed",
|
|
140
|
+
);
|
|
137
141
|
} finally {
|
|
138
142
|
if (timeoutId) {
|
|
139
143
|
clearTimeout(timeoutId);
|
|
140
144
|
}
|
|
141
145
|
}
|
|
142
|
-
if (!res || !res.ok)
|
|
146
|
+
if (!res || !res.ok) {
|
|
147
|
+
const err = new AccountAuthError(
|
|
148
|
+
"auth_rejected",
|
|
149
|
+
`token refresh failed with HTTP ${res ? res.status : "?"}`,
|
|
150
|
+
);
|
|
151
|
+
err.status = res ? res.status : 0;
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
143
154
|
|
|
144
155
|
let data = null;
|
|
145
156
|
try {
|
|
146
157
|
data = await res.json();
|
|
147
|
-
} catch {
|
|
148
|
-
|
|
158
|
+
} catch (e) {
|
|
159
|
+
throw new AccountAuthError("auth_invalid", e?.message || "token refresh returned invalid JSON");
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
const accessToken = accessTokenFromRefreshPayload(data);
|
|
152
|
-
if (!accessToken)
|
|
163
|
+
if (!accessToken) {
|
|
164
|
+
throw new AccountAuthError("auth_invalid", "token refresh returned no access token");
|
|
165
|
+
}
|
|
153
166
|
|
|
154
167
|
const expMs = decodeJwtExpMs(accessToken) || now() + 10 * 60_000;
|
|
155
168
|
tokenCache = { cacheKey, accessToken, expMs };
|
|
@@ -166,6 +179,60 @@ async function mintAccessToken({
|
|
|
166
179
|
};
|
|
167
180
|
}
|
|
168
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Mint (or reuse a cached) InsForge access token from a refresh token.
|
|
184
|
+
*
|
|
185
|
+
* Concurrent callers with the same (baseUrl, refreshToken) share ONE in-flight
|
|
186
|
+
* refresh; see `mintInflight`.
|
|
187
|
+
*
|
|
188
|
+
* @param {boolean} [throwOnFailure] when true, reject with a classified
|
|
189
|
+
* `AccountAuthError` instead of resolving to `null`.
|
|
190
|
+
* @returns {Promise<{accessToken: string, refreshToken: string|null, csrfToken: string|null}|null>}
|
|
191
|
+
* null when no refresh token is available or the refresh failed.
|
|
192
|
+
*/
|
|
193
|
+
async function mintAccessToken({
|
|
194
|
+
baseUrl,
|
|
195
|
+
anonKey,
|
|
196
|
+
refreshToken,
|
|
197
|
+
fetchImpl = fetch,
|
|
198
|
+
now = Date.now,
|
|
199
|
+
skewMs = 60_000,
|
|
200
|
+
timeoutMs,
|
|
201
|
+
throwOnFailure = false,
|
|
202
|
+
} = {}) {
|
|
203
|
+
if (!refreshToken) {
|
|
204
|
+
if (throwOnFailure) throw new AccountAuthError("auth_missing_refresh_token", "not signed in");
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const root = String(baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
208
|
+
const cacheKey = `${root}\0${refreshToken}`;
|
|
209
|
+
if (
|
|
210
|
+
tokenCache.cacheKey === cacheKey &&
|
|
211
|
+
tokenCache.accessToken &&
|
|
212
|
+
tokenCache.expMs - skewMs > now()
|
|
213
|
+
) {
|
|
214
|
+
return { accessToken: tokenCache.accessToken, refreshToken: null, csrfToken: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let inflight = mintInflight.get(cacheKey);
|
|
218
|
+
if (!inflight) {
|
|
219
|
+
inflight = performMint({ root, cacheKey, anonKey, refreshToken, fetchImpl, now, timeoutMs })
|
|
220
|
+
.finally(() => {
|
|
221
|
+
// Only clear our own entry: a later caller may already have started a
|
|
222
|
+
// fresh mint after this one settled.
|
|
223
|
+
if (mintInflight.get(cacheKey) === inflight) mintInflight.delete(cacheKey);
|
|
224
|
+
});
|
|
225
|
+
mintInflight.set(cacheKey, inflight);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
return await inflight;
|
|
230
|
+
} catch (e) {
|
|
231
|
+
if (throwOnFailure) throw e;
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
169
236
|
/**
|
|
170
237
|
* GET a `tokentracker-account-*` edge function, forwarding the popover's query
|
|
171
238
|
* params (minus `account`/`scope`, which are local-only routing knobs).
|
|
@@ -237,6 +304,7 @@ async function fetchAccountUsage({
|
|
|
237
304
|
} = {}) {
|
|
238
305
|
const slug = accountSlugFor(usageSlug);
|
|
239
306
|
if (!slug) return null;
|
|
307
|
+
if (!refreshToken) return null;
|
|
240
308
|
|
|
241
309
|
const startTime = now();
|
|
242
310
|
const getRemainingTimeout = () => {
|
|
@@ -246,6 +314,9 @@ async function fetchAccountUsage({
|
|
|
246
314
|
return remaining > 0 ? remaining : 1;
|
|
247
315
|
};
|
|
248
316
|
|
|
317
|
+
// `throwOnFailure` keeps "not signed in" (null, handled above) distinct from
|
|
318
|
+
// "signed in but the refresh call failed" (throws) so the caller does not
|
|
319
|
+
// silently downgrade an account view into a this-machine view.
|
|
249
320
|
const minted = await mintAccessToken({
|
|
250
321
|
baseUrl,
|
|
251
322
|
anonKey,
|
|
@@ -253,6 +324,7 @@ async function fetchAccountUsage({
|
|
|
253
324
|
fetchImpl,
|
|
254
325
|
now,
|
|
255
326
|
timeoutMs: getRemainingTimeout(),
|
|
327
|
+
throwOnFailure: true,
|
|
256
328
|
});
|
|
257
329
|
if (!minted) return null;
|
|
258
330
|
|
|
@@ -269,6 +341,7 @@ async function fetchAccountUsage({
|
|
|
269
341
|
}
|
|
270
342
|
|
|
271
343
|
module.exports = {
|
|
344
|
+
AccountAuthError,
|
|
272
345
|
USAGE_TO_ACCOUNT_SLUG,
|
|
273
346
|
accountSlugFor,
|
|
274
347
|
accessTokenFromRefreshPayload,
|
package/src/lib/fs.js
CHANGED
|
@@ -413,6 +413,35 @@ async function openLock(
|
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
async function updateJsonLocked(
|
|
417
|
+
filePath,
|
|
418
|
+
update,
|
|
419
|
+
{ timeoutMs = 30_000, retryMs = 10 } = {},
|
|
420
|
+
) {
|
|
421
|
+
const lockPath = `${filePath}.lock`;
|
|
422
|
+
const deadline = Date.now() + timeoutMs;
|
|
423
|
+
let lock = null;
|
|
424
|
+
while (!lock) {
|
|
425
|
+
lock = await openLock(lockPath, { quietIfLocked: true });
|
|
426
|
+
if (lock) break;
|
|
427
|
+
if (Date.now() >= deadline) {
|
|
428
|
+
throw new Error(`Timed out waiting to update JSON file: ${filePath}`);
|
|
429
|
+
}
|
|
430
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const current = (await readJson(filePath)) || {};
|
|
435
|
+
const next = await update(current);
|
|
436
|
+
if (next == null) return current;
|
|
437
|
+
await writeJson(filePath, next);
|
|
438
|
+
await chmod600IfPossible(filePath);
|
|
439
|
+
return next;
|
|
440
|
+
} finally {
|
|
441
|
+
await lock.release();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
416
445
|
module.exports = {
|
|
417
446
|
ensureDir,
|
|
418
447
|
writeFileAtomic,
|
|
@@ -422,4 +451,5 @@ module.exports = {
|
|
|
422
451
|
chmod600IfPossible,
|
|
423
452
|
openLock,
|
|
424
453
|
inspectLock,
|
|
454
|
+
updateJsonLocked,
|
|
425
455
|
};
|
package/src/lib/local-api.js
CHANGED
|
@@ -14,6 +14,11 @@ const { accountSlugFor, fetchAccountUsage, mintAccessToken } = require("./cloud-
|
|
|
14
14
|
const { getOrCreateMachineId, computeStableMachineId } = require("./machine-id");
|
|
15
15
|
|
|
16
16
|
const SYNC_TIMEOUT_MS = 120_000;
|
|
17
|
+
// A failed account-view request should not stall every dashboard refresh
|
|
18
|
+
// while an expired session or unreachable backend keeps timing out. The first
|
|
19
|
+
// request still falls back normally; subsequent fan-out requests reuse that
|
|
20
|
+
// decision for a short cooldown and return local data immediately.
|
|
21
|
+
const ACCOUNT_VIEW_FAILURE_BACKOFF_MS = 2 * 60_000;
|
|
17
22
|
const TRACKER_BIN = path.resolve(__dirname, "../../bin/tracker.js");
|
|
18
23
|
const MAX_DEVICE_NAME_LENGTH = 128;
|
|
19
24
|
|
|
@@ -219,8 +224,12 @@ function normalizeQueueRow(row) {
|
|
|
219
224
|
// non-zero billable usage (GitHub issue #106). Treat billing and usage as
|
|
220
225
|
// orthogonal: bump billable up to total_tokens at read time so historical
|
|
221
226
|
// queue.jsonl entries render correctly without requiring a file rewrite.
|
|
227
|
+
// Antigravity had the same symptom from e69e2746a (billable deleted during
|
|
228
|
+
// the O(N) refactoring, leaving 167 local rows with billable=0 until the
|
|
229
|
+
// parser fix). Apply the same read-time healing so upgraded installs show
|
|
230
|
+
// correct heatmap / rolling numbers without a queue rewrite.
|
|
222
231
|
const sourceName = String(normalized.source || "").toLowerCase();
|
|
223
|
-
if (sourceName === "cursor") {
|
|
232
|
+
if (sourceName === "cursor" || sourceName === "antigravity") {
|
|
224
233
|
const totalTokens = Number(normalized.total_tokens || 0);
|
|
225
234
|
const billable = Number(normalized.billable_total_tokens || 0);
|
|
226
235
|
if (totalTokens > 0 && billable < totalTokens) {
|
|
@@ -1135,6 +1144,30 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1135
1144
|
const cookiePath = path.join(trackerDataDir, "relay-cookies.json");
|
|
1136
1145
|
const localSyncDeviceTokenCache = new Map();
|
|
1137
1146
|
const localSyncDeviceTokenInflight = new Map();
|
|
1147
|
+
// A dashboard refresh fans out to several account-view endpoints. Keep the
|
|
1148
|
+
// circuit-breaker state per session instead of one global slot (a successful
|
|
1149
|
+
// request for one account must not clear another account's failure), and
|
|
1150
|
+
// suppress the duplicate fan-out while the first cloud probe is pending.
|
|
1151
|
+
const accountViewFailures = new Map();
|
|
1152
|
+
const accountViewInFlight = new Set();
|
|
1153
|
+
|
|
1154
|
+
function accountViewFailureUntil(failureKey) {
|
|
1155
|
+
const until = accountViewFailures.get(failureKey) || 0;
|
|
1156
|
+
if (until > Date.now()) return until;
|
|
1157
|
+
if (until) accountViewFailures.delete(failureKey);
|
|
1158
|
+
return 0;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function rememberAccountViewFailure(failureKey) {
|
|
1162
|
+
accountViewFailures.set(failureKey, Date.now() + ACCOUNT_VIEW_FAILURE_BACKOFF_MS);
|
|
1163
|
+
// Refresh-token rotation or account switching can produce many keys over a
|
|
1164
|
+
// long-lived tray session. Bound the map without affecting the active key.
|
|
1165
|
+
while (accountViewFailures.size > 16) {
|
|
1166
|
+
const oldest = accountViewFailures.keys().next().value;
|
|
1167
|
+
if (!oldest || oldest === failureKey) break;
|
|
1168
|
+
accountViewFailures.delete(oldest);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1138
1171
|
|
|
1139
1172
|
// Load persisted cookies on startup
|
|
1140
1173
|
try {
|
|
@@ -1399,15 +1432,52 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1399
1432
|
}
|
|
1400
1433
|
}
|
|
1401
1434
|
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
//
|
|
1405
|
-
//
|
|
1435
|
+
// Why a *classified* fallback: the native popover keeps the last successful
|
|
1436
|
+
// account (cross-device) snapshot on screen when a cloud read fails
|
|
1437
|
+
// transiently, but must switch to this-machine data the moment the user signs
|
|
1438
|
+
// out or turns cloud sync off. Both used to look identical from the outside
|
|
1439
|
+
// (HTTP 200 + local payload + `X-TokenTracker-Account-View: 0`), so a single
|
|
1440
|
+
// timed-out read silently shrank Activity to one device until the next
|
|
1441
|
+
// manual sync. See ACCOUNT_FALLBACK_* below for the vocabulary.
|
|
1442
|
+
const ACCOUNT_FALLBACK_CLOUD_SYNC_OFF = "cloud-sync-off";
|
|
1443
|
+
const ACCOUNT_FALLBACK_SIGNED_OUT = "signed-out";
|
|
1444
|
+
|
|
1445
|
+
// Every transient reason is prefixed "transient-"; clients only need the
|
|
1446
|
+
// prefix, so new reasons can be added without a client change.
|
|
1447
|
+
function classifyAccountFallback(err) {
|
|
1448
|
+
if (!err) return "transient-error";
|
|
1449
|
+
if (err.name === "AbortError" || err.name === "TimeoutError" || err.code === "auth_timeout") {
|
|
1450
|
+
return "transient-timeout";
|
|
1451
|
+
}
|
|
1452
|
+
if (err.code === "auth_rejected" || err.code === "auth_invalid" || err.status === 401 || err.status === 403) {
|
|
1453
|
+
return "transient-auth";
|
|
1454
|
+
}
|
|
1455
|
+
if (err.code === "auth_network") return "transient-network";
|
|
1456
|
+
if (Number.isFinite(err.status) && err.status > 0) return "transient-upstream";
|
|
1457
|
+
return "transient-network";
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// Returns "served" when the cross-device aggregate was written to `res`,
|
|
1461
|
+
// otherwise a fallback reason (see above) telling the caller — and, through
|
|
1462
|
+
// the `X-TokenTracker-Account-Fallback` header, the popover — why the local
|
|
1463
|
+
// single-machine data is being served instead.
|
|
1406
1464
|
async function tryServeAccountView(usageSlug, url, res) {
|
|
1407
|
-
if (!getCloudSyncPref()) return
|
|
1465
|
+
if (!getCloudSyncPref()) return ACCOUNT_FALLBACK_CLOUD_SYNC_OFF;
|
|
1408
1466
|
const refreshToken = getRefreshTokenForCloud();
|
|
1409
|
-
if (!refreshToken) return
|
|
1467
|
+
if (!refreshToken) return ACCOUNT_FALLBACK_SIGNED_OUT;
|
|
1410
1468
|
const runtime = resolveRuntimeConfig();
|
|
1469
|
+
const failureKey = `${runtime.baseUrl}\0${refreshToken}`;
|
|
1470
|
+
const requestKey = `${failureKey}\0${usageSlug}\0${url.searchParams.toString()}`;
|
|
1471
|
+
if (accountViewFailureUntil(failureKey) > Date.now()) {
|
|
1472
|
+
return "transient-backoff";
|
|
1473
|
+
}
|
|
1474
|
+
// Six native dashboard requests normally arrive together. If the cloud is
|
|
1475
|
+
// already being probed for this session, let the remaining requests use
|
|
1476
|
+
// their local fallback instead of opening more timeout-bound sockets.
|
|
1477
|
+
if (accountViewInFlight.has(requestKey)) {
|
|
1478
|
+
return "transient-inflight";
|
|
1479
|
+
}
|
|
1480
|
+
accountViewInFlight.add(requestKey);
|
|
1411
1481
|
const envTimeout = process.env.TOKENTRACKER_HTTP_TIMEOUT_MS
|
|
1412
1482
|
? parseInt(process.env.TOKENTRACKER_HTTP_TIMEOUT_MS, 10)
|
|
1413
1483
|
: 0;
|
|
@@ -1421,9 +1491,16 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1421
1491
|
refreshToken,
|
|
1422
1492
|
timeoutMs,
|
|
1423
1493
|
});
|
|
1424
|
-
|
|
1494
|
+
// `null` here means the slug has no cloud equivalent, or the session
|
|
1495
|
+
// vanished mid-request — a routing/session fact, not an outage.
|
|
1496
|
+
if (!out) return ACCOUNT_FALLBACK_SIGNED_OUT;
|
|
1497
|
+
if (out.data == null) {
|
|
1498
|
+
rememberAccountViewFailure(failureKey);
|
|
1499
|
+
return "transient-upstream";
|
|
1500
|
+
}
|
|
1425
1501
|
if (out.rotatedRefreshToken) setRelayRefreshToken(out.rotatedRefreshToken);
|
|
1426
1502
|
if (out.rotatedCsrfToken) setRelayCsrfToken(out.rotatedCsrfToken);
|
|
1503
|
+
accountViewFailures.delete(failureKey);
|
|
1427
1504
|
res.writeHead(200, {
|
|
1428
1505
|
"Content-Type": "application/json",
|
|
1429
1506
|
"Cache-Control": "no-store",
|
|
@@ -1433,11 +1510,17 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1433
1510
|
return "served";
|
|
1434
1511
|
} catch (e) {
|
|
1435
1512
|
// Signed in + cloud sync on, but the cloud read failed (offline, token
|
|
1436
|
-
// rejected, edge error, or timeout).
|
|
1513
|
+
// rejected, edge error, or timeout). Serve local data rather than
|
|
1514
|
+
// erroring, but say so: this is a *temporary* downgrade and the client
|
|
1515
|
+
// must not treat it as the user's real data scope.
|
|
1516
|
+
const reason = classifyAccountFallback(e);
|
|
1437
1517
|
if (resolveRuntimeConfig().debug) {
|
|
1438
|
-
console.warn(`[LocalAPI] account view fallback for ${usageSlug}:`, e?.message || e);
|
|
1518
|
+
console.warn(`[LocalAPI] account view fallback (${reason}) for ${usageSlug}:`, e?.message || e);
|
|
1439
1519
|
}
|
|
1440
|
-
|
|
1520
|
+
rememberAccountViewFailure(failureKey);
|
|
1521
|
+
return reason;
|
|
1522
|
+
} finally {
|
|
1523
|
+
accountViewInFlight.delete(requestKey);
|
|
1441
1524
|
}
|
|
1442
1525
|
}
|
|
1443
1526
|
|
|
@@ -2006,14 +2089,17 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
2006
2089
|
// --- account (cross-device) view proxy for the native popover ---
|
|
2007
2090
|
// When ?account=1 and the user is signed in with cloud sync on, serve the
|
|
2008
2091
|
// same cross-device aggregate the dashboard shows; otherwise tag the
|
|
2009
|
-
// response (X-TokenTracker-Account-View: 0
|
|
2010
|
-
//
|
|
2092
|
+
// response (X-TokenTracker-Account-View: 0 plus an
|
|
2093
|
+
// X-TokenTracker-Account-Fallback reason) so the popover knows it got local
|
|
2094
|
+
// single-machine data and whether that is permanent (signed out / cloud
|
|
2095
|
+
// sync off) or transient, and fall through to the local handler below.
|
|
2011
2096
|
if (url.searchParams.get("account") === "1") {
|
|
2012
2097
|
const usageSlug = p.startsWith("/functions/") ? p.slice("/functions/".length) : "";
|
|
2013
2098
|
if (accountSlugFor(usageSlug)) {
|
|
2014
2099
|
const result = await tryServeAccountView(usageSlug, url, res);
|
|
2015
2100
|
if (result === "served") return true;
|
|
2016
2101
|
res.setHeader("X-TokenTracker-Account-View", "0");
|
|
2102
|
+
res.setHeader("X-TokenTracker-Account-Fallback", result);
|
|
2017
2103
|
}
|
|
2018
2104
|
}
|
|
2019
2105
|
|
package/src/lib/pricing/index.js
CHANGED
|
@@ -21,6 +21,10 @@ const PI_SUBSCRIPTION_SOURCES = new Set([
|
|
|
21
21
|
"prime-agent-github-copilot",
|
|
22
22
|
"prime-agent-copilot",
|
|
23
23
|
]);
|
|
24
|
+
// LM Studio's server logs describe inference served by the local developer
|
|
25
|
+
// server (including LM Link). Secure Cloud usage has a separate billing path
|
|
26
|
+
// and is not present in these logs.
|
|
27
|
+
const LOCAL_INFERENCE_SOURCES = new Set(["lmstudio"]);
|
|
24
28
|
const SOURCES_WITH_AUTHORITATIVE_COST = new Set(["grok"]);
|
|
25
29
|
const SEED_SNAPSHOT_PATH = path.resolve(__dirname, "seed-snapshot.json");
|
|
26
30
|
const DEEPSEEK_TIME_PRICED_MODELS = [
|
|
@@ -169,6 +173,7 @@ function getRowPricing(row) {
|
|
|
169
173
|
// computeRowCost in src/lib/local-api.js. Moved here so vite mock + local
|
|
170
174
|
// server share one source of truth.
|
|
171
175
|
function computeRowCost(row) {
|
|
176
|
+
if (LOCAL_INFERENCE_SOURCES.has(String(row?.source || "").toLowerCase())) return 0;
|
|
172
177
|
// Pi can route a turn through a subscription-backed Copilot provider. Pi's
|
|
173
178
|
// usage record reports a zero marginal cost for those turns; do not
|
|
174
179
|
// reinterpret the Claude model name as an Anthropic API bill.
|
|
@@ -164,6 +164,20 @@ function normalizeWorkbuddyModel(model) {
|
|
|
164
164
|
return model;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// Unsloth Studio can mix local engines and paid API providers in one database.
|
|
168
|
+
// The parser qualifies metered models with their provider and marks local,
|
|
169
|
+
// subscription-backed, or ambiguous custom routes as unpriced. Returning a
|
|
170
|
+
// sentinel here prevents the generic reverse-substring matcher from mistaking
|
|
171
|
+
// a locally hosted model name for the similarly named cloud SKU.
|
|
172
|
+
function normalizeUnslothModel(model) {
|
|
173
|
+
if (typeof model !== "string") return model;
|
|
174
|
+
const lower = model.trim().toLowerCase();
|
|
175
|
+
if (lower.startsWith("local/") || lower.startsWith("unpriced/")) {
|
|
176
|
+
return "__tokentracker_unpriced_unsloth_model__";
|
|
177
|
+
}
|
|
178
|
+
return model;
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
// Per-source model-name normalizers, applied at pricing-lookup time only (the
|
|
168
182
|
// raw model name is preserved for storage/display). Add a source here when its
|
|
169
183
|
// model strings don't match the LiteLLM/curated keys verbatim.
|
|
@@ -174,6 +188,7 @@ const SOURCE_MODEL_NORMALIZERS = {
|
|
|
174
188
|
"pi-anthropic": normalizeClaudeModel,
|
|
175
189
|
"prime-agent-anthropic": normalizeClaudeModel,
|
|
176
190
|
zed: normalizeZedModel,
|
|
191
|
+
unsloth: normalizeUnslothModel,
|
|
177
192
|
workbuddy: normalizeWorkbuddyModel,
|
|
178
193
|
};
|
|
179
194
|
|