mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 候选排序引入 API 格式偏好, 追踪页面展示完整格式转换信息
- 候选排序优先同 kind (chat/cli) 再同 family, 替代原有固定顺序 - 不再隐藏 format_conversion_disabled 候选, 保留完整追踪链路 - DecisionTrace 新增 provider/endpoint/key 格式转换相关字段 - 前端追踪面板新增 Key 支持端点和转换策略展示
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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})),
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user