feat(accounts): add sampled weekly and monthly quota forecasts

Expose a dedicated read-only forecast endpoint with bounded quota history sampling, recent progress blocks, reset continuity checks, and independent token and USD completeness gates. Keep prediction state separate from authoritative quota and billing.

Add the account forecast dialog with weekly/monthly switching, sampling details, loading skeletons, themed cards, and refresh-icon feedback. Document estimation formulas and limits, and cover model, provider, HTTP, and PostgreSQL boundaries.

Validated 28 forecast-related tests including isolated PostgreSQL, 443 Admin/API regression tests, 12 architecture checks, strict affected-crate Clippy, Rustfmt, and frontend lint/typecheck/build. Browser acceptance remains user-owned.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-12 23:09:52 +08:00
co-authored by Codex
parent 322b602154
commit 5041d6ba26
37 changed files with 2712 additions and 33 deletions
@@ -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;
@@ -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<Item = (&ProviderQuotaWindow, AccountUsagePeriod)> {
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 使用的代表性额度比例。
@@ -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<Utc>,
pub forecasts: [AccountQuotaForecast; 2],
}
#[derive(Debug, Clone, PartialEq)]
pub struct AccountQuotaForecast {
pub period: AccountUsagePeriod,
pub target_seconds: u64,
pub extrapolated: bool,
pub source: Option<QuotaForecastSource>,
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<u64>,
pub estimated_usd: Option<f64>,
/// 剩余估算始终属于源窗口,不随目标周期折算。
pub remaining_tokens: Option<u64>,
pub remaining_usd: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct QuotaForecastSource {
pub key: String,
pub label: String,
pub window_seconds: u64,
pub used_percent: Option<f64>,
pub observed_at: Option<DateTime<Utc>>,
pub start_at: Option<DateTime<Utc>>,
pub reset_at: DateTime<Utc>,
pub request_count: u64,
pub tokens: Option<u64>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub known_cost_count: u64,
pub partial_cost_count: u64,
pub unavailable_cost_count: u64,
pub usd: Option<f64>,
pub sample_start_at: Option<DateTime<Utc>>,
pub baseline_percent: f64,
pub sampled_percent: Option<f64>,
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<Utc>,
now: DateTime<Utc>,
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<DateTime<Utc>>,
account_added_at: DateTime<Utc>,
now: DateTime<Utc>,
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<f64> {
let estimate = value * factor;
(estimate.is_finite() && estimate >= 0.0).then_some(estimate)
}
fn estimate_tokens(value: u64, factor: f64) -> Option<u64> {
estimate(value as f64, factor)
.filter(|value| value.round() < u64::MAX as f64)
.map(|value| value.round() as u64)
}
@@ -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<Self> {
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<Utc>,
pub completed_at: DateTime<Utc>,
pub usage: QuotaForecastUsage,
pub provider_observation: ProviderDocument,
}
#[derive(Debug, Clone, Default)]
pub struct QuotaForecastHistory {
pub points: Vec<QuotaForecastHistoryPoint>,
pub usage: QuotaForecastUsage,
pub pending_request_count: u64,
}
/// 具体 Provider 从自己的历史文档解释出的额度事实。
#[derive(Debug, Clone, PartialEq)]
pub struct QuotaForecastObservation {
pub used_percent: f64,
pub reset_at: DateTime<Utc>,
pub plan_type: Option<String>,
}
#[derive(Debug, Clone)]
pub struct QuotaForecastPoint {
pub observed_at: DateTime<Utc>,
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<Utc>,
pub end_at: DateTime<Utc>,
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<Utc>,
current: QuotaForecastPoint,
mut history: Vec<QuotaForecastPoint>,
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
}
@@ -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<ProviderQuota, ProviderAdminError>;
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算。
fn quota_forecast_observation(
&self,
_document: &ProviderDocument,
_window: &ProviderQuotaWindow,
) -> Option<QuotaForecastObservation> {
None
}
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
async fn profile_statistics(
&self,
@@ -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<Vec<AccountUsageWindowResult>>;
/// 从同一数据库语句取得截止快照的累计用量和有界历史观测。
async fn load_quota_forecast_history(
&self,
window: &AccountUsageWindowQuery,
) -> AdminStoreResult<QuotaForecastHistory>;
async fn credential_details(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
@@ -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<AccountDirectoryItem, AdminError>;
async fn quota_forecast(
&self,
account_id: &ProviderAccountId,
) -> Result<AccountQuotaForecastReport, AdminError>;
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::<Vec<_>>();
@@ -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<AccountQuotaForecastReport, AdminError> {
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(&quota, 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<AccountUsageWindowQuery> {
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 {
@@ -2,3 +2,5 @@
mod backup;
mod provider_credentials;
mod quota_forecast;
mod quota_forecast_sampling;
@@ -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<Utc> {
"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<ProviderQuotaWindow>) -> 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<QuotaForecastSample> {
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(&quota(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(&quota(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(&quota(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(&quota(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(&quota(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(&quota(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(&quota(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(&quota(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(&quota(vec![sample.clone()]))[0].estimated_usd,
Some(0.0)
);
sample.local_usage.as_mut().unwrap().costs[0].currency = "EUR".to_owned();
assert_eq!(forecast(&quota(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(&quota(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(&quota(vec![sample]));
assert!(week.estimated_tokens.is_none());
assert_eq!(week.estimated_usd, Some(10.0));
}
@@ -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<Utc> {
"2026-09-12T00:00:00Z".parse::<DateTime<Utc>>().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<QuotaForecastPoint>, 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);
}
@@ -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<gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation> {
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<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
quota_forecast_history: Mutex<QuotaForecastHistory>,
}
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<QuotaForecastHistory> {
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());
@@ -371,6 +371,13 @@ impl AccountStore for UnavailableStore {
Err(unavailable("account quota window usage"))
}
async fn load_quota_forecast_history(
&self,
_: &AccountUsageWindowQuery,
) -> AdminStoreResult<gateway_admin::model::quota_forecast_sampling::QuotaForecastHistory> {
Err(unavailable("quota forecast history"))
}
async fn credential_details(
&self,
_: &ProviderKind,
@@ -22,6 +22,10 @@ where
post(batch_update_accounts::<S>),
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/quota-forecast",
get(account_quota_forecast::<S>),
)
.route(
"/api/admin/accounts/profile-statistics",
get(account_profile_statistics::<S>),
@@ -364,6 +368,27 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_quota_forecast<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -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<Utc>) -> Ac
}
}
impl From<AccountQuotaForecastReport> 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<f64>) -> 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<DateTime<Utc>>,
@@ -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<AccountQuotaForecastView>,
}
#[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<QuotaForecastSourceView>,
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<u64>,
pub estimated_tokens_display: String,
pub estimated_usd: Option<f64>,
pub estimated_usd_display: String,
pub remaining_tokens: Option<u64>,
pub remaining_tokens_display: String,
pub remaining_usd: Option<f64>,
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<f64>,
pub used_percent_display: String,
pub observed_at: Option<String>,
pub observed_at_display: String,
pub start_at: Option<String>,
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<String>,
pub sample_start_at_display: String,
pub baseline_percent: f64,
pub sampled_percent: Option<f64>,
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 {
@@ -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::<AdminTestState>()
.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());
}
}
@@ -1,3 +1,6 @@
mod handlers;
mod presenter;
mod query {
use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection};
use gateway_api::admin::accounts::ListQuery;
@@ -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());
}
@@ -748,6 +748,13 @@ impl AccountStore for UnusedStore {
Err(unavailable("account quota window usage"))
}
async fn load_quota_forecast_history(
&self,
_: &AccountUsageWindowQuery,
) -> AdminStoreResult<gateway_admin::model::quota_forecast_sampling::QuotaForecastHistory> {
Err(unavailable("quota forecast history"))
}
async fn credential_details(
&self,
_: &ProviderKind,
@@ -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",
@@ -476,6 +476,13 @@ impl AccountStore for PgAdminAccountStore {
self.usage_by_windows(windows).await
}
async fn load_quota_forecast_history(
&self,
window: &AccountUsageWindowQuery,
) -> AdminStoreResult<gateway_admin::model::quota_forecast_sampling::QuotaForecastHistory> {
super::quota_forecast::load_history(&self.pool, &self.query_budget, window).await
}
async fn credential_details(
&self,
provider_kind: &ProviderKind,
@@ -61,6 +61,7 @@ mod admin_adapter;
mod admin_queries;
mod core_adapter;
mod mapping;
mod quota_forecast;
mod repository;
mod rows;
mod runtime;
@@ -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<QuotaForecastHistory> {
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::<bool>(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<sqlx::types::Json<serde_json::Map<String, serde_json::Value>>>,
>(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"
)
}
@@ -4,6 +4,8 @@ use std::{
time::{Duration, SystemTime},
};
mod quota_forecast;
use chrono::{TimeDelta, Utc};
use gateway_admin::{
model::{
@@ -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::<chrono::DateTime<Utc>>()
.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;
}
@@ -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<QuotaForecastObservation> {
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::<Utc>::from_timestamp(observed.reset_at?, 0)?,
plan_type: parsed.plan_type,
})
}
async fn profile_statistics(
&self,
account_id: &ProviderAccountId,
@@ -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";
+47
View File
@@ -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 账号。每次查询直接
+6
View File
@@ -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` 明示不完整性。
+71
View File
@@ -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<AccountQuotaForecastResponse>({
url: '/api/admin/accounts/quota-forecast',
method: 'GET',
params: data,
...options,
})
}
export function accountProfileAvatarUrl(accountId: string, sourceUrl: string) {
const params = new URLSearchParams({
accountId,
@@ -0,0 +1,98 @@
<script setup lang="ts">
import type { AccountQuotaForecast } from '@/api'
import { ArrowRight, Info, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
const props = defineProps<{ forecast: AccountQuotaForecast }>()
const source = computed(() => props.forecast.source)
const metrics = computed(() => [
{
label: 'Token 容量',
observed: source.value?.tokensDisplay ?? '—',
estimated: props.forecast.estimatedTokensDisplay,
tone: 'text-cp-cyan-text',
},
{
label: '等价费用 · USD',
observed: source.value?.usdDisplay ?? '—',
estimated: props.forecast.estimatedUsdDisplay,
tone: 'text-cp-green-text',
},
])
</script>
<template>
<section v-if="source" aria-label="容量预测" class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 md:justify-between [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-cp-xs text-cp-text-secondary">{{ source.label }}已用</span>
<span
class="inline-flex items-center gap-1 rounded-cp-sm px-2 py-1 text-cp-xs font-emphasis"
:class="forecast.lowSample ? 'bg-cp-warning-container text-cp-warning-on-container' : 'bg-cp-info-container text-cp-info-on-container'"
>
<TriangleAlert v-if="forecast.lowSample" class="size-3" />
<Info v-else class="size-3" />
{{ forecast.lowSample ? '低样本 · 误差较大' : '估算值 · 仅供参考' }}
</span>
</div>
<div class="mt-2 flex items-end justify-between gap-4">
<strong class="font-mono text-3xl font-heavy tabular-nums text-cp-text [html[data-theme=light]_&]:font-emphasis">{{ source.usedPercentDisplay }}</strong>
<span class="pb-1 text-cp-xs text-cp-text-secondary">完整源窗口 <span class="font-mono">100%</span></span>
</div>
<!-- 实心为已观测比例,虚线为待推算容量,不伪造历史趋势或置信区间。 -->
<div
class="relative mt-3 h-3 overflow-hidden rounded-full bg-cp-bg-container"
role="img"
:aria-label="`源窗口已用 ${source.usedPercentDisplay},其余为推算区域`"
>
<span class="absolute inset-x-0 top-1/2 border-t-2 border-dashed border-cp-primary/50" />
<span class="absolute inset-y-0 left-0 rounded-full bg-cp-primary" :style="{ width: `${source.usedPercent ?? 0}%` }" />
</div>
<div class="mt-2 flex justify-between font-mono text-[10px] text-cp-text-tertiary" aria-hidden="true">
<span>0</span><span>25</span><span>50</span><span>75</span><span>100%</span>
</div>
</div>
<div class="grid gap-2">
<div class="flex justify-between px-3 text-cp-xs text-cp-text-secondary">
<span>采样区间已记录</span>
<span>完整{{ forecast.period === 'weekly' ? '周' : '月' }}预测</span>
</div>
<div class="grid gap-2">
<div v-for="metric in metrics" :key="metric.label" class="rounded-cp bg-cp-bg-container px-3 py-3">
<p class="m-0 text-cp-xs text-cp-text-secondary">
{{ metric.label }}
</p>
<div class="mt-2 grid grid-cols-[1fr_20px_1.2fr] items-center gap-2">
<span class="font-mono text-cp-sm font-emphasis tabular-nums text-cp-text [html[data-theme=light]_&]:font-medium">{{ metric.observed }}</span>
<ArrowRight class="size-3.5 text-cp-text-tertiary" />
<strong class="text-right font-mono text-xl font-heavy tabular-nums" :class="metric.tone">
<span v-if="metric.estimated !== '—'" class="mr-1 text-cp-sm font-normal text-cp-text-tertiary">≈</span>{{ metric.estimated }}
</strong>
</div>
</div>
</div>
</div>
<div class="grid gap-3">
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-2 text-cp-xs">
<span class="text-cp-text-secondary">快照时源窗口剩余</span>
<span class="font-mono font-emphasis text-cp-text [html[data-theme=light]_&]:font-medium">
{{ forecast.remainingTokensDisplay }} Tokens
<span class="mx-1 text-cp-text-tertiary">/</span>
{{ forecast.remainingUsdDisplay }}
</span>
</div>
<p v-if="forecast.lowSample" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
有效额度进度或分段样本较少,估算容易波动。
</p>
<p v-if="forecast.incompleteCost" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
美元费用记录不完整,暂不预测等价费用。
</p>
<p v-if="forecast.incompleteTokens" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
Token 记录不完整,暂不预测 Token 容量;未知用量不按零消耗估算。
</p>
</div>
</section>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import type { AccountQuotaForecast } from '@/api'
import { computed } from 'vue'
const props = defineProps<{ source: NonNullable<AccountQuotaForecast['source']>, methodDisplay: string }>()
const costCoverage = computed(() => {
const total = props.source.knownCostCount + props.source.partialCostCount + props.source.unavailableCostCount
return total > 0 ? `${(props.source.knownCostCount / total * 100).toFixed(1)}%` : '—'
})
const tokenRows = computed(() => [
{
label: '输入 Token',
value: props.source.inputTokensDisplay,
tone: 'bg-cp-green-container text-cp-green-on-container [html[data-theme=light]_&]:text-cp-green-text',
markerTone: 'bg-cp-green-solid',
},
{
label: '输出 Token',
value: props.source.outputTokensDisplay,
tone: 'bg-cp-orange-container text-cp-orange-on-container [html[data-theme=light]_&]:text-cp-orange-text',
markerTone: 'bg-cp-orange-solid',
},
{
label: '缓存命中',
value: props.source.cachedTokensDisplay,
tone: 'bg-cp-cyan-container text-cp-cyan-on-container [html[data-theme=light]_&]:text-cp-cyan-text',
markerTone: 'bg-cp-cyan-solid',
},
])
const timeRows = computed(() => [
{ label: '窗口开始', value: props.source.startAtDisplay },
{ label: '采样开始', value: props.source.sampleStartAtDisplay },
{ label: '采样截止 / 快照', value: props.source.observedAtDisplay },
{ label: '额度重置', value: props.source.resetAtDisplay },
])
</script>
<template>
<section aria-label="采样依据" class="flex flex-col gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 md:justify-between [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div class="grid gap-3">
<div class="grid gap-1.5">
<div class="flex items-baseline justify-between gap-3 text-cp-xs">
<span class="text-cp-text-secondary">采样请求</span>
<strong class="font-mono text-cp-sm font-heavy text-cp-text [html[data-theme=light]_&]:font-emphasis">{{ source.requestCountDisplay }}</strong>
</div>
<div class="flex flex-wrap items-baseline justify-between gap-2 text-cp-xs">
<span class="text-cp-text-secondary">{{ methodDisplay }}</span>
<span class="font-mono text-cp-text">{{ source.sampledPercentDisplay }}</span>
</div>
</div>
<dl class="m-0 grid gap-2">
<div
v-for="row in tokenRows"
:key="row.label"
class="flex items-center justify-between gap-3 rounded-cp px-3 py-2.5 text-cp-xs [html[data-theme=light]_&]:bg-cp-bg-container"
:class="row.tone"
>
<dt class="flex items-center gap-2 [html[data-theme=light]_&]:text-cp-text-secondary">
<span class="hidden size-1.5 shrink-0 rounded-full [html[data-theme=light]_&]:block" :class="row.markerTone" aria-hidden="true" />
{{ row.label }}
</dt>
<dd class="m-0 font-mono font-emphasis tabular-nums">
{{ row.value }}
</dd>
</div>
</dl>
</div>
<div class="grid gap-2">
<div class="flex justify-between gap-3 text-cp-xs">
<span class="text-cp-text-secondary">美元费用覆盖</span>
<strong class="font-mono font-emphasis text-cp-text">{{ costCoverage }}</strong>
</div>
<div class="h-1 overflow-hidden rounded-full bg-cp-fill-secondary" role="img" :aria-label="`费用记录覆盖 ${costCoverage}`">
<div class="h-full rounded-full bg-cp-success" :style="{ width: costCoverage === '—' ? '0%' : costCoverage }" />
</div>
<p class="m-0 text-cp-xs text-cp-text-tertiary">
完整 {{ source.knownCostCountDisplay }} 次 · 部分 {{ source.partialCostCountDisplay }} 次 · 缺失 {{ source.unavailableCostCountDisplay }} 次
</p>
<p v-if="source.blockCount > 0" class="m-0 text-cp-xs text-cp-text-tertiary">
{{ source.blockCount }} 个有效进度段 · 历史读数不等于独立样本
</p>
<p v-if="source.missingTokenCount > 0 || source.excludedRequestCount > 0 || source.pendingRequestCount > 0" class="m-0 text-cp-xs leading-relaxed text-cp-warning-text">
Token 缺失 {{ source.missingTokenCount }} 次 · 未纳入 {{ source.excludedRequestCount }} 次 · 快照时未完成 {{ source.pendingRequestCount }} 次
</p>
</div>
<dl class="m-0 grid gap-3 text-cp-xs">
<div v-for="row in timeRows" :key="row.label" class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<dt class="text-cp-text-secondary">
{{ row.label }}
</dt>
<dd class="m-0 font-mono text-cp-text">
{{ row.value }}
</dd>
</div>
</dl>
</section>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
</script>
<template>
<div role="status" aria-busy="true">
<span class="sr-only">正在加载额度预测</span>
<div class="grid items-start gap-4 md:grid-cols-[1.15fr_1fr] md:items-stretch" aria-hidden="true">
<div class="flex min-h-96 flex-col justify-between gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div>
<div class="flex items-center justify-between gap-3">
<BaseSkeleton shape="text" class="w-16" />
<BaseSkeleton class="h-6 w-28" />
</div>
<BaseSkeleton class="mt-2 h-8 w-28" />
<BaseSkeleton class="mt-3 h-3 w-full rounded-full" />
<div class="mt-2 flex justify-between">
<BaseSkeleton v-for="tick in 5" :key="tick" shape="text" class="h-2.5 w-5" />
</div>
</div>
<div class="grid gap-2">
<div class="mb-1 flex justify-between gap-3 px-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-20" />
</div>
<div v-for="metric in 2" :key="metric" class="rounded-cp bg-cp-bg-container px-3 py-3">
<BaseSkeleton shape="text" class="w-20" />
<div class="mt-3 grid grid-cols-[1fr_20px_1.2fr] items-center gap-2">
<BaseSkeleton shape="text" class="w-14" />
<BaseSkeleton class="size-3" />
<BaseSkeleton class="h-6 w-24 justify-self-end" />
</div>
</div>
</div>
<div class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-28" />
<BaseSkeleton shape="text" class="w-32" />
</div>
</div>
<div class="flex min-h-96 flex-col justify-between gap-5 rounded-cp-card bg-cp-fill-tertiary/70 p-4 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<div class="grid gap-3">
<div class="grid gap-1.5">
<div v-for="row in 2" :key="row" class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" :class="row === 1 ? 'w-8' : 'w-24'" />
</div>
</div>
<div class="grid gap-2">
<div v-for="row in 3" :key="row" class="flex h-9.5 items-center justify-between gap-3 rounded-cp bg-cp-bg-container px-3">
<div class="flex items-center gap-2">
<BaseSkeleton shape="circle" class="size-1.5" />
<BaseSkeleton shape="text" class="w-16" />
</div>
<BaseSkeleton shape="text" class="w-12" />
</div>
</div>
</div>
<div class="grid gap-2">
<div class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-24" />
<BaseSkeleton shape="text" class="w-12" />
</div>
<BaseSkeleton class="h-1 w-full rounded-full" />
<BaseSkeleton shape="text" class="w-3/4" />
</div>
<div class="grid gap-3">
<div v-for="row in 4" :key="row" class="flex justify-between gap-3">
<BaseSkeleton shape="text" class="w-20" />
<BaseSkeleton shape="text" class="w-40 max-w-3/5" />
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,174 @@
<script setup lang="ts">
import type { AccountRow } from '../../constants'
import type { Account } from '@/api'
import { ChartNoAxesCombined, Info, RefreshCw } from '@lucide/vue'
import { useNow } from '@vueuse/core'
import { computed, ref, toRef, useId, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BasePopover from '@/components/base/BasePopover.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useAccountQuotaForecast } from '../../composables/useAccountQuotaForecast'
import AccountIdentityCell from '../AccountIdentityCell.vue'
import ForecastCapacity from './ForecastCapacity.vue'
import ForecastSample from './ForecastSample.vue'
import ForecastSkeleton from './Skeleton.vue'
const props = defineProps<{ account: AccountRow }>()
const emit = defineEmits<{ accountUpdated: [account: Account] }>()
const open = defineModel<boolean>({ default: false })
const period = ref('weekly')
const explanationOpen = ref(false)
const explanationId = useId()
const { report, loading, refreshing, error, load, refresh } = useAccountQuotaForecast(
toRef(() => props.account.id),
open,
account => emit('accountUpdated', account),
)
const { now, pause, resume } = useNow({ interval: 30_000, controls: true })
const options = [
{ label: '周额度', value: 'weekly' },
{ label: '月额度', value: 'monthly' },
]
const forecast = computed(() => report.value?.forecasts.find(item => item.period === period.value))
const unavailableReason = computed(() => {
if (forecast.value?.source && new Date(forecast.value.source.resetAt) <= now.value)
return '额度窗口已过期,请刷新账号额度后重试。'
return forecast.value?.unavailableReason ?? null
})
watch(open, (value) => {
explanationOpen.value = false
if (value) {
period.value = 'weekly'
resume()
}
else {
pause()
}
}, { immediate: true })
function handleExplanationKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && explanationOpen.value) {
// 先关闭说明浮层,避免同一次按键也关闭其所属弹窗。
event.preventDefault()
event.stopPropagation()
explanationOpen.value = false
}
}
</script>
<template>
<BaseModal
v-model="open"
title="额度预测"
description="按当前用量结构,估算完整周期的容量"
size="xl"
tone="info"
>
<template #icon>
<ChartNoAxesCombined class="size-5 text-cp-primary-text" :stroke-width="1.75" />
</template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<AccountIdentityCell :account="account" show-plan title-mode="email">
<template #meta>
<ProviderIconGroup :provider="account.provider" size="sm" />
</template>
</AccountIdentityCell>
<BaseSegmented v-model="period" label="预测周期" :options="options" class="w-48" />
</div>
<div aria-live="polite" :aria-busy="loading || refreshing">
<ForecastSkeleton v-if="loading || refreshing" />
<div v-else-if="error && !report" class="grid rounded-cp-card bg-cp-fill-tertiary/70 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<BaseEmpty title="预测加载失败" description="暂时无法取得预测数据,请重新加载。" surface="none" class="min-h-80 content-center">
<template #action>
<BaseButton variant="secondary" @click="load">
重新加载
</BaseButton>
</template>
</BaseEmpty>
</div>
<div v-else-if="forecast" class="grid items-start gap-4 md:items-stretch" :class="forecast.source ? 'md:grid-cols-[1.15fr_1fr]' : ''">
<div v-if="unavailableReason" class="grid rounded-cp-card bg-cp-fill-tertiary/70 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
<BaseEmpty title="暂时无法预测" :description="unavailableReason" :icon="ChartNoAxesCombined" surface="none" class="min-h-72 content-center" />
</div>
<ForecastCapacity v-else :forecast="forecast" />
<ForecastSample v-if="forecast.source" :source="forecast.source" :method-display="forecast.methodDisplay" />
</div>
</div>
</div>
<template #footer>
<div class="mr-auto flex min-w-0 items-center gap-2">
<BasePopover v-model="explanationOpen" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #trigger>
<BaseButton
variant="ghost"
size="sm"
:aria-expanded="explanationOpen"
:aria-describedby="explanationOpen ? explanationId : undefined"
@keydown="handleExplanationKeydown"
>
<template #icon>
<Info class="size-3.5" />
</template>
预测说明
</BaseButton>
</template>
<section :id="explanationId" role="tooltip" class="grid w-96 max-w-[calc(100vw-2rem)] gap-3 p-4 text-cp-xs leading-relaxed text-cp-text-secondary">
<div class="grid gap-1">
<h4 class="m-0 font-heavy text-cp-text">
计算方式
</h4>
<p class="m-0">
样本用量 ÷(有效额度进度 / 100)<span v-if="forecast?.extrapolated"> × {{ forecast.targetDays }}/{{ forecast.source?.windowDays }}</span>。
</p>
<p class="m-0">
优先使用最近 3 个完整进度段及尾部,每段至少 5 个百分点;不足时仅在本周期记录可用的情况下使用累计估算。
</p>
<p class="m-0">
历史额度以请求完成时间近似对齐,不能证明扣额同步。覆盖率表示记录完整度,不是预测准确率。
</p>
</div>
<div v-if="forecast?.extrapolated" class="grid gap-1">
<h4 class="m-0 font-heavy text-cp-text">
跨周期折算
</h4>
<p class="m-0">
不代表自然月额度或可一次用完的余额;剩余估算仍属于源窗口。
</p>
</div>
<div class="grid gap-1">
<h4 class="m-0 font-heavy text-cp-text">
仅供参考
</h4>
<p class="m-0">
非官方承诺额度。站外使用、日志清理、观测延迟及模型组合变化均会影响结果;等价费用不是订阅价格或账户余额。
</p>
</div>
</section>
</BasePopover>
<span v-if="report" class="hidden text-cp-xs text-cp-text-tertiary sm:block">
查询于 {{ report.generatedAtDisplay }}
</span>
</div>
<BaseButton variant="secondary" @click="open = false">
关闭
</BaseButton>
<BaseButton variant="primary" :loading="refreshing" :disabled="loading" @click="refresh">
<template #loading>
<RefreshCw class="size-3.5 motion-safe:animate-spin" />
</template>
<template #icon>
<RefreshCw class="size-3.5" />
</template>
刷新额度
</BaseButton>
</template>
</BaseModal>
</template>
@@ -1,16 +1,23 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { Sigma } from '@lucide/vue'
import { computed } from 'vue'
import { ChartNoAxesCombined, Sigma } from '@lucide/vue'
import { computed, ref } from 'vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import BaseTable from '@/components/base/BaseTable/index.vue'
import { modelSuccessRateTextClass } from '../constants'
import AccountQuotaForecastModal from './AccountQuotaForecastModal/index.vue'
const props = defineProps<{
account: AccountRow
}>()
const emit = defineEmits<{
accountUpdated: [account: AccountRow]
}>()
const forecastOpen = ref(false)
type AccountModelUsage = AccountRow['usage']['models'][number]
const totalBilling = computed(() => props.account.usage.costs.find(cost => cost.currency.toUpperCase() === 'USD'))
@@ -75,9 +82,19 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
<div class="min-w-0 pt-4 xl:flex xl:min-h-0 xl:flex-col xl:pt-0 xl:pl-4">
<div class="mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2">
<h3 class="m-0 shrink-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<div class="flex shrink-0 items-center gap-1">
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
模型使用排行
</h3>
<BaseIconButton
label="预测周/月额度"
size="sm"
aria-haspopup="dialog"
@click="forecastOpen = true"
>
<ChartNoAxesCombined class="size-3.5" :stroke-width="1.75" />
</BaseIconButton>
</div>
<div class="ml-auto flex items-baseline gap-4">
<div v-if="hasUsageSummary" class="flex items-baseline gap-1.5 whitespace-nowrap">
@@ -117,4 +134,10 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</div>
</div>
</section>
<AccountQuotaForecastModal
v-model="forecastOpen"
:account="account"
@account-updated="emit('accountUpdated', $event)"
/>
</template>
@@ -0,0 +1,95 @@
import type { Ref } from 'vue'
import type { Account, AccountQuotaForecastResponse } from '@/api'
import { onScopeDispose, shallowRef, watch } from 'vue'
import { getAccountQuotaForecast, refreshAccountQuota } from '@/api'
import { toast } from '@/components/base/BaseToast'
export function useAccountQuotaForecast(
accountId: Ref<string>,
open: Ref<boolean>,
onAccountUpdated: (account: Account) => void,
) {
const report = shallowRef<AccountQuotaForecastResponse | null>(null)
const loading = shallowRef(false)
const refreshing = shallowRef(false)
const error = shallowRef(false)
let requestVersion = 0
let controller: AbortController | undefined
let disposed = false
function cancelLoad() {
requestVersion += 1
controller?.abort()
controller = undefined
loading.value = false
}
async function load() {
if (!open.value || !accountId.value || disposed)
return false
cancelLoad()
const version = requestVersion
controller = new AbortController()
loading.value = true
error.value = false
try {
const result = await getAccountQuotaForecast({ accountId: accountId.value }, { signal: controller.signal })
if (version === requestVersion) {
report.value = result
return true
}
}
catch {
if (version === requestVersion) {
error.value = true
}
}
finally {
if (version === requestVersion)
loading.value = false
}
return false
}
async function refresh() {
if (refreshing.value || !open.value)
return
const targetAccountId = accountId.value
cancelLoad()
refreshing.value = true
error.value = false
try {
// 刷新属于现有额度动作;预测查询本身始终只读。
const result = await refreshAccountQuota({ accountId: targetAccountId })
if (disposed)
return
onAccountUpdated(result.account)
if (accountId.value === targetAccountId && await load())
toast.success('额度已刷新')
}
catch {
if (!disposed && accountId.value === targetAccountId) {
if (!report.value)
error.value = true
}
}
finally {
refreshing.value = false
}
}
watch([open, accountId], ([isOpen]) => {
cancelLoad()
report.value = null
error.value = false
if (isOpen)
void load()
}, { immediate: true })
onScopeDispose(() => {
disposed = true
cancelLoad()
})
return { report, loading, refreshing, error, load, refresh }
}
+5 -2
View File
@@ -291,14 +291,17 @@ const {
</template>
<template #expanded="{ row }">
<div class="grid items-stretch gap-3 p-4 lg:grid-cols-[1.05fr_2.45fr] xl:min-h-[19.25rem]">
<div class="grid items-stretch gap-3 p-4 lg:grid-cols-[1.05fr_2.45fr] xl:min-h-77">
<AccountQuotaPanel
:account="row"
:refreshing="refreshingQuotaAccountIds.has(row.id)"
@account-updated="void replaceAccount($event)"
@refresh-quota="handleRefreshQuota"
/>
<AccountUsagePanel :account="row" />
<AccountUsagePanel
:account="row"
@account-updated="void replaceAccount($event)"
/>
</div>
</template>
</BaseTable>