mslxdff 0.1.117 → 0.1.119
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
CHANGED
|
@@ -4,6 +4,8 @@ import { appendRotationLog as defaultAppend } from "./rotation-log.js";
|
|
|
4
4
|
import { createTransport } from "../../transport/index.js";
|
|
5
5
|
import { reshapeWorkbuddySse } from "./reshape.js";
|
|
6
6
|
import { attemptOnceSdk } from "./sdk-chat.js";
|
|
7
|
+
import { sanitizeToolSequence } from "./sanitize-tools.js";
|
|
8
|
+
import { rewriteWorkbuddyPayload } from "./payload.js";
|
|
7
9
|
import { resolveEngineMode } from "../../upstream-engine/mode.js";
|
|
8
10
|
|
|
9
11
|
function buildAuthHeaders(key, auth) {
|
|
@@ -98,12 +100,17 @@ export function createChatService({
|
|
|
98
100
|
// (局部 MSLXDFF_WORKBUDDY_SDK,未设则继承全局 MSLXDFF_UPSTREAM_ENGINE)回退原生 transport;
|
|
99
101
|
// 不可用(Node16/未安装)自动回退并告警一次。上层轮换/刷新/reshape 全链复用。
|
|
100
102
|
// Note: 为什么默认 SDK、翻译层代价与回退语义 — 见 .agents/notes/implemented/feature/2026-09-12-workbuddy-sdk-channel.md
|
|
101
|
-
|
|
103
|
+
// workbuddy 缺省 native transport(legacy):上游对 DeepSeek 有非标字段要求
|
|
104
|
+
// (thinking 注入 / reasoning_content 回填,见 payload.js),AI SDK 序列化层 providerOptions
|
|
105
|
+
// 白名单无法承载这些字段——v0.1.111~118 的 SDK 缺省即 tool 校验 400 的温床。
|
|
106
|
+
// 显式 MSLXDFF_WORKBUDDY_SDK=sdk(或全局 MSLXDFF_UPSTREAM_ENGINE=sdk)仍可切回 SDK 通道。
|
|
107
|
+
const engineMode = resolveEngineMode(process.env, "MSLXDFF_WORKBUDDY_SDK", "legacy");
|
|
102
108
|
let sdkFallbackLogged = false;
|
|
103
109
|
async function fetchOnce(url, body, key, auth) {
|
|
110
|
+
const payload = rewriteWorkbuddyPayload(body);
|
|
104
111
|
if (engineMode === "sdk") {
|
|
105
112
|
try {
|
|
106
|
-
return await attemptOnceSdk({ url, body, key, auth, buildHeaders: buildAuthHeaders });
|
|
113
|
+
return await attemptOnceSdk({ url, body: payload, key, auth, buildHeaders: buildAuthHeaders });
|
|
107
114
|
} catch (e) {
|
|
108
115
|
if (!e || !e._sdkLoadFailed) throw e;
|
|
109
116
|
if (!sdkFallbackLogged) {
|
|
@@ -112,7 +119,7 @@ export function createChatService({
|
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
|
-
return transport.request({ url, headers: buildAuthHeaders(key, auth), body: { ...
|
|
122
|
+
return transport.request({ url, headers: buildAuthHeaders(key, auth), body: { ...payload, stream: true }, stream: true });
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
async function withRefresh(url, body, key, auth) {
|
|
@@ -150,6 +157,11 @@ export function createChatService({
|
|
|
150
157
|
async function runChat(body, activeRing, opts = {}) {
|
|
151
158
|
const url = joinUrl(baseUrl, chatPath);
|
|
152
159
|
const t0 = nowMs(clock);
|
|
160
|
+
const clean = sanitizeToolSequence(body?.messages);
|
|
161
|
+
if (clean.droppedCalls || clean.droppedResults) {
|
|
162
|
+
try { console.error(`[workbuddy] tool-sequence sanitized: dropped ${clean.droppedCalls} call(s), ${clean.droppedResults} result(s), model=${body?.model || ""}`); } catch {}
|
|
163
|
+
body = { ...body, messages: clean.messages };
|
|
164
|
+
}
|
|
153
165
|
_t0Val = t0;
|
|
154
166
|
_modelVal = body?.model || "";
|
|
155
167
|
const preferredUid = opts?.workbuddyUid ? String(opts.workbuddyUid).trim() : "";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// workbuddy 出站 payload 改写(逆向官方客户端行为,参考 Sliverkiss/workbuddy2api 的 payload.go/thinking.go):
|
|
2
|
+
// 1. developer → system:上游 role 白名单不含 developer(命中 400 code=11128)
|
|
3
|
+
// 2. tool_choice 归一:上游该字段是 string 类型,对象形式 400 code=11101(none 连带删 tools/functions)
|
|
4
|
+
// 3. DeepSeek thinking 注入:必须显式 thinking.type=enabled + 有 effort 档位,否则上游按不思考应答;
|
|
5
|
+
// 显式 disabled 尊重并删 effort;缺档补默认 "high"
|
|
6
|
+
// 4. DeepSeek reasoning_content 回填:会话内任一 assistant 带 reasoning 痕迹时,
|
|
7
|
+
// 所有 assistant 必须带 reasoning_content(string,可空串)——requiresReasoningContentOnAssistantMessages
|
|
8
|
+
const DEEPSEEK_PREFIX = "deepseek";
|
|
9
|
+
const DEFAULT_DEEPSEEK_EFFORT = "high";
|
|
10
|
+
|
|
11
|
+
export function isDeepSeekModel(model) {
|
|
12
|
+
return String(model || "").trim().toLowerCase().startsWith(DEEPSEEK_PREFIX);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeRoles(obj) {
|
|
16
|
+
const msgs = obj?.messages;
|
|
17
|
+
if (!Array.isArray(msgs)) return obj;
|
|
18
|
+
for (const m of msgs) {
|
|
19
|
+
if (m && typeof m === "object" && typeof m.role === "string" && m.role.trim().toLowerCase() === "developer") {
|
|
20
|
+
m.role = "system";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return obj;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeToolChoice(obj) {
|
|
27
|
+
const tc = obj?.tool_choice;
|
|
28
|
+
if (tc === undefined || tc === null) return obj;
|
|
29
|
+
const dropTools = () => { delete obj.tools; delete obj.functions; };
|
|
30
|
+
if (typeof tc === "string") {
|
|
31
|
+
if (tc.trim().toLowerCase() === "none") { delete obj.tool_choice; dropTools(); }
|
|
32
|
+
return obj;
|
|
33
|
+
}
|
|
34
|
+
if (typeof tc === "object") {
|
|
35
|
+
const type = String(tc.type || "").trim().toLowerCase();
|
|
36
|
+
if (type === "none") { delete obj.tool_choice; dropTools(); }
|
|
37
|
+
else if (type === "auto" || type === "required") obj.tool_choice = type;
|
|
38
|
+
else if (type === "function") {
|
|
39
|
+
const name = String(tc?.function?.name || tc?.name || "").trim();
|
|
40
|
+
obj.tool_choice = name || "auto";
|
|
41
|
+
} else delete obj.tool_choice;
|
|
42
|
+
return obj;
|
|
43
|
+
}
|
|
44
|
+
delete obj.tool_choice;
|
|
45
|
+
return obj;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function injectThinking(obj) {
|
|
49
|
+
if (!isDeepSeekModel(obj?.model)) return obj;
|
|
50
|
+
const th = obj.thinking && typeof obj.thinking === "object" ? obj.thinking : null;
|
|
51
|
+
const type = th ? String(th.type || "").trim() : "";
|
|
52
|
+
if (type && type.toLowerCase() === "disabled") {
|
|
53
|
+
delete obj.reasoning_effort;
|
|
54
|
+
delete obj.reasoningEffort;
|
|
55
|
+
return obj;
|
|
56
|
+
}
|
|
57
|
+
if (!type) {
|
|
58
|
+
if (th) th.type = "enabled";
|
|
59
|
+
else obj.thinking = { type: "enabled" };
|
|
60
|
+
}
|
|
61
|
+
if (!("reasoning_effort" in obj) && !("reasoningEffort" in obj)) obj.reasoning_effort = DEFAULT_DEEPSEEK_EFFORT;
|
|
62
|
+
return obj;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function backfillReasoningContent(obj) {
|
|
66
|
+
if (!isDeepSeekModel(obj?.model)) return obj;
|
|
67
|
+
const msgs = obj.messages;
|
|
68
|
+
if (!Array.isArray(msgs) || !msgs.length) return obj;
|
|
69
|
+
let hasTrace = false;
|
|
70
|
+
for (const m of msgs) {
|
|
71
|
+
if (!m || typeof m !== "object") continue;
|
|
72
|
+
if (typeof m.reasoning === "string" && m.reasoning) { hasTrace = true; break; }
|
|
73
|
+
if ("reasoning_content" in m) { hasTrace = true; break; }
|
|
74
|
+
}
|
|
75
|
+
if (!hasTrace) return obj;
|
|
76
|
+
for (const m of msgs) {
|
|
77
|
+
if (!m || m.role !== "assistant") continue;
|
|
78
|
+
if ("reasoning_content" in m) continue;
|
|
79
|
+
m.reasoning_content = typeof m.reasoning === "string" ? m.reasoning : "";
|
|
80
|
+
}
|
|
81
|
+
return obj;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 入口:输入 chat body(OpenAI 格式),返回改写后的新对象(messages 浅拷贝,避免污染调用方)。
|
|
85
|
+
export function rewriteWorkbuddyPayload(body) {
|
|
86
|
+
if (!body || typeof body !== "object") return body;
|
|
87
|
+
const obj = { ...body };
|
|
88
|
+
obj.messages = Array.isArray(body.messages)
|
|
89
|
+
? body.messages.map((m) => (m && typeof m === "object" ? { ...m } : m))
|
|
90
|
+
: body.messages;
|
|
91
|
+
normalizeRoles(obj);
|
|
92
|
+
normalizeToolChoice(obj);
|
|
93
|
+
if (isDeepSeekModel(obj.model)) {
|
|
94
|
+
injectThinking(obj);
|
|
95
|
+
backfillReasoningContent(obj);
|
|
96
|
+
}
|
|
97
|
+
return obj;
|
|
98
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// workbuddy 上游对 tool 序列严格校验(400 code 11148 tool_call_sequence_broken):
|
|
2
|
+
// assistant.tool_calls 与后续 tool 结果必须按 id 一一配对,否则整单拒绝并提示开新会话。
|
|
3
|
+
// 历史上下文经压缩/编辑/跨模型切换后可能断裂(有调用无结果、结果孤立、id 漂移),
|
|
4
|
+
// AI SDK 转换层对缺失字段用占位(toolName "tool" / toolCallId "")进一步错配。
|
|
5
|
+
// 这里在 workbuddy 出口做防御性清洗:仅保留能成对出现的调用与结果,避免整单 400。
|
|
6
|
+
// 其他上游(opencode 等)宽容,不受影响——只挂在 workbuddy 链路。
|
|
7
|
+
export function sanitizeToolSequence(messages) {
|
|
8
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
9
|
+
|
|
10
|
+
const callIds = new Set();
|
|
11
|
+
for (const m of list) {
|
|
12
|
+
if (m?.role === "assistant" && Array.isArray(m.tool_calls)) {
|
|
13
|
+
for (const tc of m.tool_calls) {
|
|
14
|
+
const id = String(tc?.id || "");
|
|
15
|
+
if (id) callIds.add(id);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const resultIds = new Set();
|
|
21
|
+
for (const m of list) {
|
|
22
|
+
if (m?.role === "tool") {
|
|
23
|
+
const id = String(m.tool_call_id || "");
|
|
24
|
+
if (id) resultIds.add(id);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const paired = new Set();
|
|
29
|
+
for (const id of callIds) if (resultIds.has(id)) paired.add(id);
|
|
30
|
+
|
|
31
|
+
let droppedCalls = 0;
|
|
32
|
+
let droppedResults = 0;
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const m of list) {
|
|
35
|
+
if (m?.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
36
|
+
const kept = m.tool_calls.filter((tc) => paired.has(String(tc?.id || "")));
|
|
37
|
+
droppedCalls += m.tool_calls.length - kept.length;
|
|
38
|
+
if (kept.length) {
|
|
39
|
+
out.push({ ...m, tool_calls: kept });
|
|
40
|
+
} else if (typeof m.content === "string" && m.content) {
|
|
41
|
+
const { tool_calls: _dropped, ...rest } = m;
|
|
42
|
+
out.push(rest);
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (m?.role === "tool") {
|
|
47
|
+
if (paired.has(String(m.tool_call_id || ""))) out.push(m);
|
|
48
|
+
else droppedResults++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
out.push(m);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { messages: out, droppedCalls, droppedResults };
|
|
55
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// 引擎模式解析(纯函数,零依赖):缺省 sdk,legacy/关闭词回退原实现。
|
|
2
2
|
// 供应商级开关未设置时继承全局总闸,使 MSLXDFF_UPSTREAM_ENGINE=legacy 成为一键熔断。
|
|
3
|
+
// defaultMode:调用方可自定缺省(workbuddy 因上游非标字段 thinking/reasoning_content
|
|
4
|
+
// 无法经 AI SDK 序列化层注入,缺省 legacy);显式 env(供应商级或全局)始终优先。
|
|
3
5
|
// 供 upstream-engine/index.js(opencode 上游)与 providers/workbuddy/chat.js 共用。
|
|
4
6
|
// 见 docs/adr/0017。
|
|
5
7
|
const ENGINE_OFF_WORDS = new Set(["0", "off", "false", "no", "disable", "disabled"]);
|
|
6
8
|
|
|
7
|
-
export function resolveEngineMode(env = process.env, varName = "MSLXDFF_UPSTREAM_ENGINE") {
|
|
9
|
+
export function resolveEngineMode(env = process.env, varName = "MSLXDFF_UPSTREAM_ENGINE", defaultMode = "sdk") {
|
|
8
10
|
const pick = (name) => String(env?.[name] ?? "").trim().toLowerCase();
|
|
9
11
|
let raw = pick(varName);
|
|
10
12
|
if (!raw && varName !== "MSLXDFF_UPSTREAM_ENGINE") raw = pick("MSLXDFF_UPSTREAM_ENGINE");
|
|
11
13
|
if (raw === "legacy" || ENGINE_OFF_WORDS.has(raw)) return "legacy";
|
|
12
|
-
return "sdk";
|
|
14
|
+
if (raw) return "sdk";
|
|
15
|
+
return defaultMode === "legacy" ? "legacy" : "sdk";
|
|
13
16
|
}
|