use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use ferrox_api::RecentRequest;
use crate::attribution::Attribution;
pub(crate) const RING_CAPACITY: usize = 200;
#[derive(Default)]
pub(crate) struct Stats {
recent: Mutex<VecDeque<RecentRequest>>,
tokens_prompt_total: AtomicU64,
tokens_generated_total: AtomicU64,
}
impl Stats {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn record(&self, entry: RecentRequest) {
self.tokens_prompt_total
.fetch_add(entry.prompt_tokens as u64, Ordering::Relaxed);
self.tokens_generated_total
.fetch_add(entry.completion_tokens as u64, Ordering::Relaxed);
let mut ring = self.recent.lock().unwrap_or_else(|p| p.into_inner());
if ring.len() == RING_CAPACITY {
ring.pop_front();
}
ring.push_back(entry);
}
pub(crate) fn recent(&self) -> Vec<RecentRequest> {
self.recent
.lock()
.unwrap_or_else(|p| p.into_inner())
.iter()
.cloned()
.collect()
}
pub(crate) fn tokens_prompt_total(&self) -> u64 {
self.tokens_prompt_total.load(Ordering::Relaxed)
}
pub(crate) fn tokens_generated_total(&self) -> u64 {
self.tokens_generated_total.load(Ordering::Relaxed)
}
}
pub(crate) struct Record<'a> {
pub(crate) request_id: &'a str,
pub(crate) route: &'a str,
pub(crate) model: Option<String>,
pub(crate) status: u16,
pub(crate) stream: bool,
pub(crate) duration_ms: u64,
pub(crate) usage: Option<&'a ferrox_api::Usage>,
pub(crate) attribution: &'a Attribution,
}
pub(crate) fn entry(record: Record<'_>) -> RecentRequest {
let usage = record.usage;
RecentRequest {
request_id: record.request_id.to_string(),
at_ms: crate::tasks::now_ms(),
route: record.route.to_string(),
model: record.model,
status: record.status,
prompt_tokens: usage.map(|u| u.prompt_tokens).unwrap_or(0),
completion_tokens: usage.map(|u| u.completion_tokens).unwrap_or(0),
ttft_ms: usage.and_then(|u| u.time_to_first_token_ms),
duration_ms: record.duration_ms,
decode_ms: usage.and_then(|u| u.generation_duration_ms),
stream: record.stream,
acceptance_length: usage.and_then(|u| u.acceptance_length),
draft_accept_rate_per_position: usage
.and_then(|u| u.draft_accept_rate_per_position.clone()),
via_api_key: record.attribution.via_api_key.clone(),
client: record.attribution.client.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn usage() -> ferrox_api::Usage {
ferrox_api::Usage::new(100, 10)
.with_timings(1.0, 0.1)
.with_ttft(0.9)
}
#[test]
fn an_entry_keeps_the_two_durations_apart() {
let e = entry(Record {
request_id: "chatcmpl-1",
route: ferrox_api::routes::V1_CHAT_COMPLETIONS,
model: Some("served-model".to_string()),
status: 200,
stream: true,
duration_ms: 1_100,
usage: Some(&usage()),
attribution: &Attribution::default(),
});
assert_eq!(e.duration_ms, 1_100);
assert_eq!(e.decode_ms, Some(100.0));
assert_eq!(e.ttft_ms, Some(900.0));
assert_eq!(e.prompt_tokens, 100);
assert_eq!(e.completion_tokens, 10);
}
#[test]
fn an_untimed_request_reports_null_rather_than_reusing_the_total() {
let e = entry(Record {
request_id: "chatcmpl-2",
route: "/v1/completions",
model: None,
status: 200,
stream: false,
duration_ms: 42,
usage: None,
attribution: &Attribution::default(),
});
assert_eq!(e.decode_ms, None);
assert_eq!(e.ttft_ms, None);
assert_eq!(e.duration_ms, 42);
assert_eq!(e.prompt_tokens, 0);
}
#[test]
fn speculation_metrics_reach_the_admin_ring() {
let usage = ferrox_api::Usage::new(100, 12)
.with_timings(1.0, 0.1)
.with_speculation(5, 7, 10, vec![0.95, 0.7, 0.4]);
let e = entry(Record {
request_id: "chatcmpl-3",
route: ferrox_api::routes::V1_CHAT_COMPLETIONS,
model: None,
status: 200,
stream: false,
duration_ms: 1_100,
usage: Some(&usage),
attribution: &Attribution::default(),
});
assert_eq!(e.acceptance_length, Some(2.4));
assert_eq!(
e.draft_accept_rate_per_position,
Some(vec![0.95, 0.7, 0.4]),
"the per-position curve is the only way suffix decay shows up"
);
}
#[test]
fn a_non_speculative_request_leaves_the_acceptance_columns_empty() {
let e = entry(Record {
request_id: "chatcmpl-4",
route: ferrox_api::routes::V1_CHAT_COMPLETIONS,
model: None,
status: 200,
stream: false,
duration_ms: 42,
usage: Some(&usage()),
attribution: &Attribution::default(),
});
assert_eq!(e.acceptance_length, None);
assert_eq!(e.draft_accept_rate_per_position, None);
}
#[test]
fn token_totals_accumulate_across_requests() {
let stats = Stats::new();
for _ in 0..3 {
stats.record(entry(Record {
request_id: "id",
route: "/r",
model: None,
status: 200,
stream: false,
duration_ms: 1,
usage: Some(&usage()),
attribution: &Attribution::default(),
}));
}
assert_eq!(stats.tokens_prompt_total(), 300);
assert_eq!(stats.tokens_generated_total(), 30);
}
#[test]
fn the_ring_keeps_the_newest_entries_and_drops_the_oldest() {
let stats = Stats::new();
for i in 0..RING_CAPACITY + 25 {
stats.record(entry(Record {
request_id: &format!("id-{i}"),
route: "/r",
model: None,
status: 200,
stream: false,
duration_ms: 1,
usage: None,
attribution: &Attribution::default(),
}));
}
let recent = stats.recent();
assert_eq!(recent.len(), RING_CAPACITY);
assert_eq!(recent[0].request_id, "id-25");
assert_eq!(
recent[RING_CAPACITY - 1].request_id,
format!("id-{}", RING_CAPACITY + 24)
);
}
#[test]
fn an_error_response_is_recorded_with_its_status() {
let stats = Stats::new();
stats.record(entry(Record {
request_id: "id-e",
route: "/v1/chat/completions",
model: None,
status: 503,
stream: false,
duration_ms: 3,
usage: None,
attribution: &Attribution::default(),
}));
assert_eq!(stats.recent()[0].status, 503);
}
#[test]
fn an_entry_carries_the_attribution_it_was_given() {
let attribution = Attribution {
via_api_key: Some("key-deadbeef".to_string()),
client: Some("ferrox-studio".to_string()),
};
let e = entry(Record {
request_id: "id",
route: "/r",
model: None,
status: 200,
stream: false,
duration_ms: 1,
usage: None,
attribution: &attribution,
});
assert_eq!(e.via_api_key.as_deref(), Some("key-deadbeef"));
assert_eq!(e.client.as_deref(), Some("ferrox-studio"));
let anonymous = entry(Record {
request_id: "id",
route: "/r",
model: None,
status: 200,
stream: false,
duration_ms: 1,
usage: None,
attribution: &Attribution::default(),
});
assert_eq!(anonymous.via_api_key, None);
assert_eq!(anonymous.client, None);
}
}