diff --git a/frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.providers.spec.ts b/frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.providers.spec.ts new file mode 100644 index 000000000..6b395a406 --- /dev/null +++ b/frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.providers.spec.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, h, nextTick, ref, type App } from 'vue' +import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints' +import type { GlobalModelResponse } from '@/api/global-models' +import RoutingSchedulingPolicyEditor from '../components/RoutingSchedulingPolicyEditor.vue' +import { + createEmptyRoutingGroupConfig, + getModelPolicy, + type RoutingGroupConfig, +} from '../utils/routingPolicy' +import { createSchedulingPolicy, writeSchedulingPolicies } from '../utils/schedulingPolicies' + +vi.mock('@/api/endpoints', () => ({ getProvidersSummary: vi.fn() })) + +const globalModels = ['a', 'b', 'c'].map(name => ({ + id: `id-${name}`, name: `model-${name}`, display_name: `模型 ${name.toUpperCase()}`, +})) as GlobalModelResponse[] +const providerSources = [ + { id: 'provider-a', name: '提供商 A', global_model_ids: ['id-a'] }, + { id: 'provider-b', name: '提供商 B', global_model_ids: ['id-b'] }, + { id: 'provider-shared', name: '共享提供商', global_model_ids: ['id-a', 'id-b'] }, + { id: 'provider-other', name: '无关提供商', global_model_ids: ['id-other'] }, +].map((provider, index) => ({ + ...provider, + provider_priority: index, + is_active: true, + api_formats: ['openai:chat'], +})) as ProviderWithEndpointsSummary[] +const mounted: Array<{ app: App, root: HTMLElement }> = [] + +function mountEditor(selectedModels?: string[], initialModels = globalModels) { + const initial = createEmptyRoutingGroupConfig() + const config = ref(selectedModels === undefined ? initial : writeSchedulingPolicies(initial, [{ + ...createSchedulingPolicy(initial, 'selected'), + models: selectedModels, + }])) + const models = ref(initialModels) + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp({ + setup: () => () => h(RoutingSchedulingPolicyEditor, { + config: config.value, + globalModels: models.value, + 'onUpdate:config': (value: RoutingGroupConfig) => { config.value = value }, + }), + }) + app.mount(root) + mounted.push({ app, root }) + return { root, config, models } +} + +function providerNames(root: HTMLElement): string[] { + return [...root.querySelectorAll('[draggable="true"] .font-medium')] + .map(element => element.textContent?.trim() ?? '') +} + +async function clickButton(root: HTMLElement, label: string) { + const button = [...root.querySelectorAll('button')] + .find(element => element.getAttribute('aria-label') === label || element.textContent?.trim() === label) + expect(button, `Missing button: ${label}`).toBeTruthy() + button!.click() + await nextTick() +} + +async function toggleModel(root: HTMLElement, name: string) { + if (!root.querySelector('[aria-label="全局模型选择列表"]')) { + await clickButton(root, '选择适用模型') + } + const checkbox = root.querySelector(`[aria-label="选择模型 ${name}"]`) + expect(checkbox).toBeTruthy() + checkbox!.click() + await nextTick() +} + +beforeEach(() => { + vi.mocked(getProvidersSummary).mockReset() + vi.mocked(getProvidersSummary).mockResolvedValue({ + items: providerSources, total: providerSources.length, page: 1, page_size: 9999, + }) + vi.stubGlobal('ResizeObserver', class { + observe() {} + unobserve() {} + disconnect() {} + }) +}) + +afterEach(() => { + for (const { app, root } of mounted.splice(0)) { + app.unmount() + root.remove() + } + vi.unstubAllGlobals() +}) + +describe('scheduling provider filtering', () => { + it('filters providers after choosing a global model and restores all-model mode', async () => { + const { root } = mountEditor() + await vi.waitFor(() => expect(providerNames(root)).toHaveLength(4)) + await clickButton(root, '区分模型') + expect(providerNames(root)).toEqual([]) + await toggleModel(root, 'model-a') + await vi.waitFor(() => expect(providerNames(root)).toEqual(['提供商 A', '共享提供商'])) + await clickButton(root, '全部模型') + await vi.waitFor(() => expect(providerNames(root)).toHaveLength(4)) + }) + + it('shows the union for multiple models once and updates immediately when deselected', async () => { + const { root } = mountEditor(['model-a']) + await vi.waitFor(() => expect(providerNames(root)).toEqual(['提供商 A', '共享提供商'])) + await toggleModel(root, 'model-b') + expect(providerNames(root)).toEqual(['提供商 A', '提供商 B', '共享提供商']) + await toggleModel(root, 'model-a') + expect(providerNames(root)).toEqual(['提供商 B', '共享提供商']) + expect(getProvidersSummary).toHaveBeenCalledTimes(1) + await toggleModel(root, 'model-b') + expect(providerNames(root)).toEqual([]) + expect(root.textContent).not.toContain('提供商排序') + }) + + it('keeps the shared ranking attached to every selected model after filtering', async () => { + const { root, config } = mountEditor(['model-a', 'model-b']) + await vi.waitFor(() => expect(providerNames(root)).toHaveLength(3)) + const row = [...root.querySelectorAll('[draggable="true"]')] + .find(element => element.textContent?.includes('共享提供商'))! + const input = row.querySelector('input[type="number"]')! + input.value = '7' + input.dispatchEvent(new Event('change', { bubbles: true })) + await nextTick() + for (const model of ['model-a', 'model-b']) { + expect(getModelPolicy(config.value, model).provider_priority_overrides).toEqual({ 'provider-shared': 7 }) + } + expect(getModelPolicy(config.value, '*').provider_priority_overrides).toEqual({}) + expect(providerNames(root)).toEqual(['提供商 A', '提供商 B', '共享提供商']) + expect(getProvidersSummary).toHaveBeenCalledTimes(1) + }) + + it('waits for global model IDs without briefly displaying all providers', async () => { + const { root, models } = mountEditor(['model-a'], []) + await vi.waitFor(() => expect(root.textContent).toContain('暂无 Provider')) + expect(providerNames(root)).toEqual([]) + models.value = globalModels + await nextTick() + expect(providerNames(root)).toEqual(['提供商 A', '共享提供商']) + expect(getProvidersSummary).toHaveBeenCalledTimes(1) + }) + + it.each(['model-c', 'missing-model'])('shows an empty list when no providers match %s', async model => { + const { root } = mountEditor([model]) + await vi.waitFor(() => expect(root.textContent).toContain('暂无 Provider')) + expect(providerNames(root)).toEqual([]) + }) + + it('drops hidden providers from the temporary multiselection when models change', async () => { + const { root } = mountEditor(['model-a', 'model-b']) + await vi.waitFor(() => expect(providerNames(root)).toHaveLength(3)) + await clickButton(root, '多选') + const checkbox = root.querySelector('[aria-label="选择 提供商 A"]')! + checkbox.click() + await nextTick() + expect(checkbox.checked).toBe(true) + await toggleModel(root, 'model-a') + expect(providerNames(root)).toEqual(['提供商 B', '共享提供商']) + await toggleModel(root, 'model-a') + expect(root.querySelector('[aria-label="选择 提供商 A"]')!.checked).toBe(false) + }) +}) diff --git a/frontend/src/features/routing/components/RoutingPriorityPolicyEditor.vue b/frontend/src/features/routing/components/RoutingPriorityPolicyEditor.vue index b202230a8..ba95177bb 100644 --- a/frontend/src/features/routing/components/RoutingPriorityPolicyEditor.vue +++ b/frontend/src/features/routing/components/RoutingPriorityPolicyEditor.vue @@ -369,6 +369,7 @@ const props = defineProps<{ config: RoutingGroupConfig model?: string modelId?: string + providerModelIds?: string[] priorityMode?: RoutingPriorityMode schedulingMode?: RoutingSchedulingMode showPriorityMode?: boolean @@ -445,7 +446,10 @@ const poolProviderIds = computed(() => { const providerRows = computed(() => { const overrides = targetModelPolicy.value.provider_priority_overrides + // 多选模型取提供商并集;空数组表示模型尚未解析,不能回退到全部提供商。 + const modelIds = props.providerModelIds === undefined ? null : new Set(props.providerModelIds) return providers.value + .filter(provider => !modelIds || provider.global_model_ids?.some(id => modelIds.has(id))) .map(provider => ({ id: provider.id, name: provider.name, diff --git a/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue b/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue index cf5ca8174..5b4872410 100644 --- a/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue +++ b/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue @@ -178,6 +178,7 @@ { : [])) } +function providerModelIds(entry: SchedulingPolicy): string[] { + const names = new Set(entry.models) + return props.globalModels.filter(model => names.has(model.name)).map(model => model.id) +} + function publish(): void { emit('update:config', writeSchedulingPolicies({ ...props.config,