226 lines
22 KiB
TypeScript
226 lines
22 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
||
import { z } from 'zod'
|
||
import { AiModelError, assertAiModelAdmin } from './ai-model-access'
|
||
import { decryptAiModelKey, encryptAiModelKey, isAiModelEncryptionConfigured } from './ai-model-crypto'
|
||
import { normalizeAiModelBaseUrl, requestAiModel, requestAiModelCapabilityCatalog } from './ai-model-http'
|
||
import { aiModelCapabilityFingerprint, getAiModelCapabilities, mergeAiModelCapabilityMetadata, parseAiModelCapabilityMetadata, parseAiModelPublicCapabilityCatalog, type AiModelCapabilityMetadata } from './ai-model-capabilities'
|
||
import { aiModelAudit, aiModelTransaction, assertAiModelRevision, encryptedAiModelFields, getAiModelConfiguration, getAiModelProviderRow, insertAiModelProvider, listAiModelProviderRows, listAiModelRows, saveAiModelDefaults, updateAiModelProvider, type AiModelDatabase, type AiModelProviderRow, type AiModelRow } from './ai-model-repository'
|
||
import { AI_MODEL_STAGES, type AiModelAdminState, type AiModelBinding, type AiModelBindings, type AiModelConfigurationInput, type AiModelDefaults, type AiModelRuntimeState, type AiModelStage } from './ai-model-types'
|
||
import { DETAIL_MODEL_DEFAULTS } from './detail-generator/types'
|
||
|
||
const id = z.string().uuid()
|
||
const revision = z.number().int().min(0)
|
||
const key = z.string().trim().min(1).max(4096).regex(/^[\x21-\x7e]+$/)
|
||
const createSchema = z.object({ name: z.string().trim().min(1).max(100), baseUrl: z.string().trim().min(1).max(500), apiKey: key, enabled: z.boolean().optional() }).strict()
|
||
const updateSchema = z.object({ revision: revision.min(1), name: z.string().trim().min(1).max(100).optional(), baseUrl: z.string().trim().min(1).max(500).optional(), apiKey: key.optional(), enabled: z.boolean().optional() }).strict()
|
||
const defaultsSchema = z.object({ analysis: id.nullable(), copy: id.nullable(), image: id.nullable() }).strict()
|
||
const configurationSchema = z.object({ revision, defaults: defaultsSchema, providers: z.array(z.object({ id, revision: revision.min(1), models: z.array(z.object({ key: id, enabled: z.boolean(), stages: z.array(z.enum(AI_MODEL_STAGES)).max(3), compatibility: z.enum(['confirmed', 'unconfirmed']) }).strict()).max(5000) }).strict()).max(100) }).strict()
|
||
function parse<T>(schema: z.ZodType<T>, value: unknown): T { const parsed = schema.safeParse(value); if (!parsed.success) throw new AiModelError('提交的模型配置格式无效'); return parsed.data }
|
||
export function assertAiModelId(value: string): string { if (!id.safeParse(value).success) throw new AiModelError('模型服务编号无效', 404); return value }
|
||
function hasCredential(provider: AiModelProviderRow): boolean {
|
||
if (provider.credentialSource === 'environment') {
|
||
try { return Boolean(process.env.DETAIL_GENERATOR_API_KEY?.trim()) && provider.baseUrl === normalizeAiModelBaseUrl(process.env.DETAIL_GENERATOR_BASE_URL?.trim() || 'https://api.vip98k.com/v1') } catch { return false }
|
||
}
|
||
return Boolean(provider.secretCiphertext && provider.secretNonce && provider.secretAuthTag && isAiModelEncryptionConfigured())
|
||
}
|
||
function providerKey(provider: AiModelProviderRow): string {
|
||
if (provider.credentialSource === 'environment') { const value = process.env.DETAIL_GENERATOR_API_KEY?.trim(); if (value && hasCredential(provider)) return value }
|
||
else if (provider.secretCiphertext && provider.secretNonce && provider.secretAuthTag) return decryptAiModelKey({ secretCiphertext: provider.secretCiphertext, secretNonce: provider.secretNonce, secretAuthTag: provider.secretAuthTag })
|
||
throw new AiModelError('模型服务尚未配置可用密钥', 503, 'credential_unavailable')
|
||
}
|
||
function modelAvailable(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage): boolean { return provider.enabled && hasCredential(provider) && model.present && model.enabled && model.compatibility === 'confirmed' && model.stages.includes(stage) && getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage) }
|
||
|
||
export async function getAiModelAdminState(): Promise<AiModelAdminState> {
|
||
const [providers, models, configuration] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration()])
|
||
return { providers: providers.map(provider => ({ id: provider.id, name: provider.name, baseUrl: provider.baseUrl, enabled: provider.enabled, credentialSource: provider.credentialSource, hasApiKey: hasCredential(provider), revision: provider.revision, syncedAt: provider.syncedAt?.toISOString() || null, models: models.filter(model => model.providerId === provider.id).map(({ providerId: _providerId, capabilityMetadata, ...model }) => {
|
||
const capabilities = getAiModelCapabilities(provider.baseUrl, model.id, capabilityMetadata)
|
||
const stages = model.stages.filter(stage => capabilities.allowedStages.includes(stage))
|
||
return { ...model, stages, enabled: model.enabled && stages.length > 0, capabilities }
|
||
}) })), ...configuration, encryptionConfigured: isAiModelEncryptionConfigured(), legacyAvailable: Boolean(process.env.DETAIL_GENERATOR_API_KEY?.trim()) && !providers.some(provider => provider.legacyBinding) }
|
||
}
|
||
|
||
export async function createAiModelProvider(userId: string, body: unknown): Promise<AiModelAdminState> {
|
||
const input = parse(createSchema, body)
|
||
const provider: AiModelProviderRow = { id: randomUUID(), name: input.name, baseUrl: normalizeAiModelBaseUrl(input.baseUrl), enabled: input.enabled ?? false, credentialSource: 'encrypted', ...encryptAiModelKey(input.apiKey), legacyBinding: false, revision: 1, syncedAt: null }
|
||
await aiModelTransaction(async db => {
|
||
await assertAiModelAdmin(userId)
|
||
if ((await listAiModelProviderRows(db)).length >= 100) throw new AiModelError('最多接入 100 个模型服务')
|
||
await insertAiModelProvider(db, provider)
|
||
await aiModelAudit(db, userId, 'provider.create', provider.id, { name: provider.name, baseUrl: provider.baseUrl, enabled: provider.enabled, credentialChanged: true })
|
||
})
|
||
return getAiModelAdminState()
|
||
}
|
||
|
||
export async function editAiModelProvider(userId: string, providerId: string, body: unknown): Promise<AiModelAdminState> {
|
||
assertAiModelId(providerId); const input = parse(updateSchema, body)
|
||
const encrypted = input.apiKey ? encryptAiModelKey(input.apiKey) : null
|
||
await aiModelTransaction(async db => {
|
||
await assertAiModelAdmin(userId)
|
||
const current = await getAiModelProviderRow(providerId, db); assertAiModelRevision(current.revision, input.revision)
|
||
const baseUrl = input.baseUrl ? normalizeAiModelBaseUrl(input.baseUrl) : current.baseUrl
|
||
if (current.legacyBinding && baseUrl !== current.baseUrl) throw new AiModelError('迁入的原连接须保留原服务地址;切换上游请新增服务并在项目中明确选择')
|
||
if (baseUrl !== current.baseUrl && !encrypted) throw new AiModelError('更改服务地址须同时填写该服务的密钥,避免把旧密钥发送到新地址')
|
||
const next = { ...current, ...(input.name !== undefined ? { name: input.name } : {}), ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), baseUrl, ...(encrypted ? { ...encrypted, credentialSource: 'encrypted' as const } : {}) }
|
||
await updateAiModelProvider(db, next)
|
||
if (baseUrl !== current.baseUrl) await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE,enabled=FALSE,stages=ARRAY[]::text[],compatibility='unconfirmed',capability_metadata=NULL WHERE provider_id=$1`, providerId)
|
||
await aiModelAudit(db, userId, 'provider.update', providerId, { name: next.name, baseUrl, enabled: next.enabled, credentialChanged: Boolean(encrypted), addressChanged: baseUrl !== current.baseUrl })
|
||
})
|
||
return getAiModelAdminState()
|
||
}
|
||
|
||
function environmentModels(): Record<AiModelStage, string[]> {
|
||
const output: Record<AiModelStage, string[]> = { analysis: [DETAIL_MODEL_DEFAULTS.analysis], copy: [DETAIL_MODEL_DEFAULTS.copy], image: [DETAIL_MODEL_DEFAULTS.image] }
|
||
const setting = process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON
|
||
if (!setting) return output
|
||
let parsed: unknown; try { parsed = JSON.parse(setting) } catch { throw new AiModelError('原服务端模型配置格式无效', 503) }
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new AiModelError('原服务端模型配置格式无效', 503)
|
||
for (const stage of AI_MODEL_STAGES) {
|
||
const values = (parsed as Record<string, unknown>)[stage]
|
||
if (values !== undefined && (!Array.isArray(values) || values.length > 5000 || values.some(value => typeof value !== 'string' || !/^[\w.:-]{1,120}$/.test(value)))) throw new AiModelError('原服务端模型配置格式无效', 503)
|
||
if (Array.isArray(values)) output[stage] = Array.from(new Set([...output[stage], ...values]))
|
||
}
|
||
return output
|
||
}
|
||
|
||
export async function importAiModelEnvironment(userId: string): Promise<AiModelAdminState> {
|
||
if (!process.env.DETAIL_GENERATOR_API_KEY?.trim()) throw new AiModelError('服务端没有可迁入的模型连接', 503)
|
||
const stages = environmentModels()
|
||
const provider: AiModelProviderRow = { id: randomUUID(), name: '现有详情页上游', baseUrl: normalizeAiModelBaseUrl(process.env.DETAIL_GENERATOR_BASE_URL?.trim() || 'https://api.vip98k.com/v1'), enabled: true, credentialSource: 'environment', ...encryptedAiModelFields(null), legacyBinding: true, revision: 1, syncedAt: null }
|
||
await aiModelTransaction(async db => {
|
||
await assertAiModelAdmin(userId)
|
||
if ((await listAiModelProviderRows(db)).some(value => value.legacyBinding)) throw new AiModelError('现有连接已迁入,请在列表中管理', 409, 'already_imported')
|
||
await insertAiModelProvider(db, provider)
|
||
const imported = new Map<string, string>()
|
||
for (const modelId of Array.from(new Set(AI_MODEL_STAGES.flatMap(stage => stages[stage])))) {
|
||
const modelKey = randomUUID(); imported.set(modelId, modelKey)
|
||
const allowed = getAiModelCapabilities(provider.baseUrl, modelId).allowedStages
|
||
const selected = AI_MODEL_STAGES.filter(stage => stages[stage].includes(modelId) && allowed.includes(stage))
|
||
await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,enabled,stages,present,compatibility) VALUES ($1,$2,$3,$4,$5::text[],TRUE,$6)`, modelKey, provider.id, modelId, selected.length > 0, selected, selected.length ? 'confirmed' : 'unconfirmed')
|
||
}
|
||
const current = await getAiModelConfiguration(db)
|
||
const defaultFor = (stage: AiModelStage) => current.defaults[stage] || (getAiModelCapabilities(provider.baseUrl, DETAIL_MODEL_DEFAULTS[stage]).allowedStages.includes(stage) ? imported.get(DETAIL_MODEL_DEFAULTS[stage]) || null : null)
|
||
await saveAiModelDefaults(db, { analysis: defaultFor('analysis'), copy: defaultFor('copy'), image: defaultFor('image') })
|
||
await aiModelAudit(db, userId, 'provider.import-environment', provider.id, { modelCount: imported.size, credentialSource: 'environment', baseUrl: provider.baseUrl })
|
||
})
|
||
return getAiModelAdminState()
|
||
}
|
||
|
||
export function parseAiModelCatalog(value: unknown): string[] {
|
||
if (!value || typeof value !== 'object' || !Array.isArray((value as { data?: unknown }).data)) throw new AiModelError('上游模型目录格式不兼容', 502, 'catalog_invalid')
|
||
const data = (value as { data: unknown[] }).data
|
||
if (data.length > 5000) throw new AiModelError('上游模型目录过大', 502, 'catalog_invalid')
|
||
const models = data.map(entry => entry && typeof entry === 'object' ? (entry as { id?: unknown }).id : undefined)
|
||
if (models.some(model => typeof model !== 'string' || !model.length || model.length > 200 || model.trim() !== model || /[\x00-\x1f\x7f]/.test(model))) throw new AiModelError('上游模型编号格式无效', 502, 'catalog_invalid')
|
||
return Array.from(new Set(models as string[])).sort()
|
||
}
|
||
|
||
export async function syncAiModelProvider(userId: string, providerId: string, body: unknown): Promise<AiModelAdminState> {
|
||
assertAiModelId(providerId); const input = parse(z.object({ revision: revision.min(1) }).strict(), body)
|
||
const source = await getAiModelProviderRow(providerId); assertAiModelRevision(source.revision, input.revision)
|
||
const apiKey = providerKey(source)
|
||
let response: Response
|
||
try { response = await requestAiModel(source.baseUrl, apiKey, '/models', undefined, undefined, async () => { const current = await getAiModelProviderRow(providerId); assertAiModelRevision(current.revision, source.revision); providerKey(current); await assertAiModelAdmin(userId) }) } catch (error) { if (error instanceof AiModelError) throw error; throw new AiModelError('上游模型目录连接失败,请检查服务地址和密钥', 502, 'provider_connection_failed') }
|
||
if (!response.ok) throw new AiModelError(`上游拒绝了目录请求(${response.status})`, 502, 'provider_rejected')
|
||
let value: unknown; try { value = await response.json() } catch { throw new AiModelError('上游模型目录不是有效 JSON', 502, 'catalog_invalid') }
|
||
const models = parseAiModelCatalog(value)
|
||
if (models.some(model => model.includes(apiKey))) throw new AiModelError('上游模型目录包含敏感内容,已拒绝保存', 502, 'catalog_invalid')
|
||
const metadata = new Map<string, AiModelCapabilityMetadata | null>()
|
||
for (const entry of (value as { data: Array<Record<string, unknown>> }).data) {
|
||
const normalized = parseAiModelCapabilityMetadata(entry, source.baseUrl + '/models')
|
||
if (metadata.has(entry.id as string) && JSON.stringify(metadata.get(entry.id as string)) !== JSON.stringify(normalized)) throw new AiModelError('上游同名模型能力冲突,未保存本次同步', 502, 'catalog_invalid')
|
||
metadata.set(entry.id as string, normalized)
|
||
}
|
||
if (source.baseUrl === 'https://api.vip98k.com/v1') {
|
||
let publicResponse: Response
|
||
try { publicResponse = await requestAiModelCapabilityCatalog(source.baseUrl, async () => { const current = await getAiModelProviderRow(providerId); assertAiModelRevision(current.revision, source.revision); await assertAiModelAdmin(userId) }) }
|
||
catch (error) { if (error instanceof AiModelError) throw error; throw new AiModelError('上游能力目录连接失败,未保存本次同步', 502, 'capability_catalog_failed') }
|
||
if (!publicResponse.ok) throw new AiModelError('上游能力目录请求失败,未保存本次同步', 502, 'capability_catalog_failed')
|
||
let publicValue: unknown
|
||
try { publicValue = await publicResponse.json() } catch { throw new AiModelError('上游能力目录不是有效 JSON', 502, 'capability_catalog_invalid') }
|
||
const capabilities = parseAiModelPublicCapabilityCatalog(publicValue)
|
||
for (const modelId of models) metadata.set(modelId, mergeAiModelCapabilityMetadata(metadata.get(modelId) || null, capabilities.get(modelId) || null))
|
||
}
|
||
if (Array.from(metadata.values()).some(evidence => JSON.stringify(evidence).includes(apiKey))) throw new AiModelError('上游能力目录包含敏感内容,已拒绝保存', 502, 'catalog_invalid')
|
||
await aiModelTransaction(async db => {
|
||
await assertAiModelAdmin(userId)
|
||
const current = await getAiModelProviderRow(providerId, db); assertAiModelRevision(current.revision, source.revision)
|
||
const prior = (await listAiModelRows(db)).filter(model => model.providerId === providerId)
|
||
const catalogChanged = prior.some(model => model.present !== models.includes(model.id)) || models.some(model => !prior.some(entry => entry.id === model && aiModelCapabilityFingerprint(entry.capabilityMetadata) === aiModelCapabilityFingerprint(metadata.get(model))))
|
||
await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE WHERE provider_id=$1`, providerId)
|
||
for (const modelId of models) await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,capability_metadata) VALUES ($1,$2,$3,$4::jsonb) ON CONFLICT (provider_id,model_id) DO UPDATE SET present=TRUE,capability_metadata=EXCLUDED.capability_metadata`, randomUUID(), providerId, modelId, JSON.stringify(metadata.get(modelId) ?? null))
|
||
await db.$executeRawUnsafe(`UPDATE ai_model_providers SET synced_at=NOW(),revision=revision+$2,updated_at=NOW() WHERE id=$1`, providerId, catalogChanged ? 1 : 0)
|
||
await aiModelAudit(db, userId, 'provider.sync', providerId, { modelCount: models.length, addedCount: models.filter(model => !prior.some(entry => entry.id === model)).length, catalogChanged })
|
||
})
|
||
return getAiModelAdminState()
|
||
}
|
||
|
||
async function validateDefaults(db: AiModelDatabase, defaults: AiModelDefaults): Promise<void> {
|
||
const [providers, models] = await Promise.all([listAiModelProviderRows(db), listAiModelRows(db)])
|
||
for (const stage of AI_MODEL_STAGES) {
|
||
if (!defaults[stage]) continue
|
||
const model = models.find(value => value.key === defaults[stage]); const provider = model && providers.find(value => value.id === model.providerId)
|
||
if (!model || !provider || !modelAvailable(model, provider, stage)) throw new AiModelError('默认模型须来自已启用服务,且已确认对应阶段接口兼容', 400, 'default_unavailable')
|
||
}
|
||
}
|
||
|
||
export async function configureAiModels(userId: string, body: unknown): Promise<AiModelAdminState> {
|
||
const input: AiModelConfigurationInput = parse(configurationSchema, body)
|
||
if (new Set(input.providers.map(provider => provider.id)).size !== input.providers.length) throw new AiModelError('服务编号重复')
|
||
await aiModelTransaction(async db => {
|
||
await assertAiModelAdmin(userId)
|
||
assertAiModelRevision((await getAiModelConfiguration(db)).revision, input.revision)
|
||
const prior = await listAiModelRows(db)
|
||
const changes: Array<{ key: string; providerId: string; enabled: boolean; stages: AiModelStage[]; compatibility: string }> = []
|
||
for (const provider of input.providers) {
|
||
const current = await getAiModelProviderRow(provider.id, db); assertAiModelRevision(current.revision, provider.revision)
|
||
if (new Set(provider.models.map(model => model.key)).size !== provider.models.length) throw new AiModelError('模型编号重复')
|
||
let changed = false
|
||
for (const model of provider.models) {
|
||
const existing = prior.find(value => value.key === model.key && value.providerId === provider.id)
|
||
if (!existing) throw new AiModelError('模型不属于当前服务')
|
||
const capabilities = getAiModelCapabilities(current.baseUrl, existing.id, existing.capabilityMetadata)
|
||
if (model.stages.some(stage => !capabilities.allowedStages.includes(stage))) throw new AiModelError('所选用途超出模型已确认的能力范围,管理员确认不能扩展模型能力', 400, 'model_capability_unsupported')
|
||
if (new Set(model.stages).size !== model.stages.length || model.enabled && (!model.stages.length || model.compatibility !== 'confirmed' || !existing.present)) throw new AiModelError('启用模型前须选择用途、确认接口兼容,并确认仍在上游目录中')
|
||
if (existing.enabled !== model.enabled || existing.compatibility !== model.compatibility || JSON.stringify([...existing.stages].sort()) !== JSON.stringify([...model.stages].sort())) { changed = true; changes.push({ ...model, providerId: provider.id }) }
|
||
await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET enabled=$2,stages=$3::text[],compatibility=$4,updated_at=NOW() WHERE id=$1`, model.key, model.enabled, model.stages, model.compatibility)
|
||
}
|
||
if (changed) await db.$executeRawUnsafe(`UPDATE ai_model_providers SET revision=revision+1,updated_at=NOW() WHERE id=$1`, provider.id)
|
||
}
|
||
await validateDefaults(db, input.defaults)
|
||
await saveAiModelDefaults(db, input.defaults)
|
||
await aiModelAudit(db, userId, 'configuration.update', null, { defaults: input.defaults, changes, providers: input.providers.map(provider => ({ id: provider.id, modelCount: provider.models.length })) })
|
||
})
|
||
return getAiModelAdminState()
|
||
}
|
||
|
||
export async function getAiModelRuntimeState(): Promise<AiModelRuntimeState> {
|
||
const [providers, models, config] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration()])
|
||
const result: AiModelRuntimeState = { configured: true, defaults: { analysis: '', copy: '', image: '' }, models: { analysis: [], copy: [], image: [] }, modelOptions: { analysis: [], copy: [], image: [] }, legacyModels: { analysis: [], copy: [], image: [] } }
|
||
for (const stage of AI_MODEL_STAGES) {
|
||
for (const model of models) {
|
||
const provider = providers.find(value => value.id === model.providerId)
|
||
if (!provider || !modelAvailable(model, provider, stage)) continue
|
||
result.models[stage].push(model.key)
|
||
result.modelOptions[stage].push({ value: model.key, label: model.id, providerName: provider.name })
|
||
if (provider.legacyBinding) result.legacyModels[stage].push(model.id)
|
||
}
|
||
result.defaults[stage] = config.defaults[stage] && result.models[stage].includes(config.defaults[stage]!) ? config.defaults[stage]! : ''
|
||
if (!result.defaults[stage]) result.configured = false
|
||
}
|
||
return result
|
||
}
|
||
|
||
export async function resolveAiModel(stage: AiModelStage, selection: string, expected?: AiModelBinding): Promise<{ binding: AiModelBinding; apiKey: string }> {
|
||
const [providers, models] = await Promise.all([listAiModelProviderRows(), listAiModelRows()])
|
||
const model = models.find(value => value.key === selection) || models.find(value => value.id === selection && providers.some(provider => provider.id === value.providerId && provider.legacyBinding))
|
||
const provider = model && providers.find(value => value.id === model.providerId)
|
||
if (!model || !provider || !modelAvailable(model, provider, stage)) throw new AiModelError('所选模型当前不可用,请检查超级管理员的模型配置', 400, 'model_unavailable')
|
||
const binding: AiModelBinding = { providerId: provider.id, modelKey: model.key, modelId: model.id, providerName: provider.name, baseUrl: provider.baseUrl, revision: provider.revision }
|
||
if (expected && (expected.providerId !== binding.providerId || expected.modelKey !== binding.modelKey || expected.modelId !== binding.modelId || expected.baseUrl !== binding.baseUrl || expected.revision !== binding.revision)) throw new AiModelError('模型连接配置已改变,请刷新模型选择后重新分析;当前任务已停止', 409, 'model_binding_changed')
|
||
return { binding, apiKey: providerKey(provider) }
|
||
}
|
||
export async function captureAiModelBindings(models: Record<AiModelStage, string>): Promise<AiModelBindings> {
|
||
const values = await Promise.all(AI_MODEL_STAGES.map(async stage => [stage, (await resolveAiModel(stage, models[stage])).binding] as const))
|
||
return Object.fromEntries(values) as AiModelBindings
|
||
}
|