/** * 错误归一 —— 把厂商 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 } }