mslxdff 0.1.43 → 0.1.54
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 +162 -162
- package/bin/mslxdff.js +265 -18
- package/docs/adr/0001-reasoning-content-injection.md +14 -0
- package/docs/adr/0002-models-free-filter.md +12 -0
- package/docs/adr/0003-zero-state-no-auth.md +10 -0
- package/docs/adr/0004-bearer-token.md +18 -0
- package/docs/adr/0005-peer-mesh.md +53 -0
- package/docs/adr/0006-broadband-member.md +103 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +30 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/plugins.md +185 -0
- package/package.json +3 -1
- package/plugins/README.md +14 -0
- package/plugins/prefer-model.mjs.example +14 -0
- package/src/auto.js +31 -7
- package/src/chooser.js +29 -0
- package/src/daemon.js +84 -84
- package/src/logs.js +41 -2
- package/src/models.js +127 -127
- package/src/plugins.js +107 -0
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +88 -0
- package/src/routes/chat/exhausted-handler.js +41 -0
- package/src/routes/chat/hedge-handler.js +142 -0
- package/src/routes/chat/index.js +178 -0
- package/src/routes/chat/local-handler.js +88 -0
- package/src/routes/chat/peer-handler.js +58 -0
- package/src/routes/chat.js +3 -278
- package/src/routes/hedge.js +251 -0
- package/src/routes/index.js +2 -2
- package/src/routes/models-route.js +15 -2
- package/src/routes/peers.js +58 -15
- package/src/server.js +57 -54
- package/src/state.js +272 -141
- package/src/sync-workbuddy.js +104 -0
- package/src/upstream.js +311 -192
package/src/models.js
CHANGED
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"];
|
|
2
|
-
const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
3
|
-
const DEFAULT_REFRESH_MS = 2 * 60 * 60 * 1000;
|
|
4
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { dirname } from "node:path";
|
|
6
|
-
|
|
7
|
-
export function isFreeModel(id) {
|
|
8
|
-
return (typeof id === "string" && id.endsWith("-free")) ||
|
|
9
|
-
KNOWN_FREE_OPENCODE_MODELS.includes(id);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function filterFreeModels(list) {
|
|
13
|
-
const seen = new Set();
|
|
14
|
-
const out = [];
|
|
15
|
-
for (const m of list || []) {
|
|
16
|
-
if (!(m && m.id)) continue;
|
|
17
|
-
if (!isFreeModel(m.id)) continue;
|
|
18
|
-
if (seen.has(m.id)) continue;
|
|
19
|
-
seen.add(m.id);
|
|
20
|
-
out.push(m);
|
|
21
|
-
}
|
|
22
|
-
return out;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, refreshMs = DEFAULT_REFRESH_MS, cacheFile } = {}) {
|
|
26
|
-
let cache = null;
|
|
27
|
-
let fetchedAt = 0;
|
|
28
|
-
let inflight = null;
|
|
29
|
-
let timer = null;
|
|
30
|
-
|
|
31
|
-
async function load() {
|
|
32
|
-
const data = await fetchUpstreamModels({ baseUrl, headers });
|
|
33
|
-
cache = data;
|
|
34
|
-
fetchedAt = Date.now();
|
|
35
|
-
if (cacheFile) persistModels(data, cacheFile);
|
|
36
|
-
return data;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function get() {
|
|
40
|
-
const now = Date.now();
|
|
41
|
-
if (cache && now - fetchedAt < ttlMs) return cache;
|
|
42
|
-
if (inflight) return inflight;
|
|
43
|
-
|
|
44
|
-
inflight = (async () => {
|
|
45
|
-
try {
|
|
46
|
-
return await load();
|
|
47
|
-
} catch (err) {
|
|
48
|
-
// serve stale on failure if we have it, else rethrow
|
|
49
|
-
if (cache) return cache;
|
|
50
|
-
throw err;
|
|
51
|
-
} finally {
|
|
52
|
-
inflight = null;
|
|
53
|
-
}
|
|
54
|
-
})();
|
|
55
|
-
return inflight;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function startAutoRefresh(intervalMs = refreshMs) {
|
|
59
|
-
if (timer) return stopAutoRefresh;
|
|
60
|
-
timer = setInterval(() => {
|
|
61
|
-
void load().catch(() => {
|
|
62
|
-
// keep serving stale cache on background refresh failure
|
|
63
|
-
});
|
|
64
|
-
}, intervalMs);
|
|
65
|
-
timer.unref?.();
|
|
66
|
-
return stopAutoRefresh;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function stopAutoRefresh() {
|
|
70
|
-
if (timer) {
|
|
71
|
-
clearInterval(timer);
|
|
72
|
-
timer = null;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return { get, startAutoRefresh, stopAutoRefresh };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function fetchUpstreamModels({ baseUrl, headers, connectTimeoutMs = 30_000 }) {
|
|
80
|
-
const url = `${baseUrl}/zen/v1/models`;
|
|
81
|
-
for (let attempt = 0; ; attempt++) {
|
|
82
|
-
const res = await attemptFetch(url, headers, connectTimeoutMs);
|
|
83
|
-
if (res instanceof Error) {
|
|
84
|
-
if (attempt < NETWORK_RETRIES) continue;
|
|
85
|
-
throw res;
|
|
86
|
-
}
|
|
87
|
-
if (isRetryable(res.status) && attempt < STATUS_RETRIES) {
|
|
88
|
-
await sleep(2000);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (!res.ok) throw new Error(`models fetch failed: HTTP ${res.status}`);
|
|
92
|
-
const json = await res.json().catch(() => ({}));
|
|
93
|
-
const raw = Array.isArray(json) ? json : json.data ?? json.models ?? [];
|
|
94
|
-
return { object: "list", data: filterFreeModels(raw) };
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function attemptFetch(url, headers, connectTimeoutMs) {
|
|
99
|
-
const controller = new AbortController();
|
|
100
|
-
const timer = setTimeout(() => controller.abort(), connectTimeoutMs);
|
|
101
|
-
try {
|
|
102
|
-
return await fetch(url, { headers, signal: controller.signal });
|
|
103
|
-
} catch (err) {
|
|
104
|
-
return err;
|
|
105
|
-
} finally {
|
|
106
|
-
clearTimeout(timer);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function isRetryable(status) {
|
|
111
|
-
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function persistModels(data, cacheFile) {
|
|
115
|
-
try {
|
|
116
|
-
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
117
|
-
writeFileSync(cacheFile, JSON.stringify({ cachedAt: Date.now(), ...data }));
|
|
118
|
-
} catch {
|
|
119
|
-
// persistence is best-effort
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function sleep(ms) {
|
|
124
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const NETWORK_RETRIES = 2;
|
|
1
|
+
const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"];
|
|
2
|
+
const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
3
|
+
const DEFAULT_REFRESH_MS = 2 * 60 * 60 * 1000;
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
|
|
7
|
+
export function isFreeModel(id) {
|
|
8
|
+
return (typeof id === "string" && id.endsWith("-free")) ||
|
|
9
|
+
KNOWN_FREE_OPENCODE_MODELS.includes(id);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function filterFreeModels(list) {
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const m of list || []) {
|
|
16
|
+
if (!(m && m.id)) continue;
|
|
17
|
+
if (!isFreeModel(m.id)) continue;
|
|
18
|
+
if (seen.has(m.id)) continue;
|
|
19
|
+
seen.add(m.id);
|
|
20
|
+
out.push(m);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, refreshMs = DEFAULT_REFRESH_MS, cacheFile } = {}) {
|
|
26
|
+
let cache = null;
|
|
27
|
+
let fetchedAt = 0;
|
|
28
|
+
let inflight = null;
|
|
29
|
+
let timer = null;
|
|
30
|
+
|
|
31
|
+
async function load() {
|
|
32
|
+
const data = await fetchUpstreamModels({ baseUrl, headers });
|
|
33
|
+
cache = data;
|
|
34
|
+
fetchedAt = Date.now();
|
|
35
|
+
if (cacheFile) persistModels(data, cacheFile);
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function get() {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (cache && now - fetchedAt < ttlMs) return cache;
|
|
42
|
+
if (inflight) return inflight;
|
|
43
|
+
|
|
44
|
+
inflight = (async () => {
|
|
45
|
+
try {
|
|
46
|
+
return await load();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// serve stale on failure if we have it, else rethrow
|
|
49
|
+
if (cache) return cache;
|
|
50
|
+
throw err;
|
|
51
|
+
} finally {
|
|
52
|
+
inflight = null;
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
return inflight;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function startAutoRefresh(intervalMs = refreshMs) {
|
|
59
|
+
if (timer) return stopAutoRefresh;
|
|
60
|
+
timer = setInterval(() => {
|
|
61
|
+
void load().catch(() => {
|
|
62
|
+
// keep serving stale cache on background refresh failure
|
|
63
|
+
});
|
|
64
|
+
}, intervalMs);
|
|
65
|
+
timer.unref?.();
|
|
66
|
+
return stopAutoRefresh;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stopAutoRefresh() {
|
|
70
|
+
if (timer) {
|
|
71
|
+
clearInterval(timer);
|
|
72
|
+
timer = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { get, startAutoRefresh, stopAutoRefresh };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fetchUpstreamModels({ baseUrl, headers, connectTimeoutMs = 30_000 }) {
|
|
80
|
+
const url = `${baseUrl}/zen/v1/models`;
|
|
81
|
+
for (let attempt = 0; ; attempt++) {
|
|
82
|
+
const res = await attemptFetch(url, headers, connectTimeoutMs);
|
|
83
|
+
if (res instanceof Error) {
|
|
84
|
+
if (attempt < NETWORK_RETRIES) continue;
|
|
85
|
+
throw res;
|
|
86
|
+
}
|
|
87
|
+
if (isRetryable(res.status) && attempt < STATUS_RETRIES) {
|
|
88
|
+
await sleep(2000);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) throw new Error(`models fetch failed: HTTP ${res.status}`);
|
|
92
|
+
const json = await res.json().catch(() => ({}));
|
|
93
|
+
const raw = Array.isArray(json) ? json : json.data ?? json.models ?? [];
|
|
94
|
+
return { object: "list", data: filterFreeModels(raw) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function attemptFetch(url, headers, connectTimeoutMs) {
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => controller.abort(), connectTimeoutMs);
|
|
101
|
+
try {
|
|
102
|
+
return await fetch(url, { headers, signal: controller.signal });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return err;
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isRetryable(status) {
|
|
111
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function persistModels(data, cacheFile) {
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
117
|
+
writeFileSync(cacheFile, JSON.stringify({ cachedAt: Date.now(), ...data }));
|
|
118
|
+
} catch {
|
|
119
|
+
// persistence is best-effort
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sleep(ms) {
|
|
124
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const NETWORK_RETRIES = 2;
|
|
128
128
|
const STATUS_RETRIES = 2;
|
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
|
+
}
|
package/src/reasoning.js
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
const PLACEHOLDER = " ";
|
|
2
|
-
|
|
3
|
-
const MODEL_RULES = [
|
|
4
|
-
{ match: (m) => /^kimi-/i.test(m || ""), scope: "toolCalls" },
|
|
5
|
-
{ match: (m) => /deepseek/i.test(m || ""), scope: "all" },
|
|
6
|
-
];
|
|
7
|
-
|
|
8
|
-
export function normalizeModel(model) {
|
|
9
|
-
return model.startsWith("oc/") ? model.slice(3) : model;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function shouldInject(message, scope) {
|
|
13
|
-
if (message?.role !== "assistant") return false;
|
|
14
|
-
const rc = message.reasoning_content;
|
|
15
|
-
if (typeof rc === "string" && rc.length > 0) return false;
|
|
16
|
-
if (scope === "toolCalls") {
|
|
17
|
-
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
|
18
|
-
}
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function applyRule(body, rule) {
|
|
23
|
-
if (!rule || !body?.messages) return body;
|
|
24
|
-
const messages = body.messages.map((m) =>
|
|
25
|
-
shouldInject(m, rule.scope) ? { ...m, reasoning_content: PLACEHOLDER } : m
|
|
26
|
-
);
|
|
27
|
-
return { ...body, messages };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function injectReasoningContent(model, body) {
|
|
31
|
-
const rule = MODEL_RULES.find((r) => r.match(model));
|
|
32
|
-
return applyRule(body, rule);
|
|
1
|
+
const PLACEHOLDER = " ";
|
|
2
|
+
|
|
3
|
+
const MODEL_RULES = [
|
|
4
|
+
{ match: (m) => /^kimi-/i.test(m || ""), scope: "toolCalls" },
|
|
5
|
+
{ match: (m) => /deepseek/i.test(m || ""), scope: "all" },
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export function normalizeModel(model) {
|
|
9
|
+
return model.startsWith("oc/") ? model.slice(3) : model;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function shouldInject(message, scope) {
|
|
13
|
+
if (message?.role !== "assistant") return false;
|
|
14
|
+
const rc = message.reasoning_content;
|
|
15
|
+
if (typeof rc === "string" && rc.length > 0) return false;
|
|
16
|
+
if (scope === "toolCalls") {
|
|
17
|
+
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function applyRule(body, rule) {
|
|
23
|
+
if (!rule || !body?.messages) return body;
|
|
24
|
+
const messages = body.messages.map((m) =>
|
|
25
|
+
shouldInject(m, rule.scope) ? { ...m, reasoning_content: PLACEHOLDER } : m
|
|
26
|
+
);
|
|
27
|
+
return { ...body, messages };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function injectReasoningContent(model, body) {
|
|
31
|
+
const rule = MODEL_RULES.find((r) => r.match(model));
|
|
32
|
+
return applyRule(body, rule);
|
|
33
33
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { buildFallbackInfo } from "../fallback.js";
|
|
2
|
+
import { relay, SLOW_TOTAL_MS } from "../stream.js";
|
|
3
|
+
import { tryBroadbandRelay } from "../relay-queue.js";
|
|
4
|
+
import { runHook } from "../../plugins.js";
|
|
5
|
+
|
|
6
|
+
export async function handleBroadbandRelay({
|
|
7
|
+
model,
|
|
8
|
+
body,
|
|
9
|
+
hops,
|
|
10
|
+
lastErr,
|
|
11
|
+
requested,
|
|
12
|
+
useAuto,
|
|
13
|
+
lockModel,
|
|
14
|
+
auto,
|
|
15
|
+
groups,
|
|
16
|
+
token,
|
|
17
|
+
bus,
|
|
18
|
+
logs,
|
|
19
|
+
handlerCtx,
|
|
20
|
+
evt,
|
|
21
|
+
mark,
|
|
22
|
+
perf0,
|
|
23
|
+
stages,
|
|
24
|
+
res,
|
|
25
|
+
startedAt,
|
|
26
|
+
plugins,
|
|
27
|
+
}) {
|
|
28
|
+
const bb = await tryBroadbandRelay({ groups, token, model, body, hops, bus, logs, reqId: handlerCtx.reqId, evt, res, mark, perf0, stages });
|
|
29
|
+
if (!bb) {
|
|
30
|
+
evt("relay-miss", { reqId: handlerCtx.reqId, model });
|
|
31
|
+
return { handled: false };
|
|
32
|
+
}
|
|
33
|
+
const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
|
|
34
|
+
if (isResponse) {
|
|
35
|
+
const bbFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
|
|
36
|
+
if (bbFallback?.fallback) evt("fallback-notice", { reqId: handlerCtx.reqId, requested, actual: model, reason: bbFallback.reason, notice: bbFallback.notice, via: "broadband" });
|
|
37
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model, via: "broadband", target: bb.target, group: bb.group, fallback: bbFallback });
|
|
38
|
+
const out = await relay(res, bb.result, body, {
|
|
39
|
+
fallback: bbFallback,
|
|
40
|
+
onFirstChunk: (d) => mark(`ttf-bb-${model}`),
|
|
41
|
+
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model, totalMs: Math.round(Date.now() - startedAt), stages: [...stages] }),
|
|
42
|
+
});
|
|
43
|
+
evt("relay-done", { reqId: handlerCtx.reqId, model, via: "broadband", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
44
|
+
if (auto && out.status === 200) {
|
|
45
|
+
const latencyMs = out.totalMs ?? 0;
|
|
46
|
+
if (out.detail?.stallHits > 0 || (latencyMs && latencyMs > SLOW_TOTAL_MS)) {
|
|
47
|
+
void auto.recordError(model, { status: 200, slow: true, note: `broadband slow ${latencyMs}ms` });
|
|
48
|
+
void auto.recordLatency(model, latencyMs);
|
|
49
|
+
} else {
|
|
50
|
+
await auto.recordOk(model, { latencyMs });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
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 });
|
|
54
|
+
evt("client-response", { requested, actual: model, via: "broadband", fallback: bbFallback, status: out.status, reqId: handlerCtx.reqId });
|
|
55
|
+
if (plugins?.length) runHook(plugins, "request:completed", { reqId: handlerCtx.reqId, requested, useAuto, hops: handlerCtx.hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, via: "broadband", status: out.status, actual: model, fallback: bbFallback }).catch(() => {});
|
|
56
|
+
return { handled: true };
|
|
57
|
+
} else if (bb.result && typeof bb.result.status === "number") {
|
|
58
|
+
const fakeRes = {
|
|
59
|
+
status: bb.result.status,
|
|
60
|
+
headers: { get: (k) => bb.result.headers?.[k] || bb.result.headers?.[k.toLowerCase()] || null },
|
|
61
|
+
text: async () => typeof bb.result.body === "string" ? bb.result.body : JSON.stringify(bb.result.body),
|
|
62
|
+
body: (() => {
|
|
63
|
+
const b = bb.result.body || "";
|
|
64
|
+
const str = typeof b === "string" ? b : JSON.stringify(b);
|
|
65
|
+
const isSSE = bb.result.headers?.["Content-Type"]?.includes("text/event-stream");
|
|
66
|
+
if (isSSE) {
|
|
67
|
+
return (async function* () { yield Buffer.from(str); })();
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
})(),
|
|
71
|
+
};
|
|
72
|
+
const bbLocalFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
|
|
73
|
+
if (bbLocalFallback?.fallback) evt("fallback-notice", { reqId: handlerCtx.reqId, requested, actual: model, reason: bbLocalFallback.reason, notice: bbLocalFallback.notice, via: "broadband" });
|
|
74
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model, via: "broadband-local", target: bb.target, group: bb.group, fallback: bbLocalFallback });
|
|
75
|
+
const out = await relay(res, fakeRes, body, {
|
|
76
|
+
fallback: bbLocalFallback,
|
|
77
|
+
onFirstChunk: (d) => mark(`ttf-bb-${model}`),
|
|
78
|
+
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model, totalMs: Math.round(Date.now() - startedAt), stages: [...stages] }),
|
|
79
|
+
});
|
|
80
|
+
evt("relay-done", { reqId: handlerCtx.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 });
|
|
81
|
+
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 });
|
|
82
|
+
evt("client-response", { requested, actual: model, via: "broadband", fallback: bbLocalFallback, status: out.status, reqId: handlerCtx.reqId });
|
|
83
|
+
if (plugins?.length) runHook(plugins, "request:completed", { reqId: handlerCtx.reqId, requested, useAuto, hops: handlerCtx.hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, via: "broadband", status: out.status, actual: model, fallback: bbLocalFallback }).catch(() => {});
|
|
84
|
+
return { handled: true };
|
|
85
|
+
}
|
|
86
|
+
evt("relay-miss", { reqId: handlerCtx.reqId, model });
|
|
87
|
+
return { handled: false };
|
|
88
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { json } from "../helpers.js";
|
|
3
|
+
import { relay } from "../stream.js";
|
|
4
|
+
|
|
5
|
+
export async function handleExhaustedLocal({ res, body, lastErr, order, handlerCtx, evt, logCall, mark, perf0, stages, done, requested, useAuto }) {
|
|
6
|
+
const model = lastErr?.model ?? handlerCtx.model;
|
|
7
|
+
evt("exhausted-local", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? model, lastStatus: lastErr?.status ?? 502, order });
|
|
8
|
+
logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
|
|
9
|
+
if (lastErr?.upstream) {
|
|
10
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", isStream: Boolean(body.stream) });
|
|
11
|
+
const out = await relay(res, lastErr.upstream, body, {
|
|
12
|
+
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
13
|
+
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
14
|
+
});
|
|
15
|
+
evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
16
|
+
evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
evt("result", { reqId: handlerCtx.reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
20
|
+
done({ via: "none", status: lastErr?.status ?? 502, error: lastErr?.message || "all auto models failed" });
|
|
21
|
+
json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx, evt, logCall, mark, perf0, stages }) {
|
|
26
|
+
evt("exhausted-all", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? requested, lastStatus: lastErr?.status ?? 502, order });
|
|
27
|
+
logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
|
|
28
|
+
if (lastErr?.upstream) {
|
|
29
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", isStream: Boolean(body.stream) });
|
|
30
|
+
const out = await relay(res, lastErr.upstream, body, {
|
|
31
|
+
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
32
|
+
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
33
|
+
});
|
|
34
|
+
evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
35
|
+
evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
evt("result", { reqId: handlerCtx.reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
39
|
+
json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
40
|
+
return true;
|
|
41
|
+
}
|