diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index 6fb10719f..15a14090b 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -11,6 +11,8 @@ pub mod client_keys; pub mod observability; pub mod provider_credentials; pub mod proxies; +pub mod quota_forecast; +pub mod quota_forecast_sampling; pub mod settings; pub mod system; diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 8f03248b9..685db4412 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -884,28 +884,31 @@ impl ProviderQuota { /// Provider 投影顺序。没有符合条件的窗口时不回退到短期或历史累计。 #[must_use] pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> { - self.windows - .iter() - .enumerate() - .filter_map(|(index, window)| { - if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide - || window.reset_at.is_none() - || window.local_usage.is_none() - { - return None; - } - let seconds = window.window_seconds?; - let period = if is_week_window(seconds) { - AccountUsagePeriod::Weekly - } else if window.group == "monthly" && seconds >= 7 * 24 * 60 * 60 { - AccountUsagePeriod::Monthly - } else { - return None; - }; - Some((index, window, period)) - }) - .min_by_key(|(index, _, period)| (*period, *index)) - .map(|(_, window, period)| (window, period)) + self.usage_windows() + .filter(|(window, _)| window.local_usage.is_some()) + .min_by_key(|(_, period)| *period) + } + + /// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量。 + pub(crate) fn usage_windows( + &self, + ) -> impl Iterator { + self.windows.iter().filter_map(|window| { + if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide + || window.reset_at.is_none() + { + return None; + } + let seconds = window.window_seconds?; + let period = if is_week_window(seconds) { + AccountUsagePeriod::Weekly + } else if window.group == "monthly" && seconds >= 7 * 24 * 60 * 60 { + AccountUsagePeriod::Monthly + } else { + return None; + }; + Some((window, period)) + }) } /// 返回 Dashboard 使用的代表性额度比例。 diff --git a/backend/crates/gateway-admin/src/model/quota_forecast.rs b/backend/crates/gateway-admin/src/model/quota_forecast.rs new file mode 100644 index 000000000..35b6bf5c3 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/quota_forecast.rs @@ -0,0 +1,245 @@ +//! 账号额度的只读容量估算,不参与金额结算或账号调度。 + +use chrono::{DateTime, Duration, Utc}; + +use super::provider_credentials::{AccountUsagePeriod, ProviderQuota, ProviderQuotaWindow}; +use super::quota_forecast_sampling::{QuotaForecastMethod, QuotaForecastSample}; + +const DAY_SECONDS: u64 = 86_400; +const MIN_USED_PERCENT: f64 = 5.0; +const LOW_SAMPLE_PERCENT: f64 = 10.0; + +#[derive(Debug, Clone, PartialEq)] +pub struct AccountQuotaForecastReport { + pub account_id: String, + pub generated_at: DateTime, + pub forecasts: [AccountQuotaForecast; 2], +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AccountQuotaForecast { + pub period: AccountUsagePeriod, + pub target_seconds: u64, + pub extrapolated: bool, + pub source: Option, + pub unavailable_reason: Option<&'static str>, + pub low_sample: bool, + pub incomplete_cost: bool, + pub incomplete_tokens: bool, + pub method: QuotaForecastMethod, + pub estimated_tokens: Option, + pub estimated_usd: Option, + /// 剩余估算始终属于源窗口,不随目标周期折算。 + pub remaining_tokens: Option, + pub remaining_usd: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct QuotaForecastSource { + pub key: String, + pub label: String, + pub window_seconds: u64, + pub used_percent: Option, + pub observed_at: Option>, + pub start_at: Option>, + pub reset_at: DateTime, + pub request_count: u64, + pub tokens: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub cached_tokens: Option, + pub known_cost_count: u64, + pub partial_cost_count: u64, + pub unavailable_cost_count: u64, + pub usd: Option, + pub sample_start_at: Option>, + pub baseline_percent: f64, + pub sampled_percent: Option, + pub block_count: usize, + pub observation_count: usize, + pub missing_token_count: u64, + pub excluded_request_count: u64, + pub pending_request_count: u64, +} + +/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。 +/// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度。 +#[must_use] +pub fn account_quota_forecasts( + quota: &ProviderQuota, + account_added_at: DateTime, + now: DateTime, + samples: &[QuotaForecastSample], +) -> [AccountQuotaForecast; 2] { + [AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly].map(|period| { + let selected = quota + .usage_windows() + .find(|(_, source_period)| *source_period == period) + .or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period)); + let mut forecast = AccountQuotaForecast { + period, + target_seconds: match period { + AccountUsagePeriod::Weekly => 7 * DAY_SECONDS, + AccountUsagePeriod::Monthly => 30 * DAY_SECONDS, + }, + extrapolated: false, + source: None, + unavailable_reason: Some("没有可统计的周/月额度窗口,请先刷新账号额度。"), + low_sample: false, + incomplete_cost: false, + incomplete_tokens: false, + method: QuotaForecastMethod::Cumulative, + estimated_tokens: None, + estimated_usd: None, + remaining_tokens: None, + remaining_usd: None, + }; + if let Some((window, source_period)) = selected { + forecast.project( + window, + source_period, + quota.observed_at, + account_added_at, + now, + samples.iter().find(|sample| sample.key == window.key), + ); + } + forecast + }) +} + +impl AccountQuotaForecast { + fn project( + &mut self, + window: &ProviderQuotaWindow, + source_period: AccountUsagePeriod, + observed_at: Option>, + account_added_at: DateTime, + now: DateTime, + sample: Option<&QuotaForecastSample>, + ) { + let (Some(seconds), Some(reset_at)) = (window.window_seconds, window.reset_at) else { + return; + }; + self.extrapolated = source_period != self.period; + if !self.extrapolated { + self.target_seconds = seconds; + } + let percent = window + .used_percent + .filter(|p| p.is_finite() && (0.0..=100.0).contains(p)); + let usage = sample.map(|sample| &sample.usage); + let usd = usage + .filter(|usage| usage.known_cost_count > 0) + .map(|usage| usage.usd) + .filter(|value| value.is_finite() && *value >= 0.0); + let start = i64::try_from(seconds) + .ok() + .and_then(Duration::try_seconds) + .and_then(|duration| reset_at.checked_sub_signed(duration)); + self.source = Some(QuotaForecastSource { + key: window.key.clone(), + label: window.label.clone(), + window_seconds: seconds, + used_percent: percent, + observed_at, + start_at: start, + reset_at, + request_count: usage.map_or(0, |usage| usage.request_count), + tokens: usage.map(|usage| usage.tokens), + input_tokens: usage.map(|usage| usage.input_tokens), + output_tokens: usage.map(|usage| usage.output_tokens), + cached_tokens: usage.map(|usage| usage.cached_tokens), + known_cost_count: usage.map_or(0, |usage| usage.known_cost_count), + partial_cost_count: 0, + unavailable_cost_count: usage.map_or(0, |usage| usage.unavailable_cost_count), + usd, + sample_start_at: sample.map(|sample| sample.start_at), + baseline_percent: sample.map_or(0.0, |sample| sample.baseline_percent), + sampled_percent: sample.map(|sample| sample.sampled_percent), + block_count: sample.map_or(0, |sample| sample.block_count), + observation_count: sample.map_or(0, |sample| sample.observation_count), + missing_token_count: usage.map_or(0, |usage| usage.missing_token_count), + excluded_request_count: usage.map_or(0, |usage| usage.excluded_request_count), + pending_request_count: sample.map_or(0, |sample| sample.pending_request_count), + }); + self.incomplete_cost = usage.is_none_or(|usage| { + usage.unavailable_cost_count > 0 + || usage.known_cost_count == 0 + || usage.known_cost_count != usage.request_count + }); + self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0); + self.method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method); + let Some(start) = start.filter(|start| *start <= now && now < reset_at) else { + self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。"); + return; + }; + if !observed_at.is_some_and(|observed| start <= observed && observed <= now) { + self.unavailable_reason = Some("缺少本周期的额度快照,请先刷新账号额度。"); + return; + } + if sample.is_some_and(|sample| sample.discontinuous) { + self.unavailable_reason = + Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。"); + return; + } + if account_added_at > start && self.method == QuotaForecastMethod::Cumulative { + self.unavailable_reason = Some( + "本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。", + ); + return; + } + let Some(percent) = percent else { + self.unavailable_reason = Some("已用比例未知,请刷新额度后查看预测。"); + return; + }; + let Some(usage) = usage.filter(|usage| usage.request_count > 0) else { + self.unavailable_reason = Some("本周期没有网关用量记录,暂时无法预测额度。"); + return; + }; + let Some(sample) = sample.filter(|sample| { + sample.end_at == observed_at.unwrap_or(now) + && start <= sample.start_at + && sample.start_at <= sample.end_at + && sample.sampled_percent.is_finite() + && sample.sampled_percent >= MIN_USED_PERCENT + && sample.sampled_percent <= percent + }) else { + self.unavailable_reason = + Some("有效额度进度不足 5 个百分点或采样边界无效,请继续积累用量。"); + return; + }; + self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT + || (self.method == QuotaForecastMethod::Incremental && sample.block_count < 2); + // 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。 + let capacity_factor = 100.0 / sample.sampled_percent; + let factor = capacity_factor * self.target_seconds as f64 / seconds as f64; + let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0 && !self.incomplete_tokens); + self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor)); + self.estimated_usd = usd + .filter(|_| !self.incomplete_cost) + .and_then(|value| estimate(value, factor)); + let remaining_factor = (100.0 - percent) / sample.sampled_percent; + self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor)); + self.remaining_usd = usd + .filter(|_| !self.incomplete_cost) + .and_then(|value| estimate(value, remaining_factor)); + self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none() + { + Some("本周期缺少可用的 Token 或完整费用记录,暂时无法预测额度。") + } else { + None + }; + } +} + +fn estimate(value: f64, factor: f64) -> Option { + let estimate = value * factor; + (estimate.is_finite() && estimate >= 0.0).then_some(estimate) +} + +fn estimate_tokens(value: u64, factor: f64) -> Option { + estimate(value as f64, factor) + .filter(|value| value.round() < u64::MAX as f64) + .map(|value| value.round() as u64) +} diff --git a/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs b/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs new file mode 100644 index 000000000..1a72e438b --- /dev/null +++ b/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs @@ -0,0 +1,200 @@ +//! 预测专用的配对采样;不改变完整交付用量或费用账本的统计口径。 + +use chrono::{DateTime, Utc}; + +use super::provider_credentials::ProviderDocument; + +/// 限制带 Provider 文档的历史点数量;累计用量仍覆盖整个查询区间。 +pub const MAX_FORECAST_HISTORY_POINTS: usize = 128; +const MIN_BLOCK_PERCENT: f64 = 5.0; +const RECENT_BLOCKS: usize = 3; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct QuotaForecastUsage { + pub request_count: u64, + pub tokens: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cached_tokens: u64, + pub missing_token_count: u64, + pub known_cost_count: u64, + pub unavailable_cost_count: u64, + pub usd: f64, + pub excluded_request_count: u64, +} + +impl QuotaForecastUsage { + /// 累计计数倒退表示不能配对,不用饱和减法掩盖数据断点。 + fn difference(&self, baseline: &Self) -> Option { + let usd = self.usd - baseline.usd; + if !usd.is_finite() || usd < -1e-9 { + return None; + } + Some(Self { + request_count: self.request_count.checked_sub(baseline.request_count)?, + tokens: self.tokens.checked_sub(baseline.tokens)?, + input_tokens: self.input_tokens.checked_sub(baseline.input_tokens)?, + output_tokens: self.output_tokens.checked_sub(baseline.output_tokens)?, + cached_tokens: self.cached_tokens.checked_sub(baseline.cached_tokens)?, + missing_token_count: self + .missing_token_count + .checked_sub(baseline.missing_token_count)?, + known_cost_count: self + .known_cost_count + .checked_sub(baseline.known_cost_count)?, + unavailable_cost_count: self + .unavailable_cost_count + .checked_sub(baseline.unavailable_cost_count)?, + usd: usd.max(0.0), + excluded_request_count: self + .excluded_request_count + .checked_sub(baseline.excluded_request_count)?, + }) + } +} + +#[derive(Debug, Clone)] +pub struct QuotaForecastHistoryPoint { + pub started_at: DateTime, + pub completed_at: DateTime, + pub usage: QuotaForecastUsage, + pub provider_observation: ProviderDocument, +} + +#[derive(Debug, Clone, Default)] +pub struct QuotaForecastHistory { + pub points: Vec, + pub usage: QuotaForecastUsage, + pub pending_request_count: u64, +} + +/// 具体 Provider 从自己的历史文档解释出的额度事实。 +#[derive(Debug, Clone, PartialEq)] +pub struct QuotaForecastObservation { + pub used_percent: f64, + pub reset_at: DateTime, + pub plan_type: Option, +} + +#[derive(Debug, Clone)] +pub struct QuotaForecastPoint { + pub observed_at: DateTime, + pub used_percent: f64, + pub usage: QuotaForecastUsage, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QuotaForecastMethod { + Cumulative, + Incremental, +} + +impl QuotaForecastMethod { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Cumulative => "cumulative", + Self::Incremental => "incremental", + } + } +} + +#[derive(Debug, Clone)] +pub struct QuotaForecastSample { + pub key: String, + pub method: QuotaForecastMethod, + pub start_at: DateTime, + pub end_at: DateTime, + pub baseline_percent: f64, + pub sampled_percent: f64, + pub block_count: usize, + pub observation_count: usize, + pub pending_request_count: u64, + pub discontinuous: bool, + pub usage: QuotaForecastUsage, +} + +/// 对同一额度段的累计点形成至少 5 个百分点的连续块,使用最近三个完整块及尾部。 +/// 不平均逐请求比值;重复百分比不增加块数,5/10 个百分点仅是量化噪声门槛。 +#[must_use] +pub fn select_forecast_sample( + key: String, + start_at: DateTime, + current: QuotaForecastPoint, + mut history: Vec, + pending_request_count: u64, +) -> QuotaForecastSample { + history.retain(|point| { + start_at <= point.observed_at + && point.observed_at < current.observed_at + && point.used_percent.is_finite() + && (0.0..100.0).contains(&point.used_percent) + }); + history.sort_by_key(|point| point.observed_at); + let mut sample = QuotaForecastSample { + key, + method: QuotaForecastMethod::Cumulative, + start_at, + end_at: current.observed_at, + baseline_percent: 0.0, + sampled_percent: current.used_percent, + block_count: 0, + observation_count: history.len(), + pending_request_count, + discontinuous: false, + usage: current.usage.clone(), + }; + history.push(current); + let mut anchors: Vec<&QuotaForecastPoint> = Vec::new(); + let mut high_water: Option<&QuotaForecastPoint> = None; + for point in &history { + if let Some(previous) = high_water { + if point.observed_at <= previous.observed_at { + continue; + } + if point.used_percent < previous.used_percent { + // 一个百分点内的回落可能是量化/并发乱序;更大的回落不能跨越。 + if previous.used_percent - point.used_percent <= 1.0 { + continue; + } + sample.discontinuous = true; + anchors.clear(); + } + } + high_water = Some(point); + if anchors + .last() + .is_none_or(|anchor| point.used_percent - anchor.used_percent >= MIN_BLOCK_PERCENT) + { + anchors.push(point); + } + } + let Some(current) = history.last() else { + return sample; + }; + if anchors.len() < 2 { + return sample; + } + let baseline_index = anchors.len().saturating_sub(RECENT_BLOCKS + 1); + let baseline = anchors[baseline_index]; + let progress = current.used_percent - baseline.used_percent; + if !progress.is_finite() || progress < MIN_BLOCK_PERCENT { + return sample; + } + let Some(usage) = current.usage.difference(&baseline.usage) else { + sample.discontinuous = true; + return sample; + }; + if usage.request_count == 0 { + return sample; + } + sample.method = QuotaForecastMethod::Incremental; + sample.start_at = baseline.observed_at; + sample.baseline_percent = baseline.used_percent; + sample.sampled_percent = progress; + sample.block_count = anchors.len() - baseline_index - 1; + sample.usage = usage; + // 断点后的新基线已有足够进度时,只消费新段;旧段从不参与本次估算。 + sample.discontinuous = false; + sample +} diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index 53172d5d2..f6f297fba 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -21,6 +21,10 @@ use crate::model::provider_credentials::{ ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type, }; +use crate::model::{ + provider_credentials::{ProviderDocument, ProviderQuotaWindow}, + quota_forecast_sampling::QuotaForecastObservation, +}; /// Provider 管理失败的稳定分类。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -171,6 +175,15 @@ pub trait ProviderAdmin: Send + Sync { request: ProviderQuotaRequest, ) -> Result; + /// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算。 + fn quota_forecast_observation( + &self, + _document: &ProviderDocument, + _window: &ProviderQuotaWindow, + ) -> Option { + None + } + /// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。 async fn profile_statistics( &self, diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index 3026a674c..f13586f35 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -34,6 +34,7 @@ use crate::model::{ AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult, CredentialMutationResult, CredentialRotationCommit, ProviderExportCredentialInput, }, + quota_forecast_sampling::QuotaForecastHistory, settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings}, }; @@ -109,6 +110,12 @@ pub trait AccountStore: Send + Sync { windows: &[AccountUsageWindowQuery], ) -> AdminStoreResult>; + /// 从同一数据库语句取得截止快照的累计用量和有界历史观测。 + async fn load_quota_forecast_history( + &self, + window: &AccountUsageWindowQuery, + ) -> AdminStoreResult; + async fn credential_details( &self, provider_kind: &gateway_core::routing::ProviderKind, diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index eaf107140..1e500bf93 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -28,6 +28,8 @@ use crate::{ ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution, }, + quota_forecast::{AccountQuotaForecastReport, account_quota_forecasts}, + quota_forecast_sampling::{QuotaForecastPoint, select_forecast_sample}, }, ports::{ provider::ProviderAdminRegistry, @@ -83,6 +85,11 @@ pub trait AccountsService: Send + Sync { refresh: bool, ) -> Result; + async fn quota_forecast( + &self, + account_id: &ProviderAccountId, + ) -> Result; + async fn profile_statistics( &self, _account_id: &ProviderAccountId, @@ -213,6 +220,7 @@ impl DefaultAccountsService { quota .windows .iter() + .filter(|window| window.local_usage.is_none()) .filter_map(|window| quota_usage_window(&item.account.id, window)) }) .collect::>(); @@ -581,6 +589,97 @@ impl AccountsService for DefaultAccountsService { self.load_directory_item(account_id, refresh).await } + async fn quota_forecast( + &self, + account_id: &ProviderAccountId, + ) -> Result { + let (stored, provider) = self.provider_for_account(account_id).await?; + let quota = provider + .quota(ProviderQuotaRequest { + account_id: account_id.clone(), + refresh: false, + rolling_usage: None, + }) + .await + .map_err(|error| map_provider_error(error, "forecast quota snapshot"))?; + let now = Utc::now(); + let mut samples = Vec::new(); + for (window, _) in quota.usage_windows() { + let (Some(mut query), Some(observed), Some(percent)) = ( + quota_usage_window(account_id.as_str(), window), + quota.observed_at, + window.used_percent, + ) else { + continue; + }; + query.range.start = query.range.start.max(stored.account.created_at); + if query.range.start >= observed + || observed > now + || now >= query.range.end + || !percent.is_finite() + || !(0.0..=100.0).contains(&percent) + { + continue; + } + let reset_at = query.range.end; + query.range.end = observed; + let history = self + .accounts + .load_quota_forecast_history(&query) + .await + .map_err(|error| map_store_error(error, "forecast paired usage"))?; + let mut points = Vec::new(); + let mut interrupted = false; + for point in history.points { + let Some(fact) = + provider.quota_forecast_observation(&point.provider_observation, window) + else { + continue; + }; + let same_plan = quota + .plan_type + .as_deref() + .zip(fact.plan_type.as_deref()) + .is_some_and(|(current, previous)| current.eq_ignore_ascii_case(previous)); + // 仅容纳已观测到的秒级量化抖动,不用宽时间容差合并实际重置。 + // 不匹配的段截断基线;之后的有效观测可以重新积累。 + if !same_plan || (fact.reset_at - reset_at).abs() > Duration::seconds(2) { + points.clear(); + interrupted = true; + continue; + } + points.push(QuotaForecastPoint { + observed_at: point.completed_at, + used_percent: fact.used_percent, + usage: point.usage, + }); + } + let mut sample = select_forecast_sample( + window.key.clone(), + query.range.start, + QuotaForecastPoint { + observed_at: observed, + used_percent: percent, + usage: history.usage, + }, + points, + history.pending_request_count, + ); + if interrupted + && sample.method + == crate::model::quota_forecast_sampling::QuotaForecastMethod::Cumulative + { + sample.discontinuous = true; + } + samples.push(sample); + } + Ok(AccountQuotaForecastReport { + account_id: account_id.to_string(), + generated_at: now, + forecasts: account_quota_forecasts("a, stored.account.created_at, now, &samples), + }) + } + async fn profile_statistics( &self, account_id: &ProviderAccountId, @@ -762,14 +861,12 @@ fn quota_usage_window( account_id: &str, window: &ProviderQuotaWindow, ) -> Option { - if window.local_usage.is_some() - || window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide - { + if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide { return None; } let reset_at = window.reset_at?; let seconds = i64::try_from(window.window_seconds?).ok()?; - let start = reset_at.checked_sub_signed(Duration::seconds(seconds))?; + let start = reset_at.checked_sub_signed(Duration::try_seconds(seconds)?)?; let range = TimeRange::new(start, reset_at).ok()?; // 上游百分比以该 reset 边界定义;以当前时间回推会让本地 Token 属于另一窗口。 Some(AccountUsageWindowQuery { diff --git a/backend/crates/gateway-admin/tests/model/mod.rs b/backend/crates/gateway-admin/tests/model/mod.rs index 06e102438..9fd0a88f2 100644 --- a/backend/crates/gateway-admin/tests/model/mod.rs +++ b/backend/crates/gateway-admin/tests/model/mod.rs @@ -2,3 +2,5 @@ mod backup; mod provider_credentials; +mod quota_forecast; +mod quota_forecast_sampling; diff --git a/backend/crates/gateway-admin/tests/model/quota_forecast.rs b/backend/crates/gateway-admin/tests/model/quota_forecast.rs new file mode 100644 index 000000000..13aacb8c1 --- /dev/null +++ b/backend/crates/gateway-admin/tests/model/quota_forecast.rs @@ -0,0 +1,323 @@ +use chrono::{DateTime, Duration, Utc}; +use gateway_admin::model::{ + accounts::{AccountCost, AccountUsage}, + observability::CostCoverage, + provider_credentials::{ProviderQuota, ProviderQuotaWindow, QuotaLocalUsageAttribution}, + quota_forecast::{AccountQuotaForecast, account_quota_forecasts}, + quota_forecast_sampling::{QuotaForecastMethod, QuotaForecastSample, QuotaForecastUsage}, +}; + +fn now() -> DateTime { + "2026-09-12T00:00:00Z".parse().unwrap() +} + +fn window(key: &str, days: u64) -> ProviderQuotaWindow { + ProviderQuotaWindow { + key: key.to_owned(), + group: if days > 7 { "monthly" } else { "shortTerm" }.to_owned(), + label: key.to_owned(), + limit_id: None, + limit_name: None, + role: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, + window_seconds: Some(days * 86_400), + used_percent: Some(20.0), + reset_at: Some(now() + Duration::days(1)), + limit_reached: false, + local_usage: Some(AccountUsage { + account_id: "acct_forecast".to_owned(), + request_count: 10, + success_count: 10, + input_tokens: Some(900), + output_tokens: Some(100), + cached_tokens: Some(800), + cache_write_tokens: None, + reasoning_tokens: None, + image_input_tokens: None, + image_output_tokens: None, + image_request_count: 0, + image_request_failed_count: 0, + total_tokens: Some(1_000), + cost_coverage: CostCoverage { + calculated_count: 10, + ..Default::default() + }, + costs: vec![AccountCost { + currency: "USD".to_owned(), + amount: "2".parse().unwrap(), + }], + last_used_at: Some(now()), + request_buckets: Vec::new(), + models: Vec::new(), + }), + provider_data: None, + } +} + +fn quota(windows: Vec) -> ProviderQuota { + ProviderQuota { + plan_type: None, + observed_at: Some(now()), + refresh_token_expires_at: None, + windows, + limit_reached: false, + provider_data: None, + } +} + +fn forecast(quota: &ProviderQuota) -> [AccountQuotaForecast; 2] { + account_quota_forecasts(quota, now() - Duration::days(60), now(), &samples(quota)) +} + +fn samples(quota: &ProviderQuota) -> Vec { + quota + .windows + .iter() + .filter_map(|window| { + let usage = window.local_usage.as_ref()?; + let usd = usage.costs.iter().find(|cost| cost.currency == "USD"); + Some(QuotaForecastSample { + key: window.key.clone(), + method: QuotaForecastMethod::Cumulative, + start_at: window.reset_at?.checked_sub_signed(Duration::try_seconds( + i64::try_from(window.window_seconds?).ok()?, + )?)?, + end_at: quota.observed_at.unwrap_or(now()), + baseline_percent: 0.0, + sampled_percent: window.used_percent.unwrap_or(0.0), + block_count: 0, + observation_count: 0, + pending_request_count: 0, + discontinuous: false, + usage: QuotaForecastUsage { + request_count: usage.request_count, + tokens: usage.total_tokens.unwrap_or(0), + input_tokens: usage.input_tokens.unwrap_or(0), + output_tokens: usage.output_tokens.unwrap_or(0), + cached_tokens: usage.cached_tokens.unwrap_or(0), + missing_token_count: u64::from(usage.total_tokens.is_none()), + known_cost_count: if usd.is_some() { + usage.cost_coverage.known_count() + } else { + 0 + }, + unavailable_cost_count: usage.cost_coverage.partial_count + + usage.cost_coverage.unavailable_count, + usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()), + excluded_request_count: 0, + }, + }) + }) + .collect() +} + +#[test] +fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() { + let [week, month] = forecast("a(vec![window("week", 7)])); + assert_eq!(week.estimated_tokens, Some(5_000)); + assert_eq!(week.estimated_usd, Some(10.0)); + assert_eq!(week.remaining_tokens, Some(4_000)); + assert_eq!(week.remaining_usd, Some(8.0)); + assert!(!week.extrapolated); + assert!(month.extrapolated); + assert_eq!(month.estimated_tokens, Some(21_429)); + assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10); + assert_eq!(month.remaining_tokens, week.remaining_tokens); + assert_eq!(month.remaining_usd, week.remaining_usd); +} + +#[test] +fn corresponding_actual_window_takes_priority_and_retains_its_duration() { + let [week, month] = forecast("a(vec![window("month", 31), window("week", 7)])); + assert_eq!(week.source.unwrap().key, "week"); + assert_eq!(month.source.unwrap().key, "month"); + assert!(!month.extrapolated); + assert_eq!(month.target_seconds, 31 * 86_400); + assert_eq!(month.estimated_tokens, Some(5_000)); + let [week, _] = forecast("a(vec![window("month", 31)])); + assert!(week.extrapolated); + assert_eq!(week.estimated_tokens, Some(1_129)); +} + +#[test] +fn short_or_model_specific_windows_are_not_account_capacity() { + let mut model = window("model", 7); + model.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable; + for item in forecast("a(vec![window("day", 1), model])) { + assert!(item.source.is_none()); + assert!(item.unavailable_reason.is_some()); + assert!(item.estimated_tokens.is_none()); + } +} + +#[test] +fn invalid_or_tiny_percentages_cannot_produce_estimates() { + for percent in [ + None, + Some(f64::NAN), + Some(f64::INFINITY), + Some(-1.0), + Some(101.0), + Some(0.0), + Some(0.5), + Some(1.0), + Some(4.9), + ] { + let mut sample = window("week", 7); + sample.used_percent = percent; + let [week, _] = forecast("a(vec![sample])); + assert!(week.unavailable_reason.is_some(), "{percent:?}"); + assert!(week.estimated_tokens.is_none()); + assert!(week.estimated_usd.is_none()); + } +} + +#[test] +fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() { + let mut sample = window("week", 7); + sample.used_percent = Some(5.0); + sample.limit_reached = true; + let mut source = quota(vec![sample]); + source.limit_reached = true; + let [week, _] = forecast(&source); + assert!(week.low_sample); + assert_eq!(week.estimated_tokens, Some(20_000)); + source.windows[0].used_percent = Some(100.0); + let [week, _] = forecast(&source); + assert!(!week.low_sample); + assert_eq!(week.remaining_tokens, Some(0)); + assert_eq!(week.remaining_usd, Some(0.0)); +} + +#[test] +fn stale_future_or_missing_snapshots_do_not_predict() { + for observed in [ + None, + Some(now() - Duration::days(8)), + Some(now() + Duration::seconds(1)), + ] { + let mut source = quota(vec![window("week", 7)]); + source.observed_at = observed; + assert!(forecast(&source)[0].unavailable_reason.is_some()); + } + let mut sample = window("week", 7); + sample.reset_at = Some(now()); + assert!( + forecast("a(vec![sample]))[0] + .unavailable_reason + .is_some() + ); +} + +#[test] +fn partial_account_history_or_missing_usage_keep_diagnostics_without_estimates() { + let source = quota(vec![window("week", 7)]); + let [week, _] = + account_quota_forecasts(&source, now() - Duration::days(1), now(), &samples(&source)); + assert!(week.unavailable_reason.unwrap().contains("记录不完整")); + assert!(week.estimated_tokens.is_none()); + let mut sample = window("week", 7); + sample.local_usage = None; + let [week, _] = forecast("a(vec![sample])); + assert!(week.source.is_some()); + assert!( + week.unavailable_reason + .unwrap() + .contains("没有网关用量记录") + ); +} + +#[test] +fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_percent() { + let source = quota(vec![window("week", 7)]); + let mut sample = samples(&source).remove(0); + sample.method = QuotaForecastMethod::Incremental; + sample.start_at = now() - Duration::hours(6); + sample.baseline_percent = 10.0; + sample.sampled_percent = 10.0; + sample.block_count = 2; + let [week, month] = + account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]); + assert_eq!(week.estimated_tokens, Some(10_000)); + assert_eq!(week.remaining_tokens, Some(8_000)); + assert_eq!(month.remaining_tokens, Some(8_000)); + assert_eq!(week.method, QuotaForecastMethod::Incremental); + assert!(!week.low_sample); +} + +#[test] +fn missing_tokens_suppress_token_estimates_without_discarding_complete_costs() { + let source = quota(vec![window("week", 7)]); + let mut sample = samples(&source).remove(0); + sample.usage.missing_token_count = 1; + let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]); + assert!(week.incomplete_tokens); + assert_eq!(week.estimated_tokens, None); + assert_eq!(week.remaining_tokens, None); + assert_eq!(week.estimated_usd, Some(10.0)); + assert_eq!(week.source.unwrap().missing_token_count, 1); +} + +#[test] +fn discontinuous_samples_never_fall_back_to_cumulative_predictions() { + let source = quota(vec![window("week", 7)]); + let mut sample = samples(&source).remove(0); + sample.discontinuous = true; + let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]); + assert!(week.unavailable_reason.unwrap().contains("不连续")); + assert!(week.estimated_tokens.is_none()); +} + +#[test] +fn partial_or_unknown_costs_suppress_only_money_estimates() { + for coverage in [ + CostCoverage { + calculated_count: 9, + partial_count: 1, + ..Default::default() + }, + CostCoverage { + calculated_count: 9, + unavailable_count: 1, + ..Default::default() + }, + CostCoverage::default(), + ] { + let mut sample = window("week", 7); + sample.local_usage.as_mut().unwrap().cost_coverage = coverage; + let [week, _] = forecast("a(vec![sample])); + assert!(week.incomplete_cost); + assert!(week.unavailable_reason.is_none()); + assert!(week.estimated_usd.is_none()); + assert!(week.remaining_usd.is_none()); + assert_eq!(week.estimated_tokens, Some(5_000)); + } +} + +#[test] +fn zero_known_cost_is_valid_but_missing_usd_is_not_invented() { + let mut sample = window("week", 7); + sample.local_usage.as_mut().unwrap().costs[0].amount = "0".parse().unwrap(); + assert_eq!( + forecast("a(vec![sample.clone()]))[0].estimated_usd, + Some(0.0) + ); + sample.local_usage.as_mut().unwrap().costs[0].currency = "EUR".to_owned(); + assert_eq!(forecast("a(vec![sample]))[0].estimated_usd, None); +} + +#[test] +fn oversized_window_or_estimate_is_unavailable_without_panics_or_saturation() { + let mut sample = window("month", 30); + sample.window_seconds = Some(i64::MAX as u64); + assert!( + forecast("a(vec![sample]))[1] + .unavailable_reason + .is_some() + ); + let mut sample = window("week", 7); + sample.local_usage.as_mut().unwrap().total_tokens = Some(u64::MAX); + let [week, _] = forecast("a(vec![sample])); + assert!(week.estimated_tokens.is_none()); + assert_eq!(week.estimated_usd, Some(10.0)); +} diff --git a/backend/crates/gateway-admin/tests/model/quota_forecast_sampling.rs b/backend/crates/gateway-admin/tests/model/quota_forecast_sampling.rs new file mode 100644 index 000000000..636887562 --- /dev/null +++ b/backend/crates/gateway-admin/tests/model/quota_forecast_sampling.rs @@ -0,0 +1,124 @@ +use chrono::{DateTime, Duration, Utc}; +use gateway_admin::model::quota_forecast_sampling::{ + QuotaForecastMethod, QuotaForecastPoint, QuotaForecastSample, QuotaForecastUsage, + select_forecast_sample, +}; + +fn time(minutes: i64) -> DateTime { + "2026-09-12T00:00:00Z".parse::>().unwrap() + Duration::minutes(minutes) +} + +fn point(minute: i64, percent: f64, tokens: u64) -> QuotaForecastPoint { + QuotaForecastPoint { + observed_at: time(minute), + used_percent: percent, + usage: QuotaForecastUsage { + request_count: minute as u64, + tokens, + input_tokens: tokens, + known_cost_count: minute as u64, + usd: tokens as f64 / 1_000.0, + ..Default::default() + }, + } +} + +fn select(points: Vec, current: QuotaForecastPoint) -> QuotaForecastSample { + select_forecast_sample("week".to_owned(), time(0), current, points, 2) +} + +#[test] +fn repeated_readings_do_not_make_independent_blocks_or_shift_the_baseline() { + let points = (1..100) + .map(|minute| point(minute, 20.0, minute as u64 * 100)) + .collect(); + let sample = select(points, point(100, 25.0, 10_000)); + assert_eq!(sample.method, QuotaForecastMethod::Incremental); + assert_eq!(sample.block_count, 1); + assert_eq!(sample.start_at, time(1)); + assert_eq!(sample.sampled_percent, 5.0); + assert_eq!(sample.usage.tokens, 9_900); + assert_eq!(sample.pending_request_count, 2); +} + +#[test] +fn recent_blocks_and_tail_replace_old_workload_without_averaging_individual_ratios() { + let points = vec![ + point(1, 0.0, 0), + point(2, 5.0, 100), + point(3, 10.0, 200), + point(4, 15.0, 400), + point(5, 20.0, 600), + point(6, 25.0, 800), + ]; + let sample = select(points, point(7, 27.0, 900)); + assert_eq!(sample.baseline_percent, 10.0); + assert_eq!(sample.sampled_percent, 17.0); + assert_eq!(sample.usage.tokens, 700); + assert_eq!(sample.block_count, 3); +} + +#[test] +fn snapshot_at_a_block_boundary_keeps_only_three_recent_blocks() { + let points = (0..6) + .map(|i| point(i + 1, i as f64 * 5.0, i as u64 * 100)) + .collect(); + let sample = select(points, point(7, 30.0, 600)); + assert_eq!(sample.baseline_percent, 15.0); + assert_eq!(sample.sampled_percent, 15.0); + assert_eq!(sample.usage.tokens, 300); +} + +#[test] +fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption() { + let sample = select( + vec![point(1, 40.0, 100), point(2, 50.0, 200), point(3, 1.0, 210)], + point(4, 8.0, 280), + ); + assert_eq!(sample.baseline_percent, 1.0); + assert_eq!(sample.usage.tokens, 70); + assert_eq!(sample.sampled_percent, 7.0); + assert!(!sample.discontinuous); + let waiting = select( + vec![point(1, 40.0, 100), point(2, 50.0, 200)], + point(3, 2.0, 210), + ); + assert!(waiting.discontinuous); +} + +#[test] +fn one_point_reversal_is_skipped_without_counting_an_extra_block() { + let sample = select( + vec![ + point(1, 10.0, 100), + point(2, 15.0, 200), + point(3, 14.0, 210), + ], + point(4, 20.0, 300), + ); + assert_eq!(sample.block_count, 2); + assert_eq!(sample.usage.tokens, 200); + assert!(!sample.discontinuous); +} + +#[test] +fn missing_token_and_cost_counts_are_local_to_the_selected_interval() { + let mut baseline = point(1, 30.0, 1_000); + baseline.usage.missing_token_count = 2; + baseline.usage.unavailable_cost_count = 3; + let mut end = point(3, 40.0, 2_000); + end.usage.missing_token_count = 2; + end.usage.unavailable_cost_count = 4; + let sample = select(vec![baseline], end); + assert_eq!(sample.usage.missing_token_count, 0); + assert_eq!(sample.usage.unavailable_cost_count, 1); +} + +#[test] +fn counter_regressions_fail_closed_and_future_points_are_not_used() { + let invalid = select(vec![point(1, 10.0, 1_000)], point(2, 20.0, 500)); + assert!(invalid.discontinuous); + let future = select(vec![point(10, 1.0, 1)], point(2, 20.0, 500)); + assert_eq!(future.method, QuotaForecastMethod::Cumulative); + assert_eq!(future.observation_count, 0); +} diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index bba8ae933..b0b9c0c6a 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -42,6 +42,7 @@ use gateway_admin::{ ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult, QuotaLocalUsageAttribution, }, + quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage}, settings::{ AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings, @@ -432,6 +433,21 @@ impl ProviderAdmin for FakeProviderAdmin { }) } + fn quota_forecast_observation( + &self, + document: &ProviderDocument, + _: &ProviderQuotaWindow, + ) -> Option { + let fields = document.expose_to_provider().expose_to_provider(); + Some( + gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation { + used_percent: fields.get("percent")?.as_f64()?, + reset_at: fields.get("reset")?.as_str()?.parse().ok()?, + plan_type: fields.get("plan")?.as_str().map(str::to_owned), + }, + ) + } + async fn models( &self, _: &ProviderAccountId, @@ -472,6 +488,7 @@ pub(super) struct FakeAccountStore { import_settings: Mutex>>, quota_window_usage: Mutex>, quota_window_queries: Mutex>, + quota_forecast_history: Mutex, } impl FakeAccountStore { @@ -489,6 +506,7 @@ impl FakeAccountStore { import_settings: Mutex::new(Vec::new()), quota_window_usage: Mutex::new(Vec::new()), quota_window_queries: Mutex::new(Vec::new()), + quota_forecast_history: Mutex::new(QuotaForecastHistory::default()), }) } @@ -640,6 +658,17 @@ impl AccountStore for FakeAccountStore { .clone()) } + async fn load_quota_forecast_history( + &self, + window: &AccountUsageWindowQuery, + ) -> AdminStoreResult { + self.quota_window_queries + .lock() + .unwrap() + .push(window.clone()); + Ok(self.quota_forecast_history.lock().unwrap().clone()) + } + async fn credential_details( &self, provider_kind: &ProviderKind, @@ -1711,6 +1740,174 @@ async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() { ); } +#[tokio::test] +async fn quota_forecast_reads_raw_snapshot_and_limits_usage_to_observation_time() { + let provider = FakeProviderAdmin::new("openai", events()); + let now = Utc::now(); + let observed = now - TimeDelta::hours(1); + let reset = now + TimeDelta::days(1); + provider.set_quota(ProviderQuota { + observed_at: Some(observed), + limit_reached: true, + windows: vec![ProviderQuotaWindow { + key: "week".to_owned(), + group: "shortTerm".to_owned(), + label: "周额度".to_owned(), + limit_id: None, + limit_name: None, + role: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, + window_seconds: Some(7 * 86_400), + used_percent: Some(20.0), + reset_at: Some(reset), + limit_reached: true, + // Provider 自带的统计不保证与快照同一时间,预测必须重新采样。 + local_usage: Some(quota_local_usage("acct_test", 999_999)), + provider_data: None, + }], + ..empty_quota() + }); + let mut account = account_record("openai"); + account.created_at = now - TimeDelta::days(60); + let store = FakeAccountStore::with_account(account, events()); + store.set_quota_window_usage(vec![AccountUsageWindowResult { + account_id: "acct_test".to_owned(), + key: "week".to_owned(), + usage: quota_local_usage("acct_test", 1_000), + }]); + store.quota_forecast_history.lock().unwrap().usage = QuotaForecastUsage { + request_count: 10, + tokens: 1_000, + known_cost_count: 10, + ..Default::default() + }; + let report = accounts_service(provider.clone(), store.clone()) + .await + .accounts() + .quota_forecast(&ProviderAccountId::new("acct_test").unwrap()) + .await + .expect("quota forecast"); + assert_eq!(report.account_id, "acct_test"); + assert_eq!(report.forecasts[0].estimated_tokens, Some(5_000)); + assert_eq!( + report.forecasts[0].source.as_ref().unwrap().observed_at, + Some(observed) + ); + let queries = store.quota_window_queries(); + assert_eq!(queries.len(), 1); + assert_eq!(queries[0].range.start, reset - TimeDelta::days(7)); + assert_eq!(queries[0].range.end, observed); + let requests = provider.quota_requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert!(!requests[0].refresh); + assert!(requests[0].rolling_usage.is_none()); +} + +#[tokio::test] +async fn quota_forecast_does_not_query_usage_without_a_current_snapshot() { + let provider = FakeProviderAdmin::new("openai", events()); + let store = FakeAccountStore::new("openai", events()); + let report = accounts_service(provider.clone(), store.clone()) + .await + .accounts() + .quota_forecast(&ProviderAccountId::new("acct_test").unwrap()) + .await + .expect("empty forecast"); + assert!( + report + .forecasts + .iter() + .all(|item| item.estimated_tokens.is_none()) + ); + assert!(store.quota_window_queries().is_empty()); + assert!(!provider.quota_requests.lock().unwrap()[0].refresh); +} + +#[tokio::test] +async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_changed_bucket() { + use gateway_admin::model::quota_forecast_sampling::{ + QuotaForecastHistoryPoint, QuotaForecastMethod, + }; + let now = Utc::now(); + let observed = now - TimeDelta::minutes(1); + let reset = now + TimeDelta::days(1); + let added = now - TimeDelta::hours(5); + let provider = FakeProviderAdmin::new("openai", events()); + provider.set_quota(ProviderQuota { + plan_type: Some("pro".to_owned()), + observed_at: Some(observed), + windows: vec![ProviderQuotaWindow { + key: "week".to_owned(), + group: "shortTerm".to_owned(), + label: "周额度".to_owned(), + limit_id: None, + limit_name: None, + role: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, + window_seconds: Some(604_800), + used_percent: Some(40.0), + reset_at: Some(reset), + limit_reached: false, + local_usage: None, + provider_data: None, + }], + ..empty_quota() + }); + let mut account = account_record("openai"); + account.created_at = added; + let store = FakeAccountStore::with_account(account, events()); + let make_point = |hours, percent, tokens, delta| { + QuotaForecastHistoryPoint { + started_at: now - TimeDelta::hours(hours) - TimeDelta::seconds(10), + completed_at: now - TimeDelta::hours(hours), + usage: QuotaForecastUsage { request_count: tokens / 100, tokens, ..Default::default() }, + provider_observation: ProviderDocument::new(OpaqueProviderData::new(serde_json::json!({ + "percent": percent, "reset": (reset + TimeDelta::seconds(delta)).to_rfc3339(), "plan": "pro", + }).as_object().unwrap().clone())), + } + }; + *store.quota_forecast_history.lock().unwrap() = QuotaForecastHistory { + points: vec![ + make_point(3, 20.0, 1_000, 1), + make_point(2, 30.0, 1_500, -1), + ], + usage: QuotaForecastUsage { + request_count: 20, + tokens: 2_000, + ..Default::default() + }, + pending_request_count: 1, + }; + let services = accounts_service(provider, store.clone()).await; + let result = services + .accounts() + .quota_forecast(&ProviderAccountId::new("acct_test").unwrap()) + .await + .unwrap(); + assert_eq!(result.forecasts[0].method, QuotaForecastMethod::Incremental); + assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000)); + assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000)); + assert_eq!(store.quota_window_queries()[0].range.start, added); + store + .quota_forecast_history + .lock() + .unwrap() + .points + .push(make_point(1, 35.0, 1_700, 3_600)); + let result = services + .accounts() + .quota_forecast(&ProviderAccountId::new("acct_test").unwrap()) + .await + .unwrap(); + assert!(result.forecasts[0].estimated_tokens.is_none()); + assert!( + result.forecasts[0] + .unavailable_reason + .unwrap() + .contains("不连续") + ); +} + #[tokio::test] async fn accounts_list_should_attach_local_usage_to_quota_windows() { let provider = FakeProviderAdmin::new("openai", events()); diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index 49d79bab5..03aa63d2c 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -371,6 +371,13 @@ impl AccountStore for UnavailableStore { Err(unavailable("account quota window usage")) } + async fn load_quota_forecast_history( + &self, + _: &AccountUsageWindowQuery, + ) -> AdminStoreResult { + Err(unavailable("quota forecast history")) + } + async fn credential_details( &self, _: &ProviderKind, diff --git a/backend/crates/gateway-api/src/admin/accounts/handlers.rs b/backend/crates/gateway-api/src/admin/accounts/handlers.rs index 118459ffb..18f4f403a 100644 --- a/backend/crates/gateway-api/src/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/src/admin/accounts/handlers.rs @@ -22,6 +22,10 @@ where post(batch_update_accounts::), ) .route("/api/admin/accounts/quota", get(account_quota::)) + .route( + "/api/admin/accounts/quota-forecast", + get(account_quota_forecast::), + ) .route( "/api/admin/accounts/profile-statistics", get(account_profile_statistics::), @@ -364,6 +368,27 @@ where Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } +async fn account_quota_forecast( + _auth: AdminAuth, + State(state): State, + AdminQuery(query): AdminQuery, +) -> Result +where + S: AdminSessionState + Send + Sync, +{ + let account_id = query.into_id().map_err(map_wire_error)?; + let result = state + .admin_services() + .accounts() + .quota_forecast(&account_id) + .await + .map_err(map_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(AccountQuotaForecastData::from(result)), + )) +} + async fn account_profile_statistics( _auth: AdminAuth, State(state): State, diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index eb467f208..4768d3b11 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -1,6 +1,8 @@ //! Admin 领域结果到安全 HTTP wire 的展示投影。 use super::*; +use gateway_admin::model::quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport}; +use gateway_admin::model::quota_forecast_sampling::QuotaForecastMethod; pub(super) fn account_page_data( result: AccountDirectoryPage, @@ -122,6 +124,101 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> Ac } } +impl From for AccountQuotaForecastData { + fn from(report: AccountQuotaForecastReport) -> Self { + Self { + account_id: report.account_id, + generated_at: china_rfc3339(&report.generated_at), + generated_at_display: china_datetime(&report.generated_at), + forecasts: report + .forecasts + .into_iter() + .map(quota_forecast_view) + .collect(), + } + } +} + +fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastView { + AccountQuotaForecastView { + period: match forecast.period { + AccountUsagePeriod::Weekly => "weekly", + AccountUsagePeriod::Monthly => "monthly", + }, + target_days: forecast.target_seconds as f64 / 86_400.0, + extrapolated: forecast.extrapolated, + source: forecast.source.map(|source| QuotaForecastSourceView { + key: source.key, + label: source.label, + window_days: source.window_seconds as f64 / 86_400.0, + used_percent: source.used_percent, + used_percent_display: source + .used_percent + .map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")), + observed_at: source.observed_at.map(|value| china_rfc3339(&value)), + observed_at_display: source + .observed_at + .map_or_else(|| "—".to_owned(), |value| china_datetime(&value)), + start_at: source.start_at.map(|value| china_rfc3339(&value)), + start_at_display: source + .start_at + .map_or_else(|| "—".to_owned(), |value| china_datetime(&value)), + reset_at: china_rfc3339(&source.reset_at), + reset_at_display: china_datetime(&source.reset_at), + request_count_display: format_number(source.request_count), + tokens_display: display_optional_tokens(source.tokens), + input_tokens_display: display_optional_tokens(source.input_tokens), + output_tokens_display: display_optional_tokens(source.output_tokens), + cached_tokens_display: display_optional_tokens(source.cached_tokens), + known_cost_count: source.known_cost_count, + known_cost_count_display: format_number(source.known_cost_count), + partial_cost_count: source.partial_cost_count, + partial_cost_count_display: format_number(source.partial_cost_count), + unavailable_cost_count: source.unavailable_cost_count, + unavailable_cost_count_display: format_number(source.unavailable_cost_count), + usd_display: forecast_usd_display(source.usd), + sample_start_at: source.sample_start_at.map(|value| china_rfc3339(&value)), + sample_start_at_display: source + .sample_start_at + .map_or_else(|| "—".to_owned(), |value| china_datetime(&value)), + baseline_percent: source.baseline_percent, + sampled_percent: source.sampled_percent, + sampled_percent_display: source + .sampled_percent + .map_or_else(|| "—".to_owned(), |value| format!("{value:.1} 个百分点")), + block_count: source.block_count, + observation_count: source.observation_count, + missing_token_count: source.missing_token_count, + excluded_request_count: source.excluded_request_count, + pending_request_count: source.pending_request_count, + }), + unavailable_reason: forecast.unavailable_reason, + low_sample: forecast.low_sample, + incomplete_cost: forecast.incomplete_cost, + incomplete_tokens: forecast.incomplete_tokens, + method: forecast.method.as_str(), + method_display: match forecast.method { + QuotaForecastMethod::Cumulative => "窗口累计估算", + QuotaForecastMethod::Incremental => "近期分段估算", + }, + estimated_tokens: forecast.estimated_tokens, + estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens), + estimated_usd: forecast.estimated_usd, + estimated_usd_display: forecast_usd_display(forecast.estimated_usd), + remaining_tokens: forecast.remaining_tokens, + remaining_tokens_display: display_optional_tokens(forecast.remaining_tokens), + remaining_usd: forecast.remaining_usd, + remaining_usd_display: forecast_usd_display(forecast.remaining_usd), + } +} + +fn forecast_usd_display(value: Option) -> String { + value.map_or_else( + || "—".to_owned(), + |value| format_decimal_currency(&format!("{value:.10}"), "USD"), + ) +} + pub(super) fn account_quota_view( mut quota: ProviderQuota, rate_limited_until: Option>, diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index ba0773688..52f42b95a 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -272,6 +272,77 @@ pub struct AccountView { pub usage: AccountUsageView, } +/// 容量估算仅供管理端展示;金额不是订阅账单或可消费余额。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountQuotaForecastData { + pub account_id: String, + pub generated_at: String, + pub generated_at_display: String, + pub forecasts: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountQuotaForecastView { + pub period: &'static str, + pub target_days: f64, + pub extrapolated: bool, + pub source: Option, + pub unavailable_reason: Option<&'static str>, + pub low_sample: bool, + pub incomplete_cost: bool, + pub incomplete_tokens: bool, + pub method: &'static str, + pub method_display: &'static str, + pub estimated_tokens: Option, + pub estimated_tokens_display: String, + pub estimated_usd: Option, + pub estimated_usd_display: String, + pub remaining_tokens: Option, + pub remaining_tokens_display: String, + pub remaining_usd: Option, + pub remaining_usd_display: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaForecastSourceView { + pub key: String, + pub label: String, + pub window_days: f64, + pub used_percent: Option, + pub used_percent_display: String, + pub observed_at: Option, + pub observed_at_display: String, + pub start_at: Option, + pub start_at_display: String, + pub reset_at: String, + pub reset_at_display: String, + pub request_count_display: String, + pub tokens_display: String, + pub input_tokens_display: String, + pub output_tokens_display: String, + pub cached_tokens_display: String, + pub known_cost_count: u64, + pub known_cost_count_display: String, + pub partial_cost_count: u64, + pub partial_cost_count_display: String, + pub unavailable_cost_count: u64, + pub unavailable_cost_count_display: String, + pub usd_display: String, + pub sample_start_at: Option, + pub sample_start_at_display: String, + pub baseline_percent: f64, + pub sampled_percent: Option, + pub sampled_percent_display: String, + pub block_count: usize, + pub observation_count: usize, + pub missing_token_count: u64, + pub excluded_request_count: u64, + pub pending_request_count: u64, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountGroupRefView { diff --git a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs new file mode 100644 index 000000000..8f0dc3aaf --- /dev/null +++ b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs @@ -0,0 +1,54 @@ +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use gateway_api::admin; +use tower::ServiceExt as _; + +use super::super::{AdminTestFixture, AdminTestState}; + +#[tokio::test] +async fn quota_forecast_requires_admin_and_valid_account_query() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for (uri, authenticated, expected) in [ + ( + "/api/admin/accounts/quota-forecast?accountId=acct_test", + false, + StatusCode::UNAUTHORIZED, + ), + ( + "/api/admin/accounts/quota-forecast", + true, + StatusCode::BAD_REQUEST, + ), + ( + "/api/admin/accounts/quota-forecast?accountId=bad", + true, + StatusCode::BAD_REQUEST, + ), + ( + "/api/admin/accounts/quota-forecast?accountId=acct_test&refresh=true", + true, + StatusCode::BAD_REQUEST, + ), + ] { + let mut request = Request::builder() + .uri(uri) + .header("x-request-id", "req_forecast"); + if authenticated { + request = request.header(header::COOKIE, "cpr_admin_session=valid-session"); + } + let response = admin::router::() + .with_state(fixture.state()) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected, "{uri}"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let body = to_bytes(response.into_body(), 8192).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(value["data"].is_null()); + assert!(value["message"].is_string()); + } +} diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index 98fcbbc27..232eef3c8 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -1,3 +1,6 @@ +mod handlers; +mod presenter; + mod query { use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection}; use gateway_api::admin::accounts::ListQuery; diff --git a/backend/crates/gateway-api/tests/admin/accounts/presenter.rs b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs new file mode 100644 index 000000000..ab94624a5 --- /dev/null +++ b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs @@ -0,0 +1,80 @@ +use gateway_admin::model::{ + provider_credentials::AccountUsagePeriod, + quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport, QuotaForecastSource}, + quota_forecast_sampling::QuotaForecastMethod, +}; +use gateway_api::admin::accounts::AccountQuotaForecastData; + +#[test] +fn quota_forecast_projection_preserves_null_zero_and_sample_metadata() { + let now = "2026-09-12T00:00:00Z".parse().unwrap(); + let forecast = AccountQuotaForecast { + period: AccountUsagePeriod::Weekly, + target_seconds: 7 * 86_400, + extrapolated: false, + source: Some(QuotaForecastSource { + key: "week".to_owned(), + label: "周额度".to_owned(), + window_seconds: 7 * 86_400, + used_percent: Some(100.0), + observed_at: Some(now), + start_at: Some(now), + reset_at: now, + request_count: 1_024, + tokens: Some(1_000_000), + input_tokens: Some(900_000), + output_tokens: Some(100_000), + cached_tokens: Some(800_000), + known_cost_count: 1_000, + partial_cost_count: 24, + unavailable_cost_count: 0, + usd: Some(0.1234), + sample_start_at: Some(now), + baseline_percent: 85.0, + sampled_percent: Some(15.0), + block_count: 3, + observation_count: 96, + missing_token_count: 0, + excluded_request_count: 4, + pending_request_count: 2, + }), + unavailable_reason: None, + low_sample: false, + incomplete_cost: true, + incomplete_tokens: false, + method: QuotaForecastMethod::Incremental, + estimated_tokens: Some(1_000_000), + estimated_usd: None, + remaining_tokens: Some(0), + remaining_usd: None, + }; + let mut monthly = forecast.clone(); + monthly.period = AccountUsagePeriod::Monthly; + monthly.extrapolated = true; + monthly.target_seconds = 30 * 86_400; + let view = AccountQuotaForecastData::from(AccountQuotaForecastReport { + account_id: "acct_forecast".to_owned(), + generated_at: now, + forecasts: [forecast, monthly], + }); + let value = serde_json::to_value(view).unwrap(); + assert_eq!(value["accountId"], "acct_forecast"); + assert_eq!(value["generatedAt"], "2026-09-12T08:00:00+08:00"); + let week = &value["forecasts"][0]; + assert_eq!(week["estimatedTokens"], 1_000_000); + assert_eq!(week["estimatedTokensDisplay"], "1M"); + assert!(week["estimatedUsd"].is_null()); + assert_eq!(week["estimatedUsdDisplay"], "—"); + assert_eq!(week["remainingTokensDisplay"], "0"); + assert_eq!(week["source"]["requestCountDisplay"], "1,024"); + assert_eq!(week["source"]["usdDisplay"], "$0.1234"); + assert_eq!(week["source"]["partialCostCount"], 24); + assert_eq!(week["method"], "incremental"); + assert_eq!(week["source"]["sampledPercentDisplay"], "15.0 个百分点"); + assert_eq!(week["source"]["pendingRequestCount"], 2); + assert!(week["source"].get("providerData").is_none()); + assert_eq!(value["forecasts"][1]["period"], "monthly"); + assert_eq!(value["forecasts"][1]["targetDays"], 30.0); + assert_eq!(value["forecasts"][1]["extrapolated"], true); + assert!(value.get("account").is_none()); +} diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index c42bf1b98..6e40bfe99 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -748,6 +748,13 @@ impl AccountStore for UnusedStore { Err(unavailable("account quota window usage")) } + async fn load_quota_forecast_history( + &self, + _: &AccountUsageWindowQuery, + ) -> AdminStoreResult { + Err(unavailable("quota forecast history")) + } + async fn credential_details( &self, _: &ProviderKind, diff --git a/backend/crates/gateway-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 3dfe53841..0071354af 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -89,7 +89,9 @@ fn test_tree_should_match_frozen_rust_mirror() { actual.sort(); let mut expected = vec![ "tests/admin/account_groups.rs", + "tests/admin/accounts/handlers.rs", "tests/admin/accounts/mod.rs", + "tests/admin/accounts/presenter.rs", "tests/admin/auth.rs", "tests/admin/client_keys.rs", "tests/admin/errors.rs", diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index d087c9dbf..f9d43fa15 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -476,6 +476,13 @@ impl AccountStore for PgAdminAccountStore { self.usage_by_windows(windows).await } + async fn load_quota_forecast_history( + &self, + window: &AccountUsageWindowQuery, + ) -> AdminStoreResult { + super::quota_forecast::load_history(&self.pool, &self.query_budget, window).await + } + async fn credential_details( &self, provider_kind: &ProviderKind, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs index ab0f24497..9c8b40bd7 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs @@ -61,6 +61,7 @@ mod admin_adapter; mod admin_queries; mod core_adapter; mod mapping; +mod quota_forecast; mod repository; mod rows; mod runtime; diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/quota_forecast.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/quota_forecast.rs new file mode 100644 index 000000000..b244d18ae --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/quota_forecast.rs @@ -0,0 +1,141 @@ +//! 预测只读采样:先聚合数值、再读取有界的 Provider 文档,不扫描模型排行维度。 + +use gateway_admin::model::quota_forecast_sampling::{ + MAX_FORECAST_HISTORY_POINTS, QuotaForecastHistory, QuotaForecastHistoryPoint, + QuotaForecastUsage, +}; + +use super::*; + +pub(super) async fn load_history( + pool: &PgPool, + budget: &super::super::ObservabilityQueryBudget, + window: &AccountUsageWindowQuery, +) -> AdminStoreResult { + validate_admin_account_ids(std::slice::from_ref(&window.account_id)) + .map_err(|error| admin_store_error(ENTITY, error))?; + let range = ObservabilityRange::new(window.range.start, window.range.end) + .map_err(|error| admin_store_error(ENTITY, error))?; + if range.end - range.start > TimeDelta::days(32) { + return Err(AdminStoreError::new( + AdminStoreErrorKind::Invalid, + ENTITY, + "forecast range exceeds supported quota windows", + )); + } + let rows = budget + .run("load quota forecast history", async { + sqlx::query(sqlx::AssertSqlSafe(history_sql())) + .bind(&window.account_id) + .bind(range.start) + .bind(range.end) + .bind(MAX_FORECAST_HISTORY_POINTS as i32) + .fetch_all(pool) + .await + .map_err(|_| postgres_unavailable("load quota forecast history")) + }) + .await + .map_err(|error| admin_store_error(ENTITY, error))?; + let mut history = QuotaForecastHistory::default(); + for row in &rows { + let usage = QuotaForecastUsage { + request_count: window_usage_count(row, "request_count")?, + tokens: window_usage_count(row, "tokens")?, + input_tokens: window_usage_count(row, "input_tokens")?, + output_tokens: window_usage_count(row, "output_tokens")?, + cached_tokens: window_usage_count(row, "cached_tokens")?, + missing_token_count: window_usage_count(row, "missing_token_count")?, + known_cost_count: window_usage_count(row, "known_cost_count")?, + unavailable_cost_count: window_usage_count(row, "unavailable_cost_count")?, + usd: window_usage_value(row, "usd")?, + excluded_request_count: window_usage_count(row, "excluded_request_count")?, + }; + if window_usage_value::(row, "is_total")? { + history.usage = usage; + history.pending_request_count = window_usage_count(row, "pending_count")?; + } else if let Some(document) = window_usage_value::< + Option>>, + >(row, "document")? + { + history.points.push(QuotaForecastHistoryPoint { + started_at: window_usage_value(row, "started_at")?, + completed_at: window_usage_value(row, "completed_at")?, + usage, + provider_observation: ProviderDocument::new(OpaqueProviderData::new(document.0)), + }); + } + } + Ok(history) +} + +fn history_sql() -> String { + let completed_usage = completed_usage_fact_predicate("mr"); + // 一个语句共享 MVCC 快照。用 RANGE 帧让相同完成时间的点拥有相同累计值, + // 避免并发请求的任意行顺序制造不同分子;未完成请求只进入待决计数。 + format!( + "with scoped as materialized ( + select mr.id, mr.started_at, mr.completed_at, + mr.completed_at <= $3 and mr.outcome <> 'running' as settled, + coalesce(({completed_usage}), false) as included, + mr.provider_observation_json is not null as has_document, + mr.input_tokens, mr.output_tokens, mr.cached_tokens, mr.total_tokens, + mr.cost_amount, mr.cost_currency, + coalesce(mr.cost_source in ('calculated', 'provider_reported') + and mr.cost_amount is not null and mr.cost_currency = 'USD', false) as known_cost + from model_requests mr + where mr.provider_account_ref = $1 + and mr.started_at >= $2 and mr.started_at < $3 + ), facts as materialized ( + select *, + (included and settled)::integer as request_count, + case when included and settled then coalesce(total_tokens, + coalesce(input_tokens, 0) + coalesce(output_tokens, 0)) else 0 end as tokens, + case when included and settled then coalesce(input_tokens, 0) else 0 end as inputs, + case when included and settled then coalesce(output_tokens, 0) else 0 end as outputs, + case when included and settled then coalesce(cached_tokens, 0) else 0 end as cached, + (included and settled and total_tokens is null + and (input_tokens is null or output_tokens is null))::integer as missing_tokens, + (included and settled and known_cost)::integer as known_costs, + (included and settled and not known_cost)::integer as missing_costs, + case when included and settled and known_cost then cost_amount else 0 end as usd, + (not included and settled)::integer as excluded + from scoped + ), cumulative as ( + select id, started_at, completed_at, has_document, + least($4 - 1, floor(extract(epoch from (completed_at - $2)) + / greatest(1, extract(epoch from ($3::timestamptz - $2::timestamptz)) / $4))) as bucket, + sum(request_count) over w as request_count, + sum(tokens) over w as tokens, + sum(inputs) over w as input_tokens, + sum(outputs) over w as output_tokens, + sum(cached) over w as cached_tokens, + sum(missing_tokens) over w as missing_token_count, + sum(known_costs) over w as known_cost_count, + sum(missing_costs) over w as unavailable_cost_count, + sum(usd) over w as usd, + sum(excluded) over w as excluded_request_count + from facts where settled + window w as (order by completed_at range between unbounded preceding and current row) + ), selected as ( + select distinct on (bucket) * from cumulative + where has_document order by bucket, completed_at desc, id desc + ) + select false as is_total, s.started_at, s.completed_at, + s.request_count::bigint, s.tokens::bigint, s.input_tokens::bigint, + s.output_tokens::bigint, s.cached_tokens::bigint, s.missing_token_count::bigint, + s.known_cost_count::bigint, s.unavailable_cost_count::bigint, + s.usd::double precision, s.excluded_request_count::bigint, + 0::bigint as pending_count, mr.provider_observation_json as document + from selected s join model_requests mr on mr.id = s.id + union all + select true, $3, $3, + coalesce(sum(request_count), 0)::bigint, coalesce(sum(tokens), 0)::bigint, + coalesce(sum(inputs), 0)::bigint, coalesce(sum(outputs), 0)::bigint, + coalesce(sum(cached), 0)::bigint, coalesce(sum(missing_tokens), 0)::bigint, + coalesce(sum(known_costs), 0)::bigint, coalesce(sum(missing_costs), 0)::bigint, + coalesce(sum(usd), 0)::double precision, coalesce(sum(excluded), 0)::bigint, + count(*) filter (where settled is not true), null::jsonb + from facts + order by completed_at, is_total" + ) +} diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index b8f6b872f..5dcff16e5 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -4,6 +4,8 @@ use std::{ time::{Duration, SystemTime}, }; +mod quota_forecast; + use chrono::{TimeDelta, Utc}; use gateway_admin::{ model::{ diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs new file mode 100644 index 000000000..4d41c7ba8 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs @@ -0,0 +1,169 @@ +use super::*; +use gateway_admin::model::quota_forecast_sampling::MAX_FORECAST_HISTORY_POINTS; + +#[tokio::test] +async fn quota_forecast_pairs_completed_usage_and_preserves_missing_pending_and_excluded_counts() { + let Some(database) = TestDatabase::create("quota_forecast_pairing").await else { + return; + }; + PgProviderAccountRepository::new(database.pool.clone()) + .insert_provider_account(account("acct_forecast", "user-forecast")) + .await + .unwrap(); + let start = "2026-09-12T00:00:00Z" + .parse::>() + .unwrap(); + let end = start + TimeDelta::minutes(30); + for (id, minute, tokens) in [ + ("before", -1, 999), + ("start", 0, 100), + ("tie_a", 5, 200), + ("tie_b", 5, 300), + ("missing", 10, 400), + ("eur", 15, 500), + ("late", 20, 600), + ("cancelled", 21, 0), + ("websocket", 22, 700), + ("http", 23, 800), + ("after", 30, 999), + ] { + seed_model_request( + &database.pool, + ModelRequestSeed { + request_id: id, + account_id: "acct_forecast", + provider_kind: "openai", + model: "gpt-test", + total_tokens: tokens, + cost_amount: "1", + started_at: start + TimeDelta::minutes(minute), + }, + ) + .await + .unwrap(); + } + sqlx::query( + "update model_requests set provider_observation_json = + '{\"privateMarker\":\"never-debug-this\",\"rateLimitHeaders\":[]}'::jsonb", + ) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query( + "update model_requests set total_tokens = null, input_tokens = null, + output_tokens = null, cost_source = 'unavailable', cost_amount = null, cost_currency = null + where id = 'missing'", + ) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("update model_requests set cost_currency = 'EUR' where id = 'eur'") + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("update model_requests set completed_at = $1 where id = 'late'") + .bind(end + TimeDelta::seconds(1)) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("update model_requests set outcome = 'cancelled' where id = 'cancelled'") + .execute(&database.pool) + .await + .unwrap(); + sqlx::query( + "update model_requests set client_status_code = null, + client_transport = case when id = 'websocket' then 'websocket' else 'http_sse' end + where id in ('websocket', 'http')", + ) + .execute(&database.pool) + .await + .unwrap(); + let store = admin_account_store(&database.pool); + let history = store + .load_quota_forecast_history(&AccountUsageWindowQuery { + account_id: "acct_forecast".to_owned(), + key: "week".to_owned(), + range: TimeRange { start, end }, + }) + .await + .expect("forecast history"); + assert_eq!(history.usage.request_count, 6); + assert_eq!(history.usage.tokens, 1_800); + assert_eq!(history.usage.missing_token_count, 1); + assert_eq!(history.usage.known_cost_count, 4); + assert_eq!(history.usage.unavailable_cost_count, 2); + assert_eq!(history.usage.usd, 4.0); + assert_eq!(history.usage.excluded_request_count, 2); + assert_eq!(history.pending_request_count, 1); + assert!(history.points.iter().all(|point| point.completed_at <= end)); + let tie = history + .points + .iter() + .find(|point| point.completed_at == start + TimeDelta::minutes(5) + TimeDelta::seconds(1)) + .unwrap(); + assert_eq!(tie.usage.tokens, 600); + assert_eq!(tie.usage.request_count, 3); + assert!(!format!("{history:?}").contains("never-debug-this")); + let empty = store + .load_quota_forecast_history(&AccountUsageWindowQuery { + account_id: "acct_forecast".to_owned(), + key: "empty".to_owned(), + range: TimeRange { + start: end + TimeDelta::hours(1), + end: end + TimeDelta::hours(2), + }, + }) + .await + .unwrap(); + assert_eq!(empty.usage.request_count, 0); + assert!(empty.points.is_empty()); + database.close().await; +} + +#[tokio::test] +async fn quota_forecast_bounds_documents_without_sampling_away_token_totals() { + let Some(database) = TestDatabase::create("quota_forecast_bound").await else { + return; + }; + PgProviderAccountRepository::new(database.pool.clone()) + .insert_provider_account(account("acct_forecast_bound", "user-forecast-bound")) + .await + .unwrap(); + let start = Utc::now() - TimeDelta::hours(6); + for index in 0..256 { + seed_model_request( + &database.pool, + ModelRequestSeed { + request_id: &format!("forecast_{index}"), + account_id: "acct_forecast_bound", + provider_kind: "openai", + model: "gpt-test", + total_tokens: 100, + cost_amount: "0", + started_at: start + TimeDelta::minutes(index), + }, + ) + .await + .unwrap(); + } + sqlx::query("update model_requests set provider_observation_json = '{}'::jsonb") + .execute(&database.pool) + .await + .unwrap(); + let history = admin_account_store(&database.pool) + .load_quota_forecast_history(&AccountUsageWindowQuery { + account_id: "acct_forecast_bound".to_owned(), + key: "week".to_owned(), + range: TimeRange { + start, + end: start + TimeDelta::minutes(256), + }, + }) + .await + .unwrap(); + assert_eq!(history.usage.request_count, 256); + assert_eq!(history.usage.tokens, 25_600); + assert_eq!(history.points.len(), MAX_FORECAST_HISTORY_POINTS); + assert_eq!(history.points.last().unwrap().usage.tokens, 25_600); + database.close().await; +} diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 33099bc68..abeb5418f 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -27,6 +27,7 @@ use gateway_admin::model::provider_credentials::{ ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution, }; +use gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation; use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind}; use gateway_core::account::{ CredentialCasUpdateParts, CredentialRevision, LoadedCredential, NewProviderAccount, @@ -480,6 +481,38 @@ impl ProviderAdmin for OpenAiAdminProvider { Ok(project_quota(snapshot, &account)) } + fn quota_forecast_observation( + &self, + document: &ProviderDocument, + window: &ProviderQuotaWindow, + ) -> Option { + use gateway_protocol::openai::events::parse_rate_limit_headers; + + if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide { + return None; + } + let value = document + .expose_to_provider() + .expose_to_provider() + .get("rateLimitHeaders")?; + let headers: Vec<(String, String)> = serde_json::from_value(value.clone()).ok()?; + let parsed = parse_rate_limit_headers(&headers)?; + let limit = parsed.limits.get(window.limit_id.as_deref()?)?; + let observed = match window.role? { + ProviderQuotaWindowRole::Primary => limit.primary?, + ProviderQuotaWindowRole::Secondary => limit.secondary?, + ProviderQuotaWindowRole::Monthly => return None, + }; + if observed.window_minutes?.checked_mul(60)? != window.window_seconds? { + return None; + } + Some(QuotaForecastObservation { + used_percent: observed.used_percent, + reset_at: DateTime::::from_timestamp(observed.reset_at?, 0)?, + plan_type: parsed.plan_type, + }) + } + async fn profile_statistics( &self, account_id: &ProviderAccountId, diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 2bd838dfe..6eb428a7c 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -136,6 +136,79 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions assert!(bundle.take_worker_contributions().is_empty()); } +#[tokio::test] +async fn quota_forecast_observation_reuses_protocol_parser_and_keeps_window_identity() { + let config = valid_config(); + let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) + .await + .unwrap(); + let provider = bundle.admin_provider(); + let mut window = gateway_admin::model::provider_credentials::ProviderQuotaWindow { + key: "codex:604800s".to_owned(), + group: "shortTerm".to_owned(), + label: "周额度".to_owned(), + limit_id: Some("codex".to_owned()), + limit_name: None, + role: Some(ProviderQuotaWindowRole::Primary), + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, + window_seconds: Some(604_800), + used_percent: Some(50.0), + reset_at: None, + limit_reached: false, + local_usage: None, + provider_data: None, + }; + let document = ProviderDocument::new(OpaqueProviderData::new( + json!({ + "requestSummary": {"ignored": true}, + "rateLimitHeaders": [ + ["x-codex-primary-used-percent", "32.5"], + ["x-codex-primary-window-minutes", "10080"], + ["x-codex-primary-reset-at", "1789805447"], + ["x-codex-secondary-used-percent", "4"], + ["x-codex-secondary-window-minutes", "300"], + ["x-codex-secondary-reset-at", "1789218647"], + ["x-codex-plan-type", "pro"] + ] + }) + .as_object() + .unwrap() + .clone(), + )); + let observed = provider + .quota_forecast_observation(&document, &window) + .unwrap(); + assert_eq!(observed.used_percent, 32.5); + assert_eq!(observed.plan_type.as_deref(), Some("pro")); + assert_eq!(observed.reset_at.timestamp(), 1_789_805_447); + window.role = Some(ProviderQuotaWindowRole::Secondary); + assert!( + provider + .quota_forecast_observation(&document, &window) + .is_none() + ); + window.role = Some(ProviderQuotaWindowRole::Primary); + window.limit_id = Some("other_bucket".to_owned()); + assert!( + provider + .quota_forecast_observation(&document, &window) + .is_none() + ); + let malformed = ProviderDocument::new(OpaqueProviderData::new( + json!({ + "rateLimitHeaders": "not-a-header-list" + }) + .as_object() + .unwrap() + .clone(), + )); + assert!( + provider + .quota_forecast_observation(&malformed, &window) + .is_none() + ); +} + #[tokio::test] async fn initialized_provider_keeps_thread_spawn_transport_conversations_distinct() { let account_id = "acct_initialized_thread_spawn"; diff --git a/docs/api.md b/docs/api.md index 342b33a08..1e99edec7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -226,6 +226,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。 | `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds, outboundProxyId?, outboundProxyUrl? }` | 一次事务统一更新所选账号的调度字段、完整分组集合与可选代理 | | `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 | | `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 | +| `GET` | `/api/admin/accounts/quota-forecast` | `accountId` | 按需读取周/月容量预测、源窗口剩余估算与采样依据,不刷新上游额度 | | `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 | | `GET` | `/api/admin/accounts/profile-statistics` | `accountId` | 实时查询 OpenAI/Codex 官方个人资料中的累计活动与使用洞察 | | `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询 OpenAI 上游主动额度重置卡,不读取本地库存 | @@ -456,6 +457,52 @@ OAuth start 使用: 新状态不符合当前筛选,该行从当前页移除。请求驱动或后台任务产生的状态变化,需要下一次显式查询账号 列表后才会显示。 +### 周/月额度预测 + +`GET /api/admin/accounts/quota-forecast?accountId=...` 使用现有管理员鉴权,返回独立的预测结果,不向 +账号列表或详情附加预测字段。管理端从“模型使用排行”后的图标打开弹窗时查询;切换周/月仅切换本次结果, +不轮询。关闭弹窗取消未完成的查询,重新打开重新采样。“刷新额度”仍调用现有 +`POST /api/admin/accounts/quota/refresh`,成功后替换账号行并重新查询预测。 + +响应 `data` 包含 `accountId`、`generatedAt`、`generatedAtDisplay` 和 `forecasts`(`weekly`、`monthly`): + +- `targetDays`、`extrapolated`:对应周期存在真实账号级窗口时采用实际时长;缺少对应窗口时,使用 + 可统计的周/月窗口按 7/30 天折算,明确标记 `extrapolated: true`。不把短期限流或模型专属桶当作账号容量。 +- `estimatedTokens` / `estimatedUsd` 及对应 `*Display`:完整目标周期的近似容量, + 公式为 `样本用量 × 100 / sampledPercent × 目标窗口秒数 / 源窗口秒数`。 +- `remainingTokens` / `remainingUsd` 及对应 `*Display`:**额度快照时源窗口**的剩余估算, + 公式为 `样本用量 × (100 - usedPercent) / sampledPercent`;不随目标周期折算,不代表当前可消费余额。 +- `method` / `methodDisplay`:`incremental` 为近期分段估算,`cumulative` 为窗口累计估算。 + 同一额度段内,以历史额度和截至相应完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段, + 使用最近 3 个完整段及未满一段的尾部。只对合并区间计算比值,不平均逐请求小分母比值。 + 重复读数不增加段数;大于 1 个百分点的回落或累计计数倒退会中断采样,不能静默跨越。 +- `source`:源窗口标识、实际天数、原始已用比例、窗口开始 / 额度快照 / 重置时间, + **选中采样区间**的请求数、总 Token、输入 / 输出 / 缓存命中构成、USD 费用与费用记录数。 + `sampleStartAt` / `sampleStartAtDisplay` 为实际采样开始,`baselinePercent` 为基线已用比例, + `sampledPercent` / `sampledPercentDisplay` 为有效额度进度(百分点,而非时间进度)。 + `blockCount` 是采用的完整进度段数,`observationCount` 是可供配对的历史点数,二者均不是独立统计样本数。 + `missingTokenCount` 为不能由 total 或完整 input/output 确定 Token 的请求数; + `excludedRequestCount` 为选中区间内未进入成功用量口径的已结束请求数; + `pendingRequestCount` 为查询范围内在额度快照时尚未结束的请求数。 + 缓存命中属于输入,不重复相加。费用覆盖针对有效 USD 记录;其他币种或缺少有效金额均不能当成零 USD。 +- `unavailableReason`:不能估算时的说明,正常为 `null`。有效进度少于 5 个百分点不预测; + `lowSample` 表示有效进度不足 10 个百分点,或增量采样少于 2 个完整段,仅是质量提示,不承诺精度。 + `incompleteCost` 仅抑制费用预测;`incompleteTokens` 仅抑制 Token 预测。未知值不按确定的零消耗外推。 + +采样查询 `[max(resetAt - windowSeconds, accountAddedAt), observedAt)` 内开始的请求, +仅把 `completedAt <= observedAt` 的完整交付用量计入当前分子;历史分子按各点的完成时间累计, +同完成时间使用同一累计值。每个源窗口从同一数据库语句取得累计数值和最多 128 个按时间分桶的历史文档, +不会因历史点抽样而丢弃其间的成功 Token,也不重新聚合模型排行;接口不返回原始 Provider 文档。 + +OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明确的套餐。仅允许最多 2 秒的重置时间抖动, +超出或套餐改变则截断历史基线;不将宽时间容差当作上游窗口身份。历史文档缺少独立额度观测时间, +只能以请求完成时间近似配对,不证明上游扣额与本地完成同步。无法积累有效增量时,仅在账号于窗口开始前 +已加入且没有已知采样断点的情况下使用累计估算;中途加入账号可以在新基线之后积累,无需等待下次重置。 + +预测没有独立持久化或调度状态,也不参与账单结算。站外消耗、历史日志清理、异步观测延迟、未成功交付的 +上游消耗和模型组合变化仍可能造成误差;等价 USD 费用不是官方订阅价格或固定额度承诺, +30 天折算也不是自然月额度。记录覆盖率不等于预测准确率,不输出未经校准的置信区间。 + ### OpenAI 官方个人资料统计 `GET /api/admin/accounts/profile-statistics?accountId=...` 仅支持 OpenAI/Codex OAuth 账号。每次查询直接 diff --git a/docs/architecture.md b/docs/architecture.md index c06d40f4e..7706555c8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -389,6 +389,12 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota ## 10. 观测与后台任务 +账号容量预测属于 Admin 的只读派生规则,不参与 quota 权威状态、调度或金额结算。Store 通过专用采样端口 +在同一 SQL 快照内返回截至观测时间的累计数值及有界历史 Provider 文档;文档仅由具体 Provider 复用协议 +解析器解释,Admin 按中立的额度事实选择近期进度段。该采样不改变全站完整交付用量口径,不创建第二份 +持久化额度状态。未知 Token/美元费用分别抑制对应预测;历史请求完成时间只是额度时间的近似,不承诺 +严格扣额归因或预测准确率。公开字段与采样门槛见 [周/月额度预测](api.md#周月额度预测)。 + 请求观测通过有界进程内队列异步投影到 PostgreSQL。队列满、Store 暂不可用或进程退出超时时,允许丢失 可恢复观测并累计指标,但不允许改变客户端响应。Usage 详情中的 attempt 因此是 best-effort,并通过 `attemptsComplete: false` 明示不完整性。 diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 27da0c246..d1a7c046b 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -144,6 +144,68 @@ export interface Account { groups: AccountGroupRef[] } +export interface AccountQuotaForecast { + period: 'weekly' | 'monthly' + targetDays: number + extrapolated: boolean + source: { + key: string + label: string + windowDays: number + usedPercent: number | null + usedPercentDisplay: string + observedAt: string | null + observedAtDisplay: string + startAt: string | null + startAtDisplay: string + resetAt: string + resetAtDisplay: string + requestCountDisplay: string + tokensDisplay: string + inputTokensDisplay: string + outputTokensDisplay: string + cachedTokensDisplay: string + knownCostCount: number + knownCostCountDisplay: string + partialCostCount: number + partialCostCountDisplay: string + unavailableCostCount: number + unavailableCostCountDisplay: string + usdDisplay: string + sampleStartAt: string | null + sampleStartAtDisplay: string + baselinePercent: number + sampledPercent: number | null + sampledPercentDisplay: string + blockCount: number + observationCount: number + missingTokenCount: number + excludedRequestCount: number + pendingRequestCount: number + } | null + unavailableReason: string | null + lowSample: boolean + incompleteCost: boolean + incompleteTokens: boolean + method: 'cumulative' | 'incremental' + methodDisplay: string + estimatedTokens: number | null + estimatedTokensDisplay: string + estimatedUsd: number | null + estimatedUsdDisplay: string + remainingTokens: number | null + remainingTokensDisplay: string + remainingUsd: number | null + remainingUsdDisplay: string +} + +export interface AccountQuotaForecastResponse { + accountId: string + generatedAt: string + generatedAtDisplay: string + forecasts: AccountQuotaForecast[] +} + export interface AccountPageMeta { page: number pageSize: number @@ -395,6 +457,15 @@ export function getAccountProfileStatistics(data: AccountIdParam, options: Reque }) } +export function getAccountQuotaForecast(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/quota-forecast', + method: 'GET', + params: data, + ...options, + }) +} + export function accountProfileAvatarUrl(accountId: string, sourceUrl: string) { const params = new URLSearchParams({ accountId, diff --git a/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue b/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue new file mode 100644 index 000000000..b5e682438 --- /dev/null +++ b/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue @@ -0,0 +1,98 @@ + + + diff --git a/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastSample.vue b/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastSample.vue new file mode 100644 index 000000000..f25468602 --- /dev/null +++ b/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastSample.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/views/accounts/components/AccountQuotaForecastModal/Skeleton.vue b/frontend/src/views/accounts/components/AccountQuotaForecastModal/Skeleton.vue new file mode 100644 index 000000000..1b49352a6 --- /dev/null +++ b/frontend/src/views/accounts/components/AccountQuotaForecastModal/Skeleton.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/views/accounts/components/AccountQuotaForecastModal/index.vue b/frontend/src/views/accounts/components/AccountQuotaForecastModal/index.vue new file mode 100644 index 000000000..cbf2909d0 --- /dev/null +++ b/frontend/src/views/accounts/components/AccountQuotaForecastModal/index.vue @@ -0,0 +1,174 @@ + + + diff --git a/frontend/src/views/accounts/components/AccountUsagePanel.vue b/frontend/src/views/accounts/components/AccountUsagePanel.vue index 7049c4be0..659976753 100644 --- a/frontend/src/views/accounts/components/AccountUsagePanel.vue +++ b/frontend/src/views/accounts/components/AccountUsagePanel.vue @@ -1,16 +1,23 @@