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, core_error_default_client_api_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
}; };
pub(crate) use crate::ai_pipeline::{ pub(crate) use crate::ai_pipeline::{
request_candidate_api_formats, request_conversion_direct_auth, request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_enabled_for_transport, request_conversion_kind, request_conversion_direct_auth, request_conversion_enabled_for_transport,
request_conversion_requires_enable_flag, request_conversion_transport_supported, request_conversion_kind, request_conversion_requires_enable_flag,
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport, request_conversion_transport_supported, request_conversion_transport_unsupported_reason,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, RequestConversionKind, request_pair_allowed_for_transport, sync_chat_response_conversion_kind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind, sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
}; };
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ 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, sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind, SyncCliResponseConversionKind,
}; };
@@ -96,22 +98,33 @@ mod tests {
request_candidate_api_formats("openai:chat", false), request_candidate_api_formats("openai:chat", false),
vec![ vec![
"openai:chat", "openai:chat",
"openai:cli",
"claude:chat", "claude:chat",
"claude:cli",
"gemini:chat", "gemini:chat",
"openai:cli",
"claude:cli",
"gemini:cli", "gemini:cli",
] ]
); );
assert_eq!( assert_eq!(
request_candidate_api_formats("openai:cli", false), request_candidate_api_formats("openai:cli", false),
vec![ vec![
"openai:chat",
"openai:cli", "openai:cli",
"claude:chat",
"claude:cli", "claude:cli",
"gemini:chat",
"gemini:cli", "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!( assert_eq!(
@@ -119,4 +132,20 @@ mod tests {
vec!["openai:compact"] 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 tracing::warn;
use crate::ai_pipeline::{ 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::affinity::SCHEDULER_AFFINITY_TTL;
use crate::scheduler::config::{read_scheduler_ordering_config, SchedulerOrderingConfig}; use crate::scheduler::config::{read_scheduler_ordering_config, SchedulerOrderingConfig};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate; use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use aether_scheduler_core::{ use aether_scheduler_core::{
build_scheduler_affinity_cache_key_for_api_key_id, compare_candidates_by_priority_mode, 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::{ use super::candidate_eligibility::{
@@ -65,6 +66,10 @@ async fn rank_local_execution_candidates(
.trim() .trim()
.eq_ignore_ascii_case(normalized_client_api_format.as_str()); .eq_ignore_ascii_case(normalized_client_api_format.as_str());
let demote_cross_format = !is_same_format && !ordering.keep_priority_on_conversion; 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 = let capability_priority =
requested_capability_priority_for_candidate(required_capabilities, &candidate); requested_capability_priority_for_candidate(required_capabilities, &candidate);
ranked.push(( ranked.push((
@@ -72,6 +77,7 @@ async fn rank_local_execution_candidates(
capability_priority.1, capability_priority.1,
ordering.tunnel_bucket, ordering.tunnel_bucket,
demote_cross_format, demote_cross_format,
format_preference,
original_index, original_index,
candidate, candidate,
)); ));
@@ -83,20 +89,24 @@ async fn rank_local_execution_candidates(
.then(left.1.cmp(&right.1)) .then(left.1.cmp(&right.1))
.then(left.2.cmp(&right.2)) .then(left.2.cmp(&right.2))
.then(left.3.cmp(&right.3)) .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(|| { .then_with(|| {
compare_candidates_by_priority_mode( compare_candidates_by_priority_mode(
&left.5, &left.6,
&right.5, &right.6,
ordering_config.priority_mode, ordering_config.priority_mode,
None, None,
) )
}) })
.then(left.4.cmp(&right.4)) .then(left.5.cmp(&right.5))
}); });
ranked ranked
.into_iter() .into_iter()
.map(|(_, _, _, _, _, candidate)| candidate) .map(|(_, _, _, _, _, _, candidate)| candidate)
.collect() .collect()
} }
@@ -121,6 +131,10 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
.provider_api_format .provider_api_format
.eq_ignore_ascii_case(normalized_client_api_format.as_str()); .eq_ignore_ascii_case(normalized_client_api_format.as_str());
let demote_cross_format = !is_same_format && !ordering.keep_priority_on_conversion; 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 = let capability_priority =
requested_capability_priority_for_candidate(required_capabilities, &eligible.candidate); requested_capability_priority_for_candidate(required_capabilities, &eligible.candidate);
ranked.push(( ranked.push((
@@ -128,6 +142,7 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
capability_priority.1, capability_priority.1,
ordering.tunnel_bucket, ordering.tunnel_bucket,
demote_cross_format, demote_cross_format,
format_preference,
original_index, original_index,
eligible, eligible,
)); ));
@@ -139,20 +154,28 @@ pub(crate) async fn rank_eligible_local_execution_candidates(
.then(left.1.cmp(&right.1)) .then(left.1.cmp(&right.1))
.then(left.2.cmp(&right.2)) .then(left.2.cmp(&right.2))
.then(left.3.cmp(&right.3)) .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(|| { .then_with(|| {
compare_candidates_by_priority_mode( compare_candidates_by_priority_mode(
&left.5.candidate, &left.6.candidate,
&right.5.candidate, &right.6.candidate,
ordering_config.priority_mode, ordering_config.priority_mode,
None, None,
) )
}) })
.then(left.4.cmp(&right.4)) .then(left.5.cmp(&right.5))
}); });
ranked ranked
.into_iter() .into_iter()
.map(|(_, _, _, _, _, eligible)| eligible) .map(|(_, _, _, _, _, _, eligible)| eligible)
.collect() .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( async fn read_scheduler_ordering_config_or_default(
state: PlannerAppState<'_>, state: PlannerAppState<'_>,
) -> SchedulerOrderingConfig { ) -> SchedulerOrderingConfig {
@@ -818,6 +865,60 @@ mod tests {
assert_eq!(ranked[1].endpoint_id, "endpoint-global-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: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] #[tokio::test]
async fn local_execution_ranking_prefers_candidates_matching_requested_capabilities() { async fn local_execution_ranking_prefers_candidates_matching_requested_capabilities() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed( 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_descriptor_for_provider_type,
provider_adaptation_requires_eventstream_accept, provider_adaptation_requires_eventstream_accept,
provider_adaptation_should_unwrap_stream_envelope, provider_adaptation_should_unwrap_stream_envelope,
provider_private_response_allows_sync_finalize, request_candidate_api_formats, provider_private_response_allows_sync_finalize, request_candidate_api_format_preference,
request_conversion_direct_auth, request_conversion_enabled_for_transport, request_candidate_api_formats, request_conversion_direct_auth,
request_conversion_kind, request_conversion_requires_enable_flag, request_conversion_enabled_for_transport, request_conversion_kind,
request_conversion_transport_supported, request_conversion_transport_unsupported_reason, request_conversion_requires_enable_flag, request_conversion_transport_supported,
request_pair_allowed_for_transport, resolve_claude_stream_spec, resolve_claude_sync_spec, 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_execution_runtime_stream_plan_kind, resolve_execution_runtime_sync_plan_kind,
resolve_finalize_stream_rewrite_mode, resolve_gemini_files_stream_spec, resolve_finalize_stream_rewrite_mode, resolve_gemini_files_stream_spec,
resolve_gemini_files_sync_spec, resolve_gemini_stream_spec, resolve_gemini_sync_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_type: Some("custom".to_string()),
provider_priority: Some(0), provider_priority: Some(0),
provider_keep_priority_on_conversion: Some(false), provider_keep_priority_on_conversion: Some(false),
provider_enable_format_conversion: Some(false),
endpoint_api_format: Some("openai:chat".to_string()), endpoint_api_format: Some("openai:chat".to_string()),
endpoint_api_family: Some("openai".to_string()), endpoint_api_family: Some("openai".to_string()),
endpoint_kind: Some("chat".to_string()), endpoint_kind: Some("chat".to_string()),
endpoint_format_acceptance_config: None,
provider_key_name: Some("prod-key".to_string()), provider_key_name: Some("prod-key".to_string()),
provider_key_auth_type: Some("api_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_internal_priority: Some(50),
provider_key_global_priority_by_format: None, provider_key_global_priority_by_format: None,
provider_key_capabilities: Some(serde_json::json!({"cache_1h": true})), 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] #[tokio::test]
async fn admin_monitoring_trace_request_hides_format_conversion_disabled_candidates() { async fn admin_monitoring_trace_request_keeps_format_conversion_disabled_candidates_visible() {
let mut hidden_candidate = sample_candidate( let mut format_disabled_candidate = sample_candidate(
"cand-hidden", "cand-format-disabled",
"request-1", "request-1",
0, 0,
RequestCandidateStatus::Skipped, RequestCandidateStatus::Skipped,
@@ -86,7 +86,7 @@ async fn admin_monitoring_trace_request_hides_format_conversion_disabled_candida
None, None,
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( let mut visible_skipped_candidate = sample_candidate(
"cand-visible-skipped", "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()); visible_skipped_candidate.skip_reason = Some("transport_unsupported".to_string());
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![ let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
hidden_candidate, format_disabled_candidate,
visible_skipped_candidate, visible_skipped_candidate,
sample_candidate( sample_candidate(
"cand-used", "cand-used",
@@ -136,7 +136,7 @@ async fn admin_monitoring_trace_request_hides_format_conversion_disabled_candida
.expect("body should read"); .expect("body should read");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json body should parse"); 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!( assert_eq!(
payload["candidates"] payload["candidates"]
.as_array() .as_array()
@@ -144,10 +144,14 @@ async fn admin_monitoring_trace_request_hides_format_conversion_disabled_candida
.iter() .iter()
.map(|item| item["id"].as_str().unwrap_or_default()) .map(|item| item["id"].as_str().unwrap_or_default())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec!["cand-visible-skipped", "cand-used"] vec!["cand-format-disabled", "cand-visible-skipped", "cand-used"]
); );
assert_eq!( assert_eq!(
payload["candidates"][0]["skip_reason"], payload["candidates"][0]["skip_reason"],
json!("format_conversion_disabled")
);
assert_eq!(
payload["candidates"][1]["skip_reason"],
json!("transport_unsupported") json!("transport_unsupported")
); );
} }

View File

@@ -15,22 +15,6 @@ use axum::{
}; };
use tracing::debug; 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( pub(super) async fn build_admin_monitoring_trace_request_response(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>, request_context: &AdminRequestContext<'_>,
@@ -67,7 +51,6 @@ pub(super) async fn build_admin_monitoring_trace_request_response(
attempted_only, attempted_only,
)); ));
}; };
let trace = filter_admin_monitoring_trace_candidates(trace);
Ok(build_admin_monitoring_trace_request_payload_response( Ok(build_admin_monitoring_trace_request_payload_response(
&trace, &trace,

View File

@@ -291,13 +291,18 @@ pub fn build_admin_monitoring_trace_request_candidate_payload(
"provider_website": item.provider_website, "provider_website": item.provider_website,
"provider_priority": item.provider_priority, "provider_priority": item.provider_priority,
"provider_keep_priority_on_conversion": item.provider_keep_priority_on_conversion, "provider_keep_priority_on_conversion": item.provider_keep_priority_on_conversion,
"provider_enable_format_conversion": item.provider_enable_format_conversion,
"endpoint_id": candidate.endpoint_id, "endpoint_id": candidate.endpoint_id,
"endpoint_name": item.endpoint_api_format, "endpoint_name": item.endpoint_api_format,
"endpoint_api_family": item.endpoint_api_family,
"endpoint_kind": item.endpoint_kind,
"endpoint_format_acceptance_config": item.endpoint_format_acceptance_config,
"key_id": candidate.key_id, "key_id": candidate.key_id,
"key_name": item.provider_key_name, "key_name": item.provider_key_name,
"key_account_label": serde_json::Value::Null, "key_account_label": serde_json::Value::Null,
"key_preview": serde_json::Value::Null, "key_preview": serde_json::Value::Null,
"key_auth_type": item.provider_key_auth_type, "key_auth_type": item.provider_key_auth_type,
"key_api_formats": item.provider_key_api_formats,
"key_internal_priority": item.provider_key_internal_priority, "key_internal_priority": item.provider_key_internal_priority,
"key_global_priority_by_format": item.provider_key_global_priority_by_format, "key_global_priority_by_format": item.provider_key_global_priority_by_format,
"key_oauth_plan_type": serde_json::Value::Null, "key_oauth_plan_type": serde_json::Value::Null,

View File

@@ -69,12 +69,13 @@ pub use crate::conversion::response::{
}; };
pub use crate::conversion::{ pub use crate::conversion::{
build_core_error_body_for_client_format, is_core_error_finalize_kind, build_core_error_body_for_client_format, is_core_error_finalize_kind,
request_candidate_api_formats, request_conversion_direct_auth, request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_enabled_for_transport, request_conversion_kind, request_conversion_direct_auth, request_conversion_enabled_for_transport,
request_conversion_requires_enable_flag, request_conversion_transport_supported, request_conversion_kind, request_conversion_requires_enable_flag,
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport, request_conversion_transport_supported, request_conversion_transport_unsupported_reason,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, LocalCoreSyncErrorKind, request_pair_allowed_for_transport, sync_chat_response_conversion_kind,
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind, sync_cli_response_conversion_kind, LocalCoreSyncErrorKind, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
}; };
pub use crate::finalize::common::{ pub use crate::finalize::common::{
build_generated_tool_call_id, build_local_success_background_report, build_generated_tool_call_id, build_local_success_background_report,

View File

@@ -9,10 +9,11 @@ pub use error::{
is_core_error_finalize_kind, LocalCoreSyncErrorKind, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
}; };
pub use registry::{ pub use registry::{
request_candidate_api_formats, request_conversion_direct_auth, request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_enabled_for_transport, request_conversion_kind, request_conversion_direct_auth, request_conversion_enabled_for_transport,
request_conversion_requires_enable_flag, request_conversion_transport_supported, request_conversion_kind, request_conversion_requires_enable_flag,
request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport, request_conversion_transport_supported, request_conversion_transport_unsupported_reason,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, RequestConversionKind, request_pair_allowed_for_transport, sync_chat_response_conversion_kind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind, sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
}; };

View File

@@ -40,18 +40,57 @@ const NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS: &[&str] = &[
"gemini:chat", "gemini:chat",
"gemini:cli", "gemini:cli",
]; ];
const STANDARD_API_FAMILY_ORDER: &[&str] = &["openai", "claude", "gemini"];
pub fn request_candidate_api_format_preference(
client_api_format: &str,
provider_api_format: &str,
) -> Option<(u8, u8)> {
let client_api_format = client_api_format.trim().to_ascii_lowercase();
let provider_api_format = provider_api_format.trim().to_ascii_lowercase();
if client_api_format == "openai:compact" {
return (provider_api_format == "openai:compact").then_some((0, 0));
}
let (client_family, client_kind) =
parse_non_compact_standard_api_format(client_api_format.as_str())?;
let (provider_family, provider_kind) =
parse_non_compact_standard_api_format(provider_api_format.as_str())?;
let preference_bucket = if client_family == provider_family && client_kind == provider_kind {
0
} else if client_kind == provider_kind {
1
} else if client_family == provider_family {
2
} else {
3
};
Some((
preference_bucket,
standard_api_family_priority(provider_family),
))
}
pub fn request_candidate_api_formats( pub fn request_candidate_api_formats(
client_api_format: &str, client_api_format: &str,
_require_streaming: bool, _require_streaming: bool,
) -> Vec<&'static str> { ) -> Vec<&'static str> {
let client_api_format = client_api_format.trim().to_ascii_lowercase(); let client_api_format = client_api_format.trim().to_ascii_lowercase();
match client_api_format.as_str() { if client_api_format == "openai:compact" {
"openai:chat" | "openai:cli" | "claude:chat" | "claude:cli" | "gemini:chat" return vec!["openai:compact"];
| "gemini:cli" => NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS.to_vec(),
"openai:compact" => vec!["openai:compact"],
_ => Vec::new(),
} }
if parse_non_compact_standard_api_format(client_api_format.as_str()).is_none() {
return Vec::new();
}
let mut candidate_api_formats = NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS.to_vec();
candidate_api_formats.sort_by_key(|provider_api_format| {
request_candidate_api_format_preference(client_api_format.as_str(), provider_api_format)
.unwrap_or((u8::MAX, u8::MAX))
});
candidate_api_formats
} }
pub fn request_conversion_kind( pub fn request_conversion_kind(
@@ -259,6 +298,21 @@ fn is_standard_api_format(api_format: &str) -> bool {
) )
} }
fn parse_non_compact_standard_api_format(api_format: &str) -> Option<(&str, &str)> {
let (family, kind) = api_format.split_once(':')?;
if !STANDARD_API_FAMILY_ORDER.contains(&family) || !matches!(kind, "chat" | "cli") {
return None;
}
Some((family, kind))
}
fn standard_api_family_priority(family: &str) -> u8 {
STANDARD_API_FAMILY_ORDER
.iter()
.position(|candidate| *candidate == family)
.unwrap_or(STANDARD_API_FAMILY_ORDER.len()) as u8
}
fn api_data_format_id(api_format: &str) -> Option<&'static str> { fn api_data_format_id(api_format: &str) -> Option<&'static str> {
match api_format { match api_format {
"claude:chat" | "claude:cli" => Some("claude"), "claude:chat" | "claude:cli" => Some("claude"),
@@ -315,12 +369,12 @@ fn json_format_list_contains(value: &serde_json::Value, api_format: &str) -> boo
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
request_candidate_api_formats, request_conversion_direct_auth, request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_enabled_for_transport, request_conversion_kind, request_conversion_direct_auth, request_conversion_enabled_for_transport,
request_conversion_requires_enable_flag, request_conversion_transport_supported, request_conversion_kind, request_conversion_requires_enable_flag,
request_pair_allowed_for_transport, sync_chat_response_conversion_kind, request_conversion_transport_supported, request_pair_allowed_for_transport,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind, sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
SyncCliResponseConversionKind, RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
}; };
use aether_provider_transport::snapshot::{ use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey, GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
@@ -406,33 +460,33 @@ mod tests {
request_candidate_api_formats("openai:chat", false), request_candidate_api_formats("openai:chat", false),
vec![ vec![
"openai:chat", "openai:chat",
"openai:cli",
"claude:chat", "claude:chat",
"claude:cli",
"gemini:chat", "gemini:chat",
"openai:cli",
"claude:cli",
"gemini:cli", "gemini:cli",
] ]
); );
assert_eq!( assert_eq!(
request_candidate_api_formats("openai:cli", false), request_candidate_api_formats("openai:cli", false),
vec![ vec![
"openai:chat",
"openai:cli", "openai:cli",
"claude:chat",
"claude:cli", "claude:cli",
"gemini:chat",
"gemini:cli", "gemini:cli",
"openai:chat",
"claude:chat",
"gemini:chat",
] ]
); );
assert_eq!( assert_eq!(
request_candidate_api_formats("claude:cli", false), request_candidate_api_formats("claude:cli", false),
vec![ vec![
"openai:chat",
"openai:cli",
"claude:chat",
"claude:cli", "claude:cli",
"gemini:chat", "openai:cli",
"gemini:cli", "gemini:cli",
"claude:chat",
"openai:chat",
"gemini:chat",
] ]
); );
assert_eq!( assert_eq!(
@@ -441,6 +495,22 @@ mod tests {
); );
} }
#[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))
);
}
#[test] #[test]
fn request_conversion_enable_flag_only_applies_to_real_data_format_conversions() { fn request_conversion_enable_flag_only_applies_to_real_data_format_conversions() {
assert!(!request_conversion_requires_enable_flag( assert!(!request_conversion_requires_enable_flag(

View File

@@ -305,11 +305,14 @@ pub struct DecisionTraceCandidate {
pub provider_type: Option<String>, pub provider_type: Option<String>,
pub provider_priority: Option<i32>, pub provider_priority: Option<i32>,
pub provider_keep_priority_on_conversion: Option<bool>, pub provider_keep_priority_on_conversion: Option<bool>,
pub provider_enable_format_conversion: Option<bool>,
pub endpoint_api_format: Option<String>, pub endpoint_api_format: Option<String>,
pub endpoint_api_family: Option<String>, pub endpoint_api_family: Option<String>,
pub endpoint_kind: Option<String>, pub endpoint_kind: Option<String>,
pub endpoint_format_acceptance_config: Option<serde_json::Value>,
pub provider_key_name: Option<String>, pub provider_key_name: Option<String>,
pub provider_key_auth_type: Option<String>, pub provider_key_auth_type: Option<String>,
pub provider_key_api_formats: Option<serde_json::Value>,
pub provider_key_internal_priority: Option<i32>, pub provider_key_internal_priority: Option<i32>,
pub provider_key_global_priority_by_format: Option<serde_json::Value>, pub provider_key_global_priority_by_format: Option<serde_json::Value>,
pub provider_key_capabilities: Option<serde_json::Value>, pub provider_key_capabilities: Option<serde_json::Value>,
@@ -384,13 +387,17 @@ fn enrich_decision_trace_candidate(
provider_type: provider.map(|item| item.provider_type.clone()), provider_type: provider.map(|item| item.provider_type.clone()),
provider_priority: provider.map(|item| item.provider_priority), provider_priority: provider.map(|item| item.provider_priority),
provider_keep_priority_on_conversion: provider.map(|item| item.keep_priority_on_conversion), provider_keep_priority_on_conversion: provider.map(|item| item.keep_priority_on_conversion),
provider_enable_format_conversion: provider.map(|item| item.enable_format_conversion),
endpoint_api_format: endpoint.map(|item| item.api_format.clone()), endpoint_api_format: endpoint.map(|item| item.api_format.clone()),
endpoint_api_family: endpoint.and_then(|item| item.api_family.clone()), endpoint_api_family: endpoint.and_then(|item| item.api_family.clone()),
endpoint_kind: endpoint.and_then(|item| item.endpoint_kind.clone()), endpoint_kind: endpoint.and_then(|item| item.endpoint_kind.clone()),
endpoint_format_acceptance_config: endpoint
.and_then(|item| item.format_acceptance_config.clone()),
provider_key_name: provider_key provider_key_name: provider_key
.map(|item| item.name.clone()) .map(|item| item.name.clone())
.or_else(|| candidate.api_key_name.clone()), .or_else(|| candidate.api_key_name.clone()),
provider_key_auth_type: provider_key.map(|item| item.auth_type.clone()), provider_key_auth_type: provider_key.map(|item| item.auth_type.clone()),
provider_key_api_formats: provider_key.and_then(|item| item.api_formats.clone()),
provider_key_internal_priority: provider_key.map(|item| item.internal_priority), provider_key_internal_priority: provider_key.map(|item| item.internal_priority),
provider_key_global_priority_by_format: provider_key provider_key_global_priority_by_format: provider_key
.and_then(|item| item.global_priority_by_format.clone()), .and_then(|item| item.global_priority_by_format.clone()),

View File

@@ -259,11 +259,14 @@ mod tests {
provider_type: Some("custom".to_string()), provider_type: Some("custom".to_string()),
provider_priority: Some(0), provider_priority: Some(0),
provider_keep_priority_on_conversion: Some(false), provider_keep_priority_on_conversion: Some(false),
provider_enable_format_conversion: Some(false),
endpoint_api_format: Some("openai:chat".to_string()), endpoint_api_format: Some("openai:chat".to_string()),
endpoint_api_family: Some("openai".to_string()), endpoint_api_family: Some("openai".to_string()),
endpoint_kind: Some("chat".to_string()), endpoint_kind: Some("chat".to_string()),
endpoint_format_acceptance_config: None,
provider_key_name: Some("prod".to_string()), provider_key_name: Some("prod".to_string()),
provider_key_auth_type: Some("api_key".to_string()), provider_key_auth_type: Some("api_key".to_string()),
provider_key_api_formats: None,
provider_key_internal_priority: Some(10), provider_key_internal_priority: Some(10),
provider_key_global_priority_by_format: None, provider_key_global_priority_by_format: None,
provider_key_capabilities: None, provider_key_capabilities: None,

View File

@@ -10,13 +10,18 @@ export interface CandidateRecord {
provider_website?: string // Provider 官网 provider_website?: string // Provider 官网
provider_priority?: number provider_priority?: number
provider_keep_priority_on_conversion?: boolean provider_keep_priority_on_conversion?: boolean
provider_enable_format_conversion?: boolean
endpoint_id?: string endpoint_id?: string
endpoint_name?: string // 端点显示名称api_format endpoint_name?: string // 端点显示名称api_format
endpoint_api_family?: string
endpoint_kind?: string
endpoint_format_acceptance_config?: Record<string, unknown> | null
key_id?: string key_id?: string
key_name?: string // 密钥名称 key_name?: string // 密钥名称
key_account_label?: string // 更适合展示的测试账号标签(优先 OAuth 邮箱) key_account_label?: string // 更适合展示的测试账号标签(优先 OAuth 邮箱)
key_preview?: string // 密钥脱敏预览(如 sk-***abcOAuth 类型不返回 key_preview?: string // 密钥脱敏预览(如 sk-***abcOAuth 类型不返回
key_auth_type?: string // 密钥认证类型api_key, service_account, oauth 等) key_auth_type?: string // 密钥认证类型api_key, service_account, oauth 等)
key_api_formats?: unknown
key_internal_priority?: number key_internal_priority?: number
key_global_priority_by_format?: Record<string, number> | null key_global_priority_by_format?: Record<string, number> | null
key_oauth_plan_type?: string // OAuth 账号套餐类型free/plus/team/enterprise key_oauth_plan_type?: string // OAuth 账号套餐类型free/plus/team/enterprise

View File

@@ -250,6 +250,30 @@
>{{ currentAttempt.key_preview }}</code> >{{ currentAttempt.key_preview }}</code>
</span> </span>
</div> </div>
<div
v-if="currentAttemptKeyFormatsDisplay"
class="info-item"
>
<span class="info-label">支持端点</span>
<span class="info-value info-value-stacked">
<code class="format-code">{{ currentAttemptKeyFormatsDisplay }}</code>
<span class="text-xs text-muted-foreground">
同一 Key 的不同 endpoint 会分别参与候选与转换判定
</span>
</span>
</div>
<div
v-if="currentAttemptConversionInfo"
class="info-item"
>
<span class="info-label">转换策略</span>
<span class="info-value info-value-stacked">
<code class="format-code">{{ currentAttemptConversionInfo.summary }}</code>
<span class="text-xs text-muted-foreground">
{{ currentAttemptConversionInfo.hint }}
</span>
</span>
</div>
<div <div
v-if="currentAttempt.extra_data?.proxy" v-if="currentAttempt.extra_data?.proxy"
class="info-item" class="info-item"
@@ -1122,6 +1146,31 @@ const normalizeFormatSignature = (value: string): string => {
return value.trim().toLowerCase() return value.trim().toLowerCase()
} }
const extractObject = (value: unknown): Record<string, unknown> | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
const extractStringList = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value
.map(item => typeof item === 'string' ? item.trim() : '')
.filter(Boolean)
}
if (typeof value === 'string') {
const raw = value.trim()
if (!raw) return []
try {
return extractStringList(JSON.parse(raw))
} catch {
return [raw]
}
}
return []
}
const normalizePriorityNumber = (value: unknown): number | null => { const normalizePriorityNumber = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
return Math.trunc(value) return Math.trunc(value)
@@ -1163,14 +1212,22 @@ const resolveProviderApiFormat = (attempt: CandidateRecord): string => {
return '' return ''
} }
const resolveTransportDiagnostics = (attempt: CandidateRecord): Record<string, unknown> | null => {
const extra = extractObject(attempt.extra_data)
return extractObject(extra?.transport_diagnostics)
}
const resolveEndpointFormatAcceptanceConfig = (attempt: CandidateRecord): Record<string, unknown> | null => {
const fromAttempt = extractObject(attempt.endpoint_format_acceptance_config)
if (fromAttempt) return fromAttempt
const transport = resolveTransportDiagnostics(attempt)
return extractObject(transport?.endpoint_format_acceptance_config)
}
const currentAttemptFormatDisplay = computed(() => { const currentAttemptFormatDisplay = computed(() => {
const attempt = currentAttempt.value const attempt = currentAttempt.value
if (!attempt) return '' if (!attempt) return ''
const extra = ( const extra = extractObject(attempt.extra_data) ?? {}
attempt.extra_data && typeof attempt.extra_data === 'object' && !Array.isArray(attempt.extra_data)
? attempt.extra_data
: {}
) as Record<string, unknown>
const providerRaw = typeof extra.provider_api_format === 'string' ? extra.provider_api_format : '' const providerRaw = typeof extra.provider_api_format === 'string' ? extra.provider_api_format : ''
const clientRawFromExtra = typeof extra.client_api_format === 'string' ? extra.client_api_format : '' const clientRawFromExtra = typeof extra.client_api_format === 'string' ? extra.client_api_format : ''
@@ -1224,14 +1281,14 @@ const currentAttemptSchedulerInfo = computed<{
) )
const keepPriorityOnConversion = attempt.provider_keep_priority_on_conversion === true const keepPriorityOnConversion = attempt.provider_keep_priority_on_conversion === true
let hint = '链路按实际调度顺序展示' let hint = '顺位展示 Provider / Key 优先级;不同 endpoint 独立参与候选'
if (globalPriority !== null) { if (globalPriority !== null) {
hint = `当前格式 ${formatApiFormat(clientApiFormat)} 先看全局 Key 优先级` hint = `当前格式 ${formatApiFormat(clientApiFormat)} 先看全局 Key 优先级;不同 endpoint 单独判定`
} }
if (isCrossFormat) { if (isCrossFormat) {
hint = keepPriorityOnConversion hint = keepPriorityOnConversion
? '跨格式候选已开启保持优先级' ? '跨格式候选已开启保持优先级;同一 Key 的不同 endpoint 独立参与'
: '跨格式候选默认排在同格式候选之后' : '跨格式候选默认排在同格式候选之后;同一 Key 的不同 endpoint 独立参与'
} }
return { return {
@@ -1242,6 +1299,77 @@ const currentAttemptSchedulerInfo = computed<{
} }
}) })
const currentAttemptKeyFormatsDisplay = computed(() => {
const attempt = currentAttempt.value
if (!attempt) return ''
const formats = extractStringList(attempt.key_api_formats)
if (!formats.length) return ''
return formats
.map(format => formatApiFormat(format))
.join(' / ')
})
const currentAttemptConversionInfo = computed<{
summary: string
hint: string
} | null>(() => {
const attempt = currentAttempt.value
if (!attempt) return null
const clientApiFormat = resolveClientApiFormat(attempt)
const providerApiFormat = resolveProviderApiFormat(attempt)
if (!clientApiFormat || !providerApiFormat) return null
const isCrossFormat = normalizeFormatSignature(clientApiFormat) !== normalizeFormatSignature(providerApiFormat)
if (!isCrossFormat) {
return {
summary: '同格式直连',
hint: '当前候选直接命中 endpoint 原生格式',
}
}
const transportDiagnostics = resolveTransportDiagnostics(attempt)
const providerEnabled = attempt.provider_enable_format_conversion === true
|| transportDiagnostics?.provider_enable_format_conversion === true
const endpointConfig = resolveEndpointFormatAcceptanceConfig(attempt)
const endpointRuleEnabled = endpointConfig
? endpointConfig.enabled !== false
: false
const acceptFormats = extractStringList(endpointConfig?.accept_formats)
const rejectFormats = extractStringList(endpointConfig?.reject_formats)
const normalizedClientFormat = normalizeFormatSignature(clientApiFormat)
const endpointAcceptsClient = acceptFormats.some(
format => normalizeFormatSignature(format) === normalizedClientFormat,
)
const endpointRejectsClient = rejectFormats.some(
format => normalizeFormatSignature(format) === normalizedClientFormat,
)
let summary = '未开启格式转换'
if (providerEnabled && endpointRuleEnabled) {
summary = 'Provider 总开关 + Endpoint 规则'
} else if (providerEnabled) {
summary = 'Provider 总格式转换'
} else if (endpointRuleEnabled) {
summary = 'Endpoint 独立格式转换'
}
let hint = '同一 Key 的不同 endpoint 会分别判定格式转换'
if (endpointRejectsClient) {
hint = `当前 endpoint 明确拒绝 ${formatApiFormat(clientApiFormat)}`
} else if (endpointAcceptsClient) {
hint = `当前 endpoint 明确接受 ${formatApiFormat(clientApiFormat)}`
} else if (providerEnabled) {
hint = '当前跨格式由 Provider 总开关放行'
} else if (attempt.skip_reason === 'format_conversion_disabled') {
hint = 'Provider 总开关关闭,且当前 endpoint 未单独放行'
}
return { summary, hint }
})
// 计算当前尝试启用的能力标签(请求需要的能力) // 计算当前尝试启用的能力标签(请求需要的能力)
const activeCapabilities = computed(() => { const activeCapabilities = computed(() => {
if (!currentAttempt.value?.required_capabilities) return [] if (!currentAttempt.value?.required_capabilities) return []