mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
feat(routing): simplify model scheduling configuration
This commit is contained in:
@@ -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: [],
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user