@omdp/dsh-key-fallback 1.0.7 → 3.0.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/lib/index.js CHANGED
@@ -1,198 +1,505 @@
1
- // @omdp/dsh-key-fallback — Host half (ESM)
2
- // 极简 API Key 回退:请求报错 → 静默换下一个 key 重试同一请求。
1
+ // @omdp/dsh-key-fallback — Host half (ESM, v5)
3
2
  //
4
- // 注意:必须 ESM + 静态 import(与 dshmarket / vision-bridge 一致)。
5
- // DSH 的 bundle loader 对 CommonJS 的 require() 是受限的(先前实测
6
- // dshSettings=false zs=false),只有 ESM import 能解析 DSH 内部包
7
- // (@deepseek-ai/dsh-settings / @deepseek-ai/schemastery)。
8
- import { readFileSync, existsSync } from 'node:fs'
3
+ // v5 = v1 核心功能全量恢复(预写 + 失败标记 + 顺序配置)+ v4 的 CRUD 与极简配置:
4
+ // - 配置来源 = ~/.dsh/settings.yaml 的 key-fallback.providers[*]
5
+ // - agent/request 预写:每次请求把池当前 key 写入 env + credentials(v1 行为)
6
+ // - agent/request-error:标记失败的 key(failCount/cooldown/status/lastError)并按 nextRef 顺序切下一个
7
+ // - 每个 key 可配置 nextRef(失败后跳转到指定 key),UI 可直接配置轮换顺序
8
+ // - GET /pools 返回每个 key 的实时状态(status/failCount/cooldownRemaining/lastError),UI 展示
9
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
9
10
  import { homedir } from 'node:os'
10
11
  import { join } from 'node:path'
11
- import { load as loadYaml } from 'js-yaml'
12
- import * as dshSettings from '@deepseek-ai/dsh-settings'
13
- import * as zs from '@deepseek-ai/schemastery'
12
+ import { load as loadYaml, dump as dumpYaml } from 'js-yaml'
13
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
14
+ import z from '@deepseek-ai/schemastery'
15
+ // 当前版本 @deepseek-ai/dsh-credentials 只导出 credentialRef,不再导出
16
+ // isCredentialRefName。按官方 REF_PATTERN(POSIX 标识符)在本地板实现,保持兼容。
17
+ function isCredentialRefName(value) {
18
+ return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value)
19
+ }
14
20
 
15
21
  export const name = 'key-fallback'
16
22
  export const inject = ['llm', 'settings', 'webServer', 'credentials']
17
23
  const API_BASE = '/dsh-key-fallback'
18
24
  const SETTINGS_FILE = join(homedir(), '.dsh', 'settings.yaml')
19
- // 与客户端 slots.register key 保持一致的命名空间名。
20
- const SETTINGS_NS = 'key-fallback'
21
-
22
- function mask(key) {
23
- if (!key) return ''
24
- if (key.length <= 8) return key.slice(0, 2) + '****'
25
- return key.slice(0, 6) + '****' + key.slice(-4)
25
+ const CRED_FILE = join(homedir(), '.dsh', '.credentials.yaml')
26
+ const BACKUP_DIR = join(homedir(), '.dsh', 'backups')
27
+ if (!existsSync(BACKUP_DIR)) try { mkdirSync(BACKUP_DIR, { recursive: true }) } catch (e) {}
28
+ let _backupCounter = 0
29
+ function tsName(prefix) {
30
+ const d = new Date()
31
+ const ts = d.toISOString().replace(/[:.]/g, '-').slice(0, 19)
32
+ return prefix + '_' + ts + '_' + (++_backupCounter)
26
33
  }
27
- function sendJson(res, code, data) {
28
- const payload = JSON.stringify(data)
29
- res.writeHead(code, {
30
- 'Content-Type': 'application/json; charset=utf-8',
31
- 'Content-Length': Buffer.byteLength(payload),
32
- 'Cache-Control': 'no-store',
33
- })
34
- res.end(payload)
34
+ function backupFile(file, prefix) {
35
+ try {
36
+ if (!existsSync(file)) return
37
+ const dest = BACKUP_DIR + '/' + tsName(prefix)
38
+ copyFileSync(file, dest)
39
+ } catch (e) {}
35
40
  }
36
41
 
37
- // 从 settings.yaml 读配置块(keyFallback 或 key-fallback 均可)
38
- function readConfig() {
42
+ const profileSchema = z.object({
43
+ env: z.string().pattern(/^[A-Za-z_][A-Za-z0-9_]*$/).default('NINEROUTER_API_KEY'),
44
+ cooldownMs: z.number().min(0).default(30000),
45
+ enabled: z.boolean().default(true),
46
+ rotateOn: z.array(z.string()).default(['QUOTA_EXCEEDED', 'AUTH', 'RATE_LIMIT']),
47
+ keyRefs: z.array(z.object({
48
+ ref: z.string().pattern(/^[A-Za-z_][A-Za-z0-9_]*$/),
49
+ label: z.string().default(''),
50
+ createdAt: z.number().default(0),
51
+ nextRef: z.string().default(''),
52
+ })).default([]),
53
+ displayName: z.string().default(''),
54
+ useKeyRef: z.string().default(''),
55
+ })
56
+
57
+ function readSettings() {
58
+ if (!existsSync(SETTINGS_FILE)) return { providers: {} }
39
59
  try {
40
- if (!existsSync(SETTINGS_FILE)) return {}
41
60
  const raw = readFileSync(SETTINGS_FILE, 'utf8')
42
- const doc = (loadYaml && typeof loadYaml === 'function') ? loadYaml(raw) : {}
43
- const blk = (doc && (doc['keyFallback'] || doc['key-fallback'])) || {}
44
- return blk
45
- } catch (e) { return {} }
61
+ const doc = loadYaml(raw) || {}
62
+ const block = (doc && (doc['key-fallback'] || doc.keyFallback)) || {}
63
+ return block && block.providers ? block : { providers: {} }
64
+ } catch (e) { return { providers: {} } }
46
65
  }
47
66
 
48
- export function apply(ctx) {
49
- // 注册 settings 命名空间(官方助手 installSettingsSection,与 dshmarket 同款):
50
- // 让客户端 Settings → 插件的 keyed slot(settings.plugin.item, key='key-fallback')
51
- // 被 serve 并渲染状态卡。状态卡只读;轮换逻辑仍直接读 settings.yaml(原设计)。
67
+ function writeSettings(data) {
68
+ backupFile(SETTINGS_FILE, 'settings')
69
+ let root = {}
70
+ try { if (existsSync(SETTINGS_FILE)) root = loadYaml(readFileSync(SETTINGS_FILE, 'utf8')) || {} } catch (e) {}
71
+ root['key-fallback'] = data
72
+ writeFileSync(SETTINGS_FILE, dumpYaml(root, { lineWidth: 120 }), 'utf8')
73
+ }
74
+
75
+ function mask(v) {
76
+ if (!v) return ''
77
+ const s = String(v)
78
+ if (s.length <= 8) return s.slice(0, 2) + '****'
79
+ return s.slice(0, 6) + '****' + s.slice(-4)
80
+ }
81
+
82
+ function sendJson(res, code, data) {
83
+ const payload = JSON.stringify(data)
52
84
  try {
53
- const ns = dshSettings.settingsNamespace(SETTINGS_NS)
54
- const schema = zs.object({
55
- providers: zs.dict(zs.object({
56
- env: zs.string(),
57
- keys: zs.array(zs.string()),
58
- cooldownMs: zs.natural(),
59
- })),
60
- })
61
- dshSettings.installSettingsSection(ctx, ns, schema, {}, {
62
- setSource: () => {},
63
- onChange: () => {},
64
- })
65
- } catch (e) {
66
- // 命名空间注册失败不影响核心轮换逻辑
85
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(payload), 'Cache-Control': 'no-store' })
86
+ res.end(payload)
87
+ } catch (e) { try { res.end() } catch {} }
88
+ }
89
+
90
+ const RETRYABLE_CODES = ['RATE_LIMIT', 'AUTH', 'QUOTA_EXCEEDED', 'TIMEOUT', 'TRANSPORT']
91
+
92
+ export function apply(ctx) {
93
+ try { ctx.logger.info('key-fallback: v5 host apply() starting (config = settings.yaml)') } catch (e) {}
94
+ const diagLog = []
95
+ 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) {} }
96
+ diag('apply', 'host loaded')
97
+ // ── 包装 credentials.resolve:ref 命中池 env 时返回池当前 key(llm-pi-ai 唯一读取入口)──
98
+ const poolsByEnv = new Map()
99
+ const credSvc = ctx.credentials
100
+ // 原始 resolve(未 wrap 版本):池重建/读 env 真实值必须走它,否则 wrap 会短路成 currentRef 的值(污染)
101
+ const rawResolve = (credSvc && typeof credSvc.resolve === 'function') ? credSvc.resolve.bind(credSvc) : null
102
+ if (credSvc && typeof credSvc.resolve === 'function') {
103
+ credSvc.resolve = async (ref) => {
104
+ let result
105
+ try { result = await rawResolve(ref) } catch (e) { result = undefined }
106
+ const refName = String(ref || '')
107
+ const pool = poolsByEnv.get(refName)
108
+ if (pool && pool.currentRef) {
109
+ const cur = pool.keyValues.find((k) => k.ref === pool.currentRef)
110
+ if (cur) { diag('resolve', 'pool-hit ' + refName + ' -> ' + cur.ref.slice(-12)); return { value: cur.value, source: 'pool' } }
111
+ }
112
+ return result
113
+ }
114
+ diag('apply', 'credentials.resolve wrapped')
67
115
  }
68
116
 
69
- const pools = new Map()
70
- const attempts = new Map() // `${turn}:${step}` -> 已尝试次数(防止死循环)
117
+ const pools = new Map() // provider -> { cfg, keyValues, cursor, currentRef }
71
118
  const lifetime = new AbortController()
72
119
 
73
- function buildPools() {
74
- const cfg = readConfig()
120
+ function readPools() {
121
+ const cfg = readSettings()
75
122
  const providers = (cfg && cfg.providers) || {}
76
- for (const [provider, pcfg] of Object.entries(providers)) {
77
- const env = (pcfg && pcfg.env) || `${provider.toUpperCase()}_API_KEY`
78
- const keys = Array.isArray(pcfg && pcfg.keys) ? pcfg.keys.filter(Boolean) : []
79
- const cooldown = (pcfg && pcfg.cooldownMs) || 30000
80
- if (!pools.has(provider)) {
81
- pools.set(provider, {
82
- env, keys, cooldown, idx: 0,
83
- states: new Map(keys.map((k) => [k, { failCount: 0, cooldownUntil: 0 }])),
84
- })
85
- } else {
86
- const p = pools.get(provider)
87
- const added = keys.filter((k) => !p.keys.includes(k))
88
- for (const k of added) p.states.set(k, { failCount: 0, cooldownUntil: 0 })
89
- p.keys = keys
90
- p.env = env
91
- p.cooldown = cooldown
92
- }
123
+ for (const name in providers) {
124
+ if (!pools.has(name)) pools.set(name, { cfg: providers[name], keyValues: [], cursor: 0, currentRef: '' })
125
+ else pools.get(name).cfg = providers[name]
126
+ const envName = (providers[name] && providers[name].env) || (name.toUpperCase() + '_API_KEY')
127
+ poolsByEnv.set(envName, pools.get(name))
93
128
  }
129
+ for (const name of [...pools.keys()]) if (!providers[name]) pools.delete(name)
94
130
  }
95
- buildPools()
96
131
 
97
- function pickKey(provider) {
132
+ async function resolvePoolKeys(pool) {
133
+ // 保留已有状态(failCount/cooldown/lastError),避免重建时丢状态
134
+ const prev = new Map(pool.keyValues.map((k) => [k.ref, k]))
135
+ pool.keyValues = []
136
+ for (const entry of (pool.cfg.keyRefs || [])) {
137
+ const ref = (entry && typeof entry === 'object') ? entry.ref : entry
138
+ const nextRef = (entry && typeof entry === 'object' && entry.nextRef) ? entry.nextRef : ''
139
+ if (!ref) continue
140
+ try {
141
+ const hit = rawResolve ? await rawResolve(credentialRef(ref)) : await ctx.credentials.resolve(credentialRef(ref))
142
+ if (hit && hit.value) {
143
+ const old = prev.get(ref) || {}
144
+ pool.keyValues.push({
145
+ ref, value: hit.value, source: 'user',
146
+ nextRef,
147
+ failCount: old.failCount || 0,
148
+ cooldownUntil: old.cooldownUntil || 0,
149
+ lastErrorAt: old.lastErrorAt || 0,
150
+ lastErrorMsg: old.lastErrorMsg || '',
151
+ })
152
+ }
153
+ } catch (e) {}
154
+ }
155
+ // env key 加入轮换池(source:'env')—— keyValues 里的普通一员,可选中/失败/冷却/轮换
156
+ pool.envKeyValue = ''
157
+ const envRef = pool.cfg.env
158
+ if (envRef) {
159
+ try {
160
+ const hit = rawResolve ? await rawResolve(credentialRef(envRef)) : await ctx.credentials.resolve(credentialRef(envRef))
161
+ if (hit && hit.value) {
162
+ pool.envKeyValue = hit.value
163
+ const old = prev.get(envRef) || {}
164
+ const found = pool.keyValues.find((k) => k.ref === envRef)
165
+ if (!found) {
166
+ pool.keyValues.push({
167
+ ref: envRef, value: hit.value, source: 'env',
168
+ nextRef: '',
169
+ failCount: old.failCount || 0,
170
+ cooldownUntil: old.cooldownUntil || 0,
171
+ lastErrorAt: old.lastErrorAt || 0,
172
+ lastErrorMsg: old.lastErrorMsg || '',
173
+ })
174
+ }
175
+ }
176
+ } catch (e) {}
177
+ }
178
+ if (pool.cfg.useKeyRef && !pool.keyValues.find((k) => k.ref === pool.cfg.useKeyRef)) pool.cfg.useKeyRef = ''
179
+ }
180
+
181
+ async function getPool(provider) {
182
+ readPools()
98
183
  const pool = pools.get(provider)
99
- if (!pool || pool.keys.length === 0) return undefined
184
+ if (!pool) return undefined
185
+ await resolvePoolKeys(pool)
186
+ return pool
187
+ }
188
+
189
+ function isLive(k) {
100
190
  const now = Date.now()
101
- const live = pool.keys.filter((k) => ((pool.states.get(k) && pool.states.get(k).cooldownUntil) || 0) <= now)
102
- if (live.length === 0) return pool.keys[pool.idx % pool.keys.length]
103
- for (let i = 0; i < live.length; i++) {
104
- const key = live[(pool.idx + i) % live.length]
105
- const realIdx = pool.keys.indexOf(key)
106
- if (realIdx >= 0) { pool.idx = (realIdx + 1) % pool.keys.length; return key }
191
+ return ((k.failCount || 0) === 0) || ((k.cooldownUntil || 0) <= now)
192
+ }
193
+
194
+ function pickKey(pool) {
195
+ // useKeyRef 锁定优先 —— 但冷却中的锁定 key 不能选(否则永远重试坏 key)
196
+ if (pool.cfg.useKeyRef) {
197
+ const locked = pool.keyValues.find((k) => k.ref === pool.cfg.useKeyRef && isLive(k))
198
+ if (locked) return locked
107
199
  }
108
- return pool.keys[0]
200
+ const live = pool.keyValues.filter(isLive)
201
+ const list = live.length > 0 ? live : pool.keyValues
202
+ if (list.length === 0) return undefined
203
+ // 优先 non-locked live key,避免锁死
204
+ const nonLocked = list.filter((k) => k.ref !== pool.cfg.useKeyRef)
205
+ const pickFrom = nonLocked.length > 0 ? nonLocked : list
206
+ const start = pool.cursor % pickFrom.length
207
+ pool.cursor = (start + 1) % pickFrom.length
208
+ return pickFrom[start]
109
209
  }
110
210
 
111
- function markFailed(provider, key) {
112
- const pool = pools.get(provider)
113
- if (!pool || !key) return
114
- const st = pool.states.get(key)
115
- if (!st) return
116
- st.failCount += 1
117
- st.cooldownUntil = Date.now() + pool.cooldown * Math.min(st.failCount, 5)
211
+ function markFailed(pool, ref, code, msg) {
212
+ const kv = pool.keyValues.find((k) => k.ref === ref)
213
+ if (!kv) return
214
+ kv.failCount = (kv.failCount || 0) + 1
215
+ // 固定冷却时长(用户配置 cooldownMs),不做指数退避 —— 避免冷却越来越长
216
+ kv.cooldownUntil = Date.now() + (pool.cfg.cooldownMs || 30000)
217
+ kv.lastErrorAt = Date.now()
218
+ kv.lastErrorMsg = (code ? code + ': ' : '') + String(msg || '').slice(0, 200)
118
219
  }
119
220
 
120
- // ── agent/request:先拿 provider,再选 key 注入 env ──
121
- ctx.on('agent/request', async (payload, next) => {
122
- const config = await next()
123
- const provider = config && config.provider
124
- if (!provider) return config
125
- buildPools()
126
- const pool = pools.get(provider)
127
- if (!pool || pool.keys.length === 0) return config
128
- const key = pickKey(provider)
129
- if (!key) return config
130
- process.env[pool.env] = key
131
- // ninerouter 这类 llm-pi-ai provider 凭据走 credentials 域(apiKeyEnv),
132
- // 只写 env 不会生效 — 同步覆盖 credentials,使真实请求用池中 key。
133
- if (ctx.credentials) {
134
- try { await ctx.credentials.set(pool.env, key) } catch (e) {}
221
+ // 按配置的顺序选下一个 key:
222
+ // 1) 当前失败 key 的 nextRef(显式指定下一个)
223
+ // 2) 否则 cursor 顺序下一个 live key
224
+ function nextKeyFor(pool, curRef) {
225
+ // 失败轮换:纯按 nextRef 顺序(useKeyRef 锁定只在初始选择时生效,不锁死轮换)
226
+ const cur = pool.keyValues.find((k) => k.ref === curRef)
227
+ if (cur && cur.nextRef) {
228
+ const nk = pool.keyValues.find((k) => k.ref === cur.nextRef && isLive(k))
229
+ if (nk) return nk
135
230
  }
136
- return config
137
- })
231
+ // 无 nextRef 或指向冷却 key:顺序选下一个 live key(跳过自己)
232
+ const live = pool.keyValues.filter((k) => isLive(k) && k.ref !== curRef)
233
+ if (live.length > 0) return live[0]
234
+ // 无可轮换的 live key → 返回 undefined(停止重试,不再死循环)
235
+ return undefined
236
+ }
138
237
 
139
- // 是否还有「别的、未冷却」的 key 可用(单 key / 全冷却 / 未配置时返回 false)
140
- function hasOtherLiveKey(provider, excludeKey) {
141
- const pool = pools.get(provider)
142
- if (!pool) return false
143
- const now = Date.now()
144
- return pool.keys.some((k) => k !== excludeKey && ((pool.states.get(k) && pool.states.get(k).cooldownUntil) || 0) <= now)
238
+ // ── 预写:v1 同款 —— 全局 agent/request(DSH 全局收得到这个 waterfall),
239
+ // 每次请求同步 buildPools() 重读 settings,选 key,同步写 process.env,
240
+ // credentials.set fire-and-forget(不 await 阻塞 waterfall)。
241
+ async function warmPools() {
242
+ readPools()
243
+ for (const name of pools.keys()) {
244
+ const pool = pools.get(name)
245
+ if (pool) {
246
+ try { await resolvePoolKeys(pool) } catch (e) {}
247
+ // 预热时确定初始 currentRef:useKeyRef 锁定优先,否则 pickKey
248
+ if (pool.keyValues.length > 0 && !pool.currentRef) {
249
+ const locked = pool.cfg.useKeyRef && pool.keyValues.find((k) => k.ref === pool.cfg.useKeyRef)
250
+ pool.currentRef = (locked && locked.ref) || (pickKey(pool) && pickKey(pool).ref) || pool.keyValues[0].ref
251
+ }
252
+ }
253
+ }
145
254
  }
146
255
 
147
- // ── agent/request-error:命中可重试错误 → 标当前 key 失效 + 重试 ──
256
+ // 启动时预热 pools(resolve keyValues,供同步预写 pickKey 用)
257
+ try { warmPools().catch(() => {}) } catch (e) {}
258
+ // 每次 settings 变化也刷新 —— webServer 路由里已有调用,这里兜底周期不设
259
+
260
+ ctx.on('agent/request', (payload, next) => {
261
+ // next() 是 async,但我们要同步写 env —— 用 then 链,立即返回 p 不阻塞 waterfall
262
+ const p = next()
263
+ Promise.resolve(p).then((config) => {
264
+ try {
265
+ const provider = config && config.provider
266
+ if (!provider) return config
267
+ // 同步重建 pools(v1 同款:每次请求重读 settings.yaml)
268
+ readPools()
269
+ const pool = pools.get(provider)
270
+ if (!pool || pool.cfg.enabled === false) return config
271
+ // 当前 key:已有 currentRef 且 live 则用(尊重锁定/轮换结果),否则 pickKey
272
+ let key = pool.currentRef && pool.keyValues.find((k) => k.ref === pool.currentRef && isLive(k))
273
+ if (!key) key = pickKey(pool)
274
+ if (!key) return config
275
+ pool.currentRef = key.ref
276
+ diag('agent/request', 'PICK ' + key.ref + ' env=' + pool.cfg.env)
277
+ try { process.env[pool.cfg.env] = key.value } catch (e) {}
278
+ try { backupFile(CRED_FILE, 'credentials') } catch (e) {}
279
+ } catch (e) {}
280
+ return config
281
+ }).catch((e) => {})
282
+ return p
283
+ })
284
+
285
+ // ── 失败处理:标记当前 key + 切下一个(prepend 注册:先于 dsh-llm-retry 看到错误,切完 key 再放行)──
148
286
  ctx.on('agent/request-error', async (payload, next) => {
149
- const code = String((payload && ((payload.failure && payload.failure.code) || payload.code)) || '')
150
- const rawMsg = String((payload && ((payload.failure && payload.failure.message) || payload.message)) || '')
287
+ const code = String((payload && payload.failure && payload.failure.code) || (payload && payload.code) || '')
288
+ const rawMsg = String((payload && payload.failure && payload.failure.message) || (payload && payload.message) || '')
151
289
  const provider = (payload && payload.provider) || ''
152
- const RETRYABLE = ['RATE_LIMIT', 'AUTH', 'QUOTA_EXCEEDED', 'TIMEOUT', 'TRANSPORT']
153
- const isRetryable = RETRYABLE.includes(code) ||
154
- /^[45]\d\d$/.test(code) ||
155
- /rate|limit|quota|exhaust|throttl|429|timeout|timed?out|auth|credential|denied|forbidden|unavailable|busy|key/i.test(rawMsg)
156
- if (provider && isRetryable) {
157
- const pool = pools.get(provider)
158
- if (!pool || pool.keys.length === 0) return next() // 未配置:不接管,原样委托
159
- const cur = process.env[pool.env]
160
- if (cur && pool.keys.includes(cur)) markFailed(provider, cur)
161
- // 只有还有别的未冷却 key 才重试;单 key 或全冷却时直接放弃(不无意义重试,也不崩溃)
162
- if (hasOtherLiveKey(provider, cur)) {
163
- // 安全上限:同一 step 内每个 key 至多试一次,全试完仍失败则放弃(终态)
164
- const ak = `${payload.turn}:${payload.step}`
165
- const n = (attempts.get(ak) || 0) + 1
166
- attempts.set(ak, n)
167
- if (n <= pool.keys.length) return { kind: 'retry' }
168
- attempts.delete(ak)
169
- }
290
+ const isRetryable = RETRYABLE_CODES.includes(code) || /^[45]\d\d$/.test(code) || /rate|limit|quota|exhaust|throttl|429|timeout|timed?out|auth|credential|denied|forbidden|unavailable|busy|key/i.test(rawMsg)
291
+ if (!provider || !isRetryable) return next()
292
+ diag('request-error', 'provider=' + provider + ' code=' + code)
293
+ const pool = await getPool(provider)
294
+ if (!pool || pool.keyValues.length === 0) return next()
295
+ let curRef = pool.currentRef
296
+ if (!curRef) {
297
+ const envCur = process.env[pool.cfg.env]
298
+ if (envCur) curRef = pool.keyValues.find((k) => k.value === envCur)?.ref
299
+ }
300
+ markFailed(pool, curRef || pool.keyValues[0]?.ref, code, rawMsg)
301
+ diag('request-error', 'marked ' + (curRef || '?'))
302
+ // 失败后优先按 nextRef 轮换(用户配置的顺序);useKeyRef 只决定初始选择
303
+ const nextKey = nextKeyFor(pool, curRef)
304
+ if (nextKey) {
305
+ try { await backupFile(CRED_FILE, 'credentials'); process.env[pool.cfg.env] = nextKey.value } catch (e) {}
306
+ pool.currentRef = nextKey.ref
307
+ } else {
308
+ // 无 nextRef 可选时:若 useKeyRef 锁定 key 仍 live 则退回它,否则放弃
309
+ const locked = pool.cfg.useKeyRef && pool.keyValues.find((k) => k.ref === pool.cfg.useKeyRef)
310
+ if (locked && isLive(locked)) pool.currentRef = locked.ref
311
+ return next()
170
312
  }
313
+ // v13:插件只切换 key 并写入配置/env,不自己决定重发——重发权完全交给 DSH 自带 llm-retry
314
+ // (按用户自己的 retryPolicy):AUTH 类(未配置重发)→ 本轮失败,下次发送自动用新 key;
315
+ // RATE_LIMIT 等(配置了重发)→ llm-retry backoff 后重发,重发时 prewrite 已用新 key。
171
316
  return next()
172
- })
317
+ }, { prepend: true })
173
318
 
174
- // ── 只读状态接口(给卡片用)──
175
319
  const webServer = ctx.get('webServer')
176
320
  if (webServer) {
177
- ctx.effect(() => webServer.register({
178
- kind: 'exact',
179
- path: `${API_BASE}/pools`,
180
- handler: (req, res) => {
181
- if (req.method !== 'GET') { sendJson(res, 405, { error: 'method not allowed' }); return }
182
- const now = Date.now()
183
- const out = {}
184
- for (const [provider, p] of pools) {
185
- out[provider] = {
186
- env: p.env,
187
- keyCount: p.keys.length,
188
- currentIndex: p.idx,
189
- cooling: p.keys.filter((k) => ((p.states.get(k) && p.states.get(k).cooldownUntil) || 0) > now).map(mask),
190
- healthy: p.keys.filter((k) => ((p.states.get(k) && p.states.get(k).cooldownUntil) || 0) <= now).map(mask),
321
+ const route = async (req, res) => {
322
+ try {
323
+ const url = new URL(req.url, 'http://127.0.0.1')
324
+ const path = url.pathname.replace(new RegExp('^' + API_BASE + '/?'), '').replace(/\/$/, '')
325
+ const readBody = () => new Promise((resolve, reject) => {
326
+ let raw = '', size = 0
327
+ req.on('data', (c) => { size += c.length; if (size > (1 << 20)) { reject(new Error('body too large')); req.destroy(); return } raw += c.toString('utf8') })
328
+ req.on('end', () => { if (!raw) return resolve({}); try { resolve(JSON.parse(raw)) } catch (e) { reject(e) } })
329
+ req.on('error', reject)
330
+ })
331
+
332
+ if (path === 'diag' && req.method === 'GET') {
333
+ return sendJson(res, 200, { diag: diagLog })
334
+ }
335
+
336
+ if (path === 'providers' && req.method === 'GET') {
337
+ const live = []
338
+ try {
339
+ for (const p of (ctx.llm.listProviders ? ctx.llm.listProviders() : [])) {
340
+ if (p && p.id) live.push({ id: p.id, name: p.name || p.id, state: 'live' })
341
+ }
342
+ } catch (e) {}
343
+ const dormant = []
344
+ try {
345
+ for (const c of (ctx.llm.listConfigurableProviders ? ctx.llm.listConfigurableProviders() : [])) {
346
+ if (c && c.provider && !live.some((l) => l.id === c.provider)) dormant.push({ id: c.provider, name: c.displayName || c.provider, state: 'dormant' })
347
+ }
348
+ } catch (e) {}
349
+ return sendJson(res, 200, { live, dormant, currentPools: Object.keys(readSettings().providers || {}) })
350
+ }
351
+
352
+ if (path === 'pools') {
353
+ if (req.method === 'GET') {
354
+ // 返回含实时状态的池列表
355
+ const out = []
356
+ readPools()
357
+ for (const [name, pool] of pools) {
358
+ await resolvePoolKeys(pool)
359
+ const now = Date.now()
360
+ const keys = pool.keyValues.map((k) => {
361
+ const cooling = (k.cooldownUntil || 0) > now
362
+ return {
363
+ ref: k.ref,
364
+ label: '',
365
+ source: k.source,
366
+ nextRef: k.nextRef || '',
367
+ status: cooling ? 'cooling' : (k.failCount ? 'recovered' : 'live'),
368
+ failCount: k.failCount || 0,
369
+ cooldownUntil: k.cooldownUntil || 0,
370
+ cooldownRemainingMs: cooling ? (k.cooldownUntil - now) : 0,
371
+ lastErrorAt: k.lastErrorAt || 0,
372
+ lastErrorMsg: k.lastErrorMsg || '',
373
+ }
374
+ })
375
+ out.push({
376
+ provider: name,
377
+ displayName: pool.cfg.displayName || name,
378
+ env: pool.cfg.env,
379
+ cooldownMs: pool.cfg.cooldownMs,
380
+ enabled: pool.cfg.enabled,
381
+ rotateOn: pool.cfg.rotateOn || [],
382
+ useKeyRef: pool.cfg.useKeyRef || '',
383
+ currentRef: pool.currentRef || '',
384
+ envKeyValue: pool.envKeyValue ? mask(pool.envKeyValue) : '',
385
+ keys: keys,
386
+ })
387
+ }
388
+ return sendJson(res, 200, { pools: out })
389
+ }
390
+ if (req.method === 'POST') {
391
+ const body = await readBody()
392
+ const { provider, enabled, env, cooldownMs, rotateOn, displayName, useKeyRef } = body || {}
393
+ if (typeof provider !== 'string' || !provider) return sendJson(res, 400, { error: 'provider required' })
394
+ const envName = (typeof env === 'string' && env) ? env : (provider.toUpperCase() + '_API_KEY')
395
+ if (!isCredentialRefName(envName)) return sendJson(res, 400, { error: 'env must be POSIX identifier' })
396
+ const cfg = readSettings()
397
+ cfg.providers = cfg.providers || {}
398
+ const cur = cfg.providers[provider] || {}
399
+ const upd = {
400
+ env: envName,
401
+ enabled: enabled !== false,
402
+ cooldownMs: Number.isFinite(cooldownMs) && cooldownMs >= 0 ? cooldownMs : (cur.cooldownMs ?? 30000),
403
+ rotateOn: Array.isArray(rotateOn) && rotateOn.length > 0 ? rotateOn : (cur.rotateOn || ['QUOTA_EXCEEDED', 'AUTH', 'RATE_LIMIT']),
404
+ keyRefs: (cur.keyRefs || []).map((x) => {
405
+ if (typeof x === 'string') return { ref: x, label: '', createdAt: 0, nextRef: '' }
406
+ return { ref: x.ref, label: x.label || '', createdAt: x.createdAt || 0, nextRef: x.nextRef || '' }
407
+ }),
408
+ displayName: (typeof displayName === 'string' && displayName) ? displayName : (cur.displayName || provider),
409
+ useKeyRef: (typeof useKeyRef === 'string') ? useKeyRef : (cur.useKeyRef || ''),
410
+ }
411
+ let validated
412
+ try { validated = profileSchema(upd) } catch (e) { return sendJson(res, 400, { error: 'invalid profile: ' + (e && e.message || e) }) }
413
+ cfg.providers[provider] = validated
414
+ writeSettings(cfg)
415
+ return sendJson(res, 200, { ok: true, pool: validated })
416
+ }
417
+ if (req.method === 'DELETE') {
418
+ const provider = url.searchParams.get('provider')
419
+ if (!provider) return sendJson(res, 400, { error: 'provider required' })
420
+ const cfg = readSettings()
421
+ if (!cfg.providers || !cfg.providers[provider]) return sendJson(res, 404, { error: 'not found' })
422
+ const prof = cfg.providers[provider]
423
+ for (const k of (prof.keyRefs || [])) { try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.unset(credentialRef(k.ref || k)) } catch (e) {} }
424
+ delete cfg.providers[provider]
425
+ writeSettings(cfg)
426
+ return sendJson(res, 200, { ok: true })
427
+ }
428
+ return sendJson(res, 405, { error: 'method not allowed' })
429
+ }
430
+
431
+ if (path === 'keys') {
432
+ if (req.method === 'POST') {
433
+ const body = await readBody()
434
+ const { provider, value, label } = body || {}
435
+ if (typeof provider !== 'string' || !provider) return sendJson(res, 400, { error: 'provider required' })
436
+ if (typeof value !== 'string' || !value) return sendJson(res, 400, { error: 'value required' })
437
+ const cfg = readSettings()
438
+ if (!cfg.providers || !cfg.providers[provider]) return sendJson(res, 404, { error: 'pool not found' })
439
+ const prof = cfg.providers[provider]
440
+ const newRef = 'key_fallback_' + provider + '_' + Date.now().toString(36) + '_' + Math.floor(Math.random() * 0xffffffff).toString(36)
441
+ try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.set(credentialRef(newRef), value) } catch (e) { return sendJson(res, 500, { error: 'credentials write failed' }) }
442
+ const newEntry = { ref: newRef, label: (typeof label === 'string' && label) ? label : '', createdAt: Date.now(), nextRef: '' }
443
+ prof.keyRefs = [...(prof.keyRefs || []), newEntry]
444
+ writeSettings(cfg)
445
+ return sendJson(res, 200, { ok: true, key: newEntry, valueMask: mask(value) })
446
+ }
447
+ if (req.method === 'PATCH') {
448
+ const body = await readBody()
449
+ const { provider, ref, value, label, nextRef } = body || {}
450
+ if (typeof provider !== 'string' || !provider || typeof ref !== 'string' || !ref) return sendJson(res, 400, { error: 'provider & ref required' })
451
+ const cfg = readSettings()
452
+ const prof = cfg.providers && cfg.providers[provider]
453
+ if (!prof) return sendJson(res, 404, { error: 'pool not found' })
454
+ const entry = (prof.keyRefs || []).find((k) => k.ref === ref)
455
+ if (!entry) return sendJson(res, 404, { error: 'key not found' })
456
+ if (typeof value === 'string' && value) {
457
+ try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.set(credentialRef(ref), value) } catch (e) { return sendJson(res, 500, { error: 'credentials write failed' }) }
458
+ }
459
+ if (typeof label === 'string') entry.label = label
460
+ if (typeof nextRef === 'string') entry.nextRef = nextRef
461
+ if (typeof useKeyRef === 'string') prof.useKeyRef = useKeyRef
462
+ writeSettings(cfg)
463
+ return sendJson(res, 200, { ok: true })
464
+ }
465
+ if (req.method === 'DELETE') {
466
+ const provider = url.searchParams.get('provider')
467
+ const ref = url.searchParams.get('ref')
468
+ if (!provider || !ref) return sendJson(res, 400, { error: 'provider & ref required' })
469
+ const cfg = readSettings()
470
+ const prof = cfg.providers && cfg.providers[provider]
471
+ if (!prof) return sendJson(res, 404, { error: 'pool not found' })
472
+ const idx = (prof.keyRefs || []).findIndex((k) => k.ref === ref)
473
+ if (idx < 0) return sendJson(res, 404, { error: 'key not found' })
474
+ try { backupFile(CRED_FILE, 'credentials'); await ctx.credentials.unset(credentialRef(ref)) } catch (e) {}
475
+ prof.keyRefs.splice(idx, 1)
476
+ if (prof.useKeyRef === ref) prof.useKeyRef = ''
477
+ // 清理指向已删 key 的 nextRef
478
+ for (const k of (prof.keyRefs || [])) if (k.nextRef === ref) k.nextRef = ''
479
+ writeSettings(cfg)
480
+ return sendJson(res, 200, { ok: true })
481
+ }
482
+ return sendJson(res, 405, { error: 'method not allowed' })
483
+ }
484
+
485
+ if (path === 'reset') {
486
+ if (req.method === 'POST') {
487
+ const body = await readBody()
488
+ const provider = body && body.provider
489
+ if (!provider) return sendJson(res, 400, { error: 'provider required' })
490
+ const pool = pools.get(provider)
491
+ if (pool) { pool.cursor = 0; for (const k of pool.keyValues) { k.failCount = 0; k.cooldownUntil = 0; k.lastErrorAt = 0; k.lastErrorMsg = '' } }
492
+ return sendJson(res, 200, { ok: true })
191
493
  }
192
494
  }
193
- sendJson(res, 200, { pools: out })
194
- },
195
- }), 'key-fallback: pools route')
495
+
496
+ return sendJson(res, 404, { error: 'not found' })
497
+ } catch (e) {
498
+ try { sendJson(res, 500, { error: (e && e.message) || String(e) }) } catch {}
499
+ }
500
+ }
501
+ const handle = webServer.register({ kind: 'prefix', path: API_BASE, handler: route })
502
+ ctx.effect(() => handle)
196
503
  }
197
504
 
198
505
  ctx.effect(() => () => { lifetime.abort() })