mslxdff 0.1.159 → 0.1.161
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 -1
- package/package.json +1 -1
- package/src/autostart.js +3 -0
- package/src/chat-pipeline/auto-race.js +1 -1
- package/src/chat-pipeline/empty-turn.js +119 -0
- package/src/chat-pipeline/index.js +4 -1
- package/src/chat-pipeline/serial-trial.js +244 -210
- package/src/cli/commands/daemon.js +4 -4
- package/src/cli/commands/model/list-providers.js +1 -1
- package/src/cli/commands/provider/globalqwenwork-login.js +126 -0
- package/src/cli/commands/provider/index.js +2 -0
- package/src/cli/commands/provider/models.js +4 -1
- package/src/cli/commands/stats.js +20 -5
- package/src/cli/commands/system.js +2 -2
- package/src/cli/format.js +6 -0
- package/src/cli/policy.js +2 -2
- package/src/cli/provider-row.js +2 -2
- package/src/daemon.js +7 -2
- package/src/logs.js +14 -1
- package/src/model-trace.js +5 -2
- package/src/providers/AGENTS.md +46 -0
- package/src/providers/classify.js +1 -1
- package/src/providers/globalqwenwork/account-store.js +141 -0
- package/src/providers/globalqwenwork/constants.js +73 -0
- package/src/providers/globalqwenwork/cosy.js +123 -0
- package/src/providers/globalqwenwork/crypto.js +220 -0
- package/src/providers/globalqwenwork/http.js +22 -0
- package/src/providers/globalqwenwork/index.js +331 -0
- package/src/providers/globalqwenwork/payload.js +145 -0
- package/src/providers/globalqwenwork/rsa.js +56 -0
- package/src/providers/globalqwenwork/sse.js +270 -0
- package/src/providers/globalqwenwork/stream.js +132 -0
- package/src/providers/globalqwenwork/upstream.js +122 -0
- package/src/providers/globalqwenwork.js +1 -0
- package/src/providers/registry.js +8 -0
- package/src/providers/zcode/chat.js +11 -10
- package/src/providers/zcode/const.js +1 -1
- package/src/providers/zcode/context-shape.js +162 -0
- package/src/providers/zcode/headers.js +38 -0
- package/src/providers/zcode/sse.js +19 -2
- package/src/routes/AGENTS.md +37 -0
- package/src/routes/chat/broadband-handler.js +3 -2
- package/src/routes/chat/exhausted-handler.js +23 -8
- package/src/routes/chat/hedge-handler.js +3 -0
- package/src/routes/chat/local-handler.js +5 -1
- package/src/routes/chat/peer-handler.js +2 -0
- package/src/routes/chat/relay-pipeline.js +40 -14
- package/src/routes/chat/via-route-handler.js +2 -0
- package/src/routes/helpers.js +20 -4
- package/src/routes/stream-hold.js +138 -0
- package/src/routes/stream-scan.js +278 -0
- package/src/routes/stream.js +106 -158
- package/src/runtime/lifecycle-forensics.js +116 -0
- package/src/runtime/lifecycle-log.js +23 -0
- package/src/runtime/provider-gate.js +4 -3
- package/src/talk-log.js +226 -0
- package/src/timeline.js +5 -2
- package/src/usage/record.js +8 -1
- package/src/usage/report.js +39 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// zcode 出站请求的「官方线形」组装:system 身份块 + 首 user 消息的 currentDate 上下文前缀。
|
|
2
|
+
// 上游 zcode-plan 通道对请求做内容检查:system 缺官方身份块 → 业务码 3012(HTTP 405)。
|
|
3
|
+
// 本文案逐段移植自官方开源仓库 zai-org/ZCode(apps/zcode-cli/packages/core/src/context/):
|
|
4
|
+
// sections/cli-prefix.ts / sections/identity.ts / dynamic-sections.ts / sections/env-info.ts /
|
|
5
|
+
// sections/current-date.ts / builder.ts(块分组与 \n\n 左边界)/ system-reminder 包裹形状。
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
const EPHEMERAL = { type: "ephemeral" };
|
|
9
|
+
|
|
10
|
+
export const ZCODE_CLI_PREFIX = "You are ZCode, an interactive coding agent";
|
|
11
|
+
|
|
12
|
+
const IDENTITY_INTRO = "You are an interactive ZCode agent that helps users with software engineering tasks.";
|
|
13
|
+
const SECURITY_NOTICE =
|
|
14
|
+
"IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.";
|
|
15
|
+
const HARNESS_BLOCK = [
|
|
16
|
+
"# Harness",
|
|
17
|
+
"- Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.",
|
|
18
|
+
"- Tools run behind a user-selected permission mode; a denied call means the user declined it \u2014 adjust, don't retry verbatim.",
|
|
19
|
+
"- The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback.",
|
|
20
|
+
"- Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.",
|
|
21
|
+
"- Reference code as `file_path:line_number` \u2014 it's clickable.",
|
|
22
|
+
].join("\n");
|
|
23
|
+
// identity.ts:content = [ "\n{intro}\n\n{SECURITY}", "", harness ].join("\n") —— 前导换行是官方原样。
|
|
24
|
+
const STABLE_SECTION = [`\n${IDENTITY_INTRO}\n\n${SECURITY_NOTICE}`, "", HARNESS_BLOCK].join("\n");
|
|
25
|
+
|
|
26
|
+
// dynamic-sections.ts buildDynamicBehaviorSection()
|
|
27
|
+
const DYNAMIC_BEHAVIOR = [
|
|
28
|
+
[
|
|
29
|
+
"# Communicating with the user",
|
|
30
|
+
"",
|
|
31
|
+
"Your text output is what the user reads; they usually can't see your thinking or the raw tool results. Write it for a teammate who stepped away and is catching up, not for a log file: they don't know the codenames or shorthand you created along the way, and they didn't watch your process unfold. Before your first tool call, say in a sentence what you're about to do; while working, give brief updates when you find something load-bearing or change direction.",
|
|
32
|
+
"",
|
|
33
|
+
"Text you write between tool calls may not be shown to the user. Everything the user needs from this turn \u2014 answers, summaries, findings, conclusions, deliverables \u2014 must be in the final text message of your turn, with no tool calls after it. Keep text between tool calls to brief status notes. If something important appeared only mid-turn or in your thinking, restate it in that final message.",
|
|
34
|
+
"",
|
|
35
|
+
"Lead with the outcome. Your first sentence after finishing should answer \"what happened\" or \"what did you find\" \u2014 the thing the user would ask for if they said \"just give me the TLDR.\" Supporting detail and reasoning come after, for readers who want them.",
|
|
36
|
+
"",
|
|
37
|
+
"Being readable and being concise are different things, and readable matters more. If the user has to reread your summary or ask you to explain, any time saved by brevity is gone. The way to keep output short is to be selective about what you include (drop details that don't change what the reader would do next), not to compress the writing into fragments, abbreviations, arrow chains like `A \u2192 B \u2192 fails`, or jargon. What you do include, write in complete sentences with the technical terms spelled out. Don't make the reader cross-reference labels or numbering you invented earlier; say what you mean in place.",
|
|
38
|
+
"",
|
|
39
|
+
"Match the response to the question: a simple question gets a direct answer in prose, not headers and sections. Use tables only for short enumerable facts, with explanations in the surrounding prose rather than the cells. Calibrate to the user \u2014 a bit tighter for an expert, more explanatory for someone newer.",
|
|
40
|
+
].join("\n"),
|
|
41
|
+
"",
|
|
42
|
+
"Write code that reads like the surrounding code: match its comment density, naming, and idiom.",
|
|
43
|
+
"Only write a code comment to state a constraint the code itself can't show \u2014 never to say where it came from, what the next line does, or why your change is correct; that's you talking to the reviewer, not the next reader, and it's noise the moment the PR merges.",
|
|
44
|
+
"",
|
|
45
|
+
"For actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target \u2014 if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging.",
|
|
46
|
+
].join("\n");
|
|
47
|
+
|
|
48
|
+
// dynamic-sections.ts buildContextManagementSection()
|
|
49
|
+
const CONTEXT_MANAGEMENT = [
|
|
50
|
+
["# Context management", "When the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue \u2014 you don't need to wrap up early or hand off mid-task."].join("\n"),
|
|
51
|
+
"",
|
|
52
|
+
"When you have enough information to act, act. Do not re-derive facts already established in the conversation, re-litigate a decision the user has already made, or narrate options you will not pursue. If you are weighing a choice, give a recommendation, not an exhaustive survey",
|
|
53
|
+
"",
|
|
54
|
+
"You are operating autonomously. The user is not watching in real time and cannot answer questions mid-task, so asking 'Want me to\u2026?' or 'Shall I\u2026?' will block the work. For reversible actions that follow from the original request, proceed without asking. Stop only for destructive actions or genuine scope changes the user must decide. Offering follow-ups after the task is done is fine; asking permission before doing the work is not.",
|
|
55
|
+
"",
|
|
56
|
+
"Exception: when the user is describing a problem, asking a question, or thinking out loud rather than requesting a change, the deliverable is your assessment. Report your findings and stop. Don't apply a fix until they ask for one.",
|
|
57
|
+
"",
|
|
58
|
+
"Before ending your turn, check your last paragraph. If it is a plan, an analysis, a question, a list of next steps, or a promise about work you have not done ('I'll\u2026', 'let me know when\u2026'), do that work now with tool calls. That includes retrying after errors and gathering missing information yourself. Do not stop because the context or session is long. End your turn only when the task is complete or you are blocked on input only the user can provide.",
|
|
59
|
+
"",
|
|
60
|
+
"Before running a command that changes system state \u2014 restarts, deletes, config edits \u2014 check that the evidence actually supports that specific action. A signal that pattern-matches to a known failure may have a different cause.",
|
|
61
|
+
].join("\n");
|
|
62
|
+
|
|
63
|
+
// builder.ts buildContextMetaUserBody() 的 intro/outro(current-date.ts 的正文夹在中间)
|
|
64
|
+
const CONTEXT_INTRO = "As you answer the user's questions, you can use the following context:";
|
|
65
|
+
const CONTEXT_OUTRO = " IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.";
|
|
66
|
+
const REMINDER_OPEN = "<system-reminder>";
|
|
67
|
+
const REMINDER_CLOSE = "</system-reminder>";
|
|
68
|
+
|
|
69
|
+
function localIsoDate(d = new Date()) {
|
|
70
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
71
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeCallerSystem(system) {
|
|
75
|
+
const out = [];
|
|
76
|
+
if (typeof system === "string") {
|
|
77
|
+
if (system.trim()) out.push({ type: "text", text: system });
|
|
78
|
+
} else if (Array.isArray(system)) {
|
|
79
|
+
for (const item of system) {
|
|
80
|
+
if (typeof item === "string") {
|
|
81
|
+
if (item.trim()) out.push({ type: "text", text: item });
|
|
82
|
+
} else if (item && typeof item === "object" && item.type === "text" && typeof item.text === "string" && item.text.trim()) {
|
|
83
|
+
out.push({ type: "text", text: item.text });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// env-info.ts buildEnvInfoContent():CLI 无 git 上下文的固定形态
|
|
91
|
+
function buildEnvironmentSection({ cwd, model, provider, platform, shell, osVersion }) {
|
|
92
|
+
const lines = [
|
|
93
|
+
"# Environment",
|
|
94
|
+
"You have been invoked in the following environment:",
|
|
95
|
+
`- Primary working directory: ${cwd}`,
|
|
96
|
+
"- Is a git repository: no",
|
|
97
|
+
`- Platform: ${platform}`,
|
|
98
|
+
`- Shell: ${shell}`,
|
|
99
|
+
`- OS Version: ${osVersion}`,
|
|
100
|
+
];
|
|
101
|
+
if (model) lines.push(`- You are powered by the model named ${provider}-api/${model}.`);
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 官方式 system 块数组:cliPrefix / stable / dynamic(\n\n 左边界)三块均带 ephemeral,
|
|
107
|
+
* 调用方原 system 追加在末尾(不进官方缓存块)。
|
|
108
|
+
*/
|
|
109
|
+
export function buildZcodeSystemBlocks({ system, model, provider = "zai", cwd = process.cwd(), now = new Date(), platform, shell, osVersion } = {}) {
|
|
110
|
+
const env = buildEnvironmentSection({
|
|
111
|
+
cwd,
|
|
112
|
+
model,
|
|
113
|
+
provider,
|
|
114
|
+
platform: platform || process.platform,
|
|
115
|
+
shell: shell || (process.platform === "win32" ? "cmd" : (process.env.SHELL || "sh")),
|
|
116
|
+
osVersion: osVersion || os.release(),
|
|
117
|
+
});
|
|
118
|
+
const dynamic = [DYNAMIC_BEHAVIOR, env, CONTEXT_MANAGEMENT].join("\n\n");
|
|
119
|
+
const official = [
|
|
120
|
+
{ type: "text", text: ZCODE_CLI_PREFIX, cache_control: { ...EPHEMERAL } },
|
|
121
|
+
{ type: "text", text: STABLE_SECTION, cache_control: { ...EPHEMERAL } },
|
|
122
|
+
// builder.ts:270-271:Main Agent 的 dynamic system block 自带 \n\n 左边界
|
|
123
|
+
{ type: "text", text: `\n\n${dynamic}`, cache_control: { ...EPHEMERAL } },
|
|
124
|
+
];
|
|
125
|
+
return [...official, ...normalizeCallerSystem(system)];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 首 user 消息的 currentDate 上下文块(builder.ts meta_user → system-reminder 包裹)。 */
|
|
129
|
+
export function buildZcodeContextPrefixBlock(now = new Date()) {
|
|
130
|
+
const body = [
|
|
131
|
+
CONTEXT_INTRO,
|
|
132
|
+
`# currentDate\nToday's date is ${localIsoDate(now)}.`,
|
|
133
|
+
"",
|
|
134
|
+
CONTEXT_OUTRO,
|
|
135
|
+
].join("\n");
|
|
136
|
+
return { type: "text", text: `${REMINDER_OPEN}\n${body}\n${REMINDER_CLOSE}` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 把上下文前缀挂到首个 user 消息 content 最前(字符串 content 升格为块数组;已挂则不动)。 */
|
|
140
|
+
export function attachZcodeContextPrefix(messages, now = new Date()) {
|
|
141
|
+
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
142
|
+
const first = messages[0];
|
|
143
|
+
if (!first || first.role !== "user") return messages;
|
|
144
|
+
const content = Array.isArray(first.content)
|
|
145
|
+
? first.content
|
|
146
|
+
: [{ type: "text", text: String(first.content ?? "") }];
|
|
147
|
+
if (content.some((c) => c?.type === "text" && typeof c.text === "string" && c.text.startsWith(REMINDER_OPEN))) {
|
|
148
|
+
return messages;
|
|
149
|
+
}
|
|
150
|
+
return [{ ...first, content: [buildZcodeContextPrefixBlock(now), ...content] }, ...messages.slice(1)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 内部 Anthropic 请求 → 官方出站线形:小写模型名 + 身份 system 块 + currentDate 前缀,其余透传。 */
|
|
154
|
+
export function shapeZcodeWireRequest(req = {}, { cwd, provider = "zai", now = new Date() } = {}) {
|
|
155
|
+
const model = typeof req.model === "string" ? req.model.toLowerCase() : req.model;
|
|
156
|
+
return {
|
|
157
|
+
...req,
|
|
158
|
+
model,
|
|
159
|
+
system: buildZcodeSystemBlocks({ system: req.system, model, provider, cwd, now }),
|
|
160
|
+
messages: attachZcodeContextPrefix(req.messages, now),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -53,3 +53,41 @@ export function buildZcodeHeaders(opts = {}) {
|
|
|
53
53
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
54
54
|
return headers;
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
// 模型请求专用形态(zcode-plan 通道):与官方 CLI 出站同形 —— 双鉴权头、ai-sdk UA、
|
|
58
|
+
// x-title=cli、production 渠道、anthropic-version、gzip;**不带** X-Device-Mid,
|
|
59
|
+
// **不带** 验证码头(v3.14.4 起模型请求免校验,client/configs captcha.skip_model_request=true)。
|
|
60
|
+
export function buildZcodeModelHeaders(opts = {}) {
|
|
61
|
+
const ver = normalizeHeaderValue(opts.version) ?? zcodeAppVersion();
|
|
62
|
+
const platform = normalizeHeaderValue(opts.platform) ?? process.platform;
|
|
63
|
+
const arch = normalizeHeaderValue(opts.arch) ?? process.arch;
|
|
64
|
+
const intl = (() => { try { return Intl.DateTimeFormat().resolvedOptions() || {}; } catch { return {}; } })();
|
|
65
|
+
const language = normalizeHeaderValue(opts.lang) ?? normalizeHeaderValue(intl.locale) ?? "unknown";
|
|
66
|
+
const timeZone = normalizeHeaderValue(opts.tz) ?? normalizeHeaderValue(intl.timeZone) ?? "unknown";
|
|
67
|
+
const osVersion = "osVersion" in opts ? normalizeHeaderValue(opts.osVersion) : normalizeHeaderValue(os.release());
|
|
68
|
+
const token = normalizeHeaderValue(opts.token);
|
|
69
|
+
const headers = {
|
|
70
|
+
"accept-encoding": "gzip",
|
|
71
|
+
"anthropic-version": "2023-06-01",
|
|
72
|
+
"authorization": token ? `Bearer ${token}` : undefined,
|
|
73
|
+
"http-referer": ZCODE_ORIGIN,
|
|
74
|
+
"user-agent": `ZCode/${ver} ai-sdk/anthropic/3.0.81`,
|
|
75
|
+
"x-api-key": token,
|
|
76
|
+
"x-client-language": language,
|
|
77
|
+
"x-client-timezone": timeZone,
|
|
78
|
+
"x-os-category": osCategory(platform),
|
|
79
|
+
"x-platform": `${platform}-${arch}`,
|
|
80
|
+
"x-release-channel": "production",
|
|
81
|
+
"x-request-id": normalizeHeaderValue(opts.requestId) ?? uuid(),
|
|
82
|
+
"x-title": "Z Code@cli",
|
|
83
|
+
"x-zcode-agent": "glm",
|
|
84
|
+
"x-zcode-app-version": ver,
|
|
85
|
+
"x-zcode-session-type": "main",
|
|
86
|
+
"x-zcode-trace-id": normalizeHeaderValue(opts.traceId) ?? uuid(),
|
|
87
|
+
};
|
|
88
|
+
if (osVersion) headers["x-os-version"] = osVersion;
|
|
89
|
+
for (const k of Object.keys(headers)) {
|
|
90
|
+
if (headers[k] === undefined) delete headers[k];
|
|
91
|
+
}
|
|
92
|
+
return headers;
|
|
93
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Anthropic SSE → OpenAI 形状转换(流式 / 聚合两种出口)。
|
|
2
|
-
// 承诺字段:文本增量、tool_use 增量(tool_calls)、usage、finish_reason
|
|
2
|
+
// 承诺字段:文本增量、thinking 增量(reasoning_content)、tool_use 增量(tool_calls)、usage、finish_reason。
|
|
3
|
+
// thinking 跨轮回填:首个 thinking block 的 signature 随 reasoning_content 挂在 assistant 历史消息上(必须,
|
|
4
|
+
// 上游要求 signature 与 thinking 原文一起回传;与 reasoning_content_signature 拼写不同的上游见下兼容)。
|
|
3
5
|
const ENCODER = new TextEncoder();
|
|
4
6
|
const DECODER = new TextDecoder();
|
|
5
7
|
const STOP_REASONS = { end_turn: "stop", max_tokens: "length", tool_use: "tool_calls", stop_sequence: "stop", pause_turn: "stop" };
|
|
@@ -26,6 +28,9 @@ export function createAnthropicTranslator({ model = "zcode", id = newId(), creat
|
|
|
26
28
|
let inputTokens = null;
|
|
27
29
|
let outputTokens = null;
|
|
28
30
|
let text = "";
|
|
31
|
+
// thinking 全量文本(聚合出口回填 reasoning_content)与首个 signature(跨轮必需,见文件头)
|
|
32
|
+
let thinkingText = "";
|
|
33
|
+
let thinkingSig = null;
|
|
29
34
|
let toolCount = 0;
|
|
30
35
|
const toolIndexByBlock = new Map();
|
|
31
36
|
const toolCalls = [];
|
|
@@ -55,7 +60,12 @@ export function createAnthropicTranslator({ model = "zcode", id = newId(), creat
|
|
|
55
60
|
}
|
|
56
61
|
} else if (evt.type === "content_block_start") {
|
|
57
62
|
const block = evt.content_block || {};
|
|
58
|
-
if (block.type === "
|
|
63
|
+
if (block.type === "thinking") {
|
|
64
|
+
// 首个 thinking 块的 signature 留给聚合出口的跨轮回填(后续 thinking 块没有也必须回传空串占位——
|
|
65
|
+
// codearts 的 DeepSeek 同款要求,见 src/providers/codearts/chat.js)
|
|
66
|
+
if (thinkingSig === null && typeof block.signature === "string") thinkingSig = block.signature;
|
|
67
|
+
if (typeof block.thinking === "string" && block.thinking) thinkingText += block.thinking;
|
|
68
|
+
} else if (block.type === "tool_use") {
|
|
59
69
|
const idx = toolCount++;
|
|
60
70
|
toolIndexByBlock.set(Number(evt.index), idx);
|
|
61
71
|
toolCalls.push({ id: String(block.id || `toolu_${idx}`), name: String(block.name || ""), args: "" });
|
|
@@ -66,6 +76,10 @@ export function createAnthropicTranslator({ model = "zcode", id = newId(), creat
|
|
|
66
76
|
if (d.type === "text_delta" && typeof d.text === "string") {
|
|
67
77
|
text += d.text;
|
|
68
78
|
out.push(chunkLine({ content: d.text }));
|
|
79
|
+
} else if (d.type === "thinking_delta" && typeof d.thinking === "string") {
|
|
80
|
+
thinkingText += d.thinking;
|
|
81
|
+
if (typeof d.signature === "string" && d.signature && thinkingSig === null) thinkingSig = d.signature;
|
|
82
|
+
out.push(chunkLine({ reasoning_content: d.thinking }));
|
|
69
83
|
} else if (d.type === "input_json_delta" && typeof d.partial_json === "string") {
|
|
70
84
|
const idx = toolIndexByBlock.has(Number(evt.index)) ? toolIndexByBlock.get(Number(evt.index)) : Math.max(0, toolCount - 1);
|
|
71
85
|
if (toolCalls[idx]) toolCalls[idx].args += d.partial_json;
|
|
@@ -113,6 +127,9 @@ export function createAnthropicTranslator({ model = "zcode", id = newId(), creat
|
|
|
113
127
|
|
|
114
128
|
function result() {
|
|
115
129
|
const message = { role: "assistant", content: text || null };
|
|
130
|
+
// thinking 聚合回填:跨轮必需的 signature 与原文一起带回(见文件头;codearts DeepSeek 同款)
|
|
131
|
+
if (thinkingText) message.reasoning_content = thinkingText;
|
|
132
|
+
if (thinkingSig !== null) message.reasoning_content_signature = thinkingSig;
|
|
116
133
|
if (toolCalls.length) {
|
|
117
134
|
message.tool_calls = toolCalls.map((t) => ({ id: t.id, type: "function", function: { name: t.name, arguments: t.args || "{}" } }));
|
|
118
135
|
if (!text) message.content = null;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# src/routes — 转发链契约(改 relay / 流式 / 用量前必读)
|
|
2
|
+
|
|
3
|
+
> 从 `AGENTS.md` 下沉(2026-09-29 分层,就近生效)。改完跑:`node --test --test-concurrency=1 test/stream-*.test.js test/relay-*.test.js test/usage-*.test.js` + `npm run docs:check`。
|
|
4
|
+
|
|
5
|
+
## 时间量测的两条口径(ADR-0039,最易被改错)
|
|
6
|
+
|
|
7
|
+
- **保护闸门的零点 = `relay()` 入口的 `t0`**(首帧超时 / stall / MAX_STREAM / keepalive 四类)。**禁止**把上报锚点接进闸门判定,否则上游排队时间会吃掉超时预算、误杀正在出字的慢模型。
|
|
8
|
+
- **上报时长(usage 行)的零点 = 本次上游尝试起点**(`serial-trial` 的 `tUp`,经 `attemptStartMs` 交进 relay);relay 入口算 `preflightMs = t0 − 锚点` 并随**全部 4 个 return 点**带出,`relay-pipeline` 落行时相加。锚点缺失/负值一律 0。
|
|
9
|
+
- 报表口径与 state 口径**彻底分离**:`recordModelStats`(→ `-status`/`-model stats` 的 EMA)仍吃转发入口相对值,勿"顺手统一"。
|
|
10
|
+
- 首字与总耗时同加一个常数 → `总 − 首字` 恒等 → 速度读数不变(已锁为单测)。
|
|
11
|
+
- usage 行**只增字段**演进(`src/usage/record.js` 拥有行形状);旧行缺字段 = 未知,不得当 0。
|
|
12
|
+
- 模型日志里 `relay-first-chunk ttfMs=` 是**转发入口相对值**(故意与报表不同源),排查时别混。
|
|
13
|
+
|
|
14
|
+
## relay 的行为契约(既有测试锁死)
|
|
15
|
+
|
|
16
|
+
- **超时是显式字段 `timedOut`**,`status` 数值只是 HTTP 语义展示,不得再拿 status 当"是否可 failover"的信号。
|
|
17
|
+
- **注释帧(`: keepalive`)不算首块**:不解除闸门、不触发超时救回,但照常透传;能否安全 failover 看 `detail.wrotePayload`(=是否已**提交**模型产出,见下条留口)。
|
|
18
|
+
- 闸门到点后上游仍吐出真实数据 → **撤销超时判定照常转发**(cancel 是异步的,竞态窗口内已到达的数据是纯收益),记 `detail.recoveries`。
|
|
19
|
+
- 任一闸门到点且未写真实数据 → 回退候选;唯一/末位候选无 failover 去向 → 闸门放宽到 `MSLXDFF_LAST_CANDIDATE_TIMEOUT_MS`(默认 120s,0=不限),防误杀慢模型。
|
|
20
|
+
- **空转 200 判据单一真相** = `stream-scan.js:isEmptyTurnDetail`(`chatShaped===true` 且 `chars===0 && toolCalls===0` 且 `finish_reason ∉ {tool_calls, function_call}`);relay 侧「要不要封口」与 pipeline 5a0「要不要重拉」都必须用它,两处各写一份必然错拍。判空 → 转 failover,不记 auto 冷却(空转≠模型坏)。错误包络暂扣 `holdableChunk` 与非 chat 形状放行同在 `stream-scan.js`(**不在 stream.js**)。
|
|
21
|
+
- **空轮留口(ADR-0043)**:模型产出(正文/工具/**思考**)出现前,空 delta 帧与 `[DONE]` 一律暂扣不写(`stream-hold.js:createRevocablePrefix`);流结束仍零产出且未断下游 → **不 `res.end()`**,返回 `heldOpen:true` 交回重拉/换候选,正文续写同一条连接。三条铁律:① 异形/解析失败帧必须立即透传(`isTrivialFrame` 白名单式),透传契约不得为救空轮让路;② `[DONE]` 是终点,错误帧要排在它之前(tail 槽)且全程只允许一个;③ 重入同一条连接时 headers 可能已 flush → `statusCode`/`setHeader` 一律门控,收场经 `endHeldFailure`/`endEmptyTurnStream`/`helpers.json` 的 `writableEnded`+`headersSent` 守卫保证**恰好一次**,禁止把 JSON 体混进 `text/event-stream`。
|
|
22
|
+
- 断下游即掐上游:relay 自持 `reader`(`for-await` 会锁 ReadableStream 使 `body.cancel()` 必 reject),`reader.cancel()` 才真掐得断。
|
|
23
|
+
|
|
24
|
+
## 组/组员与宽带中继(`src/routes/groups*.js`、`peers.js`、`relay-queue.js`)
|
|
25
|
+
|
|
26
|
+
- 慢模型不掐当前流:首块 25s 内到即视为可用、后续无限等;stall 15s 仅作质量分,耗时 >20s 降权;`MSLXDFF_HEDGE_DELAY_MS=1000` 时 1s 并发组员赛跑,赢家 `x-mslxdff-via`;指定模型 429 时按延迟 EMA 择优切最快模型。
|
|
27
|
+
- `SLOW_TOTAL_MS=20s` 标 slow 进 5 分钟冷却(普通 60s);上游重试 `1次×100ms`;`MSLXDFF_STREAM_TIMEOUT_MS=25s`(**仅首块未到且未写出任何字节时**才 failover;`0`=显式关闭首块超时,给重思考模型用);`MAX_STREAM_MS=120s`。
|
|
28
|
+
- 宽带 `relay://` 成员走 `GET /v1/groups/relay/stream` SSE 单长连 25s ping 直推(`pushToStream`),`MSLXDFF_BROADBAND_STREAM=0` 回退 1s poll,`MSLXDFF_BROADBAND_PING_MS=25000`/`_STALE_MS=90000` 可配(空闲 -96%);用户明确反对 1s 轮询的空轮询。
|
|
29
|
+
- 组命令语义:`-group list` 序号按 state 成员顺序(过滤 leader)固定渲染,`-group remove <seq>` 用同一顺序踢人(仅组长);组长 `-leavegroup` 提示改用 `-delgroup`,`-delgroup` 在组员节点报错并提示该组由谁领导。
|
|
30
|
+
- 模型可观测面:事件用**黑名单** `TRACE_DENY`(默认全可见,只排噪声与敏感面);决定类事件只渲染 `DECISION_FIELDS` 登记过的标量,**"加日志"≠"倒数据"**,payload/正文一律不落;回显取头单一入口 `upstreamEcho(res)`。
|
|
31
|
+
|
|
32
|
+
## Responses 端点(`routes/responses-route.js` + `responses/translate.js`,ADR-0012)
|
|
33
|
+
|
|
34
|
+
- 复用 ChatPipeline 全链路,只做形状翻译;stateless(`previous_response_id` 不支持,thinking reasoning 暂不透传)。
|
|
35
|
+
- Codex Rust 严格解码三特例(缺一整轮作废):① 对 codex 调用者(UA/originator `codex_*` 或 `?client_version=`)`GET /v1/models` 追加顶层 `models: []`(**必须空**,填真目录会覆盖其内置 agent prompt);② completed 的 usage 转 Responses 口径(`input_tokens/output_tokens` + details);③ done 事件 `text` 带累积全文。
|
|
36
|
+
- `-setto chatgpt`(`src/sync-codex.js`)写三端共用 `~/.codex/config.toml`(`wire_api="responses"` 显式、鉴权绝对路径 `node + -showtoken`,换模型需重跑 setto / `codex exec -m` / 手改 `model=`,单键覆盖无需剪枝);排障 `MSLXDFF_RESPONSES_DEBUG=1` 看 `daemon.log [responses]` 四段 + Codex 侧 `~/.codex/logs_2.sqlite`(`Request completed ... status=`)与 `thread_history_1.sqlite`(正文)。
|
|
37
|
+
- `@ai-sdk/openai` 只是客户端包,替代不了服务端翻译层;Qwen 上游走 `/v1/messages`(Anthropic 协议),接 Codex 要多一跳(未做)。
|
|
@@ -21,6 +21,7 @@ export async function handleBroadbandRelay({
|
|
|
21
21
|
evt,
|
|
22
22
|
mark,
|
|
23
23
|
perf0,
|
|
24
|
+
attemptStartMs,
|
|
24
25
|
stages,
|
|
25
26
|
res,
|
|
26
27
|
startedAt,
|
|
@@ -48,7 +49,7 @@ export async function handleBroadbandRelay({
|
|
|
48
49
|
});
|
|
49
50
|
const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
|
|
50
51
|
if (isResponse) {
|
|
51
|
-
const r = await pipeline.execute({ res, upRes: bb.result, body, requested, actual: model, lastErr, via: "broadband", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
|
|
52
|
+
const r = await pipeline.execute({ res, upRes: bb.result, body, requested, actual: model, lastErr, via: "broadband", lockModel, useAuto, handlerCtx, mark, perf0, attemptStartMs, stages, startedAt });
|
|
52
53
|
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
53
54
|
return { handled: true };
|
|
54
55
|
}
|
|
@@ -62,7 +63,7 @@ export async function handleBroadbandRelay({
|
|
|
62
63
|
text: async () => str,
|
|
63
64
|
body: isSSE ? (async function* () { yield Buffer.from(str); })() : null,
|
|
64
65
|
};
|
|
65
|
-
const r = await pipeline.execute({ res, upRes: fakeRes, body, requested, actual: model, lastErr, via: "broadband-local", lockModel, useAuto, handlerCtx, mark, perf0, stages, startedAt });
|
|
66
|
+
const r = await pipeline.execute({ res, upRes: fakeRes, body, requested, actual: model, lastErr, via: "broadband-local", lockModel, useAuto, handlerCtx, mark, perf0, attemptStartMs, stages, startedAt });
|
|
66
67
|
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
67
68
|
return { handled: true };
|
|
68
69
|
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
// Note: 失败收尾四分支在 result 后补 client-response,与成功路径对账口径一致(result 条数 == client-response 条数)— 见 .agents/notes/implemented/bug-fix/2026-09-25-sdk-headers-timeout-and-failure-client-response.md
|
|
2
2
|
import { relay } from "../stream.js";
|
|
3
|
+
import { endHeldFailure } from "../stream-hold.js"; // 终局收场出口(502 JSON 或 SSE 错误帧 + [DONE])
|
|
3
4
|
import { json } from "../helpers.js";
|
|
4
5
|
import { performance } from "node:perf_hooks";
|
|
6
|
+
import { recordRelayTalk } from "../../talk-log.js"; // 环形对话日志:exhausted 收尾直连 relay,落盘点必须自己补一份
|
|
7
|
+
|
|
8
|
+
// 最后一站的收场:relay 的「不写响应」路径(超时 / 空轮延后封口)都汇到这里,没有上层 failover 了,
|
|
9
|
+
// 必须把响应「恰好一次」地关掉。形状由 endHeldFailure 按 headers 是否已 flush 决定
|
|
10
|
+
// (未 flush → 502 JSON;已 flush 的 SSE → 错误帧 + [DONE]),这里不再自己 json() 打已 flush 的流。
|
|
11
|
+
function terminal(res, message) {
|
|
12
|
+
endHeldFailure(res, message || "all auto models failed");
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
export async function handleExhaustedLocal({ res, body, lastErr, order, handlerCtx, evt, logCall, mark, perf0, stages, done, requested, useAuto, deps = {} }) {
|
|
7
16
|
const { relay: relayImpl = relay } = deps;
|
|
@@ -15,18 +24,22 @@ export async function handleExhaustedLocal({ res, body, lastErr, order, handlerC
|
|
|
15
24
|
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
16
25
|
});
|
|
17
26
|
evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
27
|
+
// 绕过 relay-pipeline 的最后一站:对话日志照常落盘(正文只进 talk/*.log,不进 events.log)
|
|
28
|
+
try { recordRelayTalk({ reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", body, out }); } catch {}
|
|
18
29
|
evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
19
30
|
evt("client-response", { requested, actual: lastErr.model, via: "local", fallback: false, status: out.status, reqId: handlerCtx.reqId });
|
|
20
|
-
// 最后一站:relay
|
|
21
|
-
if (out.timedOut) {
|
|
22
|
-
|
|
31
|
+
// 最后一站:relay 超时/空轮留口路径都不写响应,这里没有上层,必须自己收尾
|
|
32
|
+
if (out.timedOut || out.heldOpen) {
|
|
33
|
+
terminal(res, out.detail?.exitReason === "empty-turn-hold"
|
|
34
|
+
? "EMPTY_MODEL_RESPONSE: 空轮重拉与换候选全部用尽,仍无正文"
|
|
35
|
+
: out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms`);
|
|
23
36
|
}
|
|
24
37
|
return true;
|
|
25
38
|
}
|
|
26
39
|
evt("result", { reqId: handlerCtx.reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
27
40
|
evt("client-response", { requested, actual: model, via: "none", fallback: false, status: lastErr?.status ?? 502, reqId: handlerCtx.reqId });
|
|
28
41
|
done({ via: "none", status: lastErr?.status ?? 502, error: lastErr?.message || "all auto models failed" });
|
|
29
|
-
|
|
42
|
+
terminal(res, lastErr?.message);
|
|
30
43
|
return true;
|
|
31
44
|
}
|
|
32
45
|
|
|
@@ -41,16 +54,18 @@ export async function handleExhaustedAll({ res, body, lastErr, order, requested,
|
|
|
41
54
|
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
42
55
|
});
|
|
43
56
|
evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
57
|
+
// 同上:全链耗尽的最终转发也要留下问答原文
|
|
58
|
+
try { recordRelayTalk({ reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", body, out }); } catch {}
|
|
44
59
|
evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
45
60
|
evt("client-response", { requested, actual: lastErr.model, via: "local", fallback: false, status: out.status, reqId: handlerCtx.reqId });
|
|
46
|
-
// 最后一站:relay
|
|
47
|
-
if (out.timedOut) {
|
|
48
|
-
|
|
61
|
+
// 最后一站:relay 超时/空轮留口路径不写响应,这里没有上层 failover,必须自己收尾
|
|
62
|
+
if (out.timedOut || out.heldOpen) {
|
|
63
|
+
terminal(res, out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms`);
|
|
49
64
|
}
|
|
50
65
|
return true;
|
|
51
66
|
}
|
|
52
67
|
evt("result", { reqId: handlerCtx.reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
53
68
|
evt("client-response", { requested, actual: lastErr?.model ?? requested, via: "none", fallback: false, status: lastErr?.status ?? 502, reqId: handlerCtx.reqId });
|
|
54
|
-
|
|
69
|
+
terminal(res, lastErr?.message);
|
|
55
70
|
return true;
|
|
56
71
|
}
|
|
@@ -22,6 +22,7 @@ export async function handleHedge({
|
|
|
22
22
|
logError,
|
|
23
23
|
mark,
|
|
24
24
|
perf0,
|
|
25
|
+
attemptStartMs,
|
|
25
26
|
stages,
|
|
26
27
|
startedAt,
|
|
27
28
|
plugins,
|
|
@@ -62,6 +63,7 @@ export async function handleHedge({
|
|
|
62
63
|
handlerCtx,
|
|
63
64
|
mark,
|
|
64
65
|
perf0,
|
|
66
|
+
attemptStartMs,
|
|
65
67
|
stages,
|
|
66
68
|
startedAt,
|
|
67
69
|
});
|
|
@@ -100,6 +102,7 @@ export async function handleHedge({
|
|
|
100
102
|
handlerCtx: { ...handlerCtx, model: win.target },
|
|
101
103
|
mark: (n) => mark(n),
|
|
102
104
|
perf0,
|
|
105
|
+
attemptStartMs,
|
|
103
106
|
stages,
|
|
104
107
|
startedAt,
|
|
105
108
|
});
|
|
@@ -20,6 +20,7 @@ export async function handleLocalRelay({
|
|
|
20
20
|
logError,
|
|
21
21
|
mark,
|
|
22
22
|
perf0,
|
|
23
|
+
attemptStartMs,
|
|
23
24
|
stages,
|
|
24
25
|
startedAt,
|
|
25
26
|
plugins,
|
|
@@ -51,9 +52,12 @@ export async function handleLocalRelay({
|
|
|
51
52
|
handlerCtx,
|
|
52
53
|
mark,
|
|
53
54
|
perf0,
|
|
55
|
+
attemptStartMs,
|
|
54
56
|
stages,
|
|
55
57
|
startedAt,
|
|
56
58
|
});
|
|
59
|
+
// wrotePayload 是「正文真到了下游」的送达证据:serial-trial 记 empty-turn-recovered 只认它,
|
|
60
|
+
// 不认 handled 本身(handled 也覆盖下游已断开等零输出路径,见 ADR-0043)。
|
|
57
61
|
if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
|
|
58
|
-
return { handled: true };
|
|
62
|
+
return { handled: true, wrotePayload: r.wrotePayload === true };
|
|
59
63
|
}
|
|
@@ -18,6 +18,7 @@ export async function handlePeerRelay({
|
|
|
18
18
|
logCall,
|
|
19
19
|
mark,
|
|
20
20
|
perf0,
|
|
21
|
+
attemptStartMs,
|
|
21
22
|
stages,
|
|
22
23
|
startedAt,
|
|
23
24
|
plugins,
|
|
@@ -70,6 +71,7 @@ export async function handlePeerRelay({
|
|
|
70
71
|
handlerCtx: { ...handlerCtx, model: win.target },
|
|
71
72
|
mark,
|
|
72
73
|
perf0,
|
|
74
|
+
attemptStartMs,
|
|
73
75
|
stages,
|
|
74
76
|
startedAt,
|
|
75
77
|
});
|
|
@@ -5,6 +5,8 @@ import { computeMetrics } from "../../metrics.js";
|
|
|
5
5
|
import { recordChatUsage } from "../../usage/record.js"; // 窗口报表唯一写入点(canonical 名单记防双计)— 见 .agents/notes/implemented/feature/2026-09-19-usage-report-jsonl.md
|
|
6
6
|
import { recordOutput, computeOutputRow } from "../../providers/cline/usage.js"; // cline 专属旁路统计(账号×模型,流式也覆盖)
|
|
7
7
|
import { upstreamEcho } from "../../model-trace.js"; // 回显头→日志字段(谁上的/哪个号/为什么/是否冷却)单一来源
|
|
8
|
+
import { recordRelayTalk } from "../../talk-log.js"; // 环形对话日志(问答全文,最近 1 小时):五段流水在此汇合,落盘点只这一个
|
|
9
|
+
import { isEmptyTurnDetail } from "../stream-scan.js"; // 空轮判据单一真相(stream.js 用它决定封不封口,这里用它决定交不交回重试)
|
|
8
10
|
|
|
9
11
|
// 唯一/最后候选没有 failover 去向:首块闸门退化为纯"防连接泄漏",放宽避免误杀慢模型
|
|
10
12
|
// (参考 opencode:zen 通道不设超时;openai responses 硬编码 300s headerTimeout)
|
|
@@ -66,6 +68,7 @@ export function createRelayPipeline({
|
|
|
66
68
|
handlerCtx,
|
|
67
69
|
mark: m2,
|
|
68
70
|
perf0,
|
|
71
|
+
attemptStartMs,
|
|
69
72
|
stages: s2,
|
|
70
73
|
startedAt: sa2,
|
|
71
74
|
streamTimeoutMs: ctxStreamTimeoutMs,
|
|
@@ -100,6 +103,7 @@ export function createRelayPipeline({
|
|
|
100
103
|
const out = await _relay(res, upRes, body, {
|
|
101
104
|
fallback,
|
|
102
105
|
streamTimeoutMs,
|
|
106
|
+
attemptStartMs, // 本次上游尝试起点(performance.now 同源),relay 用它算 preflightMs
|
|
103
107
|
onFirstChunk: (delta) => {
|
|
104
108
|
try { markFn(`ttf-${actual}`); } catch {}
|
|
105
109
|
_evt("relay-first-chunk", { reqId, model: actual, ttfMs: delta, via });
|
|
@@ -125,6 +129,10 @@ export function createRelayPipeline({
|
|
|
125
129
|
detail: out.detail ?? null,
|
|
126
130
|
});
|
|
127
131
|
|
|
132
|
+
// 环形对话日志:只记有真实输出的轮次(空轮交给下面的空转判定另行报错)。
|
|
133
|
+
// 纯旁路:不改闸门、不改计时、不改返回体;任何异常都吞掉,绝不影响转发。
|
|
134
|
+
try { recordRelayTalk({ reqId, model: actual, via, hops, body, out, echo: upstreamEcho(upRes) }); } catch {}
|
|
135
|
+
|
|
128
136
|
// 5a0. 空转 200:流正常结束但零正文零工具调用 → 客户端会报 EMPTY_MODEL_RESPONSE
|
|
129
137
|
// ("The model ended its turn without producing any output");转 failover 而不是
|
|
130
138
|
// 把空轮递给客户端。chatShaped 是前置证据:只有看得出是 chat 轮才判空,
|
|
@@ -132,25 +140,34 @@ export function createRelayPipeline({
|
|
|
132
140
|
// 工具轮豁免:tool_calls 无正文是合法 agent 形态;
|
|
133
141
|
// finish=tool_calls/function_call 兜底豁免(防计数漏检误杀);下游已断开不重试(写给谁看)。
|
|
134
142
|
// 对标 dsh-cline-pass 的 EMPTY_RESPONSE 语义;不记 auto 冷却(空转≠模型坏,重试多半能好)。
|
|
143
|
+
// 判据单一真相在 stream-scan.js:isEmptyTurnDetail —— relay 侧「要不要封口」与这里「要不要重拉」必须同源,否则会出现留了口却不再救、或已终结却仍重拉的错拍
|
|
144
|
+
// heldOpen 也计入:非流式留口返回的是上游原状态码(2xx 变体或中继的 ≥400),只认 200 会漏判 → 没人收场,连接挂死
|
|
135
145
|
const _d = out.detail || {};
|
|
136
|
-
const _emptyTurn = out.status === 200 && !out.timedOut && !out.interrupted && !_d.downstreamClosed &&
|
|
137
|
-
_d.chatShaped === true &&
|
|
138
|
-
(Number(_d.chars) || 0) === 0 && (Number(_d.toolCalls) || 0) === 0 &&
|
|
139
|
-
!["tool_calls", "function_call"].includes(_d.sawFinishReason);
|
|
146
|
+
const _emptyTurn = (out.status === 200 || out.heldOpen === true) && !out.timedOut && !out.interrupted && !_d.downstreamClosed && isEmptyTurnDetail(_d);
|
|
140
147
|
if (_emptyTurn) {
|
|
141
148
|
const _why = _d.sawFinishReason ? ` (finish_reason=${_d.sawFinishReason})` : " (no content, no tool calls)";
|
|
142
149
|
// 错误包络暂扣后下游不再直观看到上游原文:把摘要带进最终报错(截断 200 字),排障不断线。
|
|
143
150
|
const _err = _d.upstreamErrorText ? ` upstream=${String(_d.upstreamErrorText).slice(0, 200)}` : "";
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
// 零正文的成因直接写进报错(不只给现象):finish_reason=length + 大量思考 = 额度被 reasoning 吃光
|
|
152
|
+
// (实测 cline-free deepseek reasoningChars=28151、completion_tokens=8192=max_tokens、chars=0)。
|
|
153
|
+
// 同参重拉必复现,故 serial-trial 的空转重试会顺手把 max_tokens 抬一档(见 emptyRaiseCap)。
|
|
154
|
+
const _lenCapped = _d.sawFinishReason === "length";
|
|
155
|
+
const _hint = _lenCapped && (Number(_d.reasoningChars) || 0) > 0
|
|
156
|
+
? ` [思考 ${Number(_d.reasoningChars) || 0} 字刷满 max_tokens,正文 0 字]`
|
|
157
|
+
: "";
|
|
158
|
+
try { _logError(actual, 502, `empty turn${_why}${_err}${_hint}`); } catch {}
|
|
159
|
+
_evt("upstream-error", { reqId, model: actual, status: 502, message: "empty turn", timing: null, finish: _d.sawFinishReason || null, reasoningChars: Number(_d.reasoningChars) || 0, wroteFrames: Number(_d.wroteChunks) || 0, heldOpen: out.heldOpen === true, ...upstreamEcho(upRes) });
|
|
146
160
|
_evt("fallback", { reqId, from: actual, to: null, reason: "empty turn" });
|
|
147
|
-
let _errMsg = `EMPTY_MODEL_RESPONSE: upstream returned 200 with no content${_why}${_err} — retry or rephrase`;
|
|
161
|
+
let _errMsg = `EMPTY_MODEL_RESPONSE: upstream returned 200 with no content${_why}${_err}${_hint} — retry or rephrase`;
|
|
148
162
|
if (_d.upstreamErrorText && _d.upstreamErrorText.includes("retryAfterSeconds")) {
|
|
149
163
|
_errMsg = _d.upstreamErrorText;
|
|
150
164
|
}
|
|
151
|
-
return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: _errMsg } };
|
|
165
|
+
return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: _errMsg, emptyTurn: true, heldOpen: out.heldOpen === true } };
|
|
152
166
|
}
|
|
153
167
|
|
|
168
|
+
// 上报锚点偏移:relay 入口之前消耗掉的等待(上游排队、建连、provider 为取判决预读的首帧)。
|
|
169
|
+
// 只加给 usage 行的时长;闸门计时与 recordModelStats 一律不碰 —— 见 design D2 与 Non-Goals。
|
|
170
|
+
const anchorMs = Number.isFinite(out?.preflightMs) && out.preflightMs > 0 ? out.preflightMs : 0;
|
|
154
171
|
// 5a. 首块超时未写字节 → 回退(显式 timedOut 字段,status 只是 HTTP 语义展示)
|
|
155
172
|
if (out.timedOut === true) {
|
|
156
173
|
const why = out.detail?.upstreamError ? ` (upstream read error: ${out.detail.upstreamError})` : "";
|
|
@@ -173,15 +190,15 @@ export function createRelayPipeline({
|
|
|
173
190
|
if (out.status === 200) {
|
|
174
191
|
try {
|
|
175
192
|
const u = out.detail?.usage || null;
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
recordChatUsage({ model: normalizeFullId(actual), via, usage: u, interrupted: 1, ttfbMs:
|
|
193
|
+
const endMs = Number.isFinite(out.totalMs) && out.totalMs >= 0 ? out.totalMs + anchorMs : (Date.now() - curStartedAt);
|
|
194
|
+
const firstMs = Number.isFinite(out.ttfMs) && out.ttfMs >= 0 ? out.ttfMs + anchorMs : null;
|
|
195
|
+
recordChatUsage({ model: normalizeFullId(actual), via, usage: u, interrupted: 1, ttfbMs: firstMs, totalMs: endMs, tps: null, reasoningChars: out.detail?.reasoningChars ?? 0, stream: Boolean(body?.stream) }).catch(() => {});
|
|
179
196
|
} catch {}
|
|
180
197
|
}
|
|
181
198
|
_evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, ...upstreamEcho(upRes), detail: out.detail ?? null, fallback, requested, actual });
|
|
182
199
|
_evt("client-response", { requested, actual, via, fallback, status: out.status, reqId, interrupted: true });
|
|
183
200
|
if (plugins?.length) runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body?.stream), durationMs: Date.now() - curStartedAt, via, status: out.status, actual, interrupted: true, fallback }).catch(() => {});
|
|
184
|
-
return { handled: true };
|
|
201
|
+
return { handled: true, wrotePayload: out.detail?.wrotePayload === true };
|
|
185
202
|
}
|
|
186
203
|
|
|
187
204
|
// 5c. 慢速计分 + ok
|
|
@@ -211,6 +228,7 @@ export function createRelayPipeline({
|
|
|
211
228
|
if (out.status === 200) {
|
|
212
229
|
try {
|
|
213
230
|
const isStream = Boolean(body?.stream);
|
|
231
|
+
// —— 状态口径(-status / -model stats 的终生 EMA):本期刻意不动,仍按转发入口量 ——
|
|
214
232
|
let ttfb = isStream ? (out.ttfMs ?? upRes?._t?.ttfbMs ?? null) : null;
|
|
215
233
|
// out.totalMs 为 0 时(非流式 <1ms 四舍五入)回退到 elapsed/durationMs
|
|
216
234
|
const elapsedFallback = Date.now() - curStartedAt;
|
|
@@ -227,9 +245,17 @@ export function createRelayPipeline({
|
|
|
227
245
|
const fullId = normalizeFullId(actual);
|
|
228
246
|
recordModelStats(fullId, { ttfbMs: ttfb, totalMs: total, tps, completionTokens: compTok });
|
|
229
247
|
if (fullId !== actual) recordModelStats(actual, { ttfbMs: ttfb, totalMs: total, tps, completionTokens: compTok });
|
|
248
|
+
// —— 报表口径(usage 行 = -stats 的唯一数据源):把 relay 之前消耗掉的等待补回来 ——
|
|
249
|
+
// 首字 = 本次上游尝试起点 → 网关转发首帧;0ms 是有效样本,只有负值判无效;
|
|
250
|
+
// transport 的 _t.ttfbMs 本就从 fetch 起算,不再叠加锚点(否则双计)。
|
|
251
|
+
const rowTtfb = !isStream ? null
|
|
252
|
+
: Number.isFinite(out.ttfMs) ? (out.ttfMs < 0 ? null : out.ttfMs + anchorMs)
|
|
253
|
+
: (Number.isFinite(upRes?._t?.ttfbMs) ? upRes._t.ttfbMs : null);
|
|
254
|
+
const rowTotal = Number.isFinite(total) ? total + anchorMs : total;
|
|
255
|
+
// 速度恒等:首字与总耗时同加一个常数 → (总−首字) 不变,故沿用 m.tps。
|
|
230
256
|
// 窗口报表:逐请求落 usage(行形状由 usage/record.js 拥有,含 prompt/total ——
|
|
231
257
|
// state 的 modelStats 只存 completion 的 EMA)。只按 canonical 名记一次,避免双计。
|
|
232
|
-
recordChatUsage({ model: fullId, via, usage, ttfbMs:
|
|
258
|
+
recordChatUsage({ model: fullId, via, usage, ttfbMs: rowTtfb, totalMs: rowTotal, tps: m.tps, reasoningChars: out.detail?.reasoningChars ?? 0, stream: isStream }).catch(() => {});
|
|
233
259
|
// cline 旁路记账:流式时 provider 已把账号哈希挂在 upRes 上,这里用消费完的 usage 记一笔。
|
|
234
260
|
// 纯旁路:只调 recordOutput,不改转发/切号/重试;无账号或非 cline 直接跳过。
|
|
235
261
|
if (upRes?.clineAccountId) {
|
|
@@ -243,7 +269,7 @@ export function createRelayPipeline({
|
|
|
243
269
|
_evt("result", { reqId, model: actual, status: out.status, via, timing: upRes?._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, ...upstreamEcho(upRes), detail: out.detail ?? null, fallback, requested, actual });
|
|
244
270
|
_evt("client-response", { requested, actual, via, fallback, status: out.status, reqId });
|
|
245
271
|
if (plugins?.length) runHook(plugins, "request:completed", { reqId, requested, useAuto, hops, stream: Boolean(body?.stream), durationMs: Date.now() - curStartedAt, via, status: out.status, actual, fallback }).catch(() => {});
|
|
246
|
-
return { handled: true };
|
|
272
|
+
return { handled: true, wrotePayload: out.detail?.wrotePayload === true };
|
|
247
273
|
}
|
|
248
274
|
|
|
249
275
|
return { execute };
|
|
@@ -25,6 +25,7 @@ export async function handleViaRoute({
|
|
|
25
25
|
logError,
|
|
26
26
|
mark,
|
|
27
27
|
perf0,
|
|
28
|
+
attemptStartMs,
|
|
28
29
|
stages,
|
|
29
30
|
startedAt,
|
|
30
31
|
plugins,
|
|
@@ -134,6 +135,7 @@ export async function handleViaRoute({
|
|
|
134
135
|
handlerCtx: { ...handlerCtx, model },
|
|
135
136
|
mark,
|
|
136
137
|
perf0,
|
|
138
|
+
attemptStartMs,
|
|
137
139
|
stages,
|
|
138
140
|
startedAt,
|
|
139
141
|
// 借道单路径无第二候选可比:按末位耐心档(默认 120s,MSLXDFF_LAST_CANDIDATE_TIMEOUT_MS 可调/0=不限)
|