| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- 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)
- }
|