Refactor pool candidate scheduling

This commit is contained in:
fawney19
2026-05-03 20:14:29 +08:00
parent 8ebee9922c
commit a24e4a793d
55 changed files with 4825 additions and 311 deletions

View File

@@ -9,6 +9,7 @@ use aether_ai_serving::{
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::VecDeque;
use std::convert::Infallible;
use uuid::Uuid;
@@ -16,16 +17,18 @@ use crate::ai_serving::planner::candidate_affinity_cache::remember_scheduler_aff
use crate::ai_serving::planner::candidate_resolution::{
resolve_and_rank_local_execution_candidates,
resolve_and_rank_local_execution_candidates_without_transport_pair_gate,
EligibleLocalExecutionCandidate, SkippedLocalExecutionCandidate,
resolve_and_rank_logical_local_execution_candidates, EligibleLocalExecutionCandidate,
LocalExecutionCandidateKind, SkippedLocalExecutionCandidate,
};
use crate::ai_serving::planner::materialization_policy::LocalCandidatePersistencePolicy;
use crate::ai_serving::planner::pool_scheduler::PoolKeyCursor;
use crate::ai_serving::planner::runtime_miss::record_local_runtime_candidate_skip_reason;
use crate::ai_serving::planner::CandidateFailureDiagnostic;
use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::clock::current_unix_ms;
use crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value;
use crate::orchestration::{local_attempt_slot_count, ExecutionAttemptIdentity};
use crate::AppState;
use crate::{AppState, GatewayError};
#[derive(Debug, Clone)]
pub(crate) struct LocalExecutionCandidateAttempt {
@@ -35,6 +38,71 @@ pub(crate) struct LocalExecutionCandidateAttempt {
pub(crate) candidate_id: String,
}
pub(crate) struct LocalExecutionCandidateAttemptSource<'a> {
items: VecDeque<LocalExecutionCandidateAttemptSourceItem<'a>>,
}
#[async_trait]
pub(crate) trait LocalExecutionAttemptSource<T>: Send {
async fn next_execution_attempt(&mut self) -> Result<Option<T>, GatewayError>;
async fn drain_execution_attempts(&mut self) -> Result<Vec<T>, GatewayError>;
}
enum LocalExecutionCandidateAttemptSourceItem<'a> {
Static {
attempts: VecDeque<LocalExecutionCandidateAttempt>,
},
Pool {
cursor: PoolKeyCursor<'a>,
candidate_index: u32,
pending_attempts: VecDeque<LocalExecutionCandidateAttempt>,
},
}
impl<'a> LocalExecutionCandidateAttemptSource<'a> {
pub(crate) async fn next_attempt(&mut self) -> Option<LocalExecutionCandidateAttempt> {
loop {
let front = self.items.front_mut()?;
match front {
LocalExecutionCandidateAttemptSourceItem::Static { attempts } => {
if let Some(attempt) = attempts.pop_front() {
if attempts.is_empty() {
self.items.pop_front();
}
return Some(attempt);
}
self.items.pop_front();
}
LocalExecutionCandidateAttemptSourceItem::Pool {
cursor,
candidate_index,
pending_attempts,
} => {
if let Some(attempt) = pending_attempts.pop_front() {
return Some(attempt);
}
let Some(candidate) = cursor.next_key().await else {
cursor.log_exhausted();
let _ = cursor.take_skipped_candidates();
self.items.pop_front();
continue;
};
*pending_attempts = build_unpersisted_local_execution_candidate_attempts(
candidate,
*candidate_index,
);
}
}
}
}
pub(crate) fn drain_static_attempts(&mut self) -> Vec<LocalExecutionCandidateAttempt> {
self.items.clear();
Vec::new()
}
}
impl LocalExecutionCandidateAttempt {
pub(crate) fn attempt_identity(&self) -> ExecutionAttemptIdentity {
ExecutionAttemptIdentity::new(self.candidate_index, self.retry_index)
@@ -350,6 +418,99 @@ where
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn build_local_execution_candidate_attempt_source_with_serving<'a, F, G>(
state: PlannerAppState<'a>,
trace_id: &str,
client_api_format: &str,
requested_model: Option<&str>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&Value>,
sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
persistence_policy: LocalCandidatePersistencePolicy<'_>,
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
preselection_skipped: Vec<SkippedLocalExecutionCandidate>,
resolution_mode: LocalCandidateResolutionMode,
build_available_extra_data: F,
decorate_skipped_candidate: G,
) -> (LocalExecutionCandidateAttemptSource<'a>, usize)
where
F: Fn(&EligibleLocalExecutionCandidate) -> Option<Value> + Send + Sync,
G: Fn(SkippedLocalExecutionCandidate) -> SkippedLocalExecutionCandidate + Send + Sync,
{
let (candidates, resolved_skipped) = resolve_and_rank_logical_local_execution_candidates(
state,
candidates,
client_api_format,
requested_model,
auth_snapshot,
required_capabilities,
sticky_session_token,
request_auth_channel,
resolution_mode,
)
.await;
let skipped_candidate_count = preselection_skipped.len() + resolved_skipped.len();
let skipped_candidates = preselection_skipped
.into_iter()
.chain(resolved_skipped)
.map(decorate_skipped_candidate)
.collect::<Vec<_>>();
let candidate_count = candidates.len() + skipped_candidate_count;
if persistence_policy.skipped.record_runtime_miss_diagnostic {
for skipped_candidate in &skipped_candidates {
record_local_runtime_candidate_skip_reason(
state.app(),
trace_id,
skipped_candidate.skip_reason,
);
}
}
remember_first_local_candidate_affinity(
state,
auth_snapshot,
client_api_format,
requested_model,
&candidates,
);
let mut items = VecDeque::new();
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let candidate_index = candidate_index as u32;
match candidate.kind {
LocalExecutionCandidateKind::SingleKey => {
let attempts = build_unpersisted_local_execution_candidate_attempts(
candidate,
candidate_index,
);
if !attempts.is_empty() {
items.push_back(LocalExecutionCandidateAttemptSourceItem::Static { attempts });
}
}
LocalExecutionCandidateKind::PoolGroup => {
items.push_back(LocalExecutionCandidateAttemptSourceItem::Pool {
cursor: PoolKeyCursor::new(
state,
candidate,
sticky_session_token,
requested_model,
request_auth_channel,
),
candidate_index,
pending_attempts: VecDeque::new(),
});
}
}
}
(
LocalExecutionCandidateAttemptSource { items },
candidate_count,
)
}
pub(crate) fn remember_first_local_candidate_affinity(
state: PlannerAppState<'_>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
@@ -438,6 +599,99 @@ where
.await
}
async fn persist_available_local_execution_candidate_at_index<F>(
state: PlannerAppState<'_>,
trace_id: &str,
context: LocalAvailableCandidatePersistenceContext<'_>,
candidate: EligibleLocalExecutionCandidate,
candidate_index: u32,
build_extra_data: &F,
) -> Vec<LocalExecutionCandidateAttempt>
where
F: Fn(&EligibleLocalExecutionCandidate) -> Option<Value> + Send + Sync,
{
let attempt_slots = local_attempt_slot_count(&candidate.transport).max(1);
let extra_data = ai_candidate_extra_data_with_ranking(
build_extra_data(&candidate),
candidate.ranking.as_ref(),
);
let should_persist = should_persist_available_local_candidate(&candidate);
let mut attempts = Vec::with_capacity(attempt_slots as usize);
let mut owned_candidate = Some(candidate);
for retry_index in 0..attempt_slots {
let candidate_ref = owned_candidate
.as_ref()
.expect("candidate should remain available until final retry");
let generated_candidate_id = Uuid::new_v4().to_string();
let candidate_id = if should_persist {
state
.persist_available_local_candidate(
trace_id,
context.user_id,
context.api_key_id,
&candidate_ref.candidate,
candidate_index,
retry_index,
generated_candidate_id.as_str(),
context.required_capabilities,
extra_data.clone(),
current_unix_ms(),
context.error_context,
)
.await
} else {
generated_candidate_id
};
let candidate = if retry_index + 1 == attempt_slots {
owned_candidate
.take()
.expect("final retry should consume owned candidate")
} else {
candidate_ref.clone()
};
attempts.push(LocalExecutionCandidateAttempt {
eligible: candidate,
candidate_index,
retry_index,
candidate_id,
});
}
attempts
}
fn build_unpersisted_local_execution_candidate_attempts(
candidate: EligibleLocalExecutionCandidate,
candidate_index: u32,
) -> VecDeque<LocalExecutionCandidateAttempt> {
let attempt_slots = local_attempt_slot_count(&candidate.transport).max(1);
let mut attempts = VecDeque::with_capacity(attempt_slots as usize);
let mut owned_candidate = Some(candidate);
for retry_index in 0..attempt_slots {
let candidate = if retry_index + 1 == attempt_slots {
owned_candidate
.take()
.expect("final retry should consume owned candidate")
} else {
owned_candidate
.as_ref()
.expect("candidate should remain available until final retry")
.clone()
};
attempts.push_back(LocalExecutionCandidateAttempt {
eligible: candidate,
candidate_index,
retry_index,
candidate_id: Uuid::new_v4().to_string(),
});
}
attempts
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn persist_skipped_local_execution_candidate(
state: &AppState,
@@ -604,6 +858,7 @@ pub(crate) async fn persist_skipped_local_execution_candidates_with_context(
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::sync::Arc;
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
@@ -707,6 +962,7 @@ mod tests {
pool_key_index: Option<u32>,
) -> EligibleLocalExecutionCandidate {
EligibleLocalExecutionCandidate {
kind: LocalExecutionCandidateKind::SingleKey,
candidate: sample_candidate(key_id),
transport: sample_transport(
key_id,
@@ -722,7 +978,7 @@ mod tests {
}
#[tokio::test]
async fn pool_group_representatives_are_persisted_as_available_before_attempt() {
async fn pool_group_keys_are_not_persisted_as_available_before_attempt() {
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
let app = AppState::new()
.expect("state should build")
@@ -753,11 +1009,9 @@ mod tests {
.read_request_candidates_by_request_id("trace-pool-lazy")
.await
.expect("request candidates should read");
assert_eq!(stored.len(), 2);
assert_eq!(stored[0].key_id.as_deref(), Some("pool-key"));
assert_eq!(stored[0].candidate_index, 0);
assert_eq!(stored[1].key_id.as_deref(), Some("normal-key"));
assert_eq!(stored[1].candidate_index, 2);
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].key_id.as_deref(), Some("normal-key"));
assert_eq!(stored[0].candidate_index, 2);
}
#[tokio::test]
@@ -818,6 +1072,28 @@ mod tests {
assert_eq!(extra_data.get("demoted_by"), Some(&json!("cross_format")));
}
#[tokio::test]
async fn dynamic_attempt_source_does_not_drain_unexecuted_single_keys() {
let mut source = LocalExecutionCandidateAttemptSource {
items: VecDeque::from([LocalExecutionCandidateAttemptSourceItem::Static {
attempts: build_unpersisted_local_execution_candidate_attempts(
sample_eligible("normal-key", None),
0,
),
}]),
};
let first = source
.next_attempt()
.await
.expect("first attempt should be available");
assert_eq!(first.eligible.candidate.key_id, "normal-key");
let remaining = source.drain_static_attempts();
assert!(remaining.is_empty());
assert!(source.next_attempt().await.is_none());
}
#[tokio::test]
async fn pool_internal_skipped_candidates_are_not_persisted() {
let repository = Arc::new(InMemoryRequestCandidateRepository::default());

View File

@@ -219,7 +219,10 @@ mod tests {
use super::super::candidate_affinity_cache::remember_scheduler_affinity_for_candidate;
use super::super::candidate_transport_ranking_facts::resolve_cached_candidate_transport_ranking_facts;
use super::{PlannerAppState, SchedulerMinimalCandidateSelectionCandidate};
use crate::ai_serving::planner::candidate_resolution::resolve_and_rank_local_execution_candidates;
use crate::ai_serving::planner::candidate_resolution::{
resolve_and_rank_local_execution_candidates,
resolve_and_rank_logical_local_execution_candidates, LocalExecutionCandidateKind,
};
use crate::data::auth::GatewayAuthApiKeySnapshot;
use crate::data::GatewayDataState;
use crate::tunnel::TunnelAttachmentRecord;
@@ -1708,7 +1711,7 @@ mod tests {
}
#[tokio::test]
async fn pool_key_affinity_promotes_pool_group_when_cached_key_is_inactive() {
async fn pool_key_affinity_promotes_logical_pool_group_when_cached_key_is_inactive() {
let provider_catalog = InMemoryProviderCatalogReadRepository::seed(
vec![
sample_provider_with_options("provider-priority", false, 0),
@@ -1764,18 +1767,10 @@ mod tests {
&cached_candidate,
);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
let (ranked, skipped) = resolve_and_rank_logical_local_execution_candidates(
PlannerAppState::new(&state),
vec![
cached_candidate,
sample_priority_candidate(
"provider-pool",
"endpoint-pool",
"key-fallback",
"openai:chat",
Some(10),
10,
),
sample_priority_candidate(
"provider-priority",
"endpoint-priority",
@@ -1786,16 +1781,18 @@ mod tests {
),
],
"openai:chat",
"gpt-4.1",
Some("gpt-4.1"),
Some(&auth_snapshot),
None,
None,
None,
aether_ai_serving::AiCandidateResolutionMode::Standard,
)
.await;
assert_eq!(ranked[0].candidate.key_id, "key-fallback");
assert_eq!(ranked[0].orchestration.pool_key_index, Some(0));
assert_eq!(ranked[0].candidate.key_id, "key-cached");
assert_eq!(ranked[0].kind, LocalExecutionCandidateKind::PoolGroup);
assert_eq!(ranked[0].orchestration.pool_key_index, None);
assert_eq!(
ranked[0]
.ranking
@@ -1803,17 +1800,11 @@ mod tests {
.and_then(|ranking| ranking.promoted_by),
Some(RANKING_REASON_CACHED_AFFINITY)
);
assert_eq!(
skipped
.iter()
.map(|item| (item.candidate.key_id.as_str(), item.skip_reason))
.collect::<Vec<_>>(),
vec![("key-cached", "key_inactive")]
);
assert!(skipped.is_empty());
}
#[tokio::test]
async fn pool_key_affinity_promotes_pool_group_when_cached_key_is_blocked() {
async fn pool_key_affinity_promotes_logical_pool_group_when_cached_key_is_blocked() {
let mut cached_key = sample_key_for_provider("provider-pool", "key-cached", "");
cached_key.oauth_invalid_reason =
Some("[ACCOUNT_BLOCK] account has been deactivated".to_string());
@@ -1866,18 +1857,10 @@ mod tests {
&cached_candidate,
);
let (ranked, skipped) = resolve_and_rank_local_execution_candidates(
let (ranked, skipped) = resolve_and_rank_logical_local_execution_candidates(
PlannerAppState::new(&state),
vec![
cached_candidate,
sample_priority_candidate(
"provider-pool",
"endpoint-pool",
"key-fallback",
"openai:chat",
Some(10),
10,
),
sample_priority_candidate(
"provider-priority",
"endpoint-priority",
@@ -1888,15 +1871,18 @@ mod tests {
),
],
"openai:chat",
"gpt-4.1",
Some("gpt-4.1"),
Some(&auth_snapshot),
None,
None,
None,
aether_ai_serving::AiCandidateResolutionMode::Standard,
)
.await;
assert_eq!(ranked[0].candidate.key_id, "key-fallback");
assert_eq!(ranked[0].candidate.key_id, "key-cached");
assert_eq!(ranked[0].kind, LocalExecutionCandidateKind::PoolGroup);
assert_eq!(ranked[0].orchestration.pool_key_index, None);
assert_eq!(
ranked[0]
.ranking
@@ -1904,13 +1890,7 @@ mod tests {
.and_then(|ranking| ranking.promoted_by),
Some(RANKING_REASON_CACHED_AFFINITY)
);
assert_eq!(
skipped
.iter()
.map(|item| (item.candidate.key_id.as_str(), item.skip_reason))
.collect::<Vec<_>>(),
vec![("key-cached", "pool_account_blocked")]
);
assert!(skipped.is_empty());
}
#[tokio::test]

View File

@@ -22,6 +22,7 @@ use super::pool_scheduler::apply_local_execution_pool_scheduler;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct EligibleLocalExecutionCandidate {
pub(crate) kind: LocalExecutionCandidateKind,
pub(crate) candidate: SchedulerMinimalCandidateSelectionCandidate,
pub(crate) transport: Arc<GatewayProviderTransportSnapshot>,
pub(crate) provider_api_format: String,
@@ -29,6 +30,13 @@ pub(crate) struct EligibleLocalExecutionCandidate {
pub(crate) ranking: Option<SchedulerRankingOutcome>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum LocalExecutionCandidateKind {
#[default]
SingleKey,
PoolGroup,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct SkippedLocalExecutionCandidate {
pub(crate) candidate: SchedulerMinimalCandidateSelectionCandidate,
@@ -87,6 +95,9 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
transport: &Self::Transport,
requested_model: Option<&str>,
) -> Option<&'static str> {
if provider_transport_uses_pool(transport) {
return pool_group_common_transport_skip_reason(candidate, transport);
}
if let Some(skip_reason) =
candidate_auth_channel_skip_reason(transport, self.request_auth_channel)
{
@@ -131,7 +142,13 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
transport: Self::Transport,
) -> Self::Eligible {
let provider_api_format = transport.endpoint.api_format.trim().to_ascii_lowercase();
let kind = if provider_transport_uses_pool(&transport) {
LocalExecutionCandidateKind::PoolGroup
} else {
LocalExecutionCandidateKind::SingleKey
};
EligibleLocalExecutionCandidate {
kind,
candidate,
transport: Arc::new(transport),
provider_api_format,
@@ -160,10 +177,14 @@ impl AiCandidateResolutionPort for GatewayLocalCandidateResolutionPort<'_> {
&self,
candidates: Vec<Self::Eligible>,
) -> Result<(Vec<Self::Eligible>, Vec<Self::Skipped>), Self::Error> {
Ok(
apply_local_execution_pool_scheduler(self.state, candidates, self.sticky_session_token)
.await,
Ok(apply_local_execution_pool_scheduler(
self.state,
candidates,
self.sticky_session_token,
self.requested_model,
self.request_auth_channel,
)
.await)
}
}
@@ -223,6 +244,35 @@ pub(crate) async fn resolve_and_rank_local_execution_candidates_without_transpor
.await
}
pub(crate) async fn resolve_and_rank_logical_local_execution_candidates(
state: PlannerAppState<'_>,
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
client_api_format: &str,
requested_model: Option<&str>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>,
sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
mode: AiCandidateResolutionMode,
) -> (
Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
) {
resolve_and_rank_local_execution_candidates_with_pool_expansion(
state,
candidates,
client_api_format,
requested_model,
auth_snapshot,
required_capabilities,
sticky_session_token,
request_auth_channel,
mode,
false,
)
.await
}
async fn resolve_and_rank_local_execution_candidates_with_mode(
state: PlannerAppState<'_>,
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -236,6 +286,37 @@ async fn resolve_and_rank_local_execution_candidates_with_mode(
) -> (
Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
) {
resolve_and_rank_local_execution_candidates_with_pool_expansion(
state,
candidates,
client_api_format,
requested_model,
auth_snapshot,
required_capabilities,
sticky_session_token,
request_auth_channel,
mode,
true,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn resolve_and_rank_local_execution_candidates_with_pool_expansion(
state: PlannerAppState<'_>,
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
client_api_format: &str,
requested_model: Option<&str>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>,
sticky_session_token: Option<&str>,
request_auth_channel: Option<&str>,
mode: AiCandidateResolutionMode,
expand_pool_groups: bool,
) -> (
Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
) {
let port = GatewayLocalCandidateResolutionPort {
state,
@@ -250,6 +331,7 @@ async fn resolve_and_rank_local_execution_candidates_with_mode(
client_api_format,
requested_model,
mode,
expand_pool_groups,
};
match run_ai_candidate_resolution(&port, candidates, request).await {
@@ -269,7 +351,33 @@ fn candidate_transport_policy_facts(
}
}
fn candidate_auth_channel_skip_reason(
fn provider_transport_uses_pool(transport: &GatewayProviderTransportSnapshot) -> bool {
crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value(
transport.provider.config.as_ref(),
)
.is_some()
}
fn pool_group_common_transport_skip_reason(
candidate: &SchedulerMinimalCandidateSelectionCandidate,
transport: &GatewayProviderTransportSnapshot,
) -> Option<&'static str> {
if !transport.provider.is_active {
return Some("provider_inactive");
}
if !transport.endpoint.is_active {
return Some("endpoint_inactive");
}
if !crate::ai_serving::api_format_alias_matches(
candidate.endpoint_api_format.as_str(),
transport.endpoint.api_format.trim(),
) {
return Some("endpoint_api_format_changed");
}
None
}
pub(crate) fn candidate_auth_channel_skip_reason(
transport: &GatewayProviderTransportSnapshot,
request_auth_channel: Option<&str>,
) -> Option<&'static str> {
@@ -381,12 +489,13 @@ pub(crate) async fn read_candidate_transport_snapshot(
#[cfg(test)]
mod tests {
use super::candidate_auth_channel_skip_reason;
use super::{candidate_auth_channel_skip_reason, pool_group_common_transport_skip_reason};
use crate::ai_serving::GatewayProviderTransportSnapshot;
use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider,
};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use serde_json::json;
fn sample_transport(auth_type: &str) -> GatewayProviderTransportSnapshot {
@@ -444,6 +553,28 @@ mod tests {
}
}
fn sample_candidate() -> SchedulerMinimalCandidateSelectionCandidate {
SchedulerMinimalCandidateSelectionCandidate {
provider_id: "provider-1".to_string(),
provider_name: "provider".to_string(),
provider_type: "custom".to_string(),
provider_priority: 10,
endpoint_id: "endpoint-1".to_string(),
endpoint_api_format: "claude:messages".to_string(),
key_id: "key-1".to_string(),
key_name: "key".to_string(),
key_auth_type: "bearer".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-model-1".to_string(),
global_model_name: "claude-sonnet".to_string(),
selected_provider_model_name: "claude-sonnet".to_string(),
mapping_matched_model: None,
}
}
#[test]
fn auth_channel_gate_skips_mismatched_raw_secret_auth() {
let transport = sample_transport("bearer");
@@ -476,4 +607,17 @@ mod tests {
Some("auth_channel_mismatch")
);
}
#[test]
fn pool_group_common_gate_ignores_representative_key_model_policy() {
let candidate = sample_candidate();
let mut transport = sample_transport("bearer");
transport.key.allowed_models = Some(vec!["different-model".to_string()]);
transport.key.api_formats = Some(vec!["different:format".to_string()]);
assert_eq!(
pool_group_common_transport_skip_reason(&candidate, &transport),
None
);
}
}

View File

@@ -24,8 +24,10 @@ mod specialized;
mod standard;
mod state;
pub(crate) use self::candidate_materialization::LocalExecutionAttemptSource;
pub(crate) use self::passthrough::{
build_local_same_format_stream_plan_and_reports, build_local_same_format_sync_plan_and_reports,
build_local_same_format_stream_attempt_source, build_local_same_format_stream_plan_and_reports,
build_local_same_format_sync_attempt_source, build_local_same_format_sync_plan_and_reports,
};
pub(crate) use self::plan_builders::{
build_gemini_stream_plan_from_decision, build_gemini_sync_plan_from_decision,
@@ -36,18 +38,29 @@ pub(crate) use self::plan_builders::{
};
pub(crate) use self::route::is_matching_stream_request as planner_is_matching_stream_request;
pub(crate) use self::specialized::{
build_local_gemini_files_stream_attempt_source_for_kind,
build_local_gemini_files_stream_plan_and_reports_for_kind,
build_local_gemini_files_sync_attempt_source_for_kind,
build_local_gemini_files_sync_plan_and_reports_for_kind,
build_local_image_stream_attempt_source_for_kind,
build_local_image_stream_plan_and_reports_for_kind,
build_local_image_sync_attempt_source_for_kind,
build_local_image_sync_plan_and_reports_for_kind,
build_local_video_sync_attempt_source_for_kind,
build_local_video_sync_plan_and_reports_for_kind,
};
pub(crate) use self::standard::{
build_local_openai_chat_stream_attempt_source_for_kind,
build_local_openai_chat_stream_plan_and_reports_for_kind,
build_local_openai_chat_sync_attempt_source_for_kind,
build_local_openai_chat_sync_plan_and_reports_for_kind,
build_local_openai_responses_stream_attempt_source_for_kind,
build_local_openai_responses_stream_plan_and_reports_for_kind,
build_local_openai_responses_sync_attempt_source_for_kind,
build_local_openai_responses_sync_plan_and_reports_for_kind,
build_local_stream_attempt_source as build_standard_family_stream_attempt_source,
build_local_stream_plan_and_reports as build_standard_family_stream_plan_and_reports,
build_local_sync_attempt_source as build_standard_family_sync_attempt_source,
build_local_sync_plan_and_reports as build_standard_family_sync_plan_and_reports,
set_local_openai_chat_execution_exhausted_diagnostic,
};

View File

@@ -3,7 +3,9 @@
mod provider;
pub(crate) use self::provider::{
build_local_stream_attempt_source as build_local_same_format_stream_attempt_source,
build_local_stream_plan_and_reports as build_local_same_format_stream_plan_and_reports,
build_local_sync_attempt_source as build_local_same_format_sync_attempt_source,
build_local_sync_plan_and_reports as build_local_same_format_sync_plan_and_reports,
maybe_build_local_same_format_provider_decision_payload_for_candidate,
maybe_build_stream_local_same_format_provider_decision_payload,

View File

@@ -62,17 +62,20 @@ mod plans;
mod request;
pub(crate) use self::family::{
build_local_same_format_provider_candidate_attempt_source,
materialize_local_same_format_provider_candidate_attempts,
maybe_build_local_same_format_provider_decision_payload_for_candidate,
resolve_local_same_format_provider_decision_input, LocalSameFormatProviderFamily,
LocalSameFormatProviderSpec,
resolve_local_same_format_provider_decision_input, LocalSameFormatProviderCandidateAttempt,
LocalSameFormatProviderCandidateAttemptSource, LocalSameFormatProviderDecisionInput,
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
};
pub(crate) use self::family::{
maybe_build_stream_local_same_format_provider_decision_payload,
maybe_build_sync_local_same_format_provider_decision_payload,
};
pub(crate) use self::plans::{
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports,
build_local_stream_attempt_source, build_local_stream_plan_and_reports,
build_local_sync_attempt_source, build_local_sync_plan_and_reports,
};
const ANTIGRAVITY_ENVELOPE_NAME: &str = "antigravity:v1internal";

View File

@@ -1,7 +1,9 @@
use tracing::warn;
use crate::ai_serving::planner::candidate_materialization::{
build_local_execution_candidate_attempt_source_with_serving,
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
LocalExecutionCandidateAttemptSource,
};
use crate::ai_serving::planner::candidate_metadata::{
build_local_execution_candidate_contract_metadata,
@@ -169,3 +171,96 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
Ok((outcome.attempts, outcome.candidate_count))
}
pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a>(
state: &'a AppState,
trace_id: &str,
input: &LocalSameFormatProviderDecisionInput,
body_json: &serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Result<(LocalExecutionCandidateAttemptSource<'a>, usize), GatewayError> {
let spec_metadata = local_same_format_provider_spec_metadata(spec);
let planner_state = PlannerAppState::new(state);
let sticky_session_token = extract_pool_sticky_session_token(body_json);
let persistence_policy = build_local_candidate_persistence_policy(
&input.auth_context,
input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::SameFormatProviderDecision,
);
let (candidates, preselection_skipped) = planner_state
.list_selectable_candidates_with_skip_reasons(
spec_metadata.api_format,
&input.requested_model,
spec_metadata.require_streaming,
input.required_capabilities.as_ref(),
Some(&input.auth_snapshot),
current_unix_secs(),
)
.await?;
Ok(build_local_execution_candidate_attempt_source_with_serving(
planner_state,
trace_id,
spec_metadata.api_format,
Some(&input.requested_model),
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
candidates,
preselection_skipped
.into_iter()
.map(|item| SkippedLocalExecutionCandidate {
candidate: item.candidate,
skip_reason: item.skip_reason,
transport: None,
ranking: None,
extra_data: None,
})
.collect(),
LocalCandidateResolutionMode::Standard,
|eligible| {
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
spec_metadata.api_format,
);
Some(build_local_execution_candidate_contract_metadata(
LocalExecutionCandidateMetadataParts {
eligible,
provider_api_format: spec_metadata.api_format,
client_api_format: spec_metadata.api_format,
extra_fields: serde_json::Map::new(),
},
execution_strategy,
conversion_mode,
spec_metadata.api_format,
))
},
|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());
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
provider_api_format.as_str(),
);
skipped_candidate.extra_data = Some(
build_local_execution_candidate_contract_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport_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
},
)
.await)
}

View File

@@ -8,10 +8,12 @@ pub(crate) use self::build::{
maybe_build_sync_local_same_format_provider_decision_payload,
};
pub(crate) use self::candidates::{
build_local_same_format_provider_candidate_attempt_source,
materialize_local_same_format_provider_candidate_attempts,
resolve_local_same_format_provider_decision_input,
};
pub(crate) use self::payload::maybe_build_local_same_format_provider_decision_payload_for_candidate;
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalSameFormatProviderCandidateAttempt;
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttemptSource as LocalSameFormatProviderCandidateAttemptSource;
pub(crate) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalSameFormatProviderDecisionInput;
pub(crate) use crate::ai_serving::{LocalSameFormatProviderFamily, LocalSameFormatProviderSpec};

View File

@@ -1,6 +1,10 @@
use async_trait::async_trait;
use tracing::warn;
use crate::ai_serving::planner::common::extract_requested_model_from_request;
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
use crate::ai_serving::planner::common::{
extract_requested_model_from_request, RequestedModelFamily,
};
use crate::ai_serving::planner::runtime_miss::{
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal,
apply_local_runtime_candidate_terminal_reason, set_local_runtime_miss_diagnostic_reason,
@@ -15,12 +19,299 @@ pub(crate) use crate::ai_serving::{
};
use super::{
build_local_same_format_provider_candidate_attempt_source,
materialize_local_same_format_provider_candidate_attempts,
maybe_build_local_same_format_provider_decision_payload_for_candidate,
resolve_local_same_format_provider_decision_input, AiStreamAttempt, AiSyncAttempt, AppState,
GatewayControlDecision, GatewayError, LocalSameFormatProviderSpec,
GatewayControlDecision, GatewayError, LocalSameFormatProviderCandidateAttempt,
LocalSameFormatProviderCandidateAttemptSource, LocalSameFormatProviderDecisionInput,
LocalSameFormatProviderSpec,
};
pub(crate) struct LocalSameFormatProviderSyncAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
body_json: &'a serde_json::Value,
input: LocalSameFormatProviderDecisionInput,
spec: LocalSameFormatProviderSpec,
requested_model_family: RequestedModelFamily,
candidates: LocalSameFormatProviderCandidateAttemptSource<'a>,
}
pub(crate) struct LocalSameFormatProviderStreamAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
body_json: &'a serde_json::Value,
input: LocalSameFormatProviderDecisionInput,
spec: LocalSameFormatProviderSpec,
requested_model_family: RequestedModelFamily,
candidates: LocalSameFormatProviderCandidateAttemptSource<'a>,
}
pub(crate) async fn build_local_sync_attempt_source<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Result<Option<(LocalSameFormatProviderSyncAttemptSource<'a>, usize)>, GatewayError> {
let spec_metadata = local_same_format_provider_spec_metadata(spec);
let requested_model_family = spec_metadata
.requested_model_family
.expect("same-format provider spec metadata should include requested-model family");
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
extract_requested_model_from_request(parts, body_json, requested_model_family)
.as_deref(),
"decision_input_unavailable",
);
return Ok(None);
};
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
);
let (candidates, candidate_count) = build_local_same_format_provider_candidate_attempt_source(
state, trace_id, &input, body_json, spec,
)
.await?;
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal(
state,
trace_id,
candidate_count,
);
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalSameFormatProviderSyncAttemptSource {
state,
parts,
trace_id,
body_json,
input,
spec,
requested_model_family,
candidates,
},
candidate_count,
)))
}
pub(crate) async fn build_local_stream_attempt_source<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
spec: LocalSameFormatProviderSpec,
) -> Result<Option<(LocalSameFormatProviderStreamAttemptSource<'a>, usize)>, GatewayError> {
let spec_metadata = local_same_format_provider_spec_metadata(spec);
let requested_model_family = spec_metadata
.requested_model_family
.expect("same-format provider spec metadata should include requested-model family");
let Some(input) = resolve_local_same_format_provider_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
extract_requested_model_from_request(parts, body_json, requested_model_family)
.as_deref(),
"decision_input_unavailable",
);
return Ok(None);
};
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
);
let (candidates, candidate_count) = build_local_same_format_provider_candidate_attempt_source(
state, trace_id, &input, body_json, spec,
)
.await?;
apply_local_runtime_candidate_evaluation_progress_preserving_candidate_signal(
state,
trace_id,
candidate_count,
);
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalSameFormatProviderStreamAttemptSource {
state,
parts,
trace_id,
body_json,
input,
spec,
requested_model_family,
candidates,
},
candidate_count,
)))
}
#[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalSameFormatProviderSyncAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_sync_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
apply_local_runtime_candidate_terminal_reason(
self.state,
self.trace_id,
"no_local_sync_plans",
);
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
#[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt>
for LocalSameFormatProviderStreamAttemptSource<'_>
{
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_stream_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
apply_local_runtime_candidate_terminal_reason(
self.state,
self.trace_id,
"no_local_stream_plans",
);
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiStreamAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_stream_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
impl LocalSameFormatProviderSyncAttemptSource<'_> {
async fn build_sync_attempt(
&self,
attempt: LocalSameFormatProviderCandidateAttempt,
) -> Result<Option<AiSyncAttempt>, GatewayError> {
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
self.state,
self.parts,
self.trace_id,
self.body_json,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_sync_plan_from_requested_model_family(
self.requested_model_family,
self.parts,
self.body_json,
payload,
) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
error = ?err,
"gateway local same-format sync decision plan build failed"
);
Ok(None)
}
}
}
}
impl LocalSameFormatProviderStreamAttemptSource<'_> {
async fn build_stream_attempt(
&self,
attempt: LocalSameFormatProviderCandidateAttempt,
) -> Result<Option<AiStreamAttempt>, GatewayError> {
let Some(payload) = maybe_build_local_same_format_provider_decision_payload_for_candidate(
self.state,
self.parts,
self.trace_id,
self.body_json,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_stream_plan_from_requested_model_family(
self.requested_model_family,
self.parts,
self.body_json,
payload,
) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
error = ?err,
"gateway local same-format stream decision plan build failed"
);
Ok(None)
}
}
}
}
pub(crate) async fn build_local_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,

View File

@@ -1,4 +1,4 @@
use std::collections::{btree_map::Entry, BTreeMap, BTreeSet};
use std::collections::{btree_map::Entry, BTreeMap, BTreeSet, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use aether_ai_serving::{
@@ -6,14 +6,20 @@ use aether_ai_serving::{
AiPoolCandidateOrchestration, AiPoolCatalogKeyContext, AiPoolRuntimeState,
AiPoolSchedulingConfig, AiPoolSchedulingPreset,
};
use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredPoolKeyCandidateRowsQuery,
};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::{Map, Value};
use tracing::warn;
use crate::ai_serving::planner::candidate_resolution::{
EligibleLocalExecutionCandidate, SkippedLocalExecutionCandidate,
candidate_auth_channel_skip_reason, read_candidate_transport_snapshot,
EligibleLocalExecutionCandidate, LocalExecutionCandidateKind, SkippedLocalExecutionCandidate,
};
use crate::ai_serving::{
candidate_common_transport_skip_reason, CandidateTransportPolicyFacts, PlannerAppState,
};
use crate::ai_serving::PlannerAppState;
use crate::clock::current_unix_ms;
use crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value;
use crate::handlers::shared::provider_pool::read_admin_provider_pool_runtime_state;
@@ -28,6 +34,8 @@ use crate::orchestration::LocalExecutionCandidateMetadata;
use crate::provider_key_auth::provider_key_auth_semantics;
static LOAD_BALANCE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const DEFAULT_POOL_KEY_PAGE_SIZE: u32 = 128;
const DEFAULT_POOL_MAX_SCANNED_KEYS: u32 = 1024;
type PoolCatalogKeyContext = AiPoolCatalogKeyContext;
@@ -35,6 +43,8 @@ pub(crate) async fn apply_local_execution_pool_scheduler(
state: PlannerAppState<'_>,
candidates: Vec<EligibleLocalExecutionCandidate>,
sticky_session_token: Option<&str>,
requested_model: Option<&str>,
request_auth_channel: Option<&str>,
) -> (
Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
@@ -47,6 +57,40 @@ pub(crate) async fn apply_local_execution_pool_scheduler(
.map(str::trim)
.filter(|value| !value.is_empty());
let mut scheduled = Vec::new();
let mut skipped = Vec::new();
for candidate in candidates {
if candidate.kind == LocalExecutionCandidateKind::PoolGroup {
let mut expanded = expand_pool_group_candidate(
state,
candidate,
sticky_session_token,
requested_model,
request_auth_channel,
)
.await;
scheduled.append(&mut expanded.0);
skipped.append(&mut expanded.1);
} else {
scheduled.push(candidate);
}
}
(scheduled, skipped)
}
async fn schedule_pool_page_candidates(
state: PlannerAppState<'_>,
candidates: Vec<EligibleLocalExecutionCandidate>,
sticky_session_token: Option<&str>,
) -> (
Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
) {
if candidates.is_empty() {
return (Vec::new(), Vec::new());
}
let mut provider_runtime_requirements =
BTreeMap::<String, (AdminProviderPoolConfig, BTreeSet<String>)>::new();
for candidate in &candidates {
@@ -88,6 +132,249 @@ pub(crate) async fn apply_local_execution_pool_scheduler(
)
}
async fn expand_pool_group_candidate(
state: PlannerAppState<'_>,
group: EligibleLocalExecutionCandidate,
sticky_session_token: Option<&str>,
requested_model: Option<&str>,
request_auth_channel: Option<&str>,
) -> (
Vec<EligibleLocalExecutionCandidate>,
Vec<SkippedLocalExecutionCandidate>,
) {
let mut cursor = PoolKeyCursor::new(
state,
group,
sticky_session_token,
requested_model,
request_auth_channel,
);
let mut scheduled = Vec::new();
let mut skipped = Vec::new();
while let Some(candidate) = cursor.next_key().await {
scheduled.push(candidate);
}
skipped.append(&mut cursor.take_skipped_candidates());
if scheduled.is_empty() {
cursor.log_exhausted();
}
(scheduled, skipped)
}
pub(crate) struct PoolKeyCursor<'a> {
state: PlannerAppState<'a>,
group: EligibleLocalExecutionCandidate,
sticky_session_token: Option<String>,
requested_model: Option<String>,
request_auth_channel: Option<String>,
next_offset: u32,
scanned_keys: u32,
page_size: u32,
max_scanned_keys: u32,
skip_reason_counts: BTreeMap<&'static str, u32>,
next_pool_key_index: u32,
queued_candidates: VecDeque<EligibleLocalExecutionCandidate>,
skipped_candidates: Vec<SkippedLocalExecutionCandidate>,
exhausted_logged: bool,
}
impl<'a> PoolKeyCursor<'a> {
pub(crate) fn new(
state: PlannerAppState<'a>,
group: EligibleLocalExecutionCandidate,
sticky_session_token: Option<&str>,
requested_model: Option<&str>,
request_auth_channel: Option<&str>,
) -> Self {
Self {
state,
group,
sticky_session_token: sticky_session_token.map(str::to_string),
requested_model: requested_model.map(str::to_string),
request_auth_channel: request_auth_channel.map(str::to_string),
next_offset: 0,
scanned_keys: 0,
page_size: DEFAULT_POOL_KEY_PAGE_SIZE,
max_scanned_keys: DEFAULT_POOL_MAX_SCANNED_KEYS,
skip_reason_counts: BTreeMap::new(),
next_pool_key_index: 0,
queued_candidates: VecDeque::new(),
skipped_candidates: Vec::new(),
exhausted_logged: false,
}
}
pub(crate) async fn next_key(&mut self) -> Option<EligibleLocalExecutionCandidate> {
loop {
if let Some(candidate) = self.queued_candidates.pop_front() {
return Some(candidate);
}
let page_candidates = self.next_page_candidates().await?;
let (mut page_scheduled, mut page_skipped) = schedule_pool_page_candidates(
self.state,
page_candidates,
self.sticky_session_token.as_deref(),
)
.await;
for skipped_candidate in &page_skipped {
self.record_skip_reason(skipped_candidate.skip_reason);
}
for candidate in &mut page_scheduled {
candidate.orchestration.pool_key_index = Some(self.next_pool_key_index);
self.next_pool_key_index = self.next_pool_key_index.saturating_add(1);
}
self.queued_candidates.extend(page_scheduled);
self.skipped_candidates.append(&mut page_skipped);
}
}
pub(crate) fn take_skipped_candidates(&mut self) -> Vec<SkippedLocalExecutionCandidate> {
std::mem::take(&mut self.skipped_candidates)
}
pub(crate) fn log_exhausted(&mut self) {
if self.exhausted_logged {
return;
}
self.exhausted_logged = true;
warn!(
event_name = "pool_group_exhausted",
log_type = "event",
provider_id = %self.group.candidate.provider_id,
endpoint_id = %self.group.candidate.endpoint_id,
model_id = %self.group.candidate.model_id,
scanned_keys = self.scanned_keys,
skip_reason_counts = ?self.skip_reason_counts,
"gateway pool scheduler exhausted pool group without a schedulable key"
);
}
async fn next_page_candidates(&mut self) -> Option<Vec<EligibleLocalExecutionCandidate>> {
if self.scanned_keys >= self.max_scanned_keys {
return None;
}
let limit = self
.page_size
.min(self.max_scanned_keys - self.scanned_keys);
let query = StoredPoolKeyCandidateRowsQuery {
api_format: self.group.candidate.endpoint_api_format.clone(),
provider_id: self.group.candidate.provider_id.clone(),
endpoint_id: self.group.candidate.endpoint_id.clone(),
model_id: self.group.candidate.model_id.clone(),
selected_provider_model_name: self.group.candidate.selected_provider_model_name.clone(),
offset: self.next_offset,
limit,
};
let rows = match self
.state
.app()
.list_pool_key_candidate_rows_for_group(&query)
.await
{
Ok(rows) => rows,
Err(err) => {
warn!(
event_name = "pool_group_key_page_load_failed",
log_type = "event",
provider_id = %self.group.candidate.provider_id,
endpoint_id = %self.group.candidate.endpoint_id,
model_id = %self.group.candidate.model_id,
selected_provider_model_name = %self.group.candidate.selected_provider_model_name,
offset = self.next_offset,
limit,
error = ?err,
"gateway pool scheduler failed to read pool key page"
);
return None;
}
};
if rows.is_empty() {
return None;
}
self.scanned_keys += rows.len() as u32;
self.next_offset = self.next_offset.saturating_add(rows.len() as u32);
Some(self.build_page_eligible_candidates(rows).await)
}
async fn build_page_eligible_candidates(
&mut self,
rows: Vec<StoredMinimalCandidateSelectionRow>,
) -> Vec<EligibleLocalExecutionCandidate> {
let mut candidates = Vec::with_capacity(rows.len());
for row in rows {
let candidate = pool_candidate_from_row(&self.group, row);
let Some(transport) = read_candidate_transport_snapshot(self.state, &candidate).await
else {
self.record_skip_reason("transport_snapshot_missing");
continue;
};
if let Some(skip_reason) =
candidate_auth_channel_skip_reason(&transport, self.request_auth_channel.as_deref())
{
self.record_skip_reason(skip_reason);
continue;
}
if let Some(skip_reason) = candidate_common_transport_skip_reason(
&transport,
pool_candidate_transport_policy_facts(&candidate),
self.requested_model.as_deref(),
) {
self.record_skip_reason(skip_reason);
continue;
}
candidates.push(EligibleLocalExecutionCandidate {
kind: LocalExecutionCandidateKind::SingleKey,
candidate,
provider_api_format: transport.endpoint.api_format.trim().to_ascii_lowercase(),
transport: std::sync::Arc::new(transport),
orchestration: LocalExecutionCandidateMetadata::default(),
ranking: self.group.ranking.clone(),
});
}
candidates
}
fn record_skip_reason(&mut self, reason: &'static str) {
*self.skip_reason_counts.entry(reason).or_insert(0) += 1;
}
}
fn pool_candidate_transport_policy_facts(
candidate: &aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate,
) -> CandidateTransportPolicyFacts<'_> {
CandidateTransportPolicyFacts {
endpoint_api_format: candidate.endpoint_api_format.as_str(),
global_model_name: candidate.global_model_name.as_str(),
selected_provider_model_name: candidate.selected_provider_model_name.as_str(),
mapping_matched_model: candidate.mapping_matched_model.as_deref(),
}
}
fn pool_candidate_from_row(
group: &EligibleLocalExecutionCandidate,
row: StoredMinimalCandidateSelectionRow,
) -> aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate {
let mut candidate = group.candidate.clone();
candidate.key_id = row.key_id;
candidate.key_name = row.key_name;
candidate.key_auth_type = row.key_auth_type;
candidate.key_internal_priority = row.key_internal_priority;
candidate.key_global_priority_for_format =
aether_scheduler_core::extract_global_priority_for_format(
row.key_global_priority_by_format.as_ref(),
group.candidate.endpoint_api_format.as_str(),
)
.ok()
.flatten();
candidate.key_capabilities = row.key_capabilities;
candidate
}
async fn read_pool_catalog_key_contexts_by_id(
state: PlannerAppState<'_>,
candidates: &[EligibleLocalExecutionCandidate],
@@ -402,7 +689,9 @@ mod tests {
apply_local_execution_pool_scheduler_with_runtime_map, build_pool_catalog_key_context,
PoolCatalogKeyContext,
};
use crate::ai_serving::planner::candidate_resolution::EligibleLocalExecutionCandidate;
use crate::ai_serving::planner::candidate_resolution::{
EligibleLocalExecutionCandidate, LocalExecutionCandidateKind,
};
use crate::ai_serving::PlannerAppState;
use crate::data::GatewayDataState;
use crate::handlers::shared::provider_pool::AdminProviderPoolRuntimeState;
@@ -1170,6 +1459,11 @@ mod tests {
provider_config: Option<serde_json::Value>,
) -> EligibleLocalExecutionCandidate {
EligibleLocalExecutionCandidate {
kind: if provider_config.is_some() {
LocalExecutionCandidateKind::PoolGroup
} else {
LocalExecutionCandidateKind::SingleKey
},
candidate: SchedulerMinimalCandidateSelectionCandidate {
provider_id: provider_id.to_string(),
provider_name: provider_id.to_string(),

View File

@@ -2,8 +2,10 @@ mod decision;
mod request;
mod support;
use async_trait::async_trait;
use tracing::warn;
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
use crate::ai_serving::planner::plan_builders::{
build_passthrough_stream_plan_from_decision, build_passthrough_sync_plan_from_decision,
AiStreamAttempt, AiSyncAttempt,
@@ -18,9 +20,33 @@ use crate::{AiExecutionDecision, AppState, GatewayError};
use self::decision::maybe_build_local_gemini_files_decision_payload_for_candidate;
use self::support::{
build_local_gemini_files_candidate_attempt_source,
materialize_local_gemini_files_candidate_attempts, resolve_local_gemini_files_decision_input,
LocalGeminiFilesCandidateAttempt, LocalGeminiFilesCandidateAttemptSource,
LocalGeminiFilesDecisionInput,
};
pub(crate) struct LocalGeminiFilesSyncAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
body_json: &'a serde_json::Value,
body_base64: Option<&'a str>,
body_is_empty: bool,
trace_id: &'a str,
input: LocalGeminiFilesDecisionInput,
spec: LocalGeminiFilesSpec,
candidates: LocalGeminiFilesCandidateAttemptSource<'a>,
}
pub(crate) struct LocalGeminiFilesStreamAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
input: LocalGeminiFilesDecisionInput,
spec: LocalGeminiFilesSpec,
candidates: LocalGeminiFilesCandidateAttemptSource<'a>,
}
pub(crate) async fn build_local_gemini_files_sync_plan_and_reports_for_kind(
state: &AppState,
parts: &http::request::Parts,
@@ -62,6 +88,202 @@ pub(crate) async fn build_local_gemini_files_stream_plan_and_reports_for_kind(
build_local_stream_plan_and_reports(state, parts, trace_id, decision, spec).await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn build_local_gemini_files_sync_attempt_source_for_kind<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
body_json: &'a serde_json::Value,
body_base64: Option<&'a str>,
body_is_empty: bool,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<(LocalGeminiFilesSyncAttemptSource<'a>, usize)>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
else {
return Ok(None);
};
let (candidates, candidate_count) =
build_local_gemini_files_candidate_attempt_source(state, trace_id, &input).await?;
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalGeminiFilesSyncAttemptSource {
state,
parts,
body_json,
body_base64,
body_is_empty,
trace_id,
input,
spec,
candidates,
},
candidate_count,
)))
}
pub(crate) async fn build_local_gemini_files_stream_attempt_source_for_kind<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<(LocalGeminiFilesStreamAttemptSource<'a>, usize)>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
else {
return Ok(None);
};
let (candidates, candidate_count) =
build_local_gemini_files_candidate_attempt_source(state, trace_id, &input).await?;
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalGeminiFilesStreamAttemptSource {
state,
parts,
trace_id,
input,
spec,
candidates,
},
candidate_count,
)))
}
#[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalGeminiFilesSyncAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_sync_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
#[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalGeminiFilesStreamAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_stream_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiStreamAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_stream_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
impl LocalGeminiFilesSyncAttemptSource<'_> {
async fn build_sync_attempt(
&self,
attempt: LocalGeminiFilesCandidateAttempt,
) -> Result<Option<AiSyncAttempt>, GatewayError> {
let spec_metadata = local_gemini_files_spec_metadata(self.spec);
let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
self.state,
self.parts,
self.body_json,
self.body_base64,
self.body_is_empty,
self.trace_id,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_passthrough_sync_plan_from_decision(self.parts, payload) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
decision_kind = spec_metadata.decision_kind,
error = ?err,
"gateway local gemini files sync decision plan build failed"
);
Ok(None)
}
}
}
}
impl LocalGeminiFilesStreamAttemptSource<'_> {
async fn build_stream_attempt(
&self,
attempt: LocalGeminiFilesCandidateAttempt,
) -> Result<Option<AiStreamAttempt>, GatewayError> {
let spec_metadata = local_gemini_files_spec_metadata(self.spec);
let empty_body_json = serde_json::Value::Null;
let Some(payload) = maybe_build_local_gemini_files_decision_payload_for_candidate(
self.state,
self.parts,
&empty_body_json,
None,
true,
self.trace_id,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_passthrough_stream_plan_from_decision(self.parts, payload) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
decision_kind = spec_metadata.decision_kind,
error = ?err,
"gateway local gemini files stream decision plan build failed"
);
Ok(None)
}
}
}
}
pub(crate) async fn maybe_build_sync_local_gemini_files_decision_payload(
state: &AppState,
parts: &http::request::Parts,

View File

@@ -3,9 +3,11 @@ use serde_json::json;
use tracing::warn;
use crate::ai_serving::planner::candidate_materialization::{
build_local_execution_candidate_attempt_source_with_serving,
mark_skipped_local_execution_candidate,
mark_skipped_local_execution_candidate_with_failure_diagnostic,
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
LocalExecutionCandidateAttemptSource,
};
use crate::ai_serving::planner::candidate_metadata::{
build_local_execution_candidate_metadata,
@@ -25,6 +27,7 @@ use crate::clock::current_unix_secs;
use crate::{AppState, GatewayError};
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalGeminiFilesCandidateAttempt;
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttemptSource as LocalGeminiFilesCandidateAttemptSource;
pub(super) use crate::ai_serving::planner::decision_input::LocalAuthenticatedDecisionInput as LocalGeminiFilesDecisionInput;
pub(super) const GEMINI_FILES_CANDIDATE_API_FORMAT: &str = "gemini:files";
@@ -134,6 +137,74 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
Ok(outcome.attempts)
}
pub(super) async fn build_local_gemini_files_candidate_attempt_source<'a>(
state: &'a AppState,
trace_id: &str,
input: &LocalGeminiFilesDecisionInput,
) -> Result<(LocalGeminiFilesCandidateAttemptSource<'a>, usize), GatewayError> {
let planner_state = PlannerAppState::new(state);
let persistence_policy = build_local_candidate_persistence_policy(
&input.auth_context,
input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::GeminiFilesDecision,
);
let candidates = planner_state
.list_selectable_candidates_for_required_capability_without_requested_model(
GEMINI_FILES_CANDIDATE_API_FORMAT,
GEMINI_FILES_REQUIRED_CAPABILITY,
false,
Some(&input.auth_snapshot),
current_unix_secs(),
)
.await?;
Ok(build_local_execution_candidate_attempt_source_with_serving(
planner_state,
trace_id,
GEMINI_FILES_CLIENT_API_FORMAT,
None,
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
None,
None,
persistence_policy,
candidates,
Vec::new(),
LocalCandidateResolutionMode::WithoutTransportPairGate,
|eligible| {
let mut extra_fields = serde_json::Map::new();
extra_fields.insert(
"candidate_api_format".to_string(),
json!(GEMINI_FILES_CANDIDATE_API_FORMAT),
);
Some(build_local_execution_candidate_metadata(
LocalExecutionCandidateMetadataParts {
eligible,
provider_api_format: GEMINI_FILES_CLIENT_API_FORMAT,
client_api_format: GEMINI_FILES_CLIENT_API_FORMAT,
extra_fields,
},
))
},
|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_ref(),
GEMINI_FILES_CLIENT_API_FORMAT,
GEMINI_FILES_CLIENT_API_FORMAT,
extra_fields,
));
skipped_candidate
},
)
.await)
}
pub(super) async fn mark_skipped_local_gemini_files_candidate(
state: &AppState,
input: &LocalGeminiFilesDecisionInput,

View File

@@ -2,8 +2,10 @@ mod decision;
mod request;
mod support;
use async_trait::async_trait;
use tracing::warn;
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
use crate::ai_serving::planner::plan_builders::{
build_passthrough_sync_plan_from_decision, build_standard_stream_plan_from_decision,
AiStreamAttempt, AiSyncAttempt,
@@ -18,11 +20,35 @@ use crate::{AiExecutionDecision, AppState, GatewayError};
use self::decision::maybe_build_local_openai_image_decision_payload_for_candidate;
use self::support::{
list_local_openai_image_candidate_attempts, resolve_local_openai_image_decision_input,
build_local_openai_image_candidate_attempt_source, list_local_openai_image_candidate_attempts,
resolve_local_openai_image_decision_input, LocalOpenAiImageCandidateAttempt,
LocalOpenAiImageCandidateAttemptSource, LocalOpenAiImageDecisionInput,
};
pub(super) use crate::ai_serving::LocalOpenAiImageSpec;
pub(crate) struct LocalOpenAiImageSyncAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
body_json: &'a serde_json::Value,
body_base64: Option<&'a str>,
trace_id: &'a str,
input: LocalOpenAiImageDecisionInput,
spec: LocalOpenAiImageSpec,
candidates: LocalOpenAiImageCandidateAttemptSource<'a>,
}
pub(crate) struct LocalOpenAiImageStreamAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
body_json: &'a serde_json::Value,
body_base64: Option<&'a str>,
trace_id: &'a str,
input: LocalOpenAiImageDecisionInput,
spec: LocalOpenAiImageSpec,
candidates: LocalOpenAiImageCandidateAttemptSource<'a>,
}
pub(crate) async fn build_local_image_sync_plan_and_reports_for_kind(
state: &AppState,
parts: &http::request::Parts,
@@ -73,6 +99,242 @@ pub(crate) async fn build_local_image_stream_plan_and_reports_for_kind(
.await
}
pub(crate) async fn build_local_image_sync_attempt_source_for_kind<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
body_json: &'a serde_json::Value,
body_base64: Option<&'a str>,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<(LocalOpenAiImageSyncAttemptSource<'a>, usize)>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let spec_metadata = local_openai_image_spec_metadata(spec);
let Some(input) = resolve_local_openai_image_decision_input(
state,
parts,
body_json,
body_base64,
trace_id,
decision,
)
.await
else {
return Ok(None);
};
let Some((candidates, candidate_count)) = build_local_openai_image_candidate_attempt_source(
state,
trace_id,
&input,
body_json,
spec_metadata.api_format,
spec_metadata.decision_kind,
)
.await?
else {
return Ok(None);
};
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalOpenAiImageSyncAttemptSource {
state,
parts,
body_json,
body_base64,
trace_id,
input,
spec,
candidates,
},
candidate_count,
)))
}
pub(crate) async fn build_local_image_stream_attempt_source_for_kind<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
body_json: &'a serde_json::Value,
body_base64: Option<&'a str>,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<(LocalOpenAiImageStreamAttemptSource<'a>, usize)>, GatewayError> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
let spec_metadata = local_openai_image_spec_metadata(spec);
let Some(input) = resolve_local_openai_image_decision_input(
state,
parts,
body_json,
body_base64,
trace_id,
decision,
)
.await
else {
return Ok(None);
};
let Some((candidates, candidate_count)) = build_local_openai_image_candidate_attempt_source(
state,
trace_id,
&input,
body_json,
spec_metadata.api_format,
spec_metadata.decision_kind,
)
.await?
else {
return Ok(None);
};
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalOpenAiImageStreamAttemptSource {
state,
parts,
body_json,
body_base64,
trace_id,
input,
spec,
candidates,
},
candidate_count,
)))
}
#[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiImageSyncAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_sync_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
#[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiImageStreamAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_stream_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiStreamAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_stream_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
impl LocalOpenAiImageSyncAttemptSource<'_> {
async fn build_sync_attempt(
&self,
attempt: LocalOpenAiImageCandidateAttempt,
) -> Result<Option<AiSyncAttempt>, GatewayError> {
let spec_metadata = local_openai_image_spec_metadata(self.spec);
let Some(payload) = maybe_build_local_openai_image_decision_payload_for_candidate(
self.state,
self.parts,
self.body_json,
self.body_base64,
self.trace_id,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_passthrough_sync_plan_from_decision(self.parts, payload) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
decision_kind = spec_metadata.decision_kind,
error = ?err,
"gateway local openai image sync decision plan build failed"
);
Ok(None)
}
}
}
}
impl LocalOpenAiImageStreamAttemptSource<'_> {
async fn build_stream_attempt(
&self,
attempt: LocalOpenAiImageCandidateAttempt,
) -> Result<Option<AiStreamAttempt>, GatewayError> {
let spec_metadata = local_openai_image_spec_metadata(self.spec);
let Some(payload) = maybe_build_local_openai_image_decision_payload_for_candidate(
self.state,
self.parts,
self.body_json,
self.body_base64,
self.trace_id,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_standard_stream_plan_from_decision(self.parts, self.body_json, payload, false) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
decision_kind = spec_metadata.decision_kind,
error = ?err,
"gateway local openai image stream decision plan build failed"
);
Ok(None)
}
}
}
}
pub(crate) async fn maybe_build_sync_local_image_decision_payload(
state: &AppState,
parts: &http::request::Parts,

View File

@@ -1,6 +1,7 @@
use tracing::warn;
use crate::ai_serving::planner::candidate_materialization::{
build_local_execution_candidate_attempt_source_with_serving,
mark_skipped_local_execution_candidate,
mark_skipped_local_execution_candidate_with_failure_diagnostic,
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
@@ -23,10 +24,11 @@ use crate::ai_serving::{
PlannerAppState,
};
use crate::clock::current_unix_secs;
use crate::AppState;
use crate::{AppState, GatewayError};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalOpenAiImageCandidateAttempt;
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttemptSource as LocalOpenAiImageCandidateAttemptSource;
pub(super) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalOpenAiImageDecisionInput;
use super::request::resolve_requested_image_model_for_request;
@@ -131,6 +133,94 @@ pub(super) async fn list_local_openai_image_candidate_attempts(
)
}
pub(super) async fn build_local_openai_image_candidate_attempt_source<'a>(
state: &'a AppState,
trace_id: &str,
input: &LocalOpenAiImageDecisionInput,
body_json: &serde_json::Value,
api_format: &str,
decision_kind: &str,
) -> Result<Option<(LocalOpenAiImageCandidateAttemptSource<'a>, usize)>, GatewayError> {
let planner_state = PlannerAppState::new(state);
let (candidates, preselection_skipped) = match planner_state
.list_selectable_candidates_with_skip_reasons(
api_format,
&input.requested_model,
false,
input.required_capabilities.as_ref(),
Some(&input.auth_snapshot),
current_unix_secs(),
)
.await
{
Ok(candidates) => candidates,
Err(err) => {
warn!(
trace_id = %trace_id,
decision_kind,
error = ?err,
"gateway local openai image decision scheduler selection failed"
);
return Ok(None);
}
};
let sticky_session_token = extract_pool_sticky_session_token(body_json);
let persistence_policy = build_local_candidate_persistence_policy(
&input.auth_context,
input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::ImageDecision,
);
let (source, candidate_count) = build_local_execution_candidate_attempt_source_with_serving(
planner_state,
trace_id,
api_format,
Some(&input.requested_model),
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
candidates,
preselection_skipped
.into_iter()
.map(|item| SkippedLocalExecutionCandidate {
candidate: item.candidate,
skip_reason: item.skip_reason,
transport: None,
ranking: None,
extra_data: None,
})
.collect(),
LocalCandidateResolutionMode::Standard,
|eligible| {
Some(build_local_execution_candidate_metadata(
LocalExecutionCandidateMetadataParts {
eligible,
provider_api_format: api_format,
client_api_format: api_format,
extra_fields: serde_json::Map::new(),
},
))
},
|mut skipped_candidate| {
skipped_candidate.extra_data =
Some(build_local_execution_candidate_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport_ref(),
api_format,
api_format,
serde_json::Map::new(),
));
skipped_candidate
},
)
.await;
Ok(Some((source, candidate_count)))
}
async fn materialize_local_openai_image_candidate_attempts(
state: PlannerAppState<'_>,
trace_id: &str,

View File

@@ -5,16 +5,22 @@ mod image;
mod video;
pub(crate) use self::files::{
build_local_gemini_files_stream_attempt_source_for_kind,
build_local_gemini_files_stream_plan_and_reports_for_kind,
build_local_gemini_files_sync_attempt_source_for_kind,
build_local_gemini_files_sync_plan_and_reports_for_kind,
maybe_build_stream_local_gemini_files_decision_payload,
maybe_build_sync_local_gemini_files_decision_payload,
};
pub(crate) use self::image::{
build_local_image_stream_attempt_source_for_kind,
build_local_image_stream_plan_and_reports_for_kind,
build_local_image_sync_attempt_source_for_kind,
build_local_image_sync_plan_and_reports_for_kind,
maybe_build_stream_local_image_decision_payload, maybe_build_sync_local_image_decision_payload,
};
pub(crate) use self::video::{
build_local_video_sync_plan_and_reports_for_kind, maybe_build_sync_local_video_decision_payload,
build_local_video_sync_attempt_source_for_kind,
build_local_video_sync_plan_and_reports_for_kind,
maybe_build_sync_local_video_decision_payload,
};

View File

@@ -2,8 +2,10 @@ mod decision;
mod request;
mod support;
use async_trait::async_trait;
use tracing::warn;
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
use crate::ai_serving::planner::plan_builders::{
build_passthrough_sync_plan_from_decision, AiSyncAttempt,
};
@@ -17,9 +19,21 @@ use crate::{AiExecutionDecision, AppState, GatewayError};
use self::decision::maybe_build_local_video_create_decision_payload_for_candidate;
use self::support::{
list_local_video_create_candidate_attempts, resolve_local_video_create_decision_input,
build_local_video_create_candidate_attempt_source, list_local_video_create_candidate_attempts,
resolve_local_video_create_decision_input, LocalVideoCreateCandidateAttempt,
LocalVideoCreateCandidateAttemptSource, LocalVideoCreateDecisionInput,
};
pub(crate) struct LocalVideoCreateSyncAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
body_json: &'a serde_json::Value,
trace_id: &'a str,
input: LocalVideoCreateDecisionInput,
spec: LocalVideoCreateSpec,
candidates: LocalVideoCreateCandidateAttemptSource<'a>,
}
pub(crate) async fn build_local_video_sync_plan_and_reports_for_kind(
state: &AppState,
parts: &http::request::Parts,
@@ -35,6 +49,116 @@ pub(crate) async fn build_local_video_sync_plan_and_reports_for_kind(
build_local_sync_plan_and_reports(state, parts, body_json, trace_id, decision, spec).await
}
pub(crate) async fn build_local_video_sync_attempt_source_for_kind<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
body_json: &'a serde_json::Value,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
plan_kind: &str,
) -> Result<Option<(LocalVideoCreateSyncAttemptSource<'a>, usize)>, GatewayError> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
let spec_metadata = local_video_create_spec_metadata(spec);
let Some(input) = resolve_local_video_create_decision_input(
state, parts, trace_id, decision, body_json, spec,
)
.await
else {
return Ok(None);
};
let Some((candidates, candidate_count)) = build_local_video_create_candidate_attempt_source(
state,
trace_id,
&input,
body_json,
spec_metadata.api_format,
spec_metadata.decision_kind,
)
.await?
else {
return Ok(None);
};
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalVideoCreateSyncAttemptSource {
state,
parts,
body_json,
trace_id,
input,
spec,
candidates,
},
candidate_count,
)))
}
#[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalVideoCreateSyncAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_sync_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
impl LocalVideoCreateSyncAttemptSource<'_> {
async fn build_sync_attempt(
&self,
attempt: LocalVideoCreateCandidateAttempt,
) -> Result<Option<AiSyncAttempt>, GatewayError> {
let spec_metadata = local_video_create_spec_metadata(self.spec);
let Some(payload) = maybe_build_local_video_create_decision_payload_for_candidate(
self.state,
self.parts,
self.body_json,
self.trace_id,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_passthrough_sync_plan_from_decision(self.parts, payload) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
decision_kind = spec_metadata.decision_kind,
error = ?err,
"gateway local video sync decision plan build failed"
);
Ok(None)
}
}
}
}
pub(crate) async fn maybe_build_sync_local_video_decision_payload(
state: &AppState,
parts: &http::request::Parts,

View File

@@ -3,6 +3,7 @@ use tracing::warn;
use super::{LocalVideoCreateFamily, LocalVideoCreateSpec};
use crate::ai_serving::planner::candidate_materialization::{
build_local_execution_candidate_attempt_source_with_serving,
mark_skipped_local_execution_candidate,
mark_skipped_local_execution_candidate_with_failure_diagnostic,
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
@@ -26,9 +27,10 @@ use crate::ai_serving::{
PlannerAppState,
};
use crate::clock::current_unix_secs;
use crate::AppState;
use crate::{AppState, GatewayError};
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalVideoCreateCandidateAttempt;
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttemptSource as LocalVideoCreateCandidateAttemptSource;
pub(super) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalVideoCreateDecisionInput;
pub(super) async fn resolve_local_video_create_decision_input(
@@ -143,6 +145,94 @@ pub(super) async fn list_local_video_create_candidate_attempts(
)
}
pub(super) async fn build_local_video_create_candidate_attempt_source<'a>(
state: &'a AppState,
trace_id: &str,
input: &LocalVideoCreateDecisionInput,
body_json: &serde_json::Value,
api_format: &str,
decision_kind: &str,
) -> Result<Option<(LocalVideoCreateCandidateAttemptSource<'a>, usize)>, GatewayError> {
let planner_state = PlannerAppState::new(state);
let (candidates, preselection_skipped) = match planner_state
.list_selectable_candidates_with_skip_reasons(
api_format,
&input.requested_model,
false,
input.required_capabilities.as_ref(),
Some(&input.auth_snapshot),
current_unix_secs(),
)
.await
{
Ok(candidates) => candidates,
Err(err) => {
warn!(
trace_id = %trace_id,
decision_kind = decision_kind,
error = ?err,
"gateway local video decision scheduler selection failed"
);
return Ok(None);
}
};
let sticky_session_token = extract_pool_sticky_session_token(body_json);
let persistence_policy = build_local_candidate_persistence_policy(
&input.auth_context,
input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::VideoDecision,
);
let (source, candidate_count) = build_local_execution_candidate_attempt_source_with_serving(
planner_state,
trace_id,
api_format,
Some(&input.requested_model),
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
candidates,
preselection_skipped
.into_iter()
.map(|item| SkippedLocalExecutionCandidate {
candidate: item.candidate,
skip_reason: item.skip_reason,
transport: None,
ranking: None,
extra_data: None,
})
.collect(),
LocalCandidateResolutionMode::Standard,
|eligible| {
Some(build_local_execution_candidate_metadata(
LocalExecutionCandidateMetadataParts {
eligible,
provider_api_format: api_format,
client_api_format: api_format,
extra_fields: serde_json::Map::new(),
},
))
},
|mut skipped_candidate| {
skipped_candidate.extra_data =
Some(build_local_execution_candidate_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport_ref(),
api_format,
api_format,
serde_json::Map::new(),
));
skipped_candidate
},
)
.await;
Ok(Some((source, candidate_count)))
}
async fn materialize_local_video_create_candidate_attempts(
state: PlannerAppState<'_>,
trace_id: &str,

View File

@@ -1,6 +1,12 @@
use async_trait::async_trait;
use tracing::warn;
use crate::ai_serving::planner::common::extract_requested_model_from_request;
use crate::ai_serving::planner::candidate_materialization::{
LocalExecutionAttemptSource, LocalExecutionCandidateAttemptSource,
};
use crate::ai_serving::planner::common::{
extract_requested_model_from_request, RequestedModelFamily,
};
use crate::ai_serving::planner::plan_builders::{AiStreamAttempt, AiSyncAttempt};
use crate::ai_serving::planner::runtime_miss::{
apply_local_runtime_candidate_evaluation_progress,
@@ -14,10 +20,279 @@ use crate::ai_serving::GatewayControlDecision;
use crate::{AiExecutionDecision, AppState, GatewayError};
use super::candidates::{
materialize_local_standard_candidate_attempts, resolve_local_standard_decision_input,
build_local_standard_candidate_attempt_source, materialize_local_standard_candidate_attempts,
resolve_local_standard_decision_input,
};
use super::payload::maybe_build_local_standard_decision_payload_for_candidate;
use super::LocalStandardSpec;
use super::{LocalStandardDecisionInput, LocalStandardSpec};
pub(crate) struct LocalStandardSyncAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
body_json: &'a serde_json::Value,
input: LocalStandardDecisionInput,
spec: LocalStandardSpec,
requested_model_family: RequestedModelFamily,
candidates: LocalExecutionCandidateAttemptSource<'a>,
}
pub(crate) struct LocalStandardStreamAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
body_json: &'a serde_json::Value,
input: LocalStandardDecisionInput,
spec: LocalStandardSpec,
requested_model_family: RequestedModelFamily,
candidates: LocalExecutionCandidateAttemptSource<'a>,
}
pub(crate) async fn build_local_sync_attempt_source<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
spec: LocalStandardSpec,
) -> Result<Option<(LocalStandardSyncAttemptSource<'a>, usize)>, GatewayError> {
let spec_metadata = local_standard_spec_metadata(spec);
let requested_model_family = spec_metadata
.requested_model_family
.expect("standard spec metadata should include requested-model family");
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
extract_requested_model_from_request(parts, body_json, requested_model_family)
.as_deref(),
"decision_input_unavailable",
);
return Ok(None);
};
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
);
let (candidates, candidate_count) =
build_local_standard_candidate_attempt_source(state, trace_id, &input, body_json, spec)
.await?;
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalStandardSyncAttemptSource {
state,
parts,
trace_id,
body_json,
input,
spec,
requested_model_family,
candidates,
},
candidate_count,
)))
}
pub(crate) async fn build_local_stream_attempt_source<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
spec: LocalStandardSpec,
) -> Result<Option<(LocalStandardStreamAttemptSource<'a>, usize)>, GatewayError> {
let spec_metadata = local_standard_spec_metadata(spec);
let requested_model_family = spec_metadata
.requested_model_family
.expect("standard spec metadata should include requested-model family");
let Some(input) =
resolve_local_standard_decision_input(state, parts, trace_id, decision, body_json, spec)
.await
else {
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
extract_requested_model_from_request(parts, body_json, requested_model_family)
.as_deref(),
"decision_input_unavailable",
);
return Ok(None);
};
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
);
let (candidates, candidate_count) =
build_local_standard_candidate_attempt_source(state, trace_id, &input, body_json, spec)
.await?;
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalStandardStreamAttemptSource {
state,
parts,
trace_id,
body_json,
input,
spec,
requested_model_family,
candidates,
},
candidate_count,
)))
}
#[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalStandardSyncAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_sync_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
apply_local_runtime_candidate_terminal_reason(
self.state,
self.trace_id,
"no_local_sync_plans",
);
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
#[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalStandardStreamAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_stream_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
apply_local_runtime_candidate_terminal_reason(
self.state,
self.trace_id,
"no_local_stream_plans",
);
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiStreamAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_stream_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
impl LocalStandardSyncAttemptSource<'_> {
async fn build_sync_attempt(
&self,
attempt: super::LocalStandardCandidateAttempt,
) -> Result<Option<AiSyncAttempt>, GatewayError> {
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
self.state,
self.parts,
self.trace_id,
self.body_json,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_sync_plan_from_requested_model_family(
self.requested_model_family,
self.parts,
self.body_json,
payload,
) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
error = ?err,
"gateway local standard sync plan build failed"
);
Ok(None)
}
}
}
}
impl LocalStandardStreamAttemptSource<'_> {
async fn build_stream_attempt(
&self,
attempt: super::LocalStandardCandidateAttempt,
) -> Result<Option<AiStreamAttempt>, GatewayError> {
let Some(payload) = maybe_build_local_standard_decision_payload_for_candidate(
self.state,
self.parts,
self.trace_id,
self.body_json,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_stream_plan_from_requested_model_family(
self.requested_model_family,
self.parts,
self.body_json,
payload,
) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
error = ?err,
"gateway local standard stream plan build failed"
);
Ok(None)
}
}
}
}
pub(crate) async fn maybe_build_sync_via_standard_family_payload(
state: &AppState,

View File

@@ -1,7 +1,9 @@
use tracing::warn;
use crate::ai_serving::planner::candidate_materialization::{
build_local_execution_candidate_attempt_source_with_serving,
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
LocalExecutionCandidateAttemptSource,
};
use crate::ai_serving::planner::candidate_metadata::{
build_local_execution_candidate_contract_metadata,
@@ -166,3 +168,95 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
Ok((outcome.attempts, outcome.candidate_count))
}
pub(super) async fn build_local_standard_candidate_attempt_source<'a>(
state: &'a AppState,
trace_id: &str,
input: &LocalStandardDecisionInput,
body_json: &serde_json::Value,
spec: LocalStandardSpec,
) -> Result<(LocalExecutionCandidateAttemptSource<'a>, usize), GatewayError> {
let spec_metadata = local_standard_spec_metadata(spec);
let planner_state = PlannerAppState::new(state);
let sticky_session_token = extract_pool_sticky_session_token(body_json);
let persistence_policy = build_local_candidate_persistence_policy(
&input.auth_context,
input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::StandardDecision,
);
let preselection = preselect_local_execution_candidates_with_serving(
planner_state,
spec_metadata.api_format,
&input.requested_model,
spec_metadata.require_streaming,
input.required_capabilities.as_ref(),
&input.auth_snapshot,
false,
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
)
.await?;
Ok(build_local_execution_candidate_attempt_source_with_serving(
planner_state,
trace_id,
spec_metadata.api_format,
Some(&input.requested_model),
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
preselection.candidates,
preselection.skipped_candidates,
LocalCandidateResolutionMode::Standard,
|eligible| {
let provider_api_format = eligible.provider_api_format.clone();
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
&provider_api_format,
);
Some(build_local_execution_candidate_contract_metadata(
LocalExecutionCandidateMetadataParts {
eligible,
provider_api_format: provider_api_format.as_str(),
client_api_format: spec_metadata.api_format,
extra_fields: serde_json::Map::new(),
},
execution_strategy,
conversion_mode,
eligible.candidate.endpoint_api_format.as_str(),
))
},
|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) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
&provider_api_format,
);
skipped_candidate.extra_data = Some(
build_local_execution_candidate_contract_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport_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
},
)
.await)
}

View File

@@ -4,7 +4,8 @@ mod payload;
mod request;
pub(crate) use self::build::{
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports,
build_local_stream_attempt_source, build_local_stream_plan_and_reports,
build_local_sync_attempt_source, build_local_sync_plan_and_reports,
maybe_build_stream_via_standard_family_payload, maybe_build_sync_via_standard_family_payload,
};
pub(super) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalStandardCandidateAttempt;

View File

@@ -288,7 +288,9 @@ mod tests {
use super::maybe_build_local_standard_decision_payload_for_candidate;
use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt;
use crate::ai_serving::planner::candidate_resolution::EligibleLocalExecutionCandidate;
use crate::ai_serving::planner::candidate_resolution::{
EligibleLocalExecutionCandidate, LocalExecutionCandidateKind,
};
use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput;
use crate::ai_serving::{
ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot, LocalStandardSourceFamily,
@@ -452,6 +454,7 @@ mod tests {
) -> LocalExecutionCandidateAttempt {
LocalExecutionCandidateAttempt {
eligible: EligibleLocalExecutionCandidate {
kind: LocalExecutionCandidateKind::SingleKey,
candidate: sample_candidate(api_format, endpoint_id),
transport: Arc::new(sample_transport(api_format, endpoint_id)),
provider_api_format: api_format.to_string(),

View File

@@ -15,7 +15,8 @@ mod openai;
pub(crate) use self::codex::apply_codex_openai_responses_special_headers;
pub(crate) use self::family::{
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports,
build_local_stream_attempt_source, build_local_stream_plan_and_reports,
build_local_sync_attempt_source, build_local_sync_plan_and_reports,
};
pub(crate) use self::normalize::{
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
@@ -25,9 +26,13 @@ pub(crate) use self::normalize::{
build_local_openai_responses_upstream_url,
};
pub(crate) use self::openai::{
build_local_openai_chat_stream_attempt_source_for_kind,
build_local_openai_chat_stream_plan_and_reports_for_kind,
build_local_openai_chat_sync_attempt_source_for_kind,
build_local_openai_chat_sync_plan_and_reports_for_kind,
build_local_openai_responses_stream_attempt_source_for_kind,
build_local_openai_responses_stream_plan_and_reports_for_kind,
build_local_openai_responses_sync_attempt_source_for_kind,
build_local_openai_responses_sync_plan_and_reports_for_kind, copy_request_number_field,
copy_request_number_field_as, map_openai_reasoning_effort_to_claude_output,
map_openai_reasoning_effort_to_gemini_budget, maybe_build_stream_local_decision_payload,

View File

@@ -7,6 +7,7 @@ mod support;
pub(super) use self::payload::maybe_build_local_openai_chat_decision_payload_for_candidate;
pub(super) use self::support::{
build_local_openai_chat_candidate_attempt_source,
materialize_local_openai_chat_candidate_attempts, LocalOpenAiChatCandidateAttempt,
LocalOpenAiChatDecisionInput,
LocalOpenAiChatCandidateAttemptSource, LocalOpenAiChatDecisionInput,
};

View File

@@ -1,9 +1,11 @@
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use crate::ai_serving::planner::candidate_materialization::{
build_local_execution_candidate_attempt_source_with_serving,
mark_skipped_local_execution_candidate, mark_skipped_local_execution_candidate_with_extra_data,
mark_skipped_local_execution_candidate_with_failure_diagnostic,
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
LocalExecutionCandidateAttemptSource,
};
use crate::ai_serving::planner::candidate_metadata::{
build_local_execution_candidate_contract_metadata,
@@ -22,6 +24,7 @@ use crate::ai_serving::{
use crate::AppState;
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalOpenAiChatCandidateAttempt;
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttemptSource as LocalOpenAiChatCandidateAttemptSource;
pub(crate) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalOpenAiChatDecisionInput;
pub(crate) async fn mark_skipped_local_openai_chat_candidate(
@@ -189,3 +192,80 @@ pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
outcome.attempts
}
pub(crate) async fn build_local_openai_chat_candidate_attempt_source<'a>(
state: &'a AppState,
trace_id: &str,
input: &LocalOpenAiChatDecisionInput,
body_json: &serde_json::Value,
candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
preselection_skipped: Vec<SkippedLocalExecutionCandidate>,
) -> (LocalOpenAiChatCandidateAttemptSource<'a>, usize) {
let planner_state = PlannerAppState::new(state);
let sticky_session_token = extract_pool_sticky_session_token(body_json);
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
let persistence_policy = build_local_candidate_persistence_policy(
auth_context,
input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::OpenAiChatDecision,
);
build_local_execution_candidate_attempt_source_with_serving(
planner_state,
trace_id,
"openai:chat",
Some(&input.requested_model),
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
candidates,
preselection_skipped,
LocalCandidateResolutionMode::Standard,
|eligible| {
let provider_api_format = eligible.provider_api_format.clone();
let (execution_strategy, conversion_mode) =
ai_local_execution_contract_for_formats("openai:chat", &provider_api_format);
Some(build_local_execution_candidate_contract_metadata(
LocalExecutionCandidateMetadataParts {
eligible,
provider_api_format: provider_api_format.as_str(),
client_api_format: "openai:chat",
extra_fields: serde_json::Map::new(),
},
execution_strategy,
conversion_mode,
eligible.candidate.endpoint_api_format.trim(),
))
},
|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) =
ai_local_execution_contract_for_formats("openai:chat", &provider_api_format);
skipped_candidate.extra_data = Some(
build_local_execution_candidate_contract_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport_ref(),
provider_api_format.as_str(),
"openai:chat",
serde_json::Map::new(),
execution_strategy,
conversion_mode,
provider_api_format.as_str(),
),
);
skipped_candidate
},
)
.await
}

View File

@@ -12,11 +12,14 @@ mod decision;
mod plans;
use self::decision::{
build_local_openai_chat_candidate_attempt_source,
materialize_local_openai_chat_candidate_attempts,
maybe_build_local_openai_chat_decision_payload_for_candidate, LocalOpenAiChatDecisionInput,
maybe_build_local_openai_chat_decision_payload_for_candidate, LocalOpenAiChatCandidateAttempt,
LocalOpenAiChatCandidateAttemptSource, LocalOpenAiChatDecisionInput,
};
use self::plans::{
build_local_openai_chat_stream_plan_and_reports, build_local_openai_chat_sync_plan_and_reports,
build_local_openai_chat_stream_attempt_source, build_local_openai_chat_stream_plan_and_reports,
build_local_openai_chat_sync_attempt_source, build_local_openai_chat_sync_plan_and_reports,
list_local_openai_chat_candidates, resolve_local_openai_chat_decision_input,
set_local_openai_chat_miss_diagnostic,
};
@@ -49,6 +52,50 @@ pub(crate) async fn build_local_openai_chat_stream_plan_and_reports_for_kind(
.await
}
pub(crate) async fn build_local_openai_chat_sync_attempt_source_for_kind<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
plan_kind: &str,
) -> Result<
Option<(
impl crate::ai_serving::planner::LocalExecutionAttemptSource<
crate::ai_serving::planner::plan_builders::AiSyncAttempt,
> + 'a,
usize,
)>,
GatewayError,
> {
build_local_openai_chat_sync_attempt_source(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await
}
pub(crate) async fn build_local_openai_chat_stream_attempt_source_for_kind<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
plan_kind: &str,
) -> Result<
Option<(
impl crate::ai_serving::planner::LocalExecutionAttemptSource<
crate::ai_serving::planner::plan_builders::AiStreamAttempt,
> + 'a,
usize,
)>,
GatewayError,
> {
build_local_openai_chat_stream_attempt_source(
state, parts, trace_id, decision, body_json, plan_kind,
)
.await
}
pub(crate) fn set_local_openai_chat_execution_exhausted_diagnostic(
state: &AppState,
trace_id: &str,

View File

@@ -12,5 +12,9 @@ mod sync;
pub(super) use self::candidates::list_local_openai_chat_candidates;
pub(super) use self::diagnostic::set_local_openai_chat_miss_diagnostic;
pub(super) use self::resolve::resolve_local_openai_chat_decision_input;
pub(super) use self::stream::build_local_openai_chat_stream_plan_and_reports;
pub(super) use self::sync::build_local_openai_chat_sync_plan_and_reports;
pub(super) use self::stream::{
build_local_openai_chat_stream_attempt_source, build_local_openai_chat_stream_plan_and_reports,
};
pub(super) use self::sync::{
build_local_openai_chat_sync_attempt_source, build_local_openai_chat_sync_plan_and_reports,
};

View File

@@ -1,21 +1,181 @@
use async_trait::async_trait;
use tracing::warn;
use super::super::{
build_local_openai_chat_candidate_attempt_source,
materialize_local_openai_chat_candidate_attempts,
maybe_build_local_openai_chat_decision_payload_for_candidate, AppState, GatewayControlDecision,
GatewayError,
GatewayError, LocalOpenAiChatCandidateAttempt, LocalOpenAiChatCandidateAttemptSource,
LocalOpenAiChatDecisionInput,
};
use super::candidates::list_local_openai_chat_candidates;
use super::diagnostic::{
set_local_openai_chat_candidate_evaluation_diagnostic, set_local_openai_chat_miss_diagnostic,
};
use super::resolve::resolve_local_openai_chat_decision_input;
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
use crate::ai_serving::planner::common::OPENAI_CHAT_STREAM_PLAN_KIND;
use crate::ai_serving::planner::plan_builders::{
build_openai_chat_stream_plan_from_decision, AiStreamAttempt,
};
use crate::ai_serving::planner::runtime_miss::apply_local_runtime_candidate_terminal_reason;
pub(crate) struct LocalOpenAiChatStreamAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
body_json: &'a serde_json::Value,
input: LocalOpenAiChatDecisionInput,
candidates: LocalOpenAiChatCandidateAttemptSource<'a>,
}
pub(crate) async fn build_local_openai_chat_stream_attempt_source<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
plan_kind: &str,
) -> Result<Option<(LocalOpenAiChatStreamAttemptSource<'a>, usize)>, GatewayError> {
if plan_kind != OPENAI_CHAT_STREAM_PLAN_KIND {
return Ok(None);
}
let Some(input) = resolve_local_openai_chat_decision_input(
state, trace_id, decision, body_json, plan_kind, true,
)
.await
else {
return Ok(None);
};
let (candidates, skipped_candidates) =
match list_local_openai_chat_candidates(state, &input, true).await {
Ok(value) => value,
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat stream decision scheduler selection failed"
);
set_local_openai_chat_miss_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(input.requested_model.as_str()),
"scheduler_selection_failed",
);
return Ok(None);
}
};
let candidate_count = candidates.len() + skipped_candidates.len();
if candidate_count == 0 {
set_local_openai_chat_candidate_evaluation_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(input.requested_model.as_str()),
0,
);
return Ok(None);
}
set_local_openai_chat_candidate_evaluation_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(input.requested_model.as_str()),
candidate_count,
);
let (candidates, candidate_count) = build_local_openai_chat_candidate_attempt_source(
state,
trace_id,
&input,
body_json,
candidates,
skipped_candidates,
)
.await;
Ok(Some((
LocalOpenAiChatStreamAttemptSource {
state,
parts,
trace_id,
body_json,
input,
candidates,
},
candidate_count,
)))
}
#[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiChatStreamAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_stream_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
apply_local_runtime_candidate_terminal_reason(
self.state,
self.trace_id,
"no_local_stream_plans",
);
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiStreamAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_stream_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
impl LocalOpenAiChatStreamAttemptSource<'_> {
async fn build_stream_attempt(
&self,
attempt: LocalOpenAiChatCandidateAttempt,
) -> Result<Option<AiStreamAttempt>, GatewayError> {
let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
self.state,
self.parts,
self.trace_id,
self.body_json,
&self.input,
attempt,
OPENAI_CHAT_STREAM_PLAN_KIND,
"openai_chat_stream_success",
true,
)
.await
else {
return Ok(None);
};
match build_openai_chat_stream_plan_from_decision(self.parts, self.body_json, payload) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
error = ?err,
"gateway local openai chat stream decision plan build failed"
);
Ok(None)
}
}
}
}
pub(crate) async fn build_local_openai_chat_stream_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,

View File

@@ -1,15 +1,19 @@
use async_trait::async_trait;
use tracing::warn;
use super::super::{
build_local_openai_chat_candidate_attempt_source,
materialize_local_openai_chat_candidate_attempts,
maybe_build_local_openai_chat_decision_payload_for_candidate, AppState, GatewayControlDecision,
GatewayError,
GatewayError, LocalOpenAiChatCandidateAttempt, LocalOpenAiChatCandidateAttemptSource,
LocalOpenAiChatDecisionInput,
};
use super::candidates::list_local_openai_chat_candidates;
use super::diagnostic::{
set_local_openai_chat_candidate_evaluation_diagnostic, set_local_openai_chat_miss_diagnostic,
};
use super::resolve::resolve_local_openai_chat_decision_input;
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
use crate::ai_serving::planner::common::{
force_upstream_streaming_for_provider, OPENAI_CHAT_SYNC_PLAN_KIND,
};
@@ -18,6 +22,15 @@ use crate::ai_serving::planner::plan_builders::{
};
use crate::ai_serving::planner::runtime_miss::apply_local_runtime_candidate_terminal_reason;
pub(crate) struct LocalOpenAiChatSyncAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
body_json: &'a serde_json::Value,
input: LocalOpenAiChatDecisionInput,
candidates: LocalOpenAiChatCandidateAttemptSource<'a>,
}
fn openai_chat_sync_upstream_is_stream_for_candidate(
provider_type: &str,
provider_api_format: &str,
@@ -25,6 +38,157 @@ fn openai_chat_sync_upstream_is_stream_for_candidate(
force_upstream_streaming_for_provider(provider_type, provider_api_format)
}
pub(crate) async fn build_local_openai_chat_sync_attempt_source<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
plan_kind: &str,
) -> Result<Option<(LocalOpenAiChatSyncAttemptSource<'a>, usize)>, GatewayError> {
if plan_kind != OPENAI_CHAT_SYNC_PLAN_KIND {
return Ok(None);
}
let Some(input) = resolve_local_openai_chat_decision_input(
state, trace_id, decision, body_json, plan_kind, true,
)
.await
else {
return Ok(None);
};
let (candidates, skipped_candidates) =
match list_local_openai_chat_candidates(state, &input, false).await {
Ok(value) => value,
Err(err) => {
warn!(
trace_id = %trace_id,
error = ?err,
"gateway local openai chat sync decision scheduler selection failed"
);
set_local_openai_chat_miss_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(input.requested_model.as_str()),
"scheduler_selection_failed",
);
return Ok(None);
}
};
let candidate_count = candidates.len() + skipped_candidates.len();
if candidate_count == 0 {
set_local_openai_chat_candidate_evaluation_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(input.requested_model.as_str()),
0,
);
return Ok(None);
}
set_local_openai_chat_candidate_evaluation_diagnostic(
state,
trace_id,
decision,
plan_kind,
Some(input.requested_model.as_str()),
candidate_count,
);
let (candidates, candidate_count) = build_local_openai_chat_candidate_attempt_source(
state,
trace_id,
&input,
body_json,
candidates,
skipped_candidates,
)
.await;
Ok(Some((
LocalOpenAiChatSyncAttemptSource {
state,
parts,
trace_id,
body_json,
input,
candidates,
},
candidate_count,
)))
}
#[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiChatSyncAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_sync_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
apply_local_runtime_candidate_terminal_reason(
self.state,
self.trace_id,
"no_local_sync_plans",
);
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
impl LocalOpenAiChatSyncAttemptSource<'_> {
async fn build_sync_attempt(
&self,
attempt: LocalOpenAiChatCandidateAttempt,
) -> Result<Option<AiSyncAttempt>, GatewayError> {
let upstream_is_stream = openai_chat_sync_upstream_is_stream_for_candidate(
attempt.eligible.transport.provider.provider_type.as_str(),
attempt.eligible.provider_api_format.as_str(),
);
let Some(payload) = maybe_build_local_openai_chat_decision_payload_for_candidate(
self.state,
self.parts,
self.trace_id,
self.body_json,
&self.input,
attempt,
OPENAI_CHAT_SYNC_PLAN_KIND,
"openai_chat_sync_success",
upstream_is_stream,
)
.await
else {
return Ok(None);
};
match build_openai_chat_sync_plan_from_decision(self.parts, self.body_json, payload) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
error = ?err,
"gateway local openai chat sync decision plan build failed"
);
Ok(None)
}
}
}
}
pub(crate) async fn build_local_openai_chat_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,

View File

@@ -7,13 +7,17 @@ pub(crate) use crate::ai_serving::{
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64,
};
pub(crate) use chat::{
build_local_openai_chat_stream_attempt_source_for_kind,
build_local_openai_chat_stream_plan_and_reports_for_kind,
build_local_openai_chat_sync_attempt_source_for_kind,
build_local_openai_chat_sync_plan_and_reports_for_kind,
maybe_build_stream_local_decision_payload, maybe_build_sync_local_decision_payload,
set_local_openai_chat_execution_exhausted_diagnostic,
};
pub(crate) use responses::{
build_local_openai_responses_stream_attempt_source_for_kind,
build_local_openai_responses_stream_plan_and_reports_for_kind,
build_local_openai_responses_sync_attempt_source_for_kind,
build_local_openai_responses_sync_plan_and_reports_for_kind,
maybe_build_stream_local_openai_responses_decision_payload,
maybe_build_sync_local_openai_responses_decision_payload,

View File

@@ -7,8 +7,9 @@ mod support;
pub(super) use self::payload::maybe_build_local_openai_responses_decision_payload_for_candidate;
pub(super) use self::support::{
build_local_openai_responses_candidate_attempt_source,
materialize_local_openai_responses_candidate_attempts,
resolve_local_openai_responses_decision_input, LocalOpenAiResponsesCandidateAttempt,
LocalOpenAiResponsesDecisionInput,
LocalOpenAiResponsesCandidateAttemptSource, LocalOpenAiResponsesDecisionInput,
};
pub(super) use crate::ai_serving::LocalOpenAiResponsesSpec;

View File

@@ -2,9 +2,11 @@ use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use tracing::warn;
use crate::ai_serving::planner::candidate_materialization::{
build_local_execution_candidate_attempt_source_with_serving,
mark_skipped_local_execution_candidate, mark_skipped_local_execution_candidate_with_extra_data,
mark_skipped_local_execution_candidate_with_failure_diagnostic,
materialize_local_execution_candidates_with_serving, LocalCandidateResolutionMode,
LocalExecutionCandidateAttemptSource,
};
use crate::ai_serving::planner::candidate_metadata::{
build_local_execution_candidate_contract_metadata,
@@ -34,6 +36,7 @@ use crate::{AppState, GatewayError};
use super::LocalOpenAiResponsesSpec;
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttempt as LocalOpenAiResponsesCandidateAttempt;
pub(crate) use crate::ai_serving::planner::candidate_materialization::LocalExecutionCandidateAttemptSource as LocalOpenAiResponsesCandidateAttemptSource;
pub(crate) use crate::ai_serving::planner::decision_input::LocalRequestedModelDecisionInput as LocalOpenAiResponsesDecisionInput;
pub(crate) async fn resolve_local_openai_responses_decision_input(
@@ -221,6 +224,98 @@ pub(crate) async fn materialize_local_openai_responses_candidate_attempts(
Ok((outcome.attempts, outcome.candidate_count))
}
pub(crate) async fn build_local_openai_responses_candidate_attempt_source<'a>(
state: &'a AppState,
trace_id: &str,
input: &LocalOpenAiResponsesDecisionInput,
body_json: &serde_json::Value,
spec: LocalOpenAiResponsesSpec,
) -> Result<(LocalOpenAiResponsesCandidateAttemptSource<'a>, usize), GatewayError> {
let spec_metadata = local_openai_responses_spec_metadata(spec);
let planner_state = PlannerAppState::new(state);
let sticky_session_token = extract_pool_sticky_session_token(body_json);
let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context;
let persistence_policy = build_local_candidate_persistence_policy(
auth_context,
input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::OpenAiResponsesDecision,
);
let preselection = preselect_local_execution_candidates_with_serving(
planner_state,
spec_metadata.api_format,
&input.requested_model,
spec_metadata.require_streaming,
input.required_capabilities.as_ref(),
&input.auth_snapshot,
true,
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
)
.await?;
Ok(build_local_execution_candidate_attempt_source_with_serving(
planner_state,
trace_id,
spec_metadata.api_format,
Some(&input.requested_model),
Some(&input.auth_snapshot),
input.required_capabilities.as_ref(),
sticky_session_token.as_deref(),
input.request_auth_channel.as_deref(),
persistence_policy,
preselection.candidates,
preselection.skipped_candidates,
LocalCandidateResolutionMode::Standard,
|eligible| {
let provider_api_format = eligible.provider_api_format.clone();
let (execution_strategy, conversion_mode) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
&provider_api_format,
);
Some(build_local_execution_candidate_contract_metadata(
LocalExecutionCandidateMetadataParts {
eligible,
provider_api_format: provider_api_format.as_str(),
client_api_format: spec_metadata.api_format,
extra_fields: serde_json::Map::new(),
},
execution_strategy,
conversion_mode,
eligible.candidate.endpoint_api_format.as_str(),
))
},
|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) = ai_local_execution_contract_for_formats(
spec_metadata.api_format,
&provider_api_format,
);
skipped_candidate.extra_data = Some(
build_local_execution_candidate_contract_metadata_for_candidate(
&skipped_candidate.candidate,
skipped_candidate.transport_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
},
)
.await)
}
pub(crate) async fn mark_skipped_local_openai_responses_candidate(
state: &AppState,
input: &LocalOpenAiResponsesDecisionInput,

View File

@@ -11,7 +11,8 @@ use self::decision::{
resolve_local_openai_responses_decision_input,
};
use self::plans::{
build_local_stream_plan_and_reports, build_local_sync_plan_and_reports, resolve_stream_spec,
build_local_stream_attempt_source, build_local_stream_plan_and_reports,
build_local_sync_attempt_source, build_local_sync_plan_and_reports, resolve_stream_spec,
resolve_sync_spec,
};
@@ -45,6 +46,48 @@ pub(crate) async fn build_local_openai_responses_stream_plan_and_reports_for_kin
build_local_stream_plan_and_reports(state, parts, trace_id, decision, body_json, spec).await
}
pub(crate) async fn build_local_openai_responses_sync_attempt_source_for_kind<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
plan_kind: &str,
) -> Result<
Option<(
impl crate::ai_serving::planner::LocalExecutionAttemptSource<AiSyncAttempt> + 'a,
usize,
)>,
GatewayError,
> {
let Some(spec) = resolve_sync_spec(plan_kind) else {
return Ok(None);
};
build_local_sync_attempt_source(state, parts, trace_id, decision, body_json, spec).await
}
pub(crate) async fn build_local_openai_responses_stream_attempt_source_for_kind<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
plan_kind: &str,
) -> Result<
Option<(
impl crate::ai_serving::planner::LocalExecutionAttemptSource<AiStreamAttempt> + 'a,
usize,
)>,
GatewayError,
> {
let Some(spec) = resolve_stream_spec(plan_kind) else {
return Ok(None);
};
build_local_stream_attempt_source(state, parts, trace_id, decision, body_json, spec).await
}
pub(crate) async fn maybe_build_sync_local_openai_responses_decision_payload(
state: &AppState,
parts: &http::request::Parts,

View File

@@ -1,10 +1,15 @@
use async_trait::async_trait;
use tracing::warn;
use super::decision::{
build_local_openai_responses_candidate_attempt_source,
materialize_local_openai_responses_candidate_attempts,
maybe_build_local_openai_responses_decision_payload_for_candidate,
resolve_local_openai_responses_decision_input, LocalOpenAiResponsesSpec,
resolve_local_openai_responses_decision_input, LocalOpenAiResponsesCandidateAttempt,
LocalOpenAiResponsesCandidateAttemptSource, LocalOpenAiResponsesDecisionInput,
LocalOpenAiResponsesSpec,
};
use crate::ai_serving::planner::candidate_materialization::LocalExecutionAttemptSource;
use crate::ai_serving::planner::plan_builders::{
build_openai_responses_stream_plan_from_decision,
build_openai_responses_sync_plan_from_decision, AiStreamAttempt, AiSyncAttempt,
@@ -21,6 +26,260 @@ pub(crate) use crate::ai_serving::{
};
use crate::{AppState, GatewayError};
pub(crate) struct LocalOpenAiResponsesSyncAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
body_json: &'a serde_json::Value,
input: LocalOpenAiResponsesDecisionInput,
spec: LocalOpenAiResponsesSpec,
candidates: LocalOpenAiResponsesCandidateAttemptSource<'a>,
}
pub(crate) struct LocalOpenAiResponsesStreamAttemptSource<'a> {
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
body_json: &'a serde_json::Value,
input: LocalOpenAiResponsesDecisionInput,
spec: LocalOpenAiResponsesSpec,
candidates: LocalOpenAiResponsesCandidateAttemptSource<'a>,
}
pub(super) async fn build_local_sync_attempt_source<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
spec: LocalOpenAiResponsesSpec,
) -> Result<Option<(LocalOpenAiResponsesSyncAttemptSource<'a>, usize)>, GatewayError> {
let spec_metadata = local_openai_responses_spec_metadata(spec);
let Some(input) = resolve_local_openai_responses_decision_input(
state,
trace_id,
decision,
body_json,
spec_metadata.decision_kind,
)
.await
else {
return Ok(None);
};
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
);
let (candidates, candidate_count) = build_local_openai_responses_candidate_attempt_source(
state, trace_id, &input, body_json, spec,
)
.await?;
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalOpenAiResponsesSyncAttemptSource {
state,
parts,
trace_id,
body_json,
input,
spec,
candidates,
},
candidate_count,
)))
}
pub(super) async fn build_local_stream_attempt_source<'a>(
state: &'a AppState,
parts: &'a http::request::Parts,
trace_id: &'a str,
decision: &'a GatewayControlDecision,
body_json: &'a serde_json::Value,
spec: LocalOpenAiResponsesSpec,
) -> Result<Option<(LocalOpenAiResponsesStreamAttemptSource<'a>, usize)>, GatewayError> {
let spec_metadata = local_openai_responses_spec_metadata(spec);
let Some(input) = resolve_local_openai_responses_decision_input(
state,
trace_id,
decision,
body_json,
spec_metadata.decision_kind,
)
.await
else {
return Ok(None);
};
set_local_runtime_miss_diagnostic_reason(
state,
trace_id,
decision,
spec_metadata.decision_kind,
Some(input.requested_model.as_str()),
"candidate_evaluation_incomplete",
);
let (candidates, candidate_count) = build_local_openai_responses_candidate_attempt_source(
state, trace_id, &input, body_json, spec,
)
.await?;
apply_local_runtime_candidate_evaluation_progress(state, trace_id, candidate_count);
if candidate_count == 0 {
return Ok(None);
}
Ok(Some((
LocalOpenAiResponsesStreamAttemptSource {
state,
parts,
trace_id,
body_json,
input,
spec,
candidates,
},
candidate_count,
)))
}
#[async_trait]
impl LocalExecutionAttemptSource<AiSyncAttempt> for LocalOpenAiResponsesSyncAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiSyncAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_sync_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
apply_local_runtime_candidate_terminal_reason(
self.state,
self.trace_id,
"no_local_sync_plans",
);
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiSyncAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_sync_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
#[async_trait]
impl LocalExecutionAttemptSource<AiStreamAttempt> for LocalOpenAiResponsesStreamAttemptSource<'_> {
async fn next_execution_attempt(&mut self) -> Result<Option<AiStreamAttempt>, GatewayError> {
while let Some(attempt) = self.candidates.next_attempt().await {
match self.build_stream_attempt(attempt).await? {
Some(attempt) => return Ok(Some(attempt)),
None => continue,
}
}
apply_local_runtime_candidate_terminal_reason(
self.state,
self.trace_id,
"no_local_stream_plans",
);
Ok(None)
}
async fn drain_execution_attempts(&mut self) -> Result<Vec<AiStreamAttempt>, GatewayError> {
let mut drained = Vec::new();
for attempt in self.candidates.drain_static_attempts() {
if let Some(attempt) = self.build_stream_attempt(attempt).await? {
drained.push(attempt);
}
}
Ok(drained)
}
}
impl LocalOpenAiResponsesSyncAttemptSource<'_> {
async fn build_sync_attempt(
&self,
attempt: LocalOpenAiResponsesCandidateAttempt,
) -> Result<Option<AiSyncAttempt>, GatewayError> {
let Some(payload) = maybe_build_local_openai_responses_decision_payload_for_candidate(
self.state,
self.parts,
self.trace_id,
self.body_json,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_openai_responses_sync_plan_from_decision(
self.parts,
self.body_json,
payload,
self.spec.compact,
) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
error = ?err,
"gateway local openai responses sync decision plan build failed"
);
Ok(None)
}
}
}
}
impl LocalOpenAiResponsesStreamAttemptSource<'_> {
async fn build_stream_attempt(
&self,
attempt: LocalOpenAiResponsesCandidateAttempt,
) -> Result<Option<AiStreamAttempt>, GatewayError> {
let Some(payload) = maybe_build_local_openai_responses_decision_payload_for_candidate(
self.state,
self.parts,
self.trace_id,
self.body_json,
&self.input,
attempt,
self.spec,
)
.await
else {
return Ok(None);
};
match build_openai_responses_stream_plan_from_decision(
self.parts,
self.body_json,
payload,
self.spec.compact,
) {
Ok(value) => Ok(value),
Err(err) => {
warn!(
trace_id = %self.trace_id,
error = ?err,
"gateway local openai responses stream decision plan build failed"
);
Ok(None)
}
}
}
}
pub(super) async fn build_local_sync_plan_and_reports(
state: &AppState,
parts: &http::request::Parts,