@omdp/dsh-key-fallback 3.1.7 → 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 +65 -3
- package/README.zh-CN.md +62 -4
- package/lib/index.js +136 -15
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -4,13 +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)** — 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.1
|
|
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
|
|
|
11
32
|
- DeepSeek Harness with a `web`-profile GUI (`npx @deepseek-ai/dsh web`)
|
|
12
33
|
- Node.js `^22.19` or `>=24`
|
|
13
|
-
- Peer ranges strictly enumerate **only compatibility-tested versions** — `@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.6-alpha.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.6-alpha.1`, `@deepseek-ai/cordis` `4.0.1 || 4.0.2`, `@deepseek-ai/schemastery` `3.18.1 || 3.18.2`. 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.
|
|
34
|
+
- Peer ranges strictly enumerate **only compatibility-tested versions** — `@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`. 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.
|
|
35
|
+
|
|
36
|
+
## What v3.2.0 offers
|
|
37
|
+
|
|
38
|
+
- **DSH `0.1.7-rc.1` support declared, with `0.1.5-rc.3` kept** (2026-09-24). This release is the first to need an
|
|
39
|
+
actual code change, because DSH `0.1.7` **relocates plugin configuration**: see the next section.
|
|
40
|
+
|
|
41
|
+
### Two config backends, one build
|
|
42
|
+
|
|
43
|
+
Since DSH `0.1.7`, plugin configuration stopped living in `<DSH_HOME>/settings.yaml`. On first boot, `0.1.7`
|
|
44
|
+
**renames that file to `settings.yaml.imported`** and copies each section into the profile patch
|
|
45
|
+
(`~/.dsh/profiles/<profile>/cordis.patch.yml`) as the `config:` of the matching loader entry; from then on the
|
|
46
|
+
`settings` service owns it, and plugins read it through an injected `config` parameter instead of touching the
|
|
47
|
+
YAML file. `ctx.settings.get()` / `register()` / `installSection()` — the rc.x API — **no longer exist** on `0.1.7`.
|
|
48
|
+
|
|
49
|
+
This plugin therefore auto-detects the backend at startup and keeps both paths working:
|
|
50
|
+
|
|
51
|
+
| | DSH `0.1.5-rc.3` (and older rc.x) | DSH `0.1.7-rc.1` (and newer) |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| Where pools live | `<DSH_HOME>/settings.yaml`, section `key-fallback:` | profile patch, the `key-fallback` entry's `config` |
|
|
54
|
+
| How the plugin reads/writes | reads/writes that YAML file directly (with timestamped backups) | `config.providers` is a live volatile ref; writes go through `settings.replace()` |
|
|
55
|
+
| Detection | no volatile-schema support ⇒ file backend | `Config` declares a volatile field ⇒ backend comes from `config` |
|
|
56
|
+
|
|
57
|
+
Two implementation notes that matter if you port this pattern elsewhere:
|
|
58
|
+
|
|
59
|
+
1. **`Config` must exist, or the migration silently drops your config.** `0.1.7`'s import path calls
|
|
60
|
+
`settings.update(ns, values)`, which throws unless the entry declares a **volatile** field; on failure it logs
|
|
61
|
+
`settings: section ... was not imported` and the section survives only inside `settings.yaml.imported`. So
|
|
62
|
+
`v3.2.0` exports `Config = z.object({ providers: <dict>.volatile() })`.
|
|
63
|
+
2. **The volatile API differs between the two schemastery builds.** `.volatile()` is available in schemastery
|
|
64
|
+
`3.18.4` (`0.1.7`) but **not** in `3.18.2` (`rc.3`) — calling it there returns `undefined` and registering a
|
|
65
|
+
schema without it would change rc.3 behaviour. The export is therefore guarded:
|
|
66
|
+
`typeof field.volatile === 'function' ? z.object({ providers: field.volatile() }) : undefined` — rc.3 gets
|
|
67
|
+
**no** `Config` (byte-identical behaviour to v3.1.x) while `0.1.7` gets the volatile one.
|
|
68
|
+
|
|
69
|
+
The values from `config.providers` are deep-frozen by DSH, so `readSettings()` hands callers a mutable deep copy;
|
|
70
|
+
writes are applied optimistically to an in-memory copy and flushed via `settings.replace()` (an unresolved-write
|
|
71
|
+
counter prevents the UI from briefly re-reading the pre-write tree).
|
|
72
|
+
|
|
73
|
+
Both backends were exercised end-to-end on real installs: read (both providers appear in `/dsh-key-fallback/pools`
|
|
74
|
+
with live env-key values), create/update/delete (POST/DELETE round-trip, persisted), and the rc.3 regression
|
|
75
|
+
(`settings.yaml` untouched and *not* renamed, `cordis.patch.yml` still `[]`, backups still written).
|
|
14
76
|
|
|
15
77
|
## What v3.1.7 offers
|
|
16
78
|
|
|
@@ -81,7 +143,7 @@ dsh plugin --profile web add link:D:/WorkSpace/omdp/dsh-key-fallback
|
|
|
81
143
|
## Known limitations
|
|
82
144
|
|
|
83
145
|
- Cooldown is a fixed per-pool `cooldownMs` (no exponential backoff); a cooled key becomes live again when the timer expires.
|
|
84
|
-
- The plugin manages rotation/cooldown state in-memory plus persisted pool config
|
|
146
|
+
- The plugin manages rotation/cooldown state in-memory plus persisted pool config; a DSH restart re-reads config and recomputes live status. On DSH `0.1.5-rc.3` and older that config is the `key-fallback` section of `<DSH_HOME>/settings.yaml`; on `0.1.7`+ it is the `key-fallback` entry's `config` inside `~/.dsh/profiles/<profile>/cordis.patch.yml`.
|
|
85
147
|
- The env key cannot be deleted through the UI (it is the pool's identity).
|
|
86
148
|
|
|
87
149
|
## License
|
package/README.zh-CN.md
CHANGED
|
@@ -1,16 +1,74 @@
|
|
|
1
1
|
# @omdp/dsh-key-fallback
|
|
2
2
|
|
|
3
|
-
English
|
|
3
|
+
[English](README.md) | 简体中文
|
|
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.1
|
|
7
|
+
当前版本:**v3.2.1**(v7 UI 代)。
|
|
8
8
|
|
|
9
9
|
## 环境要求
|
|
10
10
|
|
|
11
11
|
- DeepSeek Harness 带 `web` profile GUI(`npx @deepseek-ai/dsh web`)
|
|
12
12
|
- Node.js `^22.19` 或 `>=24`
|
|
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.6-alpha.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.6-alpha.1`、`@deepseek-ai/cordis` `4.0.1 || 4.0.2`、`@deepseek-ai/schemastery` `3.18.1 || 3.18.2`。不使用 `<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`)无需改动插件。
|
|
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
|
+
|
|
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
|
+
|
|
33
|
+
## v3.2.0 新增
|
|
34
|
+
|
|
35
|
+
- **声明支持 DSH `0.1.7-rc.1`,同时保留 `0.1.5-rc.3`**(2026-09-24)。这是本插件首个需要改动代码的版本,原因是 DSH `0.1.7`
|
|
36
|
+
**改变了插件配置的存放位置**——详见下节。
|
|
37
|
+
|
|
38
|
+
### 两种配置后端,同一份构建
|
|
39
|
+
|
|
40
|
+
自 DSH `0.1.7` 起,插件配置不再位于 `<DSH_HOME>/settings.yaml`。首次启动时 `0.1.7` 会把该文件
|
|
41
|
+
**改名成 `settings.yaml.imported`**,并把每个段的内容写入 profile 补丁
|
|
42
|
+
(`~/.dsh/profiles/<profile>/cordis.patch.yml`)中对应 loader 条目的 `config:`;此后由 `settings` 服务托管,
|
|
43
|
+
插件通过注入的 `config` 参数读取,而不再自行读写该 YAML。rc.x 时代的 `ctx.settings.get()` /
|
|
44
|
+
`register()` / `installSection()` 在 `0.1.7` 上**已不存在**。
|
|
45
|
+
|
|
46
|
+
因此本插件在启动时自动判别后端,两条路径同时可用:
|
|
47
|
+
|
|
48
|
+
| | DSH `0.1.5-rc.3`(及更早 rc.x) | DSH `0.1.7-rc.1`(及更新) |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| 池配置位置 | `<DSH_HOME>/settings.yaml` 的 `key-fallback:` 段 | profile 补丁中 `key-fallback` 条目的 `config` |
|
|
51
|
+
| 插件如何读写 | 直接读写该 YAML 文件(附时间戳备份) | `config.providers` 是活的 volatile ref;写入走 `settings.replace()` |
|
|
52
|
+
| 判别方式 | 无 volatile schema 支持 ⇒ 文件后端 | `Config` 声明了 volatile 字段 ⇒ 后端来自 `config` |
|
|
53
|
+
|
|
54
|
+
移植该模式时有两个关键点:
|
|
55
|
+
|
|
56
|
+
1. **必须声明 `Config`,否则迁移会静默丢弃配置。** `0.1.7` 的导入路径调用
|
|
57
|
+
`settings.update(ns, values)`,若该条目没有声明 **volatile** 字段就会抛错,仅打印
|
|
58
|
+
`settings: section ... was not imported`,配置只残留在 `settings.yaml.imported` 里。因此
|
|
59
|
+
`v3.2.0` 导出 `Config = z.object({ providers: <dict>.volatile() })`。
|
|
60
|
+
2. **volatile API 在两种 schemastery 构建上不同。** `.volatile()` 在 schemastery `3.18.4`(`0.1.7`)上可用,
|
|
61
|
+
但在 `3.18.2`(`rc.3`)上**不存在**——在那里调用会得到 `undefined`,而若据此注册一个无 volatile 的
|
|
62
|
+
schema 就会改变 rc.3 的行为。故导出做了守卫:
|
|
63
|
+
`typeof field.volatile === 'function' ? z.object({ providers: field.volatile() }) : undefined`——
|
|
64
|
+
rc.3 拿到 **没有** `Config`(行为与 v3.1.x 逐字节一致),`0.1.7` 拿到含 volatile 的那一份。
|
|
65
|
+
|
|
66
|
+
`config.providers` 的值被 DSH 深度冻结,故 `readSettings()` 向调用方返回可变深拷贝;写入先乐观地更新内存副本
|
|
67
|
+
再经 `settings.replace()` 落盘(未决写入计数避免 UI 短暂读回写入前的旧树)。
|
|
68
|
+
|
|
69
|
+
两条后端均已在真实安装上端到端实测:读取(两个 provider 都出现在 `/dsh-key-fallback/pools`,含 env key 实时值)、
|
|
70
|
+
增删改(POST/DELETE 往返并落盘)、以及 rc.3 回归(`settings.yaml` 未被改名也未改内容、`cordis.patch.yml` 仍为
|
|
71
|
+
`[]`、备份照旧生成)。
|
|
14
72
|
|
|
15
73
|
## v3.1.7 新增
|
|
16
74
|
|
|
@@ -81,7 +139,7 @@ dsh plugin --profile web add link:D:/WorkSpace/omdp/dsh-key-fallback
|
|
|
81
139
|
## 已知限制
|
|
82
140
|
|
|
83
141
|
- 冷却为每池固定 `cooldownMs`(无指数退避);冷却到期 key 自动恢复 live。
|
|
84
|
-
- 轮换/冷却状态为内存态 +
|
|
142
|
+
- 轮换/冷却状态为内存态 + 持久化池配置;DSH 重启会重读配置并重算 live 状态。在 DSH `0.1.5-rc.3` 及更早版本上,该配置是 `<DSH_HOME>/settings.yaml` 的 `key-fallback` 段;在 `0.1.7`+ 上则是 `~/.dsh/profiles/<profile>/cordis.patch.yml` 中 `key-fallback` 条目的 `config`。
|
|
85
143
|
- env key 不能通过 UI 删除(它是池的身份标识)。
|
|
86
144
|
|
|
87
145
|
## License
|
package/lib/index.js
CHANGED
|
@@ -32,9 +32,18 @@ function deriveEnvName(name) {
|
|
|
32
32
|
export const name = 'key-fallback'
|
|
33
33
|
export const inject = ['llm', 'settings', 'webServer', 'credentials']
|
|
34
34
|
const API_BASE = '/dsh-key-fallback'
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
35
|
+
// 遵循 DSH_HOME(未设置时回落 ~/.dsh):与 dsh-connector / dsh-archived-sessions 的
|
|
36
|
+
// 路径推导保持一致,也让隔离冒烟环境可以直接切换整个 home。
|
|
37
|
+
const DSH_HOME = (() => {
|
|
38
|
+
const h = process.env.DSH_HOME
|
|
39
|
+
return (typeof h === 'string' && h) ? h : join(homedir(), '.dsh')
|
|
40
|
+
})()
|
|
41
|
+
const SETTINGS_FILE = join(DSH_HOME, 'settings.yaml')
|
|
42
|
+
const SETTINGS_FILE_IMPORTED = SETTINGS_FILE + '.imported'
|
|
43
|
+
const CRED_FILE = join(DSH_HOME, '.credentials.yaml')
|
|
44
|
+
const BACKUP_DIR = join(DSH_HOME, 'backups')
|
|
45
|
+
// 一次性 legacy→profile 迁移的标记(见 apply() 里的迁移块)。
|
|
46
|
+
const MIGRATED_MARKER = join(DSH_HOME, '.key-fallback-migrated')
|
|
38
47
|
if (!existsSync(BACKUP_DIR)) try { mkdirSync(BACKUP_DIR, { recursive: true }) } catch (e) {}
|
|
39
48
|
let _backupCounter = 0
|
|
40
49
|
function tsName(prefix) {
|
|
@@ -66,21 +75,80 @@ const profileSchema = z.object({
|
|
|
66
75
|
refsCompacted: z.boolean().default(false),
|
|
67
76
|
})
|
|
68
77
|
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
// ── 配置后端(双版本兼容)─────────────────────────────────────────────
|
|
79
|
+
// 0.1.5-rc.3:配置在 <home>/settings.yaml 的 `key-fallback:` 段,插件自己读写该文件。
|
|
80
|
+
// 0.1.7+ :DSH 启动时把 settings.yaml 改名为 settings.yaml.imported,段内容成为该 entry 的
|
|
81
|
+
// config override(写进 profiles/<p>/cordis.patch.yml),由 ctx.settings 服务托管;
|
|
82
|
+
// 插件通过「volatile Config + describe/replace」读写,不再碰 settings.yaml。
|
|
83
|
+
//
|
|
84
|
+
// 关键前提:0.1.7 的迁移走 settings.update(ns, values) → write(),要求该 entry 存在且其
|
|
85
|
+
// Config 含 volatile 字段,否则抛错并打印「section ... was not imported」——配置静默丢失。
|
|
86
|
+
// 因此必须声明 Config(下面),且只在本版本 schemastery 支持 .volatile() 时声明,
|
|
87
|
+
// 使 rc.3 行为与改造前逐字节一致。
|
|
88
|
+
const _providersField = z.dict(z.any()).default({})
|
|
89
|
+
export const Config = (typeof _providersField.volatile === 'function')
|
|
90
|
+
? z.object({ providers: _providersField.volatile() })
|
|
91
|
+
: undefined
|
|
92
|
+
|
|
93
|
+
const ENTRY_ID = 'key-fallback'
|
|
94
|
+
const diagLog = []
|
|
95
|
+
const diag = (kind, msg) => {
|
|
71
96
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
97
|
+
diagLog.push({ kind, msg: String(msg).slice(0, 300), at: Date.now() })
|
|
98
|
+
if (diagLog.length > 200) diagLog.splice(0, diagLog.length - 200)
|
|
99
|
+
} catch (e) {}
|
|
100
|
+
}
|
|
101
|
+
let _settingsSvc = null // ctx.settings(0.1.7+)
|
|
102
|
+
let _liveRef = null // 0.1.7+: config.providers 的 volatile Ref(原地热更新)
|
|
103
|
+
let _cache = null // 0.1.7+: 内存权威副本(乐观写,避免异步落盘窗口内读到旧值)
|
|
104
|
+
let _writing = 0 // 未确认写入计数
|
|
105
|
+
|
|
106
|
+
function readSettingsFromFile() {
|
|
107
|
+
// settings.yaml 优先;0.1.7 迁移后已改名,回退读 .imported(只读引导,避免配置真空)
|
|
108
|
+
for (const file of [SETTINGS_FILE, SETTINGS_FILE_IMPORTED]) {
|
|
109
|
+
if (!existsSync(file)) continue
|
|
110
|
+
try {
|
|
111
|
+
const doc = loadYaml(readFileSync(file, 'utf8')) || {}
|
|
112
|
+
const block = (doc && (doc[ENTRY_ID] || doc.keyFallback)) || {}
|
|
113
|
+
if (block && block.providers) return block
|
|
114
|
+
} catch (e) {}
|
|
115
|
+
}
|
|
116
|
+
return { providers: {} }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function cloneJson(v) {
|
|
120
|
+
try { return v === undefined ? {} : JSON.parse(JSON.stringify(v)) } catch (e) { return v }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readSettings() {
|
|
124
|
+
if (_liveRef) {
|
|
125
|
+
// _liveRef 就是 config.providers 这个 Ref:.get() 直接返回 providers 字典。
|
|
126
|
+
// 它由 DSH deepFreeze,调用方却要就地改(新增/删除 provider、compactRefs 改 keyRefs),
|
|
127
|
+
// 所以这里始终返回「可变深拷贝」。未决写入期间以内存副本为源(replace 是异步的,
|
|
128
|
+
// 否则会读到落盘前的旧树,UI 立刻刷新会闪回旧值)。
|
|
129
|
+
let src = _cache
|
|
130
|
+
if (_writing === 0) { try { src = { providers: _liveRef.get() || {} } ; _cache = src } catch (e) {} }
|
|
131
|
+
if (!src) { try { src = { providers: _liveRef.get() || {} }; _cache = src } catch (e) { src = { providers: {} } } }
|
|
132
|
+
return { providers: cloneJson(src.providers) || {} }
|
|
133
|
+
}
|
|
134
|
+
return readSettingsFromFile()
|
|
77
135
|
}
|
|
78
136
|
|
|
79
137
|
function writeSettings(data) {
|
|
138
|
+
if (_liveRef) {
|
|
139
|
+
_cache = { providers: cloneJson((data && data.providers) || {}) }
|
|
140
|
+
if (!_settingsSvc || typeof _settingsSvc.replace !== 'function') return
|
|
141
|
+
_writing++
|
|
142
|
+
Promise.resolve()
|
|
143
|
+
.then(() => _settingsSvc.replace(ENTRY_ID, { providers: cloneJson(_cache.providers) }))
|
|
144
|
+
.catch((e) => diag('settings-write', 'replace failed: ' + ((e && e.message) || e)))
|
|
145
|
+
.finally(() => { _writing-- })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
80
148
|
backupFile(SETTINGS_FILE, 'settings')
|
|
81
149
|
let root = {}
|
|
82
150
|
try { if (existsSync(SETTINGS_FILE)) root = loadYaml(readFileSync(SETTINGS_FILE, 'utf8')) || {} } catch (e) {}
|
|
83
|
-
root[
|
|
151
|
+
root[ENTRY_ID] = data
|
|
84
152
|
writeFileSync(SETTINGS_FILE, dumpYaml(root, { lineWidth: 120 }), 'utf8')
|
|
85
153
|
}
|
|
86
154
|
|
|
@@ -168,11 +236,59 @@ function displayNameOf(entry) {
|
|
|
168
236
|
return ref
|
|
169
237
|
}
|
|
170
238
|
|
|
171
|
-
export function apply(ctx) {
|
|
172
|
-
try { ctx.logger.info('key-fallback:
|
|
173
|
-
const diagLog = []
|
|
174
|
-
const diag = (kind, msg) => { try { diagLog.push({ kind, msg: String(msg).slice(0, 300), at: Date.now() }); if (diagLog.length > 200) diagLog.splice(0, diagLog.length - 200) } catch (e) {} }
|
|
239
|
+
export function apply(ctx, config) {
|
|
240
|
+
try { ctx.logger.info('key-fallback: v7 host apply() starting') } catch (e) {}
|
|
175
241
|
diag('apply', 'host loaded')
|
|
242
|
+
|
|
243
|
+
// ── 0.1.7+:把配置接到 settings 服务托管的那份树 ──
|
|
244
|
+
// config.providers 平时是 volatile Ref(.get() 随编辑热更新);无 config 或非 Ref 时
|
|
245
|
+
// 保持文件后端。两者都在这里判定一次,之后 readSettings/writeSettings 自动分流。
|
|
246
|
+
try {
|
|
247
|
+
const svc = ctx.get('settings')
|
|
248
|
+
const prov = config && config.providers
|
|
249
|
+
if (svc && typeof svc.replace === 'function' && prov && typeof prov.get === 'function') {
|
|
250
|
+
_settingsSvc = svc
|
|
251
|
+
_liveRef = prov // = config.providers,Ref.get() 直接给 providers 字典
|
|
252
|
+
_cache = { providers: prov.get() || {} }
|
|
253
|
+
diag('settings', 'backend = profile entry (0.1.7+), providers=' + Object.keys(_cache.providers).join(','))
|
|
254
|
+
// 无需额外订阅:readSettings() 每次(无未决写入时)都从 Ref 重取,外部编辑自动生效
|
|
255
|
+
} else {
|
|
256
|
+
diag('settings', 'backend = settings.yaml file (rc.x)')
|
|
257
|
+
}
|
|
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
|
+
}
|
|
176
292
|
// ── 包装 credentials.resolve:ref 命中池 env 时返回池当前 key(llm-pi-ai 唯一读取入口)──
|
|
177
293
|
const poolsByEnv = new Map()
|
|
178
294
|
const credSvc = ctx.credentials
|
|
@@ -460,6 +576,11 @@ export function apply(ctx) {
|
|
|
460
576
|
if (webServer) {
|
|
461
577
|
const route = async (req, res) => {
|
|
462
578
|
try {
|
|
579
|
+
// 首次请求时补做 legacy 池迁移(此时 fiber 已 ACTIVE,settings.replace 可用)。
|
|
580
|
+
// 放在所有分支之前,保证无论 UI 先请求哪个端点,池都已被搬进 live 配置。
|
|
581
|
+
// 首次请求时补做 legacy 池迁移(此时 fiber 已 ACTIVE,settings.replace 可用)。
|
|
582
|
+
// 放在所有分支之前,保证无论 UI 先请求哪个端点,池都已被搬进 live 配置。
|
|
583
|
+
await ensureLegacyMigration()
|
|
463
584
|
const url = new URL(req.url, 'http://127.0.0.1')
|
|
464
585
|
const path = url.pathname.replace(new RegExp('^' + API_BASE + '/?'), '').replace(/\/$/, '')
|
|
465
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
|
-
"description": "API Key 回退轮换插件:多 key 池 + 自动轮换(触发码可配置,含 LlmError 标准码全集)+ 环境密钥编辑 + 明文揭示 + 短 ref
|
|
4
|
-
"version": "3.1
|
|
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.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -38,10 +38,10 @@
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"@deepseek-ai/cordis": "4.0.1 || 4.0.2",
|
|
42
|
-
"@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.6-alpha.1",
|
|
43
|
-
"@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.6-alpha.1",
|
|
44
|
-
"@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.6-alpha.1",
|
|
45
|
-
"@deepseek-ai/schemastery": "3.18.1 || 3.18.2"
|
|
41
|
+
"@deepseek-ai/cordis": "4.0.1 || 4.0.2 || 4.0.4",
|
|
42
|
+
"@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",
|
|
43
|
+
"@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",
|
|
44
|
+
"@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",
|
|
45
|
+
"@deepseek-ai/schemastery": "3.18.1 || 3.18.2 || 3.18.4"
|
|
46
46
|
}
|
|
47
47
|
}
|