mslxdff 0.1.135 → 0.1.138

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.
Files changed (45) hide show
  1. package/package.json +2 -2
  2. package/src/chat/gateway.js +1 -1
  3. package/src/chat/prompt.js +1 -1
  4. package/src/chat/terminal.js +1 -1
  5. package/src/chat-pipeline/serial-trial.js +11 -3
  6. package/src/cli/commands/model/list-providers.js +1 -1
  7. package/src/cli/commands/model/list-render.js +2 -2
  8. package/src/cli/commands/provider/add.js +2 -1
  9. package/src/cli/commands/provider/bench-via.js +1 -1
  10. package/src/cli/commands/provider/cline-free.js +94 -0
  11. package/src/cli/commands/provider/cline-login.js +20 -15
  12. package/src/cli/commands/provider/index.js +22 -0
  13. package/src/cli/commands/provider/models.js +4 -1
  14. package/src/cli/commands/provider/workbuddy-login.js +1 -1
  15. package/src/cli/commands/use-group.js +8 -4
  16. package/src/cli/commands/workbuddy.js +14 -21
  17. package/src/cli/index.js +3 -0
  18. package/src/compat.js +23 -5
  19. package/src/model-capabilities/index.js +16 -3
  20. package/src/model-capabilities/merge.js +127 -0
  21. package/src/providers/classify.js +2 -2
  22. package/src/providers/cline/auth.js +1 -1
  23. package/src/providers/cline/chat.js +14 -11
  24. package/src/providers/cline/free-catalog.js +71 -0
  25. package/src/providers/cline/index.js +2 -16
  26. package/src/providers/cline/models.js +28 -51
  27. package/src/providers/dispatcher.js +1 -1
  28. package/src/providers/generic.js +47 -9
  29. package/src/providers/model-id.js +4 -4
  30. package/src/providers/registry.js +1 -1
  31. package/src/providers/share-keys.js +7 -2
  32. package/src/providers/workbuddy/account-store.js +73 -7
  33. package/src/providers/workbuddy/chat.js +1 -1
  34. package/src/providers/workbuddy/index.js +10 -20
  35. package/src/readline-compat.js +9 -10
  36. package/src/routes/models-route.js +11 -1
  37. package/src/runtime/providers-setup.js +9 -0
  38. package/src/runtime/server-lifecycle.js +2 -2
  39. package/src/state/migrations/cline-unify.js +94 -0
  40. package/src/state/migrations.js +33 -0
  41. package/src/state/schemas/token.js +6 -2
  42. package/src/state/schemas/use-group.js +47 -6
  43. package/src/sync-workbuddy.js +1 -1
  44. package/src/upstream-engine/index.js +1 -1
  45. package/src/upstream-probe/rotate.js +1 -1
@@ -0,0 +1,127 @@
1
+ // /v1/models 能力富化(ADR-0022):把 models.dev 目录(+ workbuddy 原生字段兜底)
2
+ // 的能力合并进 /v1/models 每条 data 条目的 `capabilities` 子对象,客户端与 CLI 一眼可读。
3
+ // 逃生门:GET /v1/models?raw=1 保持 ADR-0016 的原始透传形状;codex 调用者恒原始 + 空 models:[]。
4
+ // 全程 best-effort:目录不可用 → 原样返回,绝不让 /models 因富化失败挂掉。
5
+ import { globalCapabilities } from "./index.js";
6
+
7
+ // "cline/deepseek/deepseek-v4-flash" → { provider: "cline", raw: "deepseek/deepseek-v4-flash" };裸 id 归 opencode
8
+ export function splitModelId(id) {
9
+ const s = String(id || "").trim();
10
+ const i = s.indexOf("/");
11
+ if (i > 0) return { provider: s.slice(0, i).toLowerCase(), raw: s.slice(i + 1) };
12
+ return { provider: "opencode", raw: s };
13
+ }
14
+
15
+ function stripFreeSuffix(raw) {
16
+ return raw.replace(/-free$/i, "");
17
+ }
18
+
19
+ // 上游原生 wire API:muse-spark* 只认 /responses(zen /chat 500 实测);
20
+ // models.dev 标 npm @ai-sdk/openai 的模型上游走 responses;其余 chat。
21
+ export function upstreamApiFor(caps, raw) {
22
+ const bare = stripFreeSuffix(String(raw || ""));
23
+ if (/^muse-spark/i.test(bare)) return "responses";
24
+ if (caps?.npm === "@ai-sdk/openai") return "responses";
25
+ return "chat";
26
+ }
27
+
28
+ // caps → 对外 capabilities 子对象(只填有值的键,未收录字段不硬造)
29
+ export function capsPayloadFor(caps, raw) {
30
+ if (!caps) return null;
31
+ const payload = {
32
+ reasoning: Boolean(caps.reasoning),
33
+ ...(caps.effortType ? { effortType: caps.effortType } : {}),
34
+ ...(Array.isArray(caps.effortValues) && caps.effortValues.length
35
+ ? { effortValues: caps.effortValues.map(String) }
36
+ : {}),
37
+ ...(caps.defaultEffort ? { defaultEffort: String(caps.defaultEffort) } : {}),
38
+ imageInput: Boolean(caps.imageInput),
39
+ inputModalities: Array.isArray(caps.inputModalities) && caps.inputModalities.length
40
+ ? caps.inputModalities
41
+ : ["text"],
42
+ outputModalities: Array.isArray(caps.outputModalities) && caps.outputModalities.length
43
+ ? caps.outputModalities
44
+ : ["text"],
45
+ toolCall: Boolean(caps.toolCall),
46
+ ...(Number(caps.context) > 0 ? { context: Number(caps.context) } : {}),
47
+ ...(Number(caps.maxOutput) > 0 ? { maxOutput: Number(caps.maxOutput) } : {}),
48
+ ...((caps.costIn != null || caps.costOut != null)
49
+ ? { costIn: Number(caps.costIn) || 0, costOut: Number(caps.costOut) || 0 }
50
+ : {}),
51
+ // 网关两侧端点都收:/v1/responses 复用 ChatPipeline 翻译层;upstreamApi 记上游原生协议
52
+ endpoints: ["chat", "responses"],
53
+ upstreamApi: upstreamApiFor(caps, raw),
54
+ };
55
+ return payload;
56
+ }
57
+
58
+ // 单条 id 的目录匹配:精确裸 id → 剥 -free 后缀 → 二级厂商前缀(cline/deepseek/x → deepseek/x)
59
+ function lookupCaps(svc, provider, raw) {
60
+ return (
61
+ svc.get(provider, raw) ||
62
+ svc.get(provider, stripFreeSuffix(raw)) ||
63
+ null
64
+ );
65
+ }
66
+
67
+ function lookupCapsDeep(svc, provider, raw) {
68
+ const direct = lookupCaps(svc, provider, raw);
69
+ if (direct) return direct;
70
+ const parts = raw.split("/");
71
+ if (parts.length >= 2) {
72
+ const head = parts[0];
73
+ const rest = parts.slice(1).join("/");
74
+ return svc.get(head, rest) || svc.get(head, stripFreeSuffix(rest)) || null;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * 把能力合并进 { object:"list", data:[...] } 的每条条目。
81
+ * capsSvc/wbSource 为测试接缝;生产用全局单例(models.dev 24h 缓存)+ workbuddy 动态源。
82
+ * wbSource 显式 null 禁用 workbuddy 兜底(测试用)。
83
+ */
84
+ export async function mergeModelsList(data, { capsSvc, wbSource } = {}) {
85
+ const svc = capsSvc === undefined ? globalCapabilities() : capsSvc;
86
+ const entries = Array.isArray(data?.data) ? data.data : [];
87
+ if (!svc || !entries.length) return data;
88
+ // 只用内存/磁盘缓存热身;冷缓存绝不阻塞请求(后台拉新,下次请求即富化)
89
+ try {
90
+ const warmed = svc.readyWarm ? await svc.readyWarm() : await svc.ready();
91
+ if (!warmed) return data;
92
+ } catch {
93
+ return data;
94
+ }
95
+ // 第一遍:目录直查;workbuddy 未命中记下,第二遍走上游原生字段
96
+ const resolved = new Map(); // index -> caps
97
+ const needWb = []; // [index, raw]
98
+ entries.forEach((entry, i) => {
99
+ const id = String(entry?.id || "");
100
+ if (!id) return;
101
+ const { provider, raw } = splitModelId(id);
102
+ const caps = lookupCapsDeep(svc, provider, raw);
103
+ if (caps) resolved.set(i, caps);
104
+ else if (provider === "workbuddy" && wbSource !== null) needWb.push([i, raw]);
105
+ });
106
+ if (needWb.length) {
107
+ try {
108
+ const { workbuddyCapsFromModels, workbuddyAllModels } = await import("./index.js");
109
+ const wbMap = await workbuddyCapsFromModels(wbSource || workbuddyAllModels)();
110
+ for (const [i, raw] of needWb) {
111
+ const caps = wbMap?.[raw] || null;
112
+ if (caps) resolved.set(i, caps);
113
+ }
114
+ } catch {
115
+ // workbuddy 源不可用:这些条目保持无能力,不拖垮整个列表
116
+ }
117
+ }
118
+ if (!resolved.size) return data;
119
+ const next = entries.map((entry, i) => {
120
+ const caps = resolved.get(i);
121
+ if (!caps) return entry;
122
+ const { raw } = splitModelId(String(entry?.id || ""));
123
+ const payload = capsPayloadFor(caps, raw);
124
+ return payload ? { ...entry, capabilities: payload } : entry;
125
+ });
126
+ return { ...data, data: next };
127
+ }
@@ -1,10 +1,10 @@
1
1
  // ADR-0015:供应商路由分类三态 + EMA 纯函数(零依赖,防循环 import)
2
- // local-only:本机账号绑定(workbuddy),组员转发无效
2
+ // local-only:本机账号绑定(workbuddy/cline 系),组员转发无效
3
3
  // quota-pool:图额度不图速度(opencode free),直连先行、429 后组员兜底
4
4
  // latency-compare:key/token 类,direct vs link+remote 比延迟
5
5
  export function classifyProvider(id) {
6
6
  const s = String(id || "").trim().toLowerCase();
7
- if (s === "workbuddy") return "local-only";
7
+ if (s === "workbuddy" || s === "cline") return "local-only";
8
8
  if (s === "opencode") return "quota-pool";
9
9
  return "latency-compare";
10
10
  }
@@ -120,7 +120,7 @@ export function createAuthPool({
120
120
  if (account.dead) throw new Error("invalid_grant");
121
121
  if (account.cooldownUntil > now) throw new Error("account_cooldown");
122
122
  if (account.accessToken && now < account.expiry) return account.accessToken;
123
- // base 可能已含 /api/v1(state 里 clinebot baseUrl 就是 …/api/v1),直接拼会 double 成
123
+ // base 可能已含 /api/v1(老 clinebot 配置就是 …/api/v1),直接拼会 double 成
124
124
  // …/api/v1/api/v1/auth/refresh → 上游回 401 Unauthorized(版本/重认证提示),绝不能当 token 死。
125
125
  const baseNoV1 = resolvedBase.replace(/\/api\/v1\/?$/, "");
126
126
  const url = joinUrl(baseNoV1, "/api/v1/auth/refresh");
@@ -1,20 +1,20 @@
1
1
  import { joinUrl, sleep } from "../base.js";
2
2
  import { clineHeaders } from "./headers.js";
3
3
  import { createTransport } from "../../transport/index.js";
4
+ import { normalizeProviderId } from "../model-id.js";
4
5
  import { createSdkDispatch } from "../../upstream-engine/sdk/dispatch.js";
5
6
 
6
7
  function genSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
7
8
 
8
- // stripProviderPrefix 去掉本地供应商前缀,发上游用裸模型 id。
9
- // 上游(对标 cline2api-workers MODELS)只认裸 id:"deepseek/deepseek-v4-flash"、
10
- // "poolside/laguna-s-2.1:free";本地 "clinebot/deepseek/..." 需取后两段。
11
- // 两段及以内视为已是裸 id,原样透传。
12
- function stripProviderPrefix(id) {
13
- const parts = String(id || "").split("/").filter(Boolean);
14
- if (parts.length > 2) return parts.slice(1).join("/");
15
- return String(id || "");
9
+ // stripProviderPrefix 只剥「本供应商」前缀(cline/<裸 id> → 裸 id),非本前缀(含上游自带多段 id
10
+ // 如 meta/vendor/x)原样透传,不误削首段(旧实现按段数剥曾是潜在 bug)。
11
+ function stripProviderPrefix(model, providerId) {
12
+ const s = String(model || "");
13
+ const i = s.indexOf("/");
14
+ if (i <= 0) return s;
15
+ const low = (v) => normalizeProviderId(String(v || "").toLowerCase());
16
+ return low(s.slice(0, i)) === low(providerId) ? s.slice(i + 1) : s;
16
17
  }
17
-
18
18
  function unwrapData(obj) {
19
19
  if (obj && obj.data && typeof obj.data === "object") {
20
20
  const d = obj.data;
@@ -169,7 +169,7 @@ export function createChatService({
169
169
  const model = body?.model || "deepseek/deepseek-v4-flash";
170
170
  const sessionId = genSessionId();
171
171
  const isStream = body?.stream === true;
172
- const upstreamModel = stripProviderPrefix(model);
172
+ const upstreamModel = stripProviderPrefix(model, id);
173
173
  // token 口径双写:对标官方 withMaxCompletionTokensForReasoningModels——
174
174
  // cline 上游默认 reasoning_effort high,推理模型认 max_completion_tokens,
175
175
  // 只发 max_tokens 会被部分通道拒;双写兼容最稳。
@@ -182,7 +182,10 @@ export function createChatService({
182
182
  reasoning_effort: body?.reasoning_effort || body?.reasoningEffort || "high",
183
183
  messages: body?.messages || [],
184
184
  };
185
- const forceStream = !isStream && String(upstreamModel).startsWith("deepseek/");
185
+ // 部分免费通道(deepseek 家族,含 cline-free/deepseek-*)原生非流式不可靠
186
+ //(500 empty response):这类模型在内部走 stream+聚合成 JSON,对外仍按请求方
187
+ // stream 标志返回(true 直透,false 聚合)。其它模型完全尊重请求方,不强制。
188
+ const forceStream = !isStream && String(upstreamModel).toLowerCase().includes("deepseek");
186
189
  if (isStream || forceStream) upstreamBody.stream = true;
187
190
  for (const k of ["temperature", "top_p", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"]) {
188
191
  if (body[k] !== undefined) upstreamBody[k] = body[k];
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Cline 免费目录深模块:只认上游 `free` 数组(公开接口,无需鉴权、无凭据)。
3
+ * GET {bareHost}/api/v1/ai/cline/recommended-models → { recommended, free, clinePass, clineCloud }
4
+ * 供 cline/models.js(聚合目录 + 启动自检)与 `-provider cline free` CLI 共用;缓存由调用方持有。
5
+ */
6
+ import { compatFetch } from "../../compat.js";
7
+ import { joinUrl } from "../base.js";
8
+
9
+ export const FREE_CATALOG_PATH = "/api/v1/ai/cline/recommended-models";
10
+ export const DEFAULT_CLINE_BASE = "https://api.cline.bot";
11
+
12
+ // 上游不可达时的内置兜底(2026-09-20 实测 5 个;只有 id 与短名,无凭据)
13
+ export const FALLBACK_FREE = Object.freeze([
14
+ { id: "cline-free/deepseek-v4.1-flash", name: "deepseek-v4.1-flash" },
15
+ { id: "cline-free/muse-spark-1.3-contributor", name: "muse-spark-1.3-contributor" },
16
+ { id: "z-ai/glm-5.3-flash", name: "glm-5.3-flash" },
17
+ { id: "cline-free/solar-pro4", name: "solar-pro4" },
18
+ { id: "poolside/laguna-s-2.1:free", name: "laguna-s-2.1:free" },
19
+ ]);
20
+ export const FALLBACK_FREE_IDS = FALLBACK_FREE.map((m) => m.id);
21
+
22
+ /** 目录 URL 归一:baseUrl 可能是裸 host 或带 /api/v1 的老 clinebot 形态;customPath 尊重用户自定义端点 */
23
+ export function catalogUrl(baseUrl, customPath) {
24
+ const raw = String(baseUrl || "").trim() || DEFAULT_CLINE_BASE;
25
+ const bare = raw.replace(/\/+$/, "").replace(/\/api\/v1\/?$/, "") || DEFAULT_CLINE_BASE;
26
+ const p = String(customPath || "").trim();
27
+ if (p && p !== "/models" && p !== FREE_CATALOG_PATH) return joinUrl(bare, p);
28
+ return joinUrl(bare, FREE_CATALOG_PATH);
29
+ }
30
+
31
+ /** 只取 json.free(对象 {id,name,...} 或裸字符串两种形态都容忍) */
32
+ function pickFree(json) {
33
+ const arr = Array.isArray(json?.free) ? json.free : [];
34
+ const out = [];
35
+ for (const m of arr) {
36
+ if (typeof m === "string" && m.trim()) out.push({ id: m.trim() });
37
+ else if (m && typeof m.id === "string" && m.id.trim()) out.push({ ...m, id: m.id.trim(), name: String(m.name || "") });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * 拉免费目录。
44
+ * @returns {Promise<{ok:boolean, status:number, url:string, models:object[], ids:string[], error?:string}>}
45
+ * ok=false 时 models/ids 为空(兜底由调用方决定);authorization 供私有/自定义目录可选透传。
46
+ */
47
+ export async function fetchFreeCatalog({ baseUrl, customPath, fetchImpl, dispatcher, authorization, timeoutMs = 15_000 } = {}) {
48
+ const url = catalogUrl(baseUrl, customPath);
49
+ const impl = fetchImpl || compatFetch;
50
+ const headers = { Accept: "application/json" };
51
+ if (authorization) headers.Authorization = authorization;
52
+ const opts = { headers };
53
+ if (dispatcher) opts.dispatcher = dispatcher;
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(new Error("cline free catalog timed out")), timeoutMs);
56
+ timer.unref?.();
57
+ opts.signal = controller.signal;
58
+ try {
59
+ const res = await impl(url, opts);
60
+ const status = Number(res?.status) || 0;
61
+ if (!res?.ok) return { ok: false, status, url, models: [], ids: [], error: `HTTP ${status}` };
62
+ const json = await res.json().catch(() => null);
63
+ const models = pickFree(json);
64
+ if (!models.length) return { ok: false, status, url, models: [], ids: [], error: "empty free list" };
65
+ return { ok: true, status, url, models, ids: models.map((m) => m.id) };
66
+ } catch (err) {
67
+ return { ok: false, status: 0, url, models: [], ids: [], error: String(err?.message || err) };
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }
@@ -41,14 +41,7 @@ export function createClineProvider({
41
41
  if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
42
42
 
43
43
  const rawKeys = collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {}));
44
- // 同时兼容 cline 与 clinebot 两个 id 的 keys(用户可能配在任一)
45
- let extraKeys = [];
46
- try {
47
- const altId = id === "cline" ? "clinebot" : "cline";
48
- const alt = loadProviderKeys(altId, file ? { file } : {});
49
- if (alt.length) extraKeys = alt;
50
- } catch {}
51
- const allKeys = [...new Set([...rawKeys, ...extraKeys].map((k) => String(k).trim()).filter(Boolean))];
44
+ const allKeys = [...new Set(rawKeys.map((k) => String(k).trim()).filter(Boolean))];
52
45
  const hasRefresh = allKeys.some((k) => isRefreshToken(k, id));
53
46
  const ring = createKeyRing(allKeys.filter((k) => !isRefreshToken(k, id)), { cooldownMs });
54
47
 
@@ -78,14 +71,7 @@ export function createClineProvider({
78
71
  // 优先写 providerConfigs,兼容旧路径由 saveProviderConfig 处理
79
72
  saveProviderConfig(id, { baseUrl: resolvedBase, keys: next }, file ? { file } : {});
80
73
  }
81
- // altId 同步
82
- const altId = id === "cline" ? "clinebot" : "cline";
83
- const cur2 = loadProviderKeys(altId, file ? { file } : {});
84
- const idx2 = cur2.indexOf(oldRefreshToken);
85
- if (idx2 >= 0) {
86
- const next2 = [...cur2]; next2[idx2] = newRefreshToken;
87
- saveProviderConfig(altId, { baseUrl: resolvedBase, keys: next2 }, file ? { file } : {});
88
- }
74
+ // 只回写 cline 自己(旧 id 轮换回写已随 id 统一删除,见 .scratch/cline-unify/SPEC.md §4.1)
89
75
  } catch {}
90
76
  },
91
77
  });
@@ -3,6 +3,7 @@ import { compatFetch } from "../../compat.js";
3
3
  import { joinModelId } from "../model-id.js";
4
4
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
5
  import { dirname } from "node:path";
6
+ import { fetchFreeCatalog, catalogUrl, FALLBACK_FREE as BUNDLED_FREE } from "./free-catalog.js";
6
7
 
7
8
  const { UndiciFetch } = getUndici();
8
9
 
@@ -18,30 +19,21 @@ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispat
18
19
  let cache = null;
19
20
  let fetchedAt = 0;
20
21
 
21
- // 离线兜底:与上游 recommended-models 的 free 对齐(2026-09-16 实测 5 个)。
22
- // 上游挂了/401 时也不返回空数组,保证 -provider clinebot models 与 picks 仍有免费可用。
23
- const FALLBACK_FREE = [
24
- { id: "cline-free/deepseek-v4.1-flash", name: "deepseek-v4.1-flash" },
25
- { id: "cline-free/muse-spark-1.3-contributor", name: "muse-spark-1.3-contributor" },
26
- { id: "z-ai/glm-5.3-flash", name: "glm-5.3-flash" },
27
- { id: "cline-free/solar-pro4", name: "solar-pro4" },
28
- { id: "poolside/laguna-s-2.1:free", name: "laguna-s-2.1:free" },
29
- ];
22
+ // 离线兜底:与上游 recommended-models 的 free 对齐(常量在 free-catalog.js,2026-09-20 实测 5 个)。
23
+ // 上游挂了/401 时也不返回空数组,保证 -provider cline models 与 picks 仍有免费可用。
30
24
 
31
25
  function fallbackList() {
32
- const out = FALLBACK_FREE.map((m) => ({ ...m, id: joinModelId(id, m.id) }));
26
+ const out = BUNDLED_FREE.map((m) => ({ ...m, id: joinModelId(id, m.id) }));
33
27
  cache = out; fetchedAt = Date.now();
34
28
  return out;
35
29
  }
36
30
 
37
- // 端点归一化:官方取 {bareHost}/api/v1/ai/cline/recommended-models。
38
- // baseUrl 可能是裸 host(https://api.cline.bot)也可能是带 /api/v1 的,
39
- // 统一收敛到 …/api/v1/ai/cline/recommended-models;用户自定义 path 原样尊重。
31
+ // 端点归一化:官方取 {bareHost}/api/v1/ai/cline/recommended-models(URL 拼装与 CLI 共用 free-catalog.catalogUrl)。
32
+ // baseUrl 可能是裸 host(https://api.cline.bot)也可能是带 /api/v1 的,统一收敛;用户自定义 path 原样尊重。
40
33
  function resolveModelsUrl() {
41
34
  const custom = modelsPath && modelsPath !== "/models" && modelsPath !== "/ai/cline/recommended-models";
42
35
  if (custom) return joinUrl(resolvedBase, resolvedPath);
43
- const bare = resolvedBase.replace(/\/api\/v1\/?$/, "");
44
- return joinUrl(bare, "/api/v1/ai/cline/recommended-models");
36
+ return catalogUrl(resolvedBase);
45
37
  }
46
38
 
47
39
  async function listModels() {
@@ -85,7 +77,7 @@ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispat
85
77
  } catch {}
86
78
  }
87
79
 
88
- // Note: clinebot free 目录三处同源(CLI 直查/聚合目录/兜底)+ daemon 启动自检快照(diff 写 daemon.log,不自动改 picks)— 见 .agents/notes/implemented/bug-fix/2026-09-16-clinebot-free-catalog-unify.md
80
+ // Note: cline free 目录三处同源(CLI 直查/聚合目录/兜底,统一在 free-catalog.js)+ daemon 启动自检快照(diff 写 daemon.log,不自动改 picks)— 见 .agents/notes/implemented/bug-fix/2026-09-16-clinebot-free-catalog-unify.md
89
81
  function detectFreeChanges(ids) {
90
82
  const prev = readSnapshotFree();
91
83
  if (!prev) {
@@ -104,46 +96,31 @@ export function createModelsService({ id, baseUrl, modelsPath, fetchImpl, dispat
104
96
  return { added, removed };
105
97
  }
106
98
 
107
- // 启动自检入口:拉 recommended-models → 填缓存 → 对比快照报 free 增删。
99
+ // 启动自检入口:拉 recommended-models → 填缓存 → 对比快照报 free 增删(拉取与 CLI 共用 free-catalog.fetchFreeCatalog)。
108
100
  async function checkFreeUpdates() {
109
- const url = resolveModelsUrl();
110
101
  const t0 = performance.now();
111
- try {
112
- const headers = { Accept: "application/json" };
113
- const key = (loadKeys ? loadKeys(id)[0] : null) || (ring ? ring.next() : null);
114
- if (key && !String(key).includes(".")) headers["Authorization"] = `Bearer ${key}`;
115
- const opts = { headers };
116
- if (dispatcher) opts.dispatcher = dispatcher;
117
- const res = await fetchImpl(url, opts);
118
- let freeIds = null;
119
- try {
120
- const text = await res.text().catch(() => "");
121
- if (res.ok && text && isClineBotHost(resolvedBase)) {
122
- const json = JSON.parse(text);
123
- if (Array.isArray(json.free)) {
124
- const valid = json.free.filter((m) => m && typeof m.id === "string");
125
- if (valid.length) {
126
- cache = valid.map((m) => ({ ...m, id: joinModelId(id, m.id) }));
127
- fetchedAt = Date.now();
128
- freeIds = valid.map((m) => m.id);
129
- }
130
- }
131
- }
132
- } catch {}
133
- let change = null;
134
- if (freeIds && snapshotPath) { try { change = detectFreeChanges(freeIds); } catch {} }
135
- return {
136
- ok: res.ok,
137
- status: res.status,
138
- ms: Math.round(performance.now() - t0),
139
- ...(change?.added ? { freeAdded: change.added, freeRemoved: change.removed } : {}),
140
- };
141
- } catch (err) {
142
- return { ok: false, error: String(err?.message || err), ms: Math.round(performance.now() - t0) };
102
+ const key = (loadKeys ? loadKeys(id)[0] : null) || (ring ? ring.next() : null);
103
+ const authorization = key && !String(key).includes(".") ? `Bearer ${key}` : undefined;
104
+ const cat = await fetchFreeCatalog({ baseUrl: resolvedBase, fetchImpl, dispatcher, authorization });
105
+ const ms = () => Math.round(performance.now() - t0);
106
+ const httpOk = cat.status >= 200 && cat.status < 300;
107
+ if (!httpOk || !cat.models.length || !isClineBotHost(resolvedBase)) {
108
+ return { ok: httpOk, status: cat.status, ...(httpOk ? {} : { error: cat.error }), ms: ms() };
143
109
  }
110
+ const valid = cat.models;
111
+ cache = valid.map((m) => ({ ...m, id: joinModelId(id, m.id) }));
112
+ fetchedAt = Date.now();
113
+ let change = null;
114
+ if (snapshotPath) { try { change = detectFreeChanges(valid.map((m) => m.id)); } catch {} }
115
+ return {
116
+ ok: true,
117
+ status: cat.status,
118
+ ms: ms(),
119
+ ...(change?.added ? { freeAdded: change.added, freeRemoved: change.removed } : {}),
120
+ };
144
121
  }
145
122
 
146
- // preheat 保留为别名:dispatcher 已不再调 clinebot,手动/测试/未来钩子仍可用,行为与自检一致
123
+ // preheat 保留为别名:dispatcher 已不再调 cline,手动/测试/未来钩子仍可用,行为与自检一致
147
124
  async function preheat() {
148
125
  return checkFreeUpdates();
149
126
  }
@@ -50,7 +50,7 @@ export function createProviderDispatcher(providers = [], opts = {}) {
50
50
  // ADR-0008:本请求携带瞬时共享 key(shareKeys 由组员侧按 header 解析后传入)。
51
51
  const sharedKeys = opts?.shareKeys?.[provider.id];
52
52
  if (sharedKeys && sharedKeys.length && typeof provider.chatWithKeys === "function") {
53
- return provider.chatWithKeys(forwarded, sharedKeys);
53
+ return provider.chatWithKeys(forwarded, sharedKeys, opts);
54
54
  }
55
55
  if (provider.id === "workbuddy" && workbuddyUid) {
56
56
  return provider.chat(forwarded, { ...opts, workbuddyUid });
@@ -2,6 +2,8 @@ import { createKeyRing } from "./keyring.js";
2
2
  import { loadProviderKeys, loadProviderBaseUrl, loadProviderModelsPath, loadProviderChatPath } from "../state.js";
3
3
  import { envInt, joinUrl, getUndici, createAgent, collectApiKeysGeneric, createChatRunner, createListModelsRunner, createPreheatRunner } from "./base.js";
4
4
  import { compatFetch } from "../compat.js";
5
+ import crypto from "node:crypto";
6
+ import { genId, opencodeUa, digestIdTail } from "../opencode-identity.js";
5
7
 
6
8
  const { UndiciFetch } = getUndici();
7
9
 
@@ -12,6 +14,26 @@ function resolveBaseUrl(id, baseUrl) {
12
14
  return "";
13
15
  }
14
16
 
17
+ function isOpencodeHost(baseUrl) {
18
+ return /opencode\.ai/i.test(String(baseUrl || ""));
19
+ }
20
+
21
+ // Console Go (zen/go) 要求 x-opencode-session 才能路由(缺失 → 400 MissingSessionID)。
22
+ // 会话取值:已合规(ses_ 前缀)直接用;客户端透传的任意串做 sha1 摘要派生(稳定+合规);
23
+ // 无则每请求 fresh(与 -chat curl / bench 直连行为一致)。
24
+ function resolveGoSession(sessionId) {
25
+ const raw = String(sessionId || "").trim();
26
+ if (/^ses_/.test(raw)) return raw;
27
+ if (raw) {
28
+ try {
29
+ return `ses_${digestIdTail(crypto.createHash("sha1").update(raw).digest())}`;
30
+ } catch {
31
+ return genId("ses_");
32
+ }
33
+ }
34
+ return genId("ses_");
35
+ }
36
+
15
37
  export function createGenericProvider({
16
38
  id,
17
39
  baseUrl,
@@ -41,6 +63,7 @@ export function createGenericProvider({
41
63
  const resolvedModelsPath = modelsPath || loadProviderModelsPath(id, file ? { file } : {});
42
64
  const resolvedChatPath = chatPath || loadProviderChatPath(id, file ? { file } : {});
43
65
  const ring = createKeyRing(collectApiKeysGeneric(id, apiKeys, apiKey, loadProviderKeys), { cooldownMs });
66
+ const goHost = isOpencodeHost(resolvedBase);
44
67
 
45
68
  let dispatcher = null;
46
69
  let agent = null;
@@ -53,7 +76,7 @@ export function createGenericProvider({
53
76
  agent = a.agent; dispatcher = a.dispatcher;
54
77
  }
55
78
 
56
- function buildHeaders(body, key) {
79
+ function buildHeaders(body, key, opts) {
57
80
  const isStream = body?.stream !== false;
58
81
  const h = {
59
82
  "Content-Type": "application/json",
@@ -61,20 +84,35 @@ export function createGenericProvider({
61
84
  "User-Agent": "mslxdff",
62
85
  };
63
86
  if (key) h["Authorization"] = `Bearer ${key}`;
64
- return { ...h, ...extraHeaders };
87
+ const out = { ...h, ...extraHeaders };
88
+ // ocgo(opencode.ai 域名):补 Console Go 路由所需的 opencode 身份头
89
+ if (goHost) {
90
+ out["User-Agent"] = opencodeUa();
91
+ out["x-opencode-client"] = "desktop";
92
+ out["x-opencode-session"] = resolveGoSession(opts?.sessionId);
93
+ out["x-opencode-request"] = genId("msg_");
94
+ out["x-opencode-project"] = "global";
95
+ }
96
+ return out;
65
97
  }
66
98
 
67
- const { runChat } = createChatRunner({
68
- id, ring, cooldownMs, retry, fetchImpl, dispatcher, buildHeaders,
69
- getUrl: () => joinUrl(resolvedBase, resolvedChatPath),
70
- connectTimeoutMs,
71
- });
99
+ // base 的 attemptOnce 只调 buildHeaders(body, key):用闭包把 opts.sessionId 带进去
100
+ function scopedRunner(activeRing, opts) {
101
+ return createChatRunner({
102
+ id, ring: activeRing, cooldownMs, retry, fetchImpl, dispatcher,
103
+ buildHeaders: (b, k) => buildHeaders(b, k, opts),
104
+ getUrl: () => joinUrl(resolvedBase, resolvedChatPath),
105
+ connectTimeoutMs,
106
+ });
107
+ }
72
108
 
73
- async function chat(body) {
109
+ async function chat(body, opts) {
110
+ const { runChat } = scopedRunner(ring, opts);
74
111
  return runChat(body, ring, `MSLXDFF_${id.toUpperCase()}_KEY`);
75
112
  }
76
- async function chatWithKeys(body, keys) {
113
+ async function chatWithKeys(body, keys, opts) {
77
114
  const tmp = createKeyRing(keys, { cooldownMs });
115
+ const { runChat } = scopedRunner(tmp, opts);
78
116
  return runChat(body, tmp, "shared provider keys");
79
117
  }
80
118
 
@@ -12,9 +12,9 @@ import os from "node:os";
12
12
 
13
13
  export const DEFAULT_PROVIDER = "opencode";
14
14
 
15
- const ALIASES = { oc: "opencode", opencode: "opencode" };
15
+ const ALIASES = { oc: "opencode", opencode: "opencode", clinebot: "cline", "cline-bot": "cline" };
16
16
 
17
- // WorkBuddy 别名映射(alias → canonical):clinebot-z-ai-glm-5.3-flash → clinebot/z-ai/glm-5.3-flash
17
+ // WorkBuddy 别名映射(alias → canonical):cline-z-ai-glm-5.3-flash → cline/z-ai/glm-5.3-flash
18
18
  const _modelAliases = new Map();
19
19
 
20
20
  function aliasFile() {
@@ -62,7 +62,7 @@ export function getModelAlias(id) {
62
62
  return _modelAliases.get(key) || null;
63
63
  }
64
64
 
65
- // 反向查询:canonical id → alias(如 clinebot/z-ai/glm-5.3-flash → clinebot-z-ai-glm-5.3-flash)
65
+ // 反向查询:canonical id → alias(如 cline/z-ai/glm-5.3-flash → cline-z-ai-glm-5.3-flash)
66
66
  export function getAliasForModel(canonicalId) {
67
67
  const c = String(canonicalId || "").trim();
68
68
  if (!_aliasLoaded) { try { loadModelAliases(); } catch {} }
@@ -116,7 +116,7 @@ export function toFullId(provider, raw) {
116
116
  export function normalizeFullId(id, knownProviders = []) {
117
117
  const s = String(id || "").trim();
118
118
  if (!s) return "";
119
- // 1. 查别名表(clinebot-z-ai-glm-5.3-flash → clinebot/z-ai/glm-5.3-flash)
119
+ // 1. 查别名表(cline-z-ai-glm-5.3-flash → cline/z-ai/glm-5.3-flash)
120
120
  const aliased = getModelAlias(s);
121
121
  if (aliased) return aliased;
122
122
  // 2. 已含 /,走原有逻辑
@@ -12,7 +12,7 @@ export const customProviders = [
12
12
  },
13
13
  {
14
14
  id: "cline",
15
- match: (id, baseUrl) => id === "cline" || id === "clinebot" || String(baseUrl).includes("cline.bot"),
15
+ match: (id, baseUrl) => id === "cline" || String(baseUrl).includes("cline.bot"),
16
16
  load: () => import("./cline.js").then((m) => m.createClineProvider),
17
17
  },
18
18
  ];
@@ -3,20 +3,25 @@
3
3
  import { splitModelId, DEFAULT_PROVIDER } from "./model-id.js";
4
4
  import { classifyProvider } from "./classify.js";
5
5
  import { loadProviderKeys, listProviderIdsWithKeys } from "../state.js";
6
+ import { isRefreshToken } from "./cline/headers.js";
6
7
 
7
8
  export const SHARE_KEYS_HEADER = "x-mslxdff-share-keys";
8
9
 
9
10
  // 硬排除:refresh-token 型凭据(对端刷新会轮换 token,与本机互相踢下线)——正确性问题。
10
- const NEVER_SHARE_IDS = new Set(["cline", "clinebot"]);
11
+ // 二层保护:① 按 id 硬排除(cline 恒 local-only);② 按凭据形状兜底(见 shareableProviderIds)。
12
+ const NEVER_SHARE_IDS = new Set(["cline"]);
11
13
 
12
14
  // 本节点应 cast key 到出站转发的供应商 id 集合 = 所有「本机有 key」的供应商,
13
- // 减去:opencode(无 key 且恒排除)、local-only(workbuddy,本就不走组员)、刷新型凭据。
15
+ // 减去:opencode(无 key 且恒排除)、local-only(workbuddy/cline系,本就不走组员)、刷新型凭据。
14
16
  export function shareableProviderIds({ file } = {}) {
15
17
  const ids = [];
16
18
  for (const id of listProviderIdsWithKeys({ file })) {
17
19
  if (id === DEFAULT_PROVIDER) continue;
18
20
  if (NEVER_SHARE_IDS.has(id)) continue;
19
21
  try { if (classifyProvider(id) === "local-only") continue; } catch {}
22
+ // 形状兜底:id 白名单可能有残留(迁移未跑/回滚/手改 config),只要该供应商现有 key 里
23
+ // 有一个是 refreshToken 形态,就一律不外借——refreshToken 借出去会被对端刷新轮换,双向踢下线。
24
+ try { if (loadProviderKeys(id, { file }).some((k) => isRefreshToken(k, id))) continue; } catch {}
20
25
  ids.push(id);
21
26
  }
22
27
  return ids;