feat(gateway): 增强候选路由策略与可观测信息

This commit is contained in:
fawney19
2026-04-14 11:50:52 +08:00
parent 62e0a0338d
commit 593640ac19
44 changed files with 1828 additions and 302 deletions

View File

@@ -5,11 +5,12 @@ 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_conversion_kind, 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_requires_enable_flag, request_conversion_transport_supported,
request_pair_allowed_for_transport, sync_chat_response_conversion_kind, request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind, sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, RequestConversionKind,
SyncCliResponseConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
}; };
#[cfg(test)] #[cfg(test)]

View File

@@ -1092,6 +1092,83 @@ mod tests {
assert_eq!(skipped[0].skip_reason, "format_conversion_disabled"); assert_eq!(skipped[0].skip_reason, "format_conversion_disabled");
} }
#[tokio::test]
async fn realtime_gate_allows_cross_format_candidates_when_endpoint_acceptance_is_enabled() {
let mut endpoint_cross =
sample_endpoint_for_provider("provider-cross", "endpoint-cross", "claude:chat");
endpoint_cross.format_acceptance_config = Some(json!({
"enabled": true,
"accept_formats": ["openai:chat"],
}));
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-cross", false, 0),
sample_provider_with_options("provider-same", false, 10),
],
vec![
endpoint_cross,
sample_endpoint_for_provider("provider-same", "endpoint-same", "openai:chat"),
],
vec![
sample_key_for_provider_with_options(
"provider-cross",
"key-cross",
"",
true,
Some(json!(["claude:chat"])),
None,
),
sample_key_for_provider_with_options(
"provider-same",
"key-same",
"",
true,
Some(json!(["openai:chat"])),
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, skipped) = filter_and_rank_local_execution_candidates(
PlannerAppState::new(&state),
vec![
sample_priority_candidate(
"provider-cross",
"endpoint-cross",
"key-cross",
"claude:chat",
Some(0),
0,
),
sample_priority_candidate(
"provider-same",
"endpoint-same",
"key-same",
"openai:chat",
Some(10),
10,
),
],
"openai:chat",
"gpt-4.1",
None,
)
.await;
assert_eq!(ranked.len(), 2);
assert_eq!(ranked[0].candidate.endpoint_id, "endpoint-same");
assert_eq!(ranked[1].candidate.endpoint_id, "endpoint-cross");
assert!(skipped.is_empty());
}
#[tokio::test] #[tokio::test]
async fn remembers_scheduler_affinity_for_candidate_using_requested_model_key() { async fn remembers_scheduler_affinity_for_candidate_using_requested_model_key() {
let state = AppState::new().expect("state should build"); let state = AppState::new().expect("state should build");

View File

@@ -17,6 +17,8 @@ pub(crate) struct EligibleLocalExecutionCandidate {
pub(crate) struct SkippedLocalExecutionCandidate { pub(crate) struct SkippedLocalExecutionCandidate {
pub(crate) candidate: SchedulerMinimalCandidateSelectionCandidate, pub(crate) candidate: SchedulerMinimalCandidateSelectionCandidate,
pub(crate) skip_reason: &'static str, pub(crate) skip_reason: &'static str,
pub(crate) transport: Option<GatewayProviderTransportSnapshot>,
pub(crate) extra_data: Option<serde_json::Value>,
} }
pub(crate) async fn filter_and_rank_local_execution_candidates( pub(crate) async fn filter_and_rank_local_execution_candidates(
@@ -96,6 +98,8 @@ where
skipped.push(SkippedLocalExecutionCandidate { skipped.push(SkippedLocalExecutionCandidate {
candidate, candidate,
skip_reason: "transport_snapshot_missing", skip_reason: "transport_snapshot_missing",
transport: None,
extra_data: None,
}); });
continue; continue;
}; };
@@ -103,6 +107,8 @@ where
Some(skip_reason) => skipped.push(SkippedLocalExecutionCandidate { Some(skip_reason) => skipped.push(SkippedLocalExecutionCandidate {
candidate, candidate,
skip_reason, skip_reason,
transport: Some(transport),
extra_data: None,
}), }),
None => selectable.push(EligibleLocalExecutionCandidate { None => selectable.push(EligibleLocalExecutionCandidate {
provider_api_format: transport.endpoint.api_format.trim().to_ascii_lowercase(), provider_api_format: transport.endpoint.api_format.trim().to_ascii_lowercase(),
@@ -190,8 +196,11 @@ fn current_local_execution_candidate_skip_reason_with_transport(
client_api_format.as_str(), client_api_format.as_str(),
endpoint_api_format.as_str(), endpoint_api_format.as_str(),
) )
&& !transport.provider.enable_format_conversion && !crate::ai_pipeline::conversion::request_conversion_enabled_for_transport(
{ transport,
client_api_format.as_str(),
endpoint_api_format.as_str(),
) {
"format_conversion_disabled" "format_conversion_disabled"
} else { } else {
"transport_unsupported" "transport_unsupported"

View File

@@ -136,6 +136,7 @@ pub(crate) async fn persist_skipped_local_execution_candidate(
candidate_id: &str, candidate_id: &str,
required_capabilities: Option<&Value>, required_capabilities: Option<&Value>,
skip_reason: &'static str, skip_reason: &'static str,
extra_data: Option<Value>,
error_context: &'static str, error_context: &'static str,
record_runtime_miss_diagnostic: bool, record_runtime_miss_diagnostic: bool,
) { ) {
@@ -153,6 +154,7 @@ pub(crate) async fn persist_skipped_local_execution_candidate(
candidate_id, candidate_id,
required_capabilities, required_capabilities,
skip_reason, skip_reason,
extra_data,
current_unix_ms(), current_unix_ms(),
error_context, error_context,
) )
@@ -178,6 +180,7 @@ pub(crate) async fn mark_skipped_local_execution_candidate(
candidate_id, candidate_id,
context.required_capabilities, context.required_capabilities,
skip_reason, skip_reason,
None,
context.error_context, context.error_context,
context.record_runtime_miss_diagnostic, context.record_runtime_miss_diagnostic,
) )
@@ -208,6 +211,7 @@ pub(crate) async fn persist_skipped_local_execution_candidates(
&generated_candidate_id, &generated_candidate_id,
required_capabilities, required_capabilities,
skipped_candidate.skip_reason, skipped_candidate.skip_reason,
skipped_candidate.extra_data,
error_context, error_context,
record_runtime_miss_diagnostic, record_runtime_miss_diagnostic,
) )

View File

@@ -1,6 +1,19 @@
use serde_json::{Map, Value}; use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use serde_json::{json, Map, Value};
use crate::ai_pipeline::planner::candidate_eligibility::EligibleLocalExecutionCandidate; use crate::ai_pipeline::planner::candidate_eligibility::EligibleLocalExecutionCandidate;
use crate::ai_pipeline::transport::{
body_rules_are_locally_supported, header_rules_are_locally_supported,
local_gemini_transport_unsupported_reason_with_network,
local_openai_chat_transport_unsupported_reason,
local_standard_transport_unsupported_reason_with_network, resolve_transport_tls_profile,
supports_local_oauth_request_auth_resolution, transport_proxy_is_locally_supported,
};
use crate::ai_pipeline::{
request_conversion_enabled_for_transport, request_conversion_kind,
request_conversion_requires_enable_flag, request_conversion_transport_unsupported_reason,
request_pair_allowed_for_transport, GatewayProviderTransportSnapshot,
};
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy}; use crate::ai_pipeline::{ConversionMode, ExecutionStrategy};
use crate::append_execution_contract_fields_to_value; use crate::append_execution_contract_fields_to_value;
@@ -14,15 +27,30 @@ pub(crate) struct LocalExecutionCandidateMetadataParts<'a> {
pub(crate) fn build_local_execution_candidate_metadata( pub(crate) fn build_local_execution_candidate_metadata(
parts: LocalExecutionCandidateMetadataParts<'_>, parts: LocalExecutionCandidateMetadataParts<'_>,
) -> Value { ) -> Value {
let candidate = &parts.eligible.candidate; build_local_execution_candidate_metadata_for_candidate(
&parts.eligible.candidate,
Some(&parts.eligible.transport),
parts.provider_api_format,
parts.client_api_format,
parts.extra_fields,
)
}
pub(crate) fn build_local_execution_candidate_metadata_for_candidate(
candidate: &SchedulerMinimalCandidateSelectionCandidate,
transport: Option<&GatewayProviderTransportSnapshot>,
provider_api_format: &str,
client_api_format: &str,
extra_fields: Map<String, Value>,
) -> Value {
let mut object = Map::new(); let mut object = Map::new();
object.insert( object.insert(
"provider_api_format".to_string(), "provider_api_format".to_string(),
Value::String(parts.provider_api_format.to_string()), Value::String(provider_api_format.to_string()),
); );
object.insert( object.insert(
"client_api_format".to_string(), "client_api_format".to_string(),
Value::String(parts.client_api_format.to_string()), Value::String(client_api_format.to_string()),
); );
object.insert( object.insert(
"global_model_id".to_string(), "global_model_id".to_string(),
@@ -56,8 +84,13 @@ pub(crate) fn build_local_execution_candidate_metadata(
"key_name".to_string(), "key_name".to_string(),
Value::String(candidate.key_name.clone()), Value::String(candidate.key_name.clone()),
); );
object.extend(parts.extra_fields); object.extend(extra_fields);
Value::Object(object) append_transport_diagnostics_to_value(
Value::Object(object),
transport,
client_api_format,
provider_api_format,
)
} }
pub(crate) fn build_local_execution_candidate_contract_metadata( pub(crate) fn build_local_execution_candidate_contract_metadata(
@@ -66,12 +99,323 @@ pub(crate) fn build_local_execution_candidate_contract_metadata(
conversion_mode: ConversionMode, conversion_mode: ConversionMode,
provider_contract: &str, provider_contract: &str,
) -> Value { ) -> Value {
let client_api_format = parts.client_api_format;
append_execution_contract_fields_to_value( append_execution_contract_fields_to_value(
build_local_execution_candidate_metadata(parts), build_local_execution_candidate_metadata_for_candidate(
&parts.eligible.candidate,
Some(&parts.eligible.transport),
parts.provider_api_format,
parts.client_api_format,
parts.extra_fields,
),
execution_strategy,
conversion_mode,
parts.client_api_format,
provider_contract,
)
}
pub(crate) fn build_local_execution_candidate_contract_metadata_for_candidate(
candidate: &SchedulerMinimalCandidateSelectionCandidate,
transport: Option<&GatewayProviderTransportSnapshot>,
provider_api_format: &str,
client_api_format: &str,
extra_fields: Map<String, Value>,
execution_strategy: ExecutionStrategy,
conversion_mode: ConversionMode,
provider_contract: &str,
) -> Value {
append_execution_contract_fields_to_value(
build_local_execution_candidate_metadata_for_candidate(
candidate,
transport,
provider_api_format,
client_api_format,
extra_fields,
),
execution_strategy, execution_strategy,
conversion_mode, conversion_mode,
client_api_format, client_api_format,
provider_contract, provider_contract,
) )
} }
fn append_transport_diagnostics_to_value(
value: Value,
transport: Option<&GatewayProviderTransportSnapshot>,
client_api_format: &str,
provider_api_format: &str,
) -> Value {
let Value::Object(mut object) = value else {
return value;
};
object.insert(
"transport_diagnostics".to_string(),
transport
.map(|transport| {
build_transport_diagnostics(transport, client_api_format, provider_api_format)
})
.unwrap_or_else(|| json!({ "transport_snapshot_available": false })),
);
Value::Object(object)
}
fn build_transport_diagnostics(
transport: &GatewayProviderTransportSnapshot,
client_api_format: &str,
provider_api_format: &str,
) -> Value {
let resolved_tls_profile = resolve_transport_tls_profile(transport);
let configured_tls_profile = transport
.key
.fingerprint
.as_ref()
.and_then(Value::as_object)
.and_then(|value| value.get("tls_profile"))
.cloned()
.unwrap_or(Value::Null);
let has_oauth_config = transport.key.decrypted_auth_config.is_some();
let oauth_resolution_supported =
!has_oauth_config || supports_local_oauth_request_auth_resolution(transport);
let request_transport_unsupported_reason = resolve_request_transport_unsupported_reason(
transport,
client_api_format,
provider_api_format,
);
json!({
"transport_snapshot_available": true,
"provider_type": transport.provider.provider_type,
"provider_is_active": transport.provider.is_active,
"endpoint_is_active": transport.endpoint.is_active,
"key_is_active": transport.key.is_active,
"provider_enable_format_conversion": transport.provider.enable_format_conversion,
"provider_keep_priority_on_conversion": transport.provider.keep_priority_on_conversion,
"endpoint_format_acceptance_config": transport.endpoint.format_acceptance_config,
"endpoint_custom_path": transport.endpoint.custom_path,
"header_rules": transport.endpoint.header_rules,
"header_rules_supported": header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()),
"body_rules": transport.endpoint.body_rules,
"body_rules_supported": body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref()),
"proxy": {
"locally_supported": transport_proxy_is_locally_supported(transport),
"provider": summarize_proxy_config(transport.provider.proxy.as_ref()),
"endpoint": summarize_proxy_config(transport.endpoint.proxy.as_ref()),
"key": summarize_proxy_config(transport.key.proxy.as_ref()),
},
"auth": {
"key_auth_type": transport.key.auth_type,
"has_oauth_config": has_oauth_config,
"oauth_request_auth_resolution_supported": oauth_resolution_supported,
},
"fingerprint": transport.key.fingerprint,
"configured_tls_profile": configured_tls_profile,
"resolved_tls_profile": resolved_tls_profile,
"request_pair": {
"client_api_format": client_api_format,
"provider_api_format": provider_api_format,
"requires_conversion_enable_flag": request_conversion_requires_enable_flag(
client_api_format,
provider_api_format,
),
"conversion_enabled": request_conversion_enabled_for_transport(
transport,
client_api_format,
provider_api_format,
),
"pair_allowed": request_pair_allowed_for_transport(
transport,
client_api_format,
provider_api_format,
),
"transport_unsupported_reason": request_transport_unsupported_reason,
},
})
}
fn summarize_proxy_config(proxy: Option<&Value>) -> Value {
let Some(object) = proxy.and_then(Value::as_object) else {
return Value::Null;
};
let has_url = object
.get("url")
.or_else(|| object.get("proxy_url"))
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
json!({
"enabled": object.get("enabled").cloned().unwrap_or(Value::Null),
"mode": object.get("mode").cloned().unwrap_or(Value::Null),
"node_id": object.get("node_id").cloned().unwrap_or(Value::Null),
"label": object.get("label").cloned().unwrap_or(Value::Null),
"has_url": has_url,
})
}
fn resolve_request_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot,
client_api_format: &str,
provider_api_format: &str,
) -> Option<&'static str> {
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 == provider_api_format {
return match provider_api_format.as_str() {
"openai:chat" => local_openai_chat_transport_unsupported_reason(transport),
"gemini:chat" | "gemini:cli" => local_gemini_transport_unsupported_reason_with_network(
transport,
provider_api_format.as_str(),
),
_ => local_standard_transport_unsupported_reason_with_network(
transport,
provider_api_format.as_str(),
),
};
}
match request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str()) {
Some(kind) => request_conversion_transport_unsupported_reason(transport, kind),
None => Some("transport_api_format_unsupported"),
}
}
#[cfg(test)]
mod tests {
use super::{
build_local_execution_candidate_contract_metadata_for_candidate,
build_local_execution_candidate_metadata_for_candidate,
};
use crate::ai_pipeline::transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider,
};
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, GatewayProviderTransportSnapshot};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use serde_json::{json, Value};
fn sample_candidate() -> SchedulerMinimalCandidateSelectionCandidate {
SchedulerMinimalCandidateSelectionCandidate {
provider_id: "provider-1".to_string(),
provider_name: "RightCode".to_string(),
provider_type: "codex".to_string(),
provider_priority: 22,
endpoint_id: "endpoint-1".to_string(),
endpoint_api_format: "openai:cli".to_string(),
key_id: "key-1".to_string(),
key_name: "codex".to_string(),
key_auth_type: "oauth".to_string(),
key_internal_priority: 10,
key_global_priority_for_format: None,
key_capabilities: None,
model_id: "model-1".to_string(),
global_model_id: "global-1".to_string(),
global_model_name: "gpt-5.4".to_string(),
selected_provider_model_name: "gpt-5.4".to_string(),
mapping_matched_model: None,
}
}
fn sample_transport() -> GatewayProviderTransportSnapshot {
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "RightCode".to_string(),
provider_type: "codex".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: true,
concurrent_limit: None,
max_retries: None,
proxy: Some(json!({"enabled": true, "mode": "node", "node_id": "proxy-node-1"})),
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "openai:cli".to_string(),
api_family: None,
endpoint_kind: None,
is_active: true,
base_url: "https://example.com".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: Some("/v1/responses".to_string()),
config: None,
format_acceptance_config: Some(json!({
"enabled": true,
"accept_formats": ["claude:cli"]
})),
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "codex".to_string(),
auth_type: "oauth".to_string(),
is_active: true,
api_formats: None,
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: Some(json!({
"tls_profile": "chrome_136",
"user_agent": "Mozilla/5.0"
})),
decrypted_api_key: "sk-test".to_string(),
decrypted_auth_config: None,
},
}
}
#[test]
fn candidate_contract_metadata_includes_transport_diagnostics() {
let metadata = build_local_execution_candidate_contract_metadata_for_candidate(
&sample_candidate(),
Some(&sample_transport()),
"openai:cli",
"claude:cli",
serde_json::Map::new(),
ExecutionStrategy::LocalCrossFormat,
ConversionMode::Bidirectional,
"openai:cli",
);
assert_eq!(metadata["transport_diagnostics"]["provider_type"], "codex");
assert_eq!(
metadata["transport_diagnostics"]["fingerprint"]["tls_profile"],
"chrome_136"
);
assert_eq!(
metadata["transport_diagnostics"]["resolved_tls_profile"],
"chrome_136"
);
assert_eq!(
metadata["transport_diagnostics"]["request_pair"]["conversion_enabled"],
Value::Bool(true)
);
assert!(
metadata["transport_diagnostics"]["request_pair"]["transport_unsupported_reason"]
.is_null()
);
}
#[test]
fn candidate_metadata_marks_missing_transport_snapshot() {
let metadata = build_local_execution_candidate_metadata_for_candidate(
&sample_candidate(),
None,
"openai:cli",
"openai:cli",
serde_json::Map::new(),
);
assert_eq!(
metadata["transport_diagnostics"]["transport_snapshot_available"],
Value::Bool(false)
);
}
}

View File

@@ -1,13 +1,17 @@
use tracing::warn; use tracing::warn;
use crate::ai_pipeline::planner::candidate_eligibility::filter_and_rank_local_execution_candidates; use crate::ai_pipeline::planner::candidate_eligibility::{
filter_and_rank_local_execution_candidates, SkippedLocalExecutionCandidate,
};
use crate::ai_pipeline::planner::candidate_materialization::{ use crate::ai_pipeline::planner::candidate_materialization::{
persist_available_local_execution_candidates_with_context, persist_available_local_execution_candidates_with_context,
persist_skipped_local_execution_candidates_with_context, persist_skipped_local_execution_candidates_with_context,
remember_first_local_candidate_affinity, remember_first_local_candidate_affinity,
}; };
use crate::ai_pipeline::planner::candidate_metadata::{ use crate::ai_pipeline::planner::candidate_metadata::{
build_local_execution_candidate_contract_metadata, LocalExecutionCandidateMetadataParts, build_local_execution_candidate_contract_metadata,
build_local_execution_candidate_contract_metadata_for_candidate,
LocalExecutionCandidateMetadataParts,
}; };
use crate::ai_pipeline::planner::common::extract_requested_model_from_request; use crate::ai_pipeline::planner::common::extract_requested_model_from_request;
use crate::ai_pipeline::planner::decision_input::{ use crate::ai_pipeline::planner::decision_input::{
@@ -90,8 +94,8 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::SameFormatProviderDecision, LocalCandidatePersistencePolicyKind::SameFormatProviderDecision,
); );
let candidates = planner_state let (candidates, preselection_skipped) = planner_state
.list_selectable_candidates( .list_selectable_candidates_with_skip_reasons(
spec_metadata.api_format, spec_metadata.api_format,
&input.requested_model, &input.requested_model,
spec_metadata.require_streaming, spec_metadata.require_streaming,
@@ -108,6 +112,36 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
) )
.await; .await;
let skipped_candidates = preselection_skipped
.into_iter()
.map(|item| SkippedLocalExecutionCandidate {
candidate: item.candidate,
skip_reason: item.skip_reason,
transport: None,
extra_data: None,
})
.chain(skipped_candidates)
.map(|mut skipped_candidate| {
let provider_api_format = skipped_candidate
.transport
.as_ref()
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
.unwrap_or_else(|| spec_metadata.api_format.to_string());
skipped_candidate.extra_data = Some(
build_local_execution_candidate_contract_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport.as_ref(),
provider_api_format.as_str(),
spec_metadata.api_format,
serde_json::Map::new(),
ExecutionStrategy::LocalSameFormat,
ConversionMode::None,
provider_api_format.as_str(),
),
);
skipped_candidate
})
.collect::<Vec<_>>();
let candidate_count = candidates.len() + skipped_candidates.len(); let candidate_count = candidates.len() + skipped_candidates.len();
remember_first_local_candidate_affinity( remember_first_local_candidate_affinity(

View File

@@ -7,7 +7,7 @@ use crate::ai_pipeline::planner::spec_metadata::local_gemini_files_spec_metadata
use crate::ai_pipeline::transport::auth::{ use crate::ai_pipeline::transport::auth::{
build_passthrough_headers_with_auth, resolve_local_gemini_auth, build_passthrough_headers_with_auth, resolve_local_gemini_auth,
}; };
use crate::ai_pipeline::transport::policy::supports_local_gemini_transport_with_network; use crate::ai_pipeline::transport::local_gemini_transport_unsupported_reason_with_network;
use crate::ai_pipeline::transport::url::build_gemini_files_passthrough_url; use crate::ai_pipeline::transport::url::build_gemini_files_passthrough_url;
use crate::ai_pipeline::transport::{apply_local_body_rules, apply_local_header_rules}; use crate::ai_pipeline::transport::{apply_local_body_rules, apply_local_header_rules};
use crate::ai_pipeline::GatewayProviderTransportSnapshot; use crate::ai_pipeline::GatewayProviderTransportSnapshot;
@@ -47,7 +47,10 @@ pub(super) async fn resolve_local_gemini_files_candidate_payload_parts(
let candidate = &attempt.eligible.candidate; let candidate = &attempt.eligible.candidate;
let transport = &attempt.eligible.transport; let transport = &attempt.eligible.transport;
if !supports_local_gemini_transport_with_network(transport, GEMINI_FILES_CANDIDATE_API_FORMAT) { if let Some(skip_reason) = local_gemini_transport_unsupported_reason_with_network(
transport,
GEMINI_FILES_CANDIDATE_API_FORMAT,
) {
mark_skipped_local_gemini_files_candidate( mark_skipped_local_gemini_files_candidate(
state, state,
input, input,
@@ -55,7 +58,7 @@ pub(super) async fn resolve_local_gemini_files_candidate_payload_parts(
candidate, candidate,
attempt.candidate_index, attempt.candidate_index,
&attempt.candidate_id, &attempt.candidate_id,
"transport_unsupported", skip_reason,
) )
.await; .await;
return None; return None;

View File

@@ -11,7 +11,8 @@ use crate::ai_pipeline::planner::candidate_materialization::{
remember_first_local_candidate_affinity, remember_first_local_candidate_affinity,
}; };
use crate::ai_pipeline::planner::candidate_metadata::{ use crate::ai_pipeline::planner::candidate_metadata::{
build_local_execution_candidate_metadata, LocalExecutionCandidateMetadataParts, build_local_execution_candidate_metadata,
build_local_execution_candidate_metadata_for_candidate, LocalExecutionCandidateMetadataParts,
}; };
use crate::ai_pipeline::planner::decision_input::{ use crate::ai_pipeline::planner::decision_input::{
build_local_authenticated_decision_input, resolve_local_authenticated_decision_input, build_local_authenticated_decision_input, resolve_local_authenticated_decision_input,
@@ -131,7 +132,25 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
trace_id, trace_id,
persistence_policy.skipped, persistence_policy.skipped,
attempts.len() as u32, attempts.len() as u32,
skipped_candidates, skipped_candidates
.into_iter()
.map(|mut skipped_candidate| {
let mut extra_fields = serde_json::Map::new();
extra_fields.insert(
"candidate_api_format".to_string(),
json!(GEMINI_FILES_CANDIDATE_API_FORMAT),
);
skipped_candidate.extra_data =
Some(build_local_execution_candidate_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport.as_ref(),
GEMINI_FILES_CLIENT_API_FORMAT,
GEMINI_FILES_CLIENT_API_FORMAT,
extra_fields,
));
skipped_candidate
})
.collect(),
) )
.await; .await;

View File

@@ -7,13 +7,14 @@ use crate::ai_pipeline::planner::spec_metadata::local_video_create_spec_metadata
use crate::ai_pipeline::transport::auth::{ use crate::ai_pipeline::transport::auth::{
build_passthrough_headers_with_auth, resolve_local_gemini_auth, resolve_local_openai_chat_auth, build_passthrough_headers_with_auth, resolve_local_gemini_auth, resolve_local_openai_chat_auth,
}; };
use crate::ai_pipeline::transport::policy::{
supports_local_gemini_transport_with_network, supports_local_standard_transport_with_network,
};
use crate::ai_pipeline::transport::url::{ use crate::ai_pipeline::transport::url::{
build_gemini_video_predict_long_running_url, build_passthrough_path_url, build_gemini_video_predict_long_running_url, build_passthrough_path_url,
}; };
use crate::ai_pipeline::transport::{apply_local_body_rules, apply_local_header_rules}; use crate::ai_pipeline::transport::{
apply_local_body_rules, apply_local_header_rules,
local_gemini_transport_unsupported_reason_with_network,
local_standard_transport_unsupported_reason_with_network,
};
use crate::ai_pipeline::GatewayProviderTransportSnapshot; use crate::ai_pipeline::GatewayProviderTransportSnapshot;
use crate::AppState; use crate::AppState;
@@ -46,15 +47,17 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts(
let candidate = &attempt.eligible.candidate; let candidate = &attempt.eligible.candidate;
let transport = &attempt.eligible.transport; let transport = &attempt.eligible.transport;
let transport_supported = match spec.family { let transport_unsupported_reason = match spec.family {
LocalVideoCreateFamily::OpenAi => { LocalVideoCreateFamily::OpenAi => local_standard_transport_unsupported_reason_with_network(
supports_local_standard_transport_with_network(transport, spec_metadata.api_format) transport,
} spec_metadata.api_format,
LocalVideoCreateFamily::Gemini => { ),
supports_local_gemini_transport_with_network(transport, spec_metadata.api_format) LocalVideoCreateFamily::Gemini => local_gemini_transport_unsupported_reason_with_network(
} transport,
spec_metadata.api_format,
),
}; };
if !transport_supported { if let Some(skip_reason) = transport_unsupported_reason {
mark_skipped_local_video_candidate( mark_skipped_local_video_candidate(
state, state,
input, input,
@@ -62,7 +65,7 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts(
candidate, candidate,
attempt.candidate_index, attempt.candidate_index,
&attempt.candidate_id, &attempt.candidate_id,
"transport_unsupported", skip_reason,
) )
.await; .await;
return None; return None;

View File

@@ -3,7 +3,9 @@ use tracing::warn;
use super::{LocalVideoCreateFamily, LocalVideoCreateSpec}; use super::{LocalVideoCreateFamily, LocalVideoCreateSpec};
use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext; use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext;
use crate::ai_pipeline::planner::candidate_eligibility::filter_and_rank_local_execution_candidates; use crate::ai_pipeline::planner::candidate_eligibility::{
filter_and_rank_local_execution_candidates, SkippedLocalExecutionCandidate,
};
use crate::ai_pipeline::planner::candidate_materialization::{ use crate::ai_pipeline::planner::candidate_materialization::{
mark_skipped_local_execution_candidate, mark_skipped_local_execution_candidate,
persist_available_local_execution_candidates_with_context, persist_available_local_execution_candidates_with_context,
@@ -11,7 +13,8 @@ use crate::ai_pipeline::planner::candidate_materialization::{
remember_first_local_candidate_affinity, remember_first_local_candidate_affinity,
}; };
use crate::ai_pipeline::planner::candidate_metadata::{ use crate::ai_pipeline::planner::candidate_metadata::{
build_local_execution_candidate_metadata, LocalExecutionCandidateMetadataParts, build_local_execution_candidate_metadata,
build_local_execution_candidate_metadata_for_candidate, LocalExecutionCandidateMetadataParts,
}; };
use crate::ai_pipeline::planner::common::extract_requested_model_from_request; use crate::ai_pipeline::planner::common::extract_requested_model_from_request;
use crate::ai_pipeline::planner::decision_input::{ use crate::ai_pipeline::planner::decision_input::{
@@ -97,8 +100,8 @@ pub(super) async fn list_local_video_create_candidate_attempts(
decision_kind: &str, decision_kind: &str,
) -> Option<Vec<LocalVideoCreateCandidateAttempt>> { ) -> Option<Vec<LocalVideoCreateCandidateAttempt>> {
let planner_state = PlannerAppState::new(state); let planner_state = PlannerAppState::new(state);
let candidates = match planner_state let (candidates, preselection_skipped) = match planner_state
.list_selectable_candidates( .list_selectable_candidates_with_skip_reasons(
api_format, api_format,
&input.requested_model, &input.requested_model,
false, false,
@@ -126,6 +129,15 @@ pub(super) async fn list_local_video_create_candidate_attempts(
trace_id, trace_id,
input, input,
candidates, candidates,
preselection_skipped
.into_iter()
.map(|item| SkippedLocalExecutionCandidate {
candidate: item.candidate,
skip_reason: item.skip_reason,
transport: None,
extra_data: None,
})
.collect(),
api_format, api_format,
) )
.await, .await,
@@ -137,6 +149,7 @@ async fn materialize_local_video_create_candidate_attempts(
trace_id: &str, trace_id: &str,
input: &LocalVideoCreateDecisionInput, input: &LocalVideoCreateDecisionInput,
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>, candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
preselection_skipped: Vec<SkippedLocalExecutionCandidate>,
api_format: &str, api_format: &str,
) -> Vec<LocalVideoCreateCandidateAttempt> { ) -> Vec<LocalVideoCreateCandidateAttempt> {
let persistence_policy = build_local_candidate_persistence_policy( let persistence_policy = build_local_candidate_persistence_policy(
@@ -152,6 +165,10 @@ async fn materialize_local_video_create_candidate_attempts(
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
) )
.await; .await;
let skipped_candidates = preselection_skipped
.into_iter()
.chain(skipped_candidates)
.collect::<Vec<_>>();
remember_first_local_candidate_affinity( remember_first_local_candidate_affinity(
state, state,
Some(&input.auth_snapshot), Some(&input.auth_snapshot),
@@ -182,7 +199,20 @@ async fn materialize_local_video_create_candidate_attempts(
trace_id, trace_id,
persistence_policy.skipped, persistence_policy.skipped,
attempts.len() as u32, attempts.len() as u32,
skipped_candidates, skipped_candidates
.into_iter()
.map(|mut skipped_candidate| {
skipped_candidate.extra_data =
Some(build_local_execution_candidate_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport.as_ref(),
api_format,
api_format,
serde_json::Map::new(),
));
skipped_candidate
})
.collect(),
) )
.await; .await;

View File

@@ -3,14 +3,18 @@ use std::collections::BTreeSet;
use tracing::warn; use tracing::warn;
use crate::ai_pipeline::conversion::{request_candidate_api_formats, request_conversion_kind}; use crate::ai_pipeline::conversion::{request_candidate_api_formats, request_conversion_kind};
use crate::ai_pipeline::planner::candidate_eligibility::filter_and_rank_local_execution_candidates; use crate::ai_pipeline::planner::candidate_eligibility::{
filter_and_rank_local_execution_candidates, SkippedLocalExecutionCandidate,
};
use crate::ai_pipeline::planner::candidate_materialization::{ use crate::ai_pipeline::planner::candidate_materialization::{
persist_available_local_execution_candidates_with_context, persist_available_local_execution_candidates_with_context,
persist_skipped_local_execution_candidates_with_context, persist_skipped_local_execution_candidates_with_context,
remember_first_local_candidate_affinity, remember_first_local_candidate_affinity,
}; };
use crate::ai_pipeline::planner::candidate_metadata::{ use crate::ai_pipeline::planner::candidate_metadata::{
build_local_execution_candidate_contract_metadata, LocalExecutionCandidateMetadataParts, build_local_execution_candidate_contract_metadata,
build_local_execution_candidate_contract_metadata_for_candidate,
LocalExecutionCandidateMetadataParts,
}; };
use crate::ai_pipeline::planner::candidate_source::auth_snapshot_allows_cross_format_candidate; use crate::ai_pipeline::planner::candidate_source::auth_snapshot_allows_cross_format_candidate;
use crate::ai_pipeline::planner::common::extract_requested_model_from_request; use crate::ai_pipeline::planner::common::extract_requested_model_from_request;
@@ -93,7 +97,9 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
LocalCandidatePersistencePolicyKind::StandardDecision, LocalCandidatePersistencePolicyKind::StandardDecision,
); );
let mut seen_candidates = BTreeSet::new(); let mut seen_candidates = BTreeSet::new();
let mut seen_skipped_candidates = BTreeSet::new();
let mut candidates = Vec::new(); let mut candidates = Vec::new();
let mut preselection_skipped = Vec::new();
for candidate_api_format in for candidate_api_format in
request_candidate_api_formats(spec_metadata.api_format, spec_metadata.require_streaming) request_candidate_api_formats(spec_metadata.api_format, spec_metadata.require_streaming)
{ {
@@ -102,8 +108,8 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
} else { } else {
None None
}; };
let mut selected_candidates = planner_state let (mut selected_candidates, skipped_candidates) = planner_state
.list_selectable_candidates( .list_selectable_candidates_with_skip_reasons(
candidate_api_format, candidate_api_format,
&input.requested_model, &input.requested_model,
spec_metadata.require_streaming, spec_metadata.require_streaming,
@@ -121,6 +127,34 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
) )
}); });
} }
for skipped_candidate in skipped_candidates {
if auth_snapshot.is_none()
&& !auth_snapshot_allows_cross_format_candidate(
&input.auth_snapshot,
&input.requested_model,
&skipped_candidate.candidate,
)
{
continue;
}
let candidate_key = format!(
"{}:{}:{}:{}:{}:{}",
skipped_candidate.candidate.provider_id,
skipped_candidate.candidate.endpoint_id,
skipped_candidate.candidate.key_id,
skipped_candidate.candidate.model_id,
skipped_candidate.candidate.selected_provider_model_name,
skipped_candidate.candidate.endpoint_api_format,
);
if seen_skipped_candidates.insert(candidate_key) {
preselection_skipped.push(SkippedLocalExecutionCandidate {
candidate: skipped_candidate.candidate,
skip_reason: skipped_candidate.skip_reason,
transport: None,
extra_data: None,
});
}
}
for candidate in selected_candidates { for candidate in selected_candidates {
let candidate_key = format!( let candidate_key = format!(
"{}:{}:{}:{}:{}:{}", "{}:{}:{}:{}:{}:{}",
@@ -144,6 +178,49 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
) )
.await; .await;
let skipped_candidates = preselection_skipped
.into_iter()
.chain(skipped_candidates)
.map(|mut skipped_candidate| {
let provider_api_format = skipped_candidate
.transport
.as_ref()
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
.unwrap_or_else(|| {
skipped_candidate
.candidate
.endpoint_api_format
.trim()
.to_ascii_lowercase()
});
let execution_strategy = if provider_api_format == spec_metadata.api_format {
ExecutionStrategy::LocalSameFormat
} else {
ExecutionStrategy::LocalCrossFormat
};
let conversion_mode =
if request_conversion_kind(spec_metadata.api_format, provider_api_format.as_str())
.is_some()
{
ConversionMode::Bidirectional
} else {
ConversionMode::None
};
skipped_candidate.extra_data = Some(
build_local_execution_candidate_contract_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport.as_ref(),
provider_api_format.as_str(),
spec_metadata.api_format,
serde_json::Map::new(),
execution_strategy,
conversion_mode,
provider_api_format.as_str(),
),
);
skipped_candidate
})
.collect::<Vec<_>>();
let candidate_count = candidates.len() + skipped_candidates.len(); let candidate_count = candidates.len() + skipped_candidates.len();
remember_first_local_candidate_affinity( remember_first_local_candidate_affinity(

View File

@@ -51,10 +51,12 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
return None; return None;
}; };
if !crate::ai_pipeline::conversion::request_conversion_transport_supported( if let Some(skip_reason) =
transport, crate::ai_pipeline::conversion::request_conversion_transport_unsupported_reason(
conversion_kind, transport,
) { conversion_kind,
)
{
mark_skipped_local_standard_candidate( mark_skipped_local_standard_candidate(
state, state,
input, input,
@@ -62,7 +64,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
candidate, candidate,
attempt.candidate_index, attempt.candidate_index,
&attempt.candidate_id, &attempt.candidate_id,
"transport_unsupported", skip_reason,
) )
.await; .await;
return None; return None;

View File

@@ -2,9 +2,7 @@ use std::collections::BTreeMap;
use serde_json::Value; use serde_json::Value;
use crate::ai_pipeline::conversion::{ use crate::ai_pipeline::conversion::{request_conversion_direct_auth, request_conversion_kind};
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
};
use crate::ai_pipeline::planner::candidate_eligibility::EligibleLocalExecutionCandidate; use crate::ai_pipeline::planner::candidate_eligibility::EligibleLocalExecutionCandidate;
use crate::ai_pipeline::planner::candidate_preparation::{ use crate::ai_pipeline::planner::candidate_preparation::{
prepare_header_authenticated_candidate, OauthPreparationContext, prepare_header_authenticated_candidate, OauthPreparationContext,
@@ -20,7 +18,7 @@ use crate::ai_pipeline::transport::auth::{
build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth, build_claude_passthrough_headers, build_complete_passthrough_headers_with_auth,
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_openai_chat_auth, build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_openai_chat_auth,
}; };
use crate::ai_pipeline::transport::policy::supports_local_openai_chat_transport; use crate::ai_pipeline::transport::local_openai_chat_transport_unsupported_reason;
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, GatewayProviderTransportSnapshot}; use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, GatewayProviderTransportSnapshot};
use crate::AppState; use crate::AppState;
@@ -60,7 +58,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
let transport = &eligible.transport; let transport = &eligible.transport;
if provider_api_format == "openai:chat" { if provider_api_format == "openai:chat" {
if !supports_local_openai_chat_transport(transport) { if let Some(skip_reason) = local_openai_chat_transport_unsupported_reason(transport) {
mark_skipped_local_openai_chat_candidate( mark_skipped_local_openai_chat_candidate(
state, state,
input, input,
@@ -68,7 +66,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
candidate, candidate,
candidate_index, candidate_index,
candidate_id, candidate_id,
"transport_unsupported", skip_reason,
) )
.await; .await;
return None; return None;
@@ -208,12 +206,17 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
candidate, candidate,
candidate_index, candidate_index,
candidate_id, candidate_id,
"transport_unsupported", "transport_api_format_unsupported",
) )
.await; .await;
return None; return None;
}; };
if !request_conversion_transport_supported(transport, conversion_kind) { if let Some(skip_reason) =
crate::ai_pipeline::conversion::request_conversion_transport_unsupported_reason(
transport,
conversion_kind,
)
{
mark_skipped_local_openai_chat_candidate( mark_skipped_local_openai_chat_candidate(
state, state,
input, input,
@@ -221,7 +224,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
candidate, candidate,
candidate_index, candidate_index,
candidate_id, candidate_id,
"transport_unsupported", skip_reason,
) )
.await; .await;
return None; return None;

View File

@@ -1,7 +1,9 @@
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate; use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext; use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext;
use crate::ai_pipeline::planner::candidate_eligibility::filter_and_rank_local_execution_candidates; use crate::ai_pipeline::planner::candidate_eligibility::{
filter_and_rank_local_execution_candidates, SkippedLocalExecutionCandidate,
};
use crate::ai_pipeline::planner::candidate_materialization::{ use crate::ai_pipeline::planner::candidate_materialization::{
mark_skipped_local_execution_candidate, mark_skipped_local_execution_candidate,
persist_available_local_execution_candidates_with_context, persist_available_local_execution_candidates_with_context,
@@ -9,7 +11,9 @@ use crate::ai_pipeline::planner::candidate_materialization::{
remember_first_local_candidate_affinity, remember_first_local_candidate_affinity,
}; };
use crate::ai_pipeline::planner::candidate_metadata::{ use crate::ai_pipeline::planner::candidate_metadata::{
build_local_execution_candidate_contract_metadata, LocalExecutionCandidateMetadataParts, build_local_execution_candidate_contract_metadata,
build_local_execution_candidate_contract_metadata_for_candidate,
LocalExecutionCandidateMetadataParts,
}; };
use crate::ai_pipeline::planner::materialization_policy::{ use crate::ai_pipeline::planner::materialization_policy::{
build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind, build_local_candidate_persistence_policy, LocalCandidatePersistencePolicyKind,
@@ -52,6 +56,7 @@ pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
trace_id: &str, trace_id: &str,
input: &LocalOpenAiChatDecisionInput, input: &LocalOpenAiChatDecisionInput,
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>, candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
preselection_skipped: Vec<SkippedLocalExecutionCandidate>,
) -> Vec<LocalOpenAiChatCandidateAttempt> { ) -> Vec<LocalOpenAiChatCandidateAttempt> {
let planner_state = PlannerAppState::new(state); let planner_state = PlannerAppState::new(state);
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context; let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
@@ -68,6 +73,44 @@ pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
) )
.await; .await;
let skipped_candidates = preselection_skipped
.into_iter()
.chain(skipped_candidates)
.map(|mut skipped_candidate| {
let provider_api_format = skipped_candidate
.transport
.as_ref()
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
.unwrap_or_else(|| {
skipped_candidate
.candidate
.endpoint_api_format
.trim()
.to_ascii_lowercase()
});
let (execution_strategy, conversion_mode) = if provider_api_format == "openai:chat" {
(ExecutionStrategy::LocalSameFormat, ConversionMode::None)
} else {
(
ExecutionStrategy::LocalCrossFormat,
ConversionMode::Bidirectional,
)
};
skipped_candidate.extra_data = Some(
build_local_execution_candidate_contract_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport.as_ref(),
provider_api_format.as_str(),
"openai:chat",
serde_json::Map::new(),
execution_strategy,
conversion_mode,
provider_api_format.as_str(),
),
);
skipped_candidate
})
.collect::<Vec<_>>();
remember_first_local_candidate_affinity( remember_first_local_candidate_affinity(
planner_state, planner_state,
Some(&input.auth_snapshot), Some(&input.auth_snapshot),

View File

@@ -99,22 +99,29 @@ pub(crate) async fn maybe_build_sync_local_decision_payload(
return Ok(None); return Ok(None);
}; };
let candidates = match list_local_openai_chat_candidates(state, &input, false).await { let (candidates, skipped_candidates) =
Ok(candidates) => candidates, match list_local_openai_chat_candidates(state, &input, false).await {
Err(err) => { Ok(value) => value,
warn!( Err(err) => {
event_name = "local_openai_chat_scheduler_selection_failed", warn!(
log_type = "event", event_name = "local_openai_chat_scheduler_selection_failed",
trace_id = %trace_id, log_type = "event",
error = ?err, trace_id = %trace_id,
"gateway local openai chat sync decision scheduler selection failed" error = ?err,
); "gateway local openai chat sync decision scheduler selection failed"
return Ok(None); );
} return Ok(None);
}; }
};
let attempts = let attempts = materialize_local_openai_chat_candidate_attempts(
materialize_local_openai_chat_candidate_attempts(state, trace_id, &input, candidates).await; state,
trace_id,
&input,
candidates,
skipped_candidates,
)
.await;
for attempt in attempts { for attempt in attempts {
if let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate( if let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
@@ -157,22 +164,29 @@ pub(crate) async fn maybe_build_stream_local_decision_payload(
return Ok(None); return Ok(None);
}; };
let candidates = match list_local_openai_chat_candidates(state, &input, true).await { let (candidates, skipped_candidates) =
Ok(candidates) => candidates, match list_local_openai_chat_candidates(state, &input, true).await {
Err(err) => { Ok(value) => value,
warn!( Err(err) => {
event_name = "local_openai_chat_scheduler_selection_failed", warn!(
log_type = "event", event_name = "local_openai_chat_scheduler_selection_failed",
trace_id = %trace_id, log_type = "event",
error = ?err, trace_id = %trace_id,
"gateway local openai chat stream decision scheduler selection failed" error = ?err,
); "gateway local openai chat stream decision scheduler selection failed"
return Ok(None); );
} return Ok(None);
}; }
};
let attempts = let attempts = materialize_local_openai_chat_candidate_attempts(
materialize_local_openai_chat_candidate_attempts(state, trace_id, &input, candidates).await; state,
trace_id,
&input,
candidates,
skipped_candidates,
)
.await;
for attempt in attempts { for attempt in attempts {
if let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate( if let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(

View File

@@ -4,6 +4,7 @@ use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use super::super::{GatewayError, LocalOpenAiChatDecisionInput}; use super::super::{GatewayError, LocalOpenAiChatDecisionInput};
use crate::ai_pipeline::conversion::request_candidate_api_formats; use crate::ai_pipeline::conversion::request_candidate_api_formats;
use crate::ai_pipeline::planner::candidate_eligibility::SkippedLocalExecutionCandidate;
use crate::ai_pipeline::planner::candidate_source::auth_snapshot_allows_cross_format_candidate; use crate::ai_pipeline::planner::candidate_source::auth_snapshot_allows_cross_format_candidate;
use crate::ai_pipeline::PlannerAppState; use crate::ai_pipeline::PlannerAppState;
use crate::clock::current_unix_secs; use crate::clock::current_unix_secs;
@@ -13,11 +14,19 @@ pub(crate) async fn list_local_openai_chat_candidates(
state: &AppState, state: &AppState,
input: &LocalOpenAiChatDecisionInput, input: &LocalOpenAiChatDecisionInput,
require_streaming: bool, require_streaming: bool,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
),
GatewayError,
> {
let planner_state = PlannerAppState::new(state); let planner_state = PlannerAppState::new(state);
let now_unix_secs = current_unix_secs(); let now_unix_secs = current_unix_secs();
let mut combined = Vec::new(); let mut combined = Vec::new();
let mut seen = BTreeSet::new(); let mut seen = BTreeSet::new();
let mut skipped = Vec::new();
let mut seen_skipped = BTreeSet::new();
let api_formats = request_candidate_api_formats("openai:chat", require_streaming); let api_formats = request_candidate_api_formats("openai:chat", require_streaming);
@@ -27,8 +36,8 @@ pub(crate) async fn list_local_openai_chat_candidates(
} else { } else {
None None
}; };
let mut candidates = planner_state let (mut candidates, skipped_candidates) = planner_state
.list_selectable_candidates( .list_selectable_candidates_with_skip_reasons(
api_format, api_format,
&input.requested_model, &input.requested_model,
require_streaming, require_streaming,
@@ -46,6 +55,33 @@ pub(crate) async fn list_local_openai_chat_candidates(
) )
}); });
} }
for skipped_candidate in skipped_candidates {
if api_format != "openai:chat"
&& !auth_snapshot_allows_cross_format_candidate(
&input.auth_snapshot,
&input.requested_model,
&skipped_candidate.candidate,
)
{
continue;
}
let candidate_key = format!(
"{}:{}:{}:{}:{}",
skipped_candidate.candidate.provider_id,
skipped_candidate.candidate.endpoint_id,
skipped_candidate.candidate.key_id,
skipped_candidate.candidate.model_id,
skipped_candidate.candidate.selected_provider_model_name,
);
if seen_skipped.insert(candidate_key) {
skipped.push(SkippedLocalExecutionCandidate {
candidate: skipped_candidate.candidate,
skip_reason: skipped_candidate.skip_reason,
transport: None,
extra_data: None,
});
}
}
for candidate in candidates { for candidate in candidates {
let candidate_key = format!( let candidate_key = format!(
"{}:{}:{}:{}:{}", "{}:{}:{}:{}:{}",
@@ -61,5 +97,5 @@ pub(crate) async fn list_local_openai_chat_candidates(
} }
} }
Ok(combined) Ok((combined, skipped))
} }

View File

@@ -36,26 +36,27 @@ pub(crate) async fn build_local_openai_chat_stream_plan_and_reports(
return Ok(Vec::new()); return Ok(Vec::new());
}; };
let candidates = match list_local_openai_chat_candidates(state, &input, true).await { let (candidates, skipped_candidates) =
Ok(candidates) => candidates, match list_local_openai_chat_candidates(state, &input, true).await {
Err(err) => { Ok(value) => value,
warn!( Err(err) => {
trace_id = %trace_id, warn!(
error = ?err, trace_id = %trace_id,
"gateway local openai chat stream decision scheduler selection failed" error = ?err,
); "gateway local openai chat stream decision scheduler selection failed"
set_local_openai_chat_miss_diagnostic( );
state, set_local_openai_chat_miss_diagnostic(
trace_id, state,
decision, trace_id,
plan_kind, decision,
Some(input.requested_model.as_str()), plan_kind,
"scheduler_selection_failed", Some(input.requested_model.as_str()),
); "scheduler_selection_failed",
return Ok(Vec::new()); );
} return Ok(Vec::new());
}; }
if candidates.is_empty() { };
if candidates.is_empty() && skipped_candidates.is_empty() {
set_local_openai_chat_candidate_evaluation_diagnostic( set_local_openai_chat_candidate_evaluation_diagnostic(
state, state,
trace_id, trace_id,
@@ -72,11 +73,17 @@ pub(crate) async fn build_local_openai_chat_stream_plan_and_reports(
decision, decision,
plan_kind, plan_kind,
Some(input.requested_model.as_str()), Some(input.requested_model.as_str()),
candidates.len(), candidates.len() + skipped_candidates.len(),
); );
let attempts = let attempts = materialize_local_openai_chat_candidate_attempts(
materialize_local_openai_chat_candidate_attempts(state, trace_id, &input, candidates).await; state,
trace_id,
&input,
candidates,
skipped_candidates,
)
.await;
let mut plans = Vec::new(); let mut plans = Vec::new();
for attempt in attempts { for attempt in attempts {

View File

@@ -36,26 +36,27 @@ pub(crate) async fn build_local_openai_chat_sync_plan_and_reports(
return Ok(Vec::new()); return Ok(Vec::new());
}; };
let candidates = match list_local_openai_chat_candidates(state, &input, false).await { let (candidates, skipped_candidates) =
Ok(candidates) => candidates, match list_local_openai_chat_candidates(state, &input, false).await {
Err(err) => { Ok(value) => value,
warn!( Err(err) => {
trace_id = %trace_id, warn!(
error = ?err, trace_id = %trace_id,
"gateway local openai chat sync decision scheduler selection failed" error = ?err,
); "gateway local openai chat sync decision scheduler selection failed"
set_local_openai_chat_miss_diagnostic( );
state, set_local_openai_chat_miss_diagnostic(
trace_id, state,
decision, trace_id,
plan_kind, decision,
Some(input.requested_model.as_str()), plan_kind,
"scheduler_selection_failed", Some(input.requested_model.as_str()),
); "scheduler_selection_failed",
return Ok(Vec::new()); );
} return Ok(Vec::new());
}; }
if candidates.is_empty() { };
if candidates.is_empty() && skipped_candidates.is_empty() {
set_local_openai_chat_candidate_evaluation_diagnostic( set_local_openai_chat_candidate_evaluation_diagnostic(
state, state,
trace_id, trace_id,
@@ -72,11 +73,17 @@ pub(crate) async fn build_local_openai_chat_sync_plan_and_reports(
decision, decision,
plan_kind, plan_kind,
Some(input.requested_model.as_str()), Some(input.requested_model.as_str()),
candidates.len(), candidates.len() + skipped_candidates.len(),
); );
let attempts = let attempts = materialize_local_openai_chat_candidate_attempts(
materialize_local_openai_chat_candidate_attempts(state, trace_id, &input, candidates).await; state,
trace_id,
&input,
candidates,
skipped_candidates,
)
.await;
let mut plans = Vec::new(); let mut plans = Vec::new();
for attempt in attempts { for attempt in attempts {

View File

@@ -3,9 +3,7 @@ use std::collections::BTreeMap;
use serde_json::Value; use serde_json::Value;
use tracing::debug; use tracing::debug;
use crate::ai_pipeline::conversion::{ use crate::ai_pipeline::conversion::{request_conversion_direct_auth, request_conversion_kind};
request_conversion_direct_auth, request_conversion_kind, request_conversion_transport_supported,
};
use crate::ai_pipeline::planner::candidate_eligibility::EligibleLocalExecutionCandidate; use crate::ai_pipeline::planner::candidate_eligibility::EligibleLocalExecutionCandidate;
use crate::ai_pipeline::planner::candidate_preparation::{ use crate::ai_pipeline::planner::candidate_preparation::{
prepare_header_authenticated_candidate, OauthPreparationContext, prepare_header_authenticated_candidate, OauthPreparationContext,
@@ -28,7 +26,7 @@ use crate::ai_pipeline::transport::auth::{
build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth, build_openai_passthrough_headers, ensure_upstream_auth_header, resolve_local_gemini_auth,
resolve_local_standard_auth, resolve_local_standard_auth,
}; };
use crate::ai_pipeline::transport::policy::supports_local_standard_transport_with_network; use crate::ai_pipeline::transport::local_standard_transport_unsupported_reason_with_network;
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy}; use crate::ai_pipeline::{ConversionMode, ExecutionStrategy};
use crate::ai_pipeline::{GatewayProviderTransportSnapshot, PlannerAppState}; use crate::ai_pipeline::{GatewayProviderTransportSnapshot, PlannerAppState};
use crate::AppState; use crate::AppState;
@@ -79,16 +77,20 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
let same_format = provider_api_format == client_api_format; let same_format = provider_api_format == client_api_format;
let conversion_kind = request_conversion_kind(spec_metadata.api_format, provider_api_format); let conversion_kind = request_conversion_kind(spec_metadata.api_format, provider_api_format);
let transport_supported = if same_format { let transport_unsupported_reason = if same_format {
supports_local_standard_transport_with_network(transport, provider_api_format) local_standard_transport_unsupported_reason_with_network(transport, provider_api_format)
} else { } else {
match conversion_kind { match conversion_kind {
Some(_) if is_antigravity && provider_api_format == "gemini:cli" => true, Some(_) if is_antigravity && provider_api_format == "gemini:cli" => None,
Some(kind) => request_conversion_transport_supported(transport, kind), Some(kind) => {
None => false, crate::ai_pipeline::conversion::request_conversion_transport_unsupported_reason(
transport, kind,
)
}
None => Some("transport_api_format_unsupported"),
} }
}; };
if !transport_supported { if let Some(skip_reason) = transport_unsupported_reason {
mark_skipped_local_openai_cli_candidate( mark_skipped_local_openai_cli_candidate(
state, state,
input, input,
@@ -96,7 +98,7 @@ pub(crate) async fn resolve_local_openai_cli_candidate_payload_parts(
candidate, candidate,
candidate_index, candidate_index,
candidate_id, candidate_id,
"transport_unsupported", skip_reason,
) )
.await; .await;
return None; return None;

View File

@@ -5,7 +5,9 @@ use tracing::warn;
use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext; use crate::ai_pipeline::contracts::ExecutionRuntimeAuthContext;
use crate::ai_pipeline::conversion::{request_candidate_api_formats, request_conversion_kind}; use crate::ai_pipeline::conversion::{request_candidate_api_formats, request_conversion_kind};
use crate::ai_pipeline::planner::candidate_eligibility::filter_and_rank_local_execution_candidates; use crate::ai_pipeline::planner::candidate_eligibility::{
filter_and_rank_local_execution_candidates, SkippedLocalExecutionCandidate,
};
use crate::ai_pipeline::planner::candidate_materialization::{ use crate::ai_pipeline::planner::candidate_materialization::{
mark_skipped_local_execution_candidate, mark_skipped_local_execution_candidate,
persist_available_local_execution_candidates_with_context, persist_available_local_execution_candidates_with_context,
@@ -13,7 +15,9 @@ use crate::ai_pipeline::planner::candidate_materialization::{
remember_first_local_candidate_affinity, remember_first_local_candidate_affinity,
}; };
use crate::ai_pipeline::planner::candidate_metadata::{ use crate::ai_pipeline::planner::candidate_metadata::{
build_local_execution_candidate_contract_metadata, LocalExecutionCandidateMetadataParts, build_local_execution_candidate_contract_metadata,
build_local_execution_candidate_contract_metadata_for_candidate,
LocalExecutionCandidateMetadataParts,
}; };
use crate::ai_pipeline::planner::candidate_source::auth_snapshot_allows_cross_format_candidate; use crate::ai_pipeline::planner::candidate_source::auth_snapshot_allows_cross_format_candidate;
use crate::ai_pipeline::planner::common::extract_standard_requested_model; use crate::ai_pipeline::planner::common::extract_standard_requested_model;
@@ -90,7 +94,9 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
LocalCandidatePersistencePolicyKind::OpenAiCliDecision, LocalCandidatePersistencePolicyKind::OpenAiCliDecision,
); );
let mut seen_candidates = BTreeSet::new(); let mut seen_candidates = BTreeSet::new();
let mut seen_skipped_candidates = BTreeSet::new();
let mut candidates = Vec::new(); let mut candidates = Vec::new();
let mut preselection_skipped = Vec::new();
for candidate_api_format in for candidate_api_format in
request_candidate_api_formats(spec_metadata.api_format, spec_metadata.require_streaming) request_candidate_api_formats(spec_metadata.api_format, spec_metadata.require_streaming)
{ {
@@ -99,8 +105,8 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
} else { } else {
None None
}; };
let mut selected_candidates = planner_state let (mut selected_candidates, skipped_candidates) = planner_state
.list_selectable_candidates( .list_selectable_candidates_with_skip_reasons(
candidate_api_format, candidate_api_format,
&input.requested_model, &input.requested_model,
spec_metadata.require_streaming, spec_metadata.require_streaming,
@@ -118,6 +124,34 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
) )
}); });
} }
for skipped_candidate in skipped_candidates {
if auth_snapshot.is_none()
&& !auth_snapshot_allows_cross_format_candidate(
&input.auth_snapshot,
&input.requested_model,
&skipped_candidate.candidate,
)
{
continue;
}
let candidate_key = format!(
"{}:{}:{}:{}:{}:{}",
skipped_candidate.candidate.provider_id,
skipped_candidate.candidate.endpoint_id,
skipped_candidate.candidate.key_id,
skipped_candidate.candidate.model_id,
skipped_candidate.candidate.selected_provider_model_name,
skipped_candidate.candidate.endpoint_api_format,
);
if seen_skipped_candidates.insert(candidate_key) {
preselection_skipped.push(SkippedLocalExecutionCandidate {
candidate: skipped_candidate.candidate,
skip_reason: skipped_candidate.skip_reason,
transport: None,
extra_data: None,
});
}
}
for candidate in selected_candidates { for candidate in selected_candidates {
let candidate_key = format!( let candidate_key = format!(
"{}:{}:{}:{}:{}:{}", "{}:{}:{}:{}:{}:{}",
@@ -141,6 +175,49 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
) )
.await; .await;
let skipped_candidates = preselection_skipped
.into_iter()
.chain(skipped_candidates)
.map(|mut skipped_candidate| {
let provider_api_format = skipped_candidate
.transport
.as_ref()
.map(|transport| transport.endpoint.api_format.trim().to_ascii_lowercase())
.unwrap_or_else(|| {
skipped_candidate
.candidate
.endpoint_api_format
.trim()
.to_ascii_lowercase()
});
let execution_strategy = if provider_api_format == client_api_format {
ExecutionStrategy::LocalSameFormat
} else {
ExecutionStrategy::LocalCrossFormat
};
let conversion_mode =
if request_conversion_kind(spec_metadata.api_format, provider_api_format.as_str())
.is_some()
{
ConversionMode::Bidirectional
} else {
ConversionMode::None
};
skipped_candidate.extra_data = Some(
build_local_execution_candidate_contract_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport.as_ref(),
provider_api_format.as_str(),
spec_metadata.api_format,
serde_json::Map::new(),
execution_strategy,
conversion_mode,
provider_api_format.as_str(),
),
);
skipped_candidate
})
.collect::<Vec<_>>();
remember_first_local_candidate_affinity( remember_first_local_candidate_affinity(
planner_state, planner_state,

View File

@@ -62,6 +62,7 @@ impl<'a> PlannerAppState<'a> {
candidate_id: &str, candidate_id: &str,
required_capabilities: Option<&Value>, required_capabilities: Option<&Value>,
skip_reason: &str, skip_reason: &str,
extra_data: Option<Value>,
finished_at_unix_ms: u64, finished_at_unix_ms: u64,
error_context: &'static str, error_context: &'static str,
) { ) {
@@ -75,6 +76,7 @@ impl<'a> PlannerAppState<'a> {
candidate_id, candidate_id,
required_capabilities, required_capabilities,
skip_reason, skip_reason,
extra_data,
finished_at_unix_ms, finished_at_unix_ms,
error_context, error_context,
) )

View File

@@ -1,6 +1,7 @@
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate; use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use super::{GatewayAuthApiKeySnapshot, PlannerAppState}; use super::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::scheduler::candidate::SchedulerSkippedCandidate;
use crate::GatewayError; use crate::GatewayError;
impl<'a> PlannerAppState<'a> { impl<'a> PlannerAppState<'a> {
@@ -26,6 +27,34 @@ impl<'a> PlannerAppState<'a> {
.await .await
} }
pub(crate) async fn list_selectable_candidates_with_skip_reasons(
self,
api_format: &str,
global_model_name: &str,
require_streaming: bool,
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
Vec<SchedulerSkippedCandidate>,
),
GatewayError,
> {
crate::scheduler::candidate::list_selectable_candidates_with_skip_reasons(
self.app().data.as_ref(),
self.app(),
api_format,
global_model_name,
require_streaming,
required_capabilities,
auth_snapshot,
now_unix_secs,
)
.await
}
pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model( pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model(
self, self,
candidate_api_format: &str, candidate_api_format: &str,

View File

@@ -45,8 +45,9 @@ pub(crate) use aether_ai_pipeline::api::{
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_formats,
request_conversion_direct_auth, 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_supported, request_conversion_transport_unsupported_reason,
request_pair_allowed_for_transport, resolve_claude_stream_spec, resolve_claude_sync_spec, 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,

View File

@@ -43,12 +43,15 @@ pub(crate) mod vertex {
} }
pub(crate) use aether_ai_pipeline::transport::{ pub(crate) use aether_ai_pipeline::transport::{
apply_local_body_rules, apply_local_header_rules, body_rules_handle_path, apply_local_body_rules, apply_local_header_rules, body_rules_are_locally_supported,
build_passthrough_headers, ensure_upstream_auth_header, resolve_transport_execution_timeouts, body_rules_handle_path, build_passthrough_headers, ensure_upstream_auth_header,
header_rules_are_locally_supported, local_gemini_transport_unsupported_reason_with_network,
local_openai_chat_transport_unsupported_reason,
local_standard_transport_unsupported_reason_with_network, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot, resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_proxy_snapshot, resolve_transport_proxy_snapshot_with_tunnel_affinity,
resolve_transport_tls_profile, should_skip_upstream_passthrough_header, resolve_transport_tls_profile, should_skip_upstream_passthrough_header,
supports_local_gemini_transport_with_network, supports_local_gemini_transport_with_network,
supports_local_generic_oauth_request_auth_resolution, supports_local_generic_oauth_request_auth_resolution,
supports_local_oauth_request_auth_resolution, GatewayProviderTransportSnapshot, supports_local_oauth_request_auth_resolution, transport_proxy_is_locally_supported,
LocalResolvedOAuthRequestAuth, GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
}; };

View File

@@ -77,6 +77,13 @@ pub(crate) async fn build_admin_global_model_routing_payload(
.flatten() .flatten()
.and_then(|value| value.as_str().map(ToOwned::to_owned)) .and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "provider".to_string()); .unwrap_or_else(|| "provider".to_string());
let keep_priority_on_conversion = state
.read_system_config_json_value("keep_priority_on_conversion")
.await
.ok()
.flatten()
.and_then(|value| value.as_bool())
.unwrap_or(false);
let global_model_mappings = global_model let global_model_mappings = global_model
.config .config
@@ -179,6 +186,7 @@ pub(crate) async fn build_admin_global_model_routing_payload(
"api_format": endpoint.api_format, "api_format": endpoint.api_format,
"base_url": endpoint.base_url, "base_url": endpoint.base_url,
"custom_path": endpoint.custom_path, "custom_path": endpoint.custom_path,
"format_acceptance_config": endpoint.format_acceptance_config,
"is_active": endpoint.is_active, "is_active": endpoint.is_active,
"keys": key_payloads, "keys": key_payloads,
"total_keys": key_payloads.len(), "total_keys": key_payloads.len(),
@@ -196,6 +204,8 @@ pub(crate) async fn build_admin_global_model_routing_payload(
"name": &provider.name, "name": &provider.name,
"model_id": &model.id, "model_id": &model.id,
"provider_priority": provider.provider_priority, "provider_priority": provider.provider_priority,
"enable_format_conversion": provider.enable_format_conversion,
"keep_priority_on_conversion": provider.keep_priority_on_conversion,
"billing_type": provider.billing_type.clone(), "billing_type": provider.billing_type.clone(),
"monthly_quota_usd": provider.monthly_quota_usd, "monthly_quota_usd": provider.monthly_quota_usd,
"monthly_used_usd": provider.monthly_used_usd, "monthly_used_usd": provider.monthly_used_usd,
@@ -242,6 +252,7 @@ pub(crate) async fn build_admin_global_model_routing_payload(
"active_providers": active_providers, "active_providers": active_providers,
"scheduling_mode": scheduling_mode, "scheduling_mode": scheduling_mode,
"priority_mode": priority_mode, "priority_mode": priority_mode,
"keep_priority_on_conversion": keep_priority_on_conversion,
"all_keys_whitelist": all_keys_whitelist, "all_keys_whitelist": all_keys_whitelist,
})) }))
} }

View File

@@ -455,6 +455,7 @@ pub(crate) async fn persist_skipped_local_candidate(
candidate_id: &str, candidate_id: &str,
required_capabilities: Option<&Value>, required_capabilities: Option<&Value>,
skip_reason: &str, skip_reason: &str,
extra_data: Option<serde_json::Value>,
finished_at_unix_ms: u64, finished_at_unix_ms: u64,
error_context: &'static str, error_context: &'static str,
) { ) {
@@ -479,7 +480,7 @@ pub(crate) async fn persist_skipped_local_candidate(
error_message: None, error_message: None,
latency_ms: None, latency_ms: None,
concurrent_requests: None, concurrent_requests: None,
extra_data: None, extra_data,
required_capabilities: required_capabilities.cloned(), required_capabilities: required_capabilities.cloned(),
created_at_unix_ms: None, created_at_unix_ms: None,
started_at_unix_ms: None, started_at_unix_ms: None,

View File

@@ -1,5 +1,7 @@
use self::affinity::candidate_affinity_hash; use self::affinity::candidate_affinity_hash;
use self::selection::collect_selectable_candidates; use self::selection::{
collect_selectable_candidates, collect_selectable_candidates_with_skip_reasons,
};
use super::state::SchedulerRuntimeState; use super::state::SchedulerRuntimeState;
mod affinity; mod affinity;
@@ -26,6 +28,8 @@ use regex::Regex;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::BTreeMap; use std::collections::BTreeMap;
pub(crate) use self::selection::SchedulerSkippedCandidate;
use crate::data::auth::GatewayAuthApiKeySnapshot; use crate::data::auth::GatewayAuthApiKeySnapshot;
use crate::data::candidate_selection::{ use crate::data::candidate_selection::{
read_global_model_names_for_api_format, read_global_model_names_for_required_capability, read_global_model_names_for_api_format, read_global_model_names_for_required_capability,
@@ -65,6 +69,35 @@ pub(crate) async fn list_selectable_candidates(
.await .await
} }
pub(crate) async fn list_selectable_candidates_with_skip_reasons(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
runtime_state: &impl SchedulerRuntimeState,
api_format: &str,
global_model_name: &str,
require_streaming: bool,
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
Vec<SchedulerSkippedCandidate>,
),
GatewayError,
> {
collect_selectable_candidates_with_skip_reasons(
selection_row_source,
runtime_state,
api_format,
global_model_name,
require_streaming,
required_capabilities,
auth_snapshot,
now_unix_secs,
)
.await
}
pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model( pub(crate) async fn list_selectable_candidates_for_required_capability_without_requested_model(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync), selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
runtime_state: &impl SchedulerRuntimeState, runtime_state: &impl SchedulerRuntimeState,

View File

@@ -4,8 +4,8 @@ use aether_data_contracts::repository::candidates::StoredRequestCandidate;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey; use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use aether_scheduler_core::{ use aether_scheduler_core::{
auth_api_key_concurrency_limit_reached, build_provider_concurrent_limit_map, auth_api_key_concurrency_limit_reached, build_provider_concurrent_limit_map,
candidate_is_selectable_with_runtime_state, CandidateRuntimeSelectabilityInput, candidate_is_selectable_with_runtime_state, candidate_runtime_skip_reason_with_state,
SchedulerAffinityTarget, CandidateRuntimeSelectabilityInput, SchedulerAffinityTarget,
}; };
use crate::data::auth::GatewayAuthApiKeySnapshot; use crate::data::auth::GatewayAuthApiKeySnapshot;
@@ -77,6 +77,32 @@ pub(super) fn is_candidate_selectable(
now_unix_secs: u64, now_unix_secs: u64,
cached_affinity_target: Option<&SchedulerAffinityTarget>, cached_affinity_target: Option<&SchedulerAffinityTarget>,
) -> bool { ) -> bool {
candidate_is_selectable_with_runtime_state(CandidateRuntimeSelectabilityInput {
candidate,
recent_candidates: &snapshot.recent_candidates,
provider_concurrent_limits: &snapshot.provider_concurrent_limits,
provider_key_rpm_states: &snapshot.provider_key_rpm_states,
now_unix_secs,
cached_affinity_target,
provider_quota_blocks_requests: snapshot
.provider_quota_blocks_requests
.get(candidate.provider_id.as_str())
.copied()
.unwrap_or(false),
rpm_reset_at: snapshot
.provider_key_rpm_reset_ats
.get(candidate.key_id.as_str())
.copied()
.flatten(),
})
}
pub(super) fn current_candidate_runtime_skip_reason(
candidate: &SchedulerMinimalCandidateSelectionCandidate,
snapshot: &CandidateRuntimeSelectionSnapshot,
now_unix_secs: u64,
cached_affinity_target: Option<&SchedulerAffinityTarget>,
) -> Option<&'static str> {
let provider_quota_blocks_requests = snapshot let provider_quota_blocks_requests = snapshot
.provider_quota_blocks_requests .provider_quota_blocks_requests
.get(candidate.provider_id.as_str()) .get(candidate.provider_id.as_str())
@@ -88,7 +114,7 @@ pub(super) fn is_candidate_selectable(
.copied() .copied()
.flatten(); .flatten();
candidate_is_selectable_with_runtime_state(CandidateRuntimeSelectabilityInput { candidate_runtime_skip_reason_with_state(CandidateRuntimeSelectabilityInput {
candidate, candidate,
recent_candidates: &snapshot.recent_candidates, recent_candidates: &snapshot.recent_candidates,
provider_concurrent_limits: &snapshot.provider_concurrent_limits, provider_concurrent_limits: &snapshot.provider_concurrent_limits,

View File

@@ -20,11 +20,17 @@ use super::affinity::{
build_scheduler_affinity_cache_key, candidate_key, remember_scheduler_affinity, build_scheduler_affinity_cache_key, candidate_key, remember_scheduler_affinity,
}; };
use super::runtime::{ use super::runtime::{
auth_snapshot_concurrency_limit_reached, is_candidate_selectable, auth_snapshot_concurrency_limit_reached, current_candidate_runtime_skip_reason,
read_candidate_runtime_selection_snapshot, read_candidate_runtime_selection_snapshot,
}; };
use super::{SchedulerMinimalCandidateSelectionCandidate, SchedulerRuntimeState}; use super::{SchedulerMinimalCandidateSelectionCandidate, SchedulerRuntimeState};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct SchedulerSkippedCandidate {
pub(crate) candidate: SchedulerMinimalCandidateSelectionCandidate,
pub(crate) skip_reason: &'static str,
}
pub(super) fn reorder_candidates_by_scheduler_health( pub(super) fn reorder_candidates_by_scheduler_health(
candidates: &mut [SchedulerMinimalCandidateSelectionCandidate], candidates: &mut [SchedulerMinimalCandidateSelectionCandidate],
provider_key_rpm_states: &BTreeMap<String, StoredProviderCatalogKey>, provider_key_rpm_states: &BTreeMap<String, StoredProviderCatalogKey>,
@@ -130,6 +136,36 @@ pub(super) async fn collect_selectable_candidates(
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64, now_unix_secs: u64,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> { ) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
Ok(collect_selectable_candidates_with_skip_reasons(
selection_row_source,
runtime_state,
api_format,
global_model_name,
require_streaming,
required_capabilities,
auth_snapshot,
now_unix_secs,
)
.await?
.0)
}
pub(super) async fn collect_selectable_candidates_with_skip_reasons(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
runtime_state: &impl SchedulerRuntimeState,
api_format: &str,
global_model_name: &str,
require_streaming: bool,
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
Vec<SchedulerSkippedCandidate>,
),
GatewayError,
> {
let ordering_config = runtime_state.read_scheduler_ordering_config().await?; let ordering_config = runtime_state.read_scheduler_ordering_config().await?;
let priority_affinity_key = let priority_affinity_key =
scheduling_priority_affinity_key(auth_snapshot, ordering_config.scheduling_mode); scheduling_priority_affinity_key(auth_snapshot, ordering_config.scheduling_mode);
@@ -176,27 +212,48 @@ pub(super) async fn collect_selectable_candidates(
}; };
if auth_snapshot_concurrency_limit_reached(auth_snapshot, &runtime_snapshot, now_unix_secs) { if auth_snapshot_concurrency_limit_reached(auth_snapshot, &runtime_snapshot, now_unix_secs) {
return Ok(Vec::new()); return Ok((
Vec::new(),
candidates
.into_iter()
.map(|candidate| SchedulerSkippedCandidate {
candidate,
skip_reason: "api_key_concurrency_limit_reached",
})
.collect(),
));
} }
let mut selected_keys = BTreeSet::new(); let mut selected_keys = BTreeSet::new();
let mut skipped = Vec::new();
let mut emitted_skipped_keys = BTreeSet::new();
for candidate in &candidates { for candidate in &candidates {
if !is_candidate_selectable( let key = candidate_key(candidate);
if let Some(skip_reason) = current_candidate_runtime_skip_reason(
candidate, candidate,
&runtime_snapshot, &runtime_snapshot,
now_unix_secs, now_unix_secs,
cached_affinity_target.as_ref(), cached_affinity_target.as_ref(),
) { ) {
if emitted_skipped_keys.insert(key) {
skipped.push(SchedulerSkippedCandidate {
candidate: candidate.clone(),
skip_reason,
});
}
continue; continue;
} }
selected_keys.insert(candidate_key(candidate)); selected_keys.insert(key);
} }
Ok(collect_selectable_candidates_from_keys( Ok((
candidates, collect_selectable_candidates_from_keys(
&selected_keys, candidates,
cached_affinity_target.as_ref(), &selected_keys,
cached_affinity_target.as_ref(),
),
skipped,
)) ))
} }

View File

@@ -22,6 +22,7 @@ use crate::{AppState, GatewayError};
use super::super::runtime::should_skip_provider_quota; use super::super::runtime::should_skip_provider_quota;
use super::super::selection::{ use super::super::selection::{
collect_selectable_candidates as collect_selectable_candidates_impl, collect_selectable_candidates as collect_selectable_candidates_impl,
collect_selectable_candidates_with_skip_reasons as collect_selectable_candidates_with_skip_reasons_impl,
select_minimal_candidate as select_candidate_impl, select_minimal_candidate as select_candidate_impl,
}; };
use super::support::{sample_auth_snapshot, sample_key, sample_provider, sample_row}; use super::support::{sample_auth_snapshot, sample_key, sample_provider, sample_row};
@@ -70,6 +71,34 @@ async fn collect_selectable_candidates(
.await .await
} }
async fn collect_selectable_candidates_with_skip_reasons(
selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync),
runtime_state: &AppState,
api_format: &str,
global_model_name: &str,
require_streaming: bool,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
Vec<super::super::SchedulerSkippedCandidate>,
),
GatewayError,
> {
collect_selectable_candidates_with_skip_reasons_impl(
selection_row_source,
runtime_state,
api_format,
global_model_name,
require_streaming,
None,
auth_snapshot,
now_unix_secs,
)
.await
}
#[test] #[test]
fn skips_inactive_or_exhausted_monthly_quota_provider() { fn skips_inactive_or_exhausted_monthly_quota_provider() {
let inactive = StoredProviderQuotaSnapshot::new( let inactive = StoredProviderQuotaSnapshot::new(
@@ -1044,6 +1073,73 @@ async fn selects_next_candidate_when_first_provider_key_circuit_is_open() {
assert_eq!(selected.key_id, "key-b"); assert_eq!(selected.key_id, "key-b");
} }
#[tokio::test]
async fn exposes_runtime_skipped_candidates_with_skip_reasons() {
let mut first = sample_row();
first.provider_id = "provider-a".to_string();
first.provider_name = "openai-a".to_string();
first.endpoint_id = "endpoint-a".to_string();
first.key_id = "key-a".to_string();
first.key_name = "alpha".to_string();
first.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 1}));
let mut second = sample_row();
second.provider_id = "provider-b".to_string();
second.provider_name = "openai-b".to_string();
second.endpoint_id = "endpoint-b".to_string();
second.key_id = "key-b".to_string();
second.key_name = "beta".to_string();
second.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 2}));
let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
first, second,
]));
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider("provider-a", None),
sample_provider("provider-b", None),
],
Vec::new(),
vec![
sample_key("key-a", "provider-a", Some(10)).with_health_fields(
Some(serde_json::json!({"openai:chat": {"health_score": 0.2}})),
Some(serde_json::json!({"openai:chat": {"open": true}})),
),
sample_key("key-b", "provider-b", Some(10)),
],
));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let request_candidates = Arc::new(InMemoryRequestCandidateRepository::seed(vec![]));
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(
GatewayDataState::with_candidate_selection_provider_catalog_quota_and_request_candidates_for_tests(
candidates,
provider_catalog,
quotas,
request_candidates,
),
);
let (selected, skipped) = collect_selectable_candidates_with_skip_reasons(
state.data.as_ref(),
&state,
"openai:chat",
"gpt-4.1",
false,
None,
100,
)
.await
.expect("selection should succeed");
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].provider_id, "provider-b");
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0].candidate.provider_id, "provider-a");
assert_eq!(skipped[0].skip_reason, "key_circuit_open");
}
#[tokio::test] #[tokio::test]
async fn same_priority_candidates_prefer_healthier_provider_key_before_id_order() { async fn same_priority_candidates_prefer_healthier_provider_key_before_id_order() {
let mut first = sample_row(); let mut first = sample_row();

View File

@@ -1764,7 +1764,7 @@ async fn gateway_skips_openai_chat_antigravity_cross_format_sync_candidate_as_tr
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Skipped); assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Skipped);
assert_eq!( assert_eq!(
stored_candidates[0].skip_reason.as_deref(), stored_candidates[0].skip_reason.as_deref(),
Some("transport_unsupported") Some("transport_provider_type_unsupported")
); );
tokio::time::sleep(std::time::Duration::from_millis(100)).await; tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(*report_hits.lock().expect("mutex should lock"), 0); assert_eq!(*report_hits.lock().expect("mutex should lock"), 0);

View File

@@ -398,7 +398,7 @@ async fn gateway_skips_unsupported_local_openai_chat_sync_candidate_before_tryin
assert_eq!(skipped_candidate.status, RequestCandidateStatus::Skipped); assert_eq!(skipped_candidate.status, RequestCandidateStatus::Skipped);
assert_eq!( assert_eq!(
skipped_candidate.skip_reason.as_deref(), skipped_candidate.skip_reason.as_deref(),
Some("transport_unsupported") Some("transport_provider_type_unsupported")
); );
assert!(skipped_candidate.started_at_unix_ms.is_none()); assert!(skipped_candidate.started_at_unix_ms.is_none());
assert!(skipped_candidate.finished_at_unix_ms.is_some()); assert!(skipped_candidate.finished_at_unix_ms.is_some());
@@ -665,7 +665,7 @@ async fn gateway_surfaces_local_execution_runtime_miss_reason_when_all_openai_ch
assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Skipped); assert_eq!(stored_candidates[0].status, RequestCandidateStatus::Skipped);
assert_eq!( assert_eq!(
stored_candidates[0].skip_reason.as_deref(), stored_candidates[0].skip_reason.as_deref(),
Some("transport_unsupported") Some("transport_provider_type_unsupported")
); );
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0); assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);

View File

@@ -69,11 +69,12 @@ 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_conversion_kind, 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_requires_enable_flag, request_conversion_transport_supported,
request_pair_allowed_for_transport, sync_chat_response_conversion_kind, request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
sync_cli_response_conversion_kind, LocalCoreSyncErrorKind, RequestConversionKind, sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, LocalCoreSyncErrorKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind, 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,9 +9,10 @@ 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_conversion_kind, 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_requires_enable_flag, request_conversion_transport_supported,
request_pair_allowed_for_transport, sync_chat_response_conversion_kind, request_conversion_transport_unsupported_reason, request_pair_allowed_for_transport,
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind, sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, RequestConversionKind,
SyncCliResponseConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
}; };

View File

@@ -4,11 +4,11 @@ use aether_provider_transport::auth::{
resolve_local_gemini_auth, resolve_local_openai_chat_auth, resolve_local_standard_auth, resolve_local_gemini_auth, resolve_local_openai_chat_auth, resolve_local_standard_auth,
}; };
use aether_provider_transport::policy::{ use aether_provider_transport::policy::{
supports_local_openai_chat_transport, supports_local_standard_transport_with_network, local_gemini_transport_unsupported_reason_with_network,
}; local_openai_chat_transport_unsupported_reason,
use aether_provider_transport::{ local_standard_transport_unsupported_reason_with_network,
supports_local_gemini_transport_with_network, GatewayProviderTransportSnapshot,
}; };
use aether_provider_transport::GatewayProviderTransportSnapshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestConversionKind { pub enum RequestConversionKind {
@@ -142,7 +142,7 @@ pub fn request_conversion_requires_enable_flag(
} }
} }
pub fn request_pair_allowed_for_transport( pub fn request_conversion_enabled_for_transport(
transport: &GatewayProviderTransportSnapshot, transport: &GatewayProviderTransportSnapshot,
client_api_format: &str, client_api_format: &str,
provider_api_format: &str, provider_api_format: &str,
@@ -162,12 +162,40 @@ pub fn request_pair_allowed_for_transport(
return true; return true;
} }
transport.provider.enable_format_conversion transport.provider.enable_format_conversion
|| endpoint_accepts_client_api_format(transport, client_api_format.as_str())
}
pub fn request_pair_allowed_for_transport(
transport: &GatewayProviderTransportSnapshot,
client_api_format: &str,
provider_api_format: &str,
) -> bool {
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 == provider_api_format {
return true;
}
if request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str()).is_none() {
return false;
}
request_conversion_enabled_for_transport(
transport,
client_api_format.as_str(),
provider_api_format.as_str(),
)
} }
pub fn request_conversion_transport_supported( pub fn request_conversion_transport_supported(
transport: &GatewayProviderTransportSnapshot, transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind, kind: RequestConversionKind,
) -> bool { ) -> bool {
request_conversion_transport_unsupported_reason(transport, kind).is_none()
}
pub fn request_conversion_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot,
_kind: RequestConversionKind,
) -> Option<&'static str> {
match transport match transport
.endpoint .endpoint
.api_format .api_format
@@ -175,16 +203,26 @@ pub fn request_conversion_transport_supported(
.to_ascii_lowercase() .to_ascii_lowercase()
.as_str() .as_str()
{ {
"openai:chat" => supports_local_openai_chat_transport(transport), "openai:chat" => local_openai_chat_transport_unsupported_reason(transport),
"openai:cli" => supports_local_standard_transport_with_network(transport, "openai:cli"), "openai:cli" => {
"openai:compact" => { local_standard_transport_unsupported_reason_with_network(transport, "openai:cli")
supports_local_standard_transport_with_network(transport, "openai:compact")
} }
"claude:chat" => supports_local_standard_transport_with_network(transport, "claude:chat"), "openai:compact" => {
"claude:cli" => supports_local_standard_transport_with_network(transport, "claude:cli"), local_standard_transport_unsupported_reason_with_network(transport, "openai:compact")
"gemini:chat" => supports_local_gemini_transport_with_network(transport, "gemini:chat"), }
"gemini:cli" => supports_local_gemini_transport_with_network(transport, "gemini:cli"), "claude:chat" => {
_ => false, local_standard_transport_unsupported_reason_with_network(transport, "claude:chat")
}
"claude:cli" => {
local_standard_transport_unsupported_reason_with_network(transport, "claude:cli")
}
"gemini:chat" => {
local_gemini_transport_unsupported_reason_with_network(transport, "gemini:chat")
}
"gemini:cli" => {
local_gemini_transport_unsupported_reason_with_network(transport, "gemini:cli")
}
_ => Some("transport_api_format_unsupported"),
} }
} }
@@ -231,13 +269,58 @@ fn api_data_format_id(api_format: &str) -> Option<&'static str> {
} }
} }
fn endpoint_accepts_client_api_format(
transport: &GatewayProviderTransportSnapshot,
client_api_format: &str,
) -> bool {
let Some(config) = transport
.endpoint
.format_acceptance_config
.as_ref()
.and_then(serde_json::Value::as_object)
else {
return false;
};
if !config
.get("enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return false;
}
if config
.get("reject_formats")
.is_some_and(|value| json_format_list_contains(value, client_api_format))
{
return false;
}
match config.get("accept_formats") {
Some(value) => json_format_list_contains(value, client_api_format),
None => true,
}
}
fn json_format_list_contains(value: &serde_json::Value, api_format: &str) -> bool {
let Some(items) = value.as_array() else {
return false;
};
items.iter().any(|item| {
item.as_str()
.is_some_and(|candidate| candidate.trim().eq_ignore_ascii_case(api_format))
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
request_candidate_api_formats, request_conversion_direct_auth, request_conversion_kind, 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_requires_enable_flag, request_conversion_transport_supported,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, request_pair_allowed_for_transport, sync_chat_response_conversion_kind,
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind, sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
}; };
use aether_provider_transport::snapshot::{ use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey, GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
@@ -440,4 +523,140 @@ mod tests {
Some(("authorization".to_string(), "Bearer secret".to_string())) Some(("authorization".to_string(), "Bearer secret".to_string()))
); );
} }
#[test]
fn endpoint_level_format_acceptance_enables_cross_format_pair_without_provider_flag() {
let transport = GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "provider".to_string(),
provider_type: "custom".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: false,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "openai:cli".to_string(),
api_family: Some("openai".to_string()),
endpoint_kind: Some("cli".to_string()),
is_active: true,
base_url: "https://right.codes/codex".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: Some("/v1/messages".to_string()),
config: None,
format_acceptance_config: Some(serde_json::json!({
"enabled": true,
"accept_formats": ["claude:cli"],
})),
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "key".to_string(),
auth_type: "bearer".to_string(),
is_active: true,
api_formats: Some(vec!["openai:cli".to_string()]),
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
decrypted_api_key: "secret".to_string(),
decrypted_auth_config: None,
},
};
assert!(request_conversion_enabled_for_transport(
&transport,
"claude:cli",
"openai:cli"
));
assert!(request_pair_allowed_for_transport(
&transport,
"claude:cli",
"openai:cli"
));
assert!(!request_pair_allowed_for_transport(
&transport,
"gemini:cli",
"openai:cli"
));
}
#[test]
fn endpoint_reject_formats_override_endpoint_cross_format_enablement() {
let transport = GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-1".to_string(),
name: "provider".to_string(),
provider_type: "custom".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: false,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-1".to_string(),
provider_id: "provider-1".to_string(),
api_format: "openai:cli".to_string(),
api_family: Some("openai".to_string()),
endpoint_kind: Some("cli".to_string()),
is_active: true,
base_url: "https://right.codes/codex".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: Some("/v1/messages".to_string()),
config: None,
format_acceptance_config: Some(serde_json::json!({
"enabled": true,
"reject_formats": ["claude:cli"],
})),
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-1".to_string(),
provider_id: "provider-1".to_string(),
name: "key".to_string(),
auth_type: "bearer".to_string(),
is_active: true,
api_formats: Some(vec!["openai:cli".to_string()]),
allowed_models: None,
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
decrypted_api_key: "secret".to_string(),
decrypted_auth_config: None,
},
};
assert!(!request_conversion_enabled_for_transport(
&transport,
"claude:cli",
"openai:cli"
));
}
} }

View File

@@ -43,12 +43,15 @@ pub mod vertex {
} }
pub use aether_provider_transport::{ pub use aether_provider_transport::{
apply_local_body_rules, apply_local_header_rules, body_rules_handle_path, apply_local_body_rules, apply_local_header_rules, body_rules_are_locally_supported,
build_passthrough_headers, ensure_upstream_auth_header, resolve_transport_execution_timeouts, body_rules_handle_path, build_passthrough_headers, ensure_upstream_auth_header,
header_rules_are_locally_supported, local_gemini_transport_unsupported_reason_with_network,
local_openai_chat_transport_unsupported_reason,
local_standard_transport_unsupported_reason_with_network, resolve_transport_execution_timeouts,
resolve_transport_proxy_snapshot, resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_proxy_snapshot, resolve_transport_proxy_snapshot_with_tunnel_affinity,
resolve_transport_tls_profile, should_skip_upstream_passthrough_header, resolve_transport_tls_profile, should_skip_upstream_passthrough_header,
supports_local_gemini_transport_with_network, supports_local_gemini_transport_with_network,
supports_local_generic_oauth_request_auth_resolution, supports_local_generic_oauth_request_auth_resolution,
supports_local_oauth_request_auth_resolution, GatewayProviderTransportSnapshot, supports_local_oauth_request_auth_resolution, transport_proxy_is_locally_supported,
LocalResolvedOAuthRequestAuth, GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
}; };

View File

@@ -33,8 +33,11 @@ pub use oauth_refresh::{
LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth, LocalOAuthRefreshError, LocalResolvedOAuthRequestAuth,
}; };
pub use policy::{ pub use policy::{
supports_local_gemini_transport, supports_local_gemini_transport_with_network, local_gemini_transport_unsupported_reason,
supports_local_standard_transport, local_gemini_transport_unsupported_reason_with_network,
local_openai_chat_transport_unsupported_reason, local_standard_transport_unsupported_reason,
local_standard_transport_unsupported_reason_with_network, supports_local_gemini_transport,
supports_local_gemini_transport_with_network, supports_local_standard_transport,
}; };
pub use rules::{ pub use rules::{
apply_local_body_rules, apply_local_header_rules, body_rules_are_locally_supported, apply_local_body_rules, apply_local_header_rules, body_rules_are_locally_supported,

View File

@@ -10,8 +10,48 @@ use super::{
}; };
pub fn supports_local_openai_chat_transport(transport: &GatewayProviderTransportSnapshot) -> bool { pub fn supports_local_openai_chat_transport(transport: &GatewayProviderTransportSnapshot) -> bool {
if !transport.provider.is_active || !transport.endpoint.is_active || !transport.key.is_active { local_openai_chat_transport_unsupported_reason(transport).is_none()
return false; }
pub fn supports_local_standard_transport(
transport: &GatewayProviderTransportSnapshot,
api_format: &str,
) -> bool {
local_standard_transport_unsupported_reason(transport, api_format).is_none()
}
pub fn supports_local_gemini_transport(
transport: &GatewayProviderTransportSnapshot,
api_format: &str,
) -> bool {
local_gemini_transport_unsupported_reason(transport, api_format).is_none()
}
pub fn supports_local_standard_transport_with_network(
transport: &GatewayProviderTransportSnapshot,
api_format: &str,
) -> bool {
local_standard_transport_unsupported_reason_with_network(transport, api_format).is_none()
}
pub fn supports_local_gemini_transport_with_network(
transport: &GatewayProviderTransportSnapshot,
api_format: &str,
) -> bool {
local_gemini_transport_unsupported_reason_with_network(transport, api_format).is_none()
}
pub fn local_openai_chat_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot,
) -> Option<&'static str> {
if !transport.provider.is_active {
return Some("provider_inactive");
}
if !transport.endpoint.is_active {
return Some("endpoint_inactive");
}
if !transport.key.is_active {
return Some("key_inactive");
} }
if !transport if !transport
.endpoint .endpoint
@@ -19,67 +59,94 @@ pub fn supports_local_openai_chat_transport(transport: &GatewayProviderTransport
.trim() .trim()
.eq_ignore_ascii_case("openai:chat") .eq_ignore_ascii_case("openai:chat")
{ {
return false; return Some("transport_api_format_mismatch");
} }
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) {
|| !body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref()) return Some("transport_header_rules_unsupported");
{ }
return false; if !body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref()) {
return Some("transport_body_rules_unsupported");
} }
if transport.key.decrypted_auth_config.is_some() if transport.key.decrypted_auth_config.is_some()
&& !supports_local_oauth_request_auth_resolution(transport) && !supports_local_oauth_request_auth_resolution(transport)
{ {
return false; return Some("transport_oauth_resolution_unsupported");
} }
if !transport_proxy_is_locally_supported(transport) { if !transport_proxy_is_locally_supported(transport) {
return false; return Some("transport_proxy_unsupported");
} }
if transport.key.fingerprint.is_some() && resolve_transport_tls_profile(transport).is_none() { if transport.key.fingerprint.is_some() && resolve_transport_tls_profile(transport).is_none() {
return false; return Some("transport_tls_profile_unsupported");
} }
if !provider_type_supports_local_openai_chat_transport(&transport.provider.provider_type) { if !provider_type_supports_local_openai_chat_transport(&transport.provider.provider_type) {
return false; return Some("transport_provider_type_unsupported");
} }
true None
} }
pub fn supports_local_standard_transport( pub fn local_standard_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot, transport: &GatewayProviderTransportSnapshot,
api_format: &str, api_format: &str,
) -> bool { ) -> Option<&'static str> {
supports_local_same_format_transport(transport, api_format, false) local_same_format_transport_unsupported_reason(
transport,
api_format,
false,
provider_type_supports_local_same_format_transport,
)
} }
pub fn supports_local_gemini_transport( pub fn local_gemini_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot, transport: &GatewayProviderTransportSnapshot,
api_format: &str, api_format: &str,
) -> bool { ) -> Option<&'static str> {
supports_local_same_format_transport(transport, api_format, false) local_same_format_transport_unsupported_reason(
transport,
api_format,
false,
provider_type_supports_local_same_format_transport,
)
} }
pub fn supports_local_standard_transport_with_network( pub fn local_standard_transport_unsupported_reason_with_network(
transport: &GatewayProviderTransportSnapshot, transport: &GatewayProviderTransportSnapshot,
api_format: &str, api_format: &str,
) -> bool { ) -> Option<&'static str> {
supports_local_same_format_transport(transport, api_format, true) local_same_format_transport_unsupported_reason(
transport,
api_format,
true,
provider_type_supports_local_same_format_transport,
)
} }
pub fn supports_local_gemini_transport_with_network( pub fn local_gemini_transport_unsupported_reason_with_network(
transport: &GatewayProviderTransportSnapshot, transport: &GatewayProviderTransportSnapshot,
api_format: &str, api_format: &str,
) -> bool { ) -> Option<&'static str> {
supports_local_same_format_transport(transport, api_format, true) local_same_format_transport_unsupported_reason(
transport,
api_format,
true,
provider_type_supports_local_same_format_transport,
)
} }
fn supports_local_same_format_transport( fn local_same_format_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot, transport: &GatewayProviderTransportSnapshot,
api_format: &str, api_format: &str,
allow_network_passthrough: bool, allow_network_passthrough: bool,
) -> bool { provider_type_supported: fn(&str) -> bool,
) -> Option<&'static str> {
if !transport.provider.is_active || !transport.endpoint.is_active || !transport.key.is_active { if !transport.provider.is_active || !transport.endpoint.is_active || !transport.key.is_active {
return false; return if !transport.provider.is_active {
Some("provider_inactive")
} else if !transport.endpoint.is_active {
Some("endpoint_inactive")
} else {
Some("key_inactive")
};
} }
if !transport if !transport
.endpoint .endpoint
@@ -87,17 +154,18 @@ fn supports_local_same_format_transport(
.trim() .trim()
.eq_ignore_ascii_case(api_format.trim()) .eq_ignore_ascii_case(api_format.trim())
{ {
return false; return Some("transport_api_format_mismatch");
} }
if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) if !header_rules_are_locally_supported(transport.endpoint.header_rules.as_ref()) {
|| !body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref()) return Some("transport_header_rules_unsupported");
{ }
return false; if !body_rules_are_locally_supported(transport.endpoint.body_rules.as_ref()) {
return Some("transport_body_rules_unsupported");
} }
if transport.key.decrypted_auth_config.is_some() if transport.key.decrypted_auth_config.is_some()
&& !supports_local_oauth_request_auth_resolution(transport) && !supports_local_oauth_request_auth_resolution(transport)
{ {
return false; return Some("transport_oauth_resolution_unsupported");
} }
let has_custom_path = transport let has_custom_path = transport
.endpoint .endpoint
@@ -105,15 +173,15 @@ fn supports_local_same_format_transport(
.as_deref() .as_deref()
.is_some_and(|value| !value.trim().is_empty()); .is_some_and(|value| !value.trim().is_empty());
if has_custom_path && !allow_network_passthrough { if has_custom_path && !allow_network_passthrough {
return false; return Some("transport_custom_path_unsupported");
} }
if allow_network_passthrough { if allow_network_passthrough {
if !transport_proxy_is_locally_supported(transport) { if !transport_proxy_is_locally_supported(transport) {
return false; return Some("transport_proxy_unsupported");
} }
if transport.key.fingerprint.is_some() && resolve_transport_tls_profile(transport).is_none() if transport.key.fingerprint.is_some() && resolve_transport_tls_profile(transport).is_none()
{ {
return false; return Some("transport_tls_profile_unsupported");
} }
} else if transport.provider.proxy.is_some() } else if transport.provider.proxy.is_some()
|| transport.endpoint.proxy.is_some() || transport.endpoint.proxy.is_some()
@@ -126,12 +194,12 @@ fn supports_local_same_format_transport(
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.is_some_and(|value| !value.trim().is_empty()) .is_some_and(|value| !value.trim().is_empty())
{ {
return false; return Some("transport_proxy_or_tls_unsupported");
} }
if !provider_type_supports_local_same_format_transport(&transport.provider.provider_type) { if !provider_type_supported(&transport.provider.provider_type) {
return false; return Some("transport_provider_type_unsupported");
} }
true None
} }

View File

@@ -376,6 +376,12 @@ pub struct CandidateRuntimeSelectabilityInput<'a> {
pub fn candidate_is_selectable_with_runtime_state( pub fn candidate_is_selectable_with_runtime_state(
input: CandidateRuntimeSelectabilityInput<'_>, input: CandidateRuntimeSelectabilityInput<'_>,
) -> bool { ) -> bool {
candidate_runtime_skip_reason_with_state(input).is_none()
}
pub fn candidate_runtime_skip_reason_with_state(
input: CandidateRuntimeSelectabilityInput<'_>,
) -> Option<&'static str> {
let CandidateRuntimeSelectabilityInput { let CandidateRuntimeSelectabilityInput {
candidate, candidate,
recent_candidates, recent_candidates,
@@ -388,7 +394,7 @@ pub fn candidate_is_selectable_with_runtime_state(
} = input; } = input;
if provider_quota_blocks_requests { if provider_quota_blocks_requests {
return false; return Some("provider_quota_blocked");
} }
if crate::is_candidate_in_recent_failure_cooldown( if crate::is_candidate_in_recent_failure_cooldown(
recent_candidates, recent_candidates,
@@ -397,7 +403,7 @@ pub fn candidate_is_selectable_with_runtime_state(
candidate.key_id.as_str(), candidate.key_id.as_str(),
now_unix_secs, now_unix_secs,
) { ) {
return false; return Some("recent_failure_cooldown");
} }
if provider_concurrent_limits if provider_concurrent_limits
.get(&candidate.provider_id) .get(&candidate.provider_id)
@@ -409,7 +415,7 @@ pub fn candidate_is_selectable_with_runtime_state(
) >= *limit ) >= *limit
}) })
{ {
return false; return Some("provider_concurrency_limit_reached");
} }
let is_cached_user = cached_affinity_target let is_cached_user = cached_affinity_target
@@ -417,12 +423,12 @@ pub fn candidate_is_selectable_with_runtime_state(
if let Some(provider_key) = provider_key_rpm_states.get(&candidate.key_id) { if let Some(provider_key) = provider_key_rpm_states.get(&candidate.key_id) {
if crate::is_provider_key_circuit_open(provider_key, candidate.endpoint_api_format.as_str()) if crate::is_provider_key_circuit_open(provider_key, candidate.endpoint_api_format.as_str())
{ {
return false; return Some("key_circuit_open");
} }
if crate::provider_key_health_score(provider_key, candidate.endpoint_api_format.as_str()) if crate::provider_key_health_score(provider_key, candidate.endpoint_api_format.as_str())
.is_some_and(|score| score <= 0.0) .is_some_and(|score| score <= 0.0)
{ {
return false; return Some("key_health_score_zero");
} }
if !crate::provider_key_rpm_allows_request_since( if !crate::provider_key_rpm_allows_request_since(
provider_key, provider_key,
@@ -431,11 +437,11 @@ pub fn candidate_is_selectable_with_runtime_state(
is_cached_user, is_cached_user,
rpm_reset_at, rpm_reset_at,
) { ) {
return false; return Some("key_rpm_exhausted");
} }
} }
true None
} }
fn compare_provider_key_health_order( fn compare_provider_key_health_order(

View File

@@ -16,12 +16,12 @@ pub use auth::{
}; };
pub use candidate::{ pub use candidate::{
auth_api_key_concurrency_limit_reached, build_minimal_candidate_selection, auth_api_key_concurrency_limit_reached, build_minimal_candidate_selection,
candidate_is_selectable_with_runtime_state, candidate_supports_required_capability, candidate_is_selectable_with_runtime_state, candidate_runtime_skip_reason_with_state,
collect_global_model_names_for_required_capability, collect_selectable_candidates_from_keys, candidate_supports_required_capability, collect_global_model_names_for_required_capability,
compare_candidates_by_priority_mode, reorder_candidates_by_scheduler_health, collect_selectable_candidates_from_keys, compare_candidates_by_priority_mode,
requested_capability_priority_for_candidate, BuildMinimalCandidateSelectionInput, reorder_candidates_by_scheduler_health, requested_capability_priority_for_candidate,
CandidateRuntimeSelectabilityInput, SchedulerMinimalCandidateSelectionCandidate, BuildMinimalCandidateSelectionInput, CandidateRuntimeSelectabilityInput,
SchedulerPriorityMode, SchedulerMinimalCandidateSelectionCandidate, SchedulerPriorityMode,
}; };
pub use health::{ pub use health::{
aggregate_provider_key_health_score, count_recent_active_requests_for_api_key, aggregate_provider_key_health_score, count_recent_active_requests_for_api_key,

View File

@@ -183,6 +183,9 @@ export interface RequestDetail {
has_response_body?: boolean has_response_body?: boolean
has_client_response_body?: boolean has_client_response_body?: boolean
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
routing?: Record<string, unknown>
body_capture?: Record<string, unknown>
trace?: Record<string, unknown>
settlement?: { settlement?: {
billing_snapshot?: Record<string, unknown> billing_snapshot?: Record<string, unknown>
billing_snapshot_schema_version?: string billing_snapshot_schema_version?: string

View File

@@ -30,6 +30,11 @@ export interface RoutingEndpointInfo {
api_format: string api_format: string
base_url: string base_url: string
custom_path?: string | null custom_path?: string | null
format_acceptance_config?: {
enabled?: boolean
accept_formats?: string[] | null
reject_formats?: string[] | null
} | null
is_active: boolean is_active: boolean
keys: RoutingKeyInfo[] keys: RoutingKeyInfo[]
total_keys: number total_keys: number
@@ -53,6 +58,8 @@ export interface RoutingProviderInfo {
name: string name: string
model_id: string model_id: string
provider_priority: number provider_priority: number
enable_format_conversion?: boolean
keep_priority_on_conversion?: boolean
billing_type?: string | null billing_type?: string | null
monthly_quota_usd?: number | null monthly_quota_usd?: number | null
monthly_used_usd?: number | null monthly_used_usd?: number | null
@@ -91,5 +98,6 @@ export interface ModelRoutingPreviewResponse {
active_providers: number active_providers: number
scheduling_mode: string scheduling_mode: string
priority_mode: string priority_mode: string
keep_priority_on_conversion?: boolean
all_keys_whitelist: GlobalKeyWhitelistItem[] all_keys_whitelist: GlobalKeyWhitelistItem[]
} }

View File

@@ -229,7 +229,7 @@
size="icon" size="icon"
class="h-6 w-6 text-green-600" class="h-6 w-6 text-green-600"
title="刷新健康状态" title="刷新健康状态"
@click.stop="handleRecoverKey(keyEntry.key.id, formatGroup.api_format)" @click.stop="handleRecoverKey(keyEntry.key.id, keyEntry.endpoint?.api_format || formatGroup.api_format)"
> >
<RefreshCw class="w-3 h-3" /> <RefreshCw class="w-3 h-3" />
</Button> </Button>
@@ -502,7 +502,7 @@
v-if="key.circuit_breaker_open || (key.health_score ?? 1) < 0.5" v-if="key.circuit_breaker_open || (key.health_score ?? 1) < 0.5"
class="p-0.5 rounded hover:bg-muted/50 text-green-600 shrink-0" class="p-0.5 rounded hover:bg-muted/50 text-green-600 shrink-0"
title="刷新健康状态" title="刷新健康状态"
@click.stop="handleRecoverKey(key.id, formatGroup.api_format)" @click.stop="handleRecoverKey(key.id, providerEntry.endpoint?.api_format || formatGroup.api_format)"
> >
<RefreshCw class="w-3 h-3" /> <RefreshCw class="w-3 h-3" />
</button> </button>
@@ -653,6 +653,8 @@ interface FormatProviderEntry {
endpoint: RoutingEndpointInfo | null endpoint: RoutingEndpointInfo | null
keys: RoutingKeyInfo[] keys: RoutingKeyInfo[]
active_keys: number active_keys: number
is_cross_format: boolean
priority_api_format: string
} }
// 全局 Key 模式下的 Key 条目(包含 Provider 信息) // 全局 Key 模式下的 Key 条目(包含 Provider 信息)
@@ -660,11 +662,14 @@ interface GlobalKeyEntry {
key: RoutingKeyInfo key: RoutingKeyInfo
provider: RoutingProviderInfo provider: RoutingProviderInfo
endpoint: RoutingEndpointInfo | null endpoint: RoutingEndpointInfo | null
is_cross_format: boolean
priority_api_format: string
} }
// 全局 Key 模式下的优先级分组 // 全局 Key 模式下的优先级分组
interface GlobalKeyGroup { interface GlobalKeyGroup {
priority: number | null priority: number | null
demote_cross_format: boolean
keys: GlobalKeyEntry[] keys: GlobalKeyEntry[]
} }
@@ -680,6 +685,128 @@ interface ApiFormatGroup {
active_keys: number active_keys: number
} }
const STANDARD_ROUTING_API_FORMATS = [
'openai:chat',
'openai:cli',
'claude:chat',
'claude:cli',
'gemini:chat',
'gemini:cli'
]
function apiDataFormatId(apiFormat: string): string | null {
switch (apiFormat.trim().toLowerCase()) {
case 'claude:chat':
case 'claude:cli':
return 'claude'
case 'gemini:chat':
case 'gemini:cli':
return 'gemini'
case 'openai:chat':
return 'openai_chat'
case 'openai:cli':
case 'openai:compact':
return 'openai_responses'
default:
return null
}
}
function requestConversionKind(clientApiFormat: string, providerApiFormat: string): string | null {
const client = clientApiFormat.trim().toLowerCase()
const provider = providerApiFormat.trim().toLowerCase()
if (client === provider) return null
if (!STANDARD_ROUTING_API_FORMATS.includes(client) || !STANDARD_ROUTING_API_FORMATS.includes(provider)) {
return null
}
switch (provider) {
case 'openai:chat':
return 'to_openai_chat'
case 'openai:cli':
return 'to_openai_cli'
case 'claude:chat':
case 'claude:cli':
return 'to_claude'
case 'gemini:chat':
case 'gemini:cli':
return 'to_gemini'
default:
return null
}
}
function requestConversionRequiresEnableFlag(clientApiFormat: string, providerApiFormat: string): boolean {
const clientDataFormat = apiDataFormatId(clientApiFormat)
const providerDataFormat = apiDataFormatId(providerApiFormat)
if (!clientDataFormat || !providerDataFormat) return true
return clientDataFormat !== providerDataFormat
}
function endpointFormatAcceptanceEnabled(endpoint: RoutingEndpointInfo): boolean {
return endpoint.format_acceptance_config?.enabled === true
}
function endpointFormatListContains(formats: string[] | null | undefined, apiFormat: string): boolean {
if (!Array.isArray(formats)) return false
return formats.some(value => value.trim().toLowerCase() === apiFormat.trim().toLowerCase())
}
function endpointAcceptsClientFormat(endpoint: RoutingEndpointInfo, clientApiFormat: string): boolean {
if (!endpointFormatAcceptanceEnabled(endpoint)) return false
const config = endpoint.format_acceptance_config
if (endpointFormatListContains(config?.reject_formats, clientApiFormat)) {
return false
}
if (config?.accept_formats) {
return endpointFormatListContains(config.accept_formats, clientApiFormat)
}
return true
}
function endpointSupportsClientFormat(
provider: RoutingProviderInfo,
endpoint: RoutingEndpointInfo,
clientApiFormat: string,
providerApiFormat: string
): boolean {
const clientFormat = clientApiFormat.trim().toLowerCase()
const providerFormat = providerApiFormat.trim().toLowerCase()
if (clientFormat === providerFormat) return true
if (!requestConversionKind(clientFormat, providerFormat)) return false
if (!requestConversionRequiresEnableFlag(clientFormat, providerFormat)) return true
return !!provider.enable_format_conversion || endpointAcceptsClientFormat(endpoint, clientFormat)
}
function targetFormatsForEndpoint(
provider: RoutingProviderInfo,
endpoint: RoutingEndpointInfo
): string[] {
return STANDARD_ROUTING_API_FORMATS.filter(format =>
endpointSupportsClientFormat(provider, endpoint, format, endpoint.api_format)
)
}
function keepPriorityOnConversion(provider: RoutingProviderInfo): boolean {
return !!(routingData.value?.keep_priority_on_conversion || provider.keep_priority_on_conversion)
}
function shouldDemoteCrossFormat(
targetApiFormat: string,
entryApiFormat: string,
provider: RoutingProviderInfo
): boolean {
return targetApiFormat !== entryApiFormat && !keepPriorityOnConversion(provider)
}
function resolvedGlobalKeyPriority(keyEntry: GlobalKeyEntry): number {
const priorityByFormat = keyEntry.key.global_priority_by_format
if (!priorityByFormat) return 999
const value = priorityByFormat[keyEntry.priority_api_format]
return typeof value === 'number' ? value : 999
}
// 按 API 格式分组的计算属性 // 按 API 格式分组的计算属性
const apiFormatGroups = computed<ApiFormatGroup[]>(() => { const apiFormatGroups = computed<ApiFormatGroup[]>(() => {
if (!routingData.value) return [] if (!routingData.value) return []
@@ -692,29 +819,32 @@ const apiFormatGroups = computed<ApiFormatGroup[]>(() => {
// 遍历所有提供商和它们的 endpoints // 遍历所有提供商和它们的 endpoints
for (const provider of routingData.value.providers) { for (const provider of routingData.value.providers) {
for (const endpoint of provider.endpoints || []) { for (const endpoint of provider.endpoints || []) {
const format = endpoint.api_format for (const format of targetFormatsForEndpoint(provider, endpoint)) {
if (!formatMap.has(format)) { if (!formatMap.has(format)) {
formatMap.set(format, { providers: [], allKeys: [] }) formatMap.set(format, { providers: [], allKeys: [] })
} }
const data = formatMap.get(format) const data = formatMap.get(format)
if (!data) continue if (!data) continue
// 添加 provider entry data.providers.push({
data.providers.push({
provider,
endpoint,
keys: endpoint.keys || [],
active_keys: endpoint.active_keys || 0
})
// 添加 key entries用于全局 Key 模式)
for (const key of endpoint.keys || []) {
data.allKeys.push({
key,
provider, provider,
endpoint endpoint,
keys: endpoint.keys || [],
active_keys: endpoint.active_keys || 0,
is_cross_format: endpoint.api_format !== format,
priority_api_format: endpoint.api_format
}) })
for (const key of endpoint.keys || []) {
data.allKeys.push({
key,
provider,
endpoint,
is_cross_format: endpoint.api_format !== format,
priority_api_format: endpoint.api_format
})
}
} }
} }
} }
@@ -727,29 +857,39 @@ const apiFormatGroups = computed<ApiFormatGroup[]>(() => {
const aActive = a.provider.is_active && a.provider.model_is_active const aActive = a.provider.is_active && a.provider.model_is_active
const bActive = b.provider.is_active && b.provider.model_is_active const bActive = b.provider.is_active && b.provider.model_is_active
if (aActive !== bActive) return bActive ? 1 : -1 if (aActive !== bActive) return bActive ? 1 : -1
const aDemoted = shouldDemoteCrossFormat(format, a.priority_api_format, a.provider)
const bDemoted = shouldDemoteCrossFormat(format, b.priority_api_format, b.provider)
if (aDemoted !== bDemoted) return aDemoted ? 1 : -1
return a.provider.provider_priority - b.provider.provider_priority return a.provider.provider_priority - b.provider.provider_priority
}) })
// Key 按全局优先级分组排序(全局 Key 优先模式) // Key 按全局优先级分组排序(全局 Key 优先模式)
// 从 global_priority_by_format 中提取当前 API 格式的优先级 const keyGroupMap = new Map<string, GlobalKeyGroup>()
const keyGroupMap = new Map<number, GlobalKeyEntry[]>()
for (const keyEntry of data.allKeys) { for (const keyEntry of data.allKeys) {
const priorityByFormat = keyEntry.key.global_priority_by_format const priority = resolvedGlobalKeyPriority(keyEntry)
const priority = (priorityByFormat && format in priorityByFormat) const demoteCrossFormat = shouldDemoteCrossFormat(format, keyEntry.priority_api_format, keyEntry.provider)
? priorityByFormat[format] const groupKey = `${demoteCrossFormat ? 1 : 0}:${priority}`
: 999 if (!keyGroupMap.has(groupKey)) {
if (!keyGroupMap.has(priority)) { keyGroupMap.set(groupKey, {
keyGroupMap.set(priority, []) priority: priority === 999 ? null : priority,
demote_cross_format: demoteCrossFormat,
keys: []
})
} }
keyGroupMap.get(priority)?.push(keyEntry) keyGroupMap.get(groupKey)?.keys.push(keyEntry)
} }
// 转换为分组数组并排序 // 转换为分组数组并排序
const keyGroups: GlobalKeyGroup[] = Array.from(keyGroupMap.entries()) const keyGroups: GlobalKeyGroup[] = Array.from(keyGroupMap.values())
.sort((a, b) => a[0] - b[0]) .sort((a, b) => {
.map(([priority, keys]) => ({ if (a.demote_cross_format !== b.demote_cross_format) {
priority: priority === 999 ? null : priority, return a.demote_cross_format ? 1 : -1
keys: keys.sort((a, b) => { }
return (a.priority ?? 999) - (b.priority ?? 999)
})
.map(group => ({
...group,
keys: group.keys.sort((a, b) => {
// 同优先级内按活跃状态和健康度排序 // 同优先级内按活跃状态和健康度排序
const aActive = a.key.is_active && a.provider.is_active && a.provider.model_is_active const aActive = a.key.is_active && a.provider.is_active && a.provider.model_is_active
const bActive = b.key.is_active && b.provider.is_active && b.provider.model_is_active const bActive = b.key.is_active && b.provider.is_active && b.provider.model_is_active

View File

@@ -651,7 +651,7 @@
<TabsContent value="metadata"> <TabsContent value="metadata">
<JsonContent <JsonContent
:data="detail.metadata" :data="metadataPanelData"
:view-mode="viewMode" :view-mode="viewMode"
:expand-depth="currentExpandDepth" :expand-depth="currentExpandDepth"
:is-dark="isDark" :is-dark="isDark"
@@ -855,6 +855,29 @@ const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
return meta as Record<string, unknown> return meta as Record<string, unknown>
}) })
const metadataPanelData = computed<Record<string, unknown> | null>(() => {
if (!detail.value) return null
const merged: Record<string, unknown> = {}
if (hasContent(detail.value.metadata)) {
Object.assign(merged, detail.value.metadata || {})
}
if (hasContent(detail.value.routing)) {
merged.routing = detail.value.routing
}
if (hasContent(detail.value.body_capture)) {
merged.body_capture = detail.value.body_capture
}
if (hasContent(detail.value.trace)) {
merged.trace = detail.value.trace
}
if (hasContent(detail.value.settlement)) {
merged.settlement = detail.value.settlement
}
return Object.keys(merged).length > 0 ? merged : null
})
const settlementInfo = computed<JsonRecord | null>(() => const settlementInfo = computed<JsonRecord | null>(() =>
asRecord(detail.value?.settlement ?? null), asRecord(detail.value?.settlement ?? null),
) )
@@ -1566,7 +1589,7 @@ const visibleTabs = computed(() => {
case 'response-body': case 'response-body':
return hasResponseBodyAvailable.value return hasResponseBodyAvailable.value
case 'metadata': case 'metadata':
return hasContent(detail.value?.metadata) return hasContent(metadataPanelData.value)
default: default:
return false return false
} }
@@ -1970,7 +1993,7 @@ function copyContent(tabName: string) {
data = currentResponseBody.value data = currentResponseBody.value
break break
case 'metadata': case 'metadata':
data = detail.value.metadata data = metadataPanelData.value
break break
} }
if (data) { if (data) {