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
+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()
})
})