pi-cache-optimizer 2.8.15 → 2.8.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/command.ts ADDED
@@ -0,0 +1,1012 @@
1
+ import { type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { readFile, rename } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { selectAdapterForModel } from "./adapters.ts";
5
+ import { backupTimestamp, hashText } from "./atomic-fs.ts";
6
+ import { FEATURE_COMMAND_MAP } from "./command-completion.ts";
7
+ import { type PiModel, asRecord } from "./common.ts";
8
+ import { describeMissingCacheCompatForModel, getModelsJsonDisplayPath, isAdaptiveThinkingCompatApplicable } from "./compat-advice.ts";
9
+ import { resolveEffectiveCompatFromConfig } from "./compat-config.ts";
10
+ import { FOOTER_MODE_ENV, type FooterStatsMode, formatOptimizerRuntimeMode, formatPersistentFeatureConfig, persistedFooterStatsMode, readPersistedCacheOptimizerConfig, resetPersistedFeatures, resolveFooterStatsMode, setPersistedCacheOptimizerConfig, setRuntimeOptimizerEnabled, writePersistedFeature, writePersistedFooterMode } from "./config.ts";
11
+ import { buildCompatDiagnosis, buildDoctorDiagnosis, buildLowHitDiagnosis, getCompatCheckNotApplicableLines, isCompatCheckApplicable, isDeepSeekCompatCheckApplicable, isPromptCacheKeyUnsupportedApplicable } from "./diagnostics.ts";
12
+ import { locateModelOverrideInJsonc, parseJsonc } from "./jsonc.ts";
13
+ import { invalidateModelsConfigCache, isValidModelsConfigForEffectiveCompat, modelKey } from "./model-identity.ts";
14
+ import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeModelOverrideInsertion, composeProviderAffinityInsertion, createModelsJsonFixReceipt, formatCompatKeysForInsertion, formatMissingEntryManualSnippet, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./models-json-fix.ts";
15
+ import { MODELS_JSON_PATH } from "./paths.ts";
16
+ import { applyPromptCacheKeyConfigFix, isActionablePromptCacheKeyConfigReceipt, readPromptCacheKeyConfigReceiptSnapshot, rollbackPromptCacheKeyConfig } from "./prompt-cache-key-config.ts";
17
+ import { getEffectiveCompatValueSource, isPromptCacheKeyOmittedForModel } from "./request-payload.ts";
18
+ import { describeNativeVirtualRouteNote, resolveRouteModel } from "./routing.ts";
19
+ import { buildAllStatsOutput, buildContributorsStatsOutput, buildSessionStatsOutput } from "./stats-report.ts";
20
+ import { type FixSuggestion } from "./fix-types.ts";
21
+ import { type ProviderRequestState } from "./request-state.ts";
22
+ import { type CacheStats, type ShardAggregate } from "./stats-store.ts";
23
+ import { type CacheUsageSample } from "./stats-report.ts";
24
+ import { describeSkillCompressionOutcome } from "./prompt-rewrite.ts";
25
+
26
+ /**
27
+ * Everything the command needs from the extension instance. The extension keeps this state in closures
28
+ * (it is shared with the event hooks), so values that are reassigned there are exposed through getters.
29
+ */
30
+ export type CommandRuntime = {
31
+ syncSessionHash(ctx: Pick<ExtensionContext, "sessionManager">): void;
32
+ resetCurrentSessionStats(): Promise<void>;
33
+ resetStatsForModel(model: PiModel): Promise<void>;
34
+ flushPersistCacheStats(ctx?: ExtensionContext, lifecycleState?: "active" | "closed"): Promise<void>;
35
+ publishStatus(ctx: ExtensionContext, model?: PiModel): Promise<void>;
36
+ refreshShardAggregate(): Promise<ShardAggregate>;
37
+ sessionModelKey(model: { provider: string; id: string }): string;
38
+ getRecentSamples(modelKeyStr: string): CacheUsageSample[];
39
+ promptCacheKeyFixApplies(model: PiModel): boolean;
40
+ buildCommandFixSuggestion(model: PiModel): FixSuggestion | undefined;
41
+ buildPromptCacheKeyConfigPreview(model: PiModel): string[];
42
+ providerRequestStates: ProviderRequestState[];
43
+ promptCacheRetention400Models: Set<string>;
44
+ promptCacheKeyRejectedModels: Set<string>;
45
+ anthropicTtlOrderErrorModels: Set<string>;
46
+ sendSessionAffinityHeaders403Models: Set<string>;
47
+ openAISdkHeader403Models: Set<string>;
48
+ getCacheStatsTotalsByModel(): Record<string, CacheStats>;
49
+ getCurrentSessionHashSet(): boolean;
50
+ getCurrentSessionHash(): string;
51
+ getLastStatusText(): string | undefined;
52
+ clearLastStatusText(): void;
53
+ };
54
+
55
+ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
56
+ const {
57
+ anthropicTtlOrderErrorModels,
58
+ buildCommandFixSuggestion,
59
+ buildPromptCacheKeyConfigPreview,
60
+ flushPersistCacheStats,
61
+ getRecentSamples,
62
+ openAISdkHeader403Models,
63
+ promptCacheKeyFixApplies,
64
+ promptCacheKeyRejectedModels,
65
+ promptCacheRetention400Models,
66
+ providerRequestStates,
67
+ publishStatus,
68
+ refreshShardAggregate,
69
+ resetCurrentSessionStats,
70
+ resetStatsForModel,
71
+ sendSessionAffinityHeaders403Models,
72
+ sessionModelKey,
73
+ syncSessionHash,
74
+ } = runtime;
75
+ const { getCacheStatsTotalsByModel, getCurrentSessionHashSet, getCurrentSessionHash, getLastStatusText, clearLastStatusText } = runtime;
76
+
77
+ return async function handleCacheOptimizerCommand(
78
+ args: string,
79
+ cmdCtx: ExtensionCommandContext,
80
+ ): Promise<void> {
81
+ syncSessionHash(cmdCtx);
82
+ const selectedModel = cmdCtx.model;
83
+ const model = resolveRouteModel(selectedModel, cmdCtx) ?? selectedModel;
84
+ const commandParts = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
85
+ const subcommand = commandParts[0] || "help";
86
+
87
+ if (subcommand === "enable") {
88
+ setRuntimeOptimizerEnabled(true);
89
+ await resetCurrentSessionStats();
90
+ await flushPersistCacheStats(cmdCtx);
91
+ await publishStatus(cmdCtx, model);
92
+ cmdCtx.ui.notify(`✅ Pi Cache Optimizer enabled for this Pi process. Local footer stats were reset for before/after comparison.\n${formatOptimizerRuntimeMode()}`, "info");
93
+ } else if (subcommand === "disable") {
94
+ setRuntimeOptimizerEnabled(false);
95
+ providerRequestStates.length = 0;
96
+ await resetCurrentSessionStats();
97
+ await flushPersistCacheStats(cmdCtx);
98
+ await publishStatus(cmdCtx, model);
99
+ cmdCtx.ui.notify(`⏸️ Pi Cache Optimizer disabled for this Pi process. Local footer stats were reset and will keep collecting while disabled for comparison.\n${formatOptimizerRuntimeMode()}`, "warning");
100
+ } else if (subcommand === "doctor") {
101
+ await refreshShardAggregate();
102
+ if (!model) {
103
+ cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
104
+ return;
105
+ }
106
+ const diagnosis = buildDoctorDiagnosis(model, { promptCacheRetention400: promptCacheRetention400Models.has(modelKey(model)), promptCacheKey400: promptCacheKeyRejectedModels.has(modelKey(model)), anthropicTtlOrderError: anthropicTtlOrderErrorModels.has(modelKey(model)), sessionAffinity403: sendSessionAffinityHeaders403Models.has(modelKey(model)), openAISdkHeader403: openAISdkHeader403Models.has(modelKey(model)) });
107
+ const adapter = selectAdapterForModel(model);
108
+ const sk = model ? sessionModelKey(model) : undefined;
109
+ const statsState = model ? getCacheStatsTotalsByModel()[modelKey(model)] : undefined;
110
+ const samples = sk ? getRecentSamples(sk) : [];
111
+ const lowHitLines = buildLowHitDiagnosis(model, adapter, statsState, samples);
112
+ const routeNote = describeNativeVirtualRouteNote(selectedModel, model);
113
+ const fullDiagnosis = [routeNote, diagnosis, describeSkillCompressionOutcome(), ...lowHitLines].filter((line) => line !== undefined).join("\n");
114
+ cmdCtx.ui.notify(fullDiagnosis, "info");
115
+ } else if (subcommand === "stats") {
116
+ const aggregate = await refreshShardAggregate();
117
+ const statsMode = commandParts[1];
118
+ if (statsMode === "all") {
119
+ cmdCtx.ui.notify(buildAllStatsOutput(aggregate), "info");
120
+ } else if (statsMode === "contributors") {
121
+ cmdCtx.ui.notify(buildContributorsStatsOutput(aggregate, model, getCurrentSessionHashSet() ? getCurrentSessionHash() : undefined), "info");
122
+ } else if (statsMode) {
123
+ cmdCtx.ui.notify("Usage: /cache-optimizer stats [all|contributors]", "info");
124
+ } else {
125
+ const sessionModels = getCurrentSessionHashSet() ? aggregate.bySession[getCurrentSessionHash()] ?? {} : {};
126
+ cmdCtx.ui.notify(buildSessionStatsOutput(sessionModels, model, aggregate.modelRefsByKey), "info");
127
+ }
128
+ } else if (subcommand === "config") {
129
+ const configKey = commandParts[1];
130
+ const requestedMode = commandParts[2];
131
+ const feature = configKey ? FEATURE_COMMAND_MAP[configKey] : undefined;
132
+ if (feature && (requestedMode === "on" || requestedMode === "off")) {
133
+ try {
134
+ await writePersistedFeature(feature, requestedMode === "on");
135
+ clearLastStatusText();
136
+ await publishStatus(cmdCtx, model);
137
+ cmdCtx.ui.notify(`✅ ${configKey} set to ${requestedMode}. Persistent config overrides its environment variable.`, "info");
138
+ } catch (error) {
139
+ cmdCtx.ui.notify(`❌ Could not update ${configKey}: ${error instanceof Error ? error.message : String(error)}`, "error");
140
+ }
141
+ return;
142
+ }
143
+ if (configKey === "reset") {
144
+ try {
145
+ await resetPersistedFeatures();
146
+ clearLastStatusText();
147
+ await publishStatus(cmdCtx, model);
148
+ cmdCtx.ui.notify("✅ Feature configuration reset. Environment variables now apply again.", "info");
149
+ } catch (error) {
150
+ cmdCtx.ui.notify(`❌ Could not reset feature configuration: ${error instanceof Error ? error.message : String(error)}`, "error");
151
+ }
152
+ return;
153
+ }
154
+ if (!configKey) {
155
+ cmdCtx.ui.notify(formatPersistentFeatureConfig() + `\n• Footer mode: ${resolveFooterStatsMode(persistedFooterStatsMode).mode}` + "\n\n" +
156
+ "Usage: /cache-optimizer config <feature> on|off | footer-mode total|session|process | reset", "info");
157
+ return;
158
+ }
159
+ if (configKey !== "footer-mode" || !requestedMode || !["session", "total", "process"].includes(requestedMode)) {
160
+ const resolved = resolveFooterStatsMode(persistedFooterStatsMode);
161
+ cmdCtx.ui.notify(
162
+ `Usage: /cache-optimizer config footer-mode total|session|process\n` +
163
+ ` /cache-optimizer config prompt-rewrite|virtual-rewrite|skill-compression|openai-cache-key|tool-order on|off\n` +
164
+ ` /cache-optimizer config reset\n` +
165
+ `Current footer mode: ${resolved.mode} (${resolved.source})`,
166
+ "info",
167
+ );
168
+ return;
169
+ }
170
+
171
+ const nextMode = requestedMode as FooterStatsMode;
172
+ try {
173
+ await writePersistedFooterMode(nextMode);
174
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
175
+ clearLastStatusText();
176
+ await publishStatus(cmdCtx, model);
177
+ const resolved = resolveFooterStatsMode(persistedFooterStatsMode);
178
+ cmdCtx.ui.notify(
179
+ `✅ Footer mode set to ${resolved.mode}. Persistent config overrides ${FOOTER_MODE_ENV}.`,
180
+ "info",
181
+ );
182
+ } catch (error) {
183
+ cmdCtx.ui.notify(
184
+ `❌ Could not update footer mode config: ${error instanceof Error ? error.message : String(error)}`,
185
+ "error",
186
+ );
187
+ }
188
+ } else if (subcommand === "compat") {
189
+ if (!model) {
190
+ cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
191
+ return;
192
+ }
193
+ const compatResult = buildCompatDiagnosis(model);
194
+ const compatRouteNote = describeNativeVirtualRouteNote(selectedModel, model);
195
+ const withRouteNote = (text: string): string => compatRouteNote ? `${compatRouteNote}\n${text}` : text;
196
+ if (compatResult) {
197
+ cmdCtx.ui.notify(withRouteNote(compatResult), "warning");
198
+ } else {
199
+ cmdCtx.ui.notify(
200
+ withRouteNote(isAdaptiveThinkingCompatApplicable(model) || isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)
201
+ ? "✅ Compat fully configured."
202
+ : getCompatCheckNotApplicableLines(model).join("\n")),
203
+ "info",
204
+ );
205
+ }
206
+ } else if (subcommand === "rollback") {
207
+ if (!model) {
208
+ cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
209
+ return;
210
+ }
211
+ const configReceiptSnapshot = await readPromptCacheKeyConfigReceiptSnapshot();
212
+ const configReceipt = configReceiptSnapshot?.receipt;
213
+ const modelsReceipt = await readModelsJsonFixReceipt();
214
+ const useConfigReceipt = isActionablePromptCacheKeyConfigReceipt(configReceipt) &&
215
+ configReceipt.provider === model.provider &&
216
+ configReceipt.modelId === model.id &&
217
+ (!isActionableModelsJsonFixReceipt(modelsReceipt) || modelsReceipt.provider !== model.provider ||
218
+ (modelsReceipt.placement !== "provider" && modelsReceipt.modelId !== model.id) || configReceipt.appliedAt >= modelsReceipt.appliedAt);
219
+ if (useConfigReceipt) {
220
+ if (!cmdCtx.hasUI) {
221
+ cmdCtx.ui.notify("❌ Rollback requires interactive confirmation. No changes were made.\nRun /cache-optimizer rollback in Pi's interactive UI to restore the prompt-cache-key setting.", "warning");
222
+ return;
223
+ }
224
+ const confirmed = await cmdCtx.ui.confirm(
225
+ "Cache Optimizer — Rollback prompt_cache_key opt-out",
226
+ `Model: ${modelKey(model)}\nAction: restore the extension config before the confirmed opt-out.\nFooter mode and unrelated configuration will be preserved.\nAfterward, run /reload or restart Pi.\n\nProceed with rollback?`,
227
+ );
228
+ if (!confirmed) {
229
+ cmdCtx.ui.notify("No changes were made. Rollback canceled by user.", "info");
230
+ return;
231
+ }
232
+ try {
233
+ if (!configReceiptSnapshot) throw new Error("prompt-cache-key receipt changed since the rollback preview");
234
+ await rollbackPromptCacheKeyConfig(configReceiptSnapshot);
235
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
236
+ cmdCtx.ui.notify(`✅ Restored prompt_cache_key behavior for ${modelKey(model)}. Run /reload or restart Pi for the change to take effect.`, "info");
237
+ } catch (error) {
238
+ cmdCtx.ui.notify(`❌ Prompt cache key rollback refused: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
239
+ }
240
+ return;
241
+ }
242
+ if (!cmdCtx.hasUI) {
243
+ const receipt = await readModelsJsonFixReceipt();
244
+ const backupHint = receipt && isActionableModelsJsonFixReceipt(receipt)
245
+ ? ` The recorded backup is ${receipt.backupFile} next to ${getModelsJsonDisplayPath()}.`
246
+ : " Check the recorded models.json backup manually if a fix receipt exists.";
247
+ cmdCtx.ui.notify(
248
+ "❌ Rollback requires interactive confirmation. No changes were made.\n" +
249
+ `Run /cache-optimizer rollback in Pi's interactive UI.${backupHint} Then run /reload.`,
250
+ "warning",
251
+ );
252
+ return;
253
+ }
254
+
255
+ const receiptSnapshot = await readModelsJsonFixReceiptSnapshot();
256
+ const receipt = receiptSnapshot?.receipt;
257
+ if (!receiptSnapshot || !isActionableModelsJsonFixReceipt(receipt)) {
258
+ cmdCtx.ui.notify("ℹ️ No unapplied /cache-optimizer fix receipt was found.", "info");
259
+ return;
260
+ }
261
+ if (receipt.provider !== model.provider || (receipt.placement !== "provider" && receipt.modelId !== model.id)) {
262
+ cmdCtx.ui.notify(
263
+ `ℹ️ The latest fix receipt is for ${receipt.provider}/${receipt.modelId}, not the active model ${model.provider}/${model.id}. ` +
264
+ "Switch to the matching model before running rollback. No changes were made.",
265
+ "warning",
266
+ );
267
+ return;
268
+ }
269
+
270
+ const rollback = await prepareModelsJsonRollback(receiptSnapshot);
271
+ if ("error" in rollback) {
272
+ cmdCtx.ui.notify(`ℹ️ ${rollback.error}`, "info");
273
+ return;
274
+ }
275
+
276
+ const rollbackScope = rollback.mode === "exact"
277
+ ? "restore the exact pre-fix models.json because the file is unchanged since the fix"
278
+ : "restore only the receipt-owned compat scalar keys and preserve subsequent user changes";
279
+ const rollbackPreview = [
280
+ `Rollback transaction ${rollback.receipt.transactionId}:`,
281
+ `Model: ${rollback.receipt.provider}/${rollback.receipt.modelId}`,
282
+ `Action: ${rollbackScope}.`,
283
+ `A new rollback backup will be written to: ${rollback.rollbackBackupPath}`,
284
+ "Comments, credentials, unrelated fields, and the existing access mode will be preserved.",
285
+ "Afterward, run /reload or restart Pi for the configuration change to take effect.",
286
+ "",
287
+ "Proceed with rollback?",
288
+ ].join("\n");
289
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Rollback", rollbackPreview);
290
+ if (!confirmed) {
291
+ cmdCtx.ui.notify("No changes were made. Rollback canceled by user.", "info");
292
+ return;
293
+ }
294
+
295
+ try {
296
+ const result = await applyModelsJsonFixTransaction(
297
+ rollback.modifiedText,
298
+ rollback.rollbackBackupPath,
299
+ (writtenText) => validateModelsJsonRollback(
300
+ writtenText,
301
+ rollback.receipt,
302
+ rollback.expectedResultHash,
303
+ ),
304
+ {
305
+ expectedCurrentHash: rollback.currentHash,
306
+ expectedCurrentMode: rollback.fileMode,
307
+ receiptGuard: rollback.receiptSnapshot,
308
+ purpose: "rollback",
309
+ onCommitted: async () => {
310
+ await markModelsJsonFixReceiptRolledBack(rollback.receiptSnapshot);
311
+ },
312
+ },
313
+ );
314
+ if ("postCheckError" in result) {
315
+ cmdCtx.ui.notify(
316
+ `❌ Rollback self-check failed: ${result.postCheckError}\n` +
317
+ `The rollback backup at ${rollback.rollbackBackupPath} was restored. No changes applied.`,
318
+ "error",
319
+ );
320
+ return;
321
+ }
322
+ invalidateModelsConfigCache();
323
+ cmdCtx.ui.notify(
324
+ `✅ Rollback completed for ${rollback.receipt.provider}/${rollback.receipt.modelId}.\n` +
325
+ `Rollback backup saved to: ${rollback.rollbackBackupPath}\n` +
326
+ "The receipt was marked as rolled back. Run /reload or restart Pi for the change to take effect.",
327
+ "info",
328
+ );
329
+ } catch (rollbackError) {
330
+ cmdCtx.ui.notify(
331
+ `❌ Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}\n` +
332
+ "No automatic overwrite was performed; use the recorded backup for manual guidance.",
333
+ "error",
334
+ );
335
+ }
336
+ } else if (subcommand === "reset") {
337
+ if (!model) {
338
+ cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
339
+ return;
340
+ }
341
+ const adapter = selectAdapterForModel(model);
342
+ if (!adapter) {
343
+ cmdCtx.ui.notify("ℹ️ Active model does not match a cache adapter. No stats to reset.", "info");
344
+ return;
345
+ }
346
+
347
+ const displayKey = modelKey(model);
348
+
349
+ // Reset local footer stats for the effective active model. If the
350
+ // selected model is a virtual router and the protocol exposes a live
351
+ // route, this clears the real upstream bucket, not the router shell.
352
+ await resetStatsForModel(model);
353
+
354
+ // Persist immediately.
355
+ await flushPersistCacheStats(cmdCtx);
356
+
357
+ // Update footer to show 0/0.
358
+ await publishStatus(cmdCtx, model);
359
+
360
+ cmdCtx.ui.notify(
361
+ `✅ Reset local footer cache stats for "${displayKey}". ` +
362
+ "Upstream provider prompt cache was not modified. " +
363
+ "New requests will start a fresh local stats bucket for this provider/model.",
364
+ "info",
365
+ );
366
+ } else if (subcommand === "fix") {
367
+ if (!model) {
368
+ cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
369
+ return;
370
+ }
371
+
372
+ const promptCacheKeyRequested = commandParts[1] === "prompt-cache-key";
373
+ const promptCacheKeyFixRequested = promptCacheKeyRequested || promptCacheKeyFixApplies(model);
374
+ if (promptCacheKeyRequested && !isPromptCacheKeyUnsupportedApplicable(model)) {
375
+ cmdCtx.ui.notify("ℹ️ Prompt cache key opt-out applies only to a known OpenAI-compatible provider/model endpoint. No changes were made.", "info");
376
+ return;
377
+ }
378
+ const suggestion = promptCacheKeyFixRequested ? undefined : buildCommandFixSuggestion(model);
379
+
380
+ if (promptCacheKeyFixRequested) {
381
+ if (isPromptCacheKeyOmittedForModel(model)) {
382
+ cmdCtx.ui.notify(`✅ prompt_cache_key is already omitted for "${modelKey(model)}".`, "info");
383
+ return;
384
+ }
385
+ if (!cmdCtx.hasUI) {
386
+ cmdCtx.ui.notify(
387
+ "❌ Non-interactive terminal detected. Prompt cache key opt-out requires UI confirmation. No changes were made.\n" +
388
+ `Run /cache-optimizer fix prompt-cache-key in Pi's interactive UI for ${modelKey(model)}.`,
389
+ "warning",
390
+ );
391
+ return;
392
+ }
393
+ const preview = [
394
+ "📝 Preview of extension configuration change:",
395
+ ...buildPromptCacheKeyConfigPreview(model),
396
+ "",
397
+ "⚠️ Risk notice:",
398
+ " 1. This affects all sessions using this exact provider/model.",
399
+ " 2. Provider prompt-cache reuse may decrease because both key spellings are removed.",
400
+ " 3. This does not modify Pi's models.json, credentials, prompts, headers, or other models.",
401
+ " 4. The extension config will be backed up and Pi must be reloaded/restarted.",
402
+ "",
403
+ "Apply this persistent opt-out?",
404
+ ].join("\n");
405
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Omit prompt_cache_key", preview);
406
+ if (!confirmed) {
407
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
408
+ return;
409
+ }
410
+ try {
411
+ const result = await applyPromptCacheKeyConfigFix(model);
412
+ setPersistedCacheOptimizerConfig(readPersistedCacheOptimizerConfig());
413
+ cmdCtx.ui.notify(
414
+ `✅ Prompt cache key opt-out saved for ${modelKey(model)}.\n` +
415
+ `Config backup saved to: ${result.backupPath}\n` +
416
+ "Run /reload or restart Pi for the change to take effect. Use /cache-optimizer rollback to restore it.",
417
+ "info",
418
+ );
419
+ } catch (error) {
420
+ cmdCtx.ui.notify(`❌ Could not save prompt cache key opt-out: ${error instanceof Error ? error.message : String(error)}. No changes were made.`, "error");
421
+ }
422
+ return;
423
+ }
424
+
425
+ if (!suggestion) {
426
+ const key = modelKey(model);
427
+ cmdCtx.ui.notify(`✅ Nothing to fix for "${key}". Compat already configured.`, "info");
428
+ return;
429
+ }
430
+
431
+ if (!cmdCtx.hasUI) {
432
+ // No UI — refuse to write, show manual guidance instead.
433
+ const compatResult = buildCompatDiagnosis(model);
434
+ const snippet = formatMissingEntryManualSnippet(
435
+ suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
436
+ );
437
+ const manualLines = [
438
+ `❌ Non-interactive terminal detected. Auto-fix requires UI confirmation.`,
439
+ "",
440
+ `Edit ${getModelsJsonDisplayPath()} and run /reload.`,
441
+ ];
442
+ if (promptCacheRetention400Models.has(modelKey(model))) {
443
+ manualLines.push(
444
+ "",
445
+ "💡 This model returned HTTP 400 for prompt_cache_retention.",
446
+ "Create or edit the entry below to override supportsLongCacheRetention to false.",
447
+ );
448
+ }
449
+ if (anthropicTtlOrderErrorModels.has(modelKey(model))) {
450
+ manualLines.push(
451
+ "",
452
+ "💡 This model returned an Anthropic cache-control TTL ordering error.",
453
+ "Create or edit the entry below to override supportsLongCacheRetention to false.",
454
+ );
455
+ }
456
+ if (sendSessionAffinityHeaders403Models.has(modelKey(model))) {
457
+ manualLines.push(
458
+ "",
459
+ "💡 This model returned HTTP 403 while sendSessionAffinityHeaders was enabled.",
460
+ "Create or edit the entry below to override sendSessionAffinityHeaders to false.",
461
+ );
462
+ }
463
+ manualLines.push(
464
+ "",
465
+ "Add these compat keys at Pi's highest-precedence model override path:",
466
+ `providers["${suggestion.providerLabel}"] -> modelOverrides -> "${suggestion.modelId}" -> compat:`,
467
+ formatCompatKeysForInsertion(suggestion.compatKeys),
468
+ );
469
+ if (snippet.length > 0) {
470
+ manualLines.push(
471
+ "",
472
+ "If the provider/model is missing (common for API-logged-in channels such as",
473
+ `opencode go), add a minimal entry under "providers" (keep existing auth as-is):`,
474
+ "",
475
+ snippet,
476
+ );
477
+ }
478
+ if (compatResult) {
479
+ manualLines.push("", compatResult);
480
+ }
481
+ cmdCtx.ui.notify(manualLines.join("\n"), "warning");
482
+ return;
483
+ }
484
+
485
+ // Read the models.json file
486
+ let originalText: string;
487
+ try {
488
+ originalText = await readFile(MODELS_JSON_PATH, "utf8");
489
+ } catch {
490
+ cmdCtx.ui.notify(`❌ Could not read ${MODELS_JSON_PATH}. File may not exist.`, "error");
491
+ return;
492
+ }
493
+
494
+ // Locate the model entry. API-logged-in providers (e.g. opencode go)
495
+ // may not appear in models.json at all.
496
+ const location = locateModelInJsonc(originalText, suggestion.providerLabel, suggestion.modelId);
497
+ if (!location) {
498
+ const diagnosis = analyzeModelsJsonForMissingEntry(originalText, suggestion.providerLabel, suggestion.modelId);
499
+ const parsedOriginal = (() => { try { return parseJsonc(originalText); } catch { return undefined; } })();
500
+ const provider = asRecord(asRecord(parsedOriginal)?.providers)?.[suggestion.providerLabel];
501
+ const explicit = resolveExplicitCompatValue(parsedOriginal, suggestion.providerLabel, suggestion.modelId, "sendSessionAffinityHeaders");
502
+ const targetOverrideLocation = locateModelOverrideInJsonc(
503
+ originalText, suggestion.providerLabel, suggestion.modelId,
504
+ );
505
+ const hasTargetOverride = (targetOverrideLocation?.modelOverrideObjectBrace ?? -1) >= 0;
506
+ const providerPlan = diagnosis && diagnosis.scenario !== "provider_missing" &&
507
+ isValidModelsConfigForEffectiveCompat(parsedOriginal) && asRecord(provider) &&
508
+ Object.keys(suggestion.compatKeys).length === 1 && suggestion.compatKeys.sendSessionAffinityHeaders === true &&
509
+ !suggestion.forceModelLevel && !hasTargetOverride && explicit === undefined &&
510
+ getEffectiveCompatValueSource(model, parsedOriginal, "sendSessionAffinityHeaders") === undefined
511
+ ? composeProviderAffinityInsertion(originalText, suggestion.providerLabel)
512
+ : undefined;
513
+ if (providerPlan) {
514
+ const checkProvider = (writtenText: string): string | null => {
515
+ try {
516
+ const changed = parseJsonc(writtenText);
517
+ if (!isValidModelsConfigForEffectiveCompat(changed)) return "provider config is invalid";
518
+ if (resolveEffectiveCompatFromConfig(model, changed).sendSessionAffinityHeaders !== true) return "affinity flag is not effective";
519
+ const reverted = JSON.parse(JSON.stringify(changed)) as Record<string, unknown>;
520
+ const target = asRecord(asRecord(reverted.providers)?.[suggestion.providerLabel]);
521
+ const compat = asRecord(target?.compat);
522
+ if (!target || !compat || compat.sendSessionAffinityHeaders !== true) return "provider affinity key is missing";
523
+ delete compat.sendSessionAffinityHeaders;
524
+ const originalCompat = asRecord(asRecord(provider)?.compat);
525
+ if (!originalCompat) delete target.compat;
526
+ return JSON.stringify(reverted) === JSON.stringify(parsedOriginal) ? null : "unrelated configuration was altered";
527
+ } catch { return "invalid provider JSONC"; }
528
+ };
529
+ const checkError = checkProvider(providerPlan.modifiedText);
530
+ if (checkError) {
531
+ cmdCtx.ui.notify(`❌ Provider-level self-check failed: ${checkError}. No changes were made.`, "error");
532
+ return;
533
+ }
534
+ const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${backupTimestamp()}`;
535
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix provider affinity", [
536
+ `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
537
+ `Location: ${providerPlan.placementLabel}`,
538
+ `Compat JSON to write: ${JSON.stringify(suggestion.compatKeys)}`,
539
+ `⚠️ This affects all models using this provider across all sessions; model-level overrides remain authoritative.`,
540
+ `A timestamped backup will be written to: ${backupPath}`,
541
+ "Run /reload or restart Pi for the change to take effect.",
542
+ "Apply these changes?",
543
+ ].join("\n"));
544
+ if (!confirmed) {
545
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
546
+ return;
547
+ }
548
+ const receipt = createModelsJsonFixReceipt(
549
+ originalText, providerPlan.modifiedText, suggestion.providerLabel, suggestion.modelId,
550
+ "provider", suggestion.compatKeys, true, backupPath,
551
+ );
552
+ if (!receipt) {
553
+ cmdCtx.ui.notify("❌ Could not create a privacy-safe fix receipt. No changes were made.", "error");
554
+ return;
555
+ }
556
+ try {
557
+ const result = await applyModelsJsonFixTransaction(providerPlan.modifiedText, backupPath, checkProvider, {
558
+ expectedCurrentHash: hashText(originalText), purpose: "fix",
559
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
560
+ });
561
+ if ("postCheckError" in result) {
562
+ cmdCtx.ui.notify(`❌ Post-write self-check failed: ${result.postCheckError}. Backup restored.`, "error");
563
+ return;
564
+ }
565
+ invalidateModelsConfigCache();
566
+ cmdCtx.ui.notify(`✅ Fix applied to ${getModelsJsonDisplayPath()}.\nBackup saved to: ${backupPath}\nRun /reload or restart Pi.`, "info");
567
+ } catch (error) {
568
+ cmdCtx.ui.notify(`❌ Write failed: ${error instanceof Error ? error.message : String(error)}. Backup may be at: ${backupPath}`, "error");
569
+ }
570
+ return;
571
+ }
572
+ if (diagnosis && cmdCtx.hasUI) {
573
+ const overrideLocation = locateModelOverrideInJsonc(
574
+ originalText, suggestion.providerLabel, suggestion.modelId,
575
+ );
576
+ const repairsExistingOverride = (overrideLocation?.modelOverrideObjectBrace ?? -1) >= 0;
577
+ // Prefer a modelOverrides edit when models[] has no target entry.
578
+ const plan = composeModelOverrideInsertion(
579
+ originalText, suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
580
+ );
581
+ if (!plan) {
582
+ cmdCtx.ui.notify(
583
+ `❌ Could not safely locate a modelOverrides insertion point.\n` +
584
+ `Falling back to manual guidance. No changes were made.`,
585
+ "error",
586
+ );
587
+ } else {
588
+ const checkError = selfCheckMissingEntryInsertion(
589
+ originalText, plan.modifiedText,
590
+ suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys,
591
+ model,
592
+ );
593
+ if (checkError !== null) {
594
+ // Fall through to manual guidance.
595
+ cmdCtx.ui.notify(
596
+ `❌ Self-check would fail for auto-created entry: ${checkError}\n` +
597
+ `Falling back to manual guidance. No changes were made.`,
598
+ "error",
599
+ );
600
+ // Continue to manual guidance below.
601
+ } else {
602
+ const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
603
+ const ts = backupTimestamp();
604
+ const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
605
+ const previewLines = [
606
+ `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
607
+ ``,
608
+ `Location: ${plan.placementLabel}`,
609
+ `Compat JSON to write:`,
610
+ keysPreview,
611
+ ``,
612
+ `⚠️ Risk notice:`,
613
+ repairsExistingOverride
614
+ ? ` 1. This updates the existing modelOverrides entry for "${suggestion.modelId}". Existing auth is not affected.`
615
+ : ` 1. This creates a modelOverrides entry in models.json. Existing auth (e.g. login API tokens) is not affected.`,
616
+ ` 2. A timestamped backup will be written to: ${backupPath}`,
617
+ ` 3. You must run /reload or restart Pi for the change to take effect.`,
618
+ ` 4. If the file contains comments or unusual formatting, please verify the result after write.`,
619
+ ];
620
+ if (promptCacheRetention400Models.has(modelKey(model))) {
621
+ previewLines.push(
622
+ "",
623
+ "💡 This fix overrides supportsLongCacheRetention to false because",
624
+ "a 400 prompt_cache_retention error was observed for this model.",
625
+ "After applying and reloading, Pi will no longer send the",
626
+ "prompt_cache_retention parameter to this provider.",
627
+ );
628
+ }
629
+ previewLines.push("", `Apply these changes?`);
630
+ const confirmed = await cmdCtx.ui.confirm(
631
+ repairsExistingOverride ? "Cache Optimizer — Fix (model override)" : "Cache Optimizer — Fix (new override)",
632
+ previewLines.join("\n"),
633
+ );
634
+ if (confirmed) {
635
+ try {
636
+ const receipt = createModelsJsonFixReceipt(
637
+ originalText,
638
+ plan.modifiedText,
639
+ suggestion.providerLabel,
640
+ suggestion.modelId,
641
+ "modelOverride",
642
+ suggestion.compatKeys,
643
+ repairsExistingOverride,
644
+ backupPath,
645
+ );
646
+ if (!receipt) {
647
+ cmdCtx.ui.notify("❌ Could not create a privacy-safe fix receipt. No changes were made.", "error");
648
+ return;
649
+ }
650
+ const result = await applyModelsJsonFixTransaction(
651
+ plan.modifiedText,
652
+ backupPath,
653
+ (writtenText) => selfCheckMissingEntryInsertion(
654
+ originalText,
655
+ writtenText,
656
+ suggestion.providerLabel,
657
+ suggestion.modelId,
658
+ suggestion.compatKeys,
659
+ model,
660
+ ),
661
+ {
662
+ expectedCurrentHash: hashText(originalText),
663
+ purpose: "fix",
664
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
665
+ },
666
+ );
667
+ if ("postCheckError" in result) {
668
+ cmdCtx.ui.notify(
669
+ `❌ Post-write self-check failed: ${result.postCheckError}\n` +
670
+ `The backup at ${backupPath} has been restored. No changes applied.`,
671
+ "error",
672
+ );
673
+ return;
674
+ }
675
+ invalidateModelsConfigCache();
676
+ cmdCtx.ui.notify(
677
+ `✅ Fix applied to ${getModelsJsonDisplayPath()}.\n` +
678
+ `Backup saved to: ${backupPath}\n` +
679
+ `Run /reload or restart Pi for the change to take effect.`,
680
+ "info",
681
+ );
682
+ } catch (e) {
683
+ cmdCtx.ui.notify(
684
+ `❌ Write failed: ${e instanceof Error ? e.message : String(e)}\n` +
685
+ `Backup may be at: ${backupPath}`,
686
+ "error",
687
+ );
688
+ }
689
+ return;
690
+ }
691
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
692
+ return;
693
+ }
694
+ }
695
+ }
696
+
697
+ // Non-interactive or no diagnosis: show manual guidance.
698
+ const snippet = diagnosis
699
+ ? formatMissingEntryManualSnippet(suggestion.providerLabel, suggestion.modelId, suggestion.compatKeys)
700
+ : formatCompatKeysForInsertion(suggestion.compatKeys);
701
+ const adviceLines: string[] = [];
702
+ if (!diagnosis) {
703
+ adviceLines.push(
704
+ `❌ Could not locate model "${suggestion.modelId}" or provider "${suggestion.providerLabel}" in ${getModelsJsonDisplayPath()}.`,
705
+ "",
706
+ "Providers that were added via Pi /login API (e.g. opencode go) do not have",
707
+ "entries in models.json. You can create a minimal modelOverrides entry by hand:",
708
+ );
709
+ } else if (diagnosis.scenario === "provider_missing") {
710
+ adviceLines.push(
711
+ `ℹ️ Provider "${suggestion.providerLabel}" does not exist in ${getModelsJsonDisplayPath()}.`,
712
+ `This is common for API-logged-in providers (e.g. /login ...).`,
713
+ "",
714
+ "Add the following minimal block under the \"providers\" key (keep your",
715
+ "existing authentication as-is):",
716
+ );
717
+ } else {
718
+ adviceLines.push(
719
+ `ℹ️ Model "${suggestion.modelId}" was not found in ${getModelsJsonDisplayPath()}`,
720
+ `under providers["${suggestion.providerLabel}"].`,
721
+ "",
722
+ "Add the following modelOverrides entry (keep existing auth):",
723
+ );
724
+ }
725
+ adviceLines.push("", snippet, "", "Then save and run /reload.");
726
+ cmdCtx.ui.notify(adviceLines.join("\n"), "warning");
727
+ return;
728
+ }
729
+
730
+ const duplicateTargetDefinitions = location.allModelIds.filter(
731
+ (configuredModelId) => configuredModelId === suggestion.modelId,
732
+ ).length;
733
+ if (duplicateTargetDefinitions > 1) {
734
+ cmdCtx.ui.notify(
735
+ `❌ ${getModelsJsonDisplayPath()} contains ${duplicateTargetDefinitions} custom model definitions ` +
736
+ `with the exact id "${suggestion.modelId}" under providers["${suggestion.providerLabel}"].\n` +
737
+ `Pi uses the last definition, but /cache-optimizer fix refuses an ambiguous duplicate-id edit. ` +
738
+ `Remove or consolidate the duplicates, then run the command again.`,
739
+ "error",
740
+ );
741
+ return;
742
+ }
743
+
744
+ // Compose the modified text — observed runtime failures are always
745
+ // model-scoped; ordinary compat fixes use the safety-based placement.
746
+ const decision = chooseFixPlacement(
747
+ originalText,
748
+ location,
749
+ suggestion.compatKeys,
750
+ suggestion.providerLabel,
751
+ suggestion.forceModelLevel,
752
+ );
753
+ const createsModelOverride = decision.placement === "modelOverride" && location.modelOverrideObjectBrace < 0;
754
+ const modelOverridePlan = createsModelOverride
755
+ ? composeModelOverrideInsertion(
756
+ originalText,
757
+ suggestion.providerLabel,
758
+ suggestion.modelId,
759
+ suggestion.compatKeys,
760
+ )
761
+ : undefined;
762
+ if (createsModelOverride && !modelOverridePlan) {
763
+ cmdCtx.ui.notify(
764
+ "❌ Could not safely create the highest-precedence model override. No changes were made.",
765
+ "error",
766
+ );
767
+ return;
768
+ }
769
+ const modifiedText = modelOverridePlan?.modifiedText
770
+ ?? composeFixInsertion(originalText, location, suggestion.compatKeys, decision.placement);
771
+
772
+ // Self-check against the same provider → model → runtime →
773
+ // modelOverride precedence used by request hooks.
774
+ const checkError = createsModelOverride
775
+ ? selfCheckMissingEntryInsertion(
776
+ originalText,
777
+ modifiedText,
778
+ suggestion.providerLabel,
779
+ suggestion.modelId,
780
+ suggestion.compatKeys,
781
+ model,
782
+ )
783
+ : selfCheckFix(
784
+ originalText,
785
+ modifiedText,
786
+ suggestion.providerLabel,
787
+ suggestion.modelId,
788
+ suggestion.compatKeys,
789
+ decision.placement,
790
+ model,
791
+ );
792
+ if (checkError !== null) {
793
+ cmdCtx.ui.notify(
794
+ `❌ Self-check failed before write: ${checkError}\n` +
795
+ `No changes were made. Manual edit required.`,
796
+ "error",
797
+ );
798
+ return;
799
+ }
800
+
801
+ // Build preview snippet as copyable JSON (the surgical editor will
802
+ // insert or repair these exact compat key/value pairs).
803
+ const keysPreview = JSON.stringify(suggestion.compatKeys, null, 2);
804
+ const targetHasCompat = decision.placement === "provider"
805
+ ? location.providerCompatBrace >= 0
806
+ : decision.placement === "modelOverride"
807
+ ? location.modelOverrideCompatBrace >= 0
808
+ : location.compatObjectBrace >= 0;
809
+ const placementDesc = targetHasCompat ? `existing "compat" object` : `new "compat" object`;
810
+ const locationDesc = decision.placement === "provider"
811
+ ? `providers["${suggestion.providerLabel}"] -> compat (provider level, ${placementDesc})`
812
+ : decision.placement === "modelOverride"
813
+ ? `providers["${suggestion.providerLabel}"] -> modelOverrides -> "${suggestion.modelId}" -> compat (${placementDesc})`
814
+ : `providers["${suggestion.providerLabel}"] -> models -> "${suggestion.modelId}" -> compat (model level, ${placementDesc})`;
815
+
816
+ const ts = backupTimestamp();
817
+ const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${ts}`;
818
+
819
+ const scopeRiskLine = decision.placement === "provider"
820
+ ? ` 1. This change applies to ALL ${location.allModelIds.length || 1} model(s) in the "${suggestion.providerLabel}" provider, across all sessions.`
821
+ : ` 1. This change affects ALL sessions using the "${suggestion.providerLabel}" provider/channel (scoped to model "${suggestion.modelId}").`;
822
+
823
+ const previewLines = [
824
+ `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
825
+ ``,
826
+ `Location: ${locationDesc}`,
827
+ `Placement: ${decision.placement} level — ${decision.reason}`,
828
+ `Compat JSON to write:`,
829
+ keysPreview,
830
+ ``,
831
+ `⚠️ Risk notice:`,
832
+ scopeRiskLine,
833
+ ` 2. A timestamped backup will be written to: ${backupPath}`,
834
+ ` 3. You must restart Pi / run /reload for the change to take effect.`,
835
+ ` 4. If the file contains comments or unusual formatting, please verify the result after write.`,
836
+ ];
837
+ if (promptCacheRetention400Models.has(modelKey(model))) {
838
+ previewLines.push(
839
+ "",
840
+ "💡 This fix overrides supportsLongCacheRetention to false because",
841
+ "a 400 prompt_cache_retention error was observed for this model.",
842
+ "After applying and reloading, Pi will no longer send the",
843
+ "prompt_cache_retention parameter to this provider.",
844
+ );
845
+ }
846
+ previewLines.push("", `Apply these changes?`);
847
+
848
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix", previewLines.join("\n"));
849
+ if (!confirmed) {
850
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
851
+ return;
852
+ }
853
+
854
+ // Write: backup → temp + rename → self-check again
855
+ try {
856
+ const receipt = createModelsJsonFixReceipt(
857
+ originalText,
858
+ modifiedText,
859
+ suggestion.providerLabel,
860
+ suggestion.modelId,
861
+ decision.placement,
862
+ suggestion.compatKeys,
863
+ !createsModelOverride,
864
+ backupPath,
865
+ );
866
+ if (!receipt) {
867
+ cmdCtx.ui.notify("❌ Could not create a privacy-safe fix receipt. No changes were made.", "error");
868
+ return;
869
+ }
870
+ const result = await applyModelsJsonFixTransaction(
871
+ modifiedText,
872
+ backupPath,
873
+ (writtenText) => createsModelOverride
874
+ ? selfCheckMissingEntryInsertion(
875
+ originalText,
876
+ writtenText,
877
+ suggestion.providerLabel,
878
+ suggestion.modelId,
879
+ suggestion.compatKeys,
880
+ model,
881
+ )
882
+ : selfCheckFix(
883
+ originalText,
884
+ writtenText,
885
+ suggestion.providerLabel,
886
+ suggestion.modelId,
887
+ suggestion.compatKeys,
888
+ decision.placement,
889
+ model,
890
+ ),
891
+ {
892
+ expectedCurrentHash: hashText(originalText),
893
+ purpose: "fix",
894
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
895
+ },
896
+ );
897
+ if ("postCheckError" in result) {
898
+ cmdCtx.ui.notify(
899
+ `❌ Post-write self-check failed: ${result.postCheckError}\n` +
900
+ `The backup at ${backupPath} has been restored. No changes applied.`,
901
+ "error",
902
+ );
903
+ return;
904
+ }
905
+
906
+ invalidateModelsConfigCache();
907
+ cmdCtx.ui.notify(
908
+ `✅ Fix applied to ${getModelsJsonDisplayPath()}.\n` +
909
+ `Backup saved to: ${backupPath}\n` +
910
+ `Run /reload or restart Pi for the change to take effect.`,
911
+ "info",
912
+ );
913
+ } catch (writeError) {
914
+ cmdCtx.ui.notify(
915
+ `❌ Write failed: ${writeError instanceof Error ? writeError.message : String(writeError)}\n` +
916
+ `Backup may be at: ${backupPath}`,
917
+ "error",
918
+ );
919
+ }
920
+ } else {
921
+ // Try interactive selection menu when UI supports it
922
+ if (cmdCtx.hasUI) {
923
+ const menuOptions = [
924
+ "Enable — Turn on runtime optimizations",
925
+ "Disable — Turn off runtime optimizations",
926
+ "Doctor — Show cache configuration",
927
+ "Stats — Show current-session model statistics",
928
+ "Compat — Show compat suggestion",
929
+ "Fix — Auto-fix compat issues (writes models.json or extension config)",
930
+ "Disable prompt_cache_key — Omit it for the active model",
931
+ "Rollback — Undo the latest confirmed fix",
932
+ "Footer mode — Choose total, session, or process stats",
933
+ "Reset — Reset local provider/model stats",
934
+ "Cancel",
935
+ ];
936
+ const choice = await cmdCtx.ui.select("Cache Optimizer", menuOptions);
937
+ if (choice === menuOptions[0]) {
938
+ await handleCacheOptimizerCommand("enable", cmdCtx);
939
+ } else if (choice === menuOptions[1]) {
940
+ await handleCacheOptimizerCommand("disable", cmdCtx);
941
+ } else if (choice === menuOptions[2]) {
942
+ await handleCacheOptimizerCommand("doctor", cmdCtx);
943
+ } else if (choice === menuOptions[3]) {
944
+ await handleCacheOptimizerCommand("stats", cmdCtx);
945
+ } else if (choice === menuOptions[4]) {
946
+ await handleCacheOptimizerCommand("compat", cmdCtx);
947
+ } else if (choice === menuOptions[5]) {
948
+ await handleCacheOptimizerCommand("fix", cmdCtx);
949
+ } else if (choice === menuOptions[6]) {
950
+ await handleCacheOptimizerCommand("fix prompt-cache-key", cmdCtx);
951
+ } else if (choice === menuOptions[7]) {
952
+ await handleCacheOptimizerCommand("rollback", cmdCtx);
953
+ } else if (choice === menuOptions[8]) {
954
+ const modeOptions = ["session — Current Pi conversation session (default)", "total — All local sessions today", "process — Current extension instance only", "Cancel"];
955
+ const modeChoice = await cmdCtx.ui.select("Footer cache stats mode", modeOptions);
956
+ const nextMode = modeChoice === modeOptions[0]
957
+ ? "session"
958
+ : modeChoice === modeOptions[1]
959
+ ? "total"
960
+ : modeChoice === modeOptions[2]
961
+ ? "process"
962
+ : undefined;
963
+ if (nextMode) await handleCacheOptimizerCommand(`config footer-mode ${nextMode}`, cmdCtx);
964
+ } else if (choice === menuOptions[9]) {
965
+ await handleCacheOptimizerCommand("reset", cmdCtx);
966
+ }
967
+ // choice === "cancel" or undefined → no action
968
+ return;
969
+ }
970
+
971
+ // Fallback: text help when no interactive UI
972
+ const diagnosis: string[] = [];
973
+ diagnosis.push("📋 /cache-optimizer commands:");
974
+ diagnosis.push(" enable — Enable prompt/cache optimizations for this Pi process");
975
+ diagnosis.push(" disable — Disable prompt/cache optimizations for this Pi process");
976
+ diagnosis.push(" doctor — Show current model/provider/api/baseUrl/compat and low-hit diagnosis");
977
+ diagnosis.push(" stats — Show detailed statistics for every model in the current session");
978
+ diagnosis.push(" stats all — Show detailed totals for every model across all local sessions");
979
+ diagnosis.push(" stats contributors — Show per-session contributors for the active model");
980
+ diagnosis.push(" compat — Show compat suggestion with edit location");
981
+ diagnosis.push(" config prompt-rewrite|virtual-rewrite|skill-compression|openai-cache-key|tool-order on|off — Persist feature settings");
982
+ diagnosis.push(" config footer-mode total|session|process — Persist the footer stats mode");
983
+ diagnosis.push(" config reset — Remove persistent feature overrides");
984
+ diagnosis.push(" fix — Auto-fix compat issues (writes models.json or extension config, requires UI)");
985
+ diagnosis.push(" fix prompt-cache-key — Explicitly omit prompt_cache_key for the active model");
986
+ diagnosis.push(" rollback — Undo the latest confirmed fix (requires UI confirmation)");
987
+ diagnosis.push(" reset — Reset local provider/model stats for current model (does not affect upstream)");
988
+ diagnosis.push("");
989
+ diagnosis.push(formatOptimizerRuntimeMode());
990
+ const resolvedFooterMode = resolveFooterStatsMode(persistedFooterStatsMode);
991
+ diagnosis.push(`Footer stats mode: ${resolvedFooterMode.mode} (${resolvedFooterMode.source})`);
992
+ diagnosis.push("");
993
+ if (model) {
994
+ const displayKey = modelKey(model);
995
+ const missing = describeMissingCacheCompatForModel(model);
996
+ if (missing.length > 0) {
997
+ diagnosis.push(`⚠️ Active model "${displayKey}" missing compat: ${missing.join(", ")}`);
998
+ diagnosis.push('Run "/cache-optimizer compat" for edit instructions.');
999
+ } else if (isAdaptiveThinkingCompatApplicable(model) || isDeepSeekCompatCheckApplicable(model) || isCompatCheckApplicable(model)) {
1000
+ diagnosis.push(`✅ Active model "${displayKey}": compat fully configured.`);
1001
+ } else {
1002
+ diagnosis.push(`ℹ️ Active model "${displayKey}": compat check not applicable.`);
1003
+ const detailLines = getCompatCheckNotApplicableLines(model).slice(1);
1004
+ for (const line of detailLines) diagnosis.push(line);
1005
+ }
1006
+ } else {
1007
+ diagnosis.push("No active model selected.");
1008
+ }
1009
+ cmdCtx.ui.notify(diagnosis.join("\n"), "info");
1010
+ }
1011
+ }
1012
+ }