mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 19:59:50 +08:00
feat: unify provider scheduling workspace
This commit is contained in:
@@ -224,6 +224,10 @@ pub struct RoutingRule {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct RoutingGroupConfig {
|
||||
/// Providers excluded from every model in this group, including providers
|
||||
/// otherwise selected by model policies or routing rules.
|
||||
#[serde(default)]
|
||||
pub disabled_providers: Vec<String>,
|
||||
/// The default policy is global for the selected strategy group. Model
|
||||
/// differences are expressed through `model_policies` and `rules`.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -85,12 +85,17 @@ pub fn resolve_routing_policy(
|
||||
selection_source: input.selection_source.to_string(),
|
||||
requested_model: input.requested_model.to_string(),
|
||||
resolved_model: input.resolved_model.to_string(),
|
||||
priority_mode: config.default_policy.priority_mode,
|
||||
// Legacy global_key values remain readable, but routing groups now
|
||||
// always rank providers before their keys.
|
||||
priority_mode: RoutingSetPriorityMode::Provider,
|
||||
scheduling_mode: config.default_policy.scheduling_mode,
|
||||
keep_priority_on_conversion: config.default_policy.keep_priority_on_conversion,
|
||||
sticky_key_attempts: config.default_policy.sticky_key_attempts,
|
||||
execution_policy: config.default_policy.execution_policy.clone(),
|
||||
ranking_overlay: RankingOverlay::default(),
|
||||
ranking_overlay: RankingOverlay {
|
||||
disabled_providers: config.disabled_providers.clone(),
|
||||
..RankingOverlay::default()
|
||||
},
|
||||
mutation_plan: MutationPlan::default(),
|
||||
pool_policy_overrides: BTreeMap::new(),
|
||||
matched_rules: Vec::new(),
|
||||
@@ -203,14 +208,13 @@ fn apply_action(
|
||||
policy.ranking_overlay.allowed_keys = key_ids.clone();
|
||||
}
|
||||
RoutingAction::SetScheduling {
|
||||
priority_mode,
|
||||
// Keep accepting the legacy field without re-enabling key-first
|
||||
// scheduling through a model rule.
|
||||
priority_mode: _,
|
||||
scheduling_mode,
|
||||
keep_priority_on_conversion,
|
||||
sticky_key_attempts,
|
||||
} => {
|
||||
if let Some(priority_mode) = priority_mode {
|
||||
policy.priority_mode = *priority_mode;
|
||||
}
|
||||
if let Some(scheduling_mode) = scheduling_mode {
|
||||
policy.scheduling_mode = *scheduling_mode;
|
||||
}
|
||||
@@ -316,6 +320,164 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_key_scheduling_keeps_overrides_but_resolves_to_provider_ordering() {
|
||||
let config: RoutingGroupConfig = serde_json::from_value(json!({
|
||||
"default_policy": { "priority_mode": "global_key" },
|
||||
"model_policies": [{
|
||||
"model": "*",
|
||||
"provider_priority_overrides": { "provider-a": 7 },
|
||||
"key_priority_overrides": { "key-a": 2 },
|
||||
"key_priority_overrides_by_format": { "openai:chat": { "key-a": 3 } },
|
||||
"pool_priority_overrides": { "provider-pool": 4 }
|
||||
}],
|
||||
"rules": [{
|
||||
"id": "legacy-key-client", "phase": "client_request",
|
||||
"actions": [{ "type": "set_scheduling", "priority_mode": "global_key", "scheduling_mode": "fixed_order" }]
|
||||
}]
|
||||
}))
|
||||
.expect("legacy key scheduling must stay readable");
|
||||
let stored = serde_json::to_value(&config).unwrap();
|
||||
assert_eq!(stored["default_policy"]["priority_mode"], "global_key");
|
||||
assert_eq!(
|
||||
stored["rules"][0]["actions"][0]["priority_mode"],
|
||||
"global_key"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<RoutingGroupConfig>(stored).unwrap(),
|
||||
config
|
||||
);
|
||||
|
||||
for phase in [
|
||||
RoutingRulePhase::ClientRequest,
|
||||
RoutingRulePhase::ProviderRequest,
|
||||
] {
|
||||
let policy = resolve_routing_policy(
|
||||
&config,
|
||||
RoutingPolicyInput {
|
||||
group_id: Some("legacy-group"),
|
||||
group_version: Some(1),
|
||||
selection_source: "explicit",
|
||||
requested_model: "model-a",
|
||||
resolved_model: "model-a",
|
||||
api_format: "openai:chat",
|
||||
user_id: None,
|
||||
api_key_id: None,
|
||||
headers: &json!({}),
|
||||
body: &json!({}),
|
||||
phase,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::Provider);
|
||||
assert_eq!(
|
||||
policy.scheduling_mode,
|
||||
if phase == RoutingRulePhase::ClientRequest {
|
||||
RoutingSchedulingMode::FixedOrder
|
||||
} else {
|
||||
RoutingSchedulingMode::CacheAffinity
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
policy.matched_rules.len(),
|
||||
usize::from(phase == RoutingRulePhase::ClientRequest)
|
||||
);
|
||||
assert_eq!(
|
||||
policy.ranking_overlay.provider_priority_overrides["provider-a"],
|
||||
7
|
||||
);
|
||||
assert_eq!(policy.ranking_overlay.key_priority_overrides["key-a"], 2);
|
||||
assert_eq!(
|
||||
policy.ranking_overlay.pool_priority_overrides["provider-pool"],
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
policy
|
||||
.ranking_overlay
|
||||
.key_priority_for_format("key-a", "openai:chat", 99),
|
||||
3
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
config.default_policy.priority_mode,
|
||||
RoutingSetPriorityMode::GlobalKey
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_disabled_providers_apply_to_every_model_and_cannot_be_reenabled() {
|
||||
let config: RoutingGroupConfig = serde_json::from_value(json!({
|
||||
"disabled_providers": ["provider-disabled"],
|
||||
"model_policies": [{
|
||||
"model": "model-allowlist",
|
||||
"allowed_providers": ["provider-disabled", "provider-enabled"]
|
||||
}],
|
||||
"rules": [{
|
||||
"id": "replace-provider-allowlist",
|
||||
"conditions": { "field": "model", "op": "eq", "value": "rule-allowlist" },
|
||||
"actions": [{
|
||||
"type": "restrict_providers",
|
||||
"provider_ids": ["provider-disabled", "provider-enabled"]
|
||||
}, {
|
||||
"type": "set_provider_priority",
|
||||
"provider_id": "provider-disabled",
|
||||
"priority": 0
|
||||
}]
|
||||
}, {
|
||||
"id": "clear-provider-allowlist",
|
||||
"conditions": { "field": "model", "op": "eq", "value": "rule-unrestricted" },
|
||||
"actions": [{ "type": "restrict_providers", "provider_ids": [] }]
|
||||
}]
|
||||
}))
|
||||
.expect("group provider exclusions should deserialize");
|
||||
|
||||
// The field survives the same round trip used when persisting or
|
||||
// publishing strategy configuration.
|
||||
let stored_config = serde_json::to_value(&config).unwrap();
|
||||
assert_eq!(
|
||||
stored_config["disabled_providers"],
|
||||
json!(["provider-disabled"])
|
||||
);
|
||||
let config: RoutingGroupConfig = serde_json::from_value(stored_config).unwrap();
|
||||
|
||||
for model in [
|
||||
"future-model",
|
||||
"model-allowlist",
|
||||
"rule-allowlist",
|
||||
"rule-unrestricted",
|
||||
] {
|
||||
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("policy with group provider exclusions should resolve");
|
||||
|
||||
assert!(
|
||||
!policy.ranking_overlay.provider_allowed("provider-disabled"),
|
||||
"{model} must retain the group exclusion"
|
||||
);
|
||||
assert!(policy.ranking_overlay.provider_allowed("provider-enabled"));
|
||||
let has_allowlist = matches!(model, "model-allowlist" | "rule-allowlist");
|
||||
assert_eq!(
|
||||
policy.ranking_overlay.provider_allowed("provider-unlisted"),
|
||||
!has_allowlist,
|
||||
"{model} should preserve its normal allowlist behavior"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_model_scheduling_and_rankings_apply_to_future_models() {
|
||||
let config: RoutingGroupConfig = serde_json::from_value(json!({
|
||||
@@ -330,6 +492,7 @@ mod tests {
|
||||
"rules": []
|
||||
}))
|
||||
.expect("all-model scheduling config should deserialize");
|
||||
assert!(config.disabled_providers.is_empty());
|
||||
|
||||
for model in ["existing-model", "future-model"] {
|
||||
let policy = resolve_routing_policy(
|
||||
@@ -349,7 +512,7 @@ mod tests {
|
||||
},
|
||||
)
|
||||
.expect("all-model scheduling policy should resolve");
|
||||
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::GlobalKey);
|
||||
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::Provider);
|
||||
assert_eq!(policy.scheduling_mode, RoutingSchedulingMode::LoadBalance);
|
||||
assert_eq!(
|
||||
policy
|
||||
@@ -419,7 +582,7 @@ mod tests {
|
||||
.is_empty());
|
||||
assert!(policy.matched_rules.is_empty());
|
||||
} else {
|
||||
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::GlobalKey);
|
||||
assert_eq!(policy.priority_mode, RoutingSetPriorityMode::Provider);
|
||||
assert_eq!(policy.scheduling_mode, RoutingSchedulingMode::FixedOrder);
|
||||
assert_eq!(
|
||||
policy
|
||||
@@ -436,6 +599,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolves_model_policy_and_matching_rule() {
|
||||
let config = RoutingGroupConfig {
|
||||
disabled_providers: vec![],
|
||||
default_policy: RoutingDefaultPolicy::default(),
|
||||
model_policies: vec![RoutingModelPolicy {
|
||||
model: "gpt-5".to_string(),
|
||||
@@ -504,6 +668,7 @@ mod tests {
|
||||
#[test]
|
||||
fn default_policy_applies_to_models_without_an_override() {
|
||||
let config = RoutingGroupConfig {
|
||||
disabled_providers: vec![],
|
||||
default_policy: RoutingDefaultPolicy {
|
||||
priority_mode: RoutingSetPriorityMode::GlobalKey,
|
||||
scheduling_mode: RoutingSchedulingMode::LoadBalance,
|
||||
@@ -538,7 +703,7 @@ mod tests {
|
||||
)
|
||||
.expect("the specially configured model should resolve");
|
||||
|
||||
assert_eq!(special.priority_mode, RoutingSetPriorityMode::GlobalKey);
|
||||
assert_eq!(special.priority_mode, RoutingSetPriorityMode::Provider);
|
||||
assert_eq!(special.scheduling_mode, RoutingSchedulingMode::LoadBalance);
|
||||
assert!(special.keep_priority_on_conversion);
|
||||
assert_eq!(special.sticky_key_attempts, 3);
|
||||
@@ -572,7 +737,7 @@ mod tests {
|
||||
)
|
||||
.expect("an unconfigured model should keep using the default policy");
|
||||
|
||||
assert_eq!(ordinary.priority_mode, RoutingSetPriorityMode::GlobalKey);
|
||||
assert_eq!(ordinary.priority_mode, RoutingSetPriorityMode::Provider);
|
||||
assert_eq!(ordinary.scheduling_mode, RoutingSchedulingMode::LoadBalance);
|
||||
assert!(ordinary.keep_priority_on_conversion);
|
||||
assert_eq!(ordinary.sticky_key_attempts, 3);
|
||||
|
||||
@@ -13,6 +13,9 @@ pub enum CandidateKind {
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RankingOverlay {
|
||||
/// Group-wide exclusions take precedence over every provider allowlist.
|
||||
#[serde(default)]
|
||||
pub disabled_providers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_providers: Vec<String>,
|
||||
#[serde(default)]
|
||||
@@ -112,11 +115,15 @@ impl RankingOverlay {
|
||||
}
|
||||
|
||||
pub fn provider_allowed(&self, provider_id: &str) -> bool {
|
||||
self.allowed_providers.is_empty()
|
||||
|| self
|
||||
.allowed_providers
|
||||
.iter()
|
||||
.any(|item| item == provider_id)
|
||||
!self
|
||||
.disabled_providers
|
||||
.iter()
|
||||
.any(|item| item == provider_id)
|
||||
&& (self.allowed_providers.is_empty()
|
||||
|| self
|
||||
.allowed_providers
|
||||
.iter()
|
||||
.any(|item| item == provider_id))
|
||||
}
|
||||
|
||||
pub fn key_allowed(&self, key_id: &str) -> bool {
|
||||
@@ -180,6 +187,41 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn disabled_providers_take_precedence_over_allowlists() {
|
||||
let mut overlay = RankingOverlay {
|
||||
disabled_providers: vec!["provider-disabled".to_string()],
|
||||
..RankingOverlay::default()
|
||||
};
|
||||
assert!(!overlay.provider_allowed("provider-disabled"));
|
||||
assert!(overlay.provider_allowed("provider-enabled"));
|
||||
|
||||
overlay.allowed_providers = vec![
|
||||
"provider-disabled".to_string(),
|
||||
"provider-enabled".to_string(),
|
||||
];
|
||||
assert!(!overlay.provider_allowed("provider-disabled"));
|
||||
assert!(overlay.provider_allowed("provider-enabled"));
|
||||
assert!(!overlay.provider_allowed("provider-unlisted"));
|
||||
|
||||
// An allowlist containing only disabled providers must not become an
|
||||
// empty allowlist, which would otherwise allow unrelated providers.
|
||||
overlay.allowed_providers = vec!["provider-disabled".to_string()];
|
||||
assert!(!overlay.provider_allowed("provider-disabled"));
|
||||
assert!(!overlay.provider_allowed("provider-enabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_overlay_without_disabled_providers_preserves_provider_selection() {
|
||||
let overlay: RankingOverlay = serde_json::from_value(serde_json::json!({
|
||||
"allowed_providers": ["provider-enabled"]
|
||||
}))
|
||||
.expect("legacy overlays should remain readable");
|
||||
assert!(overlay.disabled_providers.is_empty());
|
||||
assert!(overlay.provider_allowed("provider-enabled"));
|
||||
assert!(!overlay.provider_allowed("provider-unlisted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_applies_provider_and_key_priority() {
|
||||
let overlay = RankingOverlay {
|
||||
|
||||
Reference in New Issue
Block a user