mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
499 lines
17 KiB
Rust
499 lines
17 KiB
Rust
use std::collections::BTreeSet;
|
|
|
|
use thiserror::Error;
|
|
|
|
use crate::model::{RoutingGroupConfig, RoutingPoolPolicyOverride};
|
|
use crate::mutations::{validate_header_patch, validate_json_patch_operations};
|
|
use crate::{RoutingAction, RoutingRulePhase};
|
|
|
|
pub const MAX_ROUTING_ALLOWED_KEYS: usize = 512;
|
|
|
|
const ROUTING_POOL_PRESETS: &[&str] = &[
|
|
"lru",
|
|
"cache_affinity",
|
|
"load_balance",
|
|
"single_account",
|
|
"priority_first",
|
|
"free_team_first",
|
|
"free_first",
|
|
"team_first",
|
|
"plus_first",
|
|
"pro_first",
|
|
"health_first",
|
|
"latency_first",
|
|
"cost_first",
|
|
"quota_balanced",
|
|
"recent_refresh",
|
|
];
|
|
|
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
|
pub enum RoutingValidationError {
|
|
#[error("routing failover rules are invalid: {0}")]
|
|
InvalidFailoverRules(String),
|
|
#[error("routing rule id is empty")]
|
|
EmptyRuleId,
|
|
#[error("duplicate routing rule id: {0}")]
|
|
DuplicateRuleId(String),
|
|
#[error("routing model policy selector is empty")]
|
|
EmptyModelSelector,
|
|
#[error("invalid mutation action: {0}")]
|
|
InvalidMutation(String),
|
|
#[error("routing rule {rule_id} uses unsupported {action} action in provider_request phase")]
|
|
ProviderRequestActionNotAllowed {
|
|
rule_id: String,
|
|
action: &'static str,
|
|
},
|
|
#[error("routing key selector {selector} contains {count} entries; maximum is {max}")]
|
|
TooManyAllowedKeys {
|
|
selector: String,
|
|
count: usize,
|
|
max: usize,
|
|
},
|
|
#[error("routing pool policy {selector} has an empty provider id")]
|
|
EmptyPoolProviderId { selector: String },
|
|
#[error("routing pool policy {selector} uses unsupported preset: {preset}")]
|
|
UnsupportedPoolPreset { selector: String, preset: String },
|
|
#[error("routing pool policy {selector} contains duplicate preset: {preset}")]
|
|
DuplicatePoolPreset { selector: String, preset: String },
|
|
#[error("routing pool policy {selector} preset {preset} has invalid mode: {mode}")]
|
|
InvalidPoolPresetMode {
|
|
selector: String,
|
|
preset: String,
|
|
mode: String,
|
|
},
|
|
#[error("routing pool policy {selector} enables mutually exclusive distribution presets: {presets:?}")]
|
|
ConflictingPoolDistributionPresets {
|
|
selector: String,
|
|
presets: Vec<String>,
|
|
},
|
|
}
|
|
|
|
pub fn validate_routing_group_config(
|
|
config: &RoutingGroupConfig,
|
|
) -> Result<(), RoutingValidationError> {
|
|
crate::validate_routing_failover_rules(&config.default_policy.execution_policy.failover_rules)
|
|
.map_err(RoutingValidationError::InvalidFailoverRules)?;
|
|
let mut rule_ids = BTreeSet::new();
|
|
for model_policy in &config.model_policies {
|
|
if model_policy.model.trim().is_empty() {
|
|
return Err(RoutingValidationError::EmptyModelSelector);
|
|
}
|
|
validate_allowed_key_count(
|
|
format!("model:{}", model_policy.model.trim()),
|
|
model_policy.allowed_keys.len(),
|
|
)?;
|
|
for (provider_id, override_policy) in &model_policy.pool_policy_overrides {
|
|
let model_selector = format!("model:{}", model_policy.model.trim());
|
|
if provider_id.trim().is_empty() {
|
|
return Err(RoutingValidationError::EmptyPoolProviderId {
|
|
selector: model_selector,
|
|
});
|
|
}
|
|
validate_pool_policy_override(
|
|
format!("{model_selector}:provider:{}", provider_id.trim()),
|
|
override_policy,
|
|
)?;
|
|
}
|
|
}
|
|
for rule in &config.rules {
|
|
if rule.id.trim().is_empty() {
|
|
return Err(RoutingValidationError::EmptyRuleId);
|
|
}
|
|
if !rule_ids.insert(rule.id.clone()) {
|
|
return Err(RoutingValidationError::DuplicateRuleId(rule.id.clone()));
|
|
}
|
|
for action in &rule.actions {
|
|
if rule.phase == RoutingRulePhase::ProviderRequest
|
|
&& !matches!(
|
|
action,
|
|
RoutingAction::JsonPatchBody { .. } | RoutingAction::PatchHeaders { .. }
|
|
)
|
|
{
|
|
return Err(RoutingValidationError::ProviderRequestActionNotAllowed {
|
|
rule_id: rule.id.clone(),
|
|
action: routing_action_name(action),
|
|
});
|
|
}
|
|
match action {
|
|
RoutingAction::JsonPatchBody { patch } => {
|
|
validate_json_patch_operations(patch).map_err(|error| {
|
|
RoutingValidationError::InvalidMutation(error.to_string())
|
|
})?;
|
|
}
|
|
RoutingAction::PatchHeaders { patch } => {
|
|
validate_header_patch(patch).map_err(|error| {
|
|
RoutingValidationError::InvalidMutation(error.to_string())
|
|
})?;
|
|
}
|
|
RoutingAction::RestrictKeys { key_ids } => {
|
|
validate_allowed_key_count(format!("rule:{}", rule.id), key_ids.len())?;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_pool_policy_override(
|
|
selector: String,
|
|
override_policy: &RoutingPoolPolicyOverride,
|
|
) -> Result<(), RoutingValidationError> {
|
|
let mut seen = BTreeSet::new();
|
|
let mut enabled_distribution_presets = Vec::new();
|
|
for preset in &override_policy.scheduling_presets {
|
|
let normalized = preset.preset.trim().to_ascii_lowercase();
|
|
if !ROUTING_POOL_PRESETS.contains(&normalized.as_str()) {
|
|
return Err(RoutingValidationError::UnsupportedPoolPreset {
|
|
selector,
|
|
preset: normalized,
|
|
});
|
|
}
|
|
if !seen.insert(normalized.clone()) {
|
|
return Err(RoutingValidationError::DuplicatePoolPreset {
|
|
selector,
|
|
preset: normalized,
|
|
});
|
|
}
|
|
if let Some(mode) = preset.mode.as_deref() {
|
|
let mode = mode.trim().to_ascii_lowercase();
|
|
if !routing_pool_preset_mode_valid(&normalized, &mode) {
|
|
return Err(RoutingValidationError::InvalidPoolPresetMode {
|
|
selector,
|
|
preset: normalized,
|
|
mode,
|
|
});
|
|
}
|
|
}
|
|
if preset.enabled && routing_pool_distribution_preset(&normalized) {
|
|
enabled_distribution_presets.push(normalized);
|
|
}
|
|
}
|
|
if enabled_distribution_presets.len() > 1 {
|
|
return Err(RoutingValidationError::ConflictingPoolDistributionPresets {
|
|
selector,
|
|
presets: enabled_distribution_presets,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn routing_pool_preset_mode_valid(preset: &str, mode: &str) -> bool {
|
|
match preset {
|
|
"free_team_first" => matches!(mode, "free_only" | "team_only" | "both"),
|
|
"free_first" => mode == "free_only",
|
|
"team_first" => mode == "team_only",
|
|
"plus_first" => mode == "plus_only",
|
|
"pro_first" => mode == "pro_only",
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn routing_pool_distribution_preset(preset: &str) -> bool {
|
|
matches!(
|
|
preset,
|
|
"lru" | "cache_affinity" | "load_balance" | "single_account"
|
|
)
|
|
}
|
|
|
|
fn validate_allowed_key_count(
|
|
selector: String,
|
|
count: usize,
|
|
) -> Result<(), RoutingValidationError> {
|
|
if count > MAX_ROUTING_ALLOWED_KEYS {
|
|
return Err(RoutingValidationError::TooManyAllowedKeys {
|
|
selector,
|
|
count,
|
|
max: MAX_ROUTING_ALLOWED_KEYS,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn routing_action_name(action: &RoutingAction) -> &'static str {
|
|
match action {
|
|
RoutingAction::RestrictModels { .. } => "restrict_models",
|
|
RoutingAction::RestrictProviders { .. } => "restrict_providers",
|
|
RoutingAction::RestrictKeys { .. } => "restrict_keys",
|
|
RoutingAction::SetScheduling { .. } => "set_scheduling",
|
|
RoutingAction::SetProviderPriority { .. } => "set_provider_priority",
|
|
RoutingAction::SetKeyPriority { .. } => "set_key_priority",
|
|
RoutingAction::JsonPatchBody { .. } => "json_patch_body",
|
|
RoutingAction::PatchHeaders { .. } => "patch_headers",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use crate::{
|
|
RoutingCondition, RoutingGroupConfig, RoutingHeaderPatch, RoutingJsonPatchOperation,
|
|
RoutingModelPolicy, RoutingRule,
|
|
};
|
|
|
|
use super::*;
|
|
|
|
fn provider_request_config(actions: Vec<RoutingAction>) -> RoutingGroupConfig {
|
|
RoutingGroupConfig {
|
|
rules: vec![RoutingRule {
|
|
id: "provider-rule".to_string(),
|
|
priority: 0,
|
|
enabled: true,
|
|
phase: RoutingRulePhase::ProviderRequest,
|
|
conditions: RoutingCondition::default(),
|
|
actions,
|
|
stop_processing: false,
|
|
}],
|
|
..RoutingGroupConfig::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_mutation_actions_in_provider_request_phase() {
|
|
let actions = [
|
|
(
|
|
RoutingAction::RestrictModels {
|
|
models: vec!["gpt-5".to_string()],
|
|
},
|
|
"restrict_models",
|
|
),
|
|
(
|
|
RoutingAction::RestrictProviders {
|
|
provider_ids: vec!["provider-1".to_string()],
|
|
},
|
|
"restrict_providers",
|
|
),
|
|
(
|
|
RoutingAction::RestrictKeys {
|
|
key_ids: vec!["key-1".to_string()],
|
|
},
|
|
"restrict_keys",
|
|
),
|
|
(
|
|
RoutingAction::SetScheduling {
|
|
priority_mode: None,
|
|
scheduling_mode: None,
|
|
keep_priority_on_conversion: Some(true),
|
|
sticky_key_attempts: None,
|
|
},
|
|
"set_scheduling",
|
|
),
|
|
(
|
|
RoutingAction::SetProviderPriority {
|
|
provider_id: "provider-1".to_string(),
|
|
priority: 1,
|
|
},
|
|
"set_provider_priority",
|
|
),
|
|
(
|
|
RoutingAction::SetKeyPriority {
|
|
key_id: "key-1".to_string(),
|
|
priority: 1,
|
|
api_format: None,
|
|
},
|
|
"set_key_priority",
|
|
),
|
|
];
|
|
|
|
for (action, action_name) in actions {
|
|
let error = validate_routing_group_config(&provider_request_config(vec![action]))
|
|
.expect_err("provider_request must only accept mutations");
|
|
assert_eq!(
|
|
error,
|
|
RoutingValidationError::ProviderRequestActionNotAllowed {
|
|
rule_id: "provider-rule".to_string(),
|
|
action: action_name,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_mutation_actions_in_provider_request_phase() {
|
|
let config = provider_request_config(vec![
|
|
RoutingAction::JsonPatchBody {
|
|
patch: vec![RoutingJsonPatchOperation::Add {
|
|
path: "/metadata/routed".to_string(),
|
|
value: json!(true),
|
|
}],
|
|
},
|
|
RoutingAction::PatchHeaders {
|
|
patch: vec![RoutingHeaderPatch::Set {
|
|
name: "x-routing-profile".to_string(),
|
|
value: "provider-rule".to_string(),
|
|
}],
|
|
},
|
|
]);
|
|
|
|
validate_routing_group_config(&config)
|
|
.expect("provider_request mutation actions should remain valid");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_oversized_model_allowed_key_selector() {
|
|
let config = RoutingGroupConfig {
|
|
model_policies: vec![RoutingModelPolicy {
|
|
model: "gpt-5".to_string(),
|
|
allowed_keys: key_ids(MAX_ROUTING_ALLOWED_KEYS + 1),
|
|
..RoutingModelPolicy::default()
|
|
}],
|
|
..RoutingGroupConfig::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
validate_routing_group_config(&config),
|
|
Err(RoutingValidationError::TooManyAllowedKeys {
|
|
selector: "model:gpt-5".to_string(),
|
|
count: MAX_ROUTING_ALLOWED_KEYS + 1,
|
|
max: MAX_ROUTING_ALLOWED_KEYS,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_oversized_rule_allowed_key_selector() {
|
|
let config = RoutingGroupConfig {
|
|
rules: vec![RoutingRule {
|
|
id: "restrict-keys".to_string(),
|
|
priority: 0,
|
|
enabled: true,
|
|
phase: RoutingRulePhase::ClientRequest,
|
|
conditions: RoutingCondition::default(),
|
|
actions: vec![RoutingAction::RestrictKeys {
|
|
key_ids: key_ids(MAX_ROUTING_ALLOWED_KEYS + 1),
|
|
}],
|
|
stop_processing: false,
|
|
}],
|
|
..RoutingGroupConfig::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
validate_routing_group_config(&config),
|
|
Err(RoutingValidationError::TooManyAllowedKeys {
|
|
selector: "rule:restrict-keys".to_string(),
|
|
count: MAX_ROUTING_ALLOWED_KEYS + 1,
|
|
max: MAX_ROUTING_ALLOWED_KEYS,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_allowed_key_selectors_at_the_limit() {
|
|
let config = RoutingGroupConfig {
|
|
model_policies: vec![RoutingModelPolicy {
|
|
model: "gpt-5".to_string(),
|
|
allowed_keys: key_ids(MAX_ROUTING_ALLOWED_KEYS),
|
|
..RoutingModelPolicy::default()
|
|
}],
|
|
..RoutingGroupConfig::default()
|
|
};
|
|
|
|
validate_routing_group_config(&config)
|
|
.expect("allowed key selectors at the scan limit should remain valid");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_pool_override_preset() {
|
|
let config = pool_override_config(vec![pool_preset("typo_priority", true, None)]);
|
|
|
|
assert_eq!(
|
|
validate_routing_group_config(&config),
|
|
Err(RoutingValidationError::UnsupportedPoolPreset {
|
|
selector: "model:gpt-5:provider:provider-1".to_string(),
|
|
preset: "typo_priority".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_pool_override_presets() {
|
|
let config = pool_override_config(vec![
|
|
pool_preset("health_first", true, None),
|
|
pool_preset(" HEALTH_FIRST ", false, None),
|
|
]);
|
|
|
|
assert_eq!(
|
|
validate_routing_group_config(&config),
|
|
Err(RoutingValidationError::DuplicatePoolPreset {
|
|
selector: "model:gpt-5:provider:provider-1".to_string(),
|
|
preset: "health_first".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_pool_override_mode() {
|
|
let config =
|
|
pool_override_config(vec![pool_preset("free_team_first", true, Some("pro_only"))]);
|
|
|
|
assert_eq!(
|
|
validate_routing_group_config(&config),
|
|
Err(RoutingValidationError::InvalidPoolPresetMode {
|
|
selector: "model:gpt-5:provider:provider-1".to_string(),
|
|
preset: "free_team_first".to_string(),
|
|
mode: "pro_only".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_conflicting_pool_override_distribution_modes() {
|
|
let config = pool_override_config(vec![
|
|
pool_preset("lru", true, None),
|
|
pool_preset("cache_affinity", true, None),
|
|
]);
|
|
|
|
assert_eq!(
|
|
validate_routing_group_config(&config),
|
|
Err(RoutingValidationError::ConflictingPoolDistributionPresets {
|
|
selector: "model:gpt-5:provider:provider-1".to_string(),
|
|
presets: vec!["lru".to_string(), "cache_affinity".to_string()],
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_valid_pool_override_modes_and_disabled_alternatives() {
|
|
let config = pool_override_config(vec![
|
|
pool_preset("cache_affinity", true, None),
|
|
pool_preset("lru", false, None),
|
|
pool_preset("free_team_first", true, Some("team_only")),
|
|
]);
|
|
|
|
validate_routing_group_config(&config).expect("valid pool override should pass");
|
|
}
|
|
|
|
fn pool_override_config(
|
|
scheduling_presets: Vec<crate::RoutingSchedulingPreset>,
|
|
) -> RoutingGroupConfig {
|
|
RoutingGroupConfig {
|
|
model_policies: vec![RoutingModelPolicy {
|
|
model: "gpt-5".to_string(),
|
|
pool_policy_overrides: std::collections::BTreeMap::from([(
|
|
"provider-1".to_string(),
|
|
RoutingPoolPolicyOverride { scheduling_presets },
|
|
)]),
|
|
..RoutingModelPolicy::default()
|
|
}],
|
|
..RoutingGroupConfig::default()
|
|
}
|
|
}
|
|
|
|
fn pool_preset(
|
|
preset: &str,
|
|
enabled: bool,
|
|
mode: Option<&str>,
|
|
) -> crate::RoutingSchedulingPreset {
|
|
crate::RoutingSchedulingPreset {
|
|
preset: preset.to_string(),
|
|
enabled,
|
|
mode: mode.map(str::to_string),
|
|
}
|
|
}
|
|
|
|
fn key_ids(count: usize) -> Vec<String> {
|
|
(0..count).map(|index| format!("key-{index}")).collect()
|
|
}
|
|
}
|