feat: add cached pricing workspace

This commit is contained in:
2026-08-04 00:16:34 +08:00
parent 81e9ce1530
commit 311236d28f
5 changed files with 1156 additions and 376 deletions
+159 -237
View File
@@ -8,190 +8,175 @@
- 对应服务:无 - 对应服务:无
- 对应 stack:无 - 对应 stack:无
- Secret 边界:本地用户配置 - Secret 边界:本地用户配置
- 分发方式:`uvx` / `uv tool install` - 目标 APIPricing 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
projectionDashboard、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。
- `/`:聚焦当前页面 filterDashboard 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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+420 -10
View File
@@ -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",
Generated
+1 -1
View File
@@ -85,7 +85,7 @@ wheels = [
[[package]] [[package]]
name = "shusub2" name = "shusub2"
version = "0.2.15" version = "0.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "textual" }, { name = "textual" },