fix(gateway): honor Anthropic protocol base URLs

This commit is contained in:
wucm667
2026-10-14 14:58:26 +08:00
parent 9c2cca9e62
commit 867563ae76
4 changed files with 130 additions and 12 deletions
@@ -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"))
}
@@ -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")
@@ -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)
@@ -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 尚未