mslxdff 0.1.62 → 0.1.64
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/bin/mslxdff.js +396 -39
- package/package.json +1 -1
- package/src/auto.js +13 -0
- package/src/chat/config.js +2 -1
- package/src/chat/prompt.js +1 -1
- package/src/chat/repl.js +12 -49
- package/src/chat/stats.js +1 -1
- package/src/chat/upstream.js +183 -5
- package/src/providers/generic.js +17 -4
- package/src/providers/workbuddy.js +16 -5
- package/src/routes/index.js +10 -1
- package/src/routes/models-route.js +28 -0
- package/src/state.js +55 -10
package/src/chat/repl.js
CHANGED
|
@@ -30,35 +30,7 @@ function trace(line) {
|
|
|
30
30
|
console.log(`\x1b[90m· ${line}\x1b[0m`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
const low = String(text || "").toLowerCase();
|
|
35
|
-
const known = ["workbuddy", "clinebot", "opencode", "bai", "openrouter", "poolside", "z-ai", "deepseek"];
|
|
36
|
-
for (const k of known) if (low.includes(k)) return k;
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
function isModelListQuery(text) {
|
|
40
|
-
const low = String(text || "").toLowerCase();
|
|
41
|
-
if (!low.includes("模型")) return false;
|
|
42
|
-
return low.includes("哪些") || low.includes("可用") || low.includes("支持") || low.includes("列表") || low.includes("有啥") || low.includes("都有") || low.includes("可以") || low.includes("用");
|
|
43
|
-
}
|
|
44
|
-
function formatModelAnswer(prov, models) {
|
|
45
|
-
const byProv = {};
|
|
46
|
-
for (const id of models) {
|
|
47
|
-
const slash = id.indexOf("/");
|
|
48
|
-
const p = slash > 0 ? id.slice(0, slash) : "opencode";
|
|
49
|
-
if (!byProv[p]) byProv[p] = [];
|
|
50
|
-
byProv[p].push(id);
|
|
51
|
-
}
|
|
52
|
-
if (prov) {
|
|
53
|
-
const list = byProv[prov] || models.filter((m) => m.toLowerCase().startsWith(prov.toLowerCase() + "/"));
|
|
54
|
-
if (!list.length) return `**${prov}** 暂无可用模型(可能未配置或网关未聚合)。可用总量 ${models.length},按供应商:${Object.entries(byProv).map(([k, v]) => `${k}(${v.length})`).join(" | ")}`;
|
|
55
|
-
// 更友好:直接列 id 和调用方式
|
|
56
|
-
return `**${prov}** 可用模型(共 ${list.length} 个,网关已聚合):\n\n| 模型 id | 调用方式 |\n|:---|:---|\n${list.map((m) => `| \`${m}\` | \`${m}\` |`).join("\n")}\n\n> 提示:直接用 \`${prov}/<模型>\` 调用,例如 \`${list[0]}\``;
|
|
57
|
-
}
|
|
58
|
-
// 无指定供应商:按分组汇总
|
|
59
|
-
const summary = Object.entries(byProv).map(([p, arr]) => `**${p}**(${arr.length}):${arr.slice(0, 8).join(", ")}${arr.length > 8 ? " …" : ""}`).join("\n");
|
|
60
|
-
return `可用模型总计 ${models.length} 个,按供应商分组:\n\n${summary}\n\n> 查某供应商请说“workbuddy有哪些模型”`;
|
|
61
|
-
}
|
|
33
|
+
|
|
62
34
|
|
|
63
35
|
async function maybeCompress(messages) {
|
|
64
36
|
if (!needsCompress(messages)) return [...messages];
|
|
@@ -85,23 +57,11 @@ async function maybeCompress(messages) {
|
|
|
85
57
|
async function runAgentTurn(userText, messages) {
|
|
86
58
|
const tools = getToolDefs();
|
|
87
59
|
messages.push({ role: "user", content: userText });
|
|
88
|
-
// Fast-path:模型列表类问题本地直答,不走 LLM,避免 “provider list” 幻觉和 6 轮重复
|
|
89
|
-
if (isModelListQuery(userText)) {
|
|
90
|
-
const prov = extractProvFromQuery(userText);
|
|
91
|
-
try {
|
|
92
|
-
const models = getModelsForPrompt();
|
|
93
|
-
const answer = formatModelAnswer(prov, models);
|
|
94
|
-
if (answer) {
|
|
95
|
-
messages.push({ role: "assistant", content: answer });
|
|
96
|
-
trace(`[fast] 模型列表直答 prov=${prov || "all"} 共 ${models.length} 个`);
|
|
97
|
-
return { text: answer, model: "local", latency: 0, usage: null, fallback: false, ok: true, totalMs: 0 };
|
|
98
|
-
}
|
|
99
|
-
} catch {}
|
|
100
|
-
}
|
|
101
60
|
let loops = 0;
|
|
102
61
|
let lastModel = null;
|
|
103
62
|
let lastUsage = null;
|
|
104
63
|
let lastFallback = false;
|
|
64
|
+
let lastFallbackGateway = false;
|
|
105
65
|
let lastLatency = 0;
|
|
106
66
|
const t0 = performance.now();
|
|
107
67
|
const turnStart = performance.now();
|
|
@@ -147,6 +107,7 @@ async function runAgentTurn(userText, messages) {
|
|
|
147
107
|
lastModel = res.model;
|
|
148
108
|
lastUsage = res.usage || null;
|
|
149
109
|
lastFallback = !!res.fallback;
|
|
110
|
+
lastFallbackGateway = !!res.fallbackGateway || !!res.viaGateway;
|
|
150
111
|
const msg = res.message;
|
|
151
112
|
const toolCalls = msg.tool_calls || [];
|
|
152
113
|
let fallbackCmd = null;
|
|
@@ -158,9 +119,11 @@ async function runAgentTurn(userText, messages) {
|
|
|
158
119
|
const text = String(msg.content || "").trim() || "(空回复)";
|
|
159
120
|
messages.push({ role: "assistant", content: text });
|
|
160
121
|
const totalMs = Math.round(performance.now() - t0);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
122
|
+
let note = "";
|
|
123
|
+
if (lastFallbackGateway) note = "\n\x1b[90m[注:mimo/big-pickle 均不可用,已自动切本地网关 auto(:8989)]\x1b[0m";
|
|
124
|
+
else if (lastFallback) note = "\n\x1b[90m[注:mimo 不可用,已用 big-pickle]\x1b[0m";
|
|
125
|
+
trace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具${lastFallbackGateway ? " · gateway-fallback" : ""}`);
|
|
126
|
+
return { text: text + note, model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: true, totalMs };
|
|
164
127
|
}
|
|
165
128
|
const calls = toolCalls.length ? toolCalls : [{ id: "fallback-1", function: { name: "run_command", arguments: JSON.stringify({ command: fallbackCmd }) } }];
|
|
166
129
|
messages.push({ role: "assistant", content: msg.content || "", tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: c.function })) });
|
|
@@ -262,19 +225,19 @@ async function runAgentTurn(userText, messages) {
|
|
|
262
225
|
trace(`[loop ${loops}] 工具 ${toolsMs}ms · 本轮总 ${loopMs}ms · 累计 ${Math.round(performance.now() - turnStart)}ms`);
|
|
263
226
|
loops++;
|
|
264
227
|
}
|
|
265
|
-
return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: false };
|
|
228
|
+
return { text: "(工具调用次数已达上限,已停止)", model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, fallbackGateway: lastFallbackGateway, ok: false };
|
|
266
229
|
}
|
|
267
230
|
|
|
268
|
-
function printFooter({ model, latency, usage, totalMs, fallback }) {
|
|
231
|
+
function printFooter({ model, latency, usage, totalMs, fallback, fallbackGateway, viaGateway }) {
|
|
269
232
|
const dim = "\x1b[90m";
|
|
270
233
|
const rst = "\x1b[0m";
|
|
271
234
|
let gw = null;
|
|
272
235
|
try { gw = collectStats(); } catch {}
|
|
273
|
-
const modelLabel = model || "—";
|
|
236
|
+
const modelLabel = model ? (fallbackGateway || viaGateway ? `${model} (gateway auto)` : model) : "—";
|
|
274
237
|
const latLabel = latency ? `${latency}ms` : "—";
|
|
275
238
|
const totalLabel = totalMs ? ` · 总耗时 ${totalMs}ms` : "";
|
|
276
239
|
const tokLabel = usage ? ` · tokens ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
|
|
277
|
-
const fbLabel = fallback ? " · fallback" : "";
|
|
240
|
+
const fbLabel = fallbackGateway || viaGateway ? " · gateway-fallback" : fallback ? " · fallback" : "";
|
|
278
241
|
let gwLabel = "";
|
|
279
242
|
let extra = "";
|
|
280
243
|
if (gw) {
|
package/src/chat/stats.js
CHANGED
|
@@ -150,7 +150,7 @@ export function formatBannerLines() {
|
|
|
150
150
|
const green = "\x1b[32m";
|
|
151
151
|
const lines = [];
|
|
152
152
|
lines.push(`${cyan}┌─ mslxdff chat · 数据来自 -d 网关进程(非本会话) ─────${rst}`);
|
|
153
|
-
lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall}
|
|
153
|
+
lines.push(`${cyan}│${rst} 对话模型 ${yellow}${s.chatPref}${rst} ${dim}→ ${s.chatFall} → gateway auto:8989${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst} ${dim}三级兜底${rst}`);
|
|
154
154
|
lines.push(`${cyan}│${rst} 网关默认 ${green}${s.gatewayModel}${rst} ${dim}[${s.gatewayStatus}]${rst} · 端口 ${s.port} · ${dim}${s.endpointUrl}${rst}`);
|
|
155
155
|
const prefTtfb = s.chatPrefStat?.avgTtfbMs ?? s.chatPrefStat?.emaTtfbMs ?? s.chatPrefLat?.emaMs;
|
|
156
156
|
const fallTtfb = s.chatFallStat?.avgTtfbMs ?? s.chatFallStat?.emaTtfbMs ?? s.chatFallLat?.emaMs;
|
package/src/chat/upstream.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { performance } from "node:perf_hooks";
|
|
2
|
-
import { CHAT_PREFERRED, CHAT_FALLBACK, CHAT_TIMEOUT_MS } from "./config.js";
|
|
2
|
+
import { CHAT_PREFERRED, CHAT_FALLBACK, CHAT_TIMEOUT_MS, CHAT_GATEWAY_TIMEOUT_MS } from "./config.js";
|
|
3
3
|
import { createUpstreamClient } from "../upstream.js";
|
|
4
|
+
import { DEFAULT_PORT } from "../state.js";
|
|
4
5
|
|
|
5
6
|
function modelForAttempt(attempt) {
|
|
6
7
|
return attempt === 0 ? CHAT_PREFERRED : CHAT_FALLBACK;
|
|
@@ -56,25 +57,202 @@ async function chatOnceNoTools({ messages, model }) {
|
|
|
56
57
|
} finally { try { await client.close(); } catch {} }
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
//
|
|
60
|
+
// 带自动降级:mimo-v2.5-free → big-pickle → 本地 8989 auto(网关 auto,含多供应商择优/hedge/peer)
|
|
61
|
+
async function chatViaGateway({ messages, tools }) {
|
|
62
|
+
const TRACE = process.env.MSLXDFF_CHAT_TRACE !== "0";
|
|
63
|
+
const t0 = TRACE ? performance.now() : 0;
|
|
64
|
+
let port = DEFAULT_PORT;
|
|
65
|
+
let token = "";
|
|
66
|
+
try {
|
|
67
|
+
const state = await import("../state.js");
|
|
68
|
+
const loaded = await state.loadToken();
|
|
69
|
+
token = String(loaded?.token || "").trim();
|
|
70
|
+
const p = state.getPort();
|
|
71
|
+
if (Number.isInteger(p) && p > 0) port = p;
|
|
72
|
+
else if (Number.isInteger(Number(process.env.MSLXDFF_PORT)) && Number(process.env.MSLXDFF_PORT) > 0) port = Number(process.env.MSLXDFF_PORT);
|
|
73
|
+
} catch {}
|
|
74
|
+
// token 为空则尝试直接读 state 文件兜底(避免 loadToken 异常时无 token)
|
|
75
|
+
if (!token) {
|
|
76
|
+
try {
|
|
77
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
78
|
+
const { join } = await import("node:path");
|
|
79
|
+
const { homedir } = await import("node:os");
|
|
80
|
+
const sf = process.env.MSLXDFF_STATE_FILE || join(homedir(), ".config", "mslxdff", "state.json");
|
|
81
|
+
if (existsSync(sf)) {
|
|
82
|
+
const j = JSON.parse(readFileSync(sf, "utf8"));
|
|
83
|
+
if (typeof j.token === "string" && j.token.trim()) token = j.token.trim();
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
const url = `http://127.0.0.1:${port}/v1/chat/completions`;
|
|
88
|
+
const body = { model: "auto", messages, stream: false };
|
|
89
|
+
if (tools?.length) {
|
|
90
|
+
body.tools = tools;
|
|
91
|
+
body.tool_choice = "auto";
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timer = setTimeout(() => controller.abort(), CHAT_GATEWAY_TIMEOUT_MS);
|
|
96
|
+
const res = await fetch(url, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
signal: controller.signal,
|
|
101
|
+
});
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
const txt = await res.text();
|
|
104
|
+
let j;
|
|
105
|
+
try { j = JSON.parse(txt); } catch {
|
|
106
|
+
// 网关可能因 workbuddy 强制 stream:true 而返回 SSE(text/event-stream),需兼容
|
|
107
|
+
if (txt.includes("data:")) {
|
|
108
|
+
try {
|
|
109
|
+
const lines = txt.split(/\r?\n/);
|
|
110
|
+
let content = "";
|
|
111
|
+
let model = "auto";
|
|
112
|
+
let usage = null;
|
|
113
|
+
let sseOk = false;
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
const t = String(line).trim();
|
|
116
|
+
if (!t.startsWith("data:")) continue;
|
|
117
|
+
const payload = t.slice(5).trim();
|
|
118
|
+
if (!payload || payload === "[DONE]") continue;
|
|
119
|
+
try {
|
|
120
|
+
const obj = JSON.parse(payload);
|
|
121
|
+
sseOk = true;
|
|
122
|
+
const ch = obj.choices?.[0];
|
|
123
|
+
// 兼容 thinking 模型的 reasoning_content(delta 阶段 content 为空,实际在 reasoning_content)
|
|
124
|
+
if (ch?.delta?.content) content += ch.delta.content;
|
|
125
|
+
else if (ch?.delta?.reasoning_content) content += ch.delta.reasoning_content;
|
|
126
|
+
else if (ch?.message?.content) content += ch.message.content;
|
|
127
|
+
else if (ch?.message?.reasoning_content) content += ch.message.reasoning_content;
|
|
128
|
+
else if (typeof ch?.text === "string") content += ch.text;
|
|
129
|
+
else if (typeof obj.content === "string") content += obj.content;
|
|
130
|
+
if (obj.model) model = obj.model;
|
|
131
|
+
if (obj.usage) usage = obj.usage;
|
|
132
|
+
// 有些 SSE 直接是完整 chat.completion
|
|
133
|
+
if (obj.choices?.[0]?.message?.content && !content) content = obj.choices[0].message.content;
|
|
134
|
+
if (obj.choices?.[0]?.message?.reasoning_content && !content) content = obj.choices[0].message.reasoning_content;
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
if (sseOk && content) {
|
|
138
|
+
j = { id: `sse-${Date.now()}`, object: "chat.completion", model, choices: [{ index: 0, finish_reason: "stop", message: { role: "assistant", content } }], usage };
|
|
139
|
+
} else if (sseOk) {
|
|
140
|
+
// SSE 但无 content,按失败处理
|
|
141
|
+
return { ok: false, error: `gateway SSE no content: ${txt.slice(0, 800)}`, status: res.status };
|
|
142
|
+
} else {
|
|
143
|
+
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
return { ok: false, error: `gateway non-json: ${txt.slice(0, 800)}`, status: res.status };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
const msg = j?.error?.message || j?.error || j?.data?.error?.message || j?.data?.error || txt.slice(0, 800);
|
|
154
|
+
if (TRACE) {
|
|
155
|
+
const dt = Math.round(performance.now() - t0);
|
|
156
|
+
console.log(`\x1b[90m· [LLM] gateway auto FAIL · ${dt}ms · HTTP ${res.status} ${String(msg).slice(0, 80)}\x1b[0m`);
|
|
157
|
+
}
|
|
158
|
+
return { ok: false, error: msg, status: res.status };
|
|
159
|
+
}
|
|
160
|
+
// 兼容网关返回的两种形状:标准 {"choices":...} 与 workbuddy 聚合后的 {"data":{"choices":...}}
|
|
161
|
+
const choice = j.choices?.[0] || j.data?.choices?.[0];
|
|
162
|
+
const effectiveJ = j.choices ? j : (j.data?.choices ? j.data : j);
|
|
163
|
+
if (!choice) {
|
|
164
|
+
if (TRACE) console.log(`\x1b[90m· [gateway debug] no choice, txt=${txt.slice(0, 800)} · j=${JSON.stringify(j).slice(0, 800)}\x1b[0m`);
|
|
165
|
+
return { ok: false, error: `gateway no choice: ${txt.slice(0, 800)}`, status: res.status };
|
|
166
|
+
}
|
|
167
|
+
if (TRACE) {
|
|
168
|
+
const dt = Math.round(performance.now() - t0);
|
|
169
|
+
const m = effectiveJ.model || j.model || choice.message?.model || "auto";
|
|
170
|
+
console.log(`\x1b[90m· [LLM] gateway auto OK · ${dt}ms · 模型 ${m} · 总 ${Math.round(performance.now() - t0)}ms (gateway-fallback)\x1b[0m`);
|
|
171
|
+
}
|
|
172
|
+
// 透传 usage/raw,并标记 gateway(兼容 data 包装)
|
|
173
|
+
return { ok: true, message: choice.message, usage: effectiveJ.usage || j.usage, raw: j, status: res.status, model: effectiveJ.model || j.model || "auto", viaGateway: true };
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const msg = String(err?.message || err).slice(0, 800);
|
|
176
|
+
if (TRACE) {
|
|
177
|
+
const dt = Math.round(performance.now() - t0);
|
|
178
|
+
console.log(`\x1b[90m· [LLM] gateway auto FAIL · ${dt}ms · ${msg.slice(0, 80)}\x1b[0m`);
|
|
179
|
+
}
|
|
180
|
+
return { ok: false, error: `gateway ${msg}`, status: 502 };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 带自动降级:mimo-v2.5-free → big-pickle → 本地 8989 auto(网关 auto,含多供应商择优/hedge/peer)
|
|
185
|
+
async function safeChatOnce(opts, model) {
|
|
186
|
+
try {
|
|
187
|
+
const r = await chatOnce({ ...opts, model });
|
|
188
|
+
return r;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const msg = String(err?.message || err).slice(0, 800);
|
|
191
|
+
const status = err?._t ? 502 : (err?.cause?.code ? 502 : 502);
|
|
192
|
+
return { ok: false, error: msg, status, _thrown: err };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function isCoolingAsync(id) {
|
|
197
|
+
try {
|
|
198
|
+
const state = await import("../state.js");
|
|
199
|
+
const errors = state.loadModelErrors();
|
|
200
|
+
const e = errors[id];
|
|
201
|
+
if (!e || typeof e !== "object") return false;
|
|
202
|
+
const at = Number(e.at || 0);
|
|
203
|
+
if (!at) return false;
|
|
204
|
+
const isSlow = !!e.slow;
|
|
205
|
+
const cd = isSlow ? 5 * 60 * 1000 : 60 * 1000;
|
|
206
|
+
return Date.now() - at < cd && (e.status === "limit" || e.status === "error");
|
|
207
|
+
} catch { return false; }
|
|
208
|
+
}
|
|
209
|
+
|
|
60
210
|
export async function chatWithFallback(opts) {
|
|
61
211
|
const TRACE = process.env.MSLXDFF_CHAT_TRACE !== "0";
|
|
62
212
|
const t0 = TRACE ? performance.now() : 0;
|
|
63
|
-
|
|
213
|
+
// 若上次已确认冷却(429/limit),直接跳过,避免 7+7 秒白等,第二次直接走“上次成功”的网关
|
|
214
|
+
const firstCooling = await isCoolingAsync(CHAT_PREFERRED);
|
|
215
|
+
let first;
|
|
216
|
+
if (firstCooling) {
|
|
217
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} 跳过(冷却中)· 直接试 ${CHAT_FALLBACK}\x1b[0m`);
|
|
218
|
+
first = { ok: false, error: "skip cooling", status: 429 };
|
|
219
|
+
} else {
|
|
220
|
+
first = await safeChatOnce(opts, CHAT_PREFERRED);
|
|
221
|
+
}
|
|
64
222
|
if (TRACE) {
|
|
65
223
|
const dt = Math.round(performance.now() - t0);
|
|
66
224
|
console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} ${first.ok ? "OK" : "FAIL"} · ${dt}ms${first.ok ? "" : ` · ${String(first.error).slice(0, 80)}`}\x1b[0m`);
|
|
67
225
|
}
|
|
68
226
|
if (first.ok) return { ...first, model: CHAT_PREFERRED };
|
|
69
227
|
const t1 = TRACE ? performance.now() : 0;
|
|
70
|
-
const
|
|
228
|
+
const secondCooling = await isCoolingAsync(CHAT_FALLBACK);
|
|
229
|
+
let second;
|
|
230
|
+
if (secondCooling && firstCooling) {
|
|
231
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto\x1b[0m`);
|
|
232
|
+
second = { ok: false, error: "skip cooling", status: 429 };
|
|
233
|
+
} else if (secondCooling) {
|
|
234
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} 跳过(冷却中)· 直接走网关 auto\x1b[0m`);
|
|
235
|
+
second = { ok: false, error: "skip cooling", status: 429 };
|
|
236
|
+
} else {
|
|
237
|
+
second = await safeChatOnce(opts, CHAT_FALLBACK);
|
|
238
|
+
}
|
|
71
239
|
if (TRACE) {
|
|
72
240
|
const dt = Math.round(performance.now() - t1);
|
|
73
241
|
const total = Math.round(performance.now() - t0);
|
|
74
242
|
console.log(`\x1b[90m· [LLM] ${CHAT_FALLBACK} ${second.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${total}ms (fallback)\x1b[0m`);
|
|
75
243
|
}
|
|
76
244
|
if (second.ok) return { ...second, model: CHAT_FALLBACK, fallback: true, firstError: first.error };
|
|
77
|
-
|
|
245
|
+
// 两者皆失败 → 兜底到本地网关 auto(会走 auto 择优、hedge、peer 等完整链路)
|
|
246
|
+
const t2 = TRACE ? performance.now() : 0;
|
|
247
|
+
if (TRACE) console.log(`\x1b[90m· [LLM] ${CHAT_PREFERRED} + ${CHAT_FALLBACK} 均失败,尝试本地网关 auto(:8989)\x1b[0m`);
|
|
248
|
+
const third = await chatViaGateway(opts);
|
|
249
|
+
if (TRACE) {
|
|
250
|
+
const dt = Math.round(performance.now() - t2);
|
|
251
|
+
const total = Math.round(performance.now() - t0);
|
|
252
|
+
console.log(`\x1b[90m· [LLM] gateway auto ${third.ok ? "OK" : "FAIL"} · ${dt}ms · 总 ${total}ms (gateway-fallback)\x1b[0m`);
|
|
253
|
+
}
|
|
254
|
+
if (third.ok) return { ...third, model: third.model || "auto", fallbackGateway: true, fallback: true, firstError: first.error, secondError: second.error, viaGateway: true };
|
|
255
|
+
return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${second.error}; gateway auto failed: ${third.error}`, status: third.status || second.status || first.status };
|
|
78
256
|
}
|
|
79
257
|
|
|
80
258
|
// 压缩用:简短摘要请求(不带 tools),128k 上下文下仅 95% 触发,需完整摘要
|
package/src/providers/generic.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { joinModelId } from "./model-id.js";
|
|
2
2
|
import { createKeyRing } from "./keyring.js";
|
|
3
|
-
import { loadProviderKeys, loadProviderBaseUrl } from "../state.js";
|
|
3
|
+
import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
|
|
4
|
+
|
|
5
|
+
function joinUrl(base, path) {
|
|
6
|
+
const b = String(base || "").trim().replace(/\/+$/, "");
|
|
7
|
+
const p = String(path || "").trim();
|
|
8
|
+
if (!p) return b;
|
|
9
|
+
const pp = p.startsWith("/") ? p : `/${p}`;
|
|
10
|
+
return `${b}${pp}`;
|
|
11
|
+
}
|
|
4
12
|
|
|
5
13
|
let UndiciAgent = null;
|
|
6
14
|
let UndiciFetch = null;
|
|
@@ -36,6 +44,8 @@ export function createGenericProvider({
|
|
|
36
44
|
baseUrl,
|
|
37
45
|
apiKeys,
|
|
38
46
|
apiKey,
|
|
47
|
+
modelsPath,
|
|
48
|
+
chatPath,
|
|
39
49
|
connectTimeoutMs = Number(process.env.MSLXDFF_GENERIC_TIMEOUT_MS) || 30_000,
|
|
40
50
|
cooldownMs = envInt("MSLXDFF_GENERIC_COOLDOWN_MS", 30_000),
|
|
41
51
|
retry = {
|
|
@@ -48,11 +58,14 @@ export function createGenericProvider({
|
|
|
48
58
|
fetchImpl,
|
|
49
59
|
headers: extraHeaders,
|
|
50
60
|
noAgent = false,
|
|
61
|
+
file,
|
|
51
62
|
} = {}) {
|
|
52
63
|
if (!id) throw new Error("generic provider requires id");
|
|
53
64
|
const resolvedBase = resolveBaseUrl(id, baseUrl);
|
|
54
65
|
if (!resolvedBase) throw new Error(`generic provider ${id}: missing baseUrl`);
|
|
55
66
|
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
67
|
+
const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
|
|
68
|
+
const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
|
|
56
69
|
|
|
57
70
|
const ring = createKeyRing(collectApiKeys(id, apiKeys, apiKey), { cooldownMs });
|
|
58
71
|
|
|
@@ -98,7 +111,7 @@ export function createGenericProvider({
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
async function runChat(body, activeRing, sourceKey) {
|
|
101
|
-
const url =
|
|
114
|
+
const url = joinUrl(resolvedBase, resolvedChatPath);
|
|
102
115
|
const t0 = performance.now();
|
|
103
116
|
const attempts = [];
|
|
104
117
|
let waitMs = 0;
|
|
@@ -156,7 +169,7 @@ export function createGenericProvider({
|
|
|
156
169
|
async function listModels() {
|
|
157
170
|
const now = Date.now();
|
|
158
171
|
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
159
|
-
const url =
|
|
172
|
+
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
160
173
|
const controller = new AbortController();
|
|
161
174
|
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
162
175
|
try {
|
|
@@ -181,7 +194,7 @@ export function createGenericProvider({
|
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
async function preheat() {
|
|
184
|
-
const url =
|
|
197
|
+
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
185
198
|
const t0 = performance.now();
|
|
186
199
|
try {
|
|
187
200
|
const headers = { Accept: "application/json" };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { joinModelId } from "./model-id.js";
|
|
2
2
|
import { createKeyRing } from "./keyring.js";
|
|
3
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL } from "../state.js";
|
|
3
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
|
|
4
4
|
import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, statSync } from "node:fs";
|
|
5
5
|
import { join, dirname } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
@@ -14,6 +14,13 @@ try {
|
|
|
14
14
|
UndiciFetch = mod.fetch;
|
|
15
15
|
} catch {}
|
|
16
16
|
|
|
17
|
+
function joinUrl(base, path) {
|
|
18
|
+
const b = String(base || "").trim().replace(/\/+$/, "");
|
|
19
|
+
const p = String(path || "").trim();
|
|
20
|
+
if (!p) return b;
|
|
21
|
+
const pp = p.startsWith("/") ? p : `/${p}`;
|
|
22
|
+
return `${b}${pp}`;
|
|
23
|
+
}
|
|
17
24
|
function envInt(name, fallback) {
|
|
18
25
|
const v = Number(process.env[name]);
|
|
19
26
|
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
@@ -120,6 +127,8 @@ export function createWorkbuddyProvider({
|
|
|
120
127
|
apiKeys,
|
|
121
128
|
apiKey,
|
|
122
129
|
auths,
|
|
130
|
+
modelsPath,
|
|
131
|
+
chatPath,
|
|
123
132
|
connectTimeoutMs = Number(process.env.MSLXDFF_WORKBUDDY_TIMEOUT_MS) || 30_000,
|
|
124
133
|
cooldownMs = envInt("MSLXDFF_WORKBUDDY_COOLDOWN_MS", 30_000),
|
|
125
134
|
retry = {
|
|
@@ -134,6 +143,8 @@ export function createWorkbuddyProvider({
|
|
|
134
143
|
} = {}) {
|
|
135
144
|
const id = "workbuddy";
|
|
136
145
|
const resolvedBase = resolveBaseUrl(baseUrl);
|
|
146
|
+
const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
|
|
147
|
+
const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
|
|
137
148
|
if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
|
|
138
149
|
|
|
139
150
|
// keys 优先显式传入,其次 state
|
|
@@ -201,7 +212,7 @@ export function createWorkbuddyProvider({
|
|
|
201
212
|
const rt = auth?.refreshToken;
|
|
202
213
|
const uid = auth?.uid;
|
|
203
214
|
if (!rt || !uid) return null;
|
|
204
|
-
const url =
|
|
215
|
+
const url = joinUrl(resolvedBase, "/v2/plugin/auth/token/refresh");
|
|
205
216
|
const headers = {
|
|
206
217
|
"Content-Type": "application/json",
|
|
207
218
|
Authorization: `Bearer ${key}`,
|
|
@@ -268,7 +279,7 @@ export function createWorkbuddyProvider({
|
|
|
268
279
|
}
|
|
269
280
|
|
|
270
281
|
async function runChat(body, activeRing, opts = {}) {
|
|
271
|
-
const url =
|
|
282
|
+
const url = joinUrl(resolvedBase, resolvedChatPath);
|
|
272
283
|
const t0 = performance.now();
|
|
273
284
|
const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
|
|
274
285
|
const modelForLog = body?.model || "";
|
|
@@ -488,7 +499,7 @@ export function createWorkbuddyProvider({
|
|
|
488
499
|
async function listModels() {
|
|
489
500
|
const now = Date.now();
|
|
490
501
|
if (cache && now - fetchedAt < CACHE_TTL_MS) return cache;
|
|
491
|
-
const url =
|
|
502
|
+
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
492
503
|
const controller = new AbortController();
|
|
493
504
|
const timer = setTimeout(() => controller.abort(new Error(`${id} models timed out`)), 15_000);
|
|
494
505
|
try {
|
|
@@ -523,7 +534,7 @@ export function createWorkbuddyProvider({
|
|
|
523
534
|
}
|
|
524
535
|
|
|
525
536
|
async function preheat() {
|
|
526
|
-
const url =
|
|
537
|
+
const url = joinUrl(resolvedBase, resolvedModelsPath);
|
|
527
538
|
const t0 = performance.now();
|
|
528
539
|
try {
|
|
529
540
|
const key = ring.next() || keys[0] || "";
|
package/src/routes/index.js
CHANGED
|
@@ -4,12 +4,21 @@ import { json, notFound, authorized } from "./helpers.js";
|
|
|
4
4
|
import { chatHandler } from "./chat.js";
|
|
5
5
|
import { joinHandler, leaveHandler } from "./groups.js";
|
|
6
6
|
import { heartbeatHandler, pollHandler, resultHandler, forwardHandler } from "./groups-relay.js";
|
|
7
|
-
import { modelsHandler, modelsStatusHandler } from "./models-route.js";
|
|
7
|
+
import { modelsHandler, modelsStatusHandler, providerModelsHandler } from "./models-route.js";
|
|
8
8
|
|
|
9
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];
|
|
13
|
+
// dynamic provider models route: GET /v1/providers/:id/models
|
|
14
|
+
if (method === "GET" && /^\/v1\/providers\/[^/]+\/models\/?$/.test(path)) {
|
|
15
|
+
if (!authorized(req, token)) {
|
|
16
|
+
res.statusCode = 401;
|
|
17
|
+
res.setHeader("WWW-Authenticate", "Bearer");
|
|
18
|
+
return json(res, 401, { error: "Unauthorized" });
|
|
19
|
+
}
|
|
20
|
+
return providerModelsHandler({ req, res, models, upstream });
|
|
21
|
+
}
|
|
13
22
|
const route = ROUTES.find((r) => r.method === method && r.path === path);
|
|
14
23
|
if (!route) return notFound(res);
|
|
15
24
|
if (route.requiresAuth && !authorized(req, token)) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { json, errMsg } from "./helpers.js";
|
|
2
2
|
import { runHook } from "../plugins.js";
|
|
3
|
+
import { isModelAllowed } from "../state.js";
|
|
3
4
|
|
|
4
5
|
export async function modelsHandler({ res, models, plugins }) {
|
|
5
6
|
if (!models) return json(res, 501, { error: "Models service not configured" });
|
|
@@ -42,3 +43,30 @@ export async function modelsStatusHandler({ res, models, auto }) {
|
|
|
42
43
|
}
|
|
43
44
|
json(res, 200, { object: "list", data });
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
export async function providerModelsHandler({ req, res, models }) {
|
|
48
|
+
if (!models) return json(res, 501, { error: "Models service not configured" });
|
|
49
|
+
const url = req.url || "";
|
|
50
|
+
const path = url.split("?")[0] || "";
|
|
51
|
+
const m = path.match(/^\/v1\/providers\/([^/]+)\/models\/?$/);
|
|
52
|
+
const pid = m ? decodeURIComponent(m[1]).toLowerCase() : "";
|
|
53
|
+
if (!pid) return json(res, 400, { error: "missing provider id" });
|
|
54
|
+
try {
|
|
55
|
+
const data = await models.get();
|
|
56
|
+
const all = Array.isArray(data?.data) ? data.data : [];
|
|
57
|
+
const filtered = all.filter((entry) => {
|
|
58
|
+
const id = String(entry?.id || "");
|
|
59
|
+
const slash = id.indexOf("/");
|
|
60
|
+
const prov = slash > 0 ? id.slice(0, slash).toLowerCase() : "opencode";
|
|
61
|
+
if (prov !== pid) return false;
|
|
62
|
+
const raw = slash > 0 ? id.slice(slash + 1) : id;
|
|
63
|
+
// allowlist check: opencode always allowed via allowAny, others respect config
|
|
64
|
+
try {
|
|
65
|
+
return isModelAllowed(pid, raw);
|
|
66
|
+
} catch { return true; }
|
|
67
|
+
});
|
|
68
|
+
json(res, 200, { object: "list", data: filtered });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
json(res, 502, { error: errMsg(err) });
|
|
71
|
+
}
|
|
72
|
+
}
|