feat: add sub2apicn request logs page

- new logs page (press l / a to switch, or start with --logs) reading the
  latest 100 requests from the Sub2API admin usage API on sub2apicn
- columns: Key, Account, Model, Type, Tokens, Cost, Latency, Time with a
  per-row detail line (token buckets, actual cost, first-token latency)
- refreshes every 60s by default (--logs-refresh-seconds, --logs-limit)
- admin API key read from SHUSUB2_LOGS_TOKEN or ~/.config/shusub2/logs-token;
  --save-config now persists logs-url/logs-token too
- --once --logs prints a one-shot logs snapshot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 02:18:47 +08:00
parent 6e7831da7c
commit b68d801024
5 changed files with 614 additions and 32 deletions
+70 -4
View File
@@ -1,7 +1,23 @@
# shusub2
- 文档层级:客户端工具
- 应用性质:客户端 TUI
- 源码来源:repo 原生
- 运行关系:客户端工具
- 部署模型:无
- 对应服务:无
- 对应 stack:无
- Secret 边界:账号页无 secret;请求日志页可选持有 Sub2API admin API key(仅存本机 `~/.config/shusub2/logs-token`0600
- 分发方式:`uvx` / `uv tool install`
- 包名:`shusub2`
- 命令名:`shusub2`
- 本地状态路径:`~/.config/shusub2/`
- 目标 API`cliproxy-codex-quota``/api/tui/accounts`、可选 `sub2api-status` `/api/status`、可选 Sub2API `/api/v1/admin/usage`(请求日志页,默认 `sub2apicn.shujk.top`)。
- 备注:账号页不需要 SSH、数据库访问、API key、OAuth credentials 或 plaintext env;请求日志页需要 Sub2API admin API key。
Terminal UI for the token-safe Sub2API account feed exposed by
`cliproxy-codex-quota`.
`cliproxy-codex-quota`, plus an optional request-logs page backed by the
Sub2API admin usage API.
## Quick Start
@@ -46,10 +62,53 @@ Environment variables override the config file:
- `SHUSUB2_API_URL_FILE`
- `SHUSUB2_STATUS_URL`
- `SHUSUB2_STATUS_URL_FILE`
- `SHUSUB2_LOGS_URL`
- `SHUSUB2_LOGS_URL_FILE`
- `SHUSUB2_LOGS_TOKEN`
- `SHUSUB2_LOGS_TOKEN_FILE`
- `SHUSUB2_LOGS_REFRESH_SECONDS`
- `SHUSUB2_LOGS_LIMIT`
- `SHUSUB2_VERSION_CHECK_URL`
- `SHUSUB2_VERSION_CHECK_TIMEOUT`
- `SHUSUB2_NO_VERSION_CHECK`
## Request Logs Page
Press `l` on the accounts page to open the request logs page; press `a` to go
back. `shusub2 --logs` starts directly on the logs page, and
`shusub2 --once --logs` prints one logs snapshot to stdout.
The logs page reads the latest requests (default 100, `--logs-limit`) from the
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
```
`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.
The logs URL defaults to `https://sub2apicn.shujk.top/api/v1/admin/usage` and
can be overridden with `--logs-url` / `SHUSUB2_LOGS_URL` /
`~/.config/shusub2/logs-url`.
The page needs a Sub2API **admin API key** (generated in the Sub2API web UI
under Settings). Configure it once:
```bash
mkdir -p ~/.config/shusub2 && chmod 700 ~/.config/shusub2
printf '%s\n' '<sub2api-admin-api-key>' > ~/.config/shusub2/logs-token
chmod 600 ~/.config/shusub2/logs-token
```
or via `SHUSUB2_LOGS_TOKEN`. The key is sent as the `x-api-key` header and is
never written anywhere else by the client. Without a token the logs page stays
empty and shows a configuration hint; the accounts page keeps working without
any secret.
## Local Development
```bash
@@ -68,14 +127,21 @@ Configuration:
- `--api-url` / `SUB2API_QUOTA_TUI_API_URL`
- `--status-url` / `SHUSUB2_STATUS_URL`
- `--logs-url` / `SHUSUB2_LOGS_URL`
- `--logs-token` / `SHUSUB2_LOGS_TOKEN`
- `--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`
- `--logs-limit` / `SHUSUB2_LOGS_LIMIT`
- `--save-config`
- `--install`
- `--refresh-seconds` / `SUB2API_QUOTA_TUI_REFRESH_SECONDS`
- `--timeout` / `SUB2API_QUOTA_TUI_TIMEOUT`
The TUI reads `/api/tui/accounts` and can optionally read `sub2api-status`
`/api/status` for channel monitor health. It does not need SSH, database
access, API keys, OAuth credentials, or plaintext env files.
`/api/status` for channel monitor health. The accounts page does not need SSH,
database access, API keys, OAuth credentials, or plaintext env files. The
optional request logs page authenticates to the Sub2API admin usage API with
an admin API key stored only in `~/.config/shusub2/logs-token` (0600) or
`SHUSUB2_LOGS_TOKEN`.
When `--status-url` is omitted, `shusub2` infers a sibling `/api/status` URL
from account URLs ending in `/api/tui/accounts`, so the public Codex endpoint
@@ -83,7 +149,7 @@ automatically enables monitor availability. On startup it also checks the
public Gitea repo for a newer package version and prints a short upgrade hint
when one is available.
Columns are ordered for scanning inside zellij:
Accounts page columns are ordered for scanning inside zellij:
```text
Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset | Status | Availability
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "shusub2"
version = "0.1.7"
version = "0.2.0"
description = "Terminal UI for Sub2API account quota and daily usage"
readme = "README.md"
requires-python = ">=3.11"
+380 -26
View File
@@ -17,12 +17,17 @@ from typing import Any
APP_NAME = "shusub2"
FALLBACK_VERSION = "0.1.7"
FALLBACK_VERSION = "0.2.0"
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"
DEFAULT_LOGS_API_URL = "https://sub2apicn.shujk.top/api/v1/admin/usage"
DEFAULT_LOGS_URL_CONFIG_FILE = "~/.config/shusub2/logs-url"
DEFAULT_LOGS_TOKEN_CONFIG_FILE = "~/.config/shusub2/logs-token"
DEFAULT_VERSION_CHECK_URL = "https://gitea.shujk.top/shujakuin/shusub2/raw/branch/main/pyproject.toml"
DEFAULT_REFRESH_SECONDS = 60
DEFAULT_LOGS_REFRESH_SECONDS = 60
DEFAULT_LOGS_LIMIT = 100
DEFAULT_TIMEOUT_SECONDS = 10
DEFAULT_VERSION_CHECK_TIMEOUT_SECONDS = 2
MONITOR_OK_STATUSES = {"operational", "ok", "success"}
@@ -70,26 +75,55 @@ def default_status_url() -> str:
)
def default_logs_url() -> str:
return configured_url(
("SHUSUB2_LOGS_URL",),
os.environ.get("SHUSUB2_LOGS_URL_FILE", DEFAULT_LOGS_URL_CONFIG_FILE),
DEFAULT_LOGS_API_URL,
)
def default_logs_token() -> str:
return configured_url(
("SHUSUB2_LOGS_TOKEN",),
os.environ.get("SHUSUB2_LOGS_TOKEN_FILE", DEFAULT_LOGS_TOKEN_CONFIG_FILE),
)
def config_file_path() -> Path:
return Path(os.environ.get("SHUSUB2_API_URL_FILE", DEFAULT_CONFIG_FILE)).expanduser()
def logs_url_config_file_path() -> Path:
return Path(os.environ.get("SHUSUB2_LOGS_URL_FILE", DEFAULT_LOGS_URL_CONFIG_FILE)).expanduser()
def logs_token_config_file_path() -> Path:
return Path(os.environ.get("SHUSUB2_LOGS_TOKEN_FILE", DEFAULT_LOGS_TOKEN_CONFIG_FILE)).expanduser()
def write_config_value(path: Path, value: str) -> Path:
text = str(value or "").strip()
if not text:
raise ValueError("config value is empty")
path.parent.mkdir(parents=True, exist_ok=True)
try:
path.parent.chmod(0o700)
except OSError:
pass
path.write_text(text + "\n", encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
pass
return path
def write_api_url_config(api_url: str) -> Path:
value = str(api_url or "").strip()
if not value:
raise ValueError("api url is empty")
path = config_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
try:
path.parent.chmod(0o700)
except OSError:
pass
path.write_text(value + "\n", encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
pass
return path
return write_config_value(config_file_path(), value)
def run_install_command() -> int:
@@ -259,6 +293,166 @@ def fetch_optional_payload(url: str, timeout: int) -> tuple[dict[str, Any], str]
return {}, str(exc)
def logs_request_url(logs_url: str, limit: int) -> str:
parsed = urllib.parse.urlparse(logs_url)
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
query = [(key, value) for key, value in query if key not in {"page", "page_size", "limit"}]
query.extend((("page", "1"), ("page_size", str(max(1, min(int(limit), 1000))))))
return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query)))
def fetch_logs_payload(logs_url: str, token: str, timeout: int, limit: int = DEFAULT_LOGS_LIMIT) -> dict[str, Any]:
headers = {"Accept": "application/json"}
if str(token or "").strip():
headers["x-api-key"] = str(token).strip()
req = urllib.request.Request(logs_request_url(logs_url, limit), headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as response:
data = json.loads(response.read().decode("utf-8"))
if not isinstance(data, dict):
raise RuntimeError("logs API did not return a JSON object")
return data
def logs_envelope(payload: dict[str, Any]) -> dict[str, Any]:
data = payload.get("data")
if isinstance(data, dict) and isinstance(data.get("items"), list):
return data
return payload
def log_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
items = logs_envelope(payload).get("items")
if not isinstance(items, list):
return []
return [item for item in items if isinstance(item, dict)]
def log_type_label(item: dict[str, Any]) -> str:
text = str(item.get("request_type") or "").strip().lower()
if text and text != "unknown":
return text
if item.get("openai_ws_mode"):
return "ws_v2"
if item.get("stream") is True:
return "stream"
if item.get("stream") is False:
return "sync"
return text or "-"
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"))
)
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):
name = str(nested.get("name") or "").strip()
if name:
return name
if name_key:
name = str(item.get(name_key) or "").strip()
if name:
return name
identifier = as_int(item.get(id_key))
return f"#{identifier}" if identifier else "-"
def normalize_log_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
needle = filter_text.strip().lower()
rows = []
for item in log_items(payload):
key_name = nested_name(item, "api_key", "api_key_id", "api_key_name")
account_name = nested_name(item, "account", "account_id", "account_name")
user_name = nested_name(item, "user", "user_id", "user_name")
model = str(item.get("model") or "-")
upstream_model = str(item.get("upstream_model") or "").strip()
type_label = log_type_label(item)
row = {
"id": as_int(item.get("id")),
"key": key_name,
"account": account_name,
"user": user_name,
"model": model,
"upstream_model": upstream_model,
"type": type_label,
"tokens": log_total_tokens(item),
"input_tokens": as_int(item.get("input_tokens")),
"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")),
"cost": as_float(item.get("total_cost")),
"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")),
"created_at": str(item.get("created_at") or ""),
"time": short_time(item.get("created_at")),
"request_id": str(item.get("request_id") or ""),
"raw": item,
}
if needle:
haystack = " ".join(
(key_name, account_name, user_name, model, upstream_model, type_label, row["request_id"])
).lower()
if needle not in haystack:
continue
rows.append(row)
rows.sort(key=lambda row: (row["created_at"], row["id"]), reverse=True)
return rows
def logs_summary_line(payload: dict[str, Any], shown: int) -> str:
envelope = logs_envelope(payload)
total = as_int(envelope.get("total"))
page_size = as_int(envelope.get("page_size"))
fetched = short_time(dt.datetime.now(dt.timezone.utc).isoformat())
cost = sum(as_float(item.get("total_cost")) for item in log_items(payload))
tokens = sum(log_total_tokens(item) for item in log_items(payload))
return (
f"{fetched} logs | showing {shown}/{len(log_items(payload))} of latest {page_size or '-'} | "
f"total {format_count(total)} records | page cost {format_cost(cost)} | {format_count(tokens)} tokens"
)
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}"
)
if row["request_id"]:
detail += f" | {row['request_id']}"
return detail
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")
for row in rows:
print(
f"{row['key'][:20]:<21} "
f"{row['account'][:20]:<21} "
f"{row['model'][:24]:<25} "
f"{row['type']:<7} "
f"{format_count(row['tokens']):<8} "
f"{format_cost(row['cost']):<10} "
f"{format_latency(row['duration_ms']):<9} "
f"{row['time']}"
)
def kind_label(kind: Any) -> str:
return {
"quota_limited": "quota",
@@ -526,26 +720,32 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
)
def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: int, version_message: str = "") -> int:
def run_textual(
api_url: str,
status_url: str,
logs_url: str,
logs_token: str,
refresh_seconds: int,
logs_refresh_seconds: int,
timeout: int,
logs_limit: int,
version_message: str = "",
start_page: str = "accounts",
) -> int:
try:
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import DataTable, Footer, Header, Input, Static
except ImportError:
print("Textual is required. Run with: uv run --with textual python sub2api_quota_tui.py", file=sys.stderr)
return 2
class Sub2APIQuotaApp(App[None]):
CSS = """
#summary { height: 1; padding: 0 1; color: $accent; }
#filter { height: 3; }
#accounts { height: 1fr; }
#detail { height: 3; padding: 0 1; border-top: solid $panel; }
#status { height: 1; padding: 0 1; color: $text-muted; }
"""
class AccountsScreen(Screen[None]):
AUTO_FOCUS = "#accounts"
BINDINGS = [
("q", "quit", "Quit"),
("r", "refresh", "Refresh"),
("/", "focus_filter", "Filter"),
("l", "show_logs", "Logs"),
]
def __init__(self) -> None:
@@ -579,10 +779,17 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in
def action_focus_filter(self) -> None:
self.query_one("#filter", Input).focus()
def action_show_logs(self) -> None:
self.app.switch_screen(LogsScreen())
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "filter":
self.render_payload()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "filter":
self.query_one("#accounts", DataTable).focus()
def refresh_data(self, refresh: bool = False) -> None:
status = self.query_one("#status", Static)
status.update("refreshing...")
@@ -648,6 +855,119 @@ def run_textual(api_url: str, status_url: str, refresh_seconds: int, timeout: in
detail += f" | {error}"
self.query_one("#detail", Static).update(detail)
class LogsScreen(Screen[None]):
AUTO_FOCUS = "#logs"
BINDINGS = [
("r", "refresh", "Refresh"),
("/", "focus_filter", "Filter"),
("a", "show_accounts", "Accounts"),
]
def __init__(self) -> None:
super().__init__()
self.payload: dict[str, Any] = {}
self.rows: list[dict[str, Any]] = []
self.row_by_key: dict[str, dict[str, Any]] = {}
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield Static("", id="summary")
yield Input(placeholder="filter", id="filter")
yield DataTable(id="logs")
yield Static("", id="detail")
yield Static("", id="status")
yield Footer()
def on_mount(self) -> None:
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")
self.refresh_data()
self.set_interval(logs_refresh_seconds, self.refresh_data)
def action_refresh(self) -> None:
self.refresh_data()
def action_focus_filter(self) -> None:
self.query_one("#filter", Input).focus()
def action_show_accounts(self) -> None:
self.app.switch_screen(AccountsScreen())
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "filter":
self.render_payload()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "filter":
self.query_one("#logs", DataTable).focus()
def refresh_data(self) -> None:
status = self.query_one("#status", Static)
if not str(logs_token or "").strip():
status.update(
"logs token not configured: set SHUSUB2_LOGS_TOKEN or write the Sub2API admin API key to "
f"{logs_token_config_file_path()}"
)
return
status.update("refreshing logs...")
try:
self.payload = fetch_logs_payload(logs_url, logs_token, timeout, logs_limit)
self.render_payload()
status_bits = [version_message, f"latest {logs_limit} requests | {logs_url}"]
status.update(" | ".join(bit for bit in status_bits if bit))
except Exception as exc:
status.update(f"logs error: {exc}")
def render_payload(self) -> None:
filter_text = self.query_one("#filter", Input).value
self.rows = normalize_log_rows(self.payload, filter_text)
table = self.query_one("#logs", DataTable)
table.clear()
self.row_by_key = {}
for index, row in enumerate(self.rows):
key = f"{row['id']}-{index}"
self.row_by_key[key] = row
table.add_row(
row["key"],
row["account"],
row["model"],
row["type"],
format_count(row["tokens"]),
format_cost(row["cost"]),
format_latency(row["duration_ms"]),
row["time"],
key=key,
)
self.query_one("#summary", Static).update(logs_summary_line(self.payload, len(self.rows)))
if self.rows:
self.query_one("#detail", Static).update(log_detail_line(self.rows[0]))
else:
self.query_one("#detail", Static).update("no request logs")
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
key = str(event.row_key.value)
row = self.row_by_key.get(key)
if row:
self.query_one("#detail", Static).update(log_detail_line(row))
class Sub2APIQuotaApp(App[None]):
CSS = """
#summary { height: 1; padding: 0 1; color: $accent; }
#filter { height: 3; }
#accounts { height: 1fr; }
#logs { height: 1fr; }
#detail { height: 3; padding: 0 1; border-top: solid $panel; }
#status { height: 1; padding: 0 1; color: $text-muted; }
"""
BINDINGS = [
("q", "quit", "Quit"),
]
def on_mount(self) -> None:
self.push_screen(LogsScreen() if start_page == "logs" else AccountsScreen())
Sub2APIQuotaApp().run()
return 0
@@ -656,8 +976,10 @@ def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Sub2API quota and daily usage TUI")
parser.add_argument("--api-url", default=default_api_url())
parser.add_argument("--status-url", default=default_status_url(), help="optional sub2api-status /api/status URL for channel monitor health")
parser.add_argument("--save-config", action="store_true", help="persist --api-url to ~/.config/shusub2/api-url before running")
parser.add_argument("--install", action="store_true", help="persist --api-url, install shusub2 as a uv tool, then exit")
parser.add_argument("--logs-url", default=default_logs_url(), help="Sub2API admin usage logs URL (default: sub2apicn /api/v1/admin/usage)")
parser.add_argument("--logs-token", default=default_logs_token(), help="Sub2API admin API key for the logs page (prefer SHUSUB2_LOGS_TOKEN or ~/.config/shusub2/logs-token)")
parser.add_argument("--save-config", action="store_true", help="persist --api-url/--logs-url/--logs-token to ~/.config/shusub2/ before running")
parser.add_argument("--install", action="store_true", help="persist config, install shusub2 as a uv tool, then exit")
parser.add_argument("--version-check-url", default=os.environ.get("SHUSUB2_VERSION_CHECK_URL", DEFAULT_VERSION_CHECK_URL))
parser.add_argument(
"--version-check-timeout",
@@ -670,8 +992,19 @@ def build_parser() -> argparse.ArgumentParser:
type=int,
default=env_int("SUB2API_QUOTA_TUI_REFRESH_SECONDS", DEFAULT_REFRESH_SECONDS),
)
parser.add_argument(
"--logs-refresh-seconds",
type=int,
default=env_int("SHUSUB2_LOGS_REFRESH_SECONDS", DEFAULT_LOGS_REFRESH_SECONDS),
)
parser.add_argument(
"--logs-limit",
type=int,
default=env_int("SHUSUB2_LOGS_LIMIT", DEFAULT_LOGS_LIMIT),
)
parser.add_argument("--timeout", type=int, default=env_int("SUB2API_QUOTA_TUI_TIMEOUT", DEFAULT_TIMEOUT_SECONDS))
parser.add_argument("--once", action="store_true", help="print one snapshot and exit")
parser.add_argument("--logs", action="store_true", help="start on the request logs page (with --once: print a logs snapshot)")
parser.add_argument("--filter", default="", help="initial filter for --once output")
return parser
@@ -679,9 +1012,16 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
status_url = str(args.status_url or "").strip() or inferred_status_url(args.api_url)
logs_limit = max(1, min(args.logs_limit, 1000))
if args.save_config or args.install:
config_path = write_api_url_config(args.api_url)
print(f"saved api url to {config_path}")
if str(args.logs_url or "").strip():
logs_url_path = write_config_value(logs_url_config_file_path(), args.logs_url)
print(f"saved logs url to {logs_url_path}")
if str(args.logs_token or "").strip():
logs_token_path = write_config_value(logs_token_config_file_path(), args.logs_token)
print(f"saved logs token to {logs_token_path}")
if args.install:
print("installing shusub2 with uv tool...")
return run_install_command()
@@ -693,10 +1033,24 @@ def main(argv: list[str] | None = None) -> int:
if args.once:
if version_message:
print(version_message)
if args.logs:
print_logs_once(fetch_logs_payload(args.logs_url, args.logs_token, args.timeout, logs_limit), args.filter)
return 0
status_payload, status_error = fetch_optional_payload(status_url, args.timeout)
print_once(fetch_payload(args.api_url, args.timeout, refresh=True), args.filter, status_payload, status_error)
return 0
return run_textual(args.api_url, status_url, max(1, args.refresh_seconds), max(1, args.timeout), version_message)
return run_textual(
args.api_url,
status_url,
args.logs_url,
args.logs_token,
max(1, args.refresh_seconds),
max(1, args.logs_refresh_seconds),
max(1, args.timeout),
logs_limit,
version_message,
start_page="logs" if args.logs else "accounts",
)
if __name__ == "__main__":
+162
View File
@@ -323,5 +323,167 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
self.assertIn("monitors 1 ok / 0 failed / 0 unknown", out.getvalue())
def sample_logs_payload() -> dict:
return {
"code": 0,
"message": "success",
"data": {
"total": 2345,
"page": 1,
"page_size": 100,
"items": [
{
"id": 11,
"api_key_id": 3,
"api_key": {"id": 3, "name": "codex-main"},
"account_id": 7,
"account": {"id": 7, "name": "oai-sub-1"},
"user": {"id": 1, "name": "shujakuin"},
"model": "gpt-5.5",
"upstream_model": "gpt-5.5-codex",
"request_type": "stream",
"stream": True,
"input_tokens": 1200,
"output_tokens": 340,
"cache_creation_tokens": 50,
"cache_read_tokens": 4100,
"total_cost": 0.0123,
"actual_cost": 0.0061,
"duration_ms": 5321,
"first_token_ms": 800,
"request_id": "req-abc",
"created_at": "2026-07-21T10:00:00+08:00",
},
{
"id": 12,
"api_key_id": 4,
"account_id": 9,
"account": {"id": 9, "name": "claude-max"},
"model": "claude-sonnet-5",
"request_type": "",
"stream": False,
"input_tokens": 100,
"output_tokens": 20,
"total_cost": 0.001,
"duration_ms": 900,
"created_at": "2026-07-21T10:05:00+08:00",
},
],
},
}
class Sub2APILogsTests(unittest.TestCase):
def test_normalize_log_rows_maps_columns_and_sorts_newest_first(self) -> None:
mod = load_module()
rows = mod.normalize_log_rows(sample_logs_payload())
self.assertEqual([row["id"] for row in rows], [12, 11])
newest, oldest = rows
self.assertEqual(newest["key"], "#4")
self.assertEqual(newest["account"], "claude-max")
self.assertEqual(newest["type"], "sync")
self.assertEqual(newest["tokens"], 120)
self.assertEqual(oldest["key"], "codex-main")
self.assertEqual(oldest["account"], "oai-sub-1")
self.assertEqual(oldest["user"], "shujakuin")
self.assertEqual(oldest["type"], "stream")
self.assertEqual(oldest["tokens"], 1200 + 340 + 50 + 4100)
self.assertEqual(oldest["cost"], 0.0123)
self.assertEqual(oldest["duration_ms"], 5321)
self.assertTrue(oldest["time"].endswith("10:00") or oldest["time"] != "-")
def test_normalize_log_rows_filter_matches_key_account_model(self) -> None:
mod = load_module()
payload = sample_logs_payload()
self.assertEqual([row["id"] for row in mod.normalize_log_rows(payload, "codex-main")], [11])
self.assertEqual([row["id"] for row in mod.normalize_log_rows(payload, "claude")], [12])
self.assertEqual([row["id"] for row in mod.normalize_log_rows(payload, "stream")], [11])
self.assertEqual(mod.normalize_log_rows(payload, "no-match"), [])
def test_log_items_supports_flat_and_enveloped_payloads(self) -> None:
mod = load_module()
self.assertEqual(len(mod.log_items(sample_logs_payload())), 2)
self.assertEqual(len(mod.log_items({"items": [{"id": 1}]})), 1)
self.assertEqual(mod.log_items({"data": {"items": "nope"}}), [])
self.assertEqual(mod.log_items({}), [])
def test_log_type_label_falls_back_to_legacy_flags(self) -> None:
mod = load_module()
self.assertEqual(mod.log_type_label({"request_type": "ws_v2"}), "ws_v2")
self.assertEqual(mod.log_type_label({"request_type": "unknown", "openai_ws_mode": True}), "ws_v2")
self.assertEqual(mod.log_type_label({"stream": True}), "stream")
self.assertEqual(mod.log_type_label({"stream": False}), "sync")
self.assertEqual(mod.log_type_label({}), "-")
def test_logs_request_url_forces_first_page_and_limit(self) -> None:
mod = load_module()
url = mod.logs_request_url("https://sub2apicn.shujk.top/api/v1/admin/usage?page=9&page_size=5&user_id=2", 100)
parsed = dict(part.split("=") for part in url.split("?", 1)[1].split("&"))
self.assertEqual(parsed["page"], "1")
self.assertEqual(parsed["page_size"], "100")
self.assertEqual(parsed["user_id"], "2")
capped = mod.logs_request_url("https://sub2apicn.shujk.top/api/v1/admin/usage", 9000)
self.assertIn("page_size=1000", capped)
def test_print_logs_once_renders_requested_columns(self) -> None:
mod = load_module()
out = io.StringIO()
with contextlib.redirect_stdout(out):
mod.print_logs_once(sample_logs_payload())
text = out.getvalue()
self.assertIn("key", text)
self.assertIn("codex-main", text)
self.assertIn("oai-sub-1", text)
self.assertIn("gpt-5.5", text)
self.assertIn("stream", text)
self.assertIn("5.7K", text)
self.assertIn("$0.012", text)
self.assertIn("5321ms", text)
self.assertIn("total 2.3K records", text)
def test_default_logs_token_reads_config_file(self) -> None:
mod = load_module()
with tempfile.TemporaryDirectory() as tmp:
token_file = Path(tmp) / "logs-token"
token_file.write_text("# comment\nsk-admin-token\n", encoding="utf-8")
old_value = os.environ.get("SHUSUB2_LOGS_TOKEN_FILE")
os.environ["SHUSUB2_LOGS_TOKEN_FILE"] = str(token_file)
try:
self.assertEqual(mod.default_logs_token(), "sk-admin-token")
written = mod.write_config_value(mod.logs_token_config_file_path(), "sk-new-token")
self.assertEqual(written, token_file)
self.assertEqual(token_file.read_text(encoding="utf-8"), "sk-new-token\n")
finally:
if old_value is None:
os.environ.pop("SHUSUB2_LOGS_TOKEN_FILE", None)
else:
os.environ["SHUSUB2_LOGS_TOKEN_FILE"] = old_value
def test_default_logs_url_defaults_to_sub2apicn(self) -> None:
mod = load_module()
old_url = os.environ.pop("SHUSUB2_LOGS_URL", None)
old_file = os.environ.get("SHUSUB2_LOGS_URL_FILE")
os.environ["SHUSUB2_LOGS_URL_FILE"] = "/nonexistent/shusub2/logs-url"
try:
self.assertEqual(mod.default_logs_url(), "https://sub2apicn.shujk.top/api/v1/admin/usage")
finally:
if old_url is not None:
os.environ["SHUSUB2_LOGS_URL"] = old_url
if old_file is None:
os.environ.pop("SHUSUB2_LOGS_URL_FILE", None)
else:
os.environ["SHUSUB2_LOGS_URL_FILE"] = old_file
if __name__ == "__main__":
unittest.main()
Generated
+1 -1
View File
@@ -85,7 +85,7 @@ wheels = [
[[package]]
name = "shusub2"
version = "0.1.7"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "textual" },