/** * llm-factory.js —— `(文本能力接入, model) → Llm 实例` 的**唯一归一处**。 * * 三条覆盖顺序在此收口,两个 app 的装配都只调本文件,不再各自抄一份(此前 visual-game 用 * `row.adapter`、主站直接把 env 的厂商名当 registry 键,drift 就是这么来的): * * 1. 协议适配器:`model_provider_capability.adapter` → registry 子类。能力代码只作 * **key 池 vendor**(`llm:`)与计费主体,绝不当 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} deps.getProviders `model_provider_capability.code` → 文本能力行(装配侧保持最新)。 * @param {() => Map} 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:`(前缀 = 能力类型),去前缀得厂商身份查行。 * @param {import('@drama/llm').KeyProvider} inner * @param {() => Map} 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} */ 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 }