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.
@@ -0,0 +1,757 @@
1
+ import { join } from "node:path";
2
+ import { type CacheProviderAdapter } from "./adapters.ts";
3
+ import { type PiModel, type UnknownRecord, asRecord, isNonEmptyString, lower } from "./common.ts";
4
+ import { type CompatAdvicePlacement, appendCredentialSafeProviderGuidance, appendDeepSeekCompatAdviceLines, appendOpenAIProxyCompatAdviceLines, buildDeepSeekCompatSuggestion, buildSafeOpenAIProxyCompatSuggestion, describeMissingCacheCompatForModel, getModelsJsonDisplayPath, isAdaptiveThinkingCompatApplicable, isDeepSeekWireCompatApplicable } from "./compat-advice.ts";
5
+ import { type FixSuggestion } from "./fix-types.ts";
6
+ import { getCompat, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOpenAICompatibleProxyApi, isPiBuiltInLlamaCppModel, modelKey } from "./model-identity.ts";
7
+ import { type CacheUsageSample, formatRecentTrendSummary } from "./stats-report.ts";
8
+ import { type CacheStats, emptyCacheStats } from "./stats-store.ts";
9
+ import { snapshotBaseUrlForDiagnostics } from "./request-state.ts";
10
+
11
+ export function buildAdaptiveThinkingCompatSuggestion(missing: string[]): Record<string, unknown> {
12
+ const suggestion: Record<string, unknown> = {};
13
+ if (missing.includes("forceAdaptiveThinking")) {
14
+ suggestion.forceAdaptiveThinking = true;
15
+ }
16
+ if (missing.includes("allowEmptySignature")) {
17
+ suggestion.allowEmptySignature = true;
18
+ }
19
+ return suggestion;
20
+ }
21
+
22
+ export function appendAdaptiveThinkingCompatAdviceLines(lines: string[], missing: string[], placement: CompatAdvicePlacement = {}): void {
23
+ const suggestion = buildAdaptiveThinkingCompatSuggestion(missing);
24
+ if (Object.keys(suggestion).length > 0) {
25
+ lines.push("Suggested fix:");
26
+ lines.push(JSON.stringify(suggestion, null, 2));
27
+ }
28
+ lines.push("- forceAdaptiveThinking: true tells Pi to use adaptive thinking format");
29
+ lines.push(" (thinking: {type: 'adaptive'}) instead of legacy budget tokens format.");
30
+ lines.push(" Without this flag, Pi sends legacy thinking which adaptive-only upstreams reject.");
31
+ if (missing.includes("allowEmptySignature")) {
32
+ lines.push("- allowEmptySignature: true preserves Kimi Coding K3 thinking blocks whose replay signature is empty.");
33
+ }
34
+ appendCredentialSafeProviderGuidance(lines, placement, suggestion);
35
+ }
36
+
37
+ export function describeOptionalOpenAICompatibleProxyCompat(model: PiModel): string[] {
38
+ const compat = getCompat(model);
39
+ const optional: string[] = [];
40
+
41
+ if (!isOpenAICompatibleProxyApi(model.api)) return optional;
42
+ if (!isKnownThirdPartyOpenAIEndpoint(model)) return optional;
43
+ if (isPiBuiltInLlamaCppModel(model)) return optional;
44
+
45
+ if (compat.supportsLongCacheRetention !== true) {
46
+ optional.push("supportsLongCacheRetention");
47
+ }
48
+
49
+ return optional;
50
+ }
51
+
52
+ export function getPromptCacheRetentionUnsupportedHint(): string {
53
+ return "If this channel returns `400 Unsupported parameter: prompt_cache_retention`, remove/avoid `supportsLongCacheRetention`; this extension does not write that field directly, but Pi may send it when long retention is requested and compat says the proxy supports it.";
54
+ }
55
+
56
+ export function isPromptCacheKeyUnsupportedApplicable(model: PiModel): boolean {
57
+ return isOpenAICompatibleApi(model.api);
58
+ }
59
+
60
+ export function getOptionalAssistantHttpStatus(record: UnknownRecord): number | undefined {
61
+ const readStatus = (value: unknown): number | undefined => {
62
+ if (typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599) return value;
63
+ if (typeof value === "string" && /^\d{3}$/.test(value.trim())) {
64
+ const parsed = Number(value.trim());
65
+ return parsed >= 100 && parsed <= 599 ? parsed : undefined;
66
+ }
67
+ return undefined;
68
+ };
69
+
70
+ for (const key of ["status", "statusCode", "httpStatus", "httpStatusCode"]) {
71
+ const status = readStatus(record[key]);
72
+ if (status !== undefined) return status;
73
+ }
74
+
75
+ // Foreign/provider adapters sometimes put the HTTP status under a
76
+ // diagnostic `details`, `error`, or `cause` object rather than exposing it
77
+ // on the finalized assistant message itself. Traverse only these known
78
+ // diagnostic wrappers, with a small depth bound; never inspect arbitrary
79
+ // payload/message fields for a number that happens to look like a status.
80
+ const scanDiagnosticStatus = (value: unknown, depth: number): number | undefined => {
81
+ if (depth > 2) return undefined;
82
+ if (Array.isArray(value)) {
83
+ for (const item of value) {
84
+ const status = scanDiagnosticStatus(item, depth + 1);
85
+ if (status !== undefined) return status;
86
+ }
87
+ return undefined;
88
+ }
89
+ const source = asRecord(value);
90
+ if (!source) return undefined;
91
+ for (const key of ["status", "statusCode", "httpStatus", "httpStatusCode", "code"]) {
92
+ const status = readStatus(source[key]);
93
+ if (status !== undefined) return status;
94
+ }
95
+ for (const key of ["details", "error", "cause", "diagnostics"]) {
96
+ const status = scanDiagnosticStatus(source[key], depth + 1);
97
+ if (status !== undefined) return status;
98
+ }
99
+ return undefined;
100
+ };
101
+ for (const key of ["diagnostics", "diagnostic", "details", "error", "cause"]) {
102
+ const status = scanDiagnosticStatus(record[key], 0);
103
+ if (status !== undefined) return status;
104
+ }
105
+
106
+ // Pi's built-in OpenAI-compatible adapters normally prefix a provider error
107
+ // body with the HTTP status (for example `400: {...}` or
108
+ // `OpenAI API error (400): ...`). Only parse status-shaped prefixes; never
109
+ // search arbitrary body text where a model id or payload number could be
110
+ // mistaken for a response status.
111
+ if (typeof record.errorMessage === "string") {
112
+ const errorText = record.errorMessage;
113
+ const matches = [
114
+ /^\s*([1-5]\d{2})\b/,
115
+ /^\s*(?:http(?:\s+status)?|status(?:\s+code)?)\s*[:(]?\s*([1-5]\d{2})\b/i,
116
+ /^\s*[^():\n]{1,100}\(\s*([1-5]\d{2})\b/,
117
+ /^\s*(?:error|api\s+error|provider\s+error|request\s+failed|http\s+error)\s*[:(]\s*([1-5]\d{2})\b/i,
118
+ ];
119
+ for (const pattern of matches) {
120
+ const match = pattern.exec(errorText);
121
+ if (match) return Number(match[1]);
122
+ }
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ export function appendOptionalOpenAIProxyCompatAdviceLines(lines: string[], optional: string[]): void {
128
+ if (!optional.includes("supportsLongCacheRetention")) return;
129
+ lines.push("");
130
+ lines.push("Optional (not required, not auto-fixed):");
131
+ lines.push("- supportsLongCacheRetention: enable only after your endpoint/proxy explicitly supports OpenAI long prompt cache retention.");
132
+ lines.push(`- ${getPromptCacheRetentionUnsupportedHint()}`);
133
+ }
134
+
135
+ /**
136
+ * Compatibility diagnostics for a DeepSeek-like model are generic unless the
137
+ * user has explicitly selected Pi's DeepSeek reasoning wire format. In
138
+ * particular, never manufacture `thinkingFormat: "deepseek"`: doing so can
139
+ * turn a valid reasoning_effort request into a provider-rejected `thinking`
140
+ * request (Issue #10).
141
+ */
142
+ export function isDeepSeekCompatCheckApplicable(model: PiModel): boolean {
143
+ return isDeepSeekWireCompatApplicable(model);
144
+ }
145
+
146
+ export function isCompatCheckApplicable(model: PiModel): boolean {
147
+ return isOpenAICompatibleProxyApi(model.api) && isKnownThirdPartyOpenAIEndpoint(model) && !isPiBuiltInLlamaCppModel(model);
148
+ }
149
+
150
+ export function isPromptCacheRetention400Applicable(model: PiModel): boolean {
151
+ return isOpenAICompatibleApi(model.api) &&
152
+ isKnownThirdPartyOpenAIEndpoint(model) &&
153
+ !isPiBuiltInLlamaCppModel(model) &&
154
+ getCompat(model).supportsLongCacheRetention === true;
155
+ }
156
+
157
+ /**
158
+ * Whether the 403 sendSessionAffinityHeaders diagnostic applies to a model.
159
+ *
160
+ * Pi's openai-completions adapter sends custom HTTP headers (session_id,
161
+ * x-client-request-id, x-session-affinity) when `sendSessionAffinityHeaders`
162
+ * is enabled. Some third-party proxies / CDNs / WAFs block these headers and
163
+ * return HTTP 403 "Your request was blocked". Pi 0.80.7+ no longer uses this
164
+ * flag for openai-responses; Responses header shape is controlled by
165
+ * `sessionAffinityFormat`. This guard therefore applies only to
166
+ * openai-completions, where doctor/fix can safely advise disabling the flag.
167
+ */
168
+ export function isSessionAffinity403Applicable(model: PiModel): boolean {
169
+ if (!isOpenAICompatibleProxyApi(model.api)) return false;
170
+ if (!isKnownThirdPartyOpenAIEndpoint(model)) return false;
171
+ if (isPiBuiltInLlamaCppModel(model)) return false;
172
+ return getCompat(model).sendSessionAffinityHeaders === true;
173
+ }
174
+
175
+ /**
176
+ * Whether an OpenAI SDK header/User-Agent 403 diagnostic applies.
177
+ *
178
+ * Some third-party OpenAI-compatible proxies/CDNs/WAFs allow a plain curl
179
+ * request but block the OpenAI JS SDK's default request fingerprint, especially
180
+ * its `User-Agent: OpenAI/JS ...` and `X-Stainless-*` headers. This is distinct
181
+ * from session-affinity header blocking: it applies when session-affinity
182
+ * headers are NOT enabled but the model still uses a non-official
183
+ * OpenAI-compatible proxy transport.
184
+ */
185
+ export function isOpenAISdkHeader403Applicable(model: PiModel): boolean {
186
+ if (!isOpenAICompatibleProxyApi(model.api)) return false;
187
+ if (!isKnownThirdPartyOpenAIEndpoint(model)) return false;
188
+ if (isPiBuiltInLlamaCppModel(model)) return false;
189
+ return getCompat(model).sendSessionAffinityHeaders !== true;
190
+ }
191
+
192
+ /**
193
+ * Detect router / channel profiles from a PiModel and return diagnostic notes.
194
+ *
195
+ * This function is advisory only — it does NOT participate in adapter selection,
196
+ * prompt_cache_key injection, or footer stats. It inspects provider, api, baseUrl,
197
+ * and compat to identify common proxy/router patterns where cache performance may
198
+ * be degraded due to multi-backend routing.
199
+ *
200
+ * Known profiles (checked in order):
201
+ * 1. OpenRouter — baseUrl or provider id matching openrouter.ai / openrouter
202
+ * 2. Vercel AI Gateway — baseUrl matching ai-gateway.vercel.sh, or provider
203
+ * matching vercel / vercel-ai-gateway
204
+ * 3. LiteLLM / OneAPI / NewAPI / VoAPI — baseUrl or provider matching litellm,
205
+ * oneapi, one-api, newapi, new-api, voapi, vo-api (self-hosted aggregation)
206
+ * 4. Generic third-party OpenAI-compatible proxy — any openai-completions model
207
+ * with a non-official base URL that does not match a higher-profile above.
208
+ *
209
+ * Official OpenAI (api.openai.com) and custom transports (kiro-api, anthropic-messages,
210
+ * bedrock-converse-stream) do NOT produce notes.
211
+ */
212
+ export function describeRouterChannelDiagnostics(model: PiModel): string[] {
213
+ const notes: string[] = [];
214
+ const api = lower(model.api);
215
+ const baseUrl = lower(model.baseUrl || "");
216
+ const provider = lower(model.provider);
217
+
218
+ // Router/channel diagnostics only apply to OpenAI-compatible proxy APIs.
219
+ // Native APIs like mistral-conversations, azure-openai-responses,
220
+ // anthropic-messages, or bedrock-converse-stream are intentionally excluded.
221
+ if (api === "azure-openai-responses" || isMistralConversationsApi(api) || !isOpenAICompatibleApi(api)) {
222
+ return notes;
223
+ }
224
+
225
+ // Unknown/default endpoints and official OpenAI are not diagnosable as
226
+ // third-party router channels. Request-header bridging also fails closed.
227
+ if (!isKnownThirdPartyOpenAIEndpoint(model)) {
228
+ return notes;
229
+ }
230
+
231
+ // Pi 0.81+ built-in llama.cpp uses an OpenAI-shaped transport, but its
232
+ // untouched explicit compat fingerprint does not expose proxy-routing or
233
+ // session-affinity configuration. Same-id overrides are not exempt.
234
+ if (isPiBuiltInLlamaCppModel(model)) {
235
+ return notes;
236
+ }
237
+
238
+ // ── 1. OpenRouter ────────────────────────────────────────────────
239
+ if (
240
+ baseUrl.includes("openrouter.ai") ||
241
+ baseUrl.includes("openrouter") ||
242
+ provider.includes("openrouter")
243
+ ) {
244
+ const compat = getCompat(model);
245
+ const routing = asRecord((compat as Record<string, unknown>)["openRouterRouting"]);
246
+ const hasOnly = !!routing?.only;
247
+ const hasOrder = !!routing?.order;
248
+
249
+ notes.push(
250
+ "🔀 Router/channel: OpenRouter detected. OpenRouter is a multi-provider router; " +
251
+ "low cache hit rates are common when each turn lands on a different upstream provider.",
252
+ );
253
+
254
+ if (!hasOnly && !hasOrder) {
255
+ notes.push(
256
+ " Suggestion: Add an openRouterRouting config to fix the upstream provider. " +
257
+ "Example for models.json -> providers[\"<providerId>\"] -> compat:",
258
+ );
259
+ notes.push(
260
+ ` { "sendSessionAffinityHeaders": true, "supportsLongCacheRetention": true, ` +
261
+ `"openRouterRouting": { "only": ["<provider-slug>"] } }`,
262
+ );
263
+ notes.push(
264
+ ' Replace <provider-slug> with the actual OpenRouter provider slug (e.g. "openai", "anthropic").',
265
+ );
266
+ notes.push(
267
+ " Alternatively, use openRouterRouting.order: [\"<provider-slug>\", \"...\"] for fallback order. " +
268
+ "Only set supportsLongCacheRetention if your upstream supports long cache retention.",
269
+ );
270
+ }
271
+
272
+ return notes;
273
+ }
274
+
275
+ // ── 2. Vercel AI Gateway ─────────────────────────────────────────
276
+ if (
277
+ baseUrl.includes("ai-gateway.vercel.sh") ||
278
+ provider.includes("vercel") ||
279
+ provider.includes("vercel-ai-gateway")
280
+ ) {
281
+ const compat = getCompat(model);
282
+ const routing = asRecord((compat as Record<string, unknown>)["vercelGatewayRouting"]);
283
+ const hasOnly = !!routing?.only;
284
+ const hasOrder = !!routing?.order;
285
+
286
+ notes.push(
287
+ "🔀 Router/channel: Vercel AI Gateway detected. The gateway may route to different " +
288
+ "provider endpoints per request, reducing cache locality.",
289
+ );
290
+
291
+ if (!hasOnly && !hasOrder) {
292
+ notes.push(
293
+ " Suggestion: Add a vercelGatewayRouting config to fix the upstream. " +
294
+ "Example for models.json -> providers[\"<providerId>\"] -> compat:",
295
+ );
296
+ notes.push(
297
+ ` { "sendSessionAffinityHeaders": true, "supportsLongCacheRetention": true, ` +
298
+ `"vercelGatewayRouting": { "only": ["<provider-id>"] } }`,
299
+ );
300
+ notes.push(
301
+ " Replace <provider-id> with the actual Vercel provider ID (e.g. \"openai\").",
302
+ );
303
+ notes.push(
304
+ " Only set supportsLongCacheRetention if your upstream supports it.",
305
+ );
306
+ }
307
+
308
+ return notes;
309
+ }
310
+
311
+ // ── 3. LiteLLM / OneAPI / NewAPI / VoAPI (self-hosted aggregation) ──
312
+ const aggregationPatterns = ["litellm", "oneapi", "one-api", "newapi", "new-api", "voapi", "vo-api"];
313
+ if (
314
+ aggregationPatterns.some((p) => baseUrl.includes(p)) ||
315
+ aggregationPatterns.some((p) => provider.includes(p))
316
+ ) {
317
+ notes.push(
318
+ "🔀 Router/channel: Self-hosted aggregation proxy detected (LiteLLM / OneAPI / NewAPI / VoAPI). " +
319
+ "These proxies route to multiple upstream accounts or instances, which can split the cache.",
320
+ );
321
+ notes.push(
322
+ " Suggestions:",
323
+ );
324
+ notes.push(
325
+ " â€ĸ Ensure the proxy can fix to a single upstream per session (session_id affinity).",
326
+ );
327
+ notes.push(
328
+ " â€ĸ Forward prompt_cache_key and session-affinity headers to the upstream.",
329
+ );
330
+ notes.push(
331
+ " â€ĸ Return cache usage fields (prompt_cache_hit_tokens, etc.) in the response.",
332
+ );
333
+ notes.push(
334
+ ` Safe compat default: { "sendSessionAffinityHeaders": true }`,
335
+ );
336
+ notes.push(
337
+ ` Add supportsLongCacheRetention only if the proxy explicitly supports prompt_cache_retention.`,
338
+ );
339
+
340
+ return notes;
341
+ }
342
+
343
+ // ── 4. Generic third-party OpenAI-compatible proxy ─────────────────
344
+ if (api === "openai-completions" && baseUrl) {
345
+ const missing = describeMissingCacheCompatForModel(model);
346
+ notes.push(
347
+ "🔀 Router/channel: Third-party OpenAI-compatible proxy. If cache hit rates are low:",
348
+ );
349
+ notes.push(
350
+ " â€ĸ Verify the proxy routes to the same upstream account/instance per session.",
351
+ );
352
+ notes.push(
353
+ " â€ĸ Ensure the proxy forwards prompt_cache_key and sends session-affinity headers.",
354
+ );
355
+ notes.push(
356
+ " â€ĸ Check that the proxy returns cache usage fields (prompt_cache_hit_tokens etc.).",
357
+ );
358
+ if (missing.length > 0) {
359
+ notes.push(
360
+ ` â€ĸ The compat flags above (${missing.join(", ")}) are recommended for cache stability.`,
361
+ );
362
+ }
363
+
364
+ return notes;
365
+ }
366
+
367
+ return notes;
368
+ }
369
+
370
+ export function getCompatCheckNotApplicableLines(model: PiModel): string[] {
371
+ const api = lower(model.api);
372
+
373
+ if (isNativeVirtualModel(model)) {
374
+ return [
375
+ "â„šī¸ Compat check not applicable for this model.",
376
+ " Native virtual model: Pi routes each request to a physical model and none has answered on this session branch yet. Send a prompt, then rerun to diagnose the physical model that answered.",
377
+ ];
378
+ }
379
+
380
+ if (isMistralConversationsApi(api)) {
381
+ return [
382
+ "â„šī¸ Compat check not applicable for this model.",
383
+ " Native Mistral `mistral-conversations` uses provider-native transport; OpenAI-compatible proxy compat flags do not apply.",
384
+ ];
385
+ }
386
+
387
+ if (api === "azure-openai-responses") {
388
+ return [
389
+ "â„šī¸ Compat check not applicable for this model.",
390
+ " Native Azure OpenAI Responses uses the Responses transport; OpenAI-compatible proxy compat flags do not apply.",
391
+ ];
392
+ }
393
+
394
+ if (api === "openai-codex-responses" || (api === "openai-responses" && isOfficialOpenAIBaseUrl(model))) {
395
+ return [
396
+ "â„šī¸ Compat check not applicable for this model.",
397
+ " Native Responses transports already use Pi core request handling; OpenAI-compatible proxy compat flags do not apply.",
398
+ ];
399
+ }
400
+
401
+ if (isOpenAICompatibleApi(api) && !isNonEmptyString(model.baseUrl)) {
402
+ return [
403
+ "â„šī¸ Compat check not applicable for this model.",
404
+ " Upstream endpoint metadata is unavailable; session-affinity header injection is disabled.",
405
+ ];
406
+ }
407
+
408
+ return ["â„šī¸ Compat check not applicable for this model."];
409
+ }
410
+
411
+ export function buildDoctorDiagnosis(model: PiModel, options: { promptCacheRetention400?: boolean; promptCacheKey400?: boolean; anthropicTtlOrderError?: boolean; sessionAffinity403?: boolean; openAISdkHeader403?: boolean } = {}): string {
412
+ const lines: string[] = [];
413
+ lines.push(`Provider: ${model.provider}`);
414
+ lines.push(`Model: ${model.id}`);
415
+ if (model.name && model.name !== model.id) lines.push(`Name: ${model.name}`);
416
+ lines.push(`API: ${model.api}`);
417
+ const endpoint = snapshotBaseUrlForDiagnostics(model.baseUrl);
418
+ lines.push(`Base URL: ${endpoint || (isNonEmptyString(model.baseUrl) ? "(unavailable)" : "(default)")}`);
419
+
420
+ const compat = getCompat(model);
421
+ lines.push(`Compat: ${JSON.stringify(compat)}`);
422
+
423
+ const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
424
+ const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
425
+ const missing = describeMissingCacheCompatForModel(model);
426
+ const optionalOpenAIProxyCompat = !adaptiveThinkingApplicable
427
+ ? describeOptionalOpenAICompatibleProxyCompat(model)
428
+ : [];
429
+ const fixSug = buildFixSuggestion(model);
430
+ const safeFixableMissing = fixSug ? Object.keys(fixSug.compatKeys) : [];
431
+ const advisoryMissing = missing.filter(m => !safeFixableMissing.includes(m));
432
+
433
+ if (safeFixableMissing.length > 0) {
434
+ lines.push(`âš ī¸ Missing compat flags: ${safeFixableMissing.join(", ")}`);
435
+ }
436
+ if (advisoryMissing.length > 0) {
437
+ lines.push(`â„šī¸ Optional: ${advisoryMissing.join(", ")} (enable only if needed)`);
438
+ }
439
+
440
+ if (missing.length > 0) {
441
+ const key = modelKey(model);
442
+ const slashIdx = key.indexOf("/");
443
+ const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
444
+ const modelsJsonPath = getModelsJsonDisplayPath();
445
+ lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat (same level as baseUrl/api/apiKey/models).`);
446
+ if (adaptiveThinkingApplicable) {
447
+ appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
448
+ } else if (deepSeekCompatApplicable) {
449
+ appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
450
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
451
+ } else {
452
+ appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
453
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
454
+ }
455
+ } else if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
456
+ lines.push("✅ Compat fully configured.");
457
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
458
+ } else {
459
+ lines.push(...getCompatCheckNotApplicableLines(model));
460
+ }
461
+
462
+ if (options.promptCacheKey400 && isPromptCacheKeyUnsupportedApplicable(model)) {
463
+ lines.push("");
464
+ lines.push("âš ī¸ This model previously rejected prompt_cache_key with an explicit HTTP 400 signal.");
465
+ lines.push(" /cache-optimizer fix will offer a precise model-scoped opt-out.");
466
+ lines.push(" The opt-out removes both prompt_cache_key and promptCacheKey from the final request body.");
467
+ }
468
+
469
+ if (isPromptCacheRetention400Applicable(model)) {
470
+ lines.push("");
471
+ if (options.promptCacheRetention400) {
472
+ lines.push("âš ī¸ A 400 response was observed while supportsLongCacheRetention is enabled.");
473
+ lines.push(` ${getPromptCacheRetentionUnsupportedHint()}`);
474
+ } else {
475
+ lines.push(`â„šī¸ Long retention is enabled. ${getPromptCacheRetentionUnsupportedHint()}`);
476
+ }
477
+ }
478
+
479
+ if (options.anthropicTtlOrderError) {
480
+ lines.push("");
481
+ lines.push("âš ī¸ An Anthropic cache-control TTL ordering error was observed for this model.");
482
+ lines.push(" Runtime requests now fall back from 1h to the default 5-minute cache TTL.");
483
+ lines.push(` Run /cache-optimizer fix to set model-level supportsLongCacheRetention: false in ${getModelsJsonDisplayPath()}.`);
484
+ }
485
+
486
+ // ── Session affinity 403 diagnostics ──
487
+ // Advises the user when sendSessionAffinityHeaders is enabled, since some
488
+ // third-party proxies / CDNs / WAFs block Pi's custom session-affinity HTTP
489
+ // headers (session_id, x-client-request-id, x-session-affinity) and return
490
+ // 403. If a 403 was already observed for this model, show a stronger hint.
491
+ if (isSessionAffinity403Applicable(model)) {
492
+ lines.push("");
493
+ if (options.sessionAffinity403) {
494
+ lines.push("âš ī¸ A 403 response was observed while sendSessionAffinityHeaders is enabled.");
495
+ lines.push(" The proxy/CDN likely blocks Pi's custom session-affinity headers (session_id,");
496
+ lines.push(" x-client-request-id, x-session-affinity). Run /cache-optimizer fix to");
497
+ lines.push(` set sendSessionAffinityHeaders: false in ${getModelsJsonDisplayPath()}.`);
498
+ } else {
499
+ lines.push("â„šī¸ Session affinity headers are enabled. Some CDNs/WAFs block custom headers");
500
+ lines.push(" (session_id, x-client-request-id, x-session-affinity) and return 403. If you");
501
+ lines.push(" see 403 errors, run /cache-optimizer fix to set sendSessionAffinityHeaders: false.");
502
+ }
503
+ } else if (isOpenAISdkHeader403Applicable(model)) {
504
+ lines.push("");
505
+ if (options.openAISdkHeader403) {
506
+ lines.push("âš ī¸ A 403 response was observed while sendSessionAffinityHeaders is not enabled.");
507
+ lines.push(" The proxy/CDN may be blocking the OpenAI JS SDK request fingerprint");
508
+ lines.push(" (for example User-Agent: OpenAI/JS ... or X-Stainless-* headers). This");
509
+ lines.push(" is provider/WAF-specific; /cache-optimizer fix will not auto-write headers.");
510
+ lines.push(` Manual workaround: add a provider-level headers.User-Agent override in ${getModelsJsonDisplayPath()}`);
511
+ lines.push(" only after testing the value with the affected provider.");
512
+ } else {
513
+ lines.push("â„šī¸ If 403 persists after disabling sendSessionAffinityHeaders, some CDNs/WAFs");
514
+ lines.push(" may block the OpenAI JS SDK User-Agent / X-Stainless-* headers. Test the");
515
+ lines.push(" provider manually before adding a provider-level headers.User-Agent override.");
516
+ }
517
+ }
518
+
519
+ // ── Router/channel diagnostics ──
520
+ const routerNotes = describeRouterChannelDiagnostics(model);
521
+ if (routerNotes.length > 0) {
522
+ lines.push("");
523
+ for (const note of routerNotes) {
524
+ lines.push(note);
525
+ }
526
+ }
527
+
528
+ return lines.join("\n");
529
+ }
530
+
531
+ /**
532
+ * Build a "Cache diagnosis" section for low-hit causes, appended to doctor output.
533
+ * This is a separate function because it depends on per-session state (recent samples,
534
+ * per-model stats) that is not available at the module level.
535
+ */
536
+ export function buildLowHitDiagnosis(
537
+ model: PiModel,
538
+ adapter: CacheProviderAdapter | undefined,
539
+ stats: CacheStats | undefined,
540
+ samples: CacheUsageSample[],
541
+ ): string[] {
542
+ const lines: string[] = [];
543
+
544
+ // 1. Missing compat flags (adapter-aware: DeepSeek has extra reasoning compat)
545
+ const fixSugLHD = buildFixSuggestion(model);
546
+ const safeFixableMissingLHD = fixSugLHD ? Object.keys(fixSugLHD.compatKeys) : [];
547
+
548
+ // 2. Router/channel risk (reuse existing check)
549
+ const routerNotes = describeRouterChannelDiagnostics(model);
550
+
551
+ // 3. Recent samples missing usage fields
552
+ const missingUsageSamples = samples.filter((s) => s.missingUsageFields).length;
553
+
554
+ // 4. Recent trend analysis
555
+ const recent10 = samples.slice(-10);
556
+ const recent10Hits = recent10.filter((s) => s.hit).length;
557
+ const recent10Total = recent10.length;
558
+ const recent10Cached = recent10.reduce((sum, s) => sum + s.cachedInputTokens, 0);
559
+ const recent10Input = recent10.reduce((sum, s) => sum + s.totalInputTokens, 0);
560
+
561
+ // 5. Today's overall trend from persisted stats
562
+ const todayStats = stats ?? emptyCacheStats();
563
+
564
+ const hasMissingCompat = safeFixableMissingLHD.length > 0;
565
+ const hasRouterRisk = routerNotes.length > 0;
566
+ const hasUsageMissing = missingUsageSamples > 0;
567
+
568
+ // Today's cached-token ratio is used both inside and outside the recent-sample
569
+ // branch. Keep it block-external so doctor/stats never throw for low-hit
570
+ // models that have persisted counters but no recent in-memory samples.
571
+ const todayHitRatio = todayStats.totalInputTokens > 0
572
+ ? Math.round((todayStats.cachedInputTokens / todayStats.totalInputTokens) * 100)
573
+ : 0;
574
+
575
+ // Determine if there are actual issues worth flagging
576
+ const hasActualIssues = hasMissingCompat || hasUsageMissing ||
577
+ // Low hit trend (today total > 3 and hit ratio < 30%)
578
+ (todayStats.totalRequests > 3 && todayStats.totalInputTokens > 0 &&
579
+ (todayStats.cachedInputTokens / todayStats.totalInputTokens) < 0.3) ||
580
+ // Low hit rate in recent samples (recent10Total >= 3 and all misses)
581
+ (recent10Total >= 3 && recent10Hits === 0);
582
+
583
+ // Skip section if no issues
584
+ if (!hasActualIssues && !(hasRouterRisk && (hasMissingCompat || hasUsageMissing))) {
585
+ return lines;
586
+ }
587
+
588
+ lines.push("");
589
+ lines.push("── Cache diagnosis ──");
590
+
591
+ // Priority 1: missing compat flags
592
+ if (hasMissingCompat) {
593
+ lines.push(`âš ī¸ Missing compat flags: ${safeFixableMissingLHD.join(", ")}`);
594
+ lines.push(" These flags enable prompt caching and session-affinity routing.");
595
+ lines.push(" Run /cache-optimizer compat for edit instructions.");
596
+ }
597
+
598
+ // Priority 2: router/channel risk (only flag when there are other issues)
599
+ // Router notes are already shown in the main doctor output, so we only
600
+ // mention them in the diagnosis section when they compound a problem.
601
+ if (hasRouterRisk && (hasMissingCompat || hasUsageMissing || hasActualIssues)) {
602
+ lines.push("🔀 Router/channel proxy detected — see routing notes above.");
603
+ }
604
+
605
+ // Priority 3: usage fields missing
606
+ if (hasUsageMissing) {
607
+ lines.push(`âš ī¸ ${missingUsageSamples}/${samples.length} recent responses had missing/empty usage fields.`);
608
+ lines.push(" Footer may under-report cache hit rate.");
609
+ lines.push(" Verify the proxy returns prompt-level usage (prompt_tokens, input_tokens_details).");
610
+ }
611
+
612
+ // Priority 4: recent trend low
613
+ if (recent10Total > 0) {
614
+ const hitRatio = recent10Input > 0 ? Math.round((recent10Cached / recent10Input) * 100) : 0;
615
+ if (recent10Hits === 0 && todayStats.totalRequests > 3 && todayHitRatio < 30) {
616
+ lines.push(`📉 Cache hit rate is low: ${todayHitRatio}% today (${recent10Total} recent samples).`);
617
+ lines.push(" Likely causes: proxy routing to different backends per request,");
618
+ lines.push(" or prompt prefix changes across turns.");
619
+ lines.push(" Verify session affinity (sendSessionAffinityHeaders) and long cache retention.");
620
+ } else if (todayHitRatio < 30 && todayStats.totalRequests > 3) {
621
+ lines.push(`📉 Cache hit rate is low: ${todayHitRatio}% today (${todayStats.totalRequests} total requests).`);
622
+ lines.push(" Check compat flags and proxy upstream routing.");
623
+ }
624
+
625
+ // Show brief trend summary if there are enough samples
626
+ if (recent10Total >= 3) {
627
+ const trend = formatRecentTrendSummary(samples, 10);
628
+ lines.push(`📊 ${trend}`);
629
+ }
630
+ }
631
+
632
+ // For fully configured but low hit models, emphasize sticky routing
633
+ if (!hasMissingCompat && !hasRouterRisk && todayStats.totalRequests > 3 && todayHitRatio < 30) {
634
+ lines.push("💡 Compat is configured but cache hit rate remains low.");
635
+ lines.push(" Possible causes:");
636
+ lines.push(" â€ĸ Proxy still routes to multiple backends — check session affinity on the proxy side.");
637
+ lines.push(" â€ĸ Prompt prefix varies per turn — check dynamic context in system prompt.");
638
+ lines.push(" â€ĸ Provider does not return cache usage fields — footer can't measure hits.");
639
+ }
640
+
641
+ return lines;
642
+ }
643
+
644
+ export function buildCompatDiagnosis(model: PiModel): string | undefined {
645
+ const missing = describeMissingCacheCompatForModel(model);
646
+ const fixSugC = buildFixSuggestion(model);
647
+ const safeFixableMissingC = fixSugC ? Object.keys(fixSugC.compatKeys) : [];
648
+ const advisoryMissingC = missing.filter(m => !safeFixableMissingC.includes(m));
649
+ const adaptiveThinkingApplicable = isAdaptiveThinkingCompatApplicable(model);
650
+ const deepSeekCompatApplicable = isDeepSeekCompatCheckApplicable(model);
651
+ const optionalOpenAIProxyCompat = !adaptiveThinkingApplicable
652
+ ? describeOptionalOpenAICompatibleProxyCompat(model)
653
+ : [];
654
+ const routerNotes = describeRouterChannelDiagnostics(model);
655
+
656
+ if (missing.length === 0 && routerNotes.length === 0 && optionalOpenAIProxyCompat.length === 0) return undefined;
657
+
658
+ const key = modelKey(model);
659
+ const lines: string[] = [];
660
+
661
+ if (missing.length > 0) {
662
+ const slashIdx = key.indexOf("/");
663
+ const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
664
+ const modelsJsonPath = getModelsJsonDisplayPath();
665
+ lines.push(`Active model: ${key}`);
666
+ if (safeFixableMissingC.length > 0) {
667
+ lines.push(`Safe-fixable: ${safeFixableMissingC.join(", ")}`);
668
+ }
669
+ if (advisoryMissingC.length > 0) {
670
+ lines.push(`Optional: ${advisoryMissingC.join(", ")} (enable only if needed)`);
671
+ }
672
+ lines.push("");
673
+ lines.push(`Edit ${modelsJsonPath} -> providers["${providerLabel}"] -> compat`);
674
+ lines.push(`(at the same level as baseUrl/api/apiKey/models).`);
675
+ if (adaptiveThinkingApplicable) {
676
+ appendAdaptiveThinkingCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
677
+ } else if (deepSeekCompatApplicable) {
678
+ appendDeepSeekCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
679
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
680
+ } else {
681
+ appendOpenAIProxyCompatAdviceLines(lines, missing, { providerLabel, modelId: model.id });
682
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
683
+ }
684
+ }
685
+
686
+ // When compat is fully configured but router/optional notes exist, prefix the status.
687
+ if ((routerNotes.length > 0 || optionalOpenAIProxyCompat.length > 0) && missing.length === 0) {
688
+ if (adaptiveThinkingApplicable || deepSeekCompatApplicable || isCompatCheckApplicable(model)) {
689
+ lines.push("✅ Compat fully configured.");
690
+ if (isPromptCacheRetention400Applicable(model)) {
691
+ lines.push(getPromptCacheRetentionUnsupportedHint());
692
+ }
693
+ // Advisory for 403 session-affinity header blocking (only when enabled),
694
+ // or OpenAI SDK header/User-Agent blocking after session affinity is disabled.
695
+ if (isSessionAffinity403Applicable(model)) {
696
+ lines.push(
697
+ "â„šī¸ Session affinity headers are enabled. If you see 403 \"blocked\" errors,",
698
+ " the proxy/CDN may be blocking Pi's custom headers. Set sendSessionAffinityHeaders: false.",
699
+ );
700
+ } else if (isOpenAISdkHeader403Applicable(model)) {
701
+ lines.push(
702
+ "â„šī¸ If 403 persists with sendSessionAffinityHeaders disabled, the proxy/CDN may",
703
+ " be blocking the OpenAI JS SDK User-Agent / X-Stainless-* headers. Test a",
704
+ " provider-level headers.User-Agent override manually; /fix does not auto-write it.",
705
+ );
706
+ }
707
+ appendOptionalOpenAIProxyCompatAdviceLines(lines, optionalOpenAIProxyCompat);
708
+ } else {
709
+ lines.push(...getCompatCheckNotApplicableLines(model));
710
+ }
711
+ lines.push("");
712
+ }
713
+
714
+ if (routerNotes.length > 0) {
715
+ if (missing.length > 0) lines.push("");
716
+ for (const note of routerNotes) {
717
+ lines.push(note);
718
+ }
719
+ }
720
+
721
+ return lines.join("\n");
722
+ }
723
+
724
+ // ============================================================
725
+ // JSONC comment-preserving surgical edit helpers for /cache-optimizer fix
726
+ // ============================================================
727
+
728
+ /**
729
+ * Build the fix suggestion for the current active model.
730
+ * Returns undefined if there is nothing to fix.
731
+ */
732
+ export function buildFixSuggestion(model: PiModel): FixSuggestion | undefined {
733
+ const missing = describeMissingCacheCompatForModel(model);
734
+ if (missing.length === 0) return undefined;
735
+
736
+ let compatKeys: Record<string, unknown> = {};
737
+
738
+ if (isAdaptiveThinkingCompatApplicable(model)) {
739
+ compatKeys = buildAdaptiveThinkingCompatSuggestion(missing);
740
+ } else if (isDeepSeekCompatCheckApplicable(model)) {
741
+ compatKeys = buildDeepSeekCompatSuggestion(missing);
742
+ } else {
743
+ compatKeys = buildSafeOpenAIProxyCompatSuggestion(missing);
744
+ }
745
+
746
+ if (Object.keys(compatKeys).length === 0) return undefined;
747
+
748
+ const key = modelKey(model);
749
+ const slashIdx = key.indexOf("/");
750
+ const providerLabel = slashIdx > 0 ? key.slice(0, slashIdx) : key;
751
+
752
+ return {
753
+ providerLabel,
754
+ modelId: model.id,
755
+ compatKeys,
756
+ };
757
+ }