mocode-ai 1.5.1 → 1.5.3
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/dist/agent/index.js +2 -0
- package/dist/agent/model-turn.js +42 -10
- package/dist/i18n/index.js +16 -6
- package/dist/llm/index.js +176 -18
- package/dist/llm/providers/anthropic.js +6 -0
- package/dist/llm/stream-interrupt.js +24 -0
- package/dist/repl/commands.js +1 -0
- package/package.json +1 -1
package/dist/agent/index.js
CHANGED
|
@@ -382,6 +382,8 @@ runtimeOrContext = defaultRuntime) {
|
|
|
382
382
|
},
|
|
383
383
|
onStepStart: () => spinner.start(t('agent.thinking')),
|
|
384
384
|
onChatDone: () => spinner.stop(),
|
|
385
|
+
// 退避重试要在状态行可见:否则用户面对的是几十秒到几分钟的静止 spinner,与卡死无异。
|
|
386
|
+
onModelRetry: (r) => spinner.start(t('agent.retrying', { seconds: Math.max(1, Math.round(r.waitMs / 1000)), attempt: r.attempt })),
|
|
385
387
|
// 流式实时用量 → 底栏 context 进度条左侧 chip;轮末由 repl 清空。
|
|
386
388
|
onLiveUsage: (u) => layout.setLiveUsage(u),
|
|
387
389
|
onTextEnd: () => {
|
package/dist/agent/model-turn.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import { estimatePromptTokens, estimateTokens, isContextLengthError, } from '../llm/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* 沿 cause 链(≤3 层)取第一个 errno,供 trace 取证。
|
|
4
|
+
* undici 把底层 errno 挂在 cause 上(`TypeError: fetch failed` → cause `read ECONNRESET`),
|
|
5
|
+
* openai SDK 再包一层 APIConnectionError 后,顶层 code 恒为 undefined;不留这一项,
|
|
6
|
+
* 事后只能看到一个 'Error',无法区分 DNS 失败 / 连接被拒 / TLS 握手失败。
|
|
7
|
+
*/
|
|
8
|
+
function traceCauseCode(err) {
|
|
9
|
+
let cur = err;
|
|
10
|
+
for (let depth = 0; depth <= 3 && cur && typeof cur === 'object'; depth++) {
|
|
11
|
+
const e = cur;
|
|
12
|
+
if (typeof e.code === 'string')
|
|
13
|
+
return e.code;
|
|
14
|
+
cur = e.cause;
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
2
18
|
/** Executes context preparation plus exactly one model step, including the single overflow retry path. */
|
|
3
19
|
export async function runModelTurn(input) {
|
|
4
20
|
const { opts, ctx, history, historyManager, runtimeContextState, scheduler, contextTrimmer, modelRunner, activeTools, runPolicy, step, cacheState, turnLifecycle, cancellationLifecycle, rebuildHistoryIndexes, } = input;
|
|
@@ -112,28 +128,44 @@ export async function runModelTurn(input) {
|
|
|
112
128
|
onText,
|
|
113
129
|
onToolCall,
|
|
114
130
|
onProgress: reportLive,
|
|
115
|
-
onRetry: (retry) =>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
131
|
+
onRetry: (retry) => {
|
|
132
|
+
emitTrace('model_retry', {
|
|
133
|
+
model: requestModel,
|
|
134
|
+
provider,
|
|
135
|
+
attempt: retry.attempt,
|
|
136
|
+
nextAttempt: retry.nextAttempt,
|
|
137
|
+
waitMs: retry.waitMs,
|
|
138
|
+
code: retry.code,
|
|
139
|
+
});
|
|
140
|
+
// 退避最长可到 30s、最坏累计数分钟。只写 trace 的话用户看到的就是「卡住不动」,
|
|
141
|
+
// 与「直接报错终止」一样不可解释 —— 所以同时转达给宿主做可见反馈。
|
|
142
|
+
hooks.onModelRetry?.(retry);
|
|
143
|
+
},
|
|
123
144
|
};
|
|
124
145
|
const runChatOnce = async () => {
|
|
125
146
|
try {
|
|
126
147
|
return await modelRunner.run({ history: requestHistory, handlers: chatHandlers, tools: activeTools }, signal);
|
|
127
148
|
}
|
|
128
149
|
catch (error) {
|
|
129
|
-
const errorValue = error && typeof error === 'object'
|
|
150
|
+
const errorValue = error && typeof error === 'object'
|
|
151
|
+
? error
|
|
152
|
+
: undefined;
|
|
130
153
|
emitTrace('model_end', {
|
|
131
154
|
model: requestModel,
|
|
132
155
|
provider,
|
|
133
156
|
status: signal?.aborted ? 'aborted' : 'error',
|
|
134
157
|
code: typeof errorValue?.status === 'number'
|
|
135
158
|
? `HTTP_${errorValue.status}`
|
|
136
|
-
:
|
|
159
|
+
: // 构造器名优先于 `name`:SDK 的 APIError 家族 err.name 恒为 'Error'(无信息量),
|
|
160
|
+
// 构造器名才分得出 APIConnectionError(建连失败)/ APIError(流内报错)。
|
|
161
|
+
(errorValue?.code ??
|
|
162
|
+
error?.constructor?.name ??
|
|
163
|
+
errorValue?.name ??
|
|
164
|
+
'MODEL_ERROR'),
|
|
165
|
+
// 取证用:错误文案与 cause 链上的 errno。没有这两项时,trace 只留一个 'Error',
|
|
166
|
+
// 事后无法判断到底是 DNS 失败、连接被拒还是 TLS 握手失败(实测踩过)。
|
|
167
|
+
message: typeof errorValue?.message === 'string' ? errorValue.message.slice(0, 300) : undefined,
|
|
168
|
+
causeCode: traceCauseCode(error),
|
|
137
169
|
durationMs: Date.now() - modelStartedAt,
|
|
138
170
|
});
|
|
139
171
|
throw error;
|
package/dist/i18n/index.js
CHANGED
|
@@ -190,6 +190,7 @@ const zhCN = {
|
|
|
190
190
|
'agent.thinking': '思考中',
|
|
191
191
|
'agent.generating': '生成 {tool}',
|
|
192
192
|
'agent.executing': '执行 {tool}',
|
|
193
|
+
'agent.retrying': '连接异常,{seconds}s 后重试(第 {attempt} 次)',
|
|
193
194
|
'agent.noReply': '(无回复)',
|
|
194
195
|
'agent.maxSteps': '达到最大步数({count}),本轮停止。',
|
|
195
196
|
'agent.aborted': '(已中断)',
|
|
@@ -225,6 +226,7 @@ const zhCN = {
|
|
|
225
226
|
'repl.llmTimeoutError': '请求超时:模型服务响应过慢。可重试,或输入 /model switch 换一个模型。',
|
|
226
227
|
'repl.llmNetworkError': '网络错误:无法连接 {base}。检查网络连接,或输入 /model 核对 baseURL 配置。',
|
|
227
228
|
'repl.llmContextError': '上下文超长:输入 /compact 压缩会话后重试。',
|
|
229
|
+
'repl.llmServerError': '模型服务端在生成中途中断(错误来自响应流内部,非 HTTP 层)。该轮未写入会话历史,直接重发即可;若反复出现,用 /model switch 换模型或 /compact 压缩会话。',
|
|
228
230
|
'permission.path': '路径: {value}',
|
|
229
231
|
'permission.command': '命令: {value}',
|
|
230
232
|
'permission.task': '任务: {value}',
|
|
@@ -518,6 +520,7 @@ const en = {
|
|
|
518
520
|
'agent.thinking': 'Thinking',
|
|
519
521
|
'agent.generating': 'Generating {tool}',
|
|
520
522
|
'agent.executing': 'Running {tool}',
|
|
523
|
+
'agent.retrying': 'Connection lost, retrying in {seconds}s (attempt {attempt})',
|
|
521
524
|
'agent.noReply': '(no reply)',
|
|
522
525
|
'agent.maxSteps': 'Maximum steps reached ({count}); this turn has stopped.',
|
|
523
526
|
'agent.aborted': '(aborted)',
|
|
@@ -553,6 +556,7 @@ const en = {
|
|
|
553
556
|
'repl.llmTimeoutError': 'Request timed out: the model service responded too slowly. Retry, or run /model switch to use another model.',
|
|
554
557
|
'repl.llmNetworkError': 'Network error: cannot reach {base}. Check your connection, or run /model to verify the baseURL.',
|
|
555
558
|
'repl.llmContextError': 'Context too long: run /compact to compress the session, then retry.',
|
|
559
|
+
'repl.llmServerError': 'The model backend aborted mid-stream (the error came from inside the response stream). This turn was not written to session history — just resend it. If it repeats, run /model switch or /compact.',
|
|
556
560
|
'permission.path': 'Path: {value}',
|
|
557
561
|
'permission.command': 'Command: {value}',
|
|
558
562
|
'permission.task': 'Task: {value}',
|
|
@@ -658,7 +662,13 @@ const resources = {
|
|
|
658
662
|
'zh-CN': zhCN,
|
|
659
663
|
en,
|
|
660
664
|
};
|
|
661
|
-
|
|
665
|
+
/**
|
|
666
|
+
* 未显式配置语言时的默认值。刻意固定为 en 而**不**探测系统 locale:
|
|
667
|
+
* 中文 Windows 的 `Intl.DateTimeFormat().resolvedOptions().locale` 为 zh-CN,
|
|
668
|
+
* 会让新装用户一开就是中文界面。要中文须显式 /language zh-CN 或 MOCODE_LANGUAGE=zh-CN。
|
|
669
|
+
*/
|
|
670
|
+
export const DEFAULT_LANGUAGE = 'en';
|
|
671
|
+
let currentLanguage = DEFAULT_LANGUAGE;
|
|
662
672
|
export function normalizeLanguage(value) {
|
|
663
673
|
const normalized = value?.trim().toLowerCase().replace('_', '-');
|
|
664
674
|
if (!normalized)
|
|
@@ -669,12 +679,12 @@ export function normalizeLanguage(value) {
|
|
|
669
679
|
return 'en';
|
|
670
680
|
return null;
|
|
671
681
|
}
|
|
682
|
+
/**
|
|
683
|
+
* 解析生效语言:只认显式配置(MOCODE_LANGUAGE / ~/.mocode/config / 项目级 config),
|
|
684
|
+
* 无法识别或未配置时落 DEFAULT_LANGUAGE(en)。不再回退系统 locale。
|
|
685
|
+
*/
|
|
672
686
|
export function detectLanguage(preferred) {
|
|
673
|
-
|
|
674
|
-
if (explicit)
|
|
675
|
-
return explicit;
|
|
676
|
-
const system = normalizeLanguage(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || Intl.DateTimeFormat().resolvedOptions().locale);
|
|
677
|
-
return system ?? 'zh-CN';
|
|
687
|
+
return normalizeLanguage(preferred) ?? DEFAULT_LANGUAGE;
|
|
678
688
|
}
|
|
679
689
|
export function setLanguage(language) {
|
|
680
690
|
currentLanguage = language;
|
package/dist/llm/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getPlanDisabledTools, getProfileDisabledTools } from '../tools/constant
|
|
|
5
5
|
import { imageSizeFromDataUrl } from '../attachments/image.js';
|
|
6
6
|
import { ThinkTagFilter } from './think-filter.js';
|
|
7
7
|
import { sanitizeToolSchemas } from './tool-schema.js';
|
|
8
|
+
import { isMarkedStreamInterrupted } from './stream-interrupt.js';
|
|
8
9
|
import { defaultAnthropicFetch, anthropicChatOnce } from './providers/anthropic.js';
|
|
9
10
|
import { registerModelProvider, getModelProvider, listModelProviders } from './provider.js';
|
|
10
11
|
// 强制关闭第三方调试日志泄漏:openai SDK 在 process.env.DEBUG === 'true' 时用裸
|
|
@@ -29,6 +30,12 @@ const RETRY_MAX_ATTEMPTS = 10;
|
|
|
29
30
|
const RETRY_BASE_MS = 1000;
|
|
30
31
|
const RETRY_MAX_MS = 30000;
|
|
31
32
|
const RETRY_JITTER = 0.2;
|
|
33
|
+
/**
|
|
34
|
+
* 「流中途故障」(见 isStreamInterruptedError)单独的重试预算,不共享 HTTP 层的 10 次。
|
|
35
|
+
* 这类错误绝大多数是推理后端瞬时故障,重试两次足够;若它其实是永久故障,10 次指数退避
|
|
36
|
+
* (封顶 30s)会让用户干等近两分钟,代价远大于收益。
|
|
37
|
+
*/
|
|
38
|
+
const STREAM_RETRY_MAX_ATTEMPTS = 2;
|
|
32
39
|
/**
|
|
33
40
|
* 流式响应里出现的推理模型自创 `think` 标签(DeepSeek R1 / Qwen3 / 部分自训模型):
|
|
34
41
|
* 与 OpenAI 兼容协议的独立 `reasoning_content` 字段不同,这些模型把 thinking 直接嵌进 content
|
|
@@ -100,7 +107,9 @@ export function isRetryableError(err, signal) {
|
|
|
100
107
|
if (!err || typeof err !== 'object')
|
|
101
108
|
return false;
|
|
102
109
|
const e = err;
|
|
103
|
-
|
|
110
|
+
// SDK 的 APIError 家族不设 this.name(见下),判类别一律用构造器名。
|
|
111
|
+
const ctor = e.constructor?.name ?? '';
|
|
112
|
+
if (e.name === 'AbortError' || e.name === 'APIUserAbortError' || ctor === 'APIUserAbortError')
|
|
104
113
|
return false;
|
|
105
114
|
// OpenAI SDK APIError 走 status 分支(覆盖 4xx/5xx/429)
|
|
106
115
|
const status = e.status;
|
|
@@ -111,25 +120,138 @@ export function isRetryableError(err, signal) {
|
|
|
111
120
|
return true;
|
|
112
121
|
return false;
|
|
113
122
|
}
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
if (code
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
code === 'ECONNREFUSED' ||
|
|
121
|
-
code === 'EPIPE') {
|
|
123
|
+
// 证书 / 协议层不匹配是**永久性**错误,重试只会白等满退避(10 次≈两分钟)。
|
|
124
|
+
// 必须先于下面的宽兜底判定:fetch failed / cause 里的证书错文案都会被宽兜底捞走。
|
|
125
|
+
if (causeChainFrames(err).some((f) => (!!f.code && FATAL_TRANSPORT_CODE.test(f.code)) || (!!f.message && FATAL_TRANSPORT_MESSAGE.test(f.message))))
|
|
126
|
+
return false;
|
|
127
|
+
// Node 网络错 errno:顶层没有就沿 cause 链找(undici 把 errno 藏在 cause 里)。
|
|
128
|
+
if (causeChainFrames(err).some((f) => !!f.code && RETRYABLE_ERRNO.has(f.code)))
|
|
122
129
|
return true;
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
|
|
130
|
+
// OpenAI SDK 的网络错类(无 status)。必须用**构造器名**:SDK 的 APIError 家族只做
|
|
131
|
+
// `super(message)`,从不设 this.name(`err.name` 恒为 'Error')—— 旧代码这里写
|
|
132
|
+
// `e.name === 'APIConnectionError'` 是永不命中的死分支,于是建连失败(DNS / 连接被拒 /
|
|
133
|
+
// TLS 握手 / 半路断流)一次即抛、整轮 run 直接终止(trace 里 model_end.code 只剩 'Error')。
|
|
134
|
+
if (ctor === 'APIConnectionError' || ctor === 'APIConnectionTimeoutError')
|
|
126
135
|
return true;
|
|
127
|
-
//
|
|
128
|
-
|
|
136
|
+
// 兜底:文案。部分代理把错误折叠成普通 Error;SDK 的 APIConnectionError 默认文案就是
|
|
137
|
+
// 'Connection error.'(负载里没有任何细节,`code` 也为 undefined,只能靠文案兜)。
|
|
138
|
+
const msg = typeof e.message === 'string' ? e.message : '';
|
|
139
|
+
if (/\btime(d|ed)?\s*out\b|ETIMEDOUT/i.test(msg))
|
|
140
|
+
return true;
|
|
141
|
+
if (/^connection error\.?$/i.test(msg.trim()) || /\bfetch failed\b/i.test(msg))
|
|
129
142
|
return true;
|
|
130
|
-
}
|
|
131
143
|
return false;
|
|
132
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* 「传输/协议层的流中断」判据:连接被掐断、响应提前结束、cause 里藏着 errno。
|
|
147
|
+
*
|
|
148
|
+
* 实测(Node 22 + openai SDK + 服务端 destroy socket):HTTP 200 建连并已吐出 chunk 后
|
|
149
|
+
* 连接被掐,抛的是 `Error: Premature close`(code `ERR_STREAM_PREMATURE_CLOSE`)——
|
|
150
|
+
* 既不是 APIError 也没有 status,`isRetryableError` 一条都不命中。网关超时、NAT 回收、
|
|
151
|
+
* 推理进程 OOM 被杀、k8s pod 重启都属于这一类,是线上最常见的「半路断流」。
|
|
152
|
+
*
|
|
153
|
+
* undici 还会把底层 errno 藏在 `cause` 里(`TypeError: fetch failed` → cause
|
|
154
|
+
* `SocketError: other side closed` / `read ECONNRESET`),只看顶层必然漏,故沿 cause 链找。
|
|
155
|
+
*/
|
|
156
|
+
const STREAM_BREAK_CODES = new Set([
|
|
157
|
+
'ERR_STREAM_PREMATURE_CLOSE',
|
|
158
|
+
'ERR_STREAM_DESTROYED',
|
|
159
|
+
'ERR_STREAM_UNABLE_TO_PIPE',
|
|
160
|
+
'ERR_HTTP2_STREAM_CANCEL',
|
|
161
|
+
'UND_ERR_SOCKET',
|
|
162
|
+
'ECONNRESET',
|
|
163
|
+
'ECONNABORTED',
|
|
164
|
+
'EPIPE',
|
|
165
|
+
'ETIMEDOUT',
|
|
166
|
+
'ERR_SOCKET_CONNECTION_TIMEOUT',
|
|
167
|
+
]);
|
|
168
|
+
const STREAM_BREAK_MESSAGE = /premature close|other side closed|socket hang up|\bterminated\b|stream (?:closed|ended) (?:prematurely|unexpectedly)|econnreset|econnaborted/i;
|
|
169
|
+
/**
|
|
170
|
+
* 「连接层」可重试 errno。与 STREAM_BREAK_CODES 的区别:这些在**建连/发请求**阶段就失败
|
|
171
|
+
* (DNS 解析、拒绝连接、路由不可达、连接超时),压根没有响应流可言,但处置一样 —— 重试。
|
|
172
|
+
* 旧实现只在顶层 `err.code` 上查,而 undici 把 errno 埋在 cause 里,永远查不到。
|
|
173
|
+
*/
|
|
174
|
+
const RETRYABLE_ERRNO = new Set([
|
|
175
|
+
'ETIMEDOUT',
|
|
176
|
+
'ECONNRESET',
|
|
177
|
+
'ENOTFOUND',
|
|
178
|
+
'EAI_AGAIN',
|
|
179
|
+
'ECONNREFUSED',
|
|
180
|
+
'EPIPE',
|
|
181
|
+
'ECONNABORTED',
|
|
182
|
+
'ENETUNREACH',
|
|
183
|
+
'EHOSTUNREACH',
|
|
184
|
+
'ERR_SOCKET_CONNECTION_TIMEOUT',
|
|
185
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
186
|
+
'UND_ERR_SOCKET',
|
|
187
|
+
]);
|
|
188
|
+
/** 证书 / TLS 协议不匹配:重试必然再错,判死以免白等退避。 */
|
|
189
|
+
const FATAL_TRANSPORT_CODE = /^(?:ERR_SSL|ERR_TLS|ERR_OSSL|UNABLE_TO_VERIFY|DEPTH_ZERO|SELF_SIGNED|CERT_|EPROTO)/;
|
|
190
|
+
const FATAL_TRANSPORT_MESSAGE = /certificate|self[- ]signed|\bEPROTO\b|wrong version number|unsupported protocol/i;
|
|
191
|
+
/**
|
|
192
|
+
* 沿错误自身与 cause 链(≤3 层)收集每层的 code / message。
|
|
193
|
+
*
|
|
194
|
+
* 为什么必须看 cause:undici 把底层 errno 挂在 cause 上(`TypeError: fetch failed` →
|
|
195
|
+
* cause `Error: read ECONNRESET`,code 在 cause 里),openai SDK 又把那次 fetch 失败再包一层
|
|
196
|
+
* `APIConnectionError({ cause })`(`core.mjs` 的 catch 分支)。只看顶层 `err.code` / `err.message`
|
|
197
|
+
* 永远是 undefined / 'Connection error.' —— 这正是这类故障长期无法被识别、无法重试的根因。
|
|
198
|
+
*/
|
|
199
|
+
function causeChainFrames(err) {
|
|
200
|
+
const frames = [];
|
|
201
|
+
let cur = err;
|
|
202
|
+
for (let depth = 0; depth <= 3; depth++) {
|
|
203
|
+
if (!cur || typeof cur !== 'object')
|
|
204
|
+
break;
|
|
205
|
+
const e = cur;
|
|
206
|
+
frames.push({
|
|
207
|
+
code: typeof e.code === 'string' ? e.code : undefined,
|
|
208
|
+
message: typeof e.message === 'string' ? e.message : undefined,
|
|
209
|
+
});
|
|
210
|
+
cur = e.cause;
|
|
211
|
+
}
|
|
212
|
+
return frames;
|
|
213
|
+
}
|
|
214
|
+
/** 错误链(含 cause)上是否存在「连接被掐断」的信号。 */
|
|
215
|
+
function hasStreamBreakSignal(err) {
|
|
216
|
+
return causeChainFrames(err).some((f) => (!!f.code && STREAM_BREAK_CODES.has(f.code)) || (!!f.message && STREAM_BREAK_MESSAGE.test(f.message)));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 判定「服务端在响应流中途报错 / 流被中途掐断」——HTTP 层已建连并开始流式返回(状态码 200),
|
|
220
|
+
* 失败发生在流内部。两条命中路径:
|
|
221
|
+
*
|
|
222
|
+
* ① **SSE error chunk**:SDK 的两条流内抛错路径(node_modules/openai/streaming.mjs:41 / :57)
|
|
223
|
+
* 都构造 `new APIError(undefined, data.error, …)`:status 为 undefined,code/message 取自
|
|
224
|
+
* 负载,例如 DashScope 的 `ClientError` + `Backend buffer overflow.`。
|
|
225
|
+
* ② **传输层断流**:见 hasStreamBreakSignal;或 provider 显式打了标记
|
|
226
|
+
* (`providers/anthropic.ts` 的 SSE `event: error`,由 markStreamInterrupted 标注)。
|
|
227
|
+
*
|
|
228
|
+
* 为什么要单独识别:isRetryableError 只认 status(429 / 5xx)、Node errno 白名单与 timeout 字样,
|
|
229
|
+
* 这三类都不沾 → 被当成「不可重试的客户端请求错」一次即抛,整轮 run 直接终止。但它们的真实语义
|
|
230
|
+
* 是「服务端/链路在生成到一半时挂了」,属瞬时故障,重试是正确处置(已实测同一会话更大 prompt 可成功)。
|
|
231
|
+
*
|
|
232
|
+
* 只认 APIError 本身,不认子类:APIConnectionError / APIConnectionTimeoutError 是**建连阶段**
|
|
233
|
+
* 失败,归 isRetryableError 的构造器名分支(它有 10 次 HTTP 层预算,更合适);而 APIUserAbortError
|
|
234
|
+
* 是用户中断,绝不能重试。
|
|
235
|
+
* 判据用构造器名而非 instanceof:同进程若存在 openai 的多份模块实例(ESM/CJS 混载),
|
|
236
|
+
* instanceof 会失配。
|
|
237
|
+
*
|
|
238
|
+
* 注意:这里只判定「类别」;能否真的重试还要看调用方的「本次尝试零产出」前提。
|
|
239
|
+
*/
|
|
240
|
+
export function isStreamInterruptedError(err) {
|
|
241
|
+
if (!err || typeof err !== 'object')
|
|
242
|
+
return false;
|
|
243
|
+
const e = err;
|
|
244
|
+
if (e.name === 'AbortError' || e.name === 'APIUserAbortError')
|
|
245
|
+
return false;
|
|
246
|
+
if (isMarkedStreamInterrupted(err))
|
|
247
|
+
return true;
|
|
248
|
+
// 有 HTTP 状态码 = 建连阶段失败,交由 isRetryableError 既有规则处理。
|
|
249
|
+
if (typeof e.status === 'number')
|
|
250
|
+
return false;
|
|
251
|
+
if ((e.constructor?.name ?? '') === 'APIError')
|
|
252
|
+
return true;
|
|
253
|
+
return hasStreamBreakSignal(err);
|
|
254
|
+
}
|
|
133
255
|
/**
|
|
134
256
|
* 判定一次失败是否是「请求上下文超长」(后端实测拒绝了我们的 prompt)。
|
|
135
257
|
*
|
|
@@ -187,6 +309,13 @@ export function classifyChatError(msg) {
|
|
|
187
309
|
if (/\benotfound\b|\beconnrefused\b|\beai_again\b|getaddrinfo|fetch failed|network error|certificate/.test(m) ||
|
|
188
310
|
/无法连接|网络(?:错误|异常|不可用)|域名解析/.test(msg))
|
|
189
311
|
return 'network';
|
|
312
|
+
// 服务端在生成中途故障(流内报错,无 HTTP 状态码):推理后端崩溃 / 网关缓冲溢出等瞬时故障。
|
|
313
|
+
// 已实测 DashScope compatible-mode 会回 `code=ClientError` + `message="Backend buffer overflow."`。
|
|
314
|
+
// 走到这里说明重试预算已用尽(或已有半截输出不能重试),给「直接重发」的引导而非裸报错。
|
|
315
|
+
if (/backend\s+buffer|buffer\s+overflow|engine\s+(?:crash|error|failed|aborted)|internal\s+server\s+error|server\s+(?:overloaded|busy|unavailable)/.test(m) ||
|
|
316
|
+
/premature close|other side closed/.test(m) ||
|
|
317
|
+
/后端.{0,10}(?:溢出|崩溃|异常)|服务(?:端|器)(?:内部)?(?:错误|异常|繁忙|不可用)/.test(msg))
|
|
318
|
+
return 'server';
|
|
190
319
|
return null;
|
|
191
320
|
}
|
|
192
321
|
/** 从 OpenAI APIError.headers 解析 Retry-After(秒);不支持或缺失返回 undefined。封顶 RETRY_MAX_MS。 */
|
|
@@ -339,7 +468,9 @@ function retryErrorCode(error) {
|
|
|
339
468
|
const value = error;
|
|
340
469
|
if (typeof value.status === 'number')
|
|
341
470
|
return `HTTP_${value.status}`;
|
|
342
|
-
|
|
471
|
+
// 优先构造器名:SDK 的 APIError 家族 `err.name` 恒为 'Error'(无信息量),
|
|
472
|
+
// 构造器名才区分得出是 APIConnectionError(建连失败)还是流内 APIError。
|
|
473
|
+
return value.code ?? value.constructor?.name ?? value.name ?? 'RETRYABLE_ERROR';
|
|
343
474
|
}
|
|
344
475
|
/** Bind chat dispatch, retries and built-in providers to one explicit runtime. */
|
|
345
476
|
export function createChatTransport(runtime) {
|
|
@@ -352,23 +483,50 @@ toolsOverride) {
|
|
|
352
483
|
}
|
|
353
484
|
async function chatWithRuntime(runtime, messages, handlers, signal, toolsOverride) {
|
|
354
485
|
let lastErr;
|
|
486
|
+
// 本次尝试是否已向调用方产出过内容(可见文本 / 工具名)。用于判断「流中途故障可否安全重试」:
|
|
487
|
+
// 重试会从请求头重放,已流出并显示在内容区的半截文本会再写一遍(history 因异常未落,倒是不脏)。
|
|
488
|
+
// 用包装 handler 而非改各 provider 内部,OpenAI / Anthropic 两条路径统一生效。
|
|
489
|
+
let producedOutput = false;
|
|
490
|
+
const guardedHandlers = {
|
|
491
|
+
...handlers,
|
|
492
|
+
onText: (text) => {
|
|
493
|
+
producedOutput = true;
|
|
494
|
+
handlers.onText?.(text);
|
|
495
|
+
},
|
|
496
|
+
onToolCall: (name) => {
|
|
497
|
+
producedOutput = true;
|
|
498
|
+
handlers.onToolCall?.(name);
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
// 流中途故障用独立计数,避免与 HTTP 层共享 10 次预算(理由见 STREAM_RETRY_MAX_ATTEMPTS)。
|
|
502
|
+
let streamRetries = 0;
|
|
355
503
|
for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
|
|
356
504
|
if (signal?.aborted) {
|
|
357
505
|
throw new DOMException('This operation was aborted', 'AbortError');
|
|
358
506
|
}
|
|
507
|
+
producedOutput = false;
|
|
359
508
|
try {
|
|
360
509
|
const provider = getModelProvider(runtime.config.provider);
|
|
361
510
|
if (!provider) {
|
|
362
511
|
// 未注册的 provider:给出可用名单,避免悄悄落到错误实现。
|
|
363
512
|
throw new Error(`未知的 LLM provider "${runtime.config.provider}";已注册:${listModelProviders().join(', ') || '(空)'}`);
|
|
364
513
|
}
|
|
365
|
-
return await provider.chatOnce(messages,
|
|
514
|
+
return await provider.chatOnce(messages, guardedHandlers, signal, toolsOverride, runtime);
|
|
366
515
|
}
|
|
367
516
|
catch (err) {
|
|
368
517
|
lastErr = err;
|
|
369
|
-
|
|
518
|
+
// 流中途故障的额外可重试窗口:仅当本次尝试零产出(否则重试会重放半截文本)、用户没有中断、
|
|
519
|
+
// 且未超它的独立预算。HTTP 层规则(isRetryableError)保持原样,两条判定取并集。
|
|
520
|
+
// 用户中断必须显式排除:undici 在 abort 时也可能抛「连接被掐断」形态的错,只看错误本身会误判。
|
|
521
|
+
const streamRetryable = !signal?.aborted &&
|
|
522
|
+
isStreamInterruptedError(err) &&
|
|
523
|
+
!producedOutput &&
|
|
524
|
+
streamRetries < STREAM_RETRY_MAX_ATTEMPTS;
|
|
525
|
+
if (attempt >= RETRY_MAX_ATTEMPTS || (!isRetryableError(err, signal) && !streamRetryable)) {
|
|
370
526
|
throw err;
|
|
371
527
|
}
|
|
528
|
+
if (streamRetryable)
|
|
529
|
+
streamRetries++;
|
|
372
530
|
const wait = computeBackoff(attempt, getRetryAfterMs(err));
|
|
373
531
|
const retry = {
|
|
374
532
|
attempt,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { config, getActiveModel } from '../../config/index.js';
|
|
2
|
+
import { markStreamInterrupted } from '../stream-interrupt.js';
|
|
2
3
|
let fetchImplOverride = null;
|
|
3
4
|
/** 仅供单测注入;生产路径使用 Node 18+ 全局 fetch。 */
|
|
4
5
|
export function __setAnthropicFetchImpl(impl) {
|
|
@@ -292,6 +293,11 @@ export async function anthropicChatOnce(messages, handlers, signal, tools, runti
|
|
|
292
293
|
const error = event.error;
|
|
293
294
|
const thrown = new Error(typeof error?.message === 'string' ? error.message : 'Anthropic stream error');
|
|
294
295
|
thrown.name = typeof error?.type === 'string' ? error.type : 'AnthropicStreamError';
|
|
296
|
+
// 打「流中途中断」标记:HTTP 层已 200 建连说明请求本身被接受了,流内报错必然是服务端侧
|
|
297
|
+
// (overloaded_error / api_error 等瞬时故障)。没有这个标记,它就是个无 status 的普通
|
|
298
|
+
// Error → isRetryableError 一条都不命中 → 整轮 run 直接终止、一次都不重试。
|
|
299
|
+
// 真正的重试门槛由 chatWithRuntime 的「本次尝试零产出」前提把关。
|
|
300
|
+
markStreamInterrupted(thrown);
|
|
295
301
|
throw thrown;
|
|
296
302
|
}
|
|
297
303
|
if (type === 'message_start') {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「流中途被打断」的显式标记。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:能否安全重试取决于「错误是否发生在响应流**里面**」(HTTP 层已 200 建连),
|
|
5
|
+
* 而这类错误的判定只能由**抛错方**知道 —— OpenAI 兼容路径是 SDK 造的 status-less `APIError`,
|
|
6
|
+
* Anthropic 路径是 SSE `event: error`(一个 name 被设成供应商错误类型的普通 Error)。
|
|
7
|
+
* 与其在 transport 层猜 vendor 的错误名,不如让 provider 在抛错时打标。
|
|
8
|
+
*
|
|
9
|
+
* 独立成 leaf 模块(零 import):`llm/index.ts` 与 `llm/providers/*.ts` 都要用它,
|
|
10
|
+
* 而 providers 只对 index 做 type-only import(见 provider.ts 头注),放 index 会成环。
|
|
11
|
+
*
|
|
12
|
+
* 只标记「错误类别」,不代表可以无条件重试:调用方仍必须叠加「本次尝试零产出」前提
|
|
13
|
+
* (见 chatWithRuntime 的 producedOutput),否则重试会在内容区重放半截文本。
|
|
14
|
+
*/
|
|
15
|
+
const interrupted = new WeakSet();
|
|
16
|
+
/** 由 provider 在「响应流内部报错」时调用,标记该错误为可重试的流中断。 */
|
|
17
|
+
export function markStreamInterrupted(err) {
|
|
18
|
+
if (err && typeof err === 'object')
|
|
19
|
+
interrupted.add(err);
|
|
20
|
+
}
|
|
21
|
+
/** 该错误是否被标记为流中途中断。 */
|
|
22
|
+
export function isMarkedStreamInterrupted(err) {
|
|
23
|
+
return !!err && typeof err === 'object' && interrupted.has(err);
|
|
24
|
+
}
|
package/dist/repl/commands.js
CHANGED
|
@@ -148,6 +148,7 @@ export const LLM_ERROR_HINT_KEYS = {
|
|
|
148
148
|
timeout: 'repl.llmTimeoutError',
|
|
149
149
|
network: 'repl.llmNetworkError',
|
|
150
150
|
context: 'repl.llmContextError',
|
|
151
|
+
server: 'repl.llmServerError',
|
|
151
152
|
};
|
|
152
153
|
/** /help 的分组:按使用场景归组,组内保持菜单树顺序。未列入的顶层命令兜底进「其他」。 */
|
|
153
154
|
export const HELP_GROUPS = [
|