@omdp/dsh-key-fallback 3.2.0 → 3.2.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
@@ -4,7 +4,28 @@
4
4
 
5
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
6
 
7
- Current version: **v3.2.0** (`v7` UI generation).
7
+ Current version: **v3.2.1** (`v7` UI generation).
8
+
9
+ ## What v3.2.1 offers
10
+
11
+ - **Rescues pools stranded in `settings.yaml.imported`** (2026-09-24). `0.1.7`'s one-shot importer
12
+ **renames `settings.yaml` to `settings.yaml.imported` before it writes anything**, so if a section fails to
13
+ import (e.g. the target entry declares no volatile field) it never retries and the pools sit in
14
+ `.imported` forever. The symptom is exactly: badge shows **"尚未启用"** and the page shows
15
+ **"还没有任何池,点上方「启用新 provider 池」开始。"** even though the pools are on disk.
16
+ Note the file fallback was *already* there in v3.2.0 (`readSettingsFromFile()` falls back to
17
+ `settings.yaml.imported`) — but on `0.1.7` it is **never reached**, because `readSettings()` returns from the
18
+ live-ref branch first whenever `_liveRef` is set, and the ref holds `{}`. What was missing was a **bridge
19
+ between the two backends**, not another read fallback.
20
+ `v3.2.1` performs a **one-time explicit migration** on the first HTTP request (not in `apply()` — see below):
21
+ when the live config has zero providers *and* the legacy file has some, it writes them into the profile entry via
22
+ `settings.replace()`. A marker file (`<DSH_HOME>/.key-fallback-migrated`) makes it genuinely once-only, so
23
+ deliberately deleting every pool does not resurrect them on the next boot.
24
+ - **Why not in `apply()`:** `settings.replace()` goes through `configEditor.edit()` and requires the entry's fiber
25
+ to be **ACTIVE** — `describe()` skips entries whose `fiber.state !== 2`, and during `apply()` the fiber is still
26
+ being created, so the call throws `No configurable plugin entry`. The first UI request (`/pools`) is the
27
+ earliest safe moment.
28
+ - The marker is written **only after** the persist succeeds, so a failed write is retried on the next boot.
8
29
 
9
30
  ## Requirements
10
31
 
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
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 决定。
6
6
 
7
- 当前版本:**v3.2.0**(v7 UI 代)。
7
+ 当前版本:**v3.2.1**(v7 UI 代)。
8
8
 
9
9
  ## 环境要求
10
10
 
@@ -12,6 +12,24 @@
12
12
  - Node.js `^22.19` 或 `>=24`
13
13
  - 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`、`@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`、`@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.2.1 新增
16
+
17
+ - **救回滞留在 `settings.yaml.imported` 里的池**(2026-09-24)。`0.1.7` 的一次性导入器会在写入任何东西**之前**
18
+ 先把 `settings.yaml` 改名为 `settings.yaml.imported`——所以某段导入失败时(典型:目标条目没声明 volatile 字段)
19
+ 它永远不会重试,池子会永久留在 `.imported` 里。表现恰是:徽标显示 **「尚未启用」**、页面显示
20
+ **「还没有任何池,点上方「启用新 provider 池」开始。」**,尽管池子就在磁盘上。
21
+ 注意**文件回退在 v3.2.0 里本来就写了**(`readSettingsFromFile()` 会在 `settings.yaml` 缺失时退读
22
+ `settings.yaml.imported`)——但在 `0.1.7` 上它**永远轮不到**:只要 `_liveRef` 非空,
23
+ `readSettings()` 第一行就从 live-ref 分支返回,而 ref 里是 `{}`。**真正缺的不是又一个读回退,
24
+ 而是两个后端之间的一座桥。**
25
+ `v3.2.1` 在**首个 HTTP 请求**时做一次性显式搬运(不在 `apply()` 里——原因见下):
26
+ 当 live 配置里 provider 数为 0 *且* 旧文件里有池时,经 `settings.replace()` 写进 profile 条目。
27
+ 标记文件(`<DSH_HOME>/.key-fallback-migrated`)保证「真正只做一次」,因此有意删光所有池后不会在下次启动被复活。
28
+ - **为什么不能放在 `apply()`:** `settings.replace()` 走 `configEditor.edit()`,要求本 entry 的 fiber 已
29
+ **ACTIVE**——`describe()` 会跳过 `fiber.state !== 2` 的条目,而 `apply()` 执行时 fiber 还在创建中,
30
+ 调用必抛 `No configurable plugin entry`。首个 UI 请求(`/pools`)才是最早的安全时机。
31
+ - 标记**只在落盘成功之后**才写,因此写失败会在下次启动重试。
32
+
15
33
  ## v3.2.0 新增
16
34
 
17
35
  - **声明支持 DSH `0.1.7-rc.1`,同时保留 `0.1.5-rc.3`**(2026-09-24)。这是本插件首个需要改动代码的版本,原因是 DSH `0.1.7`
package/lib/index.js CHANGED
@@ -42,6 +42,8 @@ const SETTINGS_FILE = join(DSH_HOME, 'settings.yaml')
42
42
  const SETTINGS_FILE_IMPORTED = SETTINGS_FILE + '.imported'
43
43
  const CRED_FILE = join(DSH_HOME, '.credentials.yaml')
44
44
  const BACKUP_DIR = join(DSH_HOME, 'backups')
45
+ // 一次性 legacy→profile 迁移的标记(见 apply() 里的迁移块)。
46
+ const MIGRATED_MARKER = join(DSH_HOME, '.key-fallback-migrated')
45
47
  if (!existsSync(BACKUP_DIR)) try { mkdirSync(BACKUP_DIR, { recursive: true }) } catch (e) {}
46
48
  let _backupCounter = 0
47
49
  function tsName(prefix) {
@@ -254,6 +256,39 @@ export function apply(ctx, config) {
254
256
  diag('settings', 'backend = settings.yaml file (rc.x)')
255
257
  }
256
258
  } catch (e) { diag('settings', 'probe failed: ' + ((e && e.message) || e)) }
259
+
260
+ // ── 一次性迁移:老 settings.yaml(.imported) 里的池 → settings 托管的 entry config ──
261
+ // 0.1.7 的 importLegacyDocument() 在首次写入前就把 settings.yaml 改名成
262
+ // settings.yaml.imported,而本机的池恰好躺在 .imported 里 —— 迁移入口已永久失活,
263
+ // 池不会自己回到 UI(表现为徽标「尚未启用」+「还没有任何池」)。
264
+ //
265
+ // 必须在首个 HTTP 请求时做,不能在这里(apply 期间)做:settings.replace() 走
266
+ // configEditor.edit(),要求本 entry 的 fiber 已 ACTIVE(describe() 会跳过
267
+ // state !== 2 的 entry),apply() 执行时 fiber 尚在创建中,必然抛
268
+ // "No configurable plugin entry"。UI 打开设置页就会请求 /pools,时机正好。
269
+ // 标记文件保证「真正只做一次」:否则用户有意清空所有池后,下次启动又会灌回来。
270
+ let _migrationAttempted = false
271
+ async function ensureLegacyMigration() {
272
+ if (_migrationAttempted) return
273
+ _migrationAttempted = true
274
+ try {
275
+ if (!_liveRef || existsSync(MIGRATED_MARKER)) return
276
+ if (Object.keys((_cache && _cache.providers) || {}).length > 0) return
277
+ const migrated = (readSettingsFromFile() || {}).providers || {}
278
+ const names = Object.keys(migrated)
279
+ if (names.length === 0) {
280
+ // 无可搬运内容:也落标记(空文件算「已处理过」),避免每次启动重复探测。
281
+ try { writeFileSync(MIGRATED_MARKER, new Date().toISOString() + '\n', 'utf8') } catch (e) {}
282
+ return
283
+ }
284
+ // 直接 await 真正的 replace:只有落盘成功才写标记,否则下次启动还能重试。
285
+ if (!_settingsSvc || typeof _settingsSvc.replace !== 'function') return
286
+ _cache = { providers: cloneJson(migrated) }
287
+ await _settingsSvc.replace(ENTRY_ID, { providers: cloneJson(migrated) })
288
+ try { writeFileSync(MIGRATED_MARKER, new Date().toISOString() + '\n', 'utf8') } catch (e) {}
289
+ diag('migrate', 'imported ' + names.length + ' provider(s) from settings.yaml(.imported): ' + names.join(','))
290
+ } catch (e) { diag('migrate', 'failed: ' + ((e && e.message) || e)) }
291
+ }
257
292
  // ── 包装 credentials.resolve:ref 命中池 env 时返回池当前 key(llm-pi-ai 唯一读取入口)──
258
293
  const poolsByEnv = new Map()
259
294
  const credSvc = ctx.credentials
@@ -541,6 +576,11 @@ export function apply(ctx, config) {
541
576
  if (webServer) {
542
577
  const route = async (req, res) => {
543
578
  try {
579
+ // 首次请求时补做 legacy 池迁移(此时 fiber 已 ACTIVE,settings.replace 可用)。
580
+ // 放在所有分支之前,保证无论 UI 先请求哪个端点,池都已被搬进 live 配置。
581
+ // 首次请求时补做 legacy 池迁移(此时 fiber 已 ACTIVE,settings.replace 可用)。
582
+ // 放在所有分支之前,保证无论 UI 先请求哪个端点,池都已被搬进 live 配置。
583
+ await ensureLegacyMigration()
544
584
  const url = new URL(req.url, 'http://127.0.0.1')
545
585
  const path = url.pathname.replace(new RegExp('^' + API_BASE + '/?'), '').replace(/\/$/, '')
546
586
  const readBody = () => new Promise((resolve, reject) => {
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.0",
4
+ "version": "3.2.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {