| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402 |
- package service
- import (
- "context"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/stretchr/testify/require"
- )
- func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Hit(t *testing.T) {
- ctx := context.Background()
- groupID := int64(23)
- account := Account{
- ID: 2,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 2,
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- },
- }
- cache := &stubGatewayCache{}
- store := NewOpenAIWSStateStore(cache)
- cfg := newOpenAIWSV2TestConfig()
- svc := &OpenAIGatewayService{
- accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
- cache: cache,
- cfg: cfg,
- concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
- openaiWSStateStore: store,
- }
- require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_1", account.ID, time.Hour))
- selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_1", "gpt-5.1", nil, false)
- require.NoError(t, err)
- require.NotNil(t, selection)
- require.NotNil(t, selection.Account)
- require.Equal(t, account.ID, selection.Account.ID)
- require.True(t, selection.Acquired)
- if selection.ReleaseFunc != nil {
- selection.ReleaseFunc()
- }
- }
- func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_QuotaAutoPausedMiss(t *testing.T) {
- ctx := context.Background()
- groupID := int64(23)
- account := Account{
- ID: 77,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 2,
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- "codex_5h_used_percent": 96.0,
- "auto_pause_5h_threshold": 0.95,
- },
- }
- cache := &stubGatewayCache{}
- store := NewOpenAIWSStateStore(cache)
- cfg := newOpenAIWSV2TestConfig()
- svc := &OpenAIGatewayService{
- accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
- cache: cache,
- cfg: cfg,
- concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
- openaiWSStateStore: store,
- }
- require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_quota", account.ID, time.Hour))
- selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_quota", "gpt-5.1", nil, false)
- require.NoError(t, err)
- require.Nil(t, selection, "超过 5h 配额阈值的账号不应继续命中 previous_response_id 粘连")
- // Auto-pause is transient, so the binding is preserved: the chain can resume on the
- // same account once the quota window resets.
- boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_quota")
- require.NoError(t, getErr)
- require.Equal(t, account.ID, boundAccountID)
- }
- func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_RateLimitedMiss(t *testing.T) {
- ctx := context.Background()
- groupID := int64(23)
- rateLimitedUntil := time.Now().Add(30 * time.Minute)
- account := Account{
- ID: 12,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- RateLimitResetAt: &rateLimitedUntil,
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- },
- }
- cache := &stubGatewayCache{}
- store := NewOpenAIWSStateStore(cache)
- cfg := newOpenAIWSV2TestConfig()
- svc := &OpenAIGatewayService{
- accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
- cache: cache,
- cfg: cfg,
- concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
- openaiWSStateStore: store,
- }
- require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_rl", account.ID, time.Hour))
- selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_rl", "gpt-5.1", nil, false)
- require.NoError(t, err)
- require.Nil(t, selection, "限额中的账号不应继续命中 previous_response_id 粘连")
- boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_rl")
- require.NoError(t, getErr)
- require.Zero(t, boundAccountID)
- }
- func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_DBRuntimeRecheckRateLimitedMiss(t *testing.T) {
- ctx := context.Background()
- groupID := int64(24)
- rateLimitedUntil := time.Now().Add(30 * time.Minute)
- staleAccount := &Account{
- ID: 13,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- },
- }
- dbAccount := Account{
- ID: 13,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- RateLimitResetAt: &rateLimitedUntil,
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- },
- }
- cache := &stubGatewayCache{}
- store := NewOpenAIWSStateStore(cache)
- cfg := newOpenAIWSV2TestConfig()
- snapshotCache := &openAISnapshotCacheStub{
- accountsByID: map[int64]*Account{dbAccount.ID: staleAccount},
- }
- svc := &OpenAIGatewayService{
- accountRepo: stubOpenAIAccountRepo{accounts: []Account{dbAccount}},
- cache: cache,
- cfg: cfg,
- concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
- openaiWSStateStore: store,
- schedulerSnapshot: &SchedulerSnapshotService{cache: snapshotCache},
- }
- require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_db_rl", dbAccount.ID, time.Hour))
- selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_db_rl", "gpt-5.1", nil, false)
- require.NoError(t, err)
- require.Nil(t, selection, "DB 中已限流的账号不应继续命中 previous_response_id 粘连")
- boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_db_rl")
- require.NoError(t, getErr)
- require.Zero(t, boundAccountID)
- }
- func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Excluded(t *testing.T) {
- ctx := context.Background()
- groupID := int64(23)
- account := Account{
- ID: 8,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- },
- }
- cache := &stubGatewayCache{}
- store := NewOpenAIWSStateStore(cache)
- cfg := newOpenAIWSV2TestConfig()
- svc := &OpenAIGatewayService{
- accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
- cache: cache,
- cfg: cfg,
- concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
- openaiWSStateStore: store,
- }
- require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_2", account.ID, time.Hour))
- selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_2", "gpt-5.1", map[int64]struct{}{account.ID: {}}, false)
- require.NoError(t, err)
- require.Nil(t, selection)
- }
- func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_APIKeyForceHTTPHit(t *testing.T) {
- ctx := context.Background()
- groupID := int64(23)
- account := Account{
- ID: 11,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Extra: map[string]any{
- "openai_ws_force_http": true,
- "responses_websockets_v2_enabled": true,
- },
- }
- cache := &stubGatewayCache{}
- store := NewOpenAIWSStateStore(cache)
- cfg := newOpenAIWSV2TestConfig()
- svc := &OpenAIGatewayService{
- accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
- cache: cache,
- cfg: cfg,
- concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
- openaiWSStateStore: store,
- }
- require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_force_http", account.ID, time.Hour))
- selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_force_http", "gpt-5.1", nil, false)
- require.NoError(t, err)
- require.NotNil(t, selection, "API-key HTTP continuation must retain the key/project that created the response")
- require.NotNil(t, selection.Account)
- require.Equal(t, account.ID, selection.Account.ID)
- if selection.ReleaseFunc != nil {
- selection.ReleaseFunc()
- }
- }
- func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_OAuthForceHTTPIgnored(t *testing.T) {
- ctx := context.Background()
- groupID := int64(23)
- account := Account{
- ID: 12,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Extra: map[string]any{
- "openai_ws_force_http": true,
- "responses_websockets_v2_enabled": true,
- },
- }
- cache := &stubGatewayCache{}
- store := NewOpenAIWSStateStore(cache)
- svc := &OpenAIGatewayService{
- accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
- cache: cache,
- cfg: newOpenAIWSV2TestConfig(),
- concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
- openaiWSStateStore: store,
- }
- require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_oauth_force_http", account.ID, time.Hour))
- selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_oauth_force_http", "gpt-5.1", nil, false)
- require.NoError(t, err)
- require.Nil(t, selection, "OAuth HTTP fallback cannot preserve WSv2 continuation state")
- }
- func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_BusyKeepsSticky(t *testing.T) {
- ctx := context.Background()
- groupID := int64(23)
- accounts := []Account{
- {
- ID: 21,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Priority: 0,
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- },
- },
- {
- ID: 22,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Priority: 9,
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- },
- },
- }
- cache := &stubGatewayCache{}
- store := NewOpenAIWSStateStore(cache)
- cfg := newOpenAIWSV2TestConfig()
- cfg.Gateway.Scheduling.StickySessionMaxWaiting = 2
- cfg.Gateway.Scheduling.StickySessionWaitTimeout = 30 * time.Second
- concurrencyCache := stubConcurrencyCache{
- acquireResults: map[int64]bool{
- 21: false, // previous_response 命中的账号繁忙
- 22: true, // 次优账号可用(若回退会命中)
- },
- waitCounts: map[int64]int{
- 21: 999,
- },
- }
- svc := &OpenAIGatewayService{
- accountRepo: stubOpenAIAccountRepo{accounts: accounts},
- cache: cache,
- cfg: cfg,
- concurrencyService: NewConcurrencyService(concurrencyCache),
- openaiWSStateStore: store,
- }
- require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_busy", 21, time.Hour))
- selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_prev_busy", "gpt-5.1", nil, false)
- require.NoError(t, err)
- require.NotNil(t, selection)
- require.NotNil(t, selection.Account)
- require.Equal(t, int64(21), selection.Account.ID, "busy previous_response sticky account should remain selected")
- require.False(t, selection.Acquired)
- require.NotNil(t, selection.WaitPlan)
- require.Equal(t, int64(21), selection.WaitPlan.AccountID)
- }
- func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_CapabilityMismatchKeepsSticky(t *testing.T) {
- ctx := context.Background()
- groupID := int64(25)
- account := Account{
- ID: 31,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: true,
- Concurrency: 1,
- Credentials: map[string]any{
- "openai_capabilities": []any{"chat_completions"},
- },
- Extra: map[string]any{
- "openai_apikey_responses_websockets_v2_enabled": true,
- },
- }
- cache := &stubGatewayCache{}
- store := NewOpenAIWSStateStore(cache)
- cfg := newOpenAIWSV2TestConfig()
- svc := &OpenAIGatewayService{
- accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
- cache: cache,
- cfg: cfg,
- concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
- openaiWSStateStore: store,
- }
- require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_capability", account.ID, time.Hour))
- selection, err := svc.selectAccountByPreviousResponseIDForCapability(
- ctx,
- &groupID,
- "resp_prev_capability",
- "text-embedding-3-small",
- nil,
- OpenAIEndpointCapabilityEmbeddings,
- false,
- )
- require.NoError(t, err)
- require.Nil(t, selection)
- boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_capability")
- require.NoError(t, getErr)
- require.Equal(t, account.ID, boundAccountID)
- }
- func newOpenAIWSV2TestConfig() *config.Config {
- cfg := &config.Config{}
- cfg.Gateway.OpenAIWS.Enabled = true
- cfg.Gateway.OpenAIWS.OAuthEnabled = true
- cfg.Gateway.OpenAIWS.APIKeyEnabled = true
- cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
- cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = 3600
- return cfg
- }
|