sub2api-usage 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: sub2api-usage
3
+ Version: 0.1.0
4
+ Summary: Interactive TUI for querying sub2api backend usage stats (Claude / OpenAI / Gemini)
5
+ Project-URL: Repository, https://github.com/kadaliao/sub2api-usage
6
+ Project-URL: Issues, https://github.com/kadaliao/sub2api-usage/issues
7
+ Author-email: Kada Liao <kadaliao@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: claude,cli,gemini,openai,sub2api,tui,usage
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: textual>=0.60
24
+ Description-Content-Type: text/markdown
25
+
26
+ # sub2api-usage
27
+
28
+ [![CI](https://github.com/kadaliao/sub2api-usage/actions/workflows/ci.yml/badge.svg)](https://github.com/kadaliao/sub2api-usage/actions/workflows/ci.yml)
29
+ [![PyPI](https://img.shields.io/pypi/v/sub2api-usage.svg)](https://pypi.org/project/sub2api-usage/)
30
+ [![Python](https://img.shields.io/pypi/pyversions/sub2api-usage.svg)](https://pypi.org/project/sub2api-usage/)
31
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
32
+
33
+ [sub2api](https://github.com/Wei-Shaw/sub2api) 后台用量查询的命令行工具。终端里直接看请求数、Token、成本、明细,支持今天 / 7 天 / 30 天 / 全部切换。
34
+
35
+ ## 安装
36
+
37
+ ```bash
38
+ # 推荐:uv tool install (隔离环境)
39
+ uv tool install sub2api-usage
40
+
41
+ # 或者用 pipx
42
+ pipx install sub2api-usage
43
+
44
+ # 或者直接 pip
45
+ pip install sub2api-usage
46
+ ```
47
+
48
+ ## 用法
49
+
50
+ ```bash
51
+ # 首次运行会引导填写账号、密码、后台地址、时区,
52
+ # 配置保存到 ~/.config/sub2api-usage/config.json (chmod 600)
53
+ sub2api-usage
54
+
55
+ # 重新配置账号
56
+ sub2api-usage setup
57
+
58
+ # 非交互打印 (脚本 / 管道用)
59
+ sub2api-usage print
60
+ sub2api-usage print --period week --list --page-size 20
61
+ sub2api-usage print --period month --json
62
+ ```
63
+
64
+ ### 交互面板键位
65
+
66
+ | 键 | 功能 |
67
+ | --- | --- |
68
+ | `1` / `2` / `3` / `4` | 今天 / 7 天 / 30 天 / 全部 |
69
+ | `←` / `→` | 在标签间切换 |
70
+ | `n` / `p` | 下一页 / 上一页 |
71
+ | `r` | 刷新 |
72
+ | `q` | 退出 |
73
+
74
+ ### 单位
75
+
76
+ 数量统一用计算机领域的 K/M/G/T,耗时按 ms/s/min/h/d 自动选择最适合的尺度。
77
+
78
+ ## 开发
79
+
80
+ ```bash
81
+ git clone https://github.com/kadaliao/sub2api-usage.git
82
+ cd sub2api-usage
83
+ uv sync
84
+ uv run sub2api-usage --help
85
+ ```
86
+
87
+ ## 发布流程
88
+
89
+ CI 在每次 push 都会构建包;推 `v*.*.*` tag 时通过 Trusted Publishing 自动发布到 PyPI。
90
+
91
+ ```bash
92
+ git tag v0.1.0
93
+ git push --tags
94
+ ```
95
+
96
+ ## License
97
+
98
+ [MIT](LICENSE)
@@ -0,0 +1,6 @@
1
+ sub2api_usage.py,sha256=bAeeZoTXRxxB_CJjaZ_9jCroNC-bfBdnqLE3g07-IMA,18355
2
+ sub2api_usage-0.1.0.dist-info/METADATA,sha256=Bm_o1xisuAM-Ze5B7jeXL70paJm6dxJAlKtKgqer4G0,2891
3
+ sub2api_usage-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
4
+ sub2api_usage-0.1.0.dist-info/entry_points.txt,sha256=t9qtlVYfmwEjMiuQHSd-eqwBSVbHwFb5qCqcUAiHwZ4,53
5
+ sub2api_usage-0.1.0.dist-info/licenses/LICENSE,sha256=isncJi4e6720XvRH1SO1lWP2rwVGucgGartmf_12tXg,1066
6
+ sub2api_usage-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sub2api-usage = sub2api_usage:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kada Liao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sub2api_usage.py ADDED
@@ -0,0 +1,497 @@
1
+ # ruff: noqa: E501
2
+ """sub2api 用量查询工具
3
+
4
+ 首次运行会引导填写账号信息并保存到 ~/.config/sub2api-usage/config.json (chmod 600)。
5
+ 默认进入全屏交互式面板,可在今天 / 7 天 / 30 天 / 全部之间切换并翻页查看明细。
6
+
7
+ 用法:
8
+ sub2api-usage # 进入交互面板
9
+ sub2api-usage setup # 重新配置账号
10
+ sub2api-usage print # 非交互打印 (脚本/管道用)
11
+ sub2api-usage print --json
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import getpass
18
+ import json
19
+ import os
20
+ import stat
21
+ import sys
22
+ from datetime import date, timedelta
23
+ from pathlib import Path
24
+ from typing import Any, Optional
25
+
26
+ import httpx
27
+
28
+ DEFAULT_BASE_URL = "https://cc.aihezu.dev"
29
+ DEFAULT_TIMEZONE = "Asia/Shanghai"
30
+
31
+ CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "sub2api-usage"
32
+ CONFIG_FILE = CONFIG_DIR / "config.json"
33
+
34
+
35
+ # ===== Config =================================================================
36
+
37
+ def load_config() -> Optional[dict[str, str]]:
38
+ if not CONFIG_FILE.exists():
39
+ return None
40
+ try:
41
+ data = json.loads(CONFIG_FILE.read_text())
42
+ except (OSError, json.JSONDecodeError):
43
+ return None
44
+ if not isinstance(data, dict):
45
+ return None
46
+ return data
47
+
48
+
49
+ def save_config(cfg: dict[str, str]) -> None:
50
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
51
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
52
+ CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
53
+
54
+
55
+ # ===== API client =============================================================
56
+
57
+ class APIError(RuntimeError):
58
+ pass
59
+
60
+
61
+ class Client:
62
+ def __init__(self, base_url: str, email: str, password: str, timezone: str):
63
+ self.base_url = base_url.rstrip("/")
64
+ self.email = email
65
+ self.password = password
66
+ self.timezone = timezone
67
+ self._client = httpx.AsyncClient(timeout=30)
68
+ self._token: Optional[str] = None
69
+
70
+ async def aclose(self) -> None:
71
+ await self._client.aclose()
72
+
73
+ async def login(self) -> None:
74
+ try:
75
+ r = await self._client.post(
76
+ f"{self.base_url}/api/v1/auth/login",
77
+ json={"email": self.email, "password": self.password},
78
+ )
79
+ except httpx.HTTPError as e:
80
+ raise APIError(f"网络错误: {e}") from e
81
+ if r.status_code != 200:
82
+ raise APIError(f"登录失败 HTTP {r.status_code}: {r.text[:200]}")
83
+ payload = r.json()
84
+ if payload.get("code") != 0:
85
+ raise APIError(f"登录失败: {payload.get('message')}")
86
+ data = payload.get("data") or {}
87
+ if data.get("requires_2fa"):
88
+ raise APIError("该账号开启了二次验证 (TOTP),本工具暂不支持。")
89
+ token = data.get("access_token")
90
+ if not token:
91
+ raise APIError("登录响应未包含 access_token")
92
+ self._token = token
93
+
94
+ async def _get(self, path: str, params: dict[str, Any]) -> Any:
95
+ if not self._token:
96
+ await self.login()
97
+ headers = {"Authorization": f"Bearer {self._token}"}
98
+ try:
99
+ r = await self._client.get(f"{self.base_url}{path}", params=params, headers=headers)
100
+ if r.status_code == 401:
101
+ await self.login()
102
+ headers = {"Authorization": f"Bearer {self._token}"}
103
+ r = await self._client.get(f"{self.base_url}{path}", params=params, headers=headers)
104
+ except httpx.HTTPError as e:
105
+ raise APIError(f"网络错误: {e}") from e
106
+ if r.status_code != 200:
107
+ raise APIError(f"{path} HTTP {r.status_code}: {r.text[:200]}")
108
+ payload = r.json()
109
+ if payload.get("code") != 0:
110
+ raise APIError(f"{path}: {payload.get('message')}")
111
+ return payload.get("data") or {}
112
+
113
+ async def stats(self, start: str, end: str) -> dict[str, Any]:
114
+ return await self._get(
115
+ "/api/v1/usage/stats",
116
+ {"start_date": start, "end_date": end, "timezone": self.timezone},
117
+ )
118
+
119
+ async def list(self, start: str, end: str, page: int, page_size: int) -> dict[str, Any]:
120
+ return await self._get(
121
+ "/api/v1/usage",
122
+ {
123
+ "start_date": start,
124
+ "end_date": end,
125
+ "timezone": self.timezone,
126
+ "page": page,
127
+ "page_size": page_size,
128
+ "sort_by": "created_at",
129
+ "sort_order": "desc",
130
+ },
131
+ )
132
+
133
+
134
+ # ===== Humanize ===============================================================
135
+
136
+ def humanize_count(n: Any, decimals: int = 2) -> str:
137
+ try:
138
+ n = float(n)
139
+ except (TypeError, ValueError):
140
+ return str(n)
141
+ if n == 0:
142
+ return "0"
143
+ sign = "-" if n < 0 else ""
144
+ n = abs(n)
145
+ for unit, scale in (("T", 1e12), ("G", 1e9), ("M", 1e6), ("K", 1e3)):
146
+ if n >= scale:
147
+ return f"{sign}{n / scale:.{decimals}f}{unit}"
148
+ return f"{sign}{int(n)}" if n == int(n) else f"{sign}{n:.{decimals}f}"
149
+
150
+
151
+ def humanize_duration_ms(ms: Any) -> str:
152
+ try:
153
+ ms = float(ms)
154
+ except (TypeError, ValueError):
155
+ return str(ms)
156
+ if ms < 1000:
157
+ return f"{ms:.0f}ms"
158
+ s = ms / 1000
159
+ if s < 60:
160
+ return f"{s:.2f}s"
161
+ m = s / 60
162
+ if m < 60:
163
+ return f"{m:.2f}min"
164
+ h = m / 60
165
+ if h < 24:
166
+ return f"{h:.2f}h"
167
+ return f"{h / 24:.2f}d"
168
+
169
+
170
+ def humanize_money(v: Any) -> str:
171
+ try:
172
+ return f"${float(v):.4f}"
173
+ except (TypeError, ValueError):
174
+ return str(v)
175
+
176
+
177
+ # ===== Period helpers =========================================================
178
+
179
+ PERIODS = (
180
+ ("today", "今天"),
181
+ ("week", "7 天"),
182
+ ("month", "30 天"),
183
+ ("all", "全部"),
184
+ )
185
+
186
+
187
+ def period_range(period: str) -> tuple[str, str]:
188
+ today = date.today()
189
+ if period == "today":
190
+ return today.isoformat(), today.isoformat()
191
+ if period == "week":
192
+ return (today - timedelta(days=6)).isoformat(), today.isoformat()
193
+ if period == "month":
194
+ return (today - timedelta(days=29)).isoformat(), today.isoformat()
195
+ if period == "all":
196
+ return "2000-01-01", today.isoformat()
197
+ raise ValueError(f"unknown period: {period}")
198
+
199
+
200
+ # ===== Setup wizard ===========================================================
201
+
202
+ def _prompt(label: str, default: Optional[str] = None, secret: bool = False) -> str:
203
+ text = label + (f" [{default}]" if default else "") + ": "
204
+ while True:
205
+ val = (getpass.getpass(text) if secret else input(text)).strip()
206
+ if val:
207
+ return val
208
+ if default is not None:
209
+ return default
210
+ print(" 请输入非空值")
211
+
212
+
213
+ async def run_setup(existing: Optional[dict[str, str]] = None) -> dict[str, str]:
214
+ print()
215
+ if existing is None:
216
+ print("== sub2api-usage 首次配置 ==")
217
+ print("(密码以明文保存到 ~/.config/sub2api-usage/config.json,文件权限 600)")
218
+ else:
219
+ print("== sub2api-usage 修改配置 ==")
220
+ print(f"当前账号: {existing.get('email')} 地址: {existing.get('base_url')}")
221
+ print()
222
+
223
+ base_url = _prompt("后台地址", default=(existing or {}).get("base_url") or DEFAULT_BASE_URL)
224
+ email = _prompt("邮箱", default=(existing or {}).get("email"))
225
+ if existing and existing.get("password"):
226
+ pwd = getpass.getpass("密码 (回车保留原值): ").strip() or existing["password"]
227
+ else:
228
+ pwd = _prompt("密码", secret=True)
229
+ tz = _prompt("时区", default=(existing or {}).get("timezone") or DEFAULT_TIMEZONE)
230
+
231
+ cfg = {"base_url": base_url, "email": email, "password": pwd, "timezone": tz}
232
+
233
+ print("\n登录验证中...")
234
+ client = Client(base_url, email, pwd, tz)
235
+ try:
236
+ await client.login()
237
+ except APIError as e:
238
+ print(f"[失败] {e}", file=sys.stderr)
239
+ if input("是否重新输入?[Y/n] ").strip().lower() in ("", "y", "yes"):
240
+ await client.aclose()
241
+ return await run_setup(cfg)
242
+ await client.aclose()
243
+ raise SystemExit(1)
244
+ await client.aclose()
245
+ save_config(cfg)
246
+ print(f"[OK] 已保存到 {CONFIG_FILE}\n")
247
+ return cfg
248
+
249
+
250
+ # ===== Non-interactive print mode ============================================
251
+
252
+ async def cmd_print(cfg: dict[str, str], period: str, show_list: bool, page: int, page_size: int, as_json: bool) -> None:
253
+ start, end = period_range(period)
254
+ client = Client(cfg["base_url"], cfg["email"], cfg["password"], cfg["timezone"])
255
+ try:
256
+ stats = await client.stats(start, end)
257
+ list_data = await client.list(start, end, page, page_size) if show_list else None
258
+ finally:
259
+ await client.aclose()
260
+
261
+ if as_json:
262
+ out: dict[str, Any] = {"range": {"start": start, "end": end, "timezone": cfg["timezone"]}, "stats": stats}
263
+ if list_data is not None:
264
+ out["list"] = list_data
265
+ print(json.dumps(out, ensure_ascii=False, indent=2))
266
+ return
267
+
268
+ _print_stats(stats, start, end, cfg["timezone"])
269
+ if list_data is not None:
270
+ _print_list(list_data)
271
+
272
+
273
+ def _print_stats(stats: dict[str, Any], start: str, end: str, tz: str) -> None:
274
+ print(f"\n== 用量统计 [{start} ~ {end}] ({tz}) ==")
275
+ fields = [
276
+ ("total_requests", "请求数", humanize_count),
277
+ ("total_tokens", "Token", humanize_count),
278
+ ("total_input_tokens", " 输入", humanize_count),
279
+ ("total_output_tokens", " 输出", humanize_count),
280
+ ("total_cache_tokens", " Cache", humanize_count),
281
+ ("total_cost", "成本", humanize_money),
282
+ ("total_actual_cost", "实际成本", humanize_money),
283
+ ("average_duration_ms", "平均耗时", humanize_duration_ms),
284
+ ]
285
+ for key, label, fmt in fields:
286
+ if stats.get(key) is not None:
287
+ print(f" {label:<12} {fmt(stats[key])}")
288
+
289
+
290
+ def _print_list(data: dict[str, Any]) -> None:
291
+ items = data.get("items") or []
292
+ print(f"\n== 明细 (第 {data.get('page')}/{data.get('pages')} 页, 共 {data.get('total')} 条) ==")
293
+ header = f"{'time':<19} {'model':<18} {'key':<14} {'group':<12} {'in':>7} {'out':>7} {'cache_r':>9} {'cost':>10} {'dur':>8}"
294
+ print(f" {header}")
295
+ print(f" {'-' * len(header)}")
296
+ for row in items:
297
+ print(
298
+ f" {(row.get('created_at') or '')[:19].replace('T', ' '):<19} "
299
+ f"{(row.get('model') or '')[:18]:<18} "
300
+ f"{((row.get('api_key') or {}).get('name') or '')[:14]:<14} "
301
+ f"{((row.get('group') or {}).get('name') or '')[:12]:<12} "
302
+ f"{humanize_count(row.get('input_tokens') or 0):>7} "
303
+ f"{humanize_count(row.get('output_tokens') or 0):>7} "
304
+ f"{humanize_count(row.get('cache_read_tokens') or 0):>9} "
305
+ f"{humanize_money(row.get('total_cost') or 0):>10} "
306
+ f"{humanize_duration_ms(row.get('duration_ms') or 0):>8}"
307
+ )
308
+
309
+
310
+ # ===== Interactive TUI ========================================================
311
+
312
+ def run_tui(cfg: dict[str, str]) -> None:
313
+ from textual.app import App, ComposeResult
314
+ from textual.binding import Binding
315
+ from textual.widgets import DataTable, Footer, Header, Static, Tab, Tabs
316
+
317
+ class UsageApp(App):
318
+ CSS = """
319
+ Screen { background: $surface; }
320
+ #stats {
321
+ padding: 1 2;
322
+ margin: 0 1;
323
+ border: round $primary;
324
+ color: $text;
325
+ height: auto;
326
+ }
327
+ Tabs { margin: 0 1; }
328
+ DataTable { margin: 0 1; height: 1fr; }
329
+ #status {
330
+ dock: bottom;
331
+ height: 1;
332
+ background: $boost;
333
+ color: $text-muted;
334
+ padding: 0 2;
335
+ }
336
+ """
337
+ BINDINGS = [
338
+ Binding("q", "quit", "退出"),
339
+ Binding("r", "refresh", "刷新"),
340
+ Binding("n", "next_page", "下一页"),
341
+ Binding("p", "prev_page", "上一页"),
342
+ Binding("1", "set_period('today')", "今天"),
343
+ Binding("2", "set_period('week')", "7 天"),
344
+ Binding("3", "set_period('month')", "30 天"),
345
+ Binding("4", "set_period('all')", "全部"),
346
+ ]
347
+
348
+ period = "today"
349
+ page = 1
350
+ page_size = 50
351
+
352
+ def __init__(self, cfg: dict[str, str]):
353
+ super().__init__()
354
+ self.cfg = cfg
355
+ self.client = Client(cfg["base_url"], cfg["email"], cfg["password"], cfg["timezone"])
356
+
357
+ def compose(self) -> ComposeResult:
358
+ yield Header(show_clock=True)
359
+ yield Tabs(
360
+ Tab("今天 (1)", id="today"),
361
+ Tab("7 天 (2)", id="week"),
362
+ Tab("30 天 (3)", id="month"),
363
+ Tab("全部 (4)", id="all"),
364
+ )
365
+ yield Static("加载中...", id="stats")
366
+ table: DataTable = DataTable(id="table", zebra_stripes=True, cursor_type="row")
367
+ table.add_columns("时间", "模型", "Key", "Group", "输入", "输出", "Cache", "成本", "耗时")
368
+ yield table
369
+ yield Static(f"账号 {self.cfg['email']} · {self.cfg['base_url']}", id="status")
370
+ yield Footer()
371
+
372
+ async def on_mount(self) -> None:
373
+ self.title = "sub2api 用量"
374
+ self.sub_title = self.cfg["email"]
375
+ await self._refresh_data()
376
+
377
+ async def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: # noqa: F821
378
+ new = event.tab.id if event.tab else None
379
+ if new and new != self.period:
380
+ self.period = new
381
+ self.page = 1
382
+ await self._refresh_data()
383
+
384
+ async def action_set_period(self, p: str) -> None:
385
+ self.query_one(Tabs).active = p
386
+
387
+ async def action_refresh(self) -> None:
388
+ await self._refresh_data()
389
+
390
+ async def action_next_page(self) -> None:
391
+ self.page += 1
392
+ await self._refresh_data()
393
+
394
+ async def action_prev_page(self) -> None:
395
+ if self.page > 1:
396
+ self.page -= 1
397
+ await self._refresh_data()
398
+
399
+ async def _refresh_data(self) -> None:
400
+ start, end = period_range(self.period)
401
+ stats_widget = self.query_one("#stats", Static)
402
+ table = self.query_one(DataTable)
403
+ stats_widget.update("加载中...")
404
+ try:
405
+ stats = await self.client.stats(start, end)
406
+ list_data = await self.client.list(start, end, self.page, self.page_size)
407
+ except APIError as e:
408
+ stats_widget.update(f"[red]错误: {e}[/]")
409
+ return
410
+ stats_widget.update(self._render_stats(stats, start, end))
411
+ table.clear()
412
+ for row in list_data.get("items", []):
413
+ table.add_row(
414
+ (row.get("created_at") or "")[:19].replace("T", " "),
415
+ (row.get("model") or "")[:24],
416
+ ((row.get("api_key") or {}).get("name") or "")[:16],
417
+ ((row.get("group") or {}).get("name") or "")[:14],
418
+ humanize_count(row.get("input_tokens") or 0),
419
+ humanize_count(row.get("output_tokens") or 0),
420
+ humanize_count(row.get("cache_read_tokens") or 0),
421
+ humanize_money(row.get("total_cost") or 0),
422
+ humanize_duration_ms(row.get("duration_ms") or 0),
423
+ )
424
+ total = list_data.get("total", 0)
425
+ pages = list_data.get("pages", 1)
426
+ page = list_data.get("page", self.page)
427
+ self.query_one("#status", Static).update(
428
+ f"账号 {self.cfg['email']} · 范围 {start} ~ {end} · 第 {page}/{pages} 页 · 共 {total} 条 "
429
+ f"(n 下一页 · p 上一页 · r 刷新 · q 退出)"
430
+ )
431
+
432
+ @staticmethod
433
+ def _render_stats(stats: dict[str, Any], start: str, end: str) -> str:
434
+ req = humanize_count(stats.get("total_requests") or 0)
435
+ tok = humanize_count(stats.get("total_tokens") or 0)
436
+ tin = humanize_count(stats.get("total_input_tokens") or 0)
437
+ tout = humanize_count(stats.get("total_output_tokens") or 0)
438
+ tcache = humanize_count(stats.get("total_cache_tokens") or 0)
439
+ cost = humanize_money(stats.get("total_cost") or 0)
440
+ actual = humanize_money(stats.get("total_actual_cost") or 0)
441
+ dur = humanize_duration_ms(stats.get("average_duration_ms") or 0)
442
+ return (
443
+ f"[b]{start} ~ {end}[/]\n"
444
+ f"请求 [cyan]{req}[/] Token [cyan]{tok}[/] ([dim]in[/] {tin} · [dim]out[/] {tout} · [dim]cache[/] {tcache})\n"
445
+ f"成本 [yellow]{cost}[/] 实际 [yellow]{actual}[/] 平均耗时 [magenta]{dur}[/]"
446
+ )
447
+
448
+ async def on_unmount(self) -> None:
449
+ await self.client.aclose()
450
+
451
+ UsageApp(cfg).run()
452
+
453
+
454
+ # ===== CLI ====================================================================
455
+
456
+ def build_parser() -> argparse.ArgumentParser:
457
+ p = argparse.ArgumentParser(description="sub2api 用量查询")
458
+ sub = p.add_subparsers(dest="cmd")
459
+
460
+ sub.add_parser("setup", help="(重新) 配置账号信息")
461
+
462
+ pp = sub.add_parser("print", help="非交互打印 (脚本/管道用)")
463
+ pp.add_argument("--period", default="today", choices=[k for k, _ in PERIODS])
464
+ pp.add_argument("--list", action="store_true", help="同时拉取明细")
465
+ pp.add_argument("--page", type=int, default=1)
466
+ pp.add_argument("--page-size", type=int, default=20)
467
+ pp.add_argument("--json", action="store_true")
468
+ return p
469
+
470
+
471
+ def main() -> int:
472
+ args = build_parser().parse_args()
473
+ cfg = load_config()
474
+
475
+ if args.cmd == "setup":
476
+ asyncio.run(run_setup(cfg))
477
+ return 0
478
+
479
+ if cfg is None:
480
+ print("未检测到配置,进入引导...")
481
+ cfg = asyncio.run(run_setup(None))
482
+
483
+ if args.cmd == "print":
484
+ try:
485
+ asyncio.run(cmd_print(cfg, args.period, args.list, args.page, args.page_size, args.json))
486
+ except APIError as e:
487
+ print(f"[错误] {e}", file=sys.stderr)
488
+ return 1
489
+ return 0
490
+
491
+ # default: TUI
492
+ run_tui(cfg)
493
+ return 0
494
+
495
+
496
+ if __name__ == "__main__":
497
+ raise SystemExit(main())