| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- //go:build unit
- package service
- import (
- "context"
- "fmt"
- "net/http"
- "net/http/httptest"
- "sync"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- func TestOpenAIWSSessionPreemptRegistryCancelsSameScopedSessionOnly(t *testing.T) {
- var registry openAIWSSessionPreemptRegistry
- key := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 11, sessionHash: "sess"}
- other := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 12, sessionHash: "sess"}
- firstCtx, firstCancel := context.WithCancel(context.Background())
- firstCleanup, replaced := registry.Begin(key, firstCancel)
- require.False(t, replaced)
- otherCtx, otherCancel := context.WithCancel(context.Background())
- otherCleanup, replaced := registry.Begin(other, otherCancel)
- require.False(t, replaced)
- secondCtx, secondCancel := context.WithCancel(context.Background())
- secondCleanup, replaced := registry.Begin(key, secondCancel)
- require.True(t, replaced)
- require.ErrorIs(t, firstCtx.Err(), context.Canceled)
- require.NoError(t, otherCtx.Err())
- require.NoError(t, secondCtx.Err())
- firstCleanup()
- require.NoError(t, secondCtx.Err(), "stale cleanup must not remove the replacement")
- secondCleanup()
- otherCleanup()
- }
- type openAIWSSessionPreemptCacheStub struct {
- GatewayCache
- mu sync.Mutex
- owners map[string][]byte
- }
- func (c *openAIWSSessionPreemptCacheStub) key(groupID int64, hash string) string {
- return fmt.Sprintf("%d:%s", groupID, hash)
- }
- func (c *openAIWSSessionPreemptCacheStub) ClaimOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, owner []byte, _ time.Duration) ([]byte, error) {
- c.mu.Lock()
- defer c.mu.Unlock()
- if c.owners == nil {
- c.owners = make(map[string][]byte)
- }
- key := c.key(groupID, hash)
- previous := append([]byte(nil), c.owners[key]...)
- c.owners[key] = append([]byte(nil), owner...)
- return previous, nil
- }
- func (c *openAIWSSessionPreemptCacheStub) CompareAndRefreshOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, expected []byte, _ time.Duration) (bool, error) {
- c.mu.Lock()
- defer c.mu.Unlock()
- return string(c.owners[c.key(groupID, hash)]) == string(expected), nil
- }
- func (c *openAIWSSessionPreemptCacheStub) CompareAndDeleteOpenAIResponsesSessionWindow(_ context.Context, groupID int64, hash string, expected []byte) (bool, error) {
- c.mu.Lock()
- defer c.mu.Unlock()
- key := c.key(groupID, hash)
- if string(c.owners[key]) != string(expected) {
- return false, nil
- }
- delete(c.owners, key)
- return true, nil
- }
- func TestOpenAIWSSessionPreemptContextEligibilityAndLocalCancellation(t *testing.T) {
- stateStore := NewOpenAIWSStateStore(nil)
- svc := &OpenAIGatewayService{openaiWSStateStore: stateStore}
- oauth := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
- apiKey := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
- grok := &Account{ID: 3, Platform: PlatformGrok, Type: AccountTypeOAuth}
- _, cleanup, armed, _ := svc.beginOpenAIWSSessionPreemptContext(context.Background(), apiKey, 7, 11, "sess", false)
- cleanup()
- require.False(t, armed)
- _, cleanup, armed, _ = svc.beginOpenAIWSSessionPreemptContext(context.Background(), grok, 7, 11, "sess", false)
- cleanup()
- require.False(t, armed)
- _, cleanup, armed, _ = svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", true)
- cleanup()
- require.False(t, armed, "HTTP-ingress one-shot must not participate")
- firstCtx, firstCleanup, armed, replaced := svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", false)
- require.True(t, armed)
- require.False(t, replaced)
- stateStore.BindSessionTurnState(7, "sess", "turn-state", time.Hour)
- stateStore.BindSessionConn(7, "sess", "conn-1", time.Hour)
- _, secondCleanup, armed, replaced := svc.beginOpenAIWSSessionPreemptContext(context.Background(), oauth, 7, 11, "sess", false)
- require.True(t, armed)
- require.True(t, replaced)
- require.True(t, isOpenAIWSSessionPreempted(firstCtx))
- require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(firstCtx)))
- _, turnStateExists := stateStore.GetSessionTurnState(7, "sess")
- _, sessionConnExists := stateStore.GetSessionConn(7, "sess")
- require.False(t, turnStateExists)
- require.False(t, sessionConnExists)
- firstCleanup()
- secondCleanup()
- }
- func TestOpenAIWSIngressSessionPreemptionSurvivesNestedForwardCleanup(t *testing.T) {
- gin.SetMode(gin.TestMode)
- groupID := int64(7)
- newContext := func() *gin.Context {
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
- c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID})
- return c
- }
- svc := &OpenAIGatewayService{}
- account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
- firstMessage := []byte(`{"type":"response.create","prompt_cache_key":"session-1","input":"hello"}`)
- firstCtx, firstCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
- context.Background(), newContext(), account, firstMessage,
- )
- require.True(t, armed)
- defer firstCleanup()
- // ProxyResponsesWebSocketFromClient enters the same helper for each upstream
- // attempt. Its cleanup must not release the handler-owned registration.
- nestedCtx, nestedCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
- firstCtx, newContext(), account, firstMessage,
- )
- require.True(t, armed)
- require.Equal(t, firstCtx, nestedCtx)
- nestedCleanup()
- require.NoError(t, firstCtx.Err())
- _, secondCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
- context.Background(), newContext(), account, firstMessage,
- )
- require.True(t, armed)
- defer secondCleanup()
- require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(firstCtx)))
- }
- func TestOpenAIWSIngressSessionPreemptionRespectsResolvedMode(t *testing.T) {
- gin.SetMode(gin.TestMode)
- groupID := int64(7)
- newContext := func() *gin.Context {
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
- c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID})
- return c
- }
- newAccount := func(mode string) *Account {
- return &Account{
- ID: 1,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Extra: map[string]any{
- "openai_oauth_responses_websockets_v2_mode": mode,
- },
- }
- }
- firstMessage := []byte(`{"type":"response.create","prompt_cache_key":"session-1","input":"hello"}`)
- cfg := &config.Config{}
- cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
- cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool
- svc := &OpenAIGatewayService{cfg: cfg}
- passthrough := newAccount(OpenAIWSIngressModePassthrough)
- firstCtx, firstCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
- context.Background(), newContext(), passthrough, firstMessage,
- )
- require.False(t, armed)
- defer firstCleanup()
- secondCtx, secondCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
- context.Background(), newContext(), passthrough, firstMessage,
- )
- require.False(t, armed)
- defer secondCleanup()
- require.NoError(t, firstCtx.Err(), "concurrent passthrough request must remain isolated")
- require.NoError(t, secondCtx.Err())
- ctxPool := newAccount(OpenAIWSIngressModeCtxPool)
- sharedCtx, sharedCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
- context.Background(), newContext(), ctxPool, firstMessage,
- )
- require.True(t, armed)
- defer sharedCleanup()
- _, replacementCleanup, armed := svc.BeginOpenAIWSIngressSessionPreemption(
- context.Background(), newContext(), ctxPool, firstMessage,
- )
- require.True(t, armed)
- defer replacementCleanup()
- require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(sharedCtx)), "ctx_pool must retain shared-session preemption")
- }
- func TestOpenAIWSSessionPreemptRemoteClaimAndStaleReleaseAreAtomic(t *testing.T) {
- cache := &openAIWSSessionPreemptCacheStub{}
- svc := &OpenAIGatewayService{cache: cache}
- key := openAIWSSessionPreemptKey{groupID: 7, apiKeyID: 11, sessionHash: "sess"}
- previous, ok := svc.claimOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-a")
- require.True(t, ok)
- require.Empty(t, previous)
- previous, ok = svc.claimOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-b")
- require.True(t, ok)
- require.Equal(t, "owner-a", previous)
- svc.releaseOpenAIWSSessionPreemptOwner(context.Background(), key, "owner-a")
- cache.mu.Lock()
- current := string(cache.owners[cache.key(key.groupID, openAIWSSessionPreemptCacheHash(key.apiKeyID, key.sessionHash))])
- cache.mu.Unlock()
- require.Equal(t, "owner-b", current, "stale cleanup must preserve the replacement owner")
- }
- func TestOpenAIWSHTTPBridgeSessionPreemptionEligibility(t *testing.T) {
- gin.SetMode(gin.TestMode)
- tests := []struct {
- name string
- routerEnabled bool
- defaultMode string
- accountMode string
- wantArmed bool
- }{
- {name: "explicit HTTP bridge", routerEnabled: true, defaultMode: OpenAIWSIngressModeCtxPool, accountMode: OpenAIWSIngressModeHTTPBridge},
- {name: "default HTTP bridge", routerEnabled: true, defaultMode: OpenAIWSIngressModeHTTPBridge},
- {name: "ctx pool overrides bridge default", routerEnabled: true, defaultMode: OpenAIWSIngressModeHTTPBridge, accountMode: OpenAIWSIngressModeCtxPool, wantArmed: true},
- {name: "disabled router retains legacy preemption", defaultMode: OpenAIWSIngressModeHTTPBridge, accountMode: OpenAIWSIngressModeHTTPBridge, wantArmed: true},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cfg := &config.Config{}
- cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = tt.routerEnabled
- cfg.Gateway.OpenAIWS.IngressModeDefault = tt.defaultMode
- cache := &openAIWSSessionPreemptCacheStub{}
- svc := &OpenAIGatewayService{cfg: cfg, cache: cache}
- groupID := int64(7)
- newContext := func() *gin.Context {
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
- c.Request.Header.Set("session-id", "shared-session")
- c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID})
- return c
- }
- account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
- Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": tt.accountMode}}
- firstMessage := []byte(`{"type":"response.create","prompt_cache_key":"cache-a","input":"first request"}`)
- secondMessage := []byte(`{"type":"response.create","prompt_cache_key":"cache-b","input":"second request"}`)
- first, cleanupFirst, firstArmed := svc.BeginOpenAIWSIngressSessionPreemption(context.Background(), newContext(), account, firstMessage)
- defer cleanupFirst()
- second, cleanupSecond, secondArmed := svc.BeginOpenAIWSIngressSessionPreemption(context.Background(), newContext(), account, secondMessage)
- defer cleanupSecond()
- require.Equal(t, tt.wantArmed, firstArmed)
- require.Equal(t, tt.wantArmed, secondArmed)
- require.NoError(t, second.Err())
- if tt.wantArmed {
- require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(first)))
- } else {
- require.NoError(t, first.Err(), "independent HTTP bridges must not cancel each other")
- cache.mu.Lock()
- ownerCount := len(cache.owners)
- cache.mu.Unlock()
- require.Zero(t, ownerCount, "HTTP bridges must not claim a distributed preemption owner")
- }
- })
- }
- }
- func TestNewOpenAIWSSessionPreemptKeyRequiresFullIsolationScope(t *testing.T) {
- _, ok := newOpenAIWSSessionPreemptKey(0, 11, "sess")
- require.False(t, ok)
- _, ok = newOpenAIWSSessionPreemptKey(7, 0, "sess")
- require.False(t, ok)
- _, ok = newOpenAIWSSessionPreemptKey(7, 11, " ")
- require.False(t, ok)
- key, ok := newOpenAIWSSessionPreemptKey(7, 11, " sess ")
- require.True(t, ok)
- require.Equal(t, "sess", key.sessionHash)
- }
|