mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-17 21:34:45 +08:00
fix: 限流账号自动退出调度并优化提示文案
This commit is contained in:
@@ -925,6 +925,7 @@ func (r *accountRepository) SetRateLimited(ctx context.Context, id int64, resetA
|
|||||||
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil {
|
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil {
|
||||||
logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue rate limit failed: account=%d err=%v", id, err)
|
logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue rate limit failed: account=%d err=%v", id, err)
|
||||||
}
|
}
|
||||||
|
r.syncSchedulerAccountSnapshot(ctx, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,6 +1041,7 @@ func (r *accountRepository) ClearRateLimit(ctx context.Context, id int64) error
|
|||||||
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil {
|
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil {
|
||||||
logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue clear rate limit failed: account=%d err=%v", id, err)
|
logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue clear rate limit failed: account=%d err=%v", id, err)
|
||||||
}
|
}
|
||||||
|
r.syncSchedulerAccountSnapshot(ctx, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
|
|||||||
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if shouldClearStickySession(account, req.RequestedModel) || !account.IsOpenAI() {
|
if shouldClearStickySession(account, req.RequestedModel) || !account.IsOpenAI() || !account.IsSchedulable() {
|
||||||
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -687,16 +687,20 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
|
|||||||
|
|
||||||
for i := 0; i < len(selectionOrder); i++ {
|
for i := 0; i < len(selectionOrder); i++ {
|
||||||
candidate := selectionOrder[i]
|
candidate := selectionOrder[i]
|
||||||
result, acquireErr := s.service.tryAcquireAccountSlot(ctx, candidate.account.ID, candidate.account.Concurrency)
|
fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel)
|
||||||
|
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, acquireErr := s.service.tryAcquireAccountSlot(ctx, fresh.ID, fresh.Concurrency)
|
||||||
if acquireErr != nil {
|
if acquireErr != nil {
|
||||||
return nil, len(candidates), topK, loadSkew, acquireErr
|
return nil, len(candidates), topK, loadSkew, acquireErr
|
||||||
}
|
}
|
||||||
if result != nil && result.Acquired {
|
if result != nil && result.Acquired {
|
||||||
if req.SessionHash != "" {
|
if req.SessionHash != "" {
|
||||||
_ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, candidate.account.ID)
|
_ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, fresh.ID)
|
||||||
}
|
}
|
||||||
return &AccountSelectionResult{
|
return &AccountSelectionResult{
|
||||||
Account: candidate.account,
|
Account: fresh,
|
||||||
Acquired: true,
|
Acquired: true,
|
||||||
ReleaseFunc: result.ReleaseFunc,
|
ReleaseFunc: result.ReleaseFunc,
|
||||||
}, len(candidates), topK, loadSkew, nil
|
}, len(candidates), topK, loadSkew, nil
|
||||||
@@ -705,16 +709,23 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
|
|||||||
|
|
||||||
cfg := s.service.schedulingConfig()
|
cfg := s.service.schedulingConfig()
|
||||||
// WaitPlan.MaxConcurrency 使用 Concurrency(非 EffectiveLoadFactor),因为 WaitPlan 控制的是 Redis 实际并发槽位等待。
|
// WaitPlan.MaxConcurrency 使用 Concurrency(非 EffectiveLoadFactor),因为 WaitPlan 控制的是 Redis 实际并发槽位等待。
|
||||||
candidate := selectionOrder[0]
|
for _, candidate := range selectionOrder {
|
||||||
return &AccountSelectionResult{
|
fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel)
|
||||||
Account: candidate.account,
|
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) {
|
||||||
WaitPlan: &AccountWaitPlan{
|
continue
|
||||||
AccountID: candidate.account.ID,
|
}
|
||||||
MaxConcurrency: candidate.account.Concurrency,
|
return &AccountSelectionResult{
|
||||||
Timeout: cfg.FallbackWaitTimeout,
|
Account: fresh,
|
||||||
MaxWaiting: cfg.FallbackMaxWaiting,
|
WaitPlan: &AccountWaitPlan{
|
||||||
},
|
AccountID: fresh.ID,
|
||||||
}, len(candidates), topK, loadSkew, nil
|
MaxConcurrency: fresh.Concurrency,
|
||||||
|
Timeout: cfg.FallbackWaitTimeout,
|
||||||
|
MaxWaiting: cfg.FallbackMaxWaiting,
|
||||||
|
},
|
||||||
|
}, len(candidates), topK, loadSkew, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, len(candidates), topK, loadSkew, errors.New("no available accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *defaultOpenAIAccountScheduler) isAccountTransportCompatible(account *Account, requiredTransport OpenAIUpstreamTransport) bool {
|
func (s *defaultOpenAIAccountScheduler) isAccountTransportCompatible(account *Account, requiredTransport OpenAIUpstreamTransport) bool {
|
||||||
|
|||||||
@@ -12,6 +12,78 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type openAISnapshotCacheStub struct {
|
||||||
|
SchedulerCache
|
||||||
|
snapshotAccounts []*Account
|
||||||
|
accountsByID map[int64]*Account
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *openAISnapshotCacheStub) GetSnapshot(ctx context.Context, bucket SchedulerBucket) ([]*Account, bool, error) {
|
||||||
|
if len(s.snapshotAccounts) == 0 {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
out := make([]*Account, 0, len(s.snapshotAccounts))
|
||||||
|
for _, account := range s.snapshotAccounts {
|
||||||
|
if account == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cloned := *account
|
||||||
|
out = append(out, &cloned)
|
||||||
|
}
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *openAISnapshotCacheStub) GetAccount(ctx context.Context, accountID int64) (*Account, error) {
|
||||||
|
if s.accountsByID == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
account := s.accountsByID[accountID]
|
||||||
|
if account == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cloned := *account
|
||||||
|
return &cloned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyRateLimitedAccountFallsBackToFreshCandidate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
groupID := int64(10101)
|
||||||
|
rateLimitedUntil := time.Now().Add(30 * time.Minute)
|
||||||
|
staleSticky := &Account{ID: 31001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0}
|
||||||
|
staleBackup := &Account{ID: 31002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||||
|
freshSticky := &Account{ID: 31001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, RateLimitResetAt: &rateLimitedUntil}
|
||||||
|
freshBackup := &Account{ID: 31002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||||
|
cache := &stubGatewayCache{sessionBindings: map[string]int64{"openai:session_hash_rate_limited": 31001}}
|
||||||
|
snapshotCache := &openAISnapshotCacheStub{snapshotAccounts: []*Account{staleSticky, staleBackup}, accountsByID: map[int64]*Account{31001: freshSticky, 31002: freshBackup}}
|
||||||
|
snapshotService := &SchedulerSnapshotService{cache: snapshotCache}
|
||||||
|
svc := &OpenAIGatewayService{accountRepo: stubOpenAIAccountRepo{accounts: []Account{*freshSticky, *freshBackup}}, cache: cache, cfg: &config.Config{}, schedulerSnapshot: snapshotService, concurrencyService: NewConcurrencyService(stubConcurrencyCache{})}
|
||||||
|
|
||||||
|
selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_hash_rate_limited", "gpt-5.1", nil, OpenAIUpstreamTransportAny)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, selection)
|
||||||
|
require.NotNil(t, selection.Account)
|
||||||
|
require.Equal(t, int64(31002), selection.Account.ID)
|
||||||
|
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_SkipsFreshlyRateLimitedSnapshotCandidate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
groupID := int64(10102)
|
||||||
|
rateLimitedUntil := time.Now().Add(30 * time.Minute)
|
||||||
|
stalePrimary := &Account{ID: 32001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0}
|
||||||
|
staleSecondary := &Account{ID: 32002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||||
|
freshPrimary := &Account{ID: 32001, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, RateLimitResetAt: &rateLimitedUntil}
|
||||||
|
freshSecondary := &Account{ID: 32002, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||||
|
snapshotCache := &openAISnapshotCacheStub{snapshotAccounts: []*Account{stalePrimary, staleSecondary}, accountsByID: map[int64]*Account{32001: freshPrimary, 32002: freshSecondary}}
|
||||||
|
snapshotService := &SchedulerSnapshotService{cache: snapshotCache}
|
||||||
|
svc := &OpenAIGatewayService{accountRepo: stubOpenAIAccountRepo{accounts: []Account{*freshPrimary, *freshSecondary}}, cfg: &config.Config{}, schedulerSnapshot: snapshotService}
|
||||||
|
|
||||||
|
account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "gpt-5.1", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, account)
|
||||||
|
require.Equal(t, int64(32002), account.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_PreviousResponseSticky(t *testing.T) {
|
func TestOpenAIGatewayService_SelectAccountWithScheduler_PreviousResponseSticky(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
groupID := int64(9)
|
groupID := int64(9)
|
||||||
|
|||||||
@@ -1026,7 +1026,7 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C
|
|||||||
|
|
||||||
// 3. 按优先级 + LRU 选择最佳账号
|
// 3. 按优先级 + LRU 选择最佳账号
|
||||||
// Select by priority + LRU
|
// Select by priority + LRU
|
||||||
selected := s.selectBestAccount(accounts, requestedModel, excludedIDs)
|
selected := s.selectBestAccount(ctx, accounts, requestedModel, excludedIDs)
|
||||||
|
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
if requestedModel != "" {
|
if requestedModel != "" {
|
||||||
@@ -1099,7 +1099,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID
|
|||||||
//
|
//
|
||||||
// selectBestAccount selects the best account from candidates (priority + LRU).
|
// selectBestAccount selects the best account from candidates (priority + LRU).
|
||||||
// Returns nil if no available account.
|
// Returns nil if no available account.
|
||||||
func (s *OpenAIGatewayService) selectBestAccount(accounts []Account, requestedModel string, excludedIDs map[int64]struct{}) *Account {
|
func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, accounts []Account, requestedModel string, excludedIDs map[int64]struct{}) *Account {
|
||||||
var selected *Account
|
var selected *Account
|
||||||
|
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
@@ -1111,27 +1111,20 @@ func (s *OpenAIGatewayService) selectBestAccount(accounts []Account, requestedMo
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调度器快照可能暂时过时,这里重新检查可调度性和平台
|
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, requestedModel)
|
||||||
// Scheduler snapshots can be temporarily stale; re-check schedulability and platform
|
if fresh == nil {
|
||||||
if !acc.IsSchedulable() || !acc.IsOpenAI() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查模型支持
|
|
||||||
// Check model support
|
|
||||||
if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择优先级最高且最久未使用的账号
|
// 选择优先级最高且最久未使用的账号
|
||||||
// Select highest priority and least recently used
|
// Select highest priority and least recently used
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
selected = acc
|
selected = fresh
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.isBetterAccount(acc, selected) {
|
if s.isBetterAccount(fresh, selected) {
|
||||||
selected = acc
|
selected = fresh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1309,13 +1302,17 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
|
|||||||
ordered := append([]*Account(nil), candidates...)
|
ordered := append([]*Account(nil), candidates...)
|
||||||
sortAccountsByPriorityAndLastUsed(ordered, false)
|
sortAccountsByPriorityAndLastUsed(ordered, false)
|
||||||
for _, acc := range ordered {
|
for _, acc := range ordered {
|
||||||
result, err := s.tryAcquireAccountSlot(ctx, acc.ID, acc.Concurrency)
|
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, requestedModel)
|
||||||
|
if fresh == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := s.tryAcquireAccountSlot(ctx, fresh.ID, fresh.Concurrency)
|
||||||
if err == nil && result.Acquired {
|
if err == nil && result.Acquired {
|
||||||
if sessionHash != "" {
|
if sessionHash != "" {
|
||||||
_ = s.setStickySessionAccountID(ctx, groupID, sessionHash, acc.ID, openaiStickySessionTTL)
|
_ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL)
|
||||||
}
|
}
|
||||||
return &AccountSelectionResult{
|
return &AccountSelectionResult{
|
||||||
Account: acc,
|
Account: fresh,
|
||||||
Acquired: true,
|
Acquired: true,
|
||||||
ReleaseFunc: result.ReleaseFunc,
|
ReleaseFunc: result.ReleaseFunc,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -1359,13 +1356,17 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
|
|||||||
shuffleWithinSortGroups(available)
|
shuffleWithinSortGroups(available)
|
||||||
|
|
||||||
for _, item := range available {
|
for _, item := range available {
|
||||||
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
|
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, item.account, requestedModel)
|
||||||
|
if fresh == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := s.tryAcquireAccountSlot(ctx, fresh.ID, fresh.Concurrency)
|
||||||
if err == nil && result.Acquired {
|
if err == nil && result.Acquired {
|
||||||
if sessionHash != "" {
|
if sessionHash != "" {
|
||||||
_ = s.setStickySessionAccountID(ctx, groupID, sessionHash, item.account.ID, openaiStickySessionTTL)
|
_ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL)
|
||||||
}
|
}
|
||||||
return &AccountSelectionResult{
|
return &AccountSelectionResult{
|
||||||
Account: item.account,
|
Account: fresh,
|
||||||
Acquired: true,
|
Acquired: true,
|
||||||
ReleaseFunc: result.ReleaseFunc,
|
ReleaseFunc: result.ReleaseFunc,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -1377,11 +1378,15 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
|
|||||||
// ============ Layer 3: Fallback wait ============
|
// ============ Layer 3: Fallback wait ============
|
||||||
sortAccountsByPriorityAndLastUsed(candidates, false)
|
sortAccountsByPriorityAndLastUsed(candidates, false)
|
||||||
for _, acc := range candidates {
|
for _, acc := range candidates {
|
||||||
|
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, requestedModel)
|
||||||
|
if fresh == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return &AccountSelectionResult{
|
return &AccountSelectionResult{
|
||||||
Account: acc,
|
Account: fresh,
|
||||||
WaitPlan: &AccountWaitPlan{
|
WaitPlan: &AccountWaitPlan{
|
||||||
AccountID: acc.ID,
|
AccountID: fresh.ID,
|
||||||
MaxConcurrency: acc.Concurrency,
|
MaxConcurrency: fresh.Concurrency,
|
||||||
Timeout: cfg.FallbackWaitTimeout,
|
Timeout: cfg.FallbackWaitTimeout,
|
||||||
MaxWaiting: cfg.FallbackMaxWaiting,
|
MaxWaiting: cfg.FallbackMaxWaiting,
|
||||||
},
|
},
|
||||||
@@ -1418,6 +1423,29 @@ func (s *OpenAIGatewayService) tryAcquireAccountSlot(ctx context.Context, accoun
|
|||||||
return s.concurrencyService.AcquireAccountSlot(ctx, accountID, maxConcurrency)
|
return s.concurrencyService.AcquireAccountSlot(ctx, accountID, maxConcurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context.Context, account *Account, requestedModel string) *Account {
|
||||||
|
if account == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fresh := account
|
||||||
|
if s.schedulerSnapshot != nil {
|
||||||
|
current, err := s.getSchedulableAccount(ctx, account.ID)
|
||||||
|
if err != nil || current == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fresh = current
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fresh.IsSchedulable() || !fresh.IsOpenAI() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if requestedModel != "" && !fresh.IsModelSupported(requestedModel) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) {
|
func (s *OpenAIGatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) {
|
||||||
if s.schedulerSnapshot != nil {
|
if s.schedulerSnapshot != nil {
|
||||||
return s.schedulerSnapshot.GetAccount(ctx, accountID)
|
return s.schedulerSnapshot.GetAccount(ctx, accountID)
|
||||||
|
|||||||
@@ -48,6 +48,43 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Hit(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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_Excluded(t *testing.T) {
|
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Excluded(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
groupID := int64(23)
|
groupID := int64(23)
|
||||||
|
|||||||
@@ -3798,7 +3798,7 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID(
|
|||||||
if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 {
|
if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() {
|
if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() || !account.IsSchedulable() {
|
||||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- Rate Limit Display (429) - Two-line layout -->
|
<!-- Rate Limit Display (429) - Two-line layout -->
|
||||||
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
|
||||||
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
|
||||||
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitCountdown }}</span>
|
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitResumeText }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overload Display (529) - Two-line layout -->
|
<!-- Overload Display (529) - Two-line layout -->
|
||||||
@@ -67,9 +67,9 @@
|
|||||||
</span>
|
</span>
|
||||||
<!-- Tooltip -->
|
<!-- Tooltip -->
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||||
>
|
>
|
||||||
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
|
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatDateTime(account.rate_limit_reset_at) }) }}
|
||||||
<div
|
<div
|
||||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||||
></div>
|
></div>
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<!-- Tooltip -->
|
<!-- Tooltip -->
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||||
>
|
>
|
||||||
{{ t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) }}
|
{{ t('admin.accounts.status.modelRateLimitedUntil', { model: formatScopeName(item.model), time: formatTime(item.reset_at) }) }}
|
||||||
<div
|
<div
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<!-- Tooltip -->
|
<!-- Tooltip -->
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-56 -translate-x-1/2 whitespace-normal rounded bg-gray-900 px-3 py-2 text-center text-xs leading-relaxed text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||||
>
|
>
|
||||||
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
|
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
|
||||||
<div
|
<div
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Account } from '@/types'
|
import type { Account } from '@/types'
|
||||||
import { formatCountdownWithSuffix, formatTime } from '@/utils/format'
|
import { formatCountdown, formatDateTime, formatCountdownWithSuffix, formatTime } from '@/utils/format'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -231,7 +231,12 @@ const hasError = computed(() => {
|
|||||||
|
|
||||||
// Computed: countdown text for rate limit (429)
|
// Computed: countdown text for rate limit (429)
|
||||||
const rateLimitCountdown = computed(() => {
|
const rateLimitCountdown = computed(() => {
|
||||||
return formatCountdownWithSuffix(props.account.rate_limit_reset_at)
|
return formatCountdown(props.account.rate_limit_reset_at)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rateLimitResumeText = computed(() => {
|
||||||
|
if (!rateLimitCountdown.value) return ''
|
||||||
|
return t('admin.accounts.status.rateLimitedAutoResume', { time: rateLimitCountdown.value })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Computed: countdown text for overload (529)
|
// Computed: countdown text for overload (529)
|
||||||
|
|||||||
@@ -1694,7 +1694,8 @@ export default {
|
|||||||
rateLimited: 'Rate Limited',
|
rateLimited: 'Rate Limited',
|
||||||
overloaded: 'Overloaded',
|
overloaded: 'Overloaded',
|
||||||
tempUnschedulable: 'Temp Unschedulable',
|
tempUnschedulable: 'Temp Unschedulable',
|
||||||
rateLimitedUntil: 'Rate limited until {time}',
|
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
|
||||||
|
rateLimitedAutoResume: 'Auto resumes in {time}',
|
||||||
modelRateLimitedUntil: '{model} rate limited until {time}',
|
modelRateLimitedUntil: '{model} rate limited until {time}',
|
||||||
overloadedUntil: 'Overloaded until {time}',
|
overloadedUntil: 'Overloaded until {time}',
|
||||||
viewTempUnschedDetails: 'View temp unschedulable details'
|
viewTempUnschedDetails: 'View temp unschedulable details'
|
||||||
|
|||||||
@@ -1853,7 +1853,8 @@ export default {
|
|||||||
rateLimited: '限流中',
|
rateLimited: '限流中',
|
||||||
overloaded: '过载中',
|
overloaded: '过载中',
|
||||||
tempUnschedulable: '临时不可调度',
|
tempUnschedulable: '临时不可调度',
|
||||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
|
||||||
|
rateLimitedAutoResume: '{time} 自动恢复',
|
||||||
modelRateLimitedUntil: '{model} 限流至 {time}',
|
modelRateLimitedUntil: '{model} 限流至 {time}',
|
||||||
overloadedUntil: '负载过重,重置时间:{time}',
|
overloadedUntil: '负载过重,重置时间:{time}',
|
||||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||||
|
|||||||
Reference in New Issue
Block a user