mslxdff 0.1.100 → 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.100",
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);
@@ -84,7 +84,7 @@ export async function handleSetto(args) {
84
84
  if (!norm || norm === "auto") continue;
85
85
  // 首次循环也同步 preferred(保持 daemon 热重载语义)
86
86
  if (rawId === list[0]) savePreferredModel(norm);
87
- const r = await syncToOpencode({ id: norm, token, port, file, keep: pruneKeep() });
87
+ const r = await syncToOpencode({ id: norm, token, port, file, keep: pruneKeep(), ensureAll: pruneKeep() });
88
88
  if (r.action === "inserted") inserted++; else updated++;
89
89
  prunedTotal += r.pruned || 0;
90
90
  console.log(` ${r.action} "${r.id}" -> ${r.internal} @ ${file}`);
@@ -156,8 +156,9 @@ export async function handleSetto(args) {
156
156
  const envPort = Number(process.env.MSLXDFF_PORT);
157
157
  const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
158
158
  const file = opencodeConfigPath();
159
- const r = await syncToOpencode({ id, token, port, file, keep: pruneKeep() });
159
+ const r = await syncToOpencode({ id, token, port, file, keep: pruneKeep(), ensureAll: pruneKeep() });
160
160
  console.log(`synced to opencode: ${r.action} "${r.id}" @ ${file}`);
161
+ if (r.backfilled) console.log(` backfilled ${r.backfilled} 个 picks 模型(此前 pick 了但未同步过,现已补齐)`);
161
162
  if (r.pruned) console.log(` pruned ${r.pruned} 个失效模型(未在 picks,不再于 opencode 显示)`);
162
163
  console.log(` url: http://127.0.0.1:${port}/v1`);
163
164
  console.log(` opencode 选 mslxdff/${r.id} 直达本地 ${r.internal}${r.storageKey !== r.internal ? ` (dash→${r.internal} 自动映射)` : ""}`);
@@ -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 {
@@ -78,7 +78,43 @@ export function pruneOpencodeModels(models, keep, currentKey) {
78
78
  return pruned;
79
79
  }
80
80
 
81
- export async function syncToOpencode({ id, token, port, file, keep } = {}) {
81
+ // 补齐:把 ensureAll(picks 口径)里缺失的键写入 models(slash dash + alias 注册)。
82
+ // 返回 { nextModels, backfilled };ensureAll 非数组或为空时原样返回(backfilled=0)。
83
+ // 注意:需在剪枝之前调用——先补 picks 缺失,再剪 picks 外旧键,结果集恰为 picks ∪ currentKey。
84
+ export async function ensureAllOpencodeModels(models, ensureAll) {
85
+ if (!Array.isArray(ensureAll) || !ensureAll.length || !models || typeof models !== "object") {
86
+ return { nextModels: models, backfilled: 0 };
87
+ }
88
+ const existing = new Set(Object.keys(models).map(normalizeOpencodeKey));
89
+ let backfilled = 0;
90
+ let aliasDirty = false;
91
+ for (const raw of ensureAll) {
92
+ const internal = toInternalId(String(raw || "").trim());
93
+ if (!internal || internal === "auto") continue;
94
+ const storageKey = internal.includes("/") ? internal.replace(/\//g, "-") : internal;
95
+ if (!storageKey || existing.has(storageKey)) continue;
96
+ models[storageKey] = { name: storageKey };
97
+ existing.add(storageKey);
98
+ backfilled++;
99
+ if (internal.includes("/") && storageKey !== internal) {
100
+ try {
101
+ const { loadModelAliases, registerModelAlias } = await import("./providers/model-id.js");
102
+ loadModelAliases();
103
+ registerModelAlias(storageKey, internal);
104
+ aliasDirty = true;
105
+ } catch {}
106
+ }
107
+ }
108
+ if (aliasDirty) {
109
+ try {
110
+ const { persistModelAliases } = await import("./providers/model-id.js");
111
+ persistModelAliases();
112
+ } catch {}
113
+ }
114
+ return { nextModels: models, backfilled };
115
+ }
116
+
117
+ export async function syncToOpencode({ id, token, port, file, keep, ensureAll } = {}) {
82
118
  const targetFile = file || opencodeConfigPath();
83
119
  const normalizedRaw = String(id || "").trim();
84
120
  if (!normalizedRaw) throw new Error("model id required");
@@ -119,6 +155,7 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
119
155
  let action;
120
156
  let effectiveId = storageKey;
121
157
  let pruned = 0;
158
+ let backfilled = 0;
122
159
  if (oldProvider) {
123
160
  const oldModels = oldProvider.models && typeof oldProvider.models === "object" && !Array.isArray(oldProvider.models)
124
161
  ? oldProvider.models
@@ -158,6 +195,8 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
158
195
  effectiveId = storageKey;
159
196
  action = "inserted";
160
197
  }
198
+ const ensured = await ensureAllOpencodeModels(nextModels, ensureAll);
199
+ backfilled = ensured.backfilled;
161
200
  pruned = pruneOpencodeModels(nextModels, keep, storageKey);
162
201
  const nextProvider = {
163
202
  ...oldProvider,
@@ -177,6 +216,8 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
177
216
  if (!data.provider.mslxdff.models[storageKey]) {
178
217
  data.provider.mslxdff.models = { [storageKey]: { name: storageKey } };
179
218
  }
219
+ const ensured = await ensureAllOpencodeModels(data.provider.mslxdff.models, ensureAll);
220
+ backfilled = ensured.backfilled;
180
221
  action = "inserted";
181
222
  }
182
223
 
@@ -208,5 +249,5 @@ export async function syncToOpencode({ id, token, port, file, keep } = {}) {
208
249
  }
209
250
  } catch {}
210
251
 
211
- return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey, pruned };
252
+ return { action, file: targetFile, id: effectiveId, alias: storageKey, internal, corrupted, storageKey, pruned, backfilled };
212
253
  }
@@ -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);