pi-cache-optimizer 2.4.0 → 2.4.1

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 (2) hide show
  1. package/index.ts +166 -30
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -102,6 +102,18 @@ type PersistedCacheStatsV2 = {
102
102
  statsByProvider: Partial<Record<CacheProviderId, CacheStats>>;
103
103
  };
104
104
 
105
+ /** Per-model-key scoped state. Used in memory and for v3 persistence. */
106
+ type CacheStatsState = {
107
+ statsByModel: Record<string, CacheStats>;
108
+ legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
109
+ };
110
+
111
+ type PersistedCacheStatsV3 = {
112
+ version: 3;
113
+ statsByModel: Record<string, CacheStats>;
114
+ legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
115
+ };
116
+
105
117
  type UsageSnapshot = {
106
118
  cacheRead: number;
107
119
  cacheWrite: number;
@@ -831,7 +843,7 @@ function describeMissingOpenAIFamilyProxyCompat(model: PiModel): string[] {
831
843
  const missing: string[] = [];
832
844
 
833
845
  if (!isOpenAIFamilyModel(model)) return missing;
834
- if (model.api !== "openai-completions") return missing;
846
+ if (lower(model.api) !== "openai-completions") return missing;
835
847
  if (isOfficialOpenAIBaseUrl(model)) return missing;
836
848
 
837
849
  if (compat.supportsLongCacheRetention !== true) {
@@ -1061,30 +1073,56 @@ function parseCacheStats(value: unknown): CacheStats | undefined {
1061
1073
  };
1062
1074
  }
1063
1075
 
1064
- function parsePersistedCacheStats(value: unknown): Partial<Record<CacheProviderId, CacheStats>> | undefined {
1076
+ function parsePersistedCacheStats(value: unknown): CacheStatsState | undefined {
1065
1077
  const record = asRecord(value);
1066
1078
  if (!record) return undefined;
1067
1079
 
1068
- if (record.version === 1) {
1069
- const migrated = parseCacheStats(record.stats);
1070
- return migrated ? { deepseek: migrated } : undefined;
1071
- }
1080
+ // version 3: model-scoped stats + legacy family fallback
1081
+ if (record.version === 3) {
1082
+ const statsByModel: Record<string, CacheStats> = {};
1083
+ const rawModelMap = asRecord(record.statsByModel);
1084
+ if (rawModelMap) {
1085
+ for (const [key, val] of Object.entries(rawModelMap)) {
1086
+ const parsed = parseCacheStats(val);
1087
+ if (parsed) statsByModel[key] = parsed;
1088
+ }
1089
+ }
1072
1090
 
1073
- if (record.version !== 2) return undefined;
1091
+ const legacyFamily: Partial<Record<CacheProviderId, CacheStats>> = {};
1092
+ const rawFamily = asRecord(record.legacyFamily);
1093
+ if (rawFamily) {
1094
+ for (const id of CACHE_PROVIDER_IDS) {
1095
+ const stats = parseCacheStats(rawFamily[id]);
1096
+ if (stats) legacyFamily[id] = stats;
1097
+ }
1098
+ }
1074
1099
 
1075
- const statsByProvider = asRecord(record.statsByProvider);
1076
- if (!statsByProvider) return undefined;
1100
+ return { statsByModel, legacyFamily };
1101
+ }
1077
1102
 
1078
- const parsed: Partial<Record<CacheProviderId, CacheStats>> = {};
1079
- for (const id of CACHE_PROVIDER_IDS) {
1080
- const stats = parseCacheStats(statsByProvider[id]);
1081
- if (stats) parsed[id] = stats;
1103
+ // version 2: migrate statsByProvider into legacyFamily
1104
+ if (record.version === 2) {
1105
+ const statsByProvider = asRecord(record.statsByProvider);
1106
+ const legacyFamily: Partial<Record<CacheProviderId, CacheStats>> = {};
1107
+ if (statsByProvider) {
1108
+ for (const id of CACHE_PROVIDER_IDS) {
1109
+ const stats = parseCacheStats(statsByProvider[id]);
1110
+ if (stats) legacyFamily[id] = stats;
1111
+ }
1112
+ }
1113
+ return { statsByModel: {}, legacyFamily };
1114
+ }
1115
+
1116
+ // version 1: single DeepSeek stats -> migrate to legacyFamily.deepseek
1117
+ if (record.version === 1) {
1118
+ const migrated = parseCacheStats(record.stats);
1119
+ return migrated ? { statsByModel: {}, legacyFamily: { deepseek: migrated } } : undefined;
1082
1120
  }
1083
1121
 
1084
- return parsed;
1122
+ return undefined;
1085
1123
  }
1086
1124
 
1087
- async function readPersistedCacheStats(): Promise<Partial<Record<CacheProviderId, CacheStats>> | undefined> {
1125
+ async function readPersistedCacheStats(): Promise<CacheStatsState | undefined> {
1088
1126
  try {
1089
1127
  const raw = await readFile(STATE_FILE_PATH, "utf8");
1090
1128
  return parsePersistedCacheStats(JSON.parse(raw));
@@ -1124,9 +1162,13 @@ async function readPersistedCacheStats(): Promise<Partial<Record<CacheProviderId
1124
1162
  return undefined;
1125
1163
  }
1126
1164
 
1127
- async function writePersistedCacheStats(statsByProvider: Partial<Record<CacheProviderId, CacheStats>>): Promise<void> {
1165
+ async function writePersistedCacheStats(state: CacheStatsState): Promise<void> {
1128
1166
  await mkdir(STATE_DIR, { recursive: true });
1129
- const payload: PersistedCacheStatsV2 = { version: 2, statsByProvider };
1167
+ const payload: PersistedCacheStatsV3 = {
1168
+ version: 3,
1169
+ statsByModel: state.statsByModel,
1170
+ legacyFamily: state.legacyFamily,
1171
+ };
1130
1172
  const tempPath = `${STATE_FILE_PATH}.${process.pid}.${Date.now()}.tmp`;
1131
1173
 
1132
1174
  await writeFile(tempPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
@@ -1164,26 +1206,59 @@ export const __internals_for_tests = {
1164
1206
  getAssistantMessageModelTokenValues,
1165
1207
  getCompat,
1166
1208
  modelKey,
1209
+ // Cache stats helpers (module-level, usable from verify script)
1210
+ addUsageToCacheStats,
1211
+ formatCacheStats,
1212
+ emptyCacheStats,
1213
+ emptyAllCacheStats,
1214
+ parseCacheStats,
1215
+ parsePersistedCacheStats,
1167
1216
  };
1168
1217
 
1169
1218
  export default function (pi: ExtensionAPI) {
1170
1219
  const warnedModels = new Set<string>();
1171
- let cacheStatsByProvider: Partial<Record<CacheProviderId, CacheStats>> = emptyAllCacheStats();
1220
+ let cacheStatsByModel: Record<string, CacheStats> = {};
1221
+ let cacheStatsLegacyFamily: Partial<Record<CacheProviderId, CacheStats>> = emptyAllCacheStats();
1172
1222
  let lastStatusText: string | undefined;
1173
1223
  let persistenceWarningShown = false;
1224
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
1225
+ const PERSIST_DEBOUNCE_MS = 2000;
1226
+
1227
+ function getCacheStatsState(): CacheStatsState {
1228
+ return { statsByModel: cacheStatsByModel, legacyFamily: cacheStatsLegacyFamily };
1229
+ }
1174
1230
 
1175
- function getStatsForAdapter(adapter: CacheProviderAdapter): CacheStats {
1176
- const existing = cacheStatsByProvider[adapter.id];
1231
+ /** Look up active stats for a model, falling back to legacy family. */
1232
+ function getStatsForModel(model: PiModel | undefined, adapter: CacheProviderAdapter): CacheStats {
1233
+ if (model) {
1234
+ const key = modelKey(model);
1235
+ const existing = cacheStatsByModel[key];
1236
+ if (existing) return existing;
1237
+ }
1238
+
1239
+ // Fallback: legacy family bucket — used when model key is unknown
1240
+ // or this model hasn't been seen yet in this session.
1241
+ const family = cacheStatsLegacyFamily[adapter.id];
1242
+ if (family) return family;
1243
+
1244
+ const created = emptyCacheStats();
1245
+ cacheStatsLegacyFamily[adapter.id] = created;
1246
+ return created;
1247
+ }
1248
+
1249
+ /** Get or create a stats entry for the given model key. */
1250
+ function getOrCreateStatsByModelKey(key: string): CacheStats {
1251
+ const existing = cacheStatsByModel[key];
1177
1252
  if (existing) return existing;
1178
1253
 
1179
1254
  const created = emptyCacheStats();
1180
- cacheStatsByProvider[adapter.id] = created;
1255
+ cacheStatsByModel[key] = created;
1181
1256
  return created;
1182
1257
  }
1183
1258
 
1184
1259
  async function persistCacheStats(ctx?: ExtensionContext): Promise<void> {
1185
1260
  try {
1186
- await writePersistedCacheStats(cacheStatsByProvider);
1261
+ await writePersistedCacheStats(getCacheStatsState());
1187
1262
  } catch (error) {
1188
1263
  console.warn(`${LOG_PREFIX}: failed to persist cache stats`, error);
1189
1264
  if (!persistenceWarningShown) {
@@ -1196,14 +1271,48 @@ export default function (pi: ExtensionAPI) {
1196
1271
  }
1197
1272
  }
1198
1273
 
1274
+ /** Schedule a debounced persist. Coalesces rapid message_end writes
1275
+ * into a single disk write after PERSIST_DEBOUNCE_MS of silence.
1276
+ * In-memory stats remain instantly up-to-date for the footer; only
1277
+ * the on-disk persistence is delayed. */
1278
+ function schedulePersistCacheStats(ctx?: ExtensionContext): void {
1279
+ if (persistTimer !== null) clearTimeout(persistTimer);
1280
+ persistTimer = setTimeout(() => {
1281
+ persistTimer = null;
1282
+ persistCacheStats(ctx).catch((err) => {
1283
+ console.warn(`${LOG_PREFIX}: debounced persist failed`, err);
1284
+ });
1285
+ }, PERSIST_DEBOUNCE_MS);
1286
+ }
1287
+
1288
+ /** Flush any pending debounced persist immediately (cancels timer + writes).
1289
+ * Used on reload and day-rollover where immediate durability matters. */
1290
+ async function flushPersistCacheStats(ctx?: ExtensionContext): Promise<void> {
1291
+ if (persistTimer !== null) {
1292
+ clearTimeout(persistTimer);
1293
+ persistTimer = null;
1294
+ }
1295
+ await persistCacheStats(ctx);
1296
+ }
1297
+
1199
1298
  async function rollOverStatsIfNeeded(ctx?: ExtensionContext): Promise<void> {
1200
1299
  const day = currentLocalDay();
1201
1300
  let changed = false;
1202
1301
 
1302
+ // Roll over per-model entries.
1303
+ for (const key of Object.keys(cacheStatsByModel)) {
1304
+ const stats = cacheStatsByModel[key];
1305
+ if (stats && stats.day !== day) {
1306
+ cacheStatsByModel[key] = emptyCacheStats(day);
1307
+ changed = true;
1308
+ }
1309
+ }
1310
+
1311
+ // Roll over legacy family entries.
1203
1312
  for (const id of CACHE_PROVIDER_IDS) {
1204
- const stats = cacheStatsByProvider[id];
1313
+ const stats = cacheStatsLegacyFamily[id];
1205
1314
  if (stats && stats.day !== day) {
1206
- cacheStatsByProvider[id] = emptyCacheStats(day);
1315
+ cacheStatsLegacyFamily[id] = emptyCacheStats(day);
1207
1316
  changed = true;
1208
1317
  }
1209
1318
  }
@@ -1216,13 +1325,21 @@ export default function (pi: ExtensionAPI) {
1216
1325
 
1217
1326
  async function restoreCacheStats(reason: string, ctx: ExtensionContext): Promise<void> {
1218
1327
  if (reason === "reload") {
1219
- cacheStatsByProvider = emptyAllCacheStats();
1328
+ cacheStatsByModel = {};
1329
+ cacheStatsLegacyFamily = emptyAllCacheStats();
1220
1330
  lastStatusText = undefined;
1221
- await persistCacheStats(ctx);
1331
+ await flushPersistCacheStats(ctx);
1222
1332
  return;
1223
1333
  }
1224
1334
 
1225
- cacheStatsByProvider = (await readPersistedCacheStats()) ?? emptyAllCacheStats();
1335
+ const persisted = await readPersistedCacheStats();
1336
+ if (persisted) {
1337
+ cacheStatsByModel = persisted.statsByModel;
1338
+ cacheStatsLegacyFamily = persisted.legacyFamily;
1339
+ } else {
1340
+ cacheStatsByModel = {};
1341
+ cacheStatsLegacyFamily = emptyAllCacheStats();
1342
+ }
1226
1343
  lastStatusText = undefined;
1227
1344
  await rollOverStatsIfNeeded(ctx);
1228
1345
  }
@@ -1231,7 +1348,17 @@ export default function (pi: ExtensionAPI) {
1231
1348
  await rollOverStatsIfNeeded(ctx);
1232
1349
 
1233
1350
  const adapter = selectAdapterForModel(model);
1234
- let statusText: string | undefined = adapter ? formatCacheStats(adapter, getStatsForAdapter(adapter)) : undefined;
1351
+ let statusText: string | undefined;
1352
+ if (adapter) {
1353
+ // Display only per-model scoped stats. A model that has never been
1354
+ // used in this session shows 0/0 rather than falling back to legacy
1355
+ // family aggregated stats (which could span different providers with
1356
+ // the same model-family name). The message_end hook populates
1357
+ // cacheStatsByModel[key] on first use with that model.
1358
+ const key = model ? modelKey(model) : undefined;
1359
+ const stats = key ? cacheStatsByModel[key] : undefined;
1360
+ statusText = formatCacheStats(adapter, stats ?? emptyCacheStats());
1361
+ }
1235
1362
 
1236
1363
  // If optimizeSystemPrompt detected structural truncation on this or
1237
1364
  // a recent turn, flag it once in the footer so the user knows to
@@ -1351,8 +1478,17 @@ export default function (pi: ExtensionAPI) {
1351
1478
  if (!usage) return;
1352
1479
 
1353
1480
  await rollOverStatsIfNeeded(ctx);
1354
- addUsageToCacheStats(getStatsForAdapter(adapter), usage);
1355
- await persistCacheStats(ctx);
1481
+
1482
+ // Update stats scoped to the active model (provider/id key).
1483
+ // Falls back to legacy family when ctx.model is undefined.
1484
+ if (ctx.model) {
1485
+ const key = modelKey(ctx.model);
1486
+ addUsageToCacheStats(getOrCreateStatsByModelKey(key), usage);
1487
+ } else {
1488
+ addUsageToCacheStats(getStatsForModel(undefined, adapter), usage);
1489
+ }
1490
+
1491
+ schedulePersistCacheStats(ctx);
1356
1492
  await publishStatus(ctx);
1357
1493
  });
1358
1494
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Pi extension that improves provider-side KV/prompt cache hit rates (DeepSeek, OpenAI, Claude, Gemini) by reordering the system prompt, requesting long retention, and showing footer cache stats. Renamed from pi-deepseek-cache-optimizer.",
5
5
  "keywords": [
6
6
  "pi-package",