@luolingp/request-doctor 0.0.0-stage → 0.11.0

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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -3
  3. package/client.js +174 -0
  4. package/cordis.patch.yml +16 -0
  5. package/docs/00-/346/226/207/346/241/243/347/264/242/345/274/225.md +26 -0
  6. package/docs/01-/350/275/257/344/273/266/351/234/200/346/261/202/350/247/204/346/240/274/350/257/264/346/230/216.md +191 -0
  7. package/docs/02-/350/275/257/344/273/266/350/256/276/350/256/241/350/257/264/346/230/216.md +391 -0
  8. package/docs/03-/350/275/257/344/273/266/346/265/213/350/257/225/346/212/245/345/221/212.md +212 -0
  9. package/docs/04-/345/256/211/350/243/205/351/203/250/347/275/262/350/257/264/346/230/216.md +176 -0
  10. package/docs/05-/347/224/250/346/210/267/346/211/213/345/206/214.md +181 -0
  11. package/docs/06-/350/275/257/344/273/266/350/264/250/351/207/217/345/272/246/351/207/217/346/212/245/345/221/212.md +109 -0
  12. package/docs/quality-report.json +27 -0
  13. package/icon.svg +12 -0
  14. package/index.js +464 -0
  15. package/lib/arbitrate.js +45 -0
  16. package/lib/cache-store.js +79 -0
  17. package/lib/classify.js +389 -0
  18. package/lib/compat-env.js +490 -0
  19. package/lib/compat-repair.js +196 -0
  20. package/lib/compat.js +332 -0
  21. package/lib/diagnostics.js +77 -0
  22. package/lib/env-verify.js +160 -0
  23. package/lib/install-bundle.js +199 -0
  24. package/lib/install-rescue.js +161 -0
  25. package/lib/install-support.js +206 -0
  26. package/lib/model-repair.js +190 -0
  27. package/lib/package-fetch.js +181 -0
  28. package/lib/plugin-discovery.js +232 -0
  29. package/lib/repair-listeners.js +126 -0
  30. package/lib/retry-ledger.js +67 -0
  31. package/lib/runtime-probe.js +171 -0
  32. package/lib/semver.js +159 -0
  33. package/lib/sleep.js +18 -0
  34. package/lib/stat-cache.js +67 -0
  35. package/lib/symbol-alias.js +44 -0
  36. package/lib/timers.js +39 -0
  37. package/lib/version-repair.js +495 -0
  38. package/lib/version-search.js +44 -0
  39. package/locale/en.json +17 -0
  40. package/locale/zh.json +17 -0
  41. package/package.json +51 -3
@@ -0,0 +1,389 @@
1
+ // 纯函数:判断一次失败到底是不是本机这边能挽回的,以及重试该怎么排。
2
+ //
3
+ // 这层不依赖任何运行时服务,所以可以直接 node --test 跑。真正把判断变成动作的
4
+ // 是 index.js 和 lib 里那几个修复模块。
5
+ //
6
+ // 分类的粒度按「修复动作」来分,不是按报错原文分。同一句话在两种上下文里可能要
7
+ // 不同处理,所以每一类都带上策略:重试、先压缩、先修历史、先换凭据、或者干脆放弃。
8
+
9
+ // 这些字样说明失败出在本机传输栈,而不是对方服务。错误对象本身的 code 经常不说明问题,
10
+ // 真正有用的是 message 和 cause 链,所以要顺着链把文本拼起来再匹配。
11
+ const NETWORK_PATTERNS = [
12
+ /fetch failed/i,
13
+ /failed to fetch/i,
14
+ /network ?error/i,
15
+ /ECONNRESET/i,
16
+ /ECONNREFUSED/i,
17
+ /ECONNABORTED/i,
18
+ /ETIMEDOUT/i,
19
+ /EAI_AGAIN/i,
20
+ /ENOTFOUND/i,
21
+ /ENETUNREACH/i,
22
+ /EHOSTUNREACH/i,
23
+ /EPIPE/i,
24
+ /socket hang ?up/i,
25
+ /other side closed/i,
26
+ /UND_ERR_/i,
27
+ /premature close/i,
28
+ /stream (?:closed|reset)/i,
29
+ /connection (?:reset|lost|closed|refused|aborted)/i,
30
+ /proxy/i,
31
+ /timed? ?out/i,
32
+ /terminated/i,
33
+ /aborted/i,
34
+ ];
35
+
36
+ // 流式连接被截断:SSE 长连接超时、读到一半断开、事件流提前结束。
37
+ const STREAM_PATTERNS = [
38
+ /SSE/i,
39
+ /event ?stream/i,
40
+ /stream(?:ing)? (?:interrupted|truncated|aborted|ended early)/i,
41
+ /incomplete (?:chunk|response|stream)/i,
42
+ /unexpected end of (?:stream|json|data)/i,
43
+ /keep-?alive ?timeout/i,
44
+ ];
45
+
46
+ // 证书与 TLS:重试不会让证书变可信,这类必须放弃,否则只会把失败刷屏。
47
+ const TLS_PATTERNS = [
48
+ /tls(?: handshake)? (?:failed|error)/i,
49
+ /certificate/i,
50
+ /SELF_SIGNED_CERT/i,
51
+ /UNABLE_TO_VERIFY_LEAF_SIGNATURE/i,
52
+ /CERT_HAS_EXPIRED/i,
53
+ /ERR_TLS_/i,
54
+ /hostname\/IP does not match certificate/i,
55
+ ];
56
+
57
+ // 凭据与鉴权:OAuth 会话过期、本地缓存的 token 失效、客户端没自动刷新、
58
+ // Header 拼错、多 Key 轮换时挑到了失效的那把、provider 侧缓存了旧凭据。
59
+ const AUTH_PATTERNS = [
60
+ /oauth/i,
61
+ /token (?:expired|invalid|revoked|missing)/i,
62
+ /refresh token/i,
63
+ /unauthori[sz]ed/i,
64
+ /forbidden/i,
65
+ /invalid api ?key/i,
66
+ /api ?key (?:expired|invalid|missing)/i,
67
+ /authentication (?:failed|required)/i,
68
+ /authorization/i,
69
+ /missing credential/i,
70
+ /credential/i,
71
+ /401|403/,
72
+ ];
73
+
74
+ // 请求形状与协议:Wire API 版本对不上、角色字段不被接受、客户端自己追加了对方
75
+ // 不认识的参数、tool_call 的结构跟对方要的不一致。这类重试同样的请求没用,
76
+ // 得先让本机把请求重新拼一遍——我们做不到改请求体,所以只做一次有限重试并留痕。
77
+ const PROTOCOL_PATTERNS = [
78
+ /wire ?api/i,
79
+ /protocol (?:mismatch|version|error)/i,
80
+ /unsupported (?:parameter|field|role|content type)/i,
81
+ /invalid (?:role|message|content|tool_call|tool call|function call)/i,
82
+ /role .*(?:not allowed|unsupported|invalid)/i,
83
+ /tool_calls? (?:format|schema|invalid|malformed)/i,
84
+ /malformed (?:request|body|json)/i,
85
+ /unexpected (?:parameter|argument|field)/i,
86
+ /400 bad request/i,
87
+ /schema validation/i,
88
+ ];
89
+
90
+ // 本地缓存类:会话历史损坏、规则文件解析失败、模型能力缓存过期、上下文裁剪策略算错。
91
+ const HISTORY_PATTERNS = [
92
+ /history (?:corrupt|invalid|broken|unreadable)/i,
93
+ /invalid (?:message|session) (?:history|record|log)/i,
94
+ /corrupt(?:ed)? (?:session|history|cache|record)/i,
95
+ /rule (?:file|set).*(?:parse|invalid|syntax)/i,
96
+ /failed to parse (?:rules|config|settings)/i,
97
+ /config(?:uration)? (?:parse|syntax) error/i,
98
+ /JSON (?:parse|syntax) error/i,
99
+ ];
100
+
101
+ // 压缩自死循环:「压缩完还是超限」,连续两轮都在压缩却量没降下来。
102
+ const COMPACTION_LOOP_PATTERNS = [
103
+ /compress(?:ion)? (?:loop|did not reduce|no progress|ineffective)/i,
104
+ /still (?:too long|exceeds|over (?:the )?limit) after (?:compress|compact|prune)/i,
105
+ ];
106
+
107
+ // MCP 子服务:独立鉴权失败、子服务没起来、握手失败。
108
+ const MCP_PATTERNS = [
109
+ /\bMCP\b/,
110
+ /mcp (?:server|client|handshake|session)/i,
111
+ /subprocess .*(?:mcp|server).*(?:failed|exit)/i,
112
+ ];
113
+
114
+ // 模型选择与能力判断:模型 ID 写死成旧名字、能力表判断错、缓存里的模型列表过期。
115
+ const MODEL_PATTERNS = [
116
+ /model (?:not found|not supported|unsupported|unknown)/i,
117
+ /unknown model/i,
118
+ /invalid model/i,
119
+ /no such model/i,
120
+ /model (?:list|catalog|cache).*(?:stale|expired|invalid)/i,
121
+ /capabilit(?:y|ies) (?:mismatch|unsupported|unknown)/i,
122
+ ];
123
+
124
+ // 本机环境:端口被占、本地中转服务崩了、沙箱权限不足、子进程崩溃、
125
+ // 文件读不出来、句柄或磁盘耗尽。这些不是模型请求的问题,重试请求本身没用。
126
+ const LOCAL_PATTERNS = [
127
+ /EADDRINUSE/i,
128
+ /EACCES/i,
129
+ /EPERM/i,
130
+ /ENOENT/i,
131
+ /EMFILE/i,
132
+ /ENFILE/i,
133
+ /ENOSPC/i,
134
+ /ENOMEM/i,
135
+ /EAGAIN/i,
136
+ /spawn (?:.* )?(?:ENOENT|EACCES|failed)/i,
137
+ /child process (?:exited|died|crashed|killed)/i,
138
+ /sandbox/i,
139
+ /permission denied/i,
140
+ /resource temporarily unavailable/i,
141
+ /out of memory/i,
142
+ ];
143
+
144
+ // 这几种本机故障不会随重试消失:权限不足、路径不存在、磁盘写满。
145
+ const LOCAL_BLOCKING_PATTERNS = [
146
+ /EACCES/i,
147
+ /EPERM/i,
148
+ /ENOENT/i,
149
+ /ENOSPC/i,
150
+ /permission denied/i,
151
+ /sandbox .*(?:denied|blocked)/i,
152
+ ];
153
+ // 只读工具才允许重放。带副作用的(跑命令、写文件、发消息)重放一次就是执行两遍。
154
+ const DEFAULT_RETRYABLE_TOOL_PATTERN =
155
+ '^(read|read_image|glob|grep|web_fetch|web_search|cordis_inspect_.*|browser_(list_tabs|snapshot|get_text|wait|scroll)|db_(query|preview|describe_table|list_tables))$';
156
+
157
+ // 把一个错误和它的 cause 链摊平成一段短文本,供正则匹配。限制深度和长度是为了
158
+ // 防止循环引用或者过长的 cause 链把内存拖垮。
159
+ function errorText(value, depth = 0, seen = new Set()) {
160
+ if (value === null || value === undefined || depth > 6 || seen.has(value)) return '';
161
+ if (typeof value === 'string') return value;
162
+ if (typeof value !== 'object') return String(value);
163
+ seen.add(value);
164
+ const own = [];
165
+ for (const key of ['message', 'code', 'name', 'reason', 'errno', 'syscall']) {
166
+ const part = value[key];
167
+ if (part !== undefined && part !== null && typeof part !== 'object') own.push(String(part));
168
+ }
169
+ if (value.info && typeof value.info === 'object') own.push(String(value.info.code ?? ''));
170
+ const cause = value.cause !== undefined ? errorText(value.cause, depth + 1, seen) : '';
171
+ return `${own.join(' ')} ${cause}`.trim().slice(0, 4000);
172
+ }
173
+
174
+ // 一组正则里有没有命中的。
175
+ function matchesAny(patterns, text) {
176
+ return patterns.some((pattern) => pattern.test(text));
177
+ }
178
+
179
+ // 抛出来的东西或者失败记录里,是否指向本机传输问题。
180
+ export function isTransientNetworkError(value) {
181
+ const text = errorText(value);
182
+ if (text === '') return false;
183
+ return matchesAny(NETWORK_PATTERNS, text);
184
+ }
185
+
186
+ // 规则表。从上往下匹配,第一条命中就返回。
187
+ //
188
+ // 顺序就是优先级,错了会互相盖掉:
189
+ // - 证书问题排最前,重试它没有意义,必须先放弃;
190
+ // - 对方侧的拒绝(配额、政策、鉴权被拒)排在传输判断前,否则一句「含 timeout 的配额错误」
191
+ // 会被当成网络抖动去重试;
192
+ // - 压缩死循环排在普通超限前,否则会被当成一次新的超长而反复压缩。
193
+ const CLASSIFICATION_RULES = [
194
+ {
195
+ kind: 'tls',
196
+ strategy: 'skip',
197
+ clientCaused: false,
198
+ match: (context) => matchesAny(TLS_PATTERNS, context.text),
199
+ },
200
+ {
201
+ kind: 'policy',
202
+ strategy: 'skip',
203
+ clientCaused: false,
204
+ match: (context) => /content policy|safety|moderation|blocked by policy/i.test(context.text),
205
+ },
206
+ {
207
+ kind: 'quota',
208
+ strategy: 'skip',
209
+ clientCaused: false,
210
+ match: ({ code }) => code === 'QUOTA' || code === 'ACCOUNT_QUOTA' || code === 'RATE_LIMIT' || code === 'SERVER',
211
+ },
212
+ {
213
+ kind: 'compaction-loop',
214
+ strategy: 'history-repair',
215
+ clientCaused: true,
216
+ retryable: true,
217
+ match: (context) => matchesAny(COMPACTION_LOOP_PATTERNS, context.text),
218
+ },
219
+ {
220
+ kind: 'history',
221
+ strategy: 'history-repair',
222
+ clientCaused: true,
223
+ retryable: true,
224
+ match: (context) => matchesAny(HISTORY_PATTERNS, context.text),
225
+ },
226
+ {
227
+ kind: 'overflow',
228
+ strategy: 'compact',
229
+ clientCaused: true,
230
+ retryable: true,
231
+ match: ({ code, status, text }) =>
232
+ code === 'CONTEXT_WINDOW_EXCEEDED' || status === 413 || /context (?:window|length)|request too large|prompt too long/i.test(text),
233
+ },
234
+ { kind: 'image', strategy: 'skip', match: ({ code }) => code === 'IMAGE_OFFLOAD_REQUIRED' },
235
+ {
236
+ kind: 'adapter',
237
+ strategy: 'version-repair',
238
+ clientCaused: true,
239
+ match: ({ code, status }) => code === 'NO_ADAPTER' || status === 404,
240
+ },
241
+ {
242
+ kind: 'auth',
243
+ strategy: 'auth',
244
+ clientCaused: true,
245
+ retryable: true,
246
+ match: (context) => matchesAny(AUTH_PATTERNS, context.text) || context.status === 401 || context.status === 403,
247
+ },
248
+ {
249
+ kind: 'mcp',
250
+ strategy: 'retry',
251
+ clientCaused: true,
252
+ retryable: true,
253
+ match: (context) => matchesAny(MCP_PATTERNS, context.text),
254
+ },
255
+ {
256
+ kind: 'protocol',
257
+ strategy: 'retry',
258
+ clientCaused: true,
259
+ retryable: true,
260
+ match: (context) => matchesAny(PROTOCOL_PATTERNS, context.text) || context.status === 400,
261
+ },
262
+ {
263
+ kind: 'model',
264
+ strategy: 'retry',
265
+ clientCaused: true,
266
+ retryable: true,
267
+ match: (context) => matchesAny(MODEL_PATTERNS, context.text),
268
+ },
269
+ {
270
+ // 权限、路径、磁盘满这类重试一万次也不会变:本机缺的是权限或空间,不是时机。
271
+ kind: 'local-blocked',
272
+ strategy: 'skip',
273
+ clientCaused: true,
274
+ match: (context) => matchesAny(LOCAL_BLOCKING_PATTERNS, context.text),
275
+ },
276
+ {
277
+ kind: 'local-env',
278
+ strategy: 'local',
279
+ clientCaused: true,
280
+ retryable: true,
281
+ match: (context) => matchesAny(LOCAL_PATTERNS, context.text),
282
+ },
283
+ {
284
+ kind: 'stream',
285
+ strategy: 'retry',
286
+ clientCaused: true,
287
+ retryable: true,
288
+ match: (context) => matchesAny(STREAM_PATTERNS, context.text),
289
+ },
290
+ {
291
+ kind: 'provider-error',
292
+ strategy: 'retry',
293
+ clientCaused: true,
294
+ retryable: true,
295
+ match: ({ code, text }) => code === 'PI_AI_ERROR' || /PI_AI_ERROR/i.test(text),
296
+ },
297
+ { kind: 'empty', strategy: 'retry', clientCaused: true, retryable: true, match: ({ code }) => code === 'EMPTY_RESPONSE' },
298
+ { kind: 'timeout', strategy: 'retry', clientCaused: true, retryable: true, match: ({ code, status }) => code === 'TIMEOUT' || status === 408 },
299
+ {
300
+ kind: 'transport',
301
+ strategy: 'retry',
302
+ clientCaused: true,
303
+ retryable: true,
304
+ match: (context) => context.transient === true,
305
+ },
306
+ ];
307
+
308
+ // 判断一次模型请求失败属于哪一类。
309
+ //
310
+ // clientCaused 表示问题出在本机(传输、本地拼坏请求、路由或凭据过期),而不是对方服务;
311
+ // strategy 表示重试之前先做什么:compact 先压缩上下文,history-repair 先修本地历史或规则,
312
+ // auth 先换凭据,version-repair 先修插件版本,retry 直接退避重来,local 交给本机环境处理,
313
+ // skip 什么都不做。
314
+ export function classifyModelFailure(failure) {
315
+ const code = String(failure?.code ?? '').toUpperCase();
316
+ const context = {
317
+ code,
318
+ status: Number(failure?.status ?? 0),
319
+ text: errorText(failure),
320
+ transient: isTransientNetworkError(failure),
321
+ };
322
+ for (const rule of CLASSIFICATION_RULES) {
323
+ if (rule.match(context)) {
324
+ return {
325
+ clientCaused: rule.clientCaused ?? false,
326
+ kind: rule.kind,
327
+ strategy: rule.strategy,
328
+ retryable: rule.retryable ?? false,
329
+ };
330
+ }
331
+ }
332
+ return { clientCaused: false, kind: 'other', strategy: 'skip', retryable: false };
333
+ }
334
+
335
+ // 判断一次工具调用失败是否值得重放。只有只读工具在名单里才考虑,
336
+ // 而且失败原因得确实是传输或本地环境问题——本地环境问题重放往往就好了。
337
+ export function isTransientToolFailure(name, result, allowlist) {
338
+ if (result?.isError !== true) return false;
339
+ if (!allowlist.test(String(name ?? ''))) return false;
340
+ if (isTransientNetworkError(result.error)) return true;
341
+ const text = errorText(result.error);
342
+ if (matchesAny(LOCAL_PATTERNS, text)) return true;
343
+ const content = Array.isArray(result.content) ? JSON.stringify(result.content).slice(0, 2000) : '';
344
+ return matchesAny(NETWORK_PATTERNS, content) || matchesAny(LOCAL_PATTERNS, content);
345
+ }
346
+
347
+ // 把插件配置整理成一套完整、带默认值和上下界的策略。
348
+ // 用户配错不该导致插件不工作,所以数值一律夹到合理区间。
349
+ export function parseRetryConfig(raw = {}) {
350
+ const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
351
+ const number = (key, fallback, min, max) => {
352
+ const value = Number(source[key]);
353
+ if (!Number.isFinite(value)) return fallback;
354
+ return Math.min(Math.max(value, min), max);
355
+ };
356
+ const bool = (key, fallback) => (typeof source[key] === 'boolean' ? source[key] : fallback);
357
+ let toolPattern;
358
+ try {
359
+ toolPattern = new RegExp(typeof source.retryableToolPattern === 'string' ? source.retryableToolPattern : DEFAULT_RETRYABLE_TOOL_PATTERN);
360
+ } catch {
361
+ // 用户写的正则不合法,退回默认白名单
362
+ toolPattern = new RegExp(DEFAULT_RETRYABLE_TOOL_PATTERN);
363
+ }
364
+ return {
365
+ autoRepair: bool('autoRepair', true),
366
+ autoVersionRepair: bool('autoVersionRepair', true),
367
+ recordRetryEvents: bool('recordRetryEvents', true),
368
+ maxRetries: Math.round(number('maxRetries', 12, 0, 100)),
369
+ maxToolRetries: Math.round(number('maxToolRetries', 2, 0, 10)),
370
+ maxVersionRepairs: Math.round(number('maxVersionRepairs', 64, 0, 500)),
371
+ maxAuthRetries: Math.round(number('maxAuthRetries', 2, 0, 10)),
372
+ maxRepeatedToolCalls: Math.round(number('maxRepeatedToolCalls', 3, 0, 50)),
373
+ initialDelayMs: number('initialDelayMs', 400, 1, 2147483647),
374
+ maxDelayMs: number('maxDelayMs', 20000, 1, 2147483647),
375
+ jitterRatio: number('jitterRatio', 0.25, 0, 1),
376
+ retryableToolPattern: toolPattern,
377
+ };
378
+ }
379
+
380
+ // 指数退避加抖动。对方要是给了 retry-after 就用它的,但仍然夹在上下限内。
381
+ // 抖动是为了避免多个重试在同一时刻一起怼上去。
382
+ export function computeDelay(config, attempt, failure) {
383
+ const hint = Number(failure?.providerRetryAfterMs);
384
+ if (Number.isFinite(hint) && hint > 0) return Math.min(Math.max(hint, 100), config.maxDelayMs);
385
+ const exponent = Math.min(Math.max(attempt - 1, 0), 20);
386
+ const ceil = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs);
387
+ const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * Math.random();
388
+ return Math.max(1, Math.min(Math.round(ceil * jitter), config.maxDelayMs));
389
+ }