@bd7pil/reeve 0.1.4 → 0.1.6

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.
@@ -0,0 +1,481 @@
1
+ // GATE —— reeve 的代理层:引擎前面的唯一入口。
2
+ //
3
+ // POST /v1/chat/completions 引擎的模型调用:路由 → 候选顺位 → 首字节前失败就换 → 流式转发
4
+ // GET /v1/models 模型目录:注册表声明 ∪ 运行时池(9router 等,TTL 缓存)∪ 链名
5
+ // POST /gate/session 插件侧的 session→agent 桥(把"这个 session 是 scout"告诉 GATE)
6
+ // POST /gate/session/:id/next 插件侧要"下一个候选"(流已开始/沉默这两类层内换不了的情形)
7
+ // GET /gate/version 探针/测试用
8
+ //
9
+ // 纪律:① 只记录、不泄漏——台账里没有密钥(只记键的"名字");② 时间与 IO 全可注入(测试零 sleep、零真实出网);
10
+ // ③ 管理面只认环回;④ dispose 尽力而为、绝不因为清理炸掉(含"定时器已开"的情况)。
11
+ import { createServer } from 'node:http'
12
+ import { appendFile, mkdir, readFile as readFileFs, rename, stat as statFs } from 'node:fs/promises'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join } from 'node:path'
15
+ import { Readable } from 'node:stream'
16
+
17
+ import { classifyFailure } from './classify.js'
18
+ import { BUNDLED_FALLBACK_PATH, defaultFallbackConfigPath, loadGateConfig, normalizeGateConfig, watchGateConfig } from './config.js'
19
+ import { attemptKey, attemptRow, buildUpstreamRequest, modelsPathOf, planAttempts, resolveKeyFor, routeFor } from './lanes.js'
20
+ import { createSessionStore } from './state.js'
21
+
22
+ export const GATE_VERSION = '0.1.0'
23
+ export const GATE_DEFAULTS = Object.freeze({
24
+ host: '127.0.0.1',
25
+ maxBodyBytes: 64 * 1024 * 1024,
26
+ ledgerMaxBytes: 5 * 1024 * 1024,
27
+ configWatchMs: 5000,
28
+ errorTextBytes: 64 * 1024,
29
+ })
30
+
31
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
32
+
33
+ /** 追加式台账;超过上限就轮转到 `.1`(只留一代,够用)。**写入失败只记日志,绝不打断请求**。 */
34
+ export function createLedger({ path, maxBytes = GATE_DEFAULTS.ledgerMaxBytes, logger = console } = {}) {
35
+ let size = null
36
+ let pending = Promise.resolve()
37
+ const line = (row) => `${JSON.stringify(row)}\n`
38
+ const append = async (row) => {
39
+ try {
40
+ await mkdir(dirname(path), { recursive: true })
41
+ if (size === null) size = (await statFs(path).catch(() => ({ size: 0 }))).size
42
+ const text = line(row)
43
+ if (size > 0 && size + Buffer.byteLength(text) > maxBytes) {
44
+ await rename(path, `${path}.1`).catch(() => {})
45
+ size = 0
46
+ }
47
+ await appendFile(path, text)
48
+ size += Buffer.byteLength(text)
49
+ } catch (err) {
50
+ logger?.debug?.(`[gate] 台账写入失败(忽略):${String(err).slice(0, 140)}`)
51
+ }
52
+ }
53
+ return {
54
+ path,
55
+ write: (row) => {
56
+ pending = pending.then(() => append(row))
57
+ return pending
58
+ },
59
+ flush: () => pending,
60
+ }
61
+ }
62
+
63
+ function readBody(req, maxBytes) {
64
+ return new Promise((resolve, reject) => {
65
+ const chunks = []
66
+ let size = 0
67
+ let tooLarge = false
68
+ req.on('data', (c) => {
69
+ if (tooLarge) return // 已判过大:排空但不再累积(不 destroy —— 那样响应发不出去,客户端只看到连接被重置)
70
+ size += c.length
71
+ if (size > maxBytes) {
72
+ tooLarge = true
73
+ chunks.length = 0
74
+ reject(Object.assign(new Error('请求体过大'), { code: 'E_TOO_LARGE' }))
75
+ return
76
+ }
77
+ chunks.push(c)
78
+ })
79
+ req.on('end', () => {
80
+ if (!tooLarge) resolve(Buffer.concat(chunks).toString('utf8'))
81
+ })
82
+ req.on('error', reject)
83
+ })
84
+ }
85
+
86
+ const sendJson = (res, status, obj) => {
87
+ const text = JSON.stringify(obj)
88
+ res.writeHead(status, { 'content-type': 'application/json' })
89
+ res.end(text)
90
+ }
91
+
92
+ /**
93
+ * 拉起 GATE。返回 handle:`{ port, detach, dispose, snapshot, reload, sessions, ledgerPath, configPath }`。
94
+ * 生命周期:`detach()` = 插件实例卸载但 GATE 继续服务(引擎级,活到进程退出);`dispose()` = 真关
95
+ * (测试与显式停止用)。任何"起不来"的失败都**抛**(它是引擎启动路径上的东西,吵比静默好);运行期失败只记日志。
96
+ */
97
+ export async function startGate({
98
+ port,
99
+ host = GATE_DEFAULTS.host,
100
+ configPath = defaultFallbackConfigPath(),
101
+ bundledPath = BUNDLED_FALLBACK_PATH,
102
+ ledgerPath = join(homedir(), '.reeve', 'gate.jsonl'),
103
+ authPath = join(homedir(), '.local', 'share', 'opencode', 'auth.json'),
104
+ logger = console,
105
+ now = Date.now,
106
+ fetchImpl = fetch,
107
+ readFileImpl = readFileFs,
108
+ statImpl = statFs,
109
+ sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
110
+ configWatchMs = GATE_DEFAULTS.configWatchMs,
111
+ maxBodyBytes = GATE_DEFAULTS.maxBodyBytes,
112
+ ledgerMaxBytes = GATE_DEFAULTS.ledgerMaxBytes,
113
+ errorTextBytes = GATE_DEFAULTS.errorTextBytes,
114
+ } = {}) {
115
+ let auth = {}
116
+ const loadAuth = async () => {
117
+ try {
118
+ auth = JSON.parse(await readFileImpl(authPath, 'utf8'))
119
+ } catch {
120
+ auth = {}
121
+ }
122
+ }
123
+ await loadAuth()
124
+
125
+ const attach = (cfg) => (cfg ? { ...cfg, env: process.env, auth } : null)
126
+ const loaded = await loadGateConfig({ path: configPath, bundledPath, readFileImpl })
127
+ let config = attach(loaded.config) ?? attach(normalizeGateConfig({}))
128
+ let configFrom = loaded.from
129
+ let problems = [...(loaded.problems ?? []), ...(loaded.error ? [loaded.error] : [])]
130
+
131
+ const sessions = createSessionStore({ now, policy: () => config?.policy ?? {} })
132
+ const ledger = createLedger({ path: ledgerPath, maxBytes: ledgerMaxBytes, logger })
133
+ const modelsCache = { at: 0, data: null }
134
+ /** 多键轮转:同 provider 多把键时按序轮转(游标在服务侧;纯逻辑只负责"用哪把")。 */
135
+ const keyCursors = new Map()
136
+ const keyPicker = (provider) => {
137
+ if (provider.key !== null && provider.key !== undefined) return resolveKeyFor(provider, { env: process.env, auth })
138
+ const available = (provider.auth ?? []).filter((id) => typeof auth?.[id]?.key === 'string' && auth[id].key.length > 0)
139
+ if (available.length === 0) return null
140
+ const at = keyCursors.get(provider.id) ?? 0
141
+ keyCursors.set(provider.id, at + 1)
142
+ const id = available[at % available.length]
143
+ return { key: auth[id].key, name: `auth:${id}` }
144
+ }
145
+
146
+ const note = (row) => ledger.write(row)
147
+
148
+ /** 模型目录:声明 ∪ 运行时池(TTL)∪ 链名。池拉不到就降级(只回声明),不报错。 */
149
+ const listModels = async () => {
150
+ const ttlMs = Math.max(1, Number(config.policy.models_ttl_seconds ?? 300)) * 1000
151
+ if (modelsCache.data && now() - modelsCache.at < ttlMs) return modelsCache.data
152
+ const data = []
153
+ const seen = new Set()
154
+ const push = (id, ownedBy) => {
155
+ if (seen.has(id)) return
156
+ seen.add(id)
157
+ data.push({ id, object: 'model', owned_by: ownedBy })
158
+ }
159
+ await loadAuth()
160
+ for (const provider of Object.values(config.providers)) {
161
+ for (const m of provider.models ?? []) if (m !== '*') push(`${provider.id}/${m}`, provider.id)
162
+ if (!provider.models_fetch || !provider.base_url) continue
163
+ const resolved = resolveKeyFor(provider, { env: process.env, auth })
164
+ if (resolved === null) continue
165
+ try {
166
+ const res = await fetchImpl(`${provider.base_url}${modelsPathOf(provider)}`, {
167
+ headers: resolved.key === null ? {} : { authorization: `Bearer ${resolved.key}` },
168
+ })
169
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
170
+ const body = await res.json()
171
+ for (const m of body?.data ?? []) if (m?.id) push(`${provider.id}/${m.id}`, provider.id)
172
+ } catch (err) {
173
+ logger?.debug?.(`[gate] 拉 ${provider.id} 的模型池失败(降级):${String(err).slice(0, 120)}`)
174
+ }
175
+ }
176
+ for (const name of Object.keys(config.chains)) push(name, 'reeve')
177
+ modelsCache.data = data
178
+ modelsCache.at = now()
179
+ return data
180
+ }
181
+
182
+ let reqSeq = 0
183
+ const doChat = async (req, res) => {
184
+ const reqId = ++reqSeq
185
+ let bodyText
186
+ try {
187
+ bodyText = await readBody(req, maxBodyBytes)
188
+ } catch (err) {
189
+ return sendJson(res, err?.code === 'E_TOO_LARGE' ? 413 : 400, { error: { message: String(err?.message ?? err).slice(0, 120) } })
190
+ }
191
+ let model = null
192
+ try {
193
+ const parsed = JSON.parse(bodyText)
194
+ if (typeof parsed?.model === 'string') model = parsed.model
195
+ } catch {
196
+ /* 非 JSON:下面按 400 处理 */
197
+ }
198
+ if (!model) return sendJson(res, 400, { error: { message: 'model 字段缺失,或 body 不是 JSON' } })
199
+
200
+ const sessionID = typeof req.headers['x-opencode-session-id'] === 'string' ? req.headers['x-opencode-session-id'] : null
201
+ const route = routeFor({ model, sessionID }, { config, sessions })
202
+ if (!route) return sendJson(res, 404, { error: { message: `未知模型或链:${model}` } })
203
+ const plan = planAttempts(route, { config, sessions, sessionID, keyPicker })
204
+
205
+ if (plan.ordered.length === 0) {
206
+ return sendJson(res, 502, {
207
+ error: {
208
+ message: '没有可用候选(provider 未配置或键取不到)',
209
+ chain: plan.chainName,
210
+ skipped: plan.skipped.map((s) => `${s.target}(${s.why})`),
211
+ },
212
+ })
213
+ }
214
+
215
+ const attemptsSeen = []
216
+ for (let i = 0; i < plan.ordered.length; i++) {
217
+ const attempt = plan.ordered[i]
218
+ const spec = buildUpstreamRequest({ attempt, inHeaders: req.headers, bodyText, version: GATE_VERSION })
219
+ const t0 = now()
220
+ let upstream = null
221
+ let netError = null
222
+ let retries = 0
223
+ const controller = new AbortController()
224
+ const onClose = () => controller.abort()
225
+ // 监听**不摘**:流式阶段客户端断开也要中止上游(分别对应"客户端中途取消"这类真实行为)
226
+ res.on('close', onClose)
227
+ try {
228
+ for (let r = 0; ; r++) {
229
+ try {
230
+ upstream = await fetchImpl(spec.url, { method: 'POST', headers: spec.headers, body: spec.body, signal: controller.signal })
231
+ if (upstream.ok || upstream.status < 500 || r >= (attempt.provider.retries ?? 0)) break
232
+ retries = r + 1
233
+ await sleepImpl(200)
234
+ } catch (err) {
235
+ netError = err
236
+ if (r >= (attempt.provider.retries ?? 0) || controller.signal.aborted) break
237
+ retries = r + 1
238
+ await sleepImpl(200)
239
+ }
240
+ }
241
+ } catch (err) {
242
+ netError = err
243
+ }
244
+
245
+ if (netError) {
246
+ const detail = String(netError?.message ?? netError).slice(0, 160)
247
+ note(attemptRow({ ts: new Date(t0).toISOString(), chainName: plan.chainName, agent: plan.agent, attempt, status: 'error', code: null, latencyMs: now() - t0, retries, detail, req: reqId, n: i + 1 }))
248
+ attemptsSeen.push({ provider: attempt.provider.id, model: attempt.model, code: null, reason: 'network' })
249
+ sessions.noteFailure(sessionID ?? '(anon)', plan.chainName, attemptKey(attempt))
250
+ continue
251
+ }
252
+
253
+ if (upstream.ok) {
254
+ note(attemptRow({ ts: new Date(t0).toISOString(), chainName: plan.chainName, agent: plan.agent, attempt, status: 'ok', code: upstream.status, latencyMs: now() - t0, retries, req: reqId, n: i + 1 }))
255
+ sessions.noteSuccess(sessionID ?? '(anon)', plan.chainName, attemptKey(attempt), plan.keys)
256
+ res.writeHead(upstream.status, {
257
+ 'content-type': upstream.headers?.get?.('content-type') ?? 'application/json',
258
+ 'x-reeve-fallback': `${i + 1}/${attempt.provider.id}/${attempt.model}`,
259
+ })
260
+ if (upstream.body) Readable.fromWeb(upstream.body).pipe(res)
261
+ else res.end(await upstream.text().catch(() => ''))
262
+ return
263
+ }
264
+
265
+ const text = (await upstream.text().catch(() => '')).slice(0, errorTextBytes)
266
+ const cls = classifyFailure({ status: upstream.status, text, headers: upstream.headers, policy: config.policy })
267
+ note(attemptRow({ ts: new Date(t0).toISOString(), chainName: plan.chainName, agent: plan.agent, attempt, status: 'error', code: upstream.status, latencyMs: now() - t0, retries, detail: cls.reason, req: reqId, n: i + 1 }))
268
+ attemptsSeen.push({ provider: attempt.provider.id, model: attempt.model, code: upstream.status, reason: cls.reason })
269
+ if (!cls.retryable) {
270
+ // 不换:把上游的应答原样交给调用方(这是它的错,不是链的错)
271
+ res.writeHead(upstream.status, {
272
+ 'content-type': upstream.headers?.get?.('content-type') ?? 'application/json',
273
+ 'x-reeve-fallback': `${i + 1}/${attempt.provider.id}/${attempt.model}`,
274
+ })
275
+ res.end(text)
276
+ return
277
+ }
278
+ sessions.noteFailure(sessionID ?? '(anon)', plan.chainName, attemptKey(attempt))
279
+ }
280
+
281
+ sendJson(res, 502, { error: { message: '所有候选都失败', chain: plan.chainName, req: reqId, attempts: attemptsSeen } })
282
+ }
283
+
284
+ const server = createServer((req, res) => {
285
+ const url = String(req.url ?? '')
286
+ const remote = req.socket?.remoteAddress ?? ''
287
+ const run = async () => {
288
+ if (req.method === 'GET' && url.startsWith('/v1/models')) {
289
+ return sendJson(res, 200, { object: 'list', data: await listModels() })
290
+ }
291
+ if (req.method === 'GET' && url.startsWith('/gate/version')) {
292
+ return sendJson(res, 200, {
293
+ name: 'reeve-gate',
294
+ version: GATE_VERSION,
295
+ port: server.address()?.port ?? port,
296
+ from: configFrom,
297
+ chains: Object.keys(config.chains),
298
+ chain_targets: Object.fromEntries(Object.entries(config.chains).map(([name, spec]) => [name, [...(spec?.targets ?? [])]])),
299
+ providers: Object.keys(config.providers),
300
+ policy: {
301
+ max_fallback_attempts: config.policy.max_fallback_attempts,
302
+ cooldown_seconds: config.policy.cooldown_seconds,
303
+ restore_primary_after_cooldown: config.policy.restore_primary_after_cooldown,
304
+ provider_retries: config.policy.provider_retries,
305
+ },
306
+ agents: { ...config.agents },
307
+ chain_for_model: { ...config.chain_for_model },
308
+ default_chain: config.default_chain ?? null,
309
+ problems,
310
+ sessions: sessions.snapshot(),
311
+ ledger: ledger.path,
312
+ })
313
+ }
314
+ if (url.startsWith('/gate/')) {
315
+ if (!LOOPBACK.has(remote)) return sendJson(res, 403, { error: { message: '管理面只认环回' } })
316
+ if (req.method === 'POST' && url === '/gate/session') {
317
+ const bodyText = await readBody(req, 1024 * 1024).catch(() => '{}')
318
+ const { sessionID, agent } = JSON.parse(bodyText || '{}')
319
+ if (typeof sessionID !== 'string' || sessionID.length === 0) return sendJson(res, 400, { error: { message: 'sessionID 必填' } })
320
+ sessions.setAgent(sessionID, agent)
321
+ return sendJson(res, 200, { ok: true })
322
+ }
323
+ const nextMatch = url.match(/^\/gate\/session\/([^/]+)\/next$/)
324
+ if (req.method === 'POST' && nextMatch) {
325
+ const sessionID = decodeURIComponent(nextMatch[1])
326
+ const bodyText = await readBody(req, 1024 * 1024).catch(() => '{}')
327
+ const { dedupeKey = null } = JSON.parse(bodyText || '{}')
328
+ const agent = sessions.agentOf(sessionID)
329
+ const chainName = agent && config.agents[agent] ? config.agents[agent] : config.default_chain
330
+ if (!chainName || !config.chains[chainName]) return sendJson(res, 200, { model: null, chain: null })
331
+ const keys = config.chains[chainName].targets
332
+ const out = sessions.advance(sessionID, chainName, keys, { dedupeKey })
333
+ return sendJson(res, 200, { model: out.model, chain: chainName, index: out.index })
334
+ }
335
+ return sendJson(res, 404, { error: { message: '未知的 gate 路由' } })
336
+ }
337
+ if (req.method === 'POST' && url.startsWith('/v1/chat/completions')) return doChat(req, res)
338
+ return sendJson(res, 404, { error: { message: `未知路由:${req.method} ${url}` } })
339
+ }
340
+ void run().catch((err) => {
341
+ logger?.debug?.(`[gate] 处理请求失败:${String(err).slice(0, 160)}`)
342
+ if (!res.headersSent) sendJson(res, 500, { error: { message: String(err?.message ?? err).slice(0, 160) } })
343
+ else res.end()
344
+ })
345
+ })
346
+
347
+ await new Promise((resolve, reject) => {
348
+ server.once('error', reject)
349
+ server.listen(port, host, () => {
350
+ server.off('error', reject)
351
+ resolve()
352
+ })
353
+ })
354
+ // 引擎级服务:监听口**不**替引擎续命。引擎是长活进程,自己有事吊着;而 CLI 那种一次性
355
+ // 进程卸载插件后必须能立刻退出 —— 少了这一行,卸载后还挂着一个 ref 着的监听 handle。
356
+ server.unref?.()
357
+
358
+ const watch = watchGateConfig({
359
+ path: configPath,
360
+ bundledPath,
361
+ intervalMs: configWatchMs,
362
+ readFileImpl,
363
+ statImpl,
364
+ onChange: (next) => {
365
+ if (next.config) {
366
+ config = attach(next.config)
367
+ configFrom = next.from
368
+ problems = [...(next.problems ?? [])]
369
+ modelsCache.data = null
370
+ logger?.debug?.(`[gate] 配置热更:${next.from}`)
371
+ } else {
372
+ logger?.debug?.(`[gate] 配置读不动,保留上一份:${next.error ?? '?'}`)
373
+ }
374
+ },
375
+ })
376
+
377
+ const disposeFns = [() => watch.stop(), () => ledger.flush(), () => new Promise((r) => server.close(() => r()))]
378
+ let sweep = null
379
+ return {
380
+ port: server.address()?.port ?? port,
381
+ host,
382
+ sessions,
383
+ ledgerPath: ledger.path,
384
+ configPath,
385
+ flushLedger: () => ledger.flush(),
386
+ snapshot: () => ({
387
+ port: server.address()?.port ?? port,
388
+ version: GATE_VERSION,
389
+ from: configFrom,
390
+ chains: Object.keys(config.chains),
391
+ chain_targets: Object.fromEntries(Object.entries(config.chains).map(([name, spec]) => [name, [...(spec?.targets ?? [])]])),
392
+ providers: Object.keys(config.providers),
393
+ policy: {
394
+ max_fallback_attempts: config.policy.max_fallback_attempts,
395
+ cooldown_seconds: config.policy.cooldown_seconds,
396
+ restore_primary_after_cooldown: config.policy.restore_primary_after_cooldown,
397
+ provider_retries: config.policy.provider_retries,
398
+ },
399
+ agents: { ...config.agents },
400
+ chain_for_model: { ...config.chain_for_model },
401
+ default_chain: config.default_chain ?? null,
402
+ ledger: ledger.path,
403
+ problems,
404
+ sessions: sessions.snapshot(),
405
+ }),
406
+ /**
407
+ * 插件实例卸载时调用(引擎按工作区加载插件:工作区被闲置驱逐 = 这个实例卸载,
408
+ * **不是**引擎退出)。监听口与配置热更留着 —— 否则"起 GATE 的那个工作区"一被驱逐,
409
+ * 其它还活跃的工作区就永久拿不到代理(拆 0.1.7 前的实测)。这里只做两件事:
410
+ * ① 把已经空闲的 keep-alive 连接清掉;② 起一个 unref 的低频清扫,兜住"卸载后才变空闲"的那些
411
+ * (每条连接的生命周期里只有很短一段是 busy,第一条命令常赶不上);
412
+ * 定时器全是 unref 的,且 2s 后自停 —— 既不拖进程退出,也不长期干扰连接复用。
413
+ */
414
+ detach() {
415
+ const closeIdle = () => {
416
+ try {
417
+ server.closeIdleConnections?.()
418
+ } catch {
419
+ /* 尽力而为 */
420
+ }
421
+ }
422
+ closeIdle()
423
+ if (sweep) return
424
+ sweep = setInterval(closeIdle, 250)
425
+ sweep.unref?.()
426
+ const stop = setTimeout(() => {
427
+ if (sweep) clearInterval(sweep)
428
+ sweep = null
429
+ }, 2000)
430
+ stop.unref?.()
431
+ },
432
+ async dispose() {
433
+ if (sweep) {
434
+ clearInterval(sweep)
435
+ sweep = null
436
+ }
437
+ for (const f of disposeFns.reverse()) {
438
+ try {
439
+ await f()
440
+ } catch {
441
+ /* 清理尽力而为:一项失败不挡其余(这条纪律有前科) */
442
+ }
443
+ }
444
+ },
445
+ }
446
+ }
447
+ /**
448
+ * 探一台**已经在跑的** GATE(引擎按工作区加载插件 → 同一台机器上会有多个插件实例,
449
+ * 只有第一个该去占端口;后面的实例用这个探针接上,而不是抢口失败后误报)。
450
+ *
451
+ * 判据严格:只有 `name === 'reeve-gate'` 才算(端口上可能是别人的东西 —— 不许瞎认)。
452
+ * @returns {{ok: boolean, reason?: string, snapshot?: object}}
453
+ */
454
+ export async function probeGate({ port, host = '127.0.0.1', fetchImpl = fetch, timeoutMs = 800 } = {}) {
455
+ try {
456
+ const res = await fetchImpl(`http://${host}:${port}/gate/version`, { signal: AbortSignal.timeout(timeoutMs) })
457
+ if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` }
458
+ const doc = await res.json()
459
+ if (doc?.name !== 'reeve-gate') return { ok: false, reason: '端口上有一个服务,但它不是 GATE' }
460
+ return {
461
+ ok: true,
462
+ snapshot: {
463
+ port: Number(doc.port ?? port),
464
+ version: doc.version ?? null,
465
+ from: doc.from ?? null,
466
+ chains: Array.isArray(doc.chains) ? doc.chains : [],
467
+ chain_targets: doc.chain_targets ?? {},
468
+ providers: Array.isArray(doc.providers) ? doc.providers : [],
469
+ policy: doc.policy ?? {},
470
+ agents: doc.agents ?? {},
471
+ chain_for_model: doc.chain_for_model ?? {},
472
+ default_chain: doc.default_chain ?? null,
473
+ ledger: doc.ledger ?? null,
474
+ problems: Array.isArray(doc.problems) ? doc.problems : [],
475
+ sessions: doc.sessions ?? null,
476
+ },
477
+ }
478
+ } catch (err) {
479
+ return { ok: false, reason: String(err?.message ?? err).slice(0, 120) }
480
+ }
481
+ }
@@ -0,0 +1,194 @@
1
+ // GATE 的路由与尝试计划 —— **纯逻辑**(可测的部分)。
2
+ //
3
+ // 路由优先级(含一条 omo 语义的细化):
4
+ // ① 请求的 model 就是链名(`tier1`)→ 走它;
5
+ // ② 该 session 的 agent 有链,且请求的模型就是那条链的首选(或请求不是一个可直投的
6
+ // `provider/model`)→ 走 agent 的链;
7
+ // ③ 请求是一个可直投的 `provider/model`(provider 已在注册表里)→ 直投
8
+ // —— 这条覆盖"人在 UI 里换了模型":v1 时代那套也是"用户改模型就重建状态、尊重用户选择";
9
+ // ④ `chain_for_model` 命中(session/agent 不明时的兜底)→ 走那条链;
10
+ // ⑤ `default_chain` → 走它;都没有 → null(调用方 404)。
11
+ //
12
+ // 尝试计划:静态过滤(provider 缺 base_url、键取不到、重复的 no-op)→ 会话状态(粘性起点、
13
+ // 冷却跳过、上限)。**第一个尝试永远保留**:哪怕全在冷却,请求也得有人接。
14
+ import { LANES } from './config.js'
15
+
16
+ /** `"provider/model"`:只在**第一个** `/` 切分(模型名本身可以带斜杠,如 9router 的 `oc/xxx`)。 */
17
+ export function parseTarget(spec) {
18
+ const s = String(spec ?? '').trim()
19
+ const i = s.indexOf('/')
20
+ if (i <= 0 || i === s.length - 1) return null
21
+ return { provider: s.slice(0, i), model: s.slice(i + 1) }
22
+ }
23
+
24
+ export const targetKey = (t) => `${t.provider}/${t.model}`
25
+
26
+ /** 尝试的键(注意:attempt.provider 是**完整的 provider 对象**,要取 .id)。 */
27
+ export const attemptKey = (attempt) => `${attempt.provider.id}/${attempt.model}`
28
+
29
+ /** provider 的键:`none` = 不需要;`env.X` = 环境变量;其它 = 字面量(只允许本来就是公开的);否则按 auth 列表找 auth.json。 */
30
+ export function resolveKeyFor(provider, { env = process.env, auth = {} } = {}) {
31
+ if (provider.key === 'none') return { key: null, name: null }
32
+ if (typeof provider.key === 'string' && provider.key.startsWith('env.')) {
33
+ const v = env?.[provider.key.slice(4)]
34
+ return typeof v === 'string' && v.length > 0 ? { key: v, name: provider.key } : null
35
+ }
36
+ if (typeof provider.key === 'string' && provider.key.length > 0) return { key: provider.key, name: `${provider.id}:literal` }
37
+ for (const id of provider.auth ?? []) {
38
+ const k = auth?.[id]?.key
39
+ if (typeof k === 'string' && k.length > 0) return { key: k, name: `auth:${id}` }
40
+ }
41
+ return null
42
+ }
43
+
44
+ /** 该 provider 的模型目录端点(把 chat/completions 换成 models;只有 models_fetch 的 provider 会用)。 */
45
+ export const modelsPathOf = (provider) => String(provider.path ?? '').replace(/chat\/completions$/, 'models') || '/v1/models'
46
+
47
+ export function routeFor({ model, sessionID }, { config, sessions }) {
48
+ if (typeof model !== 'string' || model.length === 0) return null
49
+ const requested = parseTarget(model)
50
+ const direct = config.providers?.[requested?.provider] ? requested : null
51
+ const chainOf = (name, agent = null) => {
52
+ const targets = config.chains[name].targets.map(parseTarget).filter(Boolean)
53
+ return targets.length > 0 ? { kind: 'chain', chain: name, agent, targets, requestedModel: model } : null
54
+ }
55
+ // ① 请求的就是链名
56
+ if (config.chains[model]) return chainOf(model)
57
+ const agent = sessionID ? sessions.agentOf(sessionID) : null
58
+ if (agent && config.agents[agent]) {
59
+ const name = config.agents[agent]
60
+ const primary = parseTarget(config.chains[name].targets[0])
61
+ // ② 请求 == 该 agent 链的首选(或压根不是可直投的 provider/model)→ 走 agent 的链
62
+ if (!direct || (primary && targetKey(primary) === targetKey(direct))) return chainOf(name, agent)
63
+ // ③ 请求是**别的**可直投模型 → 尊重显式选择(人在 UI 里换了模型)
64
+ return { kind: 'direct', chain: null, agent, targets: [direct], requestedModel: model }
65
+ }
66
+ // ④ 没有 agent 信息:chain_for_model 的显式声明优先于泛化直投
67
+ if (config.chain_for_model[model]) return chainOf(config.chain_for_model[model], agent)
68
+ if (direct) return { kind: 'direct', chain: null, agent, targets: [direct], requestedModel: model }
69
+ // ⑤ 裸模型名(没有 `provider/` 前缀)→ 兜底链;写了 `provider/model` 却没人认识 → null(调用方 404)
70
+ // 这一条是有意为之:绝不把"用户明确选的某个模型"悄悄换成兜底链里的另一个模型。
71
+ if (!requested && config.default_chain) return chainOf(config.default_chain, agent)
72
+ return null
73
+ }
74
+
75
+ /**
76
+ * 一次请求要按什么顺序试哪些候选。
77
+ * `keyPicker(provider)` 可注入:默认取"第一个有 key 的 auth id";服务侧会传一个带轮转的实现
78
+ * (同 provider 多把键时按序轮转;测试用确定性序钉住)。
79
+ * @returns {{ chainName: string, agent: string|null, ordered: Array<object>, keys: string[], skipped: Array<{target:string, why:string}> }}
80
+ */
81
+ export function planAttempts(route, { config, sessions, sessionID = null, keyPicker = null } = {}) {
82
+ const chainName = route.chain ?? `direct:${targetKey(route.targets[0])}`
83
+ const sid = sessionID ?? '(anon)'
84
+ const pick = keyPicker ?? ((provider) => resolveKeyFor(provider, { env: config.env, auth: config.auth }))
85
+ const skipped = []
86
+ const seen = new Set()
87
+ const staticTargets = []
88
+ for (const t of route.targets) {
89
+ const key = targetKey(t)
90
+ if (seen.has(key)) {
91
+ skipped.push({ target: key, why: 'no-op' })
92
+ continue
93
+ }
94
+ seen.add(key)
95
+ const provider = config.providers[t.provider]
96
+ if (!provider?.base_url) {
97
+ skipped.push({ target: key, why: 'no-provider' })
98
+ continue
99
+ }
100
+ const resolved = pick(provider)
101
+ if (resolved === null) {
102
+ skipped.push({ target: key, why: 'no-key' })
103
+ continue
104
+ }
105
+ staticTargets.push({ provider, model: t.model, key: resolved.name, authorization: resolved.key === null ? null : `Bearer ${resolved.key}` })
106
+ }
107
+ const keys = staticTargets.map((t) => `${t.provider.id}/${t.model}`)
108
+ const start = config.chains[route.chain] && route.kind === 'chain' ? sessions.startIndex(sid, chainName, keys) : 0
109
+ let ordered = staticTargets.slice(start)
110
+ const notCooling = ordered.filter((t) => !sessions.isCooling(sid, chainName, `${t.provider.id}/${t.model}`))
111
+ if (notCooling.length > 0) ordered = notCooling
112
+ ordered = ordered.slice(0, Math.max(1, Number(config.policy.max_fallback_attempts ?? 3)))
113
+ return { chainName, agent: route.agent ?? null, ordered, keys, skipped }
114
+ }
115
+
116
+ /** 转发给上游时要剥掉的头(逐跳头 + 长度/编码 + authorization 由车道替换)。 */
117
+ export const STRIP_FORWARD_HEADERS = Object.freeze([
118
+ 'host',
119
+ 'connection',
120
+ 'content-length',
121
+ 'transfer-encoding',
122
+ 'keep-alive',
123
+ 'upgrade',
124
+ 'proxy-authorization',
125
+ 'te',
126
+ 'trailer',
127
+ 'accept-encoding',
128
+ 'authorization',
129
+ ])
130
+
131
+ export function forwardedHeaders(inHeaders, { authorization = null } = {}) {
132
+ const out = {}
133
+ const strip = new Set(STRIP_FORWARD_HEADERS)
134
+ for (const [k, v] of Object.entries(inHeaders ?? {})) {
135
+ if (v === undefined) continue
136
+ if (strip.has(String(k).toLowerCase())) continue
137
+ out[k] = v
138
+ }
139
+ if (authorization !== null) out.authorization = authorization
140
+ return out
141
+ }
142
+
143
+ /**
144
+ * 这一跳实际发什么。
145
+ * · passthrough 车道:头**逐字**转发(含 UA 与四个 x-opencode-session*:客户端身份是它的全部意义),只换 Authorization;
146
+ * · native 车道:只发最小集(content-type/accept/我们自己的 UA + Authorization)——不把会话头漏给第三方上游。
147
+ * body:把 `model` 换成候选的模型名,其余原样(tools 等不动);非 JSON body 原样透传。
148
+ */
149
+ export function buildUpstreamRequest({ attempt, inHeaders, bodyText, version = 'gate' }) {
150
+ const url = `${attempt.provider.base_url}${attempt.provider.path}`
151
+ let headers
152
+ if (attempt.provider.lane === LANES.PASSTHROUGH) {
153
+ headers = forwardedHeaders(inHeaders, { authorization: attempt.authorization })
154
+ } else {
155
+ headers = {
156
+ 'content-type': inHeaders?.['content-type'] ?? 'application/json',
157
+ accept: inHeaders?.accept ?? '*/*',
158
+ 'user-agent': `reeve-gate/${version}`,
159
+ }
160
+ if (attempt.authorization !== null) headers.authorization = attempt.authorization
161
+ }
162
+ let body = bodyText
163
+ try {
164
+ const parsed = JSON.parse(bodyText)
165
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
166
+ parsed.model = attempt.model
167
+ body = JSON.stringify(parsed)
168
+ }
169
+ } catch {
170
+ // 非 JSON:原样透传(不去猜它)
171
+ }
172
+ return { url, headers, body }
173
+ }
174
+
175
+ /** 台账一行(**不带任何密钥**:只记 provider id、模型、键的"名字")。
176
+ * `req` = 本次调用在本进程内的序号(把同一请求的多次尝试串起来);`n` = 第几次尝试(1 起)。 */
177
+ export function attemptRow({ ts, chainName, agent, attempt, status, code, latencyMs, retries = 0, detail = null, req = null, n = null }) {
178
+ return {
179
+ ts,
180
+ req,
181
+ n,
182
+ chain: chainName,
183
+ agent: agent ?? null,
184
+ provider: attempt.provider.id,
185
+ model: attempt.model,
186
+ lane: attempt.provider.lane,
187
+ key: attempt.key ?? null,
188
+ status,
189
+ code: code ?? null,
190
+ latency_ms: Number.isFinite(latencyMs) ? Math.round(latencyMs) : null,
191
+ retries,
192
+ detail,
193
+ }
194
+ }