fix: show Pricing Monitor source balances

This commit is contained in:
2026-08-03 01:08:49 +08:00
parent 845f103bd1
commit 49b2f963b9
5 changed files with 460 additions and 49 deletions
+31 -16
View File
@@ -12,7 +12,7 @@
- 包名:`shusub2`
- 命令名:`shusub2`
- 本地状态路径:`~/.config/shusub2/`
- 目标 API`cliproxy-codex-quota``/api/tui/accounts`、可选 `sub2api-status` `/api/status`,以及可选 Sub2API `/api/v1/admin/usage``/api/v1/admin/ops/errors`
- 目标 API`cliproxy-codex-quota``/api/tui/accounts`、可选 `sub2api-status` `/api/status`、可选 Pricing Monitor `/api/ui-data?view=accounts`,以及可选 Sub2API `/api/v1/admin/usage``/api/v1/admin/ops/errors`
- 备注:账号数据无 secret,不需要 SSH、数据库访问、API key、OAuth credentials 或 plaintext env;请求与错误日志可选持有 Sub2API admin API key,仅存本机 `~/.config/shusub2/logs-token`0600)或 `SHUSUB2_LOGS_TOKEN`
Single-page terminal dashboard for the token-safe Sub2API account feed,
@@ -62,6 +62,8 @@ Environment variables override the config file:
- `SHUSUB2_API_URL_FILE`
- `SHUSUB2_STATUS_URL`
- `SHUSUB2_STATUS_URL_FILE`
- `SHUSUB2_PRICING_URL`
- `SHUSUB2_PRICING_URL_FILE`
- `SHUSUB2_LOGS_URL`
- `SHUSUB2_LOGS_URL_FILE`
- `SHUSUB2_LOGS_TOKEN`
@@ -81,10 +83,12 @@ Environment variables override the config file:
## Unified Dashboard
`shusub2` opens a single dashboard with Accounts, Keys, Logs, and Errors
tables stacked vertically. Press `a`, `k`, `l`, or `e` to focus a table, `/` to
filter all four tables, and `r` to refresh all data immediately. Automatic Accounts,
Keys/Logs, and Errors refresh defaults to every five minutes. The client requests
`shusub2` opens a compact dashboard for Accounts, Keys, Logs, and Errors. Its
summary also reports Pricing Monitor upstream health and CNY total. Press `a`,
`k`, `l`, or `e` to focus a dashboard table; press `p` to open the full
Upstreams source-balance page. `/` filters the active table and `r` refreshes
all data immediately. Automatic Accounts and Upstreams, Keys/Logs, and Errors refresh defaults
to every five minutes. The client requests
gzip-compressed JSON and transparently decodes it when the upstream supports it.
The two-line detail area follows the selected row. Accounts, Keys, Logs, and
Errors use green, yellow, magenta, and red section styling respectively.
@@ -94,7 +98,8 @@ complete keys, accounts, and models. On a narrow terminal, a table scrolls
horizontally instead of truncating a field:
```text
ACCOUNT | Group | Today | Daily | 5h | 5h Bal | 7d | 7d Bal | Avail
ACCOUNT | Group | Today | Daily | 5h | 7d | Avail
UPSTREAMS (p) | Source | Kind | Balance | CNY | Unit | Status | Updated | Age
KEY | Today | Tokens | Req
LOG KEY | Account | Model | First | Duration | Tok/s | Input | Output | Cache | Tokens | Cost | Time | Age
ERR | Status | Key | Account | Model | Time | Age
@@ -104,8 +109,8 @@ The Keys table shows today's usage sorted by actual cost, including key name,
tokens, and request count. It uses the same admin API key as Logs and Errors;
without that token the table is hidden and the summary remains compact.
Use `--accounts`, `--logs`, or `--errors` to start a dedicated full-table view.
The corresponding `--once` form still prints only that data set.
Use `--accounts`, `--pricing`, `--logs`, or `--errors` to start a dedicated
full-table view. The corresponding `--once` form still prints only that data set.
## Request Logs Page
@@ -204,6 +209,7 @@ Configuration:
- `--api-url` / `SUB2API_QUOTA_TUI_API_URL`
- `--status-url` / `SHUSUB2_STATUS_URL`
- `--pricing-url` / `SHUSUB2_PRICING_URL`
- `--logs-url` / `SHUSUB2_LOGS_URL`
- `--logs-token` / `SHUSUB2_LOGS_TOKEN`
- `--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`
@@ -211,14 +217,19 @@ Configuration:
- `--errors-refresh-seconds` / `SHUSUB2_ERRORS_REFRESH_SECONDS`
- `--errors-limit` / `SHUSUB2_ERRORS_LIMIT`
- `--errors-time-range` / `SHUSUB2_ERRORS_TIME_RANGE`
- `--accounts` / `--logs` / `--errors`
- `--accounts` / `--pricing` / `--logs` / `--errors`
- `--save-config`
- `--install`
- `--refresh-seconds` / `SUB2API_QUOTA_TUI_REFRESH_SECONDS`
- `--timeout` / `SUB2API_QUOTA_TUI_TIMEOUT`
The dashboard reads `/api/tui/accounts` and can optionally read `sub2api-status`
`/api/status` for channel monitor health. The Accounts table does not need SSH,
The dashboard reads `/api/tui/accounts`, can optionally read `sub2api-status`
`/api/status` for channel monitor health, and reads Pricing Monitor's
`/api/ui-data?view=accounts` for upstream source balances. The default pricing URL
is the Tailscale HTTPS service; `--pricing-url`, `SHUSUB2_PRICING_URL`, or
`~/.config/shusub2/pricing-url` can override it. This is a cached, read-only
projection collected by Pricing Monitor on its own cadence: `shusub2` neither
contacts upstream providers directly nor asks Pricing Monitor to refresh. The Accounts table does not need SSH,
database access, API keys, OAuth credentials, or plaintext env files. The Keys,
Logs, and Errors tables authenticate to the Sub2API admin APIs with
an admin API key stored only in `~/.config/shusub2/logs-token` (0600) or
@@ -241,7 +252,7 @@ panel after the accounts table when a token is configured.
The dedicated Accounts page keeps the full columns for scanning inside zellij:
```text
Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 5h Bal | 7d | 7d Bal | Reset | Status | Availability
Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset | Status | Availability
```
`Provider` distinguishes `openai` and `anthropic` accounts from the public
@@ -250,10 +261,14 @@ Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 5h Bal | 7d
`Daily` is shown as `used/limit` when Sub2API has `quota_daily_*` fields in
`accounts.extra`; otherwise it is `-`.
`5h` / `7d` retain the used/remaining percentages, while `5h Bal` / `7d Bal` show
that same window's derived remaining USD balance from the token-safe account
feed. A missing window stays `-`; the client does not invent a cross-window
total or currency conversion.
`5h` / `7d` are only the account feed's used/remaining quota percentages. They
are not upstream provider balances and therefore never claim to be a cash or
source-account balance.
The dedicated `--pricing` page shows Pricing Monitor source rows such as
`code-plan`, `codexapis`, `testvideo`, `kedaya`, `ycy`, and `mdkj`: raw available
balance with its source unit, derived CNY when supplied, health state, and last
successful collection time. It reads only the existing sanitized source projection.
`Group` is the derived Sub2API tier alias group. Higher tiers win when multiple
aliases exist: `id < slow < fast < sfast`. The table shows `sfast` first, then
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "shusub2"
version = "0.2.13"
version = "0.2.14"
description = "Terminal UI for Sub2API account quota and daily usage"
readme = "README.md"
requires-python = ">=3.11"
+294 -26
View File
@@ -7,6 +7,7 @@ import datetime as dt
import gzip
import importlib.metadata
import json
import math
import os
import re
import subprocess
@@ -20,10 +21,12 @@ from typing import Any
APP_NAME = "shusub2"
FALLBACK_VERSION = "0.2.13"
FALLBACK_VERSION = "0.2.14"
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_PRICING_URL = "https://price.tailbeb9ad.ts.net/api/ui-data?view=accounts"
DEFAULT_PRICING_URL_CONFIG_FILE = "~/.config/shusub2/pricing-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"
@@ -86,6 +89,14 @@ def default_status_url() -> str:
)
def default_pricing_url() -> str:
return configured_url(
("SHUSUB2_PRICING_URL",),
os.environ.get("SHUSUB2_PRICING_URL_FILE", DEFAULT_PRICING_URL_CONFIG_FILE),
DEFAULT_PRICING_URL,
)
def default_logs_url() -> str:
return configured_url(
("SHUSUB2_LOGS_URL",),
@@ -109,6 +120,12 @@ def logs_url_config_file_path() -> Path:
return Path(os.environ.get("SHUSUB2_LOGS_URL_FILE", DEFAULT_LOGS_URL_CONFIG_FILE)).expanduser()
def pricing_url_config_file_path() -> Path:
return Path(
os.environ.get("SHUSUB2_PRICING_URL_FILE", DEFAULT_PRICING_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()
@@ -263,6 +280,30 @@ def format_cost(value: Any) -> str:
return f"${amount:.2f}".rstrip("0").rstrip(".")
def optional_number(value: Any) -> float | None:
if value is None or str(value).strip() == "":
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def format_amount(value: Any) -> str:
amount = optional_number(value)
if amount is None:
return "-"
if amount.is_integer():
return f"{int(amount):,}"
return f"{amount:,.6f}".rstrip("0").rstrip(".")
def format_cny(value: Any) -> str:
amount = format_amount(value)
return "-" if amount == "-" else f"¥{amount}"
def format_count(value: Any) -> str:
number = as_int(value)
if abs(number) >= 1_000_000:
@@ -361,6 +402,13 @@ def fetch_optional_payload(url: str, timeout: int) -> tuple[dict[str, Any], str]
return {}, str(exc)
def fetch_pricing_payload(pricing_url: str, timeout: int) -> dict[str, Any]:
payload = fetch_payload(pricing_url, timeout)
if not isinstance(payload.get("sources"), list):
raise RuntimeError("Pricing Monitor did not return source balances")
return payload
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)
@@ -1063,13 +1111,6 @@ def window_cell(account: dict[str, Any], window_id: str) -> str:
return f"{format_percent(window.get('used_percent'))}/{format_percent(window.get('remaining_percent'))}"
def window_balance_cell(account: dict[str, Any], window_id: str) -> str:
window = window_for(account, window_id)
if not window or window.get("remaining_balance_usd") is None:
return "-"
return format_cost(window.get("remaining_balance_usd"))
def daily_quota_cell(account: dict[str, Any]) -> str:
limit = account.get("daily_quota_limit")
used = account.get("daily_quota_used")
@@ -1142,8 +1183,6 @@ def normalize_account_rows(payload: dict[str, Any], filter_text: str = "") -> li
"quota_used_percent_max": account.get("quota_used_percent_max"),
"five_hour": window_cell(account, "five-hour"),
"weekly": window_cell(account, "weekly"),
"five_hour_balance": window_balance_cell(account, "five-hour"),
"weekly_balance": window_balance_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 ""),
@@ -1164,6 +1203,84 @@ def normalize_account_rows(payload: dict[str, Any], filter_text: str = "") -> li
return rows
def pricing_status(source: dict[str, Any]) -> str:
if str(source.get("last_error") or "").strip():
return "error"
state = str(source.get("health_state") or "").strip().lower()
return state or ("refreshing" if source.get("refreshing") else "unknown")
def normalize_pricing_rows(payload: dict[str, Any], filter_text: str = "") -> list[dict[str, Any]]:
needle = filter_text.strip().lower()
rows: list[dict[str, Any]] = []
for source in payload.get("sources") or []:
if not isinstance(source, dict):
continue
balance = source.get("balance") if isinstance(source.get("balance"), dict) else {}
row = {
"name": str(source.get("name") or "-"),
"kind": str(source.get("source_kind") or "-"),
"balance": optional_number(balance.get("available")),
"balance_cny": optional_number(balance.get("available_cny")),
"unit": str(balance.get("unit") or "-"),
"status": pricing_status(source),
"updated_at": source.get("last_success_at"),
"updated": short_time(source.get("last_success_at")),
"age": relative_age(source.get("last_success_at")),
"error": str(source.get("last_error") or "").strip(),
"raw": source,
}
haystack = " ".join(
str(row[key] or "") for key in ("name", "kind", "unit", "status", "error")
).lower()
if needle and needle not in haystack:
continue
rows.append(row)
rows.sort(key=lambda row: (row["status"] != "healthy", row["name"].lower()))
return rows
def pricing_summary(payload: dict[str, Any], rows: list[dict[str, Any]] | None = None) -> str:
source_rows = rows if rows is not None else normalize_pricing_rows(payload)
healthy = sum(row["status"] == "healthy" for row in source_rows)
cny_values = [
row["balance_cny"]
for row in source_rows
if row["status"] == "healthy" and row["balance_cny"] is not None
]
total_cny = sum(cny_values) if cny_values else None
total_label = f" | CNY {format_cny(total_cny)}" if total_cny is not None else ""
return f"upstreams {healthy}/{len(source_rows)} healthy{total_label}"
def pricing_detail_line(row: dict[str, Any]) -> str:
detail = (
f"{row['name']} | {row['kind']} | balance {format_amount(row['balance'])} {row['unit']} | "
f"CNY {format_cny(row['balance_cny'])} | {row['status']} | "
f"updated {row['updated']} ({row['age']})"
)
if row["error"]:
detail += f" | {row['error']}"
return detail
def print_pricing_once(payload: dict[str, Any], filter_text: str = "") -> None:
rows = normalize_pricing_rows(payload, filter_text)
print(f"{short_time(payload.get('generated_at'))} | {pricing_summary(payload, rows)}")
print("source kind balance cny unit status updated age")
for row in rows:
print(
f"{row['name']:<30} "
f"{row['kind']:<15} "
f"{format_amount(row['balance']):<19} "
f"{format_cny(row['balance_cny']):<19} "
f"{row['unit']:<9} "
f"{row['status']:<11} "
f"{row['updated']:<12} "
f"{row['age']}"
)
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"))
@@ -1181,7 +1298,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
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 5h bal 7d 7d bal reset status availability")
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} "
@@ -1193,9 +1310,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
f"{format_count(row['today_requests']):<5} "
f"{row['kind_label']:<9} "
f"{row['five_hour']:<9} "
f"{row['five_hour_balance']:<9} "
f"{row['weekly']:<9} "
f"{row['weekly_balance']:<9} "
f"{row['reset']:<11} "
f"{row['status']:<10} "
f"{monitor_availability(row, status_payload or {})}"
@@ -1205,6 +1320,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
def run_textual(
api_url: str,
status_url: str,
pricing_url: str,
logs_url: str,
logs_token: str,
errors_cn_url: str,
@@ -1234,6 +1350,7 @@ def run_textual(
("r", "refresh", "Refresh"),
("/", "focus_filter", "Filter"),
("a", "focus_accounts", "Accounts"),
("p", "show_pricing", "Upstreams"),
("k", "focus_keys", "Keys"),
("l", "focus_logs", "Logs"),
("e", "focus_errors", "Errors"),
@@ -1243,6 +1360,8 @@ def run_textual(
super().__init__(classes="dashboard")
self.accounts_payload: dict[str, Any] = {}
self.status_payload: dict[str, Any] = {}
self.pricing_payload: dict[str, Any] = {}
self.pricing_error = ""
self.monitor_error = ""
self.accounts_error = ""
self.logs_payload: dict[str, Any] = {}
@@ -1285,7 +1404,7 @@ def run_textual(
def on_mount(self) -> None:
self.configure_table(
self.query_one("#accounts", DataTable),
("ACCOUNT", "Group", "Today", "Daily", "5h", "5h Bal", "7d", "7d Bal", "Avail"),
("ACCOUNT", "Group", "Today", "Daily", "5h", "7d", "Avail"),
)
self.configure_table(
self.query_one("#keys", DataTable),
@@ -1315,6 +1434,7 @@ def run_textual(
)
self.refresh_all(refresh=True)
self.set_interval(refresh_seconds, self.refresh_accounts)
self.set_interval(refresh_seconds, self.refresh_pricing)
self.set_interval(logs_refresh_seconds, self.refresh_logs)
self.set_interval(errors_refresh_seconds, self.refresh_errors)
self.focus_table("accounts")
@@ -1328,6 +1448,9 @@ def run_textual(
def action_focus_accounts(self) -> None:
self.focus_table("accounts")
def action_show_pricing(self) -> None:
self.app.switch_screen(PricingScreen())
def action_focus_keys(self) -> None:
table = self.query_one("#keys", DataTable)
if table.display:
@@ -1353,8 +1476,9 @@ def run_textual(
self.focus_table(self.active_table)
def refresh_all(self, refresh: bool = False) -> None:
self.query_one("#status", Static).update("refreshing accounts, keys, logs, and errors...")
self.query_one("#status", Static).update("refreshing accounts, upstream balances, keys, logs, and errors...")
self.refresh_accounts(refresh=refresh)
self.refresh_pricing()
self.refresh_logs()
self.refresh_errors()
@@ -1368,6 +1492,15 @@ def run_textual(
self.render_accounts()
self.render_meta()
def refresh_pricing(self) -> None:
self.pricing_error = ""
try:
self.pricing_payload = fetch_pricing_payload(pricing_url, timeout)
except Exception as exc:
self.pricing_payload = {}
self.pricing_error = str(exc)
self.render_meta()
def refresh_logs(self) -> None:
self.logs_error = ""
if not str(logs_token or "").strip():
@@ -1437,9 +1570,7 @@ def run_textual(
format_cost(row["today_cost_usd"]),
row["daily_quota_cell"],
row["five_hour"],
row["five_hour_balance"],
row["weekly"],
row["weekly_balance"],
monitor_availability(row, self.status_payload),
key=key,
)
@@ -1536,6 +1667,11 @@ def run_textual(
style="green",
)
summary.append(" | ", style="dim")
if self.pricing_error:
summary.append("upstreams unavailable", style="bright_blue")
else:
summary.append(pricing_summary(self.pricing_payload), style="bright_blue")
summary.append(" | ", style="dim")
summary.append(f"keys {len(self.key_rows)} ({key_summary.removeprefix('keys ')})", style="yellow")
summary.append(" | ", style="dim")
summary.append(f"logs {len(self.log_rows)}/{logs_total}", style="magenta")
@@ -1553,6 +1689,10 @@ def run_textual(
f"source {self.accounts_payload.get('source_name') or '-'}",
)
)
if self.pricing_error:
status_bits.append(f"upstreams: {self.pricing_error}")
elif self.pricing_payload:
status_bits.append(pricing_summary(self.pricing_payload) + f" | {pricing_url}")
shared_admin_error = self.logs_error and self.logs_error == self.errors_error
if shared_admin_error:
status_bits.append(f"logs/errors: {self.logs_error}")
@@ -1592,8 +1732,8 @@ def run_textual(
detail = (
f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | "
f"daily {row['daily_quota_cell']} | "
f"5h {row['five_hour_balance']} ({row['five_hour']}) | "
f"7d {row['weekly_balance']} ({row['weekly']}) | "
f"5h {row['five_hour']} | "
f"7d {row['weekly']} | "
f"today {format_cost(row['today_cost_usd'])}, "
f"{format_count(row['today_tokens'])} tokens, {format_count(row['today_requests'])} req | "
f"{monitor_detail(row, self.status_payload)}"
@@ -1635,6 +1775,7 @@ def run_textual(
BINDINGS = [
("r", "refresh", "Refresh"),
("/", "focus_filter", "Filter"),
("p", "show_pricing", "Upstreams"),
("l", "show_logs", "Logs"),
("e", "show_errors", "Errors"),
]
@@ -1663,7 +1804,7 @@ def run_textual(
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", "5h Bal", "7d", "7d Bal", "Reset", "Status", "Availability")
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
@@ -1677,6 +1818,9 @@ def run_textual(
def action_focus_filter(self) -> None:
self.query_one("#filter", Input).focus()
def action_show_pricing(self) -> None:
self.app.switch_screen(PricingScreen())
def action_show_logs(self) -> None:
self.app.switch_screen(LogsScreen())
@@ -1751,9 +1895,7 @@ def run_textual(
format_count(row["today_requests"]),
row["kind_label"],
row["five_hour"],
row["five_hour_balance"],
row["weekly"],
row["weekly_balance"],
row["reset"],
row["status"],
monitor_availability(row, self.status_payload),
@@ -1777,7 +1919,7 @@ def run_textual(
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"5h {row['five_hour_balance']} ({row['five_hour']}) | 7d {row['weekly_balance']} ({row['weekly']}) | "
f"5h {row['five_hour']} | 7d {row['weekly']} | "
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 '-'} | "
@@ -1788,12 +1930,117 @@ def run_textual(
detail += f" | {error}"
self.query_one("#detail", Static).update(detail)
class PricingScreen(Screen[None]):
AUTO_FOCUS = "#pricing"
BINDINGS = [
("r", "refresh", "Refresh"),
("/", "focus_filter", "Filter"),
("a", "show_accounts", "Accounts"),
("l", "show_logs", "Logs"),
("e", "show_errors", "Errors"),
]
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 upstream sources", id="filter")
yield DataTable(id="pricing")
yield Static("", id="detail")
yield Static("", id="status")
yield Footer()
def on_mount(self) -> None:
table = self.query_one("#pricing", DataTable)
table.cursor_type = "row"
table.zebra_stripes = True
table.add_columns("Source", "Kind", "Balance", "CNY", "Unit", "Status", "Updated", "Age")
self.refresh_data()
self.set_interval(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 action_show_logs(self) -> None:
self.app.switch_screen(LogsScreen())
def action_show_errors(self) -> None:
self.app.switch_screen(ErrorsScreen())
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("#pricing", DataTable).focus()
def refresh_data(self) -> None:
status = self.query_one("#status", Static)
status.update("refreshing upstream balances from Pricing Monitor...")
try:
self.payload = fetch_pricing_payload(pricing_url, timeout)
self.render_payload()
status_bits = [version_message, pricing_summary(self.payload), pricing_url]
status.update(" | ".join(bit for bit in status_bits if bit))
except Exception as exc:
self.payload = {}
self.rows = []
self.row_by_key = {}
self.query_one("#pricing", DataTable).clear()
self.query_one("#summary", Static).update("upstreams unavailable")
self.query_one("#detail", Static).update("no upstream balances")
status.update(f"upstreams error: {exc}")
def render_payload(self) -> None:
filter_text = self.query_one("#filter", Input).value
self.rows = normalize_pricing_rows(self.payload, filter_text)
table = self.query_one("#pricing", DataTable)
table.clear()
self.row_by_key = {}
for index, row in enumerate(self.rows):
key = f"pricing-{row['name']}-{index}"
self.row_by_key[key] = row
table.add_row(
row["name"],
row["kind"],
format_amount(row["balance"]),
format_cny(row["balance_cny"]),
row["unit"],
row["status"],
row["updated"],
row["age"],
key=key,
)
self.query_one("#summary", Static).update(pricing_summary(self.payload, self.rows))
if self.rows:
self.query_one("#detail", Static).update(pricing_detail_line(self.rows[0]))
else:
self.query_one("#detail", Static).update("no upstream balances")
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
row = self.row_by_key.get(str(event.row_key.value))
if row:
self.query_one("#detail", Static).update(pricing_detail_line(row))
class LogsScreen(Screen[None]):
AUTO_FOCUS = "#logs"
BINDINGS = [
("r", "refresh", "Refresh"),
("/", "focus_filter", "Filter"),
("a", "show_accounts", "Accounts"),
("p", "show_pricing", "Upstreams"),
("e", "show_errors", "Errors"),
]
@@ -1845,6 +2092,9 @@ def run_textual(
def action_show_accounts(self) -> None:
self.app.switch_screen(AccountsScreen())
def action_show_pricing(self) -> None:
self.app.switch_screen(PricingScreen())
def action_show_errors(self) -> None:
self.app.switch_screen(ErrorsScreen())
@@ -1916,6 +2166,7 @@ def run_textual(
("r", "refresh", "Refresh"),
("/", "focus_filter", "Filter"),
("a", "show_accounts", "Accounts"),
("p", "show_pricing", "Upstreams"),
("l", "show_logs", "Logs"),
]
@@ -1951,6 +2202,9 @@ def run_textual(
def action_show_accounts(self) -> None:
self.app.switch_screen(AccountsScreen())
def action_show_pricing(self) -> None:
self.app.switch_screen(PricingScreen())
def action_show_logs(self) -> None:
self.app.switch_screen(LogsScreen())
@@ -2032,6 +2286,7 @@ def run_textual(
#filter { height: 1; border: none; padding: 0 1; }
#filter:focus { border: none; }
#accounts { height: 1fr; min-height: 3; }
#pricing { height: 1fr; min-height: 3; }
#keys { height: auto; max-height: 6; }
.dashboard #accounts { background: $success 5%; }
.dashboard #accounts > .datatable--header { background: $success 35%; color: $foreground; }
@@ -2053,6 +2308,8 @@ def run_textual(
def on_mount(self) -> None:
if start_page == "accounts":
self.push_screen(AccountsScreen())
elif start_page == "pricing":
self.push_screen(PricingScreen())
elif start_page == "logs":
self.push_screen(LogsScreen())
elif start_page == "errors":
@@ -2075,6 +2332,7 @@ 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("--pricing-url", default=default_pricing_url(), help="Pricing Monitor /api/ui-data?view=accounts URL for upstream source balances")
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 logs/errors pages (prefer SHUSUB2_LOGS_TOKEN or ~/.config/shusub2/logs-token)")
parser.add_argument("--errors-cn-url", default=default_errors_cn_url(), help="cn Sub2API admin ops errors URL (default: sub2apicn /api/v1/admin/ops/errors)")
@@ -2083,7 +2341,7 @@ def build_parser() -> argparse.ArgumentParser:
default=default_errors_us_url(),
help="us Sub2API admin ops errors URL (default: DNS-only us.sub2 /api/v1/admin/ops/errors; CF sub2apius often blocks non-browser clients)",
)
parser.add_argument("--save-config", action="store_true", help="persist --api-url/--logs-url/--logs-token/--errors-*-url to ~/.config/shusub2/ before running")
parser.add_argument("--save-config", action="store_true", help="persist --api-url/--pricing-url/--logs-url/--logs-token/--errors-*-url 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(
@@ -2125,6 +2383,7 @@ def build_parser() -> argparse.ArgumentParser:
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("--accounts", action="store_true", help="start on the dedicated accounts page (with --once: print the accounts snapshot)")
parser.add_argument("--pricing", action="store_true", help="start on the Pricing Monitor upstream balances page (with --once: print source balances)")
parser.add_argument("--logs", action="store_true", help="start on the dedicated request logs page (with --once: print a logs snapshot)")
parser.add_argument("--errors", action="store_true", help="start on the dedicated merged cn+us errors page (with --once: print an errors snapshot)")
parser.add_argument("--filter", default="", help="initial filter for --once output")
@@ -2137,13 +2396,16 @@ def main(argv: list[str] | None = None) -> int:
logs_limit = max(1, min(args.logs_limit, 1000))
errors_limit = max(1, min(args.errors_limit, 500))
errors_time_range = str(args.errors_time_range or DEFAULT_ERRORS_TIME_RANGE).strip() or DEFAULT_ERRORS_TIME_RANGE
selected_pages = sum(bool(value) for value in (args.accounts, args.logs, args.errors))
selected_pages = sum(bool(value) for value in (args.accounts, args.pricing, args.logs, args.errors))
if selected_pages > 1:
print("choose only one of --accounts, --logs, or --errors", file=sys.stderr)
print("choose only one of --accounts, --pricing, --logs, or --errors", file=sys.stderr)
return 2
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.pricing_url or "").strip():
pricing_url_path = write_config_value(pricing_url_config_file_path(), args.pricing_url)
print(f"saved pricing url to {pricing_url_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}")
@@ -2167,6 +2429,9 @@ def main(argv: list[str] | None = None) -> int:
if args.once:
if version_message:
print(version_message)
if args.pricing:
print_pricing_once(fetch_pricing_payload(args.pricing_url, args.timeout), args.filter)
return 0
if args.logs:
if not str(args.logs_token or "").strip():
print(logs_token_hint(), file=sys.stderr)
@@ -2200,6 +2465,8 @@ def main(argv: list[str] | None = None) -> int:
start_page = "dashboard"
if args.accounts:
start_page = "accounts"
elif args.pricing:
start_page = "pricing"
elif args.logs:
start_page = "logs"
elif args.errors:
@@ -2207,6 +2474,7 @@ def main(argv: list[str] | None = None) -> int:
return run_textual(
args.api_url,
status_url,
args.pricing_url,
args.logs_url,
args.logs_token,
args.errors_cn_url,
+133 -5
View File
@@ -39,8 +39,8 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
"today_tokens": 2000,
"today_requests": 4,
"windows": [
{"id": "five-hour", "used_percent": 80, "remaining_percent": 20, "remaining_balance_usd": 4, "reset": "2026-06-09T15:00:00+08:00"},
{"id": "weekly", "used_percent": 10, "remaining_percent": 90, "remaining_balance_usd": 108, "reset": "2026-06-10T15:00:00+08:00"},
{"id": "five-hour", "used_percent": 80, "remaining_percent": 20, "reset": "2026-06-09T15:00:00+08:00"},
{"id": "weekly", "used_percent": 10, "remaining_percent": 90, "reset": "2026-06-10T15:00:00+08:00"},
],
},
{
@@ -72,8 +72,8 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
self.assertEqual(rows[0]["kind_label"], "usage")
self.assertEqual(rows[1]["five_hour"], "80%/20%")
self.assertEqual(rows[1]["weekly"], "10%/90%")
self.assertEqual(rows[1]["five_hour_balance"], "$4")
self.assertEqual(rows[1]["weekly_balance"], "$108")
self.assertNotIn("five_hour_balance", rows[1])
self.assertNotIn("weekly_balance", rows[1])
self.assertIn("today $0.75", mod.summary_line(payload))
def test_filter_matches_kind_and_name(self) -> None:
@@ -90,6 +90,89 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
self.assertEqual([row["name"] for row in mod.normalize_account_rows(payload, "sfast")], ["beta"])
self.assertEqual([row["name"] for row in mod.normalize_account_rows(payload, "anthropic")], ["alpha"])
def test_normalize_pricing_rows_uses_pricing_monitor_source_balances(self) -> None:
mod = load_module()
payload = {
"generated_at": "2026-08-02T12:00:00Z",
"sources": [
{
"name": "code-plan",
"source_kind": "newapi",
"health_state": "healthy",
"last_success_at": "2026-08-02T11:59:00Z",
"balance_available": True,
"balance": {"available": 5000000, "available_cny": 10, "unit": "quota"},
},
{
"name": "kedaya",
"source_kind": "remote_monitor",
"health_state": "error",
"last_error": "upstream request failed",
"balance_available": False,
"balance": {"available": 99, "available_cny": 99, "unit": "balance"},
},
],
}
rows = mod.normalize_pricing_rows(payload)
self.assertEqual([row["name"] for row in rows], ["code-plan", "kedaya"])
self.assertEqual(rows[0]["balance"], 5000000)
self.assertEqual(rows[0]["balance_cny"], 10)
self.assertEqual(rows[0]["unit"], "quota")
self.assertEqual(rows[0]["status"], "healthy")
self.assertEqual(rows[1]["status"], "error")
self.assertEqual([row["name"] for row in mod.normalize_pricing_rows(payload, "keday")], ["kedaya"])
self.assertEqual(mod.pricing_summary(payload, rows), "upstreams 1/2 healthy | CNY ¥10")
out = io.StringIO()
with contextlib.redirect_stdout(out):
mod.print_pricing_once(payload)
self.assertIn("code-plan", out.getvalue())
self.assertIn("5,000,000", out.getvalue())
self.assertIn("¥10", out.getvalue())
def test_default_pricing_url_reads_env_or_config_file(self) -> None:
mod = load_module()
with tempfile.TemporaryDirectory() as tmp:
config_file = Path(tmp) / "shusub2" / "pricing-url"
config_file.parent.mkdir(parents=True)
config_file.write_text("https://pricing.example.test/api/ui-data?view=accounts\n", encoding="utf-8")
old_url = os.environ.get("SHUSUB2_PRICING_URL")
old_file = os.environ.get("SHUSUB2_PRICING_URL_FILE")
os.environ["SHUSUB2_PRICING_URL_FILE"] = str(config_file)
os.environ.pop("SHUSUB2_PRICING_URL", None)
try:
self.assertEqual(
mod.default_pricing_url(),
"https://pricing.example.test/api/ui-data?view=accounts",
)
os.environ["SHUSUB2_PRICING_URL"] = "https://env.example.test/api/ui-data?view=accounts"
self.assertEqual(
mod.default_pricing_url(),
"https://env.example.test/api/ui-data?view=accounts",
)
finally:
if old_url is None:
os.environ.pop("SHUSUB2_PRICING_URL", None)
else:
os.environ["SHUSUB2_PRICING_URL"] = old_url
if old_file is None:
os.environ.pop("SHUSUB2_PRICING_URL_FILE", None)
else:
os.environ["SHUSUB2_PRICING_URL_FILE"] = old_file
def test_fetch_pricing_payload_requires_sources_list(self) -> None:
mod = load_module()
original_fetch = mod.fetch_payload
mod.fetch_payload = lambda *args, **kwargs: {"view": "accounts", "sources": []}
try:
self.assertEqual(mod.fetch_pricing_payload("https://price.example/api/ui-data?view=accounts", 1)["sources"], [])
mod.fetch_payload = lambda *args, **kwargs: {"view": "accounts"}
with self.assertRaisesRegex(RuntimeError, "source balances"):
mod.fetch_pricing_payload("https://price.example/api/ui-data?view=accounts", 1)
finally:
mod.fetch_payload = original_fetch
def test_infers_public_status_url_from_accounts_url(self) -> None:
mod = load_module()
@@ -252,7 +335,7 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
self.assertEqual(mod.monitor_availability(rows[0], status_payload), "ok")
self.assertIn("availability", out.getvalue())
self.assertIn("5h bal", out.getvalue())
self.assertNotIn("5h bal", out.getvalue())
self.assertIn(" ok\n", out.getvalue())
def test_monitor_availability_is_dash_without_bound_monitor(self) -> None:
@@ -756,6 +839,19 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase):
],
}
mod.fetch_optional_payload = lambda *args, **kwargs: ({}, "")
mod.fetch_pricing_payload = lambda *args, **kwargs: {
"generated_at": "2026-07-24T12:00:00+08:00",
"sources": [
{
"name": "code-plan",
"source_kind": "newapi",
"health_state": "healthy",
"last_success_at": "2026-07-24T12:00:00+08:00",
"balance_available": True,
"balance": {"available": 5000000, "available_cny": 10, "unit": "quota"},
}
],
}
mod.fetch_key_usage_payload = lambda *args, **kwargs: {
"date": "2026-07-24",
"trend": [{"api_key_id": 1, "key_name": "wmy", "requests": 3, "tokens": 300}],
@@ -805,6 +901,7 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase):
rc = mod.run_textual(
"https://example/accounts",
"",
"https://example/pricing?view=accounts",
"https://example/usage",
"token",
"https://example/cn",
@@ -846,9 +943,40 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase):
await pilot.press(key)
await pilot.pause()
self.assertEqual(screen.focused.id, expected_id)
await pilot.press("p")
await pilot.pause()
self.assertEqual(type(app.screen).__name__, "PricingScreen")
self.assertEqual(app.screen.focused.id, "pricing")
self.assertEqual(app.screen.rows[0]["name"], "code-plan")
class PageSelectionTests(unittest.TestCase):
def test_once_pricing_prints_pricing_monitor_sources(self) -> None:
mod = load_module()
original_fetch = mod.fetch_pricing_payload
mod.fetch_pricing_payload = lambda *args, **kwargs: {
"generated_at": "2026-08-02T12:00:00Z",
"sources": [
{
"name": "code-plan",
"source_kind": "newapi",
"health_state": "healthy",
"last_success_at": "2026-08-02T11:59:00Z",
"balance": {"available": 5000000, "available_cny": 10, "unit": "quota"},
}
],
}
out = io.StringIO()
try:
with contextlib.redirect_stdout(out):
rc = mod.main(["--once", "--pricing", "--no-version-check"])
finally:
mod.fetch_pricing_payload = original_fetch
self.assertEqual(rc, 0)
self.assertIn("code-plan", out.getvalue())
self.assertIn("¥10", out.getvalue())
def test_dedicated_page_flags_are_mutually_exclusive(self) -> None:
mod = load_module()
old_token = os.environ.pop("SHUSUB2_LOGS_TOKEN", None)
Generated
+1 -1
View File
@@ -85,7 +85,7 @@ wheels = [
[[package]]
name = "shusub2"
version = "0.2.13"
version = "0.2.14"
source = { editable = "." }
dependencies = [
{ name = "textual" },