From a749673de39ef1f76e4153a718e9d1e5765df179 Mon Sep 17 00:00:00 2001 From: HypoxanthineOvO Date: Fri, 21 Aug 2026 16:37:25 +0800 Subject: [PATCH] fix(accounts): route CN provider anthropic-protocol tests to the native endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CN-provider accounts explicitly configured with api_protocol=anthropic (the frontend offers per-provider presets such as GLM Anthropic) fell through the account-test router to the generic Claude tester, which: - appended ?beta=true, which the native passthrough deliberately avoids for third-party Anthropic endpoints, and - defaulted a missing base_url to https://api.anthropic.com — sending the provider's API key to Anthropic instead of the provider's own Anthropic-compatible endpoint (guaranteed 401 plus cross-provider credential exposure). Route them to a dedicated probe that mirrors the adaptive Anthropic check: resolve the endpoint via GetAnthropicProtocolBaseURL (same resolution as real /v1/messages forwarding, including per-platform defaults), use the shared API-key auth header, and mark 401/403 as the account error state. Also fail fast with an actionable hint when the anthropic-protocol base_url still points at an OpenAI-compatible endpoint (paas path, version segment, or chat/completions suffix): the naive {base}/v1/messages join would 404 (e.g. /api/paas/v4/v1/messages) without any pointer to the actual misconfiguration. --- .../internal/service/account_test_service.go | 2 + .../account_test_service_cn_adaptive.go | 104 ++++++++++++++++++ .../account_test_service_cn_adaptive_test.go | 88 +++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 68c59f25af..49783b4992 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -291,6 +291,8 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.testCNProviderAdaptiveConnection(c, account, modelID, prompt) case APIProtocolChatCompletions: return s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt) + case APIProtocolAnthropic: + return s.testCNProviderAnthropicConnection(c, account, modelID) } } diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index ae52e4f117..8811fd840f 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -204,3 +205,106 @@ func (s *AccountTestService) doCNProviderAdaptiveRequest(req *http.Request, acco } return s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) } + +// testCNProviderAnthropicConnection verifies the native Anthropic endpoint of a +// CN-provider account explicitly configured with api_protocol=anthropic. Before +// this path existed such accounts fell through to the generic Claude tester,// which (a) appended ?beta=true and (b) defaulted a missing base_url to +// https://api.anthropic.com — sending the provider's API key to Anthropic +// instead of the provider's own Anthropic-compatible endpoint. The probe uses +// GetAnthropicProtocolBaseURL (same resolution as real /v1/messages forwarding, +// including per-platform defaults) and the shared API-key auth header. +func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, account *Account, modelID string) error { + ctx := c.Request.Context() + + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = claude.DefaultTestModel + } + testModelID = account.GetMappedModel(testModelID) + + authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if authToken == "" { + return s.sendErrorAndEnd(c, "No API key available") + } + + baseURL, err := s.validateUpstreamBaseURL(account.GetAnthropicProtocolBaseURL()) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Anthropic base URL: %s", err.Error())) + } + if hint := cnAnthropicBaseURLMisconfigHint(baseURL); hint != "" { + return s.sendErrorAndEnd(c, hint) + } + apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages" + + 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, err := createTestPayload(testModelID) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Anthropic test payload") + } + payloadBytes, _ := json.Marshal(payload) + + s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return s.sendErrorAndEnd(c, "Failed to create Anthropic test 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) + } + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + account.ApplyHeaderOverrides(req.Header) + + resp, err := s.doCNProviderAdaptiveRequest(req, account) + if err != nil { + return s.sendErrorAndEnd(c, fmt.Sprintf("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("Anthropic endpoint returned %d: %s", resp.StatusCode, string(body)) + if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && s.accountRepo != nil { + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } + return s.sendErrorAndEnd(c, errMsg) + } + + return s.processClaudeStream(c, resp.Body) +} + +// cnAnthropicBaseURLMisconfigHint reports an actionable error when an +// anthropic-protocol account's base_url still points at an OpenAI-compatible +// endpoint (paas path, version segment, or chat/completions / responses +// suffix). The naive {base}/v1/messages join would 404 (e.g. +// .../api/paas/v4/v1/messages) with no hint about the actual misconfiguration. +func cnAnthropicBaseURLMisconfigHint(baseURL string) string { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + path := strings.ToLower(strings.TrimRight(parsed.Path, "/")) + if path == "" { + return "" + } + openAICompatShaped := strings.Contains(path, "/paas/") || + strings.HasSuffix(path, "/chat/completions") || + strings.HasSuffix(path, "/responses") || + openAIBaseURLHasVersionSuffix(path) + if !openAICompatShaped { + return "" + } + return fmt.Sprintf( + "API protocol is anthropic but base_url (%s) looks like an OpenAI-compatible endpoint; "+ + "requests would hit {base}/v1/messages and 404. Set base_url to the provider's Anthropic endpoint "+ + "(e.g. https://open.bigmodel.cn/api/anthropic) or switch api_protocol to chat_completions/adaptive.", + baseURL, + ) +} diff --git a/backend/internal/service/account_test_service_cn_adaptive_test.go b/backend/internal/service/account_test_service_cn_adaptive_test.go index 808aec33b4..410cc26261 100644 --- a/backend/internal/service/account_test_service_cn_adaptive_test.go +++ b/backend/internal/service/account_test_service_cn_adaptive_test.go @@ -189,3 +189,91 @@ func TestAccountTestService_FixedCNChatProtocolStillTestsOnlyChatEndpoint(t *tes 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"`)) } + +func anthropicProtocolCNAccount(id int64, platform string, credentials map[string]any) *Account { + base := map[string]any{ + "api_key": "sk-anthropic-test", + "api_protocol": APIProtocolAnthropic, + } + for key, value := range credentials { + base[key] = value + } + return &Account{ + ID: id, + Name: "anthropic-protocol-cn-test", + Platform: platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: base, + } +} + +func TestAccountTestService_AnthropicProtocolProbesNativeEndpointWithoutBetaQuery(t *testing.T) { + account := anthropicProtocolCNAccount(311, PlatformZhipu, map[string]any{ + "base_url": "https://open.bigmodel.cn/api/anthropic", + }) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNAnthropicTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + req := upstream.requests[0] + // Native Anthropic path without the ?beta=true suffix the generic Claude tester appends. + require.Equal(t, "https://open.bigmodel.cn/api/anthropic/v1/messages", req.URL.String()) + require.Empty(t, req.URL.RawQuery) + require.Equal(t, "sk-anthropic-test", req.Header.Get("x-api-key")) + require.Equal(t, "2023-06-01", req.Header.Get("anthropic-version")) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_AnthropicProtocolFallsBackToProviderDefaultNotAnthropicDotCom(t *testing.T) { + // base_url intentionally absent: forwarding resolves the per-platform default + // Anthropic endpoint. The old fall-through probed https://api.anthropic.com + // with the provider's API key. + account := anthropicProtocolCNAccount(312, PlatformZhipu, nil) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNAnthropicTestResponse()) + c, _ := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://open.bigmodel.cn/api/anthropic/v1/messages", upstream.requests[0].URL.String()) +} + +func TestAccountTestService_AnthropicProtocolRejectsOpenAICompatBaseURL(t *testing.T) { + account := anthropicProtocolCNAccount(313, PlatformZhipu, map[string]any{ + "base_url": "https://open.bigmodel.cn/api/paas/v4", + }) + svc, upstream := adaptiveCNAccountTestService(account) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault) + + require.Error(t, err) + // Fails fast locally: no upstream request with the wrong endpoint shape. + require.Empty(t, upstream.requests) + require.Contains(t, recorder.Body.String(), "looks like an OpenAI-compatible endpoint") + require.Contains(t, recorder.Body.String(), "https://open.bigmodel.cn/api/anthropic") +} + +func TestAccountTestService_AnthropicProtocol401MarksAccountError(t *testing.T) { + account := anthropicProtocolCNAccount(314, PlatformKimi, map[string]any{ + "base_url": "https://api.moonshot.cn/anthropic", + }) + svc, _ := adaptiveCNAccountTestService( + account, + newJSONResponse(http.StatusUnauthorized, `{"error":{"message":"invalid key"}}`), + ) + c, _ := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "kimi-k2.5", "", AccountTestModeDefault) + + require.Error(t, err) + require.Contains(t, err.Error(), "Anthropic endpoint returned 401") + repo := svc.accountRepo.(*openAIAccountTestRepo) + require.Equal(t, account.ID, repo.setErrorID) +}