@bd7pil/reeve 0.1.4 → 0.1.5

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/src/stats.js CHANGED
@@ -1,64 +1,31 @@
1
- // 性能统计:把 Bifrost 的调用日志与本地台账**聚合成面板要看的形状**。
1
+ // 性能统计:把 **GATE 台账**(请求级归因)与编排台账(任务级)聚合成面板要看的形状。
2
2
  //
3
3
  // 分工(两条取数路径,别混):
4
- // · Bifrost 侧 = "请求级归因":哪一档(routing_rule_name)、真打了谁(provider/model)、
5
- // 是第几跳回退(fallback_index)、成没成、多久。**只有 Bifrost 知道真模型是谁** ——
6
- // 引擎侧的 session 记录里模型名永远是 `reeve/pro` 这类别名(见 PROGRESS §6.2 归因边界)。
4
+ // · GATE 侧 = "请求级归因":哪条链(chain)、真打了谁(provider/model)、第几次尝试(n)、
5
+ // 成没成、多久、有没有重试。**只有 GATE 知道真模型是谁** —— 引擎侧的 session 记录里
6
+ // 模型名永远是 `reeve/pro` 这类别名(见 PROGRESS §6.2 归因边界)。
7
7
  // · 编排侧 = "任务级台账":谁派给了谁、跑多久、超时/停止/对账、在飞与排队。
8
8
  //
9
- // 取数顺序(每步降级,最后如实标 unavailable):
10
- // ① `GET /api/logs`(需 setup token —— 插件自己写的 600 文件)→ 实测形状见下
11
- // ② 不产出统计:面板显示"拿不到",并给出手工核对的办法
12
- // 为什么不做 logs.db 直读:引擎运行时里的 node:sqlite 版本未知(Bifrost 的库用了较新的
13
- // 索引语法,EL7 的 python3.6 读它直接报 malformed schema)—— 直读是**最后**的手段,先不做。
9
+ // 取数:GATE 的台账是 JSONL(`~/.reeve/gate.jsonl`,插件与 GATE 同进程、同机器)——
10
+ // 直接按行读、只聚合窗口内的行。**不问 HTTP、不要 token**(旧代理层那套已随退役删除)。
11
+ // 空网/只读盘的机器上读不到就如实标 unavailable,面板给出手工核对的办法。
14
12
  //
15
- // **实测形状**(2026-10-19 EL7,Bifrost 2.3.0):
16
- // GET /api/logs?limit=N&offset=0[&sort_by=timestamp&order=desc|asc]
17
- // → 200 `{ has_logs, logs:[…], pagination:{limit,offset,sort_by,order,total_count}, stats:{…} }`
18
- // 行里的关键字段:`id, timestamp(ISO, 纳秒), provider, model, fallback_index, number_of_retries,
19
- // routing_rule_id, routing_rule_name, selected_key_name, latency(ms, 浮点), upstream_latency,
20
- // overhead_latency, status('success'|'error'), stream, session_id, user_agent, app`
21
- // · 排序参数生效(order=asc 拿到最旧的);未知参数**被静默忽略**(试过 content=false 等一律无效)
22
- // · `content_summary` 里是**完整提示词内容**,每行约 6.7 KB —— 聚合时一律丢掉(面板不该看内容)
23
- // · 无 token → 401;limit 上限在 1000(5000 → 400)
24
- // · `stats` 块除 total_requests 外全是 0,不可信 → 自己按行聚合
25
- //
26
- // 因此轮询策略:先 `limit=1` 探一下(`total_count` + 最新 id 就是"有没有变"的指纹),
27
- // 变了才抓 `limit` 行回来聚合 —— 空闲时每分钟只有一次 6 KB 的请求。
13
+ // 行形状(gate/lanes.js 的 attemptRow,**没有密钥材料**):
14
+ // ts, req, n, chain, agent, provider, model, lane, key(键的"名字"), status('ok'|'error'),
15
+ // code, latency_ms, retries, detail
28
16
 
29
17
  export const STATS_DEFAULTS = Object.freeze({
30
18
  /** 轮询间隔(unref 定时器,失败只记日志) */
31
19
  intervalMs: 60_000,
32
- /** 每次抓多少行(抓的是**最新**的若干行) */
33
- limit: 100,
20
+ /** 每次读多少行(读的是台账**尾部**的若干行) */
21
+ limit: 400,
34
22
  /** 聚合窗口:只有落在这个窗口里的行参与统计 */
35
23
  windowMs: 24 * 60 * 60 * 1000,
36
- /** 模型目录的刷新间隔(`/v1/models` 会真去上游探测并被 Bifrost 记日志 —— 别跟统计同频) */
24
+ /** 模型目录的刷新间隔(`/v1/models` 会去上游探测池;别跟统计同频) */
37
25
  modelsIntervalMs: 30 * 60 * 1000,
38
26
  timeoutMs: 6_000,
39
27
  })
40
28
 
41
- /** 面板要的形状里保留哪些字段 —— 其余(尤其 content_summary)一律丢掉。 */
42
- const KEEP_FIELDS = [
43
- 'id',
44
- 'timestamp',
45
- 'provider',
46
- 'model',
47
- 'fallback_index',
48
- 'number_of_retries',
49
- 'routing_rule_id',
50
- 'routing_rule_name',
51
- 'selected_key_name',
52
- 'latency',
53
- 'upstream_latency',
54
- 'status',
55
- 'stream',
56
- 'session_id',
57
- 'prompt_tokens',
58
- 'completion_tokens',
59
- 'total_tokens',
60
- ]
61
-
62
29
  // ── 纯函数(可测) ─────────────────────────────────────────────────────────
63
30
 
64
31
  /** 最近秩百分位(不做插值 —— 面板看的是"典型有多慢",不是数学分位)。输入会被排序副本。 */
@@ -83,19 +50,6 @@ export function summarizeLatency(values) {
83
50
  }
84
51
  }
85
52
 
86
- /** `reeve/pro` → `pro`;不是 REEVE 的档位规则就返回 null("不属于本层")。 */
87
- export function tierOfRuleName(name) {
88
- const m = /^reeve\/(flash|pro|multimodal)$/.exec(String(name ?? ''))
89
- return m ? m[1] : null
90
- }
91
-
92
- /** 丢内容、留计数:面板只该看归因与延迟,不该看提示词。 */
93
- export function normalizeLogRow(row) {
94
- const out = {}
95
- for (const k of KEEP_FIELDS) if (k in row) out[k] = row[k]
96
- return out
97
- }
98
-
99
53
  /** `provider/model` 的引用串(链里就是这个写法)。 */
100
54
  export function modelRef(row) {
101
55
  return row?.provider && row?.model ? `${row.provider}/${row.model}` : null
@@ -106,73 +60,72 @@ export function parseTs(value) {
106
60
  return Number.isFinite(t) ? t : null
107
61
  }
108
62
 
63
+ /** 台账一排(`createLedger.readLine` 的产物解析后)→ 行对象;坏行返回 null(调用方丢)。 */
64
+ export function parseGateRow(line) {
65
+ try {
66
+ const row = JSON.parse(String(line))
67
+ return row && typeof row === 'object' ? row : null
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
109
73
  /**
110
- * 抓回来的行 → 归因统计。
111
- * `source` 只表达"这批数字从哪来",`window` 里给出实际覆盖的时间范围(别让面板假装是 24h)。
74
+ * GATE 台账行 → 归因统计(请求级 + 尝试级)。
112
75
  *
113
- * ⚠️ 两条实测出来的口径(都踩到过):
114
- * · **`model` 为空的行不是调用**:那是 Bifrost 去上游探 `/v1/models` 的痕迹(rule 为 null、
115
- * model 为空串)。实测一次探测每 7 个 provider 就写 7 行 —— 混进来会把"调用次数"灌爆。
116
- * 所以按"有 model"过滤,并如实报出过滤了多少行。
117
- * · **status 有三态**:`success` / `error` / `cancelled`(客户端把流掐了)。cancelled 不算失败。
76
+ * 口径:
77
+ * · 一次调用 = 一个 `req`(没有 `req` 的旧行按"每行一次调用"算,如实);
78
+ * `n` 是这一次调用里的第几次尝试(1 起);失败换候选 → n 递增。
79
+ * · `fallbackRequests` = 尝试次数 >1 的调用;`fallbackHops` = Σ(n_max − 1)。
80
+ * · 调用级成败看**最后一次尝试**的状态(换到谁成功就是成功)。
81
+ * · 延迟分两层:`attemptLatency`(单次上游往返)与 `requestLatency`(一次调用的总耗时 = 同一 req 的 latency_ms 之和)。
118
82
  */
119
- export function aggregateBifrostRows(rows, { now = Date.now(), windowMs = STATS_DEFAULTS.windowMs } = {}) {
120
- const normalized = (rows ?? []).map(normalizeLogRow)
121
- const withTs = normalized.map((r) => ({ row: r, at: parseTs(r.timestamp) }))
122
- const probes = withTs.filter((x) => !x.row.model)
123
- const calls = withTs.filter((x) => x.row.model)
83
+ export function aggregateGateRows(rows, { now = Date.now(), windowMs = STATS_DEFAULTS.windowMs } = {}) {
84
+ const withTs = (rows ?? [])
85
+ .filter((r) => r && (r.provider || r.model))
86
+ .map((r) => ({ row: r, at: parseTs(r.ts) }))
124
87
  const since = now - windowMs
125
- const inWindow = calls.filter((x) => x.at != null && x.at >= since)
126
- // 窗口内一条都没有(机器闲了很久)→ 退成"就用抓到的这些行",并如实标 relaxed
127
- const relaxed = inWindow.length === 0 && calls.length > 0
128
- const used = relaxed ? calls : inWindow
88
+ const inWindow = withTs.filter((x) => x.at != null && x.at >= since)
89
+ // 窗口内一条都没有(机器闲了很久)→ 退成"就用读到的这些行",并如实标 relaxed
90
+ const relaxed = inWindow.length === 0 && withTs.length > 0
91
+ const used = relaxed ? withTs : inWindow
129
92
 
130
- const tiers = new Map()
93
+ const chains = new Map()
131
94
  const providers = new Map()
132
95
  const models = new Map()
133
- let fallbackHops = 0
134
- let fallbackRequests = 0
135
- const byIndex = {}
96
+ const byReq = new Map()
97
+ const byAttemptIndex = {}
98
+ const attemptLatencies = []
136
99
  let ok = 0
137
100
  let error = 0
138
- let cancelled = 0
139
- const latencies = []
140
101
 
141
- for (const { row, at } of used) {
142
- const isOk = row.status === 'success'
143
- const isCancelled = row.status === 'cancelled'
102
+ for (const [idx, { row, at }] of used.entries()) {
103
+ const isOk = row.status === 'ok'
144
104
  if (isOk) ok += 1
145
- else if (isCancelled) cancelled += 1
146
105
  else error += 1
147
- const lat = Number(row.latency)
148
- if (Number.isFinite(lat)) latencies.push(lat)
106
+ const lat = Number(row.latency_ms)
107
+ if (Number.isFinite(lat)) attemptLatencies.push(lat)
108
+ const n = Number(row.n)
109
+ const nIdx = Number.isFinite(n) && n >= 1 ? n : 1
110
+ byAttemptIndex[nIdx] = (byAttemptIndex[nIdx] ?? 0) + 1
149
111
 
150
- const tier = tierOfRuleName(row.routing_rule_name)
151
- const bucketKey = tier ?? '(非 REEVE 档位)'
112
+ const chainKey = row.chain ?? '(未标链)'
152
113
  const bucket =
153
- tiers.get(bucketKey) ??
154
- { tier, rule: row.routing_rule_name ?? null, n: 0, ok: 0, error: 0, cancelled: 0, fallbackRequests: 0, fallbackHops: 0, latency: [], providers: new Map() }
114
+ chains.get(chainKey) ??
115
+ { chain: chainKey, agent: null, n: 0, ok: 0, error: 0, hops: 0, latency: [], providers: new Map() }
116
+ if (!bucket.agent && row.agent) bucket.agent = row.agent
155
117
  bucket.n += 1
156
118
  if (isOk) bucket.ok += 1
157
- else if (isCancelled) bucket.cancelled += 1
158
119
  else bucket.error += 1
159
- const fb = Number(row.fallback_index) || 0
160
- if (fb > 0) {
161
- bucket.fallbackRequests += 1
162
- bucket.fallbackHops += fb
163
- fallbackRequests += 1
164
- fallbackHops += fb
165
- }
166
- byIndex[fb] = (byIndex[fb] ?? 0) + 1
120
+ if (nIdx > 1) bucket.hops += 1
167
121
  if (Number.isFinite(lat)) bucket.latency.push(lat)
168
122
  const ref = modelRef(row)
169
123
  if (ref) bucket.providers.set(ref, (bucket.providers.get(ref) ?? 0) + 1)
170
- tiers.set(bucketKey, bucket)
124
+ chains.set(chainKey, bucket)
171
125
 
172
- const p = providers.get(row.provider ?? '(未知)') ?? { provider: row.provider ?? '(未知)', n: 0, ok: 0, error: 0, cancelled: 0, latency: [], lastAt: null }
126
+ const p = providers.get(row.provider ?? '(未知)') ?? { provider: row.provider ?? '(未知)', n: 0, ok: 0, error: 0, latency: [], lastAt: null }
173
127
  p.n += 1
174
128
  if (isOk) p.ok += 1
175
- else if (isCancelled) p.cancelled += 1
176
129
  else p.error += 1
177
130
  if (Number.isFinite(lat)) p.latency.push(lat)
178
131
  if (at != null && (p.lastAt == null || at > p.lastAt)) p.lastAt = at
@@ -182,35 +135,38 @@ export function aggregateBifrostRows(rows, { now = Date.now(), windowMs = STATS_
182
135
  const m = models.get(ref) ?? { ref, provider: row.provider, model: row.model, n: 0, ok: 0, error: 0, lastAt: null }
183
136
  m.n += 1
184
137
  if (isOk) m.ok += 1
185
- else if (!isCancelled) m.error += 1
138
+ else m.error += 1
186
139
  if (at != null && (m.lastAt == null || at > m.lastAt)) m.lastAt = at
187
140
  models.set(ref, m)
188
141
  }
142
+
143
+ // 没有 `req` 的旧行按"每行一次调用"算(诚实:不能让它们被错并成一次)
144
+ const reqKey = row.req != null ? String(row.req) : `row:${idx}`
145
+ const req = byReq.get(reqKey) ?? { attempts: 0, maxN: 0, lastOk: null, chain: chainKey, totalMs: 0, at: null }
146
+ req.attempts += 1
147
+ if (nIdx > req.maxN) req.maxN = nIdx
148
+ req.lastOk = isOk
149
+ if (Number.isFinite(lat)) req.totalMs += lat
150
+ if (at != null && (req.at == null || at > req.at)) req.at = at
151
+ byReq.set(reqKey, req)
189
152
  }
190
153
 
191
- const tsList = used.map((x) => x.at).filter((v) => v != null)
192
- const TIER_ORDER = ['flash', 'pro', 'multimodal']
193
- const tierRows = [...tiers.values()]
194
- .map((b) => ({
195
- tier: b.tier,
196
- rule: b.rule,
197
- n: b.n,
198
- ok: b.ok,
199
- error: b.error,
200
- cancelled: b.cancelled,
201
- fallbackRequests: b.fallbackRequests,
202
- fallbackHops: b.fallbackHops,
203
- latency: summarizeLatency(b.latency),
204
- providers: [...b.providers.entries()]
205
- .map(([ref, n]) => ({ ref, n }))
206
- .sort((a, b) => b.n - a.n),
207
- }))
208
- .sort((a, b) => {
209
- const ia = a.tier ? TIER_ORDER.indexOf(a.tier) : 99
210
- const ib = b.tier ? TIER_ORDER.indexOf(b.tier) : 99
211
- return ia - ib || b.n - a.n
212
- })
154
+ let fallbackRequests = 0
155
+ let fallbackHops = 0
156
+ let requestsOk = 0
157
+ let requestsError = 0
158
+ const requestLatencies = []
159
+ for (const req of byReq.values()) {
160
+ if (req.maxN > 1) {
161
+ fallbackRequests += 1
162
+ fallbackHops += req.maxN - 1
163
+ }
164
+ if (req.lastOk === true) requestsOk += 1
165
+ else requestsError += 1
166
+ if (req.totalMs > 0) requestLatencies.push(req.totalMs)
167
+ }
213
168
 
169
+ const tsList = used.map((x) => x.at).filter((v) => v != null)
214
170
  return {
215
171
  window: {
216
172
  ms: windowMs,
@@ -218,18 +174,29 @@ export function aggregateBifrostRows(rows, { now = Date.now(), windowMs = STATS_
218
174
  until: tsList.length ? Math.max(...tsList) : null,
219
175
  relaxed,
220
176
  },
221
- fetched: normalized.length,
222
- probes: probes.length,
177
+ fetched: (rows ?? []).length,
223
178
  used: used.length,
224
- total: used.length,
225
- ok,
226
- error,
227
- cancelled,
228
- latency: summarizeLatency(latencies),
229
- fallback: { requests: fallbackRequests, hops: fallbackHops, byIndex },
230
- byTier: tierRows,
179
+ attempts: used.length,
180
+ requests: byReq.size,
181
+ attemptsOk: ok,
182
+ attemptsError: error,
183
+ requestsOk,
184
+ requestsError,
185
+ attemptLatency: summarizeLatency(attemptLatencies),
186
+ requestLatency: summarizeLatency(requestLatencies),
187
+ fallback: { requests: fallbackRequests, hops: fallbackHops, byIndex: byAttemptIndex },
188
+ byChain: [...chains.values()]
189
+ .map(({ latency, providers: ps, ...rest }) => ({
190
+ ...rest,
191
+ latency: summarizeLatency(latency),
192
+ providers: [...ps.entries()]
193
+ .map(([ref, n]) => ({ ref, n }))
194
+ .sort((a, b) => b.n - a.n)
195
+ .slice(0, 8),
196
+ }))
197
+ .sort((a, b) => b.n - a.n),
231
198
  byProvider: [...providers.values()]
232
- .map((p) => ({ provider: p.provider, n: p.n, ok: p.ok, error: p.error, cancelled: p.cancelled, lastAt: p.lastAt, latency: summarizeLatency(p.latency) }))
199
+ .map((p) => ({ provider: p.provider, n: p.n, ok: p.ok, error: p.error, lastAt: p.lastAt, latency: summarizeLatency(p.latency) }))
233
200
  .sort((a, b) => b.n - a.n),
234
201
  byModel: [...models.values()].sort((a, b) => b.n - a.n).slice(0, 20),
235
202
  }
@@ -294,76 +261,72 @@ export function aggregateLedger(records, { now = Date.now() } = {}) {
294
261
 
295
262
  /**
296
263
  * 快照里的 `stats` 一节。
297
- * `source` 三态:`bifrost.api`(拿到了)/ `unavailable`(拿不到,附 error)/ `disabled`。
264
+ * `source` 三态:`gate.ledger`(拿到了)/ `unavailable`(拿不到,附 error)/ `disabled`。
298
265
  */
299
- export function buildStats({ bifrost = null, orchestrate = null, now = Date.now(), windowMs = STATS_DEFAULTS.windowMs, source = 'unavailable', error = null, checkedAt = null } = {}) {
300
- return { generatedAt: now, checkedAt, windowMs, source, error, bifrost, orchestrate }
266
+ export function buildStats({ gate = null, orchestrate = null, now = Date.now(), windowMs = STATS_DEFAULTS.windowMs, source = 'unavailable', error = null, checkedAt = null } = {}) {
267
+ return { generatedAt: now, checkedAt, windowMs, source, error, gate, orchestrate }
301
268
  }
302
269
 
303
270
  // ── IO(薄,注入 fetch 便于测试) ────────────────────────────────────────────
304
271
 
305
- function headers(token) {
306
- return token ? { 'X-Bifrost-Setup-Token': token } : {}
307
- }
308
-
309
272
  /**
310
- * 抓日志。/api/logs 的 `pagination.total_count` + 最新一行的 id 就是"有没有新东西"的指纹。
311
- * @returns {{rows: any[], total: number|null, signature: string|null, error: string|null}}
273
+ * 读 GATE 台账的**尾部**若干行(JSONL)。只读文件尾 —— 台账会轮转,但就算没轮转也不该整读。
274
+ * 第一行可能是被截断的半行 → 丢掉(解析失败的行一律丢,不猜)。
275
+ * @returns {{rows: any[], truncated: boolean, error: string|null}}
312
276
  */
313
- export async function fetchBifrostLogs({
314
- port,
315
- host = '127.0.0.1',
316
- token = null,
317
- limit = STATS_DEFAULTS.limit,
318
- offset = 0,
319
- order = 'desc',
320
- fetchImpl = fetch,
321
- timeoutMs = STATS_DEFAULTS.timeoutMs,
322
- } = {}) {
323
- const url = `http://${host}:${port}/api/logs?limit=${Number(limit)}&offset=${Number(offset)}&sort_by=timestamp&order=${order}`
277
+ export async function readLedgerTail({ path, limit = STATS_DEFAULTS.limit, tailBytes = 512 * 1024, readFileImpl = null, statImpl = null } = {}) {
278
+ const { open, stat } = await import('node:fs/promises')
279
+ const doStat = statImpl ?? stat
280
+ const doOpen = readFileImpl ?? open
281
+ let fh = null
324
282
  try {
325
- const res = await fetchImpl(url, { headers: headers(token), signal: AbortSignal.timeout(timeoutMs) })
326
- if (res.status !== 200) return { rows: [], total: null, signature: null, error: `HTTP ${res.status}` }
327
- const doc = await res.json()
328
- // 实测是 `logs`;`data` 只是当年的消费假设,留着兜底
329
- const rows = Array.isArray(doc?.logs) ? doc.logs : Array.isArray(doc?.data) ? doc.data : []
330
- const total = Number(doc?.pagination?.total_count)
331
- return {
332
- rows,
333
- total: Number.isFinite(total) ? total : null,
334
- signature: `${Number.isFinite(total) ? total : '?'}:${rows[0]?.id ?? '(空)'}`,
335
- error: null,
336
- }
283
+ const st = await doStat(path)
284
+ const start = Math.max(0, Number(st.size) - tailBytes)
285
+ fh = await doOpen(path, 'r')
286
+ const len = Number(st.size) - start
287
+ const buf = Buffer.alloc(len)
288
+ await fh.read(buf, 0, len, start)
289
+ const text = buf.toString('utf8')
290
+ const lines = text.split('\n').filter((l) => l.trim().length > 0)
291
+ const truncated = start > 0
292
+ const body = truncated ? lines.slice(1) : lines
293
+ const rows = body.map(parseGateRow).filter(Boolean)
294
+ return { rows: rows.slice(-limit), truncated, error: null }
337
295
  } catch (err) {
338
- return { rows: [], total: null, signature: null, error: String(err?.message ?? err).slice(0, 140) }
296
+ return { rows: [], truncated: false, error: String(err?.message ?? err).slice(0, 140) }
297
+ } finally {
298
+ if (fh) await fh.close().catch(() => {})
339
299
  }
340
300
  }
341
301
 
342
- /** Bifrost 自己发现出来的模型目录(`provider/model`)—— 面板选择器的候选来源之一。 */
343
- export async function fetchBifrostModels({ port, host = '127.0.0.1', fetchImpl = fetch, timeoutMs = STATS_DEFAULTS.timeoutMs } = {}) {
302
+ /** GATE 自己的模型目录(`provider/model` ∪ 链名)—— 面板选择器的候选来源之一。 */
303
+ export async function fetchGateModels({ port, host = '127.0.0.1', fetchImpl = fetch, timeoutMs = STATS_DEFAULTS.timeoutMs } = {}) {
344
304
  try {
345
305
  const res = await fetchImpl(`http://${host}:${port}/v1/models`, { signal: AbortSignal.timeout(timeoutMs) })
346
- if (res.status !== 200) return { models: [], error: `HTTP ${res.status}` }
306
+ if (res.status !== 200) return { models: [], chains: [], error: `HTTP ${res.status}` }
347
307
  const doc = await res.json()
308
+ const chains = []
348
309
  const models = (Array.isArray(doc?.data) ? doc.data : [])
349
310
  .map((m) => {
350
311
  const ref = String(m?.id ?? '')
312
+ if (m?.owned_by === 'reeve') {
313
+ chains.push(ref)
314
+ return null
315
+ }
351
316
  const i = ref.indexOf('/')
352
317
  if (i <= 0) return null
353
318
  return { ref, provider: ref.slice(0, i), model: ref.slice(i + 1), context: Number(m?.context_length) || null }
354
319
  })
355
320
  .filter(Boolean)
356
- return { models, error: null }
321
+ return { models, chains, error: null }
357
322
  } catch (err) {
358
- return { models: [], error: String(err?.message ?? err).slice(0, 140) }
323
+ return { models: [], chains: [], error: String(err?.message ?? err).slice(0, 140) }
359
324
  }
360
325
  }
361
326
 
362
327
  /**
363
- * 面板选择器的候选 = 发现出来的 ∪ 链上已经在用的 ∪ 引擎注册的(reeve/*)。
364
- * 为什么必须含"链上在用的"与"手工可填":实测 `/v1/models` 只覆盖 4/7 个 provider
365
- * (zhipuai-coding-plan 的对话路径是自定义的,`/v1/models` 探不到;opencode zen 与
366
- * alibaba-cn 同理)。发现不全就如实标出来,而不是假装列表就是全集。
328
+ * 面板选择器的候选 = GATE 目录(声明 ∪ 池)∪ 链上已经在用的 ∪ 引擎注册的(reeve/*)。
329
+ * 发现不全就如实标出来(每条候选带 sources),而不是假装列表就是全集。
367
330
  */
368
331
  export function mergeModelCandidates({ discovered = [], chained = [], engine = [] } = {}) {
369
332
  const out = new Map()
@@ -377,7 +340,7 @@ export function mergeModelCandidates({ discovered = [], chained = [], engine = [
377
340
  if (!cur.sources.includes(source)) cur.sources.push(source)
378
341
  out.set(ref, cur)
379
342
  }
380
- for (const m of discovered) add(m, 'bifrost')
343
+ for (const m of discovered) add(m, 'gate')
381
344
  for (const ref of chained) add(ref, 'chain')
382
345
  for (const m of engine) add(m, 'engine')
383
346
  return [...out.values()].sort((a, b) => a.ref.localeCompare(b.ref))