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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
public Gitea repo for a newer package version and prints a short upgrade hint
|
||||||
when one is available.
|
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:
|
Accounts page columns are ordered for scanning inside zellij:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "shusub2"
|
name = "shusub2"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
description = "Terminal UI for Sub2API account quota and daily usage"
|
description = "Terminal UI for Sub2API account quota and daily usage"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+131
-2
@@ -18,7 +18,7 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
APP_NAME = "shusub2"
|
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_API_URL = "http://127.0.0.1:18318/api/tui/accounts"
|
||||||
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
|
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
|
||||||
DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-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)]
|
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:
|
def nested_name(item: dict[str, Any], object_key: str, id_key: str, name_key: str = "") -> str:
|
||||||
nested = item.get(object_key)
|
nested = item.get(object_key)
|
||||||
if isinstance(nested, dict):
|
if isinstance(nested, dict):
|
||||||
@@ -810,6 +898,8 @@ def run_textual(
|
|||||||
self.payload: dict[str, Any] = {}
|
self.payload: dict[str, Any] = {}
|
||||||
self.status_payload: dict[str, Any] = {}
|
self.status_payload: dict[str, Any] = {}
|
||||||
self.status_error = ""
|
self.status_error = ""
|
||||||
|
self.keys_payload: dict[str, Any] = {}
|
||||||
|
self.keys_error = ""
|
||||||
self.rows: list[dict[str, Any]] = []
|
self.rows: list[dict[str, Any]] = []
|
||||||
self.row_by_key: dict[str, dict[str, Any]] = {}
|
self.row_by_key: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
@@ -818,6 +908,7 @@ def run_textual(
|
|||||||
yield Static("", id="summary")
|
yield Static("", id="summary")
|
||||||
yield Input(placeholder="filter", id="filter")
|
yield Input(placeholder="filter", id="filter")
|
||||||
yield DataTable(id="accounts")
|
yield DataTable(id="accounts")
|
||||||
|
yield DataTable(id="keys")
|
||||||
yield Static("", id="detail")
|
yield Static("", id="detail")
|
||||||
yield Static("", id="status")
|
yield Static("", id="status")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
@@ -827,6 +918,10 @@ def run_textual(
|
|||||||
table.cursor_type = "row"
|
table.cursor_type = "row"
|
||||||
table.zebra_stripes = True
|
table.zebra_stripes = True
|
||||||
table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status", "Availability")
|
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.refresh_data(refresh=True)
|
||||||
self.set_interval(refresh_seconds, self.refresh_data)
|
self.set_interval(refresh_seconds, self.refresh_data)
|
||||||
|
|
||||||
@@ -853,12 +948,39 @@ def run_textual(
|
|||||||
try:
|
try:
|
||||||
self.payload = fetch_payload(api_url, timeout, refresh=refresh)
|
self.payload = fetch_payload(api_url, timeout, refresh=refresh)
|
||||||
self.status_payload, self.status_error = fetch_optional_payload(status_url, timeout)
|
self.status_payload, self.status_error = fetch_optional_payload(status_url, timeout)
|
||||||
|
self.refresh_keys()
|
||||||
self.render_payload()
|
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 '-'}"]
|
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}")
|
status.update(" | ".join(bit for bit in status_bits if bit) + f" | {api_url}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status.update(f"error: {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:
|
def render_payload(self) -> None:
|
||||||
filter_text = self.query_one("#filter", Input).value
|
filter_text = self.query_one("#filter", Input).value
|
||||||
self.rows = normalize_account_rows(self.payload, filter_text)
|
self.rows = normalize_account_rows(self.payload, filter_text)
|
||||||
@@ -1014,7 +1136,8 @@ def run_textual(
|
|||||||
CSS = """
|
CSS = """
|
||||||
#summary { height: 1; padding: 0 1; color: $accent; }
|
#summary { height: 1; padding: 0 1; color: $accent; }
|
||||||
#filter { height: 3; }
|
#filter { height: 3; }
|
||||||
#accounts { height: 1fr; }
|
#accounts { height: 2fr; }
|
||||||
|
#keys { height: 1fr; border-top: solid $panel; }
|
||||||
#logs { height: 1fr; }
|
#logs { height: 1fr; }
|
||||||
#detail { height: 3; padding: 0 1; border-top: solid $panel; }
|
#detail { height: 3; padding: 0 1; border-top: solid $panel; }
|
||||||
#status { height: 1; padding: 0 1; color: $text-muted; }
|
#status { height: 1; padding: 0 1; color: $text-muted; }
|
||||||
@@ -1106,6 +1229,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
status_payload, status_error = fetch_optional_payload(status_url, args.timeout)
|
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)
|
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 0
|
||||||
return run_textual(
|
return run_textual(
|
||||||
args.api_url,
|
args.api_url,
|
||||||
|
|||||||
@@ -563,5 +563,84 @@ class Sub2APILogsTests(unittest.TestCase):
|
|||||||
os.environ["SHUSUB2_LOGS_URL_FILE"] = old_file
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user