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
+3 -3
View File
@@ -13,9 +13,9 @@ pub use actions::{
};
pub use conditions::{RoutingCondition, RoutingConditionContext, RoutingConditionOp};
pub use model::{
RoutingDefaultPolicy, RoutingGroupBinding, RoutingGroupBindingSubject, RoutingGroupConfig,
RoutingGroupRecord, RoutingGroupVersionRecord, RoutingModelPolicy, RoutingPoolPolicyOverride,
RoutingRule, RoutingSchedulingPreset, DEFAULT_STICKY_KEY_ATTEMPTS,
RoutingDefaultPolicy, RoutingExecutionPolicy, RoutingGroupBinding, RoutingGroupBindingSubject,
RoutingGroupConfig, RoutingGroupRecord, RoutingGroupVersionRecord, RoutingModelPolicy,
RoutingPoolPolicyOverride, RoutingRule, RoutingSchedulingPreset, DEFAULT_STICKY_KEY_ATTEMPTS,
};
pub use mutations::{
apply_json_patch_operations, validate_header_patch, validate_json_patch_operations,
+52 -2
View File
@@ -27,6 +27,47 @@ pub struct RoutingPoolPolicyOverride {
/// failing over: one retry on the same key.
pub const DEFAULT_STICKY_KEY_ATTEMPTS: u32 = 2;
/// Request-independent execution behaviours selected by a routing strategy.
///
/// These flags deliberately live beside scheduling rather than in provider
/// transport configuration. A resolved policy is snapshotted for the request
/// and can therefore be consumed by execution without rereading mutable
/// system settings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
pub struct RoutingExecutionPolicy {
#[serde(default, skip_serializing_if = "is_false")]
pub enable_cf_heartbeat: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub cyber_continue_failover: bool,
}
impl<'de> Deserialize<'de> for RoutingExecutionPolicy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize, Default)]
struct LegacyCompatibleExecutionPolicy {
#[serde(default)]
enable_cf_heartbeat: bool,
#[serde(default)]
enable_openai_image_sync_heartbeat: bool,
#[serde(default)]
enable_standard_text_sync_heartbeat: bool,
#[serde(default)]
cyber_continue_failover: bool,
}
let value = LegacyCompatibleExecutionPolicy::deserialize(deserializer)?;
Ok(Self {
enable_cf_heartbeat: value.enable_cf_heartbeat
|| value.enable_openai_image_sync_heartbeat
|| value.enable_standard_text_sync_heartbeat,
cyber_continue_failover: value.cyber_continue_failover,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoutingDefaultPolicy {
#[serde(default)]
@@ -40,6 +81,10 @@ pub struct RoutingDefaultPolicy {
/// `0` and `1` both mean no same-key retry.
#[serde(default = "default_sticky_key_attempts")]
pub sticky_key_attempts: u32,
/// Strategy-scoped execution behaviour. Flattened for a stable JSON
/// shape and backwards-compatible migration from system settings.
#[serde(flatten)]
pub execution_policy: RoutingExecutionPolicy,
}
impl Default for RoutingDefaultPolicy {
@@ -49,6 +94,7 @@ impl Default for RoutingDefaultPolicy {
scheduling_mode: RoutingSchedulingMode::default(),
keep_priority_on_conversion: false,
sticky_key_attempts: DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: RoutingExecutionPolicy::default(),
}
}
}
@@ -57,6 +103,10 @@ fn default_sticky_key_attempts() -> u32 {
DEFAULT_STICKY_KEY_ATTEMPTS
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoutingModelPolicy {
pub model: String,
@@ -100,8 +150,8 @@ pub struct RoutingRule {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RoutingGroupConfig {
#[serde(default)]
pub allowed_models: Vec<String>,
/// The default policy is global for the selected strategy group. Model
/// differences are expressed through `model_policies` and `rules`.
#[serde(default)]
pub default_policy: RoutingDefaultPolicy,
#[serde(default)]
+41 -45
View File
@@ -8,7 +8,9 @@ use crate::actions::{
RoutingAction, RoutingRulePhase, RoutingSchedulingMode, RoutingSetPriorityMode,
};
use crate::conditions::RoutingConditionContext;
use crate::model::{RoutingGroupConfig, RoutingModelPolicy, RoutingPoolPolicyOverride};
use crate::model::{
RoutingExecutionPolicy, RoutingGroupConfig, RoutingModelPolicy, RoutingPoolPolicyOverride,
};
use crate::mutations::{validate_header_patch, validate_json_patch_operations, MutationPlan};
use crate::ranking::RankingOverlay;
use crate::validation::validate_routing_group_config;
@@ -17,7 +19,7 @@ use crate::validation::validate_routing_group_config;
pub enum RoutingPolicyError {
#[error("routing group config is invalid: {0}")]
InvalidConfig(String),
#[error("model is not allowed by routing group: {0}")]
#[error("model is not allowed by routing rule: {0}")]
ModelNotAllowed(String),
#[error("mutation action is invalid: {0}")]
InvalidMutation(String),
@@ -60,6 +62,8 @@ pub struct ResolvedRoutingPolicy {
/// See `RoutingDefaultPolicy::sticky_key_attempts`.
#[serde(default = "default_sticky_key_attempts")]
pub sticky_key_attempts: u32,
#[serde(flatten)]
pub execution_policy: RoutingExecutionPolicy,
pub ranking_overlay: RankingOverlay,
pub mutation_plan: MutationPlan,
#[serde(default)]
@@ -75,14 +79,6 @@ pub fn resolve_routing_policy(
validate_routing_group_config(config)
.map_err(|error| RoutingPolicyError::InvalidConfig(error.to_string()))?;
if !model_allowed(&config.allowed_models, input.requested_model)
&& !model_allowed(&config.allowed_models, input.resolved_model)
{
return Err(RoutingPolicyError::ModelNotAllowed(
input.requested_model.to_string(),
));
}
let mut policy = ResolvedRoutingPolicy {
group_id: input.group_id.map(str::to_string),
group_version: input.group_version,
@@ -93,6 +89,7 @@ pub fn resolve_routing_policy(
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,
ranking_overlay: RankingOverlay::default(),
mutation_plan: MutationPlan::default(),
pool_policy_overrides: BTreeMap::new(),
@@ -322,7 +319,6 @@ mod tests {
#[test]
fn resolves_model_policy_and_matching_rule() {
let config = RoutingGroupConfig {
allowed_models: vec!["gpt-*".to_string()],
default_policy: RoutingDefaultPolicy::default(),
model_policies: vec![RoutingModelPolicy {
model: "gpt-5".to_string(),
@@ -389,14 +385,14 @@ mod tests {
}
#[test]
fn empty_allowlist_keeps_default_policy_for_models_without_an_override() {
fn default_policy_applies_to_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,
sticky_key_attempts: 3,
execution_policy: Default::default(),
},
model_policies: vec![RoutingModelPolicy {
model: "special-model".to_string(),
@@ -471,6 +467,38 @@ mod tests {
.is_empty());
}
#[test]
fn legacy_group_model_allowlist_is_ignored() {
let config: RoutingGroupConfig = serde_json::from_value(json!({
"allowed_models": ["gpt-5"],
"default_policy": {
"priority_mode": "provider",
"scheduling_mode": "cache_affinity"
},
"model_policies": [],
"rules": []
}))
.expect("legacy routing config should remain readable");
resolve_routing_policy(
&config,
RoutingPolicyInput {
group_id: Some("group-1"),
group_version: Some(1),
selection_source: "system_default",
requested_model: "claude-sonnet",
resolved_model: "claude-sonnet",
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase: RoutingRulePhase::ClientRequest,
},
)
.expect("the legacy allowlist must not reject another model");
}
#[test]
fn sticky_key_attempts_defaults_to_two_and_can_be_overridden_by_rule() {
let default_config = RoutingGroupConfig::default();
@@ -541,41 +569,9 @@ mod tests {
assert_eq!(policy.sticky_key_attempts, 1);
}
#[test]
fn rejects_disallowed_model() {
let config = RoutingGroupConfig {
allowed_models: vec!["gpt-5".to_string()],
..RoutingGroupConfig::default()
};
let err = resolve_routing_policy(
&config,
RoutingPolicyInput {
group_id: None,
group_version: None,
selection_source: "test",
requested_model: "claude",
resolved_model: "claude",
api_format: "openai:chat",
user_id: None,
api_key_id: None,
headers: &json!({}),
body: &json!({}),
phase: RoutingRulePhase::ClientRequest,
},
)
.unwrap_err();
assert_eq!(
err,
RoutingPolicyError::ModelNotAllowed("claude".to_string())
);
}
#[test]
fn restrict_model_action_rejects_matching_request() {
let config = RoutingGroupConfig {
allowed_models: vec!["*".to_string()],
rules: vec![RoutingRule {
id: "restrict".to_string(),
priority: 1,