diff --git a/apps/aether-gateway/src/control/auth/gate.rs b/apps/aether-gateway/src/control/auth/gate.rs
index 208c13cf3..0b5ce4d33 100644
--- a/apps/aether-gateway/src/control/auth/gate.rs
+++ b/apps/aether-gateway/src/control/auth/gate.rs
@@ -205,8 +205,8 @@ async fn available_balance_capacity_usd(
.as_ref()
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
Ok(match quota.as_ref() {
- Some(quota) if !quota.allow_wallet_overage => Some(quota.remaining_usd.max(0.0)),
Some(_) if wallet_is_unlimited => None,
+ Some(quota) if !quota.allow_wallet_overage => Some(quota.remaining_usd.max(0.0)),
Some(quota) => Some(quota.remaining_usd.max(0.0) + wallet_available_usd.unwrap_or(0.0)),
None if wallet_is_unlimited => None,
None => wallet_available_usd,
@@ -832,9 +832,10 @@ mod tests {
use serde_json::json;
use super::{
- execution_plan_balance_capacity_rejection, execution_plan_cost_upper_bound_cache_key,
- max_output_tokens_from_request, openai_request_input_is_self_contained,
- output_choice_count_upper_bound, request_model_local_rejection, GatewayLocalAuthRejection,
+ available_balance_capacity_usd, execution_plan_balance_capacity_rejection,
+ execution_plan_cost_upper_bound_cache_key, max_output_tokens_from_request,
+ openai_request_input_is_self_contained, output_choice_count_upper_bound,
+ request_model_local_rejection, GatewayLocalAuthRejection,
};
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
use crate::data::GatewayDataState;
@@ -939,6 +940,14 @@ mod tests {
fn state_with_quota_and_wallet(
quota: UserDailyQuotaAvailabilityRecord,
context: StoredBillingModelContext,
+ ) -> AppState {
+ state_with_quota_context_and_wallet(quota, context, sample_wallet("user-1", 30.0))
+ }
+
+ fn state_with_quota_context_and_wallet(
+ quota: UserDailyQuotaAvailabilityRecord,
+ context: StoredBillingModelContext,
+ wallet: StoredWalletSnapshot,
) -> AppState {
let candidate_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
@@ -952,7 +961,7 @@ mod tests {
AppState::new()
.expect("state should build")
.with_data_state_for_tests(data)
- .with_auth_wallets_for_tests(vec![sample_wallet("user-1", 30.0)])
+ .with_auth_wallets_for_tests(vec![wallet])
}
fn state_with_model_mapping() -> AppState {
@@ -1350,6 +1359,26 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn unlimited_wallet_capacity_ignores_exhausted_non_overage_quota() {
+ let context = billing_context_with_pricing(None, None, None, None);
+ let mut wallet = sample_wallet("user-1", 0.0);
+ wallet.limit_mode = "unlimited".to_string();
+ let state =
+ state_with_quota_context_and_wallet(quota_availability(0.0, false), context, wallet);
+ let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
+ let auth_context = decision
+ .auth_context
+ .as_ref()
+ .expect("decision should include auth context");
+
+ let capacity = available_balance_capacity_usd(&state, auth_context)
+ .await
+ .expect("capacity should resolve");
+
+ assert_eq!(capacity, None);
+ }
+
#[tokio::test]
async fn positive_balance_does_not_allow_historical_invalid_processing_pricing() {
let context = billing_context_with_pricing(
diff --git a/apps/aether-gateway/src/dispatch/pool_scheduler.rs b/apps/aether-gateway/src/dispatch/pool_scheduler.rs
index 8fe81fce5..d79af387b 100644
--- a/apps/aether-gateway/src/dispatch/pool_scheduler.rs
+++ b/apps/aether-gateway/src/dispatch/pool_scheduler.rs
@@ -1826,17 +1826,17 @@ fn pool_key_candidate_order_for_group(
})
.collect::>();
let active_presets = ProviderPoolService::with_builtin_adapters()
- .normalize_scheduling_presets(group.transport.provider.provider_type.as_str(), &presets)
- .into_iter()
- .map(|preset| preset.preset)
- .collect::>();
+ .normalize_scheduling_presets(group.transport.provider.provider_type.as_str(), &presets);
if let Some(distribution_mode) = active_presets
.iter()
- .find(|preset| pool_distribution_mode_preset(preset.as_str()))
- .map(String::as_str)
+ .find(|preset| pool_distribution_mode_preset(preset.preset.as_str()))
{
- return match distribution_mode {
- "cache_affinity" => StoredPoolKeyCandidateOrder::CacheAffinity,
+ return match distribution_mode.preset.as_str() {
+ "cache_affinity" => match distribution_mode.mode.as_deref() {
+ Some("lru") => StoredPoolKeyCandidateOrder::Lru,
+ Some("single_account") => StoredPoolKeyCandidateOrder::SingleAccount,
+ _ => StoredPoolKeyCandidateOrder::CacheAffinity,
+ },
"load_balance" => StoredPoolKeyCandidateOrder::LoadBalance {
seed: pool_sort_seed(),
},
@@ -2151,7 +2151,7 @@ mod tests {
}
#[test]
- fn pool_scheduler_promotes_sticky_hit_before_other_sorted_keys() {
+ fn pool_scheduler_promotes_sticky_hit_before_lru_secondary_order() {
let key_a = sample_eligible_candidate(
"provider-pool",
"endpoint-1",
@@ -2159,7 +2159,11 @@ mod tests {
10,
Some(json!({
"pool_advanced": {
- "scheduling_presets": [{"preset": "cache_affinity", "enabled": true}]
+ "scheduling_presets": [{
+ "preset": "cache_affinity",
+ "enabled": true,
+ "mode": "lru"
+ }]
}
})),
);
@@ -2170,7 +2174,11 @@ mod tests {
10,
Some(json!({
"pool_advanced": {
- "scheduling_presets": [{"preset": "cache_affinity", "enabled": true}]
+ "scheduling_presets": [{
+ "preset": "cache_affinity",
+ "enabled": true,
+ "mode": "lru"
+ }]
}
})),
);
@@ -2204,6 +2212,37 @@ mod tests {
);
}
+ #[test]
+ fn cache_affinity_secondary_modes_select_distinct_candidate_orders() {
+ for (mode, expected) in [
+ ("single_account", StoredPoolKeyCandidateOrder::SingleAccount),
+ ("lru", StoredPoolKeyCandidateOrder::Lru),
+ ] {
+ let group = sample_eligible_candidate(
+ "provider-pool",
+ "endpoint-1",
+ "key-a",
+ 10,
+ Some(json!({
+ "pool_advanced": {
+ "scheduling_presets": [{
+ "preset": "cache_affinity",
+ "enabled": true,
+ "mode": mode
+ }]
+ }
+ })),
+ );
+ let config = pool_config_for_candidate(&group).expect("pool config should parse");
+
+ assert!(admin_provider_pool_cache_affinity_enabled(&config));
+ assert_eq!(
+ pool_key_candidate_order_for_group(&group, Some(&config)),
+ expected
+ );
+ }
+ }
+
#[test]
fn pool_scheduler_ignores_sticky_hit_without_cache_affinity() {
let key_a = sample_eligible_candidate(
diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution.rs b/apps/aether-gateway/src/execution_runtime/stream/execution.rs
index e7c90815a..80830455e 100644
--- a/apps/aether-gateway/src/execution_runtime/stream/execution.rs
+++ b/apps/aether-gateway/src/execution_runtime/stream/execution.rs
@@ -126,7 +126,7 @@ use crate::orchestration::{
LocalOAuthSuccessEffect, LocalPoolErrorEffect,
};
use crate::provider_pool_demand::{
- acquire_provider_pool_in_flight_guard, ProviderPoolInFlightGuard,
+ acquire_provider_pool_execution_guard, ProviderPoolInFlightAdmission, ProviderPoolInFlightGuard,
};
use crate::request_candidate_runtime::{
ensure_execution_request_candidate_slot, persist_local_request_candidate_status_record,
@@ -3772,6 +3772,41 @@ async fn execute_execution_runtime_stream_inner(
plan_kind,
report_context.as_ref(),
);
+ let candidate_started_unix_secs = current_request_candidate_unix_ms();
+ let provider_in_flight_started_at = Instant::now();
+ let mut provider_pool_in_flight_guard =
+ match acquire_provider_pool_execution_guard(state, &plan).await? {
+ ProviderPoolInFlightAdmission::Acquired(guard) => guard,
+ ProviderPoolInFlightAdmission::Saturated { limit } => {
+ if let Some(retry_scope) = retry_scope_out.as_deref_mut() {
+ *retry_scope = AiAttemptRetryScope::Candidate;
+ }
+ if let Some(snapshot) = request_candidate_status_snapshot.as_ref() {
+ record_local_request_candidate_status_snapshot(
+ state,
+ snapshot,
+ SchedulerRequestCandidateStatusUpdate {
+ status: RequestCandidateStatus::Skipped,
+ status_code: Some(http::StatusCode::TOO_MANY_REQUESTS.as_u16()),
+ error_type: Some("provider_key_concurrency_limit_reached".to_string()),
+ error_message: Some(format!(
+ "provider key concurrency limit reached: {limit}"
+ )),
+ latency_ms: Some(0),
+ started_at_unix_ms: Some(candidate_started_unix_secs),
+ finished_at_unix_ms: Some(candidate_started_unix_secs),
+ },
+ )
+ .await;
+ }
+ return Ok(None);
+ }
+ };
+ observe_gateway_stage_trace_ms(
+ &mut stage_trace,
+ "stream_provider_in_flight",
+ provider_in_flight_started_at.elapsed().as_millis() as u64,
+ );
// Inline passthrough records its lifecycle seed after upstream headers are
// available. Avoid constructing a throwaway seed on the common path.
let mut lifecycle_seed = (!defer_stream_pending_for_direct_inline)
@@ -3781,7 +3816,6 @@ async fn execute_execution_runtime_stream_inner(
record_stream_pending_lifecycle(state, seed, &mut stage_trace).await;
lifecycle_pending_recorded = true;
}
- let candidate_started_unix_secs = current_request_candidate_unix_ms();
if let Some(snapshot) = request_candidate_status_snapshot.clone() {
record_local_request_candidate_status_snapshot(
state,
@@ -3810,20 +3844,6 @@ async fn execute_execution_runtime_stream_inner(
.and_then(|context| context.candidate_index)
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string());
- let provider_in_flight_started_at = Instant::now();
- let mut provider_pool_in_flight_guard = acquire_provider_pool_in_flight_guard(
- state.runtime_state.clone(),
- &plan.provider_id,
- plan.request_id.as_str(),
- plan.candidate_id.as_deref(),
- key_id.as_str(),
- )
- .await;
- observe_gateway_stage_trace_ms(
- &mut stage_trace,
- "stream_provider_in_flight",
- provider_in_flight_started_at.elapsed().as_millis() as u64,
- );
match maybe_execute_grok_stream(&plan, report_context.as_ref()).await {
Ok(Some(grok_stream)) => {
return execute_stream_from_frame_stream_with_retry_scope(
diff --git a/apps/aether-gateway/src/execution_runtime/sync/execution.rs b/apps/aether-gateway/src/execution_runtime/sync/execution.rs
index 5d6266c7b..84d67a25a 100644
--- a/apps/aether-gateway/src/execution_runtime/sync/execution.rs
+++ b/apps/aether-gateway/src/execution_runtime/sync/execution.rs
@@ -78,7 +78,9 @@ use crate::orchestration::{
LocalExecutionEffectContext, LocalHealthFailureEffect, LocalHealthSuccessEffect,
LocalOAuthInvalidationEffect, LocalOAuthSuccessEffect, LocalPoolErrorEffect,
};
-use crate::provider_pool_demand::acquire_provider_pool_in_flight_guard;
+use crate::provider_pool_demand::{
+ acquire_provider_pool_execution_guard, ProviderPoolInFlightAdmission,
+};
use crate::request_candidate_runtime::{
ensure_execution_request_candidate_slot, record_local_request_candidate_extra_data,
record_local_request_candidate_status, record_local_request_candidate_status_snapshot,
@@ -1995,6 +1997,32 @@ async fn execute_execution_runtime_sync_impl(
.unwrap_or_else(|| "-".to_string());
let candidate_started_at = Instant::now();
let candidate_started_unix_secs = current_request_candidate_unix_ms();
+ let _provider_pool_in_flight_guard = match acquire_provider_pool_execution_guard(state, &plan)
+ .await?
+ {
+ ProviderPoolInFlightAdmission::Acquired(guard) => guard,
+ ProviderPoolInFlightAdmission::Saturated { limit } => {
+ if let Some(retry_scope) = retry_scope_out.as_deref_mut() {
+ *retry_scope = AiAttemptRetryScope::Candidate;
+ }
+ record_local_request_candidate_status(
+ state,
+ &plan,
+ report_context.as_ref(),
+ SchedulerRequestCandidateStatusUpdate {
+ status: RequestCandidateStatus::Skipped,
+ status_code: Some(StatusCode::TOO_MANY_REQUESTS.as_u16()),
+ error_type: Some("provider_key_concurrency_limit_reached".to_string()),
+ error_message: Some(format!("provider key concurrency limit reached: {limit}")),
+ latency_ms: Some(0),
+ started_at_unix_ms: Some(candidate_started_unix_secs),
+ finished_at_unix_ms: Some(candidate_started_unix_secs),
+ },
+ )
+ .await;
+ return Ok(None);
+ }
+ };
let lifecycle_seed = build_lifecycle_usage_seed(&plan, report_context.as_ref());
let usage_data = state.usage_lifecycle_data_state().as_ref().clone();
state
@@ -2024,14 +2052,6 @@ async fn execute_execution_runtime_sync_impl(
candidate_started_at,
);
let result = (async {
- let _provider_pool_in_flight_guard = acquire_provider_pool_in_flight_guard(
- state.runtime_state.clone(),
- &plan.provider_id,
- plan_request_id.as_str(),
- plan_candidate_id.as_deref(),
- key_id.as_str(),
- )
- .await;
record_sync_execution_active(
state,
&plan,
diff --git a/apps/aether-gateway/src/handlers/admin/provider/pool/config.rs b/apps/aether-gateway/src/handlers/admin/provider/pool/config.rs
index e9fe4c008..216f00405 100644
--- a/apps/aether-gateway/src/handlers/admin/provider/pool/config.rs
+++ b/apps/aether-gateway/src/handlers/admin/provider/pool/config.rs
@@ -167,6 +167,14 @@ fn parse_pool_score_rules(pool_advanced: &Map) -> PoolMemberScore
fn normalize_pool_preset_mode(preset: &str, raw_mode: Option<&Value>) -> Option {
match preset {
+ "cache_affinity" => Some(
+ raw_mode
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|value| matches!(*value, "single_account" | "lru"))
+ .unwrap_or("single_account")
+ .to_string(),
+ ),
"free_team_first" | "free_first" | "team_first" | "plus_first" | "pro_first" => {
let default_mode = match preset {
"free_team_first" => "both",
diff --git a/apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs b/apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs
index 21531664e..98076f0df 100644
--- a/apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs
+++ b/apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs
@@ -1342,6 +1342,7 @@ pub(super) fn build_admin_pool_key_payload(
json!(key.internal_priority),
);
payload.insert("rpm_limit".to_string(), json!(key.rpm_limit));
+ payload.insert("concurrent_limit".to_string(), json!(key.concurrent_limit));
payload.insert(
"cache_ttl_minutes".to_string(),
json!(key.cache_ttl_minutes),
diff --git a/apps/aether-gateway/src/handlers/proxy/websocket/responses/admission.rs b/apps/aether-gateway/src/handlers/proxy/websocket/responses/admission.rs
index 8192f5e90..31f1e4e73 100644
--- a/apps/aether-gateway/src/handlers/proxy/websocket/responses/admission.rs
+++ b/apps/aether-gateway/src/handlers/proxy/websocket/responses/admission.rs
@@ -11,7 +11,7 @@ use aether_contracts::ExecutionPlan;
use crate::execution_runtime::acquire_upstream_execution_gate;
use crate::provider_pool_demand::{
- acquire_provider_pool_in_flight_guard, ProviderPoolInFlightGuard,
+ acquire_provider_pool_execution_guard, ProviderPoolInFlightAdmission, ProviderPoolInFlightGuard,
};
use crate::upstream_admission::UpstreamTargetAdmissionPermit;
use crate::{AppState, GatewayError};
@@ -41,14 +41,17 @@ impl ResponsesWebSocketTurnAdmission {
return Err(error);
}
};
- let provider_pool = acquire_provider_pool_in_flight_guard(
- state.runtime_state.clone(),
- &plan.provider_id,
- &plan.request_id,
- plan.candidate_id.as_deref(),
- &plan.key_id,
- )
- .await;
+ let provider_pool = match acquire_provider_pool_execution_guard(state, plan).await? {
+ ProviderPoolInFlightAdmission::Acquired(guard) => guard,
+ ProviderPoolInFlightAdmission::Saturated { limit } => {
+ drop(upstream_target);
+ drop(upstream_execution);
+ return Err(GatewayError::Client {
+ status: http::StatusCode::TOO_MANY_REQUESTS,
+ message: format!("上游账号并发已达上限 ({limit})"),
+ });
+ }
+ };
Ok(Self {
upstream_execution,
diff --git a/apps/aether-gateway/src/maintenance/runtime/oauth_token_refresh.rs b/apps/aether-gateway/src/maintenance/runtime/oauth_token_refresh.rs
index 9cb608c12..7ec147a7e 100644
--- a/apps/aether-gateway/src/maintenance/runtime/oauth_token_refresh.rs
+++ b/apps/aether-gateway/src/maintenance/runtime/oauth_token_refresh.rs
@@ -256,12 +256,16 @@ fn auth_config_has_refresh_token(auth_config: Option<&str>) -> bool {
let Ok(value) = serde_json::from_str::(auth_config) else {
return false;
};
- value
- .as_object()
- .and_then(|object| object.get("refresh_token"))
- .and_then(Value::as_str)
- .map(str::trim)
- .is_some_and(|value| !value.is_empty())
+ let Some(object) = value.as_object() else {
+ return false;
+ };
+ ["refresh_token", "refreshToken"].iter().any(|field| {
+ object
+ .get(*field)
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .is_some_and(|value| !value.is_empty())
+ })
}
fn now_unix_secs() -> u64 {
@@ -273,7 +277,44 @@ fn now_unix_secs() -> u64 {
#[cfg(test)]
mod tests {
- use super::agent_identity_needs_task_recovery;
+ use aether_data_contracts::repository::provider_catalog::{
+ StoredProviderCatalogKey, StoredProviderCatalogProvider,
+ };
+
+ use super::{
+ agent_identity_needs_task_recovery, auth_config_has_refresh_token, oauth_refresh_candidate,
+ };
+
+ #[test]
+ fn legacy_antigravity_refresh_token_is_refreshable() {
+ assert!(auth_config_has_refresh_token(Some(
+ r#"{"refreshToken":"legacy-refresh-token"}"#,
+ )));
+ }
+
+ #[test]
+ fn expiring_antigravity_oauth_key_is_refresh_candidate() {
+ let provider = StoredProviderCatalogProvider::new(
+ "provider-antigravity".to_string(),
+ "Antigravity".to_string(),
+ None,
+ "antigravity".to_string(),
+ )
+ .expect("provider should build");
+ let mut key = StoredProviderCatalogKey::new(
+ "key-antigravity".to_string(),
+ provider.id.clone(),
+ "Antigravity OAuth".to_string(),
+ "oauth".to_string(),
+ None,
+ true,
+ )
+ .expect("key should build");
+ key.encrypted_auth_config = Some("encrypted-auth-config".to_string());
+ key.expires_at_unix_secs = Some(120);
+
+ assert!(oauth_refresh_candidate(&provider, &key, 120));
+ }
#[test]
fn pending_agent_identity_without_task_is_recoverable() {
diff --git a/apps/aether-gateway/src/provider_key_auth.rs b/apps/aether-gateway/src/provider_key_auth.rs
index 8bc5ae2dc..62379e62f 100644
--- a/apps/aether-gateway/src/provider_key_auth.rs
+++ b/apps/aether-gateway/src/provider_key_auth.rs
@@ -90,11 +90,15 @@ pub(crate) fn provider_key_can_refresh_oauth(
) -> bool {
auth_semantics.can_refresh_oauth()
&& (provider_key_auth_config_is_agent_identity(provider_type, auth_config)
- || auth_config
- .and_then(|config| config.get("refresh_token"))
- .and_then(Value::as_str)
- .map(str::trim)
- .is_some_and(|value| !value.is_empty()))
+ || auth_config.is_some_and(|config| {
+ ["refresh_token", "refreshToken"].iter().any(|field| {
+ config
+ .get(*field)
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .is_some_and(|value| !value.is_empty())
+ })
+ }))
}
pub(crate) fn provider_key_can_export_oauth(
@@ -425,6 +429,11 @@ mod tests {
"codex",
json!({ "refresh_token": "refresh-token" }).as_object()
));
+ assert!(provider_key_can_refresh_oauth(
+ provider_key_auth_semantics(&sample_key("oauth"), "antigravity"),
+ "antigravity",
+ json!({ "refreshToken": "legacy-refresh-token" }).as_object()
+ ));
assert!(provider_key_can_refresh_oauth(
semantics,
"codex",
diff --git a/apps/aether-gateway/src/provider_pool_demand.rs b/apps/aether-gateway/src/provider_pool_demand.rs
index 819755995..6506cb56e 100644
--- a/apps/aether-gateway/src/provider_pool_demand.rs
+++ b/apps/aether-gateway/src/provider_pool_demand.rs
@@ -4,14 +4,20 @@ use std::sync::{
};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
-use aether_runtime_state::RuntimeState;
+use aether_contracts::ExecutionPlan;
+use aether_runtime_state::{
+ RuntimeSemaphoreConfig, RuntimeSemaphoreError, RuntimeSemaphorePermit, RuntimeState,
+};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use tokio::task::JoinHandle;
use tracing::debug;
use uuid::Uuid;
+use crate::{AppState, GatewayError};
+
const PROVIDER_POOL_IN_FLIGHT_TOKENS_PREFIX: &str = "ap:provider_pool:in_flight";
+const PROVIDER_KEY_CONCURRENCY_GATE: &str = "provider_key";
const PROVIDER_POOL_DEMAND_SNAPSHOT_PREFIX: &str = "ap:provider_pool:demand";
const PROVIDER_POOL_BURST_PENDING_PREFIX: &str = "ap:quota_probe:burst_pending";
const PROVIDER_POOL_IN_FLIGHT_TOKEN_TTL_MS: u64 = 120_000;
@@ -42,10 +48,17 @@ pub(crate) struct ProviderPoolDemandSnapshot {
pub(crate) struct ProviderPoolInFlightGuard {
kind: ProviderPoolInFlightGuardKind,
+ provider_key_permit: Option,
released: bool,
}
+pub(crate) enum ProviderPoolInFlightAdmission {
+ Acquired(Option),
+ Saturated { limit: usize },
+}
+
enum ProviderPoolInFlightGuardKind {
+ Disabled,
Local {
provider_id: String,
counter: Arc,
@@ -69,6 +82,9 @@ impl ProviderPoolInFlightGuard {
return;
}
match &mut self.kind {
+ ProviderPoolInFlightGuardKind::Disabled => {
+ self.released = true;
+ }
ProviderPoolInFlightGuardKind::Local {
provider_id,
counter,
@@ -101,6 +117,16 @@ impl ProviderPoolInFlightGuard {
}
}
}
+ if self.released {
+ if let Some(provider_key_permit) = self.provider_key_permit.take() {
+ if let Err(err) = provider_key_permit.release().await {
+ debug!(
+ error = ?err,
+ "gateway provider pool demand: failed to release provider key permit; scheduling drop fallback"
+ );
+ }
+ }
+ }
}
}
@@ -111,6 +137,7 @@ impl Drop for ProviderPoolInFlightGuard {
}
self.released = true;
match &mut self.kind {
+ ProviderPoolInFlightGuardKind::Disabled => {}
ProviderPoolInFlightGuardKind::Local {
provider_id,
counter,
@@ -290,32 +317,83 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard(
candidate_id: Option<&str>,
key_id: &str,
) -> Option {
+ acquire_provider_pool_in_flight_guard_with_key_limit(
+ runtime,
+ provider_id,
+ request_id,
+ candidate_id,
+ key_id,
+ None,
+ )
+ .await
+ .ok()
+ .flatten()
+}
+
+pub(crate) async fn acquire_provider_pool_in_flight_guard_with_key_limit(
+ runtime: Arc,
+ provider_id: &str,
+ request_id: &str,
+ candidate_id: Option<&str>,
+ key_id: &str,
+ concurrent_limit: Option,
+) -> Result
+
+
+
@@ -267,13 +288,16 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
{
name: 'cache_affinity',
label: '缓存亲和',
- description: '优先复用最近使用过的 Key,利用 Prompt Caching',
+ description: '同一用户持续复用 Key,首次分配可集中或轮转',
mutex_group: DISTRIBUTION_GROUP,
- evidence_hint: '依据 LRU 时间戳(最近使用优先,与 LRU 轮转相反)',
+ evidence_hint: '先复用用户粘性 Key,未命中时按所选二级模式分配',
providers: [],
default_enabled: true,
- modes: null,
- default_mode: null,
+ modes: [
+ { value: 'single_account', label: '单号优先' },
+ { value: 'lru', label: 'LRU 轮号' },
+ ],
+ default_mode: 'single_account',
},
{
name: 'lru',
@@ -733,14 +757,17 @@ const activeDistributionPreset = computed(() => {
return found?.item.preset ?? null
})
-const activeDistributionDesc = computed(() => {
+const activeDistributionItem = computed(() => {
const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
- return found?.item.desc ?? null
+ return found?.item ?? null
+})
+
+const activeDistributionDesc = computed(() => {
+ return activeDistributionItem.value?.desc ?? null
})
const activeDistributionLabel = computed(() => {
- const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
- return found?.item.label ?? null
+ return activeDistributionItem.value?.label ?? null
})
const strategyItems = computed(() => {
diff --git a/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts b/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts
index 2b43f6900..cb70b1729 100644
--- a/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts
+++ b/frontend/src/features/pool/components/__tests__/PoolKeyDisplayPanels.spec.ts
@@ -90,6 +90,30 @@ describe('pool key display panels', () => {
root.remove()
})
+ it('renders Antigravity quota as numeric values without progress tracks', () => {
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const app = createApp(PoolKeyQuotaPanel, {
+ items: [{
+ label: 'Gemini 3.1 Pro (High)',
+ remainingPercent: 42,
+ resetText: '1h 后重置',
+ meterText: '42',
+ barClass: 'bg-amber-500',
+ meterClass: 'text-amber-600',
+ numericOnly: true,
+ }],
+ })
+ app.use(createI18n())
+ app.mount(root)
+
+ expect(root.querySelector('[data-testid="pool-quota-meter-text"]')?.textContent).toBe('42')
+ expect(root.querySelector('[data-testid="pool-quota-progress-track"]')).toBeNull()
+
+ app.unmount()
+ root.remove()
+ })
+
it('renders single-cycle stats as plain text', () => {
const root = document.createElement('div')
document.body.appendChild(root)
diff --git a/frontend/src/features/pool/components/__tests__/PoolSchedulingDialog.cache-affinity.spec.ts b/frontend/src/features/pool/components/__tests__/PoolSchedulingDialog.cache-affinity.spec.ts
new file mode 100644
index 000000000..7848fcd72
--- /dev/null
+++ b/frontend/src/features/pool/components/__tests__/PoolSchedulingDialog.cache-affinity.spec.ts
@@ -0,0 +1,125 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
+
+import PoolSchedulingDialog from '../PoolSchedulingDialog.vue'
+
+const endpointMocks = vi.hoisted(() => ({
+ getPoolSchedulingPresets: vi.fn(),
+ getProvider: vi.fn(),
+ updateProvider: vi.fn(),
+}))
+
+vi.mock('@/api/endpoints', () => ({
+ getProvider: endpointMocks.getProvider,
+ updateProvider: endpointMocks.updateProvider,
+}))
+
+vi.mock('@/api/endpoints/pool', () => ({
+ getPoolSchedulingPresets: endpointMocks.getPoolSchedulingPresets,
+}))
+
+vi.mock('@/composables/useToast', () => ({
+ useToast: () => ({
+ success: vi.fn(),
+ error: vi.fn(),
+ }),
+}))
+
+const mountedApps: Array<{ app: App, root: HTMLElement }> = []
+const cacheAffinityConfig = {
+ scheduling_presets: [{
+ preset: 'cache_affinity',
+ enabled: true,
+ mode: 'single_account',
+ }],
+}
+
+async function settle(): Promise {
+ for (let index = 0; index < 4; index += 1) {
+ await Promise.resolve()
+ await nextTick()
+ }
+}
+
+function mountDialog(): void {
+ const root = document.createElement('div')
+ document.body.appendChild(root)
+ const TestHost = defineComponent({
+ setup() {
+ const open = ref(false)
+ void nextTick(() => { open.value = true })
+ return () => h(PoolSchedulingDialog, {
+ modelValue: open.value,
+ providerId: 'provider-1',
+ providerType: 'openai',
+ currentConfig: cacheAffinityConfig,
+ 'onUpdate:modelValue': (value: boolean) => { open.value = value },
+ })
+ },
+ })
+ const app = createApp(TestHost)
+ app.mount(root)
+ mountedApps.push({ app, root })
+}
+
+beforeEach(() => {
+ endpointMocks.getPoolSchedulingPresets.mockReset()
+ endpointMocks.getProvider.mockReset()
+ endpointMocks.updateProvider.mockReset()
+ endpointMocks.getPoolSchedulingPresets.mockResolvedValue([{
+ name: 'cache_affinity',
+ label: '缓存亲和',
+ description: '同一用户持续复用 Key,首次分配可集中或轮转',
+ providers: [],
+ default_enabled: true,
+ modes: [
+ { value: 'single_account', label: '单号优先' },
+ { value: 'lru', label: 'LRU 轮号' },
+ ],
+ default_mode: 'single_account',
+ mutex_group: 'distribution_mode',
+ }])
+ endpointMocks.getProvider.mockResolvedValue({
+ id: 'provider-1',
+ pool_advanced: cacheAffinityConfig,
+ })
+ endpointMocks.updateProvider.mockResolvedValue({ id: 'provider-1' })
+})
+
+afterEach(() => {
+ for (const { app, root } of mountedApps.splice(0)) {
+ app.unmount()
+ root.remove()
+ }
+ document.body.innerHTML = ''
+})
+
+describe('PoolSchedulingDialog cache affinity modes', () => {
+ it('shows and saves the LRU secondary mode', async () => {
+ mountDialog()
+ await settle()
+
+ const modeControl = document.body.querySelector(
+ '[data-testid="pool-cache-affinity-secondary-mode"]',
+ )
+ expect(modeControl?.textContent).toContain('单号优先')
+ expect(modeControl?.textContent).toContain('LRU 轮号')
+
+ modeControl?.querySelector('[data-mode="lru"]')?.click()
+ await nextTick()
+ const saveButton = [...document.body.querySelectorAll('button')]
+ .find(button => button.textContent?.trim() === '保存')
+ saveButton?.click()
+ await settle()
+
+ expect(endpointMocks.updateProvider).toHaveBeenCalledWith('provider-1', {
+ pool_advanced: {
+ scheduling_presets: [{
+ preset: 'cache_affinity',
+ enabled: true,
+ mode: 'lru',
+ }],
+ },
+ })
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/features/pool/utils/__tests__/poolKeyBatchSettings.spec.ts b/frontend/src/features/pool/utils/__tests__/poolKeyBatchSettings.spec.ts
index 1a7389b0d..fac763879 100644
--- a/frontend/src/features/pool/utils/__tests__/poolKeyBatchSettings.spec.ts
+++ b/frontend/src/features/pool/utils/__tests__/poolKeyBatchSettings.spec.ts
@@ -35,6 +35,18 @@ describe('pool key batch settings', () => {
})
})
+ it('serializes a positive concurrency limit as a number', () => {
+ const selection = createPoolKeyBatchSettingSelection()
+ const draft = createPoolKeyBatchSettingsDraft()
+ selection.concurrent_limit = true
+ draft.concurrent_limit = 6
+
+ expect(validatePoolKeyBatchSettings(selection, draft)).toEqual([])
+ expect(buildPoolKeySettingsPatch(selection, draft)).toEqual({
+ concurrent_limit: 6,
+ })
+ })
+
it('requires a selected field and a proxy node for set mode', () => {
const selection = createPoolKeyBatchSettingSelection()
const draft = createPoolKeyBatchSettingsDraft()
diff --git a/frontend/src/features/pool/utils/poolQuotaRefresh.ts b/frontend/src/features/pool/utils/poolQuotaRefresh.ts
index 175084e2a..80a87f9fa 100644
--- a/frontend/src/features/pool/utils/poolQuotaRefresh.ts
+++ b/frontend/src/features/pool/utils/poolQuotaRefresh.ts
@@ -5,24 +5,34 @@ export function mergePoolKeyQuotaSnapshots(
keys: PoolKeyDetail[],
results: RefreshQuotaResult['results'],
): PoolKeyDetail[] {
- const quotaByKeyId = new Map>()
+ const resultByKeyId = new Map()
for (const result of results) {
- if (result.quota_snapshot) {
- quotaByKeyId.set(result.key_id, result.quota_snapshot)
+ if (result.quota_snapshot || result.metadata) {
+ resultByKeyId.set(result.key_id, result)
}
}
- if (quotaByKeyId.size === 0) return keys
+ if (resultByKeyId.size === 0) return keys
return keys.map((key) => {
- const quotaSnapshot = quotaByKeyId.get(key.key_id)
- if (!quotaSnapshot) return key
+ const result = resultByKeyId.get(key.key_id)
+ if (!result) return key
+ const quotaSnapshot = result.quota_snapshot
+ const providerType = String(quotaSnapshot?.provider_type || key.provider_type || '').trim().toLowerCase()
return {
...key,
- quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
- status_snapshot: {
- ...(key.status_snapshot ?? {}),
- quota: quotaSnapshot,
- },
+ ...(quotaSnapshot ? {
+ quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
+ status_snapshot: {
+ ...(key.status_snapshot ?? {}),
+ quota: quotaSnapshot,
+ },
+ } : {}),
+ ...(result.metadata && providerType ? {
+ upstream_metadata: {
+ ...(key.upstream_metadata ?? {}),
+ [providerType]: result.metadata,
+ },
+ } : {}),
}
})
}
diff --git a/frontend/src/views/admin/PoolManagement.vue b/frontend/src/views/admin/PoolManagement.vue
index 29b4aad2b..de4d72634 100644
--- a/frontend/src/views/admin/PoolManagement.vue
+++ b/frontend/src/views/admin/PoolManagement.vue
@@ -331,6 +331,11 @@
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
:text-class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
+ :reset-credit-text="getCodexResetCreditCountText(key)"
+ :reset-credit-items="getCodexResetCreditItemTexts(key)"
+ :can-consume-reset-credit="canConsumeCodexResetCredit(key)"
+ :consuming-reset-credit="consumingCodexResetCreditKeyId === key.key_id"
+ @consume-reset-credit="handleConsumeCodexResetCredit(key)"
/>
@@ -709,7 +714,12 @@
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
:text-class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
+ :reset-credit-text="getCodexResetCreditCountText(key)"
+ :reset-credit-items="getCodexResetCreditItemTexts(key)"
+ :can-consume-reset-credit="canConsumeCodexResetCredit(key)"
+ :consuming-reset-credit="consumingCodexResetCreditKeyId === key.key_id"
variant="mobile"
+ @consume-reset-credit="handleConsumeCodexResetCredit(key)"
/>
@@ -1074,6 +1084,7 @@ import {
deleteEndpointKey,
updateProviderKey,
refreshProviderQuota,
+ consumeCodexResetCredit,
resetProviderKeyCycleStats,
} from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
@@ -1132,6 +1143,22 @@ import {
} from '@/features/pool/utils/poolStatsDisplay'
import { resetCodexCycleUsageWindows } from '@/features/pool/utils/poolCycleStats'
import { mergePoolKeyQuotaSnapshots } from '@/features/pool/utils/poolQuotaRefresh'
+import {
+ dedupeAntigravityQuotaItemsByLabel,
+ resolveAntigravityQuotaLabel,
+} from '@/features/providers/utils/antigravityQuota'
+import {
+ clearPendingCodexResetCreditIdempotencyKey,
+ clearPendingCodexResetCreditIdempotencyKeyForOutcome,
+ createCodexResetCreditIdempotencyKey,
+ formatCodexResetCreditCount,
+ formatCodexResetCreditExpiresAt,
+ getCodexResetCreditAvailableCount,
+ getCodexResetCreditReservationIdempotencyKey,
+ getVisibleCodexResetCreditItems,
+ readPendingCodexResetCreditIdempotencyKey,
+ rememberPendingCodexResetCreditIdempotencyKey,
+} from '@/features/providers/components/codex-reset-credit-display'
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
@@ -1853,6 +1880,7 @@ const sortOrder = ref
(restoredViewState.sortOrder)
const hasPoolKeyFilters = computed(() => searchQuery.value.trim().length > 0 || statusFilter.value !== 'all')
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
const refreshingOAuthKeyId = ref(null)
+const consumingCodexResetCreditKeyId = ref(null)
const resettingCycleKeyId = ref(null)
const savingProxyKeyId = ref(null)
const proxyDesktopPopoverOpenKeyId = ref(null)
@@ -1978,6 +2006,7 @@ watch(
interface QuotaProgressItem {
label: string
remainingPercent: number
+ numericOnly?: boolean
sortOrder?: number
detail?: string
resetAtSeconds?: number | null
@@ -1993,6 +2022,7 @@ interface QuotaProgressDisplayItem {
meterText: string
barClass: string
meterClass: string
+ numericOnly?: boolean
}
type PoolKeyUiState = {
@@ -2033,9 +2063,12 @@ const quotaProgressDisplayMap = computed `${item.displayKey} ${formatCodexResetCreditExpiresAt(item.expiresAt)}`)
+}
+
+function canConsumeCodexResetCredit(key: PoolKeyDetail): boolean {
+ return getQuotaSnapshotProviderType(key) === 'codex'
+ && getCodexCredentialGeneration(key) !== undefined
+ && (getPendingCodexResetCreditIdempotencyKey(key) !== null
+ || (getCodexResetCreditAvailableCount(getCodexResetCredits(key)) ?? 0) > 0)
+ && consumingCodexResetCreditKeyId.value === null
+}
+
+async function handleConsumeCodexResetCredit(key: PoolKeyDetail): Promise {
+ if (!canConsumeCodexResetCredit(key)) return
+ const generation = getCodexCredentialGeneration(key)
+ if (generation === undefined) return
+ const pendingIdempotencyKey = getPendingCodexResetCreditIdempotencyKey(key)
+ const confirmed = await confirm({
+ title: '确认使用 Codex 重置机会',
+ message: pendingIdempotencyKey
+ ? '将继续确认上次尚未完成的 Codex 重置请求。'
+ : '将消耗 1 次 Codex 重置机会,完成后自动刷新账号额度。',
+ confirmText: '确认重置',
+ cancelText: '取消',
+ variant: 'warning',
+ })
+ if (!confirmed) return
+
+ consumingCodexResetCreditKeyId.value = key.key_id
+ try {
+ const idempotencyKey = pendingIdempotencyKey
+ || readPendingCodexResetCreditIdempotencyKey(key.key_id, generation)
+ || createCodexResetCreditIdempotencyKey()
+ rememberPendingCodexResetCreditIdempotencyKey(key.key_id, idempotencyKey, generation)
+ const result = await consumeCodexResetCredit(key.key_id, {
+ idempotency_key: idempotencyKey,
+ expected_credential_generation: generation,
+ })
+ clearPendingCodexResetCreditIdempotencyKeyForOutcome(key.key_id, result.outcome)
+ keyPage.value.keys = mergePoolKeyQuotaSnapshots(keyPage.value.keys, [{
+ key_id: result.key_id,
+ key_name: key.key_name,
+ status: result.refresh_status === 'success' ? 'success' : result.status as 'success',
+ metadata: result.metadata,
+ quota_snapshot: result.quota_snapshot,
+ }])
+ if (result.outcome === 'reset' || result.outcome === 'already_redeemed') {
+ success('Codex 重置机会已使用,账号额度已刷新')
+ } else {
+ showWarning(result.message || '重置请求已处理,请查看最新额度')
+ }
+ } catch (err: unknown) {
+ const responseData = typeof err === 'object' && err !== null && 'response' in err
+ ? (err as { response?: { data?: Record } }).response?.data
+ : undefined
+ if (responseData?.outcome === 'credential_changed') {
+ clearPendingCodexResetCreditIdempotencyKey(key.key_id)
+ } else if (typeof responseData?.active_idempotency_key === 'string') {
+ rememberPendingCodexResetCreditIdempotencyKey(
+ key.key_id,
+ responseData.active_idempotency_key,
+ generation,
+ )
+ }
+ showError(parseApiError(err, 'Codex 重置机会使用失败'))
+ } finally {
+ consumingCodexResetCreditKeyId.value = null
+ }
+}
+
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
const value = Number(raw ?? 0)
if (!Number.isFinite(value) || value <= 0) return null
@@ -2447,6 +2581,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
rate_multipliers: key.rate_multipliers ?? null,
internal_priority: key.internal_priority ?? 50,
rpm_limit: key.rpm_limit ?? null,
+ concurrent_limit: key.concurrent_limit ?? null,
allowed_models: key.allowed_models ?? null,
capabilities: key.capabilities ?? null,
cache_ttl_minutes: key.cache_ttl_minutes ?? 5,
@@ -3730,20 +3865,24 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
if (providerType === 'antigravity') {
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
if (windows.length === 0) return []
-
- const remainingPercents = windows
- .map(getQuotaWindowRemainingPercent)
- .filter((value): value is number => value != null)
- if (remainingPercents.length === 0) return []
-
- return [{
- label: '最低',
- remainingPercent: Math.min(...remainingPercents),
- detail: `${windows.length} 模型`,
- resetAtSeconds: null,
- resetSeconds: null,
- updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
- }]
+ const opaqueDisplayIndex = { value: 1 }
+ return dedupeAntigravityQuotaItemsByLabel(windows
+ .map((window): (QuotaProgressItem & { model: string, resetSeconds: number | null }) | null => {
+ const remainingPercent = getQuotaWindowRemainingPercent(window)
+ if (remainingPercent == null) return null
+ const model = String(window.model || window.code || '').trim().replace(/^model:/i, '')
+ return {
+ model,
+ label: resolveAntigravityQuotaLabel(model, window.label, opaqueDisplayIndex),
+ remainingPercent,
+ numericOnly: true,
+ resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quota.reset_at ?? null),
+ resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quota.reset_seconds ?? null),
+ updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
+ allowDynamicReset: true,
+ }
+ })
+ .filter((item): item is QuotaProgressItem & { model: string, resetSeconds: number | null } => item != null))
}
if (providerType === 'gemini_cli') {