mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
5487 lines
193 KiB
Rust
5487 lines
193 KiB
Rust
use std::collections::{BTreeMap, HashMap};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, LazyLock, Mutex as StdMutex, Weak};
|
|
use std::time::Duration;
|
|
|
|
use aether_admin::provider::quota as admin_provider_quota_pure;
|
|
use aether_cache::ExpiringMap;
|
|
use aether_contracts::{ExecutionPlan, ExecutionTelemetry};
|
|
use aether_data_contracts::repository::pool_scores::{
|
|
PoolMemberHardState, PoolMemberIdentity, PoolMemberScheduleFeedback,
|
|
};
|
|
use aether_data_contracts::repository::provider_catalog::{
|
|
ProviderCatalogKeyAdaptiveState, ProviderCatalogKeyAdaptiveStateUpdate,
|
|
ProviderCatalogKeyHealthStateUpdate,
|
|
};
|
|
use aether_routing_core::RoutingPoolPolicyOverride;
|
|
use aether_scheduler_core::{
|
|
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope,
|
|
count_recent_rpm_requests_for_provider_key, ClientSessionAffinity, SchedulerAffinityTarget,
|
|
};
|
|
use aether_usage_runtime::{
|
|
build_stream_terminal_usage_outcome, build_sync_terminal_usage_outcome,
|
|
GatewayStreamReportRequest, GatewaySyncReportRequest, TerminalUsageOutcome,
|
|
};
|
|
use serde_json::Value;
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
use tracing::warn;
|
|
|
|
use super::{
|
|
classify_failure_disposition, local_failover_error_message, project_local_adaptive_rate_limit,
|
|
project_local_adaptive_success, project_local_failure_health, project_local_key_circuit_closed,
|
|
project_local_key_circuit_failure, project_local_success_health,
|
|
resolve_local_failover_analysis_for_attempt, FailureScope, LocalFailoverAnalysis,
|
|
LocalFailoverClassification,
|
|
};
|
|
use crate::ai_serving::extract_pool_sticky_session_token;
|
|
use crate::client_session_affinity::{
|
|
client_session_affinity_from_report_context_value, CLIENT_SESSION_AFFINITY_REPORT_CONTEXT_FIELD,
|
|
};
|
|
use crate::clock::current_unix_secs;
|
|
use crate::handlers::shared::provider_pool::admin_provider_pool_config_from_config_value;
|
|
use crate::handlers::shared::provider_pool::{
|
|
admin_provider_pool_key_terminal_error_reason, record_admin_provider_pool_error,
|
|
record_admin_provider_pool_stream_timeout, record_admin_provider_pool_success,
|
|
release_admin_provider_pool_key_lease, AdminProviderPoolConfig,
|
|
AdminProviderPoolSchedulingPreset,
|
|
};
|
|
use crate::orchestration::{
|
|
local_execution_candidate_metadata_from_report_context,
|
|
ROUTING_POOL_POLICY_OVERRIDE_REPORT_FIELD,
|
|
};
|
|
use crate::scheduler::affinity::{
|
|
scheduler_affinity_policy_context_from_report_context, SCHEDULER_AFFINITY_POLICY_REPORT_FIELD,
|
|
SCHEDULER_AFFINITY_TTL,
|
|
};
|
|
use crate::scheduler::config::{read_scheduler_ordering_config, SchedulerSchedulingMode};
|
|
use crate::AppState;
|
|
|
|
const POOL_SCORE_FEEDBACK_GATE_MAX_ENTRIES: usize = 50_000;
|
|
const HEALTH_SUCCESS_PERSIST_GATE_MAX_ENTRIES: usize = 50_000;
|
|
const ADAPTIVE_SUCCESS_PERSIST_GATE_MAX_ENTRIES: usize = 50_000;
|
|
const POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_ENV: &str =
|
|
"AETHER_GATEWAY_POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_SECS";
|
|
const POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_ENV: &str =
|
|
"AETHER_GATEWAY_POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_SECS";
|
|
const HEALTH_SUCCESS_PERSIST_MIN_INTERVAL_ENV: &str =
|
|
"AETHER_GATEWAY_PROVIDER_KEY_HEALTH_SUCCESS_PERSIST_MIN_INTERVAL_SECS";
|
|
const ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL_ENV: &str =
|
|
"AETHER_GATEWAY_PROVIDER_KEY_ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL_SECS";
|
|
const DEFAULT_POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_SECS: u64 = 5;
|
|
const DEFAULT_POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_SECS: u64 = 1;
|
|
const DEFAULT_HEALTH_SUCCESS_PERSIST_MIN_INTERVAL_SECS: u64 = 5;
|
|
const DEFAULT_ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL_SECS: u64 = 5;
|
|
const MAX_POOL_SCORE_FEEDBACK_MIN_INTERVAL_SECS: u64 = 300;
|
|
const PROVIDER_KEY_EFFECT_LOCK_PRUNE_THRESHOLD: usize = 8_192;
|
|
// Same-process writers are serialized by the per-key lock. Keep remote-writer
|
|
// retries bounded so request/report completion cannot accumulate a long DB tail.
|
|
const PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS: usize = 4;
|
|
|
|
#[derive(Debug)]
|
|
struct ProviderKeyEffectLockPoolState {
|
|
entries: HashMap<String, Weak<TokioMutex<()>>>,
|
|
accesses_since_prune: usize,
|
|
next_growth_prune_at: usize,
|
|
#[cfg(test)]
|
|
prune_count: usize,
|
|
}
|
|
|
|
impl ProviderKeyEffectLockPoolState {
|
|
fn new(min_prune_threshold: usize) -> Self {
|
|
Self {
|
|
entries: HashMap::new(),
|
|
accesses_since_prune: 0,
|
|
next_growth_prune_at: min_prune_threshold,
|
|
#[cfg(test)]
|
|
prune_count: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ProviderKeyEffectLockPool {
|
|
state: StdMutex<ProviderKeyEffectLockPoolState>,
|
|
min_prune_threshold: usize,
|
|
}
|
|
|
|
impl Default for ProviderKeyEffectLockPool {
|
|
fn default() -> Self {
|
|
Self::new(PROVIDER_KEY_EFFECT_LOCK_PRUNE_THRESHOLD)
|
|
}
|
|
}
|
|
|
|
impl ProviderKeyEffectLockPool {
|
|
fn new(min_prune_threshold: usize) -> Self {
|
|
let min_prune_threshold = min_prune_threshold.max(1);
|
|
Self {
|
|
state: StdMutex::new(ProviderKeyEffectLockPoolState::new(min_prune_threshold)),
|
|
min_prune_threshold,
|
|
}
|
|
}
|
|
|
|
fn lock_for(&self, key_id: &str) -> Arc<TokioMutex<()>> {
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
state.accesses_since_prune = state.accesses_since_prune.saturating_add(1);
|
|
let entry_count = state.entries.len();
|
|
let growth_prune_due = entry_count >= state.next_growth_prune_at;
|
|
let maintenance_prune_due = entry_count >= self.min_prune_threshold
|
|
&& state.accesses_since_prune >= entry_count.max(self.min_prune_threshold);
|
|
if growth_prune_due || maintenance_prune_due {
|
|
self.prune_inactive_locks(&mut state);
|
|
}
|
|
|
|
if let Some(existing) = state.entries.get(key_id).and_then(Weak::upgrade) {
|
|
return existing;
|
|
}
|
|
let lock = Arc::new(TokioMutex::new(()));
|
|
state
|
|
.entries
|
|
.insert(key_id.to_string(), Arc::downgrade(&lock));
|
|
lock
|
|
}
|
|
|
|
fn prune_inactive_locks(&self, state: &mut ProviderKeyEffectLockPoolState) {
|
|
state.entries.retain(|_, lock| lock.strong_count() > 0);
|
|
let active_entries = state.entries.len();
|
|
state.next_growth_prune_at = if active_entries < self.min_prune_threshold {
|
|
self.min_prune_threshold
|
|
} else {
|
|
active_entries.saturating_mul(2)
|
|
};
|
|
state.accesses_since_prune = 0;
|
|
#[cfg(test)]
|
|
{
|
|
state.prune_count = state.prune_count.saturating_add(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
static POOL_SCORE_FEEDBACK_GATE: LazyLock<ExpiringMap<String, ()>> =
|
|
LazyLock::new(ExpiringMap::new);
|
|
static HEALTH_SUCCESS_PERSIST_GATE: LazyLock<ExpiringMap<String, ()>> =
|
|
LazyLock::new(ExpiringMap::new);
|
|
static ADAPTIVE_SUCCESS_PERSIST_GATE: LazyLock<ExpiringMap<String, u64>> =
|
|
LazyLock::new(ExpiringMap::new);
|
|
static ADAPTIVE_SUCCESS_PERSIST_GATE_NEXT_TOKEN: AtomicU64 = AtomicU64::new(1);
|
|
static PROVIDER_KEY_EFFECT_LOCKS: LazyLock<ProviderKeyEffectLockPool> =
|
|
LazyLock::new(ProviderKeyEffectLockPool::default);
|
|
static POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL: LazyLock<Duration> = LazyLock::new(|| {
|
|
pool_score_feedback_interval_from_env(
|
|
POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_ENV,
|
|
DEFAULT_POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_SECS,
|
|
)
|
|
});
|
|
static POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL: LazyLock<Duration> = LazyLock::new(|| {
|
|
pool_score_feedback_interval_from_env(
|
|
POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_ENV,
|
|
DEFAULT_POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_SECS,
|
|
)
|
|
});
|
|
static HEALTH_SUCCESS_PERSIST_MIN_INTERVAL: LazyLock<Duration> = LazyLock::new(|| {
|
|
pool_score_feedback_interval_from_env(
|
|
HEALTH_SUCCESS_PERSIST_MIN_INTERVAL_ENV,
|
|
DEFAULT_HEALTH_SUCCESS_PERSIST_MIN_INTERVAL_SECS,
|
|
)
|
|
});
|
|
static ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL: LazyLock<Duration> = LazyLock::new(|| {
|
|
pool_score_feedback_interval_from_env(
|
|
ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL_ENV,
|
|
DEFAULT_ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL_SECS,
|
|
)
|
|
});
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalExecutionEffectContext<'a> {
|
|
pub(crate) plan: &'a ExecutionPlan,
|
|
pub(crate) report_context: Option<&'a Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalPoolErrorEffect<'a> {
|
|
pub(crate) status_code: u16,
|
|
pub(crate) classification: LocalFailoverClassification,
|
|
pub(crate) headers: &'a BTreeMap<String, String>,
|
|
pub(crate) error_body: Option<&'a str>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalAttemptFailureEffect {
|
|
pub(crate) status_code: u16,
|
|
pub(crate) classification: LocalFailoverClassification,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalAdaptiveRateLimitEffect<'a> {
|
|
pub(crate) status_code: u16,
|
|
pub(crate) classification: LocalFailoverClassification,
|
|
pub(crate) headers: Option<&'a BTreeMap<String, String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalHealthFailureEffect {
|
|
pub(crate) status_code: u16,
|
|
pub(crate) classification: LocalFailoverClassification,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalHealthSuccessEffect;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalAdaptiveSuccessEffect;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalOAuthInvalidationEffect<'a> {
|
|
pub(crate) status_code: u16,
|
|
pub(crate) response_text: Option<&'a str>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalOAuthSuccessEffect<'a> {
|
|
pub(crate) status_code: u16,
|
|
pub(crate) request_started_at_unix_ms: Option<u64>,
|
|
pub(crate) request_order_id: Option<&'a str>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) enum LocalExecutionEffect<'a> {
|
|
AttemptFailure(LocalAttemptFailureEffect),
|
|
AdaptiveRateLimit(LocalAdaptiveRateLimitEffect<'a>),
|
|
HealthFailure(LocalHealthFailureEffect),
|
|
HealthSuccess(LocalHealthSuccessEffect),
|
|
AdaptiveSuccess(LocalAdaptiveSuccessEffect),
|
|
OauthInvalidation(LocalOAuthInvalidationEffect<'a>),
|
|
OauthSuccess(LocalOAuthSuccessEffect<'a>),
|
|
PoolSuccessSync {
|
|
payload: &'a GatewaySyncReportRequest,
|
|
},
|
|
PoolSuccessStream {
|
|
payload: &'a GatewayStreamReportRequest,
|
|
},
|
|
PoolError(LocalPoolErrorEffect<'a>),
|
|
PoolStreamTimeout,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct OwnedLocalOAuthSuccessEffect {
|
|
status_code: u16,
|
|
provider_id: String,
|
|
endpoint_id: String,
|
|
key_id: String,
|
|
authorization: String,
|
|
request_started_at_unix_ms: u64,
|
|
request_order_id: String,
|
|
observed_credential_generation: Option<String>,
|
|
}
|
|
|
|
fn owned_local_oauth_success_effect(
|
|
plan: &ExecutionPlan,
|
|
report_context: Option<&Value>,
|
|
effect: LocalOAuthSuccessEffect<'_>,
|
|
) -> Option<OwnedLocalOAuthSuccessEffect> {
|
|
if !(200..300).contains(&effect.status_code) {
|
|
return None;
|
|
}
|
|
let request_started_at_unix_ms = effect.request_started_at_unix_ms?;
|
|
let request_order_id = effect
|
|
.request_order_id
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())?;
|
|
let authorization = execution_plan_authorization(plan)?.trim().to_string();
|
|
if authorization.is_empty() || bearer_access_token(&authorization).is_none() {
|
|
return None;
|
|
}
|
|
Some(OwnedLocalOAuthSuccessEffect {
|
|
status_code: effect.status_code,
|
|
provider_id: plan.provider_id.clone(),
|
|
endpoint_id: plan.endpoint_id.clone(),
|
|
key_id: plan.key_id.clone(),
|
|
authorization,
|
|
request_started_at_unix_ms,
|
|
request_order_id: request_order_id.to_string(),
|
|
observed_credential_generation: report_context_string_field(
|
|
report_context,
|
|
"codex_credential_generation",
|
|
)
|
|
.map(ToOwned::to_owned),
|
|
})
|
|
}
|
|
|
|
/// Schedule a fenced Codex OAuth-success observation after provider headers are available.
|
|
/// Only the small identity/observation tuple is moved into the task; request bodies and plans
|
|
/// remain owned by the caller.
|
|
pub(crate) fn spawn_local_oauth_success_effect(
|
|
state: AppState,
|
|
plan: &ExecutionPlan,
|
|
report_context: Option<&Value>,
|
|
effect: LocalOAuthSuccessEffect<'_>,
|
|
) {
|
|
let Some(effect) = owned_local_oauth_success_effect(plan, report_context, effect) else {
|
|
return;
|
|
};
|
|
tokio::spawn(async move {
|
|
record_oauth_success_effect_owned(&state, effect).await;
|
|
});
|
|
}
|
|
|
|
/// Inputs for the terminal effects of a failed streaming attempt.
|
|
///
|
|
/// The status/body are deliberately supplied by the transport-specific caller:
|
|
/// a WebSocket terminal event may carry its own status and error body, while a
|
|
/// normal stream failure gets them from the HTTP response. Keeping this type at
|
|
/// the orchestration boundary prevents each transport from rebuilding the
|
|
/// health, adaptive, OAuth, and pool effect sequence independently.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub(crate) struct LocalStreamFailureEffect<'a> {
|
|
pub(crate) status_code: u16,
|
|
pub(crate) headers: &'a BTreeMap<String, String>,
|
|
pub(crate) response_text: Option<&'a str>,
|
|
pub(crate) stream_timeout: bool,
|
|
}
|
|
|
|
impl<'a> LocalStreamFailureEffect<'a> {
|
|
pub(crate) const fn new(
|
|
status_code: u16,
|
|
headers: &'a BTreeMap<String, String>,
|
|
response_text: Option<&'a str>,
|
|
) -> Self {
|
|
Self {
|
|
status_code,
|
|
headers,
|
|
response_text,
|
|
stream_timeout: false,
|
|
}
|
|
}
|
|
|
|
pub(crate) const fn with_stream_timeout(mut self) -> Self {
|
|
self.stream_timeout = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
struct PoolFeedbackContext {
|
|
pool_config: AdminProviderPoolConfig,
|
|
sticky_session_token: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum LocalExecutionAuthConfigFence {
|
|
Unfenced,
|
|
Fenced(String),
|
|
}
|
|
|
|
impl LocalExecutionAuthConfigFence {
|
|
fn encrypted_auth_config(&self) -> Option<&str> {
|
|
match self {
|
|
Self::Unfenced => None,
|
|
Self::Fenced(ciphertext) => Some(ciphertext),
|
|
}
|
|
}
|
|
}
|
|
|
|
const ADAPTIVE_RPM_RECENT_CANDIDATE_LIMIT: usize = 512;
|
|
const LOCAL_EXECUTION_SCHEDULER_AFFINITY_MAX_ENTRIES: usize = 10_000;
|
|
|
|
pub(crate) async fn apply_local_execution_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
effect: LocalExecutionEffect<'_>,
|
|
) {
|
|
match effect {
|
|
LocalExecutionEffect::AttemptFailure(effect) => {
|
|
record_attempt_failure_effect(state, context, effect).await;
|
|
}
|
|
LocalExecutionEffect::AdaptiveRateLimit(effect) => {
|
|
record_adaptive_rate_limit_effect(state, context, effect).await;
|
|
}
|
|
LocalExecutionEffect::HealthFailure(effect) => {
|
|
record_health_failure_effect(state, context, effect).await;
|
|
}
|
|
LocalExecutionEffect::HealthSuccess(effect) => {
|
|
record_health_success_effect(state, context, effect).await;
|
|
}
|
|
LocalExecutionEffect::AdaptiveSuccess(effect) => {
|
|
record_adaptive_success_effect(state, context, effect).await;
|
|
}
|
|
LocalExecutionEffect::OauthInvalidation(effect) => {
|
|
record_oauth_invalidation_effect(state, context, effect).await;
|
|
}
|
|
LocalExecutionEffect::OauthSuccess(effect) => {
|
|
record_oauth_success_effect(state, context, effect).await;
|
|
}
|
|
LocalExecutionEffect::PoolSuccessSync { payload } => {
|
|
record_sync_pool_success_effect(state, context, payload).await;
|
|
release_local_pool_key_lease(state, context).await;
|
|
}
|
|
LocalExecutionEffect::PoolSuccessStream { payload } => {
|
|
record_stream_pool_success_effect(state, context, payload).await;
|
|
release_local_pool_key_lease(state, context).await;
|
|
}
|
|
LocalExecutionEffect::PoolError(effect) => {
|
|
record_pool_error_effect(state, context, effect).await;
|
|
release_local_pool_key_lease(state, context).await;
|
|
}
|
|
LocalExecutionEffect::PoolStreamTimeout => {
|
|
record_pool_stream_timeout_effect(state, context).await;
|
|
release_local_pool_key_lease(state, context).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Apply the provider/key effects shared by every successful streaming
|
|
/// transport. Usage persistence and request-candidate terminal status remain
|
|
/// owned by the report layer; this helper only projects execution health,
|
|
/// adaptive state, and pool feedback.
|
|
pub(crate) async fn apply_local_stream_success_effects(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
payload: &GatewayStreamReportRequest,
|
|
) {
|
|
apply_local_execution_effect(
|
|
state,
|
|
context,
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
state,
|
|
context,
|
|
LocalExecutionEffect::AdaptiveSuccess(LocalAdaptiveSuccessEffect),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
state,
|
|
context,
|
|
LocalExecutionEffect::PoolSuccessStream { payload },
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Apply the provider/key effects shared by every failed streaming attempt.
|
|
/// The returned analysis is the same failover classification used by the
|
|
/// normal stream runtime, allowing the caller to make a transport-specific
|
|
/// retry/close decision without re-running policy evaluation.
|
|
pub(crate) async fn apply_local_stream_failure_effects(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
effect: LocalStreamFailureEffect<'_>,
|
|
) -> LocalFailoverAnalysis {
|
|
let analysis = resolve_local_failover_analysis_for_attempt(
|
|
state,
|
|
context.plan,
|
|
context.report_context,
|
|
effect.status_code,
|
|
effect.response_text,
|
|
)
|
|
.await;
|
|
|
|
if effect.stream_timeout {
|
|
apply_local_execution_effect(state, context, LocalExecutionEffect::PoolStreamTimeout).await;
|
|
}
|
|
apply_local_execution_effect(
|
|
state,
|
|
context,
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code: effect.status_code,
|
|
classification: analysis.classification,
|
|
}),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
state,
|
|
context,
|
|
LocalExecutionEffect::AdaptiveRateLimit(LocalAdaptiveRateLimitEffect {
|
|
status_code: effect.status_code,
|
|
classification: analysis.classification,
|
|
headers: Some(effect.headers),
|
|
}),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
state,
|
|
context,
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: effect.status_code,
|
|
classification: analysis.classification,
|
|
}),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
state,
|
|
context,
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: effect.status_code,
|
|
response_text: effect.response_text,
|
|
}),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
state,
|
|
context,
|
|
LocalExecutionEffect::PoolError(LocalPoolErrorEffect {
|
|
status_code: effect.status_code,
|
|
classification: analysis.classification,
|
|
headers: effect.headers,
|
|
error_body: effect.response_text,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
analysis
|
|
}
|
|
|
|
pub(crate) async fn release_local_pool_key_lease(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
) {
|
|
let metadata = local_execution_candidate_metadata_from_report_context(context.report_context);
|
|
let Some(lease) = metadata.pool_key_lease else {
|
|
return;
|
|
};
|
|
release_pool_key_lease(state, &lease).await;
|
|
}
|
|
|
|
/// Releases a lease carried by a planned-but-not-started report context. This
|
|
/// path has no execution plan yet, so it intentionally omits candidate health
|
|
/// logging and only performs the distributed lock cleanup.
|
|
pub(crate) async fn release_pool_key_lease_from_report_context(
|
|
state: &AppState,
|
|
report_context: Option<&Value>,
|
|
) {
|
|
let metadata = local_execution_candidate_metadata_from_report_context(report_context);
|
|
let Some(lease) = metadata.pool_key_lease else {
|
|
return;
|
|
};
|
|
release_pool_key_lease(state, &lease).await;
|
|
}
|
|
|
|
async fn release_pool_key_lease(state: &AppState, lease: &aether_runtime_state::RuntimeLockLease) {
|
|
if let Err(err) =
|
|
release_admin_provider_pool_key_lease(state.runtime_state.as_ref(), lease).await
|
|
{
|
|
warn!(
|
|
error = ?err,
|
|
"gateway orchestration effects: failed to release a planned pool key lease"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn report_context_string_field<'a>(
|
|
report_context: Option<&'a Value>,
|
|
field: &str,
|
|
) -> Option<&'a str> {
|
|
report_context
|
|
.and_then(|context| context.get(field))
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn report_context_u64_field(report_context: Option<&Value>, field: &str) -> Option<u64> {
|
|
report_context
|
|
.and_then(|context| context.get(field))
|
|
.and_then(|value| {
|
|
value
|
|
.as_u64()
|
|
.or_else(|| value.as_str().and_then(|value| value.parse::<u64>().ok()))
|
|
})
|
|
}
|
|
|
|
fn local_scheduler_affinity_cache_key(report_context: Option<&Value>) -> Option<String> {
|
|
let client_session_affinity = local_client_session_affinity(report_context);
|
|
let policy_context = scheduler_affinity_policy_context_from_report_context(report_context);
|
|
if report_context
|
|
.and_then(|context| context.get(SCHEDULER_AFFINITY_POLICY_REPORT_FIELD))
|
|
.is_some()
|
|
&& policy_context.is_none()
|
|
{
|
|
return None;
|
|
}
|
|
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope(
|
|
report_context_string_field(report_context, "api_key_id")?,
|
|
report_context_string_field(report_context, "client_api_format")?,
|
|
report_context_string_field(report_context, "model")?,
|
|
client_session_affinity.as_ref(),
|
|
policy_context
|
|
.as_ref()
|
|
.and_then(|context| context.scope.as_ref()),
|
|
)
|
|
}
|
|
|
|
fn local_client_session_affinity(report_context: Option<&Value>) -> Option<ClientSessionAffinity> {
|
|
let report_context = report_context?;
|
|
if let Some(affinity) = client_session_affinity_from_report_context_value(
|
|
report_context.get(CLIENT_SESSION_AFFINITY_REPORT_CONTEXT_FIELD),
|
|
) {
|
|
return Some(affinity);
|
|
}
|
|
|
|
let headers = header_map_from_report_context(report_context.get("original_headers"));
|
|
let body_json = report_context
|
|
.get("original_request_body")
|
|
.filter(|value| !value.is_null());
|
|
|
|
crate::client_session_affinity::client_session_affinity_from_api_request(
|
|
report_context_string_field(Some(report_context), "client_api_format").unwrap_or_default(),
|
|
&headers,
|
|
body_json,
|
|
)
|
|
}
|
|
|
|
fn header_map_from_report_context(headers: Option<&Value>) -> http::HeaderMap {
|
|
let mut header_map = http::HeaderMap::new();
|
|
let Some(headers) = headers.and_then(Value::as_object) else {
|
|
return header_map;
|
|
};
|
|
|
|
for (name, value) in headers {
|
|
let Some(value) = value.as_str() else {
|
|
continue;
|
|
};
|
|
let Ok(name) = http::header::HeaderName::from_bytes(name.as_bytes()) else {
|
|
continue;
|
|
};
|
|
let Ok(value) = http::HeaderValue::from_str(value) else {
|
|
continue;
|
|
};
|
|
header_map.insert(name, value);
|
|
}
|
|
|
|
header_map
|
|
}
|
|
|
|
fn local_scheduler_affinity_target(plan: &ExecutionPlan) -> Option<SchedulerAffinityTarget> {
|
|
let provider_id = plan.provider_id.trim();
|
|
let endpoint_id = plan.endpoint_id.trim();
|
|
let key_id = plan.key_id.trim();
|
|
if provider_id.is_empty() || endpoint_id.is_empty() || key_id.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(SchedulerAffinityTarget {
|
|
provider_id: provider_id.to_string(),
|
|
endpoint_id: endpoint_id.to_string(),
|
|
key_id: key_id.to_string(),
|
|
})
|
|
}
|
|
|
|
async fn local_execution_plan_uses_pool(state: &AppState, plan: &ExecutionPlan) -> bool {
|
|
let Ok(Some(transport)) = state
|
|
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
|
|
.await
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
admin_provider_pool_config_from_config_value(transport.provider.config.as_ref()).is_some()
|
|
}
|
|
|
|
async fn capture_local_execution_auth_config_fence(
|
|
state: &AppState,
|
|
plan: &ExecutionPlan,
|
|
) -> Option<LocalExecutionAuthConfigFence> {
|
|
let transport = match state
|
|
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
|
|
.await
|
|
{
|
|
Ok(Some(transport)) => transport,
|
|
Ok(None) => return None,
|
|
Err(err) => {
|
|
warn!(
|
|
provider_id = %plan.provider_id,
|
|
endpoint_id = %plan.endpoint_id,
|
|
key_id = %plan.key_id,
|
|
error = ?err,
|
|
"gateway orchestration effects: failed to read transport for credential fencing"
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
if !transport
|
|
.provider
|
|
.provider_type
|
|
.trim()
|
|
.eq_ignore_ascii_case("codex")
|
|
|| !transport.key.auth_type.trim().eq_ignore_ascii_case("oauth")
|
|
{
|
|
return Some(LocalExecutionAuthConfigFence::Unfenced);
|
|
}
|
|
|
|
let authorization = execution_plan_authorization(plan)?;
|
|
let current_uses_agent_identity =
|
|
crate::provider_transport::is_codex_agent_identity_transport(&transport);
|
|
let authorization_matches = if current_uses_agent_identity {
|
|
crate::provider_transport::codex_agent_identity_authorization_matches_transport(
|
|
&transport,
|
|
authorization,
|
|
)
|
|
} else if crate::provider_transport::is_codex_agent_identity_authorization(authorization) {
|
|
false
|
|
} else {
|
|
execution_plan_bearer_matches_transport(plan, &transport)
|
|
};
|
|
if !authorization_matches {
|
|
return None;
|
|
}
|
|
|
|
match state
|
|
.capture_provider_transport_auth_config_fence(&transport)
|
|
.await
|
|
{
|
|
Ok(Some(ciphertext)) => Some(LocalExecutionAuthConfigFence::Fenced(ciphertext)),
|
|
Ok(None) => None,
|
|
Err(err) => {
|
|
warn!(
|
|
provider_id = %plan.provider_id,
|
|
endpoint_id = %plan.endpoint_id,
|
|
key_id = %plan.key_id,
|
|
error = ?err,
|
|
"gateway orchestration effects: failed to capture credential fence"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn local_scheduler_affinity_matches_failed_target(
|
|
state: &AppState,
|
|
plan: &ExecutionPlan,
|
|
cached_target: &SchedulerAffinityTarget,
|
|
failed_target: &SchedulerAffinityTarget,
|
|
) -> bool {
|
|
if cached_target == failed_target {
|
|
return true;
|
|
}
|
|
if cached_target.provider_id != failed_target.provider_id
|
|
|| cached_target.endpoint_id != failed_target.endpoint_id
|
|
{
|
|
return false;
|
|
}
|
|
|
|
local_execution_plan_uses_pool(state, plan).await
|
|
}
|
|
|
|
async fn scheduler_cache_affinity_enabled(
|
|
state: &AppState,
|
|
report_context: Option<&Value>,
|
|
) -> bool {
|
|
if report_context
|
|
.and_then(|context| context.get(SCHEDULER_AFFINITY_POLICY_REPORT_FIELD))
|
|
.is_some()
|
|
{
|
|
return scheduler_affinity_policy_context_from_report_context(report_context)
|
|
.is_some_and(|context| context.cache_affinity_enabled());
|
|
}
|
|
match read_scheduler_ordering_config(state).await {
|
|
Ok(config) => config.scheduling_mode == SchedulerSchedulingMode::CacheAffinity,
|
|
Err(error) => {
|
|
warn!(
|
|
event_name = "orchestration_scheduler_affinity_config_load_failed",
|
|
log_type = "event",
|
|
error = ?error,
|
|
"failed to load scheduler config while checking cache affinity mode"
|
|
);
|
|
SchedulerSchedulingMode::default() == SchedulerSchedulingMode::CacheAffinity
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn remember_successful_local_scheduler_affinity(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
) {
|
|
if !scheduler_cache_affinity_enabled(state, context.report_context).await {
|
|
return;
|
|
}
|
|
let Some(cache_key) = local_scheduler_affinity_cache_key(context.report_context) else {
|
|
return;
|
|
};
|
|
let Some(target) = local_scheduler_affinity_target(context.plan) else {
|
|
return;
|
|
};
|
|
let expected_epoch =
|
|
local_execution_candidate_metadata_from_report_context(context.report_context)
|
|
.scheduler_affinity_epoch;
|
|
|
|
let _ = state.remember_scheduler_affinity_target_for_epoch(
|
|
&cache_key,
|
|
target,
|
|
SCHEDULER_AFFINITY_TTL,
|
|
LOCAL_EXECUTION_SCHEDULER_AFFINITY_MAX_ENTRIES,
|
|
expected_epoch,
|
|
);
|
|
}
|
|
|
|
fn pool_feedback_request_body<'a>(
|
|
plan: &'a ExecutionPlan,
|
|
report_context: Option<&'a Value>,
|
|
) -> Option<&'a Value> {
|
|
report_context
|
|
.and_then(Value::as_object)
|
|
.and_then(|object| object.get("original_request_body"))
|
|
.filter(|value| !value.is_null())
|
|
.or(plan.body.json_body.as_ref())
|
|
}
|
|
|
|
async fn resolve_pool_feedback_context(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
) -> Option<PoolFeedbackContext> {
|
|
let plan = context.plan;
|
|
capture_local_execution_auth_config_fence(state, plan).await?;
|
|
let transport = match state
|
|
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
|
|
.await
|
|
{
|
|
Ok(Some(transport)) => transport,
|
|
Ok(None) => return None,
|
|
Err(err) => {
|
|
warn!(
|
|
"gateway orchestration effects: failed to read transport snapshot for provider {} endpoint {} key {}: {:?}",
|
|
plan.provider_id, plan.endpoint_id, plan.key_id, err
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let Some(mut pool_config) =
|
|
admin_provider_pool_config_from_config_value(transport.provider.config.as_ref())
|
|
else {
|
|
return None;
|
|
};
|
|
|
|
if let Some(override_policy) = context
|
|
.report_context
|
|
.and_then(|report_context| report_context.get(ROUTING_POOL_POLICY_OVERRIDE_REPORT_FIELD))
|
|
.and_then(|value| serde_json::from_value::<RoutingPoolPolicyOverride>(value.clone()).ok())
|
|
.filter(|override_policy| !override_policy.scheduling_presets.is_empty())
|
|
{
|
|
let scheduling_presets = override_policy
|
|
.scheduling_presets
|
|
.into_iter()
|
|
.map(|preset| AdminProviderPoolSchedulingPreset {
|
|
preset: preset.preset,
|
|
enabled: preset.enabled,
|
|
mode: preset.mode,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
pool_config.lru_enabled = scheduling_presets
|
|
.iter()
|
|
.any(|preset| preset.enabled && preset.preset.eq_ignore_ascii_case("lru"));
|
|
pool_config.scheduling_presets = scheduling_presets;
|
|
}
|
|
|
|
let sticky_session_token = pool_feedback_request_body(plan, context.report_context)
|
|
.and_then(extract_pool_sticky_session_token);
|
|
|
|
Some(PoolFeedbackContext {
|
|
pool_config,
|
|
sticky_session_token,
|
|
})
|
|
}
|
|
|
|
fn total_tokens_used(outcome: &TerminalUsageOutcome) -> u64 {
|
|
outcome
|
|
.standardized_usage
|
|
.as_ref()
|
|
.map(|usage| {
|
|
usage
|
|
.input_tokens
|
|
.saturating_add(usage.output_tokens)
|
|
.max(0) as u64
|
|
})
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn resolve_ttfb_ms(telemetry: Option<&ExecutionTelemetry>) -> Option<u64> {
|
|
telemetry.and_then(|telemetry| telemetry.ttfb_ms.or(telemetry.elapsed_ms))
|
|
}
|
|
|
|
async fn record_attempt_failure_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
effect: LocalAttemptFailureEffect,
|
|
) {
|
|
if !local_candidate_failure_should_invalidate_affinity_for_provider(
|
|
&context.plan.provider_api_format,
|
|
effect.classification,
|
|
effect.status_code,
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if let Some(cache_key) = local_scheduler_affinity_cache_key(context.report_context) {
|
|
let Some(failed_target) = local_scheduler_affinity_target(context.plan) else {
|
|
return;
|
|
};
|
|
let Some(cached_target) =
|
|
state.read_scheduler_affinity_target(&cache_key, SCHEDULER_AFFINITY_TTL)
|
|
else {
|
|
return;
|
|
};
|
|
if local_scheduler_affinity_matches_failed_target(
|
|
state,
|
|
context.plan,
|
|
&cached_target,
|
|
&failed_target,
|
|
)
|
|
.await
|
|
{
|
|
let _ = state.remove_scheduler_affinity_cache_entry(&cache_key);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn record_sync_pool_success_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
payload: &GatewaySyncReportRequest,
|
|
) {
|
|
let Some(pool_context) = resolve_pool_feedback_context(state, context).await else {
|
|
return;
|
|
};
|
|
|
|
let usage_outcome =
|
|
build_sync_terminal_usage_outcome(context.plan, context.report_context, payload);
|
|
record_admin_provider_pool_success(
|
|
state.runtime_state.as_ref(),
|
|
&context.plan.provider_id,
|
|
&context.plan.key_id,
|
|
&pool_context.pool_config,
|
|
pool_context.sticky_session_token.as_deref(),
|
|
total_tokens_used(&usage_outcome),
|
|
resolve_ttfb_ms(payload.telemetry.as_ref()),
|
|
)
|
|
.await;
|
|
record_pool_score_schedule_feedback(
|
|
state,
|
|
context,
|
|
Some(true),
|
|
Some(PoolMemberHardState::Available),
|
|
Some(50),
|
|
serde_json::json!({
|
|
"last_request_feedback": {
|
|
"source": "sync_success"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn record_adaptive_rate_limit_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
effect: LocalAdaptiveRateLimitEffect<'_>,
|
|
) {
|
|
if !local_candidate_failure_should_apply_key_effects(
|
|
&context.plan.provider_api_format,
|
|
effect.classification,
|
|
effect.status_code,
|
|
) {
|
|
return;
|
|
}
|
|
let Some(auth_config_fence) =
|
|
capture_local_execution_auth_config_fence(state, context.plan).await
|
|
else {
|
|
return;
|
|
};
|
|
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
|
|
let _effect_guard = effect_lock.lock().await;
|
|
let observed_at_unix_secs = current_unix_secs();
|
|
let current_rpm = state
|
|
.read_recent_request_candidates(ADAPTIVE_RPM_RECENT_CANDIDATE_LIMIT)
|
|
.await
|
|
.ok()
|
|
.map(|recent_candidates| {
|
|
count_recent_rpm_requests_for_provider_key(
|
|
&recent_candidates,
|
|
&context.plan.key_id,
|
|
observed_at_unix_secs,
|
|
) as u32
|
|
});
|
|
|
|
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
|
|
let Some(current_key) = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
|
|
.await
|
|
.ok()
|
|
.and_then(|mut keys| keys.drain(..).next())
|
|
else {
|
|
return;
|
|
};
|
|
if auth_config_fence
|
|
.encrypted_auth_config()
|
|
.is_some_and(|expected| current_key.encrypted_auth_config.as_deref() != Some(expected))
|
|
{
|
|
return;
|
|
}
|
|
let Some(projection) = project_local_adaptive_rate_limit(
|
|
¤t_key,
|
|
effect.classification,
|
|
effect.status_code,
|
|
current_rpm,
|
|
effect.headers,
|
|
observed_at_unix_secs,
|
|
) else {
|
|
return;
|
|
};
|
|
let expected = ProviderCatalogKeyAdaptiveState::from(¤t_key);
|
|
let mut next = expected.clone();
|
|
next.rpm_429_count = Some(projection.rpm_429_count);
|
|
next.learned_rpm_limit = projection.learned_rpm_limit;
|
|
next.last_429_at_unix_secs = Some(projection.last_429_at_unix_secs);
|
|
next.last_429_type = Some(projection.last_429_type);
|
|
next.adjustment_history = projection.adjustment_history;
|
|
next.utilization_samples = projection.utilization_samples;
|
|
next.last_probe_increase_at_unix_secs = projection.last_probe_increase_at_unix_secs;
|
|
next.last_rpm_peak = projection.last_rpm_peak;
|
|
let update = ProviderCatalogKeyAdaptiveStateUpdate {
|
|
key_id: context.plan.key_id.clone(),
|
|
expected_encrypted_auth_config: auth_config_fence
|
|
.encrypted_auth_config()
|
|
.map(ToOwned::to_owned),
|
|
expected,
|
|
next,
|
|
status_snapshot_patch: adaptive_status_snapshot_patch(&projection.status_snapshot),
|
|
updated_at_unix_secs: Some(observed_at_unix_secs),
|
|
};
|
|
provider_key_adaptive_success_persist_gate_reset(&context.plan.key_id);
|
|
match state
|
|
.compare_and_update_provider_catalog_key_adaptive_state(&update)
|
|
.await
|
|
{
|
|
Ok(true) => return,
|
|
Ok(false) => tokio::task::yield_now().await,
|
|
Err(err) => {
|
|
warn!(
|
|
"gateway orchestration effects: failed to persist adaptive rate-limit projection for provider {} endpoint {} key {}: {:?}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
warn!(
|
|
"gateway orchestration effects: adaptive rate-limit CAS retries exhausted for provider {} endpoint {} key {}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
|
|
);
|
|
}
|
|
|
|
fn adaptive_status_snapshot_patch(status_snapshot: &Value) -> Value {
|
|
const OWNED_FIELDS: [&str; 6] = [
|
|
"observation_count",
|
|
"header_observation_count",
|
|
"latest_upstream_limit",
|
|
"learning_confidence",
|
|
"enforcement_active",
|
|
"known_boundary",
|
|
];
|
|
let Some(snapshot) = status_snapshot.as_object() else {
|
|
return serde_json::json!({});
|
|
};
|
|
Value::Object(
|
|
OWNED_FIELDS
|
|
.into_iter()
|
|
.filter_map(|field| {
|
|
snapshot
|
|
.get(field)
|
|
.cloned()
|
|
.map(|value| (field.to_string(), value))
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
async fn record_adaptive_success_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
_effect: LocalAdaptiveSuccessEffect,
|
|
) {
|
|
let Some(auth_config_fence) =
|
|
capture_local_execution_auth_config_fence(state, context.plan).await
|
|
else {
|
|
return;
|
|
};
|
|
let observed_at_unix_secs = current_unix_secs();
|
|
let Some(current_key) = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
|
|
.await
|
|
.ok()
|
|
.and_then(|mut keys| keys.drain(..).next())
|
|
else {
|
|
return;
|
|
};
|
|
if auth_config_fence
|
|
.encrypted_auth_config()
|
|
.is_some_and(|expected| current_key.encrypted_auth_config.as_deref() != Some(expected))
|
|
{
|
|
return;
|
|
}
|
|
if current_key.rpm_limit.is_some()
|
|
|| current_key
|
|
.learned_rpm_limit
|
|
.filter(|value| *value > 0)
|
|
.is_none()
|
|
{
|
|
return;
|
|
}
|
|
let Some(gate_token) = provider_key_adaptive_success_persist_gate_admit(&context.plan.key_id)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
|
|
let _effect_guard = effect_lock.lock().await;
|
|
if !provider_key_adaptive_success_persist_gate_admission_is_current(
|
|
&context.plan.key_id,
|
|
gate_token,
|
|
) {
|
|
return;
|
|
}
|
|
let Some(recent_candidates) = state
|
|
.read_recent_request_candidates(ADAPTIVE_RPM_RECENT_CANDIDATE_LIMIT)
|
|
.await
|
|
.ok()
|
|
else {
|
|
return;
|
|
};
|
|
let current_rpm = count_recent_rpm_requests_for_provider_key(
|
|
&recent_candidates,
|
|
&context.plan.key_id,
|
|
observed_at_unix_secs,
|
|
) as u32;
|
|
|
|
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
|
|
let Some(current_key) = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
|
|
.await
|
|
.ok()
|
|
.and_then(|mut keys| keys.drain(..).next())
|
|
else {
|
|
return;
|
|
};
|
|
if auth_config_fence
|
|
.encrypted_auth_config()
|
|
.is_some_and(|expected| current_key.encrypted_auth_config.as_deref() != Some(expected))
|
|
{
|
|
return;
|
|
}
|
|
if current_key.rpm_limit.is_some()
|
|
|| current_key
|
|
.learned_rpm_limit
|
|
.filter(|value| *value > 0)
|
|
.is_none()
|
|
{
|
|
return;
|
|
}
|
|
let Some(projection) =
|
|
project_local_adaptive_success(¤t_key, current_rpm, observed_at_unix_secs)
|
|
else {
|
|
return;
|
|
};
|
|
let expected = ProviderCatalogKeyAdaptiveState::from(¤t_key);
|
|
let mut next = expected.clone();
|
|
next.learned_rpm_limit = projection.learned_rpm_limit;
|
|
next.adjustment_history = projection.adjustment_history;
|
|
next.utilization_samples = projection.utilization_samples;
|
|
next.last_probe_increase_at_unix_secs = projection.last_probe_increase_at_unix_secs;
|
|
let update = ProviderCatalogKeyAdaptiveStateUpdate {
|
|
key_id: context.plan.key_id.clone(),
|
|
expected_encrypted_auth_config: auth_config_fence
|
|
.encrypted_auth_config()
|
|
.map(ToOwned::to_owned),
|
|
expected,
|
|
next,
|
|
status_snapshot_patch: adaptive_status_snapshot_patch(&projection.status_snapshot),
|
|
updated_at_unix_secs: Some(observed_at_unix_secs),
|
|
};
|
|
match state
|
|
.compare_and_update_provider_catalog_key_adaptive_state(&update)
|
|
.await
|
|
{
|
|
Ok(true) => return,
|
|
Ok(false) => tokio::task::yield_now().await,
|
|
Err(err) => {
|
|
warn!(
|
|
"gateway orchestration effects: failed to persist adaptive success projection for provider {} endpoint {} key {}: {:?}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
warn!(
|
|
"gateway orchestration effects: adaptive success CAS retries exhausted for provider {} endpoint {} key {}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
|
|
);
|
|
}
|
|
|
|
fn provider_key_adaptive_success_persist_gate_admit(key_id: &str) -> Option<u64> {
|
|
if cfg!(test) {
|
|
return Some(0);
|
|
}
|
|
let interval = *ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL;
|
|
if interval.is_zero() {
|
|
return Some(0);
|
|
}
|
|
let token = ADAPTIVE_SUCCESS_PERSIST_GATE_NEXT_TOKEN.fetch_add(1, Ordering::Relaxed);
|
|
ADAPTIVE_SUCCESS_PERSIST_GATE
|
|
.insert_if_absent_fresh(
|
|
provider_key_adaptive_success_persist_gate_key(key_id),
|
|
token,
|
|
interval,
|
|
ADAPTIVE_SUCCESS_PERSIST_GATE_MAX_ENTRIES,
|
|
)
|
|
.then_some(token)
|
|
}
|
|
|
|
fn provider_key_adaptive_success_persist_gate_admission_is_current(
|
|
key_id: &str,
|
|
token: u64,
|
|
) -> bool {
|
|
if cfg!(test) || ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL.is_zero() {
|
|
return true;
|
|
}
|
|
ADAPTIVE_SUCCESS_PERSIST_GATE.get_fresh(
|
|
&provider_key_adaptive_success_persist_gate_key(key_id),
|
|
*ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL,
|
|
) == Some(token)
|
|
}
|
|
|
|
fn provider_key_adaptive_success_persist_gate_reset(key_id: &str) {
|
|
ADAPTIVE_SUCCESS_PERSIST_GATE.remove(&provider_key_adaptive_success_persist_gate_key(key_id));
|
|
}
|
|
|
|
fn provider_key_adaptive_success_persist_gate_key(key_id: &str) -> String {
|
|
format!("adaptive-success:{key_id}")
|
|
}
|
|
|
|
async fn record_health_failure_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
effect: LocalHealthFailureEffect,
|
|
) {
|
|
if !local_candidate_failure_should_apply_key_effects(
|
|
&context.plan.provider_api_format,
|
|
effect.classification,
|
|
effect.status_code,
|
|
) {
|
|
return;
|
|
}
|
|
let api_format = context.plan.provider_api_format.trim();
|
|
if api_format.is_empty() {
|
|
return;
|
|
}
|
|
let Some(auth_config_fence) =
|
|
capture_local_execution_auth_config_fence(state, context.plan).await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
|
|
let _effect_guard = effect_lock.lock().await;
|
|
let is_pool_provider = local_execution_plan_uses_pool(state, context.plan).await;
|
|
let observed_at_unix_secs = current_unix_secs();
|
|
provider_key_health_success_persist_gate_reset(&context.plan.key_id, api_format);
|
|
|
|
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
|
|
let Some(current_key) = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
|
|
.await
|
|
.ok()
|
|
.and_then(|mut keys| keys.drain(..).next())
|
|
else {
|
|
return;
|
|
};
|
|
if auth_config_fence
|
|
.encrypted_auth_config()
|
|
.is_some_and(|expected| current_key.encrypted_auth_config.as_deref() != Some(expected))
|
|
{
|
|
return;
|
|
}
|
|
let Some(health_by_format) = project_local_failure_health(
|
|
current_key.health_by_format.as_ref(),
|
|
api_format,
|
|
effect.classification,
|
|
effect.status_code,
|
|
observed_at_unix_secs,
|
|
) else {
|
|
return;
|
|
};
|
|
let consecutive_failures = health_by_format
|
|
.get(api_format)
|
|
.and_then(|value| value.get("consecutive_failures"))
|
|
.and_then(Value::as_u64)
|
|
.unwrap_or(0);
|
|
let circuit_breaker_by_format = if is_pool_provider {
|
|
None
|
|
} else {
|
|
project_local_key_circuit_failure(
|
|
current_key.circuit_breaker_by_format.as_ref(),
|
|
api_format,
|
|
observed_at_unix_secs,
|
|
consecutive_failures,
|
|
current_key.max_probe_interval_minutes,
|
|
)
|
|
.or_else(|| current_key.circuit_breaker_by_format.clone())
|
|
};
|
|
let update = ProviderCatalogKeyHealthStateUpdate {
|
|
key_id: context.plan.key_id.clone(),
|
|
expected_encrypted_auth_config: auth_config_fence
|
|
.encrypted_auth_config()
|
|
.map(ToOwned::to_owned),
|
|
expected_health_by_format: current_key.health_by_format,
|
|
expected_circuit_breaker_by_format: current_key.circuit_breaker_by_format,
|
|
health_by_format: Some(health_by_format),
|
|
circuit_breaker_by_format,
|
|
};
|
|
match state
|
|
.compare_and_update_provider_catalog_key_health_state(&update)
|
|
.await
|
|
{
|
|
Ok(true) => return,
|
|
Ok(false) => tokio::task::yield_now().await,
|
|
Err(err) => {
|
|
warn!(
|
|
"gateway orchestration effects: failed to persist health failure projection for provider {} endpoint {} key {}: {:?}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
warn!(
|
|
"gateway orchestration effects: health failure CAS retries exhausted for provider {} endpoint {} key {}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
|
|
);
|
|
}
|
|
|
|
async fn record_health_success_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
_effect: LocalHealthSuccessEffect,
|
|
) {
|
|
remember_successful_local_scheduler_affinity(state, context).await;
|
|
|
|
let api_format = context.plan.provider_api_format.trim();
|
|
if api_format.is_empty() {
|
|
return;
|
|
}
|
|
let Some(auth_config_fence) =
|
|
capture_local_execution_auth_config_fence(state, context.plan).await
|
|
else {
|
|
return;
|
|
};
|
|
|
|
// Health updates replace both JSON snapshots in one write. Serialize the success
|
|
// read/project/write with failure and circuit-clear effects for this provider key so a
|
|
// stale success snapshot cannot overwrite a newer failure counter or open circuit.
|
|
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
|
|
let _effect_guard = effect_lock.lock().await;
|
|
|
|
let is_pool_provider = local_execution_plan_uses_pool(state, context.plan).await;
|
|
let mut persist_gate_checked = false;
|
|
|
|
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
|
|
let Some(current_key) = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
|
|
.await
|
|
.ok()
|
|
.and_then(|mut keys| keys.drain(..).next())
|
|
else {
|
|
return;
|
|
};
|
|
if auth_config_fence
|
|
.encrypted_auth_config()
|
|
.is_some_and(|expected| current_key.encrypted_auth_config.as_deref() != Some(expected))
|
|
{
|
|
return;
|
|
}
|
|
let Some(health_by_format) =
|
|
project_local_success_health(current_key.health_by_format.as_ref(), api_format)
|
|
else {
|
|
return;
|
|
};
|
|
let circuit_breaker_update_owned = if is_pool_provider {
|
|
None
|
|
} else {
|
|
current_key
|
|
.circuit_breaker_by_format
|
|
.as_ref()
|
|
.and_then(|current| project_local_key_circuit_closed(Some(current), api_format))
|
|
};
|
|
if current_key.health_by_format.as_ref() == Some(&health_by_format)
|
|
&& ((is_pool_provider && current_key.circuit_breaker_by_format.is_none())
|
|
|| (!is_pool_provider
|
|
&& circuit_breaker_update_owned.as_ref()
|
|
== current_key.circuit_breaker_by_format.as_ref()))
|
|
{
|
|
return;
|
|
}
|
|
if !persist_gate_checked {
|
|
if !provider_key_health_success_persist_gate_allows(
|
|
&context.plan.key_id,
|
|
api_format,
|
|
circuit_breaker_update_owned.is_some(),
|
|
) {
|
|
return;
|
|
}
|
|
persist_gate_checked = true;
|
|
}
|
|
let circuit_breaker_by_format = if is_pool_provider {
|
|
None
|
|
} else {
|
|
circuit_breaker_update_owned.or_else(|| current_key.circuit_breaker_by_format.clone())
|
|
};
|
|
let update = ProviderCatalogKeyHealthStateUpdate {
|
|
key_id: context.plan.key_id.clone(),
|
|
expected_encrypted_auth_config: auth_config_fence
|
|
.encrypted_auth_config()
|
|
.map(ToOwned::to_owned),
|
|
expected_health_by_format: current_key.health_by_format,
|
|
expected_circuit_breaker_by_format: current_key.circuit_breaker_by_format,
|
|
health_by_format: Some(health_by_format),
|
|
circuit_breaker_by_format,
|
|
};
|
|
match state
|
|
.compare_and_update_provider_catalog_key_health_state(&update)
|
|
.await
|
|
{
|
|
Ok(true) => return,
|
|
Ok(false) => tokio::task::yield_now().await,
|
|
Err(err) => {
|
|
warn!(
|
|
"gateway orchestration effects: failed to persist health success projection for provider {} endpoint {} key {}: {:?}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
warn!(
|
|
"gateway orchestration effects: health success CAS retries exhausted for provider {} endpoint {} key {}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
|
|
);
|
|
}
|
|
|
|
fn provider_key_health_success_persist_gate_allows(
|
|
key_id: &str,
|
|
api_format: &str,
|
|
closes_circuit: bool,
|
|
) -> bool {
|
|
if closes_circuit {
|
|
return true;
|
|
}
|
|
let min_interval = *HEALTH_SUCCESS_PERSIST_MIN_INTERVAL;
|
|
if min_interval.is_zero() {
|
|
return true;
|
|
}
|
|
let key = provider_key_health_success_persist_gate_key(key_id, api_format);
|
|
HEALTH_SUCCESS_PERSIST_GATE.insert_if_absent_fresh(
|
|
key,
|
|
(),
|
|
min_interval,
|
|
HEALTH_SUCCESS_PERSIST_GATE_MAX_ENTRIES,
|
|
)
|
|
}
|
|
|
|
fn provider_key_health_success_persist_gate_reset(key_id: &str, api_format: &str) {
|
|
let key = provider_key_health_success_persist_gate_key(key_id, api_format);
|
|
HEALTH_SUCCESS_PERSIST_GATE.remove(&key);
|
|
}
|
|
|
|
fn provider_key_health_success_persist_gate_key(key_id: &str, api_format: &str) -> String {
|
|
format!("success:{key_id}:{api_format}")
|
|
}
|
|
|
|
async fn record_stream_pool_success_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
payload: &GatewayStreamReportRequest,
|
|
) {
|
|
let Some(pool_context) = resolve_pool_feedback_context(state, context).await else {
|
|
return;
|
|
};
|
|
|
|
let usage_outcome =
|
|
build_stream_terminal_usage_outcome(context.plan, context.report_context, payload);
|
|
record_admin_provider_pool_success(
|
|
state.runtime_state.as_ref(),
|
|
&context.plan.provider_id,
|
|
&context.plan.key_id,
|
|
&pool_context.pool_config,
|
|
pool_context.sticky_session_token.as_deref(),
|
|
total_tokens_used(&usage_outcome),
|
|
resolve_ttfb_ms(payload.telemetry.as_ref()),
|
|
)
|
|
.await;
|
|
record_pool_score_schedule_feedback(
|
|
state,
|
|
context,
|
|
Some(true),
|
|
Some(PoolMemberHardState::Available),
|
|
Some(50),
|
|
serde_json::json!({
|
|
"last_request_feedback": {
|
|
"source": "stream_success"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn record_pool_error_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
effect: LocalPoolErrorEffect<'_>,
|
|
) {
|
|
if !local_candidate_failure_should_apply_key_effects(
|
|
&context.plan.provider_api_format,
|
|
effect.classification,
|
|
effect.status_code,
|
|
) {
|
|
return;
|
|
}
|
|
let terminal_error_reason =
|
|
admin_provider_pool_key_terminal_error_reason(effect.status_code, effect.error_body);
|
|
if terminal_error_reason.is_none()
|
|
&& !local_candidate_failure_should_record_pool_error(
|
|
effect.classification,
|
|
effect.status_code,
|
|
)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let Some(pool_context) = resolve_pool_feedback_context(state, context).await else {
|
|
return;
|
|
};
|
|
|
|
clear_pool_key_circuit_breaker(state, context).await;
|
|
if capture_local_execution_auth_config_fence(state, context.plan)
|
|
.await
|
|
.is_none()
|
|
{
|
|
return;
|
|
}
|
|
record_admin_provider_pool_error(
|
|
state.runtime_state.as_ref(),
|
|
&context.plan.provider_id,
|
|
&context.plan.key_id,
|
|
&pool_context.pool_config,
|
|
effect.status_code,
|
|
effect.error_body,
|
|
Some(effect.headers),
|
|
)
|
|
.await;
|
|
record_pool_score_schedule_feedback(
|
|
state,
|
|
context,
|
|
Some(false),
|
|
pool_score_hard_state_for_status(effect.status_code, effect.error_body),
|
|
Some(pool_score_delta_for_status(effect.status_code)),
|
|
serde_json::json!({
|
|
"last_request_feedback": {
|
|
"source": "pool_error",
|
|
"status_code": effect.status_code,
|
|
"classification": format!("{:?}", effect.classification)
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn clear_pool_key_circuit_breaker(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
) {
|
|
let Some(auth_config_fence) =
|
|
capture_local_execution_auth_config_fence(state, context.plan).await
|
|
else {
|
|
return;
|
|
};
|
|
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
|
|
let _effect_guard = effect_lock.lock().await;
|
|
|
|
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
|
|
let Some(current_key) = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
|
|
.await
|
|
.ok()
|
|
.and_then(|mut keys| keys.drain(..).next())
|
|
else {
|
|
return;
|
|
};
|
|
if auth_config_fence
|
|
.encrypted_auth_config()
|
|
.is_some_and(|expected| current_key.encrypted_auth_config.as_deref() != Some(expected))
|
|
{
|
|
return;
|
|
}
|
|
if current_key.circuit_breaker_by_format.is_none() {
|
|
return;
|
|
}
|
|
let update = ProviderCatalogKeyHealthStateUpdate {
|
|
key_id: context.plan.key_id.clone(),
|
|
expected_encrypted_auth_config: auth_config_fence
|
|
.encrypted_auth_config()
|
|
.map(ToOwned::to_owned),
|
|
expected_health_by_format: current_key.health_by_format.clone(),
|
|
expected_circuit_breaker_by_format: current_key.circuit_breaker_by_format,
|
|
health_by_format: current_key.health_by_format,
|
|
circuit_breaker_by_format: None,
|
|
};
|
|
match state
|
|
.compare_and_update_provider_catalog_key_health_state(&update)
|
|
.await
|
|
{
|
|
Ok(true) => return,
|
|
Ok(false) => tokio::task::yield_now().await,
|
|
Err(err) => {
|
|
warn!(
|
|
"gateway orchestration effects: failed to clear pool key circuit for provider {} endpoint {} key {}: {:?}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
warn!(
|
|
"gateway orchestration effects: clear pool key circuit CAS retries exhausted for provider {} endpoint {} key {}",
|
|
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
|
|
);
|
|
}
|
|
|
|
async fn record_oauth_invalidation_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
effect: LocalOAuthInvalidationEffect<'_>,
|
|
) {
|
|
if effect.status_code < 400 {
|
|
return;
|
|
}
|
|
|
|
let plan = context.plan;
|
|
// Agent assertions are long-lived credential requests whose task can rotate
|
|
// while the response is in flight. Runtime 401/403 handling must not project
|
|
// that response onto whichever credential generation is stored later.
|
|
if execution_plan_uses_codex_agent_identity(plan) {
|
|
return;
|
|
}
|
|
let transport = match state
|
|
.read_provider_transport_snapshot(&plan.provider_id, &plan.endpoint_id, &plan.key_id)
|
|
.await
|
|
{
|
|
Ok(Some(transport)) => transport,
|
|
Ok(None) => return,
|
|
Err(err) => {
|
|
warn!(
|
|
"gateway orchestration effects: failed to read transport snapshot for oauth invalidation provider {} endpoint {} key {}: {:?}",
|
|
plan.provider_id, plan.endpoint_id, plan.key_id, err
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
// The inverse replacement is equally unsafe: a response sent with an old
|
|
// bearer token must not invalidate a newly installed Agent Identity.
|
|
if crate::provider_transport::is_codex_agent_identity_transport(&transport) {
|
|
return;
|
|
}
|
|
if !transport.key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
|
return;
|
|
}
|
|
if !execution_plan_bearer_matches_transport(plan, &transport) {
|
|
return;
|
|
}
|
|
|
|
let Some(invalid_reason) = resolve_local_oauth_invalid_reason(
|
|
transport.provider.provider_type.as_str(),
|
|
effect.status_code,
|
|
effect.response_text,
|
|
) else {
|
|
return;
|
|
};
|
|
let request_started_at_unix_ms = report_context_u64_field(
|
|
context.report_context,
|
|
"provider_request_started_at_unix_ms",
|
|
);
|
|
let request_order_id =
|
|
report_context_string_field(context.report_context, "provider_request_order_id");
|
|
let observed_credential_generation =
|
|
report_context_string_field(context.report_context, "codex_credential_generation");
|
|
|
|
if let Err(err) = state
|
|
.mark_provider_transport_oauth_invalid_fenced(
|
|
&transport,
|
|
invalid_reason.as_str(),
|
|
request_started_at_unix_ms,
|
|
request_order_id,
|
|
observed_credential_generation,
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
"gateway orchestration effects: failed to persist oauth invalidation for provider {} endpoint {} key {}: {:?}",
|
|
plan.provider_id, plan.endpoint_id, plan.key_id, err
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn record_oauth_success_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
effect: LocalOAuthSuccessEffect<'_>,
|
|
) {
|
|
let Some(effect) =
|
|
owned_local_oauth_success_effect(context.plan, context.report_context, effect)
|
|
else {
|
|
return;
|
|
};
|
|
record_oauth_success_effect_owned(state, effect).await;
|
|
}
|
|
|
|
async fn record_oauth_success_effect_owned(state: &AppState, effect: OwnedLocalOAuthSuccessEffect) {
|
|
if !(200..300).contains(&effect.status_code) {
|
|
return;
|
|
}
|
|
let transport = match state
|
|
.read_provider_transport_snapshot(&effect.provider_id, &effect.endpoint_id, &effect.key_id)
|
|
.await
|
|
{
|
|
Ok(Some(transport)) => transport,
|
|
Ok(None) => return,
|
|
Err(err) => {
|
|
warn!(
|
|
"gateway orchestration effects: failed to read transport snapshot for oauth success provider {} endpoint {} key {}: {:?}",
|
|
effect.provider_id, effect.endpoint_id, effect.key_id, err
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
if !transport
|
|
.provider
|
|
.provider_type
|
|
.trim()
|
|
.eq_ignore_ascii_case("codex")
|
|
|| crate::provider_transport::is_codex_agent_identity_transport(&transport)
|
|
|| !transport.key.auth_type.trim().eq_ignore_ascii_case("oauth")
|
|
|| crate::provider_transport::resolve_local_generic_oauth_transport_authorization(
|
|
&transport,
|
|
)
|
|
.as_deref()
|
|
.and_then(bearer_access_token)
|
|
!= bearer_access_token(effect.authorization.as_str())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if let Err(err) = state
|
|
.mark_provider_transport_oauth_success_fenced(
|
|
&transport,
|
|
Some(effect.request_started_at_unix_ms),
|
|
Some(effect.request_order_id.as_str()),
|
|
effect.observed_credential_generation.as_deref(),
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
"gateway orchestration effects: failed to persist oauth success for provider {} endpoint {} key {}: {:?}",
|
|
effect.provider_id, effect.endpoint_id, effect.key_id, err
|
|
);
|
|
}
|
|
}
|
|
|
|
fn execution_plan_uses_codex_agent_identity(plan: &ExecutionPlan) -> bool {
|
|
execution_plan_authorization(plan)
|
|
.is_some_and(crate::provider_transport::is_codex_agent_identity_authorization)
|
|
}
|
|
|
|
fn execution_plan_authorization(plan: &ExecutionPlan) -> Option<&str> {
|
|
plan.headers
|
|
.iter()
|
|
.find(|(name, _)| name.eq_ignore_ascii_case("authorization"))
|
|
.map(|(_, value)| value.as_str())
|
|
}
|
|
|
|
fn execution_plan_bearer_matches_transport(
|
|
plan: &ExecutionPlan,
|
|
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
|
) -> bool {
|
|
let Some(plan_token) = execution_plan_authorization(plan).and_then(bearer_access_token) else {
|
|
return false;
|
|
};
|
|
crate::provider_transport::resolve_local_generic_oauth_transport_authorization(transport)
|
|
.as_deref()
|
|
.and_then(bearer_access_token)
|
|
.is_some_and(|current_token| current_token == plan_token)
|
|
}
|
|
|
|
fn bearer_access_token(authorization: &str) -> Option<&str> {
|
|
let mut parts = authorization.split_ascii_whitespace();
|
|
let scheme = parts.next()?;
|
|
let token = parts.next()?;
|
|
(scheme.eq_ignore_ascii_case("bearer") && parts.next().is_none()).then_some(token)
|
|
}
|
|
|
|
fn resolve_local_oauth_invalid_reason(
|
|
provider_type: &str,
|
|
status_code: u16,
|
|
response_text: Option<&str>,
|
|
) -> Option<String> {
|
|
let upstream_message = local_failover_error_message(response_text);
|
|
match provider_type.trim().to_ascii_lowercase().as_str() {
|
|
"codex" => admin_provider_quota_pure::codex_runtime_invalid_reason(
|
|
status_code,
|
|
upstream_message.as_deref(),
|
|
),
|
|
_ if super::oauth_status_may_be_invalid(status_code, response_text) => Some(format!(
|
|
"[OAUTH_EXPIRED] {}",
|
|
upstream_message
|
|
.as_deref()
|
|
.unwrap_or("OAuth access token was rejected")
|
|
)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn local_candidate_failure_should_invalidate_affinity(
|
|
classification: LocalFailoverClassification,
|
|
status_code: u16,
|
|
) -> bool {
|
|
if status_code < 400 {
|
|
return false;
|
|
}
|
|
|
|
match classification {
|
|
LocalFailoverClassification::RetrySuccessPattern
|
|
| LocalFailoverClassification::RetryStatusCode
|
|
| LocalFailoverClassification::RetryUpstreamFailure => true,
|
|
LocalFailoverClassification::UseDefault | LocalFailoverClassification::StopStatusCode => {
|
|
status_code >= 500
|
|
}
|
|
LocalFailoverClassification::StopErrorPattern
|
|
| LocalFailoverClassification::StopExecutionError
|
|
| LocalFailoverClassification::StopCyberPolicy => false,
|
|
}
|
|
}
|
|
|
|
fn local_candidate_failure_should_invalidate_affinity_for_provider(
|
|
provider_api_format: &str,
|
|
classification: LocalFailoverClassification,
|
|
status_code: u16,
|
|
) -> bool {
|
|
if !local_candidate_failure_should_invalidate_affinity(classification, status_code) {
|
|
return false;
|
|
}
|
|
if !provider_api_format
|
|
.trim()
|
|
.eq_ignore_ascii_case("claude:messages")
|
|
{
|
|
return true;
|
|
}
|
|
|
|
let disposition =
|
|
classify_failure_disposition(provider_api_format, classification, status_code);
|
|
!(disposition.retry_action == crate::orchestration::FailureRetryAction::Stop
|
|
&& disposition.failure_scope == FailureScope::None)
|
|
}
|
|
|
|
fn local_candidate_failure_should_apply_key_effects(
|
|
provider_api_format: &str,
|
|
classification: LocalFailoverClassification,
|
|
status_code: u16,
|
|
) -> bool {
|
|
if !provider_api_format
|
|
.trim()
|
|
.eq_ignore_ascii_case("claude:messages")
|
|
{
|
|
return true;
|
|
}
|
|
|
|
matches!(
|
|
classify_failure_disposition(provider_api_format, classification, status_code)
|
|
.failure_scope,
|
|
FailureScope::Credential
|
|
)
|
|
}
|
|
|
|
fn local_candidate_failure_should_record_pool_error(
|
|
classification: LocalFailoverClassification,
|
|
status_code: u16,
|
|
) -> bool {
|
|
if status_code == 400 {
|
|
return false;
|
|
}
|
|
|
|
local_candidate_failure_should_invalidate_affinity(classification, status_code)
|
|
}
|
|
|
|
async fn record_pool_stream_timeout_effect(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
) {
|
|
let Some(pool_context) = resolve_pool_feedback_context(state, context).await else {
|
|
return;
|
|
};
|
|
|
|
record_admin_provider_pool_stream_timeout(
|
|
state.runtime_state.as_ref(),
|
|
&context.plan.provider_id,
|
|
&context.plan.key_id,
|
|
&pool_context.pool_config,
|
|
)
|
|
.await;
|
|
record_pool_score_schedule_feedback(
|
|
state,
|
|
context,
|
|
Some(false),
|
|
Some(PoolMemberHardState::Cooldown),
|
|
Some(-250),
|
|
serde_json::json!({
|
|
"last_request_feedback": {
|
|
"source": "stream_timeout"
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn record_pool_score_schedule_feedback(
|
|
state: &AppState,
|
|
context: LocalExecutionEffectContext<'_>,
|
|
succeeded: Option<bool>,
|
|
hard_state: Option<PoolMemberHardState>,
|
|
score_delta: Option<i32>,
|
|
score_reason_patch: Value,
|
|
) {
|
|
if context.plan.provider_id.trim().is_empty() || context.plan.key_id.trim().is_empty() {
|
|
return;
|
|
}
|
|
if capture_local_execution_auth_config_fence(state, context.plan)
|
|
.await
|
|
.is_none()
|
|
{
|
|
return;
|
|
}
|
|
if !pool_score_feedback_gate_allows(context.plan, succeeded, hard_state, score_delta) {
|
|
return;
|
|
}
|
|
let feedback = PoolMemberScheduleFeedback {
|
|
identity: PoolMemberIdentity::provider_api_key(
|
|
context.plan.provider_id.clone(),
|
|
context.plan.key_id.clone(),
|
|
),
|
|
scope: None,
|
|
scheduled_at: current_unix_secs(),
|
|
succeeded,
|
|
hard_state,
|
|
score_delta,
|
|
score_reason_patch: Some(score_reason_patch),
|
|
};
|
|
if let Err(err) = state
|
|
.data
|
|
.record_pool_member_schedule_feedback(feedback)
|
|
.await
|
|
{
|
|
warn!(
|
|
provider_id = %context.plan.provider_id,
|
|
key_id = %context.plan.key_id,
|
|
error = ?err,
|
|
"gateway orchestration effects: failed to record pool score schedule feedback"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn pool_score_feedback_interval_from_env(key: &str, default_secs: u64) -> Duration {
|
|
let secs = std::env::var(key)
|
|
.ok()
|
|
.and_then(|value| value.trim().parse::<u64>().ok())
|
|
.unwrap_or(default_secs)
|
|
.min(MAX_POOL_SCORE_FEEDBACK_MIN_INTERVAL_SECS);
|
|
Duration::from_secs(secs)
|
|
}
|
|
|
|
fn pool_score_feedback_min_interval(succeeded: Option<bool>) -> Duration {
|
|
match succeeded {
|
|
Some(false) => *POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL,
|
|
_ => *POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL,
|
|
}
|
|
}
|
|
|
|
fn pool_score_feedback_gate_key(
|
|
plan: &ExecutionPlan,
|
|
succeeded: Option<bool>,
|
|
hard_state: Option<PoolMemberHardState>,
|
|
score_delta: Option<i32>,
|
|
) -> String {
|
|
let succeeded = match succeeded {
|
|
Some(true) => "success",
|
|
Some(false) => "failure",
|
|
None => "neutral",
|
|
};
|
|
let hard_state = hard_state
|
|
.map(PoolMemberHardState::as_database)
|
|
.unwrap_or("none");
|
|
format!(
|
|
"provider:{}:key:{}:result:{}:state:{}:delta:{}",
|
|
plan.provider_id,
|
|
plan.key_id,
|
|
succeeded,
|
|
hard_state,
|
|
score_delta.unwrap_or_default()
|
|
)
|
|
}
|
|
|
|
fn pool_score_feedback_gate_allows(
|
|
plan: &ExecutionPlan,
|
|
succeeded: Option<bool>,
|
|
hard_state: Option<PoolMemberHardState>,
|
|
score_delta: Option<i32>,
|
|
) -> bool {
|
|
let min_interval = pool_score_feedback_min_interval(succeeded);
|
|
if min_interval.is_zero() {
|
|
return true;
|
|
}
|
|
let key = pool_score_feedback_gate_key(plan, succeeded, hard_state, score_delta);
|
|
if POOL_SCORE_FEEDBACK_GATE.contains_fresh(&key, min_interval) {
|
|
return false;
|
|
}
|
|
POOL_SCORE_FEEDBACK_GATE.insert(key, (), min_interval, POOL_SCORE_FEEDBACK_GATE_MAX_ENTRIES);
|
|
true
|
|
}
|
|
|
|
fn pool_score_hard_state_for_status(
|
|
status_code: u16,
|
|
error_body: Option<&str>,
|
|
) -> Option<PoolMemberHardState> {
|
|
if let Some(reason) = admin_provider_pool_key_terminal_error_reason(status_code, error_body) {
|
|
return Some(pool_score_hard_state_for_terminal_error_reason(&reason));
|
|
}
|
|
|
|
// A number of providers report account quota exhaustion as HTTP 429 rather
|
|
// than 402. Keep those members out of the score-based pool fallback until
|
|
// the provider's quota probe observes a reset; treating every 429 as a
|
|
// generic cooldown otherwise lets the member re-enter as soon as the short
|
|
// transient cooldown expires.
|
|
if status_code == 429 && error_body_indicates_quota_exhaustion(error_body) {
|
|
return Some(PoolMemberHardState::QuotaExhausted);
|
|
}
|
|
|
|
match status_code {
|
|
401 | 403 => Some(PoolMemberHardState::AuthInvalid),
|
|
402 => Some(PoolMemberHardState::QuotaExhausted),
|
|
429 | 500..=599 => Some(PoolMemberHardState::Cooldown),
|
|
_ => {
|
|
let body = error_body.unwrap_or_default().to_ascii_lowercase();
|
|
if body.contains("quota") && body.contains("exceed") {
|
|
Some(PoolMemberHardState::QuotaExhausted)
|
|
} else if body.contains("invalid") && body.contains("token") {
|
|
Some(PoolMemberHardState::AuthInvalid)
|
|
} else if body.contains("banned")
|
|
|| body.contains("suspended")
|
|
|| body.contains("blocked")
|
|
{
|
|
Some(PoolMemberHardState::Banned)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn error_body_indicates_quota_exhaustion(error_body: Option<&str>) -> bool {
|
|
let body = error_body.unwrap_or_default().to_ascii_lowercase();
|
|
[
|
|
"quota exhausted",
|
|
"quota_exhausted",
|
|
"quota exceeded",
|
|
"quota_exceeded",
|
|
"insufficient_quota",
|
|
"resource exhausted",
|
|
"resource has been exhausted",
|
|
"resource_exhausted",
|
|
"usage_limit_reached",
|
|
"limit_reached",
|
|
"quota limit reached",
|
|
"credits exhausted",
|
|
"insufficient credits",
|
|
]
|
|
.iter()
|
|
.any(|marker| body.contains(marker))
|
|
}
|
|
|
|
fn pool_score_hard_state_for_terminal_error_reason(reason: &str) -> PoolMemberHardState {
|
|
if reason.starts_with("payment_required_") {
|
|
PoolMemberHardState::QuotaExhausted
|
|
} else if reason.starts_with("forbidden_") {
|
|
PoolMemberHardState::AuthInvalid
|
|
} else {
|
|
PoolMemberHardState::Banned
|
|
}
|
|
}
|
|
|
|
fn pool_score_delta_for_status(status_code: u16) -> i32 {
|
|
match status_code {
|
|
401 | 403 => -2_000,
|
|
402 => -1_000,
|
|
429 => -500,
|
|
500..=599 => -300,
|
|
_ => -100,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
use aether_contracts::{ExecutionPlan, RequestBody};
|
|
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
|
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
|
use aether_data_contracts::repository::candidates::{
|
|
RequestCandidateStatus, StoredRequestCandidate,
|
|
};
|
|
use aether_data_contracts::repository::pool_scores::PoolMemberHardState;
|
|
use aether_data_contracts::repository::provider_catalog::{
|
|
ProviderCatalogKeyAdaptiveState, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
|
StoredProviderCatalogProvider,
|
|
};
|
|
use aether_test_support::ManagedRedisServer;
|
|
use aether_usage_runtime::GatewaySyncReportRequest;
|
|
use serde_json::{json, Value};
|
|
|
|
use super::{
|
|
apply_local_execution_effect, apply_local_stream_failure_effects,
|
|
apply_local_stream_success_effects, execution_plan_bearer_matches_transport,
|
|
local_candidate_failure_should_apply_key_effects,
|
|
local_candidate_failure_should_record_pool_error, pool_score_feedback_gate_allows,
|
|
pool_score_hard_state_for_status, resolve_pool_feedback_context,
|
|
LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect, LocalAttemptFailureEffect,
|
|
LocalExecutionEffect, LocalExecutionEffectContext, LocalHealthFailureEffect,
|
|
LocalHealthSuccessEffect, LocalOAuthInvalidationEffect, LocalOAuthSuccessEffect,
|
|
LocalPoolErrorEffect, LocalStreamFailureEffect, ProviderKeyEffectLockPool,
|
|
};
|
|
use crate::data::{GatewayDataConfig, GatewayDataState};
|
|
use crate::orchestration::{
|
|
apply_local_report_effect, LocalFailoverClassification, LocalReportEffect,
|
|
};
|
|
use crate::scheduler::affinity::SCHEDULER_AFFINITY_TTL;
|
|
use crate::usage::GatewayStreamReportRequest;
|
|
use crate::AppState;
|
|
use aether_scheduler_core::{
|
|
build_scheduler_affinity_cache_key_for_api_key_id,
|
|
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session,
|
|
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope,
|
|
ClientSessionAffinity, SchedulerAffinityScope, SchedulerAffinityTarget,
|
|
};
|
|
|
|
async fn start_managed_redis_or_skip() -> Option<ManagedRedisServer> {
|
|
match ManagedRedisServer::start().await {
|
|
Ok(server) => Some(server),
|
|
Err(err) if err.to_string().contains("No such file or directory") => {
|
|
eprintln!("skipping redis-backed orchestration effect test: {err}");
|
|
None
|
|
}
|
|
Err(err) => panic!("redis server should start: {err}"),
|
|
}
|
|
}
|
|
|
|
fn sample_plan() -> ExecutionPlan {
|
|
ExecutionPlan {
|
|
request_id: "req-1".to_string(),
|
|
candidate_id: Some("cand-1".to_string()),
|
|
provider_name: Some("openai".to_string()),
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
method: "POST".to_string(),
|
|
url: "https://example.com/v1/chat/completions".to_string(),
|
|
headers: BTreeMap::new(),
|
|
content_type: Some("application/json".to_string()),
|
|
content_encoding: None,
|
|
body: RequestBody::from_json(json!({"model":"gpt-5"})),
|
|
stream: false,
|
|
client_api_format: "openai:chat".to_string(),
|
|
provider_api_format: "openai:chat".to_string(),
|
|
model_name: Some("gpt-5".to_string()),
|
|
proxy: None,
|
|
transport_profile: None,
|
|
timeouts: None,
|
|
}
|
|
}
|
|
|
|
fn sample_claude_plan() -> ExecutionPlan {
|
|
let mut plan = sample_plan();
|
|
plan.provider_name = Some("anthropic".to_string());
|
|
plan.provider_api_format = "claude:messages".to_string();
|
|
plan
|
|
}
|
|
|
|
fn sample_stream_report() -> GatewayStreamReportRequest {
|
|
GatewayStreamReportRequest {
|
|
trace_id: "trace-stream-effects".to_string(),
|
|
report_kind: "openai_chat_stream_success".to_string(),
|
|
report_context: None,
|
|
status_code: 200,
|
|
headers: BTreeMap::new(),
|
|
provider_body_base64: None,
|
|
provider_body_state: None,
|
|
client_body_base64: None,
|
|
client_body_state: None,
|
|
terminal_summary: None,
|
|
telemetry: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pool_score_feedback_gate_suppresses_repeated_success_writes() {
|
|
super::POOL_SCORE_FEEDBACK_GATE.clear();
|
|
let plan = sample_plan();
|
|
|
|
assert!(pool_score_feedback_gate_allows(
|
|
&plan,
|
|
Some(true),
|
|
Some(PoolMemberHardState::Available),
|
|
Some(50),
|
|
));
|
|
assert!(!pool_score_feedback_gate_allows(
|
|
&plan,
|
|
Some(true),
|
|
Some(PoolMemberHardState::Available),
|
|
Some(50),
|
|
));
|
|
assert!(pool_score_feedback_gate_allows(
|
|
&plan,
|
|
Some(false),
|
|
Some(PoolMemberHardState::Cooldown),
|
|
Some(-500),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_key_effect_lock_pool_prunes_geometrically_and_keeps_active_locks() {
|
|
let pool = ProviderKeyEffectLockPool::new(4);
|
|
let locks = (0..10)
|
|
.map(|index| pool.lock_for(&format!("key-{index}")))
|
|
.collect::<Vec<_>>();
|
|
|
|
{
|
|
let state = pool.state.lock().expect("effect lock pool should lock");
|
|
assert_eq!(state.entries.len(), 10);
|
|
assert_eq!(state.prune_count, 2);
|
|
assert_eq!(state.next_growth_prune_at, 16);
|
|
}
|
|
|
|
for (index, expected) in locks.iter().enumerate() {
|
|
let existing = pool.lock_for(&format!("key-{index}"));
|
|
assert!(Arc::ptr_eq(expected, &existing));
|
|
}
|
|
|
|
let hot_lock = Arc::clone(&locks[0]);
|
|
drop(locks);
|
|
for _ in 0..32 {
|
|
let existing = pool.lock_for("key-0");
|
|
assert!(Arc::ptr_eq(&hot_lock, &existing));
|
|
}
|
|
|
|
let state = pool.state.lock().expect("effect lock pool should lock");
|
|
assert_eq!(state.entries.len(), 1);
|
|
assert!(state.prune_count >= 3);
|
|
}
|
|
|
|
fn session_affinity() -> ClientSessionAffinity {
|
|
ClientSessionAffinity::new(
|
|
Some("generic".to_string()),
|
|
Some("session=session-1;agent=coder".to_string()),
|
|
)
|
|
}
|
|
|
|
fn session_report_context() -> Value {
|
|
json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
"client_session_affinity": {
|
|
"client_family": "generic",
|
|
"session_key": "session=session-1;agent=coder"
|
|
},
|
|
"original_headers": {
|
|
"x-aether-session-id": "raw-session",
|
|
"x-aether-agent-id": "raw-agent"
|
|
},
|
|
"original_request_body": {
|
|
"model": "gpt-5"
|
|
}
|
|
})
|
|
}
|
|
|
|
fn session_scheduler_affinity_cache_key() -> String {
|
|
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session(
|
|
"api-key-1",
|
|
"openai:chat",
|
|
"gpt-5",
|
|
Some(&session_affinity()),
|
|
)
|
|
.expect("session scheduler affinity cache key should build")
|
|
}
|
|
|
|
fn sample_codex_plan() -> ExecutionPlan {
|
|
ExecutionPlan {
|
|
request_id: "req-codex-1".to_string(),
|
|
candidate_id: Some("cand-codex-1".to_string()),
|
|
provider_name: Some("codex".to_string()),
|
|
provider_id: "provider-codex-cli-local-1".to_string(),
|
|
endpoint_id: "endpoint-codex-cli-local-1".to_string(),
|
|
key_id: "key-codex-cli-local-1".to_string(),
|
|
method: "POST".to_string(),
|
|
url: "https://chatgpt.com/backend-api/codex".to_string(),
|
|
headers: BTreeMap::from([(
|
|
"authorization".to_string(),
|
|
"Bearer codex-access-token".to_string(),
|
|
)]),
|
|
content_type: Some("application/json".to_string()),
|
|
content_encoding: None,
|
|
body: RequestBody::from_json(json!({"model":"gpt-5.4"})),
|
|
stream: false,
|
|
client_api_format: "openai:responses".to_string(),
|
|
provider_api_format: "openai:responses".to_string(),
|
|
model_name: Some("gpt-5.4".to_string()),
|
|
proxy: None,
|
|
transport_profile: None,
|
|
timeouts: None,
|
|
}
|
|
}
|
|
|
|
fn sample_codex_agent_identity_plan() -> ExecutionPlan {
|
|
let mut plan = sample_codex_plan();
|
|
plan.headers.insert(
|
|
"Authorization".to_string(),
|
|
"AgentAssertion in-flight-assertion".to_string(),
|
|
);
|
|
plan
|
|
}
|
|
|
|
fn sample_codex_provider() -> StoredProviderCatalogProvider {
|
|
StoredProviderCatalogProvider::new(
|
|
"provider-codex-cli-local-1".to_string(),
|
|
"codex".to_string(),
|
|
Some("https://chatgpt.com".to_string()),
|
|
"codex".to_string(),
|
|
)
|
|
.expect("provider should build")
|
|
.with_transport_fields(
|
|
true,
|
|
false,
|
|
false,
|
|
None,
|
|
Some(2),
|
|
None,
|
|
Some(20.0),
|
|
None,
|
|
Some(json!({"pool_advanced": {}})),
|
|
)
|
|
}
|
|
|
|
fn sample_codex_provider_with_auto_remove() -> StoredProviderCatalogProvider {
|
|
let mut provider = sample_codex_provider();
|
|
provider.config = Some(json!({
|
|
"pool_advanced": {
|
|
"auto_remove_banned_keys": true
|
|
}
|
|
}));
|
|
provider
|
|
}
|
|
|
|
fn sample_codex_endpoint() -> StoredProviderCatalogEndpoint {
|
|
StoredProviderCatalogEndpoint::new(
|
|
"endpoint-codex-cli-local-1".to_string(),
|
|
"provider-codex-cli-local-1".to_string(),
|
|
"openai:responses".to_string(),
|
|
Some("openai".to_string()),
|
|
Some("cli".to_string()),
|
|
true,
|
|
)
|
|
.expect("endpoint should build")
|
|
.with_transport_fields(
|
|
"https://chatgpt.com/backend-api/codex".to_string(),
|
|
None,
|
|
None,
|
|
Some(2),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("endpoint transport should build")
|
|
}
|
|
|
|
fn sample_codex_key() -> StoredProviderCatalogKey {
|
|
let encrypted_auth_config = encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"rt-codex-local-123"}"#,
|
|
)
|
|
.expect("auth config should encrypt");
|
|
StoredProviderCatalogKey::new(
|
|
"key-codex-cli-local-1".to_string(),
|
|
"provider-codex-cli-local-1".to_string(),
|
|
"oauth".to_string(),
|
|
"oauth".to_string(),
|
|
None,
|
|
true,
|
|
)
|
|
.expect("key should build")
|
|
.with_transport_fields(
|
|
Some(serde_json::json!(["openai:responses"])),
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "codex-access-token")
|
|
.expect("access token should encrypt"),
|
|
Some(encrypted_auth_config),
|
|
None,
|
|
Some(serde_json::json!({"openai:responses": 1})),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("key transport should build")
|
|
}
|
|
|
|
fn sample_codex_agent_identity_key() -> StoredProviderCatalogKey {
|
|
let mut key = sample_codex_key();
|
|
key.name = "Agent Identity".to_string();
|
|
key.encrypted_api_key = Some(
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "__placeholder__")
|
|
.expect("placeholder api key should encrypt"),
|
|
);
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","auth_mode":"agentIdentity","agent_runtime_id":"runtime-current","agent_private_key":"MC4CAQAwBQYDK2VwBCIEIAcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcH","task_id":"task-current"}"#,
|
|
)
|
|
.expect("Agent Identity auth config should encrypt"),
|
|
);
|
|
key
|
|
}
|
|
|
|
fn codex_state() -> AppState {
|
|
codex_state_with_provider(sample_codex_provider())
|
|
}
|
|
|
|
fn codex_state_with_auto_remove() -> AppState {
|
|
codex_state_with_provider(sample_codex_provider_with_auto_remove())
|
|
}
|
|
|
|
fn codex_state_with_provider(provider: StoredProviderCatalogProvider) -> AppState {
|
|
codex_state_with_provider_and_key(provider, sample_codex_key())
|
|
}
|
|
|
|
fn codex_state_with_provider_and_key(
|
|
provider: StoredProviderCatalogProvider,
|
|
key: StoredProviderCatalogKey,
|
|
) -> AppState {
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![sample_codex_endpoint()],
|
|
vec![key],
|
|
));
|
|
AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
}
|
|
|
|
fn claude_code_oauth_state() -> AppState {
|
|
let mut provider = sample_codex_provider();
|
|
provider.name = "claude_code".to_string();
|
|
provider.provider_type = "claude_code".to_string();
|
|
let mut endpoint = sample_codex_endpoint();
|
|
endpoint.api_format = "claude:messages".to_string();
|
|
endpoint.api_family = Some("claude".to_string());
|
|
endpoint.base_url = "https://api.anthropic.com".to_string();
|
|
let mut key = sample_codex_key();
|
|
key.api_formats = Some(json!(["claude:messages"]));
|
|
key.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"claude_code","refresh_token":"rt-claude-local-123"}"#,
|
|
)
|
|
.expect("Claude Code auth config should encrypt"),
|
|
);
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![endpoint],
|
|
vec![key],
|
|
));
|
|
AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
}
|
|
|
|
fn codex_state_with_redis(redis_url: &str, redis_key_prefix: &str) -> AppState {
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_codex_provider()],
|
|
vec![sample_codex_endpoint()],
|
|
vec![sample_codex_key()],
|
|
));
|
|
let data_state = GatewayDataState::from_config(
|
|
GatewayDataConfig::disabled()
|
|
.with_redis_url(redis_url, Some(redis_key_prefix))
|
|
.with_encryption_key(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
.expect("data state should build")
|
|
.attach_provider_catalog_repository_for_tests(repository);
|
|
AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(data_state)
|
|
}
|
|
|
|
fn sample_health_provider() -> StoredProviderCatalogProvider {
|
|
StoredProviderCatalogProvider::new(
|
|
"prov-1".to_string(),
|
|
"openai".to_string(),
|
|
Some("https://example.com".to_string()),
|
|
"custom".to_string(),
|
|
)
|
|
.expect("provider should build")
|
|
}
|
|
|
|
fn sample_pool_health_provider() -> StoredProviderCatalogProvider {
|
|
sample_health_provider().with_transport_fields(
|
|
true,
|
|
false,
|
|
false,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(json!({"pool_advanced": {}})),
|
|
)
|
|
}
|
|
|
|
fn sample_health_endpoint() -> StoredProviderCatalogEndpoint {
|
|
StoredProviderCatalogEndpoint::new(
|
|
"ep-1".to_string(),
|
|
"prov-1".to_string(),
|
|
"openai:chat".to_string(),
|
|
Some("openai".to_string()),
|
|
Some("chat".to_string()),
|
|
true,
|
|
)
|
|
.expect("endpoint should build")
|
|
.with_transport_fields(
|
|
"https://example.com/v1/chat/completions".to_string(),
|
|
None,
|
|
None,
|
|
Some(2),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("endpoint transport should build")
|
|
}
|
|
|
|
fn sample_health_key() -> StoredProviderCatalogKey {
|
|
StoredProviderCatalogKey::new(
|
|
"key-1".to_string(),
|
|
"prov-1".to_string(),
|
|
"prod".to_string(),
|
|
"api_key".to_string(),
|
|
None,
|
|
true,
|
|
)
|
|
.expect("key should build")
|
|
.with_transport_fields(
|
|
Some(serde_json::json!(["openai:chat"])),
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-test")
|
|
.expect("api key should encrypt"),
|
|
None,
|
|
None,
|
|
Some(serde_json::json!({"openai:chat": 1})),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("key transport should build")
|
|
}
|
|
|
|
fn health_state() -> AppState {
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_health_provider()],
|
|
vec![sample_health_endpoint()],
|
|
vec![sample_health_key()],
|
|
));
|
|
AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
}
|
|
|
|
fn pool_health_state() -> AppState {
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_pool_health_provider()],
|
|
vec![sample_health_endpoint()],
|
|
vec![sample_health_key()],
|
|
));
|
|
AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pool_feedback_uses_routing_profile_scheduling_override() {
|
|
let mut provider = sample_pool_health_provider();
|
|
provider.config = Some(json!({
|
|
"pool_advanced": {
|
|
"scheduling_presets": [{
|
|
"preset": "lru",
|
|
"enabled": true
|
|
}]
|
|
}
|
|
}));
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![provider],
|
|
vec![sample_health_endpoint()],
|
|
vec![sample_health_key()],
|
|
));
|
|
let state = AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
);
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"routing_pool_policy_override": {
|
|
"scheduling_presets": [{
|
|
"preset": "cache_affinity",
|
|
"enabled": true
|
|
}]
|
|
}
|
|
});
|
|
|
|
let feedback = resolve_pool_feedback_context(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
)
|
|
.await
|
|
.expect("pool feedback context should resolve");
|
|
|
|
assert!(
|
|
crate::handlers::shared::provider_pool::admin_provider_pool_cache_affinity_enabled(
|
|
&feedback.pool_config
|
|
)
|
|
);
|
|
assert!(!feedback.pool_config.lru_enabled);
|
|
assert_eq!(feedback.pool_config.scheduling_presets.len(), 1);
|
|
assert_eq!(
|
|
feedback.pool_config.scheduling_presets[0].preset,
|
|
"cache_affinity"
|
|
);
|
|
}
|
|
|
|
fn health_state_with_key(key: StoredProviderCatalogKey) -> AppState {
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_health_provider()],
|
|
vec![sample_health_endpoint()],
|
|
vec![key],
|
|
));
|
|
AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
}
|
|
|
|
fn sample_adaptive_key() -> StoredProviderCatalogKey {
|
|
let mut key = sample_health_key();
|
|
key.name = "adaptive".to_string();
|
|
key.rpm_limit = None;
|
|
key.learned_rpm_limit = Some(12);
|
|
key.rpm_429_count = Some(1);
|
|
key
|
|
}
|
|
|
|
fn adaptive_state() -> AppState {
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_health_provider()],
|
|
vec![sample_health_endpoint()],
|
|
vec![sample_adaptive_key()],
|
|
));
|
|
AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
}
|
|
|
|
fn adaptive_state_with_request_candidates(
|
|
key: StoredProviderCatalogKey,
|
|
request_candidates: Vec<StoredRequestCandidate>,
|
|
) -> AppState {
|
|
let provider_catalog = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_health_provider()],
|
|
vec![sample_health_endpoint()],
|
|
vec![key],
|
|
));
|
|
let request_candidates =
|
|
Arc::new(InMemoryRequestCandidateRepository::seed(request_candidates));
|
|
AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(provider_catalog)
|
|
.with_request_candidate_reader(request_candidates)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
}
|
|
|
|
fn fixed_limit_state() -> AppState {
|
|
let mut key = sample_health_key();
|
|
key.rpm_limit = Some(24);
|
|
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_health_provider()],
|
|
vec![sample_health_endpoint()],
|
|
vec![key],
|
|
));
|
|
AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn attempt_failure_invalidates_scheduler_affinity_cache() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
},
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
assert!(state
|
|
.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_some());
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code: 429,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert!(state
|
|
.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn attempt_failure_invalidates_session_scoped_scheduler_affinity_cache() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = session_report_context();
|
|
let session_cache_key = session_scheduler_affinity_cache_key();
|
|
let legacy_cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("legacy scheduler affinity cache key should build");
|
|
|
|
for cache_key in [&session_cache_key, &legacy_cache_key] {
|
|
state.remember_scheduler_affinity_target(
|
|
cache_key.as_str(),
|
|
SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
},
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
}
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code: 429,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert!(state
|
|
.read_scheduler_affinity_target(session_cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_none());
|
|
assert!(state
|
|
.read_scheduler_affinity_target(legacy_cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn attempt_failure_keeps_scheduler_affinity_for_non_affinity_candidate() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
let affinity_target = SchedulerAffinityTarget {
|
|
provider_id: "prov-2".to_string(),
|
|
endpoint_id: "ep-2".to_string(),
|
|
key_id: "key-2".to_string(),
|
|
};
|
|
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
affinity_target.clone(),
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code: 524,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(affinity_target)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn attempt_failure_keeps_scheduler_affinity_for_non_pool_sibling_key() {
|
|
let state = health_state();
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
let affinity_target = SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-2".to_string(),
|
|
};
|
|
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
affinity_target.clone(),
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code: 524,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(affinity_target)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn attempt_failure_invalidates_scheduler_affinity_for_same_pool_candidate() {
|
|
let state = pool_health_state();
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-2".to_string(),
|
|
},
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code: 524,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn attempt_failure_keeps_scheduler_affinity_for_non_failure_status() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
},
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code: 200,
|
|
classification: LocalFailoverClassification::UseDefault,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert!(state
|
|
.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stream_success_effect_helper_projects_health_and_scheduler_affinity() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
let payload = sample_stream_report();
|
|
|
|
apply_local_stream_success_effects(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
&payload,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stream_failure_effect_helper_returns_analysis_and_projects_health() {
|
|
let state = health_state();
|
|
let plan = sample_plan();
|
|
let headers = BTreeMap::new();
|
|
let analysis = apply_local_stream_failure_effects(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalStreamFailureEffect::new(503, &headers, Some("upstream unavailable"))
|
|
.with_stream_timeout(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
analysis.classification,
|
|
LocalFailoverClassification::UseDefault
|
|
);
|
|
assert_eq!(analysis.decision.as_str(), "use_default");
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(
|
|
stored_key
|
|
.health_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:chat"))
|
|
.and_then(|value| value.get("consecutive_failures"))
|
|
.and_then(Value::as_u64),
|
|
Some(1)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn configured_stop_pattern_keeps_scheduler_affinity_cache() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
},
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code: 400,
|
|
classification: LocalFailoverClassification::StopErrorPattern,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
assert!(state
|
|
.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn success_remembers_scheduler_affinity_cache_for_final_candidate() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn routing_profile_cache_affinity_overrides_legacy_fixed_mode_on_success() {
|
|
let state = AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::disabled().with_system_config_values_for_tests(vec![(
|
|
"scheduling_mode".to_string(),
|
|
json!("fixed_order"),
|
|
)]),
|
|
);
|
|
let plan = sample_plan();
|
|
let affinity = session_affinity();
|
|
let scope = SchedulerAffinityScope::new("routing-group-1", Some(7));
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
"client_session_affinity": {
|
|
"client_family": "generic",
|
|
"session_key": "session=session-1;agent=coder"
|
|
},
|
|
"scheduler_affinity_policy": {
|
|
"scheduling_mode": "cache_affinity",
|
|
"scope": {
|
|
"routing_group_id": "routing-group-1",
|
|
"routing_group_version": 7
|
|
}
|
|
}
|
|
});
|
|
let scoped_cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope(
|
|
"api-key-1",
|
|
"openai:chat",
|
|
"gpt-5",
|
|
Some(&affinity),
|
|
Some(&scope),
|
|
)
|
|
.expect("scoped scheduler affinity cache key should build");
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(scoped_cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
})
|
|
);
|
|
assert!(state
|
|
.read_scheduler_affinity_target(
|
|
session_scheduler_affinity_cache_key().as_str(),
|
|
SCHEDULER_AFFINITY_TTL
|
|
)
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn routing_profile_fixed_mode_overrides_legacy_cache_affinity_on_success() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
"scheduler_affinity_policy": {
|
|
"scheduling_mode": "fixed_order",
|
|
"scope": {
|
|
"routing_group_id": "routing-group-1",
|
|
"routing_group_version": 7
|
|
}
|
|
}
|
|
});
|
|
let scope = SchedulerAffinityScope::new("routing-group-1", Some(7));
|
|
let scoped_cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session_and_scope(
|
|
"api-key-1",
|
|
"openai:chat",
|
|
"gpt-5",
|
|
None,
|
|
Some(&scope),
|
|
)
|
|
.expect("scoped scheduler affinity cache key should build");
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
assert!(state
|
|
.read_scheduler_affinity_target(scoped_cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn malformed_routing_affinity_context_does_not_fall_back_to_legacy_mode() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
"scheduler_affinity_policy": {
|
|
"scheduling_mode": "unknown"
|
|
}
|
|
});
|
|
let legacy_cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("legacy scheduler affinity cache key should build");
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
assert!(state
|
|
.read_scheduler_affinity_target(legacy_cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_success_keeps_scheduler_affinity_after_health_state_update() {
|
|
let state = health_state();
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_balance_success_does_not_remember_scheduler_affinity_cache() {
|
|
let state = AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::disabled().with_system_config_values_for_tests(vec![(
|
|
"scheduling_mode".to_string(),
|
|
json!("load_balance"),
|
|
)]),
|
|
);
|
|
let plan = sample_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
assert!(state
|
|
.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn success_remembers_session_scoped_scheduler_affinity_cache() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let plan = sample_plan();
|
|
let report_context = session_report_context();
|
|
let session_cache_key = session_scheduler_affinity_cache_key();
|
|
let legacy_cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("legacy scheduler affinity cache key should build");
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
state
|
|
.read_scheduler_affinity_target(session_cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
})
|
|
);
|
|
assert!(state
|
|
.read_scheduler_affinity_target(legacy_cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fallback_success_rewarms_scheduler_affinity_after_failed_candidate_invalidates() {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let failed_plan = sample_plan();
|
|
let mut success_plan = sample_plan();
|
|
success_plan.provider_id = "prov-2".to_string();
|
|
success_plan.endpoint_id = "ep-2".to_string();
|
|
success_plan.key_id = "key-2".to_string();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "openai:chat",
|
|
"model": "gpt-5",
|
|
});
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
SchedulerAffinityTarget {
|
|
provider_id: "prov-1".to_string(),
|
|
endpoint_id: "ep-1".to_string(),
|
|
key_id: "key-1".to_string(),
|
|
},
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &failed_plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code: 429,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
assert!(state
|
|
.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL)
|
|
.is_none());
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &success_plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(SchedulerAffinityTarget {
|
|
provider_id: "prov-2".to_string(),
|
|
endpoint_id: "ep-2".to_string(),
|
|
key_id: "key-2".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_stop_pattern_does_not_penalize_pool_feedback() {
|
|
assert!(!local_candidate_failure_should_record_pool_error(
|
|
LocalFailoverClassification::StopErrorPattern,
|
|
400,
|
|
));
|
|
assert!(!local_candidate_failure_should_record_pool_error(
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
400,
|
|
));
|
|
assert!(local_candidate_failure_should_record_pool_error(
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
429,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn anthropic_non_credential_failures_do_not_apply_key_wide_effects() {
|
|
assert!(!local_candidate_failure_should_apply_key_effects(
|
|
"claude:messages",
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
529,
|
|
));
|
|
assert!(!local_candidate_failure_should_apply_key_effects(
|
|
"claude:messages",
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
429,
|
|
));
|
|
assert!(!local_candidate_failure_should_apply_key_effects(
|
|
"claude:messages",
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
503,
|
|
));
|
|
assert!(local_candidate_failure_should_apply_key_effects(
|
|
"claude:messages",
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
401,
|
|
));
|
|
assert!(local_candidate_failure_should_apply_key_effects(
|
|
"claude:messages",
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
403,
|
|
));
|
|
assert!(!local_candidate_failure_should_apply_key_effects(
|
|
"claude:messages",
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
400,
|
|
));
|
|
assert!(local_candidate_failure_should_apply_key_effects(
|
|
"openai:chat",
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
529,
|
|
));
|
|
assert!(local_candidate_failure_should_apply_key_effects(
|
|
"openai:chat",
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
429,
|
|
));
|
|
assert!(local_candidate_failure_should_apply_key_effects(
|
|
"openai:chat",
|
|
LocalFailoverClassification::RetryUpstreamFailure,
|
|
503,
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn anthropic_non_credential_failures_preserve_key_wide_state() {
|
|
for status_code in [400, 429, 503, 529] {
|
|
let mut key = sample_adaptive_key();
|
|
let circuit = json!({
|
|
"openai:chat": {
|
|
"open": true,
|
|
"reason": "existing-state"
|
|
}
|
|
});
|
|
key.circuit_breaker_by_format = Some(circuit.clone());
|
|
let expected_adaptive_state = ProviderCatalogKeyAdaptiveState::from(&key);
|
|
let expected_health = key.health_by_format.clone();
|
|
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
|
vec![sample_pool_health_provider()],
|
|
vec![sample_health_endpoint()],
|
|
vec![key],
|
|
));
|
|
let state = AppState::new()
|
|
.expect("gateway state should build")
|
|
.with_data_state_for_tests(
|
|
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
|
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
|
);
|
|
let plan = sample_claude_plan();
|
|
let report_context = json!({
|
|
"api_key_id": "api-key-1",
|
|
"client_api_format": "claude:messages",
|
|
"model": "claude-sonnet-4-5",
|
|
});
|
|
let cache_key = build_scheduler_affinity_cache_key_for_api_key_id(
|
|
"api-key-1",
|
|
"claude:messages",
|
|
"claude-sonnet-4-5",
|
|
)
|
|
.expect("scheduler affinity cache key should build");
|
|
let target = SchedulerAffinityTarget {
|
|
provider_id: plan.provider_id.clone(),
|
|
endpoint_id: plan.endpoint_id.clone(),
|
|
key_id: plan.key_id.clone(),
|
|
};
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
target.clone(),
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
let headers = BTreeMap::from([("Retry-After".to_string(), "120".to_string())]);
|
|
let context = LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
};
|
|
let classification = LocalFailoverClassification::RetryUpstreamFailure;
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
context,
|
|
LocalExecutionEffect::AttemptFailure(LocalAttemptFailureEffect {
|
|
status_code,
|
|
classification,
|
|
}),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
&state,
|
|
context,
|
|
LocalExecutionEffect::AdaptiveRateLimit(LocalAdaptiveRateLimitEffect {
|
|
status_code,
|
|
classification,
|
|
headers: Some(&headers),
|
|
}),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
&state,
|
|
context,
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code,
|
|
classification,
|
|
}),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
&state,
|
|
context,
|
|
LocalExecutionEffect::PoolError(LocalPoolErrorEffect {
|
|
status_code,
|
|
classification,
|
|
headers: &headers,
|
|
error_body: Some(r#"{"error":{"message":"temporarily unavailable"}}"#),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(
|
|
ProviderCatalogKeyAdaptiveState::from(&stored_key),
|
|
expected_adaptive_state,
|
|
"Anthropic status {status_code} must not update key-wide adaptive state"
|
|
);
|
|
assert_eq!(
|
|
stored_key.health_by_format, expected_health,
|
|
"Anthropic status {status_code} must not update key-wide health"
|
|
);
|
|
assert_eq!(
|
|
stored_key.circuit_breaker_by_format,
|
|
Some(circuit),
|
|
"Anthropic status {status_code} must not clear pool key state"
|
|
);
|
|
let expected_affinity = (status_code == 400).then_some(target);
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(&cache_key, SCHEDULER_AFFINITY_TTL),
|
|
expected_affinity,
|
|
"Anthropic status {status_code} must invalidate only retryable target affinity"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_pool_account_errors_project_pool_hard_state() {
|
|
assert_eq!(
|
|
pool_score_hard_state_for_status(
|
|
400,
|
|
Some(r#"{"error":{"message":"deactivated_workspace"}}"#),
|
|
),
|
|
Some(PoolMemberHardState::Banned)
|
|
);
|
|
assert_eq!(
|
|
pool_score_hard_state_for_status(
|
|
402,
|
|
Some(r#"{"error":{"message":"payment required"}}"#),
|
|
),
|
|
Some(PoolMemberHardState::QuotaExhausted)
|
|
);
|
|
assert_eq!(
|
|
pool_score_hard_state_for_status(
|
|
429,
|
|
Some(r#"{"error":{"status":"RESOURCE_EXHAUSTED","message":"quota exhausted"}}"#),
|
|
),
|
|
Some(PoolMemberHardState::QuotaExhausted)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pool_account_error_does_not_open_key_circuit() {
|
|
let Some(redis) = start_managed_redis_or_skip().await else {
|
|
return;
|
|
};
|
|
let state = codex_state_with_redis(redis.redis_url(), "orchestration_pool_circuit");
|
|
let plan = sample_codex_plan();
|
|
let legacy_circuit = json!({
|
|
"openai:responses": {
|
|
"open": true,
|
|
"reason": "legacy"
|
|
}
|
|
});
|
|
state
|
|
.update_provider_catalog_key_health_state(
|
|
&plan.key_id,
|
|
true,
|
|
None,
|
|
Some(&legacy_circuit),
|
|
)
|
|
.await
|
|
.expect("legacy circuit should seed");
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::PoolError(LocalPoolErrorEffect {
|
|
status_code: 401,
|
|
classification: LocalFailoverClassification::StopErrorPattern,
|
|
headers: &BTreeMap::new(),
|
|
error_body: Some(r#"{"error":{"message":"account has been deactivated"}}"#),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.circuit_breaker_by_format, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_bearer_generation_match_supports_generic_auth_config_token() {
|
|
let state = codex_state();
|
|
let mut transport = state
|
|
.read_provider_transport_snapshot(
|
|
"provider-codex-cli-local-1",
|
|
"endpoint-codex-cli-local-1",
|
|
"key-codex-cli-local-1",
|
|
)
|
|
.await
|
|
.expect("transport should load")
|
|
.expect("transport should exist");
|
|
transport.provider.provider_type = "claude_code".to_string();
|
|
transport.key.decrypted_api_key = "__placeholder__".to_string();
|
|
transport.key.decrypted_auth_config =
|
|
Some(json!({"accessToken": "current-access-token"}).to_string());
|
|
let mut plan = sample_codex_plan();
|
|
plan.headers.insert(
|
|
"authorization".to_string(),
|
|
"Bearer current-access-token".to_string(),
|
|
);
|
|
assert!(execution_plan_bearer_matches_transport(&plan, &transport));
|
|
|
|
plan.headers.insert(
|
|
"authorization".to_string(),
|
|
"Bearer stale-access-token".to_string(),
|
|
);
|
|
assert!(!execution_plan_bearer_matches_transport(&plan, &transport));
|
|
|
|
transport.key.decrypted_api_key = "replacement-access-token".to_string();
|
|
plan.headers.insert(
|
|
"authorization".to_string(),
|
|
"Bearer current-access-token".to_string(),
|
|
);
|
|
assert!(!execution_plan_bearer_matches_transport(&plan, &transport));
|
|
plan.headers.insert(
|
|
"authorization".to_string(),
|
|
"Bearer replacement-access-token".to_string(),
|
|
);
|
|
assert!(execution_plan_bearer_matches_transport(&plan, &transport));
|
|
|
|
transport.key.decrypted_auth_config = Some(
|
|
json!({
|
|
"accessToken": "current-access-token",
|
|
"request": {
|
|
"extraHeaders": {
|
|
"Authorization": "Bearer nested-override-token"
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
);
|
|
plan.headers.insert(
|
|
"authorization".to_string(),
|
|
"Bearer replacement-access-token".to_string(),
|
|
);
|
|
assert!(!execution_plan_bearer_matches_transport(&plan, &transport));
|
|
plan.headers.insert(
|
|
"authorization".to_string(),
|
|
"Bearer nested-override-token".to_string(),
|
|
);
|
|
assert!(execution_plan_bearer_matches_transport(&plan, &transport));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_marks_claude_code_authentication_failures_only() {
|
|
let state = claude_code_oauth_state();
|
|
let mut plan = sample_codex_plan();
|
|
plan.provider_name = Some("claude_code".to_string());
|
|
plan.provider_api_format = "claude:messages".to_string();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 403,
|
|
response_text: Some(
|
|
r#"{"type":"error","error":{"type":"permission_error","message":"insufficient scope"}}"#,
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
let unmarked = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert!(unmarked.oauth_invalid_at_unix_secs.is_none());
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 403,
|
|
response_text: Some(
|
|
r#"{"type":"error","error":{"type":"authentication_error","message":"invalid access token"}}"#,
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
let marked = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert!(marked.oauth_invalid_at_unix_secs.is_some());
|
|
assert_eq!(
|
|
marked.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] invalid access token")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_marks_claude_code_unauthorized_without_body() {
|
|
let state = claude_code_oauth_state();
|
|
let mut plan = sample_codex_plan();
|
|
plan.provider_name = Some("claude_code".to_string());
|
|
plan.provider_api_format = "claude:messages".to_string();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 401,
|
|
response_text: None,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] OAuth access token was rejected")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_marks_codex_key_expired() {
|
|
let state = codex_state();
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 401,
|
|
response_text: Some(
|
|
r#"{"error":{"message":"session expired","type":"invalid_request_error"}}"#,
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] session expired")
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|value| value.get("oauth"))
|
|
.and_then(|value| value.get("code"))
|
|
.and_then(Value::as_str),
|
|
Some("expired")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_success_clears_recoverable_codex_invalid_state() {
|
|
let mut key = sample_codex_key();
|
|
key.oauth_invalid_at_unix_secs = Some(100);
|
|
key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"credential_generation": "credential-generation-current",
|
|
"oauth_state_request_started_at_unix_ms": 100_000u64,
|
|
"oauth_state_request_id": "00000001-86a0-7000-8000-000000000001"
|
|
}
|
|
}));
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), key);
|
|
let plan = sample_codex_plan();
|
|
let report_context = json!({
|
|
"codex_credential_generation": "credential-generation-current",
|
|
"provider_request_started_at_unix_ms": 200_000u64,
|
|
"provider_request_order_id": "00000003-0d40-7000-8000-000000000001"
|
|
});
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::OauthSuccess(LocalOAuthSuccessEffect {
|
|
status_code: 200,
|
|
request_started_at_unix_ms: Some(200_000),
|
|
request_order_id: Some("00000003-0d40-7000-8000-000000000001"),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, None);
|
|
assert_eq!(stored_key.oauth_invalid_reason, None);
|
|
assert_eq!(
|
|
stored_key.upstream_metadata.as_ref().and_then(
|
|
|metadata| metadata.pointer("/codex/oauth_state_request_started_at_unix_ms")
|
|
),
|
|
Some(&json!(200_000u64))
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.pointer("/codex/oauth_state_request_id")),
|
|
Some(&json!("00000003-0d40-7000-8000-000000000001"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_millisecond_older_oauth_success_does_not_clear_newer_codex_invalidation() {
|
|
let mut key = sample_codex_key();
|
|
key.oauth_invalid_at_unix_secs = Some(300);
|
|
key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"credential_generation": "credential-generation-current",
|
|
"oauth_state_request_started_at_unix_ms": 300_000u64,
|
|
"oauth_state_request_id": "00000004-93e0-7000-8000-000000000002"
|
|
}
|
|
}));
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), key);
|
|
let plan = sample_codex_plan();
|
|
let report_context = json!({
|
|
"codex_credential_generation": "credential-generation-current",
|
|
"provider_request_started_at_unix_ms": 300_000u64,
|
|
"provider_request_order_id": "00000004-93e0-7000-8000-000000000001"
|
|
});
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::OauthSuccess(LocalOAuthSuccessEffect {
|
|
status_code: 200,
|
|
request_started_at_unix_ms: Some(300_000),
|
|
request_order_id: Some("00000004-93e0-7000-8000-000000000001"),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, Some(300));
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] session expired")
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.pointer("/codex/oauth_state_request_id")),
|
|
Some(&json!("00000004-93e0-7000-8000-000000000002"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_success_preserves_codex_account_block() {
|
|
let mut key = sample_codex_key();
|
|
key.oauth_invalid_at_unix_secs = Some(100);
|
|
key.oauth_invalid_reason = Some("[ACCOUNT_BLOCK] account deactivated".to_string());
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"credential_generation": "credential-generation-current"
|
|
}
|
|
}));
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), key);
|
|
let plan = sample_codex_plan();
|
|
let report_context = json!({
|
|
"codex_credential_generation": "credential-generation-current",
|
|
"provider_request_started_at_unix_ms": 200_000u64,
|
|
"provider_request_order_id": "00000003-0d40-7000-8000-000000000001"
|
|
});
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::OauthSuccess(LocalOAuthSuccessEffect {
|
|
status_code: 200,
|
|
request_started_at_unix_ms: Some(200_000),
|
|
request_order_id: Some("00000003-0d40-7000-8000-000000000001"),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, Some(100));
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[ACCOUNT_BLOCK] account deactivated")
|
|
);
|
|
assert_eq!(
|
|
stored_key.upstream_metadata.as_ref().and_then(
|
|
|metadata| metadata.pointer("/codex/oauth_state_request_started_at_unix_ms")
|
|
),
|
|
Some(&json!(200_000u64))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_success_does_not_clear_codex_invalid_state_for_replaced_bearer() {
|
|
let mut key = sample_codex_key();
|
|
key.oauth_invalid_at_unix_secs = Some(100);
|
|
key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"credential_generation": "credential-generation-current"
|
|
}
|
|
}));
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), key);
|
|
let mut plan = sample_codex_plan();
|
|
plan.headers.insert(
|
|
"authorization".to_string(),
|
|
"Bearer replaced-access-token".to_string(),
|
|
);
|
|
let report_context = json!({
|
|
"codex_credential_generation": "credential-generation-current",
|
|
"provider_request_started_at_unix_ms": 200_000u64,
|
|
"provider_request_order_id": "00000003-0d40-7000-8000-000000000001"
|
|
});
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::OauthSuccess(LocalOAuthSuccessEffect {
|
|
status_code: 200,
|
|
request_started_at_unix_ms: Some(200_000),
|
|
request_order_id: Some("00000003-0d40-7000-8000-000000000001"),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, Some(100));
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] session expired")
|
|
);
|
|
assert!(stored_key
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.pointer("/codex/oauth_state_request_started_at_unix_ms"))
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_success_effect_ignores_non_success_status() {
|
|
let mut key = sample_codex_key();
|
|
key.oauth_invalid_at_unix_secs = Some(100);
|
|
key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"credential_generation": "credential-generation-current"
|
|
}
|
|
}));
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), key);
|
|
let plan = sample_codex_plan();
|
|
let report_context = json!({
|
|
"codex_credential_generation": "credential-generation-current",
|
|
"provider_request_started_at_unix_ms": 200_000u64,
|
|
"provider_request_order_id": "00000003-0d40-7000-8000-000000000001"
|
|
});
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::OauthSuccess(LocalOAuthSuccessEffect {
|
|
status_code: 304,
|
|
request_started_at_unix_ms: Some(200_000),
|
|
request_order_id: Some("00000003-0d40-7000-8000-000000000001"),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, Some(100));
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] session expired")
|
|
);
|
|
assert!(stored_key
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.pointer("/codex/oauth_state_request_started_at_unix_ms"))
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_marks_generic_codex_403_as_token_invalid() {
|
|
let state = codex_state();
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 403,
|
|
response_text: Some(r#"{"error":{"message":"forbidden"}}"#),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] Codex Token 已失效 (403): forbidden")
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|value| value.get("oauth"))
|
|
.and_then(|value| value.get("code"))
|
|
.and_then(Value::as_str),
|
|
Some("invalid")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_marks_codex_inactive_pat_owner_403_as_token_invalid() {
|
|
let state = codex_state();
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 403,
|
|
response_text: Some(
|
|
r#"{"error":{"code":"biscuit_baker_service_auth_credential_error_status","message":"Personal access token owner is inactive."},"status":403}"#,
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] Personal access token owner is inactive.")
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|value| value.get("oauth"))
|
|
.and_then(|value| value.get("code"))
|
|
.and_then(Value::as_str),
|
|
Some("invalid")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_auto_remove_keeps_inactive_pat_owner() {
|
|
let state = codex_state_with_auto_remove();
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 403,
|
|
response_text: Some(
|
|
r#"{"error":{"code":"biscuit_baker_service_auth_credential_error_status","message":"Personal access token owner is inactive."},"status":403}"#,
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("recoverable token invalidation should retain the key");
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] Personal access token owner is inactive.")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_auto_removes_account_block() {
|
|
let state = codex_state_with_auto_remove();
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 403,
|
|
response_text: Some(
|
|
r#"{"error":{"message":"account has been deactivated"},"status":403}"#,
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_keys = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load");
|
|
assert!(stored_keys.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_auto_remove_keeps_recoverable_expired_token() {
|
|
let state = codex_state_with_auto_remove();
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 401,
|
|
response_text: Some(
|
|
r#"{"error":{"message":"session expired","type":"invalid_request_error"}}"#,
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("recoverable expired token should be retained");
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] session expired")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_does_not_mutate_replacement_after_agent_request() {
|
|
let state = codex_state_with_auto_remove();
|
|
let plan = sample_codex_agent_identity_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 403,
|
|
response_text: Some(
|
|
r#"{"error":{"code":"biscuit_baker_service_auth_credential_error_status","message":"Personal access token owner is inactive."},"status":403}"#,
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("replacement OAuth key should not be removed");
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, None);
|
|
assert_eq!(stored_key.oauth_invalid_reason, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_does_not_mutate_agent_replacement_after_bearer_request() {
|
|
let state = codex_state_with_provider_and_key(
|
|
sample_codex_provider_with_auto_remove(),
|
|
sample_codex_agent_identity_key(),
|
|
);
|
|
let mut plan = sample_codex_plan();
|
|
plan.headers.insert(
|
|
"authorization".to_string(),
|
|
"Bearer old-access-token".to_string(),
|
|
);
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 403,
|
|
response_text: Some(
|
|
r#"{"error":{"code":"biscuit_baker_service_auth_credential_error_status","message":"Personal access token owner is inactive."},"status":403}"#,
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("Agent Identity replacement should not be removed");
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, None);
|
|
assert_eq!(stored_key.oauth_invalid_reason, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn oauth_invalidation_rejects_stale_codex_credential_generation() {
|
|
let mut key = sample_codex_key();
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"credential_generation": "credential-generation-current"
|
|
}
|
|
}));
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), key);
|
|
let plan = sample_codex_plan();
|
|
let report_context = json!({
|
|
"codex_credential_generation": "credential-generation-stale",
|
|
"provider_request_started_at_unix_ms": 100_000u64,
|
|
"provider_request_order_id": "00000001-86a0-7000-8000-000000000001"
|
|
});
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&report_context),
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 401,
|
|
response_text: Some(r#"{"error":{"message":"session expired"}}"#),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.oauth_invalid_at_unix_secs, None);
|
|
assert_eq!(stored_key.oauth_invalid_reason, None);
|
|
assert!(stored_key
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.pointer("/codex/oauth_state_request_started_at_unix_ms"))
|
|
.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delayed_codex_quota_report_does_not_clear_newer_oauth_invalidation() {
|
|
let mut key = sample_codex_key();
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"credential_generation": "credential-generation-current",
|
|
"account_quota_reset_generation": 0
|
|
}
|
|
}));
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), key);
|
|
let plan = sample_codex_plan();
|
|
let older_request_id = "00000001-86a0-7000-8000-000000000001";
|
|
let newer_request_id = "00000001-86a0-7000-8000-000000000002";
|
|
let older_uuid = uuid::Uuid::parse_str(older_request_id).expect("older id should parse");
|
|
let newer_uuid = uuid::Uuid::parse_str(newer_request_id).expect("newer id should parse");
|
|
assert_eq!(older_uuid.get_version_num(), 7);
|
|
assert_eq!(newer_uuid.get_version_num(), 7);
|
|
assert!(older_uuid < newer_uuid);
|
|
|
|
let invalidation_context = json!({
|
|
"codex_credential_generation": "credential-generation-current",
|
|
"provider_request_started_at_unix_ms": 100_000u64,
|
|
"provider_request_order_id": newer_request_id
|
|
});
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: Some(&invalidation_context),
|
|
},
|
|
LocalExecutionEffect::OauthInvalidation(LocalOAuthInvalidationEffect {
|
|
status_code: 401,
|
|
response_text: Some(r#"{"error":{"message":"session expired"}}"#),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let delayed_report_context = json!({
|
|
"key_id": plan.key_id,
|
|
"codex_credential_generation": "credential-generation-current",
|
|
"codex_quota_reset_generation": 0,
|
|
"provider_request_started_at_unix_ms": 100_000u64,
|
|
"provider_response_headers_observed_at_unix_ms": 110_000u64,
|
|
"provider_request_order_id": older_request_id
|
|
});
|
|
let delayed_headers = BTreeMap::from([
|
|
("x-codex-plan-type".to_string(), "free".to_string()),
|
|
("x-codex-primary-used-percent".to_string(), "25".to_string()),
|
|
(
|
|
"x-codex-primary-reset-at".to_string(),
|
|
"2000000000".to_string(),
|
|
),
|
|
(
|
|
"x-codex-primary-window-minutes".to_string(),
|
|
"300".to_string(),
|
|
),
|
|
]);
|
|
let delayed_report = GatewaySyncReportRequest {
|
|
trace_id: "trace-delayed-codex-quota".to_string(),
|
|
report_kind: "openai_responses_sync_success".to_string(),
|
|
report_context: Some(delayed_report_context),
|
|
status_code: 200,
|
|
headers: delayed_headers,
|
|
body_json: None,
|
|
client_body_json: None,
|
|
body_base64: None,
|
|
telemetry: None,
|
|
};
|
|
apply_local_report_effect(
|
|
&state,
|
|
LocalReportEffect::Sync {
|
|
payload: &delayed_report,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(
|
|
stored_key.oauth_invalid_reason.as_deref(),
|
|
Some("[OAUTH_EXPIRED] session expired")
|
|
);
|
|
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
|
assert_eq!(
|
|
stored_key
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.pointer("/codex/oauth_state_request_id")),
|
|
Some(&json!(newer_request_id))
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|snapshot| snapshot.pointer("/oauth/code")),
|
|
Some(&json!("expired"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_failure_updates_codex_key_for_current_bearer_request() {
|
|
let state = codex_state();
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: 503,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(
|
|
stored_key
|
|
.health_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:responses"))
|
|
.and_then(|value| value.get("consecutive_failures"))
|
|
.and_then(Value::as_u64),
|
|
Some(1)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_failure_does_not_mutate_codex_bearer_replacement() {
|
|
let mut replacement = sample_codex_key();
|
|
replacement.encrypted_api_key = Some(
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "replacement-token")
|
|
.expect("replacement token should encrypt"),
|
|
);
|
|
replacement.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"replacement-refresh-token"}"#,
|
|
)
|
|
.expect("replacement auth config should encrypt"),
|
|
);
|
|
let expected_health = replacement.health_by_format.clone();
|
|
let expected_circuit = replacement.circuit_breaker_by_format.clone();
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), replacement);
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: 503,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("replacement key should exist");
|
|
assert_eq!(stored_key.health_by_format, expected_health);
|
|
assert_eq!(stored_key.circuit_breaker_by_format, expected_circuit);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn adaptive_rate_limit_does_not_mutate_codex_bearer_replacement() {
|
|
let mut replacement = sample_codex_key();
|
|
replacement.encrypted_api_key = Some(
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "replacement-token")
|
|
.expect("replacement token should encrypt"),
|
|
);
|
|
replacement.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"replacement-refresh-token"}"#,
|
|
)
|
|
.expect("replacement auth config should encrypt"),
|
|
);
|
|
replacement.learned_rpm_limit = Some(12);
|
|
replacement.rpm_429_count = Some(1);
|
|
let expected_adaptive_state = ProviderCatalogKeyAdaptiveState::from(&replacement);
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), replacement);
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::AdaptiveRateLimit(LocalAdaptiveRateLimitEffect {
|
|
status_code: 429,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
headers: Some(&BTreeMap::from([(
|
|
"x-ratelimit-limit-requests".to_string(),
|
|
"42".to_string(),
|
|
)])),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("replacement key should exist");
|
|
assert_eq!(
|
|
ProviderCatalogKeyAdaptiveState::from(&stored_key),
|
|
expected_adaptive_state
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pool_error_does_not_clear_codex_bearer_replacement_circuit() {
|
|
let legacy_circuit = json!({
|
|
"openai:responses": {
|
|
"open": true,
|
|
"reason": "replacement-state"
|
|
}
|
|
});
|
|
let mut replacement = sample_codex_key();
|
|
replacement.encrypted_api_key = Some(
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "replacement-token")
|
|
.expect("replacement token should encrypt"),
|
|
);
|
|
replacement.encrypted_auth_config = Some(
|
|
encrypt_python_fernet_plaintext(
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
r#"{"provider_type":"codex","refresh_token":"replacement-refresh-token"}"#,
|
|
)
|
|
.expect("replacement auth config should encrypt"),
|
|
);
|
|
replacement.circuit_breaker_by_format = Some(legacy_circuit.clone());
|
|
let state = codex_state_with_provider_and_key(sample_codex_provider(), replacement);
|
|
let plan = sample_codex_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::PoolError(LocalPoolErrorEffect {
|
|
status_code: 401,
|
|
classification: LocalFailoverClassification::StopErrorPattern,
|
|
headers: &BTreeMap::new(),
|
|
error_body: Some(r#"{"error":{"message":"account has been deactivated"}}"#),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("replacement key should exist");
|
|
assert_eq!(stored_key.circuit_breaker_by_format, Some(legacy_circuit));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_failure_projection_updates_key_health_for_format() {
|
|
let state = health_state();
|
|
let plan = sample_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: 503,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(
|
|
stored_key.health_by_format,
|
|
Some(json!({
|
|
"openai:chat": {
|
|
"health_score": 0.6,
|
|
"consecutive_failures": 1,
|
|
"last_failure_at": stored_key
|
|
.health_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:chat"))
|
|
.and_then(|value| value.get("last_failure_at"))
|
|
.cloned()
|
|
.unwrap_or(Value::Null)
|
|
}
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_health_failure_does_not_reactivate_admin_disabled_key() {
|
|
let mut key = sample_health_key();
|
|
key.is_active = false;
|
|
let state = health_state_with_key(key);
|
|
let plan = sample_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: 503,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert!(!stored_key.is_active);
|
|
assert_eq!(
|
|
stored_key
|
|
.health_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:chat"))
|
|
.and_then(|value| value.get("consecutive_failures"))
|
|
.and_then(Value::as_u64),
|
|
Some(1)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_failure_opens_circuit_after_eight_consecutive_failures() {
|
|
let state = health_state();
|
|
let plan = sample_plan();
|
|
|
|
for _ in 0..8 {
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: 503,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
let circuit = stored_key
|
|
.circuit_breaker_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:chat"))
|
|
.expect("format circuit should be stored");
|
|
assert_eq!(circuit["open"], json!(true));
|
|
assert_eq!(circuit["reason"], json!("consecutive_failures_8"));
|
|
assert_eq!(circuit["probe_interval_minutes"], json!(1));
|
|
assert!(circuit["next_probe_at_unix_secs"].as_u64().is_some());
|
|
assert_eq!(
|
|
circuit["request_results_window"]
|
|
.as_array()
|
|
.map(Vec::len)
|
|
.unwrap_or_default(),
|
|
8
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrent_health_failures_for_one_key_do_not_lose_updates() {
|
|
let state = health_state();
|
|
let plan = sample_plan();
|
|
let mut tasks = tokio::task::JoinSet::new();
|
|
|
|
for _ in 0..8 {
|
|
let state = state.clone();
|
|
let plan = plan.clone();
|
|
tasks.spawn(async move {
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: 503,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
while let Some(result) = tasks.join_next().await {
|
|
result.expect("health failure task should complete");
|
|
}
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
let circuit = stored_key
|
|
.circuit_breaker_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:chat"))
|
|
.expect("format circuit should be stored");
|
|
assert_eq!(
|
|
stored_key
|
|
.health_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:chat"))
|
|
.and_then(|value| value.get("consecutive_failures"))
|
|
.and_then(Value::as_u64),
|
|
Some(8)
|
|
);
|
|
assert_eq!(circuit["open"], json!(true));
|
|
assert_eq!(circuit["reason"], json!("consecutive_failures_8"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pool_health_failure_does_not_open_key_circuit_after_eight_consecutive_failures() {
|
|
let state = pool_health_state();
|
|
let plan = sample_plan();
|
|
|
|
for _ in 0..8 {
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: 503,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.circuit_breaker_by_format, None);
|
|
assert_eq!(
|
|
stored_key
|
|
.health_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:chat"))
|
|
.and_then(|value| value.get("consecutive_failures"))
|
|
.and_then(Value::as_u64),
|
|
Some(8)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_success_projection_resets_key_health_for_format() {
|
|
let state = health_state();
|
|
let plan = sample_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: 503,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(
|
|
stored_key.health_by_format,
|
|
Some(json!({
|
|
"openai:chat": {
|
|
"health_score": 1.0,
|
|
"consecutive_failures": 0,
|
|
"last_failure_at": Value::Null
|
|
}
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_success_projection_is_rate_limited_until_failure_resets_gate() {
|
|
let state = health_state();
|
|
let plan = sample_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
let first_updated_at = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist")
|
|
.updated_at_unix_secs;
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
let second_updated_at = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist")
|
|
.updated_at_unix_secs;
|
|
assert_eq!(second_updated_at, first_updated_at);
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
|
|
status_code: 503,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
}),
|
|
)
|
|
.await;
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(
|
|
stored_key
|
|
.health_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:chat"))
|
|
.and_then(|value| value.get("consecutive_failures"))
|
|
.and_then(Value::as_u64),
|
|
Some(0)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn health_success_projection_closes_key_circuit_for_format() {
|
|
let mut key = sample_health_key();
|
|
key.circuit_breaker_by_format = Some(json!({
|
|
"openai:chat": {
|
|
"open": true,
|
|
"reason": "account_deactivated_401",
|
|
"next_probe_at_unix_secs": 1_760_001_920u64
|
|
}
|
|
}));
|
|
let state = health_state_with_key(key);
|
|
let plan = sample_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
let circuit = stored_key
|
|
.circuit_breaker_by_format
|
|
.as_ref()
|
|
.and_then(|value| value.get("openai:chat"))
|
|
.expect("format circuit should be stored");
|
|
assert_eq!(circuit["open"], json!(false));
|
|
assert_eq!(circuit["reason"], Value::Null);
|
|
assert_eq!(circuit["next_probe_at_unix_secs"], Value::Null);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn adaptive_rate_limit_effect_updates_adaptive_key_observation() {
|
|
let state = adaptive_state();
|
|
let plan = sample_plan();
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
let target = SchedulerAffinityTarget {
|
|
provider_id: plan.provider_id.clone(),
|
|
endpoint_id: plan.endpoint_id.clone(),
|
|
key_id: plan.key_id.clone(),
|
|
};
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
target.clone(),
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
let initial_epoch = state.scheduler_affinity_epoch();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::AdaptiveRateLimit(LocalAdaptiveRateLimitEffect {
|
|
status_code: 429,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
headers: Some(&BTreeMap::from([(
|
|
"x-ratelimit-limit-requests".to_string(),
|
|
"42".to_string(),
|
|
)])),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.rpm_429_count, Some(2));
|
|
assert_eq!(stored_key.last_429_type.as_deref(), Some("rpm"));
|
|
assert!(stored_key.last_429_at_unix_secs.is_some());
|
|
assert_eq!(
|
|
stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|value| value.get("observation_count")),
|
|
Some(&json!(1))
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|value| value.get("header_observation_count")),
|
|
Some(&json!(1))
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|value| value.get("latest_upstream_limit")),
|
|
Some(&json!(42))
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|value| value.get("learning_confidence")),
|
|
Some(&json!(0.3))
|
|
);
|
|
assert_eq!(
|
|
stored_key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.and_then(|value| value.get("enforcement_active")),
|
|
Some(&json!(false))
|
|
);
|
|
assert_eq!(state.scheduler_affinity_epoch(), initial_epoch);
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(target)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn adaptive_rate_limit_effect_preserves_quota_status_owned_by_reports() {
|
|
let mut key = sample_adaptive_key();
|
|
key.status_snapshot = Some(json!({
|
|
"quota": {"remaining": 9},
|
|
"oauth": {"invalid": false},
|
|
"observation_count": 0
|
|
}));
|
|
let state = health_state_with_key(key);
|
|
let plan = sample_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::AdaptiveRateLimit(LocalAdaptiveRateLimitEffect {
|
|
status_code: 429,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
headers: None,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
let status = stored_key.status_snapshot.expect("status should exist");
|
|
assert_eq!(status["quota"], json!({"remaining":9}));
|
|
assert_eq!(status["oauth"], json!({"invalid":false}));
|
|
assert_eq!(status["observation_count"], json!(1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn adaptive_rate_limit_effect_ignores_fixed_limit_key() {
|
|
let state = fixed_limit_state();
|
|
let plan = sample_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::AdaptiveRateLimit(LocalAdaptiveRateLimitEffect {
|
|
status_code: 429,
|
|
classification: LocalFailoverClassification::RetryUpstreamFailure,
|
|
headers: Some(&BTreeMap::from([(
|
|
"x-ratelimit-limit-requests".to_string(),
|
|
"42".to_string(),
|
|
)])),
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.rpm_429_count, None);
|
|
assert_eq!(stored_key.last_429_at_unix_secs, None);
|
|
assert_eq!(stored_key.last_429_type, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn adaptive_rate_limit_effect_records_429_as_rpm_observation() {
|
|
let mut key = sample_health_key();
|
|
key.rpm_limit = None;
|
|
key.learned_rpm_limit = Some(20);
|
|
let state = adaptive_state_with_request_candidates(key, Vec::new());
|
|
let plan = sample_plan();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::AdaptiveRateLimit(LocalAdaptiveRateLimitEffect {
|
|
status_code: 429,
|
|
classification: LocalFailoverClassification::RetryStatusCode,
|
|
headers: None,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.rpm_429_count, Some(1));
|
|
assert_eq!(stored_key.learned_rpm_limit, Some(20));
|
|
assert_eq!(stored_key.last_429_type.as_deref(), Some("rpm"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn adaptive_success_effect_expands_limit_from_recent_rpm_usage() {
|
|
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
|
let mut key = sample_adaptive_key();
|
|
key.learned_rpm_limit = Some(20);
|
|
key.last_rpm_peak = Some(25);
|
|
key.last_429_at_unix_secs = Some(now_unix_secs.saturating_sub(600));
|
|
key.adjustment_history = Some(json!([
|
|
{
|
|
"timestamp": "2026-04-19T00:00:00Z",
|
|
"old_limit": 0,
|
|
"new_limit": 20,
|
|
"reason": "rpm_429",
|
|
"confidence": 0.8
|
|
}
|
|
]));
|
|
key.utilization_samples = Some(json!([
|
|
{"ts": now_unix_secs.saturating_sub(40), "util": 0.90},
|
|
{"ts": now_unix_secs.saturating_sub(30), "util": 0.95},
|
|
{"ts": now_unix_secs.saturating_sub(20), "util": 0.85},
|
|
{"ts": now_unix_secs.saturating_sub(10), "util": 0.80}
|
|
]));
|
|
let state = adaptive_state_with_request_candidates(
|
|
key,
|
|
vec![StoredRequestCandidate::new(
|
|
"candidate-1".to_string(),
|
|
"req-1".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
0,
|
|
0,
|
|
Some("prov-1".to_string()),
|
|
Some("ep-1".to_string()),
|
|
Some("key-1".to_string()),
|
|
RequestCandidateStatus::Success,
|
|
None,
|
|
false,
|
|
Some(200),
|
|
None,
|
|
None,
|
|
Some(10),
|
|
Some(19),
|
|
None,
|
|
None,
|
|
i64::try_from(now_unix_secs.saturating_sub(30) * 1000)
|
|
.expect("candidate created_at should fit i64"),
|
|
Some(
|
|
i64::try_from(now_unix_secs.saturating_sub(30) * 1000)
|
|
.expect("candidate started_at should fit i64"),
|
|
),
|
|
Some(
|
|
i64::try_from(now_unix_secs.saturating_sub(29) * 1000)
|
|
.expect("candidate finished_at should fit i64"),
|
|
),
|
|
)
|
|
.expect("request candidate should build")],
|
|
);
|
|
let plan = sample_plan();
|
|
let cache_key =
|
|
build_scheduler_affinity_cache_key_for_api_key_id("api-key-1", "openai:chat", "gpt-5")
|
|
.expect("scheduler affinity cache key should build");
|
|
let target = SchedulerAffinityTarget {
|
|
provider_id: plan.provider_id.clone(),
|
|
endpoint_id: plan.endpoint_id.clone(),
|
|
key_id: plan.key_id.clone(),
|
|
};
|
|
state.remember_scheduler_affinity_target(
|
|
&cache_key,
|
|
target.clone(),
|
|
SCHEDULER_AFFINITY_TTL,
|
|
16,
|
|
);
|
|
let initial_epoch = state.scheduler_affinity_epoch();
|
|
|
|
apply_local_execution_effect(
|
|
&state,
|
|
LocalExecutionEffectContext {
|
|
plan: &plan,
|
|
report_context: None,
|
|
},
|
|
LocalExecutionEffect::AdaptiveSuccess(LocalAdaptiveSuccessEffect),
|
|
)
|
|
.await;
|
|
|
|
let stored_key = state
|
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
|
.await
|
|
.expect("provider catalog keys should load")
|
|
.into_iter()
|
|
.next()
|
|
.expect("stored key should exist");
|
|
assert_eq!(stored_key.learned_rpm_limit, Some(25));
|
|
assert_eq!(stored_key.utilization_samples, Some(json!([])));
|
|
assert_eq!(
|
|
stored_key
|
|
.adjustment_history
|
|
.as_ref()
|
|
.and_then(Value::as_array)
|
|
.and_then(|items| items.last())
|
|
.and_then(Value::as_object)
|
|
.and_then(|record| record.get("reason"))
|
|
.and_then(Value::as_str),
|
|
Some("high_utilization")
|
|
);
|
|
assert_eq!(state.scheduler_affinity_epoch(), initial_epoch);
|
|
assert_eq!(
|
|
state.read_scheduler_affinity_target(cache_key.as_str(), SCHEDULER_AFFINITY_TTL),
|
|
Some(target)
|
|
);
|
|
}
|
|
}
|