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 +46 -0
- package/package.json +1 -1
- package/preload.mjs +44 -0
- package/tools/cost-report.mjs +31 -0
- package/tools/sim-cost-reconcile.sh +60 -0
- package/tools/usage-to-dashboard-ndjson.mjs +352 -0
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
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,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
|
+
}
|