feat: add cached pricing workspace
This commit is contained in:
@@ -8,190 +8,175 @@
|
|||||||
- 对应服务:无
|
- 对应服务:无
|
||||||
- 对应 stack:无
|
- 对应 stack:无
|
||||||
- Secret 边界:本地用户配置
|
- Secret 边界:本地用户配置
|
||||||
- 分发方式:`uvx` / `uv tool install`。
|
- 目标 API:Pricing Monitor `GET /api/ui-data?view=workspace`
|
||||||
|
- 分发方式:`uvx` / `uv tool install`
|
||||||
- 包名:`shusub2`
|
- 包名:`shusub2`
|
||||||
- 命令名:`shusub2`
|
- 命令名:`shusub2`
|
||||||
- 本地状态路径:`~/.config/shusub2/`
|
- 本地状态路径:`~/.config/shusub2/`
|
||||||
- 目标 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`。
|
- 默认数据源:Pricing Monitor `GET /api/ui-data?view=workspace`
|
||||||
- 备注:账号数据无 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,
|
`shusub2` 是 Sub2API 聚合运维 TUI。默认模式只读取 server2 Pricing Monitor
|
||||||
request logs, and merged cn/us error logs. Dedicated full-table views remain
|
后台生成的内存 snapshot,不直接访问 Accounts helper、`sub2api-status`、Sub2API
|
||||||
available for each data set.
|
admin API、PostgreSQL、SSH 或 pricing upstream。每次数据刷新只请求一次 workspace
|
||||||
|
projection;Dashboard、Accounts、Sources、Requests 和 Errors 共用同一份进程内缓存。后台 snapshot
|
||||||
|
读取之外,默认启动仍会进行一次短超时的 Gitea 版本检查;使用
|
||||||
|
`--no-version-check` 或 `SHUSUB2_NO_VERSION_CHECK=1` 可关闭它。
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Run once from a public Git repo with `uvx`:
|
已安装时直接运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvx --from git+https://gitea.shujk.top/shujakuin/shusub2.git shusub2 \
|
|
||||||
--api-url https://codex.server2.shujk.top/1232131231313123/api/tui/accounts
|
|
||||||
```
|
|
||||||
|
|
||||||
Bootstrap a new machine from `uvx`: save the API URL, install `shusub2` as a
|
|
||||||
user command, then run it later as `shusub2`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uvx --from git+https://gitea.shujk.top/shujakuin/shusub2.git shusub2 \
|
|
||||||
--api-url https://codex.server2.shujk.top/1232131231313123/api/tui/accounts \
|
|
||||||
--install
|
|
||||||
shusub2
|
shusub2
|
||||||
```
|
```
|
||||||
|
|
||||||
Install as a user command:
|
默认 workspace URL 是 Tailscale HTTPS 入口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://price.tailbeb9ad.ts.net/api/ui-data?view=workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖并保存 workspace URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shusub2 \
|
||||||
|
--workspace-url https://price.tailbeb9ad.ts.net/api/ui-data?view=workspace \
|
||||||
|
--save-config
|
||||||
|
```
|
||||||
|
|
||||||
|
从 Gitea 安装为用户命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install git+https://gitea.shujk.top/shujakuin/shusub2.git
|
uv tool install git+https://gitea.shujk.top/shujakuin/shusub2.git
|
||||||
shusub2 --api-url https://codex.server2.shujk.top/1232131231313123/api/tui/accounts --save-config
|
shusub2
|
||||||
```
|
```
|
||||||
|
|
||||||
Configure the default public API URL for `shusub2`:
|
也可以一次性运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx --from git+https://gitea.shujk.top/shujakuin/shusub2.git shusub2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Contract
|
||||||
|
|
||||||
|
Pricing Monitor 在后台按固定节奏采集并裁剪数据。普通 workspace HTTP 读取只复制
|
||||||
|
最近一次内存 snapshot,不触发 SSH、生产 SQL、旧 helper refresh 或 pricing source
|
||||||
|
refresh。组件失败时服务端保留对应 last-good 数据,并在 `components` / `state` 中标记
|
||||||
|
`stale`、`partial` 和更新时间。
|
||||||
|
|
||||||
|
Workspace 当前提供以下有界 projection:
|
||||||
|
|
||||||
|
- `accounts`:账号摘要、quota window、当日用量和 provider/group/status 字段。
|
||||||
|
- `status`:channel monitor 摘要。
|
||||||
|
- `sources`:Pricing Monitor 已有的脱敏 source 余额与健康状态。
|
||||||
|
- `traffic.requests`:最近的有界请求样本。
|
||||||
|
- `traffic.errors`:有界错误聚合,保留聚合次数和状态链路。
|
||||||
|
- `traffic.keys`:24 小时 key 使用聚合。
|
||||||
|
|
||||||
|
投影允许展示 account/key 名称、稳定 ID、model、instance 和运维状态,但不包含 API
|
||||||
|
key 原文、access/refresh token、cookie、密码、数据库凭据或请求/响应正文。ID 始终按
|
||||||
|
字符串处理,避免 JavaScript 或 Python 客户端误损失大整数精度。
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
|
||||||
|
默认首页是紧凑 Dashboard。页面信息架构为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Dashboard | Accounts | Sources | Requests | Errors
|
||||||
|
```
|
||||||
|
|
||||||
|
快捷键:
|
||||||
|
|
||||||
|
- `a`:聚焦 Accounts,或从独立页面打开 Accounts。
|
||||||
|
- `p`:打开 Sources。
|
||||||
|
- `k`:在 Dashboard 聚焦 Key usage。
|
||||||
|
- `l`:聚焦 Requests,或从独立页面打开 Requests。
|
||||||
|
- `e`:聚焦 Errors,或从独立页面打开 Errors。
|
||||||
|
- `d`:从独立页面返回 Dashboard。
|
||||||
|
- `/`:聚焦当前页面 filter;Dashboard filter 同时作用于全部表。
|
||||||
|
- `r`:重新读取 workspace snapshot。该操作不会要求服务端立即采集上游。
|
||||||
|
|
||||||
|
窄终端中的长 key、account 和 model 使用横向滚动,不截短原字段。独立页面可通过
|
||||||
|
以下参数启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shusub2 --accounts
|
||||||
|
shusub2 --sources # --pricing 保留为兼容别名
|
||||||
|
shusub2 --requests # --logs 保留为兼容别名
|
||||||
|
shusub2 --errors
|
||||||
|
```
|
||||||
|
|
||||||
|
加上 `--once` 可输出对应 snapshot 并退出:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shusub2 --once --requests
|
||||||
|
```
|
||||||
|
|
||||||
|
Accounts 只在 canonical 名称 `{family}-quota-{source}` 或
|
||||||
|
`{family}-quotaonly-{source}` 与唯一健康 source 精确匹配时显示 CNY 余额。不会根据
|
||||||
|
provider、URL、display name 或模糊文本推断;source 为 error/stale 时保留名称和状态,
|
||||||
|
但不伪造 CNY 数值。
|
||||||
|
|
||||||
|
Requests 展示 key、account、model、token bucket、actual cost、first-token latency、
|
||||||
|
duration 和 decode throughput。Errors 展示 instance、聚合次数、status path、key、
|
||||||
|
account、model、phase/type/owner 和时间。Key usage 使用 workspace 的 24 小时窗口,
|
||||||
|
不再宣称是客户端本地“今日”查询。
|
||||||
|
|
||||||
|
## Legacy Direct
|
||||||
|
|
||||||
|
`--legacy-direct` 仅用于迁移对账和故障诊断。它恢复旧拓扑:
|
||||||
|
|
||||||
|
- Accounts helper `/api/tui/accounts`
|
||||||
|
- `sub2api-status` `/api/status`
|
||||||
|
- Pricing Monitor `view=accounts`
|
||||||
|
- Sub2API admin usage/key/error API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shusub2 --legacy-direct --accounts
|
||||||
|
shusub2 --legacy-direct --requests
|
||||||
|
```
|
||||||
|
|
||||||
|
显式传入空 `--workspace-url ''` 也会进入 legacy direct 模式。legacy Requests、Errors
|
||||||
|
和 Key usage 需要 Sub2API admin API key;默认 workspace 模式不会读取该凭据文件。
|
||||||
|
|
||||||
|
legacy token 仅允许放在 `SHUSUB2_LOGS_TOKEN` 或权限为 `0600` 的
|
||||||
|
`~/.config/shusub2/logs-token`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.config/shusub2
|
mkdir -p ~/.config/shusub2
|
||||||
chmod 700 ~/.config/shusub2
|
chmod 700 ~/.config/shusub2
|
||||||
printf '%s\n' 'https://codex.server2.shujk.top/1232131231313123/api/tui/accounts' > ~/.config/shusub2/api-url
|
|
||||||
chmod 600 ~/.config/shusub2/api-url
|
|
||||||
shusub2
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment variables override the config file:
|
|
||||||
|
|
||||||
- `SHUSUB2_API_URL`
|
|
||||||
- `SUB2API_QUOTA_TUI_API_URL`
|
|
||||||
- `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`
|
|
||||||
- `SHUSUB2_LOGS_TOKEN_FILE`
|
|
||||||
- `SHUSUB2_LOGS_REFRESH_SECONDS`
|
|
||||||
- `SHUSUB2_LOGS_LIMIT`
|
|
||||||
- `SHUSUB2_ERRORS_CN_URL`
|
|
||||||
- `SHUSUB2_ERRORS_CN_URL_FILE`
|
|
||||||
- `SHUSUB2_ERRORS_US_URL`
|
|
||||||
- `SHUSUB2_ERRORS_US_URL_FILE`
|
|
||||||
- `SHUSUB2_ERRORS_REFRESH_SECONDS`
|
|
||||||
- `SHUSUB2_ERRORS_LIMIT`
|
|
||||||
- `SHUSUB2_ERRORS_TIME_RANGE`
|
|
||||||
- `SHUSUB2_VERSION_CHECK_URL`
|
|
||||||
- `SHUSUB2_VERSION_CHECK_TIMEOUT`
|
|
||||||
- `SHUSUB2_NO_VERSION_CHECK`
|
|
||||||
|
|
||||||
## Unified Dashboard
|
|
||||||
|
|
||||||
`shusub2` opens a compact dashboard for Accounts, Keys, Logs, and Errors. Its
|
|
||||||
summary also reports Pricing Monitor upstream health and CNY total. The
|
|
||||||
Accounts table attaches a source balance only when an account has a canonical
|
|
||||||
quota-source name and the sanitized Pricing Monitor projection has one
|
|
||||||
unambiguous matching source. 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.
|
|
||||||
|
|
||||||
The dashboard sizes every column from the fetched content so wide terminals show
|
|
||||||
complete keys, accounts, and models. On a narrow terminal, a table scrolls
|
|
||||||
horizontally instead of truncating a field:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ACCOUNT | Group | Source | Src CNY | Src state | 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
|
|
||||||
```
|
|
||||||
|
|
||||||
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`, `--pricing`, `--logs`, or `--errors` to start a dedicated
|
|
||||||
full-table view. The corresponding `--once` form still prints only that data set.
|
|
||||||
|
|
||||||
## Request Logs Page
|
|
||||||
|
|
||||||
The middle dashboard table shows request logs, and `l` focuses it.
|
|
||||||
`shusub2 --logs` starts the dedicated logs page, while
|
|
||||||
`shusub2 --once --logs` prints one logs snapshot to stdout.
|
|
||||||
|
|
||||||
The logs page reads the latest requests (default 100, `--logs-limit`) from the
|
|
||||||
Sub2API admin usage API and refreshes every five minutes by default
|
|
||||||
(`--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`). Columns:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Key | Account | Model | Effort | Type | Input | Output | Cache | Tokens | Cost | First | Duration | Tok/s | Time | Age
|
|
||||||
```
|
|
||||||
|
|
||||||
`Type` is the Sub2API `request_type` (`sync` / `stream` / `ws_v2` / `cyber`).
|
|
||||||
`Tokens` is input + output + cache write + cache read; the detail line below
|
|
||||||
the table shows the per-bucket breakdown, actual cost, first-token latency,
|
|
||||||
decode speed, upstream model mapping, user, and request id.
|
|
||||||
|
|
||||||
`Effort` is the request's `reasoning_effort` (`-` when absent). `First` is the
|
|
||||||
first-token latency and `Duration` the total request time, both shown in
|
|
||||||
seconds. `Tok/s` is the decode throughput computed as
|
|
||||||
`output_tokens / (duration - first_token)`; it shows `-` when there is no
|
|
||||||
output or no positive decode window. `Input`, `Output`, and `Cache` are token
|
|
||||||
buckets; `Cache` combines cache write and cache read. `Tokens` is their total.
|
|
||||||
`Age` is relative to local current time (`now`, `5m ago`, `2h ago`, etc.). In the TUI each
|
|
||||||
API key name is rendered in a stable per-key color so rows from the same
|
|
||||||
key are easy to group visually (`--once --logs` output stays plain text).
|
|
||||||
|
|
||||||
The logs URL defaults to `https://sub2apicn.shujk.top/api/v1/admin/usage` and
|
|
||||||
can be overridden with `--logs-url` / `SHUSUB2_LOGS_URL` /
|
|
||||||
`~/.config/shusub2/logs-url`.
|
|
||||||
|
|
||||||
The page needs a Sub2API **admin API key** (generated in the Sub2API web UI
|
|
||||||
under Settings). Configure it once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/.config/shusub2 && chmod 700 ~/.config/shusub2
|
|
||||||
printf '%s\n' '<sub2api-admin-api-key>' > ~/.config/shusub2/logs-token
|
printf '%s\n' '<sub2api-admin-api-key>' > ~/.config/shusub2/logs-token
|
||||||
chmod 600 ~/.config/shusub2/logs-token
|
chmod 600 ~/.config/shusub2/logs-token
|
||||||
```
|
```
|
||||||
|
|
||||||
or via `SHUSUB2_LOGS_TOKEN`. The key is sent as the `x-api-key` header and is
|
## Configuration
|
||||||
never written anywhere else by the client. Without a token the dashboard Logs
|
|
||||||
and Errors tables stay empty and show a configuration hint; the Accounts table
|
|
||||||
keeps working without any secret.
|
|
||||||
|
|
||||||
## Errors Page
|
Workspace-first 配置:
|
||||||
|
|
||||||
The bottom dashboard table shows merged errors, and `e` focuses it.
|
- `--workspace-url` / `SHUSUB2_WORKSPACE_URL` /
|
||||||
`shusub2 --errors` starts the dedicated errors page, while
|
`SHUSUB2_WORKSPACE_URL_FILE`
|
||||||
`shusub2 --once --errors` prints one merged snapshot to stdout.
|
- `--refresh-seconds` / `SUB2API_QUOTA_TUI_REFRESH_SECONDS`
|
||||||
|
- `--timeout` / `SUB2API_QUOTA_TUI_TIMEOUT`
|
||||||
|
- `--accounts` / `--sources` / `--requests` / `--errors`
|
||||||
|
- `--once`
|
||||||
|
- `--save-config`
|
||||||
|
- `--install`
|
||||||
|
- `--version-check-url` / `SHUSUB2_VERSION_CHECK_URL`
|
||||||
|
- `--no-version-check` / `SHUSUB2_NO_VERSION_CHECK`
|
||||||
|
|
||||||
The page pulls the latest admin ops error logs from **both** cn and us in
|
Legacy-only 配置:
|
||||||
parallel, then merges them by `created_at`:
|
|
||||||
|
|
||||||
- cn default: `https://sub2apicn.shujk.top/api/v1/admin/ops/errors`
|
- `--legacy-direct`
|
||||||
- us default: `https://us.sub2.shujk.top/api/v1/admin/ops/errors`
|
- `--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`
|
||||||
|
- `--errors-cn-url` / `SHUSUB2_ERRORS_CN_URL`
|
||||||
|
- `--errors-us-url` / `SHUSUB2_ERRORS_US_URL`
|
||||||
|
- `--logs-limit`、`--errors-limit`、`--errors-time-range`
|
||||||
|
|
||||||
`us.sub2.shujk.top` is the DNS-only standard HTTPS direct entry for the same
|
URL config 文件默认位于 `~/.config/shusub2/`。环境变量优先于 config 文件。
|
||||||
server4 backend. It avoids the Cloudflare 1010 behavior sometimes returned by
|
|
||||||
`sub2apius.shujk.top` to non-browser clients while avoiding the fixed `:19857`
|
|
||||||
port in the default client configuration. Use `--errors-us-url` to select a
|
|
||||||
different direct origin when required.
|
|
||||||
|
|
||||||
The dashboard and dedicated errors page refresh every five minutes by default
|
|
||||||
(`--errors-refresh-seconds` / `SHUSUB2_ERRORS_REFRESH_SECONDS`). Query window
|
|
||||||
defaults to `24h` (`--errors-time-range` / `SHUSUB2_ERRORS_TIME_RANGE`); each source uses
|
|
||||||
`page=1` and `page_size` from `--errors-limit` (default 100, max 500).
|
|
||||||
|
|
||||||
Columns:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Node | Status | Key | Account | Model | Phase | Type | Owner | Time | Age
|
|
||||||
```
|
|
||||||
|
|
||||||
`Age` is relative to local current time (`now`, `5m ago`, `2h ago`, etc.).
|
|
||||||
Auth reuses the same admin API key as the logs page.
|
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
@@ -200,96 +185,33 @@ cd apps/sub2api-quota-tui
|
|||||||
uv run shusub2
|
uv run shusub2
|
||||||
```
|
```
|
||||||
|
|
||||||
Run inside zellij:
|
运行完整测试和静态检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
zellij action new-pane --name sub2api-quota -- \
|
uv run python -m unittest discover -s tests -p 'test_*.py' -v
|
||||||
bash -lc 'cd /home/shujakuin/infra/apps/sub2api-quota-tui && uv run shusub2'
|
uv run python -m py_compile sub2api_quota_tui.py
|
||||||
|
uv lock --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Configuration:
|
## Maintenance Notes
|
||||||
|
|
||||||
- `--api-url` / `SUB2API_QUOTA_TUI_API_URL`
|
Workspace adapter 只负责把 server projection 映射为现有渲染模型,不得在普通刷新中
|
||||||
- `--status-url` / `SHUSUB2_STATUS_URL`
|
重新引入 legacy HTTP 请求。新增 projection 字段时应同时验证:字段 allowlist、字符串 ID、
|
||||||
- `--pricing-url` / `SHUSUB2_PRICING_URL`
|
finite number、payload 上限、单请求 cache、错误聚合次数和 legacy fallback。
|
||||||
- `--logs-url` / `SHUSUB2_LOGS_URL`
|
|
||||||
- `--logs-token` / `SHUSUB2_LOGS_TOKEN`
|
|
||||||
- `--logs-refresh-seconds` / `SHUSUB2_LOGS_REFRESH_SECONDS`
|
|
||||||
- `--logs-limit` / `SHUSUB2_LOGS_LIMIT`
|
|
||||||
- `--errors-refresh-seconds` / `SHUSUB2_ERRORS_REFRESH_SECONDS`
|
|
||||||
- `--errors-limit` / `SHUSUB2_ERRORS_LIMIT`
|
|
||||||
- `--errors-time-range` / `SHUSUB2_ERRORS_TIME_RANGE`
|
|
||||||
- `--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`, can optionally read `sub2api-status`
|
`cliproxy-codex-quota` 与 `sub2api-status` 当前仍可作为 Aggregation Hub 的过渡采集输入和
|
||||||
`/api/status` for channel monitor health, and reads Pricing Monitor's
|
双读对照,但不再是默认 TUI 客户端直连依赖。移除这些过渡输入前,必须先完成 server2
|
||||||
`/api/ui-data?view=accounts` for upstream source balances. The default pricing URL
|
聚合字段对账和 AstrBot `view=alerts` 连续性验证。
|
||||||
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
|
|
||||||
`SHUSUB2_LOGS_TOKEN`.
|
|
||||||
|
|
||||||
When `--status-url` is omitted, `shusub2` infers a sibling `/api/status` URL
|
## Tests And Release
|
||||||
from account URLs ending in `/api/tui/accounts`, so the public Codex endpoint
|
|
||||||
automatically enables monitor availability. On startup it also checks the
|
|
||||||
public Gitea repo for a newer package version and prints a short upgrade hint
|
|
||||||
when one is available.
|
|
||||||
|
|
||||||
On the dedicated `--accounts` page, a per-key panel shows today's usage for
|
发布遵循本仓库 `client-tui-gitea-uv` 流程:完成 scoped diff 审阅和测试后,将
|
||||||
each API key (`Key | Today | Tokens | Req`, sorted by cost, key names in the
|
`apps/sub2api-quota-tui/` 同步到独立 Gitea 仓库,再通过 git source 执行
|
||||||
same per-key colors as the logs page). It combines the Sub2API admin
|
`uv tool install --force`。安装后检查 `uv-receipt.toml`,确保来源仍是 Gitea git URL,
|
||||||
`dashboard/api-keys-trend` and `dashboard/api-keys-usage` endpoints and
|
而不是本地目录。
|
||||||
needs the same admin API key as the logs page; without a token the panel
|
|
||||||
stays empty and the status line shows a hint. `--once` prints the same
|
|
||||||
panel after the accounts table when a token is configured.
|
|
||||||
|
|
||||||
The dedicated Accounts page keeps the full columns for scanning inside zellij:
|
## Secret Boundary
|
||||||
|
|
||||||
```text
|
默认 workspace 模式不需要任何客户端 secret,也不读取 legacy token 文件。服务端
|
||||||
Name | Provider | Group | Source | Src CNY | Src state | Daily | Today | Tokens | Req | Kind | 5h | 7d | Reset | Status | Availability
|
workspace 不聚合原始凭据或请求/响应正文。legacy direct 模式的 admin API key 仅保存在
|
||||||
```
|
本机用户配置或进程环境中,不得写入仓库、文档、日志或测试 fixture。
|
||||||
|
|
||||||
`Provider` distinguishes `openai` and `anthropic` accounts from the public
|
|
||||||
`platform` field returned by the API.
|
|
||||||
|
|
||||||
`Daily` is shown as `used/limit` when Sub2API has `quota_daily_*` fields in
|
|
||||||
`accounts.extra`; otherwise it is `-`.
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
`Source`, `Src CNY`, and `Src state` use Pricing Monitor's cached, sanitized
|
|
||||||
`view=accounts` projection. A balance is attached only for exact canonical
|
|
||||||
account names of the form `{family}-quota-{source}` or
|
|
||||||
`{family}-quotaonly-{source}`, where `{source}` is one or more lowercase
|
|
||||||
alphanumeric segments joined by single hyphens, and a single normalized source
|
|
||||||
name. Provider, URL, display-name, and fuzzy matching are deliberately not used. Accounts
|
|
||||||
without a unique mapping show `-`; an `error` or `stale` source retains its
|
|
||||||
source/state label but does not show a CNY amount.
|
|
||||||
|
|
||||||
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
|
|
||||||
`fast`, `slow`, `id`, and ungrouped accounts.
|
|
||||||
|
|
||||||
When `--status-url` is configured, the status line shows channel monitor
|
|
||||||
health, and the selected account detail shows the matching monitor status when
|
|
||||||
one exists. Monitor binding first uses the shared `base_url_hash` emitted by
|
|
||||||
the account API and `sub2api-status`; name-token matching remains only as a
|
|
||||||
fallback for older status payloads.
|
|
||||||
|
|
||||||
`Availability` is derived from the bound channel monitor. Accounts without a
|
|
||||||
matching monitor show `-`.
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "shusub2"
|
name = "shusub2"
|
||||||
version = "0.2.15"
|
version = "0.3.0"
|
||||||
description = "Terminal UI for Sub2API account quota and daily usage"
|
description = "Aggregated operations TUI for Sub2API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
+574
-126
File diff suppressed because it is too large
Load Diff
+420
-10
@@ -1,13 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import copy
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
def load_module():
|
def load_module():
|
||||||
@@ -20,6 +24,117 @@ def load_module():
|
|||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_payload_fixture() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"service": "sub2api-pricing-monitor",
|
||||||
|
"view": "workspace",
|
||||||
|
"generated_at": "2026-08-03T12:00:00Z",
|
||||||
|
"state": {"ok": True, "partial": False},
|
||||||
|
"components": {
|
||||||
|
"accounts": {"ok": True, "stale": False},
|
||||||
|
"status": {"ok": True, "stale": False},
|
||||||
|
"traffic": {"ok": True, "stale": False},
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "code-plan",
|
||||||
|
"source_kind": "newapi",
|
||||||
|
"health_state": "healthy",
|
||||||
|
"balance_available": True,
|
||||||
|
"balance": {"available": 5000000, "available_cny": 10, "unit": "quota"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"accounts": {
|
||||||
|
"source_name": "workspace",
|
||||||
|
"totals": {
|
||||||
|
"total_accounts": 1,
|
||||||
|
"usable_accounts": 1,
|
||||||
|
"today_cost_usd": 1.25,
|
||||||
|
"today_tokens": 300,
|
||||||
|
"today_requests": 2,
|
||||||
|
},
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"id": "9007199254740993",
|
||||||
|
"name": "oai-quota-code-plan",
|
||||||
|
"routing_group": "fast",
|
||||||
|
"provider": "openai",
|
||||||
|
"kind": "quota_limited",
|
||||||
|
"usable": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"channel_monitors": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "9007199254740995",
|
||||||
|
"name": "oai-quota-code-plan",
|
||||||
|
"provider": "openai",
|
||||||
|
"latest_status": "success",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"traffic": {
|
||||||
|
"window_hours": 24,
|
||||||
|
"sample_limit": 1000,
|
||||||
|
"sample_limited": False,
|
||||||
|
"partial": False,
|
||||||
|
"instances": [
|
||||||
|
{"instance": "server6", "ok": True, "error_total": 4},
|
||||||
|
{"instance": "server4", "ok": True, "error_total": 0},
|
||||||
|
],
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"instance": "server6",
|
||||||
|
"id": "9007199254740997",
|
||||||
|
"created_at": "2026-08-03T11:59:00Z",
|
||||||
|
"api_key_id": "7",
|
||||||
|
"api_key_name": "wmy",
|
||||||
|
"account_id": "9",
|
||||||
|
"account_name": "oai-quota-code-plan",
|
||||||
|
"model": "gpt-5.5",
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 50,
|
||||||
|
"cache_read_tokens": 25,
|
||||||
|
"actual_cost": 0.25,
|
||||||
|
"duration_ms": 1000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"instance": "server6",
|
||||||
|
"latest_at": "2026-08-03T11:58:00Z",
|
||||||
|
"error_count": 4,
|
||||||
|
"status_code": 502,
|
||||||
|
"inbound_status_code": 500,
|
||||||
|
"upstream_status_code": 502,
|
||||||
|
"api_key_id": "7",
|
||||||
|
"api_key_name": "wmy",
|
||||||
|
"account_id": "9",
|
||||||
|
"account_name": "oai-quota-code-plan",
|
||||||
|
"model": "gpt-5.5",
|
||||||
|
"error_type": "api_error",
|
||||||
|
"error_source": "upstream_http",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"instance": "server6",
|
||||||
|
"api_key_id": "7",
|
||||||
|
"api_key_name": "wmy",
|
||||||
|
"status": "active",
|
||||||
|
"request_count": 2,
|
||||||
|
"token_count": 175,
|
||||||
|
"actual_cost": 0.25,
|
||||||
|
"latest_at": "2026-08-03T11:59:00Z",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Sub2APIQuotaTUITests(unittest.TestCase):
|
class Sub2APIQuotaTUITests(unittest.TestCase):
|
||||||
def test_normalize_rows_sorts_by_usage_and_formats_windows(self) -> None:
|
def test_normalize_rows_sorts_by_usage_and_formats_windows(self) -> None:
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
@@ -190,7 +305,7 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
|||||||
self.assertIsNone(unavailable_rows[0]["balance"])
|
self.assertIsNone(unavailable_rows[0]["balance"])
|
||||||
self.assertIsNone(unavailable_rows[0]["balance_cny"])
|
self.assertIsNone(unavailable_rows[0]["balance_cny"])
|
||||||
self.assertEqual([row["name"] for row in mod.normalize_pricing_rows(payload, "keday")], ["kedaya"])
|
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")
|
self.assertEqual(mod.pricing_summary(payload, rows), "sources 1/2 healthy | CNY ¥10")
|
||||||
out = io.StringIO()
|
out = io.StringIO()
|
||||||
with contextlib.redirect_stdout(out):
|
with contextlib.redirect_stdout(out):
|
||||||
mod.print_pricing_once(payload)
|
mod.print_pricing_once(payload)
|
||||||
@@ -314,8 +429,8 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
|||||||
def __exit__(self, exc_type, exc, traceback):
|
def __exit__(self, exc_type, exc, traceback):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def read(self):
|
def read(self, size=-1):
|
||||||
return compressed
|
return compressed if size < 0 else compressed[:size]
|
||||||
|
|
||||||
old_urlopen = mod.urllib.request.urlopen
|
old_urlopen = mod.urllib.request.urlopen
|
||||||
mod.urllib.request.urlopen = lambda request, timeout: (requests.append(request) or FakeResponse())
|
mod.urllib.request.urlopen = lambda request, timeout: (requests.append(request) or FakeResponse())
|
||||||
@@ -330,13 +445,35 @@ class Sub2APIQuotaTUITests(unittest.TestCase):
|
|||||||
class PlainResponse:
|
class PlainResponse:
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
def read(self):
|
def read(self, size=-1):
|
||||||
return mod.json.dumps(payload).encode("utf-8")
|
raw = mod.json.dumps(payload).encode("utf-8")
|
||||||
|
return raw if size < 0 else raw[:size]
|
||||||
|
|
||||||
self.assertEqual(mod.decode_json_response(PlainResponse(), "plain JSON failed"), payload)
|
self.assertEqual(mod.decode_json_response(PlainResponse(), "plain JSON failed"), payload)
|
||||||
self.assertEqual(len(requests), 4)
|
self.assertEqual(len(requests), 4)
|
||||||
self.assertTrue(all(request.get_header("Accept-encoding") == "gzip" for request in requests))
|
self.assertTrue(all(request.get_header("Accept-encoding") == "gzip" for request in requests))
|
||||||
|
|
||||||
|
def test_json_decoder_bounds_plain_and_gzip_payloads(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def __init__(self, raw: bytes, encoding: str = "") -> None:
|
||||||
|
self.raw = raw
|
||||||
|
self.headers = {"Content-Encoding": encoding} if encoding else {}
|
||||||
|
|
||||||
|
def read(self, size=-1):
|
||||||
|
return self.raw if size < 0 else self.raw[:size]
|
||||||
|
|
||||||
|
oversized = mod.json.dumps({"value": "x" * 256}).encode("utf-8")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "bounded"):
|
||||||
|
mod.decode_json_response(Response(oversized), "bounded response", maximum_bytes=64)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "bounded"):
|
||||||
|
mod.decode_json_response(
|
||||||
|
Response(mod.gzip.compress(oversized), "gzip"),
|
||||||
|
"bounded response",
|
||||||
|
maximum_bytes=64,
|
||||||
|
)
|
||||||
|
|
||||||
def test_default_refresh_intervals_are_five_minutes(self) -> None:
|
def test_default_refresh_intervals_are_five_minutes(self) -> None:
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
|
|
||||||
@@ -613,6 +750,181 @@ def sample_logs_payload() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceTests(unittest.TestCase):
|
||||||
|
def test_workspace_adapters_preserve_ids_and_map_compact_traffic(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
payload = workspace_payload_fixture()
|
||||||
|
|
||||||
|
accounts = mod.workspace_accounts_payload(payload)
|
||||||
|
status = mod.workspace_status_payload(payload)
|
||||||
|
pricing = mod.workspace_pricing_payload(payload)
|
||||||
|
logs = mod.workspace_logs_payload(payload)
|
||||||
|
keys = mod.workspace_keys_payload(payload)
|
||||||
|
errors = mod.workspace_errors_payload(payload)
|
||||||
|
|
||||||
|
self.assertEqual(accounts["accounts"][0]["id"], "9007199254740993")
|
||||||
|
self.assertEqual(status["channel_monitors"]["items"][0]["id"], "9007199254740995")
|
||||||
|
self.assertEqual(pricing["sources"][0]["name"], "code-plan")
|
||||||
|
self.assertEqual(logs["data"]["items"][0]["id"], "9007199254740997")
|
||||||
|
self.assertEqual(logs["data"]["items"][0]["instance"], "server6")
|
||||||
|
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_logs[0]["id"], 9007199254740997)
|
||||||
|
self.assertEqual(normalized_logs[0]["cost"], 0.25)
|
||||||
|
self.assertEqual(mod.as_int("9007199254740999"), 9007199254740999)
|
||||||
|
self.assertEqual(mod.normalize_key_rows(keys)[0]["name"], "wmy")
|
||||||
|
self.assertEqual(mod.normalize_key_rows(keys)[0]["cost"], 0.25)
|
||||||
|
self.assertEqual(errors["items"][0]["error_count"], 4)
|
||||||
|
self.assertEqual(errors["items"][0]["phase"], "upstream_http")
|
||||||
|
self.assertEqual(errors["sources"]["server6"]["total"], 4)
|
||||||
|
self.assertEqual(errors["sources"]["server4"]["total"], 0)
|
||||||
|
self.assertEqual(
|
||||||
|
mod.workspace_state_summary(payload, "workspace unavailable"),
|
||||||
|
"workspace endpoint unavailable (using last good)",
|
||||||
|
)
|
||||||
|
fallback_error = copy.deepcopy(errors)
|
||||||
|
fallback_error["items"][0].pop("api_key_name", None)
|
||||||
|
fallback_error["items"][0].pop("account_name", None)
|
||||||
|
fallback_error["items"][0]["api_key_id"] = "9007199254740999"
|
||||||
|
fallback_error["items"][0]["account_id"] = "9007199254740998"
|
||||||
|
fallback_row = mod.normalize_error_rows(fallback_error)[0]
|
||||||
|
self.assertEqual(fallback_row["key"], "#9007199254740999")
|
||||||
|
self.assertEqual(fallback_row["account"], "#9007199254740998")
|
||||||
|
normalized_errors = mod.normalize_error_rows(errors)
|
||||||
|
self.assertEqual(normalized_errors[0]["count"], 4)
|
||||||
|
self.assertIn("count 4", mod.error_detail_line(normalized_errors[0]))
|
||||||
|
|
||||||
|
def test_workspace_payload_validation_rejects_nonfinite_and_oversized_rows(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
nonfinite = workspace_payload_fixture()
|
||||||
|
nonfinite["traffic"]["requests"][0]["actual_cost"] = float("nan")
|
||||||
|
with mock.patch.object(mod, "fetch_payload", return_value=nonfinite):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "invalid workspace projection"):
|
||||||
|
mod.fetch_workspace_payload("https://workspace.example.test/data", 3)
|
||||||
|
|
||||||
|
oversized = workspace_payload_fixture()
|
||||||
|
oversized["traffic"]["requests"] = [
|
||||||
|
{"id": str(index)} for index in range(mod.MAX_WORKSPACE_REQUESTS + 1)
|
||||||
|
]
|
||||||
|
with mock.patch.object(mod, "fetch_payload", return_value=oversized):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "invalid workspace requests"):
|
||||||
|
mod.fetch_workspace_payload("https://workspace.example.test/data", 3)
|
||||||
|
|
||||||
|
def test_workspace_cache_coalesces_reads_and_returns_copies(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
calls: list[str] = []
|
||||||
|
call_lock = threading.Lock()
|
||||||
|
|
||||||
|
def fake_request(url: str, timeout: int):
|
||||||
|
with call_lock:
|
||||||
|
calls.append(url)
|
||||||
|
time.sleep(0.03)
|
||||||
|
return workspace_payload_fixture()
|
||||||
|
|
||||||
|
cache = mod.WorkspaceCache("https://workspace.example.test/api/ui-data?view=workspace", 3, 300)
|
||||||
|
results: list[dict[str, object]] = []
|
||||||
|
threads = [
|
||||||
|
threading.Thread(target=lambda: results.append(cache.get()))
|
||||||
|
for _ in range(12)
|
||||||
|
]
|
||||||
|
with mock.patch.object(mod, "fetch_workspace_payload", side_effect=fake_request):
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join(timeout=2)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertEqual(len(results), 12)
|
||||||
|
results[0]["view"] = "mutated"
|
||||||
|
self.assertEqual(cache.get()["view"], "workspace")
|
||||||
|
cache.get(force=True)
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
|
||||||
|
def test_workspace_cache_backs_off_failures_with_and_without_last_good(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
cache = mod.WorkspaceCache("https://workspace.example.test/data", 3, 300)
|
||||||
|
with mock.patch.object(
|
||||||
|
mod,
|
||||||
|
"fetch_workspace_payload",
|
||||||
|
side_effect=[
|
||||||
|
workspace_payload_fixture(),
|
||||||
|
RuntimeError("private failure"),
|
||||||
|
workspace_payload_fixture(),
|
||||||
|
],
|
||||||
|
) as fetch:
|
||||||
|
first = cache.get()
|
||||||
|
stale = cache.get(force=True)
|
||||||
|
repeated = cache.get()
|
||||||
|
self.assertEqual(fetch.call_count, 2)
|
||||||
|
cache.last_attempt_at -= cache.retry_seconds + 1
|
||||||
|
recovered = cache.get()
|
||||||
|
self.assertEqual(first["view"], "workspace")
|
||||||
|
self.assertEqual(stale["view"], "workspace")
|
||||||
|
self.assertEqual(repeated["view"], "workspace")
|
||||||
|
self.assertEqual(recovered["view"], "workspace")
|
||||||
|
self.assertEqual(fetch.call_count, 3)
|
||||||
|
self.assertEqual(cache.network_fetches, 3)
|
||||||
|
self.assertEqual(cache.error, "")
|
||||||
|
|
||||||
|
empty = mod.WorkspaceCache("https://workspace.example.test/data", 3, 300)
|
||||||
|
with mock.patch.object(mod, "fetch_workspace_payload", side_effect=RuntimeError("private failure")) as fetch:
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "workspace unavailable"):
|
||||||
|
empty.get()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "workspace unavailable"):
|
||||||
|
empty.get()
|
||||||
|
fetch.assert_called_once()
|
||||||
|
|
||||||
|
def test_default_main_uses_one_workspace_request_without_loading_admin_token(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
out = io.StringIO()
|
||||||
|
with (
|
||||||
|
mock.patch.object(mod, "fetch_workspace_payload", return_value=workspace_payload_fixture()) as workspace_fetch,
|
||||||
|
mock.patch.object(mod, "default_logs_token", side_effect=AssertionError("admin token must not be read")) as token_read,
|
||||||
|
mock.patch.object(mod, "fetch_payload", side_effect=AssertionError("legacy accounts must not be read")),
|
||||||
|
mock.patch.object(mod, "fetch_optional_payload", side_effect=AssertionError("legacy status must not be read")),
|
||||||
|
mock.patch.object(mod, "fetch_optional_pricing_payload", side_effect=AssertionError("legacy pricing must not be read")),
|
||||||
|
contextlib.redirect_stdout(out),
|
||||||
|
):
|
||||||
|
rc = mod.main(
|
||||||
|
[
|
||||||
|
"--once",
|
||||||
|
"--workspace-url",
|
||||||
|
"https://workspace.example.test/api/ui-data?view=workspace",
|
||||||
|
"--no-version-check",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
workspace_fetch.assert_called_once_with(
|
||||||
|
"https://workspace.example.test/api/ui-data?view=workspace", 10
|
||||||
|
)
|
||||||
|
token_read.assert_not_called()
|
||||||
|
self.assertIn("oai-quota-code-plan", out.getvalue())
|
||||||
|
self.assertIn("wmy", out.getvalue())
|
||||||
|
|
||||||
|
def test_empty_workspace_url_falls_back_to_legacy_direct_mode(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
accounts = workspace_payload_fixture()["accounts"]
|
||||||
|
with (
|
||||||
|
mock.patch.object(mod, "default_logs_token", return_value="") as token_read,
|
||||||
|
mock.patch.object(mod, "fetch_payload", return_value=accounts) as account_fetch,
|
||||||
|
mock.patch.object(mod, "fetch_optional_payload", return_value=({}, "")),
|
||||||
|
mock.patch.object(mod, "fetch_optional_pricing_payload", return_value=({"sources": []}, "")),
|
||||||
|
mock.patch.object(mod, "fetch_workspace_payload", side_effect=AssertionError("workspace must not be read")),
|
||||||
|
contextlib.redirect_stdout(io.StringIO()),
|
||||||
|
):
|
||||||
|
rc = mod.main(["--once", "--workspace-url", "", "--no-version-check"])
|
||||||
|
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
token_read.assert_called_once_with()
|
||||||
|
account_fetch.assert_called_once()
|
||||||
|
|
||||||
|
def test_sources_and_requests_flags_are_aliases(self) -> None:
|
||||||
|
mod = load_module()
|
||||||
|
self.assertTrue(mod.build_parser().parse_args(["--sources"]).pricing)
|
||||||
|
self.assertTrue(mod.build_parser().parse_args(["--requests"]).logs)
|
||||||
|
|
||||||
|
|
||||||
class Sub2APILogsTests(unittest.TestCase):
|
class Sub2APILogsTests(unittest.TestCase):
|
||||||
def test_normalize_log_rows_maps_columns_and_sorts_newest_first(self) -> None:
|
def test_normalize_log_rows_maps_columns_and_sorts_newest_first(self) -> None:
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
@@ -737,7 +1049,7 @@ class Sub2APILogsTests(unittest.TestCase):
|
|||||||
err = io.StringIO()
|
err = io.StringIO()
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stderr(err):
|
with contextlib.redirect_stderr(err):
|
||||||
rc = mod.main(["--once", "--logs", "--no-version-check"])
|
rc = mod.main(["--once", "--logs", "--legacy-direct", "--no-version-check"])
|
||||||
finally:
|
finally:
|
||||||
if old_token is not None:
|
if old_token is not None:
|
||||||
os.environ["SHUSUB2_LOGS_TOKEN"] = old_token
|
os.environ["SHUSUB2_LOGS_TOKEN"] = old_token
|
||||||
@@ -915,7 +1227,7 @@ class Sub2APILogsTests(unittest.TestCase):
|
|||||||
err = io.StringIO()
|
err = io.StringIO()
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stderr(err):
|
with contextlib.redirect_stderr(err):
|
||||||
rc = mod.main(["--once", "--errors", "--no-version-check"])
|
rc = mod.main(["--once", "--errors", "--legacy-direct", "--no-version-check"])
|
||||||
finally:
|
finally:
|
||||||
if old_token is not None:
|
if old_token is not None:
|
||||||
os.environ["SHUSUB2_LOGS_TOKEN"] = old_token
|
os.environ["SHUSUB2_LOGS_TOKEN"] = old_token
|
||||||
@@ -1022,6 +1334,7 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
100,
|
100,
|
||||||
100,
|
100,
|
||||||
"24h",
|
"24h",
|
||||||
|
legacy_direct=True,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
App.run = original_run
|
App.run = original_run
|
||||||
@@ -1061,6 +1374,102 @@ class DashboardLayoutTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(app.screen.rows[0]["name"], "code-plan")
|
self.assertEqual(app.screen.rows[0]["name"], "code-plan")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_workspace_dashboard_handles_initial_endpoint_failure(self) -> None:
|
||||||
|
from textual.app import App
|
||||||
|
|
||||||
|
mod = load_module()
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
original_run = App.run
|
||||||
|
App.run = lambda self, *args, **kwargs: captured.setdefault("app", self)
|
||||||
|
try:
|
||||||
|
rc = mod.run_textual(
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
999,
|
||||||
|
999,
|
||||||
|
999,
|
||||||
|
1,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
"24h",
|
||||||
|
workspace_url="https://workspace.example.test/api/ui-data?view=workspace",
|
||||||
|
legacy_direct=False,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
App.run = original_run
|
||||||
|
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
app = captured["app"]
|
||||||
|
with mock.patch.object(
|
||||||
|
mod,
|
||||||
|
"fetch_workspace_payload",
|
||||||
|
side_effect=RuntimeError("private endpoint failure"),
|
||||||
|
) as fetch:
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
self.assertEqual(type(app.screen).__name__, "DashboardScreen")
|
||||||
|
self.assertIn("workspace unavailable", str(app.screen.query_one("#status").render()))
|
||||||
|
self.assertEqual(fetch.call_count, 1)
|
||||||
|
|
||||||
|
async def test_workspace_dashboard_uses_one_snapshot_and_has_return_navigation(self) -> None:
|
||||||
|
from textual.app import App
|
||||||
|
|
||||||
|
mod = load_module()
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
original_run = App.run
|
||||||
|
App.run = lambda self, *args, **kwargs: captured.setdefault("app", self)
|
||||||
|
try:
|
||||||
|
rc = mod.run_textual(
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
999,
|
||||||
|
999,
|
||||||
|
999,
|
||||||
|
1,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
"24h",
|
||||||
|
workspace_url="https://workspace.example.test/api/ui-data?view=workspace",
|
||||||
|
legacy_direct=False,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
App.run = original_run
|
||||||
|
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
app = captured["app"]
|
||||||
|
with mock.patch.object(mod, "fetch_workspace_payload", return_value=workspace_payload_fixture()) as fetch:
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.pause()
|
||||||
|
screen = app.screen
|
||||||
|
self.assertEqual(type(screen).__name__, "DashboardScreen")
|
||||||
|
self.assertEqual(app.sub_title, "Dashboard")
|
||||||
|
self.assertEqual(screen.account_rows[0]["name"], "oai-quota-code-plan")
|
||||||
|
self.assertEqual(screen.error_rows[0]["count"], 4)
|
||||||
|
self.assertEqual(len(fetch.call_args_list), 1)
|
||||||
|
|
||||||
|
await pilot.press("p")
|
||||||
|
await pilot.pause()
|
||||||
|
self.assertEqual(type(app.screen).__name__, "PricingScreen")
|
||||||
|
self.assertEqual(app.sub_title, "Sources")
|
||||||
|
await pilot.press("d")
|
||||||
|
await pilot.pause()
|
||||||
|
self.assertEqual(type(app.screen).__name__, "DashboardScreen")
|
||||||
|
self.assertEqual(app.sub_title, "Dashboard")
|
||||||
|
self.assertEqual(len(fetch.call_args_list), 1)
|
||||||
|
|
||||||
|
|
||||||
class PageSelectionTests(unittest.TestCase):
|
class PageSelectionTests(unittest.TestCase):
|
||||||
def test_once_pricing_prints_pricing_monitor_sources(self) -> None:
|
def test_once_pricing_prints_pricing_monitor_sources(self) -> None:
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
@@ -1080,7 +1489,7 @@ class PageSelectionTests(unittest.TestCase):
|
|||||||
out = io.StringIO()
|
out = io.StringIO()
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stdout(out):
|
with contextlib.redirect_stdout(out):
|
||||||
rc = mod.main(["--once", "--pricing", "--no-version-check"])
|
rc = mod.main(["--once", "--pricing", "--legacy-direct", "--no-version-check"])
|
||||||
finally:
|
finally:
|
||||||
mod.fetch_pricing_payload = original_fetch
|
mod.fetch_pricing_payload = original_fetch
|
||||||
|
|
||||||
@@ -1097,12 +1506,12 @@ class PageSelectionTests(unittest.TestCase):
|
|||||||
err = io.StringIO()
|
err = io.StringIO()
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stderr(err):
|
with contextlib.redirect_stderr(err):
|
||||||
rc = mod.main(["--once", "--pricing", "--no-version-check"])
|
rc = mod.main(["--once", "--pricing", "--legacy-direct", "--no-version-check"])
|
||||||
finally:
|
finally:
|
||||||
mod.fetch_pricing_payload = original_fetch
|
mod.fetch_pricing_payload = original_fetch
|
||||||
|
|
||||||
self.assertEqual(rc, 1)
|
self.assertEqual(rc, 1)
|
||||||
self.assertEqual(err.getvalue().strip(), "upstreams unavailable")
|
self.assertEqual(err.getvalue().strip(), "sources unavailable")
|
||||||
|
|
||||||
def test_once_accounts_includes_only_mapped_pricing_source_balance(self) -> None:
|
def test_once_accounts_includes_only_mapped_pricing_source_balance(self) -> None:
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
@@ -1136,6 +1545,7 @@ class PageSelectionTests(unittest.TestCase):
|
|||||||
with contextlib.redirect_stdout(out):
|
with contextlib.redirect_stdout(out):
|
||||||
rc = mod.main(
|
rc = mod.main(
|
||||||
[
|
[
|
||||||
|
"--legacy-direct",
|
||||||
"--once",
|
"--once",
|
||||||
"--api-url",
|
"--api-url",
|
||||||
"https://accounts.example.test/api/tui/accounts",
|
"https://accounts.example.test/api/tui/accounts",
|
||||||
|
|||||||
Reference in New Issue
Block a user