mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
Merge pull request #8079 from wucm667/fix/issue-8074-anthropic-base-url
fix(gateway): honor Anthropic protocol base URLs.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -58,8 +58,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 {
|
||||
@@ -155,9 +156,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 {
|
||||
@@ -170,7 +171,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
|
||||
@@ -256,6 +257,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 尚未
|
||||
|
||||
Reference in New Issue
Block a user