gongxiangp2/lib/ai-model-service.ts

285 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}