mslxdff 0.1.172 → 0.1.174
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/docs/cli_help_mini.md +3 -3
- package/package.json +1 -1
- package/src/anthropic/count-tokens.js +31 -16
- package/src/anthropic/request-body.js +126 -0
- package/src/anthropic/shared.js +18 -0
- package/src/anthropic/sse-aggregate.js +56 -0
- package/src/anthropic/translate.js +53 -142
- package/src/chat-pipeline/policy.js +10 -2
- package/src/claude-desktop/apply.js +42 -5
- package/src/claude-desktop/context1m.js +38 -0
- package/src/claude-desktop/slots.js +66 -0
- package/src/cli/commands/claude-desktop.js +27 -7
- package/src/cli/commands/sync.js +1 -1
- package/src/cli/help.js +1 -1
- package/src/opencode-identity.js +39 -19
- package/src/routes/messages-route.js +8 -44
- package/src/text-tokens.js +52 -0
package/docs/cli_help_mini.md
CHANGED
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
| DeepSeek 登录 | `-provider deepseek login --token <userToken>` 或 `login <email\|mobile> <password>` | DeepSeek 官网免费对话接入(ADR-0014):userToken 在 chat.deepseek.com F12→Local Storage;无参数打印图文引导;落盘后 `allowAny on` + `-restart`;模型 `deepseek/{chat,reasoner,chat-search,reasoner-search}`;单账号 1 路并发,多号轮换 |
|
|
60
60
|
| DeepSeek 探活 | `-provider deepseek health [--json]` | 逐账号体检(防禁言):检测禁言(自动冷却 5min)/限频前兆/凭据坏;网络失败不误伤;禁言解封后再探自动恢复 |
|
|
61
61
|
| 供应商改址 | `-provider <id> set-url <baseUrl>` | 改通用供应商地址 |
|
|
62
|
-
| 供应商改模型路径 | `-provider <id> set-models-path <path>` | 改 `models` 路径(如 `/v1/models`、`/
|
|
62
|
+
| 供应商改模型路径 | `-provider <id> set-models-path <path>` | 改 `models` 路径(如 `/v1/models`、`/v3/config`) |
|
|
63
63
|
| 供应商改对话路径 | `-provider <id> set-chat-path <path>` | 改 `chat` 路径(如 `/v1/chat/completions`、`/v2/chat/completions`) |
|
|
64
64
|
| 供应商清空 | `-provider <id> clear` | 清空该供应商 keys |
|
|
65
65
|
| 供应商共享 | key 随转发自动借出(ADR-0019,无开关无白名单) | 借道时自动附带;opencode/workbuddy/cline/codearts/traework 硬排除 |
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
| 千问办公接入 | `-provider qwenwork login` | 千问办公设备授权(PKCE+poll,`gateway.qwenwork.cn`):打印兑换链接→浏览器确认→落盘 `auths/qwenwork-<uid>.json`+state;**默认 `allowAnyModels=false` + 只种 `flash/pro/qwen3.8-max-preview`**(额度是账号积分池,防 auto 烧分);登录尾部显示套餐名+积分剩余;`qwenwork/<modelId>` 前缀路由,恒 local-only 不借出 |
|
|
76
76
|
| 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 不借出 |
|
|
77
77
|
| zcode 额度 | `-provider zcode quota [--json]` | 查 ZCode 套餐名/状态/有效期 + 各模型余量(`billing/balance` 分组表格);空套餐给领取指引、401 给重登指引 |
|
|
78
|
-
| raccoon 接入 | `-provider raccoon login` | Raccoon(商汤小浣熊)扫码登录:生成 `qrcode_code`→终端二维码 + `login/mp?code=` 链接→**用微信「扫一扫」扫**并确认(浏览器直接打开链接会 404
|
|
78
|
+
| 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 不借出 |
|
|
79
79
|
| 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` 改点) |
|
|
80
80
|
| 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` 可配) |
|
|
81
81
|
| WorkBuddy 猫猫旅行 | `-workbuddy travel [--json]` / `-wb travel` | 无猫自动同意协议+领养(+300,门槛未达自动补一次对话解锁);到站领奖 / 空闲派出(location 4)/ 旅行中跳过 |
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
| 同步 opencode | `-setto opencode [modelId\|--all]` | 把本地网关注册为 opencode 供应商(`provider.mslxdff`,`http://127.0.0.1:<port>/v1`,直写裸名如 `deepseek-v4-flash-free`,`/`→`-` 如 `bai/deepseek`→`bai-deepseek` 到 8989 自动还原,`--all` 批量同步全部 picks;picks 非空时摘除失效模型;自动附模型能力:models.dev 目录 + workbuddy 走上游原生字段,opencode 原生识别;写 variants 档位供 ctrl+t 切换(effort 型按档位写,toggle 型不写,TUI 改配置后需重启)) |
|
|
96
96
|
| 同步 chatgpt | `-setto chatgpt [modelId]` | 写 Codex 三端共用 `~/.codex/config.toml`(`model_providers.mslxdff` → `127.0.0.1/v1/responses`,鉴权走 `mslxdff -showtoken` 不落盘),换模型重跑 setto 或 `codex exec -m <id>` 单次覆盖,`codex exec "hi"` 验证;排障 `MSLXDFF_RESPONSES_DEBUG=1` 看 daemon.log `[responses]` |
|
|
97
97
|
| 同步 Claude Code | `-setto claude [modelId] [--behaves-as <id>]` | 写 Claude Code 用户设置 `~/.claude/settings.json`(`CLAUDE_CONFIG_DIR` 可改址):`env.ANTHROPIC_BASE_URL=http://127.0.0.1:<port>`(**不带 /v1**)+ 明文 `ANTHROPIC_AUTH_TOKEN` + `CLAUDE_CODE_ATTRIBUTION_HEADER=0`/`CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1`/`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` + `model` + `modelPicker.options`;**不带 modelId = 写入 `modelPicks` 勾选集全集**(顶层 `model` 也只在勾选集内取,picks 空则报错退出,不兜底到可能已失效的 `preferredModel`;`--all` 与之同义);`behavesAs` 只给客户端不认识的 id(缺省 `claude-sonnet-5`,`claude-*` 行不写;实测无映射的未知 id 会被本机 Claude Code 直接拒跑)(ADR-0047 `/v1/messages` 外壳);只动这些键、其余键与键序保留,首写前一次性备份 `settings.pre-mslxdff.json`(不覆盖),JSON 解析失败拒写、写不进即报错不谎报;排障 `MSLXDFF_ANTHROPIC_DEBUG=1` 看 daemon.log `[messages]` |
|
|
98
|
-
| 接 Claude Desktop(3P) | `-setto claude-desktop [modelId ...] [--max N] [--max-effort <lvl>] [--no-alias] [--check] [--official]`、`-claude-desktop status\\|slots\\|check` | 桌面端**不读** `ANTHROPIC_BASE_URL`/`settings.json`,只读自家 3P profile 且只认「三档角色 ID」→ 一次写两处:① `%LOCALAPPDATA%\Claude-3p\configLibrary\<uuid>.json` + `_meta.json`(uuid 由端口派生、只 upsert 自家条目、他人 entry 不动、损坏拒写、幂等按**原文 vs 原文**、明文 token `0600`、baseUrl **不带 /v1**、`authScheme=bearer`(本仓 `authorized()` 只认 Bearer)、`inferenceModels[{name:角色槽,labelOverride:真模型名}]`)② `model-aliases.json` 写「角色槽 → 真模型」(入站 `src/chat-pipeline/policy.js` 生效、`getModelAlias` 未命中即重读 → **不重启 daemon**);槽位上限 12(取 App 签名 `model-catalog`,前三档 sonnet/opus/haiku,Haiku 供子 agent),缺 alias 时 App 探活 30–90 s 后 502;`--official` 摘登记不删配置;不写 `preferredModel`、不碰 `claude_desktop_config.json` 与注册表策略;⚠ **App 不热重载**,写完须完全退出重开并选 3P 入口(免 claude.ai 登录)(ADR-0048) |
|
|
98
|
+
| 接 Claude Desktop(3P) | `-setto claude-desktop [modelId ...] [--max N] [--max-effort <lvl>] [--1m <off|auto|all|列表>] [--no-alias] [--check] [--official]`、`-claude-desktop status\\|slots\\|check` | 桌面端**不读** `ANTHROPIC_BASE_URL`/`settings.json`,只读自家 3P profile 且只认「三档角色 ID」→ 一次写两处:① `%LOCALAPPDATA%\Claude-3p\configLibrary\<uuid>.json` + `_meta.json`(uuid 由端口派生、只 upsert 自家条目、他人 entry 不动、损坏拒写、幂等按**原文 vs 原文**、明文 token `0600`、baseUrl **不带 /v1**、`authScheme=bearer`(本仓 `authorized()` 只认 Bearer)、`inferenceModels[{name:角色槽,labelOverride:真模型名}]`)② `model-aliases.json` 写「角色槽 → 真模型」(入站 `src/chat-pipeline/policy.js` 生效、`getModelAlias` 未命中即重读 → **不重启 daemon**);槽位上限 12(取 App 签名 `model-catalog`,前三档 sonnet/opus/haiku,Haiku 供子 agent),缺 alias 时 App 探活 30–90 s 后 502;`--1m` 打标(ADR-0056,缺省 auto:证据=本机 `/v1/models` 已裁决窗口 ≥1M → 打官方成对键 `supports1m+prefer1m`=菜单单条默认 1M,不打标被 App 按 200K 封顶;网关不可达 ℹ 降级不阻塞写盘;`off`=历史三键、`all`=担保全打、列表=只打命中行);`--official` 摘登记不删配置;不写 `preferredModel`、不碰 `claude_desktop_config.json` 与注册表策略;⚠ **App 不热重载**,写完须完全退出重开并选 3P 入口(免 claude.ai 登录)(ADR-0048) |
|
|
99
99
|
| 建组 | `-creategroup <name>` / `-group create <name>` | 建组,本机为 leader |
|
|
100
100
|
| 加组 | `-addtogroup <host> <name> [--broadband]` | 加远端组,broadband 走中继(不占端口纯出站);**零参数/单参数进手机宽带向导**:问组长地址+组名,自动起服务,回报出口 IP |
|
|
101
101
|
| 组同步 | `-group sync` | 刷新全组成员 |
|
package/package.json
CHANGED
|
@@ -1,39 +1,54 @@
|
|
|
1
|
-
// /v1/messages/count_tokens
|
|
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
|
-
//
|
|
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
|
|
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")
|
|
17
|
-
else if (b.type === "tool_use")
|
|
18
|
-
else if (b.type === "tool_result")
|
|
19
|
-
else if (b.type === "thinking")
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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)
|
|
11
|
-
*
|
|
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 {
|
|
18
|
+
import { createTokenCounter } from "../text-tokens.js";
|
|
19
|
+
import { newMessageId, newToolUseId } from "./shared.js";
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
export
|
|
20
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
|
@@ -18,7 +18,15 @@ export function analyzePolicy({ headers = {}, body = {} } = {}) {
|
|
|
18
18
|
const rawModel = body.model || "";
|
|
19
19
|
|
|
20
20
|
let normalizedRequested = normalizeModel(lockModel || rawModel || "");
|
|
21
|
-
|
|
21
|
+
// [1m] 后缀回退(Claude Desktop 1M 条目 / Claude Code 1M 记法,ADR-0056):alias 直查未命中时
|
|
22
|
+
// 剥去尾部 [1m] 再查一次。仅用于本次查找——不回写、不注册 alias,未命中路径逐字不变(design D2)。
|
|
23
|
+
// 两个 alias 查找点(首轮 + mslxdff/ 前缀二段)共用同一回退,否则 `mslxdff/<槽位>[1m]` 不等价(评审 1 P1)。
|
|
24
|
+
const resolveAlias = (id) => {
|
|
25
|
+
const hit = getModelAlias(id);
|
|
26
|
+
if (hit) return hit;
|
|
27
|
+
return /\[1m\]$/i.test(id) ? getModelAlias(id.replace(/\[1m\]$/i, "")) : null;
|
|
28
|
+
};
|
|
29
|
+
let aliasResolved = resolveAlias(normalizedRequested);
|
|
22
30
|
if (aliasResolved) {
|
|
23
31
|
normalizedRequested = aliasResolved;
|
|
24
32
|
}
|
|
@@ -29,7 +37,7 @@ export function analyzePolicy({ headers = {}, body = {} } = {}) {
|
|
|
29
37
|
const rawPart = requested.slice("mslxdff/".length);
|
|
30
38
|
aliasInfo = `${requested} -> ${rawPart} (mslxdff provider stripped)`;
|
|
31
39
|
requested = rawPart;
|
|
32
|
-
const alias2 =
|
|
40
|
+
const alias2 = resolveAlias(requested);
|
|
33
41
|
if (alias2) {
|
|
34
42
|
aliasInfo = `${rawModel} -> ${alias2} (mslxdff + alias)`;
|
|
35
43
|
requested = alias2;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { normalizeModel } from "../reasoning.js";
|
|
2
2
|
import { registerModelAlias, persistModelAliases, loadModelAliases, modelAliasFile } from "../providers/model-id.js";
|
|
3
|
-
import { planSlots, aliasPairs, ROLE_SLOTS } from "./slots.js";
|
|
4
|
-
import {
|
|
3
|
+
import { planSlots, aliasPairs, ROLE_SLOTS, parseOneMMode, decideRows1m } from "./slots.js";
|
|
4
|
+
import { oneMContexts } from "./context1m.js";
|
|
5
|
+
import { syncToClaudeDesktop, retireClaudeDesktopProfile, claudeDesktopConfigDir, readClaudeDesktopProfile } from "../sync-claude-desktop.js";
|
|
5
6
|
import { detectManagedPolicy, currentAliases, probeMessages } from "./doctor.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -10,11 +11,37 @@ import { detectManagedPolicy, currentAliases, probeMessages } from "./doctor.js"
|
|
|
10
11
|
* ① 写 Claude Desktop 的 3P profile(`inferenceModels` 用**角色槽 id**,`labelOverride` 标真模型名);
|
|
11
12
|
* ② 写网关自己的 alias(角色槽 → 真模型),让桌面端发来的槽位请求在入站就被翻译成能跑的真模型。
|
|
12
13
|
* 少了 ②,桌面端每次请求都会被当未知模型丢组员转发 → 30~90 s 后 502(App 里就表现为 Test connection 红点)。
|
|
14
|
+
* ③ `--1m` 上下文打标(ADR-0056,Claude Desktop 专用):auto 问本机 /v1/models 拿已裁决窗口,
|
|
15
|
+
* 证据缺位时**保留盘上既有标**(无证据≠负面证据)、**绝不阻塞写盘**;all/列表为用户担保;
|
|
16
|
+
* 打标行经 toInferenceModels 写成对官方键 `supports1m+prefer1m`。evidence 参数是测试接缝。
|
|
13
17
|
*/
|
|
14
|
-
export async function applyClaudeDesktop({ picks = [], preferred = "", port = 8989, token = "", max, maxEffort, noAlias = false, dir, probe = false, probeTimeoutMs = 90000 } = {}) {
|
|
18
|
+
export async function applyClaudeDesktop({ picks = [], preferred = "", port = 8989, token = "", max, maxEffort, oneM = "auto", evidence, noAlias = false, dir, probe = false, probeTimeoutMs = 90000 } = {}) {
|
|
15
19
|
const planned = planSlots({ picks, preferred, max });
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
let rows = planned.rows.map((r) => ({ ...r, model: normalizeModel(r.model) || r.model }));
|
|
21
|
+
|
|
22
|
+
const oneMMode = oneM && typeof oneM === "object" ? oneM : parseOneMMode(oneM);
|
|
23
|
+
if (oneMMode.mode === "invalid") throw new Error("--1m 取值非法:只接受 off | auto | all | <逗号分隔槽位/模型id>");
|
|
24
|
+
const desktopDir = dir || claudeDesktopConfigDir();
|
|
25
|
+
let ev = evidence;
|
|
26
|
+
let evidenceQueried = false;
|
|
27
|
+
if (oneMMode.mode === "auto" && ev === undefined) {
|
|
28
|
+
evidenceQueried = true;
|
|
29
|
+
ev = await oneMContexts({ port, token });
|
|
30
|
+
}
|
|
31
|
+
// auto 的保留集:证据缺位(整体失败或该行不在目录)时沿用盘上既有 1M 标——网关停机的一轮
|
|
32
|
+
// 不得把上次写好的标静默抹掉(评审 1 P1-2;读盘失败自然降级为空表=与旧行为一致,不新增失败模式)
|
|
33
|
+
let preserve = null;
|
|
34
|
+
if (oneMMode.mode === "auto") {
|
|
35
|
+
preserve = new Map();
|
|
36
|
+
const existing = readClaudeDesktopProfile({ dir: desktopDir });
|
|
37
|
+
for (const m of Array.isArray(existing.profile?.inferenceModels) ? existing.profile.inferenceModels : []) {
|
|
38
|
+
if (m && typeof m === "object" && m.supports1m === true && m.name) preserve.set(String(m.name).toLowerCase(), true);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const decided = decideRows1m({ rows, oneM: oneMMode, contexts: ev && ev.ok ? ev.contexts : null, preserve });
|
|
42
|
+
rows = decided.rows;
|
|
43
|
+
|
|
44
|
+
const written = syncToClaudeDesktop({ port, token, rows, maxEffort, dir: desktopDir });
|
|
18
45
|
|
|
19
46
|
const out = {
|
|
20
47
|
dir: written.dir,
|
|
@@ -35,6 +62,16 @@ export async function applyClaudeDesktop({ picks = [], preferred = "", port = 89
|
|
|
35
62
|
aliasSkipped: noAlias,
|
|
36
63
|
managed: detectManagedPolicy(),
|
|
37
64
|
probes: [],
|
|
65
|
+
oneM: {
|
|
66
|
+
mode: oneMMode.mode,
|
|
67
|
+
list: oneMMode.list || [],
|
|
68
|
+
evidenceQueried,
|
|
69
|
+
evidenceOk: oneMMode.mode === "auto" ? Boolean(ev && ev.ok) : null,
|
|
70
|
+
evidenceReason: oneMMode.mode === "auto" && ev && !ev.ok ? String(ev.reason || "") : "",
|
|
71
|
+
markedCount: rows.filter((r) => r.supports1m).length,
|
|
72
|
+
preserved: decided.preserved,
|
|
73
|
+
unmatched: decided.unmatched,
|
|
74
|
+
},
|
|
38
75
|
};
|
|
39
76
|
|
|
40
77
|
if (!noAlias) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// -setto claude-desktop 的 1M 证据 Adapter(design D1/D3、ADR-0056):一次 GET 本机网关 /v1/models。
|
|
2
|
+
// 真相源唯一 = ADR-0054 四级解析后的已裁决值(双层同源同值;顶层 context_length 缺/非正才回落
|
|
3
|
+
// capabilities.context 作保险,CLI 进程不复刻聚合、不另起第二证据链)。
|
|
4
|
+
// 失败一律 {ok:false,reason} 不抛:写盘职责与网关可达性解耦(spec《证据获取失败不阻塞写盘》)。
|
|
5
|
+
import { compatFetch, timeoutSignal } from "../compat.js";
|
|
6
|
+
|
|
7
|
+
function positiveInt(v) {
|
|
8
|
+
const n = Number(v);
|
|
9
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{port:number|string, token?:string, fetchImpl?:Function, timeoutMs?:number}} p
|
|
14
|
+
* @returns {Promise<{ok:true, contexts:Map<string,number>} | {ok:false, reason:string}>}
|
|
15
|
+
*/
|
|
16
|
+
export async function oneMContexts({ port, token = "", fetchImpl = compatFetch, timeoutMs = 3000 } = {}) {
|
|
17
|
+
const url = `http://127.0.0.1:${Number(port) || 8989}/v1/models`;
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetchImpl(url, {
|
|
20
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
|
21
|
+
signal: timeoutSignal(timeoutMs),
|
|
22
|
+
});
|
|
23
|
+
if (!res?.ok) return { ok: false, reason: `HTTP ${res?.status ?? "?"}` };
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
const list = Array.isArray(data?.data) ? data.data : null;
|
|
26
|
+
if (!list) return { ok: false, reason: "响应形状异常:缺 data[]" };
|
|
27
|
+
const contexts = new Map();
|
|
28
|
+
for (const e of list) {
|
|
29
|
+
const id = String(e?.id || "").trim().toLowerCase();
|
|
30
|
+
if (!id) continue;
|
|
31
|
+
const n = positiveInt(e.context_length) ?? positiveInt(e?.capabilities?.context);
|
|
32
|
+
if (n) contexts.set(id, n);
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, contexts };
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return { ok: false, reason: String(err?.message || err) };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -76,11 +76,77 @@ export function aliasPairs(rows = []) {
|
|
|
76
76
|
* 桌面端 profile 的 `inferenceModels` 形状(官方 models 页:条目可为 id 字符串或对象,
|
|
77
77
|
* 对象里 `name` 是发给 provider 的真 id、`labelOverride` 是菜单显示名)。
|
|
78
78
|
* 显示名带上真模型名,用户在 App 下拉里能认出「这条其实是 qwenwork/pro」。
|
|
79
|
+
* `supports1m===true` 的行(--1m 裁决产物,decideRows1m 打标)追加官方成对键
|
|
80
|
+
* `supports1m+prefer1m`(= 菜单单条、新会话直接 1M 窗口,ADR-0056);
|
|
81
|
+
* 键序恒为 name→labelOverride→supports1m→prefer1m→maxEffort —— 幂等按原文比对,键序必须稳定。
|
|
79
82
|
*/
|
|
80
83
|
export function toInferenceModels(rows = [], { maxEffort } = {}) {
|
|
81
84
|
return (Array.isArray(rows) ? rows : []).map((r) => {
|
|
82
85
|
const e = { name: String(r.slot), labelOverride: String(r.label || r.model) };
|
|
86
|
+
if (r.supports1m === true) { e.supports1m = true; e.prefer1m = true; }
|
|
83
87
|
if (maxEffort) e.maxEffort = String(maxEffort);
|
|
84
88
|
return e;
|
|
85
89
|
});
|
|
86
90
|
}
|
|
91
|
+
|
|
92
|
+
/** `--1m` auto 模式阈值:/v1/models 已裁决窗口 ≥ 此值才打标(ADR-0056)。 */
|
|
93
|
+
export const ONE_M_THRESHOLD = 1_000_000;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `-setto claude-desktop --1m <值>` 的纯函数解析:
|
|
97
|
+
* `off|auto|all` 保留字(大小写不敏感)、空值缺省 auto;其余非空值一律按列表处理(单条即长度 1)——
|
|
98
|
+
* 列表值不与行集做存在性预校验,未命中经 decideRows1m 的 unmatched 以 ⚠ 呈现而非硬拒(spec《--1m 参数解析》)。
|
|
99
|
+
* 仅 trim 后为空/纯分隔符(`,,`)判 invalid,CLI 须在写盘前拒绝。
|
|
100
|
+
*/
|
|
101
|
+
export function parseOneMMode(raw) {
|
|
102
|
+
const s = String(raw ?? "").trim();
|
|
103
|
+
const low = s.toLowerCase();
|
|
104
|
+
if (s === "" || low === "auto") return { mode: "auto", list: [] };
|
|
105
|
+
if (low === "off") return { mode: "off", list: [] };
|
|
106
|
+
if (low === "all") return { mode: "all", list: [] };
|
|
107
|
+
const list = s.split(",").map((x) => x.trim().toLowerCase()).filter(Boolean);
|
|
108
|
+
if (!list.length) return { mode: "invalid" };
|
|
109
|
+
return { mode: "list", list };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* --1m 裁决(纯函数,不改入参):rows → { rows, unmatched, preserved }。
|
|
114
|
+
* `oneM` 为 parseOneMMode 产物;`contexts` 为证据 Adapter 的 Map<小写 id, 窗口数>;
|
|
115
|
+
* `preserve` 为盘上既有 1M 标 Map<小写槽位, true>。auto 语义:有证据且 ≥1M 才新增标、
|
|
116
|
+
* 有证据且 <1M 才清标;**无证据(整体失败或该行不在目录)≠ 负面证据 —— 保留既有标**,
|
|
117
|
+
* 网关停机的一轮不得静默抹掉上次写好的(评审 1 P1-2)。
|
|
118
|
+
*/
|
|
119
|
+
export function decideRows1m({ rows = [], oneM = { mode: "off", list: [] }, contexts = null, preserve = null } = {}) {
|
|
120
|
+
const mode = oneM && oneM.mode ? oneM.mode : "off";
|
|
121
|
+
const list = (Array.isArray(oneM && oneM.list) ? oneM.list : []).map((x) => String(x).toLowerCase());
|
|
122
|
+
const matched = new Set();
|
|
123
|
+
const preserved = [];
|
|
124
|
+
const out = (Array.isArray(rows) ? rows : []).map((r) => {
|
|
125
|
+
let mark = false;
|
|
126
|
+
if (mode === "all") {
|
|
127
|
+
mark = true;
|
|
128
|
+
} else if (mode === "list") {
|
|
129
|
+
const slot = String(r?.slot || "").toLowerCase();
|
|
130
|
+
const model = String(r?.model || "").toLowerCase();
|
|
131
|
+
const hitSlot = slot && list.includes(slot);
|
|
132
|
+
const hitModel = model && list.includes(model);
|
|
133
|
+
if (hitSlot || hitModel) {
|
|
134
|
+
mark = true;
|
|
135
|
+
if (hitSlot) matched.add(slot);
|
|
136
|
+
if (hitModel) matched.add(model);
|
|
137
|
+
}
|
|
138
|
+
} else if (mode === "auto") {
|
|
139
|
+
const key = String(r?.model || "").toLowerCase();
|
|
140
|
+
const n = contexts ? contexts.get(key) : undefined; // 短路写 `&&` 会把整体失败喂成 null → 误走「清标」分支(全量测试抓到)
|
|
141
|
+
if (n === undefined) {
|
|
142
|
+
mark = Boolean(preserve && preserve.get(String(r?.slot || "").toLowerCase()));
|
|
143
|
+
if (mark) preserved.push(String(r?.slot || "").toLowerCase());
|
|
144
|
+
} else {
|
|
145
|
+
mark = n >= ONE_M_THRESHOLD;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return mark ? { ...r, supports1m: true } : { ...r };
|
|
149
|
+
});
|
|
150
|
+
const unmatched = mode === "list" ? list.filter((v) => !matched.has(v)) : [];
|
|
151
|
+
return { rows: out, unmatched, preserved };
|
|
152
|
+
}
|
|
@@ -2,7 +2,7 @@ import { loadToken, loadModelPicks, loadPreferredModel } from "../../state.js";
|
|
|
2
2
|
import { getPreferredModel } from "../../auto.js";
|
|
3
3
|
import { effectivePort } from "../policy.js";
|
|
4
4
|
import { claudeDesktopConfigDir, readClaudeDesktopProfile } from "../../sync-claude-desktop.js";
|
|
5
|
-
import { ROLE_SLOTS, planSlots, slotRole } from "../../claude-desktop/slots.js";
|
|
5
|
+
import { ROLE_SLOTS, planSlots, slotRole, parseOneMMode } from "../../claude-desktop/slots.js";
|
|
6
6
|
import { detectManagedPolicy, readAppCatalogIds, appDataDirOf, currentAliases, probeMessages } from "../../claude-desktop/doctor.js";
|
|
7
7
|
import { normalizeModel } from "../../reasoning.js";
|
|
8
8
|
import { applyClaudeDesktop, retireClaudeDesktop } from "../../claude-desktop/apply.js";
|
|
@@ -57,7 +57,7 @@ export async function handleClaudeDesktop(args = [], flag = "-claude-desktop") {
|
|
|
57
57
|
const name = typeof m === "string" ? m : String(m?.name || "");
|
|
58
58
|
const label = typeof m === "string" ? "" : String(m?.labelOverride || "");
|
|
59
59
|
const target = aliases[name];
|
|
60
|
-
const mark = !isSlot(name) ? "⚠ 非角色槽(App 可能拒)" : target ? `→ ${target}` : "⚠ 无 alias(请求会被当未知模型丢组员转发→超时 502)";
|
|
60
|
+
const mark = (!isSlot(name) ? "⚠ 非角色槽(App 可能拒)" : target ? `→ ${target}` : "⚠ 无 alias(请求会被当未知模型丢组员转发→超时 502)") + (typeof m !== "string" && m?.supports1m === true ? " 1M" : "");
|
|
61
61
|
console.log(` ${name.padEnd(28)} ${slotRole(name).padEnd(7)} ${label ? `[${label}]`.padEnd(30) : ""}${mark}`);
|
|
62
62
|
}
|
|
63
63
|
const missing = models.map((m) => (typeof m === "string" ? m : String(m?.name || ""))).filter((n) => n && !aliases[n]);
|
|
@@ -151,7 +151,7 @@ function printUsage() {
|
|
|
151
151
|
mslxdff -claude-desktop status 看 profile / 托管策略 / 槽位 alias 是否齐(只读盘)
|
|
152
152
|
mslxdff -claude-desktop slots 看 App 签名模型目录认得哪些槽位 id(内置清单与实况比对)
|
|
153
153
|
mslxdff -claude-desktop check [id...] 逐槽向本机 /v1/messages 探活(等价 App 那个 Test connection,但给原因)
|
|
154
|
-
写配置: mslxdff -setto claude-desktop [modelId ...] [--max N] [--no-alias] [--official]`);
|
|
154
|
+
写配置: mslxdff -setto claude-desktop [modelId ...] [--max N] [--max-effort <lvl>] [--1m <off|auto|all|列表>] [--no-alias] [--check] [--official]`);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
@@ -166,11 +166,16 @@ export async function handleClaudeDesktopSetto(args = [], idx = -1) {
|
|
|
166
166
|
if (missing) bad(`${missing} 需要取值`);
|
|
167
167
|
const maxRaw = values["--max"];
|
|
168
168
|
const effortRaw = values["--max-effort"];
|
|
169
|
-
const unknown = flags.filter((f) => !["--max", "--max-effort", "--no-alias", "--check", "--official", "--port"].includes(f));
|
|
170
|
-
if (unknown.length) bad(`未知参数: ${unknown.join(" ")}(可用:--max N / --max-effort <lvl> / --no-alias / --check / --official)`);
|
|
169
|
+
const unknown = flags.filter((f) => !["--max", "--max-effort", "--1m", "--no-alias", "--check", "--official", "--port"].includes(f));
|
|
170
|
+
if (unknown.length) bad(`未知参数: ${unknown.join(" ")}(可用:--max N / --max-effort <lvl> / --1m off|auto|all|列表 / --no-alias / --check / --official)`);
|
|
171
171
|
if (maxRaw !== undefined && !(Number(maxRaw) > 0)) bad("--max 需要一个正整数(要写几条槽位),例如 --max 3");
|
|
172
172
|
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
173
173
|
if (effortRaw !== undefined && !EFFORTS.includes(String(effortRaw))) bad(`--max-effort 只能是 ${EFFORTS.join(" | ")}`);
|
|
174
|
+
// --1m 上下文标记(ADR-0056):不传=auto;显式传空值与「不传」区分、写盘前拒(评审 2 P1#1);invalid=纯分隔符值
|
|
175
|
+
const oneMRaw = values["--1m"];
|
|
176
|
+
if (oneMRaw !== undefined && !String(oneMRaw).trim()) bad("--1m 取值为空:不传该参数即缺省 auto;或显式传 off | auto | all | <逗号分隔槽位/模型id>");
|
|
177
|
+
const oneMParsed = parseOneMMode(oneMRaw);
|
|
178
|
+
if (oneMParsed.mode === "invalid") bad("--1m 取值非法:只接受 off | auto | all | <逗号分隔槽位/模型id>");
|
|
174
179
|
|
|
175
180
|
const port = effectivePort(args);
|
|
176
181
|
let token = "";
|
|
@@ -208,6 +213,7 @@ export async function handleClaudeDesktopSetto(args = [], idx = -1) {
|
|
|
208
213
|
token,
|
|
209
214
|
max: maxRaw !== undefined ? Number(maxRaw) : undefined,
|
|
210
215
|
maxEffort: effortRaw !== undefined ? String(effortRaw) : undefined,
|
|
216
|
+
oneM: oneMParsed,
|
|
211
217
|
noAlias: flags.includes("--no-alias"),
|
|
212
218
|
probe: flags.includes("--check"),
|
|
213
219
|
});
|
|
@@ -221,8 +227,9 @@ export async function handleClaudeDesktopSetto(args = [], idx = -1) {
|
|
|
221
227
|
console.log(` 登记: ${r.metaFile} appliedId=${r.configId}`);
|
|
222
228
|
console.log(` baseUrl: http://127.0.0.1:${port} (App 自己拼 /v1/messages) scheme=bearer`);
|
|
223
229
|
console.log(` inferenceModels: ${r.rows.length} 行(槽位 → 真模型)`);
|
|
224
|
-
for (const row of r.rows) console.log(` ${row.slot.padEnd(28)} ${slotRole(row.slot).padEnd(7)} → ${row.model}`);
|
|
230
|
+
for (const row of r.rows) console.log(` ${row.slot.padEnd(28)} ${slotRole(row.slot).padEnd(7)} → ${row.model}${row.supports1m ? " 1M" : ""}`);
|
|
225
231
|
if (r.overflow) console.log(` ⚠ 勾选集 ${r.rows.length + r.overflow} 条 > 槽位上限 ${r.slotCap},已截断 ${r.overflow} 条(${r.dropped.slice(0, 3).join(", ")}${r.dropped.length > 3 ? " …" : ""});想换哪条上槽用 -setto claude-desktop <modelId> ...`);
|
|
232
|
+
for (const line of oneMOutputLines(r.oneM, r.rows.length)) console.log(line);
|
|
226
233
|
if (!r.aliasSkipped) {
|
|
227
234
|
console.log(` alias: ${r.aliasAdded.length} 条新增/确认、${r.aliasOverwritten.length} 条覆盖 → ${r.aliasFile}(daemon 热读,不用重启)`);
|
|
228
235
|
for (const a of r.aliasOverwritten) console.log(` ⚠ 覆盖 ${a.slot}: ${a.from} → ${a.to}`);
|
|
@@ -250,7 +257,7 @@ export function splitArgs(rest = []) {
|
|
|
250
257
|
let missing = "";
|
|
251
258
|
for (let i = 0; i < rest.length; i++) {
|
|
252
259
|
const a = String(rest[i]);
|
|
253
|
-
if (a === "--max" || a === "--max-effort" || a === "--port") {
|
|
260
|
+
if (a === "--max" || a === "--max-effort" || a === "--port" || a === "--1m") {
|
|
254
261
|
flags.push(a);
|
|
255
262
|
const v = rest[++i];
|
|
256
263
|
if (v === undefined || String(v).startsWith("-")) { missing = a; break; }
|
|
@@ -262,3 +269,16 @@ export function splitArgs(rest = []) {
|
|
|
262
269
|
}
|
|
263
270
|
return { positional, flags, values, missing };
|
|
264
271
|
}
|
|
272
|
+
|
|
273
|
+
/** --1m 输出行(纯函数=可单测,spec《写入与 status 的 1M 呈现》+ 评审 1 P1-2 保留标披露)。 */
|
|
274
|
+
export function oneMOutputLines(oneM, rowsCount) {
|
|
275
|
+
if (!oneM || oneM.mode === "off") return [];
|
|
276
|
+
const kept = oneM.preserved && oneM.preserved.length ? `;已保留盘上 ${oneM.preserved.length} 条既有 1M 标(无证据≠负面证据)` : "";
|
|
277
|
+
if (oneM.mode === "auto" && oneM.evidenceOk) return [` 1M: ${oneM.markedCount}/${rowsCount} 条打标(证据:本机 /v1/models 已裁决窗口 ≥1M)${oneM.preserved && oneM.preserved.length ? `,其中 ${oneM.preserved.length} 条为保留` : ""}`];
|
|
278
|
+
if (oneM.mode === "auto") return [` ℹ 网关证据不可用${oneM.evidenceReason ? `(${oneM.evidenceReason})` : ""},本轮未新增打标 —— 先 mslxdff -d 起网关重跑,或 --1m all 用户担保打标${kept}`];
|
|
279
|
+
if (oneM.mode === "all") return [` 1M: 全 ${oneM.markedCount} 条打标(用户担保,未查证据)`];
|
|
280
|
+
const lines = [];
|
|
281
|
+
if (oneM.mode === "list" && oneM.markedCount) lines.push(` 1M: ${oneM.markedCount} 条打标(列表模式,不查窗口证据)`);
|
|
282
|
+
if (oneM.mode === "list" && oneM.unmatched && oneM.unmatched.length) lines.push(` ⚠ --1m 列表值未命中任何行: ${oneM.unmatched.join(", ")}`);
|
|
283
|
+
return lines;
|
|
284
|
+
}
|
package/src/cli/commands/sync.js
CHANGED
|
@@ -22,7 +22,7 @@ export async function handleSetto(args) {
|
|
|
22
22
|
const idx = args.findIndex((x) => x === "-setto" || x === "--setto");
|
|
23
23
|
const target = args[idx + 1];
|
|
24
24
|
if (!["workbuddy", "opencode", "chatgpt", "codex", "claude", "claude-desktop", "claudedesktop"].includes(target)) {
|
|
25
|
-
console.error("usage: mslxdff -setto workbuddy [modelId] | mslxdff -setto opencode [modelId|--all] | mslxdff -setto chatgpt [modelId] | mslxdff -setto claude [modelId] [--behaves-as <id>] | mslxdff -setto claude-desktop [modelId ...] [--max N] [--no-alias] [--check] [--official] (claude: 不带 modelId = 写入 modelPicks 勾选集全集,--all 与之同义 / claude-desktop: 写 Claude Desktop 3P profile + 角色槽 alias)");
|
|
25
|
+
console.error("usage: mslxdff -setto workbuddy [modelId] | mslxdff -setto opencode [modelId|--all] | mslxdff -setto chatgpt [modelId] | mslxdff -setto claude [modelId] [--behaves-as <id>] | mslxdff -setto claude-desktop [modelId ...] [--max N] [--max-effort <lvl>] [--1m <off|auto|all|列表>] [--no-alias] [--check] [--official] (claude: 不带 modelId = 写入 modelPicks 勾选集全集,--all 与之同义 / claude-desktop: 写 Claude Desktop 3P profile + 角色槽 alias,--1m 缺省 auto=有据才打 1M 标,详见 ADR-0056)");
|
|
26
26
|
process.exit(1);
|
|
27
27
|
}
|
|
28
28
|
if (target === "claude-desktop" || target === "claudedesktop") {
|
package/src/cli/help.js
CHANGED
|
@@ -25,7 +25,7 @@ Usage:
|
|
|
25
25
|
mslxdff -setto workbuddy [modelId] set default model and sync to WorkBuddy models.json (insert or update 127.0.0.1/v1 entry)
|
|
26
26
|
mslxdff -setto chatgpt [modelId] set default model and sync to Codex/ChatGPT ~/.codex/config.toml (model_providers.mslxdff → 127.0.0.1/v1/responses, auth via mslxdff -showtoken)
|
|
27
27
|
mslxdff -setto claude [modelId] [--behaves-as <id>] set default model and sync to Claude Code ~/.claude/settings.json (no modelId = write ALL modelPicks rows, --all is the same thing; env.ANTHROPIC_BASE_URL → 127.0.0.1 without /v1, /v1/messages shell; modelPicker rows carry behavesAs only for ids the client cannot recognize, default claude-sonnet-5; first write backs up settings.pre-mslxdff.json)
|
|
28
|
-
mslxdff -setto claude-desktop [modelId...] [--max N] [--max-effort <lvl>] [--no-alias] [--check] [--official] one-click Claude Desktop on 3P (ADR-0048): writes the app's configLibrary profile (role-slot model ids + labelOverride, baseUrl without /v1, bearer scheme) AND model-aliases.json (role slot → real pool model, hot-reloaded); --official retires only our registration; the app does NOT hot-reload → fully quit and reopen
|
|
28
|
+
mslxdff -setto claude-desktop [modelId...] [--max N] [--max-effort <lvl>] [--1m off|auto|all|ids] [--no-alias] [--check] [--official] one-click Claude Desktop on 3P (ADR-0048): writes the app's configLibrary profile (role-slot model ids + labelOverride, baseUrl without /v1, bearer scheme) AND model-aliases.json (role slot → real pool model, hot-reloaded); --1m (ADR-0056, default auto) tags entries with supports1m+prefer1m when the local /v1/models says the window is >=1M, so the app stops capping custom models at 200K; --official retires only our registration; the app does NOT hot-reload → fully quit and reopen
|
|
29
29
|
mslxdff -claude-desktop [status|slots|check] diagnose Claude Desktop 3P (read-only): managed policy → profile registration → per-slot alias → live /v1/messages probe with reasons and next command
|
|
30
30
|
mslxdff -provider add <id> <baseUrl> <key> [allowedModel...] add a generic OpenAI-compatible provider (myapi/gpt-4, baseUrl https://api.example.com/v1; extra models = allowlist)
|
|
31
31
|
mslxdff -provider add workbuddy https://copilot.tencent.com <key> [allow...] add WorkBuddy专用供应商(workbuddy/hy3 前缀路由,auths 与 keys 一一对应)
|
package/src/opencode-identity.js
CHANGED
|
@@ -1,37 +1,57 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
|
-
// opencode
|
|
4
|
-
//
|
|
5
|
-
// (timestamp*4096
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// opencode 客户端身份规格单一来源。真值 = 上游 packages/schema/src/identifier.ts:
|
|
4
|
+
// id = <prefix>_ + 12 位 hex 时间段 + 14 位 base62;UA 必须 "opencode/<semver>"。
|
|
5
|
+
// ⚠ 时间段是**倒序**的:value = ~(timestamp*4096 + counter) & 48bit(descending,最新排最前)。
|
|
6
|
+
// 正序时钟不合规——zen 免费层自 2026-10-13 起把低 12 位**反解回 counter** 校验,只接受 [0,63];
|
|
7
|
+
// 正序 id 反解出 4094 → 403 FreeTierError(现网实测:counter=1/40 → 200,counter=4094 → 403)。
|
|
8
|
+
// 时钟半段不校验:ms*4096 如今已需 53bit,截到 48bit 必回绕——真客户端自己反解出来是 1972 年。
|
|
9
|
+
// ⇒ 会话 id 可以继续由摘要确定性派生(跨重启稳定),只需把低 12 位强制成合法 counter。
|
|
10
|
+
// 旧判据全部仍有效:形状正则(2026-09-17)、UA ≥ 1.18.0(2026-09-18,低版本 426 UpgradeRequired)、
|
|
11
|
+
// 以及请求体那半道 agent 形状闸(stream + 核心五工具,见 free-lane.js)。
|
|
8
12
|
const ID_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
13
|
+
const MASK48 = 0xffffffffffffn;
|
|
14
|
+
const MASK12 = 0xfffn;
|
|
15
|
+
// 同毫秒内第 N 个 id:免费层只认反解后 ∈[0,63],越界必 403 ⇒ 到 63 回头,不无限自增
|
|
16
|
+
const COUNTER_MAX = 63;
|
|
9
17
|
const DEFAULT_OPENCODE_UA = "opencode/1.18.31";
|
|
10
18
|
let idLastTs = 0;
|
|
11
19
|
let idCounter = 0;
|
|
12
20
|
|
|
21
|
+
/** 合规时间段:倒序的 timestamp*4096+counter 截 48bit,恒 12 位小写 hex */
|
|
22
|
+
export function identifierTimeHex(timestamp = Date.now(), counter = 1) {
|
|
23
|
+
return ((~(BigInt(timestamp) * 0x1000n + BigInt(counter))) & MASK48).toString(16).padStart(12, "0");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从 12 位 hex 时间段反解 per-ms 计数——与免费层闸门同一判据。
|
|
28
|
+
* 值域 0..4095,闸门只放行 ≤63;形状不符返回 NaN(绝不抛,调用方用 Number.isInteger 自检)。
|
|
29
|
+
*/
|
|
30
|
+
export function counterFromTimeHex(hex) {
|
|
31
|
+
if (typeof hex !== "string" || !/^[0-9a-f]{12}$/i.test(hex)) return Number.NaN;
|
|
32
|
+
return Number((~BigInt("0x" + hex)) & MASK12);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function base62From(bytes) {
|
|
36
|
+
let out = "";
|
|
37
|
+
for (const b of bytes) out += ID_CHARS[b % 62];
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
13
41
|
export function opencodeIdTail(timestamp = Date.now()) {
|
|
14
42
|
if (timestamp !== idLastTs) {
|
|
15
43
|
idLastTs = timestamp;
|
|
16
44
|
idCounter = 0;
|
|
17
45
|
}
|
|
18
|
-
idCounter
|
|
19
|
-
|
|
20
|
-
let tail = "";
|
|
21
|
-
for (let i = 0; i < 6; i++) {
|
|
22
|
-
tail += Number((value >> BigInt(40 - 8 * i)) & 0xffn)
|
|
23
|
-
.toString(16)
|
|
24
|
-
.padStart(2, "0");
|
|
25
|
-
}
|
|
26
|
-
for (const b of crypto.randomBytes(14)) tail += ID_CHARS[b % 62];
|
|
27
|
-
return tail;
|
|
46
|
+
idCounter = (idCounter % COUNTER_MAX) + 1;
|
|
47
|
+
return identifierTimeHex(timestamp, idCounter) + base62From(crypto.randomBytes(14));
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
// 摘要派生:同种子恒同 id
|
|
50
|
+
// 摘要派生:同种子恒同 id(会话亲和——上游按 session 归组免费额度与 prompt cache,改成现取
|
|
51
|
+
// 时钟会让同一会话每请求换号,绝不)。时钟半段照摘要填(不校验),低 12 位强制 counter=1。
|
|
31
52
|
export function digestIdTail(digest) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return tail;
|
|
53
|
+
const time = ((BigInt("0x" + digest.subarray(0, 6).toString("hex")) & ~MASK12) | ((~1n) & MASK12)) & MASK48;
|
|
54
|
+
return time.toString(16).padStart(12, "0") + base62From(digest.subarray(6, 20));
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
export function genId(prefix) {
|
|
@@ -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
|
+
}
|