mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 01:17:46 +08:00
feat(providers): add provider transfer limits
This commit is contained in:
@@ -205,6 +205,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提供商内转移限制(仅编辑模式) -->
|
||||
<div
|
||||
v-if="isEditMode"
|
||||
class="grid grid-cols-2 gap-2 sm:gap-4"
|
||||
>
|
||||
<div class="min-w-0 space-y-1.5">
|
||||
<Label
|
||||
for="max-transfer-count"
|
||||
class="whitespace-nowrap text-xs sm:text-sm"
|
||||
>
|
||||
{{ legacyT('最大转移次数') }}
|
||||
</Label>
|
||||
<Input
|
||||
id="max-transfer-count"
|
||||
:model-value="form.max_transfer_count"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
@update:model-value="(v) => form.max_transfer_count = parseNumberInput(v, { min: 0 }) ?? 0"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 space-y-1.5">
|
||||
<Label
|
||||
for="max-transfer-timeout-seconds"
|
||||
class="whitespace-nowrap text-xs sm:text-sm"
|
||||
>
|
||||
{{ legacyT('最大转移超时') }}
|
||||
<span class="text-xs text-muted-foreground">{{ legacyT('(秒)') }}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="max-transfer-timeout-seconds"
|
||||
:model-value="form.max_transfer_timeout_seconds"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
@update:model-value="(v) => form.max_transfer_timeout_seconds = parseNumberInput(v, { min: 0 }) ?? 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 月卡配置 -->
|
||||
<div
|
||||
v-if="form.billing_type === 'monthly_quota'"
|
||||
@@ -410,6 +450,8 @@ const form = ref({
|
||||
concurrent_limit: undefined as number | undefined,
|
||||
// 请求配置
|
||||
max_retries: undefined as number | undefined,
|
||||
max_transfer_count: 0,
|
||||
max_transfer_timeout_seconds: 0,
|
||||
// 超时配置(秒)
|
||||
stream_first_byte_timeout: undefined as number | undefined,
|
||||
request_timeout: undefined as number | undefined,
|
||||
@@ -438,6 +480,8 @@ function resetForm() {
|
||||
concurrent_limit: undefined,
|
||||
// 请求配置
|
||||
max_retries: undefined,
|
||||
max_transfer_count: 0,
|
||||
max_transfer_timeout_seconds: 0,
|
||||
// 超时配置
|
||||
stream_first_byte_timeout: undefined,
|
||||
request_timeout: undefined,
|
||||
@@ -470,6 +514,8 @@ function loadProviderData() {
|
||||
concurrent_limit: undefined,
|
||||
// 请求配置
|
||||
max_retries: props.provider.max_retries ?? undefined,
|
||||
max_transfer_count: props.provider.max_transfer_count ?? 0,
|
||||
max_transfer_timeout_seconds: props.provider.max_transfer_timeout_seconds ?? 0,
|
||||
// 超时配置
|
||||
stream_first_byte_timeout: props.provider.stream_first_byte_timeout ?? undefined,
|
||||
request_timeout: props.provider.request_timeout ?? undefined,
|
||||
@@ -541,6 +587,8 @@ const handleSubmit = async () => {
|
||||
is_active: form.value.is_active,
|
||||
// 请求配置
|
||||
max_retries: form.value.max_retries ?? undefined,
|
||||
max_transfer_count: form.value.max_transfer_count,
|
||||
max_transfer_timeout_seconds: form.value.max_transfer_timeout_seconds,
|
||||
// 超时配置(null 表示清除,使用全局配置)
|
||||
stream_first_byte_timeout: form.value.stream_first_byte_timeout ?? null,
|
||||
request_timeout: form.value.request_timeout ?? null,
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, type App } from 'vue'
|
||||
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints/types'
|
||||
import ProviderFormDialog from '../ProviderFormDialog.vue'
|
||||
|
||||
const endpointMocks = vi.hoisted(() => ({
|
||||
createProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/endpoints', () => ({
|
||||
createProvider: endpointMocks.createProvider,
|
||||
updateProvider: endpointMocks.updateProvider,
|
||||
normalizePoolAdvancedConfig: (value: unknown) => {
|
||||
if (value == null || value === false) return null
|
||||
if (value === true) return {}
|
||||
if (typeof value !== 'object' || Array.isArray(value)) return null
|
||||
return { ...value }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function makeProvider(
|
||||
overrides: Partial<ProviderWithEndpointsSummary> = {},
|
||||
): ProviderWithEndpointsSummary {
|
||||
return {
|
||||
id: 'provider-1',
|
||||
name: 'Provider One',
|
||||
provider_type: 'custom',
|
||||
provider_priority: 100,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
max_transfer_count: 0,
|
||||
max_transfer_timeout_seconds: 0,
|
||||
is_active: true,
|
||||
total_endpoints: 0,
|
||||
active_endpoints: 0,
|
||||
total_keys: 0,
|
||||
active_keys: 0,
|
||||
total_models: 0,
|
||||
active_models: 0,
|
||||
global_model_ids: [],
|
||||
avg_health_score: 1,
|
||||
unhealthy_endpoints: 0,
|
||||
api_formats: [],
|
||||
endpoint_health_details: [],
|
||||
ops_configured: false,
|
||||
created_at: '2026-07-26T00:00:00Z',
|
||||
updated_at: '2026-07-26T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountDialog(provider?: ProviderWithEndpointsSummary | null) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(ProviderFormDialog, {
|
||||
modelValue: true,
|
||||
provider,
|
||||
'onUpdate:modelValue': vi.fn(),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
async function setInput(selector: string, value: string) {
|
||||
const input = document.body.querySelector<HTMLInputElement>(selector)
|
||||
if (!input) throw new Error(`Missing input: ${selector}`)
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function clickButton(text: string) {
|
||||
const button = [...document.body.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(candidate => candidate.textContent?.trim() === text)
|
||||
if (!button) throw new Error(`Missing button: ${text}`)
|
||||
button.click()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
endpointMocks.createProvider.mockReset()
|
||||
endpointMocks.createProvider.mockResolvedValue({ id: 'provider-new', name: 'New Provider' })
|
||||
endpointMocks.updateProvider.mockReset()
|
||||
endpointMocks.updateProvider.mockResolvedValue(makeProvider())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('ProviderFormDialog transfer limits', () => {
|
||||
it('loads and submits configured limits in edit mode', async () => {
|
||||
mountDialog(makeProvider({
|
||||
max_transfer_count: 10,
|
||||
max_transfer_timeout_seconds: 60,
|
||||
}))
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector<HTMLInputElement>('#max-transfer-count')?.value).toBe('10')
|
||||
expect(document.body.querySelector<HTMLInputElement>('#max-transfer-timeout-seconds')?.value).toBe('60')
|
||||
|
||||
await setInput('#max-transfer-count', '12')
|
||||
await setInput('#max-transfer-timeout-seconds', '45')
|
||||
clickButton('保存')
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.updateProvider).toHaveBeenCalledWith(
|
||||
'provider-1',
|
||||
expect.objectContaining({
|
||||
max_transfer_count: 12,
|
||||
max_transfer_timeout_seconds: 45,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults missing legacy values to explicit zero', async () => {
|
||||
mountDialog(makeProvider({
|
||||
max_transfer_count: undefined,
|
||||
max_transfer_timeout_seconds: undefined,
|
||||
}))
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector<HTMLInputElement>('#max-transfer-count')?.value).toBe('0')
|
||||
expect(document.body.querySelector<HTMLInputElement>('#max-transfer-timeout-seconds')?.value).toBe('0')
|
||||
|
||||
clickButton('保存')
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.updateProvider).toHaveBeenCalledWith(
|
||||
'provider-1',
|
||||
expect.objectContaining({
|
||||
max_transfer_count: 0,
|
||||
max_transfer_timeout_seconds: 0,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the controls when creating while still sending zero defaults', async () => {
|
||||
mountDialog(null)
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector('#max-transfer-count')).toBeNull()
|
||||
expect(document.body.querySelector('#max-transfer-timeout-seconds')).toBeNull()
|
||||
|
||||
await setInput('#name', 'New Provider')
|
||||
clickButton('创建')
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.createProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
max_transfer_count: 0,
|
||||
max_transfer_timeout_seconds: 0,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user