mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
feat(routing): make routing profiles the sole scheduler policy source
Bootstrap an enabled system-default routing group from the legacy scheduler config keys on startup, resolve the default ordering config from that group before falling back to the legacy keys, and stop merging keep_priority_on_conversion with the legacy flag when a policy is resolved. Thread the policy-derived ordering config into candidate preselection so it no longer reads system config independently. Add per-API-format key priority overrides so a key serving several formats keeps independent ordering, matching the legacy global_priority_by_format semantics. Expose keep_priority_on_conversion in the routing profile editor and read the effective policy in the model routing preview, monitoring metrics and provider page badge.
This commit is contained in:
@@ -1840,6 +1840,7 @@ fn routing_trace_for_candidate(
|
||||
CandidateKind::Provider => Some(candidate.key_id.clone()),
|
||||
CandidateKind::PoolGroup => None,
|
||||
},
|
||||
api_format: Some(candidate.endpoint_api_format.clone()),
|
||||
provider_priority: candidate.provider_priority,
|
||||
key_priority: candidate
|
||||
.key_global_priority_for_format
|
||||
|
||||
@@ -3,7 +3,7 @@ use aether_ai_serving::{
|
||||
AiCandidateRankingPort, AiRankableCandidateParts, AiRankingContextConfig,
|
||||
AiRankingSchedulingMode,
|
||||
};
|
||||
use aether_routing_core::{ResolvedRoutingPolicy, RoutingSchedulingMode, RoutingSetPriorityMode};
|
||||
use aether_routing_core::ResolvedRoutingPolicy;
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::warn;
|
||||
@@ -184,35 +184,16 @@ fn ai_ranking_scheduling_mode(mode: SchedulerSchedulingMode) -> AiRankingSchedul
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordering config for a request. A resolved routing policy is authoritative
|
||||
/// and is never merged with legacy system-config values; without a policy the
|
||||
/// effective default (system-default routing group, then legacy keys) applies.
|
||||
pub(crate) async fn scheduler_ordering_config_for_routing_policy(
|
||||
state: PlannerAppState<'_>,
|
||||
routing_policy: Option<&ResolvedRoutingPolicy>,
|
||||
) -> SchedulerOrderingConfig {
|
||||
let system_config = read_scheduler_ordering_config_or_default(state).await;
|
||||
match routing_policy {
|
||||
Some(policy) => {
|
||||
let mut config = scheduler_ordering_config_from_routing_policy(policy);
|
||||
config.keep_priority_on_conversion |= system_config.keep_priority_on_conversion;
|
||||
config
|
||||
}
|
||||
None => system_config,
|
||||
}
|
||||
}
|
||||
|
||||
fn scheduler_ordering_config_from_routing_policy(
|
||||
policy: &ResolvedRoutingPolicy,
|
||||
) -> SchedulerOrderingConfig {
|
||||
SchedulerOrderingConfig {
|
||||
priority_mode: match policy.priority_mode {
|
||||
RoutingSetPriorityMode::Provider => SchedulerPriorityMode::Provider,
|
||||
RoutingSetPriorityMode::GlobalKey => SchedulerPriorityMode::GlobalKey,
|
||||
},
|
||||
scheduling_mode: match policy.scheduling_mode {
|
||||
RoutingSchedulingMode::FixedOrder => SchedulerSchedulingMode::FixedOrder,
|
||||
RoutingSchedulingMode::CacheAffinity => SchedulerSchedulingMode::CacheAffinity,
|
||||
RoutingSchedulingMode::LoadBalance => SchedulerSchedulingMode::LoadBalance,
|
||||
},
|
||||
keep_priority_on_conversion: policy.keep_priority_on_conversion,
|
||||
Some(policy) => SchedulerOrderingConfig::from_routing_policy(policy),
|
||||
None => read_scheduler_ordering_config_or_default(state).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,14 +212,26 @@ fn routing_overlaid_candidate(
|
||||
let overlaid_key_priority = match kind {
|
||||
LocalExecutionCandidateKind::SingleKey => policy
|
||||
.ranking_overlay
|
||||
.key_priority_overrides
|
||||
.get(candidate.key_id.as_str()),
|
||||
.key_priority_override_matching_format(candidate.key_id.as_str(), |format| {
|
||||
crate::ai_serving::api_format_alias_matches(
|
||||
format,
|
||||
candidate.endpoint_api_format.as_str(),
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
policy
|
||||
.ranking_overlay
|
||||
.key_priority_overrides
|
||||
.get(candidate.key_id.as_str())
|
||||
.copied()
|
||||
}),
|
||||
LocalExecutionCandidateKind::PoolGroup => policy
|
||||
.ranking_overlay
|
||||
.pool_priority_overrides
|
||||
.get(candidate.provider_id.as_str()),
|
||||
.get(candidate.provider_id.as_str())
|
||||
.copied(),
|
||||
};
|
||||
if let Some(overlaid_key_priority) = overlaid_key_priority.copied() {
|
||||
if let Some(overlaid_key_priority) = overlaid_key_priority {
|
||||
overlaid.key_internal_priority = overlaid_key_priority;
|
||||
overlaid.key_global_priority_for_format = Some(overlaid_key_priority);
|
||||
}
|
||||
@@ -396,7 +389,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routing_policy_inherits_global_conversion_priority_override() {
|
||||
async fn routing_policy_ignores_legacy_global_conversion_priority_override() {
|
||||
let data_state = GatewayDataState::default().with_system_config_values_for_tests([(
|
||||
"keep_priority_on_conversion".to_string(),
|
||||
json!(true),
|
||||
@@ -429,7 +422,10 @@ mod tests {
|
||||
ordering.scheduling_mode,
|
||||
crate::scheduler::config::SchedulerSchedulingMode::FixedOrder
|
||||
);
|
||||
assert!(ordering.keep_priority_on_conversion);
|
||||
assert!(
|
||||
!ordering.keep_priority_on_conversion,
|
||||
"a resolved routing policy must not inherit the legacy system-config flag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -174,6 +174,8 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
|
||||
self.ranking_seed,
|
||||
false,
|
||||
self.request_operation,
|
||||
self.routing_policy
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1291,6 +1293,9 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
|
||||
.then_some(self.client_session_affinity.as_ref())
|
||||
.flatten(),
|
||||
self.ranking_seed,
|
||||
self.routing_policy
|
||||
.as_ref()
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await?;
|
||||
let skipped_candidates = skipped_candidates
|
||||
@@ -2657,14 +2662,16 @@ mod tests {
|
||||
candidate_repository,
|
||||
)
|
||||
.with_encryption_key_for_tests("development-key")
|
||||
// Legacy keys deliberately disagree with the routing policy: the
|
||||
// resolved policy must be the only source of scheduler ordering.
|
||||
.with_system_config_values_for_tests([
|
||||
(
|
||||
"scheduling_mode".to_string(),
|
||||
serde_json::json!("fixed_order"),
|
||||
serde_json::json!("cache_affinity"),
|
||||
),
|
||||
(
|
||||
"keep_priority_on_conversion".to_string(),
|
||||
serde_json::json!(true),
|
||||
serde_json::json!(false),
|
||||
),
|
||||
]);
|
||||
let app = AppState::new()
|
||||
@@ -2681,7 +2688,7 @@ mod tests {
|
||||
resolved_model: "gpt-5.4-mini".to_string(),
|
||||
priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider,
|
||||
scheduling_mode: aether_routing_core::RoutingSchedulingMode::FixedOrder,
|
||||
keep_priority_on_conversion: false,
|
||||
keep_priority_on_conversion: true,
|
||||
ranking_overlay: Default::default(),
|
||||
mutation_plan: Default::default(),
|
||||
pool_policy_overrides: Default::default(),
|
||||
|
||||
@@ -1073,6 +1073,7 @@ fn ensure_report_context_routing_trace(
|
||||
endpoint_id: decision.endpoint_id.clone().unwrap_or_default(),
|
||||
model_id,
|
||||
key_id,
|
||||
api_format: decision.provider_api_format.clone(),
|
||||
provider_priority,
|
||||
key_priority,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::ai_serving::{
|
||||
};
|
||||
use crate::client_session_affinity::client_session_affinity_from_api_request;
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::scheduler::config::SchedulerOrderingConfig;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
use super::{
|
||||
@@ -140,6 +141,10 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
|
||||
current_unix_secs(),
|
||||
false,
|
||||
spec.operation.map(|operation| operation.as_str()),
|
||||
input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await?;
|
||||
let outcome = materialize_local_execution_candidates_with_serving(
|
||||
@@ -246,6 +251,10 @@ pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a
|
||||
current_unix_secs(),
|
||||
false,
|
||||
spec.operation.map(|operation| operation.as_str()),
|
||||
input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::ai_serving::{
|
||||
};
|
||||
use crate::client_session_affinity::client_session_affinity_from_parts;
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::scheduler::config::SchedulerOrderingConfig;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalGeminiFilesCandidateAttempt;
|
||||
@@ -108,6 +109,10 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await?;
|
||||
let outcome = materialize_local_execution_candidates_with_serving(
|
||||
@@ -181,6 +186,10 @@ pub(super) async fn build_local_gemini_files_candidate_attempt_source<'a>(
|
||||
Some(&input.auth_snapshot),
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await?;
|
||||
Ok(build_local_execution_candidate_attempt_source_with_serving(
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::ai_serving::{
|
||||
};
|
||||
use crate::client_session_affinity::client_session_affinity_from_parts;
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::scheduler::config::SchedulerOrderingConfig;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
|
||||
|
||||
@@ -127,6 +128,10 @@ pub(super) async fn list_local_openai_image_candidate_attempts(
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
false,
|
||||
input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -201,6 +206,10 @@ pub(super) async fn build_local_openai_image_candidate_attempt_source<'a>(
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
false,
|
||||
input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ use crate::ai_serving::{
|
||||
};
|
||||
use crate::client_session_affinity::client_session_affinity_from_parts;
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::scheduler::config::SchedulerOrderingConfig;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalVideoCreateCandidateAttempt;
|
||||
@@ -133,6 +134,10 @@ pub(super) async fn list_local_video_create_candidate_attempts(
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
false,
|
||||
input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -190,6 +195,10 @@ pub(super) async fn build_local_video_create_candidate_attempt_source<'a>(
|
||||
input.client_session_affinity.as_ref(),
|
||||
current_unix_secs(),
|
||||
false,
|
||||
input
|
||||
.routing_policy
|
||||
.as_ref()
|
||||
.map(SchedulerOrderingConfig::from_routing_policy),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -8,9 +8,14 @@ use crate::constants::{
|
||||
API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS, API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS,
|
||||
};
|
||||
use crate::scheduler::candidate::SchedulerSkippedCandidate;
|
||||
use crate::scheduler::config::SchedulerOrderingConfig;
|
||||
use crate::GatewayError;
|
||||
|
||||
impl<'a> PlannerAppState<'a> {
|
||||
/// `ordering_config` is the request's routing-policy derived scheduler
|
||||
/// config (see `SchedulerOrderingConfig::from_routing_policy`). `None`
|
||||
/// falls back to the runtime default.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_candidates(
|
||||
self,
|
||||
api_format: &str,
|
||||
@@ -21,6 +26,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
enable_model_directives: bool,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||
crate::scheduler::candidate::list_selectable_candidates(
|
||||
self.app().data.as_ref(),
|
||||
@@ -33,10 +39,12 @@ impl<'a> PlannerAppState<'a> {
|
||||
client_session_affinity,
|
||||
now_unix_secs,
|
||||
enable_model_directives,
|
||||
ordering_config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_candidates_with_skip_reasons(
|
||||
self,
|
||||
api_format: &str,
|
||||
@@ -47,6 +55,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
enable_model_directives: bool,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
@@ -64,10 +73,12 @@ impl<'a> PlannerAppState<'a> {
|
||||
now_unix_secs,
|
||||
enable_model_directives,
|
||||
None,
|
||||
ordering_config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_operation(
|
||||
self,
|
||||
api_format: &str,
|
||||
@@ -79,6 +90,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
now_unix_secs: u64,
|
||||
enable_model_directives: bool,
|
||||
request_operation: Option<&str>,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
@@ -103,6 +115,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
attempt_now_unix_secs,
|
||||
enable_model_directives,
|
||||
request_operation,
|
||||
ordering_config,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -123,6 +136,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_enumerated_candidates_with_skip_reasons(
|
||||
self,
|
||||
api_format: &str,
|
||||
@@ -132,6 +146,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
@@ -148,10 +163,12 @@ impl<'a> PlannerAppState<'a> {
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
now_unix_secs,
|
||||
ordering_config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model(
|
||||
self,
|
||||
candidate_api_format: &str,
|
||||
@@ -160,6 +177,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||
let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS);
|
||||
let wait_interval = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS.max(1));
|
||||
@@ -176,6 +194,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
attempt_now_unix_secs,
|
||||
ordering_config,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -754,19 +754,28 @@ impl<'a> PoolKeyCursor<'a> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let api_format = self.group.candidate.endpoint_api_format.as_str();
|
||||
rows.sort_by(|left, right| {
|
||||
let left_priority = self
|
||||
.routing_overlay
|
||||
.as_ref()
|
||||
.map_or(left.key_internal_priority, |overlay| {
|
||||
overlay.key_priority(&left.key_id, left.key_internal_priority)
|
||||
});
|
||||
let right_priority = self
|
||||
.routing_overlay
|
||||
.as_ref()
|
||||
.map_or(right.key_internal_priority, |overlay| {
|
||||
overlay.key_priority(&right.key_id, right.key_internal_priority)
|
||||
});
|
||||
let left_priority = self.routing_overlay.as_ref().map_or(
|
||||
left.key_internal_priority,
|
||||
|overlay| {
|
||||
overlay.key_priority_for_format(
|
||||
&left.key_id,
|
||||
api_format,
|
||||
left.key_internal_priority,
|
||||
)
|
||||
},
|
||||
);
|
||||
let right_priority = self.routing_overlay.as_ref().map_or(
|
||||
right.key_internal_priority,
|
||||
|overlay| {
|
||||
overlay.key_priority_for_format(
|
||||
&right.key_id,
|
||||
api_format,
|
||||
right.key_internal_priority,
|
||||
)
|
||||
},
|
||||
);
|
||||
left_priority
|
||||
.cmp(&right_priority)
|
||||
.then(left.key_id.cmp(&right.key_id))
|
||||
|
||||
@@ -67,27 +67,14 @@ pub(crate) async fn build_admin_global_model_routing_payload(
|
||||
.push(key);
|
||||
}
|
||||
|
||||
let scheduling_mode = state
|
||||
.read_system_config_json_value("scheduling_mode")
|
||||
// Effective default scheduling: system-default routing group first, then
|
||||
// legacy system-config keys.
|
||||
let ordering_config = crate::scheduler::config::read_scheduler_ordering_config(state.app())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "cache_affinity".to_string());
|
||||
let priority_mode = state
|
||||
.read_system_config_json_value("provider_priority_mode")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "provider".to_string());
|
||||
let keep_priority_on_conversion = state
|
||||
.read_system_config_json_value("keep_priority_on_conversion")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
.unwrap_or_default();
|
||||
let scheduling_mode = ordering_config.scheduling_mode_str().to_string();
|
||||
let priority_mode = ordering_config.priority_mode_str().to_string();
|
||||
let keep_priority_on_conversion = ordering_config.keep_priority_on_conversion;
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
|
||||
@@ -264,16 +264,10 @@ async fn list_admin_monitoring_cache_affinity_records_matching(
|
||||
pub(super) async fn build_admin_monitoring_cache_snapshot(
|
||||
state: &AdminAppState<'_>,
|
||||
) -> Result<AdminMonitoringCacheSnapshot, GatewayError> {
|
||||
let scheduling_mode = state
|
||||
.read_system_config_json_value("scheduling_mode")
|
||||
.await?
|
||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "cache_affinity".to_string());
|
||||
let provider_priority_mode = state
|
||||
.read_system_config_json_value("provider_priority_mode")
|
||||
.await?
|
||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| "provider".to_string());
|
||||
let ordering_config =
|
||||
crate::scheduler::config::read_scheduler_ordering_config(state.app()).await?;
|
||||
let scheduling_mode = ordering_config.scheduling_mode_str().to_string();
|
||||
let provider_priority_mode = ordering_config.priority_mode_str().to_string();
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let usage_summary = if state.has_usage_data_reader() {
|
||||
|
||||
@@ -2096,6 +2096,22 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
}
|
||||
state.bootstrap_admin_from_env().await?;
|
||||
match state.ensure_system_default_routing_group().await {
|
||||
Ok(Some(group)) => {
|
||||
info!(
|
||||
group_id = %group.id,
|
||||
group_name = %group.name,
|
||||
"created system default routing group from legacy scheduler config"
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
"failed to bootstrap system default routing group; scheduler falls back to legacy system config"
|
||||
);
|
||||
}
|
||||
}
|
||||
match state.prewarm_chat_pii_redaction_runtime_config().await {
|
||||
Ok(enabled) => {
|
||||
info!(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use self::selection::{
|
||||
collect_selectable_candidates, collect_selectable_candidates_with_skip_reasons,
|
||||
collect_selectable_candidates, collect_selectable_candidates_with_skip_reasons_and_ordering,
|
||||
collect_selectable_enumerated_candidates_with_skip_reasons,
|
||||
resolve_preselection_ordering_config,
|
||||
};
|
||||
use super::config::SchedulerOrderingConfig;
|
||||
use super::state::SchedulerRuntimeState;
|
||||
|
||||
mod affinity;
|
||||
@@ -53,6 +55,10 @@ enum RequiredCapabilityMatchMode {
|
||||
Exclusive,
|
||||
}
|
||||
|
||||
/// `ordering_config` carries the request's routing-policy derived scheduler
|
||||
/// config. `None` falls back to the runtime default (system-default routing
|
||||
/// group, then legacy system-config keys).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_candidates(
|
||||
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
|
||||
runtime_state: &impl SchedulerRuntimeState,
|
||||
@@ -64,6 +70,7 @@ pub(crate) async fn list_selectable_candidates(
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
enable_model_directives: bool,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||
collect_selectable_candidates(
|
||||
selection_row_source,
|
||||
@@ -76,6 +83,7 @@ pub(crate) async fn list_selectable_candidates(
|
||||
client_session_affinity,
|
||||
now_unix_secs,
|
||||
enable_model_directives,
|
||||
ordering_config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -87,6 +95,7 @@ pub(crate) fn is_exact_all_skipped_by_auth_limit(
|
||||
selection::is_exact_all_skipped_by_auth_limit(selected, skipped)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_candidates_with_skip_reasons(
|
||||
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
|
||||
runtime_state: &impl SchedulerRuntimeState,
|
||||
@@ -98,6 +107,7 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons(
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
enable_model_directives: bool,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
@@ -105,7 +115,7 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons(
|
||||
),
|
||||
GatewayError,
|
||||
> {
|
||||
collect_selectable_candidates_with_skip_reasons(
|
||||
collect_selectable_candidates_with_skip_reasons_and_ordering(
|
||||
selection_row_source,
|
||||
runtime_state,
|
||||
api_format,
|
||||
@@ -117,10 +127,12 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons(
|
||||
now_unix_secs,
|
||||
enable_model_directives,
|
||||
None,
|
||||
ordering_config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_operation(
|
||||
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
|
||||
runtime_state: &impl SchedulerRuntimeState,
|
||||
@@ -133,6 +145,7 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_ope
|
||||
now_unix_secs: u64,
|
||||
enable_model_directives: bool,
|
||||
request_operation: Option<&str>,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
@@ -140,7 +153,7 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_ope
|
||||
),
|
||||
GatewayError,
|
||||
> {
|
||||
collect_selectable_candidates_with_skip_reasons(
|
||||
collect_selectable_candidates_with_skip_reasons_and_ordering(
|
||||
selection_row_source,
|
||||
runtime_state,
|
||||
api_format,
|
||||
@@ -152,6 +165,7 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_ope
|
||||
now_unix_secs,
|
||||
enable_model_directives,
|
||||
request_operation,
|
||||
ordering_config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -166,6 +180,7 @@ pub(crate) async fn list_selectable_enumerated_candidates_with_skip_reasons(
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
@@ -173,7 +188,8 @@ pub(crate) async fn list_selectable_enumerated_candidates_with_skip_reasons(
|
||||
),
|
||||
GatewayError,
|
||||
> {
|
||||
let ordering_config = runtime_state.read_scheduler_ordering_config().await?;
|
||||
let ordering_config =
|
||||
resolve_preselection_ordering_config(runtime_state, ordering_config).await?;
|
||||
let priority_affinity_key = selection::scheduling_priority_affinity_key(
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
@@ -194,6 +210,7 @@ pub(crate) async fn list_selectable_enumerated_candidates_with_skip_reasons(
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model(
|
||||
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
|
||||
runtime_state: &impl SchedulerRuntimeState,
|
||||
@@ -203,6 +220,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||
Ok(
|
||||
list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal(
|
||||
@@ -214,12 +232,14 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
now_unix_secs,
|
||||
ordering_config,
|
||||
)
|
||||
.await?
|
||||
.0,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal(
|
||||
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
|
||||
runtime_state: &impl SchedulerRuntimeState,
|
||||
@@ -229,6 +249,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<(Vec<SchedulerMinimalCandidateSelectionCandidate>, bool), GatewayError> {
|
||||
let normalized_api_format = normalize_api_format(candidate_api_format);
|
||||
if normalized_api_format.is_empty() {
|
||||
@@ -262,20 +283,22 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
|
||||
let mut all_attempts_blocked_by_auth_limit = !model_names.is_empty();
|
||||
|
||||
for global_model_name in model_names {
|
||||
let (candidates, skipped_candidates) = collect_selectable_candidates_with_skip_reasons(
|
||||
selection_row_source,
|
||||
runtime_state,
|
||||
&normalized_api_format,
|
||||
&global_model_name,
|
||||
require_streaming,
|
||||
required_capabilities.as_ref(),
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
now_unix_secs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let (candidates, skipped_candidates) =
|
||||
collect_selectable_candidates_with_skip_reasons_and_ordering(
|
||||
selection_row_source,
|
||||
runtime_state,
|
||||
&normalized_api_format,
|
||||
&global_model_name,
|
||||
require_streaming,
|
||||
required_capabilities.as_ref(),
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
now_unix_secs,
|
||||
false,
|
||||
None,
|
||||
ordering_config,
|
||||
)
|
||||
.await?;
|
||||
all_attempts_blocked_by_auth_limit &=
|
||||
is_exact_all_skipped_by_auth_limit(&candidates, &skipped_candidates);
|
||||
match capability_mode {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::data::auth::GatewayAuthApiKeySnapshot;
|
||||
use crate::data::candidate_selection::MinimalCandidateSelectionRowSource;
|
||||
use crate::scheduler::affinity::SCHEDULER_AFFINITY_TTL;
|
||||
use crate::scheduler::config::SchedulerSchedulingMode;
|
||||
use crate::scheduler::config::{SchedulerOrderingConfig, SchedulerSchedulingMode};
|
||||
use crate::GatewayError;
|
||||
use aether_scheduler_core::ClientSessionAffinity;
|
||||
|
||||
@@ -115,6 +115,7 @@ pub(super) async fn select_minimal_candidate(
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn collect_selectable_candidates(
|
||||
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
|
||||
runtime_state: &impl SchedulerRuntimeState,
|
||||
@@ -126,24 +127,32 @@ pub(super) async fn collect_selectable_candidates(
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
enable_model_directives: bool,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||
Ok(collect_selectable_candidates_with_skip_reasons(
|
||||
selection_row_source,
|
||||
runtime_state,
|
||||
api_format,
|
||||
global_model_name,
|
||||
require_streaming,
|
||||
required_capabilities,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
now_unix_secs,
|
||||
enable_model_directives,
|
||||
None,
|
||||
Ok(
|
||||
collect_selectable_candidates_with_skip_reasons_and_ordering(
|
||||
selection_row_source,
|
||||
runtime_state,
|
||||
api_format,
|
||||
global_model_name,
|
||||
require_streaming,
|
||||
required_capabilities,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
now_unix_secs,
|
||||
enable_model_directives,
|
||||
None,
|
||||
ordering_config,
|
||||
)
|
||||
.await?
|
||||
.0,
|
||||
)
|
||||
.await?
|
||||
.0)
|
||||
}
|
||||
|
||||
/// Legacy-shaped entrypoint that resolves the ordering config from the
|
||||
/// runtime state. Prefer `collect_selectable_candidates_with_skip_reasons_and_ordering`
|
||||
/// and pass the request's routing-policy config explicitly.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn collect_selectable_candidates_with_skip_reasons(
|
||||
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
|
||||
runtime_state: &impl SchedulerRuntimeState,
|
||||
@@ -163,7 +172,59 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons(
|
||||
),
|
||||
GatewayError,
|
||||
> {
|
||||
let ordering_config = runtime_state.read_scheduler_ordering_config().await?;
|
||||
collect_selectable_candidates_with_skip_reasons_and_ordering(
|
||||
selection_row_source,
|
||||
runtime_state,
|
||||
api_format,
|
||||
global_model_name,
|
||||
require_streaming,
|
||||
required_capabilities,
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
now_unix_secs,
|
||||
enable_model_directives,
|
||||
request_operation,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve the ordering config for a preselection pass: the routing-policy
|
||||
/// derived config wins when the caller has one; otherwise fall back to the
|
||||
/// runtime default (system-default routing group, then legacy keys).
|
||||
pub(super) async fn resolve_preselection_ordering_config(
|
||||
runtime_state: &impl SchedulerRuntimeState,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<SchedulerOrderingConfig, GatewayError> {
|
||||
match ordering_config {
|
||||
Some(config) => Ok(config),
|
||||
None => runtime_state.read_scheduler_ordering_config().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn collect_selectable_candidates_with_skip_reasons_and_ordering(
|
||||
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
|
||||
runtime_state: &impl SchedulerRuntimeState,
|
||||
api_format: &str,
|
||||
global_model_name: &str,
|
||||
require_streaming: bool,
|
||||
required_capabilities: Option<&serde_json::Value>,
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
enable_model_directives: bool,
|
||||
request_operation: Option<&str>,
|
||||
ordering_config: Option<SchedulerOrderingConfig>,
|
||||
) -> Result<
|
||||
(
|
||||
Vec<SchedulerMinimalCandidateSelectionCandidate>,
|
||||
Vec<SchedulerSkippedCandidate>,
|
||||
),
|
||||
GatewayError,
|
||||
> {
|
||||
let ordering_config =
|
||||
resolve_preselection_ordering_config(runtime_state, ordering_config).await?;
|
||||
let priority_affinity_key = scheduling_priority_affinity_key(
|
||||
auth_snapshot,
|
||||
client_session_affinity,
|
||||
@@ -205,7 +266,7 @@ pub(super) async fn collect_selectable_enumerated_candidates_with_skip_reasons(
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||
now_unix_secs: u64,
|
||||
ordering_config: crate::scheduler::config::SchedulerOrderingConfig,
|
||||
ordering_config: SchedulerOrderingConfig,
|
||||
priority_affinity_key: Option<&str>,
|
||||
) -> Result<
|
||||
(
|
||||
|
||||
@@ -65,6 +65,7 @@ async fn compatible_required_capability_prefers_matching_keys_without_hard_filte
|
||||
None,
|
||||
None,
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("selection should succeed");
|
||||
@@ -120,6 +121,7 @@ async fn exclusive_required_capability_keeps_hard_filtering_only_matching_keys()
|
||||
None,
|
||||
None,
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("selection should succeed");
|
||||
@@ -196,6 +198,7 @@ async fn required_capability_without_model_uses_session_scoped_affinity() {
|
||||
Some(&auth_snapshot),
|
||||
Some(&client_session_affinity),
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("selection should succeed");
|
||||
@@ -273,6 +276,7 @@ async fn required_capability_reports_auth_limit_signal_when_every_model_is_block
|
||||
Some(&auth_snapshot),
|
||||
None,
|
||||
100,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("selection should succeed");
|
||||
|
||||
@@ -75,6 +75,7 @@ async fn collect_selectable_candidates(
|
||||
None,
|
||||
now_unix_secs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use aether_data_contracts::repository::routing_profiles::RoutingGroupLookupKey;
|
||||
use aether_routing_core::{
|
||||
ResolvedRoutingPolicy, RoutingDefaultPolicy, RoutingSchedulingMode, RoutingSetPriorityMode,
|
||||
};
|
||||
use aether_scheduler_core::SchedulerPriorityMode;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
@@ -10,6 +15,23 @@ pub(crate) enum SchedulerSchedulingMode {
|
||||
LoadBalance,
|
||||
}
|
||||
|
||||
impl SchedulerSchedulingMode {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::FixedOrder => "fixed_order",
|
||||
Self::CacheAffinity => "cache_affinity",
|
||||
Self::LoadBalance => "load_balance",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scheduler_priority_mode_as_str(mode: SchedulerPriorityMode) -> &'static str {
|
||||
match mode {
|
||||
SchedulerPriorityMode::Provider => "provider",
|
||||
SchedulerPriorityMode::GlobalKey => "global_key",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct SchedulerOrderingConfig {
|
||||
pub(crate) priority_mode: SchedulerPriorityMode,
|
||||
@@ -27,6 +49,64 @@ impl Default for SchedulerOrderingConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl SchedulerOrderingConfig {
|
||||
/// Ordering config derived from a resolved routing policy. The policy is
|
||||
/// the single source of truth: no legacy system-config value is merged in.
|
||||
pub(crate) fn from_routing_policy(policy: &ResolvedRoutingPolicy) -> Self {
|
||||
Self {
|
||||
priority_mode: scheduler_priority_mode_from_routing(policy.priority_mode),
|
||||
scheduling_mode: scheduler_scheduling_mode_from_routing(policy.scheduling_mode),
|
||||
keep_priority_on_conversion: policy.keep_priority_on_conversion,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_routing_default_policy(policy: &RoutingDefaultPolicy) -> Self {
|
||||
Self {
|
||||
priority_mode: scheduler_priority_mode_from_routing(policy.priority_mode),
|
||||
scheduling_mode: scheduler_scheduling_mode_from_routing(policy.scheduling_mode),
|
||||
keep_priority_on_conversion: policy.keep_priority_on_conversion,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_routing_default_policy(self) -> RoutingDefaultPolicy {
|
||||
RoutingDefaultPolicy {
|
||||
priority_mode: match self.priority_mode {
|
||||
SchedulerPriorityMode::Provider => RoutingSetPriorityMode::Provider,
|
||||
SchedulerPriorityMode::GlobalKey => RoutingSetPriorityMode::GlobalKey,
|
||||
},
|
||||
scheduling_mode: match self.scheduling_mode {
|
||||
SchedulerSchedulingMode::FixedOrder => RoutingSchedulingMode::FixedOrder,
|
||||
SchedulerSchedulingMode::CacheAffinity => RoutingSchedulingMode::CacheAffinity,
|
||||
SchedulerSchedulingMode::LoadBalance => RoutingSchedulingMode::LoadBalance,
|
||||
},
|
||||
keep_priority_on_conversion: self.keep_priority_on_conversion,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn priority_mode_str(self) -> &'static str {
|
||||
scheduler_priority_mode_as_str(self.priority_mode)
|
||||
}
|
||||
|
||||
pub(crate) fn scheduling_mode_str(self) -> &'static str {
|
||||
self.scheduling_mode.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
fn scheduler_priority_mode_from_routing(mode: RoutingSetPriorityMode) -> SchedulerPriorityMode {
|
||||
match mode {
|
||||
RoutingSetPriorityMode::Provider => SchedulerPriorityMode::Provider,
|
||||
RoutingSetPriorityMode::GlobalKey => SchedulerPriorityMode::GlobalKey,
|
||||
}
|
||||
}
|
||||
|
||||
fn scheduler_scheduling_mode_from_routing(mode: RoutingSchedulingMode) -> SchedulerSchedulingMode {
|
||||
match mode {
|
||||
RoutingSchedulingMode::FixedOrder => SchedulerSchedulingMode::FixedOrder,
|
||||
RoutingSchedulingMode::CacheAffinity => SchedulerSchedulingMode::CacheAffinity,
|
||||
RoutingSchedulingMode::LoadBalance => SchedulerSchedulingMode::LoadBalance,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_scheduler_priority_mode(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> SchedulerPriorityMode {
|
||||
@@ -62,8 +142,62 @@ pub(crate) fn parse_scheduler_scheduling_mode(
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective scheduler ordering config for requests that carry no resolved
|
||||
/// routing policy.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. the enabled system-default routing group's `default_policy`;
|
||||
/// 2. the legacy system-config keys (`provider_priority_mode`,
|
||||
/// `scheduling_mode`, `keep_priority_on_conversion`).
|
||||
///
|
||||
/// Step 2 only exists so deployments that never created a routing group keep
|
||||
/// their behaviour; once the legacy keys are removed this function collapses
|
||||
/// to step 1 plus `SchedulerOrderingConfig::default()`.
|
||||
pub(crate) async fn read_scheduler_ordering_config(
|
||||
state: &AppState,
|
||||
) -> Result<SchedulerOrderingConfig, GatewayError> {
|
||||
if let Some(config) = read_system_default_routing_ordering_config(state).await? {
|
||||
return Ok(config);
|
||||
}
|
||||
read_legacy_scheduler_ordering_config(state).await
|
||||
}
|
||||
|
||||
/// Ordering config from the enabled system-default routing group, if any.
|
||||
pub(crate) async fn read_system_default_routing_ordering_config(
|
||||
state: &AppState,
|
||||
) -> Result<Option<SchedulerOrderingConfig>, GatewayError> {
|
||||
let Some(group) = state
|
||||
.find_routing_group(RoutingGroupLookupKey::SystemDefault)
|
||||
.await?
|
||||
.filter(|group| group.enabled)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let default_policy = match group.config_json.get("default_policy") {
|
||||
None | Some(serde_json::Value::Null) => RoutingDefaultPolicy::default(),
|
||||
Some(value) => match serde_json::from_value::<RoutingDefaultPolicy>(value.clone()) {
|
||||
Ok(policy) => policy,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
event_name = "scheduler_system_default_routing_policy_invalid",
|
||||
log_type = "event",
|
||||
group_id = %group.id,
|
||||
error = %error,
|
||||
"system default routing group has an invalid default_policy; ignoring it"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
},
|
||||
};
|
||||
Ok(Some(SchedulerOrderingConfig::from_routing_default_policy(
|
||||
&default_policy,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Legacy system-config based ordering config. Kept only as a migration
|
||||
/// fallback; see `read_scheduler_ordering_config`.
|
||||
pub(crate) async fn read_legacy_scheduler_ordering_config(
|
||||
state: &AppState,
|
||||
) -> Result<SchedulerOrderingConfig, GatewayError> {
|
||||
let priority_mode = parse_scheduler_priority_mode(
|
||||
state
|
||||
@@ -89,3 +223,162 @@ pub(crate) async fn read_scheduler_ordering_config(
|
||||
keep_priority_on_conversion,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_data::repository::routing_profiles::InMemoryRoutingGroupRepository;
|
||||
use aether_data_contracts::repository::routing_profiles::{
|
||||
CreateRoutingGroupRecord, RoutingGroupLookupKey, RoutingGroupReadRepository,
|
||||
RoutingGroupWriteRepository,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::data::GatewayDataState;
|
||||
|
||||
fn legacy_values() -> [(String, serde_json::Value); 3] {
|
||||
[
|
||||
("provider_priority_mode".to_string(), json!("global_key")),
|
||||
("scheduling_mode".to_string(), json!("load_balance")),
|
||||
("keep_priority_on_conversion".to_string(), json!(true)),
|
||||
]
|
||||
}
|
||||
|
||||
async fn create_system_default(
|
||||
repository: &InMemoryRoutingGroupRepository,
|
||||
enabled: bool,
|
||||
config_json: serde_json::Value,
|
||||
) {
|
||||
repository
|
||||
.create_routing_group(CreateRoutingGroupRecord {
|
||||
id: "system-default".to_string(),
|
||||
name: "system-default".to_string(),
|
||||
description: None,
|
||||
enabled,
|
||||
is_system_default: true,
|
||||
config_json,
|
||||
version: 1,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
published_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_default_routing_group_overrides_legacy_keys() {
|
||||
let repository = Arc::new(InMemoryRoutingGroupRepository::default());
|
||||
create_system_default(
|
||||
&repository,
|
||||
true,
|
||||
json!({
|
||||
"default_policy": {
|
||||
"priority_mode": "provider",
|
||||
"scheduling_mode": "fixed_order",
|
||||
"keep_priority_on_conversion": false
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let state = AppState::new().unwrap().with_data_state_for_tests(
|
||||
GatewayDataState::disabled()
|
||||
.with_system_config_values_for_tests(legacy_values())
|
||||
.with_routing_group_repository_for_tests(repository),
|
||||
);
|
||||
|
||||
let config = read_scheduler_ordering_config(&state).await.unwrap();
|
||||
|
||||
assert_eq!(config.priority_mode, SchedulerPriorityMode::Provider);
|
||||
assert_eq!(config.scheduling_mode, SchedulerSchedulingMode::FixedOrder);
|
||||
assert!(!config.keep_priority_on_conversion);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_default_policy_in_system_default_group_uses_routing_defaults() {
|
||||
let repository = Arc::new(InMemoryRoutingGroupRepository::default());
|
||||
create_system_default(&repository, true, json!({})).await;
|
||||
let state = AppState::new().unwrap().with_data_state_for_tests(
|
||||
GatewayDataState::disabled()
|
||||
.with_system_config_values_for_tests(legacy_values())
|
||||
.with_routing_group_repository_for_tests(repository),
|
||||
);
|
||||
|
||||
let config = read_scheduler_ordering_config(&state).await.unwrap();
|
||||
|
||||
assert_eq!(config, SchedulerOrderingConfig::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_or_missing_system_default_group_falls_back_to_legacy_keys() {
|
||||
let repository = Arc::new(InMemoryRoutingGroupRepository::default());
|
||||
create_system_default(
|
||||
&repository,
|
||||
false,
|
||||
json!({"default_policy": {"scheduling_mode": "fixed_order"}}),
|
||||
)
|
||||
.await;
|
||||
let with_disabled_group = AppState::new().unwrap().with_data_state_for_tests(
|
||||
GatewayDataState::disabled()
|
||||
.with_system_config_values_for_tests(legacy_values())
|
||||
.with_routing_group_repository_for_tests(repository),
|
||||
);
|
||||
let without_repository = AppState::new().unwrap().with_data_state_for_tests(
|
||||
GatewayDataState::disabled().with_system_config_values_for_tests(legacy_values()),
|
||||
);
|
||||
|
||||
for state in [with_disabled_group, without_repository] {
|
||||
let config = read_scheduler_ordering_config(&state).await.unwrap();
|
||||
assert_eq!(config.priority_mode, SchedulerPriorityMode::GlobalKey);
|
||||
assert_eq!(config.scheduling_mode, SchedulerSchedulingMode::LoadBalance);
|
||||
assert!(config.keep_priority_on_conversion);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bootstrap_creates_system_default_group_from_legacy_keys_once() {
|
||||
let repository = Arc::new(InMemoryRoutingGroupRepository::default());
|
||||
let state = AppState::new().unwrap().with_data_state_for_tests(
|
||||
GatewayDataState::disabled()
|
||||
.with_system_config_values_for_tests(legacy_values())
|
||||
.with_routing_group_repository_for_tests(repository.clone()),
|
||||
);
|
||||
|
||||
let created = state
|
||||
.ensure_system_default_routing_group_inner()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("first bootstrap should create the system default group");
|
||||
assert!(created.enabled);
|
||||
assert!(created.is_system_default);
|
||||
assert_eq!(
|
||||
created.config_json["default_policy"],
|
||||
json!({
|
||||
"priority_mode": "global_key",
|
||||
"scheduling_mode": "load_balance",
|
||||
"keep_priority_on_conversion": true
|
||||
})
|
||||
);
|
||||
|
||||
let second = state
|
||||
.ensure_system_default_routing_group_inner()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(second.is_none(), "bootstrap must be idempotent");
|
||||
assert_eq!(
|
||||
repository
|
||||
.find_routing_group(RoutingGroupLookupKey::SystemDefault)
|
||||
.await
|
||||
.unwrap()
|
||||
.map(|group| group.id),
|
||||
Some(created.id)
|
||||
);
|
||||
|
||||
let config = read_scheduler_ordering_config(&state).await.unwrap();
|
||||
assert_eq!(config.priority_mode, SchedulerPriorityMode::GlobalKey);
|
||||
assert_eq!(config.scheduling_mode, SchedulerSchedulingMode::LoadBalance);
|
||||
assert!(config.keep_priority_on_conversion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,89 @@ use aether_data_contracts::repository::routing_profiles::{
|
||||
StoredRoutingGroup, StoredRoutingGroupBinding, StoredRoutingGroupVersion,
|
||||
UpdateRoutingGroupBindingRecord, UpdateRoutingGroupRecord,
|
||||
};
|
||||
use aether_routing_core::RoutingGroupConfig;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
use super::{AppState, GatewayError};
|
||||
|
||||
const BOOTSTRAP_SYSTEM_DEFAULT_ROUTING_GROUP_NAME: &str = "system-default";
|
||||
|
||||
impl AppState {
|
||||
/// Make sure an enabled system-default routing group exists.
|
||||
///
|
||||
/// When none exists, one is created from the legacy scheduler system-config
|
||||
/// keys so that removing those keys later does not change behaviour. Returns
|
||||
/// the created group, or `None` when nothing had to be created (no routing
|
||||
/// storage, no writer, or a system default already exists).
|
||||
pub async fn ensure_system_default_routing_group(
|
||||
&self,
|
||||
) -> Result<Option<StoredRoutingGroup>, std::io::Error> {
|
||||
self.ensure_system_default_routing_group_inner()
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(format!("{err:?}")))
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_system_default_routing_group_inner(
|
||||
&self,
|
||||
) -> Result<Option<StoredRoutingGroup>, GatewayError> {
|
||||
if !self.has_routing_group_data_reader() {
|
||||
return Ok(None);
|
||||
}
|
||||
if self
|
||||
.find_routing_group(RoutingGroupLookupKey::SystemDefault)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.has_routing_group_data_writer() {
|
||||
warn!(
|
||||
event_name = "routing_system_default_bootstrap_skipped",
|
||||
log_type = "event",
|
||||
"no system default routing group exists and routing storage is read-only; scheduler falls back to legacy system config"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let legacy = crate::scheduler::config::read_legacy_scheduler_ordering_config(self).await?;
|
||||
let config = RoutingGroupConfig {
|
||||
default_policy: legacy.to_routing_default_policy(),
|
||||
..RoutingGroupConfig::default()
|
||||
};
|
||||
let config_json = serde_json::to_value(config)
|
||||
.map_err(|err| GatewayError::Internal(format!("serialize routing config: {err}")))?;
|
||||
|
||||
let name = if self
|
||||
.find_routing_group(RoutingGroupLookupKey::Name(
|
||||
BOOTSTRAP_SYSTEM_DEFAULT_ROUTING_GROUP_NAME,
|
||||
))
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
format!(
|
||||
"{BOOTSTRAP_SYSTEM_DEFAULT_ROUTING_GROUP_NAME}-{}",
|
||||
&uuid::Uuid::new_v4().simple().to_string()[..8]
|
||||
)
|
||||
} else {
|
||||
BOOTSTRAP_SYSTEM_DEFAULT_ROUTING_GROUP_NAME.to_string()
|
||||
};
|
||||
let now = crate::clock::current_unix_secs() as i64;
|
||||
self.create_routing_group(CreateRoutingGroupRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
description: Some("自动从旧版调度配置迁移生成的系统默认策略".to_string()),
|
||||
enabled: true,
|
||||
is_system_default: true,
|
||||
config_json,
|
||||
version: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
published_at: Some(now),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn has_routing_group_data_reader(&self) -> bool {
|
||||
self.data.has_routing_group_reader()
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ pub enum RoutingAction {
|
||||
SetKeyPriority {
|
||||
key_id: String,
|
||||
priority: i32,
|
||||
/// When set, the override only applies to candidates served through
|
||||
/// this API format; otherwise it applies to the key on every format.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
api_format: Option<String>,
|
||||
},
|
||||
JsonPatchBody {
|
||||
patch: Vec<RoutingJsonPatchOperation>,
|
||||
|
||||
@@ -13,9 +13,9 @@ pub use actions::{
|
||||
};
|
||||
pub use conditions::{RoutingCondition, RoutingConditionContext, RoutingConditionOp};
|
||||
pub use model::{
|
||||
RoutingGroupBinding, RoutingGroupBindingSubject, RoutingGroupConfig, RoutingGroupRecord,
|
||||
RoutingGroupVersionRecord, RoutingModelPolicy, RoutingPoolPolicyOverride, RoutingRule,
|
||||
RoutingSchedulingPreset,
|
||||
RoutingDefaultPolicy, RoutingGroupBinding, RoutingGroupBindingSubject, RoutingGroupConfig,
|
||||
RoutingGroupRecord, RoutingGroupVersionRecord, RoutingModelPolicy, RoutingPoolPolicyOverride,
|
||||
RoutingRule, RoutingSchedulingPreset,
|
||||
};
|
||||
pub use mutations::{
|
||||
apply_json_patch_operations, validate_header_patch, validate_json_patch_operations,
|
||||
|
||||
@@ -44,6 +44,13 @@ pub struct RoutingModelPolicy {
|
||||
pub provider_priority_overrides: BTreeMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub key_priority_overrides: BTreeMap<String, i32>,
|
||||
/// Key priority overrides scoped to one API format: `api_format -> key_id -> priority`.
|
||||
///
|
||||
/// A key can serve several API formats and legacy `global_priority_by_format`
|
||||
/// ranks it independently per format. Entries here take precedence over
|
||||
/// `key_priority_overrides` when the candidate format matches.
|
||||
#[serde(default)]
|
||||
pub key_priority_overrides_by_format: BTreeMap<String, BTreeMap<String, i32>>,
|
||||
#[serde(default)]
|
||||
pub pool_priority_overrides: BTreeMap<String, i32>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -163,6 +163,13 @@ fn apply_model_policy(policy: &mut ResolvedRoutingPolicy, model_policy: &Routing
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), *value)),
|
||||
);
|
||||
for (api_format, overrides) in &model_policy.key_priority_overrides_by_format {
|
||||
for (key_id, priority) in overrides {
|
||||
policy
|
||||
.ranking_overlay
|
||||
.insert_key_priority_override_for_format(api_format, key_id.clone(), *priority);
|
||||
}
|
||||
}
|
||||
policy.ranking_overlay.pool_priority_overrides.extend(
|
||||
model_policy
|
||||
.pool_priority_overrides
|
||||
@@ -218,12 +225,27 @@ fn apply_action(
|
||||
.provider_priority_overrides
|
||||
.insert(provider_id.clone(), *priority);
|
||||
}
|
||||
RoutingAction::SetKeyPriority { key_id, priority } => {
|
||||
policy
|
||||
.ranking_overlay
|
||||
.key_priority_overrides
|
||||
.insert(key_id.clone(), *priority);
|
||||
}
|
||||
RoutingAction::SetKeyPriority {
|
||||
key_id,
|
||||
priority,
|
||||
api_format,
|
||||
} => match api_format
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|f| !f.is_empty())
|
||||
{
|
||||
Some(api_format) => {
|
||||
policy
|
||||
.ranking_overlay
|
||||
.insert_key_priority_override_for_format(api_format, key_id.clone(), *priority);
|
||||
}
|
||||
None => {
|
||||
policy
|
||||
.ranking_overlay
|
||||
.key_priority_overrides
|
||||
.insert(key_id.clone(), *priority);
|
||||
}
|
||||
},
|
||||
RoutingAction::JsonPatchBody { patch } => {
|
||||
validate_json_patch_operations(patch)
|
||||
.map_err(|error| RoutingPolicyError::InvalidMutation(error.to_string()))?;
|
||||
|
||||
@@ -21,6 +21,9 @@ pub struct RankingOverlay {
|
||||
pub provider_priority_overrides: BTreeMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub key_priority_overrides: BTreeMap<String, i32>,
|
||||
/// `api_format -> key_id -> priority`; see `RoutingModelPolicy`.
|
||||
#[serde(default)]
|
||||
pub key_priority_overrides_by_format: BTreeMap<String, BTreeMap<String, i32>>,
|
||||
#[serde(default)]
|
||||
pub pool_priority_overrides: BTreeMap<String, i32>,
|
||||
}
|
||||
@@ -40,6 +43,46 @@ impl RankingOverlay {
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// Format-scoped key priority: a per-format override wins, then the
|
||||
/// format-agnostic key override, then `fallback`.
|
||||
pub fn key_priority_for_format(&self, key_id: &str, api_format: &str, fallback: i32) -> i32 {
|
||||
self.key_priority_override_for_format(key_id, api_format)
|
||||
.unwrap_or_else(|| self.key_priority(key_id, fallback))
|
||||
}
|
||||
|
||||
/// Format-scoped key override using exact (case-insensitive) format match.
|
||||
pub fn key_priority_override_for_format(&self, key_id: &str, api_format: &str) -> Option<i32> {
|
||||
let api_format = api_format.trim();
|
||||
self.key_priority_override_matching_format(key_id, |format| {
|
||||
format.trim().eq_ignore_ascii_case(api_format)
|
||||
})
|
||||
}
|
||||
|
||||
/// Format-scoped key override where the caller decides how configured
|
||||
/// format names match the candidate format (for alias-aware matching).
|
||||
pub fn key_priority_override_matching_format(
|
||||
&self,
|
||||
key_id: &str,
|
||||
mut format_matches: impl FnMut(&str) -> bool,
|
||||
) -> Option<i32> {
|
||||
self.key_priority_overrides_by_format
|
||||
.iter()
|
||||
.find(|(format, _)| format_matches(format))
|
||||
.and_then(|(_, overrides)| overrides.get(key_id).copied())
|
||||
}
|
||||
|
||||
pub fn insert_key_priority_override_for_format(
|
||||
&mut self,
|
||||
api_format: &str,
|
||||
key_id: String,
|
||||
priority: i32,
|
||||
) {
|
||||
self.key_priority_overrides_by_format
|
||||
.entry(api_format.trim().to_ascii_lowercase())
|
||||
.or_default()
|
||||
.insert(key_id, priority);
|
||||
}
|
||||
|
||||
pub fn pool_priority(&self, provider_id: &str, fallback: i32) -> i32 {
|
||||
self.pool_priority_overrides
|
||||
.get(provider_id)
|
||||
@@ -89,6 +132,9 @@ pub struct RoutingCandidateFacts {
|
||||
pub model_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key_id: Option<String>,
|
||||
/// Candidate API format used to resolve format-scoped key overrides.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
pub provider_priority: i32,
|
||||
pub key_priority: i32,
|
||||
}
|
||||
@@ -114,7 +160,12 @@ pub fn rank_vector_for_candidate(
|
||||
CandidateKind::Provider => facts
|
||||
.key_id
|
||||
.as_deref()
|
||||
.map(|key_id| overlay.key_priority(key_id, facts.key_priority))
|
||||
.map(|key_id| match facts.api_format.as_deref() {
|
||||
Some(api_format) => {
|
||||
overlay.key_priority_for_format(key_id, api_format, facts.key_priority)
|
||||
}
|
||||
None => overlay.key_priority(key_id, facts.key_priority),
|
||||
})
|
||||
.unwrap_or(facts.key_priority),
|
||||
CandidateKind::PoolGroup => {
|
||||
overlay.pool_priority(&facts.provider_id, facts.key_priority)
|
||||
@@ -142,6 +193,7 @@ mod tests {
|
||||
endpoint_id: "endpoint-a".to_string(),
|
||||
model_id: "model-a".to_string(),
|
||||
key_id: Some("key-a".to_string()),
|
||||
api_format: None,
|
||||
provider_priority: 10,
|
||||
key_priority: 20,
|
||||
};
|
||||
@@ -151,6 +203,47 @@ mod tests {
|
||||
assert_eq!(vector.key_priority_after, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_scoped_key_override_wins_over_key_override_for_matching_format() {
|
||||
let mut overlay = RankingOverlay {
|
||||
key_priority_overrides: BTreeMap::from([("key-a".to_string(), 5)]),
|
||||
..RankingOverlay::default()
|
||||
};
|
||||
overlay.insert_key_priority_override_for_format("openai:chat", "key-a".to_string(), 1);
|
||||
|
||||
assert_eq!(
|
||||
overlay.key_priority_for_format("key-a", "openai:chat", 20),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
overlay.key_priority_for_format("key-a", "OpenAI:Chat", 20),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
overlay.key_priority_for_format("key-a", "claude:messages", 20),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
overlay.key_priority_for_format("key-b", "openai:chat", 20),
|
||||
20
|
||||
);
|
||||
|
||||
let facts = RoutingCandidateFacts {
|
||||
candidate_kind: CandidateKind::Provider,
|
||||
provider_id: "provider-a".to_string(),
|
||||
endpoint_id: "endpoint-a".to_string(),
|
||||
model_id: "model-a".to_string(),
|
||||
key_id: Some("key-a".to_string()),
|
||||
api_format: Some("openai:chat".to_string()),
|
||||
provider_priority: 10,
|
||||
key_priority: 20,
|
||||
};
|
||||
assert_eq!(
|
||||
rank_vector_for_candidate(&overlay, &facts).key_priority_after,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_vector_falls_back_to_existing_priorities() {
|
||||
let facts = RoutingCandidateFacts {
|
||||
@@ -159,6 +252,7 @@ mod tests {
|
||||
endpoint_id: "endpoint-a".to_string(),
|
||||
model_id: "model-a".to_string(),
|
||||
key_id: Some("key-a".to_string()),
|
||||
api_format: None,
|
||||
provider_priority: 10,
|
||||
key_priority: 20,
|
||||
};
|
||||
@@ -180,6 +274,7 @@ mod tests {
|
||||
endpoint_id: "endpoint-a".to_string(),
|
||||
model_id: "model-a".to_string(),
|
||||
key_id: None,
|
||||
api_format: None,
|
||||
provider_priority: 10,
|
||||
key_priority: 20,
|
||||
};
|
||||
|
||||
@@ -285,6 +285,7 @@ mod tests {
|
||||
RoutingAction::SetKeyPriority {
|
||||
key_id: "key-1".to_string(),
|
||||
priority: 1,
|
||||
api_format: None,
|
||||
},
|
||||
"set_key_priority",
|
||||
),
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
normalizeRoutingGroupConfig,
|
||||
parseAllowedModelsInput,
|
||||
removePerModelRoutingConfig,
|
||||
resolveModelKeyPriorityOverride,
|
||||
routingModelScopeLabel,
|
||||
savePerModelRoutingConfig,
|
||||
setDefaultPoolPriorityOverrides,
|
||||
setDefaultProviderPriorityOverrides,
|
||||
setModelKeyPriorityOverridesForFormat,
|
||||
setRoutingSortingScope,
|
||||
updateAllowedModelsFromInput,
|
||||
upsertModelSchedulingRule,
|
||||
@@ -75,6 +77,52 @@ describe('routingPolicy', () => {
|
||||
expect(policy.key_priority_overrides).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps key priority overrides independent per api format', () => {
|
||||
let config = setModelKeyPriorityOverridesForFormat(
|
||||
createEmptyRoutingGroupConfig(),
|
||||
DEFAULT_ROUTING_POLICY_MODEL,
|
||||
'OpenAI:Chat',
|
||||
{ 'key-a': 0, 'key-b': 1 },
|
||||
)
|
||||
config = setModelKeyPriorityOverridesForFormat(
|
||||
config,
|
||||
DEFAULT_ROUTING_POLICY_MODEL,
|
||||
'claude:messages',
|
||||
{ 'key-a': 3 },
|
||||
)
|
||||
|
||||
const policy = getDefaultModelPolicy(config)
|
||||
expect(policy.key_priority_overrides).toEqual({})
|
||||
expect(policy.key_priority_overrides_by_format).toEqual({
|
||||
'openai:chat': { 'key-a': 0, 'key-b': 1 },
|
||||
'claude:messages': { 'key-a': 3 },
|
||||
})
|
||||
expect(resolveModelKeyPriorityOverride(config, DEFAULT_ROUTING_POLICY_MODEL, 'openai:chat', 'key-a')).toBe(0)
|
||||
expect(resolveModelKeyPriorityOverride(config, DEFAULT_ROUTING_POLICY_MODEL, 'claude:messages', 'key-a')).toBe(3)
|
||||
expect(resolveModelKeyPriorityOverride(config, DEFAULT_ROUTING_POLICY_MODEL, 'claude:messages', 'key-b')).toBeUndefined()
|
||||
|
||||
const cleared = setModelKeyPriorityOverridesForFormat(config, DEFAULT_ROUTING_POLICY_MODEL, 'claude:messages', {})
|
||||
expect(getDefaultModelPolicy(cleared).key_priority_overrides_by_format).toEqual({
|
||||
'openai:chat': { 'key-a': 0, 'key-b': 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to format-agnostic key overrides and normalizes legacy configs', () => {
|
||||
const config = normalizeRoutingGroupConfig({
|
||||
model_policies: [{
|
||||
...createEmptyModelPolicy('gpt-5'),
|
||||
key_priority_overrides: { 'key-a': 7 },
|
||||
key_priority_overrides_by_format: { ' OpenAI:Chat ': { 'key-a': 1 } },
|
||||
}],
|
||||
})
|
||||
|
||||
expect(config.model_policies[0].key_priority_overrides_by_format).toEqual({
|
||||
'openai:chat': { 'key-a': 1 },
|
||||
})
|
||||
expect(resolveModelKeyPriorityOverride(config, 'gpt-5', 'openai:chat', 'key-a')).toBe(1)
|
||||
expect(resolveModelKeyPriorityOverride(config, 'gpt-5', 'gemini:generate_content', 'key-a')).toBe(7)
|
||||
})
|
||||
|
||||
it('stores per-model scheduling as generated routing rules', () => {
|
||||
const next = upsertModelSchedulingRule(createEmptyRoutingGroupConfig(), 'gpt-5', {
|
||||
priority_mode: 'global_key',
|
||||
|
||||
@@ -315,7 +315,8 @@ import {
|
||||
getDefaultModelPolicy,
|
||||
getModelPolicy,
|
||||
normalizeRoutingGroupConfig,
|
||||
setModelKeyPriorityOverrides,
|
||||
normalizeRoutingApiFormatKey,
|
||||
setModelKeyPriorityOverridesForFormat,
|
||||
setModelPoolPriorityOverrides,
|
||||
setModelProviderPriorityOverrides,
|
||||
type RoutingDefaultPolicy,
|
||||
@@ -452,9 +453,18 @@ const providerRows = computed<ProviderPriorityRow[]>(() => {
|
||||
.sort(comparePriorityRows)
|
||||
})
|
||||
|
||||
const selectedFormatKey = computed(() => normalizeRoutingApiFormatKey(selectedApiFormat.value))
|
||||
const selectedFormatKeyOverrides = computed<Record<string, number>>(() => (
|
||||
targetModelPolicy.value.key_priority_overrides_by_format[selectedFormatKey.value] ?? {}
|
||||
))
|
||||
|
||||
const keyRows = computed<KeyPriorityRow[]>(() => {
|
||||
const format = selectedApiFormat.value
|
||||
const keyOverrides = targetModelPolicy.value.key_priority_overrides
|
||||
// 按格式覆盖优先;旧的不分格式覆盖仅作为兜底展示
|
||||
const keyOverrides: Record<string, number> = {
|
||||
...targetModelPolicy.value.key_priority_overrides,
|
||||
...selectedFormatKeyOverrides.value,
|
||||
}
|
||||
const poolOverrides = targetModelPolicy.value.pool_priority_overrides
|
||||
const normalRows: KeyPriorityRow[] = []
|
||||
const poolGroups = new Map<string, GlobalKeySource[]>()
|
||||
@@ -686,7 +696,7 @@ function setKeyPriority(keyId: string, event: Event): void {
|
||||
})
|
||||
} else {
|
||||
updateKeyOverrides({
|
||||
...targetModelPolicy.value.key_priority_overrides,
|
||||
...selectedFormatKeyOverrides.value,
|
||||
[row.target_id]: priority,
|
||||
})
|
||||
}
|
||||
@@ -697,8 +707,14 @@ function moveKey(keyId: string, direction: -1 | 1): void {
|
||||
updateVisibleKeyAndPoolOverrides(rows)
|
||||
}
|
||||
|
||||
// Key 覆盖始终写入当前选中的 API 格式,不同格式互不影响
|
||||
function updateKeyOverrides(overrides: Record<string, number>): void {
|
||||
updateConfig(setModelKeyPriorityOverrides(config.value, targetModel.value, overrides))
|
||||
updateConfig(setModelKeyPriorityOverridesForFormat(
|
||||
config.value,
|
||||
targetModel.value,
|
||||
selectedApiFormat.value,
|
||||
overrides,
|
||||
))
|
||||
}
|
||||
|
||||
function updatePoolOverrides(overrides: Record<string, number>): void {
|
||||
@@ -710,7 +726,12 @@ function updateKeyAndPoolOverrides(
|
||||
poolOverrides: Record<string, number>,
|
||||
): void {
|
||||
const next = setModelPoolPriorityOverrides(
|
||||
setModelKeyPriorityOverrides(config.value, targetModel.value, keyOverrides),
|
||||
setModelKeyPriorityOverridesForFormat(
|
||||
config.value,
|
||||
targetModel.value,
|
||||
selectedApiFormat.value,
|
||||
keyOverrides,
|
||||
),
|
||||
targetModel.value,
|
||||
poolOverrides,
|
||||
)
|
||||
@@ -718,7 +739,7 @@ function updateKeyAndPoolOverrides(
|
||||
}
|
||||
|
||||
function updateVisibleKeyAndPoolOverrides(rows: KeyPriorityRow[]): void {
|
||||
const keyOverrides = { ...targetModelPolicy.value.key_priority_overrides }
|
||||
const keyOverrides = { ...selectedFormatKeyOverrides.value }
|
||||
const poolOverrides = { ...targetModelPolicy.value.pool_priority_overrides }
|
||||
|
||||
for (const row of keyRows.value) {
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface RoutingModelPolicy {
|
||||
allowed_keys: string[]
|
||||
provider_priority_overrides: Record<string, number>
|
||||
key_priority_overrides: Record<string, number>
|
||||
/** api_format -> key_id -> priority;同一 Key 在不同 API 格式下可独立排序 */
|
||||
key_priority_overrides_by_format: Record<string, Record<string, number>>
|
||||
pool_priority_overrides: Record<string, number>
|
||||
pool_policy_overrides: Record<string, RoutingPoolPolicyOverride>
|
||||
}
|
||||
@@ -81,6 +83,7 @@ export function createEmptyModelPolicy(model = ''): RoutingModelPolicy {
|
||||
allowed_keys: [],
|
||||
provider_priority_overrides: {},
|
||||
key_priority_overrides: {},
|
||||
key_priority_overrides_by_format: {},
|
||||
pool_priority_overrides: {},
|
||||
pool_policy_overrides: {},
|
||||
}
|
||||
@@ -103,6 +106,9 @@ export function normalizeRoutingGroupConfig(value: Partial<RoutingGroupConfig> |
|
||||
allowed_keys: Array.isArray(policy.allowed_keys) ? [...policy.allowed_keys] : [],
|
||||
provider_priority_overrides: { ...(policy.provider_priority_overrides ?? {}) },
|
||||
key_priority_overrides: { ...(policy.key_priority_overrides ?? {}) },
|
||||
key_priority_overrides_by_format: normalizeKeyPriorityOverridesByFormat(
|
||||
policy.key_priority_overrides_by_format,
|
||||
),
|
||||
pool_priority_overrides: { ...(policy.pool_priority_overrides ?? {}) },
|
||||
pool_policy_overrides: { ...(policy.pool_policy_overrides ?? {}) },
|
||||
}))
|
||||
@@ -296,6 +302,64 @@ export function setModelKeyPriorityOverrides(
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeRoutingApiFormatKey(apiFormat: string): string {
|
||||
return apiFormat.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function getModelKeyPriorityOverridesForFormat(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
apiFormat: string,
|
||||
): Record<string, number> {
|
||||
const policy = getModelPolicy(config, model)
|
||||
const format = normalizeRoutingApiFormatKey(apiFormat)
|
||||
return { ...(policy.key_priority_overrides_by_format[format] ?? {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个 Key 在指定 API 格式下的生效覆盖值:按格式覆盖优先,其次是不分格式的 Key 覆盖。
|
||||
*/
|
||||
export function resolveModelKeyPriorityOverride(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
apiFormat: string,
|
||||
keyId: string,
|
||||
): number | undefined {
|
||||
const policy = getModelPolicy(config, model)
|
||||
const format = normalizeRoutingApiFormatKey(apiFormat)
|
||||
return policy.key_priority_overrides_by_format[format]?.[keyId]
|
||||
?? policy.key_priority_overrides[keyId]
|
||||
}
|
||||
|
||||
export function setModelKeyPriorityOverridesForFormat(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
apiFormat: string,
|
||||
overrides: Record<string, number>,
|
||||
): RoutingGroupConfig {
|
||||
const normalizedModel = model.trim() || DEFAULT_ROUTING_POLICY_MODEL
|
||||
const format = normalizeRoutingApiFormatKey(apiFormat)
|
||||
if (!format) return normalizeRoutingGroupConfig(config)
|
||||
|
||||
const current = getModelPolicy(config, normalizedModel)
|
||||
const byFormat = { ...current.key_priority_overrides_by_format }
|
||||
const normalized = normalizePriorityOverrides(overrides)
|
||||
if (Object.keys(normalized).length > 0) {
|
||||
byFormat[format] = normalized
|
||||
} else {
|
||||
delete byFormat[format]
|
||||
}
|
||||
|
||||
if (normalizedModel === DEFAULT_ROUTING_POLICY_MODEL) {
|
||||
return upsertDefaultModelPolicy(config, { key_priority_overrides_by_format: byFormat })
|
||||
}
|
||||
return upsertModelPolicy(config, {
|
||||
...current,
|
||||
model: normalizedModel,
|
||||
key_priority_overrides_by_format: byFormat,
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelPoolPriorityOverrides(
|
||||
config: RoutingGroupConfig,
|
||||
model: string,
|
||||
@@ -464,6 +528,25 @@ export function normalizePriorityOverrides(overrides: Record<string, number>): R
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeKeyPriorityOverridesByFormat(
|
||||
value: Record<string, Record<string, number>> | null | undefined,
|
||||
): Record<string, Record<string, number>> {
|
||||
const normalized: Record<string, Record<string, number>> = {}
|
||||
if (!value || typeof value !== 'object') return normalized
|
||||
for (const [rawFormat, overrides] of Object.entries(value)) {
|
||||
const format = normalizeRoutingApiFormatKey(rawFormat)
|
||||
if (!format || !overrides || typeof overrides !== 'object') continue
|
||||
const merged = normalizePriorityOverrides({
|
||||
...(normalized[format] ?? {}),
|
||||
...overrides,
|
||||
})
|
||||
if (Object.keys(merged).length > 0) {
|
||||
normalized[format] = merged
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isSetSchedulingAction(action: unknown): action is RoutingSetSchedulingAction {
|
||||
if (!action || typeof action !== 'object') return false
|
||||
const candidate = action as Partial<RoutingSetSchedulingAction>
|
||||
|
||||
@@ -985,6 +985,8 @@ const MOCK_ROUTING_GROUPS: MockRoutingGroup[] = [
|
||||
allowed_keys: [],
|
||||
provider_priority_overrides: { 'provider-002': 0 },
|
||||
key_priority_overrides: {},
|
||||
key_priority_overrides_by_format: {},
|
||||
pool_priority_overrides: {},
|
||||
pool_policy_overrides: {},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -272,6 +272,8 @@ import {
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { listRoutingGroups } from '@/api/routing-profiles'
|
||||
import { normalizeRoutingGroupConfig } from '@/features/routing/utils/routingPolicy'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
@@ -533,8 +535,18 @@ const maxProviderPriority = computed(() => {
|
||||
return priorities.length > 0 ? Math.max(...priorities) : undefined
|
||||
})
|
||||
|
||||
// 加载优先级模式
|
||||
// 加载优先级模式:优先使用启用中的系统默认调度策略,旧的系统配置键仅作兜底
|
||||
async function loadPriorityMode(options: { cacheTtlMs?: number } = {}) {
|
||||
try {
|
||||
const groups = await listRoutingGroups()
|
||||
const systemDefault = groups.items.find(group => group.is_system_default && group.enabled)
|
||||
if (systemDefault) {
|
||||
priorityMode.value = normalizeRoutingGroupConfig(systemDefault.config_json).default_policy.priority_mode
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 路由策略不可用时继续尝试旧配置
|
||||
}
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig('provider_priority_mode', {
|
||||
cacheTtlMs: options.cacheTtlMs ?? 0,
|
||||
|
||||
@@ -454,6 +454,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="flex items-start gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm"
|
||||
data-testid="keep-priority-on-conversion"
|
||||
>
|
||||
<Switch
|
||||
:model-value="keepPriorityOnConversion"
|
||||
:disabled="saving"
|
||||
aria-label="格式转换时保持优先级"
|
||||
@update:model-value="updateKeepPriorityOnConversion"
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-medium">格式转换时保持优先级</span>
|
||||
<span class="mt-0.5 block text-xs text-muted-foreground">
|
||||
开启后,需要跨 API 格式转换的候选不会被降级到同格式候选之后。作用于本策略范围内的全部模型;Provider 自身的同名开关仍单独生效。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<RoutingPriorityPolicyEditor
|
||||
@@ -749,6 +766,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCard,
|
||||
@@ -874,6 +892,9 @@ const firstStepSchedulingMode = computed<RoutingSchedulingMode>(() => {
|
||||
}
|
||||
return draft.value?.config_json.default_policy.scheduling_mode ?? 'cache_affinity'
|
||||
})
|
||||
const keepPriorityOnConversion = computed<boolean>(() => (
|
||||
draft.value?.config_json.default_policy.keep_priority_on_conversion ?? false
|
||||
))
|
||||
const allowedModelsLookLikeLegacyMirror = computed(() => {
|
||||
return draft.value
|
||||
? allowedModelsMirrorPerModelPolicies(draft.value.config_json)
|
||||
@@ -1190,6 +1211,17 @@ function updateFirstStepSchedulingMode(mode: RoutingSchedulingMode): void {
|
||||
})
|
||||
}
|
||||
|
||||
function updateKeepPriorityOnConversion(value: boolean): void {
|
||||
if (!draft.value) return
|
||||
updateDraftConfig({
|
||||
...draft.value.config_json,
|
||||
default_policy: {
|
||||
...draft.value.config_json.default_policy,
|
||||
keep_priority_on_conversion: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function removePerModelPolicy(model: string): void {
|
||||
if (!draft.value) return
|
||||
if (perModelEditingActive.value && editingDirty.value) {
|
||||
|
||||
@@ -131,11 +131,31 @@ vi.mock('@/components/ui', async () => {
|
||||
},
|
||||
})
|
||||
|
||||
const Switch = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () => h('button', {
|
||||
...attrs,
|
||||
type: 'button',
|
||||
role: 'switch',
|
||||
'aria-checked': props.modelValue,
|
||||
disabled: props.disabled,
|
||||
onClick: () => emit('update:modelValue', !props.modelValue),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
Badge: wrapper(),
|
||||
Button,
|
||||
Card: wrapper('section'),
|
||||
Input,
|
||||
Switch,
|
||||
Table: wrapper('table'),
|
||||
TableBody: wrapper('tbody'),
|
||||
TableCard: wrapper(),
|
||||
|
||||
Reference in New Issue
Block a user