openai_ws_account_sticky_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. package service
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "github.com/Wei-Shaw/sub2api/internal/config"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Hit(t *testing.T) {
  10. ctx := context.Background()
  11. groupID := int64(23)
  12. account := Account{
  13. ID: 2,
  14. Platform: PlatformOpenAI,
  15. Type: AccountTypeAPIKey,
  16. Status: StatusActive,
  17. Schedulable: true,
  18. Concurrency: 2,
  19. Extra: map[string]any{
  20. "openai_apikey_responses_websockets_v2_enabled": true,
  21. },
  22. }
  23. cache := &stubGatewayCache{}
  24. store := NewOpenAIWSStateStore(cache)
  25. cfg := newOpenAIWSV2TestConfig()
  26. svc := &OpenAIGatewayService{
  27. accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  28. cache: cache,
  29. cfg: cfg,
  30. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  31. openaiWSStateStore: store,
  32. }
  33. require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_1", account.ID, time.Hour))
  34. selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_1", "gpt-5.1", nil, false)
  35. require.NoError(t, err)
  36. require.NotNil(t, selection)
  37. require.NotNil(t, selection.Account)
  38. require.Equal(t, account.ID, selection.Account.ID)
  39. require.True(t, selection.Acquired)
  40. if selection.ReleaseFunc != nil {
  41. selection.ReleaseFunc()
  42. }
  43. }
  44. func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_QuotaAutoPausedMiss(t *testing.T) {
  45. ctx := context.Background()
  46. groupID := int64(23)
  47. account := Account{
  48. ID: 77,
  49. Platform: PlatformOpenAI,
  50. Type: AccountTypeAPIKey,
  51. Status: StatusActive,
  52. Schedulable: true,
  53. Concurrency: 2,
  54. Extra: map[string]any{
  55. "openai_apikey_responses_websockets_v2_enabled": true,
  56. "codex_5h_used_percent": 96.0,
  57. "auto_pause_5h_threshold": 0.95,
  58. },
  59. }
  60. cache := &stubGatewayCache{}
  61. store := NewOpenAIWSStateStore(cache)
  62. cfg := newOpenAIWSV2TestConfig()
  63. svc := &OpenAIGatewayService{
  64. accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  65. cache: cache,
  66. cfg: cfg,
  67. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  68. openaiWSStateStore: store,
  69. }
  70. require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_quota", account.ID, time.Hour))
  71. selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_quota", "gpt-5.1", nil, false)
  72. require.NoError(t, err)
  73. require.Nil(t, selection, "超过 5h 配额阈值的账号不应继续命中 previous_response_id 粘连")
  74. // Auto-pause is transient, so the binding is preserved: the chain can resume on the
  75. // same account once the quota window resets.
  76. boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_quota")
  77. require.NoError(t, getErr)
  78. require.Equal(t, account.ID, boundAccountID)
  79. }
  80. func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_RateLimitedMiss(t *testing.T) {
  81. ctx := context.Background()
  82. groupID := int64(23)
  83. rateLimitedUntil := time.Now().Add(30 * time.Minute)
  84. account := Account{
  85. ID: 12,
  86. Platform: PlatformOpenAI,
  87. Type: AccountTypeAPIKey,
  88. Status: StatusActive,
  89. Schedulable: true,
  90. Concurrency: 1,
  91. RateLimitResetAt: &rateLimitedUntil,
  92. Extra: map[string]any{
  93. "openai_apikey_responses_websockets_v2_enabled": true,
  94. },
  95. }
  96. cache := &stubGatewayCache{}
  97. store := NewOpenAIWSStateStore(cache)
  98. cfg := newOpenAIWSV2TestConfig()
  99. svc := &OpenAIGatewayService{
  100. accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  101. cache: cache,
  102. cfg: cfg,
  103. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  104. openaiWSStateStore: store,
  105. }
  106. require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_rl", account.ID, time.Hour))
  107. selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_rl", "gpt-5.1", nil, false)
  108. require.NoError(t, err)
  109. require.Nil(t, selection, "限额中的账号不应继续命中 previous_response_id 粘连")
  110. boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_rl")
  111. require.NoError(t, getErr)
  112. require.Zero(t, boundAccountID)
  113. }
  114. func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_DBRuntimeRecheckRateLimitedMiss(t *testing.T) {
  115. ctx := context.Background()
  116. groupID := int64(24)
  117. rateLimitedUntil := time.Now().Add(30 * time.Minute)
  118. staleAccount := &Account{
  119. ID: 13,
  120. Platform: PlatformOpenAI,
  121. Type: AccountTypeAPIKey,
  122. Status: StatusActive,
  123. Schedulable: true,
  124. Concurrency: 1,
  125. Extra: map[string]any{
  126. "openai_apikey_responses_websockets_v2_enabled": true,
  127. },
  128. }
  129. dbAccount := Account{
  130. ID: 13,
  131. Platform: PlatformOpenAI,
  132. Type: AccountTypeAPIKey,
  133. Status: StatusActive,
  134. Schedulable: true,
  135. Concurrency: 1,
  136. RateLimitResetAt: &rateLimitedUntil,
  137. Extra: map[string]any{
  138. "openai_apikey_responses_websockets_v2_enabled": true,
  139. },
  140. }
  141. cache := &stubGatewayCache{}
  142. store := NewOpenAIWSStateStore(cache)
  143. cfg := newOpenAIWSV2TestConfig()
  144. snapshotCache := &openAISnapshotCacheStub{
  145. accountsByID: map[int64]*Account{dbAccount.ID: staleAccount},
  146. }
  147. svc := &OpenAIGatewayService{
  148. accountRepo: stubOpenAIAccountRepo{accounts: []Account{dbAccount}},
  149. cache: cache,
  150. cfg: cfg,
  151. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  152. openaiWSStateStore: store,
  153. schedulerSnapshot: &SchedulerSnapshotService{cache: snapshotCache},
  154. }
  155. require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_db_rl", dbAccount.ID, time.Hour))
  156. selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_db_rl", "gpt-5.1", nil, false)
  157. require.NoError(t, err)
  158. require.Nil(t, selection, "DB 中已限流的账号不应继续命中 previous_response_id 粘连")
  159. boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_db_rl")
  160. require.NoError(t, getErr)
  161. require.Zero(t, boundAccountID)
  162. }
  163. func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Excluded(t *testing.T) {
  164. ctx := context.Background()
  165. groupID := int64(23)
  166. account := Account{
  167. ID: 8,
  168. Platform: PlatformOpenAI,
  169. Type: AccountTypeAPIKey,
  170. Status: StatusActive,
  171. Schedulable: true,
  172. Concurrency: 1,
  173. Extra: map[string]any{
  174. "openai_apikey_responses_websockets_v2_enabled": true,
  175. },
  176. }
  177. cache := &stubGatewayCache{}
  178. store := NewOpenAIWSStateStore(cache)
  179. cfg := newOpenAIWSV2TestConfig()
  180. svc := &OpenAIGatewayService{
  181. accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  182. cache: cache,
  183. cfg: cfg,
  184. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  185. openaiWSStateStore: store,
  186. }
  187. require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_2", account.ID, time.Hour))
  188. selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_2", "gpt-5.1", map[int64]struct{}{account.ID: {}}, false)
  189. require.NoError(t, err)
  190. require.Nil(t, selection)
  191. }
  192. func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_APIKeyForceHTTPHit(t *testing.T) {
  193. ctx := context.Background()
  194. groupID := int64(23)
  195. account := Account{
  196. ID: 11,
  197. Platform: PlatformOpenAI,
  198. Type: AccountTypeAPIKey,
  199. Status: StatusActive,
  200. Schedulable: true,
  201. Concurrency: 1,
  202. Extra: map[string]any{
  203. "openai_ws_force_http": true,
  204. "responses_websockets_v2_enabled": true,
  205. },
  206. }
  207. cache := &stubGatewayCache{}
  208. store := NewOpenAIWSStateStore(cache)
  209. cfg := newOpenAIWSV2TestConfig()
  210. svc := &OpenAIGatewayService{
  211. accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  212. cache: cache,
  213. cfg: cfg,
  214. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  215. openaiWSStateStore: store,
  216. }
  217. require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_force_http", account.ID, time.Hour))
  218. selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_force_http", "gpt-5.1", nil, false)
  219. require.NoError(t, err)
  220. require.NotNil(t, selection, "API-key HTTP continuation must retain the key/project that created the response")
  221. require.NotNil(t, selection.Account)
  222. require.Equal(t, account.ID, selection.Account.ID)
  223. if selection.ReleaseFunc != nil {
  224. selection.ReleaseFunc()
  225. }
  226. }
  227. func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_OAuthForceHTTPIgnored(t *testing.T) {
  228. ctx := context.Background()
  229. groupID := int64(23)
  230. account := Account{
  231. ID: 12,
  232. Platform: PlatformOpenAI,
  233. Type: AccountTypeOAuth,
  234. Status: StatusActive,
  235. Schedulable: true,
  236. Concurrency: 1,
  237. Extra: map[string]any{
  238. "openai_ws_force_http": true,
  239. "responses_websockets_v2_enabled": true,
  240. },
  241. }
  242. cache := &stubGatewayCache{}
  243. store := NewOpenAIWSStateStore(cache)
  244. svc := &OpenAIGatewayService{
  245. accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  246. cache: cache,
  247. cfg: newOpenAIWSV2TestConfig(),
  248. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  249. openaiWSStateStore: store,
  250. }
  251. require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_oauth_force_http", account.ID, time.Hour))
  252. selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_oauth_force_http", "gpt-5.1", nil, false)
  253. require.NoError(t, err)
  254. require.Nil(t, selection, "OAuth HTTP fallback cannot preserve WSv2 continuation state")
  255. }
  256. func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_BusyKeepsSticky(t *testing.T) {
  257. ctx := context.Background()
  258. groupID := int64(23)
  259. accounts := []Account{
  260. {
  261. ID: 21,
  262. Platform: PlatformOpenAI,
  263. Type: AccountTypeAPIKey,
  264. Status: StatusActive,
  265. Schedulable: true,
  266. Concurrency: 1,
  267. Priority: 0,
  268. Extra: map[string]any{
  269. "openai_apikey_responses_websockets_v2_enabled": true,
  270. },
  271. },
  272. {
  273. ID: 22,
  274. Platform: PlatformOpenAI,
  275. Type: AccountTypeAPIKey,
  276. Status: StatusActive,
  277. Schedulable: true,
  278. Concurrency: 1,
  279. Priority: 9,
  280. Extra: map[string]any{
  281. "openai_apikey_responses_websockets_v2_enabled": true,
  282. },
  283. },
  284. }
  285. cache := &stubGatewayCache{}
  286. store := NewOpenAIWSStateStore(cache)
  287. cfg := newOpenAIWSV2TestConfig()
  288. cfg.Gateway.Scheduling.StickySessionMaxWaiting = 2
  289. cfg.Gateway.Scheduling.StickySessionWaitTimeout = 30 * time.Second
  290. concurrencyCache := stubConcurrencyCache{
  291. acquireResults: map[int64]bool{
  292. 21: false, // previous_response 命中的账号繁忙
  293. 22: true, // 次优账号可用(若回退会命中)
  294. },
  295. waitCounts: map[int64]int{
  296. 21: 999,
  297. },
  298. }
  299. svc := &OpenAIGatewayService{
  300. accountRepo: stubOpenAIAccountRepo{accounts: accounts},
  301. cache: cache,
  302. cfg: cfg,
  303. concurrencyService: NewConcurrencyService(concurrencyCache),
  304. openaiWSStateStore: store,
  305. }
  306. require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_busy", 21, time.Hour))
  307. selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_busy", "gpt-5.1", nil, false)
  308. require.NoError(t, err)
  309. require.NotNil(t, selection)
  310. require.NotNil(t, selection.Account)
  311. require.Equal(t, int64(21), selection.Account.ID, "busy previous_response sticky account should remain selected")
  312. require.False(t, selection.Acquired)
  313. require.NotNil(t, selection.WaitPlan)
  314. require.Equal(t, int64(21), selection.WaitPlan.AccountID)
  315. }
  316. func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_CapabilityMismatchKeepsSticky(t *testing.T) {
  317. ctx := context.Background()
  318. groupID := int64(25)
  319. account := Account{
  320. ID: 31,
  321. Platform: PlatformOpenAI,
  322. Type: AccountTypeAPIKey,
  323. Status: StatusActive,
  324. Schedulable: true,
  325. Concurrency: 1,
  326. Credentials: map[string]any{
  327. "openai_capabilities": []any{"chat_completions"},
  328. },
  329. Extra: map[string]any{
  330. "openai_apikey_responses_websockets_v2_enabled": true,
  331. },
  332. }
  333. cache := &stubGatewayCache{}
  334. store := NewOpenAIWSStateStore(cache)
  335. cfg := newOpenAIWSV2TestConfig()
  336. svc := &OpenAIGatewayService{
  337. accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  338. cache: cache,
  339. cfg: cfg,
  340. concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
  341. openaiWSStateStore: store,
  342. }
  343. require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_capability", account.ID, time.Hour))
  344. selection, err := svc.selectAccountByPreviousResponseIDForCapability(
  345. ctx,
  346. &groupID,
  347. "resp_prev_capability",
  348. "text-embedding-3-small",
  349. nil,
  350. OpenAIEndpointCapabilityEmbeddings,
  351. false,
  352. )
  353. require.NoError(t, err)
  354. require.Nil(t, selection)
  355. boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_capability")
  356. require.NoError(t, getErr)
  357. require.Equal(t, account.ID, boundAccountID)
  358. }
  359. func newOpenAIWSV2TestConfig() *config.Config {
  360. cfg := &config.Config{}
  361. cfg.Gateway.OpenAIWS.Enabled = true
  362. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  363. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  364. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  365. cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = 3600
  366. return cfg
  367. }