fix: verify model generation and retain credentials

Save and test could accept a successful endpoint response without proving that the selected model generated output, while changing a connection URL or temporarily disabling authentication could require the API Key again.

Model tests now issue bounded real text or image generation requests and validate their output. API Keys remain encrypted with their connection across URL and authentication changes until explicitly cleared or the connection is deleted, and the form places the key directly below authentication. Real tests may incur a small amount of provider usage.

Release note: 修复模型“保存并测试”仅验证连通性的问题;现在会执行真实文本或图片生成测试,并在修改地址或临时关闭认证时继续保留该连接的加密 API Key。
This commit is contained in:
mesalogo
2026-08-20 11:48:16 +08:00
parent f44a0dc907
commit d7cb7ba181
14 changed files with 711 additions and 338 deletions
+166 -170
View File
@@ -294,19 +294,38 @@ const legacyStoredSettingsSchema = z.object({
toolApproval: toolApprovalPolicySchema
})
const credentialPayloadSchema = z.object({
version: z.literal(1),
apiKey: z.string(),
origin: z.string()
const savedApiKeyPayloadSchema = z.object({
version: z.literal(2),
apiKey: z.string()
})
const embeddingCredentialPayloadSchema = z.object({
version: z.literal(1),
apiKey: z.string(),
endpoint: z.string()
})
const credentialPayloadSchema = z.union([
savedApiKeyPayloadSchema,
z.object({
version: z.literal(1),
apiKey: z.string(),
origin: z.string()
})
])
const endpointCredentialPayloadSchema = z.union([
savedApiKeyPayloadSchema,
z.object({
version: z.literal(1),
apiKey: z.string(),
endpoint: z.string()
})
])
const encryptSavedApiKey = (
cipher: SettingsCredentialCipher,
apiKey: string
) =>
encryptSettingsCredential(cipher, {
version: 2,
apiKey
})
const rerankCredentialPayloadSchema = embeddingCredentialPayloadSchema
const platformHarnessProfileId = 'goodbuddy-platform-harness'
export type CredentialCipher = SettingsCredentialCipher
@@ -365,6 +384,12 @@ export type ResolvedModelProfile = {
apiKey?: string
}
type ResolvedModelCredential = {
activeApiKey?: string
configured: boolean
source: RuntimeSettings['credentialSource']
}
const defaultSettings: StoredSettings = {
version: 18,
provider: defaultRuntimeSettings.provider,
@@ -1031,14 +1056,8 @@ export class RuntimeSettingsStore {
const payload = credentialPayloadSchema.parse(
decryptSettingsCredential(this.cipher, profile.credential)
)
if (payload.origin !== new URL(profile.baseUrl).origin) {
return warning('runtime-model-credential-binding-mismatch')
}
this.removeWarnings(
[
'runtime-model-credential-unreadable',
'runtime-model-credential-binding-mismatch'
],
['runtime-model-credential-unreadable'],
profile.name
)
return payload.apiKey
@@ -1060,22 +1079,13 @@ export class RuntimeSettingsStore {
return undefined
}
try {
const payload = embeddingCredentialPayloadSchema.parse(
const payload = endpointCredentialPayloadSchema.parse(
decryptSettingsCredential(
this.cipher,
settings.knowledgeEmbeddingCredential
)
)
if (payload.endpoint !== settings.knowledgeEmbeddingBaseUrl) {
this.addWarning({
code: 'runtime-embedding-credential-binding-mismatch'
})
return undefined
}
this.removeWarnings([
'runtime-embedding-credential-unreadable',
'runtime-embedding-credential-binding-mismatch'
])
this.removeWarnings(['runtime-embedding-credential-unreadable'])
return payload.apiKey
} catch {
this.addWarning({
@@ -1098,22 +1108,13 @@ export class RuntimeSettingsStore {
return undefined
}
try {
const payload = rerankCredentialPayloadSchema.parse(
const payload = endpointCredentialPayloadSchema.parse(
decryptSettingsCredential(
this.cipher,
settings.knowledgeRerankCredential
)
)
if (payload.endpoint !== settings.knowledgeRerankEndpoint) {
this.addWarning({
code: 'runtime-rerank-credential-binding-mismatch'
})
return undefined
}
this.removeWarnings([
'runtime-rerank-credential-unreadable',
'runtime-rerank-credential-binding-mismatch'
])
this.removeWarnings(['runtime-rerank-credential-unreadable'])
return payload.apiKey
} catch {
this.addWarning({
@@ -1183,7 +1184,54 @@ export class RuntimeSettingsStore {
}
}
private resolveEffectiveModelSettings(settings: StoredSettings): {
private resolveModelCredentials(
settings: StoredSettings
): Map<string, ResolvedModelCredential> {
const defaultProfile =
settings.modelProfiles.find(
(profile) => profile.id === settings.defaultModelProfileId
) ?? settings.modelProfiles[0]
const environmentApiKey =
defaultProfile?.authentication === 'api-key'
? this.getEnvironmentApiKey()
: undefined
return new Map(
settings.modelProfiles.map((profile) => {
const environmentManaged =
profile.id === defaultProfile?.id &&
Boolean(environmentApiKey)
const storedApiKey = environmentManaged
? undefined
: this.getStoredApiKey(profile)
const source: RuntimeSettings['credentialSource'] =
environmentManaged
? 'environment'
: storedApiKey
? 'encrypted'
: profile.credential
? 'unreadable'
: 'none'
return [
profile.id,
{
activeApiKey:
profile.authentication === 'api-key'
? environmentManaged
? environmentApiKey
: storedApiKey
: undefined,
configured: environmentManaged || Boolean(storedApiKey),
source
}
]
})
)
}
private resolveEffectiveModelSettings(
settings: StoredSettings,
credentials: ReadonlyMap<string, ResolvedModelCredential>
): {
apiKey?: string
baseUrl: string
model: string
@@ -1201,36 +1249,25 @@ export class RuntimeSettingsStore {
if (!profile) {
throw new Error('默认模型连接不存在')
}
const environmentApiKey =
profile.authentication === 'api-key'
? this.getEnvironmentApiKey()
: undefined
const storedApiKey =
profile.authentication === 'api-key' && !environmentApiKey
? this.getStoredApiKey(profile)
: undefined
const credential = credentials.get(profile.id)
if (!credential) {
throw new Error(`模型连接不存在:${profile.id}`)
}
const environmentManaged = credential.source === 'environment'
const environmentBaseUrl =
this.environment.GOODBUDDY_MODEL_BASE_URL?.trim() ||
this.environment.GOODBUDDY_BIGTOKEN_BASE_URL?.trim()
const environmentModel =
this.environment.GOODBUDDY_MODEL_NAME?.trim() ||
this.environment.GOODBUDDY_BIGTOKEN_MODEL?.trim()
const baseUrl = environmentApiKey
const baseUrl = environmentManaged
? environmentBaseUrl || defaultRuntimeSettings.modelBaseUrl
: profile.baseUrl
const model = environmentApiKey
const model = environmentManaged
? environmentModel || defaultRuntimeSettings.modelName
: profile.modelName
const credentialSource: RuntimeSettings['credentialSource'] =
environmentApiKey
? 'environment'
: storedApiKey
? 'encrypted'
: profile.credential
? 'unreadable'
: 'none'
return {
apiKey: environmentApiKey ?? storedApiKey,
apiKey: credential.activeApiKey,
baseUrl,
model,
protocol: profile.protocol,
@@ -1238,7 +1275,7 @@ export class RuntimeSettingsStore {
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
imageGenerationQuality: profile.imageGenerationQuality,
credentialSource
credentialSource: credential.source
}
}
@@ -1246,39 +1283,41 @@ export class RuntimeSettingsStore {
settings: StoredSettings,
effective: ReturnType<
RuntimeSettingsStore['resolveEffectiveModelSettings']
>
>,
credentials: ReadonlyMap<string, ResolvedModelCredential>
): ResolvedModelProfile[] {
return settings.modelProfiles.map((profile) =>
profile.id === settings.defaultModelProfileId
? {
id: profile.id,
name: profile.name,
baseUrl: effective.baseUrl,
modelName: effective.model,
protocol: effective.protocol,
authentication: effective.authentication,
supportsImageInput: effective.supportsImageInput,
contextWindowTokens: effective.contextWindowTokens,
imageGenerationQuality:
effective.imageGenerationQuality,
apiKey: effective.apiKey
}
: {
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey:
profile.authentication === 'api-key'
? this.getStoredApiKey(profile)
: undefined
}
)
return settings.modelProfiles.map((profile) => {
if (profile.id === settings.defaultModelProfileId) {
return {
id: profile.id,
name: profile.name,
baseUrl: effective.baseUrl,
modelName: effective.model,
protocol: effective.protocol,
authentication: effective.authentication,
supportsImageInput: effective.supportsImageInput,
contextWindowTokens: effective.contextWindowTokens,
imageGenerationQuality: effective.imageGenerationQuality,
apiKey: effective.apiKey
}
}
const credential = credentials.get(profile.id)
if (!credential) {
throw new Error(`模型连接不存在:${profile.id}`)
}
return {
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: credential.activeApiKey
}
})
}
private resolveAgentSettings(settings: StoredSettings): {
@@ -1325,25 +1364,29 @@ export class RuntimeSettingsStore {
}
private toPublicSettings(settings: StoredSettings): RuntimeSettings {
const effective = this.resolveEffectiveModelSettings(settings)
const agent = this.resolveAgentSettings(settings)
const environmentApiKeyConfigured = Boolean(
this.getEnvironmentApiKey()
const credentials = this.resolveModelCredentials(settings)
const effective = this.resolveEffectiveModelSettings(
settings,
credentials
)
const agent = this.resolveAgentSettings(settings)
const resolvedModelProfiles = this.resolveModelProfiles(
settings,
effective
effective,
credentials
)
const resolvedProfilesById = new Map(
resolvedModelProfiles.map((profile) => [profile.id, profile])
)
const modelProfiles = settings.modelProfiles.map((profile) => {
const isDefault = profile.id === settings.defaultModelProfileId
const resolved = resolvedProfilesById.get(profile.id)
if (!resolved) {
throw new Error(`模型连接不存在:${profile.id}`)
}
const apiKey = resolved.apiKey
const credential = credentials.get(profile.id)
if (!credential) {
throw new Error(`模型连接不存在:${profile.id}`)
}
return {
id: profile.id,
name: profile.name,
@@ -1356,22 +1399,15 @@ export class RuntimeSettingsStore {
imageGenerationQuality:
resolved.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
apiKeyConfigured: Boolean(apiKey),
credentialSource: isDefault
? effective.credentialSource
: apiKey
? ('encrypted' as const)
: profile.credential
? ('unreadable' as const)
: ('none' as const)
apiKeyConfigured: credential.configured,
credentialSource: credential.source
}
})
const configuredModelProfiles = settings.modelProfiles.map((profile) => {
const environmentManaged =
profile.id === settings.defaultModelProfileId &&
profile.authentication === 'api-key' &&
environmentApiKeyConfigured
const apiKey = resolvedProfilesById.get(profile.id)?.apiKey
const credential = credentials.get(profile.id)
if (!credential) {
throw new Error(`模型连接不存在:${profile.id}`)
}
return {
id: profile.id,
name: profile.name,
@@ -1384,14 +1420,8 @@ export class RuntimeSettingsStore {
imageGenerationQuality:
profile.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
apiKeyConfigured: environmentManaged || Boolean(apiKey),
credentialSource: environmentManaged
? ('environment' as const)
: apiKey
? ('encrypted' as const)
: profile.credential
? ('unreadable' as const)
: ('none' as const)
apiKeyConfigured: credential.configured,
credentialSource: credential.source
}
})
const embeddingEnvironmentApiKey =
@@ -1529,9 +1559,17 @@ export class RuntimeSettingsStore {
async getResolvedSettings(): Promise<ResolvedRuntimeSettings> {
const settings = await this.load()
const effective = this.resolveEffectiveModelSettings(settings)
const credentials = this.resolveModelCredentials(settings)
const effective = this.resolveEffectiveModelSettings(
settings,
credentials
)
const agent = this.resolveAgentSettings(settings)
const modelProfiles = this.resolveModelProfiles(settings, effective)
const modelProfiles = this.resolveModelProfiles(
settings,
effective,
credentials
)
const profilesById = new Map(
modelProfiles.map((profile) => [profile.id, profile])
)
@@ -1664,17 +1702,6 @@ export class RuntimeSettingsStore {
const normalizedBaseUrl = normalizeModelBaseUrl(
environmentManaged ? existing.baseUrl : profile.baseUrl
)
if (
profile.authentication === 'api-key' &&
profile.apiKey.action === 'keep' &&
existing?.credential &&
new URL(existing.baseUrl).origin !==
new URL(normalizedBaseUrl).origin
) {
throw new Error(
`模型连接“${profile.name}”的服务地址已更改,请重新输入或清除 API Key`
)
}
const nextProfile: StoredSettings['modelProfiles'][number] = {
id: profile.id,
name: profile.name,
@@ -1689,7 +1716,6 @@ export class RuntimeSettingsStore {
imageGenerationQuality: profile.imageGenerationQuality
}
if (
profile.authentication === 'api-key' &&
profile.apiKey.action === 'keep' &&
existing?.credential
) {
@@ -1698,13 +1724,9 @@ export class RuntimeSettingsStore {
profile.authentication === 'api-key' &&
profile.apiKey.action === 'replace'
) {
nextProfile.credential = encryptSettingsCredential(
nextProfile.credential = encryptSavedApiKey(
this.cipher,
{
version: 1,
apiKey: profile.apiKey.value,
origin: new URL(normalizedBaseUrl).origin
}
profile.apiKey.value
)
}
return nextProfile
@@ -1715,15 +1737,6 @@ export class RuntimeSettingsStore {
).toString()
const embeddingApiKeyUpdate =
input.knowledgeEmbeddingApiKey ?? { action: 'keep' as const }
if (
embeddingApiKeyUpdate.action === 'keep' &&
current.knowledgeEmbeddingCredential &&
current.knowledgeEmbeddingBaseUrl !== embeddingEndpoint
) {
throw new Error(
'向量接口 URL 已更改,请重新输入或清除 API Key'
)
}
let knowledgeEmbeddingCredential: StoredSettings['knowledgeEmbeddingCredential']
if (
embeddingApiKeyUpdate.action === 'keep' &&
@@ -1732,13 +1745,9 @@ export class RuntimeSettingsStore {
knowledgeEmbeddingCredential =
current.knowledgeEmbeddingCredential
} else if (embeddingApiKeyUpdate.action === 'replace') {
knowledgeEmbeddingCredential = encryptSettingsCredential(
knowledgeEmbeddingCredential = encryptSavedApiKey(
this.cipher,
{
version: 1,
apiKey: embeddingApiKeyUpdate.value,
endpoint: embeddingEndpoint
}
embeddingApiKeyUpdate.value
)
}
@@ -1747,15 +1756,6 @@ export class RuntimeSettingsStore {
).toString()
const rerankApiKeyUpdate =
input.knowledgeRerankApiKey ?? { action: 'keep' as const }
if (
rerankApiKeyUpdate.action === 'keep' &&
current.knowledgeRerankCredential &&
current.knowledgeRerankEndpoint !== rerankEndpoint
) {
throw new Error(
'重排接口 URL 已更改,请重新输入或清除 API Key'
)
}
let knowledgeRerankCredential: StoredSettings['knowledgeRerankCredential']
if (
rerankApiKeyUpdate.action === 'keep' &&
@@ -1763,13 +1763,9 @@ export class RuntimeSettingsStore {
) {
knowledgeRerankCredential = current.knowledgeRerankCredential
} else if (rerankApiKeyUpdate.action === 'replace') {
knowledgeRerankCredential = encryptSettingsCredential(
knowledgeRerankCredential = encryptSavedApiKey(
this.cipher,
{
version: 1,
apiKey: rerankApiKeyUpdate.value,
endpoint: rerankEndpoint
}
rerankApiKeyUpdate.value
)
}