tokencur 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tokencur/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """tokencur — the CUR for your tokens.
2
+
3
+ Converts multi-provider AI/LLM usage data into FOCUS-conformant cost
4
+ datasets, with unit economics and savings recommendations.
5
+ """
6
+
7
+ __version__ = "0.3.0" # the only place the version lives (see pyproject)
tokencur/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """``python -m tokencur``: the same CLI as the ``tokencur`` command."""
2
+
3
+ from tokencur.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
tokencur/cli.py ADDED
@@ -0,0 +1,349 @@
1
+ """Command-line interface: ``tokencur <command>``.
2
+
3
+ One argparse parser for every command, installed as the ``tokencur``
4
+ console script and reachable as ``python -m tokencur``. The older
5
+ per-module entry points (``python -m tokencur.report`` and friends)
6
+ keep working: each delegates here.
7
+
8
+ Periods follow billing convention: ``--since`` is the first UTC day
9
+ included, ``--until`` the first day excluded, so ``--since 2026-09-01
10
+ --until 2026-10-01`` is exactly September.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sqlite3
17
+ import sys
18
+ from collections.abc import Sequence
19
+ from datetime import date
20
+ from pathlib import Path
21
+
22
+ from tokencur import __version__, doctor, ledger, observatory, outcomes, prices
23
+ from tokencur.export import export_csv
24
+ from tokencur.focus import undated_count, unpriced_models
25
+ from tokencur.ingest import claude_code, runpod
26
+ from tokencur.pricing import ConfigError, load_discounts
27
+ from tokencur.recommend import recommendations, render
28
+ from tokencur.records import UsageRecord, in_period
29
+ from tokencur.report import summarize
30
+ from tokencur.sources import load_records
31
+
32
+ _NOTHING = "no usage in known log locations or the ledger"
33
+
34
+
35
+ def build_parser() -> argparse.ArgumentParser:
36
+ parser = argparse.ArgumentParser(
37
+ prog="tokencur",
38
+ description="The CUR for your tokens: AI coding-agent usage as FOCUS cost data.",
39
+ epilog="Figures are API-equivalent list prices (showback), not a bill: "
40
+ "see the README's 'Money concepts'. With no command, runs `report`.",
41
+ )
42
+ parser.add_argument(
43
+ "--version", action="version", version=f"%(prog)s {__version__}"
44
+ )
45
+ commands = parser.add_subparsers(dest="command", metavar="<command>")
46
+
47
+ period = argparse.ArgumentParser(add_help=False)
48
+ period.add_argument(
49
+ "--since", type=_day, metavar="YYYY-MM-DD", help="first UTC day included"
50
+ )
51
+ period.add_argument(
52
+ "--until", type=_day, metavar="YYYY-MM-DD", help="first UTC day excluded"
53
+ )
54
+ root_help = "read these Claude Code logs instead; never stored in the ledger"
55
+ contract = argparse.ArgumentParser(add_help=False)
56
+ contract.add_argument(
57
+ "--discounts",
58
+ type=Path,
59
+ metavar="FILE",
60
+ help='negotiated discounts: {"discounts": {"Anthropic": 0.15}} (15%% off list)',
61
+ )
62
+
63
+ report = commands.add_parser(
64
+ "report", parents=[period, contract], help="cost summary in the terminal"
65
+ )
66
+ report.add_argument("root", nargs="?", type=Path, help=root_help)
67
+ report.add_argument(
68
+ "--currency", type=_currency, metavar="CODE", help="also show totals in CODE"
69
+ )
70
+ report.add_argument(
71
+ "--fx-rate",
72
+ type=_positive,
73
+ metavar="RATE",
74
+ help="units of --currency per USD; you give the rate, nothing is fetched",
75
+ )
76
+ report.set_defaults(handler=_report)
77
+
78
+ export = commands.add_parser(
79
+ "export", parents=[period, contract], help="write a FOCUS 1.2 CSV"
80
+ )
81
+ export.add_argument("output", type=Path, help="CSV file to write")
82
+ export.add_argument("root", nargs="?", type=Path, help=root_help)
83
+ export.set_defaults(handler=_export)
84
+
85
+ recommend = commands.add_parser(
86
+ "recommend", parents=[period], help="avoided cost and what-if headroom"
87
+ )
88
+ recommend.set_defaults(handler=_recommend)
89
+
90
+ unit = commands.add_parser(
91
+ "outcomes",
92
+ parents=[period],
93
+ help="usage value per commit, repository by repository",
94
+ )
95
+ unit.add_argument(
96
+ "repos",
97
+ nargs="*",
98
+ type=Path,
99
+ metavar="REPO",
100
+ help="git repositories to measure (default: every one the agents worked in)",
101
+ )
102
+ unit.add_argument(
103
+ "--all-authors",
104
+ action="store_true",
105
+ help="count every author's commits, not only your git user.email's",
106
+ )
107
+ unit.set_defaults(handler=_outcomes)
108
+
109
+ obs = commands.add_parser(
110
+ "observatory", help="render the static spend dashboard (aggregates only)"
111
+ )
112
+ obs.add_argument(
113
+ "outdir",
114
+ nargs="?",
115
+ type=Path,
116
+ default=observatory.DEFAULT_OUTPUT,
117
+ help=f"default: {observatory.DEFAULT_OUTPUT}",
118
+ )
119
+ obs.set_defaults(handler=_observatory)
120
+
121
+ card = commands.add_parser("prices", help="render the static price card")
122
+ card.add_argument(
123
+ "outdir",
124
+ nargs="?",
125
+ type=Path,
126
+ default=prices.DEFAULT_OUTPUT,
127
+ help=f"default: {prices.DEFAULT_OUTPUT}",
128
+ )
129
+ card.set_defaults(handler=_prices)
130
+
131
+ keep = commands.add_parser(
132
+ "import",
133
+ help="keep a provider's billing export (billed cost) in the ledger",
134
+ )
135
+ keep.add_argument("provider", choices=["runpod"])
136
+ keep.add_argument("files", nargs="+", type=Path, help="exports to import")
137
+ keep.set_defaults(handler=_import)
138
+
139
+ check = commands.add_parser(
140
+ "doctor",
141
+ help="check log formats, the ledger and pricing (read-only)",
142
+ )
143
+ check.set_defaults(handler=_doctor)
144
+ return parser
145
+
146
+
147
+ def main(argv: Sequence[str] | None = None) -> int:
148
+ parser = build_parser()
149
+ args = parser.parse_args(argv)
150
+ if args.command is None:
151
+ args = parser.parse_args(["report"])
152
+ since, until = getattr(args, "since", None), getattr(args, "until", None)
153
+ if since and until and since >= until:
154
+ parser.error("--since must be an earlier day than --until")
155
+ if (getattr(args, "currency", None) is None) != (
156
+ getattr(args, "fx_rate", None) is None
157
+ ):
158
+ parser.error("--currency and --fx-rate go together")
159
+ try:
160
+ return args.handler(args)
161
+ except (ledger.LedgerError, ConfigError, outcomes.OutcomesError) as exc:
162
+ _fail(str(exc))
163
+ except sqlite3.DatabaseError as exc:
164
+ _fail(f"the ledger looks damaged ({exc}); run `tokencur doctor`")
165
+ return 1
166
+
167
+
168
+ def _currency(text: str) -> str:
169
+ if len(text) != 3 or not text.isalpha() or not text.isupper():
170
+ raise argparse.ArgumentTypeError(f"not a 3-letter currency code: {text!r}")
171
+ return text
172
+
173
+
174
+ def _positive(text: str) -> float:
175
+ try:
176
+ value = float(text)
177
+ except ValueError:
178
+ raise argparse.ArgumentTypeError(f"not a number: {text!r}") from None
179
+ if not value > 0:
180
+ raise argparse.ArgumentTypeError(f"must be above 0: {text!r}")
181
+ return value
182
+
183
+
184
+ def _charges(args: argparse.Namespace) -> list:
185
+ """Billed charges in the ledger for the command's period. An explicit
186
+ log root is an ad hoc look at those logs alone, so it gets none."""
187
+ if getattr(args, "root", None) is not None:
188
+ return []
189
+ return in_period(
190
+ ledger.read_charges(), args.since, args.until, when=lambda c: c.period_start
191
+ )
192
+
193
+
194
+ def _discounts(args: argparse.Namespace) -> dict[str, float] | None:
195
+ return load_discounts(args.discounts) if args.discounts else None
196
+
197
+
198
+ def _day(text: str) -> date:
199
+ try:
200
+ return date.fromisoformat(text)
201
+ except ValueError:
202
+ raise argparse.ArgumentTypeError(f"not a YYYY-MM-DD date: {text!r}") from None
203
+
204
+
205
+ def _records(args: argparse.Namespace) -> list[UsageRecord] | None:
206
+ """The command's records, or None after printing why there are none."""
207
+ root = getattr(args, "root", None)
208
+ if root is not None:
209
+ if not root.exists():
210
+ return _fail(f"{root} does not exist")
211
+ records = list(claude_code.iter_usage_records(root))
212
+ else:
213
+ records = load_records()
214
+ if not records:
215
+ return _fail(_NOTHING)
216
+ return in_period(records, args.since, args.until)
217
+
218
+
219
+ def _period(args: argparse.Namespace) -> str | None:
220
+ since, until = args.since, args.until
221
+ if since and until:
222
+ return f"{since} to {until} (UTC, end excluded)"
223
+ if since:
224
+ return f"since {since} (UTC)"
225
+ if until:
226
+ return f"before {until} (UTC)"
227
+ return None
228
+
229
+
230
+ def _fail(message: str) -> None:
231
+ print(f"error: {message}", file=sys.stderr)
232
+
233
+
234
+ def _report(args: argparse.Namespace) -> int:
235
+ discounts = _discounts(args) # a bad file fails before any scan
236
+ records = _records(args)
237
+ if records is None:
238
+ return 1
239
+ if not records:
240
+ _fail(f"no usage {_period(args)}")
241
+ return 1
242
+ fx = (args.currency, args.fx_rate) if args.currency else None
243
+ print(
244
+ summarize(
245
+ records,
246
+ period=_period(args),
247
+ discounts=discounts,
248
+ fx=fx,
249
+ charges=_charges(args),
250
+ )
251
+ )
252
+ return 0
253
+
254
+
255
+ def _export(args: argparse.Namespace) -> int:
256
+ discounts = _discounts(args) # a bad file fails before any scan
257
+ records = _records(args)
258
+ if records is None:
259
+ return 1
260
+ rows = export_csv(records, args.output, discounts, _charges(args))
261
+ print(f"wrote {rows} FOCUS charge rows to {args.output}", file=sys.stderr)
262
+ skipped = unpriced_models(records)
263
+ if skipped:
264
+ pairs = ", ".join(f"{m} x{n}" for m, n in sorted(skipped.items()))
265
+ print(f"skipped unpriced usage: {pairs}", file=sys.stderr)
266
+ undated = undated_count(records)
267
+ if undated:
268
+ print(
269
+ f"skipped undated usage: {undated} records (no parseable timestamp)",
270
+ file=sys.stderr,
271
+ )
272
+ return 0
273
+
274
+
275
+ def _recommend(args: argparse.Namespace) -> int:
276
+ records = _records(args)
277
+ if not records:
278
+ if records is not None:
279
+ _fail(f"no usage {_period(args)}")
280
+ return 1
281
+ print(render(recommendations(records)))
282
+ return 0
283
+
284
+
285
+ def _outcomes(args: argparse.Namespace) -> int:
286
+ records = _records(args)
287
+ if records is None:
288
+ return 1
289
+ result = outcomes.outcomes(
290
+ records,
291
+ repos=args.repos,
292
+ since=args.since,
293
+ until=args.until,
294
+ all_authors=args.all_authors,
295
+ period=_period(args),
296
+ )
297
+ print(outcomes.render(result))
298
+ return 0
299
+
300
+
301
+ def _observatory(args: argparse.Namespace) -> int:
302
+ records = load_records()
303
+ if not records:
304
+ _fail(_NOTHING)
305
+ return 1
306
+ snap = observatory.snapshot(
307
+ records, observatory.load_subscriptions(), ledger.read_charges()
308
+ )
309
+ observatory.write_site(snap, args.outdir)
310
+ print(f"observatory written to {args.outdir} ({len(records)} records aggregated)")
311
+ return 0
312
+
313
+
314
+ def _prices(args: argparse.Namespace) -> int:
315
+ changes = prices.price_changes()
316
+ prices.write_site(args.outdir, changes)
317
+ moved = sum(len(c.changed) for c in changes)
318
+ gained = sum(len(c.added) for c in changes if not c.introduced)
319
+ print(
320
+ f"prices page written to {args.outdir} "
321
+ f"({len(changes)} events: {moved} rate moves, {gained} models added)"
322
+ )
323
+ return 0
324
+
325
+
326
+ def _doctor(args: argparse.Namespace) -> int:
327
+ diagnosis = doctor.diagnose()
328
+ print(doctor.render(diagnosis))
329
+ return 1 if diagnosis.problems else 0
330
+
331
+
332
+ def _import(args: argparse.Namespace) -> int:
333
+ charges = []
334
+ for path in args.files:
335
+ if not path.exists():
336
+ _fail(f"{path} does not exist")
337
+ return 1
338
+ charges.extend(runpod.iter_charges(path))
339
+ if not charges:
340
+ _fail("no billed charges in those files")
341
+ return 1
342
+ added = ledger.record_charges(charges)
343
+ total = sum(c.amount_usd for c in charges)
344
+ providers = ", ".join(sorted({c.provider for c in charges}))
345
+ print(
346
+ f"{len(charges)} billed charges ({added} new), ${total:,.2f} from "
347
+ f"{providers} — {ledger.default_path()}"
348
+ )
349
+ return 0
tokencur/dashboard.py ADDED
@@ -0,0 +1,177 @@
1
+ """Local cost dashboard over the FOCUS dataset.
2
+
3
+ Usage:
4
+ pip install -e ".[dashboard]"
5
+ streamlit run src/tokencur/dashboard.py
6
+
7
+ Loads the full usage history (every known local source, through the
8
+ ledger — see ``tokencur.sources``), normalizes to FOCUS charge rows and
9
+ lets DuckDB run the analytics — the same SQL any FinOps analyst would
10
+ write over a FOCUS dataset. Every view exposes its SQL and its table
11
+ (the table doubles as the accessibility fallback for chart colors).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import duckdb
17
+ import pandas as pd
18
+ import streamlit as st
19
+
20
+ from tokencur.focus import to_focus_rows, unpriced_models
21
+ from tokencur.recommend import recommendations
22
+ from tokencur.sources import load_records
23
+
24
+ # Fixed service→color mapping (color follows the entity, never the
25
+ # rank). Both palettes validated for their surface; see the dataviz
26
+ # palette notes in the commit message.
27
+ SERVICE_ORDER = ["Claude Code", "Codex CLI", "Kimi Code"]
28
+ SERVICE_COLORS = {
29
+ "dark": {"Claude Code": "#3987e5", "Codex CLI": "#199e70", "Kimi Code": "#c98500"},
30
+ "light": {"Claude Code": "#2a78d6", "Codex CLI": "#1baf7a", "Kimi Code": "#eda100"},
31
+ }
32
+ SINGLE_HUE = {"dark": "#3987e5", "light": "#2a78d6"} # magnitude charts
33
+
34
+
35
+ def _mode() -> str:
36
+ try:
37
+ return st.context.theme.type or "dark"
38
+ except Exception:
39
+ return "dark"
40
+
41
+
42
+ @st.cache_data(show_spinner="Scanning local usage logs…")
43
+ def load() -> tuple[pd.DataFrame, dict[str, int], list]:
44
+ records = load_records()
45
+ return pd.DataFrame(to_focus_rows(records)), unpriced_models(records), records
46
+
47
+
48
+ def view(title: str, sql: str, frame: pd.DataFrame) -> pd.DataFrame:
49
+ """Run one DuckDB query and render its SQL + table under the title."""
50
+ result = duckdb.sql(sql.replace("FOCUS", "frame")).df()
51
+ st.subheader(title)
52
+ with st.expander("SQL + tabla"):
53
+ st.code(sql, language="sql")
54
+ st.dataframe(result, use_container_width=True)
55
+ return result
56
+
57
+
58
+ st.set_page_config(page_title="tokencur", page_icon="🧾", layout="wide")
59
+ st.title("tokencur — local AI spend, FOCUS-shaped")
60
+
61
+ focus, unpriced, records = load()
62
+ if focus.empty:
63
+ st.warning("No usage found in any known local source.")
64
+ st.stop()
65
+
66
+ mode = _mode()
67
+ total = focus["BilledCost"].sum()
68
+ days = focus["ChargePeriodStart"].str[:10].nunique()
69
+
70
+ k1, k2, k3, k4 = st.columns(4)
71
+ k1.metric("API-equiv. usage value (showback)", f"${total:,.2f}")
72
+ k2.metric("Charge rows", f"{len(focus):,}")
73
+ k3.metric("Days covered", days)
74
+ k4.metric("Avg cost / day", f"${total / max(days, 1):,.2f}")
75
+ if unpriced:
76
+ st.caption(
77
+ "Unpriced usage (excluded): "
78
+ + ", ".join(f"{m} ×{n}" for m, n in sorted(unpriced.items()))
79
+ )
80
+
81
+ daily = view(
82
+ "Daily cost by service",
83
+ """
84
+ SELECT substr(ChargePeriodStart, 1, 10) AS day,
85
+ ServiceName,
86
+ round(sum(BilledCost), 4) AS cost_usd
87
+ FROM FOCUS
88
+ GROUP BY 1, 2
89
+ ORDER BY 1
90
+ """,
91
+ focus,
92
+ )
93
+ pivot = daily.pivot(index="day", columns="ServiceName", values="cost_usd")
94
+ services = [s for s in SERVICE_ORDER if s in pivot.columns]
95
+ st.line_chart(
96
+ pivot[services],
97
+ color=[SERVICE_COLORS[mode][s] for s in services],
98
+ height=320,
99
+ )
100
+
101
+ by_model = view(
102
+ "Cost by model",
103
+ """
104
+ SELECT regexp_replace(SkuId, '/[^/]+$', '') AS model,
105
+ round(sum(BilledCost), 2) AS cost_usd,
106
+ sum(ConsumedQuantity)::BIGINT AS tokens
107
+ FROM FOCUS
108
+ GROUP BY 1
109
+ ORDER BY 2 DESC
110
+ """,
111
+ focus,
112
+ )
113
+ st.bar_chart(
114
+ by_model.set_index("model")["cost_usd"],
115
+ color=SINGLE_HUE[mode],
116
+ horizontal=True,
117
+ height=320,
118
+ )
119
+
120
+ by_bucket = view(
121
+ "Where the money goes (token type)",
122
+ """
123
+ SELECT regexp_extract(SkuId, '[^/]+$') AS token_type,
124
+ round(sum(BilledCost), 2) AS cost_usd
125
+ FROM FOCUS
126
+ GROUP BY 1
127
+ ORDER BY 2 DESC
128
+ """,
129
+ focus,
130
+ )
131
+ st.bar_chart(
132
+ by_bucket.set_index("token_type")["cost_usd"],
133
+ color=SINGLE_HUE[mode],
134
+ height=260,
135
+ )
136
+
137
+ view(
138
+ "Unit economics by model",
139
+ """
140
+ SELECT regexp_replace(SkuId, '/[^/]+$', '') AS model,
141
+ round(sum(BilledCost), 2) AS cost_usd,
142
+ round(sum(BilledCost) / count(DISTINCT substr(ChargePeriodStart, 1, 10)), 3)
143
+ AS usd_per_active_day,
144
+ round(1e6 * sum(BilledCost) / sum(ConsumedQuantity), 3)
145
+ AS usd_per_mtok_effective
146
+ FROM FOCUS
147
+ GROUP BY 1
148
+ ORDER BY 2 DESC
149
+ """,
150
+ focus,
151
+ )
152
+
153
+ st.subheader("Recommendations")
154
+ recs = recommendations(records)
155
+ achieved = sum(r.savings_usd for r in recs if r.kind == "achieved")
156
+ potential = sum(r.savings_usd for r in recs if r.kind == "potential")
157
+ r1, r2 = st.columns(2)
158
+ r1.metric("Avoided by caching (counterfactual)", f"${achieved:,.2f}")
159
+ r2.metric("Right-sizing headroom (what-if)", f"${potential:,.2f}")
160
+ st.dataframe(
161
+ pd.DataFrame(
162
+ {
163
+ "kind": r.kind,
164
+ "recommendation": r.title,
165
+ "savings USD": round(r.savings_usd, 2),
166
+ "% of baseline": round(r.savings_pct, 1),
167
+ "detail": r.detail,
168
+ }
169
+ for r in recs
170
+ ),
171
+ use_container_width=True,
172
+ )
173
+
174
+ st.caption(
175
+ "Costs are API-equivalent list prices (showback) over local agent logs. "
176
+ "Source: tokencur FOCUS export."
177
+ )