Skip to main content

codex_helper_core/
usage_forecast.rs

1use serde::{Deserialize, Serialize};
2
3use crate::balance::ProviderBalanceSnapshot;
4use crate::config::UsageForecastConfig;
5use crate::pricing::UsdAmount;
6use crate::state::FinishedRequest;
7
8const MINUTE_MS: u64 = 60_000;
9const HOUR_MS: u64 = 60 * MINUTE_MS;
10const DAY_MS: u64 = 24 * HOUR_MS;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
13pub struct UsageSpendForecast {
14    pub enabled: bool,
15    pub sample_window_ms: u64,
16    pub sample_elapsed_ms: u64,
17    pub projected_sample_elapsed_ms: u64,
18    pub priced_requests: u64,
19    pub unpriced_requests: u64,
20    pub sample_cost_usd: Option<String>,
21    pub rate_per_hour_usd: Option<String>,
22    pub reset_at_ms: Option<u64>,
23    pub reset_in_ms: Option<u64>,
24    pub projected_until_reset_usd: Option<String>,
25    pub primary_balance_remaining_usd: Option<String>,
26    pub projected_balance_after_reset_usd: Option<String>,
27    pub projected_exhaustion: bool,
28    #[serde(default)]
29    pub balance_calibrated: bool,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub balance_calibration_multiplier_pct: Option<u32>,
32    #[serde(default)]
33    pub balance_calibration_requests: u64,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub balance_calibration_window_ms: Option<u64>,
36    pub confidence: UsageForecastConfidence,
37    pub reason: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
41pub struct UsageBalanceCalibration {
42    pub available: bool,
43    pub provider_id: String,
44    pub station_name: Option<String>,
45    pub upstream_index: Option<usize>,
46    pub window_start_ms: Option<u64>,
47    pub window_end_ms: Option<u64>,
48    pub window_ms: Option<u64>,
49    pub matched_requests: u64,
50    pub actual_delta_usd: Option<String>,
51    pub estimated_delta_usd: Option<String>,
52    pub multiplier_pct: Option<u32>,
53    pub status: UsageBalanceCalibrationStatus,
54    pub reason: Option<String>,
55}
56
57#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
58#[serde(rename_all = "snake_case")]
59pub enum UsageBalanceCalibrationStatus {
60    #[default]
61    Unavailable,
62    Calibrated,
63    LowSample,
64    OutOfRange,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
68pub struct QuotaPacingForecast {
69    pub available: bool,
70    pub provider_id: String,
71    pub station_name: Option<String>,
72    pub upstream_index: Option<usize>,
73    pub source: String,
74    pub plan_name: Option<String>,
75    pub period: Option<String>,
76    pub unlimited: bool,
77    pub remaining_usd: Option<String>,
78    pub limit_usd: Option<String>,
79    pub used_usd: Option<String>,
80    pub rate_per_hour_usd: Option<String>,
81    pub target_rate_per_hour_usd: Option<String>,
82    pub reset_at_ms: Option<u64>,
83    pub reset_in_ms: Option<u64>,
84    pub estimated_exhaustion_in_ms: Option<u64>,
85    pub pace_ratio_pct: Option<u32>,
86    pub status: QuotaPacingStatus,
87    pub reason: Option<String>,
88}
89
90#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
91#[serde(rename_all = "snake_case")]
92pub enum QuotaPacingStatus {
93    #[default]
94    Unavailable,
95    Unlimited,
96    NoSpendRate,
97    UnknownReset,
98    OnTrack,
99    Fast,
100    Slow,
101    Exhausted,
102}
103
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
105#[serde(rename_all = "snake_case")]
106pub enum UsageForecastConfidence {
107    #[default]
108    Disabled,
109    NoData,
110    LowSample,
111    PartialPricing,
112    Estimated,
113}
114
115pub trait UsageForecastRequestLike {
116    fn id(&self) -> u64;
117    fn trace_id(&self) -> Option<&str>;
118    fn ended_at_ms(&self) -> u64;
119    fn provider_id(&self) -> Option<&str>;
120    fn station_name(&self) -> Option<&str>;
121    fn total_cost_usd(&self) -> Option<&str>;
122    fn has_usage(&self) -> bool;
123}
124
125pub trait UsageForecastBalanceHistoryLike {
126    fn fetched_at_ms(&self) -> u64;
127    fn provider_id(&self) -> &str;
128    fn station_name(&self) -> Option<&str>;
129    fn upstream_index(&self) -> Option<usize>;
130    fn quota_remaining_usd(&self) -> Option<&str>;
131    fn subscription_balance_usd(&self) -> Option<&str>;
132    fn total_balance_usd(&self) -> Option<&str>;
133    fn error(&self) -> Option<&str>;
134    fn unlimited_quota(&self) -> bool;
135}
136
137impl<T: UsageForecastRequestLike + ?Sized> UsageForecastRequestLike for &T {
138    fn id(&self) -> u64 {
139        (**self).id()
140    }
141
142    fn trace_id(&self) -> Option<&str> {
143        (**self).trace_id()
144    }
145
146    fn ended_at_ms(&self) -> u64 {
147        (**self).ended_at_ms()
148    }
149
150    fn provider_id(&self) -> Option<&str> {
151        (**self).provider_id()
152    }
153
154    fn station_name(&self) -> Option<&str> {
155        (**self).station_name()
156    }
157
158    fn total_cost_usd(&self) -> Option<&str> {
159        (**self).total_cost_usd()
160    }
161
162    fn has_usage(&self) -> bool {
163        (**self).has_usage()
164    }
165}
166
167impl<T: UsageForecastBalanceHistoryLike + ?Sized> UsageForecastBalanceHistoryLike for &T {
168    fn fetched_at_ms(&self) -> u64 {
169        (**self).fetched_at_ms()
170    }
171
172    fn provider_id(&self) -> &str {
173        (**self).provider_id()
174    }
175
176    fn station_name(&self) -> Option<&str> {
177        (**self).station_name()
178    }
179
180    fn upstream_index(&self) -> Option<usize> {
181        (**self).upstream_index()
182    }
183
184    fn quota_remaining_usd(&self) -> Option<&str> {
185        (**self).quota_remaining_usd()
186    }
187
188    fn subscription_balance_usd(&self) -> Option<&str> {
189        (**self).subscription_balance_usd()
190    }
191
192    fn total_balance_usd(&self) -> Option<&str> {
193        (**self).total_balance_usd()
194    }
195
196    fn error(&self) -> Option<&str> {
197        (**self).error()
198    }
199
200    fn unlimited_quota(&self) -> bool {
201        (**self).unlimited_quota()
202    }
203}
204
205impl UsageForecastRequestLike for FinishedRequest {
206    fn id(&self) -> u64 {
207        self.id
208    }
209
210    fn trace_id(&self) -> Option<&str> {
211        self.trace_id.as_deref()
212    }
213
214    fn ended_at_ms(&self) -> u64 {
215        self.ended_at_ms
216    }
217
218    fn provider_id(&self) -> Option<&str> {
219        self.provider_id.as_deref()
220    }
221
222    fn station_name(&self) -> Option<&str> {
223        self.station_name.as_deref()
224    }
225
226    fn total_cost_usd(&self) -> Option<&str> {
227        self.cost.total_cost_usd.as_deref()
228    }
229
230    fn has_usage(&self) -> bool {
231        self.usage.is_some()
232    }
233}
234
235impl UsageForecastBalanceHistoryLike for ProviderBalanceSnapshot {
236    fn fetched_at_ms(&self) -> u64 {
237        self.fetched_at_ms
238    }
239
240    fn provider_id(&self) -> &str {
241        self.provider_id.as_str()
242    }
243
244    fn station_name(&self) -> Option<&str> {
245        self.station_name.as_deref()
246    }
247
248    fn upstream_index(&self) -> Option<usize> {
249        self.upstream_index
250    }
251
252    fn quota_remaining_usd(&self) -> Option<&str> {
253        self.quota_remaining_usd.as_deref()
254    }
255
256    fn subscription_balance_usd(&self) -> Option<&str> {
257        self.subscription_balance_usd.as_deref()
258    }
259
260    fn total_balance_usd(&self) -> Option<&str> {
261        self.total_balance_usd.as_deref()
262    }
263
264    fn error(&self) -> Option<&str> {
265        self.error.as_deref()
266    }
267
268    fn unlimited_quota(&self) -> bool {
269        self.unlimited_quota == Some(true)
270    }
271}
272
273pub fn build_usage_spend_forecast<T>(
274    config: &UsageForecastConfig,
275    recent: &[T],
276    provider_balances: &[ProviderBalanceSnapshot],
277    now_ms: u64,
278) -> UsageSpendForecast
279where
280    T: UsageForecastRequestLike,
281{
282    let empty_history: &[ProviderBalanceSnapshot] = &[];
283    build_usage_spend_forecast_with_balance_history(
284        config,
285        recent,
286        provider_balances,
287        empty_history,
288        now_ms,
289    )
290}
291
292pub fn build_usage_spend_forecast_with_balance_history<T, H>(
293    config: &UsageForecastConfig,
294    recent: &[T],
295    provider_balances: &[ProviderBalanceSnapshot],
296    provider_balance_history: &[H],
297    now_ms: u64,
298) -> UsageSpendForecast
299where
300    T: UsageForecastRequestLike,
301    H: UsageForecastBalanceHistoryLike,
302{
303    let calibration =
304        build_usage_balance_calibration(recent, provider_balance_history, config, now_ms);
305    let mut forecast = build_usage_spend_forecast_inner(config, recent, provider_balances, now_ms);
306    apply_balance_calibration(&mut forecast, calibration);
307    forecast
308}
309
310pub fn build_usage_balance_calibration<T, H>(
311    recent: &[T],
312    provider_balance_history: &[H],
313    config: &UsageForecastConfig,
314    now_ms: u64,
315) -> UsageBalanceCalibration
316where
317    T: UsageForecastRequestLike,
318    H: UsageForecastBalanceHistoryLike,
319{
320    let sample_window_ms = config.rate_window_minutes.max(1).saturating_mul(MINUTE_MS);
321    let cutoff_ms = now_ms.saturating_sub(sample_window_ms.saturating_mul(2));
322    let Some((previous, current, actual_delta)) =
323        best_balance_delta_pair(provider_balance_history, cutoff_ms, now_ms)
324    else {
325        return UsageBalanceCalibration {
326            reason: Some("no usable balance delta".to_string()),
327            ..UsageBalanceCalibration::default()
328        };
329    };
330
331    let mut estimated_delta = UsdAmount::ZERO;
332    let mut matched_requests = 0_u64;
333    for request in recent {
334        if request.ended_at_ms() <= previous.fetched_at_ms()
335            || request.ended_at_ms() > current.fetched_at_ms()
336        {
337            continue;
338        }
339        if !request_matches_balance_snapshot(request, current) {
340            continue;
341        }
342        let Some(cost) = request
343            .total_cost_usd()
344            .and_then(UsdAmount::from_decimal_str)
345        else {
346            continue;
347        };
348        if cost.is_zero() {
349            continue;
350        }
351        estimated_delta = estimated_delta.saturating_add(cost);
352        matched_requests = matched_requests.saturating_add(1);
353    }
354
355    let window_ms = current
356        .fetched_at_ms()
357        .saturating_sub(previous.fetched_at_ms());
358    let mut out = UsageBalanceCalibration {
359        available: true,
360        provider_id: current.provider_id().to_string(),
361        station_name: current.station_name().map(ToOwned::to_owned),
362        upstream_index: current.upstream_index(),
363        window_start_ms: Some(previous.fetched_at_ms()),
364        window_end_ms: Some(current.fetched_at_ms()),
365        window_ms: Some(window_ms),
366        matched_requests,
367        actual_delta_usd: Some(actual_delta.format_usd()),
368        estimated_delta_usd: Some(estimated_delta.format_usd()),
369        ..UsageBalanceCalibration::default()
370    };
371
372    if matched_requests < config.min_priced_requests.max(1) || estimated_delta.is_zero() {
373        out.status = UsageBalanceCalibrationStatus::LowSample;
374        out.reason = Some("not enough matched priced requests".to_string());
375        return out;
376    }
377
378    let multiplier_pct = amount_ratio_pct(actual_delta, estimated_delta);
379    out.multiplier_pct = Some(multiplier_pct);
380    if !(25..=400).contains(&multiplier_pct) {
381        out.status = UsageBalanceCalibrationStatus::OutOfRange;
382        out.reason = Some("balance delta multiplier outside guardrails".to_string());
383        return out;
384    }
385
386    out.status = UsageBalanceCalibrationStatus::Calibrated;
387    out
388}
389
390fn apply_balance_calibration(
391    forecast: &mut UsageSpendForecast,
392    calibration: UsageBalanceCalibration,
393) {
394    if calibration.status != UsageBalanceCalibrationStatus::Calibrated {
395        return;
396    }
397    let Some(multiplier_pct) = calibration.multiplier_pct else {
398        return;
399    };
400
401    forecast.balance_calibrated = true;
402    forecast.balance_calibration_multiplier_pct = Some(multiplier_pct);
403    forecast.balance_calibration_requests = calibration.matched_requests;
404    forecast.balance_calibration_window_ms = calibration.window_ms;
405
406    if let Some(value) = forecast
407        .sample_cost_usd
408        .as_deref()
409        .and_then(UsdAmount::from_decimal_str)
410    {
411        forecast.sample_cost_usd =
412            Some(scale_usd_by_ratio(value, u64::from(multiplier_pct), 100).format_usd());
413    }
414    if let Some(value) = forecast
415        .rate_per_hour_usd
416        .as_deref()
417        .and_then(UsdAmount::from_decimal_str)
418    {
419        forecast.rate_per_hour_usd =
420            Some(scale_usd_by_ratio(value, u64::from(multiplier_pct), 100).format_usd());
421    }
422    if let Some(value) = forecast
423        .projected_until_reset_usd
424        .as_deref()
425        .and_then(UsdAmount::from_decimal_str)
426    {
427        forecast.projected_until_reset_usd =
428            Some(scale_usd_by_ratio(value, u64::from(multiplier_pct), 100).format_usd());
429    }
430
431    let balance = forecast
432        .primary_balance_remaining_usd
433        .as_deref()
434        .and_then(UsdAmount::from_decimal_str);
435    let projected = forecast
436        .projected_until_reset_usd
437        .as_deref()
438        .and_then(UsdAmount::from_decimal_str);
439    forecast.projected_balance_after_reset_usd = match (balance, projected) {
440        (Some(balance), Some(projected)) => Some(balance.saturating_sub(projected).format_usd()),
441        _ => None,
442    };
443    forecast.projected_exhaustion =
444        matches!((balance, projected), (Some(balance), Some(projected)) if projected > balance);
445}
446
447fn best_balance_delta_pair<H>(
448    provider_balance_history: &[H],
449    cutoff_ms: u64,
450    now_ms: u64,
451) -> Option<(&H, &H, UsdAmount)>
452where
453    H: UsageForecastBalanceHistoryLike,
454{
455    let mut best: Option<(&H, &H, UsdAmount)> = None;
456    let mut histories = std::collections::BTreeMap::<String, Vec<&H>>::new();
457    for snapshot in provider_balance_history {
458        if snapshot.fetched_at_ms() < cutoff_ms || snapshot.fetched_at_ms() > now_ms {
459            continue;
460        }
461        if snapshot
462            .error()
463            .is_some_and(|value| !value.trim().is_empty())
464        {
465            continue;
466        }
467        if snapshot.unlimited_quota() {
468            continue;
469        }
470        if balance_delta_amount(snapshot).is_none() {
471            continue;
472        }
473        histories
474            .entry(balance_history_key(snapshot))
475            .or_default()
476            .push(snapshot);
477    }
478
479    for history in histories.values_mut() {
480        history.sort_by_key(|snapshot| snapshot.fetched_at_ms());
481        for pair in history.windows(2) {
482            let [previous, current] = pair else {
483                continue;
484            };
485            let Some(previous_amount) = balance_delta_amount(previous) else {
486                continue;
487            };
488            let Some(current_amount) = balance_delta_amount(current) else {
489                continue;
490            };
491            if current.fetched_at_ms() <= previous.fetched_at_ms()
492                || current_amount >= previous_amount
493            {
494                continue;
495            }
496            let delta = previous_amount.saturating_sub(current_amount);
497            if delta.is_zero() {
498                continue;
499            }
500            let replace = best.as_ref().is_none_or(|(_, best_current, _)| {
501                current.fetched_at_ms() > best_current.fetched_at_ms()
502            });
503            if replace {
504                best = Some((previous, current, delta));
505            }
506        }
507    }
508
509    best
510}
511
512fn balance_history_key<H: UsageForecastBalanceHistoryLike>(snapshot: &H) -> String {
513    format!(
514        "{}|{}|{}",
515        snapshot.station_name().unwrap_or_default(),
516        snapshot
517            .upstream_index()
518            .map(|idx| idx.to_string())
519            .unwrap_or_default(),
520        snapshot.provider_id()
521    )
522}
523
524fn balance_delta_amount<H: UsageForecastBalanceHistoryLike>(snapshot: &H) -> Option<UsdAmount> {
525    if let Some(amount) = snapshot
526        .quota_remaining_usd()
527        .and_then(UsdAmount::from_decimal_str)
528    {
529        return Some(amount);
530    }
531    if let Some(amount) = snapshot
532        .subscription_balance_usd()
533        .and_then(UsdAmount::from_decimal_str)
534    {
535        return Some(amount);
536    }
537    snapshot
538        .total_balance_usd()
539        .and_then(UsdAmount::from_decimal_str)
540}
541
542fn request_matches_balance_snapshot<T, H>(request: &T, snapshot: &H) -> bool
543where
544    T: UsageForecastRequestLike,
545    H: UsageForecastBalanceHistoryLike,
546{
547    let provider_matches = match snapshot.provider_id().trim() {
548        "" => true,
549        provider_id => request.provider_id() == Some(provider_id),
550    };
551    let station_matches = snapshot
552        .station_name()
553        .is_none_or(|station_name| request.station_name() == Some(station_name));
554    provider_matches && station_matches
555}
556
557fn build_usage_spend_forecast_inner<T>(
558    config: &UsageForecastConfig,
559    recent: &[T],
560    provider_balances: &[ProviderBalanceSnapshot],
561    now_ms: u64,
562) -> UsageSpendForecast
563where
564    T: UsageForecastRequestLike,
565{
566    if !config.enabled {
567        return UsageSpendForecast {
568            enabled: false,
569            reason: Some("disabled".to_string()),
570            ..UsageSpendForecast::default()
571        };
572    }
573
574    let sample_window_ms = config.rate_window_minutes.max(1).saturating_mul(MINUTE_MS);
575    let reset_at_ms = next_reset_at_ms(
576        now_ms,
577        config.reset_utc_offset.as_str(),
578        config.reset_time.as_str(),
579    );
580    let reset_in_ms = reset_at_ms.map(|reset| reset.saturating_sub(now_ms));
581
582    let cutoff_ms = now_ms.saturating_sub(sample_window_ms);
583    let mut priced_requests = 0_u64;
584    let mut unpriced_requests = 0_u64;
585    let mut sample_cost = UsdAmount::ZERO;
586    let mut oldest_sample_ms: Option<u64> = None;
587
588    for request in recent {
589        if request.ended_at_ms() < cutoff_ms || request.ended_at_ms() > now_ms {
590            continue;
591        }
592        if request.total_cost_usd().is_some() {
593            if let Some(cost) = request
594                .total_cost_usd()
595                .and_then(UsdAmount::from_decimal_str)
596            {
597                sample_cost = sample_cost.saturating_add(cost);
598                priced_requests = priced_requests.saturating_add(1);
599                oldest_sample_ms = Some(
600                    oldest_sample_ms
601                        .map(|oldest| oldest.min(request.ended_at_ms()))
602                        .unwrap_or(request.ended_at_ms()),
603                );
604            } else {
605                unpriced_requests = unpriced_requests.saturating_add(1);
606            }
607        } else if request.has_usage() {
608            unpriced_requests = unpriced_requests.saturating_add(1);
609        }
610    }
611
612    let sample_elapsed_ms = oldest_sample_ms
613        .map(|oldest| {
614            now_ms
615                .saturating_sub(oldest)
616                .clamp(MINUTE_MS, sample_window_ms)
617        })
618        .unwrap_or(0);
619
620    if priced_requests == 0 || sample_elapsed_ms == 0 {
621        return UsageSpendForecast {
622            enabled: true,
623            sample_window_ms,
624            sample_elapsed_ms,
625            priced_requests,
626            unpriced_requests,
627            reset_at_ms,
628            reset_in_ms,
629            confidence: UsageForecastConfidence::NoData,
630            reason: Some("no priced requests in forecast window".to_string()),
631            ..UsageSpendForecast::default()
632        };
633    }
634
635    let rate_per_hour = scale_usd_by_ratio(sample_cost, HOUR_MS, sample_elapsed_ms);
636    let confidence = if priced_requests < config.min_priced_requests.max(1) {
637        UsageForecastConfidence::LowSample
638    } else if unpriced_requests > 0 {
639        UsageForecastConfidence::PartialPricing
640    } else {
641        UsageForecastConfidence::Estimated
642    };
643    let projected_sample_elapsed_ms = if confidence == UsageForecastConfidence::LowSample {
644        0
645    } else {
646        sample_elapsed_ms
647    };
648    let projected_until_reset = if confidence == UsageForecastConfidence::LowSample {
649        None
650    } else {
651        reset_in_ms.map(|reset_in_ms| scale_usd_by_ratio(rate_per_hour, reset_in_ms, HOUR_MS))
652    };
653    let primary_balance_remaining =
654        primary_remaining_balance(provider_balances, now_ms).map(|(_, amount)| amount);
655    let projected_balance_after_reset = match (primary_balance_remaining, projected_until_reset) {
656        (Some(balance), Some(projected)) => Some(balance.saturating_sub(projected)),
657        _ => None,
658    };
659    let projected_exhaustion = matches!(
660        (primary_balance_remaining, projected_until_reset),
661        (Some(balance), Some(projected)) if projected > balance
662    );
663
664    UsageSpendForecast {
665        enabled: true,
666        sample_window_ms,
667        sample_elapsed_ms,
668        projected_sample_elapsed_ms,
669        priced_requests,
670        unpriced_requests,
671        sample_cost_usd: Some(sample_cost.format_usd()),
672        rate_per_hour_usd: Some(rate_per_hour.format_usd()),
673        reset_at_ms,
674        reset_in_ms,
675        projected_until_reset_usd: projected_until_reset.map(UsdAmount::format_usd),
676        primary_balance_remaining_usd: primary_balance_remaining.map(UsdAmount::format_usd),
677        projected_balance_after_reset_usd: projected_balance_after_reset.map(UsdAmount::format_usd),
678        projected_exhaustion,
679        confidence,
680        reason: None,
681        ..UsageSpendForecast::default()
682    }
683}
684
685pub fn build_quota_pacing_forecast(
686    spend: &UsageSpendForecast,
687    provider_balances: &[ProviderBalanceSnapshot],
688    now_ms: u64,
689) -> QuotaPacingForecast {
690    let Some(snapshot) = primary_quota_snapshot(provider_balances, now_ms) else {
691        return QuotaPacingForecast {
692            reason: Some("no package quota snapshot".to_string()),
693            ..QuotaPacingForecast::default()
694        };
695    };
696
697    let rate_per_hour = spend
698        .rate_per_hour_usd
699        .as_deref()
700        .and_then(UsdAmount::from_decimal_str);
701    let remaining = quota_remaining(snapshot);
702    let limit = snapshot
703        .quota_limit_usd
704        .as_deref()
705        .and_then(UsdAmount::from_decimal_str);
706    let used = snapshot
707        .quota_used_usd
708        .as_deref()
709        .and_then(UsdAmount::from_decimal_str);
710
711    if snapshot.unlimited_quota == Some(true) {
712        return quota_pacing_from_snapshot(snapshot, rate_per_hour, remaining, limit, used, None)
713            .with_status(QuotaPacingStatus::Unlimited, Some("unlimited quota"));
714    }
715
716    let Some(remaining) = remaining else {
717        return quota_pacing_from_snapshot(snapshot, rate_per_hour, None, limit, used, None)
718            .with_status(
719                QuotaPacingStatus::Unavailable,
720                Some("quota remaining unavailable"),
721            );
722    };
723
724    if remaining.is_zero() || snapshot.exhausted == Some(true) {
725        return quota_pacing_from_snapshot(
726            snapshot,
727            rate_per_hour,
728            Some(remaining),
729            limit,
730            used,
731            None,
732        )
733        .with_status(QuotaPacingStatus::Exhausted, Some("quota exhausted"));
734    }
735
736    let estimated_exhaustion_in_ms =
737        rate_per_hour.and_then(|rate| estimated_exhaustion_in_ms(remaining, rate));
738    let mut pacing = quota_pacing_from_snapshot(
739        snapshot,
740        rate_per_hour,
741        Some(remaining),
742        limit,
743        used,
744        estimated_exhaustion_in_ms,
745    );
746
747    let Some(rate_per_hour) = rate_per_hour.filter(|rate| !rate.is_zero()) else {
748        return pacing.with_status(QuotaPacingStatus::NoSpendRate, Some("no spend rate"));
749    };
750
751    let Some(reset_in_ms) = quota_reset_in_ms(spend, snapshot) else {
752        return pacing.with_status(
753            QuotaPacingStatus::UnknownReset,
754            Some("reset time unavailable"),
755        );
756    };
757
758    let target_rate = scale_usd_by_ratio(remaining, HOUR_MS, reset_in_ms);
759    pacing.target_rate_per_hour_usd = Some(target_rate.format_usd());
760    pacing.reset_at_ms = spend.reset_at_ms;
761    pacing.reset_in_ms = Some(reset_in_ms);
762
763    if target_rate.is_zero() {
764        return pacing.with_status(QuotaPacingStatus::Fast, Some("target rate is zero"));
765    }
766
767    let ratio_pct = amount_ratio_pct(rate_per_hour, target_rate);
768    pacing.pace_ratio_pct = Some(ratio_pct);
769    pacing.status = if ratio_pct > 120 {
770        QuotaPacingStatus::Fast
771    } else if ratio_pct < 80 {
772        QuotaPacingStatus::Slow
773    } else {
774        QuotaPacingStatus::OnTrack
775    };
776    pacing
777}
778
779fn scale_usd_by_ratio(amount: UsdAmount, numerator: u64, denominator: u64) -> UsdAmount {
780    if denominator == 0 || numerator == 0 || amount.is_zero() {
781        return UsdAmount::ZERO;
782    }
783    UsdAmount::from_femto_usd(
784        amount
785            .femto_usd()
786            .saturating_mul(i128::from(numerator))
787            .saturating_div(i128::from(denominator)),
788    )
789}
790
791fn amount_ratio_pct(numerator: UsdAmount, denominator: UsdAmount) -> u32 {
792    if denominator.is_zero() {
793        return u32::MAX;
794    }
795    let value = numerator
796        .femto_usd()
797        .saturating_mul(100)
798        .saturating_div(denominator.femto_usd());
799    u32::try_from(value.max(0)).unwrap_or(u32::MAX)
800}
801
802fn estimated_exhaustion_in_ms(remaining: UsdAmount, rate_per_hour: UsdAmount) -> Option<u64> {
803    if remaining.is_zero() || rate_per_hour.is_zero() {
804        return None;
805    }
806    let ms = remaining
807        .femto_usd()
808        .saturating_mul(i128::from(HOUR_MS))
809        .saturating_div(rate_per_hour.femto_usd());
810    u64::try_from(ms).ok().filter(|value| *value > 0)
811}
812
813fn primary_remaining_balance(
814    provider_balances: &[ProviderBalanceSnapshot],
815    now_ms: u64,
816) -> Option<(&ProviderBalanceSnapshot, UsdAmount)> {
817    provider_balances
818        .iter()
819        .filter(|snapshot| snapshot.error.as_deref().is_none_or(str::is_empty))
820        .filter_map(|snapshot| remaining_balance(snapshot).map(|amount| (snapshot, amount)))
821        .min_by(|(left, _), (right, _)| {
822            balance_rank(left, now_ms)
823                .cmp(&balance_rank(right, now_ms))
824                .then_with(|| left.upstream_index.cmp(&right.upstream_index))
825                .then_with(|| right.fetched_at_ms.cmp(&left.fetched_at_ms))
826        })
827}
828
829fn primary_quota_snapshot(
830    provider_balances: &[ProviderBalanceSnapshot],
831    now_ms: u64,
832) -> Option<&ProviderBalanceSnapshot> {
833    provider_balances
834        .iter()
835        .filter(|snapshot| snapshot.error.as_deref().is_none_or(str::is_empty))
836        .filter(|snapshot| is_package_quota_snapshot(snapshot))
837        .min_by(|left, right| {
838            balance_rank(left, now_ms)
839                .cmp(&balance_rank(right, now_ms))
840                .then_with(|| quota_rank(left).cmp(&quota_rank(right)))
841                .then_with(|| left.upstream_index.cmp(&right.upstream_index))
842                .then_with(|| right.fetched_at_ms.cmp(&left.fetched_at_ms))
843        })
844}
845
846fn remaining_balance(snapshot: &ProviderBalanceSnapshot) -> Option<UsdAmount> {
847    snapshot
848        .quota_remaining_usd
849        .as_deref()
850        .and_then(UsdAmount::from_decimal_str)
851        .or_else(|| {
852            snapshot
853                .total_balance_usd
854                .as_deref()
855                .and_then(UsdAmount::from_decimal_str)
856        })
857}
858
859fn is_package_quota_snapshot(snapshot: &ProviderBalanceSnapshot) -> bool {
860    snapshot.unlimited_quota == Some(true)
861        || snapshot.quota_period.is_some()
862        || snapshot.quota_remaining_usd.is_some()
863        || snapshot.quota_limit_usd.is_some()
864        || snapshot.quota_used_usd.is_some()
865        || (snapshot.monthly_budget_usd.is_some() && snapshot.monthly_spent_usd.is_some())
866}
867
868fn quota_remaining(snapshot: &ProviderBalanceSnapshot) -> Option<UsdAmount> {
869    snapshot
870        .quota_remaining_usd
871        .as_deref()
872        .and_then(UsdAmount::from_decimal_str)
873        .or_else(|| {
874            let budget = snapshot
875                .monthly_budget_usd
876                .as_deref()
877                .and_then(UsdAmount::from_decimal_str)?;
878            let spent = snapshot
879                .monthly_spent_usd
880                .as_deref()
881                .and_then(UsdAmount::from_decimal_str)?;
882            Some(budget.saturating_sub(spent))
883        })
884}
885
886fn quota_rank(snapshot: &ProviderBalanceSnapshot) -> u8 {
887    if snapshot.unlimited_quota == Some(true) {
888        return 3;
889    }
890    if snapshot.quota_remaining_usd.is_some() {
891        return 0;
892    }
893    if snapshot.monthly_budget_usd.is_some() && snapshot.monthly_spent_usd.is_some() {
894        return 1;
895    }
896    2
897}
898
899fn quota_reset_in_ms(
900    spend: &UsageSpendForecast,
901    snapshot: &ProviderBalanceSnapshot,
902) -> Option<u64> {
903    let period = snapshot
904        .quota_period
905        .as_deref()
906        .map(str::trim)
907        .filter(|value| !value.is_empty())?;
908    period
909        .eq_ignore_ascii_case("daily")
910        .then_some(spend.reset_in_ms)
911        .flatten()
912        .filter(|value| *value > 0)
913}
914
915fn balance_rank(snapshot: &ProviderBalanceSnapshot, now_ms: u64) -> u8 {
916    match snapshot.status_at(now_ms) {
917        crate::balance::BalanceSnapshotStatus::Ok => 0,
918        crate::balance::BalanceSnapshotStatus::Stale => 1,
919        crate::balance::BalanceSnapshotStatus::Unknown => 2,
920        crate::balance::BalanceSnapshotStatus::Error => 3,
921        crate::balance::BalanceSnapshotStatus::Exhausted => 4,
922    }
923}
924
925fn quota_pacing_from_snapshot(
926    snapshot: &ProviderBalanceSnapshot,
927    rate_per_hour: Option<UsdAmount>,
928    remaining: Option<UsdAmount>,
929    limit: Option<UsdAmount>,
930    used: Option<UsdAmount>,
931    estimated_exhaustion_in_ms: Option<u64>,
932) -> QuotaPacingForecast {
933    QuotaPacingForecast {
934        available: true,
935        provider_id: snapshot.provider_id.clone(),
936        station_name: snapshot.station_name.clone(),
937        upstream_index: snapshot.upstream_index,
938        source: snapshot.source.clone(),
939        plan_name: snapshot.plan_name.clone(),
940        period: snapshot
941            .quota_period
942            .clone()
943            .or_else(|| monthly_budget_period(snapshot)),
944        unlimited: snapshot.unlimited_quota == Some(true),
945        remaining_usd: remaining.map(UsdAmount::format_usd),
946        limit_usd: limit.map(UsdAmount::format_usd),
947        used_usd: used.map(UsdAmount::format_usd),
948        rate_per_hour_usd: rate_per_hour.map(UsdAmount::format_usd),
949        estimated_exhaustion_in_ms,
950        ..QuotaPacingForecast::default()
951    }
952}
953
954fn monthly_budget_period(snapshot: &ProviderBalanceSnapshot) -> Option<String> {
955    (snapshot.monthly_budget_usd.is_some() && snapshot.monthly_spent_usd.is_some())
956        .then(|| "monthly".to_string())
957}
958
959impl QuotaPacingForecast {
960    fn with_status(mut self, status: QuotaPacingStatus, reason: Option<&str>) -> Self {
961        self.status = status;
962        self.reason = reason.map(str::to_string);
963        self
964    }
965}
966
967pub fn next_reset_at_ms(now_ms: u64, utc_offset: &str, reset_time: &str) -> Option<u64> {
968    let offset_ms = parse_utc_offset_ms(utc_offset)?;
969    let reset_ms = parse_hh_mm_ms(reset_time)?;
970    let local_ms = i128::from(now_ms) + offset_ms;
971    let local_day_start = div_floor(local_ms, i128::from(DAY_MS)) * i128::from(DAY_MS);
972    let mut reset_local = local_day_start + i128::from(reset_ms);
973    if reset_local <= local_ms {
974        reset_local += i128::from(DAY_MS);
975    }
976    let reset_utc = reset_local - offset_ms;
977    u64::try_from(reset_utc).ok()
978}
979
980fn parse_hh_mm_ms(value: &str) -> Option<u64> {
981    let (hour, minute) = value.trim().split_once(':')?;
982    let hour = hour.parse::<u64>().ok()?;
983    let minute = minute.parse::<u64>().ok()?;
984    if hour >= 24 || minute >= 60 {
985        return None;
986    }
987    Some(
988        hour.saturating_mul(HOUR_MS)
989            .saturating_add(minute.saturating_mul(MINUTE_MS)),
990    )
991}
992
993fn parse_utc_offset_ms(value: &str) -> Option<i128> {
994    let value = value.trim();
995    if value.eq_ignore_ascii_case("z") || value == "+00:00" || value == "-00:00" {
996        return Some(0);
997    }
998    let sign = match value.as_bytes().first().copied()? {
999        b'+' => 1_i128,
1000        b'-' => -1_i128,
1001        _ => return None,
1002    };
1003    let rest = &value[1..];
1004    let (hour, minute) = rest.split_once(':')?;
1005    let hour = hour.parse::<i128>().ok()?;
1006    let minute = minute.parse::<i128>().ok()?;
1007    if hour > 23 || minute > 59 {
1008        return None;
1009    }
1010    Some(sign * (hour * i128::from(HOUR_MS) + minute * i128::from(MINUTE_MS)))
1011}
1012
1013fn div_floor(a: i128, b: i128) -> i128 {
1014    let q = a / b;
1015    let r = a % b;
1016    if r != 0 && ((r > 0) != (b > 0)) {
1017        q - 1
1018    } else {
1019        q
1020    }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026    use crate::pricing::{CostAdjustments, ModelPrice, estimate_usage_cost_with_accounting};
1027    use crate::state::RequestObservability;
1028    use crate::usage::{CacheInputAccounting, UsageMetrics};
1029
1030    fn priced_request(ended_at_ms: u64, usd: &str) -> FinishedRequest {
1031        let usage = UsageMetrics {
1032            input_tokens: 1_000_000,
1033            total_tokens: 1_000_000,
1034            ..Default::default()
1035        };
1036        let price = ModelPrice::from_per_million_usd(
1037            "gpt-test",
1038            None,
1039            usd,
1040            "0",
1041            Some("0"),
1042            Some("0"),
1043            "test",
1044        )
1045        .expect("test price");
1046        let cost = estimate_usage_cost_with_accounting(
1047            &usage,
1048            &price,
1049            CostAdjustments::default(),
1050            CacheInputAccounting::default(),
1051        );
1052
1053        FinishedRequest {
1054            id: ended_at_ms,
1055            trace_id: None,
1056            session_id: None,
1057            session_identity_source: None,
1058            client_name: None,
1059            client_addr: None,
1060            cwd: None,
1061            model: Some("gpt-test".to_string()),
1062            reasoning_effort: None,
1063            service_tier: None,
1064            station_name: Some("station".to_string()),
1065            provider_id: Some("provider".to_string()),
1066            upstream_base_url: None,
1067            route_decision: None,
1068            usage: Some(usage),
1069            cost,
1070            retry: None,
1071            provider_signals: Vec::new(),
1072            policy_actions: Vec::new(),
1073            observability: RequestObservability::default(),
1074            service: "codex".to_string(),
1075            method: "POST".to_string(),
1076            path: "/v1/responses".to_string(),
1077            status_code: 200,
1078            duration_ms: 100,
1079            ttfb_ms: None,
1080            streaming: false,
1081            ended_at_ms,
1082        }
1083    }
1084
1085    #[test]
1086    fn next_reset_uses_configured_fixed_offset_midnight() {
1087        let now_ms = 10 * HOUR_MS;
1088
1089        let reset = next_reset_at_ms(now_ms, "+08:00", "00:00").expect("reset");
1090
1091        assert_eq!(reset, 16 * HOUR_MS);
1092    }
1093
1094    #[test]
1095    fn forecast_projects_current_hourly_spend_until_reset() {
1096        let now_ms = 10 * HOUR_MS;
1097        let config = UsageForecastConfig {
1098            rate_window_minutes: 60,
1099            min_priced_requests: 2,
1100            ..UsageForecastConfig::default()
1101        };
1102        let recent = vec![
1103            priced_request(now_ms - 60 * MINUTE_MS, "1"),
1104            priced_request(now_ms - 30 * MINUTE_MS, "1"),
1105        ];
1106        let balances = vec![ProviderBalanceSnapshot {
1107            provider_id: "provider".to_string(),
1108            quota_remaining_usd: Some("20".to_string()),
1109            fetched_at_ms: now_ms,
1110            ..ProviderBalanceSnapshot::default()
1111        }];
1112
1113        let forecast = build_usage_spend_forecast(&config, &recent, &balances, now_ms);
1114
1115        assert_eq!(forecast.rate_per_hour_usd.as_deref(), Some("2"));
1116        assert_eq!(forecast.reset_in_ms, Some(6 * HOUR_MS));
1117        assert_eq!(forecast.projected_until_reset_usd.as_deref(), Some("12"));
1118        assert_eq!(
1119            forecast.projected_balance_after_reset_usd.as_deref(),
1120            Some("8")
1121        );
1122        assert!(!forecast.projected_exhaustion);
1123        assert_eq!(forecast.confidence, UsageForecastConfidence::Estimated);
1124    }
1125
1126    #[test]
1127    fn forecast_marks_exhaustion_when_projection_exceeds_remaining_balance() {
1128        let now_ms = 10 * HOUR_MS;
1129        let config = UsageForecastConfig {
1130            min_priced_requests: 2,
1131            ..UsageForecastConfig::default()
1132        };
1133        let recent = vec![
1134            priced_request(now_ms - 60 * MINUTE_MS, "1"),
1135            priced_request(now_ms - 30 * MINUTE_MS, "1"),
1136        ];
1137        let balances = vec![ProviderBalanceSnapshot {
1138            provider_id: "provider".to_string(),
1139            quota_remaining_usd: Some("2".to_string()),
1140            fetched_at_ms: now_ms,
1141            ..ProviderBalanceSnapshot::default()
1142        }];
1143
1144        let forecast = build_usage_spend_forecast(&config, &recent, &balances, now_ms);
1145
1146        assert_eq!(forecast.projected_until_reset_usd.as_deref(), Some("12"));
1147        assert_eq!(
1148            forecast.projected_balance_after_reset_usd.as_deref(),
1149            Some("0")
1150        );
1151        assert!(forecast.projected_exhaustion);
1152    }
1153
1154    #[test]
1155    fn quota_pacing_marks_package_burn_as_fast_against_reset_target() {
1156        let now_ms = 10 * HOUR_MS;
1157        let config = UsageForecastConfig {
1158            rate_window_minutes: 60,
1159            min_priced_requests: 2,
1160            ..UsageForecastConfig::default()
1161        };
1162        let recent = vec![
1163            priced_request(now_ms - 60 * MINUTE_MS, "1"),
1164            priced_request(now_ms - 30 * MINUTE_MS, "1"),
1165        ];
1166        let balances = vec![ProviderBalanceSnapshot {
1167            provider_id: "provider".to_string(),
1168            station_name: Some("station".to_string()),
1169            quota_period: Some("daily".to_string()),
1170            quota_remaining_usd: Some("6".to_string()),
1171            quota_limit_usd: Some("20".to_string()),
1172            quota_used_usd: Some("14".to_string()),
1173            fetched_at_ms: now_ms,
1174            ..ProviderBalanceSnapshot::default()
1175        }];
1176        let spend = build_usage_spend_forecast(&config, &recent, &balances, now_ms);
1177
1178        let pacing = build_quota_pacing_forecast(&spend, &balances, now_ms);
1179
1180        assert!(pacing.available);
1181        assert_eq!(pacing.period.as_deref(), Some("daily"));
1182        assert_eq!(pacing.remaining_usd.as_deref(), Some("6"));
1183        assert_eq!(pacing.limit_usd.as_deref(), Some("20"));
1184        assert_eq!(pacing.rate_per_hour_usd.as_deref(), Some("2"));
1185        assert_eq!(pacing.target_rate_per_hour_usd.as_deref(), Some("1"));
1186        assert_eq!(pacing.pace_ratio_pct, Some(200));
1187        assert_eq!(pacing.status, QuotaPacingStatus::Fast);
1188    }
1189
1190    #[test]
1191    fn spend_forecast_applies_balance_delta_calibration() {
1192        let now_ms = 10 * HOUR_MS;
1193        let config = UsageForecastConfig {
1194            rate_window_minutes: 60,
1195            min_priced_requests: 1,
1196            ..UsageForecastConfig::default()
1197        };
1198        let recent = vec![priced_request(now_ms - 30 * MINUTE_MS, "1")];
1199        let balances = vec![ProviderBalanceSnapshot {
1200            provider_id: "provider".to_string(),
1201            station_name: Some("station".to_string()),
1202            quota_period: Some("daily".to_string()),
1203            quota_remaining_usd: Some("8".to_string()),
1204            fetched_at_ms: now_ms,
1205            ..ProviderBalanceSnapshot::default()
1206        }];
1207        let history = vec![
1208            ProviderBalanceSnapshot {
1209                provider_id: "provider".to_string(),
1210                station_name: Some("station".to_string()),
1211                upstream_index: Some(0),
1212                quota_period: Some("daily".to_string()),
1213                quota_remaining_usd: Some("10".to_string()),
1214                fetched_at_ms: now_ms - 60 * MINUTE_MS,
1215                ..ProviderBalanceSnapshot::default()
1216            },
1217            ProviderBalanceSnapshot {
1218                provider_id: "provider".to_string(),
1219                station_name: Some("station".to_string()),
1220                upstream_index: Some(0),
1221                quota_period: Some("daily".to_string()),
1222                quota_remaining_usd: Some("8".to_string()),
1223                fetched_at_ms: now_ms,
1224                ..ProviderBalanceSnapshot::default()
1225            },
1226        ];
1227
1228        let forecast = build_usage_spend_forecast_with_balance_history(
1229            &config, &recent, &balances, &history, now_ms,
1230        );
1231
1232        assert!(forecast.balance_calibrated);
1233        assert_eq!(forecast.balance_calibration_multiplier_pct, Some(200));
1234        assert_eq!(forecast.rate_per_hour_usd.as_deref(), Some("4"));
1235        assert_eq!(forecast.sample_cost_usd.as_deref(), Some("2"));
1236    }
1237
1238    #[test]
1239    fn quota_pacing_uses_remaining_eta_when_reset_is_unknown() {
1240        let now_ms = 10 * HOUR_MS;
1241        let spend = UsageSpendForecast {
1242            enabled: true,
1243            rate_per_hour_usd: Some("2".to_string()),
1244            ..UsageSpendForecast::default()
1245        };
1246        let balances = vec![ProviderBalanceSnapshot {
1247            provider_id: "provider".to_string(),
1248            quota_period: Some("daily".to_string()),
1249            quota_remaining_usd: Some("6".to_string()),
1250            quota_limit_usd: Some("20".to_string()),
1251            fetched_at_ms: now_ms,
1252            ..ProviderBalanceSnapshot::default()
1253        }];
1254
1255        let pacing = build_quota_pacing_forecast(&spend, &balances, now_ms);
1256
1257        assert_eq!(pacing.status, QuotaPacingStatus::UnknownReset);
1258        assert_eq!(pacing.estimated_exhaustion_in_ms, Some(3 * HOUR_MS));
1259        assert_eq!(pacing.target_rate_per_hour_usd, None);
1260    }
1261
1262    #[test]
1263    fn quota_pacing_does_not_apply_daily_reset_to_monthly_budget() {
1264        let now_ms = 10 * HOUR_MS;
1265        let spend = UsageSpendForecast {
1266            enabled: true,
1267            rate_per_hour_usd: Some("2".to_string()),
1268            reset_in_ms: Some(6 * HOUR_MS),
1269            ..UsageSpendForecast::default()
1270        };
1271        let balances = vec![ProviderBalanceSnapshot {
1272            provider_id: "provider".to_string(),
1273            monthly_budget_usd: Some("20".to_string()),
1274            monthly_spent_usd: Some("14".to_string()),
1275            fetched_at_ms: now_ms,
1276            ..ProviderBalanceSnapshot::default()
1277        }];
1278
1279        let pacing = build_quota_pacing_forecast(&spend, &balances, now_ms);
1280
1281        assert_eq!(pacing.status, QuotaPacingStatus::UnknownReset);
1282        assert_eq!(pacing.period.as_deref(), Some("monthly"));
1283        assert_eq!(pacing.remaining_usd.as_deref(), Some("6"));
1284        assert_eq!(pacing.estimated_exhaustion_in_ms, Some(3 * HOUR_MS));
1285        assert_eq!(pacing.target_rate_per_hour_usd, None);
1286    }
1287
1288    #[test]
1289    fn forecast_keeps_burn_rate_but_suppresses_projection_for_low_sample() {
1290        let now_ms = 10 * HOUR_MS;
1291        let config = UsageForecastConfig {
1292            min_priced_requests: 2,
1293            ..UsageForecastConfig::default()
1294        };
1295        let recent = vec![priced_request(now_ms - 30 * MINUTE_MS, "1")];
1296
1297        let forecast = build_usage_spend_forecast(&config, &recent, &[], now_ms);
1298
1299        assert_eq!(forecast.rate_per_hour_usd.as_deref(), Some("2"));
1300        assert_eq!(forecast.confidence, UsageForecastConfidence::LowSample);
1301        assert_eq!(forecast.projected_until_reset_usd, None);
1302        assert_eq!(forecast.projected_sample_elapsed_ms, 0);
1303        assert!(!forecast.projected_exhaustion);
1304    }
1305}