@omdp/dsh-key-fallback 1.0.0

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 ADDED
@@ -0,0 +1,41 @@
1
+ # @omdp/dsh-key-fallback
2
+
3
+ A minimal API-key fallback plugin for DeepSeek Harness (DSH). When a provider request fails with a retryable error, it silently retries the same call with the next key in the pool instead of committing a partial message.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ dsh plugin --profile web add @omdp/dsh-key-fallback
9
+ ```
10
+
11
+ Restart DSH, then add a config block to `~/.dsh/settings.yaml`:
12
+
13
+ ```yaml
14
+ keyFallback:
15
+ providers:
16
+ ninerouter:
17
+ env: NINEROUTER_API_KEY # optional; defaults to ${PROVIDER}_API_KEY
18
+ cooldownMs: 30000 # optional; cooldown after a failure
19
+ keys:
20
+ - <your original key>
21
+ - <alternate key 1>
22
+ - <alternate key 2>
23
+ ```
24
+
25
+ ## How it works
26
+
27
+ - `agent/request`: picks the next live key (round-robin) and writes it into `process.env[<env>]` before the request goes out.
28
+ - `agent/request-error`: on a retryable error (`RATE_LIMIT`, `AUTH`, `QUOTA_EXCEEDED`, `TIMEOUT`, `TRANSPORT`, any 4xx/5xx, or a message matching rate/limit/quota/timeout/auth/key…), marks the current key as cooling and returns `{ kind: 'retry' }` so the loop retries and picks the next key.
29
+
30
+ ## Boundaries
31
+
32
+ - The plugin **overrides** `process.env[<env>]` for every provider you list under `keyFallback.providers`. Put your original key in `keys` so it stays in rotation.
33
+ - Providers you don't list are left untouched.
34
+ - With a single key, a retryable error surfaces directly (no pointless retry, no crash).
35
+ - With no configured keys for a provider, the plugin does nothing.
36
+
37
+ Settings → 插件 shows a read-only card with the key count, cursor, and cooling/healthy keys.
38
+
39
+ ## License
40
+
41
+ MIT
@@ -0,0 +1,5 @@
1
+ - insert:
2
+ - id: key-fallback
3
+ name: dsh-key-fallback
4
+ inject: [llm, settings, webServer]
5
+ config: {}
package/lib/client.js ADDED
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+ // @omdp/dsh-key-fallback — Client half
3
+ // Settings → 插件 里的只读状态卡:显示每个 provider 的 key 数 / 游标 / 冷却。
4
+ function apply(ctx) {
5
+ const slots = ctx.get('slots')
6
+ if (slots === undefined) return
7
+
8
+ async function fetchJson(url, opts) {
9
+ const r = await fetch(url, opts)
10
+ return r.json()
11
+ }
12
+
13
+ function KeyFallbackCard() {
14
+ const [state, setState] = React.useState({ loading: true, data: null })
15
+ const refresh = React.useCallback(function () {
16
+ fetchJson('/dsh-key-fallback/pools')
17
+ .then(function (data) { setState({ loading: false, data: (data && data.pools) || {} }) })
18
+ .catch(function () { setState({ loading: false, data: null }) })
19
+ }, [])
20
+ React.useEffect(function () {
21
+ refresh()
22
+ const t = setInterval(refresh, 2000)
23
+ return function () { clearInterval(t) }
24
+ }, [refresh])
25
+
26
+ if (state.loading) {
27
+ return React.createElement('div', { style: { fontSize: '12px', opacity: 0.6 } }, '加载中…')
28
+ }
29
+ const data = state.data || {}
30
+ const providers = Object.keys(data)
31
+ if (providers.length === 0) {
32
+ return React.createElement('p', { style: { fontSize: '12px', opacity: 0.6, margin: 0 } },
33
+ '未在 settings.yaml 配置 keyFallback.providers')
34
+ }
35
+ return React.createElement('div', { style: { fontSize: '12px', display: 'flex', flexDirection: 'column', gap: '8px' } },
36
+ providers.map(function (p) {
37
+ const pd = data[p]
38
+ return React.createElement('div', { key: p, style: { padding: '6px 0', borderTop: '1px solid var(--dsw-alias-border-l2, #eee)' } },
39
+ React.createElement('div', { style: { fontWeight: 600 } }, p + ' (' + pd.env + ')'),
40
+ React.createElement('div', { style: { opacity: 0.8 } }, 'key 数: ' + pd.keyCount + ',当前游标: ' + pd.currentIndex),
41
+ pd.cooling.length > 0
42
+ ? React.createElement('div', { style: { color: '#c62828' } }, '冷却中: ' + pd.cooling.join(', '))
43
+ : null,
44
+ pd.healthy.length > 0
45
+ ? React.createElement('div', { style: { color: '#2e7d32' } }, '健康: ' + pd.healthy.join(', '))
46
+ : null,
47
+ )
48
+ }),
49
+ )
50
+ }
51
+
52
+ ctx.slots.inject('settings.plugin.item', function* () {
53
+ yield ctx.slots.register({
54
+ name: 'settings.plugin.item',
55
+ key: 'key-fallback',
56
+ id: 'key-fallback',
57
+ order: 50,
58
+ label: () => 'API Key 回退',
59
+ inject: () => ({}),
60
+ }, KeyFallbackCard)
61
+ })
62
+ }
63
+
64
+ exports.apply = apply
65
+ exports.inject = ['slots']
package/lib/index.js ADDED
@@ -0,0 +1,178 @@
1
+ 'use strict'
2
+ // @omdp/dsh-key-fallback — Host half
3
+ // 极简 API Key 回退:请求报错 → 静默换下一个 key 重试同一请求。
4
+ const { readFileSync, existsSync } = require('node:fs')
5
+ const { homedir } = require('node:os')
6
+ const { join } = require('node:path')
7
+ const { load: loadYaml } = (() => {
8
+ try { return require('js-yaml') }
9
+ catch { return { load: (s) => { try { return JSON.parse(s) } catch { return {} } } } }
10
+ })()
11
+
12
+ const name = 'key-fallback'
13
+ const inject = ['llm', 'settings', 'webServer']
14
+ const API_BASE = '/dsh-key-fallback'
15
+ const SETTINGS_FILE = join(homedir(), '.dsh', 'settings.yaml')
16
+
17
+ function mask(key) {
18
+ if (!key) return ''
19
+ if (key.length <= 8) return key.slice(0, 2) + '****'
20
+ return key.slice(0, 6) + '****' + key.slice(-4)
21
+ }
22
+ function sendJson(res, code, data) {
23
+ const payload = JSON.stringify(data)
24
+ res.writeHead(code, {
25
+ 'Content-Type': 'application/json; charset=utf-8',
26
+ 'Content-Length': Buffer.byteLength(payload),
27
+ 'Cache-Control': 'no-store',
28
+ })
29
+ res.end(payload)
30
+ }
31
+
32
+ // 从 settings.yaml 读配置块(keyFallback 或 key-fallback 均可)
33
+ function readConfig() {
34
+ try {
35
+ if (!existsSync(SETTINGS_FILE)) return {}
36
+ const raw = readFileSync(SETTINGS_FILE, 'utf8')
37
+ const doc = (loadYaml && typeof loadYaml === 'function') ? loadYaml(raw) : {}
38
+ const blk = (doc && (doc['keyFallback'] || doc['key-fallback'])) || {}
39
+ return blk
40
+ } catch (e) { return {} }
41
+ }
42
+
43
+ function apply(ctx) {
44
+ // 占位命名空间:让只读状态卡出现在 Settings → 插件(keyed slot 要求 key ∈ served)
45
+ ctx.inject(['settings'], (sctx) => {
46
+ try {
47
+ if (typeof zs !== 'undefined') sctx.settings.register('key-fallback', zs.object({}), { base: {} })
48
+ } catch (e) { /* 命名空间注册失败不影响核心轮换逻辑 */ }
49
+ })
50
+
51
+ const pools = new Map()
52
+ const attempts = new Map() // `${turn}:${step}` -> 已尝试次数(防止死循环)
53
+ const lifetime = new AbortController()
54
+
55
+ function buildPools() {
56
+ const cfg = readConfig()
57
+ const providers = (cfg && cfg.providers) || {}
58
+ for (const [provider, pcfg] of Object.entries(providers)) {
59
+ const env = (pcfg && pcfg.env) || `${provider.toUpperCase()}_API_KEY`
60
+ const keys = Array.isArray(pcfg && pcfg.keys) ? pcfg.keys.filter(Boolean) : []
61
+ const cooldown = (pcfg && pcfg.cooldownMs) || 30000
62
+ if (!pools.has(provider)) {
63
+ pools.set(provider, {
64
+ env, keys, cooldown, idx: 0,
65
+ states: new Map(keys.map((k) => [k, { failCount: 0, cooldownUntil: 0 }])),
66
+ })
67
+ } else {
68
+ const p = pools.get(provider)
69
+ const added = keys.filter((k) => !p.keys.includes(k))
70
+ for (const k of added) p.states.set(k, { failCount: 0, cooldownUntil: 0 })
71
+ p.keys = keys
72
+ p.env = env
73
+ p.cooldown = cooldown
74
+ }
75
+ }
76
+ }
77
+ buildPools()
78
+
79
+ function pickKey(provider) {
80
+ const pool = pools.get(provider)
81
+ if (!pool || pool.keys.length === 0) return undefined
82
+ const now = Date.now()
83
+ const live = pool.keys.filter((k) => ((pool.states.get(k) && pool.states.get(k).cooldownUntil) || 0) <= now)
84
+ if (live.length === 0) return pool.keys[pool.idx % pool.keys.length]
85
+ for (let i = 0; i < live.length; i++) {
86
+ const key = live[(pool.idx + i) % live.length]
87
+ const realIdx = pool.keys.indexOf(key)
88
+ if (realIdx >= 0) { pool.idx = (realIdx + 1) % pool.keys.length; return key }
89
+ }
90
+ return pool.keys[0]
91
+ }
92
+
93
+ function markFailed(provider, key) {
94
+ const pool = pools.get(provider)
95
+ if (!pool || !key) return
96
+ const st = pool.states.get(key)
97
+ if (!st) return
98
+ st.failCount += 1
99
+ st.cooldownUntil = Date.now() + pool.cooldown * Math.min(st.failCount, 5)
100
+ }
101
+
102
+ // ── agent/request:先拿 provider,再选 key 注入 env ──
103
+ ctx.on('agent/request', async (payload, next) => {
104
+ const config = await next()
105
+ const provider = config && config.provider
106
+ if (!provider) return config
107
+ buildPools()
108
+ const pool = pools.get(provider)
109
+ if (!pool || pool.keys.length === 0) return config
110
+ const key = pickKey(provider)
111
+ if (!key) return config
112
+ process.env[pool.env] = key
113
+ return config
114
+ })
115
+
116
+ // 是否还有「别的、未冷却」的 key 可用(单 key / 全冷却 / 未配置时返回 false)
117
+ function hasOtherLiveKey(provider, excludeKey) {
118
+ const pool = pools.get(provider)
119
+ if (!pool) return false
120
+ const now = Date.now()
121
+ return pool.keys.some((k) => k !== excludeKey && ((pool.states.get(k) && pool.states.get(k).cooldownUntil) || 0) <= now)
122
+ }
123
+
124
+ // ── agent/request-error:命中可重试错误 → 标当前 key 失效 + 重试 ──
125
+ ctx.on('agent/request-error', async (payload, next) => {
126
+ const code = String((payload && ((payload.failure && payload.failure.code) || payload.code)) || '')
127
+ const rawMsg = String((payload && ((payload.failure && payload.failure.message) || payload.message)) || '')
128
+ const provider = (payload && payload.provider) || ''
129
+ const RETRYABLE = ['RATE_LIMIT', 'AUTH', 'QUOTA_EXCEEDED', 'TIMEOUT', 'TRANSPORT']
130
+ const isRetryable = RETRYABLE.includes(code) ||
131
+ /^[45]\d\d$/.test(code) ||
132
+ /rate|limit|quota|exhaust|throttl|429|timeout|timed?out|auth|credential|denied|forbidden|unavailable|busy|key/i.test(rawMsg)
133
+ if (provider && isRetryable) {
134
+ const pool = pools.get(provider)
135
+ if (!pool || pool.keys.length === 0) return next() // 未配置:不接管,原样委托
136
+ const cur = process.env[pool.env]
137
+ if (cur && pool.keys.includes(cur)) markFailed(provider, cur)
138
+ // 只有还有别的未冷却 key 才重试;单 key 或全冷却时直接放弃(不无意义重试,也不崩溃)
139
+ if (hasOtherLiveKey(provider, cur)) {
140
+ // 安全上限:同一 step 内每个 key 至多试一次,全试完仍失败则放弃(终态)
141
+ const ak = `${payload.turn}:${payload.step}`
142
+ const n = (attempts.get(ak) || 0) + 1
143
+ attempts.set(ak, n)
144
+ if (n <= pool.keys.length) return { kind: 'retry' }
145
+ attempts.delete(ak)
146
+ }
147
+ }
148
+ return next()
149
+ })
150
+
151
+ // ── 只读状态接口(给卡片用)──
152
+ const webServer = ctx.get('webServer')
153
+ if (webServer) {
154
+ ctx.effect(() => webServer.register({
155
+ kind: 'exact',
156
+ path: `${API_BASE}/pools`,
157
+ handler: (req, res) => {
158
+ if (req.method !== 'GET') { sendJson(res, 405, { error: 'method not allowed' }); return }
159
+ const now = Date.now()
160
+ const out = {}
161
+ for (const [provider, p] of pools) {
162
+ out[provider] = {
163
+ env: p.env,
164
+ keyCount: p.keys.length,
165
+ currentIndex: p.idx,
166
+ cooling: p.keys.filter((k) => ((p.states.get(k) && p.states.get(k).cooldownUntil) || 0) > now).map(mask),
167
+ healthy: p.keys.filter((k) => ((p.states.get(k) && p.states.get(k).cooldownUntil) || 0) <= now).map(mask),
168
+ }
169
+ }
170
+ sendJson(res, 200, { pools: out })
171
+ },
172
+ }), 'key-fallback: pools route')
173
+ }
174
+
175
+ ctx.effect(() => () => { lifetime.abort() })
176
+ }
177
+
178
+ module.exports = { name, inject, apply }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@omdp/dsh-key-fallback",
3
+ "description": "Minimal API key fallback for DeepSeek Harness: on a retryable request error, silently retry the same call with the next key in the pool.",
4
+ "version": "1.0.0",
5
+ "type": "commonjs",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": { "default": "./lib/index.js" },
9
+ "./client": { "default": "./lib/client.js" },
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "README.md"
16
+ ],
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/XJungit/omdp.git"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dsh": {
26
+ "bundle": { "patch": "./cordis.patch.yml" },
27
+ "client": {
28
+ "platform": "web",
29
+ "inject": ["@deepseek-ai/dsh-client-ui-settings"]
30
+ }
31
+ },
32
+ "peerDependencies": {
33
+ "@deepseek-ai/cordis": "^4.0.1",
34
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
35
+ "@deepseek-ai/schemastery": "^3.18.1"
36
+ }
37
+ }