mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:18:29 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user