fix(ai): preserve project bindings across unrelated model settings

This commit is contained in:
WuYongJie 2026-10-16 13:08:45 +08:00
parent 441878adb9
commit 88e0a058e2
3 changed files with 25 additions and 2 deletions

View File

@ -68,6 +68,8 @@ HTTP 410“未留存”、访问失败、格式或模型身份无法核对时,
是否可向用户开放,以当前服务端用途验证记录为准。实际执行过哪些模型及产品验收项目,须另附对应记录,不从目录数量、适配协议数量或部署成功推断。
项目中的服务配置版本保留作审计。仅调整其它模型的开放用途或默认值,不会使当前模型身份不变的项目失效,也不要求重复付费分析。执行前仍检查当前权限、用途可用性、服务地址、协议指纹及密钥对应的验证记录;真正更换连接、密钥、协议或验证记录时,原模型快照仍会被拒绝。
## 详情页资料分析与文案恢复
`POST /api/detail-generator/jobs/{id}/recover` 复用登录鉴权,当前数据库超级管理员资格与任务创建者资格均须通过,且必须发送 `Content-Type: application/json`。

View File

@ -68,9 +68,28 @@ test.each(['disabled-provider', 'disabled-model', 'missing-model', 'unconfirmed'
await expect(configureAiModels('admin', { revision: 1, providers: [], defaults: { analysis: legacyModelKey, copy: null, image: null } })).rejects.toMatchObject({ code: 'default_unavailable' })
expect(saveAiModelDefaults).not.toHaveBeenCalled()
})
test('changed connection revisions stop existing bindings before any model request', async () => {
test('unrelated catalog settings do not invalidate an unchanged project model identity', async () => {
const binding = (await resolveAiModel('image', secondModelKey)).binding
providers[1].revision++
await expect(resolveAiModel('image', secondModelKey, binding)).resolves.toMatchObject({ binding: { ...binding, revision: 2 } })
expect(requestAiModel).not.toHaveBeenCalled()
})
test('a catalog revision change cannot revive a withdrawn purpose on an existing project', async () => {
const binding = (await resolveAiModel('image', secondModelKey)).binding
providers[1].revision++
models[1].stages = ['analysis', 'copy']
await expect(resolveAiModel('image', secondModelKey, binding)).rejects.toMatchObject({ code: 'model_unavailable' })
expect(requestAiModel).not.toHaveBeenCalled()
})
test('a rotated encrypted credential and its new validation cannot reuse an old binding', async () => {
const binding = (await resolveAiModel('image', secondModelKey)).binding
const prior = await listAiModelVerifications()
Object.assign(providers[1], encryptAiModelKey('synthetic-new-encrypted-secret'), { revision: 2 })
await expect(resolveAiModel('image', secondModelKey, binding)).rejects.toMatchObject({ code: 'model_unavailable' })
;(listAiModelVerifications as jest.Mock).mockResolvedValue(prior.map(value => value.modelKey === secondModelKey && value.stage === 'image' ? {
...value, id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
contextHash: aiModelVerificationContext(providers[1].baseUrl, models[1].id, models[1].capabilityMetadata, 'synthetic-new-encrypted-secret', getAiModelStageProtocol(models[1].capabilityMetadata, 'image')!),
} : value))
await expect(resolveAiModel('image', secondModelKey, binding)).rejects.toMatchObject({ code: 'model_binding_changed' })
expect(requestAiModel).not.toHaveBeenCalled()
})

View File

@ -263,7 +263,9 @@ export async function resolveAiModel(stage: AiModelStage, selection: string, exp
// 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 }
if (expected && (expected.providerId !== binding.providerId || expected.modelKey !== binding.modelKey || expected.modelId !== binding.modelId || expected.baseUrl !== binding.baseUrl || expected.revision !== binding.revision || expected.protocolFingerprint !== binding.protocolFingerprint || expected.verificationId !== binding.verificationId)) throw new AiModelError('模型连接、路由或测试记录已改变,请刷新模型选择后重新分析;当前任务已停止', 409, 'model_binding_changed')
// 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 }