mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 04:09:48 +08:00
fix(gateway): add provider key concurrency and cache affinity modes
This commit is contained in:
@@ -205,8 +205,8 @@ async fn available_balance_capacity_usd(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
|
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
|
||||||
Ok(match quota.as_ref() {
|
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(_) 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)),
|
Some(quota) => Some(quota.remaining_usd.max(0.0) + wallet_available_usd.unwrap_or(0.0)),
|
||||||
None if wallet_is_unlimited => None,
|
None if wallet_is_unlimited => None,
|
||||||
None => wallet_available_usd,
|
None => wallet_available_usd,
|
||||||
@@ -832,9 +832,10 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
execution_plan_balance_capacity_rejection, execution_plan_cost_upper_bound_cache_key,
|
available_balance_capacity_usd, execution_plan_balance_capacity_rejection,
|
||||||
max_output_tokens_from_request, openai_request_input_is_self_contained,
|
execution_plan_cost_upper_bound_cache_key, max_output_tokens_from_request,
|
||||||
output_choice_count_upper_bound, request_model_local_rejection, GatewayLocalAuthRejection,
|
openai_request_input_is_self_contained, output_choice_count_upper_bound,
|
||||||
|
request_model_local_rejection, GatewayLocalAuthRejection,
|
||||||
};
|
};
|
||||||
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
|
use crate::control::{GatewayControlAuthContext, GatewayControlDecision};
|
||||||
use crate::data::GatewayDataState;
|
use crate::data::GatewayDataState;
|
||||||
@@ -939,6 +940,14 @@ mod tests {
|
|||||||
fn state_with_quota_and_wallet(
|
fn state_with_quota_and_wallet(
|
||||||
quota: UserDailyQuotaAvailabilityRecord,
|
quota: UserDailyQuotaAvailabilityRecord,
|
||||||
context: StoredBillingModelContext,
|
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 {
|
) -> AppState {
|
||||||
let candidate_repository =
|
let candidate_repository =
|
||||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||||
@@ -952,7 +961,7 @@ mod tests {
|
|||||||
AppState::new()
|
AppState::new()
|
||||||
.expect("state should build")
|
.expect("state should build")
|
||||||
.with_data_state_for_tests(data)
|
.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 {
|
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]
|
#[tokio::test]
|
||||||
async fn positive_balance_does_not_allow_historical_invalid_processing_pricing() {
|
async fn positive_balance_does_not_allow_historical_invalid_processing_pricing() {
|
||||||
let context = billing_context_with_pricing(
|
let context = billing_context_with_pricing(
|
||||||
|
|||||||
@@ -1826,17 +1826,17 @@ fn pool_key_candidate_order_for_group(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let active_presets = ProviderPoolService::with_builtin_adapters()
|
let active_presets = ProviderPoolService::with_builtin_adapters()
|
||||||
.normalize_scheduling_presets(group.transport.provider.provider_type.as_str(), &presets)
|
.normalize_scheduling_presets(group.transport.provider.provider_type.as_str(), &presets);
|
||||||
.into_iter()
|
|
||||||
.map(|preset| preset.preset)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if let Some(distribution_mode) = active_presets
|
if let Some(distribution_mode) = active_presets
|
||||||
.iter()
|
.iter()
|
||||||
.find(|preset| pool_distribution_mode_preset(preset.as_str()))
|
.find(|preset| pool_distribution_mode_preset(preset.preset.as_str()))
|
||||||
.map(String::as_str)
|
|
||||||
{
|
{
|
||||||
return match distribution_mode {
|
return match distribution_mode.preset.as_str() {
|
||||||
"cache_affinity" => StoredPoolKeyCandidateOrder::CacheAffinity,
|
"cache_affinity" => match distribution_mode.mode.as_deref() {
|
||||||
|
Some("lru") => StoredPoolKeyCandidateOrder::Lru,
|
||||||
|
Some("single_account") => StoredPoolKeyCandidateOrder::SingleAccount,
|
||||||
|
_ => StoredPoolKeyCandidateOrder::CacheAffinity,
|
||||||
|
},
|
||||||
"load_balance" => StoredPoolKeyCandidateOrder::LoadBalance {
|
"load_balance" => StoredPoolKeyCandidateOrder::LoadBalance {
|
||||||
seed: pool_sort_seed(),
|
seed: pool_sort_seed(),
|
||||||
},
|
},
|
||||||
@@ -2151,7 +2151,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(
|
let key_a = sample_eligible_candidate(
|
||||||
"provider-pool",
|
"provider-pool",
|
||||||
"endpoint-1",
|
"endpoint-1",
|
||||||
@@ -2159,7 +2159,11 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"pool_advanced": {
|
"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,
|
10,
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"pool_advanced": {
|
"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]
|
#[test]
|
||||||
fn pool_scheduler_ignores_sticky_hit_without_cache_affinity() {
|
fn pool_scheduler_ignores_sticky_hit_without_cache_affinity() {
|
||||||
let key_a = sample_eligible_candidate(
|
let key_a = sample_eligible_candidate(
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ use crate::orchestration::{
|
|||||||
LocalOAuthSuccessEffect, LocalPoolErrorEffect,
|
LocalOAuthSuccessEffect, LocalPoolErrorEffect,
|
||||||
};
|
};
|
||||||
use crate::provider_pool_demand::{
|
use crate::provider_pool_demand::{
|
||||||
acquire_provider_pool_in_flight_guard, ProviderPoolInFlightGuard,
|
acquire_provider_pool_execution_guard, ProviderPoolInFlightAdmission, ProviderPoolInFlightGuard,
|
||||||
};
|
};
|
||||||
use crate::request_candidate_runtime::{
|
use crate::request_candidate_runtime::{
|
||||||
ensure_execution_request_candidate_slot, persist_local_request_candidate_status_record,
|
ensure_execution_request_candidate_slot, persist_local_request_candidate_status_record,
|
||||||
@@ -3772,6 +3772,41 @@ async fn execute_execution_runtime_stream_inner(
|
|||||||
plan_kind,
|
plan_kind,
|
||||||
report_context.as_ref(),
|
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
|
// Inline passthrough records its lifecycle seed after upstream headers are
|
||||||
// available. Avoid constructing a throwaway seed on the common path.
|
// available. Avoid constructing a throwaway seed on the common path.
|
||||||
let mut lifecycle_seed = (!defer_stream_pending_for_direct_inline)
|
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;
|
record_stream_pending_lifecycle(state, seed, &mut stage_trace).await;
|
||||||
lifecycle_pending_recorded = true;
|
lifecycle_pending_recorded = true;
|
||||||
}
|
}
|
||||||
let candidate_started_unix_secs = current_request_candidate_unix_ms();
|
|
||||||
if let Some(snapshot) = request_candidate_status_snapshot.clone() {
|
if let Some(snapshot) = request_candidate_status_snapshot.clone() {
|
||||||
record_local_request_candidate_status_snapshot(
|
record_local_request_candidate_status_snapshot(
|
||||||
state,
|
state,
|
||||||
@@ -3810,20 +3844,6 @@ async fn execute_execution_runtime_stream_inner(
|
|||||||
.and_then(|context| context.candidate_index)
|
.and_then(|context| context.candidate_index)
|
||||||
.map(|value| value.to_string())
|
.map(|value| value.to_string())
|
||||||
.unwrap_or_else(|| "-".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 {
|
match maybe_execute_grok_stream(&plan, report_context.as_ref()).await {
|
||||||
Ok(Some(grok_stream)) => {
|
Ok(Some(grok_stream)) => {
|
||||||
return execute_stream_from_frame_stream_with_retry_scope(
|
return execute_stream_from_frame_stream_with_retry_scope(
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ use crate::orchestration::{
|
|||||||
LocalExecutionEffectContext, LocalHealthFailureEffect, LocalHealthSuccessEffect,
|
LocalExecutionEffectContext, LocalHealthFailureEffect, LocalHealthSuccessEffect,
|
||||||
LocalOAuthInvalidationEffect, LocalOAuthSuccessEffect, LocalPoolErrorEffect,
|
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::{
|
use crate::request_candidate_runtime::{
|
||||||
ensure_execution_request_candidate_slot, record_local_request_candidate_extra_data,
|
ensure_execution_request_candidate_slot, record_local_request_candidate_extra_data,
|
||||||
record_local_request_candidate_status, record_local_request_candidate_status_snapshot,
|
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());
|
.unwrap_or_else(|| "-".to_string());
|
||||||
let candidate_started_at = Instant::now();
|
let candidate_started_at = Instant::now();
|
||||||
let candidate_started_unix_secs = current_request_candidate_unix_ms();
|
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 lifecycle_seed = build_lifecycle_usage_seed(&plan, report_context.as_ref());
|
||||||
let usage_data = state.usage_lifecycle_data_state().as_ref().clone();
|
let usage_data = state.usage_lifecycle_data_state().as_ref().clone();
|
||||||
state
|
state
|
||||||
@@ -2024,14 +2052,6 @@ async fn execute_execution_runtime_sync_impl(
|
|||||||
candidate_started_at,
|
candidate_started_at,
|
||||||
);
|
);
|
||||||
let result = (async {
|
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(
|
record_sync_execution_active(
|
||||||
state,
|
state,
|
||||||
&plan,
|
&plan,
|
||||||
|
|||||||
@@ -167,6 +167,14 @@ fn parse_pool_score_rules(pool_advanced: &Map<String, Value>) -> PoolMemberScore
|
|||||||
|
|
||||||
fn normalize_pool_preset_mode(preset: &str, raw_mode: Option<&Value>) -> Option<String> {
|
fn normalize_pool_preset_mode(preset: &str, raw_mode: Option<&Value>) -> Option<String> {
|
||||||
match preset {
|
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" => {
|
"free_team_first" | "free_first" | "team_first" | "plus_first" | "pro_first" => {
|
||||||
let default_mode = match preset {
|
let default_mode = match preset {
|
||||||
"free_team_first" => "both",
|
"free_team_first" => "both",
|
||||||
|
|||||||
@@ -1342,6 +1342,7 @@ pub(super) fn build_admin_pool_key_payload(
|
|||||||
json!(key.internal_priority),
|
json!(key.internal_priority),
|
||||||
);
|
);
|
||||||
payload.insert("rpm_limit".to_string(), json!(key.rpm_limit));
|
payload.insert("rpm_limit".to_string(), json!(key.rpm_limit));
|
||||||
|
payload.insert("concurrent_limit".to_string(), json!(key.concurrent_limit));
|
||||||
payload.insert(
|
payload.insert(
|
||||||
"cache_ttl_minutes".to_string(),
|
"cache_ttl_minutes".to_string(),
|
||||||
json!(key.cache_ttl_minutes),
|
json!(key.cache_ttl_minutes),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use aether_contracts::ExecutionPlan;
|
|||||||
|
|
||||||
use crate::execution_runtime::acquire_upstream_execution_gate;
|
use crate::execution_runtime::acquire_upstream_execution_gate;
|
||||||
use crate::provider_pool_demand::{
|
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::upstream_admission::UpstreamTargetAdmissionPermit;
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
@@ -41,14 +41,17 @@ impl ResponsesWebSocketTurnAdmission {
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let provider_pool = acquire_provider_pool_in_flight_guard(
|
let provider_pool = match acquire_provider_pool_execution_guard(state, plan).await? {
|
||||||
state.runtime_state.clone(),
|
ProviderPoolInFlightAdmission::Acquired(guard) => guard,
|
||||||
&plan.provider_id,
|
ProviderPoolInFlightAdmission::Saturated { limit } => {
|
||||||
&plan.request_id,
|
drop(upstream_target);
|
||||||
plan.candidate_id.as_deref(),
|
drop(upstream_execution);
|
||||||
&plan.key_id,
|
return Err(GatewayError::Client {
|
||||||
)
|
status: http::StatusCode::TOO_MANY_REQUESTS,
|
||||||
.await;
|
message: format!("上游账号并发已达上限 ({limit})"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
upstream_execution,
|
upstream_execution,
|
||||||
|
|||||||
@@ -256,12 +256,16 @@ fn auth_config_has_refresh_token(auth_config: Option<&str>) -> bool {
|
|||||||
let Ok(value) = serde_json::from_str::<Value>(auth_config) else {
|
let Ok(value) = serde_json::from_str::<Value>(auth_config) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
value
|
let Some(object) = value.as_object() else {
|
||||||
.as_object()
|
return false;
|
||||||
.and_then(|object| object.get("refresh_token"))
|
};
|
||||||
.and_then(Value::as_str)
|
["refresh_token", "refreshToken"].iter().any(|field| {
|
||||||
.map(str::trim)
|
object
|
||||||
.is_some_and(|value| !value.is_empty())
|
.get(*field)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_unix_secs() -> u64 {
|
fn now_unix_secs() -> u64 {
|
||||||
@@ -273,7 +277,44 @@ fn now_unix_secs() -> u64 {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn pending_agent_identity_without_task_is_recoverable() {
|
fn pending_agent_identity_without_task_is_recoverable() {
|
||||||
|
|||||||
@@ -90,11 +90,15 @@ pub(crate) fn provider_key_can_refresh_oauth(
|
|||||||
) -> bool {
|
) -> bool {
|
||||||
auth_semantics.can_refresh_oauth()
|
auth_semantics.can_refresh_oauth()
|
||||||
&& (provider_key_auth_config_is_agent_identity(provider_type, auth_config)
|
&& (provider_key_auth_config_is_agent_identity(provider_type, auth_config)
|
||||||
|| auth_config
|
|| auth_config.is_some_and(|config| {
|
||||||
.and_then(|config| config.get("refresh_token"))
|
["refresh_token", "refreshToken"].iter().any(|field| {
|
||||||
.and_then(Value::as_str)
|
config
|
||||||
.map(str::trim)
|
.get(*field)
|
||||||
.is_some_and(|value| !value.is_empty()))
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
})
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn provider_key_can_export_oauth(
|
pub(crate) fn provider_key_can_export_oauth(
|
||||||
@@ -425,6 +429,11 @@ mod tests {
|
|||||||
"codex",
|
"codex",
|
||||||
json!({ "refresh_token": "refresh-token" }).as_object()
|
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(
|
assert!(provider_key_can_refresh_oauth(
|
||||||
semantics,
|
semantics,
|
||||||
"codex",
|
"codex",
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ use std::sync::{
|
|||||||
};
|
};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
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 dashmap::DashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{AppState, GatewayError};
|
||||||
|
|
||||||
const PROVIDER_POOL_IN_FLIGHT_TOKENS_PREFIX: &str = "ap:provider_pool:in_flight";
|
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_DEMAND_SNAPSHOT_PREFIX: &str = "ap:provider_pool:demand";
|
||||||
const PROVIDER_POOL_BURST_PENDING_PREFIX: &str = "ap:quota_probe:burst_pending";
|
const PROVIDER_POOL_BURST_PENDING_PREFIX: &str = "ap:quota_probe:burst_pending";
|
||||||
const PROVIDER_POOL_IN_FLIGHT_TOKEN_TTL_MS: u64 = 120_000;
|
const PROVIDER_POOL_IN_FLIGHT_TOKEN_TTL_MS: u64 = 120_000;
|
||||||
@@ -42,10 +48,17 @@ pub(crate) struct ProviderPoolDemandSnapshot {
|
|||||||
|
|
||||||
pub(crate) struct ProviderPoolInFlightGuard {
|
pub(crate) struct ProviderPoolInFlightGuard {
|
||||||
kind: ProviderPoolInFlightGuardKind,
|
kind: ProviderPoolInFlightGuardKind,
|
||||||
|
provider_key_permit: Option<RuntimeSemaphorePermit>,
|
||||||
released: bool,
|
released: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) enum ProviderPoolInFlightAdmission {
|
||||||
|
Acquired(Option<ProviderPoolInFlightGuard>),
|
||||||
|
Saturated { limit: usize },
|
||||||
|
}
|
||||||
|
|
||||||
enum ProviderPoolInFlightGuardKind {
|
enum ProviderPoolInFlightGuardKind {
|
||||||
|
Disabled,
|
||||||
Local {
|
Local {
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
counter: Arc<AtomicUsize>,
|
counter: Arc<AtomicUsize>,
|
||||||
@@ -69,6 +82,9 @@ impl ProviderPoolInFlightGuard {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match &mut self.kind {
|
match &mut self.kind {
|
||||||
|
ProviderPoolInFlightGuardKind::Disabled => {
|
||||||
|
self.released = true;
|
||||||
|
}
|
||||||
ProviderPoolInFlightGuardKind::Local {
|
ProviderPoolInFlightGuardKind::Local {
|
||||||
provider_id,
|
provider_id,
|
||||||
counter,
|
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;
|
self.released = true;
|
||||||
match &mut self.kind {
|
match &mut self.kind {
|
||||||
|
ProviderPoolInFlightGuardKind::Disabled => {}
|
||||||
ProviderPoolInFlightGuardKind::Local {
|
ProviderPoolInFlightGuardKind::Local {
|
||||||
provider_id,
|
provider_id,
|
||||||
counter,
|
counter,
|
||||||
@@ -290,32 +317,83 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard(
|
|||||||
candidate_id: Option<&str>,
|
candidate_id: Option<&str>,
|
||||||
key_id: &str,
|
key_id: &str,
|
||||||
) -> Option<ProviderPoolInFlightGuard> {
|
) -> Option<ProviderPoolInFlightGuard> {
|
||||||
|
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<RuntimeState>,
|
||||||
|
provider_id: &str,
|
||||||
|
request_id: &str,
|
||||||
|
candidate_id: Option<&str>,
|
||||||
|
key_id: &str,
|
||||||
|
concurrent_limit: Option<usize>,
|
||||||
|
) -> Result<Option<ProviderPoolInFlightGuard>, RuntimeSemaphoreError> {
|
||||||
|
let provider_key_permit = match concurrent_limit.filter(|limit| *limit > 0) {
|
||||||
|
Some(limit) => Some(
|
||||||
|
runtime
|
||||||
|
.keyed_semaphore(
|
||||||
|
PROVIDER_KEY_CONCURRENCY_GATE,
|
||||||
|
key_id,
|
||||||
|
limit,
|
||||||
|
RuntimeSemaphoreConfig::default(),
|
||||||
|
)?
|
||||||
|
.try_acquire()
|
||||||
|
.await?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
let provider_id = provider_id.trim();
|
let provider_id = provider_id.trim();
|
||||||
if provider_id.is_empty() {
|
if provider_id.is_empty() {
|
||||||
return None;
|
return Ok(
|
||||||
|
provider_key_permit.map(|provider_key_permit| ProviderPoolInFlightGuard {
|
||||||
|
kind: ProviderPoolInFlightGuardKind::Disabled,
|
||||||
|
provider_key_permit: Some(provider_key_permit),
|
||||||
|
released: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
match provider_pool_in_flight_mode() {
|
match provider_pool_in_flight_mode() {
|
||||||
ProviderPoolInFlightMode::Off => return None,
|
ProviderPoolInFlightMode::Off => {
|
||||||
|
return Ok(
|
||||||
|
provider_key_permit.map(|provider_key_permit| ProviderPoolInFlightGuard {
|
||||||
|
kind: ProviderPoolInFlightGuardKind::Disabled,
|
||||||
|
provider_key_permit: Some(provider_key_permit),
|
||||||
|
released: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
ProviderPoolInFlightMode::Local => {
|
ProviderPoolInFlightMode::Local => {
|
||||||
let counter = increment_local_provider_in_flight(provider_id);
|
let counter = increment_local_provider_in_flight(provider_id);
|
||||||
return Some(ProviderPoolInFlightGuard {
|
return Ok(Some(ProviderPoolInFlightGuard {
|
||||||
kind: ProviderPoolInFlightGuardKind::Local {
|
kind: ProviderPoolInFlightGuardKind::Local {
|
||||||
provider_id: provider_id.to_string(),
|
provider_id: provider_id.to_string(),
|
||||||
counter,
|
counter,
|
||||||
},
|
},
|
||||||
|
provider_key_permit,
|
||||||
released: false,
|
released: false,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
ProviderPoolInFlightMode::Runtime if runtime.is_memory() => {
|
ProviderPoolInFlightMode::Runtime if runtime.is_memory() => {
|
||||||
let counter = increment_local_provider_in_flight(provider_id);
|
let counter = increment_local_provider_in_flight(provider_id);
|
||||||
return Some(ProviderPoolInFlightGuard {
|
return Ok(Some(ProviderPoolInFlightGuard {
|
||||||
kind: ProviderPoolInFlightGuardKind::Local {
|
kind: ProviderPoolInFlightGuardKind::Local {
|
||||||
provider_id: provider_id.to_string(),
|
provider_id: provider_id.to_string(),
|
||||||
counter,
|
counter,
|
||||||
},
|
},
|
||||||
|
provider_key_permit,
|
||||||
released: false,
|
released: false,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
ProviderPoolInFlightMode::Runtime => {}
|
ProviderPoolInFlightMode::Runtime => {}
|
||||||
}
|
}
|
||||||
@@ -335,7 +413,13 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard(
|
|||||||
error = ?err,
|
error = ?err,
|
||||||
"gateway provider pool demand: failed to acquire in-flight token"
|
"gateway provider pool demand: failed to acquire in-flight token"
|
||||||
);
|
);
|
||||||
return None;
|
return Ok(
|
||||||
|
provider_key_permit.map(|provider_key_permit| ProviderPoolInFlightGuard {
|
||||||
|
kind: ProviderPoolInFlightGuardKind::Disabled,
|
||||||
|
provider_key_permit: Some(provider_key_permit),
|
||||||
|
released: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -343,7 +427,13 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard(
|
|||||||
timeout_ms = provider_pool_in_flight_acquire_timeout().as_millis() as u64,
|
timeout_ms = provider_pool_in_flight_acquire_timeout().as_millis() as u64,
|
||||||
"gateway provider pool demand: skipped in-flight token after acquire timeout"
|
"gateway provider pool demand: skipped in-flight token after acquire timeout"
|
||||||
);
|
);
|
||||||
return None;
|
return Ok(
|
||||||
|
provider_key_permit.map(|provider_key_permit| ProviderPoolInFlightGuard {
|
||||||
|
kind: ProviderPoolInFlightGuardKind::Disabled,
|
||||||
|
provider_key_permit: Some(provider_key_permit),
|
||||||
|
released: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +445,7 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard(
|
|||||||
stop_renewal.clone(),
|
stop_renewal.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Some(ProviderPoolInFlightGuard {
|
Ok(Some(ProviderPoolInFlightGuard {
|
||||||
kind: ProviderPoolInFlightGuardKind::Runtime {
|
kind: ProviderPoolInFlightGuardKind::Runtime {
|
||||||
runtime,
|
runtime,
|
||||||
tokens_key,
|
tokens_key,
|
||||||
@@ -363,8 +453,39 @@ pub(crate) async fn acquire_provider_pool_in_flight_guard(
|
|||||||
stop_renewal,
|
stop_renewal,
|
||||||
renew_handle: Some(renew_handle),
|
renew_handle: Some(renew_handle),
|
||||||
},
|
},
|
||||||
|
provider_key_permit,
|
||||||
released: false,
|
released: false,
|
||||||
})
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn acquire_provider_pool_execution_guard(
|
||||||
|
state: &AppState,
|
||||||
|
plan: &ExecutionPlan,
|
||||||
|
) -> Result<ProviderPoolInFlightAdmission, GatewayError> {
|
||||||
|
let concurrent_limit = state
|
||||||
|
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|key| key.id == plan.key_id)
|
||||||
|
.and_then(|key| key.concurrent_limit)
|
||||||
|
.filter(|limit| *limit > 0)
|
||||||
|
.and_then(|limit| usize::try_from(limit).ok());
|
||||||
|
match acquire_provider_pool_in_flight_guard_with_key_limit(
|
||||||
|
state.runtime_state.clone(),
|
||||||
|
&plan.provider_id,
|
||||||
|
&plan.request_id,
|
||||||
|
plan.candidate_id.as_deref(),
|
||||||
|
&plan.key_id,
|
||||||
|
concurrent_limit,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(guard) => Ok(ProviderPoolInFlightAdmission::Acquired(guard)),
|
||||||
|
Err(RuntimeSemaphoreError::Saturated { limit, .. }) => {
|
||||||
|
Ok(ProviderPoolInFlightAdmission::Saturated { limit })
|
||||||
|
}
|
||||||
|
Err(error) => Err(GatewayError::Internal(error.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn provider_pool_live_in_flight_count(
|
pub(crate) async fn provider_pool_live_in_flight_count(
|
||||||
@@ -586,6 +707,50 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_key_limit_rejects_concurrent_guard_until_release() {
|
||||||
|
let runtime = Arc::new(RuntimeState::memory(MemoryRuntimeStateConfig::default()));
|
||||||
|
let first = acquire_provider_pool_in_flight_guard_with_key_limit(
|
||||||
|
runtime.clone(),
|
||||||
|
"provider-limit",
|
||||||
|
"request-1",
|
||||||
|
Some("candidate-1"),
|
||||||
|
"key-limit",
|
||||||
|
Some(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("first admission should resolve")
|
||||||
|
.expect("first guard should be acquired");
|
||||||
|
|
||||||
|
let second = acquire_provider_pool_in_flight_guard_with_key_limit(
|
||||||
|
runtime.clone(),
|
||||||
|
"provider-limit",
|
||||||
|
"request-2",
|
||||||
|
Some("candidate-2"),
|
||||||
|
"key-limit",
|
||||||
|
Some(1),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(
|
||||||
|
second,
|
||||||
|
Err(RuntimeSemaphoreError::Saturated { limit: 1, .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
first.release().await;
|
||||||
|
let replacement = acquire_provider_pool_in_flight_guard_with_key_limit(
|
||||||
|
runtime,
|
||||||
|
"provider-limit",
|
||||||
|
"request-3",
|
||||||
|
Some("candidate-3"),
|
||||||
|
"key-limit",
|
||||||
|
Some(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("replacement admission should resolve")
|
||||||
|
.expect("replacement guard should acquire after release");
|
||||||
|
drop(replacement);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn demand_snapshot_uses_instant_in_flight_for_fast_rise_and_ema_for_fall() {
|
async fn demand_snapshot_uses_instant_in_flight_for_fast_rise_and_ema_for_fall() {
|
||||||
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ use std::sync::atomic::Ordering;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use aether_crypto::encrypt_python_fernet_plaintext;
|
use aether_crypto::{
|
||||||
|
decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
};
|
||||||
|
|
||||||
const LOCAL_OAUTH_HTTP_TIMEOUT_MS: u64 = 30_000;
|
const LOCAL_OAUTH_HTTP_TIMEOUT_MS: u64 = 30_000;
|
||||||
const REMOTE_OAUTH_REFRESH_WAIT_TIMEOUT: Duration = Duration::from_secs(35);
|
const REMOTE_OAUTH_REFRESH_WAIT_TIMEOUT: Duration = Duration::from_secs(35);
|
||||||
@@ -3395,7 +3397,10 @@ mod tests {
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
use aether_crypto::{
|
||||||
|
decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext,
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
};
|
||||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
ProviderCatalogKeyAdminCasUpdate, ProviderCatalogKeyListQuery,
|
ProviderCatalogKeyAdminCasUpdate, ProviderCatalogKeyListQuery,
|
||||||
@@ -3478,9 +3483,17 @@ mod tests {
|
|||||||
fn codex_oauth_state(
|
fn codex_oauth_state(
|
||||||
auth_config: &serde_json::Value,
|
auth_config: &serde_json::Value,
|
||||||
access_token: &str,
|
access_token: &str,
|
||||||
|
) -> (AppState, Arc<InMemoryProviderCatalogReadRepository>, String) {
|
||||||
|
provider_oauth_state("codex", auth_config, access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_oauth_state(
|
||||||
|
provider_type: &str,
|
||||||
|
auth_config: &serde_json::Value,
|
||||||
|
access_token: &str,
|
||||||
) -> (AppState, Arc<InMemoryProviderCatalogReadRepository>, String) {
|
) -> (AppState, Arc<InMemoryProviderCatalogReadRepository>, String) {
|
||||||
let mut provider = sample_provider();
|
let mut provider = sample_provider();
|
||||||
provider.provider_type = "codex".to_string();
|
provider.provider_type = provider_type.to_string();
|
||||||
let encrypted_auth_config =
|
let encrypted_auth_config =
|
||||||
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, &auth_config.to_string())
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, &auth_config.to_string())
|
||||||
.expect("auth config should encrypt");
|
.expect("auth config should encrypt");
|
||||||
@@ -3490,7 +3503,7 @@ mod tests {
|
|||||||
let key = StoredProviderCatalogKey::new(
|
let key = StoredProviderCatalogKey::new(
|
||||||
"key-1".to_string(),
|
"key-1".to_string(),
|
||||||
"provider-1".to_string(),
|
"provider-1".to_string(),
|
||||||
"Codex OAuth".to_string(),
|
format!("{provider_type} OAuth"),
|
||||||
"oauth".to_string(),
|
"oauth".to_string(),
|
||||||
None,
|
None,
|
||||||
true,
|
true,
|
||||||
@@ -4564,6 +4577,78 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn antigravity_refresh_entry_persists_tokens_and_expiry() {
|
||||||
|
let initial_config = json!({
|
||||||
|
"provider_type": "antigravity",
|
||||||
|
"refreshToken": "legacy-refresh-token",
|
||||||
|
"expires_at": 1,
|
||||||
|
});
|
||||||
|
let (state, repository, _) =
|
||||||
|
provider_oauth_state("antigravity", &initial_config, "stale-access-token");
|
||||||
|
let transport = state
|
||||||
|
.read_provider_transport_snapshot("provider-1", "endpoint-1", "key-1")
|
||||||
|
.await
|
||||||
|
.expect("transport should load")
|
||||||
|
.expect("transport should exist");
|
||||||
|
let expected_credential_fence = state
|
||||||
|
.capture_provider_transport_credential_fence(&transport)
|
||||||
|
.await
|
||||||
|
.expect("credential fence should load")
|
||||||
|
.expect("credential fence should match");
|
||||||
|
let expires_at = 4_102_555_900;
|
||||||
|
let refreshed_entry = crate::provider_transport::CachedOAuthEntry {
|
||||||
|
provider_type: "antigravity".to_string(),
|
||||||
|
auth_header_name: "authorization".to_string(),
|
||||||
|
auth_header_value: "Bearer fresh-access-token".to_string(),
|
||||||
|
expires_at_unix_secs: Some(expires_at),
|
||||||
|
metadata: Some(json!({
|
||||||
|
"provider_type": "antigravity",
|
||||||
|
"refresh_token": "legacy-refresh-token",
|
||||||
|
"expires_at": expires_at,
|
||||||
|
})),
|
||||||
|
source_fingerprint: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.persist_local_oauth_refresh_entry(
|
||||||
|
&transport,
|
||||||
|
&refreshed_entry,
|
||||||
|
Some(&expected_credential_fence),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Antigravity refresh should persist");
|
||||||
|
|
||||||
|
let stored = repository
|
||||||
|
.list_keys_by_ids(&["key-1".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("key should reload")
|
||||||
|
.pop()
|
||||||
|
.expect("key should remain");
|
||||||
|
let access_token = decrypt_python_fernet_ciphertext(
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
stored
|
||||||
|
.encrypted_api_key
|
||||||
|
.as_deref()
|
||||||
|
.expect("access token should persist"),
|
||||||
|
)
|
||||||
|
.expect("access token should decrypt");
|
||||||
|
let auth_config = decrypt_python_fernet_ciphertext(
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
stored
|
||||||
|
.encrypted_auth_config
|
||||||
|
.as_deref()
|
||||||
|
.expect("auth config should persist"),
|
||||||
|
)
|
||||||
|
.expect("auth config should decrypt");
|
||||||
|
let auth_config: serde_json::Value =
|
||||||
|
serde_json::from_str(&auth_config).expect("auth config should parse");
|
||||||
|
|
||||||
|
assert_eq!(access_token, "fresh-access-token");
|
||||||
|
assert_eq!(auth_config["refresh_token"], json!("legacy-refresh-token"));
|
||||||
|
assert_eq!(stored.expires_at_unix_secs, Some(expires_at));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn agent_auth_config_fence_rejects_metadata_only_rewrite() {
|
async fn agent_auth_config_fence_rejects_metadata_only_rewrite() {
|
||||||
let initial_config = json!({
|
let initial_config = json!({
|
||||||
|
|||||||
@@ -629,6 +629,7 @@ async fn gateway_pool_list_includes_usage_totals_and_nullable_lru_score() {
|
|||||||
"sk-usage",
|
"sk-usage",
|
||||||
);
|
);
|
||||||
key.name = "usage key".to_string();
|
key.name = "usage key".to_string();
|
||||||
|
key.concurrent_limit = Some(5);
|
||||||
key.request_count = Some(1566);
|
key.request_count = Some(1566);
|
||||||
key.total_tokens = 187_327_321;
|
key.total_tokens = 187_327_321;
|
||||||
key.total_cost_usd = 93.1319297;
|
key.total_cost_usd = 93.1319297;
|
||||||
@@ -661,6 +662,7 @@ async fn gateway_pool_list_includes_usage_totals_and_nullable_lru_score() {
|
|||||||
.expect("json body should parse");
|
.expect("json body should parse");
|
||||||
let keys = payload["keys"].as_array().expect("keys should be array");
|
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||||
assert_eq!(keys.len(), 1);
|
assert_eq!(keys.len(), 1);
|
||||||
|
assert_eq!(keys[0]["concurrent_limit"], json!(5));
|
||||||
assert_eq!(keys[0]["request_count"], json!(1566));
|
assert_eq!(keys[0]["request_count"], json!(1566));
|
||||||
assert_eq!(keys[0]["total_tokens"], json!(187_327_321u64));
|
assert_eq!(keys[0]["total_tokens"], json!(187_327_321u64));
|
||||||
assert_eq!(keys[0]["total_cost_usd"], json!("93.13192970"));
|
assert_eq!(keys[0]["total_cost_usd"], json!("93.13192970"));
|
||||||
@@ -3643,6 +3645,7 @@ async fn gateway_batch_updates_shared_pool_key_configuration() {
|
|||||||
"api_formats": ["openai:responses"],
|
"api_formats": ["openai:responses"],
|
||||||
"internal_priority": 7,
|
"internal_priority": 7,
|
||||||
"rpm_limit": null,
|
"rpm_limit": null,
|
||||||
|
"concurrent_limit": 6,
|
||||||
"auto_fetch_models": false,
|
"auto_fetch_models": false,
|
||||||
"allowed_models": ["gpt-5.6-sol", "gpt-5.6-luna"],
|
"allowed_models": ["gpt-5.6-sol", "gpt-5.6-luna"],
|
||||||
"locked_models": [],
|
"locked_models": [],
|
||||||
@@ -3668,6 +3671,7 @@ async fn gateway_batch_updates_shared_pool_key_configuration() {
|
|||||||
assert_eq!(key.allow_auth_channel_mismatch_formats, Some(json!([])));
|
assert_eq!(key.allow_auth_channel_mismatch_formats, Some(json!([])));
|
||||||
assert_eq!(key.internal_priority, 7);
|
assert_eq!(key.internal_priority, 7);
|
||||||
assert_eq!(key.rpm_limit, None);
|
assert_eq!(key.rpm_limit, None);
|
||||||
|
assert_eq!(key.concurrent_limit, Some(6));
|
||||||
assert_eq!(key.learned_rpm_limit, None);
|
assert_eq!(key.learned_rpm_limit, None);
|
||||||
assert!(!key.auto_fetch_models);
|
assert!(!key.auto_fetch_models);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ async fn resolve_wallet_auth_gate_with_cache(
|
|||||||
None => WalletAccessDecision::wallet_unavailable(None),
|
None => WalletAccessDecision::wallet_unavailable(None),
|
||||||
};
|
};
|
||||||
if !auth_snapshot.api_key_is_standalone {
|
if !auth_snapshot.api_key_is_standalone {
|
||||||
|
let wallet_is_unlimited = wallet
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
|
||||||
let quota = if use_cache {
|
let quota = if use_cache {
|
||||||
state
|
state
|
||||||
.find_user_daily_quota_availability_for_auth(&auth_snapshot.user_id)
|
.find_user_daily_quota_availability_for_auth(&auth_snapshot.user_id)
|
||||||
@@ -71,7 +74,11 @@ async fn resolve_wallet_auth_gate_with_cache(
|
|||||||
quota.remaining_usd,
|
quota.remaining_usd,
|
||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
if decision.failure.is_none() && !quota.allow_wallet_overage && !has_remaining_quota {
|
if !wallet_is_unlimited
|
||||||
|
&& decision.failure.is_none()
|
||||||
|
&& !quota.allow_wallet_overage
|
||||||
|
&& !has_remaining_quota
|
||||||
|
{
|
||||||
return Ok(Some(WalletAccessDecision::balance_denied(Some(0.0))));
|
return Ok(Some(WalletAccessDecision::balance_denied(Some(0.0))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,6 +271,23 @@ mod tests {
|
|||||||
assert_eq!(decision.remaining, Some(4.0));
|
assert_eq!(decision.remaining, Some(4.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unlimited_wallet_ignores_exhausted_non_overage_quota() {
|
||||||
|
let mut wallet = empty_user_wallet();
|
||||||
|
wallet.limit_mode = "unlimited".to_string();
|
||||||
|
let state = state_with_wallet_and_quota(wallet, Some(quota_availability(10.0, 0.0, false)));
|
||||||
|
let auth_snapshot = ordinary_user_api_key_snapshot();
|
||||||
|
|
||||||
|
let decision = resolve_wallet_auth_gate(&state, &auth_snapshot)
|
||||||
|
.await
|
||||||
|
.expect("wallet gate should resolve")
|
||||||
|
.expect("wallet gate should return a decision");
|
||||||
|
|
||||||
|
assert!(decision.allowed);
|
||||||
|
assert_eq!(decision.failure, None);
|
||||||
|
assert_eq!(decision.remaining, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn disabled_auth_capacity_cache_still_gates_wallet_reads() {
|
async fn disabled_auth_capacity_cache_still_gates_wallet_reads() {
|
||||||
let mut state = state_with_wallet_and_quota(empty_user_wallet(), None);
|
let mut state = state_with_wallet_and_quota(empty_user_wallet(), None);
|
||||||
|
|||||||
@@ -38,7 +38,17 @@ impl ProviderOAuthAdapter for AntigravityProviderOAuthAdapter {
|
|||||||
state: &str,
|
state: &str,
|
||||||
code_challenge: Option<&str>,
|
code_challenge: Option<&str>,
|
||||||
) -> Result<crate::core::OAuthAuthorizeResponse, crate::core::OAuthError> {
|
) -> Result<crate::core::OAuthAuthorizeResponse, crate::core::OAuthError> {
|
||||||
self.inner.build_authorize_url(ctx, state, code_challenge)
|
let mut response = self.inner.build_authorize_url(ctx, state, code_challenge)?;
|
||||||
|
let mut url = url::Url::parse(&response.authorize_url).map_err(|_| {
|
||||||
|
crate::core::OAuthError::invalid_request("authorize_url must be absolute")
|
||||||
|
})?;
|
||||||
|
{
|
||||||
|
let mut query = url.query_pairs_mut();
|
||||||
|
query.append_pair("access_type", "offline");
|
||||||
|
query.append_pair("prompt", "consent");
|
||||||
|
}
|
||||||
|
response.authorize_url = url.to_string();
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn exchange_code(
|
async fn exchange_code(
|
||||||
@@ -112,20 +122,8 @@ mod tests {
|
|||||||
|
|
||||||
struct UnusedExecutor;
|
struct UnusedExecutor;
|
||||||
|
|
||||||
#[async_trait]
|
fn transport_context() -> ProviderOAuthTransportContext {
|
||||||
impl OAuthHttpExecutor for UnusedExecutor {
|
ProviderOAuthTransportContext {
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
_request: OAuthHttpRequest,
|
|
||||||
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
|
|
||||||
unreachable!("metadata probe should not execute network requests")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn antigravity_probe_marks_forbidden_metadata_invalid() {
|
|
||||||
let adapter = AntigravityProviderOAuthAdapter::default();
|
|
||||||
let ctx = ProviderOAuthTransportContext {
|
|
||||||
provider_id: String::new(),
|
provider_id: String::new(),
|
||||||
provider_type: "antigravity".to_string(),
|
provider_type: "antigravity".to_string(),
|
||||||
endpoint_id: None,
|
endpoint_id: None,
|
||||||
@@ -137,7 +135,46 @@ mod tests {
|
|||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
key_config: None,
|
key_config: None,
|
||||||
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
network: crate::network::OAuthNetworkContext::provider_operation(None),
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl OAuthHttpExecutor for UnusedExecutor {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_request: OAuthHttpRequest,
|
||||||
|
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
|
||||||
|
unreachable!("metadata probe should not execute network requests")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn antigravity_authorize_requests_offline_refresh_token() {
|
||||||
|
let adapter = AntigravityProviderOAuthAdapter::default();
|
||||||
|
let response = adapter
|
||||||
|
.build_authorize_url(&transport_context(), "state-1", Some("challenge-1"))
|
||||||
|
.expect("authorize url should build");
|
||||||
|
let url = url::Url::parse(&response.authorize_url).expect("authorize url should parse");
|
||||||
|
let query = url.query_pairs().collect::<BTreeMap<_, _>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
query.get("access_type").map(|value| value.as_ref()),
|
||||||
|
Some("offline")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
query.get("prompt").map(|value| value.as_ref()),
|
||||||
|
Some("consent")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
query.get("code_challenge").map(|value| value.as_ref()),
|
||||||
|
Some("challenge-1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn antigravity_probe_marks_forbidden_metadata_invalid() {
|
||||||
|
let adapter = AntigravityProviderOAuthAdapter::default();
|
||||||
|
let ctx = transport_context();
|
||||||
let account = ProviderOAuthAccount {
|
let account = ProviderOAuthAccount {
|
||||||
provider_type: "antigravity".to_string(),
|
provider_type: "antigravity".to_string(),
|
||||||
access_token: "access-token".to_string(),
|
access_token: "access-token".to_string(),
|
||||||
|
|||||||
@@ -374,10 +374,9 @@ impl ProviderOAuthAdapter for GenericProviderOAuthAdapter {
|
|||||||
ctx: &ProviderOAuthTransportContext,
|
ctx: &ProviderOAuthTransportContext,
|
||||||
account: &ProviderOAuthAccount,
|
account: &ProviderOAuthAccount,
|
||||||
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
) -> Result<ProviderOAuthTokenSet, OAuthError> {
|
||||||
let refresh_token = account
|
let refresh_token = ["refresh_token", "refreshToken"]
|
||||||
.auth_config
|
.iter()
|
||||||
.get("refresh_token")
|
.find_map(|field| account.auth_config.get(*field).and_then(Value::as_str))
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or_else(|| OAuthError::invalid_request("auth_config missing refresh_token"))?;
|
.ok_or_else(|| OAuthError::invalid_request("auth_config missing refresh_token"))?;
|
||||||
|
|||||||
@@ -94,14 +94,7 @@ pub fn build_admin_pool_scheduling_presets_payload() -> Value {
|
|||||||
"依据 LRU 时间戳(最近未使用优先)",
|
"依据 LRU 时间戳(最近未使用优先)",
|
||||||
&service,
|
&service,
|
||||||
),
|
),
|
||||||
provider_pool_preset_payload(
|
cache_affinity_preset_payload(&service),
|
||||||
"cache_affinity",
|
|
||||||
"缓存亲和",
|
|
||||||
"优先复用最近使用过的 Key,利用 Prompt Caching",
|
|
||||||
None,
|
|
||||||
"依据 LRU 时间戳(最近使用优先,与 LRU 轮转相反)",
|
|
||||||
&service,
|
|
||||||
),
|
|
||||||
provider_pool_preset_payload(
|
provider_pool_preset_payload(
|
||||||
"cost_first",
|
"cost_first",
|
||||||
"成本优先",
|
"成本优先",
|
||||||
@@ -220,6 +213,23 @@ fn legacy_free_team_first_preset_payload(service: &ProviderPoolService) -> Value
|
|||||||
payload
|
payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cache_affinity_preset_payload(service: &ProviderPoolService) -> Value {
|
||||||
|
let mut payload = provider_pool_preset_payload(
|
||||||
|
"cache_affinity",
|
||||||
|
"缓存亲和",
|
||||||
|
"同一用户持续复用 Key,首次分配可集中或轮转",
|
||||||
|
None,
|
||||||
|
"先复用用户粘性 Key,未命中时按所选二级模式分配",
|
||||||
|
service,
|
||||||
|
);
|
||||||
|
payload["modes"] = json!([
|
||||||
|
{"value": "single_account", "label": "单号优先"},
|
||||||
|
{"value": "lru", "label": "LRU 轮号"}
|
||||||
|
]);
|
||||||
|
payload["default_mode"] = json!("single_account");
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
fn provider_pool_preset_payload(
|
fn provider_pool_preset_payload(
|
||||||
name: &'static str,
|
name: &'static str,
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
@@ -274,3 +284,29 @@ fn provider_pool_preset_mutex_group(preset: &str) -> Option<&'static str> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::build_admin_pool_scheduling_presets_payload;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_affinity_preset_exposes_secondary_distribution_modes() {
|
||||||
|
let payload = build_admin_pool_scheduling_presets_payload();
|
||||||
|
let cache_affinity = payload
|
||||||
|
.as_array()
|
||||||
|
.expect("preset payload should be an array")
|
||||||
|
.iter()
|
||||||
|
.find(|preset| preset["name"] == "cache_affinity")
|
||||||
|
.expect("cache affinity preset should exist");
|
||||||
|
|
||||||
|
assert_eq!(cache_affinity["default_mode"], json!("single_account"));
|
||||||
|
assert_eq!(
|
||||||
|
cache_affinity["modes"],
|
||||||
|
json!([
|
||||||
|
{"value": "single_account", "label": "单号优先"},
|
||||||
|
{"value": "lru", "label": "LRU 轮号"}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -513,10 +513,10 @@ fn generic_provider_type(provider_type: &str) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_token_from_auth_config(auth_config: &Value) -> Option<String> {
|
fn refresh_token_from_auth_config(auth_config: &Value) -> Option<String> {
|
||||||
auth_config
|
let object = auth_config.as_object()?;
|
||||||
.as_object()
|
["refresh_token", "refreshToken"]
|
||||||
.and_then(|object| object.get("refresh_token"))
|
.iter()
|
||||||
.and_then(non_empty_string)
|
.find_map(|field| object.get(*field).and_then(non_empty_string))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn access_token_from_auth_config(auth_config: &Value) -> Option<String> {
|
fn access_token_from_auth_config(auth_config: &Value) -> Option<String> {
|
||||||
@@ -892,6 +892,49 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn antigravity_expired_legacy_credential_refreshes_and_normalizes_refresh_token() {
|
||||||
|
let mut transport = sample_transport();
|
||||||
|
transport.provider.name = "Antigravity".to_string();
|
||||||
|
transport.provider.provider_type = "antigravity".to_string();
|
||||||
|
transport.key.decrypted_api_key = "stale-access-token".to_string();
|
||||||
|
transport.key.expires_at_unix_secs = Some(1);
|
||||||
|
transport.key.decrypted_auth_config = Some(
|
||||||
|
json!({
|
||||||
|
"provider_type": "antigravity",
|
||||||
|
"refreshToken": "stable-refresh-token",
|
||||||
|
"expires_at": 1,
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
let hits = Arc::new(AtomicUsize::new(0));
|
||||||
|
let executor = StaticTokenExecutor {
|
||||||
|
hits: Arc::clone(&hits),
|
||||||
|
};
|
||||||
|
let adapter = GenericOAuthRefreshAdapter::default()
|
||||||
|
.with_token_url_for_tests("antigravity", "https://oauth.example/token");
|
||||||
|
|
||||||
|
assert!(adapter.supports(&transport));
|
||||||
|
assert!(adapter.should_refresh(&transport, None));
|
||||||
|
|
||||||
|
let refreshed = adapter
|
||||||
|
.refresh(&executor, &transport, None)
|
||||||
|
.await
|
||||||
|
.expect("antigravity refresh should succeed")
|
||||||
|
.expect("antigravity refresh should return a cache entry");
|
||||||
|
|
||||||
|
assert_eq!(hits.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(refreshed.provider_type, "antigravity");
|
||||||
|
assert_eq!(refreshed.auth_header_value, "Bearer fresh-access-token");
|
||||||
|
assert_eq!(
|
||||||
|
refreshed
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.map(|metadata| &metadata["refresh_token"]),
|
||||||
|
Some(&json!("stable-refresh-token"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fenced_force_reuses_successor_when_refresh_token_does_not_rotate() {
|
async fn fenced_force_reuses_successor_when_refresh_token_does_not_rotate() {
|
||||||
let mut transport = sample_transport();
|
let mut transport = sample_transport();
|
||||||
|
|||||||
@@ -717,7 +717,23 @@ impl RuntimeState {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
config: RuntimeSemaphoreConfig,
|
config: RuntimeSemaphoreConfig,
|
||||||
) -> Result<RuntimeSemaphore, RuntimeSemaphoreError> {
|
) -> Result<RuntimeSemaphore, RuntimeSemaphoreError> {
|
||||||
RuntimeSemaphore::new(self.clone(), gate, limit, config)
|
RuntimeSemaphore::new(self.clone(), gate, None, limit, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn keyed_semaphore(
|
||||||
|
&self,
|
||||||
|
gate: &'static str,
|
||||||
|
resource_key: &str,
|
||||||
|
limit: usize,
|
||||||
|
config: RuntimeSemaphoreConfig,
|
||||||
|
) -> Result<RuntimeSemaphore, RuntimeSemaphoreError> {
|
||||||
|
let resource_key = resource_key.trim();
|
||||||
|
if resource_key.is_empty() {
|
||||||
|
return Err(RuntimeSemaphoreError::InvalidConfiguration(
|
||||||
|
"runtime semaphore resource key cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
RuntimeSemaphore::new(self.clone(), gate, Some(resource_key), limit, config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,6 +1218,7 @@ impl RuntimeSemaphore {
|
|||||||
fn new(
|
fn new(
|
||||||
runtime: RuntimeState,
|
runtime: RuntimeState,
|
||||||
gate: &'static str,
|
gate: &'static str,
|
||||||
|
resource_key: Option<&str>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
config: RuntimeSemaphoreConfig,
|
config: RuntimeSemaphoreConfig,
|
||||||
) -> Result<Self, RuntimeSemaphoreError> {
|
) -> Result<Self, RuntimeSemaphoreError> {
|
||||||
@@ -1222,7 +1239,9 @@ impl RuntimeSemaphore {
|
|||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
state: Arc::new(RuntimeSemaphoreState {
|
state: Arc::new(RuntimeSemaphoreState {
|
||||||
key: format!("admission:{gate}"),
|
key: resource_key
|
||||||
|
.map(|resource_key| format!("admission:{gate}:{resource_key}"))
|
||||||
|
.unwrap_or_else(|| format!("admission:{gate}")),
|
||||||
runtime,
|
runtime,
|
||||||
gate,
|
gate,
|
||||||
limit,
|
limit,
|
||||||
@@ -1256,6 +1275,7 @@ pub struct RuntimeSemaphorePermit {
|
|||||||
token: String,
|
token: String,
|
||||||
renew_task: JoinHandle<()>,
|
renew_task: JoinHandle<()>,
|
||||||
healthy: Arc<std::sync::atomic::AtomicBool>,
|
healthy: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
released: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl aether_runtime::AdmissionPermitHealth for RuntimeSemaphorePermit {
|
impl aether_runtime::AdmissionPermitHealth for RuntimeSemaphorePermit {
|
||||||
@@ -1264,8 +1284,22 @@ impl aether_runtime::AdmissionPermitHealth for RuntimeSemaphorePermit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RuntimeSemaphorePermit {
|
||||||
|
pub async fn release(mut self) -> Result<(), RuntimeSemaphoreError> {
|
||||||
|
self.renew_task.abort();
|
||||||
|
let result = self.state.release(&self.token).await;
|
||||||
|
if result.is_ok() {
|
||||||
|
self.released = true;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for RuntimeSemaphorePermit {
|
impl Drop for RuntimeSemaphorePermit {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
if self.released {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.renew_task.abort();
|
self.renew_task.abort();
|
||||||
let state = Arc::clone(&self.state);
|
let state = Arc::clone(&self.state);
|
||||||
let token = self.token.clone();
|
let token = self.token.clone();
|
||||||
@@ -1331,6 +1365,7 @@ impl RuntimeSemaphoreState {
|
|||||||
token,
|
token,
|
||||||
renew_task,
|
renew_task,
|
||||||
healthy,
|
healthy,
|
||||||
|
released: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1733,6 +1768,43 @@ mod tests {
|
|||||||
assert_eq!(gate.snapshot().await.expect("snapshot").in_flight, 0);
|
assert_eq!(gate.snapshot().await.expect("snapshot").in_flight, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn memory_keyed_semaphores_isolate_resource_capacity() {
|
||||||
|
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
||||||
|
let first = runtime
|
||||||
|
.keyed_semaphore(
|
||||||
|
"provider_key",
|
||||||
|
"key-a",
|
||||||
|
1,
|
||||||
|
RuntimeSemaphoreConfig::default(),
|
||||||
|
)
|
||||||
|
.expect("first gate should build");
|
||||||
|
let second = runtime
|
||||||
|
.keyed_semaphore(
|
||||||
|
"provider_key",
|
||||||
|
"key-b",
|
||||||
|
1,
|
||||||
|
RuntimeSemaphoreConfig::default(),
|
||||||
|
)
|
||||||
|
.expect("second gate should build");
|
||||||
|
let permit = first.try_acquire().await.expect("first permit");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
first
|
||||||
|
.try_acquire()
|
||||||
|
.await
|
||||||
|
.expect_err("same key should saturate"),
|
||||||
|
RuntimeSemaphoreError::Saturated { .. }
|
||||||
|
));
|
||||||
|
let second_permit = second
|
||||||
|
.try_acquire()
|
||||||
|
.await
|
||||||
|
.expect("different key should retain independent capacity");
|
||||||
|
|
||||||
|
drop(second_permit);
|
||||||
|
drop(permit);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn memory_semaphore_marks_permit_unhealthy_after_lease_loss() {
|
async fn memory_semaphore_marks_permit_unhealthy_after_lease_loss() {
|
||||||
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
let runtime = RuntimeState::memory(MemoryRuntimeStateConfig::default());
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export interface PoolKeyDetail {
|
|||||||
rate_multipliers?: Record<string, number> | null
|
rate_multipliers?: Record<string, number> | null
|
||||||
internal_priority?: number
|
internal_priority?: number
|
||||||
rpm_limit?: number | null
|
rpm_limit?: number | null
|
||||||
|
concurrent_limit?: number | null
|
||||||
cache_ttl_minutes?: number
|
cache_ttl_minutes?: number
|
||||||
max_probe_interval_minutes?: number
|
max_probe_interval_minutes?: number
|
||||||
note?: string | null
|
note?: string | null
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
>
|
>
|
||||||
{{ accountQuotaText }}
|
{{ accountQuotaText }}
|
||||||
</div>
|
</div>
|
||||||
|
<ResetCredits />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="accountQuotaText || fallbackText"
|
v-else-if="accountQuotaText || fallbackText"
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
>
|
>
|
||||||
{{ accountQuotaText }}
|
{{ accountQuotaText }}
|
||||||
</div>
|
</div>
|
||||||
|
<ResetCredits />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-else-if="accountQuotaText || fallbackText"
|
v-else-if="accountQuotaText || fallbackText"
|
||||||
@@ -72,23 +74,61 @@ export interface PoolQuotaProgressDisplayItem {
|
|||||||
meterText: string
|
meterText: string
|
||||||
barClass: string
|
barClass: string
|
||||||
meterClass: string
|
meterClass: string
|
||||||
|
numericOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
items: PoolQuotaProgressDisplayItem[]
|
items: PoolQuotaProgressDisplayItem[]
|
||||||
accountQuotaText?: string | null
|
accountQuotaText?: string | null
|
||||||
fallbackText?: string | null
|
fallbackText?: string | null
|
||||||
textClass?: string
|
textClass?: string
|
||||||
variant?: 'desktop' | 'mobile'
|
variant?: 'desktop' | 'mobile'
|
||||||
|
resetCreditText?: string | null
|
||||||
|
resetCreditItems?: string[]
|
||||||
|
canConsumeResetCredit?: boolean
|
||||||
|
consumingResetCredit?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
accountQuotaText: null,
|
accountQuotaText: null,
|
||||||
fallbackText: null,
|
fallbackText: null,
|
||||||
textClass: '',
|
textClass: '',
|
||||||
variant: 'desktop',
|
variant: 'desktop',
|
||||||
|
resetCreditText: null,
|
||||||
|
resetCreditItems: () => [],
|
||||||
|
canConsumeResetCredit: false,
|
||||||
|
consumingResetCredit: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'consume-reset-credit': []
|
||||||
|
}>()
|
||||||
|
|
||||||
const { legacyT } = useI18n()
|
const { legacyT } = useI18n()
|
||||||
|
|
||||||
|
const ResetCredits = defineComponent({
|
||||||
|
name: 'PoolQuotaResetCredits',
|
||||||
|
setup() {
|
||||||
|
return () => props.resetCreditText ? h('div', {
|
||||||
|
'data-testid': 'pool-quota-reset-credits',
|
||||||
|
class: 'mt-2 border-t border-border/50 pt-1.5 text-[10px] leading-4 text-muted-foreground',
|
||||||
|
}, [
|
||||||
|
h('div', { class: 'flex flex-wrap items-center gap-x-1' }, [
|
||||||
|
props.canConsumeResetCredit
|
||||||
|
? h('button', {
|
||||||
|
type: 'button',
|
||||||
|
disabled: props.consumingResetCredit,
|
||||||
|
class: 'font-medium text-primary hover:underline disabled:pointer-events-none disabled:opacity-60',
|
||||||
|
onClick: () => emit('consume-reset-credit'),
|
||||||
|
}, props.consumingResetCredit ? legacyT('重置中...') : legacyT('点击以进行重置'))
|
||||||
|
: null,
|
||||||
|
h('span', props.resetCreditText),
|
||||||
|
]),
|
||||||
|
props.resetCreditItems.length
|
||||||
|
? h('div', { class: 'truncate tabular-nums', title: props.resetCreditItems.join(' · ') }, props.resetCreditItems.join(' · '))
|
||||||
|
: null,
|
||||||
|
]) : null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const QuotaProgressRows = defineComponent({
|
const QuotaProgressRows = defineComponent({
|
||||||
name: 'QuotaProgressRows',
|
name: 'QuotaProgressRows',
|
||||||
props: {
|
props: {
|
||||||
@@ -122,15 +162,24 @@ const QuotaProgressRows = defineComponent({
|
|||||||
: null,
|
: null,
|
||||||
]),
|
]),
|
||||||
h('div', { class: 'flex items-center gap-1.5' }, [
|
h('div', { class: 'flex items-center gap-1.5' }, [
|
||||||
h('div', { class: 'relative flex-1 h-1.5 rounded-full bg-border overflow-hidden' }, [
|
item.numericOnly
|
||||||
h('div', {
|
? null
|
||||||
class: ['absolute left-0 top-0 h-full rounded-full transition-all duration-300', item.barClass],
|
: h('div', {
|
||||||
style: { width: `${item.remainingPercent}%` },
|
'data-testid': 'pool-quota-progress-track',
|
||||||
}),
|
class: 'relative flex-1 h-1.5 rounded-full bg-border overflow-hidden',
|
||||||
]),
|
}, [
|
||||||
|
h('div', {
|
||||||
|
class: ['absolute left-0 top-0 h-full rounded-full transition-all duration-300', item.barClass],
|
||||||
|
style: { width: `${item.remainingPercent}%` },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
h('span', {
|
h('span', {
|
||||||
'data-testid': 'pool-quota-meter-text',
|
'data-testid': 'pool-quota-meter-text',
|
||||||
class: ['shrink-0 text-[10px] font-medium tabular-nums leading-none', item.meterClass],
|
class: [
|
||||||
|
'shrink-0 text-[10px] font-medium tabular-nums leading-none',
|
||||||
|
item.numericOnly ? 'ml-auto' : '',
|
||||||
|
item.meterClass,
|
||||||
|
],
|
||||||
}, item.meterText),
|
}, item.meterText),
|
||||||
]),
|
]),
|
||||||
]))
|
]))
|
||||||
|
|||||||
@@ -48,6 +48,27 @@
|
|||||||
>
|
>
|
||||||
{{ activeDistributionDesc }}
|
{{ activeDistributionDesc }}
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="activeDistributionItem?.modeOptions.length"
|
||||||
|
data-testid="pool-cache-affinity-secondary-mode"
|
||||||
|
class="mt-2 flex w-fit flex-wrap gap-1 rounded-lg bg-muted/50 p-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="modeOpt in activeDistributionItem.modeOptions"
|
||||||
|
:key="modeOpt.value"
|
||||||
|
type="button"
|
||||||
|
:data-mode="modeOpt.value"
|
||||||
|
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all"
|
||||||
|
:class="[
|
||||||
|
activeDistributionItem.mode === modeOpt.value
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
|
||||||
|
]"
|
||||||
|
@click="setPresetModeByPreset(activeDistributionItem.preset, modeOpt.value)"
|
||||||
|
>
|
||||||
|
{{ modeOpt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,13 +288,16 @@ const FALLBACK_PRESET_DEFS: PoolPresetMeta[] = [
|
|||||||
{
|
{
|
||||||
name: 'cache_affinity',
|
name: 'cache_affinity',
|
||||||
label: '缓存亲和',
|
label: '缓存亲和',
|
||||||
description: '优先复用最近使用过的 Key,利用 Prompt Caching',
|
description: '同一用户持续复用 Key,首次分配可集中或轮转',
|
||||||
mutex_group: DISTRIBUTION_GROUP,
|
mutex_group: DISTRIBUTION_GROUP,
|
||||||
evidence_hint: '依据 LRU 时间戳(最近使用优先,与 LRU 轮转相反)',
|
evidence_hint: '先复用用户粘性 Key,未命中时按所选二级模式分配',
|
||||||
providers: [],
|
providers: [],
|
||||||
default_enabled: true,
|
default_enabled: true,
|
||||||
modes: null,
|
modes: [
|
||||||
default_mode: null,
|
{ value: 'single_account', label: '单号优先' },
|
||||||
|
{ value: 'lru', label: 'LRU 轮号' },
|
||||||
|
],
|
||||||
|
default_mode: 'single_account',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'lru',
|
name: 'lru',
|
||||||
@@ -733,14 +757,17 @@ const activeDistributionPreset = computed(() => {
|
|||||||
return found?.item.preset ?? null
|
return found?.item.preset ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeDistributionDesc = computed(() => {
|
const activeDistributionItem = computed(() => {
|
||||||
const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
|
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 activeDistributionLabel = computed(() => {
|
||||||
const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
|
return activeDistributionItem.value?.label ?? null
|
||||||
return found?.item.label ?? null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const strategyItems = computed(() => {
|
const strategyItems = computed(() => {
|
||||||
|
|||||||
@@ -90,6 +90,30 @@ describe('pool key display panels', () => {
|
|||||||
root.remove()
|
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', () => {
|
it('renders single-cycle stats as plain text', () => {
|
||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
document.body.appendChild(root)
|
document.body.appendChild(root)
|
||||||
|
|||||||
+125
@@ -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<void> {
|
||||||
|
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<HTMLButtonElement>('[data-mode="lru"]')?.click()
|
||||||
|
await nextTick()
|
||||||
|
const saveButton = [...document.body.querySelectorAll<HTMLButtonElement>('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',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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', () => {
|
it('requires a selected field and a proxy node for set mode', () => {
|
||||||
const selection = createPoolKeyBatchSettingSelection()
|
const selection = createPoolKeyBatchSettingSelection()
|
||||||
const draft = createPoolKeyBatchSettingsDraft()
|
const draft = createPoolKeyBatchSettingsDraft()
|
||||||
|
|||||||
@@ -5,24 +5,34 @@ export function mergePoolKeyQuotaSnapshots(
|
|||||||
keys: PoolKeyDetail[],
|
keys: PoolKeyDetail[],
|
||||||
results: RefreshQuotaResult['results'],
|
results: RefreshQuotaResult['results'],
|
||||||
): PoolKeyDetail[] {
|
): PoolKeyDetail[] {
|
||||||
const quotaByKeyId = new Map<string, NonNullable<RefreshQuotaResult['results'][number]['quota_snapshot']>>()
|
const resultByKeyId = new Map<string, RefreshQuotaResult['results'][number]>()
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.quota_snapshot) {
|
if (result.quota_snapshot || result.metadata) {
|
||||||
quotaByKeyId.set(result.key_id, result.quota_snapshot)
|
resultByKeyId.set(result.key_id, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (quotaByKeyId.size === 0) return keys
|
if (resultByKeyId.size === 0) return keys
|
||||||
|
|
||||||
return keys.map((key) => {
|
return keys.map((key) => {
|
||||||
const quotaSnapshot = quotaByKeyId.get(key.key_id)
|
const result = resultByKeyId.get(key.key_id)
|
||||||
if (!quotaSnapshot) return key
|
if (!result) return key
|
||||||
|
const quotaSnapshot = result.quota_snapshot
|
||||||
|
const providerType = String(quotaSnapshot?.provider_type || key.provider_type || '').trim().toLowerCase()
|
||||||
return {
|
return {
|
||||||
...key,
|
...key,
|
||||||
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
|
...(quotaSnapshot ? {
|
||||||
status_snapshot: {
|
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
|
||||||
...(key.status_snapshot ?? {}),
|
status_snapshot: {
|
||||||
quota: quotaSnapshot,
|
...(key.status_snapshot ?? {}),
|
||||||
},
|
quota: quotaSnapshot,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
|
...(result.metadata && providerType ? {
|
||||||
|
upstream_metadata: {
|
||||||
|
...(key.upstream_metadata ?? {}),
|
||||||
|
[providerType]: result.metadata,
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,11 @@
|
|||||||
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
|
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
|
||||||
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
|
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
|
||||||
:text-class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
|
: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)"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-3 px-2 align-middle">
|
<TableCell class="py-3 px-2 align-middle">
|
||||||
@@ -709,7 +714,12 @@
|
|||||||
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
|
:account-quota-text="keyUiStateMap[key.key_id]?.accountQuotaText"
|
||||||
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
|
:fallback-text="keyUiStateMap[key.key_id]?.quotaFallbackText"
|
||||||
:text-class="keyUiStateMap[key.key_id]?.quotaTextClass || ''"
|
: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"
|
variant="mobile"
|
||||||
|
@consume-reset-credit="handleConsumeCodexResetCredit(key)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-0.5">
|
||||||
@@ -1074,6 +1084,7 @@ import {
|
|||||||
deleteEndpointKey,
|
deleteEndpointKey,
|
||||||
updateProviderKey,
|
updateProviderKey,
|
||||||
refreshProviderQuota,
|
refreshProviderQuota,
|
||||||
|
consumeCodexResetCredit,
|
||||||
resetProviderKeyCycleStats,
|
resetProviderKeyCycleStats,
|
||||||
} from '@/api/endpoints/keys'
|
} from '@/api/endpoints/keys'
|
||||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||||
@@ -1132,6 +1143,22 @@ import {
|
|||||||
} from '@/features/pool/utils/poolStatsDisplay'
|
} from '@/features/pool/utils/poolStatsDisplay'
|
||||||
import { resetCodexCycleUsageWindows } from '@/features/pool/utils/poolCycleStats'
|
import { resetCodexCycleUsageWindows } from '@/features/pool/utils/poolCycleStats'
|
||||||
import { mergePoolKeyQuotaSnapshots } from '@/features/pool/utils/poolQuotaRefresh'
|
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 { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
|
||||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||||
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
|
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
|
||||||
@@ -1853,6 +1880,7 @@ const sortOrder = ref<PoolManagementSortOrder>(restoredViewState.sortOrder)
|
|||||||
const hasPoolKeyFilters = computed(() => searchQuery.value.trim().length > 0 || statusFilter.value !== 'all')
|
const hasPoolKeyFilters = computed(() => searchQuery.value.trim().length > 0 || statusFilter.value !== 'all')
|
||||||
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
|
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
|
||||||
const refreshingOAuthKeyId = ref<string | null>(null)
|
const refreshingOAuthKeyId = ref<string | null>(null)
|
||||||
|
const consumingCodexResetCreditKeyId = ref<string | null>(null)
|
||||||
const resettingCycleKeyId = ref<string | null>(null)
|
const resettingCycleKeyId = ref<string | null>(null)
|
||||||
const savingProxyKeyId = ref<string | null>(null)
|
const savingProxyKeyId = ref<string | null>(null)
|
||||||
const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
|
const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
|
||||||
@@ -1978,6 +2006,7 @@ watch(
|
|||||||
interface QuotaProgressItem {
|
interface QuotaProgressItem {
|
||||||
label: string
|
label: string
|
||||||
remainingPercent: number
|
remainingPercent: number
|
||||||
|
numericOnly?: boolean
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
detail?: string
|
detail?: string
|
||||||
resetAtSeconds?: number | null
|
resetAtSeconds?: number | null
|
||||||
@@ -1993,6 +2022,7 @@ interface QuotaProgressDisplayItem {
|
|||||||
meterText: string
|
meterText: string
|
||||||
barClass: string
|
barClass: string
|
||||||
meterClass: string
|
meterClass: string
|
||||||
|
numericOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type PoolKeyUiState = {
|
type PoolKeyUiState = {
|
||||||
@@ -2033,9 +2063,12 @@ const quotaProgressDisplayMap = computed<Record<string, QuotaProgressDisplayItem
|
|||||||
label: getQuotaProgressLabel(item.label),
|
label: getQuotaProgressLabel(item.label),
|
||||||
remainingPercent: item.remainingPercent,
|
remainingPercent: item.remainingPercent,
|
||||||
resetText: getQuotaProgressResetDisplayText(item),
|
resetText: getQuotaProgressResetDisplayText(item),
|
||||||
meterText: getQuotaProgressMeterDisplayText(item),
|
meterText: item.numericOnly
|
||||||
|
? formatQuotaValue(item.remainingPercent)
|
||||||
|
: getQuotaProgressMeterDisplayText(item),
|
||||||
barClass: getQuotaRemainingBarColorByRemaining(item.remainingPercent),
|
barClass: getQuotaRemainingBarColorByRemaining(item.remainingPercent),
|
||||||
meterClass: getQuotaRemainingClassByRemaining(item.remainingPercent),
|
meterClass: getQuotaRemainingClassByRemaining(item.remainingPercent),
|
||||||
|
numericOnly: item.numericOnly,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
@@ -2204,6 +2237,107 @@ function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof
|
|||||||
keyPage.value.keys = mergePoolKeyQuotaSnapshots(keyPage.value.keys, result.results)
|
keyPage.value.keys = mergePoolKeyQuotaSnapshots(keyPage.value.keys, result.results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCodexResetCredits(key: PoolKeyDetail) {
|
||||||
|
return getQuotaSnapshotProviderType(key) === 'codex'
|
||||||
|
? key.status_snapshot?.quota?.reset_credits ?? null
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodexCredentialGeneration(key: PoolKeyDetail): string | null | undefined {
|
||||||
|
const codex = key.upstream_metadata?.codex
|
||||||
|
return codex && typeof codex === 'object'
|
||||||
|
? codex.credential_generation?.trim() || null
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingCodexResetCreditIdempotencyKey(key: PoolKeyDetail): string | null {
|
||||||
|
const codex = key.upstream_metadata?.codex
|
||||||
|
const serverReservation = getCodexResetCreditReservationIdempotencyKey(codex)
|
||||||
|
if (serverReservation) return serverReservation
|
||||||
|
const generation = getCodexCredentialGeneration(key)
|
||||||
|
return generation === undefined
|
||||||
|
? null
|
||||||
|
: readPendingCodexResetCreditIdempotencyKey(key.key_id, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodexResetCreditCountText(key: PoolKeyDetail): string | null {
|
||||||
|
const count = getCodexResetCreditAvailableCount(getCodexResetCredits(key))
|
||||||
|
return count === null && !getPendingCodexResetCreditIdempotencyKey(key)
|
||||||
|
? null
|
||||||
|
: formatCodexResetCreditCount(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCodexResetCreditItemTexts(key: PoolKeyDetail): string[] {
|
||||||
|
return getVisibleCodexResetCreditItems(getCodexResetCredits(key), undefined, 3)
|
||||||
|
.map(item => `${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<void> {
|
||||||
|
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<string, unknown> } }).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 {
|
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
|
||||||
const value = Number(raw ?? 0)
|
const value = Number(raw ?? 0)
|
||||||
if (!Number.isFinite(value) || value <= 0) return null
|
if (!Number.isFinite(value) || value <= 0) return null
|
||||||
@@ -2447,6 +2581,7 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
|
|||||||
rate_multipliers: key.rate_multipliers ?? null,
|
rate_multipliers: key.rate_multipliers ?? null,
|
||||||
internal_priority: key.internal_priority ?? 50,
|
internal_priority: key.internal_priority ?? 50,
|
||||||
rpm_limit: key.rpm_limit ?? null,
|
rpm_limit: key.rpm_limit ?? null,
|
||||||
|
concurrent_limit: key.concurrent_limit ?? null,
|
||||||
allowed_models: key.allowed_models ?? null,
|
allowed_models: key.allowed_models ?? null,
|
||||||
capabilities: key.capabilities ?? null,
|
capabilities: key.capabilities ?? null,
|
||||||
cache_ttl_minutes: key.cache_ttl_minutes ?? 5,
|
cache_ttl_minutes: key.cache_ttl_minutes ?? 5,
|
||||||
@@ -3730,20 +3865,24 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
|||||||
if (providerType === 'antigravity') {
|
if (providerType === 'antigravity') {
|
||||||
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
const windows = getQuotaSnapshotWindowsByScope(quota, 'model')
|
||||||
if (windows.length === 0) return []
|
if (windows.length === 0) return []
|
||||||
|
const opaqueDisplayIndex = { value: 1 }
|
||||||
const remainingPercents = windows
|
return dedupeAntigravityQuotaItemsByLabel(windows
|
||||||
.map(getQuotaWindowRemainingPercent)
|
.map((window): (QuotaProgressItem & { model: string, resetSeconds: number | null }) | null => {
|
||||||
.filter((value): value is number => value != null)
|
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||||
if (remainingPercents.length === 0) return []
|
if (remainingPercent == null) return null
|
||||||
|
const model = String(window.model || window.code || '').trim().replace(/^model:/i, '')
|
||||||
return [{
|
return {
|
||||||
label: '最低',
|
model,
|
||||||
remainingPercent: Math.min(...remainingPercents),
|
label: resolveAntigravityQuotaLabel(model, window.label, opaqueDisplayIndex),
|
||||||
detail: `${windows.length} 模型`,
|
remainingPercent,
|
||||||
resetAtSeconds: null,
|
numericOnly: true,
|
||||||
resetSeconds: null,
|
resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quota.reset_at ?? null),
|
||||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
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') {
|
if (providerType === 'gemini_cli') {
|
||||||
|
|||||||
Reference in New Issue
Block a user