diff --git a/backend/crates/gateway-admin/src/model/quota_forecast.rs b/backend/crates/gateway-admin/src/model/quota_forecast.rs index 46d657208..f4ba91522 100644 --- a/backend/crates/gateway-admin/src/model/quota_forecast.rs +++ b/backend/crates/gateway-admin/src/model/quota_forecast.rs @@ -108,7 +108,7 @@ impl AccountQuotaForecast { let percent = window .used_percent .filter(|p| p.is_finite() && (0.0..=100.0).contains(p)); - let usage = sample.map(|sample| &sample.usage); + let usage = sample.map(|sample| &sample.cycle_usage); let usd = usage .filter(|usage| usage.known_cost_count > 0) .map(|usage| usage.usd) @@ -175,14 +175,21 @@ impl AccountQuotaForecast { || (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.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor)); - self.estimated_usd = usd.and_then(|value| estimate(value, factor)); + // 已发生的用量保持本周期累计,只把近期消耗比例用于尚未使用的额度。 + let factor = self.target_seconds as f64 / seconds as f64; 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.and_then(|value| estimate(value, remaining_factor)); + let remaining_tokens = Some(sample.usage.tokens) + .filter(|tokens| *tokens > 0) + .and_then(|value| estimate(value as f64, remaining_factor)); + self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0)); + self.estimated_tokens = remaining_tokens + .and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor)); + self.remaining_usd = Some(&sample.usage) + .filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0) + .and_then(|usage| estimate(usage.usd, remaining_factor)); + self.estimated_usd = usd + .zip(self.remaining_usd) + .and_then(|(used, remaining)| estimate(used + remaining, factor)); self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none() { Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。") @@ -197,8 +204,8 @@ fn estimate(value: f64, factor: f64) -> Option { (estimate.is_finite() && estimate >= 0.0).then_some(estimate) } -fn estimate_tokens(value: u64, factor: f64) -> Option { - estimate(value as f64, factor) +fn estimate_tokens(value: f64, factor: f64) -> Option { + estimate(value, factor) .filter(|value| value.round() < u64::MAX as f64) .map(|value| value.round() as u64) } diff --git a/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs b/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs index 3ea574bde..aaefcec1e 100644 --- a/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs +++ b/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs @@ -101,6 +101,8 @@ pub struct QuotaForecastSample { pub observation_count: usize, pub pending_request_count: u64, pub discontinuous: bool, + /// 本周期已记录用量;近期采样仅用于预测剩余量,不能覆盖这份累计值。 + pub cycle_usage: QuotaForecastUsage, pub usage: QuotaForecastUsage, } @@ -113,12 +115,13 @@ pub fn select_forecast_sample( current: QuotaForecastPoint, mut history: Vec, pending_request_count: u64, + interrupted: bool, ) -> 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) + && (0.0..=100.0).contains(&point.used_percent) }); history.sort_by_key(|point| point.observed_at); let mut sample = QuotaForecastSample { @@ -131,10 +134,13 @@ pub fn select_forecast_sample( block_count: 0, observation_count: history.len(), pending_request_count, - discontinuous: false, + discontinuous: interrupted, + cycle_usage: current.usage.clone(), usage: current.usage.clone(), }; history.push(current); + // 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量。 + let mut cycle_baseline = interrupted.then(|| &history[0]); let mut anchors: Vec<&QuotaForecastPoint> = Vec::new(); let mut high_water: Option<&QuotaForecastPoint> = None; for point in &history { @@ -149,6 +155,7 @@ pub fn select_forecast_sample( } sample.discontinuous = true; anchors.clear(); + cycle_baseline = Some(point); } } high_water = Some(point); @@ -162,6 +169,13 @@ pub fn select_forecast_sample( let Some(current) = history.last() else { return sample; }; + if let Some(baseline) = cycle_baseline { + let Some(usage) = current.usage.difference(&baseline.usage) else { + sample.discontinuous = true; + return sample; + }; + sample.cycle_usage = usage; + } if anchors.len() < 2 { return sample; } diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index cf7d9bfc6..bf23ccd17 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -740,7 +740,7 @@ impl AccountsService for DefaultAccountsService { usage: point.usage, }); } - let mut sample = select_forecast_sample( + let sample = select_forecast_sample( window.key.clone(), query.range.start, QuotaForecastPoint { @@ -750,13 +750,8 @@ impl AccountsService for DefaultAccountsService { }, points, history.pending_request_count, + interrupted, ); - if interrupted - && sample.method - == crate::model::quota_forecast_sampling::QuotaForecastMethod::Cumulative - { - sample.discontinuous = true; - } samples.push(sample); } Ok(AccountQuotaForecastReport { diff --git a/backend/crates/gateway-admin/tests/model/quota_forecast.rs b/backend/crates/gateway-admin/tests/model/quota_forecast.rs index 48054ee3d..3740fcb87 100644 --- a/backend/crates/gateway-admin/tests/model/quota_forecast.rs +++ b/backend/crates/gateway-admin/tests/model/quota_forecast.rs @@ -76,6 +76,23 @@ fn samples(quota: &ProviderQuota) -> Vec { .filter_map(|window| { let usage = window.local_usage.as_ref()?; let usd = usage.costs.iter().find(|cost| cost.currency == "USD"); + let 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, + }; Some(QuotaForecastSample { key: window.key.clone(), method: QuotaForecastMethod::Cumulative, @@ -89,23 +106,8 @@ fn samples(quota: &ProviderQuota) -> Vec { 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, - }, + cycle_usage: usage.clone(), + usage, }) }) .collect() @@ -238,7 +240,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per 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.estimated_tokens, Some(9_000)); assert_eq!(week.remaining_tokens, Some(8_000)); assert_eq!(month.remaining_tokens, Some(8_000)); assert!(week.unavailable_reason.is_none()); @@ -250,6 +252,7 @@ fn missing_tokens_keep_estimates_from_recorded_usage() { let source = quota(vec![window("week", 7)]); let mut sample = samples(&source).remove(0); sample.usage.missing_token_count = 1; + sample.cycle_usage = sample.usage.clone(); let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]); assert!(week.incomplete_tokens); assert!(week.unavailable_reason.is_none()); @@ -304,6 +307,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate sample.usage.unavailable_cost_count = 2; sample.usage.excluded_request_count = 1; sample.pending_request_count = 1; + sample.cycle_usage = sample.usage.clone(); let [week, month] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]); assert!(week.incomplete_tokens); @@ -345,6 +349,7 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() { unavailable_cost_count: 2, ..Default::default() }; + sample.cycle_usage = sample.usage.clone(); let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]); assert!(week.unavailable_reason.is_some()); assert_eq!(week.estimated_tokens, None); @@ -380,3 +385,50 @@ fn oversized_window_or_estimate_is_unavailable_without_panics_or_saturation() { assert!(week.estimated_tokens.is_none()); assert_eq!(week.estimated_usd, Some(10.0)); } + +#[test] +fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast() { + let mut source = quota(vec![window("week", 7)]); + source.windows[0].used_percent = Some(92.0); + let mut sample = samples(&source).remove(0); + sample.method = QuotaForecastMethod::Incremental; + sample.start_at = now() - Duration::hours(20); + sample.baseline_percent = 75.0; + sample.sampled_percent = 17.0; + sample.block_count = 3; + sample.usage.tokens = 141_226_903; + sample.usage.usd = 237.21007884; + sample.cycle_usage.tokens = 1_084_786_565; + sample.cycle_usage.usd = 1_412.57957384; + let [cycle, month] = + account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]); + assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565)); + assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384)); + assert_eq!(cycle.remaining_tokens, Some(66_459_719)); + assert_eq!(cycle.estimated_tokens, Some(1_151_246_284)); + assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8); + assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8); + assert_eq!(month.remaining_tokens, cycle.remaining_tokens); + assert!( + (month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8 + ); +} + +#[test] +fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() { + let mut source = quota(vec![window("week", 7)]); + source.windows[0].used_percent = Some(100.0); + let mut sample = samples(&source).remove(0); + sample.method = QuotaForecastMethod::Incremental; + sample.start_at = now() - Duration::hours(2); + sample.baseline_percent = 85.0; + sample.sampled_percent = 15.0; + sample.block_count = 3; + sample.usage.tokens = 100; + sample.usage.usd = 0.1; + let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]); + assert_eq!(cycle.estimated_tokens, Some(1_000)); + assert_eq!(cycle.estimated_usd, Some(2.0)); + assert_eq!(cycle.remaining_tokens, Some(0)); + assert_eq!(cycle.remaining_usd, Some(0.0)); +} diff --git a/backend/crates/gateway-admin/tests/model/quota_forecast_sampling.rs b/backend/crates/gateway-admin/tests/model/quota_forecast_sampling.rs index 636887562..67c386f50 100644 --- a/backend/crates/gateway-admin/tests/model/quota_forecast_sampling.rs +++ b/backend/crates/gateway-admin/tests/model/quota_forecast_sampling.rs @@ -24,7 +24,7 @@ fn point(minute: i64, percent: f64, tokens: u64) -> QuotaForecastPoint { } fn select(points: Vec, current: QuotaForecastPoint) -> QuotaForecastSample { - select_forecast_sample("week".to_owned(), time(0), current, points, 2) + select_forecast_sample("week".to_owned(), time(0), current, points, 2, false) } #[test] @@ -55,6 +55,7 @@ fn recent_blocks_and_tail_replace_old_workload_without_averaging_individual_rati assert_eq!(sample.baseline_percent, 10.0); assert_eq!(sample.sampled_percent, 17.0); assert_eq!(sample.usage.tokens, 700); + assert_eq!(sample.cycle_usage.tokens, 900); assert_eq!(sample.block_count, 3); } @@ -77,6 +78,7 @@ fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption( ); assert_eq!(sample.baseline_percent, 1.0); assert_eq!(sample.usage.tokens, 70); + assert_eq!(sample.cycle_usage.tokens, 70); assert_eq!(sample.sampled_percent, 7.0); assert!(!sample.discontinuous); let waiting = select( @@ -122,3 +124,41 @@ fn counter_regressions_fail_closed_and_future_points_are_not_used() { assert_eq!(future.method, QuotaForecastMethod::Cumulative); assert_eq!(future.observation_count, 0); } + +#[test] +fn exhausted_history_is_retained_to_detect_a_reset_with_an_unchanged_deadline() { + let sample = select( + vec![point(1, 100.0, 10_000), point(2, 0.0, 10_000)], + point(3, 10.0, 10_100), + ); + assert_eq!(sample.cycle_usage.tokens, 100); + assert_eq!(sample.usage.tokens, 100); + assert_eq!(sample.sampled_percent, 10.0); + assert!(!sample.discontinuous); +} + +#[test] +fn changed_window_restarts_cycle_totals_at_first_matching_observation() { + let sample = select_forecast_sample( + "week".to_owned(), + time(0), + point(4, 12.0, 10_120), + vec![point(2, 0.0, 10_000), point(3, 5.0, 10_050)], + 0, + true, + ); + assert_eq!(sample.cycle_usage.tokens, 120); + assert!((sample.cycle_usage.usd - 0.12).abs() < 1e-10); + assert_eq!(sample.usage.tokens, 120); + assert!(!sample.discontinuous); + let waiting = select_forecast_sample( + "week".to_owned(), + time(0), + point(4, 2.0, 10_020), + vec![], + 0, + true, + ); + assert_eq!(waiting.cycle_usage.tokens, 0); + assert!(waiting.discontinuous); +} diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index bfc03b18c..2eefef6f1 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -2040,6 +2040,29 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_ .unwrap() .contains("不连续") ); + // 新额度段已有足够观测后恢复预测,但总量不能带回重置前的累计用量。 + let mut first = make_point(1, 5.0, 1_750, 0); + first.completed_at = now - TimeDelta::minutes(45); + first.started_at = first.completed_at - TimeDelta::seconds(10); + let mut next = make_point(1, 20.0, 1_900, 0); + next.completed_at = now - TimeDelta::minutes(20); + next.started_at = next.completed_at - TimeDelta::seconds(10); + store + .quota_forecast_history + .lock() + .unwrap() + .points + .extend([first, next]); + let result = services + .accounts() + .quota_forecast(&ProviderAccountId::new("acct_test").unwrap()) + .await + .unwrap(); + let cycle = &result.forecasts[0]; + assert!(cycle.unavailable_reason.is_none()); + assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250)); + assert_eq!(cycle.remaining_tokens, Some(429)); + assert_eq!(cycle.estimated_tokens, Some(679)); } #[tokio::test] diff --git a/docs/api.md b/docs/api.md index f04ac9082..86d409627 100644 --- a/docs/api.md +++ b/docs/api.md @@ -740,12 +740,13 @@ OAuth start 使用: - `targetDays`、`extrapolated`:对应周期存在真实账号级窗口时采用实际时长;缺少对应窗口时,使用 可统计的周/月窗口按 7/30 天折算,明确标记 `extrapolated: true`。不把短期限流或模型专属桶当作账号容量。 -- `estimatedTokens` / `estimatedUsd` 及对应 `*Display`:完整目标周期的近似容量, - 公式为 `样本用量 × 100 / sampledPercent × 目标窗口秒数 / 源窗口秒数`。 +- `estimatedTokens` / `estimatedUsd` 及对应 `*Display`:本周期已记录用量加预计剩余量, + 公式为 `(本周期已记录用量 + 样本用量 × (100 - usedPercent) / sampledPercent) × 目标窗口秒数 / 源窗口秒数`。 + 真实周期由上游额度重置边界定义,不按自然周/月累计;只有折算结果才乘以目标与源窗口的时长比。 - `remainingTokens` / `remainingUsd` 及对应 `*Display`:**额度快照时源窗口**的剩余估算, 公式为 `样本用量 × (100 - usedPercent) / sampledPercent`;不随目标周期折算,不代表当前可消费余额。 - `source`:源窗口名称 `label`、已用比例 `usedPercent` / `usedPercentDisplay`、 - 额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及选中采样区间 + 额度观测时间 `observedAt` / `observedAtDisplay`、用于过期检查的 `resetAt`,以及本周期累计 已记录的 `tokensDisplay` / `usdDisplay`。`source: null` 表示没有可选的源窗口。 `observedAt` 表示额度观测时间,与本次查询的 `generatedAt` 不同。 - `unavailableReason`:不能估算时的说明,正常为 `null`。有效进度少于 5 个百分点不预测; @@ -759,7 +760,9 @@ OAuth start 使用: 完成时间的累计用量建立基线,每累计至少 5 个百分点形成一段,使用最近 3 个完整段及未满一段的尾部。 上式中的 `sampledPercent` 是内部有效额度进度(百分点),不是时间进度或对外响应字段。 只对合并区间计算比值,不平均逐请求小分母比值;重复读数不增加段数,大于 1 个百分点的回落或累计 -计数倒退会中断采样,不能静默跨越。缓存命中属于输入,不重复相加;其他币种或缺少有效金额不能当成零 USD。 +计数倒退会中断采样,不能静默跨越。重置时间改变或额度明显回落时,旧段不参与新的累计和预测; +当查询区间内包含重置前记录时,以首个新段观测的累计值为基线重新计数,积累至少 5 个百分点后恢复预测。 +该基线之前无法精确归属的用量不补算。缓存命中属于输入,不重复相加;其他币种或缺少有效金额不能当成零 USD。 采样查询 `[max(resetAt - windowSeconds, accountAddedAt), observedAt)` 内开始的请求, 仅把 `completedAt <= observedAt` 的完整交付用量计入当前分子;历史分子按各点的完成时间累计, diff --git a/docs/architecture.md b/docs/architecture.md index 76bb970c7..009421d99 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -513,7 +513,8 @@ OpenAI 订阅周期属于按需个人信息,不是额度事实。Admin 账号 账号容量预测属于 Admin 的只读派生规则,不参与 quota 权威状态、调度或金额结算。Store 通过专用采样端口 在同一 SQL 快照内返回截至观测时间的累计数值及有界历史 Provider 文档;文档仅由具体 Provider 复用协议 -解析器解释,Admin 按中立的额度事实选择近期进度段。该采样不改变全站完整交付用量口径,不创建第二份 +解析器解释,Admin 按中立的额度事实选择近期进度段预测剩余量,再加本周期已记录用量形成周期总量。 +周期以额度重置为边界,重置后累计与样本重新开始。该采样不改变全站完整交付用量口径,不创建第二份 持久化额度状态。部分 Token/美元费用缺失仅提示精度限制,继续按已记录数值估算;对应数值完全不可用时 才不返回该项预测,不按请求数量补齐未知消耗。历史请求完成时间只是额度时间的近似,不承诺严格扣额归因 或预测准确率。公开字段与采样门槛见 [周/月额度预测](api.md#周月额度预测)。 diff --git a/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue b/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue index 3c5c4518f..952e92810 100644 --- a/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue +++ b/frontend/src/views/accounts/components/AccountQuotaForecastModal/ForecastCapacity.vue @@ -25,7 +25,7 @@ const metrics = computed(() => [
- {{ source.label }}已用 + 本周期已用