mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
refactor gateway orchestration and failover effects
This commit is contained in:
335
apps/aether-gateway/src/orchestration/adaptive.rs
Normal file
335
apps/aether-gateway/src/orchestration/adaptive.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::LocalFailoverClassification;
|
||||
use crate::handlers::shared::default_provider_key_status_snapshot;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LocalAdaptiveRateLimitProjection {
|
||||
pub(crate) rpm_429_count: u32,
|
||||
pub(crate) last_429_at_unix_secs: u64,
|
||||
pub(crate) last_429_type: String,
|
||||
pub(crate) status_snapshot: Value,
|
||||
}
|
||||
|
||||
pub(crate) fn project_local_adaptive_rate_limit(
|
||||
current_key: &StoredProviderCatalogKey,
|
||||
classification: LocalFailoverClassification,
|
||||
status_code: u16,
|
||||
headers: Option<&BTreeMap<String, String>>,
|
||||
observed_at_unix_secs: u64,
|
||||
) -> Option<LocalAdaptiveRateLimitProjection> {
|
||||
if current_key.rpm_limit.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !local_candidate_failure_should_record_adaptive_rate_limit(classification, status_code) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let latest_upstream_limit = parse_latest_upstream_limit(headers);
|
||||
Some(LocalAdaptiveRateLimitProjection {
|
||||
rpm_429_count: current_key
|
||||
.rpm_429_count
|
||||
.unwrap_or_default()
|
||||
.saturating_add(1),
|
||||
last_429_at_unix_secs: observed_at_unix_secs,
|
||||
last_429_type: "rpm".to_string(),
|
||||
status_snapshot: project_local_adaptive_status_snapshot(current_key, latest_upstream_limit),
|
||||
})
|
||||
}
|
||||
|
||||
fn local_candidate_failure_should_record_adaptive_rate_limit(
|
||||
classification: LocalFailoverClassification,
|
||||
status_code: u16,
|
||||
) -> bool {
|
||||
status_code == 429
|
||||
|| matches!(
|
||||
classification,
|
||||
LocalFailoverClassification::RetrySemanticRateLimit
|
||||
)
|
||||
}
|
||||
|
||||
fn project_local_adaptive_status_snapshot(
|
||||
current_key: &StoredProviderCatalogKey,
|
||||
latest_upstream_limit: Option<u64>,
|
||||
) -> Value {
|
||||
let default_snapshot = default_provider_key_status_snapshot();
|
||||
let mut snapshot = current_key
|
||||
.status_snapshot
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.or_else(|| default_snapshot.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let observation_count = snapshot
|
||||
.get("observation_count")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1);
|
||||
snapshot.insert("observation_count".to_string(), json!(observation_count));
|
||||
|
||||
if let Some(limit) = latest_upstream_limit {
|
||||
let header_observation_count = snapshot
|
||||
.get("header_observation_count")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1);
|
||||
snapshot.insert(
|
||||
"header_observation_count".to_string(),
|
||||
json!(header_observation_count),
|
||||
);
|
||||
snapshot.insert("latest_upstream_limit".to_string(), json!(limit));
|
||||
}
|
||||
|
||||
let header_observation_count = snapshot
|
||||
.get("header_observation_count")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let effective_upstream_limit = latest_upstream_limit.or_else(|| {
|
||||
snapshot
|
||||
.get("latest_upstream_limit")
|
||||
.and_then(Value::as_u64)
|
||||
});
|
||||
let learning_confidence = projected_learning_confidence(
|
||||
observation_count,
|
||||
header_observation_count,
|
||||
current_key.learned_rpm_limit.is_some(),
|
||||
effective_upstream_limit.is_some(),
|
||||
);
|
||||
snapshot.insert(
|
||||
"learning_confidence".to_string(),
|
||||
json!(learning_confidence),
|
||||
);
|
||||
snapshot.insert(
|
||||
"enforcement_active".to_string(),
|
||||
json!(adaptive_enforcement_active(
|
||||
learning_confidence,
|
||||
current_key.learned_rpm_limit.is_some(),
|
||||
effective_upstream_limit.is_some(),
|
||||
)),
|
||||
);
|
||||
|
||||
Value::Object(snapshot)
|
||||
}
|
||||
|
||||
fn projected_learning_confidence(
|
||||
observation_count: u64,
|
||||
header_observation_count: u64,
|
||||
has_learned_limit: bool,
|
||||
has_upstream_limit: bool,
|
||||
) -> f64 {
|
||||
let base = if has_learned_limit || has_upstream_limit {
|
||||
0.1
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let observation_score = (observation_count.min(8) as f64) * 0.05;
|
||||
let header_score = (header_observation_count.min(3) as f64) * (0.4 / 3.0);
|
||||
((base + observation_score + header_score).min(1.0) * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
fn adaptive_enforcement_active(
|
||||
learning_confidence: f64,
|
||||
has_learned_limit: bool,
|
||||
has_upstream_limit: bool,
|
||||
) -> bool {
|
||||
(has_learned_limit || has_upstream_limit) && learning_confidence >= 0.5
|
||||
}
|
||||
|
||||
fn parse_latest_upstream_limit(headers: Option<&BTreeMap<String, String>>) -> Option<u64> {
|
||||
let normalized = headers?
|
||||
.iter()
|
||||
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
const CANDIDATE_KEYS: &[&str] = &[
|
||||
"x-ratelimit-limit-requests",
|
||||
"x-ratelimit-limit-request",
|
||||
"x-ratelimit-limit",
|
||||
"x-rate-limit-limit",
|
||||
"ratelimit-limit",
|
||||
];
|
||||
|
||||
for key in CANDIDATE_KEYS {
|
||||
if let Some(limit) = normalized
|
||||
.get(*key)
|
||||
.and_then(|value| parse_limit_header_value(value))
|
||||
{
|
||||
return Some(limit);
|
||||
}
|
||||
}
|
||||
|
||||
normalized.iter().find_map(|(key, value)| {
|
||||
if !key.contains("ratelimit") || !key.contains("limit") {
|
||||
return None;
|
||||
}
|
||||
if key.contains("token") {
|
||||
return None;
|
||||
}
|
||||
parse_limit_header_value(value)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_limit_header_value(raw: &str) -> Option<u64> {
|
||||
raw.split([',', ';'])
|
||||
.find_map(|part| {
|
||||
let digits = part
|
||||
.trim()
|
||||
.chars()
|
||||
.take_while(|ch| ch.is_ascii_digit())
|
||||
.collect::<String>();
|
||||
(!digits.is_empty())
|
||||
.then(|| digits.parse::<u64>().ok())
|
||||
.flatten()
|
||||
})
|
||||
.filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::project_local_adaptive_rate_limit;
|
||||
use crate::orchestration::LocalFailoverClassification;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_adaptive_key() -> StoredProviderCatalogKey {
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
"key-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"adaptive".to_string(),
|
||||
"api_key".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build");
|
||||
key.rpm_limit = None;
|
||||
key.learned_rpm_limit = Some(12);
|
||||
key.rpm_429_count = Some(2);
|
||||
key
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_projection_increments_adaptive_rpm_observation() {
|
||||
let key = sample_adaptive_key();
|
||||
|
||||
let projection = project_local_adaptive_rate_limit(
|
||||
&key,
|
||||
LocalFailoverClassification::RetrySemanticRateLimit,
|
||||
429,
|
||||
None,
|
||||
1_760_000_000,
|
||||
)
|
||||
.expect("projection should exist");
|
||||
|
||||
assert_eq!(projection.rpm_429_count, 3);
|
||||
assert_eq!(projection.last_429_at_unix_secs, 1_760_000_000);
|
||||
assert_eq!(projection.last_429_type, "rpm");
|
||||
assert_eq!(projection.status_snapshot["observation_count"], json!(1));
|
||||
assert_eq!(
|
||||
projection.status_snapshot["learning_confidence"],
|
||||
json!(0.15)
|
||||
);
|
||||
assert_eq!(
|
||||
projection.status_snapshot["enforcement_active"],
|
||||
json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_projection_ignores_fixed_limit_keys() {
|
||||
let mut key = sample_adaptive_key();
|
||||
key.rpm_limit = Some(20);
|
||||
|
||||
assert!(project_local_adaptive_rate_limit(
|
||||
&key,
|
||||
LocalFailoverClassification::RetrySemanticRateLimit,
|
||||
429,
|
||||
None,
|
||||
1_760_000_000,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_projection_ignores_non_rate_limit_failures() {
|
||||
let key = sample_adaptive_key();
|
||||
|
||||
assert!(project_local_adaptive_rate_limit(
|
||||
&key,
|
||||
LocalFailoverClassification::RetryUpstreamFailure,
|
||||
503,
|
||||
None,
|
||||
1_760_000_000,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_projection_records_header_observation_and_limit() {
|
||||
let mut key = sample_adaptive_key();
|
||||
key.status_snapshot = Some(json!({
|
||||
"oauth": { "code": "ok" },
|
||||
"observation_count": 4,
|
||||
"header_observation_count": 1,
|
||||
"latest_upstream_limit": 20
|
||||
}));
|
||||
let headers =
|
||||
BTreeMap::from([("x-ratelimit-limit-requests".to_string(), "60".to_string())]);
|
||||
|
||||
let projection = project_local_adaptive_rate_limit(
|
||||
&key,
|
||||
LocalFailoverClassification::RetrySemanticRateLimit,
|
||||
429,
|
||||
Some(&headers),
|
||||
1_760_000_000,
|
||||
)
|
||||
.expect("projection should exist");
|
||||
|
||||
assert_eq!(projection.status_snapshot["observation_count"], json!(5));
|
||||
assert_eq!(
|
||||
projection.status_snapshot["header_observation_count"],
|
||||
json!(2)
|
||||
);
|
||||
assert_eq!(
|
||||
projection.status_snapshot["latest_upstream_limit"],
|
||||
json!(60)
|
||||
);
|
||||
assert_eq!(projection.status_snapshot["oauth"]["code"], json!("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_projection_derives_confidence_and_enforcement_from_evidence() {
|
||||
let mut key = sample_adaptive_key();
|
||||
key.status_snapshot = Some(json!({
|
||||
"observation_count": 7,
|
||||
"header_observation_count": 2,
|
||||
"latest_upstream_limit": 24
|
||||
}));
|
||||
let headers =
|
||||
BTreeMap::from([("x-ratelimit-limit-requests".to_string(), "60".to_string())]);
|
||||
|
||||
let projection = project_local_adaptive_rate_limit(
|
||||
&key,
|
||||
LocalFailoverClassification::RetrySemanticRateLimit,
|
||||
429,
|
||||
Some(&headers),
|
||||
1_760_000_000,
|
||||
)
|
||||
.expect("projection should exist");
|
||||
|
||||
assert_eq!(
|
||||
projection.status_snapshot["learning_confidence"],
|
||||
json!(0.9)
|
||||
);
|
||||
assert_eq!(
|
||||
projection.status_snapshot["enforcement_active"],
|
||||
json!(true)
|
||||
);
|
||||
}
|
||||
}
|
||||
234
apps/aether-gateway/src/orchestration/attempt.rs
Normal file
234
apps/aether-gateway/src/orchestration/attempt.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use aether_scheduler_core::parse_request_candidate_report_context;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::provider_transport::GatewayProviderTransportSnapshot;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct ExecutionAttemptIdentity {
|
||||
pub(crate) candidate_index: u32,
|
||||
pub(crate) retry_index: u32,
|
||||
pub(crate) pool_key_index: Option<u32>,
|
||||
}
|
||||
|
||||
impl ExecutionAttemptIdentity {
|
||||
pub(crate) const fn new(candidate_index: u32, retry_index: u32) -> Self {
|
||||
Self {
|
||||
candidate_index,
|
||||
retry_index,
|
||||
pool_key_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn with_pool_key_index(mut self, pool_key_index: Option<u32>) -> Self {
|
||||
self.pool_key_index = pool_key_index;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct LocalExecutionCandidateMetadata {
|
||||
pub(crate) candidate_group_id: Option<String>,
|
||||
pub(crate) pool_key_index: Option<u32>,
|
||||
}
|
||||
|
||||
pub(crate) fn attempt_identity_from_report_context(
|
||||
report_context: Option<&Value>,
|
||||
) -> Option<ExecutionAttemptIdentity> {
|
||||
let metadata = parse_request_candidate_report_context(report_context)?;
|
||||
let candidate_metadata = local_execution_candidate_metadata_from_report_context(report_context);
|
||||
|
||||
Some(ExecutionAttemptIdentity {
|
||||
candidate_index: metadata.candidate_index?,
|
||||
retry_index: metadata.retry_index,
|
||||
pool_key_index: candidate_metadata.pool_key_index,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn local_execution_candidate_metadata_from_report_context(
|
||||
report_context: Option<&Value>,
|
||||
) -> LocalExecutionCandidateMetadata {
|
||||
LocalExecutionCandidateMetadata {
|
||||
candidate_group_id: report_context
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("candidate_group_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
pool_key_index: report_context
|
||||
.and_then(|value| value.get("pool_key_index"))
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_local_attempt_identities(
|
||||
candidate_index: u32,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Vec<ExecutionAttemptIdentity> {
|
||||
let attempt_slots = resolve_local_attempt_slot_count(transport);
|
||||
(0..attempt_slots)
|
||||
.map(|retry_index| ExecutionAttemptIdentity::new(candidate_index, retry_index))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_local_attempt_slot_count(transport: &GatewayProviderTransportSnapshot) -> u32 {
|
||||
local_attempt_slots_from_transport(transport).unwrap_or(1)
|
||||
}
|
||||
|
||||
fn local_attempt_slots_from_transport(transport: &GatewayProviderTransportSnapshot) -> Option<u32> {
|
||||
transport
|
||||
.provider
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.get("failover_rules"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|value| value.get("max_retries"))
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.map(|value| value.max(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
attempt_identity_from_report_context, build_local_attempt_identities,
|
||||
local_execution_candidate_metadata_from_report_context, ExecutionAttemptIdentity,
|
||||
LocalExecutionCandidateMetadata,
|
||||
};
|
||||
use crate::provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
fn sample_transport(
|
||||
provider_max_retries: Option<i32>,
|
||||
endpoint_max_retries: Option<i32>,
|
||||
provider_config: Option<serde_json::Value>,
|
||||
) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "OpenAI".to_string(),
|
||||
provider_type: "llm".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: provider_max_retries,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: provider_config,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "openai:chat".to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://example.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: endpoint_max_retries,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "primary".to_string(),
|
||||
auth_type: "bearer".to_string(),
|
||||
is_active: true,
|
||||
api_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "secret".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_attempt_identities_defaults_to_single_attempt() {
|
||||
let identities = build_local_attempt_identities(3, &sample_transport(None, None, None));
|
||||
|
||||
assert_eq!(identities, vec![ExecutionAttemptIdentity::new(3, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_attempt_identities_prefer_failover_rules_over_endpoint_and_provider() {
|
||||
let identities = build_local_attempt_identities(
|
||||
1,
|
||||
&sample_transport(
|
||||
Some(5),
|
||||
Some(4),
|
||||
Some(json!({
|
||||
"failover_rules": {
|
||||
"max_retries": 2
|
||||
}
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
identities,
|
||||
vec![
|
||||
ExecutionAttemptIdentity::new(1, 0),
|
||||
ExecutionAttemptIdentity::new(1, 1),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_attempt_identities_require_explicit_failover_rule_for_expansion() {
|
||||
let identities =
|
||||
build_local_attempt_identities(2, &sample_transport(Some(5), Some(3), None));
|
||||
|
||||
assert_eq!(identities, vec![ExecutionAttemptIdentity::new(2, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_attempt_identity_from_report_context_reads_candidate_and_retry_indices() {
|
||||
let identity = attempt_identity_from_report_context(Some(&json!({
|
||||
"candidate_index": 4,
|
||||
"retry_index": 1,
|
||||
"pool_key_index": 7,
|
||||
})))
|
||||
.expect("attempt identity should parse");
|
||||
|
||||
assert_eq!(
|
||||
identity,
|
||||
ExecutionAttemptIdentity {
|
||||
candidate_index: 4,
|
||||
retry_index: 1,
|
||||
pool_key_index: Some(7),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_candidate_metadata_from_report_context_reads_group_and_pool_metadata() {
|
||||
let metadata = local_execution_candidate_metadata_from_report_context(Some(&json!({
|
||||
"candidate_group_id": "group-1",
|
||||
"pool_key_index": 3,
|
||||
})));
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
LocalExecutionCandidateMetadata {
|
||||
candidate_group_id: Some("group-1".to_string()),
|
||||
pool_key_index: Some(3),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
510
apps/aether-gateway/src/orchestration/classifier.rs
Normal file
510
apps/aether-gateway/src/orchestration/classifier.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{LocalFailoverPolicy, LocalFailoverRegexRule};
|
||||
|
||||
const CLIENT_ERROR_TYPES: &[&str] = &[
|
||||
"invalid_request_error",
|
||||
"invalid_argument",
|
||||
"failed_precondition",
|
||||
"validation_error",
|
||||
"bad_request",
|
||||
];
|
||||
|
||||
const CLIENT_ERROR_REASONS: &[&str] = &[
|
||||
"CONTENT_LENGTH_EXCEEDS_THRESHOLD",
|
||||
"CONTEXT_LENGTH_EXCEEDED",
|
||||
"MAX_TOKENS_EXCEEDED",
|
||||
"INVALID_CONTENT",
|
||||
"CONTENT_POLICY_VIOLATION",
|
||||
];
|
||||
|
||||
const CLIENT_ERROR_PATTERNS: &[&str] = &[
|
||||
"could not process image",
|
||||
"image too large",
|
||||
"invalid image",
|
||||
"unsupported image",
|
||||
"content_policy_violation",
|
||||
"context_length_exceeded",
|
||||
"content_length_limit",
|
||||
"content_length_exceeds",
|
||||
"invalid_prompt",
|
||||
"content too long",
|
||||
"input is too long",
|
||||
"message is too long",
|
||||
"prompt is too long",
|
||||
"image exceeds",
|
||||
"pdf too large",
|
||||
"file too large",
|
||||
"tool_use_id",
|
||||
"validationexception",
|
||||
];
|
||||
|
||||
const COMPATIBILITY_ERROR_PATTERNS: &[&str] = &[
|
||||
"unsupported parameter",
|
||||
"unsupported model",
|
||||
"unsupported feature",
|
||||
"not supported with this model",
|
||||
"model does not support",
|
||||
"parameter is not supported",
|
||||
"feature is not supported",
|
||||
"not available for this model",
|
||||
];
|
||||
|
||||
const THINKING_ERROR_PATTERNS: &[&str] = &[
|
||||
"invalid `signature` in `thinking` block",
|
||||
"invalid signature in thinking block",
|
||||
"thinking.signature: field required",
|
||||
"thinking.signature:",
|
||||
"signature verification failed",
|
||||
"must start with a thinking block",
|
||||
"expected thinking or redacted_thinking",
|
||||
"expected `thinking`",
|
||||
"expected thinking, found",
|
||||
"expected `thinking`, found",
|
||||
"expected redacted_thinking, found",
|
||||
"expected `redacted_thinking`, found",
|
||||
"thoughtsignature",
|
||||
"thought_signature",
|
||||
];
|
||||
|
||||
const RETRYABLE_RATE_LIMIT_PATTERNS: &[&str] = &[
|
||||
"rate_limit",
|
||||
"rate limited",
|
||||
"resource_exhausted",
|
||||
"throttl",
|
||||
"too many requests",
|
||||
"quota reached",
|
||||
"quota exceeded",
|
||||
"quota hit",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
struct ParsedLocalErrorResponse {
|
||||
type_name: Option<String>,
|
||||
message: Option<String>,
|
||||
reason: Option<String>,
|
||||
raw: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct LocalFailoverInput<'a> {
|
||||
pub(crate) status_code: u16,
|
||||
pub(crate) response_text: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> LocalFailoverInput<'a> {
|
||||
pub(crate) fn new(status_code: u16, response_text: Option<&'a str>) -> Self {
|
||||
Self {
|
||||
status_code,
|
||||
response_text: response_text
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum LocalFailoverClassification {
|
||||
UseDefault,
|
||||
StopStatusCode,
|
||||
StopErrorPattern,
|
||||
StopSemanticClientError,
|
||||
RetrySuccessPattern,
|
||||
RetrySemanticCompatibilityError,
|
||||
RetrySemanticRateLimit,
|
||||
RetrySemanticThinkingError,
|
||||
RetryStatusCode,
|
||||
RetryUpstreamFailure,
|
||||
}
|
||||
|
||||
pub(crate) fn classify_local_failover(
|
||||
policy: &LocalFailoverPolicy,
|
||||
input: LocalFailoverInput<'_>,
|
||||
) -> LocalFailoverClassification {
|
||||
if policy.stop_status_codes.contains(&input.status_code) {
|
||||
return LocalFailoverClassification::StopStatusCode;
|
||||
}
|
||||
|
||||
if input.status_code >= 400
|
||||
&& input.response_text.is_some_and(|text| {
|
||||
policy
|
||||
.error_stop_patterns
|
||||
.iter()
|
||||
.any(|rule| local_failover_regex_rule_matches(rule, text, input.status_code))
|
||||
})
|
||||
{
|
||||
return LocalFailoverClassification::StopErrorPattern;
|
||||
}
|
||||
|
||||
if input.status_code == 200
|
||||
&& input.response_text.is_some_and(|text| {
|
||||
policy
|
||||
.success_failover_patterns
|
||||
.iter()
|
||||
.any(|rule| local_failover_regex_rule_matches(rule, text, input.status_code))
|
||||
})
|
||||
{
|
||||
return LocalFailoverClassification::RetrySuccessPattern;
|
||||
}
|
||||
|
||||
let parsed_error = parse_local_error_response(input.response_text);
|
||||
|
||||
if is_semantic_thinking_error(input.status_code, &parsed_error) {
|
||||
return LocalFailoverClassification::RetrySemanticThinkingError;
|
||||
}
|
||||
|
||||
if is_semantic_compatibility_error(input.status_code, &parsed_error) {
|
||||
return LocalFailoverClassification::RetrySemanticCompatibilityError;
|
||||
}
|
||||
|
||||
if is_semantic_rate_limit_error(input.status_code, &parsed_error) {
|
||||
return LocalFailoverClassification::RetrySemanticRateLimit;
|
||||
}
|
||||
|
||||
if is_semantic_client_error(input.status_code, &parsed_error) {
|
||||
return LocalFailoverClassification::StopSemanticClientError;
|
||||
}
|
||||
|
||||
if policy.continue_status_codes.contains(&input.status_code) {
|
||||
return LocalFailoverClassification::RetryStatusCode;
|
||||
}
|
||||
|
||||
if should_failover_local_upstream_status(input.status_code) {
|
||||
return LocalFailoverClassification::RetryUpstreamFailure;
|
||||
}
|
||||
|
||||
LocalFailoverClassification::UseDefault
|
||||
}
|
||||
|
||||
pub(crate) fn local_failover_error_message(response_text: Option<&str>) -> Option<String> {
|
||||
let parsed = parse_local_error_response(response_text);
|
||||
parsed
|
||||
.message
|
||||
.or(parsed.reason)
|
||||
.or(parsed.raw)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn should_failover_local_upstream_status(status_code: u16) -> bool {
|
||||
status_code >= 400
|
||||
}
|
||||
|
||||
fn parse_local_error_response(response_text: Option<&str>) -> ParsedLocalErrorResponse {
|
||||
let raw = response_text
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let Some(raw_text) = raw.clone() else {
|
||||
return ParsedLocalErrorResponse::default();
|
||||
};
|
||||
|
||||
let mut parsed = ParsedLocalErrorResponse {
|
||||
raw: Some(raw_text.clone()),
|
||||
..ParsedLocalErrorResponse::default()
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<Value>(&raw_text) else {
|
||||
parsed.message = Some(raw_text);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
let body_object = value.as_object();
|
||||
let error_object = body_object
|
||||
.and_then(|object| object.get("error"))
|
||||
.and_then(Value::as_object);
|
||||
|
||||
parsed.type_name = first_non_empty_json_text(error_object, &["type", "__type"])
|
||||
.or_else(|| first_non_empty_json_text(body_object, &["type", "__type"]));
|
||||
parsed.message = first_non_empty_json_text(error_object, &["message", "detail", "reason"])
|
||||
.or_else(|| first_non_empty_json_text(body_object, &["errorMessage"]))
|
||||
.or_else(|| {
|
||||
body_object
|
||||
.and_then(|object| object.get("error"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.or_else(|| first_non_empty_json_text(body_object, &["message", "detail", "reason"]));
|
||||
parsed.reason = first_non_empty_json_text(error_object, &["reason", "code", "status"])
|
||||
.or_else(|| first_non_empty_json_text(body_object, &["reason", "code", "status"]));
|
||||
|
||||
let Some(message) = parsed.message.clone() else {
|
||||
return parsed;
|
||||
};
|
||||
if !message.starts_with('{') {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
let Ok(nested) = serde_json::from_str::<Value>(&message) else {
|
||||
return parsed;
|
||||
};
|
||||
let nested_object = nested.as_object();
|
||||
let nested_error_object = nested_object
|
||||
.and_then(|object| object.get("error"))
|
||||
.and_then(Value::as_object);
|
||||
parsed.type_name = parsed
|
||||
.type_name
|
||||
.or_else(|| first_non_empty_json_text(nested_error_object, &["type", "__type"]))
|
||||
.or_else(|| first_non_empty_json_text(nested_object, &["type", "__type"]));
|
||||
parsed.message =
|
||||
first_non_empty_json_text(nested_error_object, &["message", "detail", "reason"])
|
||||
.or_else(|| first_non_empty_json_text(nested_object, &["message", "detail", "reason"]))
|
||||
.or(parsed.message);
|
||||
parsed.reason = parsed
|
||||
.reason
|
||||
.or_else(|| first_non_empty_json_text(nested_error_object, &["reason", "code", "status"]))
|
||||
.or_else(|| first_non_empty_json_text(nested_object, &["reason", "code", "status"]));
|
||||
|
||||
parsed
|
||||
}
|
||||
|
||||
fn first_non_empty_json_text(
|
||||
object: Option<&serde_json::Map<String, Value>>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
let object = object?;
|
||||
for key in keys {
|
||||
let Some(value) = object.get(*key) else {
|
||||
continue;
|
||||
};
|
||||
match value {
|
||||
Value::String(text) if !text.trim().is_empty() => return Some(text.trim().to_string()),
|
||||
Value::Number(number) => return Some(number.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn semantic_search_text(parsed: &ParsedLocalErrorResponse) -> String {
|
||||
[
|
||||
parsed.type_name.as_deref(),
|
||||
parsed.reason.as_deref(),
|
||||
parsed.message.as_deref(),
|
||||
parsed.raw.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn is_semantic_client_error(status_code: u16, parsed: &ParsedLocalErrorResponse) -> bool {
|
||||
if status_code < 400 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if parsed.type_name.as_deref().is_some_and(|type_name| {
|
||||
let type_name = type_name.to_ascii_lowercase();
|
||||
CLIENT_ERROR_TYPES
|
||||
.iter()
|
||||
.any(|pattern| type_name.contains(pattern))
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if parsed.reason.as_deref().is_some_and(|reason| {
|
||||
let reason = reason.to_ascii_uppercase();
|
||||
CLIENT_ERROR_REASONS
|
||||
.iter()
|
||||
.any(|pattern| reason.contains(pattern))
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let search_text = semantic_search_text(parsed);
|
||||
!search_text.is_empty()
|
||||
&& CLIENT_ERROR_PATTERNS
|
||||
.iter()
|
||||
.any(|pattern| search_text.contains(&pattern.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
fn is_semantic_compatibility_error(status_code: u16, parsed: &ParsedLocalErrorResponse) -> bool {
|
||||
if status_code < 400 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let search_text = semantic_search_text(parsed);
|
||||
!search_text.is_empty()
|
||||
&& COMPATIBILITY_ERROR_PATTERNS
|
||||
.iter()
|
||||
.any(|pattern| search_text.contains(&pattern.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
fn is_semantic_thinking_error(status_code: u16, parsed: &ParsedLocalErrorResponse) -> bool {
|
||||
if status_code != 400 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let search_text = semantic_search_text(parsed);
|
||||
!search_text.is_empty()
|
||||
&& THINKING_ERROR_PATTERNS
|
||||
.iter()
|
||||
.any(|pattern| search_text.contains(&pattern.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
fn is_semantic_rate_limit_error(status_code: u16, parsed: &ParsedLocalErrorResponse) -> bool {
|
||||
if status_code < 400 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let search_text = semantic_search_text(parsed);
|
||||
!search_text.is_empty()
|
||||
&& RETRYABLE_RATE_LIMIT_PATTERNS
|
||||
.iter()
|
||||
.any(|pattern| search_text.contains(&pattern.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
fn local_failover_regex_rule_matches(
|
||||
rule: &LocalFailoverRegexRule,
|
||||
response_text: &str,
|
||||
status_code: u16,
|
||||
) -> bool {
|
||||
if !rule.status_codes.is_empty() && !rule.status_codes.contains(&status_code) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Regex::new(&rule.pattern)
|
||||
.ok()
|
||||
.is_some_and(|regex| regex.is_match(response_text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::{classify_local_failover, LocalFailoverClassification, LocalFailoverInput};
|
||||
use crate::orchestration::{LocalFailoverPolicy, LocalFailoverRegexRule};
|
||||
|
||||
#[test]
|
||||
fn classifier_honors_explicit_stop_before_default_retryable_status() {
|
||||
let policy = LocalFailoverPolicy {
|
||||
stop_status_codes: [503].into_iter().collect(),
|
||||
..LocalFailoverPolicy::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
classify_local_failover(&policy, LocalFailoverInput::new(503, None)),
|
||||
LocalFailoverClassification::StopStatusCode
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_detects_success_failover_pattern() {
|
||||
let policy = LocalFailoverPolicy {
|
||||
success_failover_patterns: vec![LocalFailoverRegexRule {
|
||||
pattern: "relay:.*格式错误".to_string(),
|
||||
status_codes: BTreeSet::new(),
|
||||
}],
|
||||
..LocalFailoverPolicy::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
classify_local_failover(
|
||||
&policy,
|
||||
LocalFailoverInput::new(200, Some("{\"error\":\"relay: 返回格式错误\"}"))
|
||||
),
|
||||
LocalFailoverClassification::RetrySuccessPattern
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_detects_error_stop_pattern() {
|
||||
let policy = LocalFailoverPolicy {
|
||||
error_stop_patterns: vec![LocalFailoverRegexRule {
|
||||
pattern: "content_policy_violation".to_string(),
|
||||
status_codes: [400, 403].into_iter().collect(),
|
||||
}],
|
||||
..LocalFailoverPolicy::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
classify_local_failover(
|
||||
&policy,
|
||||
LocalFailoverInput::new(400, Some("{\"error\":\"content_policy_violation\"}"))
|
||||
),
|
||||
LocalFailoverClassification::StopErrorPattern
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_stops_semantic_client_errors_without_custom_rule() {
|
||||
assert_eq!(
|
||||
classify_local_failover(
|
||||
&LocalFailoverPolicy::default(),
|
||||
LocalFailoverInput::new(
|
||||
400,
|
||||
Some(
|
||||
"{\"error\":{\"type\":\"invalid_request_error\",\"message\":\"prompt is too long\"}}"
|
||||
)
|
||||
)
|
||||
),
|
||||
LocalFailoverClassification::StopSemanticClientError
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_retries_semantic_compatibility_errors() {
|
||||
assert_eq!(
|
||||
classify_local_failover(
|
||||
&LocalFailoverPolicy::default(),
|
||||
LocalFailoverInput::new(
|
||||
400,
|
||||
Some("{\"error\":{\"message\":\"Unsupported parameter: max_tokens is not supported with this model\"}}")
|
||||
)
|
||||
),
|
||||
LocalFailoverClassification::RetrySemanticCompatibilityError
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_retries_semantic_thinking_errors() {
|
||||
assert_eq!(
|
||||
classify_local_failover(
|
||||
&LocalFailoverPolicy::default(),
|
||||
LocalFailoverInput::new(
|
||||
400,
|
||||
Some(
|
||||
"{\"error\":{\"message\":\"invalid `signature` in `thinking` block: signature is for a different request\"}}"
|
||||
)
|
||||
)
|
||||
),
|
||||
LocalFailoverClassification::RetrySemanticThinkingError
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_retries_semantic_rate_limit_errors_even_when_status_is_not_429() {
|
||||
assert_eq!(
|
||||
classify_local_failover(
|
||||
&LocalFailoverPolicy::default(),
|
||||
LocalFailoverInput::new(
|
||||
400,
|
||||
Some("{\"error\":{\"message\":\"resource_exhausted: quota reached\"}}")
|
||||
)
|
||||
),
|
||||
LocalFailoverClassification::RetrySemanticRateLimit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifier_keeps_embedded_rate_limit_error_in_success_response_on_default_path() {
|
||||
assert_eq!(
|
||||
classify_local_failover(
|
||||
&LocalFailoverPolicy::default(),
|
||||
LocalFailoverInput::new(
|
||||
200,
|
||||
Some(
|
||||
"{\"error\":{\"message\":\"quota reached\",\"type\":\"rate_limit_error\"}}"
|
||||
)
|
||||
)
|
||||
),
|
||||
LocalFailoverClassification::UseDefault
|
||||
);
|
||||
}
|
||||
}
|
||||
1215
apps/aether-gateway/src/orchestration/effects.rs
Normal file
1215
apps/aether-gateway/src/orchestration/effects.rs
Normal file
File diff suppressed because it is too large
Load Diff
188
apps/aether-gateway/src/orchestration/health.rs
Normal file
188
apps/aether-gateway/src/orchestration/health.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::LocalFailoverClassification;
|
||||
use crate::handlers::shared::unix_secs_to_rfc3339;
|
||||
|
||||
const LOCAL_HEALTH_SCORE_FLOOR: f64 = 0.2;
|
||||
|
||||
pub(crate) fn project_local_failure_health(
|
||||
current_health_by_format: Option<&Value>,
|
||||
api_format: &str,
|
||||
classification: LocalFailoverClassification,
|
||||
status_code: u16,
|
||||
observed_at_unix_secs: u64,
|
||||
) -> Option<Value> {
|
||||
if !local_candidate_failure_should_project_health(classification, status_code) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let api_format = api_format.trim();
|
||||
if api_format.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut health_by_format = current_health_by_format
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let current = health_by_format
|
||||
.get(api_format)
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let previous_failures = current
|
||||
.get("consecutive_failures")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
.max(0) as u64;
|
||||
let consecutive_failures = previous_failures.saturating_add(1);
|
||||
|
||||
health_by_format.insert(
|
||||
api_format.to_string(),
|
||||
json!({
|
||||
"health_score": projected_failure_health_score(classification, status_code, consecutive_failures),
|
||||
"consecutive_failures": consecutive_failures,
|
||||
"last_failure_at": unix_secs_to_rfc3339(observed_at_unix_secs),
|
||||
}),
|
||||
);
|
||||
|
||||
Some(Value::Object(health_by_format))
|
||||
}
|
||||
|
||||
pub(crate) fn project_local_success_health(
|
||||
current_health_by_format: Option<&Value>,
|
||||
api_format: &str,
|
||||
) -> Option<Value> {
|
||||
let api_format = api_format.trim();
|
||||
if api_format.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut health_by_format = current_health_by_format
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
health_by_format.insert(
|
||||
api_format.to_string(),
|
||||
json!({
|
||||
"health_score": 1.0,
|
||||
"consecutive_failures": 0,
|
||||
"last_failure_at": Value::Null,
|
||||
}),
|
||||
);
|
||||
Some(Value::Object(health_by_format))
|
||||
}
|
||||
|
||||
fn local_candidate_failure_should_project_health(
|
||||
classification: LocalFailoverClassification,
|
||||
status_code: u16,
|
||||
) -> bool {
|
||||
if status_code < 400 {
|
||||
return false;
|
||||
}
|
||||
|
||||
match classification {
|
||||
LocalFailoverClassification::RetrySuccessPattern
|
||||
| LocalFailoverClassification::RetrySemanticCompatibilityError
|
||||
| LocalFailoverClassification::RetrySemanticRateLimit
|
||||
| LocalFailoverClassification::RetrySemanticThinkingError
|
||||
| LocalFailoverClassification::RetryStatusCode
|
||||
| LocalFailoverClassification::RetryUpstreamFailure => true,
|
||||
LocalFailoverClassification::UseDefault | LocalFailoverClassification::StopStatusCode => {
|
||||
status_code >= 500
|
||||
}
|
||||
LocalFailoverClassification::StopErrorPattern
|
||||
| LocalFailoverClassification::StopSemanticClientError => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn projected_failure_health_score(
|
||||
classification: LocalFailoverClassification,
|
||||
status_code: u16,
|
||||
consecutive_failures: u64,
|
||||
) -> f64 {
|
||||
let base_score = match classification {
|
||||
LocalFailoverClassification::RetrySemanticRateLimit => 0.7,
|
||||
LocalFailoverClassification::RetrySemanticCompatibilityError
|
||||
| LocalFailoverClassification::RetrySemanticThinkingError => 0.8,
|
||||
LocalFailoverClassification::RetrySuccessPattern => 0.75,
|
||||
_ if status_code >= 500 => 0.6,
|
||||
_ => 0.7,
|
||||
};
|
||||
|
||||
let penalty = consecutive_failures.saturating_sub(1) as f64 * 0.15;
|
||||
let normalized = (base_score - penalty).max(LOCAL_HEALTH_SCORE_FLOOR);
|
||||
(normalized * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{project_local_failure_health, project_local_success_health};
|
||||
use crate::orchestration::LocalFailoverClassification;
|
||||
|
||||
#[test]
|
||||
fn failure_projection_tracks_consecutive_failures_and_degrades_score() {
|
||||
let projected = project_local_failure_health(
|
||||
Some(&json!({
|
||||
"openai:chat": {
|
||||
"health_score": 0.7,
|
||||
"consecutive_failures": 1,
|
||||
"last_failure_at": "2026-01-01T00:00:00+00:00"
|
||||
}
|
||||
})),
|
||||
"openai:chat",
|
||||
LocalFailoverClassification::RetryUpstreamFailure,
|
||||
503,
|
||||
1_760_000_000,
|
||||
)
|
||||
.expect("projection should exist");
|
||||
|
||||
assert_eq!(projected["openai:chat"]["consecutive_failures"], json!(2));
|
||||
assert_eq!(projected["openai:chat"]["health_score"], json!(0.45));
|
||||
assert!(projected["openai:chat"]["last_failure_at"].is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_projection_ignores_semantic_client_error() {
|
||||
assert!(project_local_failure_health(
|
||||
None,
|
||||
"openai:chat",
|
||||
LocalFailoverClassification::StopSemanticClientError,
|
||||
400,
|
||||
1_760_000_000,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_projection_resets_only_target_format() {
|
||||
let projected = project_local_success_health(
|
||||
Some(&json!({
|
||||
"openai:chat": {
|
||||
"health_score": 0.4,
|
||||
"consecutive_failures": 3,
|
||||
"last_failure_at": "2026-01-01T00:00:00+00:00"
|
||||
},
|
||||
"openai:responses": {
|
||||
"health_score": 0.8,
|
||||
"consecutive_failures": 1,
|
||||
"last_failure_at": "2026-01-02T00:00:00+00:00"
|
||||
}
|
||||
})),
|
||||
"openai:chat",
|
||||
)
|
||||
.expect("projection should exist");
|
||||
|
||||
assert_eq!(
|
||||
projected["openai:chat"],
|
||||
json!({
|
||||
"health_score": 1.0,
|
||||
"consecutive_failures": 0,
|
||||
"last_failure_at": Value::Null,
|
||||
})
|
||||
);
|
||||
assert_eq!(projected["openai:responses"]["health_score"], json!(0.8));
|
||||
}
|
||||
}
|
||||
78
apps/aether-gateway/src/orchestration/mod.rs
Normal file
78
apps/aether-gateway/src/orchestration/mod.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use aether_contracts::ExecutionPlan;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
mod adaptive;
|
||||
mod attempt;
|
||||
mod classifier;
|
||||
mod effects;
|
||||
mod health;
|
||||
mod policy;
|
||||
mod recovery;
|
||||
mod report_effects;
|
||||
|
||||
pub(crate) use self::adaptive::{
|
||||
project_local_adaptive_rate_limit, LocalAdaptiveRateLimitProjection,
|
||||
};
|
||||
pub(crate) use self::attempt::{
|
||||
attempt_identity_from_report_context, build_local_attempt_identities,
|
||||
local_execution_candidate_metadata_from_report_context, ExecutionAttemptIdentity,
|
||||
LocalExecutionCandidateMetadata,
|
||||
};
|
||||
pub(crate) use self::classifier::{
|
||||
classify_local_failover, local_failover_error_message, LocalFailoverClassification,
|
||||
LocalFailoverInput,
|
||||
};
|
||||
pub(crate) use self::effects::{
|
||||
apply_local_execution_effect, LocalAdaptiveRateLimitEffect, LocalAttemptFailureEffect,
|
||||
LocalExecutionEffect, LocalExecutionEffectContext, LocalHealthFailureEffect,
|
||||
LocalHealthSuccessEffect, LocalOAuthInvalidationEffect, LocalPoolErrorEffect,
|
||||
};
|
||||
pub(crate) use self::health::{project_local_failure_health, project_local_success_health};
|
||||
pub(crate) use self::policy::{
|
||||
append_local_failover_policy_to_value, local_failover_policy_from_report_context,
|
||||
local_failover_policy_from_transport, resolve_local_failover_policy, LocalFailoverPolicy,
|
||||
LocalFailoverRegexRule,
|
||||
};
|
||||
pub(crate) use self::recovery::{
|
||||
analyze_local_failover, recover_local_failover_decision, LocalFailoverAnalysis,
|
||||
LocalFailoverDecision,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::report_effects::clear_local_report_effect_caches_for_tests;
|
||||
pub(crate) use self::report_effects::{
|
||||
apply_local_report_effect, store_local_gemini_file_mapping, LocalReportEffect,
|
||||
};
|
||||
|
||||
pub(crate) async fn resolve_local_failover_analysis_for_attempt(
|
||||
state: &AppState,
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&serde_json::Value>,
|
||||
status_code: u16,
|
||||
response_text: Option<&str>,
|
||||
) -> LocalFailoverAnalysis {
|
||||
if attempt_identity_from_report_context(report_context).is_none() {
|
||||
return LocalFailoverAnalysis::use_default();
|
||||
}
|
||||
|
||||
let policy = resolve_local_failover_policy(state, plan, report_context).await;
|
||||
analyze_local_failover(&policy, LocalFailoverInput::new(status_code, response_text))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_local_failover_decision_for_attempt(
|
||||
state: &AppState,
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&serde_json::Value>,
|
||||
status_code: u16,
|
||||
response_text: Option<&str>,
|
||||
) -> LocalFailoverDecision {
|
||||
resolve_local_failover_analysis_for_attempt(
|
||||
state,
|
||||
plan,
|
||||
report_context,
|
||||
status_code,
|
||||
response_text,
|
||||
)
|
||||
.await
|
||||
.decision
|
||||
}
|
||||
358
apps/aether-gateway/src/orchestration/policy.rs
Normal file
358
apps/aether-gateway/src/orchestration/policy.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use aether_contracts::ExecutionPlan;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::provider_transport::GatewayProviderTransportSnapshot;
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct LocalFailoverPolicy {
|
||||
pub(crate) max_retries: Option<u64>,
|
||||
pub(crate) stop_status_codes: BTreeSet<u16>,
|
||||
pub(crate) continue_status_codes: BTreeSet<u16>,
|
||||
pub(crate) success_failover_patterns: Vec<LocalFailoverRegexRule>,
|
||||
pub(crate) error_stop_patterns: Vec<LocalFailoverRegexRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LocalFailoverRegexRule {
|
||||
pub(crate) pattern: String,
|
||||
pub(crate) status_codes: BTreeSet<u16>,
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_local_failover_policy(
|
||||
state: &AppState,
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&serde_json::Value>,
|
||||
) -> LocalFailoverPolicy {
|
||||
if let Some(policy) = local_failover_policy_from_report_context(report_context) {
|
||||
debug!(
|
||||
event_name = "local_failover_policy_loaded",
|
||||
log_type = "debug",
|
||||
request_id = %plan.request_id,
|
||||
provider_id = %plan.provider_id,
|
||||
endpoint_id = %plan.endpoint_id,
|
||||
key_id = %plan.key_id,
|
||||
source = "report_context",
|
||||
max_retries = ?policy.max_retries,
|
||||
stop_status_code_count = policy.stop_status_codes.len(),
|
||||
continue_status_code_count = policy.continue_status_codes.len(),
|
||||
success_failover_pattern_count = policy.success_failover_patterns.len(),
|
||||
error_stop_pattern_count = policy.error_stop_patterns.len(),
|
||||
"gateway loaded local failover policy from report context"
|
||||
);
|
||||
return policy;
|
||||
}
|
||||
|
||||
let transport = match state
|
||||
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(transport)) => transport,
|
||||
Ok(None) | Err(_) => return LocalFailoverPolicy::default(),
|
||||
};
|
||||
let policy = local_failover_policy_from_transport(&transport);
|
||||
debug!(
|
||||
event_name = "local_failover_policy_loaded",
|
||||
log_type = "debug",
|
||||
request_id = %plan.request_id,
|
||||
provider_id = %plan.provider_id,
|
||||
endpoint_id = %plan.endpoint_id,
|
||||
key_id = %plan.key_id,
|
||||
source = "transport_snapshot",
|
||||
max_retries = ?policy.max_retries,
|
||||
stop_status_code_count = policy.stop_status_codes.len(),
|
||||
continue_status_code_count = policy.continue_status_codes.len(),
|
||||
success_failover_pattern_count = policy.success_failover_patterns.len(),
|
||||
error_stop_pattern_count = policy.error_stop_patterns.len(),
|
||||
"gateway loaded local failover policy from transport snapshot"
|
||||
);
|
||||
policy
|
||||
}
|
||||
|
||||
pub(crate) fn local_failover_policy_from_transport(
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> LocalFailoverPolicy {
|
||||
let rules = transport
|
||||
.provider
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.get("failover_rules"))
|
||||
.and_then(Value::as_object);
|
||||
let max_retries = rules
|
||||
.and_then(|value| value.get("max_retries"))
|
||||
.and_then(parse_u64_value)
|
||||
.or_else(|| {
|
||||
transport
|
||||
.endpoint
|
||||
.max_retries
|
||||
.and_then(|value| u64::try_from(value).ok())
|
||||
})
|
||||
.or_else(|| {
|
||||
transport
|
||||
.provider
|
||||
.max_retries
|
||||
.and_then(|value| u64::try_from(value).ok())
|
||||
});
|
||||
|
||||
LocalFailoverPolicy {
|
||||
max_retries,
|
||||
stop_status_codes: rules
|
||||
.map(|value| {
|
||||
parse_status_code_set(
|
||||
value,
|
||||
&[
|
||||
"stop_on_status_codes",
|
||||
"early_stop_status_codes",
|
||||
"non_retryable_status_codes",
|
||||
"stop_status_codes",
|
||||
],
|
||||
)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
continue_status_codes: rules
|
||||
.map(|value| {
|
||||
parse_status_code_set(
|
||||
value,
|
||||
&[
|
||||
"continue_on_status_codes",
|
||||
"retryable_status_codes",
|
||||
"retry_on_status_codes",
|
||||
"continue_status_codes",
|
||||
],
|
||||
)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
success_failover_patterns: rules
|
||||
.map(|value| parse_regex_rules(value, "success_failover_patterns"))
|
||||
.unwrap_or_default(),
|
||||
error_stop_patterns: rules
|
||||
.map(|value| parse_regex_rules(value, "error_stop_patterns"))
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn local_failover_policy_from_report_context(
|
||||
report_context: Option<&Value>,
|
||||
) -> Option<LocalFailoverPolicy> {
|
||||
let object = report_context
|
||||
.and_then(Value::as_object)?
|
||||
.get("local_failover_policy")?
|
||||
.as_object()?;
|
||||
|
||||
Some(LocalFailoverPolicy {
|
||||
max_retries: object.get("max_retries").and_then(parse_u64_value),
|
||||
stop_status_codes: object
|
||||
.get("stop_status_codes")
|
||||
.map(parse_status_code_list)
|
||||
.unwrap_or_default(),
|
||||
continue_status_codes: object
|
||||
.get("continue_status_codes")
|
||||
.map(parse_status_code_list)
|
||||
.unwrap_or_default(),
|
||||
success_failover_patterns: parse_regex_rules(object, "success_failover_patterns"),
|
||||
error_stop_patterns: parse_regex_rules(object, "error_stop_patterns"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn append_local_failover_policy_to_value(
|
||||
value: Value,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Value {
|
||||
let Value::Object(mut object) = value else {
|
||||
return value;
|
||||
};
|
||||
object.insert(
|
||||
"local_failover_policy".to_string(),
|
||||
local_failover_policy_to_value(&local_failover_policy_from_transport(transport)),
|
||||
);
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
fn parse_status_code_list(value: &Value) -> BTreeSet<u16> {
|
||||
value
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flat_map(|values| values.iter())
|
||||
.filter_map(|value| parse_u64_value(value).and_then(|value| u16::try_from(value).ok()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn local_failover_policy_to_value(policy: &LocalFailoverPolicy) -> Value {
|
||||
json!({
|
||||
"max_retries": policy.max_retries,
|
||||
"stop_status_codes": policy.stop_status_codes.iter().copied().collect::<Vec<_>>(),
|
||||
"continue_status_codes": policy.continue_status_codes.iter().copied().collect::<Vec<_>>(),
|
||||
"success_failover_patterns": policy.success_failover_patterns.iter().map(local_failover_regex_rule_to_value).collect::<Vec<_>>(),
|
||||
"error_stop_patterns": policy.error_stop_patterns.iter().map(local_failover_regex_rule_to_value).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn local_failover_regex_rule_to_value(rule: &LocalFailoverRegexRule) -> Value {
|
||||
json!({
|
||||
"pattern": rule.pattern,
|
||||
"status_codes": rule.status_codes.iter().copied().collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_regex_rules(
|
||||
rules: &serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
) -> Vec<LocalFailoverRegexRule> {
|
||||
rules
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flat_map(|items| items.iter())
|
||||
.filter_map(parse_regex_rule)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_regex_rule(value: &serde_json::Value) -> Option<LocalFailoverRegexRule> {
|
||||
let object = value.as_object()?;
|
||||
let pattern = object
|
||||
.get("pattern")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
Some(LocalFailoverRegexRule {
|
||||
pattern: pattern.to_string(),
|
||||
status_codes: object
|
||||
.get("status_codes")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flat_map(|values| values.iter())
|
||||
.filter_map(|value| parse_u64_value(value).and_then(|value| u16::try_from(value).ok()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_status_code_set(
|
||||
rules: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> BTreeSet<u16> {
|
||||
keys.iter()
|
||||
.filter_map(|key| rules.get(*key))
|
||||
.filter_map(Value::as_array)
|
||||
.flat_map(|values| values.iter())
|
||||
.filter_map(|value| parse_u64_value(value).and_then(|value| u16::try_from(value).ok()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_u64_value(value: &serde_json::Value) -> Option<u64> {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
append_local_failover_policy_to_value, local_failover_policy_from_report_context,
|
||||
LocalFailoverPolicy, LocalFailoverRegexRule,
|
||||
};
|
||||
use crate::provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
GatewayProviderTransportProvider, GatewayProviderTransportSnapshot,
|
||||
};
|
||||
|
||||
fn sample_transport(
|
||||
provider_max_retries: Option<i32>,
|
||||
endpoint_max_retries: Option<i32>,
|
||||
provider_config: Option<serde_json::Value>,
|
||||
) -> GatewayProviderTransportSnapshot {
|
||||
GatewayProviderTransportSnapshot {
|
||||
provider: GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "OpenAI".to_string(),
|
||||
provider_type: "llm".to_string(),
|
||||
website: None,
|
||||
is_active: true,
|
||||
keep_priority_on_conversion: false,
|
||||
enable_format_conversion: true,
|
||||
concurrent_limit: None,
|
||||
max_retries: provider_max_retries,
|
||||
proxy: None,
|
||||
request_timeout_secs: None,
|
||||
stream_first_byte_timeout_secs: None,
|
||||
config: provider_config,
|
||||
},
|
||||
endpoint: GatewayProviderTransportEndpoint {
|
||||
id: "endpoint-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
api_format: "openai:chat".to_string(),
|
||||
api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
is_active: true,
|
||||
base_url: "https://example.com".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: endpoint_max_retries,
|
||||
custom_path: None,
|
||||
config: None,
|
||||
format_acceptance_config: None,
|
||||
proxy: None,
|
||||
},
|
||||
key: GatewayProviderTransportKey {
|
||||
id: "key-1".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
name: "primary".to_string(),
|
||||
auth_type: "bearer".to_string(),
|
||||
is_active: true,
|
||||
api_formats: None,
|
||||
allowed_models: None,
|
||||
capabilities: None,
|
||||
rate_multipliers: None,
|
||||
global_priority_by_format: None,
|
||||
expires_at_unix_secs: None,
|
||||
proxy: None,
|
||||
fingerprint: None,
|
||||
decrypted_api_key: "secret".to_string(),
|
||||
decrypted_auth_config: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_local_failover_policy_to_value_round_trips_policy_shape() {
|
||||
let report_context = append_local_failover_policy_to_value(
|
||||
json!({
|
||||
"request_id": "req-1",
|
||||
}),
|
||||
&sample_transport(
|
||||
Some(5),
|
||||
Some(4),
|
||||
Some(json!({
|
||||
"failover_rules": {
|
||||
"max_retries": 2,
|
||||
"continue_status_codes": [429],
|
||||
"stop_status_codes": [400],
|
||||
"success_failover_patterns": [{"pattern": "quota", "status_codes": [200]}],
|
||||
"error_stop_patterns": [{"pattern": "validation", "status_codes": [422]}]
|
||||
}
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
local_failover_policy_from_report_context(Some(&report_context)),
|
||||
Some(LocalFailoverPolicy {
|
||||
max_retries: Some(2),
|
||||
stop_status_codes: [400].into_iter().collect(),
|
||||
continue_status_codes: [429].into_iter().collect(),
|
||||
success_failover_patterns: vec![LocalFailoverRegexRule {
|
||||
pattern: "quota".to_string(),
|
||||
status_codes: [200].into_iter().collect(),
|
||||
}],
|
||||
error_stop_patterns: vec![LocalFailoverRegexRule {
|
||||
pattern: "validation".to_string(),
|
||||
status_codes: [422].into_iter().collect(),
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
150
apps/aether-gateway/src/orchestration/recovery.rs
Normal file
150
apps/aether-gateway/src/orchestration/recovery.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use super::classifier::{classify_local_failover, LocalFailoverClassification, LocalFailoverInput};
|
||||
use super::LocalFailoverPolicy;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum LocalFailoverDecision {
|
||||
UseDefault,
|
||||
RetryNextCandidate,
|
||||
StopLocalFailover,
|
||||
}
|
||||
|
||||
impl LocalFailoverDecision {
|
||||
pub(crate) const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::UseDefault => "use_default",
|
||||
Self::RetryNextCandidate => "retry_next_candidate",
|
||||
Self::StopLocalFailover => "stop_local_failover",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct LocalFailoverAnalysis {
|
||||
pub(crate) classification: LocalFailoverClassification,
|
||||
pub(crate) decision: LocalFailoverDecision,
|
||||
}
|
||||
|
||||
impl LocalFailoverAnalysis {
|
||||
pub(crate) const fn use_default() -> Self {
|
||||
Self {
|
||||
classification: LocalFailoverClassification::UseDefault,
|
||||
decision: LocalFailoverDecision::UseDefault,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn analyze_local_failover(
|
||||
policy: &LocalFailoverPolicy,
|
||||
input: LocalFailoverInput<'_>,
|
||||
) -> LocalFailoverAnalysis {
|
||||
let classification = classify_local_failover(policy, input);
|
||||
LocalFailoverAnalysis {
|
||||
classification,
|
||||
decision: decision_from_classification(classification),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn recover_local_failover_decision(
|
||||
policy: &LocalFailoverPolicy,
|
||||
input: LocalFailoverInput<'_>,
|
||||
) -> LocalFailoverDecision {
|
||||
analyze_local_failover(policy, input).decision
|
||||
}
|
||||
|
||||
const fn decision_from_classification(
|
||||
classification: LocalFailoverClassification,
|
||||
) -> LocalFailoverDecision {
|
||||
match classification {
|
||||
LocalFailoverClassification::UseDefault => LocalFailoverDecision::UseDefault,
|
||||
LocalFailoverClassification::StopStatusCode
|
||||
| LocalFailoverClassification::StopErrorPattern
|
||||
| LocalFailoverClassification::StopSemanticClientError => {
|
||||
LocalFailoverDecision::StopLocalFailover
|
||||
}
|
||||
LocalFailoverClassification::RetrySuccessPattern
|
||||
| LocalFailoverClassification::RetrySemanticCompatibilityError
|
||||
| LocalFailoverClassification::RetrySemanticRateLimit
|
||||
| LocalFailoverClassification::RetrySemanticThinkingError
|
||||
| LocalFailoverClassification::RetryStatusCode
|
||||
| LocalFailoverClassification::RetryUpstreamFailure => {
|
||||
LocalFailoverDecision::RetryNextCandidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{analyze_local_failover, recover_local_failover_decision, LocalFailoverDecision};
|
||||
use crate::orchestration::{
|
||||
LocalFailoverClassification, LocalFailoverInput, LocalFailoverPolicy,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn recovery_maps_retryable_status_to_retry_next_candidate() {
|
||||
let policy = LocalFailoverPolicy {
|
||||
continue_status_codes: [429].into_iter().collect(),
|
||||
..LocalFailoverPolicy::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
recover_local_failover_decision(&policy, LocalFailoverInput::new(429, None)),
|
||||
LocalFailoverDecision::RetryNextCandidate
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_maps_neutral_status_to_use_default() {
|
||||
assert_eq!(
|
||||
recover_local_failover_decision(
|
||||
&LocalFailoverPolicy::default(),
|
||||
LocalFailoverInput::new(200, None)
|
||||
),
|
||||
LocalFailoverDecision::UseDefault
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_maps_semantic_client_error_to_stop_failover() {
|
||||
assert_eq!(
|
||||
recover_local_failover_decision(
|
||||
&LocalFailoverPolicy::default(),
|
||||
LocalFailoverInput::new(
|
||||
400,
|
||||
Some("{\"error\":{\"type\":\"invalid_request_error\",\"message\":\"prompt is too long\"}}")
|
||||
)
|
||||
),
|
||||
LocalFailoverDecision::StopLocalFailover
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_maps_semantic_thinking_error_to_retry_next_candidate() {
|
||||
assert_eq!(
|
||||
recover_local_failover_decision(
|
||||
&LocalFailoverPolicy::default(),
|
||||
LocalFailoverInput::new(
|
||||
400,
|
||||
Some("{\"error\":{\"message\":\"invalid `signature` in `thinking` block\"}}")
|
||||
)
|
||||
),
|
||||
LocalFailoverDecision::RetryNextCandidate
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_keeps_classification_and_decision_together() {
|
||||
let analysis = analyze_local_failover(
|
||||
&LocalFailoverPolicy::default(),
|
||||
LocalFailoverInput::new(
|
||||
400,
|
||||
Some("{\"error\":{\"message\":\"Unsupported parameter: stream_options is not supported with this model\"}}"),
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(analysis.decision, LocalFailoverDecision::RetryNextCandidate);
|
||||
assert_eq!(
|
||||
analysis.classification,
|
||||
LocalFailoverClassification::RetrySemanticCompatibilityError
|
||||
);
|
||||
}
|
||||
}
|
||||
424
apps/aether-gateway/src/orchestration/report_effects.rs
Normal file
424
apps/aether-gateway/src/orchestration/report_effects.rs
Normal file
@@ -0,0 +1,424 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_admin::provider::quota as admin_provider_quota_pure;
|
||||
use aether_usage_runtime::{
|
||||
extract_gemini_file_mapping_entries, gemini_file_mapping_cache_key, normalize_gemini_file_name,
|
||||
report_request_id, GatewayStreamReportRequest, GatewaySyncReportRequest,
|
||||
GEMINI_FILE_MAPPING_TTL_SECONDS,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::handlers::shared::sync_provider_key_quota_status_snapshot;
|
||||
use crate::log_ids::short_request_id;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
const CODEX_QUOTA_CACHE_TTL_SECONDS: u64 = 30;
|
||||
const CODEX_QUOTA_CACHE_MAX_ENTRIES: usize = 4096;
|
||||
|
||||
type HeaderFingerprintCache = Mutex<HashMap<String, (String, Instant)>>;
|
||||
|
||||
static CODEX_QUOTA_HEADER_FINGERPRINT_CACHE: OnceLock<HeaderFingerprintCache> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum LocalReportEffect<'a> {
|
||||
Sync {
|
||||
payload: &'a GatewaySyncReportRequest,
|
||||
},
|
||||
Stream {
|
||||
payload: &'a GatewayStreamReportRequest,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_local_report_effect(state: &AppState, effect: LocalReportEffect<'_>) {
|
||||
match effect {
|
||||
LocalReportEffect::Sync { payload } => {
|
||||
apply_local_sync_report_effect(state, payload).await;
|
||||
}
|
||||
LocalReportEffect::Stream { payload } => {
|
||||
apply_local_stream_report_effect(state, payload).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_quota_header_fingerprint_cache() -> &'static HeaderFingerprintCache {
|
||||
CODEX_QUOTA_HEADER_FINGERPRINT_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn report_context_key_id(report_context: Option<&Value>) -> Option<String> {
|
||||
report_context
|
||||
.and_then(|context| context.get("key_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn is_volatile_compare_field(key: &str) -> bool {
|
||||
key == "updated_at" || key.ends_with("_reset_seconds") || key.ends_with("_reset_after_seconds")
|
||||
}
|
||||
|
||||
fn canonicalize_value(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Array(items) => Value::Array(items.iter().map(canonicalize_value).collect()),
|
||||
Value::Object(object) => {
|
||||
let mut entries = object.iter().collect::<Vec<_>>();
|
||||
entries.sort_by(|left, right| left.0.cmp(right.0));
|
||||
let mut normalized = serde_json::Map::new();
|
||||
for (key, value) in entries {
|
||||
normalized.insert(key.clone(), canonicalize_value(value));
|
||||
}
|
||||
Value::Object(normalized)
|
||||
}
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fingerprint_codex_payload(value: &Value) -> Option<String> {
|
||||
let object = value.as_object()?;
|
||||
let mut entries = object
|
||||
.iter()
|
||||
.filter(|(key, _)| !is_volatile_compare_field(key))
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|left, right| left.0.cmp(right.0));
|
||||
|
||||
let mut normalized = serde_json::Map::new();
|
||||
for (key, value) in entries {
|
||||
normalized.insert(key.clone(), canonicalize_value(value));
|
||||
}
|
||||
serde_json::to_string(&Value::Object(normalized)).ok()
|
||||
}
|
||||
|
||||
fn get_cached_codex_quota_fingerprint(key_id: &str, now: Instant) -> Option<String> {
|
||||
let mut cache = codex_quota_header_fingerprint_cache()
|
||||
.lock()
|
||||
.expect("codex realtime quota cache should lock");
|
||||
match cache.get(key_id) {
|
||||
Some((fingerprint, expires_at)) if *expires_at > now => Some(fingerprint.clone()),
|
||||
Some(_) => {
|
||||
cache.remove(key_id);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_cached_codex_quota_fingerprint(key_id: &str, fingerprint: String, now: Instant) {
|
||||
let mut cache = codex_quota_header_fingerprint_cache()
|
||||
.lock()
|
||||
.expect("codex realtime quota cache should lock");
|
||||
cache.insert(
|
||||
key_id.to_string(),
|
||||
(
|
||||
fingerprint,
|
||||
now.checked_add(Duration::from_secs(CODEX_QUOTA_CACHE_TTL_SECONDS))
|
||||
.unwrap_or(now),
|
||||
),
|
||||
);
|
||||
|
||||
cache.retain(|_, (_, expires_at)| *expires_at > now);
|
||||
if cache.len() <= CODEX_QUOTA_CACHE_MAX_ENTRIES {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut entries = cache
|
||||
.iter()
|
||||
.map(|(key, (_, expires_at))| (key.clone(), *expires_at))
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by_key(|entry| entry.1);
|
||||
for (key, _) in entries
|
||||
.into_iter()
|
||||
.take(cache.len() - CODEX_QUOTA_CACHE_MAX_ENTRIES)
|
||||
{
|
||||
cache.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_metadata_object(
|
||||
current: Option<&Value>,
|
||||
section_key: &str,
|
||||
section_value: Value,
|
||||
) -> Option<Value> {
|
||||
let mut merged = current
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
merged.insert(section_key.to_string(), section_value);
|
||||
Some(Value::Object(merged))
|
||||
}
|
||||
|
||||
async fn apply_local_sync_report_effect(state: &AppState, payload: &GatewaySyncReportRequest) {
|
||||
apply_local_gemini_file_mapping_report_effect(state, payload).await;
|
||||
if let Err(err) = sync_codex_quota_from_response_headers(
|
||||
state,
|
||||
payload.report_context.as_ref(),
|
||||
&payload.headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
event_name = "codex_realtime_quota_sync_failed",
|
||||
log_type = "ops",
|
||||
report_kind = %payload.report_kind,
|
||||
report_request_id = %short_request_id(report_request_id(payload.report_context.as_ref())),
|
||||
error = ?err,
|
||||
"gateway failed to persist codex realtime quota from sync response headers"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_local_stream_report_effect(state: &AppState, payload: &GatewayStreamReportRequest) {
|
||||
if let Err(err) = sync_codex_quota_from_response_headers(
|
||||
state,
|
||||
payload.report_context.as_ref(),
|
||||
&payload.headers,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
event_name = "codex_realtime_quota_sync_failed",
|
||||
log_type = "ops",
|
||||
report_kind = %payload.report_kind,
|
||||
report_request_id = %short_request_id(report_request_id(payload.report_context.as_ref())),
|
||||
error = ?err,
|
||||
"gateway failed to persist codex realtime quota from stream response headers"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_local_gemini_file_mapping_report_effect(
|
||||
state: &AppState,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) {
|
||||
match payload.report_kind.as_str() {
|
||||
"gemini_files_store_mapping" => {
|
||||
if payload.status_code >= 300 {
|
||||
return;
|
||||
}
|
||||
|
||||
let key_id = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.get("file_key_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let user_id = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.get("user_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let Some(key_id) = key_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in extract_gemini_file_mapping_entries(payload) {
|
||||
if let Err(err) = store_local_gemini_file_mapping(
|
||||
state,
|
||||
entry.file_name.as_str(),
|
||||
key_id,
|
||||
user_id,
|
||||
entry.display_name.as_deref(),
|
||||
entry.mime_type.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
event_name = "gemini_file_mapping_store_failed",
|
||||
log_type = "ops",
|
||||
report_kind = %payload.report_kind,
|
||||
report_request_id = %short_request_id(report_request_id(payload.report_context.as_ref())),
|
||||
file_name = %entry.file_name,
|
||||
error = ?err,
|
||||
"gateway failed to persist gemini file mapping locally"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"gemini_files_delete_mapping" if payload.status_code < 300 => {
|
||||
let file_name = payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|context| context.get("file_name"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_gemini_file_name);
|
||||
let Some(file_name) = file_name else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = delete_local_gemini_file_mapping(state, file_name.as_str()).await {
|
||||
warn!(
|
||||
event_name = "gemini_file_mapping_delete_failed",
|
||||
log_type = "ops",
|
||||
report_kind = %payload.report_kind,
|
||||
report_request_id = %short_request_id(report_request_id(payload.report_context.as_ref())),
|
||||
file_name = %file_name,
|
||||
error = ?err,
|
||||
"gateway failed to delete gemini file mapping locally"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn store_local_gemini_file_mapping(
|
||||
state: &AppState,
|
||||
file_name: &str,
|
||||
key_id: &str,
|
||||
user_id: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<(), GatewayError> {
|
||||
let Some(file_name) = normalize_gemini_file_name(file_name) else {
|
||||
return Ok(());
|
||||
};
|
||||
let expires_at_unix_secs = current_unix_secs().saturating_add(GEMINI_FILE_MAPPING_TTL_SECONDS);
|
||||
|
||||
let _stored = state
|
||||
.upsert_gemini_file_mapping(
|
||||
aether_data::repository::gemini_file_mappings::UpsertGeminiFileMappingRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
file_name: file_name.clone(),
|
||||
key_id: key_id.to_string(),
|
||||
user_id: user_id.map(ToOwned::to_owned),
|
||||
display_name: display_name.map(ToOwned::to_owned),
|
||||
mime_type: mime_type.map(ToOwned::to_owned),
|
||||
source_hash: None,
|
||||
expires_at_unix_secs,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.cache_set_string_with_ttl(
|
||||
gemini_file_mapping_cache_key(file_name.as_str()).as_str(),
|
||||
key_id,
|
||||
GEMINI_FILE_MAPPING_TTL_SECONDS,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_local_gemini_file_mapping(
|
||||
state: &AppState,
|
||||
file_name: &str,
|
||||
) -> Result<(), GatewayError> {
|
||||
let Some(file_name) = normalize_gemini_file_name(file_name) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let _deleted = state
|
||||
.delete_gemini_file_mapping_by_file_name(file_name.as_str())
|
||||
.await?;
|
||||
state
|
||||
.cache_delete_key(gemini_file_mapping_cache_key(file_name.as_str()).as_str())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_codex_quota_from_response_headers(
|
||||
state: &AppState,
|
||||
report_context: Option<&Value>,
|
||||
headers: &BTreeMap<String, String>,
|
||||
) -> Result<bool, GatewayError> {
|
||||
let key_id = match report_context_key_id(report_context) {
|
||||
Some(value) => value,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
let now_unix_secs = current_unix_secs();
|
||||
let Some(parsed) = admin_provider_quota_pure::parse_codex_usage_headers(headers, now_unix_secs)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(incoming_fingerprint) = fingerprint_codex_payload(&parsed) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let now = Instant::now();
|
||||
if get_cached_codex_quota_fingerprint(&key_id, now).as_deref()
|
||||
== Some(incoming_fingerprint.as_str())
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some(key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&key.provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
|
||||
return Ok(false);
|
||||
};
|
||||
if !provider.provider_type.trim().eq_ignore_ascii_case("codex") {
|
||||
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let current_codex = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("codex"))
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_else(serde_json::Map::new);
|
||||
let current_codex = Value::Object(current_codex);
|
||||
let Some(current_fingerprint) = fingerprint_codex_payload(¤t_codex) else {
|
||||
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
|
||||
return Ok(false);
|
||||
};
|
||||
if current_fingerprint == incoming_fingerprint {
|
||||
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let updated_upstream_metadata =
|
||||
merge_metadata_object(key.upstream_metadata.as_ref(), "codex", parsed);
|
||||
let updated_status_snapshot = sync_provider_key_quota_status_snapshot(
|
||||
key.status_snapshot.as_ref(),
|
||||
provider.provider_type.as_str(),
|
||||
updated_upstream_metadata.as_ref(),
|
||||
"response_headers",
|
||||
);
|
||||
let mut updated_key = key;
|
||||
updated_key.upstream_metadata = updated_upstream_metadata;
|
||||
updated_key.status_snapshot = updated_status_snapshot;
|
||||
updated_key.updated_at_unix_secs = Some(now_unix_secs);
|
||||
|
||||
let updated = state
|
||||
.update_provider_catalog_key(&updated_key)
|
||||
.await?
|
||||
.is_some();
|
||||
if updated {
|
||||
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn clear_local_report_effect_caches_for_tests() {
|
||||
if let Some(cache) = CODEX_QUOTA_HEADER_FINGERPRINT_CACHE.get() {
|
||||
cache
|
||||
.lock()
|
||||
.expect("codex realtime quota cache should lock")
|
||||
.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user