Files
sub2api/backend/internal/handler/composite_platform_test.go
T
2026-09-08 18:50:06 +08:00

272 lines
12 KiB
Go

package handler
import (
"net/http/httptest"
"testing"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/embeddings", nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.True(t, compositeTargetPlatformAllowed(c, apiKey, "text-embedding-3-large", service.PlatformOpenAI))
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, service.PlatformOpenAI, platform)
}
func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
gin.SetMode(gin.TestMode)
providers := []struct {
model string
platform string
}{
{model: "grok-4.3", platform: service.PlatformGrok},
{model: "kimi-k2-thinking", platform: service.PlatformKimi},
{model: "k3", platform: service.PlatformKimi},
{model: "glm-5.2", platform: service.PlatformZhipu},
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
{model: "MiniMax-M3", platform: service.PlatformMiniMax},
}
for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
for _, provider := range providers {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", path, nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, provider.model), "path=%s model=%s", path, provider.model)
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok, "path=%s model=%s", path, provider.model)
require.Equal(t, provider.platform, platform, "path=%s model=%s", path, provider.model)
}
}
}
// WS ingress 对 CN 账号既过不了 transport 过滤、HTTP 桥也没有 Responses 转换,
// 放行只会把明确的策略拒绝换成 "no available account",因此 WS 白名单保持 openai+grok。
func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testing.T) {
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
for _, platform := range []string{
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
service.PlatformAnthropic, service.PlatformGemini,
} {
require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
}
}
func TestCompositeTargetPlatformAllowedRejectsWrongOrUnknownModel(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
model string
}{
{name: "wrong provider", model: "claude-sonnet-4-5"},
{name: "unknown provider", model: "llama-4-maverick"},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/embeddings", nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.False(t, compositeTargetPlatformAllowed(c, apiKey, tc.model, service.PlatformOpenAI))
})
}
}
func TestCompositeTargetPlatformResolvedRejectsUnknownModel(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.False(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick"))
_, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.False(t, ok)
}
func TestCompositeTargetPlatformResolvedAllowsConcreteGroupWithoutResolution(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformAnthropic}}
require.True(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick"))
}
func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
gin.SetMode(gin.TestMode)
group := &service.Group{
Platform: service.PlatformComposite,
MaxReasoningEffort: "medium",
ReasoningEffortMappings: []service.ReasoningEffortMapping{
{From: "max", To: "xhigh"},
},
}
apiKey := &service.APIKey{Group: group}
body := []byte(`{"reasoning":{"effort":"max"}}`)
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
require.NotNil(t, requested)
require.Equal(t, "max", *requested)
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
omittedCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, omitted)
denyGroup := *group
denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
denyAPIKey := &service.APIKey{Group: &denyGroup}
denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI))
_, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body)
require.Error(t, err)
var overLimit *service.ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
mappingDenyGroup := *group
mappingDenyGroup.MaxReasoningEffort = ""
mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{
{From: "max", To: service.ReasoningEffortMappingDeny},
}
mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup}
mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI))
_, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body)
require.Error(t, err)
require.False(t, changed)
var mappingDenied *service.ReasoningEffortMappingDeniedError
require.ErrorAs(t, err, &mappingDenied)
require.Equal(t, "max", mappingDenied.Requested)
require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, got)
}
func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
apiKey := &service.APIKey{Group: &service.Group{
Platform: service.PlatformAnthropic,
MaxReasoningEffort: "xhigh",
}}
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`))
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String())
}
func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`)
policy := func(platform string) *service.APIKey {
return &service.APIKey{Group: &service.Group{
Platform: platform,
MaxReasoningEffort: "xhigh",
ReasoningEffortMappings: []service.ReasoningEffortMapping{
{From: "max", To: "xhigh"},
},
}}
}
concrete, _ := gin.CreateTestContext(httptest.NewRecorder())
concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil)
got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got))
denyMax := policy(service.PlatformAnthropic)
denyMax.Group.ReasoningEffortMappings = nil
denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
_, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body)
require.Error(t, err)
require.False(t, changed)
var overLimit *service.ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
require.Equal(t, "max", overLimit.Requested)
require.Equal(t, "xhigh", overLimit.Max)
composite, _ := gin.CreateTestContext(httptest.NewRecorder())
composite.Request = httptest.NewRequest("POST", "/v1/messages", nil)
composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic))
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String())
openAI, _ := gin.CreateTestContext(httptest.NewRecorder())
openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil)
openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI))
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, got)
}
func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) {
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}})
require.Equal(t, "low", maxEffort)
require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings)
}
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), service.CompositeRouteDecision{
Matched: true,
Source: service.CompositeRouteSourceExplicit,
PublicModel: "public-alias",
TargetPlatform: service.PlatformOpenAI,
UpstreamModel: "gpt-5",
}))
input := buildContentModerationInput(c, nil, middleware2.AuthSubject{UserID: 42}, service.ContentModerationProtocolOpenAIChat, "gpt-5", nil)
require.Equal(t, "public-alias", input.Model)
require.Equal(t, service.PlatformOpenAI, input.Provider)
fields := clientRequestedUsageFields(c, service.ChannelMappingResult{MappedModel: "gpt-5"}, "gpt-5", "gpt-5")
require.Equal(t, "public-alias", fields.OriginalModel)
require.Equal(t, "public-alias", fields.ChannelMappedModel)
require.Equal(t, "public-alias\u2192gpt-5", fields.ModelMappingChain)
}