use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::profile_cache::{load_profile_cache, write_profile_cache};
pub(crate) const THROUGHPUT_CACHE_FILE: &str = "throughput_cache.json";
const MAX_SAMPLES: usize = 24;
const MIN_SAMPLES_FOR_DEGRADED: usize = 3;
const DEGRADED_FRACTION: f64 = 0.5;
const RATE_LIMIT_RECENT_SECS: i64 = 15 * 60;
const RECENT_WINDOW: usize = 5;
#[derive(Debug, Default, Serialize, Deserialize)]
struct ThroughputStore {
#[serde(default)]
models: BTreeMap<String, ModelThroughput>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct ModelThroughput {
#[serde(default)]
samples: Vec<Sample>,
#[serde(default)]
best_tok_s: f64,
#[serde(default)]
last_rate_limited_at: Option<i64>,
#[serde(default)]
last_retry_after_s: Option<u64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
struct Sample {
at: i64,
tok_s: f64,
}
#[derive(Debug, Clone)]
pub(crate) struct ModelSummary {
pub(crate) model: String,
pub(crate) tok_s: f64,
pub(crate) samples: usize,
pub(crate) degraded: bool,
pub(crate) rate_limited_recent: bool,
pub(crate) retry_after_s: Option<u64>,
}
fn model_key(model: Option<&str>) -> String {
model.unwrap_or("default").to_string()
}
pub(crate) fn record_success(
profile: &str,
model: Option<&str>,
output_tokens: u64,
duration_ms: u64,
now: i64,
) {
if output_tokens == 0 || duration_ms == 0 {
return;
}
let tok_s = output_tokens as f64 / (duration_ms as f64 / 1000.0);
let mut store: ThroughputStore =
load_profile_cache(profile, THROUGHPUT_CACHE_FILE).unwrap_or_default();
let entry = store.models.entry(model_key(model)).or_default();
entry.samples.push(Sample { at: now, tok_s });
let overflow = entry.samples.len().saturating_sub(MAX_SAMPLES);
if overflow > 0 {
entry.samples.drain(0..overflow);
}
if tok_s > entry.best_tok_s {
entry.best_tok_s = tok_s;
}
write_profile_cache(profile, THROUGHPUT_CACHE_FILE, &store);
}
pub(crate) fn record_rate_limit(
profile: &str,
model: Option<&str>,
retry_after_s: Option<u64>,
now: i64,
) {
let mut store: ThroughputStore =
load_profile_cache(profile, THROUGHPUT_CACHE_FILE).unwrap_or_default();
let entry = store.models.entry(model_key(model)).or_default();
entry.last_rate_limited_at = Some(now);
entry.last_retry_after_s = retry_after_s;
write_profile_cache(profile, THROUGHPUT_CACHE_FILE, &store);
}
pub(crate) fn summary(profile: &str, now: i64) -> Vec<ModelSummary> {
let Some(store) = load_profile_cache::<ThroughputStore>(profile, THROUGHPUT_CACHE_FILE) else {
return Vec::new();
};
let mut rows: Vec<(i64, ModelSummary)> = store
.models
.into_iter()
.map(|(model, m)| {
let recent = recent_avg(&m.samples);
let degraded = m.samples.len() >= MIN_SAMPLES_FOR_DEGRADED
&& m.best_tok_s > 0.0
&& recent < m.best_tok_s * DEGRADED_FRACTION;
let rate_limited_recent = m
.last_rate_limited_at
.is_some_and(|t| now - t <= RATE_LIMIT_RECENT_SECS);
let last_at = m
.samples
.last()
.map(|s| s.at)
.or(m.last_rate_limited_at)
.unwrap_or(0);
(
last_at,
ModelSummary {
model,
tok_s: recent,
samples: m.samples.len(),
degraded,
rate_limited_recent,
retry_after_s: rate_limited_recent
.then_some(m.last_retry_after_s)
.flatten(),
},
)
})
.collect();
rows.sort_by_key(|(at, _)| std::cmp::Reverse(*at));
rows.into_iter().map(|(_, s)| s).collect()
}
fn recent_avg(samples: &[Sample]) -> f64 {
let take = samples.len().min(RECENT_WINDOW);
if take == 0 {
return 0.0;
}
let slice = &samples[samples.len() - take..];
let mut weighted = 0.0;
let mut weight = 0.0;
for (i, s) in slice.iter().enumerate() {
let w = (i + 1) as f64; weighted += s.tok_s * w;
weight += w;
}
weighted / weight
}
#[cfg(test)]
#[path = "../tests/inline/throughput.rs"]
mod tests;