@bd7pil/reeve 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/default-fallback.json +49 -0
- package/package.json +11 -6
- package/provision.json +29 -23
- package/src/constants.js +1 -1
- package/src/gate/classify.js +77 -0
- package/src/gate/config.js +172 -0
- package/src/gate/gate.js +446 -0
- package/src/gate/lanes.js +194 -0
- package/src/gate/state.js +129 -0
- package/src/index.js +77 -85
- package/src/models.js +11 -0
- package/src/snapshot.js +2 -24
- package/src/stats.js +142 -179
- package/default-chain.json +0 -251
- package/src/chain.js +0 -358
- package/src/proxy.js +0 -587
package/default-chain.json
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://www.getbifrost.ai/schema",
|
|
3
|
-
"//": "REEVE 代理层配置 —— 链的唯一维护处。改这个文件要重启 Bifrost;热改走 POST /api/routing/rules(需 setup token)。",
|
|
4
|
-
"//providers": "provider 全部写成 OpenAI 兼容自定义 provider(纯透传,不做任何格式翻译)。",
|
|
5
|
-
"//baseurls": "⚠️ 只写到「主机(+路径前缀)」,绝不要带 /v1 —— Bifrost 会自动在 base_url 之后追加 request_path_overrides.chat_completion(默认 /v1/chat/completions)。实测:base_url 带 /v1 会拼出 /v1/v1/chat/completions。",
|
|
6
|
-
"//path_overrides": "⚠️ 每个 provider 都要写 **两个** 键:chat_completion 与 chat_completion_stream。流式请求走后者,不设就用默认 /v1/chat/completions —— 而 opencode 永远走流式。实测踩到:只设 chat_completion 时,curl 非流式正常、引擎一跑就 405。值是「追加到 base_url 的路径」。",
|
|
7
|
-
"//keys": "API key 一律写 env.<VAR>(Bifrost 自己的语法,与 opencode 侧的 {env:VAR} 不同),由 ocrc 的实例启动行注入 —— 密钥不进仓库。",
|
|
8
|
-
"//modelids": "链上每个 model 都在 models.dev 目录里核对过存在(见 proxy/README.md 的核对清单)。",
|
|
9
|
-
"//name_collision": "⚠️ provider id 不能与 Bifrost 内置名撞车。实测:叫 deepseek 会回 'failed to get config for provider deepseek: not found',改名 deepseek-api 后正常。这里一律加 -api 之类的后缀避开。",
|
|
10
|
-
"providers": {
|
|
11
|
-
"opencode": {
|
|
12
|
-
"custom_provider_config": {
|
|
13
|
-
"base_provider_type": "openai",
|
|
14
|
-
"request_path_overrides": {
|
|
15
|
-
"chat_completion": "/v1/chat/completions",
|
|
16
|
-
"chat_completion_stream": "/v1/chat/completions"
|
|
17
|
-
}
|
|
18
|
-
},
|
|
19
|
-
"network_config": {
|
|
20
|
-
"base_url": "https://opencode.ai/zen"
|
|
21
|
-
},
|
|
22
|
-
"keys": [
|
|
23
|
-
{
|
|
24
|
-
"name": "opencode-primary",
|
|
25
|
-
"value": "env.REEVE_KEY_OPENCODE",
|
|
26
|
-
"models": [
|
|
27
|
-
"*"
|
|
28
|
-
],
|
|
29
|
-
"weight": 1
|
|
30
|
-
}
|
|
31
|
-
]
|
|
32
|
-
},
|
|
33
|
-
"zhipuai-coding-plan": {
|
|
34
|
-
"custom_provider_config": {
|
|
35
|
-
"base_provider_type": "openai",
|
|
36
|
-
"request_path_overrides": {
|
|
37
|
-
"chat_completion": "/api/coding/paas/v4/chat/completions",
|
|
38
|
-
"chat_completion_stream": "/api/coding/paas/v4/chat/completions"
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
"network_config": {
|
|
42
|
-
"base_url": "https://open.bigmodel.cn"
|
|
43
|
-
},
|
|
44
|
-
"keys": [
|
|
45
|
-
{
|
|
46
|
-
"name": "zhipuai-primary",
|
|
47
|
-
"value": "env.REEVE_KEY_ZHIPUAI_CODING_PLAN",
|
|
48
|
-
"models": [
|
|
49
|
-
"*"
|
|
50
|
-
],
|
|
51
|
-
"weight": 1
|
|
52
|
-
}
|
|
53
|
-
]
|
|
54
|
-
},
|
|
55
|
-
"xxyapi-deepseek": {
|
|
56
|
-
"custom_provider_config": {
|
|
57
|
-
"base_provider_type": "openai",
|
|
58
|
-
"request_path_overrides": {
|
|
59
|
-
"chat_completion": "/v1/chat/completions",
|
|
60
|
-
"chat_completion_stream": "/v1/chat/completions"
|
|
61
|
-
}
|
|
62
|
-
},
|
|
63
|
-
"network_config": {
|
|
64
|
-
"base_url": "https://www.xxyapi.com"
|
|
65
|
-
},
|
|
66
|
-
"keys": [
|
|
67
|
-
{
|
|
68
|
-
"name": "xxyapi-primary",
|
|
69
|
-
"value": "env.REEVE_KEY_XXYAPI_DEEPSEEK",
|
|
70
|
-
"models": [
|
|
71
|
-
"*"
|
|
72
|
-
],
|
|
73
|
-
"weight": 1
|
|
74
|
-
}
|
|
75
|
-
]
|
|
76
|
-
},
|
|
77
|
-
"lmuai": {
|
|
78
|
-
"custom_provider_config": {
|
|
79
|
-
"base_provider_type": "openai",
|
|
80
|
-
"request_path_overrides": {
|
|
81
|
-
"chat_completion": "/v1/chat/completions",
|
|
82
|
-
"chat_completion_stream": "/v1/chat/completions"
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
"network_config": {
|
|
86
|
-
"base_url": "https://api.lmuai.com"
|
|
87
|
-
},
|
|
88
|
-
"keys": [
|
|
89
|
-
{
|
|
90
|
-
"name": "lmuai-primary",
|
|
91
|
-
"value": "env.REEVE_KEY_LMUAI",
|
|
92
|
-
"models": [
|
|
93
|
-
"*"
|
|
94
|
-
],
|
|
95
|
-
"weight": 1
|
|
96
|
-
}
|
|
97
|
-
]
|
|
98
|
-
},
|
|
99
|
-
"deepseek-api": {
|
|
100
|
-
"custom_provider_config": {
|
|
101
|
-
"base_provider_type": "openai",
|
|
102
|
-
"request_path_overrides": {
|
|
103
|
-
"chat_completion": "/v1/chat/completions",
|
|
104
|
-
"chat_completion_stream": "/v1/chat/completions"
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
"network_config": {
|
|
108
|
-
"base_url": "https://api.deepseek.com"
|
|
109
|
-
},
|
|
110
|
-
"keys": [
|
|
111
|
-
{
|
|
112
|
-
"name": "deepseek-primary",
|
|
113
|
-
"value": "env.REEVE_KEY_DEEPSEEK_API",
|
|
114
|
-
"models": [
|
|
115
|
-
"*"
|
|
116
|
-
],
|
|
117
|
-
"weight": 1,
|
|
118
|
-
"fromAuth": "deepseek"
|
|
119
|
-
}
|
|
120
|
-
]
|
|
121
|
-
},
|
|
122
|
-
"xiaomi": {
|
|
123
|
-
"custom_provider_config": {
|
|
124
|
-
"base_provider_type": "openai",
|
|
125
|
-
"request_path_overrides": {
|
|
126
|
-
"chat_completion": "/v1/chat/completions",
|
|
127
|
-
"chat_completion_stream": "/v1/chat/completions"
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
"network_config": {
|
|
131
|
-
"base_url": "https://api.xiaomimimo.com"
|
|
132
|
-
},
|
|
133
|
-
"keys": [
|
|
134
|
-
{
|
|
135
|
-
"name": "xiaomi-primary",
|
|
136
|
-
"value": "env.REEVE_KEY_XIAOMI",
|
|
137
|
-
"models": [
|
|
138
|
-
"*"
|
|
139
|
-
],
|
|
140
|
-
"weight": 1
|
|
141
|
-
}
|
|
142
|
-
]
|
|
143
|
-
},
|
|
144
|
-
"alibaba-cn": {
|
|
145
|
-
"custom_provider_config": {
|
|
146
|
-
"base_provider_type": "openai",
|
|
147
|
-
"request_path_overrides": {
|
|
148
|
-
"chat_completion": "/compatible-mode/v1/chat/completions",
|
|
149
|
-
"chat_completion_stream": "/compatible-mode/v1/chat/completions"
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
"network_config": {
|
|
153
|
-
"base_url": "https://dashscope.aliyuncs.com"
|
|
154
|
-
},
|
|
155
|
-
"keys": [
|
|
156
|
-
{
|
|
157
|
-
"name": "alibaba-primary",
|
|
158
|
-
"value": "env.REEVE_KEY_ALIBABA_CN",
|
|
159
|
-
"models": [
|
|
160
|
-
"*"
|
|
161
|
-
],
|
|
162
|
-
"weight": 1
|
|
163
|
-
}
|
|
164
|
-
]
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
"//governance": "三条 routing rule = 三个档位。CEL 变量是 model(字符串),不是 request.model —— 官方文档示例是错的。",
|
|
168
|
-
"//weight": "targets 的 weight 必须合计为 1(单 target 就是 1)。",
|
|
169
|
-
"//fallbacks": "fallbacks 用 \"provider/model\";写 \"provider/\" 表示同模型换 provider。链尾要放「任何机器上都可服务」的那家,否则等于链是空的。",
|
|
170
|
-
"//ttft": "ttft_timeout_ms 放 target 上(changelog + Helm schema + DB migration 三处一致)。⚠️ 官方两份 MDX 把它写成 rule 级 —— 上机时两种都试,见 docs/PLAN.md §11 #2。",
|
|
171
|
-
"//ttft_verified": "✅ 2026-10-19 在 EL7 上实测:运行时执行的迁移叫 add_ttft_timeout_ms_column_to_routing_targets —— 列在 targets 表上,所以是 target 级。rule 级不报错只是因为未知字段被宽容忽略。",
|
|
172
|
-
"//setup_token": "⚠️ /api/routing/rules 与 /api/governance/* 需要 setup token,否则 401/403。写在 governance.setup_token,值用 env.BIFROST_SETUP_TOKEN —— 密钥不进仓库。请求头是 X-Bifrost-Setup-Token。",
|
|
173
|
-
"//virtual_key": "⚠️ Bifrost 默认 enforce_auth_on_inference=true:推理请求必须带 x-bf-vk(virtual key),否则 401 virtual_key_required。新建的 VK 默认不允许任何 provider,还要 PUT allow_all_providers=true。VK 是「引擎 → 代理」的凭据,provider 真 key 只留在本文件的 env.* 里。",
|
|
174
|
-
"governance": {
|
|
175
|
-
"setup_token": "env.BIFROST_SETUP_TOKEN",
|
|
176
|
-
"routing_rules": [
|
|
177
|
-
{
|
|
178
|
-
"id": "reeve-flash",
|
|
179
|
-
"name": "reeve/flash",
|
|
180
|
-
"description": "便宜、量大:探索 / 检索 / 照文档干活。链序承担'先试免费再付费'。",
|
|
181
|
-
"enabled": true,
|
|
182
|
-
"cel_expression": "model == \"reeve/flash\"",
|
|
183
|
-
"targets": [
|
|
184
|
-
{
|
|
185
|
-
"provider": "opencode",
|
|
186
|
-
"model": "mimo-v2.6-flash-free",
|
|
187
|
-
"weight": 1,
|
|
188
|
-
"ttft_timeout_ms": 30000
|
|
189
|
-
}
|
|
190
|
-
],
|
|
191
|
-
"fallbacks": [
|
|
192
|
-
"zhipuai-coding-plan/glm-5.3-flash",
|
|
193
|
-
"xxyapi-deepseek/deepseek-v4.1-flash",
|
|
194
|
-
"lmuai/glm-5.3-flash",
|
|
195
|
-
"deepseek-api/deepseek-flash"
|
|
196
|
-
],
|
|
197
|
-
"scope": "global",
|
|
198
|
-
"priority": 10
|
|
199
|
-
},
|
|
200
|
-
{
|
|
201
|
-
"id": "reeve-pro",
|
|
202
|
-
"name": "reeve/pro",
|
|
203
|
-
"description": "强推理:编排 / 规划 / 评审 / 审查。",
|
|
204
|
-
"enabled": true,
|
|
205
|
-
"cel_expression": "model == \"reeve/pro\"",
|
|
206
|
-
"targets": [
|
|
207
|
-
{
|
|
208
|
-
"provider": "zhipuai-coding-plan",
|
|
209
|
-
"model": "glm-5.3",
|
|
210
|
-
"weight": 1,
|
|
211
|
-
"ttft_timeout_ms": 30000
|
|
212
|
-
}
|
|
213
|
-
],
|
|
214
|
-
"fallbacks": [
|
|
215
|
-
"deepseek-api/deepseek-flash",
|
|
216
|
-
"lmuai/glm-5.3",
|
|
217
|
-
"opencode/mimo-v2.6-flash-free"
|
|
218
|
-
],
|
|
219
|
-
"scope": "global",
|
|
220
|
-
"priority": 20
|
|
221
|
-
},
|
|
222
|
-
{
|
|
223
|
-
"id": "reeve-multimodal",
|
|
224
|
-
"name": "reeve/multimodal",
|
|
225
|
-
"description": "多模态能力档:需要读图/读 PDF 的调用。",
|
|
226
|
-
"enabled": true,
|
|
227
|
-
"cel_expression": "model == \"reeve/multimodal\"",
|
|
228
|
-
"targets": [
|
|
229
|
-
{
|
|
230
|
-
"provider": "zhipuai-coding-plan",
|
|
231
|
-
"model": "glm-5.3-flash",
|
|
232
|
-
"weight": 1,
|
|
233
|
-
"ttft_timeout_ms": 30000
|
|
234
|
-
}
|
|
235
|
-
],
|
|
236
|
-
"fallbacks": [
|
|
237
|
-
"xxyapi-deepseek/deepseek-v4.1-flash",
|
|
238
|
-
"lmuai/glm-5.3-flash",
|
|
239
|
-
"deepseek-api/deepseek-flash",
|
|
240
|
-
"opencode/mimo-v2.6-flash-free"
|
|
241
|
-
],
|
|
242
|
-
"scope": "global",
|
|
243
|
-
"priority": 30
|
|
244
|
-
}
|
|
245
|
-
]
|
|
246
|
-
},
|
|
247
|
-
"//client": "⚠️ 这个块的名字是 client,不是 client_config —— 实测 client_config 被静默忽略(DB 里仍是默认 365)。log_retention_days 默认 365 天,logs.db 只增不减。",
|
|
248
|
-
"client": {
|
|
249
|
-
"log_retention_days": 30
|
|
250
|
-
}
|
|
251
|
-
}
|
package/src/chain.js
DELETED
|
@@ -1,358 +0,0 @@
|
|
|
1
|
-
// 链文件(`~/.reeve/chain.json`)↔ Bifrost 的 `governance.routing_rules` 的**唯一映射处**。
|
|
2
|
-
//
|
|
3
|
-
// 为什么需要这一层:链原本写死在链基座里(仓库 `proxy/config.json` / 插件自带的
|
|
4
|
-
// `default-chain.json`),改一次要重启 Bifrost。使用者要的是"面板里调顺序、加回退"——
|
|
5
|
-
// 那就得有一份**只属于 REEVE 的小配置**,由插件读、由面板写,派生出的规则用
|
|
6
|
-
// `/api/routing/rules` 热更(实测:热改能持久、不用重启)。
|
|
7
|
-
//
|
|
8
|
-
// 分工(别越界):
|
|
9
|
-
// · chain.json 只说三件事:每档位「先打谁」(targets)、「打不通再打谁」(fallbacks)。
|
|
10
|
-
// · provider 的 base_url / key / 路径覆盖仍然只在链基座里(那属部署,要重启)。
|
|
11
|
-
// · 派生出的规则 id 固定 `reeve-<tier>`,CEL 固定 `model == "reeve/<tier>"`。
|
|
12
|
-
//
|
|
13
|
-
// 纯函数 + 注入式 IO:全部可在单测里跑(`plugin/test/chain.mjs`)。
|
|
14
|
-
|
|
15
|
-
import { readFile, stat } from 'node:fs/promises'
|
|
16
|
-
import { homedir } from 'node:os'
|
|
17
|
-
import { join } from 'node:path'
|
|
18
|
-
|
|
19
|
-
/** 三个档位(顺序即面板里的展示顺序,也是 priority 10/20/30 的由来)。 */
|
|
20
|
-
export const TIER_ORDER = Object.freeze(['flash', 'pro', 'multimodal'])
|
|
21
|
-
|
|
22
|
-
export const TIER_PRIORITY = Object.freeze({ flash: 10, pro: 20, multimodal: 30 })
|
|
23
|
-
|
|
24
|
-
/** target 的 TTFT 上限(实测列在 targets 上,不是 rule 上)。 */
|
|
25
|
-
export const DEFAULT_TTFT_MS = 30_000
|
|
26
|
-
|
|
27
|
-
export const CHAIN_VERSION = 1
|
|
28
|
-
|
|
29
|
-
/** 与运行时规则做"是否有差异"比对时要看的字段(服务端字段如 config_hash 不算)。 */
|
|
30
|
-
export const RULE_FIELDS = Object.freeze([
|
|
31
|
-
'name',
|
|
32
|
-
'cel_expression',
|
|
33
|
-
'enabled',
|
|
34
|
-
'targets',
|
|
35
|
-
'fallbacks',
|
|
36
|
-
'priority',
|
|
37
|
-
'scope',
|
|
38
|
-
'description',
|
|
39
|
-
])
|
|
40
|
-
|
|
41
|
-
export const TIER_DESC = Object.freeze({
|
|
42
|
-
flash: '便宜、量大:探索 / 检索 / 照文档干活。',
|
|
43
|
-
pro: '强推理:编排 / 规划 / 评审 / 审查。',
|
|
44
|
-
multimodal: '多模态能力档:需要读图 / 读 PDF 的调用。',
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
export function defaultChainPath({ home = homedir() } = {}) {
|
|
48
|
-
return join(home, '.reeve', 'chain.json')
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function tierRuleId(tier) {
|
|
52
|
-
return `reeve-${tier}`
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** 去掉整行 `//` 注释(链文件与仓库里的 proxy/config.json 同风格:注释只可能是整行)。 */
|
|
56
|
-
export function stripLineComments(text) {
|
|
57
|
-
return String(text)
|
|
58
|
-
.split('\n')
|
|
59
|
-
.filter((l) => !l.trim().startsWith('//'))
|
|
60
|
-
.join('\n')
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function parseChainText(text) {
|
|
64
|
-
return JSON.parse(stripLineComments(text))
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* `"provider/model"` → `{provider, model}`;`"provider/"`→ `{provider, model:null}`
|
|
69
|
-
* (链基座的约定:同模型换 provider)。认不出就返回 null(由调用方记问题)。
|
|
70
|
-
*/
|
|
71
|
-
export function parseTierRef(ref) {
|
|
72
|
-
if (ref && typeof ref === 'object') {
|
|
73
|
-
const provider = typeof ref.provider === 'string' ? ref.provider.trim() : ''
|
|
74
|
-
const model = typeof ref.model === 'string' ? ref.model.trim() : ''
|
|
75
|
-
return provider ? { provider, model: model || null } : null
|
|
76
|
-
}
|
|
77
|
-
if (typeof ref !== 'string') return null
|
|
78
|
-
const s = ref.trim()
|
|
79
|
-
const i = s.indexOf('/')
|
|
80
|
-
if (i <= 0) return null
|
|
81
|
-
const provider = s.slice(0, i).trim()
|
|
82
|
-
const model = s.slice(i + 1).trim()
|
|
83
|
-
return provider ? { provider, model: model || null } : null
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function formatTierRef({ provider, model }) {
|
|
87
|
-
return `${provider}/${model ?? ''}`
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* 把链文件里的 `tiers` 收拾成规范形(面板写来的、人手写的都过这一关)。
|
|
92
|
-
* 返回 `{ tiers, problems }` —— problems 非空时**也**返回能用的部分(如实报,不静默丢)。
|
|
93
|
-
*/
|
|
94
|
-
export function normalizeTiers(raw) {
|
|
95
|
-
const problems = []
|
|
96
|
-
const tiers = {}
|
|
97
|
-
if (raw == null || typeof raw !== 'object') return { tiers, problems }
|
|
98
|
-
for (const tier of TIER_ORDER) {
|
|
99
|
-
const spec = raw?.[tier]
|
|
100
|
-
if (spec == null) continue
|
|
101
|
-
if (typeof spec !== 'object') {
|
|
102
|
-
problems.push(`${tier}: 不是对象`)
|
|
103
|
-
continue
|
|
104
|
-
}
|
|
105
|
-
const targets = []
|
|
106
|
-
for (const t of Array.isArray(spec.targets) ? spec.targets : []) {
|
|
107
|
-
const parsed = parseTierRef(t)
|
|
108
|
-
if (!parsed || !parsed.model) problems.push(`${tier}: 认不出的 target ${JSON.stringify(t)}`)
|
|
109
|
-
else targets.push(parsed)
|
|
110
|
-
}
|
|
111
|
-
const fallbacks = []
|
|
112
|
-
for (const f of Array.isArray(spec.fallbacks) ? spec.fallbacks : []) {
|
|
113
|
-
const parsed = parseTierRef(f)
|
|
114
|
-
if (!parsed) problems.push(`${tier}: 认不出的 fallback ${JSON.stringify(f)}`)
|
|
115
|
-
else fallbacks.push(parsed.model ? formatTierRef(parsed) : `${parsed.provider}/`)
|
|
116
|
-
}
|
|
117
|
-
if (targets.length === 0 && fallbacks.length > 0) {
|
|
118
|
-
// 只有回退、没有首选 —— 等于把第一个回退提到首选(比"整档消失"更接近人的意图)
|
|
119
|
-
const first = parseTierRef(fallbacks.shift())
|
|
120
|
-
if (first) targets.push(first)
|
|
121
|
-
problems.push(`${tier}: 没写 targets,把第一个 fallback 提为首选`)
|
|
122
|
-
}
|
|
123
|
-
if (targets.length === 0) {
|
|
124
|
-
problems.push(`${tier}: 既没有 targets 也没有 fallbacks,跳过`)
|
|
125
|
-
continue
|
|
126
|
-
}
|
|
127
|
-
tiers[tier] = {
|
|
128
|
-
targets,
|
|
129
|
-
fallbacks,
|
|
130
|
-
...(typeof spec.description === 'string' ? { description: spec.description } : {}),
|
|
131
|
-
...(Number.isFinite(spec.ttftMs) ? { ttftMs: Number(spec.ttftMs) } : {}),
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return { tiers, problems }
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* 一个档位 → 一条 routing rule。
|
|
139
|
-
* `base` 是链基座里同 id 的规则:链文件没写 description / ttftMs 时从它继承
|
|
140
|
-
* (免得每次热更都把人工写好的中文说明冲掉)。
|
|
141
|
-
*/
|
|
142
|
-
export function tierToRule(tier, spec, { base = null, priority = null } = {}) {
|
|
143
|
-
const targets = (spec?.targets ?? []).map((t) => ({
|
|
144
|
-
provider: t.provider,
|
|
145
|
-
model: t.model,
|
|
146
|
-
weight: 1,
|
|
147
|
-
ttft_timeout_ms: Number(spec?.ttftMs ?? base?.targets?.[0]?.ttft_timeout_ms ?? DEFAULT_TTFT_MS),
|
|
148
|
-
}))
|
|
149
|
-
// weight 必须合计为 1(多 target 时均分)
|
|
150
|
-
if (targets.length > 1) for (const t of targets) t.weight = 1 / targets.length
|
|
151
|
-
return {
|
|
152
|
-
id: tierRuleId(tier),
|
|
153
|
-
name: `reeve/${tier}`,
|
|
154
|
-
description: spec?.description ?? base?.description ?? TIER_DESC[tier] ?? '',
|
|
155
|
-
enabled: spec?.enabled ?? base?.enabled ?? true,
|
|
156
|
-
cel_expression: `model == "reeve/${tier}"`,
|
|
157
|
-
targets,
|
|
158
|
-
fallbacks: [...(spec?.fallbacks ?? [])],
|
|
159
|
-
scope: spec?.scope ?? base?.scope ?? 'global',
|
|
160
|
-
priority: Number(priority ?? spec?.priority ?? TIER_PRIORITY[tier] ?? base?.priority ?? 100),
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** 三个档位 → 规则数组(按 priority 升序)。 */
|
|
165
|
-
export function tiersToRoutingRules(tiers) {
|
|
166
|
-
const out = []
|
|
167
|
-
for (const tier of TIER_ORDER) {
|
|
168
|
-
if (!tiers?.[tier]) continue
|
|
169
|
-
out.push(tierToRule(tier, tiers[tier]))
|
|
170
|
-
}
|
|
171
|
-
return out.sort((a, b) => a.priority - b.priority)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* 把档位合到链基座上:**providers 等一切照旧,只替换 `reeve-*` 那几条规则**。
|
|
176
|
-
* tiers 为空(链文件缺失/坏掉)时原样返回 —— 这就是"回落到现状"。
|
|
177
|
-
*/
|
|
178
|
-
export function applyTiers(chain, tiers) {
|
|
179
|
-
const baseRules = chain?.governance?.routing_rules ?? []
|
|
180
|
-
const derived = []
|
|
181
|
-
for (const tier of TIER_ORDER) {
|
|
182
|
-
if (!tiers?.[tier]) continue
|
|
183
|
-
const id = tierRuleId(tier)
|
|
184
|
-
derived.push(tierToRule(tier, tiers[tier], { base: baseRules.find((r) => r?.id === id) ?? null }))
|
|
185
|
-
}
|
|
186
|
-
if (derived.length === 0) return chain ?? {}
|
|
187
|
-
const ids = new Set(derived.map((r) => r.id))
|
|
188
|
-
const kept = baseRules.filter((r) => r?.id && !ids.has(r.id))
|
|
189
|
-
const governance = { ...(chain?.governance ?? {}) }
|
|
190
|
-
governance.routing_rules = [...kept, ...derived].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
|
|
191
|
-
return { ...(chain ?? {}), governance }
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* 反向:从链基座(或运行时规则)反推档位 —— 用在"链文件还没建"时给面板/快照一个起点。
|
|
196
|
-
* @param {Array<{id?:string,name?:string,targets?:any[],fallbacks?:any[]}>} rules
|
|
197
|
-
*/
|
|
198
|
-
export function tiersFromRules(rules) {
|
|
199
|
-
const tiers = {}
|
|
200
|
-
for (const tier of TIER_ORDER) {
|
|
201
|
-
const id = tierRuleId(tier)
|
|
202
|
-
const rule = (rules ?? []).find((r) => r?.id === id || r?.name === `reeve/${tier}`)
|
|
203
|
-
if (!rule) continue
|
|
204
|
-
const targets = (rule.targets ?? [])
|
|
205
|
-
.map((t) => parseTierRef(t))
|
|
206
|
-
.filter((t) => t && t.model)
|
|
207
|
-
if (targets.length === 0) continue
|
|
208
|
-
const fallbacks = (rule.fallbacks ?? [])
|
|
209
|
-
.map((f) => parseTierRef(f))
|
|
210
|
-
.filter(Boolean)
|
|
211
|
-
.map((f) => (f.model ? formatTierRef(f) : `${f.provider}/`))
|
|
212
|
-
tiers[tier] = {
|
|
213
|
-
targets,
|
|
214
|
-
fallbacks,
|
|
215
|
-
...(rule.description ? { description: rule.description } : {}),
|
|
216
|
-
...(rule.targets?.[0]?.ttft_timeout_ms ? { ttftMs: Number(rule.targets[0].ttft_timeout_ms) } : {}),
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return tiers
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export function tiersFromChainDoc(chain) {
|
|
223
|
-
return tiersFromRules(chain?.governance?.routing_rules)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** 排序后的规范 JSON —— 用于"有没有差异"的比对(键序不同不算差异)。 */
|
|
227
|
-
export function canonicalJson(value) {
|
|
228
|
-
const walk = (v) => {
|
|
229
|
-
if (Array.isArray(v)) return v.map(walk)
|
|
230
|
-
if (v && typeof v === 'object') {
|
|
231
|
-
const out = {}
|
|
232
|
-
for (const k of Object.keys(v).sort()) out[k] = walk(v[k])
|
|
233
|
-
return out
|
|
234
|
-
}
|
|
235
|
-
return v
|
|
236
|
-
}
|
|
237
|
-
return JSON.stringify(walk(value))
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** 两条规则在关心字段上是否一致(键序无关)。 */
|
|
241
|
-
export function rulesSame(a, b) {
|
|
242
|
-
return RULE_FIELDS.every((k) => canonicalJson(a?.[k]) === canonicalJson(b?.[k]))
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* 期望规则 vs 运行时规则 → 差异(收敛与快照共用一份判断)。
|
|
247
|
-
* @returns {{drifted: boolean, missing: string[], changed: string[], extra: string[]}}
|
|
248
|
-
*/
|
|
249
|
-
export function rulesDrift(desired, runtime) {
|
|
250
|
-
const want = new Map((desired ?? []).filter((r) => r?.id).map((r) => [r.id, r]))
|
|
251
|
-
const have = new Map((runtime ?? []).filter((r) => r?.id).map((r) => [r.id, r]))
|
|
252
|
-
const missing = []
|
|
253
|
-
const changed = []
|
|
254
|
-
const extra = []
|
|
255
|
-
for (const [id, rule] of want) {
|
|
256
|
-
const got = have.get(id)
|
|
257
|
-
if (!got) missing.push(id)
|
|
258
|
-
else if (!rulesSame(got, rule)) changed.push(id)
|
|
259
|
-
}
|
|
260
|
-
for (const id of have.keys()) if (!want.has(id)) extra.push(id)
|
|
261
|
-
return { drifted: missing.length + changed.length + extra.length > 0, missing, changed, extra }
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* 读链文件。三种结果都要能区分(面板会显示"没有链文件=用基座"):
|
|
266
|
-
* · 不存在 → { exists:false, doc:null, tiers:null }
|
|
267
|
-
* · 存在但读不动/坏 → { exists:true, doc:null, error:'…' }(视为"未变更",不炸)
|
|
268
|
-
* · 存在且能读 → { exists:true, doc, tiers, problems, marker }
|
|
269
|
-
* `marker` 是变更检测的记号(mtime+size)。**先 stat 再读**:万一读的这一刻文件正在被换
|
|
270
|
-
* (面板写是 temp+rename),记号会偏旧 → 下一个 tick 再收敛一遍(幂等,无害);
|
|
271
|
-
* 反过来(记号偏新)就会漏掉一次变更,那才是真 bug。
|
|
272
|
-
*/
|
|
273
|
-
export async function readChainFile({ path, readFileImpl = readFile, statImpl = stat } = {}) {
|
|
274
|
-
let st = null
|
|
275
|
-
try {
|
|
276
|
-
st = await statImpl(path)
|
|
277
|
-
} catch {
|
|
278
|
-
return { exists: false, path, doc: null, tiers: null, problems: [], marker: null, mtimeMs: null, error: null }
|
|
279
|
-
}
|
|
280
|
-
const marker = `${Number(st.mtimeMs)}:${Number(st.size)}`
|
|
281
|
-
const base = { exists: true, path, marker, mtimeMs: Number(st.mtimeMs) }
|
|
282
|
-
try {
|
|
283
|
-
const text = await readFileImpl(path, 'utf8')
|
|
284
|
-
const doc = parseChainText(text)
|
|
285
|
-
const { tiers, problems } = normalizeTiers(doc?.tiers)
|
|
286
|
-
return { ...base, doc, tiers, problems, error: null }
|
|
287
|
-
} catch (err) {
|
|
288
|
-
return { ...base, doc: null, tiers: null, problems: [], error: String(err?.message ?? err).slice(0, 160) }
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** 面板要写的形状(插件侧只用来生成"初始链文件"的建议内容,不自动写)。 */
|
|
293
|
-
export function buildChainDoc(tiers, { now = Date.now(), version = CHAIN_VERSION } = {}) {
|
|
294
|
-
const out = {}
|
|
295
|
-
for (const tier of TIER_ORDER) {
|
|
296
|
-
const spec = tiers?.[tier]
|
|
297
|
-
if (!spec) continue
|
|
298
|
-
out[tier] = {
|
|
299
|
-
targets: (spec.targets ?? []).map((t) => ({ provider: t.provider, model: t.model })),
|
|
300
|
-
fallbacks: [...(spec.fallbacks ?? [])],
|
|
301
|
-
...(spec.description ? { description: spec.description } : {}),
|
|
302
|
-
...(Number.isFinite(spec.ttftMs) ? { ttftMs: Number(spec.ttftMs) } : {}),
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
return { version, updatedAt: now, tiers: out }
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* 链文件 + 链基座 → **实际该用哪份规则**。四种情形,一个有意的区分:
|
|
310
|
-
*
|
|
311
|
-
* ① 没有链文件 → 用链基座(首次安装的常态,也是"回落现状")
|
|
312
|
-
* ② 链文件好且有三档 → 用它派生(只换 `reeve-*` 三条,providers 来自基座)
|
|
313
|
-
* ③ 链文件坏(JSON 解析不了 / → **当作"未变更"**:留上一份好的(previous),
|
|
314
|
-
* 声明了档位但一条都没活下来) 不要因为一次手滑把线上路由翻回基座
|
|
315
|
-
* ④ 链文件好但没有档位 → 用链基座(`tiers: {}` 是"恢复默认"的合法写法)
|
|
316
|
-
*
|
|
317
|
-
* `effective === null` 只在 ③ 出现 —— 调用方必须"什么都不改",而不是拿 null 当"用基座"。
|
|
318
|
-
*
|
|
319
|
-
* @param {{base: object, from: string, file: object, previous?: object|null}} args
|
|
320
|
-
* @returns {{effective: object|null, tiers: object, source: string, derived: boolean, stale: boolean, problems: string[], error: string|null}}
|
|
321
|
-
*/
|
|
322
|
-
export function resolveChain({ base, from, file, previous = null }) {
|
|
323
|
-
const problems = [...(file?.problems ?? [])]
|
|
324
|
-
const error = file?.error ?? null
|
|
325
|
-
const exists = file?.exists === true
|
|
326
|
-
const parsed = exists && file?.doc != null
|
|
327
|
-
const usable = parsed && Object.keys(file?.tiers ?? {}).length > 0
|
|
328
|
-
const declaredTiers = parsed ? Object.keys(file?.doc?.tiers ?? {}).length : 0
|
|
329
|
-
|
|
330
|
-
if (!exists) {
|
|
331
|
-
return { effective: base, tiers: tiersFromChainDoc(base), source: from, derived: false, stale: false, problems, error: null }
|
|
332
|
-
}
|
|
333
|
-
if (usable) {
|
|
334
|
-
return {
|
|
335
|
-
effective: applyTiers(base, file.tiers),
|
|
336
|
-
tiers: file.tiers,
|
|
337
|
-
source: file.path,
|
|
338
|
-
derived: true,
|
|
339
|
-
stale: false,
|
|
340
|
-
problems,
|
|
341
|
-
error: null,
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
if (!parsed || declaredTiers > 0) {
|
|
345
|
-
// 坏文件 → 当作未变更
|
|
346
|
-
return {
|
|
347
|
-
effective: null,
|
|
348
|
-
tiers: previous?.tiers ?? tiersFromChainDoc(base),
|
|
349
|
-
source: previous?.source ?? from,
|
|
350
|
-
derived: previous?.derived === true,
|
|
351
|
-
stale: true,
|
|
352
|
-
problems,
|
|
353
|
-
error: error ?? '链文件里的档位一条都没解析出来',
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
// 好文件、明确没有档位 → 回落到链基座
|
|
357
|
-
return { effective: base, tiers: tiersFromChainDoc(base), source: from, derived: false, stale: false, problems, error: null }
|
|
358
|
-
}
|