From 27cef052c9fdfbfc810f6bd89f3bfccaea1fffa7 Mon Sep 17 00:00:00 2001 From: Zyy <571092077@qq.com> Date: Tue, 13 Oct 2026 11:20:04 +0800 Subject: [PATCH] fix(accounts): preserve quota forecast totals on same-window reversals (#414) * fix(accounts): preserve quota forecast totals on same-window reversals Share exhausted-window recovery evidence between account recovery and forecast sampling. Keep recorded cycle usage on ordinary reversals and restart totals only after the recovery evidence is satisfied. Co-authored-by: Codex * fix(accounts): separate forecast cycle boundaries from quota recovery Keep same-window usage totals after quota reversals, including observations near exhaustion. Leave access recovery in the OpenAI provider and reset forecast totals only at an identified window or plan boundary. Co-authored-by: Codex --------- Co-authored-by: Zyy Co-authored-by: Codex --- .../src/model/quota_forecast_sampling.rs | 4 +- .../tests/model/quota_forecast_sampling.rs | 56 +++++++++++- .../gateway-admin/tests/use_case/accounts.rs | 87 +++++++++++++++++++ docs/architecture.md | 1 + 4 files changed, 142 insertions(+), 6 deletions(-) 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 332b852a6..3bb000b94 100644 --- a/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs +++ b/backend/crates/gateway-admin/src/model/quota_forecast_sampling.rs @@ -142,7 +142,7 @@ pub fn select_forecast_sample( }; history.push(current); // 重置边界不在查询起点时,从首个新观测重新计数,避免把旧周期累计带入总量 - let mut cycle_baseline = interrupted.then(|| &history[0]); + let cycle_baseline = interrupted.then(|| &history[0]); let mut anchors: Vec<&QuotaForecastPoint> = Vec::new(); let mut high_water: Option<&QuotaForecastPoint> = None; for point in &history { @@ -155,9 +155,9 @@ pub fn select_forecast_sample( if previous.used_percent - point.used_percent <= 1.0 { continue; } + // 同窗口回落不能证明周期重置,只重建采样并保留已记录累计 sample.discontinuous = true; anchors.clear(); - cycle_baseline = Some(point); } } high_water = Some(point); 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 e270d43e1..4d9d461ae 100644 --- a/backend/crates/gateway-admin/tests/model/quota_forecast_sampling.rs +++ b/backend/crates/gateway-admin/tests/model/quota_forecast_sampling.rs @@ -73,14 +73,14 @@ fn snapshot_at_a_block_boundary_keeps_only_three_recent_blocks() { } #[test] -fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption() { +fn large_percentage_reversal_restarts_only_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.cycle_usage.tokens, 70); + assert_eq!(sample.cycle_usage.tokens, 280); assert_eq!(sample.sampled_percent, 7.0); assert!(!sample.discontinuous); let waiting = select( @@ -90,6 +90,30 @@ fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption( assert!(waiting.discontinuous); } +#[test] +fn same_window_reversal_preserves_all_recorded_cycle_usage() { + let current = point(3, 35.0, 140); + let recorded = current.usage.clone(); + let sample = select(vec![point(1, 32.0, 100), point(2, 30.0, 120)], current); + assert_eq!(sample.cycle_usage, recorded); + assert_eq!(sample.usage.tokens, 20); + assert!((sample.usage.usd - 0.02).abs() < 1e-10); + assert!(!sample.discontinuous); +} + +#[test] +fn small_reversals_after_full_usage_preserve_cycle_totals_and_cumulative_forecasts() { + let current = point(3, 99.5, 10_020); + let recorded = current.usage.clone(); + let sample = select( + vec![point(1, 100.0, 10_000), point(2, 99.0, 10_010)], + current, + ); + assert_eq!(sample.cycle_usage, recorded); + assert_eq!(sample.method, QuotaForecastMethod::Cumulative); + assert!(!sample.discontinuous); +} + #[test] fn one_point_reversal_is_skipped_without_counting_an_extra_block() { let sample = select( @@ -128,17 +152,41 @@ fn counter_regressions_fail_closed_and_future_points_are_not_used() { } #[test] -fn exhausted_history_is_retained_to_detect_a_reset_with_an_unchanged_deadline() { +fn same_window_recovery_restarts_sampling_but_preserves_recorded_cycle_usage() { 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.cycle_usage.tokens, 10_100); + assert!((sample.cycle_usage.usd - 10.1).abs() < 1e-10); assert_eq!(sample.usage.tokens, 100); assert_eq!(sample.sampled_percent, 10.0); assert!(!sample.discontinuous); } +#[test] +fn a_single_non_exhausted_observation_does_not_restart_cycle_usage() { + let sample = select(vec![point(1, 100.0, 10_000)], point(2, 0.0, 10_000)); + assert_eq!(sample.cycle_usage.tokens, 10_000); + assert!(sample.discontinuous); +} + +#[test] +fn repeated_same_window_recoveries_preserve_recorded_cycle_usage() { + let sample = select( + vec![ + point(1, 100.0, 10_000), + point(2, 0.0, 10_000), + point(3, 100.0, 10_100), + point(4, 0.0, 10_100), + ], + point(5, 10.0, 10_200), + ); + assert_eq!(sample.cycle_usage.tokens, 10_200); + assert_eq!(sample.usage.tokens, 100); + assert!(!sample.discontinuous); +} + #[test] fn changed_window_restarts_cycle_totals_at_first_matching_observation() { let sample = select_forecast_sample( diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 9599b4aa6..a0a01b87a 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -2399,6 +2399,93 @@ async fn quota_forecast_only_queries_the_two_selected_source_windows() { ); } +#[tokio::test] +async fn quota_forecast_same_window_reversal_keeps_recorded_usage_in_the_report() { + use gateway_admin::model::quota_forecast_sampling::QuotaForecastHistoryPoint; + + for (percents, tokens, expected_tokens, expected_usd) in [ + ([32.0, 30.0, 35.0], [100, 120, 140], 400, 0.4), + ( + [100.0, 99.0, 99.5], + [10_000, 10_010, 10_020], + 10_070, + 10.02 / 0.995, + ), + ] { + let now = Utc::now(); + let observed = now - TimeDelta::minutes(1); + let reset = now + TimeDelta::days(1); + 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(percents[2]), + reset_at: Some(reset), + limit_reached: false, + local_usage: None, + provider_data: None, + }], + ..empty_quota() + }); + let mut account = account_record("openai"); + account.created_at = now - TimeDelta::days(7); + let store = FakeAccountStore::with_account(account, events()); + let usage = |requests, tokens| QuotaForecastUsage { + request_count: requests, + tokens, + input_tokens: tokens, + known_cost_count: requests, + usd: tokens as f64 / 1_000.0, + ..Default::default() + }; + *store.quota_forecast_history.lock().unwrap() = QuotaForecastHistory { + points: [(3, percents[0], tokens[0]), (2, percents[1], tokens[1])] + .into_iter() + .enumerate() + .map( + |(index, (minutes, percent, tokens))| QuotaForecastHistoryPoint { + started_at: now - TimeDelta::minutes(5), + completed_at: now - TimeDelta::minutes(minutes), + usage: usage(index as u64 + 1, tokens), + provider_observation: ProviderDocument::new(OpaqueProviderData::new( + serde_json::json!({ + "percent": percent, "reset": reset.to_rfc3339(), "plan": "pro", + }) + .as_object() + .unwrap() + .clone(), + )), + }, + ) + .collect(), + usage: usage(3, tokens[2]), + pending_request_count: 0, + }; + let services = accounts_service(provider, store).await; + let result = services + .accounts() + .quota_forecast(&ProviderAccountId::new("acct_test").unwrap()) + .await + .unwrap(); + let forecast = &result.forecasts[0]; + let source = forecast.source.as_ref().unwrap(); + assert_eq!(source.tokens, Some(tokens[2])); + assert!((source.usd.unwrap() - tokens[2] as f64 / 1_000.0).abs() < 1e-10); + assert_eq!(forecast.estimated_tokens, Some(expected_tokens)); + assert!((forecast.estimated_usd.unwrap() - expected_usd).abs() < 1e-10); + assert!(forecast.unavailable_reason.is_none()); + } +} + #[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; diff --git a/docs/architecture.md b/docs/architecture.md index fd4432b7a..e37d67328 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -885,6 +885,7 @@ OpenAI 订阅周期属于按需个人信息,不是额度事实。Admin 账号 - 同一额度窗口内,每累计至少 5 个百分点形成一段,使用最近 3 个完整段及尾部计算合并比值,不平均逐请求的小分母比值 - 重复读数不增加段数,大于 1 个百分点的额度回落、累计计数倒退或窗口身份变化会中断采样 +- 同窗口回落保留已记录累计;触顶后恢复可用不代表新周期,不能据此重设累计基线 - OpenAI 的窗口匹配复用限流解析器,核对桶、槽位、时长和明确套餐,只容许最多 2 秒的重置时间抖动 - 重置后从新段重新累计,无法精确归属的用量不补算;只有账号在窗口开始前加入且无已知断点时,才允许以窗口累计量替代增量采样 - 历史点抽样不缩减区间累计用量,同一完成时间共用同一累计值