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