pi-mega-compact 0.11.2 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +9 -1
- package/dist/extensions/dashboard-server/api-contracts/provider-cache.js +7 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
- package/dist/extensions/dashboard-server/index-reader.js +22 -0
- package/dist/extensions/dashboard-server/routes-cache.js +80 -0
- package/dist/extensions/dashboard-server/routes-cache.test.js +248 -0
- package/dist/extensions/dashboard-server/routes-maintenance.js +7 -7
- package/dist/extensions/dashboard-server/routes-repo.js +24 -0
- package/dist/extensions/dashboard-server/routes.js +1 -0
- package/dist/extensions/dashboard-server/server.js +3 -1
- package/dist/extensions/mega-cache-replay.test.js +233 -24
- package/dist/extensions/mega-compact-s38.test.js +201 -6
- package/dist/extensions/mega-config.js +3 -0
- package/dist/extensions/mega-events/agent-handlers.js +23 -7
- package/dist/extensions/mega-events/context-handler.js +37 -19
- package/dist/extensions/mega-events/error-classifier.js +27 -16
- package/dist/extensions/mega-runtime/runtime-snapshot.js +14 -4
- package/dist/extensions/mega-runtime/snapshot.js +1 -1
- package/dist/extensions/mega-runtime/snapshot.test.js +127 -0
- package/dist/extensions/mega-runtime/widget.js +4 -4
- package/dist/extensions/mega-runtime/widget.test.js +103 -12
- package/dist/src/pricing.js +78 -0
- package/dist/src/store/sqlite/perf-samples.js +104 -0
- package/dist/src/store/sqlite/perf-samples.test.js +93 -1
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js → AchievementTiles-vEo2MtE2.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-Cml6YC8w.js.map → AchievementTiles-vEo2MtE2.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/AchievementsTab-DjxfYrDc.js +2 -0
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-B8NqDt7I.js.map → AchievementsTab-DjxfYrDc.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js +2 -0
- package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js → ConfigTab-Cm6ymbmx.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-CBYvp2mV.js.map → ConfigTab-Cm6ymbmx.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js → EventsTab-DXWl_YzI.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-DlTJqiDm.js.map → EventsTab-DXWl_YzI.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js → GameTab-BLDudcO5.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-DkGTqKJW.js.map → GameTab-BLDudcO5.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js +2 -0
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js → MetricsTab-Cq4u-rZF.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-BFo7pKM-.js.map → MetricsTab-Cq4u-rZF.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js → OverviewTab-D4kGvsiQ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-CClCUd3-.js.map → OverviewTab-D4kGvsiQ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js +2 -0
- package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js → SessionsTab-DZC2-GCn.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CCYd-kSz.js.map → SessionsTab-DZC2-GCn.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js +2 -0
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js → TopicsTab-01G71hv1.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TopicsTab-DRLJmVv0.js.map → TopicsTab-01G71hv1.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js → TurnsTab-DLRg0LZT.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-CLNM0T5R.js.map → TurnsTab-DLRg0LZT.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +1 -0
- package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js → index-BX0bcgLu.js} +4 -4
- package/extensions/dashboard-client/dist/assets/{index-BcoSz-L4.js.map → index-BX0bcgLu.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js → useSSE-BBYt5UPB.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-Cezlih5V.js.map → useSSE-BBYt5UPB.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/src/api/client.ts +6 -0
- package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +13 -1
- package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +141 -0
- package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +31 -3
- package/extensions/dashboard-client/src/main.tsx +1 -0
- package/extensions/dashboard-client/src/styles/maintenance.css +267 -0
- package/extensions/dashboard-client/src/styles/overview-extra.css +9 -0
- package/extensions/dashboard-client/src/tabs/CacheTab.tsx +83 -29
- package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +301 -136
- package/extensions/dashboard-client/src/utils/format.ts +42 -7
- package/extensions/dashboard-server/api-contracts/endpoints.ts +31 -8
- package/extensions/dashboard-server/api-contracts/index.ts +4 -0
- package/extensions/dashboard-server/api-contracts/multi-repo.ts +6 -0
- package/extensions/dashboard-server/api-contracts/provider-cache.ts +30 -0
- package/extensions/dashboard-server/api-contracts.test.ts +3 -2
- package/extensions/dashboard-server/index-reader.ts +23 -0
- package/extensions/dashboard-server/routes-cache.test.ts +302 -0
- package/extensions/dashboard-server/routes-cache.ts +123 -0
- package/extensions/dashboard-server/routes-maintenance.ts +18 -11
- package/extensions/dashboard-server/routes-repo.ts +31 -4
- package/extensions/dashboard-server/routes.ts +1 -0
- package/extensions/dashboard-server/server.ts +2 -0
- package/extensions/mega-cache-replay.test.ts +481 -166
- package/extensions/mega-compact-s38.test.ts +210 -6
- package/extensions/mega-config.ts +6 -0
- package/extensions/mega-events/agent-handlers.ts +24 -6
- package/extensions/mega-events/context-handler.ts +59 -24
- package/extensions/mega-events/error-classifier.ts +30 -13
- package/extensions/mega-runtime/runtime-snapshot.ts +31 -9
- package/extensions/mega-runtime/snapshot.test.ts +139 -0
- package/extensions/mega-runtime/snapshot.ts +6 -4
- package/extensions/mega-runtime/widget.test.ts +290 -105
- package/extensions/mega-runtime/widget.ts +29 -6
- package/package.json +1 -1
- package/src/pricing.ts +95 -0
- package/src/store/sqlite/perf-samples.test.ts +107 -3
- package/src/store/sqlite/perf-samples.ts +142 -6
- package/extensions/dashboard-client/dist/assets/AchievementsTab-B8NqDt7I.js +0 -2
- package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js +0 -2
- package/extensions/dashboard-client/dist/assets/CacheTab-BCLzVL4S.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js +0 -2
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js +0 -2
- package/extensions/dashboard-client/dist/assets/ReposTab-_3R4RSLZ.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js +0 -2
- package/extensions/dashboard-client/dist/assets/TimeSavedCard-0TqsrY6y.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/index-gn66lYD2.css +0 -1
|
@@ -170,30 +170,24 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
170
170
|
runtime.diagCtxFastGate++;
|
|
171
171
|
return;
|
|
172
172
|
}
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
runtime.debounceUntil = now + 2000;
|
|
173
|
+
// D.2: Replay MUST be exempt from debounce — replay is free (no compute,
|
|
174
|
+
// no re-write) and prevents unnecessary KV-cache invalidation. Check
|
|
175
|
+
// replay FIRST, before debounce, so two context events <2s apart both
|
|
176
|
+
// return the cached sentinel verbatim (re-stabilises the provider prefix).
|
|
177
|
+
//
|
|
180
178
|
// v0.8.6 cache-stability: replay the cached trim view when still in the
|
|
181
179
|
// same compaction epoch AND context hasn't grown enough to warrant a
|
|
182
|
-
// re-compact.
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// is only valid while the transcript grows within the epoch — it is
|
|
189
|
-
// cleared on session_compact (durable truncation) + resetRuntime, so we
|
|
190
|
-
// never replay a stale cut into a truncated transcript (PREVENT-PI-001/002).
|
|
191
|
-
const RECOMPACT_PCT_DELTA = 10;
|
|
180
|
+
// re-compact. Re-compact only when context grew >=config.recompactPctDelta%
|
|
181
|
+
// of the window (percent basis) or >=50% of the effective threshold
|
|
182
|
+
// (token basis, when percent is unavailable). The cached `cut` is only
|
|
183
|
+
// valid while the transcript grows within the epoch — it is cleared on
|
|
184
|
+
// session_compact (durable truncation) + resetRuntime, so we never replay
|
|
185
|
+
// a stale cut into a truncated transcript (PREVENT-PI-001/002).
|
|
192
186
|
if (runtime.trimCache &&
|
|
193
187
|
runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
|
|
194
188
|
runtime.trimCache.cut <= messages.length) {
|
|
195
189
|
const grewEnough = pct != null && runtime.trimCache.ctxPct != null
|
|
196
|
-
? pct - runtime.trimCache.ctxPct >=
|
|
190
|
+
? pct - runtime.trimCache.ctxPct >= config.recompactPctDelta
|
|
197
191
|
: currentTokens - (runtime.trimCache.ctxTokens ?? 0) >=
|
|
198
192
|
runtime.effectiveThreshold * 0.5;
|
|
199
193
|
if (!grewEnough) {
|
|
@@ -209,6 +203,14 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
209
203
|
}
|
|
210
204
|
// else: context grew enough → fall through to re-compact (cache is stale)
|
|
211
205
|
}
|
|
206
|
+
// Debounce so we don't fire on every context event past threshold.
|
|
207
|
+
// (Replay already returned above — only fresh compacts reach this point.)
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
if (now < runtime.debounceUntil) {
|
|
210
|
+
runtime.diagCtxDebounce++;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
runtime.debounceUntil = now + 2000;
|
|
212
214
|
// Adaptive compression (Fix E): scale compression strength + keepFrom depth
|
|
213
215
|
// with how close we are to the model context limit. Null-safe: when the
|
|
214
216
|
// token-fallback path ran (pct unavailable) use the token-basis pressure
|
|
@@ -219,8 +221,22 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
219
221
|
const ran = runCompact(pi, runtime, config, ctx, messages, {
|
|
220
222
|
compressionPressure: pressure,
|
|
221
223
|
});
|
|
224
|
+
// D.3: skip paths fall back to replay instead of returning empty.
|
|
225
|
+
// If runCompact skipped and we have a valid trimCache, replay it
|
|
226
|
+
// (free stability win) — otherwise defer to the next event.
|
|
222
227
|
if (ran.skipped) {
|
|
223
228
|
runtime.diagCtxRunSkipped++;
|
|
229
|
+
if (runtime.trimCache &&
|
|
230
|
+
runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
|
|
231
|
+
runtime.trimCache.cut <= messages.length) {
|
|
232
|
+
const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002
|
|
233
|
+
runtime.diagLiveTrimFires++;
|
|
234
|
+
runtime.diagLiveTrimReplays++;
|
|
235
|
+
runtime.snapshot(ctx);
|
|
236
|
+
return {
|
|
237
|
+
messages: [{ ...runtime.trimCache.summaryAgentMsg }, ...recent],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
224
240
|
return;
|
|
225
241
|
}
|
|
226
242
|
// S27 DB-mirror: write checkpoint_epoch with deterministic nonce.
|
|
@@ -277,7 +293,9 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
277
293
|
}
|
|
278
294
|
}
|
|
279
295
|
catch (wikiErr) {
|
|
280
|
-
runtime.logger.warn("wiki_rebuild_failed", {
|
|
296
|
+
runtime.logger.warn("wiki_rebuild_failed", {
|
|
297
|
+
error: String(wikiErr),
|
|
298
|
+
});
|
|
281
299
|
}
|
|
282
300
|
// S27 Task 6: Fire-and-forget dedup pipeline.
|
|
283
301
|
// Deduplicates raw_transcript rows for the compacted range.
|
|
@@ -11,6 +11,25 @@
|
|
|
11
11
|
* compact per signature). Conservative: when unsure between transient and
|
|
12
12
|
* poisoned, choose transient (bounded by R1 dedup + R2 session cap).
|
|
13
13
|
*/
|
|
14
|
+
/** R7: known-retryable transient markers — network/throughput failures that
|
|
15
|
+
* retry can fix and /clear cannot. SINGLE SOURCE OF TRUTH: classifyError uses
|
|
16
|
+
* this for its transient-marker stage, and the agent-handlers R3 repeat
|
|
17
|
+
* upgrade guard uses it to keep these errors out of poisoned-context. Add new
|
|
18
|
+
* transient phrasings HERE only.
|
|
19
|
+
*
|
|
20
|
+
* Non-obvious alternatives:
|
|
21
|
+
* - `time(?:d[\s-]?out|out)` covers "timeout", "timed out", "timed-out" —
|
|
22
|
+
* "timed out" does NOT match /timeout/ (the 2026-07-30 incident phrasing).
|
|
23
|
+
* - `etimedout` is Node's timeout errno lowercased — does NOT contain the
|
|
24
|
+
* substring "timeout".
|
|
25
|
+
* - `socket hang up` is Node's ECONNRESET *message*; the errno lives in
|
|
26
|
+
* error.code, which extractErrorSignature never sees. */
|
|
27
|
+
export const KNOWN_RETRYABLE_TRANSIENT_PATTERN = /max(imum)? output token|rate[\s.-]?limit|429|too many requests|overloaded|5\d\d|internal server|bad gateway|service unavailable|network|time(?:d[\s-]?out|out)|etimedout|econnreset|econnrefused|epipe|eai_again|socket hang up|premature close|other side closed|connection (lost|refused|reset|aborted?)|stream (interrupted|closed|ended|failed)|disconnected/;
|
|
28
|
+
/** R7: true when the error text carries a known-retryable transient marker.
|
|
29
|
+
* Defensively lowercases so callers can pass raw or normalized text. */
|
|
30
|
+
export function isKnownRetryableTransient(text) {
|
|
31
|
+
return KNOWN_RETRYABLE_TRANSIENT_PATTERN.test(text.toLowerCase());
|
|
32
|
+
}
|
|
14
33
|
/** S38.2: classify a turn-end error/stop signal into a retry category.
|
|
15
34
|
*
|
|
16
35
|
* `length` is returned as null — S28 owns the max-output-token length stopReason
|
|
@@ -134,22 +153,14 @@ export function classifyError(message) {
|
|
|
134
153
|
if (/too long|context window|maximum context length|context length exceeded|requires at least \d+ tokens|even after compaction|reduce the conversation|reduce your input/.test(s)) {
|
|
135
154
|
return 'context-overflow';
|
|
136
155
|
}
|
|
137
|
-
// --- transient (
|
|
138
|
-
// R3: network failures (timeout, ECONNRESET, 5xx, 429) MUST stay
|
|
139
|
-
// even when usage is 0 tokens, because they are retryable
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (/5\d\d|internal server|bad gateway|service unavailable/.test(s))
|
|
146
|
-
return 'transient';
|
|
147
|
-
// 'connection aborted' is a network failure (ECONNABORTED), NOT a user ESC
|
|
148
|
-
// (which is stopReason 'aborted', handled by the early-return above). Added
|
|
149
|
-
// so "Connection aborted by peer" stays transient under R3. ECONNRESET and
|
|
150
|
-
// common errno names are included so a bare 0-token 'ECONNRESET' turn is not
|
|
151
|
-
// misclassified as poisoned (R6: ECONNRESET → transient).
|
|
152
|
-
if (/network|timeout|econnreset|econnrefused|epipe|connection (lost|refused|reset|aborted)|stream (interrupted|closed|ended|failed)|disconnected/.test(s))
|
|
156
|
+
// --- transient (known-retryable markers FIRST — these override poisoned signals) ---
|
|
157
|
+
// R3: network/throughput failures (timeout, ECONNRESET, 5xx, 429) MUST stay
|
|
158
|
+
// transient even when usage is 0 tokens, because they are retryable and
|
|
159
|
+
// /clear cannot fix them. 'connection aborted' is a network failure
|
|
160
|
+
// (ECONNABORTED), NOT a user ESC (stopReason 'aborted', early-return above).
|
|
161
|
+
// The marker set is shared with the agent-handlers R3 repeat-upgrade guard
|
|
162
|
+
// (isKnownRetryableTransient) so the two never drift.
|
|
163
|
+
if (isKnownRetryableTransient(s))
|
|
153
164
|
return 'transient';
|
|
154
165
|
// --- poisoned-context (R3: ORDER AFTER specific transient markers, BEFORE
|
|
155
166
|
// the generic 'error' transient fallthrough) ---
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { vectorStats, vectorRepoStats, vectorDataInvariant, } from "../../src/vectorStore.js";
|
|
21
|
-
import { latestModelSnapshot, recordPerfSample, recordSessionHeartbeat, appendTokenSample, } from "../../src/store/sqlite.js";
|
|
22
|
-
import { resolveRepoRoot } from "../mega-config.js";
|
|
21
|
+
import { latestModelSnapshot, readLatestCacheHitPct, recordPerfSample, recordSessionHeartbeat, appendTokenSample, } from "../../src/store/sqlite.js";
|
|
22
|
+
import { resolveRepoRoot, } from "../mega-config.js";
|
|
23
23
|
import { computeMegaSnapshot } from "./snapshot.js";
|
|
24
24
|
import { buildDashboardSnapshot } from "./dashboard-snapshot.js";
|
|
25
25
|
import { materialSigImpl, embedderNameImpl, driftStatusImpl, getTurnLevelImpl, } from "./runtime-helpers.js";
|
|
@@ -134,6 +134,16 @@ export function snapshotImpl(self, ctx) {
|
|
|
134
134
|
}
|
|
135
135
|
// ── gather widget data (computed per snapshot, rendered per frame) ────
|
|
136
136
|
const modelSnap = latestModelSnapshot(self.currentStateDir);
|
|
137
|
+
// Provider prompt cache hit % for the widget (B/C): latest
|
|
138
|
+
// cache_hit_pct sample. Non-fatal — one extra sync open per
|
|
139
|
+
// material-change-gated recompute, acceptably cheap.
|
|
140
|
+
let providerCachePct = 0;
|
|
141
|
+
try {
|
|
142
|
+
providerCachePct = readLatestCacheHitPct(self.currentStateDir);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
/* non-fatal */
|
|
146
|
+
}
|
|
137
147
|
const _snapResult = computeMegaSnapshot({
|
|
138
148
|
lastCtxTokens: self.lastCtxTokens,
|
|
139
149
|
lastCtxWindow: self.lastCtxWindow,
|
|
@@ -165,6 +175,7 @@ export function snapshotImpl(self, ctx) {
|
|
|
165
175
|
ready,
|
|
166
176
|
armed,
|
|
167
177
|
modelSnap,
|
|
178
|
+
providerCachePct,
|
|
168
179
|
});
|
|
169
180
|
self.widgetData = _snapResult.widgetData;
|
|
170
181
|
// S33: consume the flare after copying it into widgetData so it fires
|
|
@@ -186,8 +197,7 @@ export function snapshotImpl(self, ctx) {
|
|
|
186
197
|
// (effectBorderSgr returns '' once expired), so this is bookkeeping to
|
|
187
198
|
// free the slot and prevent a stale effect lingering between snapshots.
|
|
188
199
|
if (self.activeEffect &&
|
|
189
|
-
Date.now() - self.activeEffect.startedAt >=
|
|
190
|
-
self.activeEffect.durationMs) {
|
|
200
|
+
Date.now() - self.activeEffect.startedAt >= self.activeEffect.durationMs) {
|
|
191
201
|
self.activeEffect = null;
|
|
192
202
|
}
|
|
193
203
|
// Auto-fit: register a factory so pi re-renders the panel at the REAL
|
|
@@ -91,7 +91,7 @@ export function computeMegaSnapshot(p) {
|
|
|
91
91
|
// ── S31: game-mode state ──────────────────────────────────────────────
|
|
92
92
|
const gs = p.getCachedGameState();
|
|
93
93
|
const curLevel = p.getTurnLevel();
|
|
94
|
-
const cachePct =
|
|
94
|
+
const cachePct = p.providerCachePct;
|
|
95
95
|
const widgetData = {
|
|
96
96
|
version: ownVersion(),
|
|
97
97
|
tierLabel,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snapshot.test.ts — C.4 computeMegaSnapshot tests for providerCachePct
|
|
3
|
+
* and megaCacheFlare fields.
|
|
4
|
+
*
|
|
5
|
+
* Uses MEGACOMPACT_STATE_DIR + mkdtemp (G7). No pi runtime.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, before, after } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { computeMegaSnapshot } from "./snapshot.js";
|
|
13
|
+
function baseInput(overrides = {}) {
|
|
14
|
+
return {
|
|
15
|
+
rtTokensSaved: 1000,
|
|
16
|
+
lastCtxPercent: null,
|
|
17
|
+
lastCtxTokens: 0,
|
|
18
|
+
lastCtxWindow: 0,
|
|
19
|
+
activeAgents: 0,
|
|
20
|
+
currentTurn: 0,
|
|
21
|
+
statusKey: undefined,
|
|
22
|
+
ready: false,
|
|
23
|
+
armed: false,
|
|
24
|
+
st: {
|
|
25
|
+
totalTokenEstimate: 800,
|
|
26
|
+
originalTokens: 1200,
|
|
27
|
+
storageDedupRate: 0.12,
|
|
28
|
+
checkpointCount: 3,
|
|
29
|
+
lastCheckpointId: undefined,
|
|
30
|
+
lastSummary: undefined,
|
|
31
|
+
injectedCount: 0,
|
|
32
|
+
dedupHitRate: 0.05,
|
|
33
|
+
tokensSaved: 200,
|
|
34
|
+
dedupAttempts: 40,
|
|
35
|
+
dedupCollapsed: 5,
|
|
36
|
+
},
|
|
37
|
+
repo: {
|
|
38
|
+
tokensSaved: 5000,
|
|
39
|
+
totalTokenEstimate: 4000,
|
|
40
|
+
checkpointCount: 9,
|
|
41
|
+
sessionCount: 2,
|
|
42
|
+
originalTokens: 6000,
|
|
43
|
+
dedupAttempts: 100,
|
|
44
|
+
dedupCollapsed: 30,
|
|
45
|
+
storageDedupRate: 0.3,
|
|
46
|
+
},
|
|
47
|
+
pressureBand: "low",
|
|
48
|
+
configTier: "default",
|
|
49
|
+
modelSnap: undefined,
|
|
50
|
+
lastCompactAt: null,
|
|
51
|
+
embedderName: () => "Trigram",
|
|
52
|
+
driftStatus: () => "ok",
|
|
53
|
+
getCachedGameState: () => ({
|
|
54
|
+
game_mode_on: false,
|
|
55
|
+
theme: "transparent",
|
|
56
|
+
tui_display_mode: "full",
|
|
57
|
+
}),
|
|
58
|
+
getTurnLevel: () => 1,
|
|
59
|
+
providerCachePct: 56.2,
|
|
60
|
+
megaCacheFlare: false,
|
|
61
|
+
megaCacheFlarePct: 0,
|
|
62
|
+
levelUpFlare: false,
|
|
63
|
+
achievementFlare: false,
|
|
64
|
+
achievementFlareTitles: [],
|
|
65
|
+
activeEffect: null,
|
|
66
|
+
lastActivityAt: Date.now(),
|
|
67
|
+
ticker: [],
|
|
68
|
+
lastWhy: undefined,
|
|
69
|
+
tierTrace: undefined,
|
|
70
|
+
pulsing: false,
|
|
71
|
+
...overrides,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
describe("computeMegaSnapshot (C.4)", () => {
|
|
75
|
+
let dir;
|
|
76
|
+
before(() => {
|
|
77
|
+
dir = mkdtempSync(join(tmpdir(), "mc-snap-"));
|
|
78
|
+
process.env.MEGACOMPACT_STATE_DIR = dir;
|
|
79
|
+
});
|
|
80
|
+
after(() => {
|
|
81
|
+
delete process.env.MEGACOMPACT_STATE_DIR;
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
// ── C.1: providerCachePct flows into cachePct ─────────────────────────
|
|
85
|
+
it("cachePct equals providerCachePct (not dedup hit rate)", () => {
|
|
86
|
+
const res = computeMegaSnapshot(baseInput({ providerCachePct: 56.2 }));
|
|
87
|
+
assert.equal(res.widgetData.cachePct, 56.2, "cachePct is providerCachePct");
|
|
88
|
+
});
|
|
89
|
+
it("cachePct is 0 when providerCachePct is 0", () => {
|
|
90
|
+
const res = computeMegaSnapshot(baseInput({ providerCachePct: 0 }));
|
|
91
|
+
assert.equal(res.widgetData.cachePct, 0, "cachePct is 0");
|
|
92
|
+
});
|
|
93
|
+
it("cachePct is independent of dedup hit rate", () => {
|
|
94
|
+
const res = computeMegaSnapshot(baseInput({
|
|
95
|
+
providerCachePct: 12.5,
|
|
96
|
+
st: {
|
|
97
|
+
totalTokenEstimate: 99999,
|
|
98
|
+
originalTokens: 99999,
|
|
99
|
+
storageDedupRate: 0.99,
|
|
100
|
+
checkpointCount: 1,
|
|
101
|
+
lastCheckpointId: undefined,
|
|
102
|
+
lastSummary: undefined,
|
|
103
|
+
injectedCount: 0,
|
|
104
|
+
dedupHitRate: 0.05,
|
|
105
|
+
tokensSaved: 200,
|
|
106
|
+
dedupAttempts: 40,
|
|
107
|
+
dedupCollapsed: 5,
|
|
108
|
+
},
|
|
109
|
+
}));
|
|
110
|
+
assert.equal(res.widgetData.cachePct, 12.5, "cachePct ignores dedup");
|
|
111
|
+
});
|
|
112
|
+
// ── megaCacheFlare fields pass through ────────────────────────────────
|
|
113
|
+
it("megaCacheFlare + megaCacheFlarePct pass through to widgetData", () => {
|
|
114
|
+
const res = computeMegaSnapshot(baseInput({ megaCacheFlare: true, megaCacheFlarePct: 287 }));
|
|
115
|
+
assert.equal(res.widgetData.megaCacheFlare, true);
|
|
116
|
+
assert.equal(res.widgetData.megaCacheFlarePct, 287);
|
|
117
|
+
});
|
|
118
|
+
it("megaCacheFlare is false by default", () => {
|
|
119
|
+
const res = computeMegaSnapshot(baseInput());
|
|
120
|
+
assert.equal(res.widgetData.megaCacheFlare, false);
|
|
121
|
+
});
|
|
122
|
+
// ── level pass-through ────────────────────────────────────────────────
|
|
123
|
+
it("level flows from getTurnLevel", () => {
|
|
124
|
+
const res = computeMegaSnapshot(baseInput({ getTurnLevel: () => 42 }));
|
|
125
|
+
assert.equal(res.curLevel, 42);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// barrel (`extensions/mega-runtime.ts` does `export * from "./widget.js"`) and
|
|
15
15
|
// every direct consumer keep resolving the same named exports.
|
|
16
16
|
export { C, PULSE, DEFAULT_PANEL_BG, EFFECT_BASE, panelBgFor, themeAnsi, sgrReset, wrapLine, panelLine, panelBar, effectBorderSgr, effectBar, fmtTokens, ramp, sinceCompactStr, } from "./widget-ansi.js";
|
|
17
|
-
import { C, PULSE, DEFAULT_PANEL_BG, panelBgFor, themeAnsi, sgrReset, wrapLine, panelLine, panelBar, effectBorderSgr, effectBar, fmtTokens, ramp, sinceCompactStr } from "./widget-ansi.js";
|
|
17
|
+
import { C, PULSE, DEFAULT_PANEL_BG, panelBgFor, themeAnsi, sgrReset, wrapLine, panelLine, panelBar, effectBorderSgr, effectBar, fmtTokens, ramp, sinceCompactStr, } from "./widget-ansi.js";
|
|
18
18
|
// ── buildWidgetLines ───────────────────────────────────────────────────────
|
|
19
19
|
// Kept as a free function (not a MegaRuntime method) so runtime.ts stays
|
|
20
20
|
// focused on state management. It reads the WidgetData snapshot + the live
|
|
@@ -44,12 +44,12 @@ export function buildWidgetLines(wd, width, activeAgents) {
|
|
|
44
44
|
// width guard + theme bg apply identically to the full panel. Level is shown
|
|
45
45
|
// only when game mode is on (otherwise just the cache %).
|
|
46
46
|
if (wd.tuiMode === "minimal") {
|
|
47
|
-
const lvl = wd.gameMode ? wd.level ?? 1 : undefined;
|
|
47
|
+
const lvl = wd.gameMode ? (wd.level ?? 1) : undefined;
|
|
48
48
|
const cachePct = wd.cachePct ?? 0;
|
|
49
49
|
const cacheStr = `${Math.round(cachePct * 10) / 10}%`;
|
|
50
50
|
const accent = themeAnsi(wd.theme, "accent");
|
|
51
51
|
const mega = themeAnsi(wd.theme, "mega");
|
|
52
|
-
const megaFlare = wd.gameMode && wd.megaCacheFlare &&
|
|
52
|
+
const megaFlare = wd.gameMode && wd.megaCacheFlare && (wd.megaCacheFlarePct ?? 0) >= 100
|
|
53
53
|
? ` ${mega}MEGA CACHE${sgrReset(mega)}`
|
|
54
54
|
: "";
|
|
55
55
|
const body = lvl != null
|
|
@@ -71,7 +71,7 @@ export function buildWidgetLines(wd, width, activeAgents) {
|
|
|
71
71
|
const lvlPrefix = wd.gameMode
|
|
72
72
|
? `${themeAnsi(wd.theme, "accent")}${wd.gameMode && wd.levelUpFlare ? "\x1b[5m" : ""}LVL ${wd.level ?? 1}${wd.gameMode && wd.levelUpFlare ? "\x1b[0m" : ""}${sgrReset(themeAnsi(wd.theme, "accent"))} `
|
|
73
73
|
: "";
|
|
74
|
-
const megaFlareSuffix = wd.gameMode && wd.megaCacheFlare && (wd.
|
|
74
|
+
const megaFlareSuffix = wd.gameMode && wd.megaCacheFlare && (wd.megaCacheFlarePct ?? 0) >= 100
|
|
75
75
|
? `${sep}${themeAnsi(wd.theme, "mega")}MEGA CACHE! (oops, you cached so hard the dedup caught fire)${sgrReset(themeAnsi(wd.theme, "mega"))}`
|
|
76
76
|
: "";
|
|
77
77
|
// Build one long content line — let terminal wrap it naturally
|
|
@@ -94,6 +94,8 @@ describe("buildWidgetLines (S31)", () => {
|
|
|
94
94
|
level: 1,
|
|
95
95
|
cachePct,
|
|
96
96
|
megaCacheFlare: flare,
|
|
97
|
+
// C.3: flare gate reads megaCacheFlarePct, not cachePct
|
|
98
|
+
...(flare ? { megaCacheFlarePct: cachePct } : {}),
|
|
97
99
|
});
|
|
98
100
|
const lines = buildWidgetLines(wd, WIDTH, 0);
|
|
99
101
|
const body = contentLines(lines);
|
|
@@ -138,11 +140,21 @@ describe("buildWidgetLines (S31)", () => {
|
|
|
138
140
|
});
|
|
139
141
|
describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
|
|
140
142
|
const effBase = (overrides = {}) => baseWd({
|
|
141
|
-
theme: DEFAULT_THEME,
|
|
143
|
+
theme: DEFAULT_THEME,
|
|
144
|
+
tuiMode: "full",
|
|
145
|
+
gameMode: true,
|
|
146
|
+
level: 1,
|
|
147
|
+
cachePct: 42,
|
|
148
|
+
...overrides,
|
|
142
149
|
});
|
|
143
150
|
const isBorder = (l) => l.includes("─");
|
|
144
151
|
it("activeEffect (pulse, mid-window) -> border lines carry a 256-color fg SGR", () => {
|
|
145
|
-
const ae = {
|
|
152
|
+
const ae = {
|
|
153
|
+
type: "pulse",
|
|
154
|
+
role: "accent",
|
|
155
|
+
startedAt: Date.now() - 250,
|
|
156
|
+
durationMs: 2000,
|
|
157
|
+
};
|
|
146
158
|
const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
|
|
147
159
|
const borders = lines.filter(isBorder);
|
|
148
160
|
assert.ok(borders.length >= 2, "has top + bottom borders");
|
|
@@ -158,7 +170,12 @@ describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
|
|
|
158
170
|
}
|
|
159
171
|
});
|
|
160
172
|
it("expired activeEffect -> plain borders (per-frame expiry enforced)", () => {
|
|
161
|
-
const ae = {
|
|
173
|
+
const ae = {
|
|
174
|
+
type: "pulse",
|
|
175
|
+
role: "accent",
|
|
176
|
+
startedAt: Date.now() - 5000,
|
|
177
|
+
durationMs: 1000,
|
|
178
|
+
};
|
|
162
179
|
const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
|
|
163
180
|
const borders = lines.filter(isBorder);
|
|
164
181
|
for (const b of borders) {
|
|
@@ -167,7 +184,12 @@ describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
|
|
|
167
184
|
});
|
|
168
185
|
it("activeEffect border lines are width-safe (pulse, minimal + full)", () => {
|
|
169
186
|
for (const tuiMode of ["minimal", "full"]) {
|
|
170
|
-
const ae = {
|
|
187
|
+
const ae = {
|
|
188
|
+
type: "pulse",
|
|
189
|
+
role: "mega",
|
|
190
|
+
startedAt: Date.now() - 100,
|
|
191
|
+
durationMs: 2000,
|
|
192
|
+
};
|
|
171
193
|
const lines = buildWidgetLines(effBase({ activeEffect: ae, tuiMode }), 60, 0);
|
|
172
194
|
for (const l of lines)
|
|
173
195
|
assert.ok(visibleWidth(l) <= 60, `width safe (${tuiMode}): ${visibleWidth(l)}`);
|
|
@@ -175,12 +197,53 @@ describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
|
|
|
175
197
|
});
|
|
176
198
|
it("flash effect mid-window border carries the full base index SGR", () => {
|
|
177
199
|
// Force an 'on' phase of the 120ms alternate by starting just now.
|
|
178
|
-
const ae = {
|
|
200
|
+
const ae = {
|
|
201
|
+
type: "flash",
|
|
202
|
+
role: "red",
|
|
203
|
+
startedAt: Date.now(),
|
|
204
|
+
durationMs: 1200,
|
|
205
|
+
};
|
|
179
206
|
const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
|
|
180
207
|
const borders = lines.filter(isBorder);
|
|
181
208
|
assert.ok(borders.some((b) => b.includes("\x1b[38;5;203m")), `flash-on phase uses red base 203`);
|
|
182
209
|
});
|
|
183
210
|
});
|
|
211
|
+
describe("buildWidgetLines C.3 flare gate regression", () => {
|
|
212
|
+
const flareBase = (overrides = {}) => baseWd({
|
|
213
|
+
theme: DEFAULT_THEME,
|
|
214
|
+
tuiMode: "full",
|
|
215
|
+
gameMode: true,
|
|
216
|
+
level: 1,
|
|
217
|
+
cachePct: 42,
|
|
218
|
+
...overrides,
|
|
219
|
+
});
|
|
220
|
+
it("flare fires when megaCacheFlarePct >= 100 even when cachePct < 100", () => {
|
|
221
|
+
// Regression: the old code read (wd.cachePct ?? 0) >= 100, so when
|
|
222
|
+
// cachePct was 56 (provider hit rate) the flare silently broke.
|
|
223
|
+
const lines = buildWidgetLines(flareBase({
|
|
224
|
+
cachePct: 56,
|
|
225
|
+
megaCacheFlare: true,
|
|
226
|
+
megaCacheFlarePct: 287,
|
|
227
|
+
}), WIDTH, 0);
|
|
228
|
+
assert.ok(lines.join("\n").includes("MEGA CACHE"), "flare fires from megaCacheFlarePct");
|
|
229
|
+
});
|
|
230
|
+
it("flare suppressed when megaCacheFlarePct < 100 even when cachePct >= 100", () => {
|
|
231
|
+
// Symmetric regression: high cachePct should NOT alone trigger flare.
|
|
232
|
+
const lines = buildWidgetLines(flareBase({
|
|
233
|
+
cachePct: 150,
|
|
234
|
+
megaCacheFlare: true,
|
|
235
|
+
megaCacheFlarePct: 42,
|
|
236
|
+
}), WIDTH, 0);
|
|
237
|
+
assert.ok(!lines.join("\n").includes("MEGA CACHE"), "no flare when megaCacheFlarePct below threshold");
|
|
238
|
+
});
|
|
239
|
+
it("flare suppressed when megaCacheFlarePct is missing (undefined)", () => {
|
|
240
|
+
const lines = buildWidgetLines(flareBase({
|
|
241
|
+
cachePct: 150,
|
|
242
|
+
megaCacheFlare: true,
|
|
243
|
+
}), WIDTH, 0);
|
|
244
|
+
assert.ok(!lines.join("\n").includes("MEGA CACHE"), "no flare without megaCacheFlarePct");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
184
247
|
describe("buildWidgetLines footer stability (P1 — no 250ms rotation)", () => {
|
|
185
248
|
// P1: the L5 ticker branch used to re-pick the head text every 250ms via
|
|
186
249
|
// `step = floor(Date.now()/250)`, flipping the footer line on a 250·N ms
|
|
@@ -188,7 +251,12 @@ describe("buildWidgetLines footer stability (P1 — no 250ms rotation)", () => {
|
|
|
188
251
|
// the SAME WidgetData at t and t+500ms (well past one rotation slot) must
|
|
189
252
|
// produce byte-identical footer lines. Proves the metronome is gone.
|
|
190
253
|
const stabBase = (overrides = {}) => baseWd({
|
|
191
|
-
theme: DEFAULT_THEME,
|
|
254
|
+
theme: DEFAULT_THEME,
|
|
255
|
+
tuiMode: "full",
|
|
256
|
+
gameMode: false,
|
|
257
|
+
level: 1,
|
|
258
|
+
cachePct: 42,
|
|
259
|
+
...overrides,
|
|
192
260
|
});
|
|
193
261
|
it("ticker footer is byte-identical across simulated frames 500ms apart", () => {
|
|
194
262
|
const wd = stabBase({
|
|
@@ -220,7 +288,12 @@ describe("buildWidgetLines footer stability (P1 — no 250ms rotation)", () => {
|
|
|
220
288
|
try {
|
|
221
289
|
Date.now = () => 1_000_000;
|
|
222
290
|
const before = buildWidgetLines(stabBase({ ticker: [{ text: "a", at: 1 }] }), WIDTH, 0);
|
|
223
|
-
const after = buildWidgetLines(stabBase({
|
|
291
|
+
const after = buildWidgetLines(stabBase({
|
|
292
|
+
ticker: [
|
|
293
|
+
{ text: "a", at: 1 },
|
|
294
|
+
{ text: "b", at: 2 },
|
|
295
|
+
],
|
|
296
|
+
}), WIDTH, 0);
|
|
224
297
|
assert.notDeepEqual(before, after, "footer changes when ticker grows");
|
|
225
298
|
}
|
|
226
299
|
finally {
|
|
@@ -230,22 +303,40 @@ describe("buildWidgetLines footer stability (P1 — no 250ms rotation)", () => {
|
|
|
230
303
|
});
|
|
231
304
|
describe("buildWidgetLines achievement flare (S35)", () => {
|
|
232
305
|
const achBase = (overrides = {}) => baseWd({
|
|
233
|
-
theme: DEFAULT_THEME,
|
|
306
|
+
theme: DEFAULT_THEME,
|
|
307
|
+
tuiMode: "full",
|
|
308
|
+
gameMode: true,
|
|
309
|
+
level: 1,
|
|
310
|
+
cachePct: 42,
|
|
311
|
+
...overrides,
|
|
234
312
|
});
|
|
235
313
|
it("achievementFlare + titles -> renders the unlock toast line", () => {
|
|
236
|
-
const lines = buildWidgetLines(achBase({
|
|
314
|
+
const lines = buildWidgetLines(achBase({
|
|
315
|
+
achievementFlare: true,
|
|
316
|
+
achievementFlareTitles: ["First Compact"],
|
|
317
|
+
}), WIDTH, 0);
|
|
237
318
|
assert.ok(lines.some((l) => l.includes("Achievement unlocked: First Compact")), "toast line present");
|
|
238
319
|
});
|
|
239
320
|
it("achievementFlare off -> no toast line", () => {
|
|
240
|
-
const lines = buildWidgetLines(achBase({
|
|
321
|
+
const lines = buildWidgetLines(achBase({
|
|
322
|
+
achievementFlare: false,
|
|
323
|
+
achievementFlareTitles: ["First Compact"],
|
|
324
|
+
}), WIDTH, 0);
|
|
241
325
|
assert.ok(!lines.some((l) => l.includes("Achievement unlocked")), "no toast when flare off");
|
|
242
326
|
});
|
|
243
327
|
it("gameMode off -> no toast even if flare set", () => {
|
|
244
|
-
const lines = buildWidgetLines(achBase({
|
|
328
|
+
const lines = buildWidgetLines(achBase({
|
|
329
|
+
gameMode: false,
|
|
330
|
+
achievementFlare: true,
|
|
331
|
+
achievementFlareTitles: ["X"],
|
|
332
|
+
}), WIDTH, 0);
|
|
245
333
|
assert.ok(!lines.some((l) => l.includes("Achievement unlocked")), "no toast when game off");
|
|
246
334
|
});
|
|
247
335
|
it("achievement toast is width-safe", () => {
|
|
248
|
-
const lines = buildWidgetLines(achBase({
|
|
336
|
+
const lines = buildWidgetLines(achBase({
|
|
337
|
+
achievementFlare: true,
|
|
338
|
+
achievementFlareTitles: ["First Compact", "Turn Veteran"],
|
|
339
|
+
}), 60, 0);
|
|
249
340
|
for (const l of lines)
|
|
250
341
|
assert.ok(visibleWidth(l) <= 60, "width safe");
|
|
251
342
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pricing.ts — Reusable pricing constants and cache-savings computation.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for provider prompt-cache pricing multipliers and
|
|
5
|
+
* known model input rates. Extracted from routes-cache.ts inline arithmetic.
|
|
6
|
+
*
|
|
7
|
+
* PREVENT-PI-004: compute-only, zero network. No SQL, no IO.
|
|
8
|
+
*/
|
|
9
|
+
// ─── Cache Pricing Multipliers ──────────────────────────────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* Fraction of input cost saved per cache-read token.
|
|
12
|
+
* Cache reads cost 10% of full input → you save 90% of the input cost.
|
|
13
|
+
*/
|
|
14
|
+
export const CACHE_READ_MULTIPLIER = 0.9;
|
|
15
|
+
/**
|
|
16
|
+
* Fraction of input cost incurred as a premium per cache-write token.
|
|
17
|
+
* Cache writes cost 125% of full input → a 25% premium over a normal input token.
|
|
18
|
+
*/
|
|
19
|
+
export const CACHE_WRITE_MULTIPLIER = 0.25;
|
|
20
|
+
// ─── Known Model Input Rates ─────────────────────────────────────────────────
|
|
21
|
+
// USD per input token. Exact-match first, then prefix-match.
|
|
22
|
+
export const MODEL_INPUT_RATES = {
|
|
23
|
+
"claude-sonnet-4": 3e-6, // $3/Mtok
|
|
24
|
+
"claude-3.5-sonnet": 3e-6,
|
|
25
|
+
"claude-3-opus": 15e-6,
|
|
26
|
+
"claude-3-haiku": 0.25e-6,
|
|
27
|
+
"claude-3.5-haiku": 0.8e-6,
|
|
28
|
+
"gpt-4o": 2.5e-6,
|
|
29
|
+
"gpt-4o-mini": 0.15e-6,
|
|
30
|
+
"gpt-4-turbo": 10e-6,
|
|
31
|
+
"gpt-3.5-turbo": 0.5e-6,
|
|
32
|
+
"gemini-2.5-pro": 1.25e-6,
|
|
33
|
+
"gemini-2.5-flash": 0.15e-6,
|
|
34
|
+
"gemini-2.0-flash": 0.1e-6,
|
|
35
|
+
"deepseek-chat": 0.14e-6,
|
|
36
|
+
"deepseek-reasoner": 0.55e-6,
|
|
37
|
+
"codestral": 0.3e-6,
|
|
38
|
+
"mistral-large": 2e-6,
|
|
39
|
+
"llama-3.1-405b": 1.33e-6,
|
|
40
|
+
"llama-3.1-70b": 0.8e-6,
|
|
41
|
+
"llama-3.1-8b": 0.05e-6,
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Look up the input-token rate (USD) for a model string.
|
|
45
|
+
* Exact match first, then prefix match (e.g. "claude-sonnet-4-20250514" → 3e-6).
|
|
46
|
+
*/
|
|
47
|
+
export function lookupModelInputRate(model) {
|
|
48
|
+
// exact match first
|
|
49
|
+
if (MODEL_INPUT_RATES[model] != null)
|
|
50
|
+
return MODEL_INPUT_RATES[model];
|
|
51
|
+
// prefix match
|
|
52
|
+
for (const [key, rate] of Object.entries(MODEL_INPUT_RATES)) {
|
|
53
|
+
if (model.startsWith(key))
|
|
54
|
+
return rate;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Compute dollar savings from provider prompt cache read/write token counts.
|
|
60
|
+
*
|
|
61
|
+
* @param totalCacheRead — total cache-read tokens over the lifetime.
|
|
62
|
+
* @param totalCacheWrite — total cache-write tokens over the lifetime.
|
|
63
|
+
* @param inputRate — the model's input-token rate in USD (e.g. 3e-6 for Sonnet).
|
|
64
|
+
* @returns `{ cacheReadSaved, cacheWriteCost, netSaved }` — or all zeros when
|
|
65
|
+
* `inputRate` is zero or negative.
|
|
66
|
+
*/
|
|
67
|
+
export function computeCacheSavings(totalCacheRead, totalCacheWrite, inputRate) {
|
|
68
|
+
if (inputRate <= 0) {
|
|
69
|
+
return { cacheReadSaved: 0, cacheWriteCost: 0, netSaved: 0 };
|
|
70
|
+
}
|
|
71
|
+
const cacheReadSaved = totalCacheRead * inputRate * CACHE_READ_MULTIPLIER;
|
|
72
|
+
const cacheWriteCost = totalCacheWrite * inputRate * CACHE_WRITE_MULTIPLIER;
|
|
73
|
+
return {
|
|
74
|
+
cacheReadSaved,
|
|
75
|
+
cacheWriteCost,
|
|
76
|
+
netSaved: cacheReadSaved - cacheWriteCost,
|
|
77
|
+
};
|
|
78
|
+
}
|