diff --git a/README.md b/README.md index 9a8398b..027a8fe 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,19 @@ Sub2API admin usage API and refreshes every 60 seconds by default (`--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`). Columns: ```text -Key | Account | Model | Type | Tokens | Cost | Latency | Time +Key | Account | Model | Type | Tokens | Cost | Latency | First | Tok/s | Time ``` `Type` is the Sub2API `request_type` (`sync` / `stream` / `ws_v2` / `cyber`). `Tokens` is input + output + cache write + cache read; the detail line below the table shows the per-bucket breakdown, actual cost, first-token latency, -upstream model mapping, user, and request id. +decode speed, upstream model mapping, user, and request id. + +`First` is the first-token latency (`first_token_ms`). `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. 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). The logs URL defaults to `https://sub2apicn.shujk.top/api/v1/admin/usage` and can be overridden with `--logs-url` / `SHUSUB2_LOGS_URL` / diff --git a/pyproject.toml b/pyproject.toml index 07b9b67..2607bba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.2.0" +version = "0.2.1" 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 2a7349b..3caca5c 100644 --- a/sub2api_quota_tui.py +++ b/sub2api_quota_tui.py @@ -12,12 +12,13 @@ import subprocess import sys import urllib.parse import urllib.request +import zlib from pathlib import Path from typing import Any APP_NAME = "shusub2" -FALLBACK_VERSION = "0.2.0" +FALLBACK_VERSION = "0.2.1" 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" @@ -349,6 +350,46 @@ def log_total_tokens(item: dict[str, Any]) -> int: ) +def log_tokens_per_second(item: dict[str, Any]) -> float: + output = as_int(item.get("output_tokens")) + duration = as_int(item.get("duration_ms")) + first = max(0, as_int(item.get("first_token_ms"))) + decode_ms = duration - first + if output <= 0 or decode_ms <= 0: + return 0.0 + return output * 1000.0 / decode_ms + + +def format_rate(value: Any) -> str: + rate = as_float(value) + if rate <= 0: + return "-" + if rate >= 100: + return f"{rate:.0f}/s" + return f"{rate:.1f}/s" + + +KEY_COLOR_PALETTE = ( + "cyan", + "magenta", + "green", + "yellow", + "bright_blue", + "bright_cyan", + "bright_magenta", + "bright_green", + "bright_yellow", + "bright_red", +) + + +def key_color(name: Any) -> str: + text = str(name or "").strip() + if not text or text == "-": + return "" + return KEY_COLOR_PALETTE[zlib.crc32(text.encode("utf-8")) % len(KEY_COLOR_PALETTE)] + + def nested_name(item: dict[str, Any], object_key: str, id_key: str, name_key: str = "") -> str: nested = item.get(object_key) if isinstance(nested, dict): @@ -390,6 +431,7 @@ def normalize_log_rows(payload: dict[str, Any], filter_text: str = "") -> list[d "actual_cost": as_float(item.get("actual_cost")), "duration_ms": as_int(item.get("duration_ms")), "first_token_ms": as_int(item.get("first_token_ms")), + "tokens_per_second": log_tokens_per_second(item), "created_at": str(item.get("created_at") or ""), "time": short_time(item.get("created_at")), "request_id": str(item.get("request_id") or ""), @@ -423,13 +465,13 @@ def log_detail_line(row: dict[str, Any]) -> str: model = row["model"] if row["upstream_model"] and row["upstream_model"] != row["model"]: model = f"{row['model']} -> {row['upstream_model']}" - first_token = f"{row['first_token_ms']}ms" if row["first_token_ms"] > 0 else "-" detail = ( f"{row['time']} | key {row['key']} | account {row['account']} | user {row['user']} | {model} | {row['type']} | " 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"latency {format_latency(row['duration_ms'])} first {first_token}" + f"latency {format_latency(row['duration_ms'])} first {format_latency(row['first_token_ms'])} " + f"{format_rate(row['tokens_per_second'])}" ) if row["request_id"]: detail += f" | {row['request_id']}" @@ -439,7 +481,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 type tokens cost latency time") + print("key account model type tokens cost latency first tok/s time") for row in rows: print( f"{row['key'][:20]:<21} " @@ -449,6 +491,8 @@ def print_logs_once(payload: dict[str, Any], filter_text: str = "") -> None: f"{format_count(row['tokens']):<8} " f"{format_cost(row['cost']):<10} " f"{format_latency(row['duration_ms']):<9} " + f"{format_latency(row['first_token_ms']):<9} " + f"{format_rate(row['tokens_per_second']):<8} " f"{row['time']}" ) @@ -733,6 +777,7 @@ def run_textual( start_page: str = "accounts", ) -> int: try: + from rich.text import Text from textual.app import App, ComposeResult from textual.screen import Screen from textual.widgets import DataTable, Footer, Header, Input, Static @@ -882,7 +927,7 @@ def run_textual( table = self.query_one("#logs", DataTable) table.cursor_type = "row" table.zebra_stripes = True - table.add_columns("Key", "Account", "Model", "Type", "Tokens", "Cost", "Latency", "Time") + table.add_columns("Key", "Account", "Model", "Type", "Tokens", "Cost", "Latency", "First", "Tok/s", "Time") self.refresh_data() self.set_interval(logs_refresh_seconds, self.refresh_data) @@ -926,14 +971,17 @@ def run_textual( for index, row in enumerate(self.rows): key = f"{row['id']}-{index}" self.row_by_key[key] = row + color = key_color(row["key"]) table.add_row( - row["key"], + Text(row["key"], style=color) if color else row["key"], row["account"], row["model"], row["type"], format_count(row["tokens"]), format_cost(row["cost"]), format_latency(row["duration_ms"]), + format_latency(row["first_token_ms"]), + format_rate(row["tokens_per_second"]), row["time"], key=key, ) diff --git a/tests/test_payload.py b/tests/test_payload.py index 7ce2306..a59a9cd 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -432,6 +432,42 @@ 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.assertIn("first 800ms", mod.log_detail_line(oldest)) + self.assertIn("75.2/s", mod.log_detail_line(oldest)) + + 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() @@ -448,6 +484,10 @@ class Sub2APILogsTests(unittest.TestCase): self.assertIn("5.7K", text) self.assertIn("$0.012", text) self.assertIn("5321ms", text) + self.assertIn("first", text) + self.assertIn("tok/s", text) + self.assertIn("800ms", text) + self.assertIn("75.2/s", text) self.assertIn("total 2.3K records", text) def test_default_logs_token_reads_config_file(self) -> None: diff --git a/uv.lock b/uv.lock index 5ad6ada..ddf8034 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "textual" },