claude-code-cache-fix 1.6.4 → 1.7.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 +72 -0
- package/claude-fixed.bat +22 -0
- package/package.json +3 -2
- package/preload.mjs +44 -0
- package/tools/cost-report.mjs +31 -0
- package/tools/cross-version-cache-test.sh +304 -0
- package/tools/sim-cost-reconcile.sh +60 -0
- package/tools/usage-to-dashboard-ndjson.mjs +352 -0
package/README.md
CHANGED
|
@@ -70,6 +70,31 @@ NODE_OPTIONS="--import claude-code-cache-fix" claude
|
|
|
70
70
|
|
|
71
71
|
> **Note**: This only works if `claude` points to the npm/Node installation. The standalone binary uses a different execution path that bypasses Node.js preloads.
|
|
72
72
|
|
|
73
|
+
### Windows users
|
|
74
|
+
|
|
75
|
+
On Windows, `NODE_OPTIONS="--import ..."` doesn't work the same way as on Linux/macOS. Use the included `claude-fixed.bat` wrapper instead:
|
|
76
|
+
|
|
77
|
+
1. After installing both packages globally:
|
|
78
|
+
```bat
|
|
79
|
+
npm install -g claude-code-cache-fix
|
|
80
|
+
npm install -g @anthropic-ai/claude-code
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
2. Copy `claude-fixed.bat` from this package to a directory in your PATH (e.g., `C:\Users\<you>\bin\`):
|
|
84
|
+
```bat
|
|
85
|
+
copy "%NPM_ROOT%\claude-code-cache-fix\claude-fixed.bat" C:\Users\%USERNAME%\bin\
|
|
86
|
+
```
|
|
87
|
+
Or find the file manually at your npm global root (run `npm root -g` to locate it).
|
|
88
|
+
|
|
89
|
+
3. Run Claude Code with the interceptor active:
|
|
90
|
+
```bat
|
|
91
|
+
claude-fixed [any claude args...]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The wrapper dynamically resolves your npm global root, constructs a `file:///` URL for the preload module (converting backslashes to forward slashes for Node.js), and launches Claude Code with the interceptor loaded. All environment variables (`CACHE_FIX_DEBUG`, `CACHE_FIX_IMAGE_KEEP_LAST`, etc.) work the same as on Linux/macOS.
|
|
95
|
+
|
|
96
|
+
Credit: [@TomTheMenace](https://github.com/anthropics/claude-code/issues/38335) contributed the Windows wrapper and validated the interceptor across a 7.5-hour, 536-call Opus 4.6 session on Windows — 98.4% cache hit rate, 81% of calls had fingerprint instability that the interceptor corrected.
|
|
97
|
+
|
|
73
98
|
## How it works
|
|
74
99
|
|
|
75
100
|
The module intercepts `globalThis.fetch` before Claude Code makes API calls to `/v1/messages`. On each call it:
|
|
@@ -303,6 +328,51 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
|
|
|
303
328
|
|
|
304
329
|
- **[@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.
|
|
305
330
|
- **[@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.
|
|
331
|
+
- **[@fgrosswig/claude-usage-dashboard](https://github.com/fgrosswig/claude-usage-dashboard)** — Self-hosted forensic dashboard with SSE live monitoring, multi-host aggregation, cache-health scoring, and forced-restart/compaction detection. Reads from Claude Code's native session JSONL files and optionally from an HTTP proxy NDJSON stream. v1.4.0 documented the forced-session-restart mechanism at quota-cap boundaries (~490K tokens per event) and the 78–91% cache-wipe pattern at compaction events. Complementary to our interceptor's in-process vantage point. See [Works with @fgrosswig's dashboard](#works-with-fgrosswigs-dashboard) below for the interop pattern.
|
|
332
|
+
|
|
333
|
+
## Works with @fgrosswig's dashboard
|
|
334
|
+
|
|
335
|
+
This interceptor and [@fgrosswig](https://github.com/fgrosswig)'s
|
|
336
|
+
[claude-usage-dashboard](https://github.com/fgrosswig/claude-usage-dashboard)
|
|
337
|
+
solve strongly complementary problems. The interceptor captures per-call API
|
|
338
|
+
data from inside the Node.js process — cache metrics, quota state, TTL tier,
|
|
339
|
+
rewrites applied. The dashboard provides the visualization layer — historical
|
|
340
|
+
trending, per-day charts, multi-host aggregation, cache-health scoring.
|
|
341
|
+
|
|
342
|
+
Running both gives you the best of both tools, and the integration is a
|
|
343
|
+
one-liner thanks to the dashboard's tolerant NDJSON ingest and our new
|
|
344
|
+
`usage-to-dashboard-ndjson` translator.
|
|
345
|
+
|
|
346
|
+
### Quick setup
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
# Install both tools
|
|
350
|
+
npm install -g claude-code-cache-fix
|
|
351
|
+
# (follow fgrosswig's dashboard install: https://github.com/fgrosswig/claude-usage-dashboard)
|
|
352
|
+
|
|
353
|
+
# One-shot translation (reads ~/.claude/usage.jsonl, writes to
|
|
354
|
+
# ~/.claude/anthropic-proxy-logs/proxy-YYYY-MM-DD.ndjson, which his
|
|
355
|
+
# dashboard already watches)
|
|
356
|
+
node $(npm root -g)/claude-code-cache-fix/tools/usage-to-dashboard-ndjson.mjs
|
|
357
|
+
|
|
358
|
+
# Or keep it live-updating as the interceptor logs new calls
|
|
359
|
+
node $(npm root -g)/claude-code-cache-fix/tools/usage-to-dashboard-ndjson.mjs --follow &
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
No configuration required on the dashboard side — fgrosswig's
|
|
363
|
+
`collectProxyNdjsonFiles()` auto-discovers files in
|
|
364
|
+
`~/.claude/anthropic-proxy-logs/` (or `$ANTHROPIC_PROXY_LOG_DIR`), and our
|
|
365
|
+
translator writes to exactly that path with the expected `proxy-YYYY-MM-DD.ndjson`
|
|
366
|
+
filename convention. The dashboard's tolerant ingestion layer ignores unknown
|
|
367
|
+
fields, so interceptor-specific extras (`ttl_tier`, `ephemeral_1h_input_tokens`,
|
|
368
|
+
`ephemeral_5m_input_tokens`, `peak_hour`, quota state) pass through cleanly
|
|
369
|
+
and remain available to downstream consumers that know to read them.
|
|
370
|
+
|
|
371
|
+
The `cost_factor` metric in `tools/cost-report.mjs` also comes from
|
|
372
|
+
fgrosswig's methodology — the `(input + output + cache_read + cache_creation) / output`
|
|
373
|
+
ratio that gives a single-number measure of how much context is being paid
|
|
374
|
+
per useful output token. A rising cost factor across a long session is the
|
|
375
|
+
measurable signature of cache-efficiency degradation.
|
|
306
376
|
|
|
307
377
|
## Used in production
|
|
308
378
|
|
|
@@ -316,6 +386,8 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
|
|
|
316
386
|
- **[@cnighswonger](https://github.com/cnighswonger)** — Fingerprint stabilization, tool ordering fix, image stripping, monitoring features, overage TTL downgrade discovery, package maintainer
|
|
317
387
|
- **[@ArkNill](https://github.com/ArkNill)** — Microcompact mechanism analysis, GrowthBook flag documentation, false rate limiter identification
|
|
318
388
|
- **[@Renvect](https://github.com/Renvect)** — Image duplication discovery, cross-project directory contamination analysis
|
|
389
|
+
- **[@fgrosswig](https://github.com/fgrosswig)** — [claude-usage-dashboard](https://github.com/fgrosswig/claude-usage-dashboard) forensic methodology: cost-factor overhead ratio metric, `anthropic-*` header capture pattern, proxy NDJSON schema that informed our dashboard interop layer
|
|
390
|
+
- **[@TomTheMenace](https://github.com/TomTheMenace)** — Windows `.bat` wrapper for the interceptor, first Windows platform validation (7.5h/536-call Opus 4.6 session, 98.4% cache hit rate, 81% fingerprint instability corrected)
|
|
319
391
|
|
|
320
392
|
If you contributed to the community effort on these issues and aren't listed here, please open an issue or PR — we want to credit everyone properly.
|
|
321
393
|
|
package/claude-fixed.bat
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM claude-fixed.bat — Windows wrapper for Claude Code with cache-fix interceptor.
|
|
3
|
+
REM
|
|
4
|
+
REM Resolves the npm global root dynamically, constructs a file:/// URL for the
|
|
5
|
+
REM preload module (converting backslashes to forward slashes for Node.js), and
|
|
6
|
+
REM launches Claude Code with the interceptor active.
|
|
7
|
+
REM
|
|
8
|
+
REM Usage:
|
|
9
|
+
REM claude-fixed [any claude args...]
|
|
10
|
+
REM
|
|
11
|
+
REM Prerequisites:
|
|
12
|
+
REM npm install -g claude-code-cache-fix
|
|
13
|
+
REM npm install -g @anthropic-ai/claude-code
|
|
14
|
+
REM
|
|
15
|
+
REM Save this file somewhere in your PATH (e.g. C:\Users\<you>\bin\claude-fixed.bat).
|
|
16
|
+
REM
|
|
17
|
+
REM Credit: @TomTheMenace (https://github.com/anthropics/claude-code/issues/38335)
|
|
18
|
+
REM Part of claude-code-cache-fix: https://github.com/cnighswonger/claude-code-cache-fix
|
|
19
|
+
|
|
20
|
+
for /f "delims=" %%G in ('npm root -g') do set "NPM_GLOBAL=%%G"
|
|
21
|
+
set NODE_OPTIONS=--import file:///%NPM_GLOBAL:\=/%/claude-code-cache-fix/preload.mjs
|
|
22
|
+
node "%NPM_GLOBAL%\@anthropic-ai\claude-code\cli.js" %*
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-cache-fix",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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
9
|
"preload.mjs",
|
|
10
|
-
"tools/"
|
|
10
|
+
"tools/",
|
|
11
|
+
"claude-fixed.bat"
|
|
11
12
|
],
|
|
12
13
|
"engines": {
|
|
13
14
|
"node": ">=18"
|
package/preload.mjs
CHANGED
|
@@ -1009,6 +1009,30 @@ globalThis.fetch = async function (url, options) {
|
|
|
1009
1009
|
monitorContextDegradation(payload.messages);
|
|
1010
1010
|
}
|
|
1011
1011
|
|
|
1012
|
+
// Diagnostic: dump full tools array (names, descriptions, schemas, sizes) to a file
|
|
1013
|
+
// when CACHE_FIX_DUMP_TOOLS=<path> is set. Useful for per-version tool-schema drift
|
|
1014
|
+
// analysis and for understanding which tools contribute prefix bloat. First used
|
|
1015
|
+
// during the 2026-04-11 cross-version regression investigation.
|
|
1016
|
+
if (process.env.CACHE_FIX_DUMP_TOOLS && payload.tools) {
|
|
1017
|
+
try {
|
|
1018
|
+
const dumpPath = process.env.CACHE_FIX_DUMP_TOOLS;
|
|
1019
|
+
const dump = {
|
|
1020
|
+
timestamp: new Date().toISOString(),
|
|
1021
|
+
tool_count: payload.tools.length,
|
|
1022
|
+
tools: payload.tools.map(t => ({
|
|
1023
|
+
name: t.name,
|
|
1024
|
+
description: t.description || "",
|
|
1025
|
+
desc_chars: (t.description || "").length,
|
|
1026
|
+
schema_chars: JSON.stringify(t.input_schema || {}).length,
|
|
1027
|
+
total_chars: JSON.stringify(t).length,
|
|
1028
|
+
})),
|
|
1029
|
+
system_chars: JSON.stringify(payload.system || "").length,
|
|
1030
|
+
total_tools_chars: JSON.stringify(payload.tools).length,
|
|
1031
|
+
};
|
|
1032
|
+
writeFileSync(dumpPath, JSON.stringify(dump, null, 2));
|
|
1033
|
+
} catch (e) { debugLog("DUMP ERROR:", e?.message); }
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1012
1036
|
// Prompt size measurement — log system prompt, tools, and injected block sizes
|
|
1013
1037
|
if (DEBUG && payload.system && payload.tools && payload.messages) {
|
|
1014
1038
|
const sysChars = JSON.stringify(payload.system).length;
|
|
@@ -1061,6 +1085,25 @@ globalThis.fetch = async function (url, options) {
|
|
|
1061
1085
|
const status = response.headers.get("anthropic-ratelimit-unified-status");
|
|
1062
1086
|
const overage = response.headers.get("anthropic-ratelimit-unified-overage-status");
|
|
1063
1087
|
|
|
1088
|
+
// Capture ALL anthropic-* and request-id/cf-ray response headers.
|
|
1089
|
+
// Pattern borrowed from @fgrosswig's claude-usage-dashboard proxy:
|
|
1090
|
+
// https://github.com/fgrosswig/claude-usage-dashboard
|
|
1091
|
+
// Widening beyond the specific unified-ratelimit headers above future-proofs
|
|
1092
|
+
// us against Anthropic adding new headers (e.g. experimental rollout flags,
|
|
1093
|
+
// region hints, new quota dimensions) without needing code changes.
|
|
1094
|
+
const allAnthropicHeaders = {};
|
|
1095
|
+
for (const [name, value] of response.headers.entries()) {
|
|
1096
|
+
const lower = name.toLowerCase();
|
|
1097
|
+
if (
|
|
1098
|
+
lower.startsWith("anthropic-") ||
|
|
1099
|
+
lower === "request-id" ||
|
|
1100
|
+
lower === "x-request-id" ||
|
|
1101
|
+
lower === "cf-ray"
|
|
1102
|
+
) {
|
|
1103
|
+
allAnthropicHeaders[lower] = value;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1064
1107
|
if (h5 || h7d) {
|
|
1065
1108
|
const quotaFile = join(homedir(), ".claude", "quota-status.json");
|
|
1066
1109
|
let quota = {};
|
|
@@ -1070,6 +1113,7 @@ globalThis.fetch = async function (url, options) {
|
|
|
1070
1113
|
quota.seven_day = h7d ? { utilization: parseFloat(h7d), pct: Math.round(parseFloat(h7d) * 100), resets_at: reset7d ? parseInt(reset7d) : null } : quota.seven_day;
|
|
1071
1114
|
quota.status = status || null;
|
|
1072
1115
|
quota.overage_status = overage || null;
|
|
1116
|
+
quota.all_headers = allAnthropicHeaders;
|
|
1073
1117
|
|
|
1074
1118
|
// Peak hour detection — Anthropic applies higher quota drain rate during
|
|
1075
1119
|
// weekday peak hours: 13:00–19:00 UTC (Mon–Fri).
|
package/tools/cost-report.mjs
CHANGED
|
@@ -484,6 +484,12 @@ function printJsonReport(results, summary, ratesData, adminSummary) {
|
|
|
484
484
|
total_cost: summary.totalCost,
|
|
485
485
|
avg_cost_per_call: summary.totalCost / summary.calls,
|
|
486
486
|
tokens: summary.totals,
|
|
487
|
+
cost_factor: (function () {
|
|
488
|
+
// fgrosswig-style overhead ratio: gross tokens / output tokens
|
|
489
|
+
const gross = summary.totals.input + summary.totals.output +
|
|
490
|
+
summary.totals.cache_read + summary.totals.cache_1h + summary.totals.cache_5m;
|
|
491
|
+
return summary.totals.output > 0 ? gross / summary.totals.output : null;
|
|
492
|
+
})(),
|
|
487
493
|
by_model: summary.byModel,
|
|
488
494
|
degradation: summary.degradedCalls > 0 ? {
|
|
489
495
|
degraded_calls: summary.degradedCalls,
|
|
@@ -544,6 +550,15 @@ function printMarkdownReport(results, summary, ratesData, adminSummary) {
|
|
|
544
550
|
lines.push(`| Total cache write 5m | ${fmt(summary.totals.cache_5m)} |`);
|
|
545
551
|
lines.push(`| **Total cost** | **${fmtCost(summary.totalCost)}** |`);
|
|
546
552
|
lines.push(`| Avg cost per call | ${fmtCost(summary.totalCost / summary.calls)} |`);
|
|
553
|
+
{
|
|
554
|
+
// Cost factor: popularized by @fgrosswig's claude-usage-dashboard
|
|
555
|
+
// (https://github.com/fgrosswig/claude-usage-dashboard)
|
|
556
|
+
const grossTokens = summary.totals.input + summary.totals.output +
|
|
557
|
+
summary.totals.cache_read + summary.totals.cache_1h + summary.totals.cache_5m;
|
|
558
|
+
if (summary.totals.output > 0) {
|
|
559
|
+
lines.push(`| Cost factor (tokens/output) | ${(grossTokens / summary.totals.output).toFixed(1)}× |`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
547
562
|
lines.push('');
|
|
548
563
|
|
|
549
564
|
// By model
|
|
@@ -680,6 +695,22 @@ function printTextReport(results, summary, ratesData, adminSummary) {
|
|
|
680
695
|
}
|
|
681
696
|
}
|
|
682
697
|
}
|
|
698
|
+
|
|
699
|
+
// ── Cost factor (overhead ratio) ──
|
|
700
|
+
// Credit: this metric was popularized by @fgrosswig's claude-usage-dashboard
|
|
701
|
+
// (https://github.com/fgrosswig/claude-usage-dashboard). It divides total
|
|
702
|
+
// tokens processed (input + output + cache_read + cache_creation) by useful
|
|
703
|
+
// output tokens, giving a single-number "how much context am I carrying
|
|
704
|
+
// per useful word of output" multiplier. Values climb over long sessions
|
|
705
|
+
// due to resume/compaction cycles; a rising curve is a signal that cache
|
|
706
|
+
// efficiency is degrading.
|
|
707
|
+
const totalCacheCreate = summary.totals.cache_1h + summary.totals.cache_5m;
|
|
708
|
+
const grossTokens = summary.totals.input + summary.totals.output +
|
|
709
|
+
summary.totals.cache_read + totalCacheCreate;
|
|
710
|
+
if (summary.totals.output > 0) {
|
|
711
|
+
const costFactor = grossTokens / summary.totals.output;
|
|
712
|
+
console.log(` Cost factor: ${costFactor.toFixed(1)}× (tokens/output)`);
|
|
713
|
+
}
|
|
683
714
|
console.log('');
|
|
684
715
|
|
|
685
716
|
// ── Degradation ──
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cross-version-cache-test — replicable cache-behavior test across installed Claude Code versions.
|
|
3
|
+
#
|
|
4
|
+
# What it tests:
|
|
5
|
+
# Phase A (always): per-version steady-state cache behavior via 5 sequential Haiku -p calls,
|
|
6
|
+
# fired within seconds of each other. Captures:
|
|
7
|
+
# - Turn 1 prefix size (cache_creation cold start)
|
|
8
|
+
# - Turns 2-5 cache hit stability (should be ~100% cache_read if TTL holds)
|
|
9
|
+
# - Per-turn q5h_pct delta
|
|
10
|
+
# - TTL tier granted by server
|
|
11
|
+
# Phase B (optional, --include-idle): per-version idle-gap behavior via two calls 6 minutes apart.
|
|
12
|
+
# Captures whether the 1h TTL grant holds across a >5-minute idle, or whether
|
|
13
|
+
# the server flips to 5m tier and forces a rebuild.
|
|
14
|
+
#
|
|
15
|
+
# Safety:
|
|
16
|
+
# - Uses Haiku exclusively (~$0.006/call at Haiku 4.5 rates; full test at ~30 calls = ~$0.20)
|
|
17
|
+
# - No deliberate quota burn; exits gracefully if Q5h > 80% at start
|
|
18
|
+
# - Runs against fixed seed prompt to keep per-call overhead minimal
|
|
19
|
+
# - Does not trigger overage, does not pin quota state for the session
|
|
20
|
+
#
|
|
21
|
+
# Usage:
|
|
22
|
+
# ./cross-version-cache-test.sh # Phase A only, quick
|
|
23
|
+
# ./cross-version-cache-test.sh --include-idle # Phase A + Phase B (takes ~25 minutes)
|
|
24
|
+
# ./cross-version-cache-test.sh --output /some/path # Custom output dir
|
|
25
|
+
#
|
|
26
|
+
# Output:
|
|
27
|
+
# /tmp/cross-version-test-YYYYMMDD-HHMMSS/ (default) containing:
|
|
28
|
+
# - <version>-phase-a.jsonl # one usage.jsonl record per call
|
|
29
|
+
# - <version>-phase-b.jsonl # optional, only with --include-idle
|
|
30
|
+
# - summary.md # tabulated comparison across versions
|
|
31
|
+
# - raw-quota-status-*.json # quota state snapshots
|
|
32
|
+
#
|
|
33
|
+
# Part of claude-code-cache-fix. Requires:
|
|
34
|
+
# - ~/bin/cc-version launcher (see repo)
|
|
35
|
+
# - Installed versions at ~/cc-versions/<version>/ (this script checks and warns)
|
|
36
|
+
# - Interceptor active (the script verifies usage.jsonl grows per call)
|
|
37
|
+
#
|
|
38
|
+
# First created 2026-04-11 for the March 23 regression investigation follow-up.
|
|
39
|
+
|
|
40
|
+
set -euo pipefail
|
|
41
|
+
|
|
42
|
+
# ─── Configuration ──────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
VERSIONS=(2.1.81 2.1.83 2.1.90 2.1.101)
|
|
45
|
+
STEADY_STATE_TURNS=5
|
|
46
|
+
IDLE_GAP_SECONDS=360 # 6 minutes, crosses the 5m TTL boundary
|
|
47
|
+
SEED_PROMPT='Reply with exactly: ok'
|
|
48
|
+
MODEL='haiku'
|
|
49
|
+
|
|
50
|
+
# ─── CLI parsing ────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
INCLUDE_IDLE=0
|
|
53
|
+
OUTPUT_DIR=""
|
|
54
|
+
|
|
55
|
+
while [[ $# -gt 0 ]]; do
|
|
56
|
+
case "$1" in
|
|
57
|
+
--include-idle) INCLUDE_IDLE=1; shift ;;
|
|
58
|
+
--output) OUTPUT_DIR="$2"; shift 2 ;;
|
|
59
|
+
-h|--help)
|
|
60
|
+
sed -n '3,34p' "$0" | sed 's/^# \?//'
|
|
61
|
+
exit 0
|
|
62
|
+
;;
|
|
63
|
+
*)
|
|
64
|
+
echo "unknown flag: $1" >&2
|
|
65
|
+
exit 1
|
|
66
|
+
;;
|
|
67
|
+
esac
|
|
68
|
+
done
|
|
69
|
+
|
|
70
|
+
# Default output dir
|
|
71
|
+
if [[ -z "$OUTPUT_DIR" ]]; then
|
|
72
|
+
OUTPUT_DIR="/tmp/cross-version-test-$(date +%Y%m%d-%H%M%S)"
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
mkdir -p "$OUTPUT_DIR"
|
|
76
|
+
SUMMARY="$OUTPUT_DIR/summary.md"
|
|
77
|
+
echo "# Cross-Version Cache Test — $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SUMMARY"
|
|
78
|
+
echo "" >> "$SUMMARY"
|
|
79
|
+
echo "Output directory: \`$OUTPUT_DIR\`" >> "$SUMMARY"
|
|
80
|
+
echo "" >> "$SUMMARY"
|
|
81
|
+
|
|
82
|
+
# ─── Preflight ──────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
echo "=== Cross-version cache test ===" | tee -a "$SUMMARY"
|
|
85
|
+
|
|
86
|
+
# Check launcher
|
|
87
|
+
if [[ ! -x "$HOME/bin/cc-version" ]]; then
|
|
88
|
+
echo "ERROR: $HOME/bin/cc-version not found or not executable" >&2
|
|
89
|
+
exit 1
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
# Check installed versions
|
|
93
|
+
for v in "${VERSIONS[@]}"; do
|
|
94
|
+
if [[ ! -f "$HOME/cc-versions/$v/node_modules/@anthropic-ai/claude-code/cli.js" ]]; then
|
|
95
|
+
echo "ERROR: v$v not installed at ~/cc-versions/$v — run the install snippet in docs/march-23-regression-investigation.md" >&2
|
|
96
|
+
exit 1
|
|
97
|
+
fi
|
|
98
|
+
done
|
|
99
|
+
|
|
100
|
+
# Quota safety check — abort if Q5h is already high
|
|
101
|
+
Q5H=$(python3 -c "
|
|
102
|
+
import json
|
|
103
|
+
try:
|
|
104
|
+
q = json.load(open('$HOME/.claude/quota-status.json'))
|
|
105
|
+
print(q['five_hour']['pct'])
|
|
106
|
+
except Exception:
|
|
107
|
+
print(0)
|
|
108
|
+
" 2>/dev/null || echo 0)
|
|
109
|
+
|
|
110
|
+
if [[ "$Q5H" -gt 80 ]]; then
|
|
111
|
+
echo "ABORT: Q5h is at ${Q5H}% — too close to cap. Test deferred." | tee -a "$SUMMARY"
|
|
112
|
+
exit 2
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
echo "Preflight OK: Q5h at ${Q5H}%, 4 versions installed, launcher present." | tee -a "$SUMMARY"
|
|
116
|
+
echo "" | tee -a "$SUMMARY"
|
|
117
|
+
|
|
118
|
+
# Snapshot quota state at start
|
|
119
|
+
cp "$HOME/.claude/quota-status.json" "$OUTPUT_DIR/raw-quota-status-start.json" 2>/dev/null || true
|
|
120
|
+
|
|
121
|
+
# ─── Phase A: steady-state per version ─────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
echo "## Phase A — Steady-state" | tee -a "$SUMMARY"
|
|
124
|
+
echo "" | tee -a "$SUMMARY"
|
|
125
|
+
echo "5 sequential Haiku calls per version, fired in quick succession (<30s gap each)." | tee -a "$SUMMARY"
|
|
126
|
+
echo "" | tee -a "$SUMMARY"
|
|
127
|
+
|
|
128
|
+
for v in "${VERSIONS[@]}"; do
|
|
129
|
+
echo "--- Phase A: v$v ---"
|
|
130
|
+
OUTFILE="$OUTPUT_DIR/$v-phase-a.jsonl"
|
|
131
|
+
: > "$OUTFILE"
|
|
132
|
+
|
|
133
|
+
for i in $(seq 1 "$STEADY_STATE_TURNS"); do
|
|
134
|
+
USAGE_LINES_BEFORE=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
|
|
135
|
+
echo "$SEED_PROMPT" | "$HOME/bin/cc-version" "$v" -p --model "$MODEL" > /dev/null 2>&1 || {
|
|
136
|
+
echo "WARNING: v$v turn $i failed" | tee -a "$SUMMARY"
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
USAGE_LINES_AFTER=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
|
|
140
|
+
if [[ "$USAGE_LINES_AFTER" -gt "$USAGE_LINES_BEFORE" ]]; then
|
|
141
|
+
# Capture the newly-added usage.jsonl line(s) for this version
|
|
142
|
+
tail -n "$((USAGE_LINES_AFTER - USAGE_LINES_BEFORE))" "$HOME/.claude/usage.jsonl" >> "$OUTFILE"
|
|
143
|
+
fi
|
|
144
|
+
# Tiny sleep to let the interceptor finish writing the telemetry
|
|
145
|
+
sleep 0.5
|
|
146
|
+
done
|
|
147
|
+
|
|
148
|
+
TURNS_CAPTURED=$(wc -l < "$OUTFILE")
|
|
149
|
+
echo " v$v: $TURNS_CAPTURED turns captured → $OUTFILE"
|
|
150
|
+
done
|
|
151
|
+
|
|
152
|
+
echo "" | tee -a "$SUMMARY"
|
|
153
|
+
|
|
154
|
+
# ─── Phase B: idle-gap (optional) ──────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
if [[ "$INCLUDE_IDLE" -eq 1 ]]; then
|
|
157
|
+
echo "## Phase B — Idle-gap behavior" | tee -a "$SUMMARY"
|
|
158
|
+
echo "" | tee -a "$SUMMARY"
|
|
159
|
+
echo "Per version: turn 1, wait ${IDLE_GAP_SECONDS}s (crosses 5m TTL), turn 2." | tee -a "$SUMMARY"
|
|
160
|
+
echo "" | tee -a "$SUMMARY"
|
|
161
|
+
|
|
162
|
+
for v in "${VERSIONS[@]}"; do
|
|
163
|
+
echo "--- Phase B: v$v ---"
|
|
164
|
+
OUTFILE="$OUTPUT_DIR/$v-phase-b.jsonl"
|
|
165
|
+
: > "$OUTFILE"
|
|
166
|
+
|
|
167
|
+
# Turn 1
|
|
168
|
+
USAGE_LINES_BEFORE=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
|
|
169
|
+
echo "$SEED_PROMPT" | "$HOME/bin/cc-version" "$v" -p --model "$MODEL" > /dev/null 2>&1 || true
|
|
170
|
+
USAGE_LINES_AFTER=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
|
|
171
|
+
if [[ "$USAGE_LINES_AFTER" -gt "$USAGE_LINES_BEFORE" ]]; then
|
|
172
|
+
tail -n "$((USAGE_LINES_AFTER - USAGE_LINES_BEFORE))" "$HOME/.claude/usage.jsonl" >> "$OUTFILE"
|
|
173
|
+
fi
|
|
174
|
+
echo " v$v: turn 1 done, waiting ${IDLE_GAP_SECONDS}s..."
|
|
175
|
+
|
|
176
|
+
sleep "$IDLE_GAP_SECONDS"
|
|
177
|
+
|
|
178
|
+
# Turn 2
|
|
179
|
+
USAGE_LINES_BEFORE=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
|
|
180
|
+
echo "$SEED_PROMPT" | "$HOME/bin/cc-version" "$v" -p --model "$MODEL" > /dev/null 2>&1 || true
|
|
181
|
+
USAGE_LINES_AFTER=$(wc -l < "$HOME/.claude/usage.jsonl" 2>/dev/null || echo 0)
|
|
182
|
+
if [[ "$USAGE_LINES_AFTER" -gt "$USAGE_LINES_BEFORE" ]]; then
|
|
183
|
+
tail -n "$((USAGE_LINES_AFTER - USAGE_LINES_BEFORE))" "$HOME/.claude/usage.jsonl" >> "$OUTFILE"
|
|
184
|
+
fi
|
|
185
|
+
echo " v$v: turn 2 done"
|
|
186
|
+
done
|
|
187
|
+
|
|
188
|
+
echo "" | tee -a "$SUMMARY"
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
# Snapshot quota state at end
|
|
192
|
+
cp "$HOME/.claude/quota-status.json" "$OUTPUT_DIR/raw-quota-status-end.json" 2>/dev/null || true
|
|
193
|
+
|
|
194
|
+
# ─── Analysis ──────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
echo "## Phase A Results" >> "$SUMMARY"
|
|
197
|
+
echo "" >> "$SUMMARY"
|
|
198
|
+
|
|
199
|
+
python3 <<EOF >> "$SUMMARY"
|
|
200
|
+
import json, os
|
|
201
|
+
|
|
202
|
+
output_dir = "$OUTPUT_DIR"
|
|
203
|
+
versions = ["2.1.81", "2.1.83", "2.1.90", "2.1.101"]
|
|
204
|
+
include_idle = $INCLUDE_IDLE
|
|
205
|
+
|
|
206
|
+
def load_jsonl(path):
|
|
207
|
+
if not os.path.exists(path):
|
|
208
|
+
return []
|
|
209
|
+
rows = []
|
|
210
|
+
with open(path) as f:
|
|
211
|
+
for line in f:
|
|
212
|
+
line = line.strip()
|
|
213
|
+
if line:
|
|
214
|
+
try:
|
|
215
|
+
rows.append(json.loads(line))
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
return rows
|
|
219
|
+
|
|
220
|
+
# Phase A steady-state table
|
|
221
|
+
print("### Per-version per-turn usage (Phase A)")
|
|
222
|
+
print("")
|
|
223
|
+
print("| Version | Turn | cc (creation) | cr (read) | prefix | out | ttl | q5h% |")
|
|
224
|
+
print("|---|---:|---:|---:|---:|---:|---|---:|")
|
|
225
|
+
|
|
226
|
+
for v in versions:
|
|
227
|
+
rows = load_jsonl(os.path.join(output_dir, f"{v}-phase-a.jsonl"))
|
|
228
|
+
for i, r in enumerate(rows, 1):
|
|
229
|
+
cc = r.get("cache_creation_input_tokens", 0)
|
|
230
|
+
cr = r.get("cache_read_input_tokens", 0)
|
|
231
|
+
prefix = cc + cr
|
|
232
|
+
out = r.get("output_tokens", 0)
|
|
233
|
+
ttl = r.get("ttl_tier", "?")
|
|
234
|
+
q5h = r.get("q5h_pct", "?")
|
|
235
|
+
print(f"| v{v} | {i} | {cc:>6,} | {cr:>6,} | {prefix:>6,} | {out:>3} | {ttl} | {q5h}% |")
|
|
236
|
+
|
|
237
|
+
print("")
|
|
238
|
+
|
|
239
|
+
# Steady-state summary: turn-2-onwards averages
|
|
240
|
+
print("### Steady-state averages (turns 2-5)")
|
|
241
|
+
print("")
|
|
242
|
+
print("| Version | avg prefix | avg cc | avg cr | cache hit rate | Turn 1 cold cc | q5h delta turn 1→5 |")
|
|
243
|
+
print("|---|---:|---:|---:|---:|---:|---:|")
|
|
244
|
+
for v in versions:
|
|
245
|
+
rows = load_jsonl(os.path.join(output_dir, f"{v}-phase-a.jsonl"))
|
|
246
|
+
if len(rows) < 2:
|
|
247
|
+
print(f"| v{v} | (insufficient data) | | | | | |")
|
|
248
|
+
continue
|
|
249
|
+
turn1 = rows[0]
|
|
250
|
+
tail = rows[1:]
|
|
251
|
+
avg_prefix = sum((r.get("cache_creation_input_tokens",0) + r.get("cache_read_input_tokens",0)) for r in tail) / len(tail)
|
|
252
|
+
avg_cc = sum(r.get("cache_creation_input_tokens",0) for r in tail) / len(tail)
|
|
253
|
+
avg_cr = sum(r.get("cache_read_input_tokens",0) for r in tail) / len(tail)
|
|
254
|
+
hit_rate = avg_cr / avg_prefix if avg_prefix > 0 else 0
|
|
255
|
+
q5h_start = rows[0].get("q5h_pct", 0)
|
|
256
|
+
q5h_end = rows[-1].get("q5h_pct", 0)
|
|
257
|
+
q5h_delta = (q5h_end - q5h_start) if isinstance(q5h_start, (int, float)) and isinstance(q5h_end, (int, float)) else "?"
|
|
258
|
+
print(f"| v{v} | {avg_prefix:>7,.0f} | {avg_cc:>6,.0f} | {avg_cr:>6,.0f} | {hit_rate*100:.1f}% | {turn1.get('cache_creation_input_tokens',0):>7,} | {q5h_delta}% |")
|
|
259
|
+
|
|
260
|
+
print("")
|
|
261
|
+
|
|
262
|
+
if include_idle:
|
|
263
|
+
print("## Phase B Results (idle-gap behavior)")
|
|
264
|
+
print("")
|
|
265
|
+
print("| Version | Turn 1 prefix | Turn 1 ttl | idle (s) | Turn 2 cc | Turn 2 cr | Turn 2 ttl | rebuilt? |")
|
|
266
|
+
print("|---|---:|---|---:|---:|---:|---|:---:|")
|
|
267
|
+
for v in versions:
|
|
268
|
+
rows = load_jsonl(os.path.join(output_dir, f"{v}-phase-b.jsonl"))
|
|
269
|
+
if len(rows) < 2:
|
|
270
|
+
print(f"| v{v} | (incomplete) | | | | | | |")
|
|
271
|
+
continue
|
|
272
|
+
t1, t2 = rows[0], rows[1]
|
|
273
|
+
t1_prefix = t1.get("cache_creation_input_tokens",0) + t1.get("cache_read_input_tokens",0)
|
|
274
|
+
t2_cc = t2.get("cache_creation_input_tokens",0)
|
|
275
|
+
t2_cr = t2.get("cache_read_input_tokens",0)
|
|
276
|
+
# Idle gap we configured
|
|
277
|
+
idle_s = $IDLE_GAP_SECONDS
|
|
278
|
+
# Rebuilt = turn 2 had substantial cache_creation relative to turn 1 prefix
|
|
279
|
+
rebuilt = "✗ expired" if t2_cc > (t1_prefix * 0.5) else "✓ warm"
|
|
280
|
+
print(f"| v{v} | {t1_prefix:>7,} | {t1.get('ttl_tier','?')} | {idle_s} | {t2_cc:>7,} | {t2_cr:>7,} | {t2.get('ttl_tier','?')} | {rebuilt} |")
|
|
281
|
+
print("")
|
|
282
|
+
|
|
283
|
+
print("---")
|
|
284
|
+
print("")
|
|
285
|
+
print("*Generated by cross-version-cache-test.sh*")
|
|
286
|
+
EOF
|
|
287
|
+
|
|
288
|
+
echo ""
|
|
289
|
+
echo "=== Test complete ==="
|
|
290
|
+
echo "Summary written to: $SUMMARY"
|
|
291
|
+
echo ""
|
|
292
|
+
echo "Raw per-version JSONLs in: $OUTPUT_DIR"
|
|
293
|
+
echo ""
|
|
294
|
+
if [[ "$Q5H" -lt 50 ]]; then
|
|
295
|
+
NEW_Q5H=$(python3 -c "
|
|
296
|
+
import json
|
|
297
|
+
try:
|
|
298
|
+
print(json.load(open('$HOME/.claude/quota-status.json'))['five_hour']['pct'])
|
|
299
|
+
except Exception:
|
|
300
|
+
print('?')
|
|
301
|
+
" 2>/dev/null)
|
|
302
|
+
echo "Q5h at start: ${Q5H}%"
|
|
303
|
+
echo "Q5h at end: ${NEW_Q5H}%"
|
|
304
|
+
fi
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# sim-cost-reconcile — One-liner wrapper for running cost-report.mjs against
|
|
3
|
+
# a simulation log with admin API cross-reference enabled.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# sim-cost-reconcile <sim-dir-or-log> [extra cost-report.mjs args...]
|
|
7
|
+
#
|
|
8
|
+
# Examples:
|
|
9
|
+
# sim-cost-reconcile ~/git_repos/kanfei_test/kanfei-nowcast/.test_cache/simulations/realtime_sim_harnett_county_qlcs_2026_20260411_024836
|
|
10
|
+
# sim-cost-reconcile path/to/simulation.log --format md > report.md
|
|
11
|
+
#
|
|
12
|
+
# Reads admin key from $ANTHROPIC_ADMIN_KEY or ~/.config/anthropic/admin-key.
|
|
13
|
+
# If no admin key is available, runs with telemetry only and warns.
|
|
14
|
+
#
|
|
15
|
+
# NOTE on admin reconciliation: the admin API returns data at 1h-bucket
|
|
16
|
+
# resolution, so if multiple sims (or other API activity) overlap the same
|
|
17
|
+
# hour, the admin total will include all of it. For an accurate multi-sim
|
|
18
|
+
# aggregate, run this on each sim and sum the telemetry totals, then pull
|
|
19
|
+
# the admin total once over the full window.
|
|
20
|
+
|
|
21
|
+
set -euo pipefail
|
|
22
|
+
|
|
23
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
COST_REPORT="$SCRIPT_DIR/cost-report.mjs"
|
|
25
|
+
|
|
26
|
+
if [[ $# -lt 1 ]]; then
|
|
27
|
+
echo "Usage: $(basename "$0") <sim-dir-or-log> [extra cost-report args...]" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
TARGET="$1"
|
|
32
|
+
shift
|
|
33
|
+
|
|
34
|
+
# Resolve a dir to its simulation.log
|
|
35
|
+
if [[ -d "$TARGET" ]]; then
|
|
36
|
+
LOG="$TARGET/simulation.log"
|
|
37
|
+
if [[ ! -f "$LOG" ]]; then
|
|
38
|
+
echo "ERROR: no simulation.log in $TARGET" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
elif [[ -f "$TARGET" ]]; then
|
|
42
|
+
LOG="$TARGET"
|
|
43
|
+
else
|
|
44
|
+
echo "ERROR: $TARGET is neither a file nor a directory" >&2
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Load admin key
|
|
49
|
+
ADMIN_KEY_FILE="${HOME}/.config/anthropic/admin-key"
|
|
50
|
+
if [[ -n "${ANTHROPIC_ADMIN_KEY:-}" ]]; then
|
|
51
|
+
KEY="$ANTHROPIC_ADMIN_KEY"
|
|
52
|
+
elif [[ -r "$ADMIN_KEY_FILE" ]]; then
|
|
53
|
+
KEY="$(cat "$ADMIN_KEY_FILE")"
|
|
54
|
+
else
|
|
55
|
+
echo "WARNING: no admin key found ($ADMIN_KEY_FILE missing, ANTHROPIC_ADMIN_KEY unset)" >&2
|
|
56
|
+
echo " running telemetry-only — pass --admin-key or set env var to enable reconciliation" >&2
|
|
57
|
+
exec node "$COST_REPORT" --sim-log "$LOG" "$@"
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
exec node "$COST_REPORT" --sim-log "$LOG" --admin-key "$KEY" "$@"
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* usage-to-dashboard-ndjson — Translate claude-code-cache-fix's usage.jsonl
|
|
4
|
+
* into the proxy NDJSON format expected by @fgrosswig's claude-usage-dashboard,
|
|
5
|
+
* and write to the directory his dashboard already watches.
|
|
6
|
+
*
|
|
7
|
+
* https://github.com/fgrosswig/claude-usage-dashboard
|
|
8
|
+
*
|
|
9
|
+
* # Why this exists
|
|
10
|
+
*
|
|
11
|
+
* Our interceptor and fgrosswig's dashboard are strongly complementary:
|
|
12
|
+
* the interceptor captures per-call API data from inside the Node.js process
|
|
13
|
+
* (cache metrics, quota state, request rewrites), while his dashboard
|
|
14
|
+
* provides visualization, historical trending, and multi-host aggregation.
|
|
15
|
+
*
|
|
16
|
+
* Rather than build our own visualization layer, we translate our per-call
|
|
17
|
+
* usage records into the NDJSON schema his dashboard ingests. A user running
|
|
18
|
+
* both tools gets the best of both: the interceptor fixes what it can fix
|
|
19
|
+
* and emits rich per-call data, and the dashboard displays that data
|
|
20
|
+
* alongside whatever Claude Code's own session JSONLs already capture.
|
|
21
|
+
*
|
|
22
|
+
* # What this tool does
|
|
23
|
+
*
|
|
24
|
+
* Reads `~/.claude/usage.jsonl` (our interceptor's per-call log) and
|
|
25
|
+
* translates each entry into a minimal-but-compatible record in the shape
|
|
26
|
+
* his dashboard expects under `~/.claude/anthropic-proxy-logs/*.ndjson`.
|
|
27
|
+
* The output file follows the convention `proxy-YYYY-MM-DD.ndjson`, one
|
|
28
|
+
* file per UTC day, matching the filename pattern his `collectProxyNdjsonFiles()`
|
|
29
|
+
* helper discovers.
|
|
30
|
+
*
|
|
31
|
+
* # Fields emitted
|
|
32
|
+
*
|
|
33
|
+
* Mapped from our usage.jsonl to fgrosswig's proxy-core.js shape:
|
|
34
|
+
*
|
|
35
|
+
* {
|
|
36
|
+
* "ts_start": <our timestamp>,
|
|
37
|
+
* "ts_end": <our timestamp>, // single-point, no duration
|
|
38
|
+
* "duration_ms": null, // we don't measure this
|
|
39
|
+
* "method": "POST",
|
|
40
|
+
* "path": "/v1/messages",
|
|
41
|
+
* "upstream_status": 200, // implicit from usage presence
|
|
42
|
+
* "usage": {
|
|
43
|
+
* "input_tokens": <ours>,
|
|
44
|
+
* "output_tokens": <ours>,
|
|
45
|
+
* "cache_read_input_tokens": <ours>,
|
|
46
|
+
* "cache_creation_input_tokens": <ours>
|
|
47
|
+
* },
|
|
48
|
+
* "cache_read_ratio": <computed>,
|
|
49
|
+
* "cache_health": "healthy" | "affected" | "mixed",
|
|
50
|
+
* "request_hints": { "model": <ours> },
|
|
51
|
+
* "response_anthropic_headers": { // if quota fields available
|
|
52
|
+
* "anthropic-ratelimit-unified-5h-utilization": "<ours>",
|
|
53
|
+
* "anthropic-ratelimit-unified-7d-utilization": "<ours>"
|
|
54
|
+
* },
|
|
55
|
+
* "ttl_tier": <ours, interceptor-specific>,
|
|
56
|
+
* "ephemeral_1h_input_tokens": <ours, interceptor-specific>,
|
|
57
|
+
* "ephemeral_5m_input_tokens": <ours, interceptor-specific>,
|
|
58
|
+
* "source": "claude-code-cache-fix"
|
|
59
|
+
* }
|
|
60
|
+
*
|
|
61
|
+
* Extra fields beyond fgrosswig's native schema (ttl_tier, ephemeral_*,
|
|
62
|
+
* source) are added for forward-compatibility — his dashboard ignores
|
|
63
|
+
* unknown fields per its tolerant-ingest design, and our own tooling
|
|
64
|
+
* downstream may find them useful when consuming the same NDJSON.
|
|
65
|
+
*
|
|
66
|
+
* # Usage
|
|
67
|
+
*
|
|
68
|
+
* # One-shot translation (reads all of usage.jsonl, writes today's file)
|
|
69
|
+
* node tools/usage-to-dashboard-ndjson.mjs
|
|
70
|
+
*
|
|
71
|
+
* # Follow mode (tail usage.jsonl, append new records as they arrive)
|
|
72
|
+
* node tools/usage-to-dashboard-ndjson.mjs --follow
|
|
73
|
+
*
|
|
74
|
+
* # Custom input/output paths
|
|
75
|
+
* node tools/usage-to-dashboard-ndjson.mjs --input /path/to/usage.jsonl --output-dir /path/to/ndjson-dir
|
|
76
|
+
*
|
|
77
|
+
* # Dry-run: print to stdout instead of writing files
|
|
78
|
+
* node tools/usage-to-dashboard-ndjson.mjs --stdout
|
|
79
|
+
*
|
|
80
|
+
* # Environment
|
|
81
|
+
*
|
|
82
|
+
* ANTHROPIC_PROXY_LOG_DIR Override output directory (matches fgrosswig's
|
|
83
|
+
* dashboard env var so both tools stay in sync).
|
|
84
|
+
*
|
|
85
|
+
* Part of claude-code-cache-fix. MIT licensed.
|
|
86
|
+
* https://github.com/cnighswonger/claude-code-cache-fix
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, watch } from 'node:fs';
|
|
90
|
+
import { join } from 'node:path';
|
|
91
|
+
import { homedir } from 'node:os';
|
|
92
|
+
|
|
93
|
+
// ─── CLI parsing ────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function parseArgs() {
|
|
96
|
+
const args = process.argv.slice(2);
|
|
97
|
+
const opts = {
|
|
98
|
+
input: join(homedir(), '.claude', 'usage.jsonl'),
|
|
99
|
+
outputDir: process.env.ANTHROPIC_PROXY_LOG_DIR || join(homedir(), '.claude', 'anthropic-proxy-logs'),
|
|
100
|
+
stdout: false,
|
|
101
|
+
follow: false,
|
|
102
|
+
help: false,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < args.length; i++) {
|
|
106
|
+
switch (args[i]) {
|
|
107
|
+
case '--input': opts.input = args[++i]; break;
|
|
108
|
+
case '--output-dir': opts.outputDir = args[++i]; break;
|
|
109
|
+
case '--stdout': opts.stdout = true; break;
|
|
110
|
+
case '--follow': opts.follow = true; break;
|
|
111
|
+
case '-h':
|
|
112
|
+
case '--help': opts.help = true; break;
|
|
113
|
+
default:
|
|
114
|
+
console.error(`unknown flag: ${args[i]}`);
|
|
115
|
+
opts.help = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return opts;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printUsage() {
|
|
123
|
+
console.log(`usage-to-dashboard-ndjson — Translate cache-fix usage.jsonl to fgrosswig dashboard NDJSON.
|
|
124
|
+
|
|
125
|
+
Usage:
|
|
126
|
+
node usage-to-dashboard-ndjson.mjs One-shot: read all, write today's file
|
|
127
|
+
node usage-to-dashboard-ndjson.mjs --follow Tail usage.jsonl, append new records live
|
|
128
|
+
node usage-to-dashboard-ndjson.mjs --stdout Print NDJSON to stdout instead of files
|
|
129
|
+
node usage-to-dashboard-ndjson.mjs --input <path> Custom input (default: ~/.claude/usage.jsonl)
|
|
130
|
+
node usage-to-dashboard-ndjson.mjs --output-dir <path> Custom output dir (default: ~/.claude/anthropic-proxy-logs)
|
|
131
|
+
|
|
132
|
+
Output files follow the convention: proxy-YYYY-MM-DD.ndjson (one per UTC day).
|
|
133
|
+
|
|
134
|
+
Environment:
|
|
135
|
+
ANTHROPIC_PROXY_LOG_DIR Override output directory (also used by fgrosswig's dashboard).
|
|
136
|
+
|
|
137
|
+
Credit: this tool writes the NDJSON schema expected by @fgrosswig's
|
|
138
|
+
claude-usage-dashboard (https://github.com/fgrosswig/claude-usage-dashboard).
|
|
139
|
+
Running both tools together gives users per-call data from our interceptor
|
|
140
|
+
plus the visualization layer from his dashboard, with no coordination needed.
|
|
141
|
+
`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Record translation ─────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Translate one claude-code-cache-fix usage.jsonl record into a
|
|
148
|
+
* fgrosswig-dashboard-compatible NDJSON record. Returns null if the
|
|
149
|
+
* record doesn't have enough fields to be usable.
|
|
150
|
+
*/
|
|
151
|
+
function translateRecord(entry) {
|
|
152
|
+
if (!entry || !entry.timestamp || !entry.model) return null;
|
|
153
|
+
|
|
154
|
+
const inTok = entry.input_tokens || 0;
|
|
155
|
+
const outTok = entry.output_tokens || 0;
|
|
156
|
+
const crTok = entry.cache_read_input_tokens || 0;
|
|
157
|
+
const ccTok = entry.cache_creation_input_tokens || 0;
|
|
158
|
+
|
|
159
|
+
// Cache health (fgrosswig's semantic labels)
|
|
160
|
+
const totalCacheInput = crTok + ccTok;
|
|
161
|
+
const cacheReadRatio = totalCacheInput > 0 ? crTok / totalCacheInput : null;
|
|
162
|
+
let cacheHealth = 'na';
|
|
163
|
+
if (cacheReadRatio != null) {
|
|
164
|
+
if (cacheReadRatio >= 0.8) cacheHealth = 'healthy';
|
|
165
|
+
else if (cacheReadRatio < 0.4 && ccTok > 0) cacheHealth = 'affected';
|
|
166
|
+
else cacheHealth = 'mixed';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Reconstruct a minimal response_anthropic_headers blob from the quota
|
|
170
|
+
// pct fields we captured. Not byte-identical to what the proxy would see
|
|
171
|
+
// on the wire, but structurally compatible for the dashboard's consumers.
|
|
172
|
+
const responseHeaders = {};
|
|
173
|
+
if (entry.q5h_pct != null) {
|
|
174
|
+
responseHeaders['anthropic-ratelimit-unified-5h-utilization'] = String(entry.q5h_pct / 100);
|
|
175
|
+
}
|
|
176
|
+
if (entry.q7d_pct != null) {
|
|
177
|
+
responseHeaders['anthropic-ratelimit-unified-7d-utilization'] = String(entry.q7d_pct / 100);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const rec = {
|
|
181
|
+
ts_start: entry.timestamp,
|
|
182
|
+
ts_end: entry.timestamp,
|
|
183
|
+
duration_ms: null,
|
|
184
|
+
method: 'POST',
|
|
185
|
+
path: '/v1/messages',
|
|
186
|
+
upstream_status: 200,
|
|
187
|
+
usage: {
|
|
188
|
+
input_tokens: inTok,
|
|
189
|
+
output_tokens: outTok,
|
|
190
|
+
cache_read_input_tokens: crTok,
|
|
191
|
+
cache_creation_input_tokens: ccTok,
|
|
192
|
+
},
|
|
193
|
+
cache_read_ratio: cacheReadRatio,
|
|
194
|
+
cache_health: cacheHealth,
|
|
195
|
+
request_hints: {
|
|
196
|
+
model: entry.model,
|
|
197
|
+
},
|
|
198
|
+
response_anthropic_headers: responseHeaders,
|
|
199
|
+
// Interceptor-specific extras — fgrosswig's dashboard ignores unknown
|
|
200
|
+
// fields, so these pass through without breaking ingestion.
|
|
201
|
+
ttl_tier: entry.ttl_tier || null,
|
|
202
|
+
ephemeral_1h_input_tokens: entry.ephemeral_1h_input_tokens || 0,
|
|
203
|
+
ephemeral_5m_input_tokens: entry.ephemeral_5m_input_tokens || 0,
|
|
204
|
+
peak_hour: entry.peak_hour || false,
|
|
205
|
+
source: 'claude-code-cache-fix',
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Synthesize a stable pseudo-request-id from timestamp + model for dedup
|
|
209
|
+
// at the dashboard layer. Not a real request ID — just a deterministic key.
|
|
210
|
+
rec.req_id = 'ccf_' + entry.timestamp.replace(/[^0-9]/g, '') + '_' + entry.model.slice(-6);
|
|
211
|
+
|
|
212
|
+
return rec;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── File output ────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
function dayFileFor(outputDir, isoTimestamp) {
|
|
218
|
+
// proxy-YYYY-MM-DD.ndjson from UTC date
|
|
219
|
+
const date = isoTimestamp.slice(0, 10);
|
|
220
|
+
return join(outputDir, `proxy-${date}.ndjson`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function ensureDir(dir) {
|
|
224
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function writeRecords(records, outputDir, useStdout) {
|
|
228
|
+
if (useStdout) {
|
|
229
|
+
for (const r of records) {
|
|
230
|
+
process.stdout.write(JSON.stringify(r) + '\n');
|
|
231
|
+
}
|
|
232
|
+
return records.length;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
ensureDir(outputDir);
|
|
236
|
+
|
|
237
|
+
// Group by day for efficient appending
|
|
238
|
+
const byDay = new Map();
|
|
239
|
+
for (const r of records) {
|
|
240
|
+
const day = dayFileFor(outputDir, r.ts_start);
|
|
241
|
+
if (!byDay.has(day)) byDay.set(day, []);
|
|
242
|
+
byDay.get(day).push(r);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const [dayFile, dayRecords] of byDay) {
|
|
246
|
+
const payload = dayRecords.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
247
|
+
// Overwrite on one-shot mode — the tool is idempotent within a single
|
|
248
|
+
// input file, so rewriting today's file from a full replay is safe.
|
|
249
|
+
writeFileSync(dayFile, payload);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return records.length;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── One-shot batch mode ────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
function runBatch(opts) {
|
|
258
|
+
if (!existsSync(opts.input)) {
|
|
259
|
+
console.error(`ERROR: input file not found: ${opts.input}`);
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const raw = readFileSync(opts.input, 'utf8');
|
|
264
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
265
|
+
const records = [];
|
|
266
|
+
let skipped = 0;
|
|
267
|
+
|
|
268
|
+
for (const line of lines) {
|
|
269
|
+
try {
|
|
270
|
+
const entry = JSON.parse(line);
|
|
271
|
+
const rec = translateRecord(entry);
|
|
272
|
+
if (rec) records.push(rec);
|
|
273
|
+
else skipped++;
|
|
274
|
+
} catch {
|
|
275
|
+
skipped++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const written = writeRecords(records, opts.outputDir, opts.stdout);
|
|
280
|
+
if (!opts.stdout) {
|
|
281
|
+
console.error(`usage-to-dashboard-ndjson: wrote ${written} records to ${opts.outputDir} (${skipped} skipped)`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Follow mode ────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
function runFollow(opts) {
|
|
288
|
+
if (!existsSync(opts.input)) {
|
|
289
|
+
console.error(`ERROR: input file not found: ${opts.input}`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// First, catch up on the existing file (idempotent write)
|
|
294
|
+
runBatch(opts);
|
|
295
|
+
|
|
296
|
+
// Then watch for new entries
|
|
297
|
+
console.error(`usage-to-dashboard-ndjson: watching ${opts.input} for new records...`);
|
|
298
|
+
let lastSize = statSync(opts.input).size;
|
|
299
|
+
|
|
300
|
+
watch(opts.input, { persistent: true }, () => {
|
|
301
|
+
let currentSize;
|
|
302
|
+
try { currentSize = statSync(opts.input).size; } catch { return; }
|
|
303
|
+
if (currentSize <= lastSize) {
|
|
304
|
+
// File truncated or unchanged — rewind lastSize
|
|
305
|
+
if (currentSize < lastSize) lastSize = 0;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// Read only the new bytes
|
|
309
|
+
try {
|
|
310
|
+
const fd = readFileSync(opts.input, 'utf8');
|
|
311
|
+
const newContent = fd.slice(lastSize);
|
|
312
|
+
lastSize = currentSize;
|
|
313
|
+
const newLines = newContent.split('\n').filter(l => l.trim());
|
|
314
|
+
const newRecs = [];
|
|
315
|
+
for (const line of newLines) {
|
|
316
|
+
try {
|
|
317
|
+
const entry = JSON.parse(line);
|
|
318
|
+
const rec = translateRecord(entry);
|
|
319
|
+
if (rec) newRecs.push(rec);
|
|
320
|
+
} catch {}
|
|
321
|
+
}
|
|
322
|
+
if (newRecs.length > 0) {
|
|
323
|
+
// Append to today's dayfile per record
|
|
324
|
+
ensureDir(opts.outputDir);
|
|
325
|
+
for (const r of newRecs) {
|
|
326
|
+
const dayFile = dayFileFor(opts.outputDir, r.ts_start);
|
|
327
|
+
appendFileSync(dayFile, JSON.stringify(r) + '\n');
|
|
328
|
+
}
|
|
329
|
+
console.error(`[${new Date().toISOString()}] appended ${newRecs.length} records`);
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
console.error(`watch error: ${err.message}`);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Keep the process alive
|
|
337
|
+
process.stdin.resume();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
const opts = parseArgs();
|
|
343
|
+
if (opts.help) {
|
|
344
|
+
printUsage();
|
|
345
|
+
process.exit(0);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (opts.follow) {
|
|
349
|
+
runFollow(opts);
|
|
350
|
+
} else {
|
|
351
|
+
runBatch(opts);
|
|
352
|
+
}
|