From f0abd619be69dcda8b4ab5ae4aa05b5be0a69e2d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 18:16:29 +0100 Subject: [PATCH] chore: add model-usage skill --- CHANGELOG.md | 2 + skills/model-usage/SKILL.md | 45 +++ skills/model-usage/references/codexbar-cli.md | 28 ++ skills/model-usage/scripts/model_usage.py | 310 ++++++++++++++++++ 4 files changed, 385 insertions(+) create mode 100644 skills/model-usage/SKILL.md create mode 100644 skills/model-usage/references/codexbar-cli.md create mode 100644 skills/model-usage/scripts/model_usage.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cbed0e10..022b55f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ ### Maintenance - 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 diff --git a/skills/model-usage/SKILL.md b/skills/model-usage/SKILL.md new file mode 100644 index 000000000..ed8871dea --- /dev/null +++ b/skills/model-usage/SKILL.md @@ -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 ` when you need a specific model. + +## Inputs +- Default: runs `codexbar cost --format json --provider `. +- 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. diff --git a/skills/model-usage/references/codexbar-cli.md b/skills/model-usage/references/codexbar-cli.md new file mode 100644 index 000000000..a41db4d32 --- /dev/null +++ b/skills/model-usage/references/codexbar-cli.md @@ -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). diff --git a/skills/model-usage/scripts/model_usage.py b/skills/model-usage/scripts/model_usage.py new file mode 100644 index 000000000..0b71f96ea --- /dev/null +++ b/skills/model-usage/scripts/model_usage.py @@ -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())