@omdp/dsh-key-fallback 1.0.6 → 3.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 +34 -26
- package/cordis.patch.yml +1 -1
- package/lib/client.js +366 -127
- package/lib/index.js +460 -148
- package/package.json +15 -6
package/lib/index.js
CHANGED
|
@@ -1,193 +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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
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
|
|
13
|
-
import
|
|
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
|
-
export const inject = ['llm', 'settings', 'webServer']
|
|
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
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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 =
|
|
43
|
-
const
|
|
44
|
-
return
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
74
|
-
const cfg =
|
|
120
|
+
function readPools() {
|
|
121
|
+
const cfg = readSettings()
|
|
75
122
|
const providers = (cfg && cfg.providers) || {}
|
|
76
|
-
for (const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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(
|
|
112
|
-
const
|
|
113
|
-
if (!
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
230
|
+
}
|
|
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
|
+
}
|
|
133
237
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|
|
140
254
|
}
|
|
141
255
|
|
|
142
|
-
//
|
|
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'); ctx.credentials.set(credentialRef(pool.cfg.env), key.value).catch(() => {}) } catch (e) {}
|
|
279
|
+
} catch (e) {}
|
|
280
|
+
return config
|
|
281
|
+
}).catch((e) => {})
|
|
282
|
+
return p
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// ── 失败处理:标记当前 key + 切下一个(prepend 注册:先于 dsh-llm-retry 看到错误,切完 key 再放行)──
|
|
143
286
|
ctx.on('agent/request-error', async (payload, next) => {
|
|
144
|
-
const code = String((payload &&
|
|
145
|
-
const rawMsg = String((payload &&
|
|
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) || '')
|
|
146
289
|
const provider = (payload && payload.provider) || ''
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
156
|
-
// 只有还有别的未冷却 key 才重试;单 key 或全冷却时直接放弃(不无意义重试,也不崩溃)
|
|
157
|
-
if (hasOtherLiveKey(provider, cur)) {
|
|
158
|
-
// 安全上限:同一 step 内每个 key 至多试一次,全试完仍失败则放弃(终态)
|
|
159
|
-
const ak = `${payload.turn}:${payload.step}`
|
|
160
|
-
const n = (attempts.get(ak) || 0) + 1
|
|
161
|
-
attempts.set(ak, n)
|
|
162
|
-
if (n <= pool.keys.length) return { kind: 'retry' }
|
|
163
|
-
attempts.delete(ak)
|
|
164
|
-
}
|
|
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
|
|
165
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'); ctx.credentials.set(credentialRef(pool.cfg.env), nextKey.value); 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()
|
|
312
|
+
}
|
|
313
|
+
// v13:插件只切换 key 并写入配置/env,不自己决定重发——重发权完全交给 DSH 自带 llm-retry
|
|
314
|
+
// (按用户自己的 retryPolicy):AUTH 类(未配置重发)→ 本轮失败,下次发送自动用新 key;
|
|
315
|
+
// RATE_LIMIT 等(配置了重发)→ llm-retry backoff 后重发,重发时 prewrite 已用新 key。
|
|
166
316
|
return next()
|
|
167
|
-
})
|
|
317
|
+
}, { prepend: true })
|
|
168
318
|
|
|
169
|
-
// ── 只读状态接口(给卡片用)──
|
|
170
319
|
const webServer = ctx.get('webServer')
|
|
171
320
|
if (webServer) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 })
|
|
186
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' })
|
|
187
429
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 })
|
|
493
|
+
}
|
|
494
|
+
}
|
|
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)
|
|
191
503
|
}
|
|
192
504
|
|
|
193
505
|
ctx.effect(() => () => { lifetime.abort() })
|