feat: show first-token latency, decode tok/s, per-key colors

- logs page adds First (first_token_ms) and Tok/s columns; Tok/s is
  output_tokens / (latency - first_token) over the decode window
- detail line shows the same rate after first-token latency
- API key names render in a stable per-key color (crc32 over a 10-color
  palette) so rows from the same key group visually; --once stays plain
- --once --logs prints the new first/tok-s columns

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 11:19:06 +08:00
parent 7d78f1ab63
commit 2d3d433f4f
5 changed files with 104 additions and 10 deletions
+8 -2
View File
@@ -83,13 +83,19 @@ Sub2API admin usage API and refreshes every 60 seconds by default
(`--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`). Columns: (`--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`). Columns:
```text ```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`). `Type` is the Sub2API `request_type` (`sync` / `stream` / `ws_v2` / `cyber`).
`Tokens` is input + output + cache write + cache read; the detail line below `Tokens` is input + output + cache write + cache read; the detail line below
the table shows the per-bucket breakdown, actual cost, first-token latency, 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 The logs URL defaults to `https://sub2apicn.shujk.top/api/v1/admin/usage` and
can be overridden with `--logs-url` / `SHUSUB2_LOGS_URL` / can be overridden with `--logs-url` / `SHUSUB2_LOGS_URL` /
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "shusub2" name = "shusub2"
version = "0.2.0" version = "0.2.1"
description = "Terminal UI for Sub2API account quota and daily usage" description = "Terminal UI for Sub2API account quota and daily usage"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+54 -6
View File
@@ -12,12 +12,13 @@ import subprocess
import sys import sys
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import zlib
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
APP_NAME = "shusub2" 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_API_URL = "http://127.0.0.1:18318/api/tui/accounts"
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url" DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-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: def nested_name(item: dict[str, Any], object_key: str, id_key: str, name_key: str = "") -> str:
nested = item.get(object_key) nested = item.get(object_key)
if isinstance(nested, dict): 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")), "actual_cost": as_float(item.get("actual_cost")),
"duration_ms": as_int(item.get("duration_ms")), "duration_ms": as_int(item.get("duration_ms")),
"first_token_ms": as_int(item.get("first_token_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 ""), "created_at": str(item.get("created_at") or ""),
"time": short_time(item.get("created_at")), "time": short_time(item.get("created_at")),
"request_id": str(item.get("request_id") or ""), "request_id": str(item.get("request_id") or ""),
@@ -423,13 +465,13 @@ def log_detail_line(row: dict[str, Any]) -> str:
model = row["model"] model = row["model"]
if row["upstream_model"] and row["upstream_model"] != row["model"]: if row["upstream_model"] and row["upstream_model"] != row["model"]:
model = f"{row['model']} -> {row['upstream_model']}" model = f"{row['model']} -> {row['upstream_model']}"
first_token = f"{row['first_token_ms']}ms" if row["first_token_ms"] > 0 else "-"
detail = ( detail = (
f"{row['time']} | key {row['key']} | account {row['account']} | user {row['user']} | {model} | {row['type']} | " 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"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"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"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"]: if row["request_id"]:
detail += f" | {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: def print_logs_once(payload: dict[str, Any], filter_text: str = "") -> None:
rows = normalize_log_rows(payload, filter_text) rows = normalize_log_rows(payload, filter_text)
print(logs_summary_line(payload, len(rows))) 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: for row in rows:
print( print(
f"{row['key'][:20]:<21} " 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_count(row['tokens']):<8} "
f"{format_cost(row['cost']):<10} " f"{format_cost(row['cost']):<10} "
f"{format_latency(row['duration_ms']):<9} " 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']}" f"{row['time']}"
) )
@@ -733,6 +777,7 @@ def run_textual(
start_page: str = "accounts", start_page: str = "accounts",
) -> int: ) -> int:
try: try:
from rich.text import Text
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import DataTable, Footer, Header, Input, Static from textual.widgets import DataTable, Footer, Header, Input, Static
@@ -882,7 +927,7 @@ def run_textual(
table = self.query_one("#logs", DataTable) table = self.query_one("#logs", DataTable)
table.cursor_type = "row" table.cursor_type = "row"
table.zebra_stripes = True 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.refresh_data()
self.set_interval(logs_refresh_seconds, 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): for index, row in enumerate(self.rows):
key = f"{row['id']}-{index}" key = f"{row['id']}-{index}"
self.row_by_key[key] = row self.row_by_key[key] = row
color = key_color(row["key"])
table.add_row( table.add_row(
row["key"], Text(row["key"], style=color) if color else row["key"],
row["account"], row["account"],
row["model"], row["model"],
row["type"], row["type"],
format_count(row["tokens"]), format_count(row["tokens"]),
format_cost(row["cost"]), format_cost(row["cost"]),
format_latency(row["duration_ms"]), format_latency(row["duration_ms"]),
format_latency(row["first_token_ms"]),
format_rate(row["tokens_per_second"]),
row["time"], row["time"],
key=key, key=key,
) )
+40
View File
@@ -432,6 +432,42 @@ class Sub2APILogsTests(unittest.TestCase):
capped = mod.logs_request_url("https://sub2apicn.shujk.top/api/v1/admin/usage", 9000) capped = mod.logs_request_url("https://sub2apicn.shujk.top/api/v1/admin/usage", 9000)
self.assertIn("page_size=1000", capped) 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: def test_print_logs_once_renders_requested_columns(self) -> None:
mod = load_module() mod = load_module()
out = io.StringIO() out = io.StringIO()
@@ -448,6 +484,10 @@ class Sub2APILogsTests(unittest.TestCase):
self.assertIn("5.7K", text) self.assertIn("5.7K", text)
self.assertIn("$0.012", text) self.assertIn("$0.012", text)
self.assertIn("5321ms", 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) self.assertIn("total 2.3K records", text)
def test_default_logs_token_reads_config_file(self) -> None: def test_default_logs_token_reads_config_file(self) -> None:
Generated
+1 -1
View File
@@ -85,7 +85,7 @@ wheels = [
[[package]] [[package]]
name = "shusub2" name = "shusub2"
version = "0.2.0" version = "0.2.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "textual" }, { name = "textual" },