Files
zerogravity/src/quota.rs

235 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Quota monitor — polls the local LS `GetUserStatus` to track
//! prompt/flow credits and per-model rate limits without touching Google servers.
//!
//! The LS's `GetUserStatus` response contains `cascadeModelConfigData` with
//! per-model `quotaInfo` (remaining fraction, reset time) and plan-level credit
//! balances (prompt, flow, flex). This data originates from Google's
//! `PredictionService/RetrieveUserQuota` / `v1internal:retrieveUserQuota` endpoint,
//! but asking the LS is simpler since it caches this data locally.
//!
//! The credit system uses the `google/internal/cloud/code/v1internal/credits`
//! proto package with `Credits_CreditType` enum. The `CASCADE_ENFORCE_QUOTA`
//! config key controls whether quotas are actually enforced.
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, warn};
/// How often to poll the LS for fresh quota data (seconds).
const POLL_INTERVAL_SECS: u64 = 60;
// ─── Public types ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Default)]
pub struct QuotaSnapshot {
/// When this snapshot was last refreshed (ISO-8601 UTC).
pub last_updated: String,
/// Overall plan info.
pub plan: PlanInfo,
/// Monthly credit balances.
pub credits: CreditInfo,
/// Per-model rate limits.
pub models: Vec<ModelQuota>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct PlanInfo {
pub plan_name: String,
pub tier_id: String,
pub tier_name: String,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct CreditInfo {
pub prompt_available: i64,
pub prompt_total: i64,
pub prompt_used_pct: f64,
pub flow_available: i64,
pub flow_total: i64,
pub flow_used_pct: f64,
pub flex_purchasable: i64,
pub can_buy_more: bool,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct ModelQuota {
pub label: String,
pub model_id: String,
/// 0.01.0 remaining fraction (1.0 = full quota).
pub remaining_fraction: f64,
/// Percentage remaining (0100).
pub remaining_pct: f64,
/// ISO-8601 UTC reset time.
pub reset_time: String,
/// Seconds until reset (negative = already reset).
pub reset_in_secs: i64,
/// Human-readable countdown.
pub reset_in_human: String,
}
// ─── Quota Store ─────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct QuotaStore {
inner: Arc<RwLock<QuotaSnapshot>>,
}
impl QuotaStore {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(QuotaSnapshot::default())),
}
}
/// Get the latest cached snapshot.
pub async fn snapshot(&self) -> QuotaSnapshot {
self.inner.read().await.clone()
}
/// Start the background polling loop. Call once at startup.
pub fn start_polling(self, backend: Arc<crate::backend::Backend>) {
tokio::spawn(async move {
// Initial poll immediately.
self.poll_once(&backend).await;
let mut interval = tokio::time::interval(
std::time::Duration::from_secs(POLL_INTERVAL_SECS),
);
interval.tick().await; // consume the first immediate tick
loop {
interval.tick().await;
self.poll_once(&backend).await;
}
});
}
async fn poll_once(&self, backend: &crate::backend::Backend) {
match backend
.call_json("GetUserStatus", &serde_json::json!({}))
.await
{
Ok((200, data)) => {
let snapshot = parse_user_status(&data);
debug!(
"Quota poll: prompt {}/{} flow {}/{}",
snapshot.credits.prompt_available,
snapshot.credits.prompt_total,
snapshot.credits.flow_available,
snapshot.credits.flow_total,
);
*self.inner.write().await = snapshot;
}
Ok((status, data)) => {
// Profile picture fetch fails through iptables — harmless, suppress
let data_str = data.to_string();
if data_str.contains("profile picture") {
tracing::debug!("GetUserStatus: profile picture fetch failed (expected with iptables)");
} else {
warn!("GetUserStatus returned {status}: {data_str}");
}
}
Err(e) => {
warn!("GetUserStatus poll failed: {e}");
}
}
}
}
// ─── Parsing ─────────────────────────────────────────────────────────────────
fn parse_user_status(data: &serde_json::Value) -> QuotaSnapshot {
let now = chrono::Utc::now();
let us = &data["userStatus"];
let ps = &us["planStatus"];
let pi = &ps["planInfo"];
let ut = &us["userTier"];
let prompt_total = pi["monthlyPromptCredits"].as_i64().unwrap_or(0);
let prompt_avail = ps["availablePromptCredits"].as_i64().unwrap_or(0);
let flow_total = pi["monthlyFlowCredits"].as_i64().unwrap_or(0);
let flow_avail = ps["availableFlowCredits"].as_i64().unwrap_or(0);
let prompt_used_pct = if prompt_total > 0 {
((prompt_total - prompt_avail) as f64 / prompt_total as f64) * 100.0
} else {
0.0
};
let flow_used_pct = if flow_total > 0 {
((flow_total - flow_avail) as f64 / flow_total as f64) * 100.0
} else {
0.0
};
let models = us["cascadeModelConfigData"]["clientModelConfigs"]
.as_array()
.map(|arr| {
arr.iter()
.map(|m| {
let label = m["label"].as_str().unwrap_or("").to_string();
let model_id = m["modelOrAlias"]["model"]
.as_str()
.unwrap_or("")
.to_string();
let frac = m["quotaInfo"]["remainingFraction"]
.as_f64()
.unwrap_or(0.0);
let reset_str = m["quotaInfo"]["resetTime"]
.as_str()
.unwrap_or("")
.to_string();
let reset_in_secs = if !reset_str.is_empty() {
chrono::DateTime::parse_from_rfc3339(&reset_str)
.map(|dt| (dt.with_timezone(&chrono::Utc) - now).num_seconds())
.unwrap_or(0)
} else {
0
};
let reset_in_human = if reset_in_secs > 0 {
let h = reset_in_secs / 3600;
let m = (reset_in_secs % 3600) / 60;
format!("{h}h {m}m")
} else {
"available".to_string()
};
ModelQuota {
label,
model_id,
remaining_fraction: frac,
remaining_pct: frac * 100.0,
reset_time: reset_str,
reset_in_secs,
reset_in_human,
}
})
.collect()
})
.unwrap_or_default();
QuotaSnapshot {
last_updated: now.to_rfc3339(),
plan: PlanInfo {
plan_name: pi["planName"].as_str().unwrap_or("").to_string(),
tier_id: ut["id"].as_str().unwrap_or("").to_string(),
tier_name: ut["name"].as_str().unwrap_or("").to_string(),
},
credits: CreditInfo {
prompt_available: prompt_avail,
prompt_total,
prompt_used_pct,
flow_available: flow_avail,
flow_total,
flow_used_pct,
flex_purchasable: pi["monthlyFlexCreditPurchaseAmount"]
.as_i64()
.unwrap_or(0),
can_buy_more: pi["canBuyMoreCredits"].as_bool().unwrap_or(false),
},
models,
}
}