1#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct Settings {
53 pub max_prompt_tokens: u32,
56 pub max_completion_tokens: u32,
59 pub max_sources_bytes: u32,
63 pub timeout_ms: u32,
66 pub daily_cost_cap_usd: Option<f64>,
69}
70
71impl Default for Settings {
72 fn default() -> Self {
73 Self {
74 max_prompt_tokens: 8192,
75 max_completion_tokens: 1024,
76 max_sources_bytes: 262_144,
77 timeout_ms: 30_000,
78 daily_cost_cap_usd: None,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Default)]
87pub struct Usage {
88 pub prompt_tokens: u32,
90 pub completion_tokens: u32,
92 pub sources_bytes: u32,
94 pub estimated_cost_usd: f64,
98 pub elapsed_ms: u32,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Default)]
109pub struct DailyState {
110 pub spent_usd: f64,
111 pub day_epoch_secs: i64,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq)]
116pub struct Now {
117 pub epoch_secs: i64,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum LimitKind {
123 PromptTokens,
124 CompletionTokens,
125 SourcesBytes,
126 Timeout,
127 DailyCostCap,
128}
129
130impl LimitKind {
131 pub fn field_name(self) -> &'static str {
134 match self {
135 LimitKind::PromptTokens => "max_prompt_tokens",
136 LimitKind::CompletionTokens => "max_completion_tokens",
137 LimitKind::SourcesBytes => "max_sources_bytes",
138 LimitKind::Timeout => "timeout_ms",
139 LimitKind::DailyCostCap => "daily_cost_cap_usd",
140 }
141 }
142
143 pub fn http_status(self) -> u16 {
146 match self {
147 LimitKind::Timeout => 504,
148 _ => 413,
149 }
150 }
151}
152
153#[derive(Debug, Clone, PartialEq)]
154pub enum Decision {
155 Allow,
156 Reject {
157 limit: LimitKind,
158 http_status: u16,
159 detail: String,
160 },
161}
162
163pub fn evaluate(usage: &Usage, daily: &DailyState, settings: &Settings, now: Now) -> Decision {
171 if usage.prompt_tokens > settings.max_prompt_tokens {
172 return reject(
173 LimitKind::PromptTokens,
174 format!(
175 "prompt {} tokens exceeds max_prompt_tokens={}",
176 usage.prompt_tokens, settings.max_prompt_tokens
177 ),
178 );
179 }
180
181 if usage.sources_bytes > settings.max_sources_bytes {
182 return reject(
183 LimitKind::SourcesBytes,
184 format!(
185 "sources payload {} bytes exceeds max_sources_bytes={}",
186 usage.sources_bytes, settings.max_sources_bytes
187 ),
188 );
189 }
190
191 if usage.completion_tokens > settings.max_completion_tokens {
192 return reject(
193 LimitKind::CompletionTokens,
194 format!(
195 "completion {} tokens exceeds max_completion_tokens={}",
196 usage.completion_tokens, settings.max_completion_tokens
197 ),
198 );
199 }
200
201 if usage.elapsed_ms > settings.timeout_ms {
202 return reject(
203 LimitKind::Timeout,
204 format!(
205 "elapsed {}ms exceeds timeout_ms={}",
206 usage.elapsed_ms, settings.timeout_ms
207 ),
208 );
209 }
210
211 if let Some(cap) = settings.daily_cost_cap_usd {
212 let effective_spent = if same_utc_day(daily.day_epoch_secs, now.epoch_secs) {
213 daily.spent_usd
214 } else {
215 0.0
216 };
217 let projected = effective_spent + usage.estimated_cost_usd;
218 if projected > cap {
219 return reject(
220 LimitKind::DailyCostCap,
221 format!("projected spend ${projected:.6} exceeds daily_cost_cap_usd=${cap:.6}"),
222 );
223 }
224 }
225
226 Decision::Allow
227}
228
229fn reject(limit: LimitKind, detail: String) -> Decision {
230 Decision::Reject {
231 limit,
232 http_status: limit.http_status(),
233 detail,
234 }
235}
236
237const SECS_PER_DAY: i64 = 86_400;
238
239pub fn utc_day_start_epoch_secs(epoch_secs: i64) -> i64 {
247 epoch_secs.div_euclid(SECS_PER_DAY) * SECS_PER_DAY
248}
249
250fn same_utc_day(a: i64, b: i64) -> bool {
251 utc_day_start_epoch_secs(a) == utc_day_start_epoch_secs(b)
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn settings() -> Settings {
259 Settings::default()
260 }
261
262 fn now_at(epoch_secs: i64) -> Now {
263 Now { epoch_secs }
264 }
265
266 fn fresh_state() -> DailyState {
267 DailyState::default()
268 }
269
270 fn ok_usage() -> Usage {
271 Usage::default()
272 }
273
274 #[test]
277 fn at_limit_is_allowed() {
278 let s = settings();
279 let u = Usage {
280 prompt_tokens: s.max_prompt_tokens,
281 completion_tokens: s.max_completion_tokens,
282 sources_bytes: s.max_sources_bytes,
283 elapsed_ms: s.timeout_ms,
284 ..ok_usage()
285 };
286 assert_eq!(evaluate(&u, &fresh_state(), &s, now_at(0)), Decision::Allow);
287 }
288
289 #[test]
290 fn one_over_prompt_tokens_rejects_413() {
291 let s = settings();
292 let u = Usage {
293 prompt_tokens: s.max_prompt_tokens + 1,
294 ..ok_usage()
295 };
296 let d = evaluate(&u, &fresh_state(), &s, now_at(0));
297 match d {
298 Decision::Reject {
299 limit,
300 http_status,
301 detail,
302 } => {
303 assert_eq!(limit, LimitKind::PromptTokens);
304 assert_eq!(http_status, 413);
305 assert!(detail.contains("max_prompt_tokens"));
306 }
307 other => panic!("expected Reject, got {other:?}"),
308 }
309 }
310
311 #[test]
312 fn over_sources_bytes_rejects_413() {
313 let s = settings();
314 let u = Usage {
315 sources_bytes: s.max_sources_bytes + 1,
316 ..ok_usage()
317 };
318 let d = evaluate(&u, &fresh_state(), &s, now_at(0));
319 match d {
320 Decision::Reject {
321 limit, http_status, ..
322 } => {
323 assert_eq!(limit, LimitKind::SourcesBytes);
324 assert_eq!(http_status, 413);
325 }
326 other => panic!("expected Reject, got {other:?}"),
327 }
328 }
329
330 #[test]
331 fn over_completion_tokens_rejects_413() {
332 let s = settings();
333 let u = Usage {
334 completion_tokens: s.max_completion_tokens + 1,
335 ..ok_usage()
336 };
337 let d = evaluate(&u, &fresh_state(), &s, now_at(0));
338 match d {
339 Decision::Reject {
340 limit, http_status, ..
341 } => {
342 assert_eq!(limit, LimitKind::CompletionTokens);
343 assert_eq!(http_status, 413);
344 }
345 other => panic!("expected Reject, got {other:?}"),
346 }
347 }
348
349 #[test]
350 fn over_timeout_rejects_504() {
351 let s = settings();
352 let u = Usage {
353 elapsed_ms: s.timeout_ms + 1,
354 ..ok_usage()
355 };
356 let d = evaluate(&u, &fresh_state(), &s, now_at(0));
357 match d {
358 Decision::Reject {
359 limit, http_status, ..
360 } => {
361 assert_eq!(limit, LimitKind::Timeout);
362 assert_eq!(http_status, 504);
363 }
364 other => panic!("expected Reject, got {other:?}"),
365 }
366 }
367
368 #[test]
371 fn daily_cap_none_means_unlimited() {
372 let s = Settings {
373 daily_cost_cap_usd: None,
374 ..settings()
375 };
376 let u = Usage {
377 estimated_cost_usd: 9_999.0,
378 ..ok_usage()
379 };
380 let daily = DailyState {
381 spent_usd: 1_000_000.0,
382 day_epoch_secs: 0,
383 };
384 assert_eq!(evaluate(&u, &daily, &s, now_at(0)), Decision::Allow);
385 }
386
387 #[test]
388 fn daily_cap_blocks_when_projected_exceeds() {
389 let s = Settings {
390 daily_cost_cap_usd: Some(10.0),
391 ..settings()
392 };
393 let u = Usage {
394 estimated_cost_usd: 2.5,
395 ..ok_usage()
396 };
397 let daily = DailyState {
398 spent_usd: 8.0,
399 day_epoch_secs: 0,
400 };
401 let d = evaluate(&u, &daily, &s, now_at(0));
402 match d {
403 Decision::Reject {
404 limit, http_status, ..
405 } => {
406 assert_eq!(limit, LimitKind::DailyCostCap);
407 assert_eq!(http_status, 413);
408 }
409 other => panic!("expected Reject, got {other:?}"),
410 }
411 }
412
413 #[test]
414 fn daily_cap_allows_at_exact_cap() {
415 let s = Settings {
417 daily_cost_cap_usd: Some(10.0),
418 ..settings()
419 };
420 let u = Usage {
421 estimated_cost_usd: 2.0,
422 ..ok_usage()
423 };
424 let daily = DailyState {
425 spent_usd: 8.0,
426 day_epoch_secs: 0,
427 };
428 assert_eq!(evaluate(&u, &daily, &s, now_at(0)), Decision::Allow);
429 }
430
431 #[test]
432 fn daily_cap_resets_at_utc_midnight() {
433 let s = Settings {
436 daily_cost_cap_usd: Some(10.0),
437 ..settings()
438 };
439 let u = Usage {
440 estimated_cost_usd: 9.0,
441 ..ok_usage()
442 };
443 let day_zero_start = 0;
444 let day_one_start_plus_1 = SECS_PER_DAY + 1;
445 let daily = DailyState {
446 spent_usd: 100.0,
447 day_epoch_secs: day_zero_start,
448 };
449 assert_eq!(
450 evaluate(&u, &daily, &s, now_at(day_one_start_plus_1)),
451 Decision::Allow,
452 "stale spend from yesterday must not count against today",
453 );
454 }
455
456 #[test]
457 fn daily_cap_same_day_other_seconds_does_not_reset() {
458 let s = Settings {
460 daily_cost_cap_usd: Some(10.0),
461 ..settings()
462 };
463 let u = Usage {
464 estimated_cost_usd: 5.0,
465 ..ok_usage()
466 };
467 let daily = DailyState {
468 spent_usd: 9.0,
469 day_epoch_secs: 0,
470 };
471 let now_same_day = 45_240;
473 let d = evaluate(&u, &daily, &s, now_at(now_same_day));
474 assert!(matches!(
475 d,
476 Decision::Reject {
477 limit: LimitKind::DailyCostCap,
478 ..
479 }
480 ));
481 }
482
483 #[test]
486 fn prompt_check_fires_before_completion_check() {
487 let s = settings();
489 let u = Usage {
490 prompt_tokens: s.max_prompt_tokens + 1,
491 completion_tokens: s.max_completion_tokens + 1,
492 ..ok_usage()
493 };
494 let d = evaluate(&u, &fresh_state(), &s, now_at(0));
495 match d {
496 Decision::Reject { limit, .. } => assert_eq!(limit, LimitKind::PromptTokens),
497 other => panic!("expected Reject, got {other:?}"),
498 }
499 }
500
501 #[test]
502 fn timeout_check_fires_before_daily_cap() {
503 let s = Settings {
504 daily_cost_cap_usd: Some(0.0),
505 ..settings()
506 };
507 let u = Usage {
508 estimated_cost_usd: 1.0,
509 elapsed_ms: s.timeout_ms + 1,
510 ..ok_usage()
511 };
512 let d = evaluate(&u, &fresh_state(), &s, now_at(0));
513 match d {
514 Decision::Reject { limit, .. } => assert_eq!(limit, LimitKind::Timeout),
515 other => panic!("expected Reject, got {other:?}"),
516 }
517 }
518
519 #[test]
522 fn separate_daily_states_do_not_interact() {
523 let s = Settings {
525 daily_cost_cap_usd: Some(5.0),
526 ..settings()
527 };
528 let u = Usage {
529 estimated_cost_usd: 1.0,
530 ..ok_usage()
531 };
532 let tenant_a = DailyState {
533 spent_usd: 4.5,
534 day_epoch_secs: 0,
535 };
536 let tenant_b = DailyState {
537 spent_usd: 0.0,
538 day_epoch_secs: 0,
539 };
540 assert!(matches!(
541 evaluate(&u, &tenant_a, &s, now_at(0)),
542 Decision::Reject {
543 limit: LimitKind::DailyCostCap,
544 ..
545 }
546 ));
547 assert_eq!(evaluate(&u, &tenant_b, &s, now_at(0)), Decision::Allow);
548 }
549
550 #[test]
553 fn field_names_match_settings_keys() {
554 assert_eq!(LimitKind::PromptTokens.field_name(), "max_prompt_tokens");
558 assert_eq!(
559 LimitKind::CompletionTokens.field_name(),
560 "max_completion_tokens"
561 );
562 assert_eq!(LimitKind::SourcesBytes.field_name(), "max_sources_bytes");
563 assert_eq!(LimitKind::Timeout.field_name(), "timeout_ms");
564 assert_eq!(LimitKind::DailyCostCap.field_name(), "daily_cost_cap_usd");
565 }
566
567 #[test]
568 fn http_status_mapping() {
569 assert_eq!(LimitKind::PromptTokens.http_status(), 413);
570 assert_eq!(LimitKind::CompletionTokens.http_status(), 413);
571 assert_eq!(LimitKind::SourcesBytes.http_status(), 413);
572 assert_eq!(LimitKind::DailyCostCap.http_status(), 413);
573 assert_eq!(LimitKind::Timeout.http_status(), 504);
574 }
575
576 #[test]
579 fn defaults_match_spec() {
580 let s = Settings::default();
581 assert_eq!(s.max_prompt_tokens, 8192);
582 assert_eq!(s.max_completion_tokens, 1024);
583 assert_eq!(s.max_sources_bytes, 262_144);
584 assert_eq!(s.timeout_ms, 30_000);
585 assert_eq!(s.daily_cost_cap_usd, None);
586 }
587
588 #[test]
591 fn evaluation_is_deterministic() {
592 let s = Settings {
593 daily_cost_cap_usd: Some(10.0),
594 ..settings()
595 };
596 let u = Usage {
597 prompt_tokens: 100,
598 completion_tokens: 50,
599 sources_bytes: 1000,
600 estimated_cost_usd: 0.5,
601 elapsed_ms: 1234,
602 };
603 let daily = DailyState {
604 spent_usd: 1.0,
605 day_epoch_secs: 0,
606 };
607 let a = evaluate(&u, &daily, &s, now_at(500));
608 let b = evaluate(&u, &daily, &s, now_at(500));
609 assert_eq!(a, b);
610 }
611
612 #[test]
613 fn same_utc_day_negative_epoch() {
614 assert!(same_utc_day(-1, -1));
617 assert!(!same_utc_day(-1, 0));
618 assert!(same_utc_day(0, SECS_PER_DAY - 1));
619 assert!(!same_utc_day(0, SECS_PER_DAY));
620 }
621
622 #[test]
623 fn utc_day_start_handles_negative_epoch() {
624 assert_eq!(utc_day_start_epoch_secs(0), 0);
625 assert_eq!(utc_day_start_epoch_secs(SECS_PER_DAY + 123), SECS_PER_DAY);
626 assert_eq!(utc_day_start_epoch_secs(-1), -SECS_PER_DAY);
627 }
628}