fix: align Responses routing and model permissions

This commit is contained in:
zhefox
2026-08-03 18:48:01 +08:00
parent 0318808db9
commit 669f4bddc5
14 changed files with 728 additions and 396 deletions
@@ -188,9 +188,14 @@ 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) => scheduler_ordering_config_from_routing_policy(policy),
None => read_scheduler_ordering_config_or_default(state).await,
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,
}
}
@@ -390,6 +395,43 @@ mod tests {
assert_eq!(overlaid.key_global_priority_for_format, Some(2));
}
#[tokio::test]
async fn routing_policy_inherits_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,
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(
PlannerAppState::new(&state),
Some(&policy),
)
.await;
assert_eq!(
ordering.scheduling_mode,
crate::scheduler::config::SchedulerSchedulingMode::FixedOrder
);
assert!(ordering.keep_priority_on_conversion);
}
#[test]
fn routing_policy_uses_pool_priority_for_pool_group_global_key_slot() {
let mut candidate = sample_candidate("endpoint-1", "representative-key");
@@ -2540,4 +2540,146 @@ mod tests {
vec!["provider-openai-responses-regular"]
);
}
#[tokio::test]
async fn fixed_order_prefers_codex_responses_when_conversion_keeps_priority() {
let mut codex = standard_candidate_row("provider-codex", "openai:responses", 0);
codex.provider_type = "codex".to_string();
codex.key_auth_type = "oauth".to_string();
codex.global_model_name = "gpt-5.4-mini".to_string();
codex.model_provider_model_name = "gpt-5.4-mini".to_string();
let mut custom_chat = standard_candidate_row("provider-custom", "openai:chat", 10);
custom_chat.global_model_name = "gpt-5.4-mini".to_string();
custom_chat.model_provider_model_name = "gpt-5.4-mini".to_string();
let candidate_repository: Arc<dyn MinimalCandidateSelectionReadRepository> =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed([
codex.clone(),
custom_chat.clone(),
]));
let catalog_items = [
provider_catalog_for_standard_row(&codex, false),
provider_catalog_for_standard_row(&custom_chat, false),
];
let provider_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
catalog_items
.iter()
.map(|(provider, _, _)| provider.clone())
.collect(),
catalog_items
.iter()
.map(|(_, endpoint, _)| endpoint.clone())
.collect(),
catalog_items
.iter()
.map(|(_, _, key)| key.clone())
.collect(),
));
let data_state =
GatewayDataState::with_provider_catalog_and_minimal_candidate_selection_for_tests(
provider_repository,
candidate_repository,
)
.with_encryption_key_for_tests("development-key")
.with_system_config_values_for_tests([
(
"scheduling_mode".to_string(),
serde_json::json!("fixed_order"),
),
(
"keep_priority_on_conversion".to_string(),
serde_json::json!(true),
),
]);
let app = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = unrestricted_auth_snapshot();
let model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::load(&app).await;
let routing_policy = ResolvedRoutingPolicy {
group_id: Some("routing-group-codex-first".to_string()),
group_version: Some(1),
selection_source: "test".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,
ranking_overlay: Default::default(),
mutation_plan: Default::default(),
pool_policy_overrides: Default::default(),
matched_rules: Vec::new(),
};
let mut cursor = LocalCandidatePreselectionPageCursor::new(
PlannerAppState::new(&app),
&model_directive_policy,
"openai:chat",
"gpt-5.4-mini",
None,
false,
None,
&auth_snapshot,
Some(&routing_policy),
None,
None,
true,
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
false,
None,
)
.await;
let first_page = cursor
.next_page()
.await
.expect("preselection should succeed")
.expect("Codex and custom candidates should share the priority page");
assert!(
first_page.skipped_candidates.is_empty(),
"priority page unexpectedly skipped candidates: {:?}",
first_page
.skipped_candidates
.iter()
.map(|candidate| {
(
candidate.candidate.provider_id.as_str(),
candidate.skip_reason,
)
})
.collect::<Vec<_>>()
);
assert_eq!(
first_page
.candidates
.iter()
.map(|candidate| candidate.provider_id.as_str())
.collect::<Vec<_>>(),
vec!["provider-custom", "provider-codex"]
);
let (ranked, skipped) =
super::super::candidate_resolution::resolve_and_rank_logical_local_execution_candidates(
PlannerAppState::new(&app),
first_page.candidates,
"openai:chat",
Some("gpt-5.4-mini"),
Some(&auth_snapshot),
None,
None,
Some(&routing_policy),
None,
None,
aether_ai_serving::AiCandidateResolutionMode::Standard,
)
.await;
assert!(skipped.is_empty());
assert_eq!(
ranked
.iter()
.map(|candidate| candidate.candidate.provider_id.as_str())
.collect::<Vec<_>>(),
vec!["provider-codex", "provider-custom"]
);
}
}
@@ -352,7 +352,7 @@ fn final_openai_provider_contract_uses_the_mapped_model_for_reasoning() {
false,
)
.is_some());
assert!(build_local_openai_responses_request_body(
let remapped = build_local_openai_responses_request_body(
&alias,
"gpt-5.4",
false,
@@ -364,14 +364,15 @@ fn final_openai_provider_contract_uses_the_mapped_model_for_reasoning() {
&http::HeaderMap::new(),
false,
)
.is_none());
.expect("explicit reasoning effort should pass through to the mapped model");
assert_eq!(remapped["reasoning"]["effort"], "max");
let minimal = json!({
"model": "deployment-alias",
"messages": [{"role": "user", "content": "hello"}],
"reasoning_effort": "minimal"
});
assert!(build_local_openai_chat_request_body(
let minimal = build_local_openai_chat_request_body(
&minimal,
"gpt-5.6-terra",
false,
@@ -380,7 +381,8 @@ fn final_openai_provider_contract_uses_the_mapped_model_for_reasoning() {
&http::HeaderMap::new(),
false,
)
.is_none());
.expect("explicit chat reasoning effort should be validated by the upstream");
assert_eq!(minimal["reasoning_effort"], "minimal");
let opaque_mapping = json!({
"model": "gpt-5.6-sol-max",
@@ -426,7 +428,7 @@ fn final_openai_provider_contract_validates_body_rule_output() {
let model_override = json!([
{"action":"set","path":"model","value":"gpt-5.4"}
]);
assert!(build_local_openai_responses_request_body(
let provider_request = build_local_openai_responses_request_body(
&body,
"gpt-5.6-sol",
false,
@@ -438,7 +440,9 @@ fn final_openai_provider_contract_validates_body_rule_output() {
&http::HeaderMap::new(),
false,
)
.is_none());
.expect("body rule output should preserve explicit reasoning effort");
assert_eq!(provider_request["model"], "gpt-5.4");
assert_eq!(provider_request["reasoning"]["effort"], "max");
let cache_override = json!([
{"action":"set","path":"prompt_cache_options.ttl","value":"1h"}