Merge pull request #6011 from HypoxanthineOvO/fix/cn-anthropic-protocol-account-test

fix(accounts): route CN provider anthropic-protocol tests to the native endpoint
This commit is contained in:
Wesley Liddick
2026-08-21 21:24:38 +08:00
committed by GitHub
3 changed files with 194 additions and 0 deletions
@@ -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)
}
}
@@ -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,
)
}
@@ -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)
}