mslxdff 0.1.109 → 0.1.111

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 (35) hide show
  1. package/package.json +6 -1
  2. package/src/chat-pipeline/dedup.js +83 -0
  3. package/src/chat-pipeline/serial-trial.js +3 -3
  4. package/src/cli/commands/model/list.js +15 -2
  5. package/src/cli/commands/provider/models.js +14 -1
  6. package/src/cli/commands/sync.js +5 -0
  7. package/src/model-capabilities/enrich.js +86 -0
  8. package/src/model-capabilities/index.js +163 -0
  9. package/src/model-capabilities/parse.js +68 -0
  10. package/src/providers/base.js +11 -1
  11. package/src/providers/classify.js +22 -0
  12. package/src/providers/cline/chat.js +21 -6
  13. package/src/providers/model-id.js +29 -0
  14. package/src/providers/workbuddy/chat.js +19 -0
  15. package/src/providers/workbuddy/reshape.js +23 -16
  16. package/src/providers/workbuddy/sdk-chat.js +25 -0
  17. package/src/routes/hedge.js +2 -2
  18. package/src/routes/index.js +7 -1
  19. package/src/routes/models-route.js +54 -0
  20. package/src/runtime/providers-setup.js +2 -1
  21. package/src/state/schemas/use-group.js +16 -0
  22. package/src/sync-opencode.js +39 -12
  23. package/src/upstream-engine/index.js +69 -0
  24. package/src/upstream-engine/mode.js +13 -0
  25. package/src/upstream-engine/sdk/attempt.js +136 -0
  26. package/src/upstream-engine/sdk/chat.js +28 -0
  27. package/src/upstream-engine/sdk/convert.js +133 -0
  28. package/src/upstream-engine/sdk/dispatch.js +55 -0
  29. package/src/upstream-engine/sdk/responses.js +85 -0
  30. package/src/upstream-engine/sdk/sse.js +93 -0
  31. package/src/upstream-probe/display.js +52 -0
  32. package/src/upstream-probe/probe.js +49 -0
  33. package/src/upstream-probe/rotate.js +110 -0
  34. package/src/upstream-probe/start.js +45 -0
  35. package/src/upstream.js +23 -16
@@ -131,4 +131,33 @@ export function normalizeFullId(id, knownProviders = []) {
131
131
  return `${head}/${s.slice(idx + 1)}`;
132
132
  }
133
133
  return `${DEFAULT_PROVIDER}/${s}`;
134
+ }
135
+
136
+ const BUILTIN_PROVIDERS = new Set([DEFAULT_PROVIDER, "openrouter"]);
137
+
138
+ /**
139
+ * 过滤 provider 已不存在的 picks 孤儿(纯函数,零 IO,可单测)。
140
+ * - 含 `/` 前缀:head(归一+小写)∉ 已知集合 → stale(如 amddev/*)。
141
+ * - Bare id:∉ 上游 liveIds ∪ allowlist 展开 → stale(如 laguna-s-2.1-free)。
142
+ * - provider 存在但未启用(如 deepseek 缺 baseUrl)→ 保留,不可用由 [error] 表达。
143
+ * - 未知一律保留(默认放行不误删显示);dash 别名经 resolveAlias 还原后判定;
144
+ * 上游 liveIds 为空时 bare 一律保留(无法判定则放行)。
145
+ * 数据不动,只决定进不进 `-models` 交互列表 — 见 .scratch/models-stale-picks/SPEC.md。
146
+ */
147
+ export function filterStalePicks(pickedIds, { knownProviders = [], liveIds = [], allowedIds = [], resolveAlias = getModelAlias } = {}) {
148
+ const known = new Set([...BUILTIN_PROVIDERS, ...(knownProviders || []).map((p) => normalizeProviderId(p).toLowerCase())]);
149
+ const live = new Set((liveIds || []).map((id) => String(id).toLowerCase()));
150
+ const allowed = new Set((allowedIds || []).map((id) => String(id).toLowerCase()));
151
+ return (pickedIds || []).filter((pick) => {
152
+ const raw = String(pick || "").trim();
153
+ if (!raw) return false;
154
+ let id = raw;
155
+ try { id = resolveAlias(raw) || raw; } catch { id = raw; }
156
+ const slash = String(id).indexOf("/");
157
+ if (slash > 0) return known.has(normalizeProviderId(id.slice(0, slash)).toLowerCase());
158
+ // 上游列表为空(刷新与缓存双失败的极端情况)时无法判定 bare 生死 → 保留(默认放行)
159
+ if (live.size === 0) return true;
160
+ const low = String(id).toLowerCase();
161
+ return live.has(low) || allowed.has(low);
162
+ });
134
163
  }
@@ -3,6 +3,8 @@ import { isAuthError, isInsufficientStatus } from "./auth.js";
3
3
  import { appendRotationLog as defaultAppend } from "./rotation-log.js";
4
4
  import { createTransport } from "../../transport/index.js";
5
5
  import { reshapeWorkbuddySse } from "./reshape.js";
6
+ import { attemptOnceSdk } from "./sdk-chat.js";
7
+ import { resolveEngineMode } from "../../upstream-engine/mode.js";
6
8
 
7
9
  function buildAuthHeaders(key, auth) {
8
10
  const h = {
@@ -79,7 +81,24 @@ export function createChatService({
79
81
  return { uid: "", domain: "www.codebuddy.cn", enterpriseId: "", refreshToken: "" };
80
82
  }
81
83
 
84
+ // SDK 通道(缺省启用):缺省底层走 @ai-sdk/openai-compatible,`legacy`/关闭词
85
+ // (局部 MSLXDFF_WORKBUDDY_SDK,未设则继承全局 MSLXDFF_UPSTREAM_ENGINE)回退原生 transport;
86
+ // 不可用(Node16/未安装)自动回退并告警一次。上层轮换/刷新/reshape 全链复用。
87
+ // Note: 为什么默认 SDK、翻译层代价与回退语义 — 见 .agents/notes/implemented/feature/2026-09-12-workbuddy-sdk-channel.md
88
+ const engineMode = resolveEngineMode(process.env, "MSLXDFF_WORKBUDDY_SDK");
89
+ let sdkFallbackLogged = false;
82
90
  async function fetchOnce(url, body, key, auth) {
91
+ if (engineMode === "sdk") {
92
+ try {
93
+ return await attemptOnceSdk({ url, body, key, auth, buildHeaders: buildAuthHeaders });
94
+ } catch (e) {
95
+ if (!e || !e._sdkLoadFailed) throw e;
96
+ if (!sdkFallbackLogged) {
97
+ sdkFallbackLogged = true;
98
+ try { console.error(`[workbuddy] ${e.message} — 回退原生通道`); } catch {}
99
+ }
100
+ }
101
+ }
83
102
  return transport.request({ url, headers: buildAuthHeaders(key, auth), body: { ...body, stream: true }, stream: true });
84
103
  }
85
104
 
@@ -107,24 +107,31 @@ export function reshapeWorkbuddySse(res) {
107
107
  async pull(controller) {
108
108
  if (closed) { try { controller.close(); } catch {} return; }
109
109
  try {
110
- const { done, value } = await reader.read();
111
- if (done) {
112
- closed = true;
110
+ // 循环读源,直到"有输出 / 流结束 / 出错"才返回。
111
+ // WHATWG 流规范:pull 的 promise 解析后若未 enqueue,规范不会再自动调度 pull
112
+ // (仅当解析期间有新读请求触发 pullAgain 才重调)——消费者只挂一个待处理 read 时,
113
+ // 任何一次"只吞帧不出数据"的拉取都会让整流永久停摆。workbuddy 思考流按帧到达
114
+ // (role 帧后每个 reasoning 碎片都不足 150 字符阈值)时必现,即"首帧后卡死"根因。
115
+ for (;;) {
116
+ const { done, value } = await reader.read();
117
+ if (done) {
118
+ closed = true;
119
+ const out = [];
120
+ if (buf) { processInto(buf + "\n", out); buf = ""; }
121
+ flushReasoningInto(out);
122
+ if (out.length) controller.enqueue(encoder.encode(out.join("")));
123
+ controller.close();
124
+ return;
125
+ }
126
+ buf += decoder.decode(value, { stream: true });
127
+ const idx = buf.lastIndexOf("\n");
128
+ if (idx < 0) continue;
129
+ const complete = buf.slice(0, idx + 1);
130
+ buf = buf.slice(idx + 1);
113
131
  const out = [];
114
- if (buf) { processInto(buf + "\n", out); buf = ""; }
115
- flushReasoningInto(out);
116
- if (out.length) controller.enqueue(encoder.encode(out.join("")));
117
- controller.close();
118
- return;
132
+ processInto(complete, out);
133
+ if (out.length) { controller.enqueue(encoder.encode(out.join(""))); return; }
119
134
  }
120
- buf += decoder.decode(value, { stream: true });
121
- const idx = buf.lastIndexOf("\n");
122
- if (idx < 0) return;
123
- const complete = buf.slice(0, idx + 1);
124
- buf = buf.slice(idx + 1);
125
- const out = [];
126
- processInto(complete, out);
127
- if (out.length) controller.enqueue(encoder.encode(out.join("")));
128
135
  } catch {
129
136
  closed = true;
130
137
  try { controller.close(); } catch {}
@@ -0,0 +1,25 @@
1
+ // workbuddy SDK 实验通道(薄壳):转调共用执行器 upstream-engine/sdk/attempt.js。
2
+ // 契约保持:attemptOnceSdk({url, body, key, auth, buildHeaders}) → Response(SSE)。
3
+ // HTTP 错误映射与 _sdkLoadFailed 语义在共用层;本壳只注入 workbuddy 特有参数。
4
+ // 见 .scratch/workbuddy-sdk-channel/SPEC.md。
5
+ import { attemptOnceSdk as attemptGeneric, sdkBaseFromUrl } from "../../upstream-engine/sdk/attempt.js";
6
+
7
+ export { sdkBaseFromUrl };
8
+
9
+ let channelLogged = false;
10
+
11
+ export async function attemptOnceSdk({ url, body, key, auth, buildHeaders, clock = Date.now } = {}) {
12
+ const out = await attemptGeneric({
13
+ url,
14
+ body,
15
+ headers: buildHeaders ? buildHeaders(key, auth) : {},
16
+ providerName: "workbuddy",
17
+ marker: { name: "x-mslxdff-workbuddy-channel", value: "sdk" },
18
+ clock,
19
+ });
20
+ if (!channelLogged) {
21
+ channelLogged = true;
22
+ try { console.error(`[workbuddy-sdk-channel] active via @ai-sdk/openai-compatible (model=${body?.model || ""} uid=${auth?.uid || ""})`); } catch {}
23
+ }
24
+ return out;
25
+ }
@@ -18,8 +18,8 @@ function shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers, mod
18
18
  if (!canForwardPeers) return false;
19
19
  if (!hasPeers) return false;
20
20
  if (!d || d <= 0) return false;
21
- // 不做供应商级禁对冲特判:对冲仅按 useGroup/流式/peer 可用性决定
22
- // muse-spark 走 /responses 流式(event: 包装 + 加密 reasoning),组员旧版无此整形且聚合 JSON 带错 header,必抢赢本地慢首块,需本地直出
21
+ // 供应商级禁对冲在 shouldUseGroupForModel 层收敛(workbuddy local-only 硬禁,见 ADR-0015);
22
+ // 这里只留 muse-spark 特判:走 /responses 流式(event: 包装 + 加密 reasoning),组员旧版无此整形且聚合 JSON 带错 header,必抢赢本地慢首块,需本地直出
23
23
  if (String(model || "").toLowerCase().startsWith("muse-spark")) return false;
24
24
  return true;
25
25
  }
@@ -4,7 +4,7 @@ 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, streamHandler } from "./groups-relay.js";
7
- import { modelsHandler, modelsStatusHandler, providerModelsHandler } from "./models-route.js";
7
+ import { modelsHandler, modelsStatusHandler, providerModelsHandler, capabilitiesHandler } from "./models-route.js";
8
8
  import { relayHandler } from "./relay.js";
9
9
  import { responsesHandler } from "./responses-route.js";
10
10
 
@@ -110,6 +110,12 @@ const ROUTES = [
110
110
  requiresAuth: true,
111
111
  handler: modelsStatusHandler,
112
112
  },
113
+ {
114
+ method: "GET",
115
+ path: "/v1/models/capabilities",
116
+ requiresAuth: true,
117
+ handler: capabilitiesHandler,
118
+ },
113
119
  ];
114
120
 
115
121
  // re-export for facade & tests
@@ -1,6 +1,7 @@
1
1
  import { json, errMsg } from "./helpers.js";
2
2
  import { runHook } from "../plugins.js";
3
3
  import { isModelAllowed } from "../state.js";
4
+ import { globalCapabilities } from "../model-capabilities/index.js";
4
5
 
5
6
  // Codex 自定义 provider 拉目录要顶层 `models` 数组(codex-rs endpoint/models.rs 解 ModelsResponse{models}),
6
7
  // 给它 OpenAI 标准 {object,data} 会报 missing field `models`。学 OmniRoute:仅 codex 调用者追加空数组
@@ -83,3 +84,56 @@ export async function providerModelsHandler({ req, res, models }) {
83
84
  json(res, 502, { error: errMsg(err) });
84
85
  }
85
86
  }
87
+
88
+ // workbuddy 能力目录源:共享 provider 单例(src/model-capabilities/index.js,
89
+ // 与 -setto opencode 能力注入共用),拉上游原生字段全量 + 10min 缓存
90
+ // 不走聚合 models.get()——聚合会按 allowlist 过滤,blocked 模型查能力 404 会误导(能力目录≠可调用目录,与 CLI 全量展示保持一致)
91
+ async function workbuddyAllModels() {
92
+ const { workbuddyAllModels: wb } = await import("../model-capabilities/index.js");
93
+ return wb();
94
+ }
95
+
96
+ // GET /v1/models/capabilities[?provider=opencode|workbuddy][&id=big-pickle]
97
+ // 模型能力元数据(reasoning 档位/图片输入/tool_call/上下文/价格)
98
+ // - provider=opencode(默认):源 = opencode 官方 models.dev 目录
99
+ // - provider=workbuddy:源 = workbuddy 上游原生字段(maxInputTokens/supportsImages/... first-party 最准),
100
+ // id 用裸 id(如 glm-5.3-flash);wbSource 注入仅测试接缝,生产用独立 provider 单例(全量+10min 缓存)
101
+ // jsonFn 注入仅为测试接缝(S3);生产路径用 helpers.json
102
+ export async function capabilitiesHandler({ req, res, capabilities, wbSource, jsonFn = json }) {
103
+ // 仅未注入(生产)时用全局单例;显式 null 视作服务不可用(测试可复现 502 空状态)
104
+ const svc = capabilities === undefined ? globalCapabilities() : capabilities;
105
+ if (!svc && !wbSource) return jsonFn(res, 502, { error: "capabilities service unavailable" });
106
+ const q = String(req?.url || "").split("?")[1] || "";
107
+ const params = new URLSearchParams(q);
108
+ const provider = (params.get("provider") || "opencode").toLowerCase();
109
+ const id = params.get("id") || "";
110
+ // workbuddy 动态源分支:上游原生字段映射,不查 models.dev
111
+ if (provider === "workbuddy") {
112
+ try {
113
+ const { workbuddyCapsFromModels } = await import("../model-capabilities/index.js");
114
+ const map = await workbuddyCapsFromModels(wbSource || workbuddyAllModels)();
115
+ const rawId = id.replace(/^workbuddy\//i, "");
116
+ if (id) {
117
+ const caps = map[rawId];
118
+ if (!caps) return jsonFn(res, 404, { error: `model '${id}' not found in capabilities catalog (provider=workbuddy)` });
119
+ return jsonFn(res, 200, { object: "model.capabilities", id: rawId, provider, capabilities: caps });
120
+ }
121
+ const data = Object.entries(map).map(([mid, capabilities]) => ({ id: mid, capabilities }));
122
+ return jsonFn(res, 200, { object: "list", provider, data });
123
+ } catch (err) {
124
+ return jsonFn(res, 502, { error: `capabilities source unavailable: ${errMsg(err)}` });
125
+ }
126
+ }
127
+ try {
128
+ await svc.ready();
129
+ } catch (err) {
130
+ return jsonFn(res, 502, { error: `capabilities source unavailable: ${errMsg(err)}` });
131
+ }
132
+ if (id) {
133
+ const caps = svc.get(provider, id);
134
+ if (!caps) return jsonFn(res, 404, { error: `model '${id}' not found in capabilities catalog (provider=${provider})` });
135
+ return jsonFn(res, 200, { object: "model.capabilities", id, provider, capabilities: caps });
136
+ }
137
+ const data = svc.list(provider);
138
+ return jsonFn(res, 200, { object: "list", provider, data });
139
+ }
@@ -2,6 +2,7 @@ import { join, dirname } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { defaultStateFile, loadToken } from "../state.js";
4
4
  import { createUpstreamClient } from "../upstream.js";
5
+ import { createUpstreamEngine } from "../upstream-engine/index.js";
5
6
  import { createModelsService } from "../models.js";
6
7
  import { createAutoSelector } from "../auto.js";
7
8
  import { createPeersService } from "../peers.js";
@@ -50,7 +51,7 @@ export async function setupProviders() {
50
51
  }
51
52
  if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
52
53
  } else {
53
- const opencodeClient = createUpstreamClient({ hooks: upstreamHooks });
54
+ const opencodeClient = createUpstreamEngine({ hooks: upstreamHooks });
54
55
  const opencodeModels = createModelsService({
55
56
  baseUrl,
56
57
  headers: opencodeClient.headers,
@@ -1,4 +1,5 @@
1
1
  import { defaultStateFile, readState, writeStateImmediate } from "../store.js";
2
+ import { classifyProvider } from "../../providers/classify.js";
2
3
 
3
4
  function parseBool(v) {
4
5
  if (typeof v === "boolean") return v;
@@ -36,7 +37,22 @@ export function getEffectiveUseGroup({ file = defaultStateFile() } = {}) {
36
37
  }
37
38
 
38
39
  // 全局开关:off 则所有供应商都不走组员网络(via-route/hedge/peer/broadband 全禁),仅本机直连
40
+ // workbuddy 硬禁组员(ADR-0015 local-only):本机账号绑定(auths/workbuddy-*.json + uid),
41
+ // 组员没有该账号转过去也用不了,且本地直连最快——无论全局开关一律仅本机直连。
42
+ // model 兼容 canonical(workbuddy/xxx)与 dash(workbuddy-xxx)两种形态。
43
+ export function isHardLocalOnly(model) {
44
+ const s = String(model || "").trim().toLowerCase();
45
+ const slash = s.indexOf("/");
46
+ const head = slash > 0 ? s.slice(0, slash) : s.split("-")[0];
47
+ try {
48
+ return classifyProvider(head) === "local-only";
49
+ } catch {
50
+ return head === "workbuddy";
51
+ }
52
+ }
53
+
39
54
  export function shouldUseGroupForModel(model, { file = defaultStateFile() } = {}) {
55
+ if (isHardLocalOnly(model)) return false;
40
56
  return getEffectiveUseGroup({ file });
41
57
  }
42
58
 
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import os from "node:os";
4
+ import { enrichOpencodeEntry, capsSummary as capsSummaryText } from "./model-capabilities/enrich.js";
4
5
 
5
6
  export function opencodeConfigPath() {
6
7
  const env = process.env.OPENCODE_CONFIG || process.env.OPENCODE_CONFIG_PATH;
@@ -79,21 +80,36 @@ export function pruneOpencodeModels(models, keep, currentKey) {
79
80
  }
80
81
 
81
82
  // 补齐:把 ensureAll(picks 口径)里缺失的键写入 models(slash → dash + alias 注册)。
82
- // 返回 { nextModels, backfilled };ensureAll 非数组或为空时原样返回(backfilled=0)。
83
+ // 已存在但为旧格式(缺 limit 能力字段)的条目也自动补注能力(upgraded 计数)。
84
+ // 返回 { nextModels, backfilled, upgraded };ensureAll 非数组或为空时原样返回。
83
85
  // 注意:需在剪枝之前调用——先补 picks 缺失,再剪 picks 外旧键,结果集恰为 picks ∪ currentKey。
84
- export async function ensureAllOpencodeModels(models, ensureAll) {
86
+ // capsSvc 透传给能力注入(undefined=全局单例;补齐的条目同样带能力)。
87
+ export async function ensureAllOpencodeModels(models, ensureAll, capsSvc) {
85
88
  if (!Array.isArray(ensureAll) || !ensureAll.length || !models || typeof models !== "object") {
86
- return { nextModels: models, backfilled: 0 };
89
+ return { nextModels: models, backfilled: 0, upgraded: 0 };
87
90
  }
88
91
  const existing = new Set(Object.keys(models).map(normalizeOpencodeKey));
89
92
  let backfilled = 0;
93
+ let upgraded = 0;
90
94
  let aliasDirty = false;
91
95
  for (const raw of ensureAll) {
92
96
  const internal = toInternalId(String(raw || "").trim());
93
97
  if (!internal || internal === "auto") continue;
94
98
  const storageKey = internal.includes("/") ? internal.replace(/\//g, "-") : internal;
95
- if (!storageKey || existing.has(storageKey)) continue;
96
- models[storageKey] = { name: storageKey };
99
+ if (!storageKey) continue;
100
+ if (existing.has(storageKey)) {
101
+ // 已存在:旧格式条目(仅 name、无能力字段)或缺档位(variants,ctrl+t 推理档切换)自动重注入;
102
+ // enrich 幂等(字段覆盖一致),重复注入无害
103
+ const cur = models[storageKey];
104
+ if (cur && typeof cur === "object" && !Array.isArray(cur) && (cur.limit === undefined || cur.variants === undefined)) {
105
+ const en = await enrichOpencodeEntry(cur, internal, capsSvc);
106
+ models[storageKey] = en.entry;
107
+ if (en.caps) upgraded++;
108
+ }
109
+ continue;
110
+ }
111
+ const enriched = await enrichOpencodeEntry({ name: storageKey }, internal, capsSvc);
112
+ models[storageKey] = enriched.entry;
97
113
  existing.add(storageKey);
98
114
  backfilled++;
99
115
  if (internal.includes("/") && storageKey !== internal) {
@@ -111,10 +127,10 @@ export async function ensureAllOpencodeModels(models, ensureAll) {
111
127
  persistModelAliases();
112
128
  } catch {}
113
129
  }
114
- return { nextModels: models, backfilled };
130
+ return { nextModels: models, backfilled, upgraded };
115
131
  }
116
132
 
117
- export async function syncToOpencode({ id, token, port, file, keep, ensureAll } = {}) {
133
+ export async function syncToOpencode({ id, token, port, file, keep, ensureAll, capabilities } = {}) {
118
134
  const targetFile = file || opencodeConfigPath();
119
135
  const normalizedRaw = String(id || "").trim();
120
136
  if (!normalizedRaw) throw new Error("model id required");
@@ -156,6 +172,8 @@ export async function syncToOpencode({ id, token, port, file, keep, ensureAll }
156
172
  let effectiveId = storageKey;
157
173
  let pruned = 0;
158
174
  let backfilled = 0;
175
+ let upgraded = 0;
176
+ let currentCaps = null;
159
177
  if (oldProvider) {
160
178
  const oldModels = oldProvider.models && typeof oldProvider.models === "object" && !Array.isArray(oldProvider.models)
161
179
  ? oldProvider.models
@@ -187,16 +205,21 @@ export async function syncToOpencode({ id, token, port, file, keep, ensureAll }
187
205
  if (nextModels[legacyAlias]) delete nextModels[legacyAlias];
188
206
  if (legacyInternal !== legacyAlias && nextModels[legacyInternal]) delete nextModels[legacyInternal];
189
207
  }
190
- nextModels[storageKey] = { ...merged, name: storageKey };
208
+ const enriched = await enrichOpencodeEntry({ ...merged, name: storageKey }, internal, capabilities);
209
+ nextModels[storageKey] = enriched.entry;
210
+ currentCaps = enriched.caps;
191
211
  effectiveId = storageKey;
192
212
  action = "updated";
193
213
  } else {
194
- nextModels[storageKey] = { name: storageKey };
214
+ const enriched = await enrichOpencodeEntry({ name: storageKey }, internal, capabilities);
215
+ nextModels[storageKey] = enriched.entry;
216
+ currentCaps = enriched.caps;
195
217
  effectiveId = storageKey;
196
218
  action = "inserted";
197
219
  }
198
- const ensured = await ensureAllOpencodeModels(nextModels, ensureAll);
220
+ const ensured = await ensureAllOpencodeModels(nextModels, ensureAll, capabilities);
199
221
  backfilled = ensured.backfilled;
222
+ upgraded = ensured.upgraded || 0;
200
223
  pruned = pruneOpencodeModels(nextModels, keep, storageKey);
201
224
  const nextProvider = {
202
225
  ...oldProvider,
@@ -216,8 +239,12 @@ export async function syncToOpencode({ id, token, port, file, keep, ensureAll }
216
239
  if (!data.provider.mslxdff.models[storageKey]) {
217
240
  data.provider.mslxdff.models = { [storageKey]: { name: storageKey } };
218
241
  }
219
- const ensured = await ensureAllOpencodeModels(data.provider.mslxdff.models, ensureAll);
242
+ const enrichedNew = await enrichOpencodeEntry(data.provider.mslxdff.models[storageKey], internal, capabilities);
243
+ data.provider.mslxdff.models[storageKey] = enrichedNew.entry;
244
+ currentCaps = enrichedNew.caps;
245
+ const ensured = await ensureAllOpencodeModels(data.provider.mslxdff.models, ensureAll, capabilities);
220
246
  backfilled = ensured.backfilled;
247
+ upgraded = ensured.upgraded || 0;
221
248
  action = "inserted";
222
249
  }
223
250
 
@@ -249,5 +276,5 @@ export async function syncToOpencode({ id, token, port, file, keep, ensureAll }
249
276
  }
250
277
  } catch {}
251
278
 
252
- return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey, pruned, backfilled };
279
+ return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey, pruned, backfilled, upgraded, caps: currentCaps, capsSummaryText: capsSummaryText(currentCaps) };
253
280
  }
@@ -0,0 +1,69 @@
1
+ // 上游引擎选择(ADR-0017):sdk=AI SDK wire 层(缺省),legacy=原实现(显式/Node16 回退)。
2
+ // Note: 缺省即 sdk;chat 类走 /chat/completions(@ai-sdk/openai-compatible),
3
+ // responses 类(muse-spark*)走 /responses(@ai-sdk/openai);非流式与 anon 重试委派 legacy,
4
+ // SDK 不可用自动回退并告警一次 — 见 .scratch/ai-sdk-upstream/{SPEC.md,SPEC-p4-turnon.md,SPEC-p3-responses.md} 与 docs/adr/0017
5
+ import { createUpstreamClient, createOpencodeHeaderBuilder } from "../upstream.js";
6
+ import { isResponsesModel } from "../upstream-responses.js";
7
+ import { isFreeModel } from "../models.js";
8
+ import { createSdkChat } from "./sdk/chat.js";
9
+ import { createSdkResponses } from "./sdk/responses.js";
10
+ import { dispatcherFetch } from "./sdk/attempt.js";
11
+ import { resolveEngineMode } from "./mode.js";
12
+
13
+ export { resolveEngineMode };
14
+
15
+ export function createUpstreamEngine(opts = {}) {
16
+ const legacy = createUpstreamClient(opts);
17
+ const env = opts.env ?? process.env;
18
+ if (resolveEngineMode(env) !== "sdk") return legacy;
19
+
20
+ const baseUrl = opts.baseUrl || process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
21
+ const sdkFactory = opts.sdkFactory ?? createSdkChat;
22
+ const responsesFactory = opts.responsesFactory ?? createSdkResponses;
23
+ const { buildHeaders, anonFirst } = createOpencodeHeaderBuilder({
24
+ authToken: opts.authToken || process.env.UPSTREAM_AUTH_TOKEN || "public",
25
+ });
26
+ const dispatcher = legacy.dispatcher;
27
+ const fetchImpl = dispatcher ? dispatcherFetch(dispatcher) : undefined;
28
+ const sdk = sdkFactory({ baseUrl, buildHeaders, providerName: "opencode", fetchImpl });
29
+ const responses = responsesFactory({ baseUrl, buildHeaders, providerName: "opencode", fetchImpl });
30
+
31
+ let sdkDown = false;
32
+ let logged = false;
33
+
34
+ async function chat(body) {
35
+ if (sdkDown) return legacy.chat(body);
36
+ // doGenerate 聚合未实现:非流式(含 responses 非流式)委派 legacy,避免把 JSON 客户端 SSE 化
37
+ if (body?.stream === false) return legacy.chat(body);
38
+ const useResponses = isResponsesModel(body?.model);
39
+ try {
40
+ const res = useResponses ? await responses.chat(body) : await sdk.chat(body);
41
+ if (!logged) {
42
+ logged = true;
43
+ const via = useResponses ? "@ai-sdk/openai · responses" : "@ai-sdk/openai-compatible · chat";
44
+ try { console.error(`[upstream-engine] sdk 引擎生效(${via})`); } catch {}
45
+ }
46
+ // 保留 legacy 的匿名重试语义(仅 !anonFirst 配置下存在)
47
+ if (res.status === 429 && !anonFirst && isFreeModel(body?.model)) return legacy.chat(body);
48
+ return res;
49
+ } catch (e) {
50
+ if (e && e._sdkLoadFailed) {
51
+ sdkDown = true;
52
+ try { console.error(`[upstream-engine] ${e.message} — 回退 legacy 引擎`); } catch {}
53
+ return legacy.chat(body);
54
+ }
55
+ throw e;
56
+ }
57
+ }
58
+
59
+ return {
60
+ chat,
61
+ preheat: (args) => legacy.preheat(args),
62
+ close: () => legacy.close(),
63
+ headers: legacy.headers,
64
+ buildHeaders: legacy.buildHeaders,
65
+ get dispatcher() { return legacy.dispatcher; },
66
+ get agent() { return legacy.agent; },
67
+ [Symbol.asyncDispose]: () => legacy.close(),
68
+ };
69
+ }
@@ -0,0 +1,13 @@
1
+ // 引擎模式解析(纯函数,零依赖):缺省 sdk,legacy/关闭词回退原实现。
2
+ // 供应商级开关未设置时继承全局总闸,使 MSLXDFF_UPSTREAM_ENGINE=legacy 成为一键熔断。
3
+ // 供 upstream-engine/index.js(opencode 上游)与 providers/workbuddy/chat.js 共用。
4
+ // 见 docs/adr/0017。
5
+ const ENGINE_OFF_WORDS = new Set(["0", "off", "false", "no", "disable", "disabled"]);
6
+
7
+ export function resolveEngineMode(env = process.env, varName = "MSLXDFF_UPSTREAM_ENGINE") {
8
+ const pick = (name) => String(env?.[name] ?? "").trim().toLowerCase();
9
+ let raw = pick(varName);
10
+ if (!raw && varName !== "MSLXDFF_UPSTREAM_ENGINE") raw = pick("MSLXDFF_UPSTREAM_ENGINE");
11
+ if (raw === "legacy" || ENGINE_OFF_WORDS.has(raw)) return "legacy";
12
+ return "sdk";
13
+ }
@@ -0,0 +1,136 @@
1
+ // AI SDK 请求执行器(共用):@ai-sdk/openai-compatible 的懒加载与调用点。
2
+ // 契约:attemptOnceSdk({url, body, headers, providerName, marker, fetchImpl}) → Response(OpenAI SSE)。
3
+ // headers 由调用方完整构造(Authorization 原样透传,不走 apiKey 注入);
4
+ // HTTP 错误就地映射为带状态码的 Response;装载失败抛 _sdkLoadFailed 由引擎回退 legacy。
5
+ // responses 适配器(sdk/responses.js)复用本文件的 baseURL 解析、错误映射与流序列化。
6
+ // 见 .scratch/ai-sdk-upstream/{SPEC.md,SPEC-p3-responses.md} 与 docs/adr/0017。
7
+ import { toModelPrompt, toModelTools, toModelToolChoice, toModelParams } from "./convert.js";
8
+ import { createSseSerializer } from "./sse.js";
9
+ import { getUndici } from "../../compat.js";
10
+
11
+ let sdkPromise = null;
12
+
13
+ export function loadOpenAICompatibleSdk() {
14
+ if (!sdkPromise) sdkPromise = import("@ai-sdk/openai-compatible");
15
+ return sdkPromise;
16
+ }
17
+
18
+ // legacy 的 keep-alive 连接池以 fetch 形式注入 SDK(P4 转正:延迟与 legacy 同量级,不另建连接)。
19
+ export function dispatcherFetch(dispatcher) {
20
+ const f = getUndici().fetch;
21
+ return (url, init = {}) => f(url, { ...init, dispatcher });
22
+ }
23
+
24
+ // 剥掉 url 末尾的协议段,得到 SDK baseURL(chat 走 /chat/completions,responses 走 /responses)。
25
+ export function sdkBaseFromUrl(url, suffix = "/chat/completions") {
26
+ const s = String(url || "");
27
+ const esc = String(suffix).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
+ const re = new RegExp(`${esc}/?$`);
29
+ if (!re.test(s)) return null;
30
+ return s.replace(re, "");
31
+ }
32
+
33
+ // 只剥 Content-Type(由 SDK 固定 application/json);其余头(含 Authorization)原样透传
34
+ export function sanitizeHeaders(headers) {
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(headers || {})) {
37
+ if (String(k).toLowerCase() === "content-type") continue;
38
+ out[k] = v;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ export function markerHeaders(marker) {
44
+ return marker && marker.name ? { [marker.name]: marker.value } : {};
45
+ }
46
+
47
+ // SDK 抛出的 APICallError → 带状态码的 Response;非 HTTP 错误返回 null 由调用方 rethrow。
48
+ export function errorResponseFromSdkError(e, { marker = null } = {}) {
49
+ const status = Number(e?.statusCode ?? e?.status);
50
+ if (!Number.isFinite(status) || status < 400) return null;
51
+ const text = typeof e.responseBody === "string"
52
+ ? e.responseBody
53
+ : JSON.stringify({ error: { message: e?.message || String(e) } });
54
+ return new Response(text, {
55
+ status,
56
+ headers: { "content-type": "application/json", ...markerHeaders(marker) },
57
+ });
58
+ }
59
+
60
+ // SDK parts → OpenAI SSE Response(chat/responses 共用同一序列化器)。
61
+ export function streamResponseFromParts(parts, { marker = null, clock = Date.now, t0 = clock() } = {}) {
62
+ const ser = createSseSerializer();
63
+ const enc = new TextEncoder();
64
+ const stream = new ReadableStream({
65
+ async start(controller) {
66
+ try {
67
+ for await (const part of parts) {
68
+ const text = ser.push(part);
69
+ if (text) controller.enqueue(enc.encode(text));
70
+ }
71
+ } catch (e) {
72
+ controller.enqueue(enc.encode(`data: ${JSON.stringify({ error: { message: String(e?.message || e) } })}\n\n`));
73
+ }
74
+ controller.enqueue(enc.encode(ser.end()));
75
+ controller.close();
76
+ },
77
+ cancel() {
78
+ try { parts.cancel?.(); } catch {}
79
+ },
80
+ });
81
+ const out = new Response(stream, {
82
+ status: 200,
83
+ headers: { "content-type": "text/event-stream", ...markerHeaders(marker) },
84
+ });
85
+ try { out._t = { attempts: [{ type: "http200", ms: Math.round(clock() - t0) }], waitMs: 0, totalMs: Math.round(clock() - t0) }; } catch {}
86
+ return out;
87
+ }
88
+
89
+ export async function attemptOnceSdk({
90
+ url,
91
+ body,
92
+ headers = {},
93
+ providerName = "opencode",
94
+ marker = null,
95
+ clock = Date.now,
96
+ sdkLoader = loadOpenAICompatibleSdk,
97
+ fetchImpl,
98
+ } = {}) {
99
+ const t0 = clock();
100
+ const baseURL = sdkBaseFromUrl(url);
101
+ if (!baseURL) {
102
+ // 异形 chatPath(非 /chat/completions 结尾):标 _sdkUnsupported 由分派层静默回退原生
103
+ const err = new Error(`sdk-channel: chatPath 非 /chat/completions,暂不支持(${url})`);
104
+ err._sdkUnsupported = true;
105
+ throw err;
106
+ }
107
+ let createOpenAICompatible;
108
+ try {
109
+ ({ createOpenAICompatible } = await sdkLoader());
110
+ } catch (e) {
111
+ const err = new Error(`sdk-channel: @ai-sdk/openai-compatible 不可用(需 Node>=18 且已安装): ${e?.message || e}`);
112
+ err._sdkLoadFailed = true;
113
+ throw err;
114
+ }
115
+ const provider = createOpenAICompatible({
116
+ name: providerName,
117
+ baseURL,
118
+ headers: sanitizeHeaders(headers),
119
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
120
+ });
121
+ const model = provider.chatModel(String(body?.model || ""));
122
+ let res;
123
+ try {
124
+ res = await model.doStream({
125
+ prompt: toModelPrompt(body?.messages),
126
+ ...toModelParams(body, providerName),
127
+ tools: toModelTools(body?.tools),
128
+ toolChoice: toModelToolChoice(body?.tool_choice),
129
+ });
130
+ } catch (e) {
131
+ const mapped = errorResponseFromSdkError(e, { marker });
132
+ if (mapped) return mapped;
133
+ throw e;
134
+ }
135
+ return streamResponseFromParts(res.stream, { marker, clock, t0 });
136
+ }
@@ -0,0 +1,28 @@
1
+ // SDK 引擎的 chat 适配器工厂:拼 URL → attemptOnceSdk。
2
+ // buildHeaders(body) 由调用方注入(身份头随供应商而异,单一来源在引擎侧)。
3
+ import { attemptOnceSdk } from "./attempt.js";
4
+
5
+ export const ENGINE_MARKER = { name: "x-mslxdff-upstream-engine", value: "sdk" };
6
+
7
+ export function createSdkChat({
8
+ baseUrl,
9
+ chatPath = "/zen/v1/chat/completions",
10
+ buildHeaders,
11
+ providerName = "opencode",
12
+ marker = ENGINE_MARKER,
13
+ fetchImpl,
14
+ } = {}) {
15
+ const base = String(baseUrl || "").replace(/\/+$/, "");
16
+ return {
17
+ async chat(body) {
18
+ return attemptOnceSdk({
19
+ url: `${base}${chatPath}`,
20
+ body,
21
+ headers: buildHeaders ? buildHeaders(body) : {},
22
+ providerName,
23
+ marker,
24
+ fetchImpl,
25
+ });
26
+ },
27
+ };
28
+ }