openai_ws_session_preemption_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "sync"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. func TestOpenAIWSSessionPreemptRegistryCancelsSameScopedSessionOnly(t *testing.T) {
  16. var registry openAIWSSessionPreemptRegistry
  17. key := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 11, sessionHash: "sess"}
  18. other := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 12, sessionHash: "sess"}
  19. firstCtx, firstCancel := context.WithCancel(context.Background())
  20. firstCleanup, replaced := registry.Begin(key, firstCancel)
  21. require.False(t, replaced)
  22. otherCtx, otherCancel := context.WithCancel(context.Background())
  23. otherCleanup, replaced := registry.Begin(other, otherCancel)
  24. require.False(t, replaced)
  25. secondCtx, secondCancel := context.WithCancel(context.Background())
  26. secondCleanup, replaced := registry.Begin(key, secondCancel)
  27. require.True(t, replaced)
  28. require.ErrorIs(t, firstCtx.Err(), context.Canceled)
  29. require.NoError(t, otherCtx.Err())
  30. require.NoError(t, secondCtx.Err())
  31. firstCleanup()
  32. require.NoError(t, secondCtx.Err(), "stale cleanup must not remove the replacement")
  33. secondCleanup()
  34. otherCleanup()
  35. }
  36. type openAIWSSessionPreemptCacheStub struct {
  37. GatewayCache
  38. mu sync.Mutex
  39. owners map[string][]byte
  40. }
  41. func (c *openAIWSSessionPreemptCacheStub) key(groupID int64, hash string) string {
  42. return fmt.Sprintf("%d:%s", groupID, hash)
  43. }
  44. func (c *openAIWSSessionPreemptCacheStub) ClaimOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, owner []byte, _ time.Duration) ([]byte, error) {
  45. c.mu.Lock()
  46. defer c.mu.Unlock()
  47. if c.owners == nil {
  48. c.owners = make(map[string][]byte)
  49. }
  50. key := c.key(groupID, hash)
  51. previous := append([]byte(nil), c.owners[key]...)
  52. c.owners[key] = append([]byte(nil), owner...)
  53. return previous, nil
  54. }
  55. func (c *openAIWSSessionPreemptCacheStub) CompareAndRefreshOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, expected []byte, _ time.Duration) (bool, error) {
  56. c.mu.Lock()
  57. defer c.mu.Unlock()
  58. return string(c.owners[c.key(groupID, hash)]) == string(expected), nil
  59. }
  60. func (c *openAIWSSessionPreemptCacheStub) CompareAndDeleteOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, expected []byte) (bool, error) {
  61. c.mu.Lock()
  62. defer c.mu.Unlock()
  63. key := c.key(groupID, hash)
  64. if string(c.owners[key]) != string(expected) {
  65. return false, nil
  66. }
  67. delete(c.owners, key)
  68. return true, nil
  69. }
  70. func TestOpenAIWSSessionPreemptContextEligibilityAndLocalCancellation(t *testing.T) {
  71. stateStore := NewOpenAIWSStateStore(nil)
  72. svc := &OpenAIGatewayService{openaiWSStateStore: stateStore}
  73. oauth := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  74. apiKey := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  75. grok := &Account{ID: 3, Platform: PlatformGrok, Type: AccountTypeOAuth}
  76. _, cleanup, armed, _ := svc.beginOpenAIWSSessionPreemptContext(context.Background(), apiKey, 7, 11, "sess", false)
  77. cleanup()
  78. require.False(t, armed)
  79. _, cleanup, armed, _ = svc.beginOpenAIWSSessionPreemptContext(context.Background(), grok, 7, 11, "sess", false)
  80. cleanup()
  81. require.False(t, armed)
  82. _, cleanup, armed, _ = svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", true)
  83. cleanup()
  84. require.False(t, armed, "HTTP-ingress one-shot must not participate")
  85. firstCtx, firstCleanup, armed, replaced := svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", false)
  86. require.True(t, armed)
  87. require.False(t, replaced)
  88. stateStore.BindSessionTurnState(7, "sess", "turn-state", time.Hour)
  89. stateStore.BindSessionConn(7, "sess", "conn-1", time.Hour)
  90. _, secondCleanup, armed, replaced := svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", false)
  91. require.True(t, armed)
  92. require.True(t, replaced)
  93. require.True(t, isOpenAIWSSessionPreempted(firstCtx))
  94. require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(firstCtx)))
  95. _, turnStateExists := stateStore.GetSessionTurnState(7, "sess")
  96. _, sessionConnExists := stateStore.GetSessionConn(7, "sess")
  97. require.False(t, turnStateExists)
  98. require.False(t, sessionConnExists)
  99. firstCleanup()
  100. secondCleanup()
  101. }
  102. func TestOpenAIWSIngressSessionPreemptionSurvivesNestedForwardCleanup(t *testing.T) {
  103. gin.SetMode(gin.TestMode)
  104. groupID := int64(7)
  105. newContext := func() *gin.Context {
  106. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  107. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  108. c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID})
  109. return c
  110. }
  111. svc := &OpenAIGatewayService{}
  112. account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  113. firstMessage := []byte(`{"type":"response.create","prompt_cache_key":"session-1","input":"hello"}`)
  114. firstCtx, firstCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
  115. context.Background(), newContext(), account, firstMessage,
  116. )
  117. require.True(t, armed)
  118. defer firstCleanup()
  119. // ProxyResponsesWebSocketFromClient enters the same helper for each upstream
  120. // attempt. Its cleanup must not release the handler-owned registration.
  121. nestedCtx, nestedCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
  122. firstCtx, newContext(), account, firstMessage,
  123. )
  124. require.True(t, armed)
  125. require.Equal(t, firstCtx, nestedCtx)
  126. nestedCleanup()
  127. require.NoError(t, firstCtx.Err())
  128. _, secondCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
  129. context.Background(), newContext(), account, firstMessage,
  130. )
  131. require.True(t, armed)
  132. defer secondCleanup()
  133. require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(firstCtx)))
  134. }
  135. func TestOpenAIWSIngressSessionPreemptionRespectsResolvedMode(t *testing.T) {
  136. gin.SetMode(gin.TestMode)
  137. groupID := int64(7)
  138. newContext := func() *gin.Context {
  139. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  140. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  141. c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID})
  142. return c
  143. }
  144. newAccount := func(mode string) *Account {
  145. return &Account{
  146. ID: 1,
  147. Platform: PlatformOpenAI,
  148. Type: AccountTypeOAuth,
  149. Extra: map[string]any{
  150. "openai_oauth_responses_websockets_v2_mode": mode,
  151. },
  152. }
  153. }
  154. firstMessage := []byte(`{"type":"response.create","prompt_cache_key":"session-1","input":"hello"}`)
  155. cfg := &config.Config{}
  156. cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
  157. cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool
  158. svc := &OpenAIGatewayService{cfg: cfg}
  159. passthrough := newAccount(OpenAIWSIngressModePassthrough)
  160. firstCtx, firstCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
  161. context.Background(), newContext(), passthrough, firstMessage,
  162. )
  163. require.False(t, armed)
  164. defer firstCleanup()
  165. secondCtx, secondCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
  166. context.Background(), newContext(), passthrough, firstMessage,
  167. )
  168. require.False(t, armed)
  169. defer secondCleanup()
  170. require.NoError(t, firstCtx.Err(), "concurrent passthrough request must remain isolated")
  171. require.NoError(t, secondCtx.Err())
  172. ctxPool := newAccount(OpenAIWSIngressModeCtxPool)
  173. sharedCtx, sharedCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
  174. context.Background(), newContext(), ctxPool, firstMessage,
  175. )
  176. require.True(t, armed)
  177. defer sharedCleanup()
  178. _, replacementCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
  179. context.Background(), newContext(), ctxPool, firstMessage,
  180. )
  181. require.True(t, armed)
  182. defer replacementCleanup()
  183. require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(sharedCtx)), "ctx_pool must retain shared-session preemption")
  184. }
  185. func TestOpenAIWSSessionPreemptRemoteClaimAndStaleReleaseAreAtomic(t *testing.T) {
  186. cache := &openAIWSSessionPreemptCacheStub{}
  187. svc := &OpenAIGatewayService{cache: cache}
  188. key := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 11, sessionHash: "sess"}
  189. previous, ok := svc.claimOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-a")
  190. require.True(t, ok)
  191. require.Empty(t, previous)
  192. previous, ok = svc.claimOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-b")
  193. require.True(t, ok)
  194. require.Equal(t, "owner-a", previous)
  195. svc.releaseOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-a")
  196. cache.mu.Lock()
  197. current := string(cache.owners[cache.key(key.groupID, openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash))])
  198. cache.mu.Unlock()
  199. require.Equal(t, "owner-b", current, "stale cleanup must preserve the replacement owner")
  200. }
  201. func TestOpenAIWSHTTPBridgeSessionPreemptionEligibility(t *testing.T) {
  202. gin.SetMode(gin.TestMode)
  203. tests := []struct {
  204. name string
  205. routerEnabled bool
  206. defaultMode string
  207. accountMode string
  208. wantArmed bool
  209. }{
  210. {name: "explicit HTTP bridge", routerEnabled: true, defaultMode: OpenAIWSIngressModeCtxPool, accountMode: OpenAIWSIngressModeHTTPBridge},
  211. {name: "default HTTP bridge", routerEnabled: true, defaultMode: OpenAIWSIngressModeHTTPBridge},
  212. {name: "ctx pool overrides bridge default", routerEnabled: true, defaultMode: OpenAIWSIngressModeHTTPBridge, accountMode: OpenAIWSIngressModeCtxPool, wantArmed: true},
  213. {name: "disabled router retains legacy preemption", defaultMode: OpenAIWSIngressModeHTTPBridge, accountMode: OpenAIWSIngressModeHTTPBridge, wantArmed: true},
  214. }
  215. for _, tt := range tests {
  216. t.Run(tt.name, func(t *testing.T) {
  217. cfg := &config.Config{}
  218. cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = tt.routerEnabled
  219. cfg.Gateway.OpenAIWS.IngressModeDefault = tt.defaultMode
  220. cache := &openAIWSSessionPreemptCacheStub{}
  221. svc := &OpenAIGatewayService{cfg: cfg, cache: cache}
  222. groupID := int64(7)
  223. newContext := func() *gin.Context {
  224. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  225. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  226. c.Request.Header.Set("session-id", "shared-session")
  227. c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID})
  228. return c
  229. }
  230. account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  231. Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": tt.accountMode}}
  232. firstMessage := []byte(`{"type":"response.create","prompt_cache_key":"cache-a","input":"first request"}`)
  233. secondMessage := []byte(`{"type":"response.create","prompt_cache_key":"cache-b","input":"second request"}`)
  234. first, cleanupFirst, firstArmed := svc.BeginOpenAIWSIngressSessionPreemption(context.Background(), newContext(), account, firstMessage)
  235. defer cleanupFirst()
  236. second, cleanupSecond, secondArmed := svc.BeginOpenAIWSIngressSessionPreemption(context.Background(), newContext(), account, secondMessage)
  237. defer cleanupSecond()
  238. require.Equal(t, tt.wantArmed, firstArmed)
  239. require.Equal(t, tt.wantArmed, secondArmed)
  240. require.NoError(t, second.Err())
  241. if tt.wantArmed {
  242. require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(first)))
  243. } else {
  244. require.NoError(t, first.Err(), "independent HTTP bridges must not cancel each other")
  245. cache.mu.Lock()
  246. ownerCount := len(cache.owners)
  247. cache.mu.Unlock()
  248. require.Zero(t, ownerCount, "HTTP bridges must not claim a distributed preemption owner")
  249. }
  250. })
  251. }
  252. }
  253. func TestNewOpenAIWSSessionPreemptKeyRequiresFullIsolationScope(t *testing.T) {
  254. _, ok := newOpenAIWSSessionPreemptKey(0, 11, "sess")
  255. require.False(t, ok)
  256. _, ok = newOpenAIWSSessionPreemptKey(7, 0, "sess")
  257. require.False(t, ok)
  258. _, ok = newOpenAIWSSessionPreemptKey(7, 11, " ")
  259. require.False(t, ok)
  260. key, ok := newOpenAIWSSessionPreemptKey(7, 11, " sess ")
  261. require.True(t, ok)
  262. require.Equal(t, "sess", key.sessionHash)
  263. }