pi-cache-optimizer 2.8.16 → 2.8.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/common.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type PiModel = NonNullable<ExtensionContext["model"]>;
4
+
5
+ export type ModelIdentity = Pick<PiModel, "provider" | "id">;
6
+
7
+ export type UnknownRecord = Record<string, unknown>;
8
+
9
+ export const LOG_PREFIX = "pi-cache-optimizer";
10
+
11
+ export function asRecord(value: unknown): UnknownRecord | undefined {
12
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
13
+ return value as UnknownRecord;
14
+ }
15
+
16
+ export function lower(value: unknown): string {
17
+ return typeof value === "string" ? value.toLowerCase() : "";
18
+ }
19
+
20
+ export function isNonEmptyString(value: unknown): value is string {
21
+ return typeof value === "string" && value.trim().length > 0;
22
+ }
23
+
24
+ export function getErrorCode(error: unknown): string | undefined {
25
+ return typeof error === "object" && error !== null && "code" in error
26
+ ? String((error as { code?: unknown }).code)
27
+ : undefined;
28
+ }
29
+
30
+ export function isProcessAlive(pid: number): boolean {
31
+ if (!Number.isInteger(pid) || pid <= 0) return false;
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ } catch (error) {
36
+ return getErrorCode(error) === "EPERM";
37
+ }
38
+ }
39
+
40
+ export function getNumber(value: unknown): number | undefined {
41
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
42
+ }
43
+
44
+ export function getNonNegativeNumber(record: UnknownRecord, key: string): number | undefined {
45
+ const value = getNumber(record[key]);
46
+ return value !== undefined && value >= 0 ? value : undefined;
47
+ }
48
+
49
+ export type MutableEnv = Record<string, string | undefined>;
@@ -0,0 +1,320 @@
1
+ import { type PiModel, isNonEmptyString, lower } from "./common.ts";
2
+ import { isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./model-detect.ts";
3
+ import { getCompat, isDeepSeekLikeModel, isKimiCodingEmptySignatureModel, isKnownThirdPartyOpenAIEndpoint, isOpenAICompatibleProxyApi, isOpenAIFamilyModel, isPiBuiltInLlamaCppModel, isRoutedFallbackModel } from "./model-identity.ts";
4
+ import { MODELS_JSON_PATH, STATE_DIR } from "./paths.ts";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ /** Join display-only path fragments without resolving them for I/O. */
10
+ export function joinDisplayPath(base: string, child: string, platform: string = process.platform): string {
11
+ const sep = platform.startsWith("win") ? "\\" : "/";
12
+ return `${base.replace(/[\\/]+$/, "")}${sep}${child}`;
13
+ }
14
+
15
+ /**
16
+ * Return a platform-friendly display path for Pi's agent directory.
17
+ *
18
+ * Derives the display path from Pi core's `getAgentDir()` result. Home-relative
19
+ * paths use `%USERPROFILE%` on Windows and `~` on Unix-like systems; custom
20
+ * absolute or relative agent directories remain visible as configured.
21
+ */
22
+ export function getAgentDirDisplayPath(
23
+ platform: string = process.platform,
24
+ agentDir: string = getAgentDir(),
25
+ homeDir: string = homedir(),
26
+ ): string {
27
+ const sep = platform.startsWith("win") ? "\\" : "/";
28
+ const normalizedAgentDir = agentDir.replace(/[\\/]+/g, sep);
29
+ const normalizedHomeDir = homeDir.replace(/[\\/]+/g, sep).replace(/[\\/]+$/, "");
30
+ const homePrefix = `${normalizedHomeDir}${sep}`;
31
+
32
+ if (normalizedAgentDir === normalizedHomeDir || normalizedAgentDir.startsWith(homePrefix)) {
33
+ const relative = normalizedAgentDir.slice(normalizedHomeDir.length).replace(/^[\\/]+/, "");
34
+ const homeLabel = platform.startsWith("win") ? "%USERPROFILE%" : "~";
35
+ return relative ? `${homeLabel}${sep}${relative}` : homeLabel;
36
+ }
37
+
38
+ return normalizedAgentDir;
39
+ }
40
+
41
+ /**
42
+ * Return a platform-friendly display path for Pi's `models.json`.
43
+ *
44
+ * This is a DISPLAY helper only. Actual I/O uses `MODELS_JSON_PATH`, resolved
45
+ * from the same custom agent/config directory rules as `STATE_DIR`.
46
+ */
47
+ export function getModelsJsonDisplayPath(
48
+ platform: string = process.platform,
49
+ agentDir: string = getAgentDir(),
50
+ homeDir: string = homedir(),
51
+ ): string {
52
+ return joinDisplayPath(getAgentDirDisplayPath(platform, agentDir, homeDir), "models.json", platform);
53
+ }
54
+
55
+ export function isAdaptiveThinkingCompatApplicable(model: PiModel): boolean {
56
+ // A routed registry miss may preserve the upstream API/model identity while
57
+ // lacking a verified endpoint. Do not diagnose or suggest compat fixes for
58
+ // that incomplete transport metadata; ordinary direct Anthropic models still
59
+ // use their normal native adaptive-thinking check.
60
+ if (isRoutedFallbackModel(model) && !isNonEmptyString(model.baseUrl)) {
61
+ return false;
62
+ }
63
+
64
+ return lower(model.api) === "anthropic-messages"
65
+ && (isAdaptiveGenerationModel(model) || isKimiCodingAdaptiveModel(model));
66
+ }
67
+
68
+ export function describeMissingAdaptiveThinkingCompat(model: PiModel): string[] {
69
+ const compat = getCompat(model);
70
+ const missing: string[] = [];
71
+ if (compat.forceAdaptiveThinking !== true) {
72
+ missing.push("forceAdaptiveThinking");
73
+ }
74
+ if (isKimiCodingEmptySignatureModel(model) && compat.allowEmptySignature !== true) {
75
+ missing.push("allowEmptySignature");
76
+ }
77
+ return missing;
78
+ }
79
+
80
+ /**
81
+ * Like describeMissingOpenAIFamilyProxyCompat but without the isOpenAIFamilyModel
82
+ * gate. Warns for ANY model using openai-completions through a non-official base
83
+ * URL — covers GPT, Kimi, Qwen, GLM, MiniMax, Mimo, Hunyuan, and any other
84
+ * OpenAI-compatible proxy.
85
+ */
86
+ export function describeMissingOpenAICompatibleProxyCompat(model: PiModel): string[] {
87
+ const compat = getCompat(model);
88
+ const missing: string[] = [];
89
+
90
+ if (!isOpenAICompatibleProxyApi(model.api)) return missing;
91
+ if (!isKnownThirdPartyOpenAIEndpoint(model)) return missing;
92
+ if (isPiBuiltInLlamaCppModel(model)) return missing;
93
+
94
+ if (compat.sendSessionAffinityHeaders === undefined) {
95
+ missing.push("sendSessionAffinityHeaders");
96
+ }
97
+
98
+ // Explicit `sendSessionAffinityHeaders: false` is a valid safe opt-out for
99
+ // proxies/CDNs/WAFs that block Pi's custom affinity headers with HTTP 403.
100
+ // Treat only a missing/undefined value as missing compat; do not mark an
101
+ // intentional false override as ⚠️ compat or let /cache-optimizer fix turn it
102
+ // back to true.
103
+ //
104
+ // NOTE: supportsLongCacheRetention is intentionally NOT checked here.
105
+ // Per spec, it is optional/risky advisory text only and must NOT trigger
106
+ // the ⚠️ compat marker. The before_provider_request hook proactively
107
+ // strips prompt_cache_retention for models without explicit opt-in,
108
+ // so 400 errors are prevented regardless of this compat flag.
109
+ // Doctor/compat may mention it as optional guidance separately.
110
+
111
+ return missing;
112
+ }
113
+
114
+ export function buildSafeOpenAIProxyCompatSuggestion(missing: string[]): Record<string, boolean> {
115
+ const suggestion: Record<string, boolean> = {};
116
+ if (missing.includes("sendSessionAffinityHeaders")) {
117
+ suggestion.sendSessionAffinityHeaders = true;
118
+ }
119
+ // supportsLongCacheRetention is NOT suggested here — per spec it is
120
+ // optional/risky and must not appear in the copyable safe snippet.
121
+ // The proactive stripping in before_provider_request handles 400 prevention.
122
+ return suggestion;
123
+ }
124
+
125
+ export type CompatAdvicePlacement = {
126
+ providerLabel?: string;
127
+ modelId?: string;
128
+ };
129
+
130
+ export function buildProviderCompatOverride(providerLabel: string, compat: Record<string, unknown>): Record<string, unknown> {
131
+ return {
132
+ providers: {
133
+ [providerLabel]: {
134
+ compat,
135
+ },
136
+ },
137
+ };
138
+ }
139
+
140
+ export function buildModelCompatOverride(providerLabel: string, modelId: string, compat: Record<string, unknown>): Record<string, unknown> {
141
+ return {
142
+ providers: {
143
+ [providerLabel]: {
144
+ modelOverrides: {
145
+ [modelId]: {
146
+ compat,
147
+ },
148
+ },
149
+ },
150
+ },
151
+ };
152
+ }
153
+
154
+ export function appendCredentialSafeProviderGuidance(lines: string[], placement: CompatAdvicePlacement, compatSuggestion: Record<string, unknown>): void {
155
+ const providerLabel = placement.providerLabel;
156
+ if (!providerLabel) return;
157
+
158
+ lines.push("");
159
+ lines.push("If this channel has no models.json provider entry yet:");
160
+ lines.push("- Keep existing authentication as-is; do not copy credentials, tokens, or API keys.");
161
+ lines.push(`- Add only cache/routing compat overrides in ${getModelsJsonDisplayPath()}.`);
162
+
163
+ if (Object.keys(compatSuggestion).length === 0) {
164
+ lines.push("- No safe copyable override is available for the missing flags shown above.");
165
+ return;
166
+ }
167
+
168
+ lines.push("Provider-level minimal override:");
169
+ lines.push(JSON.stringify(buildProviderCompatOverride(providerLabel, compatSuggestion), null, 2));
170
+
171
+ if (placement.modelId) {
172
+ lines.push("Single-model override (use this if only this model should change):");
173
+ lines.push(JSON.stringify(buildModelCompatOverride(providerLabel, placement.modelId, compatSuggestion), null, 2));
174
+ }
175
+ }
176
+
177
+ export function appendOpenAIProxyCompatAdviceLines(lines: string[], missing: string[], options: { includeJsonIntro?: boolean } & CompatAdvicePlacement = {}): void {
178
+ const suggestion = buildSafeOpenAIProxyCompatSuggestion(missing);
179
+ const hasSafeSuggestion = Object.keys(suggestion).length > 0;
180
+
181
+ if (hasSafeSuggestion) {
182
+ if (options.includeJsonIntro !== false) {
183
+ lines.push("Safe default suggestion:");
184
+ }
185
+ lines.push(JSON.stringify(suggestion, null, 2));
186
+ }
187
+
188
+ if (missing.includes("sendSessionAffinityHeaders")) {
189
+ lines.push("- sendSessionAffinityHeaders: recommended for third-party proxies when supported; it helps keep one Pi session on the same upstream/backend.");
190
+ }
191
+ appendCredentialSafeProviderGuidance(lines, options, suggestion);
192
+ }
193
+
194
+ /**
195
+ * Build the warning text displayed to users when an OpenAI-family third-party
196
+ * proxy is missing one or more cache/session-affinity compat flags.
197
+ *
198
+ * The returned string contains a parseable JSON object (via JSON.stringify)
199
+ * listing only the missing flags with recommended value `true`. Inline
200
+ * explanations for each flag follow the JSON snippet as separate prose lines,
201
+ * so the JSON remains valid and copyable.
202
+ *
203
+ * Expected use: the openai adapter's warningText calls this function; tests
204
+ * exercise it via __internals_for_tests.
205
+ */
206
+ export function buildOpenAIProxyCompatWarningText(key: string, missing: string[]): string {
207
+ // Extract provider id from the model key (e.g. "otokapi/gpt-5.5" -> "otokapi").
208
+ // If no slash is found, fall back to the key itself.
209
+ const slashIdx = key.indexOf("/");
210
+ const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
211
+ const modelId = slashIdx > 0 ? key.slice(slashIdx + 1) : undefined;
212
+
213
+ const modelsJsonPath = getModelsJsonDisplayPath();
214
+ const lines: string[] = [
215
+ `💡 pi-cache-optimizer: ${key} is a third-party OpenAI-compatible proxy but merged compat lacks ${missing.join(" and ")}.`,
216
+ `Run /cache-optimizer fix to preview a confirmed repair (or edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat manually).`,
217
+ ``,
218
+ ];
219
+
220
+ appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId });
221
+
222
+ return lines.join("\n");
223
+ }
224
+
225
+ /**
226
+ * DeepSeek's model family and its reasoning wire protocol are separate facts.
227
+ * The family name selects the cache adapter, but only an explicit effective
228
+ * `thinkingFormat: "deepseek"` opts a third-party OpenAI Completions model into
229
+ * the reasoning/replay compat checks below. Model names, provider ids, URLs,
230
+ * and supportsReasoningEffort are not protocol evidence.
231
+ */
232
+ export function isDeepSeekWireCompatApplicable(model: PiModel): boolean {
233
+ // `thinkingFormat` is the only wire-protocol signal. The model family is
234
+ // still name/id based for adapter selection, while provider, URL, and
235
+ // supportsReasoningEffort remain deliberately irrelevant here.
236
+ return isDeepSeekLikeModel(model)
237
+ && isOpenAICompatibleProxyApi(model.api)
238
+ && getCompat(model).thinkingFormat === "deepseek";
239
+ }
240
+
241
+ export function describeMissingDeepSeekCompat(model: PiModel): string[] {
242
+ if (!isDeepSeekWireCompatApplicable(model)) return [];
243
+
244
+ const compat = getCompat(model);
245
+ const missing: string[] = [];
246
+ if (compat.requiresReasoningContentOnAssistantMessages !== true) {
247
+ missing.push("requiresReasoningContentOnAssistantMessages");
248
+ }
249
+ return missing;
250
+ }
251
+
252
+ export function describeMissingCacheCompatForModel(model: PiModel): string[] {
253
+ if (isAdaptiveThinkingCompatApplicable(model)) {
254
+ return describeMissingAdaptiveThinkingCompat(model);
255
+ }
256
+
257
+ const missing = describeMissingOpenAICompatibleProxyCompat(model);
258
+ if (isDeepSeekWireCompatApplicable(model)) {
259
+ missing.push(...describeMissingDeepSeekCompat(model));
260
+ }
261
+ return missing;
262
+ }
263
+
264
+ export function buildDeepSeekCompatSuggestion(missing: string[]): Record<string, unknown> {
265
+ const suggestion: Record<string, unknown> = {
266
+ ...buildSafeOpenAIProxyCompatSuggestion(missing),
267
+ };
268
+ if (missing.includes("requiresReasoningContentOnAssistantMessages")) {
269
+ suggestion.requiresReasoningContentOnAssistantMessages = true;
270
+ }
271
+ return suggestion;
272
+ }
273
+
274
+ export function appendDeepSeekCompatAdviceLines(lines: string[], missing: string[], placement: CompatAdvicePlacement = {}): void {
275
+ const suggestion = buildDeepSeekCompatSuggestion(missing);
276
+ if (Object.keys(suggestion).length > 0) {
277
+ lines.push("Recommended DeepSeek reasoning/replay compat snippet:");
278
+ lines.push(JSON.stringify(suggestion, null, 2));
279
+ }
280
+
281
+ if (missing.includes("requiresReasoningContentOnAssistantMessages")) {
282
+ lines.push('- requiresReasoningContentOnAssistantMessages: true keeps replayed assistant turns compatible with an explicitly selected DeepSeek reasoning wire format.');
283
+ }
284
+ if (missing.includes("sendSessionAffinityHeaders")) {
285
+ lines.push("- sendSessionAffinityHeaders: recommended for third-party OpenAI-compatible proxies when supported; it helps keep one Pi session on the same upstream/backend.");
286
+ }
287
+
288
+ appendCredentialSafeProviderGuidance(lines, placement, suggestion);
289
+ }
290
+
291
+ export function buildDeepSeekCompatWarningText(key: string, missing: string[]): string {
292
+ const slashIdx = key.indexOf("/");
293
+ const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
294
+ const modelId = slashIdx > 0 ? key.slice(slashIdx + 1) : undefined;
295
+ const modelsJsonPath = getModelsJsonDisplayPath();
296
+ const lines: string[] = [
297
+ `💡 pi-cache-optimizer: ${key} is DeepSeek-like but merged compat lacks ${missing.join(" and ")}.`,
298
+ `Proxies may reduce or hide cache hits. Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat (at the same level as baseUrl/api/apiKey/models).`,
299
+ "",
300
+ ];
301
+
302
+ appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId });
303
+
304
+ return lines.join("\n");
305
+ }
306
+
307
+ export function describeMissingOpenAIFamilyProxyCompat(model: PiModel): string[] {
308
+ const compat = getCompat(model);
309
+ const missing: string[] = [];
310
+
311
+ if (!isOpenAIFamilyModel(model)) return missing;
312
+ if (!isOpenAICompatibleProxyApi(model.api)) return missing;
313
+ if (!isKnownThirdPartyOpenAIEndpoint(model)) return missing;
314
+
315
+ if (compat.sendSessionAffinityHeaders !== true) {
316
+ missing.push("sendSessionAffinityHeaders");
317
+ }
318
+
319
+ return missing;
320
+ }
@@ -0,0 +1,99 @@
1
+ import { type PiModel, type UnknownRecord, asRecord, lower } from "./common.ts";
2
+
3
+ export type CacheCompat = {
4
+ supportsStore?: boolean;
5
+ supportsDeveloperRole?: boolean;
6
+ supportsReasoningEffort?: boolean;
7
+ supportsUsageInStreaming?: boolean;
8
+ supportsStrictMode?: boolean;
9
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
10
+ sendSessionAffinityHeaders?: boolean;
11
+ sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter";
12
+ supportsLongCacheRetention?: boolean;
13
+ thinkingFormat?: string;
14
+ requiresReasoningContentOnAssistantMessages?: boolean;
15
+ cacheControlFormat?: string;
16
+ forceAdaptiveThinking?: boolean;
17
+ allowEmptySignature?: boolean;
18
+ openRouterRouting?: UnknownRecord;
19
+ vercelGatewayRouting?: UnknownRecord;
20
+ chatTemplateKwargs?: UnknownRecord;
21
+ chatTemplateArgs?: UnknownRecord;
22
+ };
23
+
24
+ /**
25
+ * Get effective compat for a model by merging provider-level and model-level compat.
26
+ * Model-level compat takes precedence over provider-level compat for overlapping keys.
27
+ * This matches Pi's model-registry.js mergeCompat behavior.
28
+ */
29
+ export const NESTED_COMPAT_KEYS = [
30
+ "openRouterRouting",
31
+ "vercelGatewayRouting",
32
+ "chatTemplateKwargs",
33
+ "chatTemplateArgs",
34
+ ] as const;
35
+
36
+ export function findLastExactModelDefinition(
37
+ models: unknown[] | undefined,
38
+ modelId: string,
39
+ ): UnknownRecord | undefined {
40
+ if (!models) return undefined;
41
+ // Pi's provider composer processes definitions in array order. If a malformed
42
+ // or hand-edited config repeats an id, the last definition replaces earlier
43
+ // definitions, so effective resolution must use the same deterministic rule.
44
+ for (let index = models.length - 1; index >= 0; index--) {
45
+ const candidate = asRecord(models[index]);
46
+ if (candidate?.id === modelId) return candidate;
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ export function mergeCacheCompat(...sources: Array<UnknownRecord | undefined>): CacheCompat {
52
+ const merged: CacheCompat = {};
53
+ for (const source of sources) {
54
+ if (!source) continue;
55
+ const previousNested = Object.fromEntries(
56
+ NESTED_COMPAT_KEYS.map((key) => [key, asRecord(merged[key])]),
57
+ ) as Partial<Record<(typeof NESTED_COMPAT_KEYS)[number], UnknownRecord | undefined>>;
58
+ Object.assign(merged, source);
59
+ for (const key of NESTED_COMPAT_KEYS) {
60
+ const baseValue = previousNested[key];
61
+ const overrideValue = asRecord(source[key]);
62
+ if (baseValue || overrideValue) {
63
+ merged[key] = { ...baseValue, ...overrideValue };
64
+ }
65
+ }
66
+ }
67
+ return merged;
68
+ }
69
+
70
+ export function getEffectiveCompatSources(model: PiModel, config: unknown): Array<{ source: "provider" | "model" | "runtime" | "modelOverride"; compat: UnknownRecord }> {
71
+ const root = asRecord(config);
72
+ const providers = asRecord(root?.providers);
73
+ const provider = asRecord(providers?.[model.provider]);
74
+ const providerCompat = asRecord(provider?.compat);
75
+ const customModel = findLastExactModelDefinition(
76
+ Array.isArray(provider?.models) ? provider.models : undefined,
77
+ model.id,
78
+ );
79
+ const customModelCompat = asRecord(asRecord(customModel)?.compat);
80
+ const runtimeCompat = asRecord(model.compat);
81
+ const modelOverride = asRecord(asRecord(provider?.modelOverrides)?.[model.id]);
82
+ const modelOverrideCompat = asRecord(modelOverride?.compat);
83
+
84
+ return [
85
+ ...(providerCompat ? [{ source: "provider" as const, compat: providerCompat }] : []),
86
+ ...(customModelCompat ? [{ source: "model" as const, compat: customModelCompat }] : []),
87
+ ...(runtimeCompat ? [{ source: "runtime" as const, compat: runtimeCompat }] : []),
88
+ ...(modelOverrideCompat ? [{ source: "modelOverride" as const, compat: modelOverrideCompat }] : []),
89
+ ];
90
+ }
91
+
92
+ export function resolveEffectiveCompatFromConfig(model: PiModel, config: unknown): CacheCompat {
93
+ // Pi's effective precedence is provider → models[] → runtime model →
94
+ // modelOverrides. The runtime layer is intentionally included between the
95
+ // config model and modelOverride: extension providers can replace the model
96
+ // object after lower config layers were applied, while the override remains
97
+ // Pi's highest-precedence user layer.
98
+ return mergeCacheCompat(...getEffectiveCompatSources(model, config).map(({ compat }) => compat));
99
+ }