From f5a4c9ee400616d73316cd311ed2cc563fe162e2 Mon Sep 17 00:00:00 2001 From: 7723qqq <65469696+7723qqq@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:51:30 +0800 Subject: [PATCH 1/3] feat(quota-bottle): read the Token Plan quota from the signed-in desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An octopus-shaped glass bottle that floats in a 220 × 300 window and shows the remaining Token Plan quota the desktop itself reports, with a device-code login and a local token-usage estimate as fallbacks. - Quota source: the desktop's own session, borrowed through its auth lease. One GET /v1/api/openplatform/coding_plan/remains with the leased token; no client signature and no group lookup. - The account region is discovered, not guessed: the leased token is offered to both Token Plan origins and the one that answers is the client's. - The page follows the client's language (Chinese or English) and carries a 中 / EN switch. - The window is dragged from page code with screen coordinates, because -webkit-app-region is inert in a webview guest; the page also asks for its size again when the client remembers another one. - The page paints no backdrop, so the window is transparent on the native renderer; on the webview backend the guest surface is opaque, a client limitation tracked in MiniMax-AI/minimax-code#597. Verification: node --test "tests/*.test.mjs" passes 84 tests; node scripts/validate.mjs plugins/7723qqq/quota-bottle reports OK; a live desktop session on the CN region returned a quota without a second login. --- README.md | 1 + README.zh-CN.md | 2 + .../quota-bottle/.minimax-plugin/plugin.json | 18 + plugins/7723qqq/quota-bottle/LICENSE | 21 + plugins/7723qqq/quota-bottle/NOTICE.md | 20 + plugins/7723qqq/quota-bottle/README.md | 249 +++ plugins/7723qqq/quota-bottle/README.zh-CN.md | 138 ++ plugins/7723qqq/quota-bottle/icon.png | Bin 0 -> 4574 bytes .../quota-bottle/miniapp/client/index.html | 1631 +++++++++++++++++ .../7723qqq/quota-bottle/miniapp/miniapp.json | 27 + .../quota-bottle/miniapp/node/auth/auth.mjs | 160 ++ .../quota-bottle/miniapp/node/auth/config.mjs | 199 ++ .../miniapp/node/auth/constants.mjs | 24 + .../miniapp/node/auth/oauth-client.mjs | 10 + .../quota-bottle/miniapp/node/auth/oauth.mjs | 187 ++ .../quota-bottle/miniapp/node/auth/quota.mjs | 107 ++ .../quota-bottle/miniapp/node/cache.mjs | 49 + .../miniapp/node/desktop-lease.mjs | 196 ++ .../miniapp/node/desktop-session.mjs | 118 ++ .../quota-bottle/miniapp/node/errors.mjs | 105 ++ .../quota-bottle/miniapp/node/miniapp-api.ts | 397 ++++ .../7723qqq/quota-bottle/miniapp/node/net.mjs | 29 + .../quota-bottle/miniapp/node/quota.mjs | 222 +++ .../quota-bottle/miniapp/node/server.mjs | 413 +++++ .../quota-bottle/miniapp/node/store.mjs | 45 + .../quota-bottle/miniapp/node/urls.mjs | 111 ++ .../quota-bottle/miniapp/node/usage.mjs | 127 ++ plugins/7723qqq/quota-bottle/package.json | 6 + .../7723qqq/quota-bottle/tests/cache.test.mjs | 54 + .../tests/client-messages.test.mjs | 72 + .../tests/config-snapshot.test.mjs | 72 + .../quota-bottle/tests/config.test.mjs | 65 + .../quota-bottle/tests/desktop-lease.test.mjs | 218 +++ .../tests/desktop-session.test.mjs | 55 + .../quota-bottle/tests/float-pin.test.mjs | 89 + .../7723qqq/quota-bottle/tests/helpers.mjs | 52 + .../7723qqq/quota-bottle/tests/oauth.test.mjs | 121 ++ .../quota-bottle/tests/quota-fetch.test.mjs | 126 ++ .../quota-bottle/tests/quota-shape.test.mjs | 85 + .../7723qqq/quota-bottle/tests/quota.test.mjs | 112 ++ .../quota-bottle/tests/server.test.mjs | 96 + .../7723qqq/quota-bottle/tests/state.test.mjs | 106 ++ .../7723qqq/quota-bottle/tests/urls.test.mjs | 59 + .../7723qqq/quota-bottle/tests/usage.test.mjs | 67 + .../quota-bottle/tests/window-drag.test.mjs | 42 + 45 files changed, 6103 insertions(+) create mode 100644 plugins/7723qqq/quota-bottle/.minimax-plugin/plugin.json create mode 100644 plugins/7723qqq/quota-bottle/LICENSE create mode 100644 plugins/7723qqq/quota-bottle/NOTICE.md create mode 100644 plugins/7723qqq/quota-bottle/README.md create mode 100644 plugins/7723qqq/quota-bottle/README.zh-CN.md create mode 100644 plugins/7723qqq/quota-bottle/icon.png create mode 100644 plugins/7723qqq/quota-bottle/miniapp/client/index.html create mode 100644 plugins/7723qqq/quota-bottle/miniapp/miniapp.json create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/auth/auth.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/auth/config.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/auth/constants.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/auth/oauth-client.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/auth/oauth.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/auth/quota.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/cache.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/desktop-lease.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/desktop-session.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/errors.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/miniapp-api.ts create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/net.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/quota.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/server.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/store.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/urls.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/usage.mjs create mode 100644 plugins/7723qqq/quota-bottle/package.json create mode 100644 plugins/7723qqq/quota-bottle/tests/cache.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/client-messages.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/config-snapshot.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/config.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/desktop-lease.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/desktop-session.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/float-pin.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/helpers.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/oauth.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/quota-fetch.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/quota-shape.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/quota.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/server.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/state.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/urls.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/usage.test.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/window-drag.test.mjs diff --git a/README.md b/README.md index f9512f6a..a599e585 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 the desktop itself reports — no second login — with the liquid level and opacity following the remaining share, and a device-code login plus a 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 1032a896..ec081e7d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -87,6 +87,8 @@ 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/prompt-optimizer/README.md) | 把随手写的草稿扩展成写全了的文件、改动和完成标准的请求,确认后再填入聊天输入框 | [7723qqq](https://github.com/7723qqq) | +| [额度瓶](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 00000000..774245ca --- /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.14.2", + "description": "An original octopus-shaped glass bottle that floats on your desktop and shows how much Token Plan quota is left: the liquid level and opacity follow the remaining share, with a device-code login and a 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 00000000..40c7e97b --- /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 00000000..49daea2a --- /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 00000000..c4eccb3b --- /dev/null +++ b/plugins/7723qqq/quota-bottle/README.md @@ -0,0 +1,249 @@ +# 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 — the desktop reads the same endpoint on a 5 second cache — 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 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. + +Two live sources are tried, in this order: + +1. **The desktop's own session.** MiniMax Code keeps its account session in its own process and + lends it to other local processes over an **auth lease**: a named pipe + (`\\.\pipe\mcode-auth-lease-` on Windows, a unix socket elsewhere) authenticated by a + capability file at `/run/mcode-auth-lease-v1.cap`. The bottle takes a short-lived + lease (the token stays in memory, is never written to disk, and is never logged) and then makes + the request the desktop's own usage panel makes: `GET /v1/api/openplatform/coding_plan/remains` + with the leased token as a bearer credential. + - The request carries the token and nothing else: no query string, no client signature, no + group id. That is the desktop's own request shape; `X-Group-Id` is sent only when the group is + already known, which makes it optional. + - **The region is discovered, not guessed.** The account's region belongs to the installed + client, and the Host does not pass it to a Mini App process. The bottle offers the leased + token to `www.minimaxi.com` and `platform.minimax.io` in turn and keeps the one that answers. + A token issued by the client is accepted only by its own region, so the bottle cannot end up + outside the client's region. The region that answered is tried first on the next read, and it + replaces the configured region in the panel. + - The result is the same number the desktop's usage panel shows, because it is the same call: + the quota arrives as a percentage (`current_interval_remaining_percent`), and only media + quotas carry counts. + - A signed-out desktop reports that the desktop is signed out; a desktop that is not running + reports that its session is unavailable. Neither case invents a number. + - **While you are signed in to MiniMax Code, the bottle shows the quota on its own — there is + no second login.** +2. **The CLI's credential.** `~/.mmx/config.json` holds an OAuth token or an API key, which the + bottle refreshes and sends to the Token Plan endpoint. This is the source described below, and + it is the fallback when the desktop cannot answer. + +### 1. Token Plan quota through the CLI credential + +The Node entry loads `~/.mmx/config.json` (or `$MMX_CONFIG_DIR/config.json`), resolves the OAuth +credential, refreshes a token within five minutes of expiry, and 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 CLI config asks for. +- The page polls `GET /api/login`; Node polls the account token endpoint and writes a successful + credential into the CLI's own config file. The token never reaches the page, a log, or an error + response. +- 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 config update preserves unrelated fields, refuses corrupt JSON, uses a per-process temporary + name, serializes writes, and preserves the existing file mode (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 source 2. + +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 height and opacity both equal `ratio`. +- 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: the current local calendar month, from a read-only SQLite snapshot of + `/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. +- The active data root is resolved from `context.dataDir` first, then `MINIMAX_DATA_DIR`, then + `MAVIS_HOME`, then `~/.minimax`. These Host layout details are not a supported Mini App API. +- `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 84 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, and verifies that `dispose()` frees the listener. +- The lease tests run the real framed protocol against a named-pipe server, so the desktop source + (the primary path) is covered end to end: the handshake, a granted lease, a refused lease, and + the two quota calls made with a leased token. +- 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 state tests cover the login prompt and the + over-budget state, the page tests cover the window drag math and the verification origins, and + the page test keeps 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 live desktop session on the CN region, with MiniMax Code running and signed in: the desktop + source returned a live quota (two model rows) without a second login, without a client signature + and without a group lookup. A full device authorization for the CLI fallback and macOS/Linux were + not tested in this revision. + +## Data & access + +- Files read: + - `~/.mmx/config.json` or `$MMX_CONFIG_DIR/config.json`: OAuth token or API key. This relies on + the current `mmx` layout and is not a documented Mini App API. + - `/v2/sqlite/runtime-state.sqlite`, read-only, only during the manual fallback. + `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 the plugin's `quota.json` in `context.dataDir`. +- Files written: + - `~/.mmx/config.json`, by the login and by OAuth token rotation. Unknown fields are preserved. + - `/quota.json`, the manual total (atomic temporary file, then rename). + - Temporary SQLite snapshots under `/.usage-*`, removed after each read. +- Network: + - `https://account.minimaxi.com` or `https://account.minimax.io`: device-code start and poll. + - `https://www.minimaxi.com` or `https://platform.minimax.io`: the Token Plan quota read through + the desktop's leased session. No redirect is followed. + - `https://api.minimaxi.com` or `https://api.minimax.io`: Token Plan quota read through the 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 the CLI config file. 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: `quota.json` in `dataDir`; the theme preference is 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 Config, credential and device-flow implementation +miniapp/node/usage.mjs Read-only local token usage with a SQLite snapshot +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 00000000..df4a242f --- /dev/null +++ b/plugins/7723qqq/quota-bottle/README.zh-CN.md @@ -0,0 +1,138 @@ +# 额度瓶(`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 秒(桌面端读同一个接口用的是 5 秒缓存),对本机用量快照缓存 15 秒;点 **Refresh** 会开启新的缓存代际,不会复用登录前或跨月之前的在途请求。 +- 需要登录、设置手填总额,或实时额度读取有问题时,悬浮计量条会给出简短提示。 + +## 额度怎么算 + +页面向 Node 请求 `/api/state`;Node 先尝试实时 Token Plan 额度,失败后才使用手填总额与本机用量。 + +实时数据源按顺序尝试两个: + +1. **桌面端自己的会话。** MiniMax Code 把登录态留在自己的进程里,并按**租约协议**把它借给本机其他进程:Windows 上是命名管道 `\\.\pipe\mcode-auth-lease-`(macOS/Linux 上是 unix socket),配一个能力文件 `<数据根目录>/run/mcode-auth-lease-v1.cap` 做鉴权。瓶子按协议取一份短期租约(token 只在内存里,不落盘、不写日志),然后发出桌面端「用量」面板发出的同一个请求:带租约 token,`GET /v1/api/openplatform/coding_plan/remains`。 + - 请求里只有 token:没有查询参数、没有客户端签名、没有 group id。这就是桌面端自己的请求形状,`X-Group-Id` 只在已知 group 时才带,因此对瓶子来说是可选的。 + - **分区是问出来的,不是猜出来的。** 账号所属的分区属于已安装的客户端,Host 不会把它传给 Mini App 进程。瓶子把租约 token 依次交给 `www.minimaxi.com` 和 `platform.minimax.io`,留下应答的那一个;客户端签发的 token 只会被它自己的分区接受,所以瓶子不可能落到客户端所在分区之外。应答的分区会排在下一次读取的最前面,并取代面板上来自配置的分区。 + - 结果与桌面端「用量」面板**同源同值**:额度是按百分比给的(`current_interval_remaining_percent`),媒体额度才带计数。 + - 桌面端没登录 → 报"桌面端已退出登录";桌面端没运行 → 报"桌面端会话不可用"。两种都只提示、不编数字。 + - **只要在 MiniMax Code 里登录着,瓶子就自动显示额度,不需要再登一次。** +2. **CLI 的凭据。** `~/.mmx/config.json` 里的 OAuth token 或 API key,由瓶子刷新后发给 Token Plan 接口。桌面端不可用时的备份来源。 + +### 1. Token Plan 额度(CLI 凭据) + +Node 读取 `~/.mmx/config.json`(或 `$MMX_CONFIG_DIR/config.json`),解析 OAuth 凭据,在 token 距到期五分钟内刷新,然后带 `Authorization: Bearer ` 请求 `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 接口;成功后把凭据写回 CLI 自己的配置。token 不发给页面、不写日志、不进错误响应。 +- 取消或替换待完成的登录流程后,迟到的响应不会把凭据按另一个 region 写入。`dispose()` 会中止在途账户和额度请求。 +- 配置写入保留无关字段、拒绝损坏的 JSON、使用带进程标识的临时文件、串行化写入,并保留原文件权限(默认 0600)。 + +`sk-api-` 开头的 key 是账户余额 key,不是 Token Plan key;插件会在发请求前拒绝,并退回第 2 种来源。 + +被服务端拒绝的凭据会被指名,而不是笼统归为“接口拒绝”:`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 = 剩余 / 总额`;液面高度和不透明度都等于这个比例。 +- 只有所有展示模型都有可用的周额度数据时才显示周百分比;这里不应用套餐的周额度加成倍率。 + +### 2. 手填总额度 + +拿不到实时额度时,页面会让你填一个总额度(单位是 token),存在插件自己的 `dataDir/quota.json`。 + +- 已用量:本机当前自然月,从 `<数据根目录>/v2/sqlite/runtime-state.sqlite` 的只读 SQLite 快照读取 `local_runtime_token_usage` 表,按 `startMs <= ts < endMs` 汇总 `input_tokens + output_tokens + cache_read_tokens + cache_write_tokens`。reasoning token 不另加。 +- 数据根目录优先从 `context.dataDir` 的祖先推断,其次依次为 `MINIMAX_DATA_DIR`、`MAVIS_HOME`、`~/.minimax`。这些宿主布局细节不是 Mini App 的正式 API。 +- `剩余 = 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"` 的 84 项插件测试全部通过;`node scripts/validate.mjs plugins/7723qqq/quota-bottle` 报告 OK。 +- 合成 HTTP 集成测试覆盖页面、拒绝外部 Host/Origin、写入手填总额、JSON body 限制,以及 `dispose()` 释放监听端口。 +- 租约协议测试用真实的命名管道服务器跑完整协议(握手、取租约、被拒的租约、按租约读额度的两次调用),保证桌面源这条主路径真的被测到。 +- 打桩的额度接口覆盖按分区选站、回退到另一主机、被指名的凭据失败,以及余额 key 的提前拒绝;状态测试覆盖登录提示与超额状态;页面测试覆盖窗口拖拽几何与验证页来源,并保证错误码与“可登录修复”集合和 Node 侧目录保持一致。 +- 合成额度响应覆盖字段歧义、冲突行拒绝、只有百分比的行、不限量行和周额度汇总;合成 SQLite 数据库覆盖本月边界与 schema 缺失。 +- 在 CN 区、MiniMax Code 已登录运行的真实桌面上验证过:桌面来源在不二次登录、不带客户端签名、不查 group 的情况下拿到了实时额度(两行模型)。CLI 回退路径的完整设备码授权与 macOS/Linux 本轮未验证。 + +## 数据与权限 + +- 读取的文件: + - `~/.mmx/config.json` 或 `$MMX_CONFIG_DIR/config.json`:OAuth token 或 API key。读取依赖当前 `mmx` 布局,不是 Mini App 的正式接口。 + - `<数据根目录>/v2/sqlite/runtime-state.sqlite`:只读,仅在手填回退时读取。`VACUUM INTO` 会在 `context.dataDir` 下创建临时快照,查询后删除。该宿主 schema 与路径均依赖未公开布局。 + - 自己的 `miniapp/client/index.html`,以及 `dataDir` 下的 `quota.json`。 +- 写入的文件: + - `~/.mmx/config.json`:登录写入、OAuth 刷新轮换 token;无关字段会保留。 + - `/quota.json`:手填总额度(同目录临时文件,再原子重命名)。 + - `/.usage-*` 下的临时 SQLite 快照,读取后删除。 +- 网络: + - `https://account.minimaxi.com` / `https://account.minimax.io`:设备码启动与轮询。 + - `https://www.minimaxi.com` / `https://platform.minimax.io`:通过桌面端租约会话读取 Token Plan 额度;不跟随重定向。 + - `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 进程和 CLI 配置文件里,不写页面、不写日志、不进错误响应。响应只暴露固定的错误码。 +- 源码与美术:实现和图标均为本包原创,见 `NOTICE.md`。 +- 持久状态:`dataDir` 下的 `quota.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 配置、凭据与设备码实现 +miniapp/node/usage.mjs 只读本机 token 用量与 SQLite 快照 +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 0000000000000000000000000000000000000000..dc44beba13b675198cf7c0c1964d9577d8dece01 GIT binary patch literal 4574 zcmaJ_c|4R|`#<+>CVLHHkQiI`hlfgv*T|kdTd7c4>M7YuV;_6QE`^AZy+tHU8B8Lw zL`4)KBTFG;XDshs&-*-|_xJohpZEUfy6zr6ib3<+pVGaNQx3Q7l zaR4CX6#{HK5OLb4#0>zRLSsE08^WiVyC;)vdWDGo=|aUvY^}te%F8|IFqwA_I%YiD z$eYH?v)@)FnH;VsDCjC^@Np1h^}9g^BpGc}XnITTyig2Pu9=JLWj$8}sZAn|gHpcp zh|D!%*5ZjFU#8!1Vb{D?kgek9iLI6F?d@5Onjh~bjM6b+WMA+k3ZMXB0{{a6MV$Du zI1&JLoxfUe0N?>2B4i;z42BO~{g(P?w>js?m^1{gg#S|fKf|iv)%E}CqsS5Y(oulI z4*wUFB)s~q>7V)|B#`LDi;zOtAZ~&Is6owrWyHZ?5Cs)<*fwOcLCKPA0Ky?ihyd_U zf%z$tBSGSs-N~Z6{UKyXX*k%=Lrd{(l*17B!G{Rb_lS#l@B+pezC%MefFdIJi1>-| z|DlKgLyUqC1z6z&a6}lgztsQc#zd9>U~$v6h=%vH4m z`g_#>upk&-9j*NvAOeJtL#)(bFC*|g@%lQvx*YBm%kw^+^qo~bGC;3tBYPuAV^lc1?6GB9oKU5^PJFDE-JM?9izQC4WipQsv7zR99rJerK(Wap_WdB(z4_=63 z?7h75t#tdCl+g2(8K#H*D@pqLnwDB4HiUjK#pByza}ad({>4Fo=SeQ1-!zBDIYzP; z8g0TuR?Jj|8bySTK4M@!xPQY8pyybgh}~Ou)o3|2o>zLn(qU+kFN!Pup8=w{E>ZJ0s>sb#=UBXJWuHvi{<|N&TRj+{SN`Gl7P8 zTkAiRx->)EQ;hhA&Br4?cz3AvP~1o5kGpg>rI>Fc8Mpmo!Et;|zD~E5xNJorwsF&9 ztn2dFux%x{i?(bFK`-4jrY8UlKIC-hjLq>2cYoNM?&H99ART@n~?{f}3zh~ZN>So?I1yphYNX*6-os9re9U!0s!0WGNAwfHnSM1K8E_Hs| z5EU6;`O@ayew33oD7E}K}G@)9prMFL*)8((WJ##4d|Uq-yQhu0SkJ&_zy?f`gueQ2wJ z&~E0ftmPQl5G0jo3i?Wq%v_VGCBIY%L+=giI2kxFFOhwY$qc4vD%RjFnEv&{VD){07o zGSQ>Lxsi}{!tfObo;d+6W=T9z_NWavgtfMm+30ADk2dGBZiCz>?OileNaSD2rY;)` z0optzK8xp2n0)XnhE>JP9!j1^0cQ6}+x0tmaJT`r# zv*D4s4VM#6waX)sI|-b-yocJ#xJeiE>u?RB*z9`4j=!gww=zB6#9+fkd4ywacT(&) z->^G(7wEHrG!5JW3q&C-hPtu9Ua{^6RXFirN_3M4v;!1*ik;S>mc>`RR>|jIP+?~I zG8W&0^7>4>xYfMf6OiH`;5$k{B{v=vCl+sbE~og9#)E$`*R#FQU*B#|nX%D~56byA z-nxSa&xB~>Hi#PwXTpV1Bdv);TKWvj*C%Hr)ZDE{_lNpvChf0g7tiRO&BCfWVTk&J zUg_JN5}6;r^AOuRmbG``7SIqyWiz)=KvW}z%7q8JwUPC^lhgS_(VylW zC18kO<1@C3LK$5bu$d?K@UYDTtiTkYUJQt>L^kZ4+> zydk25tU>@Ck|)ZjOBpP@q5DfcQ@XJeq4cY2C`QdSMK>X4zwpqxiXq0q7y6Ugaz-sK z9Knbo&Ve3UM2_EWnK$|a`F_LUJn?G|v`TRBG=ZcY=7HZR@o8HARPG3p8rJJW8GVlp zz?|gLgWv_d@m;X<4kE~aPRJ9LyEq1{ILINv)B8hx6qBT?Ax8dg;@7G#6!4*$RI}O_ zCXNGGw2H@>hXfSHmzgZWM$?CS9>wt^!E!|`l?D1$D-80{WSnyNtMhl$da5eE$ZZJ@ zM2}g0y)jc2h7>6{Q2Xo(aWod6R1?=hzjQutjeg$0srR>nux&NSbqEJIDTNLUQ`)D4 zDgKu|6p@LnYm?zBkF74$!<-m!*_lu`T1u;WzyrqSY0BCzL;cFc2=^#X*)Eoz>XvXr z@Ou^K>-^K(M=Y_wT!;Eq8R3C|@1}F}K$uKTY)9I1D*~&Vl?>n9v;b{E-baC$n5N`u z=eecRng9ClHA-+DnqP6^d z|E9M{>-YED?eHNl14`s^kW?)Jr4ydoDuI}9FEk*o42lXKz9jLUO1~iL{gj=N-v(uh znX`}Nv$#iIDnWNggKV-{CQDgw_)WNkQuWFS@Hu3lDYQs|VMe-t7!Vl=let~$4uxKc zi$~mpAmj-LW@6cBORls?J1J*1K8MULNut{5xK$Uc5n1U^rh0}4##O~Ippw`Q3l7Vx zCOy?}lqBAm(cy}pm`nlz7a{-r@fz>MfZ+h|dRxnwTPBq6)*h~mf-lwDXJDnY?N36yy)7%g3cY z$S@rfU+Qs6R7aW{yI@fVbo7h2V$km9P44z&B1^M!OJA7ON?szgIm2T`9r~@DrhjT~F?z`skYbsvUR(Dh~5oLGdzIOG|XF`?XqlVyZ z#(Z07@b>0VsVBZmT}U9E04JOlYW-X=b{R&6!lx^5hCg&5$MKQ^qX%jI_7ZxsiRiEdxaKP){MZJ3&PE*E}E*WvR7Hr z$!=dJjGcQ}>)CVKs&xgj%qt4`cQuz`Xv@LHQD$8CFDlCE$g>`JmA)aVjj`=j(vV;8 zGCLsptZvq0#h~~^8&6UP_rScCd~kKBw?lv9NC3=+goa=hMH~7&hxp5NV^)*^2 zZxhD+0-T~fhBs!#T&#X()Ldg*0-u1Z4&-?Ex!HG zMDcKwq;J${jS+}S+^|Y5Rj*LwIZiSZb>-Z9@qK*ZA1-kR87h3`dLUw7XCeW{lAAF_3amF|yRCB@XQ@u2Vw znViYP&AP)lNCBl%u)JhboLXq!a1H5YBpHtL!sTO=xo$_JG(HwsYc6(jxIuO3ZbtLP zv%)JMlzR8&N%3E=C!1*=ZoYS3Q=YvwTeU*q-njyhx;k@cHd5YRbJ1+7j~zLe))hxx z%<$3^7to@!liHgcZjiiT%gtG*YPs3HT~lhCeX;_3>2gva^AsE2w`|JEetu~*Rw}iN zMWKy9$-H7x&>}C8)#ZvfDB3pNR?6L~M0TyeQyh^F_gB}LWT1=_L27*0(|{W!D>#EX zqBUInGHph2g{=RnDc@T0@GE+t7P0GQluM#Ga6_ufj6ZWWhByQFzn3-*mofyg9JKO) z8yxU2^b8hz*hktq)gG1ronIWmEZS(^5uj+QL|0<|b(ZIL8ye8%&xmG3;E=y4p0|rd z+}Noc4iOVg)pTT!t^_&3U4alO zwxxHpL6P|ROrr~d$?ds=WU~=H5o!~UrQth=xCxshJBGL~7!9`$>HvZ1CS|d1`V38V z=jYKp&v3f8BRJ)EoqZ3*d$L2d!8fF+n$fNPfTe|Hngo0Ky)W DuhQUm literal 0 HcmV?d00001 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 00000000..b0e2f7a9 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/client/index.html @@ -0,0 +1,1631 @@ + + + + + +Quota Bottle + + + + +
+
+
+

MiniMax Code · Token Plan

+

Quota Bottle

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

—

+

—

+
+ No data + + + +
+
+ +
+ — + — / — + +
+
+ + + +
+ + + + + + + +
+ + Loading… +
+ + +
+ + + + diff --git a/plugins/7723qqq/quota-bottle/miniapp/miniapp.json b/plugins/7723qqq/quota-bottle/miniapp/miniapp.json new file mode 100644 index 00000000..b612ecee --- /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 00000000..6484fa80 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/auth.mjs @@ -0,0 +1,160 @@ +// @ts-check + +import { getConfigPath, updateConfigFile } from './config.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(); + +/** + * Write OAuth credentials without dropping unknown fields. The token replaces + * any API key and sets the region in the CLI's own config. + * @param {'cn' | 'global'} region + * @param {Record} credentials + * @param {AbortSignal} [signal] + */ +export async function saveOauth(region, credentials, signal) { + await updateConfigFile((raw) => { + if (signal?.aborted) throw new Error('aborted'); + delete raw.api_key; + raw.oauth = { ...credentials, region }; + raw.region = region; + delete raw.base_url; + }); +} + +/** + * Resolve a credential from the config snapshot supplied by the caller. + * @param {{ oauth?: (Record & { configPath?: string }) | 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) { + const token = await ensureFreshToken(oauth, signal); + return { token, method: /** @type {'oauth'} */ ('oauth') }; + } + 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 config write. + * @param {Record & { configPath?: string }} 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 ?? ''); + if (!refreshToken) { + if (Number.isFinite(expiresAt) && Date.now() < expiresAt - REFRESH_BUFFER_MS) return accessToken; + throw new QuotaError('oauth_expired'); + } + if (Number.isFinite(expiresAt) && Date.now() < expiresAt - REFRESH_BUFFER_MS) return accessToken; + const region = creds.region === 'cn' || creds.region === 'global' ? creds.region : 'global'; + const key = JSON.stringify([String(creds.configPath ?? getConfigPath()), 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 updateConfigFile((raw) => { + if (signal?.aborted) throw new Error('aborted'); + raw.oauth = updated; + }, String(creds.configPath ?? getConfigPath())); + 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 00000000..7c82d73f --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/config.mjs @@ -0,0 +1,199 @@ +// @ts-check + +import { randomUUID } from 'node:crypto'; +import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, 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']); + +/** @type {Map>} */ +const writeQueues = new Map(); + +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; +} + +async function withConfigLock(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); + } +} + +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 config as a raw object so an unrelated field is never lost by a + * credential update. A missing file is represented by an empty object. + * @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' }; + } +} + +/** + * Patch a raw config object, serialize writes, keep unrelated fields, and + * preserve the existing file's mode. The temp name is unique to this process. + * @param {(raw: Record) => void} patch + * @param {string} [path] + */ +export async function updateConfigFile(patch, path = getConfigPath()) { + return withConfigLock(path, async () => { + const existing = await readRawConfig(path); + if (!existing.ok) throw new Error(existing.code); + const next = { ...existing.raw }; + patch(next); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + let mode = 0o600; + try { + mode = (await stat(path)).mode & 0o777; + } catch (error) { + if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error; + } + const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode }); + await chmod(tmp, mode); + await rename(tmp, path); + } catch (error) { + await unlink(tmp).catch(() => {}); + throw error; + } + }); +} + +/** + * 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. + * @param {Record} [raw] + * @param {NodeJS.ProcessEnv} [env] + */ +export function configFromRaw(raw = {}, env = process.env) { + const file = parseConfigFile(raw); + const explicitRegion = env.MINIMAX_REGION; + const region = explicitRegion === 'cn' || explicitRegion === 'global' + ? explicitRegion + : 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 { + configPath: getConfigPath(), + region, + baseUrl: baseUrls[0], + baseUrls, + timeout: 20, + }; +} + +/** + * Read the region the CLI config asks for, so a login can start against the + * right account service instead of whichever one the panel happened to pick. + * @returns {Promise<'cn' | 'global' | null>} + */ +export async function readConfiguredRegion() { + const raw = await readRawConfig(); + if (!raw.ok) return null; + return normalizeRegion(parseConfigFile(raw.raw).region); +} + +/** + * @returns {Promise<{ ok: true, config: ReturnType, credentials: Record | null, apiKey: string | undefined } | { ok: false, code: string }>} + */ +export async function loadQuotaConfig() { + const raw = await readRawConfig(); + if (!raw.ok) return raw; + const file = parseConfigFile(raw.raw); + return { + ok: true, + config: configFromRaw(file), + credentials: file.oauth ? { ...file.oauth, configPath: getConfigPath() } : null, + apiKey: typeof file.api_key === 'string' ? file.api_key : undefined, + }; +} 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 00000000..d0c116ed --- /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/oauth-client.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/auth/oauth-client.mjs new file mode 100644 index 00000000..9e24fc49 --- /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 00000000..cc4efe2f --- /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 00000000..28e713a6 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/auth/quota.mjs @@ -0,0 +1,107 @@ +// @ts-check + +import { loadQuotaConfig } from './config.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 {AbortSignal} [signal] + * @param {{ fetchImpl?: typeof fetch }} [deps] + */ +export async function fetchQuota(signal, deps = {}) { + const snapshot = await loadQuotaConfig(); + 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.3.0', + '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/cache.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/cache.mjs new file mode 100644 index 00000000..be0c691f --- /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/desktop-lease.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/desktop-lease.mjs new file mode 100644 index 00000000..e8eb51e3 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/desktop-lease.mjs @@ -0,0 +1,196 @@ +// @ts-check + +import { createHash, randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import net from 'node:net'; +import { dirname, join } from 'node:path'; + +/** + * The desktop application keeps its account session in its own process and + * hands out short-lived leases to other local processes over a named pipe (or + * a unix socket). This is the supported way for a local tool to act as the + * signed-in desktop user, and it is how the bottle reads the same quota the + * desktop's own usage panel shows. + * + * The lease is short-lived on purpose: the token is requested per read, is + * never written to disk by this module, and is never logged. + */ +const CAPABILITY_FILE = 'mcode-auth-lease-v1.cap'; +const SOCKET_FILE = 'mcode-auth-lease-v1.sock'; +const WINDOWS_PIPE_PREFIX = '\\\\.\\pipe\\mcode-auth-lease-'; +const FRAME_PREFIX_BYTES = 4; +const MAX_FRAME_BYTES = 64 * 1024; +const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const REQUEST_TIMEOUT_MS = 10_000; +const CONNECT_TIMEOUT_MS = 5_000; + +/** + * @param {string} dataDir + * @param {NodeJS.Platform} [platform] + */ +export function resolveLeaseEndpoint(dataDir, platform = process.platform) { + const canonical = dataDir.replace(/[\\/]+$/u, ''); + if (platform === 'win32') { + const digest = createHash('sha256').update(canonical.toLowerCase()).digest('hex').slice(0, 16); + return { + transport: /** @type {'pipe'} */ ('pipe'), + endpoint: `${WINDOWS_PIPE_PREFIX}${digest}`, + capabilityFile: join(canonical, 'run', CAPABILITY_FILE), + }; + } + return { + transport: /** @type {'unix'} */ ('unix'), + endpoint: join(canonical, 'run', SOCKET_FILE), + capabilityFile: join(canonical, 'run', CAPABILITY_FILE), + }; +} + +/** + * The desktop's data root, inferred from the plugin's own data directory. + * The lease endpoint lives next to the runtime state, not inside it. + * @param {string} dataDir + */ +export function resolveDesktopDataRoot(dataDir) { + let dir = dataDir; + for (let i = 0; i < 12; i += 1) { + const parts = dir.split(/[\\/]/u); + if (parts[parts.length - 1] === 'v2') { + const parent = dirname(dir); + if (parent && parent !== dir) return parent; + } + const parent = dirname(dir); + if (!parent || parent === dir) break; + dir = parent; + } + return join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.minimax'); +} + +/** @param {unknown} value */ +function encodeFrame(value) { + const payload = Buffer.from(JSON.stringify(value), 'utf8'); + if (payload.length === 0 || payload.length > MAX_FRAME_BYTES) throw new Error('frame too large'); + const prefix = Buffer.allocUnsafe(FRAME_PREFIX_BYTES); + prefix.writeUInt32BE(payload.length, 0); + return Buffer.concat([prefix, payload]); +} + +/** + * @param {string} endpoint + * @param {string} capabilityFile + * @param {{ requestTimeoutMs?: number, minValidityMs?: number, signal?: AbortSignal }} [options] + */ +export async function requestAuthLease(endpoint, capabilityFile, options = {}) { + const timeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS; + const minValidityMs = options.minValidityMs ?? timeoutMs; + const signal = options.signal; + if (signal?.aborted) return { ok: false, code: 'desktop_unavailable' }; + let capability; + try { + capability = (await readFile(capabilityFile, 'utf8')).trim(); + } catch { + return { ok: false, code: 'desktop_unavailable' }; + } + if (!CAPABILITY_PATTERN.test(capability)) return { ok: false, code: 'desktop_unavailable' }; + + return await new Promise((resolve) => { + const socket = net.createConnection(endpoint); + const pending = new Map(); + let buffer = Buffer.alloc(0); + let settled = false; + + // Declared before `finish` reads it, so an abort during connect, write or wait + // tears the socket down through the same path as a timeout. + function onAbort() { + finish({ ok: false, code: 'desktop_unavailable' }); + } + + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + socket.destroy(); + resolve(result); + }; + + const timer = setTimeout(() => finish({ ok: false, code: 'desktop_unavailable' }), timeoutMs); + signal?.addEventListener('abort', onAbort, { once: true }); + + socket.on('error', () => finish({ ok: false, code: 'desktop_unavailable' })); + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= FRAME_PREFIX_BYTES) { + const length = buffer.readUInt32BE(0); + if (length === 0 || length > MAX_FRAME_BYTES) { + finish({ ok: false, code: 'invalid_response' }); + return; + } + if (buffer.length < FRAME_PREFIX_BYTES + length) break; + let frame; + try { + frame = JSON.parse(buffer.subarray(FRAME_PREFIX_BYTES, FRAME_PREFIX_BYTES + length).toString('utf8')); + } catch { + finish({ ok: false, code: 'invalid_response' }); + return; + } + buffer = buffer.subarray(FRAME_PREFIX_BYTES + length); + const waiter = pending.get(frame?.requestId); + if (!waiter) continue; + pending.delete(frame.requestId); + waiter(frame); + } + }); + + socket.once('connect', () => { + const requestId = randomUUID(); + const timer2 = setTimeout(() => { + pending.delete(requestId); + finish({ ok: false, code: 'desktop_unavailable' }); + }, timeoutMs); + pending.set(requestId, (frame) => { + clearTimeout(timer2); + if (frame?.ok !== true) { + const code = frame?.error?.code === 'AUTH_REQUIRED' ? 'desktop_session_expired' : 'desktop_unavailable'; + finish({ ok: false, code }); + return; + } + const result = frame.result; + if (result?.method !== 'lease' || typeof result.accessToken !== 'string' || !result.accessToken) { + finish({ ok: false, code: 'invalid_response' }); + return; + } + finish({ + ok: true, + accessToken: result.accessToken, + expiresAtMs: Number(result.expiresAtMs) || 0, + generation: Number(result.generation) || 0, + }); + }); + socket.write(encodeFrame({ + version: 1, + requestId, + capability, + method: 'lease', + minValidityMs: Math.min(5 * 60 * 1000, Math.max(0, minValidityMs)), + })); + }); + }); +} + +/** + * Ask the desktop for a live session token. + * @param {{ dataDir: string, env?: NodeJS.ProcessEnv, minValidityMs?: number, signal?: AbortSignal }} options + * @returns {Promise<{ ok: true, accessToken: string, expiresAtMs: number, generation: number } | { ok: false, code: string }>} + */ +export async function requestDesktopLease(options) { + const env = options.env ?? process.env; + const leaseOptions = { signal: options.signal, minValidityMs: options.minValidityMs }; + const explicitEndpoint = env.MCODE_AUTH_BROKER_ENDPOINT?.trim(); + const explicitCapability = env.MCODE_AUTH_BROKER_CAPABILITY_FILE?.trim(); + if (explicitEndpoint && explicitCapability) { + return await requestAuthLease(explicitEndpoint, explicitCapability, leaseOptions); + } + const dataRoot = resolveDesktopDataRoot(options.dataDir); + const endpoint = resolveLeaseEndpoint(dataRoot); + return await requestAuthLease(endpoint.endpoint, endpoint.capabilityFile, leaseOptions); +} diff --git a/plugins/7723qqq/quota-bottle/miniapp/node/desktop-session.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/desktop-session.mjs new file mode 100644 index 00000000..cb3442fd --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/desktop-session.mjs @@ -0,0 +1,118 @@ +// @ts-check + +import { QuotaError, pickMostSpecific } from './errors.mjs'; +import { requestDesktopLease } from './desktop-lease.mjs'; +import { fetchWithoutRedirect } from './net.mjs'; + +/** + * The desktop keeps its account session in its own process and lends it out + * over the auth lease. The lease hands back a token and nothing else, so the + * quota is exactly one request away — and that request needs neither a client + * signature nor a group lookup. + * + * The account's region is a property of the installed client, and the Host + * does not pass it down: a Mini App process starts with a filtered environment + * that carries no region. So the region is discovered instead of guessed. The + * leased token was issued by the client, so only the client's own region will + * accept it; asking both is how "same region as the client" becomes a fact + * rather than an assumption. + */ +const OPEN_PLATFORM_ORIGINS = Object.freeze({ + cn: 'https://www.minimaxi.com', + global: 'https://platform.minimax.io', +}); + +const TOKEN_PLAN_REMAINS_PATH = '/v1/api/openplatform/coding_plan/remains'; +const REQUEST_TIMEOUT_MS = 8_000; +const LEASE_MIN_VALIDITY_MS = 10_000; + +/** + * The client's region first when it is known, the other one second. Order only + * decides which host is asked first; correctness comes from asking both. + * @param {'cn' | 'global'} preferred + * @returns {Array<'cn' | 'global'>} + */ +function regionOrder(preferred) { + return preferred === 'cn' ? ['cn', 'global'] : ['global', 'cn']; +} + +/** + * Read the Token Plan quota through the desktop's own session. + * + * @param {{ dataDir: string }} context + * @param {{ preferredRegion?: 'cn' | 'global' | null, fetchImpl?: typeof fetch, signal?: AbortSignal, env?: NodeJS.ProcessEnv }} [options] + * @returns {Promise<{ payload: any, region: 'cn' | 'global', detail: string }>} throws when no desktop session is usable + */ +export async function fetchDesktopSessionQuota(context, options = {}) { + const lease = await requestDesktopLease({ + dataDir: context.dataDir, + env: options.env, + minValidityMs: LEASE_MIN_VALIDITY_MS, + signal: options.signal, + }); + if (!lease.ok) { + // No desktop session: the desktop is signed out or not running. The caller + // decides whether another source can still answer. + throw new QuotaError(lease.code === 'desktop_session_expired' ? 'desktop_session_expired' : 'desktop_unavailable'); + } + + /** @type {string[]} */ + const failures = []; + for (const region of regionOrder(options.preferredRegion ?? 'cn')) { + try { + const payload = await readTokenPlanQuota(region, lease.accessToken, options); + return { payload, region, detail: 'desktop session' }; + } catch (error) { + if (!(error instanceof QuotaError)) throw error; + failures.push(error.code); + } + } + // When every origin failed the same way, that code is the answer; otherwise + // report the failure the user can act on. + const code = failures.length > 0 && failures.every((value) => value === failures[0]) + ? failures[0] + : pickMostSpecific(failures); + throw new QuotaError(code); +} + +/** + * One credentialed read, shaped exactly like the desktop's own request: a + * bearer token, no query string, no signature, and `X-Group-Id` only when the + * group is already known — which makes it optional here too. + * + * @param {'cn' | 'global'} region + * @param {string} accessToken + * @param {{ fetchImpl?: typeof fetch, signal?: AbortSignal }} options + */ +async function readTokenPlanQuota(region, accessToken, options) { + const response = await fetchWithoutRedirect( + `${OPEN_PLATFORM_ORIGINS[region]}${TOKEN_PLAN_REMAINS_PATH}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }, + options.signal, + REQUEST_TIMEOUT_MS, + options.fetchImpl, + ); + if (!response.ok) { + throw new QuotaError(response.status === 401 || response.status === 403 ? 'unauthorized' : 'http_error'); + } + let payload; + try { + payload = await response.json(); + } catch { + throw new QuotaError('invalid_response'); + } + const status = payload?.base_resp?.status_code; + if (typeof status === 'number' && status !== 0) { + throw new QuotaError(status === 2049 ? 'invalid_credential' : status === 1004 ? 'missing_credential' : 'api_rejected'); + } + if (!Array.isArray(payload?.model_remains) || payload.model_remains.length === 0) { + throw new QuotaError('no_quota'); + } + return payload; +} 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 00000000..bf05c896 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/errors.mjs @@ -0,0 +1,105 @@ +// @ts-check + +export const ERROR_CODES = Object.freeze([ + 'config_invalid', + 'config_unreadable', + 'no_credential', + 'oauth_expired', + 'unauthorized', + 'invalid_credential', + 'missing_credential', + 'desktop_session_expired', + 'desktop_unavailable', + 'api_rejected', + 'quota_exhausted', + 'http_error', + 'network', + 'timeout', + 'invalid_response', + 'no_quota', + 'balance_credential', + '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', + 'desktop_session_expired', + '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([ + 'desktop_session_expired', + 'invalid_credential', + 'missing_credential', + 'unauthorized', + 'oauth_expired', + 'no_credential', + 'balance_credential', + 'quota_exhausted', + 'desktop_unavailable', + '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 00000000..f5d8b7a4 --- /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 00000000..d5a525cd --- /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 00000000..f3e523c7 --- /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 desktop'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 00000000..135697a3 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/server.mjs @@ -0,0 +1,413 @@ +// @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, readConfiguredRegion } from './auth/config.mjs'; +import { openBrowser, pollDeviceLogin, startDeviceLogin } from './auth/oauth.mjs'; +import { fetchDesktopSessionQuota } from './desktop-session.mjs'; +import { fetchQuota, manualState, normalizeQuota } from './quota.mjs'; +import { createStore, normalizeTotal } from './store.mjs'; +import { monthWindow, readUsedTokens } from './usage.mjs'; + +// The desktop reads the same endpoint with a five second cache of its own, so +// three seconds here keeps a pinned window close to live without adding load. +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); + /** @type {'cn' | 'global' | null} */ + let lastLiveRegion = null; + /** @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() { + /** @type {string[]} */ + const failures = []; + // The desktop's own session first: it is a credential the user already has, + // and it reports the same Token Plan quota the desktop shows. + try { + // The client's region is not ours to know: the first read discovers it, + // and later reads start with the region that answered. + const preferred = lastLiveRegion ?? (await readConfiguredRegion()); + const session = await fetchDesktopSessionQuota(context, { + preferredRegion: preferred, + signal: service.signal, + }); + const normalized = normalizeQuota(session.payload); + if (normalized.ok) { + lastLiveRegion = session.region; + context.logger.info('quota-bottle.live.ok', { source: 'desktop', region: session.region, models: normalized.models.length }); + return { ...normalized, detail: session.detail, region: session.region }; + } + failures.push(normalized.code); + } catch (error) { + const code = error instanceof QuotaError ? error.code : 'unknown'; + context.logger.warn('quota-bottle.live.desktop_failed', { code }); + failures.push(code); + } + try { + const payload = await fetchQuota(service.signal); + const normalized = normalizeQuota(payload); + if (normalized.ok) { + context.logger.info('quota-bottle.live.ok', { source: 'cli', models: normalized.models.length }); + return { ...normalized, detail: 'token-plan' }; + } + context.logger.warn('quota-bottle.live.empty', { code: normalized.code }); + failures.push(normalized.code); + } catch (error) { + const code = error instanceof QuotaError ? error.code : 'unknown'; + context.logger.warn('quota-bottle.live.failed', { code }); + failures.push(code); + } + return { ok: false, code: safeErrorCode(pickMostSpecific(failures)) }; + } + + async function liveState(fresh) { + return liveCache.get(fresh, () => track(liveValue())); + } + + async function usageState(window, fresh) { + return usageCache.get(fresh, () => track(readUsedTokens(window, context)), window.label); + } + + async function buildState(fresh = false) { + const live = await liveState(fresh); + if (live.ok) { + // The desktop's panel reads the first row of `model_remains`; taking a + // different row here would show a second number next to that panel. + const primary = live.models[0]; + return { + generatedAt: Date.now(), + mode: 'live', + region: live.region, + 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(); + const problems = [live.code]; + if (!used.ok) problems.push(used.code); + const base = { + generatedAt: Date.now(), + region, + 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; }, + 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); + 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(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 {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/store.mjs b/plugins/7723qqq/quota-bottle/miniapp/node/store.mjs new file mode 100644 index 00000000..690267e7 --- /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 00000000..3a7059d2 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/urls.mjs @@ -0,0 +1,111 @@ +// @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; +} + +/** @param {unknown} value */ +export function normalizeAbsoluteExpiry(value) { + return typeof value === 'number' && Number.isFinite(value) && value > Date.now() && value < Date.now() + 24 * 60 * 60 * 1000 + ? value + : null; +} + +/** + * Read an expiry that may arrive as an absolute timestamp or as a lifetime in + * seconds. A value below one trillion is a lifetime, so a service that counts + * down instead of counting to a moment still yields a usable expiry. + * @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); + return normalizeAbsoluteExpiry(Date.now() + number * 1000); +} + +/** @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 00000000..b6575d9b --- /dev/null +++ b/plugins/7723qqq/quota-bottle/miniapp/node/usage.mjs @@ -0,0 +1,127 @@ +// @ts-check + +import { mkdir, rm, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join, parse } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +/** + * Resolve the active Host data root. context.dataDir may live beneath that + * root, so a bounded ancestor walk is preferred over guessing a home path. + * @param {MiniAppContext} context + * @param {NodeJS.ProcessEnv} [env] + */ +export function dataRoot(context, env = process.env) { + const fromDataDir = findDataRoot(context.dataDir); + if (fromDataDir) return fromDataDir; + 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 {MiniAppContext} context @param {NodeJS.ProcessEnv} [env] */ +export function usageDbPath(context, env) { + return join(dataRoot(context, env), 'v2', 'sqlite', 'runtime-state.sqlite'); +} + +/** @param {string} dataDir */ +function findDataRoot(dataDir) { + if (!dataDir) return null; + let dir = dataDir; + for (let i = 0; i < 12; i += 1) { + if (parse(dir).root === dir) break; + if (basename(dir) === 'v2') return dirname(dir); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function basename(value) { + return value.split(/[\\/]/).pop() ?? ''; +} + +/** @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. + * @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(context, 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 00000000..aab6b8dc --- /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 00000000..2ed88867 --- /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 00000000..491e9ab3 --- /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 00000000..11eb37f5 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/config-snapshot.test.mjs @@ -0,0 +1,72 @@ +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, loadQuotaConfig, readRawConfig } from '../miniapp/node/auth/config.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('loadQuotaConfig exposes credentials and API key from the same read', async () => { + const dir = await tempDir('quota-config-snapshot-'); + try { + process.env.MMX_CONFIG_DIR = dir; + await mkdir(dir, { recursive: true }); + await writeJson(join(dir, 'config.json'), { + api_key: 'key', + oauth: { access_token: 'access', refresh_token: 'refresh', expires_at: '2030-01-01T00:00:00.000Z', region: 'cn' }, + }); + const snapshot = await loadQuotaConfig(); + assert.equal(snapshot.ok, true); + assert.equal(snapshot.apiKey, 'key'); + assert.equal(snapshot.credentials?.access_token, 'access'); + assert.equal(snapshot.credentials?.configPath, join(dir, 'config.json')); + } finally { + delete process.env.MMX_CONFIG_DIR; + await disposeTemp(dir); + } +}); + +test('loadQuotaConfig reports corrupt JSON instead of silently using no credentials', async () => { + const dir = await tempDir('quota-config-corrupt-snapshot-'); + try { + process.env.MMX_CONFIG_DIR = dir; + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'config.json'), '{', 'utf8'); + assert.deepEqual(await loadQuotaConfig(), { ok: false, code: 'config_invalid' }); + const raw = await readRawConfig(join(dir, 'config.json')); + assert.equal(raw.ok, false); + } finally { + delete process.env.MMX_CONFIG_DIR; + 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'); +}); 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 00000000..673b32e6 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/config.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { chmod, mkdir, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { parseConfigFile, readRawConfig, updateConfigFile } from '../miniapp/node/auth/config.mjs'; +import { disposeTemp, readJson, 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('updateConfigFile preserves unknown fields and serializes concurrent writes', async () => { + const dir = await tempDir('quota-config-'); + try { + const file = join(dir, 'nested', 'config.json'); + await mkdir(join(dir, 'nested'), { recursive: true }); + await writeJson(file, { future_field: { nested: true }, region: 'cn', oauth: { access_token: 'old', refresh_token: 'r', expires_at: '2030-01-01' } }); + await chmod(file, 0o640); + await Promise.all([ + updateConfigFile((raw) => { raw.first = 1; }, file), + updateConfigFile((raw) => { raw.second = 2; }, file), + ]); + const result = await readJson(file); + assert.equal(result.future_field.nested, true); + assert.equal(result.region, 'cn'); + assert.equal(result.first, 1); + assert.equal(result.second, 2); + assert.ok((await stat(file)).size > 0); + } finally { + await disposeTemp(dir); + } +}); + +test('readRawConfig distinguishes a missing file from invalid JSON', async () => { + const dir = await tempDir('quota-config-read-'); + 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); + } +}); + +test('updateConfigFile refuses a corrupt config and leaves it untouched', async () => { + const dir = await tempDir('quota-config-corrupt-'); + try { + const file = join(dir, 'config.json'); + await writeFile(file, '{ broken', 'utf8'); + await assert.rejects(updateConfigFile((raw) => { raw.oauth = {}; }, file), /config_invalid/); + assert.equal(await (await import('node:fs/promises')).readFile(file, 'utf8'), '{ broken'); + } finally { + await disposeTemp(dir); + } +}); diff --git a/plugins/7723qqq/quota-bottle/tests/desktop-lease.test.mjs b/plugins/7723qqq/quota-bottle/tests/desktop-lease.test.mjs new file mode 100644 index 00000000..6639ac3b --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/desktop-lease.test.mjs @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import net from 'node:net'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { fetchDesktopSessionQuota } from '../miniapp/node/desktop-session.mjs'; +import { requestAuthLease, requestDesktopLease } from '../miniapp/node/desktop-lease.mjs'; +import { disposeTemp, tempDir } from './helpers.mjs'; + +const CAPABILITY = 'a'.repeat(43); + +/** @param {unknown} value */ +function encodeFrame(value) { + const payload = Buffer.from(JSON.stringify(value), 'utf8'); + const prefix = Buffer.allocUnsafe(4); + prefix.writeUInt32BE(payload.length, 0); + return Buffer.concat([prefix, payload]); +} + +/** + * A stand-in for the desktop's lease broker: it speaks the same framed + * protocol over a named pipe (Windows) or a unix socket (elsewhere). + * @param {{ token: string, status?: string, reject?: boolean }} options + */ +async function startBroker(options) { + const suffix = Math.random().toString(36).slice(2); + const endpoint = process.platform === 'win32' + ? `\\\\.\\pipe\\quota-bottle-test-${suffix}` + : join(process.env.TMPDIR ?? '/tmp', `quota-bottle-test-${suffix}.sock`); + const server = net.createServer((socket) => { + let buffer = Buffer.alloc(0); + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const length = buffer.readUInt32BE(0); + if (buffer.length < 4 + length) break; + const request = JSON.parse(buffer.subarray(4, 4 + length).toString('utf8')); + buffer = buffer.subarray(4 + length); + const reply = options.reject + ? { version: 1, requestId: request.requestId, ok: false, error: { code: 'AUTH_REQUIRED', message: 'Desktop authentication is required.' } } + : request.method === 'status' + ? { version: 1, requestId: request.requestId, ok: true, result: { method: 'status', status: options.status ?? 'authenticated', generation: 7 } } + : { version: 1, requestId: request.requestId, ok: true, result: { method: 'lease', accessToken: options.token, expiresAtMs: Date.now() + 3_600_000, generation: 7, audience: 'agent-backend', scopes: ['agent.default'] } }; + socket.write(encodeFrame(reply)); + } + }); + }); + await new Promise((resolve) => server.listen(endpoint, resolve)); + return { endpoint, close: () => new Promise((resolve) => server.close(resolve)) }; +} + +/** @param {string} dir */ +async function writeCapability(dir) { + const capabilityFile = join(dir, 'mcode-auth-lease-v1.cap'); + await writeFile(capabilityFile, CAPABILITY, 'utf8'); + return capabilityFile; +} + +test('the broker hands out a lease over the framed protocol', async () => { + const dir = await tempDir('quota-lease-'); + const broker = await startBroker({ token: 'mmoat_test_token' }); + try { + const capabilityFile = await writeCapability(dir); + const lease = await requestAuthLease(broker.endpoint, capabilityFile); + assert.equal(lease.ok, true); + assert.equal(lease.accessToken, 'mmoat_test_token'); + assert.equal(lease.generation, 7); + } finally { + await broker.close(); + await disposeTemp(dir); + } +}); + +test('a rejected lease is reported, never silently treated as a session', async () => { + const dir = await tempDir('quota-lease-reject-'); + const broker = await startBroker({ token: '', reject: true }); + try { + const capabilityFile = await writeCapability(dir); + const lease = await requestAuthLease(broker.endpoint, capabilityFile); + assert.equal(lease.ok, false); + assert.equal(lease.code, 'desktop_session_expired'); + } finally { + await broker.close(); + await disposeTemp(dir); + } +}); + +test('the desktop quota asks the Token Plan endpoint with the leased token and nothing else', async () => { + const dir = await tempDir('quota-lease-read-'); + const broker = await startBroker({ token: 'mmoat_test_token' }); + try { + const capabilityFile = await writeCapability(dir); + const calls = []; + const fetchImpl = async (url, init = {}) => { + calls.push({ href: String(url), init }); + return { + ok: true, + status: 200, + json: async () => ({ + base_resp: { status_code: 0 }, + model_remains: [{ model_name: 'general', current_interval_total_count: 0, current_interval_remaining_percent: 33 }], + }), + }; + }; + const result = await fetchDesktopSessionQuota({ dataDir: join(dir, 'x') }, { + preferredRegion: 'cn', + fetchImpl, + env: { MCODE_AUTH_BROKER_ENDPOINT: broker.endpoint, MCODE_AUTH_BROKER_CAPABILITY_FILE: capabilityFile }, + }); + + assert.equal(result.region, 'cn'); + assert.equal(result.payload.model_remains[0].current_interval_remaining_percent, 33); + assert.equal(calls.length, 1); + + const [call] = calls; + assert.equal(call.href, 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains'); + assert.equal(call.init.method, 'GET'); + assert.equal(call.init.headers.Authorization, 'Bearer mmoat_test_token'); + // The desktop sends the group id only when it already knows it, and never + // signs this request. Neither header belongs here. + assert.equal(call.init.headers['X-Group-Id'], undefined); + assert.equal(call.init.headers['x-signature'], undefined); + assert.equal(call.init.headers.yy, undefined); + assert.equal(call.init.redirect, 'error'); + } finally { + await broker.close(); + await disposeTemp(dir); + } +}); + +test('the region is discovered from the token, not guessed', async () => { + const dir = await tempDir('quota-lease-region-'); + const broker = await startBroker({ token: 'mmoat_test_token' }); + try { + const capabilityFile = await writeCapability(dir); + const calls = []; + const fetchImpl = async (url) => { + const href = String(url); + calls.push(href); + if (href.startsWith('https://www.minimaxi.com/')) { + // A token issued by one region's client is refused by the other one. + return { ok: true, status: 200, json: async () => ({ base_resp: { status_code: 1004 } }) }; + } + return { + ok: true, + status: 200, + json: async () => ({ + base_resp: { status_code: 0 }, + model_remains: [{ model_name: 'general', current_interval_total_count: 0, current_interval_remaining_percent: 61 }], + }), + }; + }; + const result = await fetchDesktopSessionQuota({ dataDir: join(dir, 'x') }, { + preferredRegion: 'cn', + fetchImpl, + env: { MCODE_AUTH_BROKER_ENDPOINT: broker.endpoint, MCODE_AUTH_BROKER_CAPABILITY_FILE: capabilityFile }, + }); + assert.equal(result.region, 'global'); + assert.deepEqual(calls, [ + 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains', + 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains', + ]); + } finally { + await broker.close(); + await disposeTemp(dir); + } +}); + +test('a refusal from both regions is reported as the failure the user can act on', async () => { + const dir = await tempDir('quota-lease-refused-'); + const broker = await startBroker({ token: 'mmoat_test_token' }); + try { + const capabilityFile = await writeCapability(dir); + const result = await fetchDesktopSessionQuota({ dataDir: join(dir, 'x') }, { + preferredRegion: 'cn', + fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({}) }), + env: { MCODE_AUTH_BROKER_ENDPOINT: broker.endpoint, MCODE_AUTH_BROKER_CAPABILITY_FILE: capabilityFile }, + }).then((value) => ({ ok: true, value }), (error) => ({ ok: false, code: error.code })); + assert.equal(result.ok, false); + assert.equal(result.code, 'unauthorized'); + } finally { + await broker.close(); + await disposeTemp(dir); + } +}); + +test('the desktop quota reports a signed-out desktop instead of guessing', async () => { + const dir = await tempDir('quota-lease-signedout-'); + const broker = await startBroker({ token: '', reject: true }); + try { + const capabilityFile = await writeCapability(dir); + const result = await fetchDesktopSessionQuota({ dataDir: join(dir, 'x') }, { + region: 'cn', + fetchImpl: async () => { throw new Error('should not reach the network'); }, + env: { MCODE_AUTH_BROKER_ENDPOINT: broker.endpoint, MCODE_AUTH_BROKER_CAPABILITY_FILE: capabilityFile }, + }).then((value) => ({ ok: true, value }), (error) => ({ ok: false, code: error.code })); + assert.equal(result.ok, false); + assert.equal(result.code, 'desktop_session_expired'); + } finally { + await broker.close(); + await disposeTemp(dir); + } +}); + +test('the lease endpoint falls back to the desktop data root when no override is set', async () => { + const dir = await tempDir('quota-lease-root-'); + try { + const lease = await requestDesktopLease({ + dataDir: join(dir, 'v2', 'plugin-data', 'liveboards', 'quota-bottle'), + env: {}, + }); + assert.equal(lease.ok, false); + assert.equal(lease.code, 'desktop_unavailable'); + } finally { + await disposeTemp(dir); + } +}); diff --git a/plugins/7723qqq/quota-bottle/tests/desktop-session.test.mjs b/plugins/7723qqq/quota-bottle/tests/desktop-session.test.mjs new file mode 100644 index 00000000..0d46df99 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/desktop-session.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { fetchDesktopSessionQuota } from '../miniapp/node/desktop-session.mjs'; +import { resolveLeaseEndpoint, resolveDesktopDataRoot } from '../miniapp/node/desktop-lease.mjs'; +import { disposeTemp, tempDir } from './helpers.mjs'; + +function jsonResponse(status, body) { + return { ok: status >= 200 && status < 300, status, json: async () => body }; +} + +test('the lease endpoint is derived from the desktop data root', () => { + const endpoint = resolveLeaseEndpoint('C:\\Users\\Someone\\.minimax', 'win32'); + assert.equal(endpoint.transport, 'pipe'); + assert.match(endpoint.endpoint, /^\\\\\.\\pipe\\mcode-auth-lease-[a-f0-9]{16}$/u); + assert.equal(endpoint.capabilityFile, join('C:\\Users\\Someone\\.minimax', 'run', 'mcode-auth-lease-v1.cap')); +}); + +test('the data root is the ancestor of the v2 directory', () => { + const root = resolveDesktopDataRoot(join('C:\\Users\\Someone\\.minimax', 'v2', 'plugin-data', 'liveboards', 'quota-bottle')); + assert.equal(root, join('C:\\Users\\Someone\\.minimax')); +}); + +test('without a desktop session the quota read reports it instead of guessing', async () => { + const dir = await tempDir('quota-desktop-none-'); + try { + const dataDir = join(dir, 'no-desktop', 'v2', 'plugin-data', 'quota-bottle'); + await mkdir(dataDir, { recursive: true }); + const result = await fetchDesktopSessionQuota({ dataDir }, { + env: { MCODE_AUTH_BROKER_ENDPOINT: join(dir, 'missing.sock'), MCODE_AUTH_BROKER_CAPABILITY_FILE: join(dir, 'missing.cap') }, + }).then((value) => ({ ok: true, value }), (error) => ({ ok: false, code: error.code })); + assert.equal(result.ok, false); + assert.equal(result.code, 'desktop_unavailable'); + } finally { + await disposeTemp(dir); + } +}); + +test('a missing capability file is never mistaken for a usable session', async () => { + const dir = await tempDir('quota-desktop-nocap-'); + try { + const dataDir = join(dir, 'no-cap', 'v2', 'plugin-data', 'quota-bottle'); + await mkdir(dataDir, { recursive: true }); + await writeFile(join(dir, 'empty.cap'), 'not-a-capability', 'utf8'); + const result = await fetchDesktopSessionQuota({ dataDir }, { + env: { MCODE_AUTH_BROKER_ENDPOINT: join(dir, 'missing.sock'), MCODE_AUTH_BROKER_CAPABILITY_FILE: join(dir, 'empty.cap') }, + }).then((value) => ({ ok: true, value }), (error) => ({ ok: false, code: error.code })); + assert.equal(result.ok, false); + assert.equal(result.code, 'desktop_unavailable'); + } 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 00000000..a2906795 --- /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 00000000..845c2f99 --- /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 00000000..f778e8d7 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/oauth.test.mjs @@ -0,0 +1,121 @@ +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 () => { + const creds = { + access_token: 'old', + refresh_token: 'refresh', + expires_at: '2020-01-01T00:00:00.000Z', + region: 'cn', + }; + 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'); +}); + +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); +}); 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 00000000..cfb95dd5 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/quota-fetch.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import test from 'node:test'; + +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; + return await run(); + } 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 () => { + const { impl, calls } = stubFetch({ [CN_HOST]: { status: 200, body: okBody } }); + const payload = await fetchQuota(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 () => { + 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(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 () => { + 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(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 () => { + 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(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 () => { + const unauthorized = stubFetch({ [GLOBAL_HOST]: { status: 401, body: {} } }); + await assert.rejects(fetchQuota(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(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 () => { + const { impl, calls } = stubFetch({}); + await assert.rejects(fetchQuota(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 () => { + const calls = []; + const impl = async (url) => { + calls.push(url); + throw new Error('offline'); + }; + await assert.rejects(fetchQuota(undefined, { fetchImpl: impl }), (error) => error.code === 'network'); + assert.deepEqual(calls, [CN_HOST]); + }); +}); 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 00000000..3ebe1b2e --- /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 desktop reports the text plan 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, the row the desktop panel reads', () => { + 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 desktop's own usage 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 00000000..9ef08e59 --- /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 desktop 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 00000000..077a3e69 --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/server.test.mjs @@ -0,0 +1,96 @@ +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; + // A test must not borrow the developer's real desktop session: point the + // lease at a capability file that cannot exist, so the manual path is what + // this server is actually exercising. + process.env.MCODE_AUTH_BROKER_ENDPOINT = join(root, 'no-broker.sock'); + process.env.MCODE_AUTH_BROKER_CAPABILITY_FILE = join(root, 'no-broker.cap'); + 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); + } +}); 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 00000000..69c285ab --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/state.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { mkdir } 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 context = { + ...fakeContext(root, { port, signal: controller.signal }), + dataDir: join(root, 'root', 'v2', 'plugin-data', 'quota-bottle'), + }; + await mkdir(context.dataDir, { recursive: true }); + if (withUsage) { + await mkdir(join(root, 'root', 'v2', 'sqlite'), { recursive: true }); + const db = new DatabaseSync(usageDbPath(context, {})); + 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_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'); + } 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 }); + try { + const saved = await fetch(`http://127.0.0.1:${port}/api/quota`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ total: 1000 }), + }); + assert.equal(saved.status, 200); + const state = await (await fetch(`http://127.0.0.1:${port}/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 00000000..babd3f44 --- /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 00000000..c20419eb --- /dev/null +++ b/plugins/7723qqq/quota-bottle/tests/usage.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +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'; + +const contextFor = (root, dataDir = join(root, 'root', 'v2', 'plugin-data', 'quota-bottle')) => ({ dataDir }); + +async function makeDb(root, table = true) { + const context = contextFor(root); + await mkdir(join(root, 'root', 'v2', 'sqlite'), { recursive: true }); + await mkdir(context.dataDir, { recursive: true }); + const file = usageDbPath(context, {}); + 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; +} + +test('dataRoot prefers the active data directory ancestor', () => { + const root = join('G:', 'mimimaxcode', 'instance'); + assert.equal(dataRoot(contextFor(root), {}), join(root, 'root')); +}); + +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 = await makeDb(dir); + const result = await readUsedTokens({ startMs: 100, endMs: 300 }, context, {}); + 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, {}), { ok: false, code: 'db_schema' }); + const missing = contextFor(join(dir, 'missing')); + assert.equal((await readUsedTokens({ startMs: 0, endMs: 999 }, missing, {})).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 00000000..94ab62ec --- /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); +}); From 80857c2e032b0f5b3cc909692043a77321ec94dc Mon Sep 17 00:00:00 2001 From: 7723qqq <65469696+7723qqq@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:43:14 +0800 Subject: [PATCH 2/3] fix(quota-bottle): read quota only from user credentials, gate local usage Review follow-up on #116. - Remove the host auth-lease path; the live quota now comes only from a device-code login stored under context.dataDir or a read-only API key / OAuth access token the user configured in the CLI config. - Stop rewriting ~/.mmx/config.json. Login and refresh write dataDir/credentials.json; CLI fields are never removed. - Make the host SQLite usage scan opt-in (default off) with a panel switch, and resolve its data root only from MINIMAX_DATA_DIR / MAVIS_HOME / ~/.minimax. - Accept the account service's absolute 48-hour token expiry; the old 24-hour cap made a successful login look like an error. - Fill the ears, neck and mouth at 100%, and show the reset countdown, weekly quota and video usage in the floating window. Version 1.15.3. --- README.md | 2 +- README.zh-CN.md | 2 +- .../quota-bottle/.minimax-plugin/plugin.json | 4 +- plugins/7723qqq/quota-bottle/README.md | 152 ++++++------ plugins/7723qqq/quota-bottle/README.zh-CN.md | 71 +++--- .../quota-bottle/miniapp/client/index.html | 142 ++++++++++-- .../quota-bottle/miniapp/node/auth/auth.mjs | 55 +++-- .../quota-bottle/miniapp/node/auth/config.mjs | 91 +------- .../miniapp/node/auth/credentials.mjs | 90 ++++++++ .../quota-bottle/miniapp/node/auth/quota.mjs | 9 +- .../miniapp/node/auth/snapshot.mjs | 52 +++++ .../miniapp/node/desktop-lease.mjs | 196 ---------------- .../miniapp/node/desktop-session.mjs | 118 ---------- .../quota-bottle/miniapp/node/errors.mjs | 6 +- .../quota-bottle/miniapp/node/quota.mjs | 2 +- .../quota-bottle/miniapp/node/server.mjs | 73 +++--- .../quota-bottle/miniapp/node/settings.mjs | 47 ++++ .../quota-bottle/miniapp/node/urls.mjs | 25 +- .../quota-bottle/miniapp/node/usage.mjs | 39 +--- .../tests/config-snapshot.test.mjs | 86 +++++-- .../quota-bottle/tests/config.test.mjs | 46 ++-- .../quota-bottle/tests/credentials.test.mjs | 83 +++++++ .../quota-bottle/tests/desktop-lease.test.mjs | 218 ------------------ .../tests/desktop-session.test.mjs | 55 ----- .../7723qqq/quota-bottle/tests/oauth.test.mjs | 40 ++++ .../quota-bottle/tests/quota-fetch.test.mjs | 55 +++-- .../quota-bottle/tests/quota-shape.test.mjs | 12 +- .../7723qqq/quota-bottle/tests/quota.test.mjs | 2 +- .../quota-bottle/tests/server.test.mjs | 23 +- .../7723qqq/quota-bottle/tests/state.test.mjs | 59 ++++- .../7723qqq/quota-bottle/tests/usage.test.mjs | 30 +-- 31 files changed, 885 insertions(+), 1000 deletions(-) create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/auth/credentials.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/auth/snapshot.mjs delete mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/desktop-lease.mjs delete mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/desktop-session.mjs create mode 100644 plugins/7723qqq/quota-bottle/miniapp/node/settings.mjs create mode 100644 plugins/7723qqq/quota-bottle/tests/credentials.test.mjs delete mode 100644 plugins/7723qqq/quota-bottle/tests/desktop-lease.test.mjs delete mode 100644 plugins/7723qqq/quota-bottle/tests/desktop-session.test.mjs diff --git a/README.md b/README.md index a599e585..97d9f7ea 100644 --- a/README.md +++ b/README.md @@ -87,7 +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 the desktop itself reports — no second login — with the liquid level and opacity following the remaining share, and a device-code login plus a local token-usage estimate as fallbacks | [7723qqq](https://github.com/7723qqq) | +| [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 ec081e7d..7643cba5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -88,7 +88,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [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/prompt-optimizer/README.md) | 把随手写的草稿扩展成写全了的文件、改动和完成标准的请求,确认后再填入聊天输入框 | [7723qqq](https://github.com/7723qqq) | -| [额度瓶](plugins/7723qqq/quota-bottle/README.zh-CN.md) | 一只悬浮在桌面上的章鱼形玻璃瓶,直接读取桌面端自己的 Token Plan 剩余额度,不需要二次登录;液面与透明度按剩余占比走,拿不到实时额度时回退到设备码登录或手填总额与本机本月用量 | [7723qqq](https://github.com/7723qqq) | +| [额度瓶](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 index 774245ca..5e1ccbde 100644 --- a/plugins/7723qqq/quota-bottle/.minimax-plugin/plugin.json +++ b/plugins/7723qqq/quota-bottle/.minimax-plugin/plugin.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "name": "quota-bottle", "displayName": "Quota Bottle", - "version": "1.14.2", - "description": "An original octopus-shaped glass bottle that floats on your desktop and shows how much Token Plan quota is left: the liquid level and opacity follow the remaining share, with a device-code login and a local token-usage fallback.", + "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", diff --git a/plugins/7723qqq/quota-bottle/README.md b/plugins/7723qqq/quota-bottle/README.md index c4eccb3b..2f988c6b 100644 --- a/plugins/7723qqq/quota-bottle/README.md +++ b/plugins/7723qqq/quota-bottle/README.md @@ -46,9 +46,11 @@ the client runs; closing it returns the page to the panel, and the next pin is a 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 — the desktop reads the same endpoint on a 5 second cache — and the local usage snapshot + 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. @@ -57,39 +59,24 @@ the client runs; closing it returns the page to the panel, and the next pin is a The page asks the Node entry for `/api/state`. Node tries the live Token Plan quota first, then the manual-total fallback. -Two live sources are tried, in this order: - -1. **The desktop's own session.** MiniMax Code keeps its account session in its own process and - lends it to other local processes over an **auth lease**: a named pipe - (`\\.\pipe\mcode-auth-lease-` on Windows, a unix socket elsewhere) authenticated by a - capability file at `/run/mcode-auth-lease-v1.cap`. The bottle takes a short-lived - lease (the token stays in memory, is never written to disk, and is never logged) and then makes - the request the desktop's own usage panel makes: `GET /v1/api/openplatform/coding_plan/remains` - with the leased token as a bearer credential. - - The request carries the token and nothing else: no query string, no client signature, no - group id. That is the desktop's own request shape; `X-Group-Id` is sent only when the group is - already known, which makes it optional. - - **The region is discovered, not guessed.** The account's region belongs to the installed - client, and the Host does not pass it to a Mini App process. The bottle offers the leased - token to `www.minimaxi.com` and `platform.minimax.io` in turn and keeps the one that answers. - A token issued by the client is accepted only by its own region, so the bottle cannot end up - outside the client's region. The region that answered is tried first on the next read, and it - replaces the configured region in the panel. - - The result is the same number the desktop's usage panel shows, because it is the same call: - the quota arrives as a percentage (`current_interval_remaining_percent`), and only media - quotas carry counts. - - A signed-out desktop reports that the desktop is signed out; a desktop that is not running - reports that its session is unavailable. Neither case invents a number. - - **While you are signed in to MiniMax Code, the bottle shows the quota on its own — there is - no second login.** -2. **The CLI's credential.** `~/.mmx/config.json` holds an OAuth token or an API key, which the - bottle refreshes and sends to the Token Plan endpoint. This is the source described below, and - it is the fallback when the desktop cannot answer. - -### 1. Token Plan quota through the CLI credential - -The Node entry loads `~/.mmx/config.json` (or `$MMX_CONFIG_DIR/config.json`), resolves the OAuth -credential, refreshes a token within five minutes of expiry, and calls +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 @@ -115,17 +102,19 @@ If no credential can read the quota — none, expired, rejected, or unreadable 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 CLI config asks for. +- 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 the CLI's own config file. The token never reaches the page, a log, or an error - response. + 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 config update preserves unrelated fields, refuses corrupt JSON, uses a per-process temporary - name, serializes writes, and preserves the existing file mode (0600 by default). +- 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 source 2. +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 @@ -143,7 +132,8 @@ Response normalization: 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 height and opacity both equal `ratio`. +- `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. @@ -152,12 +142,19 @@ Response normalization: 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: the current local calendar month, from a read-only SQLite snapshot of +- 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. -- The active data root is resolved from `context.dataDir` first, then `MINIMAX_DATA_DIR`, then - `MAVIS_HOME`, then `~/.minimax`. These Host layout details are not a supported Mini App API. + `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 @@ -169,44 +166,48 @@ plugin's `dataDir` as `quota.json`. - 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 84 plugin tests; +- 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, and verifies that `dispose()` frees the listener. -- The lease tests run the real framed protocol against a named-pipe server, so the desktop source - (the primary path) is covered end to end: the handshake, a granted lease, a refused lease, and - the two quota calls made with a leased token. -- 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 state tests cover the login prompt and the - over-budget state, the page tests cover the window drag math and the verification origins, and - the page test keeps every error code and every login-fixable code in sync with the Node catalog. + 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 live desktop session on the CN region, with MiniMax Code running and signed in: the desktop - source returned a live quota (two model rows) without a second login, without a client signature - and without a group lookup. A full device authorization for the CLI fallback and macOS/Linux were - not tested in this revision. +- A full device authorization and macOS/Linux were not re-run in this revision. ## Data & access - Files read: - - `~/.mmx/config.json` or `$MMX_CONFIG_DIR/config.json`: OAuth token or API key. This relies on - the current `mmx` layout and is not a documented Mini App API. - - `/v2/sqlite/runtime-state.sqlite`, read-only, only during the manual fallback. + - `/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 the plugin's `quota.json` in `context.dataDir`. + - `miniapp/client/index.html`, and `quota.json`/`settings.json` in `context.dataDir`. - Files written: - - `~/.mmx/config.json`, by the login and by OAuth token rotation. Unknown fields are preserved. - - `/quota.json`, the manual total (atomic temporary file, then rename). - - Temporary SQLite snapshots under `/.usage-*`, removed after each read. + - `/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://www.minimaxi.com` or `https://platform.minimax.io`: the Token Plan quota read through - the desktop's leased session. No redirect is followed. - - `https://api.minimaxi.com` or `https://api.minimax.io`: Token Plan quota read through the CLI - credential, or an approved HTTPS MiniMax API override. No redirect is followed. + - `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. @@ -217,10 +218,12 @@ plugin's `dataDir` as `quota.json`. - Conversations read: none; no `session.read`. - Tools called: none; no `toolAccess`. - Microphone: not used; no `deviceAccess`. -- Secrets: tokens stay in the Node process and the CLI config file. They are never sent to the page, - logged, or included in error responses. Responses expose a fixed error-code catalog only. +- 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: `quota.json` in `dataDir`; the theme preference is view state in `localStorage`. +- State: `credentials.json`, `quota.json` and `settings.json` in `dataDir`; the theme and language + preferences are view state in `localStorage`. ## Files @@ -235,8 +238,9 @@ 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 Config, credential and device-flow implementation -miniapp/node/usage.mjs Read-only local token usage with a SQLite snapshot +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 diff --git a/plugins/7723qqq/quota-bottle/README.zh-CN.md b/plugins/7723qqq/quota-bottle/README.zh-CN.md index df4a242f..a077119b 100644 --- a/plugins/7723qqq/quota-bottle/README.zh-CN.md +++ b/plugins/7723qqq/quota-bottle/README.zh-CN.md @@ -18,26 +18,24 @@ MiniMax Code 只在用户操作下开窗:窗口不会随启动打开,不在 - 窗口没有系统标题栏,所以拖动准备了两种方式:页面把内容标成拖动区(`-webkit-app-region: drag`,右上角 × 保持 `no-drag`),渲染器支持时由系统移动窗口;而客户端可能用 WebView2 渲染 Mini App,那里不认识这个属性,所以页面也能自己移动窗口——位移用屏幕坐标,避免窗口自身的移动把拖动抵消掉。两条路只有一条会生效:有拖动区时它先把指针事件吞掉。 - 右上角的 × 回到面板;像普通窗口那样关闭也一样,面板仍在运行。 -- 悬浮窗每 5 秒刷新一次,面板每 10 秒,页面不可见时每 60 秒;点固定后会立刻读一次,不等第一次轮询。Node 对实时额度缓存 3 秒(桌面端读同一个接口用的是 5 秒缓存),对本机用量快照缓存 15 秒;点 **Refresh** 会开启新的缓存代际,不会复用登录前或跨月之前的在途请求。 +- 悬浮窗每 5 秒刷新一次,面板每 10 秒,页面不可见时每 60 秒;点固定后会立刻读一次,不等第一次轮询。Node 对实时额度缓存 3 秒,对本机用量快照缓存 15 秒;点 **Refresh** 会开启新的缓存代际,不会复用登录前或跨月之前的在途请求。 +- 胶囊里除了百分比和 token 数,还会显示重置倒计时、周额度和视频模型的已用占比(数据里有才显示)。 - 需要登录、设置手填总额,或实时额度读取有问题时,悬浮计量条会给出简短提示。 ## 额度怎么算 页面向 Node 请求 `/api/state`;Node 先尝试实时 Token Plan 额度,失败后才使用手填总额与本机用量。 -实时数据源按顺序尝试两个: +实时额度只有一个来源:你自己配置的凭据。 -1. **桌面端自己的会话。** MiniMax Code 把登录态留在自己的进程里,并按**租约协议**把它借给本机其他进程:Windows 上是命名管道 `\\.\pipe\mcode-auth-lease-`(macOS/Linux 上是 unix socket),配一个能力文件 `<数据根目录>/run/mcode-auth-lease-v1.cap` 做鉴权。瓶子按协议取一份短期租约(token 只在内存里,不落盘、不写日志),然后发出桌面端「用量」面板发出的同一个请求:带租约 token,`GET /v1/api/openplatform/coding_plan/remains`。 - - 请求里只有 token:没有查询参数、没有客户端签名、没有 group id。这就是桌面端自己的请求形状,`X-Group-Id` 只在已知 group 时才带,因此对瓶子来说是可选的。 - - **分区是问出来的,不是猜出来的。** 账号所属的分区属于已安装的客户端,Host 不会把它传给 Mini App 进程。瓶子把租约 token 依次交给 `www.minimaxi.com` 和 `platform.minimax.io`,留下应答的那一个;客户端签发的 token 只会被它自己的分区接受,所以瓶子不可能落到客户端所在分区之外。应答的分区会排在下一次读取的最前面,并取代面板上来自配置的分区。 - - 结果与桌面端「用量」面板**同源同值**:额度是按百分比给的(`current_interval_remaining_percent`),媒体额度才带计数。 - - 桌面端没登录 → 报"桌面端已退出登录";桌面端没运行 → 报"桌面端会话不可用"。两种都只提示、不编数字。 - - **只要在 MiniMax Code 里登录着,瓶子就自动显示额度,不需要再登一次。** -2. **CLI 的凭据。** `~/.mmx/config.json` 里的 OAuth token 或 API key,由瓶子刷新后发给 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 留下的样子。 -### 1. Token Plan 额度(CLI 凭据) +瓶子不借用 MiniMax Code 已登录的桌面会话,也不读取桌面端的租约、能力文件或任何别的 Host 凭据。 -Node 读取 `~/.mmx/config.json`(或 `$MMX_CONFIG_DIR/config.json`),解析 OAuth 凭据,在 token 距到期五分钟内刷新,然后带 `Authorization: Bearer ` 请求 `GET /v1/token_plan/remains`。 +### 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 测试,此时它会替换掉全部候选主机。 @@ -48,12 +46,13 @@ Node 读取 `~/.mmx/config.json`(或 `$MMX_CONFIG_DIR/config.json`),解析 没有任何凭据能读到额度时——没有、过期、被拒或读不出来——面板显示 **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 接口;成功后把凭据写回 CLI 自己的配置。token 不发给页面、不写日志、不进错误响应。 +- 面板会按已存凭据或 CLI 配置里的分区发起登录。 +- 页面轮询 `GET /api/login`,Node 轮询账户服务的 token 接口;成功后把凭据写进 `/credentials.json`。token 不发给页面、不写日志、不进错误响应。 +- 登录和 token 刷新都不会写 CLI 配置;其中的 `api_key`、`base_url`、分区等字段保持用户留下的原样。 - 取消或替换待完成的登录流程后,迟到的响应不会把凭据按另一个 region 写入。`dispose()` 会中止在途账户和额度请求。 -- 配置写入保留无关字段、拒绝损坏的 JSON、使用带进程标识的临时文件、串行化写入,并保留原文件权限(默认 0600)。 +- 凭据写入是原子的、串行化的,使用带进程标识的临时文件,默认 0600。 -`sk-api-` 开头的 key 是账户余额 key,不是 Token Plan key;插件会在发请求前拒绝,并退回第 2 种来源。 +`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**,而不是把用户堵在死路上。多个主机给出的失败原因不同时,最终上报的是用户能动手解决的那一个。 @@ -62,15 +61,16 @@ Node 读取 `~/.mmx/config.json`(或 `$MMX_CONFIG_DIR/config.json`),解析 - 瓶子使用当前周期(`current_interval_*`);周额度另作一个小标签显示。 - `current_interval_usage_count` 在旧响应里表示“剩余”,在新响应里可能表示“已用”。有 `current_interval_remaining_percent` 时,取与该百分比相差不超过 1 个百分点的读法;两种读法都对不上,或百分比越界的行,会被忽略。没有百分比时保留旧读法。 - `current_interval_status === 3`(不限量)或总额为 0 的行跳过;剩余值限制在 `[0, total]`。 -- `ratio = 剩余 / 总额`;液面高度和不透明度都等于这个比例。 +- `ratio = 剩余 / 总额`;液体不透明度等于这个比例。瓶身占用前 80% 的高度,最后 20% 填满瓶颈和两只耳朵,所以 100% 时液面到瓶口。 - 只有所有展示模型都有可用的周额度数据时才显示周百分比;这里不应用套餐的周额度加成倍率。 ### 2. 手填总额度 拿不到实时额度时,页面会让你填一个总额度(单位是 token),存在插件自己的 `dataDir/quota.json`。 -- 已用量:本机当前自然月,从 `<数据根目录>/v2/sqlite/runtime-state.sqlite` 的只读 SQLite 快照读取 `local_runtime_token_usage` 表,按 `startMs <= ts < endMs` 汇总 `input_tokens + output_tokens + cache_read_tokens + cache_write_tokens`。reasoning token 不另加。 -- 数据根目录优先从 `context.dataDir` 的祖先推断,其次依次为 `MINIMAX_DATA_DIR`、`MAVIS_HOME`、`~/.minimax`。这些宿主布局细节不是 Mini App 的正式 API。 +- 已用量:**默认关闭。** 面板里有“读取 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 的计数,两种来源的数字不要互相比较。 @@ -79,27 +79,27 @@ Node 读取 `~/.mmx/config.json`(或 `$MMX_CONFIG_DIR/config.json`),解析 - 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"` 的 84 项插件测试全部通过;`node scripts/validate.mjs plugins/7723qqq/quota-bottle` 报告 OK。 -- 合成 HTTP 集成测试覆盖页面、拒绝外部 Host/Origin、写入手填总额、JSON body 限制,以及 `dispose()` 释放监听端口。 -- 租约协议测试用真实的命名管道服务器跑完整协议(握手、取租约、被拒的租约、按租约读额度的两次调用),保证桌面源这条主路径真的被测到。 -- 打桩的额度接口覆盖按分区选站、回退到另一主机、被指名的凭据失败,以及余额 key 的提前拒绝;状态测试覆盖登录提示与超额状态;页面测试覆盖窗口拖拽几何与验证页来源,并保证错误码与“可登录修复”集合和 Node 侧目录保持一致。 +- 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 缺失。 -- 在 CN 区、MiniMax Code 已登录运行的真实桌面上验证过:桌面来源在不二次登录、不带客户端签名、不查 group 的情况下拿到了实时额度(两行模型)。CLI 回退路径的完整设备码授权与 macOS/Linux 本轮未验证。 +- 本轮没有重跑完整的设备码授权;macOS/Linux 未验证。 ## 数据与权限 - 读取的文件: - - `~/.mmx/config.json` 或 `$MMX_CONFIG_DIR/config.json`:OAuth token 或 API key。读取依赖当前 `mmx` 布局,不是 Mini App 的正式接口。 - - `<数据根目录>/v2/sqlite/runtime-state.sqlite`:只读,仅在手填回退时读取。`VACUUM INTO` 会在 `context.dataDir` 下创建临时快照,查询后删除。该宿主 schema 与路径均依赖未公开布局。 - - 自己的 `miniapp/client/index.html`,以及 `dataDir` 下的 `quota.json`。 + - `/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`。 - 写入的文件: - - `~/.mmx/config.json`:登录写入、OAuth 刷新轮换 token;无关字段会保留。 - - `/quota.json`:手填总额度(同目录临时文件,再原子重命名)。 - - `/.usage-*` 下的临时 SQLite 快照,读取后删除。 + - `/credentials.json`:面板登录与 OAuth 刷新。本包不写 CLI 或 Host 文件。 + - `/quota.json`:手填总额度;`/settings.json`:用量开关。两者都是同目录临时文件,再原子重命名。 + - `/.usage-*` 下的临时 SQLite 快照,仅在开关打开时创建,读取后删除。 - 网络: - `https://account.minimaxi.com` / `https://account.minimax.io`:设备码启动与轮询。 - - `https://www.minimaxi.com` / `https://platform.minimax.io`:通过桌面端租约会话读取 Token Plan 额度;不跟随重定向。 - - `https://api.minimaxi.com` / `https://api.minimax.io`:通过 CLI 凭据读取 Token Plan 额度,或通过校验的 HTTPS MiniMax API 覆盖地址;不跟随重定向。 + - `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。 @@ -107,9 +107,9 @@ Node 读取 `~/.mmx/config.json`(或 `$MMX_CONFIG_DIR/config.json`),解析 - 读取会话:无;没有 `session.read`。 - 调用工具:无;没有 `toolAccess`。 - 麦克风:不使用;没有 `deviceAccess`。 -- 密钥:token 只在 Node 进程和 CLI 配置文件里,不写页面、不写日志、不进错误响应。响应只暴露固定的错误码。 +- 密钥:token 只在 Node 进程和 `dataDir/credentials.json`(或用 CLI 回退时的 CLI 配置)里,不写页面、不写日志、不进错误响应。响应只暴露固定的错误码。 - 源码与美术:实现和图标均为本包原创,见 `NOTICE.md`。 -- 持久状态:`dataDir` 下的 `quota.json`;主题偏好是视图状态,存在 `localStorage`。 +- 持久状态:`dataDir` 下的 `credentials.json`、`quota.json`、`settings.json`;主题与语言偏好是视图状态,存在 `localStorage`。 ## 文件 @@ -124,8 +124,9 @@ miniapp/node/cache.mjs 带代际的 TTL 缓存 miniapp/node/urls.mjs 主机、URL 与时间字段校验 miniapp/node/net.mjs 不跟随重定向的带凭据请求 miniapp/node/errors.mjs 固定错误码目录与“可登录修复”分类 -miniapp/node/auth/*.mjs 配置、凭据与设备码实现 -miniapp/node/usage.mjs 只读本机 token 用量与 SQLite 快照 +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 插件回归测试 diff --git a/plugins/7723qqq/quota-bottle/miniapp/client/index.html b/plugins/7723qqq/quota-bottle/miniapp/client/index.html index b0e2f7a9..9563df46 100644 --- a/plugins/7723qqq/quota-bottle/miniapp/client/index.html +++ b/plugins/7723qqq/quota-bottle/miniapp/client/index.html @@ -320,6 +320,16 @@ .floatStatus[hidden] { display: none; } + .floatDetails { + max-width: 196px; + font-size: 8.5px; + line-height: 1.3; + text-align: center; + opacity: 0.8; + } + + .floatDetails[hidden] { display: none; } + .panel { padding: 14px; border: 1px solid var(--line); @@ -341,6 +351,25 @@ gap: 8px; } + .check { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 11px; + line-height: 1.45; + color: var(--ink); + cursor: pointer; + } + + .check input { + flex: 0 0 auto; + width: 14px; + height: 14px; + margin: 2px 0 0; + padding: 0; + accent-color: var(--accent); + } + input { flex: 1 1 auto; min-width: 0; @@ -551,7 +580,7 @@ :root[data-mode="window"] .stage::before { display: none; } - :root[data-mode="window"] .bottle { max-width: 192px; } + :root[data-mode="window"] .bottle { max-width: 182px; } :root[data-mode="window"] .floatMeter { display: flex; } @@ -607,6 +636,10 @@

Quota Bottle

+ + + + @@ -670,6 +703,7 @@

Quota Bottle

— — / — + @@ -680,7 +714,7 @@

Quota Bottle

+