mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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,
|
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))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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})),
|
||||||
|
|||||||
@@ -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")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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-***abc),OAuth 类型不返回
|
key_preview?: string // 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
|
||||||
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)
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
Reference in New Issue
Block a user