@bd7pil/reeve 0.1.6 → 0.1.7

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "//": "GATE 的兜底配置(机器上没有任何 reeve-fallback.jsonc 时用它)。刻意只放付费候选——免费车道(9router)属于每台机器自己的选择,写在用户配置里。",
2
+ "//": "GATE 的兜底配置(机器上没有任何 reeve-fallback.jsonc 时用它)。刻意只放付费候选——免费车道(@free)属于每台机器自己的选择,写在用户配置里。",
3
3
  "//cells": "providers.<id>:base_url + path(默认 /v1/chat/completions)+ auth(auth.json 里的 id,按序取第一个有 key 的)+ key(env.X 引用或公开字面量,覆盖 auth)+ lane(native | passthrough)+ models/models_fetch(目录来源)。",
4
4
  "//tiers": "chain_for_model 里的 `reeve/*` 三条 = 引擎档位模型(插件注册的 reeve/flash|pro|multimodal)。引擎发**全名**进请求体,键必须写全名。没有免费车道时:flash → tier2a(便宜付费链)、multimodal → tier2a(bundle 里没有视觉专链)。",
5
5
  "schema": 1,
@@ -14,14 +14,26 @@
14
14
  "session_ttl_seconds": 1800
15
15
  },
16
16
  "providers": {
17
- "9router": { "base_url": "http://127.0.0.1:20128", "key": "none", "models": ["*"], "models_fetch": true },
17
+ "opencode": { "base_url": "https://opencode.ai/inference/openai/v1", "path": "/chat/completions", "key": "public", "lane": "passthrough", "models": ["*"] },
18
+ "openrouter": { "base_url": "https://openrouter.ai/api/v1", "auth": ["openrouter"], "models": [] },
19
+ "nvidia": { "base_url": "https://integrate.api.nvidia.com/v1", "auth": ["nvidia"], "models": [] },
18
20
  "zhipuai-coding-plan": { "base_url": "https://open.bigmodel.cn", "path": "/api/coding/paas/v4/chat/completions", "auth": ["zhipuai-coding-plan"] },
19
21
  "xxyapi-deepseek": { "base_url": "https://www.xxyapi.com", "auth": ["xxyapi-deepseek"] },
20
22
  "lmuai": { "base_url": "https://api.lmuai.com", "auth": ["lmuai", "lmuai-glm"] },
21
23
  "deepseek": { "base_url": "https://api.deepseek.com", "auth": ["deepseek", "deepseek-api"] },
22
24
  "xiaomi": { "base_url": "https://api.xiaomimimo.com", "auth": ["xiaomi"] },
23
- "alibaba-cn": { "base_url": "https://dashscope.aliyuncs.com", "path": "/compatible-mode/v1/chat/completions", "auth": ["alibaba-cn"] },
24
- "opencode": { "base_url": "https://opencode.ai/inference/openai/v1", "path": "/chat/completions", "key": "public", "lane": "passthrough", "models": ["*"] }
25
+ "alibaba-cn": { "base_url": "https://dashscope.aliyuncs.com", "path": "/compatible-mode/v1/chat/completions", "auth": ["alibaba-cn"] }
26
+ },
27
+ "free": {
28
+ "enabled": true,
29
+ "max": 3,
30
+ "providers": ["opencode", "openrouter", "nvidia"],
31
+ "refresh_seconds": 300,
32
+ "failure_threshold": 3,
33
+ "failure_cooldown_seconds": 600,
34
+ "prefer": [],
35
+ "deny": [],
36
+ "static": {}
25
37
  },
26
38
  "chains": {
27
39
  "tier1": { "targets": ["zhipuai-coding-plan/glm-5.3", "deepseek/deepseek-flash"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bd7pil/reeve",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "REEVE 编排层(opencode v2 插件):agent/skill 注册 + 子任务台账 + GATE 代理层(路由/回退/台账)+ 性能统计",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,7 +11,7 @@
11
11
  "node": ">=22"
12
12
  },
13
13
  "scripts": {
14
- "test": "node test/unit.mjs && node test/reconcile.mjs && node test/snapshot.mjs && node test/gate.mjs && node test/guard.mjs && node test/stats.mjs && node test/entry.mjs && node test/wiring.mjs && node test/runtime.mjs",
14
+ "test": "node test/unit.mjs && node test/reconcile.mjs && node test/snapshot.mjs && node test/gate.mjs && node test/free.mjs && node test/guard.mjs && node test/stats.mjs && node test/entry.mjs && node test/wiring.mjs && node test/runtime.mjs",
15
15
  "test:coverage": "c8 --reporter=text --reporter=json-summary --reports-dir=coverage --exclude=test/** --exclude=bench.mjs npm test",
16
16
  "check:coverage": "node ../scripts/check-coverage.mjs",
17
17
  "prepublishOnly": "npm test"
package/provision.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "name": "reeve",
4
- "version": "0.1.6",
4
+ "version": "0.1.7",
5
5
  "license": "MIT",
6
6
  "//": "REEVE 在 ocrc 实例里的装配说明。**没有二进制工件** —— 代理层是插件内的 GATE(纯 JS),不需要任何本地服务(Bifrost 时代已结束)。",
7
7
  "host": {
@@ -15,7 +15,7 @@
15
15
  ]
16
16
  },
17
17
  "enginePlugin": {
18
- "spec": "@bd7pil/reeve@0.1.6",
18
+ "spec": "@bd7pil/reeve@0.1.7",
19
19
  "kind": "package",
20
20
  "entry": "~/.ocrc/components/reeve/node_modules/@bd7pil/reeve",
21
21
  "requiresPeer": []
@@ -35,6 +35,24 @@ export const POLICY_DEFAULTS = Object.freeze({
35
35
  const DEFAULT_PATH = '/v1/chat/completions'
36
36
  export const LANES = Object.freeze({ NATIVE: 'native', PASSTHROUGH: 'passthrough' })
37
37
 
38
+ /** 免费车道的默认值(面板可改;见 docs/FREE-LANE.md)。 */
39
+ export const FREE_DEFAULTS = Object.freeze({
40
+ enabled: true,
41
+ max: 3,
42
+ providers: Object.freeze(['opencode', 'openrouter', 'nvidia']),
43
+ refresh_seconds: 300,
44
+ failure_threshold: 3,
45
+ failure_cooldown_seconds: 600,
46
+ prefer: Object.freeze([]),
47
+ deny: Object.freeze([]),
48
+ static: Object.freeze({}),
49
+ sources: Object.freeze({
50
+ // 公开模型列表(不需要键;拉不到只影响该 provider,请求照常降级到付费)
51
+ opencode: Object.freeze({ url: 'https://opencode.ai/zen/v1/models', filter: 'opencode-free' }),
52
+ openrouter: Object.freeze({ url: 'https://openrouter.ai/api/v1/models', filter: 'openrouter-free' }),
53
+ }),
54
+ })
55
+
38
56
  export function stripLineComments(text) {
39
57
  return String(text)
40
58
  .split('\n')
@@ -87,6 +105,44 @@ export function normalizeGateConfig(raw) {
87
105
  }
88
106
  }
89
107
 
108
+ // 免费车道:发现源 + @free 展开的偏好。全部有默认值;坏字段进 problems,不影响其余配置。
109
+ const asInt = (v, min, max, dflt) => {
110
+ const n = Number(v)
111
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.trunc(n))) : dflt
112
+ }
113
+ const fSrc = asRecord(src.free) ?? {}
114
+ const freeProviders = asStrings(fSrc.providers).length > 0 ? asStrings(fSrc.providers) : [...FREE_DEFAULTS.providers]
115
+ const free = {
116
+ enabled: fSrc.enabled !== false,
117
+ max: asInt(fSrc.max, 0, 8, FREE_DEFAULTS.max),
118
+ providers: freeProviders,
119
+ refresh_seconds: asInt(fSrc.refresh_seconds, 30, 86_400, FREE_DEFAULTS.refresh_seconds),
120
+ failure_threshold: asInt(fSrc.failure_threshold, 1, 20, FREE_DEFAULTS.failure_threshold),
121
+ failure_cooldown_seconds: asInt(fSrc.failure_cooldown_seconds, 60, 86_400, FREE_DEFAULTS.failure_cooldown_seconds),
122
+ prefer: asStrings(fSrc.prefer),
123
+ deny: asStrings(fSrc.deny),
124
+ static: {},
125
+ sources: { ...FREE_DEFAULTS.sources },
126
+ }
127
+ for (const [id, list] of Object.entries(asRecord(fSrc.static) ?? {})) {
128
+ if (!Array.isArray(list)) {
129
+ problems.push(`free.static.${id} 不是数组(忽略)`)
130
+ continue
131
+ }
132
+ free.static[id] = asStrings(list)
133
+ }
134
+ for (const [id, v] of Object.entries(asRecord(fSrc.sources) ?? {})) {
135
+ const s = asRecord(v)
136
+ if (!s || typeof s.url !== 'string' || s.url.length === 0) {
137
+ problems.push(`free.sources.${id} 缺 url(忽略)`)
138
+ continue
139
+ }
140
+ free.sources[id] = { url: s.url, filter: typeof s.filter === 'string' && s.filter.length > 0 ? s.filter : FREE_DEFAULTS.sources[id]?.filter ?? 'opencode-free' }
141
+ }
142
+ for (const id of free.providers) {
143
+ if (!providers[id]) problems.push(`free.providers 含未配置的 provider:${id}(会被跳过)`)
144
+ }
145
+
90
146
  const chains = {}
91
147
  for (const [name, v] of Object.entries(asRecord(src.chains) ?? {})) {
92
148
  const targets = asStrings(asRecord(v)?.targets)
@@ -118,7 +174,7 @@ export function normalizeGateConfig(raw) {
118
174
  const defaultChain = typeof src.default_chain === 'string' && chains[src.default_chain] ? src.default_chain : Object.keys(chains)[0] ?? null
119
175
  if (defaultChain === null) problems.push('没有可用的链(default_chain 缺失)')
120
176
 
121
- return { schema: 1, policy, providers, chains, agents, chain_for_model: chainForModel, default_chain: defaultChain, problems }
177
+ return { schema: 1, policy, providers, chains, agents, chain_for_model: chainForModel, default_chain: defaultChain, free, problems }
122
178
  }
123
179
 
124
180
  /**
@@ -0,0 +1,271 @@
1
+ // GATE 的自动免费车道。
2
+ //
3
+ // 为什么需要它:免费档(OpenCode Free / OpenRouter `:free` / NVIDIA 免费额度)的**模型列表会变**
4
+ // 且**时好时坏**。写死候选会腐坏;写一个"管理器"定时改配置又会 churn 用户的文件。这里的做法是:
5
+ // · 链里只写占位符 `@free`;
6
+ // · 请求时按"发现到的、未冷却的、有键的"候选**展开**成真实 provider/model;
7
+ // · 发现结果带 TTL(`free.refresh_seconds`),失败保留上一份好列表并如实记错误;
8
+ // · 失败的免费模型按 **ref** 进全局冷却(连续 `failure_threshold` 次 → `failure_cooldown_seconds`),
9
+ // 全部冷却/不可用 → 本次直接走链里的付费候选(不硬撞限流)。
10
+ //
11
+ // 时间与 IO 全部注入(`now` / `fetchImpl`),测试用假时钟 + 发现源 stub,不 sleep、不出网。
12
+ import { parseTarget, resolveKeyFor, targetKey } from './lanes.js'
13
+
14
+ export const FREE_TOKEN = '@free'
15
+
16
+ /** 免费过滤规则(与上游自己的"免费"口径对齐;opencode 排掉已知非免费的 v4-flash)。 */
17
+ export const FREE_FILTERS = Object.freeze({
18
+ 'opencode-free': (id) => id !== 'deepseek-v4-flash-free' && (id.endsWith('-free') || id === 'big-pickle'),
19
+ 'openrouter-free': (id) => id.endsWith(':free'),
20
+ all: () => true,
21
+ })
22
+
23
+ const asList = (v) => (Array.isArray(v) ? v.map((x) => String(x)).filter((x) => x.length > 0) : [])
24
+
25
+ /**
26
+ * @param {object} opts
27
+ * @param {() => object|null} opts.getConfig 归一化后的当前配置(热更后即时生效)
28
+ * @param {() => object} [opts.getAuth] auth.json
29
+ * @param {() => number} [opts.now]
30
+ * @param {Function} [opts.fetchImpl]
31
+ * @param {{debug?: Function}|null} [opts.logger]
32
+ * @param {number} [opts.timeoutMs] 发现请求超时(不能把首笔请求拖太久)
33
+ */
34
+ export function createFreeLane({ getConfig, getAuth = () => ({}), now = Date.now, fetchImpl = fetch, logger = null, timeoutMs = 2500 } = {}) {
35
+ /** providerId -> { at, models: string[], error: string|null, from: string|null, sig: string } */
36
+ const cache = new Map()
37
+ /** ref -> { fails: number, until: number }(全局,跨 session:坏模型对所有人都坏) */
38
+ const cooldowns = new Map()
39
+ const inflight = new Map()
40
+
41
+ const freeCfg = () => {
42
+ const f = getConfig()?.free
43
+ return f && typeof f === 'object' ? f : { enabled: false, max: 0, providers: [], prefer: [], deny: [], static: {}, sources: {} }
44
+ }
45
+ const providers = () => getConfig()?.providers ?? {}
46
+ const policy = () => getConfig()?.policy ?? {}
47
+
48
+ const hasKey = (provider) => {
49
+ try {
50
+ return resolveKeyFor(provider, { env: process.env, auth: getAuth() ?? {} }) !== null
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ const sourceOf = (free, id) => {
57
+ const staticList = free.static?.[id]
58
+ if (Array.isArray(staticList)) return { kind: 'static', models: asList(staticList) }
59
+ const src = free.sources?.[id]
60
+ if (src && typeof src.url === 'string' && src.url.length > 0) {
61
+ return { kind: 'fetch', url: src.url, filter: FREE_FILTERS[src.filter] ?? FREE_FILTERS['opencode-free'] }
62
+ }
63
+ return null
64
+ }
65
+
66
+ const doLoad = async (id, { force = false } = {}) => {
67
+ const free = freeCfg()
68
+ const provider = providers()[id]
69
+ // 调用方(discovered/warm)已保证 provider 存在且带 base_url;这里不再重复判空(否则是测不到的死分支)
70
+ const src = sourceOf(free, id)
71
+ if (!src) {
72
+ const st = { at: now(), models: [], error: '没有发现源(static 或 sources)', from: null, sig: `none:${id}` }
73
+ cache.set(id, st)
74
+ return st
75
+ }
76
+ if (src.kind === 'static') {
77
+ const st = { at: now(), models: src.models, error: null, from: 'static', sig: `static:${id}` }
78
+ cache.set(id, st)
79
+ return st
80
+ }
81
+ const ttlMs = Math.max(30, Number(free.refresh_seconds ?? 300)) * 1000
82
+ const prev = cache.get(id)
83
+ if (!force && prev && prev.sig === src.url && now() - prev.at < ttlMs && prev.error === null) return prev
84
+ try {
85
+ const res = await fetchImpl(src.url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) })
86
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
87
+ const body = await res.json()
88
+ const raw = Array.isArray(body?.data) ? body.data : Array.isArray(body?.models) ? body.models : []
89
+ const models = [...new Set(raw.map((m) => (typeof m === 'string' ? m : m?.id)).filter((x) => typeof x === 'string' && x.length > 0).filter(src.filter))]
90
+ const next = { at: now(), models, error: null, from: src.url, sig: src.url }
91
+ cache.set(id, next)
92
+ return next
93
+ } catch (err) {
94
+ // 拉不到就**保留上一份好列表**(免费池抖一下不该让链里突然没有免费候选)
95
+ const next = { at: now(), models: prev?.models ?? [], error: String(err?.message ?? err).slice(0, 140), from: src.url, sig: src.url }
96
+ cache.set(id, next)
97
+ logger?.debug?.(`[gate] 免费车道发现失败 ${id}:${next.error}`)
98
+ return next
99
+ }
100
+ }
101
+
102
+ const load = (id, opts) => {
103
+ if (!inflight.has(id)) {
104
+ const p = doLoad(id, opts).finally(() => inflight.delete(id))
105
+ inflight.set(id, p)
106
+ }
107
+ return inflight.get(id)
108
+ }
109
+
110
+ const isCooling = (ref) => {
111
+ const c = cooldowns.get(ref)
112
+ if (!c) return false
113
+ // until=0 表示"失败次数还没到阈值":**保留计数**,不能当成已过期删掉(否则永远攒不到阈值)
114
+ if (c.until === 0) return false
115
+ if (now() >= c.until) {
116
+ cooldowns.delete(ref)
117
+ return false
118
+ }
119
+ return true
120
+ }
121
+
122
+ const denySet = () => new Set(asList(freeCfg().deny))
123
+ const denied = (c, deny) => deny.has(c.ref) || deny.has(c.model)
124
+
125
+ /** 当前可发现的免费候选(provider 顺序 × 目录顺序)。不发请求时靠 TTL 缓存。 */
126
+ const discovered = async () => {
127
+ const free = freeCfg()
128
+ if (!free.enabled) return []
129
+ const out = []
130
+ for (const id of asList(free.providers)) {
131
+ const provider = providers()[id]
132
+ if (!provider?.base_url || !hasKey(provider)) continue
133
+ const st = await load(id)
134
+ for (const model of st.models ?? []) out.push({ provider: id, model, ref: `${id}/${model}` })
135
+ }
136
+ return out
137
+ }
138
+
139
+ /** prefer 里可以是 full ref(`opencode/x`)或裸模型名;命中的排最前,其余保持发现序。 */
140
+ const rank = (list, prefer) => {
141
+ const out = []
142
+ const seen = new Set()
143
+ for (const p of prefer) {
144
+ for (const c of list) {
145
+ if (seen.has(c.ref)) continue
146
+ if (c.ref === p || c.model === p) {
147
+ out.push(c)
148
+ seen.add(c.ref)
149
+ break
150
+ }
151
+ }
152
+ }
153
+ for (const c of list) if (!seen.has(c.ref)) out.push(c)
154
+ return out
155
+ }
156
+
157
+ const capFor = () => {
158
+ const free = freeCfg()
159
+ const maxAttempts = Math.max(1, Number(policy().max_fallback_attempts ?? 3))
160
+ // 永远给付费兜底留一跳:免费最多占 maxAttempts - 1
161
+ return Math.max(0, Math.min(Number(free.max ?? 0), maxAttempts - 1))
162
+ }
163
+
164
+ /**
165
+ * 把 targets 里的 `@free` 换成本次要试的真实候选。
166
+ * @returns {{targets: Array<object>, expanded: string[], dynamic: boolean}}
167
+ */
168
+ const expand = async (targets, { maxAttempts = null } = {}) => {
169
+ const list = Array.isArray(targets) ? targets : []
170
+ const hasToken = list.some((t) => t?.free)
171
+ if (!hasToken) return { targets: list, expanded: [], dynamic: false }
172
+ const base = list.filter((t) => !t?.free)
173
+ const free = freeCfg()
174
+ const cap = maxAttempts === null ? capFor() : Math.max(0, Math.min(Number(free.max ?? 0), Math.max(1, Number(maxAttempts)) - 1))
175
+ if (!free.enabled || cap === 0) return { targets: base, expanded: [], dynamic: true }
176
+ const deny = denySet()
177
+ const explicit = new Set(base.map((t) => targetKey(t)))
178
+ const all = (await discovered()).filter((c) => !denied(c, deny) && !isCooling(c.ref) && !explicit.has(c.ref))
179
+ const picked = rank(all, asList(free.prefer)).slice(0, cap)
180
+ const expandedTargets = picked.map((c) => ({ provider: c.provider, model: c.model, auto: true }))
181
+ const out = []
182
+ let injected = false
183
+ for (const t of list) {
184
+ if (t?.free) {
185
+ if (!injected) {
186
+ out.push(...expandedTargets)
187
+ injected = true
188
+ }
189
+ continue
190
+ }
191
+ out.push(t)
192
+ }
193
+ return { targets: out, expanded: picked.map((c) => c.ref), dynamic: true }
194
+ }
195
+
196
+ /** 只对 `@free` 展开出来的候选记全局健康(显式候选沿用按 session 的冷却)。 */
197
+ const noteAttempt = (attempt, ok) => {
198
+ if (attempt?.auto !== true) return
199
+ const providerId = attempt.provider?.id ?? attempt.provider
200
+ const ref = `${providerId}/${attempt.model}`
201
+ if (ok) {
202
+ cooldowns.delete(ref)
203
+ return
204
+ }
205
+ const free = freeCfg()
206
+ const threshold = Math.max(1, Number(free.failure_threshold ?? 3))
207
+ const cur = cooldowns.get(ref) ?? { fails: 0, until: 0 }
208
+ cur.fails += 1
209
+ if (cur.fails >= threshold) cur.until = now() + Math.max(0, Number(free.failure_cooldown_seconds ?? 600)) * 1000
210
+ cooldowns.set(ref, cur)
211
+ }
212
+
213
+ /** 面板/快照用的运行态(不触发新的发现请求:只读缓存;缓存为空则如实报 0/未刷新)。 */
214
+ const runtime = () => {
215
+ const free = freeCfg()
216
+ const provs = []
217
+ for (const id of asList(free.providers)) {
218
+ const provider = providers()[id]
219
+ const st = cache.get(id)
220
+ provs.push({
221
+ id,
222
+ ready: Boolean(provider?.base_url) && hasKey(provider),
223
+ models: st?.models?.length ?? 0,
224
+ error: st?.error ?? null,
225
+ at: st?.at ?? null,
226
+ source: st?.from ?? null,
227
+ })
228
+ }
229
+ const cooling = [...cooldowns.entries()]
230
+ .filter(([, c]) => now() < c.until)
231
+ .map(([ref, c]) => ({ ref, fails: c.fails, until: c.until }))
232
+ return {
233
+ enabled: free.enabled !== false,
234
+ max: Number(free.max ?? 0),
235
+ cap: capFor(),
236
+ providers: provs,
237
+ cooling,
238
+ prefer: asList(free.prefer),
239
+ deny: asList(free.deny),
240
+ }
241
+ }
242
+
243
+ /** 当前展开预览(面板用;会按 TTL 触发发现)。 */
244
+ const snapshot = async () => {
245
+ const free = freeCfg()
246
+ const cfg = getConfig()
247
+ let chain = null
248
+ let expandedRefs = []
249
+ if (free.enabled !== false && cfg?.chains) {
250
+ const hit = Object.entries(cfg.chains).find(([, c]) => (c.targets ?? []).some((t) => t === FREE_TOKEN))
251
+ if (hit) {
252
+ chain = hit[0]
253
+ const parsed = (hit[1].targets ?? []).map(parseTarget).filter(Boolean)
254
+ const out = await expand(parsed, { maxAttempts: policy().max_fallback_attempts })
255
+ expandedRefs = out.expanded
256
+ }
257
+ }
258
+ return { ...runtime(), chain, expand: expandedRefs }
259
+ }
260
+
261
+ /** 启动/热更后的后台预热(失败静默,不阻塞)。 */
262
+ const warm = () => {
263
+ const free = freeCfg()
264
+ if (free.enabled === false) return
265
+ for (const id of asList(free.providers)) {
266
+ if (providers()[id]?.base_url && hasKey(providers()[id])) void load(id).catch(() => {})
267
+ }
268
+ }
269
+
270
+ return { expand, noteAttempt, isCooling, discovered, snapshot, runtime, warm, capFor }
271
+ }
package/src/gate/gate.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // POST /gate/session 插件侧的 session→agent 桥(把"这个 session 是 scout"告诉 GATE)
6
6
  // POST /gate/session/:id/next 插件侧要"下一个候选"(流已开始/沉默这两类层内换不了的情形)
7
7
  // GET /gate/version 探针/测试用
8
+ // GET /gate/free 免费车道运行态(发现源/展开预览/冷却)——面板只读它
8
9
  //
9
10
  // 纪律:① 只记录、不泄漏——台账里没有密钥(只记键的"名字");② 时间与 IO 全可注入(测试零 sleep、零真实出网);
10
11
  // ③ 管理面只认环回;④ dispose 尽力而为、绝不因为清理炸掉(含"定时器已开"的情况)。
@@ -16,7 +17,8 @@ import { Readable } from 'node:stream'
16
17
 
17
18
  import { classifyFailure } from './classify.js'
18
19
  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 { createFreeLane } from './free.js'
21
+ import { attemptKey, attemptRow, buildUpstreamRequest, modelsPathOf, parseTarget, planAttempts, resolveKeyFor, routeFor } from './lanes.js'
20
22
  import { createSessionStore } from './state.js'
21
23
 
22
24
  export const GATE_VERSION = '0.1.0'
@@ -143,6 +145,10 @@ export async function startGate({
143
145
  return { key: auth[id].key, name: `auth:${id}` }
144
146
  }
145
147
 
148
+ // 免费车道:发现(公开模型列表/静态白名单)+ `@free` 展开 + 按 ref 的全局冷却。
149
+ // 读的是**当前** config(热更即时生效);时间/IO 注入,测试零出网。
150
+ const freeLane = createFreeLane({ getConfig: () => config, getAuth: () => auth, now, fetchImpl, logger })
151
+
146
152
  const note = (row) => ledger.write(row)
147
153
 
148
154
  /** 模型目录:声明 ∪ 运行时池(TTL)∪ 链名。池拉不到就降级(只回声明),不报错。 */
@@ -173,6 +179,12 @@ export async function startGate({
173
179
  logger?.debug?.(`[gate] 拉 ${provider.id} 的模型池失败(降级):${String(err).slice(0, 120)}`)
174
180
  }
175
181
  }
182
+ // 免费车道发现到的模型也进目录(面板的选择器才能看到并固定它们;`@free` 本身不需要进)
183
+ try {
184
+ for (const c of await freeLane.discovered()) push(c.ref, c.provider)
185
+ } catch {
186
+ /* 发现失败只影响目录的完整性,不影响请求 */
187
+ }
176
188
  for (const name of Object.keys(config.chains)) push(name, 'reeve')
177
189
  modelsCache.data = data
178
190
  modelsCache.at = now()
@@ -200,7 +212,9 @@ export async function startGate({
200
212
  const sessionID = typeof req.headers['x-opencode-session-id'] === 'string' ? req.headers['x-opencode-session-id'] : null
201
213
  const route = routeFor({ model, sessionID }, { config, sessions })
202
214
  if (!route) return sendJson(res, 404, { error: { message: `未知模型或链:${model}` } })
203
- const plan = planAttempts(route, { config, sessions, sessionID, keyPicker })
215
+ // `@free` → 当前可发现、未冷却、有键的真实候选(动态链不复用按索引的粘性指针)
216
+ const expanded = await freeLane.expand(route.targets, { maxAttempts: config.policy.max_fallback_attempts })
217
+ const plan = planAttempts({ ...route, targets: expanded.targets, dynamicFree: expanded.dynamic }, { config, sessions, sessionID, keyPicker })
204
218
 
205
219
  if (plan.ordered.length === 0) {
206
220
  return sendJson(res, 502, {
@@ -247,12 +261,14 @@ export async function startGate({
247
261
  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
262
  attemptsSeen.push({ provider: attempt.provider.id, model: attempt.model, code: null, reason: 'network' })
249
263
  sessions.noteFailure(sessionID ?? '(anon)', plan.chainName, attemptKey(attempt))
264
+ freeLane.noteAttempt(attempt, false)
250
265
  continue
251
266
  }
252
267
 
253
268
  if (upstream.ok) {
254
269
  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
270
  sessions.noteSuccess(sessionID ?? '(anon)', plan.chainName, attemptKey(attempt), plan.keys)
271
+ freeLane.noteAttempt(attempt, true)
256
272
  res.writeHead(upstream.status, {
257
273
  'content-type': upstream.headers?.get?.('content-type') ?? 'application/json',
258
274
  'x-reeve-fallback': `${i + 1}/${attempt.provider.id}/${attempt.model}`,
@@ -276,6 +292,7 @@ export async function startGate({
276
292
  return
277
293
  }
278
294
  sessions.noteFailure(sessionID ?? '(anon)', plan.chainName, attemptKey(attempt))
295
+ freeLane.noteAttempt(attempt, false)
279
296
  }
280
297
 
281
298
  sendJson(res, 502, { error: { message: '所有候选都失败', chain: plan.chainName, req: reqId, attempts: attemptsSeen } })
@@ -306,6 +323,19 @@ export async function startGate({
306
323
  agents: { ...config.agents },
307
324
  chain_for_model: { ...config.chain_for_model },
308
325
  default_chain: config.default_chain ?? null,
326
+ // 免费车道的**配置**(运行态在 /gate/free;面板的"已生效"比对用这一份)
327
+ free: {
328
+ enabled: config.free.enabled,
329
+ max: config.free.max,
330
+ providers: [...config.free.providers],
331
+ refresh_seconds: config.free.refresh_seconds,
332
+ failure_threshold: config.free.failure_threshold,
333
+ failure_cooldown_seconds: config.free.failure_cooldown_seconds,
334
+ prefer: [...config.free.prefer],
335
+ deny: [...config.free.deny],
336
+ static: { ...config.free.static },
337
+ sources: { ...config.free.sources },
338
+ },
309
339
  problems,
310
340
  sessions: sessions.snapshot(),
311
341
  ledger: ledger.path,
@@ -313,6 +343,9 @@ export async function startGate({
313
343
  }
314
344
  if (url.startsWith('/gate/')) {
315
345
  if (!LOOPBACK.has(remote)) return sendJson(res, 403, { error: { message: '管理面只认环回' } })
346
+ if (req.method === 'GET' && url === '/gate/free') {
347
+ return sendJson(res, 200, await freeLane.snapshot())
348
+ }
316
349
  if (req.method === 'POST' && url === '/gate/session') {
317
350
  const bodyText = await readBody(req, 1024 * 1024).catch(() => '{}')
318
351
  const { sessionID, agent } = JSON.parse(bodyText || '{}')
@@ -328,7 +361,10 @@ export async function startGate({
328
361
  const agent = sessions.agentOf(sessionID)
329
362
  const chainName = agent && config.agents[agent] ? config.agents[agent] : config.default_chain
330
363
  if (!chainName || !config.chains[chainName]) return sendJson(res, 200, { model: null, chain: null })
331
- const keys = config.chains[chainName].targets
364
+ // 动态链(含 `@free`)在调用时展开,插件侧拿到的才是真实候选而不是占位符
365
+ const parsed = config.chains[chainName].targets.map(parseTarget).filter(Boolean)
366
+ const expanded = await freeLane.expand(parsed, { maxAttempts: config.policy.max_fallback_attempts })
367
+ const keys = expanded.targets.filter((t) => !t.free).map((t) => `${t.provider}/${t.model}`)
332
368
  const out = sessions.advance(sessionID, chainName, keys, { dedupeKey })
333
369
  return sendJson(res, 200, { model: out.model, chain: chainName, index: out.index })
334
370
  }
@@ -368,11 +404,14 @@ export async function startGate({
368
404
  problems = [...(next.problems ?? [])]
369
405
  modelsCache.data = null
370
406
  logger?.debug?.(`[gate] 配置热更:${next.from}`)
407
+ freeLane.warm()
371
408
  } else {
372
409
  logger?.debug?.(`[gate] 配置读不动,保留上一份:${next.error ?? '?'}`)
373
410
  }
374
411
  },
375
412
  })
413
+ // 免费车道后台预热(不阻塞启动;失败只影响免费候选,请求照常降级到付费)
414
+ freeLane.warm()
376
415
 
377
416
  const disposeFns = [() => watch.stop(), () => ledger.flush(), () => new Promise((r) => server.close(() => r()))]
378
417
  let sweep = null
@@ -399,6 +438,17 @@ export async function startGate({
399
438
  agents: { ...config.agents },
400
439
  chain_for_model: { ...config.chain_for_model },
401
440
  default_chain: config.default_chain ?? null,
441
+ free: {
442
+ enabled: config.free.enabled,
443
+ max: config.free.max,
444
+ providers: [...config.free.providers],
445
+ refresh_seconds: config.free.refresh_seconds,
446
+ failure_threshold: config.free.failure_threshold,
447
+ failure_cooldown_seconds: config.free.failure_cooldown_seconds,
448
+ prefer: [...config.free.prefer],
449
+ deny: [...config.free.deny],
450
+ static: { ...config.free.static },
451
+ },
402
452
  ledger: ledger.path,
403
453
  problems,
404
454
  sessions: sessions.snapshot(),
package/src/gate/lanes.js CHANGED
@@ -13,15 +13,18 @@
13
13
  // 冷却跳过、上限)。**第一个尝试永远保留**:哪怕全在冷却,请求也得有人接。
14
14
  import { LANES } from './config.js'
15
15
 
16
- /** `"provider/model"`:只在**第一个** `/` 切分(模型名本身可以带斜杠,如 9router 的 `oc/xxx`)。 */
16
+ /** `"provider/model"`:只在**第一个** `/` 切分(模型名本身可以带斜杠,如 9router 的 `oc/xxx`)。
17
+ * `@free` 是链里的**免费车道占位符**(请求时由 free.js 展开成真实候选;不是 provider)。
18
+ * `parseTarget('@free')` → `{ free: true }`。 */
17
19
  export function parseTarget(spec) {
18
20
  const s = String(spec ?? '').trim()
21
+ if (s === '@free') return { free: true }
19
22
  const i = s.indexOf('/')
20
23
  if (i <= 0 || i === s.length - 1) return null
21
24
  return { provider: s.slice(0, i), model: s.slice(i + 1) }
22
25
  }
23
26
 
24
- export const targetKey = (t) => `${t.provider}/${t.model}`
27
+ export const targetKey = (t) => (t?.free ? '@free' : `${t.provider}/${t.model}`)
25
28
 
26
29
  /** 尝试的键(注意:attempt.provider 是**完整的 provider 对象**,要取 .id)。 */
27
30
  export const attemptKey = (attempt) => `${attempt.provider.id}/${attempt.model}`
@@ -86,6 +89,10 @@ export function planAttempts(route, { config, sessions, sessionID = null, keyPic
86
89
  const seen = new Set()
87
90
  const staticTargets = []
88
91
  for (const t of route.targets) {
92
+ if (t?.free) {
93
+ skipped.push({ target: '@free', why: 'unexpanded' })
94
+ continue
95
+ }
89
96
  const key = targetKey(t)
90
97
  if (seen.has(key)) {
91
98
  skipped.push({ target: key, why: 'no-op' })
@@ -102,10 +109,12 @@ export function planAttempts(route, { config, sessions, sessionID = null, keyPic
102
109
  skipped.push({ target: key, why: 'no-key' })
103
110
  continue
104
111
  }
105
- staticTargets.push({ provider, model: t.model, key: resolved.name, authorization: resolved.key === null ? null : `Bearer ${resolved.key}` })
112
+ staticTargets.push({ provider, model: t.model, auto: t.auto === true, key: resolved.name, authorization: resolved.key === null ? null : `Bearer ${resolved.key}` })
106
113
  }
107
114
  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
115
+ // `@free` 展开出来的候选是**逐请求动态**的(列表会变),所以不能用按索引的粘性指针:
116
+ // 动态链从 0 起,靠按 ref 的冷却(会话 + 免费车道全局)跳过刚失败过的项。
117
+ const start = config.chains[route.chain] && route.kind === 'chain' && !route.dynamicFree ? sessions.startIndex(sid, chainName, keys) : 0
109
118
  let ordered = staticTargets.slice(start)
110
119
  const notCooling = ordered.filter((t) => !sessions.isCooling(sid, chainName, `${t.provider.id}/${t.model}`))
111
120
  if (notCooling.length > 0) ordered = notCooling
@@ -175,7 +184,7 @@ export function buildUpstreamRequest({ attempt, inHeaders, bodyText, version = '
175
184
  /** 台账一行(**不带任何密钥**:只记 provider id、模型、键的"名字")。
176
185
  * `req` = 本次调用在本进程内的序号(把同一请求的多次尝试串起来);`n` = 第几次尝试(1 起)。 */
177
186
  export function attemptRow({ ts, chainName, agent, attempt, status, code, latencyMs, retries = 0, detail = null, req = null, n = null }) {
178
- return {
187
+ const row = {
179
188
  ts,
180
189
  req,
181
190
  n,
@@ -191,4 +200,7 @@ export function attemptRow({ ts, chainName, agent, attempt, status, code, latenc
191
200
  retries,
192
201
  detail,
193
202
  }
203
+ // 只有 `@free` 展开出来的候选才带这个标记(面板/统计据此区分"自动免费"与显式候选)
204
+ if (attempt.auto === true) row.auto = true
205
+ return row
194
206
  }
package/src/index.js CHANGED
@@ -36,7 +36,7 @@ import { SUBAGENT_ROSTER, withRoster } from './roster.js'
36
36
  import { probeGate, startGate } from './gate/gate.js'
37
37
  import { defaultFallbackConfigPath } from './gate/config.js'
38
38
  import { REEVE_TIERS, BUILTINS_TO_HIDE, mergeTierModels, pickDefaultAgent, readEndpoint, resolveGateEndpoint } from './models.js'
39
- import { STATS_DEFAULTS, aggregateGateRows, aggregateLedger, buildStats, fetchGateModels, mergeModelCandidates, readLedgerTail } from './stats.js'
39
+ import { STATS_DEFAULTS, aggregateGateRows, aggregateLedger, buildStats, fetchGateFree, fetchGateModels, mergeModelCandidates, readLedgerTail } from './stats.js'
40
40
  import { loadAgents, loadSkills } from './register.js'
41
41
  import { ensureProviderBlock, reeveProviderBlock } from './config-write.js'
42
42
 
@@ -80,6 +80,8 @@ export default {
80
80
  let statsState = null
81
81
  /** 模型候选(面板的链编辑选择器用)。必须在快照块之前声明。 */
82
82
  let modelsState = null
83
+ // 免费车道运行态(/gate/free):面板的"当前展开 / 冷却 / 发现源"读它
84
+ let freeState = null
83
85
  /** 从插件包注册进去的 agent / skill 名单(进快照,便于上机核对"没往配置目录拷文件")。 */
84
86
  let registeredAgents = []
85
87
  let registeredSkills = []
@@ -130,6 +132,7 @@ export default {
130
132
  agents: agentTable,
131
133
  roster,
132
134
  models: modelsState,
135
+ free: freeState,
133
136
  registered: { agents: registeredAgents, skills: registeredSkills },
134
137
  endpoint: endpointState,
135
138
  stats: statsState,
@@ -254,6 +257,7 @@ export default {
254
257
  },
255
258
  })
256
259
  gateState = gateHandle.snapshot()
260
+ freeState = await fetchGateFree({ port: gateHandle.port, host: gateHandle.host })
257
261
  // 顺手把模型候选也刷一遍(半小时一次;链编辑器的选择器用它)
258
262
  if (nowMs - lastModelsAt >= modelsIntervalMs) {
259
263
  const discovered = await fetchGateModels({ port: gateHandle.port, host: gateHandle.host })
package/src/snapshot.js CHANGED
@@ -92,7 +92,7 @@ export class SnapshotWriter {
92
92
  */
93
93
  export function buildSnapshot(
94
94
  records,
95
- { now = Date.now(), version = null, agents = null, roster = null, registered = null, endpoint = null, models = null, stats = null, gate = null } = {},
95
+ { now = Date.now(), version = null, agents = null, roster = null, registered = null, endpoint = null, models = null, free = null, stats = null, gate = null } = {},
96
96
  ) {
97
97
  const tasks = records
98
98
  .map((r) => ({
@@ -125,6 +125,7 @@ export function buildSnapshot(
125
125
  roster,
126
126
  gate,
127
127
  models,
128
+ free,
128
129
  registered,
129
130
  endpoint,
130
131
  stats,
package/src/stats.js CHANGED
@@ -299,6 +299,21 @@ export async function readLedgerTail({ path, limit = STATS_DEFAULTS.limit, tailB
299
299
  }
300
300
  }
301
301
 
302
+ /**
303
+ * 免费车道运行态(`/gate/free`):发现源状态、当前展开、冷却。
304
+ * 读不到不算错——面板会显示"未就绪",请求侧照常降级到付费。
305
+ */
306
+ export async function fetchGateFree({ port, host = '127.0.0.1', fetchImpl = fetch, timeoutMs = STATS_DEFAULTS.timeoutMs } = {}) {
307
+ try {
308
+ const res = await fetchImpl(`http://${host}:${port}/gate/free`, { signal: AbortSignal.timeout(timeoutMs) })
309
+ if (res.status !== 200) return { error: `HTTP ${res.status}` }
310
+ const doc = await res.json()
311
+ return doc && typeof doc === 'object' ? { ...doc, error: null } : { error: '空响应' }
312
+ } catch (err) {
313
+ return { error: String(err?.message ?? err).slice(0, 140) }
314
+ }
315
+ }
316
+
302
317
  /** GATE 自己的模型目录(`provider/model` ∪ 链名)—— 面板选择器的候选来源之一。 */
303
318
  export async function fetchGateModels({ port, host = '127.0.0.1', fetchImpl = fetch, timeoutMs = STATS_DEFAULTS.timeoutMs } = {}) {
304
319
  try {