pi-cache-optimizer 2.8.16 → 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 +246 -11409
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cache-optimizer",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.17",
|
|
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": "
|
|
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.
|
|
39
|
-
"@earendil-works/pi-server": "0.
|
|
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"
|
package/src/adapters.ts
ADDED
|
@@ -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
|
+
}
|
package/src/atomic-fs.ts
ADDED
|
@@ -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
|
+
}
|