mslxdff 0.1.43 → 0.1.45

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.
@@ -0,0 +1,185 @@
1
+ # mslxdff 插件开发指南
2
+
3
+ mslxdff 内置一个零依赖的插件系统:把符合约定的 `.mjs` 模块放进插件目录,daemon 启动时自动加载,在**请求链路的所有关键节点**(hook 点)调用你的代码——包括替换上游 provider 本身。**插件出错只记日志,绝不影响主链路。**
4
+
5
+ ## 快速开始
6
+
7
+ ### 1. 插件目录(双目录,都会被加载)
8
+
9
+ ```
10
+ 官方插件: <mslxdff安装目录>/plugins/ ← 随包分发,auto-update 一起更新
11
+ 用户插件: ~/.config/mslxdff/plugins/ ← 你自己的正式插件,升级永不丢
12
+ 完全接管: 环境变量 MSLXDFF_PLUGINS_DIR=/path/to/dir(只扫这一个)
13
+ ```
14
+
15
+ 优先级:同名文件时**用户目录覆盖官方目录**;加载顺序官方在前、用户在后。
16
+
17
+ > 为什么不直接放安装目录?npm 升级会重置包内文件——所以自己的正式插件务必放用户目录。
18
+
19
+ ### 2. 写一个最小插件
20
+
21
+ 创建 `~/.config/mslxdff/plugins/hello.mjs`:
22
+
23
+ ```js
24
+ export default {
25
+ name: "hello",
26
+ version: "1.0.0",
27
+ description: "我的第一个 mslxdff 插件",
28
+ hooks: {
29
+ "server:start": (ctx) => {
30
+ console.log(`[hello] mslxdff 已启动 port=${ctx.port}`);
31
+ },
32
+ },
33
+ };
34
+ ```
35
+
36
+ ### 3. 查看是否被识别
37
+
38
+ ```bash
39
+ mslxdff -plugins
40
+ # plugins dir: C:\Users\you\.config\mslxdff\plugins
41
+ # hello@1.0.0 [server:start]
42
+ # 我的第一个 mslxdff 插件
43
+ ```
44
+
45
+ 重启 daemon(`mslxdff -stop && mslxdff`)后生效。日志里会出现 `plugins loaded (1): hello@1.0.0`。
46
+
47
+ ## Hook 全表
48
+
49
+ ### 请求链路(按触发顺序)
50
+
51
+ | Hook | 触发时机 | ctx 内容 | 返回值语义 |
52
+ |---|---|---|---|
53
+ | `request:received` | 读到请求 body 后 | `{ ip, hops, headers, body }` | 返回 `{ respond: { status, body } }` **可短路请求**,直接响应客户端 |
54
+ | `model:select` | 候选顺序确定后 | `{ reqId, requested, useAuto, order, hops, stream }` | 返回数组**替换候选顺序** |
55
+ | `model:beforeTry` | 每个模型尝试前(循环内) | `{ reqId, requested, model, idx, hops }` | 返回 `false` 或 `{ skip: true }` **跳过该候选** |
56
+ | `upstream:request` | 发往上游前 | `{ reqId, requested, model, payload, stream }` | 返回 `{ payload }` **替换本次上游负载**(含 model 字段) |
57
+ | `upstream:response` | 上游响应/错误后 | `{ reqId, requested, model, status, ok, error, timing }` | 只观察 |
58
+ | `relay:first-chunk` | 流式首块到达 | `{ reqId, requested, model, via, ttfMs }` | 只观察 |
59
+ | `request:completed` | 请求结束(所有出口) | `{ reqId, requested, via, status, actual, durationMs, fallback?, interrupted?, error? }` | 只观察 |
60
+
61
+ > `x-mslxdff-model-lock` 锁定模型时 `model:select` 不触发——锁是硬约束。
62
+
63
+ ### 上游层(upstream 内部,作用于内置 client 的每次 fetch)
64
+
65
+ | Hook | 触发时机 | ctx 内容 | 返回值语义 |
66
+ |---|---|---|---|
67
+ | `upstream:headers` | 构建请求头后 | `{ url, body, headers }` | 返回 `{ headers }` **替换请求头** |
68
+ | `upstream:before-request` | fetch 调用前 | `{ url, method, body, headers }` | 返回 `{ url?, headers? }` **改目标地址/头** —— 上游不限于 opencode,可指向任意兼容端点 |
69
+
70
+ ### 模型列表 / 组内转发 / 生命周期
71
+
72
+ | Hook | 触发时机 | ctx 内容 | 返回值语义 |
73
+ |---|---|---|---|
74
+ | `models:list` | `/v1/models` 返回前 | `{ data }` | 返回 id 数组或完整 data 数组**替换对外模型列表** |
75
+ | `peer:beforeForward` | 转发给组员前 | `{ reqId, peer, model, hops }` | 只观察 |
76
+ | `peer:result` | 组员响应后 | `{ reqId, peer, model, ok, status, latencyMs }` | 只观察 |
77
+ | `server:start` | 服务就绪 | `{ port, host, version }` | 只观察 |
78
+ | `server:stop` | 关闭前 | `{ version }` | 只观察 |
79
+
80
+ ### 特殊接口(非 hooks 字段)
81
+
82
+ ```js
83
+ export default {
84
+ name: "my-plugin",
85
+ // ① 订阅全部事件流(request/ordered/upstream/fallback/result...每条 evt 都会推给你)
86
+ onEvent(evt) { /* fire-and-forget,抛错被吞 */ },
87
+ // ② 整体替换上游 provider(接任意 OpenAI 兼容服务;多个插件声明时取第一个)
88
+ async createUpstream(ctx) {
89
+ // ctx = { baseUrl, authToken, env }
90
+ return {
91
+ chat(body) { /* 返回 fetch Response,status>=400 会走 fallback */ },
92
+ preheat() { /* 可选:返回 { ok, status, ms } */ },
93
+ close() { /* 可选 */ },
94
+ };
95
+ },
96
+ hooks: { /* ...上表全部 hook */ },
97
+ };
98
+ ```
99
+
100
+ ## 实战示例
101
+
102
+ ### 改变模型列表设定(首选模型)
103
+
104
+ ```js
105
+ // prefer-model.mjs — 把指定模型排到最前
106
+ const PREFER = "big-pickle"; // 改这里,或读你自己的配置文件
107
+
108
+ export default {
109
+ name: "prefer-model",
110
+ hooks: {
111
+ "model:select": (ctx) => {
112
+ if (!ctx.order.includes(PREFER)) return; // 不在列表就不动
113
+ return [PREFER, ...ctx.order.filter((m) => m !== PREFER)];
114
+ },
115
+ "models:list": (ctx) => {
116
+ // 对外只暴露白名单模型
117
+ return ctx.data.filter((m) => /big-pickle|deepseek/i.test(m.id ?? m));
118
+ },
119
+ },
120
+ };
121
+ ```
122
+
123
+ ### 把上游换成任意 OpenAI 兼容服务(不改 URL 配置)
124
+
125
+ ```js
126
+ // redirect-upstream.mjs
127
+ export default {
128
+ name: "redirect-upstream",
129
+ hooks: {
130
+ "upstream:before-request": (ctx) => ({
131
+ url: ctx.url.replace("https://opencode.ai", "https://my-proxy.example.com"),
132
+ }),
133
+ },
134
+ };
135
+ ```
136
+
137
+ ### 自定义鉴权 / 限流
138
+
139
+ ```js
140
+ export default {
141
+ name: "guard",
142
+ hooks: {
143
+ "request:received": (ctx) => {
144
+ if (String(ctx.body?.messages?.[0]?.content || "").includes("BLOCK")) {
145
+ return { respond: { status: 403, body: { error: "blocked by plugin" } } };
146
+ }
147
+ },
148
+ },
149
+ };
150
+ ```
151
+
152
+ ### 监控统计(事件流)
153
+
154
+ ```js
155
+ let total = 0;
156
+ export default {
157
+ name: "stats",
158
+ onEvent(evt) {
159
+ if (evt.type === "request") total++;
160
+ if (evt.type === "result" && evt.status >= 500) console.log(`[stats] 5xx! ${evt.model}`);
161
+ },
162
+ };
163
+ ```
164
+
165
+ ## 规则与保证
166
+
167
+ - **文件格式**:仅 `.mjs` / `.js`,ESM,必须有 `export default { ... }`;`name` 缺省取文件名
168
+ - **串行执行**:多个插件的同一 hook 按文件名排序依次执行;返回值链式传递(前一个的输出是后一个的输入)
169
+ - **错误隔离**:
170
+ - 加载失败 → 不注册,错误进 events.log(`plugin-load-error`)和 `-plugins` 输出
171
+ - hook 抛错 → 跳过该插件继续执行后续插件,主链路无感
172
+ - **可观测**:hook 生效时 events.log 记 `plugin-hook`;报错记 `plugin-hook-error`;替换上游记 `plugin-upstream-active`
173
+ - **性能**:`request:received / model:select / model:beforeTry / upstream:request` 是 await 串行的,别做慢操作(>100ms 请改 fire-and-forget);`onEvent / request:completed / relay:first-chunk / upstream:response / peer:*` 本身就是异步不阻塞
174
+
175
+ ## 调试
176
+
177
+ ```bash
178
+ mslxdff -plugins # 列出已识别插件与其 hooks
179
+ mslxdff -debug # 前台跑,实时看 plugin-hook / plugin-hook-error 事件
180
+ mslxdff -log 50 # 回看事件日志
181
+ ```
182
+
183
+ ## 与 WorkBuddy 集成
184
+
185
+ WorkBuddy 插件的 SKILL.md 可以教 AI 在用户说"切换首选模型到 xxx"时,自动改写上面的 `prefer-model.mjs` 并重启 daemon —— mslxdff 侧无需任何改动,hook 就是稳定契约。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,8 @@
16
16
  "files": [
17
17
  "bin/",
18
18
  "src/",
19
+ "plugins/",
20
+ "docs/",
19
21
  "README.md"
20
22
  ],
21
23
  "keywords": [
@@ -0,0 +1,14 @@
1
+ # mslxdff 官方插件目录
2
+
3
+ 本目录随 mslxdff 包分发(`*.mjs` 会被自动加载)。**注意:auto-update 重装包时本目录会被重置**——你自己的正式插件请放 `~/.config/mslxdff/plugins/`(升级永不丢)。
4
+
5
+ 优先级:同名文件时用户目录(`~/.config/mslxdff/plugins/`)覆盖本目录。
6
+
7
+ ## 目录约定
8
+
9
+ - `*.mjs` / `*.js` — 插件模块(default export `{ name, version, hooks, onEvent?, createUpstream? }`)
10
+ - `*.example` — 示例模板,不会被加载;复制成 `.mjs` 并去掉后缀即可启用
11
+
12
+ ## Hook 全表
13
+
14
+ 见 `docs/plugins.md`。
@@ -0,0 +1,14 @@
1
+ // 示例:把首选模型排到最前(复制为 prefer-model.mjs 即生效;或放 ~/.config/mslxdff/plugins/ 永久生效)
2
+ const PREFER = "big-pickle";
3
+
4
+ export default {
5
+ name: "prefer-model",
6
+ version: "1.0.0",
7
+ description: `auto 时优先使用 ${PREFER}`,
8
+ hooks: {
9
+ "model:select": (ctx) => {
10
+ if (!ctx.order.includes(PREFER)) return;
11
+ return [PREFER, ...ctx.order.filter((m) => m !== PREFER)];
12
+ },
13
+ },
14
+ };
package/src/auto.js CHANGED
@@ -1,7 +1,29 @@
1
- import { loadModelErrors, saveModelErrors, loadModelLatencies, saveModelLatencies } from "./state.js";
2
-
3
- // auto 的默认首选模型:一处定义,全局生效(可用 MSLXDFF_PREFERRED_MODEL 覆盖)
4
- export const PREFERRED_MODEL = (process.env.MSLXDFF_PREFERRED_MODEL || "big-pickle").trim();
1
+ import { statSync } from "node:fs";
2
+ import { loadModelErrors, saveModelErrors, loadModelLatencies, saveModelLatencies, loadPreferredModel, defaultStateFile } from "./state.js";
3
+
4
+ // 出厂默认首选模型(state.json preferredModel / env MSLXDFF_PREFERRED_MODEL 可覆盖)
5
+ export const DEFAULT_PREFERRED_MODEL = "big-pickle";
6
+ // 兼容旧导出名:语义为"出厂默认",当前生效值请用 getPreferredModel()
7
+ export const PREFERRED_MODEL = DEFAULT_PREFERRED_MODEL;
8
+
9
+ // 当前生效的首选模型:state.json > env > 出厂默认;mtime 缓存保证 daemon 热生效
10
+ const _prefCache = { mtimeMs: -1, file: null, value: null };
11
+ export function getPreferredModel({ file = defaultStateFile() } = {}) {
12
+ try {
13
+ const st = statSync(file);
14
+ if (_prefCache.file !== file || st.mtimeMs !== _prefCache.mtimeMs) {
15
+ _prefCache.file = file;
16
+ _prefCache.mtimeMs = st.mtimeMs;
17
+ _prefCache.value = loadPreferredModel({ file });
18
+ }
19
+ } catch {
20
+ _prefCache.file = file;
21
+ _prefCache.mtimeMs = -1;
22
+ _prefCache.value = null;
23
+ }
24
+ const env = (process.env.MSLXDFF_PREFERRED_MODEL || "").trim();
25
+ return _prefCache.value || env || DEFAULT_PREFERRED_MODEL;
26
+ }
5
27
 
6
28
  export const DEFAULT_AUTO_MODELS = [
7
29
  PREFERRED_MODEL,
@@ -74,14 +96,15 @@ function normLatency(e) {
74
96
  return Number.isFinite(ema) && ema > 0 ? ema : null;
75
97
  }
76
98
 
77
- export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0, slowCooldownMs = 0, latencies = {} } = {}) {
99
+ export function rankModels(ids, errors = {}, { now = Date.now(), cooldownMs = 0, slowCooldownMs = 0, latencies = {}, preferred } = {}) {
100
+ const pref = preferred ?? getPreferredModel();
78
101
  return [...new Set(ids)]
79
102
  .filter(Boolean)
80
103
  .map((id) => ({
81
104
  id,
82
105
  e: normEntry(errors[id]),
83
106
  err: normEntry(errors[id])?.at ?? 0,
84
- isPreferred: id === PREFERRED_MODEL,
107
+ isPreferred: id === pref,
85
108
  cooling: inCooldown(id, errors, now, cooldownMs, slowCooldownMs),
86
109
  latency: normLatency(latencies[id]) ?? Number.MAX_SAFE_INTEGER,
87
110
  }))
@@ -122,7 +145,7 @@ export function createAutoSelector({
122
145
  }
123
146
 
124
147
  async function candidates() {
125
- return rankModels(await loadList(), lastErrorAt, { now: now(), cooldownMs, slowCooldownMs, latencies });
148
+ return rankModels(await loadList(), lastErrorAt, { now: now(), cooldownMs, slowCooldownMs, latencies, preferred: getPreferredModel({ file: file ?? undefined }) });
126
149
  }
127
150
 
128
151
  async function candidatesFor(requested) {
@@ -134,6 +157,7 @@ export function createAutoSelector({
134
157
  cooldownMs,
135
158
  slowCooldownMs,
136
159
  latencies,
160
+ preferred: getPreferredModel({ file: file ?? undefined }),
137
161
  });
138
162
  // 显式指定模型:严格优先,永不因冷却被挤到最后(原设计:A deepseek 失败 → B/D deepseek 并发 → 都失败才 fallback)
139
163
  // 冷却仅影响 auto 的择优,不影响指定模型的“很难被更改”语义
package/src/chooser.js ADDED
@@ -0,0 +1,29 @@
1
+ // 交互式模型选择器的纯渲染逻辑(便于测试);键盘循环在 bin/mslxdff.js
2
+
3
+ // items: [{ id, status?, ms?, fail?, current? }];cursor 当前高亮行
4
+ // 返回行数组(无 ANSI 颜色,纯文本标记,Windows 终端友好)
5
+ export function renderChooser(items, cursor = 0) {
6
+ return items.map((it, i) => {
7
+ const arrow = i === cursor ? "❯" : " ";
8
+ const check = it.current ? " ✓ (current)" : "";
9
+ let state = "";
10
+ if (it.fail) state = ` [fail: ${it.fail}]`;
11
+ else if (it.ms != null) state = ` [${it.ms}ms]`;
12
+ else if (it.status && it.status !== "normal") state = ` [${it.status}]`;
13
+ return `${arrow} ${it.id}${check}${state}`;
14
+ });
15
+ }
16
+
17
+ export function renderChooserHelp() {
18
+ return ["", "↑/↓ move · Enter select as default · q/Esc cancel"];
19
+ }
20
+
21
+ // 解析按键:返回 "up" | "down" | "enter" | "cancel" | null(忽略)
22
+ export function parseKey(str) {
23
+ if (!str) return null;
24
+ if (str === "\x1b[A" || str === "k") return "up";
25
+ if (str === "\x1b[B" || str === "j") return "down";
26
+ if (str === "\r" || str === "\n") return "enter";
27
+ if (str === "\x1b" || str === "q" || str === "\x03") return "cancel";
28
+ return null;
29
+ }
package/src/plugins.js ADDED
@@ -0,0 +1,107 @@
1
+ import { readdirSync, existsSync } from "node:fs";
2
+ import { join, basename, extname } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import os from "node:os";
5
+
6
+ // mslxdff 插件系统:约定目录下的 *.mjs / *.js 模块,default export:
7
+ // { name?, version?, description?, hooks?: { "<hook-name>": async (ctx) => nextValue? } }
8
+ // hook 返回数组时可替换流程值(如 model:select 的候选顺序);返回 undefined 表示只观察。
9
+ // 插件错误一律隔离:加载失败进 errors,hook 抛错不影响主链路。
10
+
11
+ export function pluginsDir() {
12
+ return process.env.MSLXDFF_PLUGINS_DIR ||
13
+ join(os.homedir(), ".config", "mslxdff", "plugins");
14
+ }
15
+
16
+ // 安装目录下的 plugins/(随仓库/内置分发;auto-update 重装会丢,仅作开发/内置用)
17
+ export function bundledPluginsDir(pkgRoot) {
18
+ if (!pkgRoot) return null;
19
+ return join(pkgRoot, "plugins");
20
+ }
21
+
22
+ // 解析扫描目录列表:env 完全接管;否则安装目录 plugins/ + 用户目录(同名文件用户目录优先)
23
+ export function resolvePluginDirs({ pkgRoot } = {}) {
24
+ const env = process.env.MSLXDFF_PLUGINS_DIR;
25
+ if (env) return [env];
26
+ const dirs = [];
27
+ const bundled = bundledPluginsDir(pkgRoot);
28
+ if (bundled) dirs.push(bundled);
29
+ dirs.push(pluginsDir());
30
+ return dirs;
31
+ }
32
+
33
+ const PLUGIN_EXTS = new Set([".mjs", ".js"]);
34
+
35
+ async function loadFromDir(dir, skipFiles) {
36
+ const plugins = [];
37
+ const errors = [];
38
+ if (!dir || !existsSync(dir)) return { plugins, errors };
39
+ let files = [];
40
+ try {
41
+ files = readdirSync(dir).filter((f) => PLUGIN_EXTS.has(extname(f).toLowerCase()));
42
+ } catch (err) {
43
+ errors.push({ file: dir, error: String(err?.message || err) });
44
+ return { plugins, errors };
45
+ }
46
+ files.sort();
47
+ for (const f of files) {
48
+ const file = join(dir, f);
49
+ if (skipFiles?.has(f)) continue; // 用户目录同名文件优先
50
+ try {
51
+ const mod = await import(pathToFileURL(file).href);
52
+ const plugin = mod?.default;
53
+ if (!plugin || typeof plugin !== "object") {
54
+ errors.push({ file, error: "no default export object" });
55
+ continue;
56
+ }
57
+ plugins.push({
58
+ name: typeof plugin.name === "string" && plugin.name ? plugin.name : basename(f, extname(f)),
59
+ version: typeof plugin.version === "string" ? plugin.version : "",
60
+ description: typeof plugin.description === "string" ? plugin.description : "",
61
+ hooks: plugin.hooks && typeof plugin.hooks === "object" ? plugin.hooks : {},
62
+ file,
63
+ });
64
+ } catch (err) {
65
+ errors.push({ file, error: String(err?.message || err) });
66
+ }
67
+ }
68
+ return { plugins, errors };
69
+ }
70
+
71
+ export async function loadPlugins({ dir, dirs } = {}) {
72
+ // 兼容旧签名 loadPlugins({ dir });新签名 dirs 数组按优先级从低到高(后者覆盖前者同名文件)
73
+ const list = dirs || (dir ? [dir] : [pluginsDir()]);
74
+ let plugins = [];
75
+ const errors = [];
76
+ const seen = new Set(); // 已加载的 basename,先扫的目录跳过它们 → 后目录(高优先)覆盖
77
+ for (let i = list.length - 1; i >= 0; i--) {
78
+ const r = await loadFromDir(list[i], seen);
79
+ for (const p of r.plugins) seen.add(basename(p.file));
80
+ plugins = [...r.plugins, ...plugins]; // 低优先目录在前,目录内保持文件名序
81
+ errors.push(...r.errors);
82
+ }
83
+ return { plugins, errors };
84
+ }
85
+
86
+ // 串行执行某 hook。返回 { value, changed, errors }:
87
+ // - value:最后一个返回非 undefined 值的插件的返回值(链式传递;数组、对象、布尔均可)
88
+ // - changed:是否有插件返回了非 undefined 值(调用方可据此采用 value)
89
+ export async function runHook(plugins, name, ctx) {
90
+ let value;
91
+ let changed = false;
92
+ const errors = [];
93
+ for (const p of plugins || []) {
94
+ const fn = p.hooks?.[name];
95
+ if (typeof fn !== "function") continue;
96
+ try {
97
+ const out = await fn(ctx);
98
+ if (out !== undefined) {
99
+ value = out;
100
+ changed = true;
101
+ }
102
+ } catch (err) {
103
+ errors.push({ plugin: p.name, error: String(err?.message || err) });
104
+ }
105
+ }
106
+ return { value, changed, errors };
107
+ }
@@ -6,8 +6,9 @@ import { buildFallbackInfo } from "./fallback.js";
6
6
  import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "./stream.js";
7
7
  import { racePeerCandidates } from "./peers.js";
8
8
  import { tryBroadbandRelay } from "./relay-queue.js";
9
+ import { runHook } from "../plugins.js";
9
10
 
10
- export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHops, groups, bus, token }) {
11
+ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHops, groups, bus, token, plugins }) {
11
12
  let body;
12
13
  try {
13
14
  body = await readBody(req);
@@ -15,6 +16,16 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
15
16
  return json(res, 400, { error: "Invalid JSON body" });
16
17
  }
17
18
 
19
+ // 插件 hook:request:received — 读到 body 后触发;返回 { respond:{status,body} } 可短路请求
20
+ if (plugins?.length) {
21
+ const rc = await runHook(plugins, "request:received", { ip: clientIp(req), hops: parseHops(req.headers["x-mslxdff-hops"]), headers: { "content-type": req.headers["content-type"] }, body });
22
+ for (const e of rc.errors) logs?.appendEvent?.({ ts: Date.now(), type: "plugin-hook-error", hook: "request:received", plugin: e.plugin, error: e.error });
23
+ const respond = rc.value?.respond;
24
+ if (respond && typeof respond === "object") {
25
+ return json(res, respond.status || 200, respond.body ?? {});
26
+ }
27
+ }
28
+
18
29
  const startedAt = Date.now();
19
30
  const reqId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
20
31
  const perf0 = performance.now();
@@ -49,14 +60,32 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
49
60
  if (bus) bus.emit(entry);
50
61
  logs?.appendEvent?.(entry);
51
62
  };
63
+ // 插件 hook:request:completed — 每个请求出口触发一次(只观察)
64
+ const done = (info) => {
65
+ if (!plugins?.length) return;
66
+ runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, ...info }).catch(() => {});
67
+ };
52
68
  evt("request", { reqId, hops, ip: clientIp(req), stream: Boolean(body.stream), prompt: summarizePrompt(body), rawModel, requested, lockModel: lockModel || null });
53
69
  evt("ordered", { reqId, order, canFallback, canForwardPeers, useAuto, statuses: auto?.statuses?.() ?? null });
54
70
 
71
+ // 插件 hook:model:select — 可返回新数组替换候选顺序(lockModel 时不可改)
72
+ if (plugins?.length && !lockModel) {
73
+ const sel = await runHook(plugins, "model:select", { reqId, requested, useAuto, order: [...order], hops, stream: Boolean(body.stream) });
74
+ if (sel.changed && Array.isArray(sel.value) && sel.value.length) {
75
+ order = sel.value.filter(Boolean);
76
+ if (!order.length) order = [requested];
77
+ evt("plugin-hook", { reqId, hook: "model:select", applied: true, order: [...order] });
78
+ }
79
+ for (const e of sel.errors) evt("plugin-hook-error", { reqId, hook: "model:select", plugin: e.plugin, error: e.error });
80
+ }
81
+
55
82
  const handlerCtx = {
83
+ reqId,
56
84
  model: null,
57
85
  body,
58
86
  hops,
59
87
  peers,
88
+ plugins,
60
89
  evt,
61
90
  logError,
62
91
  logCall,
@@ -67,8 +96,26 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
67
96
  const model = order[idx];
68
97
  handlerCtx.model = model;
69
98
  evt("model-try", { reqId, model, idx, remaining: order.length - idx });
99
+ // 插件 hook:model:beforeTry — 返回 false 可跳过该候选
100
+ if (plugins?.length) {
101
+ const bt = await runHook(plugins, "model:beforeTry", { reqId, requested, model, idx, hops });
102
+ for (const e of bt.errors) evt("plugin-hook-error", { reqId, hook: "model:beforeTry", plugin: e.plugin, error: e.error });
103
+ if (bt.value === false || bt.value?.skip === true) {
104
+ evt("plugin-hook", { reqId, hook: "model:beforeTry", applied: true, skipped: model });
105
+ continue;
106
+ }
107
+ }
70
108
  let upRes = null;
71
- const forwarded = { ...injectReasoningContent(model, body), model };
109
+ let forwarded = { ...injectReasoningContent(model, body), model };
110
+ // 插件 hook:upstream:request — 返回 { payload } 可替换本次发往上游的负载
111
+ if (plugins?.length) {
112
+ const ur = await runHook(plugins, "upstream:request", { reqId, requested, model, payload: forwarded, stream: Boolean(body.stream) });
113
+ for (const e of ur.errors) evt("plugin-hook-error", { reqId, hook: "upstream:request", plugin: e.plugin, error: e.error });
114
+ if (ur.changed && ur.value?.payload && typeof ur.value.payload === "object") {
115
+ forwarded = ur.value.payload;
116
+ evt("plugin-hook", { reqId, hook: "upstream:request", applied: true, model, rewrittenModel: forwarded.model ?? null });
117
+ }
118
+ }
72
119
  const tUp = performance.now();
73
120
  evt("upstream-try", { reqId, model, attempt: idx + 1 });
74
121
  try {
@@ -80,6 +127,16 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
80
127
  logError(model, 502, errMsg(err));
81
128
  evt("upstream-error", { reqId, model, status: 502, message: errMsg(err), timing: err._t ?? { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - tUp) } });
82
129
  }
130
+ // 插件 hook:upstream:response — 上游响应(或错误)后观察
131
+ if (plugins?.length) {
132
+ runHook(plugins, "upstream:response", {
133
+ reqId, requested, model,
134
+ status: upRes instanceof Error ? null : upRes instanceof Object ? (upRes.status ?? null) : null,
135
+ ok: !(upRes instanceof Error) && upRes ? upRes.status < 400 : false,
136
+ error: upRes instanceof Error ? errMsg(upRes) : null,
137
+ timing: upRes?._t ?? null,
138
+ }).catch(() => {});
139
+ }
83
140
  mark(`up-${model}`);
84
141
  if (upRes && upRes.status >= 400) {
85
142
  if (auto) await auto.recordError(model, { status: upRes.status });
@@ -98,6 +155,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
98
155
  onFirstChunk: (delta) => {
99
156
  mark(`ttf-${model}`);
100
157
  evt("relay-first-chunk", { reqId, model, ttfMs: delta });
158
+ if (plugins?.length) runHook(plugins, "relay:first-chunk", { reqId, requested, model, via: "local", ttfMs: delta }).catch(() => {});
101
159
  },
102
160
  onDownstreamAbort: () => {
103
161
  evt("client-abort", { reqId, model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] });
@@ -122,6 +180,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
122
180
  logCall(model, 200);
123
181
  evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, detail: out.detail ?? null, fallback, requested, actual: model });
124
182
  evt("client-response", { requested, actual: model, via: "local", fallback, status: out.status, interrupted: true, reqId });
183
+ done({ via: "local", status: out.status, actual: model, interrupted: true, fallback });
125
184
  return;
126
185
  }
127
186
  const elapsed = Date.now() - startedAt;
@@ -146,6 +205,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
146
205
  }
147
206
  evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback, requested, actual: model });
148
207
  evt("client-response", { requested, actual: model, via: "local", fallback, status: out.status, reqId });
208
+ done({ via: "local", status: out.status, actual: model, fallback });
149
209
  return;
150
210
  }
151
211
 
@@ -178,6 +238,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
178
238
  }
179
239
  evt("result", { model: win.target, status: out.status, via: "peer", timing: win.res._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback: peerFallback, requested, actual: win.target });
180
240
  evt("client-response", { requested, actual: win.target, via: "peer", fallback: peerFallback, status: out.status, reqId });
241
+ done({ via: "peer", status: out.status, actual: win.target, fallback: peerFallback });
181
242
  return;
182
243
  }
183
244
  evt("peer-race-lose", { reqId, model });
@@ -208,6 +269,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
208
269
  }
209
270
  evt("result", { model, status: out.status, via: "broadband", timing: bb.result._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback: bbFallback, requested, actual: model });
210
271
  evt("client-response", { requested, actual: model, via: "broadband", fallback: bbFallback, status: out.status, reqId });
272
+ done({ via: "broadband", status: out.status, actual: model, fallback: bbFallback });
211
273
  return;
212
274
  } else if (bb.result && typeof bb.result.status === "number") {
213
275
  const fakeRes = {
@@ -235,6 +297,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
235
297
  evt("relay-done", { reqId, model, via: "broadband-local", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
236
298
  evt("result", { model, status: out.status, via: "broadband", timing: null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback: bbLocalFallback, requested, actual: model });
237
299
  evt("client-response", { requested, actual: model, via: "broadband", fallback: bbLocalFallback, status: out.status, reqId });
300
+ done({ via: "broadband", status: out.status, actual: model, fallback: bbLocalFallback });
238
301
  return;
239
302
  }
240
303
  }
@@ -258,6 +321,7 @@ export async function chatHandler({ req, res, upstream, auto, logs, peers, maxHo
258
321
  return;
259
322
  }
260
323
  evt("result", { reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
324
+ done({ via: "none", status: lastErr?.status ?? 502, error: lastErr?.message || "all auto models failed" });
261
325
  return json(res, 502, { error: lastErr?.message || "all auto models failed" });
262
326
  }
263
327
 
@@ -6,7 +6,7 @@ import { joinHandler, leaveHandler } from "./groups.js";
6
6
  import { heartbeatHandler, pollHandler, resultHandler, forwardHandler } from "./groups-relay.js";
7
7
  import { modelsHandler, modelsStatusHandler } from "./models-route.js";
8
8
 
9
- export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus }) {
9
+ export function createRouter({ token, upstream, models, auto, logs, peers, maxHops = DEFAULT_MAX_HOPS, groups, bans, bus, plugins }) {
10
10
  return async function router(req, res) {
11
11
  const method = req.method || "GET";
12
12
  const path = (req.url || "").split("?")[0];
@@ -17,7 +17,7 @@ export function createRouter({ token, upstream, models, auto, logs, peers, maxHo
17
17
  res.setHeader("WWW-Authenticate", "Bearer");
18
18
  return json(res, 401, { error: "Unauthorized" });
19
19
  }
20
- await route.handler({ req, res, upstream, models, auto, logs, peers, maxHops, groups, bans, token, bus });
20
+ await route.handler({ req, res, upstream, models, auto, logs, peers, maxHops, groups, bans, token, bus, plugins });
21
21
  };
22
22
  }
23
23
 
@@ -1,9 +1,22 @@
1
1
  import { json, errMsg } from "./helpers.js";
2
+ import { runHook } from "../plugins.js";
2
3
 
3
- export async function modelsHandler({ res, models }) {
4
+ export async function modelsHandler({ res, models, plugins }) {
4
5
  if (!models) return json(res, 501, { error: "Models service not configured" });
5
6
  try {
6
- const data = await models.get();
7
+ let data = await models.get();
8
+ // 插件 hook:models:list — 返回数组可替换对外模型列表({object:"list",data:[...]} 或纯 id 数组)
9
+ if (plugins?.length) {
10
+ const ml = await runHook(plugins, "models:list", { data });
11
+ for (const e of ml.errors) console.log(`plugin models:list error (${e.plugin}): ${e.error}`);
12
+ if (ml.changed && Array.isArray(ml.value)) {
13
+ const idsOnly = ml.value.every((x) => typeof x === "string");
14
+ const out = idsOnly
15
+ ? { object: "list", data: ml.value.map((id) => ({ id, object: "model", owned_by: "plugin" })) }
16
+ : { object: "list", data: ml.value };
17
+ return json(res, 200, out);
18
+ }
19
+ }
7
20
  json(res, 200, data);
8
21
  } catch (err) {
9
22
  json(res, 502, { error: errMsg(err) });