diff --git a/README.md b/README.md index 51686a0..d8c38b2 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,10 @@ refresh。组件失败时服务端保留对应 last-good 数据,并在 `compon Workspace 当前提供以下有界 projection: -- `accounts`:账号摘要、quota window、当日用量和 provider/group/status 字段。 +- `accounts`:账号摘要、quota window、当日用量和 provider/group/status 字段;如能按当前 server6 upstream key-group binding 关联,还包含 `account_rate_multiplier` 原始有效倍率和 `account_rate_multiplier_cny` 展示倍率。 - `status`:channel monitor 摘要。 - `sources`:Pricing Monitor 已有的脱敏 source 余额与健康状态。 -- `traffic.requests`:仅 server6 的最新有界请求样本。 +- `traffic.requests`:仅 server6 的最新有界请求样本;每行带对应 account 的当前 `account_rate_multiplier` / `account_rate_multiplier_cny`,无法稳定关联时为 `null`。 - `traffic.errors`:server6/server4 的有界逐条错误事件,保留节点、时间、key/account、结构化错误类别和状态链路,不做错误分组或 per-row count。 - `traffic.keys`:仅 server6 按 `Asia/Shanghai` 自然日完整聚合的 key 使用量;事件样本上限不会截断其 request/token/cost totals,payload 同时携带统计日期和时区。 @@ -120,8 +120,8 @@ Accounts 只在 canonical 名称 `{family}-quota-{source}` 或 provider、URL、display name 或模糊文本推断;source 为 error/stale 时保留名称和状态, 但不伪造 CNY 数值。 -Requests 展示 server6 的最新有界明细:key、account、model、token bucket、actual cost、first-token latency、 -duration 和 decode throughput。Errors 展示 server6/server4 的逐条 instance、事件时间、status path、key、 +Requests 展示 server6 的最新有界明细:key、account、当前 account multiplier、model、token bucket、actual cost、first-token latency、 +duration 和 decode throughput。该 multiplier 来自当前 account binding,不代表请求发生时的历史计费倍率。Errors 展示 server6/server4 的逐条 instance、事件时间、status path、key、 account、model、phase/type/owner,不显示聚合次数。Key usage 和对应 Traffic Account usage 是 server6 完整当天 rollup;Errors 与 Requests 仍是事件样本。所有 workspace Traffic 都使用服务器声明的 `Asia/Shanghai` 自然日,不依赖客户端本地日期或滚动 24 小时窗口;每个 Traffic projection 显式携带日期、时区和 `[00:00, 次日 00:00)` 边界。 diff --git a/pyproject.toml b/pyproject.toml index 797da32..5f07bac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.3.4" +version = "0.3.5" 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 85e15a8..b0fad05 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.4" +FALLBACK_VERSION = "0.3.5" 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" @@ -392,6 +392,18 @@ def format_cny(value: Any) -> str: return "-" if amount == "-" else f"¥{amount}" +def format_multiplier(value: Any) -> str: + amount = optional_number(value) + if amount is None: + return "-" + return f"{amount:.8g}x" + + +def account_rate_value(item: dict[str, Any]) -> float | None: + display = optional_number(item.get("account_rate_multiplier_cny")) + return display if display is not None else optional_number(item.get("account_rate_multiplier")) + + def format_count(value: Any) -> str: number = as_int(value) if abs(number) >= 1_000_000: @@ -1172,6 +1184,9 @@ def normalize_log_rows(payload: dict[str, Any], filter_text: str = "") -> list[d "node": node, "key": key_name, "account": account_name, + "account_rate_multiplier": optional_number(item.get("account_rate_multiplier")), + "account_rate_multiplier_cny": optional_number(item.get("account_rate_multiplier_cny")), + "account_multiplier": account_rate_value(item), "user": user_name, "model": model, "upstream_model": upstream_model, @@ -1225,7 +1240,7 @@ def log_detail_line(row: dict[str, Any]) -> str: if row["effort"] != "-": model = f"{model} ({row['effort']})" detail = ( - f"{row['time']} | {row['node']} | key {row['key']} | account {row['account']} | user {row['user']} | {model} | {row['type']} | " + f"{row['time']} | {row['node']} | key {row['key']} | account {row['account']} | multiplier {format_multiplier(row['account_multiplier'])} | user {row['user']} | {model} | {row['type']} | " f"tokens in {format_count(row['input_tokens'])} out {format_count(row['output_tokens'])} " f"cache_w {format_count(row['cache_creation_tokens'])} cache_r {format_count(row['cache_read_tokens'])} | " f"cost {format_cost(row['cost'])} (actual {format_cost(row['actual_cost'])}) | " @@ -1240,12 +1255,13 @@ 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 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} " f"{row['key'][:20]:<21} " f"{row['account'][:20]:<21} " + f"{format_multiplier(row['account_multiplier']):<11} " f"{row['model'][:24]:<25} " f"{row['effort']:<7} " f"{row['type']:<7} " @@ -1736,6 +1752,9 @@ def normalize_account_rows( "pricing_balance": pricing_source.get("balance") if pricing_source else None, "pricing_unit": str(pricing_source.get("unit") or "-") if pricing_source else "-", "pricing_updated": str(pricing_source.get("updated") or "-") if pricing_source else "-", + "account_rate_multiplier": optional_number(account.get("account_rate_multiplier")), + "account_rate_multiplier_cny": optional_number(account.get("account_rate_multiplier_cny")), + "account_multiplier": account_rate_value(account), "provider": provider_label(account), "kind": str(account.get("kind") or "unknown"), "kind_label": kind_label(account.get("kind")), @@ -1922,13 +1941,14 @@ def print_once( print(f"sources: {pricing_error}") elif pricing_payload: print(pricing_summary(pricing_payload)) - print("name provider group source 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 availability") for row in normalize_account_rows(payload, filter_text, pricing_payload): print( f"{row['name'][:30]:<30} " f"{row['provider']:<9} " f"{row['routing_group']:<6} " f"{row['pricing_source']:<16} " + f"{format_multiplier(row['account_multiplier']):<11} " f"{format_cny(row['pricing_cny']):<13} " f"{row['pricing_state']:<12} " f"{row['daily_quota_cell']:<10} " @@ -2078,7 +2098,7 @@ def run_textual( self.app.sub_title = "Dashboard" self.configure_table( self.query_one("#accounts", DataTable), - ("ACCOUNT", "Group", "Source", "Src CNY", "Src state", "Today", "Daily", "5h", "7d", "Avail"), + ("ACCOUNT", "Group", "Source", "Multiplier", "Src CNY", "Src state", "Today", "Daily", "5h", "7d", "Avail"), ) self.configure_table( self.query_one("#keys", DataTable), @@ -2090,6 +2110,7 @@ def run_textual( "Node", "REQUEST KEY", "Account", + "Multiplier", "Model", "First", "Duration", @@ -2241,6 +2262,7 @@ def run_textual( row["name"], row["routing_group"], row["pricing_source"], + format_multiplier(row["account_multiplier"]), format_cny(row["pricing_cny"]), row["pricing_state"], format_cost(row["today_cost_usd"]), @@ -2285,6 +2307,7 @@ def run_textual( row["node"], Text(row["key"], style=color) if color else row["key"], row["account"], + format_multiplier(row["account_multiplier"]), row["model"], format_seconds(row["first_token_ms"]), format_seconds(row["duration_ms"]), @@ -2418,6 +2441,7 @@ def run_textual( if table_id == "accounts": detail = ( f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | " + f"multiplier {format_multiplier(row['account_multiplier'])} | " f"daily {row['daily_quota_cell']} | " f"5h {row['five_hour']} | " f"7d {row['weekly']} | " @@ -2499,7 +2523,7 @@ def run_textual( table = self.query_one("#accounts", DataTable) table.cursor_type = "row" table.zebra_stripes = True - table.add_columns("Name", "Provider", "Group", "Source", "Src CNY", "Src state", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status", "Availability") + table.add_columns("Name", "Provider", "Group", "Source", "Multiplier", "Src CNY", "Src state", "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 @@ -2597,6 +2621,7 @@ def run_textual( row["provider"], row["routing_group"], row["pricing_source"], + format_multiplier(row["account_multiplier"]), format_cny(row["pricing_cny"]), row["pricing_state"], row["daily_quota_cell"], @@ -2627,6 +2652,7 @@ def run_textual( raw = row.get("raw") if isinstance(row.get("raw"), dict) else {} detail = ( f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | {row['account_type']} | " + f"multiplier {format_multiplier(row['account_multiplier'])} | " 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']} | 7d {row['weekly']} | " @@ -2791,6 +2817,7 @@ def run_textual( "Node", "Key", "Account", + "Multiplier", "Model", "Effort", "Type", @@ -2865,6 +2892,7 @@ def run_textual( row["node"], Text(row["key"], style=color) if color else row["key"], row["account"], + format_multiplier(row["account_multiplier"]), row["model"], row["effort"], row["type"], diff --git a/tests/test_payload.py b/tests/test_payload.py index ad78ac0..c380895 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -67,6 +67,8 @@ def workspace_payload_fixture() -> dict[str, object]: "provider": "openai", "kind": "quota_limited", "usable": True, + "account_rate_multiplier": 0.06, + "account_rate_multiplier_cny": 0.003, } ], }, @@ -105,6 +107,8 @@ def workspace_payload_fixture() -> dict[str, object]: "api_key_name": "wmy", "account_id": "9", "account_name": "oai-quota-code-plan", + "account_rate_multiplier": 0.06, + "account_rate_multiplier_cny": 0.003, "model": "gpt-5.5", "input_tokens": 100, "output_tokens": 50, @@ -803,7 +807,9 @@ 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_logs[0]["id"], 9007199254740997) + self.assertEqual(normalized_logs[0]["account_multiplier"], 0.003) self.assertEqual(normalized_logs[0]["cost"], 0.25) self.assertEqual(mod.as_int("9007199254740999"), 9007199254740999) self.assertEqual(len(keys["items"]), 1) diff --git a/uv.lock b/uv.lock index 39a04f3..25c5f9d 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.3.4" +version = "0.3.5" source = { editable = "." } dependencies = [ { name = "textual" },