composite_platform_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. package handler
  2. import (
  3. "net/http/httptest"
  4. "testing"
  5. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  6. "github.com/Wei-Shaw/sub2api/internal/service"
  7. "github.com/gin-gonic/gin"
  8. "github.com/stretchr/testify/require"
  9. "github.com/tidwall/gjson"
  10. )
  11. func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
  12. gin.SetMode(gin.TestMode)
  13. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  14. c.Request = httptest.NewRequest("POST", "/v1/embeddings", nil)
  15. apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
  16. require.True(t, compositeTargetPlatformAllowed(c, apiKey, "text-embedding-3-large", service.PlatformOpenAI))
  17. platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
  18. require.True(t, ok)
  19. require.Equal(t, service.PlatformOpenAI, platform)
  20. }
  21. func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
  22. gin.SetMode(gin.TestMode)
  23. providers := []struct {
  24. model string
  25. platform string
  26. }{
  27. {model: "grok-4.3", platform: service.PlatformGrok},
  28. {model: "kimi-k2-thinking", platform: service.PlatformKimi},
  29. {model: "k3", platform: service.PlatformKimi},
  30. {model: "glm-5.2", platform: service.PlatformZhipu},
  31. {model: "deepseek-v3.2", platform: service.PlatformDeepseek},
  32. {model: "MiniMax-M3", platform: service.PlatformMiniMax},
  33. }
  34. for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
  35. for _, provider := range providers {
  36. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  37. c.Request = httptest.NewRequest("POST", path, nil)
  38. apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
  39. require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, provider.model), "path=%s model=%s", path, provider.model)
  40. platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
  41. require.True(t, ok, "path=%s model=%s", path, provider.model)
  42. require.Equal(t, provider.platform, platform, "path=%s model=%s", path, provider.model)
  43. }
  44. }
  45. }
  46. // WS ingress 对 CN 账号既过不了 transport 过滤、HTTP 桥也没有 Responses 转换,
  47. // 放行只会把明确的策略拒绝换成 "no available account",因此 WS 白名单保持 openai+grok。
  48. func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testing.T) {
  49. require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
  50. require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
  51. for _, platform := range []string{
  52. service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
  53. service.PlatformAnthropic, service.PlatformGemini,
  54. } {
  55. require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
  56. }
  57. }
  58. func TestCompositeTargetPlatformAllowedRejectsWrongOrUnknownModel(t *testing.T) {
  59. gin.SetMode(gin.TestMode)
  60. for _, tc := range []struct {
  61. name string
  62. model string
  63. }{
  64. {name: "wrong provider", model: "claude-sonnet-4-5"},
  65. {name: "unknown provider", model: "llama-4-maverick"},
  66. } {
  67. t.Run(tc.name, func(t *testing.T) {
  68. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  69. c.Request = httptest.NewRequest("POST", "/v1/embeddings", nil)
  70. apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
  71. require.False(t, compositeTargetPlatformAllowed(c, apiKey, tc.model, service.PlatformOpenAI))
  72. })
  73. }
  74. }
  75. func TestCompositeTargetPlatformResolvedRejectsUnknownModel(t *testing.T) {
  76. gin.SetMode(gin.TestMode)
  77. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  78. c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
  79. apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
  80. require.False(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick"))
  81. _, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
  82. require.False(t, ok)
  83. }
  84. func TestCompositeTargetPlatformResolvedAllowsConcreteGroupWithoutResolution(t *testing.T) {
  85. gin.SetMode(gin.TestMode)
  86. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  87. c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
  88. apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformAnthropic}}
  89. require.True(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick"))
  90. }
  91. func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
  92. gin.SetMode(gin.TestMode)
  93. group := &service.Group{
  94. Platform: service.PlatformComposite,
  95. MaxReasoningEffort: "medium",
  96. ReasoningEffortMappings: []service.ReasoningEffortMapping{
  97. {From: "max", To: "xhigh"},
  98. },
  99. }
  100. apiKey := &service.APIKey{Group: group}
  101. body := []byte(`{"reasoning":{"effort":"max"}}`)
  102. openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
  103. openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
  104. openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
  105. got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
  106. require.NoError(t, err)
  107. require.True(t, changed)
  108. require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
  109. requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
  110. require.NotNil(t, requested)
  111. require.Equal(t, "max", *requested)
  112. bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
  113. bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
  114. require.NoError(t, err)
  115. require.True(t, changed)
  116. require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
  117. omittedCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
  118. omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
  119. omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
  120. bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
  121. omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
  122. require.NoError(t, err)
  123. require.False(t, changed)
  124. require.Equal(t, body, omitted)
  125. denyGroup := *group
  126. denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
  127. denyAPIKey := &service.APIKey{Group: &denyGroup}
  128. denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
  129. denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
  130. denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI))
  131. _, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body)
  132. require.Error(t, err)
  133. var overLimit *service.ReasoningEffortOverLimitError
  134. require.ErrorAs(t, err, &overLimit)
  135. mappingDenyGroup := *group
  136. mappingDenyGroup.MaxReasoningEffort = ""
  137. mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{
  138. {From: "max", To: service.ReasoningEffortMappingDeny},
  139. }
  140. mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup}
  141. mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
  142. mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
  143. mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI))
  144. _, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body)
  145. require.Error(t, err)
  146. require.False(t, changed)
  147. var mappingDenied *service.ReasoningEffortMappingDeniedError
  148. require.ErrorAs(t, err, &mappingDenied)
  149. require.Equal(t, "max", mappingDenied.Requested)
  150. require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
  151. grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
  152. grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
  153. grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
  154. got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
  155. require.NoError(t, err)
  156. require.False(t, changed)
  157. require.Equal(t, body, got)
  158. }
  159. func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) {
  160. gin.SetMode(gin.TestMode)
  161. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  162. c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
  163. apiKey := &service.APIKey{Group: &service.Group{
  164. Platform: service.PlatformAnthropic,
  165. MaxReasoningEffort: "xhigh",
  166. }}
  167. got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`))
  168. require.NoError(t, err)
  169. require.True(t, changed)
  170. require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String())
  171. }
  172. func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) {
  173. gin.SetMode(gin.TestMode)
  174. body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`)
  175. policy := func(platform string) *service.APIKey {
  176. return &service.APIKey{Group: &service.Group{
  177. Platform: platform,
  178. MaxReasoningEffort: "xhigh",
  179. ReasoningEffortMappings: []service.ReasoningEffortMapping{
  180. {From: "max", To: "xhigh"},
  181. },
  182. }}
  183. }
  184. concrete, _ := gin.CreateTestContext(httptest.NewRecorder())
  185. concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil)
  186. got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body)
  187. require.NoError(t, err)
  188. require.True(t, changed)
  189. require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got))
  190. denyMax := policy(service.PlatformAnthropic)
  191. denyMax.Group.ReasoningEffortMappings = nil
  192. denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
  193. _, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body)
  194. require.Error(t, err)
  195. require.False(t, changed)
  196. var overLimit *service.ReasoningEffortOverLimitError
  197. require.ErrorAs(t, err, &overLimit)
  198. require.Equal(t, "max", overLimit.Requested)
  199. require.Equal(t, "xhigh", overLimit.Max)
  200. composite, _ := gin.CreateTestContext(httptest.NewRecorder())
  201. composite.Request = httptest.NewRequest("POST", "/v1/messages", nil)
  202. composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic))
  203. got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body)
  204. require.NoError(t, err)
  205. require.True(t, changed)
  206. require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String())
  207. openAI, _ := gin.CreateTestContext(httptest.NewRecorder())
  208. openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil)
  209. openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI))
  210. got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body)
  211. require.NoError(t, err)
  212. require.False(t, changed)
  213. require.Equal(t, body, got)
  214. }
  215. func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) {
  216. maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}})
  217. require.Equal(t, "low", maxEffort)
  218. require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings)
  219. }
  220. func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
  221. gin.SetMode(gin.TestMode)
  222. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  223. c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
  224. c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), service.CompositeRouteDecision{
  225. Matched: true,
  226. Source: service.CompositeRouteSourceExplicit,
  227. PublicModel: "public-alias",
  228. TargetPlatform: service.PlatformOpenAI,
  229. UpstreamModel: "gpt-5",
  230. }))
  231. input := buildContentModerationInput(c, nil, middleware2.AuthSubject{UserID: 42}, service.ContentModerationProtocolOpenAIChat, "gpt-5", nil)
  232. require.Equal(t, "public-alias", input.Model)
  233. require.Equal(t, service.PlatformOpenAI, input.Provider)
  234. fields := clientRequestedUsageFields(c, service.ChannelMappingResult{MappedModel: "gpt-5"}, "gpt-5", "gpt-5")
  235. require.Equal(t, "public-alias", fields.OriginalModel)
  236. require.Equal(t, "public-alias", fields.ChannelMappedModel)
  237. require.Equal(t, "public-alias\u2192gpt-5", fields.ModelMappingChain)
  238. }