feat: merge cn+us ops errors page into shusub2
Add an errors screen that pulls admin ops error logs from sub2apicn and the server4 fixed origin in parallel, refreshes every 60s while active, and keeps the 0.2.3 key-usage panel and logs enhancements.
This commit is contained in:
+149
-123
@@ -341,7 +341,6 @@ def sample_logs_payload() -> dict:
|
||||
"user": {"id": 1, "name": "shujakuin"},
|
||||
"model": "gpt-5.5",
|
||||
"upstream_model": "gpt-5.5-codex",
|
||||
"reasoning_effort": "high",
|
||||
"request_type": "stream",
|
||||
"stream": True,
|
||||
"input_tokens": 1200,
|
||||
@@ -433,55 +432,6 @@ class Sub2APILogsTests(unittest.TestCase):
|
||||
capped = mod.logs_request_url("https://sub2apicn.shujk.top/api/v1/admin/usage", 9000)
|
||||
self.assertIn("page_size=1000", capped)
|
||||
|
||||
def test_log_tokens_per_second_uses_decode_window(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
item = {"output_tokens": 340, "duration_ms": 5321, "first_token_ms": 800}
|
||||
self.assertAlmostEqual(mod.log_tokens_per_second(item), 340 * 1000.0 / (5321 - 800))
|
||||
self.assertEqual(mod.format_rate(mod.log_tokens_per_second(item)), "75.2/s")
|
||||
# missing first token: falls back to the full duration
|
||||
self.assertAlmostEqual(mod.log_tokens_per_second({"output_tokens": 100, "duration_ms": 2000}), 50.0)
|
||||
# no output, zero duration, or first token >= duration -> no rate
|
||||
self.assertEqual(mod.log_tokens_per_second({"output_tokens": 0, "duration_ms": 2000}), 0.0)
|
||||
self.assertEqual(mod.log_tokens_per_second({"output_tokens": 10, "duration_ms": 0}), 0.0)
|
||||
self.assertEqual(mod.log_tokens_per_second({"output_tokens": 10, "duration_ms": 500, "first_token_ms": 500}), 0.0)
|
||||
self.assertEqual(mod.format_rate(0), "-")
|
||||
self.assertEqual(mod.format_rate(123.4), "123/s")
|
||||
|
||||
def test_normalize_log_rows_carries_first_token_and_rate(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
rows = mod.normalize_log_rows(sample_logs_payload())
|
||||
oldest = rows[-1]
|
||||
self.assertEqual(oldest["first_token_ms"], 800)
|
||||
self.assertAlmostEqual(oldest["tokens_per_second"], 340 * 1000.0 / 4521)
|
||||
self.assertEqual(oldest["effort"], "high")
|
||||
self.assertEqual(rows[0]["effort"], "-")
|
||||
detail = mod.log_detail_line(oldest)
|
||||
self.assertIn("first 0.8s latency 5.3s", detail)
|
||||
self.assertIn("(high)", detail)
|
||||
self.assertIn("75.2/s", detail)
|
||||
|
||||
def test_format_seconds_converts_ms(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
self.assertEqual(mod.format_seconds(5321), "5.3s")
|
||||
self.assertEqual(mod.format_seconds(800), "0.8s")
|
||||
self.assertEqual(mod.format_seconds(200735), "200.7s")
|
||||
self.assertEqual(mod.format_seconds(0), "-")
|
||||
self.assertEqual(mod.format_seconds(None), "-")
|
||||
|
||||
def test_key_color_is_stable_and_from_palette(self) -> None:
|
||||
mod = load_module()
|
||||
|
||||
first = mod.key_color("codex-main")
|
||||
self.assertIn(first, mod.KEY_COLOR_PALETTE)
|
||||
self.assertEqual(first, mod.key_color("codex-main"))
|
||||
self.assertEqual(mod.key_color(""), "")
|
||||
self.assertEqual(mod.key_color("-"), "")
|
||||
colors = {mod.key_color(name) for name in ("a", "b", "c", "d", "e", "f")}
|
||||
self.assertGreater(len(colors), 1)
|
||||
|
||||
def test_print_logs_once_renders_requested_columns(self) -> None:
|
||||
mod = load_module()
|
||||
out = io.StringIO()
|
||||
@@ -497,15 +447,7 @@ class Sub2APILogsTests(unittest.TestCase):
|
||||
self.assertIn("stream", text)
|
||||
self.assertIn("5.7K", text)
|
||||
self.assertIn("$0.012", text)
|
||||
header = text.splitlines()[1]
|
||||
self.assertLess(header.index("model"), header.index("effort"))
|
||||
self.assertLess(header.index("effort"), header.index("type"))
|
||||
self.assertLess(header.index("first"), header.index("latency"))
|
||||
self.assertIn("tok/s", header)
|
||||
self.assertIn("high", text)
|
||||
self.assertIn("5.3s", text)
|
||||
self.assertIn("0.8s", text)
|
||||
self.assertIn("75.2/s", text)
|
||||
self.assertIn("total 2.3K records", text)
|
||||
|
||||
def test_default_logs_token_reads_config_file(self) -> None:
|
||||
@@ -562,84 +504,168 @@ class Sub2APILogsTests(unittest.TestCase):
|
||||
else:
|
||||
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:
|
||||
def test_errors_request_url_forces_page_limit_and_time_range(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",
|
||||
url = mod.errors_request_url(
|
||||
"https://sub2apicn.shujk.top/api/v1/admin/ops/errors?page=9&page_size=5&q=x",
|
||||
100,
|
||||
"6h",
|
||||
)
|
||||
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"), "")
|
||||
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["time_range"], "6h")
|
||||
self.assertEqual(parsed["q"], "x")
|
||||
|
||||
def test_normalize_key_rows_merges_costs_and_sorts_by_cost(self) -> None:
|
||||
mod = load_module()
|
||||
capped = mod.errors_request_url("https://sub2apicn.shujk.top/api/v1/admin/ops/errors", 9000)
|
||||
self.assertIn("page_size=500", capped)
|
||||
self.assertIn("time_range=24h", capped)
|
||||
|
||||
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:
|
||||
def test_normalize_error_rows_merges_filters_and_sorts(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": {},
|
||||
"items": [
|
||||
{
|
||||
"_node": "cn",
|
||||
"id": 1,
|
||||
"created_at": "2026-07-24T10:00:00+08:00",
|
||||
"status_code": 429,
|
||||
"api_key_name": "key-a",
|
||||
"account_name": "acc-a",
|
||||
"model": "gpt-5.5",
|
||||
"phase": "upstream",
|
||||
"type": "rate_limit",
|
||||
"error_owner": "provider",
|
||||
"error_source": "upstream_http",
|
||||
"message": "too many requests",
|
||||
"request_id": "req-a",
|
||||
},
|
||||
{
|
||||
"_node": "us",
|
||||
"id": 2,
|
||||
"created_at": "2026-07-24T11:00:00+08:00",
|
||||
"status_code": 500,
|
||||
"api_key_id": 9,
|
||||
"account_id": 12,
|
||||
"requested_model": "claude-opus",
|
||||
"phase": "internal",
|
||||
"type": "api_error",
|
||||
"error_owner": "platform",
|
||||
"error_source": "gateway",
|
||||
"message": "upstream failed",
|
||||
"request_id": "req-b",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
rows = mod.normalize_key_rows(payload)
|
||||
rows = mod.normalize_error_rows(payload)
|
||||
self.assertEqual([row["id"] for row in rows], [2, 1])
|
||||
self.assertEqual(rows[0]["node"], "us")
|
||||
self.assertEqual(rows[0]["key"], "#9")
|
||||
self.assertEqual(rows[0]["account"], "#12")
|
||||
self.assertEqual(rows[0]["model"], "claude-opus")
|
||||
self.assertEqual([row["id"] for row in mod.normalize_error_rows(payload, "rate_limit")], [1])
|
||||
self.assertEqual([row["id"] for row in mod.normalize_error_rows(payload, "us")], [2])
|
||||
self.assertEqual(mod.normalize_error_rows(payload, "no-match"), [])
|
||||
|
||||
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:
|
||||
def test_fetch_merged_errors_labels_nodes_and_tolerates_partial_failure(self) -> None:
|
||||
mod = load_module()
|
||||
out = io.StringIO()
|
||||
|
||||
with contextlib.redirect_stdout(out):
|
||||
mod.print_keys_once(sample_key_usage_payload())
|
||||
def fake_fetch(url: str, token: str, timeout: int, limit: int = 100, time_range: str = "24h"):
|
||||
if "cn" in url:
|
||||
return {
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 10,
|
||||
"created_at": "2026-07-24T12:00:00+08:00",
|
||||
"status_code": 404,
|
||||
"model": "gpt-a",
|
||||
"message": "cn miss",
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
}
|
||||
}
|
||||
raise RuntimeError("us down")
|
||||
|
||||
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)
|
||||
old = mod.fetch_errors_payload
|
||||
mod.fetch_errors_payload = fake_fetch
|
||||
try:
|
||||
payload = mod.fetch_merged_errors(
|
||||
[("cn", "https://example/cn"), ("us", "https://example/us")],
|
||||
"token",
|
||||
5,
|
||||
50,
|
||||
"1h",
|
||||
)
|
||||
finally:
|
||||
mod.fetch_errors_payload = old
|
||||
|
||||
self.assertEqual(len(payload["items"]), 1)
|
||||
self.assertEqual(payload["items"][0]["_node"], "cn")
|
||||
self.assertTrue(payload["sources"]["cn"]["ok"])
|
||||
self.assertEqual(payload["sources"]["cn"]["total"], 3)
|
||||
self.assertFalse(payload["sources"]["us"]["ok"])
|
||||
self.assertIn("us down", payload["sources"]["us"]["error"])
|
||||
self.assertIn("cn 1/3", mod.errors_summary_line(payload, 1))
|
||||
self.assertIn("us err", mod.errors_summary_line(payload, 1))
|
||||
|
||||
def test_default_errors_urls(self) -> None:
|
||||
mod = load_module()
|
||||
old_cn = os.environ.pop("SHUSUB2_ERRORS_CN_URL", None)
|
||||
old_us = os.environ.pop("SHUSUB2_ERRORS_US_URL", None)
|
||||
old_cn_file = os.environ.get("SHUSUB2_ERRORS_CN_URL_FILE")
|
||||
old_us_file = os.environ.get("SHUSUB2_ERRORS_US_URL_FILE")
|
||||
os.environ["SHUSUB2_ERRORS_CN_URL_FILE"] = "/nonexistent/shusub2/errors-cn-url"
|
||||
os.environ["SHUSUB2_ERRORS_US_URL_FILE"] = "/nonexistent/shusub2/errors-us-url"
|
||||
try:
|
||||
self.assertEqual(mod.default_errors_cn_url(), "https://sub2apicn.shujk.top/api/v1/admin/ops/errors")
|
||||
self.assertEqual(
|
||||
mod.default_errors_us_url(),
|
||||
"https://sub2api.server4.shujk.top:19857/api/v1/admin/ops/errors",
|
||||
)
|
||||
self.assertEqual(
|
||||
mod.default_error_sources(mod.default_errors_cn_url(), mod.default_errors_us_url()),
|
||||
[
|
||||
("cn", "https://sub2apicn.shujk.top/api/v1/admin/ops/errors"),
|
||||
("us", "https://sub2api.server4.shujk.top:19857/api/v1/admin/ops/errors"),
|
||||
],
|
||||
)
|
||||
finally:
|
||||
if old_cn is not None:
|
||||
os.environ["SHUSUB2_ERRORS_CN_URL"] = old_cn
|
||||
if old_us is not None:
|
||||
os.environ["SHUSUB2_ERRORS_US_URL"] = old_us
|
||||
if old_cn_file is None:
|
||||
os.environ.pop("SHUSUB2_ERRORS_CN_URL_FILE", None)
|
||||
else:
|
||||
os.environ["SHUSUB2_ERRORS_CN_URL_FILE"] = old_cn_file
|
||||
if old_us_file is None:
|
||||
os.environ.pop("SHUSUB2_ERRORS_US_URL_FILE", None)
|
||||
else:
|
||||
os.environ["SHUSUB2_ERRORS_US_URL_FILE"] = old_us_file
|
||||
|
||||
def test_once_errors_without_token_prints_hint_and_exits_2(self) -> None:
|
||||
mod = load_module()
|
||||
old_token = os.environ.pop("SHUSUB2_LOGS_TOKEN", None)
|
||||
old_file = os.environ.get("SHUSUB2_LOGS_TOKEN_FILE")
|
||||
os.environ["SHUSUB2_LOGS_TOKEN_FILE"] = "/nonexistent/shusub2/logs-token"
|
||||
err = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stderr(err):
|
||||
rc = mod.main(["--once", "--errors", "--no-version-check"])
|
||||
finally:
|
||||
if old_token is not None:
|
||||
os.environ["SHUSUB2_LOGS_TOKEN"] = old_token
|
||||
if old_file is None:
|
||||
os.environ.pop("SHUSUB2_LOGS_TOKEN_FILE", None)
|
||||
else:
|
||||
os.environ["SHUSUB2_LOGS_TOKEN_FILE"] = old_file
|
||||
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertIn("logs token not configured", err.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user