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 +11 -11
- package/README.zh-CN.md +12 -12
- package/index.ts +39 -30
- package/package.json +3 -3
- package/src/provider-errors.ts +36 -4
- package/src/stats-store.ts +17 -6
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.
|
|
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
|
|
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
|
|
144
|
-
| `total` | Aggregate
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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`(默认) |
|
|
144
|
-
| `total` |
|
|
145
|
-
| `process` | 仅显示当前 extension instance
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
|
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
|
|
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`
|
|
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
|
-
|
|
447
|
+
let instanceId = randomUUID();
|
|
447
448
|
const instanceStartedAt = Date.now();
|
|
448
|
-
|
|
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(
|
|
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 =
|
|
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:
|
|
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(
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
if (
|
|
826
|
-
|
|
827
|
-
|
|
829
|
+
if (shardDay === day) return;
|
|
830
|
+
rolloverPromise = (async () => {
|
|
831
|
+
if (persistTimer !== null) {
|
|
832
|
+
clearTimeout(persistTimer);
|
|
833
|
+
persistTimer = null;
|
|
828
834
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
841
|
-
await
|
|
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.
|
|
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.
|
|
41
|
-
"@earendil-works/pi-server": "1.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"
|
package/src/provider-errors.ts
CHANGED
|
@@ -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
|
}
|
package/src/stats-store.ts
CHANGED
|
@@ -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
|
-
|
|
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] =
|
|
390
|
-
result.totalsByModel[key] =
|
|
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);
|