From ca60cede147fbb93a95842d2cade6d9532c77bf6 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 21 May 2026 10:54:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(account):=20=E6=94=AF=E6=8C=81=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=BF=9E=E6=8E=A5=20Chat=20Completions=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/account_test_service.go | 162 +++++++++++++++++- .../account_test_service_openai_test.go | 148 +++++++++++++++- .../components/account/AccountTestModal.vue | 6 + .../__tests__/AccountTestModal.spec.ts | 42 +++++ 4 files changed, 348 insertions(+), 10 deletions(-) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index b9cd698a..bb448e2d 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -494,7 +494,6 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co // testOpenAIAccountConnection tests an OpenAI account's connection func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account *Account, modelID string, prompt string, mode string) error { ctx := c.Request.Context() - _ = prompt mode = normalizeAccountTestMode(mode) // Default to openai.DefaultTestModel for OpenAI testing @@ -555,14 +554,8 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account if err != nil { return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error())) } - // 账号已被探测为不支持 Responses(如 DeepSeek/Kimi 等)时,丢出明确提示。 - // 账号本身可用(网关会走 CC 直转),仅测试入口需要补齐 CC SSE 处理逻辑。 - // TODO:实现 CC 格式的账号测试路径(需专门的 CC SSE handler)。 if !openai_compat.ShouldUseResponsesAPI(account.Extra) { - return s.sendErrorAndEnd(c, - "账号已被探测为不支持 OpenAI Responses API(如 DeepSeek/Kimi 等三方兼容上游),"+ - "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。", - ) + return s.testOpenAIChatCompletionsConnection(c, account, testModelID, prompt, normalizedBaseURL, authToken) } apiURL = buildOpenAIResponsesURL(normalizedBaseURL) } else { @@ -637,6 +630,65 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account return s.processOpenAIStream(c, resp.Body) } +// testOpenAIChatCompletionsConnection tests an OpenAI-compatible APIKey account +// through the raw /v1/chat/completions endpoint. +func (s *AccountTestService) testOpenAIChatCompletionsConnection( + c *gin.Context, + account *Account, + testModelID string, + prompt string, + normalizedBaseURL string, + authToken string, +) error { + ctx := c.Request.Context() + apiURL := buildOpenAIChatCompletionsURL(normalizedBaseURL) + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.Flush() + + payload := createOpenAIChatCompletionsTestPayload(testModelID, prompt) + payloadBytes, _ := json.Marshal(payload) + + s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) + s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过 /v1/chat/completions 测试连接"}) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Chat Completions request") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Authorization", "Bearer "+authToken) + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + + resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusTooManyRequests { + s.reconcileOpenAI429State(ctx, account, resp.Header, body) + } + if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { + errMsg := fmt.Sprintf("Chat Completions authentication failed (401): %s", string(body)) + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) returned %d: %s", resp.StatusCode, string(body))) + } + + return s.processOpenAIChatCompletionsStream(c, resp.Body) +} + // testOpenAICompactConnection probes /responses/compact and persists the // resulting capability state on the account. func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account *Account, testModelID string) error { @@ -1197,6 +1249,24 @@ func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any { return payload } +func createOpenAIChatCompletionsTestPayload(modelID string, prompt string) map[string]any { + testPrompt := strings.TrimSpace(prompt) + if testPrompt == "" { + testPrompt = "hi" + } + + return map[string]any{ + "model": modelID, + "messages": []map[string]any{ + { + "role": "user", + "content": testPrompt, + }, + }, + "stream": true, + } +} + // processClaudeStream processes the SSE stream from Claude API func (s *AccountTestService) processClaudeStream(c *gin.Context, body io.Reader) error { reader := bufio.NewReader(body) @@ -1251,6 +1321,82 @@ func (s *AccountTestService) processClaudeStream(c *gin.Context, body io.Reader) } } +// processOpenAIChatCompletionsStream processes SSE chunks from the +// OpenAI-compatible Chat Completions API. +func (s *AccountTestService) processOpenAIChatCompletionsStream(c *gin.Context, body io.Reader) error { + reader := bufio.NewReader(body) + seenJSON := false + seenFinish := false + + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + if seenFinish { + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil + } + if seenJSON { + return s.sendErrorAndEnd(c, "Chat Completions stream from /v1/chat/completions ended before [DONE]") + } + return s.sendErrorAndEnd(c, "Invalid Chat Completions response from /v1/chat/completions: expected SSE JSON data") + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions stream read error from /v1/chat/completions: %s", err.Error())) + } + + line = strings.TrimSpace(line) + if line == "" || !sseDataPrefix.MatchString(line) { + continue + } + + jsonStr := sseDataPrefix.ReplaceAllString(line, "") + if jsonStr == "[DONE]" { + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil + } + + var data map[string]any + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + return s.sendErrorAndEnd(c, "Invalid Chat Completions response from /v1/chat/completions: expected JSON data") + } + seenJSON = true + + if errData, ok := data["error"].(map[string]any); ok { + errorMsg := "Chat Completions API (/v1/chat/completions) returned an error" + if msg, ok := errData["message"].(string); ok && msg != "" { + errorMsg = msg + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Chat Completions API (/v1/chat/completions) error: %s", errorMsg)) + } + + choices, ok := data["choices"].([]any) + if !ok { + continue + } + for _, choiceValue := range choices { + choice, ok := choiceValue.(map[string]any) + if !ok { + continue + } + if delta, ok := choice["delta"].(map[string]any); ok { + if text, ok := delta["content"].(string); ok && text != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: text}) + } + } + if message, ok := choice["message"].(map[string]any); ok { + if text, ok := message["content"].(string); ok && text != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: text}) + } + } + if finishReason, ok := choice["finish_reason"].(string); ok && finishReason != "" { + seenFinish = true + } + } + } +} + // processOpenAIStream processes the SSE stream from OpenAI Responses API func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader) error { reader := bufio.NewReader(body) diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index c6e30ed6..9844957a 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -12,10 +12,12 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" - - "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/tidwall/gjson" ) // --- shared test helpers --- @@ -333,3 +335,145 @@ func TestAccountTestService_OpenAI401SetsPermanentErrorOnly(t *testing.T) { require.Zero(t, repo.clearedErrorID) require.Nil(t, account.RateLimitResetAt) } + +func TestAccountTestService_OpenAIAPIKeyResponsesUnsupportedUsesChatCompletionsPath(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, recorder := newTestContext() + + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_test","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"pong"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_test","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 91, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://compat-upstream.example/v1", + }, + Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "hello", "") + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer sk-test", upstream.lastReq.Header.Get("Authorization")) + require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept")) + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) + body := recorder.Body.String() + require.Contains(t, body, "pong") + require.Contains(t, body, "已通过 /v1/chat/completions 验证") + require.Contains(t, body, `"success":true`) + require.NotContains(t, body, "当前测试接口仅支持 Responses API 路径") +} + +func TestAccountTestService_OpenAIChatCompletionsPathReturns4xx(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, recorder := newTestContext() + + upstream := &httpUpstreamRecorder{resp: newJSONResponse(http.StatusBadRequest, `{"error":{"message":"bad request"}}`)} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 92, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://compat-upstream.example", + }, + Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + require.Error(t, err) + require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.Contains(t, err.Error(), "Chat Completions API (/v1/chat/completions) returned 400") + require.Contains(t, recorder.Body.String(), "/v1/chat/completions") + require.NotContains(t, recorder.Body.String(), `"success":true`) +} + +func TestAccountTestService_OpenAIChatCompletionsPathTimeout(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, recorder := newTestContext() + + upstream := &httpUpstreamRecorder{err: context.DeadlineExceeded} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 93, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://compat-upstream.example", + }, + Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + require.Error(t, err) + require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.Contains(t, err.Error(), "Chat Completions API (/v1/chat/completions) request failed") + require.Contains(t, err.Error(), context.DeadlineExceeded.Error()) + require.Contains(t, recorder.Body.String(), "/v1/chat/completions") + require.NotContains(t, recorder.Body.String(), `"success":true`) +} + +func TestAccountTestService_OpenAIChatCompletionsPathRejectsNonJSONStream(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, recorder := newTestContext() + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: not-json\n\n")), + }} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 94, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://compat-upstream.example", + }, + Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + require.Error(t, err) + require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.Contains(t, err.Error(), "Invalid Chat Completions response from /v1/chat/completions") + require.Contains(t, recorder.Body.String(), "/v1/chat/completions") + require.NotContains(t, recorder.Body.String(), `"success":true`) +} diff --git a/frontend/src/components/account/AccountTestModal.vue b/frontend/src/components/account/AccountTestModal.vue index 222d2505..f0359f23 100644 --- a/frontend/src/components/account/AccountTestModal.vue +++ b/frontend/src/components/account/AccountTestModal.vue @@ -513,6 +513,12 @@ const handleEvent = (event: { } break + case 'status': + if (event.text) { + addLine(event.text, 'text-cyan-300') + } + break + case 'image': if (event.image_url) { generatedImages.value.push({ diff --git a/frontend/src/components/account/__tests__/AccountTestModal.spec.ts b/frontend/src/components/account/__tests__/AccountTestModal.spec.ts index c82a3840..9670b521 100644 --- a/frontend/src/components/account/__tests__/AccountTestModal.spec.ts +++ b/frontend/src/components/account/__tests__/AccountTestModal.spec.ts @@ -147,4 +147,46 @@ describe('AccountTestModal', () => { mode: 'compact' }) }) + + it('renders Chat Completions path status from test SSE', async () => { + const encoder = new TextEncoder() + const chunks = [ + encoder.encode('data: {"type":"status","text":"已通过 /v1/chat/completions 验证"}\n\n'), + encoder.encode('data: {"type":"test_complete","success":true}\n\n') + ] + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockImplementation(() => Promise.resolve( + chunks.length > 0 + ? { done: false, value: chunks.shift() } + : { done: true, value: undefined } + )) + }) + } + } as any) + + const wrapper = mount(AccountTestModal, { + props: { + show: true, + account: buildAccount() + }, + global: { + stubs: { + BaseDialog: BaseDialogStub, + Select: SelectStub, + TextArea: TextAreaStub, + Icon: true + } + } + }) + + await flushPromises() + ;(wrapper.vm as any).selectedModelId = 'gpt-5.4' + await (wrapper.vm as any).startTest() + await flushPromises() + + expect(wrapper.text()).toContain('已通过 /v1/chat/completions 验证') + }) })