@omdp/dsh-key-fallback 3.0.0 → 3.1.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.
Files changed (4) hide show
  1. package/README.md +53 -26
  2. package/lib/client.js +243 -122
  3. package/lib/index.js +258 -40
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -1,48 +1,75 @@
1
1
  # @omdp/dsh-key-fallback
2
2
 
3
3
  Multi-key API key pool with automatic rotation for DeepSeek Harness. Sits between the LLM adapter and the credential store:
4
- the plugin picks a key from the per-provider pool and writes it into the provider's credential reference before each request;
5
- on a retryable error (`AUTH` / `QUOTA_EXCEEDED` / `RATE_LIMIT` by default), it advances to the next key in the chain and returns `{ kind: 'retry' }` so the agent loop retries with the rotated key.
4
+ the plugin picks a key from the per-provider pool and pre-writes it into the provider's credential reference before each request;
5
+ on a configured trigger error it marks the failed key (cooldown) and advances to the next key in the chain, then lets DSH's own
6
+ `dsh-llm-retry` decide whether/when to retry (the plugin never re-sends on its own).
6
7
 
7
- This is **v3** (work in progress; not yet feature-frozen). v3 supersedes v1 (which stored keys in plaintext under `~/.dsh/settings.yaml#keyFallback.providers`);
8
- v1 still ships in this profile (it registers the `key-fallback` settings namespace), so v3 deliberately uses a different namespace (`key-fallback-pools`) to avoid the registration conflict.
8
+ Current version is **v6** (`3.1.0`).
9
9
 
10
- ## What's in v3
10
+ ## What v6 offers
11
11
 
12
- - **Independent settings page**: **Settings → API Key 回退** — a top-level section, not a card under plugin config.
13
- - **Provider dropdown** (the chooser merges `ctx.llm.listProviders()` and `ctx.llm.listConfigurableProviders()`).
14
- - **Add / remove keys via the UI** (write-only; the value is never read back into the page).
15
- - **Per-provider enabled switch** (disable = the pool is bypassed entirely).
16
- - **Environment key in the pool**: the runtime composes `effectiveKeys = [envKey, ...userKeys]`. The env key is the first entry, marked `[环境来源]` in the UI, and cannot be edited or deleted; the environment remains the source of truth for it.
17
- - **Manual key selector** (pool-level dropdown "当前使用") — pick any key (including the env one) to lock the pool to that key. Failures do **not** auto-rotate while a key is locked; select "自动轮换" to return to cursor-based rotation.
18
- - **Rotation on retryable errors** (`agent/request-error` waterfall) with cooldown tracking per key.
19
- - **Pre-write on every request** (`agent/request` waterfall) so the LLM adapter always reads the pool key, not the original env value.
20
- - **v1 → v3 one-shot migration** on first launch: reads `~/.dsh/settings.yaml#keyFallback.providers`, writes the plaintext keys into the credential store, copies the pool metadata into the new namespace, and marks `keyFallbackMigratedToV3: true` to prevent re-running. The v1 plaintext block is **left in place** (deleting it would touch a file DSH is actively using; verify the v3 UI looks right, then delete manually).
12
+ **Settings → API Key 回退** — a top-level settings page with a redesigned UI:
21
13
 
22
- ## What's still in flight / not done
14
+ - **Pool cards** with status dots, badges, enable switch, per-pool cooldown display and a "重置冷却" reset button.
15
+ - **轮转触发码 (rotation triggers) are now configurable and actually enforced**: click chips to select which error codes
16
+ cause rotation (`QUOTA` / `AUTH` / `RATE_LIMIT` / `TIMEOUT` / `TRANSPORT` / `SERVER`), or add a custom code. The default set is a
17
+ superset of the old hard-coded behavior, so existing pools behave identically unless you change it.
18
+ - **真正当前使用的 key 显示**: the page shows which key is actually being used right now (derived from the last value written to the
19
+ provider's env), instead of a truncated hash.
20
+ - **短 ref 命名**: new keys are named `key_fallback_<provider>_key1`, `key_fallback_<provider>_key2`, … so the UI shows clean short
21
+ names (`key1`, `key2`…) instead of long ref strings. Existing long refs are migrated once, automatically and idempotently.
22
+ - **明文揭示**: every key row has an eye toggle (`👁` / `🙈`) that fetches and shows the real value via `GET /keys/plain` (only for
23
+ keys owned by that pool, or the pool's env key).
24
+ - **环境密钥可编辑**: the pool's env key (`AGNES_API_KEY` etc.) can be updated right in the page when it's backed by the writable
25
+ credential file. If it is supplied by the launching environment (read-only), the UI says so and refuses to edit.
26
+ - **Per-key controls**: rename/update value, set the "失败后→" next key (nextRef), lock the pool to a specific key ("设为当前"),
27
+ delete a key (the env key is not deletable).
28
+ - **Pool-level "锁定"**: lock the pool to a specific key or return to 自动轮换 (cursor-based).
23
29
 
24
- - **Cooldown / failure status rendering in the UI**: host returns `failCount / cooldownUntil / cooldownRemainingMs / lastErrorAt / lastErrorMsg / status: 'cooling' | 'healthy'` per key and a pool-level `cursor`; the client currently shows the basic list and the env-key badge but does not yet render the cooldown progress bars. Pending — I stopped here for a stable checkpoint after you said "收尾" (wrap up).
25
- - **v1 and v3 running side by side**: v1's host still loads alongside v3 and both touch the same `~/.dsh/.credentials.yaml`. This is the root cause of the credential-file lock contention you hit while editing model config. To eliminate it: uninstall v1 (remove the dep from `~/.dsh/profiles/web/package.json` and delete `node_modules/@omdp/dsh-key-fallback` so only v3 remains). **Until you do, behavior is undefined under concurrent writes.**
26
- - **DSH-side bundle caching**: the browser keeps old client bundles by `rev=` hash. The settings-page fix you saw (label-as-function contract) only lands after a DSH restart that re-hashes the plugin. Use Ctrl+Shift+R if a page shows stale UI.
30
+ ## Rotation semantics
31
+
32
+ - **pick**: respects `useKeyRef` lock first (but a cooling locked key is skipped so the pool never deadlocks on a bad key), otherwise
33
+ cursor round-robin over live keys.
34
+ - **fail** (`agent/request-error`, registered `prepend` so it runs before `dsh-llm-retry`): only when the error matches the pool's
35
+ `rotateOn` (code / status mapping / message keyword). Marks the failed key with a fixed `cooldownMs` (no exponential backoff), then
36
+ switches to `nextRef` if configured, else the next live key.
37
+ - **re-send** is left entirely to DSH's `dsh-llm-retry` with the user's own retry policy; this plugin only switches the key and writes
38
+ config/env. `ABORTED` (user cancel) never triggers rotation.
39
+
40
+ ## Diagnostics
41
+
42
+ - The client POSTs runtime exceptions to `POST /dsh-key-fallback/diag`.
43
+ - `GET /dsh-key-fallback/diag` returns the in-memory buffer (last 200 entries) as JSON.
27
44
 
28
45
  ## Install
29
46
 
30
47
  ```sh
31
- # 1) from a local checkout (no publish required)
48
+ # from a local checkout (no publish required)
32
49
  dsh plugin --profile web add link:D:/WorkSpace/omdp/dsh-key-fallback
33
50
  # or from npm
34
51
  dsh plugin --profile web add @omdp/dsh-key-fallback
35
52
 
36
- # 2) restart DSH
37
-
38
- # 3) open Settings → API Key 回退
53
+ # restart DSH, then open Settings → API Key 回退
39
54
  ```
40
55
 
41
- ## Diagnostics (built in for v3, not for v1)
56
+ > Note: this profile currently installs the package from npm (`^3.x`) into
57
+ > `~/.dsh/profiles/web/node_modules/@omdp/dsh-key-fallback`; copying updated `lib/index.js` + `lib/client.js` there and restarting DSH
58
+ > picks up v6.
59
+
60
+ ## HTTP API (host)
42
61
 
43
- - The client wires `window.addEventListener('error' / 'unhandledrejection')` to POST every runtime exception to `POST /dsh-key-fallback/diag`.
44
- - The host keeps the last 50 reports in memory.
45
- - `GET /dsh-key-fallback/diag` returns the buffer as JSON. Use this to see what's actually breaking in the browser (instead of reading minified DSH bundles).
62
+ | Method | Path | Purpose |
63
+ | --- | --- | --- |
64
+ | GET | `/dsh-key-fallback/pools` | list pools with live status, activeRef, env writability, per-key status |
65
+ | POST | `/dsh-key-fallback/pools` | create/update pool (enabled, cooldownMs, rotateOn, useKeyRef, …) |
66
+ | DELETE | `/dsh-key-fallback/pools?provider=` | delete pool + all its keys |
67
+ | POST | `/dsh-key-fallback/keys` | add key (auto short ref `key_fallback_<p>_keyN`) |
68
+ | PATCH | `/dsh-key-fallback/keys` | update value / label / nextRef / useKeyRef (env key value = edit env) |
69
+ | DELETE | `/dsh-key-fallback/keys?provider=&ref=` | delete key (env key refused) |
70
+ | GET | `/dsh-key-fallback/keys/plain?provider=&ref=` | reveal real value (pool-owned keys / env key only) |
71
+ | POST | `/dsh-key-fallback/reset` | reset cooldown state for a pool |
72
+ | GET/POST | `/dsh-key-fallback/diag` | diagnostics buffer |
46
73
 
47
74
  ## License
48
75
 
package/lib/client.js CHANGED
@@ -1,14 +1,13 @@
1
- // @omdp/dsh-key-fallback — Client half (v3)
1
+ // @omdp/dsh-key-fallback — Client half (v6)
2
2
  //
3
3
  // 独立设置页「设置 → API Key 回退」:
4
4
  // 1) provider 下拉(来自 /providers:live + dormant)→ 「启用」创建池
5
- // 2) 每个池一张卡片:enabled 开关 / env / cooldownMs / rotateOn chips /
6
- // key 链(只写输入,保存后不回读,只显示 [hidden] 占位 + 删除)
7
- // 3) 顶部刷新按钮 + 冷却重置
5
+ // 2) 每个池一张卡片:enabled 开关 / env / cooldownMs / rotateOn 可点选 chips /
6
+ // key 链(只写输入 + 眼睛看明文 + 环境密钥可编辑)
7
+ // 3) 顶部刷新 + 冷却重置
8
8
  //
9
9
  // 走 fetch 跟 host 通信(/dsh-key-fallback/*),视觉用 --dsw-alias-* 变量。
10
- //
11
- // ── 诊断:把渲染/运行时错误上报到 host 的 /dsh-key-fallback/diag ──
10
+
12
11
  function __kfReportDiag(kind, message, stack) {
13
12
  try {
14
13
  fetch('/dsh-key-fallback/diag', {
@@ -34,60 +33,80 @@ window.__ModuleLoader__.load({
34
33
  var React = require('react')
35
34
  var ce = React.createElement
36
35
 
36
+ // ── 常用代码 / 名称 ──
37
+ var ROTATE_CODES = ['QUOTA', 'AUTH', 'RATE_LIMIT', 'TIMEOUT', 'TRANSPORT', 'SERVER']
38
+ function codeLabel(c) {
39
+ return { QUOTA: '配额用尽', AUTH: '认证失败', RATE_LIMIT: '限流', TIMEOUT: '超时', TRANSPORT: '传输错误', SERVER: '服务端' }[c] || c
40
+ }
41
+
37
42
  // ── CSS(一次注入)──
38
43
  var CSS = [
39
- '.kf_root{display:flex;flex-direction:column;gap:16px;padding:0 24px 28px}',
40
- '.kf_head{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));padding-bottom:12px}',
41
- '.kf_title{font-size:17px;font-weight:600;color:var(--dsw-alias-label-primary,#eee);margin:0}',
42
- '.kf_sub{font-size:12.5px;color:var(--dsw-alias-label-secondary,#9aa);margin:4px 0 0;line-height:1.55}',
43
- '.kf_card{background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.035));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:12px}',
44
- '.kf_cardHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
45
- '.kf_name{font-weight:600;font-size:14.5px;color:var(--dsw-alias-label-primary,#eee);display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
46
- '.kf_badge{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 9px;border-radius:999px;border:1px solid currentColor;line-height:1.6;white-space:nowrap}',
44
+ '.kf_root{display:flex;flex-direction:column;gap:18px;padding:0 24px 30px;max-width:980px;margin:0 auto}',
45
+ '.kf_head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;flex-wrap:wrap;padding-bottom:14px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2))}',
46
+ '.kf_title{font-size:19px;font-weight:700;color:var(--dsw-alias-label-primary,#f1f1f1);margin:0;display:flex;align-items:center;gap:10px;letter-spacing:.2px}',
47
+ '.kf_title .kf_dot{width:10px;height:10px;border-radius:50%;background:linear-gradient(135deg,#4ade80,#22d3ee);box-shadow:0 0 10px rgba(74,222,128,.6)}',
48
+ '.kf_sub{font-size:12.5px;color:var(--dsw-alias-label-secondary,#9aa);margin:6px 0 0;line-height:1.65}',
49
+ '.kf_headRight{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
50
+ '.kf_badge{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;padding:3px 10px;border-radius:999px;border:1px solid currentColor;line-height:1.55;white-space:nowrap;font-weight:500}',
47
51
  '.kf_badge.muted{color:var(--dsw-alias-label-secondary,#9aa)}',
48
52
  '.kf_badge.ok{color:var(--dsw-alias-state-success-primary,#4ade80)}',
49
53
  '.kf_badge.warn{color:var(--dsw-alias-state-warn-primary,#fbbf24)}',
50
54
  '.kf_badge.brand{color:var(--dsw-alias-brand-primary,#7aa2ff);border-color:var(--dsw-alias-brand-primary,#7aa2ff)}',
51
55
  '.kf_badge.err{color:var(--dsw-alias-state-error-primary,#f87171)}',
52
- '.kf_switch{position:relative;width:34px;height:20px;border-radius:999px;background:var(--dsw-alias-bg-base,rgba(0,0,0,.3));border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));cursor:pointer;transition:background .15s;flex:none}',
53
- '.kf_switch::after{content:"";position:absolute;top:1px;left:1px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-secondary,#9aa);transition:transform .15s,background .15s}',
54
- '.kf_switch.on{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 50%,transparent);border-color:var(--dsw-alias-brand-primary,#5b8cff)}',
55
- '.kf_switch.on::after{transform:translateX(14px);background:var(--dsw-alias-brand-primary,#7aa2ff)}',
56
- '.kf_btn{cursor:pointer;font:inherit;font-size:12px;padding:5px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;color:inherit;transition:background .12s;flex:none}',
56
+ '.kf_btn{cursor:pointer;font:inherit;font-size:12px;padding:6px 13px;border-radius:9px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;color:inherit;transition:background .12s,border-color .12s,transform .05s;flex:none;line-height:1.5}',
57
57
  '.kf_btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.14))}',
58
+ '.kf_btn:active:not(:disabled){transform:scale(.97)}',
58
59
  '.kf_btn:disabled{opacity:.45;cursor:default}',
59
- '.kf_btn.primary{border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 55%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 15%,transparent);color:var(--dsw-alias-brand-primary,#7aa2ff)}',
60
+ '.kf_btn.primary{border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 55%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 14%,transparent);color:var(--dsw-alias-brand-primary,#7aa2ff)}',
60
61
  '.kf_btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 26%,transparent)}',
61
- '.kf_btn.danger{color:var(--dsw-alias-state-error-primary,#f87171);border-color:currentColor}',
62
+ '.kf_btn.danger{color:var(--dsw-alias-state-error-primary,#f87171);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#f87171) 55%,transparent)}',
62
63
  '.kf_btn.ghost{border-style:dashed}',
63
- '.kf_form{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px}',
64
- '.kf_form label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa)}',
65
- '.kf_form input,.kf_form select,.kf_chips select{font:inherit;font-size:13px;padding:6px 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-base,rgba(0,0,0,.18));color:inherit}',
66
- '.kf_form input:focus,.kf_form select:focus{outline:2px solid var(--dsw-alias-brand-primary,rgba(91,140,255,.5));outline-offset:0;border-color:transparent}',
67
- '.kf_keys{display:flex;flex-direction:column;gap:6px}',
68
- '.kf_keyRow{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.18));border-radius:8px;background:var(--dsw-alias-bg-base,rgba(0,0,0,.16));flex-wrap:wrap}',
69
- '.kf_keyLabel{font-weight:500;font-size:12.5px;color:var(--dsw-alias-label-primary,#eee);min-width:120px;flex:0 0 auto;display:flex;gap:8px;align-items:center}',
70
- '.kf_keyRef{font-size:11px;color:var(--dsw-alias-label-secondary,#9aa);font-family:ui-monospace,Menlo,Consolas,monospace;opacity:.7}',
71
- '.kf_inputSecret{flex:1;min-width:200px;font:inherit;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;padding:7px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-base,rgba(0,0,0,.25));color:inherit}',
72
- '.kf_inputSecret:focus{outline:2px solid var(--dsw-alias-brand-primary,rgba(91,140,255,.5));border-color:transparent}',
73
- '.kf_hiddenBadge{font-size:11.5px;color:var(--dsw-alias-label-secondary,#9aa);font-family:ui-monospace,Menlo,Consolas,monospace;padding:7px 10px;border:1px dashed var(--dsw-alias-border-l2,rgba(128,128,128,.3));border-radius:8px;flex:1;min-width:200px;text-align:center}',
74
- '.kf_chips{display:flex;gap:6px;flex-wrap:wrap}',
75
- '.kf_chip{cursor:pointer;font:inherit;font-size:11.5px;padding:3px 11px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:transparent;color:var(--dsw-alias-label-secondary,#9aa);transition:all .12s}',
64
+ '.kf_card{background:linear-gradient(180deg,var(--dsw-alias-bg-layer-1,rgba(255,255,255,.045)),rgba(255,255,255,.015));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2));border-radius:14px;padding:16px 18px;display:flex;flex-direction:column;gap:14px;box-shadow:0 1px 0 rgba(255,255,255,.03) inset}',
65
+ '.kf_cardHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
66
+ '.kf_avatar{width:32px;height:32px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:#0b1220;background:linear-gradient(135deg,#7aa2ff,#22d3ee);flex:none;text-transform:uppercase}',
67
+ '.kf_name{font-weight:700;font-size:14.5px;color:var(--dsw-alias-label-primary,#f1f1f1);display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
68
+ '.kf_switch{position:relative;width:36px;height:21px;border-radius:999px;background:var(--dsw-alias-bg-base,rgba(0,0,0,.3));border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));cursor:pointer;transition:background .15s;flex:none}',
69
+ '.kf_switch::after{content:"";position:absolute;top:1px;left:1px;width:17px;height:17px;border-radius:50%;background:var(--dsw-alias-label-secondary,#9aa);transition:transform .15s,background .15s}',
70
+ '.kf_switch.on{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#4ade80) 45%,transparent);border-color:var(--dsw-alias-state-success-primary,#4ade80)}',
71
+ '.kf_switch.on::after{transform:translateX(15px);background:#4ade80}',
72
+ '.kf_meta{display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:12.5px;color:var(--dsw-alias-label-secondary,#9aa);background:var(--dsw-alias-bg-base,rgba(0,0,0,.14));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.14));border-radius:10px;padding:9px 12px}',
73
+ '.kf_meta b{color:var(--dsw-alias-label-primary,#e8e8e8);font-weight:600}',
74
+ '.kf_activePill{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--dsw-alias-state-success-primary,#4ade80)}',
75
+ '.kf_activePill::before{content:"";width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 8px rgba(74,222,128,.8);animation:kf_pulse 1.8s ease-in-out infinite}',
76
+ '@keyframes kf_pulse{0%,100%{opacity:1}50%{opacity:.35}}',
77
+ '.kf_sel{font:inherit;font-size:12px;background:var(--dsw-alias-bg-base,rgba(0,0,0,.18));color:inherit;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));border-radius:8px;padding:4px 8px}',
78
+ '.kf_section{font-size:11px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--dsw-alias-label-tertiary,#778);display:flex;align-items:center;gap:8px}',
79
+ '.kf_section::after{content:"";flex:1;height:1px;background:var(--dsw-alias-border-l1,rgba(128,128,128,.15))}',
80
+ '.kf_chips{display:flex;gap:7px;flex-wrap:wrap;align-items:center}',
81
+ '.kf_chip{cursor:pointer;font:inherit;font-size:11.5px;padding:4px 12px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:transparent;color:var(--dsw-alias-label-secondary,#9aa);transition:all .12s;display:inline-flex;align-items:center;gap:5px}',
76
82
  '.kf_chip:hover{color:var(--dsw-alias-label-primary,#ddd);border-color:var(--dsw-alias-brand-primary,#5b8cff)}',
77
- '.kf_chip.active{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 26%,transparent);color:var(--dsw-alias-brand-primary,#7aa2ff);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 60%,transparent)}',
78
- '.kf_err{color:var(--dsw-alias-state-error-primary,#f87171);font-size:12.5px;line-height:1.5}',
79
- '.kf_status{font-size:11px;padding:1px 8px;border-radius:999px;line-height:1.7;white-space:nowrap;flex:none}',
80
- '.kf_status.live{color:var(--dsw-alias-state-success-primary,#4ade80)}',
81
- '.kf_status.cooling{color:var(--dsw-alias-state-warn-primary,#fbbf24)}',
82
- '.kf_status.cooldown{color:var(--dsw-alias-state-error-primary,#f87171)}',
83
- '.kf_nextRow{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa)}',
84
- '.kf_nextSel{font:inherit;font-size:12px;background:transparent;color:inherit;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));border-radius:6px;padding:2px 6px}',
85
- '.kf_ok{color:var(--dsw-alias-state-success-primary,#4ade80);font-size:12.5px;line-height:1.5}',
86
- '.kf_empty{font-size:13px;color:var(--dsw-alias-label-secondary,#888);padding:16px 4px;text-align:center}',
87
- '.kf_addrow{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.035));border:1px dashed var(--dsw-alias-border-l2,rgba(128,128,128,.4));border-radius:12px;padding:12px 14px}',
83
+ '.kf_chip.on{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#4ade80) 18%,transparent);color:var(--dsw-alias-state-success-primary,#4ade80);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#4ade80) 55%,transparent)}',
84
+ '.kf_chip.on::before{content:"✓";font-size:10px}',
85
+ '.kf_customRow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
86
+ '.kf_input{font:inherit;font-size:12.5px;padding:6px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-base,rgba(0,0,0,.2));color:inherit;outline:none}',
87
+ '.kf_input:focus{border-color:var(--dsw-alias-brand-primary,#5b8cff);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-brand-primary,#5b8cff) 25%,transparent)}',
88
+ '.kf_keys{display:flex;flex-direction:column;gap:7px}',
89
+ '.kf_keyRow{display:flex;align-items:center;gap:10px;padding:9px 12px;border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.16));border-radius:10px;background:var(--dsw-alias-bg-base,rgba(0,0,0,.14));flex-wrap:wrap;transition:border-color .12s}',
90
+ '.kf_keyRow.active{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#4ade80) 55%,transparent)}',
91
+ '.kf_statusDot{width:8px;height:8px;border-radius:50%;flex:none}',
92
+ '.kf_statusDot.live{background:#4ade80}',
93
+ '.kf_statusDot.cooling{background:#fbbf24;box-shadow:0 0 6px rgba(251,191,36,.6)}',
94
+ '.kf_statusDot.recovered{background:#38bdf8}',
95
+ '.kf_keyName{font-weight:600;font-size:12.5px;color:var(--dsw-alias-label-primary,#e8e8e8);min-width:56px;display:flex;align-items:center;gap:6px}',
96
+ '.kf_keyRef{font-size:10.5px;color:var(--dsw-alias-label-secondary,#9aa);font-family:ui-monospace,Menlo,Consolas,monospace;opacity:.75;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
97
+ '.kf_keyVal{flex:1;min-width:170px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;padding:6px 10px;border-radius:8px;border:1px dashed var(--dsw-alias-border-l2,rgba(128,128,128,.3));background:var(--dsw-alias-bg-base,rgba(0,0,0,.22));color:inherit}',
98
+ '.kf_keyActions{display:flex;gap:6px;align-items:center;flex-wrap:wrap}',
99
+ '.kf_err{color:var(--dsw-alias-state-error-primary,#f87171);font-size:12px;line-height:1.5}',
100
+ '.kf_ok{color:var(--dsw-alias-state-success-primary,#4ade80);font-size:12px;line-height:1.5}',
101
+ '.kf_empty{font-size:13px;color:var(--dsw-alias-label-secondary,#888);padding:18px 4px;text-align:center}',
102
+ '.kf_addrow{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.035));border:1px dashed var(--dsw-alias-border-l2,rgba(128,128,128,.4));border-radius:12px;padding:13px 15px}',
88
103
  '.kf_addrow label{display:flex;flex-direction:column;gap:5px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa);min-width:240px;flex:1}',
89
104
  '.kf_spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));border-top-color:var(--dsw-alias-brand-primary,#5b8cff);border-radius:50%;animation:kf_spin .7s linear infinite;vertical-align:-2px;margin-right:6px}',
90
105
  '@keyframes kf_spin{to{transform:rotate(360deg)}}',
106
+ '.kf_iconBtn{cursor:pointer;font:inherit;font-size:12px;width:28px;height:28px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));background:transparent;color:var(--dsw-alias-label-secondary,#9aa);display:inline-flex;align-items:center;justify-content:center;transition:all .12s;flex:none;line-height:1}',
107
+ '.kf_iconBtn:hover:not(:disabled){color:var(--dsw-alias-label-primary,#eee);border-color:var(--dsw-alias-brand-primary,#5b8cff);background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.12))}',
108
+ '.kf_iconBtn:disabled{opacity:.4;cursor:default}',
109
+ '.kf_envNote{font-size:11.5px;color:var(--dsw-alias-label-secondary,#9aa);border:1px dashed var(--dsw-alias-border-l2,rgba(128,128,128,.28));border-radius:8px;padding:6px 10px;flex:1;min-width:150px}',
91
110
  ].join('')
92
111
  if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="@omdp/dsh-key-fallback"]')) {
93
112
  var tag = document.createElement('style')
@@ -97,8 +116,6 @@ window.__ModuleLoader__.load({
97
116
  }
98
117
 
99
118
  function api(path, options) {
100
- // 先进入 Promise 微任务:JSON.stringify、AbortController、fetch 的同步异常
101
- // 也必须落到统一错误结果,不能让按钮卡在"保存中"。
102
119
  return Promise.resolve().then(function () {
103
120
  var url = '/dsh-key-fallback' + path
104
121
  var opts = Object.assign({ method: 'GET' }, options || {})
@@ -106,15 +123,12 @@ window.__ModuleLoader__.load({
106
123
  opts.body = JSON.stringify(opts.body)
107
124
  opts.headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {})
108
125
  }
109
- // 8s 超时:避免请求挂起导致 UI 一直"保存中"
110
126
  var ctrl = (typeof AbortController === 'function') ? new AbortController() : null
111
127
  var timer = null
112
128
  if (ctrl) {
113
129
  opts.signal = ctrl.signal
114
130
  timer = setTimeout(function () { ctrl.abort() }, 8000)
115
131
  }
116
- // Promise.race:即使宿主 fetch 忽略 AbortSignal,也保证 8s 内 settle,
117
- // 避免保存/删除永久卡在“保存中”。
118
132
  var timeoutP = new Promise(function (_, reject) {
119
133
  var t = setTimeout(function () { reject(new Error('请求超时')) }, 8000)
120
134
  if (typeof t === 'object' && t && typeof t.unref === 'function') t.unref()
@@ -127,28 +141,34 @@ window.__ModuleLoader__.load({
127
141
  })
128
142
  }
129
143
 
130
- // ── 单个 key 行:编辑态显示 password input,未编辑显示 [hidden] 占位 ──
144
+ // ── 单个 key 行:状态点 / 短名 / ref / 明文揭示 / 更新 / 删除 ──
131
145
  function KeyRow(props) {
132
146
  var k = props.entry
147
+ var isEnv = props.source === 'env'
133
148
  var editing = React.useState(false)
134
149
  var isEditing = editing[0]
135
150
  var setEditing = editing[1]
136
151
  var val = React.useState('')
137
152
  var value = val[0]
138
153
  var setValue = val[1]
154
+ var reveal = React.useState(false)
155
+ var isRevealed = reveal[0]
156
+ var setRevealed = reveal[1]
157
+ var plain = React.useState('')
158
+ var plainText = plain[0]
159
+ var setPlain = plain[1]
139
160
  var busy = React.useState(false)
140
161
  var isBusy = busy[0]
141
162
  var setBusy = busy[1]
142
163
  var err = React.useState('')
143
164
  var setErr = err[1]
165
+ var revealing = React.useState(false)
166
+ var isRevealing = revealing[0]
167
+ var setRevealing = revealing[1]
168
+
144
169
  function save() {
145
170
  if (!value) { setErr('请输入 key'); return }
146
171
  setBusy(true); setErr('')
147
- try {
148
- __kfReportDiag('save', 'PATCH start provider=' + props.provider + ' ref=')
149
- } catch (e) {}
150
- // 额外包一层 Promise:即使 api 被宿主替换或请求构造同步抛错,
151
- // 也一定会进入 catch,按钮不会永久停在"保存中"。
152
172
  Promise.resolve().then(function () {
153
173
  return api('/keys', { method: 'PATCH', body: { provider: props.provider, ref: k.ref, value: value, label: k.label || '' } })
154
174
  })
@@ -156,55 +176,77 @@ window.__ModuleLoader__.load({
156
176
  if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
157
177
  setValue(''); setEditing(false); props.onChanged()
158
178
  })
159
- .catch(function (e) {
160
- setErr((e && e.message) || String(e))
161
- })
162
- .then(function () {
163
- // 成功、HTTP 错误、请求异常全部收口 busy 状态。
164
- setBusy(false)
179
+ .catch(function (e) { setErr((e && e.message) || String(e)) })
180
+ .then(function () { setBusy(false) })
181
+ }
182
+ function toggleReveal() {
183
+ if (isRevealed) { setRevealed(false); setPlain(''); return }
184
+ setRevealing(true); setErr('')
185
+ api('/keys/plain?provider=' + encodeURIComponent(props.provider) + '&ref=' + encodeURIComponent(k.ref))
186
+ .then(function (r) {
187
+ setRevealing(false)
188
+ if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
189
+ setPlain(r.body.value || ''); setRevealed(true)
165
190
  })
191
+ .catch(function (e) { setRevealing(false); setErr(String(e && e.message || e)) })
166
192
  }
167
- var isEnv = props.source === 'env'
168
193
  var st = k.status || (isEnv ? 'live' : '')
169
- var nowMs = Date.now()
170
194
  var cooling = k.cooldownRemainingMs > 0
171
- var stLabel = isEnv ? '' : (st === 'cooling' || cooling) ? ('冷却中 ' + Math.ceil((k.cooldownRemainingMs || 0) / 1000) + 's') : (st === 'recovered' ? '已恢复' : (st === 'cooldown' ? '冷却' : '正常'))
172
- return ce('div', { className: 'kf_keyRow' },
173
- ce('div', { className: 'kf_keyLabel' },
174
- k.label ? k.label : (k.ref || '').slice(-8),
175
- isEnv ? ce('span', { className: 'kf_badge brand' }, '[环境来源]') : null,
176
- isEnv || !stLabel ? null : ce('span', { className: 'kf_status ' + ((st === 'cooling' || cooling) ? 'cooling' : 'live') }, stLabel),
177
- ce('span', { className: 'kf_keyRef' }, k.ref)
195
+ var dotCls = isEnv ? 'live' : (cooling || st === 'cooling') ? 'cooling' : (st === 'recovered' ? 'recovered' : 'live')
196
+ var stLabel = isEnv ? '环境' : (cooling ? '冷却 ' + Math.ceil((k.cooldownRemainingMs || 0) / 1000) + 's' : (st === 'recovered' ? '已恢复' : '正常'))
197
+ return ce('div', { className: 'kf_keyRow' + (k.active ? ' active' : '') },
198
+ ce('span', { className: 'kf_statusDot ' + dotCls, title: stLabel }),
199
+ ce('span', { className: 'kf_keyName' },
200
+ k.name || (k.ref || '').slice(-8),
201
+ k.active ? ce('span', { className: 'kf_badge ok', style: { padding: '1px 7px', fontSize: 10.5 } }, '当前') : null,
178
202
  ),
203
+ ce('span', { className: 'kf_keyRef', title: k.ref }, k.ref),
179
204
  isEnv
180
- ? ce('span', { className: 'kf_hiddenBadge', style: { flex: 1 } }, '来自环境配置(不可编辑/删除;可设为当前、参与轮换)')
205
+ ? ce('span', { className: 'kf_envNote' },
206
+ props.pool.envSource === 'env' && props.pool.envWritable === false
207
+ ? '由启动环境提供(只读)'
208
+ : '环境来源 · 可编辑/参与轮换',
209
+ )
181
210
  : isEditing
182
- ? ce('div', null,
183
- ce('input', { className: 'kf_inputSecret', type: 'text', autoComplete: 'off', spellCheck: false, placeholder: '粘贴新 key(只写,不回读)', value: value, onChange: function (e) { setValue(e.target.value) } }),
211
+ ? ce('input', { className: 'kf_input kf_keyVal', type: 'text', autoComplete: 'off', spellCheck: false, placeholder: '粘贴新 key(只写,不回读)', value: value, onChange: function (e) { setValue(e.target.value) } })
212
+ : ce('span', { className: 'kf_keyVal' },
213
+ isRevealed ? plainText || '(空)' : '[hidden] 已保存',
214
+ ),
215
+ ce('div', { className: 'kf_keyActions' },
216
+ isEnv || !stLabel ? null : ce('span', { className: 'kf_badge ' + ((cooling || st === 'cooling') ? 'warn' : (st === 'recovered' ? 'brand' : 'ok')), style: { padding: '1px 8px', fontSize: 10.5 } }, stLabel),
217
+ ce('button', { className: 'kf_iconBtn', title: isRevealed ? '隐藏明文' : '显示明文', disabled: isRevealing, onClick: toggleReveal }, isRevealing ? '…' : (isRevealed ? '🙈' : '👁')),
218
+ isEnv ? null : (isEditing
219
+ ? ce('span', null,
184
220
  ce('button', { className: 'kf_btn primary', disabled: isBusy, onClick: save }, isBusy ? '保存…' : '保存'),
185
221
  ce('button', { className: 'kf_btn', onClick: function () { setEditing(false); setValue(''); setErr('') } }, '取消'),
186
222
  )
187
- : ce('div', null,
188
- ce('span', { className: 'kf_hiddenBadge' }, '[hidden] 已保存(点击「更新」可改)'),
189
- ce('button', { className: 'kf_btn', onClick: function () { setEditing(true) } }, '更新'),
190
- ),
191
- ce('label', { className: 'kf_nextRow' },
192
- '失败后切换到',
193
- ce('select', { className: 'kf_nextSel', value: k.nextRef || '', onChange: function (e) { api('/keys', { method: 'PATCH', body: { provider: props.provider, ref: k.ref, nextRef: e.target.value } }).then(function () { props.onChanged() }).catch(function () {}) } },
194
- ce('option', { value: '' }, '自动(顺序循环)'),
195
- (props.allRefs || []).filter(function (r) { return r !== k.ref }).map(function (r) { return ce('option', { key: r, value: r }, r.slice(-12)) }),
223
+ : ce('button', { className: 'kf_btn', onClick: function () { setEditing(true) } }, '更新')),
224
+ ce('label', { className: 'kf_meta', style: { padding: '2px 8px', gap: 5, fontSize: 11.5, background: 'transparent', border: 'none' } },
225
+ '失败后→',
226
+ ce('select', { className: 'kf_sel', value: k.nextRef || '', onChange: function (e) { api('/keys', { method: 'PATCH', body: { provider: props.provider, ref: k.ref, nextRef: e.target.value } }).then(function () { props.onChanged() }).catch(function () {}) } },
227
+ ce('option', { value: '' }, '自动'),
228
+ (props.allRefs || []).filter(function (r) { return r !== k.ref }).map(function (r) { return ce('option', { key: r, value: r }, displayShort(r, props.allNames)) }),
196
229
  ),
197
230
  ),
198
- props.currentRef === k.ref ? null : ce('button', { className: 'kf_btn primary', onClick: function () {
199
- api('/keys', { method: 'PATCH', body: { provider: props.provider, ref: k.ref, useKeyRef: k.ref } })
200
- .then(function () { props.onChanged() }).catch(function (e) { setErr((e && e.message) || String(e)) })
201
- } }, '设为当前'),
202
- isEnv ? null : ce('button', { className: 'kf_btn danger', onClick: function () { props.onDelete() } }, '删除'),
203
- isEnv || !k.lastErrorMsg ? null : ce('span', { className: 'kf_err', title: k.lastErrorMsg }, '上次错误: ' + k.lastErrorMsg.slice(0, 60)),
231
+ props.currentRef === k.ref ? null : ce('button', { className: 'kf_btn primary', onClick: function () {
232
+ api('/pools', { method: 'POST', body: { provider: props.provider, enabled: props.pool.enabled, env: props.pool.env, cooldownMs: props.pool.cooldownMs, rotateOn: props.pool.rotateOn, useKeyRef: k.ref } })
233
+ .then(function (r) { if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return } props.onChanged() })
234
+ .catch(function (e) { setErr(String(e && e.message || e)) })
235
+ } }, '设为当前'),
236
+ isEnv ? null : ce('button', { className: 'kf_btn danger', onClick: function () { props.onDelete() } }, '删除'),
237
+ ),
238
+ !k.lastErrorMsg ? null : ce('span', { className: 'kf_err', title: k.lastErrorMsg }, '上次错误: ' + k.lastErrorMsg.slice(0, 50)),
204
239
  err ? ce('span', { className: 'kf_err' }, err) : null,
205
240
  )
206
241
  }
207
242
 
243
+ function displayShort(ref, names) {
244
+ if (names && names[ref]) return names[ref]
245
+ var m = /_key(\d+)$/.exec(ref || '')
246
+ if (m) return 'key' + m[1]
247
+ return (ref || '').slice(-8)
248
+ }
249
+
208
250
  // ── 单个 provider 池卡片 ──
209
251
  function PoolCard(props) {
210
252
  var pool = props.pool
@@ -214,16 +256,31 @@ window.__ModuleLoader__.load({
214
256
  var errSet = React.useState('')
215
257
  var err = errSet[0]
216
258
  var setErr = errSet[1]
217
- var okSet = React.useState('')
218
- var ok = okSet[0]
219
- var setOk = okSet[1]
220
259
  var newKey = React.useState('')
221
260
  var newKeyVal = newKey[0]
222
261
  var setNewKey = newKey[1]
223
262
  var addBusy = React.useState(false)
224
263
  var isAddBusy = addBusy[0]
225
264
  var setAddBusy = addBusy[1]
265
+ var envEdit = React.useState('')
266
+ var envEditVal = envEdit[0]
267
+ var setEnvEdit = envEdit[1]
268
+ var envBusy = React.useState(false)
269
+ var isEnvBusy = envBusy[0]
270
+ var setEnvBusy = envBusy[1]
271
+ var customCode = React.useState('')
272
+ var customCodeVal = customCode[0]
273
+ var setCustomCode = customCode[1]
226
274
 
275
+ function savePool(patch) {
276
+ var body = Object.assign({ provider: pool.provider, enabled: pool.enabled, env: pool.env, cooldownMs: pool.cooldownMs, rotateOn: pool.rotateOn }, patch || {})
277
+ api('/pools', { method: 'POST', body: body })
278
+ .then(function (r) {
279
+ if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
280
+ props.onChanged()
281
+ })
282
+ .catch(function (e) { setErr(String(e && e.message || e)) })
283
+ }
227
284
  function toggle() {
228
285
  if (busy) return
229
286
  setBusy(true); setErr('')
@@ -248,60 +305,116 @@ window.__ModuleLoader__.load({
248
305
  .then(function (r) { if (r.ok) props.onChanged(); else setErr((r.body && r.body.error) || ('HTTP ' + r.status)) })
249
306
  .catch(function (e) { setErr(String(e && e.message || e)) })
250
307
  }
251
- function setUseKey(ref) {
252
- api('/pools', { method: 'POST', body: { provider: pool.provider, enabled: pool.enabled, env: pool.env, cooldownMs: pool.cooldownMs, rotateOn: pool.rotateOn, useKeyRef: ref } })
253
- .then(function (r) { if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return } props.onChanged() })
254
- .catch(function (e) { setErr(String(e && e.message || e)) })
255
- }
256
308
  function deletePool() {
257
309
  if (!confirm('确认删除「' + pool.provider + '」池(会清掉所有 key)?')) return
258
310
  api('/pools?provider=' + encodeURIComponent(pool.provider), { method: 'DELETE' })
259
311
  .then(function (r) { if (r.ok) props.onChanged(); else setErr((r.body && r.body.error) || ('HTTP ' + r.status)) })
260
312
  .catch(function (e) { setErr(String(e && e.message || e)) })
261
313
  }
314
+ function resetCooldown() {
315
+ api('/reset', { method: 'POST', body: { provider: pool.provider } })
316
+ .then(function (r) { if (r.ok) props.onChanged(); else setErr((r.body && r.body.error) || ('HTTP ' + r.status)) })
317
+ .catch(function (e) { setErr(String(e && e.message || e)) })
318
+ }
319
+ function toggleCode(code) {
320
+ var next = (pool.rotateOn || []).slice()
321
+ var i = next.indexOf(code)
322
+ if (i >= 0) next.splice(i, 1)
323
+ else next.push(code)
324
+ if (next.length === 0) next = ROTATE_CODES.slice()
325
+ savePool({ rotateOn: next })
326
+ }
327
+ function addCustomCode() {
328
+ var c = String(customCodeVal || '').trim().toUpperCase().replace(/\s+/g, '_')
329
+ if (!c) return
330
+ var next = (pool.rotateOn || []).slice()
331
+ if (next.indexOf(c) < 0) next.push(c)
332
+ setCustomCode('')
333
+ savePool({ rotateOn: next })
334
+ }
335
+ function saveEnvKey() {
336
+ if (!envEditVal) { setErr('请输入环境密钥'); return }
337
+ setEnvBusy(true); setErr('')
338
+ api('/keys', { method: 'PATCH', body: { provider: pool.provider, ref: pool.env, value: envEditVal } })
339
+ .then(function (r) {
340
+ setEnvBusy(false)
341
+ if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
342
+ setEnvEdit(''); props.onChanged()
343
+ })
344
+ .catch(function (e) { setEnvBusy(false); setErr(String(e && e.message || e)) })
345
+ }
346
+ var keys = Array.isArray(pool.keys) ? pool.keys : []
347
+ var userKeys = keys.filter(function (k) { return k.ref !== pool.env })
348
+ var allRefs = keys.map(function (x) { return x.ref })
349
+ var allNames = {}
350
+ keys.forEach(function (k) { allNames[k.ref] = k.name })
351
+
262
352
  return ce('div', { className: 'kf_card' },
263
353
  ce('div', { className: 'kf_cardHead' },
354
+ ce('span', { className: 'kf_avatar' }, (pool.displayName || pool.provider || '?').slice(0, 1)),
264
355
  ce('div', { className: 'kf_name' },
265
356
  pool.displayName || pool.provider,
266
357
  ce('span', { className: 'kf_badge ' + (pool.enabled ? 'ok' : 'muted') }, pool.enabled ? '启用' : '停用'),
267
358
  ce('span', { className: 'kf_badge muted' }, 'env: ' + pool.env),
268
359
  ),
269
360
  ce('div', { style: { flex: 1 } }),
361
+ ce('button', { className: 'kf_btn', title: '重置冷却', onClick: resetCooldown }, '↺ 重置冷却'),
270
362
  ce('div', { className: 'kf_switch' + (pool.enabled ? ' on' : ''), onClick: toggle, title: pool.enabled ? '停用' : '启用' }),
271
363
  ce('button', { className: 'kf_btn danger', disabled: busy, onClick: deletePool }, '删除池'),
272
364
  ),
273
- ce('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary,#9aa)', display: 'flex', gap: 14, flexWrap: 'wrap', alignItems: 'center' } },
274
- ce('span', null, 'key 数: ' + (Array.isArray(pool.keys) ? pool.keys.length : 0)),
275
- pool.currentRef ? ce('span', { style: { color: 'var(--dsw-alias-brand-primary,#7aa2ff)' } }, '当前用: ' + pool.currentRef.slice(-12)) : null,
276
- ce('span', null, 'cooldown: ' + (pool.cooldownMs || 0) + ' ms'),
365
+ ce('div', { className: 'kf_meta' },
366
+ ce('span', null, 'key 数: ', ce('b', null, keys.length)),
367
+ ce('span', null, 'cooldown: ', ce('b', null, pool.cooldownMs + ' ms')),
368
+ ce('span', null, '当前使用: ', pool.activeRef && pool.activeName
369
+ ? ce('span', { className: 'kf_activePill' }, pool.activeName)
370
+ : ce('span', { className: 'kf_badge muted', style: { padding: '1px 8px', fontSize: 10.5 } }, '尚未使用')),
277
371
  ce('label', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
278
- ce('span', null, '当前使用:'),
279
- ce('select', { value: pool.useKeyRef || '_auto', onChange: function (e) { var v = e.target.value; if (v === '_auto') setUseKey(''); else setUseKey(v) } },
372
+ ce('span', null, '锁定:'),
373
+ ce('select', { className: 'kf_sel', value: pool.useKeyRef || '_auto', onChange: function (e) { var v = e.target.value; savePool({ useKeyRef: (v === '_auto' ? '' : v) }) } },
280
374
  ce('option', { value: '_auto' }, '自动轮换'),
281
- (Array.isArray(pool.keys) ? pool.keys : []).map(function (k) {
282
- return ce('option', { value: k.ref, key: k.ref }, (k.ref === pool.env ? '[环境] ' : '') + (k.label || k.ref.slice(-8)))
283
- }),
375
+ (keys).map(function (k) { return ce('option', { value: k.ref, key: k.ref }, (k.ref === pool.env ? '[环境] ' : '') + (k.name || k.ref.slice(-8))) }),
284
376
  ),
285
377
  ),
286
378
  ),
379
+ ce('div', { className: 'kf_section' }, '轮换触发码'),
287
380
  ce('div', { className: 'kf_chips' },
288
- ce('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary,#9aa)', alignSelf: 'center' } }, '轮换触发码:'),
289
- ['QUOTA_EXCEEDED', 'AUTH', 'RATE_LIMIT'].map(function (code) {
290
- return ce('span', { key: code, className: 'kf_chip' + ((pool.rotateOn || []).includes(code) ? ' active' : '') }, code)
381
+ ROTATE_CODES.map(function (code) {
382
+ var on = (pool.rotateOn || []).indexOf(code) >= 0
383
+ return ce('button', { key: code, className: 'kf_chip' + (on ? ' on' : ''), title: codeLabel(code), onClick: function () { toggleCode(code) } }, codeLabel(code))
384
+ }),
385
+ ),
386
+ ce('div', { className: 'kf_customRow' },
387
+ ce('input', { className: 'kf_input', style: { width: 130 }, placeholder: '自定义错误码', value: customCodeVal, onChange: function (e) { setCustomCode(e.target.value) }, onKeyDown: function (e) { if (e.key === 'Enter') addCustomCode() } }),
388
+ ce('button', { className: 'kf_btn ghost', onClick: addCustomCode }, '+ 添加'),
389
+ (pool.rotateOn || []).filter(function (c) { return ROTATE_CODES.indexOf(c) < 0 }).map(function (c) {
390
+ return ce('span', { key: c, className: 'kf_chip on' }, c)
291
391
  }),
392
+ ce('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary,#9aa)' } }, '勾选=失败后轮换;取消=不轮换'),
292
393
  ),
293
- ((Array.isArray(pool.keys) ? pool.keys : [])).length === 0
394
+ ce('div', { className: 'kf_section' }, '密钥链'),
395
+ keys.length === 0
294
396
  ? ce('div', { className: 'kf_empty' }, '还没有 key。在下方粘贴第一个。')
295
- : ce('div', { className: 'kf_keys' }, (Array.isArray(pool.keys) ? pool.keys : []).map(function (k) {
296
- return ce(KeyRow, { key: k.ref, entry: k, provider: pool.provider, allRefs: (Array.isArray(pool.keys) ? pool.keys : []).map(function (x) { return x.ref }), onDelete: (k.ref === pool.env) ? null : function () { deleteKey(k.ref) }, onChanged: props.onChanged, source: (k.ref === pool.env) ? 'env' : 'user', currentRef: pool.currentRef || '' })
397
+ : ce('div', { className: 'kf_keys' },
398
+ keys.map(function (k) {
399
+ return ce(KeyRow, { key: k.ref, entry: k, provider: pool.provider, pool: pool, allRefs: allRefs, allNames: allNames, onDelete: (k.ref === pool.env) ? null : function () { deleteKey(k.ref) }, onChanged: props.onChanged, source: (k.ref === pool.env) ? 'env' : 'user', currentRef: pool.activeRef || '' })
297
400
  }),
298
401
  ),
299
- ce('div', { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
300
- ce('input', { className: 'kf_inputSecret', style: { flex: 1, minWidth: 220 }, type: 'text', autoComplete: 'off', spellCheck: false, placeholder: '粘贴新 API key 后点添加(只写,保存后不回读)', value: newKeyVal, onChange: function (e) { setNewKey(e.target.value) } }),
301
- ce('button', { className: 'kf_btn primary', disabled: isAddBusy, onClick: addKey }, isAddBusy ? '添加中…' : '添加 key'),
402
+ true
403
+ ? ce('div', { className: 'kf_customRow', style: { background: 'var(--dsw-alias-bg-base,rgba(0,0,0,.1))', padding: '8px 10px', borderRadius: 10 } },
404
+ ce('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary,#9aa)', flex: '0 0 auto' } }, '更新环境密钥 (' + pool.env + '):'),
405
+ pool.envWritable === false
406
+ ? ce('span', { className: 'kf_envNote', style: { border: 'none', padding: 0 } }, '由启动环境提供,只读(请在启动 DSH 的终端里修改)')
407
+ : ce('span', { style: { display: 'flex', flex: 1, gap: 8, flexWrap: 'wrap', minWidth: 220 } },
408
+ ce('input', { className: 'kf_input kf_keyVal', type: 'text', autoComplete: 'off', spellCheck: false, placeholder: '粘贴新环境密钥(只写,不回读)', value: envEditVal, onChange: function (e) { setEnvEdit(e.target.value) }, onKeyDown: function (e) { if (e.key === 'Enter') saveEnvKey() } }),
409
+ ce('button', { className: 'kf_btn primary', disabled: isEnvBusy, onClick: saveEnvKey }, isEnvBusy ? '保存…' : '保存'),
410
+ ),
411
+ )
412
+ : null,
413
+ ce('div', { className: 'kf_customRow' },
414
+ ce('input', { className: 'kf_input kf_keyVal', type: 'text', autoComplete: 'off', spellCheck: false, placeholder: '粘贴新 API key 后点添加(只写,保存后不回读)', value: newKeyVal, onChange: function (e) { setNewKey(e.target.value) }, onKeyDown: function (e) { if (e.key === 'Enter') addKey() } }),
415
+ ce('button', { className: 'kf_btn primary', disabled: isAddBusy, onClick: addKey }, isAddBusy ? '添加中…' : '+ 添加 key'),
302
416
  ),
303
417
  err ? ce('div', { className: 'kf_err' }, err) : null,
304
- ok ? ce('div', { className: 'kf_ok' }, ok) : null,
305
418
  )
306
419
  }
307
420
 
@@ -322,16 +435,21 @@ window.__ModuleLoader__.load({
322
435
  var addingSet = React.useState(false)
323
436
  var adding = addingSet[0]
324
437
  var setAdding = addingSet[1]
438
+ var refreshing = React.useState(false)
439
+ var isRefreshing = refreshing[0]
440
+ var setRefreshing = refreshing[1]
325
441
 
326
442
  function refresh() {
443
+ setRefreshing(true)
327
444
  Promise.all([api('/pools'), api('/providers')])
328
445
  .then(function (rs) {
446
+ setRefreshing(false)
329
447
  if (!rs[0].ok) { setErr((rs[0].body && rs[0].body.error) || ('HTTP ' + rs[0].status)); return }
330
448
  setPools(rs[0].body.pools || [])
331
449
  var d = rs[1] && rs[1].body || {}; setDir((d && typeof d === 'object') ? d : {})
332
450
  setErr('')
333
451
  })
334
- .catch(function (e) { setErr(String(e && e.message || e)) })
452
+ .catch(function (e) { setRefreshing(false); setErr(String(e && e.message || e)) })
335
453
  }
336
454
  React.useEffect(function () { refresh() }, [])
337
455
 
@@ -357,17 +475,20 @@ window.__ModuleLoader__.load({
357
475
  return ce('div', { className: 'kf_root' },
358
476
  ce('div', { className: 'kf_head' },
359
477
  ce('div', null,
360
- ce('h2', { className: 'kf_title' }, 'API Key 回退'),
478
+ ce('h2', { className: 'kf_title' }, ce('span', { className: 'kf_dot' }), 'API Key 回退'),
361
479
  ce('p', { className: 'kf_sub' }, '为 LLM provider 维护多个 API key,请求失败时自动换下一个 key 重试。key 只写入本机 .credentials.yaml,不进设置文件。'),
362
480
  ),
363
- (pools || []).length === 0 ? ce('span', { className: 'kf_badge warn' }, '尚未启用') : ce('span', { className: 'kf_badge ok' }, (pools || []).length + ' 个池'),
481
+ ce('div', { className: 'kf_headRight' },
482
+ (pools || []).length === 0 ? ce('span', { className: 'kf_badge warn' }, '尚未启用') : ce('span', { className: 'kf_badge ok' }, (pools || []).length + ' 个池'),
483
+ ce('button', { className: 'kf_btn', disabled: isRefreshing, onClick: refresh }, isRefreshing ? '刷新…' : '↻ 刷新'),
484
+ ),
364
485
  ),
365
486
  ce('div', { className: 'kf_addrow' },
366
487
  adding
367
- ? ce('div', null,
488
+ ? ce('div', { style: { display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', width: '100%' } },
368
489
  ce('label', null,
369
490
  ce('span', null, '选择 LLM provider'),
370
- ce('select', { value: pick, onChange: function (e) { setPick(e.target.value) } },
491
+ ce('select', { className: 'kf_sel', style: { fontSize: 13, padding: '7px 10px' }, value: pick, onChange: function (e) { setPick(e.target.value) } },
371
492
  ce('option', { value: '' }, '— 选择 —'),
372
493
  all.map(function (p) { return ce('option', { value: p.id, key: p.id }, p.name + '(' + p.id + ' · ' + p.hint + ')') }),
373
494
  ),
@@ -402,4 +523,4 @@ window.__ModuleLoader__.load({
402
523
  exports.inject = ['slots']
403
524
  return module.exports
404
525
  },
405
- })
526
+ })
package/lib/index.js CHANGED
@@ -1,12 +1,18 @@
1
- // @omdp/dsh-key-fallback — Host half (ESM, v5)
1
+ // @omdp/dsh-key-fallback — Host half (ESM, v6)
2
2
  //
3
- // v5 = v1 核心功能全量恢复(预写 + 失败标记 + 顺序配置)+ v4 的 CRUD 与极简配置:
4
- // - 配置来源 = ~/.dsh/settings.yaml 的 key-fallback.providers[*]
5
- // - agent/request 预写:每次请求把池当前 key 写入 env + credentials(v1 行为)
6
- // - agent/request-error:标记失败的 key(failCount/cooldown/status/lastError)并按 nextRef 顺序切下一个
7
- // - 每个 key 可配置 nextRef(失败后跳转到指定 key),UI 可直接配置轮换顺序
8
- // - GET /pools 返回每个 key 的实时状态(status/failCount/cooldownRemaining/lastError),UI 展示
9
- import { readFileSync, writeFileSync, existsSync } from 'node:fs'
3
+ // v6 = v5 全量功能 + 用户要求的修复与增强:
4
+ // - 轮转触发码真正生效:agent/request-error 按池的 rotateOn 判断(可配置、chips 可点选),
5
+ // 数字 status 与消息关键字按所选触发码映射;默认集=旧行为超集(无回归)。
6
+ // - 「当前使用」显示修复:GET /pools 返回 activeRef(= 最近一次预写进 process.env 的 key),
7
+ // POST/PATCH 改 useKeyRef 后同步刷新内存池 + env,UI 立刻显示真正在用的 key。
8
+ // - key 短命名:新增 key 自动命名为 key_fallback_<provider>_key<N>;
9
+ // 旧的长 ref 在首次加载时一次性迁移(refsCompacted 标记,幂等,先 set 新 ref 再 unset 旧 ref)。
10
+ // - 明文揭示:GET /keys/plain?provider=&ref= 返回真实值(走 rawResolve,绕过池 wrap)。
11
+ // - 环境密钥可编辑:PATCH /keys 的 ref 等于池 env 时改写 .credentials.yaml(describe 可写才允许)。
12
+ // - 隐藏 bug 修复:PATCH /keys 解构 useKeyRef(此前「设为当前」静默失效);
13
+ // warmPools 不再二次调用 pickKey;GET /pools 返回真实 label;POST /diag 接受;
14
+ // resolve wrap 尊重 pool.enabled。
15
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs'
10
16
  import { homedir } from 'node:os'
11
17
  import { join } from 'node:path'
12
18
  import { load as loadYaml, dump as dumpYaml } from 'js-yaml'
@@ -43,7 +49,7 @@ const profileSchema = z.object({
43
49
  env: z.string().pattern(/^[A-Za-z_][A-Za-z0-9_]*$/).default('NINEROUTER_API_KEY'),
44
50
  cooldownMs: z.number().min(0).default(30000),
45
51
  enabled: z.boolean().default(true),
46
- rotateOn: z.array(z.string()).default(['QUOTA_EXCEEDED', 'AUTH', 'RATE_LIMIT']),
52
+ rotateOn: z.array(z.string()).default(['QUOTA', 'AUTH', 'RATE_LIMIT', 'TIMEOUT', 'TRANSPORT', 'SERVER']),
47
53
  keyRefs: z.array(z.object({
48
54
  ref: z.string().pattern(/^[A-Za-z_][A-Za-z0-9_]*$/),
49
55
  label: z.string().default(''),
@@ -52,6 +58,7 @@ const profileSchema = z.object({
52
58
  })).default([]),
53
59
  displayName: z.string().default(''),
54
60
  useKeyRef: z.string().default(''),
61
+ refsCompacted: z.boolean().default(false),
55
62
  })
56
63
 
57
64
  function readSettings() {
@@ -87,10 +94,74 @@ function sendJson(res, code, data) {
87
94
  } catch (e) { try { res.end() } catch {} }
88
95
  }
89
96
 
90
- const RETRYABLE_CODES = ['RATE_LIMIT', 'AUTH', 'QUOTA_EXCEEDED', 'TIMEOUT', 'TRANSPORT']
97
+ // ── 轮转触发语义(v6:rotateOn 真正生效)──
98
+ // 默认集 = 旧 RETRYABLE_CODES 行为超集(QUOTA_EXCEEDED 规范化成规范码 QUOTA);
99
+ // 用户保存什么就严格按什么判断——不做「等于旧三元组就提升成超集」的魔法,
100
+ // 否则用户精确配置的三个码保存后刷新会变回六个,配置不生效。
101
+ const DEFAULT_ROTATE_ON = ['QUOTA', 'AUTH', 'RATE_LIMIT', 'TIMEOUT', 'TRANSPORT', 'SERVER']
102
+ function canonicalCode(c) {
103
+ const s = String(c || '').toUpperCase()
104
+ if (s === 'QUOTA_EXCEEDED' || s === 'QUOTA') return 'QUOTA'
105
+ return s
106
+ }
107
+ // 读取池配置时规范化 rotateOn;空数组/未配置 → 返回默认超集。
108
+ function effectiveRotateOn(stored) {
109
+ const norm = (Array.isArray(stored) ? stored : []).map(canonicalCode).filter(Boolean)
110
+ if (norm.length === 0) return [...DEFAULT_ROTATE_ON]
111
+ return norm
112
+ }
113
+ const CODE_KEYWORDS = {
114
+ QUOTA: /quota|insufficient|balance|credit|budget|exhaust|billing|limit exceeded/i,
115
+ AUTH: /401|403|auth|credential|denied|forbidden|invalid key|api key|unauthor|token/i,
116
+ RATE_LIMIT: /429|rate|limit|throttl|too many|try again later|concurr|busy/i,
117
+ TIMEOUT: /timeout|timed ?out|deadline|gateway timeout/i,
118
+ TRANSPORT: /transport|network|econn|socket|eai|dns|fetch failed|connection|closed|reset|unreachable/i,
119
+ SERVER: /5\d\d|server error|internal|unavailable|502|503|504|bad gateway|overload/i,
120
+ EMPTY_RESPONSE: /empty response|no content|empty reply/i,
121
+ }
122
+ function shouldRotate(pool, code, rawMsg, status) {
123
+ const triggers = effectiveRotateOn(pool.cfg.rotateOn)
124
+ if (triggers.includes(code)) return true
125
+ const s = Number(status)
126
+ if (Number.isFinite(s) && s >= 400 && s <= 599) {
127
+ const mapped = s === 429 ? 'RATE_LIMIT' : (s === 401 || s === 403 ? 'AUTH' : (s === 402 ? 'QUOTA' : (s >= 500 ? 'SERVER' : 'AUTH')))
128
+ if (triggers.includes(mapped)) return true
129
+ }
130
+ for (const t of triggers) {
131
+ const rx = CODE_KEYWORDS[t]
132
+ if (rx && rx.test(rawMsg)) return true
133
+ }
134
+ return false
135
+ }
136
+
137
+ // ── key 短命名 ──
138
+ function safeProvider(provider) {
139
+ return String(provider || '').replace(/[^A-Za-z0-9_]/g, '_')
140
+ }
141
+ function isShortRef(ref) {
142
+ return /^key_fallback_[A-Za-z0-9_]+_key\d+$/.test(ref)
143
+ }
144
+ function shortRefOf(provider, n) {
145
+ return 'key_fallback_' + safeProvider(provider) + '_key' + n
146
+ }
147
+ // 为新增 key 分配下一个空闲短 ref(key1、key2…)
148
+ function nextKeyRef(provider, keyRefs) {
149
+ const used = new Set((Array.isArray(keyRefs) ? keyRefs : []).map((e) => e && e.ref).filter(Boolean))
150
+ let n = 1
151
+ while (used.has(shortRefOf(provider, n))) n++
152
+ return shortRefOf(provider, n)
153
+ }
154
+ // 从 ref 派生显示名(label 优先,否则 key_fallback_x_keyN → keyN)
155
+ function displayNameOf(entry) {
156
+ if (entry && entry.label) return entry.label
157
+ const ref = (entry && entry.ref) || ''
158
+ const m = /_key(\d+)$/.exec(ref)
159
+ if (m) return 'key' + m[1]
160
+ return ref
161
+ }
91
162
 
92
163
  export function apply(ctx) {
93
- try { ctx.logger.info('key-fallback: v5 host apply() starting (config = settings.yaml)') } catch (e) {}
164
+ try { ctx.logger.info('key-fallback: v6 host apply() starting (config = settings.yaml)') } catch (e) {}
94
165
  const diagLog = []
95
166
  const diag = (kind, msg) => { try { diagLog.push({ kind, msg: String(msg).slice(0, 300), at: Date.now() }); if (diagLog.length > 200) diagLog.splice(0, diagLog.length - 200) } catch (e) {} }
96
167
  diag('apply', 'host loaded')
@@ -105,9 +176,9 @@ export function apply(ctx) {
105
176
  try { result = await rawResolve(ref) } catch (e) { result = undefined }
106
177
  const refName = String(ref || '')
107
178
  const pool = poolsByEnv.get(refName)
108
- if (pool && pool.currentRef) {
179
+ if (pool && pool.cfg && pool.cfg.enabled !== false && pool.currentRef) {
109
180
  const cur = pool.keyValues.find((k) => k.ref === pool.currentRef)
110
- if (cur) { diag('resolve', 'pool-hit ' + refName + ' -> ' + cur.ref.slice(-12)); return { value: cur.value, source: 'pool' } }
181
+ if (cur) { diag('resolve', 'pool-hit ' + refName + ' -> ' + cur.ref); return { value: cur.value, source: 'pool' } }
111
182
  }
112
183
  return result
113
184
  }
@@ -120,6 +191,8 @@ export function apply(ctx) {
120
191
  function readPools() {
121
192
  const cfg = readSettings()
122
193
  const providers = (cfg && cfg.providers) || {}
194
+ // 每次全量重建 env 索引:删除池/改 env 后不得残留旧映射(否则 wrap 会命中已删池)
195
+ poolsByEnv.clear()
123
196
  for (const name in providers) {
124
197
  if (!pools.has(name)) pools.set(name, { cfg: providers[name], keyValues: [], cursor: 0, currentRef: '' })
125
198
  else pools.get(name).cfg = providers[name]
@@ -129,11 +202,59 @@ export function apply(ctx) {
129
202
  for (const name of [...pools.keys()]) if (!providers[name]) pools.delete(name)
130
203
  }
131
204
 
132
- async function resolvePoolKeys(pool) {
205
+ // 一次性迁移旧长 ref → 短 ref(key_fallback_x_keyN)。幂等:迁移后长 ref 不存在即自然短路。
206
+ // 顺序:先 set 新 ref(从旧 ref 读值)→ 改 settings → 再 unset 旧 ref;任一步失败即中止,保留旧状态可重试。
207
+ // 注意:不依赖 refsCompacted 短路——空池创建会先置标记,若之后外部导入旧格式长 ref 仍必须迁移。
208
+ async function compactRefs(provider, prof) {
209
+ const entries = (prof.keyRefs || []).map((e) => {
210
+ if (typeof e === 'string') return { ref: e, label: '', createdAt: 0, nextRef: '' }
211
+ return { ref: e.ref || '', label: e.label || '', createdAt: e.createdAt || 0, nextRef: e.nextRef || '' }
212
+ })
213
+ const longOnes = entries.filter((e) => e.ref && e.ref.startsWith('key_fallback_') && !isShortRef(e.ref))
214
+ if (longOnes.length === 0) return false
215
+ const oldToNew = new Map()
216
+ const used = new Set(entries.map((e) => e.ref).filter(isShortRef))
217
+ let n = 1
218
+ for (const e of longOnes) {
219
+ while (used.has(shortRefOf(provider, n))) n++
220
+ oldToNew.set(e.ref, shortRefOf(provider, n))
221
+ used.add(shortRefOf(provider, n))
222
+ n++
223
+ }
224
+ // 1) 写新 ref(读旧值)
225
+ for (const [oldRef, newRef] of oldToNew) {
226
+ let hit
227
+ try { hit = rawResolve ? await rawResolve(credentialRef(oldRef)) : await ctx.credentials.resolve(credentialRef(oldRef)) } catch (e) { hit = undefined }
228
+ if (!hit || !hit.value) return false
229
+ try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.set(credentialRef(newRef), hit.value) } catch (e) { return false }
230
+ }
231
+ // 2) 更新 settings 引用
232
+ for (const e of entries) {
233
+ if (e.ref && oldToNew.has(e.ref)) e.ref = oldToNew.get(e.ref)
234
+ if (e.nextRef && oldToNew.has(e.nextRef)) e.nextRef = oldToNew.get(e.nextRef)
235
+ }
236
+ if (prof.useKeyRef && oldToNew.has(prof.useKeyRef)) prof.useKeyRef = oldToNew.get(prof.useKeyRef)
237
+ prof.keyRefs = entries
238
+ prof.refsCompacted = true
239
+ // 3) unset 旧 ref(best-effort)
240
+ for (const oldRef of oldToNew.keys()) { try { await ctx.credentials.unset(credentialRef(oldRef)) } catch (e) {} }
241
+ return true
242
+ }
243
+
244
+ async function resolvePoolKeys(pool, providerName) {
245
+ // 迁移旧 ref(一次),成功后持久化 settings —— 用与 pool.cfg 同一对象的 settings 块
246
+ const cfg = readSettings()
247
+ const providers = cfg.providers || {}
248
+ // 让 pool.cfg 指向本次解析的树(之前 readPools 解析的是旧树,改它无法写回)
249
+ if (providers[providerName]) pool.cfg = providers[providerName]
250
+ let compacted = false
251
+ if (pool.cfg) { try { compacted = await compactRefs(providerName, pool.cfg) } catch (e) { compacted = false } }
252
+ if (compacted) writeSettings(cfg)
133
253
  // 保留已有状态(failCount/cooldown/lastError),避免重建时丢状态
134
254
  const prev = new Map(pool.keyValues.map((k) => [k.ref, k]))
135
255
  pool.keyValues = []
136
- for (const entry of (pool.cfg.keyRefs || [])) {
256
+ const entries = (pool.cfg.keyRefs || []).map((e) => typeof e === 'string' ? { ref: e, label: '', createdAt: 0, nextRef: '' } : e)
257
+ for (const entry of entries) {
137
258
  const ref = (entry && typeof entry === 'object') ? entry.ref : entry
138
259
  const nextRef = (entry && typeof entry === 'object' && entry.nextRef) ? entry.nextRef : ''
139
260
  if (!ref) continue
@@ -142,7 +263,7 @@ export function apply(ctx) {
142
263
  if (hit && hit.value) {
143
264
  const old = prev.get(ref) || {}
144
265
  pool.keyValues.push({
145
- ref, value: hit.value, source: 'user',
266
+ ref, value: hit.value, source: 'user', label: (entry && entry.label) || '',
146
267
  nextRef,
147
268
  failCount: old.failCount || 0,
148
269
  cooldownUntil: old.cooldownUntil || 0,
@@ -164,7 +285,7 @@ export function apply(ctx) {
164
285
  const found = pool.keyValues.find((k) => k.ref === envRef)
165
286
  if (!found) {
166
287
  pool.keyValues.push({
167
- ref: envRef, value: hit.value, source: 'env',
288
+ ref: envRef, value: hit.value, source: 'env', label: '',
168
289
  nextRef: '',
169
290
  failCount: old.failCount || 0,
170
291
  cooldownUntil: old.cooldownUntil || 0,
@@ -182,7 +303,7 @@ export function apply(ctx) {
182
303
  readPools()
183
304
  const pool = pools.get(provider)
184
305
  if (!pool) return undefined
185
- await resolvePoolKeys(pool)
306
+ await resolvePoolKeys(pool, provider)
186
307
  return pool
187
308
  }
188
309
 
@@ -235,6 +356,15 @@ export function apply(ctx) {
235
356
  return undefined
236
357
  }
237
358
 
359
+ // 把 useKeyRef 选择立即落到内存池 + process.env,让「当前使用」立刻真实
360
+ function applySelection(pool, ref) {
361
+ if (!pool || !ref) return
362
+ const kv = pool.keyValues.find((k) => k.ref === ref)
363
+ if (!kv) return
364
+ pool.currentRef = ref
365
+ try { process.env[pool.cfg.env] = kv.value } catch (e) {}
366
+ }
367
+
238
368
  // ── 预写:v1 同款 —— 全局 agent/request(DSH 全局收得到这个 waterfall),
239
369
  // 每次请求同步 buildPools() 重读 settings,选 key,同步写 process.env,
240
370
  // credentials.set fire-and-forget(不 await 阻塞 waterfall)。
@@ -243,11 +373,13 @@ export function apply(ctx) {
243
373
  for (const name of pools.keys()) {
244
374
  const pool = pools.get(name)
245
375
  if (pool) {
246
- try { await resolvePoolKeys(pool) } catch (e) {}
247
- // 预热时确定初始 currentRef:useKeyRef 锁定优先,否则 pickKey
376
+ try { await resolvePoolKeys(pool, name) } catch (e) {}
377
+ // 预热时确定初始 currentRef:useKeyRef 锁定优先,否则 pickKey(只调一次)
248
378
  if (pool.keyValues.length > 0 && !pool.currentRef) {
249
379
  const locked = pool.cfg.useKeyRef && pool.keyValues.find((k) => k.ref === pool.cfg.useKeyRef)
250
- pool.currentRef = (locked && locked.ref) || (pickKey(pool) && pickKey(pool).ref) || pool.keyValues[0].ref
380
+ let picked = null
381
+ if (!(locked && locked.ref)) picked = pickKey(pool)
382
+ pool.currentRef = (locked && locked.ref) || (picked && picked.ref) || pool.keyValues[0].ref
251
383
  }
252
384
  }
253
385
  }
@@ -255,7 +387,6 @@ export function apply(ctx) {
255
387
 
256
388
  // 启动时预热 pools(resolve keyValues,供同步预写 pickKey 用)
257
389
  try { warmPools().catch(() => {}) } catch (e) {}
258
- // 每次 settings 变化也刷新 —— webServer 路由里已有调用,这里兜底周期不设
259
390
 
260
391
  ctx.on('agent/request', (payload, next) => {
261
392
  // next() 是 async,但我们要同步写 env —— 用 then 链,立即返回 p 不阻塞 waterfall
@@ -275,7 +406,7 @@ export function apply(ctx) {
275
406
  pool.currentRef = key.ref
276
407
  diag('agent/request', 'PICK ' + key.ref + ' env=' + pool.cfg.env)
277
408
  try { process.env[pool.cfg.env] = key.value } catch (e) {}
278
- try { backupFile(CRED_FILE, 'credentials'); ctx.credentials.set(credentialRef(pool.cfg.env), key.value).catch(() => {}) } catch (e) {}
409
+ try { backupFile(CRED_FILE, 'credentials') } catch (e) {}
279
410
  } catch (e) {}
280
411
  return config
281
412
  }).catch((e) => {})
@@ -286,12 +417,13 @@ export function apply(ctx) {
286
417
  ctx.on('agent/request-error', async (payload, next) => {
287
418
  const code = String((payload && payload.failure && payload.failure.code) || (payload && payload.code) || '')
288
419
  const rawMsg = String((payload && payload.failure && payload.failure.message) || (payload && payload.message) || '')
420
+ const status = (payload && payload.failure && payload.failure.status) || (payload && payload.status)
289
421
  const provider = (payload && payload.provider) || ''
290
- const isRetryable = RETRYABLE_CODES.includes(code) || /^[45]\d\d$/.test(code) || /rate|limit|quota|exhaust|throttl|429|timeout|timed?out|auth|credential|denied|forbidden|unavailable|busy|key/i.test(rawMsg)
291
- if (!provider || !isRetryable) return next()
292
- diag('request-error', 'provider=' + provider + ' code=' + code)
422
+ if (!provider) return next()
423
+ diag('request-error', 'provider=' + provider + ' code=' + code + ' status=' + status)
293
424
  const pool = await getPool(provider)
294
- if (!pool || pool.keyValues.length === 0) return next()
425
+ if (!pool || pool.keyValues.length === 0 || pool.cfg.enabled === false) return next()
426
+ if (!shouldRotate(pool, canonicalCode(code), rawMsg, status)) return next()
295
427
  let curRef = pool.currentRef
296
428
  if (!curRef) {
297
429
  const envCur = process.env[pool.cfg.env]
@@ -302,7 +434,7 @@ export function apply(ctx) {
302
434
  // 失败后优先按 nextRef 轮换(用户配置的顺序);useKeyRef 只决定初始选择
303
435
  const nextKey = nextKeyFor(pool, curRef)
304
436
  if (nextKey) {
305
- try { await backupFile(CRED_FILE, 'credentials'); ctx.credentials.set(credentialRef(pool.cfg.env), nextKey.value); process.env[pool.cfg.env] = nextKey.value } catch (e) {}
437
+ try { await backupFile(CRED_FILE, 'credentials'); process.env[pool.cfg.env] = nextKey.value } catch (e) {}
306
438
  pool.currentRef = nextKey.ref
307
439
  } else {
308
440
  // 无 nextRef 可选时:若 useKeyRef 锁定 key 仍 live 则退回它,否则放弃
@@ -329,7 +461,11 @@ export function apply(ctx) {
329
461
  req.on('error', reject)
330
462
  })
331
463
 
332
- if (path === 'diag' && req.method === 'GET') {
464
+ if (path === 'diag' && (req.method === 'GET' || req.method === 'POST')) {
465
+ if (req.method === 'POST') {
466
+ try { const b = await readBody(); diag(b.kind || 'client', b.message || b.msg || '') } catch (e) {}
467
+ return sendJson(res, 200, { ok: true })
468
+ }
333
469
  return sendJson(res, 200, { diag: diagLog })
334
470
  }
335
471
 
@@ -355,13 +491,32 @@ export function apply(ctx) {
355
491
  const out = []
356
492
  readPools()
357
493
  for (const [name, pool] of pools) {
358
- await resolvePoolKeys(pool)
494
+ await resolvePoolKeys(pool, name)
359
495
  const now = Date.now()
496
+ // 真正当前使用的 key:最近一次预写进 process.env 的值
497
+ let activeRef = ''
498
+ if (pool.cfg.enabled !== false) {
499
+ const envCur = process.env[pool.cfg.env]
500
+ if (envCur) { const m = pool.keyValues.find((k) => k.value === envCur); if (m) activeRef = m.ref }
501
+ if (!activeRef && pool.currentRef) activeRef = pool.currentRef
502
+ }
503
+ // 环境密钥可写性(描述:文件=可写,启动环境=只读)
504
+ let envWritable = null
505
+ let envSource = ''
506
+ try {
507
+ if (ctx.credentials.describe) {
508
+ const d = await ctx.credentials.describe(credentialRef(pool.cfg.env))
509
+ envWritable = d ? d.writable !== false : null
510
+ envSource = d ? (d.source || '') : ''
511
+ }
512
+ } catch (e) {}
360
513
  const keys = pool.keyValues.map((k) => {
361
514
  const cooling = (k.cooldownUntil || 0) > now
515
+ const entry = (pool.cfg.keyRefs || []).find((x) => (x && x.ref) === k.ref) || {}
362
516
  return {
363
517
  ref: k.ref,
364
- label: '',
518
+ name: displayNameOf({ label: k.label || entry.label, ref: k.ref }),
519
+ label: k.label || entry.label || '',
365
520
  source: k.source,
366
521
  nextRef: k.nextRef || '',
367
522
  status: cooling ? 'cooling' : (k.failCount ? 'recovered' : 'live'),
@@ -370,6 +525,7 @@ export function apply(ctx) {
370
525
  cooldownRemainingMs: cooling ? (k.cooldownUntil - now) : 0,
371
526
  lastErrorAt: k.lastErrorAt || 0,
372
527
  lastErrorMsg: k.lastErrorMsg || '',
528
+ active: k.ref === activeRef,
373
529
  }
374
530
  })
375
531
  out.push({
@@ -378,10 +534,14 @@ export function apply(ctx) {
378
534
  env: pool.cfg.env,
379
535
  cooldownMs: pool.cfg.cooldownMs,
380
536
  enabled: pool.cfg.enabled,
381
- rotateOn: pool.cfg.rotateOn || [],
537
+ rotateOn: effectiveRotateOn(pool.cfg.rotateOn),
382
538
  useKeyRef: pool.cfg.useKeyRef || '',
383
539
  currentRef: pool.currentRef || '',
540
+ activeRef: activeRef,
541
+ activeName: activeRef ? (keys.find((k) => k.ref === activeRef) || {}).name || activeRef : '',
384
542
  envKeyValue: pool.envKeyValue ? mask(pool.envKeyValue) : '',
543
+ envWritable: envWritable,
544
+ envSource: envSource,
385
545
  keys: keys,
386
546
  })
387
547
  }
@@ -400,18 +560,29 @@ export function apply(ctx) {
400
560
  env: envName,
401
561
  enabled: enabled !== false,
402
562
  cooldownMs: Number.isFinite(cooldownMs) && cooldownMs >= 0 ? cooldownMs : (cur.cooldownMs ?? 30000),
403
- rotateOn: Array.isArray(rotateOn) && rotateOn.length > 0 ? rotateOn : (cur.rotateOn || ['QUOTA_EXCEEDED', 'AUTH', 'RATE_LIMIT']),
563
+ rotateOn: Array.isArray(rotateOn) && rotateOn.length > 0 ? rotateOn.map(canonicalCode).filter(Boolean) : effectiveRotateOn(cur.rotateOn),
404
564
  keyRefs: (cur.keyRefs || []).map((x) => {
405
565
  if (typeof x === 'string') return { ref: x, label: '', createdAt: 0, nextRef: '' }
406
566
  return { ref: x.ref, label: x.label || '', createdAt: x.createdAt || 0, nextRef: x.nextRef || '' }
407
567
  }),
408
568
  displayName: (typeof displayName === 'string' && displayName) ? displayName : (cur.displayName || provider),
409
569
  useKeyRef: (typeof useKeyRef === 'string') ? useKeyRef : (cur.useKeyRef || ''),
570
+ refsCompacted: cur.refsCompacted === true,
410
571
  }
411
572
  let validated
412
573
  try { validated = profileSchema(upd) } catch (e) { return sendJson(res, 400, { error: 'invalid profile: ' + (e && e.message || e) }) }
413
574
  cfg.providers[provider] = validated
414
575
  writeSettings(cfg)
576
+ // 同步内存池 + env,让「当前使用」立刻真实
577
+ try {
578
+ const pool = await getPool(provider)
579
+ if (pool) {
580
+ if (validated.useKeyRef) applySelection(pool, validated.useKeyRef)
581
+ else if (pool.currentRef && !pool.keyValues.find((k) => k.ref === pool.currentRef && isLive(k))) {
582
+ const pk = pickKey(pool); if (pk) { pool.currentRef = pk.ref; try { process.env[pool.cfg.env] = pk.value } catch (e) {} }
583
+ }
584
+ }
585
+ } catch (e) {}
415
586
  return sendJson(res, 200, { ok: true, pool: validated })
416
587
  }
417
588
  if (req.method === 'DELETE') {
@@ -423,6 +594,7 @@ export function apply(ctx) {
423
594
  for (const k of (prof.keyRefs || [])) { try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.unset(credentialRef(k.ref || k)) } catch (e) {} }
424
595
  delete cfg.providers[provider]
425
596
  writeSettings(cfg)
597
+ pools.delete(provider)
426
598
  return sendJson(res, 200, { ok: true })
427
599
  }
428
600
  return sendJson(res, 405, { error: 'method not allowed' })
@@ -437,28 +609,49 @@ export function apply(ctx) {
437
609
  const cfg = readSettings()
438
610
  if (!cfg.providers || !cfg.providers[provider]) return sendJson(res, 404, { error: 'pool not found' })
439
611
  const prof = cfg.providers[provider]
440
- const newRef = 'key_fallback_' + provider + '_' + Date.now().toString(36) + '_' + Math.floor(Math.random() * 0xffffffff).toString(36)
612
+ const newRef = nextKeyRef(provider, prof.keyRefs || [])
441
613
  try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.set(credentialRef(newRef), value) } catch (e) { return sendJson(res, 500, { error: 'credentials write failed' }) }
442
614
  const newEntry = { ref: newRef, label: (typeof label === 'string' && label) ? label : '', createdAt: Date.now(), nextRef: '' }
443
615
  prof.keyRefs = [...(prof.keyRefs || []), newEntry]
444
616
  writeSettings(cfg)
617
+ // 刷新内存池,让新 key 立即可参与轮换(幂等;已 compacted 则快速返回)
618
+ try { await getPool(provider) } catch (e) {}
445
619
  return sendJson(res, 200, { ok: true, key: newEntry, valueMask: mask(value) })
446
620
  }
447
621
  if (req.method === 'PATCH') {
448
622
  const body = await readBody()
449
- const { provider, ref, value, label, nextRef } = body || {}
623
+ const { provider, ref, value, label, nextRef, useKeyRef } = body || {}
450
624
  if (typeof provider !== 'string' || !provider || typeof ref !== 'string' || !ref) return sendJson(res, 400, { error: 'provider & ref required' })
451
625
  const cfg = readSettings()
452
626
  const prof = cfg.providers && cfg.providers[provider]
453
627
  if (!prof) return sendJson(res, 404, { error: 'pool not found' })
454
628
  const entry = (prof.keyRefs || []).find((k) => k.ref === ref)
455
- if (!entry) return sendJson(res, 404, { error: 'key not found' })
629
+ const isEnvKey = ref === prof.env
630
+ if (!entry && !isEnvKey) return sendJson(res, 404, { error: 'key not found' })
456
631
  if (typeof value === 'string' && value) {
457
- try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.set(credentialRef(ref), value) } catch (e) { return sendJson(res, 500, { error: 'credentials write failed' }) }
632
+ // 环境密钥:允许改写 .credentials.yaml(describe 可写才允许;启动环境提供=只读)
633
+ if (isEnvKey) {
634
+ let writable = null
635
+ try { if (ctx.credentials.describe) { const d = await ctx.credentials.describe(credentialRef(ref)); writable = d ? d.writable !== false : null } } catch (e) {}
636
+ if (writable === false) return sendJson(res, 400, { error: '该环境密钥由启动环境提供(只读);请改启动 DSH 的终端环境变量' })
637
+ }
638
+ try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.set(credentialRef(ref), value) } catch (e) { return sendJson(res, 500, { error: 'credentials write failed: ' + ((e && e.message) || e) }) }
639
+ // 同步内存池 + env
640
+ try {
641
+ const pool = pools.get(provider)
642
+ if (pool) {
643
+ const kv = pool.keyValues.find((k) => k.ref === ref)
644
+ if (kv) kv.value = value
645
+ if (isEnvKey) pool.envKeyValue = value
646
+ }
647
+ } catch (e) {}
648
+ }
649
+ if (typeof label === 'string') { if (entry) entry.label = label }
650
+ if (typeof nextRef === 'string') { if (entry) entry.nextRef = nextRef }
651
+ if (typeof useKeyRef === 'string') {
652
+ prof.useKeyRef = useKeyRef
653
+ try { const pool = await getPool(provider); if (pool) applySelection(pool, useKeyRef) } catch (e) {}
458
654
  }
459
- if (typeof label === 'string') entry.label = label
460
- if (typeof nextRef === 'string') entry.nextRef = nextRef
461
- if (typeof useKeyRef === 'string') prof.useKeyRef = useKeyRef
462
655
  writeSettings(cfg)
463
656
  return sendJson(res, 200, { ok: true })
464
657
  }
@@ -469,6 +662,7 @@ export function apply(ctx) {
469
662
  const cfg = readSettings()
470
663
  const prof = cfg.providers && cfg.providers[provider]
471
664
  if (!prof) return sendJson(res, 404, { error: 'pool not found' })
665
+ if (ref === prof.env) return sendJson(res, 400, { error: '环境密钥不可删除' })
472
666
  const idx = (prof.keyRefs || []).findIndex((k) => k.ref === ref)
473
667
  if (idx < 0) return sendJson(res, 404, { error: 'key not found' })
474
668
  try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.unset(credentialRef(ref)) } catch (e) {}
@@ -477,11 +671,35 @@ export function apply(ctx) {
477
671
  // 清理指向已删 key 的 nextRef
478
672
  for (const k of (prof.keyRefs || [])) if (k.nextRef === ref) k.nextRef = ''
479
673
  writeSettings(cfg)
674
+ // 同步内存池
675
+ try {
676
+ const pool = pools.get(provider)
677
+ if (pool) {
678
+ pool.keyValues = pool.keyValues.filter((k) => k.ref !== ref)
679
+ if (pool.currentRef === ref) { pool.currentRef = ''; try { const pk = pickKey(pool); if (pk) { pool.currentRef = pk.ref; try { process.env[pool.cfg.env] = pk.value } catch (e) {} } } catch (e) {} }
680
+ }
681
+ } catch (e) {}
480
682
  return sendJson(res, 200, { ok: true })
481
683
  }
482
684
  return sendJson(res, 405, { error: 'method not allowed' })
483
685
  }
484
686
 
687
+ // 明文揭示:仅限本池拥有的 user key 或环境密钥
688
+ if (path === 'keys/plain' && req.method === 'GET') {
689
+ const provider = url.searchParams.get('provider')
690
+ const ref = url.searchParams.get('ref')
691
+ if (!provider || !ref) return sendJson(res, 400, { error: 'provider & ref required' })
692
+ const pool = await getPool(provider)
693
+ if (!pool) return sendJson(res, 404, { error: 'pool not found' })
694
+ const isUserKey = (pool.cfg.keyRefs || []).some((k) => k.ref === ref)
695
+ const isEnvKey = ref === pool.cfg.env
696
+ if (!isUserKey && !isEnvKey) return sendJson(res, 404, { error: 'key not found' })
697
+ let hit
698
+ try { hit = rawResolve ? await rawResolve(credentialRef(ref)) : await ctx.credentials.resolve(credentialRef(ref)) } catch (e) { hit = undefined }
699
+ if (!hit || !hit.value) return sendJson(res, 404, { error: 'no value' })
700
+ return sendJson(res, 200, { ok: true, ref: ref, value: hit.value })
701
+ }
702
+
485
703
  if (path === 'reset') {
486
704
  if (req.method === 'POST') {
487
705
  const body = await readBody()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@omdp/dsh-key-fallback",
3
- "description": "Elliptic key pool with automatic rotation for DeepSeek Harness LLM providers. Settings → API Key 回退 UI: pick provider, add multiple keys (write-only). v3.",
4
- "version": "3.0.0",
3
+ "description": "API Key 回退轮换插件:多 key 池 + 自动轮换(触发码可配置)+ 环境密钥编辑 + 明文揭示 + 短 ref 迁移。Settings → API Key 回退 UI. v6.",
4
+ "version": "3.1.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {