Files
Aether/apps/aether-gateway/src/ai_serving/planner/candidate_ranking.rs
T
elky 579f2c7cc1 feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
2026-09-04 03:45:52 +08:00

2190 lines
78 KiB
Rust

use aether_ai_serving::{
ai_ranking_context, build_ai_rankable_candidate, run_ai_candidate_ranking,
AiCandidateRankingPort, AiRankableCandidateParts, AiRankingContextConfig,
AiRankingSchedulingMode,
};
use aether_routing_core::ResolvedRoutingPolicy;
use async_trait::async_trait;
use tokio::sync::Mutex;
use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::clock::current_unix_ms;
use crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value;
use crate::scheduler::config::{SchedulerOrderingConfig, SchedulerSchedulingMode};
use aether_scheduler_core::{
matches_affinity_target, ClientSessionAffinity, SchedulerAffinityTarget,
SchedulerMinimalCandidateSelectionCandidate, SchedulerPriorityMode, SchedulerRankableCandidate,
SchedulerRankingContext, SchedulerRankingOutcome,
};
use super::candidate_affinity_cache::read_cached_scheduler_affinity_target;
use super::candidate_resolution::{EligibleLocalExecutionCandidate, LocalExecutionCandidateKind};
use super::candidate_transport_ranking_facts::{
resolve_cached_transport_ranking_facts, CandidateTransportRankingFactsCache,
};
struct GatewayLocalCandidateRankingPort<'a> {
state: PlannerAppState<'a>,
requested_model: Option<&'a str>,
auth_snapshot: Option<&'a GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&'a ClientSessionAffinity>,
required_capabilities: Option<&'a serde_json::Value>,
ordering_config: SchedulerOrderingConfig,
routing_policy: Option<&'a ResolvedRoutingPolicy>,
transport_ranking_facts_cache: Mutex<CandidateTransportRankingFactsCache>,
}
#[async_trait]
impl AiCandidateRankingPort for GatewayLocalCandidateRankingPort<'_> {
type Candidate = EligibleLocalExecutionCandidate;
type AffinityTarget = SchedulerAffinityTarget;
type Error = std::convert::Infallible;
fn affinity_requested_model(&self, candidates: &[Self::Candidate]) -> Option<String> {
self.requested_model
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
candidates
.first()
.map(|candidate| candidate.candidate.global_model_name.clone())
})
}
async fn read_cached_affinity_target(
&self,
normalized_client_api_format: &str,
affinity_requested_model: Option<&str>,
) -> Result<Option<Self::AffinityTarget>, Self::Error> {
Ok(read_cached_scheduler_affinity_target(
self.state,
self.auth_snapshot,
self.client_session_affinity,
normalized_client_api_format,
affinity_requested_model,
self.routing_policy,
))
}
fn cached_affinity_matches(
&self,
candidate: &Self::Candidate,
target: &Self::AffinityTarget,
) -> bool {
cached_affinity_matches_local_execution_scope(candidate, target)
}
async fn build_rankable_candidate(
&self,
candidate: &Self::Candidate,
original_index: usize,
normalized_client_api_format: &str,
cached_affinity_match: bool,
) -> Result<SchedulerRankableCandidate, Self::Error> {
let ranking_facts = {
let mut cache = self.transport_ranking_facts_cache.lock().await;
resolve_cached_transport_ranking_facts(
self.state,
&mut cache,
&candidate.candidate,
candidate.transport.as_ref(),
self.ordering_config,
)
.await
};
let routing_overlaid_candidate =
routing_overlaid_candidate(self.routing_policy, candidate.kind, &candidate.candidate);
Ok(build_ai_rankable_candidate(AiRankableCandidateParts {
candidate: &routing_overlaid_candidate,
original_index,
normalized_client_api_format,
provider_api_format: candidate.provider_api_format.as_str(),
required_capabilities: self.required_capabilities,
cached_affinity_match,
tunnel_bucket: ranking_facts.tunnel_bucket,
keep_priority_on_conversion: ranking_facts.keep_priority_on_conversion,
}))
}
fn ranking_context(&self) -> SchedulerRankingContext {
ai_ranking_context(ai_ranking_context_config(self.ordering_config))
}
fn apply_ranking_outcome(
&self,
candidate: &mut Self::Candidate,
outcome: SchedulerRankingOutcome,
) {
candidate.ranking = Some(outcome);
}
}
pub(crate) async fn rank_eligible_local_execution_candidates(
state: PlannerAppState<'_>,
candidates: Vec<EligibleLocalExecutionCandidate>,
normalized_client_api_format: &str,
requested_model: Option<&str>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>,
required_capabilities: Option<&serde_json::Value>,
routing_policy: Option<&ResolvedRoutingPolicy>,
) -> Vec<EligibleLocalExecutionCandidate> {
let ordering_config = scheduler_ordering_config_for_routing_policy(routing_policy);
let port = GatewayLocalCandidateRankingPort {
state,
requested_model,
auth_snapshot,
client_session_affinity,
required_capabilities,
ordering_config,
routing_policy,
transport_ranking_facts_cache: Mutex::new(CandidateTransportRankingFactsCache::default()),
};
match run_ai_candidate_ranking(&port, candidates, normalized_client_api_format).await {
Ok(candidates) => candidates,
Err(error) => match error {},
}
}
fn cached_affinity_matches_local_execution_scope(
eligible: &EligibleLocalExecutionCandidate,
target: &SchedulerAffinityTarget,
) -> bool {
if local_execution_candidate_uses_pool(eligible) {
return eligible.candidate.provider_id == target.provider_id
&& eligible.candidate.endpoint_id == target.endpoint_id;
}
matches_affinity_target(&eligible.candidate, target)
}
fn local_execution_candidate_uses_pool(eligible: &EligibleLocalExecutionCandidate) -> bool {
admin_provider_pool_config_from_config_value(eligible.transport.provider.config.as_ref())
.is_some()
}
fn ai_ranking_context_config(ordering_config: SchedulerOrderingConfig) -> AiRankingContextConfig {
AiRankingContextConfig {
priority_mode: ordering_config.priority_mode,
scheduling_mode: ai_ranking_scheduling_mode(ordering_config.scheduling_mode),
load_balance_seed: current_unix_ms(),
}
}
fn ai_ranking_scheduling_mode(mode: SchedulerSchedulingMode) -> AiRankingSchedulingMode {
match mode {
SchedulerSchedulingMode::FixedOrder => AiRankingSchedulingMode::FixedOrder,
SchedulerSchedulingMode::CacheAffinity => AiRankingSchedulingMode::CacheAffinity,
SchedulerSchedulingMode::LoadBalance => AiRankingSchedulingMode::LoadBalance,
}
}
/// Return the immutable scheduler snapshot carried by a resolved routing
/// policy. A missing policy is a programming error in production request
/// paths; unit tests may use the scheduler default for isolated ranking tests.
pub(crate) fn scheduler_ordering_config_for_routing_policy(
routing_policy: Option<&ResolvedRoutingPolicy>,
) -> SchedulerOrderingConfig {
match routing_policy {
Some(policy) => SchedulerOrderingConfig::from_routing_policy(policy),
None => {
#[cfg(test)]
{
SchedulerOrderingConfig::default()
}
#[cfg(not(test))]
{
panic!("resolved routing policy is required before candidate scheduling")
}
}
}
}
fn routing_overlaid_candidate(
routing_policy: Option<&ResolvedRoutingPolicy>,
kind: LocalExecutionCandidateKind,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
) -> SchedulerMinimalCandidateSelectionCandidate {
let Some(policy) = routing_policy else {
return candidate.clone();
};
let mut overlaid = candidate.clone();
overlaid.provider_priority = policy
.ranking_overlay
.provider_priority(candidate.provider_id.as_str(), candidate.provider_priority);
let overlaid_key_priority = match kind {
LocalExecutionCandidateKind::SingleKey => policy
.ranking_overlay
.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())
.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);
}
overlaid
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use aether_ai_serving::{
ai_ranking_context, build_ai_rankable_candidate, AiRankableCandidateParts,
};
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use aether_data::repository::{
provider_catalog::InMemoryProviderCatalogReadRepository,
routing_profiles::InMemoryRoutingGroupRepository,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupRecord, RoutingGroupWriteRepository,
};
use aether_scheduler_core::{
apply_scheduler_candidate_ranking,
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session,
ClientSessionAffinity, RANKING_REASON_CACHED_AFFINITY,
};
use serde_json::json;
use super::super::candidate_affinity_cache::remember_scheduler_affinity_for_candidate;
use super::super::candidate_transport_ranking_facts::{
resolve_cached_candidate_transport_ranking_facts, CandidateTransportRankingFactsCache,
};
use super::{PlannerAppState, SchedulerMinimalCandidateSelectionCandidate};
use crate::ai_serving::planner::candidate_resolution::{
resolve_and_rank_local_execution_candidates,
resolve_and_rank_logical_local_execution_candidates, LocalExecutionCandidateKind,
};
use crate::data::auth::GatewayAuthApiKeySnapshot;
use crate::data::GatewayDataState;
use crate::tunnel::TunnelAttachmentRecord;
use crate::{scheduler::affinity::SCHEDULER_AFFINITY_TTL, AppState};
use aether_data::repository::auth::StoredAuthApiKeySnapshot;
async fn rank_local_execution_candidates(
state: PlannerAppState<'_>,
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
client_api_format: &str,
required_capabilities: Option<&serde_json::Value>,
) -> Vec<SchedulerMinimalCandidateSelectionCandidate> {
let normalized_client_api_format = client_api_format.trim().to_ascii_lowercase();
let ordering_config =
crate::scheduler::config::read_system_default_routing_ordering_config(state.app())
.await
.expect("routing strategy should load")
.unwrap_or_default();
let mut candidates = candidates;
let mut rankables = Vec::with_capacity(candidates.len());
let mut ordering_cache = CandidateTransportRankingFactsCache::default();
for (original_index, candidate) in candidates.iter().enumerate() {
let ranking_facts = resolve_cached_candidate_transport_ranking_facts(
state,
&mut ordering_cache,
candidate,
ordering_config,
)
.await;
rankables.push(build_ai_rankable_candidate(AiRankableCandidateParts {
candidate,
original_index,
normalized_client_api_format: normalized_client_api_format.as_str(),
provider_api_format: candidate.endpoint_api_format.as_str(),
required_capabilities,
cached_affinity_match: false,
tunnel_bucket: ranking_facts.tunnel_bucket,
keep_priority_on_conversion: ranking_facts.keep_priority_on_conversion,
}));
}
drop(ordering_cache);
apply_scheduler_candidate_ranking(
&mut candidates,
&rankables,
ai_ranking_context(super::ai_ranking_context_config(ordering_config)),
);
candidates
}
fn sample_candidate(
endpoint_id: &str,
key_id: &str,
) -> SchedulerMinimalCandidateSelectionCandidate {
SchedulerMinimalCandidateSelectionCandidate {
provider_id: "provider-1".to_string(),
provider_name: "provider-1".to_string(),
provider_type: "custom".to_string(),
provider_priority: 0,
endpoint_id: endpoint_id.to_string(),
endpoint_api_format: "openai:chat".to_string(),
key_id: key_id.to_string(),
key_name: key_id.to_string(),
key_auth_type: "api_key".to_string(),
key_internal_priority: 0,
key_global_priority_for_format: Some(0),
key_capabilities: None,
model_id: "model-1".to_string(),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-4.1".to_string(),
selected_provider_model_name: "gpt-4.1".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
#[test]
fn routing_policy_priorities_fall_back_to_candidate_priorities() {
let mut candidate = sample_candidate("endpoint-1", "key-1");
candidate.provider_priority = 7;
candidate.key_internal_priority = 3;
candidate.key_global_priority_for_format = Some(2);
let policy = aether_routing_core::ResolvedRoutingPolicy {
group_id: Some("group-1".to_string()),
group_version: Some(1),
selection_source: "system_default".to_string(),
requested_model: "gpt-5".to_string(),
resolved_model: "gpt-5".to_string(),
priority_mode: aether_routing_core::RoutingSetPriorityMode::Provider,
scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity,
keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: aether_routing_core::RankingOverlay::default(),
mutation_plan: Default::default(),
pool_policy_overrides: BTreeMap::new(),
matched_rules: Vec::new(),
};
let overlaid = super::routing_overlaid_candidate(
Some(&policy),
LocalExecutionCandidateKind::SingleKey,
&candidate,
);
assert_eq!(overlaid.provider_priority, 7);
assert_eq!(overlaid.key_internal_priority, 3);
assert_eq!(overlaid.key_global_priority_for_format, Some(2));
}
#[tokio::test]
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),
)]);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let policy = aether_routing_core::ResolvedRoutingPolicy {
group_id: Some("group-1".to_string()),
group_version: Some(1),
selection_source: "system_default".to_string(),
requested_model: "gpt-5.4-mini".to_string(),
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,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: Default::default(),
mutation_plan: Default::default(),
pool_policy_overrides: Default::default(),
matched_rules: Vec::new(),
};
let ordering = super::scheduler_ordering_config_for_routing_policy(Some(&policy));
assert_eq!(
ordering.scheduling_mode,
crate::scheduler::config::SchedulerSchedulingMode::FixedOrder
);
assert!(
!ordering.keep_priority_on_conversion,
"a resolved routing policy must not inherit the legacy system-config flag"
);
}
#[test]
fn routing_policy_uses_pool_priority_for_pool_group_global_key_slot() {
let mut candidate = sample_candidate("endpoint-1", "representative-key");
candidate.provider_priority = 7;
candidate.key_internal_priority = 3;
candidate.key_global_priority_for_format = Some(2);
let policy = aether_routing_core::ResolvedRoutingPolicy {
group_id: Some("group-1".to_string()),
group_version: Some(1),
selection_source: "system_default".to_string(),
requested_model: "gpt-5".to_string(),
resolved_model: "gpt-5".to_string(),
priority_mode: aether_routing_core::RoutingSetPriorityMode::GlobalKey,
scheduling_mode: aether_routing_core::RoutingSchedulingMode::CacheAffinity,
keep_priority_on_conversion: false,
sticky_key_attempts: aether_routing_core::DEFAULT_STICKY_KEY_ATTEMPTS,
execution_policy: Default::default(),
ranking_overlay: aether_routing_core::RankingOverlay {
pool_priority_overrides: BTreeMap::from([("provider-1".to_string(), 4)]),
key_priority_overrides: BTreeMap::from([("representative-key".to_string(), 1)]),
..Default::default()
},
mutation_plan: Default::default(),
pool_policy_overrides: BTreeMap::new(),
matched_rules: Vec::new(),
};
let overlaid = super::routing_overlaid_candidate(
Some(&policy),
LocalExecutionCandidateKind::PoolGroup,
&candidate,
);
assert_eq!(overlaid.key_internal_priority, 4);
assert_eq!(overlaid.key_global_priority_for_format, Some(4));
}
fn sample_provider() -> StoredProviderCatalogProvider {
sample_provider_with_options("provider-1", false, 0)
}
fn sample_provider_with_options(
id: &str,
keep_priority_on_conversion: bool,
provider_priority: i32,
) -> StoredProviderCatalogProvider {
sample_provider_with_config(id, keep_priority_on_conversion, provider_priority, None)
}
fn sample_provider_with_config(
id: &str,
keep_priority_on_conversion: bool,
provider_priority: i32,
config: Option<serde_json::Value>,
) -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
id.to_string(),
id.to_string(),
Some("https://provider.example".to_string()),
"custom".to_string(),
)
.expect("provider should build")
.with_transport_fields(
true,
keep_priority_on_conversion,
false,
None,
None,
None,
None,
None,
config,
)
.with_routing_fields(provider_priority)
}
fn sample_endpoint(id: &str) -> StoredProviderCatalogEndpoint {
sample_endpoint_for_provider("provider-1", id, "openai:chat")
}
fn sample_endpoint_for_provider(
provider_id: &str,
id: &str,
api_format: &str,
) -> StoredProviderCatalogEndpoint {
StoredProviderCatalogEndpoint::new(
id.to_string(),
provider_id.to_string(),
api_format.to_string(),
Some(
api_format
.split(':')
.next()
.unwrap_or(api_format)
.to_string(),
),
Some("chat".to_string()),
true,
)
.expect("endpoint should build")
.with_transport_fields(
"https://api.provider.example".to_string(),
None,
None,
None,
None,
None,
None,
None,
)
.expect("endpoint transport should build")
}
fn sample_key(id: &str, node_id: &str) -> StoredProviderCatalogKey {
sample_key_for_provider("provider-1", id, node_id)
}
fn sample_key_for_provider(
provider_id: &str,
id: &str,
node_id: &str,
) -> StoredProviderCatalogKey {
sample_key_for_provider_with_options(
provider_id,
id,
node_id,
true,
Some(json!(["openai:chat"])),
None,
)
}
fn sample_key_for_provider_with_options(
provider_id: &str,
id: &str,
node_id: &str,
is_active: bool,
api_formats: Option<serde_json::Value>,
allowed_models: Option<serde_json::Value>,
) -> StoredProviderCatalogKey {
let credential_state = AppState::new()
.expect("credential state should build")
.with_data_state_for_tests(
GatewayDataState::disabled()
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
);
let encrypted_api_key = credential_state
.seal_provider_catalog_key_api_key(provider_id, id, "plain-upstream-key")
.expect("api key should encrypt");
StoredProviderCatalogKey::new(
id.to_string(),
provider_id.to_string(),
id.to_string(),
"api_key".to_string(),
None,
is_active,
)
.expect("key should build")
.with_transport_fields(
api_formats,
encrypted_api_key,
None,
None,
Some(json!({"openai:chat": 1})),
allowed_models,
None,
Some(json!({
"enabled": true,
"mode": "tunnel",
"node_id": node_id,
})),
None,
)
.expect("key transport should build")
}
fn tunnel_attachment_key(node_id: &str) -> String {
format!("tunnel.attachments.{node_id}")
}
fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn sample_auth_snapshot() -> GatewayAuthApiKeySnapshot {
GatewayAuthApiKeySnapshot::from_stored(
StoredAuthApiKeySnapshot::new(
"user-1".to_string(),
"alice".to_string(),
Some("[email protected]".to_string()),
"user".to_string(),
"local".to_string(),
true,
false,
None,
None,
None,
"api-key-1".to_string(),
Some("default".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(4_102_444_800),
None,
None,
None,
)
.expect("stored auth snapshot should build"),
current_unix_secs(),
)
}
fn sample_priority_candidate(
provider_id: &str,
endpoint_id: &str,
key_id: &str,
endpoint_api_format: &str,
key_global_priority_for_format: Option<i32>,
provider_priority: i32,
) -> SchedulerMinimalCandidateSelectionCandidate {
SchedulerMinimalCandidateSelectionCandidate {
provider_id: provider_id.to_string(),
provider_name: provider_id.to_string(),
provider_type: "custom".to_string(),
provider_priority,
endpoint_id: endpoint_id.to_string(),
endpoint_api_format: endpoint_api_format.to_string(),
key_id: key_id.to_string(),
key_name: key_id.to_string(),
key_auth_type: "api_key".to_string(),
key_internal_priority: 0,
key_global_priority_for_format,
key_capabilities: None,
model_id: format!("model-{provider_id}"),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-4.1".to_string(),
selected_provider_model_name: "gpt-4.1".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
#[tokio::test]
async fn local_execution_ranking_keeps_provider_priority_before_tunnel_affinity() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-priority", false, 0),
sample_provider_with_options("provider-local-tunnel", false, 10),
],
vec![
sample_endpoint_for_provider(
"provider-priority",
"endpoint-priority",
"openai:chat",
),
sample_endpoint_for_provider(
"provider-local-tunnel",
"endpoint-local-tunnel",
"openai:chat",
),
],
vec![
sample_key_for_provider("provider-priority", "key-priority", "node-remote"),
sample_key_for_provider("provider-local-tunnel", "key-local-tunnel", "node-local"),
],
);
let observed_at_unix_secs = current_unix_secs();
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
)
.with_system_config_values_for_tests(vec![
("provider_priority_mode".to_string(), json!("provider")),
(
tunnel_attachment_key("node-remote"),
serde_json::to_value(TunnelAttachmentRecord {
gateway_instance_id: "gateway-b".to_string(),
relay_base_url: "http://gateway-b:8080".to_string(),
tunnel_generation: "test-generation-remote".to_string(),
conn_count: 1,
observed_at_unix_secs,
})
.expect("remote attachment should serialize"),
),
(
tunnel_attachment_key("node-local"),
serde_json::to_value(TunnelAttachmentRecord {
gateway_instance_id: "gateway-a".to_string(),
relay_base_url: "http://gateway-a:8080".to_string(),
tunnel_generation: "test-generation-local".to_string(),
conn_count: 1,
observed_at_unix_secs,
})
.expect("local attachment should serialize"),
),
]);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state)
.with_tunnel_identity_for_tests("gateway-a", Some("http://gateway-a:8080"));
let ranked = rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-local-tunnel",
"endpoint-local-tunnel",
"key-local-tunnel",
"openai:chat",
Some(10),
10,
),
sample_priority_candidate(
"provider-priority",
"endpoint-priority",
"key-priority",
"openai:chat",
Some(0),
0,
),
],
"openai:chat",
None,
)
.await;
assert_eq!(ranked[0].provider_id, "provider-priority");
assert_eq!(ranked[1].provider_id, "provider-local-tunnel");
}
#[tokio::test]
async fn local_execution_ranking_demotes_cross_format_candidates_without_keep_priority() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-same", false, 0),
sample_provider_with_options("provider-cross", false, 0),
],
vec![
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:messages"),
],
vec![
sample_key_for_provider("provider-same", "key-same", ""),
sample_key_for_provider("provider-cross", "key-cross", ""),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let ranked = rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-cross",
"endpoint-cross",
"key-cross",
"claude:messages",
Some(0),
0,
),
sample_priority_candidate(
"provider-same",
"endpoint-same",
"key-same",
"openai:chat",
Some(0),
0,
),
],
"openai:chat",
None,
)
.await;
assert_eq!(ranked[0].endpoint_id, "endpoint-same");
assert_eq!(ranked[1].endpoint_id, "endpoint-cross");
}
#[tokio::test]
async fn fixed_order_local_execution_ranking_demotes_cross_format_before_provider_priority() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-same", false, 10),
sample_provider_with_options("provider-cross", false, 0),
],
vec![
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:messages"),
],
vec![
sample_key_for_provider("provider-same", "key-same", ""),
sample_key_for_provider("provider-cross", "key-cross", ""),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
)
.with_system_config_values_for_tests(vec![(
"scheduling_mode".to_string(),
json!("fixed_order"),
)]);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let ranked = rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-same",
"endpoint-same",
"key-same",
"openai:chat",
Some(10),
10,
),
sample_priority_candidate(
"provider-cross",
"endpoint-cross",
"key-cross",
"claude:messages",
Some(0),
0,
),
],
"openai:chat",
None,
)
.await;
assert_eq!(ranked[0].endpoint_id, "endpoint-same");
assert_eq!(ranked[1].endpoint_id, "endpoint-cross");
}
#[tokio::test]
async fn local_execution_ranking_keeps_cross_format_priority_when_enabled() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-same", false, 10),
sample_provider_with_options("provider-cross", true, 0),
],
vec![
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:messages"),
],
vec![
sample_key_for_provider("provider-same", "key-same", ""),
sample_key_for_provider("provider-cross", "key-cross", ""),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let ranked = rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-cross",
"endpoint-cross",
"key-cross",
"claude:messages",
Some(0),
0,
),
sample_priority_candidate(
"provider-same",
"endpoint-same",
"key-same",
"openai:chat",
Some(10),
10,
),
],
"openai:chat",
None,
)
.await;
assert_eq!(ranked[0].endpoint_id, "endpoint-cross");
assert_eq!(ranked[1].endpoint_id, "endpoint-same");
}
#[tokio::test]
async fn local_execution_ranking_keeps_cross_format_priority_when_strategy_override_is_enabled()
{
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-same", false, 10),
sample_provider_with_options("provider-cross", false, 0),
],
vec![
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:messages"),
],
vec![
sample_key_for_provider("provider-same", "key-same", ""),
sample_key_for_provider("provider-cross", "key-cross", ""),
],
);
let routing_repository = std::sync::Arc::new(InMemoryRoutingGroupRepository::default());
routing_repository
.create_routing_group(CreateRoutingGroupRecord {
id: "strategy-default".to_string(),
name: "strategy-default".to_string(),
description: None,
enabled: true,
is_system_default: true,
sort_order: 0,
config_json: json!({
"default_policy": {
"keep_priority_on_conversion": true
}
}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.expect("routing strategy should be created");
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
)
.with_routing_group_repository_for_tests(routing_repository);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let ranked = rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-cross",
"endpoint-cross",
"key-cross",
"claude:messages",
Some(0),
0,
),
sample_priority_candidate(
"provider-same",
"endpoint-same",
"key-same",
"openai:chat",
Some(10),
10,
),
],
"openai:chat",
None,
)
.await;
assert_eq!(ranked[0].endpoint_id, "endpoint-cross");
assert_eq!(ranked[1].endpoint_id, "endpoint-same");
}
#[tokio::test]
async fn local_execution_ranking_uses_provider_priority_mode_when_configured() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-provider-first", false, 0),
sample_provider_with_options("provider-global-first", false, 10),
],
vec![
sample_endpoint_for_provider(
"provider-provider-first",
"endpoint-provider-first",
"openai:chat",
),
sample_endpoint_for_provider(
"provider-global-first",
"endpoint-global-first",
"openai:chat",
),
],
vec![
sample_key_for_provider("provider-provider-first", "key-provider-first", ""),
sample_key_for_provider("provider-global-first", "key-global-first", ""),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
)
.with_system_config_values_for_tests(vec![(
"provider_priority_mode".to_string(),
json!("provider"),
)]);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let ranked = rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-global-first",
"endpoint-global-first",
"key-global-first",
"openai:chat",
Some(0),
10,
),
sample_priority_candidate(
"provider-provider-first",
"endpoint-provider-first",
"key-provider-first",
"openai:chat",
Some(10),
0,
),
],
"openai:chat",
None,
)
.await;
assert_eq!(ranked[0].endpoint_id, "endpoint-provider-first");
assert_eq!(ranked[1].endpoint_id, "endpoint-global-first");
}
#[tokio::test]
async fn local_execution_ranking_prefers_same_kind_endpoint_for_same_key_candidates() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider_with_options("provider-shared", false, 0)],
vec![
sample_endpoint_for_provider(
"provider-shared",
"aaa-claude-chat",
"claude:messages",
),
sample_endpoint_for_provider(
"provider-shared",
"zzz-openai-responses",
"openai:responses",
),
],
vec![sample_key_for_provider_with_options(
"provider-shared",
"key-shared",
"",
true,
Some(json!(["claude:messages", "openai:responses"])),
None,
)],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let ranked = rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-shared",
"aaa-claude-chat",
"key-shared",
"claude:messages",
Some(0),
0,
),
sample_priority_candidate(
"provider-shared",
"zzz-openai-responses",
"key-shared",
"openai:responses",
Some(0),
0,
),
],
"claude:messages",
None,
)
.await;
assert_eq!(ranked[0].endpoint_id, "aaa-claude-chat");
assert_eq!(ranked[1].endpoint_id, "zzz-openai-responses");
}
#[tokio::test]
async fn local_execution_ranking_prefers_candidates_matching_requested_capabilities() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-miss", false, 0),
sample_provider_with_options("provider-hit", false, 0),
],
vec![
sample_endpoint_for_provider("provider-miss", "endpoint-miss", "openai:chat"),
sample_endpoint_for_provider("provider-hit", "endpoint-hit", "openai:chat"),
],
vec![
sample_key_for_provider("provider-miss", "key-miss", ""),
sample_key_for_provider("provider-hit", "key-hit", ""),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let mut candidate_miss = sample_priority_candidate(
"provider-miss",
"endpoint-miss",
"key-miss",
"openai:chat",
Some(0),
0,
);
let mut candidate_hit = sample_priority_candidate(
"provider-hit",
"endpoint-hit",
"key-hit",
"openai:chat",
Some(0),
0,
);
candidate_miss.key_capabilities = Some(json!({"cache_1h": false}));
candidate_hit.key_capabilities = Some(json!({"cache_1h": true}));
let required_capabilities = json!({"cache_1h": true});
let ranked = rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![candidate_miss, candidate_hit],
"openai:chat",
Some(&required_capabilities),
)
.await;
assert_eq!(ranked[0].endpoint_id, "endpoint-hit");
assert_eq!(ranked[1].endpoint_id, "endpoint-miss");
}
#[tokio::test]
async fn realtime_gate_skips_inactive_candidates_before_ranking() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-disabled", false, 0),
sample_provider_with_options("provider-active", false, 10),
],
vec![
sample_endpoint_for_provider(
"provider-disabled",
"endpoint-disabled",
"openai:chat",
),
sample_endpoint_for_provider("provider-active", "endpoint-active", "openai:chat"),
],
vec![
sample_key_for_provider_with_options(
"provider-disabled",
"key-disabled",
"",
false,
Some(json!(["openai:chat"])),
None,
),
sample_key_for_provider_with_options(
"provider-active",
"key-active",
"",
true,
Some(json!(["openai:chat"])),
None,
),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-disabled",
"endpoint-disabled",
"key-disabled",
"openai:chat",
Some(0),
0,
),
sample_priority_candidate(
"provider-active",
"endpoint-active",
"key-active",
"openai:chat",
Some(10),
10,
),
],
"openai:chat",
"gpt-4.1",
None,
None,
None,
None,
None,
None,
)
.await;
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].candidate.endpoint_id, "endpoint-active");
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].candidate.endpoint_id, "endpoint-disabled");
assert_eq!(skipped[0].skip_reason, "key_inactive");
}
#[tokio::test]
async fn realtime_gate_skips_candidates_when_key_model_binding_is_disabled() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-restricted", false, 0),
sample_provider_with_options("provider-open", false, 10),
],
vec![
sample_endpoint_for_provider(
"provider-restricted",
"endpoint-restricted",
"openai:chat",
),
sample_endpoint_for_provider("provider-open", "endpoint-open", "openai:chat"),
],
vec![
sample_key_for_provider_with_options(
"provider-restricted",
"key-restricted",
"",
true,
Some(json!(["openai:chat"])),
Some(json!(["gpt-4o"])),
),
sample_key_for_provider_with_options(
"provider-open",
"key-open",
"",
true,
Some(json!(["openai:chat"])),
None,
),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-restricted",
"endpoint-restricted",
"key-restricted",
"openai:chat",
Some(0),
0,
),
sample_priority_candidate(
"provider-open",
"endpoint-open",
"key-open",
"openai:chat",
Some(10),
10,
),
],
"openai:chat",
"gpt-4.1",
None,
None,
None,
None,
None,
None,
)
.await;
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].candidate.endpoint_id, "endpoint-open");
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].candidate.endpoint_id, "endpoint-restricted");
assert_eq!(skipped[0].skip_reason, "key_model_disabled");
}
#[tokio::test]
async fn realtime_gate_reports_cross_format_candidates_when_conversion_is_disabled() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-cross", true, 0),
sample_provider_with_options("provider-same", false, 10),
],
vec![
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:messages"),
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
],
vec![
sample_key_for_provider_with_options(
"provider-cross",
"key-cross",
"",
true,
Some(json!(["claude:messages"])),
None,
),
sample_key_for_provider_with_options(
"provider-same",
"key-same",
"",
true,
Some(json!(["openai:chat"])),
None,
),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-cross",
"endpoint-cross",
"key-cross",
"claude:messages",
Some(0),
0,
),
sample_priority_candidate(
"provider-same",
"endpoint-same",
"key-same",
"openai:chat",
Some(10),
10,
),
],
"openai:chat",
"gpt-4.1",
None,
None,
None,
None,
None,
None,
)
.await;
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].candidate.endpoint_id, "endpoint-same");
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].candidate.endpoint_id, "endpoint-cross");
assert_eq!(skipped[0].skip_reason, "format_conversion_disabled");
}
#[tokio::test]
async fn realtime_gate_reports_cross_format_disablement_when_same_key_has_exact_endpoint() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider_with_options("provider-shared", false, 0)],
vec![
sample_endpoint_for_provider("provider-shared", "endpoint-exact", "openai:chat"),
sample_endpoint_for_provider(
"provider-shared",
"endpoint-cross",
"claude:messages",
),
],
vec![sample_key_for_provider_with_options(
"provider-shared",
"key-shared",
"",
true,
Some(json!(["openai:chat", "claude:messages"])),
None,
)],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-shared",
"endpoint-exact",
"key-shared",
"openai:chat",
Some(0),
0,
),
sample_priority_candidate(
"provider-shared",
"endpoint-cross",
"key-shared",
"claude:messages",
Some(0),
0,
),
],
"openai:chat",
"gpt-4.1",
None,
None,
None,
None,
None,
None,
)
.await;
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].candidate.endpoint_id, "endpoint-exact");
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].candidate.endpoint_id, "endpoint-cross");
assert_eq!(skipped[0].skip_reason, "format_conversion_disabled");
}
#[tokio::test]
async fn realtime_gate_allows_cross_format_candidates_when_endpoint_acceptance_is_enabled() {
let mut endpoint_cross =
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:messages");
endpoint_cross.format_acceptance_config = Some(json!({
"enabled": true,
"accept_formats": ["openai:chat"],
}));
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-cross", false, 0),
sample_provider_with_options("provider-same", false, 10),
],
vec![
endpoint_cross,
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
],
vec![
sample_key_for_provider_with_options(
"provider-cross",
"key-cross",
"",
true,
Some(json!(["claude:messages"])),
None,
),
sample_key_for_provider_with_options(
"provider-same",
"key-same",
"",
true,
Some(json!(["openai:chat"])),
None,
),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-cross",
"endpoint-cross",
"key-cross",
"claude:messages",
Some(0),
0,
),
sample_priority_candidate(
"provider-same",
"endpoint-same",
"key-same",
"openai:chat",
Some(10),
10,
),
],
"openai:chat",
"gpt-4.1",
None,
None,
None,
None,
None,
None,
)
.await;
assert_eq!(ranked.len(), 2);
assert_eq!(ranked[0].candidate.endpoint_id, "endpoint-same");
assert_eq!(ranked[1].candidate.endpoint_id, "endpoint-cross");
assert!(skipped.is_empty());
}
#[tokio::test]
async fn local_execution_ranking_reports_cached_affinity_promotion() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-priority", false, 0),
sample_provider_with_options("provider-cached", false, 10),
],
vec![
sample_endpoint_for_provider(
"provider-priority",
"endpoint-priority",
"openai:chat",
),
sample_endpoint_for_provider("provider-cached", "endpoint-cached", "openai:chat"),
],
vec![
sample_key_for_provider("provider-priority", "key-priority", ""),
sample_key_for_provider("provider-cached", "key-cached", ""),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = sample_auth_snapshot();
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
let cached_candidate = sample_priority_candidate(
"provider-cached",
"endpoint-cached",
"key-cached",
"openai:chat",
Some(10),
10,
);
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
Some(&client_session_affinity),
"openai:chat",
"gpt-4.1",
&cached_candidate,
);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-priority",
"endpoint-priority",
"key-priority",
"openai:chat",
Some(0),
0,
),
cached_candidate,
],
"openai:chat",
"gpt-4.1",
Some(&auth_snapshot),
Some(&client_session_affinity),
None,
None,
None,
None,
)
.await;
assert!(skipped.is_empty());
assert_eq!(ranked[0].candidate.endpoint_id, "endpoint-cached");
assert_eq!(
ranked[0]
.ranking
.as_ref()
.and_then(|ranking| ranking.promoted_by),
Some(RANKING_REASON_CACHED_AFFINITY)
);
}
#[tokio::test]
async fn first_request_same_key_exact_endpoint_beats_cross_format_without_affinity() {
let mut openai_endpoint =
sample_endpoint_for_provider("provider-shared", "endpoint-openai", "openai:chat");
openai_endpoint.format_acceptance_config = Some(json!({
"enabled": true,
"accept_formats": ["claude:messages"],
}));
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider_with_options("provider-shared", false, 0)],
vec![
openai_endpoint,
sample_endpoint_for_provider(
"provider-shared",
"endpoint-claude",
"claude:messages",
),
],
vec![sample_key_for_provider_with_options(
"provider-shared",
"key-shared",
"",
true,
Some(json!(["openai:chat", "claude:messages"])),
None,
)],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-shared",
"endpoint-openai",
"key-shared",
"openai:chat",
Some(0),
0,
),
sample_priority_candidate(
"provider-shared",
"endpoint-claude",
"key-shared",
"claude:messages",
Some(0),
0,
),
],
"claude:messages",
"gpt-4.1",
None,
None,
None,
None,
None,
None,
)
.await;
assert!(skipped.is_empty());
assert_eq!(ranked[0].candidate.endpoint_id, "endpoint-claude");
assert_eq!(ranked[1].candidate.endpoint_id, "endpoint-openai");
assert_eq!(
ranked[1]
.ranking
.as_ref()
.and_then(|ranking| ranking.promoted_by),
None
);
assert_eq!(
ranked[1]
.ranking
.as_ref()
.and_then(|ranking| ranking.demoted_by),
Some(aether_scheduler_core::RANKING_REASON_CROSS_FORMAT)
);
}
#[tokio::test]
async fn cached_affinity_promotes_cross_format_over_same_key_exact_endpoint() {
let mut openai_endpoint =
sample_endpoint_for_provider("provider-shared", "endpoint-openai", "openai:chat");
openai_endpoint.format_acceptance_config = Some(json!({
"enabled": true,
"accept_formats": ["claude:messages"],
}));
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider_with_options("provider-shared", false, 0)],
vec![
openai_endpoint,
sample_endpoint_for_provider(
"provider-shared",
"endpoint-claude",
"claude:messages",
),
],
vec![sample_key_for_provider_with_options(
"provider-shared",
"key-shared",
"",
true,
Some(json!(["openai:chat", "claude:messages"])),
None,
)],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = sample_auth_snapshot();
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
let cached_cross_format = sample_priority_candidate(
"provider-shared",
"endpoint-openai",
"key-shared",
"openai:chat",
Some(0),
0,
);
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
Some(&client_session_affinity),
"claude:messages",
"gpt-4.1",
&cached_cross_format,
);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
cached_cross_format,
sample_priority_candidate(
"provider-shared",
"endpoint-claude",
"key-shared",
"claude:messages",
Some(0),
0,
),
],
"claude:messages",
"gpt-4.1",
Some(&auth_snapshot),
Some(&client_session_affinity),
None,
None,
None,
None,
)
.await;
assert!(skipped.is_empty());
assert_eq!(ranked[0].candidate.endpoint_id, "endpoint-openai");
assert_eq!(
ranked[0]
.ranking
.as_ref()
.and_then(|ranking| ranking.promoted_by),
Some(RANKING_REASON_CACHED_AFFINITY)
);
assert_eq!(
ranked[0]
.ranking
.as_ref()
.and_then(|ranking| ranking.demoted_by),
Some(aether_scheduler_core::RANKING_REASON_CROSS_FORMAT)
);
assert_eq!(ranked[1].candidate.endpoint_id, "endpoint-claude");
}
#[tokio::test]
async fn non_pool_key_affinity_does_not_promote_sibling_key_when_cached_key_is_inactive() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-priority", false, 0),
sample_provider_with_options("provider-cached", false, 10),
],
vec![
sample_endpoint_for_provider(
"provider-priority",
"endpoint-priority",
"openai:chat",
),
sample_endpoint_for_provider("provider-cached", "endpoint-cached", "openai:chat"),
],
vec![
sample_key_for_provider("provider-priority", "key-priority", ""),
sample_key_for_provider_with_options(
"provider-cached",
"key-cached",
"",
false,
Some(json!(["openai:chat"])),
None,
),
sample_key_for_provider("provider-cached", "key-sibling", ""),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = sample_auth_snapshot();
let cached_candidate = sample_priority_candidate(
"provider-cached",
"endpoint-cached",
"key-cached",
"openai:chat",
Some(10),
10,
);
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
None,
"openai:chat",
"gpt-4.1",
&cached_candidate,
);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
cached_candidate,
sample_priority_candidate(
"provider-cached",
"endpoint-cached",
"key-sibling",
"openai:chat",
Some(10),
10,
),
sample_priority_candidate(
"provider-priority",
"endpoint-priority",
"key-priority",
"openai:chat",
Some(0),
0,
),
],
"openai:chat",
"gpt-4.1",
Some(&auth_snapshot),
None,
None,
None,
None,
None,
)
.await;
assert_eq!(ranked[0].candidate.key_id, "key-priority");
assert_eq!(ranked[1].candidate.key_id, "key-sibling");
assert!(ranked[1]
.ranking
.as_ref()
.is_none_or(|ranking| ranking.promoted_by.is_none()));
assert_eq!(
skipped
.iter()
.map(|item| (item.candidate.key_id.as_str(), item.skip_reason))
.collect::<Vec<_>>(),
vec![("key-cached", "key_inactive")]
);
}
#[tokio::test]
async fn pool_key_affinity_promotes_logical_pool_group_when_cached_key_is_inactive() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-priority", false, 0),
sample_provider_with_config(
"provider-pool",
false,
10,
Some(json!({ "pool_advanced": {} })),
),
],
vec![
sample_endpoint_for_provider(
"provider-priority",
"endpoint-priority",
"openai:chat",
),
sample_endpoint_for_provider("provider-pool", "endpoint-pool", "openai:chat"),
],
vec![
sample_key_for_provider("provider-priority", "key-priority", ""),
sample_key_for_provider_with_options(
"provider-pool",
"key-cached",
"",
false,
Some(json!(["openai:chat"])),
None,
),
sample_key_for_provider("provider-pool", "key-fallback", ""),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = sample_auth_snapshot();
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
let cached_candidate = sample_priority_candidate(
"provider-pool",
"endpoint-pool",
"key-cached",
"openai:chat",
Some(10),
10,
);
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
Some(&client_session_affinity),
"openai:chat",
"gpt-4.1",
&cached_candidate,
);
let (ranked, skipped) = resolve_and_rank_logical_local_execution_candidates(
PlannerAppState::new(&state),
vec![
cached_candidate,
sample_priority_candidate(
"provider-priority",
"endpoint-priority",
"key-priority",
"openai:chat",
Some(0),
0,
),
],
"openai:chat",
Some("gpt-4.1"),
Some(&auth_snapshot),
Some(&client_session_affinity),
None,
None,
None,
None,
aether_ai_serving::AiCandidateResolutionMode::Standard,
)
.await;
assert_eq!(ranked[0].candidate.key_id, "key-cached");
assert_eq!(ranked[0].kind, LocalExecutionCandidateKind::PoolGroup);
assert_eq!(ranked[0].orchestration.pool_key_index, None);
assert_eq!(
ranked[0]
.ranking
.as_ref()
.and_then(|ranking| ranking.promoted_by),
Some(RANKING_REASON_CACHED_AFFINITY)
);
assert!(skipped.is_empty());
}
#[tokio::test]
async fn pool_key_affinity_promotes_logical_pool_group_when_cached_key_is_blocked() {
let mut cached_key = sample_key_for_provider("provider-pool", "key-cached", "");
cached_key.oauth_invalid_reason =
Some("[ACCOUNT_BLOCK] account has been deactivated".to_string());
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-priority", false, 0),
sample_provider_with_config(
"provider-pool",
false,
10,
Some(json!({ "pool_advanced": {} })),
),
],
vec![
sample_endpoint_for_provider(
"provider-priority",
"endpoint-priority",
"openai:chat",
),
sample_endpoint_for_provider("provider-pool", "endpoint-pool", "openai:chat"),
],
vec![
sample_key_for_provider("provider-priority", "key-priority", ""),
cached_key,
sample_key_for_provider("provider-pool", "key-fallback", ""),
],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
DEVELOPMENT_ENCRYPTION_KEY,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = sample_auth_snapshot();
let client_session_affinity = ClientSessionAffinity::from_session_key("session-1");
let cached_candidate = sample_priority_candidate(
"provider-pool",
"endpoint-pool",
"key-cached",
"openai:chat",
Some(10),
10,
);
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
Some(&client_session_affinity),
"openai:chat",
"gpt-4.1",
&cached_candidate,
);
let (ranked, skipped) = resolve_and_rank_logical_local_execution_candidates(
PlannerAppState::new(&state),
vec![
cached_candidate,
sample_priority_candidate(
"provider-priority",
"endpoint-priority",
"key-priority",
"openai:chat",
Some(0),
0,
),
],
"openai:chat",
Some("gpt-4.1"),
Some(&auth_snapshot),
Some(&client_session_affinity),
None,
None,
None,
None,
aether_ai_serving::AiCandidateResolutionMode::Standard,
)
.await;
assert_eq!(ranked[0].candidate.key_id, "key-cached");
assert_eq!(ranked[0].kind, LocalExecutionCandidateKind::PoolGroup);
assert_eq!(ranked[0].orchestration.pool_key_index, None);
assert_eq!(
ranked[0]
.ranking
.as_ref()
.and_then(|ranking| ranking.promoted_by),
Some(RANKING_REASON_CACHED_AFFINITY)
);
assert!(skipped.is_empty());
}
#[tokio::test]
async fn ignores_scheduler_affinity_without_client_session_scope() {
let state = AppState::new().expect("state should build");
let auth_snapshot = sample_auth_snapshot();
let candidate = sample_candidate("endpoint-1", "key-1");
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
None,
"openai:chat",
"gpt-5",
&candidate,
);
assert!(state
.read_scheduler_affinity_target(
"scheduler_affinity:api-key-1:openai:chat:gpt-5",
SCHEDULER_AFFINITY_TTL,
)
.is_none());
}
#[tokio::test]
async fn remembers_scheduler_affinity_for_client_session_scope() {
let state = AppState::new().expect("state should build");
let auth_snapshot = sample_auth_snapshot();
let client_session_affinity = ClientSessionAffinity::new(
Some("generic".to_string()),
Some("session=conversation-1;agent=coder".to_string()),
);
let candidate = sample_candidate("endpoint-session", "key-session");
remember_scheduler_affinity_for_candidate(
PlannerAppState::new(&state),
Some(&auth_snapshot),
Some(&client_session_affinity),
"openai:chat",
"gpt-5",
&candidate,
);
let session_key = build_scheduler_affinity_cache_key_for_api_key_id_with_client_session(
"api-key-1",
"openai:chat",
"gpt-5",
Some(&client_session_affinity),
)
.expect("session key should build");
let remembered = state
.read_scheduler_affinity_target(&session_key, SCHEDULER_AFFINITY_TTL)
.expect("session affinity target should be cached");
assert_eq!(remembered.provider_id, "provider-1");
assert_eq!(remembered.endpoint_id, "endpoint-session");
assert_eq!(remembered.key_id, "key-session");
assert!(state
.read_scheduler_affinity_target(
"scheduler_affinity:api-key-1:openai:chat:gpt-5",
SCHEDULER_AFFINITY_TTL,
)
.is_none());
}
}