1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use serde::{Deserialize, Serialize};
4
5use crate::pricing::{CostConfidence, UsdAmount};
6use crate::routing_explain::{RoutingExplainCandidate, RoutingExplainResponse};
7use crate::state::{
8 BalanceSnapshotStatus, FinishedRequest, ProviderBalanceSnapshot, UsageBucket, UsageRollupView,
9};
10use crate::usage_providers::UsageProviderRefreshSummary;
11
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, Hash)]
13#[serde(rename_all = "snake_case")]
14pub enum UsageBalanceStatus {
15 #[default]
16 Unknown,
17 Ok,
18 Unlimited,
19 Exhausted,
20 Stale,
21 Error,
22}
23
24impl UsageBalanceStatus {
25 pub fn as_str(self) -> &'static str {
26 match self {
27 UsageBalanceStatus::Unknown => "unknown",
28 UsageBalanceStatus::Ok => "ok",
29 UsageBalanceStatus::Unlimited => "unlimited",
30 UsageBalanceStatus::Exhausted => "exhausted",
31 UsageBalanceStatus::Stale => "stale",
32 UsageBalanceStatus::Error => "error",
33 }
34 }
35
36 pub fn is_attention(self) -> bool {
37 matches!(
38 self,
39 UsageBalanceStatus::Unknown
40 | UsageBalanceStatus::Exhausted
41 | UsageBalanceStatus::Stale
42 | UsageBalanceStatus::Error
43 )
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
48pub struct UsageBalanceStatusCounts {
49 pub ok: usize,
50 pub unlimited: usize,
51 pub exhausted: usize,
52 pub stale: usize,
53 pub error: usize,
54 pub unknown: usize,
55}
56
57impl UsageBalanceStatusCounts {
58 pub fn total(&self) -> usize {
59 self.ok
60 .saturating_add(self.unlimited)
61 .saturating_add(self.exhausted)
62 .saturating_add(self.stale)
63 .saturating_add(self.error)
64 .saturating_add(self.unknown)
65 }
66
67 pub fn record(&mut self, status: UsageBalanceStatus) {
68 match status {
69 UsageBalanceStatus::Ok => self.ok += 1,
70 UsageBalanceStatus::Unlimited => self.unlimited += 1,
71 UsageBalanceStatus::Exhausted => self.exhausted += 1,
72 UsageBalanceStatus::Stale => self.stale += 1,
73 UsageBalanceStatus::Error => self.error += 1,
74 UsageBalanceStatus::Unknown => self.unknown += 1,
75 }
76 }
77
78 pub fn aggregate_status(&self) -> UsageBalanceStatus {
79 if self.total() == 0 {
80 UsageBalanceStatus::Unknown
81 } else if self.error > 0 {
82 UsageBalanceStatus::Error
83 } else if self.exhausted > 0 {
84 UsageBalanceStatus::Exhausted
85 } else if self.stale > 0 {
86 UsageBalanceStatus::Stale
87 } else if self.unknown > 0 {
88 UsageBalanceStatus::Unknown
89 } else if self.unlimited > 0 {
90 UsageBalanceStatus::Unlimited
91 } else {
92 UsageBalanceStatus::Ok
93 }
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct UsageBalanceView {
99 pub service_name: String,
100 pub window_days: usize,
101 pub generated_at_ms: u64,
102 pub totals: UsageBalanceTotals,
103 pub provider_rows: Vec<UsageBalanceProviderRow>,
104 pub endpoint_rows: Vec<UsageBalanceEndpointRow>,
105 pub routing_impacts: Vec<UsageBalanceRouteImpact>,
106 pub refresh_status: UsageBalanceRefreshStatus,
107 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub warnings: Vec<String>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112pub struct UsageBalanceTotals {
113 pub requests_total: u64,
114 pub requests_error: u64,
115 pub success_per_mille: Option<u16>,
116 pub input_tokens: i64,
117 pub cached_input_tokens: i64,
118 pub output_tokens: i64,
119 pub reasoning_output_tokens: i64,
120 pub total_tokens: i64,
121 pub cost_total_usd: Option<String>,
122 pub cost_display: String,
123 pub cost_confidence: CostConfidence,
124 pub balance_status_counts: UsageBalanceStatusCounts,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128pub struct UsageBalanceProviderRow {
129 pub provider_id: String,
130 pub display_name: String,
131 pub usage: UsageBucket,
132 pub success_per_mille: Option<u16>,
133 pub output_tokens_per_second: Option<u64>,
134 pub avg_ttfb_ms: Option<u64>,
135 pub cost_total_usd: Option<String>,
136 pub cost_display: String,
137 pub cost_confidence: CostConfidence,
138 pub balance_status: UsageBalanceStatus,
139 pub balance_counts: UsageBalanceStatusCounts,
140 pub primary_balance: Option<UsageBalanceSnapshotSummary>,
141 pub latest_balance_error: Option<String>,
142 pub balance_age_ms: Option<u64>,
143 pub routing: UsageBalanceRouteImpact,
144 pub endpoint_count: usize,
145 pub endpoints_with_balance: usize,
146 pub recent_endpoint_requests: u64,
147}
148
149impl UsageBalanceProviderRow {
150 pub fn needs_attention(&self) -> bool {
151 self.balance_status.is_attention()
152 || self
153 .latest_balance_error
154 .as_deref()
155 .is_some_and(|value| !value.trim().is_empty())
156 || self.usage.requests_error > 0
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub struct UsageBalanceEndpointRow {
162 pub provider_id: String,
163 pub endpoint_id: String,
164 pub station_name: Option<String>,
165 pub upstream_index: Option<usize>,
166 pub base_url: Option<String>,
167 pub usage: UsageBucket,
168 pub balance_status: UsageBalanceStatus,
169 pub balance: Option<UsageBalanceSnapshotSummary>,
170 pub route_selected: bool,
171 pub route_skip_reasons: Vec<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175pub struct UsageBalanceSnapshotSummary {
176 pub provider_id: String,
177 pub station_name: Option<String>,
178 pub upstream_index: Option<usize>,
179 pub source: String,
180 pub status: UsageBalanceStatus,
181 pub amount_summary: String,
182 pub fetched_at_ms: u64,
183 pub stale_after_ms: Option<u64>,
184 pub age_ms: Option<u64>,
185 pub error: Option<String>,
186 pub exhaustion_affects_routing: bool,
187 pub routing_exhausted: bool,
188 pub routing_ignored_exhaustion: bool,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
192pub struct UsageBalanceRouteImpact {
193 pub provider_id: String,
194 pub selected: bool,
195 pub selected_endpoint_id: Option<String>,
196 pub selected_provider_endpoint_key: Option<String>,
197 pub candidate_count: usize,
198 pub skip_reasons: Vec<String>,
199 pub route_paths: Vec<Vec<String>>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
203pub struct UsageBalanceRefreshStatus {
204 pub refreshing: bool,
205 pub total_snapshots: usize,
206 pub latest_fetched_at_ms: Option<u64>,
207 pub latest_error: Option<String>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub latest_error_provider_id: Option<String>,
210 pub last_message: Option<String>,
211 pub last_error: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub last_provider_refresh: Option<UsageProviderRefreshSummary>,
214 pub status_counts: UsageBalanceStatusCounts,
215}
216
217#[derive(Debug, Clone, Default)]
218pub struct UsageBalanceRefreshInput {
219 pub refreshing: bool,
220 pub last_message: Option<String>,
221 pub last_error: Option<String>,
222 pub last_provider_refresh: Option<UsageProviderRefreshSummary>,
223}
224
225pub struct UsageBalanceBuildInput<'a> {
226 pub service_name: &'a str,
227 pub window_days: usize,
228 pub generated_at_ms: u64,
229 pub usage_rollup: &'a UsageRollupView,
230 pub provider_balances: &'a HashMap<String, Vec<ProviderBalanceSnapshot>>,
231 pub recent: &'a [FinishedRequest],
232 pub routing_explain: Option<&'a RoutingExplainResponse>,
233 pub refresh: UsageBalanceRefreshInput,
234}
235
236impl UsageBalanceView {
237 pub fn build(input: UsageBalanceBuildInput<'_>) -> Self {
238 let route_index = RouteIndex::from_explain(input.routing_explain);
239 let mut endpoint_rows = build_endpoint_rows(
240 input.recent,
241 input.provider_balances,
242 &route_index,
243 input.generated_at_ms,
244 );
245 sort_endpoint_rows(&mut endpoint_rows);
246
247 let provider_rows = build_provider_rows(&input, &route_index, &endpoint_rows);
248 let totals = build_totals(
249 input.usage_rollup,
250 input.provider_balances,
251 input.generated_at_ms,
252 );
253 let refresh_status = build_refresh_status(
254 input.provider_balances,
255 input.generated_at_ms,
256 input.refresh,
257 );
258 let routing_impacts = route_index
259 .provider_impacts
260 .values()
261 .cloned()
262 .collect::<Vec<_>>();
263
264 Self {
265 service_name: input.service_name.to_string(),
266 window_days: input.window_days,
267 generated_at_ms: input.generated_at_ms,
268 totals,
269 provider_rows,
270 endpoint_rows,
271 routing_impacts,
272 refresh_status,
273 warnings: Vec::new(),
274 }
275 }
276}
277
278fn build_totals(
279 rollup: &UsageRollupView,
280 provider_balances: &HashMap<String, Vec<ProviderBalanceSnapshot>>,
281 now_ms: u64,
282) -> UsageBalanceTotals {
283 let window = &rollup.window;
284 let mut balance_status_counts = UsageBalanceStatusCounts::default();
285 for snapshot in provider_balances.values().flatten() {
286 balance_status_counts.record(classify_snapshot(snapshot, now_ms));
287 }
288
289 UsageBalanceTotals {
290 requests_total: window.requests_total,
291 requests_error: window.requests_error,
292 success_per_mille: per_mille(
293 window.requests_total.saturating_sub(window.requests_error),
294 window.requests_total,
295 ),
296 input_tokens: window.usage.input_tokens,
297 cached_input_tokens: window.usage.cache_read_tokens_total(),
298 output_tokens: window.usage.output_tokens,
299 reasoning_output_tokens: window.usage.reasoning_output_tokens_total(),
300 total_tokens: window.usage.total_tokens,
301 cost_total_usd: window.cost.total_cost_usd.clone(),
302 cost_display: window.cost.display_total(),
303 cost_confidence: window.cost.confidence,
304 balance_status_counts,
305 }
306}
307
308fn build_provider_rows(
309 input: &UsageBalanceBuildInput<'_>,
310 route_index: &RouteIndex,
311 endpoint_rows: &[UsageBalanceEndpointRow],
312) -> Vec<UsageBalanceProviderRow> {
313 let mut provider_ids = BTreeSet::new();
314 for (provider_id, _) in &input.usage_rollup.by_provider {
315 provider_ids.insert(provider_id.clone());
316 }
317 for (provider_id, _) in group_balances_by_provider(input.provider_balances) {
318 provider_ids.insert(provider_id);
319 }
320 for request in input.recent {
321 if let Some(provider_id) = request
322 .provider_id
323 .as_deref()
324 .map(str::trim)
325 .filter(|value| !value.is_empty())
326 {
327 provider_ids.insert(provider_id.to_string());
328 }
329 }
330 for provider_id in route_index.provider_impacts.keys() {
331 provider_ids.insert(provider_id.clone());
332 }
333
334 let usage_by_provider = input
335 .usage_rollup
336 .by_provider
337 .iter()
338 .cloned()
339 .collect::<BTreeMap<_, _>>();
340 let balances_by_provider = group_balances_by_provider(input.provider_balances);
341 let mut endpoint_counts = BTreeMap::<String, (usize, usize, u64)>::new();
342 for endpoint in endpoint_rows {
343 let entry = endpoint_counts
344 .entry(endpoint.provider_id.clone())
345 .or_insert((0, 0, 0));
346 entry.0 += 1;
347 if endpoint.balance.is_some() {
348 entry.1 += 1;
349 }
350 entry.2 = entry.2.saturating_add(endpoint.usage.requests_total);
351 }
352
353 let mut rows = provider_ids
354 .into_iter()
355 .map(|provider_id| {
356 let usage = usage_by_provider
357 .get(provider_id.as_str())
358 .cloned()
359 .unwrap_or_default();
360 let balances = balances_by_provider
361 .get(provider_id.as_str())
362 .cloned()
363 .unwrap_or_default();
364 let balance_counts = balance_counts_for_snapshots(&balances, input.generated_at_ms);
365 let primary_balance = primary_balance_snapshot(&balances, input.generated_at_ms)
366 .map(|snapshot| summarize_snapshot(snapshot, input.generated_at_ms));
367 let latest_balance_error = latest_balance_error(&balances);
368 let balance_age_ms = primary_balance.as_ref().and_then(|summary| summary.age_ms);
369 let balance_status = balance_counts.aggregate_status();
370 let routing = route_index
371 .provider_impacts
372 .get(provider_id.as_str())
373 .cloned()
374 .unwrap_or_else(|| UsageBalanceRouteImpact {
375 provider_id: provider_id.clone(),
376 ..UsageBalanceRouteImpact::default()
377 });
378 let (endpoint_count, endpoints_with_balance, recent_endpoint_requests) =
379 endpoint_counts
380 .get(provider_id.as_str())
381 .copied()
382 .unwrap_or_default();
383
384 UsageBalanceProviderRow {
385 display_name: provider_id.clone(),
386 provider_id,
387 success_per_mille: per_mille(
388 usage.requests_total.saturating_sub(usage.requests_error),
389 usage.requests_total,
390 ),
391 output_tokens_per_second: output_tokens_per_second(&usage),
392 avg_ttfb_ms: (usage.ttfb_samples > 0)
393 .then(|| usage.ttfb_ms_total / usage.ttfb_samples),
394 cost_total_usd: usage.cost.total_cost_usd.clone(),
395 cost_display: usage.cost.display_total(),
396 cost_confidence: usage.cost.confidence,
397 usage,
398 balance_status,
399 balance_counts,
400 primary_balance,
401 latest_balance_error,
402 balance_age_ms,
403 routing,
404 endpoint_count,
405 endpoints_with_balance,
406 recent_endpoint_requests,
407 }
408 })
409 .collect::<Vec<_>>();
410
411 sort_provider_rows(&mut rows);
412 rows
413}
414
415fn build_refresh_status(
416 provider_balances: &HashMap<String, Vec<ProviderBalanceSnapshot>>,
417 now_ms: u64,
418 input: UsageBalanceRefreshInput,
419) -> UsageBalanceRefreshStatus {
420 let mut out = UsageBalanceRefreshStatus {
421 refreshing: input.refreshing,
422 last_message: input.last_message,
423 last_error: input.last_error,
424 last_provider_refresh: input.last_provider_refresh,
425 ..UsageBalanceRefreshStatus::default()
426 };
427 let mut latest_error_fetched_at_ms: Option<u64> = None;
428
429 for snapshot in provider_balances.values().flatten() {
430 out.total_snapshots += 1;
431 out.latest_fetched_at_ms = Some(
432 out.latest_fetched_at_ms
433 .unwrap_or(0)
434 .max(snapshot.fetched_at_ms),
435 );
436 let status = classify_snapshot(snapshot, now_ms);
437 out.status_counts.record(status);
438 if let Some(error) = snapshot
439 .error
440 .as_deref()
441 .map(str::trim)
442 .filter(|v| !v.is_empty())
443 && latest_error_fetched_at_ms.is_none_or(|latest| snapshot.fetched_at_ms >= latest)
444 {
445 out.latest_error = Some(error.to_string());
446 out.latest_error_provider_id = non_empty(snapshot.provider_id.clone())
447 .or_else(|| non_empty(snapshot.station_name.clone().unwrap_or_default()));
448 latest_error_fetched_at_ms = Some(snapshot.fetched_at_ms);
449 }
450 }
451
452 out
453}
454
455fn build_endpoint_rows(
456 recent: &[FinishedRequest],
457 provider_balances: &HashMap<String, Vec<ProviderBalanceSnapshot>>,
458 route_index: &RouteIndex,
459 now_ms: u64,
460) -> Vec<UsageBalanceEndpointRow> {
461 let mut acc = BTreeMap::<EndpointKey, EndpointAccum>::new();
462
463 for candidate in route_index.candidates.values() {
464 let key = EndpointKey {
465 provider_id: candidate.provider_id.clone(),
466 endpoint_id: candidate.endpoint_id.clone(),
467 };
468 let row = acc
469 .entry(key.clone())
470 .or_insert_with(|| EndpointAccum::new(key));
471 row.base_url = non_empty(candidate.base_url.clone()).or(row.base_url.take());
472 row.route_selected |= candidate.selected;
473 row.route_skip_reasons
474 .extend(candidate.skip_reasons.iter().cloned());
475 if let Some((station_name, upstream_index)) = candidate.compatibility.as_ref() {
476 row.station_name = Some(station_name.clone());
477 row.upstream_index = Some(*upstream_index);
478 }
479 }
480
481 for request in recent {
482 let Some(provider_id) = request
483 .provider_id
484 .as_deref()
485 .map(str::trim)
486 .filter(|value| !value.is_empty())
487 else {
488 continue;
489 };
490 let base_url = request
491 .upstream_base_url
492 .as_deref()
493 .map(str::trim)
494 .filter(|value| !value.is_empty());
495 let endpoint_id = base_url
496 .and_then(|base_url| {
497 route_index
498 .endpoint_by_provider_base_url
499 .get(&(provider_id.to_string(), base_url.to_string()))
500 .cloned()
501 })
502 .unwrap_or_else(|| {
503 base_url
504 .map(endpoint_id_from_base_url)
505 .unwrap_or_else(|| "unknown_endpoint".to_string())
506 });
507 let key = EndpointKey {
508 provider_id: provider_id.to_string(),
509 endpoint_id,
510 };
511 let row = acc
512 .entry(key.clone())
513 .or_insert_with(|| EndpointAccum::new(key));
514 if row.base_url.is_none() {
515 row.base_url = base_url.map(ToOwned::to_owned);
516 }
517 record_finished_into_bucket(&mut row.usage, request);
518 }
519
520 for (station_name, snapshots) in provider_balances {
521 for snapshot in snapshots {
522 let provider_id = balance_provider_id(station_name, snapshot);
523 let endpoint_id = snapshot
524 .upstream_index
525 .and_then(|idx| {
526 route_index
527 .endpoint_by_provider_station_upstream
528 .get(&(provider_id.clone(), station_name.clone(), idx))
529 .cloned()
530 })
531 .unwrap_or_else(|| {
532 snapshot
533 .upstream_index
534 .map(|idx| format!("upstream#{idx}"))
535 .or_else(|| non_empty(snapshot.source.clone()))
536 .unwrap_or_else(|| "balance".to_string())
537 });
538 let key = EndpointKey {
539 provider_id: provider_id.clone(),
540 endpoint_id,
541 };
542 let row = acc
543 .entry(key.clone())
544 .or_insert_with(|| EndpointAccum::new(key));
545 row.station_name = snapshot
546 .station_name
547 .clone()
548 .or_else(|| Some(station_name.clone()))
549 .or(row.station_name.take());
550 row.upstream_index = snapshot.upstream_index.or(row.upstream_index);
551 let summary = summarize_snapshot(snapshot, now_ms);
552 let replace = row.balance.as_ref().is_none_or(|existing| {
553 snapshot_display_rank(summary.status) < snapshot_display_rank(existing.status)
554 });
555 if replace {
556 row.balance = Some(summary);
557 }
558 }
559 }
560
561 acc.into_values().map(EndpointAccum::finish).collect()
562}
563
564fn record_finished_into_bucket(bucket: &mut UsageBucket, request: &FinishedRequest) {
565 bucket.requests_total = bucket.requests_total.saturating_add(1);
566 if request.status_code >= 400 {
567 bucket.requests_error = bucket.requests_error.saturating_add(1);
568 }
569 bucket.duration_ms_total = bucket.duration_ms_total.saturating_add(request.duration_ms);
570
571 let Some(usage) = request.usage.as_ref() else {
572 return;
573 };
574
575 bucket.usage.add_assign(usage);
576 bucket.cost.record_usage_cost(&request.cost);
577 bucket.requests_with_usage = bucket.requests_with_usage.saturating_add(1);
578 bucket.duration_ms_with_usage_total = bucket
579 .duration_ms_with_usage_total
580 .saturating_add(request.duration_ms);
581 let observability = request.observability_view();
582 let generation_ms = observability.generation_ms.unwrap_or(request.duration_ms);
583 bucket.generation_ms_total = bucket.generation_ms_total.saturating_add(generation_ms);
584 if let Some(ttfb) = observability.ttfb_ms.filter(|value| *value > 0) {
585 bucket.ttfb_ms_total = bucket.ttfb_ms_total.saturating_add(ttfb);
586 bucket.ttfb_samples = bucket.ttfb_samples.saturating_add(1);
587 }
588}
589
590fn group_balances_by_provider(
591 provider_balances: &HashMap<String, Vec<ProviderBalanceSnapshot>>,
592) -> BTreeMap<String, Vec<&ProviderBalanceSnapshot>> {
593 let mut out = BTreeMap::<String, Vec<&ProviderBalanceSnapshot>>::new();
594 for (station_name, snapshots) in provider_balances {
595 for snapshot in snapshots {
596 out.entry(balance_provider_id(station_name, snapshot))
597 .or_default()
598 .push(snapshot);
599 }
600 }
601 out
602}
603
604fn balance_provider_id(station_name: &str, snapshot: &ProviderBalanceSnapshot) -> String {
605 if snapshot.provider_id.trim().is_empty() {
606 station_name.to_string()
607 } else {
608 snapshot.provider_id.trim().to_string()
609 }
610}
611
612fn balance_counts_for_snapshots(
613 snapshots: &[&ProviderBalanceSnapshot],
614 now_ms: u64,
615) -> UsageBalanceStatusCounts {
616 let mut counts = UsageBalanceStatusCounts::default();
617 for snapshot in snapshots {
618 counts.record(classify_snapshot(snapshot, now_ms));
619 }
620 counts
621}
622
623fn classify_snapshot(snapshot: &ProviderBalanceSnapshot, now_ms: u64) -> UsageBalanceStatus {
624 match snapshot.status_at(now_ms) {
625 BalanceSnapshotStatus::Ok if snapshot.unlimited_quota == Some(true) => {
626 UsageBalanceStatus::Unlimited
627 }
628 BalanceSnapshotStatus::Ok => UsageBalanceStatus::Ok,
629 BalanceSnapshotStatus::Exhausted => UsageBalanceStatus::Exhausted,
630 BalanceSnapshotStatus::Stale => UsageBalanceStatus::Stale,
631 BalanceSnapshotStatus::Error => UsageBalanceStatus::Error,
632 BalanceSnapshotStatus::Unknown => UsageBalanceStatus::Unknown,
633 }
634}
635
636fn summarize_snapshot(
637 snapshot: &ProviderBalanceSnapshot,
638 now_ms: u64,
639) -> UsageBalanceSnapshotSummary {
640 UsageBalanceSnapshotSummary {
641 provider_id: snapshot.provider_id.clone(),
642 station_name: snapshot.station_name.clone(),
643 upstream_index: snapshot.upstream_index,
644 source: snapshot.source.clone(),
645 status: classify_snapshot(snapshot, now_ms),
646 amount_summary: snapshot.amount_summary(),
647 fetched_at_ms: snapshot.fetched_at_ms,
648 stale_after_ms: snapshot.stale_after_ms,
649 age_ms: (now_ms >= snapshot.fetched_at_ms).then(|| now_ms - snapshot.fetched_at_ms),
650 error: snapshot.error.clone(),
651 exhaustion_affects_routing: snapshot.exhaustion_affects_routing,
652 routing_exhausted: snapshot.exhaustion_affects_routing
653 && snapshot.status_at(now_ms) == BalanceSnapshotStatus::Exhausted,
654 routing_ignored_exhaustion: !snapshot.exhaustion_affects_routing
655 && snapshot.status_at(now_ms) == BalanceSnapshotStatus::Exhausted,
656 }
657}
658
659fn primary_balance_snapshot<'a>(
660 snapshots: &'a [&ProviderBalanceSnapshot],
661 now_ms: u64,
662) -> Option<&'a ProviderBalanceSnapshot> {
663 snapshots.iter().copied().min_by(|left, right| {
664 snapshot_display_rank(classify_snapshot(left, now_ms))
665 .cmp(&snapshot_display_rank(classify_snapshot(right, now_ms)))
666 .then_with(|| left.upstream_index.cmp(&right.upstream_index))
667 .then_with(|| right.fetched_at_ms.cmp(&left.fetched_at_ms))
668 })
669}
670
671fn snapshot_display_rank(status: UsageBalanceStatus) -> u8 {
672 match status {
673 UsageBalanceStatus::Ok | UsageBalanceStatus::Unlimited => 0,
674 UsageBalanceStatus::Stale => 1,
675 UsageBalanceStatus::Unknown | UsageBalanceStatus::Error => 2,
676 UsageBalanceStatus::Exhausted => 3,
677 }
678}
679
680fn latest_balance_error(snapshots: &[&ProviderBalanceSnapshot]) -> Option<String> {
681 snapshots
682 .iter()
683 .filter_map(|snapshot| {
684 snapshot
685 .error
686 .as_deref()
687 .map(str::trim)
688 .filter(|value| !value.is_empty())
689 .map(|error| (snapshot.fetched_at_ms, error.to_string()))
690 })
691 .max_by_key(|(fetched_at_ms, _)| *fetched_at_ms)
692 .map(|(_, error)| error)
693}
694
695fn per_mille(num: u64, den: u64) -> Option<u16> {
696 (den > 0).then(|| ((num.saturating_mul(1000) / den).min(1000)) as u16)
697}
698
699fn output_tokens_per_second(bucket: &UsageBucket) -> Option<u64> {
700 let output = bucket.usage.output_tokens.max(0) as u64;
701 if output == 0 || bucket.generation_ms_total == 0 {
702 return None;
703 }
704 Some(output.saturating_mul(1000) / bucket.generation_ms_total)
705}
706
707fn sort_provider_rows(rows: &mut [UsageBalanceProviderRow]) {
708 rows.sort_by(|left, right| {
709 provider_selected_rank(right)
710 .cmp(&provider_selected_rank(left))
711 .then_with(|| {
712 provider_attention_rank(left.balance_status)
713 .cmp(&provider_attention_rank(right.balance_status))
714 })
715 .then_with(|| provider_cost_sort_value(right).cmp(&provider_cost_sort_value(left)))
716 .then_with(|| right.usage.requests_total.cmp(&left.usage.requests_total))
717 .then_with(|| left.provider_id.cmp(&right.provider_id))
718 });
719}
720
721fn provider_selected_rank(row: &UsageBalanceProviderRow) -> u8 {
722 u8::from(row.routing.selected)
723}
724
725fn provider_attention_rank(status: UsageBalanceStatus) -> u8 {
726 match status {
727 UsageBalanceStatus::Error => 0,
728 UsageBalanceStatus::Exhausted => 1,
729 UsageBalanceStatus::Stale => 2,
730 UsageBalanceStatus::Unknown => 3,
731 UsageBalanceStatus::Unlimited => 4,
732 UsageBalanceStatus::Ok => 5,
733 }
734}
735
736fn provider_cost_sort_value(row: &UsageBalanceProviderRow) -> i128 {
737 row.cost_total_usd
738 .as_deref()
739 .and_then(UsdAmount::from_decimal_str)
740 .map(UsdAmount::femto_usd)
741 .unwrap_or(0)
742}
743
744fn sort_endpoint_rows(rows: &mut [UsageBalanceEndpointRow]) {
745 rows.sort_by(|left, right| {
746 u8::from(right.route_selected)
747 .cmp(&u8::from(left.route_selected))
748 .then_with(|| {
749 provider_attention_rank(left.balance_status)
750 .cmp(&provider_attention_rank(right.balance_status))
751 })
752 .then_with(|| right.usage.requests_total.cmp(&left.usage.requests_total))
753 .then_with(|| left.provider_id.cmp(&right.provider_id))
754 .then_with(|| left.endpoint_id.cmp(&right.endpoint_id))
755 });
756}
757
758#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
759struct EndpointKey {
760 provider_id: String,
761 endpoint_id: String,
762}
763
764#[derive(Debug, Clone)]
765struct EndpointAccum {
766 key: EndpointKey,
767 station_name: Option<String>,
768 upstream_index: Option<usize>,
769 base_url: Option<String>,
770 usage: UsageBucket,
771 balance: Option<UsageBalanceSnapshotSummary>,
772 route_selected: bool,
773 route_skip_reasons: BTreeSet<String>,
774}
775
776impl EndpointAccum {
777 fn new(key: EndpointKey) -> Self {
778 Self {
779 key,
780 station_name: None,
781 upstream_index: None,
782 base_url: None,
783 usage: UsageBucket::default(),
784 balance: None,
785 route_selected: false,
786 route_skip_reasons: BTreeSet::new(),
787 }
788 }
789
790 fn finish(self) -> UsageBalanceEndpointRow {
791 let balance_status = self
792 .balance
793 .as_ref()
794 .map(|balance| balance.status)
795 .unwrap_or(UsageBalanceStatus::Unknown);
796 UsageBalanceEndpointRow {
797 provider_id: self.key.provider_id,
798 endpoint_id: self.key.endpoint_id,
799 station_name: self.station_name,
800 upstream_index: self.upstream_index,
801 base_url: self.base_url,
802 usage: self.usage,
803 balance_status,
804 balance: self.balance,
805 route_selected: self.route_selected,
806 route_skip_reasons: self.route_skip_reasons.into_iter().collect(),
807 }
808 }
809}
810
811#[derive(Debug, Clone)]
812struct RouteCandidateView {
813 provider_id: String,
814 endpoint_id: String,
815 base_url: String,
816 selected: bool,
817 skip_reasons: Vec<String>,
818 compatibility: Option<(String, usize)>,
819}
820
821#[derive(Debug, Clone, Default)]
822struct RouteIndex {
823 candidates: BTreeMap<String, RouteCandidateView>,
824 provider_impacts: BTreeMap<String, UsageBalanceRouteImpact>,
825 endpoint_by_provider_base_url: BTreeMap<(String, String), String>,
826 endpoint_by_provider_station_upstream: BTreeMap<(String, String, usize), String>,
827}
828
829impl RouteIndex {
830 fn from_explain(explain: Option<&RoutingExplainResponse>) -> Self {
831 let mut out = Self::default();
832 let Some(explain) = explain else {
833 return out;
834 };
835
836 for candidate in &explain.candidates {
837 out.record_candidate(candidate);
838 }
839 for impact in out.provider_impacts.values_mut() {
840 impact.skip_reasons.sort();
841 impact.skip_reasons.dedup();
842 impact.route_paths.sort();
843 impact.route_paths.dedup();
844 }
845 out
846 }
847
848 fn record_candidate(&mut self, candidate: &RoutingExplainCandidate) {
849 let skip_reasons = candidate
850 .skip_reasons
851 .iter()
852 .map(|reason| reason.code().to_string())
853 .collect::<Vec<_>>();
854 let compatibility = candidate.compatibility.as_ref().map(|compatibility| {
855 (
856 compatibility.station_name.clone(),
857 compatibility.upstream_index,
858 )
859 });
860 let view = RouteCandidateView {
861 provider_id: candidate.provider_id.clone(),
862 endpoint_id: candidate.endpoint_id.clone(),
863 base_url: candidate.upstream_base_url.clone(),
864 selected: candidate.selected,
865 skip_reasons: skip_reasons.clone(),
866 compatibility: compatibility.clone(),
867 };
868
869 self.endpoint_by_provider_base_url.insert(
870 (
871 candidate.provider_id.clone(),
872 candidate.upstream_base_url.clone(),
873 ),
874 candidate.endpoint_id.clone(),
875 );
876 if let Some((station_name, upstream_index)) = compatibility {
877 self.endpoint_by_provider_station_upstream.insert(
878 (candidate.provider_id.clone(), station_name, upstream_index),
879 candidate.endpoint_id.clone(),
880 );
881 }
882
883 let impact = self
884 .provider_impacts
885 .entry(candidate.provider_id.clone())
886 .or_insert_with(|| UsageBalanceRouteImpact {
887 provider_id: candidate.provider_id.clone(),
888 ..UsageBalanceRouteImpact::default()
889 });
890 impact.candidate_count += 1;
891 impact.route_paths.push(candidate.route_path.clone());
892 impact.skip_reasons.extend(skip_reasons);
893 if candidate.selected {
894 impact.selected = true;
895 impact.selected_endpoint_id = Some(candidate.endpoint_id.clone());
896 impact.selected_provider_endpoint_key = Some(candidate.provider_endpoint_key.clone());
897 }
898
899 self.candidates
900 .insert(candidate.provider_endpoint_key.clone(), view);
901 }
902}
903
904fn endpoint_id_from_base_url(base_url: &str) -> String {
905 let trimmed = base_url.trim();
906 let after_scheme = trimmed
907 .split_once("://")
908 .map(|(_, rest)| rest)
909 .unwrap_or(trimmed);
910 after_scheme
911 .split('/')
912 .next()
913 .map(str::trim)
914 .filter(|value| !value.is_empty())
915 .unwrap_or("endpoint")
916 .to_string()
917}
918
919fn non_empty(value: String) -> Option<String> {
920 (!value.trim().is_empty()).then_some(value)
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use crate::pricing::CostBreakdown;
927 use crate::routing_explain::{
928 RoutingExplainCandidate, RoutingExplainResponse, RoutingExplainSkipReason,
929 };
930 use crate::usage::UsageMetrics;
931
932 fn build_view(
933 usage_rollup: &UsageRollupView,
934 provider_balances: &HashMap<String, Vec<ProviderBalanceSnapshot>>,
935 recent: &[FinishedRequest],
936 routing_explain: Option<&RoutingExplainResponse>,
937 ) -> UsageBalanceView {
938 UsageBalanceView::build(UsageBalanceBuildInput {
939 service_name: "codex",
940 window_days: 7,
941 generated_at_ms: 1_000,
942 usage_rollup,
943 provider_balances,
944 recent,
945 routing_explain,
946 refresh: UsageBalanceRefreshInput::default(),
947 })
948 }
949
950 #[test]
951 fn balance_status_counts_keep_unknown_stale_exhausted_error_and_unlimited_distinct() {
952 let mut balances = HashMap::new();
953 balances.insert(
954 "station".to_string(),
955 vec![
956 ProviderBalanceSnapshot {
957 provider_id: "ok".to_string(),
958 exhausted: Some(false),
959 total_balance_usd: Some("3".to_string()),
960 fetched_at_ms: 900,
961 ..ProviderBalanceSnapshot::default()
962 },
963 ProviderBalanceSnapshot {
964 provider_id: "unlimited".to_string(),
965 exhausted: Some(false),
966 unlimited_quota: Some(true),
967 fetched_at_ms: 900,
968 ..ProviderBalanceSnapshot::default()
969 },
970 ProviderBalanceSnapshot {
971 provider_id: "stale".to_string(),
972 exhausted: Some(false),
973 stale_after_ms: Some(950),
974 fetched_at_ms: 900,
975 ..ProviderBalanceSnapshot::default()
976 },
977 ProviderBalanceSnapshot {
978 provider_id: "exhausted".to_string(),
979 exhausted: Some(true),
980 fetched_at_ms: 900,
981 ..ProviderBalanceSnapshot::default()
982 },
983 ProviderBalanceSnapshot {
984 provider_id: "error".to_string(),
985 error: Some("lookup failed".to_string()),
986 fetched_at_ms: 900,
987 ..ProviderBalanceSnapshot::default()
988 },
989 ProviderBalanceSnapshot {
990 provider_id: "unknown".to_string(),
991 fetched_at_ms: 900,
992 ..ProviderBalanceSnapshot::default()
993 },
994 ],
995 );
996
997 let view = build_view(&UsageRollupView::default(), &balances, &[], None);
998
999 assert_eq!(view.totals.balance_status_counts.ok, 1);
1000 assert_eq!(view.totals.balance_status_counts.unlimited, 1);
1001 assert_eq!(view.totals.balance_status_counts.stale, 1);
1002 assert_eq!(view.totals.balance_status_counts.exhausted, 1);
1003 assert_eq!(view.totals.balance_status_counts.error, 1);
1004 assert_eq!(view.totals.balance_status_counts.unknown, 1);
1005 assert_eq!(
1006 view.provider_rows
1007 .iter()
1008 .find(|row| row.provider_id == "unlimited")
1009 .map(|row| row.balance_status),
1010 Some(UsageBalanceStatus::Unlimited)
1011 );
1012 assert_eq!(
1013 view.provider_rows
1014 .iter()
1015 .find(|row| row.provider_id == "unknown")
1016 .map(|row| row.balance_status),
1017 Some(UsageBalanceStatus::Unknown)
1018 );
1019 }
1020
1021 #[test]
1022 fn provider_rows_prefer_route_selection_then_attention_and_usage() {
1023 let rollup = UsageRollupView {
1024 by_provider: vec![
1025 (
1026 "cheap".to_string(),
1027 UsageBucket {
1028 requests_total: 2,
1029 ..UsageBucket::default()
1030 },
1031 ),
1032 (
1033 "selected".to_string(),
1034 UsageBucket {
1035 requests_total: 1,
1036 ..UsageBucket::default()
1037 },
1038 ),
1039 ],
1040 ..UsageRollupView::default()
1041 };
1042 let explain = RoutingExplainResponse {
1043 api_version: 1,
1044 service_name: "codex".to_string(),
1045 runtime_loaded_at_ms: None,
1046 request_model: None,
1047 session_id: None,
1048 request_context: Default::default(),
1049 selected_route: None,
1050 candidates: vec![RoutingExplainCandidate {
1051 provider_id: "selected".to_string(),
1052 provider_alias: None,
1053 endpoint_id: "default".to_string(),
1054 provider_endpoint_key: "codex:selected:default".to_string(),
1055 route_path: vec!["main".to_string(), "selected".to_string()],
1056 preference_group: 0,
1057 compatibility: None,
1058 upstream_base_url: "https://selected.example/v1".to_string(),
1059 capacity: Default::default(),
1060 availability: crate::routing_explain::RoutingExplainAvailability {
1061 available: true,
1062 runtime_available: true,
1063 routable_except_usage: true,
1064 ..Default::default()
1065 },
1066 selected: true,
1067 skip_reasons: Vec::new(),
1068 }],
1069 affinity_policy: "off".to_string(),
1070 affinity: None,
1071 conditional_routes: Vec::new(),
1072 };
1073
1074 let view = build_view(&rollup, &HashMap::new(), &[], Some(&explain));
1075
1076 assert_eq!(view.provider_rows[0].provider_id, "selected");
1077 assert!(view.provider_rows[0].routing.selected);
1078 }
1079
1080 #[test]
1081 fn endpoint_rows_merge_recent_usage_balance_and_route_skip_reasons() {
1082 let recent = vec![FinishedRequest {
1083 id: 1,
1084 trace_id: None,
1085 session_id: None,
1086 session_identity_source: None,
1087 client_name: None,
1088 client_addr: None,
1089 cwd: None,
1090 model: Some("gpt-test".to_string()),
1091 reasoning_effort: None,
1092 service_tier: None,
1093 station_name: Some("station-a".to_string()),
1094 provider_id: Some("right".to_string()),
1095 upstream_base_url: Some("https://right.example/v1".to_string()),
1096 route_decision: None,
1097 usage: Some(UsageMetrics {
1098 input_tokens: 10,
1099 output_tokens: 20,
1100 total_tokens: 30,
1101 ..UsageMetrics::default()
1102 }),
1103 cost: CostBreakdown::unknown(),
1104 retry: None,
1105 provider_signals: Vec::new(),
1106 policy_actions: Vec::new(),
1107 observability: Default::default(),
1108 service: "codex".to_string(),
1109 method: "POST".to_string(),
1110 path: "/v1/responses".to_string(),
1111 status_code: 429,
1112 duration_ms: 120,
1113 ttfb_ms: Some(20),
1114 streaming: false,
1115 ended_at_ms: 950,
1116 }];
1117 let mut balances = HashMap::new();
1118 balances.insert(
1119 "station-a".to_string(),
1120 vec![ProviderBalanceSnapshot {
1121 provider_id: "right".to_string(),
1122 station_name: Some("station-a".to_string()),
1123 upstream_index: Some(0),
1124 exhausted: Some(true),
1125 fetched_at_ms: 940,
1126 ..ProviderBalanceSnapshot::default()
1127 }],
1128 );
1129 let explain = RoutingExplainResponse {
1130 api_version: 1,
1131 service_name: "codex".to_string(),
1132 runtime_loaded_at_ms: None,
1133 request_model: None,
1134 session_id: None,
1135 request_context: Default::default(),
1136 selected_route: None,
1137 candidates: vec![RoutingExplainCandidate {
1138 provider_id: "right".to_string(),
1139 provider_alias: None,
1140 endpoint_id: "default".to_string(),
1141 provider_endpoint_key: "codex:right:default".to_string(),
1142 route_path: vec!["main".to_string(), "right".to_string()],
1143 preference_group: 0,
1144 compatibility: Some(crate::routing_explain::RoutingExplainCompatibility {
1145 station_name: "station-a".to_string(),
1146 upstream_index: 0,
1147 }),
1148 upstream_base_url: "https://right.example/v1".to_string(),
1149 capacity: Default::default(),
1150 availability: crate::routing_explain::RoutingExplainAvailability {
1151 available: false,
1152 runtime_available: false,
1153 routable_except_usage: true,
1154 usage_exhausted: true,
1155 dominant_reason: Some(RoutingExplainSkipReason::UsageExhausted),
1156 ..Default::default()
1157 },
1158 selected: false,
1159 skip_reasons: vec![RoutingExplainSkipReason::UsageExhausted],
1160 }],
1161 affinity_policy: "off".to_string(),
1162 affinity: None,
1163 conditional_routes: Vec::new(),
1164 };
1165
1166 let view = build_view(
1167 &UsageRollupView::default(),
1168 &balances,
1169 &recent,
1170 Some(&explain),
1171 );
1172 let endpoint = view
1173 .endpoint_rows
1174 .iter()
1175 .find(|row| row.provider_id == "right")
1176 .expect("right endpoint");
1177
1178 assert_eq!(endpoint.endpoint_id, "default");
1179 assert_eq!(endpoint.usage.requests_total, 1);
1180 assert_eq!(endpoint.usage.requests_error, 1);
1181 assert_eq!(endpoint.balance_status, UsageBalanceStatus::Exhausted);
1182 assert_eq!(endpoint.route_skip_reasons, vec!["usage_exhausted"]);
1183 }
1184}