pi-cache-optimizer 2.0.0
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/LICENSE +21 -0
- package/README.md +258 -0
- package/README.zh-CN.md +260 -0
- package/extension.ts +1143 -0
- package/package.json +32 -0
package/extension.ts
ADDED
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { BuildSystemPromptOptions, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pi Cache Optimizer (formerly pi-deepseek-cache-optimizer)
|
|
15
|
+
*
|
|
16
|
+
* What it does:
|
|
17
|
+
* 1. Reorders Pi's system prompt so stable content is sent before dynamic context.
|
|
18
|
+
* 2. Sets PI_CACHE_RETENTION=long at extension load time.
|
|
19
|
+
* 3. Auto-seeds a recommended DeepSeek entry into ~/.pi/agent/models.json on first run
|
|
20
|
+
* (only when no DeepSeek-like model is already configured; never overwrites).
|
|
21
|
+
* 4. Warns once for provider/model cache compat gaps where the signal is conservative.
|
|
22
|
+
* 5. Shows lightweight persisted provider-specific cache stats in Pi's footer.
|
|
23
|
+
*
|
|
24
|
+
* Provider prompt/KV caches are provider-side and best-effort. This extension improves
|
|
25
|
+
* the odds of cache hits; it cannot guarantee hits, especially through proxies.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// ============================================================
|
|
29
|
+
// Automatically request long prompt-cache retention when Pi supports it.
|
|
30
|
+
// ============================================================
|
|
31
|
+
if (!process.env.PI_CACHE_RETENTION || process.env.PI_CACHE_RETENTION !== "long") {
|
|
32
|
+
process.env.PI_CACHE_RETENTION = "long";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type PiModel = NonNullable<ExtensionContext["model"]>;
|
|
36
|
+
type UnknownRecord = Record<string, unknown>;
|
|
37
|
+
type CacheProviderId = "deepseek" | "openai" | "claude" | "gemini";
|
|
38
|
+
|
|
39
|
+
const LOG_PREFIX = "pi-cache-optimizer";
|
|
40
|
+
const STATUS_KEY = "pi-cache-stats";
|
|
41
|
+
const STATE_DIR = join(homedir(), ".pi", "agent");
|
|
42
|
+
const STATE_FILE_PATH = join(STATE_DIR, "pi-cache-optimizer-stats.json");
|
|
43
|
+
const LEGACY_STATE_FILE_PATH = join(STATE_DIR, "deepseek-cache-optimizer-stats.json");
|
|
44
|
+
const MODELS_JSON_PATH = join(STATE_DIR, "models.json");
|
|
45
|
+
|
|
46
|
+
const CACHE_PROVIDER_IDS: CacheProviderId[] = ["deepseek", "openai", "claude", "gemini"];
|
|
47
|
+
const OPENAI_CACHE_KEY_ENV = "PI_CACHE_OPTIMIZER_OPENAI_CACHE_KEY";
|
|
48
|
+
const OPENAI_PROMPT_CACHE_KEY_PREFIX = "pi-dsco-";
|
|
49
|
+
const NO_AUTO_CONFIG_ENV = "PI_CACHE_OPTIMIZER_NO_AUTO_CONFIG";
|
|
50
|
+
const DEEPSEEK_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
51
|
+
|
|
52
|
+
const ASSISTANT_MESSAGE_MODEL_TOKEN_KEYS = ["model", "name"];
|
|
53
|
+
const OPENAI_REASONING_MODEL_PATTERN = /(^|[/\s:_-])o[1345]($|[-_.:/\s])/;
|
|
54
|
+
|
|
55
|
+
type CacheCompat = {
|
|
56
|
+
sendSessionAffinityHeaders?: boolean;
|
|
57
|
+
sendSessionIdHeader?: boolean;
|
|
58
|
+
supportsLongCacheRetention?: boolean;
|
|
59
|
+
thinkingFormat?: string;
|
|
60
|
+
cacheControlFormat?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type CacheStats = {
|
|
64
|
+
day: string;
|
|
65
|
+
totalRequests: number;
|
|
66
|
+
hitRequests: number;
|
|
67
|
+
cachedInputTokens: number;
|
|
68
|
+
cacheWriteInputTokens: number;
|
|
69
|
+
totalInputTokens: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type PersistedCacheStatsV2 = {
|
|
73
|
+
version: 2;
|
|
74
|
+
statsByProvider: Partial<Record<CacheProviderId, CacheStats>>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
type UsageSnapshot = {
|
|
78
|
+
cacheRead: number;
|
|
79
|
+
cacheWrite: number;
|
|
80
|
+
totalInput: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type OptimizedSystemPrompt = {
|
|
84
|
+
systemPrompt: string;
|
|
85
|
+
stablePrefix: string;
|
|
86
|
+
changed: boolean;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type CacheProviderAdapter = {
|
|
90
|
+
id: CacheProviderId;
|
|
91
|
+
label: string;
|
|
92
|
+
showCacheWrite?: boolean;
|
|
93
|
+
matchesModel(model: PiModel | undefined): boolean;
|
|
94
|
+
matchesAssistantMessage(message: unknown, model: PiModel | undefined): boolean;
|
|
95
|
+
normalizeUsage(message: unknown): UsageSnapshot | undefined;
|
|
96
|
+
warningText?(model: PiModel): string | undefined;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function escapeXml(value: string): string {
|
|
100
|
+
return value
|
|
101
|
+
.replace(/&/g, "&")
|
|
102
|
+
.replace(/</g, "<")
|
|
103
|
+
.replace(/>/g, ">")
|
|
104
|
+
.replace(/\"/g, """)
|
|
105
|
+
.replace(/'/g, "'");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isStableContextFilePath(filePath: string): boolean {
|
|
109
|
+
const normalized = filePath.replace(/\\/g, "/").toLowerCase();
|
|
110
|
+
const name = normalized.split("/").pop();
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
name === "agents.md" ||
|
|
114
|
+
name === "claude.md" ||
|
|
115
|
+
name === "gemini.md" ||
|
|
116
|
+
name === "cursor.md" ||
|
|
117
|
+
normalized.startsWith(".trellis/spec/") ||
|
|
118
|
+
normalized.includes("/.trellis/spec/")
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatSkillsForPrompt(skills: NonNullable<BuildSystemPromptOptions["skills"]>): string {
|
|
123
|
+
const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
|
|
124
|
+
if (visibleSkills.length === 0) return "";
|
|
125
|
+
|
|
126
|
+
const lines = [
|
|
127
|
+
"\n\nThe following skills provide specialized instructions for specific tasks.",
|
|
128
|
+
"Use the read tool to load a skill's file when the task matches its description.",
|
|
129
|
+
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
|
|
130
|
+
"",
|
|
131
|
+
"<available_skills>",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
for (const skill of visibleSkills) {
|
|
135
|
+
lines.push(" <skill>");
|
|
136
|
+
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
|
137
|
+
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
|
138
|
+
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
|
|
139
|
+
lines.push(" </skill>");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
lines.push("</available_skills>");
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildStableCandidates(opts: BuildSystemPromptOptions): string[] {
|
|
147
|
+
const candidates: string[] = [];
|
|
148
|
+
|
|
149
|
+
if (opts.customPrompt) candidates.push(opts.customPrompt);
|
|
150
|
+
if (opts.appendSystemPrompt) candidates.push(opts.appendSystemPrompt);
|
|
151
|
+
|
|
152
|
+
const tools = opts.selectedTools ?? ["read", "bash", "edit", "write"];
|
|
153
|
+
const toolLines = tools
|
|
154
|
+
.filter((name) => opts.toolSnippets?.[name])
|
|
155
|
+
.map((name) => `- ${name}: ${opts.toolSnippets?.[name]}`);
|
|
156
|
+
if (toolLines.length > 0) {
|
|
157
|
+
candidates.push(`Available tools:\n${toolLines.join("\n")}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const guideline of opts.promptGuidelines ?? []) {
|
|
161
|
+
const normalized = guideline.trim();
|
|
162
|
+
if (normalized.length > 0) candidates.push(`- ${normalized}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const file of opts.contextFiles ?? []) {
|
|
166
|
+
// Provider caches work best when stable instructions are part of the earliest prefix.
|
|
167
|
+
// Only lift known-stable project/spec instruction files. Dynamic task/session context
|
|
168
|
+
// can be large too, so size alone must never make a context file cache-prefix material.
|
|
169
|
+
if (!isStableContextFilePath(file.path)) continue;
|
|
170
|
+
candidates.push(`## ${file.path}\n\n${file.content}`);
|
|
171
|
+
candidates.push(file.content);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (opts.skills && opts.skills.length > 0) {
|
|
175
|
+
candidates.push(formatSkillsForPrompt(opts.skills));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return candidates;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function optimizeSystemPrompt(
|
|
182
|
+
original: string,
|
|
183
|
+
opts: BuildSystemPromptOptions,
|
|
184
|
+
): OptimizedSystemPrompt {
|
|
185
|
+
const stableParts: string[] = [];
|
|
186
|
+
const seen = new Set<string>();
|
|
187
|
+
let rest = original;
|
|
188
|
+
|
|
189
|
+
// Stable layer: content likely to be identical across sessions/turns.
|
|
190
|
+
for (const candidate of buildStableCandidates(opts)) {
|
|
191
|
+
const part = candidate.trim();
|
|
192
|
+
if (!part || seen.has(part) || !rest.includes(part)) continue;
|
|
193
|
+
|
|
194
|
+
stableParts.push(part);
|
|
195
|
+
seen.add(part);
|
|
196
|
+
rest = rest.replace(part, "");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const stablePrefix = stableParts.join("\n\n");
|
|
200
|
+
|
|
201
|
+
// Dynamic layer: git status, active task context, recent session context, etc.
|
|
202
|
+
const dynamicRemainder = rest.trim();
|
|
203
|
+
|
|
204
|
+
if (stableParts.length === 0) {
|
|
205
|
+
return { systemPrompt: original, stablePrefix: "", changed: false };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
systemPrompt:
|
|
210
|
+
stablePrefix +
|
|
211
|
+
(dynamicRemainder.length > 0 ? "\n\n---\n\n" + dynamicRemainder : ""),
|
|
212
|
+
stablePrefix,
|
|
213
|
+
changed: true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildPromptCacheKey(stablePrefix: string): string | undefined {
|
|
218
|
+
const normalized = stablePrefix.trim();
|
|
219
|
+
if (!normalized) return undefined;
|
|
220
|
+
|
|
221
|
+
const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 24);
|
|
222
|
+
return `${OPENAI_PROMPT_CACHE_KEY_PREFIX}${digest}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function asRecord(value: unknown): UnknownRecord | undefined {
|
|
226
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
227
|
+
return value as UnknownRecord;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function lower(value: unknown): string {
|
|
231
|
+
return typeof value === "string" ? value.toLowerCase() : "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getNumber(value: unknown): number | undefined {
|
|
235
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getNonNegativeNumber(record: UnknownRecord, key: string): number | undefined {
|
|
239
|
+
const value = getNumber(record[key]);
|
|
240
|
+
return value !== undefined && value >= 0 ? value : undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function getCompat(model: PiModel | undefined): CacheCompat {
|
|
244
|
+
return (model?.compat ?? {}) as CacheCompat;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isEnabledEnv(value: string | undefined): boolean {
|
|
248
|
+
if (!value) return false;
|
|
249
|
+
const normalized = value.trim().toLowerCase();
|
|
250
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hasOwn(record: UnknownRecord, key: string): boolean {
|
|
254
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isAssistantMessage(message: unknown): boolean {
|
|
258
|
+
return asRecord(message)?.role === "assistant";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getAssistantRecord(message: unknown): UnknownRecord | undefined {
|
|
262
|
+
const record = asRecord(message);
|
|
263
|
+
return record?.role === "assistant" ? record : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function getModelIdNameTokenValues(model: PiModel | undefined): string[] {
|
|
267
|
+
if (!model) return [];
|
|
268
|
+
return [model.id, model.name].map(lower).filter(Boolean);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function getAssistantMessageModelTokenValues(message: unknown): string[] {
|
|
272
|
+
const record = asRecord(message);
|
|
273
|
+
if (!record) return [];
|
|
274
|
+
|
|
275
|
+
return ASSISTANT_MESSAGE_MODEL_TOKEN_KEYS.map((key) => lower(record[key])).filter(Boolean);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function hasAnyTokenContaining(tokens: string[], needles: string[]): boolean {
|
|
279
|
+
return tokens.some((token) => needles.some((needle) => token.includes(needle)));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function modelOrAssistantMessageHas(message: unknown, model: PiModel | undefined, needles: string[]): boolean {
|
|
283
|
+
return hasAnyTokenContaining([...getModelIdNameTokenValues(model), ...getAssistantMessageModelTokenValues(message)], needles);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isDeepSeekLikeModel(model: PiModel | undefined): boolean {
|
|
287
|
+
return hasAnyTokenContaining(getModelIdNameTokenValues(model), ["deepseek"]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isDeepSeekLikeAssistantMessage(message: unknown, model: PiModel | undefined): boolean {
|
|
291
|
+
return modelOrAssistantMessageHas(message, model, ["deepseek"]);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isOpenAICompatibleApi(api: unknown): boolean {
|
|
295
|
+
const value = lower(api);
|
|
296
|
+
return value === "openai-completions" || value === "openai-responses";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isOpenAIFamilyToken(token: string): boolean {
|
|
300
|
+
return token.includes("gpt-") || token.includes("chatgpt") || OPENAI_REASONING_MODEL_PATTERN.test(token);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isOpenAIFamilyModel(model: PiModel | undefined): boolean {
|
|
304
|
+
return getModelIdNameTokenValues(model).some(isOpenAIFamilyToken);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isOpenAIFamilyAssistantMessage(message: unknown, model: PiModel | undefined): boolean {
|
|
308
|
+
return [...getModelIdNameTokenValues(model), ...getAssistantMessageModelTokenValues(message)].some(isOpenAIFamilyToken);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isClaudeLikeModel(model: PiModel | undefined): boolean {
|
|
312
|
+
return hasAnyTokenContaining(getModelIdNameTokenValues(model), ["anthropic", "claude"]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isClaudeLikeAssistantMessage(message: unknown, model: PiModel | undefined): boolean {
|
|
316
|
+
return modelOrAssistantMessageHas(message, model, ["anthropic", "claude"]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isGeminiLikeModel(model: PiModel | undefined): boolean {
|
|
320
|
+
return hasAnyTokenContaining(getModelIdNameTokenValues(model), ["gemini", "vertex"]);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isGeminiLikeAssistantMessage(message: unknown, model: PiModel | undefined): boolean {
|
|
324
|
+
return modelOrAssistantMessageHas(message, model, ["gemini", "vertex"]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function modelKey(model: PiModel): string {
|
|
328
|
+
return `${model.provider}/${model.id}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function usageRecordFromAssistant(message: unknown): UnknownRecord | undefined {
|
|
332
|
+
return asRecord(getAssistantRecord(message)?.usage);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function getNestedRecord(record: UnknownRecord | undefined, key: string): UnknownRecord | undefined {
|
|
336
|
+
return asRecord(record?.[key]);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function getFirstNonNegativeNumber(...values: unknown[]): number | undefined {
|
|
340
|
+
for (const value of values) {
|
|
341
|
+
const number = getNumber(value);
|
|
342
|
+
if (number !== undefined && number >= 0) return number;
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function readCachedTokensFromDetails(details: UnknownRecord | undefined): number | undefined {
|
|
348
|
+
return getFirstNonNegativeNumber(details?.cached_tokens, details?.cachedTokens);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function readCacheWriteFromDetails(details: UnknownRecord | undefined): number | undefined {
|
|
352
|
+
return getFirstNonNegativeNumber(details?.cache_write_tokens, details?.cacheWriteTokens);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Pi normalizes provider-specific raw usage (prompt_cache_hit_tokens, cached_tokens,
|
|
356
|
+
// cache_read_input_tokens, etc.) into a common shape:
|
|
357
|
+
// input = uncached prompt portion (total prompt minus cacheRead minus cacheWrite)
|
|
358
|
+
// cacheRead = tokens read from a previously-cached prefix
|
|
359
|
+
// cacheWrite= tokens newly written into cache in this request
|
|
360
|
+
//
|
|
361
|
+
// We reconstruct the total prompt-token count as input + cacheRead + cacheWrite.
|
|
362
|
+
// Pi guarantees that input, cacheRead, and cacheWrite are always present on
|
|
363
|
+
// assistant messages processed through its provider pipeline (at least as zero).
|
|
364
|
+
//
|
|
365
|
+
// Only DeepSeek sets allowInputOnly=true so that a cache miss (cacheRead=0) still
|
|
366
|
+
// contributes total input tokens to the denominator.
|
|
367
|
+
function getPiNormalizedUsage(message: unknown, allowInputOnly = false): UsageSnapshot | undefined {
|
|
368
|
+
const usage = usageRecordFromAssistant(message);
|
|
369
|
+
if (!usage) return undefined;
|
|
370
|
+
|
|
371
|
+
const input = getNonNegativeNumber(usage, "input");
|
|
372
|
+
const cacheRead = getNonNegativeNumber(usage, "cacheRead");
|
|
373
|
+
const cacheWrite = getNonNegativeNumber(usage, "cacheWrite");
|
|
374
|
+
const hasCacheSignal = cacheRead !== undefined || cacheWrite !== undefined;
|
|
375
|
+
|
|
376
|
+
if (!hasCacheSignal && (input === undefined || !allowInputOnly)) return undefined;
|
|
377
|
+
|
|
378
|
+
// Under healthy Pi normalization input is the uncached portion, so
|
|
379
|
+
// totalInput = input + cacheRead + cacheWrite gives the full prompt token count.
|
|
380
|
+
// Guard against degenerate reads where a broken proxy omits prompt_tokens and
|
|
381
|
+
// Pi's input falls to zero: totalInput must never be less than cacheRead + cacheWrite.
|
|
382
|
+
const computed = (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0);
|
|
383
|
+
const floor = (cacheRead ?? 0) + (cacheWrite ?? 0);
|
|
384
|
+
return {
|
|
385
|
+
cacheRead: cacheRead ?? 0,
|
|
386
|
+
cacheWrite: cacheWrite ?? 0,
|
|
387
|
+
totalInput: computed >= floor ? computed : floor,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Raw fallback for DeepSeek responses that still carry their native usage fields.
|
|
392
|
+
// In practice Pi normalizes usage before message_end fires, so this path is only
|
|
393
|
+
// reached when Pi-normalized fields are absent (e.g. custom/foreign providers).
|
|
394
|
+
function getDeepSeekRawUsage(message: unknown): UsageSnapshot | undefined {
|
|
395
|
+
const usage = usageRecordFromAssistant(message);
|
|
396
|
+
if (!usage) return undefined;
|
|
397
|
+
|
|
398
|
+
const cacheRead = getFirstNonNegativeNumber(usage.prompt_cache_hit_tokens);
|
|
399
|
+
if (cacheRead === undefined) return undefined;
|
|
400
|
+
|
|
401
|
+
const cacheMiss = getFirstNonNegativeNumber(usage.prompt_cache_miss_tokens);
|
|
402
|
+
const promptTokens = getFirstNonNegativeNumber(usage.prompt_tokens);
|
|
403
|
+
// DeepSeek guarantees prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens.
|
|
404
|
+
const totalInput = promptTokens ?? cacheRead + (cacheMiss ?? 0);
|
|
405
|
+
|
|
406
|
+
return { cacheRead, cacheWrite: 0, totalInput };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Raw fallback for OpenAI-family responses that still carry their native usage fields.
|
|
410
|
+
// In practice Pi normalizes usage before message_end fires, so this path is only
|
|
411
|
+
// reached when Pi-normalized fields are absent (e.g. custom/foreign providers).
|
|
412
|
+
function getOpenAIRawUsage(message: unknown): UsageSnapshot | undefined {
|
|
413
|
+
const usage = usageRecordFromAssistant(message);
|
|
414
|
+
if (!usage) return undefined;
|
|
415
|
+
|
|
416
|
+
const promptDetails = getNestedRecord(usage, "prompt_tokens_details") ?? getNestedRecord(usage, "promptTokensDetails");
|
|
417
|
+
const inputDetails = getNestedRecord(usage, "input_tokens_details") ?? getNestedRecord(usage, "inputTokensDetails");
|
|
418
|
+
const cacheRead = readCachedTokensFromDetails(promptDetails) ?? readCachedTokensFromDetails(inputDetails);
|
|
419
|
+
if (cacheRead === undefined) return undefined;
|
|
420
|
+
|
|
421
|
+
const cacheWrite = readCacheWriteFromDetails(promptDetails) ?? readCacheWriteFromDetails(inputDetails) ?? 0;
|
|
422
|
+
const totalInput = getFirstNonNegativeNumber(
|
|
423
|
+
usage.prompt_tokens,
|
|
424
|
+
usage.promptTokens,
|
|
425
|
+
usage.input_tokens,
|
|
426
|
+
usage.inputTokens,
|
|
427
|
+
) ?? cacheRead + cacheWrite;
|
|
428
|
+
|
|
429
|
+
return { cacheRead, cacheWrite, totalInput };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Raw fallback for Anthropic/Claude responses that still carry their native usage fields.
|
|
433
|
+
// In practice Pi normalizes usage before message_end fires, so this path is only
|
|
434
|
+
// reached when Pi-normalized fields are absent (e.g. custom/foreign providers).
|
|
435
|
+
function getAnthropicRawUsage(message: unknown): UsageSnapshot | undefined {
|
|
436
|
+
const usage = usageRecordFromAssistant(message);
|
|
437
|
+
if (!usage) return undefined;
|
|
438
|
+
|
|
439
|
+
const cacheRead = getFirstNonNegativeNumber(usage.cache_read_input_tokens, usage.cacheReadInputTokens);
|
|
440
|
+
const cacheWrite = getFirstNonNegativeNumber(usage.cache_creation_input_tokens, usage.cacheCreationInputTokens);
|
|
441
|
+
if (cacheRead === undefined && cacheWrite === undefined) return undefined;
|
|
442
|
+
|
|
443
|
+
// Anthropic input_tokens = tokens after the last cache breakpoint (neither read nor written).
|
|
444
|
+
const input = getFirstNonNegativeNumber(usage.input_tokens, usage.inputTokens) ?? 0;
|
|
445
|
+
return {
|
|
446
|
+
cacheRead: cacheRead ?? 0,
|
|
447
|
+
cacheWrite: cacheWrite ?? 0,
|
|
448
|
+
totalInput: input + (cacheRead ?? 0) + (cacheWrite ?? 0),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Raw fallback for Gemini/Vertex responses that still carry their native usage fields.
|
|
453
|
+
// In practice Pi normalizes usage before message_end fires, so this path is only
|
|
454
|
+
// reached when Pi-normalized fields are absent (e.g. custom/foreign providers).
|
|
455
|
+
function getGeminiRawUsage(message: unknown): UsageSnapshot | undefined {
|
|
456
|
+
const record = getAssistantRecord(message);
|
|
457
|
+
if (!record) return undefined;
|
|
458
|
+
|
|
459
|
+
const usage = asRecord(record.usage);
|
|
460
|
+
const metadata =
|
|
461
|
+
getNestedRecord(record, "usageMetadata") ??
|
|
462
|
+
getNestedRecord(record, "usage_metadata") ??
|
|
463
|
+
getNestedRecord(usage, "usageMetadata") ??
|
|
464
|
+
getNestedRecord(usage, "usage_metadata") ??
|
|
465
|
+
usage;
|
|
466
|
+
if (!metadata) return undefined;
|
|
467
|
+
|
|
468
|
+
const cacheRead = getFirstNonNegativeNumber(
|
|
469
|
+
metadata.cachedContentTokenCount,
|
|
470
|
+
metadata.cached_content_token_count,
|
|
471
|
+
);
|
|
472
|
+
if (cacheRead === undefined) return undefined;
|
|
473
|
+
|
|
474
|
+
const totalInput = getFirstNonNegativeNumber(
|
|
475
|
+
metadata.promptTokenCount,
|
|
476
|
+
metadata.prompt_token_count,
|
|
477
|
+
metadata.inputTokenCount,
|
|
478
|
+
metadata.input_token_count,
|
|
479
|
+
usage?.input_tokens,
|
|
480
|
+
usage?.inputTokens,
|
|
481
|
+
usage?.prompt_tokens,
|
|
482
|
+
usage?.promptTokens,
|
|
483
|
+
) ?? cacheRead;
|
|
484
|
+
|
|
485
|
+
return { cacheRead, cacheWrite: 0, totalInput };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Try Pi-normalized usage first (always present for messages that went through Pi's
|
|
489
|
+
// provider pipeline). Fall back to provider-specific raw-field readers when Pi-normalized
|
|
490
|
+
// fields are absent (e.g. messages from custom/foreign providers whose raw usage shape
|
|
491
|
+
// matches the official API).
|
|
492
|
+
function normalizeWithFallback(
|
|
493
|
+
message: unknown,
|
|
494
|
+
rawNormalizer: (message: unknown) => UsageSnapshot | undefined,
|
|
495
|
+
options: { allowInputOnlyPiUsage?: boolean } = {},
|
|
496
|
+
): UsageSnapshot | undefined {
|
|
497
|
+
return getPiNormalizedUsage(message, options.allowInputOnlyPiUsage) ?? rawNormalizer(message);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function addOpenAIPromptCacheKey(payload: unknown, cacheKey: string | undefined): unknown | undefined {
|
|
501
|
+
const record = asRecord(payload);
|
|
502
|
+
if (!record || !cacheKey) return undefined;
|
|
503
|
+
|
|
504
|
+
if (hasOwn(record, "prompt_cache_key") || hasOwn(record, "promptCacheKey")) {
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return { ...record, prompt_cache_key: cacheKey };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function describeMissingDeepSeekCompat(model: PiModel): string[] {
|
|
512
|
+
const compat = getCompat(model);
|
|
513
|
+
const missing: string[] = [];
|
|
514
|
+
|
|
515
|
+
if (compat.supportsLongCacheRetention !== true) {
|
|
516
|
+
missing.push("supportsLongCacheRetention");
|
|
517
|
+
}
|
|
518
|
+
if (model.api === "openai-responses") {
|
|
519
|
+
if (compat.sendSessionIdHeader !== true) {
|
|
520
|
+
missing.push("sendSessionIdHeader");
|
|
521
|
+
}
|
|
522
|
+
} else if (compat.sendSessionAffinityHeaders !== true) {
|
|
523
|
+
missing.push("sendSessionAffinityHeaders");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return missing;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const CACHE_PROVIDER_ADAPTERS: CacheProviderAdapter[] = [
|
|
530
|
+
{
|
|
531
|
+
id: "deepseek",
|
|
532
|
+
label: "DS cache",
|
|
533
|
+
matchesModel: isDeepSeekLikeModel,
|
|
534
|
+
matchesAssistantMessage(message, model) {
|
|
535
|
+
if (!isAssistantMessage(message)) return false;
|
|
536
|
+
return isDeepSeekLikeAssistantMessage(message, model);
|
|
537
|
+
},
|
|
538
|
+
normalizeUsage(message) {
|
|
539
|
+
return normalizeWithFallback(message, getDeepSeekRawUsage, { allowInputOnlyPiUsage: true });
|
|
540
|
+
},
|
|
541
|
+
warningText(model) {
|
|
542
|
+
if (!isDeepSeekLikeModel(model) || !isOpenAICompatibleApi(model.api)) return undefined;
|
|
543
|
+
|
|
544
|
+
const missing = describeMissingDeepSeekCompat(model);
|
|
545
|
+
if (missing.length === 0) return undefined;
|
|
546
|
+
|
|
547
|
+
const key = modelKey(model);
|
|
548
|
+
return (
|
|
549
|
+
`💡 pi-cache-optimizer: ${key} is DeepSeek-like but merged compat lacks ${missing.join(" and ")}. ` +
|
|
550
|
+
"Proxies may reduce or hide cache hits; add these compat flags in ~/.pi/agent/models.json when the endpoint supports them."
|
|
551
|
+
);
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
id: "claude",
|
|
556
|
+
label: "Claude cache",
|
|
557
|
+
showCacheWrite: true,
|
|
558
|
+
matchesModel: isClaudeLikeModel,
|
|
559
|
+
matchesAssistantMessage(message, model) {
|
|
560
|
+
if (!isAssistantMessage(message)) return false;
|
|
561
|
+
return isClaudeLikeAssistantMessage(message, model);
|
|
562
|
+
},
|
|
563
|
+
normalizeUsage(message) {
|
|
564
|
+
return normalizeWithFallback(message, getAnthropicRawUsage);
|
|
565
|
+
},
|
|
566
|
+
warningText(model) {
|
|
567
|
+
if (!isClaudeLikeModel(model) || !isOpenAICompatibleApi(model.api)) return undefined;
|
|
568
|
+
if (getCompat(model).cacheControlFormat === "anthropic") return undefined;
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
`💡 Cache optimizer: ${modelKey(model)} looks Claude/Anthropic-like but OpenAI-compatible compat lacks cacheControlFormat: "anthropic". ` +
|
|
572
|
+
"Pi may not place Anthropic cache_control breakpoints unless this endpoint supports and enables that compat flag."
|
|
573
|
+
);
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
id: "openai",
|
|
578
|
+
label: "OpenAI cache",
|
|
579
|
+
matchesModel: isOpenAIFamilyModel,
|
|
580
|
+
matchesAssistantMessage(message, model) {
|
|
581
|
+
if (!isAssistantMessage(message)) return false;
|
|
582
|
+
return isOpenAIFamilyAssistantMessage(message, model);
|
|
583
|
+
},
|
|
584
|
+
normalizeUsage(message) {
|
|
585
|
+
return normalizeWithFallback(message, getOpenAIRawUsage);
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
id: "gemini",
|
|
590
|
+
label: "Gemini cache",
|
|
591
|
+
matchesModel: isGeminiLikeModel,
|
|
592
|
+
matchesAssistantMessage(message, model) {
|
|
593
|
+
if (!isAssistantMessage(message)) return false;
|
|
594
|
+
return isGeminiLikeAssistantMessage(message, model);
|
|
595
|
+
},
|
|
596
|
+
normalizeUsage(message) {
|
|
597
|
+
return normalizeWithFallback(message, getGeminiRawUsage);
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
];
|
|
601
|
+
|
|
602
|
+
function selectAdapterForModel(model: PiModel | undefined): CacheProviderAdapter | undefined {
|
|
603
|
+
return CACHE_PROVIDER_ADAPTERS.find((adapter) => adapter.matchesModel(model));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function selectAdapterForAssistantMessage(message: unknown, model: PiModel | undefined): CacheProviderAdapter | undefined {
|
|
607
|
+
return CACHE_PROVIDER_ADAPTERS.find((adapter) => adapter.matchesAssistantMessage(message, model));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function notifyCacheCompatIfNeeded(
|
|
611
|
+
model: PiModel | undefined,
|
|
612
|
+
ctx: ExtensionContext,
|
|
613
|
+
warnedModels: Set<string>,
|
|
614
|
+
): void {
|
|
615
|
+
if (!model) return;
|
|
616
|
+
|
|
617
|
+
const adapter = selectAdapterForModel(model);
|
|
618
|
+
const text = adapter?.warningText?.(model);
|
|
619
|
+
if (!adapter || !text) return;
|
|
620
|
+
|
|
621
|
+
const key = `${adapter.id}:${modelKey(model)}`;
|
|
622
|
+
if (warnedModels.has(key)) return;
|
|
623
|
+
warnedModels.add(key);
|
|
624
|
+
|
|
625
|
+
ctx.ui.notify(text, "warning");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function currentLocalDay(): string {
|
|
629
|
+
const now = new Date();
|
|
630
|
+
const year = now.getFullYear();
|
|
631
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
632
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
633
|
+
return `${year}-${month}-${day}`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function emptyCacheStats(day = currentLocalDay()): CacheStats {
|
|
637
|
+
return {
|
|
638
|
+
day,
|
|
639
|
+
totalRequests: 0,
|
|
640
|
+
hitRequests: 0,
|
|
641
|
+
cachedInputTokens: 0,
|
|
642
|
+
cacheWriteInputTokens: 0,
|
|
643
|
+
totalInputTokens: 0,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function emptyAllCacheStats(day = currentLocalDay()): Partial<Record<CacheProviderId, CacheStats>> {
|
|
648
|
+
return Object.fromEntries(CACHE_PROVIDER_IDS.map((id) => [id, emptyCacheStats(day)])) as Partial<Record<CacheProviderId, CacheStats>>;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function addUsageToCacheStats(stats: CacheStats, usage: UsageSnapshot): void {
|
|
652
|
+
stats.totalRequests += 1;
|
|
653
|
+
if (usage.cacheRead > 0) stats.hitRequests += 1;
|
|
654
|
+
stats.cachedInputTokens += usage.cacheRead;
|
|
655
|
+
stats.cacheWriteInputTokens += usage.cacheWrite;
|
|
656
|
+
stats.totalInputTokens += usage.totalInput;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function formatTokenCount(value: number): string {
|
|
660
|
+
const millions = Math.max(0, Math.round(value)) / 1_000_000;
|
|
661
|
+
if (millions === 0) return "0M";
|
|
662
|
+
if (millions < 0.001) return `${millions.toFixed(4)}M`;
|
|
663
|
+
if (millions < 0.01) return `${millions.toFixed(3)}M`;
|
|
664
|
+
if (millions >= 10) return `${millions.toFixed(1)}M`;
|
|
665
|
+
return `${millions.toFixed(2)}M`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function formatCacheStats(adapter: CacheProviderAdapter, stats: CacheStats): string {
|
|
669
|
+
const percent = stats.totalInputTokens > 0
|
|
670
|
+
? ` (${Math.round((stats.cachedInputTokens / stats.totalInputTokens) * 100)}%)`
|
|
671
|
+
: "";
|
|
672
|
+
const writeText = adapter.showCacheWrite && stats.cacheWriteInputTokens > 0
|
|
673
|
+
? ` · write ${formatTokenCount(stats.cacheWriteInputTokens)} tok`
|
|
674
|
+
: "";
|
|
675
|
+
|
|
676
|
+
return `${adapter.label} ${stats.hitRequests}/${stats.totalRequests} · ${formatTokenCount(stats.cachedInputTokens)}/${formatTokenCount(stats.totalInputTokens)} tok${percent}${writeText}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function getErrorCode(error: unknown): string | undefined {
|
|
680
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
681
|
+
? String((error as { code?: unknown }).code)
|
|
682
|
+
: undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function parseCacheStats(value: unknown): CacheStats | undefined {
|
|
686
|
+
const stats = asRecord(value);
|
|
687
|
+
if (!stats || typeof stats.day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(stats.day)) {
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const totalRequests = getNonNegativeNumber(stats, "totalRequests");
|
|
692
|
+
const hitRequests = getNonNegativeNumber(stats, "hitRequests");
|
|
693
|
+
const cachedInputTokens = getNonNegativeNumber(stats, "cachedInputTokens");
|
|
694
|
+
const cacheWriteInputTokens = getNonNegativeNumber(stats, "cacheWriteInputTokens") ?? 0;
|
|
695
|
+
const totalInputTokens = getNonNegativeNumber(stats, "totalInputTokens");
|
|
696
|
+
|
|
697
|
+
if (
|
|
698
|
+
totalRequests === undefined ||
|
|
699
|
+
hitRequests === undefined ||
|
|
700
|
+
cachedInputTokens === undefined ||
|
|
701
|
+
totalInputTokens === undefined ||
|
|
702
|
+
hitRequests > totalRequests ||
|
|
703
|
+
cachedInputTokens > totalInputTokens ||
|
|
704
|
+
cacheWriteInputTokens > totalInputTokens
|
|
705
|
+
) {
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
day: stats.day,
|
|
711
|
+
totalRequests,
|
|
712
|
+
hitRequests,
|
|
713
|
+
cachedInputTokens,
|
|
714
|
+
cacheWriteInputTokens,
|
|
715
|
+
totalInputTokens,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function parsePersistedCacheStats(value: unknown): Partial<Record<CacheProviderId, CacheStats>> | undefined {
|
|
720
|
+
const record = asRecord(value);
|
|
721
|
+
if (!record) return undefined;
|
|
722
|
+
|
|
723
|
+
if (record.version === 1) {
|
|
724
|
+
const migrated = parseCacheStats(record.stats);
|
|
725
|
+
return migrated ? { deepseek: migrated } : undefined;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (record.version !== 2) return undefined;
|
|
729
|
+
|
|
730
|
+
const statsByProvider = asRecord(record.statsByProvider);
|
|
731
|
+
if (!statsByProvider) return undefined;
|
|
732
|
+
|
|
733
|
+
const parsed: Partial<Record<CacheProviderId, CacheStats>> = {};
|
|
734
|
+
for (const id of CACHE_PROVIDER_IDS) {
|
|
735
|
+
const stats = parseCacheStats(statsByProvider[id]);
|
|
736
|
+
if (stats) parsed[id] = stats;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return parsed;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function readPersistedCacheStats(): Promise<Partial<Record<CacheProviderId, CacheStats>> | undefined> {
|
|
743
|
+
try {
|
|
744
|
+
const raw = await readFile(STATE_FILE_PATH, "utf8");
|
|
745
|
+
return parsePersistedCacheStats(JSON.parse(raw));
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (getErrorCode(error) !== "ENOENT") {
|
|
748
|
+
console.warn(`${LOG_PREFIX}: failed to read persisted cache stats`, error);
|
|
749
|
+
return undefined;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// New path missing: try one-shot migration from the old (pre-rename) path.
|
|
754
|
+
try {
|
|
755
|
+
const raw = await readFile(LEGACY_STATE_FILE_PATH, "utf8");
|
|
756
|
+
const parsed = parsePersistedCacheStats(JSON.parse(raw));
|
|
757
|
+
if (parsed) {
|
|
758
|
+
try {
|
|
759
|
+
await writePersistedCacheStats(parsed);
|
|
760
|
+
// Best-effort delete; if the unlink fails the new path is still authoritative.
|
|
761
|
+
try {
|
|
762
|
+
await unlink(LEGACY_STATE_FILE_PATH);
|
|
763
|
+
} catch (unlinkError) {
|
|
764
|
+
if (getErrorCode(unlinkError) !== "ENOENT") {
|
|
765
|
+
console.warn(`${LOG_PREFIX}: failed to remove legacy stats file`, unlinkError);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
} catch (writeError) {
|
|
769
|
+
console.warn(`${LOG_PREFIX}: failed to migrate legacy cache stats`, writeError);
|
|
770
|
+
}
|
|
771
|
+
return parsed;
|
|
772
|
+
}
|
|
773
|
+
} catch (error) {
|
|
774
|
+
if (getErrorCode(error) !== "ENOENT") {
|
|
775
|
+
console.warn(`${LOG_PREFIX}: failed to read legacy cache stats`, error);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function writePersistedCacheStats(statsByProvider: Partial<Record<CacheProviderId, CacheStats>>): Promise<void> {
|
|
783
|
+
await mkdir(STATE_DIR, { recursive: true });
|
|
784
|
+
const payload: PersistedCacheStatsV2 = { version: 2, statsByProvider };
|
|
785
|
+
const tempPath = `${STATE_FILE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
786
|
+
|
|
787
|
+
await writeFile(tempPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
788
|
+
await rename(tempPath, STATE_FILE_PATH);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ============================================================
|
|
792
|
+
// models.json auto-config (DeepSeek seed)
|
|
793
|
+
// ============================================================
|
|
794
|
+
|
|
795
|
+
type ModelsJsonShape = {
|
|
796
|
+
providers?: UnknownRecord;
|
|
797
|
+
} & UnknownRecord;
|
|
798
|
+
|
|
799
|
+
const DEEPSEEK_SEED_PROVIDER = {
|
|
800
|
+
baseUrl: "https://api.deepseek.com",
|
|
801
|
+
api: "openai-completions",
|
|
802
|
+
apiKey: "$DEEPSEEK_API_KEY",
|
|
803
|
+
models: [
|
|
804
|
+
{
|
|
805
|
+
id: "deepseek-v4-pro",
|
|
806
|
+
name: "DeepSeek V4 Pro",
|
|
807
|
+
contextWindow: 1_000_000,
|
|
808
|
+
maxTokens: 384_000,
|
|
809
|
+
input: ["text"],
|
|
810
|
+
reasoning: true,
|
|
811
|
+
cost: { input: 1.74, output: 3.48, cacheRead: 0.145, cacheWrite: 0 },
|
|
812
|
+
compat: {
|
|
813
|
+
requiresReasoningContentOnAssistantMessages: true,
|
|
814
|
+
thinkingFormat: "deepseek",
|
|
815
|
+
supportsLongCacheRetention: true,
|
|
816
|
+
sendSessionAffinityHeaders: true,
|
|
817
|
+
reasoningEffortMap: {
|
|
818
|
+
minimal: "high",
|
|
819
|
+
low: "high",
|
|
820
|
+
medium: "high",
|
|
821
|
+
high: "high",
|
|
822
|
+
xhigh: "max",
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
id: "deepseek-v4-flash",
|
|
828
|
+
name: "DeepSeek V4 Flash",
|
|
829
|
+
contextWindow: 1_000_000,
|
|
830
|
+
maxTokens: 384_000,
|
|
831
|
+
input: ["text"],
|
|
832
|
+
reasoning: true,
|
|
833
|
+
cost: { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 },
|
|
834
|
+
compat: {
|
|
835
|
+
requiresReasoningContentOnAssistantMessages: true,
|
|
836
|
+
thinkingFormat: "deepseek",
|
|
837
|
+
supportsLongCacheRetention: true,
|
|
838
|
+
sendSessionAffinityHeaders: true,
|
|
839
|
+
reasoningEffortMap: {
|
|
840
|
+
minimal: "high",
|
|
841
|
+
low: "high",
|
|
842
|
+
medium: "high",
|
|
843
|
+
high: "high",
|
|
844
|
+
xhigh: "max",
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
],
|
|
849
|
+
} as const;
|
|
850
|
+
|
|
851
|
+
function modelsJsonContainsDeepseek(parsed: ModelsJsonShape): boolean {
|
|
852
|
+
const providers = asRecord(parsed.providers);
|
|
853
|
+
if (!providers) return false;
|
|
854
|
+
|
|
855
|
+
// Respect user intent: a provider key literally named "deepseek" (case-insensitive)
|
|
856
|
+
// means the user already declared their own DeepSeek block, even if its models list is empty.
|
|
857
|
+
for (const key of Object.keys(providers)) {
|
|
858
|
+
if (key.toLowerCase() === "deepseek") return true;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
for (const providerValue of Object.values(providers)) {
|
|
862
|
+
const provider = asRecord(providerValue);
|
|
863
|
+
if (!provider) continue;
|
|
864
|
+
const models = provider.models;
|
|
865
|
+
if (!Array.isArray(models)) continue;
|
|
866
|
+
for (const model of models) {
|
|
867
|
+
const record = asRecord(model);
|
|
868
|
+
if (!record) continue;
|
|
869
|
+
if (lower(record.id).includes("deepseek") || lower(record.name).includes("deepseek")) {
|
|
870
|
+
return true;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
type EnsureDeepseekResult = {
|
|
879
|
+
// Whether some DeepSeek-like model is now present in models.json (either pre-existing or just-seeded).
|
|
880
|
+
deepseekPresent: boolean;
|
|
881
|
+
// Whether we just wrote the seed in this activation.
|
|
882
|
+
seeded: boolean;
|
|
883
|
+
// Whether auto-config was deliberately skipped (env opt-out or malformed file).
|
|
884
|
+
skipped: boolean;
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
function ensureDeepseekConfigured(notify?: (text: string, level: "info" | "warning") => void): EnsureDeepseekResult {
|
|
888
|
+
const result: EnsureDeepseekResult = { deepseekPresent: false, seeded: false, skipped: false };
|
|
889
|
+
|
|
890
|
+
if (isEnabledEnv(process.env[NO_AUTO_CONFIG_ENV])) {
|
|
891
|
+
result.skipped = true;
|
|
892
|
+
// Even when opted out, callers still need to know whether DeepSeek is present so the
|
|
893
|
+
// API-key hint can fire. Read-only inspection only; no writes.
|
|
894
|
+
try {
|
|
895
|
+
const raw = readFileSync(MODELS_JSON_PATH, "utf8");
|
|
896
|
+
const parsed = JSON.parse(raw) as ModelsJsonShape;
|
|
897
|
+
if (parsed && typeof parsed === "object") {
|
|
898
|
+
result.deepseekPresent = modelsJsonContainsDeepseek(parsed);
|
|
899
|
+
}
|
|
900
|
+
} catch {
|
|
901
|
+
// ignore: missing or unreadable file means "not present"
|
|
902
|
+
}
|
|
903
|
+
return result;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
let originalBytes: string | undefined;
|
|
907
|
+
let parsed: ModelsJsonShape;
|
|
908
|
+
try {
|
|
909
|
+
originalBytes = readFileSync(MODELS_JSON_PATH, "utf8");
|
|
910
|
+
} catch (error) {
|
|
911
|
+
if (getErrorCode(error) !== "ENOENT") {
|
|
912
|
+
console.warn(`${LOG_PREFIX}: failed to read models.json; skipping auto-config`, error);
|
|
913
|
+
result.skipped = true;
|
|
914
|
+
return result;
|
|
915
|
+
}
|
|
916
|
+
parsed = { providers: {} };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (originalBytes !== undefined) {
|
|
920
|
+
try {
|
|
921
|
+
const decoded = JSON.parse(originalBytes) as unknown;
|
|
922
|
+
if (decoded && typeof decoded === "object" && !Array.isArray(decoded)) {
|
|
923
|
+
parsed = decoded as ModelsJsonShape;
|
|
924
|
+
} else {
|
|
925
|
+
// A non-object top-level JSON (array/string/number) is unexpected; treat as malformed and abort.
|
|
926
|
+
console.warn(`${LOG_PREFIX}: models.json top-level is not an object; aborting auto-config`);
|
|
927
|
+
result.skipped = true;
|
|
928
|
+
return result;
|
|
929
|
+
}
|
|
930
|
+
} catch (error) {
|
|
931
|
+
// Malformed JSON: do NOT overwrite the user's file.
|
|
932
|
+
console.warn(`${LOG_PREFIX}: models.json is not valid JSON; aborting auto-config`, error);
|
|
933
|
+
result.skipped = true;
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
parsed = { providers: {} };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (modelsJsonContainsDeepseek(parsed)) {
|
|
941
|
+
result.deepseekPresent = true;
|
|
942
|
+
return result;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Decide we will seed. Snapshot the old bytes (or empty marker) into a backup before mutating.
|
|
946
|
+
const backupPath = `${MODELS_JSON_PATH}.bak.${Date.now()}`;
|
|
947
|
+
try {
|
|
948
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
949
|
+
writeFileSync(backupPath, originalBytes ?? "", "utf8");
|
|
950
|
+
} catch (error) {
|
|
951
|
+
console.warn(`${LOG_PREFIX}: failed to write models.json backup; aborting auto-config`, error);
|
|
952
|
+
result.skipped = true;
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const providersIn = asRecord(parsed.providers) ?? {};
|
|
957
|
+
const merged: ModelsJsonShape = {
|
|
958
|
+
...parsed,
|
|
959
|
+
providers: { ...providersIn, deepseek: DEEPSEEK_SEED_PROVIDER },
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
const tempPath = `${MODELS_JSON_PATH}.tmp.${process.pid}`;
|
|
963
|
+
try {
|
|
964
|
+
writeFileSync(tempPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
965
|
+
} catch (error) {
|
|
966
|
+
console.warn(`${LOG_PREFIX}: failed to write models.json temp file; aborting auto-config`, error);
|
|
967
|
+
result.skipped = true;
|
|
968
|
+
return result;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
try {
|
|
972
|
+
renameSync(tempPath, MODELS_JSON_PATH);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
console.warn(
|
|
975
|
+
`${LOG_PREFIX}: failed to atomically rename models.json (temp left at ${tempPath})`,
|
|
976
|
+
error,
|
|
977
|
+
);
|
|
978
|
+
result.skipped = true;
|
|
979
|
+
return result;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
result.seeded = true;
|
|
983
|
+
result.deepseekPresent = true;
|
|
984
|
+
notify?.(
|
|
985
|
+
`${LOG_PREFIX}: seeded DeepSeek provider into ${MODELS_JSON_PATH} (backup at ${backupPath}). ` +
|
|
986
|
+
`Set ${DEEPSEEK_API_KEY_ENV} to use it; or set ${NO_AUTO_CONFIG_ENV}=1 next time to opt out.`,
|
|
987
|
+
"info",
|
|
988
|
+
);
|
|
989
|
+
return result;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function emitDeepseekApiKeyHintIfNeeded(
|
|
993
|
+
deepseekPresent: boolean,
|
|
994
|
+
notify: (text: string, level: "info" | "warning") => void,
|
|
995
|
+
): void {
|
|
996
|
+
if (!deepseekPresent) return;
|
|
997
|
+
const value = process.env[DEEPSEEK_API_KEY_ENV];
|
|
998
|
+
if (typeof value === "string" && value.trim().length > 0) return;
|
|
999
|
+
|
|
1000
|
+
notify(
|
|
1001
|
+
`${LOG_PREFIX}: ${DEEPSEEK_API_KEY_ENV} is not set. ` +
|
|
1002
|
+
`DeepSeek models in ${MODELS_JSON_PATH} reference $${DEEPSEEK_API_KEY_ENV}; ` +
|
|
1003
|
+
`export ${DEEPSEEK_API_KEY_ENV}=... in your shell to enable them.`,
|
|
1004
|
+
"info",
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export default function (pi: ExtensionAPI) {
|
|
1009
|
+
const warnedModels = new Set<string>();
|
|
1010
|
+
let cacheStatsByProvider: Partial<Record<CacheProviderId, CacheStats>> = emptyAllCacheStats();
|
|
1011
|
+
let lastStatusText: string | undefined;
|
|
1012
|
+
let latestPromptCacheKey: string | undefined;
|
|
1013
|
+
let persistenceWarningShown = false;
|
|
1014
|
+
let apiKeyHintShown = false;
|
|
1015
|
+
|
|
1016
|
+
// Auto-config runs once at extension activation (idempotent: skips if DeepSeek already configured).
|
|
1017
|
+
// Pi's UI logger is not yet bound here, so seed-time notifications go through console.warn / console.info.
|
|
1018
|
+
// Per-session UI notification is emitted from the session_start hook below.
|
|
1019
|
+
let autoConfig: EnsureDeepseekResult;
|
|
1020
|
+
try {
|
|
1021
|
+
autoConfig = ensureDeepseekConfigured((text, level) => {
|
|
1022
|
+
if (level === "warning") console.warn(text);
|
|
1023
|
+
else console.info(text);
|
|
1024
|
+
});
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
console.warn(`${LOG_PREFIX}: ensureDeepseekConfigured threw; continuing without auto-config`, error);
|
|
1027
|
+
autoConfig = { deepseekPresent: false, seeded: false, skipped: true };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function getStatsForAdapter(adapter: CacheProviderAdapter): CacheStats {
|
|
1031
|
+
const existing = cacheStatsByProvider[adapter.id];
|
|
1032
|
+
if (existing) return existing;
|
|
1033
|
+
|
|
1034
|
+
const created = emptyCacheStats();
|
|
1035
|
+
cacheStatsByProvider[adapter.id] = created;
|
|
1036
|
+
return created;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function persistCacheStats(ctx?: ExtensionContext): Promise<void> {
|
|
1040
|
+
try {
|
|
1041
|
+
await writePersistedCacheStats(cacheStatsByProvider);
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
console.warn(`${LOG_PREFIX}: failed to persist cache stats`, error);
|
|
1044
|
+
if (!persistenceWarningShown) {
|
|
1045
|
+
persistenceWarningShown = true;
|
|
1046
|
+
ctx?.ui.notify(
|
|
1047
|
+
`${LOG_PREFIX}: failed to persist footer stats; using in-memory stats for this process.`,
|
|
1048
|
+
"warning",
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function rollOverStatsIfNeeded(ctx?: ExtensionContext): Promise<void> {
|
|
1055
|
+
const day = currentLocalDay();
|
|
1056
|
+
let changed = false;
|
|
1057
|
+
|
|
1058
|
+
for (const id of CACHE_PROVIDER_IDS) {
|
|
1059
|
+
const stats = cacheStatsByProvider[id];
|
|
1060
|
+
if (stats && stats.day !== day) {
|
|
1061
|
+
cacheStatsByProvider[id] = emptyCacheStats(day);
|
|
1062
|
+
changed = true;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (changed) {
|
|
1067
|
+
lastStatusText = undefined;
|
|
1068
|
+
await persistCacheStats(ctx);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function restoreCacheStats(reason: string, ctx: ExtensionContext): Promise<void> {
|
|
1073
|
+
if (reason === "reload") {
|
|
1074
|
+
cacheStatsByProvider = emptyAllCacheStats();
|
|
1075
|
+
lastStatusText = undefined;
|
|
1076
|
+
await persistCacheStats(ctx);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
cacheStatsByProvider = (await readPersistedCacheStats()) ?? emptyAllCacheStats();
|
|
1081
|
+
lastStatusText = undefined;
|
|
1082
|
+
await rollOverStatsIfNeeded(ctx);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async function publishStatus(ctx: ExtensionContext, model: PiModel | undefined = ctx.model): Promise<void> {
|
|
1086
|
+
await rollOverStatsIfNeeded(ctx);
|
|
1087
|
+
|
|
1088
|
+
const adapter = selectAdapterForModel(model);
|
|
1089
|
+
const statusText = adapter ? formatCacheStats(adapter, getStatsForAdapter(adapter)) : undefined;
|
|
1090
|
+
if (statusText === lastStatusText) return;
|
|
1091
|
+
|
|
1092
|
+
lastStatusText = statusText;
|
|
1093
|
+
ctx.ui.setStatus(STATUS_KEY, statusText);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
pi.on("session_start", async (event, ctx) => {
|
|
1097
|
+
await restoreCacheStats(event.reason, ctx);
|
|
1098
|
+
notifyCacheCompatIfNeeded(ctx.model, ctx, warnedModels);
|
|
1099
|
+
if (!apiKeyHintShown) {
|
|
1100
|
+
apiKeyHintShown = true;
|
|
1101
|
+
emitDeepseekApiKeyHintIfNeeded(autoConfig.deepseekPresent, (text, level) => {
|
|
1102
|
+
ctx.ui.notify(text, level);
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
await publishStatus(ctx);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
pi.on("model_select", async (event, ctx) => {
|
|
1109
|
+
notifyCacheCompatIfNeeded(event.model, ctx, warnedModels);
|
|
1110
|
+
await publishStatus(ctx, event.model);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
1114
|
+
const optimized = optimizeSystemPrompt(event.systemPrompt, event.systemPromptOptions);
|
|
1115
|
+
latestPromptCacheKey = buildPromptCacheKey(optimized.stablePrefix);
|
|
1116
|
+
|
|
1117
|
+
if (optimized.changed && optimized.systemPrompt.trim().length > 0) {
|
|
1118
|
+
return { systemPrompt: optimized.systemPrompt };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
return {};
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
1125
|
+
if (!isEnabledEnv(process.env[OPENAI_CACHE_KEY_ENV])) return undefined;
|
|
1126
|
+
if (!isOpenAIFamilyModel(ctx.model)) return undefined;
|
|
1127
|
+
|
|
1128
|
+
return addOpenAIPromptCacheKey(event.payload, latestPromptCacheKey);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
pi.on("message_end", async (event, ctx) => {
|
|
1132
|
+
const adapter = selectAdapterForAssistantMessage(event.message, ctx.model);
|
|
1133
|
+
if (!adapter) return;
|
|
1134
|
+
|
|
1135
|
+
const usage = adapter.normalizeUsage(event.message);
|
|
1136
|
+
if (!usage) return;
|
|
1137
|
+
|
|
1138
|
+
await rollOverStatsIfNeeded(ctx);
|
|
1139
|
+
addUsageToCacheStats(getStatsForAdapter(adapter), usage);
|
|
1140
|
+
await persistCacheStats(ctx);
|
|
1141
|
+
await publishStatus(ctx);
|
|
1142
|
+
});
|
|
1143
|
+
}
|