composite_route_resolver_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. "github.com/stretchr/testify/require"
  7. )
  8. type compositeRouteRepoStub struct {
  9. routes []CompositeModelRoute
  10. }
  11. func (s compositeRouteRepoStub) ListByGroup(ctx context.Context, groupID int64, includeDisabled bool) ([]CompositeModelRoute, error) {
  12. routes := make([]CompositeModelRoute, 0, len(s.routes))
  13. for _, route := range s.routes {
  14. if route.GroupID != groupID {
  15. continue
  16. }
  17. if !includeDisabled && !route.Enabled {
  18. continue
  19. }
  20. routes = append(routes, route)
  21. }
  22. return routes, nil
  23. }
  24. func (s compositeRouteRepoStub) Create(ctx context.Context, route *CompositeModelRoute) error {
  25. return nil
  26. }
  27. func (s compositeRouteRepoStub) Update(ctx context.Context, route *CompositeModelRoute) error {
  28. return nil
  29. }
  30. func (s compositeRouteRepoStub) Delete(ctx context.Context, id int64) error {
  31. return nil
  32. }
  33. func (s compositeRouteRepoStub) DeleteByGroup(ctx context.Context, groupID int64) error {
  34. return nil
  35. }
  36. func TestCompositeRouteResolverExplicitExactRouteRewritesModel(t *testing.T) {
  37. resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
  38. routes: []CompositeModelRoute{
  39. {
  40. ID: 10,
  41. GroupID: 7,
  42. PublicModel: "openrouter/gpt-5",
  43. MatchType: CompositeRouteMatchExact,
  44. TargetPlatform: PlatformOpenAI,
  45. UpstreamModel: "gpt-5",
  46. Endpoint: CompositeRouteEndpointAny,
  47. Priority: 100,
  48. Enabled: true,
  49. },
  50. },
  51. })
  52. decision, err := resolver.Resolve(context.Background(), 7, "openrouter/gpt-5", CompositeRouteEndpointChatCompletions)
  53. require.NoError(t, err)
  54. require.True(t, decision.Matched)
  55. require.Equal(t, CompositeRouteSourceExplicit, decision.Source)
  56. require.Equal(t, PlatformOpenAI, decision.TargetPlatform)
  57. require.Equal(t, "gpt-5", decision.UpstreamModel)
  58. require.NotNil(t, decision.Route)
  59. require.Equal(t, int64(10), decision.Route.ID)
  60. }
  61. // Scenario: 唯一平台的精确别名可路由
  62. func TestCompositeRouteResolverUsesAccountModelOwnershipForUnprefixedAlias(t *testing.T) {
  63. resolver := NewCompositeRouteResolver(nil)
  64. resolver.SetModelOwnershipResolver(func(_ context.Context, groupID int64, model string) (CompositeModelOwnership, error) {
  65. require.Equal(t, int64(7), groupID)
  66. require.Equal(t, "reasoning-alias", model)
  67. return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil
  68. })
  69. decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions)
  70. require.NoError(t, err)
  71. require.True(t, decision.Matched)
  72. require.Equal(t, CompositeRouteSourceAccount, decision.Source)
  73. require.Equal(t, PlatformDeepseek, decision.TargetPlatform)
  74. require.Equal(t, "reasoning-alias", decision.UpstreamModel)
  75. }
  76. func TestCompositeRouteResolverAccountOwnershipOverridesBuiltInDetector(t *testing.T) {
  77. resolver := NewCompositeRouteResolver(nil)
  78. resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
  79. return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil
  80. })
  81. decision, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses)
  82. require.NoError(t, err)
  83. require.True(t, decision.Matched)
  84. require.Equal(t, CompositeRouteSourceAccount, decision.Source)
  85. require.Equal(t, PlatformDeepseek, decision.TargetPlatform)
  86. }
  87. // Scenario: 显式路由保持最高优先级
  88. func TestCompositeRouteResolverExplicitRouteBeatsAccountOwnership(t *testing.T) {
  89. resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
  90. routes: []CompositeModelRoute{{
  91. ID: 10,
  92. GroupID: 7,
  93. PublicModel: "reasoning-alias",
  94. MatchType: CompositeRouteMatchExact,
  95. TargetPlatform: PlatformOpenAI,
  96. UpstreamModel: "gpt-5",
  97. Endpoint: CompositeRouteEndpointAny,
  98. Enabled: true,
  99. }},
  100. })
  101. resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
  102. return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil
  103. })
  104. decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions)
  105. require.NoError(t, err)
  106. require.True(t, decision.Matched)
  107. require.Equal(t, CompositeRouteSourceExplicit, decision.Source)
  108. require.Equal(t, PlatformOpenAI, decision.TargetPlatform)
  109. require.Equal(t, "gpt-5", decision.UpstreamModel)
  110. }
  111. // Scenario: 跨平台同名别名不被猜测
  112. func TestCompositeRouteResolverDoesNotGuessAmbiguousAccountOwnership(t *testing.T) {
  113. resolver := NewCompositeRouteResolver(nil)
  114. resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
  115. return CompositeModelOwnership{Ambiguous: true}, nil
  116. })
  117. decision, err := resolver.Resolve(context.Background(), 7, "shared-alias", CompositeRouteEndpointChatCompletions)
  118. require.NoError(t, err)
  119. require.False(t, decision.Matched)
  120. require.Empty(t, decision.TargetPlatform)
  121. require.Equal(t, "model is exposed by multiple provider platforms", decision.Reason)
  122. }
  123. func TestCompositeRouteResolverOwnershipLookupErrorFallsBackOnlyForDetectableModels(t *testing.T) {
  124. lookupErr := errors.New("account catalog unavailable")
  125. resolver := NewCompositeRouteResolver(nil)
  126. resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
  127. return CompositeModelOwnership{}, lookupErr
  128. })
  129. detected, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses)
  130. require.NoError(t, err)
  131. require.True(t, detected.Matched)
  132. require.Equal(t, CompositeRouteSourceDetector, detected.Source)
  133. require.Equal(t, PlatformOpenAI, detected.TargetPlatform)
  134. unknown, err := resolver.Resolve(context.Background(), 7, "company-model", CompositeRouteEndpointResponses)
  135. require.ErrorIs(t, err, lookupErr)
  136. require.False(t, unknown.Matched)
  137. }
  138. func TestCompositeRouteResolverPrefersEndpointSpecificLongestPrefix(t *testing.T) {
  139. resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
  140. routes: []CompositeModelRoute{
  141. {
  142. ID: 1,
  143. GroupID: 7,
  144. PublicModel: "router/",
  145. MatchType: CompositeRouteMatchPrefix,
  146. TargetPlatform: PlatformAnthropic,
  147. Endpoint: CompositeRouteEndpointAny,
  148. Priority: 10,
  149. Enabled: true,
  150. },
  151. {
  152. ID: 2,
  153. GroupID: 7,
  154. PublicModel: "router/gpt-",
  155. MatchType: CompositeRouteMatchPrefix,
  156. TargetPlatform: PlatformOpenAI,
  157. UpstreamModel: "gpt-family",
  158. Endpoint: CompositeRouteEndpointResponses,
  159. Priority: 100,
  160. Enabled: true,
  161. },
  162. },
  163. })
  164. decision, err := resolver.Resolve(context.Background(), 7, "router/gpt-5", CompositeRouteEndpointResponses)
  165. require.NoError(t, err)
  166. require.True(t, decision.Matched)
  167. require.Equal(t, CompositeRouteSourceExplicit, decision.Source)
  168. require.Equal(t, PlatformOpenAI, decision.TargetPlatform)
  169. require.Equal(t, "gpt-family", decision.UpstreamModel)
  170. require.NotNil(t, decision.Route)
  171. require.Equal(t, int64(2), decision.Route.ID)
  172. }
  173. // TestCompositeRouteResolverPrefixEmptyUpstreamPassesThroughRequestedModel 验证:
  174. // 前缀匹配路由留空 upstream_model 时,转发的是具体请求模型(各自原样),而不是
  175. // 塌缩成 public_model。这是「留空 = 透传原始模型」语义的核心场景。
  176. func TestCompositeRouteResolverPrefixEmptyUpstreamPassesThroughRequestedModel(t *testing.T) {
  177. resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
  178. routes: []CompositeModelRoute{
  179. {
  180. ID: 1,
  181. GroupID: 7,
  182. PublicModel: "deepseek-v4",
  183. MatchType: CompositeRouteMatchPrefix,
  184. TargetPlatform: PlatformOpenAI,
  185. UpstreamModel: "", // 留空 = 透传
  186. Endpoint: CompositeRouteEndpointAny,
  187. Priority: 100,
  188. Enabled: true,
  189. },
  190. },
  191. })
  192. for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4"} {
  193. decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointChatCompletions)
  194. require.NoError(t, err)
  195. require.True(t, decision.Matched, "model %q should match prefix route", model)
  196. require.Equal(t, CompositeRouteSourceExplicit, decision.Source)
  197. require.Equal(t, PlatformOpenAI, decision.TargetPlatform)
  198. require.Equal(t, model, decision.UpstreamModel, "model %q should pass through verbatim", model)
  199. }
  200. }
  201. // TestCompositeRouteResolverPrefixExplicitUpstreamStillFixed 验证:前缀匹配路由显式
  202. // 填写 upstream_model 时,所有命中请求仍转发同一个固定上游模型(行为不变)。
  203. func TestCompositeRouteResolverPrefixExplicitUpstreamStillFixed(t *testing.T) {
  204. resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
  205. routes: []CompositeModelRoute{
  206. {
  207. ID: 1,
  208. GroupID: 7,
  209. PublicModel: "deepseek-v4",
  210. MatchType: CompositeRouteMatchPrefix,
  211. TargetPlatform: PlatformOpenAI,
  212. UpstreamModel: "deepseek-chat",
  213. Endpoint: CompositeRouteEndpointAny,
  214. Priority: 100,
  215. Enabled: true,
  216. },
  217. },
  218. })
  219. for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} {
  220. decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointChatCompletions)
  221. require.NoError(t, err)
  222. require.True(t, decision.Matched)
  223. require.Equal(t, "deepseek-chat", decision.UpstreamModel)
  224. }
  225. }
  226. func TestCompositeRouteResolverIgnoresDisabledRoutesAndFallsBackToDetector(t *testing.T) {
  227. resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
  228. routes: []CompositeModelRoute{
  229. {
  230. ID: 1,
  231. GroupID: 7,
  232. PublicModel: "gpt-5",
  233. MatchType: CompositeRouteMatchExact,
  234. TargetPlatform: PlatformAnthropic,
  235. UpstreamModel: "claude-sonnet-4-6",
  236. Endpoint: CompositeRouteEndpointAny,
  237. Priority: 100,
  238. Enabled: false,
  239. },
  240. },
  241. })
  242. decision, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointAny)
  243. require.NoError(t, err)
  244. require.True(t, decision.Matched)
  245. require.Equal(t, CompositeRouteSourceDetector, decision.Source)
  246. require.Equal(t, PlatformOpenAI, decision.TargetPlatform)
  247. require.Equal(t, "gpt-5", decision.UpstreamModel)
  248. require.Nil(t, decision.Route)
  249. }
  250. func TestCompositeRouteResolverDetectsKimiCodeBareModels(t *testing.T) {
  251. resolver := NewCompositeRouteResolver(nil)
  252. for _, model := range []string{"k3", "k3-256k", "kimi-code/k3"} {
  253. t.Run(model, func(t *testing.T) {
  254. decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointMessages)
  255. require.NoError(t, err)
  256. require.True(t, decision.Matched)
  257. require.Equal(t, CompositeRouteSourceDetector, decision.Source)
  258. require.Equal(t, PlatformKimi, decision.TargetPlatform)
  259. require.Equal(t, model, decision.UpstreamModel)
  260. })
  261. }
  262. }
  263. func TestCompositeRouteResolverExplicitRoutesCoverBucketTwoProviders(t *testing.T) {
  264. resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
  265. routes: []CompositeModelRoute{
  266. {
  267. ID: 1,
  268. GroupID: 7,
  269. PublicModel: "all/gpt-5",
  270. MatchType: CompositeRouteMatchExact,
  271. TargetPlatform: PlatformOpenAI,
  272. UpstreamModel: "gpt-5",
  273. Endpoint: CompositeRouteEndpointResponses,
  274. Priority: 100,
  275. Enabled: true,
  276. },
  277. {
  278. ID: 2,
  279. GroupID: 7,
  280. PublicModel: "all/claude-sonnet",
  281. MatchType: CompositeRouteMatchExact,
  282. TargetPlatform: PlatformAnthropic,
  283. UpstreamModel: "claude-sonnet-4-6",
  284. Endpoint: CompositeRouteEndpointMessages,
  285. Priority: 100,
  286. Enabled: true,
  287. },
  288. {
  289. ID: 3,
  290. GroupID: 7,
  291. PublicModel: "all/gemini-pro",
  292. MatchType: CompositeRouteMatchExact,
  293. TargetPlatform: PlatformGemini,
  294. UpstreamModel: "gemini-2.5-pro",
  295. Endpoint: CompositeRouteEndpointGemini,
  296. Priority: 100,
  297. Enabled: true,
  298. },
  299. {
  300. ID: 4,
  301. GroupID: 7,
  302. PublicModel: "all/grok",
  303. MatchType: CompositeRouteMatchExact,
  304. TargetPlatform: PlatformGrok,
  305. UpstreamModel: "grok-4.3",
  306. Endpoint: CompositeRouteEndpointResponses,
  307. Priority: 100,
  308. Enabled: true,
  309. },
  310. },
  311. })
  312. tests := []struct {
  313. model string
  314. endpoint string
  315. wantPlatform string
  316. wantUpstream string
  317. }{
  318. {"all/gpt-5", CompositeRouteEndpointResponses, PlatformOpenAI, "gpt-5"},
  319. {"all/claude-sonnet", CompositeRouteEndpointMessages, PlatformAnthropic, "claude-sonnet-4-6"},
  320. {"all/gemini-pro", CompositeRouteEndpointGemini, PlatformGemini, "gemini-2.5-pro"},
  321. {"all/grok", CompositeRouteEndpointResponses, PlatformGrok, "grok-4.3"},
  322. }
  323. for _, tt := range tests {
  324. t.Run(tt.model, func(t *testing.T) {
  325. decision, err := resolver.Resolve(context.Background(), 7, tt.model, tt.endpoint)
  326. require.NoError(t, err)
  327. require.True(t, decision.Matched)
  328. require.Equal(t, CompositeRouteSourceExplicit, decision.Source)
  329. require.Equal(t, tt.wantPlatform, decision.TargetPlatform)
  330. require.Equal(t, tt.wantUpstream, decision.UpstreamModel)
  331. })
  332. }
  333. }