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
@@ -1,6 +1,8 @@
1
1
  const fs = require("node:fs");
2
2
  const os = require("node:os");
3
3
  const path = require("node:path");
4
+ const zlib = require("node:zlib");
5
+ const { promisify } = require("node:util");
4
6
  const { spawn } = require("node:child_process");
5
7
  const crypto = require("node:crypto");
6
8
  const { setImmediate: yieldToRequests } = require("node:timers/promises");
@@ -19,7 +21,9 @@ const { functionUrlFor, fetchFunctionResponse } = require("./function-url");
19
21
  const { createCloudDeviceTokenStore } = require("./cloud-device-token");
20
22
  const { parseBearerToken } = require("./bearer-token");
21
23
  const { writeJsonAtomicSync } = require("./atomic-json");
24
+ const { negotiateEncoding } = require("./accept-encoding");
22
25
  const { createLocalUsageTransfer, MAX_BYTES: LOCAL_USAGE_MAX_BYTES } = require("./local-usage-transfer");
26
+ const { fetchProviderServiceStatus } = require("./provider-status");
23
27
 
24
28
  const SYNC_TIMEOUT_MS = 120_000;
25
29
  // A failed account-view request should not stall every dashboard refresh
@@ -53,6 +57,132 @@ const AVATAR_PROXY_MAX_BYTES = 512 * 1024; // 512 KiB per image
53
57
  const AVATAR_PROXY_MAX_ENTRIES = 64;
54
58
  const avatarProxyCache = new Map();
55
59
 
60
+ // The sessions endpoint serializes ~9MB of row data. Building it twice in a
61
+ // row is pure duplicate work when the sidecar and pricing revision have not
62
+ // changed; keep the exact JSON bytes keyed by both. A short TTL also covers
63
+ // new source session files without requiring a sidecar rebuild; sync's
64
+ // writeAtomic() replaces the sidecar and invalidates immediately. Force-refresh
65
+ // requests bypass reads but still refresh the cache.
66
+ // Keyed by sidecar signature + pricing revision + query, so one entry per
67
+ // (include_buckets, range) variant the dashboard has open. Keep a few variants
68
+ // but bound the total bytes: the bucket-less summary is ~2.9MB raw while the
69
+ // full legacy variant is ~9MB.
70
+ const SESSIONS_RESPONSE_CACHE_MAX = 8;
71
+ const SESSIONS_RESPONSE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
72
+ const SESSIONS_RESPONSE_CACHE_TTL_MS = 60 * 1000;
73
+ const sessionsResponseCache = new Map();
74
+
75
+ function sessionsResponseCacheKey({ sidecarPath, from, to, limit, includeBuckets, includeModelDetail, timeZone, offsetMinutes }) {
76
+ try {
77
+ const stat = fs.statSync(sidecarPath);
78
+ return [
79
+ sidecarPath,
80
+ stat.dev,
81
+ stat.ino,
82
+ stat.size,
83
+ stat.mtimeMs,
84
+ stat.ctimeMs,
85
+ getPricingRevision(),
86
+ from,
87
+ to,
88
+ limit,
89
+ includeBuckets ? 1 : 0,
90
+ includeModelDetail ? 1 : 0,
91
+ timeZone || "",
92
+ Number.isFinite(offsetMinutes) ? offsetMinutes : "",
93
+ ].join("\0");
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function sessionsResponseCacheBytes() {
100
+ let total = 0;
101
+ for (const entry of sessionsResponseCache.values()) {
102
+ total += entry.raw.length + (entry.gzip?.length || 0) + (entry.br?.length || 0);
103
+ }
104
+ return total;
105
+ }
106
+
107
+ function rememberSessionsResponse(key, body) {
108
+ const entry = { raw: body, gzip: null, br: null, expiresAt: Date.now() + SESSIONS_RESPONSE_CACHE_TTL_MS };
109
+ if (!key) return entry;
110
+ // Re-inserting moves the entry to the end, so eviction stays least-recently-used.
111
+ sessionsResponseCache.delete(key);
112
+ sessionsResponseCache.set(key, entry);
113
+ while (sessionsResponseCache.size > SESSIONS_RESPONSE_CACHE_MAX
114
+ || (sessionsResponseCache.size > 1 && sessionsResponseCacheBytes() > SESSIONS_RESPONSE_CACHE_MAX_BYTES)) {
115
+ sessionsResponseCache.delete(sessionsResponseCache.keys().next().value);
116
+ }
117
+ return entry;
118
+ }
119
+
120
+ function getCachedSessionsResponse(key) {
121
+ const entry = sessionsResponseCache.get(key);
122
+ if (!entry) return null;
123
+ if (entry.expiresAt <= Date.now()) {
124
+ sessionsResponseCache.delete(key);
125
+ return null;
126
+ }
127
+ return entry;
128
+ }
129
+
130
+ const gzipAsync = promisify(zlib.gzip);
131
+ const brotliCompressAsync = promisify(zlib.brotliCompress);
132
+
133
+ async function sendSessionsResponse(req, res, entry) {
134
+ const { encoding: accepted, identityAllowed } = negotiateEncoding(req.headers["accept-encoding"]);
135
+ const notAcceptable = () => {
136
+ const message = "Not Acceptable";
137
+ res.writeHead(406, {
138
+ "Content-Type": "text/plain; charset=utf-8",
139
+ "Content-Length": Buffer.byteLength(message),
140
+ "Cache-Control": "no-store",
141
+ "Vary": "Accept-Encoding",
142
+ });
143
+ res.end(message);
144
+ };
145
+ if (!accepted && !identityAllowed) {
146
+ notAcceptable();
147
+ return;
148
+ }
149
+ let body = entry.raw;
150
+ let encoding = null;
151
+ if (entry.raw.length > 32 * 1024 && accepted === "br") {
152
+ try {
153
+ entry.br ||= brotliCompressAsync(entry.raw, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } });
154
+ body = await entry.br;
155
+ encoding = "br";
156
+ } catch (error) {
157
+ entry.br = null;
158
+ console.warn("[local-api] sessions brotli failed, serving identity:", error?.message || error);
159
+ }
160
+ } else if (entry.raw.length > 32 * 1024 && accepted === "gzip") {
161
+ try {
162
+ entry.gzip ||= gzipAsync(entry.raw);
163
+ body = await entry.gzip;
164
+ encoding = "gzip";
165
+ } catch (error) {
166
+ entry.gzip = null;
167
+ console.warn("[local-api] sessions gzip failed, serving identity:", error?.message || error);
168
+ }
169
+ }
170
+ if (!encoding && !identityAllowed) {
171
+ notAcceptable();
172
+ return;
173
+ }
174
+ const headers = {
175
+ "Content-Type": "application/json",
176
+ "Content-Length": body.length,
177
+ "Cache-Control": "no-store",
178
+ "X-Content-Type-Options": "nosniff",
179
+ "Vary": "Accept-Encoding",
180
+ };
181
+ if (encoding) headers["Content-Encoding"] = encoding;
182
+ res.writeHead(200, headers);
183
+ res.end(body);
184
+ }
185
+
56
186
  // ---------------------------------------------------------------------------
57
187
  // Per-model pricing — delegated to src/lib/pricing/
58
188
  // - CURATED overrides (kiro-*, hy3-*, composer-*, kimi-for-coding, etc.)
@@ -64,6 +194,7 @@ const {
64
194
  MODEL_PRICING,
65
195
  getModelPricing,
66
196
  computeRowCost,
197
+ computeRowTierCosts,
67
198
  ensurePricingLoaded,
68
199
  getPricingRevision,
69
200
  getModelPricingInfo,
@@ -99,6 +230,19 @@ const CLAUDE_MEM_OBSERVER_PROJECT_KEY = deriveProjectKeyFromRef(
99
230
  CLAUDE_MEM_OBSERVER_PROJECT_REF,
100
231
  );
101
232
  const PROJECT_USAGE_MAX_ENTRIES = 10;
233
+
234
+ // Speed-tier subset columns surfaced by /usage-model-breakdown (issue 886):
235
+ // the Codex parser persists these on queue rows as annotations of the base
236
+ // columns ("how much of this row came from Fast/Ultrafast requests"), so the
237
+ // model breakdown can sum them per model without re-adding volume.
238
+ const SPEED_TIER_SUBSET_PREFIXES = ["priority_", "ultrafast_"];
239
+ const SPEED_TIER_SUBSET_TOKEN_FIELDS = [
240
+ "input_tokens",
241
+ "cached_input_tokens",
242
+ "cache_creation_input_tokens",
243
+ "output_tokens",
244
+ "reasoning_output_tokens",
245
+ ];
102
246
  const MAX_TIME_ZONE_CACHE_ENTRIES = 16;
103
247
  const AGGREGATION_BATCH_ROWS = 256;
104
248
 
@@ -1675,8 +1819,38 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
1675
1819
  return hasAllowedLoopbackOrigin(req?.headers || {});
1676
1820
  }
1677
1821
 
1822
+ // status.deepseek.com (no public JSON API) and status.opencode.ai (official
1823
+ // API but no CORS headers) are both unreadable from the browser, so the
1824
+ // dashboard probes them through these same-origin relays instead. The
1825
+ // server-side fetch/parse/cache logic lives in provider-status.js (6s
1826
+ // timeout, 5-minute cache, last-good fallback); a failed probe surfaces as
1827
+ // 502 and the card renders Unreachable.
1828
+ const relayProviderStatus = (providerId, label) => async (req, res) => {
1829
+ const method = String(req.method || "GET").toUpperCase();
1830
+ if (method !== "GET" && method !== "HEAD") {
1831
+ json(res, { error: "Method Not Allowed" }, 405);
1832
+ return true;
1833
+ }
1834
+ try {
1835
+ const reading = await fetchProviderServiceStatus(providerId);
1836
+ if (!reading) {
1837
+ json(res, { error: `${label} status unavailable` }, 502);
1838
+ return true;
1839
+ }
1840
+ json(res, {
1841
+ page: { updated_at: reading.updated_at },
1842
+ status: { indicator: reading.indicator, description: reading.description || "" },
1843
+ });
1844
+ } catch (error) {
1845
+ json(res, { error: `${label} status probe error: ${error?.message || error}` }, 502);
1846
+ }
1847
+ return true;
1848
+ };
1849
+
1678
1850
  return async function handleLocalApi(req, res, url) {
1679
1851
  const p = url.pathname;
1852
+ if (p === "/api/deepseek-status") return relayProviderStatus("deepseek", "DeepSeek")(req, res);
1853
+ if (p === "/api/opencode-status") return relayProviderStatus("opencode", "OpenCode")(req, res);
1680
1854
  if (p === "/api/local-usage") {
1681
1855
  if (!isAuthorizedLocalMutation(req)) { json(res, { code: "unauthorized" }, 401); return true; }
1682
1856
  try {
@@ -2582,7 +2756,9 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2582
2756
  const refresh = ["1", "true"].includes(url.searchParams.get("refresh"));
2583
2757
  try {
2584
2758
  const { buildSessionAnalytics, summarizeSessions, sessionsToCsv } = require("./session-analytics");
2585
- const sessions = await buildSessionAnalytics({ force: refresh });
2759
+ // Refresh means "re-walk the session files", not "re-parse everything":
2760
+ // recheck skips the TTL but keeps per-file stat reuse.
2761
+ const sessions = await buildSessionAnalytics({ recheck: refresh });
2586
2762
  const wantsCsv = url.searchParams.get("format") === "csv";
2587
2763
  const includeSessions = wantsCsv || ["1", "true"].includes(url.searchParams.get("include_sessions"));
2588
2764
  const result = summarizeSessions(sessions, { from, to, includeSessions, ...getTimeZoneContext(url) });
@@ -2612,16 +2788,48 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2612
2788
  const refresh = ["1", "true"].includes(url.searchParams.get("refresh"));
2613
2789
  const limitParam = parseInt(url.searchParams.get("limit") || "0", 10);
2614
2790
  const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.min(limitParam, 2000) : 0;
2791
+ const includeBuckets = !["0", "false"].includes(String(url.searchParams.get("include_buckets") || "").toLowerCase());
2792
+ // The list page renders one model name plus a token/cost/performance
2793
+ // triple per model; the remaining per-model counters (~39 of 44 fields)
2794
+ // are not read by the dashboard, so the browser can opt out of them.
2795
+ const includeModelDetail = !["0", "false"].includes(String(url.searchParams.get("include_model_detail") || "").toLowerCase());
2615
2796
  // This payload contains local paths and resumable session identifiers.
2616
2797
  // The server is loopback-only; additionally prevent browser/proxy caches
2617
2798
  // from retaining the response after the page is closed.
2618
2799
  res.setHeader("Cache-Control", "no-store");
2619
2800
  res.setHeader("X-Content-Type-Options", "nosniff");
2620
2801
  try {
2621
- const { buildSessionAnalytics, listSessionsForBrowser } = require("./session-analytics");
2622
- const sessions = await buildSessionAnalytics({ force: refresh });
2623
- const result = listSessionsForBrowser(sessions, { from, to, limit, ...getTimeZoneContext(url) });
2624
- json(res, { from, to, ...result });
2802
+ const { buildSessionAnalytics, listSessionsForBrowser, resolveSessionSidecarPath } = require("./session-analytics");
2803
+ const timeZoneContext = getTimeZoneContext(url);
2804
+ const cacheParams = {
2805
+ sidecarPath: resolveSessionSidecarPath(os.homedir()),
2806
+ from,
2807
+ to,
2808
+ limit,
2809
+ includeBuckets,
2810
+ includeModelDetail,
2811
+ ...timeZoneContext,
2812
+ };
2813
+ const beforeCacheKey = sessionsResponseCacheKey(cacheParams);
2814
+ const cacheKey = refresh ? null : beforeCacheKey;
2815
+ const cached = cacheKey ? getCachedSessionsResponse(cacheKey) : null;
2816
+ if (cached) {
2817
+ await sendSessionsResponse(req, res, cached);
2818
+ return true;
2819
+ }
2820
+ const sessions = await buildSessionAnalytics({ recheck: refresh });
2821
+ const result = listSessionsForBrowser(sessions, { from, to, limit, includeBuckets, includeModelDetail, ...timeZoneContext });
2822
+ const afterBuildCacheKey = sessionsResponseCacheKey(cacheParams);
2823
+ const body = JSON.stringify({ from, to, ...result });
2824
+ const afterSerializeCacheKey = sessionsResponseCacheKey(cacheParams);
2825
+ const incomplete = Boolean(sessions.sidecarReadFailed) ||
2826
+ (Array.isArray(sessions.incompleteDirs) && sessions.incompleteDirs.length > 0) ||
2827
+ result.skipped_files !== 0;
2828
+ const cacheable = Boolean(afterBuildCacheKey) &&
2829
+ afterBuildCacheKey === afterSerializeCacheKey &&
2830
+ !incomplete;
2831
+ const entry = rememberSessionsResponse(cacheable ? afterBuildCacheKey : null, Buffer.from(body));
2832
+ await sendSessionsResponse(req, res, entry);
2625
2833
  } catch (error) {
2626
2834
  // Node fs errors embed the absolute path ("EACCES ... open '/Users/…'").
2627
2835
  // Keep that out of the HTTP body and log it locally instead.
@@ -2718,7 +2926,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2718
2926
  const src = row.source || "unknown";
2719
2927
  const mdl = row.model || "unknown";
2720
2928
  if (!bySource.has(src))
2721
- bySource.set(src, { source: src, source_scope: getSourceScope(src), totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, total_cost_usd: "0" }, models: new Map() });
2929
+ bySource.set(src, { source: src, source_scope: getSourceScope(src), totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, conversation_count: 0, total_cost_usd: "0" }, models: new Map() });
2722
2930
  const sa = bySource.get(src);
2723
2931
  sa.totals.total_tokens += row.total_tokens || 0;
2724
2932
  sa.totals.billable_total_tokens += row.billable_total_tokens ?? row.total_tokens ?? 0;
@@ -2727,8 +2935,12 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2727
2935
  sa.totals.cached_input_tokens += row.cached_input_tokens || 0;
2728
2936
  sa.totals.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
2729
2937
  sa.totals.reasoning_output_tokens += row.reasoning_output_tokens || 0;
2938
+ // Request-only sources (#948: credit-billed transcripts that report no
2939
+ // tokens) carry their activity solely as conversation_count — thread it
2940
+ // through so the dashboard can surface "0 tokens / N requests".
2941
+ sa.totals.conversation_count += row.conversation_count || 0;
2730
2942
  if (!sa.models.has(mdl))
2731
- sa.models.set(mdl, { model: mdl, model_id: mdl, pricing: getModelPricingInfo(mdl, { source: src }), totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, total_cost_usd: "0" } });
2943
+ sa.models.set(mdl, { model: mdl, model_id: mdl, pricing: getModelPricingInfo(mdl, { source: src }), totals: { total_tokens: 0, billable_total_tokens: 0, input_tokens: 0, output_tokens: 0, cached_input_tokens: 0, cache_creation_input_tokens: 0, reasoning_output_tokens: 0, conversation_count: 0, total_cost_usd: "0" } });
2732
2944
  const ma = sa.models.get(mdl);
2733
2945
  const reportedCost = ["grok", "cline"].includes(src) && Number(row.total_cost_usd) > 0;
2734
2946
  const costSource = reportedCost ? "provider_reported" : "model_pricing";
@@ -2740,6 +2952,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2740
2952
  ma.totals.cached_input_tokens += row.cached_input_tokens || 0;
2741
2953
  ma.totals.cache_creation_input_tokens += row.cache_creation_input_tokens || 0;
2742
2954
  ma.totals.reasoning_output_tokens += row.reasoning_output_tokens || 0;
2955
+ ma.totals.conversation_count += row.conversation_count || 0;
2743
2956
  // Price each row, exactly as aggregateByDay does for the hero (#653).
2744
2957
  // computeRowCost is not linear for models with a Fast tier or
2745
2958
  // long-context rates: priority_* / long_context_* mark a subset of the
@@ -2747,6 +2960,18 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2747
2960
  // row is intact. Summing the base columns first and pricing once silently
2748
2961
  // dropped every premium — same tokens, fewer dollars than the hero.
2749
2962
  ma.totals.total_cost_usd = Number(ma.totals.total_cost_usd || 0) + computeRowCost(row);
2963
+ // Surface the Fast/Ultrafast split (issue 886): per-tier cost allocation
2964
+ // plus the observed subset token columns. Tier costs and the implicit
2965
+ // standard tier (total minus both) always add up to total_cost_usd.
2966
+ const tierCosts = computeRowTierCosts(row);
2967
+ ma.totals.priority_cost_usd = Number(ma.totals.priority_cost_usd || 0) + tierCosts.priority;
2968
+ ma.totals.ultrafast_cost_usd = Number(ma.totals.ultrafast_cost_usd || 0) + tierCosts.ultrafast;
2969
+ for (const prefix of SPEED_TIER_SUBSET_PREFIXES) {
2970
+ for (const field of SPEED_TIER_SUBSET_TOKEN_FIELDS) {
2971
+ const key = `${prefix}${field}`;
2972
+ ma.totals[key] = (ma.totals[key] || 0) + (Number(row[key]) || 0);
2973
+ }
2974
+ }
2750
2975
  }
2751
2976
  if (pricingRevision !== getPricingRevision()) return handleLocalApi(req, res, url);
2752
2977
 
@@ -2754,7 +2979,12 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2754
2979
  s.models = Array.from(s.models.values())
2755
2980
  .map((m) => ({
2756
2981
  ...m,
2757
- totals: { ...m.totals, total_cost_usd: Number(m.totals.total_cost_usd || 0).toFixed(6) },
2982
+ totals: {
2983
+ ...m.totals,
2984
+ total_cost_usd: Number(m.totals.total_cost_usd || 0).toFixed(6),
2985
+ priority_cost_usd: Number(m.totals.priority_cost_usd || 0).toFixed(6),
2986
+ ultrafast_cost_usd: Number(m.totals.ultrafast_cost_usd || 0).toFixed(6),
2987
+ },
2758
2988
  }))
2759
2989
  .sort((a, b) => b.totals.total_tokens - a.totals.total_tokens);
2760
2990
  const sourceCost = s.models.reduce((sum, m) => sum + Number(m.totals.total_cost_usd), 0);
@@ -3672,7 +3902,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
3672
3902
 
3673
3903
  // --- usage-limits ---
3674
3904
  if (p === "/functions/tokentracker-usage-limits") {
3675
- const { getUsageLimits, resetUsageLimitsCache } = require("./usage-limits");
3905
+ const { getUsageLimits, resetUsageLimitsCache, normalizeHiddenProviders } = require("./usage-limits");
3676
3906
  try {
3677
3907
  // Devin quota is opt-in (Settings > Usage & Limits > Providers). An
3678
3908
  // explicit devin=1 without local authentication is rejected before any
@@ -3683,7 +3913,14 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
3683
3913
  const localAuthorized = isAuthorizedLocalMutation(req);
3684
3914
  const devinParam = url.searchParams.get("devin");
3685
3915
  const devinEnabled = devinParam === "1" || devinParam === "true";
3686
- if (devinEnabled && !localAuthorized) {
3916
+ // Provider visibility gate (#956): hidden=<csv> names provider rows to
3917
+ // skip entirely — no upstream call, no credential read. Like devin=1,
3918
+ // an explicit list is honored only together with the loopback auth
3919
+ // header (letting an unauthenticated caller scope the fetch would let
3920
+ // it pick exactly which credentials to leave alone). Unknown ids are
3921
+ // dropped by the normalizer, so the gate only ever narrows.
3922
+ const hiddenProviders = normalizeHiddenProviders(url.searchParams.get("hidden"));
3923
+ if ((devinEnabled || hiddenProviders) && !localAuthorized) {
3687
3924
  json(res, { error: "Unauthorized" }, 401);
3688
3925
  return true;
3689
3926
  }
@@ -3701,6 +3938,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
3701
3938
  // cooldown) — an explicit user refresh should hit upstream.
3702
3939
  forceRefresh,
3703
3940
  devinEnabled,
3941
+ hiddenProviders,
3704
3942
  });
3705
3943
  json(res, data);
3706
3944
  } catch (e) {
@@ -0,0 +1,88 @@
1
+ // OpenCode service-status probe.
2
+ //
3
+ // status.opencode.ai/api/status is OpenCode's official status feed — a custom
4
+ // JSON schema served without CORS headers, so the browser cannot read it and
5
+ // the local app relays it. Shape (verified against the live feed 2026-11-01):
6
+ // { updatedAt: <ms epoch>, since, stale, status: "operational",
7
+ // components: [{ service, status, reason, models, breakdown, speed }, ...] }
8
+ //
9
+ // Mapping to the Statuspage.io contract:
10
+ // operational -> none
11
+ // degraded / maintenance -> minor
12
+ // partial_outage -> major
13
+ // major / full outage -> critical
14
+ // Unknown non-operational statuses degrade to minor: report "something is
15
+ // wrong" rather than a false green. The reading is the worst of the top-level
16
+ // status and every component; anything we cannot parse throws, so callers fall
17
+ // back to last-good/unknown instead of inventing a false "operational".
18
+
19
+ const OPENCODE_STATUS_TO_INDICATOR = {
20
+ operational: "none",
21
+ degraded: "minor",
22
+ degraded_performance: "minor",
23
+ maintenance: "minor",
24
+ under_maintenance: "minor",
25
+ partial_outage: "major",
26
+ major_outage: "critical",
27
+ full_outage: "critical",
28
+ outage: "critical",
29
+ };
30
+
31
+ const INDICATOR_RANK = { none: 0, minor: 1, major: 2, critical: 3 };
32
+
33
+ function statusToIndicator(status) {
34
+ return OPENCODE_STATUS_TO_INDICATOR[String(status || "").toLowerCase()] || "minor";
35
+ }
36
+
37
+ /**
38
+ * Parse the api/status payload into the Statuspage.io shape:
39
+ * { status: { indicator, description }, page: { updated_at } }
40
+ * Throws when the payload is not understood.
41
+ */
42
+ function parseOpenCodeStatusPayload(payload) {
43
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
44
+ throw new Error("OpenCode status: expected a JSON object");
45
+ }
46
+ if (typeof payload.status !== "string" || payload.status.length === 0) {
47
+ throw new Error("OpenCode status: missing top-level status string");
48
+ }
49
+ const components = payload.components === undefined ? [] : payload.components;
50
+ if (!Array.isArray(components)) {
51
+ throw new Error("OpenCode status: components is not an array");
52
+ }
53
+
54
+ let worstIndicator = statusToIndicator(payload.status);
55
+ let worstRank = INDICATOR_RANK[worstIndicator];
56
+ const affected = [];
57
+ for (const component of components) {
58
+ // A null/primitive entry is not skippable: it is malformed, and swallowing
59
+ // it would let an otherwise-operational payload read green (the header
60
+ // contract throws rather than inventing "operational").
61
+ if (!component || typeof component !== "object" ||
62
+ typeof component.service !== "string" || typeof component.status !== "string") {
63
+ throw new Error("OpenCode status: malformed component entry");
64
+ }
65
+ const indicator = statusToIndicator(component.status);
66
+ if (INDICATOR_RANK[indicator] > worstRank) {
67
+ worstRank = INDICATOR_RANK[indicator];
68
+ worstIndicator = indicator;
69
+ }
70
+ if (indicator !== "none" && component.service) affected.push(component.service);
71
+ }
72
+
73
+ const updatedAt = typeof payload.updatedAt === "number" && Number.isFinite(payload.updatedAt) && payload.updatedAt > 0
74
+ ? new Date(payload.updatedAt).toISOString()
75
+ : undefined;
76
+
77
+ return {
78
+ status: {
79
+ indicator: worstIndicator,
80
+ description: affected.join("; "),
81
+ },
82
+ page: { updated_at: updatedAt },
83
+ };
84
+ }
85
+
86
+ module.exports = {
87
+ parseOpenCodeStatusPayload,
88
+ };