@omdp/dsh-key-fallback 3.3.0 → 3.3.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 XJungit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,43 +4,75 @@
4
4
 
5
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
6
 
7
- Current version: **v3.3.0** (`v7` UI generation).
7
+ Current version: **v3.3.2** (`v7` UI generation).
8
+
9
+ ## What v3.3.2 offers
10
+
11
+ - **Peer declaration switched to the triple range `>=0.2.0-rc.1 <0.2.1-0` — zero code change**
12
+ (2026-09-30). Background: after DSH `0.2.0-rc.2` shipped, the gate blocked 3.3.1 exactly as it had
13
+ blocked 3.2.4 on `0.2.0-rc.1` — the **third consecutive round** of the same failure (settings item
14
+ gone, routes 404/405). The root cause was never incompatibility but per-version enumeration going
15
+ stale on every rc. Evidence: a full 20-package file-by-file SHA256 diff `0.2.0-rc.1 → rc.2` shows the
16
+ six peers this plugin declares change **only in `package.json` version strings**; the real gate
17
+ function was then run over the whole runtime matrix — `0.2.0-rc.1` / `rc.2` / `rc.3` / `rc.9` /
18
+ stable `0.2.0` all **PASS**, `0.2.1-rc.1` / `0.3.0-rc.1` all **BLOCK**.
19
+ ⚠️ **This narrows support**: the `0.1.7` line is **no longer declared** — use **3.3.1** if you need it.
20
+ ⚠️ The upper bound must be `<0.2.1-0`, never `<0.2.1`: semver sorts prereleases *before* the stable
21
+ release, so `0.2.1-rc.1 < 0.2.1` holds and `<0.2.1` would leak the next triple's rc.
22
+
23
+ ## What v3.3.1 offers
24
+
25
+ - **Documentation correction — no code change** (2026-09-29). The v3.3.0 release notes asserted that the plugin
26
+ had never actually switched keys, because the `process.env` write never reached the credential layer. **That
27
+ assertion was wrong and is retracted here.** The `credentials.resolve` wrapper that supplies the key has
28
+ existed since **v3.0.0** (`git log -S 'credSvc.resolve =' -- dsh-key-fallback/lib/index.js` → `75ea76a`), and
29
+ the **published 3.2.4 tarball** already contains both that wrapper and its `pool-hit` diagnostic (verified by
30
+ downloading `@omdp/dsh-key-fallback@3.2.4` from the registry and reading `lib/index.js:298` / `:305`).
31
+ Rotation was already reaching the provider; the redundant `process.env` writes sat *alongside* the working
32
+ wrapper rather than being the mechanism. v3.3.0's real content is the smaller, accurate list in the section
33
+ below.
34
+ - **Live production evidence that rotation works** — captured on the real desktop install (DSH `0.2.0-rc.1`,
35
+ plugin v3.3.0) while a chat was routed through a pooled provider, from `GET /dsh-key-fallback/diag`:
36
+ 200 entries (`resolve` 57 / `agent/request` 51 / `request-error` 90), with the pool's two keys used
37
+ **26 and 25 times** and alternating on each failure:
38
+ `PICK key_fallback_sensenova_key1` → `RATE_LIMIT` → `marked key_fallback_sensenova_key1` →
39
+ `PICK SENSENOVA_API_KEY` → … → `pool-hit SENSENOVA_API_KEY -> key_fallback_sensenova_key1`.
40
+ Each `pool-hit` line names the key that was current at that instant, which is exactly what the adapter
41
+ received on the wire.
8
42
 
9
43
  ## What v3.3.0 offers
10
44
 
11
- - **The key switch now actually reaches the provider** (2026-09-29) — a real bug fix, not a refactor for
12
- tidiness. Until 3.2.4 this plugin switched keys by writing `process.env[<pool env>] = <key value>`, which
13
- **never reached the credential layer**. DSH freezes `{ ...process.env }` into a *launch environment snapshot*
14
- at boot (`dsh-app-boot`), `dsh-launch-environment` copies that into a `Map`, and
15
- `dsh-credentials-local.inherited()` reads only that snapshot — so a post-startup `process.env` write is
16
- invisible. Measured on a live 0.2.0-rc.1 install: `GET /dsh-key-fallback/pools` reported
17
- `activeRef=SENSENOVA_API_KEY` while the pool's current key was `key_fallback_sensenova_key1`, and the same
18
- response reported `envSource=file` — proof the write did land on `process.env` yet the provider still saw the
19
- stored value. In effect the pool rotated in memory while every request kept authenticating with the old key.
20
- - **The fix**: the plugin now hooks the one seam that is consulted **per request** —
21
- `credentials.resolve(ref)`. `dsh-llm-pi-ai` (`:2563`), `dsh-llm-deepseek-api-key` (`:44`) and every other
22
- credential consumer resolve their key through it, so wrapping that single method covers all providers. If
23
- the resolved ref belongs to a pool, the wrapper returns the pool's current key; `describe()` is deliberately
24
- left untouched so it still reports the *stored* truth.
25
- - **All five `process.env` writes removed**, plus the two places that reverse-engineered the current key by
26
- string-matching `process.env` (a degraded projection that returned the *boot-time* key). `currentRef` is now
27
- the single source of truth — it survives `readPools()`/`resolvePoolKeys()` rebuilds, which the env write
28
- never did.
29
- - **Visible behaviour change**: `/pools` now reports `activeRef === currentRef`. Previously the two could
30
- disagree (and `activeRef` showed the stale boot-time value).
45
+ - **Four real fixes** (2026-09-29). **Note**: the original v3.3.0 notes claimed this release made key switching
46
+ "actually reach the provider" for the first time. That was wrong — see the v3.3.1 correction above. What
47
+ v3.3.0 really changed:
48
+ - **`activeRef` no longer misreports the current key.** `/pools` used to derive `activeRef` by matching the
49
+ value of `process.env[<pool env>]` against the pool's members and falling back to `currentRef` only when no
50
+ member matched — so it could highlight **the wrong key**. On a live 0.2.0-rc.1 install it reported
51
+ `activeRef=SENSENOVA_API_KEY` while `currentRef` was `key_fallback_sensenova_key1`. `activeRef` is now
52
+ simply `currentRef`, so the two always agree.
53
+ - **Five redundant `process.env` writes removed.** DSH freezes `{ ...process.env }` into a *launch
54
+ environment snapshot* at boot (`dsh-app-boot`), `dsh-launch-environment` copies that into a `Map`, and
55
+ `dsh-credentials-local.inherited()` reads only that snapshot — so a post-boot `process.env` write is
56
+ invisible to the credential layer. The writes were therefore dead weight for authentication (the `resolve`
57
+ wrapper is what supplies the key). Removing them also stops the plugin from mutating the host process
58
+ environment. **Behaviour note**: a tool that spawns a subprocess inheriting `process.env` now sees the
59
+ launch-time value rather than the rotated one — no credential consumer depends on it, because every one of
60
+ them resolves through `credentials.resolve`, which the wrapper intercepts.
61
+ - **Failure attribution is guarded.** `agent/request-error` used to fall back to blaming `keyValues[0]` when
62
+ the current key could not be identified; it now only marks a key it can actually name.
31
63
  - **Uninstall restores the original `resolve`** via `ctx.effect`, guarded by an identity check so it never
32
64
  clobbers a wrapper installed after ours — a hot reload or fiber rebuild no longer stacks wrappers whose
33
65
  closures hold expired services.
34
- - **Re-send is still DSH's job.** The rotation handler only changes the pool's current ref and still calls
35
- `next()`, so `dsh-llm-retry` keeps deciding whether/how often to re-send. Verified: `next()` is called on
36
- every rotation path.
37
- - **Verification** (all measured, no inference): the real official `@deepseek-ai/dsh-llm-pi-ai` adapter was
38
- applied in-process with a real `LocalCredentialProvider` and a local mock upstream; the API key was captured
39
- from the **actual outbound HTTP `Authorization` header** — `Bearer STORED-ENV-KEY` before the plugin,
40
- `Bearer KEY-ONE-111` after pre-selection, `Bearer KEY-TWO-222` after a `RATE_LIMIT` rotation, back to
41
- `STORED-ENV-KEY` after `dispose()`. Two further suites cover the edge cases (`enabled:false`, empty pool,
42
- unresolvable env, `useKeyRef` lock, `activeRef`/`currentRef` agreement over HTTP, `dispose` idempotence,
43
- non-matching error codes) — 14/14 and 9/9 pass.
66
+ - **Unchanged**: re-send remains DSH's job. The rotation handler only changes the pool's current ref and still
67
+ calls `next()`, so `dsh-llm-retry` keeps deciding whether and how often to re-send. Verified: `next()` is
68
+ called on every rotation path.
69
+ - **Verification** (all measured, no inference): the real official `@deepseek-ai/dsh-llm-pi-ai` adapter was
70
+ applied in-process with a real `LocalCredentialProvider` and a local mock upstream; the API key was captured
71
+ from the **actual outbound HTTP `Authorization` header** — `Bearer STORED-ENV-KEY` before the plugin,
72
+ `Bearer KEY-ONE-111` after pre-selection, `Bearer KEY-TWO-222` after a `RATE_LIMIT` rotation, back to
73
+ `STORED-ENV-KEY` after `dispose()`. Two further suites cover the edge cases (`enabled:false`, empty pool,
74
+ unresolvable env, `useKeyRef` lock, `activeRef`/`currentRef` agreement over HTTP, `dispose` idempotence,
75
+ non-matching error codes) — 14/14 and 9/9 pass. The live production timeline is in v3.3.1 above.
44
76
 
45
77
  ## What v3.2.4 offers
46
78
 
@@ -110,7 +142,7 @@ Current version: **v3.3.0** (`v7` UI generation).
110
142
 
111
143
  - DeepSeek Harness with a `web`-profile GUI (`npx @deepseek-ai/dsh web`)
112
144
  - Node.js `^22.19` or `>=24`
113
- - Peer ranges strictly enumerate **only compatibility-tested versions** — `@deepseek-ai/dsh` `0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1` (the plugin's claimed DSH runtime; the gate that enforces it only exists from 0.1.7, so older runtimes just see an unrecognized 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`. No open-ended ranges (`<0.2.0`, caret): untested versions are deliberately excluded until verified. The plugin only uses the credential-reference half (`resolve`/`describe`/`set`/`unset`/`credentialRef` — stable since `0.1.0-rc.6`) and the `agent/request` + `agent/request-error` waterfall (payload unchanged across the enumerated versions); `isCredentialRefName` (added `rc.8`) is implemented locally for compatibility. The `0.1.2-alpha.2 → alpha.5 → 0.1.2-rc.1` companion packages are byte-identical (2026-09-03 verified), so DSH `0.1.2-rc.1` (`next`) needs no plugin change.
145
+ - Peer ranges use **per-triple intervals** (since 3.3.2; per-version enumeration before that) — `@deepseek-ai/dsh` / `@deepseek-ai/dsh-credentials` / `@deepseek-ai/dsh-llm` / `@deepseek-ai/dsh-settings` all declare `>=0.2.0-rc.1 <0.2.1-0`: testing the first rc of a triple clears its later rc builds (`0.2.0-rc.2`/`rc.3`…) and the stable `0.2.0`, while crossing a triple (`0.2.1-rc.1`) is still gated off and requires a fresh check. The `-0` upper bound is mandatory: semver sorts prereleases *before* the stable release, so `<0.2.1` would leak the next triple's rc (see `AGENTS.md` rule 3). `@deepseek-ai/cordis` `4.0.1 || 4.0.2 || 4.0.4` and `@deepseek-ai/schemastery` `3.18.1 || 3.18.2 || 3.18.4` stay **enumerated** (neither goes through the gate, which only inspects `@deepseek-ai/dsh` and `dsh-*`, and they have sparse version sets with no prerelease chain). The plugin only uses the credential-reference half (`resolve`/`describe`/`set`/`unset`/`credentialRef` — stable since `0.1.0-rc.6`) and the `agent/request` + `agent/request-error` waterfall (payload unchanged across versions); `isCredentialRefName` (added `rc.8`) is implemented locally for compatibility. **Note the `0.1.7` line is no longer declared as of 3.3.2** — install 3.3.1 if you need it.
114
146
 
115
147
  ## What v3.2.0 offers
116
148
 
@@ -182,7 +214,7 @@ with live env-key values), create/update/delete (POST/DELETE round-trip, persist
182
214
  ## Rotation semantics
183
215
 
184
216
  - **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.
185
- - 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".
217
+ - 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.
186
218
  - **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.
187
219
  - **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.
188
220
  - **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.
package/README.zh-CN.md CHANGED
@@ -4,42 +4,69 @@
4
4
 
5
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.3.0**(v7 UI 代)。
7
+ 当前版本:**v3.3.2**(v7 UI 代)。
8
+
9
+ ## v3.3.2 新增
10
+
11
+ - **peer 声明改用三元组区间 `>=0.2.0-rc.1 <0.2.1-0`——代码零改动**(2026-09-30)。
12
+ 背景:DSH `0.2.0-rc.2` 发布后,门禁把 3.3.1 拦下的方式与当初把 3.2.4 在 `0.2.0-rc.1` 上拦下
13
+ 完全一致——已是**连续第三轮**同型故障(设置项消失、路由 404/405)。根因从来不是不兼容,而是
14
+ 逐版本枚举在每次 rc 迭代下必然过时。核查依据:`0.2.0-rc.1 → rc.2` 全量 20 包逐文件 SHA256
15
+ 对比显示,本插件声明的六个 peer **只有 `package.json` 版本号变化**;再用真门禁跑全版本矩阵——
16
+ `0.2.0-rc.1` / `rc.2` / `rc.3` / `rc.9` / 正式版 `0.2.0` 全 **放行**,
17
+ `0.2.1-rc.1` / `0.3.0-rc.1` 全 **拦截**。
18
+ ⚠️ **本条同时收窄支持面**:不再声明 `0.1.7` 线,需要在 0.1.7 上运行请装 **3.3.1**。
19
+ ⚠️ 上界必须写 `<0.2.1-0` 而非 `<0.2.1`:semver 里预发布排在正式版**之前**,
20
+ `0.2.1-rc.1 < 0.2.1` 成立,写 `<0.2.1` 会漏放行下一个三元组的 rc。
8
21
 
9
22
  ## 环境要求
10
23
 
11
24
  - DeepSeek Harness 带 `web` profile GUI(`npx @deepseek-ai/dsh web`)
12
25
  - Node.js `^22.19` 或 `>=24`
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`)无需改动插件。
26
+ - peer 范围用**按三元组划分的区间**(自 3.3.2 起;此前为逐版本枚举)——`@deepseek-ai/dsh` / `@deepseek-ai/dsh-credentials` / `@deepseek-ai/dsh-llm` / `@deepseek-ai/dsh-settings` 四条均声明 `>=0.2.0-rc.1 <0.2.1-0`:实测该三元组的首个 rc 即自动放行其后续 rc(`0.2.0-rc.2`/`rc.3`…)与正式版 `0.2.0`,跨三元组(如 `0.2.1-rc.1`)仍会被门禁拦下、须重新核查。上界写 `-0` 是必须的:semver 里预发布排在正式版之前,写 `<0.2.1` 会漏放行下一个三元组的 rc(规范见 `AGENTS.md` 规范 3)。`@deepseek-ai/cordis` `4.0.1 || 4.0.2 || 4.0.4`、`@deepseek-ai/schemastery` `3.18.1 || 3.18.2 || 3.18.4` 仍**精确枚举**(两者不进版本门禁——门禁只检查 `@deepseek-ai/dsh` 与 `dsh-*`——且版本稀疏无预发布链)。插件只使用 credential-reference 半边(`resolve`/`describe`/`set`/`unset`/`credentialRef`,自 `0.1.0-rc.6` 起稳定)与 `agent/request` + `agent/request-error` waterfall(载荷跨各版本未变);`isCredentialRefName`(rc.8 新增)本地实现兜底。**注意 `0.1.7` 线自 3.3.2 起不再声明**,需要它请装 3.3.1。
27
+
28
+ ## v3.3.1 新增
29
+
30
+ - **文档更正——代码零改动**(2026-09-29)。v3.3.0 的发布说明断言「本插件从来没真正换过 key,因为写
31
+ `process.env` 到不了凭证层」。**该断言是错的,在此撤回。** 真正把 key 交给 provider 的
32
+ `credentials.resolve` 包装自 **v3.0.0** 就存在
33
+ (`git log -S 'credSvc.resolve =' -- dsh-key-fallback/lib/index.js` → `75ea76a`);从 registry 下载
34
+ **已发布的 3.2.4 tarball** 核对,`lib/index.js:298` 就是该包装、`:305` 是它的 `pool-hit` 诊断。
35
+ 也就是说:**轮换一直在生效**,那几处 `process.env` 写入是与可用包装**并存**的冗余,而不是机制本身。
36
+ v3.3.0 的真实内容见下一节的准确清单。
37
+ - **轮换生效的实机证据** —— 在真实桌面端(DSH `0.2.0-rc.1` + 插件 v3.3.0)会话走池中 provider 时,
38
+ 取 `GET /dsh-key-fallback/diag`:200 条(`resolve` 57 / `agent/request` 51 / `request-error` 90),
39
+ 池里两把 key 分别被用了 **26 次 / 25 次**,每次失败即交替:
40
+ `PICK key_fallback_sensenova_key1` → `RATE_LIMIT` → `marked key_fallback_sensenova_key1` →
41
+ `PICK SENSENOVA_API_KEY` → … → `pool-hit SENSENOVA_API_KEY -> key_fallback_sensenova_key1`。
42
+ 每条 `pool-hit` 都写明当时实际生效的那把 —— 正是适配器在网络上真正拿去鉴权的那把。
14
43
 
15
44
  ## v3.3.0 新增
16
45
 
17
- - **换 key 现在真的能到 provider 了**(2026-09-29)——这是真 bug 修复,不是为整洁而重构。3.2.4 及之前,
18
- 插件靠写 `process.env[<池 env>] = <key 值>` 来换 key,而**这个写从来没到达凭证层**。DSH 在启动时把
19
- `{ ...process.env }` 冻结成「启动环境快照」(`dsh-app-boot`),`dsh-launch-environment` 把它拷进
20
- `Map`,`dsh-credentials-local.inherited()` 只读这个快照——所以启动之后写 `process.env` 它看不见。
21
- 实机(0.2.0-rc.1)实测:`GET /dsh-key-fallback/pools` 报 `activeRef=SENSENOVA_API_KEY`,而池的当前 key
22
- 是 `key_fallback_sensenova_key1`;同一响应又报 `envSource=file`——既证明写确实落到了真实
23
- `process.env`,又证明确实没生效。结果是:池在内存里轮换了,每个请求却仍用启动时那把旧 key 鉴权。
24
- - **修法**:改为挂接唯一「每次请求都问一次」的接缝 `credentials.resolve(ref)`。`dsh-llm-pi-ai`
25
- (`:2563`)、`dsh-llm-deepseek-api-key`(`:44`)以及所有凭证消费者都经它取 key,因此包装这一个方法
26
- 即可覆盖全部 provider。若解析的 ref 属于某个池,包装层返回池的当前 key;`describe()` 刻意不动,
27
- 仍报告存储里的真实值。
28
- - **删掉全部 5 处 `process.env` 写入**,以及两处「用 `process.env` 字符串反推当前 key」的逻辑(那是个
29
- 劣化投影,只会返回启动时那把)。`currentRef` 现在是唯一权威——它能跨 `readPools()` /
30
- `resolvePoolKeys()` 重建保留,而 env 写入本来就做不到。
31
- - **可见行为变化**:`/pools` 现在报 `activeRef === currentRef`。之前两者可能不一致(`activeRef` 显示
32
- 的是启动时的陈旧值)。
46
+ - **四项真实修复**(2026-09-29)。**注意**:v3.3.0 最初的说明声称本版才让「换 key 第一次真正到达
47
+ provider」,那是错的——更正见上方 v3.3.1。v3.3.0 实际改的是:
48
+ - **`activeRef` 不再报错当前 key。** `/pools` 原先用 `process.env[<池 env>]` 的值去池里反查成员、
49
+ 只在没匹配到时才回退 `currentRef`——所以**可能高亮错误的那把**。实机(0.2.0-rc.1)上它报
50
+ `activeRef=SENSENOVA_API_KEY`,而 `currentRef` 是 `key_fallback_sensenova_key1`。现在 `activeRef`
51
+ 直接等于 `currentRef`,两者永不矛盾。
52
+ - **删除 5 处冗余的 `process.env` 写入。** DSH 在启动时把 `{ ...process.env }` 冻结成「启动环境快照」
53
+ (`dsh-app-boot`),`dsh-launch-environment` 把它拷进 `Map`,`dsh-credentials-local.inherited()`
54
+ 只读这个快照——启动之后写 `process.env` 对凭证层**不可见**。因此这些写入对鉴权是死代码(真正供 key
55
+ 的是 `resolve` 包装);删掉同时避免插件改写宿主进程环境。**行为提示**:继承 `process.env` 启的子进程
56
+ 现在看到的是启动时的值而非轮换后的值——没有任何凭证消费者依赖它,因为它们全部都经
57
+ `credentials.resolve` 取值,而那正是包装拦截的地方。
58
+ - **失败归因加了守卫。** `agent/request-error` 原先在无法确定当前 key 时会退化成归咎
59
+ `keyValues[0]`;现在只标记它真能指名的那把。
33
60
  - **卸载会还原原始 `resolve`**(经 `ctx.effect`),并带身份校验,绝不覆盖「在我们之后安装的」包装层——
34
61
  热重载/fiber 重建不再层层叠加、留下持有过期 service 的闭包。
35
- - **重发仍归 DSH 管**:轮换处理只改池的当前 ref,并且仍然调用 `next()`,由 `dsh-llm-retry` 继续决定是否/
36
- 重发几次。实测:每条轮换路径都调用了 `next()`。
37
- - **验证方式**(全部实测,无推断):在进程内用真实的官方 `@deepseek-ai/dsh-llm-pi-ai` 适配器 +
38
- 真实 `LocalCredentialProvider` + 本地 mock 上游,从**真实出站 HTTP `Authorization` 头**里抓 key ——
39
- 装插件前 `Bearer STORED-ENV-KEY`,预选后 `Bearer KEY-ONE-111`,`RATE_LIMIT` 轮换后
40
- `Bearer KEY-TWO-222`,`dispose()` 后回到 `STORED-ENV-KEY`。另有两套边界用例(`enabled:false`、空池、
41
- env 解析不到、`useKeyRef` 锁定、HTTP 上 `activeRef`/`currentRef` 一致、`dispose` 幂等、不匹配的错误码)
42
- ——14/14 与 9/9 全绿。
62
+ - **未改动**:重发仍归 DSH 管。轮换处理只改池的当前 ref,并且仍然调用 `next()`,由 `dsh-llm-retry`
63
+ 继续决定是否/重发几次。实测:每条轮换路径都调用了 `next()`。
64
+ - **验证方式**(全部实测,无推断):在进程内用真实的官方 `@deepseek-ai/dsh-llm-pi-ai` 适配器 +
65
+ 真实 `LocalCredentialProvider` + 本地 mock 上游,从**真实出站 HTTP `Authorization` 头**里抓 key ——
66
+ 装插件前 `Bearer STORED-ENV-KEY`,预选后 `Bearer KEY-ONE-111`,`RATE_LIMIT` 轮换后
67
+ `Bearer KEY-TWO-222`,`dispose()` 后回到 `STORED-ENV-KEY`。另有两套边界用例(`enabled:false`、空池、
68
+ env 解析不到、`useKeyRef` 锁定、HTTP 上 `activeRef`/`currentRef` 一致、`dispose` 幂等、不匹配的错误码)
69
+ ——14/14 与 9/9 全绿。实机时间线见上方 v3.3.1。
43
70
 
44
71
  ## v3.2.4 新增
45
72
 
@@ -167,7 +194,7 @@
167
194
  ## 轮换语义
168
195
 
169
196
  - **key 到底怎么被用上**:DSH 的凭证接缝是 `credentials.resolve(ref)`。每个模型适配器都经它取 key(`dsh-llm-pi-ai`、`dsh-llm-deepseek-api-key`,以及任何解析凭证引用的插件),且**每次请求都会调用一次**。本插件包装的就是这一个方法:当解析的 ref 属于已配置的池时,包装层返回池的当前 key,而不是存储里的值。所以"换 key"就是改池的当前 ref —— 下一次请求立刻生效,无需重启。
170
- - 写 `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"。
197
+ - 写 `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 一节。
171
198
  - **pick**(`agent/request`,预选):先尊重 `useKeyRef` 锁定(但冷却中的锁定 key 会跳过,避免池在某把坏 key 上死锁),否则按游标在 live key 上轮询。选中的 ref 成为池的当前 ref —— 也就是凭证接缝下一次 resolve 交给适配器的那把。
172
199
  - **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。
173
200
  - **re-send** 完全交给 DSH 的 `dsh-llm-retry` 与用户自己的 per-provider 重试策略。两者独立互补:重试插件决定"同一把 key 重发几次"(`retryPolicy.retryableCodes` 默认 `EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT`——注意**不含 AUTH/QUOTA**);本插件决定"换下一把 key"。例如 `AUTH` 失败(重试本来也不会重发,重发也白搭)仍会轮换到下一把——下一次请求就会用新 key 鉴权。
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.3.0",
4
+ "version": "3.3.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -39,10 +39,10 @@
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@deepseek-ai/cordis": "4.0.1 || 4.0.2 || 4.0.4",
42
- "@deepseek-ai/dsh": "0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1",
43
- "@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",
44
- "@deepseek-ai/dsh-llm": "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",
45
- "@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",
42
+ "@deepseek-ai/dsh": ">=0.2.0-rc.1 <0.2.1-0",
43
+ "@deepseek-ai/dsh-credentials": ">=0.2.0-rc.1 <0.2.1-0",
44
+ "@deepseek-ai/dsh-llm": ">=0.2.0-rc.1 <0.2.1-0",
45
+ "@deepseek-ai/dsh-settings": ">=0.2.0-rc.1 <0.2.1-0",
46
46
  "@deepseek-ai/schemastery": "3.18.1 || 3.18.2 || 3.18.4"
47
47
  }
48
48
  }