refactor: extract provider pool abstractions

This commit is contained in:
fawney19
2026-05-13 18:19:15 +08:00
parent 3c2497f019
commit 5d1460e051
55 changed files with 3469 additions and 2184 deletions

23
Cargo.lock generated
View File

@@ -28,6 +28,7 @@ dependencies = [
"aether-contracts", "aether-contracts",
"aether-data", "aether-data",
"aether-data-contracts", "aether-data-contracts",
"aether-provider-pool",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
@@ -64,6 +65,7 @@ dependencies = [
"aether-ai-formats", "aether-ai-formats",
"aether-contracts", "aether-contracts",
"aether-data-contracts", "aether-data-contracts",
"aether-pool-core",
"aether-scheduler-core", "aether-scheduler-core",
"async-trait", "async-trait",
"http", "http",
@@ -189,6 +191,8 @@ dependencies = [
"aether-http", "aether-http",
"aether-model-fetch", "aether-model-fetch",
"aether-oauth", "aether-oauth",
"aether-pool-core",
"aether-provider-pool",
"aether-provider-transport", "aether-provider-transport",
"aether-runtime", "aether-runtime",
"aether-runtime-state", "aether-runtime-state",
@@ -280,6 +284,25 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "aether-pool-core"
version = "0.1.0"
dependencies = [
"aether-data-contracts",
"serde_json",
]
[[package]]
name = "aether-provider-pool"
version = "0.1.0"
dependencies = [
"aether-data-contracts",
"aether-pool-core",
"serde_json",
"url",
"uuid",
]
[[package]] [[package]]
name = "aether-provider-transport" name = "aether-provider-transport"
version = "0.1.0" version = "0.1.0"

View File

@@ -4,6 +4,8 @@ members = [
"crates/aether-ai-formats", "crates/aether-ai-formats",
"crates/aether-admin", "crates/aether-admin",
"crates/aether-ai-serving", "crates/aether-ai-serving",
"crates/aether-pool-core",
"crates/aether-provider-pool",
"crates/aether-data-contracts", "crates/aether-data-contracts",
"crates/aether-data-schema", "crates/aether-data-schema",
"crates/aether-dispatch-core", "crates/aether-dispatch-core",
@@ -37,6 +39,8 @@ repository = "https://github.com/fawney19/Aether.git"
aether-admin = { path = "crates/aether-admin" } aether-admin = { path = "crates/aether-admin" }
aether-ai-formats = { path = "crates/aether-ai-formats" } aether-ai-formats = { path = "crates/aether-ai-formats" }
aether-ai-serving = { path = "crates/aether-ai-serving" } aether-ai-serving = { path = "crates/aether-ai-serving" }
aether-pool-core = { path = "crates/aether-pool-core" }
aether-provider-pool = { path = "crates/aether-provider-pool" }
aether-data-contracts = { path = "crates/aether-data-contracts" } aether-data-contracts = { path = "crates/aether-data-contracts" }
aether-data-schema = { path = "crates/aether-data-schema" } aether-data-schema = { path = "crates/aether-data-schema" }
aether-dispatch-core = { path = "crates/aether-dispatch-core" } aether-dispatch-core = { path = "crates/aether-dispatch-core" }

View File

@@ -20,6 +20,8 @@ aether-dispatch-core.workspace = true
aether-http.workspace = true aether-http.workspace = true
aether-model-fetch.workspace = true aether-model-fetch.workspace = true
aether-oauth.workspace = true aether-oauth.workspace = true
aether-pool-core.workspace = true
aether-provider-pool.workspace = true
aether-provider-transport.workspace = true aether-provider-transport.workspace = true
aether-scheduler-core.workspace = true aether-scheduler-core.workspace = true
aether-runtime.workspace = true aether-runtime.workspace = true

View File

@@ -3,10 +3,9 @@ pub(crate) use crate::handlers::admin::{
build_internal_control_error_response, create_provider_oauth_catalog_key, build_internal_control_error_response, create_provider_oauth_catalog_key,
find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response, find_duplicate_provider_oauth_key, maybe_build_local_admin_pool_response,
maybe_build_local_admin_response, provider_oauth_maintenance_endpoint_for_provider, maybe_build_local_admin_response, provider_oauth_maintenance_endpoint_for_provider,
provider_oauth_runtime_endpoint_for_provider, provider_type_supports_quota_refresh, provider_oauth_runtime_endpoint_for_provider, provider_quota_refresh_endpoint_for_provider,
reconcile_admin_fixed_provider_template_endpoints, refresh_antigravity_provider_quota_locally, provider_type_supports_quota_refresh, reconcile_admin_fixed_provider_template_endpoints,
refresh_chatgpt_web_provider_quota_locally, refresh_codex_provider_quota_locally, refresh_provider_oauth_account_state_after_update, refresh_provider_pool_quota_locally,
refresh_kiro_provider_quota_locally, refresh_provider_oauth_account_state_after_update,
update_existing_provider_oauth_catalog_key, AdminAppState, update_existing_provider_oauth_catalog_key, AdminAppState,
AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext, AdminGatewayProviderTransportSnapshot, AdminLocalOAuthRefreshError, AdminRequestContext,
AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange, AdminRouteRequest, AdminRouteResponse, AdminRouteResult, AdminStatsTimeRange,

View File

@@ -1,11 +1,11 @@
use aether_ai_serving::{
score_pool_member_with_rules, PoolMemberScoreInput, PoolMemberScoreRules, POOL_SCORE_VERSION,
};
use aether_data_contracts::repository::pool_scores::{ use aether_data_contracts::repository::pool_scores::{
PoolMemberIdentity, PoolMemberProbeStatus, PoolScoreScope, UpsertPoolMemberScore, PoolMemberIdentity, PoolMemberProbeStatus, PoolScoreScope, UpsertPoolMemberScore,
POOL_SCORE_CAPABILITY_ACCOUNT, POOL_SCORE_SCOPE_KIND_ACCOUNT, POOL_SCORE_CAPABILITY_ACCOUNT, POOL_SCORE_SCOPE_KIND_ACCOUNT,
}; };
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey; use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use aether_pool_core::{
score_pool_member_with_rules, PoolMemberScoreInput, PoolMemberScoreRules, POOL_SCORE_VERSION,
};
use serde_json::Value; use serde_json::Value;
use crate::handlers::shared::{provider_key_health_summary, provider_key_status_snapshot_payload}; use crate::handlers::shared::{provider_key_health_summary, provider_key_status_snapshot_payload};

View File

@@ -2,11 +2,6 @@ use std::collections::{btree_map::Entry, BTreeMap, BTreeSet, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use aether_admin::provider::pool as admin_provider_pool_pure; use aether_admin::provider::pool as admin_provider_pool_pure;
use aether_ai_serving::{
normalize_enabled_ai_pool_presets, run_ai_pool_scheduler, AiPoolCandidateFacts,
AiPoolCandidateInput, AiPoolCandidateOrchestration, AiPoolCatalogKeyContext,
AiPoolRuntimeState, AiPoolSchedulingConfig, AiPoolSchedulingPreset,
};
use aether_data_contracts::repository::candidate_selection::{ use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredPoolKeyCandidateOrder, StoredMinimalCandidateSelectionRow, StoredPoolKeyCandidateOrder,
StoredPoolKeyCandidateRowsByKeyIdsQuery, StoredPoolKeyCandidateRowsQuery, StoredPoolKeyCandidateRowsByKeyIdsQuery, StoredPoolKeyCandidateRowsQuery,
@@ -16,7 +11,11 @@ use aether_data_contracts::repository::pool_scores::{
PoolMemberScheduleFeedback, PoolScoreScope, StoredPoolMemberScore, POOL_KIND_PROVIDER_KEY_POOL, PoolMemberScheduleFeedback, PoolScoreScope, StoredPoolMemberScore, POOL_KIND_PROVIDER_KEY_POOL,
}; };
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey; use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::{Map, Value}; use aether_pool_core::{
run_pool_scheduler, PoolCandidateFacts, PoolCandidateInput, PoolCandidateOrchestration,
PoolMemberSignals, PoolRuntimeState, PoolSchedulingConfig, PoolSchedulingPreset,
};
use aether_provider_pool::ProviderPoolService;
use tracing::warn; use tracing::warn;
use crate::ai_serving::{ use crate::ai_serving::{
@@ -35,16 +34,12 @@ use crate::handlers::shared::provider_pool::{
read_admin_provider_pool_key_cooldown_reason, AdminProviderPoolConfig, read_admin_provider_pool_key_cooldown_reason, AdminProviderPoolConfig,
AdminProviderPoolRuntimeState, AdminProviderPoolRuntimeState,
}; };
use crate::handlers::shared::{ use crate::handlers::shared::{parse_catalog_auth_config_json, provider_key_health_summary};
parse_catalog_auth_config_json, provider_key_health_summary,
provider_key_status_snapshot_payload,
};
use crate::orchestration::LocalExecutionCandidateMetadata; use crate::orchestration::LocalExecutionCandidateMetadata;
use crate::provider_key_auth::provider_key_auth_semantics;
static LOAD_BALANCE_SEQUENCE: AtomicU64 = AtomicU64::new(0); static LOAD_BALANCE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
type PoolCatalogKeyContext = AiPoolCatalogKeyContext; type PoolCatalogKeyContext = PoolMemberSignals;
pub(crate) async fn apply_local_execution_pool_scheduler( pub(crate) async fn apply_local_execution_pool_scheduler(
state: PlannerAppState<'_>, state: PlannerAppState<'_>,
@@ -796,6 +791,8 @@ async fn read_pool_catalog_key_contexts_by_id(
} }
}; };
let provider_pool_service = ProviderPoolService::with_builtin_adapters();
keys.into_iter() keys.into_iter()
.map(|key| { .map(|key| {
let provider_type = provider_type_by_key_id let provider_type = provider_type_by_key_id
@@ -804,7 +801,7 @@ async fn read_pool_catalog_key_contexts_by_id(
.unwrap_or_default(); .unwrap_or_default();
( (
key.id.clone(), key.id.clone(),
build_pool_catalog_key_context(state, &key, provider_type), build_pool_catalog_key_context(state, &provider_pool_service, &key, provider_type),
) )
}) })
.collect() .collect()
@@ -812,24 +809,15 @@ async fn read_pool_catalog_key_contexts_by_id(
fn build_pool_catalog_key_context( fn build_pool_catalog_key_context(
state: PlannerAppState<'_>, state: PlannerAppState<'_>,
provider_pool_service: &ProviderPoolService,
key: &StoredProviderCatalogKey, key: &StoredProviderCatalogKey,
provider_type: &str, provider_type: &str,
) -> PoolCatalogKeyContext { ) -> PoolCatalogKeyContext {
let status_snapshot = provider_key_status_snapshot_payload(key, provider_type);
let quota_snapshot = status_snapshot
.as_object()
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object);
let account_snapshot = status_snapshot
.as_object()
.and_then(|snapshot| snapshot.get("account"))
.and_then(Value::as_object);
let (health_score, _, _, _, _) = provider_key_health_summary(key); let (health_score, _, _, _, _) = provider_key_health_summary(key);
let health_score = key let health_score = key
.health_by_format .health_by_format
.as_ref() .as_ref()
.and_then(Value::as_object) .and_then(serde_json::Value::as_object)
.filter(|payload| !payload.is_empty()) .filter(|payload| !payload.is_empty())
.map(|_| health_score); .map(|_| health_score);
let latency_avg_ms = key let latency_avg_ms = key
@@ -841,131 +829,14 @@ fn build_pool_catalog_key_context(
}) })
.filter(|value| value.is_finite() && *value >= 0.0); .filter(|value| value.is_finite() && *value >= 0.0);
PoolCatalogKeyContext { let auth_config = parse_catalog_auth_config_json(state.app(), key);
oauth_plan_type: quota_snapshot let mut signals =
.and_then(|quota| quota.get("plan_type")) provider_pool_service.member_signals(provider_type, key, auth_config.as_ref());
.and_then(Value::as_str) signals.account_blocked |= admin_provider_pool_pure::admin_pool_key_is_known_banned(key);
.and_then(|value| normalize_pool_plan_type(value, provider_type)) signals.health_score = health_score;
.or_else(|| derive_pool_oauth_plan_type(state, key, provider_type)), signals.latency_avg_ms = latency_avg_ms;
quota_usage_ratio: quota_snapshot signals.catalog_lru_score = Some(key.last_used_at_unix_secs.unwrap_or(0) as f64);
.and_then(|quota| quota.get("usage_ratio")) signals
.and_then(json_f64)
.map(|value| value.clamp(0.0, 1.0)),
quota_reset_seconds: quota_snapshot
.and_then(|quota| quota.get("reset_seconds"))
.and_then(json_f64)
.filter(|value| *value >= 0.0),
account_blocked: account_snapshot
.and_then(|account| account.get("blocked"))
.and_then(Value::as_bool)
.unwrap_or(false)
|| admin_provider_pool_pure::admin_pool_key_is_known_banned(key),
quota_exhausted: pool_catalog_key_quota_exhausted(key, provider_type, quota_snapshot),
health_score,
latency_avg_ms,
catalog_lru_score: Some(key.last_used_at_unix_secs.unwrap_or(0) as f64),
}
}
fn pool_catalog_key_quota_exhausted(
key: &StoredProviderCatalogKey,
provider_type: &str,
quota_snapshot: Option<&Map<String, Value>>,
) -> bool {
match provider_type.trim().to_ascii_lowercase().as_str() {
"codex" | "kiro" | "chatgpt_web" => {
admin_provider_pool_pure::admin_pool_key_account_quota_exhausted(key, provider_type)
}
_ => quota_snapshot
.and_then(|quota| quota.get("exhausted"))
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
fn derive_pool_oauth_plan_type(
state: PlannerAppState<'_>,
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> Option<String> {
if !provider_key_auth_semantics(key, provider_type).oauth_managed() {
return None;
}
let provider_type_key = provider_type.trim().to_ascii_lowercase();
if let Some(upstream_metadata) = key.upstream_metadata.as_ref().and_then(Value::as_object) {
let provider_bucket = upstream_metadata
.get(&provider_type_key)
.and_then(Value::as_object);
for source in provider_bucket
.into_iter()
.chain(std::iter::once(upstream_metadata))
{
if let Some(plan_type) = pool_plan_type_from_source(
source,
provider_type,
&[
"plan_type",
"tier",
"subscription_title",
"subscription_plan",
"plan",
],
) {
return Some(plan_type);
}
}
}
parse_catalog_auth_config_json(state.app(), key).and_then(|auth_config| {
pool_plan_type_from_source(
&auth_config,
provider_type,
&["plan_type", "tier", "plan", "subscription_plan"],
)
})
}
fn pool_plan_type_from_source(
source: &Map<String, Value>,
provider_type: &str,
fields: &[&str],
) -> Option<String> {
for field in fields {
let Some(value) = source.get(*field).and_then(Value::as_str) else {
continue;
};
if let Some(normalized) = normalize_pool_plan_type(value, provider_type) {
return Some(normalized);
}
}
None
}
fn normalize_pool_plan_type(value: &str, provider_type: &str) -> Option<String> {
let mut normalized = value.trim().to_string();
if normalized.is_empty() {
return None;
}
let provider_type = provider_type.trim().to_ascii_lowercase();
if !provider_type.is_empty() && normalized.to_ascii_lowercase().starts_with(&provider_type) {
normalized = normalized[provider_type.len()..]
.trim_matches(|ch: char| [' ', ':', '-', '_'].contains(&ch))
.to_string();
}
let normalized = normalized.trim().to_ascii_lowercase();
(!normalized.is_empty()).then_some(normalized)
}
fn json_f64(value: &Value) -> Option<f64> {
match value {
Value::Number(number) => number.as_f64(),
Value::String(text) => text.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|value| value.is_finite())
} }
fn apply_local_execution_pool_scheduler_with_runtime_map( fn apply_local_execution_pool_scheduler_with_runtime_map(
@@ -978,7 +849,7 @@ fn apply_local_execution_pool_scheduler_with_runtime_map(
) { ) {
let runtime_by_provider = runtime_by_provider let runtime_by_provider = runtime_by_provider
.iter() .iter()
.map(|(provider_id, runtime)| (provider_id.clone(), ai_pool_runtime_state(runtime))) .map(|(provider_id, runtime)| (provider_id.clone(), pool_runtime_state(runtime)))
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();
let inputs = candidates let inputs = candidates
.into_iter() .into_iter()
@@ -987,20 +858,25 @@ fn apply_local_execution_pool_scheduler_with_runtime_map(
.get(&candidate.candidate.key_id) .get(&candidate.candidate.key_id)
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
AiPoolCandidateInput { PoolCandidateInput {
facts: ai_pool_candidate_facts(&candidate), facts: pool_candidate_facts(&candidate),
pool_config: pool_config_for_candidate(&candidate).map(ai_pool_scheduling_config), pool_config: pool_config_for_candidate(&candidate).map(|config| {
pool_scheduling_config(
config,
candidate.transport.provider.provider_type.as_str(),
)
}),
key_context, key_context,
candidate, candidate,
} }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let outcome = run_ai_pool_scheduler(inputs, &runtime_by_provider, pool_sort_seed().as_str()); let outcome = run_pool_scheduler(inputs, &runtime_by_provider, pool_sort_seed().as_str());
let candidates = outcome let candidates = outcome
.candidates .candidates
.into_iter() .into_iter()
.map(|scheduled| apply_ai_pool_orchestration(scheduled.candidate, scheduled.orchestration)) .map(|scheduled| apply_pool_orchestration(scheduled.candidate, scheduled.orchestration))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let skipped_candidates = outcome let skipped_candidates = outcome
.skipped_candidates .skipped_candidates
@@ -1032,16 +908,17 @@ fn pool_key_candidate_order_for_group(
let presets = pool_config let presets = pool_config
.scheduling_presets .scheduling_presets
.iter() .iter()
.map(|preset| AiPoolSchedulingPreset { .map(|preset| PoolSchedulingPreset {
preset: preset.preset.clone(), preset: preset.preset.clone(),
enabled: preset.enabled, enabled: preset.enabled,
mode: preset.mode.clone(), mode: preset.mode.clone(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let active_presets = normalize_enabled_ai_pool_presets( let active_presets = ProviderPoolService::with_builtin_adapters()
&presets, .normalize_scheduling_presets(group.transport.provider.provider_type.as_str(), &presets)
group.transport.provider.provider_type.as_str(), .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.as_str()))
@@ -1072,38 +949,43 @@ fn pool_sort_seed() -> String {
format!("{now_ms}:{sequence}") format!("{now_ms}:{sequence}")
} }
fn ai_pool_candidate_facts(candidate: &EligibleLocalExecutionCandidate) -> AiPoolCandidateFacts { fn pool_candidate_facts(candidate: &EligibleLocalExecutionCandidate) -> PoolCandidateFacts {
AiPoolCandidateFacts { PoolCandidateFacts {
provider_id: candidate.candidate.provider_id.clone(), provider_id: candidate.candidate.provider_id.clone(),
endpoint_id: candidate.candidate.endpoint_id.clone(), endpoint_id: candidate.candidate.endpoint_id.clone(),
model_id: candidate.candidate.model_id.clone(), model_id: candidate.candidate.model_id.clone(),
selected_provider_model_name: candidate.candidate.selected_provider_model_name.clone(), selected_provider_model_name: candidate.candidate.selected_provider_model_name.clone(),
provider_api_format: candidate.provider_api_format.clone(), provider_api_format: candidate.provider_api_format.clone(),
provider_type: candidate.transport.provider.provider_type.clone(),
key_id: candidate.candidate.key_id.clone(), key_id: candidate.candidate.key_id.clone(),
key_internal_priority: candidate.candidate.key_internal_priority, key_internal_priority: candidate.candidate.key_internal_priority,
} }
} }
fn ai_pool_scheduling_config(config: AdminProviderPoolConfig) -> AiPoolSchedulingConfig { fn pool_scheduling_config(
AiPoolSchedulingConfig { config: AdminProviderPoolConfig,
scheduling_presets: config provider_type: &str,
) -> PoolSchedulingConfig {
let service = ProviderPoolService::with_builtin_adapters();
let scheduling_presets = config
.scheduling_presets .scheduling_presets
.into_iter() .into_iter()
.map(|preset| AiPoolSchedulingPreset { .map(|preset| PoolSchedulingPreset {
preset: preset.preset, preset: preset.preset,
enabled: preset.enabled, enabled: preset.enabled,
mode: preset.mode, mode: preset.mode,
}) })
.collect(), .collect::<Vec<_>>();
PoolSchedulingConfig {
scheduling_presets: service
.normalize_scheduling_presets(provider_type, &scheduling_presets),
lru_enabled: config.lru_enabled, lru_enabled: config.lru_enabled,
skip_exhausted_accounts: config.skip_exhausted_accounts, skip_exhausted_accounts: config.skip_exhausted_accounts,
cost_limit_per_key_tokens: config.cost_limit_per_key_tokens, cost_limit_per_key_tokens: config.cost_limit_per_key_tokens,
} }
} }
fn ai_pool_runtime_state(runtime: &AdminProviderPoolRuntimeState) -> AiPoolRuntimeState { fn pool_runtime_state(runtime: &AdminProviderPoolRuntimeState) -> PoolRuntimeState {
AiPoolRuntimeState { PoolRuntimeState {
sticky_bound_key_id: runtime.sticky_bound_key_id.clone(), sticky_bound_key_id: runtime.sticky_bound_key_id.clone(),
cooldown_reason_by_key: runtime.cooldown_reason_by_key.clone(), cooldown_reason_by_key: runtime.cooldown_reason_by_key.clone(),
cost_window_usage_by_key: runtime.cost_window_usage_by_key.clone(), cost_window_usage_by_key: runtime.cost_window_usage_by_key.clone(),
@@ -1112,9 +994,9 @@ fn ai_pool_runtime_state(runtime: &AdminProviderPoolRuntimeState) -> AiPoolRunti
} }
} }
fn apply_ai_pool_orchestration( fn apply_pool_orchestration(
mut candidate: EligibleLocalExecutionCandidate, mut candidate: EligibleLocalExecutionCandidate,
orchestration: AiPoolCandidateOrchestration, orchestration: PoolCandidateOrchestration,
) -> EligibleLocalExecutionCandidate { ) -> EligibleLocalExecutionCandidate {
let scheduler_affinity_epoch = candidate.orchestration.scheduler_affinity_epoch; let scheduler_affinity_epoch = candidate.orchestration.scheduler_affinity_epoch;
candidate.orchestration = LocalExecutionCandidateMetadata { candidate.orchestration = LocalExecutionCandidateMetadata {
@@ -1142,7 +1024,6 @@ mod tests {
}; };
use crate::orchestration::LocalExecutionCandidateMetadata; use crate::orchestration::LocalExecutionCandidateMetadata;
use crate::{AppState, LocalExecutionRuntimeMissDiagnostic}; use crate::{AppState, LocalExecutionRuntimeMissDiagnostic};
use aether_ai_serving::{normalize_enabled_ai_pool_presets, AiPoolSchedulingPreset};
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository; use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::candidate_selection::{ use aether_data_contracts::repository::candidate_selection::{
@@ -1151,6 +1032,8 @@ mod tests {
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
}; };
use aether_pool_core::PoolSchedulingPreset;
use aether_provider_pool::ProviderPoolService;
use aether_provider_transport::snapshot::{ use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey, GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider, GatewayProviderTransportProvider,
@@ -1592,14 +1475,14 @@ mod tests {
( (
"key-free".to_string(), "key-free".to_string(),
PoolCatalogKeyContext { PoolCatalogKeyContext {
oauth_plan_type: Some("free".to_string()), plan_tier: Some("free".to_string()),
..PoolCatalogKeyContext::default() ..PoolCatalogKeyContext::default()
}, },
), ),
( (
"key-plus".to_string(), "key-plus".to_string(),
PoolCatalogKeyContext { PoolCatalogKeyContext {
oauth_plan_type: Some("plus".to_string()), plan_tier: Some("plus".to_string()),
..PoolCatalogKeyContext::default() ..PoolCatalogKeyContext::default()
}, },
), ),
@@ -1661,7 +1544,7 @@ mod tests {
( (
"key-plus".to_string(), "key-plus".to_string(),
PoolCatalogKeyContext { PoolCatalogKeyContext {
oauth_plan_type: Some("plus".to_string()), plan_tier: Some("plus".to_string()),
catalog_lru_score: Some(300.0), catalog_lru_score: Some(300.0),
..PoolCatalogKeyContext::default() ..PoolCatalogKeyContext::default()
}, },
@@ -1669,7 +1552,7 @@ mod tests {
( (
"key-pro".to_string(), "key-pro".to_string(),
PoolCatalogKeyContext { PoolCatalogKeyContext {
oauth_plan_type: Some("pro".to_string()), plan_tier: Some("pro".to_string()),
catalog_lru_score: Some(100.0), catalog_lru_score: Some(100.0),
..PoolCatalogKeyContext::default() ..PoolCatalogKeyContext::default()
}, },
@@ -1677,7 +1560,7 @@ mod tests {
( (
"key-team".to_string(), "key-team".to_string(),
PoolCatalogKeyContext { PoolCatalogKeyContext {
oauth_plan_type: Some("team".to_string()), plan_tier: Some("team".to_string()),
catalog_lru_score: Some(50.0), catalog_lru_score: Some(50.0),
..PoolCatalogKeyContext::default() ..PoolCatalogKeyContext::default()
}, },
@@ -1740,21 +1623,21 @@ mod tests {
( (
"key-plus".to_string(), "key-plus".to_string(),
PoolCatalogKeyContext { PoolCatalogKeyContext {
oauth_plan_type: Some("plus".to_string()), plan_tier: Some("plus".to_string()),
..PoolCatalogKeyContext::default() ..PoolCatalogKeyContext::default()
}, },
), ),
( (
"key-pro".to_string(), "key-pro".to_string(),
PoolCatalogKeyContext { PoolCatalogKeyContext {
oauth_plan_type: Some("pro".to_string()), plan_tier: Some("pro".to_string()),
..PoolCatalogKeyContext::default() ..PoolCatalogKeyContext::default()
}, },
), ),
( (
"key-team".to_string(), "key-team".to_string(),
PoolCatalogKeyContext { PoolCatalogKeyContext {
oauth_plan_type: Some("team".to_string()), plan_tier: Some("team".to_string()),
..PoolCatalogKeyContext::default() ..PoolCatalogKeyContext::default()
}, },
), ),
@@ -1822,31 +1705,35 @@ mod tests {
#[test] #[test]
fn normalizes_distribution_mode_before_strategy_presets() { fn normalizes_distribution_mode_before_strategy_presets() {
let presets = normalize_enabled_ai_pool_presets( let presets = ProviderPoolService::with_builtin_adapters()
.normalize_scheduling_presets(
"openai",
&[ &[
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "lru".to_string(), preset: "lru".to_string(),
enabled: false, enabled: false,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "single_account".to_string(), preset: "single_account".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "cache_affinity".to_string(), preset: "cache_affinity".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "priority_first".to_string(), preset: "priority_first".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
], ],
"openai", )
); .into_iter()
.map(|preset| preset.preset)
.collect::<Vec<_>>();
assert_eq!(presets, ["single_account", "priority_first"]); assert_eq!(presets, ["single_account", "priority_first"]);
} }
@@ -2286,9 +2173,14 @@ mod tests {
)), )),
)); ));
let context = build_pool_catalog_key_context(PlannerAppState::new(&app), &key, "codex"); let context = build_pool_catalog_key_context(
PlannerAppState::new(&app),
&ProviderPoolService::with_builtin_adapters(),
&key,
"codex",
);
assert_eq!(context.oauth_plan_type.as_deref(), Some("team")); assert_eq!(context.plan_tier.as_deref(), Some("team"));
assert_eq!(context.quota_usage_ratio, Some(0.25)); assert_eq!(context.quota_usage_ratio, Some(0.25));
assert_eq!(context.quota_reset_seconds, Some(3600.0)); assert_eq!(context.quota_reset_seconds, Some(3600.0));
assert_eq!(context.latency_avg_ms, Some(50.0)); assert_eq!(context.latency_avg_ms, Some(50.0));
@@ -2326,7 +2218,12 @@ mod tests {
})); }));
let app = app_state_with_catalog_key(key.clone()); let app = app_state_with_catalog_key(key.clone());
let context = build_pool_catalog_key_context(PlannerAppState::new(&app), &key, "codex"); let context = build_pool_catalog_key_context(
PlannerAppState::new(&app),
&ProviderPoolService::with_builtin_adapters(),
&key,
"codex",
);
assert!(!context.quota_exhausted); assert!(!context.quota_exhausted);
} }
@@ -2341,7 +2238,12 @@ mod tests {
})); }));
let app = app_state_with_catalog_key(key.clone()); let app = app_state_with_catalog_key(key.clone());
let context = build_pool_catalog_key_context(PlannerAppState::new(&app), &key, "codex"); let context = build_pool_catalog_key_context(
PlannerAppState::new(&app),
&ProviderPoolService::with_builtin_adapters(),
&key,
"codex",
);
assert!(context.quota_exhausted); assert!(context.quota_exhausted);
} }
@@ -2366,8 +2268,12 @@ mod tests {
})); }));
let app = app_state_with_catalog_key(key.clone()); let app = app_state_with_catalog_key(key.clone());
let context = let context = build_pool_catalog_key_context(
build_pool_catalog_key_context(PlannerAppState::new(&app), &key, "antigravity"); PlannerAppState::new(&app),
&ProviderPoolService::with_builtin_adapters(),
&key,
"antigravity",
);
assert!(context.quota_exhausted); assert!(context.quota_exhausted);
} }
@@ -2383,7 +2289,12 @@ mod tests {
})); }));
let app = app_state_with_catalog_key(key.clone()); let app = app_state_with_catalog_key(key.clone());
let context = build_pool_catalog_key_context(PlannerAppState::new(&app), &key, "codex"); let context = build_pool_catalog_key_context(
PlannerAppState::new(&app),
&ProviderPoolService::with_builtin_adapters(),
&key,
"codex",
);
assert!(context.account_blocked); assert!(context.account_blocked);
} }

View File

@@ -25,10 +25,8 @@ pub(crate) use self::provider::oauth::errors::build_internal_control_error_respo
pub(crate) use self::provider::oauth::provisioning::{ pub(crate) use self::provider::oauth::provisioning::{
create_provider_oauth_catalog_key, update_existing_provider_oauth_catalog_key, create_provider_oauth_catalog_key, update_existing_provider_oauth_catalog_key,
}; };
pub(crate) use self::provider::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally; pub(crate) use self::provider::oauth::quota::dispatch::refresh_provider_pool_quota_locally;
pub(crate) use self::provider::oauth::quota::chatgpt_web::refresh_chatgpt_web_provider_quota_locally; pub(crate) use self::provider::oauth::quota::shared::provider_quota_refresh_endpoint_for_provider;
pub(crate) use self::provider::oauth::quota::codex::refresh_codex_provider_quota_locally;
pub(crate) use self::provider::oauth::quota::kiro::refresh_kiro_provider_quota_locally;
pub(crate) use self::provider::oauth::quota::shared::provider_type_supports_quota_refresh; pub(crate) use self::provider::oauth::quota::shared::provider_type_supports_quota_refresh;
pub(crate) use self::provider::oauth::runtime::{ pub(crate) use self::provider::oauth::runtime::{
provider_oauth_maintenance_endpoint_for_provider, provider_oauth_runtime_endpoint_for_provider, provider_oauth_maintenance_endpoint_for_provider, provider_oauth_runtime_endpoint_for_provider,

View File

@@ -13,15 +13,12 @@ use axum::{
use serde_json::json; use serde_json::json;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use super::super::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally; use super::super::oauth::quota::dispatch::refresh_provider_pool_quota_locally;
use super::super::oauth::quota::chatgpt_web::refresh_chatgpt_web_provider_quota_locally;
use super::super::oauth::quota::codex::refresh_codex_provider_quota_locally;
use super::super::oauth::quota::kiro::refresh_kiro_provider_quota_locally;
use super::super::oauth::quota::shared::normalize_string_id_list; use super::super::oauth::quota::shared::normalize_string_id_list;
use super::super::oauth::quota::shared::{ use super::super::oauth::quota::shared::{
provider_quota_refresh_endpoint_for_provider, provider_quota_refresh_missing_endpoint_message,
provider_type_supports_quota_refresh, unsupported_provider_quota_refresh_message, provider_type_supports_quota_refresh, unsupported_provider_quota_refresh_message,
}; };
use super::super::oauth::runtime::provider_oauth_maintenance_endpoint_for_provider;
use super::super::write::provider::reconcile_admin_fixed_provider_template_endpoints; use super::super::write::provider::reconcile_admin_fixed_provider_template_endpoints;
fn unsupported_provider_quota_refresh_response(provider_type: &str) -> Response<Body> { fn unsupported_provider_quota_refresh_response(provider_type: &str) -> Response<Body> {
@@ -115,7 +112,7 @@ pub(super) async fn maybe_handle(
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id)) .list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?; .await?;
let mut endpoint = let mut endpoint =
provider_oauth_maintenance_endpoint_for_provider(&normalized_provider_type, &endpoints); provider_quota_refresh_endpoint_for_provider(&normalized_provider_type, &endpoints, true);
if endpoint.is_none() && is_fixed_provider { if endpoint.is_none() && is_fixed_provider {
if !state.has_provider_catalog_data_writer() { if !state.has_provider_catalog_data_writer() {
@@ -136,8 +133,11 @@ pub(super) async fn maybe_handle(
endpoints = state endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id)) .list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?; .await?;
endpoint = endpoint = provider_quota_refresh_endpoint_for_provider(
provider_oauth_maintenance_endpoint_for_provider(&normalized_provider_type, &endpoints); &normalized_provider_type,
&endpoints,
true,
);
} }
if !provider_type_supports_quota_refresh(&normalized_provider_type) { if !provider_type_supports_quota_refresh(&normalized_provider_type) {
@@ -147,15 +147,7 @@ pub(super) async fn maybe_handle(
} }
let Some(endpoint) = endpoint else { let Some(endpoint) = endpoint else {
let detail = match normalized_provider_type.as_str() { let detail = provider_quota_refresh_missing_endpoint_message(&normalized_provider_type);
"codex" => "找不到有效的 openai:responses 端点",
"antigravity" => "找不到有效的 gemini:generate_content 端点",
"kiro" => "找不到有效的 Kiro 端点",
"chatgpt_web" => "找不到有效的 openai:image 端点",
"claude_code" => "找不到有效的 claude:messages 端点",
"gemini_cli" | "vertex_ai" => "找不到有效的 gemini:generate_content 端点",
_ => "找不到有效端点",
};
return Ok(Some( return Ok(Some(
( (
http::StatusCode::BAD_REQUEST, http::StatusCode::BAD_REQUEST,
@@ -231,23 +223,16 @@ pub(super) async fn maybe_handle(
)); ));
} }
let Some(payload) = (match normalized_provider_type.as_str() { let Some(payload) = refresh_provider_pool_quota_locally(
"codex" => { state,
refresh_codex_provider_quota_locally(state, &provider, &endpoint, keys, None).await? &provider,
} &endpoint,
"kiro" => { &normalized_provider_type,
refresh_kiro_provider_quota_locally(state, &provider, &endpoint, keys, None).await? keys,
} None,
"antigravity" => { )
refresh_antigravity_provider_quota_locally(state, &provider, &endpoint, keys, None)
.await? .await?
} else {
"chatgpt_web" => {
refresh_chatgpt_web_provider_quota_locally(state, &provider, &endpoint, keys, None)
.await?
}
_ => None,
}) else {
return Ok(None); return Ok(None);
}; };
Ok(Some(Json(payload).into_response())) Ok(Some(Json(payload).into_response()))

View File

@@ -5,13 +5,12 @@ use crate::handlers::admin::provider::shared::payloads::{
use crate::handlers::admin::request::{AdminAppState, AdminKiroAuthConfig}; use crate::handlers::admin::request::{AdminAppState, AdminKiroAuthConfig};
use crate::provider_transport::kiro::{build_kiro_request_auth_from_config, KiroRequestAuth}; use crate::provider_transport::kiro::{build_kiro_request_auth_from_config, KiroRequestAuth};
use aether_contracts::ProxySnapshot; use aether_contracts::ProxySnapshot;
use serde_json::{json, Value}; use aether_oauth::core::OAuthError;
use std::time::{SystemTime, UNIX_EPOCH}; use aether_oauth::provider::providers::KiroProviderOAuthAdapter;
use aether_oauth::provider::ProviderOAuthTransportContext;
use serde_json::Value;
use url::form_urlencoded; use url::form_urlencoded;
const KIRO_IDC_AMZ_USER_AGENT: &str =
"aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
pub(super) fn admin_provider_oauth_kiro_refresh_base_url_override( pub(super) fn admin_provider_oauth_kiro_refresh_base_url_override(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
override_key: &str, override_key: &str,
@@ -21,29 +20,6 @@ pub(super) fn admin_provider_oauth_kiro_refresh_base_url_override(
(!normalized.is_empty()).then(|| normalized.to_string()) (!normalized.is_empty()).then(|| normalized.to_string())
} }
fn admin_provider_oauth_kiro_build_refresh_url(
auth_config: &AdminKiroAuthConfig,
override_base_url: Option<&str>,
path: &str,
default_host: impl FnOnce(&str) -> String,
) -> String {
if let Some(base_url) = override_base_url
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{}/{}", base_url.trim_end_matches('/'), path);
}
let region = auth_config.effective_auth_region();
default_host(region)
}
fn admin_provider_oauth_kiro_effective_host(url: &str, fallback_host: String) -> String {
reqwest::Url::parse(url)
.ok()
.and_then(|value| value.host_str().map(ToOwned::to_owned))
.unwrap_or(fallback_host)
}
fn admin_provider_oauth_kiro_ide_tag(kiro_version: &str, machine_id: &str) -> String { fn admin_provider_oauth_kiro_ide_tag(kiro_version: &str, machine_id: &str) -> String {
if machine_id.trim().is_empty() { if machine_id.trim().is_empty() {
format!("KiroIDE-{kiro_version}") format!("KiroIDE-{kiro_version}")
@@ -52,41 +28,49 @@ fn admin_provider_oauth_kiro_ide_tag(kiro_version: &str, machine_id: &str) -> St
} }
} }
fn admin_provider_oauth_kiro_refresh_expires_at(payload: &Value) -> u64 { fn admin_provider_oauth_kiro_refresh_context(
let expires_in = payload proxy: Option<ProxySnapshot>,
.get("expiresIn") ) -> ProviderOAuthTransportContext {
.and_then(|value| { ProviderOAuthTransportContext {
value provider_id: String::new(),
.as_u64() provider_type: "kiro".to_string(),
.or_else(|| value.as_str()?.parse::<u64>().ok()) endpoint_id: None,
}) key_id: None,
.unwrap_or(3600); auth_type: Some("oauth".to_string()),
SystemTime::now() decrypted_api_key: None,
.duration_since(UNIX_EPOCH) decrypted_auth_config: None,
.ok() provider_config: None,
.map(|value| value.as_secs()) endpoint_config: None,
.unwrap_or_default() key_config: None,
.saturating_add(expires_in) network: aether_oauth::network::OAuthNetworkContext::provider_operation(proxy),
}
} }
fn admin_provider_oauth_kiro_refresh_response_json( fn admin_provider_oauth_kiro_refresh_error(
body_text: &str, auth_config: &AdminKiroAuthConfig,
json_body: Option<Value>, error: OAuthError,
) -> Result<Value, String> {
json_body
.or_else(|| serde_json::from_str::<Value>(body_text).ok())
.ok_or_else(|| "refresh 接口返回了非 JSON 响应".to_string())
}
fn admin_provider_oauth_kiro_refresh_error_detail(
status: http::StatusCode,
body_text: &str,
) -> String { ) -> String {
let detail = body_text.trim(); let prefix = if auth_config.is_idc_auth() {
if detail.is_empty() { "IDC refresh"
format!("HTTP {}", status.as_u16())
} else { } else {
detail.to_string() "social refresh"
};
match error {
OAuthError::HttpStatus {
status_code,
body_excerpt,
} => {
let detail = body_excerpt.trim();
if detail.is_empty() {
format!("{prefix} 失败: HTTP {status_code}")
} else {
format!("{prefix} 失败: {detail}")
}
}
OAuthError::Transport(message) => format!("{prefix} 请求失败: {message}"),
OAuthError::InvalidRequest(message) => format!("{prefix} 参数无效: {message}"),
OAuthError::InvalidResponse(message) => format!("{prefix} 返回无效响应: {message}"),
error => format!("{prefix} 失败: {error}"),
} }
} }
@@ -97,216 +81,25 @@ pub(super) async fn refresh_admin_provider_oauth_kiro_auth_config(
social_refresh_base_url: Option<&str>, social_refresh_base_url: Option<&str>,
idc_refresh_base_url: Option<&str>, idc_refresh_base_url: Option<&str>,
) -> Result<AdminKiroAuthConfig, String> { ) -> Result<AdminKiroAuthConfig, String> {
if auth_config.is_idc_auth() { let adapter = KiroProviderOAuthAdapter::default().with_refresh_base_urls(
let fallback_host = format!("oidc.{}.amazonaws.com", auth_config.effective_auth_region()); social_refresh_base_url
let url = admin_provider_oauth_kiro_build_refresh_url( .map(str::trim)
auth_config, .filter(|value| !value.is_empty())
idc_refresh_base_url, .map(ToOwned::to_owned),
"token", idc_refresh_base_url
|region| format!("https://oidc.{region}.amazonaws.com/token"), .map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
); );
let host = admin_provider_oauth_kiro_effective_host(&url, fallback_host); let ctx = admin_provider_oauth_kiro_refresh_context(proxy);
let headers = reqwest::header::HeaderMap::from_iter([ adapter
( .refresh_auth_config(
reqwest::header::CONTENT_TYPE, &crate::oauth::GatewayOAuthHttpExecutor::new(*state),
reqwest::header::HeaderValue::from_static("application/json"), &ctx,
), auth_config,
(
reqwest::header::HOST,
reqwest::header::HeaderValue::from_str(&host)
.map_err(|_| "IDC host 无效".to_string())?,
),
(
reqwest::header::HeaderName::from_static("x-amz-user-agent"),
reqwest::header::HeaderValue::from_static(KIRO_IDC_AMZ_USER_AGENT),
),
(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("node"),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("*/*"),
),
]);
let response = state
.execute_admin_provider_oauth_http_request(
"kiro_batch_refresh:idc",
reqwest::Method::POST,
&url,
&headers,
Some("application/json"),
Some(json!({
"clientId": auth_config
.client_id
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"clientSecret": auth_config
.client_secret
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"grantType": "refresh_token",
})),
None,
proxy.clone(),
) )
.await .await
.map_err(|err| format!("IDC refresh 请求失败: {err}"))?; .map_err(|error| admin_provider_oauth_kiro_refresh_error(auth_config, error))
if !response.status.is_success() {
return Err(format!(
"IDC refresh 失败: {}",
admin_provider_oauth_kiro_refresh_error_detail(
response.status,
&response.body_text
)
));
}
let payload = admin_provider_oauth_kiro_refresh_response_json(
&response.body_text,
response.json_body,
)?;
let access_token = payload
.get("accessToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "IDC refresh 返回了空 accessToken".to_string())?;
let mut refreshed = auth_config.clone();
refreshed.access_token = Some(access_token.to_string());
refreshed.expires_at = Some(admin_provider_oauth_kiro_refresh_expires_at(&payload));
if refreshed
.machine_id
.as_deref()
.map(str::trim)
.is_none_or(|value| value.is_empty())
{
refreshed.machine_id =
crate::provider_transport::kiro::generate_machine_id(auth_config, None);
}
if let Some(refresh_token) = payload
.get("refreshToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
refreshed.refresh_token = Some(refresh_token.to_string());
}
return Ok(refreshed);
}
let machine_id = crate::provider_transport::kiro::generate_machine_id(auth_config, None)
.ok_or_else(|| "缺少 machine_id 种子,无法刷新 social token".to_string())?;
let fallback_host = format!(
"prod.{}.auth.desktop.kiro.dev",
auth_config.effective_auth_region()
);
let url = admin_provider_oauth_kiro_build_refresh_url(
auth_config,
social_refresh_base_url,
"refreshToken",
|region| format!("https://prod.{region}.auth.desktop.kiro.dev/refreshToken"),
);
let host = admin_provider_oauth_kiro_effective_host(&url, fallback_host);
let user_agent =
admin_provider_oauth_kiro_ide_tag(auth_config.effective_kiro_version(), &machine_id);
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_str(&user_agent)
.map_err(|_| "Kiro User-Agent 无效".to_string())?,
),
(
reqwest::header::HOST,
reqwest::header::HeaderValue::from_str(&host)
.map_err(|_| "Kiro host 无效".to_string())?,
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json, text/plain, */*"),
),
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
),
(
reqwest::header::CONNECTION,
reqwest::header::HeaderValue::from_static("close"),
),
(
reqwest::header::ACCEPT_ENCODING,
reqwest::header::HeaderValue::from_static("gzip, compress, deflate, br"),
),
]);
let response = state
.execute_admin_provider_oauth_http_request(
"kiro_batch_refresh:social",
reqwest::Method::POST,
&url,
&headers,
Some("application/json"),
Some(json!({
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default(),
})),
None,
proxy,
)
.await
.map_err(|err| format!("social refresh 请求失败: {err}"))?;
if !response.status.is_success() {
return Err(format!(
"social refresh 失败: {}",
admin_provider_oauth_kiro_refresh_error_detail(response.status, &response.body_text)
));
}
let payload =
admin_provider_oauth_kiro_refresh_response_json(&response.body_text, response.json_body)?;
let access_token = payload
.get("accessToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "social refresh 返回了空 accessToken".to_string())?;
let mut refreshed = auth_config.clone();
refreshed.access_token = Some(access_token.to_string());
refreshed.expires_at = Some(admin_provider_oauth_kiro_refresh_expires_at(&payload));
if refreshed
.machine_id
.as_deref()
.map(str::trim)
.is_none_or(|value| value.is_empty())
{
refreshed.machine_id = Some(machine_id);
}
if let Some(refresh_token) = payload
.get("refreshToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
refreshed.refresh_token = Some(refresh_token.to_string());
}
if let Some(profile_arn) = payload
.get("profileArn")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
refreshed.profile_arn = Some(profile_arn.to_string());
}
Ok(refreshed)
} }
fn build_kiro_usage_url(auth: &KiroRequestAuth) -> String { fn build_kiro_usage_url(auth: &KiroRequestAuth) -> String {

View File

@@ -1,17 +1,17 @@
use super::shared::{ use super::shared::{
build_quota_snapshot_payload, coerce_json_f64, coerce_json_string, build_provider_quota_execution_plan, build_quota_snapshot_payload, coerce_json_f64,
default_provider_quota_execution_timeouts, execute_provider_quota_plan, coerce_json_string, default_provider_quota_execution_timeouts, execute_provider_quota_plan,
extract_execution_error_message, persist_provider_quota_refresh_state, extract_execution_error_message, persist_provider_quota_refresh_state,
quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome, quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome,
}; };
use crate::handlers::admin::provider::shared::payloads::ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH;
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot}; use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError; use crate::GatewayError;
use aether_admin::provider::quota::parse_antigravity_usage_response; use aether_admin::provider::quota::parse_antigravity_usage_response;
use aether_contracts::{ExecutionPlan, ProxySnapshot, RequestBody}; use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
}; };
use aether_provider_pool::build_antigravity_pool_quota_request;
use serde_json::json; use serde_json::json;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -21,18 +21,9 @@ async fn execute_antigravity_quota_plan(
transport: &AdminGatewayProviderTransportSnapshot, transport: &AdminGatewayProviderTransportSnapshot,
authorization: (String, String), authorization: (String, String),
project_id: &str, project_id: &str,
mut identity_headers: BTreeMap<String, String>, identity_headers: BTreeMap<String, String>,
proxy_override: Option<&ProxySnapshot>, proxy_override: Option<&ProxySnapshot>,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> { ) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let mut headers = std::mem::take(&mut identity_headers);
headers.insert("authorization".to_string(), authorization.1);
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("accept".to_string(), "application/json".to_string());
headers
.entry("user-agent".to_string())
.or_insert_with(|| "antigravity".to_string());
let body = json!({ "project": project_id });
let proxy = match proxy_override { let proxy = match proxy_override {
Some(proxy) => Some(proxy.clone()), Some(proxy) => Some(proxy.clone()),
None => { None => {
@@ -46,35 +37,20 @@ async fn execute_antigravity_quota_plan(
.or(Some(default_provider_quota_execution_timeouts( .or(Some(default_provider_quota_execution_timeouts(
proxy.as_ref(), proxy.as_ref(),
))); )));
let plan = ExecutionPlan { let spec = build_antigravity_pool_quota_request(
request_id: format!("antigravity-quota:{}", transport.key.id), &transport.key.id,
candidate_id: None, &transport.endpoint.base_url,
provider_name: Some("antigravity".to_string()), authorization,
provider_id: transport.provider.id.clone(), project_id,
endpoint_id: transport.endpoint.id.clone(), identity_headers,
key_id: transport.key.id.clone(), );
method: "POST".to_string(), let plan = build_provider_quota_execution_plan(
url: format!( transport,
"{}{}", spec,
transport.endpoint.base_url.trim_end_matches('/'),
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH
),
headers,
content_type: Some("application/json".to_string()),
content_encoding: None,
body: RequestBody {
json_body: Some(body),
body_bytes_b64: None,
body_ref: None,
},
stream: false,
client_api_format: "gemini:generate_content".to_string(),
provider_api_format: "antigravity:fetch_available_models".to_string(),
model_name: Some("fetchAvailableModels".to_string()),
proxy, proxy,
transport_profile: state.resolve_transport_profile(transport), state.resolve_transport_profile(transport),
timeouts, timeouts,
}; );
execute_provider_quota_plan(state, transport, plan, "antigravity").await execute_provider_quota_plan(state, transport, plan, "antigravity").await
} }

View File

@@ -10,94 +10,18 @@ use crate::handlers::admin::provider::shared::payloads::{
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot}; use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError; use crate::GatewayError;
use aether_admin::provider::quota::parse_chatgpt_web_conversation_init_response; use aether_admin::provider::quota::parse_chatgpt_web_conversation_init_response;
use aether_contracts::{ use aether_contracts::ProxySnapshot;
ExecutionPlan, ProxySnapshot, RequestBody, EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER,
};
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
}; };
use aether_provider_pool::{
build_chatgpt_web_pool_quota_request, enrich_chatgpt_web_quota_metadata,
normalize_chatgpt_web_image_quota_limit,
};
use serde_json::json; use serde_json::json;
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
const CHATGPT_WEB_DEFAULT_BASE_URL: &str = "https://chatgpt.com";
const CHATGPT_WEB_CONVERSATION_INIT_PATH: &str = "/backend-api/conversation/init";
const CHATGPT_WEB_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0";
const CHATGPT_WEB_CLIENT_VERSION: &str = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad";
const CHATGPT_WEB_BUILD_NUMBER: &str = "5955942";
const CHATGPT_WEB_SEC_CH_UA: &str =
r#""Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24""#;
const PLACEHOLDER_API_KEY: &str = "__placeholder__"; const PLACEHOLDER_API_KEY: &str = "__placeholder__";
const CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT: f64 = 25.0;
fn chatgpt_web_base_url(endpoint: &StoredProviderCatalogEndpoint) -> String {
let base_url = endpoint.base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
CHATGPT_WEB_DEFAULT_BASE_URL.to_string()
} else {
base_url.to_string()
}
}
fn build_chatgpt_web_quota_headers(
authorization: (String, String),
base_url: &str,
) -> BTreeMap<String, String> {
let device_id = uuid::Uuid::new_v4().to_string();
let session_id = uuid::Uuid::new_v4().to_string();
let mut headers = BTreeMap::from([
("accept".to_string(), "application/json".to_string()),
("content-type".to_string(), "application/json".to_string()),
("user-agent".to_string(), CHATGPT_WEB_USER_AGENT.to_string()),
("origin".to_string(), base_url.to_string()),
("referer".to_string(), format!("{base_url}/")),
(
"accept-language".to_string(),
"zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7".to_string(),
),
("cache-control".to_string(), "no-cache".to_string()),
("pragma".to_string(), "no-cache".to_string()),
("priority".to_string(), "u=1, i".to_string()),
("sec-ch-ua".to_string(), CHATGPT_WEB_SEC_CH_UA.to_string()),
("sec-ch-ua-arch".to_string(), r#""x86""#.to_string()),
("sec-ch-ua-bitness".to_string(), r#""64""#.to_string()),
("sec-ch-ua-mobile".to_string(), "?0".to_string()),
("sec-ch-ua-model".to_string(), r#""""#.to_string()),
("sec-ch-ua-platform".to_string(), r#""Windows""#.to_string()),
(
"sec-ch-ua-platform-version".to_string(),
r#""19.0.0""#.to_string(),
),
("sec-fetch-dest".to_string(), "empty".to_string()),
("sec-fetch-mode".to_string(), "cors".to_string()),
("sec-fetch-site".to_string(), "same-origin".to_string()),
("oai-device-id".to_string(), device_id),
("oai-session-id".to_string(), session_id),
("oai-language".to_string(), "zh-CN".to_string()),
(
"oai-client-version".to_string(),
CHATGPT_WEB_CLIENT_VERSION.to_string(),
),
(
"oai-client-build-number".to_string(),
CHATGPT_WEB_BUILD_NUMBER.to_string(),
),
(
"x-openai-target-path".to_string(),
CHATGPT_WEB_CONVERSATION_INIT_PATH.to_string(),
),
(
"x-openai-target-route".to_string(),
CHATGPT_WEB_CONVERSATION_INIT_PATH.to_string(),
),
(
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER.to_string(),
"true".to_string(),
),
]);
headers.insert(authorization.0.to_ascii_lowercase(), authorization.1);
headers
}
fn chatgpt_web_auth_config( fn chatgpt_web_auth_config(
transport: &AdminGatewayProviderTransportSnapshot, transport: &AdminGatewayProviderTransportSnapshot,
@@ -111,133 +35,6 @@ fn chatgpt_web_auth_config(
.and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok()) .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
} }
fn chatgpt_web_auth_config_string(
auth_config: Option<&serde_json::Value>,
fields: &[&str],
) -> Option<String> {
let object = auth_config.and_then(serde_json::Value::as_object)?;
fields.iter().find_map(|field| {
object
.get(*field)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn enrich_chatgpt_web_quota_metadata(
metadata: &mut serde_json::Value,
auth_config: Option<&serde_json::Value>,
) {
let Some(object) = metadata.as_object_mut() else {
return;
};
for (target, fields) in [
("plan_type", &["plan_type", "tier", "plan"][..]),
("email", &["email"][..]),
("account_id", &["account_id", "accountId"][..]),
("account_user_id", &["account_user_id", "accountUserId"][..]),
("user_id", &["user_id", "userId"][..]),
] {
if object.contains_key(target) {
continue;
}
if let Some(value) = chatgpt_web_auth_config_string(auth_config, fields) {
object.insert(target.to_string(), json!(value));
}
}
}
fn chatgpt_web_json_number(value: Option<&serde_json::Value>) -> Option<f64> {
let value = value?;
if let Some(number) = value.as_f64() {
return number.is_finite().then_some(number);
}
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| value.is_finite())
}
fn chatgpt_web_json_string(value: Option<&serde_json::Value>) -> Option<&str> {
value
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn existing_chatgpt_web_image_quota_limit(
upstream_metadata: Option<&serde_json::Value>,
) -> Option<f64> {
upstream_metadata
.and_then(serde_json::Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.and_then(serde_json::Value::as_object)
.and_then(|bucket| chatgpt_web_json_number(bucket.get("image_quota_total")))
.filter(|value| *value > 0.0)
}
fn infer_chatgpt_web_image_quota_limit(
plan_type: Option<&str>,
remaining: Option<f64>,
existing_limit: Option<f64>,
) -> Option<f64> {
let normalized_plan = plan_type.unwrap_or_default().trim().to_ascii_lowercase();
if normalized_plan == "free" {
return Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT);
}
if let Some(existing_limit) = existing_limit.filter(|value| *value > 0.0) {
return Some(existing_limit);
}
remaining.filter(|value| *value > 0.0)
}
fn normalize_chatgpt_web_image_quota_limit(
metadata: &mut serde_json::Value,
upstream_metadata: Option<&serde_json::Value>,
) {
let existing_limit = existing_chatgpt_web_image_quota_limit(upstream_metadata);
let Some(object) = metadata.as_object_mut() else {
return;
};
let remaining = chatgpt_web_json_number(object.get("image_quota_remaining"));
let explicit_limit =
chatgpt_web_json_number(object.get("image_quota_total")).filter(|value| *value > 0.0);
let plan_type = chatgpt_web_json_string(object.get("plan_type"));
let is_free_plan = plan_type.is_some_and(|value| value.trim().eq_ignore_ascii_case("free"));
let limit = if is_free_plan {
Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT)
} else {
explicit_limit
.or_else(|| infer_chatgpt_web_image_quota_limit(plan_type, remaining, existing_limit))
};
if let Some(limit) = limit {
object.insert("image_quota_total".to_string(), json!(limit));
if !object.contains_key("image_quota_used") {
if let Some(remaining) = remaining {
object.insert(
"image_quota_used".to_string(),
json!((limit - remaining).max(0.0)),
);
} else if object
.get("image_quota_blocked")
.and_then(serde_json::Value::as_bool)
== Some(true)
{
object.insert("image_quota_used".to_string(), json!(limit));
}
}
}
}
async fn resolve_chatgpt_web_quota_auth( async fn resolve_chatgpt_web_quota_auth(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot, transport: &AdminGatewayProviderTransportSnapshot,
@@ -262,7 +59,6 @@ async fn execute_chatgpt_web_quota_plan(
authorization: (String, String), authorization: (String, String),
proxy_override: Option<&ProxySnapshot>, proxy_override: Option<&ProxySnapshot>,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> { ) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let base_url = chatgpt_web_base_url(endpoint);
let proxy = match proxy_override { let proxy = match proxy_override {
Some(proxy) => Some(proxy.clone()), Some(proxy) => Some(proxy.clone()),
None => { None => {
@@ -276,33 +72,15 @@ async fn execute_chatgpt_web_quota_plan(
.or(Some(default_provider_quota_execution_timeouts( .or(Some(default_provider_quota_execution_timeouts(
proxy.as_ref(), proxy.as_ref(),
))); )));
let plan = ExecutionPlan { let spec =
request_id: format!("chatgpt-web-quota:{}", transport.key.id), build_chatgpt_web_pool_quota_request(&transport.key.id, &endpoint.base_url, authorization);
candidate_id: None, let plan = super::shared::build_provider_quota_execution_plan(
provider_name: Some("chatgpt_web".to_string()), transport,
provider_id: transport.provider.id.clone(), spec,
endpoint_id: transport.endpoint.id.clone(),
key_id: transport.key.id.clone(),
method: "POST".to_string(),
url: format!("{base_url}{CHATGPT_WEB_CONVERSATION_INIT_PATH}"),
headers: build_chatgpt_web_quota_headers(authorization, base_url.as_str()),
content_type: Some("application/json".to_string()),
content_encoding: None,
body: RequestBody::from_json(json!({
"gizmo_id": serde_json::Value::Null,
"requested_default_model": serde_json::Value::Null,
"conversation_id": serde_json::Value::Null,
"timezone_offset_min": -480,
"system_hints": ["picture_v2"],
})),
stream: false,
client_api_format: "openai:image".to_string(),
provider_api_format: "chatgpt_web:conversation_init".to_string(),
model_name: Some("chatgpt-web-conversation-init".to_string()),
proxy, proxy,
transport_profile: state.resolve_transport_profile(transport), state.resolve_transport_profile(transport),
timeouts, timeouts,
}; );
execute_provider_quota_plan(state, transport, plan, "chatgpt_web").await execute_provider_quota_plan(state, transport, plan, "chatgpt_web").await
} }

View File

@@ -11,7 +11,7 @@ use self::parse::{
build_codex_quota_exhausted_fallback_metadata, parse_codex_usage_headers, build_codex_quota_exhausted_fallback_metadata, parse_codex_usage_headers,
parse_codex_wham_usage_response, parse_codex_wham_usage_response,
}; };
use self::plan::{build_codex_refresh_headers, execute_codex_quota_plan}; use self::plan::{build_codex_quota_request_spec, execute_codex_quota_plan};
use super::shared::{ use super::shared::{
build_quota_snapshot_payload, extract_execution_error_message, build_quota_snapshot_payload, extract_execution_error_message,
persist_provider_quota_refresh_state, provider_auto_remove_banned_keys, persist_provider_quota_refresh_state, provider_auto_remove_banned_keys,
@@ -82,8 +82,8 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
None None
}; };
let headers = match build_codex_refresh_headers(&transport, resolved_oauth_auth) { let request_spec = match build_codex_quota_request_spec(&transport, resolved_oauth_auth) {
Ok(headers) => headers, Ok(request_spec) => request_spec,
Err(message) => { Err(message) => {
failed_count += 1; failed_count += 1;
results.push(json!({ results.push(json!({
@@ -96,8 +96,12 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
} }
}; };
let result = let result = match execute_codex_quota_plan(
match execute_codex_quota_plan(state, &transport, headers, proxy_override.as_ref()) state,
&transport,
request_spec,
proxy_override.as_ref(),
)
.await? .await?
{ {
ProviderQuotaExecutionOutcome::Response(result) => result, ProviderQuotaExecutionOutcome::Response(result) => result,

View File

@@ -1,65 +1,33 @@
use super::super::shared::{ use super::super::shared::{
default_provider_quota_execution_timeouts, execute_provider_quota_plan, build_provider_quota_execution_plan, default_provider_quota_execution_timeouts,
ProviderQuotaExecutionOutcome, execute_provider_quota_plan, ProviderQuotaExecutionOutcome,
}; };
use super::parse::normalize_codex_plan_type;
use crate::handlers::admin::provider::shared::payloads::CODEX_WHAM_USAGE_URL;
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot}; use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError; use crate::GatewayError;
use aether_contracts::{ExecutionPlan, ProxySnapshot, RequestBody}; use aether_contracts::ProxySnapshot;
use std::collections::BTreeMap; use aether_provider_pool::{build_codex_pool_quota_request, ProviderPoolQuotaRequestSpec};
pub(super) fn build_codex_refresh_headers( pub(super) fn build_codex_quota_request_spec(
transport: &AdminGatewayProviderTransportSnapshot, transport: &AdminGatewayProviderTransportSnapshot,
resolved_oauth_auth: Option<(String, String)>, resolved_oauth_auth: Option<(String, String)>,
) -> Result<BTreeMap<String, String>, String> { ) -> Result<ProviderPoolQuotaRequestSpec, String> {
let mut headers = BTreeMap::new();
headers.insert("accept".to_string(), "application/json".to_string());
if let Some((name, value)) = resolved_oauth_auth {
headers.insert(name.to_ascii_lowercase(), value);
} else {
let decrypted_key = transport.key.decrypted_api_key.trim();
if decrypted_key.is_empty() || decrypted_key == "__placeholder__" {
return Err("缺少 OAuth 认证信息,请先授权/刷新 Token".to_string());
}
headers.insert(
"authorization".to_string(),
format!("Bearer {decrypted_key}"),
);
}
let auth_config = transport let auth_config = transport
.key .key
.decrypted_auth_config .decrypted_auth_config
.as_deref() .as_deref()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok()); .and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok());
let oauth_plan_type = normalize_codex_plan_type( build_codex_pool_quota_request(
auth_config &transport.key.id,
.as_ref() resolved_oauth_auth,
.and_then(|value| value.get("plan_type")) Some(transport.key.decrypted_api_key.as_str()),
.and_then(serde_json::Value::as_str), auth_config.as_ref(),
); )
let oauth_account_id = auth_config
.as_ref()
.and_then(|value| value.get("account_id"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if oauth_account_id.is_some() && oauth_plan_type.as_deref() != Some("free") {
headers.insert(
"chatgpt-account-id".to_string(),
oauth_account_id.unwrap_or_default().to_string(),
);
}
Ok(headers)
} }
pub(super) async fn execute_codex_quota_plan( pub(super) async fn execute_codex_quota_plan(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot, transport: &AdminGatewayProviderTransportSnapshot,
headers: BTreeMap<String, String>, spec: ProviderPoolQuotaRequestSpec,
proxy_override: Option<&ProxySnapshot>, proxy_override: Option<&ProxySnapshot>,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> { ) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let proxy = match proxy_override { let proxy = match proxy_override {
@@ -75,30 +43,12 @@ pub(super) async fn execute_codex_quota_plan(
.or(Some(default_provider_quota_execution_timeouts( .or(Some(default_provider_quota_execution_timeouts(
proxy.as_ref(), proxy.as_ref(),
))); )));
let plan = ExecutionPlan { let plan = build_provider_quota_execution_plan(
request_id: format!("codex-quota:{}", transport.key.id), transport,
candidate_id: None, spec,
provider_name: Some("codex".to_string()),
provider_id: transport.provider.id.clone(),
endpoint_id: transport.endpoint.id.clone(),
key_id: transport.key.id.clone(),
method: "GET".to_string(),
url: CODEX_WHAM_USAGE_URL.to_string(),
headers,
content_type: None,
content_encoding: None,
body: RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
stream: false,
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("codex-wham-usage".to_string()),
proxy, proxy,
transport_profile: state.resolve_transport_profile(transport), state.resolve_transport_profile(transport),
timeouts, timeouts,
}; );
execute_provider_quota_plan(state, transport, plan, "codex").await execute_provider_quota_plan(state, transport, plan, "codex").await
} }

View File

@@ -0,0 +1,119 @@
use std::future::Future;
use std::pin::Pin;
use super::antigravity::refresh_antigravity_provider_quota_locally;
use super::chatgpt_web::refresh_chatgpt_web_provider_quota_locally;
use super::codex::refresh_codex_provider_quota_locally;
use super::kiro::refresh_kiro_provider_quota_locally;
use crate::handlers::admin::request::AdminAppState;
use crate::GatewayError;
use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
type ProviderQuotaRefreshFuture<'a> =
Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, GatewayError>> + Send + 'a>>;
type ProviderQuotaRefreshHandler = for<'a> fn(
&'a AdminAppState<'a>,
&'a StoredProviderCatalogProvider,
&'a StoredProviderCatalogEndpoint,
Vec<StoredProviderCatalogKey>,
Option<ProxySnapshot>,
) -> ProviderQuotaRefreshFuture<'a>;
const PROVIDER_QUOTA_REFRESH_HANDLERS: &[(&str, ProviderQuotaRefreshHandler)] = &[
(
"antigravity",
refresh_antigravity_provider_quota_locally_boxed,
),
(
"chatgpt_web",
refresh_chatgpt_web_provider_quota_locally_boxed,
),
("codex", refresh_codex_provider_quota_locally_boxed),
("kiro", refresh_kiro_provider_quota_locally_boxed),
];
pub(crate) async fn refresh_provider_pool_quota_locally(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
endpoint: &StoredProviderCatalogEndpoint,
provider_type: &str,
keys: Vec<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> Result<Option<serde_json::Value>, GatewayError> {
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
let Some((_, handler)) = PROVIDER_QUOTA_REFRESH_HANDLERS
.iter()
.find(|(supported_provider_type, _)| *supported_provider_type == normalized_provider_type)
else {
return Ok(None);
};
handler(state, provider, endpoint, keys, proxy_override).await
}
fn refresh_antigravity_provider_quota_locally_boxed<'a>(
state: &'a AdminAppState<'a>,
provider: &'a StoredProviderCatalogProvider,
endpoint: &'a StoredProviderCatalogEndpoint,
keys: Vec<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> ProviderQuotaRefreshFuture<'a> {
Box::pin(refresh_antigravity_provider_quota_locally(
state,
provider,
endpoint,
keys,
proxy_override,
))
}
fn refresh_chatgpt_web_provider_quota_locally_boxed<'a>(
state: &'a AdminAppState<'a>,
provider: &'a StoredProviderCatalogProvider,
endpoint: &'a StoredProviderCatalogEndpoint,
keys: Vec<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> ProviderQuotaRefreshFuture<'a> {
Box::pin(refresh_chatgpt_web_provider_quota_locally(
state,
provider,
endpoint,
keys,
proxy_override,
))
}
fn refresh_codex_provider_quota_locally_boxed<'a>(
state: &'a AdminAppState<'a>,
provider: &'a StoredProviderCatalogProvider,
endpoint: &'a StoredProviderCatalogEndpoint,
keys: Vec<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> ProviderQuotaRefreshFuture<'a> {
Box::pin(refresh_codex_provider_quota_locally(
state,
provider,
endpoint,
keys,
proxy_override,
))
}
fn refresh_kiro_provider_quota_locally_boxed<'a>(
state: &'a AdminAppState<'a>,
provider: &'a StoredProviderCatalogProvider,
endpoint: &'a StoredProviderCatalogEndpoint,
keys: Vec<StoredProviderCatalogKey>,
proxy_override: Option<ProxySnapshot>,
) -> ProviderQuotaRefreshFuture<'a> {
Box::pin(refresh_kiro_provider_quota_locally(
state,
provider,
endpoint,
keys,
proxy_override,
))
}

View File

@@ -1,66 +1,13 @@
use super::super::shared::default_provider_quota_execution_timeouts; use super::super::shared::{
use super::super::shared::{execute_provider_quota_plan, ProviderQuotaExecutionOutcome}; build_provider_quota_execution_plan, default_provider_quota_execution_timeouts,
use crate::handlers::admin::provider::shared::payloads::{ execute_provider_quota_plan, ProviderQuotaExecutionOutcome,
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
}; };
use crate::handlers::admin::request::{ use crate::handlers::admin::request::{
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminKiroRequestAuth, AdminAppState, AdminGatewayProviderTransportSnapshot, AdminKiroRequestAuth,
}; };
use crate::GatewayError; use crate::GatewayError;
use aether_contracts::{ExecutionPlan, ProxySnapshot, RequestBody}; use aether_contracts::ProxySnapshot;
use std::collections::BTreeMap; use aether_provider_pool::{build_kiro_pool_quota_request, KiroPoolQuotaAuthInput};
use url::form_urlencoded;
use uuid::Uuid;
fn build_kiro_usage_headers(auth: &AdminKiroRequestAuth) -> BTreeMap<String, String> {
let kiro_version = auth.auth_config.effective_kiro_version();
let machine_id = auth.machine_id.trim();
let ide_tag = if machine_id.is_empty() {
format!("KiroIDE-{kiro_version}")
} else {
format!("KiroIDE-{kiro_version}-{machine_id}")
};
let host = format!(
"q.{}.amazonaws.com",
auth.auth_config.effective_api_region()
);
BTreeMap::from([
(
"x-amz-user-agent".to_string(),
format!("aws-sdk-js/{KIRO_USAGE_SDK_VERSION} {ide_tag}"),
),
(
"user-agent".to_string(),
format!(
"aws-sdk-js/{KIRO_USAGE_SDK_VERSION} ua/2.1 os/other#unknown lang/js md/nodejs#22.21.1 api/codewhispererruntime#1.0.0 m/N,E {ide_tag}"
),
),
("host".to_string(), host),
("amz-sdk-invocation-id".to_string(), Uuid::new_v4().to_string()),
("amz-sdk-request".to_string(), "attempt=1; max=1".to_string()),
("authorization".to_string(), auth.value.clone()),
("connection".to_string(), "close".to_string()),
])
}
fn build_kiro_usage_url(auth: &AdminKiroRequestAuth) -> String {
let host = format!(
"q.{}.amazonaws.com",
auth.auth_config.effective_api_region()
);
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.append_pair("origin", "AI_EDITOR");
serializer.append_pair("resourceType", "AGENTIC_REQUEST");
serializer.append_pair("isEmailRequired", "true");
if let Some(profile_arn) = auth.auth_config.profile_arn_for_payload() {
serializer.append_pair("profileArn", profile_arn);
}
format!(
"https://{host}{KIRO_USAGE_LIMITS_PATH}?{}",
serializer.finish()
)
}
pub(super) async fn execute_kiro_quota_plan( pub(super) async fn execute_kiro_quota_plan(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
@@ -81,31 +28,26 @@ pub(super) async fn execute_kiro_quota_plan(
.or(Some(default_provider_quota_execution_timeouts( .or(Some(default_provider_quota_execution_timeouts(
proxy.as_ref(), proxy.as_ref(),
))); )));
let plan = ExecutionPlan { let spec = build_kiro_pool_quota_request(
request_id: format!("kiro-quota:{}", transport.key.id), &transport.key.id,
candidate_id: None, &KiroPoolQuotaAuthInput {
provider_name: Some("kiro".to_string()), authorization_value: auth.value.clone(),
provider_id: transport.provider.id.clone(), api_region: auth.auth_config.effective_api_region().to_string(),
endpoint_id: transport.endpoint.id.clone(), kiro_version: auth.auth_config.effective_kiro_version().to_string(),
key_id: transport.key.id.clone(), machine_id: auth.machine_id.clone(),
method: "GET".to_string(), profile_arn: auth
url: build_kiro_usage_url(auth), .auth_config
headers: build_kiro_usage_headers(auth), .profile_arn_for_payload()
content_type: None, .map(str::to_string),
content_encoding: None,
body: RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
}, },
stream: false, );
client_api_format: "claude:messages".to_string(), let plan = build_provider_quota_execution_plan(
provider_api_format: "kiro:usage".to_string(), transport,
model_name: Some("kiro-usage-limits".to_string()), spec,
proxy, proxy,
transport_profile: state.resolve_transport_profile(transport), state.resolve_transport_profile(transport),
timeouts, timeouts,
}; );
execute_provider_quota_plan(state, transport, plan, "kiro").await execute_provider_quota_plan(state, transport, plan, "kiro").await
} }

View File

@@ -1,5 +1,6 @@
pub(crate) mod antigravity; pub(crate) mod antigravity;
pub(crate) mod chatgpt_web; pub(crate) mod chatgpt_web;
pub(crate) mod codex; pub(crate) mod codex;
pub(crate) mod dispatch;
pub(crate) mod kiro; pub(crate) mod kiro;
pub(crate) mod shared; pub(crate) mod shared;

View File

@@ -7,8 +7,14 @@ use crate::handlers::shared::{
}; };
use crate::GatewayError; use crate::GatewayError;
use aether_admin::provider::quota as admin_provider_quota_pure; use aether_admin::provider::quota as admin_provider_quota_pure;
use aether_contracts::{ExecutionPlan, ExecutionResult, ExecutionTimeouts, ProxySnapshot}; use aether_contracts::{
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey; ExecutionPlan, ExecutionResult, ExecutionTimeouts, ProxySnapshot, RequestBody,
ResolvedTransportProfile, EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use aether_provider_pool::{ProviderPoolQuotaRequestSpec, ProviderPoolService};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn; use tracing::warn;
@@ -51,22 +57,28 @@ pub(crate) fn normalize_string_id_list(values: Option<Vec<String>>) -> Option<Ve
} }
pub(crate) fn provider_type_supports_quota_refresh(provider_type: &str) -> bool { pub(crate) fn provider_type_supports_quota_refresh(provider_type: &str) -> bool {
matches!( ProviderPoolService::with_builtin_adapters().supports_quota_refresh(provider_type)
provider_type.trim().to_ascii_lowercase().as_str(),
"codex" | "kiro" | "antigravity" | "chatgpt_web"
)
} }
pub(crate) fn unsupported_provider_quota_refresh_message(provider_type: &str) -> String { pub(crate) fn unsupported_provider_quota_refresh_message(provider_type: &str) -> String {
match provider_type.trim().to_ascii_lowercase().as_str() { ProviderPoolService::with_builtin_adapters().quota_refresh_unsupported_message(provider_type)
"claude_code" => "Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口", }
"gemini_cli" => {
"Gemini CLI 暂不支持自动刷新额度:当前只能通过模型同步/缓存快照展示已知配额信息" pub(crate) fn provider_quota_refresh_endpoint_for_provider(
} provider_type: &str,
"vertex_ai" => "Vertex AI 暂不支持自动刷新额度:额度属于 Google Cloud 项目/区域配额", endpoints: &[StoredProviderCatalogEndpoint],
_ => "该 Provider 暂不支持自动刷新额度", include_inactive: bool,
} ) -> Option<StoredProviderCatalogEndpoint> {
.to_string() ProviderPoolService::with_builtin_adapters().quota_refresh_endpoint_for_provider(
provider_type,
endpoints,
include_inactive,
)
}
pub(crate) fn provider_quota_refresh_missing_endpoint_message(provider_type: &str) -> String {
ProviderPoolService::with_builtin_adapters()
.quota_refresh_missing_endpoint_message(provider_type)
} }
pub(super) fn coerce_json_u64(value: &serde_json::Value) -> Option<u64> { pub(super) fn coerce_json_u64(value: &serde_json::Value) -> Option<u64> {
@@ -125,6 +137,63 @@ pub(super) fn build_quota_snapshot_payload(
updated_snapshot.get("quota").cloned() updated_snapshot.get("quota").cloned()
} }
pub(super) fn build_provider_quota_execution_plan(
transport: &AdminGatewayProviderTransportSnapshot,
spec: ProviderPoolQuotaRequestSpec,
proxy: Option<ProxySnapshot>,
transport_profile: Option<ResolvedTransportProfile>,
timeouts: Option<ExecutionTimeouts>,
) -> ExecutionPlan {
let ProviderPoolQuotaRequestSpec {
request_id,
provider_name,
quota_kind: _,
method,
url,
mut headers,
content_type,
json_body,
client_api_format,
provider_api_format,
model_name,
accept_invalid_certs,
} = spec;
if accept_invalid_certs {
headers.insert(
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER.to_string(),
"true".to_string(),
);
}
let body = json_body
.map(RequestBody::from_json)
.unwrap_or(RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
});
ExecutionPlan {
request_id,
candidate_id: None,
provider_name: Some(provider_name),
provider_id: transport.provider.id.clone(),
endpoint_id: transport.endpoint.id.clone(),
key_id: transport.key.id.clone(),
method,
url,
headers,
content_type,
content_encoding: None,
body,
stream: false,
client_api_format,
provider_api_format,
model_name,
proxy,
transport_profile,
timeouts,
}
}
pub(crate) async fn persist_provider_quota_refresh_state( pub(crate) async fn persist_provider_quota_refresh_state(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
key_id: &str, key_id: &str,
@@ -142,24 +211,21 @@ pub(crate) async fn persist_provider_quota_refresh_state(
return Ok(false); return Ok(false);
}; };
let mut quota_snapshot_provider_type = None::<&str>; let mut quota_snapshot_provider_type = None::<String>;
if let Some(metadata_update) = metadata_update { if let Some(metadata_update) = metadata_update {
latest_key.upstream_metadata = Some(merge_upstream_metadata( latest_key.upstream_metadata = Some(merge_upstream_metadata(
latest_key.upstream_metadata.as_ref(), latest_key.upstream_metadata.as_ref(),
metadata_update, metadata_update,
)); ));
quota_snapshot_provider_type = metadata_update.as_object().and_then(|object| { quota_snapshot_provider_type =
["codex", "kiro", "antigravity", "gemini_cli", "chatgpt_web"] aether_provider_pool::provider_pool_quota_metadata_provider_type(metadata_update);
.into_iter()
.find(|provider_type| object.contains_key(*provider_type))
});
} }
if let Some(encrypted_auth_config) = encrypted_auth_config { if let Some(encrypted_auth_config) = encrypted_auth_config {
latest_key.encrypted_auth_config = Some(encrypted_auth_config); latest_key.encrypted_auth_config = Some(encrypted_auth_config);
} }
latest_key.oauth_invalid_at_unix_secs = oauth_invalid_at_unix_secs; latest_key.oauth_invalid_at_unix_secs = oauth_invalid_at_unix_secs;
latest_key.oauth_invalid_reason = oauth_invalid_reason; latest_key.oauth_invalid_reason = oauth_invalid_reason;
if let Some(provider_type) = quota_snapshot_provider_type { if let Some(provider_type) = quota_snapshot_provider_type.as_deref() {
latest_key.status_snapshot = sync_provider_key_quota_status_snapshot( latest_key.status_snapshot = sync_provider_key_quota_status_snapshot(
latest_key.status_snapshot.as_ref(), latest_key.status_snapshot.as_ref(),
provider_type, provider_type,

View File

@@ -1,7 +1,6 @@
use super::quota::antigravity::refresh_antigravity_provider_quota_locally; use super::quota::dispatch::refresh_provider_pool_quota_locally;
use super::quota::chatgpt_web::refresh_chatgpt_web_provider_quota_locally; use super::quota::shared::provider_quota_refresh_endpoint_for_provider;
use super::quota::codex::refresh_codex_provider_quota_locally; use super::quota::shared::provider_type_supports_quota_refresh;
use super::quota::kiro::refresh_kiro_provider_quota_locally;
use crate::handlers::admin::provider::write::provider::reconcile_admin_fixed_provider_template_endpoints; use crate::handlers::admin::provider::write::provider::reconcile_admin_fixed_provider_template_endpoints;
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
use crate::provider_key_auth::provider_key_is_oauth_managed; use crate::provider_key_auth::provider_key_is_oauth_managed;
@@ -113,16 +112,20 @@ pub(crate) struct ProviderOAuthRuntimeEndpoints {
pub(crate) runtime_endpoint: Option<StoredProviderCatalogEndpoint>, pub(crate) runtime_endpoint: Option<StoredProviderCatalogEndpoint>,
} }
pub(crate) async fn resolve_provider_oauth_runtime_endpoints( async fn resolve_provider_runtime_endpoints_with_selector(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider, provider: &StoredProviderCatalogProvider,
provider_type: &str, provider_type: &str,
endpoint_selector: fn(
&str,
&[StoredProviderCatalogEndpoint],
bool,
) -> Option<StoredProviderCatalogEndpoint>,
) -> Result<ProviderOAuthRuntimeEndpoints, GatewayError> { ) -> Result<ProviderOAuthRuntimeEndpoints, GatewayError> {
let mut endpoints = state let mut endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id)) .list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
.await?; .await?;
let mut runtime_endpoint = let mut runtime_endpoint = endpoint_selector(provider_type, &endpoints, true);
provider_oauth_maintenance_endpoint_for_provider(provider_type, &endpoints);
if runtime_endpoint.is_none() if runtime_endpoint.is_none()
&& state && state
.fixed_provider_template(&provider.provider_type) .fixed_provider_template(&provider.provider_type)
@@ -133,8 +136,7 @@ pub(crate) async fn resolve_provider_oauth_runtime_endpoints(
endpoints = state endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id)) .list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
.await?; .await?;
runtime_endpoint = runtime_endpoint = endpoint_selector(provider_type, &endpoints, true);
provider_oauth_maintenance_endpoint_for_provider(provider_type, &endpoints);
} }
Ok(ProviderOAuthRuntimeEndpoints { Ok(ProviderOAuthRuntimeEndpoints {
@@ -143,6 +145,34 @@ pub(crate) async fn resolve_provider_oauth_runtime_endpoints(
}) })
} }
pub(crate) async fn resolve_provider_oauth_runtime_endpoints(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
provider_type: &str,
) -> Result<ProviderOAuthRuntimeEndpoints, GatewayError> {
resolve_provider_runtime_endpoints_with_selector(
state,
provider,
provider_type,
select_provider_oauth_runtime_endpoint,
)
.await
}
async fn resolve_provider_quota_runtime_endpoints(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
provider_type: &str,
) -> Result<ProviderOAuthRuntimeEndpoints, GatewayError> {
resolve_provider_runtime_endpoints_with_selector(
state,
provider,
provider_type,
provider_quota_refresh_endpoint_for_provider,
)
.await
}
pub(crate) async fn refresh_provider_oauth_account_state_after_update( pub(crate) async fn refresh_provider_oauth_account_state_after_update(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider, provider: &StoredProviderCatalogProvider,
@@ -150,16 +180,13 @@ pub(crate) async fn refresh_provider_oauth_account_state_after_update(
proxy_override: Option<&ProxySnapshot>, proxy_override: Option<&ProxySnapshot>,
) -> Result<(bool, Option<String>), GatewayError> { ) -> Result<(bool, Option<String>), GatewayError> {
let provider_type = provider.provider_type.trim().to_ascii_lowercase(); let provider_type = provider.provider_type.trim().to_ascii_lowercase();
if !matches!( if !provider_type_supports_quota_refresh(&provider_type) {
provider_type.as_str(),
"codex" | "kiro" | "antigravity" | "chatgpt_web"
) {
return Ok((false, None)); return Ok((false, None));
} }
let ProviderOAuthRuntimeEndpoints { let ProviderOAuthRuntimeEndpoints {
runtime_endpoint, .. runtime_endpoint, ..
} = resolve_provider_oauth_runtime_endpoints(state, provider, &provider_type).await?; } = resolve_provider_quota_runtime_endpoints(state, provider, &provider_type).await?;
let Some(endpoint) = runtime_endpoint else { let Some(endpoint) = runtime_endpoint else {
return Ok((false, None)); return Ok((false, None));
}; };
@@ -175,50 +202,15 @@ pub(crate) async fn refresh_provider_oauth_account_state_after_update(
return Ok((false, None)); return Ok((false, None));
} }
let proxy_override = proxy_override.cloned(); let payload = refresh_provider_pool_quota_locally(
let payload = match provider_type.as_str() {
"codex" => {
refresh_codex_provider_quota_locally(
state, state,
provider, provider,
&endpoint, &endpoint,
&provider_type,
vec![key], vec![key],
proxy_override.clone(), proxy_override.cloned(),
) )
.await? .await?;
}
"kiro" => {
refresh_kiro_provider_quota_locally(
state,
provider,
&endpoint,
vec![key],
proxy_override.clone(),
)
.await?
}
"antigravity" => {
refresh_antigravity_provider_quota_locally(
state,
provider,
&endpoint,
vec![key],
proxy_override,
)
.await?
}
"chatgpt_web" => {
refresh_chatgpt_web_provider_quota_locally(
state,
provider,
&endpoint,
vec![key],
proxy_override,
)
.await?
}
_ => None,
};
let Some(payload) = payload else { let Some(payload) = payload else {
return Ok((false, None)); return Ok((false, None));
}; };

View File

@@ -1,7 +1,7 @@
use crate::handlers::admin::provider::shared::support::{ use crate::handlers::admin::provider::shared::support::{
AdminProviderPoolConfig, AdminProviderPoolSchedulingPreset, AdminProviderPoolUnschedulableRule, AdminProviderPoolConfig, AdminProviderPoolSchedulingPreset, AdminProviderPoolUnschedulableRule,
}; };
use aether_ai_serving::{PoolMemberScoreRules, PoolMemberScoreWeights}; use aether_pool_core::{PoolMemberScoreRules, PoolMemberScoreWeights};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
const POOL_ALLOWED_SCHEDULING_PRESETS: &[&str] = &[ const POOL_ALLOWED_SCHEDULING_PRESETS: &[&str] = &[

View File

@@ -618,7 +618,7 @@ mod tests {
probe_concurrency: 4, probe_concurrency: 4,
score_top_n: 128, score_top_n: 128,
score_fallback_scan_limit: 1024, score_fallback_scan_limit: 1024,
score_rules: aether_ai_serving::PoolMemberScoreRules::default(), score_rules: aether_pool_core::PoolMemberScoreRules::default(),
stream_timeout_threshold: 3, stream_timeout_threshold: 3,
stream_timeout_window_seconds: 1800, stream_timeout_window_seconds: 1800,
stream_timeout_cooldown_seconds: 300, stream_timeout_cooldown_seconds: 300,

View File

@@ -95,27 +95,6 @@ fn admin_pool_oauth_organizations(
.unwrap_or_default() .unwrap_or_default()
} }
fn admin_pool_normalize_oauth_plan_type(value: &str, provider_type: &str) -> Option<String> {
let mut normalized = value.trim().to_string();
if normalized.is_empty() {
return None;
}
let provider_type = provider_type.trim().to_ascii_lowercase();
if !provider_type.is_empty() && normalized.to_ascii_lowercase().starts_with(&provider_type) {
normalized = normalized[provider_type.len()..]
.trim_matches(|ch: char| [' ', ':', '-', '_'].contains(&ch))
.to_string();
}
let normalized = normalized.trim().to_ascii_lowercase();
if normalized.is_empty() {
None
} else {
Some(normalized)
}
}
fn admin_pool_derive_oauth_expires_at( fn admin_pool_derive_oauth_expires_at(
provider_type: &str, provider_type: &str,
key: &StoredProviderCatalogKey, key: &StoredProviderCatalogKey,
@@ -139,57 +118,6 @@ fn admin_pool_derive_oauth_expires_at(
None None
} }
fn admin_pool_derive_oauth_plan_type(
key: &StoredProviderCatalogKey,
provider_type: &str,
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<String> {
if !provider_key_auth_semantics(key, provider_type).oauth_managed() {
return None;
}
if let Some(upstream_metadata) = key
.upstream_metadata
.as_ref()
.and_then(serde_json::Value::as_object)
{
let provider_bucket = upstream_metadata
.get(&provider_type.trim().to_ascii_lowercase())
.and_then(serde_json::Value::as_object);
for source in provider_bucket
.into_iter()
.chain(std::iter::once(upstream_metadata))
{
for field in [
"plan_type",
"tier",
"subscription_title",
"subscription_plan",
] {
if let Some(value) = source.get(field).and_then(serde_json::Value::as_str) {
let normalized = admin_pool_normalize_oauth_plan_type(value, provider_type);
if normalized.is_some() {
return normalized;
}
}
}
}
}
if let Some(config) = auth_config {
for field in ["plan_type", "tier", "plan", "subscription_plan"] {
if let Some(value) = config.get(field).and_then(serde_json::Value::as_str) {
let normalized = admin_pool_normalize_oauth_plan_type(value, provider_type);
if normalized.is_some() {
return normalized;
}
}
}
}
None
}
fn admin_pool_format_percent(value: f64) -> String { fn admin_pool_format_percent(value: f64) -> String {
format!("{:.1}%", value.clamp(0.0, 100.0)) format!("{:.1}%", value.clamp(0.0, 100.0))
} }
@@ -925,8 +853,11 @@ pub(super) fn build_admin_pool_key_payload(
let auth_config = state.parse_catalog_auth_config_json(key); let auth_config = state.parse_catalog_auth_config_json(key);
let oauth_expires_at = let oauth_expires_at =
admin_pool_derive_oauth_expires_at(provider_type, key, auth_config.as_ref()); admin_pool_derive_oauth_expires_at(provider_type, key, auth_config.as_ref());
let oauth_plan_type = let oauth_plan_type = if auth_semantics.oauth_managed() {
admin_pool_derive_oauth_plan_type(key, provider_type, auth_config.as_ref()); aether_provider_pool::derive_plan_tier(provider_type, key, auth_config.as_ref())
} else {
None
};
let mut status_snapshot = provider_key_status_snapshot_payload(key, provider_type); let mut status_snapshot = provider_key_status_snapshot_payload(key, provider_type);
if provider_type.trim().eq_ignore_ascii_case("codex") { if provider_type.trim().eq_ignore_ascii_case("codex") {
admin_pool_apply_codex_window_usage_summaries( admin_pool_apply_codex_window_usage_summaries(

View File

@@ -22,74 +22,17 @@ fn admin_pool_parse_auth_config_json(
.cloned() .cloned()
} }
fn admin_pool_derive_oauth_plan_type( fn admin_pool_derive_plan_tier(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
key: &StoredProviderCatalogKey, key: &StoredProviderCatalogKey,
provider_type: &str, provider_type: &str,
) -> Option<String> { ) -> Option<String> {
let normalize = |value: &str| {
let mut text = value.trim().to_string();
if text.is_empty() {
return None;
}
let provider_type = provider_type.trim().to_ascii_lowercase();
if !provider_type.is_empty() && text.to_ascii_lowercase().starts_with(&provider_type) {
text = text[provider_type.len()..]
.trim_matches(|ch: char| [' ', ':', '-', '_'].contains(&ch))
.to_string();
}
if text.is_empty() {
None
} else {
Some(text.to_ascii_lowercase())
}
};
if !provider_key_is_oauth_managed(key, provider_type) { if !provider_key_is_oauth_managed(key, provider_type) {
return None; return None;
} }
if let Some(upstream_metadata) = key let auth_config = admin_pool_parse_auth_config_json(state, key);
.upstream_metadata aether_provider_pool::derive_plan_tier(provider_type, key, auth_config.as_ref())
.as_ref()
.and_then(serde_json::Value::as_object)
{
let provider_bucket = upstream_metadata
.get(&provider_type.trim().to_ascii_lowercase())
.and_then(serde_json::Value::as_object);
for source in provider_bucket
.into_iter()
.chain(std::iter::once(upstream_metadata))
{
for plan_key in [
"plan_type",
"tier",
"subscription_title",
"subscription_plan",
] {
if let Some(value) = source.get(plan_key).and_then(serde_json::Value::as_str) {
if let Some(normalized) = normalize(value) {
return Some(normalized);
}
}
}
}
}
if let Some(auth_config) = admin_pool_parse_auth_config_json(state, key) {
for plan_key in ["plan_type", "tier", "plan", "subscription_plan"] {
if let Some(value) = auth_config
.get(plan_key)
.and_then(serde_json::Value::as_str)
{
if let Some(normalized) = normalize(value) {
return Some(normalized);
}
}
}
}
None
} }
pub(super) fn admin_pool_matches_quick_selector( pub(super) fn admin_pool_matches_quick_selector(
@@ -98,7 +41,7 @@ pub(super) fn admin_pool_matches_quick_selector(
provider_type: &str, provider_type: &str,
selector: &str, selector: &str,
) -> bool { ) -> bool {
let oauth_plan_type = admin_pool_derive_oauth_plan_type(state, key, provider_type); let oauth_plan_type = admin_pool_derive_plan_tier(state, key, provider_type);
admin_provider_pool_pure::admin_pool_matches_quick_selector( admin_provider_pool_pure::admin_pool_matches_quick_selector(
key, key,
selector, selector,
@@ -113,7 +56,7 @@ pub(super) fn admin_pool_matches_search(
provider_type: &str, provider_type: &str,
search: Option<&str>, search: Option<&str>,
) -> bool { ) -> bool {
let oauth_plan_type = admin_pool_derive_oauth_plan_type(state, key, provider_type); let oauth_plan_type = admin_pool_derive_plan_tier(state, key, provider_type);
admin_provider_pool_pure::admin_pool_matches_search(key, search, oauth_plan_type.as_deref()) admin_provider_pool_pure::admin_pool_matches_search(key, search, oauth_plan_type.as_deref())
} }

View File

@@ -1,6 +1,6 @@
use crate::handlers::admin::request::AdminAppState; use crate::handlers::admin::request::AdminAppState;
use crate::LocalProviderDeleteTaskState; use crate::LocalProviderDeleteTaskState;
use aether_ai_serving::PoolMemberScoreRules; use aether_pool_core::PoolMemberScoreRules;
use serde_json::json; use serde_json::json;
use std::collections::BTreeMap; use std::collections::BTreeMap;

View File

@@ -9,16 +9,16 @@ use aether_data_contracts::repository::pool_scores::{
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider, StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
}; };
use aether_provider_pool::provider_pool_quota_metadata_updated_at;
use aether_runtime_state::{RuntimeLockLease, RuntimeState}; use aether_runtime_state::{RuntimeLockLease, RuntimeState};
use futures_util::{stream, StreamExt}; use futures_util::{stream, StreamExt};
use serde_json::Value; use serde_json::Value;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use crate::admin_api::{ use crate::admin_api::{
admin_provider_pool_config, provider_oauth_maintenance_endpoint_for_provider, admin_provider_pool_config, provider_quota_refresh_endpoint_for_provider,
provider_type_supports_quota_refresh, reconcile_admin_fixed_provider_template_endpoints, provider_type_supports_quota_refresh, reconcile_admin_fixed_provider_template_endpoints,
refresh_antigravity_provider_quota_locally, refresh_chatgpt_web_provider_quota_locally, refresh_provider_pool_quota_locally, AdminAppState,
refresh_codex_provider_quota_locally, refresh_kiro_provider_quota_locally, AdminAppState,
}; };
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
@@ -112,38 +112,6 @@ fn provider_supports_quota_probe(provider_type: &str) -> bool {
provider_type_supports_quota_refresh(provider_type) provider_type_supports_quota_refresh(provider_type)
} }
fn json_number(value: Option<&Value>) -> Option<f64> {
let value = value?;
if let Some(number) = value.as_f64() {
return Some(number);
}
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| value.parse::<f64>().ok())
}
fn extract_quota_updated_at(provider_type: &str, upstream_metadata: Option<&Value>) -> Option<u64> {
let metadata = upstream_metadata?.as_object()?;
let bucket_name = match provider_type.trim().to_ascii_lowercase().as_str() {
"codex" => "codex",
"kiro" => "kiro",
"antigravity" => "antigravity",
"chatgpt_web" => "chatgpt_web",
_ => return None,
};
let bucket = metadata.get(bucket_name)?.as_object()?;
let mut updated_at = json_number(bucket.get("updated_at"))?;
if updated_at <= 0.0 {
return None;
}
if updated_at > 1_000_000_000_000.0 {
updated_at /= 1000.0;
}
Some(updated_at as u64)
}
fn parse_probe_stamp(raw_value: Option<&str>) -> Option<u64> { fn parse_probe_stamp(raw_value: Option<&str>) -> Option<u64> {
let parsed = raw_value let parsed = raw_value
.map(str::trim) .map(str::trim)
@@ -169,7 +137,7 @@ pub(crate) fn select_pool_quota_probe_key_ids(
continue; continue;
} }
let quota_updated_ts = let quota_updated_ts =
extract_quota_updated_at(provider_type, key.upstream_metadata.as_ref()); provider_pool_quota_metadata_updated_at(key.upstream_metadata.as_ref(), provider_type);
let last_probe_ts = last_probe_timestamps.get(&key.id).copied(); let last_probe_ts = last_probe_timestamps.get(&key.id).copied();
let anchor_ts = quota_updated_ts let anchor_ts = quota_updated_ts
.unwrap_or(0) .unwrap_or(0)
@@ -421,7 +389,7 @@ fn endpoint_for_probe(
provider_type: &str, provider_type: &str,
endpoints: &[StoredProviderCatalogEndpoint], endpoints: &[StoredProviderCatalogEndpoint],
) -> Option<StoredProviderCatalogEndpoint> { ) -> Option<StoredProviderCatalogEndpoint> {
provider_oauth_maintenance_endpoint_for_provider(provider_type, endpoints) provider_quota_refresh_endpoint_for_provider(provider_type, endpoints, true)
} }
async fn endpoint_for_probe_with_reconcile( async fn endpoint_for_probe_with_reconcile(
@@ -462,23 +430,8 @@ async fn refresh_provider_probe_keys(
provider_type: &str, provider_type: &str,
keys: Vec<StoredProviderCatalogKey>, keys: Vec<StoredProviderCatalogKey>,
) -> Result<Option<Value>, GatewayError> { ) -> Result<Option<Value>, GatewayError> {
match provider_type { refresh_provider_pool_quota_locally(admin_state, provider, endpoint, provider_type, keys, None)
"codex" => {
refresh_codex_provider_quota_locally(admin_state, provider, endpoint, keys, None).await
}
"kiro" => {
refresh_kiro_provider_quota_locally(admin_state, provider, endpoint, keys, None).await
}
"antigravity" => {
refresh_antigravity_provider_quota_locally(admin_state, provider, endpoint, keys, None)
.await .await
}
"chatgpt_web" => {
refresh_chatgpt_web_provider_quota_locally(admin_state, provider, endpoint, keys, None)
.await
}
_ => Ok(None),
}
} }
fn update_summary_from_payload( fn update_summary_from_payload(
@@ -971,16 +924,16 @@ mod tests {
#[test] #[test]
fn parses_quota_updated_at_seconds_and_milliseconds() { fn parses_quota_updated_at_seconds_and_milliseconds() {
assert_eq!( assert_eq!(
extract_quota_updated_at( provider_pool_quota_metadata_updated_at(
"codex", Some(&json!({ "codex": { "updated_at": 1_700_000_000 } })),
Some(&json!({ "codex": { "updated_at": 1_700_000_000 } })) "codex"
), ),
Some(1_700_000_000) Some(1_700_000_000)
); );
assert_eq!( assert_eq!(
extract_quota_updated_at( provider_pool_quota_metadata_updated_at(
"kiro", Some(&json!({ "kiro": { "updated_at": 1_700_000_000_000_u64 } })),
Some(&json!({ "kiro": { "updated_at": 1_700_000_000_000_u64 } })) "kiro"
), ),
Some(1_700_000_000) Some(1_700_000_000)
); );

View File

@@ -1704,7 +1704,9 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/oauth/quota/mod.rs"); read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/oauth/quota/mod.rs");
for pattern in [ for pattern in [
"pub(crate) mod antigravity;", "pub(crate) mod antigravity;",
"pub(crate) mod chatgpt_web;",
"pub(crate) mod codex;", "pub(crate) mod codex;",
"pub(crate) mod dispatch;",
"pub(crate) mod kiro;", "pub(crate) mod kiro;",
"pub(crate) mod shared;", "pub(crate) mod shared;",
] { ] {
@@ -1730,9 +1732,7 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
"apps/aether-gateway/src/handlers/admin/provider/endpoint_keys/quota.rs", "apps/aether-gateway/src/handlers/admin/provider/endpoint_keys/quota.rs",
); );
for pattern in [ for pattern in [
"use super::super::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally;", "use super::super::oauth::quota::dispatch::refresh_provider_pool_quota_locally;",
"use super::super::oauth::quota::codex::refresh_codex_provider_quota_locally;",
"use super::super::oauth::quota::kiro::refresh_kiro_provider_quota_locally;",
"use super::super::oauth::quota::shared::normalize_string_id_list;", "use super::super::oauth::quota::shared::normalize_string_id_list;",
] { ] {
assert!( assert!(
@@ -1744,15 +1744,51 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
let oauth_runtime = let oauth_runtime =
read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/oauth/runtime.rs"); read_workspace_file("apps/aether-gateway/src/handlers/admin/provider/oauth/runtime.rs");
for pattern in [ for pattern in [
"use super::quota::antigravity::refresh_antigravity_provider_quota_locally;", "use super::quota::dispatch::refresh_provider_pool_quota_locally;",
"use super::quota::codex::refresh_codex_provider_quota_locally;", "use super::quota::shared::provider_type_supports_quota_refresh;",
"use super::quota::kiro::refresh_kiro_provider_quota_locally;",
] { ] {
assert!( assert!(
oauth_runtime.contains(pattern), oauth_runtime.contains(pattern),
"handlers/admin/provider/oauth/runtime.rs should import quota helper via explicit owner {pattern}" "handlers/admin/provider/oauth/runtime.rs should import quota helper via explicit owner {pattern}"
); );
} }
assert!(
!oauth_runtime.contains("\"codex\" | \"kiro\" | \"antigravity\" | \"chatgpt_web\""),
"handlers/admin/provider/oauth/runtime.rs should not hardcode quota refresh provider allow-list"
);
let quota_shared = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/provider/oauth/quota/shared.rs",
);
assert!(
quota_shared.contains("aether_provider_pool::provider_pool_quota_metadata_provider_type("),
"handlers/admin/provider/oauth/quota/shared.rs should delegate quota metadata provider detection to aether-provider-pool"
);
assert!(
!quota_shared.contains("[\"codex\", \"kiro\", \"antigravity\", \"gemini_cli\", \"chatgpt_web\"]"),
"handlers/admin/provider/oauth/quota/shared.rs should not hardcode quota metadata provider list"
);
let quota_dispatch = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/provider/oauth/quota/dispatch.rs",
);
for pattern in [
"pub(crate) async fn refresh_provider_pool_quota_locally(",
"const PROVIDER_QUOTA_REFRESH_HANDLERS:",
"refresh_codex_provider_quota_locally",
"refresh_kiro_provider_quota_locally",
"refresh_antigravity_provider_quota_locally",
"refresh_chatgpt_web_provider_quota_locally",
] {
assert!(
quota_dispatch.contains(pattern),
"handlers/admin/provider/oauth/quota/dispatch.rs should centralize quota refresh dispatch {pattern}"
);
}
assert!(
!quota_dispatch.contains("match provider_type.trim().to_ascii_lowercase().as_str()"),
"handlers/admin/provider/oauth/quota/dispatch.rs should use provider handler registration instead of provider_type match"
);
let quota_codex_mod = read_workspace_file( let quota_codex_mod = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/mod.rs", "apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/mod.rs",
@@ -1799,14 +1835,13 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
"apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/plan.rs", "apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/plan.rs",
); );
for pattern in [ for pattern in [
"use super::parse::normalize_codex_plan_type;", "use aether_provider_pool::{build_codex_pool_quota_request, ProviderPoolQuotaRequestSpec};",
"use crate::handlers::admin::provider::shared::payloads::CODEX_WHAM_USAGE_URL;", "pub(super) fn build_codex_quota_request_spec(",
"pub(super) fn build_codex_refresh_headers(",
"pub(super) async fn execute_codex_quota_plan(", "pub(super) async fn execute_codex_quota_plan(",
] { ] {
assert!( assert!(
quota_codex_plan.contains(pattern), quota_codex_plan.contains(pattern),
"handlers/admin/provider/oauth/quota/codex/plan.rs should own codex quota execution helper {pattern}" "handlers/admin/provider/oauth/quota/codex/plan.rs should delegate codex quota request construction and own execution helper {pattern}"
); );
} }
let quota_kiro_mod = read_workspace_file( let quota_kiro_mod = read_workspace_file(
@@ -1843,13 +1878,13 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
"apps/aether-gateway/src/handlers/admin/provider/oauth/quota/kiro/plan.rs", "apps/aether-gateway/src/handlers/admin/provider/oauth/quota/kiro/plan.rs",
); );
for pattern in [ for pattern in [
"use super::super::shared::{execute_provider_quota_plan, ProviderQuotaExecutionOutcome};", "build_provider_quota_execution_plan",
"use crate::handlers::admin::provider::shared::payloads::{", "use aether_provider_pool::{build_kiro_pool_quota_request, KiroPoolQuotaAuthInput};",
"pub(super) async fn execute_kiro_quota_plan(", "pub(super) async fn execute_kiro_quota_plan(",
] { ] {
assert!( assert!(
quota_kiro_plan.contains(pattern), quota_kiro_plan.contains(pattern),
"handlers/admin/provider/oauth/quota/kiro/plan.rs should own kiro quota execution helper {pattern}" "handlers/admin/provider/oauth/quota/kiro/plan.rs should delegate kiro quota request construction and own execution helper {pattern}"
); );
} }
let quota_antigravity = read_workspace_file( let quota_antigravity = read_workspace_file(
@@ -1859,6 +1894,30 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
quota_antigravity.contains("use super::shared::{"), quota_antigravity.contains("use super::shared::{"),
"handlers/admin/provider/oauth/quota/antigravity.rs should import common quota helpers from shared.rs" "handlers/admin/provider/oauth/quota/antigravity.rs should import common quota helpers from shared.rs"
); );
assert!(
quota_antigravity.contains("use aether_provider_pool::build_antigravity_pool_quota_request;"),
"handlers/admin/provider/oauth/quota/antigravity.rs should delegate antigravity quota request construction to aether-provider-pool"
);
let quota_chatgpt_web = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/provider/oauth/quota/chatgpt_web.rs",
);
assert!(
quota_chatgpt_web.contains("use aether_provider_pool::{")
&& quota_chatgpt_web.contains("build_chatgpt_web_pool_quota_request")
&& quota_chatgpt_web.contains("enrich_chatgpt_web_quota_metadata")
&& quota_chatgpt_web.contains("normalize_chatgpt_web_image_quota_limit"),
"handlers/admin/provider/oauth/quota/chatgpt_web.rs should delegate chatgpt_web quota request and metadata behavior to aether-provider-pool"
);
for forbidden in [
"fn enrich_chatgpt_web_quota_metadata(",
"fn normalize_chatgpt_web_image_quota_limit(",
"fn chatgpt_web_auth_config_string(",
] {
assert!(
!quota_chatgpt_web.contains(forbidden),
"handlers/admin/provider/oauth/quota/chatgpt_web.rs should not own provider-pool chatgpt_web quota metadata helper {forbidden}"
);
}
} }
#[test] #[test]
@@ -1926,6 +1985,34 @@ fn admin_provider_oauth_refresh_helpers_use_specific_local_owners() {
); );
} }
#[test]
fn admin_provider_oauth_kiro_token_refresh_delegates_to_oauth_adapter() {
let kiro_dispatch = read_workspace_file(
"apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/kiro.rs",
);
assert!(
kiro_dispatch.contains("use aether_oauth::provider::providers::KiroProviderOAuthAdapter;"),
"gateway Kiro OAuth dispatch should depend on the shared provider OAuth adapter"
);
assert!(
kiro_dispatch.contains(".refresh_auth_config("),
"gateway Kiro OAuth dispatch should delegate token refresh to aether-oauth"
);
for forbidden in [
"fn admin_provider_oauth_kiro_build_refresh_url(",
"fn admin_provider_oauth_kiro_refresh_response_json(",
"\"kiro_batch_refresh:social\"",
"\"kiro_batch_refresh:idc\"",
"\"refreshToken\": auth_config",
"\"grantType\": \"refresh_token\"",
] {
assert!(
!kiro_dispatch.contains(forbidden),
"gateway Kiro OAuth dispatch should not own provider-specific token refresh detail {forbidden}"
);
}
}
#[test] #[test]
fn admin_provider_oauth_dispatch_batch_mod_stays_thin() { fn admin_provider_oauth_dispatch_batch_mod_stays_thin() {
let batch_mod = read_workspace_file( let batch_mod = read_workspace_file(

View File

@@ -1280,10 +1280,10 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
} }
for forbidden in [ for forbidden in [
"pub(crate) async fn apply_local_execution_pool_scheduler(", "pub(crate) async fn apply_local_execution_pool_scheduler(",
"run_ai_pool_scheduler(", "run_pool_scheduler(",
"fn ai_pool_candidate_facts(", "fn pool_candidate_facts(",
"fn ai_pool_scheduling_config(", "fn pool_scheduling_config(",
"fn ai_pool_runtime_state(", "fn pool_runtime_state(",
] { ] {
assert!( assert!(
!planner_pool_scheduler.contains(forbidden), !planner_pool_scheduler.contains(forbidden),
@@ -1295,10 +1295,10 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
for pattern in [ for pattern in [
"pub(crate) async fn apply_local_execution_pool_scheduler(", "pub(crate) async fn apply_local_execution_pool_scheduler(",
"pub(crate) struct PoolKeyCursor", "pub(crate) struct PoolKeyCursor",
"run_ai_pool_scheduler(", "run_pool_scheduler(",
"fn ai_pool_candidate_facts(", "fn pool_candidate_facts(",
"fn ai_pool_scheduling_config(", "fn pool_scheduling_config(",
"fn ai_pool_runtime_state(", "fn pool_runtime_state(",
"DEFAULT_POOL_WINDOW_SIZE", "DEFAULT_POOL_WINDOW_SIZE",
"DEFAULT_POOL_PAGE_SIZE", "DEFAULT_POOL_PAGE_SIZE",
"DEFAULT_POOL_MAX_SCAN", "DEFAULT_POOL_MAX_SCAN",
@@ -1351,21 +1351,218 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
); );
} }
let serving_pool_scheduler = let pool_core_lib = read_workspace_file("crates/aether-pool-core/src/lib.rs");
read_workspace_file("crates/aether-ai-serving/src/pool_scheduler.rs");
for pattern in [ for pattern in [
"pub fn run_ai_pool_scheduler", "run_pool_scheduler",
"pub struct AiPoolCandidateInput", "PoolCandidateInput",
"pub struct AiPoolRuntimeState", "PoolRuntimeState",
"pub struct AiPoolSchedulingConfig", "PoolSchedulingConfig",
] {
assert!(
pool_core_lib.contains(pattern),
"aether-pool-core lib.rs should expose pool scheduling primitive {pattern}"
);
}
let pool_core_scheduler = read_workspace_file("crates/aether-pool-core/src/scheduler.rs");
for pattern in [
"pub fn run_pool_scheduler",
"fn schedule_pool_group", "fn schedule_pool_group",
"fn build_pool_sort_vectors", "fn build_pool_sort_vectors",
"fn plan_priority_score(", "fn plan_priority_score(",
"fn stable_hash_score(", "fn stable_hash_score(",
] { ] {
assert!( assert!(
serving_pool_scheduler.contains(pattern), pool_core_scheduler.contains(pattern),
"aether-ai-serving pool_scheduler.rs should own pool scheduling use-case primitive {pattern}" "aether-pool-core scheduler.rs should own pool scheduling use-case primitive {pattern}"
);
}
for forbidden in ["codex", "kiro", "chatgpt_web", "provider_type"] {
assert!(
!pool_core_scheduler.contains(forbidden) && !pool_core_lib.contains(forbidden),
"aether-pool-core should stay provider-agnostic and not embed provider behavior {forbidden}"
);
}
let serving_lib = read_workspace_file("crates/aether-ai-serving/src/lib.rs");
for forbidden in ["pub mod pool_scheduler;", "pub mod pool_scores;"] {
assert!(
!serving_lib.contains(forbidden),
"aether-ai-serving should not own pool core module {forbidden}"
);
}
let provider_pool_lib = read_workspace_file("crates/aether-provider-pool/src/lib.rs");
for pattern in [
"mod capability;",
"mod plan;",
"mod presets;",
"mod provider;",
"mod quota;",
"mod service;",
"pub mod providers;",
"pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};",
"pub use service::ProviderPoolService;",
] {
assert!(
provider_pool_lib.contains(pattern),
"aether-provider-pool lib.rs should stay a thin module/re-export root through {pattern}"
);
}
let provider_pool_provider = read_workspace_file("crates/aether-provider-pool/src/provider.rs");
for pattern in [
"pub trait ProviderPoolAdapter",
"ProviderPoolMemberInput",
"supports_quota_refresh",
"quota_refresh_endpoint",
] {
assert!(
provider_pool_provider.contains(pattern),
"aether-provider-pool provider.rs should own adapter contract {pattern}"
);
}
let provider_pool_service = read_workspace_file("crates/aether-provider-pool/src/service.rs");
for pattern in [
"pub struct ProviderPoolService",
"with_builtin_adapters",
"AntigravityProviderPoolAdapter",
"CodexProviderPoolAdapter",
"KiroProviderPoolAdapter",
"ChatGptWebProviderPoolAdapter",
"CLAUDE_CODE_PROVIDER_POOL_ADAPTER",
"GEMINI_CLI_PROVIDER_POOL_ADAPTER",
"VERTEX_AI_PROVIDER_POOL_ADAPTER",
"provider_types_for_capability",
"supports_quota_refresh",
"quota_refresh_endpoint_for_provider",
] {
assert!(
provider_pool_service.contains(pattern),
"aether-provider-pool service.rs should own adapter registry/service primitive {pattern}"
);
}
assert!(
!provider_pool_service.contains("match provider_type.trim().to_ascii_lowercase().as_str()"),
"aether-provider-pool service.rs should delegate provider-specific behavior to adapters"
);
let provider_pool_providers =
read_workspace_file("crates/aether-provider-pool/src/providers/mod.rs");
for pattern in [
"pub mod default;",
"pub mod unsupported;",
"pub mod antigravity;",
"pub mod codex;",
"pub mod kiro;",
"pub mod chatgpt_web;",
] {
assert!(
provider_pool_providers.contains(pattern),
"aether-provider-pool providers/mod.rs should expose provider-specific module {pattern}"
);
}
for (path, patterns) in [
(
"crates/aether-provider-pool/src/providers/default.rs",
vec!["DefaultProviderPoolAdapter"],
),
(
"crates/aether-provider-pool/src/providers/antigravity.rs",
vec!["AntigravityProviderPoolAdapter"],
),
(
"crates/aether-provider-pool/src/providers/codex.rs",
vec![
"CodexProviderPoolAdapter",
"recent_refresh",
"quota_exhausted_from_bucket",
],
),
(
"crates/aether-provider-pool/src/providers/kiro.rs",
vec!["KiroProviderPoolAdapter", "quota_exhausted_from_bucket"],
),
(
"crates/aether-provider-pool/src/providers/chatgpt_web.rs",
vec![
"ChatGptWebProviderPoolAdapter",
"build_chatgpt_web_pool_quota_request",
"enrich_chatgpt_web_quota_metadata",
"normalize_chatgpt_web_image_quota_limit",
"quota_exhausted_from_bucket",
],
),
(
"crates/aether-provider-pool/src/providers/unsupported.rs",
vec![
"UnsupportedQuotaProviderPoolAdapter",
"CLAUDE_CODE_PROVIDER_POOL_ADAPTER",
"GEMINI_CLI_PROVIDER_POOL_ADAPTER",
"VERTEX_AI_PROVIDER_POOL_ADAPTER",
],
),
] {
let source = read_workspace_file(path);
for pattern in patterns {
assert!(
source.contains(pattern),
"{path} should own provider-specific pool behavior {pattern}"
);
}
}
let provider_pool_plan = read_workspace_file("crates/aether-provider-pool/src/plan.rs");
for pattern in ["normalize_provider_plan_tier", "derive_plan_tier"] {
assert!(
provider_pool_plan.contains(pattern),
"aether-provider-pool plan.rs should own provider plan-tier normalization primitive {pattern}"
);
}
let provider_pool_quota = read_workspace_file("crates/aether-provider-pool/src/quota.rs");
for pattern in [
"provider_pool_key_account_quota_exhausted",
"provider_pool_member_quota_snapshot",
"provider_pool_quota_metadata_updated_at",
"provider_pool_quota_metadata_provider_type",
"provider_pool_key_scheduling_label",
"provider_pool_quota_snapshot_updated_at",
] {
assert!(
provider_pool_quota.contains(pattern),
"aether-provider-pool quota.rs should own provider quota/scheduling signal primitive {pattern}"
);
}
let provider_pool_presets = read_workspace_file("crates/aether-provider-pool/src/presets.rs");
for pattern in [
"normalize_provider_scheduling_presets",
"build_admin_pool_scheduling_presets_payload",
] {
assert!(
provider_pool_presets.contains(pattern),
"aether-provider-pool presets.rs should own provider preset adaptation primitive {pattern}"
);
}
for forbidden in [
"run_pool_scheduler",
"PoolSchedulerOutcome",
"schedule_pool_group",
"plan_priority_score(",
] {
let mut violations = Vec::new();
for file in collect_workspace_rust_files("crates/aether-provider-pool/src") {
let source = std::fs::read_to_string(&file).expect("source file should be readable");
if source.contains(forbidden) {
violations.push(file.display().to_string());
}
}
assert!(
violations.is_empty(),
"aether-provider-pool should not own generic pool scheduler primitive {forbidden}:\n{}",
violations.join("\n")
); );
} }
} }

View File

@@ -4302,7 +4302,7 @@ async fn gateway_batch_imports_admin_provider_oauth_kiro_via_execution_runtime_p
.and_then(|proxy| proxy.node_id.as_deref()), .and_then(|proxy| proxy.node_id.as_deref()),
Some("proxy-node-kiro-batch-runtime") Some("proxy-node-kiro-batch-runtime")
); );
if plan.request_id == "kiro_batch_refresh:social" { if plan.request_id == "provider-oauth:kiro-social-refresh" {
assert_eq!(plan.url, "https://oauth.example/refreshToken"); assert_eq!(plan.url, "https://oauth.example/refreshToken");
assert_eq!( assert_eq!(
plan.headers plan.headers

View File

@@ -12,6 +12,7 @@ aether-billing.workspace = true
aether-contracts.workspace = true aether-contracts.workspace = true
aether-data.workspace = true aether-data.workspace = true
aether-data-contracts.workspace = true aether-data-contracts.workspace = true
aether-provider-pool.workspace = true
axum.workspace = true axum.workspace = true
base64.workspace = true base64.workspace = true
chrono.workspace = true chrono.workspace = true

View File

@@ -92,180 +92,11 @@ fn admin_pool_reason_indicates_ban(reason: &str) -> bool {
.any(|hint| normalized.contains(hint)) .any(|hint| normalized.contains(hint))
} }
fn admin_pool_metadata_bucket<'a>(
upstream_metadata: Option<&'a Value>,
provider_type: &str,
) -> Option<&'a serde_json::Map<String, Value>> {
upstream_metadata
.and_then(Value::as_object)
.and_then(|metadata| metadata.get(&provider_type.trim().to_ascii_lowercase()))
.and_then(Value::as_object)
}
fn admin_pool_json_bool(value: Option<&Value>) -> Option<bool> {
match value {
Some(Value::Bool(value)) => Some(*value),
Some(Value::String(value)) => match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
},
_ => None,
}
}
fn admin_pool_json_f64(value: Option<&Value>) -> Option<f64> {
match value {
Some(Value::Number(number)) => number.as_f64(),
Some(Value::String(value)) => value.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|value| value.is_finite())
}
fn admin_pool_quota_snapshot_matches_provider(
quota_snapshot: &serde_json::Map<String, Value>,
provider_type: &str,
) -> bool {
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
match quota_snapshot
.get("provider_type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(provider_type) => provider_type.eq_ignore_ascii_case(&normalized_provider_type),
None => {
admin_pool_json_bool(quota_snapshot.get("exhausted")) == Some(true)
|| quota_snapshot
.get("code")
.and_then(Value::as_str)
.is_some_and(|code| !code.trim().eq_ignore_ascii_case("unknown"))
|| quota_snapshot
.get("updated_at")
.is_some_and(|value| !value.is_null())
|| quota_snapshot
.get("observed_at")
.is_some_and(|value| !value.is_null())
|| quota_snapshot
.get("usage_ratio")
.is_some_and(|value| !value.is_null())
|| quota_snapshot
.get("reset_seconds")
.is_some_and(|value| !value.is_null())
|| quota_snapshot
.get("windows")
.and_then(Value::as_array)
.is_some_and(|windows| !windows.is_empty())
|| quota_snapshot
.get("credits")
.and_then(Value::as_object)
.is_some_and(|credits| !credits.is_empty())
}
}
}
fn admin_pool_key_quota_snapshot<'a>(
key: &'a StoredProviderCatalogKey,
provider_type: &str,
) -> Option<&'a serde_json::Map<String, Value>> {
let quota_snapshot = key
.status_snapshot
.as_ref()
.and_then(Value::as_object)
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object)?;
admin_pool_quota_snapshot_matches_provider(quota_snapshot, provider_type)
.then_some(quota_snapshot)
}
pub fn admin_pool_key_account_quota_exhausted( pub fn admin_pool_key_account_quota_exhausted(
key: &StoredProviderCatalogKey, key: &StoredProviderCatalogKey,
provider_type: &str, provider_type: &str,
) -> bool { ) -> bool {
if let Some(exhausted) = aether_provider_pool::provider_pool_key_account_quota_exhausted(key, provider_type)
admin_pool_key_quota_snapshot(key, provider_type).and_then(|quota_snapshot| {
let exhausted = admin_pool_json_bool(quota_snapshot.get("exhausted"))?;
if exhausted {
let windows_max_ratio = quota_snapshot
.get("windows")
.and_then(Value::as_array)
.filter(|w| !w.is_empty())
.and_then(|windows| {
windows
.iter()
.filter_map(Value::as_object)
.filter_map(|w| w.get("used_ratio"))
.filter_map(Value::as_f64)
.max_by(f64::total_cmp)
});
if windows_max_ratio.is_some_and(|ratio| ratio < 1.0 - 1e-6) {
return Some(false);
}
}
Some(exhausted)
})
{
return exhausted;
}
let provider_type = provider_type.trim().to_ascii_lowercase();
let Some(bucket) = admin_pool_metadata_bucket(key.upstream_metadata.as_ref(), &provider_type)
else {
return false;
};
match provider_type.as_str() {
"codex" => {
if admin_pool_json_bool(bucket.get("credits_unlimited")) == Some(true) {
return false;
}
let has_window_data = admin_pool_json_f64(bucket.get("primary_used_percent")).is_some()
|| admin_pool_json_f64(bucket.get("secondary_used_percent")).is_some();
if !has_window_data && admin_pool_json_bool(bucket.get("has_credits")) == Some(false) {
return true;
}
admin_pool_json_f64(bucket.get("primary_used_percent"))
.is_some_and(|value| value >= 100.0)
|| admin_pool_json_f64(bucket.get("secondary_used_percent"))
.is_some_and(|value| value >= 100.0)
}
"kiro" => {
if admin_pool_json_f64(bucket.get("remaining")).is_some_and(|value| value <= 0.0) {
return true;
}
if admin_pool_json_f64(bucket.get("usage_percentage"))
.is_some_and(|value| value >= 100.0)
{
return true;
}
match (
admin_pool_json_f64(bucket.get("usage_limit")),
admin_pool_json_f64(bucket.get("current_usage")),
) {
(Some(limit), Some(current)) if limit > 0.0 => current >= limit,
_ => false,
}
}
"chatgpt_web" => {
if admin_pool_json_bool(bucket.get("image_quota_blocked")) == Some(true) {
return true;
}
if admin_pool_json_f64(bucket.get("image_quota_remaining"))
.is_some_and(|value| value <= 0.0)
{
return true;
}
match (
admin_pool_json_f64(bucket.get("image_quota_total")),
admin_pool_json_f64(bucket.get("image_quota_used")),
) {
(Some(limit), Some(used)) if limit > 0.0 => used >= limit,
_ => false,
}
}
_ => false,
}
} }
fn admin_pool_has_proxy(key: &StoredProviderCatalogKey) -> bool { fn admin_pool_has_proxy(key: &StoredProviderCatalogKey) -> bool {
@@ -937,148 +768,7 @@ pub fn build_admin_pool_key_payload(
} }
pub fn build_admin_pool_scheduling_presets_payload() -> Value { pub fn build_admin_pool_scheduling_presets_payload() -> Value {
json!([ aether_provider_pool::build_admin_pool_scheduling_presets_payload()
{
"name": "lru",
"label": "LRU 轮转",
"description": "最久未使用的 Key 优先",
"providers": [],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": "distribution_mode",
"evidence_hint": "依据 LRU 时间戳(最近未使用优先)",
},
{
"name": "cache_affinity",
"label": "缓存亲和",
"description": "优先复用最近使用过的 Key利用 Prompt Caching",
"providers": [],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": "distribution_mode",
"evidence_hint": "依据 LRU 时间戳(最近使用优先,与 LRU 轮转相反)",
},
{
"name": "cost_first",
"label": "成本优先",
"description": "优先选择窗口消耗更低的账号",
"providers": [],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据窗口成本/Token 用量,缺失时回退配额使用率",
},
{
"name": "free_first",
"label": "Free 优先",
"description": "优先消耗 Free 账号(依赖 plan_type",
"providers": ["codex", "kiro"],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据 plan_typeFree 账号优先调度)",
},
{
"name": "health_first",
"label": "健康优先",
"description": "优先选择健康分更高、失败更少的账号",
"providers": [],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据 health_by_format 聚合分(含熔断/失败衰减)",
},
{
"name": "latency_first",
"label": "延迟优先",
"description": "优先选择最近延迟更低的账号",
"providers": [],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据号池延迟窗口均值latency_window_seconds",
},
{
"name": "load_balance",
"label": "负载均衡",
"description": "随机分散 Key 使用,均匀分摊负载",
"providers": [],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": "distribution_mode",
"evidence_hint": "每次随机分值,实现完全均匀分散",
},
{
"name": "plus_first",
"label": "Plus 优先",
"description": "优先消耗 Plus 账号(依赖 plan_type",
"providers": ["codex", "kiro"],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据 plan_typePlus 账号优先调度)",
},
{
"name": "pro_first",
"label": "Pro 优先",
"description": "优先消耗 Pro 账号(依赖 plan_type",
"providers": ["codex", "kiro"],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据 plan_typePro 账号优先调度)",
},
{
"name": "priority_first",
"label": "优先级优先",
"description": "按账号优先级顺序调度(数字越小越优先)",
"providers": [],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据 internal_priority支持拖拽/手工编辑)",
},
{
"name": "quota_balanced",
"label": "额度平均",
"description": "优先选额度消耗最少的账号",
"providers": [],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据账号配额使用率;无配额时回退到窗口成本使用",
},
{
"name": "recent_refresh",
"label": "额度刷新优先",
"description": "优先选即将刷新额度的账号",
"providers": ["codex", "kiro"],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据账号额度重置倒计时next_reset / reset_seconds",
},
{
"name": "single_account",
"label": "单号优先",
"description": "集中使用同一账号(反向 LRU",
"providers": [],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": "distribution_mode",
"evidence_hint": "先按账号优先级internal_priority同级再按反向 LRU 集中",
},
{
"name": "team_first",
"label": "Team 优先",
"description": "优先消耗 Team 账号(依赖 plan_type",
"providers": ["codex", "kiro"],
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": Value::Null,
"evidence_hint": "依据 plan_typeTeam 账号优先调度)",
}
])
} }
pub fn admin_pool_batch_delete_task_parts(request_path: &str) -> Option<(String, String)> { pub fn admin_pool_batch_delete_task_parts(request_path: &str) -> Option<(String, String)> {

View File

@@ -10,6 +10,7 @@ description = "AI serving application contracts and ports for Aether"
aether-ai-formats.workspace = true aether-ai-formats.workspace = true
aether-contracts.workspace = true aether-contracts.workspace = true
aether-data-contracts.workspace = true aether-data-contracts.workspace = true
aether-pool-core.workspace = true
aether-scheduler-core.workspace = true aether-scheduler-core.workspace = true
async-trait.workspace = true async-trait.workspace = true
http.workspace = true http.workspace = true

View File

@@ -15,8 +15,6 @@ pub mod dto;
pub mod execution_path; pub mod execution_path;
pub mod failure_diagnostic; pub mod failure_diagnostic;
pub mod plan_payload; pub mod plan_payload;
pub mod pool_scheduler;
pub mod pool_scores;
pub mod ports; pub mod ports;
pub mod ranking_metadata; pub mod ranking_metadata;
pub mod report_context; pub mod report_context;
@@ -24,6 +22,34 @@ pub mod request_body_diagnostics;
pub mod runtime_miss; pub mod runtime_miss;
pub mod surface_spec; pub mod surface_spec;
pub use aether_pool_core::{
normalize_enabled_pool_presets, run_pool_scheduler, PoolCandidateFacts, PoolCandidateInput,
PoolCandidateOrchestration, PoolMemberSignals, PoolRuntimeState, PoolScheduledCandidate,
PoolSchedulerOutcome, PoolSchedulingConfig, PoolSchedulingPreset, PoolSkippedCandidate,
POOL_ACCOUNT_BLOCKED_SKIP_REASON, POOL_ACCOUNT_EXHAUSTED_SKIP_REASON,
POOL_COOLDOWN_SKIP_REASON, POOL_COST_LIMIT_REACHED_SKIP_REASON,
};
pub use aether_pool_core::{
normalize_enabled_pool_presets as normalize_enabled_ai_pool_presets,
run_pool_scheduler as run_ai_pool_scheduler, PoolCandidateFacts as AiPoolCandidateFacts,
PoolCandidateInput as AiPoolCandidateInput,
PoolCandidateOrchestration as AiPoolCandidateOrchestration,
PoolMemberSignals as AiPoolCatalogKeyContext, PoolRuntimeState as AiPoolRuntimeState,
PoolScheduledCandidate as AiPoolScheduledCandidate,
PoolSchedulerOutcome as AiPoolSchedulerOutcome, PoolSchedulingConfig as AiPoolSchedulingConfig,
PoolSchedulingPreset as AiPoolSchedulingPreset, PoolSkippedCandidate as AiPoolSkippedCandidate,
POOL_ACCOUNT_BLOCKED_SKIP_REASON as AI_POOL_ACCOUNT_BLOCKED_SKIP_REASON,
POOL_ACCOUNT_EXHAUSTED_SKIP_REASON as AI_POOL_ACCOUNT_EXHAUSTED_SKIP_REASON,
POOL_COOLDOWN_SKIP_REASON as AI_POOL_COOLDOWN_SKIP_REASON,
POOL_COST_LIMIT_REACHED_SKIP_REASON as AI_POOL_COST_LIMIT_REACHED_SKIP_REASON,
};
pub use aether_pool_core::{
probe_freshness_score, probe_freshness_score_with_ttl, score_pool_member,
score_pool_member_with_rules, PoolMemberScoreInput, PoolMemberScoreOutput,
PoolMemberScoreRules, PoolMemberScoreWeights, POOL_SCORE_VERSION,
PROBE_FAILURE_COOLDOWN_THRESHOLD, PROBE_FAILURE_PENALTY, PROBE_FRESHNESS_TTL_SECONDS,
REQUEST_FAILURE_PENALTY, UNSCHEDULABLE_SCORE_CAP,
};
pub use attempt_loop::{ pub use attempt_loop::{
run_ai_attempt_loop, AiAttemptLoopOutcome, AiAttemptLoopPort, AiExecutionAttempt, run_ai_attempt_loop, AiAttemptLoopOutcome, AiAttemptLoopPort, AiExecutionAttempt,
}; };
@@ -92,21 +118,6 @@ pub use failure_diagnostic::{CandidateFailureDiagnostic, CandidateFailureDiagnos
pub use plan_payload::{ pub use plan_payload::{
build_ai_stream_execution_plan_payload, build_ai_sync_execution_plan_payload, build_ai_stream_execution_plan_payload, build_ai_sync_execution_plan_payload,
}; };
pub use pool_scheduler::{
normalize_enabled_ai_pool_presets, run_ai_pool_scheduler, AiPoolCandidateFacts,
AiPoolCandidateInput, AiPoolCandidateOrchestration, AiPoolCatalogKeyContext,
AiPoolRuntimeState, AiPoolScheduledCandidate, AiPoolSchedulerOutcome, AiPoolSchedulingConfig,
AiPoolSchedulingPreset, AiPoolSkippedCandidate, AI_POOL_ACCOUNT_BLOCKED_SKIP_REASON,
AI_POOL_ACCOUNT_EXHAUSTED_SKIP_REASON, AI_POOL_COOLDOWN_SKIP_REASON,
AI_POOL_COST_LIMIT_REACHED_SKIP_REASON,
};
pub use pool_scores::{
probe_freshness_score, probe_freshness_score_with_ttl, score_pool_member,
score_pool_member_with_rules, PoolMemberScoreInput, PoolMemberScoreOutput,
PoolMemberScoreRules, PoolMemberScoreWeights, POOL_SCORE_VERSION,
PROBE_FAILURE_COOLDOWN_THRESHOLD, PROBE_FAILURE_PENALTY, PROBE_FRESHNESS_TTL_SECONDS,
REQUEST_FAILURE_PENALTY, UNSCHEDULABLE_SCORE_CAP,
};
pub use ranking_metadata::append_ai_ranking_metadata_to_object; pub use ranking_metadata::append_ai_ranking_metadata_to_object;
pub use report_context::{ pub use report_context::{
build_ai_execution_report_context, build_ai_report_context_original_request_echo, build_ai_execution_report_context, build_ai_report_context_original_request_echo,

View File

@@ -11,6 +11,10 @@ use sha2::{Digest, Sha256};
use std::collections::BTreeMap; use std::collections::BTreeMap;
pub const KIRO_PROVIDER_TYPE: &str = "kiro"; pub const KIRO_PROVIDER_TYPE: &str = "kiro";
pub const DEFAULT_REGION: &str = "us-east-1";
pub const DEFAULT_KIRO_VERSION: &str = "0.3.210";
pub const DEFAULT_NODE_VERSION: &str = "22.21.1";
pub const DEFAULT_SYSTEM_VERSION: &str = "other#unknown";
const IDC_AMZ_USER_AGENT: &str = const IDC_AMZ_USER_AGENT: &str =
"aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE"; "aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
@@ -39,7 +43,8 @@ impl KiroAuthConfig {
auth_method: string_field( auth_method: string_field(
object, object,
&["auth_method", "authMethod", "auth_type", "authType"], &["auth_method", "authMethod", "auth_type", "authType"],
), )
.map(|value| normalize_kiro_auth_method(&value)),
refresh_token: string_field(object, &["refresh_token", "refreshToken"]), refresh_token: string_field(object, &["refresh_token", "refreshToken"]),
expires_at: u64_field(object.get("expires_at")) expires_at: u64_field(object.get("expires_at"))
.or_else(|| u64_field(object.get("expiresAt"))), .or_else(|| u64_field(object.get("expiresAt"))),
@@ -93,7 +98,7 @@ impl KiroAuthConfig {
.or(self.region.as_deref()) .or(self.region.as_deref())
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("us-east-1") .unwrap_or(DEFAULT_REGION)
} }
pub fn effective_api_region(&self) -> &str { pub fn effective_api_region(&self) -> &str {
@@ -101,7 +106,7 @@ impl KiroAuthConfig {
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("us-east-1") .unwrap_or(DEFAULT_REGION)
} }
pub fn effective_kiro_version(&self) -> &str { pub fn effective_kiro_version(&self) -> &str {
@@ -109,40 +114,111 @@ impl KiroAuthConfig {
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("0.3.210") .unwrap_or(DEFAULT_KIRO_VERSION)
}
pub fn effective_system_version(&self) -> &str {
self.system_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_SYSTEM_VERSION)
}
pub fn effective_node_version(&self) -> &str {
self.node_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_NODE_VERSION)
}
pub fn cached_access_token(&self) -> Option<&str> {
self.access_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub fn cached_access_token_requires_refresh(&self, skew_seconds: u64) -> bool {
let Some(expires_at) = self.expires_at else {
return self.can_refresh_access_token();
};
let now = current_unix_secs();
now >= expires_at.saturating_sub(skew_seconds)
} }
pub fn is_idc_auth(&self) -> bool { pub fn is_idc_auth(&self) -> bool {
self.auth_method let explicit_method = self
.auth_method
.as_deref()
.map(normalize_kiro_auth_method)
.unwrap_or_else(|| "social".to_string());
if explicit_method != "social" {
return matches!(explicit_method.as_str(), "idc" | "external_idp");
}
self.client_id
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.map(str::to_ascii_lowercase) .filter(|value| !value.is_empty())
.is_some_and(|value| matches!(value.as_str(), "idc" | "external_idp")) .is_some()
|| (self
.client_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& self && self
.client_secret .client_secret
.as_deref() .as_deref()
.is_some_and(|value| !value.trim().is_empty())) .map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
}
pub fn uses_external_idp_token_type(&self) -> bool {
self.auth_method
.as_deref()
.map(normalize_kiro_auth_method)
.as_deref()
== Some("external_idp")
}
pub fn profile_arn_for_payload(&self) -> Option<&str> {
if self.is_idc_auth() {
return None;
}
self.profile_arn
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub fn profile_arn_for_mcp(&self) -> Option<&str> {
self.profile_arn
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
} }
pub fn can_refresh_access_token(&self) -> bool { pub fn can_refresh_access_token(&self) -> bool {
self.refresh_token let refresh_token = self
.refresh_token
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|value| value.len() >= 100 && !value.contains("...")) .filter(|value| !value.is_empty())
.is_some() .filter(|value| value.len() >= 100 && !value.contains("..."));
&& (!self.is_idc_auth() if refresh_token.is_none() {
|| (self return false;
.client_id }
if !self.is_idc_auth() {
return true;
}
self.client_id
.as_deref() .as_deref()
.is_some_and(|value| !value.trim().is_empty()) .map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
&& self && self
.client_secret .client_secret
.as_deref() .as_deref()
.is_some_and(|value| !value.trim().is_empty()))) .map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
} }
} }
@@ -163,7 +239,7 @@ impl KiroProviderOAuthAdapter {
self self
} }
async fn refresh_auth_config( pub async fn refresh_auth_config(
&self, &self,
executor: &dyn OAuthHttpExecutor, executor: &dyn OAuthHttpExecutor,
ctx: &ProviderOAuthTransportContext, ctx: &ProviderOAuthTransportContext,
@@ -436,7 +512,7 @@ pub fn generate_kiro_machine_id(
if let Some(machine_id) = auth_config if let Some(machine_id) = auth_config
.machine_id .machine_id
.as_deref() .as_deref()
.and_then(normalize_machine_id) .and_then(normalize_kiro_machine_id)
{ {
return Some(machine_id); return Some(machine_id);
} }
@@ -497,7 +573,7 @@ fn resolve_expires_at(payload: &Value) -> u64 {
current_unix_secs().saturating_add(expires_in) current_unix_secs().saturating_add(expires_in)
} }
fn normalize_machine_id(raw: &str) -> Option<String> { pub fn normalize_kiro_machine_id(raw: &str) -> Option<String> {
let raw = raw.trim(); let raw = raw.trim();
if raw.len() == 64 && raw.bytes().all(|byte| byte.is_ascii_hexdigit()) { if raw.len() == 64 && raw.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Some(raw.to_ascii_lowercase()); return Some(raw.to_ascii_lowercase());
@@ -514,6 +590,26 @@ fn normalize_machine_id(raw: &str) -> Option<String> {
None None
} }
fn normalize_kiro_auth_method(raw: &str) -> String {
let value = raw.trim().to_ascii_lowercase();
match value.as_str() {
"" => "social".to_string(),
"builder-id"
| "builder_id"
| "builderid"
| "device"
| "device-auth"
| "device_authorization"
| "iam"
| "identity-center"
| "identity_center"
| "identitycenter"
| "idc" => "idc".to_string(),
"external-idp" | "external_idp" | "externalidp" => "external_idp".to_string(),
_ => value,
}
}
fn string_field(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> { fn string_field(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter() keys.iter()
.find_map(|key| object.get(*key)) .find_map(|key| object.get(*key))
@@ -548,7 +644,51 @@ fn secret_fingerprint(value: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{generate_kiro_machine_id, KiroAuthConfig}; use super::{
generate_kiro_machine_id, KiroAuthConfig, KiroProviderOAuthAdapter, IDC_AMZ_USER_AGENT,
};
use crate::network::{OAuthHttpExecutor, OAuthHttpRequest, OAuthHttpResponse};
use crate::provider::ProviderOAuthTransportContext;
use async_trait::async_trait;
use serde_json::json;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone)]
struct StaticExecutor {
seen_request: Arc<Mutex<Option<OAuthHttpRequest>>>,
response: serde_json::Value,
}
#[async_trait]
impl OAuthHttpExecutor for StaticExecutor {
async fn execute(
&self,
request: OAuthHttpRequest,
) -> Result<OAuthHttpResponse, crate::core::OAuthError> {
*self.seen_request.lock().expect("mutex should lock") = Some(request);
Ok(OAuthHttpResponse {
status_code: 200,
body_text: self.response.to_string(),
json_body: Some(self.response.clone()),
})
}
}
fn test_ctx() -> ProviderOAuthTransportContext {
ProviderOAuthTransportContext {
provider_id: "provider-1".to_string(),
provider_type: "kiro".to_string(),
endpoint_id: None,
key_id: Some("key-1".to_string()),
auth_type: Some("oauth".to_string()),
decrypted_api_key: None,
decrypted_auth_config: None,
provider_config: None,
endpoint_config: None,
key_config: None,
network: crate::network::OAuthNetworkContext::provider_operation(None),
}
}
#[test] #[test]
fn normalizes_kiro_uuid_machine_id() { fn normalizes_kiro_uuid_machine_id() {
@@ -573,4 +713,151 @@ mod tests {
Some("123e4567e89b12d3a456426614174000123e4567e89b12d3a456426614174000") Some("123e4567e89b12d3a456426614174000123e4567e89b12d3a456426614174000")
); );
} }
#[tokio::test]
async fn refreshes_social_auth_config_with_provider_adapter() {
let seen_request = Arc::new(Mutex::new(None));
let executor = StaticExecutor {
seen_request: Arc::clone(&seen_request),
response: json!({
"accessToken": "new-social-access-token",
"refreshToken": "s".repeat(120),
"expiresIn": 3600,
"profileArn": "arn:aws:bedrock:demo"
}),
};
let adapter = KiroProviderOAuthAdapter::default()
.with_refresh_base_urls(Some("https://auth.example.test".to_string()), None);
let auth_config = KiroAuthConfig {
auth_method: Some("social".to_string()),
refresh_token: Some("r".repeat(120)),
expires_at: Some(1),
profile_arn: None,
region: None,
auth_region: None,
api_region: None,
client_id: None,
client_secret: None,
machine_id: Some("123e4567-e89b-12d3-a456-426614174000".to_string()),
kiro_version: Some("1.2.3".to_string()),
system_version: None,
node_version: None,
access_token: None,
};
let refreshed = adapter
.refresh_auth_config(&executor, &test_ctx(), &auth_config)
.await
.expect("social refresh should succeed");
assert_eq!(
refreshed.access_token.as_deref(),
Some("new-social-access-token")
);
let expected_rotated_refresh_token = "s".repeat(120);
assert_eq!(
refreshed.refresh_token.as_deref(),
Some(expected_rotated_refresh_token.as_str())
);
assert_eq!(
refreshed.profile_arn.as_deref(),
Some("arn:aws:bedrock:demo")
);
assert!(refreshed.expires_at.is_some());
let seen = seen_request
.lock()
.expect("mutex should lock")
.clone()
.expect("request should be captured");
assert_eq!(seen.request_id, "provider-oauth:kiro-social-refresh");
assert_eq!(seen.method, reqwest::Method::POST);
assert_eq!(seen.url, "https://auth.example.test/refreshToken");
assert_eq!(
seen.headers.get("user-agent").map(String::as_str),
Some("KiroIDE-1.2.3-123e4567e89b12d3a456426614174000123e4567e89b12d3a456426614174000")
);
let expected_refresh_token = "r".repeat(120);
assert_eq!(
seen.json_body
.as_ref()
.and_then(|body| body.get("refreshToken"))
.and_then(serde_json::Value::as_str),
Some(expected_refresh_token.as_str())
);
}
#[tokio::test]
async fn refreshes_idc_auth_config_with_provider_adapter() {
let seen_request = Arc::new(Mutex::new(None));
let executor = StaticExecutor {
seen_request: Arc::clone(&seen_request),
response: json!({
"accessToken": "new-idc-access-token",
"refreshToken": "i".repeat(120),
"expiresIn": 1800
}),
};
let adapter = KiroProviderOAuthAdapter::default()
.with_refresh_base_urls(None, Some("https://idc.example.test".to_string()));
let auth_config = KiroAuthConfig {
auth_method: Some("identity_center".to_string()),
refresh_token: Some("r".repeat(120)),
expires_at: Some(1),
profile_arn: Some("arn:aws:bedrock:demo".to_string()),
region: None,
auth_region: None,
api_region: None,
client_id: Some("client-id".to_string()),
client_secret: Some("client-secret".to_string()),
machine_id: None,
kiro_version: None,
system_version: None,
node_version: None,
access_token: None,
};
let refreshed = adapter
.refresh_auth_config(&executor, &test_ctx(), &auth_config)
.await
.expect("idc refresh should succeed");
assert_eq!(
refreshed.access_token.as_deref(),
Some("new-idc-access-token")
);
let expected_rotated_refresh_token = "i".repeat(120);
assert_eq!(
refreshed.refresh_token.as_deref(),
Some(expected_rotated_refresh_token.as_str())
);
assert!(refreshed.machine_id.is_some());
assert!(refreshed.expires_at.is_some());
let seen = seen_request
.lock()
.expect("mutex should lock")
.clone()
.expect("request should be captured");
assert_eq!(seen.request_id, "provider-oauth:kiro-idc-refresh");
assert_eq!(seen.method, reqwest::Method::POST);
assert_eq!(seen.url, "https://idc.example.test/token");
assert_eq!(
seen.headers.get("user-agent").map(String::as_str),
Some("node")
);
assert_eq!(
seen.headers.get("x-amz-user-agent").map(String::as_str),
Some(IDC_AMZ_USER_AGENT)
);
let body = seen.json_body.expect("json body should exist");
assert_eq!(body.get("grantType"), Some(&json!("refresh_token")));
assert_eq!(body.get("clientId"), Some(&json!("client-id")));
assert_eq!(body.get("clientSecret"), Some(&json!("client-secret")));
let expected_refresh_token = "r".repeat(120);
assert_eq!(
body.get("refreshToken").and_then(serde_json::Value::as_str),
Some(expected_refresh_token.as_str())
);
}
} }

View File

@@ -9,5 +9,7 @@ pub use generic::{
GenericProviderOAuthAdapter, GenericProviderOAuthTemplate, GENERIC_PROVIDER_OAUTH_TEMPLATES, GenericProviderOAuthAdapter, GenericProviderOAuthTemplate, GENERIC_PROVIDER_OAUTH_TEMPLATES,
}; };
pub use kiro::{ pub use kiro::{
generate_kiro_machine_id, KiroAuthConfig, KiroProviderOAuthAdapter, KIRO_PROVIDER_TYPE, generate_kiro_machine_id, normalize_kiro_machine_id, KiroAuthConfig, KiroProviderOAuthAdapter,
DEFAULT_KIRO_VERSION, DEFAULT_NODE_VERSION, DEFAULT_REGION, DEFAULT_SYSTEM_VERSION,
KIRO_PROVIDER_TYPE,
}; };

View File

@@ -0,0 +1,11 @@
[package]
name = "aether-pool-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Provider-independent pool scheduling and scoring primitives for Aether"
[dependencies]
aether-data-contracts.workspace = true
serde_json.workspace = true

View File

@@ -0,0 +1,17 @@
mod scheduler;
mod scoring;
pub use scheduler::{
normalize_enabled_pool_presets, run_pool_scheduler, PoolCandidateFacts, PoolCandidateInput,
PoolCandidateOrchestration, PoolMemberSignals, PoolRuntimeState, PoolScheduledCandidate,
PoolSchedulerOutcome, PoolSchedulingConfig, PoolSchedulingPreset, PoolSkippedCandidate,
POOL_ACCOUNT_BLOCKED_SKIP_REASON, POOL_ACCOUNT_EXHAUSTED_SKIP_REASON,
POOL_COOLDOWN_SKIP_REASON, POOL_COST_LIMIT_REACHED_SKIP_REASON,
};
pub use scoring::{
probe_freshness_score, probe_freshness_score_with_ttl, score_pool_member,
score_pool_member_with_rules, PoolMemberScoreInput, PoolMemberScoreOutput,
PoolMemberScoreRules, PoolMemberScoreWeights, POOL_SCORE_VERSION,
PROBE_FAILURE_COOLDOWN_THRESHOLD, PROBE_FAILURE_PENALTY, PROBE_FRESHNESS_TTL_SECONDS,
REQUEST_FAILURE_PENALTY, UNSCHEDULABLE_SCORE_CAP,
};

View File

@@ -2,28 +2,28 @@ use std::cmp::Ordering;
use std::collections::{btree_map::Entry, BTreeMap, BTreeSet}; use std::collections::{btree_map::Entry, BTreeMap, BTreeSet};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
pub const AI_POOL_ACCOUNT_BLOCKED_SKIP_REASON: &str = "pool_account_blocked"; pub const POOL_ACCOUNT_BLOCKED_SKIP_REASON: &str = "pool_account_blocked";
pub const AI_POOL_ACCOUNT_EXHAUSTED_SKIP_REASON: &str = "pool_account_exhausted"; pub const POOL_ACCOUNT_EXHAUSTED_SKIP_REASON: &str = "pool_account_exhausted";
pub const AI_POOL_COOLDOWN_SKIP_REASON: &str = "pool_cooldown"; pub const POOL_COOLDOWN_SKIP_REASON: &str = "pool_cooldown";
pub const AI_POOL_COST_LIMIT_REACHED_SKIP_REASON: &str = "pool_cost_limit_reached"; pub const POOL_COST_LIMIT_REACHED_SKIP_REASON: &str = "pool_cost_limit_reached";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiPoolSchedulingPreset { pub struct PoolSchedulingPreset {
pub preset: String, pub preset: String,
pub enabled: bool, pub enabled: bool,
pub mode: Option<String>, pub mode: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiPoolSchedulingConfig { pub struct PoolSchedulingConfig {
pub scheduling_presets: Vec<AiPoolSchedulingPreset>, pub scheduling_presets: Vec<PoolSchedulingPreset>,
pub lru_enabled: bool, pub lru_enabled: bool,
pub skip_exhausted_accounts: bool, pub skip_exhausted_accounts: bool,
pub cost_limit_per_key_tokens: Option<u64>, pub cost_limit_per_key_tokens: Option<u64>,
} }
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq)]
pub struct AiPoolRuntimeState { pub struct PoolRuntimeState {
pub sticky_bound_key_id: Option<String>, pub sticky_bound_key_id: Option<String>,
pub cooldown_reason_by_key: BTreeMap<String, String>, pub cooldown_reason_by_key: BTreeMap<String, String>,
pub cost_window_usage_by_key: BTreeMap<String, u64>, pub cost_window_usage_by_key: BTreeMap<String, u64>,
@@ -32,8 +32,8 @@ pub struct AiPoolRuntimeState {
} }
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq)]
pub struct AiPoolCatalogKeyContext { pub struct PoolMemberSignals {
pub oauth_plan_type: Option<String>, pub plan_tier: Option<String>,
pub quota_usage_ratio: Option<f64>, pub quota_usage_ratio: Option<f64>,
pub quota_reset_seconds: Option<f64>, pub quota_reset_seconds: Option<f64>,
pub account_blocked: bool, pub account_blocked: bool,
@@ -44,47 +44,46 @@ pub struct AiPoolCatalogKeyContext {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiPoolCandidateFacts { pub struct PoolCandidateFacts {
pub provider_id: String, pub provider_id: String,
pub endpoint_id: String, pub endpoint_id: String,
pub model_id: String, pub model_id: String,
pub selected_provider_model_name: String, pub selected_provider_model_name: String,
pub provider_api_format: String, pub provider_api_format: String,
pub provider_type: String,
pub key_id: String, pub key_id: String,
pub key_internal_priority: i32, pub key_internal_priority: i32,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AiPoolCandidateOrchestration { pub struct PoolCandidateOrchestration {
pub candidate_group_id: Option<String>, pub candidate_group_id: Option<String>,
pub pool_key_index: Option<u32>, pub pool_key_index: Option<u32>,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct AiPoolCandidateInput<Candidate> { pub struct PoolCandidateInput<Candidate> {
pub candidate: Candidate, pub candidate: Candidate,
pub facts: AiPoolCandidateFacts, pub facts: PoolCandidateFacts,
pub pool_config: Option<AiPoolSchedulingConfig>, pub pool_config: Option<PoolSchedulingConfig>,
pub key_context: AiPoolCatalogKeyContext, pub key_context: PoolMemberSignals,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct AiPoolScheduledCandidate<Candidate> { pub struct PoolScheduledCandidate<Candidate> {
pub candidate: Candidate, pub candidate: Candidate,
pub orchestration: AiPoolCandidateOrchestration, pub orchestration: PoolCandidateOrchestration,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct AiPoolSkippedCandidate<Candidate> { pub struct PoolSkippedCandidate<Candidate> {
pub candidate: Candidate, pub candidate: Candidate,
pub skip_reason: &'static str, pub skip_reason: &'static str,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct AiPoolSchedulerOutcome<Candidate> { pub struct PoolSchedulerOutcome<Candidate> {
pub candidates: Vec<AiPoolScheduledCandidate<Candidate>>, pub candidates: Vec<PoolScheduledCandidate<Candidate>>,
pub skipped_candidates: Vec<AiPoolSkippedCandidate<Candidate>>, pub skipped_candidates: Vec<PoolSkippedCandidate<Candidate>>,
} }
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
@@ -103,13 +102,13 @@ struct NormalizedPoolPreset {
mode: Option<String>, mode: Option<String>,
} }
pub fn run_ai_pool_scheduler<Candidate>( pub fn run_pool_scheduler<Candidate>(
candidates: Vec<AiPoolCandidateInput<Candidate>>, candidates: Vec<PoolCandidateInput<Candidate>>,
runtime_by_provider: &BTreeMap<String, AiPoolRuntimeState>, runtime_by_provider: &BTreeMap<String, PoolRuntimeState>,
load_balance_seed_nonce: &str, load_balance_seed_nonce: &str,
) -> AiPoolSchedulerOutcome<Candidate> { ) -> PoolSchedulerOutcome<Candidate> {
let mut group_order = Vec::new(); let mut group_order = Vec::new();
let mut groups = BTreeMap::<PoolGroupKey, Vec<AiPoolCandidateInput<Candidate>>>::new(); let mut groups = BTreeMap::<PoolGroupKey, Vec<PoolCandidateInput<Candidate>>>::new();
for candidate in candidates { for candidate in candidates {
let pool_enabled = candidate.pool_config.is_some(); let pool_enabled = candidate.pool_config.is_some();
@@ -127,20 +126,20 @@ pub fn run_ai_pool_scheduler<Candidate>(
let mut reordered = Vec::new(); let mut reordered = Vec::new();
let mut skipped = Vec::new(); let mut skipped = Vec::new();
let default_runtime = AiPoolRuntimeState::default(); let default_runtime = PoolRuntimeState::default();
for group_key in group_order { for group_key in group_order {
let Some(group) = groups.remove(&group_key) else { let Some(group) = groups.remove(&group_key) else {
continue; continue;
}; };
let candidate_group_id = ai_pool_candidate_group_id(&group_key); let candidate_group_id = pool_candidate_group_id(&group_key);
let Some(pool_config) = group let Some(pool_config) = group
.first() .first()
.expect("group should exist") .expect("group should exist")
.pool_config .pool_config
.clone() .clone()
else { else {
reordered.extend(annotate_ai_pool_candidates( reordered.extend(annotate_pool_candidates(
group, group,
candidate_group_id.as_str(), candidate_group_id.as_str(),
false, false,
@@ -161,14 +160,14 @@ pub fn run_ai_pool_scheduler<Candidate>(
skipped.extend(outcome.skipped_candidates); skipped.extend(outcome.skipped_candidates);
} }
AiPoolSchedulerOutcome { PoolSchedulerOutcome {
candidates: reordered, candidates: reordered,
skipped_candidates: skipped, skipped_candidates: skipped,
} }
} }
fn pool_group_key<Candidate>( fn pool_group_key<Candidate>(
candidate: &AiPoolCandidateInput<Candidate>, candidate: &PoolCandidateInput<Candidate>,
pool_enabled: bool, pool_enabled: bool,
) -> PoolGroupKey { ) -> PoolGroupKey {
PoolGroupKey { PoolGroupKey {
@@ -181,7 +180,7 @@ fn pool_group_key<Candidate>(
} }
} }
fn ai_pool_candidate_group_id(group_key: &PoolGroupKey) -> String { fn pool_candidate_group_id(group_key: &PoolGroupKey) -> String {
format!( format!(
"provider={}|endpoint={}|model={}|selected_model={}|api_format={}|singleton_key={}", "provider={}|endpoint={}|model={}|selected_model={}|api_format={}|singleton_key={}",
group_key.provider_id, group_key.provider_id,
@@ -194,18 +193,13 @@ fn ai_pool_candidate_group_id(group_key: &PoolGroupKey) -> String {
} }
fn schedule_pool_group<Candidate>( fn schedule_pool_group<Candidate>(
group: Vec<AiPoolCandidateInput<Candidate>>, group: Vec<PoolCandidateInput<Candidate>>,
pool_config: &AiPoolSchedulingConfig, pool_config: &PoolSchedulingConfig,
runtime: &AiPoolRuntimeState, runtime: &PoolRuntimeState,
candidate_group_id: &str, candidate_group_id: &str,
load_balance_seed_nonce: &str, load_balance_seed_nonce: &str,
) -> AiPoolSchedulerOutcome<Candidate> { ) -> PoolSchedulerOutcome<Candidate> {
let provider_type = group let active_presets = normalize_enabled_pool_preset_entries(&pool_config.scheduling_presets);
.first()
.map(|candidate| candidate.facts.provider_type.trim().to_ascii_lowercase())
.unwrap_or_default();
let active_presets =
normalize_enabled_pool_presets(&pool_config.scheduling_presets, provider_type.as_str());
let lru_distribution_enabled = pool_config.lru_enabled let lru_distribution_enabled = pool_config.lru_enabled
&& !active_presets && !active_presets
.iter() .iter()
@@ -223,25 +217,25 @@ fn schedule_pool_group<Candidate>(
.or(item.key_context.latency_avg_ms); .or(item.key_context.latency_avg_ms);
if item.key_context.account_blocked { if item.key_context.account_blocked {
skipped.push(AiPoolSkippedCandidate { skipped.push(PoolSkippedCandidate {
candidate: item.candidate, candidate: item.candidate,
skip_reason: AI_POOL_ACCOUNT_BLOCKED_SKIP_REASON, skip_reason: POOL_ACCOUNT_BLOCKED_SKIP_REASON,
}); });
continue; continue;
} }
if pool_config.skip_exhausted_accounts && item.key_context.quota_exhausted { if pool_config.skip_exhausted_accounts && item.key_context.quota_exhausted {
skipped.push(AiPoolSkippedCandidate { skipped.push(PoolSkippedCandidate {
candidate: item.candidate, candidate: item.candidate,
skip_reason: AI_POOL_ACCOUNT_EXHAUSTED_SKIP_REASON, skip_reason: POOL_ACCOUNT_EXHAUSTED_SKIP_REASON,
}); });
continue; continue;
} }
if runtime.cooldown_reason_by_key.contains_key(&key_id) { if runtime.cooldown_reason_by_key.contains_key(&key_id) {
skipped.push(AiPoolSkippedCandidate { skipped.push(PoolSkippedCandidate {
candidate: item.candidate, candidate: item.candidate,
skip_reason: AI_POOL_COOLDOWN_SKIP_REASON, skip_reason: POOL_COOLDOWN_SKIP_REASON,
}); });
continue; continue;
} }
@@ -250,9 +244,9 @@ fn schedule_pool_group<Candidate>(
.cost_limit_per_key_tokens .cost_limit_per_key_tokens
.is_some_and(|limit| runtime_cost_usage(runtime, key_id.as_str()) >= limit) .is_some_and(|limit| runtime_cost_usage(runtime, key_id.as_str()) >= limit)
{ {
skipped.push(AiPoolSkippedCandidate { skipped.push(PoolSkippedCandidate {
candidate: item.candidate, candidate: item.candidate,
skip_reason: AI_POOL_COST_LIMIT_REACHED_SKIP_REASON, skip_reason: POOL_COST_LIMIT_REACHED_SKIP_REASON,
}); });
continue; continue;
} }
@@ -269,7 +263,7 @@ fn schedule_pool_group<Candidate>(
} }
if available.is_empty() { if available.is_empty() {
return AiPoolSchedulerOutcome { return PoolSchedulerOutcome {
candidates: Vec::new(), candidates: Vec::new(),
skipped_candidates: skipped, skipped_candidates: skipped,
}; };
@@ -295,7 +289,6 @@ fn schedule_pool_group<Candidate>(
&active_presets, &active_presets,
lru_distribution_enabled, lru_distribution_enabled,
group_sort_seed( group_sort_seed(
provider_type.as_str(),
available.first().map(|item| &item.item.facts), available.first().map(|item| &item.item.facts),
load_balance_seed_nonce, load_balance_seed_nonce,
) )
@@ -324,23 +317,23 @@ fn schedule_pool_group<Candidate>(
} }
ordered.extend(available.into_iter().map(|item| item.item)); ordered.extend(available.into_iter().map(|item| item.item));
AiPoolSchedulerOutcome { PoolSchedulerOutcome {
candidates: annotate_ai_pool_candidates(ordered, candidate_group_id, true), candidates: annotate_pool_candidates(ordered, candidate_group_id, true),
skipped_candidates: skipped, skipped_candidates: skipped,
} }
} }
fn annotate_ai_pool_candidates<Candidate>( fn annotate_pool_candidates<Candidate>(
candidates: Vec<AiPoolCandidateInput<Candidate>>, candidates: Vec<PoolCandidateInput<Candidate>>,
candidate_group_id: &str, candidate_group_id: &str,
pool_enabled: bool, pool_enabled: bool,
) -> Vec<AiPoolScheduledCandidate<Candidate>> { ) -> Vec<PoolScheduledCandidate<Candidate>> {
candidates candidates
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(index, item)| AiPoolScheduledCandidate { .map(|(index, item)| PoolScheduledCandidate {
candidate: item.candidate, candidate: item.candidate,
orchestration: AiPoolCandidateOrchestration { orchestration: PoolCandidateOrchestration {
candidate_group_id: Some(candidate_group_id.to_string()), candidate_group_id: Some(candidate_group_id.to_string()),
pool_key_index: pool_enabled.then_some(index as u32), pool_key_index: pool_enabled.then_some(index as u32),
}, },
@@ -350,7 +343,7 @@ fn annotate_ai_pool_candidates<Candidate>(
#[derive(Debug)] #[derive(Debug)]
struct PoolGroupCandidateOrdering<Candidate> { struct PoolGroupCandidateOrdering<Candidate> {
item: AiPoolCandidateInput<Candidate>, item: PoolCandidateInput<Candidate>,
original_index: usize, original_index: usize,
lru_score: Option<f64>, lru_score: Option<f64>,
cost_usage: u64, cost_usage: u64,
@@ -473,7 +466,7 @@ fn plan_ranks<Candidate>(
( (
item.item.facts.key_id.clone(), item.item.facts.key_id.clone(),
Some(plan_priority_score( Some(plan_priority_score(
item.item.key_context.oauth_plan_type.as_deref(), item.item.key_context.plan_tier.as_deref(),
mode, mode,
)), )),
) )
@@ -574,19 +567,18 @@ fn load_balance_ranks<Candidate>(
} }
fn group_sort_seed( fn group_sort_seed(
provider_type: &str, candidate: Option<&PoolCandidateFacts>,
candidate: Option<&AiPoolCandidateFacts>,
load_balance_seed_nonce: &str, load_balance_seed_nonce: &str,
) -> String { ) -> String {
match candidate { match candidate {
Some(candidate) => format!( Some(candidate) => format!(
"{provider_type}:{}:{}:{}:{}:{load_balance_seed_nonce}", "{}:{}:{}:{}:{load_balance_seed_nonce}",
candidate.provider_id, candidate.provider_id,
candidate.endpoint_id, candidate.endpoint_id,
candidate.model_id, candidate.model_id,
candidate.selected_provider_model_name, candidate.selected_provider_model_name,
), ),
None => format!("{provider_type}:{load_balance_seed_nonce}"), None => load_balance_seed_nonce.to_string(),
} }
} }
@@ -736,21 +728,16 @@ fn plan_priority_score(plan_type: Option<&str>, mode: Option<&str>) -> f64 {
} }
} }
pub fn normalize_enabled_ai_pool_presets( pub fn normalize_enabled_pool_presets(scheduling_presets: &[PoolSchedulingPreset]) -> Vec<String> {
scheduling_presets: &[AiPoolSchedulingPreset], normalize_enabled_pool_preset_entries(scheduling_presets)
provider_type: &str,
) -> Vec<String> {
normalize_enabled_pool_presets(scheduling_presets, provider_type)
.into_iter() .into_iter()
.map(|preset| preset.preset) .map(|preset| preset.preset)
.collect() .collect()
} }
fn normalize_enabled_pool_presets( fn normalize_enabled_pool_preset_entries(
scheduling_presets: &[AiPoolSchedulingPreset], scheduling_presets: &[PoolSchedulingPreset],
provider_type: &str,
) -> Vec<NormalizedPoolPreset> { ) -> Vec<NormalizedPoolPreset> {
let provider_type = provider_type.trim().to_ascii_lowercase();
let mut entries = Vec::<(usize, String, bool, Option<String>)>::new(); let mut entries = Vec::<(usize, String, bool, Option<String>)>::new();
let mut seen = BTreeSet::new(); let mut seen = BTreeSet::new();
@@ -762,20 +749,11 @@ fn normalize_enabled_pool_presets(
entries.push((index, preset, item.enabled, item.mode.clone())); entries.push((index, preset, item.enabled, item.mode.clone()));
} }
if provider_type == "codex"
&& !entries.is_empty()
&& entries
.iter()
.all(|(_, preset, _, _)| preset != "recent_refresh")
{
entries.push((entries.len(), "recent_refresh".to_string(), true, None));
}
let mut distribution_mode = None::<(usize, String, Option<String>)>; let mut distribution_mode = None::<(usize, String, Option<String>)>;
let mut strategy_presets = Vec::<(usize, String, Option<String>)>::new(); let mut strategy_presets = Vec::<(usize, String, Option<String>)>::new();
for (index, preset, enabled, mode) in entries { for (index, preset, enabled, mode) in entries {
if !enabled || !pool_preset_supported_for_provider(&preset, &provider_type) { if !enabled {
continue; continue;
} }
@@ -809,15 +787,6 @@ fn normalize_enabled_pool_presets(
normalized normalized
} }
fn pool_preset_supported_for_provider(preset: &str, provider_type: &str) -> bool {
match preset {
"free_first" | "plus_first" | "pro_first" | "recent_refresh" | "team_first" => {
matches!(provider_type, "codex" | "kiro")
}
_ => true,
}
}
fn pool_preset_mutex_group(preset: &str) -> Option<&'static str> { fn pool_preset_mutex_group(preset: &str) -> Option<&'static str> {
match preset { match preset {
"lru" | "cache_affinity" | "load_balance" | "single_account" => Some("distribution_mode"), "lru" | "cache_affinity" | "load_balance" | "single_account" => Some("distribution_mode"),
@@ -825,11 +794,11 @@ fn pool_preset_mutex_group(preset: &str) -> Option<&'static str> {
} }
} }
fn runtime_lru_score(runtime: &AiPoolRuntimeState, key_id: &str) -> Option<f64> { fn runtime_lru_score(runtime: &PoolRuntimeState, key_id: &str) -> Option<f64> {
runtime.lru_score_by_key.get(key_id).copied() runtime.lru_score_by_key.get(key_id).copied()
} }
fn runtime_cost_usage(runtime: &AiPoolRuntimeState, key_id: &str) -> u64 { fn runtime_cost_usage(runtime: &PoolRuntimeState, key_id: &str) -> u64 {
runtime runtime
.cost_window_usage_by_key .cost_window_usage_by_key
.get(key_id) .get(key_id)
@@ -849,16 +818,16 @@ mod tests {
let runtime_by_provider = BTreeMap::from([( let runtime_by_provider = BTreeMap::from([(
"provider-pool".to_string(), "provider-pool".to_string(),
AiPoolRuntimeState { PoolRuntimeState {
lru_score_by_key: BTreeMap::from([ lru_score_by_key: BTreeMap::from([
("key-pool-a".to_string(), 20.0), ("key-pool-a".to_string(), 20.0),
("key-pool-b".to_string(), 10.0), ("key-pool-b".to_string(), 10.0),
]), ]),
..AiPoolRuntimeState::default() ..PoolRuntimeState::default()
}, },
)]); )]);
let outcome = run_ai_pool_scheduler( let outcome = run_pool_scheduler(
vec![pool_first, other, pool_second], vec![pool_first, other, pool_second],
&runtime_by_provider, &runtime_by_provider,
"seed", "seed",
@@ -887,17 +856,17 @@ mod tests {
let runtime_by_provider = BTreeMap::from([( let runtime_by_provider = BTreeMap::from([(
"provider-pool".to_string(), "provider-pool".to_string(),
AiPoolRuntimeState { PoolRuntimeState {
cooldown_reason_by_key: BTreeMap::from([( cooldown_reason_by_key: BTreeMap::from([(
"key-cooldown".to_string(), "key-cooldown".to_string(),
"429".to_string(), "429".to_string(),
)]), )]),
cost_window_usage_by_key: BTreeMap::from([("key-cost".to_string(), 100)]), cost_window_usage_by_key: BTreeMap::from([("key-cost".to_string(), 100)]),
..AiPoolRuntimeState::default() ..PoolRuntimeState::default()
}, },
)]); )]);
let outcome = run_ai_pool_scheduler( let outcome = run_pool_scheduler(
vec![key_ready, key_cooldown, key_cost], vec![key_ready, key_cooldown, key_cost],
&runtime_by_provider, &runtime_by_provider,
"seed", "seed",
@@ -927,13 +896,13 @@ mod tests {
#[test] #[test]
fn pool_scheduler_promotes_sticky_hit_before_other_sorted_keys() { fn pool_scheduler_promotes_sticky_hit_before_other_sorted_keys() {
let key_a = sample_candidate("provider-pool", "endpoint-1", "key-a", 10, true) let key_a = sample_candidate("provider-pool", "endpoint-1", "key-a", 10, true)
.with_presets(vec![AiPoolSchedulingPreset { .with_presets(vec![PoolSchedulingPreset {
preset: "cache_affinity".to_string(), preset: "cache_affinity".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}]); }]);
let key_b = sample_candidate("provider-pool", "endpoint-1", "key-b", 10, true) let key_b = sample_candidate("provider-pool", "endpoint-1", "key-b", 10, true)
.with_presets(vec![AiPoolSchedulingPreset { .with_presets(vec![PoolSchedulingPreset {
preset: "cache_affinity".to_string(), preset: "cache_affinity".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
@@ -941,17 +910,17 @@ mod tests {
let runtime_by_provider = BTreeMap::from([( let runtime_by_provider = BTreeMap::from([(
"provider-pool".to_string(), "provider-pool".to_string(),
AiPoolRuntimeState { PoolRuntimeState {
sticky_bound_key_id: Some("key-a".to_string()), sticky_bound_key_id: Some("key-a".to_string()),
lru_score_by_key: BTreeMap::from([ lru_score_by_key: BTreeMap::from([
("key-a".to_string(), 50.0), ("key-a".to_string(), 50.0),
("key-b".to_string(), 10.0), ("key-b".to_string(), 10.0),
]), ]),
..AiPoolRuntimeState::default() ..PoolRuntimeState::default()
}, },
)]); )]);
let outcome = run_ai_pool_scheduler(vec![key_a, key_b], &runtime_by_provider, "seed"); let outcome = run_pool_scheduler(vec![key_a, key_b], &runtime_by_provider, "seed");
assert!(outcome.skipped_candidates.is_empty()); assert!(outcome.skipped_candidates.is_empty());
assert_eq!( assert_eq!(
@@ -967,13 +936,13 @@ mod tests {
#[test] #[test]
fn load_balance_distribution_ignores_sticky_hit() { fn load_balance_distribution_ignores_sticky_hit() {
let key_a = sample_candidate("provider-pool", "endpoint-1", "key-a", 10, true) let key_a = sample_candidate("provider-pool", "endpoint-1", "key-a", 10, true)
.with_presets(vec![AiPoolSchedulingPreset { .with_presets(vec![PoolSchedulingPreset {
preset: "load_balance".to_string(), preset: "load_balance".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}]); }]);
let key_b = sample_candidate("provider-pool", "endpoint-1", "key-b", 10, true) let key_b = sample_candidate("provider-pool", "endpoint-1", "key-b", 10, true)
.with_presets(vec![AiPoolSchedulingPreset { .with_presets(vec![PoolSchedulingPreset {
preset: "load_balance".to_string(), preset: "load_balance".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
@@ -981,20 +950,20 @@ mod tests {
let nonce = (0..1000) let nonce = (0..1000)
.map(|index| format!("seed-{index}")) .map(|index| format!("seed-{index}"))
.find(|nonce| { .find(|nonce| {
let group_seed = format!("codex:provider-pool:endpoint-1:model-1:gpt-5:{nonce}"); let group_seed = format!("provider-pool:endpoint-1:model-1:gpt-5:{nonce}");
stable_hash_score(format!("{group_seed}:key-b").as_str()) stable_hash_score(format!("{group_seed}:key-b").as_str())
< stable_hash_score(format!("{group_seed}:key-a").as_str()) < stable_hash_score(format!("{group_seed}:key-a").as_str())
}) })
.expect("test seed should exist"); .expect("test seed should exist");
let runtime_by_provider = BTreeMap::from([( let runtime_by_provider = BTreeMap::from([(
"provider-pool".to_string(), "provider-pool".to_string(),
AiPoolRuntimeState { PoolRuntimeState {
sticky_bound_key_id: Some("key-a".to_string()), sticky_bound_key_id: Some("key-a".to_string()),
..AiPoolRuntimeState::default() ..PoolRuntimeState::default()
}, },
)]); )]);
let outcome = run_ai_pool_scheduler(vec![key_a, key_b], &runtime_by_provider, &nonce); let outcome = run_pool_scheduler(vec![key_a, key_b], &runtime_by_provider, &nonce);
assert!(outcome.skipped_candidates.is_empty()); assert!(outcome.skipped_candidates.is_empty());
assert_eq!( assert_eq!(
@@ -1010,21 +979,21 @@ mod tests {
#[test] #[test]
fn pool_scheduler_uses_plan_preset_with_catalog_context() { fn pool_scheduler_uses_plan_preset_with_catalog_context() {
let key_free = sample_candidate("provider-pool", "endpoint-1", "key-free", 10, true) let key_free = sample_candidate("provider-pool", "endpoint-1", "key-free", 10, true)
.with_presets(vec![AiPoolSchedulingPreset { .with_presets(vec![PoolSchedulingPreset {
preset: "plus_first".to_string(), preset: "plus_first".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}]) }])
.with_plan("free"); .with_plan("free");
let key_plus = sample_candidate("provider-pool", "endpoint-1", "key-plus", 10, true) let key_plus = sample_candidate("provider-pool", "endpoint-1", "key-plus", 10, true)
.with_presets(vec![AiPoolSchedulingPreset { .with_presets(vec![PoolSchedulingPreset {
preset: "plus_first".to_string(), preset: "plus_first".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}]) }])
.with_plan("plus"); .with_plan("plus");
let outcome = run_ai_pool_scheduler(vec![key_free, key_plus], &BTreeMap::new(), "seed"); let outcome = run_pool_scheduler(vec![key_free, key_plus], &BTreeMap::new(), "seed");
assert!(outcome.skipped_candidates.is_empty()); assert!(outcome.skipped_candidates.is_empty());
assert_eq!( assert_eq!(
@@ -1042,12 +1011,12 @@ mod tests {
let key_cache_hit = let key_cache_hit =
sample_candidate("provider-pool", "endpoint-1", "key-cache-hit", 50, true) sample_candidate("provider-pool", "endpoint-1", "key-cache-hit", 50, true)
.with_presets(vec![ .with_presets(vec![
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "cache_affinity".to_string(), preset: "cache_affinity".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "priority_first".to_string(), preset: "priority_first".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
@@ -1056,12 +1025,12 @@ mod tests {
let key_high_priority = let key_high_priority =
sample_candidate("provider-pool", "endpoint-1", "key-high-priority", 10, true) sample_candidate("provider-pool", "endpoint-1", "key-high-priority", 10, true)
.with_presets(vec![ .with_presets(vec![
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "cache_affinity".to_string(), preset: "cache_affinity".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "priority_first".to_string(), preset: "priority_first".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
@@ -1070,16 +1039,16 @@ mod tests {
let runtime_by_provider = BTreeMap::from([( let runtime_by_provider = BTreeMap::from([(
"provider-pool".to_string(), "provider-pool".to_string(),
AiPoolRuntimeState { PoolRuntimeState {
lru_score_by_key: BTreeMap::from([ lru_score_by_key: BTreeMap::from([
("key-cache-hit".to_string(), 200.0), ("key-cache-hit".to_string(), 200.0),
("key-high-priority".to_string(), 10.0), ("key-high-priority".to_string(), 10.0),
]), ]),
..AiPoolRuntimeState::default() ..PoolRuntimeState::default()
}, },
)]); )]);
let outcome = run_ai_pool_scheduler( let outcome = run_pool_scheduler(
vec![key_cache_hit, key_high_priority], vec![key_cache_hit, key_high_priority],
&runtime_by_provider, &runtime_by_provider,
"seed", "seed",
@@ -1101,12 +1070,12 @@ mod tests {
let key_random_first = let key_random_first =
sample_candidate("provider-pool", "endpoint-1", "key-random-first", 50, true) sample_candidate("provider-pool", "endpoint-1", "key-random-first", 50, true)
.with_presets(vec![ .with_presets(vec![
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "load_balance".to_string(), preset: "load_balance".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "priority_first".to_string(), preset: "priority_first".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
@@ -1115,12 +1084,12 @@ mod tests {
let key_high_priority = let key_high_priority =
sample_candidate("provider-pool", "endpoint-1", "key-high-priority", 10, true) sample_candidate("provider-pool", "endpoint-1", "key-high-priority", 10, true)
.with_presets(vec![ .with_presets(vec![
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "load_balance".to_string(), preset: "load_balance".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "priority_first".to_string(), preset: "priority_first".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
@@ -1129,13 +1098,13 @@ mod tests {
let nonce = (0..1000) let nonce = (0..1000)
.map(|index| format!("seed-{index}")) .map(|index| format!("seed-{index}"))
.find(|nonce| { .find(|nonce| {
let group_seed = format!("codex:provider-pool:endpoint-1:model-1:gpt-5:{nonce}"); let group_seed = format!("provider-pool:endpoint-1:model-1:gpt-5:{nonce}");
stable_hash_score(format!("{group_seed}:key-random-first").as_str()) stable_hash_score(format!("{group_seed}:key-random-first").as_str())
< stable_hash_score(format!("{group_seed}:key-high-priority").as_str()) < stable_hash_score(format!("{group_seed}:key-high-priority").as_str())
}) })
.expect("test seed should exist"); .expect("test seed should exist");
let outcome = run_ai_pool_scheduler( let outcome = run_pool_scheduler(
vec![key_random_first, key_high_priority], vec![key_random_first, key_high_priority],
&BTreeMap::new(), &BTreeMap::new(),
nonce.as_str(), nonce.as_str(),
@@ -1156,7 +1125,7 @@ mod tests {
fn single_account_distribution_orders_by_priority_then_reverse_lru() { fn single_account_distribution_orders_by_priority_then_reverse_lru() {
let key_priority_old = let key_priority_old =
sample_candidate("provider-pool", "endpoint-1", "key-priority-old", 10, true) sample_candidate("provider-pool", "endpoint-1", "key-priority-old", 10, true)
.with_presets(vec![AiPoolSchedulingPreset { .with_presets(vec![PoolSchedulingPreset {
preset: "single_account".to_string(), preset: "single_account".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
@@ -1168,7 +1137,7 @@ mod tests {
10, 10,
true, true,
) )
.with_presets(vec![AiPoolSchedulingPreset { .with_presets(vec![PoolSchedulingPreset {
preset: "single_account".to_string(), preset: "single_account".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
@@ -1180,7 +1149,7 @@ mod tests {
50, 50,
true, true,
) )
.with_presets(vec![AiPoolSchedulingPreset { .with_presets(vec![PoolSchedulingPreset {
preset: "single_account".to_string(), preset: "single_account".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
@@ -1188,17 +1157,17 @@ mod tests {
let runtime_by_provider = BTreeMap::from([( let runtime_by_provider = BTreeMap::from([(
"provider-pool".to_string(), "provider-pool".to_string(),
AiPoolRuntimeState { PoolRuntimeState {
lru_score_by_key: BTreeMap::from([ lru_score_by_key: BTreeMap::from([
("key-priority-old".to_string(), 10.0), ("key-priority-old".to_string(), 10.0),
("key-priority-recent".to_string(), 200.0), ("key-priority-recent".to_string(), 200.0),
("key-lower-priority-recent".to_string(), 500.0), ("key-lower-priority-recent".to_string(), 500.0),
]), ]),
..AiPoolRuntimeState::default() ..PoolRuntimeState::default()
}, },
)]); )]);
let outcome = run_ai_pool_scheduler( let outcome = run_pool_scheduler(
vec![ vec![
key_priority_old, key_priority_old,
key_lower_priority_recent, key_lower_priority_recent,
@@ -1225,57 +1194,51 @@ mod tests {
#[test] #[test]
fn normalizes_distribution_mode_before_strategy_presets() { fn normalizes_distribution_mode_before_strategy_presets() {
let presets = normalize_enabled_ai_pool_presets( let presets = normalize_enabled_pool_presets(&[
&[ PoolSchedulingPreset {
AiPoolSchedulingPreset {
preset: "lru".to_string(), preset: "lru".to_string(),
enabled: false, enabled: false,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "single_account".to_string(), preset: "single_account".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "cache_affinity".to_string(), preset: "cache_affinity".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "priority_first".to_string(), preset: "priority_first".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
], ]);
"openai",
);
assert_eq!(presets, ["single_account", "priority_first"]); assert_eq!(presets, ["single_account", "priority_first"]);
} }
#[test] #[test]
fn normalizes_lru_as_mutually_exclusive_distribution_mode() { fn normalizes_lru_as_mutually_exclusive_distribution_mode() {
let presets = normalize_enabled_ai_pool_presets( let presets = normalize_enabled_pool_presets(&[
&[ PoolSchedulingPreset {
AiPoolSchedulingPreset {
preset: "lru".to_string(), preset: "lru".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "cache_affinity".to_string(), preset: "cache_affinity".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
AiPoolSchedulingPreset { PoolSchedulingPreset {
preset: "priority_first".to_string(), preset: "priority_first".to_string(),
enabled: true, enabled: true,
mode: None, mode: None,
}, },
], ]);
"openai",
);
assert_eq!(presets, ["priority_first"]); assert_eq!(presets, ["priority_first"]);
} }
@@ -1286,37 +1249,36 @@ mod tests {
key_id: &str, key_id: &str,
internal_priority: i32, internal_priority: i32,
pool_enabled: bool, pool_enabled: bool,
) -> AiPoolCandidateInput<String> { ) -> PoolCandidateInput<String> {
let pool_config = pool_enabled.then(|| AiPoolSchedulingConfig { let pool_config = pool_enabled.then(|| PoolSchedulingConfig {
scheduling_presets: Vec::new(), scheduling_presets: Vec::new(),
lru_enabled: true, lru_enabled: true,
skip_exhausted_accounts: false, skip_exhausted_accounts: false,
cost_limit_per_key_tokens: None, cost_limit_per_key_tokens: None,
}); });
AiPoolCandidateInput { PoolCandidateInput {
candidate: key_id.to_string(), candidate: key_id.to_string(),
facts: AiPoolCandidateFacts { facts: PoolCandidateFacts {
provider_id: provider_id.to_string(), provider_id: provider_id.to_string(),
endpoint_id: endpoint_id.to_string(), endpoint_id: endpoint_id.to_string(),
model_id: "model-1".to_string(), model_id: "model-1".to_string(),
selected_provider_model_name: "gpt-5".to_string(), selected_provider_model_name: "gpt-5".to_string(),
provider_api_format: "openai:chat".to_string(), provider_api_format: "openai:chat".to_string(),
provider_type: "codex".to_string(),
key_id: key_id.to_string(), key_id: key_id.to_string(),
key_internal_priority: internal_priority, key_internal_priority: internal_priority,
}, },
pool_config, pool_config,
key_context: AiPoolCatalogKeyContext::default(), key_context: PoolMemberSignals::default(),
} }
} }
trait TestCandidateExt { trait TestCandidateExt {
fn with_cost_limit(self, limit: u64) -> Self; fn with_cost_limit(self, limit: u64) -> Self;
fn with_presets(self, presets: Vec<AiPoolSchedulingPreset>) -> Self; fn with_presets(self, presets: Vec<PoolSchedulingPreset>) -> Self;
fn with_plan(self, plan: &str) -> Self; fn with_plan(self, plan: &str) -> Self;
} }
impl TestCandidateExt for AiPoolCandidateInput<String> { impl TestCandidateExt for PoolCandidateInput<String> {
fn with_cost_limit(mut self, limit: u64) -> Self { fn with_cost_limit(mut self, limit: u64) -> Self {
if let Some(config) = self.pool_config.as_mut() { if let Some(config) = self.pool_config.as_mut() {
config.cost_limit_per_key_tokens = Some(limit); config.cost_limit_per_key_tokens = Some(limit);
@@ -1324,7 +1286,7 @@ mod tests {
self self
} }
fn with_presets(mut self, presets: Vec<AiPoolSchedulingPreset>) -> Self { fn with_presets(mut self, presets: Vec<PoolSchedulingPreset>) -> Self {
if let Some(config) = self.pool_config.as_mut() { if let Some(config) = self.pool_config.as_mut() {
config.scheduling_presets = presets; config.scheduling_presets = presets;
} }
@@ -1332,7 +1294,7 @@ mod tests {
} }
fn with_plan(mut self, plan: &str) -> Self { fn with_plan(mut self, plan: &str) -> Self {
self.key_context.oauth_plan_type = Some(plan.to_string()); self.key_context.plan_tier = Some(plan.to_string());
self self
} }
} }

View File

@@ -0,0 +1,14 @@
[package]
name = "aether-provider-pool"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Provider-specific pool behavior adapters for Aether"
[dependencies]
aether-data-contracts.workspace = true
aether-pool-core.workspace = true
serde_json.workspace = true
url.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,23 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderPoolCapability {
PlanTier,
QuotaReset,
QuotaRefresh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ProviderPoolCapabilities {
pub plan_tier: bool,
pub quota_reset: bool,
pub quota_refresh: bool,
}
impl ProviderPoolCapabilities {
pub fn supports(self, capability: ProviderPoolCapability) -> bool {
match capability {
ProviderPoolCapability::PlanTier => self.plan_tier,
ProviderPoolCapability::QuotaReset => self.quota_reset,
ProviderPoolCapability::QuotaRefresh => self.quota_refresh,
}
}
}

View File

@@ -0,0 +1,350 @@
mod capability;
mod plan;
mod presets;
mod provider;
mod quota;
mod quota_refresh;
mod service;
pub mod providers;
pub use capability::{ProviderPoolCapabilities, ProviderPoolCapability};
pub use plan::{derive_oauth_plan_type, derive_plan_tier, normalize_provider_plan_tier};
pub use presets::{
build_admin_pool_scheduling_presets_payload, normalize_provider_scheduling_presets,
};
pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
pub use providers::{
build_antigravity_pool_quota_request, build_chatgpt_web_pool_quota_request,
build_codex_pool_quota_request, build_kiro_pool_quota_request,
enrich_chatgpt_web_quota_metadata, normalize_chatgpt_web_image_quota_limit,
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, KiroPoolQuotaAuthInput, KiroProviderPoolAdapter,
UnsupportedQuotaProviderPoolAdapter, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
CHATGPT_WEB_CONVERSATION_INIT_PATH, CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL,
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
};
pub use quota::{
provider_pool_key_account_quota_exhausted, provider_pool_key_scheduling_label,
provider_pool_member_quota_snapshot, provider_pool_quota_metadata_provider_type,
provider_pool_quota_metadata_updated_at, provider_pool_quota_snapshot_updated_at,
};
pub use quota_refresh::ProviderPoolQuotaRequestSpec;
pub use service::ProviderPoolService;
#[cfg(test)]
mod tests {
use super::*;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use aether_pool_core::PoolSchedulingPreset;
use serde_json::{json, Value};
fn sample_key(upstream_metadata: Option<Value>) -> StoredProviderCatalogKey {
let mut key = StoredProviderCatalogKey::new(
"key-1".to_string(),
"provider-1".to_string(),
"key-1".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("key should build");
key.upstream_metadata = upstream_metadata;
key
}
#[test]
fn builtin_service_registers_provider_pool_adapters() {
let service = ProviderPoolService::with_builtin_adapters();
assert_eq!(
service.provider_types().collect::<Vec<_>>(),
[
"antigravity",
"chatgpt_web",
"claude_code",
"codex",
"gemini_cli",
"kiro",
"vertex_ai"
]
);
assert!(service
.adapter("codex")
.capabilities()
.supports(ProviderPoolCapability::PlanTier));
assert_eq!(service.adapter("unknown").provider_type(), "default");
}
#[test]
fn builtin_service_owns_quota_refresh_support_and_endpoint_selection() {
let service = ProviderPoolService::with_builtin_adapters();
assert_eq!(
service.provider_types_for_capability(ProviderPoolCapability::QuotaRefresh),
["antigravity", "chatgpt_web", "codex", "kiro"]
);
assert!(service.supports_quota_refresh("codex"));
assert!(service.supports_quota_refresh("antigravity"));
assert!(!service.supports_quota_refresh("gemini_cli"));
assert_eq!(
service.quota_refresh_unsupported_message("claude_code"),
"Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口"
);
assert_eq!(
service.quota_refresh_unsupported_message("vertex_ai"),
"Vertex AI 暂不支持自动刷新额度:额度属于 Google Cloud 项目/区域配额"
);
}
#[test]
fn codex_quota_request_adds_account_header_for_paid_accounts() {
let spec = build_codex_pool_quota_request(
"key-1",
Some(("authorization".to_string(), "Bearer access".to_string())),
None,
Some(&json!({
"plan_type": "plus",
"account_id": "acct-1"
})),
)
.expect("spec should build");
assert_eq!(
spec.headers.get("chatgpt-account-id").map(String::as_str),
Some("acct-1")
);
}
#[test]
fn codex_quota_request_skips_account_header_for_free_accounts() {
let spec = build_codex_pool_quota_request(
"key-1",
Some(("authorization".to_string(), "Bearer access".to_string())),
None,
Some(&json!({
"plan_type": "codex:free",
"account_id": "acct-1"
})),
)
.expect("spec should build");
assert!(!spec.headers.contains_key("chatgpt-account-id"));
}
#[test]
fn kiro_quota_request_includes_profile_arn_when_present() {
let spec = build_kiro_pool_quota_request(
"key-1",
&KiroPoolQuotaAuthInput {
authorization_value: "Bearer access".to_string(),
api_region: "us-west-2".to_string(),
kiro_version: "0.3.210".to_string(),
machine_id: "machine".to_string(),
profile_arn: Some("arn:aws:sso:::profile/p-1".to_string()),
},
);
assert!(spec.url.contains("q.us-west-2.amazonaws.com"));
assert!(spec
.url
.contains("profileArn=arn%3Aaws%3Asso%3A%3A%3Aprofile%2Fp-1"));
}
#[test]
fn chatgpt_web_quota_request_uses_default_base_url_when_empty() {
let spec = build_chatgpt_web_pool_quota_request(
"key-1",
"",
("authorization".to_string(), "Bearer access".to_string()),
);
assert_eq!(
spec.url,
"https://chatgpt.com/backend-api/conversation/init"
);
assert_eq!(
spec.headers.get("origin").map(String::as_str),
Some("https://chatgpt.com")
);
assert!(spec.accept_invalid_certs);
}
#[test]
fn chatgpt_web_quota_metadata_enriches_auth_and_normalizes_free_limit() {
let mut metadata = json!({
"image_quota_remaining": 12,
});
enrich_chatgpt_web_quota_metadata(
&mut metadata,
Some(&json!({
"plan": "free",
"email": "user@example.com",
"accountId": "acct-1"
})),
);
normalize_chatgpt_web_image_quota_limit(&mut metadata, None);
assert_eq!(metadata["plan_type"], json!("free"));
assert_eq!(metadata["email"], json!("user@example.com"));
assert_eq!(metadata["account_id"], json!("acct-1"));
assert_eq!(metadata["image_quota_total"], json!(25.0));
assert_eq!(metadata["image_quota_used"], json!(13.0));
}
#[test]
fn chatgpt_web_quota_metadata_preserves_existing_paid_limit() {
let mut metadata = json!({
"plan_type": "plus",
"image_quota_remaining": 7,
});
normalize_chatgpt_web_image_quota_limit(
&mut metadata,
Some(&json!({
"chatgpt_web": {
"image_quota_total": 40
}
})),
);
assert_eq!(metadata["image_quota_total"], json!(40.0));
assert_eq!(metadata["image_quota_used"], json!(33.0));
}
#[test]
fn preset_payload_derives_provider_support_from_capabilities() {
let payload = build_admin_pool_scheduling_presets_payload();
let items = payload.as_array().expect("payload should be array");
let free_first = items
.iter()
.find(|item| item["name"] == "free_first")
.expect("free_first should exist");
let recent_refresh = items
.iter()
.find(|item| item["name"] == "recent_refresh")
.expect("recent_refresh should exist");
assert_eq!(free_first["providers"], json!(["codex", "kiro"]));
assert_eq!(recent_refresh["providers"], json!(["codex", "kiro"]));
}
#[test]
fn quota_metadata_provider_type_comes_from_pool_registry() {
assert_eq!(
provider_pool_quota_metadata_provider_type(&json!({
"gemini_cli": {
"updated_at": 1_700_000_000u64
}
}))
.as_deref(),
Some("gemini_cli")
);
assert_eq!(
provider_pool_quota_metadata_provider_type(&json!({
"custom_provider": {
"updated_at": 1_700_000_000u64
}
}))
.as_deref(),
Some("custom_provider")
);
}
#[test]
fn codex_adapter_injects_recent_refresh_and_filters_by_capability() {
let service = ProviderPoolService::with_builtin_adapters();
let normalized = service.normalize_scheduling_presets(
"codex",
&[PoolSchedulingPreset {
preset: "cache_affinity".to_string(),
enabled: true,
mode: None,
}],
);
assert_eq!(
normalized
.iter()
.map(|preset| preset.preset.as_str())
.collect::<Vec<_>>(),
["cache_affinity", "recent_refresh"]
);
let unsupported = service.normalize_scheduling_presets(
"chatgpt_web",
&[PoolSchedulingPreset {
preset: "plus_first".to_string(),
enabled: true,
mode: None,
}],
);
assert!(unsupported.is_empty());
}
#[test]
fn provider_quota_exhaustion_is_adapter_owned() {
assert!(provider_pool_key_account_quota_exhausted(
&sample_key(Some(json!({
"codex": {
"has_credits": false,
"credits_unlimited": false
}
}))),
"codex",
));
assert!(provider_pool_key_account_quota_exhausted(
&sample_key(Some(json!({
"kiro": {
"remaining": 0
}
}))),
"kiro",
));
assert!(provider_pool_key_account_quota_exhausted(
&sample_key(Some(json!({
"chatgpt_web": {
"image_quota_blocked": true
}
}))),
"chatgpt_web",
));
assert!(!provider_pool_key_account_quota_exhausted(
&sample_key(Some(json!({
"codex": {
"has_credits": false,
"credits_unlimited": true
}
}))),
"codex",
));
}
#[test]
fn plan_tier_derivation_normalizes_provider_prefix() {
let key = sample_key(Some(json!({
"codex": {
"plan_type": "codex:Plus"
}
})));
assert_eq!(
derive_oauth_plan_type("codex", &key, None).as_deref(),
Some("plus")
);
}
#[test]
fn plan_tier_derivation_reads_quota_snapshot() {
let mut key = sample_key(None);
key.status_snapshot = Some(json!({
"quota": {
"plan_type": "team"
}
}));
assert_eq!(
derive_oauth_plan_type("codex", &key, None).as_deref(),
Some("team")
);
}
}

View File

@@ -0,0 +1,104 @@
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::{Map, Value};
pub fn derive_plan_tier(
provider_type: &str,
key: &StoredProviderCatalogKey,
auth_config: Option<&Map<String, Value>>,
) -> Option<String> {
let has_auth_config = auth_config.is_some()
|| key
.encrypted_auth_config
.as_deref()
.is_some_and(|value| !value.trim().is_empty());
if !provider_pool_auth_managed(key, provider_type, has_auth_config) {
return None;
}
if let Some(quota_snapshot) = key
.status_snapshot
.as_ref()
.and_then(Value::as_object)
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object)
{
if let Some(normalized) = derive_plan_tier_from_map(quota_snapshot, provider_type) {
return Some(normalized);
}
}
if let Some(upstream_metadata) = key.upstream_metadata.as_ref().and_then(Value::as_object) {
let provider_bucket = upstream_metadata
.get(&provider_type.trim().to_ascii_lowercase())
.and_then(Value::as_object);
for source in provider_bucket
.into_iter()
.chain(std::iter::once(upstream_metadata))
{
if let Some(normalized) = derive_plan_tier_from_map(source, provider_type) {
return Some(normalized);
}
}
}
if let Some(config) = auth_config {
if let Some(normalized) = derive_plan_tier_from_map(config, provider_type) {
return Some(normalized);
}
}
None
}
fn derive_plan_tier_from_map(source: &Map<String, Value>, provider_type: &str) -> Option<String> {
for field in [
"plan_type",
"tier",
"plan",
"subscription_title",
"subscription_plan",
] {
if let Some(value) = source.get(field).and_then(Value::as_str) {
if let Some(normalized) = normalize_provider_plan_tier(value, provider_type) {
return Some(normalized);
}
}
}
None
}
pub fn derive_oauth_plan_type(
provider_type: &str,
key: &StoredProviderCatalogKey,
auth_config: Option<&Map<String, Value>>,
) -> Option<String> {
derive_plan_tier(provider_type, key, auth_config)
}
pub fn normalize_provider_plan_tier(value: &str, provider_type: &str) -> Option<String> {
let mut normalized = value.trim().to_string();
if normalized.is_empty() {
return None;
}
let provider_type = provider_type.trim().to_ascii_lowercase();
if !provider_type.is_empty() && normalized.to_ascii_lowercase().starts_with(&provider_type) {
normalized = normalized[provider_type.len()..]
.trim_matches(|ch: char| [' ', ':', '-', '_'].contains(&ch))
.to_string();
}
let normalized = normalized.trim().to_ascii_lowercase();
(!normalized.is_empty()).then_some(normalized)
}
fn provider_pool_auth_managed(
key: &StoredProviderCatalogKey,
provider_type: &str,
has_auth_config: bool,
) -> bool {
key.auth_type.trim().eq_ignore_ascii_case("oauth")
|| (provider_type.trim().eq_ignore_ascii_case("kiro")
&& key.auth_type.trim().eq_ignore_ascii_case("bearer")
&& has_auth_config)
}

View File

@@ -0,0 +1,244 @@
use std::collections::BTreeSet;
use aether_pool_core::PoolSchedulingPreset;
use serde_json::{json, Value};
use crate::capability::ProviderPoolCapability;
use crate::provider::ProviderPoolAdapter;
use crate::service::ProviderPoolService;
pub fn normalize_provider_scheduling_presets(
adapter: &dyn ProviderPoolAdapter,
scheduling_presets: &[PoolSchedulingPreset],
) -> Vec<PoolSchedulingPreset> {
let mut entries = Vec::<(usize, PoolSchedulingPreset)>::new();
let mut seen = BTreeSet::new();
for (index, item) in scheduling_presets.iter().enumerate() {
let preset = item.preset.trim().to_ascii_lowercase();
if preset.is_empty() || !seen.insert(preset.clone()) {
continue;
}
if !provider_pool_supports_preset(adapter, &preset) {
continue;
}
entries.push((
index,
PoolSchedulingPreset {
preset,
enabled: item.enabled,
mode: item.mode.clone(),
},
));
}
if !entries.is_empty() {
for preset in adapter.default_scheduling_presets() {
let preset_name = preset.preset.trim().to_ascii_lowercase();
if preset_name.is_empty() || seen.contains(&preset_name) {
continue;
}
if !provider_pool_supports_preset(adapter, &preset_name) {
continue;
}
seen.insert(preset_name.clone());
entries.push((
entries.len(),
PoolSchedulingPreset {
preset: preset_name,
enabled: preset.enabled,
mode: preset.mode,
},
));
}
}
let mut distribution_mode = None::<(usize, PoolSchedulingPreset)>;
let mut strategy_presets = Vec::<(usize, PoolSchedulingPreset)>::new();
for (index, preset) in entries {
if !preset.enabled {
continue;
}
if let Some(mutex_group) = provider_pool_preset_mutex_group(&preset.preset) {
if mutex_group == "distribution_mode"
&& distribution_mode
.as_ref()
.is_none_or(|current| index < current.0)
{
distribution_mode = Some((index, preset));
}
} else {
strategy_presets.push((index, preset));
}
}
let mut normalized = Vec::new();
if let Some((_, preset)) = distribution_mode.filter(|(_, preset)| preset.preset != "lru") {
normalized.push(preset);
}
strategy_presets.sort_by_key(|left| left.0);
normalized.extend(strategy_presets.into_iter().map(|(_, preset)| preset));
normalized
}
pub fn build_admin_pool_scheduling_presets_payload() -> Value {
let service = ProviderPoolService::with_builtin_adapters();
json!([
provider_pool_preset_payload(
"lru",
"LRU 轮转",
"最久未使用的 Key 优先",
None,
"依据 LRU 时间戳(最近未使用优先)",
&service,
),
provider_pool_preset_payload(
"cache_affinity",
"缓存亲和",
"优先复用最近使用过的 Key利用 Prompt Caching",
None,
"依据 LRU 时间戳(最近使用优先,与 LRU 轮转相反)",
&service,
),
provider_pool_preset_payload(
"cost_first",
"成本优先",
"优先选择窗口消耗更低的账号",
None,
"依据窗口成本/Token 用量,缺失时回退配额使用率",
&service,
),
provider_pool_preset_payload(
"free_first",
"Free 优先",
"优先消耗 Free 账号(依赖 plan_type",
Some(ProviderPoolCapability::PlanTier),
"依据 plan_typeFree 账号优先调度)",
&service,
),
provider_pool_preset_payload(
"health_first",
"健康优先",
"优先选择健康分更高、失败更少的账号",
None,
"依据 health_by_format 聚合分(含熔断/失败衰减)",
&service,
),
provider_pool_preset_payload(
"latency_first",
"延迟优先",
"优先选择最近延迟更低的账号",
None,
"依据号池延迟窗口均值latency_window_seconds",
&service,
),
provider_pool_preset_payload(
"load_balance",
"负载均衡",
"随机分散 Key 使用,均匀分摊负载",
None,
"每次随机分值,实现完全均匀分散",
&service,
),
provider_pool_preset_payload(
"plus_first",
"Plus 优先",
"优先消耗 Plus 账号(依赖 plan_type",
Some(ProviderPoolCapability::PlanTier),
"依据 plan_typePlus 账号优先调度)",
&service,
),
provider_pool_preset_payload(
"pro_first",
"Pro 优先",
"优先消耗 Pro 账号(依赖 plan_type",
Some(ProviderPoolCapability::PlanTier),
"依据 plan_typePro 账号优先调度)",
&service,
),
provider_pool_preset_payload(
"priority_first",
"优先级优先",
"按账号优先级顺序调度(数字越小越优先)",
None,
"依据 internal_priority支持拖拽/手工编辑)",
&service,
),
provider_pool_preset_payload(
"quota_balanced",
"额度平均",
"优先选额度消耗最少的账号",
None,
"依据账号配额使用率;无配额时回退到窗口成本使用",
&service,
),
provider_pool_preset_payload(
"recent_refresh",
"额度刷新优先",
"优先选即将刷新额度的账号",
Some(ProviderPoolCapability::QuotaReset),
"依据账号额度重置倒计时next_reset / reset_seconds",
&service,
),
provider_pool_preset_payload(
"single_account",
"单号优先",
"集中使用同一账号(反向 LRU",
None,
"先按账号优先级internal_priority同级再按反向 LRU 集中",
&service,
),
provider_pool_preset_payload(
"team_first",
"Team 优先",
"优先消耗 Team 账号(依赖 plan_type",
Some(ProviderPoolCapability::PlanTier),
"依据 plan_typeTeam 账号优先调度)",
&service,
),
])
}
fn provider_pool_preset_payload(
name: &'static str,
label: &'static str,
description: &'static str,
capability: Option<ProviderPoolCapability>,
evidence_hint: &'static str,
service: &ProviderPoolService,
) -> Value {
let providers = capability
.map(|capability| service.provider_types_for_capability(capability))
.unwrap_or_default();
json!({
"name": name,
"label": label,
"description": description,
"providers": providers,
"modes": Value::Null,
"default_mode": Value::Null,
"mutex_group": provider_pool_preset_mutex_group(name),
"evidence_hint": evidence_hint,
})
}
fn provider_pool_supports_preset(adapter: &dyn ProviderPoolAdapter, preset: &str) -> bool {
match preset {
"free_first" | "plus_first" | "pro_first" | "team_first" => adapter
.capabilities()
.supports(ProviderPoolCapability::PlanTier),
"recent_refresh" => adapter
.capabilities()
.supports(ProviderPoolCapability::QuotaReset),
_ => true,
}
}
fn provider_pool_preset_mutex_group(preset: &str) -> Option<&'static str> {
match preset {
"lru" | "cache_affinity" | "load_balance" | "single_account" => Some("distribution_mode"),
_ => None,
}
}

View File

@@ -0,0 +1,107 @@
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use aether_pool_core::{PoolMemberSignals, PoolSchedulingPreset};
use serde_json::{Map, Value};
use crate::capability::{ProviderPoolCapabilities, ProviderPoolCapability};
use crate::plan::{derive_plan_tier, normalize_provider_plan_tier};
use crate::quota::{
provider_pool_account_blocked, provider_pool_quota_reset_seconds,
provider_pool_quota_snapshot_exhausted_decision, provider_pool_quota_usage_ratio,
};
#[derive(Debug, Clone)]
pub struct ProviderPoolMemberInput<'a> {
pub provider_type: &'a str,
pub key: &'a StoredProviderCatalogKey,
pub auth_config: Option<&'a Map<String, Value>>,
}
pub trait ProviderPoolAdapter: Send + Sync {
fn provider_type(&self) -> &'static str;
fn capabilities(&self) -> ProviderPoolCapabilities {
ProviderPoolCapabilities::default()
}
fn default_scheduling_presets(&self) -> Vec<PoolSchedulingPreset> {
Vec::new()
}
fn supports_quota_refresh(&self) -> bool {
self.capabilities()
.supports(ProviderPoolCapability::QuotaRefresh)
}
fn quota_refresh_endpoint(
&self,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
if !self.supports_quota_refresh() {
return None;
}
provider_pool_matching_endpoint(endpoints, include_inactive, |_| true)
}
fn quota_refresh_unsupported_message(&self) -> String {
"该 Provider 暂不支持自动刷新额度".to_string()
}
fn quota_refresh_missing_endpoint_message(&self) -> String {
"找不到有效端点".to_string()
}
fn normalize_plan_tier(&self, value: &str) -> Option<String> {
normalize_provider_plan_tier(value, self.provider_type())
}
fn member_signals(&self, input: &ProviderPoolMemberInput<'_>) -> PoolMemberSignals {
PoolMemberSignals {
plan_tier: derive_plan_tier(input.provider_type, input.key, input.auth_config),
quota_usage_ratio: provider_pool_quota_usage_ratio(input.key),
quota_reset_seconds: provider_pool_quota_reset_seconds(input.key),
account_blocked: provider_pool_account_blocked(input.key),
quota_exhausted: self.quota_exhausted(input),
..PoolMemberSignals::default()
}
}
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
provider_pool_quota_snapshot_exhausted_decision(input.key, input.provider_type)
.unwrap_or(false)
}
}
pub(crate) fn provider_pool_matching_endpoint<F>(
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
predicate: F,
) -> Option<StoredProviderCatalogEndpoint>
where
F: Fn(&StoredProviderCatalogEndpoint) -> bool,
{
endpoints
.iter()
.find(|endpoint| endpoint.is_active && predicate(endpoint))
.cloned()
.or_else(|| {
include_inactive.then(|| {
endpoints
.iter()
.find(|endpoint| !endpoint.is_active && predicate(endpoint))
.cloned()
})?
})
}
pub(crate) fn provider_pool_endpoint_format_matches(
endpoint: &StoredProviderCatalogEndpoint,
expected: &str,
) -> bool {
endpoint
.api_format
.trim()
.eq_ignore_ascii_case(expected.trim())
}

View File

@@ -0,0 +1,77 @@
use std::collections::BTreeMap;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
use serde_json::json;
use crate::capability::ProviderPoolCapabilities;
use crate::provider::{
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
};
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH: &str = "/v1internal:fetchAvailableModels";
#[derive(Debug, Clone, Default)]
pub struct AntigravityProviderPoolAdapter;
impl ProviderPoolAdapter for AntigravityProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"antigravity"
}
fn capabilities(&self) -> ProviderPoolCapabilities {
ProviderPoolCapabilities {
quota_refresh: true,
..ProviderPoolCapabilities::default()
}
}
fn quota_refresh_endpoint(
&self,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| {
provider_pool_endpoint_format_matches(endpoint, "gemini:generate_content")
})
}
fn quota_refresh_missing_endpoint_message(&self) -> String {
"找不到有效的 gemini:generate_content 端点".to_string()
}
}
pub fn build_antigravity_pool_quota_request(
key_id: &str,
endpoint_base_url: &str,
authorization: (String, String),
project_id: &str,
mut identity_headers: BTreeMap<String, String>,
) -> ProviderPoolQuotaRequestSpec {
let mut headers = std::mem::take(&mut identity_headers);
headers.insert("authorization".to_string(), authorization.1);
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("accept".to_string(), "application/json".to_string());
headers
.entry("user-agent".to_string())
.or_insert_with(|| "antigravity".to_string());
ProviderPoolQuotaRequestSpec {
request_id: format!("antigravity-quota:{key_id}"),
provider_name: "antigravity".to_string(),
quota_kind: "antigravity".to_string(),
method: "POST".to_string(),
url: format!(
"{}{}",
endpoint_base_url.trim_end_matches('/'),
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH
),
headers,
content_type: Some("application/json".to_string()),
json_body: Some(json!({ "project": project_id })),
client_api_format: "gemini:generate_content".to_string(),
provider_api_format: "antigravity:fetch_available_models".to_string(),
model_name: Some("fetchAvailableModels".to_string()),
accept_invalid_certs: false,
}
}

View File

@@ -0,0 +1,272 @@
use std::collections::BTreeMap;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
use serde_json::{json, Map, Value};
use uuid::Uuid;
use crate::capability::ProviderPoolCapabilities;
use crate::provider::{
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
ProviderPoolMemberInput,
};
use crate::quota::{
provider_pool_json_bool, provider_pool_json_f64, provider_pool_metadata_bucket,
provider_pool_quota_snapshot_exhausted_decision,
};
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const CHATGPT_WEB_DEFAULT_BASE_URL: &str = "https://chatgpt.com";
pub const CHATGPT_WEB_CONVERSATION_INIT_PATH: &str = "/backend-api/conversation/init";
const CHATGPT_WEB_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0";
const CHATGPT_WEB_CLIENT_VERSION: &str = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad";
const CHATGPT_WEB_BUILD_NUMBER: &str = "5955942";
const CHATGPT_WEB_SEC_CH_UA: &str =
r#""Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24""#;
const CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT: f64 = 25.0;
#[derive(Debug, Clone, Default)]
pub struct ChatGptWebProviderPoolAdapter;
impl ProviderPoolAdapter for ChatGptWebProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"chatgpt_web"
}
fn capabilities(&self) -> ProviderPoolCapabilities {
ProviderPoolCapabilities {
quota_refresh: true,
..ProviderPoolCapabilities::default()
}
}
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
if let Some(exhausted) =
provider_pool_quota_snapshot_exhausted_decision(input.key, input.provider_type)
{
return exhausted;
}
provider_pool_metadata_bucket(input.key.upstream_metadata.as_ref(), input.provider_type)
.is_some_and(quota_exhausted_from_bucket)
}
fn quota_refresh_endpoint(
&self,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| {
provider_pool_endpoint_format_matches(endpoint, "openai:image")
})
}
fn quota_refresh_missing_endpoint_message(&self) -> String {
"找不到有效的 openai:image 端点".to_string()
}
}
pub fn build_chatgpt_web_pool_quota_request(
key_id: &str,
endpoint_base_url: &str,
authorization: (String, String),
) -> ProviderPoolQuotaRequestSpec {
let base_url = chatgpt_web_base_url(endpoint_base_url);
let device_id = Uuid::new_v4().to_string();
let session_id = Uuid::new_v4().to_string();
let mut headers = BTreeMap::from([
("accept".to_string(), "application/json".to_string()),
("content-type".to_string(), "application/json".to_string()),
("user-agent".to_string(), CHATGPT_WEB_USER_AGENT.to_string()),
("origin".to_string(), base_url.clone()),
("referer".to_string(), format!("{base_url}/")),
(
"accept-language".to_string(),
"zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7".to_string(),
),
("cache-control".to_string(), "no-cache".to_string()),
("pragma".to_string(), "no-cache".to_string()),
("priority".to_string(), "u=1, i".to_string()),
("sec-ch-ua".to_string(), CHATGPT_WEB_SEC_CH_UA.to_string()),
("sec-ch-ua-arch".to_string(), r#""x86""#.to_string()),
("sec-ch-ua-bitness".to_string(), r#""64""#.to_string()),
("sec-ch-ua-mobile".to_string(), "?0".to_string()),
("sec-ch-ua-model".to_string(), r#""""#.to_string()),
("sec-ch-ua-platform".to_string(), r#""Windows""#.to_string()),
(
"sec-ch-ua-platform-version".to_string(),
r#""19.0.0""#.to_string(),
),
("sec-fetch-dest".to_string(), "empty".to_string()),
("sec-fetch-mode".to_string(), "cors".to_string()),
("sec-fetch-site".to_string(), "same-origin".to_string()),
("oai-device-id".to_string(), device_id),
("oai-session-id".to_string(), session_id),
("oai-language".to_string(), "zh-CN".to_string()),
(
"oai-client-version".to_string(),
CHATGPT_WEB_CLIENT_VERSION.to_string(),
),
(
"oai-client-build-number".to_string(),
CHATGPT_WEB_BUILD_NUMBER.to_string(),
),
(
"x-openai-target-path".to_string(),
CHATGPT_WEB_CONVERSATION_INIT_PATH.to_string(),
),
(
"x-openai-target-route".to_string(),
CHATGPT_WEB_CONVERSATION_INIT_PATH.to_string(),
),
]);
headers.insert(authorization.0.to_ascii_lowercase(), authorization.1);
ProviderPoolQuotaRequestSpec {
request_id: format!("chatgpt-web-quota:{key_id}"),
provider_name: "chatgpt_web".to_string(),
quota_kind: "chatgpt_web".to_string(),
method: "POST".to_string(),
url: format!("{base_url}{CHATGPT_WEB_CONVERSATION_INIT_PATH}"),
headers,
content_type: Some("application/json".to_string()),
json_body: Some(json!({
"gizmo_id": Value::Null,
"requested_default_model": Value::Null,
"conversation_id": Value::Null,
"timezone_offset_min": -480,
"system_hints": ["picture_v2"],
})),
client_api_format: "openai:image".to_string(),
provider_api_format: "chatgpt_web:conversation_init".to_string(),
model_name: Some("chatgpt-web-conversation-init".to_string()),
accept_invalid_certs: true,
}
}
fn chatgpt_web_base_url(endpoint_base_url: &str) -> String {
let base_url = endpoint_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
CHATGPT_WEB_DEFAULT_BASE_URL.to_string()
} else {
base_url.to_string()
}
}
pub fn enrich_chatgpt_web_quota_metadata(metadata: &mut Value, auth_config: Option<&Value>) {
let Some(object) = metadata.as_object_mut() else {
return;
};
for (target, fields) in [
("plan_type", &["plan_type", "tier", "plan"][..]),
("email", &["email"][..]),
("account_id", &["account_id", "accountId"][..]),
("account_user_id", &["account_user_id", "accountUserId"][..]),
("user_id", &["user_id", "userId"][..]),
] {
if object.contains_key(target) {
continue;
}
if let Some(value) = chatgpt_web_auth_config_string(auth_config, fields) {
object.insert(target.to_string(), json!(value));
}
}
}
pub fn normalize_chatgpt_web_image_quota_limit(
metadata: &mut Value,
upstream_metadata: Option<&Value>,
) {
let existing_limit = existing_chatgpt_web_image_quota_limit(upstream_metadata);
let Some(object) = metadata.as_object_mut() else {
return;
};
let remaining = provider_pool_json_f64(object.get("image_quota_remaining"));
let explicit_limit =
provider_pool_json_f64(object.get("image_quota_total")).filter(|value| *value > 0.0);
let plan_type = chatgpt_web_json_string(object.get("plan_type"));
let is_free_plan = plan_type.is_some_and(|value| value.trim().eq_ignore_ascii_case("free"));
let limit = if is_free_plan {
Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT)
} else {
explicit_limit
.or_else(|| infer_chatgpt_web_image_quota_limit(plan_type, remaining, existing_limit))
};
if let Some(limit) = limit {
object.insert("image_quota_total".to_string(), json!(limit));
if !object.contains_key("image_quota_used") {
if let Some(remaining) = remaining {
object.insert(
"image_quota_used".to_string(),
json!((limit - remaining).max(0.0)),
);
} else if object.get("image_quota_blocked").and_then(Value::as_bool) == Some(true) {
object.insert("image_quota_used".to_string(), json!(limit));
}
}
}
}
fn chatgpt_web_auth_config_string(auth_config: Option<&Value>, fields: &[&str]) -> Option<String> {
let object = auth_config.and_then(Value::as_object)?;
fields.iter().find_map(|field| {
object
.get(*field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn chatgpt_web_json_string(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn existing_chatgpt_web_image_quota_limit(upstream_metadata: Option<&Value>) -> Option<f64> {
upstream_metadata
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.and_then(Value::as_object)
.and_then(|bucket| provider_pool_json_f64(bucket.get("image_quota_total")))
.filter(|value| *value > 0.0)
}
fn infer_chatgpt_web_image_quota_limit(
plan_type: Option<&str>,
remaining: Option<f64>,
existing_limit: Option<f64>,
) -> Option<f64> {
let normalized_plan = plan_type.unwrap_or_default().trim().to_ascii_lowercase();
if normalized_plan == "free" {
return Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT);
}
if let Some(existing_limit) = existing_limit.filter(|value| *value > 0.0) {
return Some(existing_limit);
}
remaining.filter(|value| *value > 0.0)
}
pub(crate) fn quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
if provider_pool_json_bool(bucket.get("image_quota_blocked")) == Some(true) {
return true;
}
if provider_pool_json_f64(bucket.get("image_quota_remaining")).is_some_and(|value| value <= 0.0)
{
return true;
}
match (
provider_pool_json_f64(bucket.get("image_quota_total")),
provider_pool_json_f64(bucket.get("image_quota_used")),
) {
(Some(limit), Some(used)) if limit > 0.0 => used >= limit,
_ => false,
}
}

View File

@@ -0,0 +1,136 @@
use std::collections::BTreeMap;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
use aether_pool_core::PoolSchedulingPreset;
use serde_json::{Map, Value};
use crate::capability::ProviderPoolCapabilities;
use crate::provider::{
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
ProviderPoolMemberInput,
};
use crate::quota::{
provider_pool_json_bool, provider_pool_json_f64, provider_pool_metadata_bucket,
provider_pool_quota_snapshot_exhausted_decision,
};
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
#[derive(Debug, Clone, Default)]
pub struct CodexProviderPoolAdapter;
impl ProviderPoolAdapter for CodexProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"codex"
}
fn capabilities(&self) -> ProviderPoolCapabilities {
ProviderPoolCapabilities {
plan_tier: true,
quota_reset: true,
quota_refresh: true,
}
}
fn default_scheduling_presets(&self) -> Vec<PoolSchedulingPreset> {
vec![PoolSchedulingPreset {
preset: "recent_refresh".to_string(),
enabled: true,
mode: None,
}]
}
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
if let Some(exhausted) =
provider_pool_quota_snapshot_exhausted_decision(input.key, input.provider_type)
{
return exhausted;
}
provider_pool_metadata_bucket(input.key.upstream_metadata.as_ref(), input.provider_type)
.is_some_and(quota_exhausted_from_bucket)
}
fn quota_refresh_endpoint(
&self,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| {
provider_pool_endpoint_format_matches(endpoint, "openai:responses")
})
}
fn quota_refresh_missing_endpoint_message(&self) -> String {
"找不到有效的 openai:responses 端点".to_string()
}
}
pub fn build_codex_pool_quota_request(
key_id: &str,
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let mut headers = BTreeMap::new();
headers.insert("accept".to_string(), "application/json".to_string());
if let Some((name, value)) = resolved_oauth_auth {
headers.insert(name.to_ascii_lowercase(), value);
} else {
let decrypted_key = decrypted_api_key.unwrap_or_default().trim();
if decrypted_key.is_empty() || decrypted_key == PLACEHOLDER_API_KEY {
return Err("缺少 OAuth 认证信息,请先授权/刷新 Token".to_string());
}
headers.insert(
"authorization".to_string(),
format!("Bearer {decrypted_key}"),
);
}
let oauth_plan_type = auth_config
.and_then(|value| value.get("plan_type"))
.and_then(Value::as_str)
.and_then(|value| crate::plan::normalize_provider_plan_tier(value, "codex"));
let oauth_account_id = auth_config
.and_then(|value| value.get("account_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if oauth_account_id.is_some() && oauth_plan_type.as_deref() != Some("free") {
headers.insert(
"chatgpt-account-id".to_string(),
oauth_account_id.unwrap_or_default().to_string(),
);
}
Ok(ProviderPoolQuotaRequestSpec {
request_id: format!("codex-quota:{key_id}"),
provider_name: "codex".to_string(),
quota_kind: "codex".to_string(),
method: "GET".to_string(),
url: CODEX_WHAM_USAGE_URL.to_string(),
headers,
content_type: None,
json_body: None,
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("codex-wham-usage".to_string()),
accept_invalid_certs: false,
})
}
pub(crate) fn quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
if provider_pool_json_bool(bucket.get("credits_unlimited")) == Some(true) {
return false;
}
let has_window_data = provider_pool_json_f64(bucket.get("primary_used_percent")).is_some()
|| provider_pool_json_f64(bucket.get("secondary_used_percent")).is_some();
if !has_window_data && provider_pool_json_bool(bucket.get("has_credits")) == Some(false) {
return true;
}
provider_pool_json_f64(bucket.get("primary_used_percent")).is_some_and(|value| value >= 100.0)
|| provider_pool_json_f64(bucket.get("secondary_used_percent"))
.is_some_and(|value| value >= 100.0)
}

View File

@@ -0,0 +1,10 @@
use crate::provider::ProviderPoolAdapter;
#[derive(Debug, Clone, Default)]
pub struct DefaultProviderPoolAdapter;
impl ProviderPoolAdapter for DefaultProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"default"
}
}

View File

@@ -0,0 +1,167 @@
use std::collections::BTreeMap;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
use serde_json::{Map, Value};
use url::form_urlencoded;
use uuid::Uuid;
use crate::capability::ProviderPoolCapabilities;
use crate::provider::{
provider_pool_endpoint_format_matches, provider_pool_matching_endpoint, ProviderPoolAdapter,
ProviderPoolMemberInput,
};
use crate::quota::{
provider_pool_json_f64, provider_pool_metadata_bucket,
provider_pool_quota_snapshot_exhausted_decision,
};
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const KIRO_USAGE_LIMITS_PATH: &str = "/getUsageLimits";
pub const KIRO_USAGE_SDK_VERSION: &str = "1.0.0";
#[derive(Debug, Clone, PartialEq)]
pub struct KiroPoolQuotaAuthInput {
pub authorization_value: String,
pub api_region: String,
pub kiro_version: String,
pub machine_id: String,
pub profile_arn: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct KiroProviderPoolAdapter;
impl ProviderPoolAdapter for KiroProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"kiro"
}
fn capabilities(&self) -> ProviderPoolCapabilities {
ProviderPoolCapabilities {
plan_tier: true,
quota_reset: true,
quota_refresh: true,
}
}
fn quota_exhausted(&self, input: &ProviderPoolMemberInput<'_>) -> bool {
if let Some(exhausted) =
provider_pool_quota_snapshot_exhausted_decision(input.key, input.provider_type)
{
return exhausted;
}
provider_pool_metadata_bucket(input.key.upstream_metadata.as_ref(), input.provider_type)
.is_some_and(quota_exhausted_from_bucket)
}
fn quota_refresh_endpoint(
&self,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
provider_pool_matching_endpoint(endpoints, include_inactive, |endpoint| {
provider_pool_endpoint_format_matches(endpoint, "claude:messages")
})
.or_else(|| provider_pool_matching_endpoint(endpoints, include_inactive, |_| true))
}
fn quota_refresh_missing_endpoint_message(&self) -> String {
"找不到有效的 Kiro 端点".to_string()
}
}
pub fn build_kiro_pool_quota_request(
key_id: &str,
auth: &KiroPoolQuotaAuthInput,
) -> ProviderPoolQuotaRequestSpec {
let host = format!("q.{}.amazonaws.com", normalize_region(&auth.api_region));
let machine_id = auth.machine_id.trim();
let ide_tag = if machine_id.is_empty() {
format!("KiroIDE-{}", normalize_kiro_version(&auth.kiro_version))
} else {
format!(
"KiroIDE-{}-{machine_id}",
normalize_kiro_version(&auth.kiro_version)
)
};
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.append_pair("origin", "AI_EDITOR");
serializer.append_pair("resourceType", "AGENTIC_REQUEST");
serializer.append_pair("isEmailRequired", "true");
if let Some(profile_arn) = auth
.profile_arn
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
serializer.append_pair("profileArn", profile_arn);
}
ProviderPoolQuotaRequestSpec {
request_id: format!("kiro-quota:{key_id}"),
provider_name: "kiro".to_string(),
quota_kind: "kiro".to_string(),
method: "GET".to_string(),
url: format!("https://{host}{KIRO_USAGE_LIMITS_PATH}?{}", serializer.finish()),
headers: BTreeMap::from([
(
"x-amz-user-agent".to_string(),
format!("aws-sdk-js/{KIRO_USAGE_SDK_VERSION} {ide_tag}"),
),
(
"user-agent".to_string(),
format!(
"aws-sdk-js/{KIRO_USAGE_SDK_VERSION} ua/2.1 os/other#unknown lang/js md/nodejs#22.21.1 api/codewhispererruntime#1.0.0 m/N,E {ide_tag}"
),
),
("host".to_string(), host),
("amz-sdk-invocation-id".to_string(), Uuid::new_v4().to_string()),
("amz-sdk-request".to_string(), "attempt=1; max=1".to_string()),
(
"authorization".to_string(),
auth.authorization_value.clone(),
),
("connection".to_string(), "close".to_string()),
]),
content_type: None,
json_body: None,
client_api_format: "claude:messages".to_string(),
provider_api_format: "kiro:usage".to_string(),
model_name: Some("kiro-usage-limits".to_string()),
accept_invalid_certs: false,
}
}
fn normalize_region(value: &str) -> &str {
let value = value.trim();
if value.is_empty() {
"us-east-1"
} else {
value
}
}
fn normalize_kiro_version(value: &str) -> &str {
let value = value.trim();
if value.is_empty() {
"0.3.210"
} else {
value
}
}
pub(crate) fn quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
if provider_pool_json_f64(bucket.get("remaining")).is_some_and(|value| value <= 0.0) {
return true;
}
if provider_pool_json_f64(bucket.get("usage_percentage")).is_some_and(|value| value >= 100.0) {
return true;
}
match (
provider_pool_json_f64(bucket.get("usage_limit")),
provider_pool_json_f64(bucket.get("current_usage")),
) {
(Some(limit), Some(current)) if limit > 0.0 => current >= limit,
_ => false,
}
}

View File

@@ -0,0 +1,29 @@
pub mod antigravity;
pub mod chatgpt_web;
pub mod codex;
pub mod default;
pub mod kiro;
pub mod unsupported;
pub use antigravity::AntigravityProviderPoolAdapter;
pub use antigravity::{
build_antigravity_pool_quota_request, ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH,
};
pub use chatgpt_web::ChatGptWebProviderPoolAdapter;
pub use chatgpt_web::{
build_chatgpt_web_pool_quota_request, enrich_chatgpt_web_quota_metadata,
normalize_chatgpt_web_image_quota_limit, CHATGPT_WEB_CONVERSATION_INIT_PATH,
CHATGPT_WEB_DEFAULT_BASE_URL,
};
pub use codex::CodexProviderPoolAdapter;
pub use codex::{build_codex_pool_quota_request, CODEX_WHAM_USAGE_URL};
pub use default::DefaultProviderPoolAdapter;
pub use kiro::KiroProviderPoolAdapter;
pub use kiro::{
build_kiro_pool_quota_request, KiroPoolQuotaAuthInput, KIRO_USAGE_LIMITS_PATH,
KIRO_USAGE_SDK_VERSION,
};
pub use unsupported::{
UnsupportedQuotaProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
GEMINI_CLI_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
};

View File

@@ -0,0 +1,47 @@
use crate::provider::ProviderPoolAdapter;
#[derive(Debug, Clone, Copy)]
pub struct UnsupportedQuotaProviderPoolAdapter {
provider_type: &'static str,
quota_refresh_unsupported_message: &'static str,
}
impl UnsupportedQuotaProviderPoolAdapter {
pub const fn new(
provider_type: &'static str,
quota_refresh_unsupported_message: &'static str,
) -> Self {
Self {
provider_type,
quota_refresh_unsupported_message,
}
}
}
impl ProviderPoolAdapter for UnsupportedQuotaProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
self.provider_type
}
fn quota_refresh_unsupported_message(&self) -> String {
self.quota_refresh_unsupported_message.to_string()
}
}
pub const CLAUDE_CODE_PROVIDER_POOL_ADAPTER: UnsupportedQuotaProviderPoolAdapter =
UnsupportedQuotaProviderPoolAdapter::new(
"claude_code",
"Claude Code 暂不支持自动刷新额度:上游没有稳定可用的账号额度查询接口",
);
pub const GEMINI_CLI_PROVIDER_POOL_ADAPTER: UnsupportedQuotaProviderPoolAdapter =
UnsupportedQuotaProviderPoolAdapter::new(
"gemini_cli",
"Gemini CLI 暂不支持自动刷新额度:当前只能通过模型同步/缓存快照展示已知配额信息",
);
pub const VERTEX_AI_PROVIDER_POOL_ADAPTER: UnsupportedQuotaProviderPoolAdapter =
UnsupportedQuotaProviderPoolAdapter::new(
"vertex_ai",
"Vertex AI 暂不支持自动刷新额度:额度属于 Google Cloud 项目/区域配额",
);

View File

@@ -0,0 +1,263 @@
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::{json, Map, Value};
use crate::provider::ProviderPoolMemberInput;
use crate::service::ProviderPoolService;
pub fn provider_pool_key_account_quota_exhausted(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> bool {
let adapter = ProviderPoolService::with_builtin_adapters().adapter(provider_type);
adapter.quota_exhausted(&ProviderPoolMemberInput {
provider_type,
key,
auth_config: None,
})
}
pub fn provider_pool_member_quota_snapshot<'a>(
key: &'a StoredProviderCatalogKey,
provider_type: &str,
) -> Option<&'a Map<String, Value>> {
let quota_snapshot = key
.status_snapshot
.as_ref()
.and_then(Value::as_object)
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object)?;
provider_pool_quota_snapshot_matches_provider(quota_snapshot, provider_type)
.then_some(quota_snapshot)
}
pub fn provider_pool_quota_snapshot_updated_at(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> Option<u64> {
let quota_snapshot = provider_pool_member_quota_snapshot(key, provider_type)?;
provider_pool_timestamp_unix_secs(quota_snapshot.get("updated_at"))
}
pub fn provider_pool_quota_metadata_updated_at(
upstream_metadata: Option<&Value>,
provider_type: &str,
) -> Option<u64> {
let bucket = provider_pool_metadata_bucket(upstream_metadata, provider_type)?;
provider_pool_timestamp_unix_secs(bucket.get("updated_at"))
}
pub fn provider_pool_quota_metadata_provider_type(metadata_update: &Value) -> Option<String> {
let object = metadata_update.as_object()?;
let service = ProviderPoolService::with_builtin_adapters();
let known_provider_type = service
.provider_types()
.find(|provider_type| object.contains_key(*provider_type))
.map(ToOwned::to_owned);
known_provider_type.or_else(|| {
object
.iter()
.find(|(_, value)| value.is_object())
.map(|(provider_type, _)| provider_type.clone())
})
}
pub fn provider_pool_key_scheduling_label(
is_active: bool,
cooldown_reason: Option<&str>,
cooldown_ttl_seconds: Option<u64>,
) -> (String, String, String, Vec<Value>) {
if !is_active {
return (
"blocked".to_string(),
"inactive".to_string(),
"已禁用".to_string(),
vec![json!({
"code": "inactive",
"label": "已禁用",
"blocking": true,
"source": "manual",
"ttl_seconds": Value::Null,
"detail": Value::Null,
})],
);
}
if let Some(reason) = cooldown_reason {
return (
"degraded".to_string(),
"cooldown".to_string(),
"冷却中".to_string(),
vec![json!({
"code": "cooldown",
"label": "冷却中",
"blocking": true,
"source": "pool",
"ttl_seconds": cooldown_ttl_seconds,
"detail": reason,
})],
);
}
(
"available".to_string(),
"available".to_string(),
"可用".to_string(),
Vec::new(),
)
}
pub(crate) fn provider_pool_metadata_bucket<'a>(
upstream_metadata: Option<&'a Value>,
provider_type: &str,
) -> Option<&'a Map<String, Value>> {
upstream_metadata
.and_then(Value::as_object)
.and_then(|metadata| metadata.get(&provider_type.trim().to_ascii_lowercase()))
.and_then(Value::as_object)
}
pub(crate) fn provider_pool_json_bool(value: Option<&Value>) -> Option<bool> {
match value {
Some(Value::Bool(value)) => Some(*value),
Some(Value::String(value)) => match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
},
_ => None,
}
}
pub(crate) fn provider_pool_json_f64(value: Option<&Value>) -> Option<f64> {
match value {
Some(Value::Number(number)) => number.as_f64(),
Some(Value::String(value)) => value.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|value| value.is_finite())
}
fn provider_pool_timestamp_unix_secs(value: Option<&Value>) -> Option<u64> {
let mut timestamp = provider_pool_json_f64(value)?;
if timestamp <= 0.0 {
return None;
}
if timestamp > 1_000_000_000_000.0 {
timestamp /= 1000.0;
}
Some(timestamp as u64)
}
fn provider_pool_quota_snapshot_matches_provider(
quota_snapshot: &Map<String, Value>,
provider_type: &str,
) -> bool {
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
match quota_snapshot
.get("provider_type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(provider_type) => provider_type.eq_ignore_ascii_case(&normalized_provider_type),
None => {
provider_pool_json_bool(quota_snapshot.get("exhausted")) == Some(true)
|| quota_snapshot
.get("code")
.and_then(Value::as_str)
.is_some_and(|code| !code.trim().eq_ignore_ascii_case("unknown"))
|| quota_snapshot
.get("updated_at")
.is_some_and(|value| !value.is_null())
|| quota_snapshot
.get("observed_at")
.is_some_and(|value| !value.is_null())
|| quota_snapshot
.get("usage_ratio")
.is_some_and(|value| !value.is_null())
|| quota_snapshot
.get("reset_seconds")
.is_some_and(|value| !value.is_null())
|| quota_snapshot
.get("windows")
.and_then(Value::as_array)
.is_some_and(|windows| !windows.is_empty())
|| quota_snapshot
.get("credits")
.and_then(Value::as_object)
.is_some_and(|credits| !credits.is_empty())
}
}
}
pub(crate) fn provider_pool_quota_snapshot_exhausted_decision(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> Option<bool> {
let quota_snapshot = key
.status_snapshot
.as_ref()
.and_then(Value::as_object)
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object)?;
if !provider_pool_quota_snapshot_matches_provider(quota_snapshot, provider_type) {
return None;
}
let exhausted = provider_pool_json_bool(quota_snapshot.get("exhausted"))?;
if exhausted {
let windows_max_ratio = quota_snapshot
.get("windows")
.and_then(Value::as_array)
.filter(|w| !w.is_empty())
.and_then(|windows| {
windows
.iter()
.filter_map(Value::as_object)
.filter_map(|w| w.get("used_ratio"))
.filter_map(Value::as_f64)
.max_by(f64::total_cmp)
});
if windows_max_ratio.is_some_and(|ratio| ratio < 1.0 - 1e-6) {
return Some(false);
}
}
Some(exhausted)
}
pub(crate) fn provider_pool_quota_usage_ratio(key: &StoredProviderCatalogKey) -> Option<f64> {
key.status_snapshot
.as_ref()
.and_then(Value::as_object)
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object)
.and_then(|quota| provider_pool_json_f64(quota.get("usage_ratio")))
}
pub(crate) fn provider_pool_quota_reset_seconds(key: &StoredProviderCatalogKey) -> Option<f64> {
key.status_snapshot
.as_ref()
.and_then(Value::as_object)
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object)
.and_then(|quota| provider_pool_json_f64(quota.get("reset_seconds")))
}
pub(crate) fn provider_pool_account_blocked(key: &StoredProviderCatalogKey) -> bool {
key.oauth_invalid_reason.as_deref().is_some_and(|reason| {
let normalized = reason.trim().to_ascii_lowercase();
!normalized.is_empty()
&& [
"banned",
"forbidden",
"blocked",
"suspend",
"deactivated",
"disabled",
"verification",
"workspace",
"受限",
"",
"",
]
.iter()
.any(|hint| normalized.contains(hint))
})
}

View File

@@ -0,0 +1,19 @@
use std::collections::BTreeMap;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderPoolQuotaRequestSpec {
pub request_id: String,
pub provider_name: String,
pub quota_kind: String,
pub method: String,
pub url: String,
pub headers: BTreeMap<String, String>,
pub content_type: Option<String>,
pub json_body: Option<Value>,
pub client_api_format: String,
pub provider_api_format: String,
pub model_name: Option<String>,
pub accept_invalid_certs: bool,
}

View File

@@ -0,0 +1,132 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use aether_pool_core::PoolSchedulingPreset;
use serde_json::{Map, Value};
use crate::capability::ProviderPoolCapability;
use crate::presets::normalize_provider_scheduling_presets;
use crate::provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
use crate::providers::{
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, KiroProviderPoolAdapter, CLAUDE_CODE_PROVIDER_POOL_ADAPTER,
GEMINI_CLI_PROVIDER_POOL_ADAPTER, VERTEX_AI_PROVIDER_POOL_ADAPTER,
};
#[derive(Clone)]
pub struct ProviderPoolService {
adapters: BTreeMap<String, Arc<dyn ProviderPoolAdapter>>,
default_adapter: Arc<dyn ProviderPoolAdapter>,
}
impl std::fmt::Debug for ProviderPoolService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderPoolService")
.field("provider_types", &self.adapters.keys().collect::<Vec<_>>())
.finish()
}
}
impl Default for ProviderPoolService {
fn default() -> Self {
Self {
adapters: BTreeMap::new(),
default_adapter: Arc::new(DefaultProviderPoolAdapter),
}
}
}
impl ProviderPoolService {
pub fn new() -> Self {
Self::default()
}
pub fn with_builtin_adapters() -> Self {
Self::new()
.with_adapter(Arc::new(AntigravityProviderPoolAdapter))
.with_adapter(Arc::new(CLAUDE_CODE_PROVIDER_POOL_ADAPTER))
.with_adapter(Arc::new(CodexProviderPoolAdapter))
.with_adapter(Arc::new(GEMINI_CLI_PROVIDER_POOL_ADAPTER))
.with_adapter(Arc::new(KiroProviderPoolAdapter))
.with_adapter(Arc::new(ChatGptWebProviderPoolAdapter))
.with_adapter(Arc::new(VERTEX_AI_PROVIDER_POOL_ADAPTER))
}
pub fn with_adapter(mut self, adapter: Arc<dyn ProviderPoolAdapter>) -> Self {
self.adapters
.insert(adapter.provider_type().trim().to_ascii_lowercase(), adapter);
self
}
pub fn adapter(&self, provider_type: &str) -> Arc<dyn ProviderPoolAdapter> {
self.adapters
.get(provider_type.trim().to_ascii_lowercase().as_str())
.cloned()
.unwrap_or_else(|| self.default_adapter.clone())
}
pub fn provider_types(&self) -> impl Iterator<Item = &str> {
self.adapters.keys().map(String::as_str)
}
pub fn provider_types_for_capability(&self, capability: ProviderPoolCapability) -> Vec<String> {
self.adapters
.iter()
.filter(|(_, adapter)| adapter.capabilities().supports(capability))
.map(|(provider_type, _)| provider_type.clone())
.collect()
}
pub fn supports_quota_refresh(&self, provider_type: &str) -> bool {
self.adapter(provider_type).supports_quota_refresh()
}
pub fn quota_refresh_endpoint_for_provider(
&self,
provider_type: &str,
endpoints: &[StoredProviderCatalogEndpoint],
include_inactive: bool,
) -> Option<StoredProviderCatalogEndpoint> {
self.adapter(provider_type)
.quota_refresh_endpoint(endpoints, include_inactive)
}
pub fn quota_refresh_unsupported_message(&self, provider_type: &str) -> String {
self.adapter(provider_type)
.quota_refresh_unsupported_message()
}
pub fn quota_refresh_missing_endpoint_message(&self, provider_type: &str) -> String {
self.adapter(provider_type)
.quota_refresh_missing_endpoint_message()
}
pub fn normalize_scheduling_presets(
&self,
provider_type: &str,
scheduling_presets: &[PoolSchedulingPreset],
) -> Vec<PoolSchedulingPreset> {
normalize_provider_scheduling_presets(
self.adapter(provider_type).as_ref(),
scheduling_presets,
)
}
pub fn member_signals(
&self,
provider_type: &str,
key: &StoredProviderCatalogKey,
auth_config: Option<&Map<String, Value>>,
) -> aether_pool_core::PoolMemberSignals {
let adapter = self.adapter(provider_type);
let input = ProviderPoolMemberInput {
provider_type,
key,
auth_config,
};
adapter.member_signals(&input)
}
}

View File

@@ -1,333 +1,7 @@
use serde_json::Value; pub use aether_oauth::provider::providers::{
use sha2::{Digest, Sha256}; generate_kiro_machine_id as generate_machine_id,
use std::time::{SystemTime, UNIX_EPOCH}; normalize_kiro_machine_id as normalize_machine_id, KiroAuthConfig, DEFAULT_REGION,
};
pub const DEFAULT_REGION: &str = "us-east-1";
pub const DEFAULT_KIRO_VERSION: &str = "0.3.210";
pub const DEFAULT_NODE_VERSION: &str = "22.21.1";
pub const DEFAULT_SYSTEM_VERSION: &str = "other#unknown";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KiroAuthConfig {
pub auth_method: Option<String>,
pub refresh_token: Option<String>,
pub expires_at: Option<u64>,
pub profile_arn: Option<String>,
pub region: Option<String>,
pub auth_region: Option<String>,
pub api_region: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub machine_id: Option<String>,
pub kiro_version: Option<String>,
pub system_version: Option<String>,
pub node_version: Option<String>,
pub access_token: Option<String>,
}
impl KiroAuthConfig {
pub fn from_raw_json(raw: Option<&str>) -> Option<Self> {
let raw = raw?.trim();
if raw.is_empty() {
return None;
}
let parsed: Value = serde_json::from_str(raw).ok()?;
Self::from_json_value(&parsed)
}
pub fn from_json_value(raw: &Value) -> Option<Self> {
let object = raw.as_object()?;
Some(Self {
auth_method: get_nonempty_string(
object,
&["auth_method", "authMethod", "auth_type", "authType"],
)
.map(|value| normalize_auth_method(&value)),
refresh_token: get_nonempty_string(object, &["refresh_token", "refreshToken"]),
expires_at: get_epoch_seconds(object.get("expires_at"))
.or_else(|| get_epoch_seconds(object.get("expiresAt"))),
profile_arn: get_nonempty_string(object, &["profile_arn", "profileArn"]),
region: get_nonempty_string(object, &["region"]),
auth_region: get_nonempty_string(object, &["auth_region", "authRegion"]),
api_region: get_nonempty_string(object, &["api_region", "apiRegion"]),
client_id: get_nonempty_string(object, &["client_id", "clientId"]),
client_secret: get_nonempty_string(object, &["client_secret", "clientSecret"]),
machine_id: get_nonempty_string(object, &["machine_id", "machineId"]),
kiro_version: get_nonempty_string(object, &["kiro_version", "kiroVersion"]),
system_version: get_nonempty_string(object, &["system_version", "systemVersion"]),
node_version: get_nonempty_string(object, &["node_version", "nodeVersion"]),
access_token: get_nonempty_string(object, &["access_token", "accessToken"]),
})
}
pub fn to_json_value(&self) -> Value {
let mut object = serde_json::Map::new();
insert_optional_string(&mut object, "auth_method", self.auth_method.as_deref());
insert_optional_string(&mut object, "refresh_token", self.refresh_token.as_deref());
if let Some(expires_at) = self.expires_at {
object.insert("expires_at".to_string(), Value::from(expires_at));
}
insert_optional_string(&mut object, "profile_arn", self.profile_arn.as_deref());
insert_optional_string(&mut object, "region", self.region.as_deref());
insert_optional_string(&mut object, "auth_region", self.auth_region.as_deref());
insert_optional_string(&mut object, "api_region", self.api_region.as_deref());
insert_optional_string(&mut object, "client_id", self.client_id.as_deref());
insert_optional_string(&mut object, "client_secret", self.client_secret.as_deref());
insert_optional_string(&mut object, "machine_id", self.machine_id.as_deref());
insert_optional_string(&mut object, "kiro_version", self.kiro_version.as_deref());
insert_optional_string(
&mut object,
"system_version",
self.system_version.as_deref(),
);
insert_optional_string(&mut object, "node_version", self.node_version.as_deref());
insert_optional_string(&mut object, "access_token", self.access_token.as_deref());
Value::Object(object)
}
pub fn effective_api_region(&self) -> &str {
self.api_region
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_REGION)
}
pub fn effective_auth_region(&self) -> &str {
self.auth_region
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
self.region
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
})
.unwrap_or(DEFAULT_REGION)
}
pub fn effective_kiro_version(&self) -> &str {
self.kiro_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_KIRO_VERSION)
}
pub fn effective_system_version(&self) -> &str {
self.system_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_SYSTEM_VERSION)
}
pub fn effective_node_version(&self) -> &str {
self.node_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_NODE_VERSION)
}
pub fn cached_access_token(&self) -> Option<&str> {
self.access_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub fn cached_access_token_requires_refresh(&self, skew_seconds: u64) -> bool {
let Some(expires_at) = self.expires_at else {
return self.can_refresh_access_token();
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|value| value.as_secs())
.unwrap_or_default();
now >= expires_at.saturating_sub(skew_seconds)
}
pub fn is_idc_auth(&self) -> bool {
let explicit_method = self
.auth_method
.as_deref()
.map(normalize_auth_method)
.unwrap_or_else(|| "social".to_string());
if explicit_method != "social" {
return matches!(explicit_method.as_str(), "idc" | "external_idp");
}
self.client_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
&& self
.client_secret
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
}
pub fn uses_external_idp_token_type(&self) -> bool {
self.auth_method
.as_deref()
.map(normalize_auth_method)
.as_deref()
== Some("external_idp")
}
pub fn profile_arn_for_payload(&self) -> Option<&str> {
if self.is_idc_auth() {
return None;
}
self.profile_arn
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub fn profile_arn_for_mcp(&self) -> Option<&str> {
self.profile_arn
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub fn can_refresh_access_token(&self) -> bool {
let refresh_token = self
.refresh_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|value| value.len() >= 100 && !value.contains("..."));
if refresh_token.is_none() {
return false;
}
if !self.is_idc_auth() {
return true;
}
self.client_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
&& self
.client_secret
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
}
}
pub fn normalize_machine_id(raw: &str) -> Option<String> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if raw.len() == 64 && raw.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Some(raw.to_ascii_lowercase());
}
if raw.len() == 36
&& raw.chars().enumerate().all(|(idx, ch)| match idx {
8 | 13 | 18 | 23 => ch == '-',
_ => ch.is_ascii_hexdigit(),
})
{
let normalized = raw.replace('-', "").to_ascii_lowercase();
return Some(format!("{normalized}{normalized}"));
}
None
}
pub fn generate_machine_id(
auth_config: &KiroAuthConfig,
fallback_secret: Option<&str>,
) -> Option<String> {
if let Some(machine_id) = auth_config
.machine_id
.as_deref()
.and_then(normalize_machine_id)
{
return Some(machine_id);
}
let seed = auth_config
.refresh_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
fallback_secret
.map(str::trim)
.filter(|value| !value.is_empty())
})?;
let mut hasher = Sha256::new();
hasher.update(b"KotlinNativeAPI/");
hasher.update(seed.as_bytes());
Some(format!("{:x}", hasher.finalize()))
}
fn get_nonempty_string(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| object.get(*key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn insert_optional_string(
object: &mut serde_json::Map<String, Value>,
key: &str,
value: Option<&str>,
) {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return;
};
object.insert(key.to_string(), Value::String(value.to_string()));
}
fn get_epoch_seconds(value: Option<&Value>) -> Option<u64> {
match value? {
Value::Number(number) => number.as_u64().or_else(|| {
number
.as_i64()
.and_then(|value| (value >= 0).then_some(value as u64))
}),
Value::String(text) => text.trim().parse::<u64>().ok(),
_ => None,
}
}
fn normalize_auth_method(raw: &str) -> String {
let value = raw.trim().to_ascii_lowercase();
match value.as_str() {
"" => "social".to_string(),
"builder-id"
| "builder_id"
| "builderid"
| "device"
| "device-auth"
| "device_authorization"
| "iam"
| "identity-center"
| "identity_center"
| "identitycenter"
| "idc" => "idc".to_string(),
"external-idp" | "external_idp" | "externalidp" => "external_idp".to_string(),
_ => value,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@@ -1,7 +1,4 @@
use std::collections::BTreeMap;
use aether_oauth::provider::providers::KiroProviderOAuthAdapter as CoreKiroProviderOAuthAdapter; use aether_oauth::provider::providers::KiroProviderOAuthAdapter as CoreKiroProviderOAuthAdapter;
use aether_oauth::provider::{ProviderOAuthAccount, ProviderOAuthAdapter};
use async_trait::async_trait; use async_trait::async_trait;
use super::super::oauth_refresh::{ use super::super::oauth_refresh::{
@@ -48,26 +45,10 @@ impl KiroOAuthRefreshAdapter {
let oauth_executor = let oauth_executor =
ProviderOAuthLocalHttpExecutor::new(PROVIDER_TYPE, transport, executor); ProviderOAuthLocalHttpExecutor::new(PROVIDER_TYPE, transport, executor);
let ctx = provider_oauth_transport_context_from_snapshot(transport); let ctx = provider_oauth_transport_context_from_snapshot(transport);
let account = ProviderOAuthAccount { adapter
provider_type: PROVIDER_TYPE.to_string(), .refresh_auth_config(&oauth_executor, &ctx, auth_config)
access_token: auth_config
.cached_access_token()
.map(ToOwned::to_owned)
.unwrap_or_default(),
auth_config: auth_config.to_json_value(),
expires_at_unix_secs: auth_config.expires_at,
identity: BTreeMap::new(),
};
let refreshed = adapter
.refresh(&oauth_executor, &ctx, &account)
.await .await
.map_err(|error| oauth_error_to_local_refresh_error(PROVIDER_TYPE, error))?; .map_err(|error| oauth_error_to_local_refresh_error(PROVIDER_TYPE, error))
KiroAuthConfig::from_json_value(&refreshed.auth_config).ok_or_else(|| {
LocalOAuthRefreshError::InvalidResponse {
provider_type: PROVIDER_TYPE,
message: "kiro refresh returned invalid auth_config".to_string(),
}
})
} }
fn auth_config_from_entry(entry: &CachedOAuthEntry) -> Option<KiroAuthConfig> { fn auth_config_from_entry(entry: &CachedOAuthEntry) -> Option<KiroAuthConfig> {