pi-cache-optimizer 2.8.15 → 2.8.17

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.
@@ -0,0 +1,771 @@
1
+ import { type CacheProviderAdapter, isVirtualRoutingModel, selectAdapterForModel } from "./adapters.ts";
2
+ import { LOG_PREFIX, type PiModel, asRecord, getErrorCode, getNonNegativeNumber } from "./common.ts";
3
+ import { type FooterStatsMode } from "./config.ts";
4
+ import { modelKey } from "./model-identity.ts";
5
+ import { STATE_DIR } from "./paths.ts";
6
+ import { CACHE_PROVIDER_IDS, type CacheProviderId, type CacheStats, LEGACY_STATE_FILE_PATH, type PersistedRoutedModelRef, STATE_FILE_PATH, type ShardAggregate, currentLocalDay, emptyCacheStats, mergeCacheStatsForTotal, parseCacheStats, parsePersistedRoutedModelRef } from "./stats-store.ts";
7
+ import { usageRecordFromAssistant } from "./usage.ts";
8
+ import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+
12
+ export type CacheStatsState = {
13
+ statsByModel: Record<string, CacheStats>;
14
+ totalsByModel: Record<string, CacheStats>;
15
+ legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
16
+ lastRoutedModelBySession?: Record<string, PersistedRoutedModelRef>;
17
+ };
18
+
19
+ export type PersistedCacheStatsV6 = {
20
+ version: 6;
21
+ sessions: Record<string, Record<string, CacheStats>>;
22
+ totalsByModel: Record<string, CacheStats>;
23
+ legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
24
+ lastRoutedModelBySession?: Record<string, PersistedRoutedModelRef>;
25
+ };
26
+
27
+ export type CacheUsageSample = {
28
+ timestamp: number;
29
+ hit: boolean;
30
+ cachedInputTokens: number;
31
+ cacheWriteInputTokens: number;
32
+ totalInputTokens: number;
33
+ missingUsageFields: boolean;
34
+ };
35
+
36
+ /**
37
+ * Build a session-scoped stats key from a session hash + provider/id.
38
+ * Pure function (no closure dependency) for use by tests and internals.
39
+ */
40
+ export function makeSessionModelKey(sessionHash: string, provider: string, id: string): string {
41
+ return `${sessionHash}:${provider}/${id}`;
42
+ }
43
+
44
+ /**
45
+ * Extract the user-facing model key from a session-scoped key.
46
+ * "abc123:otokapi/gpt-5.5" → "otokapi/gpt-5.5"
47
+ */
48
+ export function modelKeyFromSessionKey(sessionModelKey: string): string {
49
+ const idx = sessionModelKey.indexOf(":");
50
+ return idx >= 0 ? sessionModelKey.slice(idx + 1) : sessionModelKey;
51
+ }
52
+
53
+ export function formatTokenCount(value: number): string {
54
+ const millions = Math.max(0, Math.round(value)) / 1_000_000;
55
+ if (millions === 0) return "0M";
56
+ if (millions < 0.001) return `${millions.toFixed(4)}M`;
57
+ if (millions < 0.01) return `${millions.toFixed(3)}M`;
58
+ if (millions >= 10) return `${millions.toFixed(1)}M`;
59
+ return `${millions.toFixed(2)}M`;
60
+ }
61
+
62
+ export function formatCacheStats(adapter: CacheProviderAdapter, stats: CacheStats): string {
63
+ const percent = stats.totalInputTokens > 0
64
+ ? (stats.cachedInputTokens / stats.totalInputTokens) * 100
65
+ : 0;
66
+ const writeText = adapter.showCacheWrite && stats.cacheWriteInputTokens > 0
67
+ ? `·write ${formatTokenCount(stats.cacheWriteInputTokens)}`
68
+ : "";
69
+
70
+ return `${adapter.label} ${stats.hitRequests}/${stats.totalRequests}·${formatTokenCount(stats.cachedInputTokens)}/${formatTokenCount(stats.totalInputTokens)} ${percent.toFixed(1)}%${writeText}`;
71
+ }
72
+
73
+ export function prefixFooterStatus(statusText: string | undefined): string | undefined {
74
+ if (!statusText || statusText.startsWith("· ")) return statusText;
75
+ return `· ${statusText}`;
76
+ }
77
+
78
+ /**
79
+ * Compute a hit-ratio percentage string for a value between 0 and 1.
80
+ * Returns e.g. "75%", "0%", "100%", or "N/A" for zero total.
81
+ */
82
+ export function formatHitRatio(hits: number, total: number): string {
83
+ if (total <= 0) return "N/A";
84
+ return `${Math.round((hits / total) * 100)}%`;
85
+ }
86
+
87
+ /**
88
+ * Format a token-to-M abbreviation for stats output.
89
+ * Example: 1500000 → "1.50M"
90
+ */
91
+ export function formatTokenM(value: number): string {
92
+ const millions = Math.max(0, Math.round(value)) / 1_000_000;
93
+ if (millions === 0) return "0";
94
+ if (millions < 0.01) return millions.toFixed(4);
95
+ if (millions >= 10) return millions.toFixed(1);
96
+ return millions.toFixed(2);
97
+ }
98
+
99
+ /**
100
+ * Check if an assistant message's usage fields appear to be missing or empty.
101
+ * Returns true when Pi-normalized fields (input, cacheRead, cacheWrite) are all
102
+ * absent/zero AND raw usage fields (prompt_tokens, etc.) are also absent/zero
103
+ * for the given adapter.
104
+ */
105
+ export function hasMissingUsageFields(message: unknown, adapter: CacheProviderAdapter): boolean {
106
+ const usage = usageRecordFromAssistant(message);
107
+ if (!usage) return true;
108
+
109
+ // Check Pi-normalized fields
110
+ const input = getNonNegativeNumber(usage, "input");
111
+ const cacheRead = getNonNegativeNumber(usage, "cacheRead");
112
+ const cacheWrite = getNonNegativeNumber(usage, "cacheWrite");
113
+
114
+ // If Pi-normalized fields exist with non-zero values, usage is present
115
+ if (cacheRead !== undefined || cacheWrite !== undefined || (input !== undefined && input > 0)) {
116
+ return false;
117
+ }
118
+
119
+ // Check raw usage for the adapter's provider family
120
+ const rawUsage = adapter.normalizeUsage(message);
121
+ if (!rawUsage || (rawUsage.cacheRead === 0 && rawUsage.cacheWrite === 0 && rawUsage.totalInput === 0)) {
122
+ return true;
123
+ }
124
+
125
+ return false;
126
+ }
127
+
128
+ /**
129
+ * Build a summary string for the recent trend (last N samples).
130
+ * Example: "Recent 10: 7/10 hits · 65% tok cached · no missing usage"
131
+ */
132
+ export function formatRecentTrendSummary(samples: CacheUsageSample[], maxCount: number): string {
133
+ const recent = samples.slice(-maxCount);
134
+ if (recent.length === 0) return `Recent ${maxCount}: no samples yet`;
135
+
136
+ const hits = recent.filter((s) => s.hit).length;
137
+ const totalCached = recent.reduce((sum, s) => sum + s.cachedInputTokens, 0);
138
+ const totalInput = recent.reduce((sum, s) => sum + s.totalInputTokens, 0);
139
+ const missingCount = recent.filter((s) => s.missingUsageFields).length;
140
+
141
+ const hitRatio = formatHitRatio(hits, recent.length);
142
+ const tokenRatio = totalInput > 0 ? formatHitRatio(totalCached, totalInput) : "N/A";
143
+
144
+ let result = `Recent ${recent.length}/${maxCount}: ${hits}/${recent.length} hits · ${tokenRatio} tok cached`;
145
+ if (missingCount > 0) {
146
+ result += ` · ${missingCount} missing usage`;
147
+ }
148
+ return result;
149
+ }
150
+
151
+ /**
152
+ * Build the output for `/cache-optimizer stats`.
153
+ */
154
+ export function formatCompactStats(stats: CacheStats): string {
155
+ const percent = stats.totalInputTokens > 0
156
+ ? (stats.cachedInputTokens / stats.totalInputTokens) * 100
157
+ : 0;
158
+ return `${stats.hitRequests}/${stats.totalRequests}·${formatTokenCount(stats.cachedInputTokens)}/${formatTokenCount(stats.totalInputTokens)} ${percent.toFixed(1)}%`;
159
+ }
160
+
161
+ export function modelFromStatsKey(key: string, ref?: PersistedRoutedModelRef): PiModel | undefined {
162
+ const slash = key.indexOf("/");
163
+ if (slash <= 0 || slash >= key.length - 1) return undefined;
164
+ return routedModelRefToPiModel(ref ?? { provider: key.slice(0, slash), id: key.slice(slash + 1) });
165
+ }
166
+
167
+ export function sortStatsEntries(entries: Array<[string, CacheStats]>, activeModelKey?: string): Array<[string, CacheStats]> {
168
+ return entries.sort(([left], [right]) => {
169
+ if (left === activeModelKey) return -1;
170
+ if (right === activeModelKey) return 1;
171
+ return left.localeCompare(right);
172
+ });
173
+ }
174
+
175
+ export function buildSessionStatsOutput(
176
+ sessionModels: Record<string, CacheStats>,
177
+ activeModel?: PiModel,
178
+ modelRefsByKey: Record<string, PersistedRoutedModelRef> = {},
179
+ ): string {
180
+ const activeKey = activeModel ? modelKey(activeModel) : undefined;
181
+ const models = { ...sessionModels };
182
+ if (activeModel && selectAdapterForModel(activeModel) && !models[activeKey!]) {
183
+ models[activeKey!] = emptyCacheStats();
184
+ }
185
+ const entries = sortStatsEntries(Object.entries(models), activeKey);
186
+ if (entries.length === 0) return "ℹ️ No cache statistics recorded for the current session today.";
187
+ const lines = ["Scope: current session", `Day: ${currentLocalDay()}`, `Models: ${entries.length}`];
188
+ for (const [key, stats] of entries) {
189
+ const model = modelFromStatsKey(key, modelRefsByKey[key]);
190
+ const adapter = model ? selectAdapterForModel(model) : undefined;
191
+ lines.push("", `── ${key} ──`, `Adapter: ${adapter?.label ?? "Unknown cache adapter"}`);
192
+ lines.push(`Requests: ${stats.hitRequests} hit / ${stats.totalRequests} total`);
193
+ lines.push(`Cached tokens: ${formatTokenCount(stats.cachedInputTokens)} / ${formatTokenCount(stats.totalInputTokens)} input · ${stats.totalInputTokens > 0 ? `${((stats.cachedInputTokens / stats.totalInputTokens) * 100).toFixed(1)}%` : "0.0%"}`);
194
+ lines.push(`Summary: ${formatCompactStats(stats)}`);
195
+ if (stats.cacheWriteInputTokens > 0) lines.push(`Cache write: ${formatTokenCount(stats.cacheWriteInputTokens)}`);
196
+ }
197
+ return lines.join("\n");
198
+ }
199
+
200
+ export function buildAllStatsOutput(aggregate: ShardAggregate): string {
201
+ const entries = sortStatsEntries(Object.entries(aggregate.totalsByModel));
202
+ if (entries.length === 0) return "ℹ️ No local cache statistics recorded today.";
203
+ const sessionCount = Object.keys(aggregate.instancesBySession).length;
204
+ const instanceCount = Object.values(aggregate.instancesBySession).reduce((sum, value) => sum + value, 0);
205
+ const lines = ["Scope: all local sessions", `Day: ${currentLocalDay()}`, `Sessions: ${sessionCount}`, `Instances: ${instanceCount}`];
206
+ for (const [key, stats] of entries) {
207
+ const model = modelFromStatsKey(key, aggregate.modelRefsByKey[key]);
208
+ const adapter = model ? selectAdapterForModel(model) : undefined;
209
+ lines.push("", `── ${key} ──`, `Adapter: ${adapter?.label ?? "Unknown cache adapter"}`);
210
+ lines.push(`Sessions: ${aggregate.sessionsByModel[key] ?? 0} · Instances: ${aggregate.instancesByModel[key] ?? 0}`);
211
+ lines.push(`Requests: ${stats.hitRequests} hit / ${stats.totalRequests} total`);
212
+ lines.push(`Cached tokens: ${formatTokenCount(stats.cachedInputTokens)} / ${formatTokenCount(stats.totalInputTokens)} input · ${stats.totalInputTokens > 0 ? `${((stats.cachedInputTokens / stats.totalInputTokens) * 100).toFixed(1)}%` : "0.0%"}`);
213
+ lines.push(`Summary: ${formatCompactStats(stats)}`);
214
+ if (stats.cacheWriteInputTokens > 0) lines.push(`Cache write: ${formatTokenCount(stats.cacheWriteInputTokens)}`);
215
+ }
216
+ return lines.join("\n");
217
+ }
218
+
219
+ export function buildContributorsStatsOutput(aggregate: ShardAggregate, activeModel: PiModel | undefined, currentSessionHash?: string): string {
220
+ if (!activeModel) return "ℹ️ No active model selected.";
221
+ const key = modelKey(activeModel);
222
+ const rows = Object.entries(aggregate.bySession)
223
+ .filter(([, models]) => models[key])
224
+ .sort(([left], [right]) => left === currentSessionHash ? -1 : right === currentSessionHash ? 1 : left.localeCompare(right));
225
+ if (rows.length === 0) return `ℹ️ No contributors recorded for ${key} today.`;
226
+ const lines = [`Model: ${key}`, "Scope: contributing local sessions today"];
227
+ let otherIndex = 0;
228
+ for (const [sessionHash, models] of rows) {
229
+ const label = sessionHash === currentSessionHash ? "Current session" : `Other session ${++otherIndex}`;
230
+ lines.push("", label, ` Instances: ${aggregate.instancesBySessionModel[sessionHash]?.[key] ?? 0}`, ` ${formatCompactStats(models[key])}`);
231
+ }
232
+ return lines.join("\n");
233
+ }
234
+
235
+ export function buildStatsOutput(model: PiModel | undefined, adapter: CacheProviderAdapter | undefined, stats: CacheStats | undefined, recentSamples: CacheUsageSample[]): string {
236
+ const lines: string[] = [];
237
+
238
+ if (!model || !adapter) {
239
+ lines.push("ℹ️ No cache-adapter-matched model active. Select a model with a recognized provider family.");
240
+ return lines.join("\n");
241
+ }
242
+
243
+ const key = modelKey(model);
244
+ const currentStats = stats ?? emptyCacheStats();
245
+
246
+ lines.push(`Model key: ${key}`);
247
+ lines.push(`Adapter: ${adapter.label}`);
248
+ lines.push("");
249
+ lines.push("── Today ──");
250
+ lines.push(`Requests: ${currentStats.hitRequests} hit / ${currentStats.totalRequests} total · ${formatHitRatio(currentStats.hitRequests, currentStats.totalRequests)}`);
251
+ lines.push(`Cached tokens: ${formatTokenM(currentStats.cachedInputTokens)}M / ${formatTokenM(currentStats.totalInputTokens)}M input · ${currentStats.totalInputTokens > 0 ? `${Math.round((currentStats.cachedInputTokens / currentStats.totalInputTokens) * 100)}%` : "N/A"}`);
252
+ if (currentStats.cacheWriteInputTokens > 0) {
253
+ lines.push(`Cache write: ${formatTokenM(currentStats.cacheWriteInputTokens)}M tok`);
254
+ }
255
+
256
+ lines.push("");
257
+ lines.push("── Recent trend ──");
258
+ lines.push(formatRecentTrendSummary(recentSamples, 10));
259
+ lines.push(formatRecentTrendSummary(recentSamples, 30));
260
+
261
+ // Check if any sample has missingUsageFields flagged
262
+ const missingAny = recentSamples.some((s) => s.missingUsageFields);
263
+ if (missingAny) {
264
+ lines.push("");
265
+ lines.push("⚠️ Some recent responses had missing or empty cache usage fields. Footer may under-report hits.");
266
+ lines.push(" The proxy may not return prompt_cache_hit_tokens or usage.input/cacheRead in responses.");
267
+ }
268
+
269
+ return lines.join("\n");
270
+ }
271
+
272
+ export function deriveTotalsByModelFromSessionStats(statsByModel: Record<string, CacheStats>): Record<string, CacheStats> {
273
+ const totals: Record<string, CacheStats> = {};
274
+ for (const [fullKey, stats] of Object.entries(statsByModel)) {
275
+ totals[modelKeyFromSessionKey(fullKey)] = mergeCacheStatsForTotal(
276
+ totals[modelKeyFromSessionKey(fullKey)],
277
+ stats,
278
+ );
279
+ }
280
+ return totals;
281
+ }
282
+
283
+ export function parsePersistedTotalsByModel(value: unknown): Record<string, CacheStats> | undefined {
284
+ const record = asRecord(value);
285
+ if (!record) return undefined;
286
+
287
+ const totals: Record<string, CacheStats> = {};
288
+ for (const [modelKeyStr, rawStats] of Object.entries(record)) {
289
+ const stats = parseCacheStats(rawStats);
290
+ if (stats) totals[modelKeyStr] = stats;
291
+ }
292
+ return totals;
293
+ }
294
+
295
+ export function routedModelRefToPiModel(ref: PersistedRoutedModelRef): PiModel {
296
+ return {
297
+ id: ref.id,
298
+ name: ref.name ?? ref.id,
299
+ provider: ref.provider,
300
+ api: "",
301
+ baseUrl: "",
302
+ reasoning: false,
303
+ input: ["text"],
304
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
305
+ contextWindow: 0,
306
+ maxTokens: 0,
307
+ } as PiModel;
308
+ }
309
+
310
+ export function selectFooterStatsForModel(
311
+ mode: FooterStatsMode,
312
+ sessionHash: string | undefined,
313
+ statsByModel: Record<string, CacheStats>,
314
+ totalsByModel: Record<string, CacheStats>,
315
+ model: { provider: string; id: string },
316
+ processByModel: Record<string, CacheStats> = {},
317
+ ): CacheStats | undefined {
318
+ if (mode === "total") return totalsByModel[modelKey(model)];
319
+ if (mode === "process") return processByModel[modelKey(model)];
320
+ if (!sessionHash) return undefined;
321
+ return statsByModel[makeSessionModelKey(sessionHash, model.provider, model.id)];
322
+ }
323
+
324
+ export function parsePersistedCacheStats(value: unknown): CacheStatsState | undefined {
325
+ const record = asRecord(value);
326
+ if (!record) return undefined;
327
+
328
+ // version 4/5/6: session-scoped stats + legacy family fallback.
329
+ // v5 additionally persists the last actual routed model per session so
330
+ // router/auto can restore the exact upstream footer after /reload.
331
+ // v6 adds provider/model totals used for footer continuity across Pi
332
+ // process/terminal restarts, while retaining session buckets for migration
333
+ // and best-effort preservation of older data.
334
+ if (record.version === 4 || record.version === 5 || record.version === 6) {
335
+ const legacyFamily: Partial<Record<CacheProviderId, CacheStats>> = {};
336
+ const rawFamily = asRecord(record.legacyFamily);
337
+ if (rawFamily) {
338
+ for (const id of CACHE_PROVIDER_IDS) {
339
+ const stats = parseCacheStats(rawFamily[id]);
340
+ if (stats) legacyFamily[id] = stats;
341
+ }
342
+ }
343
+
344
+ // Collect all session entries into statsByModel with session-hash-prefixed keys
345
+ // (e.g. "abc123:otokapi/gpt-5.5") so that writePersistedCacheStats can later
346
+ // reconstruct individual sessions from the flat key format and other sessions'
347
+ // data is not silently lost on round-trip.
348
+ const statsByModel: Record<string, CacheStats> = {};
349
+ const rawSessions = asRecord(record.sessions);
350
+ if (rawSessions) {
351
+ for (const [sessionHash, modelMap] of Object.entries(rawSessions)) {
352
+ const parsedMap = asRecord(modelMap);
353
+ if (parsedMap) {
354
+ for (const [modelKey, val] of Object.entries(parsedMap)) {
355
+ const parsed = parseCacheStats(val);
356
+ if (parsed) statsByModel[`${sessionHash}:${modelKey}`] = parsed;
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ const lastRoutedModelBySession: Record<string, PersistedRoutedModelRef> = {};
363
+ const rawLastRoutedModels = asRecord(record.lastRoutedModelBySession);
364
+ if (rawLastRoutedModels) {
365
+ for (const [sessionHash, rawModel] of Object.entries(rawLastRoutedModels)) {
366
+ const parsed = parsePersistedRoutedModelRef(rawModel);
367
+ if (parsed) lastRoutedModelBySession[sessionHash] = parsed;
368
+ }
369
+ }
370
+
371
+ const parsedTotals = parsePersistedTotalsByModel(record.totalsByModel);
372
+ const totalsByModel = parsedTotals ?? deriveTotalsByModelFromSessionStats(statsByModel);
373
+
374
+ return { statsByModel, totalsByModel, legacyFamily, lastRoutedModelBySession };
375
+ }
376
+
377
+ // version 3: migrate to v4/v5 semantics by wrapping statsByModel into sessions
378
+ if (record.version === 3) {
379
+ const statsByModel: Record<string, CacheStats> = {};
380
+ const rawModelMap = asRecord(record.statsByModel);
381
+ if (rawModelMap) {
382
+ for (const [key, val] of Object.entries(rawModelMap)) {
383
+ const parsed = parseCacheStats(val);
384
+ if (parsed) statsByModel[key] = parsed;
385
+ }
386
+ }
387
+
388
+ const legacyFamily: Partial<Record<CacheProviderId, CacheStats>> = {};
389
+ const rawFamily = asRecord(record.legacyFamily);
390
+ if (rawFamily) {
391
+ for (const id of CACHE_PROVIDER_IDS) {
392
+ const stats = parseCacheStats(rawFamily[id]);
393
+ if (stats) legacyFamily[id] = stats;
394
+ }
395
+ }
396
+
397
+ return { statsByModel, totalsByModel: deriveTotalsByModelFromSessionStats(statsByModel), legacyFamily };
398
+ }
399
+
400
+ // version 2: migrate statsByProvider into legacyFamily
401
+ if (record.version === 2) {
402
+ const statsByProvider = asRecord(record.statsByProvider);
403
+ const legacyFamily: Partial<Record<CacheProviderId, CacheStats>> = {};
404
+ if (statsByProvider) {
405
+ for (const id of CACHE_PROVIDER_IDS) {
406
+ const stats = parseCacheStats(statsByProvider[id]);
407
+ if (stats) legacyFamily[id] = stats;
408
+ }
409
+ }
410
+ return { statsByModel: {}, totalsByModel: {}, legacyFamily };
411
+ }
412
+
413
+ // version 1: single DeepSeek stats -> migrate to legacyFamily.deepseek
414
+ if (record.version === 1) {
415
+ const migrated = parseCacheStats(record.stats);
416
+ return migrated ? { statsByModel: {}, totalsByModel: {}, legacyFamily: { deepseek: migrated } } : undefined;
417
+ }
418
+
419
+ return undefined;
420
+ }
421
+
422
+ export async function readPersistedCacheStats(): Promise<CacheStatsState | undefined> {
423
+ try {
424
+ const raw = await readFile(STATE_FILE_PATH, "utf8");
425
+ return parsePersistedCacheStats(JSON.parse(raw));
426
+ } catch (error) {
427
+ if (getErrorCode(error) !== "ENOENT") {
428
+ console.warn(`${LOG_PREFIX}: failed to read persisted cache stats`, error);
429
+ return undefined;
430
+ }
431
+ }
432
+
433
+ // New path missing: try one-shot migration from the old (pre-rename) path.
434
+ try {
435
+ const raw = await readFile(LEGACY_STATE_FILE_PATH, "utf8");
436
+ const parsed = parsePersistedCacheStats(JSON.parse(raw));
437
+ if (parsed) {
438
+ try {
439
+ await writePersistedCacheStats(parsed);
440
+ // Best-effort delete; if the unlink fails the new path is still authoritative.
441
+ try {
442
+ await unlink(LEGACY_STATE_FILE_PATH);
443
+ } catch (unlinkError) {
444
+ if (getErrorCode(unlinkError) !== "ENOENT") {
445
+ console.warn(`${LOG_PREFIX}: failed to remove legacy stats file`, unlinkError);
446
+ }
447
+ }
448
+ } catch (writeError) {
449
+ console.warn(`${LOG_PREFIX}: failed to migrate legacy cache stats`, writeError);
450
+ }
451
+ return parsed;
452
+ }
453
+ } catch (error) {
454
+ if (getErrorCode(error) !== "ENOENT") {
455
+ console.warn(`${LOG_PREFIX}: failed to read legacy cache stats`, error);
456
+ }
457
+ }
458
+
459
+ return undefined;
460
+ }
461
+
462
+ export function filterRestorableStatsForSession(
463
+ persisted: CacheStatsState | undefined,
464
+ currentSessionHash?: string,
465
+ ): Record<string, CacheStats> {
466
+ if (!persisted || !currentSessionHash) return {};
467
+
468
+ const prefix = `${currentSessionHash}:`;
469
+ const filteredModelStats: Record<string, CacheStats> = {};
470
+ for (const [fullKey, stats] of Object.entries(persisted.statsByModel)) {
471
+ if (fullKey.startsWith(prefix)) {
472
+ filteredModelStats[fullKey] = stats;
473
+ } else if (!fullKey.includes(":")) {
474
+ // Legacy v3-style key without session hash — migrate to current session.
475
+ filteredModelStats[`${currentSessionHash}:${fullKey}`] = stats;
476
+ } else if (fullKey.startsWith("_nosession:")) {
477
+ // Transitional _nosession bucket — migrate to current session.
478
+ filteredModelStats[`${currentSessionHash}:${fullKey.slice("_nosession:".length)}`] = stats;
479
+ }
480
+ }
481
+
482
+ return filteredModelStats;
483
+ }
484
+
485
+ /**
486
+ * The closure-internal writer. Since the closure has access to currentSessionHash,
487
+ * it passes the hash and statsByModel here. This function wraps them in the v4
488
+ * sessions format, combining with any previously-persisted sessions for safety.
489
+ *
490
+ * When called from the closure, `state.statsByModel` contains only the current
491
+ * session's entries (keyed by `${sessionHash}:${provider}/${id}`). We extract
492
+ * the model-key-only entries and store them under the session hash.
493
+ */
494
+ /**
495
+ * Merge in-memory stats state into an existing sessions map for persistence.
496
+ *
497
+ * When `currentSessionHash` is provided (explicit hash mode):
498
+ * - Current-session entries are extracted from `state.statsByModel` (keys
499
+ * prefixed with `currentSessionHash:`) and written under the session hash.
500
+ * - The transitional legacy `_nosession` bucket is DELETED — its entries
501
+ * were already consumed and migrated into memory by `restoreCacheStats`.
502
+ * Keeping `_nosession` on disk would allow resurrection of reset stats
503
+ * on the next reload (the reset-undo bug).
504
+ * - Other real session hashes are preserved intact.
505
+ *
506
+ * When `currentSessionHash` is undefined (no-hash mode):
507
+ * - Keys with a hash prefix (`hash:provider/model`) are grouped under their
508
+ * respective session hashes.
509
+ * - Keys without a hash prefix (legacy v3) are grouped under `_nosession` so
510
+ * `restoreCacheStats` can migrate them on the next load before the session
511
+ * id is known.
512
+ *
513
+ * Historical v1-v6 pure helper (no runtime I/O). Permanent migration tests
514
+ * exercise it directly; v7 startup never reads or writes the obsolete file.
515
+ */
516
+ export function mergeCacheSessions(
517
+ existingSessions: Record<string, Record<string, CacheStats>>,
518
+ state: CacheStatsState,
519
+ currentSessionHash?: string,
520
+ ): Record<string, Record<string, CacheStats>> {
521
+ // Deep-copy to avoid mutating the caller's object.
522
+ const sessions: Record<string, Record<string, CacheStats>> = {};
523
+ for (const [hash, models] of Object.entries(existingSessions)) {
524
+ sessions[hash] = { ...models };
525
+ }
526
+
527
+ if (currentSessionHash !== undefined) {
528
+ // Explicit hash mode: extract this session's data from state.statsByModel.
529
+ // When the session has no entries (e.g. after reset of sole bucket), this
530
+ // still sets an empty map, ensuring the deleted bucket does not return.
531
+ const prefix = `${currentSessionHash}:`;
532
+ const currentModelStats: Record<string, CacheStats> = {};
533
+ for (const [fullKey, stats] of Object.entries(state.statsByModel)) {
534
+ if (fullKey.startsWith(prefix)) {
535
+ currentModelStats[fullKey.slice(prefix.length)] = stats;
536
+ }
537
+ }
538
+ sessions[currentSessionHash] = currentModelStats;
539
+
540
+ // _nosession is a transitional legacy migration bucket — once we write
541
+ // under an authoritative session hash, those entries have already been
542
+ // consumed and migrated into memory by restoreCacheStats. Delete to
543
+ // prevent resurrection of reset stats on the next reload.
544
+ delete sessions["_nosession"];
545
+ } else {
546
+ // No-hash mode: group entries by their existing hash prefix to avoid
547
+ // collapsing multiple sessions into one bucket. Keys without a hash
548
+ // prefix (legacy v3) go under "_nosession" so restoreCacheStats can
549
+ // migrate them to the current session on next load.
550
+ const nosessionMap: Record<string, CacheStats> = {};
551
+ for (const [fullKey, stats] of Object.entries(state.statsByModel)) {
552
+ const idx = fullKey.indexOf(":");
553
+ if (idx >= 0) {
554
+ const hash = fullKey.slice(0, idx);
555
+ const modelKey = fullKey.slice(idx + 1);
556
+ if (!sessions[hash]) sessions[hash] = {};
557
+ sessions[hash][modelKey] = stats;
558
+ } else {
559
+ // Key without hash prefix (legacy v3) — group under _nosession.
560
+ nosessionMap[fullKey] = stats;
561
+ }
562
+ }
563
+ if (Object.keys(nosessionMap).length > 0) {
564
+ sessions["_nosession"] = nosessionMap;
565
+ }
566
+ }
567
+
568
+ return sessions;
569
+ }
570
+
571
+ export function mergeCacheTotals(
572
+ existingTotalsByModel: Record<string, CacheStats>,
573
+ stateTotalsByModel: Record<string, CacheStats>,
574
+ options: { deleteModelKeys?: string[]; replaceTotals?: boolean } = {},
575
+ ): Record<string, CacheStats> {
576
+ const totals = options.replaceTotals
577
+ ? { ...stateTotalsByModel }
578
+ : { ...existingTotalsByModel, ...stateTotalsByModel };
579
+ for (const key of options.deleteModelKeys ?? []) {
580
+ delete totals[key];
581
+ }
582
+ return totals;
583
+ }
584
+
585
+ export function mergeLastRoutedModels(
586
+ existingLastRoutedModelBySession: Record<string, PersistedRoutedModelRef>,
587
+ state: CacheStatsState,
588
+ currentSessionHash?: string,
589
+ ): Record<string, PersistedRoutedModelRef> {
590
+ const merged: Record<string, PersistedRoutedModelRef> = { ...existingLastRoutedModelBySession };
591
+ const incoming = state.lastRoutedModelBySession ?? {};
592
+
593
+ if (currentSessionHash !== undefined) {
594
+ const current = incoming[currentSessionHash];
595
+ if (current) {
596
+ merged[currentSessionHash] = current;
597
+ } else {
598
+ // Explicit deletion: when incoming state has no entry for current session,
599
+ // remove any existing stale entry to reflect intentional reset.
600
+ delete merged[currentSessionHash];
601
+ }
602
+ return merged;
603
+ }
604
+
605
+ for (const [sessionHash, ref] of Object.entries(incoming)) {
606
+ merged[sessionHash] = ref;
607
+ }
608
+ return merged;
609
+ }
610
+
611
+ export async function writePersistedCacheStats(
612
+ state: CacheStatsState,
613
+ currentSessionHash?: string,
614
+ options: { deleteModelKeys?: string[]; replaceTotals?: boolean } = {},
615
+ ): Promise<void> {
616
+ await mkdir(STATE_DIR, { recursive: true });
617
+
618
+ // Read existing file to preserve other sessions' data.
619
+ let existingSessions: Record<string, Record<string, CacheStats>> = {};
620
+ let existingTotalsByModel: Record<string, CacheStats> = {};
621
+ let existingLastRoutedModelBySession: Record<string, PersistedRoutedModelRef> = {};
622
+ try {
623
+ const raw = await readFile(STATE_FILE_PATH, "utf8");
624
+ const parsed = parsePersistedCacheStats(JSON.parse(raw));
625
+ if (parsed) {
626
+ // Reconstruct sessions from statsByModel keys.
627
+ // Each key has form `${hash}:${provider}/${id}`; group by hash.
628
+ for (const [fullKey, stats] of Object.entries(parsed.statsByModel)) {
629
+ const idx = fullKey.indexOf(":");
630
+ if (idx >= 0) {
631
+ const hash = fullKey.slice(0, idx);
632
+ const modelKey = fullKey.slice(idx + 1);
633
+ if (!existingSessions[hash]) existingSessions[hash] = {};
634
+ existingSessions[hash][modelKey] = stats;
635
+ }
636
+ }
637
+ existingTotalsByModel = { ...(parsed.totalsByModel ?? {}) };
638
+ existingLastRoutedModelBySession = { ...(parsed.lastRoutedModelBySession ?? {}) };
639
+ }
640
+ } catch {
641
+ // Ignore read errors (file may not exist yet).
642
+ }
643
+
644
+ const sessions = mergeCacheSessions(existingSessions, state, currentSessionHash);
645
+ const totalsByModel = mergeCacheTotals(existingTotalsByModel, state.totalsByModel, options);
646
+ const lastRoutedModelBySession = mergeLastRoutedModels(
647
+ existingLastRoutedModelBySession,
648
+ state,
649
+ currentSessionHash,
650
+ );
651
+
652
+ const payload: PersistedCacheStatsV6 = {
653
+ version: 6,
654
+ sessions,
655
+ totalsByModel,
656
+ legacyFamily: state.legacyFamily,
657
+ ...(Object.keys(lastRoutedModelBySession).length > 0 ? { lastRoutedModelBySession } : {}),
658
+ };
659
+ const tempPath = `${STATE_FILE_PATH}.${process.pid}.${Date.now()}.tmp`;
660
+
661
+ await writeFile(tempPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
662
+ await rename(tempPath, STATE_FILE_PATH);
663
+ }
664
+
665
+ /** Maximum number of recent samples kept per model key (in-memory only, not persisted). */
666
+ export const MAX_RECENT_SAMPLES = 50;
667
+
668
+ export function keyForModelExt(model: { provider: string; id: string }): string {
669
+ return `${model.provider}/${model.id}`;
670
+ }
671
+
672
+ /**
673
+ * For direct (non-virtual-routing) providers, the upstream API may normalize or
674
+ * rename the model id echoed in its response (e.g. a request to
675
+ * `zai-org/GLM-5.2-FP8` returns a message whose `model` field is
676
+ * `GLM5.2-FP8`). Without a catalog identity, writing stats under the echoed
677
+ * name fragments that request's bucket from its configured model key.
678
+ *
679
+ * Consolidate only against a verified request-local model snapshot, never
680
+ * the model selected when the response finishes. A shared provider/adapter
681
+ * does not prove that two model ids are aliases. The caller prefers an exact
682
+ * catalog model first; this is only the legacy echoed-id fallback. Virtual
683
+ * routing providers retain message-local physical identity.
684
+ */
685
+ export function consolidateDirectProviderStatsModel(
686
+ statsModel: PiModel | undefined,
687
+ requestModel: PiModel | undefined,
688
+ ctx?: Pick<ExtensionContext, "sessionManager">,
689
+ ): PiModel | undefined {
690
+ if (!statsModel || !requestModel) return statsModel;
691
+ // Virtual routing providers keep message-local stats identity.
692
+ if (isVirtualRoutingModel(requestModel, ctx)) return statsModel;
693
+ // Only consolidate within the same provider.
694
+ if (statsModel.provider !== requestModel.provider) return statsModel;
695
+ // Adapter identity is an additional sanity check on the request-bound
696
+ // alias fallback, not sufficient evidence of identity on its own.
697
+ const statsAdapter = selectAdapterForModel(statsModel);
698
+ const requestAdapter = selectAdapterForModel(requestModel);
699
+ if (!statsAdapter || !requestAdapter || statsAdapter !== requestAdapter) return statsModel;
700
+ if (statsModel.id === requestModel.id) return statsModel;
701
+ return {
702
+ ...statsModel,
703
+ id: requestModel.id,
704
+ name: requestModel.name || requestModel.id,
705
+ };
706
+ }
707
+
708
+ export function buildExactRouterStatusEntry(
709
+ sessionHash: string | undefined,
710
+ statsByModel: Record<string, CacheStats>,
711
+ lastRoutedModel: PersistedRoutedModelRef | undefined,
712
+ totalsByModel: Record<string, CacheStats> = {},
713
+ mode: FooterStatsMode = "total",
714
+ processByModel: Record<string, CacheStats> = {},
715
+ ): { model: PiModel; adapter: CacheProviderAdapter; stats: CacheStats } | undefined {
716
+ if (!sessionHash || !lastRoutedModel) return undefined;
717
+
718
+ const model = routedModelRefToPiModel(lastRoutedModel);
719
+ const adapter = selectAdapterForModel(model);
720
+ if (!adapter) return undefined;
721
+
722
+ return {
723
+ model,
724
+ adapter,
725
+ stats: selectFooterStatsForModel(mode, sessionHash, statsByModel, totalsByModel, model, processByModel) ?? emptyCacheStats(),
726
+ };
727
+ }
728
+
729
+ export function findBestRouterModelStats(
730
+ mode: FooterStatsMode,
731
+ sessionHash: string | undefined,
732
+ statsByModel: Record<string, CacheStats>,
733
+ totalsByModel: Record<string, CacheStats>,
734
+ processByModel: Record<string, CacheStats> = {},
735
+ ): { model: PiModel; adapter: CacheProviderAdapter; stats: CacheStats } | undefined {
736
+ const entries = mode === "total"
737
+ ? Object.entries(totalsByModel)
738
+ : mode === "process"
739
+ ? Object.entries(processByModel)
740
+ : sessionHash
741
+ ? Object.entries(statsByModel)
742
+ .filter(([key]) => key.startsWith(`${sessionHash}:`))
743
+ .map(([key, stats]) => [key.slice(sessionHash.length + 1), stats] as const)
744
+ : [];
745
+ let best: { model: PiModel; adapter: CacheProviderAdapter; stats: CacheStats; total: number } | undefined;
746
+
747
+ for (const [modelKeyPart, stats] of entries) {
748
+ const slashIdx = modelKeyPart.indexOf("/");
749
+ if (slashIdx < 1 || slashIdx >= modelKeyPart.length - 1) continue;
750
+ const model = routedModelRefToPiModel({
751
+ provider: modelKeyPart.slice(0, slashIdx),
752
+ id: modelKeyPart.slice(slashIdx + 1),
753
+ });
754
+ const adapter = selectAdapterForModel(model);
755
+ if (!adapter) continue;
756
+ if (!best || stats.totalRequests > best.total) {
757
+ best = { model, adapter, stats, total: stats.totalRequests };
758
+ }
759
+ }
760
+
761
+ return best ? { model: best.model, adapter: best.adapter, stats: best.stats } : undefined;
762
+ }
763
+
764
+ export function createSerializedAsyncRunner(): <T>(operation: () => Promise<T>) => Promise<T> {
765
+ let tail: Promise<unknown> = Promise.resolve();
766
+ return <T>(operation: () => Promise<T>): Promise<T> => {
767
+ const result = tail.then(operation);
768
+ tail = result.catch(() => undefined);
769
+ return result;
770
+ };
771
+ }