feat: show account window balances
This commit is contained in:
@@ -94,7 +94,7 @@ complete keys, accounts, and models. On a narrow terminal, a table scrolls
|
|||||||
horizontally instead of truncating a field:
|
horizontally instead of truncating a field:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ACCOUNT | Group | Today | Daily | 5h | 7d | Avail
|
ACCOUNT | Group | Today | Daily | 5h | 5h Bal | 7d | 7d Bal | Avail
|
||||||
KEY | Today | Tokens | Req
|
KEY | Today | Tokens | Req
|
||||||
LOG KEY | Account | Model | First | Duration | Tok/s | Input | Output | Cache | Tokens | Cost | Time | Age
|
LOG KEY | Account | Model | First | Duration | Tok/s | Input | Output | Cache | Tokens | Cost | Time | Age
|
||||||
ERR | Status | Key | Account | Model | Time | Age
|
ERR | Status | Key | Account | Model | Time | Age
|
||||||
@@ -241,7 +241,7 @@ panel after the accounts table when a token is configured.
|
|||||||
The dedicated Accounts page keeps the full columns for scanning inside zellij:
|
The dedicated Accounts page keeps the full columns for scanning inside zellij:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset | Status | Availability
|
Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 5h Bal | 7d | 7d Bal | Reset | Status | Availability
|
||||||
```
|
```
|
||||||
|
|
||||||
`Provider` distinguishes `openai` and `anthropic` accounts from the public
|
`Provider` distinguishes `openai` and `anthropic` accounts from the public
|
||||||
@@ -250,6 +250,11 @@ Name | Provider | Group | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset
|
|||||||
`Daily` is shown as `used/limit` when Sub2API has `quota_daily_*` fields in
|
`Daily` is shown as `used/limit` when Sub2API has `quota_daily_*` fields in
|
||||||
`accounts.extra`; otherwise it is `-`.
|
`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.
|
||||||
|
|
||||||
`Group` is the derived Sub2API tier alias group. Higher tiers win when multiple
|
`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
|
aliases exist: `id < slow < fast < sfast`. The table shows `sfast` first, then
|
||||||
`fast`, `slow`, `id`, and ungrouped accounts.
|
`fast`, `slow`, `id`, and ungrouped accounts.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "shusub2"
|
name = "shusub2"
|
||||||
version = "0.2.12"
|
version = "0.2.13"
|
||||||
description = "Terminal UI for Sub2API account quota and daily usage"
|
description = "Terminal UI for Sub2API account quota and daily usage"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+24
-5
@@ -20,7 +20,7 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
APP_NAME = "shusub2"
|
APP_NAME = "shusub2"
|
||||||
FALLBACK_VERSION = "0.2.12"
|
FALLBACK_VERSION = "0.2.13"
|
||||||
DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts"
|
DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts"
|
||||||
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
|
DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url"
|
||||||
DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-url"
|
DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-url"
|
||||||
@@ -1063,6 +1063,13 @@ 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'))}"
|
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:
|
def daily_quota_cell(account: dict[str, Any]) -> str:
|
||||||
limit = account.get("daily_quota_limit")
|
limit = account.get("daily_quota_limit")
|
||||||
used = account.get("daily_quota_used")
|
used = account.get("daily_quota_used")
|
||||||
@@ -1135,6 +1142,8 @@ def normalize_account_rows(payload: dict[str, Any], filter_text: str = "") -> li
|
|||||||
"quota_used_percent_max": account.get("quota_used_percent_max"),
|
"quota_used_percent_max": account.get("quota_used_percent_max"),
|
||||||
"five_hour": window_cell(account, "five-hour"),
|
"five_hour": window_cell(account, "five-hour"),
|
||||||
"weekly": window_cell(account, "weekly"),
|
"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),
|
"reset": reset_cell(account),
|
||||||
"latest_usage_at": short_time(account.get("latest_usage_at") or account.get("last_used_at")),
|
"latest_usage_at": short_time(account.get("latest_usage_at") or account.get("last_used_at")),
|
||||||
"error": str(account.get("error") or ""),
|
"error": str(account.get("error") or ""),
|
||||||
@@ -1172,7 +1181,7 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
|
|||||||
print(summary_line(payload))
|
print(summary_line(payload))
|
||||||
if status_payload or status_error:
|
if status_payload or status_error:
|
||||||
print(monitor_summary(status_payload or {}, status_error))
|
print(monitor_summary(status_payload or {}, status_error))
|
||||||
print("name provider group daily today tokens req kind 5h 7d reset status availability")
|
print("name provider group daily today tokens req kind 5h 5h bal 7d 7d bal reset status availability")
|
||||||
for row in normalize_account_rows(payload, filter_text):
|
for row in normalize_account_rows(payload, filter_text):
|
||||||
print(
|
print(
|
||||||
f"{row['name'][:30]:<30} "
|
f"{row['name'][:30]:<30} "
|
||||||
@@ -1184,7 +1193,9 @@ def print_once(payload: dict[str, Any], filter_text: str = "", status_payload: d
|
|||||||
f"{format_count(row['today_requests']):<5} "
|
f"{format_count(row['today_requests']):<5} "
|
||||||
f"{row['kind_label']:<9} "
|
f"{row['kind_label']:<9} "
|
||||||
f"{row['five_hour']:<9} "
|
f"{row['five_hour']:<9} "
|
||||||
|
f"{row['five_hour_balance']:<9} "
|
||||||
f"{row['weekly']:<9} "
|
f"{row['weekly']:<9} "
|
||||||
|
f"{row['weekly_balance']:<9} "
|
||||||
f"{row['reset']:<11} "
|
f"{row['reset']:<11} "
|
||||||
f"{row['status']:<10} "
|
f"{row['status']:<10} "
|
||||||
f"{monitor_availability(row, status_payload or {})}"
|
f"{monitor_availability(row, status_payload or {})}"
|
||||||
@@ -1274,7 +1285,7 @@ def run_textual(
|
|||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.configure_table(
|
self.configure_table(
|
||||||
self.query_one("#accounts", DataTable),
|
self.query_one("#accounts", DataTable),
|
||||||
("ACCOUNT", "Group", "Today", "Daily", "5h", "7d", "Avail"),
|
("ACCOUNT", "Group", "Today", "Daily", "5h", "5h Bal", "7d", "7d Bal", "Avail"),
|
||||||
)
|
)
|
||||||
self.configure_table(
|
self.configure_table(
|
||||||
self.query_one("#keys", DataTable),
|
self.query_one("#keys", DataTable),
|
||||||
@@ -1426,7 +1437,9 @@ def run_textual(
|
|||||||
format_cost(row["today_cost_usd"]),
|
format_cost(row["today_cost_usd"]),
|
||||||
row["daily_quota_cell"],
|
row["daily_quota_cell"],
|
||||||
row["five_hour"],
|
row["five_hour"],
|
||||||
|
row["five_hour_balance"],
|
||||||
row["weekly"],
|
row["weekly"],
|
||||||
|
row["weekly_balance"],
|
||||||
monitor_availability(row, self.status_payload),
|
monitor_availability(row, self.status_payload),
|
||||||
key=key,
|
key=key,
|
||||||
)
|
)
|
||||||
@@ -1578,7 +1591,10 @@ def run_textual(
|
|||||||
if table_id == "accounts":
|
if table_id == "accounts":
|
||||||
detail = (
|
detail = (
|
||||||
f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | "
|
f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | "
|
||||||
f"daily {row['daily_quota_cell']} | today {format_cost(row['today_cost_usd'])}, "
|
f"daily {row['daily_quota_cell']} | "
|
||||||
|
f"5h {row['five_hour_balance']} ({row['five_hour']}) | "
|
||||||
|
f"7d {row['weekly_balance']} ({row['weekly']}) | "
|
||||||
|
f"today {format_cost(row['today_cost_usd'])}, "
|
||||||
f"{format_count(row['today_tokens'])} tokens, {format_count(row['today_requests'])} req | "
|
f"{format_count(row['today_tokens'])} tokens, {format_count(row['today_requests'])} req | "
|
||||||
f"{monitor_detail(row, self.status_payload)}"
|
f"{monitor_detail(row, self.status_payload)}"
|
||||||
)
|
)
|
||||||
@@ -1647,7 +1663,7 @@ def run_textual(
|
|||||||
table = self.query_one("#accounts", DataTable)
|
table = self.query_one("#accounts", DataTable)
|
||||||
table.cursor_type = "row"
|
table.cursor_type = "row"
|
||||||
table.zebra_stripes = True
|
table.zebra_stripes = True
|
||||||
table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "7d", "Reset", "Status", "Availability")
|
table.add_columns("Name", "Provider", "Group", "Daily", "Today", "Tokens", "Req", "Kind", "5h", "5h Bal", "7d", "7d Bal", "Reset", "Status", "Availability")
|
||||||
keys_table = self.query_one("#keys", DataTable)
|
keys_table = self.query_one("#keys", DataTable)
|
||||||
keys_table.cursor_type = "row"
|
keys_table.cursor_type = "row"
|
||||||
keys_table.zebra_stripes = True
|
keys_table.zebra_stripes = True
|
||||||
@@ -1735,7 +1751,9 @@ def run_textual(
|
|||||||
format_count(row["today_requests"]),
|
format_count(row["today_requests"]),
|
||||||
row["kind_label"],
|
row["kind_label"],
|
||||||
row["five_hour"],
|
row["five_hour"],
|
||||||
|
row["five_hour_balance"],
|
||||||
row["weekly"],
|
row["weekly"],
|
||||||
|
row["weekly_balance"],
|
||||||
row["reset"],
|
row["reset"],
|
||||||
row["status"],
|
row["status"],
|
||||||
monitor_availability(row, self.status_payload),
|
monitor_availability(row, self.status_payload),
|
||||||
@@ -1759,6 +1777,7 @@ def run_textual(
|
|||||||
f"{row['name']} | {row['provider']} | group {row['routing_group']} | {row['kind_label']} | {row['account_type']} | "
|
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"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"{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"today {format_cost(row['today_cost_usd'])}, {format_count(row['today_tokens'])} tokens, "
|
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"{format_count(row['today_requests'])} req | latest {row['latest_usage_at']} | "
|
||||||
f"priority {row['priority'] if row['priority'] is not None else '-'} | "
|
f"priority {row['priority'] if row['priority'] is not None else '-'} | "
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
|||||||
"today_tokens": 2000,
|
"today_tokens": 2000,
|
||||||
"today_requests": 4,
|
"today_requests": 4,
|
||||||
"windows": [
|
"windows": [
|
||||||
{"id": "five-hour", "used_percent": 80, "remaining_percent": 20, "reset": "2026-06-09T15:00:00+08:00"},
|
{"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, "reset": "2026-06-10T15:00:00+08:00"},
|
{"id": "weekly", "used_percent": 10, "remaining_percent": 90, "remaining_balance_usd": 108, "reset": "2026-06-10T15:00:00+08:00"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,6 +72,8 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
|||||||
self.assertEqual(rows[0]["kind_label"], "usage")
|
self.assertEqual(rows[0]["kind_label"], "usage")
|
||||||
self.assertEqual(rows[1]["five_hour"], "80%/20%")
|
self.assertEqual(rows[1]["five_hour"], "80%/20%")
|
||||||
self.assertEqual(rows[1]["weekly"], "10%/90%")
|
self.assertEqual(rows[1]["weekly"], "10%/90%")
|
||||||
|
self.assertEqual(rows[1]["five_hour_balance"], "$4")
|
||||||
|
self.assertEqual(rows[1]["weekly_balance"], "$108")
|
||||||
self.assertIn("today $0.75", mod.summary_line(payload))
|
self.assertIn("today $0.75", mod.summary_line(payload))
|
||||||
|
|
||||||
def test_filter_matches_kind_and_name(self) -> None:
|
def test_filter_matches_kind_and_name(self) -> None:
|
||||||
@@ -250,6 +252,7 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(mod.monitor_availability(rows[0], status_payload), "ok")
|
self.assertEqual(mod.monitor_availability(rows[0], status_payload), "ok")
|
||||||
self.assertIn("availability", out.getvalue())
|
self.assertIn("availability", out.getvalue())
|
||||||
|
self.assertIn("5h bal", out.getvalue())
|
||||||
self.assertIn(" ok\n", out.getvalue())
|
self.assertIn(" ok\n", out.getvalue())
|
||||||
|
|
||||||
def test_monitor_availability_is_dash_without_bound_monitor(self) -> None:
|
def test_monitor_availability_is_dash_without_bound_monitor(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user