feat: expand shusub2 log metrics
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+55
-13
@@ -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"]),
|
||||
|
||||
@@ -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")):
|
||||
|
||||
Reference in New Issue
Block a user