map-error.js 4.67 KB
/**
 * 错误归一 —— 把厂商 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 }
}