From 454ed0e357d35af4f4c10d5aa5689c7fa426386c Mon Sep 17 00:00:00 2001 From: itscooleric Date: Mon, 16 Mar 2026 22:50:14 +0000 Subject: [PATCH] feat: token/cost tracking per agent session (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses Claude Code conversation JSONL for token usage data and adds it to the session_end event in events.jsonl: - input_tokens, output_tokens, total_tokens - estimated_cost_usd (per-model pricing: Opus, Sonnet, Haiku) - turns count New script: scripts/token-cost.py — standalone parser, can also be run manually on any conversation.jsonl file. Closes #43 Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 3 +- scripts/session-logger.sh | 26 +++++++++ scripts/token-cost.py | 120 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100755 scripts/token-cost.py diff --git a/Dockerfile b/Dockerfile index 68b2228..77dc5ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,7 +90,8 @@ COPY claude-entrypoint.sh /usr/local/bin/claude-entrypoint.sh COPY firewall.sh /usr/local/bin/firewall.sh COPY scripts/session-logger.sh /usr/local/bin/session-logger.sh COPY scripts/notify.sh /usr/local/bin/notify.sh -RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/claude-entrypoint.sh /usr/local/bin/firewall.sh /usr/local/bin/session-logger.sh /usr/local/bin/notify.sh +COPY scripts/token-cost.py /usr/local/bin/token-cost.py +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/claude-entrypoint.sh /usr/local/bin/firewall.sh /usr/local/bin/session-logger.sh /usr/local/bin/notify.sh /usr/local/bin/token-cost.py # Default CLAUDE.md template — seeded into /workspace on first run if none exists COPY CLAUDE.md.template /usr/local/share/clide/CLAUDE.md.template diff --git a/scripts/session-logger.sh b/scripts/session-logger.sh index 1b3204c..0cf6035 100755 --- a/scripts/session-logger.sh +++ b/scripts/session-logger.sh @@ -273,6 +273,32 @@ except: pass fi if [[ -f "${SESSION_DIR}/conversation.jsonl" ]]; then _end_args+=("has_conversation=true") + + # ── Token / cost tracking ────────────────────────────────────── + # Parse the conversation log for token counts and estimated cost. + # Uses the Python script for reliable JSON parsing + pricing math. + COST_SCRIPT="$(dirname "$0")/token-cost.py" + if [[ ! -x "$COST_SCRIPT" ]]; then + COST_SCRIPT="/usr/local/bin/token-cost.py" + fi + if [[ -x "$COST_SCRIPT" || -f "$COST_SCRIPT" ]]; then + _cost_json=$(python3 "$COST_SCRIPT" "${SESSION_DIR}/conversation.jsonl" 2>/dev/null || echo '{}') + if [[ -n "$_cost_json" && "$_cost_json" != "{}" ]]; then + # Extract fields and add to session_end args + _input_tokens=$(echo "$_cost_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('input_tokens',0))" 2>/dev/null || echo 0) + _output_tokens=$(echo "$_cost_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('output_tokens',0))" 2>/dev/null || echo 0) + _total_tokens=$(echo "$_cost_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('total_tokens',0))" 2>/dev/null || echo 0) + _cost_usd=$(echo "$_cost_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('estimated_cost_usd',0))" 2>/dev/null || echo 0) + _turns=$(echo "$_cost_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('turns',0))" 2>/dev/null || echo 0) + + _end_args+=("input_tokens=${_input_tokens}") + _end_args+=("output_tokens=${_output_tokens}") + _end_args+=("total_tokens=${_total_tokens}") + _end_args+=("estimated_cost_usd=${_cost_usd}") + _end_args+=("turns=${_turns}") + echo "[session-logger] Tokens: ${_input_tokens} in / ${_output_tokens} out (${_total_tokens} total) — \$${_cost_usd}" + fi + fi fi emit_event "session_end" "${_end_args[@]}" diff --git a/scripts/token-cost.py b/scripts/token-cost.py new file mode 100755 index 0000000..7641eb7 --- /dev/null +++ b/scripts/token-cost.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Parse a Claude Code conversation JSONL and output token/cost summary. + +Usage: + token-cost.py + +Outputs a JSON object with: + input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, + total_tokens, estimated_cost_usd, model, turns + +Pricing (per million tokens, as of 2026-03): + Claude Sonnet 4: input $3, output $15, cache_write $3.75, cache_read $0.30 + Claude Opus 4: input $15, output $75, cache_write $18.75, cache_read $1.50 + Claude Haiku 3.5: input $0.80, output $4, cache_write $1, cache_read $0.08 +""" + +import json +import sys +from pathlib import Path + +# Pricing per million tokens +PRICING = { + "claude-sonnet-4-20250514": { + "input": 3.0, "output": 15.0, + "cache_write": 3.75, "cache_read": 0.30, + }, + "claude-opus-4-20250514": { + "input": 15.0, "output": 75.0, + "cache_write": 18.75, "cache_read": 1.50, + }, + "claude-haiku-3-5-20241022": { + "input": 0.80, "output": 4.0, + "cache_write": 1.0, "cache_read": 0.08, + }, +} + +# Default to sonnet pricing if model unknown +DEFAULT_PRICING = PRICING["claude-sonnet-4-20250514"] + + +def parse_conversation(path: str) -> dict: + """Parse conversation JSONL and return token/cost summary.""" + input_tokens = 0 + output_tokens = 0 + cache_creation = 0 + cache_read = 0 + model = "" + turns = 0 + + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + + # Extract model from summary or first assistant message + if not model: + if obj.get("type") == "summary": + model = obj.get("model", "") + elif obj.get("type") == "assistant": + model = obj.get("message", {}).get("model", "") + + # Count turns + if obj.get("type") in ("user", "human"): + turns += 1 + + # Extract usage from assistant messages + if obj.get("type") == "assistant": + usage = obj.get("message", {}).get("usage", {}) + if usage: + input_tokens += usage.get("input_tokens", 0) + output_tokens += usage.get("output_tokens", 0) + cache_creation += usage.get("cache_creation_input_tokens", 0) + cache_read += usage.get("cache_read_input_tokens", 0) + + # Calculate cost — match model family by keyword + pricing = DEFAULT_PRICING + model_lower = model.lower() + if "opus" in model_lower: + pricing = PRICING["claude-opus-4-20250514"] + elif "haiku" in model_lower: + pricing = PRICING["claude-haiku-3-5-20241022"] + elif "sonnet" in model_lower: + pricing = PRICING["claude-sonnet-4-20250514"] + + cost = ( + (input_tokens / 1_000_000) * pricing["input"] + + (output_tokens / 1_000_000) * pricing["output"] + + (cache_creation / 1_000_000) * pricing["cache_write"] + + (cache_read / 1_000_000) * pricing["cache_read"] + ) + + return { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_creation_tokens": cache_creation, + "cache_read_tokens": cache_read, + "total_tokens": input_tokens + output_tokens + cache_creation + cache_read, + "estimated_cost_usd": round(cost, 6), + "model": model or "unknown", + "turns": turns, + } + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + path = sys.argv[1] + if not Path(path).exists(): + print(json.dumps({"error": f"File not found: {path}"})) + sys.exit(1) + + result = parse_conversation(path) + print(json.dumps(result))