pi-cache-optimizer 2.8.16 → 2.8.18
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 +21 -17
- package/README.zh-CN.md +21 -17
- package/index.ts +260 -11412
- 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,500 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { LOG_PREFIX, asRecord, getErrorCode, getNonNegativeNumber, isNonEmptyString, isProcessAlive } from "./common.ts";
|
|
5
|
+
import { STATE_DIR } from "./paths.ts";
|
|
6
|
+
|
|
7
|
+
export type CacheProviderId = "deepseek" | "openai" | "claude" | "gemini";
|
|
8
|
+
|
|
9
|
+
export const STATE_FILE_PATH = join(STATE_DIR, "pi-cache-optimizer-stats.json");
|
|
10
|
+
|
|
11
|
+
export const LEGACY_STATE_FILE_PATH = join(STATE_DIR, "deepseek-cache-optimizer-stats.json");
|
|
12
|
+
|
|
13
|
+
export const SHARD_STATE_DIR = join(STATE_DIR, "pi-cache-optimizer-stats.d");
|
|
14
|
+
|
|
15
|
+
export const SHARD_FILES_DIR = join(SHARD_STATE_DIR, "shards");
|
|
16
|
+
|
|
17
|
+
export const SHARD_EPOCH_DIR = join(SHARD_STATE_DIR, "epochs");
|
|
18
|
+
|
|
19
|
+
export const SHARD_MODEL_EPOCH_DIR = join(SHARD_EPOCH_DIR, "models");
|
|
20
|
+
|
|
21
|
+
export const SHARD_MAINTENANCE_DIR = join(SHARD_STATE_DIR, "maintenance");
|
|
22
|
+
|
|
23
|
+
export const SHARD_GLOBAL_EPOCH_PATH = join(SHARD_EPOCH_DIR, "global.json");
|
|
24
|
+
|
|
25
|
+
export const SHARD_CLEANUP_LOCK_PATH = join(SHARD_MAINTENANCE_DIR, "cleanup.lock");
|
|
26
|
+
|
|
27
|
+
export const SHARD_CLEANUP_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-cleanup");
|
|
28
|
+
|
|
29
|
+
export const SHARD_RETENTION_MS = 48 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
export const SHARD_TEMP_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
export const SHARD_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
export const SHARD_CLEANUP_LOCK_STALE_MS = 60 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
export const CACHE_PROVIDER_IDS: CacheProviderId[] = ["deepseek", "openai", "claude", "gemini"];
|
|
38
|
+
|
|
39
|
+
export type CacheStats = {
|
|
40
|
+
day: string;
|
|
41
|
+
totalRequests: number;
|
|
42
|
+
hitRequests: number;
|
|
43
|
+
cachedInputTokens: number;
|
|
44
|
+
cacheWriteInputTokens: number;
|
|
45
|
+
totalInputTokens: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Per-model-key scoped state. Used in memory and for v3 persistence. */
|
|
49
|
+
export type PersistedRoutedModelRef = {
|
|
50
|
+
provider: string;
|
|
51
|
+
id: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type UsageSnapshot = {
|
|
56
|
+
cacheRead: number;
|
|
57
|
+
cacheWrite: number;
|
|
58
|
+
totalInput: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Per-request sample stored for trend analysis and usage-field-missing detection.
|
|
63
|
+
* Contains only numeric counters and booleans — never message content, prompts,
|
|
64
|
+
* payloads, headers, API keys, or model outputs.
|
|
65
|
+
*/
|
|
66
|
+
export type PersistedStatsShardV7 = {
|
|
67
|
+
version: 7;
|
|
68
|
+
kind: "pi-cache-optimizer-shard";
|
|
69
|
+
instanceId: string;
|
|
70
|
+
sessionHash: string;
|
|
71
|
+
process: {
|
|
72
|
+
pid: number;
|
|
73
|
+
ppid: number;
|
|
74
|
+
instanceStartedAt: number;
|
|
75
|
+
};
|
|
76
|
+
lifecycle: {
|
|
77
|
+
state: "active" | "closed";
|
|
78
|
+
createdAt: number;
|
|
79
|
+
updatedAt: number;
|
|
80
|
+
closedAt?: number;
|
|
81
|
+
};
|
|
82
|
+
day: string;
|
|
83
|
+
globalEpoch: string;
|
|
84
|
+
models: Record<string, {
|
|
85
|
+
modelEpoch: string;
|
|
86
|
+
provider: string;
|
|
87
|
+
modelId: string;
|
|
88
|
+
modelName?: string;
|
|
89
|
+
api?: string;
|
|
90
|
+
stats: CacheStats;
|
|
91
|
+
}>;
|
|
92
|
+
lastRoutedModel?: PersistedRoutedModelRef;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type ShardAggregate = {
|
|
96
|
+
bySession: Record<string, Record<string, CacheStats>>;
|
|
97
|
+
totalsByModel: Record<string, CacheStats>;
|
|
98
|
+
instancesBySession: Record<string, number>;
|
|
99
|
+
instancesBySessionModel: Record<string, Record<string, number>>;
|
|
100
|
+
sessionsByModel: Record<string, number>;
|
|
101
|
+
instancesByModel: Record<string, number>;
|
|
102
|
+
modelRefsByKey: Record<string, PersistedRoutedModelRef>;
|
|
103
|
+
lastRoutedModelBySession: Record<string, PersistedRoutedModelRef>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function currentLocalDay(): string {
|
|
107
|
+
const now = new Date();
|
|
108
|
+
const year = now.getFullYear();
|
|
109
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
110
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
111
|
+
return `${year}-${month}-${day}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function emptyCacheStats(day = currentLocalDay()): CacheStats {
|
|
115
|
+
return {
|
|
116
|
+
day,
|
|
117
|
+
totalRequests: 0,
|
|
118
|
+
hitRequests: 0,
|
|
119
|
+
cachedInputTokens: 0,
|
|
120
|
+
cacheWriteInputTokens: 0,
|
|
121
|
+
totalInputTokens: 0,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function emptyAllCacheStats(day = currentLocalDay()): Partial<Record<CacheProviderId, CacheStats>> {
|
|
126
|
+
return Object.fromEntries(CACHE_PROVIDER_IDS.map((id) => [id, emptyCacheStats(day)])) as Partial<Record<CacheProviderId, CacheStats>>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function addUsageToCacheStats(stats: CacheStats, usage: UsageSnapshot): void {
|
|
130
|
+
stats.totalRequests += 1;
|
|
131
|
+
if (usage.cacheRead > 0) stats.hitRequests += 1;
|
|
132
|
+
stats.cachedInputTokens += usage.cacheRead;
|
|
133
|
+
stats.cacheWriteInputTokens += usage.cacheWrite;
|
|
134
|
+
stats.totalInputTokens += usage.totalInput;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function parseCacheStats(value: unknown): CacheStats | undefined {
|
|
138
|
+
const stats = asRecord(value);
|
|
139
|
+
if (!stats || typeof stats.day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(stats.day)) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const totalRequests = getNonNegativeNumber(stats, "totalRequests");
|
|
144
|
+
const hitRequests = getNonNegativeNumber(stats, "hitRequests");
|
|
145
|
+
const cachedInputTokens = getNonNegativeNumber(stats, "cachedInputTokens");
|
|
146
|
+
const cacheWriteInputTokens = getNonNegativeNumber(stats, "cacheWriteInputTokens") ?? 0;
|
|
147
|
+
const totalInputTokens = getNonNegativeNumber(stats, "totalInputTokens");
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
totalRequests === undefined ||
|
|
151
|
+
hitRequests === undefined ||
|
|
152
|
+
cachedInputTokens === undefined ||
|
|
153
|
+
totalInputTokens === undefined ||
|
|
154
|
+
hitRequests > totalRequests ||
|
|
155
|
+
cachedInputTokens > totalInputTokens ||
|
|
156
|
+
cacheWriteInputTokens > totalInputTokens
|
|
157
|
+
) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
day: stats.day,
|
|
163
|
+
totalRequests,
|
|
164
|
+
hitRequests,
|
|
165
|
+
cachedInputTokens,
|
|
166
|
+
cacheWriteInputTokens,
|
|
167
|
+
totalInputTokens,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function cloneCacheStats(stats: CacheStats): CacheStats {
|
|
172
|
+
return { ...stats };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function addCacheStatsTotals(target: CacheStats, source: CacheStats): void {
|
|
176
|
+
target.totalRequests += source.totalRequests;
|
|
177
|
+
target.hitRequests += source.hitRequests;
|
|
178
|
+
target.cachedInputTokens += source.cachedInputTokens;
|
|
179
|
+
target.cacheWriteInputTokens += source.cacheWriteInputTokens;
|
|
180
|
+
target.totalInputTokens += source.totalInputTokens;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function mergeCacheStatsForTotal(existing: CacheStats | undefined, incoming: CacheStats): CacheStats {
|
|
184
|
+
if (!existing) return cloneCacheStats(incoming);
|
|
185
|
+
if (incoming.day > existing.day) return cloneCacheStats(incoming);
|
|
186
|
+
if (incoming.day < existing.day) return existing;
|
|
187
|
+
addCacheStatsTotals(existing, incoming);
|
|
188
|
+
return existing;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function parsePersistedRoutedModelRef(value: unknown): PersistedRoutedModelRef | undefined {
|
|
192
|
+
const record = asRecord(value);
|
|
193
|
+
const provider = record?.provider;
|
|
194
|
+
const id = record?.id;
|
|
195
|
+
const name = record?.name;
|
|
196
|
+
if (!isNonEmptyString(provider) || !isNonEmptyString(id)) return undefined;
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
provider: provider.trim(),
|
|
200
|
+
id: id.trim(),
|
|
201
|
+
name: isNonEmptyString(name) ? name.trim() : id.trim(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function modelEpochPath(modelKeyValue: string): string {
|
|
206
|
+
return join(SHARD_MODEL_EPOCH_DIR, `${createHash("sha256").update(modelKeyValue).digest("hex")}.json`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function initialEpoch(scope: string): string {
|
|
210
|
+
return `initial:${scope}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function parseEpochRecord(value: unknown): string | undefined {
|
|
214
|
+
const record = asRecord(value);
|
|
215
|
+
return record?.version === 1 && isNonEmptyString(record.epoch) ? record.epoch.trim() : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function readEpochFile(path: string, fallback: string): Promise<string> {
|
|
219
|
+
try {
|
|
220
|
+
return parseEpochRecord(JSON.parse(await readFile(path, "utf8"))) ?? fallback;
|
|
221
|
+
} catch {
|
|
222
|
+
return fallback;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function readGlobalStatsEpoch(): Promise<string> {
|
|
227
|
+
return readEpochFile(SHARD_GLOBAL_EPOCH_PATH, initialEpoch("global"));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function readModelStatsEpoch(modelKeyValue: string): Promise<string> {
|
|
231
|
+
return readEpochFile(modelEpochPath(modelKeyValue), initialEpoch(`model:${modelKeyValue}`));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function writeStatsEpoch(path: string, epoch: string): Promise<void> {
|
|
235
|
+
await mkdir(dirname(path), { recursive: true });
|
|
236
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
237
|
+
await writeFile(tempPath, JSON.stringify({ version: 1, epoch, createdAt: Date.now() }, null, 2) + "\n", "utf8");
|
|
238
|
+
await rename(tempPath, path);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function advanceGlobalStatsEpoch(): Promise<string> {
|
|
242
|
+
const epoch = `${Date.now()}-${randomUUID()}`;
|
|
243
|
+
await writeStatsEpoch(SHARD_GLOBAL_EPOCH_PATH, epoch);
|
|
244
|
+
return epoch;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function advanceModelStatsEpoch(modelKeyValue: string): Promise<string> {
|
|
248
|
+
const epoch = `${Date.now()}-${randomUUID()}`;
|
|
249
|
+
await writeStatsEpoch(modelEpochPath(modelKeyValue), epoch);
|
|
250
|
+
return epoch;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function parsePersistedStatsShardV7(value: unknown): PersistedStatsShardV7 | undefined {
|
|
254
|
+
const record = asRecord(value);
|
|
255
|
+
const processRecord = asRecord(record?.process);
|
|
256
|
+
const lifecycle = asRecord(record?.lifecycle);
|
|
257
|
+
const rawModels = asRecord(record?.models);
|
|
258
|
+
if (
|
|
259
|
+
record?.version !== 7 ||
|
|
260
|
+
record.kind !== "pi-cache-optimizer-shard" ||
|
|
261
|
+
!isNonEmptyString(record.instanceId) ||
|
|
262
|
+
!isNonEmptyString(record.sessionHash) ||
|
|
263
|
+
!processRecord ||
|
|
264
|
+
!Number.isInteger(processRecord.pid) ||
|
|
265
|
+
!Number.isInteger(processRecord.ppid) ||
|
|
266
|
+
typeof processRecord.instanceStartedAt !== "number" ||
|
|
267
|
+
!lifecycle ||
|
|
268
|
+
(lifecycle.state !== "active" && lifecycle.state !== "closed") ||
|
|
269
|
+
typeof lifecycle.createdAt !== "number" ||
|
|
270
|
+
typeof lifecycle.updatedAt !== "number" ||
|
|
271
|
+
!isNonEmptyString(record.day) ||
|
|
272
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(record.day) ||
|
|
273
|
+
!isNonEmptyString(record.globalEpoch) ||
|
|
274
|
+
!rawModels
|
|
275
|
+
) return undefined;
|
|
276
|
+
|
|
277
|
+
const models: PersistedStatsShardV7["models"] = {};
|
|
278
|
+
for (const [key, rawEntry] of Object.entries(rawModels)) {
|
|
279
|
+
const entry = asRecord(rawEntry);
|
|
280
|
+
const stats = parseCacheStats(entry?.stats);
|
|
281
|
+
if (
|
|
282
|
+
!entry || !stats || !isNonEmptyString(entry.modelEpoch) ||
|
|
283
|
+
!isNonEmptyString(entry.provider) || !isNonEmptyString(entry.modelId) ||
|
|
284
|
+
key !== `${entry.provider.trim()}/${entry.modelId.trim()}`
|
|
285
|
+
) continue;
|
|
286
|
+
models[key] = {
|
|
287
|
+
modelEpoch: entry.modelEpoch.trim(),
|
|
288
|
+
provider: entry.provider.trim(),
|
|
289
|
+
modelId: entry.modelId.trim(),
|
|
290
|
+
...(isNonEmptyString(entry.modelName) ? { modelName: entry.modelName.trim() } : {}),
|
|
291
|
+
...(isNonEmptyString(entry.api) ? { api: entry.api.trim() } : {}),
|
|
292
|
+
stats,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const lastRoutedModel = parsePersistedRoutedModelRef(record.lastRoutedModel);
|
|
297
|
+
return {
|
|
298
|
+
version: 7,
|
|
299
|
+
kind: "pi-cache-optimizer-shard",
|
|
300
|
+
instanceId: record.instanceId.trim(),
|
|
301
|
+
sessionHash: record.sessionHash.trim(),
|
|
302
|
+
process: {
|
|
303
|
+
pid: Number(processRecord.pid),
|
|
304
|
+
ppid: Number(processRecord.ppid),
|
|
305
|
+
instanceStartedAt: processRecord.instanceStartedAt,
|
|
306
|
+
},
|
|
307
|
+
lifecycle: {
|
|
308
|
+
state: lifecycle.state,
|
|
309
|
+
createdAt: lifecycle.createdAt,
|
|
310
|
+
updatedAt: lifecycle.updatedAt,
|
|
311
|
+
...(typeof lifecycle.closedAt === "number" ? { closedAt: lifecycle.closedAt } : {}),
|
|
312
|
+
},
|
|
313
|
+
day: record.day,
|
|
314
|
+
globalEpoch: record.globalEpoch.trim(),
|
|
315
|
+
models,
|
|
316
|
+
...(lastRoutedModel ? { lastRoutedModel } : {}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function writeStatsShardV7(path: string, shard: PersistedStatsShardV7): Promise<void> {
|
|
321
|
+
await mkdir(dirname(path), { recursive: true });
|
|
322
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
323
|
+
await writeFile(tempPath, JSON.stringify(shard, null, 2) + "\n", "utf8");
|
|
324
|
+
await rename(tempPath, path);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR): Promise<PersistedStatsShardV7[]> {
|
|
328
|
+
let names: string[];
|
|
329
|
+
try {
|
|
330
|
+
names = await readdir(directory);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (getErrorCode(error) === "ENOENT") return [];
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
const shards: PersistedStatsShardV7[] = [];
|
|
336
|
+
for (const name of names) {
|
|
337
|
+
if (!/^[0-9a-f-]+\.json$/i.test(name)) continue;
|
|
338
|
+
const path = join(directory, name);
|
|
339
|
+
try {
|
|
340
|
+
const info = await lstat(path);
|
|
341
|
+
if (!info.isFile() || info.isSymbolicLink()) continue;
|
|
342
|
+
const parsed = parsePersistedStatsShardV7(JSON.parse(await readFile(path, "utf8")));
|
|
343
|
+
const filenameInstanceId = name.slice(0, -".json".length);
|
|
344
|
+
if (parsed && parsed.instanceId === filenameInstanceId) shards.push(parsed);
|
|
345
|
+
} catch {
|
|
346
|
+
// Ignore malformed or transiently unavailable shards. Atomic writers will
|
|
347
|
+
// publish a complete replacement on the next successful update.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return shards;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function aggregateStatsShardsV7(
|
|
354
|
+
shards: PersistedStatsShardV7[],
|
|
355
|
+
day: string = currentLocalDay(),
|
|
356
|
+
): Promise<ShardAggregate> {
|
|
357
|
+
const globalEpoch = await readGlobalStatsEpoch();
|
|
358
|
+
const modelEpochs = new Map<string, string>();
|
|
359
|
+
const result: ShardAggregate = {
|
|
360
|
+
bySession: {},
|
|
361
|
+
totalsByModel: {},
|
|
362
|
+
instancesBySession: {},
|
|
363
|
+
instancesBySessionModel: {},
|
|
364
|
+
sessionsByModel: {},
|
|
365
|
+
instancesByModel: {},
|
|
366
|
+
modelRefsByKey: {},
|
|
367
|
+
lastRoutedModelBySession: {},
|
|
368
|
+
};
|
|
369
|
+
const routedUpdatedAt = new Map<string, number>();
|
|
370
|
+
const modelRefUpdatedAt = new Map<string, number>();
|
|
371
|
+
const modelSessions = new Map<string, Set<string>>();
|
|
372
|
+
|
|
373
|
+
for (const shard of shards) {
|
|
374
|
+
if (shard.day !== day || shard.globalEpoch !== globalEpoch) continue;
|
|
375
|
+
let contributed = false;
|
|
376
|
+
if (shard.lastRoutedModel && (routedUpdatedAt.get(shard.sessionHash) ?? -1) < shard.lifecycle.updatedAt) {
|
|
377
|
+
routedUpdatedAt.set(shard.sessionHash, shard.lifecycle.updatedAt);
|
|
378
|
+
result.lastRoutedModelBySession[shard.sessionHash] = shard.lastRoutedModel;
|
|
379
|
+
}
|
|
380
|
+
for (const [key, entry] of Object.entries(shard.models)) {
|
|
381
|
+
let epoch = modelEpochs.get(key);
|
|
382
|
+
if (!epoch) {
|
|
383
|
+
epoch = await readModelStatsEpoch(key);
|
|
384
|
+
modelEpochs.set(key, epoch);
|
|
385
|
+
}
|
|
386
|
+
if (entry.modelEpoch !== epoch || entry.stats.day !== day) continue;
|
|
387
|
+
contributed = true;
|
|
388
|
+
const sessionModels = result.bySession[shard.sessionHash] ??= {};
|
|
389
|
+
sessionModels[key] = mergeCacheStatsForTotal(sessionModels[key], entry.stats);
|
|
390
|
+
result.totalsByModel[key] = mergeCacheStatsForTotal(result.totalsByModel[key], entry.stats);
|
|
391
|
+
result.instancesByModel[key] = (result.instancesByModel[key] ?? 0) + 1;
|
|
392
|
+
if ((modelRefUpdatedAt.get(key) ?? -1) < shard.lifecycle.updatedAt) {
|
|
393
|
+
modelRefUpdatedAt.set(key, shard.lifecycle.updatedAt);
|
|
394
|
+
result.modelRefsByKey[key] = {
|
|
395
|
+
provider: entry.provider,
|
|
396
|
+
id: entry.modelId,
|
|
397
|
+
name: entry.modelName ?? entry.modelId,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const sessionInstances = result.instancesBySessionModel[shard.sessionHash] ??= {};
|
|
401
|
+
sessionInstances[key] = (sessionInstances[key] ?? 0) + 1;
|
|
402
|
+
const sessions = modelSessions.get(key) ?? new Set<string>();
|
|
403
|
+
sessions.add(shard.sessionHash);
|
|
404
|
+
modelSessions.set(key, sessions);
|
|
405
|
+
}
|
|
406
|
+
if (contributed) {
|
|
407
|
+
result.instancesBySession[shard.sessionHash] = (result.instancesBySession[shard.sessionHash] ?? 0) + 1;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const [key, sessions] of modelSessions) result.sessionsByModel[key] = sessions.size;
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function loadStatsShardAggregateV7(directory: string = SHARD_FILES_DIR): Promise<ShardAggregate> {
|
|
415
|
+
return aggregateStatsShardsV7(await readValidStatsShardsV7(directory));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function removeLegacyStatsFiles(): Promise<void> {
|
|
419
|
+
for (const path of [STATE_FILE_PATH, LEGACY_STATE_FILE_PATH]) {
|
|
420
|
+
try {
|
|
421
|
+
await unlink(path);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (getErrorCode(error) !== "ENOENT") console.warn(`${LOG_PREFIX}: failed to remove obsolete stats file ${path}`, error);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function cleanupStatsShardsV7(now = Date.now(), directory: string = SHARD_FILES_DIR): Promise<number> {
|
|
429
|
+
let names: string[];
|
|
430
|
+
try {
|
|
431
|
+
names = await readdir(directory);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return getErrorCode(error) === "ENOENT" ? 0 : Promise.reject(error);
|
|
434
|
+
}
|
|
435
|
+
const today = currentLocalDay();
|
|
436
|
+
let removed = 0;
|
|
437
|
+
for (const name of names) {
|
|
438
|
+
const isShard = /^[0-9a-f-]+\.json$/i.test(name);
|
|
439
|
+
const isTemp = name.endsWith(".tmp");
|
|
440
|
+
if (!isShard && !isTemp) continue;
|
|
441
|
+
const path = join(directory, name);
|
|
442
|
+
try {
|
|
443
|
+
const info = await lstat(path);
|
|
444
|
+
if (!info.isFile() || info.isSymbolicLink()) continue;
|
|
445
|
+
if (isTemp) {
|
|
446
|
+
if (now - info.mtimeMs < SHARD_TEMP_RETENTION_MS) continue;
|
|
447
|
+
} else {
|
|
448
|
+
const text = await readFile(path, "utf8");
|
|
449
|
+
let parsed: PersistedStatsShardV7 | undefined;
|
|
450
|
+
try {
|
|
451
|
+
parsed = parsePersistedStatsShardV7(JSON.parse(text));
|
|
452
|
+
} catch {
|
|
453
|
+
// A corrupt JSON shard has no usable lifecycle metadata. Apply the
|
|
454
|
+
// same mtime retention as schema-invalid shards instead of keeping
|
|
455
|
+
// it forever; read failures still leave the file untouched.
|
|
456
|
+
}
|
|
457
|
+
if (parsed?.day === today) continue;
|
|
458
|
+
const updatedAt = parsed?.lifecycle.updatedAt ?? info.mtimeMs;
|
|
459
|
+
if (now - updatedAt < SHARD_RETENTION_MS) continue;
|
|
460
|
+
if (parsed?.lifecycle.state === "active" && isProcessAlive(parsed.process.pid)) continue;
|
|
461
|
+
}
|
|
462
|
+
await unlink(path);
|
|
463
|
+
removed += 1;
|
|
464
|
+
} catch {
|
|
465
|
+
// Best-effort maintenance must never block the extension.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return removed;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export async function maybeCleanupStatsShardsV7(now = Date.now()): Promise<void> {
|
|
472
|
+
await mkdir(SHARD_MAINTENANCE_DIR, { recursive: true });
|
|
473
|
+
try {
|
|
474
|
+
const marker = await stat(SHARD_CLEANUP_MARKER_PATH);
|
|
475
|
+
if (now - marker.mtimeMs < SHARD_CLEANUP_INTERVAL_MS) return;
|
|
476
|
+
} catch {}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
await mkdir(SHARD_CLEANUP_LOCK_PATH);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (getErrorCode(error) !== "EEXIST") return;
|
|
482
|
+
try {
|
|
483
|
+
const lock = await stat(SHARD_CLEANUP_LOCK_PATH);
|
|
484
|
+
if (now - lock.mtimeMs <= SHARD_CLEANUP_LOCK_STALE_MS) return;
|
|
485
|
+
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
486
|
+
await mkdir(SHARD_CLEANUP_LOCK_PATH);
|
|
487
|
+
} catch {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
await cleanupStatsShardsV7(now);
|
|
494
|
+
const tempPath = `${SHARD_CLEANUP_MARKER_PATH}.${process.pid}.${randomUUID()}.tmp`;
|
|
495
|
+
await writeFile(tempPath, `${now}\n`, "utf8");
|
|
496
|
+
await rename(tempPath, SHARD_CLEANUP_MARKER_PATH);
|
|
497
|
+
} finally {
|
|
498
|
+
await rm(SHARD_CLEANUP_LOCK_PATH, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { type PiModel, type UnknownRecord, asRecord, isNonEmptyString } from "./common.ts";
|
|
2
|
+
import { isResponsesPromptRewriteBypassApi } from "./model-identity.ts";
|
|
3
|
+
|
|
4
|
+
export type ToolOrderApi =
|
|
5
|
+
| "openai-completions"
|
|
6
|
+
| "openai-responses"
|
|
7
|
+
| "anthropic-messages"
|
|
8
|
+
| "google-generative-ai"
|
|
9
|
+
| "google-vertex"
|
|
10
|
+
| "bedrock-converse-stream";
|
|
11
|
+
|
|
12
|
+
export function isKnownToolOrderApi(api: unknown): api is ToolOrderApi {
|
|
13
|
+
return api === "openai-completions" || api === "openai-responses" ||
|
|
14
|
+
api === "anthropic-messages" || api === "google-generative-ai" ||
|
|
15
|
+
api === "google-vertex" || api === "bedrock-converse-stream";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isToolOrderingEligibleModel(model: PiModel | undefined): boolean {
|
|
19
|
+
// Responses/Codex prompt bypasses remain untouched. The pure helper still
|
|
20
|
+
// supports the verified Responses shape for fixture use, but the request
|
|
21
|
+
// hook must preserve Pi's server-managed/safety-sensitive bypass.
|
|
22
|
+
return !!model && isKnownToolOrderApi(model.api) && !isResponsesPromptRewriteBypassApi(model.api);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getToolNameForPayload(tool: unknown): string | undefined {
|
|
26
|
+
const record = asRecord(tool);
|
|
27
|
+
if (!record) return undefined;
|
|
28
|
+
if (isNonEmptyString(record.name)) return record.name;
|
|
29
|
+
const fn = asRecord(record.function);
|
|
30
|
+
if (isNonEmptyString(fn?.name)) return fn.name;
|
|
31
|
+
const custom = asRecord(record.custom);
|
|
32
|
+
if (isNonEmptyString(custom?.name)) return custom.name;
|
|
33
|
+
const spec = asRecord(record.toolSpec);
|
|
34
|
+
if (isNonEmptyString(spec?.name)) return spec.name;
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function compareToolOrderEntries(
|
|
39
|
+
left: { name: string; index: number },
|
|
40
|
+
right: { name: string; index: number },
|
|
41
|
+
): number {
|
|
42
|
+
// Do not use localeCompare here: its result can vary with the host locale.
|
|
43
|
+
// Exact UTF-16 code-unit ordering plus the original index is reproducible.
|
|
44
|
+
return left.name < right.name ? -1 : left.name > right.name ? 1 : left.index - right.index;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isJsonObject(value: unknown): value is UnknownRecord {
|
|
48
|
+
return asRecord(value) !== undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isVerifiedToolForApi(tool: unknown, api: ToolOrderApi): tool is UnknownRecord {
|
|
52
|
+
const record = asRecord(tool);
|
|
53
|
+
if (!record) return false;
|
|
54
|
+
|
|
55
|
+
if (api === "openai-completions") {
|
|
56
|
+
if (record.type === "function") {
|
|
57
|
+
const fn = asRecord(record.function);
|
|
58
|
+
return isNonEmptyString(fn?.name) && isJsonObject(fn.parameters);
|
|
59
|
+
}
|
|
60
|
+
if (record.type === "custom") {
|
|
61
|
+
const custom = asRecord(record.custom);
|
|
62
|
+
return isNonEmptyString(custom?.name) && isJsonObject(custom.format);
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (api === "openai-responses") {
|
|
68
|
+
if (record.type === "function") {
|
|
69
|
+
return isNonEmptyString(record.name) && isJsonObject(record.parameters);
|
|
70
|
+
}
|
|
71
|
+
if (record.type === "custom") {
|
|
72
|
+
return isNonEmptyString(record.name) && isJsonObject(record.format);
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (api === "anthropic-messages") {
|
|
78
|
+
return isNonEmptyString(record.name) && isJsonObject(record.input_schema);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (api === "google-generative-ai" || api === "google-vertex") {
|
|
82
|
+
return isNonEmptyString(record.name) &&
|
|
83
|
+
(isJsonObject(record.parametersJsonSchema) || isJsonObject(record.parameters));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const spec = asRecord(record.toolSpec);
|
|
87
|
+
const inputSchema = asRecord(spec?.inputSchema);
|
|
88
|
+
return isNonEmptyString(spec?.name) && isJsonObject(inputSchema?.json);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ToolArrayInspection = {
|
|
92
|
+
sortedIndices: number[];
|
|
93
|
+
changed: boolean;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function hasTopLevelCacheControl(tools: unknown): boolean {
|
|
97
|
+
return Array.isArray(tools) && tools.some((tool) => {
|
|
98
|
+
const record = asRecord(tool);
|
|
99
|
+
return !!record && Object.prototype.hasOwnProperty.call(record, "cache_control");
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function inspectToolArray(tools: unknown, api: ToolOrderApi): ToolArrayInspection | undefined {
|
|
104
|
+
if (!Array.isArray(tools)) return undefined;
|
|
105
|
+
// Native Anthropic and OpenAI-compatible transports can attach a cache
|
|
106
|
+
// breakpoint to a specific tool (normally the final one). Never move it,
|
|
107
|
+
// regardless of API id. Anthropic defer_loading also encodes immediate vs
|
|
108
|
+
// deferred tool groups in array order, so mixed/grouped payloads are no-ops.
|
|
109
|
+
if (hasTopLevelCacheControl(tools)) return undefined;
|
|
110
|
+
if (api === "anthropic-messages" && tools.some((tool) => {
|
|
111
|
+
const record = asRecord(tool);
|
|
112
|
+
return !!record && Object.prototype.hasOwnProperty.call(record, "defer_loading");
|
|
113
|
+
})) return undefined;
|
|
114
|
+
const entries = tools.map((tool, index) => {
|
|
115
|
+
if (!isVerifiedToolForApi(tool, api)) return undefined;
|
|
116
|
+
return { name: getToolNameForPayload(tool) ?? "", index };
|
|
117
|
+
});
|
|
118
|
+
if (entries.some((entry) => entry === undefined)) return undefined;
|
|
119
|
+
|
|
120
|
+
const verified = entries as Array<{ name: string; index: number }>;
|
|
121
|
+
const sorted = [...verified].sort(compareToolOrderEntries);
|
|
122
|
+
return {
|
|
123
|
+
sortedIndices: sorted.map((entry) => entry.index),
|
|
124
|
+
changed: sorted.some((entry, index) => entry.index !== index),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ToolArrayPath = {
|
|
129
|
+
kind: "root" | "bedrock" | "google";
|
|
130
|
+
groupIndex?: number;
|
|
131
|
+
sortedIndices: number[];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pure deterministic tool-order normalizer for the exact payload shapes
|
|
136
|
+
* emitted by Pi's built-in transports. It returns the original object for a
|
|
137
|
+
* no-op/unsupported/malformed payload and shallow-clones only the verified
|
|
138
|
+
* path to a tool array when sorting is needed. Tool objects and unrelated
|
|
139
|
+
* request fields (including SDK objects and AbortSignal) retain their identity.
|
|
140
|
+
*/
|
|
141
|
+
export function normalizeToolsInPayload(
|
|
142
|
+
payload: unknown,
|
|
143
|
+
api: unknown,
|
|
144
|
+
): { payload: unknown; changed: boolean } {
|
|
145
|
+
if (!isKnownToolOrderApi(api)) return { payload, changed: false };
|
|
146
|
+
const root = asRecord(payload);
|
|
147
|
+
if (!root) return { payload, changed: false };
|
|
148
|
+
|
|
149
|
+
if (api === "google-generative-ai" || api === "google-vertex") {
|
|
150
|
+
const config = asRecord(root.config);
|
|
151
|
+
const groups = config?.tools;
|
|
152
|
+
if (!Array.isArray(groups) || groups.length === 0) return { payload, changed: false };
|
|
153
|
+
|
|
154
|
+
const paths: ToolArrayPath[] = [];
|
|
155
|
+
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
|
156
|
+
const group = asRecord(groups[groupIndex]);
|
|
157
|
+
if (!group || !Array.isArray(group.functionDeclarations)) return { payload, changed: false };
|
|
158
|
+
const inspected = inspectToolArray(group.functionDeclarations, api);
|
|
159
|
+
if (!inspected) return { payload, changed: false };
|
|
160
|
+
if (inspected.changed) paths.push({ kind: "google", groupIndex, sortedIndices: inspected.sortedIndices });
|
|
161
|
+
}
|
|
162
|
+
if (paths.length === 0) return { payload, changed: false };
|
|
163
|
+
|
|
164
|
+
const sortedGroups = [...groups];
|
|
165
|
+
for (const path of paths) {
|
|
166
|
+
const groupIndex = path.groupIndex!;
|
|
167
|
+
const group = asRecord(groups[groupIndex])!;
|
|
168
|
+
const tools = group.functionDeclarations as unknown[];
|
|
169
|
+
sortedGroups[groupIndex] = {
|
|
170
|
+
...group,
|
|
171
|
+
functionDeclarations: path.sortedIndices.map((index) => tools[index]),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
payload: { ...root, config: { ...config, tools: sortedGroups } },
|
|
176
|
+
changed: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (api === "bedrock-converse-stream") {
|
|
181
|
+
const toolConfig = asRecord(root.toolConfig);
|
|
182
|
+
const tools = toolConfig?.tools;
|
|
183
|
+
const inspected = inspectToolArray(tools, api);
|
|
184
|
+
if (!inspected || !inspected.changed || !Array.isArray(tools)) return { payload, changed: false };
|
|
185
|
+
return {
|
|
186
|
+
payload: {
|
|
187
|
+
...root,
|
|
188
|
+
toolConfig: {
|
|
189
|
+
...toolConfig,
|
|
190
|
+
tools: inspected.sortedIndices.map((index) => tools[index]),
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
changed: true,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const tools = root.tools;
|
|
198
|
+
const inspected = inspectToolArray(tools, api);
|
|
199
|
+
if (!inspected || !inspected.changed || !Array.isArray(tools)) return { payload, changed: false };
|
|
200
|
+
return {
|
|
201
|
+
payload: { ...root, tools: inspected.sortedIndices.map((index) => tools[index]) },
|
|
202
|
+
changed: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Public test-facing alias: a pure payload transformation. */
|
|
207
|
+
export function sortToolsInPayload(payload: unknown, api: unknown): unknown {
|
|
208
|
+
return normalizeToolsInPayload(payload, api).payload;
|
|
209
|
+
}
|