mslxdff 0.1.110 → 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.
- package/package.json +6 -1
- package/src/chat-pipeline/dedup.js +83 -0
- package/src/chat-pipeline/serial-trial.js +3 -3
- package/src/cli/commands/model/list.js +15 -2
- package/src/cli/commands/provider/models.js +14 -1
- package/src/model-capabilities/enrich.js +16 -2
- package/src/model-capabilities/index.js +34 -1
- package/src/model-capabilities/parse.js +29 -0
- package/src/providers/base.js +11 -1
- package/src/providers/classify.js +22 -0
- package/src/providers/cline/chat.js +21 -6
- package/src/providers/model-id.js +29 -0
- package/src/providers/workbuddy/chat.js +19 -0
- package/src/providers/workbuddy/reshape.js +23 -16
- package/src/providers/workbuddy/sdk-chat.js +25 -0
- package/src/routes/hedge.js +2 -2
- package/src/routes/models-route.js +32 -4
- package/src/runtime/providers-setup.js +2 -1
- package/src/state/schemas/use-group.js +16 -0
- package/src/sync-opencode.js +3 -2
- package/src/upstream-engine/index.js +69 -0
- package/src/upstream-engine/mode.js +13 -0
- package/src/upstream-engine/sdk/attempt.js +136 -0
- package/src/upstream-engine/sdk/chat.js +28 -0
- package/src/upstream-engine/sdk/convert.js +133 -0
- package/src/upstream-engine/sdk/dispatch.js +55 -0
- package/src/upstream-engine/sdk/responses.js +85 -0
- package/src/upstream-engine/sdk/sse.js +93 -0
- package/src/upstream-probe/display.js +52 -0
- package/src/upstream-probe/probe.js +49 -0
- package/src/upstream-probe/rotate.js +110 -0
- package/src/upstream-probe/start.js +45 -0
- package/src/upstream.js +23 -16
|
@@ -85,17 +85,45 @@ export async function providerModelsHandler({ req, res, models }) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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 缓存)
|
|
90
101
|
// jsonFn 注入仅为测试接缝(S3);生产路径用 helpers.json
|
|
91
|
-
export async function capabilitiesHandler({ req, res, capabilities, jsonFn = json }) {
|
|
102
|
+
export async function capabilitiesHandler({ req, res, capabilities, wbSource, jsonFn = json }) {
|
|
92
103
|
// 仅未注入(生产)时用全局单例;显式 null 视作服务不可用(测试可复现 502 空状态)
|
|
93
104
|
const svc = capabilities === undefined ? globalCapabilities() : capabilities;
|
|
94
|
-
if (!svc) return jsonFn(res, 502, { error: "capabilities service unavailable" });
|
|
105
|
+
if (!svc && !wbSource) return jsonFn(res, 502, { error: "capabilities service unavailable" });
|
|
95
106
|
const q = String(req?.url || "").split("?")[1] || "";
|
|
96
107
|
const params = new URLSearchParams(q);
|
|
97
108
|
const provider = (params.get("provider") || "opencode").toLowerCase();
|
|
98
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
|
+
}
|
|
99
127
|
try {
|
|
100
128
|
await svc.ready();
|
|
101
129
|
} catch (err) {
|
|
@@ -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 =
|
|
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
|
|
package/src/sync-opencode.js
CHANGED
|
@@ -98,9 +98,10 @@ export async function ensureAllOpencodeModels(models, ensureAll, capsSvc) {
|
|
|
98
98
|
const storageKey = internal.includes("/") ? internal.replace(/\//g, "-") : internal;
|
|
99
99
|
if (!storageKey) continue;
|
|
100
100
|
if (existing.has(storageKey)) {
|
|
101
|
-
// 已存在:旧格式条目(仅 name
|
|
101
|
+
// 已存在:旧格式条目(仅 name、无能力字段)或缺档位(variants,ctrl+t 推理档切换)自动重注入;
|
|
102
|
+
// enrich 幂等(字段覆盖一致),重复注入无害
|
|
102
103
|
const cur = models[storageKey];
|
|
103
|
-
if (cur && typeof cur === "object" && !Array.isArray(cur) && cur.limit === undefined) {
|
|
104
|
+
if (cur && typeof cur === "object" && !Array.isArray(cur) && (cur.limit === undefined || cur.variants === undefined)) {
|
|
104
105
|
const en = await enrichOpencodeEntry(cur, internal, capsSvc);
|
|
105
106
|
models[storageKey] = en.entry;
|
|
106
107
|
if (en.caps) upgraded++;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// OpenAI 请求体 → @ai-sdk/openai-compatible 入参(仅 chat)。
|
|
2
|
+
// 显式映射常用面;未映射字段经 toProviderExtras 原样透传(providerOptions.<providerName> 会被 spread 进请求体)。
|
|
3
|
+
// 共用库:workbuddy 实验通道与 upstream-engine 同源。见 .scratch/ai-sdk-upstream/SPEC.md。
|
|
4
|
+
|
|
5
|
+
const HANDLED_KEYS = new Set([
|
|
6
|
+
"model", "messages", "stream", "tools", "tool_choice",
|
|
7
|
+
"temperature", "top_p", "max_tokens", "stop",
|
|
8
|
+
"presence_penalty", "frequency_penalty", "seed", "reasoning_effort",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function textOf(content) {
|
|
12
|
+
if (typeof content === "string") return content;
|
|
13
|
+
if (Array.isArray(content)) {
|
|
14
|
+
return content.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
|
|
15
|
+
}
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function userContentParts(content) {
|
|
20
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
21
|
+
if (!Array.isArray(content)) return [{ type: "text", text: String(content ?? "") }];
|
|
22
|
+
const parts = [];
|
|
23
|
+
for (const p of content) {
|
|
24
|
+
if (!p || typeof p !== "object") continue;
|
|
25
|
+
if (p.type === "text" || p.type === "input_text") {
|
|
26
|
+
parts.push({ type: "text", text: String(p.text ?? "") });
|
|
27
|
+
} else if (p.type === "image_url") {
|
|
28
|
+
const url = typeof p.image_url === "string" ? p.image_url : p.image_url?.url;
|
|
29
|
+
if (!url) continue;
|
|
30
|
+
try { parts.push({ type: "file", mediaType: "image/*", data: new URL(url) }); } catch {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!parts.length) parts.push({ type: "text", text: "" });
|
|
34
|
+
return parts;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseToolArgs(raw) {
|
|
38
|
+
if (raw && typeof raw === "object") return raw;
|
|
39
|
+
try { return JSON.parse(String(raw)); } catch { return {}; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toModelPrompt(messages) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const m of Array.isArray(messages) ? messages : []) {
|
|
45
|
+
if (!m || typeof m !== "object") continue;
|
|
46
|
+
const role = String(m.role || "");
|
|
47
|
+
if (role === "system" || role === "developer") {
|
|
48
|
+
out.push({ role: "system", content: textOf(m.content) });
|
|
49
|
+
} else if (role === "user") {
|
|
50
|
+
out.push({ role: "user", content: userContentParts(m.content) });
|
|
51
|
+
} else if (role === "assistant") {
|
|
52
|
+
const parts = [];
|
|
53
|
+
const text = textOf(m.content);
|
|
54
|
+
if (text) parts.push({ type: "text", text });
|
|
55
|
+
if (m.reasoning_content) parts.push({ type: "reasoning", text: String(m.reasoning_content) });
|
|
56
|
+
for (const tc of Array.isArray(m.tool_calls) ? m.tool_calls : []) {
|
|
57
|
+
if (!tc || typeof tc !== "object") continue;
|
|
58
|
+
parts.push({
|
|
59
|
+
type: "tool-call",
|
|
60
|
+
toolCallId: String(tc.id || ""),
|
|
61
|
+
toolName: String(tc.function?.name || ""),
|
|
62
|
+
input: parseToolArgs(tc.function?.arguments),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
out.push({ role: "assistant", content: parts });
|
|
66
|
+
} else if (role === "tool") {
|
|
67
|
+
out.push({
|
|
68
|
+
role: "tool",
|
|
69
|
+
content: [{
|
|
70
|
+
type: "tool-result",
|
|
71
|
+
toolCallId: String(m.tool_call_id || ""),
|
|
72
|
+
toolName: String(m.name || "tool"),
|
|
73
|
+
output: { type: "text", value: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "") },
|
|
74
|
+
}],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function toModelTools(tools) {
|
|
82
|
+
if (!Array.isArray(tools)) return undefined;
|
|
83
|
+
const out = tools
|
|
84
|
+
.filter((t) => t && t.type === "function" && t.function?.name)
|
|
85
|
+
.map((t) => ({
|
|
86
|
+
type: "function",
|
|
87
|
+
name: String(t.function.name),
|
|
88
|
+
description: t.function.description ? String(t.function.description) : undefined,
|
|
89
|
+
inputSchema: t.function.parameters && typeof t.function.parameters === "object"
|
|
90
|
+
? t.function.parameters
|
|
91
|
+
: { type: "object", properties: {} },
|
|
92
|
+
}));
|
|
93
|
+
return out.length ? out : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function toModelToolChoice(choice) {
|
|
97
|
+
if (!choice) return undefined;
|
|
98
|
+
if (typeof choice === "string") {
|
|
99
|
+
return ["auto", "none", "required"].includes(choice) ? { type: choice } : undefined;
|
|
100
|
+
}
|
|
101
|
+
if (choice.type === "function" && choice.function?.name) {
|
|
102
|
+
return { type: "tool", toolName: String(choice.function.name) };
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function toProviderExtras(body) {
|
|
108
|
+
const extras = {};
|
|
109
|
+
for (const [k, v] of Object.entries(body || {})) {
|
|
110
|
+
if (!HANDLED_KEYS.has(k) && v !== undefined) extras[k] = v;
|
|
111
|
+
}
|
|
112
|
+
return Object.keys(extras).length ? extras : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function toModelParams(body, providerName = "opencode") {
|
|
116
|
+
const params = {};
|
|
117
|
+
if (body?.max_tokens != null) params.maxOutputTokens = body.max_tokens;
|
|
118
|
+
if (body?.temperature != null) params.temperature = body.temperature;
|
|
119
|
+
if (body?.top_p != null) params.topP = body.top_p;
|
|
120
|
+
if (body?.presence_penalty != null) params.presencePenalty = body.presence_penalty;
|
|
121
|
+
if (body?.frequency_penalty != null) params.frequencyPenalty = body.frequency_penalty;
|
|
122
|
+
if (Array.isArray(body?.stop)) params.stopSequences = body.stop;
|
|
123
|
+
else if (typeof body?.stop === "string" && body.stop) params.stopSequences = [body.stop];
|
|
124
|
+
if (body?.seed != null) params.seed = body.seed;
|
|
125
|
+
const providerOptions = {};
|
|
126
|
+
if (body?.reasoning_effort != null) {
|
|
127
|
+
providerOptions.openaiCompatible = { reasoningEffort: String(body.reasoning_effort) };
|
|
128
|
+
}
|
|
129
|
+
const extras = toProviderExtras(body);
|
|
130
|
+
if (extras) providerOptions[providerName] = extras;
|
|
131
|
+
if (Object.keys(providerOptions).length) params.providerOptions = providerOptions;
|
|
132
|
+
return params;
|
|
133
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// 供应商级 SDK 分派(薄壳):通用 OpenAI 兼容上游的"请求发出口"共用。
|
|
2
|
+
// 缺省 sdk(attemptOnceSdk 走 @ai-sdk/openai-compatible);varName 显式设 legacy/关闭词即回退,
|
|
3
|
+
// 未设置则继承全局 MSLXDFF_UPSTREAM_ENGINE(一键熔断)。
|
|
4
|
+
// SDK 不可用抛 _sdkLoadFailed → 回退原生并告警一次;其它错误 rethrow 交调用方重试语义。
|
|
5
|
+
// 见 .scratch/ai-sdk-providers-rest/SPEC.md 与 docs/adr/0017。
|
|
6
|
+
import { resolveEngineMode } from "../mode.js";
|
|
7
|
+
import { attemptOnceSdk } from "./attempt.js";
|
|
8
|
+
|
|
9
|
+
export function sdkEnvName(id) {
|
|
10
|
+
return `MSLXDFF_${String(id || "").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}_SDK`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createSdkDispatch({
|
|
14
|
+
id,
|
|
15
|
+
varName,
|
|
16
|
+
providerName,
|
|
17
|
+
markerName = "x-mslxdff-upstream-engine",
|
|
18
|
+
label,
|
|
19
|
+
env = process.env,
|
|
20
|
+
logger = console,
|
|
21
|
+
attemptImpl = attemptOnceSdk,
|
|
22
|
+
} = {}) {
|
|
23
|
+
const name = varName || sdkEnvName(id);
|
|
24
|
+
const provider = providerName || id || "provider";
|
|
25
|
+
const tag = label || provider;
|
|
26
|
+
const mode = resolveEngineMode(env, name);
|
|
27
|
+
let fallbackLogged = false;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
varName: name,
|
|
31
|
+
enabled: mode === "sdk",
|
|
32
|
+
// 成功返回 Response;_sdkLoadFailed → null(调用方回退原生);其它错误 rethrow
|
|
33
|
+
async trySdk({ url, body, headers, fetchImpl, clock } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
return await attemptImpl({
|
|
36
|
+
url,
|
|
37
|
+
body,
|
|
38
|
+
headers,
|
|
39
|
+
providerName: provider,
|
|
40
|
+
marker: markerName ? { name: markerName, value: "sdk" } : null,
|
|
41
|
+
fetchImpl,
|
|
42
|
+
clock,
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e && e._sdkUnsupported) return null; // 异形 chatPath:静默回退原生
|
|
46
|
+
if (!e || !e._sdkLoadFailed) throw e;
|
|
47
|
+
if (!fallbackLogged) {
|
|
48
|
+
fallbackLogged = true;
|
|
49
|
+
try { logger.error(`[${tag}] ${e.message} — 回退原生通道`); } catch {}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Responses API 适配器(@ai-sdk/openai):models.dev 里模型级 npm=@ai-sdk/openai 的模型
|
|
2
|
+
// (如 muse-spark*,打 /zen/v1/responses)。与 chat 适配器同契约:输出 OpenAI chat SSE 帧。
|
|
3
|
+
// 复用 attempt.js 的 baseURL 解析 / 错误映射 / 流序列化,不复制实现。
|
|
4
|
+
// 见 .scratch/ai-sdk-upstream/SPEC-p3-responses.md 与 docs/adr/0017。
|
|
5
|
+
import { toModelPrompt, toModelTools, toModelToolChoice, toModelParams } from "./convert.js";
|
|
6
|
+
import { sdkBaseFromUrl, sanitizeHeaders, errorResponseFromSdkError, streamResponseFromParts } from "./attempt.js";
|
|
7
|
+
import { ENGINE_MARKER } from "./chat.js";
|
|
8
|
+
|
|
9
|
+
export const RESPONSES_CHAT_PATH = "/zen/v1/responses";
|
|
10
|
+
|
|
11
|
+
let sdkPromise = null;
|
|
12
|
+
|
|
13
|
+
export function loadOpenAISdk() {
|
|
14
|
+
if (!sdkPromise) sdkPromise = import("@ai-sdk/openai");
|
|
15
|
+
return sdkPromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function attemptOnceResponsesSdk({
|
|
19
|
+
url,
|
|
20
|
+
body,
|
|
21
|
+
headers = {},
|
|
22
|
+
providerName = "opencode",
|
|
23
|
+
marker = null,
|
|
24
|
+
clock = Date.now,
|
|
25
|
+
sdkLoader = loadOpenAISdk,
|
|
26
|
+
fetchImpl,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const t0 = clock();
|
|
29
|
+
const baseURL = sdkBaseFromUrl(url, "/responses");
|
|
30
|
+
if (!baseURL) throw new Error(`sdk-channel: responsesPath 非 /responses,暂不支持(${url})`);
|
|
31
|
+
let createOpenAI;
|
|
32
|
+
try {
|
|
33
|
+
({ createOpenAI } = await sdkLoader());
|
|
34
|
+
} catch (e) {
|
|
35
|
+
const err = new Error(`sdk-channel: @ai-sdk/openai 不可用(需 Node>=18 且已安装): ${e?.message || e}`);
|
|
36
|
+
err._sdkLoadFailed = true;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
// headers 由调用方构造(含 Authorization),headers 优先于 apiKey 默认头
|
|
40
|
+
const provider = createOpenAI({
|
|
41
|
+
name: providerName,
|
|
42
|
+
baseURL,
|
|
43
|
+
apiKey: "public",
|
|
44
|
+
headers: sanitizeHeaders(headers),
|
|
45
|
+
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
46
|
+
});
|
|
47
|
+
const model = provider.responses(String(body?.model || ""));
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await model.doStream({
|
|
51
|
+
prompt: toModelPrompt(body?.messages),
|
|
52
|
+
...toModelParams(body, providerName),
|
|
53
|
+
tools: toModelTools(body?.tools),
|
|
54
|
+
toolChoice: toModelToolChoice(body?.tool_choice),
|
|
55
|
+
});
|
|
56
|
+
} catch (e) {
|
|
57
|
+
const mapped = errorResponseFromSdkError(e, { marker });
|
|
58
|
+
if (mapped) return mapped;
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
return streamResponseFromParts(res.stream, { marker, clock, t0 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createSdkResponses({
|
|
65
|
+
baseUrl,
|
|
66
|
+
chatPath = RESPONSES_CHAT_PATH,
|
|
67
|
+
buildHeaders,
|
|
68
|
+
providerName = "opencode",
|
|
69
|
+
marker = ENGINE_MARKER,
|
|
70
|
+
fetchImpl,
|
|
71
|
+
} = {}) {
|
|
72
|
+
const base = String(baseUrl || "").replace(/\/+$/, "");
|
|
73
|
+
return {
|
|
74
|
+
async chat(body) {
|
|
75
|
+
return attemptOnceResponsesSdk({
|
|
76
|
+
url: `${base}${chatPath}`,
|
|
77
|
+
body,
|
|
78
|
+
headers: buildHeaders ? buildHeaders(body) : {},
|
|
79
|
+
providerName,
|
|
80
|
+
marker,
|
|
81
|
+
fetchImpl,
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|