smlflg's picture
Initial public upload from Projekte/Codex_Usage_Profil
10b5888 verified
Raw History Blame Contribute Delete
13.1 kB
from __future__ import annotations
import json
import sqlite3
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable, Iterable
from urllib.parse import quote
from zoneinfo import ZoneInfo
@dataclass(slots=True)
class TokenBreakdown:
input_tokens: int = 0
cached_input_tokens: int = 0
output_tokens: int = 0
reasoning_output_tokens: int = 0
total_tokens: int = 0
@classmethod
def from_usage(cls, usage: dict[str, Any]) -> "TokenBreakdown":
return cls(
input_tokens=_int_value(usage.get("input_tokens")),
cached_input_tokens=_int_value(usage.get("cached_input_tokens")),
output_tokens=_int_value(usage.get("output_tokens")),
reasoning_output_tokens=_int_value(usage.get("reasoning_output_tokens")),
total_tokens=_int_value(usage.get("total_tokens")),
)
def add(self, other: "TokenBreakdown | None") -> None:
if other is None:
return
self.input_tokens += other.input_tokens
self.cached_input_tokens += other.cached_input_tokens
self.output_tokens += other.output_tokens
self.reasoning_output_tokens += other.reasoning_output_tokens
self.total_tokens += other.total_tokens
@dataclass(slots=True)
class RolloutStats:
path: Path | None
found: bool
token_breakdown: TokenBreakdown | None = None
model: str | None = None
cwd: str | None = None
turn_count: int = 0
malformed_lines: int = 0
warnings: list[str] = field(default_factory=list)
@dataclass(slots=True)
class SessionUsage:
thread_id: str
created_at: datetime
updated_at: datetime
cwd: str
model: str
model_provider: str
role: str
agent_role: str
agent_nickname: str
kind: str
parent_thread_id: str | None
title: str
tokens: int
rollout_path: Path | None
rollout_found: bool
rollout_breakdown: TokenBreakdown | None
turn_count: int
@property
def week_label(self) -> str:
year, week, _ = self.created_at.isocalendar()
return f"{year}-W{week:02d}"
@property
def day_label(self) -> str:
return self.created_at.date().isoformat()
@dataclass(slots=True)
class Bucket:
label: str
sessions: int = 0
tokens: int = 0
rollout: TokenBreakdown = field(default_factory=TokenBreakdown)
def add(self, session: SessionUsage) -> None:
self.sessions += 1
self.tokens += session.tokens
self.rollout.add(session.rollout_breakdown)
@dataclass(slots=True)
class UsageReport:
generated_at: datetime
timezone_name: str
state_path: Path
sessions_dir: Path
sessions: list[SessionUsage]
daily: list[Bucket]
weekly: list[Bucket]
by_cwd: list[Bucket]
by_model: list[Bucket]
by_role: list[Bucket]
by_kind: list[Bucket]
top_sessions: list[SessionUsage]
rollout_totals: TokenBreakdown
warnings: list[str]
days_filter: int | None = None
@property
def total_tokens(self) -> int:
return sum(session.tokens for session in self.sessions)
@property
def parsed_rollout_count(self) -> int:
return sum(1 for session in self.sessions if session.rollout_breakdown is not None)
@property
def missing_rollout_count(self) -> int:
return sum(1 for session in self.sessions if not session.rollout_found)
@property
def date_range_label(self) -> str:
if not self.sessions:
return "n/a"
first = min(session.created_at for session in self.sessions)
last = max(session.created_at for session in self.sessions)
return f"{first.date().isoformat()} to {last.date().isoformat()}"
def build_report(
*,
state_path: Path,
sessions_dir: Path,
tz_name: str,
days: int | None = None,
top: int = 20,
now: datetime | None = None,
) -> UsageReport:
tz = ZoneInfo(tz_name)
generated_at = (now or datetime.now(tz)).astimezone(tz)
rows, child_to_parent = _load_threads(state_path)
sessions: list[SessionUsage] = []
warnings: list[str] = []
if days is not None and days <= 0:
raise ValueError("--days must be greater than zero")
cutoff_date = None
if days is not None:
cutoff_date = generated_at.date() - timedelta(days=days - 1)
for row in rows:
created_at = _local_datetime(row.get("created_at_ms"), row.get("created_at"), tz)
if cutoff_date is not None and created_at.date() < cutoff_date:
continue
updated_at = _local_datetime(row.get("updated_at_ms"), row.get("updated_at"), tz)
thread_id = str(row["id"])
rollout_path = _resolve_rollout_path(row.get("rollout_path"), sessions_dir, thread_id)
rollout = parse_rollout(rollout_path)
warnings.extend(rollout.warnings)
parent_thread_id = child_to_parent.get(thread_id)
kind = "subagent" if parent_thread_id else "main"
agent_role = str(row.get("agent_role") or "")
role = agent_role or ("main" if kind == "main" else "unknown")
model_provider = str(row.get("model_provider") or "unknown")
model = str(row.get("model") or rollout.model or "unknown")
cwd = str(row.get("cwd") or rollout.cwd or "unknown")
sessions.append(
SessionUsage(
thread_id=thread_id,
created_at=created_at,
updated_at=updated_at,
cwd=cwd,
model=model,
model_provider=model_provider,
role=role,
agent_role=agent_role,
agent_nickname=str(row.get("agent_nickname") or ""),
kind=kind,
parent_thread_id=parent_thread_id,
title=str(row.get("title") or ""),
tokens=_int_value(row.get("tokens_used")),
rollout_path=rollout.path,
rollout_found=rollout.found,
rollout_breakdown=rollout.token_breakdown,
turn_count=rollout.turn_count,
)
)
sessions.sort(key=lambda session: (session.created_at, session.thread_id))
rollout_totals = TokenBreakdown()
for session in sessions:
rollout_totals.add(session.rollout_breakdown)
return UsageReport(
generated_at=generated_at,
timezone_name=tz_name,
state_path=Path(state_path).expanduser(),
sessions_dir=Path(sessions_dir).expanduser(),
sessions=sessions,
daily=_group_sessions(sessions, lambda session: session.day_label, sort_by_label=True),
weekly=_group_sessions(sessions, lambda session: session.week_label, sort_by_label=True),
by_cwd=_group_sessions(sessions, lambda session: _compact_path(session.cwd)),
by_model=_group_sessions(sessions, lambda session: session.model or "unknown"),
by_role=_group_sessions(sessions, lambda session: session.role or "unknown"),
by_kind=_group_sessions(sessions, lambda session: session.kind),
top_sessions=sorted(sessions, key=lambda session: session.tokens, reverse=True)[:top],
rollout_totals=rollout_totals,
warnings=warnings,
days_filter=days,
)
def parse_rollout(path: Path | None) -> RolloutStats:
if path is None:
return RolloutStats(path=None, found=False, warnings=["No rollout path for a session"])
expanded = path.expanduser()
if not expanded.exists():
return RolloutStats(
path=expanded,
found=False,
warnings=[f"Missing rollout file: {expanded}"],
)
stats = RolloutStats(path=expanded, found=True)
try:
with expanded.open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, start=1):
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
stats.malformed_lines += 1
continue
_read_rollout_event(stats, event)
except OSError as exc:
stats.found = False
stats.warnings.append(f"Could not read rollout file {expanded}: {exc}")
return stats
if stats.malformed_lines:
stats.warnings.append(
f"Malformed JSONL lines in {expanded}: {stats.malformed_lines}"
)
return stats
def _read_rollout_event(stats: RolloutStats, event: dict[str, Any]) -> None:
event_type = event.get("type")
payload = event.get("payload")
if not isinstance(payload, dict):
payload = {}
if event_type == "session_meta":
stats.cwd = stats.cwd or _string_or_none(payload.get("cwd"))
return
if event_type == "turn_context":
stats.model = stats.model or _string_or_none(payload.get("model"))
stats.cwd = stats.cwd or _string_or_none(payload.get("cwd"))
return
if event_type != "event_msg":
return
payload_type = payload.get("type")
if payload_type == "task_started":
stats.turn_count += 1
return
if payload_type != "token_count":
return
info = payload.get("info")
if not isinstance(info, dict):
return
usage = info.get("total_token_usage")
if isinstance(usage, dict):
stats.token_breakdown = TokenBreakdown.from_usage(usage)
def _load_threads(state_path: Path) -> tuple[list[dict[str, Any]], dict[str, str]]:
expanded = Path(state_path).expanduser()
if not expanded.exists():
raise FileNotFoundError(f"State database not found: {expanded}")
uri = f"file:{quote(str(expanded), safe='/')}?mode=ro"
with sqlite3.connect(uri, uri=True) as conn:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA query_only = ON")
rows = [
dict(row)
for row in conn.execute(
"""
SELECT
id,
rollout_path,
created_at,
updated_at,
created_at_ms,
updated_at_ms,
model_provider,
cwd,
title,
tokens_used,
agent_nickname,
agent_role,
model
FROM threads
ORDER BY created_at_ms ASC, id ASC
"""
)
]
edges = [
dict(edge)
for edge in conn.execute(
"""
SELECT parent_thread_id, child_thread_id
FROM thread_spawn_edges
"""
)
]
return rows, {str(edge["child_thread_id"]): str(edge["parent_thread_id"]) for edge in edges}
def _resolve_rollout_path(
rollout_path: Any, sessions_dir: Path, thread_id: str
) -> Path | None:
sessions_root = Path(sessions_dir).expanduser()
if rollout_path:
candidate = Path(str(rollout_path)).expanduser()
if not candidate.is_absolute():
candidate = sessions_root / candidate
if candidate.exists():
return candidate
if sessions_root.exists():
matches = sorted(sessions_root.rglob(f"*{thread_id}.jsonl"))
if matches:
return matches[0]
if rollout_path:
return candidate
return None
def _group_sessions(
sessions: Iterable[SessionUsage],
key: Callable[[SessionUsage], str],
*,
sort_by_label: bool = False,
) -> list[Bucket]:
buckets: dict[str, Bucket] = {}
for session in sessions:
label = key(session)
bucket = buckets.setdefault(label, Bucket(label=label))
bucket.add(session)
values = list(buckets.values())
if sort_by_label:
values.sort(key=lambda bucket: bucket.label)
else:
values.sort(key=lambda bucket: bucket.tokens, reverse=True)
return values
def _local_datetime(ms_value: Any, seconds_value: Any, tz: ZoneInfo) -> datetime:
if ms_value is not None:
timestamp = _int_value(ms_value) / 1000
else:
timestamp = _int_value(seconds_value)
return datetime.fromtimestamp(timestamp, timezone.utc).astimezone(tz)
def _compact_path(value: str) -> str:
if not value:
return "unknown"
try:
path = Path(value).expanduser()
home = Path.home()
if path.is_absolute():
relative = path.relative_to(home)
return "~" if str(relative) == "." else "~/" + str(relative)
return str(path)
except ValueError:
return value
def _int_value(value: Any) -> int:
if value is None:
return 0
try:
return int(value)
except (TypeError, ValueError):
return 0
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value)
return text if text else None