fix: 修复会话限制功能并在创建账号时支持配额控制
This commit is contained in:
@@ -410,11 +410,9 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
|
// SelectAccountWithLoadAwareness selects account with load-awareness and wait plan.
|
||||||
// metadataUserID: 原始 metadata.user_id 字段(用于提取会话 UUID 进行会话数量限制)
|
// metadataUserID: 已废弃参数,会话限制现在统一使用 sessionHash
|
||||||
func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string) (*AccountSelectionResult, error) {
|
func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string) (*AccountSelectionResult, error) {
|
||||||
cfg := s.schedulingConfig()
|
cfg := s.schedulingConfig()
|
||||||
// 提取会话 UUID(用于会话数量限制)
|
|
||||||
sessionUUID := extractSessionUUID(metadataUserID)
|
|
||||||
|
|
||||||
var stickyAccountID int64
|
var stickyAccountID int64
|
||||||
if sessionHash != "" && s.cache != nil {
|
if sessionHash != "" && s.cache != nil {
|
||||||
@@ -440,41 +438,63 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.concurrencyService == nil || !cfg.LoadBatchEnabled {
|
if s.concurrencyService == nil || !cfg.LoadBatchEnabled {
|
||||||
account, err := s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, excludedIDs)
|
// 复制排除列表,用于会话限制拒绝时的重试
|
||||||
if err != nil {
|
localExcluded := make(map[int64]struct{})
|
||||||
return nil, err
|
for k, v := range excludedIDs {
|
||||||
|
localExcluded[k] = v
|
||||||
}
|
}
|
||||||
result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency)
|
|
||||||
if err == nil && result.Acquired {
|
for {
|
||||||
return &AccountSelectionResult{
|
account, err := s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, localExcluded)
|
||||||
Account: account,
|
if err != nil {
|
||||||
Acquired: true,
|
return nil, err
|
||||||
ReleaseFunc: result.ReleaseFunc,
|
}
|
||||||
}, nil
|
|
||||||
}
|
result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency)
|
||||||
if stickyAccountID > 0 && stickyAccountID == account.ID && s.concurrencyService != nil {
|
if err == nil && result.Acquired {
|
||||||
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, account.ID)
|
// 获取槽位后检查会话限制(使用 sessionHash 作为会话标识符)
|
||||||
if waitingCount < cfg.StickySessionMaxWaiting {
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
||||||
|
result.ReleaseFunc() // 释放槽位
|
||||||
|
localExcluded[account.ID] = struct{}{} // 排除此账号
|
||||||
|
continue // 重新选择
|
||||||
|
}
|
||||||
return &AccountSelectionResult{
|
return &AccountSelectionResult{
|
||||||
Account: account,
|
Account: account,
|
||||||
WaitPlan: &AccountWaitPlan{
|
Acquired: true,
|
||||||
AccountID: account.ID,
|
ReleaseFunc: result.ReleaseFunc,
|
||||||
MaxConcurrency: account.Concurrency,
|
|
||||||
Timeout: cfg.StickySessionWaitTimeout,
|
|
||||||
MaxWaiting: cfg.StickySessionMaxWaiting,
|
|
||||||
},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对于等待计划的情况,也需要先检查会话限制
|
||||||
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
||||||
|
localExcluded[account.ID] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if stickyAccountID > 0 && stickyAccountID == account.ID && s.concurrencyService != nil {
|
||||||
|
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, account.ID)
|
||||||
|
if waitingCount < cfg.StickySessionMaxWaiting {
|
||||||
|
return &AccountSelectionResult{
|
||||||
|
Account: account,
|
||||||
|
WaitPlan: &AccountWaitPlan{
|
||||||
|
AccountID: account.ID,
|
||||||
|
MaxConcurrency: account.Concurrency,
|
||||||
|
Timeout: cfg.StickySessionWaitTimeout,
|
||||||
|
MaxWaiting: cfg.StickySessionMaxWaiting,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &AccountSelectionResult{
|
||||||
|
Account: account,
|
||||||
|
WaitPlan: &AccountWaitPlan{
|
||||||
|
AccountID: account.ID,
|
||||||
|
MaxConcurrency: account.Concurrency,
|
||||||
|
Timeout: cfg.FallbackWaitTimeout,
|
||||||
|
MaxWaiting: cfg.FallbackMaxWaiting,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
return &AccountSelectionResult{
|
|
||||||
Account: account,
|
|
||||||
WaitPlan: &AccountWaitPlan{
|
|
||||||
AccountID: account.ID,
|
|
||||||
MaxConcurrency: account.Concurrency,
|
|
||||||
Timeout: cfg.FallbackWaitTimeout,
|
|
||||||
MaxWaiting: cfg.FallbackMaxWaiting,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
platform, hasForcePlatform, err := s.resolvePlatform(ctx, groupID, group)
|
platform, hasForcePlatform, err := s.resolvePlatform(ctx, groupID, group)
|
||||||
@@ -590,7 +610,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
result, err := s.tryAcquireAccountSlot(ctx, stickyAccountID, stickyAccount.Concurrency)
|
result, err := s.tryAcquireAccountSlot(ctx, stickyAccountID, stickyAccount.Concurrency)
|
||||||
if err == nil && result.Acquired {
|
if err == nil && result.Acquired {
|
||||||
// 会话数量限制检查
|
// 会话数量限制检查
|
||||||
if !s.checkAndRegisterSession(ctx, stickyAccount, sessionUUID) {
|
if !s.checkAndRegisterSession(ctx, stickyAccount, sessionHash) {
|
||||||
result.ReleaseFunc() // 释放槽位
|
result.ReleaseFunc() // 释放槽位
|
||||||
// 继续到负载感知选择
|
// 继续到负载感知选择
|
||||||
} else {
|
} else {
|
||||||
@@ -608,15 +628,20 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
|
|
||||||
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, stickyAccountID)
|
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, stickyAccountID)
|
||||||
if waitingCount < cfg.StickySessionMaxWaiting {
|
if waitingCount < cfg.StickySessionMaxWaiting {
|
||||||
return &AccountSelectionResult{
|
// 会话数量限制检查(等待计划也需要占用会话配额)
|
||||||
Account: stickyAccount,
|
if !s.checkAndRegisterSession(ctx, stickyAccount, sessionHash) {
|
||||||
WaitPlan: &AccountWaitPlan{
|
// 会话限制已满,继续到负载感知选择
|
||||||
AccountID: stickyAccountID,
|
} else {
|
||||||
MaxConcurrency: stickyAccount.Concurrency,
|
return &AccountSelectionResult{
|
||||||
Timeout: cfg.StickySessionWaitTimeout,
|
Account: stickyAccount,
|
||||||
MaxWaiting: cfg.StickySessionMaxWaiting,
|
WaitPlan: &AccountWaitPlan{
|
||||||
},
|
AccountID: stickyAccountID,
|
||||||
}, nil
|
MaxConcurrency: stickyAccount.Concurrency,
|
||||||
|
Timeout: cfg.StickySessionWaitTimeout,
|
||||||
|
MaxWaiting: cfg.StickySessionMaxWaiting,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 粘性账号槽位满且等待队列已满,继续使用负载感知选择
|
// 粘性账号槽位满且等待队列已满,继续使用负载感知选择
|
||||||
}
|
}
|
||||||
@@ -677,7 +702,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
|
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
|
||||||
if err == nil && result.Acquired {
|
if err == nil && result.Acquired {
|
||||||
// 会话数量限制检查
|
// 会话数量限制检查
|
||||||
if !s.checkAndRegisterSession(ctx, item.account, sessionUUID) {
|
if !s.checkAndRegisterSession(ctx, item.account, sessionHash) {
|
||||||
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -695,20 +720,26 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 所有路由账号槽位满,返回等待计划(选择负载最低的)
|
// 5. 所有路由账号槽位满,尝试返回等待计划(选择负载最低的)
|
||||||
acc := routingAvailable[0].account
|
// 遍历找到第一个满足会话限制的账号
|
||||||
if s.debugModelRoutingEnabled() {
|
for _, item := range routingAvailable {
|
||||||
log.Printf("[ModelRoutingDebug] routed wait: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), acc.ID)
|
if !s.checkAndRegisterSession(ctx, item.account, sessionHash) {
|
||||||
|
continue // 会话限制已满,尝试下一个
|
||||||
|
}
|
||||||
|
if s.debugModelRoutingEnabled() {
|
||||||
|
log.Printf("[ModelRoutingDebug] routed wait: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
|
||||||
|
}
|
||||||
|
return &AccountSelectionResult{
|
||||||
|
Account: item.account,
|
||||||
|
WaitPlan: &AccountWaitPlan{
|
||||||
|
AccountID: item.account.ID,
|
||||||
|
MaxConcurrency: item.account.Concurrency,
|
||||||
|
Timeout: cfg.StickySessionWaitTimeout,
|
||||||
|
MaxWaiting: cfg.StickySessionMaxWaiting,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
return &AccountSelectionResult{
|
// 所有路由账号会话限制都已满,继续到 Layer 2 回退
|
||||||
Account: acc,
|
|
||||||
WaitPlan: &AccountWaitPlan{
|
|
||||||
AccountID: acc.ID,
|
|
||||||
MaxConcurrency: acc.Concurrency,
|
|
||||||
Timeout: cfg.StickySessionWaitTimeout,
|
|
||||||
MaxWaiting: cfg.StickySessionMaxWaiting,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
// 路由列表中的账号都不可用(负载率 >= 100),继续到 Layer 2 回退
|
// 路由列表中的账号都不可用(负载率 >= 100),继续到 Layer 2 回退
|
||||||
log.Printf("[ModelRouting] All routed accounts unavailable for model=%s, falling back to normal selection", requestedModel)
|
log.Printf("[ModelRouting] All routed accounts unavailable for model=%s, falling back to normal selection", requestedModel)
|
||||||
@@ -728,7 +759,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
||||||
if err == nil && result.Acquired {
|
if err == nil && result.Acquired {
|
||||||
// 会话数量限制检查
|
// 会话数量限制检查
|
||||||
if !s.checkAndRegisterSession(ctx, account, sessionUUID) {
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
||||||
result.ReleaseFunc() // 释放槽位,继续到 Layer 2
|
result.ReleaseFunc() // 释放槽位,继续到 Layer 2
|
||||||
} else {
|
} else {
|
||||||
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
_ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
|
||||||
@@ -742,15 +773,20 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
|
|
||||||
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID)
|
waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID)
|
||||||
if waitingCount < cfg.StickySessionMaxWaiting {
|
if waitingCount < cfg.StickySessionMaxWaiting {
|
||||||
return &AccountSelectionResult{
|
// 会话数量限制检查(等待计划也需要占用会话配额)
|
||||||
Account: account,
|
if !s.checkAndRegisterSession(ctx, account, sessionHash) {
|
||||||
WaitPlan: &AccountWaitPlan{
|
// 会话限制已满,继续到 Layer 2
|
||||||
AccountID: accountID,
|
} else {
|
||||||
MaxConcurrency: account.Concurrency,
|
return &AccountSelectionResult{
|
||||||
Timeout: cfg.StickySessionWaitTimeout,
|
Account: account,
|
||||||
MaxWaiting: cfg.StickySessionMaxWaiting,
|
WaitPlan: &AccountWaitPlan{
|
||||||
},
|
AccountID: accountID,
|
||||||
}, nil
|
MaxConcurrency: account.Concurrency,
|
||||||
|
Timeout: cfg.StickySessionWaitTimeout,
|
||||||
|
MaxWaiting: cfg.StickySessionMaxWaiting,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -799,7 +835,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
|
|
||||||
loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads)
|
loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth, sessionUUID); ok {
|
if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); ok {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -849,7 +885,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
|
result, err := s.tryAcquireAccountSlot(ctx, item.account.ID, item.account.Concurrency)
|
||||||
if err == nil && result.Acquired {
|
if err == nil && result.Acquired {
|
||||||
// 会话数量限制检查
|
// 会话数量限制检查
|
||||||
if !s.checkAndRegisterSession(ctx, item.account, sessionUUID) {
|
if !s.checkAndRegisterSession(ctx, item.account, sessionHash) {
|
||||||
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -869,6 +905,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
// ============ Layer 3: 兜底排队 ============
|
// ============ Layer 3: 兜底排队 ============
|
||||||
sortAccountsByPriorityAndLastUsed(candidates, preferOAuth)
|
sortAccountsByPriorityAndLastUsed(candidates, preferOAuth)
|
||||||
for _, acc := range candidates {
|
for _, acc := range candidates {
|
||||||
|
// 会话数量限制检查(等待计划也需要占用会话配额)
|
||||||
|
if !s.checkAndRegisterSession(ctx, acc, sessionHash) {
|
||||||
|
continue // 会话限制已满,尝试下一个账号
|
||||||
|
}
|
||||||
return &AccountSelectionResult{
|
return &AccountSelectionResult{
|
||||||
Account: acc,
|
Account: acc,
|
||||||
WaitPlan: &AccountWaitPlan{
|
WaitPlan: &AccountWaitPlan{
|
||||||
@@ -882,7 +922,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
|
|||||||
return nil, errors.New("no available accounts")
|
return nil, errors.New("no available accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates []*Account, groupID *int64, sessionHash string, preferOAuth bool, sessionUUID string) (*AccountSelectionResult, bool) {
|
func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates []*Account, groupID *int64, sessionHash string, preferOAuth bool) (*AccountSelectionResult, bool) {
|
||||||
ordered := append([]*Account(nil), candidates...)
|
ordered := append([]*Account(nil), candidates...)
|
||||||
sortAccountsByPriorityAndLastUsed(ordered, preferOAuth)
|
sortAccountsByPriorityAndLastUsed(ordered, preferOAuth)
|
||||||
|
|
||||||
@@ -890,7 +930,7 @@ func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates
|
|||||||
result, err := s.tryAcquireAccountSlot(ctx, acc.ID, acc.Concurrency)
|
result, err := s.tryAcquireAccountSlot(ctx, acc.ID, acc.Concurrency)
|
||||||
if err == nil && result.Acquired {
|
if err == nil && result.Acquired {
|
||||||
// 会话数量限制检查
|
// 会话数量限制检查
|
||||||
if !s.checkAndRegisterSession(ctx, acc, sessionUUID) {
|
if !s.checkAndRegisterSession(ctx, acc, sessionHash) {
|
||||||
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1188,15 +1228,16 @@ checkSchedulability:
|
|||||||
|
|
||||||
// checkAndRegisterSession 检查并注册会话,用于会话数量限制
|
// checkAndRegisterSession 检查并注册会话,用于会话数量限制
|
||||||
// 仅适用于 Anthropic OAuth/SetupToken 账号
|
// 仅适用于 Anthropic OAuth/SetupToken 账号
|
||||||
|
// sessionID: 会话标识符(使用粘性会话的 hash)
|
||||||
// 返回 true 表示允许(在限制内或会话已存在),false 表示拒绝(超出限制且是新会话)
|
// 返回 true 表示允许(在限制内或会话已存在),false 表示拒绝(超出限制且是新会话)
|
||||||
func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *Account, sessionUUID string) bool {
|
func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *Account, sessionID string) bool {
|
||||||
// 只检查 Anthropic OAuth/SetupToken 账号
|
// 只检查 Anthropic OAuth/SetupToken 账号
|
||||||
if !account.IsAnthropicOAuthOrSetupToken() {
|
if !account.IsAnthropicOAuthOrSetupToken() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
maxSessions := account.GetMaxSessions()
|
maxSessions := account.GetMaxSessions()
|
||||||
if maxSessions <= 0 || sessionUUID == "" {
|
if maxSessions <= 0 || sessionID == "" {
|
||||||
return true // 未启用会话限制或无会话ID
|
return true // 未启用会话限制或无会话ID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,7 +1247,7 @@ func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *A
|
|||||||
|
|
||||||
idleTimeout := time.Duration(account.GetSessionIdleTimeoutMinutes()) * time.Minute
|
idleTimeout := time.Duration(account.GetSessionIdleTimeoutMinutes()) * time.Minute
|
||||||
|
|
||||||
allowed, err := s.sessionLimitCache.RegisterSession(ctx, account.ID, sessionUUID, maxSessions, idleTimeout)
|
allowed, err := s.sessionLimitCache.RegisterSession(ctx, account.ID, sessionID, maxSessions, idleTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 失败开放:缓存错误时允许通过
|
// 失败开放:缓存错误时允许通过
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1191,6 +1191,136 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) -->
|
||||||
|
<div
|
||||||
|
v-if="form.platform === 'anthropic' && accountCategory === 'oauth-based'"
|
||||||
|
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.quotaControl.hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Window Cost Limit -->
|
||||||
|
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.windowCost.label') }}</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.quotaControl.windowCost.hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="windowCostEnabled = !windowCostEnabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="windowCostEnabled" class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.limit') }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||||
|
<input
|
||||||
|
v-model.number="windowCostLimit"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
class="input pl-7"
|
||||||
|
:placeholder="t('admin.accounts.quotaControl.windowCost.limitPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.limitHint') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.stickyReserve') }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||||
|
<input
|
||||||
|
v-model.number="windowCostStickyReserve"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
class="input pl-7"
|
||||||
|
:placeholder="t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.stickyReserveHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Limit -->
|
||||||
|
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionLimit.label') }}</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.quotaControl.sessionLimit.hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="sessionLimitEnabled = !sessionLimitEnabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="sessionLimitEnabled" class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessions') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="maxSessions"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessionsHint') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeout') }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model.number="sessionIdleTimeout"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="input pr-12"
|
||||||
|
:placeholder="t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')"
|
||||||
|
/>
|
||||||
|
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">{{ t('common.minutes') }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeoutHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
||||||
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
|
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
|
||||||
@@ -1763,6 +1893,14 @@ const geminiAIStudioOAuthEnabled = ref(false)
|
|||||||
const showAdvancedOAuth = ref(false)
|
const showAdvancedOAuth = ref(false)
|
||||||
const showGeminiHelpDialog = ref(false)
|
const showGeminiHelpDialog = ref(false)
|
||||||
|
|
||||||
|
// Quota control state (Anthropic OAuth/SetupToken only)
|
||||||
|
const windowCostEnabled = ref(false)
|
||||||
|
const windowCostLimit = ref<number | null>(null)
|
||||||
|
const windowCostStickyReserve = ref<number | null>(null)
|
||||||
|
const sessionLimitEnabled = ref(false)
|
||||||
|
const maxSessions = ref<number | null>(null)
|
||||||
|
const sessionIdleTimeout = ref<number | null>(null)
|
||||||
|
|
||||||
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
|
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
|
||||||
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
|
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
|
||||||
const geminiTierGcp = ref<'gcp_standard' | 'gcp_enterprise'>('gcp_standard')
|
const geminiTierGcp = ref<'gcp_standard' | 'gcp_enterprise'>('gcp_standard')
|
||||||
@@ -2140,6 +2278,13 @@ const resetForm = () => {
|
|||||||
customErrorCodeInput.value = null
|
customErrorCodeInput.value = null
|
||||||
interceptWarmupRequests.value = false
|
interceptWarmupRequests.value = false
|
||||||
autoPauseOnExpired.value = true
|
autoPauseOnExpired.value = true
|
||||||
|
// Reset quota control state
|
||||||
|
windowCostEnabled.value = false
|
||||||
|
windowCostLimit.value = null
|
||||||
|
windowCostStickyReserve.value = null
|
||||||
|
sessionLimitEnabled.value = false
|
||||||
|
maxSessions.value = null
|
||||||
|
sessionIdleTimeout.value = null
|
||||||
tempUnschedEnabled.value = false
|
tempUnschedEnabled.value = false
|
||||||
tempUnschedRules.value = []
|
tempUnschedRules.value = []
|
||||||
geminiOAuthType.value = 'code_assist'
|
geminiOAuthType.value = 'code_assist'
|
||||||
@@ -2407,7 +2552,22 @@ const handleAnthropicExchange = async (authCode: string) => {
|
|||||||
...proxyConfig
|
...proxyConfig
|
||||||
})
|
})
|
||||||
|
|
||||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
// Build extra with quota control settings
|
||||||
|
const baseExtra = oauth.buildExtraInfo(tokenInfo) || {}
|
||||||
|
const extra: Record<string, unknown> = { ...baseExtra }
|
||||||
|
|
||||||
|
// Add window cost limit settings
|
||||||
|
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||||||
|
extra.window_cost_limit = windowCostLimit.value
|
||||||
|
extra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add session limit settings
|
||||||
|
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||||||
|
extra.max_sessions = maxSessions.value
|
||||||
|
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||||
|
}
|
||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
...tokenInfo,
|
...tokenInfo,
|
||||||
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||||
@@ -2475,7 +2635,22 @@ const handleCookieAuth = async (sessionKey: string) => {
|
|||||||
...proxyConfig
|
...proxyConfig
|
||||||
})
|
})
|
||||||
|
|
||||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
// Build extra with quota control settings
|
||||||
|
const baseExtra = oauth.buildExtraInfo(tokenInfo) || {}
|
||||||
|
const extra: Record<string, unknown> = { ...baseExtra }
|
||||||
|
|
||||||
|
// Add window cost limit settings
|
||||||
|
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||||||
|
extra.window_cost_limit = windowCostLimit.value
|
||||||
|
extra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add session limit settings
|
||||||
|
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||||||
|
extra.max_sessions = maxSessions.value
|
||||||
|
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||||
|
}
|
||||||
|
|
||||||
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||||
|
|
||||||
// Merge interceptWarmupRequests into credentials
|
// Merge interceptWarmupRequests into credentials
|
||||||
|
|||||||
Reference in New Issue
Block a user