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:
+2
-2
@@ -28,9 +28,9 @@
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
|
||||
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
|
||||
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。
|
||||
- [x] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用有总时限的原生 Compact;并发外部 Server 对话的提问使用请求级公开 ID 映射,回答不会串到其他会话。外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。
|
||||
- [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。
|
||||
- [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。
|
||||
|
||||
@@ -599,6 +599,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供
|
||||
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
|
||||
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
|
||||
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
|
||||
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
|
||||
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
|
||||
|
||||
@@ -369,12 +369,22 @@ describe('ModelAgentRuntime', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('performs a real minimal request when testing the connection', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
Response.json({
|
||||
content: [{ type: 'text', text: 'OK' }]
|
||||
it('requires generated text from a real minimal Anthropic request when testing the connection', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const body = JSON.parse(init?.body as string) as {
|
||||
messages: Array<{ content: string }>
|
||||
}
|
||||
const marker =
|
||||
/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(
|
||||
body.messages[0]?.content ?? ''
|
||||
)?.[0]
|
||||
if (!marker) {
|
||||
throw new Error('missing model test marker')
|
||||
}
|
||||
return Response.json({
|
||||
content: [{ type: 'text', text: marker }]
|
||||
})
|
||||
)
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://bigtoken.ai',
|
||||
@@ -386,12 +396,71 @@ describe('ModelAgentRuntime', () => {
|
||||
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true,
|
||||
id: 'model'
|
||||
id: 'model',
|
||||
detail: expect.stringContaining('真实模型生成测试')
|
||||
})
|
||||
const body = JSON.parse(
|
||||
fetcher.mock.calls[0]?.[1]?.body as string
|
||||
) as { max_tokens: number; stream: boolean }
|
||||
expect(body).toMatchObject({ max_tokens: 1, stream: false })
|
||||
expect(body).toMatchObject({ max_tokens: 64, stream: false })
|
||||
})
|
||||
|
||||
it('rejects a successful HTTP response that does not contain generated test text', async () => {
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://bigtoken.ai',
|
||||
model: 'sonnet-5',
|
||||
protocol: 'anthropic-messages',
|
||||
authentication: 'api-key',
|
||||
fetcher: vi.fn(async () =>
|
||||
Response.json({
|
||||
content: [{ type: 'text', text: 'generic health check' }]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
await expect(runtime.testConnection()).rejects.toThrow(
|
||||
'未完成真实生成测试'
|
||||
)
|
||||
})
|
||||
|
||||
it('validates generated test text from an OpenAI Chat Completions response', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const body = JSON.parse(init?.body as string) as {
|
||||
messages: Array<{ content: string }>
|
||||
}
|
||||
const marker =
|
||||
/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(
|
||||
body.messages[0]?.content ?? ''
|
||||
)?.[0]
|
||||
if (!marker) {
|
||||
throw new Error('missing model test marker')
|
||||
}
|
||||
return Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: marker
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://model.example/v1',
|
||||
model: 'chat-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
fetcher
|
||||
})
|
||||
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true,
|
||||
detail: expect.stringContaining('真实模型生成测试')
|
||||
})
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the Anthropic messages endpoint and streams text deltas', async () => {
|
||||
@@ -1818,9 +1887,26 @@ describe('ModelAgentRuntime', () => {
|
||||
})
|
||||
|
||||
it('tests an OpenAI Responses connection with Responses request fields', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
Response.json({ id: 'resp-test', output: [] })
|
||||
)
|
||||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const body = JSON.parse(init?.body as string) as {
|
||||
input: string
|
||||
}
|
||||
const marker =
|
||||
/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(body.input)?.[0]
|
||||
if (!marker) {
|
||||
throw new Error('missing model test marker')
|
||||
}
|
||||
return Response.json({
|
||||
id: 'resp-test',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
content: [{ type: 'output_text', text: marker }]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://api.openai.com/v1/',
|
||||
@@ -1832,19 +1918,21 @@ describe('ModelAgentRuntime', () => {
|
||||
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||||
available: true,
|
||||
detail: expect.stringContaining('已验证')
|
||||
detail: expect.stringContaining('真实模型生成测试')
|
||||
})
|
||||
expect(fetcher.mock.calls[0]?.[0]?.toString()).toBe(
|
||||
'https://api.openai.com/v1/responses'
|
||||
)
|
||||
expect(
|
||||
JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string)
|
||||
).toEqual({
|
||||
).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
max_output_tokens: 16,
|
||||
stream: false,
|
||||
input: 'Reply OK.'
|
||||
max_output_tokens: 64,
|
||||
stream: false
|
||||
})
|
||||
expect(
|
||||
JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string).input
|
||||
).toMatch(/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u)
|
||||
})
|
||||
|
||||
it('runs approved direct-model tools and returns their results to OpenAI', async () => {
|
||||
@@ -4527,8 +4615,14 @@ describe('ModelAgentRuntime', () => {
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports image configuration checks without pretending to generate', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
it('performs and validates a real image generation when testing an image connection', async () => {
|
||||
const png = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00
|
||||
]).toString('base64')
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
Response.json({ data: [{ b64_json: png }] })
|
||||
)
|
||||
const runtime = new ModelAgentRuntime({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://bigtoken.ai/v1',
|
||||
@@ -4543,10 +4637,18 @@ describe('ModelAgentRuntime', () => {
|
||||
available: true,
|
||||
capability: 'image-generation',
|
||||
detail: expect.stringContaining(
|
||||
'发送提示词时执行实际生成验证'
|
||||
'已完成真实图像生成测试'
|
||||
)
|
||||
})
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string)
|
||||
).toMatchObject({
|
||||
model: 'gpt-image-2',
|
||||
n: 1,
|
||||
quality: 'medium',
|
||||
response_format: 'b64_json'
|
||||
})
|
||||
})
|
||||
|
||||
it('generates a bounded image through the BigToken-compatible endpoint', async () => {
|
||||
|
||||
@@ -1640,6 +1640,18 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
return headers
|
||||
}
|
||||
|
||||
private createImageGenerationRequest(
|
||||
prompt: string
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
model: this.options.model,
|
||||
prompt: prompt.slice(0, 100_000),
|
||||
n: 1,
|
||||
quality: this.options.imageGenerationQuality ?? 'auto',
|
||||
response_format: 'b64_json'
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(
|
||||
input: URL,
|
||||
init: RequestInit,
|
||||
@@ -1688,38 +1700,44 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
if (!this.isConfigured()) {
|
||||
return this.getStatus()
|
||||
}
|
||||
if (this.options.protocol === 'openai-images-generations') {
|
||||
return {
|
||||
...(await this.getStatus()),
|
||||
detail: `已识别图像生成配置,发送提示词时执行实际生成验证 · ${this.options.baseUrl}`
|
||||
}
|
||||
}
|
||||
const imageGeneration =
|
||||
this.options.protocol === 'openai-images-generations'
|
||||
const marker = `GOODBUDDY_MODEL_TEST_${randomBytes(12)
|
||||
.toString('hex')
|
||||
.toUpperCase()}`
|
||||
const prompt = imageGeneration
|
||||
? 'Generate a simple image of one solid blue circle centered on a white background.'
|
||||
: `Reply with exactly this text and nothing else: ${marker}`
|
||||
const response = await this.fetcher(this.getEndpoint(), {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(
|
||||
this.options.protocol === 'openai-responses'
|
||||
imageGeneration
|
||||
? this.createImageGenerationRequest(prompt)
|
||||
: this.options.protocol === 'openai-responses'
|
||||
? {
|
||||
model: this.options.model,
|
||||
max_output_tokens: 16,
|
||||
max_output_tokens: 64,
|
||||
stream: false,
|
||||
input: 'Reply OK.'
|
||||
input: prompt
|
||||
}
|
||||
: {
|
||||
model: this.options.model,
|
||||
max_tokens: 1,
|
||||
max_tokens: 64,
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'Reply OK.' }]
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
}
|
||||
)
|
||||
})
|
||||
const responseText = await readBoundedResponseText(response, {
|
||||
maxBytes: response.ok && imageGeneration
|
||||
? maxImageResponseBytes
|
||||
: 128 * 1024,
|
||||
missingBodyMessage: '模型接口未返回响应内容',
|
||||
tooLargeMessage: '模型接口响应超过安全限制'
|
||||
})
|
||||
if (!response.ok) {
|
||||
const responseText = await readBoundedResponseText(response, {
|
||||
maxBytes: 128 * 1024,
|
||||
missingBodyMessage: '模型接口未返回响应内容',
|
||||
tooLargeMessage: '模型接口响应超过安全限制'
|
||||
})
|
||||
let detail: string | undefined
|
||||
try {
|
||||
detail = getErrorMessage(
|
||||
@@ -1733,13 +1751,34 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
`模型接口连接测试失败(HTTP ${response.status})`
|
||||
)
|
||||
}
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(responseText)
|
||||
} catch {
|
||||
throw new Error('模型接口返回了无效 JSON,未完成真实生成测试')
|
||||
}
|
||||
if (imageGeneration) {
|
||||
parseGeneratedImage(payload)
|
||||
} else {
|
||||
const result = parseModelToolResponse(
|
||||
payload,
|
||||
this.options.protocol === 'anthropic-messages'
|
||||
? 'anthropic'
|
||||
: this.options.protocol === 'openai-responses'
|
||||
? 'openai-responses'
|
||||
: 'openai'
|
||||
)
|
||||
if (!result.text.includes(marker)) {
|
||||
throw new Error('模型接口未返回测试文本,未完成真实生成测试')
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'model',
|
||||
label: this.options.model,
|
||||
...(await this.getStatus()),
|
||||
available: true,
|
||||
supportsToolExecution: this.supportsToolExecution,
|
||||
detail: `已验证模型接口连接 · ${this.options.baseUrl}`
|
||||
detail: `${imageGeneration
|
||||
? '已完成真实图像生成测试'
|
||||
: '已完成真实模型生成测试'
|
||||
} · ${this.options.baseUrl}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2376,13 +2415,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
||||
type: 'status',
|
||||
message: `${this.options.model} 正在生成图片`
|
||||
}
|
||||
const imageRequest = {
|
||||
model: this.options.model,
|
||||
prompt: request.prompt.slice(0, 100_000),
|
||||
n: 1,
|
||||
quality: this.options.imageGenerationQuality ?? 'auto',
|
||||
response_format: 'b64_json'
|
||||
}
|
||||
const imageRequest = this.createImageGenerationRequest(
|
||||
request.prompt
|
||||
)
|
||||
const modelRequest = await this.fetchWithTimeout(
|
||||
this.getEndpoint(),
|
||||
{
|
||||
|
||||
@@ -906,7 +906,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('encrypts an OpenAI-compatible embedding API key and binds it to the full endpoint', async () => {
|
||||
it('keeps an encrypted embedding API key when its endpoint changes', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
@@ -933,15 +933,18 @@ describe('RuntimeSettingsStore', () => {
|
||||
knowledgeEmbeddingApiKeyConfigured: true,
|
||||
knowledgeEmbeddingCredentialSource: 'encrypted'
|
||||
})
|
||||
await expect(
|
||||
store.update(
|
||||
settings({
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
'https://vectors.example/v1/embeddings',
|
||||
knowledgeEmbeddingApiKey: { action: 'keep' }
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('重新输入或清除 API Key')
|
||||
await store.update(
|
||||
settings({
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
'https://vectors.example/v1/embeddings',
|
||||
knowledgeEmbeddingApiKey: { action: 'keep' }
|
||||
})
|
||||
)
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
'https://vectors.example/v1/embeddings',
|
||||
knowledgeEmbeddingApiKey: 'vector-secret-value'
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates version 13 with reranking disabled by default', async () => {
|
||||
@@ -968,7 +971,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('encrypts and endpoint-binds the rerank API key', async () => {
|
||||
it('keeps an encrypted rerank API key when its endpoint changes', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
@@ -995,14 +998,16 @@ describe('RuntimeSettingsStore', () => {
|
||||
knowledgeRerankApiKeyConfigured: true,
|
||||
knowledgeRerankCredentialSource: 'encrypted'
|
||||
})
|
||||
await expect(
|
||||
store.update(
|
||||
settings({
|
||||
knowledgeRerankEndpoint: 'https://other.example/v1/rerank',
|
||||
knowledgeRerankApiKey: { action: 'keep' }
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('重排接口 URL 已更改')
|
||||
await store.update(
|
||||
settings({
|
||||
knowledgeRerankEndpoint: 'https://other.example/v1/rerank',
|
||||
knowledgeRerankApiKey: { action: 'keep' }
|
||||
})
|
||||
)
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
knowledgeRerankEndpoint: 'https://other.example/v1/rerank',
|
||||
knowledgeRerankApiKey: 'rerank-secret-value'
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the rerank environment API key without exposing it', async () => {
|
||||
@@ -1251,7 +1256,7 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('encrypts the API key and binds it to the configured origin', async () => {
|
||||
it('keeps an encrypted API key with its model connection when the URL changes', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
@@ -1266,14 +1271,93 @@ describe('RuntimeSettingsStore', () => {
|
||||
modelBaseUrl: 'https://bigtoken.ai'
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.update(
|
||||
settings({
|
||||
modelBaseUrl: 'https://other.example',
|
||||
apiKey: { action: 'keep' }
|
||||
await store.update(
|
||||
settings({
|
||||
modelBaseUrl: 'https://other.example',
|
||||
apiKey: { action: 'keep' }
|
||||
})
|
||||
)
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
apiKey: 'test-secret-value',
|
||||
modelBaseUrl: 'https://other.example'
|
||||
})
|
||||
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'encrypted'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a model API key while authentication is disabled and restores it when re-enabled', async () => {
|
||||
const { store } = await createStore()
|
||||
await store.update(
|
||||
settings({
|
||||
apiKey: {
|
||||
action: 'replace',
|
||||
value: 'connection-scoped-secret'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await store.update(
|
||||
settings({
|
||||
modelAuthentication: 'none',
|
||||
apiKey: { action: 'keep' }
|
||||
})
|
||||
)
|
||||
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
||||
modelAuthentication: 'none',
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
authentication: 'none',
|
||||
apiKeyConfigured: true,
|
||||
credentialSource: 'encrypted'
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('请重新输入或清除')
|
||||
]
|
||||
})
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelAuthentication: 'none',
|
||||
apiKey: undefined
|
||||
})
|
||||
|
||||
await store.update(
|
||||
settings({
|
||||
modelAuthentication: 'api-key',
|
||||
apiKey: { action: 'keep' }
|
||||
})
|
||||
)
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelAuthentication: 'api-key',
|
||||
apiKey: 'connection-scoped-secret'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a legacy API key payload after the model connection URL changes', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
await store.update(settings())
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
|
||||
modelProfiles: Array<Record<string, unknown>>
|
||||
}
|
||||
persisted.modelProfiles[0]!.baseUrl = 'https://new.example/v1'
|
||||
persisted.modelProfiles[0]!.credential = {
|
||||
formatVersion: 1,
|
||||
scheme: 'electron-safe-storage',
|
||||
ciphertextBase64: cipher
|
||||
.encrypt(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
apiKey: 'legacy-connection-secret',
|
||||
origin: 'https://old.example'
|
||||
})
|
||||
)
|
||||
.toString('base64')
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify(persisted), 'utf8')
|
||||
|
||||
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
|
||||
modelBaseUrl: 'https://new.example/v1',
|
||||
apiKey: 'legacy-connection-secret'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not infer image capability from the model name', async () => {
|
||||
@@ -1473,6 +1557,59 @@ describe('RuntimeSettingsStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the default environment key only for the default model connection', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const defaultId = '00000000-0000-4000-8000-000000000031'
|
||||
const secondaryId = '00000000-0000-4000-8000-000000000032'
|
||||
await store.update(
|
||||
settings({
|
||||
modelProfiles: [
|
||||
{
|
||||
id: defaultId,
|
||||
name: 'Default',
|
||||
baseUrl: 'https://default.example/v1',
|
||||
modelName: 'default-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: { action: 'replace', value: 'stored-default-key' }
|
||||
},
|
||||
{
|
||||
id: secondaryId,
|
||||
name: 'Secondary',
|
||||
baseUrl: 'https://secondary.example/v1',
|
||||
modelName: 'secondary-model',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key',
|
||||
imageGenerationQuality: 'auto',
|
||||
apiKey: { action: 'replace', value: 'secondary-key' }
|
||||
}
|
||||
],
|
||||
defaultModelProfileId: defaultId
|
||||
})
|
||||
)
|
||||
|
||||
const environmentStore = new RuntimeSettingsStore(filePath, cipher, {
|
||||
GOODBUDDY_MODEL_API_KEY: 'environment-default-key',
|
||||
GOODBUDDY_MODEL_BASE_URL: 'https://environment.example/v1',
|
||||
GOODBUDDY_MODEL_NAME: 'environment-model'
|
||||
})
|
||||
await expect(
|
||||
environmentStore.getResolvedSettings()
|
||||
).resolves.toMatchObject({
|
||||
modelProfiles: [
|
||||
expect.objectContaining({
|
||||
id: defaultId,
|
||||
apiKey: 'environment-default-key'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: secondaryId,
|
||||
apiKey: 'secondary-key'
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers generic model environment variables over legacy fallbacks', async () => {
|
||||
const { filePath } = await createStore()
|
||||
const store = new RuntimeSettingsStore(filePath, cipher, {
|
||||
|
||||
+166
-170
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? ''
|
||||
})
|
||||
|
||||
@@ -6,11 +6,8 @@ export const settingsWarningCodeSchema = z.enum([
|
||||
'capability-settings-recovered',
|
||||
'runtime-settings-recovered',
|
||||
'runtime-model-credential-unreadable',
|
||||
'runtime-model-credential-binding-mismatch',
|
||||
'runtime-embedding-credential-unreadable',
|
||||
'runtime-embedding-credential-binding-mismatch',
|
||||
'runtime-rerank-credential-unreadable',
|
||||
'runtime-rerank-credential-binding-mismatch',
|
||||
'channel-settings-recovered',
|
||||
'channel-weixin-credential-unreadable',
|
||||
'channel-weixin-secure-storage-unavailable',
|
||||
|
||||
Reference in New Issue
Block a user