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:
@@ -3402,11 +3402,119 @@ describe('SettingsPanel runtime files', () => {
|
||||
await waitFor(() =>
|
||||
expect(onNotify).toHaveBeenCalledWith({
|
||||
tone: 'success',
|
||||
message: '连接成功:sonnet-5',
|
||||
message: '真实生成测试通过:sonnet-5',
|
||||
dedupeKey: 'model-connection-tested'
|
||||
})
|
||||
)
|
||||
expect(screen.queryByText('连接成功:sonnet-5')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('真实生成测试通过:sonnet-5')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('places the API Key directly after authentication and keeps it when the model URL changes', async () => {
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'encrypted',
|
||||
modelProfiles: [
|
||||
{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'encrypted'
|
||||
}
|
||||
]
|
||||
})
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
const authentication = screen.getByLabelText(
|
||||
'认证方式 默认模型'
|
||||
)
|
||||
const apiKey = screen.getByLabelText('API Key')
|
||||
expect(
|
||||
authentication.closest('label')?.nextElementSibling
|
||||
).toBe(apiKey.closest('label'))
|
||||
expect(apiKey).toHaveAttribute(
|
||||
'placeholder',
|
||||
'已配置,留空保持不变'
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('https://bigtoken.ai'), {
|
||||
target: { value: 'https://new-model.example/v1' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
baseUrl: 'https://new-model.example/v1',
|
||||
apiKey: { action: 'keep' }
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not clear a saved API Key when authentication is temporarily disabled', async () => {
|
||||
getRuntime.mockResolvedValueOnce({
|
||||
...runtimeSettings,
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'encrypted',
|
||||
modelProfiles: [
|
||||
{
|
||||
...runtimeSettings.modelProfiles[0]!,
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'encrypted'
|
||||
}
|
||||
]
|
||||
})
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('默认模型')
|
||||
fireEvent.change(screen.getByLabelText('认证方式 默认模型'), {
|
||||
target: { value: 'none' }
|
||||
})
|
||||
expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'当前无需认证;已保存的 API Key 仍保留在此连接中'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
authentication: 'none',
|
||||
apiKey: { action: 'keep' }
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('shows an actionable model error without Electron IPC prefixes', async () => {
|
||||
|
||||
@@ -1138,7 +1138,8 @@ export function SettingsPanel({
|
||||
imageGenerationQuality: profile.imageGenerationQuality,
|
||||
apiKey: profile.clearApiKey
|
||||
? ({ action: 'clear' } as const)
|
||||
: profile.apiKey.trim()
|
||||
: profile.authentication === 'api-key' &&
|
||||
profile.apiKey.trim()
|
||||
? ({
|
||||
action: 'replace',
|
||||
value: profile.apiKey.trim()
|
||||
@@ -1295,12 +1296,13 @@ export function SettingsPanel({
|
||||
}
|
||||
onNotify({
|
||||
tone: 'success',
|
||||
message:
|
||||
status.capability === 'image-generation'
|
||||
? status.detail
|
||||
: t('notifications.connectionSucceeded', {
|
||||
label: status.label
|
||||
}),
|
||||
message: testingModel
|
||||
? t('notifications.modelGenerationTestSucceeded', {
|
||||
label: status.label
|
||||
})
|
||||
: t('notifications.connectionSucceeded', {
|
||||
label: status.label
|
||||
}),
|
||||
dedupeKey: testingModel
|
||||
? 'model-connection-tested'
|
||||
: `runtime-connection-tested-${agentRuntimeType}`
|
||||
@@ -2765,11 +2767,7 @@ export function SettingsPanel({
|
||||
const authentication = event.target
|
||||
.value as ModelProfileDraft['authentication']
|
||||
updateModelProfile(profile.id, {
|
||||
authentication,
|
||||
apiKey: '',
|
||||
clearApiKey:
|
||||
authentication === 'none' &&
|
||||
profile.apiKeyConfigured
|
||||
authentication
|
||||
})
|
||||
}}
|
||||
value={profile.authentication}
|
||||
@@ -2780,6 +2778,67 @@ export function SettingsPanel({
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{profile.authentication === 'api-key' ? (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>API Key</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
disabled={
|
||||
environmentManaged ||
|
||||
!settings?.secureStorageAvailable
|
||||
}
|
||||
onChange={(event) =>
|
||||
updateModelProfile(profile.id, {
|
||||
apiKey: event.target.value,
|
||||
clearApiKey: false
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
profile.apiKeyConfigured
|
||||
? t('credentials.configuredPlaceholder')
|
||||
: t('credentials.enterApiKey')
|
||||
}
|
||||
type="password"
|
||||
value={profile.apiKey}
|
||||
/>
|
||||
</label>
|
||||
<div className="credential-state">
|
||||
<LockKeyhole size={15} />
|
||||
<span>
|
||||
{t(
|
||||
`credentials.${profile.credentialSource}`
|
||||
)}
|
||||
</span>
|
||||
{profile.credentialSource === 'encrypted' && (
|
||||
<button
|
||||
onClick={() =>
|
||||
updateModelProfile(profile.id, {
|
||||
apiKey: '',
|
||||
clearApiKey: true
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{profile.clearApiKey
|
||||
? t('actions.clearAfterSave')
|
||||
: t('actions.clearCredential')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="credential-state">
|
||||
<LockKeyhole size={15} />
|
||||
<span>
|
||||
{profile.apiKeyConfigured
|
||||
? t('credentials.savedForConnection')
|
||||
: t(
|
||||
'credentials.noAuthenticationDescription'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isAgentRuntimeModelProtocol(profile.protocol) && (
|
||||
<>
|
||||
<div className="field">
|
||||
@@ -2864,63 +2923,6 @@ export function SettingsPanel({
|
||||
</small>
|
||||
</label>
|
||||
)}
|
||||
{profile.authentication === 'api-key' ? (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>API Key</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
disabled={
|
||||
environmentManaged ||
|
||||
!settings?.secureStorageAvailable
|
||||
}
|
||||
onChange={(event) =>
|
||||
updateModelProfile(profile.id, {
|
||||
apiKey: event.target.value,
|
||||
clearApiKey: false
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
profile.apiKeyConfigured
|
||||
? t('credentials.configuredPlaceholder')
|
||||
: t('credentials.enterApiKey')
|
||||
}
|
||||
type="password"
|
||||
value={profile.apiKey}
|
||||
/>
|
||||
</label>
|
||||
<div className="credential-state">
|
||||
<LockKeyhole size={15} />
|
||||
<span>
|
||||
{t(
|
||||
`credentials.${profile.credentialSource}`
|
||||
)}
|
||||
</span>
|
||||
{profile.credentialSource === 'encrypted' && (
|
||||
<button
|
||||
onClick={() =>
|
||||
updateModelProfile(profile.id, {
|
||||
apiKey: '',
|
||||
clearApiKey: true
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{profile.clearApiKey
|
||||
? t('actions.clearAfterSave')
|
||||
: t('actions.clearCredential')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="credential-state">
|
||||
<LockKeyhole size={15} />
|
||||
<span>
|
||||
{t('credentials.noAuthenticationDescription')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<small className="model-connection-detail__compatibility">
|
||||
{t('model.profile.compatibilitySummary', {
|
||||
directCapability:
|
||||
|
||||
@@ -147,6 +147,8 @@ export const settings = {
|
||||
notifications: {
|
||||
settingsSaved: 'Settings saved',
|
||||
connectionSucceeded: 'Connected: {{label}}',
|
||||
modelGenerationTestSucceeded:
|
||||
'Real generation test passed: {{label}}',
|
||||
documentParsingSaved: 'Document parsing settings saved',
|
||||
documentParsingTestSucceeded: 'Document parsing test completed'
|
||||
},
|
||||
@@ -159,7 +161,9 @@ export const settings = {
|
||||
enterApiKey: 'Enter API Key',
|
||||
noAuthentication: 'No authentication',
|
||||
noAuthenticationDescription:
|
||||
'No authentication is required; no API Key will be sent'
|
||||
'No authentication is required; no API Key will be sent',
|
||||
savedForConnection:
|
||||
'Authentication is currently disabled; the saved API Key remains stored with this connection'
|
||||
},
|
||||
runtime: {
|
||||
configCard: {
|
||||
@@ -684,7 +688,7 @@ export const settings = {
|
||||
generatedName: 'Model connection {{count}}',
|
||||
title: 'LLM model connections',
|
||||
description:
|
||||
'Supports OpenAI Responses, Anthropic Messages, and OpenAI-compatible Chat Completions. Image models use the separate OpenAI Images Generations protocol.',
|
||||
'Supports OpenAI Responses, Anthropic Messages, and OpenAI-compatible Chat Completions. Image models use the separate OpenAI Images Generations protocol. “Save and test model” sends a real text or image generation request and may incur provider usage charges.',
|
||||
listAriaLabel: 'Model connection list',
|
||||
listTitle: 'Connections',
|
||||
editAriaLabel: 'Edit model connection {{name}}',
|
||||
|
||||
@@ -12,16 +12,10 @@ export const warnings = {
|
||||
'The Runtime settings file was corrupt. The original file was isolated, and defaults are now in use.',
|
||||
'runtime-model-credential-unreadable':
|
||||
'The API Key for model connection “{{subject}}” cannot be read. Re-enter or clear this credential.',
|
||||
'runtime-model-credential-binding-mismatch':
|
||||
'The service address for model connection “{{subject}}” does not match its saved API Key. Re-enter or clear this credential.',
|
||||
'runtime-embedding-credential-unreadable':
|
||||
'The embedding model API Key cannot be read. Re-enter or clear this credential.',
|
||||
'runtime-embedding-credential-binding-mismatch':
|
||||
'The embedding endpoint does not match its saved API Key. Re-enter or clear this credential.',
|
||||
'runtime-rerank-credential-unreadable':
|
||||
'The rerank model API Key cannot be read. Re-enter or clear this credential.',
|
||||
'runtime-rerank-credential-binding-mismatch':
|
||||
'The rerank endpoint does not match its saved API Key. Re-enter or clear this credential.',
|
||||
'channel-settings-recovered':
|
||||
'The channel settings file was corrupt. The original file was isolated, and all channels were restored as disabled.',
|
||||
'channel-weixin-credential-unreadable':
|
||||
|
||||
@@ -129,6 +129,7 @@ export const settings = {
|
||||
notifications: {
|
||||
settingsSaved: '设置已保存',
|
||||
connectionSucceeded: '连接成功:{{label}}',
|
||||
modelGenerationTestSucceeded: '真实生成测试通过:{{label}}',
|
||||
documentParsingSaved: '文档解析设置已保存',
|
||||
documentParsingTestSucceeded: '文档解析测试完成'
|
||||
},
|
||||
@@ -140,7 +141,9 @@ export const settings = {
|
||||
configuredPlaceholder: '已配置,留空保持不变',
|
||||
enterApiKey: '输入 API Key',
|
||||
noAuthentication: '无需认证',
|
||||
noAuthenticationDescription: '无需认证,不会发送 API Key'
|
||||
noAuthenticationDescription: '无需认证,不会发送 API Key',
|
||||
savedForConnection:
|
||||
'当前无需认证;已保存的 API Key 仍保留在此连接中'
|
||||
},
|
||||
runtime: {
|
||||
configCard: {
|
||||
@@ -628,7 +631,7 @@ export const settings = {
|
||||
generatedName: '模型连接 {{count}}',
|
||||
title: 'LLM 模型连接',
|
||||
description:
|
||||
'支持 OpenAI Responses、Anthropic Messages 和 OpenAI 兼容 Chat Completions;图片模型使用独立的 OpenAI Images Generations 接口类型',
|
||||
'支持 OpenAI Responses、Anthropic Messages 和 OpenAI 兼容 Chat Completions;图片模型使用独立的 OpenAI Images Generations 接口类型。“保存并测试模型”会发送一次真实文本或图片生成请求,可能产生服务商用量费用。',
|
||||
listAriaLabel: '模型连接列表',
|
||||
listTitle: '连接列表',
|
||||
editAriaLabel: '编辑模型连接 {{name}}',
|
||||
|
||||
@@ -9,16 +9,10 @@ export const warnings = {
|
||||
'Runtime 设置文件已损坏。原文件已隔离,当前使用默认设置。',
|
||||
'runtime-model-credential-unreadable':
|
||||
'模型连接“{{subject}}”的 API Key 无法读取。请重新输入或清除该凭据。',
|
||||
'runtime-model-credential-binding-mismatch':
|
||||
'模型连接“{{subject}}”的服务地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
|
||||
'runtime-embedding-credential-unreadable':
|
||||
'向量模型 API Key 无法读取。请重新输入或清除该凭据。',
|
||||
'runtime-embedding-credential-binding-mismatch':
|
||||
'向量接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
|
||||
'runtime-rerank-credential-unreadable':
|
||||
'重排模型 API Key 无法读取。请重新输入或清除该凭据。',
|
||||
'runtime-rerank-credential-binding-mismatch':
|
||||
'重排接口地址与已保存 API Key 不匹配。请重新输入或清除该凭据。',
|
||||
'channel-settings-recovered':
|
||||
'通道设置文件已损坏。原文件已隔离,所有通道已恢复为关闭状态。',
|
||||
'channel-weixin-credential-unreadable':
|
||||
|
||||
@@ -7,7 +7,6 @@ export function translateSettingsWarning(
|
||||
): string {
|
||||
switch (warning.code) {
|
||||
case 'runtime-model-credential-unreadable':
|
||||
case 'runtime-model-credential-binding-mismatch':
|
||||
return t(warning.code, {
|
||||
subject: warning.subject ?? ''
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user