pi-cache-optimizer 2.8.25 → 2.8.26

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 CHANGED
@@ -63,7 +63,7 @@ Run `/reload` in Pi after install/update/remove so extension hooks refresh.
63
63
 
64
64
  On Pi 0.79.7 and newer, `pi update` updates Pi itself only. To update installed Pi packages such as this extension, run `pi update --extensions` (packages only) or `pi update --all` (Pi + packages).
65
65
 
66
- This extension requires Pi 0.82+ and is validated against Pi 1.1.0. It uses the official Pi package types directly for type-checking, along with extension hooks, `getAgentDir()`, and prompt options shared by those versions; it does not depend on Pi 0.83+ APIs such as `ctx.scopedModels` or the bundled TypeBox 1.3 aliases. Native virtual model support and codemode nested-call coalescing activate only on Pi 0.99+ hosts that produce them; older hosts keep the previous behavior. The local development baseline uses Node.js 22.19.0 or newer, matching Pi 1.0.3's engine requirement.
66
+ This extension requires Pi 0.82+ and is validated against Pi 1.3.0. It uses the official Pi package types directly for type-checking, along with extension hooks, `getAgentDir()`, and prompt options shared by those versions; it does not depend on Pi 0.83+ APIs such as `ctx.scopedModels` or the bundled TypeBox 1.3 aliases. Native virtual model support and codemode nested-call coalescing activate only on Pi 0.99+ hosts that produce them; older hosts keep the previous behavior. The local development baseline uses Node.js 22.19.0 or newer, matching Pi 1.0.3's engine requirement.
67
67
 
68
68
  ## Commands
69
69
 
@@ -82,9 +82,10 @@ This extension requires Pi 0.82+ and is validated against Pi 1.1.0. It uses the
82
82
  | `/cache-optimizer fix` | Auto-repairs safe compat issues for the active model. Shows preview + risk warning, requires confirmation. It writes `models.json` only for native compat fixes, or the extension config for an evidenced `prompt_cache_key` issue. |
83
83
  | `/cache-optimizer fix prompt-cache` | Adds `promptCache` lifetimes for the active model so Pi's native cache warming can run. Preview, backup, receipt, and rollback. Requires confirmation. |
84
84
  | `/cache-optimizer fix prompt-cache-key` | Explicitly configures the active OpenAI-compatible provider/model to omit `prompt_cache_key` and `promptCacheKey` from the final request body. Requires confirmation. |
85
+ | `/cache-optimizer fix kimi-base-url` | Appends `/v1` to an official Kimi Code base URL (`api.kimi.com` / `api.kimi.ai`, path `/coding`) for a model on OpenAI Chat Completions (Pi 1.3.0+ `kimi-coding`). Preview, backup, receipt, and rollback. Requires confirmation. |
85
86
  | `/cache-optimizer rollback` | Reviews the latest matching confirmed fix and, after UI confirmation, restores either the extension config or `models.json` fix safely. |
86
87
 
87
- `/cache-optimizer` uses Pi's native Tab completion. Type `/cache-optimizer <Tab>` for the supported subcommands, `/cache-optimizer stats <Tab>` for `all` or `contributors`, `/cache-optimizer fix <Tab>` for `prompt-cache-key`, `/cache-optimizer c<Tab>` for `config`, `/cache-optimizer config <Tab>` for `footer-mode`, and `/cache-optimizer config footer-mode <Tab>` for `total`, `session`, or `process`. Suggestions are prefix-filtered and invalid prefixes are left to Pi's normal fallback behavior.
88
+ `/cache-optimizer` uses Pi's native Tab completion. Type `/cache-optimizer <Tab>` for the supported subcommands, `/cache-optimizer stats <Tab>` for `all` or `contributors`, `/cache-optimizer fix <Tab>` for `prompt-cache`, `prompt-cache-key`, or `kimi-base-url`, `/cache-optimizer c<Tab>` for `config`, `/cache-optimizer config <Tab>` for `footer-mode`, and `/cache-optimizer config footer-mode <Tab>` for `total`, `session`, or `process`. Suggestions are prefix-filtered and invalid prefixes are left to Pi's normal fallback behavior.
88
89
 
89
90
  The interactive `/cache-optimizer` menu includes `Footer mode`, where you can choose `total`, `session`, or `process`. `enable` / `disable` are current-process switches. For a persistent opt-out, use environment variables below.
90
91
 
@@ -209,7 +210,7 @@ Notes:
209
210
 
210
211
  Pi itself can keep a prompt cache alive by re-sending the last request with a one-token output cap shortly before the entry expires (the global `cacheWarming` setting: `off`, `streaming` (default), or `idle`; change it with `/settings` → Cache warming or `"cacheWarming"` in `~/.pi/agent/settings.json`). This extension does not run a keepalive timer of its own: a second warmer would pay for duplicate refreshes and cannot replay Pi's exact request.
211
212
 
212
- Pi only warms a model that declares a cache lifetime for the retention tier in use, has input/cache prices, and is replayable (Anthropic budget-based thinking is not). Custom proxy models in `models.json` often lack the first two, so warming never starts. For models whose prices are all zero, Pi stops with "cache economics unavailable"; this extension answers Pi's `cache_warming_decision` with `warm` in that case only, since a refresh is just a cache read plus one output token. Models with real prices keep Pi's own decision, and Pi's mode, lifetime, replayability and time limits still apply. Each refresh is a real request, so a channel that does bill despite the zero prices pays for it. Pi's `/session` still shows "cache economics unavailable" between refreshes, and if another extension also handles `cache_warming_decision`, the one Pi runs last wins. Turn it off with `/cache-optimizer config zero-price-warming off` or `PI_CACHE_OPTIMIZER_NO_ZERO_PRICE_WARMING=1`. `/cache-optimizer doctor` now reports the mode, the tier, and what is missing, with a snippet such as `"promptCache": {"short":300,"long":3600}` for `anthropic-messages` models. Put it on the model entry (custom models) or under the provider's `modelOverrides` (built-in models), or run `/cache-optimizer fix prompt-cache` to have it written for you: it previews the edit, asks for confirmation, keeps a timestamped backup and comments, and records a receipt so `/cache-optimizer rollback` removes exactly that `promptCache` object (or restores the whole file if nothing else changed). It writes `{"short":300,"long":3600}` for `anthropic-messages` models (Anthropic's documented 5m/1h, the values Pi ships for its own Claude models) and `{"short":300}` elsewhere; when the long tier is in use and no long lifetime is documented for the API, it refuses rather than guessing. It never merges into or overwrites an existing `promptCache`, and it does not create a provider entry that models.json lacks.
213
+ Pi only warms a model that declares a cache lifetime for the retention tier in use, has input/cache prices, and is replayable (Anthropic budget-based thinking is not). Custom proxy models in `models.json` often lack the first two, so warming never starts. For models whose prices are all zero, Pi stops with "cache economics unavailable"; this extension answers Pi's `cache_warming_decision` with `warm` in that case only, since a refresh is just a cache read plus one output token. Models with real prices keep Pi's own decision, and Pi's mode, lifetime, replayability and time limits still apply. Each refresh is a real request, so a channel that does bill despite the zero prices pays for it. Pi's `/session` still shows "cache economics unavailable" between refreshes, and if another extension also handles `cache_warming_decision`, the one Pi runs last wins. Two zeros are not treated as free: OpenRouter router models (ids starting with `openrouter/`, such as `openrouter/auto`), whose -1 placeholder price Pi 1.2.0+ shows as 0 although the routed model is billed, and models whose Pi 1.2.0 `cost.tiers` carry a positive price. Turn it off with `/cache-optimizer config zero-price-warming off` or `PI_CACHE_OPTIMIZER_NO_ZERO_PRICE_WARMING=1`. `/cache-optimizer doctor` now reports the mode, the tier, and what is missing, with a snippet such as `"promptCache": {"short":300,"long":3600}` for `anthropic-messages` models. Put it on the model entry (custom models) or under the provider's `modelOverrides` (built-in models), or run `/cache-optimizer fix prompt-cache` to have it written for you: it previews the edit, asks for confirmation, keeps a timestamped backup and comments, and records a receipt so `/cache-optimizer rollback` removes exactly that `promptCache` object (or restores the whole file if nothing else changed). It writes `{"short":300,"long":3600}` for `anthropic-messages` models (Anthropic's documented 5m/1h, the values Pi ships for its own Claude models) and `{"short":300}` elsewhere; when the long tier is in use and no long lifetime is documented for the API, it refuses rather than guessing. It never merges into or overwrites an existing `promptCache`, and it does not create a provider entry that models.json lacks.
213
214
 
214
215
  Streaming warming stops 60 minutes after the real request and idle warming after 30 minutes. With this extension's 1-hour retention, the refresh would fire at 54 minutes, so `idle` adds nothing; the 1-hour lifetime already covers breaks under an hour. Keep the default `streaming` unless you use 5-minute caches and often return within half an hour.
215
216
 
@@ -284,6 +285,8 @@ For Kimi Coding K3 custom channels, use model-level compat when the provider con
284
285
 
285
286
  Pi 0.80.9+ already includes Kimi K3 in built-in Kimi Coding, Moonshot AI / China, OpenRouter, and Vercel AI Gateway catalogs. The Moonshot/OpenRouter variants use their OpenAI-compatible transport and continue through the normal Kimi footer/proxy path; the adaptive compat above applies only to `anthropic-messages` Kimi Coding channels.
286
287
 
288
+ **Pi 1.3.0+:** the built-in `kimi-coding` provider now uses OpenAI Chat Completions (`openai-completions`) at `https://api.kimi.com/coding/v1` (international: `https://api.kimi.ai/coding/v1`), and Pi ships its compat (`thinkingFormat: "openai"`, `reasoning_content` replay). The K3 adaptive compat above therefore only applies to older Pi versions or custom channels that still declare `anthropic-messages`. The official Kimi Code hosts are first-party origins, not proxies, so doctor/compat/fix no longer flag them for `sendSessionAffinityHeaders`; Kimi reached through a third-party relay is still checked as before. A `baseUrl` override left over from the Anthropic era (`https://api.kimi.com/coding`, no `/v1`) makes every Chat Completions request fail: doctor reports it, and `/cache-optimizer fix kimi-base-url` (or plain `fix`) appends `/v1` with a preview, backup, receipt and rollback. It only touches an official host with the bare `/coding` path on a model that actually uses `openai-completions`; proxies, other paths and Anthropic-protocol entries get a hint and are never changed. A provider-level `baseUrl` that also feeds a custom or built-in model on another API (for example the `anthropic-messages` built-ins of Pi < 1.3.0) is refused; set `baseUrl` on the model entry instead.
289
+
287
290
  `/cache-optimizer doctor` and `/cache-optimizer compat` detect missing flags and show copyable JSON.
288
291
 
289
292
  ## Auto-repair with `/cache-optimizer fix`
package/README.zh-CN.md CHANGED
@@ -63,7 +63,7 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
63
63
 
64
64
  Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
65
65
 
66
- 本扩展要求 Pi 0.82+,并已使用 Pi 1.1.0 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。原生虚拟模型支持与 codemode 嵌套工具调用合并只在会产生它们的 Pi 0.99+ 上生效,较早版本保持原有行为。本地开发基线需要 Node.js 22.19.0 或更高版本,与 Pi 1.0.3 的 engine 要求一致。
66
+ 本扩展要求 Pi 0.82+,并已使用 Pi 1.3.0 验证。TypeScript 校验直接使用官方 Pi package 类型,同时只使用这些版本共有的 extension hooks、`getAgentDir()` 和 prompt options;不依赖 Pi 0.83+ 专有 API(例如 `ctx.scopedModels` 或 bundled TypeBox 1.3 aliases)。原生虚拟模型支持与 codemode 嵌套工具调用合并只在会产生它们的 Pi 0.99+ 上生效,较早版本保持原有行为。本地开发基线需要 Node.js 22.19.0 或更高版本,与 Pi 1.0.3 的 engine 要求一致。
67
67
 
68
68
  ## 命令
69
69
 
@@ -82,9 +82,10 @@ Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安
82
82
  | `/cache-optimizer fix` | 为当前模型自动修复安全的 compat 问题。展示预览 + 风险提示,需要用户确认;原生 compat 修复写 `models.json`,已观测到的 `prompt_cache_key` 问题写扩展配置。 |
83
83
  | `/cache-optimizer fix prompt-cache` | 为当前模型写入 `promptCache` 缓存时长,让 Pi 原生保温生效。带预览、备份、receipt 和回滚。需要确认。 |
84
84
  | `/cache-optimizer fix prompt-cache-key` | 明确把当前 OpenAI-compatible provider/model 配置为从最终请求体移除 `prompt_cache_key` 与 `promptCacheKey`。需要确认。 |
85
+ | `/cache-optimizer fix kimi-base-url` | 为使用 OpenAI Chat Completions 的模型(Pi 1.3.0+ `kimi-coding`)在官方 Kimi Code base URL(`api.kimi.com` / `api.kimi.ai`,路径 `/coding`)后补 `/v1`。带预览、备份、receipt 和回滚。需要确认。 |
85
86
  | `/cache-optimizer rollback` | 查看最近一次匹配的已确认修复;经 UI 确认后安全恢复扩展配置或 `models.json` 修复。 |
86
87
 
87
- `/cache-optimizer` 使用 Pi 原生 Tab 补全:输入 `/cache-optimizer <Tab>` 查看支持的子命令,输入 `/cache-optimizer stats <Tab>` 补全 `all` 或 `contributors`,输入 `/cache-optimizer fix <Tab>` 补全 `prompt-cache-key`,输入 `/cache-optimizer c<Tab>` 补全 `config`,输入 `/cache-optimizer config <Tab>` 补全 `footer-mode`,输入 `/cache-optimizer config footer-mode <Tab>` 补全 `total`、`session` 或 `process`。建议会按当前前缀过滤;无效前缀返回空结果,由 Pi 正常回退处理。
88
+ `/cache-optimizer` 使用 Pi 原生 Tab 补全:输入 `/cache-optimizer <Tab>` 查看支持的子命令,输入 `/cache-optimizer stats <Tab>` 补全 `all` 或 `contributors`,输入 `/cache-optimizer fix <Tab>` 补全 `prompt-cache`、`prompt-cache-key` 或 `kimi-base-url`,输入 `/cache-optimizer c<Tab>` 补全 `config`,输入 `/cache-optimizer config <Tab>` 补全 `footer-mode`,输入 `/cache-optimizer config footer-mode <Tab>` 补全 `total`、`session` 或 `process`。建议会按当前前缀过滤;无效前缀返回空结果,由 Pi 正常回退处理。
88
89
 
89
90
  交互式 `/cache-optimizer` 菜单包含 `Footer mode`,可以选择 `total`、`session` 或 `process`。`enable` / `disable` 是当前进程内开关。若要持久关闭某些能力,请使用下面的环境变量。
90
91
 
@@ -209,7 +210,7 @@ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention`
209
210
 
210
211
  Pi 自带缓存保温:在缓存条目过期前,用一个 token 的输出上限原样重放上一次请求(全局设置 `cacheWarming`:`off`、`streaming`(默认)或 `idle`;在 `/settings` → Cache warming 中修改,或在 `~/.pi/agent/settings.json` 中设置 `"cacheWarming"`)。本扩展不自行运行保活定时器:第二个保温器会重复计费,而且无法精确重放 Pi 的请求。
211
212
 
212
- Pi 只会为满足以下条件的模型保温:为当前保留档位声明了缓存时长、有输入/缓存价格、请求可重放(Anthropic 基于 budget 的 thinking 不可重放)。`models.json` 里的自定义代理模型常常缺前两项,保温根本不会启动。对价格全为 0 的模型,Pi 会以 “cache economics unavailable” 停止保温;本扩展仅在这种情况下通过 `cache_warming_decision` 返回 `warm`,因为一次刷新只是一次缓存读取加一个输出 token。有真实价格的模型仍按 Pi 自己的判断,Pi 的模式、缓存时长、可重放性和时间上限也照常生效。每次刷新都是一次真实请求,若渠道价格写 0 但实际计费,会产生费用。刷新间隔里 Pi 的 `/session` 仍会显示 “cache economics unavailable”;若其他扩展也处理 `cache_warming_decision`,以 Pi 最后执行的那个为准。可用 `/cache-optimizer config zero-price-warming off` 或 `PI_CACHE_OPTIMIZER_NO_ZERO_PRICE_WARMING=1` 关闭。`/cache-optimizer doctor` 现在会显示保温模式、档位和缺失项,并给出片段,例如 `anthropic-messages` 模型的 `"promptCache": {"short":300,"long":3600}`。自定义模型写在该模型条目中,内置模型写在 provider 的 `modelOverrides` 下;也可以运行 `/cache-optimizer fix prompt-cache` 一键写入:先预览、需确认、保留带时间戳的备份和注释,并记录 receipt,`/cache-optimizer rollback` 只会删除这次加入的 `promptCache` 对象(若文件之后没有改动则整文件恢复)。`anthropic-messages` 模型写入 `{"short":300,"long":3600}`(Anthropic 文档的 5 分钟/1 小时,与 Pi 内置 Claude 模型一致),其他 API 写入 `{"short":300}`;若当前使用长档位而该 API 没有文档化的长时长,则拒绝写入而不是猜测。已有 `promptCache` 时不会合并或覆盖,models.json 中不存在的 provider 条目也不会被创建。
213
+ Pi 只会为满足以下条件的模型保温:为当前保留档位声明了缓存时长、有输入/缓存价格、请求可重放(Anthropic 基于 budget 的 thinking 不可重放)。`models.json` 里的自定义代理模型常常缺前两项,保温根本不会启动。对价格全为 0 的模型,Pi 会以 “cache economics unavailable” 停止保温;本扩展仅在这种情况下通过 `cache_warming_decision` 返回 `warm`,因为一次刷新只是一次缓存读取加一个输出 token。有真实价格的模型仍按 Pi 自己的判断,Pi 的模式、缓存时长、可重放性和时间上限也照常生效。每次刷新都是一次真实请求,若渠道价格写 0 但实际计费,会产生费用。刷新间隔里 Pi 的 `/session` 仍会显示 “cache economics unavailable”;若其他扩展也处理 `cache_warming_decision`,以 Pi 最后执行的那个为准。有两种 0 不算免费:OpenRouter 路由模型(ID 以 `openrouter/` 开头,如 `openrouter/auto`),其 -1 占位价格在 Pi 1.2.0+ 显示为 0,但实际按路由到的模型计费;以及 Pi 1.2.0 `cost.tiers` 中有正价格的模型。可用 `/cache-optimizer config zero-price-warming off` 或 `PI_CACHE_OPTIMIZER_NO_ZERO_PRICE_WARMING=1` 关闭。`/cache-optimizer doctor` 现在会显示保温模式、档位和缺失项,并给出片段,例如 `anthropic-messages` 模型的 `"promptCache": {"short":300,"long":3600}`。自定义模型写在该模型条目中,内置模型写在 provider 的 `modelOverrides` 下;也可以运行 `/cache-optimizer fix prompt-cache` 一键写入:先预览、需确认、保留带时间戳的备份和注释,并记录 receipt,`/cache-optimizer rollback` 只会删除这次加入的 `promptCache` 对象(若文件之后没有改动则整文件恢复)。`anthropic-messages` 模型写入 `{"short":300,"long":3600}`(Anthropic 文档的 5 分钟/1 小时,与 Pi 内置 Claude 模型一致),其他 API 写入 `{"short":300}`;若当前使用长档位而该 API 没有文档化的长时长,则拒绝写入而不是猜测。已有 `promptCache` 时不会合并或覆盖,models.json 中不存在的 provider 条目也不会被创建。
213
214
 
214
215
  streaming 保温在真实请求后 60 分钟停止,idle 保温在 30 分钟后停止。本扩展使用 1 小时保留时,刷新会在第 54 分钟才触发,所以 `idle` 没有额外作用;1 小时的缓存本身已覆盖一小时内的中断。除非你使用 5 分钟缓存且经常在半小时内回来,否则保持默认的 `streaming` 即可。
215
216
 
@@ -284,6 +285,8 @@ Kimi Coding K3 自定义渠道如果包含混合模型,建议使用模型级 c
284
285
 
285
286
  Pi 0.80.9+ 已在内置 Kimi Coding、Moonshot AI / 中国区、OpenRouter 和 Vercel AI Gateway catalog 中加入 Kimi K3。Moonshot/OpenRouter 变体使用 OpenAI-compatible transport,继续走普通 Kimi footer / proxy 路径;上面的 adaptive compat 只适用于 `anthropic-messages` Kimi Coding 渠道。
286
287
 
288
+ **Pi 1.3.0+:** 内置 `kimi-coding` 改用 OpenAI Chat Completions(`openai-completions`),地址为 `https://api.kimi.com/coding/v1`(海外:`https://api.kimi.ai/coding/v1`),compat 由 Pi 自带(`thinkingFormat: "openai"`、`reasoning_content` 回传)。因此上面的 K3 adaptive compat 只适用于旧版 Pi,或仍声明 `anthropic-messages` 的自定义渠道。官方 Kimi Code 地址是源站而不是代理,doctor/compat/fix 不再对它提示缺 `sendSessionAffinityHeaders`;经第三方中转访问 Kimi 时仍照常检查。Anthropic 时代留下的 `baseUrl` 覆盖(`https://api.kimi.com/coding`,缺 `/v1`)会让所有 Chat Completions 请求失败:doctor 会报告,`/cache-optimizer fix kimi-base-url`(或直接 `fix`)会补上 `/v1`,带预览、备份、receipt 和回滚。只处理官方域名、路径恰为 `/coding`、且模型实际使用 `openai-completions` 的情况;代理、其他路径和 Anthropic 协议条目只提示,不会修改。provider 级 `baseUrl` 若同时被其他 API 的自定义或内置模型继承(例如 Pi < 1.3.0 的 `anthropic-messages` 内置模型),则拒绝修改;请改在该模型条目上设置 `baseUrl`。
289
+
287
290
  `/cache-optimizer doctor` 和 `/cache-optimizer compat` 会检测缺失的 flag 并显示可复制的 JSON。
288
291
 
289
292
  ## 使用 `/cache-optimizer fix` 自动修复
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.25",
3
+ "version": "2.8.26",
4
4
  "description": "Improve Pi prompt/KV cache hit rates with stable prompts, OpenAI-compatible cache keys, proxy compat warnings, and footer cache stats.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -37,7 +37,7 @@
37
37
  "check:modules": "node scripts/check-module-graph.mjs"
38
38
  },
39
39
  "devDependencies": {
40
- "@earendil-works/pi-coding-agent": "^1.1.0",
40
+ "@earendil-works/pi-coding-agent": "^1.3.0",
41
41
  "@earendil-works/pi-server": "^1.1.0",
42
42
  "@types/node": "^22.0.0",
43
43
  "jiti": "^2.7.0",
@@ -70,11 +70,46 @@ function hasPricing(model: PiModel): boolean {
70
70
  return ["input", "cacheRead", "cacheWrite"].some((key) => typeof cost[key] === "number" && (cost[key] as number) > 0);
71
71
  }
72
72
 
73
- /** Every price Pi uses for warming economics is zero or missing. */
74
- function isZeroPriced(model: PiModel): boolean {
73
+ const PRICE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const;
74
+
75
+ function hasPositivePrice(record: Record<string, unknown> | undefined): boolean {
76
+ if (!record) return false;
77
+ return PRICE_KEYS.some((key) => typeof record[key] === "number" && (record[key] as number) > 0);
78
+ }
79
+
80
+ /**
81
+ * OpenRouter router models (`openrouter/auto`, `openrouter/free`-style ids)
82
+ * forward to whichever model the router picks. OpenRouter lists them with a -1
83
+ * placeholder price, which Pi 1.2.0+ normalises to 0, so a zero cost block
84
+ * means "unknown", not "free". Matched on the id prefix so the same router
85
+ * reached through another provider entry is covered too.
86
+ */
87
+ export function isOpenRouterRouterModel(model: PiModel): boolean {
88
+ return typeof model.id === "string" && model.id.toLowerCase().startsWith("openrouter/");
89
+ }
90
+
91
+ function costTiers(cost: Record<string, unknown>): unknown[] {
92
+ return Array.isArray(cost.tiers) ? cost.tiers : [];
93
+ }
94
+
95
+ /** Prompt-length tiers carry prices but the base cost block does not. */
96
+ function hasOnlyTierPrices(model: PiModel): boolean {
97
+ const cost = asRecord((model as unknown as Record<string, unknown>).cost);
98
+ if (!cost || hasPositivePrice(cost)) return false;
99
+ return costTiers(cost).some((tier) => hasPositivePrice(asRecord(tier)));
100
+ }
101
+
102
+ /**
103
+ * Every price Pi uses for warming economics is zero or missing, including the
104
+ * prompt-length cost tiers Pi 1.2.0 added, and the zero is not a placeholder
105
+ * for an unknown router price.
106
+ */
107
+ export function isZeroPriced(model: PiModel): boolean {
108
+ if (isOpenRouterRouterModel(model)) return false;
75
109
  const cost = asRecord((model as unknown as Record<string, unknown>).cost);
76
110
  if (!cost) return true;
77
- return ["input", "output", "cacheRead", "cacheWrite"].every((key) => !(typeof cost[key] === "number" && (cost[key] as number) > 0));
111
+ if (hasPositivePrice(cost)) return false;
112
+ return !costTiers(cost).some((tier) => hasPositivePrice(asRecord(tier)));
78
113
  }
79
114
 
80
115
  function isReplayableForWarming(model: PiModel): boolean {
@@ -141,6 +176,12 @@ export function buildCacheWarmingDiagnosis(model: PiModel, input: CacheWarmingDi
141
176
  if (!hasPricing(model)) {
142
177
  if (input.zeroPriceWarming && isZeroPriced(model)) {
143
178
  lines.push("- The model has no input/cache prices. Pi would skip warming; this extension overrides that (config zero-price-warming on).");
179
+ } else if (isOpenRouterRouterModel(model)) {
180
+ issues.push("pricing_unknown");
181
+ lines.push("- ⚠️ OpenRouter router models list a placeholder price that Pi shows as 0, but the routed model is billed at its own price. Pi cannot estimate savings and skips warming; zero-price warming does not apply.");
182
+ } else if (hasOnlyTierPrices(model)) {
183
+ issues.push("pricing_missing");
184
+ lines.push("- ⚠️ The model has prompt-length price tiers but no base input/cache prices, so Pi cannot estimate savings and skips warming. Add base prices to the cost block.");
144
185
  } else {
145
186
  issues.push("pricing_missing");
146
187
  lines.push("- ⚠️ The model has no input/cache prices, so Pi cannot estimate savings and skips warming. Add a cost block, or turn on /cache-optimizer config zero-price-warming.");
@@ -37,7 +37,7 @@ export const FEATURE_COMMAND_MAP: Record<string, PersistedCacheOptimizerFeature>
37
37
 
38
38
  export const CACHE_OPTIMIZER_STATS_ARGUMENTS = ["all", "contributors"] as const;
39
39
 
40
- export const CACHE_OPTIMIZER_FIX_ARGUMENTS = ["prompt-cache", "prompt-cache-key"] as const;
40
+ export const CACHE_OPTIMIZER_FIX_ARGUMENTS = ["prompt-cache", "prompt-cache-key", "kimi-base-url"] as const;
41
41
 
42
42
  export function filterCommandCompletionItems(
43
43
  values: readonly string[],
package/src/command.ts CHANGED
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { selectAdapterForModel } from "./adapters.ts";
4
4
  import { backupTimestamp, hashText, readRegularTextFile } from "./atomic-fs.ts";
5
5
  import { choosePromptCacheLifetimes, planPromptCacheFix, validateWrittenPromptCacheFix } from "./prompt-cache-fix.ts";
6
+ import { buildKimiBaseUrlDoctorLines, diagnoseKimiCodeBaseUrl, planKimiBaseUrlFix, validateWrittenKimiBaseUrlFix } from "./kimi-base-url-fix.ts";
6
7
  import { FEATURE_COMMAND_MAP } from "./command-completion.ts";
7
8
  import { type PiModel, asRecord } from "./common.ts";
8
9
  import { describeMissingCacheCompatForModel, getModelsJsonDisplayPath, isAdaptiveThinkingCompatApplicable } from "./compat-advice.ts";
@@ -11,7 +12,7 @@ import { isZeroPriceWarmingEnabled, FOOTER_MODE_ENV, type FooterStatsMode, forma
11
12
  import { buildCompatDiagnosis, buildDoctorDiagnosis, buildLowHitDiagnosis, getCompatCheckNotApplicableLines, isCompatCheckApplicable, isDeepSeekCompatCheckApplicable, isPromptCacheKeyUnsupportedApplicable } from "./diagnostics.ts";
12
13
  import { locateModelOverrideInJsonc, parseJsonc } from "./jsonc.ts";
13
14
  import { invalidateModelsConfigCache, isValidModelsConfigForEffectiveCompat, modelKey } from "./model-identity.ts";
14
- import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeModelOverrideInsertion, composeProviderAffinityInsertion, createModelsJsonFixReceipt, createPromptCacheFixReceipt, formatCompatKeysForInsertion, formatMissingEntryManualSnippet, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./models-json-fix.ts";
15
+ import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeModelOverrideInsertion, composeProviderAffinityInsertion, createBaseUrlFixReceipt, createModelsJsonFixReceipt, createPromptCacheFixReceipt, formatCompatKeysForInsertion, formatMissingEntryManualSnippet, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./models-json-fix.ts";
15
16
  import { MODELS_JSON_PATH } from "./paths.ts";
16
17
  import { applyPromptCacheKeyConfigFix, isActionablePromptCacheKeyConfigReceipt, readPromptCacheKeyConfigReceiptSnapshot, rollbackPromptCacheKeyConfig } from "./prompt-cache-key-config.ts";
17
18
  import { getEffectiveCompatValueSource, isPromptCacheKeyOmittedForModel } from "./request-payload.ts";
@@ -121,7 +122,8 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
121
122
  zeroPriceWarming: isZeroPriceWarmingEnabled(),
122
123
  }).lines
123
124
  : [];
124
- const fullDiagnosis = [routeNote, diagnosis, describeSkillCompressionOutcome(), describePromptCacheRetentionDecision(getLastPromptCacheRetentionDecision()), ...warmingLines, ...lowHitLines].filter((line) => line !== undefined).join("\n");
125
+ const kimiBaseUrlLines = buildKimiBaseUrlDoctorLines(model);
126
+ const fullDiagnosis = [routeNote, diagnosis, kimiBaseUrlLines.length > 0 ? kimiBaseUrlLines.join("\n") : undefined, describeSkillCompressionOutcome(), describePromptCacheRetentionDecision(getLastPromptCacheRetentionDecision()), ...warmingLines, ...lowHitLines].filter((line) => line !== undefined).join("\n");
125
127
  cmdCtx.ui.notify(fullDiagnosis, "info");
126
128
  } else if (subcommand === "stats") {
127
129
  const aggregate = await refreshShardAggregate();
@@ -286,6 +288,8 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
286
288
 
287
289
  const rollbackScope = rollback.mode === "exact"
288
290
  ? "restore the exact pre-fix models.json because the file is unchanged since the fix"
291
+ : rollback.receipt.baseUrlChange
292
+ ? `restore baseUrl ${JSON.stringify(rollback.receipt.baseUrlChange.before)} (the value before the Kimi /v1 fix), preserving subsequent user changes`
289
293
  : rollback.receipt.promptCacheAdded
290
294
  ? "remove only the promptCache object the fix added (and restore any receipt-owned compat keys), preserving subsequent user changes"
291
295
  : "restore only the receipt-owned compat scalar keys and preserve subsequent user changes";
@@ -387,6 +391,13 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
387
391
  return;
388
392
  }
389
393
 
394
+ // A Kimi Code base URL without /v1 makes every request fail, so plain
395
+ // `fix` handles it before any compat suggestion.
396
+ if (commandParts[1] === "kimi-base-url" || (commandParts[1] === undefined && diagnoseKimiCodeBaseUrl(model).kind === "fixable")) {
397
+ await runKimiBaseUrlFix(cmdCtx, model);
398
+ return;
399
+ }
400
+
390
401
  const promptCacheKeyRequested = commandParts[1] === "prompt-cache-key";
391
402
  const promptCacheKeyFixRequested = promptCacheKeyRequested || promptCacheKeyFixApplies(model);
392
403
  if (promptCacheKeyRequested && !isPromptCacheKeyUnsupportedApplicable(model)) {
@@ -1002,6 +1013,7 @@ export function createCacheOptimizerCommandHandler(runtime: CommandRuntime) {
1002
1013
  diagnosis.push(" fix — Auto-fix compat issues (writes models.json or extension config, requires UI)");
1003
1014
  diagnosis.push(" fix prompt-cache — Add promptCache lifetimes so Pi's cache warming can run");
1004
1015
  diagnosis.push(" fix prompt-cache-key — Explicitly omit prompt_cache_key for the active model");
1016
+ diagnosis.push(" fix kimi-base-url — Append /v1 to an official Kimi Code base URL (Pi 1.3.0+ OpenAI Chat Completions)");
1005
1017
  diagnosis.push(" rollback — Undo the latest confirmed fix (requires UI confirmation)");
1006
1018
  diagnosis.push(" reset — Reset local provider/model stats for current model (does not affect upstream)");
1007
1019
  diagnosis.push("");
@@ -1140,3 +1152,116 @@ async function runPromptCacheFix(cmdCtx: ExtensionCommandContext, model: PiModel
1140
1152
  cmdCtx.ui.notify(`❌ Write failed: ${error instanceof Error ? error.message : String(error)}. No changes were kept.`, "error");
1141
1153
  }
1142
1154
  }
1155
+
1156
+ /**
1157
+ * `/cache-optimizer fix kimi-base-url`: append `/v1` to an official Kimi Code
1158
+ * base URL for a model on OpenAI Chat Completions. Same safety model as the
1159
+ * other fixes: preview + confirmation, timestamped backup, guarded atomic
1160
+ * write, post-write check, and a receipt /cache-optimizer rollback reverses.
1161
+ */
1162
+ async function runKimiBaseUrlFix(cmdCtx: ExtensionCommandContext, model: PiModel): Promise<void> {
1163
+ const status = diagnoseKimiCodeBaseUrl(model);
1164
+ if (status.kind === "hint") {
1165
+ cmdCtx.ui.notify([...status.lines, "No changes were made."].join("\n"), "info");
1166
+ return;
1167
+ }
1168
+ if (status.kind !== "fixable") {
1169
+ cmdCtx.ui.notify(`✅ ${modelKey(model)} does not need the Kimi Code /v1 fix. No changes were made.`, "info");
1170
+ return;
1171
+ }
1172
+ const manual = (reason: string): string => [
1173
+ `❌ ${reason}. No changes were made.`,
1174
+ "",
1175
+ `Change the baseUrl that ${modelKey(model)} uses in ${getModelsJsonDisplayPath()} from`,
1176
+ ` ${JSON.stringify(status.current)}`,
1177
+ "to",
1178
+ ` ${JSON.stringify(status.suggested)}`,
1179
+ "then run /reload.",
1180
+ ].join("\n");
1181
+
1182
+ if (!cmdCtx.hasUI) {
1183
+ cmdCtx.ui.notify(manual("Non-interactive terminal detected. The fix requires UI confirmation"), "warning");
1184
+ return;
1185
+ }
1186
+
1187
+ let current: { text: string; mode: number };
1188
+ try {
1189
+ current = await readRegularTextFile(MODELS_JSON_PATH);
1190
+ } catch {
1191
+ cmdCtx.ui.notify(manual(`Could not read ${getModelsJsonDisplayPath()} as a regular file`), "error");
1192
+ return;
1193
+ }
1194
+ let registeredModels: PiModel[] | undefined;
1195
+ try {
1196
+ const all = (cmdCtx as { modelRegistry?: { getAll?(): PiModel[] } }).modelRegistry?.getAll?.();
1197
+ registeredModels = Array.isArray(all) ? all : undefined;
1198
+ } catch {
1199
+ registeredModels = undefined;
1200
+ }
1201
+ const plan = planKimiBaseUrlFix(current.text, model, registeredModels);
1202
+ if ("error" in plan) {
1203
+ cmdCtx.ui.notify(manual(`Cannot edit models.json safely: ${plan.error}`), "error");
1204
+ return;
1205
+ }
1206
+
1207
+ const backupPath = `${MODELS_JSON_PATH}.backup-cache-optimizer-${backupTimestamp()}`;
1208
+ const pendingReceipt = await readModelsJsonFixReceipt();
1209
+ const confirmed = await cmdCtx.ui.confirm("Cache Optimizer — Fix Kimi Code base URL", [
1210
+ `📝 Preview of changes to ${getModelsJsonDisplayPath()}:`,
1211
+ `Location: ${plan.locationLabel}`,
1212
+ `Before: ${JSON.stringify(plan.before)}`,
1213
+ `After: ${JSON.stringify(plan.after)}`,
1214
+ plan.affectedNote ?? "",
1215
+ "",
1216
+ "Why: this model uses OpenAI Chat Completions (Pi 1.3.0+ kimi-coding), whose official endpoint is /coding/v1.",
1217
+ "Without /v1, requests go to /coding/chat/completions and fail.",
1218
+ "",
1219
+ `A timestamped backup will be written to: ${backupPath}`,
1220
+ "Only this string changes; comments and unrelated fields are preserved. Undo with /cache-optimizer rollback.",
1221
+ isActionableModelsJsonFixReceipt(pendingReceipt)
1222
+ ? `⚠️ This replaces the rollback receipt of the earlier fix for ${pendingReceipt.provider}/${pendingReceipt.modelId}; its backup ${pendingReceipt.backupFile} stays on disk.`
1223
+ : "",
1224
+ "Run /reload or restart Pi for the change to take effect.",
1225
+ "",
1226
+ "Apply this change?",
1227
+ ].filter((line, index, lines) => line !== "" || lines[index - 1] !== "").join("\n"));
1228
+ if (!confirmed) {
1229
+ cmdCtx.ui.notify("No changes were made. Canceled by user.", "info");
1230
+ return;
1231
+ }
1232
+
1233
+ const receipt = createBaseUrlFixReceipt(
1234
+ current.text, plan.modifiedText, model.provider, model.id,
1235
+ plan.placement, plan.before, plan.after, backupPath,
1236
+ );
1237
+ if (!receipt) {
1238
+ cmdCtx.ui.notify("❌ Could not create a fix receipt for rollback. No changes were made.", "error");
1239
+ return;
1240
+ }
1241
+ try {
1242
+ const result = await applyModelsJsonFixTransaction(
1243
+ plan.modifiedText,
1244
+ backupPath,
1245
+ (writtenText) => validateWrittenKimiBaseUrlFix(writtenText, plan),
1246
+ {
1247
+ expectedCurrentHash: hashText(current.text),
1248
+ expectedCurrentMode: current.mode,
1249
+ purpose: "fix",
1250
+ onCommitted: async () => writeModelsJsonFixReceipt(receipt),
1251
+ },
1252
+ );
1253
+ if ("postCheckError" in result) {
1254
+ cmdCtx.ui.notify(`❌ Post-write self-check failed: ${result.postCheckError}. Backup restored.`, "error");
1255
+ return;
1256
+ }
1257
+ invalidateModelsConfigCache();
1258
+ cmdCtx.ui.notify(
1259
+ `✅ baseUrl updated to ${plan.after} for ${modelKey(model)}.\n` +
1260
+ `Backup saved to: ${backupPath}\n` +
1261
+ "Run /reload or restart Pi, then /cache-optimizer doctor to confirm.",
1262
+ "info",
1263
+ );
1264
+ } catch (error) {
1265
+ cmdCtx.ui.notify(`❌ Write failed: ${error instanceof Error ? error.message : String(error)}. No changes were kept.`, "error");
1266
+ }
1267
+ }
@@ -1,6 +1,6 @@
1
1
  import { type PiModel, isNonEmptyString, lower } from "./common.ts";
2
2
  import { isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./model-detect.ts";
3
- import { getCompat, isDeepSeekLikeModel, isKimiCodingEmptySignatureModel, isKnownThirdPartyOpenAIEndpoint, isOpenAICompatibleProxyApi, isOpenAIFamilyModel, isPiBuiltInLlamaCppModel, isRoutedFallbackModel } from "./model-identity.ts";
3
+ import { getCompat, isDeepSeekLikeModel, isKimiCodingEmptySignatureModel, isKnownThirdPartyOpenAIEndpoint, isOpenAICompatibleProxyApi, isThirdPartyOpenAIProxyEndpoint, isOpenAIFamilyModel, isPiBuiltInLlamaCppModel, isRoutedFallbackModel } from "./model-identity.ts";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { homedir } from "node:os";
6
6
 
@@ -86,7 +86,7 @@ export function describeMissingOpenAICompatibleProxyCompat(model: PiModel): stri
86
86
  const missing: string[] = [];
87
87
 
88
88
  if (!isOpenAICompatibleProxyApi(model.api)) return missing;
89
- if (!isKnownThirdPartyOpenAIEndpoint(model)) return missing;
89
+ if (!isThirdPartyOpenAIProxyEndpoint(model)) return missing;
90
90
  if (isPiBuiltInLlamaCppModel(model)) return missing;
91
91
 
92
92
  if (compat.sendSessionAffinityHeaders === undefined) {
@@ -2,7 +2,7 @@ import { type CacheProviderAdapter } from "./adapters.ts";
2
2
  import { type PiModel, type UnknownRecord, asRecord, isNonEmptyString, lower } from "./common.ts";
3
3
  import { type CompatAdvicePlacement, appendCredentialSafeProviderGuidance, appendDeepSeekCompatAdviceLines, appendOpenAIProxyCompatAdviceLines, buildDeepSeekCompatSuggestion, buildSafeOpenAIProxyCompatSuggestion, describeMissingCacheCompatForModel, getModelsJsonDisplayPath, isAdaptiveThinkingCompatApplicable, isDeepSeekWireCompatApplicable } from "./compat-advice.ts";
4
4
  import { type FixSuggestion } from "./fix-types.ts";
5
- import { getCompat, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOpenAICompatibleProxyApi, isPiBuiltInLlamaCppModel, modelKey } from "./model-identity.ts";
5
+ import { getCompat, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOfficialKimiCodeEndpoint, isOpenAICompatibleProxyApi, isPiBuiltInLlamaCppModel, isThirdPartyOpenAIProxyEndpoint, modelKey } from "./model-identity.ts";
6
6
  import { type CacheUsageSample, formatRecentTrendSummary } from "./stats-report.ts";
7
7
  import { type CacheStats, emptyCacheStats } from "./stats-store.ts";
8
8
  import { snapshotBaseUrlForDiagnostics } from "./request-state.ts";
@@ -38,7 +38,7 @@ export function describeOptionalOpenAICompatibleProxyCompat(model: PiModel): str
38
38
  const optional: string[] = [];
39
39
 
40
40
  if (!isOpenAICompatibleProxyApi(model.api)) return optional;
41
- if (!isKnownThirdPartyOpenAIEndpoint(model)) return optional;
41
+ if (!isThirdPartyOpenAIProxyEndpoint(model)) return optional;
42
42
  if (isPiBuiltInLlamaCppModel(model)) return optional;
43
43
 
44
44
  if (compat.supportsLongCacheRetention !== true) {
@@ -143,7 +143,7 @@ export function isDeepSeekCompatCheckApplicable(model: PiModel): boolean {
143
143
  }
144
144
 
145
145
  export function isCompatCheckApplicable(model: PiModel): boolean {
146
- return isOpenAICompatibleProxyApi(model.api) && isKnownThirdPartyOpenAIEndpoint(model) && !isPiBuiltInLlamaCppModel(model);
146
+ return isOpenAICompatibleProxyApi(model.api) && isThirdPartyOpenAIProxyEndpoint(model) && !isPiBuiltInLlamaCppModel(model);
147
147
  }
148
148
 
149
149
  export function isPromptCacheRetention400Applicable(model: PiModel): boolean {
@@ -223,7 +223,7 @@ export function describeRouterChannelDiagnostics(model: PiModel): string[] {
223
223
 
224
224
  // Unknown/default endpoints and official OpenAI are not diagnosable as
225
225
  // third-party router channels. Request-header bridging also fails closed.
226
- if (!isKnownThirdPartyOpenAIEndpoint(model)) {
226
+ if (!isThirdPartyOpenAIProxyEndpoint(model)) {
227
227
  return notes;
228
228
  }
229
229
 
@@ -404,6 +404,13 @@ export function getCompatCheckNotApplicableLines(model: PiModel): string[] {
404
404
  ];
405
405
  }
406
406
 
407
+ if (isOpenAICompatibleApi(api) && isOfficialKimiCodeEndpoint(model)) {
408
+ return [
409
+ "ℹ️ Compat check not applicable for this model.",
410
+ " Official Kimi Code endpoint: Pi ships this provider's compat itself, and proxy session-affinity flags do not apply to a first-party origin.",
411
+ ];
412
+ }
413
+
407
414
  return ["ℹ️ Compat check not applicable for this model."];
408
415
  }
409
416
 
@@ -499,7 +506,9 @@ export function buildDoctorDiagnosis(model: PiModel, options: { promptCacheReten
499
506
  lines.push(" (session_id, x-client-request-id, x-session-affinity) and return 403. If you");
500
507
  lines.push(" see 403 errors, run /cache-optimizer fix to set sendSessionAffinityHeaders: false.");
501
508
  }
502
- } else if (isOpenAISdkHeader403Applicable(model)) {
509
+ } else if (isOpenAISdkHeader403Applicable(model) && (options.openAISdkHeader403 || !isOfficialKimiCodeEndpoint(model))) {
510
+ // Official Kimi Code gets the 403 hint only after a 403 was observed; the
511
+ // passive "after disabling sendSessionAffinityHeaders" advice is proxy-only.
503
512
  lines.push("");
504
513
  if (options.openAISdkHeader403) {
505
514
  lines.push("⚠️ A 403 response was observed while sendSessionAffinityHeaders is not enabled.");
package/src/fix-types.ts CHANGED
@@ -35,11 +35,12 @@ export function samePromptCacheLifetimes(left: unknown, right: unknown): boolean
35
35
  /**
36
36
  * Version 1 receipts record scalar compat changes only. Version 2 receipts
37
37
  * also record a `promptCache` object the fix added (it never overwrites one),
38
- * so rollback can remove exactly that property. Older extension versions
39
- * reject version 2 receipts instead of misreading them.
38
+ * so rollback can remove exactly that property. Version 3 receipts record a
39
+ * single `baseUrl` string rewrite (`/cache-optimizer fix kimi-base-url`).
40
+ * Older extension versions reject newer receipts instead of misreading them.
40
41
  */
41
42
  export type ModelsJsonFixReceiptV1 = {
42
- version: 1 | 2;
43
+ version: 1 | 2 | 3;
43
44
  kind: "pi-cache-optimizer-fix-receipt";
44
45
  transactionId: string;
45
46
  provider: string;
@@ -49,6 +50,11 @@ export type ModelsJsonFixReceiptV1 = {
49
50
  changedKeys: Record<string, FixReceiptCompatChange>;
50
51
  /** Version 2 only: the promptCache object added at the target (absent before). */
51
52
  promptCacheAdded?: PromptCacheLifetimes;
53
+ /**
54
+ * Version 3 only: the `baseUrl` string the fix rewrote at the provider or
55
+ * custom model target (official Kimi Code `/coding` → `/coding/v1`).
56
+ */
57
+ baseUrlChange?: { before: string; after: string };
52
58
  beforeHash: string;
53
59
  afterHash: string;
54
60
  backupFile: string;
@@ -0,0 +1,249 @@
1
+ import { type PiModel, asRecord, isNonEmptyString } from "./common.ts";
2
+ import { parseJsonc } from "./jsonc.ts";
3
+ import { kimiCodeV1BaseUrl, normalizeBaseUrlForComparison, parseOfficialKimiCodeBaseUrl } from "./kimi-code.ts";
4
+ import { isValidModelsConfigForEffectiveCompat } from "./model-identity.ts";
5
+ import { type BaseUrlFixPlacement, locateBaseUrlTarget, readBaseUrlState, replaceBaseUrlValue } from "./models-json-fix.ts";
6
+
7
+ /**
8
+ * Kimi Code base URL check and `/cache-optimizer fix kimi-base-url`.
9
+ *
10
+ * Pi 1.3.0 moved the built-in kimi-coding provider from anthropic-messages
11
+ * (`https://api.kimi.com/coding`) to OpenAI Chat Completions
12
+ * (`https://api.kimi.com/coding/v1`). A models.json `baseUrl` left over from
13
+ * the Anthropic era keeps `/coding`, so Pi posts to `/coding/chat/completions`
14
+ * and every request fails.
15
+ *
16
+ * The fix only appends `/v1`, and only when the live model uses
17
+ * openai-completions and its base URL is exactly an official Kimi Code host
18
+ * with the bare `/coding` path. The effective API is the real condition: Pi
19
+ * versions before 1.3.0 ship kimi-coding as anthropic-messages, and Kimi's
20
+ * OpenAI-compatible endpoint has always been `/coding/v1`. Proxies, other
21
+ * paths, and Anthropic-protocol entries only get a hint.
22
+ */
23
+
24
+ export type KimiBaseUrlStatus =
25
+ | { kind: "not-applicable" }
26
+ | { kind: "fixable"; current: string; suggested: string }
27
+ | { kind: "hint"; lines: string[] };
28
+
29
+ const KIMI_CODING_PATH = /\/coding\/*$/i;
30
+
31
+ export function diagnoseKimiCodeBaseUrl(model: PiModel | undefined): KimiBaseUrlStatus {
32
+ if (!model || !isNonEmptyString(model.baseUrl)) return { kind: "not-applicable" };
33
+ const api = String(model.api ?? "").toLowerCase();
34
+ const official = parseOfficialKimiCodeBaseUrl(model.baseUrl);
35
+
36
+ if (api === "openai-completions") {
37
+ if (official && !official.hasV1) {
38
+ const suggested = kimiCodeV1BaseUrl(model.baseUrl);
39
+ if (suggested) return { kind: "fixable", current: model.baseUrl, suggested };
40
+ }
41
+ if (!official && isKimiLike(model) && KIMI_CODING_PATH.test(pathOf(model.baseUrl))) {
42
+ return {
43
+ kind: "hint",
44
+ lines: [
45
+ "ℹ️ This Kimi entry uses OpenAI Chat Completions, and its base URL ends in /coding without /v1.",
46
+ " Kimi Code's official OpenAI endpoint is https://api.kimi.com/coding/v1. Check what your proxy expects;",
47
+ " it is not an official Kimi host, so /cache-optimizer fix leaves it unchanged.",
48
+ ],
49
+ };
50
+ }
51
+ return { kind: "not-applicable" };
52
+ }
53
+
54
+ if (api === "anthropic-messages" && official?.hasV1) {
55
+ return {
56
+ kind: "hint",
57
+ lines: [
58
+ "ℹ️ This Kimi Code entry uses the Anthropic Messages API, but its base URL ends in /coding/v1.",
59
+ " The Anthropic endpoint is https://api.kimi.com/coding (Pi appends /v1/messages itself).",
60
+ " Pi 1.3.0+ ships kimi-coding on OpenAI Chat Completions; drop the api override or the /v1. Not changed automatically.",
61
+ ],
62
+ };
63
+ }
64
+ return { kind: "not-applicable" };
65
+ }
66
+
67
+ function isKimiLike(model: PiModel): boolean {
68
+ const provider = String(model.provider ?? "").toLowerCase();
69
+ const baseUrl = String(model.baseUrl ?? "").toLowerCase();
70
+ return provider.includes("kimi") || provider.includes("moonshot") || baseUrl.includes("kimi") || baseUrl.includes("moonshot");
71
+ }
72
+
73
+ function pathOf(baseUrl: string): string {
74
+ try {
75
+ return new URL(baseUrl.trim()).pathname;
76
+ } catch {
77
+ return "";
78
+ }
79
+ }
80
+
81
+ export function buildKimiBaseUrlDoctorLines(model: PiModel | undefined): string[] {
82
+ const status = diagnoseKimiCodeBaseUrl(model);
83
+ if (status.kind === "fixable") {
84
+ return [
85
+ "",
86
+ "⚠️ Kimi Code base URL is missing /v1.",
87
+ ` Current: ${status.current}`,
88
+ ` Expected: ${status.suggested}`,
89
+ " This model uses OpenAI Chat Completions (Pi 1.3.0+ kimi-coding), so requests to /coding/chat/completions fail.",
90
+ " Run /cache-optimizer fix kimi-base-url to append /v1 with a preview, backup and rollback.",
91
+ ];
92
+ }
93
+ if (status.kind === "hint") return ["", ...status.lines];
94
+ return [];
95
+ }
96
+
97
+ export type KimiBaseUrlFixPlan = {
98
+ modifiedText: string;
99
+ placement: BaseUrlFixPlacement;
100
+ before: string;
101
+ after: string;
102
+ locationLabel: string;
103
+ /** Other models whose base URL changes too (provider-level edits). */
104
+ affectedNote?: string;
105
+ };
106
+
107
+ /**
108
+ * Plan the models.json edit for the active model. The `baseUrl` Pi uses comes
109
+ * from the model's own custom `models[]` entry when it has one, otherwise
110
+ * from the provider entry. The located string must match the live base URL;
111
+ * a mismatch means something else (an extension, another file) supplies it.
112
+ */
113
+ export function planKimiBaseUrlFix(
114
+ originalText: string,
115
+ model: PiModel,
116
+ /** Live models from Pi's registry (`modelRegistry.getAll()`), when available. */
117
+ registeredModels?: readonly PiModel[],
118
+ ): KimiBaseUrlFixPlan | { error: string } {
119
+ const status = diagnoseKimiCodeBaseUrl(model);
120
+ if (status.kind !== "fixable") return { error: "the active model does not need the Kimi Code /v1 fix" };
121
+
122
+ let parsed: unknown;
123
+ try {
124
+ parsed = parseJsonc(originalText);
125
+ } catch {
126
+ return { error: "models.json is not valid JSONC" };
127
+ }
128
+ if (!isValidModelsConfigForEffectiveCompat(parsed)) {
129
+ return { error: "models.json does not match Pi's model configuration schema" };
130
+ }
131
+ const providerRecord = asRecord(asRecord(asRecord(parsed)?.providers)?.[model.provider]);
132
+ if (!providerRecord) {
133
+ return { error: `models.json has no "${model.provider}" provider entry, so the base URL comes from somewhere else (an extension or Pi's built-in catalog)` };
134
+ }
135
+ const definitions = (Array.isArray(providerRecord.models) ? providerRecord.models : []).map(asRecord);
136
+ const ownDefinitions = definitions.filter((entry) => entry?.id === model.id);
137
+ if (ownDefinitions.length > 1) return { error: `models.json defines ${model.provider}/${model.id} more than once` };
138
+
139
+ let placement: BaseUrlFixPlacement;
140
+ if (ownDefinitions[0] && ownDefinitions[0].baseUrl !== undefined) {
141
+ placement = "model";
142
+ } else if (providerRecord.baseUrl !== undefined) {
143
+ placement = "provider";
144
+ if (providerRecord.oauth === "radius") {
145
+ return { error: "the provider uses oauth \"radius\", where Pi ignores the provider baseUrl for built-in models" };
146
+ }
147
+ // A provider-level baseUrl also feeds custom models without their own.
148
+ // Refuse when one of them is declared on another protocol, since /v1
149
+ // would break it (Anthropic Messages wants the bare /coding path).
150
+ const providerApi = providerRecord.api;
151
+ const conflicting = definitions
152
+ .filter((entry) => entry && entry.baseUrl === undefined)
153
+ .filter((entry) => {
154
+ const api = entry?.api ?? providerApi;
155
+ return api !== undefined && String(api).toLowerCase() !== "openai-completions";
156
+ })
157
+ .map((entry) => String(entry?.id));
158
+ if (conflicting.length > 0) {
159
+ return { error: `the provider baseUrl is shared with custom models on another API (${conflicting.join(", ")}); give each its own baseUrl first` };
160
+ }
161
+ // The provider baseUrl also overrides every built-in model of the
162
+ // provider. On Pi < 1.3.0 the built-in kimi-coding models are
163
+ // anthropic-messages, so a custom openai-completions entry inheriting the
164
+ // provider baseUrl must not drag them onto /coding/v1. Check the live
165
+ // registry; without it, refuse for a custom entry (built-ins unknown).
166
+ const ownIds = new Set(definitions.map((entry) => entry?.id));
167
+ if (registeredModels) {
168
+ const builtInConflicts = registeredModels
169
+ .filter((entry) => entry.provider === model.provider && !ownIds.has(entry.id))
170
+ .filter((entry) => String(entry.api ?? "").toLowerCase() !== "openai-completions")
171
+ .filter((entry) => isNonEmptyString(entry.baseUrl) && normalizeBaseUrlForComparison(entry.baseUrl) === normalizeBaseUrlForComparison(String(providerRecord.baseUrl)))
172
+ .map((entry) => String(entry.id));
173
+ if (builtInConflicts.length > 0) {
174
+ return { error: `the provider baseUrl is shared with built-in models on another API (${builtInConflicts.join(", ")}); set baseUrl on the ${model.id} models[] entry instead` };
175
+ }
176
+ } else if (ownDefinitions[0]) {
177
+ return { error: `the provider baseUrl may also feed built-in ${model.provider} models on another API; set baseUrl on the ${model.id} models[] entry instead` };
178
+ }
179
+ } else {
180
+ return { error: `models.json sets no baseUrl for ${model.provider}/${model.id}, so the base URL comes from somewhere else` };
181
+ }
182
+
183
+ const target = locateBaseUrlTarget(originalText, model.provider, model.id, placement);
184
+ const before = readBaseUrlState(originalText, target);
185
+ if (!target || before === undefined || before === "invalid") {
186
+ return { error: "the baseUrl entry is duplicated or cannot be located safely" };
187
+ }
188
+ if (normalizeBaseUrlForComparison(before) !== normalizeBaseUrlForComparison(status.current)) {
189
+ return { error: "the baseUrl in models.json differs from the active model's base URL (an extension or another source overrides it)" };
190
+ }
191
+ const after = kimiCodeV1BaseUrl(before);
192
+ if (!after) return { error: "the baseUrl in models.json is not a bare official Kimi Code /coding URL" };
193
+ const modifiedText = replaceBaseUrlValue(originalText, target, after);
194
+ if (modifiedText === undefined) return { error: "the baseUrl value cannot be rewritten safely" };
195
+
196
+ const locationLabel = placement === "provider"
197
+ ? `providers[${JSON.stringify(model.provider)}].baseUrl`
198
+ : `providers[${JSON.stringify(model.provider)}].models[id=${JSON.stringify(model.id)}].baseUrl`;
199
+ const plan: KimiBaseUrlFixPlan = {
200
+ modifiedText,
201
+ placement,
202
+ before,
203
+ after,
204
+ locationLabel,
205
+ ...(placement === "provider" ? { affectedNote: `Every ${model.provider} model without its own baseUrl uses this value.` } : {}),
206
+ };
207
+ const checkError = selfCheckKimiBaseUrlFix(originalText, plan, model.provider, model.id);
208
+ if (checkError) return { error: `self-check failed: ${checkError}` };
209
+ return plan;
210
+ }
211
+
212
+ /** The edit must be valid, write `after`, and change nothing but that string. */
213
+ export function selfCheckKimiBaseUrlFix(originalText: string, plan: KimiBaseUrlFixPlan, provider: string, modelId: string): string | null {
214
+ let original: unknown;
215
+ let modified: unknown;
216
+ try {
217
+ original = parseJsonc(originalText);
218
+ modified = parseJsonc(plan.modifiedText);
219
+ } catch {
220
+ return "the edited file is not valid JSONC";
221
+ }
222
+ if (!isValidModelsConfigForEffectiveCompat(modified)) return "the edited file does not match Pi's model configuration schema";
223
+ if (readBaseUrlState(plan.modifiedText, locateBaseUrlTarget(plan.modifiedText, provider, modelId, plan.placement)) !== plan.after) {
224
+ return "baseUrl was not written at the target";
225
+ }
226
+ const reverted = JSON.parse(JSON.stringify(modified)) as Record<string, unknown>;
227
+ const providerRecord = asRecord(asRecord(reverted.providers)?.[provider]);
228
+ if (!providerRecord) return "the provider entry disappeared";
229
+ if (plan.placement === "provider") {
230
+ providerRecord.baseUrl = plan.before;
231
+ } else {
232
+ const entry = (Array.isArray(providerRecord.models) ? providerRecord.models : []).map(asRecord).find((item) => item?.id === modelId);
233
+ if (!entry) return "the model entry disappeared";
234
+ entry.baseUrl = plan.before;
235
+ }
236
+ if (JSON.stringify(reverted) !== JSON.stringify(original)) return "unrelated configuration was altered";
237
+ return null;
238
+ }
239
+
240
+ /** Validate a file written by the fix transaction (re-read from disk). */
241
+ export function validateWrittenKimiBaseUrlFix(writtenText: string, plan: KimiBaseUrlFixPlan): string | null {
242
+ if (writtenText !== plan.modifiedText) return "models.json content differs from the previewed edit";
243
+ try {
244
+ parseJsonc(writtenText);
245
+ } catch {
246
+ return "the written file is not valid JSONC";
247
+ }
248
+ return null;
249
+ }
@@ -0,0 +1,53 @@
1
+ import { isNonEmptyString } from "./common.ts";
2
+
3
+ // Moonshot's official Kimi Code hosts (China and international). Pi 1.3.0
4
+ // ships kimi-coding as openai-completions at `<host>/coding/v1`; older Pi
5
+ // versions used anthropic-messages at `<host>/coding`.
6
+ const OFFICIAL_KIMI_CODE_HOSTS = new Set(["api.kimi.com", "api.kimi.ai"]);
7
+
8
+ export type OfficialKimiCodeBaseUrl = {
9
+ host: string;
10
+ /** Whether the path is `/coding/v1` (true) or bare `/coding` (false). */
11
+ hasV1: boolean;
12
+ };
13
+
14
+ /**
15
+ * Parse an official Kimi Code base URL: https, one of the official hosts, no
16
+ * port, credentials, query or fragment, and a path of exactly `/coding` or
17
+ * `/coding/v1` (trailing slashes ignored). Anything else, including proxies
18
+ * that mirror the path, returns undefined.
19
+ */
20
+ export function parseOfficialKimiCodeBaseUrl(baseUrl: unknown): OfficialKimiCodeBaseUrl | undefined {
21
+ if (!isNonEmptyString(baseUrl)) return undefined;
22
+ let url: URL;
23
+ try {
24
+ url = new URL(baseUrl.trim());
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ if (url.protocol !== "https:" || url.port !== "" || url.username !== "" || url.password !== "") return undefined;
29
+ if (url.search !== "" || url.hash !== "" || /[?#]/.test(baseUrl)) return undefined;
30
+ const host = url.hostname.toLowerCase();
31
+ if (!OFFICIAL_KIMI_CODE_HOSTS.has(host)) return undefined;
32
+ const path = url.pathname.replace(/\/+$/, "").toLowerCase();
33
+ if (path === "/coding") return { host, hasV1: false };
34
+ if (path === "/coding/v1") return { host, hasV1: true };
35
+ return undefined;
36
+ }
37
+
38
+ /** Strip surrounding whitespace and trailing slashes for base URL comparison. */
39
+ export function normalizeBaseUrlForComparison(baseUrl: string): string {
40
+ return baseUrl.trim().replace(/\/+$/, "");
41
+ }
42
+
43
+ /** The `/coding/v1` form of a bare official `/coding` base URL, keeping its spelling. */
44
+ export function kimiCodeV1BaseUrl(baseUrl: string): string | undefined {
45
+ const parsed = parseOfficialKimiCodeBaseUrl(baseUrl);
46
+ if (!parsed || parsed.hasV1) return undefined;
47
+ return `${normalizeBaseUrlForComparison(baseUrl)}/v1`;
48
+ }
49
+
50
+ /** A receipt-recorded base URL change is exactly "bare official /coding" → "+ /v1". */
51
+ export function isKimiCodeV1BaseUrlChange(before: unknown, after: unknown): boolean {
52
+ return typeof before === "string" && typeof after === "string" && kimiCodeV1BaseUrl(before) === after;
53
+ }
@@ -2,6 +2,7 @@ import { type ModelIdentity, type PiModel, type UnknownRecord, asRecord, isNonEm
2
2
  import { type CacheCompat, NESTED_COMPAT_KEYS, resolveEffectiveCompatFromConfig } from "./compat-config.ts";
3
3
  import { parseJsonc } from "./jsonc.ts";
4
4
  import { getAssistantMessageModelTokenValues, getModelIdNameTokenValues, hasAnyTokenContaining, isKimiCodingAdaptiveModel, modelOrAssistantMessageHas } from "./model-detect.ts";
5
+ import { parseOfficialKimiCodeBaseUrl } from "./kimi-code.ts";
5
6
  import { MODELS_JSON_PATH } from "./paths.ts";
6
7
  import { readFileSync, statSync } from "node:fs";
7
8
 
@@ -363,6 +364,21 @@ export function isKnownThirdPartyOpenAIEndpoint(model: PiModel): boolean {
363
364
  return isNonEmptyString(model.baseUrl) && !isOfficialOpenAIBaseUrl(model);
364
365
  }
365
366
 
367
+ /** The model talks to Moonshot's official Kimi Code endpoint directly. */
368
+ export function isOfficialKimiCodeEndpoint(model: PiModel | undefined): boolean {
369
+ return parseOfficialKimiCodeBaseUrl(model?.baseUrl) !== undefined;
370
+ }
371
+
372
+ /**
373
+ * A non-official OpenAI-compatible endpoint that may sit in front of several
374
+ * backends (relay, aggregator, gateway), where session-affinity headers help
375
+ * keep one session on one cache. Official first-party endpoints that Pi
376
+ * configures itself (Kimi Code) are origins, not proxies.
377
+ */
378
+ export function isThirdPartyOpenAIProxyEndpoint(model: PiModel): boolean {
379
+ return isKnownThirdPartyOpenAIEndpoint(model) && !isOfficialKimiCodeEndpoint(model);
380
+ }
381
+
366
382
  export function invalidateModelsConfigCache(): void {
367
383
  modelsConfigCache = undefined;
368
384
  }
@@ -8,6 +8,7 @@ import { findLastExactModelDefinition, resolveEffectiveCompatFromConfig } from "
8
8
  import { type FixReceiptCompatChange, type FixReceiptPlacement, type ModelsJsonFixReceiptV1, type PromptCacheLifetimes, RECEIPT_COMPAT_KEYS, type ReceiptScalar, type ReceiptScalarState, isReceiptTimestamp, isValidPromptCacheLifetimes, samePromptCacheLifetimes, isSafeReceiptText, isSha256 } from "./fix-types.ts";
9
9
  import { type JsonPropertyEdit, type ModelNodeLocation, deepEqualIgnoringKeys, deriveInnerIndent, findExistingCompatKeysInJsonc, findJsonObjectKey, findMatchingBracket, isJsonWhitespace, lineIndentOf, locateJsonPropertyValueSpan, locateModelOverrideInJsonc, locateProviderCompatInJsonc, parseJsonc, readJsonStringLiteral, skipJsonValue, skipJsonWhitespace, stripJsoncComments } from "./jsonc.ts";
10
10
  import { isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./model-detect.ts";
11
+ import { isKimiCodeV1BaseUrlChange } from "./kimi-code.ts";
11
12
  import { FIX_RECEIPT_PATH, MODELS_JSON_PATH } from "./paths.ts";
12
13
 
13
14
  export type ExplicitCompatSource = "modelOverride" | "model" | "provider";
@@ -1528,10 +1529,10 @@ export function createRollbackBackupPath(modelsPath: string = MODELS_JSON_PATH):
1528
1529
 
1529
1530
  export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV1 | undefined {
1530
1531
  const record = asRecord(value);
1531
- if (!record || (record.version !== 1 && record.version !== 2) || record.kind !== "pi-cache-optimizer-fix-receipt") return undefined;
1532
+ if (!record || (record.version !== 1 && record.version !== 2 && record.version !== 3) || record.kind !== "pi-cache-optimizer-fix-receipt") return undefined;
1532
1533
  const allowedKeys = new Set([
1533
1534
  "version", "kind", "transactionId", "provider", "modelId", "placement",
1534
- "targetExistedBefore", "changedKeys", "promptCacheAdded", "beforeHash", "afterHash", "backupFile",
1535
+ "targetExistedBefore", "changedKeys", "promptCacheAdded", "baseUrlChange", "beforeHash", "afterHash", "backupFile",
1535
1536
  "createdAt", "appliedAt", "status", "rolledBackAt",
1536
1537
  ]);
1537
1538
  if (Object.keys(record).some((key) => !allowedKeys.has(key))) return undefined;
@@ -1549,8 +1550,19 @@ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV
1549
1550
  const isV2 = record.version === 2;
1550
1551
  if (isV2 !== (record.promptCacheAdded !== undefined)) return undefined;
1551
1552
  if (isV2 && (!isValidPromptCacheLifetimes(record.promptCacheAdded) || record.placement === "provider")) return undefined;
1553
+ // baseUrlChange is the version 3 marker: a single official Kimi Code
1554
+ // `/coding` → `/coding/v1` rewrite at an existing provider or model target.
1555
+ const isV3 = record.version === 3;
1556
+ if (isV3 !== (record.baseUrlChange !== undefined)) return undefined;
1557
+ const rawBaseUrlChange = asRecord(record.baseUrlChange);
1558
+ if (isV3) {
1559
+ if (!rawBaseUrlChange || Object.keys(rawBaseUrlChange).some((key) => key !== "before" && key !== "after")) return undefined;
1560
+ if (!isKimiCodeV1BaseUrlChange(rawBaseUrlChange.before, rawBaseUrlChange.after)) return undefined;
1561
+ if (record.placement === "modelOverride" || record.targetExistedBefore !== true) return undefined;
1562
+ }
1552
1563
  const changedKeys = asRecord(record.changedKeys);
1553
- if (!changedKeys || (Object.keys(changedKeys).length === 0 && !isV2)) return undefined;
1564
+ if (!changedKeys || (Object.keys(changedKeys).length === 0 && record.version === 1)) return undefined;
1565
+ if (isV3 && Object.keys(changedKeys).length > 0) return undefined;
1554
1566
 
1555
1567
  const parsedChanges: Record<string, FixReceiptCompatChange> = {};
1556
1568
  for (const [key, rawChange] of Object.entries(changedKeys)) {
@@ -1569,8 +1581,11 @@ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV
1569
1581
  if (record.status === undefined && record.rolledBackAt !== undefined) return undefined;
1570
1582
 
1571
1583
  const promptCacheAdded = isV2 ? record.promptCacheAdded as PromptCacheLifetimes : undefined;
1584
+ const baseUrlChange = isV3 && rawBaseUrlChange
1585
+ ? { before: rawBaseUrlChange.before as string, after: rawBaseUrlChange.after as string }
1586
+ : undefined;
1572
1587
  return {
1573
- version: isV2 ? 2 : 1,
1588
+ version: isV3 ? 3 : isV2 ? 2 : 1,
1574
1589
  kind: "pi-cache-optimizer-fix-receipt",
1575
1590
  transactionId: record.transactionId.trim(),
1576
1591
  provider: record.provider.trim(),
@@ -1579,6 +1594,7 @@ export function parseModelsJsonFixReceipt(value: unknown): ModelsJsonFixReceiptV
1579
1594
  targetExistedBefore: record.targetExistedBefore,
1580
1595
  changedKeys: parsedChanges,
1581
1596
  ...(promptCacheAdded ? { promptCacheAdded: { ...(promptCacheAdded.short !== undefined ? { short: promptCacheAdded.short } : {}), ...(promptCacheAdded.long !== undefined ? { long: promptCacheAdded.long } : {}) } } : {}),
1597
+ ...(baseUrlChange ? { baseUrlChange } : {}),
1582
1598
  beforeHash,
1583
1599
  afterHash,
1584
1600
  backupFile: record.backupFile,
@@ -1854,6 +1870,100 @@ export function createPromptCacheFixReceipt(
1854
1870
  });
1855
1871
  }
1856
1872
 
1873
+ export type BaseUrlFixPlacement = "provider" | "model";
1874
+
1875
+ /**
1876
+ * Locate the object that holds a direct `baseUrl`: the provider object, or
1877
+ * the single custom `models[]` entry with this id. Duplicate provider keys or
1878
+ * duplicate model ids are refused.
1879
+ */
1880
+ export function locateBaseUrlTarget(
1881
+ text: string,
1882
+ provider: string,
1883
+ modelId: string,
1884
+ placement: BaseUrlFixPlacement,
1885
+ ): { objectBrace: number; objectEnd: number } | undefined {
1886
+ if (placement === "provider") {
1887
+ const location = locateProviderCompatInJsonc(text, provider);
1888
+ return location ? { objectBrace: location.providerObjectBrace, objectEnd: location.providerObjectEnd } : undefined;
1889
+ }
1890
+ const location = locateModelInJsonc(text, provider, modelId);
1891
+ if (!location || location.providerKeyCount !== 1 || location.modelObjectBrace < 0) return undefined;
1892
+ if (location.allModelIds.filter((id) => id === modelId).length !== 1) return undefined;
1893
+ return { objectBrace: location.modelObjectBrace, objectEnd: location.modelObjectEnd };
1894
+ }
1895
+
1896
+ /**
1897
+ * Read the direct `baseUrl` of a target: its string value, `undefined` when
1898
+ * absent, or "invalid" when duplicated, not a string, or unreadable.
1899
+ */
1900
+ export function readBaseUrlState(
1901
+ text: string,
1902
+ target: { objectBrace: number; objectEnd: number } | undefined,
1903
+ ): string | undefined | "invalid" {
1904
+ if (!target) return "invalid";
1905
+ const clean = stripJsoncComments(text);
1906
+ const property = findJsonObjectKey(clean, target.objectBrace, "baseUrl");
1907
+ if (!property || property.keyStart >= target.objectEnd) return undefined;
1908
+ if (property.count !== 1) return "invalid";
1909
+ const valueStart = skipJsonWhitespace(clean, property.valueStart);
1910
+ if (clean[valueStart] !== "\"") return "invalid";
1911
+ const literal = readJsonStringLiteral(clean, valueStart);
1912
+ if (!literal || literal.end > target.objectEnd) return "invalid";
1913
+ return literal.value;
1914
+ }
1915
+
1916
+ /** Replace the direct `baseUrl` string of a target, or explain why it cannot. */
1917
+ export function replaceBaseUrlValue(
1918
+ text: string,
1919
+ target: { objectBrace: number; objectEnd: number },
1920
+ value: string,
1921
+ ): string | undefined {
1922
+ const property = locateJsonPropertyValueSpan(text, target.objectBrace, target.objectEnd, "baseUrl");
1923
+ if (!property) return undefined;
1924
+ const clean = stripJsoncComments(text);
1925
+ if (clean[property.valueStart] !== "\"") return undefined;
1926
+ return text.slice(0, property.valueStart) + JSON.stringify(value) + text.slice(property.valueEnd);
1927
+ }
1928
+
1929
+ /**
1930
+ * Receipt for `/cache-optimizer fix kimi-base-url`: a version 3 receipt with
1931
+ * no compat changes and the single baseUrl string the fix rewrote.
1932
+ */
1933
+ export function createBaseUrlFixReceipt(
1934
+ originalText: string,
1935
+ modifiedText: string,
1936
+ provider: string,
1937
+ modelId: string,
1938
+ placement: BaseUrlFixPlacement,
1939
+ before: string,
1940
+ after: string,
1941
+ backupPath: string,
1942
+ now: number = Date.now(),
1943
+ ): ModelsJsonFixReceiptV1 | undefined {
1944
+ const backupFile = basename(backupPath);
1945
+ if (!/^models\.json\.backup-cache-optimizer-/.test(backupFile)) return undefined;
1946
+ if (!isKimiCodeV1BaseUrlChange(before, after)) return undefined;
1947
+ if (readBaseUrlState(originalText, locateBaseUrlTarget(originalText, provider, modelId, placement)) !== before) return undefined;
1948
+ if (readBaseUrlState(modifiedText, locateBaseUrlTarget(modifiedText, provider, modelId, placement)) !== after) return undefined;
1949
+ return parseModelsJsonFixReceipt({
1950
+ version: 3,
1951
+ kind: "pi-cache-optimizer-fix-receipt",
1952
+ transactionId: randomUUID(),
1953
+ provider,
1954
+ modelId,
1955
+ placement,
1956
+ targetExistedBefore: true,
1957
+ changedKeys: {},
1958
+ baseUrlChange: { before, after },
1959
+ beforeHash: hashText(originalText),
1960
+ afterHash: hashText(modifiedText),
1961
+ backupFile,
1962
+ createdAt: now,
1963
+ appliedAt: now,
1964
+ });
1965
+ }
1966
+
1857
1967
  export function createModelsJsonFixReceipt(
1858
1968
  originalText: string,
1859
1969
  modifiedText: string,
@@ -1908,6 +2018,19 @@ export function composeModelsJsonReceiptRollback(
1908
2018
  return { error: "the fix created a new target entry and the file changed; refusing to remove user changes automatically" };
1909
2019
  }
1910
2020
 
2021
+ if (receipt.baseUrlChange) {
2022
+ if (receipt.placement === "modelOverride") return { error: "the receipt's baseUrl target is not supported" };
2023
+ const baseUrlTarget = locateBaseUrlTarget(currentText, receipt.provider, receipt.modelId, receipt.placement);
2024
+ const current = readBaseUrlState(currentText, baseUrlTarget);
2025
+ if (current === "invalid" || !baseUrlTarget) return { error: "receipt-owned baseUrl is no longer safely locatable" };
2026
+ if (current !== receipt.baseUrlChange.after) {
2027
+ return { error: "receipt-owned baseUrl was changed after the fix; refusing to overwrite it" };
2028
+ }
2029
+ const modifiedText = replaceBaseUrlValue(currentText, baseUrlTarget, receipt.baseUrlChange.before);
2030
+ if (modifiedText === undefined) return { error: "receipt-owned baseUrl is no longer safely locatable" };
2031
+ return { modifiedText, changed: modifiedText !== currentText };
2032
+ }
2033
+
1911
2034
  const target = locateReceiptCompatTarget(currentText, receipt.provider, receipt.modelId, receipt.placement);
1912
2035
  if (!target?.targetExists) {
1913
2036
  return { error: "the original target entry is missing or no longer safely locatable" };
@@ -1969,6 +2092,12 @@ export function validateModelsJsonRollback(
1969
2092
  // newly created target entry (which intentionally did not exist before).
1970
2093
  if (expectedHash !== undefined) return null;
1971
2094
 
2095
+ if (receipt.baseUrlChange) {
2096
+ if (receipt.placement === "modelOverride") return "rollback result has an unsupported baseUrl target";
2097
+ const state = readBaseUrlState(writtenText, locateBaseUrlTarget(writtenText, receipt.provider, receipt.modelId, receipt.placement));
2098
+ return state === receipt.baseUrlChange.before ? null : "rollback result did not restore baseUrl";
2099
+ }
2100
+
1972
2101
  const target = locateReceiptCompatTarget(writtenText, receipt.provider, receipt.modelId, receipt.placement);
1973
2102
  if (!target?.targetExists) return "rollback result lost the original target entry";
1974
2103
  for (const [key, change] of Object.entries(receipt.changedKeys)) {