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:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user