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
+117
View File
@@ -316,6 +316,123 @@ mod tests {
use super::*;
#[test]
fn all_model_scheduling_and_rankings_apply_to_future_models() {
let config: RoutingGroupConfig = serde_json::from_value(json!({
"default_policy": {
"priority_mode": "global_key",
"scheduling_mode": "load_balance"
},
"model_policies": [{
"model": "*",
"provider_priority_overrides": { "provider-a": 7 }
}],
"rules": []
}))
.expect("all-model scheduling config should deserialize");
for model in ["existing-model", "future-model"] {
let policy = resolve_routing_policy(
&config,
RoutingPolicyInput {
group_id: Some("group-1"),
group_version: Some(1),
selection_source: "explicit",
requested_model: model,
resolved_model: model,
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase: RoutingRulePhase::ClientRequest,
},
)
.expect("all-model scheduling policy should resolve");
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::GlobalKey);
assert_eq!(policy.scheduling_mode, RoutingSchedulingMode::LoadBalance);
assert_eq!(
policy
.ranking_overlay
.provider_priority_overrides
.get("provider-a"),
Some(&7)
);
assert!(policy.matched_rules.is_empty());
}
}
#[test]
fn shared_scheduling_rule_applies_only_to_selected_models() {
let config: RoutingGroupConfig = serde_json::from_value(json!({
"default_policy": {
"priority_mode": "provider",
"scheduling_mode": "cache_affinity"
},
"model_policies": [
{ "model": "model-a", "provider_priority_overrides": { "provider-a": 7 } },
{ "model": "model-b", "provider_priority_overrides": { "provider-a": 7 } }
],
"rules": [{
"id": "ui_scheduling_policy:shared",
"priority": 10000,
"enabled": true,
"phase": "client_request",
"conditions": { "any": [
{ "field": "model", "op": "eq", "value": "model-a" },
{ "field": "model", "op": "eq", "value": "model-b" }
] },
"actions": [{
"type": "set_scheduling",
"priority_mode": "global_key",
"scheduling_mode": "fixed_order"
}],
"stop_processing": false
}]
}))
.expect("shared scheduling config should deserialize");
for model in ["model-a", "model-b", "other-model"] {
let policy = resolve_routing_policy(
&config,
RoutingPolicyInput {
group_id: Some("group-1"),
group_version: Some(1),
selection_source: "explicit",
requested_model: model,
resolved_model: model,
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase: RoutingRulePhase::ClientRequest,
},
)
.expect("shared scheduling policy should resolve");
if model == "other-model" {
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::Provider);
assert_eq!(policy.scheduling_mode, RoutingSchedulingMode::CacheAffinity);
assert!(policy
.ranking_overlay
.provider_priority_overrides
.is_empty());
assert!(policy.matched_rules.is_empty());
} else {
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::GlobalKey);
assert_eq!(policy.scheduling_mode, RoutingSchedulingMode::FixedOrder);
assert_eq!(
policy
.ranking_overlay
.provider_priority_overrides
.get("provider-a"),
Some(&7)
);
assert_eq!(policy.matched_rules.len(), 1);
}
}
}
#[test]
fn resolves_model_policy_and_matching_rule() {
let config = RoutingGroupConfig {
+28
View File
@@ -0,0 +1,28 @@
# 按策略配置模型调度
管理端「调度策略 → 调度配置」按配置组织模型,不再逐个模型编辑和保存。
1. 先选择调度范围「全部模型」或「区分模型」。「全部模型」只显示一套调度设置,不显示模型选择和「添加配置」,保存后自动包含以后新增的模型。
2. 「区分模型」下,在表单内展开适用模型列表并勾选模型,再设置调度优先级(Provider / Key)和调度策略(缓存亲和、负载均衡、固定顺序)。搜索只过滤模型列表,其他配置已占用的模型默认隐藏;通过取消勾选移除模型。选择框显示已选名称或数量,不重复显示已选标签。
3. 设置此配置共用的提供商 / Key 排序。实际请求仍只使用该模型可用的候选,不会因为共用排序而启用不支持该模型的提供商。
4. 「区分模型」下,点击调度配置标题旁的「添加配置」,为剩余模型选择不同策略。收起的配置直接显示适用模型名称,已分配给其他指定配置的模型不能重复选择。
5. 点击页面顶部「保存」统一生效,不需要逐个模型另存草稿。指定范围为空时不能保存。
## 范围语义
- 「全部模型」是独立的动态范围,不是勾选当前列表的快捷操作,保存后也自动适用于之后新增的模型。「区分模型」的列表不提供「全部模型」选项。
- 「全选当前」与「全选结果」只批量勾选当前可选模型,仍属于指定模型范围,不自动包含以后新增的模型。
- 编辑期间切换范围会分别保留两种模式的草稿,切回后恢复原有选择和设置。首次切换时继承当前配置的调度和排序;从多配置首次切到全部模型时,优先继承默认配置,否则使用第一条配置。保存只写入当前模式,全部模型模式会清除模型级的自动生成规则和独立排序。
- 旧数据中的指定模型与全部模型混合配置按「区分模型」加载,原全部模型条目显示为「默认配置」。默认排序仍可被各模型继承并覆盖,且不妨碍为剩余模型添加配置。
- 没有全部模型配置时,未指定的模型继续使用已保存的默认调度设置,不会被禁用。先修改配置再改为指定范围,不会把修改后的调度模式应用到未选择的模型。
- 移除模型或删除配置会同步移除对应的排序覆盖和自动生成的调度规则。
- 故障转移、首个候选重试次数和客户端断开处理等仍作用于整个调度策略,不随模型范围拆分。
## 存储兼容
无需数据库迁移,继续使用 `default_policy``model_policies``rules`
- 全部模型的调度模式写入 `default_policy`,排序写入 `model: "*"` 的模型策略。
- 指定范围的共用排序展开为各模型的 `model_policies`;同一配置使用一条 `ui_scheduling_policy:` 前缀的调度规则,通过 `conditions.any` 匹配适用模型,并使用 `set_scheduling` 设置调度模式。
- 旧的逐模型排序和 `ui_model_scheduling:` 规则仍可读取;编辑调度配置时转换为新结构。等价的旧模型配置可合并显示,已有自定义规则和全局故障转移设置保留。
- 新建的不同配置即使调度设置相同,也保留独立的配置范围,重新打开页面后仍可分别编辑。
@@ -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: [],
})
}
+26 -661
View File
@@ -509,285 +509,17 @@
@pending-change="routingFailoverPending = $event"
/>
<section class="space-y-4 rounded-lg border border-border/60 p-4">
<div>
<h3 class="text-sm font-medium">
调度配置
</h3>
<p class="mt-1 text-xs text-muted-foreground">
先选择调度维度再配置优先级模式调度策略和提供商排序
</p>
</div>
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">调度维度</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="sortingScope === 'unified'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setSortingScope('unified')"
>
统一调度
</button>
<button
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="sortingScope === 'per_model'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="setSortingScope('per_model')"
>
区分模型
</button>
</div>
</div>
<div class="grid gap-3 lg:grid-cols-2">
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">优先级模式</span>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1">
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="firstStepPriorityMode === 'provider'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
:disabled="sortingScope === 'per_model' && !activePerModelPolicy"
@click="updateFirstStepPriorityMode('provider')"
>
<Layers class="h-4 w-4" />
Provider
</button>
<button
type="button"
class="flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-colors"
:class="firstStepPriorityMode === 'global_key'
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
:disabled="sortingScope === 'per_model' && !activePerModelPolicy"
@click="updateFirstStepPriorityMode('global_key')"
>
<Key class="h-4 w-4" />
Key
</button>
</div>
</div>
<div class="space-y-1 text-sm">
<span class="text-muted-foreground">调度策略</span>
<div class="grid grid-cols-3 gap-1 rounded-lg bg-muted/40 p-1">
<button
v-for="mode in schedulingModes"
:key="mode.value"
type="button"
class="h-9 rounded-md px-3 text-sm font-medium transition-colors"
:class="firstStepSchedulingMode === mode.value
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
:disabled="sortingScope === 'per_model' && !activePerModelPolicy"
@click="updateFirstStepSchedulingMode(mode.value)"
>
{{ mode.label }}
</button>
</div>
</div>
</div>
<p
v-if="sortingScope === 'per_model' && !activePerModelPolicy"
class="text-xs text-muted-foreground"
>
请先在下方选择一个模型再配置该模型的优先级模式和调度策略
</p>
<section
v-if="sortingScope === 'unified'"
class="space-y-4"
>
<RoutingPriorityPolicyEditor
:config="draft.config_json"
:model="DEFAULT_ROUTING_POLICY_MODEL"
:show-priority-mode="false"
:show-scheduling-mode="false"
subtitle="统一作用于当前策略的所有模型"
@update:config="updateDraftConfig"
/>
</section>
<section v-else>
<div class="mb-3">
<h3 class="text-sm font-medium">
按模型配置
</h3>
<p class="mt-1 text-xs text-muted-foreground">
选择模型后在下方配置该模型的提供商排序
</p>
</div>
<div class="flex max-h-[560px] flex-col gap-3 overflow-hidden rounded-lg border border-border/60 p-3">
<div class="grid grid-cols-2 gap-3">
<Input
v-model="globalModelSearch"
placeholder="搜索模型"
class="w-full"
/>
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted/40 p-1 text-xs">
<button
v-for="filter in modelFilters"
:key="filter.value"
type="button"
class="h-9 rounded-md px-3 font-medium transition-colors"
:class="modelFilter === filter.value
? 'bg-primary/10 text-primary shadow-sm ring-1 ring-border'
: 'text-muted-foreground hover:bg-background/60 hover:text-foreground'"
@click="modelFilter = filter.value"
>
{{ filter.label }}
</button>
</div>
</div>
<div
v-if="loadingGlobalModels"
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
>
正在加载模型
</div>
<div
v-else-if="globalModelsError"
class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
>
{{ globalModelsError }}
</div>
<div
v-else-if="modelRows.length === 0"
class="rounded-md border border-dashed border-border/70 px-3 py-6 text-center text-xs text-muted-foreground"
>
{{ globalModelSearch.trim() ? '未匹配到模型' : modelFilter === 'configured' ? '暂无已配置模型' : '暂无未配置模型' }}
</div>
<div
v-else
class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1"
>
<div
v-for="row in modelRows"
:key="row.name"
class="rounded-lg border transition-colors"
:class="selectedPerModelName === row.name
? 'border-primary/50 bg-primary/5'
: 'border-border/60'"
>
<div class="flex w-full items-center gap-3 px-4 py-3">
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 text-left text-sm"
@click="selectGlobalModel(row.name)"
>
<span
v-if="row.configured"
class="h-2 w-2 shrink-0 rounded-full bg-primary"
aria-hidden="true"
/>
<Plus
v-else
class="h-3.5 w-3.5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">{{ row.displayName }}</span>
<span class="block truncate text-xs text-muted-foreground">{{ row.name }}</span>
</span>
</button>
<template v-if="selectedPerModelName === row.name && activePerModelPolicy">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
:disabled="copySourceCandidates.length === 0"
title="加载其他模型配置"
>
<Copy class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="max-h-[320px] overflow-y-auto"
>
<DropdownMenuItem
v-for="source in copySourceCandidates"
:key="source.model"
@select="copyModelConfig(source.model)"
>
<span class="min-w-0">
<span class="block truncate text-sm font-medium">{{ source.label }}</span>
<span class="block truncate text-xs text-muted-foreground">{{ source.model }}</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 text-muted-foreground/70 hover:text-foreground"
:disabled="!canSaveCurrentModel"
title="保存到草稿"
@click="saveCurrentModel"
>
<Save class="h-4 w-4" />
</Button>
<Button
v-if="hasModelPolicy(activePerModelPolicy.model)"
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:class="canRemoveCurrentModel ? 'text-muted-foreground/70 hover:text-destructive' : 'text-muted-foreground/30'"
:disabled="!canRemoveCurrentModel"
:title="canRemoveCurrentModel ? '移除当前模型排序' : '当前有未保存改动,不能移除'"
@click="removePerModelPolicy(activePerModelPolicy.model)"
>
<Trash2 class="h-4 w-4" />
</Button>
</template>
<button
type="button"
class="shrink-0"
@click="selectGlobalModel(row.name)"
>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="selectedPerModelName === row.name ? 'rotate-180' : ''"
/>
</button>
</div>
<div
v-if="selectedPerModelName === row.name && activePerModelPolicy"
class="border-t border-border/60 p-4"
>
<RoutingPriorityPolicyEditor
:config="activeConfigForReading"
:model="activePerModelPolicy.model"
:model-id="globalModelIdFor(activePerModelPolicy.model)"
:priority-mode="modelPriorityMode(activePerModelPolicy.model)"
:scheduling-mode="modelSchedulingMode(activePerModelPolicy.model)"
:show-priority-mode="false"
:show-scheduling-mode="false"
:subtitle="`仅作用于 ${activePerModelPolicy.model}`"
@update:config="updateEditingConfig"
@update:priority-mode="mode => activePerModelPolicy && updateModelPriorityMode(activePerModelPolicy.model, mode)"
@update:scheduling-mode="mode => activePerModelPolicy && updateModelSchedulingMode(activePerModelPolicy.model, mode)"
/>
</div>
</div>
</div>
</div>
</section>
</section>
<RoutingSchedulingPolicyEditor
:key="draftGeneration"
:config="draft.config_json"
:global-models="globalModels"
:loading-models="loadingGlobalModels"
:models-error="globalModelsError"
:disabled="saving"
@update:config="updateDraftConfig"
@validity-change="routingSchedulingValid = $event"
@reload-models="loadGlobalModels()"
/>
</div>
</Card>
@@ -812,16 +544,6 @@
</Card>
</section>
<AlertDialog
v-model="switchModelDialogOpen"
type="warning"
title="切换模型"
description="当前模型有未保存的改动切换将丢弃这些改动是否继续"
confirm-text="继续"
@confirm="confirmSwitchModel"
@cancel="cancelSwitchModel"
/>
<AlertDialog
v-model="deleteDialogOpen"
type="destructive"
@@ -839,12 +561,8 @@ import { getI18nLocale } from '@/i18n'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ChevronDown,
ChevronRight,
Copy,
GripVertical,
Key,
Layers,
Plus,
Power,
Save,
@@ -868,30 +586,20 @@ import {
TableHeader,
TableRow,
} from '@/components/ui'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { AlertDialog } from '@/components/common'
import HelpHint from '@/components/common/HelpHint.vue'
import {
DEFAULT_ROUTING_POLICY_MODEL,
DEFAULT_STICKY_KEY_ATTEMPTS,
copyPerModelRoutingConfig,
createEmptyModelPolicy,
createEmptyRoutingGroupConfig,
getModelScheduling,
isGeneratedModelSchedulingRule,
modelSchedulingRuleId,
isGeneratedSchedulingPolicyRule,
normalizeRoutingGroupConfig,
normalizeStickyKeyAttempts,
removePerModelRoutingConfig,
savePerModelRoutingConfig,
setRoutingSortingScope,
upsertModelSchedulingRule,
type RoutingGroupConfig,
type RoutingPriorityMode,
type RoutingSchedulingMode,
type RoutingSortingScope,
} from '@/features/routing/utils/routingPolicy'
import { RoutingFailoverPolicyEditor, RoutingPriorityPolicyEditor } from '@/features/routing/components'
import { RoutingFailoverPolicyEditor, RoutingSchedulingPolicyEditor } from '@/features/routing/components'
import { normalizeRoutingFailoverPolicy, validateRoutingFailoverPolicy, type RoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
import {
createRoutingGroup,
@@ -916,13 +624,6 @@ interface RoutingGroupDraft {
updated_at?: number | null
}
type ModelFilter = 'configured' | 'unconfigured'
const modelFilters: Array<{ value: ModelFilter; label: string }> = [
{ value: 'unconfigured', label: '未配置' },
{ value: 'configured', label: '已配置' },
]
const { success, error: showError } = useToast()
const route = useRoute()
const router = useRouter()
@@ -938,12 +639,8 @@ const selectedGroupId = ref<string | null>(null)
const draft = ref<RoutingGroupDraft | null>(null)
const routingFailoverPolicyEditor = ref<{ commitJsonDrafts: () => boolean } | null>(null)
const routingFailoverPending = ref(false)
const routingSchedulingValid = ref(true)
const savedDraftSnapshot = ref<string | null>(null)
const sortingScope = ref<RoutingSortingScope>('unified')
const selectedPerModelName = ref<string | null>(null)
const editingConfig = ref<RoutingGroupConfig | null>(null)
const globalModelSearch = ref('')
const modelFilter = ref<ModelFilter>('unconfigured')
const globalModels = ref<GlobalModelResponse[]>([])
const loadingGlobalModels = ref(false)
const globalModelsError = ref<string | null>(null)
@@ -957,37 +654,12 @@ const dragOverGroupId = ref<string | null>(null)
const isCreating = ref(false)
const draftGeneration = ref(0)
const switchModelTarget = ref<string | null>(null)
const switchModelDialogOpen = ref(false)
const deleteDialogOpen = ref(false)
const listDeleteTarget = ref<RoutingGroupRecord | null>(null)
const isCreateRoute = computed(() => route.name === 'RoutingProfileCreate')
const routeGroupId = computed(() => paramToString(route.params.groupId))
const isDetailView = computed(() => isCreateRoute.value || route.name === 'RoutingProfileDetail')
const perModelPolicies = computed(() => {
return draft.value?.config_json.model_policies
.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL)
?? []
})
const activePerModelPolicy = computed(() => {
if (!selectedPerModelName.value) return null
const existing = perModelPolicies.value.find(policy => policy.model === selectedPerModelName.value)
if (existing) return existing
return createEmptyModelPolicy(selectedPerModelName.value)
})
const firstStepPriorityMode = computed<RoutingPriorityMode>(() => {
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
return modelPriorityMode(activePerModelPolicy.value.model)
}
return draft.value?.config_json.default_policy.priority_mode ?? 'provider'
})
const firstStepSchedulingMode = computed<RoutingSchedulingMode>(() => {
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
return modelSchedulingMode(activePerModelPolicy.value.model)
}
return draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity'
})
const keepPriorityOnConversion = computed<boolean>(() => (
draft.value?.config_json.default_policy.keep_priority_on_conversion ?? false
))
@@ -1003,55 +675,6 @@ const cyberContinueFailover = computed<boolean>(() => (
const cancelOnClientDisconnect = computed<boolean>(() => (
draft.value?.config_json.default_policy.cancel_on_client_disconnect ?? false
))
interface ModelRow {
name: string
displayName: string
configured: boolean
}
const modelRows = computed<ModelRow[]>(() => {
const query = globalModelSearch.value.trim().toLowerCase()
const seen = new Set<string>()
const rows: ModelRow[] = []
for (const policy of perModelPolicies.value) {
const name = policy.model
const found = globalModels.value.find(item => item.name === name)
rows.push({
name,
displayName: found?.display_name || name,
configured: true,
})
seen.add(name)
}
for (const model of globalModels.value) {
if (seen.has(model.name)) continue
rows.push({
name: model.name,
displayName: model.display_name || model.name,
configured: false,
})
}
return rows
.filter(row => {
if (modelFilter.value === 'configured' && !row.configured) return false
if (modelFilter.value === 'unconfigured' && row.configured) return false
if (!query) return true
return (
row.name.toLowerCase().includes(query)
|| row.displayName.toLowerCase().includes(query)
)
})
.sort((left, right) => {
if (left.configured !== right.configured) {
return left.configured ? -1 : 1
}
return left.name.localeCompare(right.name)
})
})
function normalizeRecord(group: RoutingGroupRecord): RoutingGroupRecord {
return {
...group,
@@ -1103,14 +726,11 @@ function paramToString(value: unknown): string | null {
function clearDraftState(): void {
draftGeneration.value += 1
routingFailoverPending.value = false
routingSchedulingValid.value = true
isCreating.value = false
selectedGroupId.value = null
draft.value = null
savedDraftSnapshot.value = null
selectedPerModelName.value = null
editingConfig.value = null
switchModelTarget.value = null
switchModelDialogOpen.value = false
deleteDialogOpen.value = false
listDeleteTarget.value = null
}
@@ -1119,12 +739,11 @@ function selectGroup(group: RoutingGroupRecord): void {
const normalized = normalizeRecord(group)
draftGeneration.value += 1
routingFailoverPending.value = false
routingSchedulingValid.value = true
isCreating.value = false
selectedGroupId.value = normalized.id
draft.value = buildDraft(normalized)
savedDraftSnapshot.value = draftSnapshotValue(draft.value)
syncEditorStateFromConfig(draft.value.config_json)
resetEditingConfig()
}
function setDraftEnabled(value: boolean): void {
@@ -1135,6 +754,7 @@ function setDraftEnabled(value: boolean): void {
function startCreate(): void {
draftGeneration.value += 1
routingFailoverPending.value = false
routingSchedulingValid.value = true
isCreating.value = true
selectedGroupId.value = null
draft.value = {
@@ -1147,8 +767,6 @@ function startCreate(): void {
updated_at: null,
}
savedDraftSnapshot.value = null
syncEditorStateFromConfig(draft.value.config_json)
resetEditingConfig()
}
function syncRouteState(): void {
@@ -1199,124 +817,34 @@ function schedulingModeLabel(mode: RoutingSchedulingMode): string {
}
function groupSortingScopeLabel(group: RoutingGroupRecord): string {
return hasPerModelSorting(normalizeRoutingGroupConfig(group.config_json)) ? '区分模型' : '统一调度'
return hasPerModelSorting(normalizeRoutingGroupConfig(group.config_json)) ? '指定模型' : '全部模型'
}
function groupSchedulingSummary(group: RoutingGroupRecord): string {
const config = normalizeRoutingGroupConfig(group.config_json)
if (hasPerModelSorting(config)) return '按模型配置'
if (hasPerModelSorting(config)) return '按适用范围配置'
return schedulingModeLabel(config.default_policy.scheduling_mode)
}
function updateDraftConfig(value: RoutingGroupConfig): void {
if (!draft.value) return
draft.value.config_json = normalizeRoutingGroupConfig(value)
syncSelectedPerModelPolicy()
}
function resetEditingConfig(): void {
if (!draft.value) {
editingConfig.value = null
return
}
editingConfig.value = cloneConfig(draft.value.config_json)
}
function updateEditingConfig(value: RoutingGroupConfig): void {
editingConfig.value = normalizeRoutingGroupConfig(value)
}
const editingDirty = computed(() => {
if (!editingConfig.value || !draft.value) return false
return JSON.stringify(editingConfig.value) !== JSON.stringify(draft.value.config_json)
})
const draftDirty = computed(() => {
if (!draft.value) return false
if (isCreating.value) return true
return routingFailoverPending.value || savedDraftSnapshot.value !== draftSnapshotValue(draft.value)
})
const canSaveDraft = computed(() => {
const hasPendingCurrentModel = perModelEditingActive.value
&& Boolean(activePerModelPolicy.value)
&& (editingDirty.value || !currentModelPersisted.value)
return Boolean(draft.value)
&& !saving.value
&& draftDirty.value
&& !hasPendingCurrentModel
&& !(perModelEditingActive.value && perModelPolicies.value.length === 0)
})
const currentModelPersisted = computed(() => {
const model = activePerModelPolicy.value?.model
return model ? hasModelPolicy(model) : false
})
const canSaveCurrentModel = computed(() => {
return Boolean(activePerModelPolicy.value)
&& !saving.value
&& (editingDirty.value || !currentModelPersisted.value)
})
const canRemoveCurrentModel = computed(() => {
return Boolean(activePerModelPolicy.value)
&& currentModelPersisted.value
&& !saving.value
&& !editingDirty.value
})
function syncEditorStateFromConfig(config: RoutingGroupConfig): void {
const normalized = normalizeRoutingGroupConfig(config)
sortingScope.value = hasPerModelSorting(normalized) ? 'per_model' : 'unified'
syncSelectedPerModelPolicy()
}
const canSaveDraft = computed(() => Boolean(draft.value)
&& !saving.value
&& draftDirty.value
&& routingSchedulingValid.value)
function hasPerModelSorting(config: RoutingGroupConfig): boolean {
return config.model_policies.some(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL)
|| config.rules.some(isGeneratedModelSchedulingRule)
}
function setSortingScope(scope: RoutingSortingScope): void {
if (!draft.value) return
sortingScope.value = scope
if (scope === 'unified') {
const next = setRoutingSortingScope(draft.value.config_json, scope)
updateDraftConfig(next)
resetEditingConfig()
return
}
resetEditingConfig()
}
function updateFirstStepPriorityMode(mode: RoutingPriorityMode): void {
if (!draft.value) return
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
updateModelPriorityMode(activePerModelPolicy.value.model, mode)
return
}
updateDraftConfig({
...draft.value.config_json,
default_policy: {
...draft.value.config_json.default_policy,
priority_mode: mode,
},
})
}
function updateFirstStepSchedulingMode(mode: RoutingSchedulingMode): void {
if (!draft.value) return
if (sortingScope.value === 'per_model' && activePerModelPolicy.value) {
updateModelSchedulingMode(activePerModelPolicy.value.model, mode)
return
}
updateDraftConfig({
...draft.value.config_json,
default_policy: {
...draft.value.config_json.default_policy,
scheduling_mode: mode,
},
})
|| config.rules.some(rule => isGeneratedModelSchedulingRule(rule) || isGeneratedSchedulingPolicyRule(rule))
}
function updateStickyKeyAttempts(value: string | number): void {
@@ -1359,155 +887,6 @@ function updateRoutingFailoverPolicy(value: RoutingFailoverPolicy): void {
if (!draft.value) return
const patch = normalizeRoutingFailoverPolicy(value)
Object.assign(draft.value.config_json.default_policy, patch)
if (editingConfig.value) {
Object.assign(editingConfig.value.default_policy, normalizeRoutingFailoverPolicy(patch))
}
}
function removePerModelPolicy(model: string): void {
if (!draft.value) return
if (perModelEditingActive.value && editingDirty.value) {
showError('请先保存当前改动后再移除模型')
return
}
const next = removePerModelRoutingConfig(draft.value.config_json, model)
if (selectedPerModelName.value === model) {
selectedPerModelName.value = null
}
modelFilter.value = 'unconfigured'
updateDraftConfig(next)
resetEditingConfig()
}
function selectGlobalModel(model: string): void {
if (!model) return
if (model === selectedPerModelName.value) {
resetEditingConfig()
selectedPerModelName.value = null
return
}
const shouldAddModel = !hasModelPolicy(model)
if (perModelEditingActive.value && editingDirty.value) {
switchModelTarget.value = model
switchModelDialogOpen.value = true
return
}
if (shouldAddModel) {
resetEditingConfig()
}
selectedPerModelName.value = model
}
function confirmSwitchModel(): void {
const target = switchModelTarget.value
if (target) {
resetEditingConfig()
selectedPerModelName.value = target
}
switchModelTarget.value = null
switchModelDialogOpen.value = false
}
function cancelSwitchModel(): void {
switchModelTarget.value = null
}
function hasModelPolicy(model: string): boolean {
if (perModelPolicies.value.some(policy => policy.model === model)) return true
const ruleId = modelSchedulingRuleId(model)
return draft.value?.config_json.rules.some(rule => rule.id === ruleId) ?? false
}
const copySourceCandidates = computed(() => {
if (!draft.value) return []
const current = selectedPerModelName.value
return perModelPolicies.value
.filter(policy => policy.model !== current)
.map(policy => ({
model: policy.model,
label: globalModelLabel(policy.model),
}))
})
function copyModelConfig(sourceModel: string): void {
if (!draft.value || !editingConfig.value) return
const target = selectedPerModelName.value
if (!target || target === sourceModel) return
const next = copyPerModelRoutingConfig(
editingConfig.value,
draft.value.config_json,
sourceModel,
target,
)
updateEditingConfig(next)
success(`已加载 ${globalModelLabel(sourceModel)} 的配置,点击保存生效`)
}
function syncSelectedPerModelPolicy(): void {
if (selectedPerModelName.value) return
const firstConfigured = perModelPolicies.value[0]?.model
selectedPerModelName.value = firstConfigured ?? null
}
const perModelEditingActive = computed(() => sortingScope.value === 'per_model')
const activeConfigForReading = computed<RoutingGroupConfig>(() => {
if (perModelEditingActive.value && editingConfig.value) return editingConfig.value
return draft.value?.config_json ?? createEmptyRoutingGroupConfig()
})
function modelPriorityMode(model: string): RoutingPriorityMode {
return getModelScheduling(activeConfigForReading.value, model).priority_mode
}
function modelSchedulingMode(model: string): RoutingSchedulingMode {
return getModelScheduling(activeConfigForReading.value, model).scheduling_mode
}
function updateModelPriorityMode(model: string, mode: RoutingPriorityMode): void {
if (!draft.value) return
const baseConfig = perModelEditingActive.value && editingConfig.value
? editingConfig.value
: draft.value.config_json
const current = getModelScheduling(baseConfig, model)
const next = upsertModelSchedulingRule(baseConfig, model, {
priority_mode: mode,
scheduling_mode: current.scheduling_mode,
})
if (perModelEditingActive.value) {
updateEditingConfig(next)
return
}
updateDraftConfig(next)
}
function updateModelSchedulingMode(model: string, mode: RoutingSchedulingMode): void {
if (!draft.value) return
const baseConfig = perModelEditingActive.value && editingConfig.value
? editingConfig.value
: draft.value.config_json
const current = getModelScheduling(baseConfig, model)
const next = upsertModelSchedulingRule(baseConfig, model, {
priority_mode: current.priority_mode,
scheduling_mode: mode,
})
if (perModelEditingActive.value) {
updateEditingConfig(next)
return
}
updateDraftConfig(next)
}
function globalModelLabel(modelName: string): string {
const model = globalModels.value.find(item => item.name === modelName)
if (!model) return modelName
if (!model.display_name || model.display_name === model.name) return model.name
return `${model.display_name} (${model.name})`
}
function globalModelIdFor(modelName: string): string | undefined {
const normalizedName = modelName.trim()
return globalModels.value.find(item => item.name.trim() === normalizedName)?.id
}
function replaceGroup(group: RoutingGroupRecord, select = true): void {
@@ -1679,8 +1058,8 @@ async function saveDraft(): Promise<void> {
return
}
const config = cloneConfig(draft.value.config_json)
if (sortingScope.value === 'per_model' && perModelPolicies.value.length === 0) {
showError('按模型排序时至少选择一个模型')
if (!routingSchedulingValid.value) {
showError('请为每条调度配置选择适用模型')
return
}
@@ -1733,20 +1112,6 @@ async function saveDraft(): Promise<void> {
}
}
function saveCurrentModel(): void {
if (!draft.value || !editingConfig.value) return
const model = selectedPerModelName.value
if (!model) {
showError('请先选择模型')
return
}
const next = savePerModelRoutingConfig(editingConfig.value, model)
updateDraftConfig(next)
modelFilter.value = 'configured'
resetEditingConfig()
success('当前模型配置已保存到草稿,点击外层保存后生效')
}
function deleteDraft(): void {
if (!draft.value?.id) return
listDeleteTarget.value = null
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, reactive, type App } from 'vue'
import RoutingProfiles from '../RoutingProfiles.vue'
import { createEmptyRoutingGroupConfig, getModelScheduling, savePerModelRoutingConfig } from '@/features/routing/utils/routingPolicy'
import { createSchedulingPolicy, readSchedulingPolicies, writeSchedulingPolicies } from '@/features/routing/utils/schedulingPolicies'
import type { RoutingGroupRecord, RoutingGroupUpdateRequest } from '@/api/routing-profiles'
const routingApi = vi.hoisted(() => ({
@@ -11,17 +12,19 @@ const routingApi = vi.hoisted(() => ({
deleteRoutingGroup: vi.fn(),
}))
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
const globalModelsApi = vi.hoisted(() => ({ getGlobalModels: vi.fn() }))
const route = reactive({ name: 'RoutingProfileDetail', params: { groupId: 'strategy-a' } })
vi.mock('@/api/routing-profiles', () => routingApi)
vi.mock('@/api/global-models', () => ({ getGlobalModels: vi.fn().mockResolvedValue({ models: [] }) }))
vi.mock('@/api/global-models', () => globalModelsApi)
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn() } }))
vi.mock('@/features/routing/components', async () => ({
RoutingFailoverPolicyEditor: (await import('@/features/routing/components/RoutingFailoverPolicyEditor.vue')).default,
RoutingPriorityPolicyEditor: { render: () => null },
RoutingSchedulingPolicyEditor: (await import('@/features/routing/components/RoutingSchedulingPolicyEditor.vue')).default,
}))
vi.mock('@/features/routing/components/RoutingPriorityPolicyEditor.vue', () => ({ default: { render: () => null } }))
const mounted: Array<{ app: App, root: HTMLElement }> = []
@@ -86,6 +89,15 @@ async function editJson(root: HTMLElement, section: string, value: string) {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
})
globalModelsApi.getGlobalModels.mockResolvedValue({ models: [
{ id: 'id-a', name: 'model-a', display_name: '模型 A' },
{ id: 'id-b', name: 'model-b', display_name: '模型 B' },
] })
route.name = 'RoutingProfileDetail'
route.params.groupId = 'strategy-a'
})
@@ -95,6 +107,7 @@ afterEach(() => {
app.unmount()
root.remove()
}
vi.unstubAllGlobals()
})
describe('RoutingProfiles failover persistence', () => {
@@ -144,13 +157,10 @@ describe('RoutingProfiles failover persistence', () => {
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toEqual([])
})
it('preserves global failover edits while saving an independently edited model', async () => {
it('saves scoped scheduling and global failover edits together without a per-model save', async () => {
const strategy = group('strategy-a')
strategy.config_json = savePerModelRoutingConfig(strategy.config_json, 'model-a')
const root = await mountPage([strategy])
const configured = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '已配置')
configured?.click()
await nextTick()
const loadBalance = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '负载均衡')
if (!loadBalance) throw new Error('Missing model scheduling control')
loadBalance.click()
@@ -159,9 +169,6 @@ describe('RoutingProfiles failover persistence', () => {
button(root, '添加错误终止规则').click()
await nextTick()
await input(root, '终止规则 1 状态码', '429')
expect(button(root, '保存').disabled).toBe(true)
element<HTMLButtonElement>(root, 'button[title="保存到草稿"]').click()
await nextTick()
expect(button(root, '保存').disabled).toBe(false)
button(root, '保存').click()
await flush()
@@ -172,4 +179,69 @@ describe('RoutingProfiles failover persistence', () => {
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
expect(toast.error).not.toHaveBeenCalled()
})
it('blocks saving an empty scope and persists all selected models in one strategy', async () => {
const root = await mountPage()
const byText = (text: string) => {
const found = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === text)
if (!found) throw new Error(`Missing control: ${text}`)
return found
}
button(root, '区分模型').click()
await flush()
expect(button(root, '保存').disabled).toBe(true)
element<HTMLInputElement>(document.body, 'input[aria-label="选择模型 model-a"]').click()
await flush()
button(document.body, '清空已选').click()
await flush()
expect(button(root, '保存').disabled).toBe(true)
for (const model of ['model-a', 'model-b']) {
element<HTMLInputElement>(document.body, `input[aria-label="选择模型 ${model}"]`).click()
await nextTick()
}
const done = [...document.querySelectorAll<HTMLButtonElement>('[aria-label="全局模型选择列表"] button')]
.find(control => control.textContent?.trim() === '完成选择')
done?.click()
await flush()
byText('负载均衡').click()
await nextTick()
expect(button(root, '保存').disabled).toBe(false)
button(root, '保存').click()
await flush()
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(saved.model_policies.map((policy: { model: string }) => policy.model)).toEqual(['model-a', 'model-b'])
expect(saved.rules).toHaveLength(1)
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(saved, 'model-b').scheduling_mode).toBe('load_balance')
expect(getModelScheduling(saved, 'other-model').scheduling_mode).toBe('cache_affinity')
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(1)
expect(button(root, '保存').disabled).toBe(true)
})
it('saves one all-model configuration after switching from multiple model-specific configurations', async () => {
const strategy = group('strategy-a')
const first = { ...createSchedulingPolicy(strategy.config_json), models: ['model-a'], schedulingMode: 'fixed_order' as const }
const second = { ...createSchedulingPolicy(strategy.config_json), models: ['model-b'], schedulingMode: 'load_balance' as const }
strategy.config_json = writeSchedulingPolicies(strategy.config_json, [first, second])
const root = await mountPage([strategy])
expect(button(root, '区分模型').getAttribute('aria-pressed')).toBe('true')
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(2)
button(root, '全部模型').click()
await flush()
expect(root.querySelectorAll('section[aria-label^="调度配置 "]')).toHaveLength(1)
expect(root.querySelector('[aria-label="添加调度配置"]')).toBeNull()
expect(button(root, '保存').disabled).toBe(false)
button(root, '保存').click()
await flush()
expect(routingApi.updateRoutingGroup).toHaveBeenCalledOnce()
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
expect(readSchedulingPolicies(saved)).toHaveLength(1)
expect(readSchedulingPolicies(saved)[0]).toMatchObject({ scope: 'all', models: [], schedulingMode: 'fixed_order' })
expect(saved.rules).toEqual([])
expect(saved.model_policies.map((policy: { model: string }) => policy.model)).toEqual(['*'])
expect(getModelScheduling(saved, 'model-b').scheduling_mode).toBe('fixed_order')
expect(getModelScheduling(saved, 'future-model').scheduling_mode).toBe('fixed_order')
expect(button(root, '保存').disabled).toBe(true)
expect(toast.error).not.toHaveBeenCalled()
})
})