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/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
- function extractProvFromQuery(text) {
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
- const note = lastFallback ? "\n\x1b[90m[注:mimo 不可用,已用 big-pickle]\x1b[0m" : "";
162
- trace(`[turn] 完成 总计 ${totalMs}ms · LLM ${lastLatency}ms · 0 工具`);
163
- return { text: text + note, model: lastModel, latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
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}(自动降级)${rst} ${dim}[${s.chatPrefStatus}/${s.chatFallStatus}]${rst}`);
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;
@@ -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
- // 带自动降级:仅 mimo-v2.5-free → big-pickle,不引入其他模型
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
- const first = await chatOnce({ ...opts, model: CHAT_PREFERRED });
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 second = await chatOnce({ ...opts, model: CHAT_FALLBACK });
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
- return { ok: false, error: `${CHAT_PREFERRED} failed: ${first.error}; ${CHAT_FALLBACK} failed: ${second.error}`, status: second.status || first.status };
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% 触发,需完整摘要
@@ -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 = `${resolvedBase}/chat/completions`;
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 = `${resolvedBase}/models`;
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 = `${resolvedBase}/models`;
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 = `${resolvedBase}/v2/plugin/auth/token/refresh`;
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 = `${resolvedBase}/v2/chat/completions`;
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 = `${resolvedBase}/console/enterprises/personal/models`;
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 = `${resolvedBase}/console/enterprises/personal/models`;
537
+ const url = joinUrl(resolvedBase, resolvedModelsPath);
527
538
  const t0 = performance.now();
528
539
  try {
529
540
  const key = ring.next() || keys[0] || "";
@@ -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
+ }