llm-factory.js
4.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/**
* 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
}