From 867563ae76f3ff99ed20dfed93ee216a7939ce3f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 14 Oct 2026 14:58:26 +0800 Subject: [PATCH] fix(gateway): honor Anthropic protocol base URLs --- .../gateway_anthropic_base_url_test.go | 106 ++++++++++++++++++ .../service/gateway_anthropic_passthrough.go | 8 +- .../internal/service/gateway_count_tokens.go | 9 +- .../service/gateway_upstream_request.go | 19 +++- 4 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/gateway_anthropic_base_url_test.go diff --git a/backend/internal/service/gateway_anthropic_base_url_test.go b/backend/internal/service/gateway_anthropic_base_url_test.go new file mode 100644 index 0000000000..490c01cc82 --- /dev/null +++ b/backend/internal/service/gateway_anthropic_base_url_test.go @@ -0,0 +1,106 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func gatewayAnthropicBaseURLAccount(apiBaseURLs map[string]any) *Account { + return &Account{ + ID: 8074, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "base_url": "https://api.deepseek.com", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": apiBaseURLs, + }, + } +} + +func gatewayAnthropicBaseURLContext(t *testing.T) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + return c +} + +func TestGatewayService_AdaptiveAnthropicBaseURLRouting(t *testing.T) { + svc := &GatewayService{cfg: &config.Config{}} + body := []byte(`{"model":"deepseek-chat","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + + t.Run("base present is used by messages and count_tokens", func(t *testing.T) { + account := gatewayAnthropicBaseURLAccount(map[string]any{ + APIProtocolAnthropic: "https://anthropic.example.com", + APIProtocolChatCompletions: "https://chat.example.com", + }) + c := gatewayAnthropicBaseURLContext(t) + + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-chat", false, false) + require.NoError(t, err) + require.Equal(t, "https://anthropic.example.com/v1/messages?beta=true", req.URL.String()) + + countReq, _, err := svc.buildCountTokensRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-chat", false) + require.NoError(t, err) + require.Equal(t, "https://anthropic.example.com/v1/messages/count_tokens?beta=true", countReq.URL.String()) + }) + + t.Run("missing anthropic base uses provider fallback", func(t *testing.T) { + account := gatewayAnthropicBaseURLAccount(map[string]any{ + APIProtocolChatCompletions: "https://chat.example.com", + }) + req, _, err := svc.buildUpstreamRequest(context.Background(), gatewayAnthropicBaseURLContext(t), account, body, "test-key", "apikey", "deepseek-chat", false, false) + require.NoError(t, err) + require.Equal(t, "https://api.deepseek.com/anthropic/v1/messages?beta=true", req.URL.String()) + }) + + t.Run("ordinary anthropic api key keeps base_url fallback", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "base_url": "https://custom-anthropic.example.com", + }, + } + req, _, err := svc.buildUpstreamRequest(context.Background(), gatewayAnthropicBaseURLContext(t), account, body, "test-key", "apikey", "deepseek-chat", false, false) + require.NoError(t, err) + require.Equal(t, "https://custom-anthropic.example.com/v1/messages?beta=true", req.URL.String()) + }) +} + +func TestGatewayService_AnthropicPassthroughAndCountTokensUseSelectedBase(t *testing.T) { + svc := &GatewayService{cfg: &config.Config{}} + account := gatewayAnthropicBaseURLAccount(map[string]any{ + APIProtocolAnthropic: "https://ollama.com", + }) + body := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"messages":[{"role":"user","content":"hi"}]}`) + c := gatewayAnthropicBaseURLContext(t) + + passthroughReq, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "test-key") + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", passthroughReq.URL.String()) + require.Equal(t, "Bearer test-key", passthroughReq.Header.Get("Authorization")) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + + countPassthroughReq, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "test-key") + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countPassthroughReq.URL.String()) + require.Equal(t, "Bearer test-key", countPassthroughReq.Header.Get("Authorization")) + + countReq, _, err := svc.buildCountTokensRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-v4-flash", false) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countReq.URL.String()) + require.Equal(t, "Bearer test-key", countReq.Header.Get("Authorization")) +} diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index b998f69eeb..889e2faa36 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -301,7 +301,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( ) (*http.Request, []byte, error) { body = stripDeferredToolCacheControl(body) targetURL := claudeAPIURL - baseURL := account.GetBaseURL() + baseURL := getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -326,8 +326,8 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( } // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 - // base 取值同源(GetBaseURL),详见 helper 注释。 - body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + // base 取值同源,详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, baseURL, body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { @@ -354,7 +354,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( req.Header.Del("cookie") // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) if getHeaderRaw(req.Header, "content-type") == "" { setHeaderRaw(req.Header, "content-type", "application/json") diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index ec3c2bad0e..b07feb7071 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -399,7 +399,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( ) (*http.Request, error) { body = stripDeferredToolCacheControl(body) targetURL := claudeAPICountTokensURL - baseURL := account.GetBaseURL() + baseURL := getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -446,7 +446,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( req.Header.Del("cookie") // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") @@ -467,8 +467,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con body = stripDeferredToolCacheControl(body) // 确定目标 URL targetURL := claudeAPICountTokensURL + baseURL := "" if account.Type == AccountTypeAPIKey { - baseURL := account.GetBaseURL() + baseURL = getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -558,7 +559,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } else { // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) } // 白名单透传 headers(恢复真实 wire casing) diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index cdf2319ebb..d5f08f9a85 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -27,8 +27,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // 确定目标URL targetURL := claudeAPIURL + baseURL := "" if account.Type == AccountTypeAPIKey { - baseURL := account.GetBaseURL() + baseURL = getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -124,9 +125,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 - // base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型 + // base 取值同源,仅实际上游为 ollama.com 且映射后出站模型 // 为 DeepSeek 系时压到 cap,详见 helper 注释。 - body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, baseURL, body) req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { @@ -139,7 +140,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } else { // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) } // 白名单透传 headers @@ -225,6 +226,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex return req, body, nil } +func getAnthropicGatewayBaseURL(account *Account) string { + if account == nil { + return "" + } + if baseURL := account.GetAnthropicProtocolBaseURL(); baseURL != "" { + return baseURL + } + return account.GetBaseURL() +} + // vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta // 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的 // bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未