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

View File

@@ -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))
);
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -176,11 +176,14 @@ mod tests {
provider_type: Some("custom".to_string()),
provider_priority: Some(0),
provider_keep_priority_on_conversion: Some(false),
provider_enable_format_conversion: Some(false),
endpoint_api_format: Some("openai:chat".to_string()),
endpoint_api_family: Some("openai".to_string()),
endpoint_kind: Some("chat".to_string()),
endpoint_format_acceptance_config: None,
provider_key_name: Some("prod-key".to_string()),
provider_key_auth_type: Some("api_key".to_string()),
provider_key_api_formats: None,
provider_key_internal_priority: Some(50),
provider_key_global_priority_by_format: None,
provider_key_capabilities: Some(serde_json::json!({"cache_1h": true})),

View File

@@ -76,9 +76,9 @@ async fn admin_monitoring_trace_request_returns_local_payload() {
}
#[tokio::test]
async fn admin_monitoring_trace_request_hides_format_conversion_disabled_candidates() {
let mut hidden_candidate = sample_candidate(
"cand-hidden",
async fn admin_monitoring_trace_request_keeps_format_conversion_disabled_candidates_visible() {
let mut format_disabled_candidate = sample_candidate(
"cand-format-disabled",
"request-1",
0,
RequestCandidateStatus::Skipped,
@@ -86,7 +86,7 @@ async fn admin_monitoring_trace_request_hides_format_conversion_disabled_candida
None,
None,
);
hidden_candidate.skip_reason = Some("format_conversion_disabled".to_string());
format_disabled_candidate.skip_reason = Some("format_conversion_disabled".to_string());
let mut visible_skipped_candidate = sample_candidate(
"cand-visible-skipped",
@@ -100,7 +100,7 @@ async fn admin_monitoring_trace_request_hides_format_conversion_disabled_candida
visible_skipped_candidate.skip_reason = Some("transport_unsupported".to_string());
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
hidden_candidate,
format_disabled_candidate,
visible_skipped_candidate,
sample_candidate(
"cand-used",
@@ -136,7 +136,7 @@ async fn admin_monitoring_trace_request_hides_format_conversion_disabled_candida
.expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse");
assert_eq!(payload["total_candidates"], json!(2));
assert_eq!(payload["total_candidates"], json!(3));
assert_eq!(
payload["candidates"]
.as_array()
@@ -144,10 +144,14 @@ async fn admin_monitoring_trace_request_hides_format_conversion_disabled_candida
.iter()
.map(|item| item["id"].as_str().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["cand-visible-skipped", "cand-used"]
vec!["cand-format-disabled", "cand-visible-skipped", "cand-used"]
);
assert_eq!(
payload["candidates"][0]["skip_reason"],
json!("format_conversion_disabled")
);
assert_eq!(
payload["candidates"][1]["skip_reason"],
json!("transport_unsupported")
);
}

View File

@@ -15,22 +15,6 @@ use axum::{
};
use tracing::debug;
const HIDDEN_TRACE_SKIP_REASONS: &[&str] = &["format_conversion_disabled"];
fn filter_admin_monitoring_trace_candidates(mut trace: DecisionTrace) -> DecisionTrace {
trace.candidates.retain(|item| {
let skip_reason = item
.candidate
.skip_reason
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
!skip_reason.is_some_and(|reason| HIDDEN_TRACE_SKIP_REASONS.contains(&reason))
});
trace.total_candidates = trace.candidates.len();
trace
}
pub(super) async fn build_admin_monitoring_trace_request_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
@@ -67,7 +51,6 @@ pub(super) async fn build_admin_monitoring_trace_request_response(
attempted_only,
));
};
let trace = filter_admin_monitoring_trace_candidates(trace);
Ok(build_admin_monitoring_trace_request_payload_response(
&trace,