/** * llm 用例 —— LLM 实例的四元绑定管理(编码规范 §5.3;语义出处 OS 02 系统管理)。 */ import type { LlmInstanceView } from '@contracts'; import type { LlmService, UpsertLlmInstance } from '../../contract.ts'; import type { Clock, IdGenerator, LlmInstanceRepository, ProviderInvoker, SecretBox } from '../../port.ts'; import { pickSystemUse, toPublicView, validateBinding } from '../domain/model-binding.ts'; import { resolveBaseUrl } from '../domain/provider-profile.ts'; import type { ModelBinding } from '../domain/model-binding.ts'; export type LlmServiceDeps = { readonly instances: LlmInstanceRepository; readonly provider: ProviderInvoker; readonly secrets: SecretBox; readonly clock: Clock; readonly ids: IdGenerator; }; export function createLlmService(deps: LlmServiceDeps): LlmService { return { async list(): Promise { const all = await deps.instances.list(); return all.map(toPublicView); }, async upsert(input: UpsertLlmInstance): Promise { const existing = input.id === undefined ? undefined : await deps.instances.findById(input.id); // 只写不读:apiKey 留空即保持原密文(ADR-009 #4) const apiKeyCipher = input.apiKey !== undefined && input.apiKey !== '' ? deps.secrets.seal(input.apiKey) : existing?.apiKeyCipher; const binding: ModelBinding = { id: existing?.id ?? input.id ?? deps.ids.next(), name: input.name, provider: input.provider, // URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址 // URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址 baseUrl: resolveBaseUrl(input.provider, input.baseUrl), model: input.model, apiKeyCipher, systemUse: input.systemUse, enabled: input.enabled, }; const saved = await deps.instances.upsert(binding); return toPublicView(saved); }, async remove(id) { await deps.instances.remove(id); }, async probe(id) { const binding = await deps.instances.findById(id); if (binding === undefined) return { ok: false, error: '实例不存在' }; const violations = validateBinding(binding); if (violations.length > 0) { return { ok: false, error: violations.map((v) => `${v.field}:${v.message}`).join('; ') }; } const started = deps.clock.now().getTime(); try { await deps.provider.invoke({ binding, apiKey: deps.secrets.open(binding.apiKeyCipher!), call: { model: binding.name, prompt: 'ping', maxOutputTokens: 8, label: 'probe' }, }); return { ok: true, latencyMs: deps.clock.now().getTime() - started }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } }, /** * 测试连接(草稿)—— **只测不存**。 * * 「测试」按下去就把表单写进库,是个会咬人的副作用:人只想知道这把钥匙对不对, * 结果端点被改了、实例被建了,取消也收不回来。所以这里拿**临时绑定**去试: * 密钥现填就用现填的,没填就取这条实例已存的密文(改端点、换型号不必重打钥匙)。 */ async probeDraft(draft) { const stored = draft.id === undefined ? undefined : await deps.instances.findById(draft.id); const cipher = draft.apiKey === undefined || draft.apiKey === '' ? stored?.apiKeyCipher : deps.secrets.seal(draft.apiKey); const binding: ModelBinding = { id: stored?.id ?? 'draft', name: draft.name ?? stored?.name ?? draft.model, provider: draft.provider, baseUrl: resolveBaseUrl(draft.provider, draft.baseUrl), model: draft.model, apiKeyCipher: cipher, systemUse: false, enabled: true, }; const violations = validateBinding(binding); if (violations.length > 0) { return { ok: false, error: violations.map((v) => `${v.field}:${v.message}`).join('; ') }; } const started = deps.clock.now().getTime(); try { await deps.provider.invoke({ binding, apiKey: deps.secrets.open(binding.apiKeyCipher!), call: { model: binding.name, prompt: 'ping', maxOutputTokens: 8, label: 'probe' }, }); return { ok: true, latencyMs: deps.clock.now().getTime() - started }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } }, async assertNamesBound(names) { const all = await deps.instances.list(); const bound = new Set(all.filter((b) => validateBinding(b).length === 0 && b.enabled).map((b) => b.name)); const missing = [...new Set(names)].filter((n) => !bound.has(n)); return { ok: missing.length === 0, missing }; }, async systemUseBinding() { return pickSystemUse(await deps.instances.list()); }, }; }