From 49b2f963b91217593bdb85ae6aec6fa89898595d Mon Sep 17 00:00:00 2001 From: yunyaozhou Date: Mon, 3 Aug 2026 01:08:49 +0800 Subject: [PATCH] fix: show Pricing Monitor source balances --- README.md | 47 ++++--- pyproject.toml | 2 +- sub2api_quota_tui.py | 320 ++++++++++++++++++++++++++++++++++++++---- tests/test_payload.py | 138 +++++++++++++++++- uv.lock | 2 +- 5 files changed, 460 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 104a5c8..ab86a04 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index c1557b6..c68c5f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/sub2api_quota_tui.py b/sub2api_quota_tui.py index 104d0a0..c3f64ea 100644 --- a/sub2api_quota_tui.py +++ b/sub2api_quota_tui.py @@ -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, diff --git a/tests/test_payload.py b/tests/test_payload.py index 0d480cd..d9d6497 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -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) diff --git a/uv.lock b/uv.lock index 7372d4a..7684dc0 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.2.13" +version = "0.2.14" source = { editable = "." } dependencies = [ { name = "textual" },