tokentracker-cli 1.5.14 → 1.5.15

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 (114) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-DqJWve1h.js → AchievementBadge-CwjI28oT.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-CnuBayj3.js → AchievementsPage-Dq544fjX.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-WRU5TxAB.js → AchievementsSection-DufV6nod.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-B5ikEQik.js → BadgeDetailModal-vw8xydKu.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-CaMG6j-6.js → BotAnimated-De0anwsh.js} +1 -1
  6. package/dashboard/dist/assets/{Card-BL3vxENk.js → Card-CuSeWBDM.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-CsUb8CJ7.js → CloudCheckoutPage-CxFcOwAj.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-5ZiPFzeC.js → CloudPage-Beb7_JLW.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-CYd9nXW-.js → CommandPalette-PsUq2iRS.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-BItox7Sz.js → CommunityStatsModal-b54WaPbK.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-BE1ABR4z.js → ConfirmModal-_NdunIh5.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-BRqIsaHT.js → Controls-5Is6MWln.js} +1 -1
  13. package/dashboard/dist/assets/{DashboardPage-Cm0YuBlx.js → DashboardPage-C0n0qC7h.js} +1 -1
  14. package/dashboard/dist/assets/{DateRangePopover-CRwEu66e.js → DateRangePopover-C3WfGy-0.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-DFUPsGb_.js → DevicePage-Vn6k-mGd.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-DALl4GmP.js → DialogClose-CXttnna-.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-C3KGos_G.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-BSR8ozyn.js → DialogTitle-B0cNIo0E.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-BtrycBUW.js → ElectricLogo-DC3XSfIu.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-CfhgvLVd.js → FadeIn-H7S3TMHi.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-N8wqAdOf.js → HeaderGithubStar-xgbIo5OP.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-CwdMYIzZ.js → HoverTooltip-CpzM5Dju.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-DCRor9cb.js → IpCheckPage-BdV7S4dY.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-s9xvTHSp.js → LandingPage-D9f28ryO.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-CUj3WLMo.js → LeaderboardPage--WUFpkBI.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-N4NOKZWj.js → LeaderboardProBadge-CEvrQ75Y.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-09HiUyxm.js → LeaderboardProfileModal-Bn0HFr5h.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-Cw9_rodG.js → LeaderboardProfilePage-DJ7fQI68.js} +1 -1
  29. package/dashboard/dist/assets/{LimitsPage-F8b6kZa0.js → LimitsPage-7tPDheuX.js} +1 -1
  30. package/dashboard/dist/assets/{LocalOnlyNotice-D1eEzDEv.js → LocalOnlyNotice-DlGbVQS8.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-0sIovG_J.js → LoginCard-BZZmzUGh.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-BT3oMVak.js → LoginModal-BT5_bIxU.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-CDsXdaE5.js → LoginPage-DMjDUPPi.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-BEnC1azp.js → PetPage-Dk3j6ITn.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-Dwzp-hyf.js → PopoverPopup-pugRu3RC.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-Cu_lcuuu.js → ProviderIcon-CtmL1CmY.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-DrpEscm1.js → ResetPasswordPage-BB6U7t8P.js} +1 -1
  38. package/dashboard/dist/assets/{Select-wIR4qvEI.js → Select-7w1su8lw.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-CNuJgYps.js → SelectItemText-CaEUmWkj.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-CWNLygpL.js → SelfHostPage-DctWoF-A.js} +1 -1
  41. package/dashboard/dist/assets/{ServiceStatusPage-BId9t16Q.js → ServiceStatusPage-Dg3ljEXC.js} +1 -1
  42. package/dashboard/dist/assets/{SessionsPage-C_klx7d8.js → SessionsPage-B1dvW-be.js} +1 -1
  43. package/dashboard/dist/assets/{SettingsPage-nC73JaZ4.js → SettingsPage-DuXSX1R2.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-B2agou8Y.js → SkillsPage-CvXQFgc9.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-BNXUWRwa.js → TokenGalaxy-DKC8Rk_Y.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup--X2kExQ0.js → TooltipPopup-Dp9EpHLG.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-BELHsTnz.js → TrendMonitor-DWnlBn3H.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-DWp0SMXA.js → WidgetsPage-GECNmb1Q.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-DgwczKHe.js → WrappedPage-Dq_1lOnb.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-BHphZRSC.js → __vite-browser-external-CkF0pdTJ.js} +2 -2
  51. package/dashboard/dist/assets/{agent-logos-Cfx5WzU5.js → agent-logos-Dqe3Vrah.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-left-D83SdANw.js → arrow-left-Cr_2QnDu.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-right-D7ye9bmJ.js → arrow-right-BsK2sJgt.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-CLmwEwwC.js → arrow-up-right-DkWMGqXo.js} +1 -1
  55. package/dashboard/dist/assets/{calendar-days-D1qLlvh4.js → calendar-days-BB9VV438.js} +1 -1
  56. package/dashboard/dist/assets/{check-BHJvi4ey.js → check-bpSCJZWh.js} +1 -1
  57. package/dashboard/dist/assets/{chevron-down-6RBPKNpt.js → chevron-down-CtECtDmx.js} +1 -1
  58. package/dashboard/dist/assets/{cloud-checkout-DgWcF-at.js → cloud-checkout-C4PrzKhN.js} +1 -1
  59. package/dashboard/dist/assets/{composite-D-0pfyMY.js → composite-BHisG82k.js} +1 -1
  60. package/dashboard/dist/assets/{copy-B6mJSQsD.js → copy-Dmxzd-Sw.js} +1 -1
  61. package/dashboard/dist/assets/{download-D31Nn-bI.js → download-CkdQzw2w.js} +1 -1
  62. package/dashboard/dist/assets/{external-link-C_lK9S3p.js → external-link-C971FjvC.js} +1 -1
  63. package/dashboard/dist/assets/{flame-1ZxhHshT.js → flame-mKbp7kxw.js} +1 -1
  64. package/dashboard/dist/assets/{format-tokens-DJk25SkD.js → format-tokens-C35FrJnH.js} +1 -1
  65. package/dashboard/dist/assets/{gift-BUZzFh-1.js → gift-BX2lSOmc.js} +1 -1
  66. package/dashboard/dist/assets/{icons-BVPThCa4.js → icons-ov92u52T.js} +1 -1
  67. package/dashboard/dist/assets/{index-CjPsyTl7.js → index-9OCL-BL8.js} +1 -1
  68. package/dashboard/dist/assets/{index-C0zFsOWh.js → index-Bgm38oHe.js} +1 -1
  69. package/dashboard/dist/assets/{index-CzstoU6d.js → index-Ca5xkNrK.js} +1 -1
  70. package/dashboard/dist/assets/{index-B-mxrdIz.js → index-DF03nIHb.js} +1 -1
  71. package/dashboard/dist/assets/{info-DmU-vlJ6.js → info-B-f9hWcw.js} +1 -1
  72. package/dashboard/dist/assets/{link-2-BlBM7uw7.js → link-2-l1wuezTo.js} +1 -1
  73. package/dashboard/dist/assets/{loader-circle-Dhrb_Ocy.js → loader-circle-Lmjrs8XX.js} +1 -1
  74. package/dashboard/dist/assets/{local-usage-transfer-BudmMtkd.js → local-usage-transfer-Ds6cF41d.js} +1 -1
  75. package/dashboard/dist/assets/{modifiers.esm-kmw1VM8W.js → modifiers.esm-tVlltJXO.js} +1 -1
  76. package/dashboard/dist/assets/{monitor-smartphone-DtjXNC_x.js → monitor-smartphone-XRiYiY8u.js} +1 -1
  77. package/dashboard/dist/assets/{play-9w46lbjy.js → play-DaD9EMjk.js} +1 -1
  78. package/dashboard/dist/assets/{plus-DAf_WGKT.js → plus-D30fbsKQ.js} +1 -1
  79. package/dashboard/dist/assets/{provider-display-BZ0XlB2q.js → provider-display-CKdhBOwz.js} +1 -1
  80. package/dashboard/dist/assets/{react-B01ZJuZY.js → react-BY0y0K4x.js} +1 -1
  81. package/dashboard/dist/assets/{safePolygon-B6_x12Gb.js → safePolygon-CSG_P7rG.js} +1 -1
  82. package/dashboard/dist/assets/{search-D3dW9GbV.js → search-Kiq8wVHA.js} +1 -1
  83. package/dashboard/dist/assets/{server-C2XZBar7.js → server-CjovGBGz.js} +1 -1
  84. package/dashboard/dist/assets/{shield-check-4FX5vbS7.js → shield-check-DF9MmMCq.js} +1 -1
  85. package/dashboard/dist/assets/{skills-api-BvW_MUyg.js → skills-api-CEzr6p92.js} +1 -1
  86. package/dashboard/dist/assets/{sortable.esm-CAa_BJgh.js → sortable.esm-D7jd9mE_.js} +1 -1
  87. package/dashboard/dist/assets/{store-2AX2MNkz.js → store-D9_jo_do.js} +1 -1
  88. package/dashboard/dist/assets/{terminal-CON7n_ir.js → terminal-CLOlvzR-.js} +1 -1
  89. package/dashboard/dist/assets/{trash-C2f88jSi.js → trash-DIe0fsDD.js} +1 -1
  90. package/dashboard/dist/assets/{use-community-stats-CgQu43xV.js → use-community-stats-gDkdiRBz.js} +1 -1
  91. package/dashboard/dist/assets/{use-latest-request-guard-lShM46-3.js → use-latest-request-guard-tXGuKvua.js} +1 -1
  92. package/dashboard/dist/assets/{use-limits-display-prefs-8o7pbEg0.js → use-limits-display-prefs-CCuLFy00.js} +1 -1
  93. package/dashboard/dist/assets/{use-native-settings-dN8shG3M.js → use-native-settings-hgYqaiR7.js} +1 -1
  94. package/dashboard/dist/assets/{use-ordered-list-beJy2IqQ.js → use-ordered-list-D6-YzYJI.js} +1 -1
  95. package/dashboard/dist/assets/use-reduced-motion-DfbDxs5a.js +1 -0
  96. package/dashboard/dist/assets/{use-transform-cJpAQr2d.js → use-transform-BEiXvAuE.js} +1 -1
  97. package/dashboard/dist/assets/{use-usage-limits-CuusZA6b.js → use-usage-limits-BlwtyPtT.js} +1 -1
  98. package/dashboard/dist/assets/{useCurrency-Cx9UsKJK.js → useCurrency-CwCGJLHb.js} +1 -1
  99. package/dashboard/dist/assets/{usePositioner-TEClK6mF.js → usePositioner-BqHtRIrE.js} +1 -1
  100. package/dashboard/dist/assets/{useTokenFormat-BNwTLiac.js → useTokenFormat-BUNBUX-x.js} +1 -1
  101. package/dashboard/dist/assets/{zap-CFgbVEhC.js → zap-C0RFs2qQ.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/limits-cache.js +475 -0
  108. package/src/lib/pricing/curated-overrides.json +24 -0
  109. package/src/lib/pricing/matcher.js +4 -1
  110. package/src/lib/pricing/seed-snapshot.json +1 -1
  111. package/src/lib/rollout.js +43 -47
  112. package/src/lib/usage-limits.js +370 -1063
  113. package/dashboard/dist/assets/DialogDescription-CO0hykis.js +0 -1
  114. 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
+ };
@@ -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,
@@ -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");