fix(copilot): harden provider configuration flow

This commit is contained in:
2026-07-13 14:57:28 +08:00
parent 19cee9af11
commit a712b91982
8 changed files with 153 additions and 36 deletions
+27 -4
View File
@@ -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")))
}
}
+17 -5
View File
@@ -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 {
@@ -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"
}
}
@@ -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"),
))
}
+12 -4
View File
@@ -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` 通过。
### 真实运行验证
+6 -2
View File
@@ -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 ProviderOpenAI/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 数据模型层
@@ -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();
});
});
@@ -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"
>
<input v-model="form.chat.clearApiKey" type="checkbox" />
<input
v-model="form.chat.clearApiKey"
data-test-id="clear-chat-api-key"
type="checkbox"
/>
{{ t('CAPTAIN_SETTINGS.PROVIDER.CLEAR_CHAT_KEY') }}
</label>
</label>
@@ -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 =>
>
<input
v-model="form.embedding.clearApiKey"
data-test-id="clear-embedding-api-key"
type="checkbox"
/>
{{ 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"
>
{{