llm-factory.js 4.63 KB
/**
 * llm-factory.js —— `(文本能力接入, model) → Llm 实例` 的**唯一归一处**。
 *
 * 三条覆盖顺序在此收口,两个 app 的装配都只调本文件,不再各自抄一份(此前 visual-game 用
 * `row.adapter`、主站直接把 env 的厂商名当 registry 键,drift 就是这么来的):
 *
 *   1. 协议适配器:`model_provider_capability.adapter` → registry 子类。能力代码只作
 *      **key 池 vendor**(`llm:<name>`)与计费主体,绝不当 registry 键——name/adapter 解耦的全部
 *      意义就在这里(`name='deepseek', adapter='openai'` 必须能跑)。
 *   2. base_url:`llm_key.base_url` > `model_provider_capability.base_url` > 适配器默认(见 withProviderBaseUrl)。
 *   3. 请求参数:请求体 > `llm_model.params` > `model_provider_capability.params`。
 *
 * 包内不读 env、不建连接:ports 与两张表的读取函数全部注入。
 *
 * @typedef {import('@drama/llm').Llm} Llm
 */

import { createLLM, LLMError } from '@drama/llm'

/**
 * @param {object} deps
 * @param {object} deps.ports                        成本、结算、审计、key、trace、notify 端口。
 * @param {object} deps.billingPolicy                composition root 明确选择的积分策略。
 * @param {() => Map<string, any>} deps.getProviders `model_provider_capability.code` → 文本能力行(装配侧保持最新)。
 * @param {() => Map<string, any>} deps.getModels    `${provider}#${model}` → 行(装配侧保持最新)。
 * @returns {(cfg: { provider: string, model: string }) => Llm}
 */
export function createLlmFactory({ ports, billingPolicy, getProviders, getModels }) {
  if (!billingPolicy?.mode) throw new Error('createLlmFactory: billingPolicy is required')
  return ({ provider, model }) => {
    const row = getProviders().get(provider)
    if (!row) {
      throw new LLMError({
        provider: 'llm-router',
        reason: 'badRequest',
        retryable: false,
        message: `createLlmFactory: text provider capability '${provider}' is not registered/enabled`,
      })
    }
    const llm = createLLM({ ...ports, provider: row.adapter, vendor: row.name, model })
    const providerParams = paramsOf(row.params, `model_provider_capability ${provider}`)
    const modelRow = getModels().get(`${provider}#${model}`)
    if (!modelRow) throw new Error(`createLlmFactory: llm_model '${provider}#${model}' is not registered/enabled`)
    const modelParams = paramsOf(modelRow.params, `llm_model ${provider}#${model}`)
    return {
      /**
       * @param {import('@drama/llm').ChatRequest} req
       * @param {import('@drama/llm').ChatOpts} [opts]
       */
      chat(req, opts = {}) {
        const billing_ctx = { ...opts.billing_ctx, policy: billingPolicy }
        return llm.chat({ ...providerParams, ...modelParams, ...req }, { ...opts, billing_ctx })
      },
    }
  }
}

/**
 * keyProvider 装配包装:lease.base_url 为空时回落 `model_provider_capability.base_url`。
 * keyrouter 只知 key 级 base_url,厂商级默认端点是装配层知识——不改 @drama/llm 与 @drama/keyrouter。
 * vendor 形如 `text:<name>`(前缀 = 能力类型),去前缀得厂商身份查行。
 * @param {import('@drama/llm').KeyProvider} inner
 * @param {() => Map<string, any>} getProviders
 * @returns {import('@drama/llm').KeyProvider}
 */
export function withProviderBaseUrl(inner, getProviders) {
  return {
    async acquire(q) {
      const lease = await inner.acquire(q)
      if (lease.base_url) return lease
      const name = q.vendor.startsWith('text:') ? q.vendor.slice(5) : q.vendor
      const base_url = getProviders().get(name).base_url
      return base_url === null ? lease : { ...lease, base_url }
    },
    report_usage: (key_id, usage) => inner.report_usage(key_id, usage),
    report_rate_limited: (key_id, retry_after_ms) => inner.report_rate_limited(key_id, retry_after_ms),
    report_error: (key_id, kind) => inner.report_error(key_id, kind),
    // 包装不许把端口削薄:漏掉 stats 就等于「运行中的那个 keyrouter」对外不可观测,
    // 后台面板只能另造一个实例去看——那看到的是另一份进程状态,不是这一份。
    stats: () => inner.stats(),
  }
}

/**
 * MySQL JSON row 的唯一构造点:NULL 表示没有默认参数;非 NULL 必须是 mysql2 已解析的普通对象。
 * @param {unknown} params
 * @param {string} owner
 * @returns {Record<string, any>}
 */
function paramsOf(params, owner) {
  if (params === null) return {}
  if (typeof params !== 'object' || Array.isArray(params)) {
    throw new Error(`createLlmFactory: ${owner}.params 必须是 JSON object 或 NULL`)
  }
  return params
}