mslxdff 0.1.172 → 0.1.173

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.172",
3
+ "version": "0.1.173",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,39 +1,54 @@
1
- // /v1/messages/count_tokens 的字符估算(从 translate.js 拆出,控单文件体积 ≤20KB 硬门)。
1
+ // /v1/messages/count_tokens 的 token 估算(从 translate.js 拆出,控单文件体积 ≤20KB 硬门)。
2
2
  // 精度非目标:Anthropic 官方说明该端点缺失时客户端本来就退回字符估算。
3
+ // 权重口径的单一来源在 ../text-tokens.js(ADR-0055):旧版整段 `chars/4` 把中文低报约 4 倍,而客户端
4
+ // 正是拿这个读数决定何时压实上下文 —— 读数偏低就一路不压实,撞上游「上下文超限」才炸。
3
5
  import { toolResultTextOf } from "./translate.js";
6
+ import { estimateTextTokens, createTokenCounter } from "../text-tokens.js";
4
7
 
5
- // 图片不吃字符但占真实上下文,按固定面值计;总长设硬上限防极端请求把除法推到 Infinity
8
+ // 图片不吃字符但占真实上下文,按固定面值计;总长设硬上限防极端请求把估算推到天文数字
6
9
  const IMAGE_TOKENS = 2000;
7
- const CHARS_PER_TOKEN = 4;
8
10
  const CHAR_CAP = 32_000_000;
9
11
 
12
+ // 请求侧按「一条消息」聚成一段再套一次公式:粒度对齐参照实现 wb_proxy.py:14267-14278
13
+ // (每条消息 content 整段一次 + 每个 tool_call 一次)。逐块各套会把「非空至少 1」下限乘上块数
14
+ // —— 几十个 1 字符 text 块能被高报几十倍(评审 P3:方向虽偏高安全,但与生成侧口径分叉就是第二个真相源)。
15
+ function add(acc, text) {
16
+ if (acc.chars > CHAR_CAP) return; // 超限后停止累计,只保留已计部分与图片面值
17
+ const s = text == null ? "" : String(text);
18
+ const c = createTokenCounter();
19
+ c.add(s);
20
+ acc.chars += s.length;
21
+ acc.tokens += c.tokens();
22
+ }
23
+
10
24
  function countMessage(m, acc) {
11
25
  if (!m || typeof m !== "object") return;
12
26
  const blocks = typeof m.content === "string" ? [{ type: "text", text: m.content }] : Array.isArray(m.content) ? m.content : null;
13
- if (!blocks) { acc.chars += JSON.stringify(m.content ?? "").length; return; }
27
+ if (!blocks) { add(acc, JSON.stringify(m.content ?? "")); return; }
28
+ const c = createTokenCounter();
29
+ let chars = 0;
30
+ const put = (s) => { c.add(s); chars += s.length; };
14
31
  for (const b of blocks) {
15
32
  if (!b || typeof b !== "object") continue;
16
- if (typeof b.text === "string") acc.chars += b.text.length;
17
- else if (b.type === "tool_use") acc.chars += String(b.name || "").length + JSON.stringify(b.input ?? {}).length;
18
- else if (b.type === "tool_result") acc.chars += toolResultTextOf(b.content).length; // 工具结果是大头,必须算(grill Q7)
19
- else if (b.type === "thinking") acc.chars += String(b.thinking || "").length;
33
+ if (typeof b.text === "string") put(b.text);
34
+ else if (b.type === "tool_use") put(String(b.name || "") + JSON.stringify(b.input ?? {}));
35
+ else if (b.type === "tool_result") put(toolResultTextOf(b.content)); // 工具结果是大头,必须算
36
+ else if (b.type === "thinking") put(String(b.thinking || ""));
20
37
  else if (b.type === "image") acc.images += 1;
21
38
  }
39
+ if (acc.chars <= CHAR_CAP) acc.chars += chars;
40
+ acc.tokens += c.tokens();
22
41
  }
23
42
 
24
43
  /** 字符估算版 token 计数(Anthropic 官方:端点缺失时客户端本来就退回字符估算,精度非目标)。 */
25
44
  export function estimateTokens(req = {}) {
26
- let chars = 0;
27
- let images = 0;
28
- const acc = { chars: 0, images: 0 };
45
+ const acc = { chars: 0, tokens: 0, images: 0 };
29
46
  const sys = typeof req.system === "string" ? [{ type: "text", text: req.system }] : Array.isArray(req.system) ? req.system : [];
30
- for (const b of sys) acc.chars += b && typeof b.text === "string" ? b.text.length : 0;
47
+ for (const b of sys) add(acc, b && typeof b.text === "string" ? b.text : "");
31
48
  for (const m of Array.isArray(req.messages) ? req.messages : []) countMessage(m, acc);
32
- if (Array.isArray(req.tools)) acc.chars += JSON.stringify(req.tools).length;
33
- chars = Math.min(acc.chars, CHAR_CAP);
34
- images = acc.images;
49
+ if (Array.isArray(req.tools)) add(acc, JSON.stringify(req.tools));
35
50
  // 字段名以 Anthropic 线上为准:`POST /v1/messages/count_tokens` 回 `{"input_tokens": N}`;
36
51
  // 额外带一份 count_tokens 仅作人读兼容(Claude Code 读的是 input_tokens)。
37
- const n = Math.max(1, Math.ceil(chars / CHARS_PER_TOKEN) + images * IMAGE_TOKENS);
52
+ const n = Math.max(1, acc.tokens) + acc.images * IMAGE_TOKENS;
38
53
  return { input_tokens: n, count_tokens: n };
39
54
  }
@@ -0,0 +1,126 @@
1
+ // 请求侧翻译:Anthropic Messages 请求体 → OpenAI chat 请求体(/v1/messages 外壳,ADR-0047)。
2
+ // 从 translate.js 拆出(ADR-0055):translate.js 贴 20KB 体积硬门,且「入站请求形状」与
3
+ // 「出站响应/usage 翻译」本就是两向关注点,拆后 translate.js 单向 import 本文件,不回环。
4
+ // 对外接口不变:调用方与测试仍从 anthropic/translate.js import(那边 re-export)。
5
+ //
6
+ // 纪律(别改回去):
7
+ // * 思考档位**原样透传**(thinking/effort/reasoning_effort/reasoningEffort/extra_body),语义归一交给
8
+ // 各 provider 与 src/thinking-effort.js —— 旧版在此丢掉,下游被迫补默认值(workbuddy 恒 high,ADR-0053);
9
+ // * 但**必须主动丢** output_config 原始对象 / context_management / 消息块内 thinking:Claude Code 对
10
+ // 不认识的 model ID 照发全套字段,块内 thinking 无法签发 signature,回传必被上游拒(ADR-0047)。
11
+ import { isServerTool } from "./web-search.js";
12
+ import { newToolUseId } from "./shared.js";
13
+
14
+ // system 允许字符串或 text 块数组;块间空行连接(缓存语义不保留,上游是 OpenAI)
15
+ function systemTextOf(system) {
16
+ if (typeof system === "string") return system;
17
+ 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;
28
+ }
29
+
30
+ export function toolResultTextOf(content) {
31
+ if (typeof content === "string") return content;
32
+ if (Array.isArray(content)) {
33
+ return content
34
+ .map((p) => (typeof p === "string" ? p : p && typeof p.text === "string" ? p.text : ""))
35
+ .filter(Boolean)
36
+ .join("\n");
37
+ }
38
+ return content == null ? "" : String(content);
39
+ }
40
+
41
+ /** Anthropic Messages 请求体 → OpenAI chat 请求体(直接喂既有 ChatPipeline)。 */
42
+ export function messagesToChatBody(req = {}) {
43
+ const model = String(req.model || "").trim();
44
+ if (!model) throw new Error("messages: 缺少 model");
45
+ const messages = [];
46
+ const sys = systemTextOf(req.system);
47
+ if (sys) messages.push({ role: "system", content: sys });
48
+
49
+ // 重复 tool_use_id 保首个、丢后续:客户端重试/重放历史会把同一工具结果存两份,
50
+ // 部分上游按 call_id 查重直接 400(同 responses/translate.js:57-59 的实测教训)。
51
+ const seenToolResult = new Set();
52
+
53
+ for (const m of Array.isArray(req.messages) ? req.messages : []) {
54
+ if (!m || typeof m !== "object") continue;
55
+ // role=system/developer 必须保留:Claude Code ≥2.1.286 的 mid-conversation system beta 就往 messages[] 里塞系统指令,
56
+ 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 = [];
60
+ const toolCalls = [];
61
+ for (const b of blocks) {
62
+ 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 } });
69
+ } else if (b.type === "tool_result") {
70
+ // tool 消息必须先于同轮 user 文本进数组:OpenAI 侧要求 role:tool 紧跟其 assistant
71
+ const key = String(b.tool_use_id || "");
72
+ 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 });
75
+ }
76
+ // thinking / redacted_thinking 忽略:我们无法签发 signature,回传必被上游拒(ADR-0047)
77
+ }
78
+ if (!text && !imgs.length && !toolCalls.length) continue;
79
+ const msg = { role, content: imgs.length ? [...(text ? [{ type: "text", text }] : []), ...imgs] : text };
80
+ if (toolCalls.length) msg.tool_calls = toolCalls;
81
+ messages.push(msg);
82
+ }
83
+ // 空会话直接 400:绝不伪造一句 "hi" 打上游(那会把假提问写进用户上下文并白烧一发额度)
84
+ if (!messages.some((x) => x.role !== "system")) throw new Error("messages: 缺少用户消息");
85
+
86
+ 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
+ }));
98
+ }
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
+ if (req.temperature != null) body.temperature = req.temperature;
108
+ if (req.top_p != null) body.top_p = req.top_p;
109
+ if (Array.isArray(req.stop_sequences) && req.stop_sequences.length) body.stop = req.stop_sequences;
110
+ // 思考档位原样透传(不解释语义,语义归一交给各 provider / src/thinking-effort.js):
111
+ // 客户端显式声明必须一路带到上游,旧版在此丢掉 → 下游被迫补默认值(workbuddy 恒 high)。
112
+ if (req.thinking != null) body.thinking = req.thinking;
113
+ // 档位载体只收「非空 trimmed 字符串」:空串声明不算声明,不该发出去顶掉下游默认值。
114
+ for (const k of ["effort", "reasoning_effort", "reasoningEffort"]) {
115
+ const v = typeof req[k] === "string" ? req[k].trim() : "";
116
+ if (v) body[k] = v;
117
+ }
118
+ if (req.extra_body != null) body.extra_body = req.extra_body;
119
+ // output_config.effort 是 Claude Code 的另一档位载体(beta),原始对象上游不认故不外发,
120
+ // 但档位本身不能丢 → 归一到 reasoning_effort(客户端已显式声明时以声明为准)。
121
+ const ocEffort = req.output_config && typeof req.output_config === "object" ? req.output_config.effort : undefined;
122
+ if (ocEffort != null && body.reasoning_effort == null && body.reasoningEffort == null) {
123
+ body.reasoning_effort = ocEffort;
124
+ }
125
+ return body;
126
+ }
@@ -0,0 +1,18 @@
1
+ // /v1/messages 外壳的公共原语:消息/工具 id 与 Anthropic 协议错误体。
2
+ // ADR-0055 把请求侧拆去 request-body.js 时独立成档,让 request-body.js 与 translate.js 单向依赖它,
3
+ // 避免两文件互相 import 成环(函数提升虽能跑,顶层常量会 TDZ,属定时炸弹)。
4
+ // 共用面:src/anthropic/request-body.js(newToolUseId)、src/anthropic/translate.js(newMessageId/
5
+ // newToolUseId,并对外 re-export 三个符号,调用方与测试的 import 路径不变)。
6
+
7
+ let seq = 0;
8
+ export function newMessageId() {
9
+ return `msg_${Date.now().toString(36)}${(seq++).toString(36)}`;
10
+ }
11
+ export function newToolUseId() {
12
+ return `toolu_${Date.now().toString(36)}${(seq++).toString(36)}`;
13
+ }
14
+
15
+ /** Anthropic 协议错误体形状(非流式 JSON 与流式 in-band error 帧共用)。 */
16
+ export function anthropicError(type, message) {
17
+ return { type: "error", error: { type: String(type || "api_error"), message: String(message || "error").slice(0, 500) } };
18
+ }
@@ -0,0 +1,56 @@
1
+ // 上游只会流式、客户端要非流式时的 SSE 聚合(ADR-0055)。
2
+ // 从 `src/routes/messages-route.js` 抽出:该文件贴着 20KB 体积硬门(20306/20480),本单要加的
3
+ // usage 兜底接线必须先进一段,故沿用 ADR-0053 把 count_tokens 估算器拆成独立文件的同类先例。
4
+ // 共用面:唯一调用方 `src/routes/messages-route.js`(体积门驱动的等价搬移,非新抽象)。
5
+ //
6
+ // 逐行吃 `data:` 帧聚成 chat.completion JSON(与 qoder/aggregate.js、zcode 非流式聚合同构),
7
+ // 出口复用 `chatJsonToAnthropic`;一帧都没有或夹带 in-band 错误帧时返回 null,落回原有 502 + errorText
8
+ // 路径 —— 错误绝不聚合成正文。
9
+
10
+ /**
11
+ * @param text collector 收到的原始响应体(SSE 帧序列)
12
+ * @param model 回显给客户端的模型名
13
+ * @returns chat.completion 形状对象,或 null(无有效帧 / 夹带错误帧)
14
+ */
15
+ export function sseToChatJson(text, model) {
16
+ let content = "";
17
+ let finish = "";
18
+ let usage = null;
19
+ let sawChunk = false;
20
+ const toolSlots = new Map();
21
+ for (const raw of String(text || "").split("\n")) {
22
+ const line = raw.trim();
23
+ if (!line.startsWith("data:")) continue; // 注释帧/keepalive/事件名行全跳过
24
+ const payload = line.slice(5).trim();
25
+ if (!payload || payload === "[DONE]") continue;
26
+ let chunk;
27
+ try { chunk = JSON.parse(payload); } catch { continue; }
28
+ if (chunk?.error) return null;
29
+ sawChunk = true;
30
+ const choice = chunk.choices?.[0] || {};
31
+ const delta = choice.delta || {};
32
+ if (choice.finish_reason) finish = choice.finish_reason;
33
+ if (chunk.usage) usage = chunk.usage;
34
+ if (typeof delta.content === "string" && delta.content) content += delta.content;
35
+ for (const tc of delta.tool_calls || []) {
36
+ const slot = Number(tc?.index ?? 0);
37
+ let t = toolSlots.get(slot);
38
+ if (!t) { t = { id: "", name: "", args: "" }; toolSlots.set(slot, t); }
39
+ if (tc?.id) t.id = String(tc.id);
40
+ if (tc?.function?.name) t.name += String(tc.function.name);
41
+ if (tc?.function?.arguments) t.args += String(tc.function.arguments);
42
+ }
43
+ }
44
+ if (!sawChunk) return null;
45
+ const toolCalls = [...toolSlots.values()].map((t, i) => ({ id: t.id || `call_${i}`, type: "function", function: { name: t.name, arguments: t.args || "{}" } }));
46
+ const message = { role: "assistant", content: content || (toolCalls.length ? null : "") };
47
+ if (toolCalls.length) message.tool_calls = toolCalls;
48
+ const out = {
49
+ id: "", object: "chat.completion", created: Math.floor(Date.now() / 1000), model,
50
+ choices: [{ index: 0, message, finish_reason: finish || (toolCalls.length ? "tool_calls" : "stop") }],
51
+ };
52
+ // 上游真报过 usage 才带这个字段:旧版合成 `{prompt_tokens:0,…}` 会把「未报」伪装成「报了 0」,
53
+ // 让翻译层的兜底判据永远看不到真实形状(ADR-0055 决策 3)。
54
+ if (usage) out.usage = usage;
55
+ return out;
56
+ }
@@ -1,148 +1,26 @@
1
1
  /**
2
- * Anthropic Messages ⇄ OpenAI chat 翻译层(/v1/messages 外壳,ADR-0047)。
2
+ * Anthropic Messages 出站翻译层:chat → Anthropic message / SSE 具名事件(/v1/messages 外壳,ADR-0047)。
3
3
  * 纯函数,无网络、无副作用;出站形状依据 code.claude.com/docs/en/llm-gateway-protocol
4
4
  * 与参考实现 lamdt1/ms-copilot365-2api/app/formatters/anthropic_sse.py。
5
+ * 分档(ADR-0055,为守本文件 ≤20KB 体积硬门;对外 import 路径全走本文件、调用方零改动):
6
+ * · 请求侧 Anthropic → chat 在 ./request-body.js;
7
+ * · 消息/工具 id 与协议错误体在 ./shared.js;
8
+ * · token 估算的单一来源在 ../text-tokens.js(上游未报 usage 时由它兜底)。
5
9
  *
6
10
  * 与 src/responses/translate.js 的三点关键差异:
7
11
  * 1) Anthropic 的 content block **只封口不复用**(Responses 侧 ensureTextItem 会复用已开项);
8
12
  * 2) 事件是具名的(`event: <type>` + `data:`),且**没有 `data: [DONE]`**,收场只有
9
13
  * `message_delta`+`message_stop`(成功)或一帧 `error`(失败)——见 grill-decisions.md Q2;
10
- * 3) 请求侧**保留思考档位**(thinking/effort/reasoning_effort/reasoningEffort/extra_body 原样透传给
11
- * pipeline,语义归一交给各 provider 与 src/thinking-effort.js),但**必须主动丢** output_config/
12
- * context_management 与消息块内的 thinking:Claude Code 对不认识的 model ID 照发全套字段,而块内
13
- * thinking 我们无法签发 signature,回传必被上游拒(ADR-0047)。
14
+ * 3) 请求侧**保留思考档位**、**必须主动丢** output_config/context_management 与消息块内 thinking
15
+ * (块内 thinking 我们无法签发 signature,回传必被上游拒)——细则见 ./request-body.js 头(ADR-0047)。
14
16
  */
15
17
  import { chunkToString } from "../responses/translate.js";
16
- import { isServerTool } from "./web-search.js";
18
+ import { createTokenCounter } from "../text-tokens.js";
19
+ import { newMessageId, newToolUseId } from "./shared.js";
17
20
 
18
- let seq = 0;
19
- export function newMessageId() {
20
- return `msg_${Date.now().toString(36)}${(seq++).toString(36)}`;
21
- }
22
- export function newToolUseId() {
23
- return `toolu_${Date.now().toString(36)}${(seq++).toString(36)}`;
24
- }
25
-
26
- /** Anthropic 协议错误体形状(非流式 JSON 与流式 in-band error 帧共用)。 */
27
- export function anthropicError(type, message) {
28
- return { type: "error", error: { type: String(type || "api_error"), message: String(message || "error").slice(0, 500) } };
29
- }
30
-
31
- // ---------- 请求:Anthropic → chat ----------
32
-
33
- // system 允许字符串或 text 块数组;块间空行连接(缓存语义不保留,上游是 OpenAI)
34
- function systemTextOf(system) {
35
- if (typeof system === "string") return system;
36
- if (!Array.isArray(system)) return "";
37
- return system.map((b) => (b && typeof b.text === "string" ? b.text : "")).filter(Boolean).join("\n\n");
38
- }
39
-
40
- function imagePartOf(block) {
41
- const src = block?.source || {};
42
- if (src.type === "base64" && src.data) {
43
- return { type: "image_url", image_url: { url: `data:${src.media_type || "image/png"};base64,${src.data}` } };
44
- }
45
- if (src.type === "url" && src.url) return { type: "image_url", image_url: { url: String(src.url) } };
46
- return null;
47
- }
48
-
49
- export function toolResultTextOf(content) {
50
- if (typeof content === "string") return content;
51
- if (Array.isArray(content)) {
52
- return content
53
- .map((p) => (typeof p === "string" ? p : p && typeof p.text === "string" ? p.text : ""))
54
- .filter(Boolean)
55
- .join("\n");
56
- }
57
- return content == null ? "" : String(content);
58
- }
59
-
60
- /** Anthropic Messages 请求体 → OpenAI chat 请求体(直接喂既有 ChatPipeline)。 */
61
- export function messagesToChatBody(req = {}) {
62
- const model = String(req.model || "").trim();
63
- if (!model) throw new Error("messages: 缺少 model");
64
- const messages = [];
65
- const sys = systemTextOf(req.system);
66
- if (sys) messages.push({ role: "system", content: sys });
67
-
68
- // 重复 tool_use_id 保首个、丢后续:客户端重试/重放历史会把同一工具结果存两份,
69
- // 部分上游按 call_id 查重直接 400(同 responses/translate.js:57-59 的实测教训)。
70
- const seenToolResult = new Set();
71
-
72
- for (const m of Array.isArray(req.messages) ? req.messages : []) {
73
- if (!m || typeof m !== "object") continue;
74
- // role=system/developer 必须保留:Claude Code ≥2.1.286 的 mid-conversation system beta 就往 messages[] 里塞系统指令,
75
- const role = m.role === "assistant" ? "assistant" : m.role === "system" || m.role === "developer" ? "system" : "user";
76
- const blocks = typeof m.content === "string" ? [{ type: "text", text: m.content }] : Array.isArray(m.content) ? m.content : [];
77
- let text = "";
78
- const imgs = [];
79
- const toolCalls = [];
80
- for (const b of blocks) {
81
- if (!b || typeof b !== "object") continue;
82
- if (b.type === "text") text += typeof b.text === "string" ? b.text : "";
83
- else if (b.type === "image") { const p = imagePartOf(b); if (p) imgs.push(p); }
84
- else if (b.type === "tool_use") {
85
- let args = "{}";
86
- try { args = JSON.stringify(b.input ?? {}); } catch { args = "{}"; }
87
- toolCalls.push({ id: String(b.id || newToolUseId()), type: "function", function: { name: String(b.name || ""), arguments: args } });
88
- } else if (b.type === "tool_result") {
89
- // tool 消息必须先于同轮 user 文本进数组:OpenAI 侧要求 role:tool 紧跟其 assistant
90
- const key = String(b.tool_use_id || "");
91
- if (key) { if (seenToolResult.has(key)) continue; seenToolResult.add(key); }
92
- const body = toolResultTextOf(b.content);
93
- messages.push({ role: "tool", tool_call_id: key, content: b.is_error ? `[tool_error] ${body}` : body });
94
- }
95
- // thinking / redacted_thinking 忽略:我们无法签发 signature,回传必被上游拒(ADR-0047)
96
- }
97
- if (!text && !imgs.length && !toolCalls.length) continue;
98
- const msg = { role, content: imgs.length ? [...(text ? [{ type: "text", text }] : []), ...imgs] : text };
99
- if (toolCalls.length) msg.tool_calls = toolCalls;
100
- messages.push(msg);
101
- }
102
- // 空会话直接 400:绝不伪造一句 "hi" 打上游(那会把假提问写进用户上下文并白烧一发额度)
103
- if (!messages.some((x) => x.role !== "system")) throw new Error("messages: 缺少用户消息");
104
-
105
- const body = { model, messages, stream: Boolean(req.stream) };
106
- // 流式必须让上游把 usage 放进收尾帧,否则 message_delta.usage 恒 0(OpenAI 规范字段)。
107
- // 个别上游不认 stream_options 会 400 → `MSLXDFF_ANTHROPIC_STREAM_USAGE=0` 关掉。
108
- if (body.stream && process.env.MSLXDFF_ANTHROPIC_STREAM_USAGE !== "0") body.stream_options = { include_usage: true };
109
- // Anthropic 的 server tool(web_search_*/web_fetch_*/…)没有 input_schema,折成假 function 会
110
- // 让上游收到「空参数空描述」的工具(严格端点直接 422),本网关不实现的发前一律剥除(ADR-0049)。
111
- const fnTools = (Array.isArray(req.tools) ? req.tools : []).filter((t) => !isServerTool(t));
112
- if (fnTools.length) {
113
- body.tools = fnTools.map((t) => ({
114
- type: "function",
115
- function: { name: String(t?.name || ""), description: String(t?.description || ""), parameters: t?.input_schema || {} },
116
- }));
117
- }
118
- const keptNames = new Set(fnTools.map((t) => String(t?.name || "")));
119
- const tc = req.tool_choice;
120
- if (tc === "auto" || (tc && typeof tc === "object" && tc.type === "auto")) body.tool_choice = "auto";
121
- else if (tc && typeof tc === "object" && tc.type === "any") body.tool_choice = "required";
122
- // 点名一个已被剥掉的工具时降为 auto:上游会按不存在的函数强制点名,那样一定拿不到有用回答
123
- 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";
124
- else if (tc && typeof tc === "object" && tc.type === "none") body.tool_choice = "none";
125
- if (req.max_tokens != null && Number.isFinite(Number(req.max_tokens))) body.max_tokens = Number(req.max_tokens);
126
- if (req.temperature != null) body.temperature = req.temperature;
127
- if (req.top_p != null) body.top_p = req.top_p;
128
- if (Array.isArray(req.stop_sequences) && req.stop_sequences.length) body.stop = req.stop_sequences;
129
- // 思考档位原样透传(不解释语义,语义归一交给各 provider / src/thinking-effort.js):
130
- // 客户端显式声明必须一路带到上游,旧版在此丢掉 → 下游被迫补默认值(workbuddy 恒 high)。
131
- if (req.thinking != null) body.thinking = req.thinking;
132
- // 档位载体只收「非空 trimmed 字符串」:空串声明不算声明,不该发出去顶掉下游默认值。
133
- for (const k of ["effort", "reasoning_effort", "reasoningEffort"]) {
134
- const v = typeof req[k] === "string" ? req[k].trim() : "";
135
- if (v) body[k] = v;
136
- }
137
- if (req.extra_body != null) body.extra_body = req.extra_body;
138
- // output_config.effort 是 Claude Code 的另一档位载体(beta),原始对象上游不认故不外发,
139
- // 但档位本身不能丢 → 归一到 reasoning_effort(客户端已显式声明时以声明为准)。
140
- const ocEffort = req.output_config && typeof req.output_config === "object" ? req.output_config.effort : undefined;
141
- if (ocEffort != null && body.reasoning_effort == null && body.reasoningEffort == null) {
142
- body.reasoning_effort = ocEffort;
143
- }
144
- return body;
145
- }
21
+ // re-export:拆档不改对外接口,调用方与测试仍从本文件取这些符号
22
+ export { messagesToChatBody, toolResultTextOf } from "./request-body.js";
23
+ export { newMessageId, newToolUseId, anthropicError } from "./shared.js";
146
24
 
147
25
  // ---------- usage / stop_reason ----------
148
26
 
@@ -151,20 +29,27 @@ export function messagesToChatBody(req = {}) {
151
29
  * Anthropic 的 `input_tokens` **不含**缓存读命中的部分(两者分开计费),而上游
152
30
  * `prompt_tokens` 是含缓存的总输入 —— 故命中数要从 input 里扣掉,否则客户端把同一段
153
31
  * 前缀算两遍(读数虚高)。
32
+ * `fallback()` 只在**上游整条未报**(缺字段,或 prompt/output/cache_read/cache_creation 四项全为 0)时才用:
33
+ * 真实模型对非空 prompt 不可能报 0,照抄 0 会让客户端的上下文计度停在原地、永不压实(ADR-0055 决策 3)。
154
34
  */
155
- export function toAnthropicUsage(u = {}) {
35
+ export function toAnthropicUsage(u = {}, fallback = null) {
156
36
  const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
157
37
  const src = u && typeof u === "object" ? u : {};
158
38
  const prompt = num(src.prompt_tokens ?? src.input_tokens);
159
39
  const output = num(src.completion_tokens ?? src.output_tokens);
160
40
  const cacheRead = num(src.cache_read_input_tokens ?? src.prompt_tokens_details?.cached_tokens ?? src.input_tokens_details?.cached_tokens);
161
41
  const cacheWrite = num(src.cache_creation_input_tokens ?? src.prompt_tokens_details?.cache_creation_input_tokens);
162
- return {
42
+ const usage = {
163
43
  input_tokens: Math.max(0, prompt - cacheRead - cacheWrite),
164
44
  output_tokens: output,
165
45
  cache_read_input_tokens: cacheRead,
166
46
  cache_creation_input_tokens: cacheWrite,
167
47
  };
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
+ if (fallback && !(prompt > 0 || output > 0 || cacheRead > 0 || cacheWrite > 0)) return { ...usage, ...(fallback() || {}) };
52
+ return usage;
168
53
  }
169
54
 
170
55
  function toStopReason(finish, hasToolUse) {
@@ -200,7 +85,7 @@ export function textFromChatContent(c) {
200
85
  return t;
201
86
  }
202
87
 
203
- export function chatJsonToAnthropic(chatJson = {}, model = "") {
88
+ export function chatJsonToAnthropic(chatJson = {}, model = "", opts = {}) {
204
89
  const choice = chatJson.choices?.[0] || {};
205
90
  const message = choice.message || {};
206
91
  const content = [];
@@ -217,10 +102,21 @@ export function chatJsonToAnthropic(chatJson = {}, model = "") {
217
102
  content: content.length ? content : [{ type: "text", text: "" }],
218
103
  stop_reason: toStopReason(choice.finish_reason, content.some((b) => b.type === "tool_use")),
219
104
  stop_sequence: null,
220
- usage: toAnthropicUsage(chatJson.usage),
105
+ usage: toAnthropicUsage(chatJson.usage, () => fallbackUsage(opts, content)),
221
106
  };
222
107
  }
223
108
 
109
+ // 上游整条未报 usage 时的兜底(ADR-0055 决策 3):请求侧用注入的 estInput,生成侧按已产出的块估。
110
+ function fallbackUsage(opts = {}, content = []) {
111
+ const counter = createTokenCounter();
112
+ for (const b of content) {
113
+ if (b?.type === "text") counter.add(b.text || "");
114
+ else if (b?.type === "tool_use") counter.add(JSON.stringify(b.input ?? {})); // 只计参数:与流式 pushTool 同口径(name 从不进 gen)
115
+ }
116
+ const n = typeof opts.estInput === "function" ? Number(opts.estInput()) : 0;
117
+ return { input_tokens: Number.isFinite(n) && n > 0 ? Math.floor(n) : 0, output_tokens: counter.tokens() };
118
+ }
119
+
224
120
  // ---------- 流式响应:chat SSE chunk → Anthropic 具名事件 ----------
225
121
 
226
122
  // thinking 块必须在 content_block_stop **之前**交一帧 signature_delta,缺它 Claude Code 把整块静默丢弃
@@ -240,6 +136,18 @@ export function createAnthropicChunkTranslator(model = "", opts = {}) {
240
136
  const created = Math.floor(Date.now() / 1000);
241
137
  const ev = (type, extra = {}) => ({ type, ...extra });
242
138
 
139
+ // 请求侧 token 估算(ADR-0055 决策 3/5):路由注入 `opts.estInput()`,memoize —— `message_start` 必须在
140
+ // 首块前就带 input_tokens,全文只能扫一遍;上游后报实测 usage 时估算只留在首帧,不进 message_delta。
141
+ let estCached = null;
142
+ const estInput = () => {
143
+ if (estCached == null) {
144
+ const n = typeof opts.estInput === "function" ? Number(opts.estInput()) : 0;
145
+ estCached = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
146
+ }
147
+ return estCached;
148
+ };
149
+ const gen = createTokenCounter(); // 生成侧累计(text + thinking + tool 参数增量)
150
+
243
151
  let buf = "";
244
152
  let nextIndex = 0;
245
153
  let open = null; // 当前未封口块:{ index, kind: "text"|"thinking"|"tool_use", text? }
@@ -266,6 +174,7 @@ export function createAnthropicChunkTranslator(model = "", opts = {}) {
266
174
  const out = [];
267
175
  if (!open || open.kind !== "text") openBlock("text", { type: "text", text: "" }, out);
268
176
  open.text += delta;
177
+ gen.add(delta); // 生成侧只累计字数、末帧一次套公式:逐帧套会把参照实现的 +1 下限乘上帧数
269
178
  out.push(ev("content_block_delta", { index: open.index, delta: { type: "text_delta", text: delta } }));
270
179
  return out;
271
180
  }
@@ -273,6 +182,7 @@ export function createAnthropicChunkTranslator(model = "", opts = {}) {
273
182
  const out = [];
274
183
  if (!open || open.kind !== "thinking") openBlock("thinking", { type: "thinking", thinking: "", signature: "" }, out);
275
184
  open.text += delta;
185
+ gen.add(delta); // thinking 也按 output 计费(ADR-0055 grill Q6)
276
186
  out.push(ev("content_block_delta", { index: open.index, delta: { type: "thinking_delta", thinking: delta } }));
277
187
  return out;
278
188
  }
@@ -293,15 +203,15 @@ export function createAnthropicChunkTranslator(model = "", opts = {}) {
293
203
  if (tc?.id) t.id = String(tc.id);
294
204
  if (tc?.function?.name) t.name += String(tc.function.name);
295
205
  const frag = tc?.function?.arguments ? String(tc.function.arguments) : "";
296
- if (frag) { dbg.toolDeltas++; t.args += frag; }
206
+ if (frag) { dbg.toolDeltas++; t.args += frag; gen.add(frag); }
297
207
  if (!t.announced && t.id && t.name) announceTool(t, out);
298
208
  else if (t.announced && frag && open && open.index === t.index) out.push(ev("content_block_delta", { index: t.index, delta: { type: "input_json_delta", partial_json: frag } }));
299
- else if (t.announced && frag) dbg.lateToolDeltas++; // 块已封口后才来的参数增量:发出即违规帧,丢弃并留读数
209
+ else if (t.announced && frag) dbg.lateToolDeltas++; // 块已封口后才来的参数增量:发出即违规帧,丢弃但上面已计入 gen(output_tokens 取计费口径:模型确实生成了这些字节)
300
210
  return out;
301
211
  }
302
212
 
303
213
  function begin() {
304
- return [ev("message_start", { message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } } })];
214
+ return [ev("message_start", { message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: estInput(), output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } } })];
305
215
  }
306
216
 
307
217
  function push(data) {
@@ -329,6 +239,7 @@ export function createAnthropicChunkTranslator(model = "", opts = {}) {
329
239
  if (rc) {
330
240
  dbg.thinkingChars += rc.length;
331
241
  if (emitThinking) out.push(...pushThinking(rc)); // 只发 thinking_delta,无 signature 可发
242
+ else gen.add(rc); // `MSLXDFF_ANTHROPIC_THINKING=0` 时思考字节仍是真实生成,兜底 output 照计(在此加、不在 pushThinking 内加,避免双计)
332
243
  }
333
244
  }
334
245
  return out;
@@ -341,7 +252,7 @@ export function createAnthropicChunkTranslator(model = "", opts = {}) {
341
252
  if (!t.announced && (t.id || t.name || t.args)) announceTool(t, out); // 只有 arguments 无 name 的退化收尾
342
253
  }
343
254
  closeOpen(out);
344
- out.push(ev("message_delta", { delta: { stop_reason: toStopReason(fr, sawToolUse), stop_sequence: null }, usage: toAnthropicUsage(usage || lastUsage) }));
255
+ out.push(ev("message_delta", { delta: { stop_reason: toStopReason(fr, sawToolUse), stop_sequence: null }, usage: toAnthropicUsage(usage || lastUsage, () => ({ input_tokens: estInput(), output_tokens: gen.tokens() })) }));
345
256
  out.push(ev("message_stop"));
346
257
  return out;
347
258
  }
@@ -22,6 +22,7 @@ import {
22
22
  anthropicError,
23
23
  } from "../anthropic/translate.js";
24
24
  import { estimateTokens } from "../anthropic/count-tokens.js";
25
+ import { sseToChatJson } from "../anthropic/sse-aggregate.js";
25
26
  import {
26
27
  detectSearchRequest,
27
28
  runWebSearch,
@@ -100,47 +101,6 @@ function tryAggregate(text) {
100
101
  return null;
101
102
  }
102
103
  }
103
- // 客户端要非流式而上游只会流式(workbuddy 在 provider 层写死 stream:true,SSE 硬吃回来):
104
- // collector 收到的 text 是 SSE 帧序列而非 JSON,JSON.parse 必炸 → 此前恒 502。逐行吃 data: 帧
105
- // 聚成 chat.completion JSON(与 qoder/aggregate.js、zcode 非流式聚合同构),出口复用 chatJsonToAnthropic;
106
- // 一帧都没有或夹带 in-band 错误帧时返回 null,落回原有 502 + errorText 路径(错误绝不聚合成正文)。
107
- function sseToChatJson(text, model) {
108
- let content = "";
109
- let finish = "";
110
- let usage = null;
111
- let sawChunk = false;
112
- const toolSlots = new Map();
113
- for (const raw of String(text || "").split("\n")) {
114
- const line = raw.trim();
115
- if (!line.startsWith("data:")) continue; // 注释帧/keepalive/事件名行全跳过
116
- const payload = line.slice(5).trim();
117
- if (!payload || payload === "[DONE]") continue;
118
- let chunk;
119
- try { chunk = JSON.parse(payload); } catch { continue; }
120
- if (chunk?.error) return null;
121
- sawChunk = true;
122
- const choice = chunk.choices?.[0] || {};
123
- const delta = choice.delta || {};
124
- if (choice.finish_reason) finish = choice.finish_reason;
125
- if (chunk.usage) usage = chunk.usage;
126
- if (typeof delta.content === "string" && delta.content) content += delta.content;
127
- for (const tc of delta.tool_calls || []) {
128
- const slot = Number(tc?.index ?? 0);
129
- let t = toolSlots.get(slot);
130
- if (!t) { t = { id: "", name: "", args: "" }; toolSlots.set(slot, t); }
131
- if (tc?.id) t.id = String(tc.id);
132
- if (tc?.function?.name) t.name += String(tc.function.name);
133
- if (tc?.function?.arguments) t.args += String(tc.function.arguments);
134
- }
135
- }
136
- if (!sawChunk) return null;
137
- const toolCalls = [...toolSlots.values()].map((t, i) => ({ id: t.id || `call_${i}`, type: "function", function: { name: t.name, arguments: t.args || "{}" } }));
138
- const message = { role: "assistant", content: content || (toolCalls.length ? null : "") };
139
- if (toolCalls.length) message.tool_calls = toolCalls;
140
- return { id: "", object: "chat.completion", created: Math.floor(Date.now() / 1000), model,
141
- choices: [{ index: 0, message, finish_reason: finish || (toolCalls.length ? "tool_calls" : "stop") }],
142
- usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } };
143
- }
144
104
 
145
105
 
146
106
  /**
@@ -348,10 +308,14 @@ export async function messagesHandler(ctx) {
348
308
  }));
349
309
  const pipeline = createChatPipeline(ctx);
350
310
  const fakeReq = withBody(req, chatBody);
311
+ // ADR-0055:请求侧 token 估算 thunk(路由层也 memoize,与决策 4「每请求至多算一次」对齐;
312
+ // translator 内部还有一层 memoize,双保险)。
313
+ let estCache;
314
+ const estInput = () => (estCache ??= estimateTokens(body).input_tokens);
351
315
  const forwardClose = (shim) => { try { req.on?.("close", () => shim.emit("close")); } catch { /* ignore */ } };
352
316
  try {
353
317
  if (chatBody.stream) {
354
- const translator = createAnthropicChunkTranslator(chatBody.model, { thinking: emitThinking() });
318
+ const translator = createAnthropicChunkTranslator(chatBody.model, { thinking: emitThinking(), estInput });
355
319
  const live = createAnthropicForwarder(res, translator);
356
320
  forwardClose(live);
357
321
  await pipeline.execute({ req: fakeReq, res: live });
@@ -372,11 +336,11 @@ export async function messagesHandler(ctx) {
372
336
  }
373
337
  // 上游偶发非 chat 形状(无 choices):包成单 text 块,绝不 500(照 responses-route.js 先例)
374
338
  if (!Array.isArray(chatJson.choices)) {
375
- const r = chatJsonToAnthropic({ choices: [{ message: { content: String(text).slice(0, 8000) } }] }, chatBody.model);
339
+ const r = chatJsonToAnthropic({ choices: [{ message: { content: String(text).slice(0, 8000) } }] }, chatBody.model, { estInput });
376
340
  alog("done-nonchat", JSON.stringify({ ms: Date.now() - t0, bytes: text.length }));
377
341
  return json(res, 200, r);
378
342
  }
379
- const out = chatJsonToAnthropic(chatJson, chatBody.model);
343
+ const out = chatJsonToAnthropic(chatJson, chatBody.model, { estInput });
380
344
  alog("done-json", JSON.stringify({ ms: Date.now() - t0, status, blocks: out.content.length, stop: out.stop_reason, usage: out.usage }));
381
345
  return json(res, 200, out);
382
346
  }
@@ -0,0 +1,52 @@
1
+ // `/v1/messages` 面 token 估算的单一来源(ADR-0055):`count_tokens` 读数与响应 `usage` 兜底共用一个公式。
2
+ // 共用面:`src/anthropic/count-tokens.js`(请求侧逐段累加,走 `estimateTextTokens`)、
3
+ // `src/anthropic/translate.js`(上游未报 usage 时的兜底与流式生成侧累计,走 `createTokenCounter`)。
4
+ // 两处必须同源,否则 `count_tokens` 与响应 `usage` 会各说一套读数
5
+ // —— 同 ADR-0053 把 effort 词表收成单一来源的理由。
6
+ //
7
+ // 公式照抄参照实现 `wb_proxy.py:12841-12848`:`cjk + max(1, int(other / 3.6))`(空文本 0),
8
+ // CJK 区间 = \u4e00-\u9fff 主区 + \u3400-\u4dbf 扩展 A。自造系数=造第二个真相源(design.md 决策 2)。
9
+ // 实现不抄 `re.sub` 再数长度差:那要额外分配一份与原文等长的新串,本实现单遍扫描、无分配。
10
+ // 与 Python 的两处已知差异(不影响本仓自洽,但别写成「逐值相等」):① astral 字符(emoji)JS 按
11
+ // 2 个 UTF-16 单元计、Python 按 1 个码点计 —— emoji 密集文本可差到 2 倍以上;② falsy 非串(`0`/`false`)
12
+ // Python 直接返回 0、本实现按 `String()` 后非空计 ≥1。现所有调用方传的都是字符串,两条均不可达。
13
+
14
+ const CJK_HI_START = 0x4e00;
15
+ const CJK_HI_END = 0x9fff; // 主区,占实际中文文本的绝对多数
16
+ const CJK_EXT_A_START = 0x3400;
17
+ const CJK_EXT_A_END = 0x4dbf;
18
+ const OTHER_CHARS_PER_TOKEN = 3.6;
19
+
20
+ /**
21
+ * 跨多段累计的计数器。逐段各套一次公式会把参照实现的 `max(1, …)` 下限乘上段数
22
+ * (流式按 1~3 字一帧下发时,1000 个 ASCII 字会被估成 1000 tokens 而非 277),
23
+ * 所以累计的是字数、最后一次性套公式。
24
+ * @returns {{ add: (text: unknown) => void, tokens: () => number }}
25
+ */
26
+ export function createTokenCounter() {
27
+ let cjk = 0;
28
+ let other = 0;
29
+ return {
30
+ add(text) {
31
+ if (text == null) return;
32
+ const s = typeof text === "string" ? text : String(text);
33
+ for (let i = 0; i < s.length; i++) {
34
+ const c = s.charCodeAt(i);
35
+ if (c >= CJK_HI_START && c <= CJK_HI_END) cjk++;
36
+ else if (c >= CJK_EXT_A_START && c <= CJK_EXT_A_END) cjk++;
37
+ else other++;
38
+ }
39
+ },
40
+ tokens() {
41
+ if (cjk + other <= 0) return 0; // 空文本给 0,不吃 +1 下限
42
+ return cjk + Math.max(1, Math.floor(other / OTHER_CHARS_PER_TOKEN));
43
+ },
44
+ };
45
+ }
46
+
47
+ /** 单段文本的 token 估算(CJK 每字 1 token,其余每 3.6 字符 1 token,非空至少 1)。 */
48
+ export function estimateTextTokens(text) {
49
+ const counter = createTokenCounter();
50
+ counter.add(text);
51
+ return counter.tokens();
52
+ }