@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 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)** — sits between the LLM adapter and the credential store. Before each request the plugin picks a key from the per-provider pool and pre-writes it into the provider's credential reference; 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.2.4** (`v7` UI generation).
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 (derived from the last value written to the provider's env) — not a truncated hash, not a guessed name.
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
- - **pick** (`agent/request`, pre-write): 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 key is written via `credentials.set` **and** `process.env` so the provider actually authenticates with it.
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 池 + 自动轮换**——插件位于 LLM 适配器与凭证存储之间:每次请求前从按 provider 分组的 key 池里选一把,预写入该 provider 的凭证引用;遇到配置的触发错误时,把失败 key 标记为冷却(固定 `cooldownMs`,无指数退避)并前进到下一把。**重发完全交给 DSH 自带的 `dsh-llm-retry`**——本插件从不自行重发,只负责换 key,重试策略由 retry policy 决定。
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.2.4**(v7 UI 代)。
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(从最后一次写入 provider env 的值推导)——不是截断的哈希,不是猜的名字。
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
- - **pick**(`agent/request` 预写):先尊重 `useKeyRef` 锁定(但冷却中的锁定 key 会跳过,避免池在某把坏 key 上死锁),否则按游标在 live key 上轮询。选中的 key 同时写 `credentials.set` 和 `process.env`,保证 provider 真的用这把 key 鉴权。
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(= 最近一次预写进 process.env 的 key),
7
- // POST/PATCH 改 useKeyRef 后同步刷新内存池 + env,UI 立刻显示真正在用的 key。
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(llm-pi-ai 唯一读取入口)──
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
- credSvc.resolve = async (ref) => {
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 选择立即落到内存池 + process.env,让「当前使用」立刻真实
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
- // ── 预写:v1 同款 —— 全局 agent/request(DSH 全局收得到这个 waterfall),
493
- // 每次请求同步 buildPools() 重读 settings,选 key,同步写 process.env,
494
- // credentials.set fire-and-forget(不 await 阻塞 waterfall)。
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,供同步预写 pickKey 用)
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,但我们要同步写 env —— 用 then 链,立即返回 p 不阻塞 waterfall
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
- let curRef = pool.currentRef
552
- if (!curRef) {
553
- const envCur = process.env[pool.cfg.env]
554
- if (envCur) curRef = pool.keyValues.find((k) => k.value === envCur)?.ref
555
- }
556
- markFailed(pool, curRef || pool.keyValues[0]?.ref, code, rawMsg)
557
- diag('request-error', 'marked ' + (curRef || '?'))
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
- try { await backupFile(CRED_FILE, 'credentials'); process.env[pool.cfg.env] = nextKey.value } catch (e) {}
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:最近一次预写进 process.env 的值
638
+ // 真正当前使用的 key = 池的 currentRef —— 它就是 resolve 包装返回的那把。
639
+ // 不再用 process.env 反推:运行期改 env 对凭据层(启动快照)不可见,
640
+ // 反推会得到启动时那把旧 key,与 resolve 实际返回值不一致。
626
641
  let activeRef = ''
627
- if (pool.cfg.enabled !== false) {
628
- const envCur = process.env[pool.cfg.env]
629
- if (envCur) { const m = pool.keyValues.find((k) => k.value === envCur); if (m) activeRef = m.ref }
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
- // 同步内存池 + env,让「当前使用」立刻真实
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) { pool.currentRef = pk.ref; try { process.env[pool.cfg.env] = pk.value } catch (e) {} }
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) { pool.currentRef = pk.ref; try { process.env[pool.cfg.env] = pk.value } catch (e) {} } } catch (e) {} }
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.2.4",
4
+ "version": "3.3.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {