Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
9fd95df5
Unverified
Commit
9fd95df5
authored
Feb 28, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 28, 2026
Browse files
Merge pull request #679 from DaydreamCoding/feat/account-rpm-limit
feat: 添加账号级别 RPM(每分钟请求数)限流功能
parents
54de3bf2
212cbbd3
Changes
27
Show whitespace changes
Inline
Side-by-side
frontend/src/components/account/BulkEditAccountModal.vue
View file @
9fd95df5
...
...
@@ -585,6 +585,111 @@
<
/div
>
<
/div
>
<!--
RPM
Limit
(
仅全部为
Anthropic
OAuth
/
SetupToken
时显示
)
-->
<
div
v
-
if
=
"
allAnthropicOAuthOrSetupToken
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
label
id
=
"
bulk-edit-rpm-limit-label
"
class
=
"
input-label mb-0
"
for
=
"
bulk-edit-rpm-limit-enabled
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.label
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableRpmLimit
"
id
=
"
bulk-edit-rpm-limit-enabled
"
type
=
"
checkbox
"
aria
-
controls
=
"
bulk-edit-rpm-limit-body
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/div
>
<
div
id
=
"
bulk-edit-rpm-limit-body
"
:
class
=
"
!enableRpmLimit && 'pointer-events-none opacity-50'
"
role
=
"
group
"
aria
-
labelledby
=
"
bulk-edit-rpm-limit-label
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
span
class
=
"
text-sm text-gray-700 dark:text-gray-300
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.hint
'
)
}}
<
/span
>
<
button
type
=
"
button
"
@
click
=
"
rpmLimitEnabled = !rpmLimitEnabled
"
:
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',
rpmLimitEnabled ? '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',
rpmLimitEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
rpmLimitEnabled
"
class
=
"
space-y-3
"
>
<
div
>
<
label
class
=
"
input-label text-xs
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.baseRpm
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
bulkBaseRpm
"
type
=
"
number
"
min
=
"
1
"
max
=
"
1000
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.baseRpmHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label text-xs
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategy
'
)
}}
<
/label
>
<
div
class
=
"
flex gap-2
"
>
<
button
type
=
"
button
"
@
click
=
"
bulkRpmStrategy = 'tiered'
"
:
class
=
"
[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
bulkRpmStrategy === 'tiered'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyTiered
'
)
}}
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
bulkRpmStrategy = 'sticky_exempt'
"
:
class
=
"
[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
bulkRpmStrategy === 'sticky_exempt'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyStickyExempt
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
div
v
-
if
=
"
bulkRpmStrategy === 'tiered'
"
>
<
label
class
=
"
input-label text-xs
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.stickyBuffer
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
bulkRpmStickyBuffer
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.stickyBufferHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Groups
-->
<
div
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
...
...
@@ -658,7 +763,7 @@ import { ref, watch, computed } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Proxy
as
ProxyConfig
,
AdminGroup
,
AccountPlatform
}
from
'
@/types
'
import
type
{
Proxy
as
ProxyConfig
,
AdminGroup
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
...
...
@@ -670,6 +775,7 @@ interface Props {
show
:
boolean
accountIds
:
number
[]
selectedPlatforms
:
AccountPlatform
[]
selectedTypes
:
AccountType
[]
proxies
:
ProxyConfig
[]
groups
:
AdminGroup
[]
}
...
...
@@ -686,6 +792,15 @@ const appStore = useAppStore()
// Platform awareness
const
isMixedPlatform
=
computed
(()
=>
props
.
selectedPlatforms
.
length
>
1
)
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
const
allAnthropicOAuthOrSetupToken
=
computed
(()
=>
{
return
(
props
.
selectedPlatforms
.
length
===
1
&&
props
.
selectedPlatforms
[
0
]
===
'
anthropic
'
&&
props
.
selectedTypes
.
every
(
t
=>
t
===
'
oauth
'
||
t
===
'
setup-token
'
)
)
}
)
const
platformModelPrefix
:
Record
<
string
,
string
[]
>
=
{
anthropic
:
[
'
claude-
'
],
antigravity
:
[
'
claude-
'
,
'
gemini-
'
,
'
gpt-oss-
'
,
'
tab_
'
],
...
...
@@ -725,6 +840,7 @@ const enablePriority = ref(false)
const
enableRateMultiplier
=
ref
(
false
)
const
enableStatus
=
ref
(
false
)
const
enableGroups
=
ref
(
false
)
const
enableRpmLimit
=
ref
(
false
)
// State - field values
const
submitting
=
ref
(
false
)
...
...
@@ -741,6 +857,10 @@ const priority = ref(1)
const
rateMultiplier
=
ref
(
1
)
const
status
=
ref
<
'
active
'
|
'
inactive
'
>
(
'
active
'
)
const
groupIds
=
ref
<
number
[]
>
([])
const
rpmLimitEnabled
=
ref
(
false
)
const
bulkBaseRpm
=
ref
<
number
|
null
>
(
null
)
const
bulkRpmStrategy
=
ref
<
'
tiered
'
|
'
sticky_exempt
'
>
(
'
tiered
'
)
const
bulkRpmStickyBuffer
=
ref
<
number
|
null
>
(
null
)
// All models list (combined Anthropic + OpenAI + Gemini)
const
allModels
=
[
...
...
@@ -1094,6 +1214,26 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
updates
.
credentials
=
credentials
}
// RPM limit settings (写入 extra 字段)
if
(
enableRpmLimit
.
value
)
{
const
extra
:
Record
<
string
,
unknown
>
=
{
}
if
(
rpmLimitEnabled
.
value
&&
bulkBaseRpm
.
value
!=
null
&&
bulkBaseRpm
.
value
>
0
)
{
extra
.
base_rpm
=
bulkBaseRpm
.
value
extra
.
rpm_strategy
=
bulkRpmStrategy
.
value
if
(
bulkRpmStickyBuffer
.
value
!=
null
&&
bulkRpmStickyBuffer
.
value
>
0
)
{
extra
.
rpm_sticky_buffer
=
bulkRpmStickyBuffer
.
value
}
}
else
{
// 关闭 RPM 限制 - 设置 base_rpm 为 0,并用空值覆盖关联字段
// 后端使用 JSONB || merge 语义,不会删除已有 key,
// 所以必须显式发送空值来重置(后端读取时会 fallback 到默认值)
extra
.
base_rpm
=
0
extra
.
rpm_strategy
=
''
extra
.
rpm_sticky_buffer
=
0
}
updates
.
extra
=
extra
}
return
Object
.
keys
(
updates
).
length
>
0
?
updates
:
null
}
...
...
@@ -1117,7 +1257,8 @@ const handleSubmit = async () => {
enablePriority
.
value
||
enableRateMultiplier
.
value
||
enableStatus
.
value
||
enableGroups
.
value
enableGroups
.
value
||
enableRpmLimit
.
value
if
(
!
hasAnyFieldEnabled
)
{
appStore
.
showError
(
t
(
'
admin.accounts.bulkEdit.noFieldsSelected
'
))
...
...
@@ -1173,6 +1314,7 @@ watch(
enableRateMultiplier
.
value
=
false
enableStatus
.
value
=
false
enableGroups
.
value
=
false
enableRpmLimit
.
value
=
false
// Reset all values
baseUrl
.
value
=
''
...
...
@@ -1188,6 +1330,10 @@ watch(
rateMultiplier
.
value
=
1
status
.
value
=
'
active
'
groupIds
.
value
=
[]
rpmLimitEnabled
.
value
=
false
bulkBaseRpm
.
value
=
null
bulkRpmStrategy
.
value
=
'
tiered
'
bulkRpmStickyBuffer
.
value
=
null
}
}
)
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
9fd95df5
...
...
@@ -1536,6 +1536,98 @@
<
/div
>
<
/div
>
<!--
RPM
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.rpmLimit.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
rpmLimitEnabled = !rpmLimitEnabled
"
:
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',
rpmLimitEnabled ? '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',
rpmLimitEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
rpmLimitEnabled
"
class
=
"
space-y-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.baseRpm
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
baseRpm
"
type
=
"
number
"
min
=
"
1
"
max
=
"
1000
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.baseRpmHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategy
'
)
}}
<
/label
>
<
div
class
=
"
flex gap-2
"
>
<
button
type
=
"
button
"
@
click
=
"
rpmStrategy = 'tiered'
"
:
class
=
"
[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
rpmStrategy === 'tiered'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
<
div
class
=
"
text-center
"
>
<
div
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyTiered
'
)
}}
<
/div
>
<
div
class
=
"
mt-0.5 text-[10px] opacity-70
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyTieredHint
'
)
}}
<
/div
>
<
/div
>
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
rpmStrategy = 'sticky_exempt'
"
:
class
=
"
[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
rpmStrategy === 'sticky_exempt'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
<
div
class
=
"
text-center
"
>
<
div
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyStickyExempt
'
)
}}
<
/div
>
<
div
class
=
"
mt-0.5 text-[10px] opacity-70
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyStickyExemptHint
'
)
}}
<
/div
>
<
/div
>
<
/button
>
<
/div
>
<
/div
>
<
div
v
-
if
=
"
rpmStrategy === 'tiered'
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.stickyBuffer
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
rpmStickyBuffer
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.stickyBufferHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
TLS
Fingerprint
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
...
...
@@ -2393,6 +2485,10 @@ const windowCostStickyReserve = ref<number | null>(null)
const
sessionLimitEnabled
=
ref
(
false
)
const
maxSessions
=
ref
<
number
|
null
>
(
null
)
const
sessionIdleTimeout
=
ref
<
number
|
null
>
(
null
)
const
rpmLimitEnabled
=
ref
(
false
)
const
baseRpm
=
ref
<
number
|
null
>
(
null
)
const
rpmStrategy
=
ref
<
'
tiered
'
|
'
sticky_exempt
'
>
(
'
tiered
'
)
const
rpmStickyBuffer
=
ref
<
number
|
null
>
(
null
)
const
tlsFingerprintEnabled
=
ref
(
false
)
const
sessionIdMaskingEnabled
=
ref
(
false
)
const
cacheTTLOverrideEnabled
=
ref
(
false
)
...
...
@@ -3017,6 +3113,10 @@ const resetForm = () => {
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
rpmLimitEnabled
.
value
=
false
baseRpm
.
value
=
null
rpmStrategy
.
value
=
'
tiered
'
rpmStickyBuffer
.
value
=
null
tlsFingerprintEnabled
.
value
=
false
sessionIdMaskingEnabled
.
value
=
false
cacheTTLOverrideEnabled
.
value
=
false
...
...
@@ -3926,6 +4026,15 @@ const handleAnthropicExchange = async (authCode: string) => {
extra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
// Add RPM limit settings
if
(
rpmLimitEnabled
.
value
&&
baseRpm
.
value
!=
null
&&
baseRpm
.
value
>
0
)
{
extra
.
base_rpm
=
baseRpm
.
value
extra
.
rpm_strategy
=
rpmStrategy
.
value
if
(
rpmStickyBuffer
.
value
!=
null
&&
rpmStickyBuffer
.
value
>
0
)
{
extra
.
rpm_sticky_buffer
=
rpmStickyBuffer
.
value
}
}
// Add TLS fingerprint settings
if
(
tlsFingerprintEnabled
.
value
)
{
extra
.
enable_tls_fingerprint
=
true
...
...
@@ -4024,6 +4133,15 @@ const handleCookieAuth = async (sessionKey: string) => {
extra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
// Add RPM limit settings
if
(
rpmLimitEnabled
.
value
&&
baseRpm
.
value
!=
null
&&
baseRpm
.
value
>
0
)
{
extra
.
base_rpm
=
baseRpm
.
value
extra
.
rpm_strategy
=
rpmStrategy
.
value
if
(
rpmStickyBuffer
.
value
!=
null
&&
rpmStickyBuffer
.
value
>
0
)
{
extra
.
rpm_sticky_buffer
=
rpmStickyBuffer
.
value
}
}
// Add TLS fingerprint settings
if
(
tlsFingerprintEnabled
.
value
)
{
extra
.
enable_tls_fingerprint
=
true
...
...
frontend/src/components/account/EditAccountModal.vue
View file @
9fd95df5
...
...
@@ -946,6 +946,98 @@
<
/div
>
<
/div
>
<!--
RPM
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.rpmLimit.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
rpmLimitEnabled = !rpmLimitEnabled
"
:
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',
rpmLimitEnabled ? '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',
rpmLimitEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
rpmLimitEnabled
"
class
=
"
space-y-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.baseRpm
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
baseRpm
"
type
=
"
number
"
min
=
"
1
"
max
=
"
1000
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.baseRpmHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategy
'
)
}}
<
/label
>
<
div
class
=
"
flex gap-2
"
>
<
button
type
=
"
button
"
@
click
=
"
rpmStrategy = 'tiered'
"
:
class
=
"
[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
rpmStrategy === 'tiered'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
<
div
class
=
"
text-center
"
>
<
div
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyTiered
'
)
}}
<
/div
>
<
div
class
=
"
mt-0.5 text-[10px] opacity-70
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyTieredHint
'
)
}}
<
/div
>
<
/div
>
<
/button
>
<
button
type
=
"
button
"
@
click
=
"
rpmStrategy = 'sticky_exempt'
"
:
class
=
"
[
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
rpmStrategy === 'sticky_exempt'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]
"
>
<
div
class
=
"
text-center
"
>
<
div
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyStickyExempt
'
)
}}
<
/div
>
<
div
class
=
"
mt-0.5 text-[10px] opacity-70
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.strategyStickyExemptHint
'
)
}}
<
/div
>
<
/div
>
<
/button
>
<
/div
>
<
/div
>
<
div
v
-
if
=
"
rpmStrategy === 'tiered'
"
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.stickyBuffer
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
rpmStickyBuffer
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.rpmLimit.stickyBufferHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
TLS
Fingerprint
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
...
...
@@ -1251,6 +1343,10 @@ const windowCostStickyReserve = ref<number | null>(null)
const
sessionLimitEnabled
=
ref
(
false
)
const
maxSessions
=
ref
<
number
|
null
>
(
null
)
const
sessionIdleTimeout
=
ref
<
number
|
null
>
(
null
)
const
rpmLimitEnabled
=
ref
(
false
)
const
baseRpm
=
ref
<
number
|
null
>
(
null
)
const
rpmStrategy
=
ref
<
'
tiered
'
|
'
sticky_exempt
'
>
(
'
tiered
'
)
const
rpmStickyBuffer
=
ref
<
number
|
null
>
(
null
)
const
tlsFingerprintEnabled
=
ref
(
false
)
const
sessionIdMaskingEnabled
=
ref
(
false
)
const
cacheTTLOverrideEnabled
=
ref
(
false
)
...
...
@@ -1710,6 +1806,10 @@ function loadQuotaControlSettings(account: Account) {
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
rpmLimitEnabled
.
value
=
false
baseRpm
.
value
=
null
rpmStrategy
.
value
=
'
tiered
'
rpmStickyBuffer
.
value
=
null
tlsFingerprintEnabled
.
value
=
false
sessionIdMaskingEnabled
.
value
=
false
cacheTTLOverrideEnabled
.
value
=
false
...
...
@@ -1733,6 +1833,14 @@ function loadQuotaControlSettings(account: Account) {
sessionIdleTimeout
.
value
=
account
.
session_idle_timeout_minutes
??
5
}
// RPM limit
if
(
account
.
base_rpm
!=
null
&&
account
.
base_rpm
>
0
)
{
rpmLimitEnabled
.
value
=
true
baseRpm
.
value
=
account
.
base_rpm
rpmStrategy
.
value
=
(
account
.
rpm_strategy
as
'
tiered
'
|
'
sticky_exempt
'
)
||
'
tiered
'
rpmStickyBuffer
.
value
=
account
.
rpm_sticky_buffer
??
null
}
// Load TLS fingerprint setting
if
(
account
.
enable_tls_fingerprint
===
true
)
{
tlsFingerprintEnabled
.
value
=
true
...
...
@@ -2043,6 +2151,21 @@ const handleSubmit = async () => {
delete
newExtra
.
session_idle_timeout_minutes
}
// RPM limit settings
if
(
rpmLimitEnabled
.
value
&&
baseRpm
.
value
!=
null
&&
baseRpm
.
value
>
0
)
{
newExtra
.
base_rpm
=
baseRpm
.
value
newExtra
.
rpm_strategy
=
rpmStrategy
.
value
if
(
rpmStickyBuffer
.
value
!=
null
&&
rpmStickyBuffer
.
value
>
0
)
{
newExtra
.
rpm_sticky_buffer
=
rpmStickyBuffer
.
value
}
else
{
delete
newExtra
.
rpm_sticky_buffer
}
}
else
{
delete
newExtra
.
base_rpm
delete
newExtra
.
rpm_strategy
delete
newExtra
.
rpm_sticky_buffer
}
// TLS fingerprint setting
if
(
tlsFingerprintEnabled
.
value
)
{
newExtra
.
enable_tls_fingerprint
=
true
...
...
frontend/src/i18n/locales/en.ts
View file @
9fd95df5
...
...
@@ -1616,7 +1616,19 @@ export default {
sessions
:
{
full
:
'
Active sessions full, new sessions must wait (idle timeout: {idle} min)
'
,
normal
:
'
Active sessions normal (idle timeout: {idle} min)
'
}
},
rpm
:
{
full
:
'
RPM limit reached
'
,
warning
:
'
RPM approaching limit
'
,
normal
:
'
RPM normal
'
,
tieredNormal
:
'
RPM limit (Tiered) - Normal
'
,
tieredWarning
:
'
RPM limit (Tiered) - Approaching limit
'
,
tieredStickyOnly
:
'
RPM limit (Tiered) - Sticky only | Buffer: {buffer}
'
,
tieredBlocked
:
'
RPM limit (Tiered) - Blocked | Buffer: {buffer}
'
,
stickyExemptNormal
:
'
RPM limit (Sticky Exempt) - Normal
'
,
stickyExemptWarning
:
'
RPM limit (Sticky Exempt) - Approaching limit
'
,
stickyExemptOver
:
'
RPM limit (Sticky Exempt) - Over limit, sticky only
'
},
},
tempUnschedulable
:
{
title
:
'
Temp Unschedulable
'
,
...
...
@@ -1831,6 +1843,22 @@ export default {
idleTimeoutPlaceholder
:
'
5
'
,
idleTimeoutHint
:
'
Sessions will be released after idle timeout
'
},
rpmLimit
:
{
label
:
'
RPM Limit
'
,
hint
:
'
Limit requests per minute to protect upstream accounts
'
,
baseRpm
:
'
Base RPM
'
,
baseRpmPlaceholder
:
'
15
'
,
baseRpmHint
:
'
Max requests per minute, 0 or empty means no limit
'
,
strategy
:
'
RPM Strategy
'
,
strategyTiered
:
'
Tiered Model
'
,
strategyStickyExempt
:
'
Sticky Exempt
'
,
strategyTieredHint
:
'
Green → Yellow → Sticky only → Blocked, progressive throttling
'
,
strategyStickyExemptHint
:
'
Only sticky sessions allowed when over limit
'
,
strategyHint
:
'
Tiered: gradually restrict when exceeded; Sticky Exempt: existing sessions unrestricted
'
,
stickyBuffer
:
'
Sticky Buffer
'
,
stickyBufferPlaceholder
:
'
Default: 20% of base RPM
'
,
stickyBufferHint
:
'
Extra requests allowed for sticky sessions after exceeding base RPM. Leave empty to use default (20% of base RPM, min 1)
'
},
tlsFingerprint
:
{
label
:
'
TLS Fingerprint Simulation
'
,
hint
:
'
Simulate Node.js/Claude Code client TLS fingerprint
'
...
...
frontend/src/i18n/locales/zh.ts
View file @
9fd95df5
...
...
@@ -1667,7 +1667,19 @@ export default {
sessions
:
{
full
:
'
活跃会话已满,新会话需等待(空闲超时:{idle}分钟)
'
,
normal
:
'
活跃会话正常(空闲超时:{idle}分钟)
'
}
},
rpm
:
{
full
:
'
已达 RPM 上限
'
,
warning
:
'
RPM 接近上限
'
,
normal
:
'
RPM 正常
'
,
tieredNormal
:
'
RPM 限制 (三区模型) - 正常
'
,
tieredWarning
:
'
RPM 限制 (三区模型) - 接近阈值
'
,
tieredStickyOnly
:
'
RPM 限制 (三区模型) - 仅粘性会话 | 缓冲区: {buffer}
'
,
tieredBlocked
:
'
RPM 限制 (三区模型) - 已阻塞 | 缓冲区: {buffer}
'
,
stickyExemptNormal
:
'
RPM 限制 (粘性豁免) - 正常
'
,
stickyExemptWarning
:
'
RPM 限制 (粘性豁免) - 接近阈值
'
,
stickyExemptOver
:
'
RPM 限制 (粘性豁免) - 超限,仅粘性会话
'
},
},
clearRateLimit
:
'
清除速率限制
'
,
testConnection
:
'
测试连接
'
,
...
...
@@ -1974,6 +1986,22 @@ export default {
idleTimeoutPlaceholder
:
'
5
'
,
idleTimeoutHint
:
'
会话空闲超时后自动释放
'
},
rpmLimit
:
{
label
:
'
RPM 限制
'
,
hint
:
'
限制每分钟请求数量,保护上游账号
'
,
baseRpm
:
'
基础 RPM
'
,
baseRpmPlaceholder
:
'
15
'
,
baseRpmHint
:
'
每分钟最大请求数,0 或留空表示不限制
'
,
strategy
:
'
RPM 策略
'
,
strategyTiered
:
'
三区模型
'
,
strategyStickyExempt
:
'
粘性豁免
'
,
strategyTieredHint
:
'
绿区→黄区→仅粘性→阻塞,逐步限流
'
,
strategyStickyExemptHint
:
'
超限后仅允许粘性会话
'
,
strategyHint
:
'
三区模型: 超限后逐步限制; 粘性豁免: 已有会话不受限
'
,
stickyBuffer
:
'
粘性缓冲区
'
,
stickyBufferPlaceholder
:
'
默认: base RPM 的 20%
'
,
stickyBufferHint
:
'
超过 base RPM 后,粘性会话额外允许的请求数。为空则使用默认值(base RPM 的 20%,最小为 1)
'
},
tlsFingerprint
:
{
label
:
'
TLS 指纹模拟
'
,
hint
:
'
模拟 Node.js/Claude Code 客户端的 TLS 指纹
'
...
...
frontend/src/types/index.ts
View file @
9fd95df5
...
...
@@ -661,6 +661,11 @@ export interface Account {
max_sessions
?:
number
|
null
session_idle_timeout_minutes
?:
number
|
null
// RPM 限制(仅 Anthropic OAuth/SetupToken 账号有效)
base_rpm
?:
number
|
null
rpm_strategy
?:
string
|
null
rpm_sticky_buffer
?:
number
|
null
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
enable_tls_fingerprint
?:
boolean
|
null
...
...
@@ -675,6 +680,7 @@ export interface Account {
// 运行时状态(仅当启用对应限制时返回)
current_window_cost
?:
number
|
null
// 当前窗口费用
active_sessions
?:
number
|
null
// 当前活跃会话数
current_rpm
?:
number
|
null
// 当前分钟 RPM 计数
}
// Account Usage types
...
...
frontend/src/views/admin/AccountsView.vue
View file @
9fd95df5
...
...
@@ -263,7 +263,7 @@
<
AccountActionMenu
:
show
=
"
menu.show
"
:
account
=
"
menu.acc
"
:
position
=
"
menu.pos
"
@
close
=
"
menu.show = false
"
@
test
=
"
handleTest
"
@
stats
=
"
handleViewStats
"
@
reauth
=
"
handleReAuth
"
@
refresh
-
token
=
"
handleRefresh
"
@
reset
-
status
=
"
handleResetStatus
"
@
clear
-
rate
-
limit
=
"
handleClearRateLimit
"
/>
<
SyncFromCrsModal
:
show
=
"
showSync
"
@
close
=
"
showSync = false
"
@
synced
=
"
reload
"
/>
<
ImportDataModal
:
show
=
"
showImportData
"
@
close
=
"
showImportData = false
"
@
imported
=
"
handleDataImported
"
/>
<
BulkEditAccountModal
:
show
=
"
showBulkEdit
"
:
account
-
ids
=
"
selIds
"
:
selected
-
platforms
=
"
selPlatforms
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
showBulkEdit = false
"
@
updated
=
"
handleBulkUpdated
"
/>
<
BulkEditAccountModal
:
show
=
"
showBulkEdit
"
:
account
-
ids
=
"
selIds
"
:
selected
-
platforms
=
"
selPlatforms
"
:
selected
-
types
=
"
selTypes
"
:
proxies
=
"
proxies
"
:
groups
=
"
groups
"
@
close
=
"
showBulkEdit = false
"
@
updated
=
"
handleBulkUpdated
"
/>
<
TempUnschedStatusModal
:
show
=
"
showTempUnsched
"
:
account
=
"
tempUnschedAcc
"
@
close
=
"
showTempUnsched = false
"
@
reset
=
"
handleTempUnschedReset
"
/>
<
ConfirmDialog
:
show
=
"
showDeleteDialog
"
:
title
=
"
t('admin.accounts.deleteAccount')
"
:
message
=
"
t('admin.accounts.deleteConfirm', { name: deletingAcc?.name
}
)
"
:
confirm
-
text
=
"
t('common.delete')
"
:
cancel
-
text
=
"
t('common.cancel')
"
:
danger
=
"
true
"
@
confirm
=
"
confirmDelete
"
@
cancel
=
"
showDeleteDialog = false
"
/>
<
ConfirmDialog
:
show
=
"
showExportDataDialog
"
:
title
=
"
t('admin.accounts.dataExport')
"
:
message
=
"
t('admin.accounts.dataExportConfirmMessage')
"
:
confirm
-
text
=
"
t('admin.accounts.dataExportConfirm')
"
:
cancel
-
text
=
"
t('common.cancel')
"
@
confirm
=
"
handleExportData
"
@
cancel
=
"
showExportDataDialog = false
"
>
...
...
@@ -307,7 +307,7 @@ import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ErrorPassthroughRulesModal
from
'
@/components/admin/ErrorPassthroughRulesModal.vue
'
import
{
formatDateTime
,
formatRelativeTime
}
from
'
@/utils/format
'
import
type
{
Account
,
AccountPlatform
,
Proxy
,
AdminGroup
,
WindowStats
}
from
'
@/types
'
import
type
{
Account
,
AccountPlatform
,
AccountType
,
Proxy
,
AdminGroup
,
WindowStats
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
...
...
@@ -324,6 +324,14 @@ const selPlatforms = computed<AccountPlatform[]>(() => {
)
return
[...
platforms
]
}
)
const
selTypes
=
computed
<
AccountType
[]
>
(()
=>
{
const
types
=
new
Set
(
accounts
.
value
.
filter
(
a
=>
selIds
.
value
.
includes
(
a
.
id
))
.
map
(
a
=>
a
.
type
)
)
return
[...
types
]
}
)
const
showCreate
=
ref
(
false
)
const
showEdit
=
ref
(
false
)
const
showSync
=
ref
(
false
)
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment