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.
- package/README.md +19 -17
- package/README.zh-CN.md +19 -17
- package/index.ts +248 -11380
- package/package.json +8 -6
- package/src/adapters.ts +250 -0
- package/src/atomic-fs.ts +300 -0
- package/src/command-completion.ts +114 -0
- package/src/command.ts +1012 -0
- package/src/common.ts +49 -0
- package/src/compat-advice.ts +320 -0
- package/src/compat-config.ts +99 -0
- package/src/config.ts +374 -0
- package/src/diagnostics.ts +757 -0
- package/src/fix-types.ts +66 -0
- package/src/jsonc.ts +602 -0
- package/src/model-detect.ts +66 -0
- package/src/model-identity.ts +368 -0
- package/src/models-json-fix.ts +1982 -0
- package/src/paths.ts +18 -0
- package/src/prompt-cache-key-config.ts +406 -0
- package/src/prompt-rewrite.ts +294 -0
- package/src/provider-errors.ts +328 -0
- package/src/request-payload.ts +197 -0
- package/src/request-state.ts +102 -0
- package/src/retention.ts +82 -0
- package/src/routing.ts +551 -0
- package/src/stats-report.ts +771 -0
- package/src/stats-store.ts +500 -0
- package/src/tool-ordering.ts +209 -0
- package/src/usage.ts +172 -0
|
@@ -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
|
+
}
|