chore: add model-usage skill

This commit is contained in:
Peter Steinberger
2026-01-05 18:16:29 +01:00
parent 110e2255c4
commit f0abd619be
4 changed files with 385 additions and 0 deletions

View File

@@ -24,6 +24,8 @@
### Maintenance ### Maintenance
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome. - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
- Skills: add CodexBar model usage helper with macOS requirement metadata.
- Lint: organize imports and wrap long lines in reply commands.
## 2026.1.5-3 ## 2026.1.5-3

View File

@@ -0,0 +1,45 @@
---
name: model-usage
description: Use CodexBar CLI local cost usage to summarize per-model usage for Codex or Claude, including the current (most recent) model or a full model breakdown. Trigger when asked for model-level usage/cost data from codexbar, or when you need a scriptable per-model summary from codexbar cost JSON.
metadata: {"clawdbot":{"emoji":"📊","os":["darwin"],"requires":{"bins":["codexbar"]},"install":[{"id":"brew-cask","kind":"brew","cask":"steipete/tap/codexbar","bins":["codexbar"],"label":"Install CodexBar (brew cask)"}]}}
---
# Model usage
## Overview
Get per-model usage cost from CodexBar's local cost logs. Supports "current model" (most recent daily entry) or "all models" summaries for Codex or Claude.
TODO: add Linux CLI support guidance once CodexBar CLI install path is documented for Linux.
## Quick start
1) Fetch cost JSON via CodexBar CLI or pass a JSON file.
2) Use the bundled script to summarize by model.
```bash
python {baseDir}/scripts/model_usage.py --provider codex --mode current
python {baseDir}/scripts/model_usage.py --provider codex --mode all
python {baseDir}/scripts/model_usage.py --provider claude --mode all --format json --pretty
```
## Current model logic
- Uses the most recent daily row with `modelBreakdowns`.
- Picks the model with the highest cost in that row.
- Falls back to the last entry in `modelsUsed` when breakdowns are missing.
- Override with `--model <name>` when you need a specific model.
## Inputs
- Default: runs `codexbar cost --format json --provider <codex|claude>`.
- File or stdin:
```bash
codexbar cost --provider codex --format json > /tmp/cost.json
python {baseDir}/scripts/model_usage.py --input /tmp/cost.json --mode all
cat /tmp/cost.json | python {baseDir}/scripts/model_usage.py --input - --mode current
```
## Output
- Text (default) or JSON (`--format json --pretty`).
- Values are cost-only per model; tokens are not split by model in CodexBar output.
## References
- Read `references/codexbar-cli.md` for CLI flags and cost JSON fields.

View File

@@ -0,0 +1,28 @@
# CodexBar CLI quick ref (usage + cost)
## Install
- App: Preferences -> Advanced -> Install CLI
- Repo: ./bin/install-codexbar-cli.sh
## Commands
- Usage snapshot (web/cli sources):
- codexbar usage --format json --pretty
- codexbar --provider all --format json
- Local cost usage (Codex + Claude only):
- codexbar cost --format json --pretty
- codexbar cost --provider codex|claude --format json
## Cost JSON fields
The payload is an array (one per provider).
- provider, source, updatedAt
- sessionTokens, sessionCostUSD
- last30DaysTokens, last30DaysCostUSD
- daily[]: date, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns[]
- modelBreakdowns[]: modelName, cost
- totals: totalInputTokens, totalOutputTokens, cacheReadTokens, cacheCreationTokens, totalTokens, totalCost
## Notes
- Cost usage is local-only. It reads JSONL logs under:
- Codex: ~/.codex/sessions/**/*.jsonl
- Claude: ~/.config/claude/projects/**/*.jsonl or ~/.claude/projects/**/*.jsonl
- If web usage is required (non-local), use codexbar usage (not cost).

View File

@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
Summarize CodexBar local cost usage by model.
Defaults to current model (most recent daily entry), or list all models.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any, Dict, Iterable, List, Optional, Tuple
def eprint(msg: str) -> None:
print(msg, file=sys.stderr)
def run_codexbar_cost(provider: str) -> List[Dict[str, Any]]:
cmd = ["codexbar", "cost", "--format", "json", "--provider", provider]
try:
output = subprocess.check_output(cmd, text=True)
except FileNotFoundError:
raise RuntimeError("codexbar not found on PATH. Install CodexBar CLI first.")
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"codexbar cost failed (exit {exc.returncode}).")
try:
payload = json.loads(output)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Failed to parse codexbar JSON output: {exc}")
if not isinstance(payload, list):
raise RuntimeError("Expected codexbar cost JSON array.")
return payload
def load_payload(input_path: Optional[str], provider: str) -> Dict[str, Any]:
if input_path:
if input_path == "-":
raw = sys.stdin.read()
else:
with open(input_path, "r", encoding="utf-8") as handle:
raw = handle.read()
data = json.loads(raw)
else:
data = run_codexbar_cost(provider)
if isinstance(data, dict):
return data
if isinstance(data, list):
for entry in data:
if isinstance(entry, dict) and entry.get("provider") == provider:
return entry
raise RuntimeError(f"Provider '{provider}' not found in codexbar payload.")
raise RuntimeError("Unsupported JSON input format.")
@dataclass
class ModelCost:
model: str
cost: float
def parse_daily_entries(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
daily = payload.get("daily")
if not daily:
return []
if not isinstance(daily, list):
return []
return [entry for entry in daily if isinstance(entry, dict)]
def parse_date(value: str) -> Optional[date]:
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except Exception:
return None
def filter_by_days(entries: List[Dict[str, Any]], days: Optional[int]) -> List[Dict[str, Any]]:
if not days:
return entries
cutoff = date.today() - timedelta(days=days - 1)
filtered: List[Dict[str, Any]] = []
for entry in entries:
day = entry.get("date")
if not isinstance(day, str):
continue
parsed = parse_date(day)
if parsed and parsed >= cutoff:
filtered.append(entry)
return filtered
def aggregate_costs(entries: Iterable[Dict[str, Any]]) -> Dict[str, float]:
totals: Dict[str, float] = {}
for entry in entries:
breakdowns = entry.get("modelBreakdowns")
if not breakdowns:
continue
if not isinstance(breakdowns, list):
continue
for item in breakdowns:
if not isinstance(item, dict):
continue
model = item.get("modelName")
cost = item.get("cost")
if not isinstance(model, str):
continue
if not isinstance(cost, (int, float)):
continue
totals[model] = totals.get(model, 0.0) + float(cost)
return totals
def pick_current_model(entries: List[Dict[str, Any]]) -> Tuple[Optional[str], Optional[str]]:
if not entries:
return None, None
sorted_entries = sorted(
entries,
key=lambda entry: entry.get("date") or "",
)
for entry in reversed(sorted_entries):
breakdowns = entry.get("modelBreakdowns")
if isinstance(breakdowns, list) and breakdowns:
scored: List[ModelCost] = []
for item in breakdowns:
if not isinstance(item, dict):
continue
model = item.get("modelName")
cost = item.get("cost")
if isinstance(model, str) and isinstance(cost, (int, float)):
scored.append(ModelCost(model=model, cost=float(cost)))
if scored:
scored.sort(key=lambda item: item.cost, reverse=True)
return scored[0].model, entry.get("date") if isinstance(entry.get("date"), str) else None
models_used = entry.get("modelsUsed")
if isinstance(models_used, list) and models_used:
last = models_used[-1]
if isinstance(last, str):
return last, entry.get("date") if isinstance(entry.get("date"), str) else None
return None, None
def usd(value: Optional[float]) -> str:
if value is None:
return ""
return f"${value:,.2f}"
def latest_day_cost(entries: List[Dict[str, Any]], model: str) -> Tuple[Optional[str], Optional[float]]:
if not entries:
return None, None
sorted_entries = sorted(
entries,
key=lambda entry: entry.get("date") or "",
)
for entry in reversed(sorted_entries):
breakdowns = entry.get("modelBreakdowns")
if not isinstance(breakdowns, list):
continue
for item in breakdowns:
if not isinstance(item, dict):
continue
if item.get("modelName") == model:
cost = item.get("cost") if isinstance(item.get("cost"), (int, float)) else None
day = entry.get("date") if isinstance(entry.get("date"), str) else None
return day, float(cost) if cost is not None else None
return None, None
def render_text_current(
provider: str,
model: str,
latest_date: Optional[str],
total_cost: Optional[float],
latest_cost: Optional[float],
latest_cost_date: Optional[str],
entry_count: int,
) -> str:
lines = [f"Provider: {provider}", f"Current model: {model}"]
if latest_date:
lines.append(f"Latest model date: {latest_date}")
lines.append(f"Total cost (rows): {usd(total_cost)}")
if latest_cost_date:
lines.append(f"Latest day cost: {usd(latest_cost)} ({latest_cost_date})")
lines.append(f"Daily rows: {entry_count}")
return "\n".join(lines)
def render_text_all(provider: str, totals: Dict[str, float]) -> str:
lines = [f"Provider: {provider}", "Models:"]
for model, cost in sorted(totals.items(), key=lambda item: item[1], reverse=True):
lines.append(f"- {model}: {usd(cost)}")
return "\n".join(lines)
def build_json_current(
provider: str,
model: str,
latest_date: Optional[str],
total_cost: Optional[float],
latest_cost: Optional[float],
latest_cost_date: Optional[str],
entry_count: int,
) -> Dict[str, Any]:
return {
"provider": provider,
"mode": "current",
"model": model,
"latestModelDate": latest_date,
"totalCostUSD": total_cost,
"latestDayCostUSD": latest_cost,
"latestDayCostDate": latest_cost_date,
"dailyRowCount": entry_count,
}
def build_json_all(provider: str, totals: Dict[str, float]) -> Dict[str, Any]:
return {
"provider": provider,
"mode": "all",
"models": [
{"model": model, "totalCostUSD": cost}
for model, cost in sorted(totals.items(), key=lambda item: item[1], reverse=True)
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="Summarize CodexBar model usage from local cost logs.")
parser.add_argument("--provider", choices=["codex", "claude"], default="codex")
parser.add_argument("--mode", choices=["current", "all"], default="current")
parser.add_argument("--model", help="Explicit model name to report instead of auto-current.")
parser.add_argument("--input", help="Path to codexbar cost JSON (or '-' for stdin).")
parser.add_argument("--days", type=int, help="Limit to last N days (based on daily rows).")
parser.add_argument("--format", choices=["text", "json"], default="text")
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output.")
args = parser.parse_args()
try:
payload = load_payload(args.input, args.provider)
except Exception as exc:
eprint(str(exc))
return 1
entries = parse_daily_entries(payload)
entries = filter_by_days(entries, args.days)
if args.mode == "current":
model = args.model
latest_date = None
if not model:
model, latest_date = pick_current_model(entries)
if not model:
eprint("No model data found in codexbar cost payload.")
return 2
totals = aggregate_costs(entries)
total_cost = totals.get(model)
latest_cost_date, latest_cost = latest_day_cost(entries, model)
if args.format == "json":
payload_out = build_json_current(
provider=args.provider,
model=model,
latest_date=latest_date,
total_cost=total_cost,
latest_cost=latest_cost,
latest_cost_date=latest_cost_date,
entry_count=len(entries),
)
indent = 2 if args.pretty else None
print(json.dumps(payload_out, indent=indent, sort_keys=args.pretty))
else:
print(
render_text_current(
provider=args.provider,
model=model,
latest_date=latest_date,
total_cost=total_cost,
latest_cost=latest_cost,
latest_cost_date=latest_cost_date,
entry_count=len(entries),
)
)
return 0
totals = aggregate_costs(entries)
if not totals:
eprint("No model breakdowns found in codexbar cost payload.")
return 2
if args.format == "json":
payload_out = build_json_all(provider=args.provider, totals=totals)
indent = 2 if args.pretty else None
print(json.dumps(payload_out, indent=indent, sort_keys=args.pretty))
else:
print(render_text_all(provider=args.provider, totals=totals))
return 0
if __name__ == "__main__":
raise SystemExit(main())