feat(account): 支持测试连接 Chat Completions 路径

This commit is contained in:
wucm667 2026-05-21 10:54:41 +08:00
parent 16793d3af0
commit ca60cede14
4 changed files with 348 additions and 10 deletions

View File

@ -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)

View File

@ -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`)
}

View File

@ -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({

View File

@ -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 验证')
})
})