From f4935860f1286dd7adae12838e90df8b28058cd3 Mon Sep 17 00:00:00 2001 From: Lorenzo Cancellara Date: Wed, 5 Aug 2026 18:59:49 +0200 Subject: [PATCH] feat(cli,db,render): add row filters, cost sorting, run-rate projection, and CSV output --- README.md | 15 ++++- src/opencode_usage/cli.py | 119 ++++++++++++++++++++++++++++++----- src/opencode_usage/db.py | 71 +++++++++++++++++++++ src/opencode_usage/render.py | 42 +++++++++++++ tests/test_cli.py | 58 +++++++++++++++++ tests/test_db.py | 51 ++++++++++++++- 6 files changed, 339 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d313e27..e391c7e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ CLI tool to track and display [OpenCode](https://github.com/opencodeco/opencode) - **Agent × Model view** — see which model each agent uses - **Time filtering** — last N days, relative durations (`7d`, `2w`), or ISO dates - **Period comparison** — compare current vs previous period with `--compare` -- **JSON output** — pipe to `jq` or other tools +- **Filters** — narrow by provider, model, or agent (`--provider`, `--model`, `--agent`, `--exclude-provider`) +- **JSON / CSV output** — pipe to `jq`, Excel, or other tools +- **Run-rate projection** — estimated monthly cost at the observed pace - **LLM-powered insights** — analyze session transcripts and generate a self-contained HTML report - **Cross-platform** — macOS, Linux, Windows @@ -55,9 +57,18 @@ opencode-usage run --by agent # shows model per agent opencode-usage run --by provider opencode-usage run --by session --limit 10 -# JSON output +# Filters (repeatable) +opencode-usage run --provider openrouter --by model +opencode-usage run --model deepseek-r1 --agent build +opencode-usage run --exclude-provider kimi-for-coding --by provider + +# Sorting +opencode-usage run --by model --sort cost + +# JSON / CSV output opencode-usage run --json opencode-usage run --by model --json | jq '.rows[].label' +opencode-usage run --by provider --csv # Compare with previous period opencode-usage run --since 7d --compare diff --git a/src/opencode_usage/cli.py b/src/opencode_usage/cli.py index d0192a7..b91dc32 100644 --- a/src/opencode_usage/cli.py +++ b/src/opencode_usage/cli.py @@ -10,8 +10,8 @@ from typing import Any from . import __version__, render -from .db import OpenCodeDB, UsageRow -from .render import render_daily, render_grouped, render_summary +from .db import Filters, OpenCodeDB, UsageRow +from .render import render_csv, render_daily, render_grouped, render_summary def _parse_since(value: str) -> datetime: @@ -40,6 +40,38 @@ def _parse_since(value: str) -> datetime: ) +def _add_filter_args(parser: argparse.ArgumentParser) -> None: + """Add row filters to *parser* (repeatable).""" + parser.add_argument( + "--provider", + action="append", + default=None, + metavar="ID", + help="Only include this provider (repeatable)", + ) + parser.add_argument( + "--model", + action="append", + default=None, + metavar="ID", + help="Only include this model (repeatable)", + ) + parser.add_argument( + "--agent", + action="append", + default=None, + metavar="NAME", + help="Only include this agent (repeatable)", + ) + parser.add_argument( + "--exclude-provider", + action="append", + default=None, + metavar="ID", + help="Exclude this provider (repeatable)", + ) + + def _add_time_args(parser: argparse.ArgumentParser) -> None: """Add --days and --since to *parser* (shared by run & insights).""" parser.add_argument( @@ -76,6 +108,14 @@ def _build_parser() -> argparse.ArgumentParser: default=None, help="Group results by dimension", ) + _add_filter_args(run_p) + run_p.add_argument( + "--sort", + choices=["tokens", "cost", "calls"], + default=None, + metavar="KEY", + help="Sort grouped rows by tokens (default), cost, or calls", + ) run_p.add_argument( "--limit", type=int, @@ -83,11 +123,18 @@ def _build_parser() -> argparse.ArgumentParser: metavar="N", help="Max rows to display", ) - run_p.add_argument( + out = run_p.add_mutually_exclusive_group() + out.add_argument( "--json", action="store_true", dest="json_output", - help="Output as JSON", + help="Output as JSON (includes run_rate_monthly)", + ) + out.add_argument( + "--csv", + action="store_true", + dest="csv_output", + help="Output as CSV", ) run_p.add_argument( "--compare", @@ -148,18 +195,22 @@ def _fetch_rows( since: datetime | None = None, until: datetime | None = None, limit: int | None = None, + filters: Filters | None = None, + sort_by: str | None = None, ) -> list[UsageRow]: """Fetch rows based on group_by dimension.""" if group_by == "day": - return db.daily(since=since, until=until, limit=limit) + return db.daily(since=since, until=until, limit=limit, filters=filters) if group_by == "model": - return db.by_model(since=since, until=until, limit=limit) + return db.by_model(since=since, until=until, limit=limit, filters=filters, sort_by=sort_by) if group_by == "agent": - return db.by_agent(since=since, until=until, limit=limit) + return db.by_agent(since=since, until=until, limit=limit, filters=filters) if group_by == "provider": - return db.by_provider(since=since, until=until, limit=limit) + return db.by_provider( + since=since, until=until, limit=limit, filters=filters, sort_by=sort_by + ) if group_by == "session": - return db.by_session(since=since, until=until, limit=limit) + return db.by_session(since=since, until=until, limit=limit, filters=filters) return [] @@ -184,6 +235,25 @@ def _compute_deltas( return deltas +def _build_filters(args: argparse.Namespace) -> Filters: + return Filters( + providers=args.provider, + models=args.model, + agents=args.agent, + exclude_providers=args.exclude_provider, + ) + + +def _compute_run_rate(total: UsageRow, since: datetime | None) -> float | None: + """Project the current cost onto a 30-day month at the observed pace.""" + if total.cost <= 0 or since is None: + return None + elapsed_days = (datetime.now().astimezone() - since).total_seconds() / 86400 + if elapsed_days < 0.1: + return None + return total.cost / elapsed_days * 30.44 + + def _cmd_run(args: argparse.Namespace) -> None: """Execute the ``run`` subcommand.""" try: @@ -194,6 +264,7 @@ def _cmd_run(args: argparse.Namespace) -> None: since, period = _resolve_since(args) group_by = args.by or "day" + sort_by = args.sort now = datetime.now().astimezone() prev_since = None @@ -201,22 +272,38 @@ def _cmd_run(args: argparse.Namespace) -> None: period_length = now - since prev_since = since - period_length - rows = _fetch_rows(db, group_by, since=since, limit=args.limit) - total = db.totals(since=since) + filters = _build_filters(args) + rows = _fetch_rows( + db, group_by, since=since, limit=args.limit, filters=filters, sort_by=sort_by + ) + total = db.totals(since=since, filters=filters) prev_total = None prev_rows: list[UsageRow] = [] if prev_since is not None: - prev_total = db.totals(since=prev_since, until=since) + prev_total = db.totals(since=prev_since, until=since, filters=filters) if group_by != "day": - prev_rows = _fetch_rows(db, group_by, since=prev_since, until=since, limit=args.limit) + prev_rows = _fetch_rows( + db, + group_by, + since=prev_since, + until=since, + limit=args.limit, + filters=filters, + sort_by=sort_by, + ) + + run_rate = _compute_run_rate(total, since) if args.json_output: output: dict[str, Any] = { "period": period, + "group_by": group_by, "total": db.to_dicts([total])[0], "rows": db.to_dicts(rows), } + if run_rate is not None: + output["run_rate_monthly"] = round(run_rate, 4) if prev_total is not None: output["previous_total"] = db.to_dicts([prev_total])[0] if prev_rows: @@ -224,7 +311,11 @@ def _cmd_run(args: argparse.Namespace) -> None: print(json.dumps(output, indent=2, ensure_ascii=False)) return - render_summary(total, period, prev_total=prev_total) + if args.csv_output: + render_csv(rows, include_detail=(group_by == "agent")) + return + + render_summary(total, period, prev_total=prev_total, run_rate=run_rate) render.console.print() deltas = _compute_deltas(rows, prev_rows) if prev_rows else None diff --git a/src/opencode_usage/db.py b/src/opencode_usage/db.py index 7e1711b..7e9a4b8 100644 --- a/src/opencode_usage/db.py +++ b/src/opencode_usage/db.py @@ -38,6 +38,23 @@ class UsageRow: detail: str | None = None +@dataclass +class Filters: + """Row filters applied to every query (all fields are optional).""" + + providers: list[str] | None = None + models: list[str] | None = None + agents: list[str] | None = None + exclude_providers: list[str] | None = None + + +_SORT_EXPRESSIONS = { + "tokens": "total_tokens DESC", + "cost": "cost DESC", + "calls": "calls DESC", +} + + @dataclass class SessionMeta: """Metadata about a user session.""" @@ -94,6 +111,32 @@ def _time_filter( params.append(ts_ms) return " ".join(clauses), params + def _filter_clause( + self, + filters: Filters | None, + *, + col: str = "data", + ) -> tuple[str, list[Any]]: + """Return WHERE clause fragments and params for row filters.""" + if filters is None: + return "", [] + clauses: list[str] = [] + params: list[Any] = [] + for key, values in ( + ("providerID", filters.providers), + ("modelID", filters.models), + ("agent", filters.agents), + ): + if values: + placeholders = ", ".join("?" * len(values)) + clauses.append(f"json_extract({col}, '$.{key}') IN ({placeholders})") + params.extend(values) + if filters.exclude_providers: + placeholders = ", ".join("?" * len(filters.exclude_providers)) + clauses.append(f"json_extract({col}, '$.providerID') NOT IN ({placeholders})") + params.extend(filters.exclude_providers) + return (" AND " + " AND ".join(clauses)) if clauses else "", params + def _base_query( self, group_expr: str, @@ -101,8 +144,15 @@ def _base_query( until: datetime | None = None, order: str = "total_tokens DESC", limit: int | None = None, + filters: Filters | None = None, + sort_by: str | None = None, ) -> list[UsageRow]: time_clause, params = self._time_filter(since, until) + filter_clause, filter_params = self._filter_clause(filters) + params.extend(filter_params) + + if sort_by in _SORT_EXPRESSIONS: + order = _SORT_EXPRESSIONS[sort_by] sql = f""" SELECT @@ -119,6 +169,7 @@ def _base_query( WHERE json_extract(data, '$.role') = 'assistant' AND json_extract(data, '$.tokens.total') IS NOT NULL {time_clause} + {filter_clause} GROUP BY label ORDER BY {order} """ @@ -156,6 +207,7 @@ def daily( since: datetime | None = None, until: datetime | None = None, limit: int | None = None, + filters: Filters | None = None, ) -> list[UsageRow]: return self._base_query( group_expr=( @@ -165,6 +217,7 @@ def daily( until=until, order="label DESC", limit=limit, + filters=filters, ) def by_model( @@ -172,12 +225,16 @@ def by_model( since: datetime | None = None, until: datetime | None = None, limit: int | None = None, + filters: Filters | None = None, + sort_by: str | None = None, ) -> list[UsageRow]: return self._base_query( group_expr="json_extract(data, '$.modelID')", since=since, until=until, limit=limit, + filters=filters, + sort_by=sort_by, ) def by_agent( @@ -185,9 +242,12 @@ def by_agent( since: datetime | None = None, until: datetime | None = None, limit: int | None = None, + filters: Filters | None = None, ) -> list[UsageRow]: """Group by agent x model, showing which model each agent uses.""" time_clause, params = self._time_filter(since, until) + filter_clause, filter_params = self._filter_clause(filters) + params.extend(filter_params) sql = f""" SELECT @@ -205,6 +265,7 @@ def by_agent( WHERE json_extract(data, '$.role') = 'assistant' AND json_extract(data, '$.tokens.total') IS NOT NULL {time_clause} + {filter_clause} GROUP BY agent, model ORDER BY agent, total_tokens DESC """ @@ -241,12 +302,16 @@ def by_provider( since: datetime | None = None, until: datetime | None = None, limit: int | None = None, + filters: Filters | None = None, + sort_by: str | None = None, ) -> list[UsageRow]: return self._base_query( group_expr="json_extract(data, '$.providerID')", since=since, until=until, limit=limit, + filters=filters, + sort_by=sort_by, ) def by_session( @@ -254,9 +319,12 @@ def by_session( since: datetime | None = None, until: datetime | None = None, limit: int | None = None, + filters: Filters | None = None, ) -> list[UsageRow]: """Group by session, using session title as label.""" time_clause, params = self._time_filter(since, until, col="m.data") + filter_clause, filter_params = self._filter_clause(filters, col="m.data") + params.extend(filter_params) sql = f""" SELECT @@ -274,6 +342,7 @@ def by_session( WHERE json_extract(m.data, '$.role') = 'assistant' AND json_extract(m.data, '$.tokens.total') IS NOT NULL {time_clause} + {filter_clause} GROUP BY m.session_id ORDER BY total_tokens DESC """ @@ -308,12 +377,14 @@ def totals( self, since: datetime | None = None, until: datetime | None = None, + filters: Filters | None = None, ) -> UsageRow: """Return a single aggregated row for the period.""" rows = self._base_query( group_expr="'total'", since=since, until=until, + filters=filters, ) if rows: return rows[0] diff --git a/src/opencode_usage/render.py b/src/opencode_usage/render.py index db9ebb9..e8d97e7 100644 --- a/src/opencode_usage/render.py +++ b/src/opencode_usage/render.py @@ -174,6 +174,7 @@ def render_summary( total: UsageRow, period: str, prev_total: UsageRow | None = None, + run_rate: float | None = None, ) -> None: """Print a one-line summary panel.""" text = Text() @@ -192,9 +193,50 @@ def render_summary( if prev_total is not None and prev_total.cost > 0: pct = (total.cost - prev_total.cost) / prev_total.cost * 100 text.append(Text.from_markup(f" {_fmt_delta(pct)}")) + if run_rate is not None: + text.append(" │ ≈ ", style="dim") + text.append(_fmt_cost(run_rate), style="cyan") + text.append("/mo", style="dim") console.print(Panel(text, title=f"[bold]OpenCode Usage — {period}[/bold]", border_style="blue")) +def render_csv(rows: list[UsageRow], include_detail: bool = False) -> None: + """Print rows as CSV to stdout.""" + import csv + import sys + + writer = csv.writer(sys.stdout) + header = [ + "label", + "calls", + "input", + "output", + "reasoning", + "cache_read", + "cache_write", + "total", + "cost", + ] + if include_detail: + header.insert(1, "model") + writer.writerow(header) + for r in rows: + row = [ + r.label, + r.calls, + r.tokens.input, + r.tokens.output, + r.tokens.reasoning, + r.tokens.cache_read, + r.tokens.cache_write, + r.tokens.total, + round(r.cost, 6), + ] + if include_detail: + row.insert(1, r.detail or "") + writer.writerow(row) + + def render_daily(rows: list[UsageRow], period: str) -> None: """Render the daily breakdown table.""" trend = [r.tokens.total for r in rows] diff --git a/tests/test_cli.py b/tests/test_cli.py index 6715502..4de3bb0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -326,3 +326,61 @@ def test_unknown_group_returns_empty(self, tmp_path): db = OpenCodeDB(db_path=_make_cli_db(tmp_path)) rows = _fetch_rows(db, "unknown") assert rows == [] + + +# ── filters, --sort and --csv flags ────────────────────────── + + +class TestFilterFlags: + def test_provider_flag(self): + args = _build_parser().parse_args(["run", "--provider", "openrouter"]) + assert args.provider == ["openrouter"] + + def test_repeatable_provider(self): + args = _build_parser().parse_args(["run", "--provider", "a", "--provider", "b"]) + assert args.provider == ["a", "b"] + + def test_model_and_agent_flags(self): + args = _build_parser().parse_args(["run", "--model", "k3", "--agent", "build"]) + assert args.model == ["k3"] + assert args.agent == ["build"] + + def test_exclude_provider(self): + args = _build_parser().parse_args(["run", "--exclude-provider", "kimi-for-coding"]) + assert args.exclude_provider == ["kimi-for-coding"] + + +class TestSortFlag: + def test_sort_cost(self): + args = _build_parser().parse_args(["run", "--by", "model", "--sort", "cost"]) + assert args.sort == "cost" + + def test_sort_invalid(self): + with pytest.raises(SystemExit): + _build_parser().parse_args(["run", "--sort", "bogus"]) + + +class TestCsvFlag: + def test_csv_flag(self): + args = _build_parser().parse_args(["run", "--csv"]) + assert args.csv_output is True + + def test_json_and_csv_exclusive(self): + with pytest.raises(SystemExit): + _build_parser().parse_args(["run", "--json", "--csv"]) + + +class TestRunRate: + def test_computes_monthly_projection(self): + from opencode_usage.cli import _compute_run_rate + + row = UsageRow(label="total", cost=10.0) + since = datetime.now().astimezone() - timedelta(days=30) + rate = _compute_run_rate(row, since) + assert rate == pytest.approx(10.0, rel=0.1) + + def test_zero_cost_returns_none(self): + from opencode_usage.cli import _compute_run_rate + + row = UsageRow(label="total", cost=0.0) + assert _compute_run_rate(row, datetime.now().astimezone()) is None diff --git a/tests/test_db.py b/tests/test_db.py index 50dd535..55ab651 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -11,7 +11,7 @@ import pytest -from opencode_usage.db import OpenCodeDB, UsageRow, _default_db_path +from opencode_usage.db import Filters, OpenCodeDB, UsageRow, _default_db_path def _make_msg( @@ -179,6 +179,55 @@ def test_valid_path(self, db_path): assert db.path == db_path +# ── filters ───────────────────────────────────────────────── + + +class TestFilters: + def test_provider_filter(self, db_path): + db = OpenCodeDB(db_path=db_path) + rows = db.by_model(filters=Filters(providers=["openrouter"])) + assert [r.label for r in rows] == ["deepseek-r1"] + assert sum(r.tokens.total for r in rows) == 11699 # m1 + m2 + m5 + m6 + + def test_provider_filter_matches_exact_ids(self, db_path): + db = OpenCodeDB(db_path=db_path) + rows = db.by_model(filters=Filters(providers=["nonexistent"])) + assert rows == [] + + def test_exclude_provider(self, db_path): + db = OpenCodeDB(db_path=db_path) + rows = db.by_model(filters=Filters(exclude_providers=["openrouter"])) + labels = {r.label for r in rows} + assert "deepseek-r1" not in labels + assert rows + + def test_agent_filter(self, db_path): + db = OpenCodeDB(db_path=db_path) + rows = db.by_model(filters=Filters(agents=["build"])) + assert [r.label for r in rows] == ["deepseek-r1"] + + def test_model_filter(self, db_path): + db = OpenCodeDB(db_path=db_path) + rows = db.by_provider(filters=Filters(models=["gemma-3"])) + assert [r.label for r in rows] == ["google"] + + def test_filters_apply_to_totals(self, db_path): + db = OpenCodeDB(db_path=db_path) + total = db.totals(filters=Filters(providers=["nonexistent"])) + assert total.calls == 0 + + def test_sort_by_cost(self, db_path): + db = OpenCodeDB(db_path=db_path) + rows = db.by_model(sort_by="cost") + costs = [r.cost for r in rows] + assert costs == sorted(costs, reverse=True) + + def test_filters_apply_to_session(self, db_path): + db = OpenCodeDB(db_path=db_path) + rows = db.by_session(filters=Filters(providers=["nonexistent"])) + assert rows == [] + + # ── daily ────────────────────────────────────────────────────