@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.
- package/README.md +53 -26
- package/lib/client.js +243 -122
- package/lib/index.js +258 -40
- 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
|
|
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
|
-
|
|
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
|
|
10
|
+
## What v6 offers
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
- **
|
|
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
|
-
#
|
|
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
|
-
#
|
|
37
|
-
|
|
38
|
-
# 3) open Settings → API Key 回退
|
|
53
|
+
# restart DSH, then open Settings → API Key 回退
|
|
39
54
|
```
|
|
40
55
|
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 (
|
|
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
|
|
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:
|
|
40
|
-
'.kf_head{display:flex;align-items:flex-
|
|
41
|
-
'.kf_title{font-size:
|
|
42
|
-
'.
|
|
43
|
-
'.
|
|
44
|
-
'.
|
|
45
|
-
'.
|
|
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
|
-
'.
|
|
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)
|
|
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:
|
|
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
|
-
'.
|
|
64
|
-
'.
|
|
65
|
-
'.
|
|
66
|
-
'.
|
|
67
|
-
'.
|
|
68
|
-
'.
|
|
69
|
-
'.
|
|
70
|
-
'.
|
|
71
|
-
'.
|
|
72
|
-
'.
|
|
73
|
-
'.
|
|
74
|
-
'.
|
|
75
|
-
'
|
|
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.
|
|
78
|
-
'.
|
|
79
|
-
'.
|
|
80
|
-
'.
|
|
81
|
-
'.
|
|
82
|
-
'.
|
|
83
|
-
'.
|
|
84
|
-
'.
|
|
85
|
-
'.
|
|
86
|
-
'.
|
|
87
|
-
'.
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
ce('span', { className: '
|
|
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: '
|
|
205
|
+
? ce('span', { className: 'kf_envNote' },
|
|
206
|
+
props.pool.envSource === 'env' && props.pool.envWritable === false
|
|
207
|
+
? '由启动环境提供(只读)'
|
|
208
|
+
: '环境来源 · 可编辑/参与轮换',
|
|
209
|
+
)
|
|
181
210
|
: isEditing
|
|
182
|
-
? ce('
|
|
183
|
-
|
|
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('
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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', {
|
|
274
|
-
ce('span', null, 'key 数: '
|
|
275
|
-
|
|
276
|
-
ce('span', null, '
|
|
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;
|
|
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
|
-
(
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
return ce('
|
|
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
|
-
(
|
|
394
|
+
ce('div', { className: 'kf_section' }, '密钥链'),
|
|
395
|
+
keys.length === 0
|
|
294
396
|
? ce('div', { className: 'kf_empty' }, '还没有 key。在下方粘贴第一个。')
|
|
295
|
-
: ce('div', { className: 'kf_keys' },
|
|
296
|
-
|
|
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
|
-
|
|
300
|
-
ce('
|
|
301
|
-
|
|
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
|
-
|
|
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',
|
|
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,
|
|
1
|
+
// @omdp/dsh-key-fallback — Host half (ESM, v6)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// -
|
|
5
|
-
//
|
|
6
|
-
// -
|
|
7
|
-
//
|
|
8
|
-
// -
|
|
9
|
-
|
|
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(['
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
291
|
-
|
|
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');
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
4
|
-
"version": "3.
|
|
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": {
|