From 4d8d117fb15e462054c574a2a89903c1213bce59 Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Mon, 27 Jul 2026 20:50:55 +0800 Subject: [PATCH] feat: expand shusub2 log metrics --- README.md | 15 +++++----- pyproject.toml | 2 +- sub2api_quota_tui.py | 68 ++++++++++++++++++++++++++++++++++--------- tests/test_payload.py | 18 ++++++++++++ uv.lock | 2 +- 5 files changed, 83 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6ce11fd..d33558b 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ horizontally instead of truncating a field: ```text ACCOUNT | Group | Today | Daily | 5h | 7d | Avail KEY | Today | Tokens | Req -LOG KEY | Account | Model | Cost | Latency | Time | Age +LOG KEY | Account | Model | First | Duration | Tok/s | Input | Output | Cache | Tokens | Cost | Time | Age ERR | Status | Key | Account | Model | Time | Age ``` @@ -116,7 +116,7 @@ Sub2API admin usage API and refreshes every 60 seconds by default (`--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`). Columns: ```text -Key | Account | Model | Effort | Type | Tokens | Cost | First | Latency | Tok/s | Time | Age +Key | Account | Model | Effort | Type | Input | Output | Cache | Tokens | Cost | First | Duration | Tok/s | Time | Age ``` `Type` is the Sub2API `request_type` (`sync` / `stream` / `ws_v2` / `cyber`). @@ -125,11 +125,12 @@ the table shows the per-bucket breakdown, actual cost, first-token latency, decode speed, upstream model mapping, user, and request id. `Effort` is the request's `reasoning_effort` (`-` when absent). `First` is the -first-token latency and `Latency` the total duration, both shown in seconds. -`Tok/s` is the decode throughput computed as -`output_tokens / (latency - first_token)`; it shows -`-` when there is no output or no positive decode window. `Age` is relative to -local current time (`now`, `5m ago`, `2h ago`, etc.). In the TUI each +first-token latency and `Duration` the total request time, both shown in +seconds. `Tok/s` is the decode throughput computed as +`output_tokens / (duration - first_token)`; it shows `-` when there is no +output or no positive decode window. `Input`, `Output`, and `Cache` are token +buckets; `Cache` combines cache write and cache read. `Tokens` is their total. +`Age` is relative to local current time (`now`, `5m ago`, `2h ago`, etc.). In the TUI each API key name is rendered in a stable per-key color so rows from the same key are easy to group visually (`--once --logs` output stays plain text). diff --git a/pyproject.toml b/pyproject.toml index bcc9f05..6604d70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.2.9" +version = "0.2.10" 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 19923e5..dd9bf19 100644 --- a/sub2api_quota_tui.py +++ b/sub2api_quota_tui.py @@ -19,7 +19,7 @@ from typing import Any APP_NAME = "shusub2" -FALLBACK_VERSION = "0.2.9" +FALLBACK_VERSION = "0.2.10" 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,13 +397,12 @@ def log_type_label(item: dict[str, Any]) -> str: return text or "-" +def log_cache_tokens(item: dict[str, Any]) -> int: + return as_int(item.get("cache_creation_tokens")) + as_int(item.get("cache_read_tokens")) + + def log_total_tokens(item: dict[str, Any]) -> int: - return ( - as_int(item.get("input_tokens")) - + as_int(item.get("output_tokens")) - + as_int(item.get("cache_creation_tokens")) - + as_int(item.get("cache_read_tokens")) - ) + return as_int(item.get("input_tokens")) + as_int(item.get("output_tokens")) + log_cache_tokens(item) def log_tokens_per_second(item: dict[str, Any]) -> float: @@ -583,6 +582,7 @@ def normalize_log_rows(payload: dict[str, Any], filter_text: str = "") -> list[d "output_tokens": as_int(item.get("output_tokens")), "cache_creation_tokens": as_int(item.get("cache_creation_tokens")), "cache_read_tokens": as_int(item.get("cache_read_tokens")), + "cache_tokens": log_cache_tokens(item), "cost": as_float(item.get("total_cost")), "actual_cost": as_float(item.get("actual_cost")), "duration_ms": as_int(item.get("duration_ms")), @@ -629,7 +629,7 @@ def log_detail_line(row: dict[str, Any]) -> str: f"tokens in {format_count(row['input_tokens'])} out {format_count(row['output_tokens'])} " f"cache_w {format_count(row['cache_creation_tokens'])} cache_r {format_count(row['cache_read_tokens'])} | " f"cost {format_cost(row['cost'])} (actual {format_cost(row['actual_cost'])}) | " - f"first {format_seconds(row['first_token_ms'])} latency {format_seconds(row['duration_ms'])} " + f"first {format_seconds(row['first_token_ms'])} duration {format_seconds(row['duration_ms'])} " f"{format_rate(row['tokens_per_second'])}" ) if row["request_id"]: @@ -640,7 +640,7 @@ def log_detail_line(row: dict[str, Any]) -> str: def print_logs_once(payload: dict[str, Any], filter_text: str = "") -> None: rows = normalize_log_rows(payload, filter_text) print(logs_summary_line(payload, len(rows))) - print("key account model effort type tokens cost first latency tok/s time age") + print("key account model effort type input output cache tokens cost first duration tok/s time age") for row in rows: print( f"{row['key'][:20]:<21} " @@ -648,10 +648,13 @@ def print_logs_once(payload: dict[str, Any], filter_text: str = "") -> None: f"{row['model'][:24]:<25} " f"{row['effort']:<7} " f"{row['type']:<7} " + f"{format_count(row['input_tokens']):<8} " + f"{format_count(row['output_tokens']):<8} " + f"{format_count(row['cache_tokens']):<8} " f"{format_count(row['tokens']):<8} " f"{format_cost(row['cost']):<10} " f"{format_seconds(row['first_token_ms']):<8} " - f"{format_seconds(row['duration_ms']):<8} " + f"{format_seconds(row['duration_ms']):<10} " f"{format_rate(row['tokens_per_second']):<8} " f"{row['time']:<11} " f"{row['age']}" @@ -1277,7 +1280,21 @@ def run_textual( ) self.configure_table( self.query_one("#logs", DataTable), - ("LOG KEY", "Account", "Model", "Cost", "Latency", "Time", "Age"), + ( + "LOG KEY", + "Account", + "Model", + "First", + "Duration", + "Tok/s", + "Input", + "Output", + "Cache", + "Tokens", + "Cost", + "Time", + "Age", + ), ) self.configure_table( self.query_one("#errors", DataTable), @@ -1445,8 +1462,14 @@ def run_textual( Text(row["key"], style=color) if color else row["key"], row["account"], row["model"], - format_cost(row["cost"]), + format_seconds(row["first_token_ms"]), format_seconds(row["duration_ms"]), + format_rate(row["tokens_per_second"]), + format_count(row["input_tokens"]), + format_count(row["output_tokens"]), + format_count(row["cache_tokens"]), + format_count(row["tokens"]), + format_cost(row["cost"]), row["time"], row["age"], key=key, @@ -1772,7 +1795,23 @@ def run_textual( table = self.query_one("#logs", DataTable) table.cursor_type = "row" table.zebra_stripes = True - table.add_columns("Key", "Account", "Model", "Effort", "Type", "Tokens", "Cost", "First", "Latency", "Tok/s", "Time", "Age") + table.add_columns( + "Key", + "Account", + "Model", + "Effort", + "Type", + "Input", + "Output", + "Cache", + "Tokens", + "Cost", + "First", + "Duration", + "Tok/s", + "Time", + "Age", + ) self.refresh_data() self.set_interval(logs_refresh_seconds, self.refresh_data) @@ -1826,6 +1865,9 @@ def run_textual( row["model"], row["effort"], row["type"], + format_count(row["input_tokens"]), + format_count(row["output_tokens"]), + format_count(row["cache_tokens"]), format_count(row["tokens"]), format_cost(row["cost"]), format_seconds(row["first_token_ms"]), diff --git a/tests/test_payload.py b/tests/test_payload.py index 198e66b..e3ba3ff 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -390,6 +390,11 @@ class Sub2APILogsTests(unittest.TestCase): self.assertEqual(oldest["user"], "shujakuin") self.assertEqual(oldest["type"], "stream") self.assertEqual(oldest["tokens"], 1200 + 340 + 50 + 4100) + self.assertEqual(oldest["input_tokens"], 1200) + self.assertEqual(oldest["output_tokens"], 340) + self.assertEqual(oldest["cache_tokens"], 50 + 4100) + self.assertEqual(oldest["first_token_ms"], 800) + self.assertGreater(oldest["tokens_per_second"], 0) self.assertEqual(oldest["cost"], 0.0123) self.assertEqual(oldest["duration_ms"], 5321) self.assertTrue(oldest["time"].endswith("10:00") or oldest["time"] != "-") @@ -454,6 +459,12 @@ class Sub2APILogsTests(unittest.TestCase): self.assertIn("oai-sub-1", text) self.assertIn("gpt-5.5", text) self.assertIn("stream", text) + self.assertIn("input", text) + self.assertIn("output", text) + self.assertIn("cache", text) + self.assertIn("duration", text) + self.assertIn("1.2K", text) + self.assertIn("4.2K", text) self.assertIn("5.7K", text) self.assertIn("$0.012", text) self.assertIn("5.3s", text) @@ -711,6 +722,11 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase): "api_key_name": "wmy-production-key-with-a-complete-expanded-name", "account_name": "account-one-with-a-complete-expanded-dashboard-name", "model": "gpt-5.5-codex-with-a-complete-expanded-dashboard-model-name", + "input_tokens": 1200, + "output_tokens": 340, + "cache_creation_tokens": 50, + "cache_read_tokens": 4100, + "first_token_ms": 200, "duration_ms": 1200, "created_at": "2026-07-24T12:00:00+08:00", } @@ -774,6 +790,8 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase): self.assertGreater(errors_table.virtual_size.width, errors_table.region.width) self.assertGreater(logs_table.max_scroll_x, 0) self.assertGreater(errors_table.max_scroll_x, 0) + self.assertEqual(screen.log_rows[0]["cache_tokens"], 4150) + self.assertGreater(screen.log_rows[0]["tokens_per_second"], 0) self.assertEqual(len({table.styles.background for table in tables}), 4) self.assertIn("wmy", str(screen.query_one("#summary").render())) for key, expected_id in (("k", "keys"), ("l", "logs"), ("e", "errors"), ("a", "accounts")): diff --git a/uv.lock b/uv.lock index 1275182..5bf048c 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.2.9" +version = "0.2.10" source = { editable = "." } dependencies = [ { name = "textual" },