mslxdff 0.1.101 → 0.1.102

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.101",
3
+ "version": "0.1.102",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,10 +30,14 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
30
30
  }
31
31
 
32
32
  // order 推导 + plugin model:select 可改
33
+ // 语义:指定模型 = 死锁单模型(本机→组员同款,挂了就报挂,不兜其他 picks);只有 auto 才轮 picks
33
34
  let order;
34
35
  if (lockModel) order = [requested];
35
36
  else if (useAuto) order = auto ? await auto.candidates() : [""];
36
- else order = auto ? await auto.candidatesFor(requested) : [requested];
37
+ else {
38
+ if (auto && requested) { try { await auto.candidatesFor(requested); } catch {} }
39
+ order = [requested];
40
+ }
37
41
  if (!order.length) order = [""];
38
42
  const canFallback = order.length > 1;
39
43
  const canForwardPeers = Boolean(peers) && hops < (maxHops ?? 3);
@@ -2,6 +2,7 @@ import { joinUrl } from "../base.js";
2
2
  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
+ import { reshapeWorkbuddySse } from "./reshape.js";
5
6
 
6
7
  function buildAuthHeaders(key, auth) {
7
8
  const h = {
@@ -86,7 +87,7 @@ export function createChatService({
86
87
  let res;
87
88
  try { res = await fetchOnce(url, body, key, auth); }
88
89
  catch (e) { throw e; }
89
- if (res.status < 400) return res;
90
+ if (res.status < 400) return reshapeWorkbuddySse(res);
90
91
  let txt = "";
91
92
  try { txt = await res.text(); } catch {}
92
93
  if (!isAuthError(res.status, txt)) return res;
@@ -105,7 +106,7 @@ export function createChatService({
105
106
  if (stillAuth || res2.status === 429 || res2.status >= 500) try { ring.onError(newKey); } catch {}
106
107
  doLog({ uid: uid2, model: _modelForLog(), totalMs: Math.round(nowMs(clock) - _t0()), balanceHit: false, error: stillAuth ? `still auth ${res2.status}` : undefined });
107
108
  if (stillAuth) throw failErr(`workbuddy auth still failing after refresh for ${uid2}: ${stillTxt.slice(0, 120)}`, _t0(), clock);
108
- return res2;
109
+ return reshapeWorkbuddySse(res2);
109
110
  }
110
111
 
111
112
  let _t0Val = 0;
@@ -0,0 +1,142 @@
1
+ // workbuddy 流式 reasoning_content 整形:上游思考模型(如 glm-5.3-flash)把思考过程一词一 chunk
2
+ // 地放进 delta.reasoning_content,客户端会把每个增量渲染成独立 "- Thought" 条目。
3
+ // 旧版:连续 reasoning 增量缓冲到首个 content/finish 才一次性 flush → 思考期 10~30s 界面完全无变化(用户看到 workbuddy 后台流量走但 opencode 界面卡住)
4
+ // 新版:增量聚合 + 阈值分片:缓冲 reasoning,达到 REASONING_CHUNK_SIZE(默认 100 字符)即分片 flush,
5
+ // 期间仅首个 role 帧透传保持 TTFB,其余纯 reasoning 空帧吞掉(之前会透传 800+ 空 role 帧);
6
+ // 尾段不足阈值的在 content/finish/流结束时兜底 flush。恒空 reasoning(deepseek-v4-flash)零改写。
7
+ // 仅包 workbuddy 出口的 SSE 响应,不影响其他供应商与全局聚合器。
8
+
9
+ const REASONING_CHUNK_SIZE = 150;
10
+
11
+ export function reshapeWorkbuddySse(res) {
12
+ try {
13
+ const ct = res.headers?.get?.("content-type") || "";
14
+ if (res.status !== 200 || !ct.includes("text/event-stream") || !res.body) return res;
15
+ } catch { return res; }
16
+
17
+ const reader = res.body.getReader();
18
+ const decoder = new TextDecoder();
19
+ const encoder = new TextEncoder();
20
+ let buf = "";
21
+ let reasoningBuf = "";
22
+ let lastMeta = null;
23
+ let closed = false;
24
+ let hasSentRole = false;
25
+ let lastFlushAt = Date.now();
26
+
27
+ function flushReasoningInto(out) {
28
+ if (!reasoningBuf) return;
29
+ const meta = lastMeta || {};
30
+ out.push(`data: ${JSON.stringify({
31
+ id: meta.id || "chatcmpl-workbuddy-reshape",
32
+ object: meta.object || "chat.completion.chunk",
33
+ created: meta.created || Math.floor(Date.now() / 1000),
34
+ model: meta.model || "",
35
+ choices: [{ index: 0, delta: { reasoning_content: reasoningBuf }, finish_reason: null }],
36
+ })}\n\n`);
37
+ reasoningBuf = "";
38
+ lastFlushAt = Date.now();
39
+ }
40
+
41
+ let blankStreak = false;
42
+ // 处理一段完整行文本(以 \n 结尾),产出的行/帧推进 out(连续空行合并为单个事件分隔)
43
+ function processInto(text, out) {
44
+ for (const line of text.split("\n")) {
45
+ if (!line) { if (!blankStreak) out.push("\n"); blankStreak = true; continue; }
46
+ blankStreak = false;
47
+ if (!line.startsWith("data:")) { out.push(line + "\n"); continue; }
48
+ const payload = line.slice(5).trim();
49
+ if (!payload || payload === "[DONE]") { out.push(line + "\n"); continue; }
50
+ let obj;
51
+ try { obj = JSON.parse(payload); } catch { out.push(line + "\n"); continue; }
52
+ const ch = obj.choices?.[0];
53
+ if (ch && typeof ch === "object") {
54
+ if (obj.id) lastMeta = { id: obj.id, model: obj.model, object: obj.object, created: obj.created };
55
+ const d = ch.delta && typeof ch.delta === "object" ? ch.delta : null;
56
+ const rc = d ? d.reasoning_content : undefined;
57
+ if (typeof rc === "string" && rc) {
58
+ reasoningBuf += rc;
59
+ d.reasoning_content = "";
60
+ const isBoundary = (typeof d.content === "string" && d.content) || !!ch.finish_reason;
61
+ // 首个 reasoning 帧的 role 透传一次,保 TTFB;后续纯 reasoning 的 role 重复帧直接吞掉
62
+ if (!hasSentRole && d.role !== undefined) {
63
+ hasSentRole = true;
64
+ const rm = lastMeta || obj;
65
+ out.push(`data: ${JSON.stringify({
66
+ id: rm.id || obj.id || "chatcmpl-workbuddy-reshape",
67
+ object: rm.object || obj.object || "chat.completion.chunk",
68
+ created: rm.created || obj.created || Math.floor(Date.now() / 1000),
69
+ model: rm.model || obj.model || "",
70
+ choices: [{ index: 0, delta: { role: d.role }, finish_reason: null }],
71
+ })}\n\n`);
72
+ } else if (d.role !== undefined) {
73
+ // 后续重复 role 帧不再透传,避免 800+ 空帧洪泛
74
+ // 标记已发送过,避免后续非 reasoning 帧再误判为首次
75
+ hasSentRole = true;
76
+ }
77
+ // 阈值分片:攒够 100 字符即向前吐一块,期间界面可见增量而非全程静默
78
+ // 时间阈值:若距上次 flush 已超 700ms 且已攒 20+ 字符,也吐一块(避免小尾巴长时间不更新)
79
+ const now = Date.now();
80
+ const shouldChunk = reasoningBuf.length >= REASONING_CHUNK_SIZE
81
+ || (reasoningBuf.length >= 50 && now - lastFlushAt > 1500);
82
+ if (shouldChunk) flushReasoningInto(out);
83
+ if (isBoundary) {
84
+ // 兜底:content/finish 前把剩余思考一次性吐出,再透传本帧
85
+ flushReasoningInto(out);
86
+ const hasPassthrough = isBoundary || (Array.isArray(d.tool_calls) && d.tool_calls.length);
87
+ if (hasPassthrough) out.push(`data: ${JSON.stringify(obj)}\n\n`);
88
+ continue;
89
+ }
90
+ // 纯 reasoning 碎片帧已缓冲/或已分片吐出,原空帧吞掉
91
+ continue;
92
+ }
93
+ // 非 reasoning 帧:若有 content/finish 先兜底 flush
94
+ if ((typeof d?.content === "string" && d.content) || ch.finish_reason) flushReasoningInto(out);
95
+ if (!hasSentRole && d?.role !== undefined) {
96
+ hasSentRole = true;
97
+ } else if (hasSentRole && d?.role !== undefined) {
98
+ // 去重:首个 role 后续重复 role 去掉,避免下游每帧都带 role
99
+ try { const clone = JSON.parse(payload); delete clone.choices[0].delta.role; out.push(`data: ${JSON.stringify(clone)}\n\n`); continue; } catch { /* fallback透传 */ }
100
+ }
101
+ }
102
+ out.push(line + "\n");
103
+ }
104
+ }
105
+
106
+ const body = new ReadableStream({
107
+ async pull(controller) {
108
+ if (closed) { try { controller.close(); } catch {} return; }
109
+ try {
110
+ const { done, value } = await reader.read();
111
+ if (done) {
112
+ closed = true;
113
+ 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;
119
+ }
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
+ } catch {
129
+ closed = true;
130
+ try { controller.close(); } catch {}
131
+ }
132
+ },
133
+ cancel() {
134
+ closed = true;
135
+ try { reader.cancel(); } catch {}
136
+ },
137
+ });
138
+
139
+ const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
140
+ try { out._t = res._t; } catch {}
141
+ return out;
142
+ }
@@ -20,6 +20,8 @@ function shouldHedge({ isStream, canForwardPeers, hedgeDelayMs: d, hasPeers, mod
20
20
  if (!d || d <= 0) return false;
21
21
  // deepseek = 本机私有凭据供应商(组员节点没有凭据),对冲必败且 both-fail 会误杀本地慢首块流(reasoner 思考 3-30s),不走组员
22
22
  if (String(model || "").startsWith("deepseek/")) return false;
23
+ // muse-spark 走 /responses 流式(event: 包装 + 加密 reasoning),组员旧版无此整形且聚合 JSON 带错 header,必抢赢本地慢首块,需本地直出
24
+ if (String(model || "").toLowerCase().startsWith("muse-spark")) return false;
23
25
  return true;
24
26
  }
25
27
 
@@ -31,7 +31,8 @@ export const MAX_STREAM_MS = (() => {
31
31
  export async function relay(res, upRes, body, { onFirstChunk, onDownstreamAbort, streamTimeoutMs = STREAM_TIMEOUT_MS, fallback } = {}) {
32
32
  const t0 = performance.now();
33
33
  const contentType = upRes.headers.get("content-type") || "";
34
- const isStream = Boolean(body?.stream) || contentType.includes("text/event-stream");
34
+ // 需同时满足:客户端要流 + 上游真的是 SSE;避免 muse-spark 聚合 JSON 被误判为流式,或 workbuddy SSE 被聚合
35
+ const isStream = Boolean(body?.stream) && contentType.includes("text/event-stream");
35
36
  res.statusCode = upRes.status;
36
37
  // propagate workbuddy uid / allowlist headers
37
38
  try {
@@ -17,7 +17,7 @@ export function chatToResponsesBody(chatBody) {
17
17
  return `${m.role}: ${String(c || "")}`;
18
18
  });
19
19
  const input = inputParts.join("\n\n") || "hi";
20
- const out = { model: chatBody.model, input, stream: false };
20
+ const out = { model: chatBody.model, input, stream: chatBody.stream !== false };
21
21
  if (system) out.instructions = system;
22
22
  if (chatBody.tools) out.tools = chatBody.tools;
23
23
  if (chatBody.tool_choice) out.tool_choice = chatBody.tool_choice;
@@ -62,3 +62,120 @@ export function toChatResponse(res, respJson) {
62
62
  headers.set("content-type", "application/json");
63
63
  return new Response(JSON.stringify(chatJson), { status: res.status, headers });
64
64
  }
65
+
66
+ export function reshapeResponsesSse(res, fallbackModel) {
67
+ try {
68
+ const ct = res.headers?.get?.("content-type") || "";
69
+ if (res.status !== 200 || !ct.includes("text/event-stream") || !res.body) return res;
70
+ } catch { return res; }
71
+ const reader = res.body.getReader();
72
+ const decoder = new TextDecoder();
73
+ const encoder = new TextEncoder();
74
+ let buf = "";
75
+ let evtType = "";
76
+ let respId = "";
77
+ let respModel = fallbackModel || "";
78
+ let created = Math.floor(Date.now() / 1000);
79
+ let hasSentRole = false;
80
+
81
+ function chatChunk(delta, finish) {
82
+ const id = respId || `resp_${Date.now()}`;
83
+ const payload = {
84
+ id,
85
+ object: "chat.completion.chunk",
86
+ created,
87
+ model: respModel,
88
+ choices: [{ index: 0, delta: delta || {}, finish_reason: finish || null }],
89
+ };
90
+ return `data: ${JSON.stringify(payload)}\n\n`;
91
+ }
92
+
93
+ let closed = false;
94
+ const body = new ReadableStream({
95
+ async pull(controller) {
96
+ if (closed) { try { controller.close(); } catch {} return; }
97
+ try {
98
+ const { done, value } = await reader.read();
99
+ if (done) {
100
+ closed = true;
101
+ if (buf.trim()) {
102
+ // 残余缓冲尝试处理
103
+ }
104
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
105
+ controller.close();
106
+ return;
107
+ }
108
+ buf += decoder.decode(value, { stream: true });
109
+ let out = "";
110
+ // 按 \n\n 分事件
111
+ while (true) {
112
+ const sep = buf.indexOf("\n\n");
113
+ if (sep < 0) break;
114
+ const raw = buf.slice(0, sep);
115
+ buf = buf.slice(sep + 2);
116
+ const lines = raw.split("\n");
117
+ let curEvent = evtType;
118
+ let dataStr = "";
119
+ for (const line of lines) {
120
+ if (line.startsWith("event:")) curEvent = line.slice(6).trim();
121
+ else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
122
+ }
123
+ if (!dataStr) { evtType = ""; continue; }
124
+ evtType = "";
125
+ let data;
126
+ try { data = JSON.parse(dataStr); } catch { continue; }
127
+ // 记录 id/model/created
128
+ if (data.response?.id) respId = data.response.id;
129
+ if (data.response?.model) respModel = data.response.model;
130
+ if (data.response?.created_at) created = Math.floor(data.response.created_at);
131
+ if (data.response?.id && !respId) respId = data.response.id;
132
+ // 关注 output_text.delta
133
+ if (curEvent === "response.output_text.delta" || data.type === "response.output_text.delta") {
134
+ const deltaText = data.delta || "";
135
+ if (deltaText) {
136
+ if (!hasSentRole) {
137
+ hasSentRole = true;
138
+ out += chatChunk({ role: "assistant" }, null);
139
+ }
140
+ out += chatChunk({ content: deltaText }, null);
141
+ }
142
+ } else if (curEvent === "response.completed" || data.type === "response.completed") {
143
+ const usage = data.response?.usage || null;
144
+ const finish = data.response?.status === "completed" ? "stop" : null;
145
+ // 末帧带 usage
146
+ const id = respId || `resp_${Date.now()}`;
147
+ const payload = {
148
+ id,
149
+ object: "chat.completion.chunk",
150
+ created,
151
+ model: respModel,
152
+ choices: [{ index: 0, delta: {}, finish_reason: finish }],
153
+ usage: usage || undefined,
154
+ };
155
+ out += `data: ${JSON.stringify(payload)}\n\n`;
156
+ } else if (data.type === "response.output_item.added" && data.item?.type === "message") {
157
+ // message 开始,可发送 role
158
+ if (!hasSentRole) {
159
+ hasSentRole = true;
160
+ out += chatChunk({ role: "assistant" }, null);
161
+ }
162
+ }
163
+ // reasoning 加密块忽略
164
+ }
165
+ if (out) controller.enqueue(encoder.encode(out));
166
+ } catch {
167
+ closed = true;
168
+ try { controller.close(); } catch {}
169
+ }
170
+ },
171
+ cancel() {
172
+ closed = true;
173
+ try { reader.cancel(); } catch {}
174
+ },
175
+ });
176
+ const headers = new Headers(res.headers);
177
+ headers.set("content-type", "text/event-stream");
178
+ const out = new Response(body, { status: res.status, statusText: res.statusText, headers });
179
+ try { out._t = res._t; } catch {}
180
+ return out;
181
+ }
package/src/upstream.js CHANGED
@@ -6,7 +6,7 @@ import { performance } from "node:perf_hooks";
6
6
  import { isFreeModel } from "./models.js";
7
7
  import { fmtShanghaiYMDHMS } from "./time.js";
8
8
  import { createTransport } from "./transport/index.js";
9
- import { isResponsesModel, chatToResponsesBody, toChatResponse } from "./upstream-responses.js";
9
+ import { isResponsesModel, chatToResponsesBody, toChatResponse, reshapeResponsesSse } from "./upstream-responses.js";
10
10
  import { uuid } from "./compat.js";
11
11
 
12
12
  function genId(prefix) {
@@ -110,7 +110,7 @@ export function createUpstreamClient({
110
110
  const reqBody = isResp ? chatToResponsesBody(body) : body;
111
111
  const t0 = performance.now();
112
112
 
113
- // 首发请求(transport 已处理 network/429 等重试)
113
+ // 首发请求(transport 已处理 network/429 等重试)
114
114
  let res;
115
115
  try {
116
116
  res = await transport.request({
@@ -151,15 +151,21 @@ export function createUpstreamClient({
151
151
  // responses 模型需转回 chat 形状(复用 upstream-responses)
152
152
  let outAnon = anonRes;
153
153
  if (isResp && anonRes.ok) {
154
- try {
155
- const txt = await anonRes.text();
156
- const j = JSON.parse(txt);
157
- if (j && Array.isArray(j.output)) {
158
- outAnon = toChatResponse(anonRes, j);
159
- } else {
160
- outAnon = new Response(txt, { status: anonRes.status, headers: anonRes.headers });
161
- }
162
- } catch { outAnon = anonRes; }
154
+ const ctAnon = anonRes.headers.get("content-type") || "";
155
+ const isStreamAnon = body?.stream !== false && ctAnon.includes("text/event-stream");
156
+ if (isStreamAnon) {
157
+ outAnon = reshapeResponsesSse(anonRes, body.model);
158
+ } else {
159
+ try {
160
+ const txt = await anonRes.text();
161
+ const j = JSON.parse(txt);
162
+ if (j && Array.isArray(j.output)) {
163
+ outAnon = toChatResponse(anonRes, j);
164
+ } else {
165
+ outAnon = new Response(txt, { status: anonRes.status, headers: anonRes.headers });
166
+ }
167
+ } catch { outAnon = anonRes; }
168
+ }
163
169
  }
164
170
  outAnon._t = { ...(outAnon._t || {}), anonTried: true, anonAttempts: i + 1, totalMs: Math.round(performance.now() - t0) };
165
171
  consecutiveHits += 1;
@@ -180,6 +186,13 @@ export function createUpstreamClient({
180
186
 
181
187
  // responses 模型成功态转 chat(复用 upstream-responses)
182
188
  if (isResp && res.ok) {
189
+ const ct = res.headers.get("content-type") || "";
190
+ const isStream = body?.stream !== false && ct.includes("text/event-stream");
191
+ if (isStream) {
192
+ const transformed = reshapeResponsesSse(res, body.model);
193
+ transformed._t = { ...(res._t || {}), totalMs: Math.round(performance.now() - t0) };
194
+ return transformed;
195
+ }
183
196
  try {
184
197
  const txt = await res.text();
185
198
  const j = JSON.parse(txt);