tokentracker-cli 1.5.14 → 1.5.16

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 (119) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-DqJWve1h.js → AchievementBadge-CnnJGct8.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-CnuBayj3.js → AchievementsPage-CFkUJVxy.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-WRU5TxAB.js → AchievementsSection-CoLQX04M.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-B5ikEQik.js → BadgeDetailModal-s8rObEHY.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-CaMG6j-6.js → BotAnimated-BmBnL-i8.js} +1 -1
  6. package/dashboard/dist/assets/{Card-BL3vxENk.js → Card-BIqie5mN.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-CsUb8CJ7.js → CloudCheckoutPage-B_c8Q58A.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-5ZiPFzeC.js → CloudPage-CfW_pDf2.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-CYd9nXW-.js → CommandPalette-BRryGPVJ.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-BItox7Sz.js → CommunityStatsModal-Cyh1Q9Fw.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-BE1ABR4z.js → ConfirmModal-DWBYqpLJ.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-BRqIsaHT.js → Controls-CPSiCzVW.js} +1 -1
  13. package/dashboard/dist/assets/{DashboardPage-Cm0YuBlx.js → DashboardPage-JcS1xwUm.js} +1 -1
  14. package/dashboard/dist/assets/{DateRangePopover-CRwEu66e.js → DateRangePopover-DjuS6888.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-DFUPsGb_.js → DevicePage-DZ3qiDbT.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-DALl4GmP.js → DialogClose-CfORRnoT.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-ChihKLnm.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-BSR8ozyn.js → DialogTitle-DZO6-sTI.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-BtrycBUW.js → ElectricLogo-BzR7mRin.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-CfhgvLVd.js → FadeIn-BxHaUvz_.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-N8wqAdOf.js → HeaderGithubStar-KGzKAmuQ.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-CwdMYIzZ.js → HoverTooltip-C6pASVDN.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-DCRor9cb.js → IpCheckPage-BJ8TROZC.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-s9xvTHSp.js → LandingPage-B15GRFpI.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-CUj3WLMo.js → LeaderboardPage-C3hJ2Gh3.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-N4NOKZWj.js → LeaderboardProBadge-CRfBTxpt.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-09HiUyxm.js → LeaderboardProfileModal-DTGmcjWF.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-Cw9_rodG.js → LeaderboardProfilePage-BD04D7ML.js} +1 -1
  29. package/dashboard/dist/assets/{LimitsPage-F8b6kZa0.js → LimitsPage-LsDCIUKd.js} +1 -1
  30. package/dashboard/dist/assets/{LocalOnlyNotice-D1eEzDEv.js → LocalOnlyNotice-B3XQKD8k.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-0sIovG_J.js → LoginCard-DF1XcskU.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-BT3oMVak.js → LoginModal-DuyVT0Tt.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-CDsXdaE5.js → LoginPage-kge4kJKa.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-BEnC1azp.js → PetPage-DuYtsKza.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-Dwzp-hyf.js → PopoverPopup-BkNNS3VG.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-Cu_lcuuu.js → ProviderIcon-z2TKXPlS.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-DrpEscm1.js → ResetPasswordPage-CXM9E6d1.js} +1 -1
  38. package/dashboard/dist/assets/{Select-wIR4qvEI.js → Select-CeoAayzi.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-CNuJgYps.js → SelectItemText-CRxvAgUV.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-CWNLygpL.js → SelfHostPage-Cavstn-d.js} +1 -1
  41. package/dashboard/dist/assets/{ServiceStatusPage-BId9t16Q.js → ServiceStatusPage-CdIeM_DX.js} +1 -1
  42. package/dashboard/dist/assets/{SessionsPage-C_klx7d8.js → SessionsPage-DPihAS26.js} +1 -1
  43. package/dashboard/dist/assets/{SettingsPage-nC73JaZ4.js → SettingsPage-zxOsLeE5.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-B2agou8Y.js → SkillsPage-BRGonzsC.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-BNXUWRwa.js → TokenGalaxy-D9-huQJ_.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup--X2kExQ0.js → TooltipPopup-BS99Tf44.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-BELHsTnz.js → TrendMonitor-DlNT4ObY.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-DWp0SMXA.js → WidgetsPage-DTFnmTwq.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-DgwczKHe.js → WrappedPage-DwMdnusQ.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-BHphZRSC.js → __vite-browser-external-DR_a-OTS.js} +2 -2
  51. package/dashboard/dist/assets/{agent-logos-Cfx5WzU5.js → agent-logos-D6kTooyz.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-left-D83SdANw.js → arrow-left-jpuJhYL4.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-right-D7ye9bmJ.js → arrow-right-n1hLkwiK.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-CLmwEwwC.js → arrow-up-right-fZmEfh7R.js} +1 -1
  55. package/dashboard/dist/assets/{calendar-days-D1qLlvh4.js → calendar-days-B0LdECvD.js} +1 -1
  56. package/dashboard/dist/assets/{check-BHJvi4ey.js → check-CcnNd8bi.js} +1 -1
  57. package/dashboard/dist/assets/{chevron-down-6RBPKNpt.js → chevron-down-DhbHBjVC.js} +1 -1
  58. package/dashboard/dist/assets/{cloud-checkout-DgWcF-at.js → cloud-checkout-9XOoux-p.js} +1 -1
  59. package/dashboard/dist/assets/{composite-D-0pfyMY.js → composite-DIDAkYhy.js} +1 -1
  60. package/dashboard/dist/assets/{copy-B6mJSQsD.js → copy-BNDBunFG.js} +1 -1
  61. package/dashboard/dist/assets/{download-D31Nn-bI.js → download-wlqdqJmq.js} +1 -1
  62. package/dashboard/dist/assets/{external-link-C_lK9S3p.js → external-link-BvssZ_45.js} +1 -1
  63. package/dashboard/dist/assets/{flame-1ZxhHshT.js → flame-DP8IUT3E.js} +1 -1
  64. package/dashboard/dist/assets/{format-tokens-DJk25SkD.js → format-tokens-DMsx_fWk.js} +1 -1
  65. package/dashboard/dist/assets/{gift-BUZzFh-1.js → gift-zF4D6O4a.js} +1 -1
  66. package/dashboard/dist/assets/{icons-BVPThCa4.js → icons-Batw5aOg.js} +1 -1
  67. package/dashboard/dist/assets/{index-B-mxrdIz.js → index-3qKpTOzz.js} +1 -1
  68. package/dashboard/dist/assets/{index-CjPsyTl7.js → index-Cqla2Zq5.js} +1 -1
  69. package/dashboard/dist/assets/{index-C0zFsOWh.js → index-Dc8PQ8ZR.js} +1 -1
  70. package/dashboard/dist/assets/{index-CzstoU6d.js → index-dCfimErX.js} +1 -1
  71. package/dashboard/dist/assets/{info-DmU-vlJ6.js → info-DcUyOPKG.js} +1 -1
  72. package/dashboard/dist/assets/{link-2-BlBM7uw7.js → link-2-CS-cWLmx.js} +1 -1
  73. package/dashboard/dist/assets/{loader-circle-Dhrb_Ocy.js → loader-circle-DKc-gwgL.js} +1 -1
  74. package/dashboard/dist/assets/{local-usage-transfer-BudmMtkd.js → local-usage-transfer-bHATORmq.js} +1 -1
  75. package/dashboard/dist/assets/{modifiers.esm-kmw1VM8W.js → modifiers.esm-B4QifJzx.js} +1 -1
  76. package/dashboard/dist/assets/{monitor-smartphone-DtjXNC_x.js → monitor-smartphone-D8vpN9Jv.js} +1 -1
  77. package/dashboard/dist/assets/{play-9w46lbjy.js → play-D70P7DVT.js} +1 -1
  78. package/dashboard/dist/assets/{plus-DAf_WGKT.js → plus-YomnSdqY.js} +1 -1
  79. package/dashboard/dist/assets/{provider-display-BZ0XlB2q.js → provider-display-VXu7Hzwq.js} +1 -1
  80. package/dashboard/dist/assets/{react-B01ZJuZY.js → react-CUO9Gs2G.js} +1 -1
  81. package/dashboard/dist/assets/{safePolygon-B6_x12Gb.js → safePolygon-CbVKM3Lv.js} +1 -1
  82. package/dashboard/dist/assets/{search-D3dW9GbV.js → search-BUi1hAmV.js} +1 -1
  83. package/dashboard/dist/assets/{server-C2XZBar7.js → server-B-agbJYI.js} +1 -1
  84. package/dashboard/dist/assets/{shield-check-4FX5vbS7.js → shield-check-yHx3fQDo.js} +1 -1
  85. package/dashboard/dist/assets/{skills-api-BvW_MUyg.js → skills-api-DojEO2dS.js} +1 -1
  86. package/dashboard/dist/assets/{sortable.esm-CAa_BJgh.js → sortable.esm-r4Lz4ugj.js} +1 -1
  87. package/dashboard/dist/assets/{store-2AX2MNkz.js → store-GzQbKyT-.js} +1 -1
  88. package/dashboard/dist/assets/{terminal-CON7n_ir.js → terminal-BLCZgZTm.js} +1 -1
  89. package/dashboard/dist/assets/{trash-C2f88jSi.js → trash-BFGtCBJS.js} +1 -1
  90. package/dashboard/dist/assets/{use-community-stats-CgQu43xV.js → use-community-stats-DGdLAa8L.js} +1 -1
  91. package/dashboard/dist/assets/{use-latest-request-guard-lShM46-3.js → use-latest-request-guard-l39bM4s0.js} +1 -1
  92. package/dashboard/dist/assets/{use-limits-display-prefs-8o7pbEg0.js → use-limits-display-prefs-JcWgMNA0.js} +1 -1
  93. package/dashboard/dist/assets/{use-native-settings-dN8shG3M.js → use-native-settings-0sXFw6mq.js} +1 -1
  94. package/dashboard/dist/assets/{use-ordered-list-beJy2IqQ.js → use-ordered-list-mRdyZ18r.js} +1 -1
  95. package/dashboard/dist/assets/use-reduced-motion-D2g3iGbi.js +1 -0
  96. package/dashboard/dist/assets/{use-transform-cJpAQr2d.js → use-transform-CbJqMa_Y.js} +1 -1
  97. package/dashboard/dist/assets/{use-usage-limits-CuusZA6b.js → use-usage-limits-qQKS4sHR.js} +1 -1
  98. package/dashboard/dist/assets/{useCurrency-Cx9UsKJK.js → useCurrency-BJyZCVtd.js} +1 -1
  99. package/dashboard/dist/assets/{usePositioner-TEClK6mF.js → usePositioner-zsiJqVGU.js} +1 -1
  100. package/dashboard/dist/assets/{useTokenFormat-BNwTLiac.js → useTokenFormat-DxEKSp5H.js} +1 -1
  101. package/dashboard/dist/assets/{zap-CFgbVEhC.js → zap-D40-8VPk.js} +1 -1
  102. package/dashboard/dist/index.html +1 -1
  103. package/dashboard/dist/ip-check.html +1 -1
  104. package/dashboard/dist/leaderboard.html +1 -1
  105. package/dashboard/dist/share.html +1 -1
  106. package/package.json +1 -1
  107. package/src/lib/ark-agent-plan-limits.js +26 -55
  108. package/src/lib/ark-coding-plan-limits.js +31 -62
  109. package/src/lib/cloud-account.js +29 -3
  110. package/src/lib/limits-cache.js +475 -0
  111. package/src/lib/local-api.js +92 -5
  112. package/src/lib/pricing/curated-overrides.json +32 -0
  113. package/src/lib/pricing/matcher.js +4 -1
  114. package/src/lib/pricing/seed-snapshot.json +1 -1
  115. package/src/lib/qoder-limits.js +35 -69
  116. package/src/lib/rollout.js +43 -47
  117. package/src/lib/usage-limits.js +370 -1063
  118. package/dashboard/dist/assets/DialogDescription-CO0hykis.js +0 -1
  119. package/dashboard/dist/assets/use-reduced-motion-S0bR_G1B.js +0 -1
@@ -0,0 +1,475 @@
1
+ "use strict";
2
+
3
+ // Shared last-good disk-cache pipeline for usage-limits providers (issue #941).
4
+ //
5
+ // Fifteen providers shipped their own copy of the same pipeline: a cache file
6
+ // under ~/.tokentracker/tracker/, written only on a successful live read
7
+ // (tmp + rename, cached_at stamped), read back only when the live read failed
8
+ // with a transient error and every cached window still lies in the future.
9
+ // The copies differed almost exclusively in three parameters — the identity
10
+ // partition key, the field/window list, and the TTL gates — so this module
11
+ // folds the pipeline into one factory plus the storage primitives. Per the
12
+ // umbrella issue the UI already renders stale results on every surface, so a
13
+ // provider adopting this helper needs no UI change.
14
+ //
15
+ // Red lines every parameterization must keep (issue #941):
16
+ // 1. actionable auth/config errors punch through — the cache never masks them;
17
+ // 2. a window whose reset has already rolled over is never served as current;
18
+ // 3. windows without a reset stamp are bounded by cached_at + a TTL
19
+ // (the global max-age, or an explicit unknownResetTtlMs);
20
+ // 4. multi-identity providers partition the cache so one account's bars can
21
+ // never be pinned by another's entry.
22
+
23
+ const fs = require("node:fs");
24
+ const os = require("node:os");
25
+ const path = require("node:path");
26
+
27
+ // Clock skew tolerated when a cache stamp lies slightly in the future (copied
28
+ // files, VM pauses): up to one minute past `now` still counts as usable.
29
+ const FUTURE_STAMP_SKEW_MS = 60_000;
30
+
31
+ // ISO-8601 strings only (the shape every provider stamps via
32
+ // `new Date(ms).toISOString()`). Returns ms, or null when absent/unparseable.
33
+ function parseTimeMs(value) {
34
+ if (typeof value !== "string" || !value) return null;
35
+ const ts = Date.parse(value);
36
+ return Number.isFinite(ts) && ts > 0 ? ts : null;
37
+ }
38
+
39
+ function resolveTrackerDir({ home, trackerDir } = {}) {
40
+ if (typeof trackerDir === "string" && trackerDir.trim()) return trackerDir.trim();
41
+ return path.join(home || os.homedir(), ".tokentracker", "tracker");
42
+ }
43
+
44
+ // Reads and parses a cache file, collapsing every I/O / JSON error into null —
45
+ // a broken cache file must degrade to "no cache", never to a thrown error.
46
+ function readCacheJson(cachePath) {
47
+ try {
48
+ return JSON.parse(fs.readFileSync(cachePath, "utf8"));
49
+ } catch (_error) {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ // Atomic write: write a same-directory tmp file, then rename over the target.
55
+ // Readers therefore see either the previous or the next payload, never a torn
56
+ // one. Failures are swallowed by design — a cache write must never turn a
57
+ // successful live read into a failed poll.
58
+ function writeCacheJsonAtomic(cachePath, payload) {
59
+ try {
60
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
61
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
62
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
63
+ fs.renameSync(tmpPath, cachePath);
64
+ } catch (_error) {}
65
+ }
66
+
67
+ function clearCacheFile(cachePath) {
68
+ try {
69
+ fs.rmSync(cachePath, { force: true });
70
+ } catch (_error) {}
71
+ }
72
+
73
+ /**
74
+ * Builds the read/write/clear facade for one provider's last-good cache.
75
+ *
76
+ * Options:
77
+ * file filename, or ({ namespace }) => filename for
78
+ * namespace-partitioned files (qoder / qoder-cn).
79
+ * key payload wrapper key ("kimi" → `{ kimi: {...} }`);
80
+ * null stores the entry flat at the top level (ark).
81
+ * fields { name: kind } — kinds: "string" (write `|| null`,
82
+ * read typeof check), "number" (Number.isFinite both
83
+ * sides), "raw" (`?? null` both sides), "boolTrue"
84
+ * (`=== true` both sides). A field may instead be
85
+ * { read, write } functions for bespoke shapes
86
+ * (claude's weekly_scoped array). A kind may also
87
+ * declare `survivor(value, ctx)` — content counting as
88
+ * window survivors for the write guard and the read
89
+ * survivor gate (claude's scoped-only snapshots).
90
+ * windowKeys object windows gated per-window by their reset stamp;
91
+ * dead windows become null (or vanish — sparseWindowKeys).
92
+ * sparseWindowKeys window keys omitted from the payload entirely when
93
+ * absent/dead (qoder's tertiary_window), instead of
94
+ * being written/read as null.
95
+ * windowResetField per-window reset field name ("reset_at"; claude uses
96
+ * "resets_at").
97
+ * resetTimeMode "iso" (default) | "unix-seconds" (codex).
98
+ * maxAgeMs global fresh-keeping bound; number, (rawEntry) => number
99
+ * for source-dependent bounds (opencode-go), or null for
100
+ * no global gate (ark — undated windows ride
101
+ * unknownResetTtlMs alone). Default: 7 days.
102
+ * maxAgeGate "always" (reject old snapshots up front) |
103
+ * "ifNoDatedSurvivor" (qoder: a snapshot with a
104
+ * future-dated window outlives the global max-age; the
105
+ * gate then only bounds undated survivors).
106
+ * unknownResetTtlMs keep-without-reset bound for windows carrying no reset
107
+ * stamp (12h where used); default: keep (the global
108
+ * max-age already bounds them).
109
+ * stale true (fallback reads — the standard annotation) |
110
+ * false | "byAge" (stale = older than freshTtlMs, qoder)
111
+ * | null (omit the field entirely, antigravity).
112
+ * freshTtlMs required by stale: "byAge".
113
+ * cachedFlag include `cached: true` in reads (antigravity/qoder/ark).
114
+ * source read-side provenance for `source`:
115
+ * { const: "disk-cache" } (ark) | "provenance" (qoder —
116
+ * fresh hits re-serve the written source, older hits
117
+ * report "disk-cache"; #955). The field is written from
118
+ * record.source as a plain string-or-null.
119
+ * identityField entry field holding the partition key ("identity";
120
+ * ark: "profile_identity"). null = unpartitioned.
121
+ * identityRequired true: a read/write without an identity is refused and a
122
+ * stored identity must match (cursor-family, fail-closed).
123
+ * false: mismatch only rejects when the caller could
124
+ * establish an identity at all (ark, fail-open).
125
+ * writeGuard (record) => bool; default: configured, error-free, and
126
+ * carrying at least one window. Ark overrides (its
127
+ * normalizer already threw on windowless payloads).
128
+ * dirOverride option name overriding the tracker dir
129
+ * ("trackerDir", opencode-go).
130
+ */
131
+ function createLimitsCache(options) {
132
+ const {
133
+ file,
134
+ key,
135
+ fields = {},
136
+ windowKeys = [],
137
+ sparseWindowKeys = [],
138
+ windowResetField = "reset_at",
139
+ resetTimeMode = "iso",
140
+ maxAgeMs = 7 * 24 * 60 * 60 * 1000,
141
+ maxAgeGate = "always",
142
+ unknownResetTtlMs = null,
143
+ stale = true,
144
+ freshTtlMs = null,
145
+ cachedFlag = false,
146
+ source = null,
147
+ identityField = null,
148
+ identityRequired = false,
149
+ writeGuard = null,
150
+ dirOverride = null,
151
+ } = options;
152
+
153
+ // Fields whose content can keep a snapshot usable even when no windowKey
154
+ // does (claude's weekly_scoped array). A kind declares a `survivor(value,
155
+ // ctx) => bool` hook — ctx mirrors the fields.read context — applied to the
156
+ // record on the write guard and to the raw entry on the read survivor gate,
157
+ // so scoped-only snapshots live and expire through the same predicate.
158
+ const survivorFields = Object.entries(fields).filter(
159
+ ([, kind]) => typeof kind?.survivor === "function",
160
+ );
161
+
162
+ if (stale === "byAge" && !Number.isFinite(freshTtlMs)) {
163
+ throw new Error("limits-cache: stale \"byAge\" requires freshTtlMs");
164
+ }
165
+
166
+ function cachePath({ home, namespace, trackerDir } = {}) {
167
+ const name = typeof file === "function" ? file({ namespace }) : file;
168
+ return path.join(resolveTrackerDir({ home, trackerDir: dirOverride ? trackerDir : undefined }), name);
169
+ }
170
+
171
+ function hasWindow(record, { nowMs = Date.now() } = {}) {
172
+ if (windowKeys.some((windowKey) => Boolean(record?.[windowKey]))) return true;
173
+ return survivorFields.some(([field, kind]) => Boolean(
174
+ kind.survivor(record?.[field], { nowMs, cachedAtMs: null, isWindowUsable }),
175
+ ));
176
+ }
177
+
178
+ function shouldWrite(record, { nowMs } = {}) {
179
+ if (writeGuard) return writeGuard(record);
180
+ return Boolean(record?.configured) && !record.error && hasWindow(record, { nowMs });
181
+ }
182
+
183
+ // A window whose reset has already passed is stale data, not a usable
184
+ // fallback — the quota rolled over, so serving its old usage would mislead.
185
+ // Windows without a parseable stamp are kept unless unknownResetTtlMs
186
+ // bounds them by the snapshot's age instead.
187
+ function isWindowUsable(window, { cachedAtMs, nowMs } = {}) {
188
+ if (!window || typeof window !== "object") return false;
189
+ let resetAtMs;
190
+ if (resetTimeMode === "unix-seconds") {
191
+ const resetAt = Number(window[windowResetField]);
192
+ resetAtMs = Number.isFinite(resetAt) && resetAt > 0 ? resetAt * 1000 : null;
193
+ } else {
194
+ resetAtMs = parseTimeMs(window[windowResetField]);
195
+ }
196
+ if (resetAtMs !== null) return resetAtMs > nowMs;
197
+ if (unknownResetTtlMs !== null) {
198
+ return Number.isFinite(cachedAtMs) && nowMs - cachedAtMs <= unknownResetTtlMs;
199
+ }
200
+ return true;
201
+ }
202
+
203
+ function writeField(field, kind, record) {
204
+ if (typeof kind === "object" && kind !== null && typeof kind.write === "function") {
205
+ return kind.write(record);
206
+ }
207
+ const value = record?.[field];
208
+ switch (kind) {
209
+ case "string":
210
+ return value || null;
211
+ case "number":
212
+ return Number.isFinite(value) ? value : null;
213
+ case "raw":
214
+ return value ?? null;
215
+ case "boolTrue":
216
+ return value === true;
217
+ default:
218
+ return value ?? null;
219
+ }
220
+ }
221
+
222
+ function readField(field, kind, raw, ctx) {
223
+ if (typeof kind === "object" && kind !== null && typeof kind.read === "function") {
224
+ return kind.read(raw, ctx);
225
+ }
226
+ const value = raw?.[field];
227
+ switch (kind) {
228
+ case "string":
229
+ return typeof value === "string" ? value : null;
230
+ case "number":
231
+ return Number.isFinite(value) ? value : null;
232
+ case "raw":
233
+ return value ?? null;
234
+ case "boolTrue":
235
+ return value === true;
236
+ default:
237
+ return value ?? null;
238
+ }
239
+ }
240
+
241
+ // Core validator: cached_at gates, identity gate, then the per-window reset
242
+ // gates. Returns the servable record or null — a cache with no surviving
243
+ // window must never pin an error-shaped result in place.
244
+ function normalizeEntry(raw, { nowMs = Date.now(), identity, maxAgeMs: maxAgeOverride, stale: staleOverride } = {}) {
245
+ const cachedAtMs = parseTimeMs(raw?.cached_at);
246
+ if (!Number.isFinite(cachedAtMs)) return null;
247
+ if (cachedAtMs > nowMs + FUTURE_STAMP_SKEW_MS) return null;
248
+
249
+ if (identityField && identityRequired && !identity) return null;
250
+ if (identityField && identity && raw?.[identityField] !== identity) return null;
251
+
252
+ const effectiveMaxAge = typeof maxAgeMs === "function"
253
+ ? maxAgeMs(raw)
254
+ : maxAgeMs;
255
+
256
+ // Per-window gates first; dropped windows become null (or vanish for
257
+ // sparse keys) so a partially-dead snapshot still serves its survivors.
258
+ const windows = {};
259
+ let hasSurvivor = false;
260
+ let hasDatedSurvivor = false;
261
+ for (const windowKey of windowKeys) {
262
+ const window = raw?.[windowKey];
263
+ const usable = isWindowUsable(window, { cachedAtMs, nowMs });
264
+ if (sparseWindowKeys.includes(windowKey)) {
265
+ if (usable) {
266
+ windows[windowKey] = window;
267
+ hasSurvivor = true;
268
+ if (parseTimeMs(window?.[windowResetField]) !== null) hasDatedSurvivor = true;
269
+ }
270
+ } else {
271
+ windows[windowKey] = usable ? window : null;
272
+ if (usable) {
273
+ hasSurvivor = true;
274
+ if (parseTimeMs(window?.[windowResetField]) !== null) hasDatedSurvivor = true;
275
+ }
276
+ }
277
+ }
278
+ // Extra survivor fields (claude's weekly_scoped) count as window content:
279
+ // their survivor predicate applies the field's own expiry filter, so a
280
+ // scoped-only snapshot survives while its entries are still live and a
281
+ // windowless shell is still refused when they are not.
282
+ let hasExtraSurvivor = false;
283
+ for (const [field, kind] of survivorFields) {
284
+ if (kind.survivor(raw?.[field], { nowMs, cachedAtMs, isWindowUsable })) {
285
+ hasExtraSurvivor = true;
286
+ }
287
+ }
288
+ if (windowKeys.length > 0 && !hasSurvivor && !hasExtraSurvivor) return null;
289
+
290
+ if (effectiveMaxAge !== null) {
291
+ if (maxAgeGate === "ifNoDatedSurvivor") {
292
+ if (!hasDatedSurvivor && nowMs - cachedAtMs > effectiveMaxAge) return null;
293
+ } else if (nowMs - cachedAtMs > (maxAgeOverride ?? effectiveMaxAge)) {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ const normalized = {
299
+ configured: true,
300
+ error: null,
301
+ };
302
+ for (const [field, kind] of Object.entries(fields)) {
303
+ normalized[field] = readField(field, kind, raw, { nowMs, cachedAtMs, isWindowUsable });
304
+ }
305
+ Object.assign(normalized, windows);
306
+ if (cachedFlag) normalized.cached = true;
307
+
308
+ const effectiveStale = staleOverride ?? stale;
309
+ if (effectiveStale === "byAge") {
310
+ normalized.stale = nowMs - cachedAtMs > freshTtlMs;
311
+ } else if (effectiveStale !== null) {
312
+ normalized.stale = effectiveStale;
313
+ }
314
+ normalized.cached_at = raw.cached_at;
315
+
316
+ if (source !== null) {
317
+ if (source === "provenance") {
318
+ const writtenSource = typeof raw?.source === "string" && raw.source.trim()
319
+ ? raw.source.trim()
320
+ : null;
321
+ // Provenance must describe the data's age, not the transport (#955):
322
+ // a fresh hit re-serves the winning source it was written with, an
323
+ // older hit honestly reports the disk cache.
324
+ normalized.source = normalized.stale === false && writtenSource
325
+ ? writtenSource
326
+ : "disk-cache";
327
+ } else if (typeof source === "object" && typeof source.const === "string") {
328
+ normalized.source = source.const;
329
+ }
330
+ }
331
+
332
+ return windowKeys.length > 0 || Object.keys(fields).length > 0 ? normalized : null;
333
+ }
334
+
335
+ function read(opts = {}) {
336
+ const { home, namespace, trackerDir, nowMs = Date.now(), identity, maxAgeMs: maxAgeOverride, stale: staleOverride } = opts;
337
+ if (identityField && identityRequired && !identity) return null;
338
+ const parsed = readCacheJson(cachePath({ home, namespace, trackerDir }));
339
+ const entry = key ? parsed?.[key] : parsed;
340
+ return normalizeEntry(entry, { nowMs, identity, maxAgeMs: maxAgeOverride, stale: staleOverride });
341
+ }
342
+
343
+ function write(record, opts = {}) {
344
+ const { home, namespace, trackerDir, nowMs = Date.now(), identity } = opts;
345
+ if (identityField && identityRequired && !identity) return;
346
+ if (!shouldWrite(record, { nowMs })) return;
347
+ const entry = {};
348
+ if (identityField && identityRequired) entry[identityField] = identity;
349
+ for (const [field, kind] of Object.entries(fields)) {
350
+ entry[field] = writeField(field, kind, record);
351
+ }
352
+ for (const windowKey of windowKeys) {
353
+ if (sparseWindowKeys.includes(windowKey)) {
354
+ if (record?.[windowKey]) entry[windowKey] = record[windowKey];
355
+ } else {
356
+ entry[windowKey] = record?.[windowKey] || null;
357
+ }
358
+ }
359
+ entry.cached_at = new Date(nowMs).toISOString();
360
+ const payload = key ? { [key]: entry } : entry;
361
+ writeCacheJsonAtomic(cachePath({ home, namespace, trackerDir }), payload);
362
+ }
363
+
364
+ function clear(opts = {}) {
365
+ clearCacheFile(cachePath(opts));
366
+ }
367
+
368
+ return {
369
+ file,
370
+ key,
371
+ path: cachePath,
372
+ hasWindow,
373
+ isWindowUsable,
374
+ normalize: normalizeEntry,
375
+ read,
376
+ write,
377
+ clear,
378
+ };
379
+ }
380
+
381
+ // Internal cache-plumbing markers ride on provider slot results between the
382
+ // fetcher and the assembly; they are never payload and are stripped before the
383
+ // aggregate is served.
384
+ function stripInternalMarkers(record, markers) {
385
+ if (!record || !markers.some((marker) => marker in record)) return record;
386
+ const payload = { ...record };
387
+ for (const marker of markers) delete payload[marker];
388
+ return payload;
389
+ }
390
+
391
+ /**
392
+ * The standard assembly-site integration, shared verbatim by the providers
393
+ * whose fallback is the plain three-way shape:
394
+ *
395
+ * success with windows → stamp fresh, write cache (generation-gated,
396
+ * write-identity hook may refuse);
397
+ * actionable failure → pass through untouched (auth/config errors must
398
+ * never be hidden behind stale cache);
399
+ * transient failure → serve the cache (replace or overlay), else the
400
+ * failed slot itself.
401
+ *
402
+ * Async because at least one provider (copilot) re-derives its cache identity
403
+ * through async storage; a failed poll must not block the embedded server's
404
+ * event loop on that path.
405
+ */
406
+ async function withLimitsCacheFallback(slot, {
407
+ cache,
408
+ hasWindow = null,
409
+ nowMs = Date.now(),
410
+ home,
411
+ env,
412
+ isCurrentGeneration = () => true,
413
+ resolveWriteIdentity = () => undefined,
414
+ resolveReadIdentity = () => undefined,
415
+ beforeRead = null,
416
+ passthrough = null,
417
+ merge = "replace",
418
+ markers = ["cache_identity", "auth_error"],
419
+ } = {}) {
420
+ const slotHasWindow = hasWindow || cache.hasWindow;
421
+ if (!slot || slot.configured === false) return slot;
422
+
423
+ if (!slot.error && slotHasWindow(slot)) {
424
+ const stamped = {
425
+ ...slot,
426
+ stale: false,
427
+ cached_at: new Date(nowMs).toISOString(),
428
+ };
429
+ if (isCurrentGeneration()) {
430
+ const identity = await resolveWriteIdentity(slot);
431
+ // Recheck after the await: a newer poll can bump the generation while
432
+ // the (copilot-style) async identity lookup runs, and the superseded
433
+ // fetch's stamp would then poison the account's current entry.
434
+ if (identity && isCurrentGeneration()) {
435
+ cache.write(stamped, { home, nowMs, identity });
436
+ }
437
+ }
438
+ return stripInternalMarkers(stamped, markers);
439
+ }
440
+
441
+ if (passthrough && passthrough(slot)) {
442
+ return stripInternalMarkers(slot, markers);
443
+ }
444
+
445
+ // The slot's error object loses the fetcher's identity field when the outer
446
+ // timeout race wins (the common failure mode this cache exists for), so the
447
+ // read identity is re-derived here on the failure-only path. beforeRead may
448
+ // be async — copilot re-derives its identity through the keychain and must
449
+ // sample the wall clock only after that lookup (a slow poll can straddle a
450
+ // reset), so its hook returns { identity, nowMs } in one awaited step.
451
+ const readOptions = beforeRead ? await beforeRead(slot) : {};
452
+ if (readOptions === null) return stripInternalMarkers(slot, markers);
453
+ const cached = cache.read({
454
+ home,
455
+ nowMs: readOptions.nowMs ?? nowMs,
456
+ identity: await (readOptions.identity !== undefined
457
+ ? Promise.resolve(readOptions.identity)
458
+ : resolveReadIdentity()),
459
+ });
460
+ const merged = cached
461
+ ? (merge === "overlay" ? { ...slot, ...cached } : cached)
462
+ : slot;
463
+ return stripInternalMarkers(merged, markers);
464
+ }
465
+
466
+ module.exports = {
467
+ parseTimeMs,
468
+ resolveTrackerDir,
469
+ readCacheJson,
470
+ writeCacheJsonAtomic,
471
+ clearCacheFile,
472
+ createLimitsCache,
473
+ stripInternalMarkers,
474
+ withLimitsCacheFallback,
475
+ };
@@ -1400,6 +1400,13 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
1400
1400
  }
1401
1401
  }
1402
1402
 
1403
+ function rememberIssuedAccessToken(accessToken, sourceFingerprint) {
1404
+ if (!sourceFingerprint || typeof accessToken !== "string" || accessToken.length > 8192) return;
1405
+ const hash = crypto.createHash("sha256").update(accessToken).digest("hex");
1406
+ if (!issuedAccessTokenInstances.has(hash)) issuedAccessTokenInstances.set(hash, sourceFingerprint);
1407
+ while (issuedAccessTokenInstances.size > 64) issuedAccessTokenInstances.delete(issuedAccessTokenInstances.keys().next().value);
1408
+ }
1409
+
1403
1410
  function captureAuthTokensFromBody(bodyBuffer, contentType, { newLogin = false, sourceFingerprint } = {}) {
1404
1411
  if (!bodyBuffer || !String(contentType || "").toLowerCase().includes("application/json")) return;
1405
1412
  let parsed = null;
@@ -1408,11 +1415,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
1408
1415
  } catch {
1409
1416
  return;
1410
1417
  }
1411
- if (sourceFingerprint && typeof parsed?.accessToken === "string" && parsed.accessToken.length <= 8192) {
1412
- const hash = crypto.createHash("sha256").update(parsed.accessToken).digest("hex");
1413
- if (!issuedAccessTokenInstances.has(hash)) issuedAccessTokenInstances.set(hash, sourceFingerprint);
1414
- while (issuedAccessTokenInstances.size > 64) issuedAccessTokenInstances.delete(issuedAccessTokenInstances.keys().next().value);
1415
- }
1418
+ rememberIssuedAccessToken(parsed?.accessToken, sourceFingerprint);
1416
1419
  let changed = false;
1417
1420
  const token = typeof parsed?.csrfToken === "string" ? parsed.csrfToken.trim() : "";
1418
1421
  if (token) {
@@ -2073,6 +2076,90 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2073
2076
  const hasCsrfHeader = typeof proxyHeaders["x-csrf-token"] === "string" && proxyHeaders["x-csrf-token"].trim().length > 0;
2074
2077
  const relayCsrfToken = getRelayCookieValue(csrfRelayCookieName);
2075
2078
  const relayRefreshToken = getRelayCookieValue("insforge_refresh_token", { decode: true });
2079
+ // Issue #1074: the backend rotates refresh tokens, but this proxy and
2080
+ // `mintAccessToken` used to POST the same token independently. A page
2081
+ // refresh concurrent with a background account read then consumed the
2082
+ // token twice — the loser got 401 and the SPA signed the user out.
2083
+ // Route every refresh that still has a persisted token through the
2084
+ // shared mint (cache + single-flight + one retry against a just-
2085
+ // rotated file) so both callers share one result.
2086
+ if (p === "/api/auth/refresh" && relayRefreshToken && String(req.method || "GET").toUpperCase() === "POST") {
2087
+ for await (const _chunk of req) { /* drain unused body */ }
2088
+ const latestRuntime = getRuntime();
2089
+ if (latestRuntime.configurationError || latestRuntime.baseUrl !== runtime.baseUrl || latestRuntime.anonKey !== runtime.anonKey) {
2090
+ json(res, { code: "instance_changed", error: "The configured backend changed during authentication. Reload and sign in again." }, 409);
2091
+ return true;
2092
+ }
2093
+ const mintOpts = {
2094
+ baseUrl: insforgeBase,
2095
+ anonKey: runtime.anonKey,
2096
+ timeoutMs: runtime.httpTimeoutMs,
2097
+ throwOnFailure: true,
2098
+ };
2099
+ const rejectIfRefreshContextChanged = () => {
2100
+ const currentRuntime = getRuntime();
2101
+ if (currentRuntime.configurationError || currentRuntime.baseUrl !== runtime.baseUrl || currentRuntime.anonKey !== runtime.anonKey) {
2102
+ json(res, { code: "instance_changed", error: "The configured backend changed during authentication. Reload and sign in again." }, 409);
2103
+ return true;
2104
+ }
2105
+ if (authSessionAtStart !== getCloudAccountSessionGeneration()) {
2106
+ json(res, { error: "Account session changed", code: "auth_session_changed" }, 409);
2107
+ return true;
2108
+ }
2109
+ return false;
2110
+ };
2111
+ let minted = null;
2112
+ try {
2113
+ try {
2114
+ minted = await mintAccessToken({ ...mintOpts, refreshToken: relayRefreshToken });
2115
+ } catch (error) {
2116
+ // Only retry a consumed-token 401 against a just-rotated
2117
+ // persisted token for the SAME backend/session. Otherwise a
2118
+ // rejected mint from instance A could submit instance B's
2119
+ // newly saved refresh token to A.
2120
+ if (rejectIfRefreshContextChanged()) return true;
2121
+ const latest = getRelayCookieValue("insforge_refresh_token", { decode: true });
2122
+ if (latest && latest !== relayRefreshToken && error?.status === 401) {
2123
+ minted = await mintAccessToken({ ...mintOpts, refreshToken: latest });
2124
+ } else {
2125
+ throw error;
2126
+ }
2127
+ }
2128
+ } catch (error) {
2129
+ if (rejectIfRefreshContextChanged()) return true;
2130
+ // performMint tags every non-OK HTTP status as auth_rejected.
2131
+ // Only a real 401 means the credential was consumed/invalid;
2132
+ // 429/503 must not sign the SPA out.
2133
+ if (error?.status === 401) {
2134
+ json(res, { error: "AUTH_UNAUTHORIZED", message: "Invalid or expired refresh token" }, 401);
2135
+ return true;
2136
+ }
2137
+ throw error;
2138
+ }
2139
+ if (rejectIfRefreshContextChanged()) return true;
2140
+ if (!minted?.accessToken) {
2141
+ json(res, { error: "AUTH_UNAUTHORIZED", message: "Invalid or expired refresh token" }, 401);
2142
+ return true;
2143
+ }
2144
+ const payload = { accessToken: minted.accessToken };
2145
+ if (minted.refreshToken) payload.refreshToken = minted.refreshToken;
2146
+ if (minted.user) payload.user = minted.user;
2147
+ // Persist the rotation without bumping sessionGeneration — that
2148
+ // would 409 a concurrent waiter that shared this mint.
2149
+ // setRelayRefreshToken matches the background account-read path.
2150
+ if (minted.refreshToken) setRelayRefreshToken(minted.refreshToken);
2151
+ if (minted.csrfToken) setRelayCsrfToken(minted.csrfToken);
2152
+ // Cache hits return csrfToken:null. The SDK still needs the current
2153
+ // CSRF to sign out; without it, logout 403s and a reload signs the
2154
+ // user back in. Fall back to the relayed token we just persisted.
2155
+ const csrfToken = minted.csrfToken || getRelayCookieValue(csrfRelayCookieName);
2156
+ if (csrfToken) payload.csrfToken = csrfToken;
2157
+ rememberIssuedAccessToken(minted.accessToken, fingerprintFor(runtime));
2158
+ const resBody = Buffer.from(JSON.stringify(payload));
2159
+ res.writeHead(200, { "content-type": "application/json" });
2160
+ res.end(resBody);
2161
+ return true;
2162
+ }
2076
2163
  // A cookie-less client (fresh WebView after an app update/restart) has no
2077
2164
  // browser session to pair a CSRF token with — the persisted refresh token
2078
2165
  // replayed through the mobile flow is the only viable recovery. The relay
@@ -335,6 +335,30 @@
335
335
  "cache_read": 0.05,
336
336
  "note": "LiteLLM entry (sourced from https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing, cached 2026-10-31): $0.50/$3 per MTok, $0.05 cache read, no published cache-write rate. Same rate the edge table already pins; promoted so curated/edge parity guards the key (issue #929)."
337
337
  },
338
+ "gemini-3.5-flash": {
339
+ "input": 1.5,
340
+ "output": 9,
341
+ "cache_read": 0.15,
342
+ "note": "Google Gemini 3.5 Flash Global Standard list rate: $1.50/$9.00 per MTok, $0.15 cache read (output includes thinking). No published per-token cache-write rate (context-cache storage is billed separately per hour). Verified 2026-11-08 from https://cloud.google.com/model-garden/pricing; matches the existing LiteLLM snapshot. Promoted so curated/edge stay in parity after the Antigravity 3.x-flash remap was removed (issue #1063). Do not remap this id to gemini-2.5-flash — that SKU is $0.30/$2.50/$0.03."
343
+ },
344
+ "gemini-3.8-flash": {
345
+ "input": 0.75,
346
+ "output": 3.75,
347
+ "cache_read": 0.075,
348
+ "note": "Google Gemini 3.8 Flash Global Standard introductory list rate through 2026-12-31: $0.75/$3.75 per MTok, $0.075 cache read (output includes thinking). Starting 2027-01-01 the published standard list is $1.50/$7.50/$0.15. No published per-token cache-write rate (context-cache storage is billed separately per hour). Verified 2026-11-08 from https://cloud.google.com/model-garden/pricing and https://aistudio.google.com/docs/pricing (issue #1063). Do not remap these ids to gemini-2.5-flash — that SKU is $0.30/$2.50/$0.03."
349
+ },
350
+ "gemini-3.7-flash": {
351
+ "input": 0.75,
352
+ "output": 3.75,
353
+ "cache_read": 0.075,
354
+ "note": "Google Gemini 3.7 Flash Global Standard introductory list rate through 2026-12-31: $0.75/$3.75 per MTok, $0.075 cache read (output includes thinking). Starting 2027-01-01 the published standard list is $1.50/$7.50/$0.15. No published per-token cache-write rate (context-cache storage is billed separately per hour). Verified 2026-11-08 from https://cloud.google.com/model-garden/pricing and https://aistudio.google.com/docs/pricing (issue #1063). Do not remap these ids to gemini-2.5-flash — that SKU is $0.30/$2.50/$0.03."
355
+ },
356
+ "gemini-3.6-flash": {
357
+ "input": 0.75,
358
+ "output": 3.75,
359
+ "cache_read": 0.075,
360
+ "note": "Google Gemini 3.6 Flash Global Standard introductory list rate through 2026-12-31: $0.75/$3.75 per MTok, $0.075 cache read (output includes thinking). Starting 2027-01-01 the published standard list is $1.50/$7.50/$0.15. No published per-token cache-write rate (context-cache storage is billed separately per hour). Verified 2026-11-08 from https://cloud.google.com/model-garden/pricing and https://aistudio.google.com/docs/pricing (issue #1063). Do not remap these ids to gemini-2.5-flash — that SKU is $0.30/$2.50/$0.03."
361
+ },
338
362
  "MiniMax-M2.7": {
339
363
  "input": 0.3,
340
364
  "output": 1.2,
@@ -879,6 +903,14 @@
879
903
  "match": "gpt-5.6",
880
904
  "ref": "gpt-5.6-sol"
881
905
  },
906
+ {
907
+ "match": "gemini-4-argon",
908
+ "ref": "gemini-4-argon"
909
+ },
910
+ {
911
+ "match": "gemini-4.argon",
912
+ "ref": "gemini-4-argon"
913
+ },
882
914
  {
883
915
  "match": "kiro",
884
916
  "ref": "kiro-cli-agent"
@@ -47,7 +47,10 @@ function normalizeAntigravityModel(model) {
47
47
  }
48
48
 
49
49
  if (/^gemini-3\.\d+-flash-lite/.test(lower)) return "gemini-2.5-flash-lite";
50
- if (/^gemini-3\.\d+-flash/.test(lower)) return "gemini-2.5-flash";
50
+ // Gemini 3.x Flash models have their own published list rates (3.5 Flash
51
+ // and 3.6/3.7/3.8 Flash are curated so cloud edge stays in parity).
52
+ // Remapping the whole family to gemini-2.5-flash under-counted
53
+ // API-equivalent cost (#1063).
51
54
  if (/^gemini-3\.\d+-pro/.test(lower)) return "gemini-2.5-pro";
52
55
  if (/^claude-(sonnet|opus|haiku)-4\.\d+/.test(lower)) {
53
56
  return lower.replace(/^claude-(sonnet|opus|haiku)-4\.(\d+)/, "claude-$1-4-$2");