claude-code-cache-fix 1.4.1 → 1.5.1
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 +16 -0
- package/README.zh.md +167 -0
- package/package.json +3 -2
- package/preload.mjs +151 -9
- package/tools/cost-report.mjs +880 -0
- package/tools/rates.json +91 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# claude-code-cache-fix
|
|
2
2
|
|
|
3
|
+
English | [中文](./README.zh.md)
|
|
4
|
+
|
|
3
5
|
Fixes prompt cache regressions in [Claude Code](https://github.com/anthropics/claude-code) that cause **up to 20x cost increase** on resumed sessions, plus monitoring for silent context degradation. Confirmed through v2.1.92.
|
|
4
6
|
|
|
5
7
|
## The problem
|
|
@@ -118,6 +120,19 @@ Response headers are parsed for `anthropic-ratelimit-unified-5h-utilization` and
|
|
|
118
120
|
|
|
119
121
|
Anthropic applies elevated quota drain rates during weekday peak hours (13:00–19:00 UTC, Mon–Fri). The interceptor detects peak windows and writes `peak_hour: true/false` to `quota-status.json`. See `docs/peak-hours-reference.md` for sources and details.
|
|
120
122
|
|
|
123
|
+
### Usage telemetry and cost reporting
|
|
124
|
+
|
|
125
|
+
The interceptor logs per-call usage data to `~/.claude/usage.jsonl` — one JSON line per API call with model, token counts, and cache breakdown. Use the bundled cost report tool to analyze costs:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
node tools/cost-report.mjs # today's costs from interceptor log
|
|
129
|
+
node tools/cost-report.mjs --date 2026-04-08 # specific date
|
|
130
|
+
node tools/cost-report.mjs --since 2h # last 2 hours
|
|
131
|
+
node tools/cost-report.mjs --admin-key <key> # cross-reference with Admin API
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Also works with any JSONL containing Anthropic usage fields (`--file`, stdin) — useful for SDK users and proxy setups. See `docs/cost-report.md` for full documentation.
|
|
135
|
+
|
|
121
136
|
## Debug mode
|
|
122
137
|
|
|
123
138
|
Enable debug logging to verify the fix is working:
|
|
@@ -157,6 +172,7 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
|
|
|
157
172
|
| `CACHE_FIX_DEBUG` | `0` | Enable debug logging to `~/.claude/cache-fix-debug.log` |
|
|
158
173
|
| `CACHE_FIX_PREFIXDIFF` | `0` | Enable prefix snapshot diffing |
|
|
159
174
|
| `CACHE_FIX_IMAGE_KEEP_LAST` | `0` | Keep images in last N user messages (0 = disabled) |
|
|
175
|
+
| `CACHE_FIX_USAGE_LOG` | `~/.claude/usage.jsonl` | Path for per-call usage telemetry log |
|
|
160
176
|
|
|
161
177
|
## Limitations
|
|
162
178
|
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# claude-code-cache-fix
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | 中文
|
|
4
|
+
|
|
5
|
+
修复 [Claude Code](https://github.com/anthropics/claude-code) 中导致恢复会话时**成本增加高达 20 倍**的提示缓存回归问题,同时监控静默上下文降级。已在 v2.1.92 至 v2.1.96 上验证。
|
|
6
|
+
|
|
7
|
+
## 问题描述
|
|
8
|
+
|
|
9
|
+
当你在 Claude Code 中使用 `--resume` 或 `/resume` 时,提示缓存会静默失效。API 不再读取已缓存的 token(廉价),而是每一轮都从头重建(昂贵)。原本每小时约 $0.50 的会话可能在无任何提示的情况下飙升至 $5-10/小时。
|
|
10
|
+
|
|
11
|
+
三个 bug 导致了这个问题:
|
|
12
|
+
|
|
13
|
+
1. **附件块散布** — 技能列表、MCP 服务器、延迟工具、钩子等附件块应当位于 `messages[0]` 中。恢复会话时,它们会漂移到后续消息中,改变缓存前缀。
|
|
14
|
+
|
|
15
|
+
2. **指纹不稳定** — `cc_version` 指纹(如 `2.1.92.a3f`)是根据 `messages[0]` 的内容计算的,包括元数据/附件块。当这些块发生偏移时,指纹改变,系统提示改变,缓存失效。
|
|
16
|
+
|
|
17
|
+
3. **工具定义排序不确定** — 工具定义在不同轮次间可能以不同顺序到达,改变请求字节并使缓存键失效。
|
|
18
|
+
|
|
19
|
+
此外,通过 Read 工具读取的图片会以 base64 形式持久化在对话历史中,在每次后续 API 调用时一并发送,悄然增加 token 成本。
|
|
20
|
+
|
|
21
|
+
## 安装
|
|
22
|
+
|
|
23
|
+
需要 Node.js >= 18,且 Claude Code 通过 npm 安装(非独立二进制文件)。
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g claude-code-cache-fix
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 使用方法
|
|
30
|
+
|
|
31
|
+
本修复以 Node.js 预加载模块的形式工作,在 API 请求离开本机之前进行拦截。
|
|
32
|
+
|
|
33
|
+
### 方式 A:包装脚本(推荐)
|
|
34
|
+
|
|
35
|
+
创建包装脚本(如 `~/bin/claude-fixed`):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
#!/bin/bash
|
|
39
|
+
CLAUDE_NPM_CLI="$HOME/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"
|
|
40
|
+
|
|
41
|
+
if [ ! -f "$CLAUDE_NPM_CLI" ]; then
|
|
42
|
+
echo "Error: Claude Code npm package not found at $CLAUDE_NPM_CLI" >&2
|
|
43
|
+
echo "Install with: npm install -g @anthropic-ai/claude-code" >&2
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
exec env NODE_OPTIONS="--import claude-code-cache-fix" node "$CLAUDE_NPM_CLI" "$@"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
chmod +x ~/bin/claude-fixed
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
如果你的 npm 全局前缀不同,请相应调整 `CLAUDE_NPM_CLI`。使用以下命令查找:
|
|
55
|
+
```bash
|
|
56
|
+
npm root -g
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 方式 B:Shell 别名
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
alias claude='NODE_OPTIONS="--import claude-code-cache-fix" node "$(npm root -g)/@anthropic-ai/claude-code/cli.js"'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 方式 C:直接调用
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
NODE_OPTIONS="--import claude-code-cache-fix" claude
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
> **注意**:仅在 `claude` 指向 npm/Node 安装时有效。独立二进制文件使用不同的执行路径,会绕过 Node.js 预加载。
|
|
72
|
+
|
|
73
|
+
## 工作原理
|
|
74
|
+
|
|
75
|
+
模块在 Claude Code 向 `/v1/messages` 发起 API 调用前拦截 `globalThis.fetch`。每次调用时:
|
|
76
|
+
|
|
77
|
+
1. **扫描所有用户消息**中的附件块(技能、MCP、延迟工具、钩子),将每种类型的最新版本移回 `messages[0]`,匹配全新会话的布局
|
|
78
|
+
2. **按名称字母顺序排列工具定义**,确保确定性排序
|
|
79
|
+
3. **重新计算 cc_version 指纹**,基于真实用户消息文本而非元数据/附件内容
|
|
80
|
+
|
|
81
|
+
所有修复都是幂等的 — 如果无需修复,请求将原样传递。拦截器对你的对话是只读的;它只在请求到达 API 之前规范化请求结构。
|
|
82
|
+
|
|
83
|
+
## 图片剥离
|
|
84
|
+
|
|
85
|
+
通过 Read 工具读取的图片以 base64 编码存储在对话历史的 `tool_result` 块中。它们会**在每次后续 API 调用中**随行发送,直到压缩。单张 500KB 的图片每轮带来约 62,500 token 的额外开销。
|
|
86
|
+
|
|
87
|
+
启用图片剥离以移除旧的工具结果中的图片:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
export CACHE_FIX_IMAGE_KEEP_LAST=3
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
这将保留最近 3 条用户消息中的图片,并将较早的替换为文本占位符。仅针对 `tool_result` 块(Read 工具输出)中的图片 — 用户粘贴的图片不受影响。文件仍保留在磁盘上,需要时可重新读取。
|
|
94
|
+
|
|
95
|
+
设为 `0`(默认)以禁用。
|
|
96
|
+
|
|
97
|
+
## 监控功能
|
|
98
|
+
|
|
99
|
+
拦截器包含社区发现的多项额外问题的监控:
|
|
100
|
+
|
|
101
|
+
### 微压缩 / 预算执行
|
|
102
|
+
|
|
103
|
+
Claude Code 通过服务器控制机制(GrowthBook 标志)静默替换旧的工具结果为 `[Old tool result content cleared]`。200,000 字符的聚合上限和每工具上限(Bash: 30K, Grep: 20K)会截断较早的结果且无通知。
|
|
104
|
+
|
|
105
|
+
拦截器检测已清除的工具结果并记录计数。当总工具结果字符数接近 200K 阈值时,会记录警告。
|
|
106
|
+
|
|
107
|
+
### 虚假速率限制器
|
|
108
|
+
|
|
109
|
+
客户端可以在不发起 API 调用的情况下生成合成的 "Rate limit reached" 错误,可通过 `"model": "<synthetic>"` 识别。拦截器会记录这些事件。
|
|
110
|
+
|
|
111
|
+
### 配额追踪
|
|
112
|
+
|
|
113
|
+
解析响应头中的 `anthropic-ratelimit-unified-5h-utilization` 和 `7d-utilization`,保存到 `~/.claude/quota-status.json`,供状态栏钩子或其他工具使用。
|
|
114
|
+
|
|
115
|
+
### 高峰时段检测
|
|
116
|
+
|
|
117
|
+
Anthropic 在工作日高峰时段(UTC 13:00-19:00,周一至周五)会提高配额消耗速率。拦截器检测高峰窗口并将 `peak_hour: true/false` 写入 `quota-status.json`。详见 `docs/peak-hours-reference.md`。
|
|
118
|
+
|
|
119
|
+
### 使用量遥测与成本报告
|
|
120
|
+
|
|
121
|
+
拦截器将每次调用的使用数据记录到 `~/.claude/usage.jsonl` — 每次 API 调用一行 JSON,包含模型、token 计数和缓存明细。使用内置的成本报告工具分析费用:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
node tools/cost-report.mjs # 从拦截器日志查看今日费用
|
|
125
|
+
node tools/cost-report.mjs --date 2026-04-08 # 指定日期
|
|
126
|
+
node tools/cost-report.mjs --since 2h # 最近 2 小时
|
|
127
|
+
node tools/cost-report.mjs --admin-key <key> # 与 Admin API 交叉验证
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
同样适用于任何包含 Anthropic 使用量字段的 JSONL(`--file`、stdin)— 适合 SDK 用户和代理设置。支持文本、JSON 和 Markdown 输出格式。详见 `docs/cost-report.md`。
|
|
131
|
+
|
|
132
|
+
## 调试模式
|
|
133
|
+
|
|
134
|
+
启用调试日志以验证修复是否生效:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
CACHE_FIX_DEBUG=1 claude-fixed
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
日志写入 `~/.claude/cache-fix-debug.log`。
|
|
141
|
+
|
|
142
|
+
## 环境变量
|
|
143
|
+
|
|
144
|
+
| 变量 | 默认值 | 说明 |
|
|
145
|
+
|------|--------|------|
|
|
146
|
+
| `CACHE_FIX_DEBUG` | `0` | 启用调试日志 |
|
|
147
|
+
| `CACHE_FIX_PREFIXDIFF` | `0` | 启用前缀快照差异对比 |
|
|
148
|
+
| `CACHE_FIX_IMAGE_KEEP_LAST` | `0` | 保留最近 N 条用户消息中的图片(0 = 禁用) |
|
|
149
|
+
| `CACHE_FIX_USAGE_LOG` | `~/.claude/usage.jsonl` | 每次调用使用量遥测日志路径 |
|
|
150
|
+
|
|
151
|
+
## 限制
|
|
152
|
+
|
|
153
|
+
- **仅支持 npm 安装** — 独立 Claude Code 二进制文件具有 Zig 级别的证明机制,会绕过 Node.js。本修复仅适用于 npm 包(`npm install -g @anthropic-ai/claude-code`)。
|
|
154
|
+
- **超额 TTL 降级** — 超过 5 小时配额的 100% 会触发服务器端 TTL 从 1h 降级至 5m。这是服务器端决策,无法在客户端修复。拦截器通过防止缓存不稳定来避免你首先进入超额状态。
|
|
155
|
+
- **微压缩不可阻止** — 监控功能可以检测上下文降级,但无法阻止。微压缩和预算执行机制是通过 GrowthBook 标志进行服务器控制的,没有客户端禁用选项。
|
|
156
|
+
|
|
157
|
+
## 相关问题
|
|
158
|
+
|
|
159
|
+
- [#34629](https://github.com/anthropics/claude-code/issues/34629) — 恢复缓存回归的原始报告
|
|
160
|
+
- [#40524](https://github.com/anthropics/claude-code/issues/40524) — 会话内指纹失效,图片持久化
|
|
161
|
+
- [#42052](https://github.com/anthropics/claude-code/issues/42052) — 社区拦截器开发,TTL 降级发现
|
|
162
|
+
- [#44045](https://github.com/anthropics/claude-code/issues/44045) — 恢复时提示缓存部分缺失
|
|
163
|
+
- [#41930](https://github.com/anthropics/claude-code/issues/41930) — 多种根因导致的异常用量消耗
|
|
164
|
+
|
|
165
|
+
## 许可证
|
|
166
|
+
|
|
167
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-cache-fix",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Fixes prompt cache regression in Claude Code that causes up to 20x cost increase on resumed sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": "./preload.mjs",
|
|
7
7
|
"main": "./preload.mjs",
|
|
8
8
|
"files": [
|
|
9
|
-
"preload.mjs"
|
|
9
|
+
"preload.mjs",
|
|
10
|
+
"tools/"
|
|
10
11
|
],
|
|
11
12
|
"engines": {
|
|
12
13
|
"node": ">=18"
|
package/preload.mjs
CHANGED
|
@@ -169,6 +169,75 @@ function sortSkillsBlock(text) {
|
|
|
169
169
|
return header + entries.join("\n") + footer;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Sort deferred tools listing for deterministic ordering. The block format is:
|
|
174
|
+
* <system-reminder>
|
|
175
|
+
* The following deferred tools are now available via ToolSearch:
|
|
176
|
+
* ToolName1
|
|
177
|
+
* ToolName2
|
|
178
|
+
* ...
|
|
179
|
+
* </system-reminder>
|
|
180
|
+
*
|
|
181
|
+
* When MCP tools register asynchronously, new tools can appear between API
|
|
182
|
+
* calls, changing the block content and busting cache. Sorting ensures that
|
|
183
|
+
* once a tool appears, its position is deterministic.
|
|
184
|
+
*/
|
|
185
|
+
function sortDeferredToolsBlock(text) {
|
|
186
|
+
const match = text.match(
|
|
187
|
+
/^(<system-reminder>\nThe following deferred tools are now available[^\n]*\n)([\s\S]+?)(\n<\/system-reminder>\s*)$/
|
|
188
|
+
);
|
|
189
|
+
if (!match) return text;
|
|
190
|
+
const [, header, toolsList, footer] = match;
|
|
191
|
+
const tools = toolsList.split("\n").map(t => t.trim()).filter(Boolean);
|
|
192
|
+
tools.sort();
|
|
193
|
+
return header + tools.join("\n") + footer;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --------------------------------------------------------------------------
|
|
197
|
+
// Content pinning for MCP registration jitter (Bug 4)
|
|
198
|
+
// --------------------------------------------------------------------------
|
|
199
|
+
//
|
|
200
|
+
// When MCP tools register asynchronously, the skills and deferred tools blocks
|
|
201
|
+
// can change between consecutive API calls as new tools finish registering.
|
|
202
|
+
// This causes repeated cache busts even though the final tool set is stable.
|
|
203
|
+
//
|
|
204
|
+
// Fix: track the content hash of each block type. When content changes, accept
|
|
205
|
+
// one cache miss (the new tool needs to be visible), then pin the new content.
|
|
206
|
+
// If the SAME content appears on consecutive calls, use the pinned version
|
|
207
|
+
// with normalized whitespace to prevent trivial diffs.
|
|
208
|
+
//
|
|
209
|
+
// Reported by @bilby91 on #44045 (Agent SDK with MCP tools).
|
|
210
|
+
// --------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
const _pinnedBlocks = new Map(); // blockType → { hash, text }
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Normalize a block's trailing whitespace and pin its content. Returns the
|
|
216
|
+
* normalized text. On first call for a block type, pins the content. On
|
|
217
|
+
* subsequent calls, if the content hash matches the pin, returns the pinned
|
|
218
|
+
* version (byte-identical). If content changed, updates the pin and returns
|
|
219
|
+
* the new content (accepts one cache bust).
|
|
220
|
+
*/
|
|
221
|
+
function pinBlockContent(blockType, text) {
|
|
222
|
+
// Normalize: trim trailing whitespace inside the </system-reminder> tag
|
|
223
|
+
const normalized = text.replace(/\s+(<\/system-reminder>)\s*$/, "\n$1");
|
|
224
|
+
|
|
225
|
+
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
226
|
+
const pinned = _pinnedBlocks.get(blockType);
|
|
227
|
+
|
|
228
|
+
if (pinned && pinned.hash === hash) {
|
|
229
|
+
// Content matches pin — return pinned version (byte-identical)
|
|
230
|
+
return pinned.text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Content changed or first call — update pin
|
|
234
|
+
if (pinned && pinned.hash !== hash) {
|
|
235
|
+
debugLog(`CONTENT PIN: ${blockType} changed (${pinned.hash} → ${hash}) — accepting one cache bust`);
|
|
236
|
+
}
|
|
237
|
+
_pinnedBlocks.set(blockType, { hash, text: normalized });
|
|
238
|
+
return normalized;
|
|
239
|
+
}
|
|
240
|
+
|
|
172
241
|
/**
|
|
173
242
|
* Strip session_knowledge from hooks blocks — ephemeral content that differs
|
|
174
243
|
* between sessions and would bust cache.
|
|
@@ -226,7 +295,46 @@ function normalizeResumeMessages(messages) {
|
|
|
226
295
|
}
|
|
227
296
|
}
|
|
228
297
|
}
|
|
229
|
-
|
|
298
|
+
|
|
299
|
+
// Even when blocks aren't scattered, apply sorting and content pinning to
|
|
300
|
+
// blocks in messages[0]. This handles MCP registration jitter where block
|
|
301
|
+
// CONTENT changes between calls (new tool registers) without scattering.
|
|
302
|
+
// (Reported by @bilby91 — Agent SDK with async MCP tools, #44045)
|
|
303
|
+
if (!hasScatteredBlocks) {
|
|
304
|
+
let contentModified = false;
|
|
305
|
+
const newContent = firstMsg.content.map((block) => {
|
|
306
|
+
const text = block.text || "";
|
|
307
|
+
if (!isRelocatableBlock(text)) return block;
|
|
308
|
+
|
|
309
|
+
let fixedText = text;
|
|
310
|
+
if (isSkillsBlock(text)) fixedText = sortSkillsBlock(text);
|
|
311
|
+
else if (isDeferredToolsBlock(text)) fixedText = sortDeferredToolsBlock(text);
|
|
312
|
+
else if (isHooksBlock(text)) fixedText = stripSessionKnowledge(text);
|
|
313
|
+
|
|
314
|
+
// Determine block type for pinning
|
|
315
|
+
let blockType;
|
|
316
|
+
if (isSkillsBlock(text)) blockType = "skills";
|
|
317
|
+
else if (isDeferredToolsBlock(text)) blockType = "deferred";
|
|
318
|
+
else if (isMcpBlock(text)) blockType = "mcp";
|
|
319
|
+
else if (isHooksBlock(text)) blockType = "hooks";
|
|
320
|
+
|
|
321
|
+
if (blockType) fixedText = pinBlockContent(blockType, fixedText);
|
|
322
|
+
|
|
323
|
+
if (fixedText !== text) {
|
|
324
|
+
contentModified = true;
|
|
325
|
+
const { cache_control, ...rest } = block;
|
|
326
|
+
return { ...rest, text: fixedText };
|
|
327
|
+
}
|
|
328
|
+
return block;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (contentModified) {
|
|
332
|
+
return messages.map((msg, idx) =>
|
|
333
|
+
idx === firstUserIdx ? { ...msg, content: newContent } : msg
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return messages;
|
|
337
|
+
}
|
|
230
338
|
|
|
231
339
|
// Scan ALL user messages (including first) in reverse to collect the LATEST
|
|
232
340
|
// version of each block type. This handles both full and partial scatter.
|
|
@@ -254,6 +362,10 @@ function normalizeResumeMessages(messages) {
|
|
|
254
362
|
let fixedText = text;
|
|
255
363
|
if (blockType === "hooks") fixedText = stripSessionKnowledge(text);
|
|
256
364
|
if (blockType === "skills") fixedText = sortSkillsBlock(text);
|
|
365
|
+
if (blockType === "deferred") fixedText = sortDeferredToolsBlock(text);
|
|
366
|
+
|
|
367
|
+
// Pin content to prevent jitter from late MCP tool registration
|
|
368
|
+
fixedText = pinBlockContent(blockType, fixedText);
|
|
257
369
|
|
|
258
370
|
const { cache_control, ...rest } = block;
|
|
259
371
|
found.set(blockType, { ...rest, text: fixedText });
|
|
@@ -399,6 +511,7 @@ const DEBUG = process.env.CACHE_FIX_DEBUG === "1";
|
|
|
399
511
|
const PREFIXDIFF = process.env.CACHE_FIX_PREFIXDIFF === "1";
|
|
400
512
|
const LOG_PATH = join(homedir(), ".claude", "cache-fix-debug.log");
|
|
401
513
|
const SNAPSHOT_DIR = join(homedir(), ".claude", "cache-fix-snapshots");
|
|
514
|
+
const USAGE_JSONL = process.env.CACHE_FIX_USAGE_LOG || join(homedir(), ".claude", "usage.jsonl");
|
|
402
515
|
|
|
403
516
|
function debugLog(...args) {
|
|
404
517
|
if (!DEBUG) return;
|
|
@@ -813,10 +926,13 @@ globalThis.fetch = async function (url, options) {
|
|
|
813
926
|
// Non-critical — don't break the response
|
|
814
927
|
}
|
|
815
928
|
|
|
816
|
-
// Clone response to extract TTL tier
|
|
929
|
+
// Clone response to extract TTL tier and usage telemetry from SSE stream.
|
|
930
|
+
// Pass the model from the request so we can log a complete usage record.
|
|
817
931
|
try {
|
|
932
|
+
let reqModel = "unknown";
|
|
933
|
+
try { reqModel = JSON.parse(options?.body)?.model || "unknown"; } catch {}
|
|
818
934
|
const clone = response.clone();
|
|
819
|
-
drainTTLFromClone(clone).catch(() => {});
|
|
935
|
+
drainTTLFromClone(clone, reqModel).catch(() => {});
|
|
820
936
|
} catch {
|
|
821
937
|
// clone() failure is non-fatal
|
|
822
938
|
}
|
|
@@ -837,13 +953,18 @@ globalThis.fetch = async function (url, options) {
|
|
|
837
953
|
* Writes TTL tier to ~/.claude/quota-status.json (merges with existing data)
|
|
838
954
|
* and logs to debug log.
|
|
839
955
|
*/
|
|
840
|
-
async function drainTTLFromClone(clone) {
|
|
956
|
+
async function drainTTLFromClone(clone, model) {
|
|
841
957
|
if (!clone.body) return;
|
|
842
958
|
|
|
843
959
|
const reader = clone.body.getReader();
|
|
844
960
|
const decoder = new TextDecoder();
|
|
845
961
|
let buffer = "";
|
|
846
962
|
|
|
963
|
+
// Accumulate usage across message_start (input/cache) and message_delta (output)
|
|
964
|
+
let startUsage = null;
|
|
965
|
+
let deltaUsage = null;
|
|
966
|
+
let ttlTier = "unknown";
|
|
967
|
+
|
|
847
968
|
try {
|
|
848
969
|
while (true) {
|
|
849
970
|
const { done, value } = await reader.read();
|
|
@@ -862,6 +983,7 @@ async function drainTTLFromClone(clone) {
|
|
|
862
983
|
|
|
863
984
|
if (event.type === "message_start" && event.message?.usage) {
|
|
864
985
|
const u = event.message.usage;
|
|
986
|
+
startUsage = u;
|
|
865
987
|
const cc = u.cache_creation || {};
|
|
866
988
|
const e1h = cc.ephemeral_1h_input_tokens ?? 0;
|
|
867
989
|
const e5m = cc.ephemeral_5m_input_tokens ?? 0;
|
|
@@ -869,8 +991,6 @@ async function drainTTLFromClone(clone) {
|
|
|
869
991
|
const cacheRead = u.cache_read_input_tokens ?? 0;
|
|
870
992
|
|
|
871
993
|
// Determine TTL tier from which ephemeral bucket got tokens
|
|
872
|
-
// When cache is fully warm (no creation), infer tier from previous
|
|
873
|
-
let ttlTier = "unknown";
|
|
874
994
|
if (e1h > 0 && e5m === 0) ttlTier = "1h";
|
|
875
995
|
else if (e5m > 0 && e1h === 0) ttlTier = "5m";
|
|
876
996
|
else if (e1h === 0 && e5m === 0 && cacheCreate === 0) {
|
|
@@ -908,10 +1028,11 @@ async function drainTTLFromClone(clone) {
|
|
|
908
1028
|
};
|
|
909
1029
|
writeFileSync(quotaFile, JSON.stringify(quota, null, 2));
|
|
910
1030
|
} catch {}
|
|
1031
|
+
}
|
|
911
1032
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1033
|
+
// Capture final usage from message_delta (has output_tokens)
|
|
1034
|
+
if (event.type === "message_delta" && event.usage) {
|
|
1035
|
+
deltaUsage = event.usage;
|
|
915
1036
|
}
|
|
916
1037
|
} catch {
|
|
917
1038
|
// Skip malformed SSE lines
|
|
@@ -921,4 +1042,25 @@ async function drainTTLFromClone(clone) {
|
|
|
921
1042
|
} finally {
|
|
922
1043
|
try { reader.releaseLock(); } catch {}
|
|
923
1044
|
}
|
|
1045
|
+
|
|
1046
|
+
// Write usage record to JSONL after stream completes
|
|
1047
|
+
if (startUsage) {
|
|
1048
|
+
try {
|
|
1049
|
+
const cc = startUsage.cache_creation || {};
|
|
1050
|
+
const record = {
|
|
1051
|
+
timestamp: new Date().toISOString(),
|
|
1052
|
+
model: model || "unknown",
|
|
1053
|
+
input_tokens: startUsage.input_tokens ?? 0,
|
|
1054
|
+
output_tokens: deltaUsage?.output_tokens ?? 0,
|
|
1055
|
+
cache_read_input_tokens: startUsage.cache_read_input_tokens ?? 0,
|
|
1056
|
+
cache_creation_input_tokens: startUsage.cache_creation_input_tokens ?? 0,
|
|
1057
|
+
ephemeral_1h_input_tokens: cc.ephemeral_1h_input_tokens ?? 0,
|
|
1058
|
+
ephemeral_5m_input_tokens: cc.ephemeral_5m_input_tokens ?? 0,
|
|
1059
|
+
ttl_tier: ttlTier,
|
|
1060
|
+
};
|
|
1061
|
+
appendFileSync(USAGE_JSONL, JSON.stringify(record) + "\n");
|
|
1062
|
+
} catch {
|
|
1063
|
+
// Non-critical — don't break anything
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
924
1066
|
}
|