Files
wehub-resource-sync 0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:20 +08:00

190 lines
5.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Parse Claude Code transcript JSONL files for per-window token breakdowns.
Mirrors the approach in the ClaudeCacheTTLStatusLine TypeScript reference
implementation (session-tracking.ts). Reads ~/.claude/projects/**/*.jsonl
and aggregates token usage for entries whose timestamp falls within a window.
Model weights (Sonnet-normalised, empirical estimates):
opus: 2.0× (higher rate-limit cost)
sonnet: 1.0× (baseline)
haiku: 0.5× (cheaper, lower rate-limit cost)
The weighted_token_equivalent lets callers detect surge pricing by comparing
it against the API-reported utilisation × window_limit.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any
from headroom.subscription.models import WindowTokens
logger = logging.getLogger(__name__)
# Maximum bytes to read per transcript file (10 MB cap — generous, typical files <1 MB)
_MAX_FILE_BYTES = 10 * 1024 * 1024
# Sonnet-normalised model family weights
MODEL_FAMILY_WEIGHTS: dict[str, float] = {
"opus": 2.0,
"sonnet": 1.0,
"haiku": 0.5,
}
DEFAULT_MODEL_WEIGHT: float = 1.0
def _claude_config_dir() -> Path:
base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
return Path(base)
def get_model_weight(model_id: str) -> float:
"""Return the Sonnet-normalised weight for a model ID.
Matches against known family names using a word-boundary check.
Falls back to DEFAULT_MODEL_WEIGHT for unrecognised models.
"""
lower = model_id.lower()
import re
for family, weight in MODEL_FAMILY_WEIGHTS.items():
if re.search(rf"(?<![a-z]){family}(?![a-z])", lower):
return weight
return DEFAULT_MODEL_WEIGHT
def find_transcript_files() -> list[Path]:
"""Return all .jsonl files under ~/.claude/projects."""
projects = _claude_config_dir() / "projects"
results: list[Path] = []
_walk_jsonl(projects, results)
return results
def _walk_jsonl(directory: Path, results: list[Path]) -> None:
try:
entries = list(directory.iterdir())
except (OSError, PermissionError):
return
for entry in entries:
try:
if entry.is_dir():
_walk_jsonl(entry, results)
elif entry.suffix == ".jsonl":
results.append(entry)
except OSError:
continue
def _read_transcript_lines(path: Path) -> list[str]:
try:
size = path.stat().st_size
read_size = min(size, _MAX_FILE_BYTES)
with path.open("rb") as fh:
raw = fh.read(read_size)
return [line for line in raw.decode("utf-8", errors="replace").splitlines() if line.strip()]
except Exception:
return []
def _add_usage_to_tokens(dest: WindowTokens, usage: dict[str, Any]) -> None:
dest.input += int(usage.get("input_tokens") or 0)
dest.output += int(usage.get("output_tokens") or 0)
dest.cache_reads += int(usage.get("cache_read_input_tokens") or 0)
cache_creation = usage.get("cache_creation") or {}
w5m = int(cache_creation.get("ephemeral_5m_input_tokens") or 0)
w1h = int(cache_creation.get("ephemeral_1h_input_tokens") or 0)
total_writes = int(usage.get("cache_creation_input_tokens") or (w5m + w1h))
dest.cache_writes_5m += w5m
dest.cache_writes_1h += w1h
dest.cache_writes_total += total_writes
def compute_window_tokens(start_ts: float, end_ts: float) -> WindowTokens:
"""Sum transcript token usage for entries in [start_ts, end_ts).
Args:
start_ts: Window start as a Unix timestamp (seconds).
end_ts: Window end as a Unix timestamp (seconds).
Returns:
:class:`WindowTokens` with aggregate + per-model breakdown and
``weighted_token_equivalent`` (Sonnet-normalised).
"""
totals = WindowTokens()
by_model: dict[str, WindowTokens] = {}
unattributed = WindowTokens()
for path in find_transcript_files():
for line in _read_transcript_lines(path):
try:
entry: dict[str, Any] = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
ts_str = entry.get("timestamp")
if not ts_str:
continue
try:
from datetime import datetime
dt = datetime.fromisoformat(str(ts_str).replace("Z", "+00:00"))
ts = dt.timestamp()
except (ValueError, TypeError):
continue
if ts < start_ts or ts >= end_ts:
continue
msg = entry.get("message") or {}
usage = msg.get("usage")
if not usage:
continue
_add_usage_to_tokens(totals, usage)
model_id: str | None = msg.get("model")
if model_id:
if model_id not in by_model:
by_model[model_id] = WindowTokens()
_add_usage_to_tokens(by_model[model_id], usage)
else:
_add_usage_to_tokens(unattributed, usage)
# Compute Sonnet-normalised weighted equivalent
model_weights: dict[str, float] = {}
weighted = 0.0
for model_id, model_tokens in by_model.items():
w = get_model_weight(model_id)
model_weights[model_id] = w
weighted += _total_token_count(model_tokens) * w
weighted += _total_token_count(unattributed) * DEFAULT_MODEL_WEIGHT
totals.weighted_token_equivalent = weighted
totals.by_model = {mid: _window_tokens_to_dict(mt) for mid, mt in by_model.items()}
return totals
def _total_token_count(t: WindowTokens) -> int:
return t.input + t.output + t.cache_reads + t.cache_writes_total
def _window_tokens_to_dict(t: WindowTokens) -> dict[str, int]:
return {
"input": t.input,
"output": t.output,
"cache_reads": t.cache_reads,
"cache_writes_5m": t.cache_writes_5m,
"cache_writes_1h": t.cache_writes_1h,
"cache_writes_total": t.cache_writes_total,
}