gateway_handler_warmup_intercept_unit_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. //go:build unit
  2. package handler
  3. import (
  4. "bytes"
  5. "context"
  6. "encoding/json"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  14. middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  15. "github.com/Wei-Shaw/sub2api/internal/service"
  16. "github.com/gin-gonic/gin"
  17. "github.com/stretchr/testify/require"
  18. )
  19. // 目标:严格验证“antigravity 账号通过 /v1/messages 提供 Claude 服务时”,
  20. // 当账号 credentials.intercept_warmup_requests=true 且请求为 Warmup 时,
  21. // 后端会在转发上游前直接拦截并返回 mock 响应(不依赖上游)。
  22. type fakeSchedulerCache struct {
  23. accounts []*service.Account
  24. }
  25. func (f *fakeSchedulerCache) GetSnapshot(_ context.Context, _ service.SchedulerBucket) ([]*service.Account, bool, error) {
  26. return f.accounts, true, nil
  27. }
  28. func (f *fakeSchedulerCache) CaptureBucketWriteToken(_ context.Context, bucket service.SchedulerBucket) (service.SchedulerBucketWriteToken, error) {
  29. return service.SchedulerBucketWriteToken{Bucket: bucket, Epoch: 1}, nil
  30. }
  31. func (f *fakeSchedulerCache) SetSnapshot(_ context.Context, _ service.SchedulerBucket, _ service.SchedulerBucketWriteToken, _ []service.Account) error {
  32. return nil
  33. }
  34. func (f *fakeSchedulerCache) RetireBucket(_ context.Context, _ service.SchedulerBucket) error {
  35. return nil
  36. }
  37. func (f *fakeSchedulerCache) ReopenBucket(_ context.Context, bucket service.SchedulerBucket) (service.SchedulerBucketWriteToken, error) {
  38. return service.SchedulerBucketWriteToken{Bucket: bucket, Epoch: 1}, nil
  39. }
  40. func (f *fakeSchedulerCache) TryAcquireGroupLifecycleLease(_ context.Context, _ int64, _ time.Duration) (service.SchedulerGroupLifecycleLease, bool, error) {
  41. return service.SchedulerGroupLifecycleLease{}, false, nil
  42. }
  43. func (f *fakeSchedulerCache) ReleaseGroupLifecycleLease(_ context.Context, _ service.SchedulerGroupLifecycleLease) error {
  44. return nil
  45. }
  46. func (f *fakeSchedulerCache) GetAccount(_ context.Context, id int64) (*service.Account, error) {
  47. for _, account := range f.accounts {
  48. if account != nil && account.ID == id {
  49. return account, nil
  50. }
  51. }
  52. return nil, nil
  53. }
  54. func (f *fakeSchedulerCache) SetAccount(_ context.Context, _ *service.Account) error { return nil }
  55. func (f *fakeSchedulerCache) DeleteAccount(_ context.Context, _ int64) error { return nil }
  56. func (f *fakeSchedulerCache) UpdateLastUsed(_ context.Context, _ map[int64]time.Time) error {
  57. return nil
  58. }
  59. func (f *fakeSchedulerCache) TryLockBucket(_ context.Context, _ service.SchedulerBucket, _ time.Duration) (bool, error) {
  60. return true, nil
  61. }
  62. func (f *fakeSchedulerCache) UnlockBucket(_ context.Context, _ service.SchedulerBucket) error {
  63. return nil
  64. }
  65. func (f *fakeSchedulerCache) ListBuckets(_ context.Context) ([]service.SchedulerBucket, error) {
  66. return nil, nil
  67. }
  68. func (f *fakeSchedulerCache) GetOutboxWatermark(_ context.Context) (int64, error) { return 0, nil }
  69. func (f *fakeSchedulerCache) SetOutboxWatermark(_ context.Context, _ int64) error { return nil }
  70. type fakeGroupRepo struct {
  71. group *service.Group
  72. }
  73. func (f *fakeGroupRepo) Create(context.Context, *service.Group) error { return nil }
  74. func (f *fakeGroupRepo) GetByID(context.Context, int64) (*service.Group, error) {
  75. return f.group, nil
  76. }
  77. func (f *fakeGroupRepo) GetByIDLite(context.Context, int64) (*service.Group, error) {
  78. return f.group, nil
  79. }
  80. func (f *fakeGroupRepo) Update(context.Context, *service.Group) error { return nil }
  81. func (f *fakeGroupRepo) Delete(context.Context, int64) error { return nil }
  82. func (f *fakeGroupRepo) DeleteCascade(context.Context, int64) ([]int64, error) { return nil, nil }
  83. func (f *fakeGroupRepo) List(context.Context, pagination.PaginationParams) ([]service.Group, *pagination.PaginationResult, error) {
  84. return nil, nil, nil
  85. }
  86. func (f *fakeGroupRepo) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]service.Group, *pagination.PaginationResult, error) {
  87. return nil, nil, nil
  88. }
  89. func (f *fakeGroupRepo) ListActive(context.Context) ([]service.Group, error) { return nil, nil }
  90. func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service.Group, error) {
  91. return nil, nil
  92. }
  93. func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
  94. func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) {
  95. return 0, 0, nil
  96. }
  97. func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
  98. return 0, nil
  99. }
  100. func (f *fakeGroupRepo) GetAccountIDsByGroupIDs(context.Context, []int64) ([]int64, error) {
  101. return nil, nil
  102. }
  103. func (f *fakeGroupRepo) BindAccountsToGroup(context.Context, int64, []int64) error { return nil }
  104. func (f *fakeGroupRepo) UpdateSortOrders(context.Context, []service.GroupSortOrderUpdate) error {
  105. return nil
  106. }
  107. type fakeConcurrencyCache struct{}
  108. func (f *fakeConcurrencyCache) AcquireAccountSlot(context.Context, int64, int, string) (bool, error) {
  109. return true, nil
  110. }
  111. func (f *fakeConcurrencyCache) ReleaseAccountSlot(context.Context, int64, string) error { return nil }
  112. func (f *fakeConcurrencyCache) GetAccountConcurrency(context.Context, int64) (int, error) {
  113. return 0, nil
  114. }
  115. func (f *fakeConcurrencyCache) IncrementAccountWaitCount(context.Context, int64, int) (bool, error) {
  116. return true, nil
  117. }
  118. func (f *fakeConcurrencyCache) DecrementAccountWaitCount(context.Context, int64) error { return nil }
  119. func (f *fakeConcurrencyCache) GetAccountWaitingCount(context.Context, int64) (int, error) {
  120. return 0, nil
  121. }
  122. func (f *fakeConcurrencyCache) AcquireUserSlot(context.Context, int64, int, string) (bool, error) {
  123. return true, nil
  124. }
  125. func (f *fakeConcurrencyCache) ReleaseUserSlot(context.Context, int64, string) error { return nil }
  126. func (f *fakeConcurrencyCache) GetUserConcurrency(context.Context, int64) (int, error) { return 0, nil }
  127. func (f *fakeConcurrencyCache) IncrementWaitCount(context.Context, int64, int) (bool, error) {
  128. return true, nil
  129. }
  130. func (f *fakeConcurrencyCache) DecrementWaitCount(context.Context, int64) error { return nil }
  131. func (f *fakeConcurrencyCache) GetAccountsLoadBatch(context.Context, []service.AccountWithConcurrency) (map[int64]*service.AccountLoadInfo, error) {
  132. return map[int64]*service.AccountLoadInfo{}, nil
  133. }
  134. func (f *fakeConcurrencyCache) GetUsersLoadBatch(context.Context, []service.UserWithConcurrency) (map[int64]*service.UserLoadInfo, error) {
  135. return map[int64]*service.UserLoadInfo{}, nil
  136. }
  137. func (f *fakeConcurrencyCache) GetAccountConcurrencyBatch(_ context.Context, accountIDs []int64) (map[int64]int, error) {
  138. result := make(map[int64]int, len(accountIDs))
  139. for _, id := range accountIDs {
  140. result[id] = 0
  141. }
  142. return result, nil
  143. }
  144. func (f *fakeConcurrencyCache) CleanupExpiredAccountSlots(context.Context, int64) error { return nil }
  145. func (f *fakeConcurrencyCache) CleanupExpiredAccountSlotKeys(context.Context) error { return nil }
  146. func (f *fakeConcurrencyCache) CleanupStaleProcessSlots(context.Context, string) error { return nil }
  147. func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*service.Account) (*GatewayHandler, func()) {
  148. t.Helper()
  149. schedulerCache := &fakeSchedulerCache{accounts: accounts}
  150. schedulerSnapshot := service.NewSchedulerSnapshotService(schedulerCache, nil, nil, nil, nil)
  151. gwSvc := service.NewGatewayService(
  152. nil, // accountRepo (not used: scheduler snapshot hit)
  153. &fakeGroupRepo{group: group},
  154. nil, // usageLogRepo
  155. nil, // usageBillingRepo
  156. nil, // userRepo
  157. nil, // userSubRepo
  158. nil, // userGroupRateRepo
  159. nil, // cache (disable sticky)
  160. nil, // cfg
  161. schedulerSnapshot,
  162. nil, // concurrencyService (disable load-aware; tryAcquire always acquired)
  163. nil, // billingService
  164. nil, // rateLimitService
  165. nil, // billingCacheService
  166. nil, // identityService
  167. nil, // httpUpstream
  168. nil, // deferredService
  169. nil, // claudeTokenProvider
  170. nil, // sessionLimitCache
  171. nil, // rpmCache
  172. nil, // digestStore
  173. nil, // settingService
  174. nil, // tlsFPProfileService
  175. nil, // channelService
  176. nil, // resolver
  177. nil, // compositeResolver
  178. nil, // balanceNotifyService
  179. nil, // userPlatformQuotaRepo
  180. )
  181. // RunModeSimple:跳过计费检查,避免引入 repo/cache 依赖。
  182. cfg := &config.Config{RunMode: config.RunModeSimple}
  183. billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
  184. concurrencySvc := service.NewConcurrencyService(&fakeConcurrencyCache{})
  185. concurrencyHelper := NewConcurrencyHelper(concurrencySvc, SSEPingFormatClaude, 0)
  186. h := &GatewayHandler{
  187. gatewayService: gwSvc,
  188. billingCacheService: billingCacheSvc,
  189. concurrencyHelper: concurrencyHelper,
  190. // 这些字段对本测试不敏感,保持较小即可
  191. maxAccountSwitches: 1,
  192. maxAccountSwitchesGemini: 1,
  193. }
  194. cleanup := func() {
  195. billingCacheSvc.Stop()
  196. }
  197. return h, cleanup
  198. }
  199. func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_MixedSchedulingV1(t *testing.T) {
  200. gin.SetMode(gin.TestMode)
  201. groupID := int64(2001)
  202. accountID := int64(1001)
  203. group := &service.Group{
  204. ID: groupID,
  205. Hydrated: true,
  206. Platform: service.PlatformAnthropic, // /v1/messages(Claude兼容)入口
  207. Status: service.StatusActive,
  208. }
  209. account := &service.Account{
  210. ID: accountID,
  211. Name: "ag-1",
  212. Platform: service.PlatformAntigravity,
  213. Type: service.AccountTypeOAuth,
  214. Credentials: map[string]any{
  215. "access_token": "tok_xxx",
  216. "intercept_warmup_requests": true,
  217. },
  218. Extra: map[string]any{
  219. "mixed_scheduling": true, // 关键:允许被 anthropic 分组混合调度选中
  220. },
  221. Concurrency: 1,
  222. Priority: 1,
  223. Status: service.StatusActive,
  224. Schedulable: true,
  225. AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}},
  226. }
  227. h, cleanup := newTestGatewayHandler(t, group, []*service.Account{account})
  228. defer cleanup()
  229. rec := httptest.NewRecorder()
  230. c, _ := gin.CreateTestContext(rec)
  231. body := []byte(`{
  232. "model": "claude-sonnet-4-5",
  233. "max_tokens": 256,
  234. "messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
  235. }`)
  236. req := httptest.NewRequest("POST", "/v1/messages", bytes.NewReader(body))
  237. req.Header.Set("Content-Type", "application/json")
  238. req = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, group))
  239. c.Request = req
  240. apiKey := &service.APIKey{
  241. ID: 3001,
  242. UserID: 4001,
  243. GroupID: &groupID,
  244. Status: service.StatusActive,
  245. User: &service.User{
  246. ID: 4001,
  247. Concurrency: 10,
  248. Balance: 100,
  249. },
  250. Group: group,
  251. }
  252. c.Set(string(middleware.ContextKeyAPIKey), apiKey)
  253. c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
  254. h.Messages(c)
  255. require.Equal(t, 200, rec.Code)
  256. // 断言:确实选中了 antigravity 账号(不是纯函数测试,而是从 Handler 里验证调度结果)
  257. selected, ok := c.Get(opsAccountIDKey)
  258. require.True(t, ok)
  259. require.Equal(t, accountID, selected)
  260. var resp map[string]any
  261. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  262. require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01"))
  263. require.Equal(t, "claude-sonnet-4-5", resp["model"])
  264. content, ok := resp["content"].([]any)
  265. require.True(t, ok)
  266. require.Len(t, content, 1)
  267. first, ok := content[0].(map[string]any)
  268. require.True(t, ok)
  269. require.Equal(t, "New Conversation", first["text"])
  270. }
  271. func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_ForcePlatform(t *testing.T) {
  272. gin.SetMode(gin.TestMode)
  273. groupID := int64(2002)
  274. accountID := int64(1002)
  275. group := &service.Group{
  276. ID: groupID,
  277. Hydrated: true,
  278. Platform: service.PlatformAntigravity,
  279. Status: service.StatusActive,
  280. }
  281. account := &service.Account{
  282. ID: accountID,
  283. Name: "ag-2",
  284. Platform: service.PlatformAntigravity,
  285. Type: service.AccountTypeOAuth,
  286. Credentials: map[string]any{
  287. "access_token": "tok_xxx",
  288. "intercept_warmup_requests": true,
  289. },
  290. Concurrency: 1,
  291. Priority: 1,
  292. Status: service.StatusActive,
  293. Schedulable: true,
  294. AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}},
  295. }
  296. h, cleanup := newTestGatewayHandler(t, group, []*service.Account{account})
  297. defer cleanup()
  298. rec := httptest.NewRecorder()
  299. c, _ := gin.CreateTestContext(rec)
  300. body := []byte(`{
  301. "model": "claude-sonnet-4-5",
  302. "max_tokens": 256,
  303. "messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
  304. }`)
  305. req := httptest.NewRequest("POST", "/antigravity/v1/messages", bytes.NewReader(body))
  306. req.Header.Set("Content-Type", "application/json")
  307. // 模拟 routes/gateway.go 里的 ForcePlatform 中间件效果:
  308. // - 写入 request.Context(Service读取)
  309. // - 写入 gin.Context(Handler快速读取)
  310. ctx := context.WithValue(req.Context(), ctxkey.Group, group)
  311. ctx = context.WithValue(ctx, ctxkey.ForcePlatform, service.PlatformAntigravity)
  312. req = req.WithContext(ctx)
  313. c.Request = req
  314. c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
  315. apiKey := &service.APIKey{
  316. ID: 3002,
  317. UserID: 4002,
  318. GroupID: &groupID,
  319. Status: service.StatusActive,
  320. User: &service.User{
  321. ID: 4002,
  322. Concurrency: 10,
  323. Balance: 100,
  324. },
  325. Group: group,
  326. }
  327. c.Set(string(middleware.ContextKeyAPIKey), apiKey)
  328. c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
  329. h.Messages(c)
  330. require.Equal(t, 200, rec.Code)
  331. selected, ok := c.Get(opsAccountIDKey)
  332. require.True(t, ok)
  333. require.Equal(t, accountID, selected)
  334. var resp map[string]any
  335. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
  336. require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01"))
  337. require.Equal(t, "claude-sonnet-4-5", resp["model"])
  338. }