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:
2026-07-21 12:32:10 +08:00
parent fd4e3256d4
commit 0e9b53dc79
5 changed files with 220 additions and 4 deletions
+79
View File
@@ -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()