mslxdff 0.1.62 → 0.1.63

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 CHANGED
@@ -232,8 +232,25 @@ if (args.includes("-model") || args.includes("-models")) {
232
232
  for (const id of picks) console.log(` ${id}`);
233
233
  process.exit(0);
234
234
  }
235
+ // -model list supports optional provider filter: -model list --provider <id> | -model list <id> | --json
236
+ let modelListProvider = null;
237
+ let modelListJson = false;
238
+ if (sub === "list") {
239
+ const restArgs = args.slice(idx + 2);
240
+ for (let i = 0; i < restArgs.length; i++) {
241
+ const a = String(restArgs[i] || "");
242
+ if (a === "--json" || a === "-json") modelListJson = true;
243
+ else if (a === "--provider" || a === "-provider" || a === "--providerId") { modelListProvider = String(restArgs[i + 1] || "").trim() || null; i++; }
244
+ else if (!a.startsWith("-") && !modelListProvider) modelListProvider = a;
245
+ }
246
+ if (modelListProvider) {
247
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
248
+ const nid = normalizeProviderId(modelListProvider);
249
+ modelListProvider = nid || modelListProvider.toLowerCase();
250
+ }
251
+ }
235
252
  if (sub !== undefined && sub !== "list") {
236
- console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status | mslxdff -model refresh");
253
+ console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list [--provider <id>] [--json] | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status | mslxdff -model refresh");
237
254
  process.exit(1);
238
255
  }
239
256
  const cacheFile = join(logDir(), "models.json");
@@ -275,12 +292,32 @@ if (args.includes("-model") || args.includes("-models")) {
275
292
  throw new Error("no cached models and refresh failed");
276
293
  }
277
294
  }
295
+ // provider filter: bare ids are opencode, prefixed are <provider>/...
296
+ if (modelListProvider) {
297
+ const prov = String(modelListProvider).toLowerCase();
298
+ ids = ids.filter((id) => {
299
+ const slash = String(id).indexOf("/");
300
+ const p = slash > 0 ? String(id).slice(0, slash).toLowerCase() : "opencode";
301
+ return p === prov;
302
+ });
303
+ if (modelListJson) {
304
+ console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
305
+ process.exit(0);
306
+ }
307
+ if (!ids.length) {
308
+ console.log(`no models for provider "${prov}" — try: mslxdff -provider ${prov} models or mslxdff -model refresh`);
309
+ process.exit(0);
310
+ }
311
+ } else if (modelListJson) {
312
+ console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
313
+ process.exit(0);
314
+ }
278
315
  if (!ids.length) {
279
316
  console.log("no models available — try: mslxdff -model refresh");
280
317
  process.exit(0);
281
318
  }
282
319
  // TTY:交互式多选勾选常用模型(空格勾选,Enter 保存);非 TTY(管道/脚本):保持纯列表并标注勾选
283
- if (process.stdin.isTTY && process.stdout.isTTY) {
320
+ if (process.stdin.isTTY && process.stdout.isTTY && !modelListProvider) {
284
321
  const statuses = loadModelErrors();
285
322
  const current = getPreferredModel();
286
323
  const pickedIds = loadModelPicks();
@@ -809,10 +846,24 @@ if (args.includes("-provider") || args.includes("--provider")) {
809
846
  const nid = normalizeProviderId(gid);
810
847
  if (!nid) { console.error(`invalid provider id: ${gid}`); process.exit(1); }
811
848
  if (!/^https?:\/\/.+/.test(String(gBase).trim())) { console.error(`invalid baseUrl: ${gBase} (must start with http:// or https://)`); process.exit(1); }
812
- const cur = loadProviderConfig(nid) || { baseUrl: "", keys: [], allowedModels: [], auths: [] };
849
+ const cur = loadProviderConfig(nid) || { baseUrl: "", keys: [], allowedModels: [], auths: [], modelsPath: "", chatPath: "" };
813
850
  let keys, auths, baseUrl;
814
851
  baseUrl = String(gBase).trim();
815
- const extraModels = rest.slice(3).filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
852
+ // parse --models-path / --chat-path from tail
853
+ let parsedModelsPath = null;
854
+ let parsedChatPath = null;
855
+ const extraTokens = [];
856
+ for (let _i = 3; _i < rest.length; _i++) {
857
+ const tok = String(rest[_i] || "");
858
+ if (tok === "--models-path" || tok === "--modelsPath" || tok === "--models_path") { parsedModelsPath = String(rest[_i + 1] || "").trim() || null; _i++; }
859
+ else if (tok.startsWith("--models-path=")) { parsedModelsPath = tok.slice("--models-path=".length).trim() || null; }
860
+ else if (tok === "--chat-path" || tok === "--chatPath" || tok === "--chat_path") { parsedChatPath = String(rest[_i + 1] || "").trim() || null; _i++; }
861
+ else if (tok.startsWith("--chat-path=")) { parsedChatPath = tok.slice("--chat-path=".length).trim() || null; }
862
+ else extraTokens.push(tok);
863
+ }
864
+ if (parsedModelsPath && !String(parsedModelsPath).startsWith("/")) { console.error(`invalid --models-path: ${parsedModelsPath} (must start with /)`); process.exit(1); }
865
+ if (parsedChatPath && !String(parsedChatPath).startsWith("/")) { console.error(`invalid --chat-path: ${parsedChatPath} (must start with /)`); process.exit(1); }
866
+ const extraModels = extraTokens.filter((x) => x && !String(x).startsWith("-")).map((m) => String(m).trim()).filter(Boolean);
816
867
  const allowedModels = extraModels.length ? [...new Set([...(cur.allowedModels || []), ...extraModels])] : (cur.allowedModels || []);
817
868
  if (nid === "workbuddy") {
818
869
  // workbuddy: keys/auths 一一对应,需解析 uid
@@ -838,7 +889,12 @@ if (args.includes("-provider") || args.includes("--provider")) {
838
889
  while (newKeys.length < newAuths.length) newKeys.push(token);
839
890
  }
840
891
  keys = newKeys; auths = newAuths;
841
- saveProviderConfig(nid, { baseUrl, keys, auths, allowedModels });
892
+ const cfgToSave = { baseUrl, keys, auths, allowedModels };
893
+ if (parsedModelsPath) cfgToSave.modelsPath = parsedModelsPath;
894
+ else if (cur.modelsPath) cfgToSave.modelsPath = cur.modelsPath;
895
+ if (parsedChatPath) cfgToSave.chatPath = parsedChatPath;
896
+ else if (cur.chatPath) cfgToSave.chatPath = cur.chatPath;
897
+ saveProviderConfig(nid, cfgToSave);
842
898
  // 同步写 auths/workbuddy-<uid>.json 供 checkin 使用
843
899
  try {
844
900
  const { writeFileSync, mkdirSync } = await import("node:fs");
@@ -860,7 +916,12 @@ if (args.includes("-provider") || args.includes("--provider")) {
860
916
  }
861
917
  keys = [...new Set([...(cur.keys || []), trimmed].filter(Boolean))];
862
918
  auths = undefined;
863
- saveProviderConfig(nid, { baseUrl, keys, allowedModels });
919
+ const cfgToSave2 = { baseUrl, keys, allowedModels };
920
+ if (parsedModelsPath) cfgToSave2.modelsPath = parsedModelsPath;
921
+ else if (cur.modelsPath) cfgToSave2.modelsPath = cur.modelsPath;
922
+ if (parsedChatPath) cfgToSave2.chatPath = parsedChatPath;
923
+ else if (cur.chatPath) cfgToSave2.chatPath = cur.chatPath;
924
+ saveProviderConfig(nid, cfgToSave2);
864
925
  }
865
926
  console.log(`added generic provider: ${nid}`);
866
927
  console.log(` baseUrl: ${String(gBase).trim().replace(/\/+$/, "")}`);
@@ -880,6 +941,40 @@ if (args.includes("-provider") || args.includes("--provider")) {
880
941
  process.exit(0);
881
942
  }
882
943
  const { loadProviderKeys, saveProviderKeys, addProviderKey, removeProviderKeys, loadProviderShareKeys, saveProviderShareKeys, loadProviderConfig, saveProviderConfig, saveProviderBaseUrl, loadProviderConfigs } = await import("../src/state.js");
944
+ if (sub === "set-models-path" || sub === "setModelsPath" || sub === "models-path") {
945
+ const p = rest[1];
946
+ if (!p) {
947
+ console.error(`usage: mslxdff -provider ${id} set-models-path <path> (e.g. /v1/models)`);
948
+ process.exit(1);
949
+ }
950
+ if (!String(p).trim().startsWith("/")) {
951
+ console.error(`invalid modelsPath: ${p} (must start with /)`);
952
+ process.exit(1);
953
+ }
954
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
955
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
956
+ const nid = normalizeProviderId(id);
957
+ saveProviderConfig(nid || id, { baseUrl: cur.baseUrl || "", keys: cur.keys || [], modelsPath: String(p).trim() });
958
+ console.log(`set ${nid || id} modelsPath: ${String(p).trim()} — restart daemon to activate`);
959
+ process.exit(0);
960
+ }
961
+ if (sub === "set-chat-path" || sub === "setChatPath" || sub === "chat-path") {
962
+ const p = rest[1];
963
+ if (!p) {
964
+ console.error(`usage: mslxdff -provider ${id} set-chat-path <path> (e.g. /v1/chat/completions)`);
965
+ process.exit(1);
966
+ }
967
+ if (!String(p).trim().startsWith("/")) {
968
+ console.error(`invalid chatPath: ${p} (must start with /)`);
969
+ process.exit(1);
970
+ }
971
+ const cur = loadProviderConfig(id) || { baseUrl: "", keys: [] };
972
+ const { normalizeProviderId } = await import("../src/providers/model-id.js");
973
+ const nid = normalizeProviderId(id);
974
+ saveProviderConfig(nid || id, { baseUrl: cur.baseUrl || "", keys: cur.keys || [], chatPath: String(p).trim() });
975
+ console.log(`set ${nid || id} chatPath: ${String(p).trim()} — restart daemon to activate`);
976
+ process.exit(0);
977
+ }
883
978
  if (sub === "clear") {
884
979
  const configs = loadProviderConfigs();
885
980
  if (configs[id]) {
@@ -1020,6 +1115,69 @@ if (args.includes("-provider") || args.includes("--provider")) {
1020
1115
  console.error(` mslxdff -provider ${id} allowAny on|off (empty allowlist = block or allow all)`);
1021
1116
  process.exit(1);
1022
1117
  }
1118
+ if (sub === "models" || sub === "list-models" || sub === "ls") {
1119
+ const wantsJson = args.includes("--json") || args.includes("-json");
1120
+ const cfg = loadProviderConfig(id);
1121
+ // opencode: use aggregated cache + provider-specific? For opencode, show bare ids from cache
1122
+ if (id === "opencode" || id === "oc") {
1123
+ try {
1124
+ const cacheFile = join(logDir(), "models.json");
1125
+ const { readFileSync } = await import("node:fs");
1126
+ const raw = JSON.parse(readFileSync(cacheFile, "utf8"));
1127
+ const ids = (raw.data || []).map((m) => m.id).filter((x) => !String(x).includes("/"));
1128
+ if (wantsJson) {
1129
+ console.log(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }, null, 2));
1130
+ } else {
1131
+ console.log(`opencode models (${ids.length}):`);
1132
+ for (const mid of ids) console.log(` ${mid}`);
1133
+ }
1134
+ } catch (e) {
1135
+ console.error(`could not read models cache: ${String(e?.message || e)}`);
1136
+ process.exit(1);
1137
+ }
1138
+ process.exit(0);
1139
+ }
1140
+ // generic/workbuddy: live fetch via provider listModels
1141
+ try {
1142
+ const { createGenericProvider } = await import("../src/providers/generic.js");
1143
+ const { createWorkbuddyProvider } = await import("../src/providers/workbuddy.js");
1144
+ const { isModelAllowed } = await import("../src/state.js");
1145
+ const baseUrl = cfg?.baseUrl || (id === "workbuddy" ? "https://copilot.tencent.com" : "");
1146
+ const keys = loadProviderKeys(id);
1147
+ const auths = cfg?.auths || [];
1148
+ let provider;
1149
+ if (id === "workbuddy") {
1150
+ provider = createWorkbuddyProvider({ baseUrl, apiKeys: keys, auths, file: defaultStateFile() });
1151
+ } else {
1152
+ if (!baseUrl) {
1153
+ console.error(`provider ${id}: missing baseUrl — set via: mslxdff -provider ${id} set-url <baseUrl>`);
1154
+ process.exit(1);
1155
+ }
1156
+ provider = createGenericProvider({ id, baseUrl, apiKeys: keys, file: defaultStateFile() });
1157
+ }
1158
+ const all = await provider.listModels();
1159
+ // apply allowlist filtering mirroring dispatcher
1160
+ const filtered = all.filter((m) => {
1161
+ const raw = String(m.id || "").includes("/") ? String(m.id).split("/").slice(1).join("/") : String(m.id);
1162
+ // for workbuddy, raw is like hy3, for generic it's raw id without prefix? joinModelId adds prefix, so need to extract raw
1163
+ const checkRaw = m.id.startsWith(`${id}/`) ? m.id.slice(id.length + 1) : raw;
1164
+ return isModelAllowed(id, checkRaw);
1165
+ });
1166
+ if (wantsJson) {
1167
+ console.log(JSON.stringify({ object: "list", data: filtered }, null, 2));
1168
+ } else {
1169
+ console.log(`${id} models (${filtered.length}${filtered.length !== all.length ? `/${all.length}` : ""}):`);
1170
+ for (const m of filtered) console.log(` ${m.id}`);
1171
+ if (!filtered.length && all.length) console.log(` (all ${all.length} filtered by allowlist — use: mslxdff -provider ${id} allowlist list)`);
1172
+ if (!all.length) console.log(` (no models — check baseUrl/keys or try: curl ${baseUrl}/models)`);
1173
+ }
1174
+ try { await provider.close?.(); } catch {}
1175
+ } catch (e) {
1176
+ console.error(`could not list ${id} models: ${String(e?.message || e)}`);
1177
+ process.exit(1);
1178
+ }
1179
+ process.exit(0);
1180
+ }
1023
1181
  if (sub === "list" || sub === "status") {
1024
1182
  const keys = loadProviderKeys(id);
1025
1183
  const cfg = loadProviderConfig(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,7 +52,7 @@ ${mini}
52
52
  - 永远输出精确的命令与模型 id,大小写敏感。
53
53
  - 需要执行命令时调用 run_command,需要看文件时调用 read_file,需要检查网络/服务可用性时调用 curl。
54
54
  - curl 简写:upstream(=上游 https://opencode.ai/zen/v1/models)、local/health(=本机 /health)、local/models(=本机 /v1/models),也支持完整 http(s) URL;会自动补上游头、本机 token 与已配置供应商 key(直连 https://api.b.ai/v1/models 会自动带 bai 的 key,无需手动加头)。
55
- - 查“某供应商有哪些模型”**禁止调用 run_command**:**直接用上方“可用模型”按前缀过滤回答**(如 workbuddy/ 开头的即 workbuddy 供应商,clinebot/ 开头即 clinebot),无需调工具;如需实时刷新,调 curl local/models(GET,自动带本机 token)看网关聚合列表。**错误示例**:用户问 workbuddy有哪些模型 → 调用 -provider workbuddy list(这是查配置,不是查模型!)→ 错。**正确**:直接列 workbuddy/ 前缀的可用模型。禁止为此调用 -showtoken(本机 token 已自动注入)。
55
+ - 查“某供应商有哪些模型”**优先用 CLI 直查**:若上方“可用模型”已能回答,直接前缀过滤回答(如 workbuddy/ workbuddy);需实时拉取时调用 run_command: "-provider <id> models"(如 -provider workbuddy models)或 "-model list --provider <id>",按 allowlist 过滤,--json 供脚本。**禁止**调 -provider <id> list(这是查配置,不是查模型!)。**错误示例**:workbuddy有哪些模型 → -provider workbuddy list 错。**正确**:-provider workbuddy models。禁止为此调用 -showtoken
56
56
  - 严禁幻觉命令:mslxdff "hi" --model X / mslxdff --model X "hi" / mslxdff -chat --model X 都不存在,输出只会是 status 页。探活任意模型(含 clinebot/*、workbuddy/*、bai/*)必须用 curl POST http://localhost:8989/v1/chat/completions,body 为 {"model":"<前缀/模型>","messages":[{"role":"user","content":"hi"}],"stream":false},成功 200 + x-mslxdff-via:local 即通;401 代表本机 token 陈旧需提示 mslxdff -stop && mslxdff;403 + x-mslxdff-allowlist:1 代表白名单未放行需 allowlist add。
57
57
  - **禁止重复调用(最高优先级)**:同一 run_command/curl/read_file 在本轮只执行一次,重复会被工具侧 SKIPPED_DUP 拦截;查询类(-showtoken/-status/-provider list/-providers list/-model list/-group list/-log 等)**调用一次即答案**,拿到 OK 结果后必须**立即用中文直接回答用户**,禁止再发起任何工具调用。收到 SKIPPED_DUP 或“请直接回答/禁止再调用”提示时,必须 0 工具直接回答。
58
58
  - 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。
@@ -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
+ }
package/src/state.js CHANGED
@@ -395,6 +395,29 @@ function normalizeAllowedModel(model, providerId) {
395
395
  }
396
396
  return s;
397
397
  }
398
+ function normalizeEndpointPath(v) {
399
+ const s = String(v || "").trim();
400
+ if (!s) return "";
401
+ return s.startsWith("/") ? s : `/${s}`;
402
+ }
403
+ function defaultModelsPath(id) {
404
+ if (String(id).toLowerCase() === "workbuddy") return "/console/enterprises/personal/models";
405
+ return "/models";
406
+ }
407
+ function defaultChatPath(id) {
408
+ if (String(id).toLowerCase() === "workbuddy") return "/v2/chat/completions";
409
+ return "/chat/completions";
410
+ }
411
+ export function loadProviderModelsPath(id, { file = defaultStateFile() } = {}) {
412
+ const cfg = loadProviderConfigs({ file })[id];
413
+ if (cfg && typeof cfg.modelsPath === "string" && cfg.modelsPath.trim()) return normalizeEndpointPath(cfg.modelsPath);
414
+ return defaultModelsPath(id);
415
+ }
416
+ export function loadProviderChatPath(id, { file = defaultStateFile() } = {}) {
417
+ const cfg = loadProviderConfigs({ file })[id];
418
+ if (cfg && typeof cfg.chatPath === "string" && cfg.chatPath.trim()) return normalizeEndpointPath(cfg.chatPath);
419
+ return defaultChatPath(id);
420
+ }
398
421
 
399
422
  export function loadProviderConfigs({ file = defaultStateFile() } = {}) {
400
423
  const v = readState(file).providerConfigs;
@@ -411,7 +434,9 @@ export function loadProviderConfig(id, { file = defaultStateFile() } = {}) {
411
434
  const cfg = loadProviderConfigs({ file })[id];
412
435
  const allowedModels = cfg && Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
413
436
  const auths = cfg && Array.isArray(cfg.auths) ? normalizeAuths(cfg.auths) : [];
414
- if (baseUrl || envKeys.length || allowedModels.length || auths.length) return { baseUrl: normalizeBaseUrl(baseUrl), keys: envKeys, auths, allowedModels };
437
+ const modelsPath = cfg && typeof cfg.modelsPath === "string" && cfg.modelsPath.trim() ? normalizeEndpointPath(cfg.modelsPath) : defaultModelsPath(id);
438
+ const chatPath = cfg && typeof cfg.chatPath === "string" && cfg.chatPath.trim() ? normalizeEndpointPath(cfg.chatPath) : defaultChatPath(id);
439
+ if (baseUrl || envKeys.length || allowedModels.length || auths.length) return { baseUrl: normalizeBaseUrl(baseUrl), keys: envKeys, auths, allowedModels, modelsPath, chatPath };
415
440
  return null;
416
441
  }
417
442
  const configs = loadProviderConfigs({ file });
@@ -422,11 +447,13 @@ export function loadProviderConfig(id, { file = defaultStateFile() } = {}) {
422
447
  keys: Array.isArray(cfg.keys) ? [...new Set(cfg.keys.filter((x) => typeof x === "string" && x.trim().length))] : [],
423
448
  auths: normalizeAuths(cfg.auths),
424
449
  allowedModels: Array.isArray(cfg.allowedModels) ? [...new Set(cfg.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [],
450
+ modelsPath: typeof cfg.modelsPath === "string" && cfg.modelsPath.trim() ? normalizeEndpointPath(cfg.modelsPath) : defaultModelsPath(id),
451
+ chatPath: typeof cfg.chatPath === "string" && cfg.chatPath.trim() ? normalizeEndpointPath(cfg.chatPath) : defaultChatPath(id),
425
452
  };
426
453
  }
427
454
  // 兼容旧 providerKeys 形态:有 key 但无 configs 时视为通用供应商(baseUrl 为空,需后补)
428
455
  const keys = loadProviderKeys(id, { file });
429
- if (keys.length) return { baseUrl: String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "", keys, auths: [], allowedModels: [] };
456
+ if (keys.length) return { baseUrl: String(id).toLowerCase() === "workbuddy" ? WORKBUDDY_DEFAULT_BASE_URL : "", keys, auths: [], allowedModels: [], modelsPath: defaultModelsPath(id), chatPath: defaultChatPath(id) };
430
457
  return null;
431
458
  }
432
459
 
@@ -451,12 +478,16 @@ export function saveProviderAuths(id, list, { file = defaultStateFile() } = {})
451
478
  const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
452
479
  const keys = Array.isArray(cur.keys) ? [...new Set(cur.keys.filter((x) => typeof x === "string" && x.trim().length))] : loadProviderKeys(id, { file });
453
480
  const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
454
- if (!baseUrl && !keys.length && !clean.length && !allowedModels.length) {
481
+ const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
482
+ const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
483
+ if (!baseUrl && !keys.length && !clean.length && !allowedModels.length && !modelsPath && !chatPath) {
455
484
  delete configs[id];
456
485
  } else {
457
486
  configs[id] = { baseUrl, keys };
458
487
  if (clean.length) configs[id].auths = clean;
459
488
  if (allowedModels.length) configs[id].allowedModels = allowedModels;
489
+ if (modelsPath) configs[id].modelsPath = modelsPath;
490
+ if (chatPath) configs[id].chatPath = chatPath;
460
491
  }
461
492
  writeStateImmediate(file, { providerConfigs: configs });
462
493
  return clean;
@@ -469,33 +500,43 @@ export function saveProviderBaseUrl(id, baseUrl, { file = defaultStateFile() } =
469
500
  const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
470
501
  const auths = normalizeAuths(cur.auths);
471
502
  const allowedModels = Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : [];
472
- if (!clean && !keys.length && !auths.length && !allowedModels.length) {
503
+ const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
504
+ const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
505
+ if (!clean && !keys.length && !auths.length && !allowedModels.length && !modelsPath && !chatPath) {
473
506
  delete configs[id];
474
507
  } else {
475
508
  configs[id] = { baseUrl: clean, keys };
476
509
  if (auths.length) configs[id].auths = auths;
477
510
  if (allowedModels.length) configs[id].allowedModels = allowedModels;
511
+ if (modelsPath) configs[id].modelsPath = modelsPath;
512
+ if (chatPath) configs[id].chatPath = chatPath;
478
513
  }
479
514
  writeStateImmediate(file, { providerConfigs: configs });
480
515
  return clean;
481
516
  }
482
517
 
483
- export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels }, { file = defaultStateFile() } = {}) {
518
+ export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels, modelsPath, chatPath }, { file = defaultStateFile() } = {}) {
484
519
  const cleanUrl = normalizeBaseUrl(baseUrl);
485
520
  const cleanKeys = [...new Set((Array.isArray(keys) ? keys : []).map((k) => String(k || "").trim()).filter(Boolean))];
486
521
  const cleanAuths = auths === undefined ? undefined : normalizeAuths(auths);
487
522
  const cleanAllowed = [...new Set((Array.isArray(allowedModels) ? allowedModels : []).map((m) => normalizeAllowedModel(m, id)).filter(Boolean))];
523
+ const cleanModelsPath = modelsPath === undefined ? undefined : (String(modelsPath).trim() ? normalizeEndpointPath(modelsPath) : "");
524
+ const cleanChatPath = chatPath === undefined ? undefined : (String(chatPath).trim() ? normalizeEndpointPath(chatPath) : "");
488
525
  const configs = { ...loadProviderConfigs({ file }) };
489
526
  const cur = configs[id] && typeof configs[id] === "object" ? configs[id] : {};
490
- // 保留已有的 allowedModels / auths 若本次未传入
527
+ // 保留已有的 allowedModels / auths / paths 若本次未传入
491
528
  const finalAllowed = allowedModels === undefined ? (Array.isArray(cur.allowedModels) ? [...new Set(cur.allowedModels.map((m) => normalizeAllowedModel(m, id)).filter(Boolean))] : []) : cleanAllowed;
492
529
  const finalAuths = cleanAuths === undefined ? normalizeAuths(cur.auths) : cleanAuths;
493
- if (!cleanUrl && !cleanKeys.length && !finalAllowed.length && !finalAuths.length) {
530
+ const finalModelsPath = cleanModelsPath === undefined ? (typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "") : cleanModelsPath;
531
+ const finalChatPath = cleanChatPath === undefined ? (typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "") : cleanChatPath;
532
+ if (!cleanUrl && !cleanKeys.length && !finalAllowed.length && !finalAuths.length && !finalModelsPath && !finalChatPath) {
494
533
  delete configs[id];
495
534
  } else {
496
535
  configs[id] = { baseUrl: cleanUrl, keys: cleanKeys };
497
536
  if (finalAuths.length) configs[id].auths = finalAuths;
498
537
  if (finalAllowed.length) configs[id].allowedModels = finalAllowed;
538
+ if (finalModelsPath) configs[id].modelsPath = finalModelsPath;
539
+ if (finalChatPath) configs[id].chatPath = finalChatPath;
499
540
  }
500
541
  // 同步清理旧 providerKeys 中同 id 的残留,避免双写
501
542
  const oldKeys = readState(file).providerKeys;
@@ -503,10 +544,10 @@ export function saveProviderConfig(id, { baseUrl, keys, auths, allowedModels },
503
544
  const nextKeys = { ...oldKeys };
504
545
  delete nextKeys[id];
505
546
  writeStateImmediate(file, { providerKeys: nextKeys, providerConfigs: configs });
506
- return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
547
+ return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed, modelsPath: finalModelsPath, chatPath: finalChatPath };
507
548
  }
508
549
  writeStateImmediate(file, { providerConfigs: configs });
509
- return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed };
550
+ return { baseUrl: cleanUrl, keys: cleanKeys, auths: finalAuths, allowedModels: finalAllowed, modelsPath: finalModelsPath, chatPath: finalChatPath };
510
551
  }
511
552
 
512
553
  // ---- 供应商模型白名单:providerConfigs.<id>.allowedModels(空 = 不限) ----
@@ -525,12 +566,16 @@ export function saveProviderAllowedModels(id, list, { file = defaultStateFile()
525
566
  const baseUrl = normalizeBaseUrl(cur.baseUrl || loadProviderBaseUrl(id, { file }) || "");
526
567
  const keys = Array.isArray(cur.keys) ? cur.keys : loadProviderKeys(id, { file });
527
568
  const auths = normalizeAuths(cur.auths);
528
- if (!baseUrl && !keys.length && !auths.length && !clean.length) {
569
+ const modelsPath = typeof cur.modelsPath === "string" ? normalizeEndpointPath(cur.modelsPath) : "";
570
+ const chatPath = typeof cur.chatPath === "string" ? normalizeEndpointPath(cur.chatPath) : "";
571
+ if (!baseUrl && !keys.length && !auths.length && !clean.length && !modelsPath && !chatPath) {
529
572
  delete configs[id];
530
573
  } else {
531
574
  configs[id] = { baseUrl, keys };
532
575
  if (auths.length) configs[id].auths = auths;
533
576
  if (clean.length) configs[id].allowedModels = clean;
577
+ if (modelsPath) configs[id].modelsPath = modelsPath;
578
+ if (chatPath) configs[id].chatPath = chatPath;
534
579
  }
535
580
  writeStateImmediate(file, { providerConfigs: configs });
536
581
  return clean;