#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Settings {
pub max_prompt_tokens: u32,
pub max_completion_tokens: u32,
pub max_sources_bytes: u32,
pub timeout_ms: u32,
pub daily_cost_cap_usd: Option<f64>,
}
impl Default for Settings {
fn default() -> Self {
Self {
max_prompt_tokens: 8192,
max_completion_tokens: 1024,
max_sources_bytes: 262_144,
timeout_ms: 30_000,
daily_cost_cap_usd: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub sources_bytes: u32,
pub estimated_cost_usd: f64,
pub elapsed_ms: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct DailyState {
pub spent_usd: f64,
pub day_epoch_secs: i64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Now {
pub epoch_secs: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LimitKind {
PromptTokens,
CompletionTokens,
SourcesBytes,
Timeout,
DailyCostCap,
}
impl LimitKind {
pub fn field_name(self) -> &'static str {
match self {
LimitKind::PromptTokens => "max_prompt_tokens",
LimitKind::CompletionTokens => "max_completion_tokens",
LimitKind::SourcesBytes => "max_sources_bytes",
LimitKind::Timeout => "timeout_ms",
LimitKind::DailyCostCap => "daily_cost_cap_usd",
}
}
pub fn http_status(self) -> u16 {
match self {
LimitKind::Timeout => 504,
_ => 413,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Decision {
Allow,
Reject {
limit: LimitKind,
http_status: u16,
detail: String,
},
}
pub fn evaluate(usage: &Usage, daily: &DailyState, settings: &Settings, now: Now) -> Decision {
if usage.prompt_tokens > settings.max_prompt_tokens {
return reject(
LimitKind::PromptTokens,
format!(
"prompt {} tokens exceeds max_prompt_tokens={}",
usage.prompt_tokens, settings.max_prompt_tokens
),
);
}
if usage.sources_bytes > settings.max_sources_bytes {
return reject(
LimitKind::SourcesBytes,
format!(
"sources payload {} bytes exceeds max_sources_bytes={}",
usage.sources_bytes, settings.max_sources_bytes
),
);
}
if usage.completion_tokens > settings.max_completion_tokens {
return reject(
LimitKind::CompletionTokens,
format!(
"completion {} tokens exceeds max_completion_tokens={}",
usage.completion_tokens, settings.max_completion_tokens
),
);
}
if usage.elapsed_ms > settings.timeout_ms {
return reject(
LimitKind::Timeout,
format!(
"elapsed {}ms exceeds timeout_ms={}",
usage.elapsed_ms, settings.timeout_ms
),
);
}
if let Some(cap) = settings.daily_cost_cap_usd {
let effective_spent = if same_utc_day(daily.day_epoch_secs, now.epoch_secs) {
daily.spent_usd
} else {
0.0
};
let projected = effective_spent + usage.estimated_cost_usd;
if projected > cap {
return reject(
LimitKind::DailyCostCap,
format!("projected spend ${projected:.6} exceeds daily_cost_cap_usd=${cap:.6}"),
);
}
}
Decision::Allow
}
fn reject(limit: LimitKind, detail: String) -> Decision {
Decision::Reject {
limit,
http_status: limit.http_status(),
detail,
}
}
const SECS_PER_DAY: i64 = 86_400;
pub fn utc_day_start_epoch_secs(epoch_secs: i64) -> i64 {
epoch_secs.div_euclid(SECS_PER_DAY) * SECS_PER_DAY
}
fn same_utc_day(a: i64, b: i64) -> bool {
utc_day_start_epoch_secs(a) == utc_day_start_epoch_secs(b)
}
#[cfg(test)]
mod tests {
use super::*;
fn settings() -> Settings {
Settings::default()
}
fn now_at(epoch_secs: i64) -> Now {
Now { epoch_secs }
}
fn fresh_state() -> DailyState {
DailyState::default()
}
fn ok_usage() -> Usage {
Usage::default()
}
#[test]
fn at_limit_is_allowed() {
let s = settings();
let u = Usage {
prompt_tokens: s.max_prompt_tokens,
completion_tokens: s.max_completion_tokens,
sources_bytes: s.max_sources_bytes,
elapsed_ms: s.timeout_ms,
..ok_usage()
};
assert_eq!(evaluate(&u, &fresh_state(), &s, now_at(0)), Decision::Allow);
}
#[test]
fn one_over_prompt_tokens_rejects_413() {
let s = settings();
let u = Usage {
prompt_tokens: s.max_prompt_tokens + 1,
..ok_usage()
};
let d = evaluate(&u, &fresh_state(), &s, now_at(0));
match d {
Decision::Reject {
limit,
http_status,
detail,
} => {
assert_eq!(limit, LimitKind::PromptTokens);
assert_eq!(http_status, 413);
assert!(detail.contains("max_prompt_tokens"));
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn over_sources_bytes_rejects_413() {
let s = settings();
let u = Usage {
sources_bytes: s.max_sources_bytes + 1,
..ok_usage()
};
let d = evaluate(&u, &fresh_state(), &s, now_at(0));
match d {
Decision::Reject {
limit, http_status, ..
} => {
assert_eq!(limit, LimitKind::SourcesBytes);
assert_eq!(http_status, 413);
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn over_completion_tokens_rejects_413() {
let s = settings();
let u = Usage {
completion_tokens: s.max_completion_tokens + 1,
..ok_usage()
};
let d = evaluate(&u, &fresh_state(), &s, now_at(0));
match d {
Decision::Reject {
limit, http_status, ..
} => {
assert_eq!(limit, LimitKind::CompletionTokens);
assert_eq!(http_status, 413);
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn over_timeout_rejects_504() {
let s = settings();
let u = Usage {
elapsed_ms: s.timeout_ms + 1,
..ok_usage()
};
let d = evaluate(&u, &fresh_state(), &s, now_at(0));
match d {
Decision::Reject {
limit, http_status, ..
} => {
assert_eq!(limit, LimitKind::Timeout);
assert_eq!(http_status, 504);
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn daily_cap_none_means_unlimited() {
let s = Settings {
daily_cost_cap_usd: None,
..settings()
};
let u = Usage {
estimated_cost_usd: 9_999.0,
..ok_usage()
};
let daily = DailyState {
spent_usd: 1_000_000.0,
day_epoch_secs: 0,
};
assert_eq!(evaluate(&u, &daily, &s, now_at(0)), Decision::Allow);
}
#[test]
fn daily_cap_blocks_when_projected_exceeds() {
let s = Settings {
daily_cost_cap_usd: Some(10.0),
..settings()
};
let u = Usage {
estimated_cost_usd: 2.5,
..ok_usage()
};
let daily = DailyState {
spent_usd: 8.0,
day_epoch_secs: 0,
};
let d = evaluate(&u, &daily, &s, now_at(0));
match d {
Decision::Reject {
limit, http_status, ..
} => {
assert_eq!(limit, LimitKind::DailyCostCap);
assert_eq!(http_status, 413);
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn daily_cap_allows_at_exact_cap() {
let s = Settings {
daily_cost_cap_usd: Some(10.0),
..settings()
};
let u = Usage {
estimated_cost_usd: 2.0,
..ok_usage()
};
let daily = DailyState {
spent_usd: 8.0,
day_epoch_secs: 0,
};
assert_eq!(evaluate(&u, &daily, &s, now_at(0)), Decision::Allow);
}
#[test]
fn daily_cap_resets_at_utc_midnight() {
let s = Settings {
daily_cost_cap_usd: Some(10.0),
..settings()
};
let u = Usage {
estimated_cost_usd: 9.0,
..ok_usage()
};
let day_zero_start = 0;
let day_one_start_plus_1 = SECS_PER_DAY + 1;
let daily = DailyState {
spent_usd: 100.0,
day_epoch_secs: day_zero_start,
};
assert_eq!(
evaluate(&u, &daily, &s, now_at(day_one_start_plus_1)),
Decision::Allow,
"stale spend from yesterday must not count against today",
);
}
#[test]
fn daily_cap_same_day_other_seconds_does_not_reset() {
let s = Settings {
daily_cost_cap_usd: Some(10.0),
..settings()
};
let u = Usage {
estimated_cost_usd: 5.0,
..ok_usage()
};
let daily = DailyState {
spent_usd: 9.0,
day_epoch_secs: 0,
};
let now_same_day = 45_240;
let d = evaluate(&u, &daily, &s, now_at(now_same_day));
assert!(matches!(
d,
Decision::Reject {
limit: LimitKind::DailyCostCap,
..
}
));
}
#[test]
fn prompt_check_fires_before_completion_check() {
let s = settings();
let u = Usage {
prompt_tokens: s.max_prompt_tokens + 1,
completion_tokens: s.max_completion_tokens + 1,
..ok_usage()
};
let d = evaluate(&u, &fresh_state(), &s, now_at(0));
match d {
Decision::Reject { limit, .. } => assert_eq!(limit, LimitKind::PromptTokens),
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn timeout_check_fires_before_daily_cap() {
let s = Settings {
daily_cost_cap_usd: Some(0.0),
..settings()
};
let u = Usage {
estimated_cost_usd: 1.0,
elapsed_ms: s.timeout_ms + 1,
..ok_usage()
};
let d = evaluate(&u, &fresh_state(), &s, now_at(0));
match d {
Decision::Reject { limit, .. } => assert_eq!(limit, LimitKind::Timeout),
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn separate_daily_states_do_not_interact() {
let s = Settings {
daily_cost_cap_usd: Some(5.0),
..settings()
};
let u = Usage {
estimated_cost_usd: 1.0,
..ok_usage()
};
let tenant_a = DailyState {
spent_usd: 4.5,
day_epoch_secs: 0,
};
let tenant_b = DailyState {
spent_usd: 0.0,
day_epoch_secs: 0,
};
assert!(matches!(
evaluate(&u, &tenant_a, &s, now_at(0)),
Decision::Reject {
limit: LimitKind::DailyCostCap,
..
}
));
assert_eq!(evaluate(&u, &tenant_b, &s, now_at(0)), Decision::Allow);
}
#[test]
fn field_names_match_settings_keys() {
assert_eq!(LimitKind::PromptTokens.field_name(), "max_prompt_tokens");
assert_eq!(
LimitKind::CompletionTokens.field_name(),
"max_completion_tokens"
);
assert_eq!(LimitKind::SourcesBytes.field_name(), "max_sources_bytes");
assert_eq!(LimitKind::Timeout.field_name(), "timeout_ms");
assert_eq!(LimitKind::DailyCostCap.field_name(), "daily_cost_cap_usd");
}
#[test]
fn http_status_mapping() {
assert_eq!(LimitKind::PromptTokens.http_status(), 413);
assert_eq!(LimitKind::CompletionTokens.http_status(), 413);
assert_eq!(LimitKind::SourcesBytes.http_status(), 413);
assert_eq!(LimitKind::DailyCostCap.http_status(), 413);
assert_eq!(LimitKind::Timeout.http_status(), 504);
}
#[test]
fn defaults_match_spec() {
let s = Settings::default();
assert_eq!(s.max_prompt_tokens, 8192);
assert_eq!(s.max_completion_tokens, 1024);
assert_eq!(s.max_sources_bytes, 262_144);
assert_eq!(s.timeout_ms, 30_000);
assert_eq!(s.daily_cost_cap_usd, None);
}
#[test]
fn evaluation_is_deterministic() {
let s = Settings {
daily_cost_cap_usd: Some(10.0),
..settings()
};
let u = Usage {
prompt_tokens: 100,
completion_tokens: 50,
sources_bytes: 1000,
estimated_cost_usd: 0.5,
elapsed_ms: 1234,
};
let daily = DailyState {
spent_usd: 1.0,
day_epoch_secs: 0,
};
let a = evaluate(&u, &daily, &s, now_at(500));
let b = evaluate(&u, &daily, &s, now_at(500));
assert_eq!(a, b);
}
#[test]
fn same_utc_day_negative_epoch() {
assert!(same_utc_day(-1, -1));
assert!(!same_utc_day(-1, 0));
assert!(same_utc_day(0, SECS_PER_DAY - 1));
assert!(!same_utc_day(0, SECS_PER_DAY));
}
#[test]
fn utc_day_start_handles_negative_epoch() {
assert_eq!(utc_day_start_epoch_secs(0), 0);
assert_eq!(utc_day_start_epoch_secs(SECS_PER_DAY + 123), SECS_PER_DAY);
assert_eq!(utc_day_start_epoch_secs(-1), -SECS_PER_DAY);
}
}