@omdp/dsh-key-fallback 3.0.1 → 3.1.1
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 +54 -26
- package/lib/client.js +251 -123
- package/lib/index.js +259 -38
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,48 +1,76 @@
|
|
|
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 — the preset chips cover the full DSH `LlmError` standard code set (`QUOTA` / `AUTH` / `RATE_LIMIT` / `TIMEOUT` /
|
|
17
|
+
`TRANSPORT` / `SERVER` / `EMPTY_RESPONSE` / `INVALID_CREDENTIAL`), or add a non-standard/custom code (matched exactly against the
|
|
18
|
+
provider's `failure.code`). The default set is the full standard set.
|
|
19
|
+
- **真实当前使用 key 显示**: the page shows which key is actually being used right now (derived from the last value written to the
|
|
20
|
+
provider's env), instead of a truncated hash.
|
|
21
|
+
- **短 ref 命名**: new keys are named `key_fallback_<provider>_key1`, `key_fallback_<provider>_key2`, … so the UI shows clean short
|
|
22
|
+
names (`key1`, `key2`…) instead of long ref strings. Existing long refs are migrated once, automatically and idempotently.
|
|
23
|
+
- **明文揭示**: every key row has an eye toggle (`👁` / `🙈`) that fetches and shows the real value via `GET /keys/plain` (only for
|
|
24
|
+
keys owned by that pool, or the pool's env key).
|
|
25
|
+
- **环境密钥可编辑**: the pool's env key (`AGNES_API_KEY` etc.) can be updated right in the page when it's backed by the writable
|
|
26
|
+
credential file. If it is supplied by the launching environment (read-only), the UI says so and refuses to edit.
|
|
27
|
+
- **Per-key controls**: rename/update value, set the "失败后→" next key (nextRef), lock the pool to a specific key ("设为当前"),
|
|
28
|
+
delete a key (the env key is not deletable).
|
|
29
|
+
- **Pool-level "锁定"**: lock the pool to a specific key or return to 自动轮换 (cursor-based).
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
- **
|
|
31
|
+
## Rotation semantics
|
|
32
|
+
|
|
33
|
+
- **pick**: respects `useKeyRef` lock first (but a cooling locked key is skipped so the pool never deadlocks on a bad key), otherwise
|
|
34
|
+
cursor round-robin over live keys.
|
|
35
|
+
- **fail** (`agent/request-error`, registered `prepend` so it runs before `dsh-llm-retry`): only when the error matches the pool's
|
|
36
|
+
`rotateOn` (code / status mapping / message keyword). Marks the failed key with a fixed `cooldownMs` (no exponential backoff), then
|
|
37
|
+
switches to `nextRef` if configured, else the next live key.
|
|
38
|
+
- **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
|
|
39
|
+
config/env. `ABORTED` (user cancel) never triggers rotation.
|
|
40
|
+
|
|
41
|
+
## Diagnostics
|
|
42
|
+
|
|
43
|
+
- The client POSTs runtime exceptions to `POST /dsh-key-fallback/diag`.
|
|
44
|
+
- `GET /dsh-key-fallback/diag` returns the in-memory buffer (last 200 entries) as JSON.
|
|
27
45
|
|
|
28
46
|
## Install
|
|
29
47
|
|
|
30
48
|
```sh
|
|
31
|
-
#
|
|
49
|
+
# from a local checkout (no publish required)
|
|
32
50
|
dsh plugin --profile web add link:D:/WorkSpace/omdp/dsh-key-fallback
|
|
33
51
|
# or from npm
|
|
34
52
|
dsh plugin --profile web add @omdp/dsh-key-fallback
|
|
35
53
|
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
# 3) open Settings → API Key 回退
|
|
54
|
+
# restart DSH, then open Settings → API Key 回退
|
|
39
55
|
```
|
|
40
56
|
|
|
41
|
-
|
|
57
|
+
> Note: this profile currently installs the package from npm (`^3.x`) into
|
|
58
|
+
> `~/.dsh/profiles/web/node_modules/@omdp/dsh-key-fallback`; copying updated `lib/index.js` + `lib/client.js` there and restarting DSH
|
|
59
|
+
> picks up v6.
|
|
60
|
+
|
|
61
|
+
## HTTP API (host)
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
| Method | Path | Purpose |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| GET | `/dsh-key-fallback/pools` | list pools with live status, activeRef, env writability, per-key status |
|
|
66
|
+
| POST | `/dsh-key-fallback/pools` | create/update pool (enabled, cooldownMs, rotateOn, useKeyRef, …) |
|
|
67
|
+
| DELETE | `/dsh-key-fallback/pools?provider=` | delete pool + all its keys |
|
|
68
|
+
| POST | `/dsh-key-fallback/keys` | add key (auto short ref `key_fallback_<p>_keyN`) |
|
|
69
|
+
| PATCH | `/dsh-key-fallback/keys` | update value / label / nextRef / useKeyRef (env key value = edit env) |
|
|
70
|
+
| DELETE | `/dsh-key-fallback/keys?provider=&ref=` | delete key (env key refused) |
|
|
71
|
+
| GET | `/dsh-key-fallback/keys/plain?provider=&ref=` | reveal real value (pool-owned keys / env key only) |
|
|
72
|
+
| POST | `/dsh-key-fallback/reset` | reset cooldown state for a pool |
|
|
73
|
+
| GET/POST | `/dsh-key-fallback/diag` | diagnostics buffer |
|
|
46
74
|
|
|
47
75
|
## License
|
|
48
76
|
|
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,82 @@ window.__ModuleLoader__.load({
|
|
|
34
33
|
var React = require('react')
|
|
35
34
|
var ce = React.createElement
|
|
36
35
|
|
|
36
|
+
// ── 常用代码 / 名称 ──
|
|
37
|
+
// 预设 chips = DSH LlmError 标准码(换 key 有意义的全集)。这些码决定「失败后是否换 key」,
|
|
38
|
+
// 与 DSH 的重试机制(provider 的 retryPolicy.retryableCodes,决定同一把 key 是否重发)独立互补。
|
|
39
|
+
var ROTATE_CODES = ['QUOTA', 'AUTH', 'RATE_LIMIT', 'TIMEOUT', 'TRANSPORT', 'SERVER', 'EMPTY_RESPONSE', 'INVALID_CREDENTIAL']
|
|
40
|
+
function codeLabel(c) {
|
|
41
|
+
return { QUOTA: '配额用尽', AUTH: '认证失败', RATE_LIMIT: '限流', TIMEOUT: '超时', TRANSPORT: '传输错误', SERVER: '服务端', EMPTY_RESPONSE: '空响应', INVALID_CREDENTIAL: '凭据无效' }[c] || c
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
// ── CSS(一次注入)──
|
|
38
45
|
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}',
|
|
46
|
+
'.kf_root{display:flex;flex-direction:column;gap:18px;padding:0 24px 30px;max-width:980px;margin:0 auto}',
|
|
47
|
+
'.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))}',
|
|
48
|
+
'.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}',
|
|
49
|
+
'.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)}',
|
|
50
|
+
'.kf_sub{font-size:12.5px;color:var(--dsw-alias-label-secondary,#9aa);margin:6px 0 0;line-height:1.65}',
|
|
51
|
+
'.kf_headRight{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
52
|
+
'.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
53
|
'.kf_badge.muted{color:var(--dsw-alias-label-secondary,#9aa)}',
|
|
48
54
|
'.kf_badge.ok{color:var(--dsw-alias-state-success-primary,#4ade80)}',
|
|
49
55
|
'.kf_badge.warn{color:var(--dsw-alias-state-warn-primary,#fbbf24)}',
|
|
50
56
|
'.kf_badge.brand{color:var(--dsw-alias-brand-primary,#7aa2ff);border-color:var(--dsw-alias-brand-primary,#7aa2ff)}',
|
|
51
57
|
'.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}',
|
|
58
|
+
'.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
59
|
'.kf_btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.14))}',
|
|
60
|
+
'.kf_btn:active:not(:disabled){transform:scale(.97)}',
|
|
58
61
|
'.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)
|
|
62
|
+
'.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
63
|
'.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:
|
|
64
|
+
'.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
65
|
'.kf_btn.ghost{border-style:dashed}',
|
|
63
|
-
'.
|
|
64
|
-
'.
|
|
65
|
-
'.
|
|
66
|
-
'.
|
|
67
|
-
'.
|
|
68
|
-
'.
|
|
69
|
-
'.
|
|
70
|
-
'.
|
|
71
|
-
'.
|
|
72
|
-
'.
|
|
73
|
-
'.
|
|
74
|
-
'.
|
|
75
|
-
'
|
|
66
|
+
'.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}',
|
|
67
|
+
'.kf_cardHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
|
|
68
|
+
'.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}',
|
|
69
|
+
'.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}',
|
|
70
|
+
'.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}',
|
|
71
|
+
'.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}',
|
|
72
|
+
'.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)}',
|
|
73
|
+
'.kf_switch.on::after{transform:translateX(15px);background:#4ade80}',
|
|
74
|
+
'.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}',
|
|
75
|
+
'.kf_meta b{color:var(--dsw-alias-label-primary,#e8e8e8);font-weight:600}',
|
|
76
|
+
'.kf_activePill{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--dsw-alias-state-success-primary,#4ade80)}',
|
|
77
|
+
'.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}',
|
|
78
|
+
'@keyframes kf_pulse{0%,100%{opacity:1}50%{opacity:.35}}',
|
|
79
|
+
'.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}',
|
|
80
|
+
'.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}',
|
|
81
|
+
'.kf_section::after{content:"";flex:1;height:1px;background:var(--dsw-alias-border-l1,rgba(128,128,128,.15))}',
|
|
82
|
+
'.kf_chips{display:flex;gap:7px;flex-wrap:wrap;align-items:center}',
|
|
83
|
+
'.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
84
|
'.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
|
-
'.
|
|
85
|
+
'.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)}',
|
|
86
|
+
'.kf_chip.on::before{content:"✓";font-size:10px}',
|
|
87
|
+
'.kf_customRow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
|
|
88
|
+
'.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}',
|
|
89
|
+
'.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)}',
|
|
90
|
+
'.kf_keys{display:flex;flex-direction:column;gap:7px}',
|
|
91
|
+
'.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}',
|
|
92
|
+
'.kf_keyRow.active{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#4ade80) 55%,transparent)}',
|
|
93
|
+
'.kf_statusDot{width:8px;height:8px;border-radius:50%;flex:none}',
|
|
94
|
+
'.kf_statusDot.live{background:#4ade80}',
|
|
95
|
+
'.kf_statusDot.cooling{background:#fbbf24;box-shadow:0 0 6px rgba(251,191,36,.6)}',
|
|
96
|
+
'.kf_statusDot.recovered{background:#38bdf8}',
|
|
97
|
+
'.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}',
|
|
98
|
+
'.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}',
|
|
99
|
+
'.kf_keyVal{flex:1;min-width:170px;max-width:100%;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;overflow-wrap:anywhere;word-break:break-all;line-height:1.5}',
|
|
100
|
+
'.kf_keyActions{display:flex;gap:6px;align-items:center;flex-wrap:wrap}',
|
|
101
|
+
'.kf_err{color:var(--dsw-alias-state-error-primary,#f87171);font-size:12px;line-height:1.5}',
|
|
102
|
+
'.kf_ok{color:var(--dsw-alias-state-success-primary,#4ade80);font-size:12px;line-height:1.5}',
|
|
103
|
+
'.kf_empty{font-size:13px;color:var(--dsw-alias-label-secondary,#888);padding:18px 4px;text-align:center}',
|
|
104
|
+
'.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
105
|
'.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
106
|
'.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
107
|
'@keyframes kf_spin{to{transform:rotate(360deg)}}',
|
|
108
|
+
'.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}',
|
|
109
|
+
'.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))}',
|
|
110
|
+
'.kf_iconBtn:disabled{opacity:.4;cursor:default}',
|
|
111
|
+
'.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;overflow-wrap:anywhere}',
|
|
91
112
|
].join('')
|
|
92
113
|
if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="@omdp/dsh-key-fallback"]')) {
|
|
93
114
|
var tag = document.createElement('style')
|
|
@@ -97,8 +118,6 @@ window.__ModuleLoader__.load({
|
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
function api(path, options) {
|
|
100
|
-
// 先进入 Promise 微任务:JSON.stringify、AbortController、fetch 的同步异常
|
|
101
|
-
// 也必须落到统一错误结果,不能让按钮卡在"保存中"。
|
|
102
121
|
return Promise.resolve().then(function () {
|
|
103
122
|
var url = '/dsh-key-fallback' + path
|
|
104
123
|
var opts = Object.assign({ method: 'GET' }, options || {})
|
|
@@ -106,15 +125,12 @@ window.__ModuleLoader__.load({
|
|
|
106
125
|
opts.body = JSON.stringify(opts.body)
|
|
107
126
|
opts.headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {})
|
|
108
127
|
}
|
|
109
|
-
// 8s 超时:避免请求挂起导致 UI 一直"保存中"
|
|
110
128
|
var ctrl = (typeof AbortController === 'function') ? new AbortController() : null
|
|
111
129
|
var timer = null
|
|
112
130
|
if (ctrl) {
|
|
113
131
|
opts.signal = ctrl.signal
|
|
114
132
|
timer = setTimeout(function () { ctrl.abort() }, 8000)
|
|
115
133
|
}
|
|
116
|
-
// Promise.race:即使宿主 fetch 忽略 AbortSignal,也保证 8s 内 settle,
|
|
117
|
-
// 避免保存/删除永久卡在“保存中”。
|
|
118
134
|
var timeoutP = new Promise(function (_, reject) {
|
|
119
135
|
var t = setTimeout(function () { reject(new Error('请求超时')) }, 8000)
|
|
120
136
|
if (typeof t === 'object' && t && typeof t.unref === 'function') t.unref()
|
|
@@ -127,28 +143,34 @@ window.__ModuleLoader__.load({
|
|
|
127
143
|
})
|
|
128
144
|
}
|
|
129
145
|
|
|
130
|
-
// ── 单个 key
|
|
146
|
+
// ── 单个 key 行:状态点 / 短名 / ref / 明文揭示 / 更新 / 删除 ──
|
|
131
147
|
function KeyRow(props) {
|
|
132
148
|
var k = props.entry
|
|
149
|
+
var isEnv = props.source === 'env'
|
|
133
150
|
var editing = React.useState(false)
|
|
134
151
|
var isEditing = editing[0]
|
|
135
152
|
var setEditing = editing[1]
|
|
136
153
|
var val = React.useState('')
|
|
137
154
|
var value = val[0]
|
|
138
155
|
var setValue = val[1]
|
|
156
|
+
var reveal = React.useState(false)
|
|
157
|
+
var isRevealed = reveal[0]
|
|
158
|
+
var setRevealed = reveal[1]
|
|
159
|
+
var plain = React.useState('')
|
|
160
|
+
var plainText = plain[0]
|
|
161
|
+
var setPlain = plain[1]
|
|
139
162
|
var busy = React.useState(false)
|
|
140
163
|
var isBusy = busy[0]
|
|
141
164
|
var setBusy = busy[1]
|
|
142
165
|
var err = React.useState('')
|
|
143
166
|
var setErr = err[1]
|
|
167
|
+
var revealing = React.useState(false)
|
|
168
|
+
var isRevealing = revealing[0]
|
|
169
|
+
var setRevealing = revealing[1]
|
|
170
|
+
|
|
144
171
|
function save() {
|
|
145
172
|
if (!value) { setErr('请输入 key'); return }
|
|
146
173
|
setBusy(true); setErr('')
|
|
147
|
-
try {
|
|
148
|
-
__kfReportDiag('save', 'PATCH start provider=' + props.provider + ' ref=')
|
|
149
|
-
} catch (e) {}
|
|
150
|
-
// 额外包一层 Promise:即使 api 被宿主替换或请求构造同步抛错,
|
|
151
|
-
// 也一定会进入 catch,按钮不会永久停在"保存中"。
|
|
152
174
|
Promise.resolve().then(function () {
|
|
153
175
|
return api('/keys', { method: 'PATCH', body: { provider: props.provider, ref: k.ref, value: value, label: k.label || '' } })
|
|
154
176
|
})
|
|
@@ -156,55 +178,77 @@ window.__ModuleLoader__.load({
|
|
|
156
178
|
if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
|
|
157
179
|
setValue(''); setEditing(false); props.onChanged()
|
|
158
180
|
})
|
|
159
|
-
.catch(function (e) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
181
|
+
.catch(function (e) { setErr((e && e.message) || String(e)) })
|
|
182
|
+
.then(function () { setBusy(false) })
|
|
183
|
+
}
|
|
184
|
+
function toggleReveal() {
|
|
185
|
+
if (isRevealed) { setRevealed(false); setPlain(''); return }
|
|
186
|
+
setRevealing(true); setErr('')
|
|
187
|
+
api('/keys/plain?provider=' + encodeURIComponent(props.provider) + '&ref=' + encodeURIComponent(k.ref))
|
|
188
|
+
.then(function (r) {
|
|
189
|
+
setRevealing(false)
|
|
190
|
+
if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
|
|
191
|
+
setPlain(r.body.value || ''); setRevealed(true)
|
|
165
192
|
})
|
|
193
|
+
.catch(function (e) { setRevealing(false); setErr(String(e && e.message || e)) })
|
|
166
194
|
}
|
|
167
|
-
var isEnv = props.source === 'env'
|
|
168
195
|
var st = k.status || (isEnv ? 'live' : '')
|
|
169
|
-
var nowMs = Date.now()
|
|
170
196
|
var cooling = k.cooldownRemainingMs > 0
|
|
171
|
-
var
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
ce('span', { className: '
|
|
197
|
+
var dotCls = isEnv ? 'live' : (cooling || st === 'cooling') ? 'cooling' : (st === 'recovered' ? 'recovered' : 'live')
|
|
198
|
+
var stLabel = isEnv ? '环境' : (cooling ? '冷却 ' + Math.ceil((k.cooldownRemainingMs || 0) / 1000) + 's' : (st === 'recovered' ? '已恢复' : '正常'))
|
|
199
|
+
return ce('div', { className: 'kf_keyRow' + (k.active ? ' active' : '') },
|
|
200
|
+
ce('span', { className: 'kf_statusDot ' + dotCls, title: stLabel }),
|
|
201
|
+
ce('span', { className: 'kf_keyName' },
|
|
202
|
+
k.name || (k.ref || '').slice(-8),
|
|
203
|
+
k.active ? ce('span', { className: 'kf_badge ok', style: { padding: '1px 7px', fontSize: 10.5 } }, '当前') : null,
|
|
178
204
|
),
|
|
179
|
-
|
|
180
|
-
|
|
205
|
+
ce('span', { className: 'kf_keyRef', title: k.ref }, k.ref),
|
|
206
|
+
isEnv && !isRevealed
|
|
207
|
+
? ce('span', { className: 'kf_envNote' },
|
|
208
|
+
props.pool.envSource === 'env' && props.pool.envWritable === false
|
|
209
|
+
? '由启动环境提供(只读)'
|
|
210
|
+
: '环境来源 · 可编辑/参与轮换',
|
|
211
|
+
)
|
|
181
212
|
: isEditing
|
|
182
|
-
? ce('
|
|
183
|
-
|
|
213
|
+
? ce('input', { className: 'kf_input kf_keyVal', type: 'text', autoComplete: 'off', spellCheck: false, placeholder: '粘贴新 key(只写,不回读)', value: value, onChange: function (e) { setValue(e.target.value) } })
|
|
214
|
+
: ce('span', { className: 'kf_keyVal' },
|
|
215
|
+
isRevealed ? plainText || '(空)' : '[hidden] 已保存',
|
|
216
|
+
),
|
|
217
|
+
ce('div', { className: 'kf_keyActions' },
|
|
218
|
+
isEnv || !stLabel ? null : ce('span', { className: 'kf_badge ' + ((cooling || st === 'cooling') ? 'warn' : (st === 'recovered' ? 'brand' : 'ok')), style: { padding: '1px 8px', fontSize: 10.5 } }, stLabel),
|
|
219
|
+
ce('button', { className: 'kf_iconBtn', title: isRevealed ? '隐藏明文' : '显示明文', disabled: isRevealing, onClick: toggleReveal }, isRevealing ? '…' : (isRevealed ? '🙈' : '👁')),
|
|
220
|
+
isEnv ? null : (isEditing
|
|
221
|
+
? ce('span', null,
|
|
184
222
|
ce('button', { className: 'kf_btn primary', disabled: isBusy, onClick: save }, isBusy ? '保存…' : '保存'),
|
|
185
223
|
ce('button', { className: 'kf_btn', onClick: function () { setEditing(false); setValue(''); setErr('') } }, '取消'),
|
|
186
224
|
)
|
|
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)) }),
|
|
225
|
+
: ce('button', { className: 'kf_btn', onClick: function () { setEditing(true) } }, '更新')),
|
|
226
|
+
ce('label', { className: 'kf_meta', style: { padding: '2px 8px', gap: 5, fontSize: 11.5, background: 'transparent', border: 'none' } },
|
|
227
|
+
'失败后→',
|
|
228
|
+
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 () {}) } },
|
|
229
|
+
ce('option', { value: '' }, '自动'),
|
|
230
|
+
(props.allRefs || []).filter(function (r) { return r !== k.ref }).map(function (r) { return ce('option', { key: r, value: r }, displayShort(r, props.allNames)) }),
|
|
196
231
|
),
|
|
197
232
|
),
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
233
|
+
props.currentRef === k.ref ? null : ce('button', { className: 'kf_btn primary', onClick: function () {
|
|
234
|
+
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 } })
|
|
235
|
+
.then(function (r) { if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return } props.onChanged() })
|
|
236
|
+
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
237
|
+
} }, '设为当前'),
|
|
238
|
+
isEnv ? null : ce('button', { className: 'kf_btn danger', onClick: function () { props.onDelete() } }, '删除'),
|
|
239
|
+
),
|
|
240
|
+
!k.lastErrorMsg ? null : ce('span', { className: 'kf_err', title: k.lastErrorMsg }, '上次错误: ' + k.lastErrorMsg.slice(0, 50)),
|
|
204
241
|
err ? ce('span', { className: 'kf_err' }, err) : null,
|
|
205
242
|
)
|
|
206
243
|
}
|
|
207
244
|
|
|
245
|
+
function displayShort(ref, names) {
|
|
246
|
+
if (names && names[ref]) return names[ref]
|
|
247
|
+
var m = /_key(\d+)$/.exec(ref || '')
|
|
248
|
+
if (m) return 'key' + m[1]
|
|
249
|
+
return (ref || '').slice(-8)
|
|
250
|
+
}
|
|
251
|
+
|
|
208
252
|
// ── 单个 provider 池卡片 ──
|
|
209
253
|
function PoolCard(props) {
|
|
210
254
|
var pool = props.pool
|
|
@@ -214,16 +258,31 @@ window.__ModuleLoader__.load({
|
|
|
214
258
|
var errSet = React.useState('')
|
|
215
259
|
var err = errSet[0]
|
|
216
260
|
var setErr = errSet[1]
|
|
217
|
-
var okSet = React.useState('')
|
|
218
|
-
var ok = okSet[0]
|
|
219
|
-
var setOk = okSet[1]
|
|
220
261
|
var newKey = React.useState('')
|
|
221
262
|
var newKeyVal = newKey[0]
|
|
222
263
|
var setNewKey = newKey[1]
|
|
223
264
|
var addBusy = React.useState(false)
|
|
224
265
|
var isAddBusy = addBusy[0]
|
|
225
266
|
var setAddBusy = addBusy[1]
|
|
267
|
+
var envEdit = React.useState('')
|
|
268
|
+
var envEditVal = envEdit[0]
|
|
269
|
+
var setEnvEdit = envEdit[1]
|
|
270
|
+
var envBusy = React.useState(false)
|
|
271
|
+
var isEnvBusy = envBusy[0]
|
|
272
|
+
var setEnvBusy = envBusy[1]
|
|
273
|
+
var customCode = React.useState('')
|
|
274
|
+
var customCodeVal = customCode[0]
|
|
275
|
+
var setCustomCode = customCode[1]
|
|
226
276
|
|
|
277
|
+
function savePool(patch) {
|
|
278
|
+
var body = Object.assign({ provider: pool.provider, enabled: pool.enabled, env: pool.env, cooldownMs: pool.cooldownMs, rotateOn: pool.rotateOn }, patch || {})
|
|
279
|
+
api('/pools', { method: 'POST', body: body })
|
|
280
|
+
.then(function (r) {
|
|
281
|
+
if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
|
|
282
|
+
props.onChanged()
|
|
283
|
+
})
|
|
284
|
+
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
285
|
+
}
|
|
227
286
|
function toggle() {
|
|
228
287
|
if (busy) return
|
|
229
288
|
setBusy(true); setErr('')
|
|
@@ -248,60 +307,121 @@ window.__ModuleLoader__.load({
|
|
|
248
307
|
.then(function (r) { if (r.ok) props.onChanged(); else setErr((r.body && r.body.error) || ('HTTP ' + r.status)) })
|
|
249
308
|
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
250
309
|
}
|
|
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
310
|
function deletePool() {
|
|
257
311
|
if (!confirm('确认删除「' + pool.provider + '」池(会清掉所有 key)?')) return
|
|
258
312
|
api('/pools?provider=' + encodeURIComponent(pool.provider), { method: 'DELETE' })
|
|
259
313
|
.then(function (r) { if (r.ok) props.onChanged(); else setErr((r.body && r.body.error) || ('HTTP ' + r.status)) })
|
|
260
314
|
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
261
315
|
}
|
|
316
|
+
function resetCooldown() {
|
|
317
|
+
api('/reset', { method: 'POST', body: { provider: pool.provider } })
|
|
318
|
+
.then(function (r) { if (r.ok) props.onChanged(); else setErr((r.body && r.body.error) || ('HTTP ' + r.status)) })
|
|
319
|
+
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
320
|
+
}
|
|
321
|
+
function toggleCode(code) {
|
|
322
|
+
var next = (pool.rotateOn || []).slice()
|
|
323
|
+
var i = next.indexOf(code)
|
|
324
|
+
if (i >= 0) next.splice(i, 1)
|
|
325
|
+
else next.push(code)
|
|
326
|
+
if (next.length === 0) next = ROTATE_CODES.slice()
|
|
327
|
+
savePool({ rotateOn: next })
|
|
328
|
+
}
|
|
329
|
+
function addCustomCode() {
|
|
330
|
+
var c = String(customCodeVal || '').trim().toUpperCase().replace(/\s+/g, '_')
|
|
331
|
+
if (!c) return
|
|
332
|
+
var next = (pool.rotateOn || []).slice()
|
|
333
|
+
if (next.indexOf(c) < 0) next.push(c)
|
|
334
|
+
setCustomCode('')
|
|
335
|
+
savePool({ rotateOn: next })
|
|
336
|
+
}
|
|
337
|
+
function saveEnvKey() {
|
|
338
|
+
if (!envEditVal) { setErr('请输入环境密钥'); return }
|
|
339
|
+
setEnvBusy(true); setErr('')
|
|
340
|
+
api('/keys', { method: 'PATCH', body: { provider: pool.provider, ref: pool.env, value: envEditVal } })
|
|
341
|
+
.then(function (r) {
|
|
342
|
+
setEnvBusy(false)
|
|
343
|
+
if (!r.ok) { setErr((r.body && r.body.error) || ('HTTP ' + r.status)); return }
|
|
344
|
+
setEnvEdit(''); props.onChanged()
|
|
345
|
+
})
|
|
346
|
+
.catch(function (e) { setEnvBusy(false); setErr(String(e && e.message || e)) })
|
|
347
|
+
}
|
|
348
|
+
var keys = Array.isArray(pool.keys) ? pool.keys : []
|
|
349
|
+
var userKeys = keys.filter(function (k) { return k.ref !== pool.env })
|
|
350
|
+
var allRefs = keys.map(function (x) { return x.ref })
|
|
351
|
+
var allNames = {}
|
|
352
|
+
keys.forEach(function (k) { allNames[k.ref] = k.name })
|
|
353
|
+
|
|
262
354
|
return ce('div', { className: 'kf_card' },
|
|
263
355
|
ce('div', { className: 'kf_cardHead' },
|
|
356
|
+
ce('span', { className: 'kf_avatar' }, (pool.displayName || pool.provider || '?').slice(0, 1)),
|
|
264
357
|
ce('div', { className: 'kf_name' },
|
|
265
358
|
pool.displayName || pool.provider,
|
|
266
359
|
ce('span', { className: 'kf_badge ' + (pool.enabled ? 'ok' : 'muted') }, pool.enabled ? '启用' : '停用'),
|
|
267
360
|
ce('span', { className: 'kf_badge muted' }, 'env: ' + pool.env),
|
|
268
361
|
),
|
|
269
362
|
ce('div', { style: { flex: 1 } }),
|
|
363
|
+
ce('button', { className: 'kf_btn', title: '重置冷却', onClick: resetCooldown }, '↺ 重置冷却'),
|
|
270
364
|
ce('div', { className: 'kf_switch' + (pool.enabled ? ' on' : ''), onClick: toggle, title: pool.enabled ? '停用' : '启用' }),
|
|
271
365
|
ce('button', { className: 'kf_btn danger', disabled: busy, onClick: deletePool }, '删除池'),
|
|
272
366
|
),
|
|
273
|
-
ce('div', {
|
|
274
|
-
ce('span', null, 'key 数: '
|
|
275
|
-
|
|
276
|
-
ce('span', null, '
|
|
367
|
+
ce('div', { className: 'kf_meta' },
|
|
368
|
+
ce('span', null, 'key 数: ', ce('b', null, keys.length)),
|
|
369
|
+
ce('span', null, 'cooldown: ', ce('b', null, pool.cooldownMs + ' ms')),
|
|
370
|
+
ce('span', null, '当前使用: ', pool.activeRef && pool.activeName
|
|
371
|
+
? ce('span', { className: 'kf_activePill' }, pool.activeName)
|
|
372
|
+
: ce('span', { className: 'kf_badge muted', style: { padding: '1px 8px', fontSize: 10.5 } }, '尚未使用')),
|
|
277
373
|
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;
|
|
374
|
+
ce('span', null, '锁定:'),
|
|
375
|
+
ce('select', { className: 'kf_sel', value: pool.useKeyRef || '_auto', onChange: function (e) { var v = e.target.value; savePool({ useKeyRef: (v === '_auto' ? '' : v) }) } },
|
|
280
376
|
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
|
-
}),
|
|
377
|
+
(keys).map(function (k) { return ce('option', { value: k.ref, key: k.ref }, (k.ref === pool.env ? '[环境] ' : '') + (k.name || k.ref.slice(-8))) }),
|
|
284
378
|
),
|
|
285
379
|
),
|
|
286
380
|
),
|
|
381
|
+
ce('div', { className: 'kf_section' }, '轮换触发码'),
|
|
287
382
|
ce('div', { className: 'kf_chips' },
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
return ce('
|
|
383
|
+
ROTATE_CODES.map(function (code) {
|
|
384
|
+
var on = (pool.rotateOn || []).indexOf(code) >= 0
|
|
385
|
+
return ce('button', { key: code, className: 'kf_chip' + (on ? ' on' : ''), title: codeLabel(code), onClick: function () { toggleCode(code) } }, codeLabel(code))
|
|
386
|
+
}),
|
|
387
|
+
),
|
|
388
|
+
ce('div', { className: 'kf_customRow' },
|
|
389
|
+
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() } }),
|
|
390
|
+
ce('button', { className: 'kf_btn ghost', onClick: addCustomCode }, '+ 添加'),
|
|
391
|
+
(pool.rotateOn || []).filter(function (c) { return ROTATE_CODES.indexOf(c) < 0 }).map(function (c) {
|
|
392
|
+
return ce('span', { key: c, className: 'kf_chip on' }, c)
|
|
291
393
|
}),
|
|
394
|
+
ce('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary,#9aa)' } }, '勾选=该错误发生后换下一把 key;换 key 与 DSH 的重试再发相互独立'),
|
|
292
395
|
),
|
|
293
|
-
(
|
|
396
|
+
ce('div', { className: 'kf_customRow', style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#778)', lineHeight: 1.6 } },
|
|
397
|
+
'补充:DSH 的重试插件负责「同一把 key 重发几次」,由各模型 provider 的 retryPolicy 决定;',
|
|
398
|
+
ce('br'),
|
|
399
|
+
'这里只决定「失败后是否切换到下一把 key」。预设 chips 已含 LlmError 标准码,下方输入框用于添加非标准/自定义错误码(与 provider 返回的 failure.code 精确匹配)。',
|
|
400
|
+
),
|
|
401
|
+
ce('div', { className: 'kf_section' }, '密钥链'),
|
|
402
|
+
keys.length === 0
|
|
294
403
|
? ce('div', { className: 'kf_empty' }, '还没有 key。在下方粘贴第一个。')
|
|
295
|
-
: ce('div', { className: 'kf_keys' },
|
|
296
|
-
|
|
404
|
+
: ce('div', { className: 'kf_keys' },
|
|
405
|
+
keys.map(function (k) {
|
|
406
|
+
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
407
|
}),
|
|
298
408
|
),
|
|
299
|
-
|
|
300
|
-
ce('
|
|
301
|
-
|
|
409
|
+
true
|
|
410
|
+
? ce('div', { className: 'kf_customRow', style: { background: 'var(--dsw-alias-bg-base,rgba(0,0,0,.1))', padding: '8px 10px', borderRadius: 10 } },
|
|
411
|
+
ce('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary,#9aa)', flex: '0 0 auto' } }, '更新环境密钥 (' + pool.env + '):'),
|
|
412
|
+
pool.envWritable === false
|
|
413
|
+
? ce('span', { className: 'kf_envNote', style: { border: 'none', padding: 0 } }, '由启动环境提供,只读(请在启动 DSH 的终端里修改)')
|
|
414
|
+
: ce('span', { style: { display: 'flex', flex: 1, gap: 8, flexWrap: 'wrap', minWidth: 220 } },
|
|
415
|
+
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() } }),
|
|
416
|
+
ce('button', { className: 'kf_btn primary', disabled: isEnvBusy, onClick: saveEnvKey }, isEnvBusy ? '保存…' : '保存'),
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
: null,
|
|
420
|
+
ce('div', { className: 'kf_customRow' },
|
|
421
|
+
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() } }),
|
|
422
|
+
ce('button', { className: 'kf_btn primary', disabled: isAddBusy, onClick: addKey }, isAddBusy ? '添加中…' : '+ 添加 key'),
|
|
302
423
|
),
|
|
303
424
|
err ? ce('div', { className: 'kf_err' }, err) : null,
|
|
304
|
-
ok ? ce('div', { className: 'kf_ok' }, ok) : null,
|
|
305
425
|
)
|
|
306
426
|
}
|
|
307
427
|
|
|
@@ -322,16 +442,21 @@ window.__ModuleLoader__.load({
|
|
|
322
442
|
var addingSet = React.useState(false)
|
|
323
443
|
var adding = addingSet[0]
|
|
324
444
|
var setAdding = addingSet[1]
|
|
445
|
+
var refreshing = React.useState(false)
|
|
446
|
+
var isRefreshing = refreshing[0]
|
|
447
|
+
var setRefreshing = refreshing[1]
|
|
325
448
|
|
|
326
449
|
function refresh() {
|
|
450
|
+
setRefreshing(true)
|
|
327
451
|
Promise.all([api('/pools'), api('/providers')])
|
|
328
452
|
.then(function (rs) {
|
|
453
|
+
setRefreshing(false)
|
|
329
454
|
if (!rs[0].ok) { setErr((rs[0].body && rs[0].body.error) || ('HTTP ' + rs[0].status)); return }
|
|
330
455
|
setPools(rs[0].body.pools || [])
|
|
331
456
|
var d = rs[1] && rs[1].body || {}; setDir((d && typeof d === 'object') ? d : {})
|
|
332
457
|
setErr('')
|
|
333
458
|
})
|
|
334
|
-
.catch(function (e) { setErr(String(e && e.message || e)) })
|
|
459
|
+
.catch(function (e) { setRefreshing(false); setErr(String(e && e.message || e)) })
|
|
335
460
|
}
|
|
336
461
|
React.useEffect(function () { refresh() }, [])
|
|
337
462
|
|
|
@@ -357,17 +482,20 @@ window.__ModuleLoader__.load({
|
|
|
357
482
|
return ce('div', { className: 'kf_root' },
|
|
358
483
|
ce('div', { className: 'kf_head' },
|
|
359
484
|
ce('div', null,
|
|
360
|
-
ce('h2', { className: 'kf_title' }, 'API Key 回退'),
|
|
485
|
+
ce('h2', { className: 'kf_title' }, ce('span', { className: 'kf_dot' }), 'API Key 回退'),
|
|
361
486
|
ce('p', { className: 'kf_sub' }, '为 LLM provider 维护多个 API key,请求失败时自动换下一个 key 重试。key 只写入本机 .credentials.yaml,不进设置文件。'),
|
|
362
487
|
),
|
|
363
|
-
|
|
488
|
+
ce('div', { className: 'kf_headRight' },
|
|
489
|
+
(pools || []).length === 0 ? ce('span', { className: 'kf_badge warn' }, '尚未启用') : ce('span', { className: 'kf_badge ok' }, (pools || []).length + ' 个池'),
|
|
490
|
+
ce('button', { className: 'kf_btn', disabled: isRefreshing, onClick: refresh }, isRefreshing ? '刷新…' : '↻ 刷新'),
|
|
491
|
+
),
|
|
364
492
|
),
|
|
365
493
|
ce('div', { className: 'kf_addrow' },
|
|
366
494
|
adding
|
|
367
|
-
? ce('div',
|
|
495
|
+
? ce('div', { style: { display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', width: '100%' } },
|
|
368
496
|
ce('label', null,
|
|
369
497
|
ce('span', null, '选择 LLM provider'),
|
|
370
|
-
ce('select', { value: pick, onChange: function (e) { setPick(e.target.value) } },
|
|
498
|
+
ce('select', { className: 'kf_sel', style: { fontSize: 13, padding: '7px 10px' }, value: pick, onChange: function (e) { setPick(e.target.value) } },
|
|
371
499
|
ce('option', { value: '' }, '— 选择 —'),
|
|
372
500
|
all.map(function (p) { return ce('option', { value: p.id, key: p.id }, p.name + '(' + p.id + ' · ' + p.hint + ')') }),
|
|
373
501
|
),
|
|
@@ -402,4 +530,4 @@ window.__ModuleLoader__.load({
|
|
|
402
530
|
exports.inject = ['slots']
|
|
403
531
|
return module.exports
|
|
404
532
|
},
|
|
405
|
-
})
|
|
533
|
+
})
|
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,77 @@ function sendJson(res, code, data) {
|
|
|
87
94
|
} catch (e) { try { res.end() } catch {} }
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
// ── 轮转触发语义(v6:rotateOn 真正生效)──
|
|
98
|
+
// 注意:rotateOn 决定「失败后是否换下一把 key」,与 DSH 的重试机制(dsh-llm-retry 按
|
|
99
|
+
// provider 的 retryPolicy.retryableCodes 决定是否用同一把 key 重发、重试几次)相互独立、互补:
|
|
100
|
+
// 同一 failure.code,重试插件决定重发,本插件决定换 key。所以这是两套开关,不存在谁覆盖谁。
|
|
101
|
+
// 预设 = LlmError 标准码(换 key 有意义的全集):QUOTA/AUTH/RATE_LIMIT/TIMEOUT/TRANSPORT/SERVER
|
|
102
|
+
// + EMPTY_RESPONSE/INVALID_CREDENTIAL。ABORTED(用户取消)永远不轮换。
|
|
103
|
+
const DEFAULT_ROTATE_ON = ['QUOTA', 'AUTH', 'RATE_LIMIT', 'TIMEOUT', 'TRANSPORT', 'SERVER', 'EMPTY_RESPONSE', 'INVALID_CREDENTIAL']
|
|
104
|
+
function canonicalCode(c) {
|
|
105
|
+
const s = String(c || '').toUpperCase()
|
|
106
|
+
if (s === 'QUOTA_EXCEEDED' || s === 'QUOTA') return 'QUOTA'
|
|
107
|
+
return s
|
|
108
|
+
}
|
|
109
|
+
// 读取池配置时规范化 rotateOn;空数组/未配置 → 返回默认超集。
|
|
110
|
+
function effectiveRotateOn(stored) {
|
|
111
|
+
const norm = (Array.isArray(stored) ? stored : []).map(canonicalCode).filter(Boolean)
|
|
112
|
+
if (norm.length === 0) return [...DEFAULT_ROTATE_ON]
|
|
113
|
+
return norm
|
|
114
|
+
}
|
|
115
|
+
const CODE_KEYWORDS = {
|
|
116
|
+
QUOTA: /quota|insufficient|balance|credit|budget|exhaust|billing|limit exceeded/i,
|
|
117
|
+
AUTH: /401|403|auth|credential|denied|forbidden|invalid key|api key|unauthor|token/i,
|
|
118
|
+
RATE_LIMIT: /429|rate|limit|throttl|too many|try again later|concurr|busy/i,
|
|
119
|
+
TIMEOUT: /timeout|timed ?out|deadline|gateway timeout/i,
|
|
120
|
+
TRANSPORT: /transport|network|econn|socket|eai|dns|fetch failed|connection|closed|reset|unreachable/i,
|
|
121
|
+
SERVER: /5\d\d|server error|internal|unavailable|502|503|504|bad gateway|overload/i,
|
|
122
|
+
EMPTY_RESPONSE: /empty response|no content|empty reply/i,
|
|
123
|
+
INVALID_CREDENTIAL: /invalid credential|malformed|bad credential|invalid api key/i,
|
|
124
|
+
}
|
|
125
|
+
function shouldRotate(pool, code, rawMsg, status) {
|
|
126
|
+
const triggers = effectiveRotateOn(pool.cfg.rotateOn)
|
|
127
|
+
if (triggers.includes(code)) return true
|
|
128
|
+
const s = Number(status)
|
|
129
|
+
if (Number.isFinite(s) && s >= 400 && s <= 599) {
|
|
130
|
+
const mapped = s === 429 ? 'RATE_LIMIT' : (s === 401 || s === 403 ? 'AUTH' : (s === 402 ? 'QUOTA' : (s >= 500 ? 'SERVER' : 'AUTH')))
|
|
131
|
+
if (triggers.includes(mapped)) return true
|
|
132
|
+
}
|
|
133
|
+
for (const t of triggers) {
|
|
134
|
+
const rx = CODE_KEYWORDS[t]
|
|
135
|
+
if (rx && rx.test(rawMsg)) return true
|
|
136
|
+
}
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── key 短命名 ──
|
|
141
|
+
function safeProvider(provider) {
|
|
142
|
+
return String(provider || '').replace(/[^A-Za-z0-9_]/g, '_')
|
|
143
|
+
}
|
|
144
|
+
function isShortRef(ref) {
|
|
145
|
+
return /^key_fallback_[A-Za-z0-9_]+_key\d+$/.test(ref)
|
|
146
|
+
}
|
|
147
|
+
function shortRefOf(provider, n) {
|
|
148
|
+
return 'key_fallback_' + safeProvider(provider) + '_key' + n
|
|
149
|
+
}
|
|
150
|
+
// 为新增 key 分配下一个空闲短 ref(key1、key2…)
|
|
151
|
+
function nextKeyRef(provider, keyRefs) {
|
|
152
|
+
const used = new Set((Array.isArray(keyRefs) ? keyRefs : []).map((e) => e && e.ref).filter(Boolean))
|
|
153
|
+
let n = 1
|
|
154
|
+
while (used.has(shortRefOf(provider, n))) n++
|
|
155
|
+
return shortRefOf(provider, n)
|
|
156
|
+
}
|
|
157
|
+
// 从 ref 派生显示名(label 优先,否则 key_fallback_x_keyN → keyN)
|
|
158
|
+
function displayNameOf(entry) {
|
|
159
|
+
if (entry && entry.label) return entry.label
|
|
160
|
+
const ref = (entry && entry.ref) || ''
|
|
161
|
+
const m = /_key(\d+)$/.exec(ref)
|
|
162
|
+
if (m) return 'key' + m[1]
|
|
163
|
+
return ref
|
|
164
|
+
}
|
|
91
165
|
|
|
92
166
|
export function apply(ctx) {
|
|
93
|
-
try { ctx.logger.info('key-fallback:
|
|
167
|
+
try { ctx.logger.info('key-fallback: v6 host apply() starting (config = settings.yaml)') } catch (e) {}
|
|
94
168
|
const diagLog = []
|
|
95
169
|
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
170
|
diag('apply', 'host loaded')
|
|
@@ -105,9 +179,9 @@ export function apply(ctx) {
|
|
|
105
179
|
try { result = await rawResolve(ref) } catch (e) { result = undefined }
|
|
106
180
|
const refName = String(ref || '')
|
|
107
181
|
const pool = poolsByEnv.get(refName)
|
|
108
|
-
if (pool && pool.currentRef) {
|
|
182
|
+
if (pool && pool.cfg && pool.cfg.enabled !== false && pool.currentRef) {
|
|
109
183
|
const cur = pool.keyValues.find((k) => k.ref === pool.currentRef)
|
|
110
|
-
if (cur) { diag('resolve', 'pool-hit ' + refName + ' -> ' + cur.ref
|
|
184
|
+
if (cur) { diag('resolve', 'pool-hit ' + refName + ' -> ' + cur.ref); return { value: cur.value, source: 'pool' } }
|
|
111
185
|
}
|
|
112
186
|
return result
|
|
113
187
|
}
|
|
@@ -120,6 +194,8 @@ export function apply(ctx) {
|
|
|
120
194
|
function readPools() {
|
|
121
195
|
const cfg = readSettings()
|
|
122
196
|
const providers = (cfg && cfg.providers) || {}
|
|
197
|
+
// 每次全量重建 env 索引:删除池/改 env 后不得残留旧映射(否则 wrap 会命中已删池)
|
|
198
|
+
poolsByEnv.clear()
|
|
123
199
|
for (const name in providers) {
|
|
124
200
|
if (!pools.has(name)) pools.set(name, { cfg: providers[name], keyValues: [], cursor: 0, currentRef: '' })
|
|
125
201
|
else pools.get(name).cfg = providers[name]
|
|
@@ -129,11 +205,59 @@ export function apply(ctx) {
|
|
|
129
205
|
for (const name of [...pools.keys()]) if (!providers[name]) pools.delete(name)
|
|
130
206
|
}
|
|
131
207
|
|
|
132
|
-
|
|
208
|
+
// 一次性迁移旧长 ref → 短 ref(key_fallback_x_keyN)。幂等:迁移后长 ref 不存在即自然短路。
|
|
209
|
+
// 顺序:先 set 新 ref(从旧 ref 读值)→ 改 settings → 再 unset 旧 ref;任一步失败即中止,保留旧状态可重试。
|
|
210
|
+
// 注意:不依赖 refsCompacted 短路——空池创建会先置标记,若之后外部导入旧格式长 ref 仍必须迁移。
|
|
211
|
+
async function compactRefs(provider, prof) {
|
|
212
|
+
const entries = (prof.keyRefs || []).map((e) => {
|
|
213
|
+
if (typeof e === 'string') return { ref: e, label: '', createdAt: 0, nextRef: '' }
|
|
214
|
+
return { ref: e.ref || '', label: e.label || '', createdAt: e.createdAt || 0, nextRef: e.nextRef || '' }
|
|
215
|
+
})
|
|
216
|
+
const longOnes = entries.filter((e) => e.ref && e.ref.startsWith('key_fallback_') && !isShortRef(e.ref))
|
|
217
|
+
if (longOnes.length === 0) return false
|
|
218
|
+
const oldToNew = new Map()
|
|
219
|
+
const used = new Set(entries.map((e) => e.ref).filter(isShortRef))
|
|
220
|
+
let n = 1
|
|
221
|
+
for (const e of longOnes) {
|
|
222
|
+
while (used.has(shortRefOf(provider, n))) n++
|
|
223
|
+
oldToNew.set(e.ref, shortRefOf(provider, n))
|
|
224
|
+
used.add(shortRefOf(provider, n))
|
|
225
|
+
n++
|
|
226
|
+
}
|
|
227
|
+
// 1) 写新 ref(读旧值)
|
|
228
|
+
for (const [oldRef, newRef] of oldToNew) {
|
|
229
|
+
let hit
|
|
230
|
+
try { hit = rawResolve ? await rawResolve(credentialRef(oldRef)) : await ctx.credentials.resolve(credentialRef(oldRef)) } catch (e) { hit = undefined }
|
|
231
|
+
if (!hit || !hit.value) return false
|
|
232
|
+
try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.set(credentialRef(newRef), hit.value) } catch (e) { return false }
|
|
233
|
+
}
|
|
234
|
+
// 2) 更新 settings 引用
|
|
235
|
+
for (const e of entries) {
|
|
236
|
+
if (e.ref && oldToNew.has(e.ref)) e.ref = oldToNew.get(e.ref)
|
|
237
|
+
if (e.nextRef && oldToNew.has(e.nextRef)) e.nextRef = oldToNew.get(e.nextRef)
|
|
238
|
+
}
|
|
239
|
+
if (prof.useKeyRef && oldToNew.has(prof.useKeyRef)) prof.useKeyRef = oldToNew.get(prof.useKeyRef)
|
|
240
|
+
prof.keyRefs = entries
|
|
241
|
+
prof.refsCompacted = true
|
|
242
|
+
// 3) unset 旧 ref(best-effort)
|
|
243
|
+
for (const oldRef of oldToNew.keys()) { try { await ctx.credentials.unset(credentialRef(oldRef)) } catch (e) {} }
|
|
244
|
+
return true
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function resolvePoolKeys(pool, providerName) {
|
|
248
|
+
// 迁移旧 ref(一次),成功后持久化 settings —— 用与 pool.cfg 同一对象的 settings 块
|
|
249
|
+
const cfg = readSettings()
|
|
250
|
+
const providers = cfg.providers || {}
|
|
251
|
+
// 让 pool.cfg 指向本次解析的树(之前 readPools 解析的是旧树,改它无法写回)
|
|
252
|
+
if (providers[providerName]) pool.cfg = providers[providerName]
|
|
253
|
+
let compacted = false
|
|
254
|
+
if (pool.cfg) { try { compacted = await compactRefs(providerName, pool.cfg) } catch (e) { compacted = false } }
|
|
255
|
+
if (compacted) writeSettings(cfg)
|
|
133
256
|
// 保留已有状态(failCount/cooldown/lastError),避免重建时丢状态
|
|
134
257
|
const prev = new Map(pool.keyValues.map((k) => [k.ref, k]))
|
|
135
258
|
pool.keyValues = []
|
|
136
|
-
|
|
259
|
+
const entries = (pool.cfg.keyRefs || []).map((e) => typeof e === 'string' ? { ref: e, label: '', createdAt: 0, nextRef: '' } : e)
|
|
260
|
+
for (const entry of entries) {
|
|
137
261
|
const ref = (entry && typeof entry === 'object') ? entry.ref : entry
|
|
138
262
|
const nextRef = (entry && typeof entry === 'object' && entry.nextRef) ? entry.nextRef : ''
|
|
139
263
|
if (!ref) continue
|
|
@@ -142,7 +266,7 @@ export function apply(ctx) {
|
|
|
142
266
|
if (hit && hit.value) {
|
|
143
267
|
const old = prev.get(ref) || {}
|
|
144
268
|
pool.keyValues.push({
|
|
145
|
-
ref, value: hit.value, source: 'user',
|
|
269
|
+
ref, value: hit.value, source: 'user', label: (entry && entry.label) || '',
|
|
146
270
|
nextRef,
|
|
147
271
|
failCount: old.failCount || 0,
|
|
148
272
|
cooldownUntil: old.cooldownUntil || 0,
|
|
@@ -164,7 +288,7 @@ export function apply(ctx) {
|
|
|
164
288
|
const found = pool.keyValues.find((k) => k.ref === envRef)
|
|
165
289
|
if (!found) {
|
|
166
290
|
pool.keyValues.push({
|
|
167
|
-
ref: envRef, value: hit.value, source: 'env',
|
|
291
|
+
ref: envRef, value: hit.value, source: 'env', label: '',
|
|
168
292
|
nextRef: '',
|
|
169
293
|
failCount: old.failCount || 0,
|
|
170
294
|
cooldownUntil: old.cooldownUntil || 0,
|
|
@@ -182,7 +306,7 @@ export function apply(ctx) {
|
|
|
182
306
|
readPools()
|
|
183
307
|
const pool = pools.get(provider)
|
|
184
308
|
if (!pool) return undefined
|
|
185
|
-
await resolvePoolKeys(pool)
|
|
309
|
+
await resolvePoolKeys(pool, provider)
|
|
186
310
|
return pool
|
|
187
311
|
}
|
|
188
312
|
|
|
@@ -235,6 +359,15 @@ export function apply(ctx) {
|
|
|
235
359
|
return undefined
|
|
236
360
|
}
|
|
237
361
|
|
|
362
|
+
// 把 useKeyRef 选择立即落到内存池 + process.env,让「当前使用」立刻真实
|
|
363
|
+
function applySelection(pool, ref) {
|
|
364
|
+
if (!pool || !ref) return
|
|
365
|
+
const kv = pool.keyValues.find((k) => k.ref === ref)
|
|
366
|
+
if (!kv) return
|
|
367
|
+
pool.currentRef = ref
|
|
368
|
+
try { process.env[pool.cfg.env] = kv.value } catch (e) {}
|
|
369
|
+
}
|
|
370
|
+
|
|
238
371
|
// ── 预写:v1 同款 —— 全局 agent/request(DSH 全局收得到这个 waterfall),
|
|
239
372
|
// 每次请求同步 buildPools() 重读 settings,选 key,同步写 process.env,
|
|
240
373
|
// credentials.set fire-and-forget(不 await 阻塞 waterfall)。
|
|
@@ -243,11 +376,13 @@ export function apply(ctx) {
|
|
|
243
376
|
for (const name of pools.keys()) {
|
|
244
377
|
const pool = pools.get(name)
|
|
245
378
|
if (pool) {
|
|
246
|
-
try { await resolvePoolKeys(pool) } catch (e) {}
|
|
247
|
-
// 预热时确定初始 currentRef:useKeyRef 锁定优先,否则 pickKey
|
|
379
|
+
try { await resolvePoolKeys(pool, name) } catch (e) {}
|
|
380
|
+
// 预热时确定初始 currentRef:useKeyRef 锁定优先,否则 pickKey(只调一次)
|
|
248
381
|
if (pool.keyValues.length > 0 && !pool.currentRef) {
|
|
249
382
|
const locked = pool.cfg.useKeyRef && pool.keyValues.find((k) => k.ref === pool.cfg.useKeyRef)
|
|
250
|
-
|
|
383
|
+
let picked = null
|
|
384
|
+
if (!(locked && locked.ref)) picked = pickKey(pool)
|
|
385
|
+
pool.currentRef = (locked && locked.ref) || (picked && picked.ref) || pool.keyValues[0].ref
|
|
251
386
|
}
|
|
252
387
|
}
|
|
253
388
|
}
|
|
@@ -255,7 +390,6 @@ export function apply(ctx) {
|
|
|
255
390
|
|
|
256
391
|
// 启动时预热 pools(resolve keyValues,供同步预写 pickKey 用)
|
|
257
392
|
try { warmPools().catch(() => {}) } catch (e) {}
|
|
258
|
-
// 每次 settings 变化也刷新 —— webServer 路由里已有调用,这里兜底周期不设
|
|
259
393
|
|
|
260
394
|
ctx.on('agent/request', (payload, next) => {
|
|
261
395
|
// next() 是 async,但我们要同步写 env —— 用 then 链,立即返回 p 不阻塞 waterfall
|
|
@@ -286,12 +420,13 @@ export function apply(ctx) {
|
|
|
286
420
|
ctx.on('agent/request-error', async (payload, next) => {
|
|
287
421
|
const code = String((payload && payload.failure && payload.failure.code) || (payload && payload.code) || '')
|
|
288
422
|
const rawMsg = String((payload && payload.failure && payload.failure.message) || (payload && payload.message) || '')
|
|
423
|
+
const status = (payload && payload.failure && payload.failure.status) || (payload && payload.status)
|
|
289
424
|
const provider = (payload && payload.provider) || ''
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
diag('request-error', 'provider=' + provider + ' code=' + code)
|
|
425
|
+
if (!provider) return next()
|
|
426
|
+
diag('request-error', 'provider=' + provider + ' code=' + code + ' status=' + status)
|
|
293
427
|
const pool = await getPool(provider)
|
|
294
|
-
if (!pool || pool.keyValues.length === 0) return next()
|
|
428
|
+
if (!pool || pool.keyValues.length === 0 || pool.cfg.enabled === false) return next()
|
|
429
|
+
if (!shouldRotate(pool, canonicalCode(code), rawMsg, status)) return next()
|
|
295
430
|
let curRef = pool.currentRef
|
|
296
431
|
if (!curRef) {
|
|
297
432
|
const envCur = process.env[pool.cfg.env]
|
|
@@ -329,7 +464,11 @@ export function apply(ctx) {
|
|
|
329
464
|
req.on('error', reject)
|
|
330
465
|
})
|
|
331
466
|
|
|
332
|
-
if (path === 'diag' && req.method === 'GET') {
|
|
467
|
+
if (path === 'diag' && (req.method === 'GET' || req.method === 'POST')) {
|
|
468
|
+
if (req.method === 'POST') {
|
|
469
|
+
try { const b = await readBody(); diag(b.kind || 'client', b.message || b.msg || '') } catch (e) {}
|
|
470
|
+
return sendJson(res, 200, { ok: true })
|
|
471
|
+
}
|
|
333
472
|
return sendJson(res, 200, { diag: diagLog })
|
|
334
473
|
}
|
|
335
474
|
|
|
@@ -355,13 +494,32 @@ export function apply(ctx) {
|
|
|
355
494
|
const out = []
|
|
356
495
|
readPools()
|
|
357
496
|
for (const [name, pool] of pools) {
|
|
358
|
-
await resolvePoolKeys(pool)
|
|
497
|
+
await resolvePoolKeys(pool, name)
|
|
359
498
|
const now = Date.now()
|
|
499
|
+
// 真正当前使用的 key:最近一次预写进 process.env 的值
|
|
500
|
+
let activeRef = ''
|
|
501
|
+
if (pool.cfg.enabled !== false) {
|
|
502
|
+
const envCur = process.env[pool.cfg.env]
|
|
503
|
+
if (envCur) { const m = pool.keyValues.find((k) => k.value === envCur); if (m) activeRef = m.ref }
|
|
504
|
+
if (!activeRef && pool.currentRef) activeRef = pool.currentRef
|
|
505
|
+
}
|
|
506
|
+
// 环境密钥可写性(描述:文件=可写,启动环境=只读)
|
|
507
|
+
let envWritable = null
|
|
508
|
+
let envSource = ''
|
|
509
|
+
try {
|
|
510
|
+
if (ctx.credentials.describe) {
|
|
511
|
+
const d = await ctx.credentials.describe(credentialRef(pool.cfg.env))
|
|
512
|
+
envWritable = d ? d.writable !== false : null
|
|
513
|
+
envSource = d ? (d.source || '') : ''
|
|
514
|
+
}
|
|
515
|
+
} catch (e) {}
|
|
360
516
|
const keys = pool.keyValues.map((k) => {
|
|
361
517
|
const cooling = (k.cooldownUntil || 0) > now
|
|
518
|
+
const entry = (pool.cfg.keyRefs || []).find((x) => (x && x.ref) === k.ref) || {}
|
|
362
519
|
return {
|
|
363
520
|
ref: k.ref,
|
|
364
|
-
label:
|
|
521
|
+
name: displayNameOf({ label: k.label || entry.label, ref: k.ref }),
|
|
522
|
+
label: k.label || entry.label || '',
|
|
365
523
|
source: k.source,
|
|
366
524
|
nextRef: k.nextRef || '',
|
|
367
525
|
status: cooling ? 'cooling' : (k.failCount ? 'recovered' : 'live'),
|
|
@@ -370,6 +528,7 @@ export function apply(ctx) {
|
|
|
370
528
|
cooldownRemainingMs: cooling ? (k.cooldownUntil - now) : 0,
|
|
371
529
|
lastErrorAt: k.lastErrorAt || 0,
|
|
372
530
|
lastErrorMsg: k.lastErrorMsg || '',
|
|
531
|
+
active: k.ref === activeRef,
|
|
373
532
|
}
|
|
374
533
|
})
|
|
375
534
|
out.push({
|
|
@@ -378,10 +537,14 @@ export function apply(ctx) {
|
|
|
378
537
|
env: pool.cfg.env,
|
|
379
538
|
cooldownMs: pool.cfg.cooldownMs,
|
|
380
539
|
enabled: pool.cfg.enabled,
|
|
381
|
-
rotateOn: pool.cfg.rotateOn
|
|
540
|
+
rotateOn: effectiveRotateOn(pool.cfg.rotateOn),
|
|
382
541
|
useKeyRef: pool.cfg.useKeyRef || '',
|
|
383
542
|
currentRef: pool.currentRef || '',
|
|
543
|
+
activeRef: activeRef,
|
|
544
|
+
activeName: activeRef ? (keys.find((k) => k.ref === activeRef) || {}).name || activeRef : '',
|
|
384
545
|
envKeyValue: pool.envKeyValue ? mask(pool.envKeyValue) : '',
|
|
546
|
+
envWritable: envWritable,
|
|
547
|
+
envSource: envSource,
|
|
385
548
|
keys: keys,
|
|
386
549
|
})
|
|
387
550
|
}
|
|
@@ -400,18 +563,29 @@ export function apply(ctx) {
|
|
|
400
563
|
env: envName,
|
|
401
564
|
enabled: enabled !== false,
|
|
402
565
|
cooldownMs: Number.isFinite(cooldownMs) && cooldownMs >= 0 ? cooldownMs : (cur.cooldownMs ?? 30000),
|
|
403
|
-
rotateOn: Array.isArray(rotateOn) && rotateOn.length > 0 ? rotateOn : (cur.rotateOn
|
|
566
|
+
rotateOn: Array.isArray(rotateOn) && rotateOn.length > 0 ? rotateOn.map(canonicalCode).filter(Boolean) : effectiveRotateOn(cur.rotateOn),
|
|
404
567
|
keyRefs: (cur.keyRefs || []).map((x) => {
|
|
405
568
|
if (typeof x === 'string') return { ref: x, label: '', createdAt: 0, nextRef: '' }
|
|
406
569
|
return { ref: x.ref, label: x.label || '', createdAt: x.createdAt || 0, nextRef: x.nextRef || '' }
|
|
407
570
|
}),
|
|
408
571
|
displayName: (typeof displayName === 'string' && displayName) ? displayName : (cur.displayName || provider),
|
|
409
572
|
useKeyRef: (typeof useKeyRef === 'string') ? useKeyRef : (cur.useKeyRef || ''),
|
|
573
|
+
refsCompacted: cur.refsCompacted === true,
|
|
410
574
|
}
|
|
411
575
|
let validated
|
|
412
576
|
try { validated = profileSchema(upd) } catch (e) { return sendJson(res, 400, { error: 'invalid profile: ' + (e && e.message || e) }) }
|
|
413
577
|
cfg.providers[provider] = validated
|
|
414
578
|
writeSettings(cfg)
|
|
579
|
+
// 同步内存池 + env,让「当前使用」立刻真实
|
|
580
|
+
try {
|
|
581
|
+
const pool = await getPool(provider)
|
|
582
|
+
if (pool) {
|
|
583
|
+
if (validated.useKeyRef) applySelection(pool, validated.useKeyRef)
|
|
584
|
+
else if (pool.currentRef && !pool.keyValues.find((k) => k.ref === pool.currentRef && isLive(k))) {
|
|
585
|
+
const pk = pickKey(pool); if (pk) { pool.currentRef = pk.ref; try { process.env[pool.cfg.env] = pk.value } catch (e) {} }
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
} catch (e) {}
|
|
415
589
|
return sendJson(res, 200, { ok: true, pool: validated })
|
|
416
590
|
}
|
|
417
591
|
if (req.method === 'DELETE') {
|
|
@@ -423,6 +597,7 @@ export function apply(ctx) {
|
|
|
423
597
|
for (const k of (prof.keyRefs || [])) { try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.unset(credentialRef(k.ref || k)) } catch (e) {} }
|
|
424
598
|
delete cfg.providers[provider]
|
|
425
599
|
writeSettings(cfg)
|
|
600
|
+
pools.delete(provider)
|
|
426
601
|
return sendJson(res, 200, { ok: true })
|
|
427
602
|
}
|
|
428
603
|
return sendJson(res, 405, { error: 'method not allowed' })
|
|
@@ -437,28 +612,49 @@ export function apply(ctx) {
|
|
|
437
612
|
const cfg = readSettings()
|
|
438
613
|
if (!cfg.providers || !cfg.providers[provider]) return sendJson(res, 404, { error: 'pool not found' })
|
|
439
614
|
const prof = cfg.providers[provider]
|
|
440
|
-
const newRef =
|
|
615
|
+
const newRef = nextKeyRef(provider, prof.keyRefs || [])
|
|
441
616
|
try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.set(credentialRef(newRef), value) } catch (e) { return sendJson(res, 500, { error: 'credentials write failed' }) }
|
|
442
617
|
const newEntry = { ref: newRef, label: (typeof label === 'string' && label) ? label : '', createdAt: Date.now(), nextRef: '' }
|
|
443
618
|
prof.keyRefs = [...(prof.keyRefs || []), newEntry]
|
|
444
619
|
writeSettings(cfg)
|
|
620
|
+
// 刷新内存池,让新 key 立即可参与轮换(幂等;已 compacted 则快速返回)
|
|
621
|
+
try { await getPool(provider) } catch (e) {}
|
|
445
622
|
return sendJson(res, 200, { ok: true, key: newEntry, valueMask: mask(value) })
|
|
446
623
|
}
|
|
447
624
|
if (req.method === 'PATCH') {
|
|
448
625
|
const body = await readBody()
|
|
449
|
-
const { provider, ref, value, label, nextRef } = body || {}
|
|
626
|
+
const { provider, ref, value, label, nextRef, useKeyRef } = body || {}
|
|
450
627
|
if (typeof provider !== 'string' || !provider || typeof ref !== 'string' || !ref) return sendJson(res, 400, { error: 'provider & ref required' })
|
|
451
628
|
const cfg = readSettings()
|
|
452
629
|
const prof = cfg.providers && cfg.providers[provider]
|
|
453
630
|
if (!prof) return sendJson(res, 404, { error: 'pool not found' })
|
|
454
631
|
const entry = (prof.keyRefs || []).find((k) => k.ref === ref)
|
|
455
|
-
|
|
632
|
+
const isEnvKey = ref === prof.env
|
|
633
|
+
if (!entry && !isEnvKey) return sendJson(res, 404, { error: 'key not found' })
|
|
456
634
|
if (typeof value === 'string' && value) {
|
|
457
|
-
|
|
635
|
+
// 环境密钥:允许改写 .credentials.yaml(describe 可写才允许;启动环境提供=只读)
|
|
636
|
+
if (isEnvKey) {
|
|
637
|
+
let writable = null
|
|
638
|
+
try { if (ctx.credentials.describe) { const d = await ctx.credentials.describe(credentialRef(ref)); writable = d ? d.writable !== false : null } } catch (e) {}
|
|
639
|
+
if (writable === false) return sendJson(res, 400, { error: '该环境密钥由启动环境提供(只读);请改启动 DSH 的终端环境变量' })
|
|
640
|
+
}
|
|
641
|
+
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) }) }
|
|
642
|
+
// 同步内存池 + env
|
|
643
|
+
try {
|
|
644
|
+
const pool = pools.get(provider)
|
|
645
|
+
if (pool) {
|
|
646
|
+
const kv = pool.keyValues.find((k) => k.ref === ref)
|
|
647
|
+
if (kv) kv.value = value
|
|
648
|
+
if (isEnvKey) pool.envKeyValue = value
|
|
649
|
+
}
|
|
650
|
+
} catch (e) {}
|
|
651
|
+
}
|
|
652
|
+
if (typeof label === 'string') { if (entry) entry.label = label }
|
|
653
|
+
if (typeof nextRef === 'string') { if (entry) entry.nextRef = nextRef }
|
|
654
|
+
if (typeof useKeyRef === 'string') {
|
|
655
|
+
prof.useKeyRef = useKeyRef
|
|
656
|
+
try { const pool = await getPool(provider); if (pool) applySelection(pool, useKeyRef) } catch (e) {}
|
|
458
657
|
}
|
|
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
658
|
writeSettings(cfg)
|
|
463
659
|
return sendJson(res, 200, { ok: true })
|
|
464
660
|
}
|
|
@@ -469,6 +665,7 @@ export function apply(ctx) {
|
|
|
469
665
|
const cfg = readSettings()
|
|
470
666
|
const prof = cfg.providers && cfg.providers[provider]
|
|
471
667
|
if (!prof) return sendJson(res, 404, { error: 'pool not found' })
|
|
668
|
+
if (ref === prof.env) return sendJson(res, 400, { error: '环境密钥不可删除' })
|
|
472
669
|
const idx = (prof.keyRefs || []).findIndex((k) => k.ref === ref)
|
|
473
670
|
if (idx < 0) return sendJson(res, 404, { error: 'key not found' })
|
|
474
671
|
try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.unset(credentialRef(ref)) } catch (e) {}
|
|
@@ -477,11 +674,35 @@ export function apply(ctx) {
|
|
|
477
674
|
// 清理指向已删 key 的 nextRef
|
|
478
675
|
for (const k of (prof.keyRefs || [])) if (k.nextRef === ref) k.nextRef = ''
|
|
479
676
|
writeSettings(cfg)
|
|
677
|
+
// 同步内存池
|
|
678
|
+
try {
|
|
679
|
+
const pool = pools.get(provider)
|
|
680
|
+
if (pool) {
|
|
681
|
+
pool.keyValues = pool.keyValues.filter((k) => k.ref !== ref)
|
|
682
|
+
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) {} }
|
|
683
|
+
}
|
|
684
|
+
} catch (e) {}
|
|
480
685
|
return sendJson(res, 200, { ok: true })
|
|
481
686
|
}
|
|
482
687
|
return sendJson(res, 405, { error: 'method not allowed' })
|
|
483
688
|
}
|
|
484
689
|
|
|
690
|
+
// 明文揭示:仅限本池拥有的 user key 或环境密钥
|
|
691
|
+
if (path === 'keys/plain' && req.method === 'GET') {
|
|
692
|
+
const provider = url.searchParams.get('provider')
|
|
693
|
+
const ref = url.searchParams.get('ref')
|
|
694
|
+
if (!provider || !ref) return sendJson(res, 400, { error: 'provider & ref required' })
|
|
695
|
+
const pool = await getPool(provider)
|
|
696
|
+
if (!pool) return sendJson(res, 404, { error: 'pool not found' })
|
|
697
|
+
const isUserKey = (pool.cfg.keyRefs || []).some((k) => k.ref === ref)
|
|
698
|
+
const isEnvKey = ref === pool.cfg.env
|
|
699
|
+
if (!isUserKey && !isEnvKey) return sendJson(res, 404, { error: 'key not found' })
|
|
700
|
+
let hit
|
|
701
|
+
try { hit = rawResolve ? await rawResolve(credentialRef(ref)) : await ctx.credentials.resolve(credentialRef(ref)) } catch (e) { hit = undefined }
|
|
702
|
+
if (!hit || !hit.value) return sendJson(res, 404, { error: 'no value' })
|
|
703
|
+
return sendJson(res, 200, { ok: true, ref: ref, value: hit.value })
|
|
704
|
+
}
|
|
705
|
+
|
|
485
706
|
if (path === 'reset') {
|
|
486
707
|
if (req.method === 'POST') {
|
|
487
708
|
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 池 + 自动轮换(触发码可配置,含 LlmError 标准码全集)+ 环境密钥编辑 + 明文揭示 + 短 ref 迁移。Settings → API Key 回退 UI. v6.1.",
|
|
4
|
+
"version": "3.1.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|