"frontend/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "5fa22fdf824ee2f2db25b2864b7e66604998da9c"
Unverified Commit 9fd95df5 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #679 from DaydreamCoding/feat/account-rpm-limit

feat: 添加账号级别 RPM(每分钟请求数)限流功能
parents 54de3bf2 212cbbd3
......@@ -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
}
}
)
......
......@@ -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
......
......@@ -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
......
......@@ -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'
......
......@@ -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 指纹'
......
......@@ -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
......
......@@ -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)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment