feat(routing): consolidate scheduling strategy configuration

This commit is contained in:
fawney
2026-09-03 11:05:59 +08:00
parent e8d9877b79
commit 2cb4d554aa
88 changed files with 1836 additions and 3635 deletions
File diff suppressed because it is too large Load Diff
@@ -37,6 +37,7 @@
:provider-proxy-node-name="getProviderProxyNodeName()"
:saving-provider-proxy="savingProviderProxy"
@toggle-format-conversion="toggleFormatConversion"
@toggle-keep-priority-on-conversion="toggleKeepPriorityOnConversion"
@open-failover-rules="failoverRulesDialogOpen = true"
@set-provider-proxy="setProviderProxy"
@clear-provider-proxy="clearProviderProxy"
@@ -1412,6 +1413,24 @@ async function toggleFormatConversion() {
}
}
async function toggleKeepPriorityOnConversion() {
if (!provider.value) return
const formatConversionAvailable =
provider.value.enable_format_conversion || systemFormatConversionEnabled.value
if (!formatConversionAvailable) return
const newValue = !provider.value.keep_priority_on_conversion
try {
const updated = await updateProvider(provider.value.id, {
keep_priority_on_conversion: newValue,
})
applyProviderSnapshot(updated)
showSuccess(legacyT(newValue ? '已启用格式转换保持优先级' : '已禁用格式转换保持优先级'))
emit('refresh')
} catch {
showError(legacyT('切换格式转换保持优先级失败'))
}
}
function getProviderProxyNodeName(): string {
const nodeId = provider.value?.proxy?.node_id
if (!nodeId) return legacyT('未知节点')
@@ -24,6 +24,17 @@
<Shuffle class="w-4 h-4" />
</Button>
</span>
<span :title="keepPriorityTitle">
<Button
variant="ghost"
size="icon"
:class="provider.keep_priority_on_conversion ? 'text-primary' : ''"
:disabled="!formatConversionAvailable"
@click="$emit('toggleKeepPriorityOnConversion')"
>
<Layers class="w-4 h-4" />
</Button>
</span>
<span :title="legacyT(hasFailoverRules ? '已配置故障转移规则(点击编辑)' : '配置故障转移规则')">
<Button
variant="ghost"
@@ -163,7 +174,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Edit, GitBranch, Globe, Loader2, Plus, Power, Shuffle, X } from 'lucide-vue-next'
import { Edit, GitBranch, Globe, Layers, Loader2, Plus, Power, Shuffle, X } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
@@ -185,6 +196,7 @@ const props = defineProps<{
defineEmits<{
(e: 'toggleFormatConversion'): void
(e: 'toggleKeepPriorityOnConversion'): void
(e: 'openFailoverRules'): void
(e: 'update:providerProxyPopoverOpen', value: boolean): void
(e: 'setProviderProxy', value: string): void
@@ -203,4 +215,15 @@ const formatConversionTitle = computed(() => {
if (props.provider.enable_format_conversion) return legacyT('已启用格式转换(点击关闭)')
return legacyT('启用格式转换')
})
const formatConversionAvailable = computed(() => (
props.provider.enable_format_conversion || props.systemFormatConversionEnabled
))
const keepPriorityTitle = computed(() => {
if (!formatConversionAvailable.value) return legacyT('请先启用格式转换')
return props.provider.keep_priority_on_conversion
? legacyT('已启用格式转换保持优先级(点击关闭)')
: legacyT('启用格式转换保持优先级')
})
</script>
@@ -280,19 +280,6 @@
{{ legacyT('功能开关') }}
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">{{ legacyT('格式转换保持优先级') }}</span>
<p class="text-xs text-muted-foreground">
{{ legacyT('跨格式请求时保持原优先级排名,不降级到格式匹配的提供商之后') }}
</p>
</div>
<Switch
:model-value="form.keep_priority_on_conversion"
@update:model-value="(v: boolean) => form.keep_priority_on_conversion = v"
/>
</div>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">{{ legacyT('号池调度模式') }}</span>
@@ -475,7 +462,6 @@ const form = ref({
quota_last_reset_at: '', // 周期开始时间
quota_expires_at: '',
provider_priority: 100,
keep_priority_on_conversion: false, // 格式转换时是否保持优先级
// 状态配置
is_active: true,
rate_limit: undefined as number | undefined,
@@ -510,7 +496,6 @@ function resetForm() {
quota_last_reset_at: '',
quota_expires_at: '',
provider_priority: defaultPriority.value,
keep_priority_on_conversion: false,
is_active: true,
rate_limit: undefined,
concurrent_limit: undefined,
@@ -548,7 +533,6 @@ function loadProviderData() {
quota_last_reset_at: formatDateTimeLocalInput(props.provider.quota_last_reset_at),
quota_expires_at: formatDateTimeLocalInput(props.provider.quota_expires_at),
provider_priority: props.provider.provider_priority || 999,
keep_priority_on_conversion: props.provider.keep_priority_on_conversion ?? false,
is_active: props.provider.is_active,
rate_limit: undefined,
concurrent_limit: undefined,
@@ -625,7 +609,6 @@ const handleSubmit = async () => {
quota_reset_day: form.value.quota_reset_day,
quota_last_reset_at: quotaLastResetAt,
quota_expires_at: quotaExpiresAt,
keep_priority_on_conversion: form.value.keep_priority_on_conversion,
responses_websocket_enabled: form.value.responses_websocket_enabled,
is_active: form.value.is_active,
// 请求配置
@@ -98,19 +98,6 @@
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 调度策略 -->
<button
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
:title="legacyT('点击调整调度策略')"
@click="$emit('openPriorityDialog')"
>
<span class="text-muted-foreground/80 hidden sm:inline">{{ legacyT('调度:') }}</span>
<span class="font-medium text-foreground/90">{{ priorityModeLabel }}</span>
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
</button>
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
variant="ghost"
@@ -141,7 +128,7 @@
</template>
<script setup lang="ts">
import { Search, Plus, ChevronDown, FilterX, Users } from 'lucide-vue-next'
import { Search, Plus, FilterX, Users } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Select from '@/components/ui/select.vue'
@@ -162,7 +149,6 @@ defineProps<{
apiFormatFilters: FilterOption[]
modelFilters: FilterOption[]
hasActiveFilters: boolean
priorityModeLabel: string
loading: boolean
}>()
@@ -172,7 +158,6 @@ defineEmits<{
'update:filterApiFormat': [value: string]
'update:filterModel': [value: string]
'resetFilters': []
'openPriorityDialog': []
'batchProcess': []
'addProvider': []
'refresh': []
@@ -9,7 +9,6 @@ export { default as EndpointFormDialog } from './EndpointFormDialog.vue'
export { default as KeyFormDialog } from './KeyFormDialog.vue'
export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue'
export { default as KeyAllowedModelsEditDialog } from './KeyAllowedModelsEditDialog.vue'
export { default as PriorityManagementDialog } from './PriorityManagementDialog.vue'
export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vue'
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
@@ -2,27 +2,17 @@ import { describe, expect, it } from 'vitest'
import {
DEFAULT_ROUTING_POLICY_MODEL,
allowedModelsMirrorPerModelPolicies,
clearAllowedModels,
copyPerModelRoutingConfig,
createEmptyModelPolicy,
createEmptyRoutingGroupConfig,
formatAllowedModelsInput,
getDefaultModelPolicy,
getModelScheduling,
modelSchedulingRuleId,
normalizeRoutingGroupConfig,
normalizeStickyKeyAttempts,
parseAllowedModelsInput,
removePerModelRoutingConfig,
resolveModelKeyPriorityOverride,
routingModelScopeLabel,
savePerModelRoutingConfig,
setDefaultPoolPriorityOverrides,
setDefaultProviderPriorityOverrides,
setModelKeyPriorityOverridesForFormat,
setRoutingSortingScope,
updateAllowedModelsFromInput,
upsertModelSchedulingRule,
upsertModelPolicy,
} from '../utils/routingPolicy'
@@ -30,13 +20,18 @@ import { sortCandidateTraces, summarizeRoutingTrace, type RoutingDecisionTrace }
describe('routingPolicy', () => {
it('normalizes partial configs with stable defaults', () => {
const config = normalizeRoutingGroupConfig({
allowed_models: ['gpt-5'],
})
const config = normalizeRoutingGroupConfig({})
expect(config.default_policy.priority_mode).toBe('provider')
expect(config.default_policy.scheduling_mode).toBe('cache_affinity')
expect(config.allowed_models).toEqual(['gpt-5'])
})
it('drops the legacy group model allowlist while normalizing config', () => {
const config = normalizeRoutingGroupConfig({
allowed_models: ['legacy-model'],
} as unknown as Parameters<typeof normalizeRoutingGroupConfig>[0])
expect(config).not.toHaveProperty('allowed_models')
})
it('upserts model policies by model name', () => {
@@ -155,115 +150,6 @@ describe('routingPolicy', () => {
scheduling_mode: 'fixed_order',
})
})
it('updates the model allowlist only through explicit scope controls', () => {
const config = normalizeRoutingGroupConfig({
allowed_models: ['legacy-model'],
})
expect(parseAllowedModelsInput(' gpt-5\nclaude-*\nlegacy-model\ngpt-5 ')).toEqual([
'gpt-5',
'claude-*',
'legacy-model',
])
const restricted = updateAllowedModelsFromInput(
config,
'gpt-5\nclaude-*\nlegacy-model\ngpt-5',
)
expect(restricted.allowed_models).toEqual(['gpt-5', 'claude-*', 'legacy-model'])
expect(formatAllowedModelsInput(restricted.allowed_models)).toBe('gpt-5\nclaude-*\nlegacy-model')
expect(routingModelScopeLabel(restricted)).toBe('3 个模型')
const unrestricted = clearAllowedModels(restricted)
expect(unrestricted.allowed_models).toEqual([])
expect(routingModelScopeLabel(unrestricted)).toBe('全部模型')
})
it('round-trips selectors containing commas and labels wildcard scope as unrestricted', () => {
const selectors = ['vendor,model', 'gpt-*']
expect(parseAllowedModelsInput(formatAllowedModelsInput(selectors))).toEqual(selectors)
const wildcard = normalizeRoutingGroupConfig({ allowed_models: ['gpt-*', '*'] })
expect(routingModelScopeLabel(wildcard)).toBe('全部模型')
})
it('preserves historical empty selectors until unrestricted scope is explicit', () => {
const legacy = normalizeRoutingGroupConfig({ allowed_models: ['', ' '] })
expect(updateAllowedModelsFromInput(legacy, ' \n')).toMatchObject({
allowed_models: ['', ' '],
})
expect(clearAllowedModels(legacy).allowed_models).toEqual([])
})
it('preserves an explicit model allowlist across per-model editing actions', () => {
const allowlist = ['gpt-*', 'legacy-model']
let config = normalizeRoutingGroupConfig({
allowed_models: allowlist,
model_policies: [{
...createEmptyModelPolicy('special-model'),
allowed_providers: ['provider-special'],
}],
})
config = upsertModelSchedulingRule(config, 'special-model', {
priority_mode: 'global_key',
scheduling_mode: 'fixed_order',
})
const perModel = setRoutingSortingScope(config, 'per_model')
expect(perModel.allowed_models).toEqual(allowlist)
expect(getModelScheduling(perModel, 'special-model')).toMatchObject({
priority_mode: 'global_key',
scheduling_mode: 'fixed_order',
})
const saved = savePerModelRoutingConfig(perModel, 'new-special-model')
expect(saved.allowed_models).toEqual(allowlist)
expect(saved.model_policies.map(policy => policy.model)).toContain('new-special-model')
const copied = copyPerModelRoutingConfig(
saved,
saved,
'special-model',
'copied-special-model',
)
expect(copied.allowed_models).toEqual(allowlist)
expect(copied.model_policies.find(policy => policy.model === 'copied-special-model'))
.toMatchObject({ allowed_providers: ['provider-special'] })
expect(getModelScheduling(copied, 'copied-special-model')).toMatchObject({
priority_mode: 'global_key',
scheduling_mode: 'fixed_order',
})
const removed = removePerModelRoutingConfig(copied, 'special-model')
expect(removed.allowed_models).toEqual(allowlist)
expect(removed.model_policies.map(policy => policy.model)).not.toContain('special-model')
expect(removed.rules.map(rule => rule.id)).not.toContain(modelSchedulingRuleId('special-model'))
const unified = setRoutingSortingScope(removed, 'unified')
expect(unified.allowed_models).toEqual(allowlist)
expect(unified.model_policies.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL))
.toEqual([])
expect(unified.rules.some(rule => rule.id.startsWith('ui_model_scheduling:'))).toBe(false)
})
it('recognizes legacy allowlist mirrors without mutating historical values', () => {
const config = normalizeRoutingGroupConfig({
allowed_models: [' model-b ', 'model-a', 'model-a'],
model_policies: [
createEmptyModelPolicy('model-a'),
createEmptyModelPolicy('model-b'),
],
})
expect(allowedModelsMirrorPerModelPolicies(config)).toBe(true)
expect(config.allowed_models).toEqual([' model-b ', 'model-a', 'model-a'])
expect(allowedModelsMirrorPerModelPolicies({
...config,
allowed_models: ['model-*'],
})).toBe(false)
})
})
describe('routingTrace', () => {
@@ -1,16 +1,5 @@
<template>
<section class="space-y-4">
<div class="grid gap-3">
<label class="space-y-1 text-sm">
<span class="text-muted-foreground">允许模型</span>
<input
v-model="allowedModelsText"
class="h-10 w-full rounded-md border border-border bg-background px-3 text-sm"
placeholder="gpt-5, claude-sonnet-*"
>
</label>
</div>
<RoutingModelPolicyEditor
:model-policies="config.model_policies"
@update:model-policies="updateModelPolicies"
@@ -34,16 +23,6 @@ const emit = defineEmits<{
const config = computed(() => normalizeRoutingGroupConfig(props.config))
const allowedModelsText = computed({
get: () => config.value.allowed_models.join(', '),
set: value => {
emit('update:config', {
...config.value,
allowed_models: value.split(',').map(item => item.trim()).filter(Boolean),
})
},
})
function updateModelPolicies(modelPolicies: RoutingModelPolicy[]) {
emit('update:config', {
...config.value,
@@ -10,6 +10,8 @@ export interface RoutingDefaultPolicy {
priority_mode: RoutingPriorityMode
scheduling_mode: RoutingSchedulingMode
keep_priority_on_conversion: boolean
enable_cf_heartbeat: boolean
cyber_continue_failover: boolean
/** 首个候选的总尝试次数;后续候选始终只尝试 1 次。0 或 1 表示不重试 */
sticky_key_attempts: number
}
@@ -60,7 +62,6 @@ export interface RoutingSetSchedulingAction {
}
export interface RoutingGroupConfig {
allowed_models: string[]
default_policy: RoutingDefaultPolicy
model_policies: RoutingModelPolicy[]
rules: RoutingRule[]
@@ -71,11 +72,12 @@ export const MODEL_SCHEDULING_RULE_PREFIX = 'ui_model_scheduling:'
export function createEmptyRoutingGroupConfig(): RoutingGroupConfig {
return {
allowed_models: [],
default_policy: {
priority_mode: 'provider',
scheduling_mode: 'cache_affinity',
keep_priority_on_conversion: false,
enable_cf_heartbeat: false,
cyber_continue_failover: false,
sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS,
},
model_policies: [],
@@ -104,14 +106,25 @@ export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> | null | undefined): RoutingGroupConfig {
const base = createEmptyRoutingGroupConfig()
const rawDefaultPolicy = (value?.default_policy ?? {}) as Partial<RoutingDefaultPolicy> & {
enable_openai_image_sync_heartbeat?: boolean
enable_standard_text_sync_heartbeat?: boolean
}
const {
enable_openai_image_sync_heartbeat: legacyImageHeartbeat,
enable_standard_text_sync_heartbeat: legacyTextHeartbeat,
...defaultPolicyWithoutLegacyHeartbeat
} = rawDefaultPolicy
return {
allowed_models: Array.isArray(value?.allowed_models) ? [...value.allowed_models] : base.allowed_models,
default_policy: {
...base.default_policy,
...(value?.default_policy ?? {}),
...defaultPolicyWithoutLegacyHeartbeat,
enable_cf_heartbeat: Boolean(
rawDefaultPolicy.enable_cf_heartbeat || legacyImageHeartbeat || legacyTextHeartbeat,
),
sticky_key_attempts: normalizeStickyKeyAttempts(
value?.default_policy?.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS,
rawDefaultPolicy.sticky_key_attempts ?? DEFAULT_STICKY_KEY_ATTEMPTS,
),
},
model_policies: Array.isArray(value?.model_policies)
@@ -133,74 +146,6 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
}
}
export function parseAllowedModelsInput(value: string): string[] {
const seen = new Set<string>()
return value
.split(/\r\n?|\n/u)
.map(item => item.trim())
.filter(Boolean)
.filter((model) => {
if (seen.has(model)) return false
seen.add(model)
return true
})
}
export function formatAllowedModelsInput(models: string[]): string {
return models.join('\n')
}
export function updateAllowedModelsFromInput(
config: RoutingGroupConfig,
value: string,
): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
// Preserve the historical "empty selector" form until the user explicitly
// chooses the unrestricted scope. It is distinct from an empty allowlist in
// the routing core, where it matches no normal model.
const hasHistoricalEmptySelector = next.allowed_models.length > 0
&& next.allowed_models.every(model => model.trim() === '')
if (value.trim() === '' && hasHistoricalEmptySelector) {
return next
}
next.allowed_models = parseAllowedModelsInput(value)
return next
}
export function clearAllowedModels(config: RoutingGroupConfig): RoutingGroupConfig {
const next = normalizeRoutingGroupConfig(config)
next.allowed_models = []
return next
}
export function routingModelScopeLabel(config: RoutingGroupConfig): string {
const models = normalizeRoutingGroupConfig(config).allowed_models
if (models.length === 0 || models.some(model => model.trim() === '*')) {
return '全部模型'
}
return `${models.length} 个模型`
}
export function allowedModelsMirrorPerModelPolicies(config: RoutingGroupConfig): boolean {
const normalized = normalizeRoutingGroupConfig(config)
const allowedModels = normalized.allowed_models
.map(model => model.trim())
.filter(Boolean)
const perModelNames = normalized.model_policies
.map(policy => policy.model)
.map(model => model.trim())
.filter(Boolean)
.filter(model => model !== DEFAULT_ROUTING_POLICY_MODEL)
if (allowedModels.length === 0 || perModelNames.length === 0) return false
if (allowedModels.some(model => model.includes('*'))) return false
const allowedSet = new Set(allowedModels)
const perModelSet = new Set(perModelNames)
return allowedSet.size === perModelSet.size
&& [...allowedSet].every(model => perModelSet.has(model))
}
export function upsertModelPolicy(config: RoutingGroupConfig, policy: RoutingModelPolicy): RoutingGroupConfig {
const model = policy.model.trim()
if (!model) {
@@ -427,6 +372,8 @@ export function getModelScheduling(
priority_mode: action?.priority_mode ?? normalized.default_policy.priority_mode,
scheduling_mode: action?.scheduling_mode ?? normalized.default_policy.scheduling_mode,
keep_priority_on_conversion: normalized.default_policy.keep_priority_on_conversion,
enable_cf_heartbeat: normalized.default_policy.enable_cf_heartbeat,
cyber_continue_failover: normalized.default_policy.cyber_continue_failover,
sticky_key_attempts: action?.sticky_key_attempts ?? normalized.default_policy.sticky_key_attempts,
}
}