pi-cache-optimizer 2.8.25 → 2.8.27

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.
@@ -2,7 +2,7 @@ import { type CacheProviderAdapter } from "./adapters.ts";
2
2
  import { type PiModel, type UnknownRecord, asRecord, isNonEmptyString, lower } from "./common.ts";
3
3
  import { type CompatAdvicePlacement, appendCredentialSafeProviderGuidance, appendDeepSeekCompatAdviceLines, appendOpenAIProxyCompatAdviceLines, buildDeepSeekCompatSuggestion, buildSafeOpenAIProxyCompatSuggestion, describeMissingCacheCompatForModel, getModelsJsonDisplayPath, isAdaptiveThinkingCompatApplicable, isDeepSeekWireCompatApplicable } from "./compat-advice.ts";
4
4
  import { type FixSuggestion } from "./fix-types.ts";
5
- import { getCompat, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOpenAICompatibleProxyApi, isPiBuiltInLlamaCppModel, modelKey } from "./model-identity.ts";
5
+ import { getCompat, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOfficialKimiCodeEndpoint, isOpenAICompatibleProxyApi, isPiBuiltInLlamaCppModel, isThirdPartyOpenAIProxyEndpoint, modelKey } from "./model-identity.ts";
6
6
  import { type CacheUsageSample, formatRecentTrendSummary } from "./stats-report.ts";
7
7
  import { type CacheStats, emptyCacheStats } from "./stats-store.ts";
8
8
  import { snapshotBaseUrlForDiagnostics } from "./request-state.ts";
@@ -38,7 +38,7 @@ export function describeOptionalOpenAICompatibleProxyCompat(model: PiModel): str
38
38
  const optional: string[] = [];
39
39
 
40
40
  if (!isOpenAICompatibleProxyApi(model.api)) return optional;
41
- if (!isKnownThirdPartyOpenAIEndpoint(model)) return optional;
41
+ if (!isThirdPartyOpenAIProxyEndpoint(model)) return optional;
42
42
  if (isPiBuiltInLlamaCppModel(model)) return optional;
43
43
 
44
44
  if (compat.supportsLongCacheRetention !== true) {
@@ -143,7 +143,7 @@ export function isDeepSeekCompatCheckApplicable(model: PiModel): boolean {
143
143
  }
144
144
 
145
145
  export function isCompatCheckApplicable(model: PiModel): boolean {
146
- return isOpenAICompatibleProxyApi(model.api) && isKnownThirdPartyOpenAIEndpoint(model) && !isPiBuiltInLlamaCppModel(model);
146
+ return isOpenAICompatibleProxyApi(model.api) && isThirdPartyOpenAIProxyEndpoint(model) && !isPiBuiltInLlamaCppModel(model);
147
147
  }
148
148
 
149
149
  export function isPromptCacheRetention400Applicable(model: PiModel): boolean {
@@ -223,7 +223,7 @@ export function describeRouterChannelDiagnostics(model: PiModel): string[] {
223
223
 
224
224
  // Unknown/default endpoints and official OpenAI are not diagnosable as
225
225
  // third-party router channels. Request-header bridging also fails closed.
226
- if (!isKnownThirdPartyOpenAIEndpoint(model)) {
226
+ if (!isThirdPartyOpenAIProxyEndpoint(model)) {
227
227
  return notes;
228
228
  }
229
229
 
@@ -404,6 +404,13 @@ export function getCompatCheckNotApplicableLines(model: PiModel): string[] {
404
404
  ];
405
405
  }
406
406
 
407
+ if (isOpenAICompatibleApi(api) && isOfficialKimiCodeEndpoint(model)) {
408
+ return [
409
+ "ℹ️ Compat check not applicable for this model.",
410
+ " Official Kimi Code endpoint: Pi ships this provider's compat itself, and proxy session-affinity flags do not apply to a first-party origin.",
411
+ ];
412
+ }
413
+
407
414
  return ["ℹ️ Compat check not applicable for this model."];
408
415
  }
409
416
 
@@ -499,7 +506,9 @@ export function buildDoctorDiagnosis(model: PiModel, options: { promptCacheReten
499
506
  lines.push(" (session_id, x-client-request-id, x-session-affinity) and return 403. If you");
500
507
  lines.push(" see 403 errors, run /cache-optimizer fix to set sendSessionAffinityHeaders: false.");
501
508
  }
502
- } else if (isOpenAISdkHeader403Applicable(model)) {
509
+ } else if (isOpenAISdkHeader403Applicable(model) && (options.openAISdkHeader403 || !isOfficialKimiCodeEndpoint(model))) {
510
+ // Official Kimi Code gets the 403 hint only after a 403 was observed; the
511
+ // passive "after disabling sendSessionAffinityHeaders" advice is proxy-only.
503
512
  lines.push("");
504
513
  if (options.openAISdkHeader403) {
505
514
  lines.push("⚠️ A 403 response was observed while sendSessionAffinityHeaders is not enabled.");
package/src/fix-types.ts CHANGED
@@ -35,11 +35,12 @@ export function samePromptCacheLifetimes(left: unknown, right: unknown): boolean
35
35
  /**
36
36
  * Version 1 receipts record scalar compat changes only. Version 2 receipts
37
37
  * also record a `promptCache` object the fix added (it never overwrites one),
38
- * so rollback can remove exactly that property. Older extension versions
39
- * reject version 2 receipts instead of misreading them.
38
+ * so rollback can remove exactly that property. Version 3 receipts record a
39
+ * single `baseUrl` string rewrite (`/cache-optimizer fix kimi-base-url`).
40
+ * Older extension versions reject newer receipts instead of misreading them.
40
41
  */
41
42
  export type ModelsJsonFixReceiptV1 = {
42
- version: 1 | 2;
43
+ version: 1 | 2 | 3;
43
44
  kind: "pi-cache-optimizer-fix-receipt";
44
45
  transactionId: string;
45
46
  provider: string;
@@ -49,6 +50,11 @@ export type ModelsJsonFixReceiptV1 = {
49
50
  changedKeys: Record<string, FixReceiptCompatChange>;
50
51
  /** Version 2 only: the promptCache object added at the target (absent before). */
51
52
  promptCacheAdded?: PromptCacheLifetimes;
53
+ /**
54
+ * Version 3 only: the `baseUrl` string the fix rewrote at the provider or
55
+ * custom model target (official Kimi Code `/coding` → `/coding/v1`).
56
+ */
57
+ baseUrlChange?: { before: string; after: string };
52
58
  beforeHash: string;
53
59
  afterHash: string;
54
60
  backupFile: string;
@@ -0,0 +1,249 @@
1
+ import { type PiModel, asRecord, isNonEmptyString } from "./common.ts";
2
+ import { parseJsonc } from "./jsonc.ts";
3
+ import { kimiCodeV1BaseUrl, normalizeBaseUrlForComparison, parseOfficialKimiCodeBaseUrl } from "./kimi-code.ts";
4
+ import { isValidModelsConfigForEffectiveCompat } from "./model-identity.ts";
5
+ import { type BaseUrlFixPlacement, locateBaseUrlTarget, readBaseUrlState, replaceBaseUrlValue } from "./models-json-fix.ts";
6
+
7
+ /**
8
+ * Kimi Code base URL check and `/cache-optimizer fix kimi-base-url`.
9
+ *
10
+ * Pi 1.3.0 moved the built-in kimi-coding provider from anthropic-messages
11
+ * (`https://api.kimi.com/coding`) to OpenAI Chat Completions
12
+ * (`https://api.kimi.com/coding/v1`). A models.json `baseUrl` left over from
13
+ * the Anthropic era keeps `/coding`, so Pi posts to `/coding/chat/completions`
14
+ * and every request fails.
15
+ *
16
+ * The fix only appends `/v1`, and only when the live model uses
17
+ * openai-completions and its base URL is exactly an official Kimi Code host
18
+ * with the bare `/coding` path. The effective API is the real condition: Pi
19
+ * versions before 1.3.0 ship kimi-coding as anthropic-messages, and Kimi's
20
+ * OpenAI-compatible endpoint has always been `/coding/v1`. Proxies, other
21
+ * paths, and Anthropic-protocol entries only get a hint.
22
+ */
23
+
24
+ export type KimiBaseUrlStatus =
25
+ | { kind: "not-applicable" }
26
+ | { kind: "fixable"; current: string; suggested: string }
27
+ | { kind: "hint"; lines: string[] };
28
+
29
+ const KIMI_CODING_PATH = /\/coding\/*$/i;
30
+
31
+ export function diagnoseKimiCodeBaseUrl(model: PiModel | undefined): KimiBaseUrlStatus {
32
+ if (!model || !isNonEmptyString(model.baseUrl)) return { kind: "not-applicable" };
33
+ const api = String(model.api ?? "").toLowerCase();
34
+ const official = parseOfficialKimiCodeBaseUrl(model.baseUrl);
35
+
36
+ if (api === "openai-completions") {
37
+ if (official && !official.hasV1) {
38
+ const suggested = kimiCodeV1BaseUrl(model.baseUrl);
39
+ if (suggested) return { kind: "fixable", current: model.baseUrl, suggested };
40
+ }
41
+ if (!official && isKimiLike(model) && KIMI_CODING_PATH.test(pathOf(model.baseUrl))) {
42
+ return {
43
+ kind: "hint",
44
+ lines: [
45
+ "ℹ️ This Kimi entry uses OpenAI Chat Completions, and its base URL ends in /coding without /v1.",
46
+ " Kimi Code's official OpenAI endpoint is https://api.kimi.com/coding/v1. Check what your proxy expects;",
47
+ " it is not an official Kimi host, so /cache-optimizer fix leaves it unchanged.",
48
+ ],
49
+ };
50
+ }
51
+ return { kind: "not-applicable" };
52
+ }
53
+
54
+ if (api === "anthropic-messages" && official?.hasV1) {
55
+ return {
56
+ kind: "hint",
57
+ lines: [
58
+ "ℹ️ This Kimi Code entry uses the Anthropic Messages API, but its base URL ends in /coding/v1.",
59
+ " The Anthropic endpoint is https://api.kimi.com/coding (Pi appends /v1/messages itself).",
60
+ " Pi 1.3.0+ ships kimi-coding on OpenAI Chat Completions; drop the api override or the /v1. Not changed automatically.",
61
+ ],
62
+ };
63
+ }
64
+ return { kind: "not-applicable" };
65
+ }
66
+
67
+ function isKimiLike(model: PiModel): boolean {
68
+ const provider = String(model.provider ?? "").toLowerCase();
69
+ const baseUrl = String(model.baseUrl ?? "").toLowerCase();
70
+ return provider.includes("kimi") || provider.includes("moonshot") || baseUrl.includes("kimi") || baseUrl.includes("moonshot");
71
+ }
72
+
73
+ function pathOf(baseUrl: string): string {
74
+ try {
75
+ return new URL(baseUrl.trim()).pathname;
76
+ } catch {
77
+ return "";
78
+ }
79
+ }
80
+
81
+ export function buildKimiBaseUrlDoctorLines(model: PiModel | undefined): string[] {
82
+ const status = diagnoseKimiCodeBaseUrl(model);
83
+ if (status.kind === "fixable") {
84
+ return [
85
+ "",
86
+ "⚠️ Kimi Code base URL is missing /v1.",
87
+ ` Current: ${status.current}`,
88
+ ` Expected: ${status.suggested}`,
89
+ " This model uses OpenAI Chat Completions (Pi 1.3.0+ kimi-coding), so requests to /coding/chat/completions fail.",
90
+ " Run /cache-optimizer fix kimi-base-url to append /v1 with a preview, backup and rollback.",
91
+ ];
92
+ }
93
+ if (status.kind === "hint") return ["", ...status.lines];
94
+ return [];
95
+ }
96
+
97
+ export type KimiBaseUrlFixPlan = {
98
+ modifiedText: string;
99
+ placement: BaseUrlFixPlacement;
100
+ before: string;
101
+ after: string;
102
+ locationLabel: string;
103
+ /** Other models whose base URL changes too (provider-level edits). */
104
+ affectedNote?: string;
105
+ };
106
+
107
+ /**
108
+ * Plan the models.json edit for the active model. The `baseUrl` Pi uses comes
109
+ * from the model's own custom `models[]` entry when it has one, otherwise
110
+ * from the provider entry. The located string must match the live base URL;
111
+ * a mismatch means something else (an extension, another file) supplies it.
112
+ */
113
+ export function planKimiBaseUrlFix(
114
+ originalText: string,
115
+ model: PiModel,
116
+ /** Live models from Pi's registry (`modelRegistry.getAll()`), when available. */
117
+ registeredModels?: readonly PiModel[],
118
+ ): KimiBaseUrlFixPlan | { error: string } {
119
+ const status = diagnoseKimiCodeBaseUrl(model);
120
+ if (status.kind !== "fixable") return { error: "the active model does not need the Kimi Code /v1 fix" };
121
+
122
+ let parsed: unknown;
123
+ try {
124
+ parsed = parseJsonc(originalText);
125
+ } catch {
126
+ return { error: "models.json is not valid JSONC" };
127
+ }
128
+ if (!isValidModelsConfigForEffectiveCompat(parsed)) {
129
+ return { error: "models.json does not match Pi's model configuration schema" };
130
+ }
131
+ const providerRecord = asRecord(asRecord(asRecord(parsed)?.providers)?.[model.provider]);
132
+ if (!providerRecord) {
133
+ return { error: `models.json has no "${model.provider}" provider entry, so the base URL comes from somewhere else (an extension or Pi's built-in catalog)` };
134
+ }
135
+ const definitions = (Array.isArray(providerRecord.models) ? providerRecord.models : []).map(asRecord);
136
+ const ownDefinitions = definitions.filter((entry) => entry?.id === model.id);
137
+ if (ownDefinitions.length > 1) return { error: `models.json defines ${model.provider}/${model.id} more than once` };
138
+
139
+ let placement: BaseUrlFixPlacement;
140
+ if (ownDefinitions[0] && ownDefinitions[0].baseUrl !== undefined) {
141
+ placement = "model";
142
+ } else if (providerRecord.baseUrl !== undefined) {
143
+ placement = "provider";
144
+ if (providerRecord.oauth === "radius") {
145
+ return { error: "the provider uses oauth \"radius\", where Pi ignores the provider baseUrl for built-in models" };
146
+ }
147
+ // A provider-level baseUrl also feeds custom models without their own.
148
+ // Refuse when one of them is declared on another protocol, since /v1
149
+ // would break it (Anthropic Messages wants the bare /coding path).
150
+ const providerApi = providerRecord.api;
151
+ const conflicting = definitions
152
+ .filter((entry) => entry && entry.baseUrl === undefined)
153
+ .filter((entry) => {
154
+ const api = entry?.api ?? providerApi;
155
+ return api !== undefined && String(api).toLowerCase() !== "openai-completions";
156
+ })
157
+ .map((entry) => String(entry?.id));
158
+ if (conflicting.length > 0) {
159
+ return { error: `the provider baseUrl is shared with custom models on another API (${conflicting.join(", ")}); give each its own baseUrl first` };
160
+ }
161
+ // The provider baseUrl also overrides every built-in model of the
162
+ // provider. On Pi < 1.3.0 the built-in kimi-coding models are
163
+ // anthropic-messages, so a custom openai-completions entry inheriting the
164
+ // provider baseUrl must not drag them onto /coding/v1. Check the live
165
+ // registry; without it, refuse for a custom entry (built-ins unknown).
166
+ const ownIds = new Set(definitions.map((entry) => entry?.id));
167
+ if (registeredModels) {
168
+ const builtInConflicts = registeredModels
169
+ .filter((entry) => entry.provider === model.provider && !ownIds.has(entry.id))
170
+ .filter((entry) => String(entry.api ?? "").toLowerCase() !== "openai-completions")
171
+ .filter((entry) => isNonEmptyString(entry.baseUrl) && normalizeBaseUrlForComparison(entry.baseUrl) === normalizeBaseUrlForComparison(String(providerRecord.baseUrl)))
172
+ .map((entry) => String(entry.id));
173
+ if (builtInConflicts.length > 0) {
174
+ return { error: `the provider baseUrl is shared with built-in models on another API (${builtInConflicts.join(", ")}); set baseUrl on the ${model.id} models[] entry instead` };
175
+ }
176
+ } else if (ownDefinitions[0]) {
177
+ return { error: `the provider baseUrl may also feed built-in ${model.provider} models on another API; set baseUrl on the ${model.id} models[] entry instead` };
178
+ }
179
+ } else {
180
+ return { error: `models.json sets no baseUrl for ${model.provider}/${model.id}, so the base URL comes from somewhere else` };
181
+ }
182
+
183
+ const target = locateBaseUrlTarget(originalText, model.provider, model.id, placement);
184
+ const before = readBaseUrlState(originalText, target);
185
+ if (!target || before === undefined || before === "invalid") {
186
+ return { error: "the baseUrl entry is duplicated or cannot be located safely" };
187
+ }
188
+ if (normalizeBaseUrlForComparison(before) !== normalizeBaseUrlForComparison(status.current)) {
189
+ return { error: "the baseUrl in models.json differs from the active model's base URL (an extension or another source overrides it)" };
190
+ }
191
+ const after = kimiCodeV1BaseUrl(before);
192
+ if (!after) return { error: "the baseUrl in models.json is not a bare official Kimi Code /coding URL" };
193
+ const modifiedText = replaceBaseUrlValue(originalText, target, after);
194
+ if (modifiedText === undefined) return { error: "the baseUrl value cannot be rewritten safely" };
195
+
196
+ const locationLabel = placement === "provider"
197
+ ? `providers[${JSON.stringify(model.provider)}].baseUrl`
198
+ : `providers[${JSON.stringify(model.provider)}].models[id=${JSON.stringify(model.id)}].baseUrl`;
199
+ const plan: KimiBaseUrlFixPlan = {
200
+ modifiedText,
201
+ placement,
202
+ before,
203
+ after,
204
+ locationLabel,
205
+ ...(placement === "provider" ? { affectedNote: `Every ${model.provider} model without its own baseUrl uses this value.` } : {}),
206
+ };
207
+ const checkError = selfCheckKimiBaseUrlFix(originalText, plan, model.provider, model.id);
208
+ if (checkError) return { error: `self-check failed: ${checkError}` };
209
+ return plan;
210
+ }
211
+
212
+ /** The edit must be valid, write `after`, and change nothing but that string. */
213
+ export function selfCheckKimiBaseUrlFix(originalText: string, plan: KimiBaseUrlFixPlan, provider: string, modelId: string): string | null {
214
+ let original: unknown;
215
+ let modified: unknown;
216
+ try {
217
+ original = parseJsonc(originalText);
218
+ modified = parseJsonc(plan.modifiedText);
219
+ } catch {
220
+ return "the edited file is not valid JSONC";
221
+ }
222
+ if (!isValidModelsConfigForEffectiveCompat(modified)) return "the edited file does not match Pi's model configuration schema";
223
+ if (readBaseUrlState(plan.modifiedText, locateBaseUrlTarget(plan.modifiedText, provider, modelId, plan.placement)) !== plan.after) {
224
+ return "baseUrl was not written at the target";
225
+ }
226
+ const reverted = JSON.parse(JSON.stringify(modified)) as Record<string, unknown>;
227
+ const providerRecord = asRecord(asRecord(reverted.providers)?.[provider]);
228
+ if (!providerRecord) return "the provider entry disappeared";
229
+ if (plan.placement === "provider") {
230
+ providerRecord.baseUrl = plan.before;
231
+ } else {
232
+ const entry = (Array.isArray(providerRecord.models) ? providerRecord.models : []).map(asRecord).find((item) => item?.id === modelId);
233
+ if (!entry) return "the model entry disappeared";
234
+ entry.baseUrl = plan.before;
235
+ }
236
+ if (JSON.stringify(reverted) !== JSON.stringify(original)) return "unrelated configuration was altered";
237
+ return null;
238
+ }
239
+
240
+ /** Validate a file written by the fix transaction (re-read from disk). */
241
+ export function validateWrittenKimiBaseUrlFix(writtenText: string, plan: KimiBaseUrlFixPlan): string | null {
242
+ if (writtenText !== plan.modifiedText) return "models.json content differs from the previewed edit";
243
+ try {
244
+ parseJsonc(writtenText);
245
+ } catch {
246
+ return "the written file is not valid JSONC";
247
+ }
248
+ return null;
249
+ }
@@ -0,0 +1,53 @@
1
+ import { isNonEmptyString } from "./common.ts";
2
+
3
+ // Moonshot's official Kimi Code hosts (China and international). Pi 1.3.0
4
+ // ships kimi-coding as openai-completions at `<host>/coding/v1`; older Pi
5
+ // versions used anthropic-messages at `<host>/coding`.
6
+ const OFFICIAL_KIMI_CODE_HOSTS = new Set(["api.kimi.com", "api.kimi.ai"]);
7
+
8
+ export type OfficialKimiCodeBaseUrl = {
9
+ host: string;
10
+ /** Whether the path is `/coding/v1` (true) or bare `/coding` (false). */
11
+ hasV1: boolean;
12
+ };
13
+
14
+ /**
15
+ * Parse an official Kimi Code base URL: https, one of the official hosts, no
16
+ * port, credentials, query or fragment, and a path of exactly `/coding` or
17
+ * `/coding/v1` (trailing slashes ignored). Anything else, including proxies
18
+ * that mirror the path, returns undefined.
19
+ */
20
+ export function parseOfficialKimiCodeBaseUrl(baseUrl: unknown): OfficialKimiCodeBaseUrl | undefined {
21
+ if (!isNonEmptyString(baseUrl)) return undefined;
22
+ let url: URL;
23
+ try {
24
+ url = new URL(baseUrl.trim());
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ if (url.protocol !== "https:" || url.port !== "" || url.username !== "" || url.password !== "") return undefined;
29
+ if (url.search !== "" || url.hash !== "" || /[?#]/.test(baseUrl)) return undefined;
30
+ const host = url.hostname.toLowerCase();
31
+ if (!OFFICIAL_KIMI_CODE_HOSTS.has(host)) return undefined;
32
+ const path = url.pathname.replace(/\/+$/, "").toLowerCase();
33
+ if (path === "/coding") return { host, hasV1: false };
34
+ if (path === "/coding/v1") return { host, hasV1: true };
35
+ return undefined;
36
+ }
37
+
38
+ /** Strip surrounding whitespace and trailing slashes for base URL comparison. */
39
+ export function normalizeBaseUrlForComparison(baseUrl: string): string {
40
+ return baseUrl.trim().replace(/\/+$/, "");
41
+ }
42
+
43
+ /** The `/coding/v1` form of a bare official `/coding` base URL, keeping its spelling. */
44
+ export function kimiCodeV1BaseUrl(baseUrl: string): string | undefined {
45
+ const parsed = parseOfficialKimiCodeBaseUrl(baseUrl);
46
+ if (!parsed || parsed.hasV1) return undefined;
47
+ return `${normalizeBaseUrlForComparison(baseUrl)}/v1`;
48
+ }
49
+
50
+ /** A receipt-recorded base URL change is exactly "bare official /coding" → "+ /v1". */
51
+ export function isKimiCodeV1BaseUrlChange(before: unknown, after: unknown): boolean {
52
+ return typeof before === "string" && typeof after === "string" && kimiCodeV1BaseUrl(before) === after;
53
+ }
@@ -2,6 +2,7 @@ import { type ModelIdentity, type PiModel, type UnknownRecord, asRecord, isNonEm
2
2
  import { type CacheCompat, NESTED_COMPAT_KEYS, resolveEffectiveCompatFromConfig } from "./compat-config.ts";
3
3
  import { parseJsonc } from "./jsonc.ts";
4
4
  import { getAssistantMessageModelTokenValues, getModelIdNameTokenValues, hasAnyTokenContaining, isKimiCodingAdaptiveModel, modelOrAssistantMessageHas } from "./model-detect.ts";
5
+ import { parseOfficialKimiCodeBaseUrl } from "./kimi-code.ts";
5
6
  import { MODELS_JSON_PATH } from "./paths.ts";
6
7
  import { readFileSync, statSync } from "node:fs";
7
8
 
@@ -363,6 +364,21 @@ export function isKnownThirdPartyOpenAIEndpoint(model: PiModel): boolean {
363
364
  return isNonEmptyString(model.baseUrl) && !isOfficialOpenAIBaseUrl(model);
364
365
  }
365
366
 
367
+ /** The model talks to Moonshot's official Kimi Code endpoint directly. */
368
+ export function isOfficialKimiCodeEndpoint(model: PiModel | undefined): boolean {
369
+ return parseOfficialKimiCodeBaseUrl(model?.baseUrl) !== undefined;
370
+ }
371
+
372
+ /**
373
+ * A non-official OpenAI-compatible endpoint that may sit in front of several
374
+ * backends (relay, aggregator, gateway), where session-affinity headers help
375
+ * keep one session on one cache. Official first-party endpoints that Pi
376
+ * configures itself (Kimi Code) are origins, not proxies.
377
+ */
378
+ export function isThirdPartyOpenAIProxyEndpoint(model: PiModel): boolean {
379
+ return isKnownThirdPartyOpenAIEndpoint(model) && !isOfficialKimiCodeEndpoint(model);
380
+ }
381
+
366
382
  export function invalidateModelsConfigCache(): void {
367
383
  modelsConfigCache = undefined;
368
384
  }
@@ -8,6 +8,7 @@ import { findLastExactModelDefinition, resolveEffectiveCompatFromConfig } from "
8
8
  import { type FixReceiptCompatChange, type FixReceiptPlacement, type ModelsJsonFixReceiptV1, type PromptCacheLifetimes, RECEIPT_COMPAT_KEYS, type ReceiptScalar, type ReceiptScalarState, isReceiptTimestamp, isValidPromptCacheLifetimes, samePromptCacheLifetimes, isSafeReceiptText, isSha256 } from "./fix-types.ts";
9
9
  import { type JsonPropertyEdit, type ModelNodeLocation, deepEqualIgnoringKeys, deriveInnerIndent, findExistingCompatKeysInJsonc, findJsonObjectKey, findMatchingBracket, isJsonWhitespace, lineIndentOf, locateJsonPropertyValueSpan, locateModelOverrideInJsonc, locateProviderCompatInJsonc, parseJsonc, readJsonStringLiteral, skipJsonValue, skipJsonWhitespace, stripJsoncComments } from "./jsonc.ts";
10
10
  import { isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./model-detect.ts";
11
+ import { isKimiCodeV1BaseUrlChange } from "./kimi-code.ts";
11
12
  import { FIX_RECEIPT_PATH, MODELS_JSON_PATH } from "./paths.ts";
12
13
 
13
14
  export type ExplicitCompatSource = "modelOverride" | "model" | "provider";
@@ -1528,10 +1529,10 @@ export function createRollbackBackupPath(modelsPath: string = MODELS_JSON_PATH):
1528
1529
 
1529
1530
  export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV1 | undefined {
1530
1531
  const record = asRecord(value);
1531
- if (!record || (record.version !== 1 && record.version !== 2) || record.kind !== "pi-cache-optimizer-fix-receipt") return undefined;
1532
+ if (!record || (record.version !== 1 && record.version !== 2 && record.version !== 3) || record.kind !== "pi-cache-optimizer-fix-receipt") return undefined;
1532
1533
  const allowedKeys = new Set([
1533
1534
  "version", "kind", "transactionId", "provider", "modelId", "placement",
1534
- "targetExistedBefore", "changedKeys", "promptCacheAdded", "beforeHash", "afterHash", "backupFile",
1535
+ "targetExistedBefore", "changedKeys", "promptCacheAdded", "baseUrlChange", "beforeHash", "afterHash", "backupFile",
1535
1536
  "createdAt", "appliedAt", "status", "rolledBackAt",
1536
1537
  ]);
1537
1538
  if (Object.keys(record).some((key) => !allowedKeys.has(key))) return undefined;
@@ -1549,8 +1550,19 @@ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV
1549
1550
  const isV2 = record.version === 2;
1550
1551
  if (isV2 !== (record.promptCacheAdded !== undefined)) return undefined;
1551
1552
  if (isV2 && (!isValidPromptCacheLifetimes(record.promptCacheAdded) || record.placement === "provider")) return undefined;
1553
+ // baseUrlChange is the version 3 marker: a single official Kimi Code
1554
+ // `/coding` → `/coding/v1` rewrite at an existing provider or model target.
1555
+ const isV3 = record.version === 3;
1556
+ if (isV3 !== (record.baseUrlChange !== undefined)) return undefined;
1557
+ const rawBaseUrlChange = asRecord(record.baseUrlChange);
1558
+ if (isV3) {
1559
+ if (!rawBaseUrlChange || Object.keys(rawBaseUrlChange).some((key) => key !== "before" && key !== "after")) return undefined;
1560
+ if (!isKimiCodeV1BaseUrlChange(rawBaseUrlChange.before, rawBaseUrlChange.after)) return undefined;
1561
+ if (record.placement === "modelOverride" || record.targetExistedBefore !== true) return undefined;
1562
+ }
1552
1563
  const changedKeys = asRecord(record.changedKeys);
1553
- if (!changedKeys || (Object.keys(changedKeys).length === 0 && !isV2)) return undefined;
1564
+ if (!changedKeys || (Object.keys(changedKeys).length === 0 && record.version === 1)) return undefined;
1565
+ if (isV3 && Object.keys(changedKeys).length > 0) return undefined;
1554
1566
 
1555
1567
  const parsedChanges: Record<string, FixReceiptCompatChange> = {};
1556
1568
  for (const [key, rawChange] of Object.entries(changedKeys)) {
@@ -1569,8 +1581,11 @@ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV
1569
1581
  if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
1570
1582
 
1571
1583
  const promptCacheAdded = isV2 ? record.promptCacheAdded as PromptCacheLifetimes : undefined;
1584
+ const baseUrlChange = isV3 && rawBaseUrlChange
1585
+ ? { before: rawBaseUrlChange.before as string, after: rawBaseUrlChange.after as string }
1586
+ : undefined;
1572
1587
  return {
1573
- version: isV2 ? 2 : 1,
1588
+ version: isV3 ? 3 : isV2 ? 2 : 1,
1574
1589
  kind: "pi-cache-optimizer-fix-receipt",
1575
1590
  transactionId: record.transactionId.trim(),
1576
1591
  provider: record.provider.trim(),
@@ -1579,6 +1594,7 @@ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV
1579
1594
  targetExistedBefore: record.targetExistedBefore,
1580
1595
  changedKeys: parsedChanges,
1581
1596
  ...(promptCacheAdded ? { promptCacheAdded: { ...(promptCacheAdded.short !== undefined ? { short: promptCacheAdded.short } : {}), ...(promptCacheAdded.long !== undefined ? { long: promptCacheAdded.long } : {}) } } : {}),
1597
+ ...(baseUrlChange ? { baseUrlChange } : {}),
1582
1598
  beforeHash,
1583
1599
  afterHash,
1584
1600
  backupFile: record.backupFile,
@@ -1854,6 +1870,100 @@ export function createPromptCacheFixReceipt(
1854
1870
  });
1855
1871
  }
1856
1872
 
1873
+ export type BaseUrlFixPlacement = "provider" | "model";
1874
+
1875
+ /**
1876
+ * Locate the object that holds a direct `baseUrl`: the provider object, or
1877
+ * the single custom `models[]` entry with this id. Duplicate provider keys or
1878
+ * duplicate model ids are refused.
1879
+ */
1880
+ export function locateBaseUrlTarget(
1881
+ text: string,
1882
+ provider: string,
1883
+ modelId: string,
1884
+ placement: BaseUrlFixPlacement,
1885
+ ): { objectBrace: number; objectEnd: number } | undefined {
1886
+ if (placement === "provider") {
1887
+ const location = locateProviderCompatInJsonc(text, provider);
1888
+ return location ? { objectBrace: location.providerObjectBrace, objectEnd: location.providerObjectEnd } : undefined;
1889
+ }
1890
+ const location = locateModelInJsonc(text, provider, modelId);
1891
+ if (!location || location.providerKeyCount !== 1 || location.modelObjectBrace < 0) return undefined;
1892
+ if (location.allModelIds.filter((id) => id === modelId).length !== 1) return undefined;
1893
+ return { objectBrace: location.modelObjectBrace, objectEnd: location.modelObjectEnd };
1894
+ }
1895
+
1896
+ /**
1897
+ * Read the direct `baseUrl` of a target: its string value, `undefined` when
1898
+ * absent, or "invalid" when duplicated, not a string, or unreadable.
1899
+ */
1900
+ export function readBaseUrlState(
1901
+ text: string,
1902
+ target: { objectBrace: number; objectEnd: number } | undefined,
1903
+ ): string | undefined | "invalid" {
1904
+ if (!target) return "invalid";
1905
+ const clean = stripJsoncComments(text);
1906
+ const property = findJsonObjectKey(clean, target.objectBrace, "baseUrl");
1907
+ if (!property || property.keyStart >= target.objectEnd) return undefined;
1908
+ if (property.count !== 1) return "invalid";
1909
+ const valueStart = skipJsonWhitespace(clean, property.valueStart);
1910
+ if (clean[valueStart] !== "\"") return "invalid";
1911
+ const literal = readJsonStringLiteral(clean, valueStart);
1912
+ if (!literal || literal.end > target.objectEnd) return "invalid";
1913
+ return literal.value;
1914
+ }
1915
+
1916
+ /** Replace the direct `baseUrl` string of a target, or explain why it cannot. */
1917
+ export function replaceBaseUrlValue(
1918
+ text: string,
1919
+ target: { objectBrace: number; objectEnd: number },
1920
+ value: string,
1921
+ ): string | undefined {
1922
+ const property = locateJsonPropertyValueSpan(text, target.objectBrace, target.objectEnd, "baseUrl");
1923
+ if (!property) return undefined;
1924
+ const clean = stripJsoncComments(text);
1925
+ if (clean[property.valueStart] !== "\"") return undefined;
1926
+ return text.slice(0, property.valueStart) + JSON.stringify(value) + text.slice(property.valueEnd);
1927
+ }
1928
+
1929
+ /**
1930
+ * Receipt for `/cache-optimizer fix kimi-base-url`: a version 3 receipt with
1931
+ * no compat changes and the single baseUrl string the fix rewrote.
1932
+ */
1933
+ export function createBaseUrlFixReceipt(
1934
+ originalText: string,
1935
+ modifiedText: string,
1936
+ provider: string,
1937
+ modelId: string,
1938
+ placement: BaseUrlFixPlacement,
1939
+ before: string,
1940
+ after: string,
1941
+ backupPath: string,
1942
+ now: number = Date.now(),
1943
+ ): ModelsJsonFixReceiptV1 | undefined {
1944
+ const backupFile = basename(backupPath);
1945
+ if (!/^models\.json\.backup-cache-optimizer-/.test(backupFile)) return undefined;
1946
+ if (!isKimiCodeV1BaseUrlChange(before, after)) return undefined;
1947
+ if (readBaseUrlState(originalText, locateBaseUrlTarget(originalText, provider, modelId, placement)) !== before) return undefined;
1948
+ if (readBaseUrlState(modifiedText, locateBaseUrlTarget(modifiedText, provider, modelId, placement)) !== after) return undefined;
1949
+ return parseModelsJsonFixReceipt({
1950
+ version: 3,
1951
+ kind: "pi-cache-optimizer-fix-receipt",
1952
+ transactionId: randomUUID(),
1953
+ provider,
1954
+ modelId,
1955
+ placement,
1956
+ targetExistedBefore: true,
1957
+ changedKeys: {},
1958
+ baseUrlChange: { before, after },
1959
+ beforeHash: hashText(originalText),
1960
+ afterHash: hashText(modifiedText),
1961
+ backupFile,
1962
+ createdAt: now,
1963
+ appliedAt: now,
1964
+ });
1965
+ }
1966
+
1857
1967
  export function createModelsJsonFixReceipt(
1858
1968
  originalText: string,
1859
1969
  modifiedText: string,
@@ -1908,6 +2018,19 @@ export function composeModelsJsonReceiptRollback(
1908
2018
  return { error: "the fix created a new target entry and the file changed; refusing to remove user changes automatically" };
1909
2019
  }
1910
2020
 
2021
+ if (receipt.baseUrlChange) {
2022
+ if (receipt.placement === "modelOverride") return { error: "the receipt's baseUrl target is not supported" };
2023
+ const baseUrlTarget = locateBaseUrlTarget(currentText, receipt.provider, receipt.modelId, receipt.placement);
2024
+ const current = readBaseUrlState(currentText, baseUrlTarget);
2025
+ if (current === "invalid" || !baseUrlTarget) return { error: "receipt-owned baseUrl is no longer safely locatable" };
2026
+ if (current !== receipt.baseUrlChange.after) {
2027
+ return { error: "receipt-owned baseUrl was changed after the fix; refusing to overwrite it" };
2028
+ }
2029
+ const modifiedText = replaceBaseUrlValue(currentText, baseUrlTarget, receipt.baseUrlChange.before);
2030
+ if (modifiedText === undefined) return { error: "receipt-owned baseUrl is no longer safely locatable" };
2031
+ return { modifiedText, changed: modifiedText !== currentText };
2032
+ }
2033
+
1911
2034
  const target = locateReceiptCompatTarget(currentText, receipt.provider, receipt.modelId, receipt.placement);
1912
2035
  if (!target?.targetExists) {
1913
2036
  return { error: "the original target entry is missing or no longer safely locatable" };
@@ -1969,6 +2092,12 @@ export function validateModelsJsonRollback(
1969
2092
  // newly created target entry (which intentionally did not exist before).
1970
2093
  if (expectedHash !== undefined) return null;
1971
2094
 
2095
+ if (receipt.baseUrlChange) {
2096
+ if (receipt.placement === "modelOverride") return "rollback result has an unsupported baseUrl target";
2097
+ const state = readBaseUrlState(writtenText, locateBaseUrlTarget(writtenText, receipt.provider, receipt.modelId, receipt.placement));
2098
+ return state === receipt.baseUrlChange.before ? null : "rollback result did not restore baseUrl";
2099
+ }
2100
+
1972
2101
  const target = locateReceiptCompatTarget(writtenText, receipt.provider, receipt.modelId, receipt.placement);
1973
2102
  if (!target?.targetExists) return "rollback result lost the original target entry";
1974
2103
  for (const [key, change] of Object.entries(receipt.changedKeys)) {