From 0e9b53dc7928dae12946cfc3f10a94f77b222660 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Tue, 21 Jul 2026 12:32:10 +0800 Subject: [PATCH] feat: per-key today usage panel under the accounts table - accounts page gains a #keys DataTable below the accounts table showing each API key's usage today: Key | Today | Tokens | Req, sorted by cost - data comes from the Sub2API admin dashboard api-keys-trend (requests, tokens, key names) plus api-keys-usage (today_actual_cost), derived from the configured logs url and reusing the same admin API key - key names use the same stable per-key colors as the logs page - --once prints the same panel after the accounts snapshot; failures or a missing token surface in the status line without touching accounts Co-Authored-By: Claude Fable 5 --- README.md | 8 +++ pyproject.toml | 2 +- sub2api_quota_tui.py | 133 +++++++++++++++++++++++++++++++++++++++++- tests/test_payload.py | 79 +++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 220 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a505e04..69fcf99 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,14 @@ automatically enables monitor availability. On startup it also checks the public Gitea repo for a newer package version and prints a short upgrade hint when one is available. +Below the accounts table, a per-key panel shows today's usage for each +API key (`Key | Today | Tokens | Req`, sorted by cost, key names in the +same per-key colors as the logs page). It combines the Sub2API admin +`dashboard/api-keys-trend` and `dashboard/api-keys-usage` endpoints and +needs the same admin API key as the logs page; without a token the panel +stays empty and the status line shows a hint. `--once` prints the same +panel after the accounts table when a token is configured. + Accounts page columns are ordered for scanning inside zellij: ```text diff --git a/pyproject.toml b/pyproject.toml index 71e1f37..bf2d314 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.2.2" +version = "0.2.3" description = "Terminal UI for Sub2API account quota and daily usage" readme = "README.md" requires-python = ">=3.11" diff --git a/sub2api_quota_tui.py b/sub2api_quota_tui.py index b11522b..a6098e3 100644 --- a/sub2api_quota_tui.py +++ b/sub2api_quota_tui.py @@ -18,7 +18,7 @@ from typing import Any APP_NAME = "shusub2" -FALLBACK_VERSION = "0.2.2" +FALLBACK_VERSION = "0.2.3" DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts" DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url" DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-url" @@ -397,6 +397,94 @@ def key_color(name: Any) -> str: return KEY_COLOR_PALETTE[zlib.crc32(text.encode("utf-8")) % len(KEY_COLOR_PALETTE)] +def admin_api_base(logs_url: str) -> str: + parsed = urllib.parse.urlparse(str(logs_url or "").strip()) + if not parsed.scheme or not parsed.netloc: + return "" + path = parsed.path.rstrip("/") + if path.endswith("/usage"): + path = path[: -len("/usage")] + if not path.endswith("/admin"): + return "" + return urllib.parse.urlunparse(parsed._replace(path=path, params="", query="", fragment="")) + + +def fetch_admin_json(url: str, token: str, timeout: int, body: dict[str, Any] | None = None) -> dict[str, Any]: + headers = {"Accept": "application/json"} + if str(token or "").strip(): + headers["x-api-key"] = str(token).strip() + data = None + if body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + if not isinstance(payload, dict): + raise RuntimeError("admin API did not return a JSON object") + return payload + + +def payload_data(payload: dict[str, Any]) -> dict[str, Any]: + data = payload.get("data") + return data if isinstance(data, dict) else payload + + +def fetch_key_usage_payload(logs_url: str, token: str, timeout: int, limit: int = 100) -> dict[str, Any]: + base = admin_api_base(logs_url) + if not base: + raise RuntimeError("cannot derive the admin API base from the logs url") + today = dt.date.today().isoformat() + query = urllib.parse.urlencode({"start_date": today, "end_date": today, "granularity": "day", "limit": limit}) + trend_payload = fetch_admin_json(f"{base}/dashboard/api-keys-trend?{query}", token, timeout) + trend = payload_data(trend_payload).get("trend") + points = [point for point in trend if isinstance(point, dict)] if isinstance(trend, list) else [] + ids = sorted({as_int(point.get("api_key_id")) for point in points if as_int(point.get("api_key_id"))}) + stats: dict[str, Any] = {} + if ids: + costs_payload = fetch_admin_json(f"{base}/dashboard/api-keys-usage", token, timeout, body={"api_key_ids": ids}) + raw_stats = payload_data(costs_payload).get("stats") + if isinstance(raw_stats, dict): + stats = raw_stats + return {"date": today, "trend": points, "stats": stats} + + +def normalize_key_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: + stats = payload.get("stats") if isinstance(payload.get("stats"), dict) else {} + merged: dict[Any, dict[str, Any]] = {} + for point in payload.get("trend") or []: + if not isinstance(point, dict): + continue + key_id = as_int(point.get("api_key_id")) + name = str(point.get("key_name") or "").strip() or (f"#{key_id}" if key_id else "-") + row = merged.setdefault(key_id or name, {"id": key_id, "name": name, "requests": 0, "tokens": 0, "cost": 0.0}) + row["requests"] += as_int(point.get("requests")) + row["tokens"] += as_int(point.get("tokens")) + for row in merged.values(): + stat = stats.get(str(row["id"])) + if not isinstance(stat, dict): + stat = stats.get(row["id"]) + if isinstance(stat, dict): + row["cost"] = as_float(stat.get("today_actual_cost")) + rows = list(merged.values()) + rows.sort(key=lambda row: (-row["cost"], -row["tokens"], str(row["name"]).lower())) + return rows + + +def print_keys_once(payload: dict[str, Any]) -> None: + rows = normalize_key_rows(payload) + total_cost = sum(row["cost"] for row in rows) + print(f"keys today {payload.get('date') or '-'} | {len(rows)} keys | {format_cost(total_cost)}") + print("key today tokens req") + for row in rows: + print( + f"{str(row['name'])[:20]:<21} " + f"{format_cost(row['cost']):<10} " + f"{format_count(row['tokens']):<8} " + f"{format_count(row['requests'])}" + ) + + def nested_name(item: dict[str, Any], object_key: str, id_key: str, name_key: str = "") -> str: nested = item.get(object_key) if isinstance(nested, dict): @@ -810,6 +898,8 @@ def run_textual( self.payload: dict[str, Any] = {} self.status_payload: dict[str, Any] = {} self.status_error = "" + self.keys_payload: dict[str, Any] = {} + self.keys_error = "" self.rows: list[dict[str, Any]] = [] self.row_by_key: dict[str, dict[str, Any]] = {} @@ -818,6 +908,7 @@ def run_textual( yield Static("", id="summary") yield Input(placeholder="filter", id="filter") yield DataTable(id="accounts") + yield DataTable(id="keys") yield Static("", id="detail") yield Static("", id="status") yield Footer() @@ -827,6 +918,10 @@ def run_textual( table.cursor_type = "row" table.zebra_stripes = True table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status", "Availability") + keys_table = self.query_one("#keys", DataTable) + keys_table.cursor_type = "row" + keys_table.zebra_stripes = True + keys_table.add_columns("Key", "Today", "Tokens", "Req") self.refresh_data(refresh=True) self.set_interval(refresh_seconds, self.refresh_data) @@ -853,12 +948,39 @@ def run_textual( try: self.payload = fetch_payload(api_url, timeout, refresh=refresh) self.status_payload, self.status_error = fetch_optional_payload(status_url, timeout) + self.refresh_keys() self.render_payload() + self.render_keys() status_bits = [version_message, monitor_summary(self.status_payload, self.status_error), f"source {self.payload.get('source_name') or '-'}"] + if self.keys_error: + status_bits.append(f"keys: {self.keys_error}") status.update(" | ".join(bit for bit in status_bits if bit) + f" | {api_url}") except Exception as exc: status.update(f"error: {exc}") + def refresh_keys(self) -> None: + self.keys_error = "" + self.keys_payload = {} + if not str(logs_token or "").strip(): + self.keys_error = "logs token not configured" + return + try: + self.keys_payload = fetch_key_usage_payload(logs_url, logs_token, timeout) + except Exception as exc: + self.keys_error = str(exc) + + def render_keys(self) -> None: + table = self.query_one("#keys", DataTable) + table.clear() + for row in normalize_key_rows(self.keys_payload): + color = key_color(row["name"]) + table.add_row( + Text(str(row["name"]), style=color) if color else str(row["name"]), + format_cost(row["cost"]), + format_count(row["tokens"]), + format_count(row["requests"]), + ) + def render_payload(self) -> None: filter_text = self.query_one("#filter", Input).value self.rows = normalize_account_rows(self.payload, filter_text) @@ -1014,7 +1136,8 @@ def run_textual( CSS = """ #summary { height: 1; padding: 0 1; color: $accent; } #filter { height: 3; } - #accounts { height: 1fr; } + #accounts { height: 2fr; } + #keys { height: 1fr; border-top: solid $panel; } #logs { height: 1fr; } #detail { height: 3; padding: 0 1; border-top: solid $panel; } #status { height: 1; padding: 0 1; color: $text-muted; } @@ -1106,6 +1229,12 @@ def main(argv: list[str] | None = None) -> int: return 0 status_payload, status_error = fetch_optional_payload(status_url, args.timeout) print_once(fetch_payload(args.api_url, args.timeout, refresh=True), args.filter, status_payload, status_error) + if str(args.logs_token or "").strip(): + print() + try: + print_keys_once(fetch_key_usage_payload(args.logs_url, args.logs_token, args.timeout)) + except Exception as exc: + print(f"keys error: {exc}", file=sys.stderr) return 0 return run_textual( args.api_url, diff --git a/tests/test_payload.py b/tests/test_payload.py index 3e1d9e6..1370d0a 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -563,5 +563,84 @@ class Sub2APILogsTests(unittest.TestCase): os.environ["SHUSUB2_LOGS_URL_FILE"] = old_file +def sample_key_usage_payload() -> dict: + return { + "date": "2026-07-21", + "trend": [ + {"date": "2026-07-21", "api_key_id": 3, "key_name": "codex-main", "requests": 120, "tokens": 4_500_000}, + {"date": "2026-07-21", "api_key_id": 4, "key_name": "claude-max", "requests": 40, "tokens": 9_100_000}, + {"date": "2026-07-21", "api_key_id": 9, "key_name": "test", "requests": 3, "tokens": 250}, + ], + "stats": { + "3": {"api_key_id": 3, "today_actual_cost": 12.5, "total_actual_cost": 100.0}, + "4": {"api_key_id": 4, "today_actual_cost": 30.25, "total_actual_cost": 90.0}, + }, + } + + +class Sub2APIKeyUsageTests(unittest.TestCase): + def test_admin_api_base_derived_from_logs_url(self) -> None: + mod = load_module() + + self.assertEqual( + mod.admin_api_base("https://sub2apicn.shujk.top/api/v1/admin/usage"), + "https://sub2apicn.shujk.top/api/v1/admin", + ) + self.assertEqual( + mod.admin_api_base("https://sub2apicn.shujk.top/api/v1/admin/usage/"), + "https://sub2apicn.shujk.top/api/v1/admin", + ) + self.assertEqual( + mod.admin_api_base("https://sub2apicn.shujk.top/api/v1/admin"), + "https://sub2apicn.shujk.top/api/v1/admin", + ) + self.assertEqual(mod.admin_api_base("https://example.com/other/path"), "") + self.assertEqual(mod.admin_api_base("not a url"), "") + + def test_normalize_key_rows_merges_costs_and_sorts_by_cost(self) -> None: + mod = load_module() + + rows = mod.normalize_key_rows(sample_key_usage_payload()) + + self.assertEqual([row["name"] for row in rows], ["claude-max", "codex-main", "test"]) + top = rows[0] + self.assertEqual(top["cost"], 30.25) + self.assertEqual(top["tokens"], 9_100_000) + self.assertEqual(top["requests"], 40) + # key 9 has no cost stats -> cost 0, sorted last + self.assertEqual(rows[-1]["cost"], 0.0) + self.assertEqual(rows[-1]["requests"], 3) + + def test_normalize_key_rows_aggregates_multiple_points_per_key(self) -> None: + mod = load_module() + payload = { + "trend": [ + {"api_key_id": 3, "key_name": "codex-main", "requests": 10, "tokens": 100}, + {"api_key_id": 3, "key_name": "codex-main", "requests": 5, "tokens": 50}, + ], + "stats": {}, + } + + rows = mod.normalize_key_rows(payload) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["requests"], 15) + self.assertEqual(rows[0]["tokens"], 150) + + def test_print_keys_once_renders_key_summary(self) -> None: + mod = load_module() + out = io.StringIO() + + with contextlib.redirect_stdout(out): + mod.print_keys_once(sample_key_usage_payload()) + + text = out.getvalue() + self.assertIn("keys today 2026-07-21 | 3 keys | $42.75", text) + self.assertIn("claude-max", text) + self.assertIn("$30.25", text) + self.assertIn("9.1M", text) + self.assertIn("codex-main", text) + + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index 6535808..9f47fad 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.2.2" +version = "0.2.3" source = { editable = "." } dependencies = [ { name = "textual" },