token-finops-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {token_finops_cli-0.1.0 → token_finops_cli-0.2.0}/PKG-INFO +32 -2
- {token_finops_cli-0.1.0 → token_finops_cli-0.2.0}/README.md +31 -1
- token_finops_cli-0.2.0/examples/generate_synthetic_db.py +24 -0
- {token_finops_cli-0.1.0 → token_finops_cli-0.2.0}/pyproject.toml +1 -1
- {token_finops_cli-0.1.0 → token_finops_cli-0.2.0}/src/token_finops_cli/__init__.py +1 -1
- token_finops_cli-0.2.0/src/token_finops_cli/cli.py +608 -0
- token_finops_cli-0.1.0/examples/generate_synthetic_db.py → token_finops_cli-0.2.0/src/token_finops_cli/examples_helper.py +5 -20
- token_finops_cli-0.2.0/tests/test_cli.py +121 -0
- token_finops_cli-0.1.0/src/token_finops_cli/cli.py +0 -308
- token_finops_cli-0.1.0/tests/test_cli.py +0 -31
- {token_finops_cli-0.1.0 → token_finops_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {token_finops_cli-0.1.0 → token_finops_cli-0.2.0}/.github/workflows/release.yml +0 -0
- {token_finops_cli-0.1.0 → token_finops_cli-0.2.0}/.gitignore +0 -0
- {token_finops_cli-0.1.0 → token_finops_cli-0.2.0}/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: token-finops-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Read-only CLI to monitor GitHub Copilot CLI token usage and estimate a monthly budget runway
|
|
5
5
|
Project-URL: Homepage, https://github.com/oh-my-agent-code/token-finops-cli
|
|
6
6
|
Project-URL: Issues, https://github.com/oh-my-agent-code/token-finops-cli/issues
|
|
@@ -67,7 +67,7 @@ pip install token-finops-cli
|
|
|
67
67
|
## Usage
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
token-finops # full report, last 7 days
|
|
70
|
+
token-finops # full report, last 7 days (same as "report")
|
|
71
71
|
token-finops --since 30d # last 30 days
|
|
72
72
|
token-finops --since all # all-time
|
|
73
73
|
token-finops --session <id> # filter to one session
|
|
@@ -78,6 +78,26 @@ token-finops --compact # 2-line minimal output
|
|
|
78
78
|
token-finops --watch 5 # live-refreshing view every 5s
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
## Session history & break/gap reports
|
|
82
|
+
|
|
83
|
+
The `sessions` subcommand gives per-session archival/historical reporting
|
|
84
|
+
— useful for looking back at past sessions rather than just current
|
|
85
|
+
budget status:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
token-finops sessions # list all past sessions
|
|
89
|
+
token-finops sessions --since 30d --limit 10
|
|
90
|
+
token-finops sessions --session <id> # detailed break/gap report
|
|
91
|
+
token-finops sessions --session <id> --gap-minutes 60
|
|
92
|
+
token-finops sessions --totals # ONE combined report across ALL sessions
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The detailed per-session view detects "breaks" — gaps between requests
|
|
96
|
+
longer than `--gap-minutes` (default 30) — and reports active time (time
|
|
97
|
+
actually spent working) vs. idle/paused time (e.g. you closed the
|
|
98
|
+
terminal and came back the next day), alongside total elapsed wall-clock
|
|
99
|
+
time and a list of each pause with its start/end/duration.
|
|
100
|
+
|
|
81
101
|
## Try it without your own data (synthetic demo)
|
|
82
102
|
|
|
83
103
|
A small script generates a throwaway SQLite DB with fake usage events (same
|
|
@@ -105,6 +125,16 @@ Or point the tool at any DB via the `TOKEN_FINOPS_DB` env var instead of
|
|
|
105
125
|
| `--compact` / `-c` | 2-line minimal output (budget bar + runway status) |
|
|
106
126
|
| `--verbose` / `-vv` | Full report (overrides `--compact`) |
|
|
107
127
|
|
|
128
|
+
`sessions` subcommand options:
|
|
129
|
+
|
|
130
|
+
| Flag | Description |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `--since {1d,7d,30d,all}` | Time window for the session list (default `all`) |
|
|
133
|
+
| `--limit N` | Max sessions to list (default `20`) |
|
|
134
|
+
| `--session ID` | Show a detailed break/gap report for one session instead of the list |
|
|
135
|
+
| `--gap-minutes N` | Idle-gap threshold in minutes to count as a "break" (default `30`) |
|
|
136
|
+
| `--totals` | Print one combined report aggregated across ALL sessions matching `--since` (total requests/tokens/AI units, plus summed active/idle time and break count across every session) |
|
|
137
|
+
|
|
108
138
|
## What it measures
|
|
109
139
|
|
|
110
140
|
The "AI unit" is GitHub's own cost/usage unit for a mixed
|
|
@@ -45,7 +45,7 @@ pip install token-finops-cli
|
|
|
45
45
|
## Usage
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
token-finops # full report, last 7 days
|
|
48
|
+
token-finops # full report, last 7 days (same as "report")
|
|
49
49
|
token-finops --since 30d # last 30 days
|
|
50
50
|
token-finops --since all # all-time
|
|
51
51
|
token-finops --session <id> # filter to one session
|
|
@@ -56,6 +56,26 @@ token-finops --compact # 2-line minimal output
|
|
|
56
56
|
token-finops --watch 5 # live-refreshing view every 5s
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
## Session history & break/gap reports
|
|
60
|
+
|
|
61
|
+
The `sessions` subcommand gives per-session archival/historical reporting
|
|
62
|
+
— useful for looking back at past sessions rather than just current
|
|
63
|
+
budget status:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
token-finops sessions # list all past sessions
|
|
67
|
+
token-finops sessions --since 30d --limit 10
|
|
68
|
+
token-finops sessions --session <id> # detailed break/gap report
|
|
69
|
+
token-finops sessions --session <id> --gap-minutes 60
|
|
70
|
+
token-finops sessions --totals # ONE combined report across ALL sessions
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The detailed per-session view detects "breaks" — gaps between requests
|
|
74
|
+
longer than `--gap-minutes` (default 30) — and reports active time (time
|
|
75
|
+
actually spent working) vs. idle/paused time (e.g. you closed the
|
|
76
|
+
terminal and came back the next day), alongside total elapsed wall-clock
|
|
77
|
+
time and a list of each pause with its start/end/duration.
|
|
78
|
+
|
|
59
79
|
## Try it without your own data (synthetic demo)
|
|
60
80
|
|
|
61
81
|
A small script generates a throwaway SQLite DB with fake usage events (same
|
|
@@ -83,6 +103,16 @@ Or point the tool at any DB via the `TOKEN_FINOPS_DB` env var instead of
|
|
|
83
103
|
| `--compact` / `-c` | 2-line minimal output (budget bar + runway status) |
|
|
84
104
|
| `--verbose` / `-vv` | Full report (overrides `--compact`) |
|
|
85
105
|
|
|
106
|
+
`sessions` subcommand options:
|
|
107
|
+
|
|
108
|
+
| Flag | Description |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `--since {1d,7d,30d,all}` | Time window for the session list (default `all`) |
|
|
111
|
+
| `--limit N` | Max sessions to list (default `20`) |
|
|
112
|
+
| `--session ID` | Show a detailed break/gap report for one session instead of the list |
|
|
113
|
+
| `--gap-minutes N` | Idle-gap threshold in minutes to count as a "break" (default `30`) |
|
|
114
|
+
| `--totals` | Print one combined report aggregated across ALL sessions matching `--since` (total requests/tokens/AI units, plus summed active/idle time and break count across every session) |
|
|
115
|
+
|
|
86
116
|
## What it measures
|
|
87
117
|
|
|
88
118
|
The "AI unit" is GitHub's own cost/usage unit for a mixed
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""CLI wrapper for building a synthetic Copilot session-store.db (demos/tests).
|
|
2
|
+
|
|
3
|
+
See token_finops_cli.examples_helper.build_synthetic_db for the actual
|
|
4
|
+
generation logic - kept in the package so tests can import it too.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
|
|
8
|
+
from token_finops_cli.examples_helper import build_synthetic_db
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
13
|
+
parser.add_argument("path", help="Output path for the synthetic .db file")
|
|
14
|
+
parser.add_argument("--days", type=int, default=14)
|
|
15
|
+
parser.add_argument("--events-per-day", type=int, default=8)
|
|
16
|
+
parser.add_argument("--seed", type=int, default=42)
|
|
17
|
+
args = parser.parse_args()
|
|
18
|
+
|
|
19
|
+
n = build_synthetic_db(args.path, args.days, args.events_per_day, args.seed)
|
|
20
|
+
print(f"Wrote {n} synthetic events to {args.path}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
main()
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "token-finops-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Read-only CLI to monitor GitHub Copilot CLI token usage and estimate a monthly budget runway"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "AGPL-3.0-or-later"
|
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Copilot CLI usage/efficiency summary.
|
|
3
|
+
|
|
4
|
+
Reads directly from the local session store's assistant_usage_events
|
|
5
|
+
table (~/.copilot/session-store.db) - this is the CLI's own local
|
|
6
|
+
telemetry, no external logs/network calls involved. Read-only (opened
|
|
7
|
+
in immutable/read-only mode to avoid any risk of corrupting the CLI's
|
|
8
|
+
live database while it's in use).
|
|
9
|
+
|
|
10
|
+
Also estimates a "runway" (days until a self-defined monthly AI-token
|
|
11
|
+
budget would run out at the current burn rate), based on the
|
|
12
|
+
`total_nano_aiu` column - GitHub's own cost/usage unit for a mixed
|
|
13
|
+
included-quota + pay-per-use Copilot plan (divide by 1e9 to get "AI
|
|
14
|
+
units" matching what's shown on the Copilot billing page). The budget
|
|
15
|
+
number itself is NOT fetched automatically (no API/scrape of the
|
|
16
|
+
billing page - that would need an authenticated browser session we
|
|
17
|
+
deliberately don't wire up here); it's a value you tell the script
|
|
18
|
+
yourself via --budget, defaulting to what you've told the agent
|
|
19
|
+
(50,000), and the cycle is assumed to reset monthly on --cycle-day
|
|
20
|
+
(default 1st) unless your actual billing cycle differs.
|
|
21
|
+
|
|
22
|
+
Also supports a `sessions` subcommand for per-session history: list all
|
|
23
|
+
past sessions with usage totals, or drill into one session_id for a
|
|
24
|
+
break/gap report (detects pauses between requests, e.g. across days you
|
|
25
|
+
paused and resumed) - handy for archival/relative reporting on past work.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
token-finops [report] [--since 1d|7d|30d|all] [--session <id>]
|
|
29
|
+
[--budget 50000] [--cycle-day 1]
|
|
30
|
+
token-finops sessions [--since ...] [--limit N]
|
|
31
|
+
token-finops sessions --session <id> [--gap-minutes 30]
|
|
32
|
+
"""
|
|
33
|
+
import argparse
|
|
34
|
+
import os
|
|
35
|
+
import sqlite3
|
|
36
|
+
import sys
|
|
37
|
+
from datetime import datetime, timedelta, timezone
|
|
38
|
+
|
|
39
|
+
DEFAULT_DB_PATH = os.path.expanduser("~/.copilot/session-store.db")
|
|
40
|
+
DEFAULT_BUDGET_AIU = 50_000
|
|
41
|
+
|
|
42
|
+
SINCE_MAP = {
|
|
43
|
+
"1d": timedelta(days=1),
|
|
44
|
+
"7d": timedelta(days=7),
|
|
45
|
+
"30d": timedelta(days=30),
|
|
46
|
+
"all": None,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def connect_readonly(path):
|
|
51
|
+
# Open read-only via URI so we never risk writing to the CLI's live DB.
|
|
52
|
+
uri = f"file:{path}?mode=ro"
|
|
53
|
+
return sqlite3.connect(uri, uri=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fetch_stats(con, since_delta, session_id=None):
|
|
57
|
+
where = []
|
|
58
|
+
params = []
|
|
59
|
+
if since_delta is not None:
|
|
60
|
+
cutoff = (datetime.now(timezone.utc) - since_delta).isoformat()
|
|
61
|
+
where.append("created_at >= ?")
|
|
62
|
+
params.append(cutoff)
|
|
63
|
+
if session_id:
|
|
64
|
+
where.append("session_id = ?")
|
|
65
|
+
params.append(session_id)
|
|
66
|
+
where_clause = f"WHERE {' AND '.join(where)}" if where else ""
|
|
67
|
+
|
|
68
|
+
query = f"""
|
|
69
|
+
SELECT
|
|
70
|
+
COUNT(*) AS requests,
|
|
71
|
+
COALESCE(SUM(input_tokens), 0) AS total_input,
|
|
72
|
+
COALESCE(SUM(output_tokens), 0) AS total_output,
|
|
73
|
+
COALESCE(SUM(reasoning_tokens), 0) AS total_reasoning,
|
|
74
|
+
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
|
|
75
|
+
COUNT(DISTINCT session_id) AS sessions
|
|
76
|
+
FROM assistant_usage_events
|
|
77
|
+
{where_clause}
|
|
78
|
+
"""
|
|
79
|
+
cur = con.cursor()
|
|
80
|
+
cur.execute(query, params)
|
|
81
|
+
row = cur.fetchone()
|
|
82
|
+
return {
|
|
83
|
+
"requests": row[0],
|
|
84
|
+
"total_input": row[1],
|
|
85
|
+
"total_output": row[2],
|
|
86
|
+
"total_reasoning": row[3],
|
|
87
|
+
"avg_duration_ms": row[4],
|
|
88
|
+
"sessions": row[5],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def format_tokens(n):
|
|
93
|
+
if n >= 1_000_000:
|
|
94
|
+
return f"{n / 1_000_000:.1f}M"
|
|
95
|
+
if n >= 1_000:
|
|
96
|
+
return f"{n / 1_000:.1f}k"
|
|
97
|
+
return str(n)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def current_cycle_start(cycle_day, now=None):
|
|
101
|
+
"""First moment of the current billing cycle (assumed monthly, resetting
|
|
102
|
+
on `cycle_day` of each month). If today's day-of-month is before
|
|
103
|
+
cycle_day, the cycle started last month instead."""
|
|
104
|
+
now = now or datetime.now(timezone.utc)
|
|
105
|
+
year, month = now.year, now.month
|
|
106
|
+
if now.day < cycle_day:
|
|
107
|
+
month -= 1
|
|
108
|
+
if month == 0:
|
|
109
|
+
month, year = 12, year - 1
|
|
110
|
+
return datetime(year, month, cycle_day, tzinfo=timezone.utc)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fetch_cycle_aiu(con, cycle_start):
|
|
114
|
+
cur = con.cursor()
|
|
115
|
+
cur.execute(
|
|
116
|
+
"""
|
|
117
|
+
SELECT COALESCE(SUM(total_nano_aiu), 0), COUNT(*)
|
|
118
|
+
FROM assistant_usage_events
|
|
119
|
+
WHERE created_at >= ?
|
|
120
|
+
""",
|
|
121
|
+
(cycle_start.isoformat(),),
|
|
122
|
+
)
|
|
123
|
+
total_nano, requests = cur.fetchone()
|
|
124
|
+
return total_nano / 1e9, requests
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def runway_report(con, budget, cycle_day):
|
|
128
|
+
now = datetime.now(timezone.utc)
|
|
129
|
+
cycle_start = current_cycle_start(cycle_day, now)
|
|
130
|
+
used_aiu, requests = fetch_cycle_aiu(con, cycle_start)
|
|
131
|
+
|
|
132
|
+
elapsed_days = max((now - cycle_start).total_seconds() / 86400, 1e-6)
|
|
133
|
+
daily_rate = used_aiu / elapsed_days
|
|
134
|
+
remaining_aiu = budget - used_aiu
|
|
135
|
+
|
|
136
|
+
# Days left until the cycle itself resets (next reset date).
|
|
137
|
+
next_reset_month = cycle_start.month + 1
|
|
138
|
+
next_reset_year = cycle_start.year
|
|
139
|
+
if next_reset_month == 13:
|
|
140
|
+
next_reset_month, next_reset_year = 1, next_reset_year + 1
|
|
141
|
+
next_reset = datetime(next_reset_year, next_reset_month, cycle_start.day, tzinfo=timezone.utc)
|
|
142
|
+
days_left_in_cycle = (next_reset - now).total_seconds() / 86400
|
|
143
|
+
|
|
144
|
+
if daily_rate > 0:
|
|
145
|
+
runway_days = remaining_aiu / daily_rate
|
|
146
|
+
else:
|
|
147
|
+
runway_days = float("inf")
|
|
148
|
+
|
|
149
|
+
cycle_length_days = max((next_reset - cycle_start).total_seconds() / 86400, 1e-6)
|
|
150
|
+
cycle_fraction = 1 - (days_left_in_cycle / cycle_length_days)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"cycle_start": cycle_start,
|
|
154
|
+
"next_reset": next_reset,
|
|
155
|
+
"used_aiu": used_aiu,
|
|
156
|
+
"requests": requests,
|
|
157
|
+
"daily_rate": daily_rate,
|
|
158
|
+
"remaining_aiu": remaining_aiu,
|
|
159
|
+
"runway_days": runway_days,
|
|
160
|
+
"days_left_in_cycle": days_left_in_cycle,
|
|
161
|
+
"cycle_fraction": cycle_fraction,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def progress_bar(fraction, width=30):
|
|
166
|
+
"""Render a plain-ASCII progress bar (fraction clamped to [0, 1]).
|
|
167
|
+
|
|
168
|
+
Deliberately uses only '#'/'-' rather than Unicode block characters
|
|
169
|
+
(U+2588/U+2591) - some terminal fonts don't have glyphs for those and
|
|
170
|
+
render them as blank space, making the bar invisible even though the
|
|
171
|
+
rest of the line displays fine.
|
|
172
|
+
"""
|
|
173
|
+
fraction = max(0.0, min(1.0, fraction))
|
|
174
|
+
filled = round(width * fraction)
|
|
175
|
+
bar = "#" * filled + "-" * (width - filled)
|
|
176
|
+
return f"[{bar}] {fraction * 100:5.1f}%"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def render(args):
|
|
180
|
+
con = connect_readonly(args.db_path)
|
|
181
|
+
stats = fetch_stats(con, SINCE_MAP[args.since], args.session)
|
|
182
|
+
runway = runway_report(con, args.budget, args.cycle_day)
|
|
183
|
+
con.close()
|
|
184
|
+
|
|
185
|
+
total_tokens = stats["total_input"] + stats["total_output"]
|
|
186
|
+
tok_per_req = total_tokens / stats["requests"] if stats["requests"] else 0
|
|
187
|
+
out_in_ratio = (
|
|
188
|
+
stats["total_output"] / stats["total_input"] if stats["total_input"] else 0
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
lines = []
|
|
192
|
+
label = "all time" if args.since == "all" else f"last {args.since}"
|
|
193
|
+
lines.append(f"Copilot CLI usage ({label}):")
|
|
194
|
+
lines.append(f" requests: {stats['requests']}")
|
|
195
|
+
lines.append(f" sessions: {stats['sessions']}")
|
|
196
|
+
lines.append(f" input tokens: {format_tokens(stats['total_input'])}")
|
|
197
|
+
lines.append(f" output tokens: {format_tokens(stats['total_output'])}")
|
|
198
|
+
lines.append(f" reasoning tokens:{format_tokens(stats['total_reasoning'])}")
|
|
199
|
+
lines.append(f" tokens/request: {format_tokens(int(tok_per_req))}")
|
|
200
|
+
lines.append(f" output/input: {out_in_ratio:.2f}")
|
|
201
|
+
lines.append(f" avg duration: {stats['avg_duration_ms']/1000:.1f}s")
|
|
202
|
+
|
|
203
|
+
# Compact status-bar style one-liner
|
|
204
|
+
lines.append("")
|
|
205
|
+
lines.append(
|
|
206
|
+
f"\U0001F916 {stats['requests']} req "
|
|
207
|
+
f"\U0001FA99 {format_tokens(total_tokens)} tok "
|
|
208
|
+
f"\U0001F4C8 {format_tokens(int(tok_per_req))} tok/req "
|
|
209
|
+
f"\u23F1\uFE0F {stats['avg_duration_ms']/1000:.1f}s avg"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
lines.append("")
|
|
213
|
+
lines.append(f"Budget runway (cycle since {runway['cycle_start'].date()}, "
|
|
214
|
+
f"resets {runway['next_reset'].date()}):")
|
|
215
|
+
used_fraction = runway["used_aiu"] / args.budget if args.budget else 0
|
|
216
|
+
lines.append(f" budget: {progress_bar(used_fraction)}")
|
|
217
|
+
lines.append(f" used: {runway['used_aiu']:.1f} / {args.budget:.0f} AI units"
|
|
218
|
+
f" ({runway['requests']} requests this cycle)")
|
|
219
|
+
lines.append(f" burn rate: {runway['daily_rate']:.1f} AI units/day")
|
|
220
|
+
if runway["runway_days"] == float("inf"):
|
|
221
|
+
lines.append(" runway: no usage yet this cycle, can't estimate")
|
|
222
|
+
else:
|
|
223
|
+
lines.append(f" runway: {runway['runway_days']:.1f} days at this rate")
|
|
224
|
+
status = "OK" if runway["runway_days"] >= runway["days_left_in_cycle"] else "WILL RUN OUT EARLY"
|
|
225
|
+
lines.append(f" vs. {runway['days_left_in_cycle']:.1f} days left in cycle -> {status}")
|
|
226
|
+
lines.append(f" cycle time: {progress_bar(runway['cycle_fraction'])}")
|
|
227
|
+
|
|
228
|
+
if runway["runway_days"] != float("inf"):
|
|
229
|
+
lines.append(f"\U0001F6E3\uFE0F runway: {runway['runway_days']:.0f}d")
|
|
230
|
+
|
|
231
|
+
return "\n".join(lines)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def render_compact(args):
|
|
235
|
+
"""Minimal 2-line render: just the budget progress bar and a one-line
|
|
236
|
+
runway summary. Meant for tiny terminal panes (e.g. a 3-line split)
|
|
237
|
+
where the full report would scroll/clip."""
|
|
238
|
+
con = connect_readonly(args.db_path)
|
|
239
|
+
runway = runway_report(con, args.budget, args.cycle_day)
|
|
240
|
+
con.close()
|
|
241
|
+
|
|
242
|
+
used_fraction = runway["used_aiu"] / args.budget if args.budget else 0
|
|
243
|
+
lines = [
|
|
244
|
+
(
|
|
245
|
+
f"AI budget {progress_bar(used_fraction)} "
|
|
246
|
+
f"{runway['used_aiu']:.0f}/{args.budget:.0f}"
|
|
247
|
+
),
|
|
248
|
+
]
|
|
249
|
+
if runway["runway_days"] == float("inf"):
|
|
250
|
+
lines.append("runway: no usage yet this cycle")
|
|
251
|
+
else:
|
|
252
|
+
status = "OK" if runway["runway_days"] >= runway["days_left_in_cycle"] else "RUNNING OUT EARLY"
|
|
253
|
+
lines.append(
|
|
254
|
+
f"\U0001F6E3\uFE0F {runway['runway_days']:.0f}d runway "
|
|
255
|
+
f"({runway['days_left_in_cycle']:.0f}d left in cycle) - {status}"
|
|
256
|
+
)
|
|
257
|
+
return "\n".join(lines)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
DEFAULT_GAP_MINUTES = 30.0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def list_sessions(con, since_delta=None, limit=20):
|
|
265
|
+
"""Per-session summary: requests, tokens, AI units, first/last activity,
|
|
266
|
+
elapsed wall-clock span, and active time (sum of gaps below the
|
|
267
|
+
break threshold) vs idle time (gaps above it, i.e. actual pauses)."""
|
|
268
|
+
where = []
|
|
269
|
+
params = []
|
|
270
|
+
if since_delta is not None:
|
|
271
|
+
cutoff = (datetime.now(timezone.utc) - since_delta).isoformat()
|
|
272
|
+
where.append("created_at >= ?")
|
|
273
|
+
params.append(cutoff)
|
|
274
|
+
where_clause = f"WHERE {' AND '.join(where)}" if where else ""
|
|
275
|
+
|
|
276
|
+
cur = con.cursor()
|
|
277
|
+
cur.execute(
|
|
278
|
+
f"""
|
|
279
|
+
SELECT
|
|
280
|
+
session_id,
|
|
281
|
+
COUNT(*) AS requests,
|
|
282
|
+
COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) AS total_tokens,
|
|
283
|
+
COALESCE(SUM(total_nano_aiu), 0) / 1e9 AS aiu,
|
|
284
|
+
MIN(created_at) AS first_at,
|
|
285
|
+
MAX(created_at) AS last_at
|
|
286
|
+
FROM assistant_usage_events
|
|
287
|
+
{where_clause}
|
|
288
|
+
GROUP BY session_id
|
|
289
|
+
ORDER BY last_at DESC
|
|
290
|
+
LIMIT ?
|
|
291
|
+
""",
|
|
292
|
+
params + [limit],
|
|
293
|
+
)
|
|
294
|
+
rows = cur.fetchall()
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
"session_id": r[0],
|
|
298
|
+
"requests": r[1],
|
|
299
|
+
"total_tokens": r[2],
|
|
300
|
+
"aiu": r[3],
|
|
301
|
+
"first_at": r[4],
|
|
302
|
+
"last_at": r[5],
|
|
303
|
+
}
|
|
304
|
+
for r in rows
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def session_breaks(con, session_id, gap_minutes=DEFAULT_GAP_MINUTES):
|
|
309
|
+
"""Detect pauses/breaks within a single session: any gap between two
|
|
310
|
+
consecutive requests larger than `gap_minutes` counts as a break.
|
|
311
|
+
Returns (events_with_gaps, active_seconds, idle_seconds, elapsed_seconds).
|
|
312
|
+
"Active" time sums only the small in-between gaps (actual working
|
|
313
|
+
time); "idle" time sums the large gaps (time you were away/paused)."""
|
|
314
|
+
cur = con.cursor()
|
|
315
|
+
cur.execute(
|
|
316
|
+
"""
|
|
317
|
+
SELECT created_at FROM assistant_usage_events
|
|
318
|
+
WHERE session_id = ?
|
|
319
|
+
ORDER BY created_at
|
|
320
|
+
""",
|
|
321
|
+
(session_id,),
|
|
322
|
+
)
|
|
323
|
+
timestamps = [
|
|
324
|
+
datetime.fromisoformat(row[0].replace("Z", "+00:00"))
|
|
325
|
+
for row in cur.fetchall()
|
|
326
|
+
]
|
|
327
|
+
if not timestamps:
|
|
328
|
+
return [], 0.0, 0.0, 0.0
|
|
329
|
+
|
|
330
|
+
gap_threshold = timedelta(minutes=gap_minutes)
|
|
331
|
+
breaks = []
|
|
332
|
+
active_seconds = 0.0
|
|
333
|
+
idle_seconds = 0.0
|
|
334
|
+
for prev, cur_ts in zip(timestamps, timestamps[1:]):
|
|
335
|
+
gap = (cur_ts - prev).total_seconds()
|
|
336
|
+
if gap >= gap_threshold.total_seconds():
|
|
337
|
+
breaks.append((prev, cur_ts, gap))
|
|
338
|
+
idle_seconds += gap
|
|
339
|
+
else:
|
|
340
|
+
active_seconds += gap
|
|
341
|
+
elapsed_seconds = (timestamps[-1] - timestamps[0]).total_seconds()
|
|
342
|
+
return breaks, active_seconds, idle_seconds, elapsed_seconds
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def format_duration(seconds):
|
|
346
|
+
seconds = int(seconds)
|
|
347
|
+
days, rem = divmod(seconds, 86400)
|
|
348
|
+
hours, rem = divmod(rem, 3600)
|
|
349
|
+
minutes, secs = divmod(rem, 60)
|
|
350
|
+
if days:
|
|
351
|
+
return f"{days}d {hours}h{minutes:02d}m"
|
|
352
|
+
if hours:
|
|
353
|
+
return f"{hours}h{minutes:02d}m{secs:02d}s"
|
|
354
|
+
if minutes:
|
|
355
|
+
return f"{minutes}m{secs:02d}s"
|
|
356
|
+
return f"{secs}s"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def render_sessions_list(args):
|
|
360
|
+
con = connect_readonly(args.db_path)
|
|
361
|
+
since_delta = SINCE_MAP[args.since]
|
|
362
|
+
sessions = list_sessions(con, since_delta, args.limit)
|
|
363
|
+
|
|
364
|
+
lines = [f"Sessions ({'all time' if args.since == 'all' else 'last ' + args.since}):"]
|
|
365
|
+
if not sessions:
|
|
366
|
+
lines.append(" (none found)")
|
|
367
|
+
con.close()
|
|
368
|
+
return "\n".join(lines)
|
|
369
|
+
|
|
370
|
+
lines.append(
|
|
371
|
+
f" {'session_id':<38} {'reqs':>5} {'tokens':>9} {'AI units':>9} last activity"
|
|
372
|
+
)
|
|
373
|
+
for s in sessions:
|
|
374
|
+
short_id = s["session_id"][:36]
|
|
375
|
+
last_at = s["last_at"][:19].replace("T", " ")
|
|
376
|
+
lines.append(
|
|
377
|
+
f" {short_id:<38} {s['requests']:>5} "
|
|
378
|
+
f"{format_tokens(s['total_tokens']):>9} {s['aiu']:>9.1f} {last_at}"
|
|
379
|
+
)
|
|
380
|
+
con.close()
|
|
381
|
+
return "\n".join(lines)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def render_session_detail(args):
|
|
385
|
+
con = connect_readonly(args.db_path)
|
|
386
|
+
stats = fetch_stats(con, None, args.session)
|
|
387
|
+
breaks, active_s, idle_s, elapsed_s = session_breaks(
|
|
388
|
+
con, args.session, args.gap_minutes
|
|
389
|
+
)
|
|
390
|
+
con.close()
|
|
391
|
+
|
|
392
|
+
if stats["requests"] == 0:
|
|
393
|
+
return f"No events found for session {args.session}"
|
|
394
|
+
|
|
395
|
+
total_tokens = stats["total_input"] + stats["total_output"]
|
|
396
|
+
lines = [f"Session {args.session}:"]
|
|
397
|
+
lines.append(f" requests: {stats['requests']}")
|
|
398
|
+
lines.append(f" input tokens: {format_tokens(stats['total_input'])}")
|
|
399
|
+
lines.append(f" output tokens: {format_tokens(stats['total_output'])}")
|
|
400
|
+
lines.append(f" reasoning tokens:{format_tokens(stats['total_reasoning'])}")
|
|
401
|
+
lines.append(f" total tokens: {format_tokens(total_tokens)}")
|
|
402
|
+
lines.append("")
|
|
403
|
+
lines.append(f" elapsed (first->last request): {format_duration(elapsed_s)}")
|
|
404
|
+
lines.append(f" active time (gaps < {args.gap_minutes:.0f}m): {format_duration(active_s)}")
|
|
405
|
+
lines.append(f" idle/paused time (gaps >= {args.gap_minutes:.0f}m): {format_duration(idle_s)}")
|
|
406
|
+
lines.append(f" breaks detected: {len(breaks)}")
|
|
407
|
+
if breaks:
|
|
408
|
+
lines.append("")
|
|
409
|
+
lines.append(" Breaks (pause start -> resume, duration):")
|
|
410
|
+
for start, end, gap in breaks:
|
|
411
|
+
lines.append(
|
|
412
|
+
f" {start.isoformat(timespec='seconds')} -> "
|
|
413
|
+
f"{end.isoformat(timespec='seconds')} ({format_duration(gap)})"
|
|
414
|
+
)
|
|
415
|
+
return "\n".join(lines)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def aggregate_all_sessions(con, since_delta=None, gap_minutes=DEFAULT_GAP_MINUTES):
|
|
419
|
+
"""Combined break/gap totals across every session matching the filter
|
|
420
|
+
(not just the ones in a --limit-truncated list). For each session,
|
|
421
|
+
reuses session_breaks() and sums up active/idle time and break counts
|
|
422
|
+
- gives a true "all sessions" picture rather than one session at a time."""
|
|
423
|
+
where = []
|
|
424
|
+
params = []
|
|
425
|
+
if since_delta is not None:
|
|
426
|
+
cutoff = (datetime.now(timezone.utc) - since_delta).isoformat()
|
|
427
|
+
where.append("created_at >= ?")
|
|
428
|
+
params.append(cutoff)
|
|
429
|
+
where_clause = f"WHERE {' AND '.join(where)}" if where else ""
|
|
430
|
+
|
|
431
|
+
cur = con.cursor()
|
|
432
|
+
cur.execute(
|
|
433
|
+
f"SELECT DISTINCT session_id FROM assistant_usage_events {where_clause}",
|
|
434
|
+
params,
|
|
435
|
+
)
|
|
436
|
+
session_ids = [row[0] for row in cur.fetchall()]
|
|
437
|
+
|
|
438
|
+
total_active = 0.0
|
|
439
|
+
total_idle = 0.0
|
|
440
|
+
total_breaks = 0
|
|
441
|
+
for sid in session_ids:
|
|
442
|
+
breaks, active_s, idle_s, _elapsed_s = session_breaks(con, sid, gap_minutes)
|
|
443
|
+
total_active += active_s
|
|
444
|
+
total_idle += idle_s
|
|
445
|
+
total_breaks += len(breaks)
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
"session_count": len(session_ids),
|
|
449
|
+
"active_seconds": total_active,
|
|
450
|
+
"idle_seconds": total_idle,
|
|
451
|
+
"break_count": total_breaks,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def render_sessions_totals(args):
|
|
456
|
+
"""Aggregate report across ALL sessions matching --since (ignores
|
|
457
|
+
--limit, since this is meant to cover everything): total requests,
|
|
458
|
+
tokens, AI units, session count, plus combined active vs idle time
|
|
459
|
+
and break count summed across every individual session."""
|
|
460
|
+
con = connect_readonly(args.db_path)
|
|
461
|
+
since_delta = SINCE_MAP[args.since]
|
|
462
|
+
stats = fetch_stats(con, since_delta, session_id=None)
|
|
463
|
+
agg = aggregate_all_sessions(con, since_delta, args.gap_minutes)
|
|
464
|
+
con.close()
|
|
465
|
+
|
|
466
|
+
total_tokens = stats["total_input"] + stats["total_output"]
|
|
467
|
+
label = "all time" if args.since == "all" else f"last {args.since}"
|
|
468
|
+
lines = [f"All sessions report ({label}):"]
|
|
469
|
+
lines.append(f" sessions: {agg['session_count']}")
|
|
470
|
+
lines.append(f" requests: {stats['requests']}")
|
|
471
|
+
lines.append(f" input tokens: {format_tokens(stats['total_input'])}")
|
|
472
|
+
lines.append(f" output tokens: {format_tokens(stats['total_output'])}")
|
|
473
|
+
lines.append(f" reasoning tokens:{format_tokens(stats['total_reasoning'])}")
|
|
474
|
+
lines.append(f" total tokens: {format_tokens(total_tokens)}")
|
|
475
|
+
lines.append("")
|
|
476
|
+
lines.append(
|
|
477
|
+
f" active time (gaps < {args.gap_minutes:.0f}m), summed across "
|
|
478
|
+
f"all sessions: {format_duration(agg['active_seconds'])}"
|
|
479
|
+
)
|
|
480
|
+
lines.append(
|
|
481
|
+
f" idle/paused time (gaps >= {args.gap_minutes:.0f}m), summed: "
|
|
482
|
+
f"{format_duration(agg['idle_seconds'])}"
|
|
483
|
+
)
|
|
484
|
+
lines.append(f" total breaks detected: {agg['break_count']}")
|
|
485
|
+
return "\n".join(lines)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _add_common_db_arg(p):
|
|
489
|
+
p.add_argument(
|
|
490
|
+
"--db-path",
|
|
491
|
+
default=os.environ.get("TOKEN_FINOPS_DB", DEFAULT_DB_PATH),
|
|
492
|
+
help="Path to the session-store.db to read (default: "
|
|
493
|
+
"$TOKEN_FINOPS_DB or ~/.copilot/session-store.db). Useful for "
|
|
494
|
+
"pointing at a synthetic/demo database.",
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def build_parser():
|
|
499
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
500
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
501
|
+
|
|
502
|
+
report = subparsers.add_parser(
|
|
503
|
+
"report", help="Usage summary + budget/runway report (default)"
|
|
504
|
+
)
|
|
505
|
+
report.add_argument("--since", choices=SINCE_MAP.keys(), default="7d")
|
|
506
|
+
report.add_argument("--session", default=None, help="Filter to one session_id")
|
|
507
|
+
_add_common_db_arg(report)
|
|
508
|
+
report.add_argument(
|
|
509
|
+
"--budget", type=float, default=DEFAULT_BUDGET_AIU,
|
|
510
|
+
help=f"Monthly AI-unit budget for the runway estimate (default {DEFAULT_BUDGET_AIU})",
|
|
511
|
+
)
|
|
512
|
+
report.add_argument(
|
|
513
|
+
"--cycle-day", type=int, default=1,
|
|
514
|
+
help="Day of month your billing cycle resets on (default 1st)",
|
|
515
|
+
)
|
|
516
|
+
report.add_argument(
|
|
517
|
+
"--watch", type=float, default=None, metavar="SECONDS",
|
|
518
|
+
help="Keep running, redrawing the report every SECONDS (e.g. --watch 30). "
|
|
519
|
+
"Meant for a spare terminal pane/tmux split, not the Copilot session itself.",
|
|
520
|
+
)
|
|
521
|
+
report.add_argument(
|
|
522
|
+
"--compact", "-c", action="store_true",
|
|
523
|
+
help="Print only the budget progress bar + one-line runway summary "
|
|
524
|
+
"(2 lines total) instead of the full report. Good for tiny "
|
|
525
|
+
"terminal panes/splits.",
|
|
526
|
+
)
|
|
527
|
+
report.add_argument(
|
|
528
|
+
"--verbose", "-vv", action="store_true",
|
|
529
|
+
help="Explicitly request the full report (this is the default; "
|
|
530
|
+
"provided as the counterpart to --compact/-c).",
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
sessions = subparsers.add_parser(
|
|
534
|
+
"sessions", help="List past sessions, or show break/gap detail for one"
|
|
535
|
+
)
|
|
536
|
+
sessions.add_argument("--since", choices=SINCE_MAP.keys(), default="all")
|
|
537
|
+
sessions.add_argument(
|
|
538
|
+
"--session", default=None,
|
|
539
|
+
help="Show a detailed break/gap report for this one session_id "
|
|
540
|
+
"instead of the list",
|
|
541
|
+
)
|
|
542
|
+
sessions.add_argument(
|
|
543
|
+
"--limit", type=int, default=20,
|
|
544
|
+
help="Max number of sessions to list (default 20, ignored with "
|
|
545
|
+
"--session/--totals)",
|
|
546
|
+
)
|
|
547
|
+
sessions.add_argument(
|
|
548
|
+
"--gap-minutes", type=float, default=DEFAULT_GAP_MINUTES,
|
|
549
|
+
help=f"Idle gap threshold in minutes to count as a 'break' in the "
|
|
550
|
+
f"detailed --session/--totals views (default {DEFAULT_GAP_MINUTES:.0f})",
|
|
551
|
+
)
|
|
552
|
+
sessions.add_argument(
|
|
553
|
+
"--totals", action="store_true",
|
|
554
|
+
help="Print one combined report aggregated across ALL sessions "
|
|
555
|
+
"matching --since (requests, tokens, AI units, and combined "
|
|
556
|
+
"active/idle/break totals), instead of a per-session list.",
|
|
557
|
+
)
|
|
558
|
+
_add_common_db_arg(sessions)
|
|
559
|
+
|
|
560
|
+
return parser
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _normalize_argv(argv):
|
|
564
|
+
"""Insert the "report" subcommand by default, so plain flag usage
|
|
565
|
+
(e.g. `token-finops --since 30d`) keeps working exactly as it did
|
|
566
|
+
before subcommands were introduced. Only "sessions" opts out."""
|
|
567
|
+
if not argv:
|
|
568
|
+
return ["report"]
|
|
569
|
+
if argv[0] in ("sessions", "report", "-h", "--help"):
|
|
570
|
+
return argv
|
|
571
|
+
return ["report", *argv]
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def main(argv=None):
|
|
575
|
+
parser = build_parser()
|
|
576
|
+
argv = _normalize_argv(sys.argv[1:] if argv is None else argv)
|
|
577
|
+
args = parser.parse_args(argv)
|
|
578
|
+
|
|
579
|
+
if args.command == "sessions":
|
|
580
|
+
if args.session:
|
|
581
|
+
print(render_session_detail(args))
|
|
582
|
+
elif args.totals:
|
|
583
|
+
print(render_sessions_totals(args))
|
|
584
|
+
else:
|
|
585
|
+
print(render_sessions_list(args))
|
|
586
|
+
return
|
|
587
|
+
|
|
588
|
+
# command == "report"
|
|
589
|
+
renderer = render_compact if (args.compact and not args.verbose) else render
|
|
590
|
+
|
|
591
|
+
if args.watch is None:
|
|
592
|
+
print(renderer(args))
|
|
593
|
+
return
|
|
594
|
+
|
|
595
|
+
import time
|
|
596
|
+
try:
|
|
597
|
+
while True:
|
|
598
|
+
# Clear screen + move cursor home (ANSI), then redraw in place.
|
|
599
|
+
print("\033[2J\033[H", end="")
|
|
600
|
+
print(renderer(args))
|
|
601
|
+
print(f"\n(refreshing every {args.watch:.0f}s, Ctrl+C to stop)")
|
|
602
|
+
time.sleep(args.watch)
|
|
603
|
+
except KeyboardInterrupt:
|
|
604
|
+
pass
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
if __name__ == "__main__":
|
|
608
|
+
main()
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Build a synthetic Copilot session-store.db, for demos and tests.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Creates a throwaway SQLite database with only the columns the CLI's
|
|
4
4
|
queries actually reference (see cli.py's SQL) - it doesn't need to match
|
|
5
5
|
GitHub's real internal schema exactly, just provide those columns with
|
|
6
6
|
plausible fake data so the tool can be demonstrated/tested without ever
|
|
7
7
|
touching a real ~/.copilot/session-store.db.
|
|
8
8
|
"""
|
|
9
|
-
import argparse
|
|
10
9
|
import random
|
|
11
10
|
import sqlite3
|
|
12
11
|
from datetime import datetime, timedelta, timezone
|
|
@@ -42,7 +41,9 @@ def build_synthetic_db(path, days=14, events_per_day=8, seed=42):
|
|
|
42
41
|
reasoning_tokens = rng.randint(0, 800)
|
|
43
42
|
duration_ms = rng.uniform(800, 15000)
|
|
44
43
|
# Roughly proportional to tokens, in "nano AI units".
|
|
45
|
-
total_nano_aiu = int(
|
|
44
|
+
total_nano_aiu = int(
|
|
45
|
+
(input_tokens + output_tokens) * rng.uniform(150_000_000, 400_000_000)
|
|
46
|
+
)
|
|
46
47
|
rows.append((
|
|
47
48
|
session_id, ts.isoformat(), input_tokens, output_tokens,
|
|
48
49
|
reasoning_tokens, duration_ms, total_nano_aiu,
|
|
@@ -60,19 +61,3 @@ def build_synthetic_db(path, days=14, events_per_day=8, seed=42):
|
|
|
60
61
|
con.commit()
|
|
61
62
|
con.close()
|
|
62
63
|
return len(rows)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def main():
|
|
66
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
67
|
-
parser.add_argument("path", help="Output path for the synthetic .db file")
|
|
68
|
-
parser.add_argument("--days", type=int, default=14)
|
|
69
|
-
parser.add_argument("--events-per-day", type=int, default=8)
|
|
70
|
-
parser.add_argument("--seed", type=int, default=42)
|
|
71
|
-
args = parser.parse_args()
|
|
72
|
-
|
|
73
|
-
n = build_synthetic_db(args.path, args.days, args.events_per_day, args.seed)
|
|
74
|
-
print(f"Wrote {n} synthetic events to {args.path}")
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if __name__ == "__main__":
|
|
78
|
-
main()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Smoke tests for token_finops_cli — no live Copilot DB required."""
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_help_runs():
|
|
7
|
+
result = subprocess.run(
|
|
8
|
+
[sys.executable, "-m", "token_finops_cli.cli", "--help"],
|
|
9
|
+
capture_output=True,
|
|
10
|
+
text=True,
|
|
11
|
+
check=False,
|
|
12
|
+
)
|
|
13
|
+
assert result.returncode == 0
|
|
14
|
+
assert "report" in result.stdout
|
|
15
|
+
assert "sessions" in result.stdout
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_report_help_runs():
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
[sys.executable, "-m", "token_finops_cli.cli", "report", "--help"],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
check=False,
|
|
24
|
+
)
|
|
25
|
+
assert result.returncode == 0
|
|
26
|
+
assert "--budget" in result.stdout
|
|
27
|
+
assert "--watch" in result.stdout
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_default_command_is_report():
|
|
31
|
+
"""No subcommand should behave the same as `report` (backward compat
|
|
32
|
+
with the pre-subcommand CLI)."""
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
[sys.executable, "-m", "token_finops_cli.cli", "--help"],
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
check=False,
|
|
38
|
+
)
|
|
39
|
+
assert result.returncode == 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_sessions_help_runs():
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
[sys.executable, "-m", "token_finops_cli.cli", "sessions", "--help"],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
check=False,
|
|
48
|
+
)
|
|
49
|
+
assert result.returncode == 0
|
|
50
|
+
assert "--gap-minutes" in result.stdout
|
|
51
|
+
assert "--limit" in result.stdout
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_sessions_list_against_synthetic_db(tmp_path):
|
|
55
|
+
from token_finops_cli.examples_helper import build_synthetic_db
|
|
56
|
+
|
|
57
|
+
db_path = tmp_path / "demo.db"
|
|
58
|
+
build_synthetic_db(str(db_path), days=10, events_per_day=5, seed=1)
|
|
59
|
+
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
[sys.executable, "-m", "token_finops_cli.cli", "sessions",
|
|
62
|
+
"--db-path", str(db_path)],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
check=False,
|
|
66
|
+
)
|
|
67
|
+
assert result.returncode == 0
|
|
68
|
+
assert "demo-session-0" in result.stdout
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_sessions_totals_report(tmp_path):
|
|
72
|
+
from token_finops_cli.examples_helper import build_synthetic_db
|
|
73
|
+
|
|
74
|
+
db_path = tmp_path / "demo.db"
|
|
75
|
+
build_synthetic_db(str(db_path), days=10, events_per_day=5, seed=1)
|
|
76
|
+
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
[sys.executable, "-m", "token_finops_cli.cli", "sessions",
|
|
79
|
+
"--totals", "--db-path", str(db_path)],
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
assert result.returncode == 0
|
|
85
|
+
assert "All sessions report" in result.stdout
|
|
86
|
+
assert "sessions:" in result.stdout
|
|
87
|
+
assert "total breaks detected" in result.stdout
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_session_breaks_detection(tmp_path):
|
|
91
|
+
from token_finops_cli.cli import connect_readonly, session_breaks
|
|
92
|
+
from token_finops_cli.examples_helper import build_synthetic_db
|
|
93
|
+
|
|
94
|
+
db_path = tmp_path / "demo.db"
|
|
95
|
+
build_synthetic_db(str(db_path), days=10, events_per_day=5, seed=1)
|
|
96
|
+
|
|
97
|
+
con = connect_readonly(str(db_path))
|
|
98
|
+
breaks, active_s, idle_s, elapsed_s = session_breaks(
|
|
99
|
+
con, "demo-session-0", gap_minutes=60
|
|
100
|
+
)
|
|
101
|
+
con.close()
|
|
102
|
+
|
|
103
|
+
assert elapsed_s > 0
|
|
104
|
+
assert active_s + idle_s <= elapsed_s + 1 # allow float rounding
|
|
105
|
+
assert isinstance(breaks, list)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_progress_bar_bounds():
|
|
109
|
+
from token_finops_cli.cli import progress_bar
|
|
110
|
+
|
|
111
|
+
assert progress_bar(0.0).startswith("[")
|
|
112
|
+
assert "100.0%" in progress_bar(1.5) # clamps above 1
|
|
113
|
+
assert "0.0%" in progress_bar(-0.5) # clamps below 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_format_tokens():
|
|
117
|
+
from token_finops_cli.cli import format_tokens
|
|
118
|
+
|
|
119
|
+
assert format_tokens(500) == "500"
|
|
120
|
+
assert format_tokens(1_500) == "1.5k"
|
|
121
|
+
assert format_tokens(2_000_000) == "2.0M"
|
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Copilot CLI usage/efficiency summary.
|
|
3
|
-
|
|
4
|
-
Reads directly from the local session store's assistant_usage_events
|
|
5
|
-
table (~/.copilot/session-store.db) - this is the CLI's own local
|
|
6
|
-
telemetry, no external logs/network calls involved. Read-only (opened
|
|
7
|
-
in immutable/read-only mode to avoid any risk of corrupting the CLI's
|
|
8
|
-
live database while it's in use).
|
|
9
|
-
|
|
10
|
-
Also estimates a "runway" (days until a self-defined monthly AI-token
|
|
11
|
-
budget would run out at the current burn rate), based on the
|
|
12
|
-
`total_nano_aiu` column - GitHub's own cost/usage unit for a mixed
|
|
13
|
-
included-quota + pay-per-use Copilot plan (divide by 1e9 to get "AI
|
|
14
|
-
units" matching what's shown on the Copilot billing page). The budget
|
|
15
|
-
number itself is NOT fetched automatically (no API/scrape of the
|
|
16
|
-
billing page - that would need an authenticated browser session we
|
|
17
|
-
deliberately don't wire up here); it's a value you tell the script
|
|
18
|
-
yourself via --budget, defaulting to what you've told the agent
|
|
19
|
-
(50,000), and the cycle is assumed to reset monthly on --cycle-day
|
|
20
|
-
(default 1st) unless your actual billing cycle differs.
|
|
21
|
-
|
|
22
|
-
Usage:
|
|
23
|
-
python3 copilot_usage.py [--since 1d|7d|30d|all] [--session <id>]
|
|
24
|
-
[--budget 50000] [--cycle-day 1]
|
|
25
|
-
"""
|
|
26
|
-
import argparse
|
|
27
|
-
import os
|
|
28
|
-
import sqlite3
|
|
29
|
-
from datetime import datetime, timedelta, timezone
|
|
30
|
-
|
|
31
|
-
DEFAULT_DB_PATH = os.path.expanduser("~/.copilot/session-store.db")
|
|
32
|
-
DEFAULT_BUDGET_AIU = 50_000
|
|
33
|
-
|
|
34
|
-
SINCE_MAP = {
|
|
35
|
-
"1d": timedelta(days=1),
|
|
36
|
-
"7d": timedelta(days=7),
|
|
37
|
-
"30d": timedelta(days=30),
|
|
38
|
-
"all": None,
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def connect_readonly(path):
|
|
43
|
-
# Open read-only via URI so we never risk writing to the CLI's live DB.
|
|
44
|
-
uri = f"file:{path}?mode=ro"
|
|
45
|
-
return sqlite3.connect(uri, uri=True)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def fetch_stats(con, since_delta, session_id=None):
|
|
49
|
-
where = []
|
|
50
|
-
params = []
|
|
51
|
-
if since_delta is not None:
|
|
52
|
-
cutoff = (datetime.now(timezone.utc) - since_delta).isoformat()
|
|
53
|
-
where.append("created_at >= ?")
|
|
54
|
-
params.append(cutoff)
|
|
55
|
-
if session_id:
|
|
56
|
-
where.append("session_id = ?")
|
|
57
|
-
params.append(session_id)
|
|
58
|
-
where_clause = f"WHERE {' AND '.join(where)}" if where else ""
|
|
59
|
-
|
|
60
|
-
query = f"""
|
|
61
|
-
SELECT
|
|
62
|
-
COUNT(*) AS requests,
|
|
63
|
-
COALESCE(SUM(input_tokens), 0) AS total_input,
|
|
64
|
-
COALESCE(SUM(output_tokens), 0) AS total_output,
|
|
65
|
-
COALESCE(SUM(reasoning_tokens), 0) AS total_reasoning,
|
|
66
|
-
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
|
|
67
|
-
COUNT(DISTINCT session_id) AS sessions
|
|
68
|
-
FROM assistant_usage_events
|
|
69
|
-
{where_clause}
|
|
70
|
-
"""
|
|
71
|
-
cur = con.cursor()
|
|
72
|
-
cur.execute(query, params)
|
|
73
|
-
row = cur.fetchone()
|
|
74
|
-
return {
|
|
75
|
-
"requests": row[0],
|
|
76
|
-
"total_input": row[1],
|
|
77
|
-
"total_output": row[2],
|
|
78
|
-
"total_reasoning": row[3],
|
|
79
|
-
"avg_duration_ms": row[4],
|
|
80
|
-
"sessions": row[5],
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def format_tokens(n):
|
|
85
|
-
if n >= 1_000_000:
|
|
86
|
-
return f"{n / 1_000_000:.1f}M"
|
|
87
|
-
if n >= 1_000:
|
|
88
|
-
return f"{n / 1_000:.1f}k"
|
|
89
|
-
return str(n)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def current_cycle_start(cycle_day, now=None):
|
|
93
|
-
"""First moment of the current billing cycle (assumed monthly, resetting
|
|
94
|
-
on `cycle_day` of each month). If today's day-of-month is before
|
|
95
|
-
cycle_day, the cycle started last month instead."""
|
|
96
|
-
now = now or datetime.now(timezone.utc)
|
|
97
|
-
year, month = now.year, now.month
|
|
98
|
-
if now.day < cycle_day:
|
|
99
|
-
month -= 1
|
|
100
|
-
if month == 0:
|
|
101
|
-
month, year = 12, year - 1
|
|
102
|
-
return datetime(year, month, cycle_day, tzinfo=timezone.utc)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def fetch_cycle_aiu(con, cycle_start):
|
|
106
|
-
cur = con.cursor()
|
|
107
|
-
cur.execute(
|
|
108
|
-
"""
|
|
109
|
-
SELECT COALESCE(SUM(total_nano_aiu), 0), COUNT(*)
|
|
110
|
-
FROM assistant_usage_events
|
|
111
|
-
WHERE created_at >= ?
|
|
112
|
-
""",
|
|
113
|
-
(cycle_start.isoformat(),),
|
|
114
|
-
)
|
|
115
|
-
total_nano, requests = cur.fetchone()
|
|
116
|
-
return total_nano / 1e9, requests
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def runway_report(con, budget, cycle_day):
|
|
120
|
-
now = datetime.now(timezone.utc)
|
|
121
|
-
cycle_start = current_cycle_start(cycle_day, now)
|
|
122
|
-
used_aiu, requests = fetch_cycle_aiu(con, cycle_start)
|
|
123
|
-
|
|
124
|
-
elapsed_days = max((now - cycle_start).total_seconds() / 86400, 1e-6)
|
|
125
|
-
daily_rate = used_aiu / elapsed_days
|
|
126
|
-
remaining_aiu = budget - used_aiu
|
|
127
|
-
|
|
128
|
-
# Days left until the cycle itself resets (next reset date).
|
|
129
|
-
next_reset_month = cycle_start.month + 1
|
|
130
|
-
next_reset_year = cycle_start.year
|
|
131
|
-
if next_reset_month == 13:
|
|
132
|
-
next_reset_month, next_reset_year = 1, next_reset_year + 1
|
|
133
|
-
next_reset = datetime(next_reset_year, next_reset_month, cycle_start.day, tzinfo=timezone.utc)
|
|
134
|
-
days_left_in_cycle = (next_reset - now).total_seconds() / 86400
|
|
135
|
-
|
|
136
|
-
if daily_rate > 0:
|
|
137
|
-
runway_days = remaining_aiu / daily_rate
|
|
138
|
-
else:
|
|
139
|
-
runway_days = float("inf")
|
|
140
|
-
|
|
141
|
-
cycle_length_days = max((next_reset - cycle_start).total_seconds() / 86400, 1e-6)
|
|
142
|
-
cycle_fraction = 1 - (days_left_in_cycle / cycle_length_days)
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
"cycle_start": cycle_start,
|
|
146
|
-
"next_reset": next_reset,
|
|
147
|
-
"used_aiu": used_aiu,
|
|
148
|
-
"requests": requests,
|
|
149
|
-
"daily_rate": daily_rate,
|
|
150
|
-
"remaining_aiu": remaining_aiu,
|
|
151
|
-
"runway_days": runway_days,
|
|
152
|
-
"days_left_in_cycle": days_left_in_cycle,
|
|
153
|
-
"cycle_fraction": cycle_fraction,
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def progress_bar(fraction, width=30):
|
|
158
|
-
"""Render a plain-ASCII progress bar (fraction clamped to [0, 1]).
|
|
159
|
-
|
|
160
|
-
Deliberately uses only '#'/'-' rather than Unicode block characters
|
|
161
|
-
(U+2588/U+2591) - some terminal fonts don't have glyphs for those and
|
|
162
|
-
render them as blank space, making the bar invisible even though the
|
|
163
|
-
rest of the line displays fine.
|
|
164
|
-
"""
|
|
165
|
-
fraction = max(0.0, min(1.0, fraction))
|
|
166
|
-
filled = round(width * fraction)
|
|
167
|
-
bar = "#" * filled + "-" * (width - filled)
|
|
168
|
-
return f"[{bar}] {fraction * 100:5.1f}%"
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def render(args):
|
|
172
|
-
con = connect_readonly(args.db_path)
|
|
173
|
-
stats = fetch_stats(con, SINCE_MAP[args.since], args.session)
|
|
174
|
-
runway = runway_report(con, args.budget, args.cycle_day)
|
|
175
|
-
con.close()
|
|
176
|
-
|
|
177
|
-
total_tokens = stats["total_input"] + stats["total_output"]
|
|
178
|
-
tok_per_req = total_tokens / stats["requests"] if stats["requests"] else 0
|
|
179
|
-
out_in_ratio = (
|
|
180
|
-
stats["total_output"] / stats["total_input"] if stats["total_input"] else 0
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
lines = []
|
|
184
|
-
label = "all time" if args.since == "all" else f"last {args.since}"
|
|
185
|
-
lines.append(f"Copilot CLI usage ({label}):")
|
|
186
|
-
lines.append(f" requests: {stats['requests']}")
|
|
187
|
-
lines.append(f" sessions: {stats['sessions']}")
|
|
188
|
-
lines.append(f" input tokens: {format_tokens(stats['total_input'])}")
|
|
189
|
-
lines.append(f" output tokens: {format_tokens(stats['total_output'])}")
|
|
190
|
-
lines.append(f" reasoning tokens:{format_tokens(stats['total_reasoning'])}")
|
|
191
|
-
lines.append(f" tokens/request: {format_tokens(int(tok_per_req))}")
|
|
192
|
-
lines.append(f" output/input: {out_in_ratio:.2f}")
|
|
193
|
-
lines.append(f" avg duration: {stats['avg_duration_ms']/1000:.1f}s")
|
|
194
|
-
|
|
195
|
-
# Compact status-bar style one-liner
|
|
196
|
-
lines.append("")
|
|
197
|
-
lines.append(
|
|
198
|
-
f"\U0001F916 {stats['requests']} req "
|
|
199
|
-
f"\U0001FA99 {format_tokens(total_tokens)} tok "
|
|
200
|
-
f"\U0001F4C8 {format_tokens(int(tok_per_req))} tok/req "
|
|
201
|
-
f"\u23F1\uFE0F {stats['avg_duration_ms']/1000:.1f}s avg"
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
lines.append("")
|
|
205
|
-
lines.append(f"Budget runway (cycle since {runway['cycle_start'].date()}, "
|
|
206
|
-
f"resets {runway['next_reset'].date()}):")
|
|
207
|
-
used_fraction = runway["used_aiu"] / args.budget if args.budget else 0
|
|
208
|
-
lines.append(f" budget: {progress_bar(used_fraction)}")
|
|
209
|
-
lines.append(f" used: {runway['used_aiu']:.1f} / {args.budget:.0f} AI units"
|
|
210
|
-
f" ({runway['requests']} requests this cycle)")
|
|
211
|
-
lines.append(f" burn rate: {runway['daily_rate']:.1f} AI units/day")
|
|
212
|
-
if runway["runway_days"] == float("inf"):
|
|
213
|
-
lines.append(" runway: no usage yet this cycle, can't estimate")
|
|
214
|
-
else:
|
|
215
|
-
lines.append(f" runway: {runway['runway_days']:.1f} days at this rate")
|
|
216
|
-
status = "OK" if runway["runway_days"] >= runway["days_left_in_cycle"] else "WILL RUN OUT EARLY"
|
|
217
|
-
lines.append(f" vs. {runway['days_left_in_cycle']:.1f} days left in cycle -> {status}")
|
|
218
|
-
lines.append(f" cycle time: {progress_bar(runway['cycle_fraction'])}")
|
|
219
|
-
|
|
220
|
-
if runway["runway_days"] != float("inf"):
|
|
221
|
-
lines.append(f"\U0001F6E3\uFE0F runway: {runway['runway_days']:.0f}d")
|
|
222
|
-
|
|
223
|
-
return "\n".join(lines)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def render_compact(args):
|
|
227
|
-
"""Minimal 2-line render: just the budget progress bar and a one-line
|
|
228
|
-
runway summary. Meant for tiny terminal panes (e.g. a 3-line split)
|
|
229
|
-
where the full report would scroll/clip."""
|
|
230
|
-
con = connect_readonly(args.db_path)
|
|
231
|
-
runway = runway_report(con, args.budget, args.cycle_day)
|
|
232
|
-
con.close()
|
|
233
|
-
|
|
234
|
-
used_fraction = runway["used_aiu"] / args.budget if args.budget else 0
|
|
235
|
-
lines = [
|
|
236
|
-
(
|
|
237
|
-
f"AI budget {progress_bar(used_fraction)} "
|
|
238
|
-
f"{runway['used_aiu']:.0f}/{args.budget:.0f}"
|
|
239
|
-
),
|
|
240
|
-
]
|
|
241
|
-
if runway["runway_days"] == float("inf"):
|
|
242
|
-
lines.append("runway: no usage yet this cycle")
|
|
243
|
-
else:
|
|
244
|
-
status = "OK" if runway["runway_days"] >= runway["days_left_in_cycle"] else "RUNNING OUT EARLY"
|
|
245
|
-
lines.append(
|
|
246
|
-
f"\U0001F6E3\uFE0F {runway['runway_days']:.0f}d runway "
|
|
247
|
-
f"({runway['days_left_in_cycle']:.0f}d left in cycle) - {status}"
|
|
248
|
-
)
|
|
249
|
-
return "\n".join(lines)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def main():
|
|
253
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
254
|
-
parser.add_argument("--since", choices=SINCE_MAP.keys(), default="7d")
|
|
255
|
-
parser.add_argument("--session", default=None, help="Filter to one session_id")
|
|
256
|
-
parser.add_argument(
|
|
257
|
-
"--db-path",
|
|
258
|
-
default=os.environ.get("TOKEN_FINOPS_DB", DEFAULT_DB_PATH),
|
|
259
|
-
help="Path to the session-store.db to read (default: "
|
|
260
|
-
"$TOKEN_FINOPS_DB or ~/.copilot/session-store.db). Useful for "
|
|
261
|
-
"pointing at a synthetic/demo database.",
|
|
262
|
-
)
|
|
263
|
-
parser.add_argument(
|
|
264
|
-
"--budget", type=float, default=DEFAULT_BUDGET_AIU,
|
|
265
|
-
help=f"Monthly AI-unit budget for the runway estimate (default {DEFAULT_BUDGET_AIU})",
|
|
266
|
-
)
|
|
267
|
-
parser.add_argument(
|
|
268
|
-
"--cycle-day", type=int, default=1,
|
|
269
|
-
help="Day of month your billing cycle resets on (default 1st)",
|
|
270
|
-
)
|
|
271
|
-
parser.add_argument(
|
|
272
|
-
"--watch", type=float, default=None, metavar="SECONDS",
|
|
273
|
-
help="Keep running, redrawing the report every SECONDS (e.g. --watch 30). "
|
|
274
|
-
"Meant for a spare terminal pane/tmux split, not the Copilot session itself.",
|
|
275
|
-
)
|
|
276
|
-
parser.add_argument(
|
|
277
|
-
"--compact", "-c", action="store_true",
|
|
278
|
-
help="Print only the budget progress bar + one-line runway summary "
|
|
279
|
-
"(2 lines total) instead of the full report. Good for tiny "
|
|
280
|
-
"terminal panes/splits.",
|
|
281
|
-
)
|
|
282
|
-
parser.add_argument(
|
|
283
|
-
"--verbose", "-vv", action="store_true",
|
|
284
|
-
help="Explicitly request the full report (this is the default; "
|
|
285
|
-
"provided as the counterpart to --compact/-c).",
|
|
286
|
-
)
|
|
287
|
-
args = parser.parse_args()
|
|
288
|
-
|
|
289
|
-
renderer = render_compact if (args.compact and not args.verbose) else render
|
|
290
|
-
|
|
291
|
-
if args.watch is None:
|
|
292
|
-
print(renderer(args))
|
|
293
|
-
return
|
|
294
|
-
|
|
295
|
-
import time
|
|
296
|
-
try:
|
|
297
|
-
while True:
|
|
298
|
-
# Clear screen + move cursor home (ANSI), then redraw in place.
|
|
299
|
-
print("\033[2J\033[H", end="")
|
|
300
|
-
print(renderer(args))
|
|
301
|
-
print(f"\n(refreshing every {args.watch:.0f}s, Ctrl+C to stop)")
|
|
302
|
-
time.sleep(args.watch)
|
|
303
|
-
except KeyboardInterrupt:
|
|
304
|
-
pass
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if __name__ == "__main__":
|
|
308
|
-
main()
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
"""Smoke tests for token_finops_cli — no live Copilot DB required."""
|
|
2
|
-
import subprocess
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_help_runs():
|
|
7
|
-
result = subprocess.run(
|
|
8
|
-
[sys.executable, "-m", "token_finops_cli.cli", "--help"],
|
|
9
|
-
capture_output=True,
|
|
10
|
-
text=True,
|
|
11
|
-
check=False,
|
|
12
|
-
)
|
|
13
|
-
assert result.returncode == 0
|
|
14
|
-
assert "--budget" in result.stdout
|
|
15
|
-
assert "--watch" in result.stdout
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_progress_bar_bounds():
|
|
19
|
-
from token_finops_cli.cli import progress_bar
|
|
20
|
-
|
|
21
|
-
assert progress_bar(0.0).startswith("[")
|
|
22
|
-
assert "100.0%" in progress_bar(1.5) # clamps above 1
|
|
23
|
-
assert "0.0%" in progress_bar(-0.5) # clamps below 0
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_format_tokens():
|
|
27
|
-
from token_finops_cli.cli import format_tokens
|
|
28
|
-
|
|
29
|
-
assert format_tokens(500) == "500"
|
|
30
|
-
assert format_tokens(1_500) == "1.5k"
|
|
31
|
-
assert format_tokens(2_000_000) == "2.0M"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|