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("a_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}