diff --git a/crates/aether-routing-core/src/policy.rs b/crates/aether-routing-core/src/policy.rs index 1a784cc6e..0cdda40e2 100644 --- a/crates/aether-routing-core/src/policy.rs +++ b/crates/aether-routing-core/src/policy.rs @@ -277,7 +277,10 @@ mod tests { use serde_json::json; - use crate::actions::{RoutingJsonPatchOperation, RoutingRulePhase}; + use crate::actions::{ + RoutingJsonPatchOperation, RoutingRulePhase, RoutingSchedulingMode, + RoutingSetPriorityMode, + }; use crate::conditions::{RoutingCondition, RoutingConditionOp}; use crate::model::{RoutingDefaultPolicy, RoutingRule}; @@ -352,6 +355,89 @@ mod tests { assert_eq!(policy.mutation_plan.body_patch.len(), 1); } + #[test] + fn empty_allowlist_keeps_default_policy_for_models_without_an_override() { + let config = RoutingGroupConfig { + allowed_models: vec![], + default_policy: RoutingDefaultPolicy { + priority_mode: RoutingSetPriorityMode::GlobalKey, + scheduling_mode: RoutingSchedulingMode::LoadBalance, + keep_priority_on_conversion: true, + }, + model_policies: vec![RoutingModelPolicy { + model: "special-model".to_string(), + allowed_providers: vec!["provider-special".to_string()], + provider_priority_overrides: BTreeMap::from([( + "provider-special".to_string(), + 0, + )]), + ..RoutingModelPolicy::default() + }], + rules: vec![], + }; + + let special = resolve_routing_policy( + &config, + RoutingPolicyInput { + group_id: Some("group-1"), + group_version: Some(1), + selection_source: "test", + requested_model: "special-model", + resolved_model: "special-model", + api_format: "openai:chat", + user_id: None, + api_key_id: None, + headers: &json!({}), + body: &json!({}), + phase: RoutingRulePhase::ClientRequest, + }, + ) + .expect("the specially configured model should resolve"); + + assert_eq!(special.priority_mode, RoutingSetPriorityMode::GlobalKey); + assert_eq!(special.scheduling_mode, RoutingSchedulingMode::LoadBalance); + assert!(special.keep_priority_on_conversion); + assert_eq!( + special.ranking_overlay.allowed_providers, + vec!["provider-special"] + ); + assert_eq!( + special + .ranking_overlay + .provider_priority_overrides + .get("provider-special"), + Some(&0) + ); + + let ordinary = resolve_routing_policy( + &config, + RoutingPolicyInput { + group_id: Some("group-1"), + group_version: Some(1), + selection_source: "test", + requested_model: "ordinary-model", + resolved_model: "ordinary-model", + api_format: "openai:chat", + user_id: None, + api_key_id: None, + headers: &json!({}), + body: &json!({}), + phase: RoutingRulePhase::ClientRequest, + }, + ) + .expect("an unconfigured model should keep using the default policy"); + + assert_eq!(ordinary.priority_mode, RoutingSetPriorityMode::GlobalKey); + assert_eq!(ordinary.scheduling_mode, RoutingSchedulingMode::LoadBalance); + assert!(ordinary.keep_priority_on_conversion); + assert!(ordinary.ranking_overlay.allowed_providers.is_empty()); + assert!(ordinary.ranking_overlay.allowed_keys.is_empty()); + assert!(ordinary + .ranking_overlay + .provider_priority_overrides + .is_empty()); + } + #[test] fn rejects_disallowed_model() { let config = RoutingGroupConfig { diff --git a/frontend/src/features/routing/__tests__/routingPolicy.spec.ts b/frontend/src/features/routing/__tests__/routingPolicy.spec.ts index e6a32bc84..2c04df7ba 100644 --- a/frontend/src/features/routing/__tests__/routingPolicy.spec.ts +++ b/frontend/src/features/routing/__tests__/routingPolicy.spec.ts @@ -2,14 +2,24 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_ROUTING_POLICY_MODEL, + allowedModelsMirrorPerModelPolicies, + clearAllowedModels, + copyPerModelRoutingConfig, createEmptyModelPolicy, createEmptyRoutingGroupConfig, + formatAllowedModelsInput, getDefaultModelPolicy, getModelScheduling, modelSchedulingRuleId, normalizeRoutingGroupConfig, + parseAllowedModelsInput, + removePerModelRoutingConfig, + routingModelScopeLabel, + savePerModelRoutingConfig, setDefaultPoolPriorityOverrides, setDefaultProviderPriorityOverrides, + setRoutingSortingScope, + updateAllowedModelsFromInput, upsertModelSchedulingRule, upsertModelPolicy, } from '../utils/routingPolicy' @@ -83,6 +93,98 @@ 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, claude-*\nlegacy-model, gpt-5 ')).toEqual([ + 'gpt-5', + 'claude-*', + 'legacy-model', + ]) + + const restricted = updateAllowedModelsFromInput( + config, + 'gpt-5, claude-*\nlegacy-model, gpt-5', + ) + expect(restricted.allowed_models).toEqual(['gpt-5', 'claude-*', 'legacy-model']) + expect(formatAllowedModelsInput(restricted.allowed_models)).toBe('gpt-5, claude-*, legacy-model') + expect(routingModelScopeLabel(restricted)).toBe('3 个模型') + + const unrestricted = clearAllowedModels(restricted) + expect(unrestricted.allowed_models).toEqual([]) + expect(routingModelScopeLabel(unrestricted)).toBe('全部模型') + }) + + 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', () => { diff --git a/frontend/src/features/routing/utils/routingPolicy.ts b/frontend/src/features/routing/utils/routingPolicy.ts index 38bed5f58..4e31ca6fc 100644 --- a/frontend/src/features/routing/utils/routingPolicy.ts +++ b/frontend/src/features/routing/utils/routingPolicy.ts @@ -1,6 +1,7 @@ export type RoutingPriorityMode = 'provider' | 'global_key' export type RoutingSchedulingMode = 'fixed_order' | 'cache_affinity' | 'load_balance' export type RoutingRulePhase = 'client_request' | 'provider_request' +export type RoutingSortingScope = 'unified' | 'per_model' export interface RoutingDefaultPolicy { priority_mode: RoutingPriorityMode @@ -110,6 +111,63 @@ export function normalizeRoutingGroupConfig(value: Partial | } } +export function parseAllowedModelsInput(value: string): string[] { + const seen = new Set() + return value + .split(/[,\r\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(', ') +} + +export function updateAllowedModelsFromInput( + config: RoutingGroupConfig, + value: string, +): RoutingGroupConfig { + const next = normalizeRoutingGroupConfig(config) + 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 count = normalizeRoutingGroupConfig(config).allowed_models.length + return count ? `${count} 个模型` : '全部模型' +} + +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) { @@ -332,6 +390,58 @@ export function removeGeneratedModelSchedulingRules(config: RoutingGroupConfig): return next } +export function setRoutingSortingScope( + config: RoutingGroupConfig, + scope: RoutingSortingScope, +): RoutingGroupConfig { + if (scope === 'per_model') return normalizeRoutingGroupConfig(config) + + const next = removeGeneratedModelSchedulingRules(config) + next.model_policies = next.model_policies + .filter(policy => policy.model === DEFAULT_ROUTING_POLICY_MODEL) + return next +} + +export function removePerModelRoutingConfig( + config: RoutingGroupConfig, + model: string, +): RoutingGroupConfig { + return removeModelSchedulingRule(removeModelPolicy(config, model), model) +} + +export function copyPerModelRoutingConfig( + config: RoutingGroupConfig, + sourceConfig: RoutingGroupConfig, + sourceModel: string, + targetModel: string, +): RoutingGroupConfig { + const source = sourceModel.trim() + const target = targetModel.trim() + if (!source || !target || source === target) return normalizeRoutingGroupConfig(config) + + const sourcePolicy = getModelPolicy(sourceConfig, source) + const sourceScheduling = getModelScheduling(sourceConfig, source) + const next = upsertModelPolicy(config, { + ...sourcePolicy, + model: target, + }) + return upsertModelSchedulingRule(next, target, { + priority_mode: sourceScheduling.priority_mode, + scheduling_mode: sourceScheduling.scheduling_mode, + }) +} + +export function savePerModelRoutingConfig( + config: RoutingGroupConfig, + model: string, +): RoutingGroupConfig { + const normalizedModel = model.trim() + const next = normalizeRoutingGroupConfig(config) + if (!normalizedModel || normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) return next + if (next.model_policies.some(policy => policy.model === normalizedModel)) return next + return upsertModelPolicy(next, createEmptyModelPolicy(normalizedModel)) +} + export function normalizePriorityOverrides(overrides: Record): Record { const normalized: Record = {} for (const [rawId, rawPriority] of Object.entries(overrides)) { diff --git a/frontend/src/views/admin/RoutingProfiles.vue b/frontend/src/views/admin/RoutingProfiles.vue index 641062b6a..eef10b728 100644 --- a/frontend/src/views/admin/RoutingProfiles.vue +++ b/frontend/src/views/admin/RoutingProfiles.vue @@ -323,6 +323,83 @@ +
+
+
+
+

+ 模型白名单 +

+ + {{ draft.config_json.allowed_models.length ? `${draft.config_json.allowed_models.length} 项` : '全部模型' }} + +
+

+ 控制此策略分组适用于哪些模型;留空表示全部模型。它与“区分模型”中的专属调度覆盖相互独立,支持精确值、* 和前缀通配符(如 gpt-*),多个值用英文逗号或换行分隔。 +

+
+ +
+ +
+ + +
+ +
+ + {{ model }} + +
+ +
+ 当前白名单与按模型策略列表一致,可能来自旧版界面的联动保存。现有范围会原样保留;如需让其他模型也使用默认策略,请显式点击“改为全部模型”。 +
+
+
= [ @@ -729,9 +810,10 @@ const groups = ref([]) const selectedGroupId = ref(null) const draft = ref(null) const savedDraftSnapshot = ref(null) -const sortingScope = ref('unified') +const sortingScope = ref('unified') const selectedPerModelName = ref(null) const editingConfig = ref(null) +const allowedModelsInput = ref('') const globalModelSearch = ref('') const modelFilter = ref('unconfigured') const globalModels = ref([]) @@ -773,6 +855,11 @@ const firstStepSchedulingMode = computed(() => { } return draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity' }) +const allowedModelsLookLikeLegacyMirror = computed(() => { + return draft.value + ? allowedModelsMirrorPerModelPolicies(draft.value.config_json) + : false +}) interface ModelRow { name: string @@ -869,6 +956,7 @@ function clearDraftState(): void { savedDraftSnapshot.value = null selectedPerModelName.value = null editingConfig.value = null + allowedModelsInput.value = '' switchModelTarget.value = null switchModelDialogOpen.value = false deleteDialogOpen.value = false @@ -879,6 +967,7 @@ function selectGroup(group: RoutingGroupRecord): void { isCreating.value = false selectedGroupId.value = normalized.id draft.value = buildDraft(normalized) + allowedModelsInput.value = formatAllowedModelsInput(draft.value.config_json.allowed_models) savedDraftSnapshot.value = draftSnapshotValue(draft.value) syncEditorStateFromConfig(draft.value.config_json) resetEditingConfig() @@ -902,6 +991,7 @@ function startCreate(): void { updated_at: null, } savedDraftSnapshot.value = null + allowedModelsInput.value = '' syncEditorStateFromConfig(draft.value.config_json) resetEditingConfig() } @@ -958,13 +1048,7 @@ function groupSortingScopeLabel(group: RoutingGroupRecord): string { } function groupModelScopeLabel(group: RoutingGroupRecord): string { - const config = normalizeRoutingGroupConfig(group.config_json) - if (hasPerModelSorting(config)) { - const count = config.model_policies.filter(policy => policy.model !== DEFAULT_ROUTING_POLICY_MODEL).length - || config.allowed_models.length - return count ? `${count} 个模型` : '未选择模型' - } - return config.allowed_models.length ? `${config.allowed_models.length} 个模型` : '全部模型' + return routingModelScopeLabel(group.config_json) } function groupSchedulingSummary(group: RoutingGroupRecord): string { @@ -1042,13 +1126,11 @@ function hasPerModelSorting(config: RoutingGroupConfig): boolean { || config.rules.some(isGeneratedModelSchedulingRule) } -function setSortingScope(scope: SortingScope): void { +function setSortingScope(scope: RoutingSortingScope): void { if (!draft.value) return sortingScope.value = scope if (scope === 'unified') { - const next = removeGeneratedModelSchedulingRules(draft.value.config_json) - next.model_policies = next.model_policies.filter(policy => policy.model === DEFAULT_ROUTING_POLICY_MODEL) - next.allowed_models = [] + const next = setRoutingSortingScope(draft.value.config_json, scope) updateDraftConfig(next) resetEditingConfig() return @@ -1092,9 +1174,7 @@ function removePerModelPolicy(model: string): void { showError('请先保存当前改动后再移除模型') return } - let next = removeModelPolicy(draft.value.config_json, model) - next = removeModelSchedulingRule(next, model) - next.allowed_models = next.allowed_models.filter(item => item !== model) + const next = removePerModelRoutingConfig(draft.value.config_json, model) if (selectedPerModelName.value === model) { selectedPerModelName.value = null } @@ -1157,19 +1237,12 @@ function copyModelConfig(sourceModel: string): void { if (!draft.value || !editingConfig.value) return const target = selectedPerModelName.value if (!target || target === sourceModel) return - const sourcePolicy = getModelPolicy(draft.value.config_json, sourceModel) - const sourceScheduling = getModelScheduling(draft.value.config_json, sourceModel) - let next = upsertModelPolicy(editingConfig.value, { - ...sourcePolicy, - model: target, - }) - next = upsertModelSchedulingRule(next, target, { - priority_mode: sourceScheduling.priority_mode, - scheduling_mode: sourceScheduling.scheduling_mode, - }) - if (!next.allowed_models.includes(target)) { - next = { ...next, allowed_models: [...next.allowed_models, target] } - } + const next = copyPerModelRoutingConfig( + editingConfig.value, + draft.value.config_json, + sourceModel, + target, + ) updateEditingConfig(next) success(`已加载 ${globalModelLabel(sourceModel)} 的配置,点击保存生效`) } @@ -1236,6 +1309,26 @@ function globalModelLabel(modelName: string): string { return `${model.display_name} (${model.name})` } +function applyAllowedModelScope(): void { + if (!draft.value) return + const next = updateAllowedModelsFromInput(draft.value.config_json, allowedModelsInput.value) + updateDraftConfig(next) + if (editingConfig.value) { + editingConfig.value = updateAllowedModelsFromInput(editingConfig.value, allowedModelsInput.value) + } + allowedModelsInput.value = formatAllowedModelsInput(next.allowed_models) +} + +function clearAllowedModelScope(): void { + if (!draft.value) return + const next = clearAllowedModels(draft.value.config_json) + updateDraftConfig(next) + if (editingConfig.value) { + editingConfig.value = clearAllowedModels(editingConfig.value) + } + allowedModelsInput.value = '' +} + function replaceGroup(group: RoutingGroupRecord): void { const normalized = normalizeRecord(group) const index = groups.value.findIndex(item => item.id === normalized.id) @@ -1326,13 +1419,7 @@ function saveCurrentModel(): void { showError('请先选择模型') return } - let next = editingConfig.value - if (!next.model_policies.some(policy => policy.model === model)) { - next = upsertModelPolicy(next, createEmptyModelPolicy(model)) - } - if (!next.allowed_models.includes(model)) { - next = { ...next, allowed_models: [...next.allowed_models, model] } - } + const next = savePerModelRoutingConfig(editingConfig.value, model) updateDraftConfig(next) modelFilter.value = 'configured' resetEditingConfig()