feat(routing): simplify model scheduling configuration

This commit is contained in:
elky
2026-09-10 16:08:27 +08:00
parent 95e4d0149c
commit 531f53b443
11 changed files with 1955 additions and 673 deletions
@@ -0,0 +1,611 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref, type App } from 'vue'
import type { GlobalModelResponse } from '@/api/global-models'
import RoutingSchedulingPolicyEditor from '../components/RoutingSchedulingPolicyEditor.vue'
import {
createEmptyRoutingGroupConfig,
getDefaultModelPolicy,
getModelPolicy,
getModelScheduling,
setDefaultProviderPriorityOverrides,
type RoutingGroupConfig,
} from '../utils/routingPolicy'
import { createSchedulingPolicy, readSchedulingPolicies, writeSchedulingPolicies } from '../utils/schedulingPolicies'
vi.mock('../components/RoutingPriorityPolicyEditor.vue', () => ({
default: {
props: ['config'],
emits: ['update:config'],
setup: (props: { config: RoutingGroupConfig }, { emit }: { emit: (event: string, config: RoutingGroupConfig) => void }) => () => h('button', {
'aria-label': '调整排序',
onClick: () => emit('update:config', setDefaultProviderPriorityOverrides(props.config, { provider: 7 })),
}, '调整排序'),
},
}))
const mounted: Array<{ app: App, root: HTMLElement }> = []
function mountEditor(initial = createEmptyRoutingGroupConfig()) {
const config = ref(initial)
const valid = ref(true)
const disabled = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
const reload = vi.fn()
const models = ref(['a', 'b', 'c'].map(name => ({
id: `id-${name}`, name: `model-${name}`, display_name: `模型 ${name.toUpperCase()}`,
})) as GlobalModelResponse[])
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
setup: () => () => h(RoutingSchedulingPolicyEditor, {
config: config.value,
disabled: disabled.value,
globalModels: models.value,
loadingModels: loading.value,
modelsError: error.value,
'onUpdate:config': (value: RoutingGroupConfig) => { config.value = value },
onValidityChange: (value: boolean) => { valid.value = value },
onReloadModels: reload,
}),
})
app.mount(root)
mounted.push({ app, root })
return { root, config, valid, disabled, loading, error, models, reload }
}
function control<T extends HTMLElement>(root: HTMLElement, label: string): T {
const element = root.querySelector<T>(`[aria-label="${label}"]`)
if (!element) throw new Error(`Missing control: ${label}`)
return element
}
async function clickText(root: HTMLElement, text: string) {
const element = [...root.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === text)
if (!element) throw new Error(`Missing button: ${text}`)
element.click()
await flush()
}
async function select(root: HTMLElement, model: string) {
await openModels(root)
control<HTMLInputElement>(root, `选择模型 ${model}`).click()
await flush()
}
async function flush() {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
await nextTick()
}
async function openModels(root: HTMLElement) {
if (control(root, '全部模型').getAttribute('aria-pressed') === 'true') {
await clickText(root, '区分模型')
}
if (root.querySelector('[aria-label="全局模型选择列表"]')) return
control<HTMLButtonElement>(root, '选择适用模型').click()
await flush()
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
})
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
vi.unstubAllGlobals()
})
describe('RoutingSchedulingPolicyEditor', () => {
it('starts with one all-model configuration and no model picker or add control', async () => {
const { root, config, valid } = mountEditor()
expect(control(root, '调度范围').getAttribute('role')).toBe('group')
expect(control(root, '全部模型').getAttribute('aria-pressed')).toBe('true')
expect(control(root, '区分模型').getAttribute('aria-pressed')).toBe('false')
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(1)
expect(root.querySelector('[aria-label="选择适用模型"]')).toBeNull()
expect(root.querySelector('[aria-label="添加调度配置"]')).toBeNull()
expect(valid.value).toBe(true)
await clickText(root, '负载均衡')
expect(readSchedulingPolicies(config.value)).toHaveLength(1)
expect(readSchedulingPolicies(config.value)[0]).toMatchObject({ scope: 'all', models: [] })
expect(getModelScheduling(config.value, 'future-model').scheduling_mode).toBe('load_balance')
})
it('selects models first and then configures their shared scheduling and ranking', async () => {
const { root, config, valid } = mountEditor()
const focusedControl = control<HTMLButtonElement>(root, '区分模型')
focusedControl.focus()
await clickText(root, '区分模型')
const picker = control<HTMLButtonElement>(root, '选择适用模型')
expect(control<HTMLButtonElement>(root, '添加调度配置').disabled).toBe(true)
expect(valid.value).toBe(false)
const list = control(root, '全局模型选择列表')
expect(list.getAttribute('role')).toBe('region')
expect(list.id).toBeTruthy()
expect(picker.getAttribute('aria-controls')).toBe(list.id)
expect(picker.getAttribute('aria-expanded')).toBe('true')
expect(document.querySelector('[role="dialog"][aria-label="选择适用模型"]')).toBeNull()
expect(document.activeElement).toBe(focusedControl)
expect(document.querySelector('button[aria-label="指定全局模型"]')).toBeNull()
expect(control(root, '全部模型').getAttribute('aria-pressed')).toBe('false')
expect(list.querySelector('[aria-label="全部模型"]')).toBeNull()
expect(control<HTMLInputElement>(root, '选择模型 model-a').checked).toBe(false)
await select(root, 'model-a')
await select(root, 'model-b')
await clickText(root, '完成选择')
expect(picker.getAttribute('aria-expanded')).toBe('false')
expect(root.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(document.activeElement).toBe(picker)
await clickText(root, 'Key')
await clickText(root, '固定顺序')
control<HTMLButtonElement>(root, '调整排序').click()
await nextTick()
expect(valid.value).toBe(true)
expect(readSchedulingPolicies(config.value)).toHaveLength(1)
for (const model of ['model-a', 'model-b']) {
expect(getModelScheduling(config.value, model)).toMatchObject({ priority_mode: 'global_key', scheduling_mode: 'fixed_order' })
expect(getModelPolicy(config.value, model).provider_priority_overrides).toEqual({ provider: 7 })
}
expect(getModelScheduling(config.value, 'model-c')).toMatchObject({ priority_mode: 'provider', scheduling_mode: 'cache_affinity' })
expect(getModelPolicy(config.value, '*').provider_priority_overrides).toEqual({})
expect(control<HTMLButtonElement>(root, '添加调度配置').disabled).toBe(false)
expect([...root.querySelectorAll('h4')].map(heading => heading.textContent?.trim())).toEqual(['适用模型', '调度设置'])
})
it('keeps the inline picker open while editing scheduling outside it', async () => {
const { root, config } = mountEditor()
await openModels(root)
await select(root, 'model-a')
const list = control(root, '全局模型选择列表')
const schedulingButton = [...root.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === '负载均衡')!
schedulingButton.focus()
schedulingButton.click()
await flush()
expect(control(root, '全局模型选择列表')).toBe(list)
expect(control(root, '选择适用模型').getAttribute('aria-expanded')).toBe('true')
expect(document.activeElement).toBe(schedulingButton)
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('load_balance')
})
it('expands a newly mounted empty configuration without moving focus', async () => {
const { root, valid } = mountEditor()
await select(root, 'model-a')
await clickText(root, '完成选择')
const focusedControl = root.appendChild(document.createElement('button'))
focusedControl.focus()
control<HTMLButtonElement>(root, '添加调度配置').click()
await flush()
expect(valid.value).toBe(false)
expect(control(root, '选择适用模型').getAttribute('aria-expanded')).toBe('true')
expect(control(root, '全局模型选择列表').getAttribute('role')).toBe('region')
expect(document.activeElement).toBe(focusedControl)
})
it('selects or clears only matching search results, keeping hidden selections intact', async () => {
const { root, config } = mountEditor()
await openModels(root)
await select(root, 'model-a')
const search = control<HTMLInputElement>(root, '搜索全局模型')
search.value = '模型 B'
search.dispatchEvent(new Event('input', { bubbles: true }))
await flush()
control<HTMLButtonElement>(root, '全选搜索结果').click()
await flush()
expect(readSchedulingPolicies(config.value)[0].models).toEqual(['model-a', 'model-b'])
control<HTMLButtonElement>(root, '全选搜索结果').click()
await flush()
expect(readSchedulingPolicies(config.value)[0].models).toEqual(['model-a'])
})
it('keeps list order stable while selecting models and displays their names when collapsed', async () => {
const { root } = mountEditor()
await openModels(root)
const names = () => [...document.querySelectorAll<HTMLInputElement>('[aria-label="全局模型选择列表"] input[aria-label^="选择模型 "]')]
.map(input => input.getAttribute('aria-label'))
const originalOrder = names()
await select(root, 'model-c')
await select(root, 'model-a')
expect(names()).toEqual(originalOrder)
expect(control<HTMLInputElement>(root, '选择模型 model-c').checked).toBe(true)
expect(control<HTMLInputElement>(root, '选择模型 model-a').checked).toBe(true)
await clickText(root, '完成选择')
control<HTMLButtonElement>(root, '收起调度配置 1').click()
await nextTick()
expect(control<HTMLButtonElement>(root, '展开调度配置 1').textContent).toContain('模型 C、模型 A')
})
it('shows the selected value in the form field and keeps model checkboxes in sync', async () => {
const { root } = mountEditor()
await openModels(root)
const picker = control<HTMLButtonElement>(root, '选择适用模型')
expect(picker.textContent).toContain('请选择全局模型')
await select(root, 'model-a')
expect(picker.textContent).toContain('模型 A')
await select(root, 'model-b')
expect(picker.textContent).toContain('模型 A、模型 B')
await select(root, 'model-c')
expect(picker.textContent).toContain('已选择 3 个模型')
await clickText(root, '完成选择')
await select(root, 'model-b')
expect(picker.textContent).toContain('模型 A、模型 C')
expect(control<HTMLInputElement>(root, '选择模型 model-b').checked).toBe(false)
await clickText(root, '清空已选')
expect(picker.textContent).toContain('请选择全局模型')
expect(control<HTMLInputElement>(root, '选择模型 model-a').checked).toBe(false)
expect(control<HTMLInputElement>(root, '选择模型 model-c').checked).toBe(false)
})
it('closes with Escape without losing selections and restores focus to the picker', async () => {
const { root, config } = mountEditor()
await openModels(root)
await select(root, 'model-a')
const search = control<HTMLInputElement>(root, '搜索全局模型')
search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flush()
expect(document.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(readSchedulingPolicies(config.value)[0].models).toEqual(['model-a'])
await vi.waitFor(() => expect(document.activeElement).toBe(control(root, '选择适用模型')))
})
it('clears selections without discarding scheduling settings', async () => {
const { root, config, valid } = mountEditor()
await openModels(root)
await select(root, 'model-a')
await clickText(root, '完成选择')
await clickText(root, '固定顺序')
await openModels(root)
await clickText(root, '清空已选')
expect(valid.value).toBe(false)
expect(control(root, '选择适用模型').textContent).toContain('请选择全局模型')
await select(root, 'model-b')
expect(valid.value).toBe(true)
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('fixed_order')
})
it('closes the inline picker while saving and does not modify config on a no-op click', async () => {
const { root, config, disabled } = mountEditor()
const original = JSON.stringify(config.value)
await clickText(root, '全部模型')
await clickText(root, '缓存亲和')
expect(JSON.stringify(config.value)).toBe(original)
await openModels(root)
await select(root, 'model-a')
disabled.value = true
await flush()
expect(document.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(control<HTMLButtonElement>(root, '选择适用模型').disabled).toBe(true)
})
it('adds another strategy for remaining models and prevents duplicate assignment', async () => {
const { root, config, valid } = mountEditor()
await openModels(root)
await select(root, 'model-a')
control<HTMLButtonElement>(root, '添加调度配置').click()
await flush()
expect(valid.value).toBe(false)
expect(document.querySelector('input[aria-label="选择模型 model-a"]')).toBeNull()
const search = control<HTMLInputElement>(root, '搜索全局模型')
search.value = 'model-a'
search.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(control<HTMLInputElement>(root, '选择模型 model-a').disabled).toBe(true)
search.value = ''
search.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
control<HTMLButtonElement>(root, '选择当前列表').click()
await flush()
await clickText(root, '完成选择')
await clickText(root, '负载均衡')
expect(valid.value).toBe(true)
const entries = readSchedulingPolicies(config.value)
expect(entries.map(entry => entry.models)).toEqual([['model-a'], ['model-b', 'model-c']])
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('cache_affinity')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
expect(control<HTMLButtonElement>(root, '添加调度配置').disabled).toBe(true)
})
it('inherits all-model settings on first entering model-specific mode and restores both drafts', async () => {
const { root, config, valid } = mountEditor()
await clickText(root, '负载均衡')
await clickText(root, 'Key')
control<HTMLButtonElement>(root, '调整排序').click()
await flush()
await clickText(root, '区分模型')
expect(valid.value).toBe(false)
expect(control(root, '选择适用模型').textContent).toContain('请选择全局模型')
await select(root, 'model-a')
expect(readSchedulingPolicies(config.value)).toHaveLength(1)
expect(getModelScheduling(config.value, 'model-a')).toMatchObject({ priority_mode: 'global_key', scheduling_mode: 'load_balance' })
expect(getModelPolicy(config.value, 'model-a').provider_priority_overrides).toEqual({ provider: 7 })
expect(getModelScheduling(config.value, 'new-model').scheduling_mode).toBe('cache_affinity')
await clickText(root, '固定顺序')
await clickText(root, '全部模型')
expect(valid.value).toBe(true)
expect(config.value.rules).toEqual([])
expect(config.value.model_policies.map(policy => policy.model)).toEqual(['*'])
expect(root.querySelector('[aria-label="添加调度配置"]')).toBeNull()
expect(getModelScheduling(config.value, 'new-model').scheduling_mode).toBe('load_balance')
await clickText(root, '区分模型')
expect(readSchedulingPolicies(config.value)[0].models).toEqual(['model-a'])
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
expect(getModelScheduling(config.value, 'new-model').scheduling_mode).toBe('cache_affinity')
await openModels(root)
expect(control<HTMLInputElement>(root, '选择模型 model-a').checked).toBe(true)
})
it('uses the first model-specific configuration when first switching multiple entries to all models', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(initial), models: ['model-b'], schedulingMode: 'load_balance' as const }
const { root, config } = mountEditor(writeSchedulingPolicies(initial, [first, second]))
await clickText(root, '全部模型')
expect(readSchedulingPolicies(config.value)).toHaveLength(1)
expect(readSchedulingPolicies(config.value)[0]).toMatchObject({ scope: 'all', models: [], schedulingMode: 'fixed_order' })
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(1)
expect(config.value.rules).toEqual([])
expect(config.value.model_policies.map(policy => policy.model)).toEqual(['*'])
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('fixed_order')
await clickText(root, '区分模型')
expect(readSchedulingPolicies(config.value).map(entry => entry.models)).toEqual([['model-a'], ['model-b']])
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
})
it('restores an unfinished model-specific draft after temporarily using all models', async () => {
const { root, config, valid } = mountEditor()
await select(root, 'model-a')
await clickText(root, '固定顺序')
control<HTMLButtonElement>(root, '添加调度配置').click()
await flush()
expect(valid.value).toBe(false)
await clickText(root, '全部模型')
expect(valid.value).toBe(true)
await clickText(root, '区分模型')
expect(valid.value).toBe(false)
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(2)
expect(control<HTMLButtonElement>(root, '添加调度配置').disabled).toBe(true)
control<HTMLButtonElement>(root, '展开调度配置 2').click()
await flush()
expect(control(root, '选择适用模型').textContent).toContain('请选择全局模型')
await select(root, 'model-b')
expect(valid.value).toBe(true)
expect(readSchedulingPolicies(config.value).map(entry => entry.models)).toEqual([['model-a'], ['model-b']])
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
})
it('offers all models independently of the catalog and automatically covers future models', async () => {
const initial = createEmptyRoutingGroupConfig()
const entry = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'load_balance' as const }
const { root, config, models, loading, error, valid } = mountEditor(writeSchedulingPolicies(initial, [entry]))
control<HTMLButtonElement>(root, '调整排序').click()
await nextTick()
models.value = []
loading.value = true
await flush()
expect(control<HTMLButtonElement>(root, '全部模型').disabled).toBe(false)
loading.value = false
error.value = '模型加载失败'
await flush()
await clickText(root, '全部模型')
expect(valid.value).toBe(true)
expect(control(root, '全部模型').getAttribute('aria-pressed')).toBe('true')
expect(root.querySelector('[aria-label="选择适用模型"]')).toBeNull()
expect(root.querySelector('[aria-label="添加调度配置"]')).toBeNull()
const saved = JSON.stringify(config.value)
error.value = null
models.value = [{ id: 'id-new', name: 'new-model', display_name: '新增模型' }] as GlobalModelResponse[]
await flush()
expect(JSON.stringify(config.value)).toBe(saved)
expect(readSchedulingPolicies(JSON.parse(saved))[0]).toMatchObject({ scope: 'all', models: [] })
expect(config.value.rules).toEqual([])
expect(config.value.model_policies.map(policy => policy.model)).toEqual(['*'])
expect(getModelScheduling(config.value, 'new-model').scheduling_mode).toBe('load_balance')
expect(getDefaultModelPolicy(config.value).provider_priority_overrides).toEqual({ provider: 7 })
expect(control(root, '全部模型').getAttribute('aria-pressed')).toBe('true')
expect(root.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
})
it('keeps selecting the current list distinct from the all-model scope', async () => {
const { root, config, models } = mountEditor()
await openModels(root)
control<HTMLButtonElement>(root, '选择当前列表').click()
await flush()
expect(control(root, '全部模型').getAttribute('aria-pressed')).toBe('false')
expect(readSchedulingPolicies(config.value)[0]).toMatchObject({ scope: 'selected', models: ['model-a', 'model-b', 'model-c'] })
await clickText(root, '完成选择')
await clickText(root, '负载均衡')
const saved = JSON.stringify(config.value)
models.value.push({ id: 'id-new', name: 'new-model', display_name: '新增模型' } as GlobalModelResponse)
await flush()
expect(JSON.stringify(config.value)).toBe(saved)
expect(getModelScheduling(config.value, 'new-model').scheduling_mode).toBe('cache_affinity')
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('load_balance')
await openModels(root)
expect(control<HTMLInputElement>(root, '选择模型 new-model').checked).toBe(false)
})
it('preserves a legacy all-model fallback and allows more model-specific configurations', async () => {
const initial = createEmptyRoutingGroupConfig()
const selected = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const fallback = { ...createSchedulingPolicy(initial, 'all'), schedulingMode: 'load_balance' as const }
const { root, config } = mountEditor(writeSchedulingPolicies(initial, [selected, fallback]))
const saved = JSON.stringify(config.value)
expect(control(root, '区分模型').getAttribute('aria-pressed')).toBe('true')
expect(control(root, '展开调度配置 2').textContent).toContain('默认配置')
expect(control<HTMLButtonElement>(root, '添加调度配置').disabled).toBe(false)
control<HTMLButtonElement>(root, '展开调度配置 2').click()
await flush()
expect(root.querySelector('[aria-label="选择适用模型"]')).toBeNull()
expect(JSON.stringify(config.value)).toBe(saved)
control<HTMLButtonElement>(root, '添加调度配置').click()
await flush()
await select(root, 'model-b')
await clickText(root, '缓存亲和')
expect(readSchedulingPolicies(config.value)).toHaveLength(3)
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('cache_affinity')
expect(getModelScheduling(config.value, 'future-model').scheduling_mode).toBe('load_balance')
})
it('uses the legacy fallback when switching mixed configurations to all models', async () => {
const initial = createEmptyRoutingGroupConfig()
const selected = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const fallback = { ...createSchedulingPolicy(initial, 'all'), schedulingMode: 'load_balance' as const }
const { root, config } = mountEditor(writeSchedulingPolicies(initial, [selected, fallback]))
await clickText(root, '全部模型')
expect(readSchedulingPolicies(config.value)).toHaveLength(1)
expect(config.value.rules).toEqual([])
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
await clickText(root, '区分模型')
expect(readSchedulingPolicies(config.value)).toHaveLength(2)
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('fixed_order')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
})
it('keeps scope and ranking edits when moving between strategy cards', async () => {
const { root, config } = mountEditor()
await clickText(root, '固定顺序')
await openModels(root)
await select(root, 'model-a')
control<HTMLButtonElement>(root, '添加调度配置').click()
await nextTick()
await select(root, 'model-b')
control<HTMLButtonElement>(root, '展开调度配置 1').click()
await nextTick()
await select(root, 'model-c')
control<HTMLButtonElement>(root, '调整排序').click()
await nextTick()
const entries = readSchedulingPolicies(config.value)
expect(entries[0]).toMatchObject({ models: ['model-a', 'model-c'], schedulingMode: 'fixed_order' })
expect(getModelPolicy(config.value, 'model-c').provider_priority_overrides).toEqual({ provider: 7 })
expect(getModelPolicy(config.value, 'model-b').provider_priority_overrides).toEqual({})
})
it('releases models and removes rules when a strategy is deleted', async () => {
const initial = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(initial), models: ['model-a'] }
const second = { ...createSchedulingPolicy(initial), models: ['model-b'] }
const { root, config } = mountEditor(writeSchedulingPolicies(initial, [first, second]))
control<HTMLButtonElement>(root, '删除调度配置 2').click()
await nextTick()
expect(config.value.rules).toHaveLength(1)
expect(config.value.model_policies.map(policy => policy.model)).toEqual(['model-a'])
await openModels(root)
expect(control<HTMLInputElement>(root, '选择模型 model-b').disabled).toBe(false)
await select(root, 'model-b')
expect(readSchedulingPolicies(config.value)[0].models).toEqual(['model-a', 'model-b'])
})
it('returns to all-model mode when deleting the last selected entry beside a legacy fallback', async () => {
const initial = createEmptyRoutingGroupConfig()
const selected = { ...createSchedulingPolicy(initial), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const fallback = { ...createSchedulingPolicy(initial, 'all'), schedulingMode: 'load_balance' as const }
const { root, config, valid } = mountEditor(writeSchedulingPolicies(initial, [selected, fallback]))
control<HTMLButtonElement>(root, '删除调度配置 1').click()
await flush()
expect(valid.value).toBe(true)
expect(control(root, '全部模型').getAttribute('aria-pressed')).toBe('true')
expect(root.querySelector('[aria-label="添加调度配置"]')).toBeNull()
expect(readSchedulingPolicies(config.value)).toHaveLength(1)
expect(config.value.rules).toEqual([])
expect(getModelScheduling(config.value, 'model-a').scheduling_mode).toBe('load_balance')
await clickText(root, '区分模型')
expect(valid.value).toBe(false)
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(1)
expect(control<HTMLInputElement>(root, '选择模型 model-a').checked).toBe(false)
await select(root, 'model-b')
expect(getModelScheduling(config.value, 'model-b').scheduling_mode).toBe('load_balance')
})
it('searches global model names and display names without losing selected models', async () => {
const { root, config } = mountEditor()
await openModels(root)
await select(root, 'model-a')
const search = control<HTMLInputElement>(root, '搜索全局模型')
search.value = '模型 B'
search.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(root.querySelector('input[aria-label="选择模型 model-a"]')).toBeNull()
await select(root, 'model-b')
expect(readSchedulingPolicies(config.value)[0].models).toEqual(['model-a', 'model-b'])
search.value = ''
search.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(control<HTMLInputElement>(root, '选择模型 model-a').checked).toBe(true)
await select(root, 'model-a')
expect(readSchedulingPolicies(config.value)[0].models).toEqual(['model-b'])
})
it('retains group-wide failover changes when the shared ranking is edited', async () => {
const { root, config } = mountEditor()
config.value.default_policy.max_transfer_count = 9
config.value.default_policy.cancel_on_client_disconnect = true
await nextTick()
await openModels(root)
await select(root, 'model-a')
control<HTMLButtonElement>(root, '调整排序').click()
await nextTick()
expect(config.value.default_policy.max_transfer_count).toBe(9)
expect(config.value.default_policy.cancel_on_client_disconnect).toBe(true)
})
it('reports loading failures without clearing previously selected models', async () => {
const initial = createEmptyRoutingGroupConfig()
const entry = { ...createSchedulingPolicy(initial), models: ['removed-model'] }
const { root, config, models, loading, error, reload, valid } = mountEditor(writeSchedulingPolicies(initial, [entry]))
models.value = []
loading.value = true
await nextTick()
await openModels(root)
expect(document.body.textContent).toContain('正在加载全局模型')
loading.value = false
error.value = '模型加载失败'
await nextTick()
expect(document.body.textContent).toContain('模型加载失败')
await clickText(root, '重试')
expect(reload).toHaveBeenCalledOnce()
expect(readSchedulingPolicies(config.value)[0].models).toEqual(['removed-model'])
expect(valid.value).toBe(true)
expect(control<HTMLButtonElement>(root, '添加调度配置').disabled).toBe(true)
})
it('disables configuration controls while saving', async () => {
const { root, config, disabled } = mountEditor()
const previous = JSON.stringify(config.value)
disabled.value = true
await nextTick()
await clickText(root, '负载均衡')
await clickText(root, '区分模型')
await flush()
expect(control<HTMLButtonElement>(root, '全部模型').disabled).toBe(true)
expect(control<HTMLButtonElement>(root, '区分模型').disabled).toBe(true)
expect(control(root, '全部模型').getAttribute('aria-pressed')).toBe('true')
expect(root.querySelector('[aria-label="选择适用模型"]')).toBeNull()
expect(document.querySelector('[aria-label="全局模型选择列表"]')).toBeNull()
expect(root.querySelector('fieldset')?.disabled).toBe(true)
expect(JSON.stringify(config.value)).toBe(previous)
})
it('keeps model-specific drafts unchanged when scope switching is disabled', async () => {
const { root, config, disabled } = mountEditor()
await select(root, 'model-a')
const previous = JSON.stringify(config.value)
disabled.value = true
await flush()
await clickText(root, '全部模型')
expect(control(root, '区分模型').getAttribute('aria-pressed')).toBe('true')
expect(control<HTMLButtonElement>(root, '添加调度配置').disabled).toBe(true)
expect(JSON.stringify(config.value)).toBe(previous)
})
})
@@ -0,0 +1,179 @@
import { describe, expect, it, vi } from 'vitest'
import {
createEmptyModelPolicy,
createEmptyRoutingGroupConfig,
getDefaultModelPolicy,
getModelPolicy,
getModelScheduling,
modelSchedulingRuleId,
setDefaultProviderPriorityOverrides,
setModelKeyPriorityOverridesForFormat,
upsertModelPolicy,
upsertModelSchedulingRule,
type RoutingRule,
} from '../utils/routingPolicy'
import {
createSchedulingPolicy,
readSchedulingPolicies,
schedulingPolicyEditorConfig,
validateSchedulingPolicies,
writeSchedulingPolicies,
} from '../utils/schedulingPolicies'
describe('strategy-scoped scheduling policies', () => {
it('starts with one all-model strategy', () => {
const entries = readSchedulingPolicies(createEmptyRoutingGroupConfig())
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ scope: 'all', priorityMode: 'provider', schedulingMode: 'cache_affinity' })
expect(validateSchedulingPolicies(entries)).toBeNull()
})
it('generates unique policy ids on HTTP pages without crypto.randomUUID', () => {
vi.stubGlobal('crypto', {})
try {
const config = createEmptyRoutingGroupConfig()
expect(createSchedulingPolicy(config).id).not.toBe(createSchedulingPolicy(config).id)
expect(readSchedulingPolicies(config)).toHaveLength(1)
} finally {
vi.unstubAllGlobals()
}
})
it('persists all models as a wildcard rather than enumerating the current catalog', () => {
const config = createEmptyRoutingGroupConfig()
const entry = createSchedulingPolicy(config, 'all')
entry.models = ['model-a', 'model-b']
entry.priorityMode = 'global_key'
entry.schedulingMode = 'load_balance'
entry.policy.provider_priority_overrides = { provider: 2 }
const saved = JSON.parse(JSON.stringify(writeSchedulingPolicies(config, [entry])))
expect(saved.rules).toEqual([])
expect(saved.model_policies.map((policy: { model: string }) => policy.model)).toEqual(['*'])
expect(readSchedulingPolicies(saved)[0]).toMatchObject({ scope: 'all', models: [] })
expect(getDefaultModelPolicy(saved).provider_priority_overrides).toEqual({ provider: 2 })
for (const model of ['model-a', 'model-b', 'future-model']) {
expect(getModelScheduling(saved, model)).toMatchObject({ priority_mode: 'global_key', scheduling_mode: 'load_balance' })
}
})
it('persists one strategy for multiple models with shared rankings', () => {
const config = createEmptyRoutingGroupConfig()
const entry = createSchedulingPolicy(config)
entry.models = ['model-a', 'model-b']
entry.priorityMode = 'global_key'
entry.schedulingMode = 'fixed_order'
entry.policy = {
...entry.policy,
allowed_providers: ['provider-a'],
provider_priority_overrides: { 'provider-a': 2 },
key_priority_overrides_by_format: { 'openai:chat': { 'key-a': 1 } },
pool_priority_overrides: { 'pool-a': 3 },
pool_policy_overrides: { 'pool-a': { scheduling_presets: [{ preset: 'cache_affinity', enabled: true }] } },
}
const saved = writeSchedulingPolicies(config, [entry])
expect(saved.rules).toHaveLength(1)
expect(saved.rules[0].conditions).toEqual({ any: [
{ field: 'model', op: 'eq', value: 'model-a' },
{ field: 'model', op: 'eq', value: 'model-b' },
] })
for (const model of entry.models) {
expect(getModelScheduling(saved, model)).toMatchObject({ priority_mode: 'global_key', scheduling_mode: 'fixed_order' })
expect(getModelPolicy(saved, model)).toEqual({ ...entry.policy, model })
}
expect(getModelScheduling(saved, 'other-model').scheduling_mode).toBe('cache_affinity')
const reloaded = readSchedulingPolicies(JSON.parse(JSON.stringify(saved)))
expect(reloaded).toHaveLength(1)
expect(reloaded[0]).toMatchObject({ id: entry.id, models: ['model-a', 'model-b'], policy: entry.policy })
expect(schedulingPolicyEditorConfig(saved, reloaded[0]).model_policies).toEqual([entry.policy])
})
it('retains separate strategies even when their settings are identical', () => {
const config = createEmptyRoutingGroupConfig()
const first = { ...createSchedulingPolicy(config), models: ['model-a'] }
const second = { ...createSchedulingPolicy(config), models: ['model-b'] }
const entries = readSchedulingPolicies(writeSchedulingPolicies(config, [first, second]))
expect(entries.map(entry => entry.id)).toEqual([first.id, second.id])
})
it('loads legacy per-model policies, including rules without a ranking policy', () => {
let config = createEmptyRoutingGroupConfig()
for (const model of ['model-a', 'model-b']) {
config = upsertModelPolicy(config, { ...createEmptyModelPolicy(model), provider_priority_overrides: { provider: 2 } })
config = upsertModelSchedulingRule(config, model, { priority_mode: 'provider', scheduling_mode: 'load_balance' })
}
config = upsertModelSchedulingRule(config, 'model-c', { priority_mode: 'global_key', scheduling_mode: 'fixed_order' })
const entries = readSchedulingPolicies(config)
expect(entries).toHaveLength(2)
expect(entries[0].models).toEqual(['model-a', 'model-b'])
expect(entries[1].models).toEqual(['model-c'])
const saved = writeSchedulingPolicies(config, entries)
for (const model of ['model-a', 'model-b', 'model-c', 'other']) {
expect(getModelScheduling(saved, model)).toEqual(getModelScheduling(config, model))
expect(getModelPolicy(saved, model)).toEqual(getModelPolicy(config, model))
}
expect(saved.rules.every(rule => !rule.id.startsWith('ui_model_scheduling:'))).toBe(true)
})
it('preserves group execution options, failover rules, and custom routing rules', () => {
const config = createEmptyRoutingGroupConfig()
config.default_policy.cancel_on_client_disconnect = true
config.default_policy.sticky_key_attempts = 5
config.default_policy.max_transfer_count = 7
config.default_policy.failover_rules.error_stop_patterns = [{ pattern: '', status_codes: [429] }]
const rule: RoutingRule = {
id: 'custom-header-rule', priority: 3, enabled: true, phase: 'provider_request',
conditions: { field: 'model', op: 'prefix', value: 'model-' },
actions: [{ type: 'set_header', name: 'x-test', value: 'kept' }], stop_processing: false,
}
config.rules.push(rule)
const entry = { ...createSchedulingPolicy(config), models: ['model-a'] }
const saved = writeSchedulingPolicies(config, [entry])
expect(saved.default_policy).toEqual(config.default_policy)
expect(saved.rules[0]).toEqual(rule)
expect(config.model_policies).toEqual([])
expect(config.rules).toEqual([rule])
})
it('keeps the wildcard fallback before specific rankings and preserves per-format keys', () => {
let config = setDefaultProviderPriorityOverrides(createEmptyRoutingGroupConfig(), { provider: 4 })
config = setModelKeyPriorityOverridesForFormat(config, 'model-a', 'openai:chat', { key: 2 })
const entries = readSchedulingPolicies(config)
expect(entries.map(entry => entry.scope)).toEqual(['selected', 'all'])
const saved = writeSchedulingPolicies(config, entries)
expect(saved.model_policies.map(policy => policy.model)).toEqual(['*', 'model-a'])
expect(getModelPolicy(saved, 'model-a').key_priority_overrides_by_format).toEqual({ 'openai:chat': { key: 2 } })
expect(getModelPolicy(saved, '*').provider_priority_overrides).toEqual({ provider: 4 })
})
it('removes obsolete model rules when a model leaves a strategy', () => {
const config = createEmptyRoutingGroupConfig()
const entry = { ...createSchedulingPolicy(config), models: ['model-a', 'model-b'], schedulingMode: 'load_balance' as const }
const previous = writeSchedulingPolicies(config, [entry])
const saved = writeSchedulingPolicies(previous, [{ ...entry, models: ['model-b', 'model-c'] }])
expect(saved.model_policies.map(policy => policy.model)).toEqual(['model-b', 'model-c'])
expect(saved.rules).toHaveLength(1)
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('cache_affinity')
expect(getModelScheduling(saved, 'model-c').scheduling_mode).toBe('load_balance')
})
it('preserves legacy model retry overrides and prefix matching', () => {
const config = upsertModelSchedulingRule(createEmptyRoutingGroupConfig(), 'legacy-*', {
priority_mode: 'provider', scheduling_mode: 'fixed_order',
})
const rule = config.rules.find(rule => rule.id === modelSchedulingRuleId('legacy-*'))!
rule.actions = [{ type: 'set_scheduling', priority_mode: 'provider', scheduling_mode: 'fixed_order', sticky_key_attempts: 4 }]
const saved = writeSchedulingPolicies(config, readSchedulingPolicies(config))
expect(getModelScheduling(saved, 'legacy-model').sticky_key_attempts).toBe(4)
expect(getModelScheduling(saved, 'other-model').scheduling_mode).toBe('cache_affinity')
})
it('rejects empty or overlapping scopes', () => {
const config = createEmptyRoutingGroupConfig()
const first = createSchedulingPolicy(config)
expect(validateSchedulingPolicies([first])).toContain('选择至少一个')
first.models = ['model-a']
expect(validateSchedulingPolicies([first, { ...createSchedulingPolicy(config), models: ['model-a'] }])).toContain('不能重复')
expect(validateSchedulingPolicies([createSchedulingPolicy(config, 'all'), createSchedulingPolicy(config, 'all')])).toContain('只能有一条')
expect(validateSchedulingPolicies([])).not.toBeNull()
})
})
@@ -0,0 +1,253 @@
<template>
<div class="min-w-0 space-y-2">
<div class="min-w-0 space-y-2">
<div class="overflow-hidden rounded-lg border border-border/60 bg-background">
<button
ref="trigger"
type="button"
class="flex min-h-10 w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm font-normal text-foreground transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
:disabled="disabled"
aria-label="选择适用模型"
:aria-expanded="open"
:aria-controls="listId"
@click="open ? closeModels() : openModels()"
>
<span
class="min-w-0 flex-1 truncate"
:class="!modelValue.length ? 'text-muted-foreground' : ''"
>
{{ selectionLabel }}
</span>
<ChevronDown
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform"
:class="open ? 'rotate-180' : ''"
/>
</button>
<div
v-if="open"
:id="listId"
class="flex min-w-0 flex-col border-t border-border/60"
role="region"
aria-label="全局模型选择列表"
@keydown.esc.stop.prevent="closeModels"
>
<div class="relative shrink-0 p-2">
<Search class="pointer-events-none absolute left-5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref="searchInput"
v-model="search"
size="sm"
class="h-9 rounded-md border-border/60 bg-background pl-9 pr-3 text-sm"
placeholder="搜索模型名称"
aria-label="搜索全局模型"
:disabled="disabled"
/>
</div>
<p
v-if="loading"
class="p-6 text-center text-xs text-muted-foreground"
>
正在加载全局模型
</p>
<div
v-else-if="error"
role="alert"
class="flex items-center justify-between gap-3 p-4 text-xs text-destructive"
>
<span class="min-w-0 break-words">{{ error }}</span>
<Button
type="button"
variant="outline"
size="sm"
class="shrink-0"
:disabled="disabled"
@click="emit('reload')"
>
重试
</Button>
</div>
<template v-else>
<div class="flex min-h-8 shrink-0 items-center justify-between gap-2 px-3 py-1 text-xs text-muted-foreground">
<span>指定全局模型</span>
<Button
v-if="selectableRows.length"
type="button"
variant="ghost"
size="sm"
class="h-6 px-1.5 text-xs font-normal"
:disabled="disabled"
:aria-label="search.trim() ? '全选搜索结果' : '选择当前列表'"
:aria-pressed="allResultsSelected"
@click="selectResults(!allResultsSelected)"
>
{{ allResultsSelected ? '取消当前选择' : search.trim() ? '全选结果' : '全选当前' }}
</Button>
</div>
<div class="grid max-h-64 min-h-0 grid-cols-1 gap-1 overflow-y-auto overscroll-contain p-2 sm:grid-cols-2">
<label
v-for="model in filteredModels"
:key="model.name"
class="flex min-w-0 items-center gap-3 rounded-md px-2 py-2 text-sm"
:class="[
model.owner ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-muted/50',
selectedModels.includes(model.name) ? 'bg-accent/60' : '',
]"
>
<Checkbox
:checked="selectedModels.includes(model.name)"
:disabled="disabled || Boolean(model.owner)"
:aria-label="`选择模型 ${model.name}`"
@update:checked="selected => toggleModel(model.name, selected)"
/>
<span class="min-w-0 flex-1">
<span
class="block truncate"
:title="model.displayName"
>{{ model.displayName }}</span>
<span
v-if="model.displayName !== model.name"
class="block truncate text-xs text-muted-foreground"
:title="model.name"
>
{{ model.name }}
</span>
<span
v-if="model.owner"
class="block text-xs text-muted-foreground"
>
已用于配置 {{ model.owner }}
</span>
</span>
</label>
<p
v-if="filteredModels.length === 0"
class="col-span-full px-3 py-6 text-center text-xs text-muted-foreground"
>
{{ search.trim() ? '未匹配到全局模型' : '暂无可选模型' }}
</p>
</div>
</template>
<div class="flex shrink-0 flex-wrap items-center gap-1 border-t border-border/60 p-2">
<span class="flex-1 text-xs text-muted-foreground">
已选 {{ selectedModels.length }} 个
</span>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs font-normal text-muted-foreground"
:disabled="disabled || selectedModels.length === 0"
aria-label="清空已选"
@click="updateModels([])"
>
清空已选
</Button>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 gap-1 px-2 text-xs font-medium"
:disabled="disabled"
@click="closeModels"
>
<Check class="h-3.5 w-3.5" />
完成选择
</Button>
</div>
</div>
</div>
</div>
<p
v-if="modelValue.length === 0"
class="text-xs text-muted-foreground"
>
支持多选,选中的模型共用一套调度设置。
</p>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import { Check, ChevronDown, Search } from 'lucide-vue-next'
import { Button, Checkbox, Input } from '@/components/ui'
import type { GlobalModelResponse } from '@/api/global-models'
const props = defineProps<{
modelValue: string[]
models: GlobalModelResponse[]
assignedModels: Record<string, number>
loading?: boolean
error?: string | null
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [models: string[]]
reload: []
}>()
const trigger = ref<HTMLButtonElement | null>(null)
const searchInput = ref<InstanceType<typeof Input> | null>(null)
const listId = useId()
const open = ref(props.modelValue.length === 0 && !props.disabled)
const search = ref('')
const selectedModels = computed(() => props.modelValue)
const selectionLabel = computed(() => {
if (!props.modelValue.length) return '请选择全局模型'
if (props.modelValue.length <= 2) return props.modelValue.map(modelLabel).join('、')
return `已选择 ${props.modelValue.length} 个模型`
})
const filteredModels = computed(() => {
const models = new Map(props.models.map(model => [model.name, {
name: model.name,
displayName: model.display_name || model.name,
owner: props.assignedModels[model.name],
}]))
for (const name of props.modelValue) {
if (!models.has(name)) models.set(name, { name, displayName: name, owner: props.assignedModels[name] })
}
const query = search.value.trim().toLowerCase()
return [...models.values()].filter(model => query
? model.name.toLowerCase().includes(query) || model.displayName.toLowerCase().includes(query)
: !model.owner)
})
const selectableRows = computed(() => filteredModels.value.filter(model => !model.owner))
const allResultsSelected = computed(() => selectableRows.value.length > 0
&& selectableRows.value.every(model => selectedModels.value.includes(model.name)))
watch(open, value => { if (!value) search.value = '' })
watch(() => props.disabled, disabled => { if (disabled) open.value = false })
async function openModels(): Promise<void> {
if (props.disabled) return
open.value = true
await nextTick()
searchInput.value?.inputRef?.focus({ preventScroll: true })
}
function closeModels(): void {
open.value = false
trigger.value?.focus({ preventScroll: true })
}
function modelLabel(name: string): string {
return props.models.find(model => model.name === name)?.display_name || name
}
function updateModels(models: string[]): void {
if (props.disabled) return
emit('update:modelValue', models)
}
function toggleModel(model: string, selected: boolean): void {
if (props.assignedModels[model]) return
updateModels(selected ? [...new Set([...selectedModels.value, model])] : selectedModels.value.filter(name => name !== model))
}
function selectResults(selected: boolean): void {
const names = new Set(selectableRows.value.map(model => model.name))
updateModels(selected ? [...new Set([...selectedModels.value, ...names])] : selectedModels.value.filter(name => !names.has(name)))
}
</script>
@@ -0,0 +1,424 @@
<template>
<section class="space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-medium">
调度配置
</h3>
</div>
<Button
v-if="scopeMode === 'selected'"
type="button"
variant="outline"
size="sm"
class="shrink-0 gap-1.5"
:disabled="!canAddEntry"
:title="addEntryHint"
aria-label="添加调度配置"
@click="addEntry"
>
<Plus class="h-3.5 w-3.5" />
添加配置
</Button>
</div>
<div
role="group"
aria-label="调度范围"
class="scheduling-switch w-full grid-cols-2 sm:w-80"
>
<button
v-for="mode in scopeModes"
:key="mode.value"
type="button"
class="scheduling-switch__option"
:aria-label="mode.label"
:aria-pressed="scopeMode === mode.value"
:disabled="disabled"
@click="setScopeMode(mode.value)"
>
<component
:is="mode.icon"
class="h-4 w-4 shrink-0"
/>
{{ mode.label }}
</button>
</div>
<fieldset
:disabled="disabled"
:inert="disabled"
class="min-w-0 space-y-3"
>
<section
v-for="(entry, index) in entries"
:key="entry.id"
:class="scopeMode === 'selected' ? 'rounded-lg border border-border/60' : ''"
:aria-label="`调度配置 ${index + 1}`"
>
<div
v-if="scopeMode === 'selected'"
class="flex items-center gap-3 px-4 py-3"
>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 text-left"
:aria-label="`${expandedId === entry.id ? '收起' : '展开'}调度配置 ${index + 1}`"
:aria-expanded="expandedId === entry.id"
@click="expandedId = expandedId === entry.id ? null : entry.id"
>
<ChevronDown
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform"
:class="expandedId === entry.id ? 'rotate-180' : ''"
/>
<span class="min-w-0">
<span
class="block truncate text-sm font-medium"
:title="entry.scope === 'selected' ? entry.models.join('、') : '默认配置'"
>
{{ scopeSummary(entry) }}
</span>
<span class="mt-0.5 block text-xs text-muted-foreground">
配置 {{ index + 1 }} · {{ entry.priorityMode === 'provider' ? 'Provider' : 'Key' }} · {{ schedulingModeLabel(entry.schedulingMode) }}
</span>
</span>
</button>
<Button
v-if="entries.length > 1"
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
:aria-label="`删除调度配置 ${index + 1}`"
@click="removeEntry(entry.id)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
<div
v-if="scopeMode === 'all' || expandedId === entry.id"
class="space-y-5"
:class="scopeMode === 'selected' ? 'border-t border-border/60 p-4' : ''"
>
<div
v-if="entry.scope === 'selected'"
class="min-w-0"
>
<div class="min-w-0 space-y-2">
<h4 class="text-sm font-medium">
适用模型
</h4>
<RoutingModelSelector
:model-value="entry.models"
:models="globalModels"
:assigned-models="otherModelOwners(entry.id)"
:loading="loadingModels"
:error="modelsError"
:disabled="disabled"
@update:model-value="models => updateEntry(entry.id, { models })"
@reload="emit('reload-models')"
/>
</div>
</div>
<div
v-if="entry.scope === 'all' || entry.models.length > 0"
class="space-y-4"
:class="entry.scope === 'selected' ? 'border-t border-border/60 pt-4' : ''"
>
<h4 class="text-sm font-medium">
调度设置
</h4>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div class="space-y-1.5 text-sm">
<span class="text-muted-foreground">调度优先级</span>
<div
role="group"
aria-label="调度优先级"
class="scheduling-switch grid-cols-2"
>
<button
v-for="mode in priorityModes"
:key="mode.value"
type="button"
class="scheduling-switch__option"
:aria-pressed="entry.priorityMode === mode.value"
:disabled="disabled"
@click="updateEntry(entry.id, { priorityMode: mode.value })"
>
<component
:is="mode.icon"
class="h-4 w-4"
/>
{{ mode.label }}
</button>
</div>
</div>
<div class="space-y-1.5 text-sm">
<span class="text-muted-foreground">调度策略</span>
<div
role="group"
aria-label="调度策略"
class="scheduling-switch grid-cols-3"
>
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="scheduling-switch__option"
:aria-pressed="entry.schedulingMode === mode.value"
:disabled="disabled"
@click="updateEntry(entry.id, { schedulingMode: mode.value })"
>
{{ mode.label }}
</button>
</div>
</div>
</div>
<RoutingPriorityPolicyEditor
:config="schedulingPolicyEditorConfig(config, entry)"
:show-priority-mode="false"
:show-scheduling-mode="false"
subtitle="所选模型共用此排序,仅对各模型可用的候选生效"
@update:config="value => updateEntry(entry.id, { policy: getDefaultModelPolicy(value) })"
/>
</div>
<p
v-else
class="border-t border-border/60 pt-4 text-xs text-muted-foreground"
>
选好模型后,即可设置调度方式和排序。
</p>
</div>
</section>
</fieldset>
<p
v-if="validationError && entries.every(entry => entry.scope === 'all' || entry.models.length > 0)"
role="alert"
class="text-xs text-destructive"
>
{{ validationError }}
</p>
<p
v-if="scopeMode === 'selected' && !hasAllModels"
class="text-xs text-muted-foreground"
>
{{ availableModels.length ? `还有 ${availableModels.length} 个模型可配置;` : '' }}未指定的模型继续使用默认调度。
</p>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ChevronDown, Globe, Key, Layers, ListFilter, Plus, Trash2 } from 'lucide-vue-next'
import { Button } from '@/components/ui'
import type { GlobalModelResponse } from '@/api/global-models'
import RoutingPriorityPolicyEditor from './RoutingPriorityPolicyEditor.vue'
import RoutingModelSelector from './RoutingModelSelector.vue'
import { getDefaultModelPolicy, type RoutingGroupConfig, type RoutingPriorityMode, type RoutingSchedulingMode } from '../utils/routingPolicy'
import {
createSchedulingPolicy,
readSchedulingPolicies,
schedulingPolicyEditorConfig,
validateSchedulingPolicies,
writeSchedulingPolicies,
type SchedulingPolicy,
} from '../utils/schedulingPolicies'
const props = defineProps<{
config: RoutingGroupConfig
globalModels: GlobalModelResponse[]
loadingModels?: boolean
modelsError?: string | null
disabled?: boolean
}>()
const emit = defineEmits<{
'update:config': [value: RoutingGroupConfig]
'validity-change': [valid: boolean]
'reload-models': []
}>()
const scopeModes = [
{ value: 'all' as const, label: '全部模型', icon: Globe },
{ value: 'selected' as const, label: '区分模型', icon: ListFilter },
]
const priorityModes = [
{ value: 'provider' as RoutingPriorityMode, label: 'Provider', icon: Layers },
{ value: 'global_key' as RoutingPriorityMode, label: 'Key', icon: Key },
]
const schedulingModes: Array<{ value: RoutingSchedulingMode; label: string }> = [
{ value: 'cache_affinity', label: '缓存亲和' },
{ value: 'load_balance', label: '负载均衡' },
{ value: 'fixed_order', label: '固定顺序' },
]
const entries = ref(readSchedulingPolicies(props.config))
const scopeMode = ref<SchedulingPolicy['scope']>(entries.value.some(entry => entry.scope === 'selected') ? 'selected' : 'all')
let allModelsDraft: SchedulingPolicy[] | null = null
let selectedModelsDraft: SchedulingPolicy[] | null = null
const fallbackScheduling = {
priority_mode: props.config.default_policy.priority_mode,
scheduling_mode: props.config.default_policy.scheduling_mode,
}
const expandedId = ref<string | null>(entries.value[0]?.id ?? null)
const validationError = computed(() => validateSchedulingPolicies(entries.value))
const hasAllModels = computed(() => entries.value.some(entry => entry.scope === 'all'))
const assignedModels = computed(() => new Set(entries.value.filter(entry => entry.scope === 'selected').flatMap(entry => entry.models)))
const availableModels = computed(() => props.globalModels.filter(model => !assignedModels.value.has(model.name)))
const canAddEntry = computed(() => scopeMode.value === 'selected' && !props.disabled && !props.loadingModels && !props.modelsError
&& !validationError.value && availableModels.value.length > 0)
const addEntryHint = computed(() => {
if (validationError.value) return validationError.value
if (props.loadingModels) return '正在加载全局模型'
if (props.modelsError) return '请先重新加载全局模型'
return availableModels.value.length ? '为其他模型添加一套调度配置' : '所有全局模型都已有配置'
})
watch(validationError, error => emit('validity-change', !error), { immediate: true })
function schedulingModeLabel(mode: RoutingSchedulingMode): string {
return schedulingModes.find(item => item.value === mode)?.label ?? mode
}
function scopeSummary(entry: SchedulingPolicy): string {
if (entry.scope === 'all') return '默认配置'
if (entry.models.length === 0) return '请选择适用模型'
const labels = entry.models.slice(0, 2).map(name => props.globalModels.find(model => model.name === name)?.display_name || name)
return labels.join('、') + (entry.models.length > 2 ? ` 等 ${entry.models.length} 个模型` : '')
}
function otherModelOwners(entryId: string): Record<string, number> {
return Object.fromEntries(entries.value.flatMap((entry, index) => entry.id !== entryId && entry.scope === 'selected'
? entry.models.map(model => [model, index + 1])
: []))
}
function publish(): void {
emit('update:config', writeSchedulingPolicies({
...props.config,
default_policy: { ...props.config.default_policy, ...fallbackScheduling },
}, entries.value))
}
function setScopeMode(scope: SchedulingPolicy['scope']): void {
if (props.disabled || scopeMode.value === scope) return
if (scopeMode.value === 'all') allModelsDraft = entries.value
else selectedModelsDraft = entries.value
const saved = scope === 'all' ? allModelsDraft : selectedModelsDraft
if (saved) {
entries.value = saved
} else {
const source = entries.value.find(entry => entry.scope === 'all') ?? entries.value[0]
?? createSchedulingPolicy(props.config, scope)
entries.value = [{
...createSchedulingPolicy(props.config, scope),
priorityMode: source.priorityMode,
schedulingMode: source.schedulingMode,
policy: source.policy,
}]
}
scopeMode.value = scope
expandedId.value = entries.value[0]?.id ?? null
publish()
}
function updateEntry(id: string, patch: Partial<SchedulingPolicy>): void {
if (props.disabled) return
const current = entries.value.find(entry => entry.id === id)
if (!current || Object.entries(patch).every(([field, value]) => current[field as keyof SchedulingPolicy] === value)) return
entries.value = entries.value.map(entry => {
if (entry.id !== id) return entry
const updated = { ...entry, ...patch }
if (updated.scope === 'selected') {
updated.models = updated.models.filter(model => !otherModelOwners(id)[model])
}
return updated
})
publish()
}
function addEntry(): void {
if (!canAddEntry.value) return
const entry = createSchedulingPolicy(props.config)
entries.value.push(entry)
expandedId.value = entry.id
publish()
}
function removeEntry(id: string): void {
if (props.disabled || entries.value.length === 1) return
entries.value = entries.value.filter(entry => entry.id !== id)
if (entries.value.every(entry => entry.scope === 'all')) {
scopeMode.value = 'all'
selectedModelsDraft = null
}
if (expandedId.value === id) expandedId.value = entries.value[0]?.id ?? null
publish()
}
</script>
<style scoped>
.scheduling-switch {
display: grid;
gap: 4px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in oklab, var(--muted) 40%, var(--background));
}
.scheduling-switch__option {
position: relative;
display: flex;
min-width: 0;
min-height: 38px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 4px;
border: 1px solid transparent;
border-radius: 5px;
color: color-mix(in oklab, var(--foreground) 75%, var(--muted-foreground));
font-size: 14px;
font-weight: 600;
line-height: 20px;
letter-spacing: 0;
cursor: pointer;
transition: background-color 150ms, border-color 150ms, color 150ms, box-shadow 150ms;
}
.scheduling-switch__option:hover:not(:disabled) {
background: color-mix(in oklab, var(--primary) 8%, var(--background));
color: var(--foreground);
}
.scheduling-switch__option[aria-pressed='true'] {
background: var(--primary);
color: var(--primary-foreground);
box-shadow: 0 1px 2px color-mix(in oklab, var(--primary) 20%, transparent);
}
.scheduling-switch__option[aria-pressed='true']:hover:not(:disabled) {
background: color-mix(in oklab, var(--primary) 92%, black);
color: var(--primary-foreground);
}
.scheduling-switch__option:focus-visible {
z-index: 1;
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.scheduling-switch__option:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (prefers-reduced-motion: reduce) {
.scheduling-switch__option {
transition: none;
}
}
</style>
@@ -4,5 +4,6 @@ export { default as RoutingFailoverPolicyEditor } from './RoutingFailoverPolicyE
export { default as RoutingGroupList } from './RoutingGroupList.vue'
export { default as RoutingModelPolicyEditor } from './RoutingModelPolicyEditor.vue'
export { default as RoutingPriorityPolicyEditor } from './RoutingPriorityPolicyEditor.vue'
export { default as RoutingSchedulingPolicyEditor } from './RoutingSchedulingPolicyEditor.vue'
export { default as RoutingRuleEditor } from './RoutingRuleEditor.vue'
export { default as RoutingTraceViewer } from './RoutingTraceViewer.vue'
@@ -72,6 +72,7 @@ export interface RoutingGroupConfig {
export const DEFAULT_ROUTING_POLICY_MODEL = '*'
export const MODEL_SCHEDULING_RULE_PREFIX = 'ui_model_scheduling:'
export const SCHEDULING_POLICY_RULE_PREFIX = 'ui_scheduling_policy:'
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
return {
@@ -351,6 +352,29 @@ export function isGeneratedModelSchedulingRule(rule: RoutingRule): boolean {
return rule.id.startsWith(MODEL_SCHEDULING_RULE_PREFIX)
}
export function isGeneratedSchedulingPolicyRule(rule: RoutingRule): boolean {
return rule.id.startsWith(SCHEDULING_POLICY_RULE_PREFIX)
}
export function schedulingRuleModels(rule: RoutingRule): string[] {
if (isGeneratedModelSchedulingRule(rule)) {
try {
return [decodeURIComponent(rule.id.slice(MODEL_SCHEDULING_RULE_PREFIX.length))]
} catch {
return []
}
}
if (!isGeneratedSchedulingPolicyRule(rule)) return []
const conditions = rule.conditions as { any?: RoutingPredicateCondition[] } | null
if (!Array.isArray(conditions?.any)) return []
return conditions.any.flatMap(condition => {
if (condition?.field !== 'model' || typeof condition.value !== 'string') return []
if (condition.op === 'eq') return [condition.value]
if (condition.op === 'prefix') return [`${condition.value}*`]
return []
})
}
export function modelPatternCondition(model: string): RoutingPredicateCondition {
const normalizedModel = model.trim()
if (normalizedModel.endsWith('*')) {
@@ -372,8 +396,18 @@ export function getModelScheduling(
model: string,
): RoutingDefaultPolicy {
const normalized = normalizeRoutingGroupConfig(config)
const rule = normalized.rules.find(rule => rule.id === modelSchedulingRuleId(model))
const action = rule?.actions.find(isSetSchedulingAction)
const rules = normalized.rules
.filter(rule => rule.enabled && rule.phase === 'client_request' && schedulingRuleModels(rule).some(pattern => (
pattern.endsWith('*') ? model.startsWith(pattern.slice(0, -1)) : pattern === model
)))
.sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id))
let action: RoutingSetSchedulingAction | undefined
for (const rule of rules) {
for (const candidate of rule.actions) {
if (isSetSchedulingAction(candidate)) action = { ...action, ...candidate }
}
if (rule.stop_processing) break
}
return {
...normalized.default_policy,
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
@@ -433,7 +467,7 @@ export function removeModelSchedulingRule(config: RoutingGroupConfig, model: str
export function removeGeneratedModelSchedulingRules(config: RoutingGroupConfig): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
next.rules = next.rules.filter(rule => !isGeneratedModelSchedulingRule(rule))
next.rules = next.rules.filter(rule => !isGeneratedModelSchedulingRule(rule) && !isGeneratedSchedulingPolicyRule(rule))
return next
}
@@ -0,0 +1,198 @@
import {
DEFAULT_ROUTING_POLICY_MODEL,
SCHEDULING_POLICY_RULE_PREFIX,
createEmptyModelPolicy,
getModelPolicy,
getModelScheduling,
isGeneratedModelSchedulingRule,
isGeneratedSchedulingPolicyRule,
modelPatternCondition,
modelSchedulingRuleId,
normalizeRoutingGroupConfig,
schedulingRuleModels,
type RoutingGroupConfig,
type RoutingModelPolicy,
type RoutingPriorityMode,
type RoutingRule,
type RoutingSchedulingMode,
type RoutingSetSchedulingAction,
} from './routingPolicy'
export interface SchedulingPolicy {
id: string
scope: 'all' | 'selected'
models: string[]
priorityMode: RoutingPriorityMode
schedulingMode: RoutingSchedulingMode
policy: RoutingModelPolicy
rule?: RoutingRule
}
export function createSchedulingPolicy(config: RoutingGroupConfig, scope: SchedulingPolicy['scope'] = 'selected'): SchedulingPolicy {
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
return {
id: `${SCHEDULING_POLICY_RULE_PREFIX}${id}`,
scope,
models: [],
priorityMode: config.default_policy.priority_mode,
schedulingMode: config.default_policy.scheduling_mode,
policy: createEmptyModelPolicy(DEFAULT_ROUTING_POLICY_MODEL),
}
}
function policySignature(policy: RoutingModelPolicy): string {
return JSON.stringify(policy, (_key, value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)))
})
}
export function readSchedulingPolicies(config: RoutingGroupConfig): SchedulingPolicy[] {
const normalized = normalizeRoutingGroupConfig(config)
const entries: SchedulingPolicy[] = []
const assignedModels = new Set<string>()
const sharedRules = normalized.rules.filter(isGeneratedSchedulingPolicyRule)
for (const rule of sharedRules) {
const grouped = new Map<string, SchedulingPolicy>()
for (const model of schedulingRuleModels(rule)) {
if (assignedModels.has(model)) continue
const policy = { ...getModelPolicy(normalized, model), model: DEFAULT_ROUTING_POLICY_MODEL }
const signature = policySignature(policy)
let entry = grouped.get(signature)
if (!entry) {
const scheduling = getModelScheduling(normalized, model)
const created = createSchedulingPolicy(normalized)
entry = {
...created,
id: grouped.size === 0 ? rule.id : created.id,
priorityMode: scheduling.priority_mode,
schedulingMode: scheduling.scheduling_mode,
policy,
rule,
}
grouped.set(signature, entry)
entries.push(entry)
}
entry.models.push(model)
assignedModels.add(model)
}
}
const legacyModels = new Set([
...normalized.model_policies.map(policy => policy.model),
...normalized.rules.filter(isGeneratedModelSchedulingRule).flatMap(schedulingRuleModels),
])
const legacyGroups = new Map<string, SchedulingPolicy>()
for (const model of legacyModels) {
if (model === DEFAULT_ROUTING_POLICY_MODEL || assignedModels.has(model)) continue
const scheduling = getModelScheduling(normalized, model)
const policy = { ...getModelPolicy(normalized, model), model: DEFAULT_ROUTING_POLICY_MODEL }
const rule = normalized.rules.find(rule => rule.id === modelSchedulingRuleId(model))
const signature = JSON.stringify([
policySignature(policy),
scheduling.priority_mode,
scheduling.scheduling_mode,
rule?.actions,
rule?.enabled,
rule?.phase,
rule?.stop_processing,
])
let entry = legacyGroups.get(signature)
if (!entry) {
entry = {
...createSchedulingPolicy(normalized),
priorityMode: scheduling.priority_mode,
schedulingMode: scheduling.scheduling_mode,
policy,
rule,
}
legacyGroups.set(signature, entry)
entries.push(entry)
}
entry.models.push(model)
}
const defaultPolicy = normalized.model_policies.find(policy => policy.model === DEFAULT_ROUTING_POLICY_MODEL)
if (defaultPolicy || entries.length === 0) {
entries.push({
...createSchedulingPolicy(normalized, 'all'),
policy: defaultPolicy ?? createEmptyModelPolicy(DEFAULT_ROUTING_POLICY_MODEL),
})
}
return entries
}
export function validateSchedulingPolicies(entries: SchedulingPolicy[]): string | null {
if (entries.length === 0) return '请至少添加一条调度配置'
const assignedModels = new Set<string>()
let hasAllModels = false
for (const [index, entry] of entries.entries()) {
if (entry.scope === 'all') {
if (hasAllModels) return '只能有一条适用于全部模型的配置'
hasAllModels = true
continue
}
if (entry.models.length === 0) return `请为配置 ${index + 1} 选择至少一个全局模型`
for (const model of entry.models) {
if (!model.trim() || model === DEFAULT_ROUTING_POLICY_MODEL) return `配置 ${index + 1} 的模型无效`
if (assignedModels.has(model)) return `模型 ${model} 不能重复分配给多条配置`
assignedModels.add(model)
}
}
return null
}
export function writeSchedulingPolicies(config: RoutingGroupConfig, entries: SchedulingPolicy[]): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
const defaultEntry = entries.find(entry => entry.scope === 'all')
if (defaultEntry) {
next.default_policy.priority_mode = defaultEntry.priorityMode
next.default_policy.scheduling_mode = defaultEntry.schedulingMode
}
next.model_policies = defaultEntry
? [{ ...defaultEntry.policy, model: DEFAULT_ROUTING_POLICY_MODEL }]
: []
next.rules = next.rules.filter(rule => !isGeneratedModelSchedulingRule(rule) && !isGeneratedSchedulingPolicyRule(rule))
for (const [index, entry] of entries.entries()) {
if (entry.scope === 'all' || entry.models.length === 0) continue
for (const model of entry.models) {
next.model_policies.push({ ...entry.policy, model })
}
const actions = [...(entry.rule?.actions ?? [])]
const schedulingIndex = actions.findIndex(action => (
Boolean(action) && typeof action === 'object' && (action as { type?: string }).type === 'set_scheduling'
))
const action: RoutingSetSchedulingAction = {
...(schedulingIndex >= 0 ? actions[schedulingIndex] as RoutingSetSchedulingAction : {}),
type: 'set_scheduling',
priority_mode: entry.priorityMode,
scheduling_mode: entry.schedulingMode,
}
if (schedulingIndex >= 0) actions[schedulingIndex] = action
else actions.push(action)
next.rules.push({
priority: 10_000 + index,
enabled: true,
phase: 'client_request',
stop_processing: false,
...entry.rule,
id: entry.id,
conditions: { any: entry.models.map(modelPatternCondition) },
actions,
})
}
return normalizeRoutingGroupConfig(next)
}
export function schedulingPolicyEditorConfig(config: RoutingGroupConfig, entry: SchedulingPolicy): RoutingGroupConfig {
return normalizeRoutingGroupConfig({
default_policy: {
...config.default_policy,
priority_mode: entry.priorityMode,
scheduling_mode: entry.schedulingMode,
},
model_policies: [{ ...entry.policy, model: DEFAULT_ROUTING_POLICY_MODEL }],
rules: [],
})
}