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.
- package/README.md +6 -3
- package/README.zh-CN.md +6 -3
- package/index.ts +103 -14
- package/package.json +2 -2
- package/src/cache-warming.ts +44 -3
- package/src/command-completion.ts +1 -1
- package/src/command.ts +127 -2
- package/src/compat-advice.ts +2 -2
- package/src/diagnostics.ts +14 -5
- package/src/fix-types.ts +9 -3
- package/src/kimi-base-url-fix.ts +249 -0
- package/src/kimi-code.ts +53 -0
- package/src/model-identity.ts +16 -0
- package/src/models-json-fix.ts +133 -4
- package/src/stats-store.ts +110 -50
package/src/diagnostics.ts
CHANGED
|
@@ -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 (!
|
|
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) &&
|
|
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 (!
|
|
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.
|
|
39
|
-
*
|
|
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
|
+
}
|
package/src/kimi-code.ts
ADDED
|
@@ -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
|
+
}
|
package/src/model-identity.ts
CHANGED
|
@@ -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
|
}
|
package/src/models-json-fix.ts
CHANGED
|
@@ -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 &&
|
|
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)) {
|