fix(copilot): harden provider configuration flow
This commit is contained in:
@@ -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")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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` 通过。
|
||||
|
||||
### 真实运行验证
|
||||
|
||||
@@ -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 数据模型层
|
||||
|
||||
+24
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+30
-9
@@ -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"
|
||||
>
|
||||
{{
|
||||
|
||||
Reference in New Issue
Block a user