diff --git a/crates/aether-routing-core/src/policy.rs b/crates/aether-routing-core/src/policy.rs index 526128e7e..1b2972da1 100644 --- a/crates/aether-routing-core/src/policy.rs +++ b/crates/aether-routing-core/src/policy.rs @@ -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 { diff --git a/docs/operations/routing-scheduling.md b/docs/operations/routing-scheduling.md new file mode 100644 index 000000000..ab836cb9f --- /dev/null +++ b/docs/operations/routing-scheduling.md @@ -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:` 规则仍可读取;编辑调度配置时转换为新结构。等价的旧模型配置可合并显示,已有自定义规则和全局故障转移设置保留。 +- 新建的不同配置即使调度设置相同,也保留独立的配置范围,重新打开页面后仍可分别编辑。 diff --git a/frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.spec.ts b/frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.spec.ts new file mode 100644 index 000000000..e7b90f57f --- /dev/null +++ b/frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.spec.ts @@ -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(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(root: HTMLElement, label: string): T { + const element = root.querySelector(`[aria-label="${label}"]`) + if (!element) throw new Error(`Missing control: ${label}`) + return element +} + +async function clickText(root: HTMLElement, text: string) { + const element = [...root.querySelectorAll('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(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(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(root, '区分模型') + focusedControl.focus() + await clickText(root, '区分模型') + const picker = control(root, '选择适用模型') + expect(control(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(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(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(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('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(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(root, '搜索全局模型') + search.value = '模型 B' + search.dispatchEvent(new Event('input', { bubbles: true })) + await flush() + control(root, '全选搜索结果').click() + await flush() + expect(readSchedulingPolicies(config.value)[0].models).toEqual(['model-a', 'model-b']) + control(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('[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(root, '选择模型 model-c').checked).toBe(true) + expect(control(root, '选择模型 model-a').checked).toBe(true) + await clickText(root, '完成选择') + control(root, '收起调度配置 1').click() + await nextTick() + expect(control(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(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(root, '选择模型 model-b').checked).toBe(false) + await clickText(root, '清空已选') + expect(picker.textContent).toContain('请选择全局模型') + expect(control(root, '选择模型 model-a').checked).toBe(false) + expect(control(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(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(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(root, '添加调度配置').click() + await flush() + expect(valid.value).toBe(false) + expect(document.querySelector('input[aria-label="选择模型 model-a"]')).toBeNull() + const search = control(root, '搜索全局模型') + search.value = 'model-a' + search.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + expect(control(root, '选择模型 model-a').disabled).toBe(true) + search.value = '' + search.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + control(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(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(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(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(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(root, '添加调度配置').disabled).toBe(true) + control(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(root, '调整排序').click() + await nextTick() + models.value = [] + loading.value = true + await flush() + expect(control(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(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(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(root, '添加调度配置').disabled).toBe(false) + control(root, '展开调度配置 2').click() + await flush() + expect(root.querySelector('[aria-label="选择适用模型"]')).toBeNull() + expect(JSON.stringify(config.value)).toBe(saved) + control(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(root, '添加调度配置').click() + await nextTick() + await select(root, 'model-b') + control(root, '展开调度配置 1').click() + await nextTick() + await select(root, 'model-c') + control(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(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(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(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(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(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(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(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(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(root, '全部模型').disabled).toBe(true) + expect(control(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(root, '添加调度配置').disabled).toBe(true) + expect(JSON.stringify(config.value)).toBe(previous) + }) +}) diff --git a/frontend/src/features/routing/__tests__/schedulingPolicies.spec.ts b/frontend/src/features/routing/__tests__/schedulingPolicies.spec.ts new file mode 100644 index 000000000..8161ef342 --- /dev/null +++ b/frontend/src/features/routing/__tests__/schedulingPolicies.spec.ts @@ -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() + }) +}) diff --git a/frontend/src/features/routing/components/RoutingModelSelector.vue b/frontend/src/features/routing/components/RoutingModelSelector.vue new file mode 100644 index 000000000..77170377c --- /dev/null +++ b/frontend/src/features/routing/components/RoutingModelSelector.vue @@ -0,0 +1,253 @@ + + + diff --git a/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue b/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue new file mode 100644 index 000000000..cf5ca8174 --- /dev/null +++ b/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue @@ -0,0 +1,424 @@ + + + + + diff --git a/frontend/src/features/routing/components/index.ts b/frontend/src/features/routing/components/index.ts index 945c6cb72..7cf9b0430 100644 --- a/frontend/src/features/routing/components/index.ts +++ b/frontend/src/features/routing/components/index.ts @@ -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' diff --git a/frontend/src/features/routing/utils/routingPolicy.ts b/frontend/src/features/routing/utils/routingPolicy.ts index da45935e3..62b76337d 100644 --- a/frontend/src/features/routing/utils/routingPolicy.ts +++ b/frontend/src/features/routing/utils/routingPolicy.ts @@ -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 } diff --git a/frontend/src/features/routing/utils/schedulingPolicies.ts b/frontend/src/features/routing/utils/schedulingPolicies.ts new file mode 100644 index 000000000..ba8488df8 --- /dev/null +++ b/frontend/src/features/routing/utils/schedulingPolicies.ts @@ -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() + const sharedRules = normalized.rules.filter(isGeneratedSchedulingPolicyRule) + + for (const rule of sharedRules) { + const grouped = new Map() + 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() + 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() + 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: [], + }) +} diff --git a/frontend/src/views/admin/RoutingProfiles.vue b/frontend/src/views/admin/RoutingProfiles.vue index e97b6ae5f..cc20e3e6c 100644 --- a/frontend/src/views/admin/RoutingProfiles.vue +++ b/frontend/src/views/admin/RoutingProfiles.vue @@ -509,285 +509,17 @@ @pending-change="routingFailoverPending = $event" /> -
-
-

- 调度配置 -

-

- 先选择调度维度,再配置优先级模式、调度策略和提供商排序。 -

-
-
- 调度维度 -
- - -
-
- -
-
- 优先级模式 -
- - -
-
- -
- 调度策略 -
- -
-
-
-

- 请先在下方选择一个模型,再配置该模型的优先级模式和调度策略。 -

- -
- -
- -
-
-

- 按模型配置 -

-

- 选择模型后,在下方配置该模型的提供商排序。 -

-
-
-
- -
- -
-
- -
- 正在加载模型 -
-
- {{ globalModelsError }} -
-
- {{ globalModelSearch.trim() ? '未匹配到模型' : modelFilter === 'configured' ? '暂无已配置模型' : '暂无未配置模型' }} -
-
-
-
- - - -
- -
- -
-
-
-
-
-
+ @@ -812,16 +544,6 @@ - - = [ - { value: 'unconfigured', label: '未配置' }, - { value: 'configured', label: '已配置' }, -] - const { success, error: showError } = useToast() const route = useRoute() const router = useRouter() @@ -938,12 +639,8 @@ const selectedGroupId = ref(null) const draft = ref(null) const routingFailoverPolicyEditor = ref<{ commitJsonDrafts: () => boolean } | null>(null) const routingFailoverPending = ref(false) +const routingSchedulingValid = ref(true) const savedDraftSnapshot = ref(null) -const sortingScope = ref('unified') -const selectedPerModelName = ref(null) -const editingConfig = ref(null) -const globalModelSearch = ref('') -const modelFilter = ref('unconfigured') const globalModels = ref([]) const loadingGlobalModels = ref(false) const globalModelsError = ref(null) @@ -957,37 +654,12 @@ const dragOverGroupId = ref(null) const isCreating = ref(false) const draftGeneration = ref(0) -const switchModelTarget = ref(null) -const switchModelDialogOpen = ref(false) const deleteDialogOpen = ref(false) const listDeleteTarget = ref(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(() => { - 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(() => { - 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(() => ( draft.value?.config_json.default_policy.keep_priority_on_conversion ?? false )) @@ -1003,55 +675,6 @@ const cyberContinueFailover = computed(() => ( const cancelOnClientDisconnect = computed(() => ( draft.value?.config_json.default_policy.cancel_on_client_disconnect ?? false )) -interface ModelRow { - name: string - displayName: string - configured: boolean -} - -const modelRows = computed(() => { - const query = globalModelSearch.value.trim().toLowerCase() - const seen = new Set() - 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(() => { - 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 { 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 { } } -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 diff --git a/frontend/src/views/admin/__tests__/RoutingProfiles.failover.spec.ts b/frontend/src/views/admin/__tests__/RoutingProfiles.failover.spec.ts index 70c290358..416b214fc 100644 --- a/frontend/src/views/admin/__tests__/RoutingProfiles.failover.spec.ts +++ b/frontend/src/views/admin/__tests__/RoutingProfiles.failover.spec.ts @@ -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('button')].find(control => control.textContent?.trim() === '已配置') - configured?.click() - await nextTick() const loadBalance = [...root.querySelectorAll('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(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('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(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(document.body, `input[aria-label="选择模型 ${model}"]`).click() + await nextTick() + } + const done = [...document.querySelectorAll('[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() + }) })