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:
@@ -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
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user