diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 3640d1b82e..cd821165a4 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -44,6 +44,7 @@ const ( APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认) APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code) APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex) + APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点 ) // Account type constants diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index cd66ef89ce..20d4ec5823 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1310,6 +1310,13 @@ func (a *Account) GetOpenAIBaseURL() string { if !a.IsOpenAI() && !a.IsCNProvider() { return "" } + if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() { + if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok { + if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" { + return strings.TrimSpace(baseURL) + } + } + } if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream { if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { return baseURL @@ -1361,6 +1368,8 @@ func (a *Account) GetAPIProtocol() string { return APIProtocolChatCompletions } switch strings.TrimSpace(a.GetCredential("api_protocol")) { + case APIProtocolAdaptive: + return APIProtocolAdaptive case APIProtocolAnthropic: return APIProtocolAnthropic case APIProtocolResponses: @@ -1373,6 +1382,66 @@ func (a *Account) GetAPIProtocol() string { return APIProtocolChatCompletions } +// IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。 +func (a *Account) IsAdaptiveAPIProtocol() bool { + return a.GetAPIProtocol() == APIProtocolAdaptive +} + +// GetCNProtocolBaseURL 返回国产供应商指定协议的上游 base URL。 +// adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和 +// account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。 +func (a *Account) GetCNProtocolBaseURL(protocol string) string { + if a == nil || !a.IsCNProvider() { + return "" + } + if a.IsAdaptiveAPIProtocol() { + if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok { + if baseURL, ok := baseURLs[protocol].(string); ok && strings.TrimSpace(baseURL) != "" { + return strings.TrimSpace(baseURL) + } + } + if protocol == APIProtocolChatCompletions { + if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { + return baseURL + } + } + } + return a.defaultCNProtocolBaseURL(protocol) +} + +func (a *Account) defaultCNProtocolBaseURL(protocol string) string { + switch protocol { + case APIProtocolAnthropic: + switch a.Platform { + case PlatformKimi: + if a.GetAccountMode() == AccountModeCoding { + return DefaultKimiCodingAnthropicBaseURL + } + return DefaultKimiPayGAnthropicBaseURL + case PlatformZhipu: + return DefaultZhipuAnthropicBaseURL + case PlatformDeepseek: + return DefaultDeepseekAnthropicBaseURL + } + case APIProtocolChatCompletions, APIProtocolResponses: + switch a.Platform { + case PlatformKimi: + if a.GetAccountMode() == AccountModeCoding { + return DefaultKimiCodingBaseURL + } + return DefaultKimiPayGBaseURL + case PlatformZhipu: + if a.GetAccountMode() == AccountModeCoding { + return DefaultZhipuCodingBaseURL + } + return DefaultZhipuPayGBaseURL + case PlatformDeepseek: + return DefaultDeepseekBaseURL + } + } + return "" +} + // IsAnthropicProtocol 报告账号是否以原生 Anthropic 协议接入上游 // (/v1/messages 直通,适配 Claude Code 等客户端)。 func (a *Account) IsAnthropicProtocol() bool { @@ -1383,9 +1452,12 @@ func (a *Account) IsAnthropicProtocol() bool { // (上游路径为 {base}/v1/messages)。优先取凭证 base_url,缺失时按 // 供应商 × 接入模式返回默认端点。非 Anthropic 协议账号返回空串。 func (a *Account) GetAnthropicProtocolBaseURL() string { - if a == nil || !a.IsAnthropicProtocol() { + if a == nil || (!a.IsAnthropicProtocol() && !a.IsAdaptiveAPIProtocol()) { return "" } + if a.IsAdaptiveAPIProtocol() { + return a.GetCNProtocolBaseURL(APIProtocolAnthropic) + } if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream { if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" { return baseURL diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index a2a08f2e0a..49d5c76295 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -285,8 +285,13 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int } // Route to platform-specific test method - if account.IsCNProvider() && account.GetAPIProtocol() == APIProtocolChatCompletions { - return s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt) + if account.IsCNProvider() { + switch account.GetAPIProtocol() { + case APIProtocolAdaptive: + return s.testCNProviderAdaptiveConnection(c, account, modelID, prompt) + case APIProtocolChatCompletions: + return s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt) + } } if account.IsOpenAI() { @@ -3063,6 +3068,13 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co } func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) { + if event.Type == "test_complete" { + if suppress, ok := c.Get(accountTestSuppressCompletionContextKey); ok { + if suppressCompletion, _ := suppress.(bool); suppressCompletion { + return + } + } + } eventJSON, _ := json.Marshal(event) if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil { log.Printf("failed to write SSE event: %v", err) diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go new file mode 100644 index 0000000000..ae52e4f117 --- /dev/null +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -0,0 +1,206 @@ +package service + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" +) + +const accountTestSuppressCompletionContextKey = "account_test_suppress_completion" + +// testCNProviderAdaptiveConnection verifies every native endpoint used by an +// adaptive CN-provider account. Kimi and Zhipu use Chat Completions plus +// Anthropic; DeepSeek additionally uses its native Responses endpoint. +func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error { + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = openai.DefaultTestModel + } + testModelID = account.GetMappedModel(testModelID) + + authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if authToken == "" { + return s.sendErrorAndEnd(c, "No API key available") + } + + // The existing Chat probe owns the SSE lifecycle. Suppress intermediate + // completion events until every native adaptive endpoint has passed. + c.Set(accountTestSuppressCompletionContextKey, true) + defer c.Set(accountTestSuppressCompletionContextKey, false) + if err := s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt); err != nil { + return err + } + + if err := s.testCNProviderAdaptiveAnthropicConnection(c, account, testModelID, authToken); err != nil { + return err + } + + if account.Platform == PlatformDeepseek { + if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil { + return err + } + } + + c.Set(accountTestSuppressCompletionContextKey, false) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil +} + +func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Context, account *Account, testModelID string, authToken string) error { + ctx := c.Request.Context() + baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolAnthropic)) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Anthropic base URL: %s", err.Error())) + } + apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages" + + payload, err := createTestPayload(testModelID) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic test payload") + } + payloadBytes, _ := json.Marshal(payload) + + s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /v1/messages 测试自适应 Anthropic 端点"}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic request") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("anthropic-version", "2023-06-01") + for key, value := range claude.DefaultHeaders { + req.Header.Set(key, value) + } + req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.doCNProviderAdaptiveRequest(req, account) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := fmt.Sprintf("Adaptive Anthropic endpoint returned %d: %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, errMsg) + } + + if err := s.processCNProviderAdaptiveAnthropicStream(c, resp.Body); err != nil { + return err + } + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /v1/messages 验证"}) + return nil +} + +func (s *AccountTestService) processCNProviderAdaptiveAnthropicStream(c *gin.Context, body io.Reader) error { + reader := bufio.NewReader(body) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + return s.sendErrorAndEnd(c, "Adaptive Anthropic stream ended before message_stop") + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic stream read error: %s", err.Error())) + } + + line = strings.TrimSpace(line) + if line == "" || !sseDataPrefix.MatchString(line) { + continue + } + jsonStr := sseDataPrefix.ReplaceAllString(line, "") + if jsonStr == "[DONE]" { + return nil + } + + var data map[string]any + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { + continue + } + switch eventType, _ := data["type"].(string); eventType { + case "content_block_delta": + if delta, ok := data["delta"].(map[string]any); ok { + if text, ok := delta["text"].(string); ok && text != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: text}) + } + } + case "message_stop": + return nil + case "error": + errorMsg := "Unknown error" + if errData, ok := data["error"].(map[string]any); ok { + if message, ok := errData["message"].(string); ok && message != "" { + errorMsg = message + } + } + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint error: %s", errorMsg)) + } + } +} + +func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Context, account *Account, testModelID string, authToken string) error { + ctx := c.Request.Context() + baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolResponses)) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Responses base URL: %s", err.Error())) + } + apiURL := buildOpenAIResponsesURLForPlatform(account.Platform, baseURL) + + payload := createOpenAITestPayload(testModelID, false) + // DeepSeek's native Responses endpoint is stateless and does not need the + // OpenAI probe's synthetic instructions. + delete(payload, "instructions") + payloadBytes, _ := json.Marshal(payload) + payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes) + + s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /responses 测试自适应 Responses 端点"}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create adaptive Responses request") + } + req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Authorization", "Bearer "+authToken) + applyOpenAICodexProbeHeaders(req.Header) + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.doCNProviderAdaptiveRequest(req, account) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Responses endpoint request failed: %s", err.Error())) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + errMsg := fmt.Sprintf("Adaptive Responses endpoint returned %d: %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, errMsg) + } + + if err := s.processOpenAIStream(c, resp.Body); err != nil { + return err + } + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /responses 验证"}) + return nil +} + +func (s *AccountTestService) doCNProviderAdaptiveRequest(req *http.Request, account *Account) (*http.Response, error) { + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + return s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) +} diff --git a/backend/internal/service/account_test_service_cn_adaptive_test.go b/backend/internal/service/account_test_service_cn_adaptive_test.go new file mode 100644 index 0000000000..808aec33b4 --- /dev/null +++ b/backend/internal/service/account_test_service_cn_adaptive_test.go @@ -0,0 +1,191 @@ +//go:build unit + +package service + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func adaptiveCNAccountTestAccount(id int64, platform string) *Account { + return &Account{ + ID: id, + Name: "adaptive-cn-test", + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-adaptive-test", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "http://chat.example/v1", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example", + }, + }, + } +} + +func adaptiveCNAccountTestService(account *Account, responses ...*http.Response) (*AccountTestService, *httpUpstreamRecorder) { + repo := &openAIAccountTestRepo{ + mockAccountRepoForGemini: mockAccountRepoForGemini{ + accountsByID: map[int64]*Account{account.ID: account}, + }, + } + upstream := &httpUpstreamRecorder{responses: responses} + return &AccountTestService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: rawChatCompletionsTestConfig(), + }, upstream +} + +func adaptiveCNChatTestResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(`data: {"choices":[{"delta":{"content":"chat ok"},"finish_reason":"stop"}]} + +data: [DONE] + +`)), + } +} + +func adaptiveCNAnthropicTestResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(`data: {"type":"content_block_delta","delta":{"text":"anthropic ok"}} + +data: {"type":"message_stop"} + +`)), + } +} + +func adaptiveCNResponsesTestResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"responses ok"} + +data: {"type":"response.completed"} + +`)), + } +} + +func TestAccountTestService_AdaptiveChatOnlyProvidersTestChatAndAnthropicEndpoints(t *testing.T) { + for index, testCase := range []struct { + name string + platform string + model string + }{ + {name: "Kimi", platform: PlatformKimi, model: "kimi-k2.5"}, + {name: "Zhipu", platform: PlatformZhipu, model: "glm-4.7"}, + } { + t.Run(testCase.name, func(t *testing.T) { + account := adaptiveCNAccountTestAccount(int64(301+index), testCase.platform) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + adaptiveCNAnthropicTestResponse(), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, testCase.model, "hello", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 2) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, "http://anthropic.example/v1/messages", upstream.requests[1].URL.String()) + require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[0].Header.Get("Authorization")) + require.Equal(t, "sk-adaptive-test", upstream.requests[1].Header.Get("x-api-key")) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_start"`)) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) + require.Contains(t, recorder.Body.String(), "已通过原生 /v1/messages 验证") + }) + } +} + +func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(302, PlatformDeepseek) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + adaptiveCNAnthropicTestResponse(), + adaptiveCNResponsesTestResponse(), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "deepseek-chat", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 3) + require.Equal(t, "http://responses.example/responses", upstream.requests[2].URL.String()) + require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[2].Context())) + require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[2].Header.Get("Authorization")) + require.True(t, gjson.GetBytes(upstream.bodies[2], "stream").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "store").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "instructions").Exists()) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) + require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证") +} + +func TestAccountTestService_AdaptiveStopsAndNamesFailingEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(303, PlatformDeepseek) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + newJSONResponse(http.StatusNotFound, `{"error":{"message":"missing messages route"}}`), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "deepseek-chat", "", AccountTestModeDefault) + + require.Error(t, err) + require.Contains(t, err.Error(), "Adaptive Anthropic endpoint returned 404") + require.Len(t, upstream.requests, 2) + require.Contains(t, recorder.Body.String(), `"type":"error"`) + require.NotContains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_AdaptiveRejectsInvalidAnthropicSuccessBody(t *testing.T) { + account := adaptiveCNAccountTestAccount(305, PlatformKimi) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + newJSONResponse(http.StatusOK, `not an Anthropic stream`), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "kimi-k2.5", "", AccountTestModeDefault) + + require.Error(t, err) + require.Contains(t, err.Error(), "Adaptive Anthropic stream ended before message_stop") + require.Len(t, upstream.requests, 2) + require.Contains(t, recorder.Body.String(), `"type":"error"`) + require.NotContains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_FixedCNChatProtocolStillTestsOnlyChatEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(304, PlatformZhipu) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["base_url"] = "http://fixed-chat.example/v1" + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "http://fixed-chat.example/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) +} diff --git a/backend/internal/service/adaptive_api_protocol_test.go b/backend/internal/service/adaptive_api_protocol_test.go new file mode 100644 index 0000000000..210caa0695 --- /dev/null +++ b/backend/internal/service/adaptive_api_protocol_test.go @@ -0,0 +1,227 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func adaptiveProtocolTestAccount(platform string, baseURLs map[string]any) *Account { + return &Account{ + ID: 701, + Name: "adaptive-cn", + Platform: platform, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAdaptive, + "account_mode": AccountModePayG, + "api_base_urls": baseURLs, + }, + } +} + +func adaptiveProtocolTestContext(path string, body []byte) *gin.Context { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c +} + +type cnProtocolIngressCase struct { + name string + path string + body []byte + forward func(*OpenAIGatewayService, *gin.Context, *Account, []byte) error +} + +func cnProtocolIngressCases() []cnProtocolIngressCase { + return []cnProtocolIngressCase{ + { + name: "chat completions", + path: "/v1/chat/completions", + body: []byte(`{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}],"stream":false}`), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + return err + }, + }, + { + name: "messages", + path: "/v1/messages", + body: []byte(`{"model":"deepseek-chat","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + return err + }, + }, + { + name: "responses", + path: "/v1/responses", + body: []byte(`{"model":"deepseek-chat","input":"hello","stream":false}`), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.Forward(context.Background(), c, account, body) + return err + }, + }, + } +} + +func TestAdaptiveProtocolRoutesChatCompletionsToNativeChat(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"glm-4.7","messages":[{"role":"user","content":"hello"}],"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) +} + +func TestAdaptiveProtocolRoutesResponsesShapedChatToNativeResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"deepseek-v4","input":"hello","max_output_tokens":32,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists()) +} + +func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"kimi-k2.5","input":"hello","max_output_tokens":32,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) +} + +func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"glm-4.7","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.ForwardAsAnthropic(context.Background(), adaptiveProtocolTestContext("/v1/messages", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://anthropic.example/v1/messages", upstream.lastReq.URL.String()) + require.Equal(t, "glm-4.7", gjson.GetBytes(upstream.lastBody, "model").String()) +} + +func TestAdaptiveProtocolConvertsKimiResponsesToChatCompletions(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"kimi-k2.5","input":"hello","stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + }) + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) +} + +func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"deepseek-v4","input":"hello","max_output_tokens":32,"store":true,"previous_response_id":"resp_old","stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example", + }) + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool()) + require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) + require.Equal(t, int64(32), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists()) +} + +func TestFixedCNChatProtocolOverridesStaleResponsesMode(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range cnProtocolIngressCases() { + t.Run(tc.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, nil) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["base_url"] = "http://chat.example" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), + } + + err := tc.forward(svc, adaptiveProtocolTestContext(tc.path, tc.body), account, tc.body) + + require.Error(t, err) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) + }) + } +} + +func TestFixedCNResponsesProtocolOverridesStaleChatMode(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range cnProtocolIngressCases() { + t.Run(tc.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformDeepseek, nil) + account.Credentials["api_protocol"] = APIProtocolResponses + account.Credentials["base_url"] = "http://responses.example" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + } + + err := tc.forward(svc, adaptiveProtocolTestContext(tc.path, tc.body), account, tc.body) + + require.Error(t, err) + require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String()) + }) + } +} diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 24b8bd4405..8f46e828e1 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -55,9 +55,9 @@ func TestCNMillisToRFC3339(t *testing.T) { t.Parallel() // 1700000000 秒 = 1700000000000 毫秒 want := time.UnixMilli(1700000000000).UTC().Format(time.RFC3339) - require.Equal(t, want, cnMillisToRFC3339(1700000000)) // 秒级 - require.Equal(t, want, cnMillisToRFC3339(1700000000000)) // 毫秒级 - require.Equal(t, "", cnMillisToRFC3339(0)) // 非正 + require.Equal(t, want, cnMillisToRFC3339(1700000000)) // 秒级 + require.Equal(t, want, cnMillisToRFC3339(1700000000000)) // 毫秒级 + require.Equal(t, "", cnMillisToRFC3339(0)) // 非正 require.Equal(t, "", cnMillisToRFC3339(-1)) } @@ -294,8 +294,8 @@ func TestEvaluateAccountSchedulingThreshold_KimiCodingPlan(t *testing.T) { account := &Account{ Platform: PlatformKimi, Extra: map[string]any{ - "kimi_5h_used_percent": 90.0, - "kimi_5h_reset_at": reset.Format(time.RFC3339), + "kimi_5h_used_percent": 90.0, + "kimi_5h_reset_at": reset.Format(time.RFC3339), "kimi_weekly_used_percent": 30.0, "kimi_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339), }, @@ -410,9 +410,9 @@ func TestGetOpenAIProtocolAPIKey_CNProviders(t *testing.T) { t.Parallel() kimi := &Account{ - Platform: PlatformKimi, - Type: AccountTypeAPIKey, - Credentials: map[string]any{"api_key": "sk-kimi"}, + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-kimi"}, } require.Equal(t, "sk-kimi", kimi.GetOpenAIProtocolAPIKey()) require.False(t, kimi.IsOpenAIApiKey(), "IsOpenAIApiKey stays openai-only for scheduling gates") @@ -486,12 +486,66 @@ func TestGetAPIProtocol(t *testing.T) { require.Equal(t, APIProtocolAnthropic, mk(PlatformKimi, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol(), "kimi 无 responses 端点") require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点") require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认") require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认") } +func TestAdaptiveProtocolBaseURLs(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + platform string + mode string + wantChat string + wantAnthropic string + wantResponses string + }{ + {"kimi payg", PlatformKimi, AccountModePayG, DefaultKimiPayGBaseURL, DefaultKimiPayGAnthropicBaseURL, DefaultKimiPayGBaseURL}, + {"kimi coding", PlatformKimi, AccountModeCoding, DefaultKimiCodingBaseURL, DefaultKimiCodingAnthropicBaseURL, DefaultKimiCodingBaseURL}, + {"zhipu payg", PlatformZhipu, AccountModePayG, DefaultZhipuPayGBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuPayGBaseURL}, + {"zhipu coding", PlatformZhipu, AccountModeCoding, DefaultZhipuCodingBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuCodingBaseURL}, + {"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + account := &Account{Platform: tc.platform, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_protocol": APIProtocolAdaptive, + "account_mode": tc.mode, + }} + require.Equal(t, tc.wantChat, account.GetCNProtocolBaseURL(APIProtocolChatCompletions)) + require.Equal(t, tc.wantAnthropic, account.GetCNProtocolBaseURL(APIProtocolAnthropic)) + require.Equal(t, tc.wantResponses, account.GetCNProtocolBaseURL(APIProtocolResponses)) + require.Equal(t, tc.wantAnthropic, account.GetAnthropicProtocolBaseURL()) + }) + } +} + +func TestAdaptiveProtocolBaseURLOverrides(t *testing.T) { + t.Parallel() + + account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_protocol": APIProtocolAdaptive, + "base_url": "https://legacy-chat.example.com", + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "https://chat.example.com", + APIProtocolAnthropic: "https://anthropic.example.com", + APIProtocolResponses: "https://responses.example.com", + }, + }} + + require.Equal(t, "https://chat.example.com", account.GetOpenAIBaseURL()) + require.Equal(t, "https://chat.example.com", account.GetCNProtocolBaseURL(APIProtocolChatCompletions)) + require.Equal(t, "https://anthropic.example.com", account.GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://responses.example.com", account.GetCNProtocolBaseURL(APIProtocolResponses)) +} + // TestAnthropicProtocolBaseURL 验证 Anthropic 协议默认端点与协议感知的 // OpenAI 格式 base 回退。 func TestAnthropicProtocolBaseURL(t *testing.T) { @@ -600,6 +654,14 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) { require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists()) require.Equal(t, "deepseek-v4-pro", gjson.GetBytes(normalized, "model").String()) + deepseekAdaptive := &Account{ + Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAdaptive}, + } + adaptiveNormalized := normalizeDeepSeekResponsesRequestBody(deepseekAdaptive, body) + require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool()) + require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists()) + // 非 responses 协议(deepseek CC 账号)原样返回 deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body))) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index ba35301be0..860276b753 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -64,6 +64,7 @@ const ( APIProtocolChatCompletions = domain.APIProtocolChatCompletions APIProtocolAnthropic = domain.APIProtocolAnthropic APIProtocolResponses = domain.APIProtocolResponses + APIProtocolAdaptive = domain.APIProtocolAdaptive ) // 国产 OpenAI 兼容供应商各模式的默认 base_url。 diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 645a581694..329cfebad4 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -125,9 +125,10 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte if account.IsCNProvider() { // 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions, // 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。 - // 例外:deepseek 的 responses 协议账号(api_protocol=responses)使用官方原生 - // /responses 端点,落标 force_responses 强制走 Responses 路径。 - if account.GetAPIProtocol() == APIProtocolResponses { + // 例外:deepseek 的固定 responses 和 adaptive 账号使用官方原生 /responses + // 端点,落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。 + if account.GetAPIProtocol() == APIProtocolResponses || + (account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) { _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), openai_compat.ExtraKeyResponsesSupported: true, @@ -135,6 +136,7 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte return } _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto), openai_compat.ExtraKeyResponsesSupported: false, }) return diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go index fac351a0ec..f3b4eb0e6d 100644 --- a/backend/internal/service/openai_apikey_responses_probe_test.go +++ b/backend/internal/service/openai_apikey_responses_probe_test.go @@ -49,6 +49,46 @@ func TestProbeOpenAIAPIKeyResponsesSupportUsesCodexProbeHeaders(t *testing.T) { require.Equal(t, true, updates[openai_compat.ExtraKeyResponsesSupported]) } +func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) { + tests := []struct { + name string + id int64 + platform string + protocol string + wantSupport bool + wantMode string + }{ + {name: "deepseek adaptive supports responses", id: 201, platform: PlatformDeepseek, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)}, + {name: "deepseek chat clears forced responses", id: 202, platform: PlatformDeepseek, protocol: APIProtocolChatCompletions, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + {name: "kimi adaptive falls back to chat", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + {name: "zhipu adaptive falls back to chat", id: 204, platform: PlatformZhipu, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + updateCalls := make(chan map[string]any, 1) + account := Account{ + ID: tc.id, Platform: tc.platform, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "api_protocol": tc.protocol}, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), + }, + } + repo := &snapshotUpdateAccountRepo{ + stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, + updateExtraCalls: updateCalls, + } + svc := &AccountTestService{accountRepo: repo} + + svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID) + + updates := <-updateCalls + require.Equal(t, tc.wantSupport, updates[openai_compat.ExtraKeyResponsesSupported]) + require.Equal(t, tc.wantMode, updates[openai_compat.ExtraKeyResponsesMode]) + }) + } +} + func TestDecideResponsesProbeSupport(t *testing.T) { fnCall := []byte(`{"output":[{"type":"reasoning"},{"type":"function_call","name":"probe_ping"}]}`) reasoningOnly := []byte(`{"output":[{"type":"reasoning"}]}`) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 8308c4c0c3..9c413f0bc6 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -46,11 +46,11 @@ var cursorResponsesUnsupportedFields = []string{ // 正确的,但 sub2api 接入 DeepSeek/Kimi/GLM 等第三方 OpenAI 兼容上游后假设破裂: // 这些上游普遍只支持 /v1/chat/completions,无 /v1/responses 端点。 // -// 当前路由策略(基于账号覆盖模式/探测标记,详见 openai_compat.ShouldUseResponsesAPI): -// - APIKey 账号 + 强制或探测确认不支持 Responses → 走 forwardAsRawChatCompletions -// 直转上游 /v1/chat/completions,不做协议转换 -// - 其他所有情况(OAuth、APIKey 强制/探测确认支持、未探测)→ 走原有 CC→Responses -// 转换路径(保留旧行为,存量未探测账号零兼容破坏) +// 当前路由策略: +// - CN 账号以 credentials.api_protocol 为权威;adaptive/chat_completions 入站 Chat +// 直转原生 CC,anthropic 走原生 Anthropic,responses 走 Responses +// - 其他 APIKey 账号仍按覆盖模式/探测标记分流(详见 +// openai_compat.ShouldUseResponsesAPI) func (s *OpenAIGatewayService) ForwardAsChatCompletions( ctx context.Context, c *gin.Context, @@ -88,6 +88,39 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } + // Cursor compatibility: some clients send a Responses-shaped body to the + // /v1/chat/completions URL. Detect it before adaptive routing so adaptive + // accounts never forward the body unchanged to a Chat Completions endpoint. + isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() + + // 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。 + // Responses 形状下,DeepSeek 继续走下方原生 Responses 链;Kimi/GLM + // 没有 Responses 端点,先转换成 Chat Completions 再直转。 + if account.IsAdaptiveAPIProtocol() { + if !isResponsesShape { + return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + } + if account.Platform != PlatformDeepseek { + var responsesReq apicompat.ResponsesRequest + if err := json.Unmarshal(body, &responsesReq); err != nil { + return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err) + } + chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions( + &responsesReq, + &apicompat.ResponsesToChatOptions{ReasoningContentByID: s.reasoningContentByID}, + ) + if err != nil { + return nil, fmt.Errorf("convert responses-shaped chat completions request: %w", err) + } + chatBody, err := json.Marshal(chatReq) + if err != nil { + return nil, fmt.Errorf("marshal converted chat completions request: %w", err) + } + return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) + } + // DeepSeek 原生 Responses 请求继续走下方 Responses→Chat 回程转换。 + } + // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点, // CC 入站请求经 CC→Responses→Anthropic 转换链直通该端点。必须先于 // ShouldUseResponsesAPI 分流:该类账号经 probe 落标 @@ -96,9 +129,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) } - // 入口分流:APIKey 账号 + 强制或已探测确认上游不支持 Responses,走 CC 直转。 - // 自动模式下标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。 - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + // 固定 chat_completions 的 CN 账号,以及强制或已探测确认不支持 Responses + // 的其他 APIKey 账号,均走 CC 直转。 + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } @@ -133,11 +166,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( // and produce `input: null`, which Codex upstreams reject with // "Invalid type for 'input': expected a string, but got an object". // - // Detect that shape and forward the raw body as-is, only rewriting `model` + // Forward that shape as-is, only rewriting `model` // to the resolved upstream model. The downstream codex OAuth transform will // still normalize store/stream/instructions/etc. - isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() - var ( responsesReq *apicompat.ResponsesRequest responsesBody []byte diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 47c02d866e..ec5fafdb72 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -104,6 +104,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco requestView := newOpenAIRequestView(body) reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey originalModel := reqModel + nativeDeepSeekResponses := account.Platform == PlatformDeepseek && + (account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol()) if account.Platform == PlatformGrok { return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) @@ -286,7 +288,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && !compatMessagesBridge { + if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses { markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) } @@ -459,7 +461,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco maxOutputTokens := gjson.GetBytes(body, "max_output_tokens") if maxOutputTokens.Exists() { switch account.Platform { - case PlatformOpenAI: + case PlatformOpenAI, PlatformDeepseek: // Preserve Responses-native output limits unless the selected upstream // explicitly rejects the field in the bounded HTTP retry loop below. case PlatformAnthropic: @@ -1040,9 +1042,22 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { - return account != nil && - account.Type == AccountTypeAPIKey && - !openai_compat.ShouldUseResponsesAPI(account.Extra) + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + if account.IsCNProvider() { + // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生 + // Responses,Kimi/GLM 回退 Chat Completions。 + switch account.GetAPIProtocol() { + case APIProtocolChatCompletions: + return true + case APIProtocolAdaptive: + return account.Platform != PlatformDeepseek + default: + return false + } + } + return !openai_compat.ShouldUseResponsesAPI(account.Extra) } func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) { @@ -1055,6 +1070,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. case AccountTypeAPIKey: // API Key accounts use Platform API or custom base URL baseURL := account.GetOpenAIBaseURL() + if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() { + baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) + } if baseURL == "" { targetURL = openaiPlatformAPIURL } else { diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 4b0aa58edf..f1ae729ad1 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -15,7 +15,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -41,15 +40,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。 // 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标 // openai_responses_supported=false,会先命中下方的 CC 直转分支。 - if account.IsAnthropicProtocol() { + if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() { return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) } - // 入口分流:APIKey 账号 + 上游不支持 Responses API → 走 CC 直转(与 - // ForwardAsChatCompletions 对称)。缺少此分流时,/v1/messages 入站请求 - // 会被无条件转为 Responses 格式发往上游 /v1/responses,导致只支持 - // /v1/chat/completions 的第三方 OpenAI 兼容上游全部 400。 - if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { + // 固定 chat_completions 的 CN 账号,以及不支持 Responses 的其他 APIKey + // 账号,均将 Messages 转为 CC;固定 responses 的 CN 账号不受探针旧值覆盖。 + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) } diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index f2efddcf77..59199cea05 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -59,7 +59,8 @@ func buildOpenAIResponsesURLForPlatform(platform string, base string) string { // 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端 // 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。 func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte { - if account == nil || account.Platform != PlatformDeepseek || account.GetAPIProtocol() != APIProtocolResponses { + if account == nil || account.Platform != PlatformDeepseek || + (account.GetAPIProtocol() != APIProtocolResponses && !account.IsAdaptiveAPIProtocol()) { return body } normalized, err := sjson.SetBytes(body, "store", false) diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index c6a62bab09..7255464c03 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -597,6 +597,9 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "missing_api_key", 0) } baseURL := account.GetCredential("base_url") + if account.IsCNProvider() && account.IsAdaptiveAPIProtocol() { + baseURL = account.GetCNProtocolBaseURL(APIProtocolChatCompletions) + } if account.Platform == PlatformOpenAI { if baseURL == "" { // 保持官方语义:OpenAI 账号无自定义 base 时探官方域(404 → unsupported)。 diff --git a/backend/internal/service/upstream_billing_probe_test.go b/backend/internal/service/upstream_billing_probe_test.go index e032e9aa16..9a5f774f4f 100644 --- a/backend/internal/service/upstream_billing_probe_test.go +++ b/backend/internal/service/upstream_billing_probe_test.go @@ -340,6 +340,38 @@ func TestUpstreamBillingProbeSuccessPersistsSanitizedSnapshot(t *testing.T) { require.Equal(t, snapshot.Status, persisted.Status) } +func TestUpstreamBillingProbeAdaptiveCNUsesChatProtocolBaseURL(t *testing.T) { + account := &Account{ + ID: 18, + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-sensitive", + "api_protocol": APIProtocolAdaptive, + "base_url": "https://legacy-relay.example/v1", + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "https://chat-relay.example/v1", + }, + }, + Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: upstreamBillingProbeValidBody(), + }} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) + require.Equal(t, "https://chat-relay.example/v1/sub2api/billing", upstream.lastReq.URL.String()) +} + func TestUpstreamBillingProbeSyncsResolvedRateForAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, diff --git a/frontend/src/components/account/CnBaseUrlPresets.vue b/frontend/src/components/account/CnBaseUrlPresets.vue index 5a5172555a..45ccb32dc3 100644 --- a/frontend/src/components/account/CnBaseUrlPresets.vue +++ b/frontend/src/components/account/CnBaseUrlPresets.vue @@ -30,7 +30,7 @@ const props = defineProps<{ /** 当前已选账号类型,用于过滤和高亮匹配的预设 */ mode?: 'payg' | 'coding' /** 当前已选 API 协议,用于过滤和高亮匹配的预设 */ - protocol?: 'chat_completions' | 'anthropic' | 'responses' + protocol?: 'adaptive' | 'chat_completions' | 'anthropic' | 'responses' /** 当前输入框中的 base url,用于高亮完全匹配项 */ currentUrl?: string }>() diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 47ee8c27d5..2c9621f17f 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -537,7 +537,7 @@ : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' ]" > - +
{{ t(`admin.accounts.cnProviders.apiProtocol.${opt.labelKey}`) }} @@ -1250,7 +1250,7 @@
-
+
+
+ +
+
+ + +
+
+

+ {{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }} +

+
('payg') // API 协议决定转发端点与格式:cc=现有转换链,anthropic=原生直通(Claude Code), // responses=deepseek 原生 Responses 端点(Codex)。与账号类型正交。 -const apiProtocol = ref('chat_completions') +const apiProtocol = ref('adaptive') +const adaptiveBaseUrls = ref>({ + chat_completions: '', + anthropic: '', + responses: '' +}) const isCNPlatform = computed( () => form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek' ) @@ -3947,6 +3973,7 @@ const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => { // 当前平台可选的协议档(responses 仅 deepseek)。 const cnProtocolOptions = computed>(() => { const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [ + { value: 'adaptive', labelKey: 'adaptive' }, { value: 'chat_completions', labelKey: 'chatCompletions' }, { value: 'anthropic', labelKey: 'anthropic' } ] @@ -3955,6 +3982,18 @@ const cnProtocolOptions = computed>(() => { + const opts: Array<{ value: CnNativeApiProtocol; labelKey: string }> = [ + { value: 'chat_completions', labelKey: 'chatCompletions' }, + { value: 'anthropic', labelKey: 'anthropic' } + ] + if (form.platform === 'deepseek') opts.push({ value: 'responses', labelKey: 'responses' }) + return opts +}) + +function resetAdaptiveBaseUrls(platform: 'kimi' | 'zhipu' | 'deepseek', mode: CnAccountMode) { + adaptiveBaseUrls.value = defaultCNAdaptiveBaseUrls(platform, mode) +} // 当前选中平台的品牌色(选中卡片描边 / 图标底色),与 platformColors 取色一致。 const cnAccentActiveClass = computed(() => { switch (form.platform) { @@ -3981,24 +4020,44 @@ const cnAccentIconClass = computed(() => { } }) // 切换国产供应商平台:强制 apikey 类型,deepseek 无 coding 套餐故锁定 payg, -// 协议回落 chat_completions,并把 base url 重置为该平台默认端点。 +// 协议回落 adaptive,并把 base url 重置为该平台默认端点。 function selectCNPlatform(platform: 'kimi' | 'zhipu' | 'deepseek') { form.platform = platform form.type = 'apikey' accountCategory.value = 'apikey' - apiProtocol.value = 'chat_completions' + apiProtocol.value = 'adaptive' if (platform === 'deepseek') { accountMode.value = 'payg' } apiKeyBaseUrl.value = defaultCNBaseUrl(platform, accountMode.value, apiProtocol.value) + resetAdaptiveBaseUrls(platform, accountMode.value) } // 账号类型 / 协议变更时同步默认 base url。 -watch(accountMode, (mode) => { +watch(accountMode, (mode, previousMode) => { if (!isCNPlatform.value) return + if (apiProtocol.value === 'adaptive') { + const previousDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, previousMode) + const nextDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, mode) + for (const item of cnAdaptiveProtocolOptions.value) { + if (!adaptiveBaseUrls.value[item.value] || adaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) { + adaptiveBaseUrls.value[item.value] = nextDefaults[item.value] + } + } + apiKeyBaseUrl.value = adaptiveBaseUrls.value.chat_completions + return + } apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, mode, apiProtocol.value) }) watch(apiProtocol, (protocol) => { if (!isCNPlatform.value) return + if (protocol === 'adaptive') { + const defaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, accountMode.value) + for (const item of cnAdaptiveProtocolOptions.value) { + if (!adaptiveBaseUrls.value[item.value]) adaptiveBaseUrls.value[item.value] = defaults[item.value] + } + apiKeyBaseUrl.value = adaptiveBaseUrls.value.chat_completions + return + } apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, accountMode.value, protocol) }) // 点击预设端点:同时回填 base url、账号类型与协议。 @@ -4010,10 +4069,13 @@ function onCnPresetSelect(preset: { mode: CnAccountMode; protocol: CnApiProtocol const syncPreviewCredentials = computed(() => { if (!apiKeyValue.value) return undefined + const baseUrl = isCNPlatform.value && apiProtocol.value === 'adaptive' + ? adaptiveBaseUrls.value.chat_completions.trim() || apiKeyBaseUrl.value.trim() + : apiKeyBaseUrl.value.trim() return { platform: form.platform, type: form.type, - base_url: apiKeyBaseUrl.value || undefined, + base_url: baseUrl || undefined, api_key: apiKeyValue.value } }) @@ -4956,7 +5018,8 @@ const resetForm = () => { accountCategory.value = 'oauth-based' addMethod.value = 'oauth' accountMode.value = 'payg' - apiProtocol.value = 'chat_completions' + apiProtocol.value = 'adaptive' + adaptiveBaseUrls.value = { chat_completions: '', anthropic: '', responses: '' } apiKeyBaseUrl.value = 'https://api.anthropic.com' apiKeyValue.value = '' upstreamBillingAutoProbeEnabled.value = true @@ -5425,10 +5488,19 @@ const handleSubmit = async () => { if (form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek') { credentials.account_mode = accountMode.value credentials.api_protocol = apiProtocol.value + if (apiProtocol.value === 'adaptive') { + const defaults = defaultCNAdaptiveBaseUrls(form.platform, accountMode.value) + const protocolBaseUrls: Record = {} + for (const item of cnAdaptiveProtocolOptions.value) { + protocolBaseUrls[item.value] = (adaptiveBaseUrls.value[item.value] || defaults[item.value]).trim() + } + credentials.api_base_urls = protocolBaseUrls + credentials.base_url = protocolBaseUrls.chat_completions + } const resolvedCNBase = ( apiKeyBaseUrl.value.trim() || defaultCNBaseUrl(form.platform, accountMode.value, apiProtocol.value) ).trim() - if (resolvedCNBase) { + if (apiProtocol.value !== 'adaptive' && resolvedCNBase) { credentials.base_url = resolvedCNBase } } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 6495b61e6a..d1cb92df59 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -28,7 +28,7 @@
-
+
+
+ +
+
+ + +
+
+

+ {{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }} +

+
@@ -2802,11 +2816,13 @@ import { isHeaderOverrideCapable, splitHeaderOverridesObject, validateHeaderOverrideRows, + defaultCNAdaptiveBaseUrls, defaultCNBaseUrl, HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY, HEADER_OVERRIDES_CREDENTIAL_KEY, type CnAccountMode, type CnApiProtocol, + type CnNativeApiProtocol, type HeaderOverrideRow } from '@/components/account/credentialsBuilder' import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' @@ -2909,8 +2925,13 @@ const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => { } return 'kimi' }) -const editApiProtocol = ref('chat_completions') +const editApiProtocol = ref('adaptive') const editAccountMode = ref('payg') +const editAdaptiveBaseUrls = ref>({ + chat_completions: '', + anthropic: '', + responses: '' +}) // 回填窗口标志:syncFormFromAccount 会同步改写 editAccountMode / editApiProtocol, // 而 watcher(pre-flush)在同步代码执行完之后才触发——若不抑制,会把刚恢复的 // 存储版 base_url(可能是用户自定义/中转地址)覆盖为官方预设并在下次保存时持久化。 @@ -2930,6 +2951,7 @@ const cnAccountModeOptions = computed>(() => { const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [ + { value: 'adaptive', labelKey: 'adaptive' }, { value: 'chat_completions', labelKey: 'chatCompletions' }, { value: 'anthropic', labelKey: 'anthropic' } ] @@ -2938,11 +2960,35 @@ const cnProtocolOptions = computed { +const editAdaptiveProtocolOptions = computed>(() => { + const opts: Array<{ value: CnNativeApiProtocol; labelKey: string }> = [ + { value: 'chat_completions', labelKey: 'chatCompletions' }, + { value: 'anthropic', labelKey: 'anthropic' } + ] + if (props.account?.platform === 'deepseek') opts.push({ value: 'responses', labelKey: 'responses' }) + return opts +}) +watch(editApiProtocol, (protocol, previousProtocol) => { if (!isCNApiKeyAccount.value || syncingForm.value) return + if (protocol === 'adaptive') { + const defaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, editAccountMode.value) + for (const item of editAdaptiveProtocolOptions.value) { + if (!editAdaptiveBaseUrls.value[item.value]) editAdaptiveBaseUrls.value[item.value] = defaults[item.value] + } + if (previousProtocol !== 'adaptive' && editBaseUrl.value.trim()) { + editAdaptiveBaseUrls.value[previousProtocol] = editBaseUrl.value.trim() + } + editBaseUrl.value = editAdaptiveBaseUrls.value.chat_completions + return + } + if (previousProtocol === 'adaptive') { + editBaseUrl.value = editAdaptiveBaseUrls.value[protocol] || + defaultCNBaseUrl(props.account!.platform, editAccountMode.value, protocol) + return + } editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, editAccountMode.value, protocol) }) -watch(editAccountMode, (mode) => { +watch(editAccountMode, (mode, previousMode) => { if (!isCNApiKeyAccount.value || syncingForm.value) return // deepseek 无 coding 套餐:防御性回退(UI 已隐藏该选项)。 const effectiveMode = props.account!.platform === 'deepseek' && mode === 'coding' ? 'payg' : mode @@ -2950,6 +2996,17 @@ watch(editAccountMode, (mode) => { editAccountMode.value = effectiveMode return } + if (editApiProtocol.value === 'adaptive') { + const previousDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, previousMode) + const nextDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, mode) + for (const item of editAdaptiveProtocolOptions.value) { + if (!editAdaptiveBaseUrls.value[item.value] || editAdaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) { + editAdaptiveBaseUrls.value[item.value] = nextDefaults[item.value] + } + } + editBaseUrl.value = editAdaptiveBaseUrls.value.chat_completions + return + } editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, mode, editApiProtocol.value) }) const cnProtocolDescKey = computed( @@ -3774,10 +3831,46 @@ const syncFormFromAccount = (newAccount: Account | null) => { editAccountMode.value = credentials.account_mode === 'coding' ? 'coding' : 'payg' const storedProtocol = credentials.api_protocol editApiProtocol.value = - storedProtocol === 'anthropic' || storedProtocol === 'responses' ? storedProtocol : 'chat_completions' + storedProtocol === 'adaptive' || + storedProtocol === 'chat_completions' || + storedProtocol === 'anthropic' || + storedProtocol === 'responses' + ? storedProtocol + : 'chat_completions' if (newAccount.platform !== 'deepseek' && editApiProtocol.value === 'responses') { editApiProtocol.value = 'chat_completions' } + const adaptiveDefaults = defaultCNAdaptiveBaseUrls(newAccount.platform, editAccountMode.value) + const storedBaseUrls = (credentials.api_base_urls as Record | undefined) || {} + const legacyBaseUrl = typeof credentials.base_url === 'string' ? credentials.base_url.trim() : '' + const storedChatBaseUrl = typeof storedBaseUrls.chat_completions === 'string' + ? storedBaseUrls.chat_completions.trim() + : '' + const storedAnthropicBaseUrl = typeof storedBaseUrls.anthropic === 'string' + ? storedBaseUrls.anthropic.trim() + : '' + const storedResponsesBaseUrl = typeof storedBaseUrls.responses === 'string' + ? storedBaseUrls.responses.trim() + : '' + const nextAdaptiveBaseUrls: Record = { + chat_completions: storedChatBaseUrl || adaptiveDefaults.chat_completions, + anthropic: storedAnthropicBaseUrl || adaptiveDefaults.anthropic, + responses: storedResponsesBaseUrl || adaptiveDefaults.responses + } + const legacyProtocol: CnNativeApiProtocol = editApiProtocol.value === 'anthropic' + ? 'anthropic' + : editApiProtocol.value === 'responses' + ? 'responses' + : 'chat_completions' + const storedLegacyBaseUrl = legacyProtocol === 'anthropic' + ? storedAnthropicBaseUrl + : legacyProtocol === 'responses' + ? storedResponsesBaseUrl + : storedChatBaseUrl + if (legacyBaseUrl && !storedLegacyBaseUrl) { + nextAdaptiveBaseUrls[legacyProtocol] = legacyBaseUrl + } + editAdaptiveBaseUrls.value = nextAdaptiveBaseUrls } const platformDefaultUrl = newAccount.platform === 'openai' @@ -3791,7 +3884,9 @@ const syncFormFromAccount = (newAccount: Account | null) => { newAccount.platform === 'deepseek' ? defaultCNBaseUrl(newAccount.platform, editAccountMode.value, editApiProtocol.value) : 'https://api.anthropic.com' - editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl + editBaseUrl.value = isCNApiKeyAccount.value && editApiProtocol.value === 'adaptive' + ? editAdaptiveBaseUrls.value.chat_completions + : (credentials.base_url as string) || platformDefaultUrl // Load model mappings and detect mode loadModelRestrictionFromMapping(credentials.model_mapping as Record | undefined) @@ -4463,6 +4558,17 @@ const handleSubmit = async () => { if (isCNApiKeyAccount.value) { newCredentials.account_mode = editAccountMode.value newCredentials.api_protocol = editApiProtocol.value + if (editApiProtocol.value === 'adaptive') { + const defaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, editAccountMode.value) + const protocolBaseUrls: Record = {} + for (const item of editAdaptiveProtocolOptions.value) { + protocolBaseUrls[item.value] = (editAdaptiveBaseUrls.value[item.value] || defaults[item.value]).trim() + } + newCredentials.api_base_urls = protocolBaseUrls + newCredentials.base_url = protocolBaseUrls.chat_completions + } else { + delete newCredentials.api_base_urls + } } // Handle API key diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 552998802f..e5a0c8c9c1 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -110,6 +110,20 @@ const GroupSelectorStub = defineComponent({ `, }) +const ModelWhitelistSelectorStub = defineComponent({ + name: 'ModelWhitelistSelector', + props: { + modelValue: { + type: Array, + default: () => [], + }, + platform: String, + syncCredentials: Object, + }, + emits: ['update:modelValue'], + template: '
', +}) + function mountModal(groups: any[] = []) { return mount(CreateAccountModal, { props: { show: true, proxies: [], groups }, @@ -124,7 +138,7 @@ function mountModal(groups: any[] = []) { ProxySelector: true, ProxyAdBanner: true, GroupSelector: GroupSelectorStub, - ModelWhitelistSelector: true, + ModelWhitelistSelector: ModelWhitelistSelectorStub, QuotaLimitCard: true, }, }, @@ -269,6 +283,43 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(probeUpstreamBillingMock).not.toHaveBeenCalled() }) + it('submits adaptive Kimi protocol endpoints', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'Kimi') + await wrapper.get('form#create-account-form input[type="text"]').setValue('Kimi adaptive') + await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi') + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({ + account_mode: 'payg', + api_protocol: 'adaptive', + base_url: 'https://api.moonshot.cn/v1', + api_base_urls: { + chat_completions: 'https://api.moonshot.cn/v1', + anthropic: 'https://api.moonshot.cn/anthropic' + } + }) + }) + + it('uses the edited adaptive Chat endpoint when previewing upstream models', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'Kimi') + await wrapper + .get('[data-testid="cn-adaptive-base-url-chat_completions"]') + .setValue('https://relay.example.com/v1') + await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-relay') + + expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toMatchObject({ + platform: 'kimi', + type: 'apikey', + base_url: 'https://relay.example.com/v1', + api_key: 'sk-relay' + }) + }) + it('exposes Agent Identity in the OpenAI authorization methods', async () => { const wrapper = mountModal() await selectButtonByText(wrapper, 'OpenAI') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index e21089313e..acd0436f6a 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -343,6 +343,178 @@ describe('EditAccountModal', () => { }) }) + it('preserves adaptive GLM endpoints on submit', async () => { + const account = buildAccount() + account.platform = 'zhipu' + account.credentials = { + api_key: 'sk-glm', + account_mode: 'coding', + api_protocol: 'adaptive', + base_url: 'https://open.bigmodel.cn/api/coding/paas/v4', + api_base_urls: { + chat_completions: 'https://open.bigmodel.cn/api/coding/paas/v4', + anthropic: 'https://open.bigmodel.cn/api/anthropic' + } + } + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + + const wrapper = mountModal(account) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({ + account_mode: 'coding', + api_protocol: 'adaptive', + base_url: 'https://open.bigmodel.cn/api/coding/paas/v4', + api_base_urls: { + chat_completions: 'https://open.bigmodel.cn/api/coding/paas/v4', + anthropic: 'https://open.bigmodel.cn/api/anthropic' + } + }) + }) + + it.each([ + ['explicit Chat Completions', 'chat_completions'], + ['legacy missing protocol', undefined] + ])('preserves a custom CN relay for %s accounts', async (_name, storedProtocol) => { + const account = buildAccount() + account.platform = 'zhipu' + account.credentials = { + api_key: 'sk-glm', + account_mode: 'payg', + base_url: 'https://relay.example.com/v1' + } + if (storedProtocol) { + account.credentials.api_protocol = storedProtocol + } + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + + const wrapper = mountModal(account) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + const submittedCredentials = updateAccountMock.mock.calls[0]?.[1]?.credentials + expect(submittedCredentials).toMatchObject({ + account_mode: 'payg', + api_protocol: 'chat_completions', + base_url: 'https://relay.example.com/v1' + }) + expect(submittedCredentials).not.toHaveProperty('api_base_urls') + }) + + it('uses the legacy base_url when adaptive endpoints are missing', async () => { + const account = buildAccount() + account.platform = 'zhipu' + account.credentials = { + api_key: 'sk-glm', + account_mode: 'payg', + api_protocol: 'adaptive', + base_url: 'https://relay.example.com/v1', + api_base_urls: { + chat_completions: ' ' + } + } + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + + const wrapper = mountModal(account) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({ + api_protocol: 'adaptive', + base_url: 'https://relay.example.com/v1', + api_base_urls: { + chat_completions: 'https://relay.example.com/v1', + anthropic: 'https://open.bigmodel.cn/api/anthropic' + } + }) + }) + + it('carries a fixed Chat relay into Adaptive when the user switches protocols', async () => { + const account = buildAccount() + account.platform = 'zhipu' + account.credentials = { + api_key: 'sk-glm', + account_mode: 'payg', + api_protocol: 'chat_completions', + base_url: 'https://relay.example.com/v1' + } + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + + const wrapper = mountModal(account) + const adaptiveButton = wrapper + .findAll('button') + .find(button => button.text().includes('admin.accounts.cnProviders.apiProtocol.adaptive')) + expect(adaptiveButton).toBeDefined() + await adaptiveButton!.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({ + api_protocol: 'adaptive', + base_url: 'https://relay.example.com/v1', + api_base_urls: { + chat_completions: 'https://relay.example.com/v1' + } + }) + }) + + it.each([ + { + name: 'Anthropic', + platform: 'zhipu', + protocol: 'anthropic', + baseUrl: 'https://relay.example.com/anthropic', + expectedBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', + expectedProtocolUrls: { + chat_completions: 'https://open.bigmodel.cn/api/paas/v4', + anthropic: 'https://relay.example.com/anthropic' + } + }, + { + name: 'Responses', + platform: 'deepseek', + protocol: 'responses', + baseUrl: 'https://relay.example.com/responses', + expectedBaseUrl: 'https://api.deepseek.com', + expectedProtocolUrls: { + chat_completions: 'https://api.deepseek.com', + anthropic: 'https://api.deepseek.com/anthropic', + responses: 'https://relay.example.com/responses' + } + } + ])('keeps a fixed $name relay in its protocol slot when switching to Adaptive', async (testCase) => { + const account = buildAccount() + account.platform = testCase.platform + account.credentials = { + api_key: 'sk-cn', + account_mode: 'payg', + api_protocol: testCase.protocol, + base_url: testCase.baseUrl + } + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + + const wrapper = mountModal(account) + const adaptiveButton = wrapper + .findAll('button') + .find(button => button.text().includes('admin.accounts.cnProviders.apiProtocol.adaptive')) + expect(adaptiveButton).toBeDefined() + await adaptiveButton!.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({ + api_protocol: 'adaptive', + base_url: testCase.expectedBaseUrl, + api_base_urls: testCase.expectedProtocolUrls + }) + }) + it('preserves model mappings when editing the whitelist', async () => { const account = buildAccount() account.credentials.model_mapping = { diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts new file mode 100644 index 0000000000..618210ddf0 --- /dev/null +++ b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' + +import { defaultCNAdaptiveBaseUrls } from '../credentialsBuilder' + +describe('defaultCNAdaptiveBaseUrls', () => { + it('resolves Kimi endpoints by account mode', () => { + expect(defaultCNAdaptiveBaseUrls('kimi', 'payg')).toEqual({ + chat_completions: 'https://api.moonshot.cn/v1', + anthropic: 'https://api.moonshot.cn/anthropic', + responses: '' + }) + expect(defaultCNAdaptiveBaseUrls('kimi', 'coding')).toEqual({ + chat_completions: 'https://api.kimi.com/coding/v1', + anthropic: 'https://api.kimi.com/coding', + responses: '' + }) + }) + + it('resolves GLM endpoints by account mode', () => { + expect(defaultCNAdaptiveBaseUrls('zhipu', 'payg')).toEqual({ + chat_completions: 'https://open.bigmodel.cn/api/paas/v4', + anthropic: 'https://open.bigmodel.cn/api/anthropic', + responses: '' + }) + expect(defaultCNAdaptiveBaseUrls('zhipu', 'coding')).toEqual({ + chat_completions: 'https://open.bigmodel.cn/api/coding/paas/v4', + anthropic: 'https://open.bigmodel.cn/api/anthropic', + responses: '' + }) + }) + + it('includes all three native DeepSeek endpoints', () => { + expect(defaultCNAdaptiveBaseUrls('deepseek', 'payg')).toEqual({ + chat_completions: 'https://api.deepseek.com', + anthropic: 'https://api.deepseek.com/anthropic', + responses: 'https://api.deepseek.com' + }) + }) +}) diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 1ba2cb22bf..7f844fba0a 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -256,8 +256,9 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [ export type CnAccountMode = 'payg' | 'coding' -/** 仅 deepseek 支持 responses 协议(官方原生 /responses 端点,适配 Codex)。 */ -export type CnApiProtocol = 'chat_completions' | 'anthropic' | 'responses' +/** 仅 deepseek 支持原生 responses;adaptive 会按入站协议选择原生端点。 */ +export type CnApiProtocol = 'adaptive' | 'chat_completions' | 'anthropic' | 'responses' +export type CnNativeApiProtocol = Exclude export interface CnBaseUrlPreset { mode: CnAccountMode @@ -321,6 +322,18 @@ export function defaultCNBaseUrl( } } +/** 返回自适应模式下需要配置的原生协议及其默认端点。 */ +export function defaultCNAdaptiveBaseUrls( + platform: 'kimi' | 'zhipu' | 'deepseek', + mode: CnAccountMode +): Record { + return { + chat_completions: defaultCNBaseUrl(platform, mode, 'chat_completions'), + anthropic: defaultCNBaseUrl(platform, mode, 'anthropic'), + responses: platform === 'deepseek' ? defaultCNBaseUrl(platform, mode, 'responses') : '' + } +} + // ===== 国产供应商用量单元格可见性(单一事实源) ===== // CNProviderQuotaCell / CNProviderBalanceCell 与 AccountUsageCell 的占位符判定 // 共用,避免多处复制条件后一处改另一处漏改。 diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 858e420b99..b207251a60 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -118,6 +118,10 @@ export default { }, apiProtocol: { title: 'API Protocol', + adaptive: 'Adaptive', + adaptiveDesc: 'Uses the matching native provider endpoint for each inbound protocol, converting only when unavailable.', + endpoints: 'Protocol endpoints', + responsesFallbackDesc: 'Responses requests are converted to Chat Completions because this provider has no native Responses endpoint.', chatCompletions: 'Chat Completions', chatCompletionsDesc: 'Standard OpenAI-compatible endpoint; requests in other formats are converted.', anthropic: 'Anthropic', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 0d1883da06..382e86cbd1 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -321,6 +321,10 @@ export default { }, apiProtocol: { title: 'API 协议', + adaptive: '自适应', + adaptiveDesc: '按入站协议优先使用供应商原生端点,仅在没有对应端点时转换。', + endpoints: '协议端点', + responsesFallbackDesc: '该供应商没有原生 Responses 端点,Responses 请求将转换为 Chat Completions。', chatCompletions: 'Chat Completions', chatCompletionsDesc: '标准 OpenAI 兼容端点,其他格式请求将被转换。', anthropic: 'Anthropic',