pi-cache-optimizer 2.8.25 → 2.8.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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/index.ts CHANGED
@@ -13,7 +13,7 @@ import { atomicCreateTextFileNoReplace, atomicReplaceTextFilePreservingMode, ato
13
13
  import { getAssistantMessageModelTokenValues, getModelIdNameTokenValues, isAdaptiveGenerationModel, isKimiCodingAdaptiveModel } from "./src/model-detect.ts";
14
14
  import { findLastExactModelDefinition, mergeCacheCompat, resolveEffectiveCompatFromConfig } from "./src/compat-config.ts";
15
15
  import { analyzeModelsJsonForMissingEntry, applyModelsJsonFixTransaction, chooseFixPlacement, composeFixInsertion, composeMissingEntryInsertion, composeModelOverrideInsertion, composeModelsJsonReceiptRollback, composeProviderAffinityInsertion, createModelsJsonFixReceipt, createRollbackBackupPath, decideFixPlacement, formatCompatKeysForInsertion, hasExplicitLongRetentionOptInFromConfig, hasReceiptReasoningProtocolChange, isActionableModelsJsonFixReceipt, locateModelInJsonc, markModelsJsonFixReceiptRolledBack, parseModelsJsonFixReceipt, prepareModelsJsonRollback, readModelsJsonFixReceipt, readModelsJsonFixReceiptSnapshot, receiptBackupPath, resolveExplicitCompatValue, selfCheckFix, selfCheckMissingEntryInsertion, validateModelsJsonRollback, writeModelsJsonFixReceipt } from "./src/models-json-fix.ts";
16
- import { type CacheProviderId, type CacheStats, LEGACY_STATE_FILE_PATH, type PersistedRoutedModelRef, type PersistedStatsShardV7, SHARD_FILES_DIR, SHARD_GLOBAL_EPOCH_PATH, SHARD_STATE_DIR, STATE_FILE_PATH, type ShardAggregate, type UsageSnapshot, addUsageToCacheStats, advanceGlobalStatsEpoch, advanceModelStatsEpoch, aggregateStatsShardsV7, cleanupStatsShardsV7, cloneCacheStats, currentLocalDay, emptyAllCacheStats, emptyCacheStats, initialEpoch, loadStatsShardAggregateV7, maybeCleanupStatsShardsV7, modelEpochPath, parseCacheStats, parsePersistedRoutedModelRef, parsePersistedStatsShardV7, readGlobalStatsEpoch, readModelStatsEpoch, readValidStatsShardsV7, removeLegacyStatsFiles, writeStatsShardV7 } from "./src/stats-store.ts";
16
+ import { type CacheProviderId, type CacheStats, LEGACY_STATE_FILE_PATH, type PersistedRoutedModelRef, type PersistedStatsShardV7, SHARD_FILES_DIR, SHARD_GLOBAL_EPOCH_PATH, SHARD_STATE_DIR, STATE_FILE_PATH, type ShardAggregate, type UsageSnapshot, addUsageToCacheStats, advanceGlobalStatsEpoch, advanceModelStatsEpoch, aggregateStatsShardsV7, cleanupStatsShardsV7, cloneCacheStats, mapWithConcurrency, currentLocalDay, emptyAllCacheStats, emptyCacheStats, initialEpoch, loadStatsShardAggregateV7, maybeCleanupStatsShardsV7, modelEpochPath, parseCacheStats, parsePersistedRoutedModelRef, parsePersistedStatsShardV7, readGlobalStatsEpoch, readModelStatsEpoch, readValidStatsShardsV7, removeLegacyStatsFiles, writeStatsShardV7 } from "./src/stats-store.ts";
17
17
  import { PI_VIRTUAL_MODEL_API, getCompat, isAnthropicMessagesApi, isKimiCodingEmptySignatureModel, isKnownThirdPartyOpenAIEndpoint, isMistralConversationsApi, isNativeVirtualModel, isOfficialOpenAIBaseUrl, isOpenAICompatibleApi, isOpenAICompatibleProxyApi, isOpenAIFamilyAssistantMessage, isOpenAIFamilyModel, isOpenAIFamilyToken, isPiBuiltInLlamaCppModel, isResponsesPromptRewriteBypassApi, isRoutedFallbackModel, isValidModelsConfigForEffectiveCompat, modelKey, readEffectiveCompatConfig } from "./src/model-identity.ts";
18
18
  import { CONFIG_FILE_PATH, VIRTUAL_REWRITE_ENV, FOOTER_MODE_ENV, TOOL_ORDER_ENV, featureEnabled, footerStatsMode, isEnabledEnv, isToolOrderEnabled, parseFooterStatsMode, parsePersistedCacheOptimizerConfig, readPersistedCacheOptimizerConfig, resolveFooterStatsMode, runtimeOptimizerEnabled, setPersistedCacheOptimizerConfig, shouldInjectOpenAIPromptCacheKey, writePersistedCacheOptimizerConfig, writePersistedFeature, writePersistedFooterMode } from "./src/config.ts";
19
19
  import { decideZeroPriceWarming, readCacheWarmingMode } from "./src/cache-warming.ts";
@@ -103,7 +103,13 @@ function notifyCacheCompatIfNeeded(
103
103
  // Internal helpers exported only so the task verification script
104
104
  // (.trellis/tasks/.../verify.ts) can exercise them. They are not part of the
105
105
  // extension's public API; pi only invokes the default export below.
106
+ // Most recent background stats restore started by any extension instance in
107
+ // this module. Tests await it after session_start; runtime code uses the
108
+ // per-instance promise instead.
109
+ let latestStatsRestoreForTests: Promise<void> = Promise.resolve();
110
+
106
111
  export const __internals_for_tests = {
112
+ waitForStatsRestore: (): Promise<void> => latestStatsRestoreForTests,
107
113
  getLastSkillCompressionOutcome,
108
114
  decidePromptCacheRetention,
109
115
  userRequestedLongCacheRetention,
@@ -317,6 +323,7 @@ export const __internals_for_tests = {
317
323
  aggregateStatsShardsV7,
318
324
  loadStatsShardAggregateV7,
319
325
  cleanupStatsShardsV7,
326
+ mapWithConcurrency,
320
327
  removeLegacyStatsFiles,
321
328
  readGlobalStatsEpoch,
322
329
  readModelStatsEpoch,
@@ -410,6 +417,20 @@ export default function (pi: ExtensionAPI) {
410
417
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
411
418
  let shardRefreshTimer: ReturnType<typeof setTimeout> | null = null;
412
419
  let shardWatcher: ReturnType<typeof watch> | undefined;
420
+ // Historical stats are restored in the background so a large shard
421
+ // directory never holds up Pi's session_start (issue #28). Handlers that
422
+ // read or persist aggregated stats await this first.
423
+ let statsRestorePromise: Promise<void> = Promise.resolve();
424
+ let statsRestoreGeneration = 0;
425
+ async function waitForStatsRestore(): Promise<void> {
426
+ // A session_start may chain a newer restore while we wait; keep waiting
427
+ // until the chain is stable so callers never act on a half-restored view.
428
+ let current: Promise<void>;
429
+ do {
430
+ current = statsRestorePromise;
431
+ await current;
432
+ } while (current !== statsRestorePromise);
433
+ }
413
434
  const enqueuePersist = createSerializedAsyncRunner();
414
435
  let instanceId = randomUUID();
415
436
  const instanceStartedAt = Date.now();
@@ -664,7 +685,8 @@ export default function (pi: ExtensionAPI) {
664
685
  if (shardRefreshTimer !== null) clearTimeout(shardRefreshTimer);
665
686
  shardRefreshTimer = setTimeout(() => {
666
687
  shardRefreshTimer = null;
667
- void refreshShardAggregate()
688
+ void waitForStatsRestore()
689
+ .then(() => refreshShardAggregate())
668
690
  .then(() => publishStatus(ctx as ExtensionContext, model ?? ctx?.model))
669
691
  .catch(() => undefined);
670
692
  }, SHARD_REFRESH_DEBOUNCE_MS);
@@ -771,7 +793,12 @@ export default function (pi: ExtensionAPI) {
771
793
  if (persistTimer !== null) clearTimeout(persistTimer);
772
794
  persistTimer = setTimeout(() => {
773
795
  persistTimer = null;
774
- void persistCacheStats(ctx).then(() => refreshShardAggregate()).catch(() => undefined);
796
+ // A persist scheduled while a background restore runs (e.g. a routed
797
+ // model change without usage) must not race the restore's own write.
798
+ void waitForStatsRestore()
799
+ .then(() => persistCacheStats(ctx))
800
+ .then(() => refreshShardAggregate())
801
+ .catch(() => undefined);
775
802
  }, PERSIST_DEBOUNCE_MS);
776
803
  persistTimer.unref?.();
777
804
  }
@@ -818,7 +845,12 @@ export default function (pi: ExtensionAPI) {
818
845
  finally { rolloverPromise = undefined; }
819
846
  }
820
847
 
821
- async function restoreCacheStats(reason: string, ctx: ExtensionContext): Promise<void> {
848
+ /**
849
+ * Synchronous part of a session restore: reset in-memory state immediately
850
+ * at session_start, so usage recorded before the background disk restore
851
+ * finishes is never wiped by a reset that runs later.
852
+ */
853
+ function resetStatsForSessionStart(reason: string, ctx: ExtensionContext): void {
822
854
  syncSessionHash(ctx);
823
855
  lastStatusText = undefined;
824
856
  cacheStatsProcessByModel = {};
@@ -826,19 +858,48 @@ export default function (pi: ExtensionAPI) {
826
858
  modelEpochByKey.clear();
827
859
  modelApiByKey.clear();
828
860
  modelNameByKey.clear();
829
- currentGlobalEpoch = await readGlobalStatsEpoch();
830
861
  if (reason === "reload") {
831
862
  clearRecentSamples();
832
863
  }
864
+ }
865
+
866
+ async function restoreCacheStats(ctx: ExtensionContext): Promise<void> {
867
+ // Session identity was synced by resetStatsForSessionStart. Usage may
868
+ // already have been recorded (message_end does not wait to record), and
869
+ // message_end syncs the epoch before recording. If the disk epoch differs
870
+ // now, a reset happened after that usage: adopt it and drop the stale
871
+ // process bucket instead of re-labelling old counters with the new epoch.
872
+ const latestGlobalEpoch = await readGlobalStatsEpoch();
873
+ if (latestGlobalEpoch !== currentGlobalEpoch) {
874
+ currentGlobalEpoch = latestGlobalEpoch;
875
+ cacheStatsProcessByModel = {};
876
+ modelEpochByKey.clear();
877
+ modelApiByKey.clear();
878
+ modelNameByKey.clear();
879
+ }
833
880
  await removeLegacyStatsFiles();
834
881
  await mkdir(SHARD_FILES_DIR, { recursive: true });
882
+ await rollOverStatsIfNeeded(ctx);
883
+ // Run due retention maintenance before the single startup scan, so the
884
+ // aggregate never retains counters from files maintenance just removed and
885
+ // no second directory pass is needed. Cleanup also warms the parsed-shard
886
+ // cache, so the scan below only re-stats files.
887
+ try {
888
+ await maybeCleanupStatsShardsV7();
889
+ } catch (error) {
890
+ console.warn(`${LOG_PREFIX}: stats shard maintenance failed`, error);
891
+ }
892
+ // One scan adopts other processes' epochs and restores this session's
893
+ // routed-model metadata before this instance publishes its own shard.
894
+ // In-memory process counters were cleared above, so no pre-persist
895
+ // re-scan is needed; this instance's own shard is always replaced by its
896
+ // in-memory copy during aggregation, so no post-persist scan either.
835
897
  await refreshShardAggregate();
836
- await flushPersistCacheStats(ctx);
837
- await maybeCleanupStatsShardsV7();
838
- // Maintenance may remove expired shard files after the initial refresh.
839
- // Re-scan before publishing so startup/footer command state cannot retain
840
- // counters from files that no longer exist.
841
- await refreshShardAggregate();
898
+ if (persistTimer !== null) {
899
+ clearTimeout(persistTimer);
900
+ persistTimer = null;
901
+ }
902
+ await persistCacheStats(ctx);
842
903
  }
843
904
 
844
905
  async function publishStatus(ctx: ExtensionContext, model: PiModel | undefined = ctx.model): Promise<void> {
@@ -968,7 +1029,26 @@ export default function (pi: ExtensionAPI) {
968
1029
 
969
1030
  pi.on("session_start", async (event, ctx) => {
970
1031
  if (runtimeOptimizerEnabled) requestLongCacheRetention();
971
- await restoreCacheStats(event.reason, ctx);
1032
+ // Do not block Pi's startup on historical stats (issue #28). The
1033
+ // restore runs in the background (chained after any earlier restore so
1034
+ // two never interleave) and the footer is published when it finishes.
1035
+ // A newer session_start supersedes an older restore, which then must not
1036
+ // publish a stale footer. Handlers that read or persist stats await
1037
+ // waitForStatsRestore() first.
1038
+ const generation = ++statsRestoreGeneration;
1039
+ resetStatsForSessionStart(event.reason, ctx);
1040
+ const previousRestore = statsRestorePromise;
1041
+ statsRestorePromise = previousRestore
1042
+ // A restore superseded before it started is skipped entirely: the newer
1043
+ // one redoes all of its work, and its ctx may already be stale.
1044
+ .then(() => (generation === statsRestoreGeneration ? restoreCacheStats(ctx) : undefined))
1045
+ .then(async () => {
1046
+ if (generation === statsRestoreGeneration) await publishStatus(ctx);
1047
+ })
1048
+ .catch((error) => {
1049
+ console.warn(`${LOG_PREFIX}: failed to restore cache stats`, error);
1050
+ });
1051
+ latestStatsRestoreForTests = statsRestorePromise;
972
1052
  if (ctx.mode === "tui" && !shardWatcher) {
973
1053
  try {
974
1054
  shardWatcher = watch(SHARD_FILES_DIR, () => scheduleShardRefresh(ctx));
@@ -977,13 +1057,13 @@ export default function (pi: ExtensionAPI) {
977
1057
  // Lifecycle and explicit command refreshes remain authoritative.
978
1058
  }
979
1059
  }
980
- await publishStatus(ctx);
981
1060
  });
982
1061
 
983
1062
  pi.on("tool_execution_end", async (event, ctx) => {
984
1063
  // Pi 0.99+ codemode scripts run nested tool calls in parallel; the calling
985
1064
  // tool's own end event follows them, so one refresh covers the batch.
986
1065
  if (isNonEmptyString((event as { parentToolCallId?: unknown } | undefined)?.parentToolCallId)) return;
1066
+ await waitForStatsRestore();
987
1067
  await refreshShardAggregate();
988
1068
  await publishStatus(ctx);
989
1069
  });
@@ -1003,12 +1083,14 @@ export default function (pi: ExtensionAPI) {
1003
1083
  // a record consumed out of order) so they cannot be correlated with the
1004
1084
  // next run's messages.
1005
1085
  providerRequestStates.length = 0;
1086
+ await waitForStatsRestore();
1006
1087
  await refreshShardAggregate();
1007
1088
  await publishStatus(ctx);
1008
1089
  });
1009
1090
 
1010
1091
  pi.on("session_shutdown", async (_event, ctx) => {
1011
1092
  try {
1093
+ await waitForStatsRestore();
1012
1094
  await flushPersistCacheStats(ctx, "closed");
1013
1095
  } finally {
1014
1096
  if (shardRefreshTimer !== null) clearTimeout(shardRefreshTimer);
@@ -1025,6 +1107,7 @@ export default function (pi: ExtensionAPI) {
1025
1107
 
1026
1108
  pi.on("model_select", async (event, ctx) => {
1027
1109
  if (runtimeOptimizerEnabled) notifyCacheCompatIfNeeded(resolveRouteModel(event.model, ctx) ?? event.model, ctx, warnedModels);
1110
+ await waitForStatsRestore();
1028
1111
  await refreshShardAggregate();
1029
1112
  await publishStatus(ctx, event.model);
1030
1113
  });
@@ -1568,6 +1651,7 @@ export default function (pi: ExtensionAPI) {
1568
1651
  addUsageToCacheStats(getStatsForModel(undefined, adapter), usage);
1569
1652
  }
1570
1653
 
1654
+ await waitForStatsRestore();
1571
1655
  schedulePersistCacheStats(ctx);
1572
1656
  await refreshShardAggregate();
1573
1657
  // A late response from an older selection must not replace the active
@@ -1627,6 +1711,11 @@ export default function (pi: ExtensionAPI) {
1627
1711
  pi.registerCommand("cache-optimizer", {
1628
1712
  description: "Configure and diagnose Pi cache behavior",
1629
1713
  getArgumentCompletions: getCacheOptimizerArgumentCompletions,
1630
- handler: handleCacheOptimizerCommand,
1714
+ handler: async (args: string, cmdCtx: Parameters<typeof handleCacheOptimizerCommand>[1]) => {
1715
+ // Commands read and reset aggregated stats; let a background restore
1716
+ // finish first so they never act on a half-restored view.
1717
+ await waitForStatsRestore();
1718
+ return handleCacheOptimizerCommand(args, cmdCtx);
1719
+ },
1631
1720
  });
1632
1721
  }
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.27",
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) {