fallback.js 2.13 KB
/**
 * withFallback —— 把一条模型链(主→备)包成与单实例 `Llm` 同形的实例。
 *
 * 候选遍历与降级判定收敛在 @drama/service-call 的通用 withFallback:唯一降级信号是
 * 结构化错误的 `retryable === true`(duck-type;`@drama/llm` 已把 rateLimit/transient 归一为 true,
 * auth/badRequest/aborted/unknown 为 false)。不读 `err.reason` 字符串、不匹配 `err.message`、
 * 不判 HTTP 状态。本文件只负责 LLM 域的 attempt 上下文:mode / correlation_id / attempt_index 注入。
 *
 * 返回值 `{ chat }` 与 `createLLM` 逐字一致;`opts`(on_content/on_thinking/on_tool_call/signal/billing_ctx)
 * 整体透传、不解构,故流式回调与 signal 天然透传给每个被尝试的实例。
 */

import { randomUUID } from 'node:crypto'
import { LLMError } from '@drama/llm'
import { withFallback as chainCandidates } from '@drama/service-call'

/** @typedef {import('@drama/llm').Llm} Llm */

/**
 * @param {Llm[]} llms  非空模型链,索引 0 为主、末位为最后兜底。
 * @param {object} [cfg]
 * @param {string} [cfg.mode]  场景名(`mode_model_config.mode`);由 mode-router 传入,随每次 attempt 落审计。
 * @returns {Llm}
 */
export function withFallback(llms, { mode } = {}) {
  if (llms.length < 1) {
    throw new LLMError({
      provider: 'llm-router',
      reason: 'badRequest',
      retryable: false,
      message: 'withFallback: llms must be a non-empty array',
    })
  }

  /**
   * @param {import('@drama/llm').ChatRequest} req
   * @param {import('@drama/llm').ChatOpts} [opts]
   * @returns {Promise<import('@drama/llm').ChatResult>}
   */
  async function chat(req, opts) {
    // 一次 chat = 一个 correlation_id:链上每个 attempt 各自是一行 llm_call_log,靠它串成"这次 turn 降级了"。
    // 链上每个实例各自 genId 出自己的 requestId(对账键,不可共用),故关联只能另立一个 id。
    const correlation_id = randomUUID()
    return chainCandidates(
      llms.map((llm, i) => () => llm.chat(req, { ...opts, mode, correlation_id, attempt_index: i })),
    )()
  }

  return { chat }
}