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