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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.16",
3
+ "version": "2.8.18",
4
4
  "description": "Improve Pi prompt/KV cache hit rates with stable prompts, OpenAI-compatible cache keys, proxy compat warnings, and footer cache stats.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -16,7 +16,8 @@
16
16
  "author": "freescheme",
17
17
  "license": "MIT",
18
18
  "files": [
19
- "index.ts"
19
+ "index.ts",
20
+ "src"
20
21
  ],
21
22
  "pi": {
22
23
  "extensions": [
@@ -31,12 +32,13 @@
31
32
  "test": "node --import jiti/register --test tests/*.test.ts",
32
33
  "typecheck": "tsc --noEmit --pretty false",
33
34
  "check:diff": "git diff --check",
34
- "check:pack": "npm pack --dry-run --cache node_modules/.cache/npm",
35
- "check": "npm run typecheck && npm test && npm run check:diff && npm run check:pack"
35
+ "check:pack": "node scripts/check-pack.mjs",
36
+ "check": "npm run typecheck && npm test && npm run check:diff && npm run check:modules && npm run check:pack",
37
+ "check:modules": "node scripts/check-module-graph.mjs"
36
38
  },
37
39
  "devDependencies": {
38
- "@earendil-works/pi-coding-agent": "^0.99.2",
39
- "@earendil-works/pi-server": "0.99.2",
40
+ "@earendil-works/pi-coding-agent": "^1.0.0",
41
+ "@earendil-works/pi-server": "1.0.0",
40
42
  "@types/node": "^22.0.0",
41
43
  "jiti": "^2.7.0",
42
44
  "typescript": "^5.8.3"
@@ -0,0 +1,250 @@
1
+ import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type PiModel, isNonEmptyString } from "./common.ts";
3
+ import { buildDeepSeekCompatWarningText, buildOpenAIProxyCompatWarningText, describeMissingCacheCompatForModel, describeMissingOpenAICompatibleProxyCompat, isDeepSeekWireCompatApplicable } from "./compat-advice.ts";
4
+ import { getAssistantMessageModelTokenValues, getModelIdNameTokenValues, hasAnyTokenContaining } from "./model-detect.ts";
5
+ import { ALEPH_MODEL_PATTERN, ARCTIC_MODEL_PATTERN, AYA_MODEL_PATTERN, DOUBAO_SEED_PATTERN, MIMO_MODEL_PATTERN, MPT_MODEL_PATTERN, NOVA_MODEL_PATTERN, ORION_MODEL_PATTERN, PHI_MODEL_PATTERN, PPLX_MODEL_PATTERN, XAI_MODEL_PATTERN, YI_MODEL_PATTERN, getAssistantRecord, getCompat, isAssistantMessage, isClaudeLikeAssistantMessage, isClaudeLikeModel, isDeepSeekLikeAssistantMessage, isDeepSeekLikeModel, isGeminiLikeAssistantMessage, isGeminiLikeModel, isNativeVirtualModel, isOpenAICompatibleApi, isOpenAIFamilyAssistantMessage, isOpenAIFamilyModel, isPiBuiltInLlamaCppModel, modelKey } from "./model-identity.ts";
6
+ import { firstNonEmptyString, getRoutingRegistry, isRouterModel, resolveActiveRouteSnapshot } from "./routing.ts";
7
+ import { type CacheProviderId, type UsageSnapshot } from "./stats-store.ts";
8
+ import { getAnthropicRawUsage, getDeepSeekRawUsage, getGeminiRawUsage, getOpenAIRawUsage, normalizeWithFallback } from "./usage.ts";
9
+
10
+ export type CacheProviderAdapter = {
11
+ id: CacheProviderId;
12
+ label: string;
13
+ showCacheWrite?: boolean;
14
+ matchesModel(model: PiModel | undefined): boolean;
15
+ matchesAssistantMessage(message: unknown, model: PiModel | undefined): boolean;
16
+ normalizeUsage(message: unknown): UsageSnapshot | undefined;
17
+ warningText?(model: PiModel): string | undefined;
18
+ };
19
+
20
+ export function isVirtualRoutingModel(model: PiModel | undefined, ctx?: Pick<ExtensionContext, "sessionManager">): boolean {
21
+ if (!model) return false;
22
+ return isNativeVirtualModel(model) || isRouterModel(model) || !!getRoutingRegistry()?.getRouter(model.provider) || !!resolveActiveRouteSnapshot(model, ctx);
23
+ }
24
+
25
+ export function modelFromAssistantMessage(message: unknown, fallback: PiModel | undefined, preferCatalogId = false): PiModel | undefined {
26
+ const record = getAssistantRecord(message);
27
+ if (!record) return fallback;
28
+
29
+ // Pi's model field is the dispatched catalog id. Legacy routing adapters
30
+ // can still use responseModel as their only physical identity, so callers
31
+ // that need catalog attribution opt in rather than changing that protocol.
32
+ const id = preferCatalogId
33
+ ? firstNonEmptyString(record.model, record.responseModel, fallback?.id)
34
+ : firstNonEmptyString(record.responseModel, record.model, fallback?.id);
35
+ const provider = firstNonEmptyString(record.provider, fallback?.provider);
36
+ const api = firstNonEmptyString(record.api, fallback?.api) ?? "";
37
+ if (!id || !provider) return fallback;
38
+
39
+ const fallbackName = isNonEmptyString(fallback?.name) ? fallback.name : undefined;
40
+ const preservesFallbackIdentity =
41
+ !isVirtualRoutingModel(fallback) &&
42
+ provider === fallback?.provider &&
43
+ id === fallback?.id &&
44
+ fallbackName !== undefined;
45
+
46
+ return {
47
+ ...(fallback ?? {}),
48
+ id,
49
+ // Direct providers such as kimi-coding may echo only a short model id
50
+ // (`k3`) while the active model name (`Kimi K3`) carries the adapter token.
51
+ // Preserve that display name only when response and fallback identities are
52
+ // exactly the same; routed/different identities keep message-local naming.
53
+ name: preservesFallbackIdentity ? fallbackName : id,
54
+ provider,
55
+ api,
56
+ baseUrl: fallback?.baseUrl ?? "",
57
+ reasoning: fallback?.reasoning ?? false,
58
+ input: fallback?.input ?? ["text"],
59
+ cost: fallback?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
60
+ contextWindow: fallback?.contextWindow ?? 0,
61
+ maxTokens: fallback?.maxTokens ?? 0,
62
+ } as PiModel;
63
+ }
64
+
65
+ /**
66
+ * Model families that share one adapter shape: usage is read from OpenAI-shaped
67
+ * fields and the OpenAI-compatible proxy compat warning applies. A family
68
+ * matches when any of the model's (or the assistant message's) lowercase id /
69
+ * name tokens contains one of `needles`, or satisfies `pattern`. Order matters:
70
+ * the first matching adapter wins, so it follows the specific adapters above.
71
+ * Supporting a new family is one row here.
72
+ */
73
+ export type OpenAIShapedFamily = { label: string; needles: string[]; pattern?: RegExp };
74
+
75
+ export const OPENAI_SHAPED_FAMILIES: OpenAIShapedFamily[] = [
76
+ { label: "Kimi cache", needles: ["kimi"] },
77
+ { label: "Qwen cache", needles: ["qwen"] },
78
+ { label: "GLM cache", needles: ["glm"] },
79
+ { label: "MiniMax cache", needles: ["minimax"] },
80
+ { label: "Mimo cache", needles: ["xiaomimimo"], pattern: MIMO_MODEL_PATTERN },
81
+ { label: "Hunyuan cache", needles: ["hunyuan"] },
82
+ { label: "Mistral cache", needles: ["mistral", "mixtral", "codestral"] },
83
+ { label: "Grok cache", needles: ["grok"], pattern: XAI_MODEL_PATTERN },
84
+ { label: "Llama cache", needles: ["llama"] },
85
+ { label: "Nemotron cache", needles: ["nemotron"] },
86
+ { label: "Cohere cache", needles: ["cohere", "command-r"] },
87
+ { label: "Yi cache", needles: ["yi-", "01-ai", "zero-one"], pattern: YI_MODEL_PATTERN },
88
+ { label: "Doubao cache", needles: ["doubao", "豆包", "volcengine", "bytedance", "byte-dance"], pattern: DOUBAO_SEED_PATTERN },
89
+ { label: "ERNIE cache", needles: ["ernie", "wenxin", "文心", "yiyan", "一言", "baidu"] },
90
+ { label: "Baichuan cache", needles: ["baichuan", "百川"] },
91
+ { label: "StepFun cache", needles: ["stepfun", "step-"] },
92
+ { label: "Spark cache", needles: ["spark", "xinghuo", "星火", "iflytek", "讯飞"] },
93
+ { label: "InternLM cache", needles: ["internlm", "intern-lm", "书生"] },
94
+ { label: "Gemma cache", needles: ["gemma"] },
95
+ { label: "Phi cache", needles: ["phi-"], pattern: PHI_MODEL_PATTERN },
96
+ { label: "Jamba cache", needles: ["jamba", "ai21"] },
97
+ { label: "Solar cache", needles: ["solar", "upstage"] },
98
+ { label: "Sonar cache", needles: ["sonar", "perplexity"], pattern: PPLX_MODEL_PATTERN }, // Perplexity / Sonar
99
+ { label: "Nova cache", needles: ["amazon-nova"], pattern: NOVA_MODEL_PATTERN }, // Amazon Nova
100
+ { label: "Reka cache", needles: ["reka"] }, // Reka
101
+ { label: "Falcon cache", needles: ["falcon", "tiiuae"] }, // Falcon / TII
102
+ { label: "DBRX cache", needles: ["dbrx", "databricks"] }, // Databricks DBRX
103
+ { label: "MPT cache", needles: ["mosaicml", "mpt-"], pattern: MPT_MODEL_PATTERN }, // MosaicML MPT
104
+ { label: "StableLM cache", needles: ["stablelm", "stable-lm", "stability-ai"] }, // StableLM / Stability AI
105
+ { label: "Aquila cache", needles: ["aquila", "baai"] }, // BAAI / Aquila
106
+ { label: "EXAONE cache", needles: ["exaone"] }, // LG EXAONE
107
+ { label: "HyperCLOVA cache", needles: ["hyperclova", "clova-x"] }, // Naver HyperCLOVA X (conservative: hyperclova, clova-x only)
108
+ { label: "Luminous cache", needles: ["luminous", "aleph-alpha"], pattern: ALEPH_MODEL_PATTERN }, // Aleph Alpha Luminous
109
+ { label: "Hermes cache", needles: ["nous", "hermes", "openhermes"] }, // Nous / Hermes / OpenHermes
110
+ { label: "Granite cache", needles: ["granite", "ibm-granite"] }, // IBM Granite
111
+ { label: "Arctic cache", needles: ["snowflake-arctic"], pattern: ARCTIC_MODEL_PATTERN }, // Snowflake Arctic
112
+ { label: "Pangu cache", needles: ["pangu", "pan-gu", "盘古", "huawei-pangu"] }, // Huawei Pangu / 盘古
113
+ { label: "SenseNova cache", needles: ["sensenova", "sense-nova", "sensechat", "商汤"] }, // SenseTime SenseNova / 商汤
114
+ { label: "Zhinao cache", needles: ["360gpt", "360-gpt", "zhinao", "智脑"] }, // 360 Zhinao / 智脑
115
+ { label: "MiniCPM cache", needles: ["minicpm", "mini-cpm", "openbmb"] }, // OpenBMB MiniCPM
116
+ { label: "XVERSE cache", needles: ["xverse"] }, // XVERSE
117
+ { label: "Orion cache", needles: ["orionstar", "orion-star"], pattern: ORION_MODEL_PATTERN }, // OrionStar Orion
118
+ { label: "OpenChat cache", needles: ["openchat"] }, // OpenChat
119
+ { label: "Vicuna cache", needles: ["vicuna"] }, // Vicuna
120
+ { label: "Wizard cache", needles: ["wizardlm", "wizard-lm", "wizardcoder", "wizard-coder"] }, // WizardLM / WizardCoder
121
+ { label: "Zephyr cache", needles: ["zephyr"] }, // Zephyr
122
+ { label: "Dolphin cache", needles: ["dolphin"] }, // Dolphin
123
+ { label: "OpenOrca cache", needles: ["openorca", "open-orca"] }, // OpenOrca
124
+ { label: "Starling cache", needles: ["starling"] }, // Starling
125
+ { label: "BLOOM cache", needles: ["bloom", "bigscience"] }, // BLOOM / BigScience
126
+ { label: "RWKV cache", needles: ["rwkv"] }, // RWKV
127
+ { label: "Aya cache", needles: ["aya-expanse"], pattern: AYA_MODEL_PATTERN }, // Cohere Aya
128
+ ];
129
+
130
+ export function familyMatchesTokens(family: OpenAIShapedFamily, tokens: string[]): boolean {
131
+ return hasAnyTokenContaining(tokens, family.needles) || (family.pattern !== undefined && tokens.some((token) => family.pattern!.test(token)));
132
+ }
133
+
134
+ export function createOpenAIShapedAdapter(family: OpenAIShapedFamily): CacheProviderAdapter {
135
+ return {
136
+ id: "openai" as CacheProviderId,
137
+ label: family.label,
138
+ matchesModel: (model) => familyMatchesTokens(family, getModelIdNameTokenValues(model)),
139
+ matchesAssistantMessage(message, model) {
140
+ if (!isAssistantMessage(message)) return false;
141
+ return familyMatchesTokens(family, [...getModelIdNameTokenValues(model), ...getAssistantMessageModelTokenValues(message)]);
142
+ },
143
+ normalizeUsage(message) {
144
+ return normalizeWithFallback(message, getOpenAIRawUsage);
145
+ },
146
+ warningText(model) {
147
+ const missing = describeMissingOpenAICompatibleProxyCompat(model);
148
+ if (missing.length === 0) return undefined;
149
+ return buildOpenAIProxyCompatWarningText(modelKey(model), missing);
150
+ },
151
+ };
152
+ }
153
+
154
+ export const OPENAI_SHAPED_FAMILY_ADAPTERS: CacheProviderAdapter[] = OPENAI_SHAPED_FAMILIES.map(createOpenAIShapedAdapter);
155
+
156
+ export const CACHE_PROVIDER_ADAPTERS: CacheProviderAdapter[] = [
157
+ {
158
+ id: "deepseek",
159
+ label: "DS cache",
160
+ matchesModel: isDeepSeekLikeModel,
161
+ matchesAssistantMessage(message, model) {
162
+ if (!isAssistantMessage(message)) return false;
163
+ return isDeepSeekLikeAssistantMessage(message, model);
164
+ },
165
+ normalizeUsage(message) {
166
+ return normalizeWithFallback(message, getDeepSeekRawUsage, { allowInputOnlyPiUsage: true });
167
+ },
168
+ warningText(model) {
169
+ const missing = describeMissingCacheCompatForModel(model);
170
+ if (missing.length === 0) return undefined;
171
+
172
+ const key = modelKey(model);
173
+ return isDeepSeekWireCompatApplicable(model)
174
+ ? buildDeepSeekCompatWarningText(key, missing)
175
+ : buildOpenAIProxyCompatWarningText(key, missing);
176
+ },
177
+ },
178
+ {
179
+ id: "claude",
180
+ label: "Claude cache",
181
+ showCacheWrite: true,
182
+ matchesModel: isClaudeLikeModel,
183
+ matchesAssistantMessage(message, model) {
184
+ if (!isAssistantMessage(message)) return false;
185
+ return isClaudeLikeAssistantMessage(message, model);
186
+ },
187
+ normalizeUsage(message) {
188
+ return normalizeWithFallback(message, getAnthropicRawUsage);
189
+ },
190
+ warningText(model) {
191
+ if (!isClaudeLikeModel(model) || !isOpenAICompatibleApi(model.api) || isPiBuiltInLlamaCppModel(model)) return undefined;
192
+ if (getCompat(model).cacheControlFormat === "anthropic") return undefined;
193
+
194
+ return (
195
+ `💡 Cache optimizer: ${modelKey(model)} looks Claude/Anthropic-like but OpenAI-compatible compat lacks cacheControlFormat: "anthropic". ` +
196
+ "Pi may not place Anthropic cache_control breakpoints unless this endpoint supports and enables that compat flag."
197
+ );
198
+ },
199
+ },
200
+ {
201
+ id: "openai",
202
+ label: "OpenAI cache",
203
+ matchesModel: isOpenAIFamilyModel,
204
+ matchesAssistantMessage(message, model) {
205
+ if (!isAssistantMessage(message)) return false;
206
+ return isOpenAIFamilyAssistantMessage(message, model);
207
+ },
208
+ normalizeUsage(message) {
209
+ return normalizeWithFallback(message, getOpenAIRawUsage);
210
+ },
211
+ warningText(model) {
212
+ const missing = describeMissingOpenAICompatibleProxyCompat(model);
213
+ if (missing.length === 0) return undefined;
214
+ return buildOpenAIProxyCompatWarningText(modelKey(model), missing);
215
+ },
216
+ },
217
+ {
218
+ id: "gemini",
219
+ label: "Gemini cache",
220
+ matchesModel: isGeminiLikeModel,
221
+ matchesAssistantMessage(message, model) {
222
+ if (!isAssistantMessage(message)) return false;
223
+ return isGeminiLikeAssistantMessage(message, model);
224
+ },
225
+ normalizeUsage(message) {
226
+ return normalizeWithFallback(message, getGeminiRawUsage);
227
+ },
228
+ },
229
+ // ── Non-GPT OpenAI-compatible adapters ──────────────────────
230
+ // ── More OpenAI-compatible adapters ──────────────────────────
231
+ // ── More OpenAI-compatible adapters (batch 2) ───────────────────
232
+ // ── New OpenAI-compatible adapters (batch 3, 12 families) ────────
233
+ // ── More OpenAI-compatible adapters (batch 4, 18 families) ────────
234
+ ...OPENAI_SHAPED_FAMILY_ADAPTERS,
235
+ ];
236
+
237
+ export function selectAdapterForModel(model: PiModel | undefined): CacheProviderAdapter | undefined {
238
+ // A native virtual selection has no cache identity of its own; stats always
239
+ // belong to the physical model it routed to.
240
+ if (isNativeVirtualModel(model)) return undefined;
241
+ return CACHE_PROVIDER_ADAPTERS.find((adapter) => adapter.matchesModel(model));
242
+ }
243
+
244
+ export function selectAdapterForAssistantMessage(message: unknown, model: PiModel | undefined): CacheProviderAdapter | undefined {
245
+ // Assistant message metadata is request-local and authoritative for virtual
246
+ // routing providers. Use it first for every model; direct providers normally
247
+ // echo the same provider/model and therefore remain unchanged.
248
+ const responseModel = modelFromAssistantMessage(message, model);
249
+ return CACHE_PROVIDER_ADAPTERS.find((adapter) => adapter.matchesAssistantMessage(message, responseModel));
250
+ }
@@ -0,0 +1,300 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { chmod, copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { LOG_PREFIX, asRecord, getErrorCode, isProcessAlive } from "./common.ts";
5
+ import { MODELS_TRANSACTION_LOCK_PATH, MODELS_TRANSACTION_LOCK_STALE_MS, MODELS_TRANSACTION_LOCK_WAIT_MS, STATE_DIR } from "./paths.ts";
6
+
7
+ /**
8
+ * Generate a unique UTC timestamp component for backup filenames.
9
+ * Milliseconds, process id, and an in-process sequence prevent collisions.
10
+ */
11
+ export let backupSequence = 0;
12
+
13
+ export function backupTimestamp(now: Date = new Date()): string {
14
+ const y = now.getUTCFullYear();
15
+ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
16
+ const d = String(now.getUTCDate()).padStart(2, "0");
17
+ const h = String(now.getUTCHours()).padStart(2, "0");
18
+ const min = String(now.getUTCMinutes()).padStart(2, "0");
19
+ const s = String(now.getUTCSeconds()).padStart(2, "0");
20
+ const ms = String(now.getUTCMilliseconds()).padStart(3, "0");
21
+ return `${y}${m}${d}T${h}${min}${s}${ms}Z-${process.pid}-${backupSequence++}`;
22
+ }
23
+
24
+ export function uniqueTempPath(targetPath: string, purpose: string): string {
25
+ return `${targetPath}.${process.pid}.${Date.now()}.${backupSequence++}.${purpose}.tmp`;
26
+ }
27
+
28
+ export type FileIdentity = { dev: number | bigint; ino: number | bigint };
29
+
30
+ export function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
31
+ // Some platforms do not expose a meaningful inode. On those platforms the
32
+ // hash checks around the transaction remain authoritative.
33
+ const leftDev = Number(left.dev);
34
+ const rightDev = Number(right.dev);
35
+ const leftIno = Number(left.ino);
36
+ const rightIno = Number(right.ino);
37
+ return leftIno === 0 || rightIno === 0 || (leftDev === rightDev && leftIno === rightIno);
38
+ }
39
+
40
+ export type AtomicTargetGuard = {
41
+ identity?: FileIdentity;
42
+ hash?: string;
43
+ mode?: number;
44
+ };
45
+
46
+ export async function validateAtomicTarget(
47
+ targetPath: string,
48
+ guard?: AtomicTargetGuard,
49
+ ): Promise<Awaited<ReturnType<typeof lstat>>> {
50
+ const info = await lstat(targetPath);
51
+ if (info.isSymbolicLink() || !info.isFile()) {
52
+ throw new Error("target is not a regular file; refusing atomic replacement");
53
+ }
54
+ if (guard?.identity && !sameFileIdentity(guard.identity, info)) {
55
+ throw new Error("target changed during atomic replacement");
56
+ }
57
+ if (guard?.mode !== undefined && (info.mode & 0o7777) !== guard.mode) {
58
+ throw new Error("target access mode changed during atomic replacement");
59
+ }
60
+ if (guard?.hash !== undefined) {
61
+ const text = await readFile(targetPath, "utf8");
62
+ if (hashText(text) !== guard.hash) {
63
+ throw new Error("target content changed during atomic replacement");
64
+ }
65
+ }
66
+ return info;
67
+ }
68
+
69
+ export async function atomicReplaceTextFilePreservingMode(
70
+ targetPath: string,
71
+ content: string,
72
+ mode: number,
73
+ purpose: string,
74
+ guard?: AtomicTargetGuard,
75
+ /** Test-only race injector; production callers leave this undefined. */
76
+ beforeRename?: () => Promise<void>,
77
+ ): Promise<void> {
78
+ const initialTargetInfo = await validateAtomicTarget(targetPath, guard);
79
+ const tempPath = uniqueTempPath(targetPath, purpose);
80
+ try {
81
+ // `wx` makes the unique temporary path non-overwriting even if a hostile
82
+ // or stale file appears between name generation and the write.
83
+ await writeFile(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
84
+ const tempInfo = await lstat(tempPath);
85
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
86
+ throw new Error("temporary replacement is not a regular file");
87
+ }
88
+ await chmod(tempPath, mode);
89
+ await validateAtomicTarget(targetPath, {
90
+ ...guard,
91
+ identity: initialTargetInfo,
92
+ mode,
93
+ });
94
+ if (beforeRename) await beforeRename();
95
+ await validateAtomicTarget(targetPath, {
96
+ ...guard,
97
+ identity: initialTargetInfo,
98
+ mode,
99
+ });
100
+ await rename(tempPath, targetPath);
101
+ } catch (error) {
102
+ try {
103
+ await unlink(tempPath);
104
+ } catch (cleanupError) {
105
+ if (getErrorCode(cleanupError) !== "ENOENT") {
106
+ console.warn(`${LOG_PREFIX}: failed to remove temporary models.json file`, cleanupError);
107
+ }
108
+ }
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ export async function atomicCreateTextFileNoReplace(
114
+ targetPath: string,
115
+ content: string,
116
+ mode: number,
117
+ purpose: string,
118
+ /** Test-only race injector; production callers leave this undefined. */
119
+ beforeLink?: () => Promise<void>,
120
+ ): Promise<void> {
121
+ const tempPath = uniqueTempPath(targetPath, purpose);
122
+ try {
123
+ await writeFile(tempPath, content, { encoding: "utf8", mode, flag: "wx" });
124
+ const tempInfo = await lstat(tempPath);
125
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
126
+ throw new Error("temporary creation is not a regular file");
127
+ }
128
+ await chmod(tempPath, mode);
129
+ // A hard link gives us an atomic no-replace create. Unlike rename(), it
130
+ // cannot overwrite a file that appeared after the absence check.
131
+ if (beforeLink) await beforeLink();
132
+ await link(tempPath, targetPath);
133
+ await unlink(tempPath).catch((cleanupError) => {
134
+ console.warn(`${LOG_PREFIX}: committed config file but failed to remove its temporary hard link`, cleanupError);
135
+ });
136
+ } catch (error) {
137
+ try {
138
+ await unlink(tempPath);
139
+ } catch (cleanupError) {
140
+ if (getErrorCode(cleanupError) !== "ENOENT") {
141
+ console.warn(`${LOG_PREFIX}: failed to remove temporary config file`, cleanupError);
142
+ }
143
+ }
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ export async function atomicRestoreFileFromBackup(
149
+ backupPath: string,
150
+ targetPath: string,
151
+ mode: number,
152
+ guard?: AtomicTargetGuard & { backupHash?: string },
153
+ /** Test-only race injector; production callers leave this undefined. */
154
+ beforeRename?: () => Promise<void>,
155
+ ): Promise<void> {
156
+ const backupInfo = await lstat(backupPath);
157
+ if (backupInfo.isSymbolicLink() || !backupInfo.isFile()) {
158
+ throw new Error("backup is not a regular file; refusing atomic restore");
159
+ }
160
+ if (guard?.backupHash !== undefined) {
161
+ const backupText = await readFile(backupPath, "utf8");
162
+ if (hashText(backupText) !== guard.backupHash) {
163
+ throw new Error("backup content changed during atomic restore");
164
+ }
165
+ }
166
+ const targetInfo = await validateAtomicTarget(targetPath, guard);
167
+ const tempPath = uniqueTempPath(targetPath, "restore");
168
+ try {
169
+ await copyFile(backupPath, tempPath, fsConstants.COPYFILE_EXCL);
170
+ const tempInfo = await lstat(tempPath);
171
+ if (tempInfo.isSymbolicLink() || !tempInfo.isFile()) {
172
+ throw new Error("temporary restore is not a regular file");
173
+ }
174
+ await chmod(tempPath, mode);
175
+ const tempText = await readFile(tempPath, "utf8");
176
+ if (guard?.backupHash !== undefined && hashText(tempText) !== guard.backupHash) {
177
+ throw new Error("backup content changed during atomic restore");
178
+ }
179
+ await validateAtomicTarget(targetPath, {
180
+ ...guard,
181
+ identity: targetInfo,
182
+ });
183
+ if (beforeRename) await beforeRename();
184
+ await validateAtomicTarget(targetPath, {
185
+ ...guard,
186
+ identity: targetInfo,
187
+ });
188
+ await rename(tempPath, targetPath);
189
+ } catch (error) {
190
+ try {
191
+ await unlink(tempPath);
192
+ } catch (cleanupError) {
193
+ if (getErrorCode(cleanupError) !== "ENOENT") {
194
+ console.warn(`${LOG_PREFIX}: failed to remove temporary restore file`, cleanupError);
195
+ }
196
+ }
197
+ throw error;
198
+ }
199
+ }
200
+
201
+ export function hashText(text: string): string {
202
+ return createHash("sha256").update(text, "utf8").digest("hex");
203
+ }
204
+
205
+ export async function withModelsJsonTransactionLock<T>(operation: () => Promise<T>): Promise<T> {
206
+ await mkdir(STATE_DIR, { recursive: true });
207
+ const deadline = Date.now() + MODELS_TRANSACTION_LOCK_WAIT_MS;
208
+ const ownerToken = randomUUID();
209
+ const ownerText = JSON.stringify({ pid: process.pid, token: ownerToken });
210
+ let ownedLockIdentity: FileIdentity | undefined;
211
+ while (true) {
212
+ try {
213
+ // A single O_EXCL file is the lease: unlike mkdir-then-owner-file, there
214
+ // is no ownerless crash window between creating the lock and identifying
215
+ // its owner.
216
+ await writeFile(MODELS_TRANSACTION_LOCK_PATH, ownerText, {
217
+ encoding: "utf8",
218
+ mode: 0o600,
219
+ flag: "wx",
220
+ });
221
+ const lockInfo = await lstat(MODELS_TRANSACTION_LOCK_PATH);
222
+ if (lockInfo.isSymbolicLink() || !lockInfo.isFile()) {
223
+ throw new Error("models.json transaction lock path is unsafe");
224
+ }
225
+ ownedLockIdentity = lockInfo;
226
+ break;
227
+ } catch (error) {
228
+ if (getErrorCode(error) !== "EEXIST") throw error;
229
+ const lockInfo = await lstat(MODELS_TRANSACTION_LOCK_PATH).catch(() => undefined);
230
+ if (!lockInfo) continue;
231
+ if (lockInfo.isSymbolicLink() || !lockInfo.isFile()) {
232
+ throw new Error("models.json transaction lock path is unsafe");
233
+ }
234
+ let staleOwnerPid: number | undefined;
235
+ try {
236
+ const owner = asRecord(JSON.parse(await readFile(MODELS_TRANSACTION_LOCK_PATH, "utf8")));
237
+ if (typeof owner?.pid === "number") staleOwnerPid = owner.pid;
238
+ } catch {}
239
+ const stale = Date.now() - lockInfo.mtimeMs > MODELS_TRANSACTION_LOCK_STALE_MS;
240
+ if (stale && (staleOwnerPid === undefined || !isProcessAlive(staleOwnerPid))) {
241
+ // Recheck identity immediately before unlinking so a recovered owner
242
+ // cannot delete a replacement lease created at the same path.
243
+ const currentLock = await lstat(MODELS_TRANSACTION_LOCK_PATH).catch(() => undefined);
244
+ if (currentLock && sameFileIdentity(lockInfo, currentLock)) {
245
+ await unlink(MODELS_TRANSACTION_LOCK_PATH).catch((unlinkError) => {
246
+ if (getErrorCode(unlinkError) !== "ENOENT") throw unlinkError;
247
+ });
248
+ }
249
+ continue;
250
+ }
251
+ if (Date.now() >= deadline) {
252
+ throw new Error("another cache-optimizer models.json transaction is still running");
253
+ }
254
+ await new Promise((resolve) => setTimeout(resolve, 25));
255
+ }
256
+ }
257
+
258
+ try {
259
+ return await operation();
260
+ } finally {
261
+ // Only the matching inode and owner token may remove the lease. A
262
+ // recovered/replaced lock belongs to another transaction.
263
+ try {
264
+ const currentLock = await lstat(MODELS_TRANSACTION_LOCK_PATH);
265
+ if (
266
+ ownedLockIdentity &&
267
+ !currentLock.isSymbolicLink() &&
268
+ currentLock.isFile() &&
269
+ sameFileIdentity(ownedLockIdentity, currentLock) &&
270
+ await readFile(MODELS_TRANSACTION_LOCK_PATH, "utf8") === ownerText
271
+ ) {
272
+ await unlink(MODELS_TRANSACTION_LOCK_PATH);
273
+ }
274
+ } catch (error) {
275
+ if (getErrorCode(error) !== "ENOENT") {
276
+ console.warn(`${LOG_PREFIX}: failed to remove models.json transaction lock`, error);
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ export async function readRegularTextFile(path: string): Promise<{ text: string; mode: number }> {
283
+ const info = await lstat(path);
284
+ if (info.isSymbolicLink() || !info.isFile()) {
285
+ throw new Error("file is not a regular file");
286
+ }
287
+ const text = await readFile(path, "utf8");
288
+ const afterRead = await lstat(path);
289
+ if (
290
+ afterRead.isSymbolicLink() ||
291
+ !afterRead.isFile() ||
292
+ !sameFileIdentity(info, afterRead)
293
+ ) {
294
+ throw new Error("file changed while it was being read");
295
+ }
296
+ return {
297
+ text,
298
+ mode: afterRead.mode & 0o7777,
299
+ };
300
+ }