pi-cache-optimizer 2.8.18 → 2.8.20

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
@@ -62,7 +62,7 @@ Run `/reload` in Pi after install/update/remove so extension hooks refresh.
62
62
 
63
63
  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).
64
64
 
65
- This extension requires Pi 0.82+ and is validated against Pi 1.0.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.0's engine requirement.
65
+ This extension requires Pi 0.82+ and is validated against Pi 1.0.3. 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
66
 
67
67
  ## Commands
68
68
 
@@ -132,17 +132,17 @@ The fixture verifier reports numeric tool-order changes and confirms cache-marke
132
132
 
133
133
  ## Footer cache stats mode
134
134
 
135
- Current versions store stats under `pi-cache-optimizer-stats.d/shards/` in Pi's agent directory. Each loaded extension instance owns one UUID-named shard and writes it through temp-file + atomic rename. This prevents parent/child/parallel Pi processes from overwriting one another. Upgrading from the old v6 single-file format deletes the old local stats files and starts footer counters from zero; upstream provider caches are not affected.
135
+ Current versions store stats under `pi-cache-optimizer-stats.d/shards/` in Pi's agent directory. Each loaded extension instance owns one active UUID-named daily shard and writes it through temp-file + atomic rename; at local midnight it closes the old shard and starts a new UUID without erasing session history. This prevents parent/child/parallel Pi processes from overwriting one another. Upgrading from the old v6 single-file format deletes the old local stats files and starts footer counters from zero; upstream provider caches are not affected.
136
136
 
137
- Response stats stay with the dispatched catalog/request-local model even if you switch to another model on the same provider while waiting. Legacy response aliases use the request snapshot, not the new selection; the footer continues showing the selected direct model. Old corrupt JSON/schema shards expire by file modification time after 48 hours; young corrupt files, valid current-day shards, live old instances, and symlink targets remain protected.
137
+ Response stats stay with the dispatched catalog/request-local model even if you switch to another model on the same provider while waiting. Legacy response aliases use the request snapshot, not the new selection; the footer continues showing the selected direct model. Old corrupt JSON/schema shards expire by file modification time after two months; young corrupt files, valid current-day shards, live old instances, and symlink targets remain protected.
138
138
 
139
139
  The footer defaults to `session`, which reflects the current Pi conversation rather than another parallel Pi terminal using the same provider/model. Use either the command or environment variable to select the scope:
140
140
 
141
141
  | Value | Effect |
142
142
  |---|---|
143
- | `session` (default) | Aggregate today's shards carrying the current hashed Pi conversation session id and exact provider/model. A reload creates a new instance shard but remains in the same session scope. |
144
- | `total` | Aggregate today's valid local shards for the exact provider/model across sessions, including child Pi agents that load this extension and share the same agent directory. |
145
- | `process` | Show only counters collected by the current extension instance. It starts at `0/0` after Pi restart or extension reload. |
143
+ | `session` (default) | Aggregate all retained shards carrying the current hashed Pi conversation session id and exact provider/model, including across local-day boundaries. A reload creates a new instance shard but remains in the same lifetime session scope. |
144
+ | `total` | Aggregate the current local day's valid shards for the exact provider/model across sessions, including child Pi agents that load this extension and share the same agent directory. |
145
+ | `process` | Show only today's counters collected by the current extension instance. It starts at `0/0` at local midnight, after Pi restart, or extension reload. |
146
146
 
147
147
  Persistent command configuration takes precedence over the environment variable:
148
148
 
@@ -156,9 +156,9 @@ The explicit setting is stored in `pi-cache-optimizer-config.json` under Pi's ag
156
156
 
157
157
  ## Per-model `prompt_cache_key` opt-out
158
158
 
159
- Some OpenAI-compatible Completions endpoints reject `prompt_cache_key` with HTTP 400 even though the same field is valid for other providers. Pi 1.0.0 owns the key for Responses/Codex transports; this extension's key opt-out and fallback apply only to `openai-completions`. Pi 1.0.0 has no native `supportsPromptCacheKey` compat field; do **not** add that unknown field to `models.json`. `supportsLongCacheRetention` is not an equivalent switch and should not be used for this purpose.
159
+ Some OpenAI-compatible Completions endpoints reject `prompt_cache_key` with HTTP 400 even though the same field is valid for other providers. Pi 1.0.3 owns the key for Responses/Codex transports; this extension's key opt-out and fallback apply only to `openai-completions`. Pi 1.0.3 has no native `supportsPromptCacheKey` compat field; do **not** add that unknown field to `models.json`. `supportsLongCacheRetention` is not an equivalent switch and should not be used for this purpose.
160
160
 
161
- When the extension observes an explicit field-level `prompt_cache_key` unsupported error for the exact provider/model, ordinary `/cache-optimizer fix` offers a confirmed model-scoped repair. Value-validation failures and conditional restrictions such as “not allowed when temperature is set” do not qualify. If concurrent responses from different models cannot be correlated because Pi provides no request ID, header-only evidence is ignored unless the finalized assistant message supplies exact provider/model identity. If you already know that the endpoint rejects the field, use the explicit command:
161
+ When the extension observes an explicit field-level `prompt_cache_key` unsupported error for the exact provider/model (including recognized Chinese-localized forms such as `未知请求字段:prompt_cache_key`), ordinary `/cache-optimizer fix` offers a confirmed model-scoped repair. Value-validation failures and conditional restrictions such as “not allowed when temperature is set” do not qualify. If concurrent responses from different models cannot be correlated because Pi provides no request ID, header-only evidence is ignored unless the finalized assistant message supplies exact provider/model identity. If you already know that the endpoint rejects the field, use the explicit command:
162
162
 
163
163
  ```text
164
164
  /cache-optimizer fix prompt-cache-key
@@ -196,7 +196,7 @@ Notes:
196
196
 
197
197
  - `sendSessionAffinityHeaders: true` is the safe default when your proxy supports sticky routing.
198
198
  - `supportsLongCacheRetention: true` is optional. Add it only when the endpoint explicitly supports OpenAI long prompt cache retention.
199
- - Do not add `supportsPromptCacheKey` to `models.json`: Pi 1.0.0 does not define that compat field. Use `/cache-optimizer fix prompt-cache-key` to store an exact provider/model omit rule in the extension-owned config; it removes both key spellings, including a key supplied by Pi.
199
+ - Do not add `supportsPromptCacheKey` to `models.json`: Pi 1.0.3 does not define that compat field. Use `/cache-optimizer fix prompt-cache-key` to store an exact provider/model omit rule in the extension-owned config; it removes both key spellings, including a key supplied by Pi.
200
200
  - If you see `400 Unsupported parameter: prompt_cache_retention`, remove/avoid `supportsLongCacheRetention` for that channel. Keep `sendSessionAffinityHeaders` if supported. The extension detects the explicit error from response headers or the finalized assistant error message and strips the parameter from subsequent requests in the current process.
201
201
  - Use `/cache-optimizer compat` or `/cache-optimizer doctor` to see model-specific advice.
202
202
  - DeepSeek model names select the `DS cache` adapter only; they do not prove a reasoning wire protocol. Generic cache/routing advice remains active for absent or non-DeepSeek formats. DeepSeek replay advice is shown only when effective `compat.thinkingFormat: "deepseek"` is explicitly configured; it never treats `thinkingFormat` as a missing fix key.
@@ -354,7 +354,7 @@ Pi's model-family name and its reasoning wire protocol are separate. A DeepSeek-
354
354
  The novice-safe workflow is staged:
355
355
 
356
356
  1. Run `/cache-optimizer fix` for protocol-neutral cache/routing repairs such as session affinity. It shows the exact placement and requires confirmation.
357
- 2. Make a normal request. If an OpenAI-compatible DeepSeek-like model explicitly rejects the `thinking` parameter in favor of `reasoning_effort`, the extension keeps only a model-scoped, process-local category. It does not persist or display the complete provider error, send a hidden probe, or edit configuration from a response hook.
357
+ 2. Make a normal request. If an OpenAI-compatible DeepSeek-like model explicitly rejects the `thinking` parameter in favor of `reasoning_effort` (including recognized Chinese-localized rejection and recommendation text), the extension keeps only a model-scoped, process-local category. It does not persist or display the complete provider error, send a hidden probe, or edit configuration from a response hook.
358
358
  3. Run `/cache-optimizer fix` again to review an evidence-based model-level protocol repair. It is written through the highest-precedence `modelOverrides[modelId].compat` layer and validated against runtime compat, so an extension-provided model cannot silently shadow it. A provider-level change is never broadened from a model name alone. Explicit `openai`, `qwen`, `openrouter`, and `together` formats remain respected.
359
359
  4. If the latest confirmed fix caused the problem, run `/cache-optimizer rollback`. Rollback always requires UI confirmation.
360
360
 
@@ -364,7 +364,7 @@ Rollback creates a new access-mode-preserving backup and uses atomic replacement
364
364
 
365
365
  ## Footer stats
366
366
 
367
- Stats are read-only local counters stored as UUID-owned shards under `pi-cache-optimizer-stats.d/shards/` in Pi's agent directory (custom agent dirs use `PI_CODING_AGENT_DIR`). Shards contain only dates, opaque session hashes, exact provider/model counters, reset epochs, and process lifecycle metadata — no API keys, prompts, payloads, headers, responses, or model output. The footer defaults to the current conversation session; `total` aggregates all valid current-day shards for the exact provider/model, and `process` shows only the current extension instance. Footer mode configuration is stored separately in `pi-cache-optimizer-config.json`. Upgrading from the old v6 shared stats files deletes those local counters instead of migrating them.
367
+ Stats are read-only local counters stored as UUID-owned shards under `pi-cache-optimizer-stats.d/shards/` in Pi's agent directory (custom agent dirs use `PI_CODING_AGENT_DIR`). Shards contain only dates, opaque session hashes, exact provider/model counters, reset epochs, and process lifecycle metadata — no API keys, prompts, payloads, headers, responses, or model output. The footer defaults to the current conversation session and retains that session's counters across local-day boundaries; `total` aggregates only current-day shards, and `process` shows only the current extension instance. Footer mode configuration is stored separately in `pi-cache-optimizer-config.json`. Upgrading from the old v6 shared stats files deletes those local counters instead of migrating them.
368
368
 
369
369
  Pi 0.79+ also includes a built-in footer `CH` marker for the latest prompt cache hit rate. This extension complements that marker with persisted provider/model counters plus proxy compat diagnostics.
370
370
 
package/README.zh-CN.md CHANGED
@@ -62,7 +62,7 @@ pi remove npm:pi-deepseek-cache-optimizer && pi install npm:pi-cache-optimizer
62
62
 
63
63
  Pi 0.79.7 及之后,`pi update` 默认只更新 Pi 本体。若要更新已安装的 Pi package(包括本扩展),请运行 `pi update --extensions`(只更新 packages)或 `pi update --all`(Pi 与 packages 一起更新)。
64
64
 
65
- 本扩展要求 Pi 0.82+,并已使用 Pi 1.0.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.0 的 engine 要求一致。
65
+ 本扩展要求 Pi 0.82+,并已使用 Pi 1.0.3 验证。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
66
 
67
67
  ## 命令
68
68
 
@@ -132,17 +132,17 @@ bun .trellis/tasks/09-03-context-epoch-tool-ordering/verify.ts
132
132
 
133
133
  ## Footer 缓存统计模式
134
134
 
135
- 当前版本把统计保存在 Pi agent 目录的 `pi-cache-optimizer-stats.d/shards/` 下。每个已加载的 extension instance 独占一个 UUID 命名 shard,并通过临时文件 + 原子 rename 写入,因此父/子/并行 Pi 进程不会互相覆盖。旧 v6 单文件统计在升级时直接删除,本地 footer 计数从零开始;不会影响上游 provider 的实际缓存。
135
+ 当前版本把统计保存在 Pi agent 目录的 `pi-cache-optimizer-stats.d/shards/` 下。每个已加载的 extension instance 独占一个当天的活跃 UUID shard,并通过临时文件 + 原子 rename 写入;本地日期切换时关闭旧 shard、创建新的 UUID,保留 session 历史,因此父/子/并行 Pi 进程不会互相覆盖。旧 v6 单文件统计在升级时直接删除,本地 footer 计数从零开始;不会影响上游 provider 的实际缓存。
136
136
 
137
- 即使等待响应时切换到同一 provider 的另一个模型,响应统计仍归属于实际请求的 catalog/request-local 模型。旧 transport 的响应别名只根据请求快照处理,不使用新选择的模型;footer 继续显示当前选择的 direct 模型。损坏 JSON/schema 的旧 shard 按文件修改时间在 48 小时后清理;较新的损坏文件、有效的当天 shard、仍存活的旧 instance 和 symlink 目标均受保护。
137
+ 即使等待响应时切换到同一 provider 的另一个模型,响应统计仍归属于实际请求的 catalog/request-local 模型。旧 transport 的响应别名只根据请求快照处理,不使用新选择的模型;footer 继续显示当前选择的 direct 模型。损坏 JSON/schema 的旧 shard 按文件修改时间在两个月后清理;较新的损坏文件、有效的当天 shard、仍存活的旧 instance 和 symlink 目标均受保护。
138
138
 
139
139
  Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 provider/model 时污染当前窗口。可以通过命令或环境变量切换显示范围:
140
140
 
141
141
  | 值 | 作用 |
142
142
  |---|---|
143
- | `session`(默认) | 聚合今天携带当前 hashed Pi conversation session id 且精确 provider/model 相同的 shard。Extension reload 会产生新 instance shard,但仍属于同一 session 范围。 |
144
- | `total` | 聚合今天同一精确 provider/model 的全部有效本地 shard,包括加载本扩展并共享同一 agent 目录的子 Pi agent。 |
145
- | `process` | 仅显示当前 extension instance 采集的统计。Pi 重启或 extension reload 后从 `0/0` 开始。 |
143
+ | `session`(默认) | 聚合所有携带当前 hashed Pi conversation session id 且精确 provider/model 相同的保留 shard,跨越本地日期仍会累计。Extension reload 会产生新 instance shard,但仍属于同一长期 session 范围。 |
144
+ | `total` | 聚合当前本地日期同一精确 provider/model 的全部有效本地 shard,包括加载本扩展并共享同一 agent 目录的子 Pi agent。 |
145
+ | `process` | 仅显示当前 extension instance 当天采集的统计。本地日期切换、Pi 重启或 extension reload 后从 `0/0` 开始。 |
146
146
 
147
147
  持久命令配置优先于环境变量:
148
148
 
@@ -156,9 +156,9 @@ Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 prov
156
156
 
157
157
  ## 按模型关闭 `prompt_cache_key`
158
158
 
159
- 某些 OpenAI-compatible Completions endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 1.0+ 已负责 Responses/Codex transport 的 key;本扩展的 fallback 和 opt-out 只作用于 `openai-completions`。Pi 1.0.0 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
159
+ 某些 OpenAI-compatible Completions endpoint 会因 `prompt_cache_key` 返回 HTTP 400,但同一个字段对其他 provider 可能有效。Pi 1.0+ 已负责 Responses/Codex transport 的 key;本扩展的 fallback 和 opt-out 只作用于 `openai-completions`。Pi 1.0.3 没有原生的 `supportsPromptCacheKey` compat 字段,**不要**把这个未知字段加入 `models.json`。`supportsLongCacheRetention` 也不是等价开关,不应借用来实现此目的。
160
160
 
161
- 当扩展观察到精确 provider/model 对 `prompt_cache_key` 的明确字段级拒绝后,普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。参数值校验失败,以及“设置 temperature 时不允许”这类条件限制都不构成证据。若不同模型的响应并发交错,而 Pi 又没有提供 request ID,扩展会忽略无法安全关联的 response-header 证据;只有最终 assistant message 提供精确 provider/model 身份时才恢复归因。如果你已经确定 endpoint 不支持该字段,可以主动执行:
161
+ 当扩展观察到精确 provider/model 对 `prompt_cache_key` 的明确字段级拒绝后(包括 `未知请求字段:prompt_cache_key` 这类已识别的中文错误),普通 `/cache-optimizer fix` 会提供经过确认的模型级修复。参数值校验失败,以及“设置 temperature 时不允许”这类条件限制都不构成证据。若不同模型的响应并发交错,而 Pi 又没有提供 request ID,扩展会忽略无法安全关联的 response-header 证据;只有最终 assistant message 提供精确 provider/model 身份时才恢复归因。如果你已经确定 endpoint 不支持该字段,可以主动执行:
162
162
 
163
163
  ```text
164
164
  /cache-optimizer fix prompt-cache-key
@@ -170,7 +170,7 @@ Footer 默认使用 `session`,避免另一个并行 Pi 终端使用相同 prov
170
170
 
171
171
  LiteLLM / OneAPI / NewAPI / 类 OpenRouter 渠道等第三方 `openai-completions` 代理,常会把同一个 session 分散到多个上游后端,导致 provider 侧 prompt cache 被拆散。
172
172
 
173
- Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention` 的模型兼容性;本扩展不按 provider 名称增加特殊分支,而是结合 `models.json` 与 runtime model,按精确 provider/model 解析有效 compat。Pi 0.81+ 也内置了使用 OpenAI-shaped transport 的 `llama.cpp` provider。本扩展只对第三方 `openai-completions` 保留 session-id key fallback;Pi 1.0+ 已负责 Responses/Codex transport 的 key。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 继续遵循统一安全规则:仅官方 OpenAI 或 `models.json` 中有效配置为 `supportsLongCacheRetention: true` 时保留,否则发送前移除。Pi 1.0.0 没有原生的 `supportsPromptCacheKey` compat 字段,因此仅对 `openai-completions` 按模型关闭 key 的策略保存在本扩展的 `pi-cache-optimizer-config.json` 中,而不是 `models.json`。扩展配置独立于 Pi 的 compat 优先级,只影响其中列出的精确 provider/model。
173
+ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention` 的模型兼容性;本扩展不按 provider 名称增加特殊分支,而是结合 `models.json` 与 runtime model,按精确 provider/model 解析有效 compat。Pi 0.81+ 也内置了使用 OpenAI-shaped transport 的 `llama.cpp` provider。本扩展只对第三方 `openai-completions` 保留 session-id key fallback;Pi 1.0+ 已负责 Responses/Codex transport 的 key。只有符合 Pi 内置 provider 明确 compat 指纹的模型会跳过通用 proxy 路由 / session-affinity 建议;仅复用 `llama.cpp` id 的自定义或覆盖 provider 仍按普通 OpenAI-compatible 渠道处理。`prompt_cache_retention` 继续遵循统一安全规则:仅官方 OpenAI 或 `models.json` 中有效配置为 `supportsLongCacheRetention: true` 时保留,否则发送前移除。Pi 1.0.3 没有原生的 `supportsPromptCacheKey` compat 字段,因此仅对 `openai-completions` 按模型关闭 key 的策略保存在本扩展的 `pi-cache-optimizer-config.json` 中,而不是 `models.json`。扩展配置独立于 Pi 的 compat 优先级,只影响其中列出的精确 provider/model。
174
174
 
175
175
  对真正的代理,建议先启用 session affinity:
176
176
 
@@ -196,7 +196,7 @@ Pi 0.84.1 还修复了内置 Fireworks 渠道对拒绝 `prompt_cache_retention`
196
196
 
197
197
  - `sendSessionAffinityHeaders: true` 是安全默认项,前提是你的代理支持 sticky routing。
198
198
  - `supportsLongCacheRetention: true` 是可选项。只有 endpoint 明确支持 OpenAI long prompt cache retention 时才添加。
199
- - 不要把 `supportsPromptCacheKey` 加入 `models.json`:Pi 1.0.0 没有定义这个 compat 字段。请使用 `/cache-optimizer fix prompt-cache-key` 在扩展自有配置中保存精确 provider/model 的 omit 规则;它会移除两种 key 写法,包括 Pi 提供的 key。
199
+ - 不要把 `supportsPromptCacheKey` 加入 `models.json`:Pi 1.0.3 没有定义这个 compat 字段。请使用 `/cache-optimizer fix prompt-cache-key` 在扩展自有配置中保存精确 provider/model 的 omit 规则;它会移除两种 key 写法,包括 Pi 提供的 key。
200
200
  - 如果出现 `400 Unsupported parameter: prompt_cache_retention`,请为该渠道移除 / 避免 `supportsLongCacheRetention`;如支持,可保留 `sendSessionAffinityHeaders`。扩展会从响应头或最终 assistant error message 中识别这条明确错误,并在当前进程的后续请求中移除该参数。
201
201
  - 使用 `/cache-optimizer compat` 或 `/cache-optimizer doctor` 查看当前模型的具体建议。
202
202
  - DeepSeek 模型名只用于选择 `DS cache` adapter,不能证明 reasoning wire protocol。缺少或使用非 DeepSeek format 时仍保留通用缓存 / 路由建议;只有 effective `compat.thinkingFormat: "deepseek"` 被明确配置时,才显示 DeepSeek replay 建议,且不会把 `thinkingFormat` 列为缺失修复项。
@@ -353,7 +353,7 @@ Pi Cache Optimizer 按 Pi 的优先级解析有效 compat(`provider.compat`
353
353
  面向新手的安全流程是分阶段的:
354
354
 
355
355
  1. 先运行 `/cache-optimizer fix`,只处理协议无关的缓存 / 路由修复,例如 session affinity;命令会展示具体位置并要求确认。
356
- 2. 发起一次普通请求。如果 OpenAI-compatible 的 DeepSeek-like 模型明确拒绝 `thinking` 并要求使用 `reasoning_effort`,扩展只在当前进程保留 model-scoped 分类,不会持久化或显示完整错误,不会发送隐藏探测请求,也不会在 response hook 中自动修改配置。
356
+ 2. 发起一次普通请求。如果 OpenAI-compatible 的 DeepSeek-like 模型明确拒绝 `thinking` 并要求使用 `reasoning_effort`(包括已识别的中文拒绝和建议文本),扩展只在当前进程保留 model-scoped 分类,不会持久化或显示完整错误,不会发送隐藏探测请求,也不会在 response hook 中自动修改配置。
357
357
  3. 再次运行 `/cache-optimizer fix`,查看基于证据的 model-level 协议修复;该修复写入最高优先级的 `modelOverrides[modelId].compat`,并结合 runtime compat 自检,避免 extension-provided model 静默遮挡。不会仅凭共享 provider 或模型名字扩大修改范围,显式 `openai`、`qwen`、`openrouter`、`together` format 会被尊重。
358
358
  4. 如果最近一次已确认修复导致问题,运行 `/cache-optimizer rollback`;回滚始终需要 UI 确认。
359
359
 
@@ -363,7 +363,7 @@ Rollback 会创建新的、保留访问权限的备份并使用原子替换。
363
363
 
364
364
  ## Footer 统计
365
365
 
366
- 统计是只读本地计数,保存在 Pi agent 目录的 UUID shard(`pi-cache-optimizer-stats.d/shards/`;自定义 agent 目录使用 `PI_CODING_AGENT_DIR`)。Shard 只包含日期、opaque session hash、精确 provider/model 计数、reset epoch 和进程生命周期元数据,不包含 API key、prompt、payload、headers、响应或模型输出。Footer 默认显示当前 conversation session;`total` 聚合同一精确 provider/model 的所有有效本地 shard,`process` 只显示当前 extension instance。旧 v6 共享统计文件升级时直接删除,不迁移旧计数。
366
+ 统计是只读本地计数,保存在 Pi agent 目录的 UUID shard(`pi-cache-optimizer-stats.d/shards/`;自定义 agent 目录使用 `PI_CODING_AGENT_DIR`)。Shard 只包含日期、opaque session hash、精确 provider/model 计数、reset epoch 和进程生命周期元数据,不包含 API key、prompt、payload、headers、响应或模型输出。Footer 默认显示当前 conversation session,并跨本地日期累计该 session;`total` 只聚合当前本地日期的同一精确 provider/model shard,`process` 只显示当前 extension instance。旧 v6 共享统计文件升级时直接删除,不迁移旧计数。
367
367
 
368
368
  Pi 0.79+ 已内置 footer `CH` 标记,用于显示最近一次 prompt cache hit rate。本扩展在此基础上补充持久化的 provider/model 计数,以及代理 compat 诊断。
369
369
 
package/index.ts CHANGED
@@ -197,6 +197,7 @@ export const __internals_for_tests = {
197
197
  isOpenAISdkHeader403Applicable,
198
198
  hasPromptCacheRetentionUnsupportedSignal,
199
199
  hasPromptCacheRetentionUnsupportedErrorMessage,
200
+ hasPromptCacheKeyUnsupportedText,
200
201
  hasPromptCacheKeyUnsupportedSignal,
201
202
  hasPromptCacheKeyUnsupportedErrorMessage,
202
203
  isPromptCacheKeyUnsupportedApplicable,
@@ -443,9 +444,13 @@ export default function (pi: ExtensionAPI) {
443
444
  let shardRefreshTimer: ReturnType<typeof setTimeout> | null = null;
444
445
  let shardWatcher: ReturnType<typeof watch> | undefined;
445
446
  const enqueuePersist = createSerializedAsyncRunner();
446
- const instanceId = randomUUID();
447
+ let instanceId = randomUUID();
447
448
  const instanceStartedAt = Date.now();
448
- const instanceShardPath = join(SHARD_FILES_DIR, `${instanceId}.json`);
449
+ let instanceShardPath = join(SHARD_FILES_DIR, `${instanceId}.json`);
450
+ let shardDay = currentLocalDay();
451
+ let rolloverPromise: Promise<void> | undefined;
452
+ // Failed day-close writes remain visible in memory until persisted or reload.
453
+ const retiredShards = new Map<string, PersistedStatsShardV7>();
449
454
  const modelApiByKey = new Map<string, string>();
450
455
  const modelNameByKey = new Map<string, string>();
451
456
  const modelEpochByKey = new Map<string, string>();
@@ -607,9 +612,14 @@ export default function (pi: ExtensionAPI) {
607
612
 
608
613
  async function refreshShardAggregate(): Promise<ShardAggregate> {
609
614
  const persistedShards = await readValidStatsShardsV7();
615
+ for (const shard of persistedShards) retiredShards.delete(shard.instanceId);
610
616
  const shards = persistedShards.filter((shard) => shard.instanceId !== instanceId);
617
+ shards.push(...retiredShards.values());
611
618
  if (currentSessionHashSet) shards.push(buildCurrentStatsShard());
619
+ // Keep lifetime session footer data separate from daily totals/commands.
620
+ // null explicitly means all retained days; undefined invokes the default day.
612
621
  const aggregate = await aggregateStatsShardsV7(shards);
622
+ const sessionAggregate = await aggregateStatsShardsV7(shards, null);
613
623
 
614
624
  // A reset from another process can advance epochs while this instance is
615
625
  // idle. Adopt those epochs during every aggregate refresh, not only when a
@@ -636,26 +646,19 @@ export default function (pi: ExtensionAPI) {
636
646
  }
637
647
 
638
648
  cacheStatsByModel = {};
639
- for (const [sessionHash, models] of Object.entries(aggregate.bySession)) {
649
+ for (const [sessionHash, models] of Object.entries(sessionAggregate.bySession)) {
640
650
  for (const [key, stats] of Object.entries(models)) {
641
651
  cacheStatsByModel[`${sessionHash}:${key}`] = stats;
642
652
  }
643
653
  }
644
654
  cacheStatsTotalsByModel = aggregate.totalsByModel;
645
655
  if (currentSessionHashSet) {
646
- lastActualRoutedModel = aggregate.lastRoutedModelBySession[currentSessionHash];
656
+ lastActualRoutedModel = sessionAggregate.lastRoutedModelBySession[currentSessionHash];
647
657
  }
648
658
  lastStatusText = undefined;
649
659
  return aggregate;
650
660
  }
651
661
 
652
- async function ensureCurrentEpochs(): Promise<void> {
653
- currentGlobalEpoch = await readGlobalStatsEpoch();
654
- for (const key of Object.keys(cacheStatsProcessByModel)) {
655
- modelEpochByKey.set(key, await readModelStatsEpoch(key));
656
- }
657
- }
658
-
659
662
  function buildCurrentStatsShard(state: "active" | "closed" = "active"): PersistedStatsShardV7 {
660
663
  const now = Date.now();
661
664
  const models: PersistedStatsShardV7["models"] = {};
@@ -683,7 +686,7 @@ export default function (pi: ExtensionAPI) {
683
686
  updatedAt: now,
684
687
  ...(state === "closed" ? { closedAt: now } : {}),
685
688
  },
686
- day: currentLocalDay(),
689
+ day: shardDay,
687
690
  globalEpoch: currentGlobalEpoch,
688
691
  models,
689
692
  ...(lastActualRoutedModel ? { lastRoutedModel: { ...lastActualRoutedModel } } : {}),
@@ -780,9 +783,10 @@ export default function (pi: ExtensionAPI) {
780
783
 
781
784
  function persistCacheStats(ctx?: ExtensionContext, lifecycleState: "active" | "closed" = "active"): Promise<void> {
782
785
  const shard = buildCurrentStatsShard(lifecycleState);
786
+ const path = instanceShardPath;
783
787
  return enqueuePersist(async () => {
784
788
  try {
785
- await writeStatsShardV7(instanceShardPath, shard);
789
+ await writeStatsShardV7(path, shard);
786
790
  } catch (error) {
787
791
  console.warn(`${LOG_PREFIX}: failed to persist cache stats shard`, error);
788
792
  if (!persistenceWarningShown) {
@@ -810,6 +814,7 @@ export default function (pi: ExtensionAPI) {
810
814
  clearTimeout(persistTimer);
811
815
  persistTimer = null;
812
816
  }
817
+ await rollOverStatsIfNeeded(ctx);
813
818
  // Adopt resets performed by another Pi process before capturing the shard
814
819
  // snapshot. Otherwise shutdown/command flush could rewrite stale local
815
820
  // counters under an old epoch even though aggregation already hid them.
@@ -819,27 +824,31 @@ export default function (pi: ExtensionAPI) {
819
824
  }
820
825
 
821
826
  async function rollOverStatsIfNeeded(ctx?: ExtensionContext): Promise<void> {
827
+ if (rolloverPromise) return rolloverPromise;
822
828
  const day = currentLocalDay();
823
- let changed = false;
824
- for (const key of Object.keys(cacheStatsProcessByModel)) {
825
- if (cacheStatsProcessByModel[key]?.day !== day) {
826
- cacheStatsProcessByModel[key] = emptyCacheStats(day);
827
- changed = true;
829
+ if (shardDay === day) return;
830
+ rolloverPromise = (async () => {
831
+ if (persistTimer !== null) {
832
+ clearTimeout(persistTimer);
833
+ persistTimer = null;
828
834
  }
829
- }
830
- for (const id of CACHE_PROVIDER_IDS) {
831
- const stats = cacheStatsLegacyFamily[id];
832
- if (stats && stats.day !== day) {
833
- cacheStatsLegacyFamily[id] = emptyCacheStats(day);
834
- changed = true;
835
- }
836
- }
837
- if (changed) {
835
+ // Preserve yesterday under its original UUID/day before clearing daily
836
+ // counters. Each day owns a new shard so total never includes yesterday.
837
+ await refreshShardAggregate();
838
+ await persistCacheStats(ctx, "closed");
839
+ retiredShards.set(instanceId, buildCurrentStatsShard("closed"));
840
+ instanceId = randomUUID();
841
+ instanceShardPath = join(SHARD_FILES_DIR, `${instanceId}.json`);
842
+ shardDay = day;
838
843
  shardCreatedAt = Date.now();
844
+ cacheStatsProcessByModel = {};
845
+ cacheStatsLegacyFamily = emptyAllCacheStats();
839
846
  lastStatusText = undefined;
840
- await ensureCurrentEpochs();
841
- await flushPersistCacheStats(ctx);
842
- }
847
+ await persistCacheStats(ctx);
848
+ await refreshShardAggregate();
849
+ })();
850
+ try { await rolloverPromise; }
851
+ finally { rolloverPromise = undefined; }
843
852
  }
844
853
 
845
854
  async function restoreCacheStats(reason: string, ctx: ExtensionContext): Promise<void> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cache-optimizer",
3
- "version": "2.8.18",
3
+ "version": "2.8.20",
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,8 +37,8 @@
37
37
  "check:modules": "node scripts/check-module-graph.mjs"
38
38
  },
39
39
  "devDependencies": {
40
- "@earendil-works/pi-coding-agent": "^1.0.0",
41
- "@earendil-works/pi-server": "1.0.0",
40
+ "@earendil-works/pi-coding-agent": "^1.0.3",
41
+ "@earendil-works/pi-server": "1.0.3",
42
42
  "@types/node": "^22.0.0",
43
43
  "jiti": "^2.7.0",
44
44
  "typescript": "^5.8.3"
@@ -84,6 +84,15 @@ export function hasPromptCacheRetentionUnsupportedErrorMessage(message: unknown)
84
84
  hasPromptCacheRetentionUnsupportedText(record.errorMessage);
85
85
  }
86
86
 
87
+ // A Chinese rejection must start a diagnostic clause, not occur inside a
88
+ // negation or condition. Keep comma-separated conditions in the same sentence.
89
+ function hasChineseRejectionContext(value: string, index: number): boolean {
90
+ const prefix = value.slice(0, index).split(/[;!\n。;!]/).at(-1) ?? "";
91
+ const suffix = value.slice(index).split(/[;!\n。;!]/)[0];
92
+ return !/(?:如果|若|当|仅在|只有|模式下|情况下|设置.{0,40}时)/.test(prefix + suffix) &&
93
+ !/(?:并非|并不|并未|不是)/.test(prefix);
94
+ }
95
+
87
96
  export function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
88
97
  const normalized = lower(value)
89
98
  .replace(/["'`]/g, "")
@@ -98,11 +107,13 @@ export function hasPromptCacheKeyUnsupportedText(value: unknown): boolean {
98
107
  // Keep this deliberately grammar-bound. A rejected value or a conditional
99
108
  // restriction (for example, "not allowed when temperature is set") is not
100
109
  // proof that the endpoint lacks support for the field itself.
101
- const terminal = String.raw`(?=$|[}\]>,.;])`;
110
+ const terminal = String.raw`(?=$|[}\]>,.;,。;])`;
102
111
  return (
103
112
  new RegExp(String.raw`(?:unsupported|unknown|unrecognized|unexpected)\s+${field}\s*[:=]?\s*${key}${terminal}`).test(normalized) ||
104
113
  new RegExp(String.raw`(?:extra\s+inputs?|${field}\s+not\s+(?:allowed|permitted|supported))\s*[:=]\s*${key}${terminal}`).test(normalized) ||
105
- new RegExp(String.raw`${key}(?:\s+${field})?\s*[:=]?\s*(?:is\s+)?${unsupported}${terminal}`).test(normalized)
114
+ new RegExp(String.raw`${key}(?:\s+${field})?\s*[:=]?\s*(?:is\s+)?${unsupported}${terminal}`).test(normalized) ||
115
+ Array.from(normalized.matchAll(new RegExp(String.raw`(?:^|[^a-z0-9_\u4e00-\u9fff])(?:未知|未识别|不支持|不允许)(?:请求)?(?:字段|参数)\s*[::]\s*${key}${terminal}`, "g")))
116
+ .some((match) => hasChineseRejectionContext(normalized, match.index))
106
117
  );
107
118
  }
108
119
 
@@ -126,6 +137,15 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
126
137
  /(?:^|[^a-z0-9_])(?:unsupported|unknown|unrecognized|invalid|disallowed|rejected|not\s+(?:a\s+)?valid|not\s+accepted|not\s+allowed|not\s+permitted)(?:[_ ](?:parameter|field|argument))?\s*[:=]?\s*["'`]?thinking["'`]?(?![a-z0-9_])/,
127
138
  /(?:^|[^a-z0-9_])(?:extra\s+inputs?|additional\s+(?:inputs?|parameters?))\s+(?:are\s+)?(?:not permitted|not allowed|unsupported)\s*[:=]?\s*["'`]?thinking["'`]?(?![a-z0-9_])/,
128
139
  ];
140
+ const chineseStart = String.raw`(?:^|[^a-z0-9_\u4e00-\u9fff])`;
141
+ const chineseEnd = String.raw`(?=\s*(?:$|["'\x60}\]>,.;!?,。;!?]))`;
142
+ const chineseRejection = String.raw`(?:未知|未识别|无法识别|不支持|不被支持|不允许|不被允许|不接受|不被接受|无效|不合法|拒绝|被拒绝)`;
143
+ const chineseThinking = String.raw`["'\x60]?thinking["'\x60]?`;
144
+ const chinesePatterns = [
145
+ new RegExp(String.raw`${chineseStart}${chineseRejection}(?:请求)?(?:参数|字段)\s*[::]?\s*${chineseThinking}${chineseEnd}`, "g"),
146
+ new RegExp(String.raw`${chineseStart}${chineseRejection}\s*${chineseThinking}(?:\s*(?:请求)?(?:参数|字段))?${chineseEnd}`, "g"),
147
+ new RegExp(String.raw`${chineseStart}${chineseThinking}\s*(?:(?:请求)?(?:参数|字段)\s*)?${chineseRejection}${chineseEnd}`, "g"),
148
+ ];
129
149
  let rejectionEnd = -1;
130
150
  for (const pattern of thinkingParameterRejectionPatterns) {
131
151
  const match = pattern.exec(normalized);
@@ -133,6 +153,13 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
133
153
  rejectionEnd = match.index + match[0].length;
134
154
  }
135
155
  }
156
+ for (const pattern of chinesePatterns) {
157
+ for (const match of normalized.matchAll(pattern)) {
158
+ if (hasChineseRejectionContext(normalized, match.index)) {
159
+ rejectionEnd = Math.max(rejectionEnd, match.index + match[0].length);
160
+ }
161
+ }
162
+ }
136
163
  if (rejectionEnd < 0) return false;
137
164
 
138
165
  // The provider must direct the caller to reasoning_effort after the
@@ -142,7 +169,7 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
142
169
  const recommendation = normalized.slice(rejectionEnd, rejectionEnd + 260);
143
170
  if (!/\breasoning[_\.]effort\b/.test(recommendation)) return false;
144
171
 
145
- const recommendationClauses = recommendation.split(/[.;!?\n]/);
172
+ const recommendationClauses = recommendation.split(/[.;!?\n,。;!?]/);
146
173
  return recommendationClauses.some((clause) => {
147
174
  if (!/\breasoning[_\.]effort\b/.test(clause)) return false;
148
175
  // A target mentioned inside a negated/disabled clause is not positive
@@ -150,7 +177,9 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
150
177
  if (
151
178
  /\b(?:do\s+not|don't|never|avoid)\s+(?:use|set|send|pass|provide)?\s*["'`]?reasoning[_\.]effort\b/.test(clause) ||
152
179
  /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:must|should|may|do)\s+(?:not|never)\b/.test(clause) ||
153
- /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:unsupported|disabled|unavailable|not\s+(?:supported|accepted|allowed|available|enabled|required|recommended|expected))\b/.test(clause)
180
+ /\breasoning[_\.]effort\b[^.;]{0,80}\b(?:unsupported|disabled|unavailable|not\s+(?:supported|accepted|allowed|available|enabled|required|recommended|expected))\b/.test(clause) ||
181
+ /(?:请)?(?:不要|勿|不应|不该|不得|不可|不能|不建议|不推荐|禁止|无需|不必|不需要)\s*(?:再)?(?:使用|设置|发送|传入|提供|改用|采用|把)?\s*(?:参数\s*)?["'`]?reasoning[_\.]effort\b/.test(clause) ||
182
+ /\breasoning[_\.]effort\b[^.;,。;!?]{0,80}(?:不支持|不允许|不可用|不接受|不能|不建议|不推荐|不需要|无需|不必|不得|不应|不该|禁止)/.test(clause)
154
183
  ) return false;
155
184
 
156
185
  return [
@@ -159,6 +188,9 @@ export function hasReasoningProtocolRejectionText(value: unknown): boolean {
159
188
  /(?:parameter|field|option)\s+(?:is|should be)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
160
189
  /instead[^.;]{0,120}(?:use|try|set|send|pass|provide)\s+(?:the\s+)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
161
190
  /(?:replace|change|switch)\s+(?:the\s+)?["'`]?thinking["'`]?\s+(?:with|to)\s+["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
191
+ /(?:请)?(?:使用|改用|采用|设置|发送|传入|提供|切换到|改为|用)\s*(?:参数\s*)?["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
192
+ /(?:将|把)\s*["'`]?thinking["'`]?(?:\s*(?:请求)?参数)?\s*(?:替换为|改为|换成|切换为|替代为)\s*["'`]?reasoning[_\.]effort["'`]?(?![a-z0-9_])/,
193
+ /reasoning[_\.]effort\b[^.;,。;!?]{0,80}(?:必须|必需|应当|应该|推荐|受支持|可用)/,
162
194
  ].some((pattern) => pattern.test(clause));
163
195
  });
164
196
  }
@@ -26,7 +26,9 @@ export const SHARD_CLEANUP_LOCK_PATH = join(SHARD_MAINTENANCE_DIR, "cleanup.lock
26
26
 
27
27
  export const SHARD_CLEANUP_MARKER_PATH = join(SHARD_MAINTENANCE_DIR, "last-cleanup");
28
28
 
29
- export const SHARD_RETENTION_MS = 48 * 60 * 60 * 1000;
29
+ // Retain closed and inactive shards for roughly two months so session-scoped
30
+ // statistics can survive long-running conversations without meaningful disk use.
31
+ export const SHARD_RETENTION_MS = 60 * 24 * 60 * 60 * 1000;
30
32
 
31
33
  export const SHARD_TEMP_RETENTION_MS = 24 * 60 * 60 * 1000;
32
34
 
@@ -188,6 +190,15 @@ export function mergeCacheStatsForTotal(existing: CacheStats | undefined, incomi
188
190
  return existing;
189
191
  }
190
192
 
193
+ /** Merge counters while retaining historical days for an explicitly session-scoped view. */
194
+ export function mergeStatsAcrossDays(existing: CacheStats | undefined, incoming: CacheStats, day: string | null): CacheStats {
195
+ if (day !== null) return mergeCacheStatsForTotal(existing, incoming);
196
+ if (!existing) return cloneCacheStats(incoming);
197
+ addCacheStatsTotals(existing, incoming);
198
+ existing.day = incoming.day > existing.day ? incoming.day : existing.day;
199
+ return existing;
200
+ }
201
+
191
202
  export function parsePersistedRoutedModelRef(value: unknown): PersistedRoutedModelRef | undefined {
192
203
  const record = asRecord(value);
193
204
  const provider = record?.provider;
@@ -352,7 +363,7 @@ export async function readValidStatsShardsV7(directory: string = SHARD_FILES_DIR
352
363
 
353
364
  export async function aggregateStatsShardsV7(
354
365
  shards: PersistedStatsShardV7[],
355
- day: string = currentLocalDay(),
366
+ day: string | null = currentLocalDay(),
356
367
  ): Promise<ShardAggregate> {
357
368
  const globalEpoch = await readGlobalStatsEpoch();
358
369
  const modelEpochs = new Map<string, string>();
@@ -371,7 +382,7 @@ export async function aggregateStatsShardsV7(
371
382
  const modelSessions = new Map<string, Set<string>>();
372
383
 
373
384
  for (const shard of shards) {
374
- if (shard.day !== day || shard.globalEpoch !== globalEpoch) continue;
385
+ if ((day !== null && shard.day !== day) || shard.globalEpoch !== globalEpoch) continue;
375
386
  let contributed = false;
376
387
  if (shard.lastRoutedModel && (routedUpdatedAt.get(shard.sessionHash) ?? -1) < shard.lifecycle.updatedAt) {
377
388
  routedUpdatedAt.set(shard.sessionHash, shard.lifecycle.updatedAt);
@@ -383,11 +394,11 @@ export async function aggregateStatsShardsV7(
383
394
  epoch = await readModelStatsEpoch(key);
384
395
  modelEpochs.set(key, epoch);
385
396
  }
386
- if (entry.modelEpoch !== epoch || entry.stats.day !== day) continue;
397
+ if (entry.modelEpoch !== epoch || (day !== null && entry.stats.day !== day)) continue;
387
398
  contributed = true;
388
399
  const sessionModels = result.bySession[shard.sessionHash] ??= {};
389
- sessionModels[key] = mergeCacheStatsForTotal(sessionModels[key], entry.stats);
390
- result.totalsByModel[key] = mergeCacheStatsForTotal(result.totalsByModel[key], entry.stats);
400
+ sessionModels[key] = mergeStatsAcrossDays(sessionModels[key], entry.stats, day);
401
+ result.totalsByModel[key] = mergeStatsAcrossDays(result.totalsByModel[key], entry.stats, day);
391
402
  result.instancesByModel[key] = (result.instancesByModel[key] ?? 0) + 1;
392
403
  if ((modelRefUpdatedAt.get(key) ?? -1) < shard.lifecycle.updatedAt) {
393
404
  modelRefUpdatedAt.set(key, shard.lifecycle.updatedAt);