diff --git a/README.md b/README.md index f9512f6..97d9f7e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [MiniMax Pet](plugins/minimax-ai/minimax-pet/) | A lightweight companion with appearance, task and settings tabs, visible-session activity, answer-needed reminders and a transparent desktop pet. Requires MiniMax Code 3.1.6 or later | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth Console](plugins/microbiosis/mcp-oauth-console/) | Sign in to MCP servers that require OAuth: device-code or authorization-code + PKCE flow, the `Authorization` header written into the MCP configuration, and an opt-in refresh before expiry | [Microbiosis](https://github.com/Microbiosis) | | [本机大盘 · Local Market Board](plugins/jzlikewei/kurumi-monitor/) | Watch your own machine as a stock terminal: CPU, GPU, temperature, memory, disk, network and every running process become listed stocks with live candlesticks, moving averages, a watchlist, a leaderboard and a trade tape, on macOS and Windows. Requires minimax Code 3.1.2 or later | [jzlikewei](https://github.com/jzlikewei) | +| [Quota Bottle](plugins/7723qqq/quota-bottle/) | An octopus-shaped glass bottle that floats on your desktop and shows the remaining Token Plan quota from a login you configure yourself, with the liquid level and opacity following the remaining share, and a device-code login plus an opt-in local token-usage estimate as fallbacks | [7723qqq](https://github.com/7723qqq) |
Preview: Cutline diff --git a/README.zh-CN.md b/README.zh-CN.md index 1032a89..79c4d9a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -87,6 +87,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [MiniMax Pet](plugins/minimax-ai/minimax-pet/README.zh-CN.md) | 提供外观/任务/设置、可见会话状态、需要回答提醒和透明桌宠;需要 MiniMax Code 3.1.6 或更高版本 | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth 令牌台](plugins/microbiosis/mcp-oauth-console/README.zh-CN.md) | 给需要 OAuth 的 MCP 服务器完成登录:设备码或授权码 + PKCE 流程,把 `Authorization` 头写入 MCP 配置,并可选择在到期前自动续期 | [Microbiosis](https://github.com/Microbiosis) | | [本机大盘](plugins/jzlikewei/kurumi-monitor/README.zh-CN.md) | 把本机当成行情终端来看:CPU、GPU、温度、内存、磁盘、网络与每个运行中的进程都是一只个股,实时 K 线、均线、自选行情、主力动向与逐笔成交,支持 macOS 与 Windows。需要 minimax Code 3.1.2 或更高版本 | [jzlikewei](https://github.com/jzlikewei) | +| [额度瓶](plugins/7723qqq/quota-bottle/README.zh-CN.md) | 一只悬浮在桌面上的章鱼形玻璃瓶,用你自己配置的登录读取 Token Plan 剩余额度;液面与透明度按剩余占比走,拿不到实时额度时回退到设备码登录或手填总额与可选的本机本月用量 | [7723qqq](https://github.com/7723qqq) |
预览:Cutline diff --git a/plugins/7723qqq/quota-bottle/.minimax-plugin/plugin.json b/plugins/7723qqq/quota-bottle/.minimax-plugin/plugin.json new file mode 100644 index 0000000..5e1ccbd --- /dev/null +++ b/plugins/7723qqq/quota-bottle/.minimax-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "name": "quota-bottle", + "displayName": "Quota Bottle", + "version": "1.15.3", + "description": "An original octopus-shaped glass bottle that floats on your desktop and shows how much Token Plan quota is left from a login you configure yourself: the liquid level and opacity follow the remaining share, with a device-code login and an opt-in local token-usage fallback.", + "author": "7723qqq", + "icon": "icon.png", + "category": "Productivity", + "exampleQueries": [ + "Open Quota Bottle", + "打开额度瓶,看看我还剩多少额度", + "把额度瓶悬浮在桌面上,实时显示剩余额度" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/7723qqq/quota-bottle/LICENSE b/plugins/7723qqq/quota-bottle/LICENSE new file mode 100644 index 0000000..40c7e97 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 7723qqq + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/7723qqq/quota-bottle/NOTICE.md b/plugins/7723qqq/quota-bottle/NOTICE.md new file mode 100644 index 0000000..49daea2 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/NOTICE.md @@ -0,0 +1,20 @@ +# Source attribution and license scope + +The Quota Bottle implementation in this package is an independent implementation +written for this community repository. It speaks the public MiniMax Token Plan +and device-authorization HTTP endpoints and follows the published Mini App +contract in this repository. It does not copy or redistribute source files from +the `MiniMax-AI/cli` repository. + +The device-flow client ID, scopes, endpoint paths and quota field names are +protocol and API identifiers, not a license grant over this package's source. +Where the repository's public documentation describes those identifiers, this +package implements the behaviour from that description rather than embedding +upstream source. + +`icon.png` is an original PNG drawn for this package by its contributor. No +third-party artwork is bundled. + +The MIT license in `LICENSE` covers all source, documentation and artwork in +this package. It does not grant rights to any separate MiniMax product, service, +trademark or API; those remain subject to their own terms. diff --git a/plugins/7723qqq/quota-bottle/README.md b/plugins/7723qqq/quota-bottle/README.md new file mode 100644 index 0000000..2f988c6 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/README.md @@ -0,0 +1,253 @@ +# Quota Bottle (`quota-bottle`) + +An octopus-shaped glass bottle that holds your remaining MiniMax Token Plan quota. The liquid level +and its opacity both follow the remaining share, so a full quota is a saturated octopus and an +exhausted quota is a transparent glass outline. The same page can float as a small transparent +desktop window. + +The page is SVG and has no framework, CDN, build step or runtime dependency. It shows the remaining +and total numbers, the percentage, the reset time, per-model rows, the weekly chip, and light/dark +themes. The interface follows the client's language — Chinese or English — and the 中 / EN buttons +in the header switch it for good. + +## Install + +Copy this directory, including the hidden `.minimax-plugin/`, into the MiniMax Code plugins +directory as `quota-bottle/` (`~/.minimax/plugins/quota-bottle/` by default). Restart MiniMax Code +and ask the Agent to "Open Quota Bottle". + +## Floating window + +**Pin to desktop** under the bottle moves the page to a 220 × 300 frameless, transparent, +always-on-top window; the panel keeps running. This requires MiniMax Code 3.1.2 or later. + +The window is 220 × 300. MiniMax Code remembers a popped-out window's bounds per plugin and lets that +memory outrank the manifest, so a window reopens at the size it last had. The package ships it +non-resizable, because a transparent frameless window that can be resized loses its transparency on +Windows; a client that reports a resizable window is offered a size repair instead, and that patch +carries a visible position because the Host refuses a geometry change that would leave the screen. +The page paints no backdrop — neither on the root element nor on the body. On a client that renders +Mini Apps with the `webview` backend the window surface itself is opaque: the page still paints +nothing, but the desktop cannot show through. That is a client-side limitation, tracked upstream in +[MiniMax-AI/minimax-code#597](https://github.com/MiniMax-AI/minimax-code/issues/597). + +MiniMax Code opens a window only from a user action: a window never opens at launch, and a mode +change outside a click is refused with `not_permitted`. The first pin is therefore one click, and +the offer sits on the opening screen instead of behind a menu. After that the window stays up while +the client runs; closing it returns the page to the panel, and the next pin is a click again. + +- The window has no native title bar, so dragging it takes two paths. The page marks its content as + a drag handle (`-webkit-app-region: drag`, with the × kept `no-drag`), which the operating system + honours where the renderer supports it; and, because the client may render Mini Apps in a WebView2 + guest that ignores that property, the page can also move the window itself, in screen coordinates + so the window's own movement cannot cancel the drag. Only one path is ever live: where the drag + region exists it swallows the pointer events first. +- The × in the top-right corner returns it to the panel. Closing it like any other window does the + same; the panel keeps running. +- A pinned window polls every 5 seconds, the panel every 10, and a hidden page every 60; pinning + reads once immediately instead of waiting for the first poll. Node caches the live quota for 3 + seconds, so a pinned window stays close to live without adding load, and the local usage snapshot + for 15; **Refresh** starts a new cache generation, so it cannot reuse an in-flight request + started before a login or a month change. +- The compact meter shows the percentage and token amount, then the reset countdown, the weekly + quota and the video model's used share when the response carries them. +- The floating meter shows a short status when a login, a manual total, or a live-quota problem + needs attention in the panel. + +## How the quota is calculated + +The page asks the Node entry for `/api/state`. Node tries the live Token Plan quota first, then the +manual-total fallback. + +The live quota has one source: a credential you configure yourself. + +1. **The plugin's own login.** The panel's device-code login stores an OAuth token in + `/credentials.json` and refreshes it there. The token is used for + `GET /v1/token_plan/remains` with `Authorization: Bearer `. +2. **A credential already in the CLI config.** `~/.mmx/config.json` (or + `$MMX_CONFIG_DIR/config.json`) is read, never written: an API key, or an OAuth access token + that is still valid. A CLI-owned token is not refreshed or rotated; when it expires the panel + offers its own login. This keeps a session created with `mmx auth login` exactly as the CLI + left it. + +The bottle does not borrow MiniMax Code's signed-in session. It does not read the desktop's auth +lease, its capability file, or any other Host credential. + +### 1. Token Plan quota + +The Node entry reads the credential snapshot in this order: the plugin store first, then the CLI +config's OAuth access token while it is still valid, then the CLI config's API key. It calls +`GET /v1/token_plan/remains` with `Authorization: Bearer `. + +The host follows the region: `https://api.minimaxi.com` for `cn` and `https://api.minimax.io` for +`global`. A configured `base_url` or OAuth `resource_url` is accepted only when it is an HTTPS +MiniMax API host, and the other region's host stays a fallback, so a stale region setting cannot +hide a working credential: every candidate host is tried in turn and the first one that answers with +a quota wins. A transport failure stops the walk, because it says nothing about the host. A loopback +HTTP host is accepted only through `MINIMAX_BASE_URL`, which replaces every candidate host for local +stub testing. + +Credentialed requests never follow redirects. `MINIMAX_BASE_URL`, `MINIMAX_REGION`, +`MMX_CONFIG_DIR`, `MINIMAX_DATA_DIR` and `MAVIS_HOME` may change which host or local file is used. +All are read from the Node process environment. + +#### Log in once + +If no credential can read the quota — none, expired, rejected, or unreadable — the panel shows +**Token Plan login**: + +- **Log in** starts the device-code OAuth flow. The Node process requests a code from the fixed + `account.minimaxi.com` or `account.minimax.io` host, validates the returned URL and code, opens + the verification page, and shows the URL and code in the page. The verification page itself + lives on the region's platform host (`platform.minimaxi.com` or `platform.minimax.io`); the + account host stays allowed because older responses point there. Each region accepts only its + own two origins. +- The panel starts the login against the region the stored credential or CLI config asks for. +- The page polls `GET /api/login`; Node polls the account token endpoint and writes a successful + credential into `/credentials.json`. The token never reaches the page, a log, + or an error response. +- The CLI config is never written by the login or by token refresh. Its `api_key`, `base_url`, + region and other fields stay exactly as the user left them. +- Quitting or replacing a pending flow does not let a late response write credentials for a + different region. `dispose()` aborts in-flight account and quota requests. +- The credential write is atomic, serialized, uses a per-process temporary name, and is 0600 by + default. + +`sk-api-` keys are account-balance keys, not Token Plan keys. They are rejected before a request is +made and the page falls back to the manual total. + +A credential the service refuses is named instead of being lumped together: `2049 invalid api key` +becomes `invalid_credential`, `1004` (no secret key in the request) becomes `missing_credential`, and +an HTTP 401 or 403 becomes `unauthorized`. Every one of them clears with a fresh login, so the panel +offers **Token Plan login** rather than leaving the user without a way forward. When several hosts +fail differently, the failure the user can act on is the one that gets reported. + +Response normalization: + +- The current interval (`current_interval_*`) is the bottle's number. Weekly figures are shown as a + second chip. +- `current_interval_usage_count` has meant "remaining" in older responses and "consumed" in newer + ones. When `current_interval_remaining_percent` is present, the reading within one percentage + point of it wins; a row that agrees with neither, or has an invalid percentage, is ignored. + Without the percentage, the historical "remaining" reading is kept. +- A row with `current_interval_status === 3` (unlimited) or a zero total is skipped. Remaining + values are clamped to `[0, total]`. +- `ratio = remaining / total`; the liquid opacity equals `ratio`. The body fills across the first 80% + of the height, and the last 20% fills the neck and the two ears, so 100% reaches the mouth. +- Weekly percentages are shown only when every displayed model has a usable weekly row; the + weekly chip does not apply a plan boost multiplier. + +### 2. Total you enter + +When no live quota is available, the page asks for a total quota in tokens and stores it in the +plugin's `dataDir` as `quota.json`. + +- Used: **off by default.** The panel has a "Read local usage from MiniMax Code" switch. Only + after it is on does the bottle read the current local calendar month from + `/v2/sqlite/runtime-state.sqlite`, table `local_runtime_token_usage`, summing + `input_tokens + output_tokens + cache_read_tokens + cache_write_tokens` where + `startMs <= ts < endMs`. Reasoning tokens are not added separately. It opens the database + read-only and copies it with `VACUUM INTO` into a temporary snapshot under `context.dataDir`, + then removes the snapshot; the Host database is never written. +- The data root comes only from `MINIMAX_DATA_DIR`, then `MAVIS_HOME`, then `~/.minimax`. The + bottle does not infer it from `context.dataDir` or walk the Host directory tree. That path and + schema are unpublished Host layout details; if your install keeps its data elsewhere, set + `MINIMAX_DATA_DIR`. +- With the switch off, the database is not opened, copied or read at all: the manual state reports + `usage_scan_off`, and the page says local usage is off. +- `remaining = max(0, total - used)`, `ratio = remaining / total`. +- Usage above the saved total is reported as its own state (`overBudget`, `overBy`) instead of being + clamped into a believable-looking empty bottle. The page says how far the usage is above the saved + total and keeps pointing at the real quota. +- This is a raw token count, not a Token Plan count. Do not compare the two sources' numbers. + +## Tested environment + +- MiniMax Code 3.1.12.216 on Windows 11: the package was installed, opened from the Agent, and the + floating octopus window rendered. +- MiniMax Code 3.1.2 or later is required by `surface.window`. +- Node.js 24.9.0 on Windows 11: `node --test "tests/*.test.mjs"` passes all 85 plugin tests; + `node scripts/validate.mjs plugins/7723qqq/quota-bottle` reports OK. +- The synthetic HTTP integration test serves the page, rejects foreign Host and Origin, writes a + manual total, bounds JSON bodies, validates the usage-scan switch, and verifies that `dispose()` + frees the listener. +- The credential tests put an `api_key`, a `base_url` and an unrelated field in a CLI config, run + the panel login, and assert the CLI file is byte-for-byte unchanged. Refresh tests assert a + CLI-owned token is never refreshed or written, while a plugin-owned token is refreshed into + `dataDir/credentials.json`. +- The state tests assert the Host SQLite database is not opened until the "Read local usage" + switch is on, and that turning it on reads the current month. A stubbed quota endpoint covers the + region host choice, the fallback to the other host, the named credential failures, and the + balance-key refusal. The page tests cover the window drag math and the verification origins, and + keep every error code and every login-fixable code in sync with the Node catalog. +- Synthetic quota API responses cover ambiguous counts, conflict rejection, percentage-only rows, + unlimited rows, and weekly aggregation. A synthetic SQLite database covers the local month + window and missing-schema cases. +- A full device authorization and macOS/Linux were not re-run in this revision. + +## Data & access + +- Files read: + - `/credentials.json`: the OAuth token written by the panel login. + - `~/.mmx/config.json` or `$MMX_CONFIG_DIR/config.json`: read-only. An API key, or an OAuth + access token that is still valid. This relies on the current `mmx` layout and is not a + documented Mini App API. + - `/v2/sqlite/runtime-state.sqlite`, read-only, only when "Read local usage" is on. + `VACUUM INTO` creates a temporary snapshot under `context.dataDir`; the snapshot is removed + after the query. That Host schema and path are unsupported layout details. + - `miniapp/client/index.html`, and `quota.json`/`settings.json` in `context.dataDir`. +- Files written: + - `/credentials.json`: the panel login and OAuth token rotation. No CLI or Host + file is written by this package. + - `/quota.json`, the manual total, and `/settings.json`, the + usage-scan switch. Both use an atomic temporary file, then rename. + - Temporary SQLite snapshots under `/.usage-*`, only when the switch is on, + removed after each read. +- Network: + - `https://account.minimaxi.com` or `https://account.minimax.io`: device-code start and poll. + - `https://api.minimaxi.com` or `https://api.minimax.io`: Token Plan quota read through the + plugin login or a CLI credential, or an approved HTTPS MiniMax API override. No redirect is + followed. + - A loopback HTTP stub is used only in tests through `MINIMAX_BASE_URL`. + - If credentials exist, each state request still tries the live source first, even when a manual + total is saved. Manual mode alone makes no network request. +- Processes: the system browser opener (`explorer.exe` on Windows, `open` on macOS, `xdg-open` on + Linux) for the validated verification page. Quota and OAuth requests run in-process; there is no + `mmx` executable and no shell. +- Agent turns: none; no `agentAccess`. +- Conversations read: none; no `session.read`. +- Tools called: none; no `toolAccess`. +- Microphone: not used; no `deviceAccess`. +- Secrets: tokens stay in the Node process and `dataDir/credentials.json` (or the CLI config when + that fallback is used). They are never sent to the page, logged, or included in error responses. + Responses expose a fixed error-code catalog only. +- Source and artwork: the implementation and icon are original to this package. See `NOTICE.md`. +- State: `credentials.json`, `quota.json` and `settings.json` in `dataDir`; the theme and language + preferences are view state in `localStorage`. + +## Files + +```text +.minimax-plugin/plugin.json Plugin manifest +package.json Mini App declaration +miniapp/miniapp.json Payload roots, Node entry, page route +miniapp/client/index.html The page served at /bottle +miniapp/node/server.mjs Node entry: start(context) → { dispose } +miniapp/node/quota.mjs Response normalization, manual-total state, quota entry point +miniapp/node/cache.mjs Generation-aware TTL cache +miniapp/node/urls.mjs Host, URL and timing validation +miniapp/node/net.mjs No-redirect credentialed fetch +miniapp/node/errors.mjs Fixed error-code catalog and login-fixable classification +miniapp/node/auth/*.mjs CLI config reader, plugin credential store and device flow +miniapp/node/usage.mjs Opt-in read-only local usage with a SQLite snapshot +miniapp/node/settings.mjs Usage-scan switch, default off +miniapp/node/store.mjs Manual total, stored atomically in dataDir +miniapp/node/miniapp-api.ts Type declarations for the runtime context +tests/*.test.mjs Plugin regression tests +NOTICE.md Source and artwork attribution +icon.png Original plugin icon +``` + +## License + +[MIT](./LICENSE). See [NOTICE.md](./NOTICE.md) for the license scope. diff --git a/plugins/7723qqq/quota-bottle/README.zh-CN.md b/plugins/7723qqq/quota-bottle/README.zh-CN.md new file mode 100644 index 0000000..a077119 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/README.zh-CN.md @@ -0,0 +1,139 @@ +# 额度瓶(`quota-bottle`) + +一只章鱼形状的玻璃瓶,装着你的 MiniMax Token Plan 剩余额度:液面高度和液体不透明度都按剩余占比走。额度满时章鱼装满、颜色最饱和;额度用光时只剩透明玻璃轮廓。同一张页面也能变成透明的小悬浮窗。 + +页面用 SVG 绘制,没有框架、CDN、构建步骤或运行时依赖。它显示剩余 / 总额度、百分比、重置时间、分模型明细、周额度标签,并支持明暗主题。界面跟随客户端语言(中文或英文),标题栏的 中 / EN 可以随时切换并记住选择。 + +## 安装 + +把整个目录(连同隐藏的 `.minimax-plugin/`)复制到 MiniMax Code 的插件目录,目录名用 `quota-bottle/`(默认在 `~/.minimax/plugins/quota-bottle/`)。重启 MiniMax Code,然后在对话里说“打开额度瓶”。 + +## 悬浮窗 + +瓶子下方点 **Pin to desktop**,页面会移到 220 × 300 的无边框、透明、置顶窗口;面板继续运行。该功能最低需要 MiniMax Code 3.1.2。 + +窗口是 220 × 300。MiniMax Code 会按插件记住弹出窗口的位置和大小,记忆排在清单之前,所以窗口会按上次的尺寸打开。包里把窗口设成不可缩放——在 Windows 上,可缩放的透明无边框窗口会失去透明;客户端如果报告窗口可缩放,页面才提供一次尺寸修复,而且那个补丁会带上可见位置,因为宿主会拒绝会跑到屏幕外的几何改动。页面本身不画任何底——根元素和 body 都是透明的。在把 Mini App 渲染在 `webview` 后端上的客户端里,窗口表面本身是不透明的:页面照样没画底,但桌面透不过来。这是客户端侧的限制,已在上游 [MiniMax-AI/minimax-code#597](https://github.com/MiniMax-AI/minimax-code/issues/597) 记录。 + +MiniMax Code 只在用户操作下开窗:窗口不会随启动打开,不在点击里的模式切换会被拒绝(`not_permitted`)。所以第一次固定需要点一下,入口就放在首屏,而不是藏在菜单里。之后窗口在客户端运行期间一直存在;关掉它回到面板,下次固定再点一下。 + +- 窗口没有系统标题栏,所以拖动准备了两种方式:页面把内容标成拖动区(`-webkit-app-region: drag`,右上角 × 保持 `no-drag`),渲染器支持时由系统移动窗口;而客户端可能用 WebView2 渲染 Mini App,那里不认识这个属性,所以页面也能自己移动窗口——位移用屏幕坐标,避免窗口自身的移动把拖动抵消掉。两条路只有一条会生效:有拖动区时它先把指针事件吞掉。 +- 右上角的 × 回到面板;像普通窗口那样关闭也一样,面板仍在运行。 +- 悬浮窗每 5 秒刷新一次,面板每 10 秒,页面不可见时每 60 秒;点固定后会立刻读一次,不等第一次轮询。Node 对实时额度缓存 3 秒,对本机用量快照缓存 15 秒;点 **Refresh** 会开启新的缓存代际,不会复用登录前或跨月之前的在途请求。 +- 胶囊里除了百分比和 token 数,还会显示重置倒计时、周额度和视频模型的已用占比(数据里有才显示)。 +- 需要登录、设置手填总额,或实时额度读取有问题时,悬浮计量条会给出简短提示。 + +## 额度怎么算 + +页面向 Node 请求 `/api/state`;Node 先尝试实时 Token Plan 额度,失败后才使用手填总额与本机用量。 + +实时额度只有一个来源:你自己配置的凭据。 + +1. **插件自己的登录。** 面板里的设备码登录把 OAuth token 写进 `/credentials.json`,刷新也写在这里。之后带 `Authorization: Bearer ` 请求 `GET /v1/token_plan/remains`。 +2. **CLI 配置里已有的凭据。** `~/.mmx/config.json`(或 `$MMX_CONFIG_DIR/config.json`)只读:里面的 API key,或还没过期的 OAuth access token。CLI 自己的 token 不会被刷新或轮换;过期后由面板提供自己的登录。这样 `mmx auth login` 建立的会话保持 CLI 留下的样子。 + +瓶子不借用 MiniMax Code 已登录的桌面会话,也不读取桌面端的租约、能力文件或任何别的 Host 凭据。 + +### 1. Token Plan 额度 + +Node 按这个顺序取凭据快照:插件自己的凭据、CLI 配置里仍有效的 OAuth access token、CLI 配置里的 API key。然后请求 `GET /v1/token_plan/remains`。 + +主机由分区决定:`cn` 区用 `https://api.minimaxi.com`,`global` 区用 `https://api.minimax.io`。配置中的 `base_url` 或 OAuth `resource_url` 只有属于 HTTPS MiniMax API 主机时才会被接受;另一个分区的主机保留为备选,因此写错的分区设置不会掩盖本来可用的凭据:候选主机按顺序逐个请求,第一个返回额度的即为命中。传输层失败(网络、超时)会中止这次遍历,因为那说明不了主机的问题。`MINIMAX_BASE_URL` 可显式指定回环 HTTP 地址,仅用于本地 stub 测试,此时它会替换掉全部候选主机。 + +带凭据的请求一律不跟随重定向。`MINIMAX_BASE_URL`、`MINIMAX_REGION`、`MMX_CONFIG_DIR`、`MINIMAX_DATA_DIR`、`MAVIS_HOME` 都可能改变所用主机或本机文件,均从 Node 进程环境读取。 + +#### 登录一次 + +没有任何凭据能读到额度时——没有、过期、被拒或读不出来——面板显示 **Token Plan login**: + +- 点 **Log in** 启动设备码 OAuth。Node 向固定的 `account.minimaxi.com` 或 `account.minimax.io` 申请设备码,校验返回的 URL 和 code,打开授权页,并在页面显示 URL 与 code。授权页本身在对应分区的平台站上(`platform.minimaxi.com` / `platform.minimax.io`);账号站仍然保留,因为旧响应指向那里。每个分区只接受自己的这两个来源。 +- 面板会按已存凭据或 CLI 配置里的分区发起登录。 +- 页面轮询 `GET /api/login`,Node 轮询账户服务的 token 接口;成功后把凭据写进 `/credentials.json`。token 不发给页面、不写日志、不进错误响应。 +- 登录和 token 刷新都不会写 CLI 配置;其中的 `api_key`、`base_url`、分区等字段保持用户留下的原样。 +- 取消或替换待完成的登录流程后,迟到的响应不会把凭据按另一个 region 写入。`dispose()` 会中止在途账户和额度请求。 +- 凭据写入是原子的、串行化的,使用带进程标识的临时文件,默认 0600。 + +`sk-api-` 开头的 key 是账户余额 key,不是 Token Plan key;插件会在发请求前拒绝,并退回手填总额。 + +被服务端拒绝的凭据会被指名,而不是笼统归为“接口拒绝”:`2049 invalid api key` 记为 `invalid_credential`,`1004`(请求里没带 secret key)记为 `missing_credential`,HTTP 401 或 403 记为 `unauthorized`。这些都能靠重新登录解决,所以面板会给出 **Token Plan login**,而不是把用户堵在死路上。多个主机给出的失败原因不同时,最终上报的是用户能动手解决的那一个。 + +响应归一化: + +- 瓶子使用当前周期(`current_interval_*`);周额度另作一个小标签显示。 +- `current_interval_usage_count` 在旧响应里表示“剩余”,在新响应里可能表示“已用”。有 `current_interval_remaining_percent` 时,取与该百分比相差不超过 1 个百分点的读法;两种读法都对不上,或百分比越界的行,会被忽略。没有百分比时保留旧读法。 +- `current_interval_status === 3`(不限量)或总额为 0 的行跳过;剩余值限制在 `[0, total]`。 +- `ratio = 剩余 / 总额`;液体不透明度等于这个比例。瓶身占用前 80% 的高度,最后 20% 填满瓶颈和两只耳朵,所以 100% 时液面到瓶口。 +- 只有所有展示模型都有可用的周额度数据时才显示周百分比;这里不应用套餐的周额度加成倍率。 + +### 2. 手填总额度 + +拿不到实时额度时,页面会让你填一个总额度(单位是 token),存在插件自己的 `dataDir/quota.json`。 + +- 已用量:**默认关闭。** 面板里有“读取 MiniMax Code 的本机用量”开关。只有打开后,才从 `<数据根目录>/v2/sqlite/runtime-state.sqlite` 的 `local_runtime_token_usage` 表读取本机当前自然月,按 `startMs <= ts < endMs` 汇总 `input_tokens + output_tokens + cache_read_tokens + cache_write_tokens`。reasoning token 不另加。数据库以只读方式打开,用 `VACUUM INTO` 复制到 `context.dataDir` 下的临时快照,查完删除;宿主数据库永远不会被写入。 +- 数据根目录只来自 `MINIMAX_DATA_DIR`、`MAVIS_HOME`、`~/.minimax`。瓶子不会从 `context.dataDir` 推断,也不会沿宿主目录树向上找。该路径和 schema 都是未公开的宿主布局;如果你的安装把数据放在别处,设置 `MINIMAX_DATA_DIR`。 +- 开关关闭时,数据库不会被打开、复制或读取:手填状态报 `usage_scan_off`,页面显示本机用量已关闭。 +- `剩余 = max(0, 总额 - 已用)`,`ratio = 剩余 / 总额`。 +- 用量超过手填总额时,会作为独立状态上报(`overBudget`、`overBy`),而不是被夹成一个看起来合理的空瓶。页面会说明用量比手填总额高出多少,并继续指向真实额度。 +- 这是原始 token 数,不是 Token Plan 的计数,两种来源的数字不要互相比较。 + +## 验证环境 + +- MiniMax Code 3.1.12.216,Windows 11:插件已安装,能从对话打开,悬浮的章鱼窗能渲染。 +- `surface.window` 要求 MiniMax Code 3.1.2 或更高版本。 +- Node.js 24.9.0,Windows 11:`node --test "tests/*.test.mjs"` 的 85 项插件测试全部通过;`node scripts/validate.mjs plugins/7723qqq/quota-bottle` 报告 OK。 +- 合成 HTTP 集成测试覆盖页面、拒绝外部 Host/Origin、写入手填总额、JSON body 限制、用量开关校验,以及 `dispose()` 释放监听端口。 +- 凭据测试先在 CLI 配置里放 `api_key`、`base_url` 和一个无关字段,再跑面板登录,断言 CLI 文件逐字节不变;刷新测试断言 CLI 自己的 token 不会被刷新或写入,而插件自己的 token 会刷新进 `dataDir/credentials.json`。 +- 状态测试断言“读取本机用量”开关关闭时不会打开宿主 SQLite,打开后才读取本月;打桩的额度接口覆盖按分区选站、回退到另一主机、被指名的凭据失败,以及余额 key 的提前拒绝;页面测试覆盖窗口拖拽几何与验证页来源,并保证错误码与“可登录修复”集合和 Node 侧目录保持一致。 +- 合成额度响应覆盖字段歧义、冲突行拒绝、只有百分比的行、不限量行和周额度汇总;合成 SQLite 数据库覆盖本月边界与 schema 缺失。 +- 本轮没有重跑完整的设备码授权;macOS/Linux 未验证。 + +## 数据与权限 + +- 读取的文件: + - `/credentials.json`:面板登录写入的 OAuth token。 + - `~/.mmx/config.json` 或 `$MMX_CONFIG_DIR/config.json`:只读。API key,或仍有效的 OAuth access token。读取依赖当前 `mmx` 布局,不是 Mini App 的正式接口。 + - `<数据根目录>/v2/sqlite/runtime-state.sqlite`:只读,仅在“读取本机用量”打开时读取。`VACUUM INTO` 会在 `context.dataDir` 下创建临时快照,查询后删除。该宿主 schema 与路径均依赖未公开布局。 + - 自己的 `miniapp/client/index.html`,以及 `dataDir` 下的 `quota.json`、`settings.json`。 +- 写入的文件: + - `/credentials.json`:面板登录与 OAuth 刷新。本包不写 CLI 或 Host 文件。 + - `/quota.json`:手填总额度;`/settings.json`:用量开关。两者都是同目录临时文件,再原子重命名。 + - `/.usage-*` 下的临时 SQLite 快照,仅在开关打开时创建,读取后删除。 +- 网络: + - `https://account.minimaxi.com` / `https://account.minimax.io`:设备码启动与轮询。 + - `https://api.minimaxi.com` / `https://api.minimax.io`:通过插件登录或 CLI 凭据读取 Token Plan 额度,或通过校验的 HTTPS MiniMax API 覆盖地址;不跟随重定向。 + - 回环 HTTP stub 仅在测试里通过 `MINIMAX_BASE_URL` 使用。 + - 只要配置里存在凭据,即使已保存手填总额,每次状态请求仍会先尝试实时额度;仅使用手填模式的配置不会触发实时网络请求。 +- 子进程:只有打开已校验授权页的系统浏览器(Windows `explorer.exe`、macOS `open`、Linux `xdg-open`)。额度和 OAuth 请求在进程内完成,没有 `mmx` 可执行文件,也不经过 shell。 +- Agent 轮次:无;没有声明 `agentAccess`。 +- 读取会话:无;没有 `session.read`。 +- 调用工具:无;没有 `toolAccess`。 +- 麦克风:不使用;没有 `deviceAccess`。 +- 密钥:token 只在 Node 进程和 `dataDir/credentials.json`(或用 CLI 回退时的 CLI 配置)里,不写页面、不写日志、不进错误响应。响应只暴露固定的错误码。 +- 源码与美术:实现和图标均为本包原创,见 `NOTICE.md`。 +- 持久状态:`dataDir` 下的 `credentials.json`、`quota.json`、`settings.json`;主题与语言偏好是视图状态,存在 `localStorage`。 + +## 文件 + +```text +.minimax-plugin/plugin.json 插件清单 +package.json Mini App 声明 +miniapp/miniapp.json 载荷目录、Node 入口、页面路由 +miniapp/client/index.html 页面,由 /bottle 提供 +miniapp/node/server.mjs Node 入口:start(context) → { dispose } +miniapp/node/quota.mjs 响应归一化、手填总额状态与额度入口 +miniapp/node/cache.mjs 带代际的 TTL 缓存 +miniapp/node/urls.mjs 主机、URL 与时间字段校验 +miniapp/node/net.mjs 不跟随重定向的带凭据请求 +miniapp/node/errors.mjs 固定错误码目录与“可登录修复”分类 +miniapp/node/auth/*.mjs CLI 配置读取、插件凭据存储与设备码实现 +miniapp/node/usage.mjs 可选、只读的本机 token 用量与 SQLite 快照 +miniapp/node/settings.mjs 用量开关,默认关闭 +miniapp/node/store.mjs 手填总额度,原子写入 dataDir +miniapp/node/miniapp-api.ts 运行时 context 类型声明 +tests/*.test.mjs 插件回归测试 +NOTICE.md 源码与美术归属说明 +icon.png 原创插件图标 +``` + +## 许可 + +[MIT](./LICENSE)。许可范围见 [NOTICE.md](./NOTICE.md)。 diff --git a/plugins/7723qqq/quota-bottle/icon.png b/plugins/7723qqq/quota-bottle/icon.png new file mode 100644 index 0000000..dc44beb Binary files /dev/null and b/plugins/7723qqq/quota-bottle/icon.png differ diff --git a/plugins/7723qqq/quota-bottle/miniapp/client/index.html b/plugins/7723qqq/quota-bottle/miniapp/client/index.html new file mode 100644 index 0000000..9563df4 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/client/index.html @@ -0,0 +1,1737 @@ + + + + + +Quota Bottle + + + + +
+
+
+

MiniMax Code · Token Plan

+

Quota Bottle

+
+
+
+ + +
+
+ + + +
+
+
+ +
+ + A glass octopus bottle whose liquid level shows the remaining quota + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

—

+

—

+
+ No data + + + +
+
+ +
+ — + — / — + + +
+
+ + + + + + + +
+ +

Off by default. When on, the bottle reads the local token counters in MiniMax Code's SQLite state to estimate this month's usage. It never writes to that database.

+ +
+ + + + + +
+ + Loading… +
+ + +
+ + + + diff --git a/plugins/7723qqq/quota-bottle/miniapp/miniapp.json b/plugins/7723qqq/quota-bottle/miniapp/miniapp.json new file mode 100644 index 0000000..b612ece --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/miniapp.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/bottle", + "window": { + "width": 220, + "height": 300, + "minWidth": 220, + "minHeight": 300, + "resizable": false, + "frame": false, + "transparent": true, + "alwaysOnTop": true, + "skipTaskbar": false + } + }, + "mcpEndpoints": [] +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/auth/auth.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/auth/auth.mjs new file mode 100644 index 0000000..1cea558 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/auth.mjs @@ -0,0 +1,169 @@ +// @ts-check + +import { saveCredentials } from './credentials.mjs'; +import { OAUTH_CLIENT } from './oauth-client.mjs'; +import { QuotaError } from '../errors.mjs'; +import { normalizeFlexibleExpiry } from '../urls.mjs'; + +const MAX_RETRIES = 2; +const RETRY_DELAY_MS = 500; +const REQUEST_TIMEOUT_MS = 10_000; +const REFRESH_BUFFER_MS = 5 * 60 * 1000; +/** @type {Map>} */ +const pendingRefreshes = new Map(); + +/** + * Store the panel login's credential in this package's own data directory. + * The user's CLI config is never written, so its api_key, base_url and OAuth + * token stay exactly as the user left them. + * @param {string} dataDir + * @param {'cn' | 'global'} region + * @param {Record} credentials + * @param {AbortSignal} [signal] + */ +export async function saveOauth(dataDir, region, credentials, signal) { + if (signal?.aborted) throw new Error('aborted'); + await saveCredentials(dataDir, { ...credentials, region }); +} + +/** + * Resolve a credential from the config snapshot supplied by the caller. + * @param {{ oauth?: (Record & { configPath?: string, storePath?: string, persist?: (updated: Record) => Promise }) | null, apiKey?: string }} snapshot + * @param {AbortSignal} [signal] + */ +export async function resolveCredentialFromSnapshot(snapshot, signal) { + const oauth = snapshot.oauth; + if (oauth && typeof oauth.access_token === 'string' && oauth.access_token) { + try { + const token = await ensureFreshToken(oauth, signal); + return { token, method: /** @type {'oauth'} */ ('oauth') }; + } catch (error) { + // A CLI-owned token is never refreshed. When it has expired, a valid API + // key beside it is still a credential the user configured, so use it + // instead of sending them through a login they do not need. + if (!(error instanceof QuotaError) || error.code !== 'oauth_expired' || !snapshot.apiKey) throw error; + } + } + if (typeof snapshot.apiKey === 'string' && snapshot.apiKey) { + return { token: snapshot.apiKey, method: /** @type {'api-key'} */ ('api-key') }; + } + throw new QuotaError('no_credential'); +} + +/** + * Refresh and persist a token approaching expiry. Concurrent callers share one + * network request and one credential write. Only a credential this package + * owns is persisted; a CLI-owned token is used while valid and then reported + * as expired instead of being rotated behind the CLI's back. + * @param {Record & { configPath?: string, storePath?: string, persist?: (updated: Record) => Promise }} creds + * @param {AbortSignal} [signal] + * @param {{ fetchImpl?: typeof fetch }} [deps] + */ +export async function ensureFreshToken(creds, signal, deps = {}) { + const expiresAt = new Date(String(creds.expires_at)).getTime(); + const accessToken = String(creds.access_token ?? ''); + const refreshToken = String(creds.refresh_token ?? ''); + const valid = Number.isFinite(expiresAt) && Date.now() < expiresAt - REFRESH_BUFFER_MS; + const persist = typeof creds.persist === 'function' ? creds.persist : null; + + if (!persist) { + if (valid && accessToken) return accessToken; + throw new QuotaError('oauth_expired'); + } + if (valid && accessToken) return accessToken; + if (!refreshToken) { + throw new QuotaError('oauth_expired'); + } + const region = creds.region === 'cn' || creds.region === 'global' ? creds.region : 'global'; + const key = JSON.stringify([String(creds.storePath ?? creds.configPath ?? 'plugin'), region, refreshToken]); + const existing = pendingRefreshes.get(key); + if (existing && !signal?.aborted) return existing; + const pending = (async () => { + const fresh = await refreshAccessToken(refreshToken, region, signal, deps); + const updated = { + access_token: fresh.access_token, + refresh_token: fresh.refresh_token, + expires_at: fresh.expires_at, + region, + }; + if (typeof creds.account === 'string') updated.account = creds.account; + if (signal?.aborted) throw new QuotaError('timeout'); + await persist(updated); + return fresh.access_token; + })(); + pendingRefreshes.set(key, pending); + try { + return await pending; + } finally { + if (pendingRefreshes.get(key) === pending) pendingRefreshes.delete(key); + } +} + +/** + * @param {string} refreshToken + * @param {'cn' | 'global'} region + * @param {AbortSignal} [signal] + * @param {{ fetchImpl?: typeof fetch }} [deps] + */ +async function refreshAccessToken(refreshToken, region, signal, deps = {}) { + /** @type {string | null} */ + let lastCode = null; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) { + if (signal?.aborted) throw new QuotaError('timeout'); + if (attempt > 0) await delay(RETRY_DELAY_MS * attempt, signal); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const onAbort = () => controller.abort(); + signal?.addEventListener('abort', onAbort, { once: true }); + const run = deps.fetchImpl ?? fetch; + try { + const response = await run(OAUTH_CLIENT.tokenUrl(region), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: OAUTH_CLIENT.clientId, + }), + signal: controller.signal, + redirect: 'error', + }); + if (response.status === 401 || response.status === 403) throw new QuotaError('oauth_expired'); + if (!response.ok) { + lastCode = response.status >= 500 ? 'network' : 'api_rejected'; + continue; + } + const body = await response.json(); + // A refreshed token is recognized by the token itself, so a renamed + // status field cannot turn a good refresh into an expired login. + if (typeof body?.access_token !== 'string' || !body.access_token) { + throw new QuotaError('oauth_expired'); + } + const expiredIn = normalizeFlexibleExpiry(body.expired_in); + if (!expiredIn) throw new QuotaError('invalid_response'); + return { + access_token: body.access_token, + refresh_token: typeof body.refresh_token === 'string' && body.refresh_token ? body.refresh_token : refreshToken, + expires_at: new Date(expiredIn).toISOString(), + }; + } catch (error) { + if (error instanceof QuotaError) throw error; + lastCode = signal?.aborted ? 'timeout' : 'network'; + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', onAbort); + } + } + throw new QuotaError(lastCode ?? 'network'); +} + +/** @param {number} ms @param {AbortSignal} [signal] */ +function delay(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new QuotaError('timeout')); + let onAbort; + const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); + onAbort = () => { clearTimeout(timer); reject(new QuotaError('timeout')); }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/auth/config.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/auth/config.mjs new file mode 100644 index 0000000..284d66b --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/config.mjs @@ -0,0 +1,128 @@ +// @ts-check + +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { normalizeHttpsUrl, quotaHostCandidates } from '../urls.mjs'; + +export { REGIONS, OAUTH_HOSTS } from './constants.mjs'; + +const CONFIG_DIR_NAME = '.mmx'; +const VALID_REGIONS = new Set(['global', 'cn']); +const VALID_OUTPUTS = new Set(['text', 'json']); + +export function getConfigDir() { + const dir = process.env.MMX_CONFIG_DIR?.trim(); + return dir || join(homedir(), CONFIG_DIR_NAME); +} + +export function getConfigPath() { + return join(getConfigDir(), 'config.json'); +} + +/** @param {unknown} value */ +export function normalizeRegion(value) { + return value === 'cn' || value === 'global' ? value : null; +} + +function parseOAuth(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const o = /** @type {Record} */ (raw); + if (typeof o.access_token !== 'string' || !o.access_token) return undefined; + if (typeof o.refresh_token !== 'string') return undefined; + if (typeof o.expires_at !== 'string') return undefined; + const out = /** @type {Record} */ ({ + access_token: o.access_token, + refresh_token: o.refresh_token, + expires_at: o.expires_at, + }); + if (typeof o.region === 'string' && VALID_REGIONS.has(o.region)) out.region = o.region; + if (typeof o.resource_url === 'string') { + const resource = normalizeHttpsUrl(o.resource_url, { purpose: 'api' }); + if (resource) out.resource_url = resource; + } + if (typeof o.account === 'string' && o.account.length > 0) out.account = o.account; + return out; +} + +/** @param {unknown} raw */ +export function parseConfigFile(raw) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + const obj = /** @type {Record} */ (raw); + const out = /** @type {Record} */ ({}); + if (typeof obj.api_key === 'string') out.api_key = obj.api_key; + if (typeof obj.region === 'string' && VALID_REGIONS.has(obj.region)) out.region = obj.region; + if (typeof obj.base_url === 'string') { + const base = normalizeHttpsUrl(obj.base_url, { purpose: 'api' }); + if (base) out.base_url = base; + } + if (typeof obj.output === 'string' && VALID_OUTPUTS.has(obj.output)) out.output = obj.output; + if (typeof obj.proxy === 'string') { + const proxy = normalizeHttpsUrl(obj.proxy, { purpose: 'api' }); + if (proxy) out.proxy = proxy; + } + const oauth = parseOAuth(obj.oauth); + if (oauth) out.oauth = oauth; + for (const key of ['default_text_model', 'default_speech_model', 'default_video_model', 'default_music_model']) { + if (typeof obj[key] === 'string' && obj[key].length > 0) out[key] = obj[key]; + } + return out; +} + +/** + * Read the CLI config for a user-configured credential. This module only + * reads it: the panel login stores its own credentials under dataDir, and a + * credential found here is never rewritten. + * @param {string} [path] + * @returns {Promise<{ ok: true, raw: Record } | { ok: false, code: string }>} + */ +export async function readRawConfig(path = getConfigPath()) { + let text; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + const code = /** @type {any} */ (error)?.code; + if (code === 'ENOENT') return { ok: true, raw: {} }; + return { ok: false, code: 'config_unreadable' }; + } + try { + const raw = JSON.parse(text); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, code: 'config_invalid' }; + return { ok: true, raw: /** @type {Record} */ (raw) }; + } catch { + return { ok: false, code: 'config_invalid' }; + } +} + +/** + * Build a runtime view from one raw config read. Callers that already read the + * file pass `raw` so a request never resolves credentials from a second, + * possibly changed snapshot. `regionOverride` is the region stored with the + * plugin's own login; an explicit environment value still wins. + * @param {Record} [raw] + * @param {NodeJS.ProcessEnv} [env] + * @param {'cn' | 'global' | null} [regionOverride] + */ +export function configFromRaw(raw = {}, env = process.env, regionOverride = null) { + const file = parseConfigFile(raw); + const explicitRegion = env.MINIMAX_REGION; + const region = explicitRegion === 'cn' || explicitRegion === 'global' + ? explicitRegion + : normalizeRegion(regionOverride) ?? normalizeRegion(file.region) ?? 'global'; + const envBase = env.MINIMAX_BASE_URL + ? normalizeHttpsUrl(env.MINIMAX_BASE_URL, { purpose: 'api', allowHttpLoopback: true }) + : null; + // An explicit override wins outright. Otherwise the region picks the host and + // the other MiniMax host stays a fallback, so a stale region no longer + // decides which API answers the quota request. + const baseUrls = envBase + ? [envBase] + : quotaHostCandidates(region, file.base_url ?? file.oauth?.resource_url ?? null); + return { + region, + baseUrl: baseUrls[0], + baseUrls, + timeout: 20, + }; +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/auth/constants.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/auth/constants.mjs new file mode 100644 index 0000000..d0c116e --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/constants.mjs @@ -0,0 +1,24 @@ +// @ts-check + +export const REGIONS = Object.freeze({ + global: 'https://api.minimax.io', + cn: 'https://api.minimaxi.com', +}); + +export const OAUTH_HOSTS = Object.freeze({ + global: 'https://account.minimax.io', + cn: 'https://account.minimaxi.com', +}); + +/** + * Origins a device-code verification page may live on. + * + * The account service answers with the region's platform host + * (`platform.minimaxi.com` / `platform.minimax.io`); the account host stays + * allowed because older responses point there. Each list is bound to one + * region, so a response can never point the user at another region's page. + */ +export const OAUTH_VERIFY_ORIGINS = Object.freeze({ + global: Object.freeze(['https://account.minimax.io', 'https://platform.minimax.io']), + cn: Object.freeze(['https://account.minimaxi.com', 'https://platform.minimaxi.com']), +}); diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/auth/credentials.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/auth/credentials.mjs new file mode 100644 index 0000000..c96836b --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/credentials.mjs @@ -0,0 +1,90 @@ +// @ts-check + +import { randomUUID } from 'node:crypto'; +import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { normalizeRegion, parseConfigFile } from './config.mjs'; + +/** + * OAuth credentials written by the panel's device-code login belong to this + * package, not to the user's CLI configuration. The file lives under + * context.dataDir and only ever holds the `oauth` object and its region. + */ +const FILE_NAME = 'credentials.json'; + +/** @type {Map>} */ +const writeQueues = new Map(); + +/** @param {string} dataDir */ +export function credentialsPath(dataDir) { + return join(dataDir, FILE_NAME); +} + +/** @param {string} path @param {() => Promise} task */ +async function withLock(path, task) { + const previous = writeQueues.get(path) ?? Promise.resolve(); + const next = previous.catch(() => {}).then(task); + const settled = next.catch(() => {}); + writeQueues.set(path, settled); + try { + return await next; + } finally { + if (writeQueues.get(path) === settled) writeQueues.delete(path); + } +} + +/** + * Read the credential file this package owns. A missing file is not an error: + * it means the panel login has not run yet. + * @param {string} dataDir + * @returns {Promise<{ ok: true, oauth: Record | null, region: 'cn' | 'global' | null } | { ok: false, code: string }>} + */ +export async function readCredentials(dataDir) { + let text; + try { + text = await readFile(credentialsPath(dataDir), 'utf8'); + } catch (error) { + if (/** @type {any} */ (error)?.code === 'ENOENT') return { ok: true, oauth: null, region: null }; + return { ok: false, code: 'config_unreadable' }; + } + let raw; + try { + raw = JSON.parse(text); + } catch { + return { ok: false, code: 'config_invalid' }; + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, code: 'config_invalid' }; + const parsed = parseConfigFile(raw); + const oauth = parsed.oauth ? /** @type {Record} */ (parsed.oauth) : null; + return { + ok: true, + oauth, + region: normalizeRegion(raw.region) ?? normalizeRegion(oauth?.region), + }; +} + +/** + * Write OAuth credentials into this package's data directory. Only the + * credential file changes; no CLI or Host file is touched. + * @param {string} dataDir + * @param {Record} oauth + */ +export async function saveCredentials(dataDir, oauth) { + if (typeof oauth?.access_token !== 'string' || !oauth.access_token) throw new Error('credentials'); + const path = credentialsPath(dataDir); + return withLock(path, async () => { + const region = normalizeRegion(oauth.region); + const payload = { oauth: region ? { ...oauth, region } : oauth, region }; + await mkdir(dataDir, { recursive: true, mode: 0o700 }); + const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); + await chmod(tmp, 0o600); + await rename(tmp, path); + } catch (error) { + await unlink(tmp).catch(() => {}); + throw error; + } + }); +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/auth/oauth-client.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/auth/oauth-client.mjs new file mode 100644 index 0000000..9e24fc4 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/oauth-client.mjs @@ -0,0 +1,10 @@ +// @ts-check + +import { OAUTH_HOSTS } from './constants.mjs'; + +export const OAUTH_CLIENT = Object.freeze({ + clientId: '659cf4c1-615c-45f6-a5f6-4bf15eb476e5', + scopes: Object.freeze(['openid', 'profile', 'coding_plan']), + deviceCodeUrl: (/** @type {'cn' | 'global'} */ region) => `${OAUTH_HOSTS[region]}/oauth2/device/code`, + tokenUrl: (/** @type {'cn' | 'global'} */ region) => `${OAUTH_HOSTS[region]}/oauth2/token`, +}); diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/auth/oauth.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/auth/oauth.mjs new file mode 100644 index 0000000..cc4efe2 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/oauth.mjs @@ -0,0 +1,187 @@ +// @ts-check + +import { spawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; + +import { QuotaError } from '../errors.mjs'; +import { normalizeAbsoluteExpiry, normalizeFlexibleExpiry, normalizePollInterval, normalizeUserCode, normalizeVerificationUrl, oauthRegionForUrl } from '../urls.mjs'; +import { OAUTH_CLIENT } from './oauth-client.mjs'; + +const REQUEST_TIMEOUT_MS = 12_000; + +/** + * Start a device authorization grant. Every value the network returns is + * validated before it is shown to the user or passed to a browser. + * @param {'cn' | 'global'} region + * @param {AbortSignal} [signal] + */ +export async function startDeviceLogin(region, signal) { + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const state = randomBytes(16).toString('base64url'); + const controller = combinedSignal(signal, REQUEST_TIMEOUT_MS); + let response; + try { + response = await fetch(OAUTH_CLIENT.deviceCodeUrl(region), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: OAUTH_CLIENT.clientId, + scope: OAUTH_CLIENT.scopes.join(' '), + code_challenge: challenge, + code_challenge_method: 'S256', + state, + }), + signal: controller.signal, + redirect: 'error', + }); + } catch { + throw new QuotaError(signal?.aborted ? 'timeout' : 'network'); + } finally { + controller.cleanup(); + } + if (!response.ok) throw new QuotaError(response.status >= 500 ? 'network' : 'api_rejected'); + let data; + try { + data = await response.json(); + } catch { + throw new QuotaError('invalid_response'); + } + if (data?.state !== state) throw new QuotaError('invalid_response'); + const verificationUri = normalizeVerificationUrl(data.verification_uri, region); + const userCode = normalizeUserCode(data.user_code); + const expiresAt = normalizeAbsoluteExpiry(Number(data.expired_in)); + const intervalMs = normalizePollInterval(data.interval); + if (!verificationUri || !userCode || !expiresAt || !intervalMs) throw new QuotaError('invalid_response'); + return { + region, + verifier, + userCode, + verificationUri, + expiresAt, + intervalMs, + lastPollAt: 0, + status: 'pending', + }; +} + +/** + * Poll once. A transient network failure keeps the flow pending; an + * authorization response is validated before credentials are returned. + * @param {any} flow + * @param {AbortSignal} [signal] + * @param {{ fetchImpl?: typeof fetch }} [deps] + */ +export async function pollDeviceLogin(flow, signal, deps = {}) { + if (flow.status !== 'pending') return { status: flow.status === 'cancelled' ? 'idle' : flow.status }; + const now = Date.now(); + if (now >= flow.expiresAt) { + flow.status = 'expired'; + return { status: 'expired' }; + } + if (now < flow.lastPollAt + flow.intervalMs) return { status: 'pending' }; + flow.lastPollAt = now; + const controller = combinedSignal(signal, REQUEST_TIMEOUT_MS); + const run = deps.fetchImpl ?? fetch; + let response; + try { + response = await run(OAUTH_CLIENT.tokenUrl(flow.region), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: OAUTH_CLIENT.clientId, + user_code: flow.userCode, + code_verifier: flow.verifier, + }), + signal: controller.signal, + redirect: 'error', + }); + } catch { + if (signal?.aborted) { + flow.status = 'error'; + return { status: 'error' }; + } + return { status: 'pending' }; + } finally { + controller.cleanup(); + } + let data; + try { + data = await response.json(); + } catch { + flow.status = 'error'; + return { status: 'error' }; + } + // The service has answered with more than one shape: a granted token, a + // pending grant, and a refused grant. A token is the only thing that makes + // the flow succeed, and it is recognized by its own presence, so a renamed + // status field cannot turn a granted login into a failure. + const token = typeof data?.access_token === 'string' ? data.access_token : ''; + if (response.ok && token) { + const expiredIn = normalizeFlexibleExpiry(data?.expired_in); + if (!expiredIn) { + flow.status = 'error'; + return { status: 'error' }; + } + flow.status = 'success'; + return { + status: 'success', + credentials: { + access_token: token, + refresh_token: typeof data.refresh_token === 'string' ? data.refresh_token : '', + expires_at: new Date(expiredIn).toISOString(), + region: flow.region, + }, + }; + } + const code = typeof data?.error === 'string' ? data.error : typeof data?.status === 'string' ? data.status : ''; + if (code === 'authorization_pending' || code === 'pending') return { status: 'pending' }; + if (code === 'slow_down') { + flow.intervalMs = Math.min(60_000, flow.intervalMs + 2000); + return { status: 'pending' }; + } + flow.status = 'error'; + return { status: 'error' }; +} + +/** + * Open the validated verification page using a platform opener. Arguments are + * passed as an array and the process is never routed through a shell. + * @param {string} url + */ +export function openBrowser(url) { + const normalized = normalizeVerificationUrl(url, oauthRegionForUrl(url)); + if (!normalized) return; + try { + const command = process.platform === 'win32' + ? 'explorer.exe' + : process.platform === 'darwin' + ? 'open' + : 'xdg-open'; + const child = spawn(command, [normalized], { detached: true, stdio: 'ignore', windowsHide: true }); + child.on('error', () => {}); + child.unref(); + } catch { + // The page shows the same link, so the login can continue by hand. + } +} + +/** + * @param {AbortSignal} [signal] + * @param {number} timeoutMs + */ +function combinedSignal(signal, timeoutMs) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const onAbort = () => controller.abort(); + if (signal?.aborted) controller.abort(); + else signal?.addEventListener('abort', onAbort, { once: true }); + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeout); + signal?.removeEventListener('abort', onAbort); + }, + }; +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/auth/quota.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/auth/quota.mjs new file mode 100644 index 0000000..3645c3c --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/quota.mjs @@ -0,0 +1,108 @@ +// @ts-check + +import { loadQuotaConfig } from './snapshot.mjs'; +import { resolveCredentialFromSnapshot } from './auth.mjs'; +import { QuotaError, pickMostSpecific } from '../errors.mjs'; +import { fetchWithoutRedirect } from '../net.mjs'; + +const QUOTA_PATH = '/v1/token_plan/remains'; + +/** @param {string} apiKey */ +export function isSecretApiKey(apiKey) { + return apiKey.startsWith('sk-api-'); +} + +/** @param {string} baseUrl */ +export function quotaEndpoint(baseUrl) { + return `${baseUrl}${QUOTA_PATH}`; +} + +/** + * Read a signed-in user's Token Plan quota. Account-balance API keys have no + * total, so they are reported as a fallback condition rather than queried. + * + * The request is sent to every candidate host in turn: a credential that one + * host rejects may belong to the other region, and a host that refuses the + * credential must not hide a host that accepts it. The failure that gets + * reported is the one the user can act on. + * @param {string} dataDir + * @param {AbortSignal} [signal] + * @param {{ fetchImpl?: typeof fetch }} [deps] + */ +export async function fetchQuota(dataDir, signal, deps = {}) { + const snapshot = await loadQuotaConfig(dataDir); + if (!snapshot.ok) throw new QuotaError(snapshot.code); + const { config } = snapshot; + const credential = await resolveCredentialFromSnapshot({ + oauth: snapshot.credentials, + apiKey: snapshot.apiKey, + }, signal); + if (credential.method === 'api-key' && isSecretApiKey(credential.token)) { + throw new QuotaError('balance_credential'); + } + const hosts = Array.isArray(config.baseUrls) && config.baseUrls.length > 0 + ? config.baseUrls + : [config.baseUrl]; + /** @type {string[]} */ + const failures = []; + for (const host of hosts) { + try { + return await readQuota(host, credential.token, signal, config.timeout * 1000, deps.fetchImpl); + } catch (error) { + if (!(error instanceof QuotaError)) throw error; + failures.push(error.code); + // A transport failure says nothing about the host, so another host is + // not worth trying; a rejected credential might be another host's. + if (error.code === 'network' || error.code === 'timeout') break; + } + } + throw new QuotaError(pickMostSpecific(failures)); +} + +/** + * @param {string} baseUrl + * @param {string} token + * @param {AbortSignal} [signal] + * @param {number} timeoutMs + * @param {typeof fetch} [fetchImpl] + */ +async function readQuota(baseUrl, token, signal, timeoutMs, fetchImpl) { + const response = await fetchWithoutRedirect(quotaEndpoint(baseUrl), { + method: 'GET', + headers: { + 'User-Agent': 'quota-bottle/1.15.3', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }, signal, timeoutMs, fetchImpl); + if (!response.ok) throw new QuotaError(mapStatus(response.status)); + let data; + try { + data = await response.json(); + } catch { + throw new QuotaError('invalid_response'); + } + const code = data?.base_resp?.status_code; + if (typeof code === 'number' && code !== 0) throw new QuotaError(mapBaseCode(code)); + return data; +} + +/** @param {number} status */ +function mapStatus(status) { + if (status === 401 || status === 403) return 'unauthorized'; + if (status === 429 || status === 402) return 'quota_exhausted'; + if (status === 408 || status === 504) return 'timeout'; + return 'http_error'; +} + +/** + * The service answers a refused credential inside a 200 response. Each code + * means something different, and only one of them is fixed by logging in. + * @param {number} code + */ +function mapBaseCode(code) { + if (code === 2049) return 'invalid_credential'; + if (code === 1004) return 'missing_credential'; + if (code === 1028 || code === 1030 || code === 2061) return 'quota_exhausted'; + return 'api_rejected'; +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/auth/snapshot.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/auth/snapshot.mjs new file mode 100644 index 0000000..d0d587b --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/snapshot.mjs @@ -0,0 +1,52 @@ +// @ts-check + +import { configFromRaw, getConfigPath, parseConfigFile, readRawConfig } from './config.mjs'; +import { credentialsPath, readCredentials, saveCredentials } from './credentials.mjs'; + +/** + * Resolve the credential and host list from one read of each store. + * + * The plugin's own login wins. A credential already present in the CLI config + * is a fallback and stays read-only: it is never rewritten or refreshed, so a + * rotation here cannot invalidate the CLI's own session. + * @param {string} dataDir + * @returns {Promise<{ ok: true, config: ReturnType, credentials: Record | null, apiKey: string | undefined } | { ok: false, code: string }>} + */ +export async function loadQuotaConfig(dataDir) { + const stored = await readCredentials(dataDir); + // A damaged credential file is only this package's login cache. Ignore it + // and let the panel login replace it, rather than blocking a CLI credential. + const plugin = stored.ok ? stored : { oauth: null, region: null }; + const cli = await readRawConfig(); + if (!cli.ok && !plugin.oauth) return cli; + const raw = cli.ok ? cli.raw : {}; + const file = parseConfigFile(raw); + /** @type {Record | null} */ + let credentials = null; + if (plugin.oauth) { + credentials = { + ...plugin.oauth, + storePath: credentialsPath(dataDir), + persist: (/** @type {Record} */ updated) => saveCredentials(dataDir, updated), + }; + } else if (file.oauth) { + credentials = { ...file.oauth, configPath: getConfigPath() }; + } + return { + ok: true, + config: configFromRaw(raw, process.env, plugin.region), + credentials, + apiKey: typeof file.api_key === 'string' ? file.api_key : undefined, + }; +} + +/** + * Read the region the current credential asks for, so a login can start + * against the right account service instead of whichever one the panel picked. + * @param {string} dataDir + * @returns {Promise<'cn' | 'global' | null>} + */ +export async function readConfiguredRegion(dataDir) { + const snapshot = await loadQuotaConfig(dataDir); + return snapshot.ok ? snapshot.config.region : null; +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/cache.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/cache.mjs new file mode 100644 index 0000000..be0c691 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/cache.mjs @@ -0,0 +1,49 @@ +// @ts-check + +/** + * A small TTL cache with a caller-owned version key. A force refresh starts a + * new generation, so its result cannot be satisfied by a request started under + * the old credentials or the previous month. + * @template T + */ +export class ValueCache { + /** @param {number} ttlMs @param {() => number} [now] */ + constructor(ttlMs, now = Date.now) { + this.ttlMs = ttlMs; + this.now = now; + this.generation = 0; + /** @type {{ key: string, at: number, value: T } | null} */ + this.cached = null; + /** @type {{ key: string, promise: Promise } | null} */ + this.inFlight = null; + } + + /** + * @param {boolean} fresh + * @param {() => Promise} load + * @param {string} [version] + */ + async get(fresh, load, version = '') { + if (fresh) { + this.generation += 1; + this.cached = null; + } + const generation = this.generation; + const key = `${generation}:${version}`; + if (this.cached?.key === key && this.now() - this.cached.at < this.ttlMs) return this.cached.value; + if (this.inFlight?.key === key) return this.inFlight.promise; + const promise = load().then((value) => { + if (this.generation === generation) this.cached = { key, at: this.now(), value }; + return value; + }).finally(() => { + if (this.inFlight?.key === key) this.inFlight = null; + }); + this.inFlight = { key, promise }; + return promise; + } + + clear() { + this.generation += 1; + this.cached = null; + } +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/errors.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/errors.mjs new file mode 100644 index 0000000..c1e2e5e --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/errors.mjs @@ -0,0 +1,101 @@ +// @ts-check + +export const ERROR_CODES = Object.freeze([ + 'config_invalid', + 'config_unreadable', + 'no_credential', + 'oauth_expired', + 'unauthorized', + 'invalid_credential', + 'missing_credential', + 'api_rejected', + 'quota_exhausted', + 'http_error', + 'network', + 'timeout', + 'invalid_response', + 'no_quota', + 'balance_credential', + 'usage_scan_off', + 'db_missing', + 'db_unreadable', + 'db_schema', + 'db_query_failed', + 'sqlite_unavailable', + 'unknown', +]); + +/** + * Failures a fresh Token Plan login can clear. A rejected credential belongs + * here: only a new login replaces it, so it is the one remedy worth offering. + */ +export const LOGIN_FIXABLE_CODES = Object.freeze([ + 'no_credential', + 'oauth_expired', + 'unauthorized', + 'invalid_credential', + 'missing_credential', + 'config_invalid', + 'config_unreadable', +]); + +/** + * Most actionable first. When several hosts fail differently, the report names + * the failure the user can act on instead of the last one that happened. + */ +const SPECIFICITY = Object.freeze([ + 'invalid_credential', + 'missing_credential', + 'unauthorized', + 'oauth_expired', + 'no_credential', + 'balance_credential', + 'quota_exhausted', + 'api_rejected', + 'http_error', + 'invalid_response', + 'config_invalid', + 'config_unreadable', + 'network', + 'timeout', + 'unknown', +]); + +const CODE_SET = new Set(ERROR_CODES); + +/** @param {unknown} code */ +export function safeErrorCode(code) { + return typeof code === 'string' && CODE_SET.has(code) ? code : 'unknown'; +} + +/** @param {unknown} code */ +export function isLoginFixable(code) { + return typeof code === 'string' && LOGIN_FIXABLE_CODES.includes(code); +} + +/** + * Pick the failure worth reporting out of several candidates. + * @param {string[]} codes + */ +export function pickMostSpecific(codes) { + let best = 'unknown'; + let bestRank = SPECIFICITY.length; + for (const code of codes) { + const rank = SPECIFICITY.indexOf(code); + const at = rank === -1 ? SPECIFICITY.length : rank; + if (at < bestRank) { + best = code; + bestRank = at; + } + } + return best; +} + +export class QuotaError extends Error { + /** @param {string} code */ + constructor(code) { + super(safeErrorCode(code)); + this.name = 'QuotaError'; + this.code = safeErrorCode(code); + } +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/miniapp-api.ts b/plugins/7723qqq/quota-bottle/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..f5d8b7a --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/miniapp-api.ts @@ -0,0 +1,397 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export type MiniAppAgentRunStatus = 'finished' | 'error' | 'aborted'; + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: JsonObject; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a Node-created session starts with. */ + readonly default: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed one rejects with `AGENT_OUTCOME_UNKNOWN`. */ + readonly status: MiniAppAgentRunStatus; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + */ +/** + * Every event except `run.queued` names its session, so `reply` can be called from `onEvent` even + * before the `run` that created the session has returned. `answer` cannot: the paused `run` still holds the + * session's single-run slot while `question.requested` is delivered, so call `answer` only after + * that `run` resolves (an earlier call rejects with `BUSY`). Ignore event types you do not know. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + /** A tool call is waiting for approval; decide it with `agent.reply`. */ + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + /** The turn paused on a questionnaire; continue with `agent.answer`. */ + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: MiniAppAgentRunStatus; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunOptions { + readonly signal?: AbortSignal; + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +export type MiniAppAgentErrorCode = + | 'NOT_PERMITTED' + | 'BUSY' + | 'SESSION_NOT_FOUND' + | 'INVALID_ARGUMENTS' + | 'AGENT_UNAVAILABLE' + | 'REQUEST_CANCELLED' + | 'AGENT_OUTCOME_UNKNOWN' + | 'REQUEST_NOT_FOUND'; + +export interface MiniAppAgentError extends Error { + readonly code: MiniAppAgentErrorCode; + /** + * True when nothing was started and the same call may be retried later. + * `AGENT_OUTCOME_UNKNOWN` is never retryable: the turn may have run. + */ + readonly retryable: boolean; + /** Set on every `BUSY`: the session already has a run in flight, or the wait queue is full. */ + readonly reason?: 'session_busy' | 'queue_full'; +} + +/** + * Present only when the manifest declares `agentAccess: ["session.run"]`. + * A new Node session uses the primary Agent unless `agent` names one from `agents()`, and works in + * this Mini App's own Agent directory and on the runtime default model; + * continuing a session keeps its Agent, directory and model (unless `model` switches it), including + * one a page created using its host chat's Agent, project directory and model; a session never + * changes its Agent. Runs one turn with that Agent's full capabilities + * (files, shell, Skills, MCP including this Mini App's own), and resolves when the turn ends. + * + * Interaction is yours to render: pass `onEvent` and handle `permission.requested` with + * `reply` and `question.requested` with `answer`. A permission request waits only while + * someone can answer it: this run passed `onEvent`, or a page of this Mini App currently has + * the session open in a chat side panel via `open`. Otherwise it is denied (the model is told + * why), and a questionnaire ends the turn with `result.pendingQuestion`. A run waiting on a + * permission is released by `cancel`/abort, process exit or republish. Up to `agentConcurrency` + * runs (default 4) per Mini App run at once; further `run` / `answer` calls wait in order (up to + * four times that many, see `run.queued`), and aborting a waiting call rejects it with + * `REQUEST_CANCELLED`. + */ +export interface MiniAppAgentClient { + run( + input: { + readonly text: string; + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a + * new session's model: it must be one `models()` lists; unusable is `INVALID_ARGUMENTS`, + * missing BYOK credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. Passing it + * with `sessionId` is `INVALID_ARGUMENTS`. + */ + readonly agent?: string; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Decide a pending permission request. Never writes the user's global rules. Node replies + * have 16 separate pending slots; `run` / `answer` / `models` / `agents` / `create` and Connector + * requests share another 16. Filling that ordinary budget still permits replies. A full reply + * budget rejects with retryable `AGENT_UNAVAILABLE`. + */ + reply(input: { + readonly sessionId: string; + readonly requestId: string; + readonly decision: 'allowOnce' | 'deny'; + }): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `AGENT_OUTCOME_UNKNOWN` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: { + readonly sessionId: string; + readonly requestId: string; + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `run`'s `model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + }, + options?: MiniAppAgentRunOptions, + ): Promise; + /** + * Returns the model catalog and runtime `default`. Pass `default` directly as `model` to + * `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, add a selected `variant` + * if needed and preserve `variant: ''`. If thinking is supported, select an effort from + * `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session (agent, this Mini App's Agent directory, the runtime default model) without + * running a turn, and return its `sessionId`; continue it with `run({ sessionId })`. A page's + * `create` binds the session to its chat instead, and its `sessionId` can be continued here. + * Takes no Host run slot but uses the ordinary Node request budget. `agent` picks the session's + * agent from `agents()`; a session never changes it. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; + readonly agent?: MiniAppAgentClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/net.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/net.mjs new file mode 100644 index 0000000..d5a525c --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/net.mjs @@ -0,0 +1,29 @@ +// @ts-check + +import { QuotaError } from './errors.mjs'; + +/** + * Fetch an account or quota endpoint without ever following a credentialed + * request to another URL. Every request has its own bounded timeout. + * @param {string} url + * @param {RequestInit & { headers?: Record }} init + * @param {AbortSignal} [signal] + * @param {number} [timeoutMs] + * @param {typeof fetch} [fetchImpl] + */ +export async function fetchWithoutRedirect(url, init, signal, timeoutMs = 20_000, fetchImpl = globalThis.fetch) { + if (signal?.aborted) throw new QuotaError('timeout'); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const onAbort = () => controller.abort(); + signal?.addEventListener('abort', onAbort, { once: true }); + try { + return await fetchImpl(url, { ...init, signal: controller.signal, redirect: 'error' }); + } catch (error) { + const aborted = signal?.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')); + throw new QuotaError(aborted ? 'timeout' : 'network'); + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', onAbort); + } +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/quota.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/quota.mjs new file mode 100644 index 0000000..2d15cc3 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/quota.mjs @@ -0,0 +1,222 @@ +// @ts-check + +import { QuotaError, safeErrorCode } from './errors.mjs'; +import { fetchQuota } from './auth/quota.mjs'; + +export { QuotaError, safeErrorCode, fetchQuota }; + +/** + * Resolve the ambiguous `*_usage_count` field using the server's remaining + * percentage. Without a percentage the historical "remaining" reading wins. + * @param {number} reported + * @param {number} total + * @param {number | null} remainingPercent + */ +export function resolveCounts(reported, total, remainingPercent) { + if (!Number.isSafeInteger(reported) || !Number.isSafeInteger(total)) return null; + if (total <= 0 || reported < 0 || reported > total) return null; + if (remainingPercent !== null && (!Number.isFinite(remainingPercent) || remainingPercent < 0 || remainingPercent > 100)) return null; + + let remaining = reported; + if (remainingPercent !== null) { + const asRemaining = (reported / total) * 100; + const asUsed = ((total - reported) / total) * 100; + const remainingGap = Math.abs(asRemaining - remainingPercent); + const usedGap = Math.abs(asUsed - remainingPercent); + if (Math.min(remainingGap, usedGap) > 1) return null; + if (usedGap < remainingGap) remaining = total - reported; + } + return { used: total - remaining, remaining, total }; +} + +/** + * Compute the manual-total fallback state. + * + * Usage above the saved total is a fact about two numbers, not an empty + * balance, so it is reported as its own state instead of being clamped into a + * believable-looking zero. The saved total is hand-written, so the state never + * pretends to be a measured quota. + * @param {number | null} total + * @param {number | null} used + */ +export function manualState(total, used) { + if (total === null) { + return { + mode: /** @type {'unset'} */ ('unset'), + total: null, + used, + remaining: null, + ratio: null, + percent: null, + overBudget: false, + overBy: 0, + }; + } + if (used === null) { + return { + mode: /** @type {'manual'} */ ('manual'), + total, + used: null, + remaining: null, + ratio: null, + percent: null, + overBudget: false, + overBy: 0, + }; + } + const overBudget = used > total; + const remaining = Math.max(0, total - used); + const ratio = Math.min(1, remaining / total); + return { + mode: /** @type {'manual'} */ ('manual'), + total, + used, + remaining, + ratio, + percent: ratio * 100, + overBudget, + overBy: overBudget ? used - total : 0, + }; +} + +/** + * Read one quota window from a response row. + * + * A window can arrive in two shapes: a counted window (`total_count` and + * `usage_count`) and a percentage-only window (`total_count` is 0 and only + * `remaining_percent` is set). A percentage-only window is a real quota and + * must not be dropped, so both shapes are read and reported side by side. + * + * @param {any} row + * @param {'interval' | 'weekly'} kind + */ +function readWindow(row, kind) { + const prefix = kind === 'interval' ? 'current_interval' : 'current_weekly'; + const status = numberOrNull(row[`${prefix}_status`]); + const unlimited = status === 3; + const total = finiteCount(row[`${prefix}_total_count`]); + const reported = numberOrNull(row[`${prefix}_usage_count`]); + const remainingPercent = percentOrNull(row[`${prefix}_remaining_percent`]); + const usedPercent = percentOrNull(row[`${prefix}_used_percent`]); + + // The counted shape wins when it is present: the percentage only resolves + // which direction the ambiguous count is read in. + const counts = total > 0 && reported !== null + ? resolveCounts(reported, total, remainingPercent) + : null; + + let remaining = counts ? counts.remaining : null; + let used = counts ? counts.used : null; + let percent = remainingPercent; + if (percent === null && usedPercent !== null) percent = 100 - usedPercent; + if (percent === null && counts) percent = (counts.remaining / counts.total) * 100; + + const resetKey = kind === 'interval' ? 'end_time' : 'weekly_end_time'; + const remainsKey = kind === 'interval' ? 'remains_time' : 'weekly_remains_time'; + return { + unlimited, + percent: percent === null ? null : clamp(percent, 0, 100), + usedPercent: percent === null ? null : clamp(100 - percent, 0, 100), + total: counts ? counts.total : null, + remaining, + used, + resetAt: numberOrNull(row[resetKey]), + remainsMs: numberOrNull(row[remainsKey]), + }; +} + +/** @param {any} payload */ +export function normalizeQuota(payload) { + const rows = Array.isArray(payload?.model_remains) ? payload.model_remains : []; + /** @type {any[]} */ + const models = []; + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + if (hasNumber(row.current_interval_remaining_percent) && percentOrNull(row.current_interval_remaining_percent) === null) continue; + + const interval = readWindow(row, 'interval'); + const weekly = readWindow(row, 'weekly'); + // A row with neither a percentage nor a count says nothing about the quota. + if (interval.percent === null && weekly.percent === null) continue; + + models.push({ + name: typeof row.model_name === 'string' && row.model_name ? row.model_name : 'model', + percent: interval.percent, + usedPercent: interval.usedPercent, + unlimited: interval.unlimited, + total: interval.total, + remaining: interval.remaining, + used: interval.used, + resetAt: interval.resetAt, + remainsMs: interval.remainsMs, + weekly: weekly.percent === null && !weekly.unlimited + ? null + : { + percent: weekly.percent, + usedPercent: weekly.usedPercent, + unlimited: weekly.unlimited, + total: weekly.total, + remaining: weekly.remaining, + used: weekly.used, + resetAt: weekly.resetAt, + remainsMs: weekly.remainsMs, + }, + }); + } + if (models.length === 0) return { ok: false, code: 'no_quota' }; + + // The plan's own panel reads the first row of `model_remains`; choosing a + // different row here would put a second number next to the same panel. + const text = models[0]; + const percent = text.percent ?? 0; + const ratio = clamp(percent / 100, 0, 1); + return { + ok: true, + total: text.total, + used: text.used, + remaining: text.remaining, + ratio, + percent, + usedPercent: text.usedPercent ?? 100 - percent, + models, + weekly: text.weekly, + primaryModel: text.name, + }; +} + +/** @param {unknown} value */ +function finiteCount(value) { + const n = numberOrNull(value); + return n === null || n < 0 || !Number.isSafeInteger(n) ? 0 : n; +} + +/** @param {unknown} value */ +function numberOrNull(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** @param {unknown} value */ +function hasNumber(value) { + return typeof value === 'number'; +} + +/** @param {unknown} value */ +function percentOrNull(value) { + const n = numberOrNull(value); + return n === null || n < 0 || n > 100 ? null : n; +} + +/** @param {number} value */ +function clamp(value, low, high) { + return Math.min(high, Math.max(low, value)); +} + +/** @param {Array} values */ +function earliest(values) { + let best = null; + for (const value of values) { + if (value === null || value <= 0) continue; + if (best === null || value < best) best = value; + } + return best; +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/server.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/server.mjs new file mode 100644 index 0000000..8456cad --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/server.mjs @@ -0,0 +1,408 @@ +// @ts-check + +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; + +import { ValueCache } from './cache.mjs'; +import { QuotaError, isLoginFixable, pickMostSpecific, safeErrorCode } from './errors.mjs'; +import { saveOauth } from './auth/auth.mjs'; +import { normalizeRegion } from './auth/config.mjs'; +import { openBrowser, pollDeviceLogin, startDeviceLogin } from './auth/oauth.mjs'; +import { readConfiguredRegion } from './auth/snapshot.mjs'; +import { fetchQuota, manualState, normalizeQuota } from './quota.mjs'; +import { readSettings, writeSettings } from './settings.mjs'; +import { createStore, normalizeTotal } from './store.mjs'; +import { monthWindow, readUsedTokens } from './usage.mjs'; + +// Three seconds keeps a pinned window close to live without adding load; the +// page polls every five seconds while pinned. +const LIVE_TTL_MS = 3_000; +const USAGE_TTL_MS = 15_000; +const BODY_LIMIT = 4096; +const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']); + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +/** @param {MiniAppContext} context @returns {Promise} */ +export async function start(context) { + const clientEntry = await readFile(join(context.pluginRoot, 'miniapp/client/index.html')); + const store = createStore(context.dataDir); + const service = new AbortController(); + if (context.signal.aborted) service.abort(); + + const liveCache = new ValueCache(LIVE_TTL_MS); + const usageCache = new ValueCache(USAGE_TTL_MS); + let settings = await readSettings(context.dataDir); + /** @type {any} */ + let loginFlow = null; + /** @type {Promise | null} */ + let loginRequest = null; + /** @type {Set>} */ + const tasks = new Set(); + let disposed = false; + + function track(task) { + if (disposed) return Promise.reject(new Error('disposed')); + tasks.add(task); + const tracked = task.finally(() => tasks.delete(task)); + return tracked; + } + + async function liveValue() { + try { + const payload = await fetchQuota(context.dataDir, service.signal); + const normalized = normalizeQuota(payload); + if (normalized.ok) { + context.logger.info('quota-bottle.live.ok', { source: 'credential', models: normalized.models.length }); + return { ...normalized, detail: 'token-plan' }; + } + context.logger.warn('quota-bottle.live.empty', { code: normalized.code }); + return { ok: false, code: safeErrorCode(normalized.code) }; + } catch (error) { + const code = error instanceof QuotaError ? error.code : 'unknown'; + context.logger.warn('quota-bottle.live.failed', { code }); + return { ok: false, code: safeErrorCode(pickMostSpecific([code])) }; + } + } + + async function liveState(fresh) { + return liveCache.get(fresh, () => track(liveValue())); + } + + async function usageState(window, fresh) { + if (!settings.usageScan) return { ok: false, code: 'usage_scan_off' }; + return usageCache.get(fresh, () => track(readUsedTokens(window, context)), window.label); + } + + async function buildState(fresh = false) { + const live = await liveState(fresh); + if (live.ok) { + // The plan's first row is the headline; a different row would be a + // second number next to the plan's own panel. + const primary = live.models[0]; + return { + generatedAt: Date.now(), + mode: 'live', + region: live.region, + usageScan: settings.usageScan, + total: live.total, + used: live.used, + remaining: live.remaining, + ratio: live.ratio, + percent: live.percent, + usedPercent: live.usedPercent, + period: null, + resetAt: primary?.resetAt ?? null, + remainsMs: primary?.remainsMs ?? null, + models: live.models, + weekly: live.weekly, + source: { kind: 'token-plan', label: 'Token Plan', detail: live.detail }, + problems: [], + needsLogin: false, + overBudget: false, + overBy: 0, + }; + } + + const manualTotal = await store.read(); + const window = monthWindow(); + const used = await usageState(window, fresh); + // The manual fallback has no live session to ask, so the configured region + // is the only hint available for the label. + const region = await readConfiguredRegion(context.dataDir); + const problems = [live.code]; + if (!used.ok) problems.push(used.code); + const base = { + generatedAt: Date.now(), + region, + usageScan: settings.usageScan, + period: window.label, + resetAt: window.endMs, + remainsMs: null, + models: [], + weekly: null, + source: { kind: 'local-tokens', label: 'Local usage', detail: 'this month' }, + problems, + // A rejected or missing credential is fixed by logging in, so the panel + // offers the login instead of leaving the user without a way forward. + needsLogin: problems.some((code) => isLoginFixable(code)), + }; + const manual = manualState(manualTotal, used.ok ? used.used : null); + return { ...base, ...manual }; + } + + const server = createServer((request, response) => { + if (!isLoopbackRequest(request, context.listen.port)) { + request.resume(); + return sendJson(response, 403, { error: 'forbidden' }); + } + let url; + try { + url = new URL(request.url ?? '/', 'http://miniapp.local'); + } catch { + return sendJson(response, 400, { error: 'bad_request' }); + } + const route = `${request.method ?? 'GET'} ${url.pathname}`; + const task = track(handleRoute(route, url, request, response, { + clientEntry, context, store, service, + getFlow: () => loginFlow, + setFlow: (value) => { loginFlow = value; }, + getLoginRequest: () => loginRequest, + setLoginRequest: (value) => { loginRequest = value; }, + setSettings: (value) => { settings = value; }, + clearCache: () => { liveCache.clear(); usageCache.clear(); }, + buildState, + })); + task.catch((error) => { + context.logger.warn('quota-bottle.route.failed', { name: /** @type {any} */ (error)?.name ?? 'Error' }); + sendJson(response, 500, { error: 'internal_error' }); + }); + }); + + await listen(server, context.listen.host, context.listen.port); + context.logger.info('quota-bottle.listening'); + + const dispose = async () => { + if (disposed) return; + disposed = true; + service.abort(); + loginFlow = null; + await Promise.allSettled([...tasks]); + await close(server); + }; + context.signal.addEventListener('abort', () => { void dispose(); }, { once: true }); + if (context.signal.aborted) await dispose(); + return { dispose }; +} + +/** @param {any} deps */ +async function handleRoute(route, url, request, response, deps) { + if (route === 'GET /bottle') { + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'self'; form-action 'none'; base-uri 'none'", + }); + response.end(deps.clientEntry); + return; + } + if (route === 'GET /api/state') { + const fresh = url.searchParams.has('fresh'); + const state = await deps.buildState(fresh); + sendJson(response, 200, state); + return; + } + if (route === 'POST /api/login') return handleLoginStart(request, response, deps); + if (route === 'GET /api/login') return handleLoginStatus(response, deps); + if (route === 'DELETE /api/login') { + const active = deps.getFlow(); + if (active) active.status = 'cancelled'; + deps.setFlow(null); + deps.setLoginRequest(null); + sendJson(response, 200, { status: 'idle' }); + return; + } + if (route === 'POST /api/quota') return handleQuotaPost(request, response, deps); + if (route === 'POST /api/settings') return handleSettingsPost(request, response, deps); + sendJson(response, 404, { error: 'not_found' }); +} + +/** @param {any} deps */ +async function handleLoginStart(request, response, deps) { + const body = await readJsonBody(request, response); + if (body === undefined) return; + const region = normalizeRegion(body?.region); + if (region === null) return sendJson(response, 400, { error: 'invalid_region' }); + if (deps.getLoginRequest()) { + const result = await deps.getLoginRequest(); + if (result?.ok) return sendJson(response, 409, { error: 'login_pending' }); + } + if (deps.getFlow()?.status === 'pending') return sendJson(response, 409, { error: 'login_pending' }); + const task = (async () => { + const flow = await startDeviceLogin(region, deps.service.signal); + if (deps.getLoginRequest() === task && !deps.service.signal.aborted) { + deps.setFlow(flow); + openBrowser(flow.verificationUri); + sendJson(response, 200, { + status: flow.status, + verificationUri: flow.verificationUri, + userCode: flow.userCode, + expiresAt: flow.expiresAt, + intervalMs: flow.intervalMs, + }); + return { ok: true, flow }; + } + return { ok: false, flow }; + })(); + deps.setLoginRequest(task); + try { + const result = await task; + if (deps.getLoginRequest() === task) { + deps.setLoginRequest(null); + if (!result.ok && !response.headersSent) sendJson(response, 502, { error: 'login_start_failed' }); + } + } catch (error) { + if (deps.getLoginRequest() === task) deps.setLoginRequest(null); + if (!response.headersSent) sendJson(response, 502, { error: 'login_start_failed' }); + deps.context.logger.warn('quota-bottle.login.start_failed', { code: error instanceof QuotaError ? error.code : 'unknown' }); + } +} + +/** @param {any} deps */ +async function handleLoginStatus(response, deps) { + const flow = deps.getFlow(); + if (!flow) return sendJson(response, 200, { status: 'idle' }); + try { + const result = await pollDeviceLogin(flow, deps.service.signal); + if (result.status === 'success' && result.credentials && deps.getFlow() === flow) { + await saveOauth(deps.context.dataDir, flow.region, result.credentials, deps.service.signal); + deps.clearCache(); + deps.setFlow(null); + } + sendJson(response, 200, { + status: result.status, + verificationUri: flow.verificationUri, + userCode: flow.userCode, + }); + } catch (error) { + deps.context.logger.warn('quota-bottle.login.poll_failed', { code: error instanceof QuotaError ? error.code : 'unknown' }); + sendJson(response, 500, { status: 'error' }); + } +} + +/** @param {any} deps */ +async function handleQuotaPost(request, response, deps) { + const body = await readJsonBody(request, response); + if (body === undefined) return; + const total = normalizeTotal(body?.total); + if (total === null) return sendJson(response, 400, { error: 'invalid_total' }); + try { + await deps.store.write(total); + deps.clearCache(); + sendJson(response, 200, { ok: true, total }); + } catch { + sendJson(response, 500, { error: 'write_failed' }); + } +} + +/** @param {any} deps */ +async function handleSettingsPost(request, response, deps) { + const body = await readJsonBody(request, response); + if (body === undefined) return; + if (typeof body?.usageScan !== 'boolean') return sendJson(response, 400, { error: 'invalid_settings' }); + try { + const next = await writeSettings(deps.context.dataDir, { usageScan: body.usageScan }); + deps.setSettings(next); + deps.clearCache(); + sendJson(response, 200, { ok: true, usageScan: next.usageScan }); + } catch { + sendJson(response, 500, { error: 'write_failed' }); + } +} + +/** @param {import('node:http').IncomingMessage} request @param {import('node:http').ServerResponse} response */ +async function readJsonBody(request, response) { + const type = String(request.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); + if (type !== 'application/json') { + sendJson(response, 415, { error: 'json_required' }); + return undefined; + } + let text; + try { + text = await readBody(request, BODY_LIMIT); + } catch { + request.resume(); + sendJson(response, 413, { error: 'body_too_large' }); + return undefined; + } + try { + const parsed = JSON.parse(text || '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('shape'); + return parsed; + } catch { + sendJson(response, 400, { error: 'invalid_json' }); + return undefined; + } +} + +/** @param {import('node:http').ServerResponse} response @param {number} status @param {any} body */ +function sendJson(response, status, body) { + if (response.destroyed || response.headersSent) return; + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }); + response.end(JSON.stringify(body)); +} + +/** @param {import('node:http').IncomingMessage} request @param {number} limit */ +function readBody(request, limit) { + return new Promise((resolve, reject) => { + let size = 0; + let done = false; + const chunks = []; + request.on('data', (chunk) => { + if (done) return; + size += chunk.length; + if (size > limit) { + done = true; + chunks.length = 0; + reject(new Error('too_large')); + return; + } + chunks.push(chunk); + }); + request.on('end', () => { + if (!done) { done = true; resolve(Buffer.concat(chunks).toString('utf8')); } + }); + request.on('error', (error) => { if (!done) { done = true; reject(error); } }); + }); +} + +/** @param {import('node:http').IncomingMessage} request @param {number} port */ +function isLoopbackRequest(request, port) { + const host = request.headers.host; + if (host !== undefined && !LOOPBACK_HOSTS.has(hostnameOf(host))) return false; + const origin = request.headers.origin; + if (origin === undefined) return true; + try { + const url = new URL(origin); + return url.protocol === 'http:' && LOOPBACK_HOSTS.has(hostnameOf(url.host)) && Number(url.port || 80) === port; + } catch { + return false; + } +} + +/** @param {string} hostHeader */ +function hostnameOf(hostHeader) { + const h = hostHeader.trim().toLowerCase(); + const bracketed = /^\[([^\]]*)\]/.exec(h); + if (bracketed) return bracketed[1]; + const colon = h.lastIndexOf(':'); + return colon === -1 ? h : h.slice(0, colon); +} + +/** @param {import('node:http').Server} server @param {string} host @param {number} port */ +function listen(server, host, port) { + return new Promise((resolve, reject) => { + const onError = (/** @type {Error} */ error) => reject(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolve(undefined); + }); + }); +} + +/** @param {import('node:http').Server} server */ +function close(server) { + return new Promise((resolve) => { + server.close(() => resolve(undefined)); + server.closeAllConnections?.(); + }); +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/settings.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/settings.mjs new file mode 100644 index 0000000..4db2b87 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/settings.mjs @@ -0,0 +1,47 @@ +// @ts-check + +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * Local usage is a read of MiniMax Code's own SQLite state, which depends on + * an unpublished Host schema. It stays off until the user turns it on. + */ +export const DEFAULT_SETTINGS = Object.freeze({ usageScan: false }); + +/** @param {string} dataDir */ +export function settingsPath(dataDir) { + return join(dataDir, 'settings.json'); +} + +/** @param {unknown} raw */ +export function normalizeSettings(raw) { + const value = raw && typeof raw === 'object' && !Array.isArray(raw) + ? /** @type {Record} */ (raw) + : {}; + return { usageScan: value.usageScan === true }; +} + +/** + * A missing or unreadable settings file means the safe default: no host read. + * @param {string} dataDir + */ +export async function readSettings(dataDir) { + try { + return normalizeSettings(JSON.parse(await readFile(settingsPath(dataDir), 'utf8'))); + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +/** @param {string} dataDir @param {{ usageScan: boolean }} settings */ +export async function writeSettings(dataDir, settings) { + const normalized = normalizeSettings(settings); + const file = settingsPath(dataDir); + await mkdir(dataDir, { recursive: true }); + const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(tmp, `${JSON.stringify(normalized)}\n`, 'utf8'); + await rename(tmp, file); + return normalized; +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/store.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/store.mjs new file mode 100644 index 0000000..690267e --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/store.mjs @@ -0,0 +1,45 @@ +// @ts-check + +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const MAX_TOTAL = 1e15; + +/** @param {unknown} value */ +export function normalizeTotal(value) { + const number = typeof value === 'string' ? Number(value.trim()) : value; + if (typeof number !== 'number' || !Number.isFinite(number)) return null; + if (!Number.isInteger(number) || number < 1 || number > MAX_TOTAL) return null; + return number; +} + +/** @param {string} dataDir */ +export function createStore(dataDir) { + const file = join(dataDir, 'quota.json'); + const tmp = `${file}.${process.pid}.tmp`; + let queue = Promise.resolve(); + + return { + /** @returns {Promise} */ + async read() { + try { + const parsed = JSON.parse(await readFile(file, 'utf8')); + return normalizeTotal(parsed?.total); + } catch { + return null; + } + }, + + /** @param {number} total */ + write(total) { + const next = queue.then(async () => { + await mkdir(dataDir, { recursive: true }); + await writeFile(tmp, `${JSON.stringify({ total })}\n`, 'utf8'); + await rename(tmp, file); + return total; + }); + queue = next.catch(() => {}); + return next; + }, + }; +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/urls.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/urls.mjs new file mode 100644 index 0000000..76d21e0 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/urls.mjs @@ -0,0 +1,122 @@ +// @ts-check + +import { OAUTH_HOSTS, OAUTH_VERIFY_ORIGINS, REGIONS } from './auth/constants.mjs'; + +const API_HOSTS = new Set(['api.minimaxi.com', 'api.minimax.io', 'api.minimax.cn']); +const ACCOUNT_HOSTS = new Set(Object.values(OAUTH_HOSTS).map((host) => new URL(host).hostname)); +const ALLOWED_HOSTS = new Set([...API_HOSTS, ...ACCOUNT_HOSTS, '127.0.0.1', 'localhost', '[::1]']); + +/** + * Accept a credential-bearing URL only when it is an HTTPS MiniMax host. A + * config entry may override the API host; loopback HTTP is allowed only for + * explicit local testing. + * @param {unknown} value + * @param {{ purpose: 'api' | 'account', allowHttpLoopback?: boolean }} options + */ +export function normalizeHttpsUrl(value, options) { + if (typeof value !== 'string' || value.length > 2048) return null; + let url; + try { + url = new URL(value); + } catch { + return null; + } + if (url.username || url.password || url.hash || url.search) return null; + const hostname = url.hostname.toLowerCase(); + const loopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'; + if (url.protocol === 'http:' && !(loopback && options.allowHttpLoopback === true)) return null; + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + if (options.purpose === 'api' && !API_HOSTS.has(hostname) && !loopback) return null; + if (options.purpose === 'account' && !ACCOUNT_HOSTS.has(hostname)) return null; + if (!ALLOWED_HOSTS.has(hostname) && !loopback) return null; + return url.origin + url.pathname.replace(/\/+$/, ''); +} + +/** + * Accept a verification URL returned by the account service. It must be an + * HTTPS URL on one of the region's verification origins: the account host, or + * the platform host the service answers with today. + * @param {unknown} value + * @param {'cn' | 'global'} region + */ +export function normalizeVerificationUrl(value, region) { + if (typeof value !== 'string' || value.length > 2048) return null; + let url; + try { + url = new URL(value); + } catch { + return null; + } + const allowed = OAUTH_VERIFY_ORIGINS[region] ?? []; + if (url.protocol !== 'https:' || !allowed.includes(url.origin) || url.username || url.password) return null; + url.hash = ''; + return url.href; +} + +/** @param {unknown} value */ +export function normalizeUserCode(value) { + return typeof value === 'string' && /^[A-Za-z0-9-]{4,32}$/.test(value) ? value : null; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +// A device code lives for minutes. An access token can be days out: the +// account service currently answers with an absolute timestamp about 48 hours +// ahead, so a token expiry gets a wider horizon than a device code. +const MAX_TOKEN_AHEAD_MS = 30 * DAY_MS; + +/** + * @param {unknown} value + * @param {number} [maxAheadMs] + */ +export function normalizeAbsoluteExpiry(value, maxAheadMs = DAY_MS) { + const now = Date.now(); + return typeof value === 'number' && Number.isFinite(value) && value > now && value < now + maxAheadMs + ? value + : null; +} + +/** + * Read an expiry that may arrive as an absolute timestamp or as a lifetime in + * seconds. A value at or above one trillion is an absolute millisecond + * timestamp; a smaller one is a lifetime in seconds. The account service uses + * both shapes, and its access token is valid for days rather than hours. + * @param {unknown} value + */ +export function normalizeFlexibleExpiry(value) { + const number = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(number) || number <= 0) return null; + if (number >= 1e12) return normalizeAbsoluteExpiry(number, MAX_TOKEN_AHEAD_MS); + return normalizeAbsoluteExpiry(Date.now() + number * 1000, MAX_TOKEN_AHEAD_MS); +} + +/** @param {unknown} value */ +export function normalizePollInterval(value) { + return typeof value === 'number' && Number.isInteger(value) && value >= 1000 && value <= 60_000 + ? value + : null; +} + +/** + * Hosts to try for a quota read, most likely first: the host the region + * points at, an explicitly configured host, then the other region's host so a + * stale region setting cannot hide a working credential. + * @param {'cn' | 'global'} region + * @param {string | null | undefined} configuredBaseUrl + */ +export function quotaHostCandidates(region, configuredBaseUrl) { + const configured = normalizeHttpsUrl(configuredBaseUrl, { purpose: 'api', allowHttpLoopback: true }); + const other = region === 'cn' ? 'global' : 'cn'; + const ordered = [REGIONS[region], configured, REGIONS[other]]; + return [...new Set(ordered.filter((host) => typeof host === 'string' && host.length > 0))]; +} + +/** + * @param {string} url + * @returns {'cn' | 'global'} + */ +export function oauthRegionForUrl(url) { + for (const [region, origins] of Object.entries(OAUTH_VERIFY_ORIGINS)) { + if (origins.some((origin) => url.startsWith(`${origin}/`))) return /** @type {'cn' | 'global'} */ (region); + } + return 'global'; +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/usage.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/usage.mjs new file mode 100644 index 0000000..91c2c95 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/usage.mjs @@ -0,0 +1,108 @@ +// @ts-check + +import { mkdir, rm, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +/** + * The Host data root comes only from variables the Host itself documents. + * This package never infers it by walking up from context.dataDir: that would + * make Mini App state depend on the Host's directory layout. + * @param {NodeJS.ProcessEnv} [env] + */ +export function dataRoot(env = process.env) { + const explicit = env.MINIMAX_DATA_DIR?.trim(); + if (explicit) return explicit; + const mavis = env.MAVIS_HOME?.trim(); + if (mavis) return mavis; + return join(homedir(), '.minimax'); +} + +/** @param {NodeJS.ProcessEnv} [env] */ +export function usageDbPath(env = process.env) { + return join(dataRoot(env), 'v2', 'sqlite', 'runtime-state.sqlite'); +} + +/** @param {Date} [now] */ +export function monthWindow(now = new Date()) { + const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0); + const end = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0); + return { + startMs: start.getTime(), + endMs: end.getTime(), + label: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}`, + }; +} + +/** + * Sum the runtime's token counters inside the local calendar month. The + * database is copied into a fresh snapshot node:sqlite can open read-only, + * including any WAL side files; the plugin never writes to the Host database. + * Callers must only reach this after the user turned the local usage scan on. + * @param {{ startMs: number, endMs: number }} window + * @param {MiniAppContext} context + * @param {NodeJS.ProcessEnv} [env] + * @param {{ sqlite?: any, now?: () => number }} [deps] + */ +export async function readUsedTokens({ startMs, endMs }, context, env = process.env, deps = {}) { + const file = usageDbPath(env); + const info = await stat(file).catch(() => null); + if (!info?.isFile()) return { ok: false, code: 'db_missing' }; + + let sqlite = deps.sqlite; + if (!sqlite) { + try { + sqlite = await import('node:sqlite'); + } catch { + return { ok: false, code: 'sqlite_unavailable' }; + } + } + + const stamp = (deps.now ?? Date.now)(); + const tempDir = join(context.dataDir, `.usage-${stamp}-${randomUUID()}`); + await mkdir(tempDir, { recursive: true, mode: 0o700 }); + const snapshot = join(tempDir, 'snapshot.sqlite'); + /** @type {any} */ + let source; + /** @type {any} */ + let db; + try { + source = new sqlite.DatabaseSync(file, { readOnly: true }); + source.prepare('VACUUM INTO ?').run(snapshot); + source.close(); + source = null; + db = new sqlite.DatabaseSync(snapshot, { readOnly: true }); + const schema = db.prepare(` + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'local_runtime_token_usage' + `).get(); + if (!schema) return { ok: false, code: 'db_schema' }; + const columns = new Set(db.prepare('PRAGMA table_info(local_runtime_token_usage)').all().map((row) => String(row.name))); + for (const required of ['ts', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens']) { + if (!columns.has(required)) return { ok: false, code: 'db_schema' }; + } + const row = db.prepare(` + SELECT + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + COUNT(*) AS rows + FROM local_runtime_token_usage + WHERE ts >= ? AND ts < ? + `).get(startMs, endMs); + const used = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens'] + .reduce((sum, key) => sum + Number(row[key] ?? 0), 0); + if (!Number.isFinite(used) || used < 0) return { ok: false, code: 'db_query_failed' }; + return { ok: true, used, rows: Number(row.rows) }; + } catch { + return { ok: false, code: 'db_unreadable' }; + } finally { + try { source?.close(); } catch {} + try { db?.close(); } catch {} + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +} + +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ diff --git a/plugins/7723qqq/quota-bottle/package.json b/plugins/7723qqq/quota-bottle/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/7723qqq/quota-bottle/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/7723qqq/quota-bottle/tests/cache.test.mjs b/plugins/7723qqq/quota-bottle/tests/cache.test.mjs new file mode 100644 index 0000000..2ed8886 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/cache.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ValueCache } from '../miniapp/node/cache.mjs'; + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test('ValueCache reuses a fresh result and shares one in-flight load', async () => { + let calls = 0; + let now = 0; + const cache = new ValueCache(1000, () => now); + const load = () => { calls += 1; return Promise.resolve(calls); }; + assert.equal(await cache.get(false, load, '2026-11'), 1); + assert.equal(await cache.get(false, load, '2026-11'), 1); + now = 2000; + assert.equal(await cache.get(false, load, '2026-11'), 2); + assert.equal(calls, 2); +}); + +test('ValueCache misses when the caller version changes', async () => { + let calls = 0; + const cache = new ValueCache(60_000, () => 0); + const load = () => Promise.resolve(++calls); + assert.equal(await cache.get(false, load, '2026-11'), 1); + assert.equal(await cache.get(false, load, '2026-12'), 2); +}); + +test('a force refresh does not reuse a request from the previous generation', async () => { + const oldLoad = deferred(); + const newLoad = deferred(); + const loads = [oldLoad, newLoad]; + const cache = new ValueCache(60_000, () => 0); + const first = cache.get(false, () => oldLoad.promise, 'v1'); + const second = cache.get(true, () => newLoad.promise, 'v1'); + newLoad.resolve('new'); + oldLoad.resolve('old'); + assert.equal(await second, 'new'); + assert.equal(await first, 'old'); + assert.equal(await cache.get(false, () => Promise.resolve('unexpected'), 'v1'), 'new'); + assert.equal(loads.length, 2); +}); + +test('clear invalidates a result without changing the caller version', async () => { + let calls = 0; + const cache = new ValueCache(60_000, () => 0); + const load = () => Promise.resolve(++calls); + assert.equal(await cache.get(false, load, 'same'), 1); + cache.clear(); + assert.equal(await cache.get(false, load, 'same'), 2); +}); diff --git a/plugins/7723qqq/quota-bottle/tests/client-messages.test.mjs b/plugins/7723qqq/quota-bottle/tests/client-messages.test.mjs new file mode 100644 index 0000000..491e9ab --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/client-messages.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { ERROR_CODES, LOGIN_FIXABLE_CODES } from '../miniapp/node/errors.mjs'; +import { packageRoot } from './helpers.mjs'; + +async function clientSource() { + return readFile(join(packageRoot, 'miniapp', 'client', 'index.html'), 'utf8'); +} + +/** The `errors` tables, English first and Chinese second. */ +function errorTables(source) { + const blocks = [...source.matchAll(/errors: \{([\s\S]*?)\n \},/gu)].map((match) => match[1]); + assert.equal(blocks.length, 2, 'expected one errors table per language'); + return blocks.map((block) => [...block.matchAll(/(\w+):\s*'/gu)].map((match) => match[1])); +} + +function loginCodes(source) { + const block = /const LOGIN_CODES = new Set\(\[([\s\S]*?)\]\);/.exec(source); + assert.ok(block, 'LOGIN_CODES block not found'); + return [...block[1].matchAll(/'([^']+)'/g)].map((match) => match[1]); +} + +test('every code the Node side can report has a message in both languages', async () => { + const [en, zh] = errorTables(await clientSource()); + for (const code of ERROR_CODES) { + assert.ok(en.includes(code), `English has no message for ${code}`); + assert.ok(zh.includes(code), `Chinese has no message for ${code}`); + } +}); + +test('the two message tables stay in step', async () => { + const [en, zh] = errorTables(await clientSource()); + assert.deepEqual([...en].sort(), [...zh].sort()); +}); + +test('the page offers the login for every failure a login can clear', async () => { + const codes = new Set(loginCodes(await clientSource())); + for (const code of LOGIN_FIXABLE_CODES) { + assert.ok(codes.has(code), `page does not offer the login for ${code}`); + } +}); + +test('the interface follows the client locale and can be switched', async () => { + const source = await clientSource(); + assert.match(source, /navigator\.language/u); + assert.match(source, /data-lang-choice="zh"/u); + assert.match(source, /data-lang-choice="en"/u); + assert.match(source, /function localize\(\)/u); + assert.match(source, /localize\(\);/u); +}); + +/** The whole STRINGS literal, evaluated: it holds nothing but strings. */ +function stringTables(source) { + const block = /const STRINGS = (\{[\s\S]*?\n \});/u.exec(source); + assert.ok(block, 'STRINGS not found'); + return new Function(`return (${block[1]});`)(); +} + +test('every key the page asks for exists in both languages', async () => { + const source = await clientSource(); + const tables = stringTables(source); + const used = new Set([...source.matchAll(/\bt\('([^']+)'/gu)].map((match) => match[1])); + // These three are reached through THEME_KEYS rather than a literal call. + for (const key of ['themeAuto', 'themeLight', 'themeDark']) used.add(key); + for (const key of used) { + assert.ok(key in tables.en, `English is missing ${key}`); + assert.ok(key in tables.zh, `Chinese is missing ${key}`); + } +}); diff --git a/plugins/7723qqq/quota-bottle/tests/config-snapshot.test.mjs b/plugins/7723qqq/quota-bottle/tests/config-snapshot.test.mjs new file mode 100644 index 0000000..9c102bf --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/config-snapshot.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { configFromRaw, readRawConfig } from '../miniapp/node/auth/config.mjs'; +import { credentialsPath, saveCredentials } from '../miniapp/node/auth/credentials.mjs'; +import { loadQuotaConfig } from '../miniapp/node/auth/snapshot.mjs'; +import { resolveCredentialFromSnapshot } from '../miniapp/node/auth/auth.mjs'; +import { disposeTemp, tempDir, writeJson } from './helpers.mjs'; + +test('configFromRaw derives region, base URL and API key from one snapshot', () => { + const config = configFromRaw({ + region: 'cn', + base_url: 'https://api.minimaxi.com/', + api_key: 'token-key', + custom: { keep: true }, + }, {}); + assert.equal(config.region, 'cn'); + assert.equal(config.baseUrl, 'https://api.minimaxi.com'); +}); + +test('the region picks the quota host when no base URL is configured', () => { + const config = configFromRaw({ region: 'cn', api_key: 'token-key' }, {}); + assert.equal(config.baseUrl, 'https://api.minimaxi.com'); + assert.deepEqual(config.baseUrls, ['https://api.minimaxi.com', 'https://api.minimax.io']); +}); + +test('an explicit base URL override replaces every candidate host', () => { + const config = configFromRaw({ region: 'cn' }, { MINIMAX_BASE_URL: 'http://127.0.0.1:8787/stub' }); + assert.deepEqual(config.baseUrls, ['http://127.0.0.1:8787/stub']); +}); + +test('the plugin login outranks the CLI credential and supplies the region', async () => { + const dir = await tempDir('quota-config-snapshot-'); + const previous = process.env.MMX_CONFIG_DIR; + try { + const dataDir = join(dir, 'data'); + const cliDir = join(dir, 'mmx'); + process.env.MMX_CONFIG_DIR = cliDir; + await mkdir(cliDir, { recursive: true }); + await writeJson(join(cliDir, 'config.json'), { + api_key: 'cli-key', + region: 'cn', + oauth: { access_token: 'cli-access', refresh_token: 'cli-refresh', expires_at: '2030-01-01T00:00:00.000Z', region: 'cn' }, + }); + await saveCredentials(dataDir, { + access_token: 'plugin-access', + refresh_token: 'plugin-refresh', + expires_at: '2030-01-01T00:00:00.000Z', + region: 'global', + }); + + const snapshot = await loadQuotaConfig(dataDir); + assert.equal(snapshot.ok, true); + assert.equal(snapshot.apiKey, 'cli-key'); + assert.equal(snapshot.credentials?.access_token, 'plugin-access'); + assert.equal(snapshot.credentials?.storePath, credentialsPath(dataDir)); + assert.equal(typeof snapshot.credentials?.persist, 'function'); + assert.equal(snapshot.config.region, 'global'); + assert.equal(snapshot.config.baseUrl, 'https://api.minimax.io'); + } finally { + if (previous === undefined) delete process.env.MMX_CONFIG_DIR; + else process.env.MMX_CONFIG_DIR = previous; + await disposeTemp(dir); + } +}); + +test('loadQuotaConfig reports corrupt CLI JSON instead of silently using no credentials', async () => { + const dir = await tempDir('quota-config-corrupt-snapshot-'); + const previous = process.env.MMX_CONFIG_DIR; + try { + const dataDir = join(dir, 'data'); + process.env.MMX_CONFIG_DIR = dir; + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'config.json'), '{', 'utf8'); + assert.deepEqual(await loadQuotaConfig(dataDir), { ok: false, code: 'config_invalid' }); + const raw = await readRawConfig(join(dir, 'config.json')); + assert.equal(raw.ok, false); + } finally { + if (previous === undefined) delete process.env.MMX_CONFIG_DIR; + else process.env.MMX_CONFIG_DIR = previous; + await disposeTemp(dir); + } +}); + +test('a damaged plugin credential file does not block a CLI API key', async () => { + const dir = await tempDir('quota-config-damaged-'); + const previous = process.env.MMX_CONFIG_DIR; + try { + const dataDir = join(dir, 'data'); + const cliDir = join(dir, 'mmx'); + process.env.MMX_CONFIG_DIR = cliDir; + await mkdir(cliDir, { recursive: true }); + await writeJson(join(cliDir, 'config.json'), { api_key: 'cli-key' }); + await mkdir(dataDir, { recursive: true }); + await writeFile(credentialsPath(dataDir), '{', 'utf8'); + const snapshot = await loadQuotaConfig(dataDir); + assert.equal(snapshot.ok, true); + assert.equal(snapshot.credentials, null); + assert.equal(snapshot.apiKey, 'cli-key'); + } finally { + if (previous === undefined) delete process.env.MMX_CONFIG_DIR; + else process.env.MMX_CONFIG_DIR = previous; + await disposeTemp(dir); + } +}); + +test('resolveCredentialFromSnapshot uses only the supplied credential', async () => { + const result = await resolveCredentialFromSnapshot({ oauth: null, apiKey: 'plain-key' }); + assert.deepEqual(result, { token: 'plain-key', method: 'api-key' }); + await assert.rejects(resolveCredentialFromSnapshot({ oauth: null }), (error) => error.code === 'no_credential'); +}); + +test('an expired CLI token falls back to the CLI API key instead of needing a login', async () => { + const result = await resolveCredentialFromSnapshot({ + oauth: { + access_token: 'expired', + refresh_token: 'refresh', + expires_at: '2020-01-01T00:00:00.000Z', + region: 'cn', + }, + apiKey: 'plain-key', + }); + assert.deepEqual(result, { token: 'plain-key', method: 'api-key' }); +}); diff --git a/plugins/7723qqq/quota-bottle/tests/config.test.mjs b/plugins/7723qqq/quota-bottle/tests/config.test.mjs new file mode 100644 index 0000000..a90f135 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/config.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { parseConfigFile, readRawConfig } from '../miniapp/node/auth/config.mjs'; +import { disposeTemp, tempDir, writeJson } from './helpers.mjs'; + +test('parseConfigFile rejects insecure API URLs and preserves safe fields', async () => { + const parsed = parseConfigFile({ + base_url: 'http://attacker.example/path', + oauth: { access_token: 'a', refresh_token: 'b', expires_at: '2030-01-01T00:00:00.000Z', resource_url: 'javascript:alert(1)' }, + future_field: 'keep me', + }); + assert.equal(parsed.base_url, undefined); + assert.equal(parsed.oauth.resource_url, undefined); + assert.equal(parsed.future_field, undefined); +}); + +test('readRawConfig keeps every field it finds, so a reader never rewrites the file', async () => { + const dir = await tempDir('quota-config-read-'); + try { + const file = join(dir, 'nested', 'config.json'); + await mkdir(join(dir, 'nested'), { recursive: true }); + await writeJson(file, { + api_key: 'keep-me', + base_url: 'https://api.minimaxi.com', + region: 'cn', + future_field: { nested: true }, + }); + const result = await readRawConfig(file); + assert.equal(result.ok, true); + assert.deepEqual(result.raw.future_field, { nested: true }); + assert.equal(result.raw.api_key, 'keep-me'); + } finally { + await disposeTemp(dir); + } +}); + +test('readRawConfig distinguishes a missing file from invalid JSON', async () => { + const dir = await tempDir('quota-config-read-missing-'); + try { + const missing = join(dir, 'missing.json'); + assert.deepEqual(await readRawConfig(missing), { ok: true, raw: {} }); + const broken = join(dir, 'broken.json'); + await writeFile(broken, '{', 'utf8'); + assert.deepEqual(await readRawConfig(broken), { ok: false, code: 'config_invalid' }); + } finally { + await disposeTemp(dir); + } +}); diff --git a/plugins/7723qqq/quota-bottle/tests/credentials.test.mjs b/plugins/7723qqq/quota-bottle/tests/credentials.test.mjs new file mode 100644 index 0000000..79873fb --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/credentials.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { readFile, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { saveOauth } from '../miniapp/node/auth/auth.mjs'; +import { credentialsPath, readCredentials, saveCredentials } from '../miniapp/node/auth/credentials.mjs'; +import { disposeTemp, readJson, tempDir, writeJson } from './helpers.mjs'; + +const EXPIRES = '2030-01-01T00:00:00.000Z'; + +test('the panel login writes the plugin store and leaves the CLI config byte for byte', async () => { + const dir = await tempDir('quota-credentials-'); + const previous = process.env.MMX_CONFIG_DIR; + try { + const dataDir = join(dir, 'data'); + const cliFile = join(dir, 'mmx', 'config.json'); + process.env.MMX_CONFIG_DIR = join(dir, 'mmx'); + await writeJson(cliFile, { + api_key: 'keep-me', + base_url: 'https://api.minimaxi.com', + region: 'cn', + future_field: { keep: true }, + }); + const before = await readFile(cliFile, 'utf8'); + + await saveOauth(dataDir, 'cn', { + access_token: 'access', + refresh_token: 'refresh', + expires_at: EXPIRES, + }); + + assert.equal(await readFile(cliFile, 'utf8'), before); + const stored = await readJson(credentialsPath(dataDir)); + assert.equal(stored.oauth.access_token, 'access'); + assert.equal(stored.oauth.region, 'cn'); + assert.equal(stored.region, 'cn'); + if (process.platform !== 'win32') { + assert.equal((await stat(credentialsPath(dataDir))).mode & 0o777, 0o600); + } + } finally { + if (previous === undefined) delete process.env.MMX_CONFIG_DIR; + else process.env.MMX_CONFIG_DIR = previous; + await disposeTemp(dir); + } +}); + +test('a missing credential file is not an error and a corrupt one is named', async () => { + const dir = await tempDir('quota-credentials-read-'); + try { + assert.deepEqual(await readCredentials(dir), { ok: true, oauth: null, region: null }); + await writeFile(credentialsPath(dir), '{', 'utf8'); + assert.deepEqual(await readCredentials(dir), { ok: false, code: 'config_invalid' }); + } finally { + await disposeTemp(dir); + } +}); + +test('a credential without an access token is refused before it reaches disk', async () => { + const dir = await tempDir('quota-credentials-empty-'); + try { + await assert.rejects(saveCredentials(dir, { access_token: '' }), /credentials/u); + assert.deepEqual(await readCredentials(dir), { ok: true, oauth: null, region: null }); + } finally { + await disposeTemp(dir); + } +}); + +test('concurrent writes leave one valid credential file', async () => { + const dir = await tempDir('quota-credentials-concurrent-'); + try { + await Promise.all([ + saveCredentials(dir, { access_token: 'one', refresh_token: 'r1', expires_at: EXPIRES, region: 'cn' }), + saveCredentials(dir, { access_token: 'two', refresh_token: 'r2', expires_at: EXPIRES, region: 'global' }), + ]); + const stored = await readCredentials(dir); + assert.equal(stored.ok, true); + assert.ok(stored.oauth.access_token === 'one' || stored.oauth.access_token === 'two'); + assert.ok(stored.region === 'cn' || stored.region === 'global'); + } finally { + await disposeTemp(dir); + } +}); diff --git a/plugins/7723qqq/quota-bottle/tests/float-pin.test.mjs b/plugins/7723qqq/quota-bottle/tests/float-pin.test.mjs new file mode 100644 index 0000000..a290679 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/float-pin.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { packageRoot } from './helpers.mjs'; + +async function clientSource() { + return readFile(join(packageRoot, 'miniapp', 'client', 'index.html'), 'utf8'); +} + +/** The body of one `addEventListener('click', ...)` handler. */ +function handlerBlock(source, marker) { + const start = source.indexOf(marker); + assert.notEqual(start, -1, `${marker} not found`); + const end = source.indexOf('\n });', start); + assert.notEqual(end, -1, `${marker} has no closing line`); + return source.slice(start, end); +} + +test('the panel offers one click to pin the bottle to the desktop', async () => { + const source = await clientSource(); + assert.equal(source.includes('id="pinBtn"'), true); + assert.equal(source.includes('id="pin"'), true); +}); + +test('the pin handler calls update in the same turn as the click', async () => { + const source = await clientSource(); + const handler = handlerBlock(source, "byId('pinBtn').addEventListener('click'"); + assert.match(handler, /windowHandle\.update\(\{ mode: 'window' \}\)/u); + // The Host accepts a mode change only inside a user gesture; an `await` + // before `update` would spend it. + assert.equal(handler.includes('await'), false); +}); + +test('the pin offer disappears once the window is up', async () => { + const source = await clientSource(); + assert.match(source, /byId\('pin'\)\.hidden = next === 'window' \|\| !windowHandle/u); +}); + +test('the window layout is applied when update resolves, not only through the subscription', async () => { + const source = await clientSource(); + const pin = handlerBlock(source, "byId('pinBtn').addEventListener('click'"); + assert.match(pin, /\.then\(\(\) => applyMode\('window'\)\)/u); + const exit = handlerBlock(source, "byId('floatExit').addEventListener('click'"); + assert.match(exit, /\.then\(\(\) => applyMode\('inline'\)\)/u); +}); + +test('click-through is not offered at all, so a window cannot become unclickable', async () => { + const source = await clientSource(); + // The Host refuses a flag change from the side panel (inline mode), so a + // window that turned click-through on had no way back. The feature is gone. + assert.equal(source.includes('clickThrough'), false); + assert.equal(source.includes('floatToggle'), false); + assert.equal(source.includes('passThrough'), false); +}); + +test('polling follows the mode: five seconds pinned, ten in the panel, one minute hidden', async () => { + const source = await clientSource(); + assert.match(source, /POLL_WINDOW_MS = 5000/u); + assert.match(source, /POLL_PANEL_MS = 10000/u); + assert.match(source, /POLL_HIDDEN_MS = 60000/u); + assert.match(source, /mode === 'window' \? POLL_WINDOW_MS : POLL_PANEL_MS/u); + // The old fixed start-up interval would override the mode-aware one. + assert.equal(source.includes('startPolling(60000)'), false); +}); + +test('pinning refreshes at once instead of waiting for the first poll', async () => { + const source = await clientSource(); + assert.match(source, /if \(next === 'window'\) refresh\(false\);/u); +}); + +test('pinning sends the mode alone, and a size repair only to a resizable window', async () => { + const source = await clientSource(); + const manifest = JSON.parse(await readFile(join(packageRoot, 'miniapp', 'miniapp.json'), 'utf8')); + const window = manifest.surface.window; + assert.equal(window.transparent, true); + assert.equal(window.frame, false); + // A non-resizable window is what keeps the transparency on Windows, and the + // Host refuses a mode switch that carries width/height in that case. + assert.equal(window.resizable, false); + assert.equal(window.width, 220); + assert.equal(window.height, 300); + assert.match(source, /windowHandle\.update\(\{ mode: 'window' \}\)/u); + // Repairing a remembered size needs a resizeable window, so it is gated. + assert.match(source, /win\.resizable === true/u); + assert.match(source, /windowHandle\.update\(windowPatch\(\)\)/u); + assert.match(source, /x: Math\.round\(left \+ Math\.max\(0, availWidth - width - 24\)\)/u); +}); diff --git a/plugins/7723qqq/quota-bottle/tests/helpers.mjs b/plugins/7723qqq/quota-bottle/tests/helpers.mjs new file mode 100644 index 0000000..845c2f9 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/helpers.mjs @@ -0,0 +1,52 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const packageRoot = fileURLToPath(new URL('..', import.meta.url)); + +export async function tempDir(prefix) { + return mkdtemp(join(tmpdir(), prefix)); +} + +export async function disposeTemp(dir) { + await rm(dir, { recursive: true, force: true }); +} + +export async function writeJson(path, value) { + await mkdir(join(path, '..'), { recursive: true }).catch(() => {}); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +export async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +export function fakeContext(root, { port = 0, signal = new AbortController().signal } = {}) { + const logs = []; + const logger = { + debug: (...args) => logs.push(args), + info: (...args) => logs.push(args), + warn: (...args) => logs.push(args), + error: (...args) => logs.push(args), + }; + return { + pluginId: 'quota-bottle', + pluginRoot: packageRoot, + dataDir: join(root, 'data'), + listen: { host: '127.0.0.1', port }, + signal, + logger, + logs, + }; +} + +export async function freePort() { + const { createServer } = await import('node:http'); + const server = createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + await new Promise((resolve) => server.close(resolve)); + return port; +} diff --git a/plugins/7723qqq/quota-bottle/tests/oauth.test.mjs b/plugins/7723qqq/quota-bottle/tests/oauth.test.mjs new file mode 100644 index 0000000..23c1a23 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/oauth.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { pollDeviceLogin } from '../miniapp/node/auth/oauth.mjs'; +import { ensureFreshToken } from '../miniapp/node/auth/auth.mjs'; +import { normalizeFlexibleExpiry } from '../miniapp/node/urls.mjs'; + +function jsonResponse(status, body) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +function pendingFlow(overrides = {}) { + return { + region: 'cn', + verifier: 'verifier', + userCode: 'ABCD-1234', + expiresAt: Date.now() + 10 * 60 * 1000, + intervalMs: 0, + lastPollAt: 0, + status: 'pending', + ...overrides, + }; +} + +test('a granted token is recognized by the token itself', async () => { + const flow = pendingFlow(); + const result = await pollDeviceLogin(flow, undefined, { + fetchImpl: async () => jsonResponse(200, { + base_resp: { status_code: 0, status_msg: 'success' }, + access_token: 'granted-token', + refresh_token: 'refresh', + expired_in: Date.now() + 3600 * 1000, + }), + }); + assert.equal(result.status, 'success'); + assert.equal(result.credentials.access_token, 'granted-token'); + assert.equal(result.credentials.region, 'cn'); + assert.ok(new Date(result.credentials.expires_at).getTime() > Date.now()); +}); + +test('a status field of success still works', async () => { + const flow = pendingFlow(); + const result = await pollDeviceLogin(flow, undefined, { + fetchImpl: async () => jsonResponse(200, { + status: 'success', + access_token: 'granted-token', + refresh_token: 'refresh', + expired_in: Date.now() + 3600 * 1000, + }), + }); + assert.equal(result.status, 'success'); +}); + +test('an expiry given as a lifetime in seconds is usable', async () => { + const flow = pendingFlow(); + const result = await pollDeviceLogin(flow, undefined, { + fetchImpl: async () => jsonResponse(200, { + access_token: 'granted-token', + expired_in: 3600, + }), + }); + assert.equal(result.status, 'success'); + const expiry = new Date(result.credentials.expires_at).getTime(); + assert.ok(expiry > Date.now() + 3000 * 1000); + assert.ok(expiry < Date.now() + 3600 * 1000 + 5000); +}); + +test('a pending grant stays pending, including the RFC 8628 refusal', async () => { + const pending200 = pendingFlow(); + const ok = await pollDeviceLogin(pending200, undefined, { + fetchImpl: async () => jsonResponse(200, { status: 'pending' }), + }); + assert.equal(ok.status, 'pending'); + assert.equal(pending200.status, 'pending'); + + const pending400 = pendingFlow(); + const refused = await pollDeviceLogin(pending400, undefined, { + fetchImpl: async () => jsonResponse(400, { error: 'authorization_pending' }), + }); + assert.equal(refused.status, 'pending'); + assert.equal(pending400.status, 'pending'); +}); + +test('a refused grant ends the flow', async () => { + const flow = pendingFlow(); + const result = await pollDeviceLogin(flow, undefined, { + fetchImpl: async () => jsonResponse(400, { error: 'invalid_grant', error_description: 'invalid or expired code' }), + }); + assert.equal(result.status, 'error'); + assert.equal(flow.status, 'error'); +}); + +test('a refresh that answers with a token succeeds without a status field', async () => { + /** @type {Record | null} */ + let saved = null; + const creds = { + access_token: 'old', + refresh_token: 'refresh', + expires_at: '2020-01-01T00:00:00.000Z', + region: 'cn', + persist: async (/** @type {Record} */ updated) => { saved = updated; }, + }; + const token = await ensureFreshToken(creds, undefined, { + fetchImpl: async () => jsonResponse(200, { + base_resp: { status_code: 0, status_msg: 'success' }, + access_token: 'fresh-token', + refresh_token: 'fresh-refresh', + expired_in: Date.now() + 3600 * 1000, + }), + }); + assert.equal(token, 'fresh-token'); + assert.equal(saved?.access_token, 'fresh-token'); + assert.equal(saved?.refresh_token, 'fresh-refresh'); +}); + +test('a CLI-owned token is used while valid and is never refreshed or written', async () => { + let calls = 0; + const fetchImpl = async () => { + calls += 1; + return jsonResponse(200, { access_token: 'unexpected', expired_in: 3600 }); + }; + const valid = new Date(Date.now() + 3600 * 1000).toISOString(); + assert.equal(await ensureFreshToken({ + access_token: 'valid-cli-token', + refresh_token: 'refresh', + expires_at: valid, + region: 'cn', + }, undefined, { fetchImpl }), 'valid-cli-token'); + assert.equal(calls, 0); + + await assert.rejects(ensureFreshToken({ + access_token: 'expired-cli-token', + refresh_token: 'refresh', + expires_at: '2020-01-01T00:00:00.000Z', + region: 'cn', + }, undefined, { fetchImpl }), (error) => error.code === 'oauth_expired'); + assert.equal(calls, 0); +}); + +test('normalizeFlexibleExpiry reads both a timestamp and a lifetime', () => { + const now = Date.now(); + assert.equal(normalizeFlexibleExpiry(now + 60_000), now + 60_000); + assert.ok(Math.abs(normalizeFlexibleExpiry(600) - (now + 600_000)) < 2000); + assert.equal(normalizeFlexibleExpiry(0), null); + assert.equal(normalizeFlexibleExpiry('nope'), null); +}); + +test('an absolute token expiry two days out is accepted', () => { + // The account service answers with expired_in = 1794385769283, an absolute + // millisecond timestamp about 48 hours ahead. Rejecting it broke the login. + const expiry = Date.now() + 48 * 60 * 60 * 1000; + assert.equal(normalizeFlexibleExpiry(expiry), expiry); +}); + +test('a clearly absurd expiry is still refused', () => { + assert.equal(normalizeFlexibleExpiry(Date.now() + 365 * 24 * 60 * 60 * 1000), null); +}); diff --git a/plugins/7723qqq/quota-bottle/tests/quota-fetch.test.mjs b/plugins/7723qqq/quota-bottle/tests/quota-fetch.test.mjs new file mode 100644 index 0000000..5c408ee --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/quota-fetch.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { saveCredentials } from '../miniapp/node/auth/credentials.mjs'; +import { fetchQuota } from '../miniapp/node/auth/quota.mjs'; +import { disposeTemp, tempDir, writeJson } from './helpers.mjs'; + +const CN_HOST = 'https://api.minimaxi.com/v1/token_plan/remains'; +const GLOBAL_HOST = 'https://api.minimax.io/v1/token_plan/remains'; + +async function withConfig(config, run) { + const dir = await tempDir('quota-fetch-'); + const previous = { + configDir: process.env.MMX_CONFIG_DIR, + baseUrl: process.env.MINIMAX_BASE_URL, + region: process.env.MINIMAX_REGION, + }; + try { + await mkdir(dir, { recursive: true }); + await writeJson(join(dir, 'config.json'), config); + process.env.MMX_CONFIG_DIR = dir; + delete process.env.MINIMAX_BASE_URL; + delete process.env.MINIMAX_REGION; + const dataDir = join(dir, 'data'); + await mkdir(dataDir, { recursive: true }); + return await run(dataDir); + } finally { + if (previous.configDir === undefined) delete process.env.MMX_CONFIG_DIR; + else process.env.MMX_CONFIG_DIR = previous.configDir; + if (previous.baseUrl === undefined) delete process.env.MINIMAX_BASE_URL; + else process.env.MINIMAX_BASE_URL = previous.baseUrl; + if (previous.region === undefined) delete process.env.MINIMAX_REGION; + else process.env.MINIMAX_REGION = previous.region; + await disposeTemp(dir); + } +} + +function stubFetch(responses) { + const calls = []; + const impl = async (url) => { + calls.push(url); + const answer = responses[url]; + if (!answer) throw new Error(`unexpected host ${url}`); + return { + ok: answer.status >= 200 && answer.status < 300, + status: answer.status, + json: async () => answer.body, + }; + }; + return { impl, calls }; +} + +const okBody = { + base_resp: { status_code: 0 }, + model_remains: [{ model_name: 'm', current_interval_total_count: 100, current_interval_usage_count: 10 }], +}; + +test('the region decides the first host, not the global default', async () => { + await withConfig({ region: 'cn', api_key: 'sk-cp-test' }, async (dataDir) => { + const { impl, calls } = stubFetch({ [CN_HOST]: { status: 200, body: okBody } }); + const payload = await fetchQuota(dataDir, undefined, { fetchImpl: impl }); + assert.equal(payload.base_resp.status_code, 0); + assert.deepEqual(calls, [CN_HOST]); + }); +}); + +test('a host that refuses the credential does not hide a host that accepts it', async () => { + await withConfig({ region: 'cn', api_key: 'sk-cp-test' }, async (dataDir) => { + const { impl, calls } = stubFetch({ + [CN_HOST]: { status: 200, body: { base_resp: { status_code: 2049, status_msg: 'invalid api key' } } }, + [GLOBAL_HOST]: { status: 200, body: okBody }, + }); + const payload = await fetchQuota(dataDir, undefined, { fetchImpl: impl }); + assert.equal(payload.base_resp.status_code, 0); + assert.deepEqual(calls, [CN_HOST, GLOBAL_HOST]); + }); +}); + +test('a rejected credential is reported as a credential problem, not a generic refusal', async () => { + await withConfig({ region: 'cn', api_key: 'sk-cp-test' }, async (dataDir) => { + const { impl } = stubFetch({ + [CN_HOST]: { status: 200, body: { base_resp: { status_code: 2049, status_msg: 'invalid api key' } } }, + [GLOBAL_HOST]: { status: 200, body: { base_resp: { status_code: 2049, status_msg: 'invalid api key' } } }, + }); + await assert.rejects(fetchQuota(dataDir, undefined, { fetchImpl: impl }), (error) => error.code === 'invalid_credential'); + }); +}); + +test('the actionable failure wins when two hosts disagree', async () => { + await withConfig({ region: 'cn', api_key: 'sk-cp-test' }, async (dataDir) => { + const { impl } = stubFetch({ + [CN_HOST]: { status: 200, body: { base_resp: { status_code: 2049, status_msg: 'invalid api key' } } }, + [GLOBAL_HOST]: { status: 500, body: {} }, + }); + await assert.rejects(fetchQuota(dataDir, undefined, { fetchImpl: impl }), (error) => error.code === 'invalid_credential'); + }); +}); + +test('an HTTP 401 stays unauthorized and a missing secret key is its own code', async () => { + await withConfig({ region: 'global', api_key: 'sk-cp-test' }, async (dataDir) => { + const unauthorized = stubFetch({ [GLOBAL_HOST]: { status: 401, body: {} } }); + await assert.rejects(fetchQuota(dataDir, undefined, { fetchImpl: unauthorized.impl }), (error) => error.code === 'unauthorized'); + + const missing = stubFetch({ [GLOBAL_HOST]: { status: 200, body: { base_resp: { status_code: 1004, status_msg: 'no secret key' } } } }); + await assert.rejects(fetchQuota(dataDir, undefined, { fetchImpl: missing.impl }), (error) => error.code === 'missing_credential'); + }); +}); + +test('an account-balance key is refused before any request is sent', async () => { + await withConfig({ region: 'cn', api_key: 'sk-api-balance' }, async (dataDir) => { + const { impl, calls } = stubFetch({}); + await assert.rejects(fetchQuota(dataDir, undefined, { fetchImpl: impl }), (error) => error.code === 'balance_credential'); + assert.deepEqual(calls, []); + }); +}); + +test('a transport failure stops the walk instead of retrying every host', async () => { + await withConfig({ region: 'cn', api_key: 'sk-cp-test' }, async (dataDir) => { + const calls = []; + const impl = async (url) => { + calls.push(url); + throw new Error('offline'); + }; + await assert.rejects(fetchQuota(dataDir, undefined, { fetchImpl: impl }), (error) => error.code === 'network'); + assert.deepEqual(calls, [CN_HOST]); + }); +}); + +test('the plugin login is used before the CLI API key', async () => { + await withConfig({ region: 'cn', api_key: 'sk-cp-cli' }, async (dataDir) => { + await saveCredentials(dataDir, { + access_token: 'plugin-token', + refresh_token: 'plugin-refresh', + expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), + region: 'cn', + }); + const calls = []; + const impl = async (url, init = {}) => { + calls.push({ url: String(url), init }); + return { ok: true, status: 200, json: async () => okBody }; + }; + await fetchQuota(dataDir, undefined, { fetchImpl: impl }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, CN_HOST); + assert.equal(calls[0].init.headers.Authorization, 'Bearer plugin-token'); + }); +}); diff --git a/plugins/7723qqq/quota-bottle/tests/quota-shape.test.mjs b/plugins/7723qqq/quota-bottle/tests/quota-shape.test.mjs new file mode 100644 index 0000000..3c82367 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/quota-shape.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { normalizeQuota } from '../miniapp/node/quota.mjs'; + +test('a percentage-only window is a real quota and is not dropped', () => { + // The Token Plan reports the text quota without token counts: a zero total + // and a remaining percentage. Dropping this row would hide the whole quota. + const result = normalizeQuota({ + model_remains: [{ + model_name: 'general', + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_status: 1, + current_interval_remaining_percent: 33, + current_weekly_status: 3, + current_weekly_remaining_percent: 100, + end_time: 1_800_000_000_000, + remains_time: 3_600_000, + }], + }); + assert.equal(result.ok, true); + assert.equal(result.percent, 33); + assert.equal(result.usedPercent, 67); + assert.equal(result.total, null); + assert.equal(result.remaining, null); + assert.equal(result.primaryModel, 'general'); + assert.equal(result.models[0].weekly.unlimited, true); + assert.equal(result.models[0].resetAt, 1_800_000_000_000); +}); + +test('a counted window still reports counts', () => { + const result = normalizeQuota({ + model_remains: [{ + model_name: 'general', + current_interval_total_count: 100, + current_interval_usage_count: 40, + current_interval_remaining_percent: 60, + }], + }); + assert.equal(result.ok, true); + assert.equal(result.total, 100); + assert.equal(result.remaining, 60); + assert.equal(result.used, 40); + assert.equal(result.percent, 60); +}); + +test('the first model row is the headline', () => { + const result = normalizeQuota({ + model_remains: [ + { model_name: 'video', current_interval_total_count: 3, current_interval_usage_count: 3, current_interval_remaining_percent: 100 }, + { model_name: 'general', current_interval_total_count: 0, current_interval_usage_count: 0, current_interval_remaining_percent: 33 }, + ], + }); + assert.equal(result.ok, true); + // Row order is the service's, and the plan's own panel reads its first row. + // Picking a different row here would show a second number next to that + // panel, so the bottle follows the same rule. + assert.equal(result.primaryModel, 'video'); + assert.equal(result.percent, 100); + assert.equal(result.models.length, 2); + assert.equal(result.models[1].name, 'general'); + assert.equal(result.models[1].percent, 33); +}); + +test('a used percentage is read when no remaining percentage is sent', () => { + const result = normalizeQuota({ + model_remains: [{ + model_name: 'general', + current_interval_used_percent: 67, + current_interval_total_count: 0, + }], + }); + assert.equal(result.ok, true); + assert.equal(result.percent, 33); + assert.equal(result.usedPercent, 67); +}); + +test('a row with neither a percentage nor a count says nothing and is skipped', () => { + const result = normalizeQuota({ + model_remains: [{ model_name: 'empty', current_interval_status: 1 }], + }); + assert.equal(result.ok, false); + assert.equal(result.code, 'no_quota'); +}); diff --git a/plugins/7723qqq/quota-bottle/tests/quota.test.mjs b/plugins/7723qqq/quota-bottle/tests/quota.test.mjs new file mode 100644 index 0000000..24f32b7 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/quota.test.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { normalizeQuota, resolveCounts } from '../miniapp/node/quota.mjs'; + +test('resolveCounts selects the consumed reading that agrees with the percentage', () => { + assert.deepEqual(resolveCounts(60, 100, 40), { used: 60, remaining: 40, total: 100 }); + assert.deepEqual(resolveCounts(40, 100, 40), { used: 60, remaining: 40, total: 100 }); +}); + +test('normalizeQuota rejects a row whose reported count conflicts with its percentage', () => { + const result = normalizeQuota({ + model_remains: [{ + model_name: 'conflict', + current_interval_total_count: 100, + current_interval_usage_count: 250, + current_interval_remaining_percent: 40, + }], + }); + // The conflicting count is dropped, but the percentage still describes the + // window, so the row survives with the percentage it reported. + assert.equal(result.ok, true); + assert.equal(result.percent, 40); + assert.equal(result.total, null); + assert.equal(result.remaining, null); +}); + +test('normalizeQuota rejects negative and over-100 percentages', () => { + for (const percent of [-20, 140]) { + const result = normalizeQuota({ + model_remains: [{ + model_name: 'bad-percent', + current_interval_total_count: 100, + current_interval_remaining_percent: percent, + }], + }); + assert.equal(result.ok, false); + } +}); + +test('normalizeQuota uses a percentage only when a count is absent', () => { + const result = normalizeQuota({ + model_remains: [{ + model_name: 'percent-only', + current_interval_total_count: 100, + current_interval_remaining_percent: 25, + end_time: 1_800_000_000_000, + remains_time: 3_600_000, + }], + }); + assert.equal(result.ok, true); + assert.equal(result.percent, 25); + assert.equal(result.usedPercent, 75); + assert.equal(result.models[0].resetAt, 1_800_000_000_000); + assert.equal(result.models[0].remainsMs, 3_600_000); +}); + +test('normalizeQuota keeps a counted row and reports its counts', () => { + const result = normalizeQuota({ + model_remains: [ + { model_name: 'general', current_interval_total_count: 100, current_interval_usage_count: 20, current_interval_remaining_percent: 80 }, + { model_name: 'other', current_interval_total_count: 300, current_interval_usage_count: 100, current_interval_remaining_percent: 200 / 3 }, + ], + }); + assert.equal(result.ok, true); + assert.equal(result.total, 100); + assert.equal(result.remaining, 80); + assert.equal(result.used, 20); + assert.equal(result.percent, 80); + assert.equal(result.models.length, 2); +}); + +test('normalizeQuota refuses an invalid numeric percentage even with a usage count', () => { + for (const percent of [-20, 140, Number.POSITIVE_INFINITY]) { + const result = normalizeQuota({ + model_remains: [{ + model_name: 'bad-percent-with-count', + current_interval_total_count: 100, + current_interval_usage_count: 60, + current_interval_remaining_percent: percent, + }], + }); + assert.equal(result.ok, false, String(percent)); + } +}); + +test('normalizeQuota rejects non-integer and unsafe counts', () => { + const result = normalizeQuota({ + model_remains: [ + { model_name: 'fractional', current_interval_total_count: 100.5, current_interval_usage_count: 20 }, + { model_name: 'unsafe', current_interval_total_count: Number.MAX_SAFE_INTEGER + 1, current_interval_usage_count: 1 }, + ], + }); + assert.equal(result.ok, false); +}); + +test('the weekly aggregate is the text model window, unlimited included', () => { + const result = normalizeQuota({ + model_remains: [ + { model_name: 'general', current_interval_total_count: 0, current_interval_usage_count: 0, current_interval_remaining_percent: 33, current_weekly_status: 3, current_weekly_remaining_percent: 100 }, + { model_name: 'video', current_interval_total_count: 3, current_interval_usage_count: 3, current_interval_remaining_percent: 100, current_weekly_total_count: 21, current_weekly_usage_count: 21 }, + ], + }); + assert.equal(result.ok, true); + assert.equal(result.primaryModel, 'general'); + // The text model's weekly window is unlimited, exactly as the service reports. + assert.equal(result.weekly.unlimited, true); + assert.equal(result.weekly.percent, 100); + // The video row keeps its own weekly counts. + assert.equal(result.models[1].weekly.total, 21); + assert.equal(result.models[1].weekly.remaining, 21); +}); diff --git a/plugins/7723qqq/quota-bottle/tests/server.test.mjs b/plugins/7723qqq/quota-bottle/tests/server.test.mjs new file mode 100644 index 0000000..b07bd50 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/server.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { request } from 'node:http'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { start } from '../miniapp/node/server.mjs'; +import { disposeTemp, fakeContext, freePort, tempDir } from './helpers.mjs'; + +function nodeRequest(port, path, headers = {}) { + return new Promise((resolve, reject) => { + const req = request({ host: '127.0.0.1', port, path, headers }, (res) => { + res.resume(); + res.on('end', () => resolve({ status: res.statusCode })); + }); + req.on('error', reject); + req.end(); + }); +} + +async function boot(root) { + const controller = new AbortController(); + const port = await freePort(); + const context = fakeContext(root, { port, signal: controller.signal }); + await mkdir(context.dataDir, { recursive: true }); + process.env.MMX_CONFIG_DIR = join(root, 'mmx'); + process.env.MINIMAX_BASE_URL = ''; + delete process.env.MINIMAX_REGION; + const lifecycle = await start(context); + return { context, controller, port, lifecycle }; +} + +test('serves the page, rejects foreign Host/Origin, and reads a saved manual total', async () => { + const dir = await tempDir('quota-server-'); + const { context, controller, port, lifecycle } = await boot(dir); + const base = `http://127.0.0.1:${port}`; + try { + const page = await fetch(`${base}/bottle`); + assert.equal(page.status, 200); + assert.match(await page.text(), /Quota Bottle/); + assert.equal(page.headers.get('content-security-policy')?.includes("default-src 'none'"), true); + + const hostileHost = await nodeRequest(port, '/api/state', { host: 'attacker.example' }); + assert.equal(hostileHost.status, 403); + const hostileOrigin = await fetch(`${base}/api/state`, { headers: { origin: 'http://attacker.example' } }); + assert.equal(hostileOrigin.status, 403); + + const saved = await fetch(`${base}/api/quota`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ total: 1000 }), + }); + assert.equal(saved.status, 200); + const state = await (await fetch(`${base}/api/state`)).json(); + assert.equal(state.total, 1000); + assert.ok(state.mode === 'manual' || state.mode === 'unset'); + } finally { + await lifecycle.dispose(); + controller.abort(); + await disposeTemp(dir); + } +}); + +test('JSON writes require the right content type and a bounded body', async () => { + const dir = await tempDir('quota-server-body-'); + const { context, controller, port, lifecycle } = await boot(dir); + const base = `http://127.0.0.1:${port}`; + try { + const wrongType = await fetch(`${base}/api/quota`, { method: 'POST', body: '{}' }); + assert.equal(wrongType.status, 415); + const tooLarge = await fetch(`${base}/api/quota`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ total: 1, pad: 'x'.repeat(5000) }), + }); + assert.equal(tooLarge.status, 413); + } finally { + await lifecycle.dispose(); + controller.abort(); + await disposeTemp(dir); + } +}); + +test('dispose frees the listener and closes the server', async () => { + const dir = await tempDir('quota-server-dispose-'); + const { context, controller, port, lifecycle } = await boot(dir); + try { + await lifecycle.dispose(); + const listener = await fetch(`http://127.0.0.1:${port}/bottle`).catch((error) => error); + assert.ok(listener instanceof Error); + } finally { + controller.abort(); + await disposeTemp(dir); + } +}); + +test('the usage scan switch only accepts a boolean', async () => { + const dir = await tempDir('quota-server-settings-'); + const { context, controller, port, lifecycle } = await boot(dir); + const base = `http://127.0.0.1:${port}`; + try { + const invalid = await fetch(`${base}/api/settings`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ usageScan: 'yes' }), + }); + assert.equal(invalid.status, 400); + const state = await (await fetch(`${base}/api/state`)).json(); + assert.equal(state.usageScan, false); + } finally { + await lifecycle.dispose(); + controller.abort(); + await disposeTemp(dir); + } +}); diff --git a/plugins/7723qqq/quota-bottle/tests/state.test.mjs b/plugins/7723qqq/quota-bottle/tests/state.test.mjs new file mode 100644 index 0000000..be62ef9 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/state.test.mjs @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import { mkdir, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; + +import { start } from '../miniapp/node/server.mjs'; +import { manualState } from '../miniapp/node/quota.mjs'; +import { usageDbPath } from '../miniapp/node/usage.mjs'; +import { disposeTemp, fakeContext, freePort, tempDir } from './helpers.mjs'; + +test('a total above the usage is a state of its own, not an empty balance', () => { + const state = manualState(1000, 4289389); + assert.equal(state.mode, 'manual'); + assert.equal(state.overBudget, true); + assert.equal(state.overBy, 4288389); + assert.equal(state.remaining, 0); + assert.equal(state.percent, 0); +}); + +test('the manual state keeps a usable total honest', () => { + const under = manualState(1000, 400); + assert.equal(under.overBudget, false); + assert.equal(under.overBy, 0); + assert.equal(under.remaining, 600); + assert.equal(under.percent, 60); + + const unset = manualState(null, 400); + assert.equal(unset.mode, 'unset'); + assert.equal(unset.total, null); + assert.equal(unset.percent, null); + + const unknown = manualState(1000, null); + assert.equal(unknown.mode, 'manual'); + assert.equal(unknown.used, null); + assert.equal(unknown.remaining, null); + assert.equal(unknown.overBudget, false); +}); + +async function boot(root, { withUsage = false } = {}) { + const controller = new AbortController(); + const port = await freePort(); + const dataRoot = join(root, 'root'); + const context = { + ...fakeContext(root, { port, signal: controller.signal }), + dataDir: join(dataRoot, 'v2', 'plugin-data', 'quota-bottle'), + }; + await mkdir(context.dataDir, { recursive: true }); + if (withUsage) { + await mkdir(join(dataRoot, 'v2', 'sqlite'), { recursive: true }); + const db = new DatabaseSync(usageDbPath({ MINIMAX_DATA_DIR: dataRoot })); + db.exec(` + CREATE TABLE local_runtime_token_usage ( + ts INTEGER, input_tokens INTEGER, output_tokens INTEGER, + cache_read_tokens INTEGER, cache_write_tokens INTEGER + ); + `); + const now = Date.now(); + db.prepare('INSERT INTO local_runtime_token_usage VALUES (?, ?, ?, ?, ?)') + .run(now, 4000000, 0, 0, 0); + db.close(); + } + process.env.MMX_CONFIG_DIR = join(root, 'mmx'); + process.env.MINIMAX_DATA_DIR = dataRoot; + process.env.MINIMAX_BASE_URL = ''; + delete process.env.MINIMAX_REGION; + const lifecycle = await start(context); + return { context, controller, port, lifecycle }; +} + +test('the state says a login is needed when no credential can read the quota', async () => { + const dir = await tempDir('quota-state-login-'); + const { controller, port, lifecycle } = await boot(dir); + try { + const state = await (await fetch(`http://127.0.0.1:${port}/api/state`)).json(); + assert.equal(state.needsLogin, true); + assert.ok(state.problems.includes('no_credential')); + assert.equal(state.mode, 'unset'); + assert.equal(state.usageScan, false); + } finally { + await lifecycle.dispose(); + controller.abort(); + await disposeTemp(dir); + } +}); + +test('the host database is not opened until the user turns the scan on', async () => { + const dir = await tempDir('quota-state-off-'); + const { context, controller, port, lifecycle } = await boot(dir, { withUsage: true }); + try { + const state = await (await fetch(`http://127.0.0.1:${port}/api/state`)).json(); + assert.equal(state.usageScan, false); + assert.equal(state.used, null); + assert.ok(state.problems.includes('usage_scan_off')); + const names = await readdir(context.dataDir); + assert.equal(names.some((name) => name.startsWith('.usage-')), false); + } finally { + await lifecycle.dispose(); + controller.abort(); + await disposeTemp(dir); + } +}); + +test('turning the scan on reads the local month without touching the database', async () => { + const dir = await tempDir('quota-state-on-'); + const { controller, port, lifecycle } = await boot(dir, { withUsage: true }); + const base = `http://127.0.0.1:${port}`; + try { + const saved = await fetch(`${base}/api/settings`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ usageScan: true }), + }); + assert.equal(saved.status, 200); + assert.deepEqual(await saved.json(), { ok: true, usageScan: true }); + + const state = await (await fetch(`${base}/api/state`)).json(); + assert.equal(state.usageScan, true); + assert.equal(state.used, 4000000); + assert.equal(state.problems.includes('usage_scan_off'), false); + } finally { + await lifecycle.dispose(); + controller.abort(); + await disposeTemp(dir); + } +}); + +test('usage above the saved total is reported as over budget, not as zero remaining', async () => { + const dir = await tempDir('quota-state-over-'); + const { controller, port, lifecycle } = await boot(dir, { withUsage: true }); + const base = `http://127.0.0.1:${port}`; + try { + const saved = await fetch(`${base}/api/quota`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ total: 1000 }), + }); + assert.equal(saved.status, 200); + const enabled = await fetch(`${base}/api/settings`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ usageScan: true }), + }); + assert.equal(enabled.status, 200); + const state = await (await fetch(`${base}/api/state`)).json(); + assert.equal(state.mode, 'manual'); + assert.equal(state.total, 1000); + assert.equal(state.used, 4000000); + assert.equal(state.overBudget, true); + assert.equal(state.overBy, 3999000); + assert.equal(state.remaining, 0); + assert.equal(state.percent, 0); + assert.equal(state.needsLogin, true); + } finally { + await lifecycle.dispose(); + controller.abort(); + await disposeTemp(dir); + } +}); diff --git a/plugins/7723qqq/quota-bottle/tests/urls.test.mjs b/plugins/7723qqq/quota-bottle/tests/urls.test.mjs new file mode 100644 index 0000000..babd3f4 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/urls.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + normalizeAbsoluteExpiry, + normalizeHttpsUrl, + normalizePollInterval, + normalizeUserCode, + normalizeVerificationUrl, + oauthRegionForUrl, +} from '../miniapp/node/urls.mjs'; + +test('normalizeHttpsUrl accepts MiniMax HTTPS hosts and explicit loopback test hosts', () => { + assert.equal(normalizeHttpsUrl('https://api.minimaxi.com/', { purpose: 'api' }), 'https://api.minimaxi.com'); + assert.equal(normalizeHttpsUrl('http://127.0.0.1:8787/stub', { purpose: 'api', allowHttpLoopback: true }), 'http://127.0.0.1:8787/stub'); + assert.equal(normalizeHttpsUrl('https://account.minimax.io/oauth2', { purpose: 'account' }), 'https://account.minimax.io/oauth2'); +}); + +test('normalizeHttpsUrl rejects plain HTTP, userinfo, queries, fragments, and unrelated hosts', () => { + for (const value of [ + 'http://api.minimaxi.com', + 'https://user:pass@api.minimaxi.com', + 'https://api.minimaxi.com/?token=secret', + 'https://api.minimaxi.com/#x', + 'https://attacker.example/v1/token_plan/remains', + 'javascript:alert(1)', + 'https://api.minimaxi.com.evil.example', + ]) { + assert.equal(normalizeHttpsUrl(value, { purpose: 'api' }), null, value); + } +}); + +test('normalizeVerificationUrl accepts only the region verification origins', () => { + assert.equal(normalizeVerificationUrl('https://account.minimaxi.com/device?user_code=ABCD', 'cn'), 'https://account.minimaxi.com/device?user_code=ABCD'); + assert.equal(normalizeVerificationUrl('https://platform.minimaxi.com/oauth-authorize?user_code=ABCD', 'cn'), 'https://platform.minimaxi.com/oauth-authorize?user_code=ABCD'); + assert.equal(normalizeVerificationUrl('https://platform.minimax.io/oauth-authorize?user_code=ABCD', 'global'), 'https://platform.minimax.io/oauth-authorize?user_code=ABCD'); + assert.equal(normalizeVerificationUrl('https://attacker.example/device', 'cn'), null); + assert.equal(normalizeVerificationUrl('http://account.minimaxi.com/device', 'cn'), null); + // Another region's page is never a valid target. + assert.equal(normalizeVerificationUrl('https://platform.minimax.io/oauth-authorize?user_code=ABCD', 'cn'), null); + assert.equal(normalizeVerificationUrl('https://platform.minimaxi.com/oauth-authorize?user_code=ABCD', 'global'), null); +}); + +test('oauthRegionForUrl reads the region from either verification origin', () => { + assert.equal(oauthRegionForUrl('https://account.minimaxi.com/device'), 'cn'); + assert.equal(oauthRegionForUrl('https://platform.minimaxi.com/oauth-authorize?user_code=ABCD'), 'cn'); + assert.equal(oauthRegionForUrl('https://platform.minimax.io/oauth-authorize?user_code=ABCD'), 'global'); +}); + +test('normalizeUserCode and timing fields reject malformed network responses', () => { + assert.equal(normalizeUserCode('ABCD-1234'), 'ABCD-1234'); + assert.equal(normalizeUserCode('javascript:alert(1)'), null); + assert.equal(normalizePollInterval(0), null); + assert.equal(normalizePollInterval(3000), 3000); + const expiresAt = Date.now() + 10 * 60 * 1000; + assert.equal(normalizeAbsoluteExpiry(expiresAt), expiresAt); + assert.equal(normalizeAbsoluteExpiry(Date.now() - 1000), null); + assert.equal(normalizeAbsoluteExpiry(Date.now() + 48 * 60 * 60 * 1000), null); +}); diff --git a/plugins/7723qqq/quota-bottle/tests/usage.test.mjs b/plugins/7723qqq/quota-bottle/tests/usage.test.mjs new file mode 100644 index 0000000..b4a801e --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/usage.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; + +import { dataRoot, monthWindow, readUsedTokens, usageDbPath } from '../miniapp/node/usage.mjs'; +import { disposeTemp, tempDir } from './helpers.mjs'; + +async function makeDb(root, table = true) { + const context = { dataDir: join(root, 'data') }; + const env = { MINIMAX_DATA_DIR: join(root, 'root') }; + await mkdir(join(root, 'root', 'v2', 'sqlite'), { recursive: true }); + await mkdir(context.dataDir, { recursive: true }); + const file = usageDbPath(env); + const db = new DatabaseSync(file); + if (table) { + db.exec(` + CREATE TABLE local_runtime_token_usage ( + ts INTEGER, input_tokens INTEGER, output_tokens INTEGER, + cache_read_tokens INTEGER, cache_write_tokens INTEGER + ); + INSERT INTO local_runtime_token_usage VALUES + (100, 1, 2, 3, 4), (200, 10, 20, 30, 40), (400, 100, 100, 100, 100); + `); + } else { + db.exec('CREATE TABLE other (a INTEGER);'); + } + db.close(); + return { context, env }; +} + +test('dataRoot comes from explicit Host variables, never from dataDir', () => { + assert.equal(dataRoot({ MINIMAX_DATA_DIR: join('G:', 'mimimaxcode', 'instance') }), join('G:', 'mimimaxcode', 'instance')); + assert.equal(dataRoot({ MAVIS_HOME: join('G:', 'mavis') }), join('G:', 'mavis')); + assert.equal(dataRoot({}), join(homedir(), '.minimax')); +}); + +test('monthWindow uses a half-open local calendar month', () => { + const window = monthWindow(new Date(2026, 10, 7, 12)); + assert.equal(new Date(window.startMs).getMonth(), 10); + assert.equal(new Date(window.endMs).getMonth(), 11); + assert.equal(window.label, '2026-11'); +}); + +test('readUsedTokens sums only rows inside the month window', async () => { + const dir = await tempDir('quota-usage-'); + try { + const { context, env } = await makeDb(dir); + const result = await readUsedTokens({ startMs: 100, endMs: 300 }, context, env); + assert.deepEqual(result, { ok: true, used: 110, rows: 2 }); + } finally { + await disposeTemp(dir); + } +}); + +test('readUsedTokens reports a missing table and a missing database', async () => { + const dir = await tempDir('quota-usage-schema-'); + try { + const noTable = await makeDb(dir, false); + assert.deepEqual(await readUsedTokens({ startMs: 0, endMs: 999 }, noTable.context, noTable.env), { ok: false, code: 'db_schema' }); + const missing = { + context: { dataDir: join(dir, 'missing-data') }, + env: { MINIMAX_DATA_DIR: join(dir, 'missing-root') }, + }; + assert.equal((await readUsedTokens({ startMs: 0, endMs: 999 }, missing.context, missing.env)).code, 'db_missing'); + } finally { + await disposeTemp(dir); + } +}); diff --git a/plugins/7723qqq/quota-bottle/tests/window-drag.test.mjs b/plugins/7723qqq/quota-bottle/tests/window-drag.test.mjs new file mode 100644 index 0000000..94ab62e --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/window-drag.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { OAUTH_VERIFY_ORIGINS } from '../miniapp/node/auth/constants.mjs'; +import { packageRoot } from './helpers.mjs'; + +async function clientSource() { + return readFile(join(packageRoot, 'miniapp', 'client', 'index.html'), 'utf8'); +} + +test('the frameless window declares an OS drag region', async () => { + const source = await clientSource(); + assert.match(source, /-webkit-app-region: drag/u); + + // The close button sits inside the drag handle and must stay clickable. + const exit = /\.floatExit \{([\s\S]*?)\n \}/u.exec(source); + assert.ok(exit, '.floatExit was not found'); + assert.match(exit[1], /-webkit-app-region: no-drag/u); +}); + +test('the page can also move the window, because a WebView2 guest ignores the app region', async () => { + const source = await clientSource(); + // Screen coordinates: the window moves with the pointer, so a client-relative + // delta would shrink by exactly the distance the window just moved. + assert.match(source, /startX: event\.screenX/u); + assert.match(source, /drag\.lastX = event\.screenX/u); + // One request per frame, and none when the pointer has not moved since the + // last one. + assert.match(source, /drag\.sentX === target\.x && drag\.sentY === target\.y/u); + assert.match(source, /windowHandle\.update\(target\)/u); +}); + +test('the page and the Node side accept the same verification origins', async () => { + const source = await clientSource(); + const block = /const VERIFY_ORIGINS = \[([\s\S]*?)\];/.exec(source); + assert.ok(block, 'VERIFY_ORIGINS not found in the page'); + const pageOrigins = [...block[1].matchAll(/'([^']+)'/g)].map((match) => match[1]).sort(); + const nodeOrigins = Object.values(OAUTH_VERIFY_ORIGINS).flat().sort(); + assert.deepEqual(pageOrigins, nodeOrigins); +});