mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(gateway): 增强候选路由策略与可观测信息
This commit is contained in:
@@ -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)]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user