feat: 候选排序引入 API 格式偏好, 追踪页面展示完整格式转换信息

- 候选排序优先同 kind (chat/cli) 再同 family, 替代原有固定顺序
- 不再隐藏 format_conversion_disabled 候选, 保留完整追踪链路
- DecisionTrace 新增 provider/endpoint/key 格式转换相关字段
- 前端追踪面板新增 Key 支持端点和转换策略展示
This commit is contained in:
fawney19
2026-04-16 13:00:44 +08:00
parent d805a28c9a
commit 9a41b2c0dc
14 changed files with 433 additions and 92 deletions
@@ -5,18 +5,20 @@ pub(crate) use crate::ai_pipeline::{
core_error_default_client_api_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
};
pub(crate) use crate::ai_pipeline::{
request_candidate_api_formats, request_conversion_direct_auth,
request_conversion_enabled_for_transport, request_conversion_kind,
request_conversion_requires_enable_flag, request_conversion_transport_supported,
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_direct_auth, request_conversion_enabled_for_transport,
request_conversion_kind, request_conversion_requires_enable_flag,
request_conversion_transport_supported, request_conversion_transport_unsupported_reason,
request_pair_allowed_for_transport, sync_chat_response_conversion_kind,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
};
#[cfg(test)]
mod tests {
use super::{
request_candidate_api_formats, request_conversion_kind, sync_chat_response_conversion_kind,
request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_kind, sync_chat_response_conversion_kind,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
};
@@ -96,22 +98,33 @@ mod tests {
request_candidate_api_formats("openai:chat", false),
vec![
"openai:chat",
"openai:cli",
"claude:chat",
"claude:cli",
"gemini:chat",
"openai:cli",
"claude:cli",
"gemini:cli",
]
);
assert_eq!(
request_candidate_api_formats("openai:cli", false),
vec![
"openai:chat",
"openai:cli",
"claude:chat",
"claude:cli",
"gemini:chat",
"gemini:cli",
"openai:chat",
"claude:chat",
"gemini:chat",
]
);
assert_eq!(
request_candidate_api_formats("claude:cli", false),
vec![
"claude:cli",
"openai:cli",
"gemini:cli",
"claude:chat",
"openai:chat",
"gemini:chat",
]
);
assert_eq!(
@@ -119,4 +132,20 @@ mod tests {
vec!["openai:compact"]
);
}
#[test]
fn request_candidate_registry_prefers_same_kind_before_same_family_fallbacks() {
assert_eq!(
request_candidate_api_format_preference("claude:cli", "openai:cli"),
Some((1, 0))
);
assert_eq!(
request_candidate_api_format_preference("claude:cli", "claude:chat"),
Some((2, 1))
);
assert_eq!(
request_candidate_api_format_preference("claude:cli", "openai:chat"),
Some((3, 0))
);
}
}
@@ -1,14 +1,15 @@
use tracing::warn;
use crate::ai_pipeline::{
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, PlannerAppState,
request_candidate_api_format_preference, GatewayAuthApiKeySnapshot,
GatewayProviderTransportSnapshot, PlannerAppState,
};
use crate::scheduler::affinity::SCHEDULER_AFFINITY_TTL;
use crate::scheduler::config::{read_scheduler_ordering_config, SchedulerOrderingConfig};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use aether_scheduler_core::{
build_scheduler_affinity_cache_key_for_api_key_id, compare_candidates_by_priority_mode,
requested_capability_priority_for_candidate, SchedulerAffinityTarget,
requested_capability_priority_for_candidate, SchedulerAffinityTarget, SchedulerPriorityMode,
};
use super::candidate_eligibility::{
@@ -65,6 +66,10 @@ async fn rank_local_execution_candidates(
.trim()
.eq_ignore_ascii_case(normalized_client_api_format.as_str());
let demote_cross_format = !is_same_format && !ordering.keep_priority_on_conversion;
let format_preference = candidate_api_format_preference(
normalized_client_api_format.as_str(),
candidate.endpoint_api_format.as_str(),
);
let capability_priority =
requested_capability_priority_for_candidate(required_capabilities, &candidate);
ranked.push((
@@ -72,6 +77,7 @@ async fn rank_local_execution_candidates(
capability_priority.1,
ordering.tunnel_bucket,
demote_cross_format,
format_preference,
original_index,
candidate,
));
@@ -83,20 +89,24 @@ async fn rank_local_execution_candidates(
.then(left.1.cmp(&right.1))
.then(left.2.cmp(&right.2))
.then(left.3.cmp(&right.3))
.then_with(|| {
compare_candidate_priority_slot(&left.6, &right.6, ordering_config.priority_mode)
})
.then(left.4.cmp(&right.4))
.then_with(|| {
compare_candidates_by_priority_mode(
&left.5,
&right.5,
&left.6,
&right.6,
ordering_config.priority_mode,
None,
)
})
.then(left.4.cmp(&right.4))
.then(left.5.cmp(&right.5))
});
ranked
.into_iter()
.map(|(_, _, _, _, _, candidate)| candidate)
.map(|(_, _, _, _, _, _, candidate)| candidate)
.collect()
}
@@ -121,6 +131,10 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
.provider_api_format
.eq_ignore_ascii_case(normalized_client_api_format.as_str());
let demote_cross_format = !is_same_format && !ordering.keep_priority_on_conversion;
let format_preference = candidate_api_format_preference(
normalized_client_api_format.as_str(),
eligible.provider_api_format.as_str(),
);
let capability_priority =
requested_capability_priority_for_candidate(required_capabilities, &eligible.candidate);
ranked.push((
@@ -128,6 +142,7 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
capability_priority.1,
ordering.tunnel_bucket,
demote_cross_format,
format_preference,
original_index,
eligible,
));
@@ -139,20 +154,28 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
.then(left.1.cmp(&right.1))
.then(left.2.cmp(&right.2))
.then(left.3.cmp(&right.3))
.then_with(|| {
compare_candidate_priority_slot(
&left.6.candidate,
&right.6.candidate,
ordering_config.priority_mode,
)
})
.then(left.4.cmp(&right.4))
.then_with(|| {
compare_candidates_by_priority_mode(
&left.5.candidate,
&right.5.candidate,
&left.6.candidate,
&right.6.candidate,
ordering_config.priority_mode,
None,
)
})
.then(left.4.cmp(&right.4))
.then(left.5.cmp(&right.5))
});
ranked
.into_iter()
.map(|(_, _, _, _, _, eligible)| eligible)
.map(|(_, _, _, _, _, _, eligible)| eligible)
.collect()
}
@@ -278,6 +301,30 @@ async fn resolve_tunnel_owner_affinity_from_transport(
}
}
fn candidate_api_format_preference(client_api_format: &str, provider_api_format: &str) -> (u8, u8) {
request_candidate_api_format_preference(client_api_format, provider_api_format)
.unwrap_or((u8::MAX, u8::MAX))
}
fn compare_candidate_priority_slot(
left: &SchedulerMinimalCandidateSelectionCandidate,
right: &SchedulerMinimalCandidateSelectionCandidate,
priority_mode: SchedulerPriorityMode,
) -> std::cmp::Ordering {
match priority_mode {
SchedulerPriorityMode::Provider => left
.provider_priority
.cmp(&right.provider_priority)
.then(left.key_internal_priority.cmp(&right.key_internal_priority)),
SchedulerPriorityMode::GlobalKey => left
.key_global_priority_for_format
.unwrap_or(i32::MAX)
.cmp(&right.key_global_priority_for_format.unwrap_or(i32::MAX))
.then(left.provider_priority.cmp(&right.provider_priority))
.then(left.key_internal_priority.cmp(&right.key_internal_priority)),
}
}
async fn read_scheduler_ordering_config_or_default(
state: PlannerAppState<'_>,
) -> SchedulerOrderingConfig {
@@ -818,6 +865,60 @@ mod tests {
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:chat"),
sample_endpoint_for_provider("provider-shared", "zzz-openai-cli", "openai:cli"),
],
vec![sample_key_for_provider_with_options(
"provider-shared",
"key-shared",
"",
true,
Some(json!(["claude:chat", "openai:cli"])),
None,
)],
);
let data_state = GatewayDataState::with_provider_transport_reader_for_tests(
std::sync::Arc::new(provider_catalog),
"development-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:chat",
Some(0),
0,
),
sample_priority_candidate(
"provider-shared",
"zzz-openai-cli",
"key-shared",
"openai:cli",
Some(0),
0,
),
],
"claude:cli",
None,
)
.await;
assert_eq!(ranked[0].endpoint_id, "zzz-openai-cli");
assert_eq!(ranked[1].endpoint_id, "aaa-claude-chat");
}
#[tokio::test]
async fn local_execution_ranking_prefers_candidates_matching_requested_capabilities() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
@@ -44,11 +44,12 @@ pub(crate) use aether_ai_pipeline::api::{
provider_adaptation_descriptor_for_provider_type,
provider_adaptation_requires_eventstream_accept,
provider_adaptation_should_unwrap_stream_envelope,
provider_private_response_allows_sync_finalize, request_candidate_api_formats,
request_conversion_direct_auth, request_conversion_enabled_for_transport,
request_conversion_kind, request_conversion_requires_enable_flag,
request_conversion_transport_supported, request_conversion_transport_unsupported_reason,
request_pair_allowed_for_transport, resolve_claude_stream_spec, resolve_claude_sync_spec,
provider_private_response_allows_sync_finalize, request_candidate_api_format_preference,
request_candidate_api_formats, request_conversion_direct_auth,
request_conversion_enabled_for_transport, request_conversion_kind,
request_conversion_requires_enable_flag, request_conversion_transport_supported,
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
resolve_claude_stream_spec, resolve_claude_sync_spec,
resolve_execution_runtime_stream_plan_kind, resolve_execution_runtime_sync_plan_kind,
resolve_finalize_stream_rewrite_mode, resolve_gemini_files_stream_spec,
resolve_gemini_files_sync_spec, resolve_gemini_stream_spec, resolve_gemini_sync_spec,