diff --git a/README.md b/README.md index d8c38b2..22ea818 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ refresh。组件失败时服务端保留对应 last-good 数据,并在 `compon Workspace 当前提供以下有界 projection: -- `accounts`:账号摘要、quota window、当日用量和 provider/group/status 字段;如能按当前 server6 upstream key-group binding 关联,还包含 `account_rate_multiplier` 原始有效倍率和 `account_rate_multiplier_cny` 展示倍率。 +- `accounts`:按完整 `(account_id, account_name)` 对齐 server6/server4 fresh binding 和 scheduled-test plan 的账号摘要、quota window 与当日用量;`active_state`、`schedulable_state` 是 `yes` / `partial` / `no`,`last_test_state` 另可为 `NA`。每个节点先选择自身最新的有效测试结果,聚合 `last_test_time` / `last_test_model` 取两者中较早的一次;详情保留脱敏 `node_status` 用于定位 partial。当前 server6 upstream key-group binding 精确关联时仍包含 `account_rate_multiplier` 原始有效倍率和 `account_rate_multiplier_cny` 展示倍率。 - `status`:channel monitor 摘要。 - `sources`:Pricing Monitor 已有的脱敏 source 余额与健康状态。 - `traffic.requests`:仅 server6 的最新有界请求样本;每行带对应 account 的当前 `account_rate_multiplier` / `account_rate_multiplier_cny`,无法稳定关联时为 `null`。 @@ -131,7 +131,7 @@ account、model、phase/type/owner,不显示聚合次数。Key usage 和对应 `--legacy-direct` 仅用于迁移对账和故障诊断。它恢复旧拓扑: - Accounts helper `/api/tui/accounts` -- `sub2api-status` `/api/status` +- `sub2api-status` `/api/status`(历史接口,已归档下线;仅作为旧客户端参数的兼容说明) - Pricing Monitor `view=accounts` - Sub2API admin usage/key/error API @@ -202,12 +202,15 @@ uv lock --check ## Maintenance Notes Workspace adapter 只负责把 server projection 映射为现有渲染模型,不得在普通刷新中 -重新引入 legacy HTTP 请求。新增 projection 字段时应同时验证:字段 allowlist、字符串 ID、 +重新引入 legacy HTTP 请求。当前 `sub2api-status` 与旧 Accounts helper 已归档下线,Pricing Monitor +受管模型中的 Workspace 输入已成对清空。由于含用户未提交 Rule Control 改动的 companion 不在本次部署范围, +远端 collector 暂保留旧环境并会显示 stale / partial;下一次安全的 selected deployment 或重启后,默认 +`shusub2` Workspace 入口将返回 `503 workspace data unavailable`。新增替代 projection 字段时应同时验证:字段 allowlist、字符串 ID、 finite number、payload 上限、单请求 cache、错误聚合次数和 legacy fallback。 -`cliproxy-codex-quota` 与 `sub2api-status` 当前仍可作为 Aggregation Hub 的过渡采集输入和 -双读对照,但不再是默认 TUI 客户端直连依赖。移除这些过渡输入前,必须先完成 server2 -聚合字段对账和 AstrBot `view=alerts` 连续性验证。 +`cliproxy-codex-quota` 可继续作为 Aggregation Hub 的过渡采集输入;`sub2api-status` 已归档下线, +不得再作为采集输入或双读对照。恢复 Workspace 前,必须先完成新的服务端 projection、server2 聚合字段 +对账和 AstrBot `view=alerts` 连续性验证。 ## Tests And Release diff --git a/pyproject.toml b/pyproject.toml index 5f07bac..77a835a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.3.5" +version = "0.3.6" description = "Aggregated operations TUI for Sub2API" readme = "README.md" requires-python = ">=3.11" diff --git a/sub2api_quota_tui.py b/sub2api_quota_tui.py index b0fad05..6dc4765 100644 --- a/sub2api_quota_tui.py +++ b/sub2api_quota_tui.py @@ -27,7 +27,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError APP_NAME = "shusub2" -FALLBACK_VERSION = "0.3.5" +FALLBACK_VERSION = "0.3.6" DEFAULT_WORKSPACE_URL = "https://price.tailbeb9ad.ts.net/api/ui-data?view=workspace" DEFAULT_WORKSPACE_URL_CONFIG_FILE = "~/.config/shusub2/workspace-url" DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts" @@ -664,6 +664,38 @@ def fetch_workspace_response( return payload, etag +def workspace_account_contract_is_valid(rows: list[dict[str, Any]]) -> bool: + for row in rows: + if str(row.get("active_state") or "") not in {"yes", "partial", "no"}: + return False + if str(row.get("schedulable_state") or "") not in {"yes", "partial", "no"}: + return False + if str(row.get("last_test_state") or "") not in {"yes", "partial", "no", "NA"}: + return False + if not isinstance(row.get("last_test_time"), str) or not isinstance( + row.get("last_test_model"), str + ): + return False + node_status = row.get("node_status") + if not isinstance(node_status, dict): + return False + for node_name in ("server6", "server4"): + node = node_status.get(node_name) + if not isinstance(node, dict): + return False + if node.get("active") is not None and not isinstance(node.get("active"), bool): + return False + if node.get("schedulable") is not None and not isinstance(node.get("schedulable"), bool): + return False + if str(node.get("last_test") or "") not in {"yes", "no", "NA"}: + return False + if not isinstance(node.get("last_test_time"), str) or not isinstance( + node.get("last_test_model"), str + ): + return False + return True + + def fetch_workspace_payload( workspace_url: str, timeout: int, @@ -709,6 +741,8 @@ def fetch_workspace_payload( not isinstance(row, dict) for row in rows ): raise RuntimeError(f"Pricing Monitor returned invalid workspace {label}") + if not workspace_account_contract_is_valid(payload["accounts"]["accounts"]): + raise RuntimeError("Pricing Monitor returned invalid workspace account contract") for label in ("requests", "keys"): for row in traffic.get(label) or []: if str(row.get("instance") or row.get("node") or "").strip() != "server6": @@ -1255,7 +1289,7 @@ def log_detail_line(row: dict[str, Any]) -> str: 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("node key account multiplier model effort type input output cache tokens cost first duration tok/s time age") + print("node key account multiplier model effort type input output cache tokens cost first duration tok/s time age") for row in rows: print( f"{row['node'][:8]:<9} " @@ -1708,6 +1742,36 @@ def reset_cell(account: dict[str, Any]) -> str: return "-" +def workspace_account_state(value: Any, *, last_test: bool = False) -> str: + if isinstance(value, bool): + return "yes" if value else "no" + allowed = {"yes", "partial", "no"} + if last_test: + allowed.add("NA") + state = str(value or "").strip() + return state if state in allowed else "-" + + +def workspace_account_node_detail(row: dict[str, Any]) -> str: + node_status = row.get("node_status") + if not isinstance(node_status, dict): + return "two-node -" + details: list[str] = [] + for node_name in ("server6", "server4"): + node = node_status.get(node_name) + if not isinstance(node, dict): + details.append(f"{node_name} unavailable") + continue + details.append( + f"{node_name} active {workspace_account_state(node.get('active'))} " + f"schedulable {workspace_account_state(node.get('schedulable'))} " + f"test {workspace_account_state(node.get('last_test'), last_test=True)} " + f"{short_time(node.get('last_test_time'))} " + f"{str(node.get('last_test_model') or '-')}" + ) + return " | ".join(details) if details else "two-node -" + + def normalize_account_rows( payload: dict[str, Any], filter_text: str = "", @@ -1762,6 +1826,12 @@ def normalize_account_rows( "account_type": str(account.get("account_type") or ""), "plan": str(account.get("plan") or ""), "priority": account.get("priority"), + "active": workspace_account_state(account.get("active_state")), + "schedulable_state": workspace_account_state(account.get("schedulable_state")), + "last_test": workspace_account_state(account.get("last_test_state"), last_test=True), + "last_test_time": short_time(account.get("last_test_time")), + "last_test_model": str(account.get("last_test_model") or "-"), + "node_status": account.get("node_status") if isinstance(account.get("node_status"), dict) else {}, "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")), @@ -1941,7 +2011,7 @@ def print_once( print(f"sources: {pricing_error}") elif pricing_payload: print(pricing_summary(pricing_payload)) - print("name provider group source multiplier src cny src state daily today tokens req kind 5h 7d reset status availability") + print("name provider group source multiplier src cny src state daily today tokens req kind 5h 7d reset status active schedulable last-test last-test-time last-test-model availability") for row in normalize_account_rows(payload, filter_text, pricing_payload): print( f"{row['name'][:30]:<30} " @@ -1960,6 +2030,11 @@ def print_once( f"{row['weekly']:<9} " f"{row['reset']:<11} " f"{row['status']:<10} " + f"{row['active']:<9} " + f"{row['schedulable_state']:<9} " + f"{row['last_test']:<10} " + f"{row['last_test_time']:<13} " + f"{row['last_test_model'][:23]:<23} " f"{monitor_availability(row, status_payload or {})}" ) @@ -2096,9 +2171,15 @@ def run_textual( def on_mount(self) -> None: self.app.sub_title = "Dashboard" + account_columns = ( + "ACCOUNT", "Group", "Source", "Multiplier", "Src CNY", "Src state", "Today", "Daily", "5h", "7d" + ) + if not legacy_direct: + account_columns += ("Active", "Schedulable", "Last test", "Last test time", "Last test model") + account_columns += ("Avail",) self.configure_table( self.query_one("#accounts", DataTable), - ("ACCOUNT", "Group", "Source", "Multiplier", "Src CNY", "Src state", "Today", "Daily", "5h", "7d", "Avail"), + account_columns, ) self.configure_table( self.query_one("#keys", DataTable), @@ -2258,7 +2339,7 @@ def run_textual( for index, row in enumerate(self.account_rows): key = f"account-{row['id']}-{index}" self.account_by_key[key] = row - table.add_row( + cells: list[Any] = [ row["name"], row["routing_group"], row["pricing_source"], @@ -2269,9 +2350,19 @@ def run_textual( row["daily_quota_cell"], row["five_hour"], row["weekly"], - monitor_availability(row, self.status_payload), - key=key, - ) + ] + if not legacy_direct: + cells.extend( + ( + row["active"], + row["schedulable_state"], + row["last_test"], + row["last_test_time"], + row["last_test_model"], + ) + ) + cells.append(monitor_availability(row, self.status_payload)) + table.add_row(*cells, key=key) def render_keys(self) -> None: filter_text = self.query_one("#filter", Input).value @@ -2447,6 +2538,9 @@ def run_textual( 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"active {row['active']} | schedulable {row['schedulable_state']} | " + f"last test {row['last_test']} {row['last_test_time']} {row['last_test_model']} | " + f"{workspace_account_node_detail(row)} | " f"{account_pricing_detail(row)} | " f"{monitor_detail(row, self.status_payload)}" ) @@ -2523,7 +2617,13 @@ def run_textual( table = self.query_one("#accounts", DataTable) table.cursor_type = "row" table.zebra_stripes = True - table.add_columns("Name", "Provider", "Group", "Source", "Multiplier", "Src CNY", "Src state", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status", "Availability") + account_columns = [ + "Name", "Provider", "Group", "Source", "Multiplier", "Src CNY", "Src state", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status" + ] + if not legacy_direct: + account_columns.extend(("Active", "Schedulable", "Last test", "Last test time", "Last test model")) + account_columns.append("Availability") + table.add_columns(*account_columns) keys_table = self.query_one("#keys", DataTable) keys_table.cursor_type = "row" keys_table.zebra_stripes = True @@ -2616,7 +2716,7 @@ def run_textual( for row in self.rows: key = str(row["id"]) self.row_by_key[key] = row - table.add_row( + cells: list[Any] = [ row["name"], row["provider"], row["routing_group"], @@ -2633,9 +2733,19 @@ def run_textual( row["weekly"], row["reset"], row["status"], - monitor_availability(row, self.status_payload), - key=key, - ) + ] + if not legacy_direct: + cells.extend( + ( + row["active"], + row["schedulable_state"], + row["last_test"], + row["last_test_time"], + row["last_test_model"], + ) + ) + cells.append(monitor_availability(row, self.status_payload)) + table.add_row(*cells, key=key) self.query_one("#summary", Static).update(summary_line(self.payload)) if self.rows: self.render_detail(self.rows[0]) @@ -2660,6 +2770,9 @@ def run_textual( 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"active {row['active']} | schedulable {row['schedulable_state']} | " + f"last test {row['last_test']} {row['last_test_time']} {row['last_test_model']} | " + f"{workspace_account_node_detail(row)} | " f"{monitor_detail(row, self.status_payload)}" ) error = str(raw.get("error") or "").strip() diff --git a/tests/test_payload.py b/tests/test_payload.py index c380895..270664f 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -67,6 +67,27 @@ def workspace_payload_fixture() -> dict[str, object]: "provider": "openai", "kind": "quota_limited", "usable": True, + "active_state": "yes", + "schedulable_state": "partial", + "last_test_state": "partial", + "last_test_time": "2026-08-03T10:00:00Z", + "last_test_model": "gpt-5.6-luna", + "node_status": { + "server6": { + "active": True, + "schedulable": True, + "last_test": "yes", + "last_test_time": "2026-08-03T10:00:00Z", + "last_test_model": "gpt-5.6-luna", + }, + "server4": { + "active": True, + "schedulable": False, + "last_test": "no", + "last_test_time": "2026-08-03T10:02:00Z", + "last_test_model": "gpt-5.6-mini", + }, + }, "account_rate_multiplier": 0.06, "account_rate_multiplier_cny": 0.003, } @@ -730,6 +751,8 @@ def sample_logs_payload() -> dict: "api_key": {"id": 3, "name": "codex-main"}, "account_id": 7, "account": {"id": 7, "name": "oai-sub-1"}, + "account_rate_multiplier": 0.06, + "account_rate_multiplier_cny": 0.003, "user": {"id": 1, "name": "shujakuin"}, "model": "gpt-5.5", "upstream_model": "gpt-5.5-codex", @@ -807,10 +830,45 @@ class WorkspaceTests(unittest.TestCase): normalized_accounts = mod.normalize_account_rows(accounts, pricing_payload=pricing) normalized_logs = mod.normalize_log_rows(logs) self.assertEqual(normalized_accounts[0]["id"], 9007199254740993) - self.assertEqual(normalized_accounts[0]["account_multiplier"], 0.003) + self.assertEqual(normalized_accounts[0]["active"], "yes") + self.assertEqual(normalized_accounts[0]["schedulable_state"], "partial") + self.assertEqual(normalized_accounts[0]["last_test"], "partial") + self.assertEqual(normalized_accounts[0]["last_test_model"], "gpt-5.6-luna") + self.assertIn("server6 active yes", mod.workspace_account_node_detail(normalized_accounts[0])) + once_output = io.StringIO() + with contextlib.redirect_stdout(once_output): + mod.print_once(accounts, pricing_payload=pricing) + self.assertIn("last-test", once_output.getvalue()) + self.assertIn("gpt-5.6-luna", once_output.getvalue()) self.assertEqual(normalized_logs[0]["id"], 9007199254740997) self.assertEqual(normalized_logs[0]["account_multiplier"], 0.003) self.assertEqual(normalized_logs[0]["cost"], 0.25) + self.assertEqual(mod.account_rate_value({"account_rate_multiplier": 0.06}), 0.06) + self.assertEqual( + mod.account_rate_value( + { + "account_rate_multiplier": 0.06, + "account_rate_multiplier_cny": 0.003, + } + ), + 0.003, + ) + self.assertEqual( + mod.account_rate_value( + { + "account_rate_multiplier": 0.06, + "account_rate_multiplier_cny": "not-a-number", + } + ), + 0.06, + ) + self.assertEqual(mod.account_rate_value({"account_rate_multiplier_cny": 0}), 0) + self.assertIsNone( + mod.account_rate_value({"account_rate_multiplier": float("nan")}) + ) + self.assertEqual(mod.format_multiplier(0.003), "0.003x") + self.assertEqual(mod.format_multiplier(0), "0x") + self.assertEqual(mod.format_multiplier(float("inf")), "-") self.assertEqual(mod.as_int("9007199254740999"), 9007199254740999) self.assertEqual(len(keys["items"]), 1) self.assertEqual(mod.normalize_key_rows(keys)[0]["name"], "wmy") @@ -863,6 +921,12 @@ class WorkspaceTests(unittest.TestCase): with self.assertRaisesRegex(RuntimeError, "invalid workspace requests"): mod.fetch_workspace_payload("https://workspace.example.test/data", 3) + invalid_contract = workspace_payload_fixture() + invalid_contract["accounts"]["accounts"][0]["node_status"].pop("server4") + with mock.patch.object(mod, "fetch_workspace_response", return_value=(invalid_contract, "")): + with self.assertRaisesRegex(RuntimeError, "account contract"): + mod.fetch_workspace_payload("https://workspace.example.test/data", 3) + invalid_period = workspace_payload_fixture() invalid_period["traffic"].pop("ends_at") with mock.patch.object(mod, "fetch_workspace_response", return_value=(invalid_period, "")): @@ -1323,6 +1387,7 @@ class Sub2APILogsTests(unittest.TestCase): self.assertEqual(oldest["input_tokens"], 1200) self.assertEqual(oldest["output_tokens"], 340) self.assertEqual(oldest["cache_tokens"], 50 + 4100) + self.assertEqual(oldest["account_multiplier"], 0.003) self.assertEqual(oldest["first_token_ms"], 800) self.assertGreater(oldest["tokens_per_second"], 0) self.assertEqual(oldest["cost"], 0.0123) @@ -1399,6 +1464,11 @@ class Sub2APILogsTests(unittest.TestCase): self.assertIn("$0.012", text) self.assertIn("5.3s", text) self.assertIn("total 2.3K records", text) + lines = text.splitlines() + header = next(line for line in lines if line.startswith("node")) + multiplier_row = next(line for line in lines if "codex-main" in line) + self.assertEqual(header.index("key"), multiplier_row.index("codex-main")) + self.assertEqual(header.index("multiplier"), multiplier_row.index("0.003x")) def test_default_logs_token_reads_config_file(self) -> None: mod = load_module() diff --git a/uv.lock b/uv.lock index 25c5f9d..e5ed45c 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.3.5" +version = "0.3.6" source = { editable = "." } dependencies = [ { name = "textual" },