mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
- 后端读取层 provider_key_status_snapshot_payload 复用调度侧同一判定 provider_pool_reset_deadline_elapsed,对已到期的 Codex 配额窗口归一化为 used_ratio=0 / remaining_ratio=1,并清除窗口级耗尽标记;跳过 window_minutes=0 与无用量观测的窗口 - 前端展示层在倒计时归零(isExpired)时兜底显示 100%,并隐藏重置前的旧用量 文本,保证归零瞬间即时恢复,与后端读取口径、调度口径一致 - 补充后端 2 个单测(到期归一化、遗留耗尽状态清理)与前端 1 个回归用例 (负向验证可复现旧行为) 验证:aether-provider-pool 71/71;aether-gateway lib 回归通过(仅 3 个依赖 PostgreSQL 服务端的用例因本机环境缺失失败,与本改动无关);前端 222 文件 / 1718 用例全部通过;clippy -D warnings、cargo fmt、vue-tsc、eslint 均通过。
1091 lines
40 KiB
Rust
1091 lines
40 KiB
Rust
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,
|
|
provider_model_name: None,
|
|
})
|
|
}
|
|
|
|
pub fn provider_pool_key_quota_hard_blocked(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
) -> bool {
|
|
let adapter = ProviderPoolService::with_builtin_adapters().adapter(provider_type);
|
|
adapter.quota_hard_blocked(&ProviderPoolMemberInput {
|
|
provider_type,
|
|
key,
|
|
auth_config: None,
|
|
provider_model_name: None,
|
|
})
|
|
}
|
|
|
|
/// Model-aware hard-block lookup for pre-scheduler candidate filtering. Some
|
|
/// providers expose permanent account flags alongside independent model
|
|
/// buckets; adapters can suppress the account flag when the selected model
|
|
/// has its own usable quota.
|
|
pub fn provider_pool_key_model_quota_hard_blocked(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
provider_model_name: &str,
|
|
) -> bool {
|
|
let adapter = ProviderPoolService::with_builtin_adapters().adapter(provider_type);
|
|
adapter.quota_hard_blocked(&ProviderPoolMemberInput {
|
|
provider_type,
|
|
key,
|
|
auth_config: None,
|
|
provider_model_name: Some(provider_model_name),
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// Resolve exhaustion for the quota bucket applicable to one provider model.
|
|
///
|
|
/// Providers are free to expose quota windows in different shapes. Newer
|
|
/// snapshots should put an explicit `model`/`models` (or `quota_group`) on a
|
|
/// window; legacy Codex snapshots use a family prefix in `code` (for example
|
|
/// `spark_5h`). We deliberately do not name any product or model here: the
|
|
/// resolver compares the metadata supplied by the provider with the selected
|
|
/// model and only falls back to account-level exhaustion when no model bucket
|
|
/// can be identified.
|
|
pub(crate) fn provider_pool_model_quota_exhausted(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
provider_model_name: &str,
|
|
) -> Option<bool> {
|
|
provider_pool_quota_reaches_reserve(key, provider_type, Some(provider_model_name), 0.0)
|
|
}
|
|
|
|
fn provider_pool_quota_reaches_reserve(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
provider_model_name: Option<&str>,
|
|
reserve_ratio: f64,
|
|
) -> Option<bool> {
|
|
let is_codex = provider_type.trim().eq_ignore_ascii_case("codex");
|
|
let requested = provider_model_name.map(provider_pool_identifier_tokens);
|
|
if reserve_ratio <= 0.0 && requested.as_ref().is_some_and(|tokens| tokens.is_empty()) {
|
|
return None;
|
|
}
|
|
|
|
// Prefer the materialized status snapshot, but also inspect the raw
|
|
// provider metadata. A quota refresh and a request can race, leaving the
|
|
// latter newer than the snapshot; resolving both avoids falling back to an
|
|
// account-wide signal and incorrectly blocking an unrelated model bucket.
|
|
let sources = [
|
|
provider_pool_member_quota_snapshot(key, provider_type),
|
|
provider_pool_metadata_bucket(key.upstream_metadata.as_ref(), provider_type),
|
|
];
|
|
let mut resolved = None::<(Option<u64>, bool)>;
|
|
let mut resolved_specific_bucket = false;
|
|
for source in sources.into_iter().flatten() {
|
|
let observed_at = provider_pool_timestamp_unix_secs(source.get("observed_at"))
|
|
.or_else(|| provider_pool_timestamp_unix_secs(source.get("updated_at")));
|
|
let account_signal = (is_codex && reserve_ratio <= 0.0)
|
|
.then(|| provider_pool_codex_account_quota_signal(source, observed_at))
|
|
.flatten();
|
|
let mut windows = provider_pool_collect_quota_windows(source);
|
|
if is_codex {
|
|
// Raw refresh metadata can be newer than the materialized windows.
|
|
// Include all four legacy slots before selecting the model bucket.
|
|
for prefix in ["primary", "secondary", "spark_primary", "spark_secondary"] {
|
|
let Some(used_percent) =
|
|
provider_pool_json_f64(source.get(&format!("{prefix}_used_percent")))
|
|
else {
|
|
continue;
|
|
};
|
|
if provider_pool_json_f64(source.get(&format!("{prefix}_window_minutes")))
|
|
== Some(0.0)
|
|
{
|
|
continue;
|
|
}
|
|
let mut window = Map::from_iter([
|
|
("code".to_string(), json!(prefix)),
|
|
("used_percent".to_string(), json!(used_percent)),
|
|
]);
|
|
for field in [
|
|
"reset_at",
|
|
"next_reset_at",
|
|
"reset_seconds",
|
|
"reset_after_seconds",
|
|
] {
|
|
if let Some(value) = source.get(&format!("{prefix}_{field}")) {
|
|
window.insert(field.to_string(), value.clone());
|
|
}
|
|
}
|
|
windows.push(window);
|
|
}
|
|
windows.retain(provider_pool_quota_window_has_observation);
|
|
if let Some(exhausted) = account_signal {
|
|
if !windows.iter().any(provider_pool_window_is_generic) {
|
|
// A flags-only quota refresh is still a newer account
|
|
// observation, but must not override a model-specific bucket.
|
|
windows.push(Map::from_iter([
|
|
("code".to_string(), json!("account")),
|
|
("is_exhausted".to_string(), json!(exhausted)),
|
|
]));
|
|
}
|
|
}
|
|
}
|
|
if windows.is_empty() {
|
|
continue;
|
|
}
|
|
let model_matches = windows
|
|
.iter()
|
|
.filter(|window| {
|
|
provider_model_name.is_some_and(|model| {
|
|
provider_pool_window_explicitly_matches_model(window, model)
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if !model_matches.is_empty() {
|
|
let exhausted = if reserve_ratio > 0.0 {
|
|
// Reserving quota protects every applicable rate-limit window,
|
|
// including short and weekly limits for an explicit model.
|
|
provider_pool_any_window_exhausted(model_matches, observed_at, reserve_ratio)
|
|
} else {
|
|
provider_pool_explicit_model_windows_exhausted(model_matches, observed_at)
|
|
};
|
|
if resolved.is_none()
|
|
|| (is_codex && !resolved_specific_bucket)
|
|
|| provider_pool_should_replace_model_quota_resolution(
|
|
resolved.as_ref().and_then(|(observed_at, _)| *observed_at),
|
|
observed_at,
|
|
)
|
|
{
|
|
resolved = Some((observed_at, exhausted));
|
|
resolved_specific_bucket = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Legacy snapshots may not carry a model field. Match an opaque
|
|
// family token (the prefix before `_`/`:` in `code`, or an explicit
|
|
// family key) against the model's tokens. This keeps independent
|
|
// windows isolated without baking in names such as "spark".
|
|
let family_matches = windows
|
|
.iter()
|
|
.filter(|window| {
|
|
requested
|
|
.as_ref()
|
|
.is_some_and(|tokens| provider_pool_window_family_matches_model(window, tokens))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if !family_matches.is_empty() {
|
|
let exhausted =
|
|
provider_pool_any_window_exhausted(family_matches, observed_at, reserve_ratio);
|
|
if resolved.is_none()
|
|
|| (is_codex && !resolved_specific_bucket)
|
|
|| provider_pool_should_replace_model_quota_resolution(
|
|
resolved.as_ref().and_then(|(observed_at, _)| *observed_at),
|
|
observed_at,
|
|
)
|
|
{
|
|
resolved = Some((observed_at, exhausted));
|
|
resolved_specific_bucket = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// A newer account observation does not update an independent model
|
|
// bucket. Only compare freshness between applicable model sources.
|
|
if is_codex && resolved_specific_bucket {
|
|
continue;
|
|
}
|
|
|
|
// Account-scoped windows (for example the ordinary weekly and short
|
|
// windows emitted by Codex) apply to every model that has no more
|
|
// specific family. Restrict this fallback to well-known structural
|
|
// window names; opaque family names such as `alpha_weekly` must not
|
|
// accidentally make an unrelated model look schedulable.
|
|
let generic_matches = windows
|
|
.iter()
|
|
.filter(|window| provider_pool_window_is_generic(window))
|
|
.collect::<Vec<_>>();
|
|
if !generic_matches.is_empty() {
|
|
let exhausted = account_signal.unwrap_or_else(|| {
|
|
provider_pool_any_window_exhausted(generic_matches, observed_at, reserve_ratio)
|
|
});
|
|
if resolved.is_none()
|
|
|| provider_pool_should_replace_model_quota_resolution(
|
|
resolved.as_ref().and_then(|(observed_at, _)| *observed_at),
|
|
observed_at,
|
|
)
|
|
{
|
|
resolved = Some((observed_at, exhausted));
|
|
}
|
|
}
|
|
}
|
|
|
|
resolved.map(|(_, exhausted)| exhausted)
|
|
}
|
|
|
|
fn provider_pool_codex_account_quota_signal(
|
|
source: &Map<String, Value>,
|
|
observed_at: Option<u64>,
|
|
) -> Option<bool> {
|
|
let allowed = provider_pool_json_bool(source.get("allowed"));
|
|
let limit_reached = provider_pool_json_bool(source.get("limit_reached"));
|
|
if allowed == Some(false) || limit_reached == Some(true) {
|
|
let reset_elapsed = provider_pool_current_unix_secs().is_some_and(|now| {
|
|
provider_pool_reset_deadline_elapsed(source, observed_at, now)
|
|
|| ["primary", "secondary"].into_iter().any(|prefix| {
|
|
let window = [
|
|
"reset_at",
|
|
"next_reset_at",
|
|
"reset_seconds",
|
|
"reset_after_seconds",
|
|
]
|
|
.into_iter()
|
|
.filter_map(|field| {
|
|
source
|
|
.get(&format!("{prefix}_{field}"))
|
|
.map(|value| (field.to_string(), value.clone()))
|
|
})
|
|
.collect::<Map<_, _>>();
|
|
provider_pool_reset_deadline_elapsed(&window, observed_at, now)
|
|
})
|
|
});
|
|
return (!reset_elapsed).then_some(true);
|
|
}
|
|
(allowed == Some(true) || limit_reached == Some(false)).then_some(false)
|
|
}
|
|
|
|
fn provider_pool_should_replace_model_quota_resolution(
|
|
previous_observed_at: Option<u64>,
|
|
next_observed_at: Option<u64>,
|
|
) -> bool {
|
|
match (previous_observed_at, next_observed_at) {
|
|
(Some(previous), Some(next)) => next >= previous,
|
|
(None, Some(_)) => true,
|
|
(Some(_), None) => false,
|
|
// Preserve source order when neither side carries freshness metadata;
|
|
// the materialized status snapshot is preferred over raw metadata.
|
|
(None, None) => false,
|
|
}
|
|
}
|
|
|
|
/// Public adapter-independent model quota lookup used by schedulers that need
|
|
/// to prefilter candidates before constructing provider-pool signals.
|
|
pub fn provider_pool_key_model_quota_exhausted(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
provider_model_name: &str,
|
|
) -> Option<bool> {
|
|
provider_pool_model_quota_exhausted(key, provider_type, provider_model_name)
|
|
}
|
|
|
|
fn provider_pool_explicit_model_windows_exhausted(
|
|
windows: Vec<&Map<String, Value>>,
|
|
snapshot_observed_at: Option<u64>,
|
|
) -> bool {
|
|
let now_unix_secs = provider_pool_current_unix_secs();
|
|
|
|
// Explicit model buckets represent independent windows for one model. The
|
|
// model remains usable while at least one of those windows still has
|
|
// capacity, so exhaustion is reported only when all are exhausted.
|
|
windows.iter().all(|window| {
|
|
provider_pool_quota_window_is_exhausted(window)
|
|
&& !now_unix_secs.is_some_and(|now| {
|
|
provider_pool_reset_deadline_elapsed(window, snapshot_observed_at, now)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn provider_pool_any_window_exhausted(
|
|
windows: Vec<&Map<String, Value>>,
|
|
snapshot_observed_at: Option<u64>,
|
|
reserve_ratio: f64,
|
|
) -> bool {
|
|
let now_unix_secs = provider_pool_current_unix_secs();
|
|
windows.iter().any(|window| {
|
|
provider_pool_quota_window_reaches_reserve(window, reserve_ratio)
|
|
&& !now_unix_secs.is_some_and(|now| {
|
|
provider_pool_reset_deadline_elapsed(window, snapshot_observed_at, now)
|
|
})
|
|
})
|
|
}
|
|
|
|
fn provider_pool_quota_window_has_observation(window: &Map<String, Value>) -> bool {
|
|
["is_exhausted", "exhausted"]
|
|
.into_iter()
|
|
.any(|field| provider_pool_json_bool(window.get(field)).is_some())
|
|
|| [
|
|
"used_ratio",
|
|
"usage_ratio",
|
|
"used_percent",
|
|
"remaining_ratio",
|
|
"remaining_fraction",
|
|
"remaining_percent",
|
|
]
|
|
.into_iter()
|
|
.any(|field| provider_pool_json_f64(window.get(field)).is_some())
|
|
|| (provider_pool_json_f64(
|
|
window
|
|
.get("remaining")
|
|
.or_else(|| window.get("remaining_value")),
|
|
)
|
|
.is_some()
|
|
&& provider_pool_json_f64(
|
|
window
|
|
.get("limit")
|
|
.or_else(|| window.get("limit_value"))
|
|
.or_else(|| window.get("total")),
|
|
)
|
|
.is_some_and(|limit| limit > 0.0))
|
|
}
|
|
|
|
pub(crate) fn provider_pool_source_account_quota_exhausted(
|
|
source: &Map<String, Value>,
|
|
) -> Option<bool> {
|
|
let windows = provider_pool_collect_quota_windows(source);
|
|
let account_windows = windows
|
|
.iter()
|
|
.filter(|window| provider_pool_window_is_generic(window))
|
|
.filter(|window| provider_pool_quota_window_has_observation(window))
|
|
.collect::<Vec<_>>();
|
|
if account_windows.is_empty() {
|
|
return None;
|
|
}
|
|
let observed_at = provider_pool_timestamp_unix_secs(source.get("observed_at"))
|
|
.or_else(|| provider_pool_timestamp_unix_secs(source.get("updated_at")));
|
|
Some(provider_pool_any_window_exhausted(
|
|
account_windows,
|
|
observed_at,
|
|
0.0,
|
|
))
|
|
}
|
|
|
|
/// Whether Codex metadata contains an account quota observation rather than an
|
|
/// identity-only update or an independent model quota bucket.
|
|
pub fn provider_pool_codex_metadata_has_account_quota(source: &Map<String, Value>) -> bool {
|
|
["primary_used_percent", "secondary_used_percent"]
|
|
.into_iter()
|
|
.any(|field| provider_pool_json_f64(source.get(field)).is_some())
|
|
|| provider_pool_source_account_quota_exhausted(source).is_some()
|
|
|| ["allowed", "limit_reached", "has_credits"]
|
|
.into_iter()
|
|
.any(|field| provider_pool_json_bool(source.get(field)).is_some())
|
|
|| provider_pool_json_bool(source.get("credits_unlimited")) == Some(true)
|
|
}
|
|
|
|
/// Whether an applicable Codex window has at most 1% remaining. Missing quota
|
|
/// data and windows whose reset has elapsed do not trigger this opt-in guard.
|
|
pub fn provider_pool_key_minimum_quota_reached(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
provider_model_name: Option<&str>,
|
|
) -> bool {
|
|
if !provider_type.trim().eq_ignore_ascii_case("codex") {
|
|
return false;
|
|
}
|
|
provider_pool_quota_reaches_reserve(key, provider_type, provider_model_name, 0.01)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn provider_pool_quota_window_reaches_reserve(
|
|
window: &Map<String, Value>,
|
|
reserve_ratio: f64,
|
|
) -> bool {
|
|
if provider_pool_quota_window_is_exhausted(window) {
|
|
return true;
|
|
}
|
|
if reserve_ratio <= 0.0 {
|
|
return false;
|
|
}
|
|
let used_ratio = provider_pool_json_f64(window.get("used_ratio"))
|
|
.or_else(|| provider_pool_json_f64(window.get("usage_ratio")))
|
|
.or_else(|| provider_pool_json_f64(window.get("used_percent")).map(|value| value / 100.0))
|
|
.or_else(|| {
|
|
provider_pool_json_f64(window.get("remaining_ratio"))
|
|
.or_else(|| provider_pool_json_f64(window.get("remaining_fraction")))
|
|
.map(|value| 1.0 - value)
|
|
})
|
|
.or_else(|| {
|
|
provider_pool_json_f64(window.get("remaining_percent")).map(|value| 1.0 - value / 100.0)
|
|
})
|
|
.or_else(|| {
|
|
let remaining = provider_pool_json_f64(
|
|
window
|
|
.get("remaining")
|
|
.or_else(|| window.get("remaining_value")),
|
|
)?;
|
|
let limit = provider_pool_json_f64(
|
|
window
|
|
.get("limit")
|
|
.or_else(|| window.get("limit_value"))
|
|
.or_else(|| window.get("total")),
|
|
)?;
|
|
(limit > 0.0).then_some(1.0 - remaining / limit)
|
|
});
|
|
used_ratio.is_some_and(|used_ratio| used_ratio >= 1.0 - reserve_ratio)
|
|
}
|
|
|
|
fn provider_pool_window_is_generic(window: &Map<String, Value>) -> bool {
|
|
if provider_pool_window_has_explicit_model(window)
|
|
|| window
|
|
.get("scope")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|scope| scope.trim().eq_ignore_ascii_case("model"))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let code = window
|
|
.get("code")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default();
|
|
let family = code
|
|
.split_once(['_', ':', '/'])
|
|
.map(|(prefix, _)| prefix)
|
|
.unwrap_or(code)
|
|
.trim()
|
|
.to_ascii_lowercase();
|
|
if family.is_empty() {
|
|
return window
|
|
.get("scope")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|scope| scope.trim().eq_ignore_ascii_case("account"));
|
|
}
|
|
[
|
|
"weekly",
|
|
"5h",
|
|
"daily",
|
|
"monthly",
|
|
"primary",
|
|
"secondary",
|
|
"account",
|
|
"quota",
|
|
"window",
|
|
"rate",
|
|
"reset",
|
|
]
|
|
.contains(&family.as_str())
|
|
}
|
|
|
|
fn provider_pool_window_explicitly_matches_model(
|
|
window: &Map<String, Value>,
|
|
requested_model: &str,
|
|
) -> bool {
|
|
let requested = provider_pool_normalize_identifier(requested_model);
|
|
let requested_tokens = provider_pool_identifier_tokens(requested_model);
|
|
if requested.is_empty() {
|
|
return false;
|
|
}
|
|
[
|
|
"model",
|
|
"model_name",
|
|
"model_id",
|
|
"quota_model",
|
|
"quota_model_name",
|
|
"target_model",
|
|
"limit_name",
|
|
]
|
|
.iter()
|
|
.filter_map(|key| window.get(*key))
|
|
.any(|value| match value {
|
|
Value::String(value) => {
|
|
provider_pool_identifiers_match(&requested, value, &requested_tokens)
|
|
}
|
|
Value::Array(values) => values
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.any(|value| provider_pool_identifiers_match(&requested, value, &requested_tokens)),
|
|
_ => false,
|
|
}) || ["models", "model_ids"]
|
|
.iter()
|
|
.filter_map(|key| window.get(*key).and_then(Value::as_array))
|
|
.flatten()
|
|
.filter_map(Value::as_str)
|
|
.any(|value| provider_pool_identifiers_match(&requested, value, &requested_tokens))
|
|
}
|
|
|
|
fn provider_pool_window_family_matches_model(
|
|
window: &Map<String, Value>,
|
|
requested_tokens: &std::collections::BTreeSet<String>,
|
|
) -> bool {
|
|
let explicit_scope = window
|
|
.get("scope")
|
|
.and_then(Value::as_str)
|
|
.map(|scope| scope.trim().to_ascii_lowercase());
|
|
// A model-scoped window without an explicit model must not accidentally
|
|
// match a token from its opaque code.
|
|
if explicit_scope.as_deref() == Some("model") || provider_pool_window_has_explicit_model(window)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let mut families = Vec::new();
|
|
for key in ["quota_group", "quota_family", "family", "bucket"] {
|
|
if let Some(value) = window.get(key).and_then(Value::as_str) {
|
|
families.push(value.to_string());
|
|
}
|
|
}
|
|
if let Some(code) = window.get("code").and_then(Value::as_str) {
|
|
let code = code.trim();
|
|
if let Some((prefix, _)) = code.split_once(['_', ':', '/']) {
|
|
families.push(prefix.to_string());
|
|
}
|
|
}
|
|
families.into_iter().any(|family| {
|
|
let normalized = provider_pool_normalize_identifier(&family);
|
|
if normalized.is_empty()
|
|
|| [
|
|
"account",
|
|
"quota",
|
|
"window",
|
|
"primary",
|
|
"secondary",
|
|
"rate",
|
|
"reset",
|
|
]
|
|
.iter()
|
|
.any(|generic| normalized == *generic)
|
|
{
|
|
return false;
|
|
}
|
|
requested_tokens.iter().any(|token| {
|
|
token.len() >= 3
|
|
&& (token == &normalized
|
|
|| token.contains(&normalized)
|
|
|| normalized.contains(token))
|
|
})
|
|
})
|
|
}
|
|
|
|
fn provider_pool_identifiers_match(
|
|
requested: &str,
|
|
candidate: &str,
|
|
requested_tokens: &std::collections::BTreeSet<String>,
|
|
) -> bool {
|
|
let candidate_tokens = provider_pool_identifier_tokens(candidate);
|
|
let candidate = provider_pool_normalize_identifier(candidate);
|
|
if candidate.is_empty() {
|
|
return false;
|
|
}
|
|
requested == candidate
|
|
|| candidate_tokens
|
|
.iter()
|
|
.any(|token| {
|
|
requested_tokens.contains(token) && provider_pool_is_specific_model_token(token)
|
|
})
|
|
// Handle compact upstream identifiers such as `spark` embedded in a
|
|
// provider model name (`vendor-codex-spark`) while avoiding accidental
|
|
// one/two-character matches.
|
|
|| (candidate.len() >= 4
|
|
&& requested.len() >= 6
|
|
&& (requested.contains(&candidate) || candidate.contains(requested)))
|
|
}
|
|
|
|
fn provider_pool_is_specific_model_token(token: &str) -> bool {
|
|
token.len() >= 4
|
|
&& !token.chars().all(|character| character.is_ascii_digit())
|
|
&& ![
|
|
"auto",
|
|
"base",
|
|
"claude",
|
|
"codex",
|
|
"default",
|
|
"fast",
|
|
"flash",
|
|
"free",
|
|
"gemini",
|
|
"gpt",
|
|
"latest",
|
|
"mini",
|
|
"model",
|
|
"plus",
|
|
"pro",
|
|
"reasoning",
|
|
"team",
|
|
"think",
|
|
"thinking",
|
|
"tiered",
|
|
"vendor",
|
|
]
|
|
.contains(&token)
|
|
}
|
|
|
|
fn provider_pool_window_has_explicit_model(window: &Map<String, Value>) -> bool {
|
|
[
|
|
"model",
|
|
"model_name",
|
|
"model_id",
|
|
"quota_model",
|
|
"quota_model_name",
|
|
"target_model",
|
|
"models",
|
|
"model_ids",
|
|
]
|
|
.iter()
|
|
.any(|key| match window.get(*key) {
|
|
Some(Value::String(value)) => !value.trim().is_empty(),
|
|
Some(Value::Array(values)) => values
|
|
.iter()
|
|
.any(|value| value.as_str().is_some_and(|value| !value.trim().is_empty())),
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
fn provider_pool_normalize_identifier(value: &str) -> String {
|
|
value
|
|
.trim()
|
|
.to_ascii_lowercase()
|
|
.chars()
|
|
.filter(|character| character.is_ascii_alphanumeric())
|
|
.collect()
|
|
}
|
|
|
|
fn provider_pool_identifier_tokens(value: &str) -> std::collections::BTreeSet<String> {
|
|
value
|
|
.split(|character: char| !character.is_ascii_alphanumeric())
|
|
.map(|token| token.trim().to_ascii_lowercase())
|
|
.filter(|token| token.len() >= 3)
|
|
.collect()
|
|
}
|
|
|
|
/// Materialize quota windows from the small set of shapes emitted by current
|
|
/// and legacy adapters. Model maps (`quota_by_model`/`models`) are converted
|
|
/// to the same window representation used by status snapshots, with the map
|
|
/// key retained as the model identity. Keeping this normalization here means
|
|
/// provider adapters do not need to grow model-specific quota code whenever an
|
|
/// upstream introduces another independent bucket.
|
|
fn provider_pool_collect_quota_windows(source: &Map<String, Value>) -> Vec<Map<String, Value>> {
|
|
let mut windows = Vec::new();
|
|
for key in ["windows", "additional_quota_windows"] {
|
|
if let Some(values) = source.get(key).and_then(Value::as_array) {
|
|
windows.extend(values.iter().filter_map(Value::as_object).cloned());
|
|
}
|
|
}
|
|
for key in ["quota_by_model", "models", "model_quotas"] {
|
|
let Some(models) = source.get(key).and_then(Value::as_object) else {
|
|
continue;
|
|
};
|
|
for (model_name, item) in models {
|
|
let Some(item) = item.as_object() else {
|
|
continue;
|
|
};
|
|
let mut window = item.clone();
|
|
window
|
|
.entry("model".to_string())
|
|
.or_insert_with(|| json!(model_name));
|
|
window
|
|
.entry("scope".to_string())
|
|
.or_insert_with(|| json!("model"));
|
|
window
|
|
.entry("code".to_string())
|
|
.or_insert_with(|| json!(format!("model:{model_name}")));
|
|
windows.push(window);
|
|
}
|
|
}
|
|
windows
|
|
}
|
|
|
|
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 = match provider_pool_json_f64(value) {
|
|
Some(timestamp) => timestamp,
|
|
None => {
|
|
return chrono::DateTime::parse_from_rfc3339(value?.as_str()?.trim())
|
|
.ok()?
|
|
.timestamp()
|
|
.try_into()
|
|
.ok();
|
|
}
|
|
};
|
|
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))
|
|
})
|
|
}
|
|
|
|
/// Whether the quota window's reset deadline has already passed.
|
|
///
|
|
/// The deadline is taken from `reset_at`/`next_reset_at` when present, otherwise
|
|
/// derived from `reset_seconds`/`reset_after_seconds` anchored at the window (or
|
|
/// fallback) observation time. Scheduling ignores exhausted windows once this is
|
|
/// true; read paths can reuse the same predicate so the displayed quota matches
|
|
/// the scheduling decision after a reset.
|
|
pub 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_bool(window.get("exhausted")))
|
|
.or_else(|| {
|
|
provider_pool_json_f64(
|
|
window
|
|
.get("used_ratio")
|
|
.or_else(|| window.get("usage_ratio")),
|
|
)
|
|
.map(|value| value >= 1.0 - 1e-6)
|
|
})
|
|
.or_else(|| {
|
|
provider_pool_json_f64(window.get("used_percent")).map(|value| value >= 100.0 - 1e-6)
|
|
})
|
|
.or_else(|| {
|
|
provider_pool_json_f64(
|
|
window
|
|
.get("remaining_ratio")
|
|
.or_else(|| window.get("remaining_fraction")),
|
|
)
|
|
.map(|value| value <= 1e-6)
|
|
})
|
|
.or_else(|| {
|
|
provider_pool_json_f64(window.get("remaining_percent")).map(|value| value <= 1e-6)
|
|
})
|
|
.or_else(|| {
|
|
let remaining = provider_pool_json_f64(
|
|
window
|
|
.get("remaining")
|
|
.or_else(|| window.get("remaining_value")),
|
|
)?;
|
|
let limit = provider_pool_json_f64(
|
|
window
|
|
.get("limit")
|
|
.or_else(|| window.get("limit_value"))
|
|
.or_else(|| window.get("total")),
|
|
)?;
|
|
(limit > 0.0).then_some(remaining <= 0.0)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn provider_pool_window_is_model_scoped(window: &Map<String, Value>) -> bool {
|
|
window
|
|
.get("scope")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|scope| scope.trim().eq_ignore_ascii_case("model"))
|
|
|| provider_pool_window_has_explicit_model(window)
|
|
}
|
|
|
|
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("additional_quota_windows")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|windows| !windows.is_empty())
|
|
|| ["quota_by_model", "models", "model_quotas"]
|
|
.iter()
|
|
.any(|key| {
|
|
quota_snapshot
|
|
.get(*key)
|
|
.and_then(Value::as_object)
|
|
.is_some_and(|models| !models.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")));
|
|
|
|
let materialized_windows = provider_pool_collect_quota_windows(quota_snapshot);
|
|
if !materialized_windows.is_empty() {
|
|
// Model-scoped windows are evaluated only when the request model
|
|
// is known. They must not turn the account-level fallback into
|
|
// an exhausted state for unrelated models.
|
|
let account_scoped_windows = materialized_windows
|
|
.iter()
|
|
.filter(|window| !provider_pool_window_is_model_scoped(window))
|
|
.collect::<Vec<_>>();
|
|
// A snapshot containing only model-scoped buckets has no
|
|
// account-wide signal to apply when the caller did not provide a
|
|
// model name (for example, an admin status listing). Do not let a
|
|
// single exhausted model poison every sibling bucket.
|
|
if account_scoped_windows.is_empty() {
|
|
return Some(false);
|
|
}
|
|
let windows = account_scoped_windows;
|
|
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() {
|
|
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))
|
|
})
|
|
}
|