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