claude-code-cache-fix 1.6.4 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -303,6 +303,51 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
303
303
 
304
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.
305
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.
306
+ - **[@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.
307
+
308
+ ## Works with @fgrosswig's dashboard
309
+
310
+ This interceptor and [@fgrosswig](https://github.com/fgrosswig)'s
311
+ [claude-usage-dashboard](https://github.com/fgrosswig/claude-usage-dashboard)
312
+ solve strongly complementary problems. The interceptor captures per-call API
313
+ data from inside the Node.js process — cache metrics, quota state, TTL tier,
314
+ rewrites applied. The dashboard provides the visualization layer — historical
315
+ trending, per-day charts, multi-host aggregation, cache-health scoring.
316
+
317
+ Running both gives you the best of both tools, and the integration is a
318
+ one-liner thanks to the dashboard's tolerant NDJSON ingest and our new
319
+ `usage-to-dashboard-ndjson` translator.
320
+
321
+ ### Quick setup
322
+
323
+ ```bash
324
+ # Install both tools
325
+ npm install -g claude-code-cache-fix
326
+ # (follow fgrosswig's dashboard install: https://github.com/fgrosswig/claude-usage-dashboard)
327
+
328
+ # One-shot translation (reads ~/.claude/usage.jsonl, writes to
329
+ # ~/.claude/anthropic-proxy-logs/proxy-YYYY-MM-DD.ndjson, which his
330
+ # dashboard already watches)
331
+ node $(npm root -g)/claude-code-cache-fix/tools/usage-to-dashboard-ndjson.mjs
332
+
333
+ # Or keep it live-updating as the interceptor logs new calls
334
+ node $(npm root -g)/claude-code-cache-fix/tools/usage-to-dashboard-ndjson.mjs --follow &
335
+ ```
336
+
337
+ No configuration required on the dashboard side — fgrosswig's
338
+ `collectProxyNdjsonFiles()` auto-discovers files in
339
+ `~/.claude/anthropic-proxy-logs/` (or `$ANTHROPIC_PROXY_LOG_DIR`), and our
340
+ translator writes to exactly that path with the expected `proxy-YYYY-MM-DD.ndjson`
341
+ filename convention. The dashboard's tolerant ingestion layer ignores unknown
342
+ fields, so interceptor-specific extras (`ttl_tier`, `ephemeral_1h_input_tokens`,
343
+ `ephemeral_5m_input_tokens`, `peak_hour`, quota state) pass through cleanly
344
+ and remain available to downstream consumers that know to read them.
345
+
346
+ The `cost_factor` metric in `tools/cost-report.mjs` also comes from
347
+ fgrosswig's methodology — the `(input + output + cache_read + cache_creation) / output`
348
+ ratio that gives a single-number measure of how much context is being paid
349
+ per useful output token. A rising cost factor across a long session is the
350
+ measurable signature of cache-efficiency degradation.
306
351
 
307
352
  ## Used in production
308
353
 
@@ -316,6 +361,7 @@ Snapshots are saved to `~/.claude/cache-fix-snapshots/` and diff reports are gen
316
361
  - **[@cnighswonger](https://github.com/cnighswonger)** — Fingerprint stabilization, tool ordering fix, image stripping, monitoring features, overage TTL downgrade discovery, package maintainer
317
362
  - **[@ArkNill](https://github.com/ArkNill)** — Microcompact mechanism analysis, GrowthBook flag documentation, false rate limiter identification
318
363
  - **[@Renvect](https://github.com/Renvect)** — Image duplication discovery, cross-project directory contamination analysis
364
+ - **[@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
319
365
 
320
366
  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
367
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-cache-fix",
3
- "version": "1.6.4",
3
+ "version": "1.7.0",
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",
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).
@@ -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,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
+ }