Files
shusub2/sub2api_quota_tui.py
T
shujakuin 0e9b53dc79 feat: per-key today usage panel under the accounts table
- accounts page gains a #keys DataTable below the accounts table showing
  each API key's usage today: Key | Today | Tokens | Req, sorted by cost
- data comes from the Sub2API admin dashboard api-keys-trend (requests,
  tokens, key names) plus api-keys-usage (today_actual_cost), derived
  from the configured logs url and reusing the same admin API key
- key names use the same stable per-key colors as the logs page
- --once prints the same panel after the accounts snapshot; failures or
  a missing token surface in the status line without touching accounts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 12:32:10 +08:00

1255 lines
48 KiB
Python

#!/usr/bin/env python3
"""Textual TUI for Sub2API quota and daily account usage."""
from __future__ import annotations
import argparse
import datetime as dt
import importlib.metadata
import json
import os
import re
import subprocess
import sys
import urllib.parse
import urllib.request
import zlib
from pathlib import Path
from typing import Any
APP_NAME = "shusub2"
FALLBACK_VERSION = "0.2.3"
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"}
MONITOR_FAILED_STATUSES = {"error", "failed", "failure"}
MONITOR_STOPWORDS = {"response", "responses", "monitor"}
INSTALL_COMMAND = "uv tool install --force git+https://gitea.shujk.top/shujakuin/shusub2.git"
INSTALL_COMMAND_ARGS = ["uv", "tool", "install", "--force", "git+https://gitea.shujk.top/shujakuin/shusub2.git"]
def env_int(name: str, default: int, *, minimum: int = 1) -> int:
try:
return max(minimum, int(os.environ.get(name, default)))
except Exception:
return default
def configured_url(env_names: tuple[str, ...], config_path: str, default: str = "") -> str:
for name in env_names:
value = os.environ.get(name, "").strip()
if value:
return value
config_file = Path(config_path).expanduser()
try:
for line in config_file.read_text(encoding="utf-8").splitlines():
value = line.strip()
if value and not value.startswith("#"):
return value
except OSError:
pass
return default
def default_api_url() -> str:
return configured_url(
("SHUSUB2_API_URL", "SUB2API_QUOTA_TUI_API_URL"),
os.environ.get("SHUSUB2_API_URL_FILE", DEFAULT_CONFIG_FILE),
DEFAULT_API_URL,
)
def default_status_url() -> str:
return configured_url(
("SHUSUB2_STATUS_URL",),
os.environ.get("SHUSUB2_STATUS_URL_FILE", DEFAULT_STATUS_CONFIG_FILE),
)
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")
return write_config_value(config_file_path(), value)
def run_install_command() -> int:
try:
return subprocess.run(INSTALL_COMMAND_ARGS, check=False).returncode
except FileNotFoundError:
print("uv command not found; install uv first, then run:", INSTALL_COMMAND, file=sys.stderr)
return 127
def inferred_status_url(api_url: str) -> str:
parsed = urllib.parse.urlparse(str(api_url or "").strip())
if not parsed.scheme or not parsed.netloc:
return ""
path = parsed.path.rstrip("/")
if path.endswith("/api/tui/accounts"):
status_path = path[: -len("/api/tui/accounts")] + "/api/status"
elif path.endswith("/api/accounts"):
status_path = path[: -len("/api/accounts")] + "/api/status"
else:
return ""
return urllib.parse.urlunparse(parsed._replace(path=status_path, params="", query="", fragment=""))
def current_version() -> str:
try:
return importlib.metadata.version(APP_NAME)
except importlib.metadata.PackageNotFoundError:
return FALLBACK_VERSION
except Exception:
return FALLBACK_VERSION
def parse_version(value: Any) -> tuple[int, ...]:
parts = []
for part in re.split(r"[^0-9]+", str(value or "")):
if part:
parts.append(int(part))
return tuple(parts)
def version_is_newer(latest: str, current: str) -> bool:
latest_parts = parse_version(latest)
current_parts = parse_version(current)
width = max(len(latest_parts), len(current_parts), 1)
return latest_parts + (0,) * (width - len(latest_parts)) > current_parts + (0,) * (width - len(current_parts))
def latest_version_from_text(text: str) -> str:
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
return "" if not match else match.group(1).strip()
def fetch_latest_version(url: str, timeout: int) -> str:
req = urllib.request.Request(url, headers={"Accept": "text/plain"})
with urllib.request.urlopen(req, timeout=timeout) as response:
return latest_version_from_text(response.read(65536).decode("utf-8", errors="replace"))
def version_update_message(latest: str, current: str | None = None) -> str:
current = current or current_version()
if not latest or not version_is_newer(latest, current):
return ""
return f"update available: {APP_NAME} {current} -> {latest}; run `{INSTALL_COMMAND}`"
def check_version_update(url: str, timeout: int, *, disabled: bool = False) -> str:
if disabled or os.environ.get("SHUSUB2_NO_VERSION_CHECK", "").strip().lower() in {"1", "true", "yes", "on"}:
return ""
try:
return version_update_message(fetch_latest_version(url, timeout))
except Exception:
return ""
def as_float(value: Any) -> float:
try:
if value is None or str(value).strip() == "":
return 0.0
return float(value)
except Exception:
return 0.0
def as_int(value: Any) -> int:
try:
if value is None or str(value).strip() == "":
return 0
return int(float(value))
except Exception:
return 0
def format_cost(value: Any) -> str:
amount = as_float(value)
if amount == 0:
return "$0"
if abs(amount) < 0.01:
return f"${amount:.6f}".rstrip("0").rstrip(".")
if abs(amount) < 10:
return f"${amount:.3f}".rstrip("0").rstrip(".")
return f"${amount:.2f}".rstrip("0").rstrip(".")
def format_count(value: Any) -> str:
number = as_int(value)
if abs(number) >= 1_000_000:
return f"{number / 1_000_000:.1f}M".rstrip("0").rstrip(".")
if abs(number) >= 1_000:
return f"{number / 1_000:.1f}K".rstrip("0").rstrip(".")
return str(number)
def format_percent(value: Any) -> str:
if value is None or str(value).strip() == "":
return "-"
percent = as_float(value)
rounded = round(percent, 1)
if rounded.is_integer():
return f"{int(rounded)}%"
return f"{rounded}%"
def parse_time(value: Any) -> dt.datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
normalized = text[:-1] + "+00:00" if text.endswith("Z") else text
return dt.datetime.fromisoformat(normalized)
except Exception:
return None
def short_time(value: Any) -> str:
parsed = parse_time(value)
if not parsed:
return "-"
return parsed.astimezone().strftime("%m-%d %H:%M")
def add_refresh_param(api_url: str, refresh: bool) -> str:
if not refresh:
return api_url
parsed = urllib.parse.urlparse(api_url)
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
query = [(key, value) for key, value in query if key != "refresh"]
query.append(("refresh", "1"))
return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query)))
def fetch_payload(api_url: str, timeout: int, *, refresh: bool = False) -> dict[str, Any]:
req = urllib.request.Request(add_refresh_param(api_url, refresh), headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as response:
data = json.loads(response.read().decode("utf-8"))
if not isinstance(data, dict):
raise RuntimeError("API did not return a JSON object")
return data
def fetch_optional_payload(url: str, timeout: int) -> tuple[dict[str, Any], str]:
if not str(url or "").strip():
return {}, ""
try:
return fetch_payload(url, timeout), ""
except Exception as exc:
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 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"
def format_seconds(value: Any) -> str:
number = as_int(value)
if number <= 0:
return "-"
return f"{number / 1000:.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 admin_api_base(logs_url: str) -> str:
parsed = urllib.parse.urlparse(str(logs_url or "").strip())
if not parsed.scheme or not parsed.netloc:
return ""
path = parsed.path.rstrip("/")
if path.endswith("/usage"):
path = path[: -len("/usage")]
if not path.endswith("/admin"):
return ""
return urllib.parse.urlunparse(parsed._replace(path=path, params="", query="", fragment=""))
def fetch_admin_json(url: str, token: str, timeout: int, body: dict[str, Any] | None = None) -> dict[str, Any]:
headers = {"Accept": "application/json"}
if str(token or "").strip():
headers["x-api-key"] = str(token).strip()
data = None
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
if not isinstance(payload, dict):
raise RuntimeError("admin API did not return a JSON object")
return payload
def payload_data(payload: dict[str, Any]) -> dict[str, Any]:
data = payload.get("data")
return data if isinstance(data, dict) else payload
def fetch_key_usage_payload(logs_url: str, token: str, timeout: int, limit: int = 100) -> dict[str, Any]:
base = admin_api_base(logs_url)
if not base:
raise RuntimeError("cannot derive the admin API base from the logs url")
today = dt.date.today().isoformat()
query = urllib.parse.urlencode({"start_date": today, "end_date": today, "granularity": "day", "limit": limit})
trend_payload = fetch_admin_json(f"{base}/dashboard/api-keys-trend?{query}", token, timeout)
trend = payload_data(trend_payload).get("trend")
points = [point for point in trend if isinstance(point, dict)] if isinstance(trend, list) else []
ids = sorted({as_int(point.get("api_key_id")) for point in points if as_int(point.get("api_key_id"))})
stats: dict[str, Any] = {}
if ids:
costs_payload = fetch_admin_json(f"{base}/dashboard/api-keys-usage", token, timeout, body={"api_key_ids": ids})
raw_stats = payload_data(costs_payload).get("stats")
if isinstance(raw_stats, dict):
stats = raw_stats
return {"date": today, "trend": points, "stats": stats}
def normalize_key_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
stats = payload.get("stats") if isinstance(payload.get("stats"), dict) else {}
merged: dict[Any, dict[str, Any]] = {}
for point in payload.get("trend") or []:
if not isinstance(point, dict):
continue
key_id = as_int(point.get("api_key_id"))
name = str(point.get("key_name") or "").strip() or (f"#{key_id}" if key_id else "-")
row = merged.setdefault(key_id or name, {"id": key_id, "name": name, "requests": 0, "tokens": 0, "cost": 0.0})
row["requests"] += as_int(point.get("requests"))
row["tokens"] += as_int(point.get("tokens"))
for row in merged.values():
stat = stats.get(str(row["id"]))
if not isinstance(stat, dict):
stat = stats.get(row["id"])
if isinstance(stat, dict):
row["cost"] = as_float(stat.get("today_actual_cost"))
rows = list(merged.values())
rows.sort(key=lambda row: (-row["cost"], -row["tokens"], str(row["name"]).lower()))
return rows
def print_keys_once(payload: dict[str, Any]) -> None:
rows = normalize_key_rows(payload)
total_cost = sum(row["cost"] for row in rows)
print(f"keys today {payload.get('date') or '-'} | {len(rows)} keys | {format_cost(total_cost)}")
print("key today tokens req")
for row in rows:
print(
f"{str(row['name'])[:20]:<21} "
f"{format_cost(row['cost']):<10} "
f"{format_count(row['tokens']):<8} "
f"{format_count(row['requests'])}"
)
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()
effort = str(item.get("reasoning_effort") or "").strip() or "-"
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,
"effort": effort,
"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")),
"tokens_per_second": log_tokens_per_second(item),
"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, effort, 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']}"
if row["effort"] != "-":
model = f"{model} ({row['effort']})"
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"first {format_seconds(row['first_token_ms'])} latency {format_seconds(row['duration_ms'])} "
f"{format_rate(row['tokens_per_second'])}"
)
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 effort type tokens cost first latency tok/s time")
for row in rows:
print(
f"{row['key'][:20]:<21} "
f"{row['account'][:20]:<21} "
f"{row['model'][:24]:<25} "
f"{row['effort']:<7} "
f"{row['type']:<7} "
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_rate(row['tokens_per_second']):<8} "
f"{row['time']}"
)
def kind_label(kind: Any) -> str:
return {
"quota_limited": "quota",
"pending_quota": "pending",
"daily_limited": "daily",
"pay_as_you_go": "usage",
"disabled": "disabled",
}.get(str(kind or ""), str(kind or "unknown"))
def provider_label(account: dict[str, Any]) -> str:
text = str(account.get("provider") or account.get("platform") or "").strip().lower()
if not text:
return "-"
if "anthropic" in text or "claude" in text:
return "anthropic"
if "openai" in text or "chatgpt" in text:
return "openai"
return text
def routing_group_sort_rank(value: Any) -> int:
return {"sfast": 0, "fast": 1, "slow": 2, "id": 3, "": 4, "-": 4}.get(str(value or "").strip().lower(), 4)
def status_kind(value: Any) -> str:
text = str(value or "").strip().lower()
if text in MONITOR_OK_STATUSES:
return "ok"
if text in MONITOR_FAILED_STATUSES:
return "failed"
return "unknown"
def format_latency(value: Any) -> str:
number = as_int(value)
return "-" if number <= 0 else f"{number}ms"
def monitor_items(status_payload: dict[str, Any]) -> list[dict[str, Any]]:
monitors = status_payload.get("channel_monitors") if isinstance(status_payload.get("channel_monitors"), dict) else {}
items = []
for item in monitors.get("items") or []:
if isinstance(item, dict):
items.append(item)
return items
def monitor_summary(status_payload: dict[str, Any], error: str = "") -> str:
if error:
return f"monitors error: {error}"
monitors = status_payload.get("channel_monitors") if isinstance(status_payload.get("channel_monitors"), dict) else {}
if not monitors:
return "monitors -"
enabled = as_int(monitors.get("enabled"))
ok = as_int(monitors.get("latest_ok"))
failed = as_int(monitors.get("latest_failed"))
unknown = as_int(monitors.get("latest_unknown"))
checked = short_time(monitors.get("latest_checked_max"))
return f"monitors {ok} ok / {failed} failed / {unknown} unknown ({enabled} enabled, {checked})"
def text_tokens(value: Any) -> set[str]:
tokens = set()
for token in "".join(ch.lower() if ch.isalnum() else " " for ch in str(value or "")).split():
if token and token not in MONITOR_STOPWORDS and not token.isdigit():
tokens.add(token)
return tokens
def matching_monitor(row: dict[str, Any], status_payload: dict[str, Any]) -> dict[str, Any] | None:
base_url_hash = str(row.get("base_url_hash") or "").strip()
if base_url_hash:
for item in monitor_items(status_payload):
if str(item.get("base_url_hash") or "").strip() == base_url_hash:
return item
account_tokens = text_tokens(row.get("name"))
if not account_tokens:
return None
best: tuple[int, dict[str, Any]] | None = None
for item in monitor_items(status_payload):
monitor_tokens = text_tokens(item.get("name"))
if not monitor_tokens:
continue
overlap = account_tokens & monitor_tokens
if not overlap:
continue
subset_bonus = 10 if account_tokens <= monitor_tokens or monitor_tokens <= account_tokens else 0
score = subset_bonus + len(overlap)
if best is None or score > best[0]:
best = (score, item)
return None if best is None else best[1]
def monitor_detail(row: dict[str, Any], status_payload: dict[str, Any]) -> str:
item = matching_monitor(row, status_payload)
if not item:
return "monitor -"
status = str(item.get("latest_status") or "unknown")
latency = format_latency(item.get("latency_ms"))
checked = short_time(item.get("checked_at"))
message = str(item.get("message") or "").strip()
detail = f"monitor {status} {latency} checked {checked}"
if message:
detail += f" {message}"
return detail
def monitor_availability(row: dict[str, Any], status_payload: dict[str, Any]) -> str:
item = matching_monitor(row, status_payload)
if not item:
return "-"
if item.get("enabled") is False:
return "disabled"
raw_status = str(item.get("latest_status") or "").strip().lower()
if not raw_status:
return "unknown"
kind = status_kind(raw_status)
if kind == "ok":
return "ok"
if kind == "failed":
return "failed"
return raw_status
def window_for(account: dict[str, Any], window_id: str) -> dict[str, Any]:
for window in account.get("windows") or []:
if isinstance(window, dict) and window.get("id") == window_id:
return window
return {}
def window_cell(account: dict[str, Any], window_id: str) -> str:
window = window_for(account, window_id)
if not window or window.get("used_percent") is None:
return "-"
return f"{format_percent(window.get('used_percent'))}/{format_percent(window.get('remaining_percent'))}"
def daily_quota_cell(account: dict[str, Any]) -> str:
limit = account.get("daily_quota_limit")
used = account.get("daily_quota_used")
if limit is None:
return "-"
return f"{used or 0}/{limit}"
def reset_cell(account: dict[str, Any]) -> str:
daily_reset = short_time(account.get("daily_quota_reset_at"))
if daily_reset != "-":
return daily_reset
five_hour = window_for(account, "five-hour")
weekly = window_for(account, "weekly")
for window in (five_hour, weekly):
reset = short_time(window.get("reset"))
if reset != "-":
return reset
return "-"
def normalize_account_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
needle = filter_text.strip().lower()
rows = []
for account in payload.get("accounts") or []:
if not isinstance(account, dict):
continue
haystack = " ".join(
str(account.get(key) or "")
for key in (
"id",
"name",
"routing_group",
"provider",
"kind",
"status",
"account_type",
"platform",
"plan",
"daily_quota_timezone",
)
).lower()
if needle and needle not in haystack:
continue
routing_group = str(account.get("routing_group") or "-")
rows.append(
{
"id": as_int(account.get("id")),
"name": str(account.get("name") or ""),
"routing_group": routing_group,
"base_url_hash": str(account.get("base_url_hash") or ""),
"provider": provider_label(account),
"kind": str(account.get("kind") or "unknown"),
"kind_label": kind_label(account.get("kind")),
"status": str(account.get("status") or ""),
"account_type": str(account.get("account_type") or ""),
"plan": str(account.get("plan") or ""),
"priority": account.get("priority"),
"today_cost_usd": as_float(account.get("today_cost_usd")),
"today_actual_cost_usd": as_float(account.get("today_actual_cost_usd")),
"today_tokens": as_int(account.get("today_tokens")),
"today_requests": as_int(account.get("today_requests")),
"daily_quota": account.get("daily_quota") if isinstance(account.get("daily_quota"), dict) else {},
"daily_quota_limit": account.get("daily_quota_limit"),
"daily_quota_used": account.get("daily_quota_used"),
"daily_quota_remaining": account.get("daily_quota_remaining"),
"daily_quota_used_percent": account.get("daily_quota_used_percent"),
"daily_quota_reset_at": account.get("daily_quota_reset_at"),
"daily_quota_cell": daily_quota_cell(account),
"quota_used_percent_max": account.get("quota_used_percent_max"),
"five_hour": window_cell(account, "five-hour"),
"weekly": window_cell(account, "weekly"),
"reset": reset_cell(account),
"latest_usage_at": short_time(account.get("latest_usage_at") or account.get("last_used_at")),
"error": str(account.get("error") or ""),
"raw": account,
}
)
rows.sort(
key=lambda item: (
routing_group_sort_rank(item["routing_group"]),
1 if item["kind"] == "disabled" else 0,
-as_float(item["today_cost_usd"]),
-as_int(item["today_tokens"]),
-as_float(item["daily_quota_used_percent"]),
-as_float(item["quota_used_percent_max"]),
str(item["name"]).lower(),
)
)
return rows
def summary_line(payload: dict[str, Any]) -> str:
totals = payload.get("totals") if isinstance(payload.get("totals"), dict) else {}
generated = short_time(payload.get("generated_local") or payload.get("generated_at"))
cached = "cached" if payload.get("cached") else "live"
return (
f"{generated} {cached} | "
f"{as_int(totals.get('usable_accounts'))}/{as_int(totals.get('total_accounts'))} usable | "
f"today {format_cost(totals.get('today_cost_usd'))} | "
f"{format_count(totals.get('today_tokens'))} tokens | "
f"{format_count(totals.get('today_requests'))} req"
)
def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: dict[str, Any] | None = None, status_error: str = "") -> None:
print(summary_line(payload))
if status_payload or status_error:
print(monitor_summary(status_payload or {}, status_error))
print("name provider group daily today tokens req kind 5h 7d reset status availability")
for row in normalize_account_rows(payload, filter_text):
print(
f"{row['name'][:30]:<30} "
f"{row['provider']:<9} "
f"{row['routing_group']:<6} "
f"{row['daily_quota_cell']:<10} "
f"{format_cost(row['today_cost_usd']):<10} "
f"{format_count(row['today_tokens']):<8} "
f"{format_count(row['today_requests']):<5} "
f"{row['kind_label']:<9} "
f"{row['five_hour']:<9} "
f"{row['weekly']:<9} "
f"{row['reset']:<11} "
f"{row['status']:<10} "
f"{monitor_availability(row, status_payload or {})}"
)
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 rich.text import Text
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 AccountsScreen(Screen[None]):
AUTO_FOCUS = "#accounts"
BINDINGS = [
("r", "refresh", "Refresh"),
("/", "focus_filter", "Filter"),
("l", "show_logs", "Logs"),
]
def __init__(self) -> None:
super().__init__()
self.payload: dict[str, Any] = {}
self.status_payload: dict[str, Any] = {}
self.status_error = ""
self.keys_payload: dict[str, Any] = {}
self.keys_error = ""
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="accounts")
yield DataTable(id="keys")
yield Static("", id="detail")
yield Static("", id="status")
yield Footer()
def on_mount(self) -> None:
table = self.query_one("#accounts", DataTable)
table.cursor_type = "row"
table.zebra_stripes = True
table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status", "Availability")
keys_table = self.query_one("#keys", DataTable)
keys_table.cursor_type = "row"
keys_table.zebra_stripes = True
keys_table.add_columns("Key", "Today", "Tokens", "Req")
self.refresh_data(refresh=True)
self.set_interval(refresh_seconds, self.refresh_data)
def action_refresh(self) -> None:
self.refresh_data(refresh=True)
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...")
try:
self.payload = fetch_payload(api_url, timeout, refresh=refresh)
self.status_payload, self.status_error = fetch_optional_payload(status_url, timeout)
self.refresh_keys()
self.render_payload()
self.render_keys()
status_bits = [version_message, monitor_summary(self.status_payload, self.status_error), f"source {self.payload.get('source_name') or '-'}"]
if self.keys_error:
status_bits.append(f"keys: {self.keys_error}")
status.update(" | ".join(bit for bit in status_bits if bit) + f" | {api_url}")
except Exception as exc:
status.update(f"error: {exc}")
def refresh_keys(self) -> None:
self.keys_error = ""
self.keys_payload = {}
if not str(logs_token or "").strip():
self.keys_error = "logs token not configured"
return
try:
self.keys_payload = fetch_key_usage_payload(logs_url, logs_token, timeout)
except Exception as exc:
self.keys_error = str(exc)
def render_keys(self) -> None:
table = self.query_one("#keys", DataTable)
table.clear()
for row in normalize_key_rows(self.keys_payload):
color = key_color(row["name"])
table.add_row(
Text(str(row["name"]), style=color) if color else str(row["name"]),
format_cost(row["cost"]),
format_count(row["tokens"]),
format_count(row["requests"]),
)
def render_payload(self) -> None:
filter_text = self.query_one("#filter", Input).value
self.rows = normalize_account_rows(self.payload, filter_text)
table = self.query_one("#accounts", DataTable)
table.clear()
self.row_by_key = {}
for row in self.rows:
key = str(row["id"])
self.row_by_key[key] = row
table.add_row(
row["name"],
row["provider"],
row["routing_group"],
row["daily_quota_cell"],
format_cost(row["today_cost_usd"]),
format_count(row["today_tokens"]),
format_count(row["today_requests"]),
row["kind_label"],
row["five_hour"],
row["weekly"],
row["reset"],
row["status"],
monitor_availability(row, self.status_payload),
key=key,
)
self.query_one("#summary", Static).update(summary_line(self.payload))
if self.rows:
self.render_detail(self.rows[0])
else:
self.query_one("#detail", Static).update("no accounts")
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.render_detail(row)
def render_detail(self, row: dict[str, Any]) -> None:
raw = row.get("raw") if isinstance(row.get("raw"), dict) else {}
detail = (
f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | {row['account_type']} | "
f"daily {row['daily_quota_cell']} ({format_percent(row['daily_quota_used_percent'])} used, "
f"{row['daily_quota_remaining'] if row['daily_quota_remaining'] is not None else '-'} left) | "
f"today {format_cost(row['today_cost_usd'])}, {format_count(row['today_tokens'])} tokens, "
f"{format_count(row['today_requests'])} req | latest {row['latest_usage_at']} | "
f"priority {row['priority'] if row['priority'] is not None else '-'} | "
f"{monitor_detail(row, self.status_payload)}"
)
error = str(raw.get("error") or "").strip()
if error:
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", "Effort", "Type", "Tokens", "Cost", "First", "Latency", "Tok/s", "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_hint())
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
color = key_color(row["key"])
table.add_row(
Text(row["key"], style=color) if color else row["key"],
row["account"],
row["model"],
row["effort"],
row["type"],
format_count(row["tokens"]),
format_cost(row["cost"]),
format_seconds(row["first_token_ms"]),
format_seconds(row["duration_ms"]),
format_rate(row["tokens_per_second"]),
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: 2fr; }
#keys { height: 1fr; border-top: solid $panel; }
#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
def logs_token_hint() -> str:
return (
"logs token not configured: set SHUSUB2_LOGS_TOKEN or write the Sub2API admin API key to "
f"{logs_token_config_file_path()}"
)
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("--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",
type=int,
default=env_int("SHUSUB2_VERSION_CHECK_TIMEOUT", DEFAULT_VERSION_CHECK_TIMEOUT_SECONDS),
)
parser.add_argument("--no-version-check", action="store_true", default=os.environ.get("SHUSUB2_NO_VERSION_CHECK", "").strip().lower() in {"1", "true", "yes", "on"})
parser.add_argument(
"--refresh-seconds",
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
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()
version_message = check_version_update(
args.version_check_url,
max(1, args.version_check_timeout),
disabled=bool(args.no_version_check),
)
if args.once:
if version_message:
print(version_message)
if args.logs:
if not str(args.logs_token or "").strip():
print(logs_token_hint(), file=sys.stderr)
return 2
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)
if str(args.logs_token or "").strip():
print()
try:
print_keys_once(fetch_key_usage_payload(args.logs_url, args.logs_token, args.timeout))
except Exception as exc:
print(f"keys error: {exc}", file=sys.stderr)
return 0
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__":
raise SystemExit(main())