map-error.js
4.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
/**
* 错误归一 —— 把厂商 HTTP 状态 / 抛出的异常映射成 LLMError 的 reason + retryable。
*
* 通用分类(HTTP 状态、超时/abort 判定)单源在 @drama/service-call,本文件导入并再导出,
* 只保留 LLM 域特有的判定(keyrouter 结构化错误、OpenAI Responses 终态、网络异常归并)。
* 子类与编排层都用它,保证「错误分类」只有一处来源。
* reason ∈ { 'auth', 'rateLimit', 'transient', 'badRequest', 'contentFilter', 'timeout', 'aborted', 'unknown' }。
*/
import { mapHttpStatus, isTimeoutError, isAbortError } from '@drama/service-call'
export { mapHttpStatus, isTimeoutError, isAbortError }
/**
* @typedef {'auth'|'rateLimit'|'transient'|'badRequest'|'contentFilter'|'timeout'|'aborted'|'unknown'} LLMErrorReason
*/
/**
* @typedef {object} MappedError
* @property {LLMErrorReason} reason
* @property {boolean} retryable
*/
/**
* OpenAI Responses `response.failed` 事件的 error.code → reason/retryable。
* 与 mapHttpStatus 同源的分类惯例(终态语义错误无 HTTP 状态可依,按 code 归一):
* rate_limit_exceeded = rateLimit(重试);server_error = transient(重试);其余 = badRequest(不重试)。
* @param {string|undefined} code
* @returns {MappedError}
*/
export function mapResponsesFailure(code) {
if (code === 'rate_limit_exceeded') return { reason: 'rateLimit', retryable: true }
if (code === 'server_error') return { reason: 'transient', retryable: true }
return { reason: 'badRequest', retryable: false }
}
/**
* OpenAI-compatible Chat Completions 的 finish_reason → 错误分类。
* stop/tool_calls/function_call 是完整终态;length 是供应商正常返回的截断结果,
* 对 JSON/工具调用上层都不可消费,必须进入可重试/降级链。
* content_filter 在 HTTP 错误里代表输入被供应商拒绝;在 Chat finish_reason 里代表
* 供应商已经生成了不可消费的截断输出,只允许跨供应商 fallback,不在同供应商内重试。
* @param {string|null} reason
* @returns {MappedError | null}
*/
export function mapChatFinishReason(reason) {
if (reason === null || reason === 'stop' || reason === 'tool_calls' || reason === 'function_call') return null
if (reason === 'length') return { reason: 'transient', retryable: true }
if (reason === 'content_filter') return { reason: 'contentFilter', retryable: true }
return { reason: 'unknown', retryable: false }
}
/**
* 判定并归一 keyrouter 抛出的结构化错误(不 import @drama/keyrouter,靠 name / duck-type 识别)。
* 命中返回归一后的 reason/retryable;非 keyrouter 错误返回 null(交回后续通用判定)。
* 有明确冷却窗口(retryAfterMs)→ rateLimit(可重试);否则按其 retryable 归一为 transient
* (keyrouter 的 noKeyAvailable/slotTimeout/store 都是「稍后可能好」的瞬态,config 缺失为硬错不重试)。
* @param {unknown} err
* @returns {MappedError | null}
*/
export function mapKeyRouterError(err) {
if (!err || typeof err !== 'object') return null
const e = /** @type {{ name?:string, retryAfterMs?:number, kind?:string, retryable?:boolean }} */ (err)
const isKeyRouter =
e.name === 'KeyRouterError' || typeof e.retryAfterMs === 'number' || typeof e.kind === 'string'
if (!isKeyRouter) return null
if (typeof e.retryAfterMs === 'number') return { reason: 'rateLimit', retryable: true }
return { reason: 'transient', retryable: e.retryable !== false }
}
/**
* 把任意抛出的异常(网络错误 / abort / keyrouter 结构化 / 已是结构化)映射成 reason/retryable。
* 网络层错误(fetch 抛 TypeError、连接重置)当作 transient 可重试。
* @param {unknown} err
* @returns {MappedError}
*/
export function mapThrownError(err) {
// timeout 先于 aborted 判定:内部超时可重试,外部 abort 不重试;二者 name 不同、互斥。
if (isTimeoutError(err)) return { reason: 'timeout', retryable: true }
if (isAbortError(err)) return { reason: 'aborted', retryable: false }
// keyrouter 结构化错误(acquire 无 key / 槽超时等):归一为 rateLimit/transient,不落 unknown。
const kr = mapKeyRouterError(err)
if (kr) return kr
// fetch 网络故障:TypeError('fetch failed') / ECONNRESET / ETIMEDOUT 等。
if (err && typeof err === 'object') {
const e = /** @type {{ code?:string, name?:string }} */ (err)
if (
e.name === 'TypeError' ||
e.code === 'ECONNRESET' ||
e.code === 'ETIMEDOUT' ||
e.code === 'ECONNREFUSED' ||
e.code === 'UND_ERR_SOCKET'
) {
return { reason: 'transient', retryable: true }
}
}
return { reason: 'unknown', retryable: false }
}