feat: reorder first/latency in seconds, add effort column
- logs columns are now Key | Account | Model | Effort | Type | Tokens | Cost | First | Latency | Tok/s | Time - First and Latency render in seconds (5.3s) instead of milliseconds - Effort shows the request reasoning_effort (- when absent) and is filterable; the detail line appends it to the model as model (effort) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -83,7 +83,7 @@ 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 | First | Tok/s | Time
|
Key | Account | Model | Effort | Type | Tokens | Cost | First | Latency | 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`).
|
||||||
@@ -91,8 +91,10 @@ Key | Account | Model | Type | Tokens | Cost | Latency | First | Tok/s | Time
|
|||||||
the table shows the per-bucket breakdown, actual cost, first-token latency,
|
the table shows the per-bucket breakdown, actual cost, first-token latency,
|
||||||
decode speed, 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
|
`Effort` is the request's `reasoning_effort` (`-` when absent). `First` is the
|
||||||
throughput computed as `output_tokens / (latency - first_token)`; it shows
|
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. In the TUI each
|
`-` 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
|
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).
|
key are easy to group visually (`--once --logs` output stays plain text).
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "shusub2"
|
name = "shusub2"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
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"
|
||||||
|
|||||||
+22
-9
@@ -18,7 +18,7 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
APP_NAME = "shusub2"
|
APP_NAME = "shusub2"
|
||||||
FALLBACK_VERSION = "0.2.1"
|
FALLBACK_VERSION = "0.2.2"
|
||||||
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"
|
||||||
@@ -369,6 +369,13 @@ def format_rate(value: Any) -> str:
|
|||||||
return f"{rate:.1f}/s"
|
return f"{rate:.1f}/s"
|
||||||
|
|
||||||
|
|
||||||
|
def format_seconds(value: Any) -> str:
|
||||||
|
number = as_int(value)
|
||||||
|
if number <= 0:
|
||||||
|
return "-"
|
||||||
|
return f"{number / 1000:.1f}s"
|
||||||
|
|
||||||
|
|
||||||
KEY_COLOR_PALETTE = (
|
KEY_COLOR_PALETTE = (
|
||||||
"cyan",
|
"cyan",
|
||||||
"magenta",
|
"magenta",
|
||||||
@@ -413,6 +420,7 @@ def normalize_log_rows(payload: dict[str, Any], filter_text: str = "") -> list[d
|
|||||||
user_name = nested_name(item, "user", "user_id", "user_name")
|
user_name = nested_name(item, "user", "user_id", "user_name")
|
||||||
model = str(item.get("model") or "-")
|
model = str(item.get("model") or "-")
|
||||||
upstream_model = str(item.get("upstream_model") or "").strip()
|
upstream_model = str(item.get("upstream_model") or "").strip()
|
||||||
|
effort = str(item.get("reasoning_effort") or "").strip() or "-"
|
||||||
type_label = log_type_label(item)
|
type_label = log_type_label(item)
|
||||||
row = {
|
row = {
|
||||||
"id": as_int(item.get("id")),
|
"id": as_int(item.get("id")),
|
||||||
@@ -421,6 +429,7 @@ def normalize_log_rows(payload: dict[str, Any], filter_text: str = "") -> list[d
|
|||||||
"user": user_name,
|
"user": user_name,
|
||||||
"model": model,
|
"model": model,
|
||||||
"upstream_model": upstream_model,
|
"upstream_model": upstream_model,
|
||||||
|
"effort": effort,
|
||||||
"type": type_label,
|
"type": type_label,
|
||||||
"tokens": log_total_tokens(item),
|
"tokens": log_total_tokens(item),
|
||||||
"input_tokens": as_int(item.get("input_tokens")),
|
"input_tokens": as_int(item.get("input_tokens")),
|
||||||
@@ -439,7 +448,7 @@ def normalize_log_rows(payload: dict[str, Any], filter_text: str = "") -> list[d
|
|||||||
}
|
}
|
||||||
if needle:
|
if needle:
|
||||||
haystack = " ".join(
|
haystack = " ".join(
|
||||||
(key_name, account_name, user_name, model, upstream_model, type_label, row["request_id"])
|
(key_name, account_name, user_name, model, upstream_model, effort, type_label, row["request_id"])
|
||||||
).lower()
|
).lower()
|
||||||
if needle not in haystack:
|
if needle not in haystack:
|
||||||
continue
|
continue
|
||||||
@@ -465,12 +474,14 @@ 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']}"
|
||||||
|
if row["effort"] != "-":
|
||||||
|
model = f"{model} ({row['effort']})"
|
||||||
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 {format_latency(row['first_token_ms'])} "
|
f"first {format_seconds(row['first_token_ms'])} latency {format_seconds(row['duration_ms'])} "
|
||||||
f"{format_rate(row['tokens_per_second'])}"
|
f"{format_rate(row['tokens_per_second'])}"
|
||||||
)
|
)
|
||||||
if row["request_id"]:
|
if row["request_id"]:
|
||||||
@@ -481,17 +492,18 @@ 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 first tok/s time")
|
print("key account model effort type tokens cost first latency tok/s time")
|
||||||
for row in rows:
|
for row in rows:
|
||||||
print(
|
print(
|
||||||
f"{row['key'][:20]:<21} "
|
f"{row['key'][:20]:<21} "
|
||||||
f"{row['account'][:20]:<21} "
|
f"{row['account'][:20]:<21} "
|
||||||
f"{row['model'][:24]:<25} "
|
f"{row['model'][:24]:<25} "
|
||||||
|
f"{row['effort']:<7} "
|
||||||
f"{row['type']:<7} "
|
f"{row['type']:<7} "
|
||||||
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_seconds(row['first_token_ms']):<8} "
|
||||||
f"{format_latency(row['first_token_ms']):<9} "
|
f"{format_seconds(row['duration_ms']):<8} "
|
||||||
f"{format_rate(row['tokens_per_second']):<8} "
|
f"{format_rate(row['tokens_per_second']):<8} "
|
||||||
f"{row['time']}"
|
f"{row['time']}"
|
||||||
)
|
)
|
||||||
@@ -927,7 +939,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", "First", "Tok/s", "Time")
|
table.add_columns("Key", "Account", "Model", "Effort", "Type", "Tokens", "Cost", "First", "Latency", "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)
|
||||||
|
|
||||||
@@ -976,11 +988,12 @@ def run_textual(
|
|||||||
Text(row["key"], style=color) if color else row["key"],
|
Text(row["key"], style=color) if color else row["key"],
|
||||||
row["account"],
|
row["account"],
|
||||||
row["model"],
|
row["model"],
|
||||||
|
row["effort"],
|
||||||
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_seconds(row["first_token_ms"]),
|
||||||
format_latency(row["first_token_ms"]),
|
format_seconds(row["duration_ms"]),
|
||||||
format_rate(row["tokens_per_second"]),
|
format_rate(row["tokens_per_second"]),
|
||||||
row["time"],
|
row["time"],
|
||||||
key=key,
|
key=key,
|
||||||
|
|||||||
+24
-6
@@ -341,6 +341,7 @@ def sample_logs_payload() -> dict:
|
|||||||
"user": {"id": 1, "name": "shujakuin"},
|
"user": {"id": 1, "name": "shujakuin"},
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.5",
|
||||||
"upstream_model": "gpt-5.5-codex",
|
"upstream_model": "gpt-5.5-codex",
|
||||||
|
"reasoning_effort": "high",
|
||||||
"request_type": "stream",
|
"request_type": "stream",
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"input_tokens": 1200,
|
"input_tokens": 1200,
|
||||||
@@ -454,8 +455,21 @@ class Sub2APILogsTests(unittest.TestCase):
|
|||||||
oldest = rows[-1]
|
oldest = rows[-1]
|
||||||
self.assertEqual(oldest["first_token_ms"], 800)
|
self.assertEqual(oldest["first_token_ms"], 800)
|
||||||
self.assertAlmostEqual(oldest["tokens_per_second"], 340 * 1000.0 / 4521)
|
self.assertAlmostEqual(oldest["tokens_per_second"], 340 * 1000.0 / 4521)
|
||||||
self.assertIn("first 800ms", mod.log_detail_line(oldest))
|
self.assertEqual(oldest["effort"], "high")
|
||||||
self.assertIn("75.2/s", mod.log_detail_line(oldest))
|
self.assertEqual(rows[0]["effort"], "-")
|
||||||
|
detail = mod.log_detail_line(oldest)
|
||||||
|
self.assertIn("first 0.8s latency 5.3s", detail)
|
||||||
|
self.assertIn("(high)", detail)
|
||||||
|
self.assertIn("75.2/s", detail)
|
||||||
|
|
||||||
|
def test_format_seconds_converts_ms(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
|
||||||
|
self.assertEqual(mod.format_seconds(5321), "5.3s")
|
||||||
|
self.assertEqual(mod.format_seconds(800), "0.8s")
|
||||||
|
self.assertEqual(mod.format_seconds(200735), "200.7s")
|
||||||
|
self.assertEqual(mod.format_seconds(0), "-")
|
||||||
|
self.assertEqual(mod.format_seconds(None), "-")
|
||||||
|
|
||||||
def test_key_color_is_stable_and_from_palette(self) -> None:
|
def test_key_color_is_stable_and_from_palette(self) -> None:
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
@@ -483,10 +497,14 @@ class Sub2APILogsTests(unittest.TestCase):
|
|||||||
self.assertIn("stream", text)
|
self.assertIn("stream", text)
|
||||||
self.assertIn("5.7K", text)
|
self.assertIn("5.7K", text)
|
||||||
self.assertIn("$0.012", text)
|
self.assertIn("$0.012", text)
|
||||||
self.assertIn("5321ms", text)
|
header = text.splitlines()[1]
|
||||||
self.assertIn("first", text)
|
self.assertLess(header.index("model"), header.index("effort"))
|
||||||
self.assertIn("tok/s", text)
|
self.assertLess(header.index("effort"), header.index("type"))
|
||||||
self.assertIn("800ms", text)
|
self.assertLess(header.index("first"), header.index("latency"))
|
||||||
|
self.assertIn("tok/s", header)
|
||||||
|
self.assertIn("high", text)
|
||||||
|
self.assertIn("5.3s", text)
|
||||||
|
self.assertIn("0.8s", text)
|
||||||
self.assertIn("75.2/s", text)
|
self.assertIn("75.2/s", text)
|
||||||
self.assertIn("total 2.3K records", text)
|
self.assertIn("total 2.3K records", text)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user