feat: add provider-key concurrent limit (#352)

* feat: add provider-key concurrent limit

* Fix provider key concurrent limit checks

---------

Co-authored-by: fawney19 <elky0401@gmail.com>
This commit is contained in:
Kayphoon
2026-05-03 01:55:23 +08:00
committed by GitHub
parent 11c5884d4f
commit fe27fb17fb
23 changed files with 1827 additions and 62 deletions

View File

@@ -146,6 +146,7 @@ export async function addProviderKey(
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority?: number
rpm_limit?: number | null // RPM 限制(留空=自适应模式)
concurrent_limit?: number | null // 并发请求上限(留空或 0=不限制)
cache_ttl_minutes?: number
max_probe_interval_minutes?: number
allowed_models?: AllowedModels
@@ -177,6 +178,7 @@ export async function updateProviderKey(
internal_priority: number
global_priority_by_format: Record<string, number> | null // 按 API 格式的全局优先级
rpm_limit: number | null // RPM 限制(留空=自适应模式)
concurrent_limit: number | null // 并发请求上限(留空或 0=不限制)
cache_ttl_minutes: number
max_probe_interval_minutes: number
allowed_models: AllowedModels

View File

@@ -243,6 +243,7 @@ export interface EndpointAPIKey {
internal_priority: number // Key 内部优先级
global_priority_by_format?: Record<string, number> | null // 按 endpoint signature 的全局优先级
rpm_limit?: number | null // RPM 速率限制 (1-10000)null 表示自适应模式
concurrent_limit?: number | null // 并发请求上限null/0 表示不限制
allowed_models?: AllowedModels // 允许使用的模型列表null=不限制)
capabilities?: Record<string, boolean> | null // 能力标签配置(如 cache_1h, context_1m
// 缓存与熔断配置
@@ -395,6 +396,7 @@ export interface EndpointAPIKeyUpdate {
internal_priority?: number
global_priority_by_format?: Record<string, number> | null // 按 API 格式的全局优先级
rpm_limit?: number | null // RPM 速率限制 (1-10000)null 表示切换为自适应模式
concurrent_limit?: number | null // 并发请求上限null/0 表示不限制
allowed_models?: AllowedModels
capabilities?: Record<string, boolean> | null
cache_ttl_minutes?: number

View File

@@ -217,6 +217,24 @@
留空自适应
</p>
</div>
<div>
<Label
for="concurrent_limit"
class="text-xs"
>并发请求上限</Label>
<Input
id="concurrent_limit"
:model-value="form.concurrent_limit ?? ''"
type="number"
min="0"
placeholder="不限制"
class="h-8"
@update:model-value="(v) => form.concurrent_limit = parseNullableNumberInput(v, { min: 0 })"
/>
<p class="text-xs text-muted-foreground mt-0.5">
同一时间允许使用该 Key 的最大请求数,留空或 0 表示不限制
</p>
</div>
<div>
<Label
for="cache_ttl_minutes"
@@ -679,6 +697,7 @@ const form = ref({
rate_multipliers: {} as Record<string, number>, // 按 API 格式的成本倍率
internal_priority: 10,
rpm_limit: undefined as number | null | undefined, // RPM 限制null=自适应undefined=保持原值)
concurrent_limit: undefined as number | null | undefined, // 并发请求上限null/0=不限制undefined=保持原值)
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
@@ -786,6 +805,7 @@ function resetForm() {
rate_multipliers: {},
internal_priority: 10,
rpm_limit: undefined,
concurrent_limit: undefined,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
@@ -837,6 +857,7 @@ function loadKeyData() {
internal_priority: props.editingKey.internal_priority ?? 10,
// 保留原始的 null/undefined 状态null 表示自适应模式
rpm_limit: props.editingKey.rpm_limit ?? undefined,
concurrent_limit: props.editingKey.concurrent_limit ?? undefined,
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
@@ -969,6 +990,7 @@ async function handleSave() {
rate_multipliers: rateMultipliersData,
internal_priority: form.value.internal_priority,
rpm_limit: form.value.rpm_limit,
concurrent_limit: form.value.concurrent_limit,
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
@@ -1003,6 +1025,7 @@ async function handleSave() {
rate_multipliers: rateMultipliersData,
internal_priority: form.value.internal_priority,
rpm_limit: form.value.rpm_limit,
concurrent_limit: form.value.concurrent_limit,
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,

View File

@@ -73,6 +73,24 @@
留空自适应
</p>
</div>
<div>
<Label
for="concurrent_limit"
class="text-xs"
>并发请求上限</Label>
<Input
id="concurrent_limit"
:model-value="form.concurrent_limit ?? ''"
type="number"
min="0"
placeholder="不限制"
class="h-8"
@update:model-value="(v) => form.concurrent_limit = parseNullableNumberInput(v, { min: 0 })"
/>
<p class="text-xs text-muted-foreground mt-0.5">
同一时间允许使用该 Key 的最大请求数,留空或 0 表示不限制
</p>
</div>
<div>
<Label
for="cache_ttl_minutes"
@@ -243,6 +261,7 @@ const form = ref({
name: '',
internal_priority: 10,
rpm_limit: undefined as number | null | undefined,
concurrent_limit: undefined as number | null | undefined,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
@@ -278,6 +297,7 @@ function resetForm() {
name: '',
internal_priority: 10,
rpm_limit: undefined,
concurrent_limit: undefined,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
note: '',
@@ -295,6 +315,7 @@ function loadKeyData() {
name: props.editingKey.name,
internal_priority: props.editingKey.internal_priority ?? 10,
rpm_limit: props.editingKey.rpm_limit ?? undefined,
concurrent_limit: props.editingKey.concurrent_limit ?? undefined,
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
@@ -359,6 +380,7 @@ async function handleSave() {
name: form.value.name,
internal_priority: form.value.internal_priority,
rpm_limit: form.value.rpm_limit,
concurrent_limit: form.value.concurrent_limit,
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,

View File

@@ -0,0 +1,371 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App, type Component } from 'vue'
import KeyFormDialog from '@/features/providers/components/KeyFormDialog.vue'
import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDialog.vue'
import type { EndpointAPIKey } from '@/api/endpoints'
const endpointMocks = vi.hoisted(() => ({
addProviderKey: vi.fn(),
updateProviderKey: vi.fn(),
getAllCapabilities: vi.fn(),
sortApiFormats: vi.fn((formats: string[]) => [...formats].sort()),
}))
vi.mock('@/api/endpoints', () => ({
addProviderKey: endpointMocks.addProviderKey,
updateProviderKey: endpointMocks.updateProviderKey,
getAllCapabilities: endpointMocks.getAllCapabilities,
sortApiFormats: endpointMocks.sortApiFormats,
}))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string, tag = 'div') => defineComponent({
name,
setup(_, { slots }) {
return () => h(tag, slots.default?.())
},
})
const Dialog = defineComponent({
name: 'DialogStub',
props: {
modelValue: Boolean,
},
setup(props, { slots }) {
return () => props.modelValue
? h('section', [slots.default?.(), slots.footer?.()])
: null
},
})
const Input = defineComponent({
name: 'InputStub',
inheritAttrs: false,
props: {
modelValue: {
type: [String, Number],
default: '',
},
masked: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
value: props.modelValue ?? '',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
})
const Label = defineComponent({
name: 'LabelStub',
inheritAttrs: false,
props: {
for: String,
},
setup(props, { attrs, slots }) {
return () => h('label', { ...attrs, for: props.for }, slots.default?.())
},
})
const Button = defineComponent({
name: 'ButtonStub',
inheritAttrs: false,
props: {
disabled: Boolean,
variant: String,
},
setup(props, { attrs, slots }) {
return () => h('button', {
...attrs,
disabled: props.disabled,
type: attrs.type ?? 'button',
}, slots.default?.())
},
})
const Switch = defineComponent({
name: 'SwitchStub',
inheritAttrs: false,
props: {
modelValue: Boolean,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
type: 'checkbox',
checked: props.modelValue,
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).checked),
})
},
})
return {
Dialog,
Button,
Input,
Label,
Switch,
Select: passthrough('SelectStub'),
SelectTrigger: passthrough('SelectTriggerStub'),
SelectValue: passthrough('SelectValueStub', 'span'),
SelectContent: passthrough('SelectContentStub'),
SelectItem: passthrough('SelectItemStub'),
}
})
vi.mock('@/components/common/JsonImportInput.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'JsonImportInputStub',
setup() {
return () => h('textarea')
},
}),
}
})
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => ({
confirmWarning: vi.fn().mockResolvedValue(true),
}),
}))
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
CircleHelp: Icon,
Key: Icon,
SquarePen: Icon,
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function createProviderKey(overrides: Partial<EndpointAPIKey> = {}): EndpointAPIKey {
return {
id: 'provider-key-1',
provider_id: 'provider-1',
api_formats: ['openai:chat'],
api_key_masked: 'sk-***',
auth_type: 'api_key',
name: 'Primary key',
rate_multipliers: null,
internal_priority: 10,
rpm_limit: 30,
concurrent_limit: null,
allowed_models: null,
capabilities: null,
cache_ttl_minutes: 5,
max_probe_interval_minutes: 32,
health_score: 100,
consecutive_failures: 0,
request_count: 0,
success_count: 0,
error_count: 0,
success_rate: 1,
avg_response_time_ms: 0,
is_active: true,
note: '',
created_at: '2026-04-27T00:00:00Z',
updated_at: '2026-04-27T00:00:00Z',
auto_fetch_models: false,
model_include_patterns: [],
model_exclude_patterns: [],
...overrides,
}
}
function mountDialog(component: Component, props: Record<string, unknown>) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(component, props)
app.mount(root)
mountedApps.push({ app, root })
return root
}
async function settle() {
await nextTick()
await Promise.resolve()
await nextTick()
}
function findInput(root: HTMLElement, id: string) {
const input = root.querySelector<HTMLInputElement>(`#${id}`)
expect(input).not.toBeNull()
return input as HTMLInputElement
}
function updateInput(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
async function submit(root: HTMLElement) {
const form = root.querySelector('form')
expect(form).not.toBeNull()
form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await settle()
}
function lastUpdatePayload() {
const calls = endpointMocks.updateProviderKey.mock.calls
expect(calls.length).toBeGreaterThan(0)
return calls[calls.length - 1][1] as Record<string, unknown>
}
beforeEach(() => {
endpointMocks.addProviderKey.mockReset()
endpointMocks.updateProviderKey.mockReset()
endpointMocks.getAllCapabilities.mockReset()
endpointMocks.sortApiFormats.mockClear()
endpointMocks.addProviderKey.mockResolvedValue(createProviderKey())
endpointMocks.updateProviderKey.mockResolvedValue(createProviderKey())
endpointMocks.getAllCapabilities.mockResolvedValue([])
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('provider key concurrent_limit form behavior', () => {
it('hydrates and serializes a positive concurrent_limit number from the normal key form', async () => {
const root = mountDialog(KeyFormDialog, {
open: true,
endpoint: null,
editingKey: createProviderKey({ rpm_limit: 42, concurrent_limit: 3 }),
providerId: 'provider-1',
providerType: 'openai',
availableApiFormats: ['openai:chat'],
})
await settle()
const concurrentLimitInput = findInput(root, 'concurrent_limit')
expect(concurrentLimitInput.value).toBe('3')
expect(findInput(root, 'rpm_limit').value).toBe('42')
updateInput(concurrentLimitInput, '5')
await submit(root)
const payload = lastUpdatePayload()
expect(payload.concurrent_limit).toBe(5)
expect(typeof payload.concurrent_limit).toBe('number')
expect(payload.concurrent_limit).not.toBe('')
expect(payload.rpm_limit).toBe(42)
})
it('serializes cleared normal key concurrent_limit as null instead of an empty string', async () => {
const root = mountDialog(KeyFormDialog, {
open: true,
endpoint: null,
editingKey: createProviderKey({ rpm_limit: 24, concurrent_limit: 6 }),
providerId: 'provider-1',
providerType: 'openai',
availableApiFormats: ['openai:chat'],
})
await settle()
updateInput(findInput(root, 'concurrent_limit'), '')
await submit(root)
const payload = lastUpdatePayload()
expect(payload).toHaveProperty('concurrent_limit', null)
expect(payload.concurrent_limit).not.toBe('')
expect(payload.rpm_limit).toBe(24)
})
it('hydrates and serializes a positive concurrent_limit number from the OAuth edit form', async () => {
const root = mountDialog(OAuthKeyEditDialog, {
open: true,
editingKey: createProviderKey({
id: 'oauth-key-1',
auth_type: 'oauth',
name: 'OAuth account',
rpm_limit: 35,
concurrent_limit: 3,
}),
})
await settle()
const concurrentLimitInput = findInput(root, 'concurrent_limit')
expect(concurrentLimitInput.value).toBe('3')
expect(findInput(root, 'rpm_limit').value).toBe('35')
updateInput(concurrentLimitInput, '7')
await submit(root)
const payload = lastUpdatePayload()
expect(endpointMocks.updateProviderKey).toHaveBeenCalledWith('oauth-key-1', expect.any(Object))
expect(payload.concurrent_limit).toBe(7)
expect(typeof payload.concurrent_limit).toBe('number')
expect(payload.concurrent_limit).not.toBe('')
expect(payload.rpm_limit).toBe(35)
})
it('serializes cleared OAuth concurrent_limit as null instead of an empty string', async () => {
const root = mountDialog(OAuthKeyEditDialog, {
open: true,
editingKey: createProviderKey({
id: 'oauth-key-2',
auth_type: 'oauth',
rpm_limit: 18,
concurrent_limit: 4,
}),
})
await settle()
updateInput(findInput(root, 'concurrent_limit'), '')
await submit(root)
const payload = lastUpdatePayload()
expect(payload).toHaveProperty('concurrent_limit', null)
expect(payload.concurrent_limit).not.toBe('')
expect(payload.rpm_limit).toBe(18)
})
it('keeps zero concurrent_limit as a numeric unlimited value', async () => {
const root = mountDialog(OAuthKeyEditDialog, {
open: true,
editingKey: createProviderKey({
id: 'oauth-key-zero',
auth_type: 'oauth',
rpm_limit: 11,
concurrent_limit: 2,
}),
})
await settle()
updateInput(findInput(root, 'concurrent_limit'), '0')
await submit(root)
const payload = lastUpdatePayload()
expect(payload.concurrent_limit).toBe(0)
expect(typeof payload.concurrent_limit).toBe('number')
expect(payload.rpm_limit).toBe(11)
})
})