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"
>
{{