@omdp/dsh-key-fallback 3.2.4 → 3.3.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 +61 -5
- package/README.zh-CN.md +49 -4
- package/lib/index.js +44 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
[简体中文](README.zh-CN.md) | English
|
|
4
4
|
|
|
5
|
-
**Multi-key API key pool with automatic rotation for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness)** —
|
|
6
|
-
|
|
7
|
-
Current version: **v3.
|
|
5
|
+
**Multi-key API key pool with automatic rotation for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness)** — hooks DSH's credential seam `credentials.resolve`: each time an adapter asks for a key, if that ref belongs to a configured pool the wrapper returns the pool's current key; when a configured trigger error occurs it marks the failed key cooling (fixed `cooldownMs`, no exponential backoff) and advances to the next key. **Re-sending is left entirely to DSH's own `dsh-llm-retry`** — this plugin never re-sends on its own; it only switches the key and lets the retry policy decide.
|
|
6
|
+
|
|
7
|
+
Current version: **v3.3.1** (`v7` UI generation).
|
|
8
|
+
|
|
9
|
+
## What v3.3.1 offers
|
|
10
|
+
|
|
11
|
+
- **Documentation correction — no code change** (2026-09-29). The v3.3.0 release notes asserted that the plugin
|
|
12
|
+
had never actually switched keys, because the `process.env` write never reached the credential layer. **That
|
|
13
|
+
assertion was wrong and is retracted here.** The `credentials.resolve` wrapper that supplies the key has
|
|
14
|
+
existed since **v3.0.0** (`git log -S 'credSvc.resolve =' -- dsh-key-fallback/lib/index.js` → `75ea76a`), and
|
|
15
|
+
the **published 3.2.4 tarball** already contains both that wrapper and its `pool-hit` diagnostic (verified by
|
|
16
|
+
downloading `@omdp/dsh-key-fallback@3.2.4` from the registry and reading `lib/index.js:298` / `:305`).
|
|
17
|
+
Rotation was already reaching the provider; the redundant `process.env` writes sat *alongside* the working
|
|
18
|
+
wrapper rather than being the mechanism. v3.3.0's real content is the smaller, accurate list in the section
|
|
19
|
+
below.
|
|
20
|
+
- **Live production evidence that rotation works** — captured on the real desktop install (DSH `0.2.0-rc.1`,
|
|
21
|
+
plugin v3.3.0) while a chat was routed through a pooled provider, from `GET /dsh-key-fallback/diag`:
|
|
22
|
+
200 entries (`resolve` 57 / `agent/request` 51 / `request-error` 90), with the pool's two keys used
|
|
23
|
+
**26 and 25 times** and alternating on each failure:
|
|
24
|
+
`PICK key_fallback_sensenova_key1` → `RATE_LIMIT` → `marked key_fallback_sensenova_key1` →
|
|
25
|
+
`PICK SENSENOVA_API_KEY` → … → `pool-hit SENSENOVA_API_KEY -> key_fallback_sensenova_key1`.
|
|
26
|
+
Each `pool-hit` line names the key that was current at that instant, which is exactly what the adapter
|
|
27
|
+
received on the wire.
|
|
28
|
+
|
|
29
|
+
## What v3.3.0 offers
|
|
30
|
+
|
|
31
|
+
- **Four real fixes** (2026-09-29). **Note**: the original v3.3.0 notes claimed this release made key switching
|
|
32
|
+
"actually reach the provider" for the first time. That was wrong — see the v3.3.1 correction above. What
|
|
33
|
+
v3.3.0 really changed:
|
|
34
|
+
- **`activeRef` no longer misreports the current key.** `/pools` used to derive `activeRef` by matching the
|
|
35
|
+
value of `process.env[<pool env>]` against the pool's members and falling back to `currentRef` only when no
|
|
36
|
+
member matched — so it could highlight **the wrong key**. On a live 0.2.0-rc.1 install it reported
|
|
37
|
+
`activeRef=SENSENOVA_API_KEY` while `currentRef` was `key_fallback_sensenova_key1`. `activeRef` is now
|
|
38
|
+
simply `currentRef`, so the two always agree.
|
|
39
|
+
- **Five redundant `process.env` writes removed.** DSH freezes `{ ...process.env }` into a *launch
|
|
40
|
+
environment snapshot* at boot (`dsh-app-boot`), `dsh-launch-environment` copies that into a `Map`, and
|
|
41
|
+
`dsh-credentials-local.inherited()` reads only that snapshot — so a post-boot `process.env` write is
|
|
42
|
+
invisible to the credential layer. The writes were therefore dead weight for authentication (the `resolve`
|
|
43
|
+
wrapper is what supplies the key). Removing them also stops the plugin from mutating the host process
|
|
44
|
+
environment. **Behaviour note**: a tool that spawns a subprocess inheriting `process.env` now sees the
|
|
45
|
+
launch-time value rather than the rotated one — no credential consumer depends on it, because every one of
|
|
46
|
+
them resolves through `credentials.resolve`, which the wrapper intercepts.
|
|
47
|
+
- **Failure attribution is guarded.** `agent/request-error` used to fall back to blaming `keyValues[0]` when
|
|
48
|
+
the current key could not be identified; it now only marks a key it can actually name.
|
|
49
|
+
- **Uninstall restores the original `resolve`** via `ctx.effect`, guarded by an identity check so it never
|
|
50
|
+
clobbers a wrapper installed after ours — a hot reload or fiber rebuild no longer stacks wrappers whose
|
|
51
|
+
closures hold expired services.
|
|
52
|
+
- **Unchanged**: re-send remains DSH's job. The rotation handler only changes the pool's current ref and still
|
|
53
|
+
calls `next()`, so `dsh-llm-retry` keeps deciding whether and how often to re-send. Verified: `next()` is
|
|
54
|
+
called on every rotation path.
|
|
55
|
+
- **Verification** (all measured, no inference): the real official `@deepseek-ai/dsh-llm-pi-ai` adapter was
|
|
56
|
+
applied in-process with a real `LocalCredentialProvider` and a local mock upstream; the API key was captured
|
|
57
|
+
from the **actual outbound HTTP `Authorization` header** — `Bearer STORED-ENV-KEY` before the plugin,
|
|
58
|
+
`Bearer KEY-ONE-111` after pre-selection, `Bearer KEY-TWO-222` after a `RATE_LIMIT` rotation, back to
|
|
59
|
+
`STORED-ENV-KEY` after `dispose()`. Two further suites cover the edge cases (`enabled:false`, empty pool,
|
|
60
|
+
unresolvable env, `useKeyRef` lock, `activeRef`/`currentRef` agreement over HTTP, `dispose` idempotence,
|
|
61
|
+
non-matching error codes) — 14/14 and 9/9 pass. The live production timeline is in v3.3.1 above.
|
|
8
62
|
|
|
9
63
|
## What v3.2.4 offers
|
|
10
64
|
|
|
@@ -136,7 +190,7 @@ with live env-key values), create/update/delete (POST/DELETE round-trip, persist
|
|
|
136
190
|
**Settings → API Key 回退** — a top-level settings page with a redesigned UI (status dots, badges, gradient pool cards, per-key rows):
|
|
137
191
|
|
|
138
192
|
- **Configurable rotation triggers that are actually enforced** (`rotateOn`): click chips to select which error codes cause rotation. The preset chips cover the **full DSH `LlmError` standard code set** — `QUOTA` / `AUTH` / `RATE_LIMIT` / `TIMEOUT` / `TRANSPORT` / `SERVER` / `EMPTY_RESPONSE` / `INVALID_CREDENTIAL` — or add a non-standard/custom code (matched **exactly** against the provider's `failure.code`). What you save is what runs: there is no "magic upgrade" that rewrites three selected codes back into a six-code superset. `ABORTED` (user cancel) never triggers rotation.
|
|
139
|
-
- **真实当前使用 key 显示**: the page shows which key is actually being used right now (
|
|
193
|
+
- **真实当前使用 key 显示**: the page shows which key is actually being used right now (the pool's current ref — exactly what the credential seam returns to the adapter) — not a truncated hash, not a guessed name.
|
|
140
194
|
- **短 ref 命名**: new keys are auto-named `key_fallback_<provider>_key1`, `key_fallback_<provider>_key2`, … so the UI shows clean short names (`key1`, `key2`, … or your custom `label`) instead of long ref strings. Existing legacy long refs are migrated **once, automatically and idempotently** (write new ref → persist config → best-effort unset old ref; any failure aborts and retries safely).
|
|
141
195
|
- **明文揭示**: every key row has an eye toggle (`👁` / `🙈`) that fetches and shows the real value via `GET /keys/plain` (only for keys owned by that pool, or the pool's env key). The env key row also reveals plaintext — it is not swallowed by the read-only note.
|
|
142
196
|
- **环境密钥可编辑**: the pool's env key (`AGNES_API_KEY` etc.) can be updated right in the page when it is backed by the writable credential file. If it is supplied by the launching environment (read-only), the UI says so and refuses to edit (HTTP 400).
|
|
@@ -145,7 +199,9 @@ with live env-key values), create/update/delete (POST/DELETE round-trip, persist
|
|
|
145
199
|
|
|
146
200
|
## Rotation semantics
|
|
147
201
|
|
|
148
|
-
- **
|
|
202
|
+
- **How a key actually gets used**: DSH's credential seam is `credentials.resolve(ref)`. Every model adapter reads its key through it (`dsh-llm-pi-ai`, `dsh-llm-deepseek-api-key`, and any plugin that resolves a credential ref), and it is called **per request**. This plugin wraps that one method: when the resolved ref belongs to a configured pool, the wrapper returns the pool's current key instead of the stored value. Switching the key is therefore just changing the pool's current ref — the next request picks it up with no restart.
|
|
203
|
+
- Writing to `process.env` would **not** work: the credential provider reads the *launch environment snapshot* that DSH froze at boot (`{...process.env}` in `dsh-app-boot`, copied into a `Map` by `dsh-launch-environment`), so anything written to `process.env` after startup is invisible to it. DSH's own comment on the provider entry says the managed document "is never materialized into the process environment". **History**: up to 3.2.4 the plugin *also* wrote `process.env[<pool env>]`, but always **alongside** the wrapper above — that write was redundant rather than the mechanism, so rotation worked regardless. The writes were removed in v3.3.0 for hygiene. If you are reading an older note claiming rotation "never worked", it is wrong; see the v3.3.1 section.
|
|
204
|
+
- **pick** (`agent/request`, pre-select): respects `useKeyRef` lock first (but a cooling locked key is skipped so the pool never deadlocks on a bad key), otherwise cursor round-robin over live keys. The chosen ref becomes the pool's current ref, which is what the credential seam hands to the adapter on the next resolve.
|
|
149
205
|
- **fail** (`agent/request-error`, registered `prepend` so it runs before `dsh-llm-retry`): only when the error matches the pool's `rotateOn` — by `failure.code` (exact), by HTTP status mapping (`429→RATE_LIMIT`, `401/403→AUTH`, `402→QUOTA`, `5xx→SERVER`), or by message keyword. Marks the failed key with a fixed `cooldownMs` (default 30 s), then switches to `nextRef` if configured, else the next live key.
|
|
150
206
|
- **re-send** is left entirely to DSH's `dsh-llm-retry` with the user's own per-provider retry policy. The two are independent and complementary: the retry plugin decides "retry the same key N times" (`retryPolicy.retryableCodes`, default `EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT` — which notably excludes `AUTH`/`QUOTA`); this plugin decides "switch to the next key". So e.g. an `AUTH` failure (which retry would not re-send anyway) still rotates to the next key — that next request will authenticate with the fresh key.
|
|
151
207
|
|
package/README.zh-CN.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
**为 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 提供多 key 池 +
|
|
5
|
+
**为 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 提供多 key 池 + 自动轮换**——插件挂在 DSH 的凭证接缝 `credentials.resolve` 上:每次适配器取 key 时,若该 ref 属于已配置的池,就返回池的当前 key;遇到配置的触发错误时,把失败 key 标记为冷却(固定 `cooldownMs`,无指数退避)并前进到下一把。**重发完全交给 DSH 自带的 `dsh-llm-retry`**——本插件从不自行重发,只负责换 key,重试策略由 retry policy 决定。
|
|
6
6
|
|
|
7
|
-
当前版本:**v3.
|
|
7
|
+
当前版本:**v3.3.1**(v7 UI 代)。
|
|
8
8
|
|
|
9
9
|
## 环境要求
|
|
10
10
|
|
|
@@ -12,6 +12,49 @@
|
|
|
12
12
|
- Node.js `^22.19` 或 `>=24`
|
|
13
13
|
- peer 范围**只枚举已实际进行过兼容测试的版本**——`@deepseek-ai/dsh` `0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1`(本插件声明的 DSH 运行时;**执行该声明的门禁本身只有 0.1.7 才有**,更旧的运行时只是看到一条不认识的 peer 而已,不受影响)、`@deepseek-ai/dsh-credentials` `0.1.0-rc.6 || 0.1.1-rc.2 || 0.1.2-alpha.1 || 0.1.2-alpha.2 || 0.1.2-alpha.3 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1`、`@deepseek-ai/dsh-llm` / `@deepseek-ai/dsh-settings` `0.1.1-rc.2 || 0.1.2-alpha.1 || 0.1.2-alpha.2 || 0.1.2-alpha.3 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1`、`@deepseek-ai/cordis` `4.0.1 || 4.0.2 || 4.0.4`、`@deepseek-ai/schemastery` `3.18.1 || 3.18.2 || 3.18.4`。不使用 `<0.2.0`、caret 之类的开放范围:未测试版本在核查通过前刻意排除。插件只使用 credential-reference 半边(`resolve`/`describe`/`set`/`unset`/`credentialRef`,自 `0.1.0-rc.6` 起稳定)与 `agent/request` + `agent/request-error` waterfall(载荷跨上述枚举版本未变);`isCredentialRefName`(rc.8 新增)本地实现兜底。`0.1.2-alpha.2 → alpha.5 → 0.1.2-rc.1` 配套包逐字节一致(2026-09-03 复核),故 DSH `0.1.2-rc.1`(`next`)无需改动插件。
|
|
14
14
|
|
|
15
|
+
## v3.3.1 新增
|
|
16
|
+
|
|
17
|
+
- **文档更正——代码零改动**(2026-09-29)。v3.3.0 的发布说明断言「本插件从来没真正换过 key,因为写
|
|
18
|
+
`process.env` 到不了凭证层」。**该断言是错的,在此撤回。** 真正把 key 交给 provider 的
|
|
19
|
+
`credentials.resolve` 包装自 **v3.0.0** 就存在
|
|
20
|
+
(`git log -S 'credSvc.resolve =' -- dsh-key-fallback/lib/index.js` → `75ea76a`);从 registry 下载
|
|
21
|
+
**已发布的 3.2.4 tarball** 核对,`lib/index.js:298` 就是该包装、`:305` 是它的 `pool-hit` 诊断。
|
|
22
|
+
也就是说:**轮换一直在生效**,那几处 `process.env` 写入是与可用包装**并存**的冗余,而不是机制本身。
|
|
23
|
+
v3.3.0 的真实内容见下一节的准确清单。
|
|
24
|
+
- **轮换生效的实机证据** —— 在真实桌面端(DSH `0.2.0-rc.1` + 插件 v3.3.0)会话走池中 provider 时,
|
|
25
|
+
取 `GET /dsh-key-fallback/diag`:200 条(`resolve` 57 / `agent/request` 51 / `request-error` 90),
|
|
26
|
+
池里两把 key 分别被用了 **26 次 / 25 次**,每次失败即交替:
|
|
27
|
+
`PICK key_fallback_sensenova_key1` → `RATE_LIMIT` → `marked key_fallback_sensenova_key1` →
|
|
28
|
+
`PICK SENSENOVA_API_KEY` → … → `pool-hit SENSENOVA_API_KEY -> key_fallback_sensenova_key1`。
|
|
29
|
+
每条 `pool-hit` 都写明当时实际生效的那把 —— 正是适配器在网络上真正拿去鉴权的那把。
|
|
30
|
+
|
|
31
|
+
## v3.3.0 新增
|
|
32
|
+
|
|
33
|
+
- **四项真实修复**(2026-09-29)。**注意**:v3.3.0 最初的说明声称本版才让「换 key 第一次真正到达
|
|
34
|
+
provider」,那是错的——更正见上方 v3.3.1。v3.3.0 实际改的是:
|
|
35
|
+
- **`activeRef` 不再报错当前 key。** `/pools` 原先用 `process.env[<池 env>]` 的值去池里反查成员、
|
|
36
|
+
只在没匹配到时才回退 `currentRef`——所以**可能高亮错误的那把**。实机(0.2.0-rc.1)上它报
|
|
37
|
+
`activeRef=SENSENOVA_API_KEY`,而 `currentRef` 是 `key_fallback_sensenova_key1`。现在 `activeRef`
|
|
38
|
+
直接等于 `currentRef`,两者永不矛盾。
|
|
39
|
+
- **删除 5 处冗余的 `process.env` 写入。** DSH 在启动时把 `{ ...process.env }` 冻结成「启动环境快照」
|
|
40
|
+
(`dsh-app-boot`),`dsh-launch-environment` 把它拷进 `Map`,`dsh-credentials-local.inherited()`
|
|
41
|
+
只读这个快照——启动之后写 `process.env` 对凭证层**不可见**。因此这些写入对鉴权是死代码(真正供 key
|
|
42
|
+
的是 `resolve` 包装);删掉同时避免插件改写宿主进程环境。**行为提示**:继承 `process.env` 启的子进程
|
|
43
|
+
现在看到的是启动时的值而非轮换后的值——没有任何凭证消费者依赖它,因为它们全部都经
|
|
44
|
+
`credentials.resolve` 取值,而那正是包装拦截的地方。
|
|
45
|
+
- **失败归因加了守卫。** `agent/request-error` 原先在无法确定当前 key 时会退化成归咎
|
|
46
|
+
`keyValues[0]`;现在只标记它真能指名的那把。
|
|
47
|
+
- **卸载会还原原始 `resolve`**(经 `ctx.effect`),并带身份校验,绝不覆盖「在我们之后安装的」包装层——
|
|
48
|
+
热重载/fiber 重建不再层层叠加、留下持有过期 service 的闭包。
|
|
49
|
+
- **未改动**:重发仍归 DSH 管。轮换处理只改池的当前 ref,并且仍然调用 `next()`,由 `dsh-llm-retry`
|
|
50
|
+
继续决定是否/重发几次。实测:每条轮换路径都调用了 `next()`。
|
|
51
|
+
- **验证方式**(全部实测,无推断):在进程内用真实的官方 `@deepseek-ai/dsh-llm-pi-ai` 适配器 +
|
|
52
|
+
真实 `LocalCredentialProvider` + 本地 mock 上游,从**真实出站 HTTP `Authorization` 头**里抓 key ——
|
|
53
|
+
装插件前 `Bearer STORED-ENV-KEY`,预选后 `Bearer KEY-ONE-111`,`RATE_LIMIT` 轮换后
|
|
54
|
+
`Bearer KEY-TWO-222`,`dispose()` 后回到 `STORED-ENV-KEY`。另有两套边界用例(`enabled:false`、空池、
|
|
55
|
+
env 解析不到、`useKeyRef` 锁定、HTTP 上 `activeRef`/`currentRef` 一致、`dispose` 幂等、不匹配的错误码)
|
|
56
|
+
——14/14 与 9/9 全绿。实机时间线见上方 v3.3.1。
|
|
57
|
+
|
|
15
58
|
## v3.2.4 新增
|
|
16
59
|
|
|
17
60
|
- **peer 枚举追加 DSH `0.2.0-rc.1`**(2026-09-28)——**代码零改动**。DSH 桌面端升到 `0.2.0-rc.1` 后,
|
|
@@ -128,7 +171,7 @@
|
|
|
128
171
|
**设置 → API Key 回退** —— 顶层设置页,全新 UI(状态点、徽章、渐变池卡片、逐 key 行):
|
|
129
172
|
|
|
130
173
|
- **可配置且真正生效的轮转触发码**(`rotateOn`):点选 chips 决定哪些错误码触发轮换。预设 chips 覆盖 **DSH `LlmError` 标准码全集**——`QUOTA` / `AUTH` / `RATE_LIMIT` / `TIMEOUT` / `TRANSPORT` / `SERVER` / `EMPTY_RESPONSE` / `INVALID_CREDENTIAL`——也可添加非标准/自定义错误码(与 provider 的 `failure.code` **精确匹配**)。你保存什么就执行什么:不存在"把选中的三个码偷偷变回六码超集"的魔法。`ABORTED`(用户取消)永不触发轮换。
|
|
131
|
-
- **真实当前使用 key 显示**:页面显示当前真正在用哪把 key
|
|
174
|
+
- **真实当前使用 key 显示**:页面显示当前真正在用哪把 key(池的当前 ref —— 正是凭证接缝返回给适配器的那把)——不是截断的哈希,不是猜的名字。
|
|
132
175
|
- **短 ref 命名**:新 key 自动命名为 `key_fallback_<provider>_key1`、`key_fallback_<provider>_key2`、…,UI 显示干净的短名(`key1`、`key2`、…或你的自定义 `label`)。已有旧长 ref **一次性、幂等地自动迁移**(写新 ref → 持久化配置 → best-effort 删旧 ref;任何一步失败即中止、安全可重试)。
|
|
133
176
|
- **明文揭示**:每行有眼睛开关(`👁` / `🙈`),经 `GET /keys/plain` 显示真实值(仅限本池的 key 或本池 env key)。env key 行也会显示明文——不会被只读说明吞掉。
|
|
134
177
|
- **环境密钥可编辑**:池的 env key(`AGNES_API_KEY` 等)在凭证文件可写时可直接在页面里改。若由启动环境提供(只读),UI 会说明并拒绝编辑(HTTP 400)。
|
|
@@ -137,7 +180,9 @@
|
|
|
137
180
|
|
|
138
181
|
## 轮换语义
|
|
139
182
|
|
|
140
|
-
- **
|
|
183
|
+
- **key 到底怎么被用上**:DSH 的凭证接缝是 `credentials.resolve(ref)`。每个模型适配器都经它取 key(`dsh-llm-pi-ai`、`dsh-llm-deepseek-api-key`,以及任何解析凭证引用的插件),且**每次请求都会调用一次**。本插件包装的就是这一个方法:当解析的 ref 属于已配置的池时,包装层返回池的当前 key,而不是存储里的值。所以"换 key"就是改池的当前 ref —— 下一次请求立刻生效,无需重启。
|
|
184
|
+
- 写 `process.env` **不管用**:凭证 provider 读的是 DSH 启动时冻结的「启动环境快照」(`dsh-app-boot` 里的 `{...process.env}`,由 `dsh-launch-environment` 拷进 `Map`),启动之后再改 `process.env` 对它不可见。DSH 自己在 provider 条目上的注释也写着 managed document "is never materialized into the process environment"。**历史说明**:3.2.4 及之前插件**也**会写 `process.env[<池 env>]`,但它始终与上面的包装**并存**——那个写入是冗余的,**不是**机制本身,所以轮换一直照常工作。v3.3.0 出于整洁删掉了这些写入。若你看到旧笔记声称轮换「从未生效」,那是错的,见 v3.3.1 一节。
|
|
185
|
+
- **pick**(`agent/request`,预选):先尊重 `useKeyRef` 锁定(但冷却中的锁定 key 会跳过,避免池在某把坏 key 上死锁),否则按游标在 live key 上轮询。选中的 ref 成为池的当前 ref —— 也就是凭证接缝下一次 resolve 交给适配器的那把。
|
|
141
186
|
- **fail**(`agent/request-error`,注册 `prepend` 保证先于 `dsh-llm-retry` 看到错误):仅当错误匹配本池 `rotateOn` 时才处理——按 `failure.code`(精确)、按 HTTP 状态映射(`429→RATE_LIMIT`、`401/403→AUTH`、`402→QUOTA`、`5xx→SERVER`)、或按消息关键字。把失败 key 标记为固定 `cooldownMs`(默认 30 s),然后切到 `nextRef`(若配置)否则下一把 live key。
|
|
142
187
|
- **re-send** 完全交给 DSH 的 `dsh-llm-retry` 与用户自己的 per-provider 重试策略。两者独立互补:重试插件决定"同一把 key 重发几次"(`retryPolicy.retryableCodes` 默认 `EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT`——注意**不含 AUTH/QUOTA**);本插件决定"换下一把 key"。例如 `AUTH` 失败(重试本来也不会重发,重发也白搭)仍会轮换到下一把——下一次请求就会用新 key 鉴权。
|
|
143
188
|
|
package/lib/index.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// v6 = v5 全量功能 + 用户要求的修复与增强:
|
|
4
4
|
// - 轮转触发码真正生效:agent/request-error 按池的 rotateOn 判断(可配置、chips 可点选),
|
|
5
5
|
// 数字 status 与消息关键字按所选触发码映射;默认集=旧行为超集(无回归)。
|
|
6
|
-
// - 「当前使用」显示修复:GET /pools 返回 activeRef(=
|
|
7
|
-
// POST/PATCH 改 useKeyRef
|
|
6
|
+
// - 「当前使用」显示修复:GET /pools 返回 activeRef(= 池的 currentRef,即 resolve 包装
|
|
7
|
+
// 实际返回的那把 key),POST/PATCH 改 useKeyRef 后同步刷新内存池,UI 立刻显示真正在用的 key。
|
|
8
8
|
// - key 短命名:新增 key 自动命名为 key_fallback_<provider>_key<N>;
|
|
9
9
|
// 旧的长 ref 在首次加载时一次性迁移(refsCompacted 标记,幂等,先 set 新 ref 再 unset 旧 ref)。
|
|
10
10
|
// - 明文揭示:GET /keys/plain?provider=&ref= 返回真实值(走 rawResolve,绕过池 wrap)。
|
|
@@ -289,13 +289,16 @@ export function apply(ctx, config) {
|
|
|
289
289
|
diag('migrate', 'imported ' + names.length + ' provider(s) from settings.yaml(.imported): ' + names.join(','))
|
|
290
290
|
} catch (e) { diag('migrate', 'failed: ' + ((e && e.message) || e)) }
|
|
291
291
|
}
|
|
292
|
-
// ── 包装 credentials.resolve:ref 命中池 env 时返回池当前 key
|
|
292
|
+
// ── 包装 credentials.resolve:ref 命中池 env 时返回池当前 key ──
|
|
293
|
+
// 这是 DSH 官方唯一的「每次请求都问一次用哪把 key」接缝(dsh-llm-pi-ai:2563、
|
|
294
|
+
// dsh-llm-deepseek-api-key:44、dsh-vision-bridge:91、dsh-cost-meter:957 等全部经它取值),
|
|
295
|
+
// 因此拦这一处即可覆盖所有 provider,且语义与「重发归官方、插件只切 key」完全同构。
|
|
293
296
|
const poolsByEnv = new Map()
|
|
294
297
|
const credSvc = ctx.credentials
|
|
295
298
|
// 原始 resolve(未 wrap 版本):池重建/读 env 真实值必须走它,否则 wrap 会短路成 currentRef 的值(污染)
|
|
296
299
|
const rawResolve = (credSvc && typeof credSvc.resolve === 'function') ? credSvc.resolve.bind(credSvc) : null
|
|
297
300
|
if (credSvc && typeof credSvc.resolve === 'function') {
|
|
298
|
-
|
|
301
|
+
const wrappedResolve = async (ref) => {
|
|
299
302
|
let result
|
|
300
303
|
try { result = await rawResolve(ref) } catch (e) { result = undefined }
|
|
301
304
|
const refName = String(ref || '')
|
|
@@ -306,7 +309,14 @@ export function apply(ctx, config) {
|
|
|
306
309
|
}
|
|
307
310
|
return result
|
|
308
311
|
}
|
|
312
|
+
credSvc.resolve = wrappedResolve
|
|
309
313
|
diag('apply', 'credentials.resolve wrapped')
|
|
314
|
+
// 卸载时还原原始 resolve:热重载 / fiber 重建会重新 apply,不还原则 wrap 逐层叠加,
|
|
315
|
+
// 且旧层闭包持有过期 service 与已清空的 poolsByEnv。仅在仍是本层 wrap 时还原,
|
|
316
|
+
// 避免把后来者的 wrap 覆盖掉。
|
|
317
|
+
ctx.effect(() => () => {
|
|
318
|
+
try { if (credSvc.resolve === wrappedResolve) credSvc.resolve = rawResolve } catch (e) {}
|
|
319
|
+
})
|
|
310
320
|
}
|
|
311
321
|
|
|
312
322
|
const pools = new Map() // provider -> { cfg, keyValues, cursor, currentRef }
|
|
@@ -480,18 +490,21 @@ export function apply(ctx, config) {
|
|
|
480
490
|
return undefined
|
|
481
491
|
}
|
|
482
492
|
|
|
483
|
-
// 把 useKeyRef
|
|
493
|
+
// 把 useKeyRef 选择立即落到内存池,「当前使用」立刻真实。
|
|
494
|
+
// 注意:这里不再写 process.env —— provider 认证走 credentials.resolve,而 DSH 的凭据层读的是
|
|
495
|
+
// 启动时冻结的 launchEnvironment 快照(dsh-app-boot:3435 → dsh-launch-environment:30-35),
|
|
496
|
+
// 运行期改 process.env 对它不可见(官方注释亦称 managed document "is never materialized
|
|
497
|
+
// into the process environment")。真正的切换点只有上面的 resolve 包装。
|
|
484
498
|
function applySelection(pool, ref) {
|
|
485
499
|
if (!pool || !ref) return
|
|
486
500
|
const kv = pool.keyValues.find((k) => k.ref === ref)
|
|
487
501
|
if (!kv) return
|
|
488
502
|
pool.currentRef = ref
|
|
489
|
-
try { process.env[pool.cfg.env] = kv.value } catch (e) {}
|
|
490
503
|
}
|
|
491
504
|
|
|
492
|
-
// ──
|
|
493
|
-
//
|
|
494
|
-
//
|
|
505
|
+
// ── 预热:全局 agent/request(DSH 全局收得到这个 waterfall)每次请求重读 settings、选 key,
|
|
506
|
+
// 写内存池 currentRef;真正的「这次用哪把 key」由上面的 credentials.resolve 包装在
|
|
507
|
+
// 适配器取值时给出,无需(也无法)预写环境变量。
|
|
495
508
|
async function warmPools() {
|
|
496
509
|
readPools()
|
|
497
510
|
for (const name of pools.keys()) {
|
|
@@ -509,11 +522,11 @@ export function apply(ctx, config) {
|
|
|
509
522
|
}
|
|
510
523
|
}
|
|
511
524
|
|
|
512
|
-
// 启动时预热 pools(resolve keyValues
|
|
525
|
+
// 启动时预热 pools(resolve keyValues,供 pickKey 预选用)
|
|
513
526
|
try { warmPools().catch(() => {}) } catch (e) {}
|
|
514
527
|
|
|
515
528
|
ctx.on('agent/request', (payload, next) => {
|
|
516
|
-
// next() 是 async
|
|
529
|
+
// next() 是 async;用 then 链在拿到 config 后同步更新内存池,立即返回 p 不阻塞 waterfall
|
|
517
530
|
const p = next()
|
|
518
531
|
Promise.resolve(p).then((config) => {
|
|
519
532
|
try {
|
|
@@ -529,7 +542,6 @@ export function apply(ctx, config) {
|
|
|
529
542
|
if (!key) return config
|
|
530
543
|
pool.currentRef = key.ref
|
|
531
544
|
diag('agent/request', 'PICK ' + key.ref + ' env=' + pool.cfg.env)
|
|
532
|
-
try { process.env[pool.cfg.env] = key.value } catch (e) {}
|
|
533
545
|
try { backupFile(CRED_FILE, 'credentials') } catch (e) {}
|
|
534
546
|
} catch (e) {}
|
|
535
547
|
return config
|
|
@@ -548,17 +560,20 @@ export function apply(ctx, config) {
|
|
|
548
560
|
const pool = await getPool(provider)
|
|
549
561
|
if (!pool || pool.keyValues.length === 0 || pool.cfg.enabled === false) return next()
|
|
550
562
|
if (!shouldRotate(pool, canonicalCode(code), rawMsg, status)) return next()
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
563
|
+
// 当前 key 只信 currentRef:它是跨 readPools()/resolvePoolKeys() 重建保留的权威值
|
|
564
|
+
// (readPools 只对新建 provider 置空、resolvePoolKeys 全程不碰它),
|
|
565
|
+
// 不再从 process.env 反推 —— 那是它的劣化投影,且运行期改 env 对凭据层不可见。
|
|
566
|
+
const curRef = pool.currentRef
|
|
567
|
+
// 认不出当前 key 时不乱罚:宁可只轮换不冷却,也不要误伤一把没用过的 key
|
|
568
|
+
// (正常情况下 currentRef 已被 agent/request 或 warmPools 置好,此分支近乎不可达)
|
|
569
|
+
if (curRef) markFailed(pool, curRef, code, rawMsg)
|
|
570
|
+
diag('request-error', 'marked ' + (curRef || '(none)'))
|
|
558
571
|
// 失败后优先按 nextRef 轮换(用户配置的顺序);useKeyRef 只决定初始选择
|
|
559
572
|
const nextKey = nextKeyFor(pool, curRef)
|
|
560
573
|
if (nextKey) {
|
|
561
|
-
|
|
574
|
+
// 只改内存池:下一次 credentials.resolve 就会返回新 key。
|
|
575
|
+
// 重发由 DSH 官方 dsh-llm-retry 按用户自己的 retryPolicy 完成,本插件不干预。
|
|
576
|
+
try { await backupFile(CRED_FILE, 'credentials') } catch (e) {}
|
|
562
577
|
pool.currentRef = nextKey.ref
|
|
563
578
|
} else {
|
|
564
579
|
// 无 nextRef 可选时:若 useKeyRef 锁定 key 仍 live 则退回它,否则放弃
|
|
@@ -576,8 +591,6 @@ export function apply(ctx, config) {
|
|
|
576
591
|
if (webServer) {
|
|
577
592
|
const route = async (req, res) => {
|
|
578
593
|
try {
|
|
579
|
-
// 首次请求时补做 legacy 池迁移(此时 fiber 已 ACTIVE,settings.replace 可用)。
|
|
580
|
-
// 放在所有分支之前,保证无论 UI 先请求哪个端点,池都已被搬进 live 配置。
|
|
581
594
|
// 首次请求时补做 legacy 池迁移(此时 fiber 已 ACTIVE,settings.replace 可用)。
|
|
582
595
|
// 放在所有分支之前,保证无论 UI 先请求哪个端点,池都已被搬进 live 配置。
|
|
583
596
|
await ensureLegacyMigration()
|
|
@@ -622,12 +635,13 @@ export function apply(ctx, config) {
|
|
|
622
635
|
for (const [name, pool] of pools) {
|
|
623
636
|
await resolvePoolKeys(pool, name)
|
|
624
637
|
const now = Date.now()
|
|
625
|
-
// 真正当前使用的 key
|
|
638
|
+
// 真正当前使用的 key = 池的 currentRef —— 它就是 resolve 包装返回的那把。
|
|
639
|
+
// 不再用 process.env 反推:运行期改 env 对凭据层(启动快照)不可见,
|
|
640
|
+
// 反推会得到启动时那把旧 key,与 resolve 实际返回值不一致。
|
|
626
641
|
let activeRef = ''
|
|
627
|
-
if (pool.cfg.enabled !== false) {
|
|
628
|
-
const
|
|
629
|
-
if (
|
|
630
|
-
if (!activeRef && pool.currentRef) activeRef = pool.currentRef
|
|
642
|
+
if (pool.cfg.enabled !== false && pool.currentRef) {
|
|
643
|
+
const cur = pool.keyValues.find((k) => k.ref === pool.currentRef)
|
|
644
|
+
if (cur) activeRef = cur.ref
|
|
631
645
|
}
|
|
632
646
|
// 环境密钥可写性(描述:文件=可写,启动环境=只读)
|
|
633
647
|
let envWritable = null
|
|
@@ -702,13 +716,13 @@ export function apply(ctx, config) {
|
|
|
702
716
|
try { validated = profileSchema(upd) } catch (e) { return sendJson(res, 400, { error: 'invalid profile: ' + (e && e.message || e) }) }
|
|
703
717
|
cfg.providers[provider] = validated
|
|
704
718
|
writeSettings(cfg)
|
|
705
|
-
//
|
|
719
|
+
// 同步内存池,让「当前使用」立刻真实(切 key 由 resolve 包装在下次取值时生效)
|
|
706
720
|
try {
|
|
707
721
|
const pool = await getPool(provider)
|
|
708
722
|
if (pool) {
|
|
709
723
|
if (validated.useKeyRef) applySelection(pool, validated.useKeyRef)
|
|
710
724
|
else if (pool.currentRef && !pool.keyValues.find((k) => k.ref === pool.currentRef && isLive(k))) {
|
|
711
|
-
const pk = pickKey(pool); if (pk)
|
|
725
|
+
const pk = pickKey(pool); if (pk) pool.currentRef = pk.ref
|
|
712
726
|
}
|
|
713
727
|
}
|
|
714
728
|
} catch (e) {}
|
|
@@ -805,7 +819,7 @@ export function apply(ctx, config) {
|
|
|
805
819
|
const pool = pools.get(provider)
|
|
806
820
|
if (pool) {
|
|
807
821
|
pool.keyValues = pool.keyValues.filter((k) => k.ref !== ref)
|
|
808
|
-
if (pool.currentRef === ref) { pool.currentRef = ''; try { const pk = pickKey(pool); if (pk)
|
|
822
|
+
if (pool.currentRef === ref) { pool.currentRef = ''; try { const pk = pickKey(pool); if (pk) pool.currentRef = pk.ref } catch (e) {} }
|
|
809
823
|
}
|
|
810
824
|
} catch (e) {}
|
|
811
825
|
return sendJson(res, 200, { ok: true })
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omdp/dsh-key-fallback",
|
|
3
3
|
"description": "API Key 回退轮换插件:多 key 池 + 自动轮换(触发码可配置,含 LlmError 标准码全集)+ 环境密钥编辑 + 明文揭示 + 短 ref 迁移。同时兼容 DSH 0.1.5-rc.3(settings.yaml 后端)与 0.1.7+(profile entry config 后端)。Settings → API Key 回退 UI. v7.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.3.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|