mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
2410 lines
81 KiB
Rust
2410 lines
81 KiB
Rust
use crate::handlers::shared::{json_string_list, unix_secs_to_rfc3339};
|
|
use crate::provider_key_auth::{
|
|
provider_key_auth_semantics, provider_key_configured_api_formats,
|
|
provider_key_inherits_provider_api_formats,
|
|
};
|
|
use crate::AppState;
|
|
use aether_admin::provider::quota as admin_provider_quota_pure;
|
|
use aether_admin::provider::status as admin_provider_status_pure;
|
|
#[cfg(test)]
|
|
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
|
use aether_crypto::{decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext};
|
|
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
|
use serde_json::{json, Map, Value};
|
|
use std::borrow::Cow;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
const OAUTH_ACCOUNT_BLOCK_PREFIX: &str = "[ACCOUNT_BLOCK] ";
|
|
const OAUTH_EXPIRED_PREFIX: &str = "[OAUTH_EXPIRED] ";
|
|
const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] ";
|
|
const OAUTH_REQUEST_FAILED_PREFIX: &str = "[REQUEST_FAILED] ";
|
|
|
|
pub(crate) fn provider_catalog_key_supports_format(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
api_format: &str,
|
|
) -> bool {
|
|
if provider_key_inherits_provider_api_formats(key, provider_type) {
|
|
return true;
|
|
}
|
|
let formats = provider_key_configured_api_formats(key);
|
|
if formats.is_empty() {
|
|
return true;
|
|
}
|
|
formats
|
|
.iter()
|
|
.any(|candidate| crate::ai_serving::api_format_alias_matches(candidate, api_format))
|
|
}
|
|
|
|
pub(crate) fn decrypt_catalog_secret_with_fallbacks(
|
|
encryption_key: Option<&str>,
|
|
ciphertext: &str,
|
|
) -> Option<String> {
|
|
let encryption_key = encryption_key.map(str::trim).unwrap_or("");
|
|
if !encryption_key.is_empty() {
|
|
if let Ok(value) = decrypt_python_fernet_ciphertext(encryption_key, ciphertext) {
|
|
return Some(value);
|
|
}
|
|
}
|
|
for env_key in ["AETHER_GATEWAY_DATA_ENCRYPTION_KEY", "ENCRYPTION_KEY"] {
|
|
let Ok(fallback) = std::env::var(env_key) else {
|
|
continue;
|
|
};
|
|
let fallback = fallback.trim();
|
|
if fallback.is_empty() || fallback == encryption_key {
|
|
continue;
|
|
}
|
|
if let Ok(value) = decrypt_python_fernet_ciphertext(fallback, ciphertext) {
|
|
return Some(value);
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
if encryption_key != DEVELOPMENT_ENCRYPTION_KEY {
|
|
if let Ok(value) = decrypt_python_fernet_ciphertext(DEVELOPMENT_ENCRYPTION_KEY, ciphertext)
|
|
{
|
|
return Some(value);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn effective_catalog_encryption_key(state: &AppState) -> Option<Cow<'_, str>> {
|
|
let encryption_key = state.encryption_key().map(str::trim).unwrap_or("");
|
|
if !encryption_key.is_empty() {
|
|
return Some(Cow::Borrowed(encryption_key));
|
|
}
|
|
for env_key in ["AETHER_GATEWAY_DATA_ENCRYPTION_KEY", "ENCRYPTION_KEY"] {
|
|
let Ok(candidate) = std::env::var(env_key) else {
|
|
continue;
|
|
};
|
|
let trimmed = candidate.trim();
|
|
if !trimmed.is_empty() {
|
|
return Some(if trimmed.len() == candidate.len() {
|
|
Cow::Owned(candidate)
|
|
} else {
|
|
Cow::Owned(trimmed.to_string())
|
|
});
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
{
|
|
return Some(Cow::Borrowed(DEVELOPMENT_ENCRYPTION_KEY));
|
|
}
|
|
#[allow(unreachable_code)]
|
|
None
|
|
}
|
|
|
|
pub(crate) fn encrypt_catalog_secret_with_fallbacks(
|
|
state: &AppState,
|
|
plaintext: &str,
|
|
) -> Option<String> {
|
|
let encryption_key = effective_catalog_encryption_key(state)?;
|
|
encrypt_python_fernet_plaintext(encryption_key.as_ref(), plaintext).ok()
|
|
}
|
|
|
|
pub(crate) fn take_secret_prefix(value: &str, prefix_chars: usize) -> &str {
|
|
let end = value
|
|
.char_indices()
|
|
.nth(prefix_chars)
|
|
.map(|(index, _)| index)
|
|
.unwrap_or(value.len());
|
|
&value[..end]
|
|
}
|
|
|
|
pub(crate) fn take_secret_suffix(value: &str, suffix_chars: usize) -> &str {
|
|
if suffix_chars == 0 {
|
|
return &value[value.len()..];
|
|
}
|
|
|
|
let start = value
|
|
.char_indices()
|
|
.rev()
|
|
.nth(suffix_chars - 1)
|
|
.map(|(index, _)| index)
|
|
.unwrap_or(0);
|
|
&value[start..]
|
|
}
|
|
|
|
pub(crate) fn masked_catalog_api_key(state: &AppState, key: &StoredProviderCatalogKey) -> String {
|
|
match key.auth_type.trim() {
|
|
"service_account" | "vertex_ai" => "[Service Account]".to_string(),
|
|
"oauth" => "[OAuth Token]".to_string(),
|
|
_ => {
|
|
let Some(ciphertext) = key
|
|
.encrypted_api_key
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return "[未设置]".to_string();
|
|
};
|
|
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
|
|
.map(|value| {
|
|
if value.chars().count() <= 12 {
|
|
format!("{value}***")
|
|
} else {
|
|
format!(
|
|
"{}***{}",
|
|
take_secret_prefix(&value, 8),
|
|
take_secret_suffix(&value, 4)
|
|
)
|
|
}
|
|
})
|
|
.unwrap_or_else(|| "***ERROR***".to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn parse_catalog_auth_config_json(
|
|
state: &AppState,
|
|
key: &StoredProviderCatalogKey,
|
|
) -> Option<serde_json::Map<String, serde_json::Value>> {
|
|
let ciphertext = key.encrypted_auth_config.as_deref()?.trim();
|
|
if ciphertext.is_empty() {
|
|
return None;
|
|
}
|
|
let plaintext = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)?;
|
|
serde_json::from_str::<serde_json::Value>(&plaintext)
|
|
.ok()?
|
|
.as_object()
|
|
.cloned()
|
|
}
|
|
|
|
pub(crate) fn default_provider_key_status_snapshot() -> serde_json::Value {
|
|
json!({
|
|
"oauth": {
|
|
"code": "none",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"expires_at": serde_json::Value::Null,
|
|
"invalid_at": serde_json::Value::Null,
|
|
"source": serde_json::Value::Null,
|
|
"requires_reauth": false,
|
|
"expiring_soon": false,
|
|
},
|
|
"account": {
|
|
"code": "ok",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"blocked": false,
|
|
"source": serde_json::Value::Null,
|
|
"recoverable": false,
|
|
},
|
|
"quota": {
|
|
"code": "unknown",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"exhausted": false,
|
|
"usage_ratio": serde_json::Value::Null,
|
|
"updated_at": serde_json::Value::Null,
|
|
"reset_seconds": serde_json::Value::Null,
|
|
"plan_type": serde_json::Value::Null,
|
|
}
|
|
})
|
|
}
|
|
|
|
fn default_oauth_status_snapshot_value() -> Value {
|
|
default_provider_key_status_snapshot()
|
|
.get("oauth")
|
|
.cloned()
|
|
.unwrap_or_else(|| {
|
|
json!({
|
|
"code": "none",
|
|
"label": Value::Null,
|
|
"reason": Value::Null,
|
|
"expires_at": Value::Null,
|
|
"invalid_at": Value::Null,
|
|
"source": Value::Null,
|
|
"requires_reauth": false,
|
|
"expiring_soon": false,
|
|
})
|
|
})
|
|
}
|
|
|
|
fn trimmed_oauth_invalid_reason(reason: Option<&str>) -> Option<String> {
|
|
reason
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn tagged_oauth_invalid_reason(reason: Option<&str>, prefix: &str) -> Option<String> {
|
|
reason.and_then(|value| {
|
|
value
|
|
.lines()
|
|
.map(str::trim)
|
|
.find_map(|line| line.strip_prefix(prefix))
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
})
|
|
}
|
|
|
|
fn oauth_access_token_expired(expires_at_unix_secs: Option<u64>, now_unix_secs: u64) -> bool {
|
|
expires_at_unix_secs.is_none_or(|expires_at| expires_at == 0 || expires_at <= now_unix_secs)
|
|
}
|
|
|
|
fn build_provider_key_oauth_status_snapshot(key: &StoredProviderCatalogKey) -> Value {
|
|
if !key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
|
return default_oauth_status_snapshot_value();
|
|
}
|
|
|
|
let now_unix_secs = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.ok()
|
|
.map(|duration| duration.as_secs())
|
|
.unwrap_or(0);
|
|
let expires_at_unix_secs = key.expires_at_unix_secs;
|
|
let invalid_at_unix_secs = key.oauth_invalid_at_unix_secs;
|
|
let invalid_reason = trimmed_oauth_invalid_reason(key.oauth_invalid_reason.as_deref());
|
|
|
|
if let Some(reason) =
|
|
tagged_oauth_invalid_reason(invalid_reason.as_deref(), OAUTH_EXPIRED_PREFIX)
|
|
{
|
|
return json!({
|
|
"code": "invalid",
|
|
"label": "已失效",
|
|
"reason": reason,
|
|
"expires_at": expires_at_unix_secs,
|
|
"invalid_at": invalid_at_unix_secs,
|
|
"source": "oauth_invalid",
|
|
"requires_reauth": true,
|
|
"expiring_soon": false,
|
|
});
|
|
}
|
|
if let Some(reason) =
|
|
tagged_oauth_invalid_reason(invalid_reason.as_deref(), OAUTH_REFRESH_FAILED_PREFIX)
|
|
{
|
|
let access_token_expired = oauth_access_token_expired(expires_at_unix_secs, now_unix_secs);
|
|
return json!({
|
|
"code": if access_token_expired { "invalid" } else { "reauth_required" },
|
|
"label": if access_token_expired { "已失效" } else { "续期失败" },
|
|
"reason": reason,
|
|
"expires_at": expires_at_unix_secs,
|
|
"invalid_at": invalid_at_unix_secs,
|
|
"source": "oauth_refresh",
|
|
"requires_reauth": true,
|
|
"usable_until_expiry": !access_token_expired,
|
|
"expiring_soon": false,
|
|
});
|
|
}
|
|
if let Some(reason) =
|
|
tagged_oauth_invalid_reason(invalid_reason.as_deref(), OAUTH_REQUEST_FAILED_PREFIX)
|
|
{
|
|
return json!({
|
|
"code": "check_failed",
|
|
"label": "检查失败",
|
|
"reason": reason,
|
|
"expires_at": expires_at_unix_secs,
|
|
"invalid_at": Value::Null,
|
|
"source": "oauth_request",
|
|
"requires_reauth": false,
|
|
"expiring_soon": false,
|
|
});
|
|
}
|
|
if invalid_reason
|
|
.as_deref()
|
|
.is_some_and(|reason| !reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX))
|
|
|| invalid_at_unix_secs.is_some()
|
|
{
|
|
return json!({
|
|
"code": "invalid",
|
|
"label": "已失效",
|
|
"reason": invalid_reason,
|
|
"expires_at": expires_at_unix_secs,
|
|
"invalid_at": invalid_at_unix_secs,
|
|
"source": "oauth_invalid",
|
|
"requires_reauth": true,
|
|
"expiring_soon": false,
|
|
});
|
|
}
|
|
|
|
let Some(expires_at_unix_secs) = expires_at_unix_secs else {
|
|
return default_oauth_status_snapshot_value();
|
|
};
|
|
if expires_at_unix_secs <= now_unix_secs {
|
|
return json!({
|
|
"code": "expired",
|
|
"label": "已过期",
|
|
"reason": "Access Token 已过期,等待自动续期",
|
|
"expires_at": expires_at_unix_secs,
|
|
"invalid_at": Value::Null,
|
|
"source": "expires_at",
|
|
"requires_reauth": false,
|
|
"expiring_soon": false,
|
|
});
|
|
}
|
|
|
|
let expiring_soon = expires_at_unix_secs.saturating_sub(now_unix_secs) < 24 * 60 * 60;
|
|
json!({
|
|
"code": if expiring_soon { "expiring" } else { "valid" },
|
|
"label": if expiring_soon { "即将过期" } else { "有效" },
|
|
"reason": Value::Null,
|
|
"expires_at": expires_at_unix_secs,
|
|
"invalid_at": Value::Null,
|
|
"source": "expires_at",
|
|
"requires_reauth": false,
|
|
"expiring_soon": expiring_soon,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn sync_provider_key_oauth_status_snapshot(
|
|
status_snapshot: Option<&Value>,
|
|
key: &StoredProviderCatalogKey,
|
|
) -> Option<Value> {
|
|
let mut snapshot = provider_key_status_snapshot_object(status_snapshot)
|
|
.or_else(|| default_provider_key_status_snapshot().as_object().cloned())
|
|
.unwrap_or_default();
|
|
snapshot.insert(
|
|
"oauth".to_string(),
|
|
build_provider_key_oauth_status_snapshot(key),
|
|
);
|
|
Some(Value::Object(snapshot))
|
|
}
|
|
|
|
fn build_provider_key_account_status_snapshot(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
) -> Value {
|
|
let snapshot = admin_provider_status_pure::resolve_account_status_snapshot(
|
|
Some(provider_type),
|
|
key.upstream_metadata.as_ref(),
|
|
key.oauth_invalid_reason.as_deref(),
|
|
);
|
|
json!({
|
|
"code": snapshot.code,
|
|
"label": snapshot.label,
|
|
"reason": snapshot.reason,
|
|
"blocked": snapshot.blocked,
|
|
"source": snapshot.source,
|
|
"recoverable": snapshot.recoverable,
|
|
})
|
|
}
|
|
|
|
fn provider_key_status_snapshot_object(
|
|
status_snapshot: Option<&Value>,
|
|
) -> Option<Map<String, Value>> {
|
|
status_snapshot.and_then(|value| match value {
|
|
Value::Object(object) => Some(object.clone()),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn provider_quota_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)
|
|
}
|
|
|
|
fn provider_quota_timestamp_unix_secs(value: Option<&Value>) -> Option<u64> {
|
|
let mut parsed = match value {
|
|
Some(Value::Number(number)) => number.as_f64(),
|
|
Some(Value::String(text)) => text.trim().parse::<f64>().ok(),
|
|
_ => None,
|
|
}?;
|
|
if !parsed.is_finite() || parsed <= 0.0 {
|
|
return None;
|
|
}
|
|
if parsed > 1_000_000_000_000.0 {
|
|
parsed /= 1000.0;
|
|
}
|
|
Some(parsed.floor() as u64)
|
|
}
|
|
|
|
fn provider_quota_model_bucket(metadata: &Map<String, Value>) -> Option<&Map<String, Value>> {
|
|
metadata
|
|
.get("quota_by_model")
|
|
.or_else(|| metadata.get("models"))
|
|
.and_then(Value::as_object)
|
|
}
|
|
|
|
fn quota_window_reset_seconds(
|
|
observed_at_unix_secs: Option<u64>,
|
|
reset_at_unix_secs: Option<u64>,
|
|
) -> Option<u64> {
|
|
observed_at_unix_secs
|
|
.zip(reset_at_unix_secs)
|
|
.map(|(observed_at, reset_at)| reset_at.saturating_sub(observed_at))
|
|
}
|
|
|
|
fn chatgpt_web_image_quota_limit(
|
|
metadata: &Map<String, Value>,
|
|
remaining: Option<f64>,
|
|
) -> Option<f64> {
|
|
let plan_type = metadata
|
|
.get("plan_type")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.to_ascii_lowercase());
|
|
if plan_type.as_deref() == Some("free") {
|
|
return Some(25.0);
|
|
}
|
|
|
|
let explicit_limit = metadata
|
|
.get("image_quota_total")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
|
.filter(|value| *value > 0.0);
|
|
if let Some(limit) = explicit_limit {
|
|
return Some(limit);
|
|
}
|
|
|
|
remaining.filter(|value| *value > 0.0)
|
|
}
|
|
|
|
fn model_quota_window_snapshot(
|
|
model_name: &str,
|
|
item: &Map<String, Value>,
|
|
observed_at_unix_secs: Option<u64>,
|
|
) -> Option<Value> {
|
|
let used_ratio = item
|
|
.get("used_percent")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
|
.map(|value| (value / 100.0).clamp(0.0, 1.0))
|
|
.or_else(|| {
|
|
item.get("remaining_fraction")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
|
.map(|value| (1.0 - value.clamp(0.0, 1.0)).clamp(0.0, 1.0))
|
|
});
|
|
let remaining_ratio = item
|
|
.get("remaining_fraction")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
|
.map(|value| value.clamp(0.0, 1.0))
|
|
.or_else(|| used_ratio.map(|value| (1.0 - value).max(0.0)));
|
|
let reset_at = provider_quota_timestamp_unix_secs(
|
|
item.get("reset_at").or_else(|| item.get("next_reset_at")),
|
|
);
|
|
let reset_seconds = quota_window_reset_seconds(observed_at_unix_secs, reset_at);
|
|
let is_exhausted = item
|
|
.get("is_exhausted")
|
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
|
.or_else(|| used_ratio.map(|value| value >= 1.0 - 1e-6));
|
|
|
|
if used_ratio.is_none()
|
|
&& remaining_ratio.is_none()
|
|
&& reset_at.is_none()
|
|
&& reset_seconds.is_none()
|
|
&& is_exhausted.is_none()
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let mut window = Map::new();
|
|
let label = item
|
|
.get("display_name")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or(model_name);
|
|
window.insert("code".to_string(), json!(format!("model:{model_name}")));
|
|
window.insert("label".to_string(), json!(label));
|
|
window.insert("scope".to_string(), json!("model"));
|
|
window.insert("unit".to_string(), json!("percent"));
|
|
window.insert("model".to_string(), json!(model_name));
|
|
window.insert("used_ratio".to_string(), json!(used_ratio));
|
|
window.insert("remaining_ratio".to_string(), json!(remaining_ratio));
|
|
window.insert("reset_at".to_string(), json!(reset_at));
|
|
window.insert("reset_seconds".to_string(), json!(reset_seconds));
|
|
window.insert("is_exhausted".to_string(), json!(is_exhausted));
|
|
Some(Value::Object(window))
|
|
}
|
|
|
|
fn quota_windows_usage_ratio(windows: &[Value]) -> Option<f64> {
|
|
windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.filter_map(|window| window.get("used_ratio"))
|
|
.filter_map(Value::as_f64)
|
|
.max_by(f64::total_cmp)
|
|
}
|
|
|
|
fn quota_windows_min_reset_seconds(windows: &[Value]) -> Option<u64> {
|
|
windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.filter_map(|window| window.get("reset_seconds"))
|
|
.filter_map(admin_provider_quota_pure::coerce_json_u64)
|
|
.min()
|
|
}
|
|
|
|
fn quota_windows_min_reset_at(windows: &[Value]) -> Option<u64> {
|
|
windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.filter_map(|window| window.get("reset_at"))
|
|
.filter_map(|value| provider_quota_timestamp_unix_secs(Some(value)))
|
|
.min()
|
|
}
|
|
|
|
fn quota_windows_all_exhausted(windows: &[Value]) -> bool {
|
|
let mut total = 0usize;
|
|
let mut exhausted = 0usize;
|
|
for window in windows.iter().filter_map(Value::as_object) {
|
|
total += 1;
|
|
let is_exhausted = window
|
|
.get("is_exhausted")
|
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
|
.or_else(|| {
|
|
window
|
|
.get("used_ratio")
|
|
.and_then(Value::as_f64)
|
|
.map(|value| value >= 1.0 - 1e-6)
|
|
})
|
|
.unwrap_or(false);
|
|
if is_exhausted {
|
|
exhausted += 1;
|
|
}
|
|
}
|
|
total > 0 && exhausted == total
|
|
}
|
|
|
|
fn preserve_quota_window_usage_state(current_status_snapshot: Option<&Value>, quota: &mut Value) {
|
|
let Some(current_windows) = current_status_snapshot
|
|
.and_then(Value::as_object)
|
|
.and_then(|snapshot| snapshot.get("quota"))
|
|
.and_then(Value::as_object)
|
|
.and_then(|quota| quota.get("windows"))
|
|
.and_then(Value::as_array)
|
|
else {
|
|
return;
|
|
};
|
|
let Some(next_windows) = quota.get_mut("windows").and_then(Value::as_array_mut) else {
|
|
return;
|
|
};
|
|
|
|
for next_window in next_windows.iter_mut().filter_map(Value::as_object_mut) {
|
|
let Some(code) = next_window
|
|
.get("code")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|code| !code.is_empty())
|
|
else {
|
|
continue;
|
|
};
|
|
let current_window =
|
|
current_windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.find(|current_window| {
|
|
current_window
|
|
.get("code")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.is_some_and(|current_code| current_code.eq_ignore_ascii_case(code))
|
|
});
|
|
let Some(current_window) = current_window else {
|
|
continue;
|
|
};
|
|
let current_reset_at = current_window
|
|
.get("reset_at")
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
|
let next_reset_at = next_window
|
|
.get("reset_at")
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
|
if current_reset_at.is_none() || current_reset_at != next_reset_at {
|
|
continue;
|
|
}
|
|
|
|
if next_window
|
|
.get("window_minutes")
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
|
.is_none()
|
|
{
|
|
if let Some(window_minutes) = current_window
|
|
.get("window_minutes")
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
|
.or_else(|| codex_default_window_minutes(code))
|
|
{
|
|
next_window.insert("window_minutes".to_string(), json!(window_minutes));
|
|
}
|
|
}
|
|
if let Some(usage_reset_at) = current_window
|
|
.get("usage_reset_at")
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
|
{
|
|
next_window.insert("usage_reset_at".to_string(), json!(usage_reset_at));
|
|
}
|
|
if let Some(usage) = current_window.get("usage") {
|
|
next_window.insert("usage".to_string(), usage.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn codex_default_window_minutes(code: &str) -> Option<u64> {
|
|
if code.eq_ignore_ascii_case("5h") {
|
|
Some(300)
|
|
} else if code.eq_ignore_ascii_case("weekly") {
|
|
Some(10_080)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn codex_quota_window_snapshot(
|
|
metadata: &Map<String, Value>,
|
|
prefix: &str,
|
|
code: &str,
|
|
label: &str,
|
|
observed_at_unix_secs: Option<u64>,
|
|
) -> Option<Value> {
|
|
let used_percent_key = format!("{prefix}_used_percent");
|
|
let reset_seconds_key = format!("{prefix}_reset_seconds");
|
|
let reset_after_seconds_key = format!("{prefix}_reset_after_seconds");
|
|
let reset_at_key = format!("{prefix}_reset_at");
|
|
let window_minutes_key = format!("{prefix}_window_minutes");
|
|
|
|
let used_percent = metadata
|
|
.get(&used_percent_key)
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
|
let explicit_reset_at = metadata
|
|
.get(&reset_at_key)
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
|
let reset_seconds = metadata
|
|
.get(&reset_seconds_key)
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
|
.or_else(|| {
|
|
metadata
|
|
.get(&reset_after_seconds_key)
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64)
|
|
})
|
|
.or_else(|| {
|
|
observed_at_unix_secs
|
|
.zip(explicit_reset_at)
|
|
.map(|(observed_at, reset_at)| reset_at.saturating_sub(observed_at))
|
|
});
|
|
let reset_at = explicit_reset_at.or_else(|| {
|
|
observed_at_unix_secs
|
|
.zip(reset_seconds)
|
|
.map(|(observed_at, reset_seconds)| observed_at.saturating_add(reset_seconds))
|
|
});
|
|
let explicit_window_minutes = metadata
|
|
.get(&window_minutes_key)
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
|
|
|
if used_percent.is_none()
|
|
&& reset_at.is_none()
|
|
&& reset_seconds.is_none()
|
|
&& explicit_window_minutes.is_none()
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let window_minutes = explicit_window_minutes.or_else(|| codex_default_window_minutes(code));
|
|
let used_ratio = used_percent.map(|value| (value / 100.0).clamp(0.0, 1.0));
|
|
let remaining_ratio = used_ratio.map(|value| (1.0 - value).max(0.0));
|
|
|
|
let mut window = Map::new();
|
|
window.insert("code".to_string(), json!(code));
|
|
window.insert("label".to_string(), json!(label));
|
|
window.insert("scope".to_string(), json!("account"));
|
|
window.insert("unit".to_string(), json!("percent"));
|
|
window.insert("used_ratio".to_string(), json!(used_ratio));
|
|
window.insert("remaining_ratio".to_string(), json!(remaining_ratio));
|
|
window.insert("reset_at".to_string(), json!(reset_at));
|
|
window.insert("reset_seconds".to_string(), json!(reset_seconds));
|
|
window.insert("window_minutes".to_string(), json!(window_minutes));
|
|
Some(Value::Object(window))
|
|
}
|
|
|
|
fn build_codex_quota_status_snapshot(
|
|
upstream_metadata: Option<&Value>,
|
|
source: &str,
|
|
) -> Option<Value> {
|
|
let metadata = provider_quota_metadata_bucket(upstream_metadata, "codex")?;
|
|
let observed_at_unix_secs = metadata
|
|
.get("updated_at")
|
|
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
|
let plan_type = metadata
|
|
.get("plan_type")
|
|
.and_then(Value::as_str)
|
|
.and_then(|value| admin_provider_quota_pure::normalize_codex_plan_type(Some(value)));
|
|
let credits_has_credits = metadata
|
|
.get("has_credits")
|
|
.and_then(admin_provider_quota_pure::coerce_json_bool);
|
|
let credits_balance = metadata
|
|
.get("credits_balance")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
|
let credits_unlimited = metadata
|
|
.get("credits_unlimited")
|
|
.and_then(admin_provider_quota_pure::coerce_json_bool);
|
|
|
|
let windows = [
|
|
codex_quota_window_snapshot(metadata, "primary", "weekly", "周", observed_at_unix_secs),
|
|
codex_quota_window_snapshot(metadata, "secondary", "5h", "5H", observed_at_unix_secs),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect::<Vec<_>>();
|
|
|
|
if windows.is_empty()
|
|
&& plan_type.is_none()
|
|
&& credits_has_credits.is_none()
|
|
&& credits_balance.is_none()
|
|
&& credits_unlimited.is_none()
|
|
&& observed_at_unix_secs.is_none()
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let usage_ratio = windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.filter_map(|window| window.get("used_ratio"))
|
|
.filter_map(Value::as_f64)
|
|
.max_by(f64::total_cmp);
|
|
let reset_seconds = windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.filter_map(|window| window.get("reset_seconds"))
|
|
.filter_map(admin_provider_quota_pure::coerce_json_u64)
|
|
.min();
|
|
let reset_at = quota_windows_min_reset_at(&windows);
|
|
let exhausted_by_credits =
|
|
windows.is_empty() && credits_unlimited != Some(true) && credits_has_credits == Some(false);
|
|
let exhausted_by_window = usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6);
|
|
let exhausted = exhausted_by_credits || exhausted_by_window;
|
|
|
|
let mut credits = Map::new();
|
|
if let Some(value) = credits_has_credits {
|
|
credits.insert("has_credits".to_string(), json!(value));
|
|
}
|
|
if let Some(value) = credits_balance {
|
|
credits.insert("balance".to_string(), json!(value));
|
|
}
|
|
if let Some(value) = credits_unlimited {
|
|
credits.insert("unlimited".to_string(), json!(value));
|
|
}
|
|
|
|
let reason = if exhausted_by_credits {
|
|
Some("无可用积分")
|
|
} else if exhausted_by_window {
|
|
Some("额度窗口已耗尽")
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Some(json!({
|
|
"version": 2,
|
|
"provider_type": "codex",
|
|
"code": if exhausted { "exhausted" } else { "ok" },
|
|
"label": if exhausted { Some("额度耗尽") } else { None::<&str> },
|
|
"reason": reason,
|
|
"freshness": "fresh",
|
|
"source": source,
|
|
"observed_at": observed_at_unix_secs,
|
|
"exhausted": exhausted,
|
|
"usage_ratio": usage_ratio,
|
|
"updated_at": observed_at_unix_secs,
|
|
"reset_at": reset_at,
|
|
"reset_seconds": reset_seconds,
|
|
"plan_type": plan_type,
|
|
"credits": if credits.is_empty() {
|
|
Value::Null
|
|
} else {
|
|
Value::Object(credits)
|
|
},
|
|
"windows": windows,
|
|
}))
|
|
}
|
|
|
|
fn build_kiro_quota_status_snapshot(
|
|
upstream_metadata: Option<&Value>,
|
|
source: &str,
|
|
) -> Option<Value> {
|
|
let metadata = provider_quota_metadata_bucket(upstream_metadata, "kiro")?;
|
|
let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at"));
|
|
let usage_limit = metadata
|
|
.get("usage_limit")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
|
let current_usage = metadata
|
|
.get("current_usage")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
|
let remaining = metadata
|
|
.get("remaining")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
|
let usage_ratio = metadata
|
|
.get("usage_percentage")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
|
.map(|value| (value / 100.0).clamp(0.0, 1.0))
|
|
.or_else(|| {
|
|
current_usage
|
|
.zip(usage_limit)
|
|
.and_then(|(current_usage, usage_limit)| {
|
|
(usage_limit > 0.0).then_some((current_usage / usage_limit).clamp(0.0, 1.0))
|
|
})
|
|
});
|
|
let remaining_ratio = usage_ratio.map(|value| (1.0 - value).max(0.0));
|
|
let next_reset_at = provider_quota_timestamp_unix_secs(metadata.get("next_reset_at"));
|
|
let reset_seconds = quota_window_reset_seconds(observed_at_unix_secs, next_reset_at);
|
|
let plan_type = metadata
|
|
.get("subscription_title")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned);
|
|
let is_banned = metadata
|
|
.get("is_banned")
|
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
|
== Some(true);
|
|
let ban_reason = metadata
|
|
.get("ban_reason")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned);
|
|
|
|
let mut windows = Vec::new();
|
|
if usage_ratio.is_some()
|
|
|| remaining.is_some()
|
|
|| usage_limit.is_some()
|
|
|| current_usage.is_some()
|
|
|| next_reset_at.is_some()
|
|
{
|
|
windows.push(json!({
|
|
"code": "usage",
|
|
"label": "额度",
|
|
"scope": "account",
|
|
"unit": "count",
|
|
"used_ratio": usage_ratio,
|
|
"remaining_ratio": remaining_ratio,
|
|
"used_value": current_usage,
|
|
"remaining_value": remaining,
|
|
"limit_value": usage_limit,
|
|
"reset_at": next_reset_at,
|
|
"reset_seconds": reset_seconds,
|
|
}));
|
|
}
|
|
|
|
if windows.is_empty() && plan_type.is_none() && observed_at_unix_secs.is_none() && !is_banned {
|
|
return None;
|
|
}
|
|
|
|
let exhausted = !is_banned
|
|
&& (remaining.is_some_and(|value| value <= 0.0)
|
|
|| usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6));
|
|
let reason = if is_banned {
|
|
ban_reason
|
|
} else if exhausted {
|
|
Some("额度已耗尽".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
let label = if is_banned {
|
|
Some("账号已封禁")
|
|
} else if exhausted {
|
|
Some("额度耗尽")
|
|
} else {
|
|
None
|
|
};
|
|
let code = if is_banned {
|
|
"banned"
|
|
} else if exhausted {
|
|
"exhausted"
|
|
} else {
|
|
"ok"
|
|
};
|
|
|
|
Some(json!({
|
|
"version": 2,
|
|
"provider_type": "kiro",
|
|
"code": code,
|
|
"label": label,
|
|
"reason": reason,
|
|
"freshness": "fresh",
|
|
"source": source,
|
|
"observed_at": observed_at_unix_secs,
|
|
"exhausted": exhausted,
|
|
"usage_ratio": usage_ratio,
|
|
"updated_at": observed_at_unix_secs,
|
|
"reset_at": next_reset_at,
|
|
"reset_seconds": reset_seconds,
|
|
"plan_type": plan_type,
|
|
"windows": windows,
|
|
}))
|
|
}
|
|
|
|
fn build_chatgpt_web_quota_status_snapshot(
|
|
upstream_metadata: Option<&Value>,
|
|
source: &str,
|
|
) -> Option<Value> {
|
|
let metadata = provider_quota_metadata_bucket(upstream_metadata, "chatgpt_web")?;
|
|
let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at"));
|
|
let remaining = metadata
|
|
.get("image_quota_remaining")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
|
let limit = chatgpt_web_image_quota_limit(metadata, remaining);
|
|
let used = metadata
|
|
.get("image_quota_used")
|
|
.and_then(admin_provider_quota_pure::coerce_json_f64)
|
|
.or_else(|| {
|
|
limit
|
|
.zip(remaining)
|
|
.map(|(limit, remaining)| (limit - remaining).max(0.0))
|
|
});
|
|
let reset_at = provider_quota_timestamp_unix_secs(metadata.get("image_quota_reset_at"));
|
|
let reset_seconds = quota_window_reset_seconds(observed_at_unix_secs, reset_at);
|
|
let plan_type = metadata
|
|
.get("plan_type")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.to_ascii_lowercase());
|
|
let image_blocked = metadata
|
|
.get("image_quota_blocked")
|
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
|
== Some(true);
|
|
let usage_ratio = used
|
|
.zip(limit)
|
|
.and_then(|(used, limit)| (limit > 0.0).then_some((used / limit).clamp(0.0, 1.0)));
|
|
let remaining_ratio = remaining.zip(limit).and_then(|(remaining, limit)| {
|
|
(limit > 0.0).then_some((remaining / limit).clamp(0.0, 1.0))
|
|
});
|
|
|
|
let mut windows = Vec::new();
|
|
if remaining.is_some()
|
|
|| limit.is_some()
|
|
|| used.is_some()
|
|
|| reset_at.is_some()
|
|
|| image_blocked
|
|
{
|
|
windows.push(json!({
|
|
"code": "image_gen",
|
|
"label": "生图",
|
|
"scope": "account",
|
|
"unit": "count",
|
|
"used_ratio": usage_ratio,
|
|
"remaining_ratio": remaining_ratio,
|
|
"used_value": used,
|
|
"remaining_value": remaining,
|
|
"limit_value": limit,
|
|
"reset_at": reset_at,
|
|
"reset_seconds": reset_seconds,
|
|
"is_exhausted": image_blocked || remaining.is_some_and(|value| value <= 0.0),
|
|
}));
|
|
}
|
|
|
|
if windows.is_empty() && plan_type.is_none() && observed_at_unix_secs.is_none() {
|
|
return None;
|
|
}
|
|
|
|
let exhausted = image_blocked
|
|
|| remaining.is_some_and(|value| value <= 0.0)
|
|
|| usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6);
|
|
let reason = if exhausted {
|
|
Some("生图额度已耗尽")
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Some(json!({
|
|
"version": 2,
|
|
"provider_type": "chatgpt_web",
|
|
"code": if exhausted { "exhausted" } else { "ok" },
|
|
"label": if exhausted { Some("额度耗尽") } else { None::<&str> },
|
|
"reason": reason,
|
|
"freshness": "fresh",
|
|
"source": source,
|
|
"observed_at": observed_at_unix_secs,
|
|
"exhausted": exhausted,
|
|
"usage_ratio": usage_ratio,
|
|
"updated_at": observed_at_unix_secs,
|
|
"reset_at": reset_at,
|
|
"reset_seconds": reset_seconds,
|
|
"plan_type": plan_type,
|
|
"windows": windows,
|
|
}))
|
|
}
|
|
|
|
fn build_antigravity_quota_status_snapshot(
|
|
upstream_metadata: Option<&Value>,
|
|
source: &str,
|
|
) -> Option<Value> {
|
|
let metadata = provider_quota_metadata_bucket(upstream_metadata, "antigravity")?;
|
|
let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at"));
|
|
let is_forbidden = metadata
|
|
.get("is_forbidden")
|
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
|
== Some(true);
|
|
let forbidden_reason = metadata
|
|
.get("forbidden_reason")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned);
|
|
let windows = provider_quota_model_bucket(metadata)
|
|
.map(|models| {
|
|
models
|
|
.iter()
|
|
.filter_map(|(model_name, item)| {
|
|
model_quota_window_snapshot(
|
|
model_name,
|
|
item.as_object()?,
|
|
observed_at_unix_secs,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
if windows.is_empty() && observed_at_unix_secs.is_none() && !is_forbidden {
|
|
return None;
|
|
}
|
|
|
|
let usage_ratio = quota_windows_usage_ratio(&windows);
|
|
let reset_seconds = quota_windows_min_reset_seconds(&windows);
|
|
let reset_at = quota_windows_min_reset_at(&windows);
|
|
let exhausted = !is_forbidden && quota_windows_all_exhausted(&windows);
|
|
let reason = if is_forbidden {
|
|
forbidden_reason
|
|
} else if exhausted {
|
|
Some("所有模型额度已耗尽".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
let label = if is_forbidden {
|
|
Some("访问受限")
|
|
} else if exhausted {
|
|
Some("额度耗尽")
|
|
} else {
|
|
None
|
|
};
|
|
let code = if is_forbidden {
|
|
"forbidden"
|
|
} else if exhausted {
|
|
"exhausted"
|
|
} else {
|
|
"ok"
|
|
};
|
|
|
|
Some(json!({
|
|
"version": 2,
|
|
"provider_type": "antigravity",
|
|
"code": code,
|
|
"label": label,
|
|
"reason": reason,
|
|
"freshness": "fresh",
|
|
"source": source,
|
|
"observed_at": observed_at_unix_secs,
|
|
"exhausted": exhausted,
|
|
"usage_ratio": usage_ratio,
|
|
"updated_at": observed_at_unix_secs,
|
|
"reset_at": reset_at,
|
|
"reset_seconds": reset_seconds,
|
|
"plan_type": serde_json::Value::Null,
|
|
"windows": windows,
|
|
}))
|
|
}
|
|
|
|
fn build_gemini_cli_quota_status_snapshot(
|
|
upstream_metadata: Option<&Value>,
|
|
source: &str,
|
|
) -> Option<Value> {
|
|
let metadata = provider_quota_metadata_bucket(upstream_metadata, "gemini_cli")?;
|
|
let observed_at_unix_secs = provider_quota_timestamp_unix_secs(metadata.get("updated_at"));
|
|
let windows = provider_quota_model_bucket(metadata)
|
|
.map(|models| {
|
|
models
|
|
.iter()
|
|
.filter_map(|(model_name, item)| {
|
|
model_quota_window_snapshot(
|
|
model_name,
|
|
item.as_object()?,
|
|
observed_at_unix_secs,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
if windows.is_empty() && observed_at_unix_secs.is_none() {
|
|
return None;
|
|
}
|
|
|
|
let usage_ratio = quota_windows_usage_ratio(&windows);
|
|
let active_exhausted_windows = windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.filter(|window| {
|
|
window
|
|
.get("is_exhausted")
|
|
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
|
.or_else(|| {
|
|
window
|
|
.get("used_ratio")
|
|
.and_then(Value::as_f64)
|
|
.map(|value| value >= 1.0 - 1e-6)
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
.filter(|window| {
|
|
provider_quota_timestamp_unix_secs(window.get("reset_at"))
|
|
.zip(observed_at_unix_secs)
|
|
.map(|(reset_at, observed_at)| reset_at > observed_at)
|
|
.unwrap_or(true)
|
|
})
|
|
.count();
|
|
let exhausted = !windows.is_empty() && active_exhausted_windows == windows.len();
|
|
let cooling = active_exhausted_windows > 0;
|
|
let reset_seconds = if cooling {
|
|
quota_windows_min_reset_seconds(&windows)
|
|
} else {
|
|
None
|
|
};
|
|
let reset_at = if cooling {
|
|
quota_windows_min_reset_at(&windows)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Some(json!({
|
|
"version": 2,
|
|
"provider_type": "gemini_cli",
|
|
"code": if exhausted {
|
|
"exhausted"
|
|
} else if cooling {
|
|
"cooldown"
|
|
} else {
|
|
"ok"
|
|
},
|
|
"label": if cooling { Some("冷却中") } else { None::<&str> },
|
|
"reason": if exhausted {
|
|
Some("所有模型均处于冷却中")
|
|
} else {
|
|
None::<&str>
|
|
},
|
|
"freshness": "fresh",
|
|
"source": source,
|
|
"observed_at": observed_at_unix_secs,
|
|
"exhausted": exhausted,
|
|
"usage_ratio": usage_ratio,
|
|
"updated_at": observed_at_unix_secs,
|
|
"reset_at": reset_at,
|
|
"reset_seconds": reset_seconds,
|
|
"plan_type": serde_json::Value::Null,
|
|
"windows": windows,
|
|
}))
|
|
}
|
|
|
|
pub(crate) fn sync_provider_key_quota_status_snapshot(
|
|
status_snapshot: Option<&Value>,
|
|
provider_type: &str,
|
|
upstream_metadata: Option<&Value>,
|
|
source: &str,
|
|
) -> Option<Value> {
|
|
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
|
let mut quota = match normalized_provider_type.as_str() {
|
|
"codex" => build_codex_quota_status_snapshot(upstream_metadata, source),
|
|
"kiro" => build_kiro_quota_status_snapshot(upstream_metadata, source),
|
|
"chatgpt_web" => build_chatgpt_web_quota_status_snapshot(upstream_metadata, source),
|
|
"antigravity" => build_antigravity_quota_status_snapshot(upstream_metadata, source),
|
|
"gemini_cli" => build_gemini_cli_quota_status_snapshot(upstream_metadata, source),
|
|
_ => None,
|
|
}?;
|
|
if normalized_provider_type == "codex" {
|
|
preserve_quota_window_usage_state(status_snapshot, &mut quota);
|
|
}
|
|
|
|
let default_snapshot = default_provider_key_status_snapshot();
|
|
let mut snapshot = provider_key_status_snapshot_object(status_snapshot)
|
|
.or_else(|| default_snapshot.as_object().cloned())
|
|
.unwrap_or_default();
|
|
snapshot.insert("quota".to_string(), quota);
|
|
Some(Value::Object(snapshot))
|
|
}
|
|
|
|
fn quota_snapshot_has_materialized_data(
|
|
quota_snapshot: Option<&Map<String, Value>>,
|
|
provider_type: &str,
|
|
) -> bool {
|
|
let Some(quota_snapshot) = quota_snapshot else {
|
|
return false;
|
|
};
|
|
|
|
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
|
let snapshot_provider_type = quota_snapshot
|
|
.get("provider_type")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.unwrap_or_default()
|
|
.to_ascii_lowercase();
|
|
if !snapshot_provider_type.is_empty() && snapshot_provider_type != normalized_provider_type {
|
|
return false;
|
|
}
|
|
|
|
if quota_snapshot
|
|
.get("windows")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|windows| !windows.is_empty())
|
|
{
|
|
return true;
|
|
}
|
|
if quota_snapshot
|
|
.get("credits")
|
|
.is_some_and(|credits| !credits.is_null())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
quota_snapshot
|
|
.get("code")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.is_some_and(|code| {
|
|
!code.is_empty()
|
|
&& !code.eq_ignore_ascii_case("unknown")
|
|
&& !code.eq_ignore_ascii_case("ok")
|
|
})
|
|
}
|
|
|
|
pub(crate) fn provider_key_status_snapshot_payload(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
) -> serde_json::Value {
|
|
let status_snapshot = key
|
|
.status_snapshot
|
|
.as_ref()
|
|
.filter(|value| value.is_object());
|
|
let quota_snapshot = status_snapshot
|
|
.and_then(Value::as_object)
|
|
.and_then(|snapshot| snapshot.get("quota"))
|
|
.and_then(Value::as_object);
|
|
|
|
let payload = if quota_snapshot_has_materialized_data(quota_snapshot, provider_type) {
|
|
status_snapshot
|
|
.cloned()
|
|
.unwrap_or_else(default_provider_key_status_snapshot)
|
|
} else {
|
|
sync_provider_key_quota_status_snapshot(
|
|
status_snapshot,
|
|
provider_type,
|
|
key.upstream_metadata.as_ref(),
|
|
"catalog_fallback",
|
|
)
|
|
.or_else(|| status_snapshot.cloned())
|
|
.unwrap_or_else(default_provider_key_status_snapshot)
|
|
};
|
|
|
|
let mut snapshot = provider_key_status_snapshot_object(Some(&payload))
|
|
.or_else(|| default_provider_key_status_snapshot().as_object().cloned())
|
|
.unwrap_or_default();
|
|
snapshot.insert(
|
|
"oauth".to_string(),
|
|
build_provider_key_oauth_status_snapshot(key),
|
|
);
|
|
snapshot.insert(
|
|
"account".to_string(),
|
|
build_provider_key_account_status_snapshot(key, provider_type),
|
|
);
|
|
Value::Object(snapshot)
|
|
}
|
|
|
|
pub(crate) fn provider_key_health_summary(
|
|
key: &StoredProviderCatalogKey,
|
|
) -> (
|
|
f64,
|
|
i64,
|
|
Option<String>,
|
|
bool,
|
|
serde_json::Map<String, serde_json::Value>,
|
|
) {
|
|
let health_by_format = key
|
|
.health_by_format
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_object)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let circuit_by_format = key
|
|
.circuit_breaker_by_format
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_object)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
let mut min_health_score = 1.0_f64;
|
|
let mut max_consecutive = 0_i64;
|
|
let mut last_failure_at: Option<String> = None;
|
|
for value in health_by_format.values() {
|
|
let score = value
|
|
.get("health_score")
|
|
.and_then(serde_json::Value::as_f64)
|
|
.unwrap_or(1.0);
|
|
min_health_score = min_health_score.min(score);
|
|
let consecutive = value
|
|
.get("consecutive_failures")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.unwrap_or(0);
|
|
max_consecutive = max_consecutive.max(consecutive);
|
|
if let Some(last_failure) = value
|
|
.get("last_failure_at")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
{
|
|
if last_failure_at
|
|
.as_ref()
|
|
.is_none_or(|current| last_failure > *current)
|
|
{
|
|
last_failure_at = Some(last_failure);
|
|
}
|
|
}
|
|
}
|
|
|
|
let any_circuit_open = circuit_by_format.values().any(|value| {
|
|
value
|
|
.get("open")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false)
|
|
});
|
|
|
|
(
|
|
if health_by_format.is_empty() {
|
|
1.0
|
|
} else {
|
|
min_health_score
|
|
},
|
|
max_consecutive,
|
|
last_failure_at,
|
|
any_circuit_open,
|
|
circuit_by_format,
|
|
)
|
|
}
|
|
|
|
fn normalize_catalog_oauth_plan_type(value: &str, provider_type: &str) -> Option<String> {
|
|
let mut normalized = value.trim().to_string();
|
|
if normalized.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let provider_type = provider_type.trim().to_ascii_lowercase();
|
|
if !provider_type.is_empty() && normalized.to_ascii_lowercase().starts_with(&provider_type) {
|
|
normalized = normalized[provider_type.len()..]
|
|
.trim_matches(|ch: char| [' ', ':', '-', '_'].contains(&ch))
|
|
.to_string();
|
|
}
|
|
|
|
let normalized = normalized.trim().to_ascii_lowercase();
|
|
if normalized.is_empty() {
|
|
None
|
|
} else {
|
|
Some(normalized)
|
|
}
|
|
}
|
|
|
|
fn catalog_oauth_plan_type_from_source(
|
|
source: &serde_json::Map<String, serde_json::Value>,
|
|
provider_type: &str,
|
|
fields: &[&str],
|
|
) -> Option<String> {
|
|
for field in fields {
|
|
let Some(value) = source.get(*field).and_then(serde_json::Value::as_str) else {
|
|
continue;
|
|
};
|
|
if let Some(normalized) = normalize_catalog_oauth_plan_type(value, provider_type) {
|
|
return Some(normalized);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn derive_catalog_oauth_plan_type(
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
|
) -> Option<String> {
|
|
if !provider_key_auth_semantics(key, provider_type).oauth_managed() {
|
|
return None;
|
|
}
|
|
|
|
let provider_type_key = provider_type.trim().to_ascii_lowercase();
|
|
if let Some(upstream_metadata) = key
|
|
.upstream_metadata
|
|
.as_ref()
|
|
.and_then(serde_json::Value::as_object)
|
|
{
|
|
let provider_bucket = if provider_type_key.is_empty() {
|
|
None
|
|
} else {
|
|
upstream_metadata
|
|
.get(&provider_type_key)
|
|
.and_then(serde_json::Value::as_object)
|
|
};
|
|
for source in provider_bucket
|
|
.into_iter()
|
|
.chain(std::iter::once(upstream_metadata))
|
|
{
|
|
if let Some(plan_type) = catalog_oauth_plan_type_from_source(
|
|
source,
|
|
provider_type,
|
|
&[
|
|
"plan_type",
|
|
"tier",
|
|
"subscription_title",
|
|
"subscription_plan",
|
|
"plan",
|
|
],
|
|
) {
|
|
return Some(plan_type);
|
|
}
|
|
}
|
|
}
|
|
|
|
auth_config.and_then(|source| {
|
|
catalog_oauth_plan_type_from_source(
|
|
source,
|
|
provider_type,
|
|
&["plan_type", "tier", "plan", "subscription_plan"],
|
|
)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn build_admin_provider_key_response(
|
|
state: &AppState,
|
|
key: &StoredProviderCatalogKey,
|
|
provider_type: &str,
|
|
api_formats: &[String],
|
|
now_unix_secs: u64,
|
|
) -> serde_json::Value {
|
|
let request_count = u64::from(key.request_count.unwrap_or(0));
|
|
let success_count = u64::from(key.success_count.unwrap_or(0));
|
|
let error_count = u64::from(key.error_count.unwrap_or(0));
|
|
let total_response_time_ms = f64::from(key.total_response_time_ms.unwrap_or(0));
|
|
let success_rate = if request_count > 0 {
|
|
success_count as f64 / request_count as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
let avg_response_time_ms = if success_count > 0 {
|
|
total_response_time_ms / success_count as f64
|
|
} else {
|
|
0.0
|
|
};
|
|
let auth_semantics = provider_key_auth_semantics(key, provider_type);
|
|
let auth_config = parse_catalog_auth_config_json(state, key);
|
|
let oauth_organizations = if auth_semantics.can_show_oauth_metadata() {
|
|
auth_config
|
|
.as_ref()
|
|
.and_then(|config| config.get("organizations"))
|
|
.and_then(serde_json::Value::as_array)
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let oauth_temporary = auth_semantics.can_show_oauth_metadata()
|
|
&& auth_config
|
|
.as_ref()
|
|
.and_then(|config| config.get("access_token_import_temporary"))
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false);
|
|
let oauth_plan_type = derive_catalog_oauth_plan_type(key, provider_type, auth_config.as_ref());
|
|
let (
|
|
health_score,
|
|
consecutive_failures,
|
|
last_failure_at,
|
|
circuit_breaker_open,
|
|
circuit_by_format,
|
|
) = provider_key_health_summary(key);
|
|
let circuit_sample = circuit_by_format
|
|
.values()
|
|
.find(|value| {
|
|
value
|
|
.get("open")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false)
|
|
})
|
|
.or_else(|| circuit_by_format.values().next());
|
|
let is_adaptive = key.rpm_limit.is_none();
|
|
let effective_limit = if is_adaptive {
|
|
key.learned_rpm_limit
|
|
} else {
|
|
key.rpm_limit
|
|
};
|
|
let mut payload = serde_json::Map::new();
|
|
payload.insert("id".to_string(), json!(key.id));
|
|
payload.insert("provider_id".to_string(), json!(key.provider_id));
|
|
payload.insert(
|
|
"api_formats".to_string(),
|
|
serde_json::Value::Array(
|
|
api_formats
|
|
.iter()
|
|
.cloned()
|
|
.map(serde_json::Value::String)
|
|
.collect(),
|
|
),
|
|
);
|
|
payload.insert(
|
|
"api_key_masked".to_string(),
|
|
json!(masked_catalog_api_key(state, key)),
|
|
);
|
|
payload.insert("api_key_plain".to_string(), serde_json::Value::Null);
|
|
payload.insert("auth_type".to_string(), json!(key.auth_type));
|
|
payload.insert(
|
|
"auth_type_by_format".to_string(),
|
|
json!(key.auth_type_by_format),
|
|
);
|
|
payload.insert(
|
|
"allow_auth_channel_mismatch_formats".to_string(),
|
|
json!(key.allow_auth_channel_mismatch_formats),
|
|
);
|
|
payload.insert(
|
|
"credential_kind".to_string(),
|
|
json!(auth_semantics.credential_kind().as_str()),
|
|
);
|
|
payload.insert(
|
|
"runtime_auth_kind".to_string(),
|
|
json!(auth_semantics.runtime_auth_kind().as_str()),
|
|
);
|
|
payload.insert(
|
|
"oauth_managed".to_string(),
|
|
json!(auth_semantics.oauth_managed()),
|
|
);
|
|
payload.insert(
|
|
"can_refresh_oauth".to_string(),
|
|
json!(auth_semantics.can_refresh_oauth()),
|
|
);
|
|
payload.insert(
|
|
"can_export_oauth".to_string(),
|
|
json!(auth_semantics.can_export_oauth()),
|
|
);
|
|
payload.insert(
|
|
"can_edit_oauth".to_string(),
|
|
json!(auth_semantics.can_edit_oauth()),
|
|
);
|
|
payload.insert("name".to_string(), json!(key.name));
|
|
payload.insert("rate_multipliers".to_string(), json!(key.rate_multipliers));
|
|
payload.insert(
|
|
"internal_priority".to_string(),
|
|
json!(key.internal_priority),
|
|
);
|
|
payload.insert(
|
|
"global_priority_by_format".to_string(),
|
|
json!(key.global_priority_by_format),
|
|
);
|
|
payload.insert("rpm_limit".to_string(), json!(key.rpm_limit));
|
|
payload.insert("concurrent_limit".to_string(), json!(key.concurrent_limit));
|
|
payload.insert(
|
|
"allowed_models".to_string(),
|
|
serde_json::Value::Array(
|
|
json_string_list(key.allowed_models.as_ref())
|
|
.into_iter()
|
|
.map(serde_json::Value::String)
|
|
.collect(),
|
|
),
|
|
);
|
|
payload.insert("capabilities".to_string(), json!(key.capabilities));
|
|
payload.insert(
|
|
"oauth_expires_at".to_string(),
|
|
json!(auth_semantics
|
|
.can_show_oauth_metadata()
|
|
.then_some(key.expires_at_unix_secs)
|
|
.flatten()),
|
|
);
|
|
payload.insert(
|
|
"oauth_email".to_string(),
|
|
if auth_semantics.can_show_oauth_metadata() {
|
|
auth_config
|
|
.as_ref()
|
|
.and_then(|config| config.get("email"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null)
|
|
} else {
|
|
serde_json::Value::Null
|
|
},
|
|
);
|
|
payload.insert("oauth_plan_type".to_string(), json!(oauth_plan_type));
|
|
payload.insert(
|
|
"oauth_account_id".to_string(),
|
|
if auth_semantics.can_show_oauth_metadata() {
|
|
auth_config
|
|
.as_ref()
|
|
.and_then(|config| config.get("account_id"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null)
|
|
} else {
|
|
serde_json::Value::Null
|
|
},
|
|
);
|
|
payload.insert(
|
|
"oauth_account_name".to_string(),
|
|
if auth_semantics.can_show_oauth_metadata() {
|
|
auth_config
|
|
.as_ref()
|
|
.and_then(|config| config.get("account_name"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null)
|
|
} else {
|
|
serde_json::Value::Null
|
|
},
|
|
);
|
|
payload.insert(
|
|
"oauth_account_user_id".to_string(),
|
|
if auth_semantics.can_show_oauth_metadata() {
|
|
auth_config
|
|
.as_ref()
|
|
.and_then(|config| config.get("account_user_id"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null)
|
|
} else {
|
|
serde_json::Value::Null
|
|
},
|
|
);
|
|
payload.insert(
|
|
"oauth_organizations".to_string(),
|
|
serde_json::Value::Array(oauth_organizations),
|
|
);
|
|
payload.insert("oauth_temporary".to_string(), json!(oauth_temporary));
|
|
payload.insert(
|
|
"oauth_invalid_at".to_string(),
|
|
json!(auth_semantics
|
|
.can_show_oauth_metadata()
|
|
.then_some(key.oauth_invalid_at_unix_secs)
|
|
.flatten()),
|
|
);
|
|
payload.insert(
|
|
"oauth_invalid_reason".to_string(),
|
|
json!(auth_semantics
|
|
.can_show_oauth_metadata()
|
|
.then_some(key.oauth_invalid_reason.clone())
|
|
.flatten()),
|
|
);
|
|
payload.insert(
|
|
"status_snapshot".to_string(),
|
|
provider_key_status_snapshot_payload(key, provider_type),
|
|
);
|
|
payload.insert(
|
|
"cache_ttl_minutes".to_string(),
|
|
json!(key.cache_ttl_minutes),
|
|
);
|
|
payload.insert(
|
|
"max_probe_interval_minutes".to_string(),
|
|
json!(key.max_probe_interval_minutes),
|
|
);
|
|
payload.insert("health_by_format".to_string(), json!(key.health_by_format));
|
|
payload.insert(
|
|
"circuit_breaker_by_format".to_string(),
|
|
json!(key.circuit_breaker_by_format),
|
|
);
|
|
payload.insert("health_score".to_string(), json!(health_score));
|
|
payload.insert(
|
|
"consecutive_failures".to_string(),
|
|
json!(consecutive_failures),
|
|
);
|
|
payload.insert("last_failure_at".to_string(), json!(last_failure_at));
|
|
payload.insert(
|
|
"circuit_breaker_open".to_string(),
|
|
json!(circuit_breaker_open),
|
|
);
|
|
payload.insert(
|
|
"circuit_breaker_open_at".to_string(),
|
|
circuit_sample
|
|
.and_then(|value| value.get("open_at"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null),
|
|
);
|
|
payload.insert(
|
|
"next_probe_at".to_string(),
|
|
circuit_sample
|
|
.and_then(|value| value.get("next_probe_at"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null),
|
|
);
|
|
payload.insert(
|
|
"half_open_until".to_string(),
|
|
circuit_sample
|
|
.and_then(|value| value.get("half_open_until"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null),
|
|
);
|
|
payload.insert(
|
|
"half_open_successes".to_string(),
|
|
json!(circuit_sample
|
|
.and_then(|value| value.get("half_open_successes"))
|
|
.and_then(serde_json::Value::as_i64)
|
|
.unwrap_or(0)),
|
|
);
|
|
payload.insert(
|
|
"half_open_failures".to_string(),
|
|
json!(circuit_sample
|
|
.and_then(|value| value.get("half_open_failures"))
|
|
.and_then(serde_json::Value::as_i64)
|
|
.unwrap_or(0)),
|
|
);
|
|
payload.insert(
|
|
"request_results_window".to_string(),
|
|
circuit_sample
|
|
.and_then(|value| value.get("request_results_window"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null),
|
|
);
|
|
payload.insert("request_count".to_string(), json!(request_count));
|
|
payload.insert("success_count".to_string(), json!(success_count));
|
|
payload.insert("error_count".to_string(), json!(error_count));
|
|
payload.insert("success_rate".to_string(), json!(success_rate));
|
|
payload.insert(
|
|
"avg_response_time_ms".to_string(),
|
|
json!(avg_response_time_ms),
|
|
);
|
|
payload.insert("is_active".to_string(), json!(key.is_active));
|
|
payload.insert("is_adaptive".to_string(), json!(is_adaptive));
|
|
payload.insert(
|
|
"learned_rpm_limit".to_string(),
|
|
json!(key.learned_rpm_limit),
|
|
);
|
|
payload.insert("effective_limit".to_string(), json!(effective_limit));
|
|
payload.insert(
|
|
"utilization_samples".to_string(),
|
|
json!(key.utilization_samples),
|
|
);
|
|
payload.insert(
|
|
"last_probe_increase_at".to_string(),
|
|
json!(key
|
|
.last_probe_increase_at_unix_secs
|
|
.and_then(unix_secs_to_rfc3339)),
|
|
);
|
|
payload.insert(
|
|
"concurrent_429_count".to_string(),
|
|
json!(key.concurrent_429_count),
|
|
);
|
|
payload.insert("rpm_429_count".to_string(), json!(key.rpm_429_count));
|
|
payload.insert(
|
|
"last_429_at".to_string(),
|
|
json!(key.last_429_at_unix_secs.and_then(unix_secs_to_rfc3339)),
|
|
);
|
|
payload.insert("last_429_type".to_string(), json!(key.last_429_type));
|
|
payload.insert("note".to_string(), json!(key.note));
|
|
payload.insert(
|
|
"auto_fetch_models".to_string(),
|
|
json!(key.auto_fetch_models),
|
|
);
|
|
payload.insert(
|
|
"last_models_fetch_at".to_string(),
|
|
json!(key
|
|
.last_models_fetch_at_unix_secs
|
|
.and_then(unix_secs_to_rfc3339)),
|
|
);
|
|
payload.insert(
|
|
"last_models_fetch_error".to_string(),
|
|
json!(key.last_models_fetch_error),
|
|
);
|
|
payload.insert("locked_models".to_string(), json!(key.locked_models));
|
|
payload.insert(
|
|
"model_include_patterns".to_string(),
|
|
json!(key.model_include_patterns),
|
|
);
|
|
payload.insert(
|
|
"model_exclude_patterns".to_string(),
|
|
json!(key.model_exclude_patterns),
|
|
);
|
|
payload.insert(
|
|
"upstream_metadata".to_string(),
|
|
json!(key.upstream_metadata),
|
|
);
|
|
payload.insert("proxy".to_string(), json!(key.proxy));
|
|
payload.insert("fingerprint".to_string(), json!(key.fingerprint));
|
|
payload.insert(
|
|
"last_used_at".to_string(),
|
|
json!(key.last_used_at_unix_secs.and_then(unix_secs_to_rfc3339)),
|
|
);
|
|
payload.insert(
|
|
"created_at".to_string(),
|
|
json!(unix_secs_to_rfc3339(
|
|
key.created_at_unix_ms.unwrap_or(now_unix_secs)
|
|
)),
|
|
);
|
|
payload.insert(
|
|
"updated_at".to_string(),
|
|
json!(unix_secs_to_rfc3339(
|
|
key.updated_at_unix_secs.unwrap_or(now_unix_secs)
|
|
)),
|
|
);
|
|
serde_json::Value::Object(payload)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample_catalog_key() -> StoredProviderCatalogKey {
|
|
let encrypted_api_key =
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "sk-test-123")
|
|
.expect("api key ciphertext should build");
|
|
StoredProviderCatalogKey::new(
|
|
"key-test".to_string(),
|
|
"provider-test".to_string(),
|
|
"default".to_string(),
|
|
"api_key".to_string(),
|
|
None,
|
|
true,
|
|
)
|
|
.expect("key should build")
|
|
.with_transport_fields(
|
|
Some(json!(["openai:chat"])),
|
|
encrypted_api_key,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("key transport should build")
|
|
}
|
|
|
|
#[test]
|
|
fn masked_catalog_api_key_handles_unicode_plaintext_without_panicking() {
|
|
let state = AppState::new().expect("gateway should build");
|
|
let encrypted_api_key =
|
|
encrypt_python_fernet_plaintext(DEVELOPMENT_ENCRYPTION_KEY, "测试-密钥-1234567890")
|
|
.expect("api key ciphertext should build");
|
|
let key = StoredProviderCatalogKey::new(
|
|
"key-unicode".to_string(),
|
|
"provider-test".to_string(),
|
|
"default".to_string(),
|
|
"api_key".to_string(),
|
|
None,
|
|
true,
|
|
)
|
|
.expect("key should build")
|
|
.with_transport_fields(
|
|
Some(json!(["openai:chat"])),
|
|
encrypted_api_key,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("key transport should build");
|
|
|
|
let masked = masked_catalog_api_key(&state, &key);
|
|
assert!(masked.contains("***"));
|
|
assert_ne!(masked, "***ERROR***");
|
|
}
|
|
|
|
#[test]
|
|
fn provider_key_status_snapshot_payload_backfills_missing_quota_from_upstream_metadata() {
|
|
let mut key = sample_catalog_key();
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"updated_at": 1_775_553_285u64,
|
|
"plan_type": "plus",
|
|
"primary_used_percent": 55.0,
|
|
"primary_reset_at": 1_900_000_000u64,
|
|
"secondary_used_percent": 12.5,
|
|
"secondary_reset_at": 1_900_500_000u64,
|
|
"has_credits": true,
|
|
"credits_balance": 42.0
|
|
}
|
|
}));
|
|
|
|
let payload = provider_key_status_snapshot_payload(&key, "codex");
|
|
let quota = payload
|
|
.get("quota")
|
|
.and_then(Value::as_object)
|
|
.expect("quota snapshot should be object");
|
|
|
|
assert_eq!(quota.get("provider_type"), Some(&json!("codex")));
|
|
assert_eq!(quota.get("plan_type"), Some(&json!("plus")));
|
|
assert_eq!(quota.get("updated_at"), Some(&json!(1_775_553_285u64)));
|
|
assert_eq!(quota.get("reset_at"), Some(&json!(1_900_000_000u64)));
|
|
assert_eq!(
|
|
quota
|
|
.get("credits")
|
|
.and_then(Value::as_object)
|
|
.and_then(|credits| credits.get("balance")),
|
|
Some(&json!(42.0))
|
|
);
|
|
assert_eq!(
|
|
quota.get("windows").and_then(Value::as_array).map(Vec::len),
|
|
Some(2usize)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_key_status_snapshot_payload_keeps_codex_free_window_quota_available() {
|
|
let mut key = sample_catalog_key();
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"updated_at": 1_775_553_285u64,
|
|
"plan_type": "free",
|
|
"primary_used_percent": 64.0,
|
|
"primary_reset_at": 1_900_000_000u64,
|
|
"secondary_used_percent": 3.0,
|
|
"secondary_reset_at": 1_900_500_000u64,
|
|
"has_credits": false,
|
|
"credits_balance": 0.0,
|
|
"credits_unlimited": false
|
|
}
|
|
}));
|
|
|
|
let payload = provider_key_status_snapshot_payload(&key, "codex");
|
|
let quota = payload
|
|
.get("quota")
|
|
.and_then(Value::as_object)
|
|
.expect("quota snapshot should be object");
|
|
|
|
assert_eq!(quota.get("code"), Some(&json!("ok")));
|
|
assert_eq!(quota.get("exhausted"), Some(&json!(false)));
|
|
assert_eq!(quota.get("usage_ratio"), Some(&json!(0.64)));
|
|
assert_eq!(
|
|
quota
|
|
.get("credits")
|
|
.and_then(Value::as_object)
|
|
.and_then(|credits| credits.get("has_credits")),
|
|
Some(&json!(false))
|
|
);
|
|
assert_eq!(
|
|
quota.get("windows").and_then(Value::as_array).map(Vec::len),
|
|
Some(2usize)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_key_status_snapshot_payload_derives_codex_reset_at_from_countdown() {
|
|
let mut key = sample_catalog_key();
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"updated_at": 1_775_553_285u64,
|
|
"primary_used_percent": 55.0,
|
|
"primary_reset_after_seconds": 3_600u64
|
|
}
|
|
}));
|
|
|
|
let payload = provider_key_status_snapshot_payload(&key, "codex");
|
|
let quota = payload
|
|
.get("quota")
|
|
.and_then(Value::as_object)
|
|
.expect("quota snapshot should be object");
|
|
let window = quota
|
|
.get("windows")
|
|
.and_then(Value::as_array)
|
|
.and_then(|windows| windows.first())
|
|
.and_then(Value::as_object)
|
|
.expect("quota window should exist");
|
|
|
|
assert_eq!(quota.get("reset_at"), Some(&json!(1_775_556_885u64)));
|
|
assert_eq!(quota.get("reset_seconds"), Some(&json!(3_600u64)));
|
|
assert_eq!(window.get("reset_at"), Some(&json!(1_775_556_885u64)));
|
|
assert_eq!(window.get("reset_seconds"), Some(&json!(3_600u64)));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_key_status_snapshot_payload_backfills_chatgpt_web_image_quota() {
|
|
let mut key = sample_catalog_key();
|
|
key.upstream_metadata = Some(json!({
|
|
"chatgpt_web": {
|
|
"updated_at": 1_778_067_246u64,
|
|
"plan_type": "free",
|
|
"image_quota_remaining": 24.0,
|
|
"image_quota_reset_at": 1_778_157_172u64
|
|
}
|
|
}));
|
|
|
|
let payload = provider_key_status_snapshot_payload(&key, "chatgpt_web");
|
|
let quota = payload
|
|
.get("quota")
|
|
.and_then(Value::as_object)
|
|
.expect("quota snapshot should be object");
|
|
let window = quota
|
|
.get("windows")
|
|
.and_then(Value::as_array)
|
|
.and_then(|windows| windows.first())
|
|
.and_then(Value::as_object)
|
|
.expect("image quota window should exist");
|
|
|
|
assert_eq!(quota.get("provider_type"), Some(&json!("chatgpt_web")));
|
|
assert_eq!(quota.get("code"), Some(&json!("ok")));
|
|
assert_eq!(quota.get("plan_type"), Some(&json!("free")));
|
|
assert_eq!(quota.get("reset_at"), Some(&json!(1_778_157_172u64)));
|
|
assert_eq!(quota.get("usage_ratio"), Some(&json!(0.04)));
|
|
assert_eq!(window.get("code"), Some(&json!("image_gen")));
|
|
assert_eq!(window.get("remaining_value"), Some(&json!(24.0)));
|
|
assert_eq!(window.get("limit_value"), Some(&json!(25.0)));
|
|
assert_eq!(window.get("used_value"), Some(&json!(1.0)));
|
|
assert_eq!(window.get("remaining_ratio"), Some(&json!(0.96)));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_key_status_snapshot_payload_preserves_existing_materialized_quota_snapshot() {
|
|
let mut key = sample_catalog_key();
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"updated_at": 100u64,
|
|
"primary_used_percent": 100.0
|
|
}
|
|
}));
|
|
key.status_snapshot = Some(json!({
|
|
"oauth": {
|
|
"code": "none",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"expires_at": serde_json::Value::Null,
|
|
"invalid_at": serde_json::Value::Null,
|
|
"source": serde_json::Value::Null,
|
|
"requires_reauth": false,
|
|
"expiring_soon": false
|
|
},
|
|
"account": {
|
|
"code": "ok",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"blocked": false,
|
|
"source": serde_json::Value::Null,
|
|
"recoverable": false
|
|
},
|
|
"quota": {
|
|
"version": 2,
|
|
"provider_type": "codex",
|
|
"code": "ok",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"freshness": "fresh",
|
|
"source": "refresh_api",
|
|
"observed_at": 200u64,
|
|
"exhausted": false,
|
|
"usage_ratio": 0.25,
|
|
"updated_at": 200u64,
|
|
"reset_seconds": 3600u64,
|
|
"plan_type": "team",
|
|
"windows": [{
|
|
"code": "weekly",
|
|
"label": "周",
|
|
"scope": "account",
|
|
"unit": "percent",
|
|
"used_ratio": 0.25,
|
|
"remaining_ratio": 0.75,
|
|
"reset_at": 1_900_000_000u64,
|
|
"reset_seconds": 3600u64
|
|
}]
|
|
}
|
|
}));
|
|
|
|
let payload = provider_key_status_snapshot_payload(&key, "codex");
|
|
let quota = payload
|
|
.get("quota")
|
|
.and_then(Value::as_object)
|
|
.expect("quota snapshot should be object");
|
|
|
|
assert_eq!(quota.get("updated_at"), Some(&json!(200u64)));
|
|
assert_eq!(quota.get("plan_type"), Some(&json!("team")));
|
|
assert_eq!(
|
|
quota
|
|
.get("windows")
|
|
.and_then(Value::as_array)
|
|
.and_then(|windows| windows.first())
|
|
.and_then(Value::as_object)
|
|
.and_then(|window| window.get("used_ratio")),
|
|
Some(&json!(0.25))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_provider_key_quota_status_snapshot_preserves_codex_usage_state() {
|
|
let current_status_snapshot = json!({
|
|
"quota": {
|
|
"version": 2,
|
|
"provider_type": "codex",
|
|
"windows": [
|
|
{
|
|
"code": "weekly",
|
|
"usage_reset_at": 1_775_600_000u64,
|
|
"usage": {
|
|
"request_count": 3,
|
|
"total_tokens": 375,
|
|
"total_cost_usd": "0.60000000"
|
|
},
|
|
"reset_at": 1_900_000_000u64,
|
|
"window_minutes": 10_080u64
|
|
},
|
|
{
|
|
"code": "5h",
|
|
"usage_reset_at": 1_775_700_000u64,
|
|
"usage": {
|
|
"request_count": 2,
|
|
"total_tokens": 225,
|
|
"total_cost_usd": "0.30000000"
|
|
},
|
|
"reset_at": 1_900_500_000u64,
|
|
"window_minutes": 300u64
|
|
}
|
|
]
|
|
}
|
|
});
|
|
let upstream_metadata = json!({
|
|
"codex": {
|
|
"updated_at": 1_775_800_000u64,
|
|
"plan_type": "plus",
|
|
"primary_used_percent": 5.0,
|
|
"primary_reset_at": 1_900_000_000u64,
|
|
"primary_window_minutes": 10_080u64,
|
|
"secondary_used_percent": 1.0,
|
|
"secondary_reset_at": 1_900_500_000u64,
|
|
"secondary_window_minutes": 300u64
|
|
}
|
|
});
|
|
|
|
let payload = sync_provider_key_quota_status_snapshot(
|
|
Some(¤t_status_snapshot),
|
|
"codex",
|
|
Some(&upstream_metadata),
|
|
"refresh_api",
|
|
)
|
|
.expect("quota snapshot should sync");
|
|
let windows = payload
|
|
.get("quota")
|
|
.and_then(Value::as_object)
|
|
.and_then(|quota| quota.get("windows"))
|
|
.and_then(Value::as_array)
|
|
.expect("quota windows should exist");
|
|
let weekly = windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.find(|window| window.get("code") == Some(&json!("weekly")))
|
|
.expect("weekly window should exist");
|
|
let five_h = windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.find(|window| window.get("code") == Some(&json!("5h")))
|
|
.expect("5h window should exist");
|
|
|
|
assert_eq!(weekly.get("usage_reset_at"), Some(&json!(1_775_600_000u64)));
|
|
assert_eq!(
|
|
weekly
|
|
.get("usage")
|
|
.and_then(|usage| usage.get("request_count")),
|
|
Some(&json!(3))
|
|
);
|
|
assert_eq!(
|
|
weekly
|
|
.get("usage")
|
|
.and_then(|usage| usage.get("total_tokens")),
|
|
Some(&json!(375))
|
|
);
|
|
assert_eq!(
|
|
weekly
|
|
.get("usage")
|
|
.and_then(|usage| usage.get("total_cost_usd")),
|
|
Some(&json!("0.60000000"))
|
|
);
|
|
assert_eq!(five_h.get("usage_reset_at"), Some(&json!(1_775_700_000u64)));
|
|
assert_eq!(
|
|
five_h
|
|
.get("usage")
|
|
.and_then(|usage| usage.get("request_count")),
|
|
Some(&json!(2))
|
|
);
|
|
assert_eq!(
|
|
five_h
|
|
.get("usage")
|
|
.and_then(|usage| usage.get("total_tokens")),
|
|
Some(&json!(225))
|
|
);
|
|
assert_eq!(
|
|
five_h
|
|
.get("usage")
|
|
.and_then(|usage| usage.get("total_cost_usd")),
|
|
Some(&json!("0.30000000"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sync_provider_key_quota_status_snapshot_drops_codex_usage_state_when_window_resets() {
|
|
let current_status_snapshot = json!({
|
|
"quota": {
|
|
"version": 2,
|
|
"provider_type": "codex",
|
|
"windows": [
|
|
{
|
|
"code": "weekly",
|
|
"usage_reset_at": 1_775_600_000u64,
|
|
"usage": {
|
|
"request_count": 3,
|
|
"total_tokens": 375,
|
|
"total_cost_usd": "0.60000000"
|
|
},
|
|
"reset_at": 1_900_000_000u64,
|
|
"window_minutes": 10_080u64
|
|
}
|
|
]
|
|
}
|
|
});
|
|
let upstream_metadata = json!({
|
|
"codex": {
|
|
"updated_at": 1_900_000_100u64,
|
|
"plan_type": "plus",
|
|
"primary_used_percent": 0.0,
|
|
"primary_reset_at": 1_960_480_100u64,
|
|
"primary_window_minutes": 10_080u64
|
|
}
|
|
});
|
|
|
|
let payload = sync_provider_key_quota_status_snapshot(
|
|
Some(¤t_status_snapshot),
|
|
"codex",
|
|
Some(&upstream_metadata),
|
|
"refresh_api",
|
|
)
|
|
.expect("quota snapshot should sync");
|
|
let weekly = payload["quota"]["windows"]
|
|
.as_array()
|
|
.expect("quota windows should exist")
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.find(|window| window.get("code") == Some(&json!("weekly")))
|
|
.expect("weekly window should exist");
|
|
|
|
assert_eq!(weekly.get("reset_at"), Some(&json!(1_960_480_100u64)));
|
|
assert!(weekly.get("usage_reset_at").is_none());
|
|
assert!(weekly.get("usage").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn sync_provider_key_quota_status_snapshot_defaults_codex_window_minutes() {
|
|
let upstream_metadata = json!({
|
|
"codex": {
|
|
"updated_at": 1_775_800_000u64,
|
|
"plan_type": "plus",
|
|
"primary_used_percent": 5.0,
|
|
"primary_reset_at": 1_900_000_000u64,
|
|
"secondary_used_percent": 1.0,
|
|
"secondary_reset_at": 1_900_500_000u64
|
|
}
|
|
});
|
|
|
|
let payload = sync_provider_key_quota_status_snapshot(
|
|
None,
|
|
"codex",
|
|
Some(&upstream_metadata),
|
|
"response_headers",
|
|
)
|
|
.expect("quota snapshot should sync");
|
|
let windows = payload["quota"]["windows"]
|
|
.as_array()
|
|
.expect("quota windows should exist");
|
|
let weekly = windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.find(|window| window.get("code") == Some(&json!("weekly")))
|
|
.expect("weekly window should exist");
|
|
let five_h = windows
|
|
.iter()
|
|
.filter_map(Value::as_object)
|
|
.find(|window| window.get("code") == Some(&json!("5h")))
|
|
.expect("5h window should exist");
|
|
|
|
assert_eq!(weekly.get("window_minutes"), Some(&json!(10_080u64)));
|
|
assert_eq!(five_h.get("window_minutes"), Some(&json!(300u64)));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_key_status_snapshot_payload_backfills_thin_ok_snapshot_from_upstream_metadata() {
|
|
let mut key = sample_catalog_key();
|
|
key.upstream_metadata = Some(json!({
|
|
"antigravity": {
|
|
"updated_at": 1_775_553_285u64,
|
|
"quota_by_model": {
|
|
"gemini-2.5-pro": { "used_percent": 0.0 },
|
|
"gemini-2.5-flash": { "used_percent": 25.0 }
|
|
}
|
|
}
|
|
}));
|
|
key.status_snapshot = Some(json!({
|
|
"oauth": {
|
|
"code": "none",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"expires_at": serde_json::Value::Null,
|
|
"invalid_at": serde_json::Value::Null,
|
|
"source": serde_json::Value::Null,
|
|
"requires_reauth": false,
|
|
"expiring_soon": false
|
|
},
|
|
"account": {
|
|
"code": "ok",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"blocked": false,
|
|
"source": serde_json::Value::Null,
|
|
"recoverable": false
|
|
},
|
|
"quota": {
|
|
"version": 2,
|
|
"provider_type": "antigravity",
|
|
"code": "ok",
|
|
"label": serde_json::Value::Null,
|
|
"reason": serde_json::Value::Null,
|
|
"freshness": "fresh",
|
|
"source": "refresh_api",
|
|
"observed_at": 100u64,
|
|
"exhausted": false,
|
|
"usage_ratio": 0.0,
|
|
"updated_at": 100u64,
|
|
"reset_seconds": serde_json::Value::Null,
|
|
"plan_type": serde_json::Value::Null
|
|
}
|
|
}));
|
|
|
|
let payload = provider_key_status_snapshot_payload(&key, "antigravity");
|
|
let quota = payload
|
|
.get("quota")
|
|
.and_then(Value::as_object)
|
|
.expect("quota snapshot should be object");
|
|
|
|
assert_eq!(quota.get("provider_type"), Some(&json!("antigravity")));
|
|
assert_eq!(quota.get("updated_at"), Some(&json!(1_775_553_285u64)));
|
|
assert_eq!(
|
|
quota.get("windows").and_then(Value::as_array).map(Vec::len),
|
|
Some(2usize)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_key_status_snapshot_payload_backfills_account_block_from_oauth_invalid_reason() {
|
|
let mut key = sample_catalog_key();
|
|
key.oauth_invalid_reason = Some("[ACCOUNT_BLOCK] account has been deactivated".to_string());
|
|
|
|
let payload = provider_key_status_snapshot_payload(&key, "codex");
|
|
let account = payload
|
|
.get("account")
|
|
.and_then(Value::as_object)
|
|
.expect("account snapshot should be object");
|
|
|
|
assert_eq!(account.get("code"), Some(&json!("account_disabled")));
|
|
assert_eq!(account.get("label"), Some(&json!("账号停用")));
|
|
assert_eq!(
|
|
account.get("reason"),
|
|
Some(&json!("account has been deactivated"))
|
|
);
|
|
assert_eq!(account.get("blocked"), Some(&json!(true)));
|
|
assert_eq!(account.get("source"), Some(&json!("oauth_invalid")));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_key_status_snapshot_payload_backfills_workspace_deactivated_from_metadata() {
|
|
let mut key = sample_catalog_key();
|
|
key.upstream_metadata = Some(json!({
|
|
"codex": {
|
|
"account_disabled": true,
|
|
"reason": "deactivated_workspace"
|
|
}
|
|
}));
|
|
|
|
let payload = provider_key_status_snapshot_payload(&key, "codex");
|
|
let account = payload
|
|
.get("account")
|
|
.and_then(Value::as_object)
|
|
.expect("account snapshot should be object");
|
|
|
|
assert_eq!(account.get("code"), Some(&json!("workspace_deactivated")));
|
|
assert_eq!(account.get("label"), Some(&json!("工作区停用")));
|
|
assert_eq!(account.get("blocked"), Some(&json!(true)));
|
|
assert_eq!(account.get("source"), Some(&json!("metadata")));
|
|
}
|
|
}
|