refactor(workspace): enforce layered crate boundaries

This commit is contained in:
elky
2026-07-15 23:47:19 +08:00
parent a728c090a9
commit 8616fe6ee2
969 changed files with 40187 additions and 27240 deletions
+14
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
@@ -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,
}
}
}
+854
View File
@@ -0,0 +1,854 @@
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_codex_pool_reset_credit_consume_request,
build_codex_pool_reset_credits_request, build_gemini_cli_pool_quota_request,
build_kiro_pool_quota_request, build_windsurf_pool_model_configs_request,
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
build_windsurf_pool_rate_limit_request_with_base_url, enrich_chatgpt_web_quota_metadata,
grok_mode_id_for_model, grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
grok_supported_quota_windows_for_tier, normalize_chatgpt_web_image_quota_limit,
AntigravityProviderPoolAdapter, ChatGptWebProviderPoolAdapter, CodexProviderPoolAdapter,
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
KiroPoolQuotaAuthInput, KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter,
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, CHATGPT_WEB_CONVERSATION_INIT_PATH,
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_RESET_CREDITS_CONSUME_URL,
CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH,
GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH, WINDSURF_USER_STATUS_PATH,
};
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",
"grok",
"kiro",
"vertex_ai",
"windsurf"
]
);
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",
"gemini_cli",
"grok",
"kiro",
"windsurf"
]
);
assert!(service.supports_quota_refresh("codex"));
assert!(service.supports_quota_refresh("antigravity"));
assert!(service.supports_quota_refresh("grok"));
assert!(service.supports_quota_refresh("gemini_cli"));
assert!(service.supports_quota_refresh("windsurf"));
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_prefers_imported_authorization_header() {
let spec = build_codex_pool_quota_request(
"key-1",
Some((
"authorization".to_string(),
"Bearer jwt-access-token".to_string(),
)),
None,
Some(&json!({
"headers": {
"authorization": "Bearer imported-session"
}
})),
)
.expect("spec should build");
assert_eq!(
spec.headers.get("authorization").map(String::as_str),
Some("Bearer imported-session")
);
}
#[test]
fn gemini_cli_quota_request_uses_v1internal_retrieve_user_quota() {
let spec = build_gemini_cli_pool_quota_request(
"key-1",
"https://cloudcode-pa.googleapis.com/",
("authorization".to_string(), "Bearer access".to_string()),
"project-1",
);
assert_eq!(spec.method, "POST");
assert_eq!(
spec.url,
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota"
);
assert_eq!(
spec.headers.get("authorization").map(String::as_str),
Some("Bearer access")
);
assert_eq!(
spec.json_body.as_ref().and_then(|body| body.get("project")),
Some(&json!("project-1"))
);
assert_eq!(
spec.headers.get("user-agent").map(String::as_str),
Some(GEMINI_CLI_USER_AGENT)
);
assert!(spec
.json_body
.as_ref()
.is_some_and(|body| body.get("userAgent").is_none()));
assert_eq!(spec.client_api_format, "gemini:generate_content");
assert_eq!(spec.provider_api_format, "gemini_cli:retrieve_user_quota");
}
#[test]
fn codex_quota_request_uses_wham_usage_endpoint() {
let spec = build_codex_pool_quota_request(
"key-1",
Some(("authorization".to_string(), "Bearer access".to_string())),
None,
None,
)
.expect("spec should build");
assert_eq!(spec.method, "GET");
assert_eq!(spec.url, "https://chatgpt.com/backend-api/wham/usage");
assert_eq!(
spec.headers.get("authorization").map(String::as_str),
Some("Bearer access")
);
assert_eq!(
spec.headers.get("accept").map(String::as_str),
Some("application/json")
);
assert_eq!(spec.model_name.as_deref(), Some("codex-wham-usage"));
}
#[test]
fn codex_reset_credits_request_uses_wham_detail_endpoint() {
let spec = build_codex_pool_reset_credits_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.method, "GET");
assert_eq!(spec.url, CODEX_WHAM_RESET_CREDITS_URL);
assert_eq!(
spec.headers.get("authorization").map(String::as_str),
Some("Bearer access")
);
assert_eq!(
spec.headers.get("chatgpt-account-id").map(String::as_str),
Some("acct-1")
);
assert_eq!(spec.model_name.as_deref(), Some("codex-wham-reset-credits"));
}
#[test]
fn codex_reset_credit_consume_request_posts_redeem_request_id() {
let spec = build_codex_pool_reset_credit_consume_request(
"key-1",
Some(("authorization".to_string(), "Bearer access".to_string())),
None,
None,
"8ae6f1c7-7e9e-4f5d-9b8a-000000000000",
)
.expect("spec should build");
assert_eq!(spec.method, "POST");
assert_eq!(spec.url, CODEX_WHAM_RESET_CREDITS_CONSUME_URL);
assert_eq!(spec.content_type.as_deref(), Some("application/json"));
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.get("redeem_request_id")),
Some(&json!("8ae6f1c7-7e9e-4f5d-9b8a-000000000000"))
);
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.as_object())
.map(|body| body.len()),
Some(1)
);
assert_eq!(
spec.model_name.as_deref(),
Some("codex-wham-reset-credit-consume")
);
}
#[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_uses_first_remaining_as_limit() {
let mut metadata = json!({
"image_quota_remaining": 12,
});
enrich_chatgpt_web_quota_metadata(
&mut metadata,
Some(&json!({
"plan": "free",
"email": "[email protected]",
"accountId": "acct-1"
})),
);
normalize_chatgpt_web_image_quota_limit(&mut metadata, None);
assert_eq!(metadata["plan_type"], json!("free"));
assert_eq!(metadata["email"], json!("[email protected]"));
assert_eq!(metadata["account_id"], json!("acct-1"));
assert_eq!(metadata["image_quota_total"], json!(12.0));
assert_eq!(metadata["image_quota_used"], json!(0.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 chatgpt_web_quota_metadata_does_not_preserve_legacy_free_25_limit() {
let mut metadata = json!({
"plan_type": "free",
"image_quota_remaining": 19,
});
normalize_chatgpt_web_image_quota_limit(
&mut metadata,
Some(&json!({
"chatgpt_web": {
"plan_type": "free",
"image_quota_total": 25
}
})),
);
assert_eq!(metadata["image_quota_total"], json!(19.0));
assert_eq!(metadata["image_quota_used"], json!(0.0));
assert_eq!(
metadata["image_quota_limit_source"],
json!("first_remaining")
);
}
#[test]
fn chatgpt_web_quota_metadata_ignores_upstream_free_25_default() {
let mut metadata = json!({
"plan_type": "free",
"image_quota_remaining": 19,
"image_quota_total": 25,
});
normalize_chatgpt_web_image_quota_limit(&mut metadata, None);
assert_eq!(metadata["image_quota_total"], json!(19.0));
assert_eq!(metadata["image_quota_used"], json!(0.0));
assert_eq!(
metadata["image_quota_limit_source"],
json!("first_remaining")
);
}
#[test]
fn chatgpt_web_quota_metadata_preserves_marked_free_first_limit() {
let mut metadata = json!({
"plan_type": "free",
"image_quota_remaining": 18,
});
normalize_chatgpt_web_image_quota_limit(
&mut metadata,
Some(&json!({
"chatgpt_web": {
"plan_type": "free",
"image_quota_total": 19,
"image_quota_limit_source": "first_remaining"
}
})),
);
assert_eq!(metadata["image_quota_total"], json!(19.0));
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(
metadata["image_quota_limit_source"],
json!("first_remaining")
);
}
#[test]
fn windsurf_quota_request_uses_user_status_connect_rpc() {
let spec = build_windsurf_pool_quota_request("key-ws", "session-token-123");
assert_eq!(spec.request_id, "windsurf-quota:key-ws");
assert_eq!(spec.method, "POST");
assert_eq!(
spec.url,
format!("https://server.codeium.com{WINDSURF_USER_STATUS_PATH}")
);
assert_eq!(spec.content_type.as_deref(), Some("application/json"));
assert_eq!(
spec.headers
.get("connect-protocol-version")
.map(String::as_str),
Some("1")
);
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.pointer("/metadata/apiKey"))
.and_then(Value::as_str),
Some("session-token-123")
);
assert_eq!(spec.provider_api_format, "windsurf:user_status");
}
#[test]
fn windsurf_model_and_rate_limit_requests_use_connect_rpc_metadata() {
let models = build_windsurf_pool_model_configs_request("key-ws", "api-key-123");
let rate_limit = build_windsurf_pool_rate_limit_request("key-ws", "api-key-123");
assert_eq!(
models.url,
format!("https://server.codeium.com{WINDSURF_MODEL_CONFIGS_PATH}")
);
assert_eq!(
rate_limit.url,
format!("https://server.codeium.com{WINDSURF_RATE_LIMIT_PATH}")
);
for spec in [models, rate_limit] {
assert_eq!(spec.method, "POST");
assert_eq!(
spec.headers
.get("connect-protocol-version")
.map(String::as_str),
Some("1")
);
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.pointer("/metadata/apiKey"))
.and_then(Value::as_str),
Some("api-key-123")
);
assert_eq!(spec.client_api_format, "openai:chat");
}
}
#[test]
fn windsurf_rate_limit_metadata_keeps_member_schedulable() {
let service = ProviderPoolService::with_builtin_adapters();
let key = sample_key(Some(json!({
"windsurf": {
"updated_at": 1_700_000_000u64,
"rate_limit": {
"limited": true,
"retry_after_ms": 60_000
}
}
})));
let signals = service.member_signals("windsurf", &key, None);
assert!(!signals.quota_exhausted);
}
#[test]
fn windsurf_status_snapshot_ban_marks_member_exhausted() {
let service = ProviderPoolService::with_builtin_adapters();
let mut key = sample_key(Some(json!({
"windsurf": {
"updated_at": 1_700_000_000u64,
"daily_remaining_percent": 100.0
}
})));
key.status_snapshot = Some(json!({
"quota": {
"provider_type": "windsurf",
"code": "banned",
"exhausted": false,
"windows": [{
"code": "daily",
"used_ratio": 0.0,
"remaining_ratio": 1.0
}]
}
}));
let signals = service.member_signals("windsurf", &key, None);
assert!(signals.quota_exhausted);
}
#[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", "grok", "kiro", "windsurf"])
);
assert_eq!(
recent_refresh["providers"],
json!(["codex", "grok", "kiro", "windsurf"])
);
}
#[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!({
"grok": {
"quota_by_model": {
"quota_fast": {
"is_exhausted": true,
"remaining": 0.0
}
}
}
}))),
"grok",
));
assert!(!provider_pool_key_account_quota_exhausted(
&sample_key(Some(json!({
"grok": {
"pool_tier": "basic",
"quota_by_model": {
"quota_fast": {
"is_exhausted": false,
"remaining": 1.0
},
"quota_heavy": {
"is_exhausted": true,
"remaining": 0.0
}
}
}
}))),
"grok",
));
assert!(!provider_pool_key_account_quota_exhausted(
&sample_key(Some(json!({
"codex": {
"has_credits": false,
"credits_unlimited": true
}
}))),
"codex",
));
}
#[test]
fn provider_quota_exhaustion_snapshot_expires_after_reset_at() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_secs();
let mut expired = sample_key(None);
expired.status_snapshot = Some(json!({
"quota": {
"version": 2,
"provider_type": "codex",
"code": "exhausted",
"exhausted": true,
"updated_at": now.saturating_sub(600),
"windows": [{
"code": "5h",
"used_ratio": 1.0,
"reset_at": now.saturating_sub(60),
"is_exhausted": true
}]
}
}));
assert!(!provider_pool_key_account_quota_exhausted(
&expired, "codex"
));
let mut active = sample_key(None);
active.status_snapshot = Some(json!({
"quota": {
"version": 2,
"provider_type": "codex",
"code": "exhausted",
"exhausted": true,
"updated_at": now,
"windows": [{
"code": "5h",
"used_ratio": 1.0,
"reset_at": now.saturating_add(3600),
"is_exhausted": true
}]
}
}));
assert!(provider_pool_key_account_quota_exhausted(&active, "codex"));
}
#[test]
fn provider_quota_exhaustion_metadata_expires_after_reset_at() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_secs();
assert!(!provider_pool_key_account_quota_exhausted(
&sample_key(Some(json!({
"codex": {
"updated_at": now.saturating_sub(600),
"primary_used_percent": 100.0,
"primary_reset_at": now.saturating_sub(60)
}
}))),
"codex",
));
assert!(provider_pool_key_account_quota_exhausted(
&sample_key(Some(json!({
"codex": {
"updated_at": now,
"primary_used_percent": 100.0,
"primary_reset_at": now.saturating_add(3600)
}
}))),
"codex",
));
}
#[test]
fn grok_quota_tier_boundaries_match_pool_modes() {
assert_eq!(
grok_supported_quota_windows_for_tier(Some("basic")),
[("quota_fast", "fast")]
);
assert_eq!(
grok_supported_quota_windows_for_tier(Some("super")),
[
("quota_auto", "auto"),
("quota_fast", "fast"),
("quota_expert", "expert"),
("quota_grok_4_3", "grok-420-computer-use-sa")
]
);
assert_eq!(
grok_supported_quota_windows_for_tier(Some("heavy")),
[
("quota_auto", "auto"),
("quota_fast", "fast"),
("quota_expert", "expert"),
("quota_heavy", "heavy"),
("quota_grok_4_3", "grok-420-computer-use-sa")
]
);
}
#[test]
fn grok_pool_tier_infers_from_live_quota_totals() {
let bucket = json!({
"quota_by_model": {
"quota_fast": {
"remaining": 20.0,
"total": 30.0
},
"quota_auto": {
"remaining": 7.0,
"total": 7.0
}
}
});
let bucket = bucket.as_object().expect("bucket should be object");
assert_eq!(grok_pool_tier_from_quota_bucket(bucket), Some("basic"));
}
#[test]
fn grok_model_name_maps_to_quota_window() {
assert_eq!(
grok_quota_window_key_for_model(Some("grok-4.20-fast")),
Some("quota_fast")
);
assert_eq!(
grok_quota_window_key_for_model(Some("grok-4.20-multi-agent-0309")),
Some("quota_heavy")
);
assert_eq!(
grok_quota_window_key_for_model(Some("grok-4.3-beta")),
Some("quota_grok_4_3")
);
}
#[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")
);
}
}
+104
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)
}
+244
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_type(Free 账号优先调度)",
&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_type(Plus 账号优先调度)",
&service,
),
provider_pool_preset_payload(
"pro_first",
"Pro 优先",
"优先消耗 Pro 账号(依赖 plan_type)",
Some(ProviderPoolCapability::PlanTier),
"依据 plan_type(Pro 账号优先调度)",
&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_type(Team 账号优先调度)",
&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,
}
}
+107
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())
}
@@ -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,
}
}
@@ -0,0 +1,359 @@
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_current_unix_secs, provider_pool_json_bool, provider_pool_json_f64,
provider_pool_metadata_bucket, provider_pool_quota_snapshot_exhausted_decision,
provider_pool_reset_deadline_elapsed, provider_pool_timestamp_unix_secs,
};
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""#;
#[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 plan_type = chatgpt_web_image_quota_plan_type(object)
.map(ToOwned::to_owned)
.or_else(|| {
existing_limit
.as_ref()
.and_then(|existing| existing.plan_type.clone())
});
let raw_explicit_limit =
provider_pool_json_f64(object.get("image_quota_total")).filter(|value| *value > 0.0);
let explicit_limit_is_free_default = raw_explicit_limit.is_some_and(|limit| {
is_legacy_chatgpt_web_free_default_limit_value(limit, None, plan_type.as_deref(), remaining)
});
if explicit_limit_is_free_default {
object.remove("image_quota_total");
object.remove("image_quota_limit_source");
}
let explicit_limit = raw_explicit_limit.filter(|_| !explicit_limit_is_free_default);
let limit = explicit_limit
.map(|limit| ChatGptWebImageQuotaLimit {
value: limit,
source: Some("upstream_total".to_string()),
plan_type: plan_type.clone(),
})
.or_else(|| {
infer_chatgpt_web_image_quota_limit(remaining, existing_limit, plan_type.as_deref())
});
if let Some(limit) = limit {
object.insert("image_quota_total".to_string(), json!(limit.value));
if let Some(source) = limit.source.as_deref().filter(|value| !value.is_empty()) {
object.insert("image_quota_limit_source".to_string(), json!(source));
}
if !object.contains_key("image_quota_used") {
if let Some(remaining) = remaining {
object.insert(
"image_quota_used".to_string(),
json!((limit.value - 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.value));
}
}
}
}
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)
})
}
#[derive(Debug, Clone)]
struct ChatGptWebImageQuotaLimit {
value: f64,
source: Option<String>,
plan_type: Option<String>,
}
fn chatgpt_web_image_quota_plan_type(object: &Map<String, Value>) -> Option<&str> {
object
.get("plan_type")
.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<ChatGptWebImageQuotaLimit> {
let bucket = upstream_metadata
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.and_then(Value::as_object)?;
let value =
provider_pool_json_f64(bucket.get("image_quota_total")).filter(|value| *value > 0.0)?;
let source = bucket
.get("image_quota_limit_source")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let plan_type = chatgpt_web_image_quota_plan_type(bucket).map(ToOwned::to_owned);
Some(ChatGptWebImageQuotaLimit {
value,
source,
plan_type,
})
}
fn infer_chatgpt_web_image_quota_limit(
remaining: Option<f64>,
existing_limit: Option<ChatGptWebImageQuotaLimit>,
plan_type: Option<&str>,
) -> Option<ChatGptWebImageQuotaLimit> {
if let Some(existing_limit) = existing_limit {
if !is_legacy_chatgpt_web_free_default_limit(&existing_limit, plan_type, remaining) {
return Some(existing_limit);
}
}
remaining
.filter(|value| *value > 0.0)
.map(|value| ChatGptWebImageQuotaLimit {
value,
source: Some("first_remaining".to_string()),
plan_type: plan_type.map(ToOwned::to_owned),
})
}
fn is_legacy_chatgpt_web_free_default_limit(
existing_limit: &ChatGptWebImageQuotaLimit,
plan_type: Option<&str>,
remaining: Option<f64>,
) -> bool {
is_legacy_chatgpt_web_free_default_limit_value(
existing_limit.value,
existing_limit.source.as_deref(),
plan_type,
remaining,
)
}
fn is_legacy_chatgpt_web_free_default_limit_value(
value: f64,
source: Option<&str>,
plan_type: Option<&str>,
remaining: Option<f64>,
) -> bool {
let plan_type_is_free = plan_type
.map(str::trim)
.is_some_and(|value| value.eq_ignore_ascii_case("free"));
if !plan_type_is_free || source.is_some() {
return false;
}
if (value - 25.0).abs() > f64::EPSILON {
return false;
}
remaining.is_none_or(|remaining| remaining < value)
}
pub(crate) fn quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
if provider_pool_current_unix_secs().is_some_and(|now| {
let mut image_quota = Map::new();
if let Some(value) = bucket.get("image_quota_reset_at") {
image_quota.insert("reset_at".to_string(), value.clone());
}
provider_pool_reset_deadline_elapsed(
&image_quota,
provider_pool_timestamp_unix_secs(bucket.get("updated_at")),
now,
)
}) {
return false;
}
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,
}
}
@@ -0,0 +1,253 @@
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_current_unix_secs, provider_pool_json_bool, provider_pool_json_f64,
provider_pool_metadata_bucket, provider_pool_quota_snapshot_exhausted_decision,
provider_pool_reset_deadline_elapsed, provider_pool_timestamp_unix_secs,
};
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
pub const CODEX_WHAM_RESET_CREDITS_URL: &str =
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
pub const CODEX_WHAM_RESET_CREDITS_CONSUME_URL: &str =
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
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()
}
}
fn build_codex_wham_headers(
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
) -> Result<BTreeMap<String, String>, String> {
let mut headers = BTreeMap::new();
headers.insert("accept".to_string(), "application/json".to_string());
let auth_config_headers = auth_config
.and_then(|value| value.get("headers"))
.and_then(Value::as_object);
let auth_config_authorization = auth_config_headers
.and_then(|headers| {
headers
.get("authorization")
.or_else(|| headers.get("Authorization"))
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(authorization) = auth_config_authorization {
headers.insert("authorization".to_string(), authorization.to_string());
} else 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(headers)
}
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 headers = build_codex_wham_headers(resolved_oauth_auth, decrypted_api_key, auth_config)?;
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 fn build_codex_pool_reset_credits_request(
key_id: &str,
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let headers = build_codex_wham_headers(resolved_oauth_auth, decrypted_api_key, auth_config)?;
Ok(ProviderPoolQuotaRequestSpec {
request_id: format!("codex-reset-credits:{key_id}"),
provider_name: "codex".to_string(),
quota_kind: "codex_reset_credits".to_string(),
method: "GET".to_string(),
url: CODEX_WHAM_RESET_CREDITS_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-reset-credits".to_string()),
accept_invalid_certs: false,
})
}
pub fn build_codex_pool_reset_credit_consume_request(
key_id: &str,
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
redeem_request_id: &str,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let redeem_request_id = redeem_request_id.trim();
if redeem_request_id.is_empty() {
return Err("缺少 Codex reset credit 幂等请求 ID".to_string());
}
let mut headers =
build_codex_wham_headers(resolved_oauth_auth, decrypted_api_key, auth_config)?;
headers.insert("content-type".to_string(), "application/json".to_string());
Ok(ProviderPoolQuotaRequestSpec {
request_id: format!("codex-reset-credit-consume:{key_id}:{redeem_request_id}"),
provider_name: "codex".to_string(),
quota_kind: "codex_reset_credit_consume".to_string(),
method: "POST".to_string(),
url: CODEX_WHAM_RESET_CREDITS_CONSUME_URL.to_string(),
headers,
content_type: Some("application/json".to_string()),
json_body: Some(serde_json::json!({
"redeem_request_id": redeem_request_id,
})),
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("codex-wham-reset-credit-consume".to_string()),
accept_invalid_certs: false,
})
}
fn codex_window_reset_elapsed(bucket: &Map<String, Value>, prefix: &str) -> bool {
let Some(now_unix_secs) = provider_pool_current_unix_secs() else {
return false;
};
let mut window = Map::new();
for (target, source) in [
("reset_at", format!("{prefix}_reset_at")),
("next_reset_at", format!("{prefix}_next_reset_at")),
("reset_seconds", format!("{prefix}_reset_seconds")),
(
"reset_after_seconds",
format!("{prefix}_reset_after_seconds"),
),
] {
if let Some(value) = bucket.get(source.as_str()) {
window.insert(target.to_string(), value.clone());
}
}
provider_pool_reset_deadline_elapsed(
&window,
provider_pool_timestamp_unix_secs(bucket.get("updated_at")),
now_unix_secs,
)
}
fn codex_window_used_percent_exhausted(bucket: &Map<String, Value>, prefix: &str) -> bool {
let used_percent_key = format!("{prefix}_used_percent");
provider_pool_json_f64(bucket.get(used_percent_key.as_str()))
.is_some_and(|value| value >= 100.0 && !codex_window_reset_elapsed(bucket, prefix))
}
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;
}
codex_window_used_percent_exhausted(bucket, "primary")
|| codex_window_used_percent_exhausted(bucket, "secondary")
}
@@ -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"
}
}
@@ -0,0 +1,78 @@
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 GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH: &str = "/v1internal:retrieveUserQuota";
pub const GEMINI_CLI_USER_AGENT: &str = "GeminiCLI/0.1.5 (Windows; AMD64)";
#[derive(Debug, Clone, Default)]
pub struct GeminiCliProviderPoolAdapter;
impl ProviderPoolAdapter for GeminiCliProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"gemini_cli"
}
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_gemini_cli_pool_quota_request(
key_id: &str,
endpoint_base_url: &str,
authorization: (String, String),
project_id: &str,
) -> ProviderPoolQuotaRequestSpec {
let headers = BTreeMap::from([
("authorization".to_string(), authorization.1),
("content-type".to_string(), "application/json".to_string()),
("accept".to_string(), "application/json".to_string()),
("user-agent".to_string(), GEMINI_CLI_USER_AGENT.to_string()),
]);
ProviderPoolQuotaRequestSpec {
request_id: format!("gemini-cli-quota:{key_id}"),
provider_name: "gemini_cli".to_string(),
quota_kind: "gemini_cli".to_string(),
method: "POST".to_string(),
url: format!(
"{}{}",
endpoint_base_url.trim_end_matches('/'),
GEMINI_CLI_RETRIEVE_USER_QUOTA_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: "gemini_cli:retrieve_user_quota".to_string(),
model_name: Some("retrieveUserQuota".to_string()),
accept_invalid_certs: false,
}
}
@@ -0,0 +1,225 @@
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
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_current_unix_secs, provider_pool_json_bool, provider_pool_json_f64,
provider_pool_metadata_bucket, provider_pool_quota_snapshot_exhausted_decision,
provider_pool_reset_deadline_elapsed, provider_pool_timestamp_unix_secs,
};
pub const GROK_QUOTA_WINDOWS_BASIC: &[(&str, &str)] = &[("quota_fast", "fast")];
pub const GROK_QUOTA_WINDOWS_SUPER: &[(&str, &str)] = &[
("quota_auto", "auto"),
("quota_fast", "fast"),
("quota_expert", "expert"),
("quota_grok_4_3", "grok-420-computer-use-sa"),
];
pub const GROK_QUOTA_WINDOWS_HEAVY: &[(&str, &str)] = &[
("quota_auto", "auto"),
("quota_fast", "fast"),
("quota_expert", "expert"),
("quota_heavy", "heavy"),
("quota_grok_4_3", "grok-420-computer-use-sa"),
];
#[derive(Debug, Clone, Default)]
pub struct GrokProviderPoolAdapter;
impl ProviderPoolAdapter for GrokProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"grok"
}
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, "openai:chat")
})
.or_else(|| provider_pool_matching_endpoint(endpoints, include_inactive, |_| true))
}
fn quota_refresh_missing_endpoint_message(&self) -> String {
"找不到有效的 Grok 端点".to_string()
}
}
pub fn grok_supported_quota_windows_for_tier(
tier: Option<&str>,
) -> &'static [(&'static str, &'static str)] {
match grok_normalize_pool_tier(tier) {
Some("basic") => GROK_QUOTA_WINDOWS_BASIC,
Some("super") => GROK_QUOTA_WINDOWS_SUPER,
Some("heavy") => GROK_QUOTA_WINDOWS_HEAVY,
_ => GROK_QUOTA_WINDOWS_HEAVY,
}
}
pub fn grok_pool_tier_from_quota_bucket(bucket: &Map<String, Value>) -> Option<&'static str> {
if let Some(tier) = grok_normalize_pool_tier(
grok_bucket_string(bucket, &["pool_tier", "tier", "plan_type", "plan"]).as_deref(),
) {
return Some(tier);
}
if let Some(auto_total) = grok_quota_total(bucket, "quota_auto") {
if (auto_total - 50.0).abs() < f64::EPSILON {
return Some("super");
}
if (auto_total - 150.0).abs() < f64::EPSILON {
return Some("heavy");
}
}
if let Some(fast_total) = grok_quota_total(bucket, "quota_fast") {
if (fast_total - 30.0).abs() < f64::EPSILON {
return Some("basic");
}
if (fast_total - 140.0).abs() < f64::EPSILON {
return Some("super");
}
if (fast_total - 400.0).abs() < f64::EPSILON {
return Some("heavy");
}
}
None
}
pub fn grok_quota_window_key_for_model(model: Option<&str>) -> Option<&'static str> {
Some(match grok_mode_id_for_model(model) {
"fast" => "quota_fast",
"auto" => "quota_auto",
"expert" => "quota_expert",
"heavy" => "quota_heavy",
"grok-420-computer-use-sa" => "quota_grok_4_3",
_ => return None,
})
}
pub fn grok_mode_id_for_model(model: Option<&str>) -> &'static str {
let model = model.unwrap_or_default().to_ascii_lowercase();
if model.contains("4.3") || model.contains("computer") {
"grok-420-computer-use-sa"
} else if model.contains("multi-agent") {
"heavy"
} else if model.contains("non-reasoning") || model.contains("fast") || model.contains("lite") {
"fast"
} else if model.contains("expert") || model.contains("reasoning") {
"expert"
} else if model.contains("0309-heavy") {
"auto"
} else if model.contains("heavy") {
"heavy"
} else {
"auto"
}
}
fn grok_normalize_pool_tier(value: Option<&str>) -> Option<&'static str> {
match value?.trim().to_ascii_lowercase().as_str() {
"basic" => Some("basic"),
"super" => Some("super"),
"heavy" => Some("heavy"),
_ => None,
}
}
fn grok_bucket_string(bucket: &Map<String, Value>, fields: &[&str]) -> Option<String> {
fields.iter().find_map(|field| {
bucket
.get(*field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn grok_quota_total(quota_by_model: &Map<String, Value>, key: &str) -> Option<f64> {
let models = quota_by_model
.get("quota_by_model")
.or_else(|| quota_by_model.get("models"))
.and_then(Value::as_object)
.unwrap_or(quota_by_model);
models
.get(key)
.and_then(Value::as_object)
.and_then(|quota| quota.get("total"))
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value > 0.0)
}
pub(crate) fn quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
let models = bucket
.get("quota_by_model")
.or_else(|| bucket.get("models"))
.and_then(Value::as_object);
let Some(models) = models else {
return false;
};
let supported_mode_keys =
grok_supported_quota_windows_for_tier(grok_pool_tier_from_quota_bucket(bucket))
.iter()
.map(|(quota_key, _)| *quota_key)
.collect::<Vec<_>>();
let mut model_count = 0usize;
let mut exhausted_count = 0usize;
let now_unix_secs = provider_pool_current_unix_secs();
let bucket_updated_at = provider_pool_timestamp_unix_secs(bucket.get("updated_at"));
for (model_key, item) in models.iter() {
if !supported_mode_keys.is_empty() && !supported_mode_keys.contains(&model_key.as_str()) {
continue;
}
let Some(item) = item.as_object() else {
continue;
};
let has_quota_data = provider_pool_json_bool(item.get("is_exhausted")).is_some()
|| provider_pool_json_f64(item.get("used_percent")).is_some()
|| provider_pool_json_f64(item.get("remaining")).is_some()
|| provider_pool_json_f64(item.get("remaining_fraction")).is_some();
if !has_quota_data {
continue;
}
model_count += 1;
let quota_exhausted = provider_pool_json_bool(item.get("is_exhausted")) == Some(true)
|| provider_pool_json_f64(item.get("used_percent")).is_some_and(|value| value >= 100.0)
|| provider_pool_json_f64(item.get("remaining")).is_some_and(|value| value <= 0.0)
|| provider_pool_json_f64(item.get("remaining_fraction"))
.is_some_and(|value| value <= 0.0);
let reset_elapsed = now_unix_secs
.is_some_and(|now| provider_pool_reset_deadline_elapsed(item, bucket_updated_at, now));
if quota_exhausted && !reset_elapsed {
exhausted_count += 1;
}
}
model_count > 0 && model_count == exhausted_count
}
@@ -0,0 +1,177 @@
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_current_unix_secs, provider_pool_json_f64, provider_pool_metadata_bucket,
provider_pool_quota_snapshot_exhausted_decision, provider_pool_reset_deadline_elapsed,
provider_pool_timestamp_unix_secs,
};
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_current_unix_secs().is_some_and(|now| {
provider_pool_reset_deadline_elapsed(
bucket,
provider_pool_timestamp_unix_secs(bucket.get("updated_at")),
now,
)
}) {
return false;
}
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,
}
}
@@ -0,0 +1,52 @@
pub mod antigravity;
pub mod chatgpt_web;
pub mod codex;
pub mod default;
pub mod gemini_cli;
pub mod grok;
pub mod kiro;
pub mod unsupported;
pub mod windsurf;
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, build_codex_pool_reset_credit_consume_request,
build_codex_pool_reset_credits_request, CODEX_WHAM_RESET_CREDITS_CONSUME_URL,
CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL,
};
pub use default::DefaultProviderPoolAdapter;
pub use gemini_cli::GeminiCliProviderPoolAdapter;
pub use gemini_cli::{
build_gemini_cli_pool_quota_request, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH, GEMINI_CLI_USER_AGENT,
};
pub use grok::{
grok_mode_id_for_model, grok_pool_tier_from_quota_bucket, grok_quota_window_key_for_model,
grok_supported_quota_windows_for_tier, GrokProviderPoolAdapter,
};
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,
VERTEX_AI_PROVIDER_POOL_ADAPTER,
};
pub use windsurf::{
build_windsurf_pool_model_configs_request,
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
build_windsurf_pool_rate_limit_request_with_base_url, WindsurfProviderPoolAdapter,
WINDSURF_DEFAULT_BASE_URL, WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH,
WINDSURF_USER_STATUS_PATH,
};
@@ -0,0 +1,41 @@
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 VERTEX_AI_PROVIDER_POOL_ADAPTER: UnsupportedQuotaProviderPoolAdapter =
UnsupportedQuotaProviderPoolAdapter::new(
"vertex_ai",
"Vertex AI 暂不支持自动刷新额度:额度属于 Google Cloud 项目/区域配额",
);
@@ -0,0 +1,286 @@
use std::collections::BTreeMap;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use aether_pool_core::PoolSchedulingPreset;
use serde_json::{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_member_quota_snapshot,
provider_pool_metadata_bucket, provider_pool_quota_snapshot_exhausted_decision,
};
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const WINDSURF_DEFAULT_BASE_URL: &str = "https://server.codeium.com";
pub const WINDSURF_USER_STATUS_PATH: &str =
"/exa.seat_management_pb.SeatManagementService/GetUserStatus";
pub const WINDSURF_MODEL_CONFIGS_PATH: &str =
"/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs";
pub const WINDSURF_RATE_LIMIT_PATH: &str =
"/exa.api_server_pb.ApiServerService/CheckUserMessageRateLimit";
#[derive(Debug, Clone, Default)]
pub struct WindsurfProviderPoolAdapter;
impl ProviderPoolAdapter for WindsurfProviderPoolAdapter {
fn provider_type(&self) -> &'static str {
"windsurf"
}
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 windsurf_quota_snapshot_hard_exhausted(input.key, input.provider_type) {
return true;
}
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(windsurf_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:chat")
})
}
fn quota_refresh_missing_endpoint_message(&self) -> String {
"找不到有效的 openai:chat 端点".to_string()
}
}
fn windsurf_quota_snapshot_hard_exhausted(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> bool {
provider_pool_member_quota_snapshot(key, provider_type)
.and_then(|quota| quota.get("code"))
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.is_some_and(|code| matches!(code.as_str(), "banned" | "forbidden" | "quarantined"))
}
pub fn build_windsurf_pool_quota_request(
key_id: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_pool_quota_request_with_base_url(key_id, WINDSURF_DEFAULT_BASE_URL, api_key)
}
pub fn build_windsurf_pool_quota_request_with_base_url(
key_id: &str,
base_url: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_connect_rpc_request(
format!("windsurf-quota:{key_id}"),
"windsurf:user_status",
"windsurf-user-status",
base_url,
WINDSURF_USER_STATUS_PATH,
api_key,
)
}
pub fn build_windsurf_pool_model_configs_request(
key_id: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_pool_model_configs_request_with_base_url(
key_id,
WINDSURF_DEFAULT_BASE_URL,
api_key,
)
}
pub fn build_windsurf_pool_model_configs_request_with_base_url(
key_id: &str,
base_url: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_connect_rpc_request(
format!("windsurf-models:{key_id}"),
"windsurf:model_configs",
"windsurf-model-configs",
base_url,
WINDSURF_MODEL_CONFIGS_PATH,
api_key,
)
}
pub fn build_windsurf_pool_rate_limit_request(
key_id: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_pool_rate_limit_request_with_base_url(key_id, WINDSURF_DEFAULT_BASE_URL, api_key)
}
pub fn build_windsurf_pool_rate_limit_request_with_base_url(
key_id: &str,
base_url: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
build_windsurf_connect_rpc_request(
format!("windsurf-rate-limit:{key_id}"),
"windsurf:rate_limit",
"windsurf-rate-limit",
base_url,
WINDSURF_RATE_LIMIT_PATH,
api_key,
)
}
fn build_windsurf_connect_rpc_request(
request_id: String,
provider_api_format: &str,
model_name: &str,
base_url: &str,
path: &str,
api_key: &str,
) -> ProviderPoolQuotaRequestSpec {
let mut headers = BTreeMap::new();
headers.insert("content-type".to_string(), "application/json".to_string());
headers.insert("accept".to_string(), "application/json".to_string());
headers.insert("connect-protocol-version".to_string(), "1".to_string());
headers.insert("user-agent".to_string(), "windsurf/1.9600.41".to_string());
ProviderPoolQuotaRequestSpec {
request_id,
provider_name: "windsurf".to_string(),
quota_kind: "windsurf".to_string(),
method: "POST".to_string(),
url: format!("{}{}", base_url.trim_end_matches('/'), path),
headers,
content_type: Some("application/json".to_string()),
json_body: Some(json!({
"metadata": windsurf_metadata(api_key),
})),
client_api_format: "openai:chat".to_string(),
provider_api_format: provider_api_format.to_string(),
model_name: Some(model_name.to_string()),
accept_invalid_certs: false,
}
}
fn windsurf_metadata(api_key: &str) -> Value {
json!({
"apiKey": api_key,
"ideName": "windsurf",
"ideVersion": "1.9600.41",
"extensionName": "windsurf",
"extensionVersion": "1.9600.41",
"locale": "en",
})
}
pub(crate) fn windsurf_quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
if provider_pool_json_bool(bucket.get("banned"))
.or_else(|| provider_pool_json_bool(bucket.get("quarantined")))
.unwrap_or(false)
{
return true;
}
let daily_remaining = provider_pool_json_f64(bucket.get("daily_remaining_percent"));
let weekly_remaining = provider_pool_json_f64(bucket.get("weekly_remaining_percent"));
daily_remaining.is_some_and(|value| value <= 0.0)
|| weekly_remaining.is_some_and(|value| value <= 0.0)
}
#[cfg(test)]
mod tests {
use super::{windsurf_quota_exhausted_from_bucket, windsurf_quota_snapshot_hard_exhausted};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use serde_json::json;
fn sample_key_with_quota(code: &str, exhausted: bool) -> StoredProviderCatalogKey {
let mut key = StoredProviderCatalogKey::new(
"key-windsurf".to_string(),
"provider-windsurf".to_string(),
"[email protected]".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("sample key should build");
key.status_snapshot = Some(json!({
"quota": {
"provider_type": "windsurf",
"code": code,
"exhausted": exhausted,
"windows": [{
"code": "daily",
"used_ratio": 0.0,
"remaining_ratio": 1.0
}]
}
}));
key
}
#[test]
fn windsurf_rate_limit_bucket_does_not_mark_quota_exhausted() {
let bucket = json!({
"rate_limit": {
"limited": true,
"retry_after_ms": 60_000u64
},
"daily_remaining_percent": 50.0,
"weekly_remaining_percent": 50.0
});
let bucket = bucket.as_object().expect("bucket should be object");
assert!(!windsurf_quota_exhausted_from_bucket(bucket));
}
#[test]
fn windsurf_banned_and_quarantined_snapshot_codes_are_hard_exhausted() {
for code in ["banned", "forbidden", "quarantined"] {
let key = sample_key_with_quota(code, false);
assert!(
windsurf_quota_snapshot_hard_exhausted(&key, "windsurf"),
"{code} should be hard exhausted"
);
}
let cooldown_key = sample_key_with_quota("cooldown", false);
assert!(!windsurf_quota_snapshot_hard_exhausted(
&cooldown_key,
"windsurf"
));
let rate_limited_key = sample_key_with_quota("rate_limited", false);
assert!(!windsurf_quota_snapshot_hard_exhausted(
&rate_limited_key,
"windsurf"
));
}
}
+334
View File
@@ -0,0 +1,334 @@
use std::time::{SystemTime, UNIX_EPOCH};
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())
}
pub(crate) 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)
}
pub(crate) fn provider_pool_current_unix_secs() -> Option<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
}
fn provider_pool_reset_deadline_unix_secs(
item: &Map<String, Value>,
fallback_observed_at: Option<u64>,
) -> Option<u64> {
provider_pool_timestamp_unix_secs(item.get("reset_at"))
.or_else(|| provider_pool_timestamp_unix_secs(item.get("next_reset_at")))
.or_else(|| {
let reset_seconds = provider_pool_json_f64(item.get("reset_seconds"))
.or_else(|| provider_pool_json_f64(item.get("reset_after_seconds")))?;
if reset_seconds < 0.0 {
return None;
}
let base = provider_pool_timestamp_unix_secs(item.get("observed_at"))
.or_else(|| provider_pool_timestamp_unix_secs(item.get("updated_at")))
.or(fallback_observed_at)?;
Some(base.saturating_add(reset_seconds.ceil() as u64))
})
}
pub(crate) fn provider_pool_reset_deadline_elapsed(
item: &Map<String, Value>,
fallback_observed_at: Option<u64>,
now_unix_secs: u64,
) -> bool {
provider_pool_reset_deadline_unix_secs(item, fallback_observed_at)
.is_some_and(|reset_at| reset_at <= now_unix_secs)
}
fn provider_pool_quota_window_is_exhausted(window: &Map<String, Value>) -> bool {
provider_pool_json_bool(window.get("is_exhausted"))
.or_else(|| {
provider_pool_json_f64(window.get("used_ratio")).map(|value| value >= 1.0 - 1e-6)
})
.unwrap_or(false)
}
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 now_unix_secs = provider_pool_current_unix_secs();
let snapshot_observed_at =
provider_pool_timestamp_unix_secs(quota_snapshot.get("observed_at"))
.or_else(|| provider_pool_timestamp_unix_secs(quota_snapshot.get("updated_at")));
if let Some(windows) = quota_snapshot
.get("windows")
.and_then(Value::as_array)
.filter(|windows| !windows.is_empty())
{
let mut saw_exhausted_window = false;
let mut saw_active_exhausted_window = false;
let mut windows_max_ratio = None::<f64>;
for window in windows.iter().filter_map(Value::as_object) {
if let Some(ratio) = provider_pool_json_f64(window.get("used_ratio")) {
windows_max_ratio =
Some(windows_max_ratio.map_or(ratio, |current| current.max(ratio)));
}
if provider_pool_quota_window_is_exhausted(window) {
saw_exhausted_window = true;
let reset_elapsed = now_unix_secs.is_some_and(|now| {
provider_pool_reset_deadline_elapsed(window, snapshot_observed_at, now)
});
if !reset_elapsed {
saw_active_exhausted_window = true;
}
}
}
if saw_exhausted_window {
return Some(saw_active_exhausted_window);
}
if windows_max_ratio.is_some_and(|ratio| ratio < 1.0 - 1e-6) {
return Some(false);
}
} else if now_unix_secs.is_some_and(|now| {
provider_pool_reset_deadline_elapsed(quota_snapshot, snapshot_observed_at, now)
}) {
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))
})
}
@@ -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,
}
+135
View File
@@ -0,0 +1,135 @@
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, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
KiroProviderPoolAdapter, WindsurfProviderPoolAdapter, CLAUDE_CODE_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(GeminiCliProviderPoolAdapter))
.with_adapter(Arc::new(GrokProviderPoolAdapter))
.with_adapter(Arc::new(KiroProviderPoolAdapter))
.with_adapter(Arc::new(ChatGptWebProviderPoolAdapter))
.with_adapter(Arc::new(WindsurfProviderPoolAdapter))
.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)
}
}