@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.
- package/default-fallback.json +16 -4
- package/package.json +2 -2
- package/provision.json +2 -2
- package/src/constants.js +16 -2
- package/src/gate/config.js +57 -1
- package/src/gate/free.js +271 -0
- package/src/gate/gate.js +90 -5
- package/src/gate/lanes.js +17 -5
- package/src/index.js +11 -3
- package/src/snapshot.js +2 -1
- package/src/stats.js +15 -0
package/default-fallback.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"//": "GATE 的兜底配置(机器上没有任何 reeve-fallback.jsonc
|
|
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
7
|
-
|
|
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'
|
package/src/gate/config.js
CHANGED
|
@@ -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
|
/**
|
package/src/gate/free.js
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 {
|