@bd7pil/reeve 0.1.5 → 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.5",
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.5",
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.5",
18
+ "spec": "@bd7pil/reeve@0.1.7",
19
19
  "kind": "package",
20
20
  "entry": "~/.ocrc/components/reeve/node_modules/@bd7pil/reeve",
21
21
  "requiresPeer": []
package/src/constants.js CHANGED
@@ -1,10 +1,24 @@
1
1
  // REEVE 记账层的常量与状态机定义。
2
2
  // 施工图:docs/research/opencode-v2-plugin-api.md
3
3
 
4
+ import { readFileSync } from 'node:fs'
5
+
4
6
  export const PLUGIN_ID = 'reeve'
5
7
 
6
- /** 与 package.json 的 version 保持一致;快照文件里会带上,便于面板判断版本。 */
7
- export const PLUGIN_VERSION = '0.1.4'
8
+ /**
9
+ * 插件版本(快照文件里会带上,便于面板/台账判断"跑的是哪一版")。
10
+ *
11
+ * **从 package.json 读**,不再手抄一个常量:手抄的那种"保持一致"是句祝愿 ——
12
+ * 0.1.5 发布时就漂过(package.json 0.1.5、常量还写 0.1.4,两端快照都报了旧版本)。
13
+ * 读不到就退回 '0.0.0'(只影响一个字符串,别让插件起不来)。
14
+ */
15
+ export const PLUGIN_VERSION = (() => {
16
+ try {
17
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? '0.0.0'
18
+ } catch {
19
+ return '0.0.0'
20
+ }
21
+ })()
8
22
 
9
23
  /** 工具注册的命名空间。注意:真正决定"模型能否看见"的是 codemode:false(施工图 §3.1)。 */
10
24
  export const TOOL_NAMESPACE = 'reeve'
@@ -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'
@@ -90,8 +92,9 @@ const sendJson = (res, status, obj) => {
90
92
  }
91
93
 
92
94
  /**
93
- * 拉起 GATE。返回 handle:`{ port, dispose, snapshot, reload, sessions, ledgerPath, configPath }`。
94
- * 任何"起不来"的失败都**抛**(它是引擎启动路径上的东西,吵比静默好);运行期失败只记日志。
95
+ * 拉起 GATE。返回 handle:`{ port, detach, dispose, snapshot, reload, sessions, ledgerPath, configPath }`。
96
+ * 生命周期:`detach()` = 插件实例卸载但 GATE 继续服务(引擎级,活到进程退出);`dispose()` = 真关
97
+ * (测试与显式停止用)。任何"起不来"的失败都**抛**(它是引擎启动路径上的东西,吵比静默好);运行期失败只记日志。
95
98
  */
96
99
  export async function startGate({
97
100
  port,
@@ -142,6 +145,10 @@ export async function startGate({
142
145
  return { key: auth[id].key, name: `auth:${id}` }
143
146
  }
144
147
 
148
+ // 免费车道:发现(公开模型列表/静态白名单)+ `@free` 展开 + 按 ref 的全局冷却。
149
+ // 读的是**当前** config(热更即时生效);时间/IO 注入,测试零出网。
150
+ const freeLane = createFreeLane({ getConfig: () => config, getAuth: () => auth, now, fetchImpl, logger })
151
+
145
152
  const note = (row) => ledger.write(row)
146
153
 
147
154
  /** 模型目录:声明 ∪ 运行时池(TTL)∪ 链名。池拉不到就降级(只回声明),不报错。 */
@@ -172,6 +179,12 @@ export async function startGate({
172
179
  logger?.debug?.(`[gate] 拉 ${provider.id} 的模型池失败(降级):${String(err).slice(0, 120)}`)
173
180
  }
174
181
  }
182
+ // 免费车道发现到的模型也进目录(面板的选择器才能看到并固定它们;`@free` 本身不需要进)
183
+ try {
184
+ for (const c of await freeLane.discovered()) push(c.ref, c.provider)
185
+ } catch {
186
+ /* 发现失败只影响目录的完整性,不影响请求 */
187
+ }
175
188
  for (const name of Object.keys(config.chains)) push(name, 'reeve')
176
189
  modelsCache.data = data
177
190
  modelsCache.at = now()
@@ -199,7 +212,9 @@ export async function startGate({
199
212
  const sessionID = typeof req.headers['x-opencode-session-id'] === 'string' ? req.headers['x-opencode-session-id'] : null
200
213
  const route = routeFor({ model, sessionID }, { config, sessions })
201
214
  if (!route) return sendJson(res, 404, { error: { message: `未知模型或链:${model}` } })
202
- 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 })
203
218
 
204
219
  if (plan.ordered.length === 0) {
205
220
  return sendJson(res, 502, {
@@ -246,12 +261,14 @@ export async function startGate({
246
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 }))
247
262
  attemptsSeen.push({ provider: attempt.provider.id, model: attempt.model, code: null, reason: 'network' })
248
263
  sessions.noteFailure(sessionID ?? '(anon)', plan.chainName, attemptKey(attempt))
264
+ freeLane.noteAttempt(attempt, false)
249
265
  continue
250
266
  }
251
267
 
252
268
  if (upstream.ok) {
253
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 }))
254
270
  sessions.noteSuccess(sessionID ?? '(anon)', plan.chainName, attemptKey(attempt), plan.keys)
271
+ freeLane.noteAttempt(attempt, true)
255
272
  res.writeHead(upstream.status, {
256
273
  'content-type': upstream.headers?.get?.('content-type') ?? 'application/json',
257
274
  'x-reeve-fallback': `${i + 1}/${attempt.provider.id}/${attempt.model}`,
@@ -275,6 +292,7 @@ export async function startGate({
275
292
  return
276
293
  }
277
294
  sessions.noteFailure(sessionID ?? '(anon)', plan.chainName, attemptKey(attempt))
295
+ freeLane.noteAttempt(attempt, false)
278
296
  }
279
297
 
280
298
  sendJson(res, 502, { error: { message: '所有候选都失败', chain: plan.chainName, req: reqId, attempts: attemptsSeen } })
@@ -305,6 +323,19 @@ export async function startGate({
305
323
  agents: { ...config.agents },
306
324
  chain_for_model: { ...config.chain_for_model },
307
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
+ },
308
339
  problems,
309
340
  sessions: sessions.snapshot(),
310
341
  ledger: ledger.path,
@@ -312,6 +343,9 @@ export async function startGate({
312
343
  }
313
344
  if (url.startsWith('/gate/')) {
314
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
+ }
315
349
  if (req.method === 'POST' && url === '/gate/session') {
316
350
  const bodyText = await readBody(req, 1024 * 1024).catch(() => '{}')
317
351
  const { sessionID, agent } = JSON.parse(bodyText || '{}')
@@ -327,7 +361,10 @@ export async function startGate({
327
361
  const agent = sessions.agentOf(sessionID)
328
362
  const chainName = agent && config.agents[agent] ? config.agents[agent] : config.default_chain
329
363
  if (!chainName || !config.chains[chainName]) return sendJson(res, 200, { model: null, chain: null })
330
- 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}`)
331
368
  const out = sessions.advance(sessionID, chainName, keys, { dedupeKey })
332
369
  return sendJson(res, 200, { model: out.model, chain: chainName, index: out.index })
333
370
  }
@@ -350,6 +387,9 @@ export async function startGate({
350
387
  resolve()
351
388
  })
352
389
  })
390
+ // 引擎级服务:监听口**不**替引擎续命。引擎是长活进程,自己有事吊着;而 CLI 那种一次性
391
+ // 进程卸载插件后必须能立刻退出 —— 少了这一行,卸载后还挂着一个 ref 着的监听 handle。
392
+ server.unref?.()
353
393
 
354
394
  const watch = watchGateConfig({
355
395
  path: configPath,
@@ -364,13 +404,17 @@ export async function startGate({
364
404
  problems = [...(next.problems ?? [])]
365
405
  modelsCache.data = null
366
406
  logger?.debug?.(`[gate] 配置热更:${next.from}`)
407
+ freeLane.warm()
367
408
  } else {
368
409
  logger?.debug?.(`[gate] 配置读不动,保留上一份:${next.error ?? '?'}`)
369
410
  }
370
411
  },
371
412
  })
413
+ // 免费车道后台预热(不阻塞启动;失败只影响免费候选,请求照常降级到付费)
414
+ freeLane.warm()
372
415
 
373
416
  const disposeFns = [() => watch.stop(), () => ledger.flush(), () => new Promise((r) => server.close(() => r()))]
417
+ let sweep = null
374
418
  return {
375
419
  port: server.address()?.port ?? port,
376
420
  host,
@@ -394,11 +438,52 @@ export async function startGate({
394
438
  agents: { ...config.agents },
395
439
  chain_for_model: { ...config.chain_for_model },
396
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
+ },
397
452
  ledger: ledger.path,
398
453
  problems,
399
454
  sessions: sessions.snapshot(),
400
455
  }),
456
+ /**
457
+ * 插件实例卸载时调用(引擎按工作区加载插件:工作区被闲置驱逐 = 这个实例卸载,
458
+ * **不是**引擎退出)。监听口与配置热更留着 —— 否则"起 GATE 的那个工作区"一被驱逐,
459
+ * 其它还活跃的工作区就永久拿不到代理(拆 0.1.7 前的实测)。这里只做两件事:
460
+ * ① 把已经空闲的 keep-alive 连接清掉;② 起一个 unref 的低频清扫,兜住"卸载后才变空闲"的那些
461
+ * (每条连接的生命周期里只有很短一段是 busy,第一条命令常赶不上);
462
+ * 定时器全是 unref 的,且 2s 后自停 —— 既不拖进程退出,也不长期干扰连接复用。
463
+ */
464
+ detach() {
465
+ const closeIdle = () => {
466
+ try {
467
+ server.closeIdleConnections?.()
468
+ } catch {
469
+ /* 尽力而为 */
470
+ }
471
+ }
472
+ closeIdle()
473
+ if (sweep) return
474
+ sweep = setInterval(closeIdle, 250)
475
+ sweep.unref?.()
476
+ const stop = setTimeout(() => {
477
+ if (sweep) clearInterval(sweep)
478
+ sweep = null
479
+ }, 2000)
480
+ stop.unref?.()
481
+ },
401
482
  async dispose() {
483
+ if (sweep) {
484
+ clearInterval(sweep)
485
+ sweep = null
486
+ }
402
487
  for (const f of disposeFns.reverse()) {
403
488
  try {
404
489
  await f()
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,
@@ -164,7 +167,9 @@ export default {
164
167
  // **复用优先**:引擎是**按工作区**加载插件的 —— 同一个引擎里可能有多个插件实例(每个工作区一个)。
165
168
  // 第一个实例把 GATE 拉到端点口上,后面的实例**用健康检查接上**(`external: true`),
166
169
  // 而不是去抢同一个口(抢必失败 → 快照里会出现假的"GATE 未就绪",面板跟着误报)。
167
- // 自己起的才自己关:接上别人的实例在卸载时**不动**那台 GATE。
170
+ // 卸载语义:**都不在卸载时关端口** —— GATE 是引擎级服务,插件实例(工作区)被闲置驱逐
171
+ // 并不意味着引擎退出。以前"自己起的自己关"会让起服务的工作区被驱逐后,其它活跃工作区的
172
+ // 请求永久拿不到代理(2026-10-22 两台机器实测)。`detach()` 只清空闲连接,不阻塞进程退出。
168
173
  let gateHandle = null
169
174
  if (options.gate !== false && options.gate?.enabled !== false) {
170
175
  const endpoint = await readEndpoint({})
@@ -252,6 +257,7 @@ export default {
252
257
  },
253
258
  })
254
259
  gateState = gateHandle.snapshot()
260
+ freeState = await fetchGateFree({ port: gateHandle.port, host: gateHandle.host })
255
261
  // 顺手把模型候选也刷一遍(半小时一次;链编辑器的选择器用它)
256
262
  if (nowMs - lastModelsAt >= modelsIntervalMs) {
257
263
  const discovered = await fetchGateModels({ port: gateHandle.port, host: gateHandle.host })
@@ -563,7 +569,9 @@ export default {
563
569
  snapshot.cancel()
564
570
  void snapshot.flush().catch(() => {})
565
571
  }
566
- if (gateHandle) void gateHandle.dispose().catch(() => {})
572
+ // GATE 是引擎级服务:卸载只"松手"(清空闲连接、不关监听口)。真正关端口只在进程退出
573
+ // 或测试里显式 dispose;`detach` 的定时器都是 unref 的,CLI 一次性进程照样能立刻退出。
574
+ if (gateHandle) gateHandle.detach?.()
567
575
  logger.debug('[reeve] 已卸载')
568
576
  }
569
577
  },
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 {