feat: add sub2apicn request logs page
- new logs page (press l / a to switch, or start with --logs) reading the latest 100 requests from the Sub2API admin usage API on sub2apicn - columns: Key, Account, Model, Type, Tokens, Cost, Latency, Time with a per-row detail line (token buckets, actual cost, first-token latency) - refreshes every 60s by default (--logs-refresh-seconds, --logs-limit) - admin API key read from SHUSUB2_LOGS_TOKEN or ~/.config/shusub2/logs-token; --save-config now persists logs-url/logs-token too - --once --logs prints a one-shot logs snapshot Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -323,5 +323,167 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
||||
self.assertIn("monitors 1 ok / 0 failed / 0 unknown", out.getvalue())
|
||||
|
||||
|
||||
def sample_logs_payload() -> dict:
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"total": 2345,
|
||||
"page": 1,
|
||||
"page_size": 100,
|
||||
"items": [
|
||||
{
|
||||
"id": 11,
|
||||
"api_key_id": 3,
|
||||
"api_key": {"id": 3, "name": "codex-main"},
|
||||
"account_id": 7,
|
||||
"account": {"id": 7, "name": "oai-sub-1"},
|
||||
"user": {"id": 1, "name": "shujakuin"},
|
||||
"model": "gpt-5.5",
|
||||
"upstream_model": "gpt-5.5-codex",
|
||||
"request_type": "stream",
|
||||
"stream": True,
|
||||
"input_tokens": 1200,
|
||||
"output_tokens": 340,
|
||||
"cache_creation_tokens": 50,
|
||||
"cache_read_tokens": 4100,
|
||||
"total_cost": 0.0123,
|
||||
"actual_cost": 0.0061,
|
||||
"duration_ms": 5321,
|
||||
"first_token_ms": 800,
|
||||
"request_id": "req-abc",
|
||||
"created_at": "2026-07-21T10:00:00+08:00",
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"api_key_id": 4,
|
||||
"account_id": 9,
|
||||
"account": {"id": 9, "name": "claude-max"},
|
||||
"model": "claude-sonnet-5",
|
||||
"request_type": "",
|
||||
"stream": False,
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 20,
|
||||
"total_cost": 0.001,
|
||||
"duration_ms": 900,
|
||||
"created_at": "2026-07-21T10:05:00+08:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Sub2APILogsTests(unittest.TestCase):
|
||||
def test_normalize_log_rows_maps_columns_and_sorts_newest_first(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
rows = mod.normalize_log_rows(sample_logs_payload())
|
||||
|
||||
self.assertEqual([row["id"] for row in rows], [12, 11])
|
||||
newest, oldest = rows
|
||||
self.assertEqual(newest["key"], "#4")
|
||||
self.assertEqual(newest["account"], "claude-max")
|
||||
self.assertEqual(newest["type"], "sync")
|
||||
self.assertEqual(newest["tokens"], 120)
|
||||
self.assertEqual(oldest["key"], "codex-main")
|
||||
self.assertEqual(oldest["account"], "oai-sub-1")
|
||||
self.assertEqual(oldest["user"], "shujakuin")
|
||||
self.assertEqual(oldest["type"], "stream")
|
||||
self.assertEqual(oldest["tokens"], 1200 + 340 + 50 + 4100)
|
||||
self.assertEqual(oldest["cost"], 0.0123)
|
||||
self.assertEqual(oldest["duration_ms"], 5321)
|
||||
self.assertTrue(oldest["time"].endswith("10:00") or oldest["time"] != "-")
|
||||
|
||||
def test_normalize_log_rows_filter_matches_key_account_model(self) -> None:
|
||||
mod = load_module()
|
||||
payload = sample_logs_payload()
|
||||
|
||||
self.assertEqual([row["id"] for row in mod.normalize_log_rows(payload, "codex-main")], [11])
|
||||
self.assertEqual([row["id"] for row in mod.normalize_log_rows(payload, "claude")], [12])
|
||||
self.assertEqual([row["id"] for row in mod.normalize_log_rows(payload, "stream")], [11])
|
||||
self.assertEqual(mod.normalize_log_rows(payload, "no-match"), [])
|
||||
|
||||
def test_log_items_supports_flat_and_enveloped_payloads(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
self.assertEqual(len(mod.log_items(sample_logs_payload())), 2)
|
||||
self.assertEqual(len(mod.log_items({"items": [{"id": 1}]})), 1)
|
||||
self.assertEqual(mod.log_items({"data": {"items": "nope"}}), [])
|
||||
self.assertEqual(mod.log_items({}), [])
|
||||
|
||||
def test_log_type_label_falls_back_to_legacy_flags(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
self.assertEqual(mod.log_type_label({"request_type": "ws_v2"}), "ws_v2")
|
||||
self.assertEqual(mod.log_type_label({"request_type": "unknown", "openai_ws_mode": True}), "ws_v2")
|
||||
self.assertEqual(mod.log_type_label({"stream": True}), "stream")
|
||||
self.assertEqual(mod.log_type_label({"stream": False}), "sync")
|
||||
self.assertEqual(mod.log_type_label({}), "-")
|
||||
|
||||
def test_logs_request_url_forces_first_page_and_limit(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
url = mod.logs_request_url("https://sub2apicn.shujk.top/api/v1/admin/usage?page=9&page_size=5&user_id=2", 100)
|
||||
parsed = dict(part.split("=") for part in url.split("?", 1)[1].split("&"))
|
||||
self.assertEqual(parsed["page"], "1")
|
||||
self.assertEqual(parsed["page_size"], "100")
|
||||
self.assertEqual(parsed["user_id"], "2")
|
||||
|
||||
capped = mod.logs_request_url("https://sub2apicn.shujk.top/api/v1/admin/usage", 9000)
|
||||
self.assertIn("page_size=1000", capped)
|
||||
|
||||
def test_print_logs_once_renders_requested_columns(self) -> None:
|
||||
mod = load_module()
|
||||
out = io.StringIO()
|
||||
|
||||
with contextlib.redirect_stdout(out):
|
||||
mod.print_logs_once(sample_logs_payload())
|
||||
|
||||
text = out.getvalue()
|
||||
self.assertIn("key", text)
|
||||
self.assertIn("codex-main", text)
|
||||
self.assertIn("oai-sub-1", text)
|
||||
self.assertIn("gpt-5.5", text)
|
||||
self.assertIn("stream", text)
|
||||
self.assertIn("5.7K", text)
|
||||
self.assertIn("$0.012", text)
|
||||
self.assertIn("5321ms", text)
|
||||
self.assertIn("total 2.3K records", text)
|
||||
|
||||
def test_default_logs_token_reads_config_file(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
token_file = Path(tmp) / "logs-token"
|
||||
token_file.write_text("# comment\nsk-admin-token\n", encoding="utf-8")
|
||||
old_value = os.environ.get("SHUSUB2_LOGS_TOKEN_FILE")
|
||||
os.environ["SHUSUB2_LOGS_TOKEN_FILE"] = str(token_file)
|
||||
try:
|
||||
self.assertEqual(mod.default_logs_token(), "sk-admin-token")
|
||||
written = mod.write_config_value(mod.logs_token_config_file_path(), "sk-new-token")
|
||||
self.assertEqual(written, token_file)
|
||||
self.assertEqual(token_file.read_text(encoding="utf-8"), "sk-new-token\n")
|
||||
finally:
|
||||
if old_value is None:
|
||||
os.environ.pop("SHUSUB2_LOGS_TOKEN_FILE", None)
|
||||
else:
|
||||
os.environ["SHUSUB2_LOGS_TOKEN_FILE"] = old_value
|
||||
|
||||
def test_default_logs_url_defaults_to_sub2apicn(self) -> None:
|
||||
mod = load_module()
|
||||
old_url = os.environ.pop("SHUSUB2_LOGS_URL", None)
|
||||
old_file = os.environ.get("SHUSUB2_LOGS_URL_FILE")
|
||||
os.environ["SHUSUB2_LOGS_URL_FILE"] = "/nonexistent/shusub2/logs-url"
|
||||
try:
|
||||
self.assertEqual(mod.default_logs_url(), "https://sub2apicn.shujk.top/api/v1/admin/usage")
|
||||
finally:
|
||||
if old_url is not None:
|
||||
os.environ["SHUSUB2_LOGS_URL"] = old_url
|
||||
if old_file is None:
|
||||
os.environ.pop("SHUSUB2_LOGS_URL_FILE", None)
|
||||
else:
|
||||
os.environ["SHUSUB2_LOGS_URL_FILE"] = old_file
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user