claude-code-cache-fix 1.6.3 → 1.6.4
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 +25 -0
- package/README.zh.md +5 -0
- package/package.json +2 -2
- package/tools/quota-analysis.mjs +539 -0
package/README.md
CHANGED
|
@@ -217,6 +217,26 @@ node tools/cost-report.mjs --admin-key <key> # cross-reference with Admin API
|
|
|
217
217
|
|
|
218
218
|
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.
|
|
219
219
|
|
|
220
|
+
### Quota analysis (5-hour quota counting)
|
|
221
|
+
|
|
222
|
+
The same `usage.jsonl` log can be analyzed to test how Anthropic's 5-hour quota is actually computed. Run the bundled tool:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
node tools/quota-analysis.mjs # analyze your default log
|
|
226
|
+
node tools/quota-analysis.mjs --since 24h # last 24 hours only
|
|
227
|
+
node tools/quota-analysis.mjs --json # machine-readable output
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The tool answers three questions from your own data:
|
|
231
|
+
|
|
232
|
+
1. **Does `cache_read` count toward your 5-hour quota?** Tests three hypotheses (cache_read costs 0x / 0.1x / 1x of input rate) and reports which one best explains your `q5h_pct` trajectory across reset windows. Lower coefficient of variation across windows = better fit.
|
|
233
|
+
2. **Do peak hours cost more quota per token?** Splits windows into peak-dominant (≥80% peak calls) and off-peak-dominant (≤20%) and compares the implied 100% quota under the best-fit model.
|
|
234
|
+
3. **What is your account's effective 5-hour quota in token-equivalents?** Reports a concrete number you can compare against your subscription tier or against what other users measure.
|
|
235
|
+
|
|
236
|
+
Requires `q5h_pct`, `q7d_pct`, and `peak_hour` fields in usage.jsonl, which were added in v1.6.1 (2026-04-09). Older entries are silently filtered out.
|
|
237
|
+
|
|
238
|
+
**Help us validate across accounts:** if you run this on your own log, please open an issue or PR on this repo with your output (or just the best-fit hypothesis name and your peak/off-peak ratio). Cross-validating across multiple accounts is the only way to distinguish per-account variance from real findings. Reference: [anthropics/claude-code#45756](https://github.com/anthropics/claude-code/issues/45756).
|
|
239
|
+
|
|
220
240
|
## Debug mode
|
|
221
241
|
|
|
222
242
|
Enable debug logging to verify the fix is working:
|
|
@@ -284,9 +304,14 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
|
|
|
284
304
|
- **[@ArkNill/claude-code-hidden-problem-analysis](https://github.com/ArkNill/claude-code-hidden-problem-analysis)** — Systematic proxy-based analysis of 7 bugs including microcompact, budget enforcement, false rate limiter, and extended thinking quota impact. The monitoring features in v1.1.0 are informed by this research.
|
|
285
305
|
- **[@Renvect/X-Ray-Claude-Code-Interceptor](https://github.com/Renvect/X-Ray-Claude-Code-Interceptor)** — Diagnostic HTTPS proxy with real-time dashboard, system prompt section diffing, per-tool stripping thresholds, and multi-stream JSONL logging. Works with any Claude client that supports `ANTHROPIC_BASE_URL` (CLI, VS Code extension, desktop app), complementing this package's CLI-only `NODE_OPTIONS` approach.
|
|
286
306
|
|
|
307
|
+
## Used in production
|
|
308
|
+
|
|
309
|
+
- **[Crunchloop DAP](https://dap.crunchloop.ai)** — Agent SDK / DAP development environment. First production team to merge the interceptor to trunk for team-wide deployment (2026-04-10). Identified two distinct cache regression patterns through real-world testing — tool ordering jitter and the fresh-session sort gap — and contributed debug traces that drove the v1.5.1 and v1.6.2 fixes.
|
|
310
|
+
|
|
287
311
|
## Contributors
|
|
288
312
|
|
|
289
313
|
- **[@VictorSun92](https://github.com/VictorSun92)** — Original monkey-patch fix for v2.1.88, identified partial scatter on v2.1.90, contributed forward-scan detection, correct block ordering, tighter block matchers, and the optional output-efficiency rewrite hook
|
|
314
|
+
- **[@bilby91](https://github.com/bilby91)** ([Crunchloop DAP](https://dap.crunchloop.ai)) — Agent SDK / DAP production environment validation, 1h cache TTL confirmation, tool ordering jitter discovery via debug trace (fixed in v1.5.1), fresh-session sort bug discovery via SKILLS SORT diagnostic (fixed in v1.6.2). First production team to roll the interceptor to trunk.
|
|
290
315
|
- **[@jmarianski](https://github.com/jmarianski)** — Root cause analysis via MITM proxy capture and Ghidra reverse engineering, multi-mode cache test script
|
|
291
316
|
- **[@cnighswonger](https://github.com/cnighswonger)** — Fingerprint stabilization, tool ordering fix, image stripping, monitoring features, overage TTL downgrade discovery, package maintainer
|
|
292
317
|
- **[@ArkNill](https://github.com/ArkNill)** — Microcompact mechanism analysis, GrowthBook flag documentation, false rate limiter identification
|
package/README.zh.md
CHANGED
|
@@ -277,9 +277,14 @@ CACHE_FIX_PREFIXDIFF=1 claude-fixed
|
|
|
277
277
|
- [#44045](https://github.com/anthropics/claude-code/issues/44045) — SDK 层面的复现与 token 测量
|
|
278
278
|
- [#32508](https://github.com/anthropics/claude-code/issues/32508) — 关于 `Output efficiency` 系统提示词变更及其可能影响模型行为的社区讨论
|
|
279
279
|
|
|
280
|
+
## 生产环境使用
|
|
281
|
+
|
|
282
|
+
- **[Crunchloop DAP](https://dap.crunchloop.ai)** — Agent SDK / DAP 开发环境。首个将本拦截器合入 trunk 并团队级部署的生产团队(2026-04-10)。通过真实环境测试发现两类不同的缓存回归问题——工具排序抖动与 fresh-session 排序漏洞,并贡献了驱动 v1.5.1 与 v1.6.2 修复的调试日志。
|
|
283
|
+
|
|
280
284
|
## 贡献者
|
|
281
285
|
|
|
282
286
|
- **[@VictorSun92](https://github.com/VictorSun92)** — 原始 v2.1.88 monkey-patch 修复作者,识别出 v2.1.90 中的部分块散布问题,并贡献了前向扫描检测、正确的块排序、更严格的块匹配器,以及可选的 output-efficiency 重写 hook
|
|
287
|
+
- **[@bilby91](https://github.com/bilby91)** ([Crunchloop DAP](https://dap.crunchloop.ai)) — Agent SDK / DAP 生产环境验证、1h 缓存 TTL 确认、通过调试日志发现工具排序抖动(v1.5.1 修复)、通过 SKILLS SORT 诊断发现 fresh-session 排序 bug(v1.6.2 修复)。首个将本拦截器合入 trunk 的生产团队。
|
|
283
288
|
- **[@jmarianski](https://github.com/jmarianski)** — 通过 MITM 代理抓包和 Ghidra 逆向分析定位根因,并提供多模式缓存测试脚本
|
|
284
289
|
- **[@cnighswonger](https://github.com/cnighswonger)** — 指纹稳定化、工具顺序修复、图片剥离、监控功能、超额 TTL 降级发现,本包维护者
|
|
285
290
|
- **[@ArkNill](https://github.com/ArkNill)** — 微压缩机制分析、GrowthBook 标志文档整理、虚假速率限制识别
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-cache-fix",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.4",
|
|
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",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"node": ">=18"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
|
-
"test": "node --test
|
|
16
|
+
"test": "node --test"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"claude-code",
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* quota-analysis — Test how Anthropic's 5-hour quota is actually computed
|
|
4
|
+
* by analyzing your own per-call telemetry.
|
|
5
|
+
*
|
|
6
|
+
* Reads usage.jsonl (the per-call log written by claude-code-cache-fix v1.6.1+)
|
|
7
|
+
* and answers three questions:
|
|
8
|
+
*
|
|
9
|
+
* 1. Does cache_read count toward your 5-hour quota?
|
|
10
|
+
* Tests three hypotheses (cache_read costs 0x / 0.1x / 1x of input rate)
|
|
11
|
+
* and reports which one best explains the q5h_pct trajectory across
|
|
12
|
+
* reset windows in your data.
|
|
13
|
+
*
|
|
14
|
+
* 2. Do peak hours (weekday 13:00–19:00 UTC) cost more quota per token?
|
|
15
|
+
* Splits windows into peak-dominant vs off-peak-dominant and compares
|
|
16
|
+
* the implied 100% quota under the best-fit counting model.
|
|
17
|
+
*
|
|
18
|
+
* 3. What is your account's effective 5-hour quota in token-equivalents?
|
|
19
|
+
* Reports a concrete number you can compare against your subscription
|
|
20
|
+
* tier or against what other users are seeing.
|
|
21
|
+
*
|
|
22
|
+
* Telemetry requirements:
|
|
23
|
+
* - usage.jsonl entries must include q5h_pct, q7d_pct, peak_hour fields
|
|
24
|
+
* - These were added in claude-code-cache-fix v1.6.1 (2026-04-09)
|
|
25
|
+
* - Older entries are silently filtered out
|
|
26
|
+
* - Need at least 2 q5h reset events in the data for meaningful analysis
|
|
27
|
+
* (typically 10+ hours of active use)
|
|
28
|
+
*
|
|
29
|
+
* Methodology and caveats:
|
|
30
|
+
* - q5h is a 5-hour SLIDING window. We approximate it as discrete reset
|
|
31
|
+
* boundaries by looking for drops in q5h_pct >= 5 percentage points.
|
|
32
|
+
* - Token-equivalent weights: uncached_input = 1.0, output = 5.0,
|
|
33
|
+
* cache_creation = 2.0 (treats all writes as 1h-tier; the 5m tier is
|
|
34
|
+
* 1.25 but most writes are 1h with the interceptor's TTL injection).
|
|
35
|
+
* - Coefficient of variation (CV) is used to compare hypotheses: lower
|
|
36
|
+
* CV across windows = better fit. CV < 50% suggests a clear winner;
|
|
37
|
+
* CV > 80% suggests the model is wrong or sample is too small.
|
|
38
|
+
* - Single-account analysis. Sample is yours. Findings should be
|
|
39
|
+
* compared across multiple accounts before generalizing.
|
|
40
|
+
*
|
|
41
|
+
* Part of claude-code-cache-fix. Works with the interceptor's usage log.
|
|
42
|
+
* https://github.com/cnighswonger/claude-code-cache-fix
|
|
43
|
+
*
|
|
44
|
+
* Reference: anthropics/claude-code#45756 (cache_read quota counting hypothesis)
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
48
|
+
import { homedir } from 'node:os';
|
|
49
|
+
import { join } from 'node:path';
|
|
50
|
+
|
|
51
|
+
const DEFAULT_USAGE_LOG = join(homedir(), '.claude', 'usage.jsonl');
|
|
52
|
+
|
|
53
|
+
// Token-equivalent weights for the H_zero counting model.
|
|
54
|
+
// (cache_read weight is the variable being tested.)
|
|
55
|
+
const W_UNCACHED_INPUT = 1.0;
|
|
56
|
+
const W_OUTPUT = 5.0;
|
|
57
|
+
const W_CACHE_CREATION = 2.0; // 1h tier conservative; 5m would be 1.25
|
|
58
|
+
|
|
59
|
+
// Q5h window boundary detection threshold (in percentage points)
|
|
60
|
+
const RESET_THRESHOLD = 5;
|
|
61
|
+
|
|
62
|
+
// Window classification thresholds
|
|
63
|
+
const PEAK_WINDOW_MIN_PCT = 80; // >= 80% peak calls = peak-dominant window
|
|
64
|
+
const OFFPEAK_WINDOW_MAX_PCT = 20; // <= 20% peak calls = offpeak-dominant window
|
|
65
|
+
|
|
66
|
+
// Minimum delta_q5h for a window to be useful for extrapolation
|
|
67
|
+
const MIN_DELTA_Q5H = 5;
|
|
68
|
+
|
|
69
|
+
// ─── CLI parsing ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
function parseArgs() {
|
|
72
|
+
const args = process.argv.slice(2);
|
|
73
|
+
const opts = { file: null, since: null, format: 'text', help: false };
|
|
74
|
+
for (let i = 0; i < args.length; i++) {
|
|
75
|
+
const a = args[i];
|
|
76
|
+
if (a === '--help' || a === '-h') opts.help = true;
|
|
77
|
+
else if (a === '--file' || a === '-f') opts.file = args[++i];
|
|
78
|
+
else if (a === '--since' || a === '-s') opts.since = args[++i];
|
|
79
|
+
else if (a === '--format') opts.format = args[++i];
|
|
80
|
+
else if (a === '--json') opts.format = 'json';
|
|
81
|
+
else { console.error(`Unknown argument: ${a}`); opts.help = true; }
|
|
82
|
+
}
|
|
83
|
+
return opts;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function printUsage() {
|
|
87
|
+
console.log(`quota-analysis — analyze 5-hour quota counting from usage telemetry
|
|
88
|
+
|
|
89
|
+
Usage:
|
|
90
|
+
quota-analysis [options]
|
|
91
|
+
|
|
92
|
+
Options:
|
|
93
|
+
-f, --file <path> JSONL file to read (default: ~/.claude/usage.jsonl)
|
|
94
|
+
-s, --since <duration> Filter to last N hours/days (e.g. 24h, 3d, 7d)
|
|
95
|
+
--format <fmt> Output format: text (default), json, markdown
|
|
96
|
+
--json Shorthand for --format json
|
|
97
|
+
-h, --help Show this help
|
|
98
|
+
|
|
99
|
+
Examples:
|
|
100
|
+
quota-analysis # Analyze your default log
|
|
101
|
+
quota-analysis --since 24h # Last 24 hours only
|
|
102
|
+
quota-analysis --file /tmp/team.jsonl # A different log file
|
|
103
|
+
quota-analysis --json > report.json # Machine-readable output
|
|
104
|
+
|
|
105
|
+
Methodology:
|
|
106
|
+
Tests three counting hypotheses for cache_read in the 5-hour quota:
|
|
107
|
+
H_zero = cache_read costs nothing for quota
|
|
108
|
+
H_billed = cache_read costs 0.1x of input rate (matches the billing rate)
|
|
109
|
+
H_full = cache_read costs 1.0x of input rate (the original concern)
|
|
110
|
+
The hypothesis with the lowest coefficient of variation across reset
|
|
111
|
+
windows is the best fit for your data.
|
|
112
|
+
|
|
113
|
+
Then splits windows into peak (weekday 13:00–19:00 UTC) and off-peak
|
|
114
|
+
groups and compares the effective quota multiplier between them.
|
|
115
|
+
|
|
116
|
+
Reference:
|
|
117
|
+
anthropics/claude-code#45756 — original "cache_read counts at full rate"
|
|
118
|
+
hypothesis from @molu0219.
|
|
119
|
+
`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Data loading ───────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function loadUsage(filePath) {
|
|
125
|
+
if (!existsSync(filePath)) {
|
|
126
|
+
console.error(`Error: usage file not found: ${filePath}`);
|
|
127
|
+
console.error(`Hint: claude-code-cache-fix writes its log to ${DEFAULT_USAGE_LOG} by default.`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
const text = readFileSync(filePath, 'utf8');
|
|
131
|
+
const rows = [];
|
|
132
|
+
for (const line of text.split('\n')) {
|
|
133
|
+
const t = line.trim();
|
|
134
|
+
if (!t) continue;
|
|
135
|
+
try { rows.push(JSON.parse(t)); }
|
|
136
|
+
catch { /* skip malformed */ }
|
|
137
|
+
}
|
|
138
|
+
return rows;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function filterSince(rows, since) {
|
|
142
|
+
if (!since) return rows;
|
|
143
|
+
const m = since.match(/^(\d+)([hd])$/);
|
|
144
|
+
if (!m) {
|
|
145
|
+
console.error(`Invalid --since format: ${since}. Expected like 24h, 3d.`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
const n = parseInt(m[1], 10);
|
|
149
|
+
const ms = m[2] === 'h' ? n * 3600 * 1000 : n * 86400 * 1000;
|
|
150
|
+
const cutoff = new Date(Date.now() - ms).toISOString();
|
|
151
|
+
return rows.filter(r => r.timestamp >= cutoff);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Window detection ───────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function findResetWindows(rows) {
|
|
157
|
+
// Sort by timestamp (defensive — should already be sorted)
|
|
158
|
+
rows = rows.slice().sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
159
|
+
|
|
160
|
+
// Find indices where q5h_pct drops by RESET_THRESHOLD or more
|
|
161
|
+
// (these are window boundaries)
|
|
162
|
+
const windowStarts = [0]; // first call is always a window start
|
|
163
|
+
for (let i = 1; i < rows.length; i++) {
|
|
164
|
+
const prev = rows[i - 1].q5h_pct;
|
|
165
|
+
const cur = rows[i].q5h_pct;
|
|
166
|
+
if (typeof prev === 'number' && typeof cur === 'number' && cur < prev - RESET_THRESHOLD) {
|
|
167
|
+
windowStarts.push(i);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
windowStarts.push(rows.length); // sentinel for last window
|
|
171
|
+
|
|
172
|
+
const windows = [];
|
|
173
|
+
for (let i = 0; i < windowStarts.length - 1; i++) {
|
|
174
|
+
const slice = rows.slice(windowStarts[i], windowStarts[i + 1]);
|
|
175
|
+
if (slice.length === 0) continue;
|
|
176
|
+
windows.push(slice);
|
|
177
|
+
}
|
|
178
|
+
return windows;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Token-equivalent calculation ───────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
function callEquivalent(r, cacheReadWeight) {
|
|
184
|
+
return (
|
|
185
|
+
(r.input_tokens || 0) * W_UNCACHED_INPUT
|
|
186
|
+
+ (r.output_tokens || 0) * W_OUTPUT
|
|
187
|
+
+ (r.cache_creation_input_tokens || 0) * W_CACHE_CREATION
|
|
188
|
+
+ (r.cache_read_input_tokens || 0) * cacheReadWeight
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function windowEquivalent(window, cacheReadWeight) {
|
|
193
|
+
let sum = 0;
|
|
194
|
+
for (const r of window) sum += callEquivalent(r, cacheReadWeight);
|
|
195
|
+
return sum;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function windowDeltaQ5h(window) {
|
|
199
|
+
const start = window[0].q5h_pct ?? 0;
|
|
200
|
+
let peak = start;
|
|
201
|
+
for (const r of window) {
|
|
202
|
+
if (typeof r.q5h_pct === 'number' && r.q5h_pct > peak) peak = r.q5h_pct;
|
|
203
|
+
}
|
|
204
|
+
return peak - start;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function windowPeakFraction(window) {
|
|
208
|
+
let peakCount = 0;
|
|
209
|
+
for (const r of window) if (r.peak_hour) peakCount++;
|
|
210
|
+
return peakCount / window.length;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Statistics helpers ─────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function mean(xs) {
|
|
216
|
+
if (xs.length === 0) return 0;
|
|
217
|
+
return xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stdev(xs) {
|
|
221
|
+
if (xs.length < 2) return 0;
|
|
222
|
+
const m = mean(xs);
|
|
223
|
+
const sq = xs.map(x => (x - m) ** 2);
|
|
224
|
+
return Math.sqrt(sq.reduce((a, b) => a + b, 0) / (xs.length - 1));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function cv(xs) {
|
|
228
|
+
const m = mean(xs);
|
|
229
|
+
if (m === 0) return Infinity;
|
|
230
|
+
return stdev(xs) / m;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Counting model fit ─────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
function fitCountingModels(windows) {
|
|
236
|
+
// For each window, compute equivalent tokens under each hypothesis,
|
|
237
|
+
// then extrapolate to 100% quota using the observed delta_q5h.
|
|
238
|
+
// The model whose extrapolations are most consistent (lowest CV) wins.
|
|
239
|
+
const models = {
|
|
240
|
+
zero: { weight: 0.0, label: 'H_zero (cache_read = 0.0x)', extrapolations: [] },
|
|
241
|
+
billed: { weight: 0.1, label: 'H_billed (cache_read = 0.1x)', extrapolations: [] },
|
|
242
|
+
full: { weight: 1.0, label: 'H_full (cache_read = 1.0x)', extrapolations: [] },
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
for (const w of windows) {
|
|
246
|
+
const delta = windowDeltaQ5h(w);
|
|
247
|
+
if (delta < MIN_DELTA_Q5H) continue;
|
|
248
|
+
|
|
249
|
+
for (const key of Object.keys(models)) {
|
|
250
|
+
const eq = windowEquivalent(w, models[key].weight);
|
|
251
|
+
const implied100 = eq / (delta / 100);
|
|
252
|
+
models[key].extrapolations.push(implied100);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Compute CV for each model
|
|
257
|
+
const usableWindows = models.zero.extrapolations.length;
|
|
258
|
+
const fits = {};
|
|
259
|
+
for (const key of Object.keys(models)) {
|
|
260
|
+
const xs = models[key].extrapolations;
|
|
261
|
+
fits[key] = {
|
|
262
|
+
label: models[key].label,
|
|
263
|
+
weight: models[key].weight,
|
|
264
|
+
mean: mean(xs),
|
|
265
|
+
stdev: stdev(xs),
|
|
266
|
+
cv: cv(xs),
|
|
267
|
+
values: xs,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Determine the best fit
|
|
272
|
+
let bestKey = null;
|
|
273
|
+
let bestCv = Infinity;
|
|
274
|
+
for (const key of Object.keys(fits)) {
|
|
275
|
+
if (fits[key].cv < bestCv) {
|
|
276
|
+
bestCv = fits[key].cv;
|
|
277
|
+
bestKey = key;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { fits, bestKey, usableWindows };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── Peak vs off-peak analysis ─────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
function peakSplit(windows, weight) {
|
|
287
|
+
// Returns { peakWindows: [...], offPeakWindows: [...], skipped: [...] }
|
|
288
|
+
// and computes mean implied 100% quota for each group under the given
|
|
289
|
+
// cache_read weight.
|
|
290
|
+
const peakDom = [];
|
|
291
|
+
const offDom = [];
|
|
292
|
+
const skipped = [];
|
|
293
|
+
|
|
294
|
+
for (const w of windows) {
|
|
295
|
+
const delta = windowDeltaQ5h(w);
|
|
296
|
+
if (delta < MIN_DELTA_Q5H) {
|
|
297
|
+
skipped.push({ reason: 'delta_q5h too small', window: w });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const eq = windowEquivalent(w, weight);
|
|
301
|
+
const implied100 = eq / (delta / 100);
|
|
302
|
+
const pf = windowPeakFraction(w) * 100;
|
|
303
|
+
|
|
304
|
+
const entry = {
|
|
305
|
+
start: w[0].timestamp,
|
|
306
|
+
end: w[w.length - 1].timestamp,
|
|
307
|
+
calls: w.length,
|
|
308
|
+
delta,
|
|
309
|
+
peakFraction: pf,
|
|
310
|
+
eq,
|
|
311
|
+
implied100,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
if (pf >= PEAK_WINDOW_MIN_PCT) peakDom.push(entry);
|
|
315
|
+
else if (pf <= OFFPEAK_WINDOW_MAX_PCT) offDom.push(entry);
|
|
316
|
+
else skipped.push({ reason: 'mixed peak/off-peak', ...entry });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { peakDom, offDom, skipped };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── Output rendering ───────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
function fmt(n, decimals = 2) {
|
|
325
|
+
if (n === null || n === undefined || !isFinite(n)) return 'n/a';
|
|
326
|
+
if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(decimals) + 'M';
|
|
327
|
+
if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(decimals) + 'K';
|
|
328
|
+
return n.toFixed(decimals);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function pct(n) { return (n * 100).toFixed(1) + '%'; }
|
|
332
|
+
|
|
333
|
+
function printText(report) {
|
|
334
|
+
const { meta, windows, fit, peak } = report;
|
|
335
|
+
|
|
336
|
+
console.log('═══════════════════════════════════════════════════════════════════════');
|
|
337
|
+
console.log(' CLAUDE 5-HOUR QUOTA ANALYSIS');
|
|
338
|
+
console.log('═══════════════════════════════════════════════════════════════════════');
|
|
339
|
+
console.log();
|
|
340
|
+
console.log(`Data source: ${meta.file}`);
|
|
341
|
+
console.log(`Total entries: ${meta.totalRows}`);
|
|
342
|
+
console.log(`With q5h_pct: ${meta.withQuota} (${pct(meta.withQuota / meta.totalRows)})`);
|
|
343
|
+
console.log(`Time range: ${meta.timeStart}`);
|
|
344
|
+
console.log(` → ${meta.timeEnd}`);
|
|
345
|
+
console.log(`Reset windows: ${windows.total} detected, ${windows.usable} usable for fit`);
|
|
346
|
+
console.log();
|
|
347
|
+
|
|
348
|
+
if (windows.usable < 2) {
|
|
349
|
+
console.log('⚠ Not enough usable reset windows to fit counting models.');
|
|
350
|
+
console.log(' Need at least 2 windows with q5h_pct increase ≥ 5%.');
|
|
351
|
+
console.log(' Run the interceptor through more activity and try again.');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
console.log('───────────────────────────────────────────────────────────────────────');
|
|
356
|
+
console.log(' Per-window breakdown');
|
|
357
|
+
console.log('───────────────────────────────────────────────────────────────────────');
|
|
358
|
+
console.log();
|
|
359
|
+
console.log(' ' + 'Window'.padEnd(34) + 'Calls'.padStart(6) + 'Δq5h'.padStart(6) + 'Peak%'.padStart(7) + 'EqToks'.padStart(10) + '100%impl'.padStart(11));
|
|
360
|
+
for (const wr of report.windowRows) {
|
|
361
|
+
console.log(' ' + wr.label.padEnd(34) + String(wr.calls).padStart(6) + (wr.delta + '%').padStart(6) + (wr.peakFraction.toFixed(0) + '%').padStart(7) + fmt(wr.eq).padStart(10) + fmt(wr.implied100).padStart(11));
|
|
362
|
+
}
|
|
363
|
+
console.log();
|
|
364
|
+
|
|
365
|
+
console.log('───────────────────────────────────────────────────────────────────────');
|
|
366
|
+
console.log(' Q1: Does cache_read count toward 5h quota?');
|
|
367
|
+
console.log('───────────────────────────────────────────────────────────────────────');
|
|
368
|
+
console.log();
|
|
369
|
+
console.log(' Tests three hypotheses against your data. Lower CV = better fit.');
|
|
370
|
+
console.log();
|
|
371
|
+
console.log(' ' + 'Hypothesis'.padEnd(34) + 'Mean impl 100%'.padStart(18) + 'CV'.padStart(10));
|
|
372
|
+
for (const key of ['zero', 'billed', 'full']) {
|
|
373
|
+
const f = fit.fits[key];
|
|
374
|
+
const marker = key === fit.bestKey ? ' ★' : '';
|
|
375
|
+
console.log(' ' + f.label.padEnd(34) + (fmt(f.mean) + ' tok').padStart(18) + (f.cv === Infinity ? 'inf' : (f.cv * 100).toFixed(1) + '%').padStart(10) + marker);
|
|
376
|
+
}
|
|
377
|
+
console.log();
|
|
378
|
+
console.log(' ★ = best fit (lowest coefficient of variation)');
|
|
379
|
+
console.log();
|
|
380
|
+
const bestFit = fit.fits[fit.bestKey];
|
|
381
|
+
if (bestFit.cv < 0.5) {
|
|
382
|
+
console.log(` Verdict: ${bestFit.label} is the best fit (CV ${(bestFit.cv * 100).toFixed(1)}%).`);
|
|
383
|
+
if (fit.bestKey === 'zero') {
|
|
384
|
+
console.log(' Interpretation: cache_read does NOT meaningfully count toward your 5h quota.');
|
|
385
|
+
console.log(' The cache really is saving you quota, not just billing.');
|
|
386
|
+
} else if (fit.bestKey === 'billed') {
|
|
387
|
+
console.log(' Interpretation: cache_read counts at the BILLING rate (0.1x of input).');
|
|
388
|
+
console.log(' Quota and billing are aligned for cache reads.');
|
|
389
|
+
} else {
|
|
390
|
+
console.log(' Interpretation: cache_read counts at the FULL input rate for quota purposes.');
|
|
391
|
+
console.log(' This means cache hits save you billing but NOT quota — a stealth multiplier.');
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
console.log(` Verdict: No clear winner. Best fit (${fit.fits[fit.bestKey].label}) has CV ${(fit.fits[fit.bestKey].cv * 100).toFixed(1)}%.`);
|
|
395
|
+
console.log(' Likely cause: small sample, mixed-model traffic, or sliding-window noise.');
|
|
396
|
+
console.log(' Run for longer and try again.');
|
|
397
|
+
}
|
|
398
|
+
console.log();
|
|
399
|
+
|
|
400
|
+
console.log('───────────────────────────────────────────────────────────────────────');
|
|
401
|
+
console.log(' Q2: Do peak hours cost more quota per token?');
|
|
402
|
+
console.log('───────────────────────────────────────────────────────────────────────');
|
|
403
|
+
console.log();
|
|
404
|
+
console.log(` Peak hours: weekday 13:00–19:00 UTC (interceptor default)`);
|
|
405
|
+
console.log();
|
|
406
|
+
if (peak.peakDom.length === 0 && peak.offDom.length === 0) {
|
|
407
|
+
console.log(' Not enough peak-dominant or off-peak-dominant windows to compare.');
|
|
408
|
+
console.log(' Need at least 1 of each (≥80% same-bucket calls per window).');
|
|
409
|
+
} else {
|
|
410
|
+
console.log(' ' + 'Group'.padEnd(20) + 'Windows'.padStart(10) + 'Mean impl 100%'.padStart(20));
|
|
411
|
+
if (peak.peakDom.length > 0) {
|
|
412
|
+
const m = mean(peak.peakDom.map(p => p.implied100));
|
|
413
|
+
console.log(' ' + 'Peak-dominant'.padEnd(20) + String(peak.peakDom.length).padStart(10) + (fmt(m) + ' tok').padStart(20));
|
|
414
|
+
}
|
|
415
|
+
if (peak.offDom.length > 0) {
|
|
416
|
+
const m = mean(peak.offDom.map(p => p.implied100));
|
|
417
|
+
console.log(' ' + 'Off-peak'.padEnd(20) + String(peak.offDom.length).padStart(10) + (fmt(m) + ' tok').padStart(20));
|
|
418
|
+
}
|
|
419
|
+
if (peak.peakDom.length > 0 && peak.offDom.length > 0) {
|
|
420
|
+
const peakMean = mean(peak.peakDom.map(p => p.implied100));
|
|
421
|
+
const offMean = mean(peak.offDom.map(p => p.implied100));
|
|
422
|
+
const ratio = peakMean / offMean;
|
|
423
|
+
console.log();
|
|
424
|
+
if (ratio < 0.85) {
|
|
425
|
+
console.log(` ⚠ Peak windows imply ${pct(ratio)} of off-peak quota.`);
|
|
426
|
+
console.log(` That's a ${pct(1 - ratio)} effective quota REDUCTION during peak hours.`);
|
|
427
|
+
console.log(' Same usage pattern, fewer tokens until you hit 100%.');
|
|
428
|
+
} else if (ratio > 1.15) {
|
|
429
|
+
console.log(` Peak windows imply ${pct(ratio)} of off-peak quota — peak is MORE generous?`);
|
|
430
|
+
console.log(' Unusual. Check your sample size and time range.');
|
|
431
|
+
} else {
|
|
432
|
+
console.log(` Peak / off-peak ratio is ${pct(ratio)} — no significant peak penalty detected.`);
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
console.log();
|
|
436
|
+
console.log(' Need both peak-dominant AND off-peak-dominant windows for the comparison.');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
console.log();
|
|
440
|
+
|
|
441
|
+
console.log('───────────────────────────────────────────────────────────────────────');
|
|
442
|
+
console.log(' Q3: Implied 5h quota for your account');
|
|
443
|
+
console.log('───────────────────────────────────────────────────────────────────────');
|
|
444
|
+
console.log();
|
|
445
|
+
console.log(` Under best-fit model (${fit.fits[fit.bestKey].label}):`);
|
|
446
|
+
console.log(` Mean implied 100% quota: ${fmt(fit.fits[fit.bestKey].mean)} token-equivalents`);
|
|
447
|
+
console.log();
|
|
448
|
+
console.log(' Token-equivalent weights used:');
|
|
449
|
+
console.log(` uncached input × ${W_UNCACHED_INPUT}`);
|
|
450
|
+
console.log(` output × ${W_OUTPUT} (Opus output is 5x input rate)`);
|
|
451
|
+
console.log(` cache_creation × ${W_CACHE_CREATION} (1h tier; 5m tier would be 1.25)`);
|
|
452
|
+
console.log(` cache_read × ${fit.fits[fit.bestKey].weight} (this hypothesis)`);
|
|
453
|
+
console.log();
|
|
454
|
+
console.log(' Compare against your subscription tier and plan estimate. If your');
|
|
455
|
+
console.log(' number is wildly different from other reports, your sample may be');
|
|
456
|
+
console.log(' too small or your model mix may differ significantly.');
|
|
457
|
+
console.log();
|
|
458
|
+
|
|
459
|
+
console.log('═══════════════════════════════════════════════════════════════════════');
|
|
460
|
+
console.log();
|
|
461
|
+
console.log('Caveats:');
|
|
462
|
+
console.log(' • q5h is a 5-hour SLIDING window; we approximate as discrete resets');
|
|
463
|
+
console.log(' • Single account; aggregate findings need cross-validation');
|
|
464
|
+
console.log(' • cache_creation TTL weight averaged at 2.0; mixed 5m/1h would lower it');
|
|
465
|
+
console.log(' • Only Anthropic knows the exact quota formula');
|
|
466
|
+
console.log();
|
|
467
|
+
console.log('Reference: anthropics/claude-code#45756');
|
|
468
|
+
console.log('Report your findings: open an issue or PR on cnighswonger/claude-code-cache-fix');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function printJson(report) {
|
|
472
|
+
console.log(JSON.stringify(report, null, 2));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
function main() {
|
|
478
|
+
const opts = parseArgs();
|
|
479
|
+
if (opts.help) { printUsage(); return; }
|
|
480
|
+
|
|
481
|
+
const filePath = opts.file || DEFAULT_USAGE_LOG;
|
|
482
|
+
const rawRows = loadUsage(filePath);
|
|
483
|
+
const filtered = filterSince(rawRows, opts.since);
|
|
484
|
+
const withQuota = filtered.filter(r => typeof r.q5h_pct === 'number');
|
|
485
|
+
|
|
486
|
+
if (withQuota.length === 0) {
|
|
487
|
+
console.error('No entries with q5h_pct field found.');
|
|
488
|
+
console.error('This field was added in claude-code-cache-fix v1.6.1 (2026-04-09).');
|
|
489
|
+
console.error('Older log entries are silently filtered out.');
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
withQuota.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
494
|
+
const allWindows = findResetWindows(withQuota);
|
|
495
|
+
const fit = fitCountingModels(allWindows);
|
|
496
|
+
|
|
497
|
+
// Use the best-fit weight for the peak/off-peak analysis
|
|
498
|
+
const bestWeight = fit.fits[fit.bestKey].weight;
|
|
499
|
+
const peak = peakSplit(allWindows, bestWeight);
|
|
500
|
+
|
|
501
|
+
// Build per-window rows for the breakdown table
|
|
502
|
+
const windowRows = [];
|
|
503
|
+
for (const w of allWindows) {
|
|
504
|
+
const delta = windowDeltaQ5h(w);
|
|
505
|
+
if (delta < MIN_DELTA_Q5H) continue;
|
|
506
|
+
const eq = windowEquivalent(w, bestWeight);
|
|
507
|
+
const implied100 = eq / (delta / 100);
|
|
508
|
+
const pf = windowPeakFraction(w) * 100;
|
|
509
|
+
windowRows.push({
|
|
510
|
+
label: `${w[0].timestamp.slice(5, 16)} → ${w[w.length - 1].timestamp.slice(5, 16)}`,
|
|
511
|
+
calls: w.length,
|
|
512
|
+
delta,
|
|
513
|
+
peakFraction: pf,
|
|
514
|
+
eq,
|
|
515
|
+
implied100,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const report = {
|
|
520
|
+
meta: {
|
|
521
|
+
file: filePath,
|
|
522
|
+
totalRows: rawRows.length,
|
|
523
|
+
filteredRows: filtered.length,
|
|
524
|
+
withQuota: withQuota.length,
|
|
525
|
+
timeStart: withQuota[0].timestamp,
|
|
526
|
+
timeEnd: withQuota[withQuota.length - 1].timestamp,
|
|
527
|
+
since: opts.since,
|
|
528
|
+
},
|
|
529
|
+
windows: { total: allWindows.length, usable: fit.usableWindows },
|
|
530
|
+
windowRows,
|
|
531
|
+
fit,
|
|
532
|
+
peak,
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
if (opts.format === 'json') printJson(report);
|
|
536
|
+
else printText(report);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
main();
|