pi-cache-optimizer 2.8.16 → 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 +246 -11409
- 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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { type BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { NO_SKILL_COMPRESSION_ENV, featureEnabled } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// Minimum count of skills before compression is worth applying.
|
|
7
|
+
// Below this, pi's verbose XML block is small enough that the overhead of
|
|
8
|
+
// an additional one-line index isn't worth the loss of per-skill
|
|
9
|
+
// description hints. The 31-skill snapshot in this repo was 13.3 KB; one
|
|
10
|
+
// or two skills is well under 1 KB and not worth touching.
|
|
11
|
+
export const SKILL_COMPRESSION_MIN_COUNT = 4;
|
|
12
|
+
|
|
13
|
+
export function escapeXml(value: string): string {
|
|
14
|
+
return value
|
|
15
|
+
.replace(/&/g, "&")
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/\"/g, """)
|
|
19
|
+
.replace(/'/g, "'");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The tool Pi tells the model to load skill files with: `read` when selected, otherwise `bash`; undefined when neither. */
|
|
23
|
+
export type SkillFileReadTool = "read" | "bash";
|
|
24
|
+
|
|
25
|
+
export function skillFileReadTool(opts: Pick<BuildSystemPromptOptions, "selectedTools">): SkillFileReadTool | undefined {
|
|
26
|
+
const tools = opts.selectedTools ?? ["read", "bash", "edit", "write"];
|
|
27
|
+
return (["read", "bash"] as const).find((tool) => tools.includes(tool));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Byte-for-byte copy of Pi's formatter (contract-tested against the installed Pi for both tool wordings). */
|
|
31
|
+
export function formatSkillsForPrompt(skills: NonNullable<BuildSystemPromptOptions["skills"]>, fileReadTool: SkillFileReadTool = "read"): string {
|
|
32
|
+
const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
|
|
33
|
+
if (visibleSkills.length === 0) return "";
|
|
34
|
+
|
|
35
|
+
const lines = [
|
|
36
|
+
"\n\nThe following skills provide specialized instructions for specific tasks.",
|
|
37
|
+
fileReadTool === "read"
|
|
38
|
+
? "Use the read tool to load a skill's file when the task matches its description."
|
|
39
|
+
: "Use bash to load a skill's file when the task matches its description.",
|
|
40
|
+
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
|
|
41
|
+
"",
|
|
42
|
+
"<available_skills>",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
for (const skill of visibleSkills) {
|
|
46
|
+
lines.push(" <skill>");
|
|
47
|
+
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
|
48
|
+
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
|
49
|
+
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
|
|
50
|
+
lines.push(" </skill>");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lines.push("</available_skills>");
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compressed alternative to `formatSkillsForPrompt`.
|
|
59
|
+
*
|
|
60
|
+
* Pi emits one four-line XML element per skill (`<skill>`, `<name>`,
|
|
61
|
+
* `<description>`, `<location>`). In a 54-skill setup that block was
|
|
62
|
+
* ~24.6 KB, of which the descriptions are 54 % and the rest is XML tags
|
|
63
|
+
* plus a repeated absolute path per skill.
|
|
64
|
+
*
|
|
65
|
+
* This form keeps everything the model needs to choose and load a skill —
|
|
66
|
+
* every skill's name and its full description — and removes the repetition:
|
|
67
|
+
* - Skills are grouped by skills root directory; the root and the
|
|
68
|
+
* `<name>/SKILL.md` convention are stated once per group instead of one
|
|
69
|
+
* `<location>` per skill. agentskills.io requires `location` so file-read
|
|
70
|
+
* activation can find SKILL.md; the rule gives the model the same path.
|
|
71
|
+
* - Skills that do not follow `<root>/<name>/SKILL.md` (different file or
|
|
72
|
+
* directory name, Windows-style paths) are listed with their explicit
|
|
73
|
+
* file path, so no path is ever guessed.
|
|
74
|
+
* - The XML envelope is replaced by Markdown headings and bullets.
|
|
75
|
+
*
|
|
76
|
+
* Descriptions are kept verbatim except that whitespace runs (including
|
|
77
|
+
* newlines from folded YAML) collapse to one space so each skill is one
|
|
78
|
+
* bullet. Groups and skills are sorted for determinism (cache stability).
|
|
79
|
+
*/
|
|
80
|
+
export function formatSkillsForPromptCompressed(
|
|
81
|
+
skills: NonNullable<BuildSystemPromptOptions["skills"]>,
|
|
82
|
+
fileReadTool: SkillFileReadTool = "read",
|
|
83
|
+
): string {
|
|
84
|
+
const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
|
|
85
|
+
if (visibleSkills.length === 0) return "";
|
|
86
|
+
|
|
87
|
+
const byRoot = new Map<string, Array<{ name: string; description: string }>>();
|
|
88
|
+
const explicit: Array<{ name: string; description: string; filePath: string }> = [];
|
|
89
|
+
for (const skill of visibleSkills) {
|
|
90
|
+
const description = skill.description.replace(/\s+/g, " ").trim();
|
|
91
|
+
const skillDir = dirname(skill.filePath);
|
|
92
|
+
const conventional =
|
|
93
|
+
!skill.filePath.includes("\\") &&
|
|
94
|
+
basename(skill.filePath) === "SKILL.md" &&
|
|
95
|
+
basename(skillDir) === skill.name;
|
|
96
|
+
if (!conventional) {
|
|
97
|
+
explicit.push({ name: skill.name, description, filePath: skill.filePath });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const root = dirname(skillDir);
|
|
101
|
+
const list = byRoot.get(root) ?? [];
|
|
102
|
+
list.push({ name: skill.name, description });
|
|
103
|
+
byRoot.set(root, list);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
107
|
+
const header = formatSkillsForPrompt(skills, fileReadTool);
|
|
108
|
+
const preamble = header.slice(0, header.indexOf("<available_skills>")).trim();
|
|
109
|
+
|
|
110
|
+
const lines: string[] = [preamble];
|
|
111
|
+
for (const [root, entries] of [...byRoot.entries()].sort(([a], [b]) => compare(a, b))) {
|
|
112
|
+
lines.push("", `## Skills in ${root}/`, `Each skill file is at ${root}/<name>/SKILL.md`, "");
|
|
113
|
+
for (const entry of entries.sort((a, b) => compare(a.name, b.name))) {
|
|
114
|
+
lines.push(`- ${entry.name}: ${entry.description}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (explicit.length > 0) {
|
|
118
|
+
lines.push("", "## Skills with explicit file paths", "");
|
|
119
|
+
for (const entry of explicit.sort((a, b) => compare(a.filePath, b.filePath))) {
|
|
120
|
+
lines.push(`- ${entry.name} (file: ${entry.filePath}): ${entry.description}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Replace pi's verbose `<available_skills>` block in `prompt` with the
|
|
129
|
+
* compressed grouped form. Idempotent: if the verbose form is not
|
|
130
|
+
* present (compression already applied, or skill count below threshold),
|
|
131
|
+
* the prompt is returned unchanged.
|
|
132
|
+
*
|
|
133
|
+
* Opt-out: set `PI_CACHE_OPTIMIZER_NO_SKILL_COMPRESSION=1`.
|
|
134
|
+
*
|
|
135
|
+
* Pre-conditions for compression to fire:
|
|
136
|
+
* - opts.skills present and visible-skill count >= SKILL_COMPRESSION_MIN_COUNT
|
|
137
|
+
* - Verbose block (built from the same `opts.skills`) is found in
|
|
138
|
+
* `prompt` (substring match, no regex). This anchors the substitution
|
|
139
|
+
* to pi's own emitter; if pi changes the format, we no-op rather
|
|
140
|
+
* than mangle.
|
|
141
|
+
*
|
|
142
|
+
* Both sides are compared trimmed: since Pi 0.86 the skills block is trimmed
|
|
143
|
+
* and wrapped in a `<skills>` section, older Pi appended it with leading
|
|
144
|
+
* newlines. Matching the untrimmed text silently disabled compression on
|
|
145
|
+
* every Pi >= 0.86.
|
|
146
|
+
*/
|
|
147
|
+
export function compressSkillsInSystemPrompt(
|
|
148
|
+
prompt: string,
|
|
149
|
+
opts: BuildSystemPromptOptions,
|
|
150
|
+
): string {
|
|
151
|
+
if (!featureEnabled("skillCompression", NO_SKILL_COMPRESSION_ENV, true)) return prompt;
|
|
152
|
+
if (!opts.skills || opts.skills.length === 0) return prompt;
|
|
153
|
+
|
|
154
|
+
const visible = opts.skills.filter((skill) => !skill.disableModelInvocation);
|
|
155
|
+
if (visible.length < SKILL_COMPRESSION_MIN_COUNT) return prompt;
|
|
156
|
+
|
|
157
|
+
const verbose = formatSkillsForPrompt(opts.skills, skillFileReadTool(opts) ?? "read").trim();
|
|
158
|
+
if (!verbose || !prompt.includes(verbose)) return prompt;
|
|
159
|
+
|
|
160
|
+
const compressed = formatSkillsForPromptCompressed(opts.skills, skillFileReadTool(opts) ?? "read").trim();
|
|
161
|
+
if (!compressed || compressed.length >= verbose.length) return prompt;
|
|
162
|
+
|
|
163
|
+
return prompt.replace(verbose, () => compressed);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Preferred way to apply skill compression on Pi >= 0.86: set the structured
|
|
168
|
+
* `skills` prompt section instead of returning a whole replacement prompt.
|
|
169
|
+
*
|
|
170
|
+
* Returning `systemPrompt` makes Pi treat it as a forced prompt that replaces the
|
|
171
|
+
* entire leading system message, which silently discards section edits made by
|
|
172
|
+
* handlers that run later and stops Pi from recording/sending only the changed
|
|
173
|
+
* sections. A custom section named `skills` overrides Pi's own and keeps its
|
|
174
|
+
* position and `<skills>` wrapper. Returns true only when Pi's re-rendered prompt
|
|
175
|
+
* really contains the compressed list; otherwise the section is restored and the
|
|
176
|
+
* caller falls back to the string substitution.
|
|
177
|
+
*/
|
|
178
|
+
export function compressSkillsViaSection(event: {
|
|
179
|
+
readonly systemPrompt: string;
|
|
180
|
+
systemPromptOptions: BuildSystemPromptOptions;
|
|
181
|
+
}): boolean {
|
|
182
|
+
if (!featureEnabled("skillCompression", NO_SKILL_COMPRESSION_ENV, true)) return false;
|
|
183
|
+
const options = event.systemPromptOptions;
|
|
184
|
+
const sections = options?.sections;
|
|
185
|
+
// Pi < 0.86 has no sections; a forced prompt (set by an earlier handler) ignores them.
|
|
186
|
+
if (!sections || typeof sections !== "object" || options.forceSystemPrompt !== undefined) return false;
|
|
187
|
+
if (!options.skills || options.skills.filter((skill) => !skill.disableModelInvocation).length < SKILL_COMPRESSION_MIN_COUNT) return false;
|
|
188
|
+
|
|
189
|
+
const verbose = formatSkillsForPrompt(options.skills, skillFileReadTool(options) ?? "read").trim();
|
|
190
|
+
if (!verbose || !event.systemPrompt.includes(verbose)) return false;
|
|
191
|
+
const compressed = formatSkillsForPromptCompressed(options.skills, skillFileReadTool(options) ?? "read").trim();
|
|
192
|
+
if (!compressed || compressed.length >= verbose.length) return false;
|
|
193
|
+
|
|
194
|
+
const previous = sections.skills;
|
|
195
|
+
sections.skills = compressed;
|
|
196
|
+
if (event.systemPrompt.includes(compressed) && !event.systemPrompt.includes(verbose)) return true;
|
|
197
|
+
if (previous === undefined) delete sections.skills;
|
|
198
|
+
else sections.skills = previous;
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Strip per-turn churn from trellis `<session-overview>` block.
|
|
204
|
+
*
|
|
205
|
+
* Trellis injects a session-overview that includes `RECENT COMMITS`
|
|
206
|
+
* (shifts on every git commit), `Working directory: Clean/N uncommitted`
|
|
207
|
+
* (shifts on every edit/commit), and `Line count: N / 2000` (shifts on
|
|
208
|
+
* every journal append). These fields are at the tail of the
|
|
209
|
+
* session-overview and poison the prompt-prefix cache for everything
|
|
210
|
+
* that follows.
|
|
211
|
+
*
|
|
212
|
+
* This function surgically removes those three churn fields from the
|
|
213
|
+
* `<session-overview>...</session-overview>` block. The remaining
|
|
214
|
+
* fields (DEVELOPER, GIT STATUS branch-only, CURRENT TASK, ACTIVE
|
|
215
|
+
* TASKS, MY TASKS, JOURNAL FILE active-file-only, PACKAGES, PATHS)
|
|
216
|
+
* are stable within a session and become cache-friendlier.
|
|
217
|
+
*
|
|
218
|
+
* No-op when the `<session-overview>` tag is not present (e.g.
|
|
219
|
+
* trellis hook chose not to inject it, or a different extension
|
|
220
|
+
* owns the prompt).
|
|
221
|
+
*/
|
|
222
|
+
export function stripSessionOverviewChurn(prompt: string): string {
|
|
223
|
+
const startTag = "<session-overview>";
|
|
224
|
+
const endTag = "</session-overview>";
|
|
225
|
+
|
|
226
|
+
const startIdx = prompt.indexOf(startTag);
|
|
227
|
+
if (startIdx === -1) return prompt;
|
|
228
|
+
|
|
229
|
+
const endIdx = prompt.indexOf(endTag, startIdx + startTag.length);
|
|
230
|
+
if (endIdx === -1) return prompt;
|
|
231
|
+
|
|
232
|
+
const before = prompt.slice(0, startIdx + startTag.length);
|
|
233
|
+
const inner = prompt.slice(startIdx + startTag.length, endIdx);
|
|
234
|
+
const after = prompt.slice(endIdx);
|
|
235
|
+
|
|
236
|
+
let cleaned = inner
|
|
237
|
+
// Drop the RECENT COMMITS section (from the heading through the
|
|
238
|
+
// next heading or end of inner). The model sees commit history
|
|
239
|
+
// via `git log`; carrying it in every system prompt is redundant.
|
|
240
|
+
.replace(/\n## RECENT COMMITS\n[\s\S]*?(?=\n## |$)/, "")
|
|
241
|
+
// Drop "Working directory: ..." (Git status tail churn).
|
|
242
|
+
.replace(/\nWorking directory:[^\n]*/g, "")
|
|
243
|
+
// Drop "Line count: N / NNNN" (Journal tail churn).
|
|
244
|
+
.replace(/\nLine count:[^\n]*/g, "");
|
|
245
|
+
|
|
246
|
+
return before + cleaned + after;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What happened to the skill list on the most recent prompt build. Compression no-ops silently by design when
|
|
251
|
+
* Pi's text is not recognised, which once hid a regression for two weeks (Pi 0.86 changed how the block is
|
|
252
|
+
* assembled); `/cache-optimizer doctor` shows this so that state is visible.
|
|
253
|
+
*/
|
|
254
|
+
export type SkillCompressionOutcome = {
|
|
255
|
+
applied: "section" | "string" | false;
|
|
256
|
+
/** Why compression did not apply, when it did not. */
|
|
257
|
+
reason?: string;
|
|
258
|
+
visibleSkills: number;
|
|
259
|
+
at: number;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
let lastSkillCompressionOutcome: SkillCompressionOutcome | undefined;
|
|
263
|
+
|
|
264
|
+
export function recordSkillCompressionOutcome(outcome: SkillCompressionOutcome): void {
|
|
265
|
+
lastSkillCompressionOutcome = outcome;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function getLastSkillCompressionOutcome(): SkillCompressionOutcome | undefined {
|
|
269
|
+
return lastSkillCompressionOutcome;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The reason the skill list in `prompt` is left unchanged; call only when neither compression path applied. */
|
|
273
|
+
export function explainSkillCompressionSkip(prompt: string, opts: BuildSystemPromptOptions): string {
|
|
274
|
+
if (!featureEnabled("skillCompression", NO_SKILL_COMPRESSION_ENV, true)) return "skill compression is turned off";
|
|
275
|
+
const visible = (opts.skills ?? []).filter((skill) => !skill.disableModelInvocation);
|
|
276
|
+
if (visible.length < SKILL_COMPRESSION_MIN_COUNT) {
|
|
277
|
+
return `only ${visible.length} visible skill(s); compression starts at ${SKILL_COMPRESSION_MIN_COUNT}`;
|
|
278
|
+
}
|
|
279
|
+
if (!prompt.includes("<available_skills>")) {
|
|
280
|
+
return "the prompt has no skill list (no read tool selected, or another extension replaced it)";
|
|
281
|
+
}
|
|
282
|
+
if (!prompt.includes(formatSkillsForPrompt(opts.skills ?? [], skillFileReadTool(opts) ?? "read").trim())) {
|
|
283
|
+
return "Pi's skill list format was not recognised; Pi may have changed it (please report this)";
|
|
284
|
+
}
|
|
285
|
+
return "the compressed list would not be shorter";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function describeSkillCompressionOutcome(outcome: SkillCompressionOutcome | undefined = lastSkillCompressionOutcome): string {
|
|
289
|
+
if (!outcome) return "Skill compression: no prompt built yet in this process";
|
|
290
|
+
const skills = `${outcome.visibleSkills} visible skill(s)`;
|
|
291
|
+
if (outcome.applied === "section") return `Skill compression: applied (structured skills section, ${skills})`;
|
|
292
|
+
if (outcome.applied === "string") return `Skill compression: applied (prompt text substitution, ${skills})`;
|
|
293
|
+
return `Skill compression: not applied — ${outcome.reason ?? "unknown reason"}`;
|
|
294
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { LOG_PREFIX, type PiModel, lower } from "./common.ts";
|
|
2
|
+
import { isDeepSeekWireCompatApplicable } from "./compat-advice.ts";
|
|
3
|
+
import { getOptionalAssistantHttpStatus } from "./diagnostics.ts";
|
|
4
|
+
import { type FixSuggestion } from "./fix-types.ts";
|
|
5
|
+
import { getAssistantRecord, getCompat, isDeepSeekLikeModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOpenAICompatibleProxyApi, isPiBuiltInLlamaCppModel, modelKey } from "./model-identity.ts";
|
|
6
|
+
import { isActionableModelsJsonFixReceipt, readModelsJsonFixReceipt } from "./models-json-fix.ts";
|
|
7
|
+
import { firstNonEmptyString } from "./routing.ts";
|
|
8
|
+
import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const ANTHROPIC_TTL_FALLBACK_SYMBOL = Symbol.for("pi.cache.optimizer.anthropic-ttl-fallback.v1");
|
|
12
|
+
|
|
13
|
+
export const REASONING_PROTOCOL_FALLBACK_SYMBOL = Symbol.for("pi.cache.optimizer.reasoning-protocol-fallback.v1");
|
|
14
|
+
|
|
15
|
+
export type AnthropicTtlFallbackStateV1 = {
|
|
16
|
+
version: 1;
|
|
17
|
+
modelKeys: Set<string>;
|
|
18
|
+
warnedModelKeys: Set<string>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ReasoningProtocolFallbackStateV1 = {
|
|
22
|
+
version: 1;
|
|
23
|
+
modelKeys: Set<string>;
|
|
24
|
+
warnedModelKeys: Set<string>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function getReasoningProtocolFallbackState(): ReasoningProtocolFallbackStateV1 {
|
|
28
|
+
const globals = globalThis as Record<symbol, unknown>;
|
|
29
|
+
const existing = globals[REASONING_PROTOCOL_FALLBACK_SYMBOL] as Partial<ReasoningProtocolFallbackStateV1> | undefined;
|
|
30
|
+
if (
|
|
31
|
+
existing?.version === 1 &&
|
|
32
|
+
existing.modelKeys instanceof Set &&
|
|
33
|
+
existing.warnedModelKeys instanceof Set
|
|
34
|
+
) {
|
|
35
|
+
return existing as ReasoningProtocolFallbackStateV1;
|
|
36
|
+
}
|
|
37
|
+
const state: ReasoningProtocolFallbackStateV1 = {
|
|
38
|
+
version: 1,
|
|
39
|
+
modelKeys: new Set<string>(),
|
|
40
|
+
warnedModelKeys: new Set<string>(),
|
|
41
|
+
};
|
|
42
|
+
globals[REASONING_PROTOCOL_FALLBACK_SYMBOL] = state;
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getAnthropicTtlFallbackState(): AnthropicTtlFallbackStateV1 {
|
|
47
|
+
const globals = globalThis as Record<symbol, unknown>;
|
|
48
|
+
const existing = globals[ANTHROPIC_TTL_FALLBACK_SYMBOL] as Partial<AnthropicTtlFallbackStateV1> | undefined;
|
|
49
|
+
if (
|
|
50
|
+
existing?.version === 1 &&
|
|
51
|
+
existing.modelKeys instanceof Set &&
|
|
52
|
+
existing.warnedModelKeys instanceof Set
|
|
53
|
+
) {
|
|
54
|
+
return existing as AnthropicTtlFallbackStateV1;
|
|
55
|
+
}
|
|
56
|
+
const state: AnthropicTtlFallbackStateV1 = {
|
|
57
|
+
version: 1,
|
|
58
|
+
modelKeys: new Set<string>(),
|
|
59
|
+
warnedModelKeys: new Set<string>(),
|
|
60
|
+
};
|
|
61
|
+
globals[ANTHROPIC_TTL_FALLBACK_SYMBOL] = state;
|
|
62
|
+
return state;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function hasPromptCacheRetentionUnsupportedText(value: unknown): boolean {
|
|
66
|
+
const normalized = lower(value);
|
|
67
|
+
if (!normalized.includes("prompt_cache_retention")) return false;
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
"unsupported parameter",
|
|
71
|
+
"unsupported_parameter",
|
|
72
|
+
"unknown parameter",
|
|
73
|
+
"not supported",
|
|
74
|
+
"unsupported field",
|
|
75
|
+
"extra inputs",
|
|
76
|
+
"not permitted",
|
|
77
|
+
"unrecognized",
|
|
78
|
+
].some((needle) => normalized.includes(needle));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown): boolean {
|
|
82
|
+
const record = getAssistantRecord(message);
|
|
83
|
+
return record?.stopReason === "error" &&
|
|
84
|
+
hasPromptCacheRetentionUnsupportedText(record.errorMessage);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
|
|
88
|
+
const normalized = lower(value)
|
|
89
|
+
.replace(/["'`]/g, "")
|
|
90
|
+
.replace(/[\s_-]+/g, " ")
|
|
91
|
+
.replace(/\s+/g, " ")
|
|
92
|
+
.trim();
|
|
93
|
+
const key = String.raw`(?:prompt ?cache ?key|promptcachekey)`;
|
|
94
|
+
const field = String.raw`(?:parameter|field|input|argument)`;
|
|
95
|
+
const unsupported = String.raw`(?:unsupported|unknown|unrecognized|unexpected|not supported|not allowed|not permitted|must be omitted|should be omitted)`;
|
|
96
|
+
if (!new RegExp(key).test(normalized)) return false;
|
|
97
|
+
|
|
98
|
+
// Keep this deliberately grammar-bound. A rejected value or a conditional
|
|
99
|
+
// restriction (for example, "not allowed when temperature is set") is not
|
|
100
|
+
// proof that the endpoint lacks support for the field itself.
|
|
101
|
+
const terminal = String.raw`(?=$|[}\]>,.;])`;
|
|
102
|
+
return (
|
|
103
|
+
new RegExp(String.raw`(?:unsupported|unknown|unrecognized|unexpected)\s+${field}\s*[:=]?\s*${key}${terminal}`).test(normalized) ||
|
|
104
|
+
new RegExp(String.raw`(?:extra\s+inputs?|${field}\s+not\s+(?:allowed|permitted|supported))\s*[:=]\s*${key}${terminal}`).test(normalized) ||
|
|
105
|
+
new RegExp(String.raw`${key}(?:\s+${field})?\s*[:=]?\s*(?:is\s+)?${unsupported}${terminal}`).test(normalized)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function hasPromptCacheKeyUnsupportedErrorMessage(message: unknown): boolean {
|
|
110
|
+
const record = getAssistantRecord(message);
|
|
111
|
+
return record?.stopReason === "error" &&
|
|
112
|
+
getOptionalAssistantHttpStatus(record) === 400 &&
|
|
113
|
+
hasPromptCacheKeyUnsupportedText(record.errorMessage);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function hasReasoningProtocolRejectionText(value: unknown): boolean {
|
|
117
|
+
const normalized = lower(value).replace(/\s+/g, " ").trim();
|
|
118
|
+
if (!normalized) return false;
|
|
119
|
+
|
|
120
|
+
// Match only a rejection that is attached to the `thinking` parameter. Do
|
|
121
|
+
// not merely look for both parameter names: `reasoning_effort is not
|
|
122
|
+
// supported; use thinking` is the opposite direction and must not activate
|
|
123
|
+
// this fallback.
|
|
124
|
+
const thinkingParameterRejectionPatterns = [
|
|
125
|
+
/(?:^|[^a-z0-9_])["'`]?thinking["'`]?(?:\s+(?:parameter|field|argument))?\s*(?:is\s+)?(?:not supported|unsupported|unknown|unrecognized|not allowed|not permitted|rejected|invalid|not valid|not accepted|disallowed|not a valid(?:\s+(?:parameter|field|argument))?)(?![a-z0-9_])/,
|
|
126
|
+
/(?:^|[^a-z0-9_])(?:unsupported|unknown|unrecognized|invalid|disallowed|rejected|not\s+(?:a\s+)?valid|not\s+accepted|not\s+allowed|not\s+permitted)(?:[_ ](?:parameter|field|argument))?\s*[:=]?\s*["'`]?thinking["'`]?(?![a-z0-9_])/,
|
|
127
|
+
/(?:^|[^a-z0-9_])(?:extra\s+inputs?|additional\s+(?:inputs?|parameters?))\s+(?:are\s+)?(?:not permitted|not allowed|unsupported)\s*[:=]?\s*["'`]?thinking["'`]?(?![a-z0-9_])/,
|
|
128
|
+
];
|
|
129
|
+
let rejectionEnd = -1;
|
|
130
|
+
for (const pattern of thinkingParameterRejectionPatterns) {
|
|
131
|
+
const match = pattern.exec(normalized);
|
|
132
|
+
if (match && match.index + match[0].length > rejectionEnd) {
|
|
133
|
+
rejectionEnd = match.index + match[0].length;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (rejectionEnd < 0) return false;
|
|
137
|
+
|
|
138
|
+
// The provider must direct the caller to reasoning_effort after the
|
|
139
|
+
// rejection. Limiting this to the short suffix after the matched rejection
|
|
140
|
+
// keeps generic documentation or an unrelated earlier sentence from being
|
|
141
|
+
// treated as runtime protocol evidence.
|
|
142
|
+
const recommendation = normalized.slice(rejectionEnd, rejectionEnd + 260);
|
|
143
|
+
if (!/\breasoning[_\.]effort\b/.test(recommendation)) return false;
|
|
144
|
+
|
|
145
|
+
const recommendationClauses = recommendation.split(/[.;!?\n]/);
|
|
146
|
+
return recommendationClauses.some((clause) => {
|
|
147
|
+
if (!/\breasoning[_\.]effort\b/.test(clause)) return false;
|
|
148
|
+
// A target mentioned inside a negated/disabled clause is not positive
|
|
149
|
+
// protocol guidance, even if words such as `must` or `supported` occur.
|
|
150
|
+
if (
|
|
151
|
+
/\b(?:do\s+not|don't|never|avoid)\s+(?:use|set|send|pass|provide)?\s*["'`]?reasoning[_\.]effort\b/.test(clause) ||
|
|
152
|
+
/\breasoning[_\.]effort\b[^.;]{0,80}\b(?:must|should|may|do)\s+(?:not|never)\b/.test(clause) ||
|
|
153
|
+
/\breasoning[_\.]effort\b[^.;]{0,80}\b(?:unsupported|disabled|unavailable|not\s+(?:supported|accepted|allowed|available|enabled|required|recommended|expected))\b/.test(clause)
|
|
154
|
+
) return false;
|
|
155
|
+
|
|
156
|
+
return [
|
|
157
|
+
/(?:use|try|set|send|pass|provide)\s+(?:the\s+)?(?:top[- ]level\s+)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
|
|
158
|
+
/(?:reasoning[_\.]effort)\b[^.;]{0,120}(?:instead|required|must\s+be|should\s+be|is\s+(?:supported|accepted|preferred|recommended|expected))\b/,
|
|
159
|
+
/(?:parameter|field|option)\s+(?:is|should be)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
|
|
160
|
+
/instead[^.;]{0,120}(?:use|try|set|send|pass|provide)\s+(?:the\s+)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
|
|
161
|
+
/(?:replace|change|switch)\s+(?:the\s+)?["'`]?thinking["'`]?\s+(?:with|to)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
|
|
162
|
+
].some((pattern) => pattern.test(clause));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function hasReasoningProtocolRejectionErrorMessage(message: unknown): boolean {
|
|
167
|
+
const record = getAssistantRecord(message);
|
|
168
|
+
if (!record || record.stopReason !== "error") return false;
|
|
169
|
+
// Finalized assistant messages do not expose a separate status field in the
|
|
170
|
+
// normal Pi path, so recover it only from structured diagnostics or the
|
|
171
|
+
// adapter's status-shaped error prefix. A text-only 400-looking parameter
|
|
172
|
+
// message is not enough evidence for a protocol repair.
|
|
173
|
+
return getOptionalAssistantHttpStatus(record) === 400 &&
|
|
174
|
+
hasReasoningProtocolRejectionText(record.errorMessage);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function isReasoningProtocolRejectionSignalApplicable(model: PiModel | undefined): boolean {
|
|
178
|
+
// A provider error can teach us which protocol it expects, so this gate is
|
|
179
|
+
// intentionally broader than the explicit DeepSeek-format diagnostic gate.
|
|
180
|
+
// The model family identifies the affected cache/compat bucket; the error
|
|
181
|
+
// text, not the model name, supplies the wire-protocol evidence.
|
|
182
|
+
return !!model && isOpenAICompatibleProxyApi(model.api) && isDeepSeekLikeModel(model);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function isReasoningProtocolRejectionForModel(message: unknown, model: PiModel | undefined): boolean {
|
|
186
|
+
if (!model || !isReasoningProtocolRejectionSignalApplicable(model)) return false;
|
|
187
|
+
if (!hasReasoningProtocolRejectionErrorMessage(message)) return false;
|
|
188
|
+
const record = getAssistantRecord(message);
|
|
189
|
+
if (!record) return false;
|
|
190
|
+
const messageProvider = firstNonEmptyString(record.provider);
|
|
191
|
+
const messageModel = firstNonEmptyString(record.responseModel, record.model);
|
|
192
|
+
// An explicit identity in the finalized assistant error is authoritative.
|
|
193
|
+
// Do not broaden a model-scoped observation to another DeepSeek-named model.
|
|
194
|
+
return (!messageProvider || messageProvider === model.provider) &&
|
|
195
|
+
(!messageModel || messageModel === model.id);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function notifyReasoningProtocolObservation(
|
|
199
|
+
model: PiModel,
|
|
200
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
201
|
+
rejectedModelKeys: Set<string>,
|
|
202
|
+
warnedModelKeys: Set<string>,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const key = modelKey(model);
|
|
205
|
+
rejectedModelKeys.add(key);
|
|
206
|
+
if (warnedModelKeys.has(key)) return;
|
|
207
|
+
warnedModelKeys.add(key);
|
|
208
|
+
|
|
209
|
+
const receipt = await readModelsJsonFixReceipt();
|
|
210
|
+
const matchingReceipt = isActionableModelsJsonFixReceipt(receipt) &&
|
|
211
|
+
receipt.provider === model.provider &&
|
|
212
|
+
receipt.modelId === model.id;
|
|
213
|
+
const recovery = matchingReceipt
|
|
214
|
+
? "A matching confirmed fix receipt exists; run /cache-optimizer rollback to undo it safely."
|
|
215
|
+
: "Run /cache-optimizer fix to review a model-scoped repair.";
|
|
216
|
+
ctx.ui.notify(
|
|
217
|
+
`⚠️ ${LOG_PREFIX}: ${key} rejected the configured reasoning format. ${recovery} ` +
|
|
218
|
+
"No configuration was changed automatically.",
|
|
219
|
+
"warning",
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function fixSuggestionIdentity(model: PiModel): { providerLabel: string; modelId: string } {
|
|
224
|
+
const key = modelKey(model);
|
|
225
|
+
const slashIdx = key.indexOf("/");
|
|
226
|
+
return {
|
|
227
|
+
providerLabel: slashIdx > 0 ? key.slice(0, slashIdx) : key,
|
|
228
|
+
modelId: model.id,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function mergeFixSuggestions(...suggestions: Array<FixSuggestion | undefined>): FixSuggestion | undefined {
|
|
233
|
+
const present = suggestions.filter((suggestion): suggestion is FixSuggestion => suggestion !== undefined);
|
|
234
|
+
if (present.length === 0) return undefined;
|
|
235
|
+
const first = present[0];
|
|
236
|
+
return {
|
|
237
|
+
providerLabel: first.providerLabel,
|
|
238
|
+
modelId: first.modelId,
|
|
239
|
+
compatKeys: Object.assign({}, ...present.map((suggestion) => suggestion.compatKeys)),
|
|
240
|
+
forceModelLevel: present.some((suggestion) => suggestion.forceModelLevel === true),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function buildReasoningProtocolFixSuggestion(
|
|
245
|
+
model: PiModel,
|
|
246
|
+
protocolRejectionObserved = false,
|
|
247
|
+
): FixSuggestion | undefined {
|
|
248
|
+
if (!protocolRejectionObserved) return undefined;
|
|
249
|
+
if (!isDeepSeekLikeModel(model) || !isOpenAICompatibleProxyApi(model.api)) return undefined;
|
|
250
|
+
|
|
251
|
+
const compat = getCompat(model);
|
|
252
|
+
// Explicit provider-specific formats are user/provider evidence in their own
|
|
253
|
+
// right. Do not replace qwen/openrouter/together/etc. merely because a
|
|
254
|
+
// generic error happened to mention both reasoning parameter names. The
|
|
255
|
+
// evidence-driven repair is limited to the old DeepSeek `thinking` format,
|
|
256
|
+
// an explicit standard OpenAI format, or an absent format that needs to be
|
|
257
|
+
// made unambiguous after the provider's rejection.
|
|
258
|
+
if (
|
|
259
|
+
compat.thinkingFormat !== undefined &&
|
|
260
|
+
compat.thinkingFormat !== "deepseek" &&
|
|
261
|
+
compat.thinkingFormat !== "openai"
|
|
262
|
+
) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const identity = fixSuggestionIdentity(model);
|
|
267
|
+
const compatKeys: Record<string, unknown> = {};
|
|
268
|
+
if (compat.thinkingFormat !== "openai") {
|
|
269
|
+
compatKeys.thinkingFormat = "openai";
|
|
270
|
+
}
|
|
271
|
+
if (compat.supportsReasoningEffort !== true) {
|
|
272
|
+
compatKeys.supportsReasoningEffort = true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Replay behavior is changed only when the current effective configuration
|
|
276
|
+
// actually enabled it. Never invent a false value merely because the model
|
|
277
|
+
// family is named DeepSeek or because a provider supports reasoning effort.
|
|
278
|
+
if (compat.requiresReasoningContentOnAssistantMessages === true) {
|
|
279
|
+
compatKeys.requiresReasoningContentOnAssistantMessages = false;
|
|
280
|
+
}
|
|
281
|
+
if (Object.keys(compatKeys).length === 0) return undefined;
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
...identity,
|
|
285
|
+
// Protocol observations are always model-scoped. A provider may expose
|
|
286
|
+
// DeepSeek-named models with different wire formats, so sibling models
|
|
287
|
+
// must never inherit this evidence-driven change.
|
|
288
|
+
compatKeys,
|
|
289
|
+
forceModelLevel: true,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function hasExplicitDeepSeekReasoningProtocol(model: PiModel): boolean {
|
|
294
|
+
return isDeepSeekWireCompatApplicable(model);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function isExplicitPromptCacheRetentionUnsupportedApplicable(model: PiModel): boolean {
|
|
298
|
+
// A finalized assistant error with an explicit unsupported-parameter signal
|
|
299
|
+
// proves that prompt_cache_retention reached this provider/model. Do not
|
|
300
|
+
// require compat inherited from the active fallback model: router shells may
|
|
301
|
+
// have no upstream compat metadata when no live routing registry is present.
|
|
302
|
+
return isOpenAICompatibleApi(model.api) &&
|
|
303
|
+
!isOfficialOpenAIBaseUrl(model) &&
|
|
304
|
+
!isPiBuiltInLlamaCppModel(model);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function hasPromptCacheRetentionUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
|
|
308
|
+
if (!headers) return false;
|
|
309
|
+
return hasPromptCacheRetentionUnsupportedText(
|
|
310
|
+
Object.entries(headers)
|
|
311
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
312
|
+
.join("\n"),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function hasPromptCacheKeyUnsupportedSignal(headers: Record<string, string> | undefined): boolean {
|
|
317
|
+
if (!headers) return false;
|
|
318
|
+
return Object.entries(headers).some(([key, value]) => hasPromptCacheKeyUnsupportedText(`${key}: ${value}`));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function hasReasoningProtocolRejectionSignal(headers: Record<string, string> | undefined): boolean {
|
|
322
|
+
if (!headers) return false;
|
|
323
|
+
// Each response header is one diagnostic unit. Joining all values can pair a
|
|
324
|
+
// rejection from one header with unrelated documentation in another.
|
|
325
|
+
return Object.entries(headers).some(([key, headerValue]) =>
|
|
326
|
+
hasReasoningProtocolRejectionText(`${key}: ${headerValue}`)
|
|
327
|
+
);
|
|
328
|
+
}
|