Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions scripts/session-logger.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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[@]}"

Expand Down
120 changes: 120 additions & 0 deletions scripts/token-cost.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Parse a Claude Code conversation JSONL and output token/cost summary.

Usage:
token-cost.py <conversation.jsonl>

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]} <conversation.jsonl>", 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))
Loading