dsh-cliproxyapi-pro 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@
4
4
 
5
5
  本插件的所有重要变更。版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 1.9.0 — 2026-10-25
8
+
9
+ ### 新增
10
+ - **图片和视频生成的总开关。** 「生成」标签顶部新增「启用图片和视频生成」,对所有账号生效。关闭时输入框旁不显示生成按钮,`/cpa-image`、`/cpa-video`、`/cpa-video-edit`、`/cpa-video-extend`、`/cpa-video-status` 不注册,agent 生成工具 `generate_media` 也不注册,「生成」标签只显示这个开关;已提交的视频照常完成,聊天记录里已生成的结果仍可查看(不再提供编辑 / 延长按钮)。新安装默认关闭;已有 `media-settings.json`(字段缺失视为开启)或用过生成(存在 `media-sessions.json` / `media-jobs.json`)的配置保持开启。保存在 `media-settings.json` 的新字段 `enabled` 中。
11
+
12
+ ### 变更
13
+ - **管理密钥被拒绝后最多自动重试 3 次。** 额度查询遇到 CPA 管理接口 401/403 时,按 5 分钟缓存周期在后台继续尝试,连续 3 次被拒绝后停止自动查询(CPA 对同一地址累计 5 次错误密钥会封禁 30 分钟)。失败次数保存在 `.dsh-cliproxyapi/quota-state.json`(不含密钥),重启 DSH 或重载插件后继续计数。保存新的管理密钥(包括在 DSH 凭证页修改)、清除密钥或一次成功的查询会清零;停止后手动刷新只再试一次,再被拒绝立即停止。额度页和输入框徽标显示「已连续被拒绝 n/3 次」或已停止的说明,`/cpa-status` 也会注明。之前的版本在第一次被拒绝后就暂停(仅在本次运行内有效)。
14
+
7
15
  ## 1.8.0 — 2026-10-22
8
16
 
9
17
  ### 新增
package/README.en.md CHANGED
@@ -49,7 +49,7 @@ The plugin is published on npm as [`dsh-cliproxyapi-pro`](https://www.npmjs.com/
49
49
  dsh-cliproxyapi-pro
50
50
  ```
51
51
 
52
- To pin a version, write `dsh-cliproxyapi-pro@1.8.0`.
52
+ To pin a version, write `dsh-cliproxyapi-pro@1.9.0`.
53
53
  4. Click **Install**. When it finishes, the dialog offers to enable the plugin right away — accept it.
54
54
 
55
55
  > If downloads are slow in mainland China, open **Registry** next to the input and choose **the mainland China mirror**.
@@ -78,7 +78,7 @@ In **Add plugin**, or as the `plugin_manager` `target`, you can also enter:
78
78
 
79
79
  | Source | What to enter |
80
80
  |---|---|
81
- | A released version | `git+https://github.com/TinyBOOOOM/dsh-cliproxyapi-pro.git#v1.8.0` |
81
+ | A released version | `git+https://github.com/TinyBOOOOM/dsh-cliproxyapi-pro.git#v1.9.0` |
82
82
  | Latest development version | `git+https://github.com/TinyBOOOOM/dsh-cliproxyapi-pro.git#main` |
83
83
  | Offline package | Download [`dsh-cliproxyapi-pro.tgz`](https://github.com/TinyBOOOOM/dsh-cliproxyapi-pro/releases/latest/download/dsh-cliproxyapi-pro.tgz) (always the latest release) and enter its absolute local path |
84
84
  | Local source (development) | Absolute path of a cloned repository; installed as a link, so code changes go live when the plugin reloads |
@@ -96,29 +96,37 @@ If CPA runs on this machine on the default port `8317` without a key, there is n
96
96
 
97
97
  ## Screenshots
98
98
 
99
- The panel follows the DSH display language; the screenshots show the Chinese UI. Images 1–5 are offline component renders: this plugin's real UI code with sample data (`example.com` addresses, fictional accounts) rendered in a browser, with colors approximating the DSH dark theme; the real colors follow your DSH theme. Image 6 is an actual capture of 1.6.0 in the DSH desktop app.
99
+ The panel follows the DSH display language; the screenshots show the Chinese UI. Images 1–7 are offline component renders: this plugin's real UI code with sample data (`example.com` addresses, fictional accounts) rendered in a browser, with colors approximating the DSH dark theme; the real colors follow your DSH theme. Image 8 is an actual capture of 1.6.0 in the DSH desktop app.
100
100
 
101
- **1. Accounts and connection.** The **CLIProxyAPI** page in the Settings dialog's left navigation: one account bar for all tabs; under the account title are the connection state, address and model count, with the **Enabled** switch (applies at once) and the **⋯** menu (**Probe & Refresh**, **Delete Account**) on the right. The **Connection** tab has one row per setting, **Test Connection** next to the address (uses the values being edited, saves nothing), and secondary explanations in ⓘ tips.
101
+ **1. Accounts and connection.** The **CLIProxyAPI** page in the Settings dialog's left navigation: one account bar for all four tabs (Connection / Models / Generation / Quota); under the account title are the connection state, address and model count, with the **Enabled** switch (applies at once) and the **⋯** menu (**Probe & Refresh**, **Delete Account**) on the right. The **Connection** tab has one row per setting, **Test Connection** next to the address (uses the values being edited, saves nothing), and secondary explanations in ⓘ tips.
102
102
 
103
103
  ![Account bar, account header and Connection tab (offline render)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/connection.png)
104
104
 
105
- **2. Models and one save bar.** The **Models** tab lists every model on that CPA: All / Checked / Unchecked, family filter, search, bulk actions and **Reset to Recommended**; checked models show the protocol they will use and can be edited with **Edit Parameters**. Connection and model edits are tracked together, and a bar at the bottom shows **Unsaved changes · Discard / Save & Apply**.
105
+ **2. Models and one save bar.** The **Models** tab lists every chat model on that CPA: All / Checked / Unchecked, family filter, search, bulk actions and **Reset to Recommended**; checked models show the protocol they will use and can be edited with **Edit Parameters**. Connection and model edits are tracked together, and a bar at the bottom shows **Unsaved changes · Discard / Save & Apply**.
106
106
 
107
107
  ![Models tab with the save bar (offline render)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/model-filter.png)
108
108
 
109
- **3. Quota.** The **Quota** tab: management key, quota data and refresh, a distribution bar, credentials grouped by channel (plan, prefix, status, 5-hour / daily / weekly meters with reset times; Devin shows its daily / weekly quota, shared by every Devin model; channels without a quota endpoint show status and recent requests only), and an expandable "Model → Channel" table. The tab label shows the lowest remaining quota among the account's usable credentials.
109
+ **3. Image and video generation.** The top of the **Generation** tab holds the master switch **Enable image and video generation** (for every account, off on a new installation; while off this page shows only the switch, and neither the composer's generation buttons nor the generation commands exist). Once on, it lists image and video models in groups, each with its own switch and kind; below are marking missed models, the save location, video defaults and the agent generation settings.
110
+
111
+ ![Generation tab: master switch, image / video models and generation settings (offline render)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/generation.png)
112
+
113
+ **4. Quota.** The **Quota** tab: management key, quota data and refresh, a distribution bar, credentials grouped by channel (plan, prefix, status, 5-hour / daily / weekly meters with reset times; Devin shows its daily / weekly quota, shared by every Devin model; channels without a quota endpoint show status and recent requests only), and an expandable "Model → Channel" table. The tab label shows the lowest remaining quota among the account's usable credentials.
110
114
 
111
115
  ![Quota tab: distribution bar and per-channel credential quota (offline render)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/quota.png)
112
116
 
113
- **4. Quota in the composer.** The badge left of the model selector shows the best remaining quota among the usable credentials serving the current model; clicking it lists every such credential with its quota windows. It appears only for models this plugin synchronizes whose account has a management key.
117
+ **5. A rejected management key.** After CPA rejects the key 3 times in a row (401/403), automatic checks stop so CPA does not ban this address; the page explains why and keeps the last data it read. Saving a new management key resumes them; a manual **Refresh Quota** tries only once.
118
+
119
+ ![Quota tab: automatic checks stopped after the management key was rejected 3 times in a row (offline render)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/quota-key-stopped.png)
120
+
121
+ **6. Quota in the composer.** The badge left of the model selector shows the best remaining quota among the usable credentials serving the current model; clicking it lists every such credential with its quota windows. It appears only for models this plugin synchronizes whose account has a management key.
114
122
 
115
123
  ![Quota badge next to the model selector with its peek (offline render)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/quota-badge.png)
116
124
 
117
- **5. Adding an account.** Enter a name, address and API key and click **Connect**: it tests first and creates an enabled account only when that works; on failure the reason is shown and **Add Anyway (Disabled)** is offered.
125
+ **7. Adding an account.** Enter a name, address and API key and click **Connect**: it tests first and creates an enabled account only when that works; on failure the reason is shown and **Add Anyway (Disabled)** is offered.
118
126
 
119
127
  ![Add account form (offline render)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/add-account.png)
120
128
 
121
- **6. The plugin update page under Built-in plugins** (actual 1.6.0 capture). **Settings** → **Built-in plugins** → the **CLIProxyAPI** tab holds only plugin updates: checking and offline download, **Update Now** once the DSH plugin market is detected, the update channel, the download region, and the automatic check / download / install switches. Here the plugin is already the latest Stable version, so **Update Now** is disabled.
129
+ **8. The plugin update page under Built-in plugins** (actual 1.6.0 capture). **Settings** → **Built-in plugins** → the **CLIProxyAPI** tab holds only plugin updates: checking and offline download, **Update Now** once the DSH plugin market is detected, the update channel, the download region, and the automatic check / download / install switches. Here the plugin is already the latest Stable version, so **Update Now** is disabled.
122
130
 
123
131
  ![The CLIProxyAPI update page under Built-in plugins: version status, plugin-market update, channel, region and automatic switches](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/updates.png)
124
132
 
@@ -180,12 +188,13 @@ Each account's **Quota** tab (next to **Connection**, **Models** and **Generatio
180
188
  - **Composer badge**: a small badge left of the model selector (signal bars + percentage) shows the best remaining quota among the usable credentials serving the selected model; click it for every credential's windows. It appears only for models this plugin synchronizes whose account has a management key; without a key there is no badge. On the New Session page (before the composer takes over the session) the badge sits at the right end of the workspace row above the input box.
181
189
  - **Exact matching**: credentials are looked up only in the account the selected route belongs to (`cli-proxy*`, `cli-proxy-2*`, …); a prefixed model (such as `devin/claude-opus-5-5`) matches only credentials with that prefix; a bare id (such as `claude-opus-5-5`) prefers un-prefixed credentials, so the Claude and Devin channels never mix. Per-model-group windows (for example Antigravity) count only the group that fits the current model.
182
190
  - **Troubleshooting**: `/cpa-status` includes a quota summary: each account's key state, quota state, credential count, and which credentials each picker model maps to with its remaining quota (never the key).
183
- - **Refreshing**: results are cached on the Host for 5 minutes; stale accounts refresh in the background only while a page is asking, or on **Refresh Quota**. A rejected management key (401/403) pauses that account's automatic refresh until the key changes or you refresh manually, so CPA's bad-key protection is not triggered.
191
+ - **Refreshing**: results are cached on the Host for 5 minutes; stale accounts refresh in the background only while a page is asking, or on **Refresh Quota**. When the management key is rejected (401/403), background checks continue at the 5-minute interval and stop for that account after 3 rejections in a row, so CPA's bad-key protection (a 30-minute ban after 5 bad keys from one address) is not triggered. The count is stored in `.dsh-cliproxyapi/quota-state.json` (never the key) and continues after a DSH restart; saving a new management key (also on the DSH credentials page), clearing the key or a successful check resets it. Once stopped, a manual refresh tries only once and stops again on another rejection. The Quota tab and the badge show how many times the key was rejected, or that checks have stopped.
184
192
 
185
193
  ### Image and video models (the "Generation" tab)
186
194
 
187
195
  CPA serves image and video generators (such as Grok Imagine) through `/v1/images/*` and `/v1/videos/*`. They cannot chat, so the plugin recognizes them and keeps them out of the chat model picker.
188
196
 
197
+ - **Master switch**: **Enable image and video generation** at the top of the Generation tab applies to every account. While off, the composer shows no generation buttons, `/cpa-image`, `/cpa-video`, `/cpa-video-edit`, `/cpa-video-extend` and `/cpa-video-status` are not registered, the agent generation tool is not registered either, and the Generation tab shows only this switch; videos already submitted still finish, and past results in the chat stay viewable (without the edit / extend buttons). New installations start with it off; a profile that already has `media-settings.json` or has used generation keeps it on after the upgrade. Either way, media models never enter the chat model picker. Everything below needs it switched on.
189
198
  - **Recognition**, in order: a kind you set on the Generation tab; the catalog's `type` (`openai-image` is an image model) or output modalities (image-only output is an image model, video output a video model); the model families CPA routes to its image / video handlers, matched by prefix: ids starting with `grok-imagine-image` or `gpt-image-` are image models, ids starting with `grok-imagine-video` and `sora-2*` are video models. Everything else is a chat model (for example `gemini-3.1-flash-image`: CPA serves it only through chat, and DSH cannot show images returned in a chat reply). A media model that only the plain `/v1/models` listing carries is included too. A catalog may list variants CPA's generation endpoints do not accept yet (for example `grok-imagine-video-1.5-lite`); they cannot chat either, so they count as media models, and generating with one shows CPA's error.
190
199
  - **Not in the model picker**: media models are written to no chat route and take no part in the first-run recommendation; an older selection that names them does not synchronize them (the selection itself is kept). The Models tab lists chat models only.
191
200
  - **The Generation tab** (next to Connection / Models / Quota) lists "Image models" and "Video models"; changes are saved at once. A switch decides whether each model is offered for generation (models that appear later start switched on), and a kind menu changes it to image / video / chat; a kind you set can be reset to the detected one. A model detection missed can be marked under "Mark another model as a media model". Changing a kind re-synchronizes that account's routes immediately. `sora-2*` is labelled "→ grok-imagine-video": CPA rewrites it to the Grok video model.
@@ -259,7 +268,7 @@ Each time `/cpa-image`, `/cpa-video`, `/cpa-video-edit` or `/cpa-video-extend` r
259
268
 
260
269
  #### Letting the agent generate (`generate_media` tool, off by default)
261
270
 
262
- With **Let the agent generate images / videos** switched on in the Generation tab, the chat agent can call the `generate_media` tool to generate images and videos, or to edit and extend videos. While off (the default) the tool is not registered and takes no model context; when on, it is registered only while at least one generation model is enabled. Its definition is compact and lists no models.
271
+ With **Let the agent generate images / videos** switched on in the Generation tab, the chat agent can call the `generate_media` tool to generate images and videos, or to edit and extend videos. While off (the default) the tool is not registered and takes no model context; when on, it is registered only while the **Enable image and video generation** master switch is on and at least one generation model is enabled. Its definition is compact and lists no models.
263
272
 
264
273
  - **What it does**: parameters `kind` (image / video) and `prompt`, optionally `mode` (generate / edit / extend), `model`, `images` (local image paths: for images, to edit or combine; for videos, 1 is the first frame and 2 or more are references), `frames` (first frame / references / first & last frame / last frame), `video` (the source of an edit / extension: a local MP4 path or a public link; without it, this session's newest generated video), `aspect`, `seconds`, `resolution`, `count`. Optional fields may be omitted or set to `null` (including on Responses strict-schema routes); video `count` should be `null`, as each call generates one video. An omitted, `null` or empty `model` uses this session's pick in the composer button (a sub-agent uses the pick of the session it belongs to). It works like the matching slash command, except that the prompt is **sent as written**: words like "10s" or "portrait" in it are not read as options and the chat model is not asked to interpret parameters. The tool description also tells the agent to use the user's own wording and level of detail, never expanding it into a longer scene description or adding camera, lighting, style, character or environment details the user did not ask for.
265
274
  - **Result**: files go to the save location; the tool returns their paths, the model and the actual parameters. For images, when the current chat model can see images, the generated images (downscaled by DSH) are handed to it as well. The chat renders one **independent generation row** from the recorded tool lifecycle, outside the thinking process, using the slash command's exchange layout (progress, images / a playable video, open / show in folder / copy, and **Edit video… / Extend video…** under a video). There is no need to expand thinking, no extra user command is fabricated, and tool cards or legacy mirrored command cards with a matching generation row do not repeat the result.
@@ -336,6 +345,8 @@ Apart from the generation tool `generate_media` (off by default, see above), thi
336
345
  - `/cpa-video-status` — list this session's video jobs.
337
346
  - `/cpa-video-edit [--video path | --url link | --from command-id] [-m model] change` / `/cpa-video-extend [same] [-s 2-10] what happens next` — edit / extend any MP4 (an attached video file works too). See [Editing and extending videos](#editing-and-extending-videos-cpa-video-edit-cpa-video-extend).
338
347
 
348
+ `/cpa-image` and `/cpa-video*` exist only while **Enable image and video generation** is on in the Generation tab.
349
+
339
350
  ### HTTP API
340
351
 
341
352
  Used by the settings panel. Requires the DSH Web session's authentication; POST accepts only same-origin requests from loopback or trusted hosts.
@@ -356,7 +367,7 @@ Used by the settings panel. Requires the DSH Web session's authentication; POST
356
367
  | POST | `/api/dsh-cliproxyapi/quota/refresh` | `{ accountId? }` | Start refreshing the quota of one or every account and return at once (`refreshing` is set in the snapshot) |
357
368
  | POST | `/api/dsh-cliproxyapi/quota/save` | `{ accountId, managementKey?, clear? }` | Store or clear the account's management key and start refreshing its quota; the response never contains the key |
358
369
  | GET | `/api/dsh-cliproxyapi/media/status` | — | `{ ok, media }`: generation settings and each account's recognized image / video models; no network access |
359
- | POST | `/api/dsh-cliproxyapi/media/save` | `{ account: { accountId, disabledModels?, kindOverrides? } }` | Save an account's switched-off media models (replaces the list) or kind overrides (merged per model; `null` restores detection); a kind change re-synchronizes that account and adds `status` to the response. Invalid values return 400 with a code such as `invalid-account` or `invalid-kind` |
370
+ | POST | `/api/dsh-cliproxyapi/media/save` | `{ enabled? }`, global fields (such as `agentTool`, `outputDir`), or `{ account: { accountId, disabledModels?, kindOverrides? } }` | `enabled` switches generation on / off (registering or removing the generation commands and the agent tool at once); save an account's switched-off media models (replaces the list) or kind overrides (merged per model; `null` restores detection); a kind change re-synchronizes that account and adds `status` to the response. Invalid values return 400 with a code such as `invalid-enabled`, `invalid-account` or `invalid-kind` |
360
371
  | POST | `/api/dsh-cliproxyapi/media/diagnose` | `{ accountId }` | Read-only diagnostic (see above); returns `{ ok, diagnostic }` without the key |
361
372
  | GET | `/api/dsh-cliproxyapi/media/session?sessionId=` | — | `{ ok, session }`: the image / video models to choose from, the current pick and where it comes from (`session` / `last` / `only`), and this session's recent files (names and ids only, no paths); `pendingConfirmations` lists only compatibility requests owned by this exact session when the native question service is absent, as `{ commandId, kind, en, zh, expiresAt }`; native questions never appear in that list; no network access |
362
373
  | POST | `/api/dsh-cliproxyapi/media/session/save` | `{ sessionId?, kind: "image" \| "video", ref }` | Pick a session's model (`ref` is `accountId/modelId`; `null` clears the session's pick) and remember it as the last pick; a model that is not offered returns 400 `invalid-model`. Alternatively `{ sessionId, videoConfirm?, imageConfirm?, videoBudget? }` (`ask` / `auto` / `inherit`; the limit is `{ mode: "limited", seconds }` / `{ mode: "unlimited" }` / `inherit`) sets this session's agent-generation rules |
@@ -404,11 +415,12 @@ Failures use a top-level `errorCode` or `update.errorCode`; HTTP status or `ok`
404
415
  ```
405
416
 
406
417
  Panel saves take effect immediately. After editing this file manually, reload the plugin or restart DSH to re-read it. Corrupt, structurally invalid, invalid-field or unreadable files use safe defaults with automatic network checks disabled. Check results, the candidate version, download notices and commands are in-memory only and reset on plugin reload; downloaded packages remain in the separate `updates` subdirectory for you to clean up when needed.
407
- - **Generation settings**: stored separately at `<profile dir>/.dsh-cliproxyapi/media-settings.json`; `state.json` is not changed. Without this file the defaults apply; a damaged file also falls back to the defaults with a log warning, and the next save first keeps the damaged file as `media-settings.corrupt-<time>.json`. Currently `outputDir` (save location), `lastSelection` (the last picked models) and each account's `disabledModels` (switched-off media models) and `kindOverrides` (kinds you set) take effect; the other fields are reserved for the generation features still to come and have no effect yet:
418
+ - **Generation settings**: stored separately at `<profile dir>/.dsh-cliproxyapi/media-settings.json`; `state.json` is not changed. Without this file the defaults apply (`enabled: false`, generation off; but on when `media-sessions.json` or `media-jobs.json` exists); a file without the `enabled` field (written before the master switch existed) reads as on. A damaged file also falls back to the defaults with a log warning, and the next save first keeps the damaged file as `media-settings.corrupt-<time>.json`. The fields: `enabled` (master switch), `agentTool` (agent generation tool), `outputDir` (save location), `defaultVideoSeconds` / `defaultVideoResolution` (video defaults), `imageConfirm` / `videoConfirm` / `videoBudget` (agent confirmation and seconds limit), `lastSelection` (the last picked models), and each account's `disabledModels` (switched-off media models) and `kindOverrides` (kinds you set):
408
419
 
409
420
  ```json
410
421
  {
411
422
  "version": 1,
423
+ "enabled": true,
412
424
  "agentTool": false,
413
425
  "outputDir": "generated",
414
426
  "defaultVideoSeconds": 6,
@@ -425,6 +437,7 @@ Failures use a top-level `errorCode` or `update.errorCode`; HTTP status or `ok`
425
437
 
426
438
  - **Per-session generation records**: `<profile dir>/.dsh-cliproxyapi/media-sessions.json` keeps each session's picked generation models, records of its last 48 runs (status, model, prompt, options, start and end times, a display name for the save folder, error summary), and the paths of its last 48 generated files with the command id that produced them (for the command rows in the conversation), for the 500 most recently updated sessions. Deleting it only clears those picks and records, never generated files. A damaged file is treated as empty and kept as `media-sessions.corrupt-<time>.json` before the next write.
427
439
  - **Video jobs**: `<profile dir>/.dsh-cliproxyapi/media-jobs.json` keeps submitted video jobs (CPA request id, account, model, session and command id, working directory and save location, prompt, length, submission time, state) so a reload or restart can keep waiting; at most 200, finished ones kept 7 days. No keys and no video URLs. A damaged file is treated as empty and kept as `media-jobs.corrupt-<time>.json` before the next write.
440
+ - **Management key failures**: `<profile dir>/.dsh-cliproxyapi/quota-state.json` records, per account, how many times in a row the CPA management API rejected the key (401/403), the last error code and time, so automatic checks stop after 3; it never holds the key. Deleting it only resets the counts.
428
441
  - **Keys**: stored and resolved by ref through the DSH `credentials` service, falling back to an environment variable of the same name.
429
442
  - **Environment**: `CLIPROXYAPI_BASE_URL` is used only for auto-discovery of accounts without a base URL.
430
443
  - **Plugin row config**: the plugin row's `config` in `cordis.patch.yml` is overlaid on the state file at startup: `accounts` replaces the account list, other fields apply to the first account. Normally keep it `{}` and configure through the Settings panel.
package/README.md CHANGED
@@ -49,7 +49,7 @@
49
49
  dsh-cliproxyapi-pro
50
50
  ```
51
51
 
52
- 需要固定版本时可以写成 `dsh-cliproxyapi-pro@1.8.0`。
52
+ 需要固定版本时可以写成 `dsh-cliproxyapi-pro@1.9.0`。
53
53
  4. 点击 **安装**,完成后点击 **立即启用**。
54
54
 
55
55
  > 在中国大陆下载慢,可以点输入框旁的 **安装源**,切换到 **中国大陆镜像源**。
@@ -78,7 +78,7 @@ dsh plugin --profile <profile 名称> add dsh-cliproxyapi-pro
78
78
 
79
79
  | 来源 | 填写内容 |
80
80
  |---|---|
81
- | 某个发布版本 | `git+https://github.com/TinyBOOOOM/dsh-cliproxyapi-pro.git#v1.8.0` |
81
+ | 某个发布版本 | `git+https://github.com/TinyBOOOOM/dsh-cliproxyapi-pro.git#v1.9.0` |
82
82
  | 最新开发版 | `git+https://github.com/TinyBOOOOM/dsh-cliproxyapi-pro.git#main` |
83
83
  | 离线安装包 | 下载 [`dsh-cliproxyapi-pro.tgz`](https://github.com/TinyBOOOOM/dsh-cliproxyapi-pro/releases/latest/download/dsh-cliproxyapi-pro.tgz)(始终是最新版),填写它的本地绝对路径 |
84
84
  | 本地源码(开发用) | 克隆后的仓库目录绝对路径;以链接方式安装,改代码后重载插件即生效 |
@@ -96,29 +96,37 @@ CPA 就在本机默认端口 `8317` 且没有设置密钥时,什么都不用
96
96
 
97
97
  ## 截图
98
98
 
99
- 图 1–5 是离线组件渲染:用本插件的真实界面代码加示例数据(`example.com` 地址、虚构账号)在浏览器中渲染,配色按 DSH 深色主题近似,实际颜色跟随你的 DSH 主题。图 6 是 1.6.0 在 DSH 桌面版中的实际截图。
99
+ 图 1–7 是离线组件渲染:用本插件的真实界面代码加示例数据(`example.com` 地址、虚构账号)在浏览器中渲染,配色按 DSH 深色主题近似,实际颜色跟随你的 DSH 主题。图 8 是 1.6.0 在 DSH 桌面版中的实际截图。
100
100
 
101
- **1. 账号与连接。** 设置左侧导航的 **CLIProxyAPI** 页:顶部账号栏三个标签共用;账号标题下是连接状态、地址和模型数,右侧「启用」开关立即生效,「⋯」菜单里是「立即探测刷新」和「删除此账号」。「连接」标签里每项一行,地址旁可「测试连接」(用正在编辑的值,不保存),次要说明在 ⓘ 里。
101
+ **1. 账号与连接。** 设置左侧导航的 **CLIProxyAPI** 页:顶部账号栏四个标签(连接 / 模型 / 生成 / 额度)共用;账号标题下是连接状态、地址和模型数,右侧「启用」开关立即生效,「⋯」菜单里是「立即探测刷新」和「删除此账号」。「连接」标签里每项一行,地址旁可「测试连接」(用正在编辑的值,不保存),次要说明在 ⓘ 里。
102
102
 
103
103
  ![账号栏、账号标题与连接标签(离线渲染)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/connection.png)
104
104
 
105
- **2. 模型与统一保存。** 「模型」标签列出该 CPA 上的全部模型:全部 / 已勾选 / 未勾选、家族筛选、搜索、批量操作与「恢复推荐精选」;勾选的模型显示将使用的协议,可「编辑参数」。连接和模型的改动统一记录,底部出现「有未保存的更改 · 放弃更改 / 保存并应用」。
105
+ **2. 模型与统一保存。** 「模型」标签列出该 CPA 上的全部对话模型:全部 / 已勾选 / 未勾选、家族筛选、搜索、批量操作与「恢复推荐精选」;勾选的模型显示将使用的协议,可「编辑参数」。连接和模型的改动统一记录,底部出现「有未保存的更改 · 放弃更改 / 保存并应用」。
106
106
 
107
107
  ![模型标签与底部保存栏(离线渲染)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/model-filter.png)
108
108
 
109
- **3. 额度。** 「额度」标签:管理密钥、额度数据与刷新、凭证分布条,按渠道分组的凭证(套餐、前缀、状态、5 小时 / 每日 / 每周等额度条与重置时间;Devin 显示每日 / 每周额度,所有 Devin 模型共用;没有额度接口的渠道只显示状态与近期请求),以及可展开的「模型 → 渠道」表。标签上显示该账号可用凭证中最低的剩余额度。
109
+ **3. 生图 / 生视频。** 「生成」标签顶部是总开关「启用图片和视频生成」(对所有账号生效,新安装默认关闭;关闭时这一页只剩这个开关,输入框旁的生成按钮和生成命令都不出现)。打开后分组列出生图 / 生视频模型,每个模型可单独开关、改类型;下面是标记漏识别的模型、保存位置、视频默认值和 agent 生成设置。
110
+
111
+ ![生成标签:总开关、生图 / 生视频模型与生成设置(离线渲染)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/generation.png)
112
+
113
+ **4. 额度。** 「额度」标签:管理密钥、额度数据与刷新、凭证分布条,按渠道分组的凭证(套餐、前缀、状态、5 小时 / 每日 / 每周等额度条与重置时间;Devin 显示每日 / 每周额度,所有 Devin 模型共用;没有额度接口的渠道只显示状态与近期请求),以及可展开的「模型 → 渠道」表。标签上显示该账号可用凭证中最低的剩余额度。
110
114
 
111
115
  ![额度标签:分布条与按渠道分组的凭证额度(离线渲染)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/quota.png)
112
116
 
113
- **4. 对话框里的额度。** 模型选择器左侧的徽标显示当前模型可用凭证中最充足的剩余额度;点击后列出服务该模型的每个凭证及其额度窗口。只对本插件同步、且所属账号已填写管理密钥的模型显示。
117
+ **5. 管理密钥被拒绝。** 密钥连续 3 次被 CPA 拒绝(401/403)后停止自动查询,以免 CPA 封禁本机地址;页面说明原因,保留上次成功读取的数据。保存新的管理密钥后恢复,手动「刷新额度」只再试一次。
118
+
119
+ ![额度标签:管理密钥连续 3 次被拒绝后停止自动查询(离线渲染)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/quota-key-stopped.png)
120
+
121
+ **6. 对话框里的额度。** 模型选择器左侧的徽标显示当前模型可用凭证中最充足的剩余额度;点击后列出服务该模型的每个凭证及其额度窗口。只对本插件同步、且所属账号已填写管理密钥的模型显示。
114
122
 
115
123
  ![模型选择器旁的额度徽标与弹出列表(离线渲染)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/quota-badge.png)
116
124
 
117
- **5. 添加账号。** 填名称、地址和 API Key 后点「连接」:先测试,成功才创建并启用账号;失败时显示原因,可选「仍然添加(不启用)」。
125
+ **7. 添加账号。** 填名称、地址和 API Key 后点「连接」:先测试,成功才创建并启用账号;失败时显示原因,可选「仍然添加(不启用)」。
118
126
 
119
127
  ![添加账号表单(离线渲染)](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/add-account.png)
120
128
 
121
- **6. 内置插件里的插件更新页**(1.6.0 实际截图)。**设置** → **内置插件** → **CLIProxyAPI** 标签页只有插件更新:检查更新与离线下载、检测到 DSH 插件市场后的「立即更新」、更新通道、下载区域,以及自动检查 / 自动下载 / 自动安装开关。图中已是稳定通道最新版本,所以「立即更新」不可用。
129
+ **8. 内置插件里的插件更新页**(1.6.0 实际截图)。**设置** → **内置插件** → **CLIProxyAPI** 标签页只有插件更新:检查更新与离线下载、检测到 DSH 插件市场后的「立即更新」、更新通道、下载区域,以及自动检查 / 自动下载 / 自动安装开关。图中已是稳定通道最新版本,所以「立即更新」不可用。
122
130
 
123
131
  ![内置插件中的 CLIProxyAPI 更新页:版本状态、插件市场更新、通道、区域与自动开关](https://raw.githubusercontent.com/TinyBOOOOM/dsh-cliproxyapi-pro/main/assets/screenshots/updates.png)
124
132
 
@@ -180,12 +188,13 @@ CPA 对每个模型同时提供 OpenAI Chat、OpenAI Responses、Anthropic Messa
180
188
  - **对话框徽标**:模型选择器左侧的小徽标(信号格 + 百分比)显示当前所选模型可用凭证中最充足的剩余额度,点击可查看每个凭证的额度。只对本插件同步、且所属账号已填写管理密钥的模型显示;没有管理密钥时不显示徽标。在新建对话页(输入栏尚未接管会话时),徽标显示在输入框上方工作区一行的右侧。
181
189
  - **精确匹配**:只在所选模型所属的账号(按路由 `cli-proxy*`、`cli-proxy-2*`…)里找凭证;带前缀的模型(如 `devin/claude-opus-5-5`)只匹配该前缀的凭证;不带前缀的同名模型(如 `claude-opus-5-5`)优先匹配不带前缀的凭证,Claude 渠道与 Devin 渠道不会混用。Antigravity 等按模型分组的额度窗口只取与当前模型相符的那一组。
182
190
  - **排查**:`/cpa-status` 会附带额度摘要:每个账号的管理密钥状态、额度状态、凭证数,以及模型选择器中每个模型匹配到的凭证和剩余额度(不输出密钥)。
183
- - **刷新**:结果在宿主端缓存 5 分钟;有页面在看时才在后台刷新过期账号,也可手动「刷新额度」。管理密钥被拒(401/403)后暂停该账号的自动刷新,直到更新密钥或手动刷新,避免触发 CPA 的错误密钥封禁。
191
+ - **刷新**:结果在宿主端缓存 5 分钟;有页面在看时才在后台刷新过期账号,也可手动「刷新额度」。管理密钥被拒(401/403)时,后台按 5 分钟周期继续尝试,连续 3 次被拒绝后停止该账号的自动查询,避免触发 CPA 的错误密钥封禁(同一地址累计 5 次错误会被封 30 分钟)。失败次数保存在 `.dsh-cliproxyapi/quota-state.json`(不含密钥),重启 DSH 后继续计数;保存新的管理密钥(包括在 DSH 凭证页修改)、清除密钥或一次成功的查询会清零。停止后手动刷新只再试一次,再被拒绝立即停止。额度页和徽标会显示已被拒绝的次数或已停止的说明。
184
192
 
185
193
  ### 生图 / 生视频模型(「生成」标签页)
186
194
 
187
195
  CPA 通过 `/v1/images/*` 和 `/v1/videos/*` 提供生图、生视频模型(如 Grok Imagine)。这些模型不能用于对话,所以插件会把它们识别出来,不放进对话模型选择器。
188
196
 
197
+ - **总开关**:「生成」标签顶部的「启用图片和视频生成」对所有账号生效。关闭时输入框旁不显示生成按钮,`/cpa-image`、`/cpa-video`、`/cpa-video-edit`、`/cpa-video-extend`、`/cpa-video-status` 不注册,agent 生成工具也不注册,「生成」标签只显示这个开关;已提交的视频照常完成,聊天记录里已生成的结果仍可查看(不提供编辑 / 延长按钮)。新安装默认关闭;已有 `media-settings.json` 或用过生成的配置升级后保持开启。不论开关如何,生成模型都不会进入对话模型选择器。下面介绍的功能都需要先打开它。
189
198
  - **识别规则**(按顺序):你在「生成」标签里手动指定的类型;模型目录里的 `type`(`openai-image` 为生图)或输出模态(只输出图片为生图,输出视频为生视频);CPA 分流到图片 / 视频接口的模型族,按前缀匹配:以 `grok-imagine-image` 或 `gpt-image-` 开头为生图,以 `grok-imagine-video` 开头或 `sora-2*` 为生视频。其余都是对话模型(如 `gemini-3.1-flash-image`:CPA 只通过对话接口提供它,DSH 也不能显示对话中返回的图片)。只出现在 `/v1/models` 普通列表里的生成模型也会被收录。模型目录可能列出 CPA 生成接口暂不接受的变体(例如 `grok-imagine-video-1.5-lite`),它们同样不能对话,因此归为生成模型;用它们生成时会显示 CPA 返回的错误。
190
199
  - **不进入模型选择器**:生成模型不会写入任何对话路由,也不参与首次推荐勾选;即使旧的勾选里含有它们,也不会被同步(勾选本身保持不变)。「模型」标签只列对话模型。
191
200
  - **「生成」标签**(与 连接 / 模型 / 额度 并列):分「生图模型」「生视频模型」两组列出,修改立即保存。每个模型可以用开关决定是否提供给生成功能(新出现的模型默认开启),用类型下拉框改为生图 / 生视频 / 对话模型;手动指定的类型可以恢复为自动识别。识别漏掉的模型可以在「把其他模型标记为生成模型」里手动标记。改动类型会立即重新同步该账号的路由。`sora-2*` 标注为「→ grok-imagine-video」:CPA 会把它改写为 Grok 视频模型调用。
@@ -259,7 +268,7 @@ CPA 通过 `/v1/images/*` 和 `/v1/videos/*` 提供生图、生视频模型(
259
268
 
260
269
  #### 让 agent 生成(`generate_media` 工具,默认关闭)
261
270
 
262
- 在「生成」标签打开 **允许 agent 生成图片 / 视频** 后,对话里的 agent 可以调用 `generate_media` 工具生图、生视频、编辑或延长视频。关闭时(默认)不注册这个工具,不占用模型上下文;打开后,且至少有一个启用的生成模型时才注册,工具说明保持精简,不列模型清单。
271
+ 在「生成」标签打开 **允许 agent 生成图片 / 视频** 后,对话里的 agent 可以调用 `generate_media` 工具生图、生视频、编辑或延长视频。关闭时(默认)不注册这个工具,不占用模型上下文;打开后,且总开关「启用图片和视频生成」打开、至少有一个启用的生成模型时才注册,工具说明保持精简,不列模型清单。
263
272
 
264
273
  - **能做什么**:参数为 `kind`(image / video)、`prompt`,可选 `mode`(generate / edit / extend)、`model`、`images`(本地图片路径:生图时改图 / 合成,生视频时 1 张为首帧、2 张以上为参考图)、`frames`(首帧 / 参考图 / 首尾帧 / 尾帧)、`video`(编辑 / 延长的原视频:本地 MP4 路径或公开链接,不写时用本会话最新生成的视频)、`aspect`、`seconds`、`resolution`、`count`。可选字段可以省略或填 `null`(兼容 Responses 严格参数模式);视频的 `count` 应填 `null`,一次只生成一个视频。没写 `model` 或填 `null` / 空字符串时,用输入框旁按钮里本会话选中的模型(子 agent 用其所属会话的选择)。处理方式与对应的斜杠命令相同,但提示词**原样发送**:不识别提示词里的「10秒」「竖屏」等词,也不请对话模型解读参数。工具说明同时要求 agent 使用用户自己的措辞和细节,不要扩写成更长的场景描写,也不要添加用户没有要求的镜头、光线、风格、人物或环境细节。
265
274
  - **结果**:文件保存到保存位置,工具返回文件路径、模型和实际参数。生图时,如果当前对话模型能看图,还会把生成的图片(DSH 自动缩小)一起交给它。对话根据工具调用的原始记录显示一条**独立的生成行**,放在思考过程之外,和斜杠命令一样显示一问一答(进度、图片 / 可播放的视频、打开 / 显示文件夹 / 复制,视频下方同样有「编辑视频…」「延长视频…」);不需要展开思考过程,不再额外伪造用户命令,也不会重复显示已有对应生成行的工具卡片或旧版镜像命令卡片。
@@ -336,6 +345,8 @@ CPA 通过 `/v1/images/*` 和 `/v1/videos/*` 提供生图、生视频模型(
336
345
  - `/cpa-video-status`:列出本会话的视频任务。
337
346
  - `/cpa-video-edit [--video 路径 | --url 链接 | --from 命令id] [-m 模型] 修改说明`、`/cpa-video-extend [同上] [-s 2-10] 接下来的内容`:编辑 / 延长任意 MP4(可附带视频文件)。见 [编辑 / 延长视频](#编辑--延长视频cpa-video-editcpa-video-extend)。
338
347
 
348
+ `/cpa-image` 和 `/cpa-video*` 只在「生成」标签的「启用图片和视频生成」打开时存在。
349
+
339
350
  ### HTTP 接口
340
351
 
341
352
  供设置面板使用,需要 DSH Web 会话的认证;POST 只接受来自本机或受信任主机的同源请求。
@@ -356,7 +367,7 @@ CPA 通过 `/v1/images/*` 和 `/v1/videos/*` 提供生图、生视频模型(
356
367
  | POST | `/api/dsh-cliproxyapi/quota/refresh` | `{ accountId? }` | 开始刷新一个或全部账号的额度并立即返回(快照中 `refreshing` 为真) |
357
368
  | POST | `/api/dsh-cliproxyapi/quota/save` | `{ accountId, managementKey?, clear? }` | 保存或清除该账号的管理密钥,并开始刷新额度;响应不含密钥 |
358
369
  | GET | `/api/dsh-cliproxyapi/media/status` | — | `{ ok, media }`:生成设置与每个账号识别出的生图 / 生视频模型;不联网 |
359
- | POST | `/api/dsh-cliproxyapi/media/save` | `{ account: { accountId, disabledModels?, kindOverrides? } }` | 保存某账号关闭的生成模型(整表替换)或类型覆盖(按模型合并,`null` 表示恢复自动识别);类型变化时重新同步该账号并在响应中附带 `status`。无效值返回 400 和错误码,如 `invalid-account`、`invalid-kind` |
370
+ | POST | `/api/dsh-cliproxyapi/media/save` | `{ enabled? }`、全局字段(如 `agentTool`、`outputDir`),或 `{ account: { accountId, disabledModels?, kindOverrides? } }` | `enabled` 打开 / 关闭生成总开关(随即注册或移除生成命令和 agent 工具);保存某账号关闭的生成模型(整表替换)或类型覆盖(按模型合并,`null` 表示恢复自动识别);类型变化时重新同步该账号并在响应中附带 `status`。无效值返回 400 和错误码,如 `invalid-enabled`、`invalid-account`、`invalid-kind` |
360
371
  | POST | `/api/dsh-cliproxyapi/media/diagnose` | `{ accountId }` | 只读诊断(见上文),返回 `{ ok, diagnostic }`,不含 Key |
361
372
  | GET | `/api/dsh-cliproxyapi/media/session?sessionId=` | — | `{ ok, session }`:可选的生图 / 生视频模型、当前选择及其来源(`session` / `last` / `only`)、本会话最近生成的文件(只有文件名和 id,不含路径);`pendingConfirmations` 仅列出缺少原生提问服务时、属于这个确切会话的待确认项 `{ commandId, kind, en, zh, expiresAt }`,原生提问不在其中;不联网 |
362
373
  | POST | `/api/dsh-cliproxyapi/media/session/save` | `{ sessionId?, kind: "image" \| "video", ref }` | 为会话选择模型(`ref` 为 `账号id/模型id`,`null` 清除本会话的选择),并记为上一次选择;不在可选列表里的模型返回 400 `invalid-model`。也可以只传 `{ sessionId, videoConfirm?, imageConfirm?, videoBudget? }`(`ask` / `auto` / `inherit`,上限为 `{ mode: "limited", seconds }` / `{ mode: "unlimited" }` / `inherit`)修改本会话的 agent 生成设置 |
@@ -404,11 +415,12 @@ CPA 通过 `/v1/images/*` 和 `/v1/videos/*` 提供生图、生视频模型(
404
415
  ```
405
416
 
406
417
  设置通过面板保存后立即应用。手动编辑此文件后需重载插件或重启 DSH 才会重新读取;文件损坏、结构 / 字段无效或无法读取时采用关闭自动联网检查的安全默认值。版本检查结果、候选版本、下载完成提示与命令只在内存中,插件重载后重新检查;下载包仍位于独立的 `updates` 子目录,由用户按需清理。
407
- - **生成设置**:独立保存在 `<Profile 目录>/.dsh-cliproxyapi/media-settings.json`,不改动 `state.json`。没有此文件时使用默认值;文件损坏时也使用默认值并在日志中提示,下次保存前会把损坏的文件另存为 `media-settings.corrupt-<时间>.json`。目前生效的是 `outputDir`(保存位置)、`lastSelection`(上一次选择的模型)以及每个账号的 `disabledModels`(关闭的生成模型)和 `kindOverrides`(手动指定的类型);其余字段为后续生成功能预留,现在没有效果:
418
+ - **生成设置**:独立保存在 `<Profile 目录>/.dsh-cliproxyapi/media-settings.json`,不改动 `state.json`。没有此文件时使用默认值(`enabled: false`,生成关闭;但已存在 `media-sessions.json` 或 `media-jobs.json` 时视为开启);文件里没有 `enabled` 字段(总开关出现之前写入的文件)时视为开启。文件损坏时也使用默认值并在日志中提示,下次保存前会把损坏的文件另存为 `media-settings.corrupt-<时间>.json`。各字段:`enabled`(总开关)、`agentTool`(agent 生成工具)、`outputDir`(保存位置)、`defaultVideoSeconds` / `defaultVideoResolution`(视频默认值)、`imageConfirm` / `videoConfirm` / `videoBudget`(agent 生成的确认方式和秒数上限)、`lastSelection`(上一次选择的模型),以及每个账号的 `disabledModels`(关闭的生成模型)和 `kindOverrides`(手动指定的类型):
408
419
 
409
420
  ```json
410
421
  {
411
422
  "version": 1,
423
+ "enabled": true,
412
424
  "agentTool": false,
413
425
  "outputDir": "generated",
414
426
  "defaultVideoSeconds": 6,
@@ -425,6 +437,7 @@ CPA 通过 `/v1/images/*` 和 `/v1/videos/*` 提供生图、生视频模型(
425
437
 
426
438
  - **会话生成记录**:`<Profile 目录>/.dsh-cliproxyapi/media-sessions.json` 保存每个会话选择的生成模型、最近 48 次生成的记录(状态、模型、提示词、选项、起止时间、保存目录显示名、错误摘要),以及最近生成的最多 48 个文件路径和生成它们的命令 id(供对话里的命令行显示图片),只保留最近更新的 500 个会话。删除它只会清空这些选择和记录,不会删除已生成的文件。文件损坏时按空记录处理,下次写入前另存为 `media-sessions.corrupt-<时间>.json`。
427
439
  - **视频任务**:`<Profile 目录>/.dsh-cliproxyapi/media-jobs.json` 保存已提交的视频任务(CPA 请求 id、账号、模型、会话和命令 id、工作目录与保存位置、提示词、时长、提交时间、状态),用于重载或重启后继续等待;最多 200 个,已结束的保留 7 天。不含 Key 和视频地址。文件损坏时按空处理,下次写入前另存为 `media-jobs.corrupt-<时间>.json`。
440
+ - **管理密钥失败次数**:`<Profile 目录>/.dsh-cliproxyapi/quota-state.json` 记录每个账号连续被 CPA 管理接口拒绝(401/403)的次数、最后的错误码和时间,用来在 3 次后停止自动查询;不含密钥。删除它只会清零计数。
428
441
  - **密钥**:通过 DSH 的 `credentials` 服务按引用名存取;凭证服务里没有时,回退读取同名环境变量。
429
442
  - **环境变量**:`CLIPROXYAPI_BASE_URL` 只用于服务地址留空的账号的自动发现。
430
443
  - **插件行配置**:`cordis.patch.yml` 中本插件行的 `config` 会在启动时叠加到状态文件上:包含 `accounts` 时替换账号列表,否则其中的字段作用于第一个账号。一般保持 `{}`,用设置面板配置即可。
package/lib/client.js CHANGED
@@ -309,8 +309,10 @@ window.__ModuleLoader__.load({
309
309
  qDurDH: "{d}天{h}小时",
310
310
  qDurHM: "{h}小时{m}分",
311
311
  qDurM: "{m}分钟",
312
- qErrUnauthorized: "管理密钥无效(401)。为避免 CPA 因错误密钥封禁地址,已暂停自动刷新;更新密钥或手动刷新后重试。",
312
+ qErrUnauthorized: "管理密钥无效(401),请检查后重新保存。",
313
313
  qErrForbidden: "CPA 拒绝了管理请求(403):请确认 CPA 已开启 remote-management.allow-remote,且密钥正确。",
314
+ qAuthRetrying: "已连续被拒绝 {n}/{max} 次;连续 {max} 次后停止自动查询。",
315
+ qAuthPaused: "已连续被拒绝 {n} 次,自动查询已停止,以免 CPA 因错误密钥封禁本机地址。保存新的管理密钥后恢复;手动刷新只会再试一次。",
314
316
  qErrNetwork: "无法连接 CPA 管理接口,请检查服务地址与网络。",
315
317
  qErrTimeout: "CPA 管理接口响应超时,请稍后重试。",
316
318
  qErrHttp: "CPA 管理接口返回错误(HTTP {status})。",
@@ -484,6 +486,10 @@ window.__ModuleLoader__.load({
484
486
  mediaConfirmAllowed: "已允许,开始生成",
485
487
  mediaConfirmDeclined: "已拒绝",
486
488
  mediaConfirmFailed: "没有送达:{error}",
489
+ mediaMasterTitle: "启用图片和视频生成",
490
+ mediaMasterHint: "对所有账号生效。关闭时输入框旁不显示生成按钮,没有 /cpa-image、/cpa-video 等生成命令,也不注册 agent 生成工具;已提交的视频会继续完成,聊天记录里已生成的结果仍可查看。",
491
+ mediaMasterOnNote: "已开启:可以用输入框旁的按钮、斜杠命令生成图片和视频。",
492
+ mediaMasterOffNote: "已关闭:界面上不显示生成功能,也没有生成命令。打开后才会出现下面的模型和设置。",
487
493
  mediaAgentTitle: "允许 agent 生成图片 / 视频",
488
494
  mediaAgentHint: "打开后,对话里的 agent 可以调用 generate_media 工具,用上面启用的生成模型生图、生视频、编辑或延长视频,文件保存到会话工作目录下的保存位置。关闭时不注册这个工具,不占用上下文。斜杠命令和输入框旁的按钮不受影响。",
489
495
  mediaAgentOnNote: "已开启:agent 可以调用 generate_media。确认方式和秒数上限可在输入框旁的生成按钮里按会话单独修改。",
@@ -805,8 +811,10 @@ window.__ModuleLoader__.load({
805
811
  qDurDH: "{d}d {h}h",
806
812
  qDurHM: "{h}h {m}m",
807
813
  qDurM: "{m}m",
808
- qErrUnauthorized: "The management key was rejected (401). Automatic refresh is paused so CPA does not ban this address for bad keys; update the key or refresh manually to retry.",
814
+ qErrUnauthorized: "The management key was rejected (401). Check it and save it again.",
809
815
  qErrForbidden: "CPA refused the management request (403). Make sure remote-management.allow-remote is on and the key is correct.",
816
+ qAuthRetrying: "Rejected {n}/{max} times in a row; automatic checks stop after {max}.",
817
+ qAuthPaused: "Rejected {n} times in a row, so automatic checks have stopped to keep CPA from banning this address for bad keys. Saving a new management key resumes them; a manual refresh tries only once.",
810
818
  qErrNetwork: "Could not reach the CPA management API. Check the server address and network.",
811
819
  qErrTimeout: "The CPA management API timed out. Retry shortly.",
812
820
  qErrHttp: "The CPA management API returned an error (HTTP {status}).",
@@ -980,6 +988,10 @@ window.__ModuleLoader__.load({
980
988
  mediaConfirmAllowed: "Allowed; generating",
981
989
  mediaConfirmDeclined: "Declined",
982
990
  mediaConfirmFailed: "Not delivered: {error}",
991
+ mediaMasterTitle: "Enable image and video generation",
992
+ mediaMasterHint: "Applies to every account. While off, the composer shows no generation buttons, /cpa-image, /cpa-video and the other generation commands do not exist, and no agent generation tool is registered. Videos already submitted still finish, and past results in the chat stay viewable.",
993
+ mediaMasterOnNote: "On: generate images and videos with the composer buttons and slash commands.",
994
+ mediaMasterOffNote: "Off: no generation features appear and no generation commands exist. Turn it on to see the models and settings below.",
983
995
  mediaAgentTitle: "Let the agent generate images / videos",
984
996
  mediaAgentHint: "When on, the chat agent can call the generate_media tool to generate images and videos, or edit / extend videos, with the generation models enabled above; files go to the save location under the session's working directory. When off the tool is not registered and takes no context. Slash commands and the composer button work either way.",
985
997
  mediaAgentOnNote: "On: the agent can call generate_media. Confirmation and the seconds limit can be changed per session in the generation button next to the composer.",
@@ -4745,6 +4757,7 @@ select.dcpa-q-input.dcpa-media-kind { flex: none; width: auto; height: 28px; fon
4745
4757
  if (!mounted.current) return;
4746
4758
  setData(body.media ?? null);
4747
4759
  after?.();
4760
+ if ("enabled" in patch) announceMediaGeneration();
4748
4761
  setNotice({ tone: "ok", text: t("mediaSaved") });
4749
4762
  } catch (err) {
4750
4763
  if (mounted.current) setNotice({ tone: "error", text: mediaErrorText(t, err) });
@@ -5005,13 +5018,47 @@ select.dcpa-q-input.dcpa-media-kind { flex: none; width: auto; height: 28px; fon
5005
5018
  return h("div", { className: "dcpa-tabbody dcpa-models dcpa-media" }, h("p", { className: "dcpa-q-sub" }, t("mediaLoading")));
5006
5019
  }
5007
5020
 
5021
+ // The master switch (all accounts): off hides every generation feature.
5022
+ const generationOn = data?.settings?.enabled === true;
5023
+ const masterCard = h(
5024
+ "div",
5025
+ { className: "dcpa-media-card", "data-generation": generationOn ? "on" : "off" },
5026
+ h(
5027
+ "div",
5028
+ { className: "dcpa-media-agent-head" },
5029
+ h("span", { className: "dcpa-frow-label" }, t("mediaMasterTitle"), h(InfoTip, { t, text: t("mediaMasterHint") })),
5030
+ h(
5031
+ "button",
5032
+ {
5033
+ type: "button",
5034
+ role: "switch",
5035
+ className: "dcpa-switch",
5036
+ "aria-checked": generationOn,
5037
+ "aria-label": t("mediaMasterTitle"),
5038
+ disabled: busy !== null || data === null,
5039
+ onClick: () => void saveGlobal({ enabled: !generationOn }),
5040
+ },
5041
+ h("span", { className: "dcpa-switch-thumb" })
5042
+ )
5043
+ ),
5044
+ h("p", { className: "dcpa-q-sub" }, t(generationOn ? "mediaMasterOnNote" : "mediaMasterOffNote"))
5045
+ );
5046
+ const messages = [
5047
+ error ? h("p", { key: "err", className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, fmt(t, "mediaLoadError", { error })) : null,
5048
+ data?.settingsError ? h("p", { key: "invalid", className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, t("mediaSettingsInvalid")) : null,
5049
+ notice ? h("p", { key: "notice", className: "dcpa-q-msg", "data-tone": notice.tone, role: notice.tone === "error" ? "alert" : "status" }, notice.text) : null,
5050
+ ];
5051
+
5052
+ if (!generationOn) {
5053
+ return h("div", { className: "dcpa-tabbody dcpa-models dcpa-media" }, masterCard, ...messages);
5054
+ }
5055
+
5008
5056
  return h(
5009
5057
  "div",
5010
5058
  { className: "dcpa-tabbody dcpa-models dcpa-media" },
5059
+ masterCard,
5011
5060
  h("p", { className: "dcpa-q-sub" }, t("mediaIntro")),
5012
- error ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, fmt(t, "mediaLoadError", { error })) : null,
5013
- data?.settingsError ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, t("mediaSettingsInvalid")) : null,
5014
- notice ? h("p", { className: "dcpa-q-msg", "data-tone": notice.tone, role: notice.tone === "error" ? "alert" : "status" }, notice.text) : null,
5061
+ ...messages,
5015
5062
  mine && models.length === 0
5016
5063
  ? h("p", { className: "dcpa-hint" }, t(catalogRead ? "mediaNone" : "mediaNoCatalog"))
5017
5064
  : null,
@@ -5215,7 +5262,12 @@ select.dcpa-q-input.dcpa-media-kind { flex: none; width: auto; height: 28px; fon
5215
5262
  function quotaErrorText(t, account) {
5216
5263
  const key = QUOTA_ERROR_KEYS[account?.errorCode];
5217
5264
  if (key === undefined) return null;
5218
- return fmt(t, key, { status: account.errorStatus ?? "?" });
5265
+ const text = fmt(t, key, { status: account.errorStatus ?? "?" });
5266
+ // Consecutive management-key rejections: the count, or that background checks stopped.
5267
+ const n = account.authFailures;
5268
+ if ((account.errorCode !== "unauthorized" && account.errorCode !== "forbidden") || !Number.isInteger(n) || n <= 0) return text;
5269
+ const max = account.authFailureLimit ?? 3;
5270
+ return `${text} ${fmt(t, account.authPaused ? "qAuthPaused" : "qAuthRetrying", { n, max })}`;
5219
5271
  }
5220
5272
 
5221
5273
  function windowLabel(t, w) {
@@ -5527,6 +5579,16 @@ select.dcpa-q-input.dcpa-media-kind { flex: none; width: auto; height: 28px; fon
5527
5579
  const composerBadges = createBadgePresence();
5528
5580
  // The generation-model button has its own composer/hero presence.
5529
5581
  const mediaChips = createBadgePresence();
5582
+ // The Generation tab tells the composer buttons when the master switch
5583
+ // changed, so they appear / disappear without waiting for a retry.
5584
+ const mediaGenerationListeners = new Set();
5585
+ const announceMediaGeneration = () => {
5586
+ for (const fn of [...mediaGenerationListeners]) {
5587
+ try {
5588
+ fn();
5589
+ } catch {}
5590
+ }
5591
+ };
5530
5592
 
5531
5593
  /**
5532
5594
  * Composer placement announces itself for its session; hero placement
@@ -6293,8 +6355,10 @@ select.dcpa-q-input.dcpa-media-kind { flex: none; width: auto; height: 28px; fon
6293
6355
  if (controller.signal.aborted) return;
6294
6356
  if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
6295
6357
  setData(body.session ?? null);
6296
- needsRetry = body.session?.catalogIncomplete === true ||
6297
- !["image", "video"].some((kind) => Array.isArray(body.session?.options?.[kind]) && body.session.options[kind].length > 0);
6358
+ // Generation switched off: nothing to show and nothing to wait for
6359
+ // (the Generation tab announces when it is switched on again).
6360
+ needsRetry = body.session?.generation !== false && (body.session?.catalogIncomplete === true ||
6361
+ !["image", "video"].some((kind) => Array.isArray(body.session?.options?.[kind]) && body.session.options[kind].length > 0));
6298
6362
  } catch (err) {
6299
6363
  if (!controller.signal.aborted) setError(err instanceof Error ? err.message : String(err));
6300
6364
  } finally {
@@ -6309,6 +6373,19 @@ select.dcpa-q-input.dcpa-media-kind { flex: none; width: auto; height: 28px; fon
6309
6373
  };
6310
6374
  }, [sessionId, shadowed, openKind, retry]);
6311
6375
 
6376
+ // The master switch changed on the Generation tab: load again now.
6377
+ useEffect(() => {
6378
+ const reload = () => {
6379
+ if (!mounted.current) return;
6380
+ closeMenu();
6381
+ setRetry((n) => n + 1);
6382
+ };
6383
+ mediaGenerationListeners.add(reload);
6384
+ return () => {
6385
+ mediaGenerationListeners.delete(reload);
6386
+ };
6387
+ }, []);
6388
+
6312
6389
  useEffect(() => {
6313
6390
  if (openKind === null) return undefined;
6314
6391
  const place = () => {
package/lib/index.js CHANGED
@@ -2156,7 +2156,11 @@ export function renderQuotaText(snapshot, onlyAccountId) {
2156
2156
  continue;
2157
2157
  }
2158
2158
  const creds = Array.isArray(a.credentials) ? a.credentials : [];
2159
- const state = a.state + (a.errorCode ? ` (${a.errorCode}${a.errorStatus ? ` ${a.errorStatus}` : ""})` : "") + (a.refreshing ? ", refreshing" : "");
2159
+ const state = a.state + (a.errorCode ? ` (${a.errorCode}${a.errorStatus ? ` ${a.errorStatus}` : ""})` : "") +
2160
+ (Number.isInteger(a.authFailures) && a.authFailures > 0
2161
+ ? `, key rejected ${a.authFailures}/${a.authFailureLimit}${a.authPaused ? ", automatic checks stopped until a new management key is saved" : ""}`
2162
+ : "") +
2163
+ (a.refreshing ? ", refreshing" : "");
2160
2164
  lines.push(`- ${a.displayName} [${a.accountId}]: ${state}; ${creds.length} credential(s)${a.fetchedAt ? `; fetched ${a.fetchedAt}` : ""}; routes ${(a.routes ?? []).join(", ") || "none"}`);
2161
2165
  for (const m of a.pickerModels ?? []) {
2162
2166
  const ids = a.modelIndex?.[m.id] ?? [];
@@ -2356,8 +2360,16 @@ function applyImpl(ctx, config) {
2356
2360
  // account state, never inside it, so no state migration is involved.
2357
2361
  const mediaSettingsPath = join(updateDirectory, "media-settings.json");
2358
2362
  let mediaWarned = false;
2363
+ // Generation is off for a new profile; one that already picked a model or
2364
+ // generated something before the switch existed (session or job file, no
2365
+ // readable settings file) keeps it on.
2366
+ const mediaUsedBefore = () =>
2367
+ existsSync(join(updateDirectory, "media-sessions.json")) || existsSync(join(updateDirectory, "media-jobs.json"));
2359
2368
  const readMedia = () => {
2360
2369
  const loaded = loadMediaSettings(mediaSettingsPath);
2370
+ if ((loaded.error !== undefined || !existsSync(mediaSettingsPath)) && mediaUsedBefore()) {
2371
+ loaded.settings = { ...loaded.settings, enabled: true };
2372
+ }
2361
2373
  if (loaded.error === undefined) {
2362
2374
  mediaWarned = false;
2363
2375
  } else if (!mediaWarned) {
@@ -2396,7 +2408,19 @@ function applyImpl(ctx, config) {
2396
2408
 
2397
2409
  // Quota ("额度" tab + composer badge): management keys live in the
2398
2410
  // credentials service under their own refs, never in account state.
2411
+ // Consecutive management-key rejections per account (never the key itself),
2412
+ // so a wrong key stays stopped across reloads and restarts.
2413
+ const quotaStatePath = join(updateDirectory, "quota-state.json");
2399
2414
  const quota = createQuotaController({
2415
+ authStore: {
2416
+ read: () => (existsSync(quotaStatePath) ? JSON.parse(readFileSync(quotaStatePath, "utf8")) : undefined),
2417
+ write: (value) => {
2418
+ mkdirSync(dirname(quotaStatePath), { recursive: true });
2419
+ const tmp = `${quotaStatePath}.${Date.now().toString(36)}.tmp`;
2420
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
2421
+ renameSync(tmp, quotaStatePath);
2422
+ },
2423
+ },
2400
2424
  listAccounts: () => controller.quotaAccounts(),
2401
2425
  resolveKey: (ref) => resolveApiKey(ref),
2402
2426
  describeKey: (ref) => describeApiKey(ref),
@@ -2453,13 +2477,18 @@ function applyImpl(ctx, config) {
2453
2477
  // Per-session model picks and recent outputs, in their own file.
2454
2478
  const mediaSessions = createMediaSessionStore(join(updateDirectory, "media-sessions.json"));
2455
2479
 
2480
+ // With generation switched off nothing is offered: no composer buttons, no
2481
+ // agent tool, and no command can pick a model.
2456
2482
  const mediaChoices = () => {
2457
2483
  const { settings } = readMedia();
2458
2484
  const accounts = controller.mediaAccounts();
2485
+ const on = settings.enabled === true;
2459
2486
  return {
2460
2487
  settings,
2461
2488
  accounts,
2462
- options: { image: mediaOptions(accounts, settings, "image"), video: mediaOptions(accounts, settings, "video") },
2489
+ options: on
2490
+ ? { image: mediaOptions(accounts, settings, "image"), video: mediaOptions(accounts, settings, "video") }
2491
+ : { image: [], video: [] },
2463
2492
  };
2464
2493
  };
2465
2494
 
@@ -2474,6 +2503,7 @@ function applyImpl(ctx, config) {
2474
2503
  let catalogRetryAt = 0;
2475
2504
  const mediaChoicesFor = async (kind) => {
2476
2505
  let choices = mediaChoices();
2506
+ if (choices.settings.enabled !== true) return choices;
2477
2507
  const missing = (k) => choices.options[k].length === 0;
2478
2508
  const someMissing = kind === "any" ? missing("image") || missing("video") : missing(kind);
2479
2509
  const cold = choices.accounts.some((a) => a.enabled && a.discovered === 0);
@@ -2513,6 +2543,8 @@ function applyImpl(ctx, config) {
2513
2543
  const policy = agentPolicy(settings, entry);
2514
2544
  return {
2515
2545
  sessionId: sessionId ?? null,
2546
+ // The Generation tab's master switch; off = the composer shows nothing.
2547
+ generation: settings.enabled === true,
2516
2548
  options,
2517
2549
  current,
2518
2550
  // Compatibility composer fallback; native questions never enter this map.
@@ -2522,7 +2554,7 @@ function applyImpl(ctx, config) {
2522
2554
  // True while an enabled account's last catalog read missed the plain
2523
2555
  // listing; the composer button retries instead of keeping an old,
2524
2556
  // image-less list.
2525
- catalogIncomplete: accounts.some((a) => a.enabled === true && a.catalogIncomplete === true),
2557
+ catalogIncomplete: settings.enabled === true && accounts.some((a) => a.enabled === true && a.catalogIncomplete === true),
2526
2558
  // The agent tool's rules for this session (shown while the tool is switched on).
2527
2559
  agent: {
2528
2560
  enabled: settings.agentTool === true,
@@ -3608,6 +3640,9 @@ function applyImpl(ctx, config) {
3608
3640
  const TOOL_PREVIEWS = 4;
3609
3641
  let toolsCtx;
3610
3642
  let toolOff;
3643
+ // The generation slash commands (registered only while generation is on).
3644
+ let commandsCtx;
3645
+ let mediaCommandsOff;
3611
3646
 
3612
3647
  const gateError = (message) => Object.assign(new Error(message), { gateError: true });
3613
3648
 
@@ -3964,6 +3999,37 @@ function applyImpl(ctx, config) {
3964
3999
  }
3965
4000
  ctx.logger?.info?.("cpa: generate_media tool unregistered");
3966
4001
  }
4002
+ syncMediaCommands();
4003
+ };
4004
+
4005
+ /**
4006
+ * Register or unregister /cpa-image and the /cpa-video* commands to match
4007
+ * the Generation tab's master switch. Jobs already submitted keep running.
4008
+ */
4009
+ const syncMediaCommands = () => {
4010
+ let wanted = false;
4011
+ try {
4012
+ wanted = readMedia().settings.enabled === true;
4013
+ } catch {
4014
+ wanted = false;
4015
+ }
4016
+ if (wanted && mediaCommandsOff === undefined && commandsCtx !== undefined) {
4017
+ try {
4018
+ mediaCommandsOff = registerMediaCommands(commandsCtx);
4019
+ ctx.logger?.info?.("cpa: generation commands registered");
4020
+ } catch (error) {
4021
+ ctx.logger?.warn?.(`cpa: could not register the generation commands: ${error instanceof Error ? error.message : String(error)}`);
4022
+ }
4023
+ } else if (!wanted && mediaCommandsOff !== undefined) {
4024
+ const off = mediaCommandsOff;
4025
+ mediaCommandsOff = undefined;
4026
+ try {
4027
+ off();
4028
+ } catch {
4029
+ // already gone with its scope
4030
+ }
4031
+ ctx.logger?.info?.("cpa: generation commands unregistered");
4032
+ }
3967
4033
  };
3968
4034
  // #endregion
3969
4035
 
@@ -3977,6 +4043,8 @@ function applyImpl(ctx, config) {
3977
4043
  );
3978
4044
 
3979
4045
  ctx.on("credentials/reference-updated", (ref) => {
4046
+ // A management key changed (also from the DSH credentials page): start a new failure count.
4047
+ quota.keyChanged(ref);
3980
4048
  for (const id of controller.accountsUsingKey(ref)) {
3981
4049
  void controller.refreshAccount(id);
3982
4050
  }
@@ -4013,6 +4081,26 @@ function applyImpl(ctx, config) {
4013
4081
  return { kind: failed ? "error" : "success", text: renderStatusText(payload) };
4014
4082
  },
4015
4083
  });
4084
+ commandsCtx = cmdCtx;
4085
+ syncMediaCommands();
4086
+ return () => {
4087
+ offStatus?.();
4088
+ offRefresh?.();
4089
+ commandsCtx = undefined;
4090
+ const off = mediaCommandsOff;
4091
+ mediaCommandsOff = undefined;
4092
+ try {
4093
+ off?.();
4094
+ } catch {
4095
+ // disposed with its scope
4096
+ }
4097
+ };
4098
+ }, "cpa: commands");
4099
+ });
4100
+
4101
+ /** The generation commands, registered only while generation is switched on (see syncMediaCommands). */
4102
+ function registerMediaCommands(cmdCtx) {
4103
+ {
4016
4104
  const offVideo = cmdCtx.commands.register({
4017
4105
  name: "cpa-video",
4018
4106
  description: "Generate a video with a CLIProxyAPI video model: text only, one image as the first frame, reference images (--ref, or 2+ images), or pinned first / last frames (--first-last, --last). A submitted video cannot be cancelled.",
@@ -4043,16 +4131,14 @@ function applyImpl(ctx, config) {
4043
4131
  handler: runImageCommand,
4044
4132
  });
4045
4133
  return () => {
4046
- offStatus?.();
4047
- offRefresh?.();
4048
4134
  offImage?.();
4049
4135
  offVideo?.();
4050
4136
  offVideoStatus?.();
4051
4137
  offVideoEdit?.();
4052
4138
  offVideoExtend?.();
4053
4139
  };
4054
- }, "cpa: commands");
4055
- });
4140
+ }
4141
+ }
4056
4142
 
4057
4143
  // The optional agent tool (registered only while switched on; see syncAgentTool).
4058
4144
  ctx.inject(["tools"], (scope) => {
@@ -4230,8 +4316,9 @@ function applyImpl(ctx, config) {
4230
4316
  const desktop = process.platform === "win32" ? "explorer" : process.platform === "darwin" ? "finder" : "filemanager";
4231
4317
  // Edit / extend this video: by xAI's short-lived link while it is
4232
4318
  // remembered, else by sending its file inline when small enough.
4319
+ // Past results stay visible with generation off, without the edit / extend actions.
4233
4320
  let actions;
4234
- if (run?.kind === "video" && run.status === "done") {
4321
+ if (run?.kind === "video" && run.status === "done" && readMedia().settings.enabled === true) {
4235
4322
  const source = usableSource(commandId);
4236
4323
  const video = files.find((f) => f.kind === "video");
4237
4324
  let size;
package/lib/media.js CHANGED
@@ -97,9 +97,15 @@ export class MediaError extends Error {
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * Defaults for a profile without `media-settings.json`: generation is off
102
+ * until the user switches it on (an older file without `enabled` reads as on,
103
+ * see {@link normalizeMediaSettings}).
104
+ */
100
105
  export function defaultMediaSettings() {
101
106
  return {
102
107
  version: MEDIA_SETTINGS_VERSION,
108
+ enabled: false,
103
109
  agentTool: false,
104
110
  outputDir: "generated",
105
111
  defaultVideoSeconds: 6,
@@ -182,7 +188,9 @@ function normalizeAccountMedia(value) {
182
188
 
183
189
  /**
184
190
  * Tolerant normalization: every invalid or missing field takes its default,
185
- * unknown fields are dropped. The result always has `version: 1`.
191
+ * unknown fields are dropped. The result always has `version: 1`. A missing
192
+ * `enabled` means a file written before the switch existed (by someone who
193
+ * already used generation), so it reads as on.
186
194
  */
187
195
  export function normalizeMediaSettings(raw) {
188
196
  const input = plain(raw) ?? {};
@@ -199,6 +207,7 @@ export function normalizeMediaSettings(raw) {
199
207
  }
200
208
  return {
201
209
  version: MEDIA_SETTINGS_VERSION,
210
+ enabled: typeof input.enabled === "boolean" ? input.enabled : true,
202
211
  agentTool: input.agentTool === true,
203
212
  outputDir: validOutputDir(input.outputDir) ?? defaults.outputDir,
204
213
  defaultVideoSeconds: intIn(input.defaultVideoSeconds, VIDEO_SECONDS) ?? defaults.defaultVideoSeconds,
@@ -241,6 +250,7 @@ export function saveMediaSettings(filePath, settings) {
241
250
  }
242
251
 
243
252
  const PATCH_FIELDS = [
253
+ "enabled",
244
254
  "agentTool",
245
255
  "outputDir",
246
256
  "defaultVideoSeconds",
@@ -265,6 +275,10 @@ export function applyMediaPatch(current, patch) {
265
275
  const unknown = Object.keys(input).filter((key) => !PATCH_FIELDS.includes(key) && key !== "accountId");
266
276
  if (unknown.length > 0) throw new MediaError("invalid-request", `unknown field(s): ${unknown.join(", ")}`);
267
277
  const next = normalizeMediaSettings(current);
278
+ if ("enabled" in input) {
279
+ if (typeof input.enabled !== "boolean") throw new MediaError("invalid-enabled");
280
+ next.enabled = input.enabled;
281
+ }
268
282
  if ("agentTool" in input) {
269
283
  if (typeof input.agentTool !== "boolean") throw new MediaError("invalid-agent-tool");
270
284
  next.agentTool = input.agentTool;
package/lib/quota.js CHANGED
@@ -16,8 +16,39 @@ export const DEFAULT_QUOTA_TIMEOUT_MS = 15_000;
16
16
  export const DEFAULT_QUOTA_CONCURRENCY = 4;
17
17
  export const MAX_QUOTA_RESPONSE_BYTES = 4 * 1024 * 1024;
18
18
 
19
+ // Consecutive management 401/403 answers after which automatic refresh stops
20
+ // (CPA bans the client address for 30 minutes after 5 failed attempts).
21
+ export const AUTH_FAILURE_LIMIT = 3;
22
+ export const QUOTA_AUTH_STATE_VERSION = 1;
23
+
19
24
  const MANAGEMENT_KEY_REF = "CLI_PROXY_MANAGEMENT_KEY";
20
25
  const STATUS_MESSAGE_MAX = 160;
26
+ const AUTH_ERROR_CODES = ["unauthorized", "forbidden"];
27
+ const AUTH_ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
28
+
29
+ /**
30
+ * The persisted management-key failure counts (`quota-state.json`):
31
+ * `{ version: 1, accounts: { [id]: { authFailures, errorCode, errorStatus?, failedAt } } }`.
32
+ * Never holds a key. Invalid entries are dropped; accounts without failures are not kept.
33
+ */
34
+ export function normalizeQuotaAuthState(raw) {
35
+ const accounts = {};
36
+ const input = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw.accounts : undefined;
37
+ if (input !== null && typeof input === "object" && !Array.isArray(input)) {
38
+ for (const [id, entry] of Object.entries(input)) {
39
+ if (!AUTH_ACCOUNT_ID.test(id) || entry === null || typeof entry !== "object") continue;
40
+ const failures = Number.isInteger(entry.authFailures) && entry.authFailures > 0 ? Math.min(entry.authFailures, 1000) : 0;
41
+ if (failures === 0) continue;
42
+ accounts[id] = {
43
+ authFailures: failures,
44
+ errorCode: AUTH_ERROR_CODES.includes(entry.errorCode) ? entry.errorCode : "unauthorized",
45
+ ...(Number.isInteger(entry.errorStatus) ? { errorStatus: entry.errorStatus } : {}),
46
+ ...(typeof entry.failedAt === "string" && entry.failedAt.length <= 40 ? { failedAt: entry.failedAt } : {}),
47
+ };
48
+ }
49
+ }
50
+ return { version: QUOTA_AUTH_STATE_VERSION, accounts };
51
+ }
21
52
 
22
53
  const UPSTREAM = {
23
54
  codexUsage: "https://chatgpt.com/backend-api/wham/usage",
@@ -1053,14 +1084,53 @@ export function createQuotaController(deps) {
1053
1084
  const agPreferred = new Map();
1054
1085
  let disposed = false;
1055
1086
 
1087
+ // Consecutive management 401/403 counts survive reloads through
1088
+ // `deps.authStore` ({ read(), write(state) }); without one they live in memory.
1089
+ const readAuth = () => {
1090
+ try {
1091
+ return normalizeQuotaAuthState(deps.authStore?.read?.());
1092
+ } catch {
1093
+ return normalizeQuotaAuthState(undefined);
1094
+ }
1095
+ };
1096
+ const writeAuthEntry = (id, entry) => {
1097
+ if (deps.authStore?.write === undefined) return;
1098
+ try {
1099
+ const next = readAuth();
1100
+ if (entry === undefined) {
1101
+ if (next.accounts[id] === undefined) return;
1102
+ delete next.accounts[id];
1103
+ } else {
1104
+ next.accounts[id] = entry;
1105
+ }
1106
+ deps.authStore.write(normalizeQuotaAuthState(next));
1107
+ } catch (error) {
1108
+ log("warn", `dsh-cliproxyapi: could not save the quota key failure count: ${error instanceof Error ? error.message : String(error)}`);
1109
+ }
1110
+ };
1111
+
1056
1112
  const stateFor = (id) => {
1057
1113
  let st = states.get(id);
1058
1114
  if (st === undefined) {
1059
- st = { gen: 0, inflight: null, result: null, data: null, lockedOut: false, lastAttemptAt: undefined };
1115
+ const saved = readAuth().accounts[id];
1116
+ st = {
1117
+ gen: 0,
1118
+ inflight: null,
1119
+ result: saved === undefined ? null : { state: "error", errorCode: saved.errorCode, ...(saved.errorStatus === undefined ? {} : { errorStatus: saved.errorStatus }) },
1120
+ data: null,
1121
+ authFailures: saved?.authFailures ?? 0,
1122
+ lastAttemptAt: undefined,
1123
+ };
1060
1124
  states.set(id, st);
1061
1125
  }
1062
1126
  return st;
1063
1127
  };
1128
+ const authPaused = (st) => st.authFailures >= AUTH_FAILURE_LIMIT;
1129
+ /** Forget an account's failure count (a new key, a cleared key, a removed account). */
1130
+ const resetAuth = (id, st = states.get(id)) => {
1131
+ if (st !== undefined) st.authFailures = 0;
1132
+ writeAuthEntry(id, undefined);
1133
+ };
1064
1134
 
1065
1135
  const log = (level, message) => {
1066
1136
  try {
@@ -1479,15 +1549,30 @@ export function createQuotaController(deps) {
1479
1549
  const fetchedAt = new Date(now()).toISOString();
1480
1550
  st.data = { credentials: result.credentials, fetchedAt };
1481
1551
  st.result = { state: "ok" };
1482
- st.lockedOut = false;
1552
+ if (st.authFailures > 0) resetAuth(account.id, st);
1483
1553
  }
1484
1554
  } catch (error) {
1485
1555
  if (!current()) return;
1486
1556
  const code = error instanceof QuotaError && error.code !== "aborted" ? error.code : error?.code === "aborted" ? null : "network";
1487
1557
  if (code === null) return;
1488
1558
  st.result = { state: "error", errorCode: code, ...(error?.status === undefined ? {} : { errorStatus: error.status }) };
1489
- if (code === "unauthorized" || code === "forbidden") st.lockedOut = true;
1490
- log("warn", `dsh-cliproxyapi: quota refresh failed for account ${account.id}: ${code}${error?.status === undefined ? "" : ` (${error.status})`}`);
1559
+ // Only a rejected key counts; network errors neither count nor reset.
1560
+ if (AUTH_ERROR_CODES.includes(code)) {
1561
+ st.authFailures += 1;
1562
+ writeAuthEntry(account.id, {
1563
+ authFailures: st.authFailures,
1564
+ errorCode: code,
1565
+ ...(error?.status === undefined ? {} : { errorStatus: error.status }),
1566
+ failedAt: new Date(now()).toISOString(),
1567
+ });
1568
+ }
1569
+ log(
1570
+ "warn",
1571
+ `dsh-cliproxyapi: quota refresh failed for account ${account.id}: ${code}${error?.status === undefined ? "" : ` (${error.status})`}` +
1572
+ (AUTH_ERROR_CODES.includes(code)
1573
+ ? ` — management key rejected ${st.authFailures}/${AUTH_FAILURE_LIMIT}${authPaused(st) ? "; automatic refresh stopped" : ""}`
1574
+ : "")
1575
+ );
1491
1576
  } finally {
1492
1577
  root.signal.removeEventListener("abort", onRootAbort);
1493
1578
  if (st.gen === gen) {
@@ -1532,6 +1617,9 @@ export function createQuotaController(deps) {
1532
1617
  ...(state === "error" ? { errorCode: st.result.errorCode, ...(st.result.errorStatus === undefined ? {} : { errorStatus: st.result.errorStatus }) } : {}),
1533
1618
  ...(configured && st.data !== null ? { fetchedAt: st.data.fetchedAt } : {}),
1534
1619
  refreshing,
1620
+ ...(configured && st.authFailures > 0
1621
+ ? { authFailures: st.authFailures, authFailureLimit: AUTH_FAILURE_LIMIT, authPaused: authPaused(st) }
1622
+ : {}),
1535
1623
  credentials: configured && st.data !== null ? structuredClone(st.data.credentials) : [],
1536
1624
  pickerModels,
1537
1625
  };
@@ -1552,6 +1640,8 @@ export function createQuotaController(deps) {
1552
1640
  if (!known.has(id)) {
1553
1641
  cancelInflight(st);
1554
1642
  states.delete(id);
1643
+ // An empty list may be a failed read; keep the stored count then.
1644
+ if (accounts.length > 0) resetAuth(id);
1555
1645
  }
1556
1646
  }
1557
1647
  return Promise.all(accounts.map(async (account) => {
@@ -1568,7 +1658,8 @@ export function createQuotaController(deps) {
1568
1658
  for (const { account, keyConfigured: configured } of prepared) {
1569
1659
  if (account.enabled !== true || !configured) continue;
1570
1660
  const st = stateFor(account.id);
1571
- if (st.inflight !== null || st.lockedOut) continue;
1661
+ // A key rejected AUTH_FAILURE_LIMIT times in a row is not tried in the background again.
1662
+ if (st.inflight !== null || authPaused(st)) continue;
1572
1663
  if (st.lastAttemptAt !== undefined && at - st.lastAttemptAt < ttlMs) continue;
1573
1664
  startRefresh(account);
1574
1665
  }
@@ -1582,8 +1673,8 @@ export function createQuotaController(deps) {
1582
1673
  if (accountId !== undefined && accountId !== null && accountId !== "") {
1583
1674
  const account = findAccount(accountId);
1584
1675
  if (!disposed) {
1585
- const st = stateFor(account.id);
1586
- st.lockedOut = false;
1676
+ // A manual refresh tries once even when paused; the count is kept,
1677
+ // so another rejection pauses again at once (a success resets it).
1587
1678
  if (await keyConfigured(managementKeyRef(account.id))) {
1588
1679
  const running = startRefresh(account);
1589
1680
  if (wait) await running;
@@ -1593,9 +1684,9 @@ export function createQuotaController(deps) {
1593
1684
  }
1594
1685
  const prepared = await prepare();
1595
1686
  if (!disposed) {
1596
- // Refresh-all never retries a locked-out account; only refresh(id) or saveKey clears a lockout.
1687
+ // Refresh-all never retries a paused account; only refresh(id) tries it once, and a new key resets it.
1597
1688
  await Promise.all(prepared
1598
- .filter(({ account, keyConfigured: configured }) => account.enabled === true && configured && !stateFor(account.id).lockedOut)
1689
+ .filter(({ account, keyConfigured: configured }) => account.enabled === true && configured && !authPaused(stateFor(account.id)))
1599
1690
  .map(({ account }) => (wait ? startRefresh(account) : (startRefresh(account), undefined))));
1600
1691
  }
1601
1692
  return buildSnapshot(prepared);
@@ -1611,7 +1702,7 @@ export function createQuotaController(deps) {
1611
1702
  await deps.unsetKey(ref);
1612
1703
  st.data = null;
1613
1704
  st.result = null;
1614
- st.lockedOut = false;
1705
+ resetAuth(account.id, st);
1615
1706
  st.lastAttemptAt = undefined;
1616
1707
  return buildSnapshot();
1617
1708
  }
@@ -1621,16 +1712,27 @@ export function createQuotaController(deps) {
1621
1712
  cancelInflight(st);
1622
1713
  await deps.setKey(ref, value);
1623
1714
  st.result = null;
1624
- st.lockedOut = false;
1715
+ resetAuth(account.id, st);
1625
1716
  }
1626
1717
  if (!disposed && (await keyConfigured(ref))) {
1627
- st.lockedOut = false;
1628
1718
  const running = startRefresh(account);
1629
1719
  if (wait) await running;
1630
1720
  }
1631
1721
  return buildSnapshot();
1632
1722
  },
1633
1723
 
1724
+ /**
1725
+ * A credential reference changed outside saveKey (e.g. the DSH credentials
1726
+ * page): when it is an account's management key, forget its failure count.
1727
+ * @returns the account id, or undefined for any other reference.
1728
+ */
1729
+ keyChanged(ref) {
1730
+ const account = listAccounts().find((a) => managementKeyRef(a.id) === ref);
1731
+ if (account === undefined) return undefined;
1732
+ resetAuth(account.id);
1733
+ return account.id;
1734
+ },
1735
+
1634
1736
  dispose() {
1635
1737
  if (disposed) return;
1636
1738
  disposed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cliproxyapi-pro",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Auto-discovery CLIProxyAPI (CPA) provider and management UI for DeepSeek Harness — multiple accounts, dynamic model sync, capability detection, and visual settings.",
5
5
  "keywords": [
6
6
  "dsh",