mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
fix: carry session affinity through Gemini files
This commit is contained in:
@@ -28,6 +28,7 @@ pub(crate) struct LocalAuthenticatedDecisionInput {
|
|||||||
pub(crate) auth_context: ExecutionRuntimeAuthContext,
|
pub(crate) auth_context: ExecutionRuntimeAuthContext,
|
||||||
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
|
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
|
||||||
pub(crate) required_capabilities: Option<serde_json::Value>,
|
pub(crate) required_capabilities: Option<serde_json::Value>,
|
||||||
|
pub(crate) client_session_affinity: Option<ClientSessionAffinity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayAuthenticatedDecisionInputPort<'a> {
|
struct GatewayAuthenticatedDecisionInputPort<'a> {
|
||||||
@@ -108,6 +109,7 @@ pub(crate) fn build_local_authenticated_decision_input(
|
|||||||
auth_context: resolved_input.auth_context,
|
auth_context: resolved_input.auth_context,
|
||||||
auth_snapshot: resolved_input.auth_snapshot,
|
auth_snapshot: resolved_input.auth_snapshot,
|
||||||
required_capabilities: resolved_input.required_capabilities,
|
required_capabilities: resolved_input.required_capabilities,
|
||||||
|
client_session_affinity: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,14 @@ pub(crate) async fn build_local_gemini_files_sync_attempt_source_for_kind<'a>(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
|
let Some(input) = resolve_local_gemini_files_decision_input(
|
||||||
|
state,
|
||||||
|
parts,
|
||||||
|
Some(body_json),
|
||||||
|
trace_id,
|
||||||
|
decision,
|
||||||
|
)
|
||||||
|
.await
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -140,7 +147,8 @@ pub(crate) async fn build_local_gemini_files_stream_attempt_source_for_kind<'a>(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
|
let Some(input) =
|
||||||
|
resolve_local_gemini_files_decision_input(state, parts, None, trace_id, decision).await
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -298,7 +306,14 @@ pub(crate) async fn maybe_build_sync_local_gemini_files_decision_payload(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
|
let Some(input) = resolve_local_gemini_files_decision_input(
|
||||||
|
state,
|
||||||
|
parts,
|
||||||
|
Some(body_json),
|
||||||
|
trace_id,
|
||||||
|
decision,
|
||||||
|
)
|
||||||
|
.await
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -338,7 +353,8 @@ pub(crate) async fn maybe_build_stream_local_gemini_files_decision_payload(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
|
let Some(input) =
|
||||||
|
resolve_local_gemini_files_decision_input(state, parts, None, trace_id, decision).await
|
||||||
else {
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
@@ -379,7 +395,14 @@ async fn build_local_sync_plan_and_reports(
|
|||||||
spec: LocalGeminiFilesSpec,
|
spec: LocalGeminiFilesSpec,
|
||||||
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
) -> Result<Vec<AiSyncAttempt>, GatewayError> {
|
||||||
let spec_metadata = local_gemini_files_spec_metadata(spec);
|
let spec_metadata = local_gemini_files_spec_metadata(spec);
|
||||||
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
|
let Some(input) = resolve_local_gemini_files_decision_input(
|
||||||
|
state,
|
||||||
|
parts,
|
||||||
|
Some(body_json),
|
||||||
|
trace_id,
|
||||||
|
decision,
|
||||||
|
)
|
||||||
|
.await
|
||||||
else {
|
else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
@@ -430,7 +453,8 @@ async fn build_local_stream_plan_and_reports(
|
|||||||
spec: LocalGeminiFilesSpec,
|
spec: LocalGeminiFilesSpec,
|
||||||
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
) -> Result<Vec<AiStreamAttempt>, GatewayError> {
|
||||||
let spec_metadata = local_gemini_files_spec_metadata(spec);
|
let spec_metadata = local_gemini_files_spec_metadata(spec);
|
||||||
let Some(input) = resolve_local_gemini_files_decision_input(state, trace_id, decision).await
|
let Some(input) =
|
||||||
|
resolve_local_gemini_files_decision_input(state, parts, None, trace_id, decision).await
|
||||||
else {
|
else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::ai_serving::{
|
|||||||
resolve_local_decision_execution_runtime_auth_context, CandidateFailureDiagnostic,
|
resolve_local_decision_execution_runtime_auth_context, CandidateFailureDiagnostic,
|
||||||
ExecutionRuntimeAuthContext, GatewayControlDecision, PlannerAppState,
|
ExecutionRuntimeAuthContext, GatewayControlDecision, PlannerAppState,
|
||||||
};
|
};
|
||||||
|
use crate::client_session_affinity::client_session_affinity_from_parts;
|
||||||
use crate::clock::current_unix_secs;
|
use crate::clock::current_unix_secs;
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ pub(super) const GEMINI_FILES_REQUIRED_CAPABILITY: &str = "gemini_files";
|
|||||||
|
|
||||||
pub(super) async fn resolve_local_gemini_files_decision_input(
|
pub(super) async fn resolve_local_gemini_files_decision_input(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
parts: &http::request::Parts,
|
||||||
|
body_json: Option<&serde_json::Value>,
|
||||||
trace_id: &str,
|
trace_id: &str,
|
||||||
decision: &GatewayControlDecision,
|
decision: &GatewayControlDecision,
|
||||||
) -> Option<LocalGeminiFilesDecisionInput> {
|
) -> Option<LocalGeminiFilesDecisionInput> {
|
||||||
@@ -64,7 +67,9 @@ pub(super) async fn resolve_local_gemini_files_decision_input(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(build_local_authenticated_decision_input(resolved_input))
|
let mut input = build_local_authenticated_decision_input(resolved_input);
|
||||||
|
input.client_session_affinity = client_session_affinity_from_parts(parts, body_json);
|
||||||
|
Some(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn materialize_local_gemini_files_candidate_attempts(
|
pub(super) async fn materialize_local_gemini_files_candidate_attempts(
|
||||||
@@ -84,6 +89,7 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
|
|||||||
GEMINI_FILES_REQUIRED_CAPABILITY,
|
GEMINI_FILES_REQUIRED_CAPABILITY,
|
||||||
false,
|
false,
|
||||||
Some(&input.auth_snapshot),
|
Some(&input.auth_snapshot),
|
||||||
|
input.client_session_affinity.as_ref(),
|
||||||
current_unix_secs(),
|
current_unix_secs(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -93,7 +99,7 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
|
|||||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||||
None,
|
None,
|
||||||
Some(&input.auth_snapshot),
|
Some(&input.auth_snapshot),
|
||||||
None,
|
input.client_session_affinity.as_ref(),
|
||||||
input.required_capabilities.as_ref(),
|
input.required_capabilities.as_ref(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -155,6 +161,7 @@ pub(super) async fn build_local_gemini_files_candidate_attempt_source<'a>(
|
|||||||
GEMINI_FILES_REQUIRED_CAPABILITY,
|
GEMINI_FILES_REQUIRED_CAPABILITY,
|
||||||
false,
|
false,
|
||||||
Some(&input.auth_snapshot),
|
Some(&input.auth_snapshot),
|
||||||
|
input.client_session_affinity.as_ref(),
|
||||||
current_unix_secs(),
|
current_unix_secs(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -164,7 +171,7 @@ pub(super) async fn build_local_gemini_files_candidate_attempt_source<'a>(
|
|||||||
GEMINI_FILES_CLIENT_API_FORMAT,
|
GEMINI_FILES_CLIENT_API_FORMAT,
|
||||||
None,
|
None,
|
||||||
Some(&input.auth_snapshot),
|
Some(&input.auth_snapshot),
|
||||||
None,
|
input.client_session_affinity.as_ref(),
|
||||||
input.required_capabilities.as_ref(),
|
input.required_capabilities.as_ref(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ impl<'a> PlannerAppState<'a> {
|
|||||||
required_capability: &str,
|
required_capability: &str,
|
||||||
require_streaming: bool,
|
require_streaming: bool,
|
||||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||||
|
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||||
now_unix_secs: u64,
|
now_unix_secs: u64,
|
||||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||||
let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS);
|
let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS);
|
||||||
@@ -153,6 +154,7 @@ impl<'a> PlannerAppState<'a> {
|
|||||||
required_capability,
|
required_capability,
|
||||||
require_streaming,
|
require_streaming,
|
||||||
auth_snapshot,
|
auth_snapshot,
|
||||||
|
client_session_affinity,
|
||||||
attempt_now_unix_secs,
|
attempt_now_unix_secs,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
|
|||||||
required_capability: &str,
|
required_capability: &str,
|
||||||
require_streaming: bool,
|
require_streaming: bool,
|
||||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||||
|
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||||
now_unix_secs: u64,
|
now_unix_secs: u64,
|
||||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
|
||||||
Ok(
|
Ok(
|
||||||
@@ -168,6 +169,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
|
|||||||
required_capability,
|
required_capability,
|
||||||
require_streaming,
|
require_streaming,
|
||||||
auth_snapshot,
|
auth_snapshot,
|
||||||
|
client_session_affinity,
|
||||||
now_unix_secs,
|
now_unix_secs,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -182,6 +184,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
|
|||||||
required_capability: &str,
|
required_capability: &str,
|
||||||
require_streaming: bool,
|
require_streaming: bool,
|
||||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||||
|
client_session_affinity: Option<&ClientSessionAffinity>,
|
||||||
now_unix_secs: u64,
|
now_unix_secs: u64,
|
||||||
) -> Result<(Vec<SchedulerMinimalCandidateSelectionCandidate>, bool), GatewayError> {
|
) -> Result<(Vec<SchedulerMinimalCandidateSelectionCandidate>, bool), GatewayError> {
|
||||||
let normalized_api_format = normalize_api_format(candidate_api_format);
|
let normalized_api_format = normalize_api_format(candidate_api_format);
|
||||||
@@ -224,7 +227,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
|
|||||||
require_streaming,
|
require_streaming,
|
||||||
required_capabilities.as_ref(),
|
required_capabilities.as_ref(),
|
||||||
auth_snapshot,
|
auth_snapshot,
|
||||||
None,
|
client_session_affinity,
|
||||||
now_unix_secs,
|
now_unix_secs,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ use aether_data::repository::quota::InMemoryProviderQuotaRepository;
|
|||||||
use aether_data_contracts::repository::candidates::{
|
use aether_data_contracts::repository::candidates::{
|
||||||
RequestCandidateStatus, StoredRequestCandidate,
|
RequestCandidateStatus, StoredRequestCandidate,
|
||||||
};
|
};
|
||||||
|
use aether_scheduler_core::ClientSessionAffinity;
|
||||||
|
|
||||||
|
use crate::cache::SchedulerAffinityTarget;
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
|
use crate::scheduler::affinity::SCHEDULER_AFFINITY_TTL;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
use super::super::affinity::build_scheduler_affinity_cache_key;
|
||||||
use super::super::{
|
use super::super::{
|
||||||
list_selectable_candidates_for_required_capability_without_requested_model,
|
list_selectable_candidates_for_required_capability_without_requested_model,
|
||||||
list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal,
|
list_selectable_candidates_for_required_capability_without_requested_model_with_auth_limit_signal,
|
||||||
@@ -59,6 +63,7 @@ async fn compatible_required_capability_prefers_matching_keys_without_hard_filte
|
|||||||
"cache_1h",
|
"cache_1h",
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
100,
|
100,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -113,6 +118,7 @@ async fn exclusive_required_capability_keeps_hard_filtering_only_matching_keys()
|
|||||||
"gemini_files",
|
"gemini_files",
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
100,
|
100,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -123,6 +129,82 @@ async fn exclusive_required_capability_keeps_hard_filtering_only_matching_keys()
|
|||||||
assert_eq!(selection[0].key_id, "key-b");
|
assert_eq!(selection[0].key_id, "key-b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn required_capability_without_model_uses_session_scoped_affinity() {
|
||||||
|
let mut fallback = sample_row();
|
||||||
|
fallback.provider_id = "provider-a".to_string();
|
||||||
|
fallback.provider_name = "provider-a".to_string();
|
||||||
|
fallback.endpoint_id = "endpoint-a".to_string();
|
||||||
|
fallback.endpoint_api_format = "gemini:generate_content".to_string();
|
||||||
|
fallback.endpoint_api_family = Some("gemini".to_string());
|
||||||
|
fallback.key_api_formats = Some(vec!["gemini:generate_content".to_string()]);
|
||||||
|
fallback.key_id = "key-a".to_string();
|
||||||
|
fallback.key_name = "alpha".to_string();
|
||||||
|
fallback.global_model_name = "gemini-2.5-pro".to_string();
|
||||||
|
fallback.key_capabilities = Some(serde_json::json!({"gemini_files": true}));
|
||||||
|
fallback.key_global_priority_by_format =
|
||||||
|
Some(serde_json::json!({"gemini:generate_content": 0}));
|
||||||
|
|
||||||
|
let mut session_target = fallback.clone();
|
||||||
|
session_target.provider_id = "provider-b".to_string();
|
||||||
|
session_target.provider_name = "provider-b".to_string();
|
||||||
|
session_target.endpoint_id = "endpoint-b".to_string();
|
||||||
|
session_target.key_id = "key-b".to_string();
|
||||||
|
session_target.key_name = "beta".to_string();
|
||||||
|
session_target.key_global_priority_by_format =
|
||||||
|
Some(serde_json::json!({"gemini:generate_content": 10}));
|
||||||
|
|
||||||
|
let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||||
|
fallback,
|
||||||
|
session_target,
|
||||||
|
]));
|
||||||
|
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("state should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas),
|
||||||
|
);
|
||||||
|
let auth_snapshot = sample_auth_snapshot("api-key-1");
|
||||||
|
let client_session_affinity = ClientSessionAffinity::new(
|
||||||
|
Some("generic".to_string()),
|
||||||
|
Some("session=conversation-1".to_string()),
|
||||||
|
);
|
||||||
|
let cache_key = build_scheduler_affinity_cache_key(
|
||||||
|
Some(&auth_snapshot),
|
||||||
|
"gemini:generate_content",
|
||||||
|
"gemini-2.5-pro",
|
||||||
|
Some(&client_session_affinity),
|
||||||
|
)
|
||||||
|
.expect("session affinity cache key should build");
|
||||||
|
state.scheduler_affinity_cache.insert(
|
||||||
|
cache_key,
|
||||||
|
SchedulerAffinityTarget {
|
||||||
|
provider_id: "provider-b".to_string(),
|
||||||
|
endpoint_id: "endpoint-b".to_string(),
|
||||||
|
key_id: "key-b".to_string(),
|
||||||
|
},
|
||||||
|
SCHEDULER_AFFINITY_TTL,
|
||||||
|
16,
|
||||||
|
);
|
||||||
|
|
||||||
|
let selection = list_selectable_candidates_for_required_capability_without_requested_model(
|
||||||
|
state.data.as_ref(),
|
||||||
|
&state,
|
||||||
|
"gemini:generate_content",
|
||||||
|
"gemini_files",
|
||||||
|
false,
|
||||||
|
Some(&auth_snapshot),
|
||||||
|
Some(&client_session_affinity),
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("selection should succeed");
|
||||||
|
|
||||||
|
assert_eq!(selection.len(), 2);
|
||||||
|
assert_eq!(selection[0].provider_id, "provider-b");
|
||||||
|
assert_eq!(selection[0].key_id, "key-b");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn required_capability_reports_auth_limit_signal_when_every_model_is_blocked_by_api_key_concurrency(
|
async fn required_capability_reports_auth_limit_signal_when_every_model_is_blocked_by_api_key_concurrency(
|
||||||
) {
|
) {
|
||||||
@@ -189,6 +271,7 @@ async fn required_capability_reports_auth_limit_signal_when_every_model_is_block
|
|||||||
"cache_1h",
|
"cache_1h",
|
||||||
false,
|
false,
|
||||||
Some(&auth_snapshot),
|
Some(&auth_snapshot),
|
||||||
|
None,
|
||||||
100,
|
100,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in New Issue
Block a user