mslxdff 0.1.229 → 0.1.231

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.229",
3
+ "version": "0.1.231",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,9 +40,22 @@ export function cleanToolSchema(schema) {
40
40
  function cleanSchemaInner(schema, isRoot) {
41
41
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
42
42
  const out = { ...schema };
43
+ // 删 $schema 元字段(JSON Schema draft 声明):它不是 OpenAI function parameters 的字段;
44
+ // agentrouter 实测 2026-10-21——带 `$schema: draft/2020-12` 的空参数工具(Claude Code 内建
45
+ // CronList 等)被其 new_api 内核按更严的校验语义判 `null is not of type "array"`(400),
46
+ // 去掉 $schema 即 200。嵌套层同样可能出现,逐层删。
47
+ delete out.$schema;
43
48
  const missingType = isRoot && !("type" in out);
44
49
  if (missingType) out.type = "object";
45
50
  if (missingType && !("properties" in out)) out.properties = {};
51
+ // 根层 properties 存在但缺 required 时补 `required: []`:agentrouter 实测 2026-10-21——其
52
+ // new_api 后端异构副本里部分实例把缺省 required 归一成 null 再按 array 校验,随机报
53
+ // `Invalid schema for function 'X': null is not of type "array"`(400)。现网 A/B 各 10 发:
54
+ // 缺失版 200×5/400×3/500×2,补 required:[] 版 200×10——显式空数组即无从归一 null,
55
+ // 且 required:[] 对 OpenAI/Anthropic 均合法,属通用正确形状(不止为一家打补丁)。
56
+ if (isRoot && out.properties && typeof out.properties === "object" && !Array.isArray(out.properties) && !("required" in out)) {
57
+ out.required = [];
58
+ }
46
59
  if (out.format === "uri") delete out.format;
47
60
  if (out.properties && typeof out.properties === "object" && !Array.isArray(out.properties)) {
48
61
  const props = {};
@@ -1,16 +1,20 @@
1
- // AgentRouter 定制供应商:OpenAI 兼容聚合网关,有三道硬门槛(2026-10-21 现网 curl 实测):
1
+ // AgentRouter 定制供应商:OpenAI 兼容聚合网关,有四道硬门槛(2026-10-21 现网 curl 实测):
2
2
  // ① 客户端身份白名单 —— UA 为 `mslxdff`/`curl`/浏览器一律 401 `unauthorized client detected`,
3
3
  // 只放行 `opencode/*` 与 `claude-cli/<ver> (external…)`;
4
4
  // ② 模型分端点 —— `deepseek-v4-flash` 吃 /v1/chat/completions,`gpt-6.1-sol`/`gpt-6-astra`
5
5
  // 对该端点回 400「请改用 /v1/responses」,只在 responses 通道可用;
6
6
  // ③ 内容闸 —— 正文含 `x-anthropic` 字样稳定 500 `sensitive words detected`(连 user 消息也拦,
7
- // 与 workbuddy 只扫 system/assistant 不同),见 stripAgentRouterSensitive。
7
+ // 与 workbuddy 只扫 system/assistant 不同),见 stripAgentRouterSensitive;
8
+ // ④ 传输层 —— 上游对 Node/undici 的**新建 TCP 连接**选择性丢包(现网对照:undici 0/4~0/6
9
+ // 全 connect 超时,curl 同刻 100% 200,走系统代理亦不稳)⇒ 本家 fetchImpl 缺省换
10
+ // curl-fetch 子进程通道(`MSLXDFF_AGENTROUTER_FETCH=undici` 可切回)。
8
11
  // 故不复用 generic(其 UA 写死 `mslxdff`、responses 判定只认 models.dev npm 索引),这里自组装,
9
12
  // 底层仍复用 base.js 的 runner 与 responses-channel 的协议互转。
10
13
  import { createKeyRing } from "./keyring.js";
11
14
  import { loadProviderBaseUrl, loadProviderKeys } from "../state.js";
12
15
  import { createChatRunner, createAgent, envInt, getUndici, joinUrl, collectApiKeysGeneric } from "./base.js";
13
16
  import { createResponsesChannel, resolveResponsesPath } from "./responses-channel.js";
17
+ import { createCurlFetch } from "./curl-fetch.js";
14
18
  import { compatFetch } from "../compat.js";
15
19
  import { opencodeUa } from "../opencode-identity.js";
16
20
  import { joinModelId } from "./model-id.js";
@@ -80,7 +84,10 @@ export function createAgentRouterProvider({
80
84
  } = {}) {
81
85
  if (id !== "agentrouter") throw new Error(`agentrouter provider id mismatch: ${id}`);
82
86
  const resolvedBase = resolveAgentRouterBase(baseUrl);
83
- if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
87
+ // 传输层缺省走 curl 子进程(见文件头门槛④):上游对 undici 新建连接选择性丢包,
88
+ // 实测 curl 同刻稳定。`MSLXDFF_AGENTROUTER_FETCH=undici` 切回 undici(配好上游修复后或对照排查用)。
89
+ const useCurl = !fetchImpl && String(process.env.MSLXDFF_AGENTROUTER_FETCH || "curl").toLowerCase() !== "undici";
90
+ if (!fetchImpl) fetchImpl = useCurl ? createCurlFetch() : (UndiciFetch || compatFetch);
84
91
 
85
92
  const ring = createKeyRing(
86
93
  collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {})),
@@ -119,8 +126,13 @@ export function createAgentRouterProvider({
119
126
  return h;
120
127
  }
121
128
 
129
+ // 网络重试:agentrouter 只有 1 把 key,任何一次瞬时连接抖动(现网实测 ECONNRESET 偶发,
130
+ // 上游高频时会触发)若直接 onError,= 立刻冷却 30s、把后续请求全挡成
131
+ // "all keys in cooldown"。补 attempts=2(对齐 generic/cline/workbuddy),让网络层重试先扛一下。
132
+ const chatRetry = { network: { attempts: 2, delayMs: 300 }, 429: { attempts: 1, delayMs: 100 }, 502: { attempts: 1, delayMs: 100 }, 503: { attempts: 1, delayMs: 100 }, 504: { attempts: 1, delayMs: 100 } };
133
+
122
134
  const { runChat } = createChatRunner({
123
- id, ring, cooldownMs, fetchImpl, dispatcher, buildHeaders,
135
+ id, ring, cooldownMs, retry: chatRetry, fetchImpl, dispatcher, buildHeaders,
124
136
  getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
125
137
  connectTimeoutMs,
126
138
  // 非流式走原生(SDK 通道只服务 opencode zen 的形状),responses 另走专用通道
@@ -132,6 +144,7 @@ export function createAgentRouterProvider({
132
144
  url: joinUrl(resolvedBase, resolveResponsesPath(id, RESPONSES_PATH)),
133
145
  connectTimeoutMs,
134
146
  cooldownMs,
147
+ retry: chatRetry,
135
148
  // 关 SDK:其缺省会强塞 include:["reasoning.encrypted_content"] / store:false,agentrouter 未验证支持
136
149
  sdkEnabled: false,
137
150
  buildHeaders,
@@ -151,7 +164,7 @@ export function createAgentRouterProvider({
151
164
  const clean = stripAgentRouterSensitive(body);
152
165
  if (isAgentRouterResponsesModel(clean?.model)) return responses.run(normalizeResponsesBody(clean), tmp, "shared provider keys", opts);
153
166
  const scoped = createChatRunner({
154
- id, ring: tmp, cooldownMs, fetchImpl, dispatcher, buildHeaders,
167
+ id, ring: tmp, cooldownMs, retry: chatRetry, fetchImpl, dispatcher, buildHeaders,
155
168
  getUrl: () => joinUrl(resolvedBase, CHAT_PATH),
156
169
  connectTimeoutMs,
157
170
  sdkDispatch: { enabled: false },
@@ -0,0 +1,155 @@
1
+ // curl-fetch 适配器:fetch(url, init) → curl.exe 子进程 → 标准 Response
2
+ // 背景(ADR-0066 门槛④,2026-10-21 现网取证):agentrouter 对 Node/undici 新建 TCP 连接
3
+ // 选择性丢包(undici 0/4~0/6 全 connect 超时、curl 同刻 100% 200),供应商传输层整体换 curl。
4
+ // 契约:只消费 init 的 method/headers/body(string)/signal;其余字段(如 undici dispatcher)忽略。
5
+ // 失败语义:拿不到响应头即抛 Error(交上层 runner 的 network 重试/冷却,与 fetch 抛错同语义)。
6
+ // ⚠ 流式关键教训(2026-10-21 本机踩坑):stdout 必须**立即挂 data 监听进流式(flowing)模式**
7
+ // ——等消费者 pull 才读会让 curl 写满管道后卡住/退出,res.text() 永不返回;
8
+ // 统一 chunk 队列:header 阶段从队列文本判就绪,body 阶段同一队列续吐,零丢字节。
9
+ // ⚠ 弹窗教训(2026-10-21 用户机实测):daemon 是无控制台后台进程,spawn 不加 `windowsHide`
10
+ // 时 Windows 会给每个 console 子进程(curl.exe)新开一个控制台窗口——流式+重试+预热
11
+ // 叠加就是窗口轰炸。
12
+ // ⚠ 100-continue 教训(2026-10-21 同批,Claude Code 大 body 实锤):body 超 ~1KB 时 curl 自动
13
+ // 带 `Expect: 100-continue`,`-D` 头文件先落 `HTTP/1.1 100 Continue` 伪段;若把它当终局
14
+ // 交卷,`new Response(status=100)` 直接 RangeError → 上层 502 → key 冷却 30s 连锁。
15
+ // 双保险:请求侧 `-H "Expect:"` 关掉该机制 + 头解析侧跳过所有 1xx/CONNECT 伪段。
16
+ import { spawn } from "node:child_process";
17
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ const DEFAULT_CURL = process.env.MSLXDFF_CURL_PATH || "curl.exe";
22
+ const HEAD_WAIT_MS = Number(process.env.MSLXDFF_CURL_HEAD_WAIT_MS) || 15_000; // 等响应头的上限(连接失败由 curl 退出/EOF 提前揭穿)
23
+ // curl 侧整请求硬闸(防无 signal 调用方遗留僵尸进程;runner 的 connectTimeoutMs 通常先到期掐进程)
24
+ const FALLBACK_MAX_TIME_S = Number(process.env.MSLXDFF_CURL_MAX_TIME_S) || 600;
25
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
+ const safeRead = (fp) => { try { return readFileSync(fp, "latin1"); } catch { return ""; } };
27
+
28
+ /** 1xx 中间应答段(含代理 CONNECT 的 `200 Connection established` 伪应答——curl 虽按 RFC 记
29
+ * 它为 1xx 语义,现网写出的是 `HTTP/1.1 200 Connection established`,两者都不算终局)。 */
30
+ const INTERMEDIATE_SEGMENT = /^HTTP\/[\d.]+[ \t]+(?:1\d{2}|200[ \t]+Connection established)/i;
31
+
32
+ /** 解析 curl -D 头文件(代理 CONNECT / 1xx 中间应答都是前置伪段,取最后一个非伪段)。 */
33
+ export function parseHeaderBlob(blob) {
34
+ const segs = String(blob || "").split(/\r?\n\r?\n/).filter((s) => /^HTTP\//i.test(s.trim()));
35
+ // 从后往前找第一个「终局」段(非 1xx / 非 CONNECT 伪应答);全是伪段则回退最后一段
36
+ let last = segs.length ? segs[segs.length - 1] : String(blob || "");
37
+ for (let i = segs.length - 1; i >= 0; i--) {
38
+ if (!INTERMEDIATE_SEGMENT.test(segs[i].trim())) { last = segs[i]; break; }
39
+ }
40
+ const m = last.match(/HTTP\/[\d.]+[ \t]+(\d{3})/i);
41
+ // 伪段状态(1xx)绝不能外泄给 new Response(RangeError → 上层假 502);归 0 按无头失败处理
42
+ let status = m ? Number(m[1]) : 0;
43
+ if (status && status < 200) status = 0;
44
+ const headers = new Headers();
45
+ for (const line of last.split(/\r?\n/).slice(1)) {
46
+ const i = line.indexOf(":");
47
+ if (i > 0) headers.set(line.slice(0, i).trim(), line.slice(i + 1).trim());
48
+ }
49
+ return { status, headers };
50
+ }
51
+
52
+ /**
53
+ * 头块是否已完整可交卷:存在至少一个以空行收尾的 HTTP 段,且**最后一个完整段不是**
54
+ * 1xx 中间应答/代理 CONNECT 伪应答(`-H "Expect:"` 之外的第二道保险:防上游无视 Expect
55
+ * 仍回 100,或代理链回 CONNECT)。
56
+ */
57
+ export function headerBlobReady(blob) {
58
+ const segs = String(blob || "").split(/\r?\n\r?\n/);
59
+ if (segs.length < 2) return false;
60
+ const lastFull = segs[segs.length - 2];
61
+ if (!/^HTTP\//i.test(String(lastFull || "").trim())) return false;
62
+ if (INTERMEDIATE_SEGMENT.test(String(lastFull).trim())) return false;
63
+ return true;
64
+ }
65
+
66
+ /**
67
+ * 建 fetch 兼容函数。
68
+ * @param {{curlPath?: string, headWaitMs?: number}} [opts]
69
+ */
70
+ export function createCurlFetch({ curlPath = DEFAULT_CURL, headWaitMs = HEAD_WAIT_MS } = {}) {
71
+ const tmpRoot = mkdtempSync(join(tmpdir(), "mslxdff-curl-"));
72
+ return async function curlFetch(url, init = {}) {
73
+ const dir = mkdtempSync(join(tmpRoot, "r"));
74
+ const headerFile = join(dir, "hdr.txt");
75
+ // `-H "Expect:"`:显式移除 curl 对大 body(>1KB)自动附加的 Expect 头,杜绝 100-continue 往返
76
+ const args = ["-sS", "-N", "--http1.1", "--max-time", String(FALLBACK_MAX_TIME_S), "-H", "Expect:", "-D", headerFile];
77
+ const body = init.body != null ? String(init.body) : null;
78
+ if (body !== null) {
79
+ const method = init.method && init.method !== "GET" ? init.method : "POST";
80
+ args.push("-X", method, "--data-binary", "@-");
81
+ } else if (init.method && init.method !== "GET") {
82
+ args.push("-X", init.method);
83
+ }
84
+ const headers = init.headers || {};
85
+ for (const k of Object.keys(headers)) {
86
+ const v = headers[k];
87
+ if (typeof v === "string" && v) args.push("-H", `${k}: ${v}`);
88
+ }
89
+ args.push(String(url));
90
+
91
+ // windowsHide:daemon(无控制台后台进程)下不加这项,Windows 会**每次 spawn 弹一个 curl 控制台窗口**
92
+ const child = spawn(curlPath, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
93
+ child.stdin.on("error", () => {}); // EPIPE:curl 启动即败时写 body 不崩主流程
94
+ let stderr = "";
95
+ child.stderr.on("data", (c) => { stderr += c.toString(); });
96
+ if (body !== null) child.stdin.end(body);
97
+
98
+ // ① spawn 即流式:stdout 全部进队列(header 就绪前的 body 字节也在其中,不丢)
99
+ const chunks = [];
100
+ let eof = false;
101
+ let streamErr = null;
102
+ let waiters = [];
103
+ child.stdout.on("data", (c) => {
104
+ chunks.push(c);
105
+ if (waiters.length) waiters.shift()({ value: chunks.shift(), done: false });
106
+ });
107
+ child.stdout.on("error", (e) => { streamErr = e; eof = true; wakeAll(); });
108
+ child.stdout.on("end", () => { eof = true; wakeAll(); });
109
+ child.on("error", (e) => { streamErr = e; eof = true; wakeAll(); });
110
+ function wakeAll() { const ws = waiters; waiters = []; for (const w of ws) w({ value: undefined, done: true }); }
111
+ const kill = () => { try { child.kill(); } catch {} };
112
+
113
+ // ② 等响应头(-D 文件出现完整非-CONNECT 段;curl 退出/EOF 提前结束等待)
114
+ const deadline = Date.now() + headWaitMs;
115
+ let blob = "";
116
+ for (;;) {
117
+ blob = safeRead(headerFile);
118
+ if (headerBlobReady(blob)) break;
119
+ if (eof || streamErr) break; // 无头 EOF:下面按失败处理
120
+ if (Date.now() >= deadline) { kill(); break; }
121
+ await sleep(20);
122
+ }
123
+ const { status, headers: resHeaders } = parseHeaderBlob(blob);
124
+ if (!status) {
125
+ kill();
126
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
127
+ const err = new Error(`curl-fetch: 无响应头(${String(url)})${streamErr ? ` err=${streamErr.message}` : ""}${stderr ? ` stderr=${stderr.slice(0, 160)}` : ""}`);
128
+ err._curlNoHeader = true;
129
+ throw err; // 交上层 network 重试/冷却(等价 fetch 的连接类抛错)
130
+ }
131
+
132
+ // ③ Response.body:先吐队列积压、再随 data 事件续吐;signal/cancel 杀进程
133
+ const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }); } catch {} };
134
+ const stream = new ReadableStream({
135
+ async pull(controller) {
136
+ if (chunks.length) { controller.enqueue(new Uint8Array(chunks.shift())); return; }
137
+ if (streamErr) { cleanup(); controller.error(streamErr); return; }
138
+ if (eof) { cleanup(); controller.close(); return; }
139
+ const r = await new Promise((res) => waiters.push(res));
140
+ if (r.done) {
141
+ if (streamErr) { cleanup(); controller.error(streamErr); return; }
142
+ cleanup(); controller.close(); return;
143
+ }
144
+ controller.enqueue(new Uint8Array(r.value));
145
+ },
146
+ cancel() { kill(); cleanup(); },
147
+ });
148
+ const onAbort = () => kill();
149
+ if (init.signal) {
150
+ if (init.signal.aborted) kill();
151
+ else init.signal.addEventListener("abort", onAbort, { once: true });
152
+ }
153
+ return new Response(stream, { status, statusText: "", headers: resHeaders });
154
+ };
155
+ }
@@ -1,8 +1,9 @@
1
1
  import { appendEvent } from "../logs.js";
2
2
  import { splitModelId, DEFAULT_PROVIDER, joinModelId } from "./model-id.js";
3
3
  import { isModelAllowed as stateIsAllowed, loadProviderAllowedModels as stateLoadAllowed, loadProviderAllowAnyModels as stateLoadAllowAny } from "../state.js";
4
- // 定制预热名单:registry customProviders 9 家 + 默认 opencode;openrouter/自配 generic 不预热(按需自拉)
5
- const CUSTOM_PREHEAT_IDS = new Set(["opencode", "workbuddy", "traework", "cline", "codearts", "qoder", "globalqwenwork", "qwenwork", "zcode", "raccoon", "agentrouter"]);
4
+ // 定制预热名单:registry customProviders + 默认 opencode;openrouter/自配 generic 不预热(按需自拉)
5
+ // agentrouter 已于 2026-10-21 随 registry 一并摘除(见 src/providers/registry.js 注释)。
6
+ const CUSTOM_PREHEAT_IDS = new Set(["opencode", "workbuddy", "traework", "cline", "codearts", "qoder", "globalqwenwork", "qwenwork", "zcode", "raccoon"]);
6
7
  function isPreheatDisabled() {
7
8
  const raw = process.env.MSLXDFF_PREHEAT;
8
9
  if (raw === undefined || raw === null || raw === "") return false;
@@ -60,13 +60,9 @@ export const customProviders = [
60
60
  match: (id, baseUrl) => id === "raccoon" || String(baseUrl).includes("xiaohuanxiong.com"),
61
61
  load: () => import("./raccoon/index.js").then((m) => m.createRaccoonProvider),
62
62
  },
63
- {
64
- id: "agentrouter",
65
- // 客户端身份白名单(UA 非 opencode/claude-cli 恒 401)+ gpt-6* 只吃 /v1/responses,
66
- // generic 的写死 UA 与 models.dev 判定都覆盖不到,故走定制工厂。
67
- match: (id, baseUrl) => id === "agentrouter" || String(baseUrl).includes("agentrouter"),
68
- load: () => import("./agentrouter.js").then((m) => m.createAgentRouterProvider),
69
- },
63
+ // agentrouter 定制工厂已于 2026-10-21 摘除:现网成功率仅 ~48%(502/400/422/500 高发),
64
+ // 从注册表 + 预热名单移除后网关不再解析 agentrouter/* 模型。工厂文件 src/providers/agentrouter.js
65
+ // 与其测试保留,日后要重新启用只需在此加回一条 { id:"agentrouter", ... } 并同步 dispatcher 预热名单。
70
66
  ];
71
67
 
72
68
  // 供 bench/probe 等需要定制化解析模型列表的场景