mslxdff 0.1.200 → 0.1.207

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.
@@ -78,7 +78,7 @@
78
78
  | zcode 接入 | `-provider zcode login [--bigmodel]` | ZCode(智谱官方编程工作台)免费额度授权:打印 chat.z.ai 授权链接→浏览器登录→CLI 轮询(`oauth/cli/init`→`poll`)拿 JWT,落盘 `auths/zcode-<uid>.json`+state 并把内置目录(`GLM-5.3`/`GLM-5.3-Flash`/`GLM-5.2`/`GLM-5-Turbo`)并入 allowlist;`zcode/<modelId>` 前缀路由(Anthropic 网关,恒上游 stream,**免验证码**);`--bigmodel` 切 bigmodel.cn;恒 local-only 不借出 |
79
79
  | zcode 额度 | `-provider zcode quota [--json]` | 查 ZCode 套餐名/状态/有效期 + 各模型余量(`billing/balance` 分组表格);空套餐给领取指引、401 给重登指引 |
80
80
  | raccoon 接入 | `-provider raccoon login` | Raccoon(商汤小浣熊)扫码登录:生成 `qrcode_code`→终端二维码 + `login/mp?code=` 链接→**用微信「扫一扫」扫**并确认(浏览器直接打开链接会 404,站点 SPA 无该路由)→轮询拿 token,落盘 `auths/raccoon-<uid>.json`+state(`keys` 一律以文档为准,续期后自愈)并把兜底 6 模型并入 allowlist;`raccoon/<modelId>` 前缀路由(**OpenAI 兼容网关**,**免验证码**);多号自动轮换:请求内撞积分不足/限流就当场换下一个号,**暂不支持手动指定用哪个号**;积分不足长冷 1h、限流短冷 30s;恒 local-only 不借出 |
81
- | raccoon 密码登录 | `-provider raccoon login --password` | Raccoon **手机号+密码登录**(多号免扫码):走 IAM 明文端点(`/api/iam/v1/auth/login/password/phone`,**不用**官方那条要加密手机号的 web 端点);TTY 下密码**关回显**(逐键 `*`),非 TTY 须 `--password=<密码>`;默认 `--nation 86`;失败给人话(「手机号或密码不正确」),回执回显手机号但**打码**、**不回显密码**;落盘/确认/领奖与扫码登录完全同路 |
81
+ | raccoon 密码登录 | `-provider raccoon login --password` | Raccoon **手机号+密码登录**(多号免扫码):走官方同款 `POST /api/web/auth/v1/login_with_password`,**phone/password 须 AES-128-CFB 加密**(key 硬编码于前端,见 `crypto.js`;发明文会报「手机号或密码不正确」);TTY 下密码**关回显**(逐键 `*`),非 TTY 须 `--password=<密码>`;默认 `--nation 86`;失败给人话(「手机号或密码不正确」),回执回显手机号但**打码**、**不回显密码**;落盘/确认/领奖与扫码登录完全同路 |
82
82
  | raccoon 积分 | `-provider raccoon quota [--json]` / `checkin` | 逐号查积分五分项 / 领「桌面端登录奖励」——**这家没有每日签到**:每日 300 与注册礼包 3000 由服务端自动发(无端点,每日 300 当天 23:59:59 清零),唯一要领的登录奖励 3000 是**每号一次性**且 `login` 时已自动领掉,`checkin` 只是补领兜底;判据只认账单、不认上游 `code:0`(重复领会虚回成功);未登录给 login 指引;daemon 每日 09:00 兜底扫描未领过的号(`MSLXDFF_RACCOON_CHECKIN=0` 关,`_CHECKIN_HOUR` 改点) |
83
83
  | WorkBuddy 成长任务 | `-workbuddy growth [--json]` / `-wb growth` | 成长任务全自动(拉列表→参与→触发→领奖,串行 1.2s/1s,已领幂等跳过);可自动 `chat_5`/`automation_1`/`skill_1`/`Model_chat_GLM5.2`,需客户端任务标 MANUAL 不发包;daemon 每日 09:30 自动(`MSLXDFF_WORKBUDDY_GROWTH=0` 关,`_GROWTH_HOUR`/`_GROWTH_MODEL` 可配) |
84
84
  | WorkBuddy 猫猫旅行 | `-workbuddy travel [--json]` / `-wb travel` | 无猫自动同意协议+领养(+300,门槛未达自动补一次对话解锁);到站领奖 / 空闲派出(location 4)/ 旅行中跳过 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.200",
3
+ "version": "0.1.207",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 键序规范化 JSON —— 出站请求体的「跨轮字节稳定」层。
3
+ *
4
+ * 1:1 照抄 cc-switch `src-tauri/src/proxy/json_canonical.rs`(v3.19 线):
5
+ * · `canonical_json_string`:对象键**按序**排列、紧凑输出(无空格换行);数组保序;数字原样,
6
+ * 不做浮点规格化(Rust 侧 `Value::Number::to_string()`,本实现用 `String(n)`)。
7
+ * · `canonicalize_value`:递归重建对象、按键序插入 —— 让后续 `JSON.stringify` 的字节序恒定。
8
+ *
9
+ * 为什么只在 /v1/messages 用:前缀缓存按「字节前缀」命中,同一逻辑 body 若键序漂移,
10
+ * 上游就会从头重算 prefill(实测 TTFT 与 prompt 长度成正比 = 每轮全量 prefill)。
11
+ * 客户端(Claude Code)回传历史里的 tool_use input 也可能由它自己重新序列化,
12
+ * 故 arguments 单独走 canonical(见 request-body.js)。
13
+ *
14
+ * 已知差异:Rust 的 `sort_by_key` 按字节序,JS `Array#sort` 按 UTF-16 码元序 ——
15
+ * 对 ASCII 键名(本协议全部字段名)两者等价;非 ASCII 键名理论上可能不同,本仓无此输入。
16
+ */
17
+
18
+ /** 单值 JSON 字面量(字符串转义走 JSON.stringify,与 Rust `serde_json::to_string` 同形)。 */
19
+ function literal(v) {
20
+ if (v === null) return "null";
21
+ const t = typeof v;
22
+ if (t === "string") return JSON.stringify(v);
23
+ if (t === "number") return Number.isFinite(v) ? String(v) : "null";
24
+ if (t === "boolean") return v ? "true" : "false";
25
+ return "null";
26
+ }
27
+
28
+ /**
29
+ * 规范 JSON 字符串:对象键序排列 + 紧凑输出。
30
+ * @param {unknown} value
31
+ * @returns {string}
32
+ */
33
+ export function canonicalJson(value) {
34
+ if (value === null || typeof value !== "object") return literal(value);
35
+ if (Array.isArray(value)) {
36
+ return `[${value.map((v) => canonicalJson(v === undefined ? null : v)).join(",")}]`;
37
+ }
38
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
39
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
40
+ }
41
+
42
+ /**
43
+ * 递归重建:对象按键序插入(数组保序)。出站 body 过一遍后,同一逻辑内容必然产出同一份字节。
44
+ * @param {unknown} value
45
+ * @returns {unknown}
46
+ */
47
+ export function canonicalizeValue(value) {
48
+ if (Array.isArray(value)) return value.map(canonicalizeValue);
49
+ if (value && typeof value === "object") {
50
+ const out = {};
51
+ for (const k of Object.keys(value).sort()) out[k] = canonicalizeValue(value[k]);
52
+ return out;
53
+ }
54
+ return value;
55
+ }
@@ -3,30 +3,42 @@
3
3
  // 「出站响应/usage 翻译」本就是两向关注点,拆后 translate.js 单向 import 本文件,不回环。
4
4
  // 对外接口不变:调用方与测试仍从 anthropic/translate.js import(那边 re-export)。
5
5
  //
6
+ // 形状对齐(1:1 照抄 cc-switch `src-tauri/src/proxy/providers/transform.rs:236-402`,只服务 /v1/messages):
7
+ // * system 块间用 "\n" 拼成**一条**;逐块剥开头的 `x-anthropic-billing-header:` 行(#2350:
8
+ // 该行带轮换 `cch=`,留在前缀位置会让上游前缀缓存每轮失效 → 每轮全量 prefill);
9
+ // * `tool_use.arguments` 走 canonical JSON;出站体整体 canonicalize(键序恒定 = 字节前缀稳定);
10
+ // * tool_result 里的媒体外移成一条 user 消息(改前是静默丢图);
11
+ // * tools 过滤 `BatchTool`、description 缺省不写、`input_schema` 过 cleanToolSchema;
12
+ // * o 系列 `max_tokens` → `max_completion_tokens`;`stream_options` 改 merge 不覆盖。
13
+ //
6
14
  // 纪律(别改回去):
7
15
  // * 思考档位**原样透传**(thinking/effort/reasoning_effort/reasoningEffort/extra_body),语义归一交给
8
16
  // 各 provider 与 src/thinking-effort.js —— 旧版在此丢掉,下游被迫补默认值(workbuddy 恒 high,ADR-0053);
17
+ // 此处刻意**不**照抄 cc-switch 的 thinking→reasoning_effort 本地映射表(transform.rs:100-190);
9
18
  // * 但**必须主动丢** output_config 原始对象 / context_management / 消息块内 thinking:Claude Code 对
10
- // 不认识的 model ID 照发全套字段,块内 thinking 无法签发 signature,回传必被上游拒(ADR-0047)。
19
+ // 不认识的 model ID 照发全套字段,块内 thinking 无法签发 signature,回传必被上游拒(ADR-0047);
20
+ // * 空会话抛错,绝不伪造一句 "hi" 打上游(会把假提问写进用户上下文并白烧一发额度);
21
+ // * tool_result 重复 tool_use_id 保首个、丢后续(部分上游按 call_id 查重直接 400);
22
+ // `is_error` 保留 `[tool_error]` 前缀(上游可见的错误信号,别照抄 cc-switch 丢掉)。
11
23
  import { isServerTool } from "./web-search.js";
12
24
  import { newToolUseId } from "./shared.js";
25
+ import { canonicalJson, canonicalizeValue } from "./canonical-json.js";
26
+ import { stripLeadingBillingHeader, cleanToolSchema, isOpenAiOSeries } from "./request-shape.js";
27
+ import { imagePartOf, planToolResultMedia, queueToolMedia, flushToolMedia } from "./tool-media.js";
13
28
 
14
- // system 允许字符串或 text 块数组;块间空行连接(缓存语义不保留,上游是 OpenAI)
29
+ // system 允许字符串或 text 块数组;块间用 "\n" 连接(照抄 cc-switch:跨轮字节稳定)。
30
+ // 逐块剥开头的计费头:它每轮变一次,是前缀缓存失效的头号嫌疑人(cc-switch #2350)。
15
31
  function systemTextOf(system) {
16
- if (typeof system === "string") return system;
32
+ const one = (t) => stripLeadingBillingHeader(typeof t === "string" ? t : "");
33
+ if (typeof system === "string") return one(system);
17
34
  if (!Array.isArray(system)) return "";
18
- return system.map((b) => (b && typeof b.text === "string" ? b.text : "")).filter(Boolean).join("\n\n");
19
- }
20
-
21
- function imagePartOf(block) {
22
- const src = block?.source || {};
23
- if (src.type === "base64" && src.data) {
24
- return { type: "image_url", image_url: { url: `data:${src.media_type || "image/png"};base64,${src.data}` } };
25
- }
26
- if (src.type === "url" && src.url) return { type: "image_url", image_url: { url: String(src.url) } };
27
- return null;
35
+ return system
36
+ .map((b) => one(b && typeof b.text === "string" ? b.text : ""))
37
+ .filter(Boolean)
38
+ .join("\n");
28
39
  }
29
40
 
41
+ /** tool_result 的 content → 纯文本(字符串原样;块数组取各 text;其余 String())。 */
30
42
  export function toolResultTextOf(content) {
31
43
  if (typeof content === "string") return content;
32
44
  if (Array.isArray(content)) {
@@ -49,61 +61,70 @@ export function messagesToChatBody(req = {}) {
49
61
  // 重复 tool_use_id 保首个、丢后续:客户端重试/重放历史会把同一工具结果存两份,
50
62
  // 部分上游按 call_id 查重直接 400(同 responses/translate.js:57-59 的实测教训)。
51
63
  const seenToolResult = new Set();
64
+ // 工具结果里的媒体暂存:同轮普通消息之前冲成一条 user 消息(Chat 的 tool 消息不能带图分片)
65
+ const pendingMedia = [];
52
66
 
53
67
  for (const m of Array.isArray(req.messages) ? req.messages : []) {
54
68
  if (!m || typeof m !== "object") continue;
55
69
  // role=system/developer 必须保留:Claude Code ≥2.1.286 的 mid-conversation system beta 就往 messages[] 里塞系统指令,
56
70
  const role = m.role === "assistant" ? "assistant" : m.role === "system" || m.role === "developer" ? "system" : "user";
57
- const blocks = typeof m.content === "string" ? [{ type: "text", text: m.content }] : Array.isArray(m.content) ? m.content : [];
58
- let text = "";
59
- const imgs = [];
71
+ if (typeof m.content === "string") {
72
+ if (m.content) messages.push({ role, content: m.content });
73
+ continue;
74
+ }
75
+ const blocks = Array.isArray(m.content) ? m.content : null;
76
+ if (!blocks) {
77
+ if (m.content != null) messages.push({ role, content: m.content }); // 其它形状原样透传
78
+ continue;
79
+ }
80
+ const parts = []; // text / image 分片(照抄 cc-switch 的 content_parts)
60
81
  const toolCalls = [];
61
82
  for (const b of blocks) {
62
83
  if (!b || typeof b !== "object") continue;
63
- if (b.type === "text") text += typeof b.text === "string" ? b.text : "";
64
- else if (b.type === "image") { const p = imagePartOf(b); if (p) imgs.push(p); }
65
- else if (b.type === "tool_use") {
66
- let args = "{}";
67
- try { args = JSON.stringify(b.input ?? {}); } catch { args = "{}"; }
68
- toolCalls.push({ id: String(b.id || newToolUseId()), type: "function", function: { name: String(b.name || ""), arguments: args } });
84
+ if (b.type === "text") {
85
+ if (typeof b.text === "string" && b.text) parts.push({ type: "text", text: b.text });
86
+ } else if (b.type === "image") {
87
+ const p = imagePartOf(b);
88
+ if (p) parts.push(p);
89
+ } else if (b.type === "tool_use") {
90
+ toolCalls.push({
91
+ id: String(b.id || newToolUseId()),
92
+ type: "function",
93
+ function: { name: String(b.name || ""), arguments: canonicalJson(b.input ?? {}) },
94
+ });
69
95
  } else if (b.type === "tool_result") {
70
96
  // tool 消息必须先于同轮 user 文本进数组:OpenAI 侧要求 role:tool 紧跟其 assistant
71
97
  const key = String(b.tool_use_id || "");
72
98
  if (key) { if (seenToolResult.has(key)) continue; seenToolResult.add(key); }
73
- const body = toolResultTextOf(b.content);
74
- messages.push({ role: "tool", tool_call_id: key, content: b.is_error ? `[tool_error] ${body}` : body });
99
+ const plan = planToolResultMedia(b.content, canonicalJson);
100
+ let text;
101
+ if (plan) { queueToolMedia(pendingMedia, key, plan.media); text = plan.text; }
102
+ else text = toolResultTextOf(b.content);
103
+ messages.push({ role: "tool", tool_call_id: key, content: b.is_error ? `[tool_error] ${text}` : text });
75
104
  }
76
105
  // thinking / redacted_thinking 忽略:我们无法签发 signature,回传必被上游拒(ADR-0047)
77
106
  }
78
- if (!text && !imgs.length && !toolCalls.length) continue;
79
- const msg = { role, content: imgs.length ? [...(text ? [{ type: "text", text }] : []), ...imgs] : text };
107
+ // 媒体先给、再给同轮普通内容(照抄 cc-switch transform.rs:517 的顺序)
108
+ flushToolMedia(messages, pendingMedia);
109
+ if (!parts.length && !toolCalls.length) continue;
110
+ // 单 text 分片化简成纯字符串;无分片给 null(照抄 transform.rs:524-535)
111
+ const content = parts.length === 0 ? null
112
+ : parts.length === 1 && parts[0].type === "text" ? parts[0].text
113
+ : parts;
114
+ const msg = { role, content };
80
115
  if (toolCalls.length) msg.tool_calls = toolCalls;
81
116
  messages.push(msg);
82
117
  }
118
+ flushToolMedia(messages, pendingMedia); // 尾部残余(末条就是工具轮时)
83
119
  // 空会话直接 400:绝不伪造一句 "hi" 打上游(那会把假提问写进用户上下文并白烧一发额度)
84
120
  if (!messages.some((x) => x.role !== "system")) throw new Error("messages: 缺少用户消息");
85
121
 
86
122
  const body = { model, messages, stream: Boolean(req.stream) };
87
- // 流式必须让上游把 usage 放进收尾帧,否则 message_delta.usage 恒 0(OpenAI 规范字段)。
88
- // 个别上游不认 stream_options 会 400 → `MSLXDFF_ANTHROPIC_STREAM_USAGE=0` 关掉。
89
- if (body.stream && process.env.MSLXDFF_ANTHROPIC_STREAM_USAGE !== "0") body.stream_options = { include_usage: true };
90
- // Anthropic 的 server tool(web_search_*/web_fetch_*/…)没有 input_schema,折成假 function 会
91
- // 让上游收到「空参数空描述」的工具(严格端点直接 422),本网关不实现的发前一律剥除(ADR-0049)。
92
- const fnTools = (Array.isArray(req.tools) ? req.tools : []).filter((t) => !isServerTool(t));
93
- if (fnTools.length) {
94
- body.tools = fnTools.map((t) => ({
95
- type: "function",
96
- function: { name: String(t?.name || ""), description: String(t?.description || ""), parameters: t?.input_schema || {} },
97
- }));
123
+ // o 系列必须用 max_completion_tokens(照抄 cc-switch transform.rs:278-286)
124
+ if (req.max_tokens != null && Number.isFinite(Number(req.max_tokens))) {
125
+ if (isOpenAiOSeries(model)) body.max_completion_tokens = Number(req.max_tokens);
126
+ else body.max_tokens = Number(req.max_tokens);
98
127
  }
99
- const keptNames = new Set(fnTools.map((t) => String(t?.name || "")));
100
- const tc = req.tool_choice;
101
- if (tc === "auto" || (tc && typeof tc === "object" && tc.type === "auto")) body.tool_choice = "auto";
102
- else if (tc && typeof tc === "object" && tc.type === "any") body.tool_choice = "required";
103
- // 点名一个已被剥掉的工具时降为 auto:上游会按不存在的函数强制点名,那样一定拿不到有用回答
104
- else if (tc && typeof tc === "object" && tc.type === "tool") body.tool_choice = keptNames.has(String(tc.name || "")) ? { type: "function", function: { name: String(tc.name || "") } } : "auto";
105
- else if (tc && typeof tc === "object" && tc.type === "none") body.tool_choice = "none";
106
- if (req.max_tokens != null && Number.isFinite(Number(req.max_tokens))) body.max_tokens = Number(req.max_tokens);
107
128
  if (req.temperature != null) body.temperature = req.temperature;
108
129
  if (req.top_p != null) body.top_p = req.top_p;
109
130
  if (Array.isArray(req.stop_sequences) && req.stop_sequences.length) body.stop = req.stop_sequences;
@@ -122,5 +143,41 @@ export function messagesToChatBody(req = {}) {
122
143
  if (ocEffort != null && body.reasoning_effort == null && body.reasoningEffort == null) {
123
144
  body.reasoning_effort = ocEffort;
124
145
  }
125
- return body;
146
+ // 流式必须让上游把 usage 放进收尾帧,否则 message_delta.usage 恒 0(OpenAI 规范字段)。
147
+ // 照抄 cc-switch transform.rs:349-365:**merge** 客户端既有 stream_options,而不是整段覆盖。
148
+ // 个别上游不认 stream_options 会 400 → `MSLXDFF_ANTHROPIC_STREAM_USAGE=0` 关掉。
149
+ if (body.stream && process.env.MSLXDFF_ANTHROPIC_STREAM_USAGE !== "0") {
150
+ const opts = req.stream_options && typeof req.stream_options === "object" ? req.stream_options : {};
151
+ body.stream_options = { ...opts, include_usage: true };
152
+ }
153
+ // Anthropic 的 server tool(web_search_*/web_fetch_*/…)没有 input_schema,折成假 function 会
154
+ // 让上游收到「空参数空描述」的工具(严格端点直接 422),本网关不实现的发前一律剥除(ADR-0049);
155
+ // 另外照抄 cc-switch 剥 `BatchTool`、description 缺省不写、schema 过 cleanToolSchema。
156
+ const fnTools = (Array.isArray(req.tools) ? req.tools : [])
157
+ .filter((t) => !isServerTool(t))
158
+ .filter((t) => String(t?.type || "") !== "BatchTool");
159
+ if (fnTools.length) {
160
+ body.tools = fnTools.map((t) => {
161
+ const fn = { name: String(t?.name || "") };
162
+ if (typeof t?.description === "string" && t.description) fn.description = t.description;
163
+ fn.parameters = cleanToolSchema(t?.input_schema ?? {});
164
+ return { type: "function", function: fn };
165
+ });
166
+ }
167
+ const keptNames = new Set(fnTools.map((t) => String(t?.name || "")));
168
+ const tc = req.tool_choice;
169
+ if (typeof tc === "string") {
170
+ // 字符串形态:Anthropic 的 "any" → OpenAI 的 "required"(照抄 transform.rs:381-386)
171
+ if (tc === "any") body.tool_choice = "required";
172
+ else if (tc) body.tool_choice = tc;
173
+ } else if (tc && typeof tc === "object") {
174
+ if (tc.type === "auto") body.tool_choice = "auto";
175
+ else if (tc.type === "any") body.tool_choice = "required";
176
+ // 点名一个已被剥掉的工具时降为 auto:上游会按不存在的函数强制点名,那样一定拿不到有用回答
177
+ else if (tc.type === "tool") body.tool_choice = keptNames.has(String(tc.name || "")) ? { type: "function", function: { name: String(tc.name || "") } } : "auto";
178
+ else if (tc.type === "none") body.tool_choice = "none";
179
+ }
180
+ // 出站体键序规范化:同一逻辑内容必然产出同一份字节 → 上游前缀缓存能稳定命中
181
+ // (照抄 cc-switch forwarder.rs prepare_upstream_request_body / json_canonical.rs)。
182
+ return canonicalizeValue(body);
126
183
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * /v1/messages 请求形状的「取稳与清洗」原语 —— 1:1 照抄 cc-switch。
3
+ *
4
+ * 三件套:
5
+ * 1. `stripLeadingBillingHeader` —— 剥 system 文本**开头**的 `x-anthropic-billing-header:` 行
6
+ * (照抄 transform.rs:17-55,对应 cc-switch #2350)。Claude Code 会把含轮换 `cch=` 的
7
+ * 计费头塞在 system 最前面;它每轮变一次 → 整条前缀缓存失效 → 每轮全量 prefill。
8
+ * 只在开头剥,后文再出现同样的字符串一律保留(那是用户自己写的内容)。
9
+ * 2. `cleanToolSchema` —— 工具参数 schema 清洗(照抄 transform.rs:563-594):根缺 `type` 时补
10
+ * `"object"` 并顺带补 `properties: {}`,全树删 `format: "uri"`(严格上游会拒),嵌套递归。
11
+ * 3. `isOpenAiOSeries` —— o 系列(o1/o3/o4…)需 `max_completion_tokens` 而非 `max_tokens`
12
+ * (照抄 transform.rs:57-63 的意图;本仓模型 id 带供应商前缀,故按末段判定)。
13
+ */
14
+
15
+ const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
16
+
17
+ /**
18
+ * 剥 system 开头的计费头行(含紧随的一个空行)。
19
+ * 无换行(整段就是这一行)→ 返回空串,与 cc-switch 一致。
20
+ * @param {unknown} text
21
+ * @returns {string}
22
+ */
23
+ export function stripLeadingBillingHeader(text) {
24
+ const s = typeof text === "string" ? text : "";
25
+ if (!s.startsWith(BILLING_HEADER_PREFIX)) return s;
26
+ const nl = s.search(/[\r\n]/);
27
+ if (nl < 0) return "";
28
+ let rest = s.slice(nl + 1);
29
+ if (s[nl] === "\r" && rest.startsWith("\n")) rest = rest.slice(1);
30
+ if (rest.startsWith("\r\n")) return rest.slice(2);
31
+ if (rest.startsWith("\n") || rest.startsWith("\r")) return rest.slice(1);
32
+ return rest;
33
+ }
34
+
35
+ /** 递归清洗一份 JSON schema(不修改入参:每层都产新对象)。 */
36
+ export function cleanToolSchema(schema) {
37
+ return cleanSchemaInner(schema, true);
38
+ }
39
+
40
+ function cleanSchemaInner(schema, isRoot) {
41
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
42
+ const out = { ...schema };
43
+ const missingType = isRoot && !("type" in out);
44
+ if (missingType) out.type = "object";
45
+ if (missingType && !("properties" in out)) out.properties = {};
46
+ if (out.format === "uri") delete out.format;
47
+ if (out.properties && typeof out.properties === "object" && !Array.isArray(out.properties)) {
48
+ const props = {};
49
+ for (const k of Object.keys(out.properties)) props[k] = cleanSchemaInner(out.properties[k], false);
50
+ out.properties = props;
51
+ }
52
+ if (out.items !== undefined) out.items = cleanSchemaInner(out.items, false);
53
+ return out;
54
+ }
55
+
56
+ /** OpenAI o 系列(o1/o3/o4-mini…):只看 id 末段,避免供应商前缀(如 `openai/o3`)干扰。 */
57
+ export function isOpenAiOSeries(model) {
58
+ const id = String(model || "").split("/").pop() || "";
59
+ return /^o\d(?:$|[-.])/.test(id.toLowerCase());
60
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Claude 系会话身份提取 —— 1:1 照抄 cc-switch `src-tauri/src/proxy/session.rs:107-231`(Claude 分支)。
3
+ *
4
+ * 为什么需要:会话 id 用来给上游做「缓存亲和 / 粘性路由」(`prompt_cache_key`、会话头)。
5
+ * cc-switch #8236 的教训是:拿不到真实会话身份就每轮生成一个新 id ⇒ 每轮换缓存 key ⇒
6
+ * 前缀缓存永不命中。Claude Code 走 `/v1/messages` 时把会话身份放在**请求体 metadata**里
7
+ * (不是 header),所以只读 header 的实现会一路漏。
8
+ *
9
+ * 优先级(与 cc-switch 一致):
10
+ * 1. header `x-claude-code-session-id`,再 `claude-code-session-id`(非空即用:**不 trim、无长度门槛**)
11
+ * 2. `metadata.user_id`:trim 后以 `{` 开头 → 当 JSON 解析取 `session_id`(trim 后非空才认);
12
+ * 否则找字面量 `_session_`,取其后的整段(旧格式)
13
+ * 3. `metadata.session_id`(字符串,trim 后非空)
14
+ * 4. 都没有 → 返回 null(由调用方走既有兜底,绝不在这里凭空生成:生成值会每轮变)
15
+ *
16
+ * 明确不用(cc-switch 同款判断):Responses 的 `previous_response_id` 之类「每轮变化的响应游标」
17
+ * 不是稳定会话身份,当成缓存 key 会导致每轮换 key。
18
+ */
19
+
20
+ const CLAUDE_SESSION_HEADERS = ["x-claude-code-session-id", "claude-code-session-id"];
21
+ const LEGACY_MARKER = "_session_";
22
+
23
+ /** `metadata.user_id` → 会话 id(JSON 里的 session_id,或旧 `_session_` 后缀)。 */
24
+ function sessionFromUserId(userId) {
25
+ const t = typeof userId === "string" ? userId.trim() : "";
26
+ if (!t) return null;
27
+ if (t.startsWith("{")) {
28
+ try {
29
+ const j = JSON.parse(t);
30
+ const s = typeof j?.session_id === "string" ? j.session_id.trim() : "";
31
+ return s || null;
32
+ } catch {
33
+ return null; // 坏 JSON:不猜,交下一档
34
+ }
35
+ }
36
+ const i = t.indexOf(LEGACY_MARKER);
37
+ if (i < 0) return null;
38
+ return t.slice(i + LEGACY_MARKER.length) || null;
39
+ }
40
+
41
+ /**
42
+ * @param {Record<string, unknown>} headers 入站头(Node 已小写化)
43
+ * @param {any} body 原始 Anthropic 请求体
44
+ * @returns {{sessionId: string, source: string} | null}
45
+ */
46
+ export function claudeSessionId(headers = {}, body = null) {
47
+ for (const name of CLAUDE_SESSION_HEADERS) {
48
+ const v = headers?.[name];
49
+ if (typeof v === "string" && v) return { sessionId: v, source: "header" };
50
+ }
51
+ const meta = body && typeof body === "object" && body.metadata && typeof body.metadata === "object" ? body.metadata : null;
52
+ if (!meta) return null;
53
+ const fromUser = sessionFromUserId(meta.user_id);
54
+ if (fromUser) return { sessionId: fromUser, source: "metadata.user_id" };
55
+ const sid = typeof meta.session_id === "string" ? meta.session_id.trim() : "";
56
+ if (sid) return { sessionId: sid, source: "metadata.session_id" };
57
+ return null;
58
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 工具结果里的媒体:抽取 → 排队 → 冲刷成一条 user 消息。
3
+ *
4
+ * 1:1 照抄 cc-switch `src-tauri/src/proxy/tool_media.rs:40-145`:
5
+ * · 抽取:把 tool_result content 里的媒体块**原地换成一行文本标记**,媒体本体单独收起来;
6
+ * 原本是字符串的 content 抽完仍是字符串(给原始 data URL / 字符串化 JSON 再套一层引号
7
+ * 会改变模型看到的东西);原本是结构化的则走 canonical JSON。
8
+ * · 排队:把 `[media output of tool call <id>]` 文本 + 媒体挂在待冲刷表上。
9
+ * · 冲刷:**在同轮的普通消息之前**插一条 `role:"user"` 消息承载全部媒体 ——
10
+ * OpenAI Chat 的 `role:"tool"` 消息不能带图片分片,故媒体必须另开一条 user 轮。
11
+ *
12
+ * 为什么在 /v1/messages 做:改前这里是**静默丢图**(tool_result 里的截图/图片直接消失),
13
+ * 模型看不到用户给的图,只能靠文字猜。
14
+ */
15
+
16
+ export const MEDIA_MOVED_MARKER = "[mslxdff: media moved to the following user turn]";
17
+
18
+ /**
19
+ * Anthropic 图片块 → OpenAI Chat 图片分片(base64 / url 两种 source)。
20
+ * 本仓唯一的「块 → 分片」判据,request-body 的 user 消息图片也走它。
21
+ * @param {any} block
22
+ * @returns {{type: "image_url", image_url: {url: string}} | null}
23
+ */
24
+ export function imagePartOf(block) {
25
+ const src = block?.source || {};
26
+ if (src.type === "base64" && src.data) {
27
+ return { type: "image_url", image_url: { url: `data:${src.media_type || "image/png"};base64,${src.data}` } };
28
+ }
29
+ if (src.type === "url" && src.url) return { type: "image_url", image_url: { url: String(src.url) } };
30
+ return null;
31
+ }
32
+
33
+ /** 递归把媒体块换成文本标记,媒体本体收进 out。返回替换个数。 */
34
+ function stripMedia(value, out) {
35
+ if (Array.isArray(value)) return value.map((v) => stripMedia(v, out));
36
+ if (value && typeof value === "object") {
37
+ const part = imagePartOf(value);
38
+ if (part) {
39
+ out.push(part);
40
+ return { type: "text", text: MEDIA_MOVED_MARKER };
41
+ }
42
+ const o = {};
43
+ for (const k of Object.keys(value)) o[k] = stripMedia(value[k], out);
44
+ return o;
45
+ }
46
+ return value;
47
+ }
48
+
49
+ /**
50
+ * 规划一条 tool_result 的 content:抽出媒体并给出 tool 消息该放的文本。
51
+ * @param {unknown} content Anthropic tool_result 的 content(字符串或块数组)
52
+ * @param {(v: unknown) => string} canonicalJson 键序规范化序列化(避免本模块反向依赖)
53
+ * @returns {{text: string, media: any[]} | null} 无媒体返回 null(保持旧字节不变)
54
+ */
55
+ export function planToolResultMedia(content, canonicalJson) {
56
+ const wasString = typeof content === "string";
57
+ const media = [];
58
+ const replaced = stripMedia(content, media);
59
+ if (!media.length) return null;
60
+ const text = wasString ? String(replaced) : canonicalJson(replaced);
61
+ return { text, media };
62
+ }
63
+
64
+ /** 把一条工具调用的媒体挂到待冲刷表(照抄 queue_chat_tool_output_media)。 */
65
+ export function queueToolMedia(pending, toolUseId, media) {
66
+ if (!Array.isArray(media) || !media.length) return;
67
+ pending.push({ type: "text", text: `[mslxdff: media output of tool call ${toolUseId}]` });
68
+ pending.push(...media);
69
+ }
70
+
71
+ /** 冲刷成一条 user 消息(照抄 flush_pending_chat_tool_media:整批一起给,顺序不变)。 */
72
+ export function flushToolMedia(messages, pending) {
73
+ if (!pending.length) return;
74
+ messages.push({ role: "user", content: pending.splice(0, pending.length) });
75
+ }
@@ -17,6 +17,7 @@
17
17
  import { chunkToString } from "../responses/translate.js";
18
18
  import { createTokenCounter } from "../text-tokens.js";
19
19
  import { newMessageId, newToolUseId } from "./shared.js";
20
+ import { cacheReadTokens, cacheWriteTokens } from "../usage/cache-fields.js";
20
21
 
21
22
  // re-export:拆档不改对外接口,调用方与测试仍从本文件取这些符号
22
23
  export { messagesToChatBody, toolResultTextOf } from "./request-body.js";
@@ -34,20 +35,21 @@ export { newMessageId, newToolUseId, anthropicError } from "./shared.js";
34
35
  */
35
36
  export function toAnthropicUsage(u = {}, fallback = null) {
36
37
  const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
38
+ // 缓存读数候选链的**单一来源**在 ../usage/cache-fields.js(metrics.js 的日志投影同源):
39
+ // 命中=「存在且 >0」才采信(显式 0 视为未报,防中转桩 0 掩蔽真命中);写入=字段存在即采信。
37
40
  const src = u && typeof u === "object" ? u : {};
38
41
  const prompt = num(src.prompt_tokens ?? src.input_tokens);
39
42
  const output = num(src.completion_tokens ?? src.output_tokens);
40
- const cacheRead = num(src.cache_read_input_tokens ?? src.prompt_tokens_details?.cached_tokens ?? src.input_tokens_details?.cached_tokens);
41
- const cacheWrite = num(src.cache_creation_input_tokens ?? src.prompt_tokens_details?.cache_creation_input_tokens);
43
+ const cacheRead = cacheReadTokens(src);
44
+ const cacheWrite = cacheWriteTokens(src) ?? 0;
42
45
  const usage = {
43
46
  input_tokens: Math.max(0, prompt - cacheRead - cacheWrite),
44
47
  output_tokens: output,
45
48
  cache_read_input_tokens: cacheRead,
46
49
  cache_creation_input_tokens: cacheWrite,
47
50
  };
48
- // cache 非零也算「报了」:SDK 通道(upstream-engine/sdk/sse.js 的 usageToOpenAI)会输出
49
- // `{prompt_tokens:0, …, prompt_tokens_details:{cached_tokens:N}}` 这种形状,若当未报整条覆盖,
50
- // 客户端「input + cache_read」会把同一段前缀算两遍(正是上面扣减要防的事,评审 P2)。
51
+ // cache 非零也算「报了」:SDK 通道会输出 `{prompt_tokens:0, …, prompt_tokens_details:{cached_tokens:N}}`,
52
+ // 若当未报整条覆盖,客户端「input + cache_read」会把同一段前缀算两遍。
51
53
  if (fallback && !(prompt > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0)) return { ...usage, ...(fallback() || {}) };
52
54
  return usage;
53
55
  }
@@ -88,14 +90,20 @@ export function textFromChatContent(c) {
88
90
  export function chatJsonToAnthropic(chatJson = {}, model = "", opts = {}) {
89
91
  const choice = chatJson.choices?.[0] || {};
90
92
  const message = choice.message || {};
93
+ const id = typeof chatJson.id === "string" && chatJson.id.startsWith("msg_") ? chatJson.id : newMessageId();
91
94
  const content = [];
95
+ // 思考块排在正文前:上游把思考放在 message.reasoning_content(DeepSeek/MiMo/聚合网关);
96
+ // 照抄 cc-switch transform.rs:614-641 的前置顺序。signature 用本仓占位(ADR-0052:缺签名
97
+ // Claude Code 直接丢弃整块);外发开关与流式同源(MSLXDFF_ANTHROPIC_THINKING=0 关)。
98
+ const rt = typeof message.reasoning_content === "string" ? message.reasoning_content : "";
99
+ if (opts.thinking === true && rt) content.push({ type: "thinking", thinking: rt, signature: thinkingSignature(id, 0) });
92
100
  const text = textFromChatContent(message.content);
93
101
  if (text) content.push({ type: "text", text });
94
102
  for (const tc of message.tool_calls || []) {
95
103
  content.push({ type: "tool_use", id: String(tc.id || newToolUseId()), name: String(tc.function?.name || ""), input: parseToolInput(tc.function?.arguments) });
96
104
  }
97
105
  return {
98
- id: typeof chatJson.id === "string" && chatJson.id.startsWith("msg_") ? chatJson.id : newMessageId(),
106
+ id,
99
107
  type: "message",
100
108
  role: "assistant",
101
109
  model: model || chatJson.model || "",
@@ -112,6 +120,7 @@ function fallbackUsage(opts = {}, content = []) {
112
120
  for (const b of content) {
113
121
  if (b?.type === "text") counter.add(b.text || "");
114
122
  else if (b?.type === "tool_use") counter.add(JSON.stringify(b.input ?? {})); // 只计参数:与流式 pushTool 同口径(name 从不进 gen)
123
+ else if (b?.type === "thinking") counter.add(b.thinking || ""); // thinking 也按 output 计费(与流式 pushThinking 同口径)
115
124
  }
116
125
  const n = typeof opts.estInput === "function" ? Number(opts.estInput()) : 0;
117
126
  return { input_tokens: Number.isFinite(n) && n > 0 ? Math.floor(n) : 0, output_tokens: counter.tokens() };
@@ -13,22 +13,6 @@ import { apiKindOf } from "../api-kind.js";
13
13
  * 内部组合 Policy→Engine:解析 header/model → 产 order → 委托 engine 执行
14
14
  * gateway 仅薄适配:readBody + request:received hook + 调 execute
15
15
  */
16
- /**
17
- * 指定 free 模型时是否允许横向切同池兄弟(BUG-7)。
18
- * 缺省开:opencode 免费池的裸 id 模型(`mimo-v2.6-flash-free` 等)语义上就是"免费额度内随便用",
19
- * 单个模型额度耗尽不该让整条链路失败。逃生阀 `MSLXDFF_FREE_POOL_FALLBACK=0` 回到"显式指定即锁死"。
20
- * 只在**非 lockModel**(即不经组内 model-lock 转发)时生效。
21
- */
22
- // 同池互备最多横向试几个(含首个指定的那个):额度类失败常见于"整池都满",
23
- // 扫全池只会把快速失败拖成分钟级,试前几个足够区分"这个模型满"与"这池满"。
24
- const FREE_POOL_MAX = 4;
25
-
26
- export function freePoolFallbackOn(env = process.env) {
27
- const raw = env.MSLXDFF_FREE_POOL_FALLBACK;
28
- if (raw === undefined || raw === null || raw === "") return true;
29
- const s = String(raw).trim().toLowerCase();
30
- return !(s === "0" || s === "false" || s === "off" || s === "no");
31
- }
32
16
 
33
17
  export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, token, plugins, maxHops } = {}) {
34
18
  const engine = createEngine();
@@ -91,11 +75,10 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
91
75
 
92
76
  // order 推导 + plugin model:select 可改
93
77
  // 语义:指定模型 = 死锁单模型(本机→组员同款,挂了就报挂,不兜其他 picks);只有 auto 才轮 picks
94
- // 例外:opencode 免费池的裸 id free 模型(mimo-v2.6-flash-free / deepseek-v4.1-flash-free…)
95
- // 额度耗尽时允许**同池互备**——见 freePoolFallbackOn()(BUG-7 用户点名诉求)。
78
+ // 指定模型的额度耗尽时**不换模型**:横向只在同模型的组员/组长间接力(本机出口 IP 耗尽 → 换组员机器),
79
+ // 模型永不漂移。详见 serial-trial 的 order 单元素路径与 peer 接力。
96
80
  // x-mslxdff-auto-provider 头可把 auto 候选限定到单供应商(opencode=裸 id 免费池),-chat 默认带
97
81
  let order;
98
- let poolFallback = false; // 指定 free 模型时的「同池互备」标记:只有额度类失败才允许继续换兄弟
99
82
  if (lockModel) order = [requested];
100
83
  else if (useAuto) {
101
84
  let cands = auto ? await auto.candidates() : [""];
@@ -108,28 +91,13 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
108
91
  }
109
92
  order = cands;
110
93
  } else {
111
- // 指定模型的常态是死锁单模型。**唯一例外**:裸 id 的 free 模型(opencode 免费池),
112
- // 且只在**额度类**失败(429 / 具名额度判决)时才横向切同池兄弟——参数错/5xx 照旧原样透传,
113
- // 不把「客户端发错了」伪装成换模型成功,也不白烧别的模型额度(见 isPoolSwitchWorthy)。
114
- // candidatesFor 的持久化副作用(把指定模型加入勾选集)在任何分支都必须保留。
115
- const wantFreePool = Boolean(auto && requested && freePoolFallbackOn() && isFreeModel(requested) && !String(requested).includes("/"));
94
+ // 指定模型 = 死锁单模型:额度耗尽只换组员/组长(同模型换机器),**绝不换模型**。
95
+ // candidatesFor 的持久化副作用(把显式指定的模型加入勾选集,供日后 auto 用)必须保留——
96
+ // 见 auto.js:180「显式指定某模型 = 认可它,自动加入勾选集」,历史上删它会丢勾选记录。
116
97
  if (auto && requested) {
117
- try {
118
- const cands = await auto.candidatesFor(requested);
119
- if (wantFreePool && Array.isArray(cands) && cands.length) {
120
- // 上限截断:串行换人是"试一试"不是"扫全池",太多候选会把额度类失败也拖成分钟级
121
- const samePool = cands
122
- .filter((m) => m === requested || (isFreeModel(m) && !String(m).includes("/")))
123
- .slice(0, FREE_POOL_MAX);
124
- if (samePool.length > 1) {
125
- order = samePool;
126
- poolFallback = true;
127
- evt("free-pool-fallback", { reqId, model: requested, pool: samePool, size: samePool.length });
128
- }
129
- }
130
- } catch {}
98
+ try { await auto.candidatesFor(requested); } catch {}
131
99
  }
132
- if (!order) order = [requested];
100
+ order = [requested];
133
101
  }
134
102
  if (!order.length) order = [""];
135
103
  const canFallback = order.length > 1;
@@ -163,7 +131,7 @@ export function createChatPipeline({ upstream, auto, logs, peers, groups, bus, t
163
131
  await engine.run({
164
132
  reqId, startedAt, req, res, body: req?.body, policy,
165
133
  useAuto, lockModel, requested, hops,
166
- canFallback, canForwardPeers, poolFallback,
134
+ canFallback, canForwardPeers,
167
135
  perf0, stages, mark, evt, logCall, logError, done, handlerCtx,
168
136
  auto, upstream, peers, groups, bus, token, plugins, logs,
169
137
  order,