285 lines
29 KiB
TypeScript
285 lines
29 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto'
|
||
import { z } from 'zod'
|
||
import { AiModelError, assertAiModelAdmin, assertAiModelAdminForWrite } from './ai-model-access'
|
||
import { decryptAiModelKey, encryptAiModelKey, isAiModelEncryptionConfigured } from './ai-model-crypto'
|
||
import { normalizeAiModelBaseUrl, requestAiModel, requestAiModelCapabilityCatalog } from './ai-model-http'
|
||
import { aiModelCapabilityFingerprint, getAiModelCapabilities, getAiModelStageProtocol, 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'
|
||
import { aiModelVerificationContext, aiModelVerificationSummary, listAiModelVerifications, verifiedReferenceImageCount, type AiModelVerificationRow } from './ai-model-verification-state'
|
||
|
||
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) }).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')
|
||
}
|
||
/** Recovery notes are audit metadata, never a place to store credentials. */
|
||
export async function assertAiModelRecoveryTextSafe(providerId: string, text: string): Promise<void> {
|
||
const provider = await getAiModelProviderRow(providerId)
|
||
if (hasCredential(provider) && text.includes(providerKey(provider))) throw new AiModelError('核查记录不能包含模型密钥', 400, 'reconciliation_sensitive')
|
||
if (/\b(?:sk-[\w-]{12,}|Bearer\s+\S+|api[_-]?key\s*[:=]\s*\S+)/i.test(text)) throw new AiModelError('核查记录不能包含凭据', 400, 'reconciliation_sensitive')
|
||
}
|
||
function modelContext(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage): string | null {
|
||
const protocol = getAiModelStageProtocol(model.capabilityMetadata, stage)
|
||
return protocol && hasCredential(provider) ? aiModelVerificationContext(provider.baseUrl, model.id, model.capabilityMetadata, providerKey(provider), protocol) : null
|
||
}
|
||
function modelVerified(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage, verifications: AiModelVerificationRow[]): boolean {
|
||
if (aiModelVerificationSummary(verifications, model.key, stage, modelContext(model, provider, stage)).status !== 'passed') return false
|
||
const protocol = getAiModelStageProtocol(model.capabilityMetadata, stage)
|
||
return stage !== 'image' || protocol?.referenceContract !== 'probe-required' || verifiedReferenceImageCount(verifications.find(row => row.modelKey === model.key && row.stage === stage)) !== null
|
||
}
|
||
function modelAvailable(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage, verifications: AiModelVerificationRow[]): boolean { return provider.enabled && hasCredential(provider) && model.present && model.enabled && model.stages.includes(stage) && getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage) && modelVerified(model, provider, stage, verifications) }
|
||
|
||
export async function getAiModelAdminState(): Promise<AiModelAdminState> {
|
||
const [providers, models, configuration, verifications] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration(), listAiModelVerifications()])
|
||
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 storedCapabilities = getAiModelCapabilities(provider.baseUrl, model.id, capabilityMetadata)
|
||
const capabilities = model.present ? storedCapabilities : { ...storedCapabilities, allowedStages: [], protocols: {}, reason: '已从当前连接的模型目录下架,暂不可用' }
|
||
const fullModel = { ...model, providerId: provider.id, capabilityMetadata }
|
||
const verification = Object.fromEntries(AI_MODEL_STAGES.map(stage => {
|
||
const result = verifications.find(value => value.modelKey === model.key && value.stage === stage)
|
||
const group = result?.evidence?.purpose === 'analysis-copy' ? verifications.filter(value => value.modelKey === model.key && value.evidence?.executionGroupId === result.evidence.executionGroupId) : []
|
||
const originalCanBeRead = !!result?.taskId || ['openai-chat', 'anthropic-messages', 'openai-image-generate', 'openai-image-edit'].includes(capabilities.protocols?.[stage]?.id || '')
|
||
const queryAvailable = provider.enabled && model.present && originalCanBeRead && (result?.evidence?.purpose !== 'analysis-copy' || group.length === 2 && group.every(value => storedCapabilities.allowedStages.includes(value.stage) && value.contextHash === modelContext(fullModel, provider, value.stage)))
|
||
return [stage, aiModelVerificationSummary(verifications, model.key, stage, modelContext(fullModel, provider, stage), queryAvailable)]
|
||
})) as import('./ai-model-types').AiModelCatalogEntry['verification']
|
||
return { ...model, capabilities, verification }
|
||
}) })), ...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> {
|
||
await assertAiModelAdmin(userId)
|
||
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 assertAiModelAdminForWrite(userId, db)
|
||
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> {
|
||
await assertAiModelAdmin(userId)
|
||
assertAiModelId(providerId); const input = parse(updateSchema, body)
|
||
const encrypted = input.apiKey ? encryptAiModelKey(input.apiKey) : null
|
||
await aiModelTransaction(async db => {
|
||
await assertAiModelAdminForWrite(userId, db)
|
||
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[],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> {
|
||
await assertAiModelAdmin(userId)
|
||
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 assertAiModelAdminForWrite(userId, db)
|
||
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) VALUES ($1,$2,$3,FALSE,$4::text[],TRUE)`, modelKey, provider.id, modelId, selected)
|
||
}
|
||
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> {
|
||
await assertAiModelAdmin(userId)
|
||
assertAiModelId(providerId); const input = parse(z.object({ revision: revision.min(1), modelKeys: z.array(id).min(1).max(5000).optional() }).strict(), body)
|
||
const source = await getAiModelProviderRow(providerId); assertAiModelRevision(source.revision, input.revision)
|
||
if (input.modelKeys) {
|
||
const owned = (await listAiModelRows()).filter(model => model.providerId === providerId)
|
||
if (input.modelKeys.some(key => !owned.some(model => model.key === key))) throw new AiModelError('所选模型不属于当前上游,未更新目录', 400, 'catalog_selection_invalid')
|
||
}
|
||
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 assertAiModelAdminForWrite(userId, db)
|
||
const current = await getAiModelProviderRow(providerId, db); assertAiModelRevision(current.revision, source.revision)
|
||
const prior = (await listAiModelRows(db)).filter(model => model.providerId === providerId)
|
||
const selected = input.modelKeys && new Set(input.modelKeys)
|
||
if (selected && Array.from(selected).some(key => !prior.some(model => model.key === key))) throw new AiModelError('所选模型不属于当前上游,未更新目录', 400, 'catalog_selection_invalid')
|
||
const scopedPrior = selected ? prior.filter(model => selected.has(model.key)) : prior
|
||
const selectedIds = selected && new Set(scopedPrior.map(model => model.id))
|
||
const scopedModels = selectedIds ? models.filter(model => selectedIds.has(model)) : models
|
||
const catalogChanged = scopedPrior.some(model => model.present !== scopedModels.includes(model.id)) || scopedModels.some(model => !scopedPrior.some(entry => entry.id === model && aiModelCapabilityFingerprint(entry.capabilityMetadata) === aiModelCapabilityFingerprint(metadata.get(model))))
|
||
if (selected) await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE WHERE provider_id=$1 AND id=ANY($2::text[])`, providerId, Array.from(selected))
|
||
else await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE WHERE provider_id=$1`, providerId)
|
||
for (const modelId of scopedModels) 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: scopedModels.length, addedCount: scopedModels.filter(model => !prior.some(entry => entry.id === model)).length, catalogChanged, ...(selected ? { modelKeys: Array.from(selected) } : {}) })
|
||
})
|
||
return getAiModelAdminState()
|
||
}
|
||
|
||
async function validateDefaults(db: AiModelDatabase, defaults: AiModelDefaults): Promise<void> {
|
||
const [providers, models, verifications] = await Promise.all([listAiModelProviderRows(db), listAiModelRows(db), listAiModelVerifications(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, verifications)) throw new AiModelError('默认模型须已启用,并通过当前连接对应用途的实际测试', 400, 'default_unavailable')
|
||
}
|
||
}
|
||
|
||
export async function configureAiModels(userId: string, body: unknown): Promise<AiModelAdminState> {
|
||
await assertAiModelAdmin(userId)
|
||
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 assertAiModelAdminForWrite(userId, db)
|
||
assertAiModelRevision((await getAiModelConfiguration(db)).revision, input.revision)
|
||
const prior = await listAiModelRows(db)
|
||
const verifications = await listAiModelVerifications(db)
|
||
const changes: Array<{ key: string; providerId: string; enabled: boolean; stages: AiModelStage[] }> = []
|
||
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('模型不属于当前服务')
|
||
if (new Set(model.stages).size !== model.stages.length) throw new AiModelError('所选用途重复', 400, 'model_verification_required')
|
||
const stagesUnchanged = JSON.stringify([...existing.stages].sort()) === JSON.stringify([...model.stages].sort())
|
||
if (existing.enabled === model.enabled && stagesUnchanged) continue
|
||
const capabilities = getAiModelCapabilities(current.baseUrl, existing.id, existing.capabilityMetadata)
|
||
const onlyRetainsPriorStagesWhileDisabled = !model.enabled && model.stages.every(stage => existing.stages.includes(stage))
|
||
if (!onlyRetainsPriorStagesWhileDisabled && model.stages.some(stage => !capabilities.allowedStages.includes(stage))) throw new AiModelError('所选用途不适用于该模型的当前接口与能力', 400, 'model_capability_unsupported')
|
||
if (model.enabled && (!model.stages.length || !existing.present || model.stages.some(stage => !modelVerified(existing, current, stage, verifications)))) throw new AiModelError('启用前须通过所选用途的实际测试,并确认模型仍在上游目录中', 400, 'model_verification_required')
|
||
changed = true; changes.push({ ...model, providerId: provider.id })
|
||
await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET enabled=$2,stages=$3::text[],updated_at=NOW() WHERE id=$1`, model.key, model.enabled, model.stages)
|
||
}
|
||
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, verifications] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration(), listAiModelVerifications()])
|
||
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, verifications)) 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) {
|
||
const [providers, models, verifications] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), listAiModelVerifications()])
|
||
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, verifications)) throw new AiModelError('所选模型当前不可用,请检查超级管理员的模型配置及实际测试结果', 400, 'model_unavailable')
|
||
const protocolFingerprint = createHash('sha256').update(aiModelCapabilityFingerprint(model.capabilityMetadata)).update(JSON.stringify(getAiModelStageProtocol(model.capabilityMetadata, stage))).digest('hex')
|
||
// Pin the server-owned validation generation too: environment key rotation does not change the provider revision.
|
||
const verificationId = aiModelVerificationSummary(verifications, model.key, stage, modelContext(model, provider, stage)).id!
|
||
const binding: AiModelBinding = { providerId: provider.id, modelKey: model.key, modelId: model.id, providerName: provider.name, baseUrl: provider.baseUrl, revision: provider.revision, protocolFingerprint, verificationId }
|
||
// The provider revision also tracks other models' visibility settings. Execution identity
|
||
// is pinned by the endpoint, protocol and credential-bound verification, not that edit counter.
|
||
if (expected && (expected.providerId !== binding.providerId || expected.modelKey !== binding.modelKey || expected.modelId !== binding.modelId || expected.baseUrl !== binding.baseUrl || expected.protocolFingerprint !== binding.protocolFingerprint || expected.verificationId !== binding.verificationId)) throw new AiModelError('模型连接、路由或测试记录已改变,请刷新模型选择后重新分析;当前任务已停止', 409, 'model_binding_changed')
|
||
const protocol = getAiModelStageProtocol(model.capabilityMetadata, stage)!
|
||
const testedCount = stage === 'image' && protocol.referenceContract === 'probe-required' ? verifiedReferenceImageCount(verifications.find(row => row.modelKey === model.key && row.stage === stage)) : null
|
||
return { binding, apiKey: providerKey(provider), protocol: testedCount ? { ...protocol, maxImages: Math.min(protocol.maxImages ?? 8, testedCount) } : protocol }
|
||
}
|
||
export async function resolveAiModelVerification(providerId: string, modelKey: string, stage: AiModelStage, expectedRevision?: number) {
|
||
const provider = await getAiModelProviderRow(assertAiModelId(providerId))
|
||
if (expectedRevision !== undefined) assertAiModelRevision(provider.revision, expectedRevision)
|
||
const model = (await listAiModelRows()).find(row => row.key === assertAiModelId(modelKey) && row.providerId === providerId)
|
||
const protocol = model && getAiModelStageProtocol(model.capabilityMetadata, stage)
|
||
if (!model || !protocol || !provider.enabled || !model.present || !hasCredential(provider) || !getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage)) throw new AiModelError('该模型的当前连接或用途暂不支持测试', 400, 'model_capability_unsupported')
|
||
return { provider, model, protocol, apiKey: providerKey(provider), contextHash: modelContext(model, provider, stage)! }
|
||
}
|
||
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
|
||
}
|