diff --git a/backend/internal/llm/llm_test.go b/backend/internal/llm/llm_test.go index e9197b4c..52758195 100644 --- a/backend/internal/llm/llm_test.go +++ b/backend/internal/llm/llm_test.go @@ -63,8 +63,8 @@ func TestNewOpenAIProvider_TrailingSlashTrimmed(t *testing.T) { func TestChatRequest_MarshalJSON(t *testing.T) { req := ChatRequest{ - Model: "gpt-4", - Messages: []ChatMessage{ + Model: "gpt-4", + Messages: []ChatMessage{ {Role: "system", Content: "You are a helpful assistant."}, {Role: "user", Content: "Hello!"}, }, @@ -314,8 +314,9 @@ func TestOpenAIProvider_ChatCompletion_APIError(t *testing.T) { var apiErr *APIError require.True(t, errors.As(err, &apiErr), "error should wrap APIError") assert.Equal(t, http.StatusUnauthorized, apiErr.StatusCode) - assert.Equal(t, "Invalid API key", apiErr.Message) + assert.Equal(t, "provider authentication failed", apiErr.Message) assert.Equal(t, "invalid_request_error", apiErr.Type) + assert.NotContains(t, err.Error(), "Invalid API key") } func TestOpenAIProvider_ChatCompletion_RetryOn5xx(t *testing.T) { @@ -438,6 +439,28 @@ data: [DONE] assert.Equal(t, " world", chunks[2].Choices[0].Delta.Content) } +func TestOpenAIProvider_ParseSSEStreamSkipsMalformedProviderResponse(t *testing.T) { + sseData := "data: secret-provider-response\n\ndata: [DONE]\n\n" + p := NewOpenAIProvider(OpenAIProviderConfig{APIKey: "test"}) + + err := p.parseSSEStream(strings.NewReader(sseData), func(StreamChunk) error { + t.Fatal("malformed provider chunks must not reach the callback") + return nil + }) + require.NoError(t, err) +} + +func TestParseAPIErrorDoesNotExposeProviderResponse(t *testing.T) { + providerBody := []byte(`{"error":{"message":"authorization failed for sk-secret-value","type":"server_error","code":"provider_failure"},"debug":"complete response"}`) + apiErr := parseAPIError(http.StatusInternalServerError, providerBody) + + assert.Equal(t, "provider request failed", apiErr.Message) + assert.Equal(t, "server_error", apiErr.Type) + assert.Equal(t, "provider_failure", apiErr.Code) + assert.NotContains(t, apiErr.Error(), "sk-secret-value") + assert.NotContains(t, apiErr.Error(), "complete response") +} + // --- Utility tests --- func TestParseFloatEmbedding(t *testing.T) { @@ -481,4 +504,4 @@ func TestIsNonRetriableError(t *testing.T) { // Non-APIError should not be treated as non-retriable assert.False(t, isNonRetriableError(fmt.Errorf("some error"))) -} \ No newline at end of file +} diff --git a/backend/internal/llm/openai_provider.go b/backend/internal/llm/openai_provider.go index aaddcfdb..418b3842 100644 --- a/backend/internal/llm/openai_provider.go +++ b/backend/internal/llm/openai_provider.go @@ -211,7 +211,7 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, path string, body []byte if resp.StatusCode >= 400 { apiErr := parseAPIError(resp.StatusCode, respBody) - applogger.L().Errorf("Provider API error (status %d, type=%s, code=%s)", resp.StatusCode, apiErr.Type, apiErr.Code) + applogger.L().Errorf("Provider API error (status %d)", resp.StatusCode) return nil, apiErr } @@ -251,7 +251,7 @@ func (p *OpenAIProvider) parseSSEStream(body io.Reader, onChunk func(StreamChunk var chunk StreamChunk if err := json.Unmarshal([]byte(event.Data), &chunk); err != nil { - applogger.L().Errorf("parseSSEStream: failed to unmarshal chunk: %v (data: %s)", err, event.Data) + applogger.L().Errorf("parseSSEStream: failed to unmarshal provider chunk: %v", err) continue } @@ -279,7 +279,7 @@ func (e *APIError) Error() string { func parseAPIError(statusCode int, body []byte) *APIError { apiErr := &APIError{ StatusCode: statusCode, - Message: string(body), + Message: providerErrorMessage(statusCode), } // Try to parse OpenAI error structure @@ -290,8 +290,7 @@ func parseAPIError(statusCode int, body []byte) *APIError { Code string `json:"code"` } `json:"error"` } - if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" { - apiErr.Message = errResp.Error.Message + if err := json.Unmarshal(body, &errResp); err == nil { apiErr.Type = errResp.Error.Type apiErr.Code = errResp.Error.Code } @@ -299,6 +298,19 @@ func parseAPIError(statusCode int, body []byte) *APIError { return apiErr } +func providerErrorMessage(statusCode int) string { + switch statusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return "provider authentication failed" + case http.StatusNotFound: + return "provider endpoint or model was not found" + case http.StatusTooManyRequests: + return "provider rate limit exceeded" + default: + return "provider request failed" + } +} + // isNonRetriableError returns true for errors that should not be retried. func isNonRetriableError(err error) bool { if apiErr, ok := err.(*APIError); ok { diff --git a/backend/internal/service/copilot_config_service.go b/backend/internal/service/copilot_config_service.go index c3b9435c..c9014cd3 100644 --- a/backend/internal/service/copilot_config_service.go +++ b/backend/internal/service/copilot_config_service.go @@ -7,6 +7,8 @@ import ( "encoding/json" "errors" "fmt" + "net" + "net/http" "net/url" "strings" "time" @@ -626,22 +628,30 @@ func normalizeCopilotProviderError(err error) string { if err == nil { return "" } - message := strings.TrimSpace(err.Error()) + var providerErr *llm.APIError + if errors.As(err, &providerErr) { + switch providerErr.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return "provider authentication failed" + case http.StatusNotFound: + return "provider endpoint or model was not found" + case http.StatusTooManyRequests: + return "provider rate limit exceeded" + default: + return "provider request failed" + } + } + + message := strings.ToLower(strings.TrimSpace(err.Error())) + var networkErr net.Error switch { - case errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(message), "timeout"): + case errors.Is(err, context.DeadlineExceeded) || errors.As(err, &networkErr) && networkErr.Timeout() || strings.Contains(message, "timeout"): return "provider request timed out" - case strings.Contains(message, "401") || strings.Contains(message, "403"): - return "provider authentication failed" - case strings.Contains(message, "404"): - return "provider endpoint or model was not found" - case strings.Contains(message, "429"): - return "provider rate limit exceeded" case strings.Contains(message, "connection refused") || strings.Contains(message, "no such host"): return "provider endpoint is unreachable" + case strings.Contains(message, "unmarshal") || strings.Contains(message, "response format"): + return "provider response format is incompatible" default: - if len(message) > 240 { - message = message[:240] - } - return message + return "provider request failed" } } diff --git a/backend/internal/service/copilot_config_service_test.go b/backend/internal/service/copilot_config_service_test.go index c2127e7e..878eefc6 100644 --- a/backend/internal/service/copilot_config_service_test.go +++ b/backend/internal/service/copilot_config_service_test.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -132,3 +133,17 @@ func TestCopilotConfigServiceTestsChatAndEmbeddingWithoutChangingSavedConfig(t * require.NoError(t, err) assert.False(t, payload.Configured, "testing candidate settings must not persist them") } + +func TestNormalizeCopilotProviderErrorDoesNotExposeProviderResponse(t *testing.T) { + providerErr := fmt.Errorf("chat completion: %w", &llm.APIError{ + StatusCode: http.StatusInternalServerError, + Message: "complete provider response containing sk-secret-value", + }) + + normalized := normalizeCopilotProviderError(providerErr) + assert.Equal(t, "provider request failed", normalized) + assert.NotContains(t, normalized, "sk-secret-value") + assert.Equal(t, "provider response format is incompatible", normalizeCopilotProviderError( + fmt.Errorf("unmarshal chat response: invalid character"), + )) +} diff --git a/docs/plans/2026-07-12-copilot-configuration.md b/docs/plans/2026-07-12-copilot-configuration.md index bbaad341..b6030dc5 100644 --- a/docs/plans/2026-07-12-copilot-configuration.md +++ b/docs/plans/2026-07-12-copilot-configuration.md @@ -97,9 +97,12 @@ Provider 预设只负责填充默认 Endpoint,不锁死模型: | Provider | 默认 Base URL | 协议实现 | |---|---|---| -| OpenAI | `https://api.openai.com/v1` | Eino OpenAI | -| Anthropic | `https://api.anthropic.com` | Anthropic Messages API | -| OpenAI Compatible | 用户填写 | Eino OpenAI;适用于 DeepSeek、通义千问、豆包及自建网关 | +| OpenAI | `https://api.openai.com/v1` | `llm.OpenAIProvider`,OpenAI Chat Completions API | +| Anthropic | `https://api.anthropic.com` | `llm.AnthropicProvider`,Anthropic Messages API | +| OpenAI Compatible | 用户填写 | `llm.OpenAIProvider` + 自定义 Base URL;适用于 DeepSeek、通义千问、豆包及自建网关 | + +`EinoProvider` 适配器仍保留给显式构造的 Eino ChatModel/Embedder 使用;数据库驱动的 +`ProviderManager` 运行时配置链路不经过该适配器,而是按协议直接创建上述 Provider。 首期不单独增加 DeepSeek/Qwen/Doubao 枚举,避免把供应商宣传名称与真实协议实现绑死。 @@ -313,13 +316,18 @@ Embedding 维度由迁移 `000056_make_article_embedding_dimension_dynamic` 改 - `go test ./...` 通过。 - 覆盖明文存储、掩码/不回显、保留/替换/清除、候选测试不落库、Provider 热切换、 OpenAI/OpenAI-compatible/Anthropic、超时/重试、统一安全错误、动态 Embedding 维度和重建状态。 +- Service → `ProviderManager` → Fake HTTP Provider 集成测试证明 editor、copilot、assistant、 + label suggestion 分别使用账户配置的功能模型,未配置时由 Manager 回退平台默认模型。 +- Assistant 显式配置的 Temperature(包括 `0`)优先于平台默认值;未显式配置时继续使用平台值。 +- OpenAI-compatible 的错误响应与 malformed SSE chunk 不进入日志或健康检查响应;对外只返回稳定错误类别。 - SuperAdmin 平台权限、Administrator 账户权限和 Agent 禁止访问均有 Handler/Middleware 回归测试。 - 配置变更写入 `audits`,只记录 Provider、模型、维度、配置状态和是否变更 Key,不记录 Key 或掩码。 ### 前端自动化 - Vitest 覆盖菜单/路由、Pinia 平台与账户请求、SuperAdmin 识别、Provider 条件字段、 - API Key 不回填/清除确认、Anthropic 非法组合、维度确认、回复行为和自动回复确认。 + API Key 不回填/清除确认、清除 Key 时允许保存但禁止连接测试、Anthropic 非法组合、 + 维度确认、回复行为和自动回复确认。 - `pnpm build` 通过。 ### 真实运行验证 diff --git a/docs/product/08-ai-roadmap.md b/docs/product/08-ai-roadmap.md index 926d2c83..b67ad49d 100644 --- a/docs/product/08-ai-roadmap.md +++ b/docs/product/08-ai-roadmap.md @@ -8,8 +8,10 @@ > 调研日期:2026-07-08(初版)/ 2026-07-12 更新 > 基于代码库:2026-07-12 Copilot 配置中心实现状态 > 对标项目:Chatwoot Captain AI (enterprise edition) -> 注:2026-07-09 状态更新 — Eino 框架已替换手写 LLM 层,多 LLM Provider(OpenAI/Anthropic)已接入, -> Function Calling 已实现,Help Center pgvector 语义搜索已实现,AutoReplyRule 已集成到消息流程。 +> 注:2026-07-12 状态更新 — 数据库驱动的运行时配置链路使用协议原生 +> `OpenAIProvider`/`AnthropicProvider`;`EinoProvider` 适配器仍可供显式构造的 Eino +> ChatModel/Embedder 使用,但不承载 Copilot 配置中心的 Provider 热切换。Function Calling、 +> Help Center pgvector 语义搜索和 AutoReplyRule 消息流程均已接入。 --- @@ -64,6 +66,8 @@ Provider 配置与运行链路已闭环,保存后无需重启即可生效。 - 数据结构:`ChatRequest`/`ChatResponse`/`ChatMessage`/`EmbeddingRequest`/`EmbeddingResponse`/ `StreamChunk`/`ToolDefinition`/`ToolFunction` - `ProviderManager` 原子替换 Chat/Embedding Provider Snapshot;账户模型按功能解析后进入请求。 +- 配置中心按协议直接构造 `OpenAIProvider`/`AnthropicProvider`;OpenAI-compatible 复用 + `OpenAIProvider` 并使用自定义 Base URL,Eino 适配器不在该运行时链路中。 - `ToolExecutionService` 已将 Custom Tool 转为 `ToolDefinition`,执行 tool-call 循环并回传结果。 ### 2.2 数据模型层 diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/ProviderConfiguration.spec.js b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/ProviderConfiguration.spec.js index b42dabc8..a37a44b1 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/ProviderConfiguration.spec.js +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/ProviderConfiguration.spec.js @@ -86,4 +86,28 @@ describe('ProviderConfiguration', () => { expect(wrapper.emitted('save')).toBeUndefined(); confirm.mockRestore(); }); + + it('allows clearing a saved key without allowing a connection test', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + const wrapper = mount(ProviderConfiguration, { + props: { config: configuredProvider }, + }); + + await wrapper.get('[data-test-id="clear-chat-api-key"]').setValue(true); + + expect( + wrapper.get('[data-test-id="save-provider"]').attributes('disabled') + ).toBeUndefined(); + expect( + wrapper.get('[data-test-id="test-provider"]').attributes('disabled') + ).toBeDefined(); + + await wrapper.get('[data-test-id="save-provider"]').trigger('click'); + expect(confirm).toHaveBeenCalledOnce(); + expect(wrapper.emitted('save')?.[0]?.[0].chat).toMatchObject({ + api_key: '', + clear_api_key: true, + }); + confirm.mockRestore(); + }); }); diff --git a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/ProviderConfiguration.vue b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/ProviderConfiguration.vue index c1e7f5fb..56f7d3cc 100644 --- a/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/ProviderConfiguration.vue +++ b/frontend/app/javascript/dashboard/routes/dashboard/settings/captain/components/ProviderConfiguration.vue @@ -130,21 +130,36 @@ const hasEmbeddingCredential = computed( (Boolean(props.config?.embedding?.api_key?.configured) && !form.embedding.clearApiKey) ); -const isFormComplete = computed( +const isConfigurationValid = computed( () => Boolean( form.chat.provider && form.chat.baseUrl && form.chat.model && form.embedding.model && - form.embedding.dimensions >= 1 && - hasChatCredential.value && - hasEmbeddingCredential.value + form.embedding.dimensions >= 1 ) && (!isSeparateEmbedding.value || Boolean(form.embedding.provider && form.embedding.baseUrl)) && !invalidAnthropicReuse.value ); +const canTest = computed( + () => + isConfigurationValid.value && + hasChatCredential.value && + hasEmbeddingCredential.value +); +const canSave = computed( + () => + isConfigurationValid.value && + (hasChatCredential.value || + (Boolean(props.config?.chat?.api_key?.configured) && + form.chat.clearApiKey)) && + (!isSeparateEmbedding.value || + hasEmbeddingCredential.value || + (Boolean(props.config?.embedding?.api_key?.configured) && + form.embedding.clearApiKey)) +); const activeHealth = computed(() => props.testResult || props.config?.health); const reindexProgress = computed(() => { if (!props.reindexStatus?.total) return 0; @@ -296,7 +311,7 @@ const resultClass = ok => 'CAPTAIN_SETTINGS.PROVIDER.API_KEY_CONFIGURED', { masked: - config.chat.api_key.masked_value || + config.chat.api_key.masked_value || t( 'CAPTAIN_SETTINGS.PROVIDER.API_KEY_MASKED' ), @@ -312,7 +327,11 @@ const resultClass = ok => v-if="config?.chat?.api_key?.configured && !readOnly" class="flex items-center gap-2 text-xs text-n-ruby-11" > - + {{ t('CAPTAIN_SETTINGS.PROVIDER.CLEAR_CHAT_KEY') }} @@ -393,7 +412,8 @@ const resultClass = ok => 'CAPTAIN_SETTINGS.PROVIDER.API_KEY_CONFIGURED', { masked: - config.embedding.api_key.masked_value || + config.embedding.api_key + .masked_value || t( 'CAPTAIN_SETTINGS.PROVIDER.API_KEY_MASKED' ), @@ -408,6 +428,7 @@ const resultClass = ok => > {{ t('CAPTAIN_SETTINGS.PROVIDER.CLEAR_EMBEDDING_KEY') }} @@ -618,7 +639,7 @@ const resultClass = ok => type="button" data-test-id="test-provider" class="rounded-lg border border-n-weak bg-n-solid-2 px-4 py-2 text-sm font-medium text-n-slate-12 hover:bg-n-alpha-2 disabled:opacity-60" - :disabled="isTesting || isSaving || !isFormComplete" + :disabled="isTesting || isSaving || !canTest" @click="test" > {{ @@ -631,7 +652,7 @@ const resultClass = ok => type="button" data-test-id="save-provider" class="rounded-lg bg-n-brand px-4 py-2 text-sm font-medium text-white hover:bg-n-brand/90 disabled:opacity-60" - :disabled="isSaving || isTesting || !isFormComplete" + :disabled="isSaving || isTesting || !canSave" @click="save" > {{