use std::collections::{BTreeMap, HashMap};
use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::ids::LeaseId;
#[derive(Debug, Clone, PartialEq)]
pub struct CostBudgetAmount {
pub currency: String,
pub amount: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CostBudgetParseError {
#[error("missing ':' in cost.budget amount {0:?}")]
MissingSeparator(String),
#[error("empty currency in cost.budget amount {0:?}")]
EmptyCurrency(String),
#[error("invalid decimal in cost.budget amount {0:?}")]
InvalidAmount(String),
#[error("negative cost.budget amount {0:?}")]
Negative(String),
}
impl CostBudgetAmount {
pub fn parse(input: &str) -> Result<Self, CostBudgetParseError> {
let Some((currency, rest)) = input.split_once(':') else {
return Err(CostBudgetParseError::MissingSeparator(input.to_owned()));
};
if currency.is_empty() {
return Err(CostBudgetParseError::EmptyCurrency(input.to_owned()));
}
let amount: f64 = rest
.parse()
.map_err(|_| CostBudgetParseError::InvalidAmount(input.to_owned()))?;
if !amount.is_finite() || amount < 0.0 {
return Err(CostBudgetParseError::Negative(input.to_owned()));
}
Ok(Self {
currency: currency.to_owned(),
amount,
})
}
#[must_use]
pub fn format(&self) -> String {
format!("{}:{}", self.currency, self.amount)
}
}
impl fmt::Display for CostBudgetAmount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.format())
}
}
impl Serialize for CostBudgetAmount {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.format())
}
}
impl<'de> Deserialize<'de> for CostBudgetAmount {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let raw = String::deserialize(deserializer)?;
Self::parse(&raw).map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CostBudget {
pub amounts: Vec<CostBudgetAmount>,
}
impl CostBudget {
#[must_use]
pub const fn new() -> Self {
Self {
amounts: Vec::new(),
}
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.amounts.is_empty()
}
#[must_use]
pub fn max(&self, currency: &str) -> Option<f64> {
self.amounts
.iter()
.find(|a| a.currency == currency)
.map(|a| a.amount)
}
#[must_use]
pub fn subset_violation<'a>(
&'a self,
child: &'a Self,
remaining: &HashMap<String, f64>,
) -> Option<&'a str> {
for c in &child.amounts {
let parent_remaining = remaining
.get(&c.currency)
.copied()
.or_else(|| self.max(&c.currency));
let Some(parent_remaining) = parent_remaining else {
return Some(&c.currency);
};
if c.amount > parent_remaining {
return Some(&c.currency);
}
}
None
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ModelUse {
pub patterns: Vec<String>,
}
impl ModelUse {
#[must_use]
pub const fn new() -> Self {
Self {
patterns: Vec::new(),
}
}
#[must_use]
pub fn matches(&self, model: &str) -> bool {
self.patterns
.iter()
.any(|pattern| glob_star_matches(pattern, model))
}
#[must_use]
pub fn subset_violation<'a>(&'a self, child: &'a Self) -> Option<&'a str> {
child
.patterns
.iter()
.find(|pattern| {
!self
.patterns
.iter()
.any(|parent| glob_pattern_subsumes(parent, pattern))
})
.map(String::as_str)
}
}
fn glob_star_matches(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
if !pattern.contains('*') {
return pattern == value;
}
let parts: Vec<&str> = pattern.split('*').collect();
let mut rest = value;
for (index, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if index == 0 {
let Some(next) = rest.strip_prefix(part) else {
return false;
};
rest = next;
continue;
}
let Some(pos) = rest.find(part) else {
return false;
};
rest = &rest[pos + part.len()..];
}
pattern.ends_with('*')
|| parts
.last()
.is_none_or(|last| rest.is_empty() || last.is_empty())
}
fn glob_pattern_subsumes(parent: &str, child: &str) -> bool {
if parent == "*" || parent == child {
return true;
}
if !parent.contains('*') {
return false;
}
if !child.contains('*') {
return glob_star_matches(parent, child);
}
let parent_prefix = parent.split_once('*').map_or(parent, |(prefix, _)| prefix);
let child_prefix = child.split_once('*').map_or(child, |(prefix, _)| prefix);
let parent_suffix = parent.rsplit_once('*').map_or(parent, |(_, suffix)| suffix);
let child_suffix = child.rsplit_once('*').map_or(child, |(_, suffix)| suffix);
child_prefix.starts_with(parent_prefix) && child_suffix.ends_with(parent_suffix)
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LeaseRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_budget: Option<CostBudget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_use: Option<ModelUse>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl LeaseRequest {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cost_budget.is_none()
&& self.model_use.is_none()
&& self.expires_at.is_none()
&& self.extra.is_empty()
}
#[must_use]
pub fn subset_violation(
&self,
child: &Self,
remaining_budget: &HashMap<String, f64>,
) -> Option<LeaseSubsetViolation> {
if let Some(child_budget) = child.cost_budget.as_ref() {
let Some(parent_budget) = self.cost_budget.as_ref() else {
return Some(LeaseSubsetViolation::CostBudget(
child_budget
.amounts
.first()
.map_or_else(|| "cost.budget".to_owned(), |a| a.currency.clone()),
));
};
if let Some(currency) = parent_budget.subset_violation(child_budget, remaining_budget) {
return Some(LeaseSubsetViolation::CostBudget(currency.to_owned()));
}
}
if let Some(child_models) = child.model_use.as_ref() {
let Some(parent_models) = self.model_use.as_ref() else {
return Some(LeaseSubsetViolation::ModelUse(
child_models
.patterns
.first()
.cloned()
.unwrap_or_else(|| "model.use".to_owned()),
));
};
if let Some(pattern) = parent_models.subset_violation(child_models) {
return Some(LeaseSubsetViolation::ModelUse(pattern.to_owned()));
}
}
if let Some(child_expiry) = child.expires_at {
let Some(parent_expiry) = self.expires_at else {
return Some(LeaseSubsetViolation::ExpiresAtBeyondParent);
};
if child_expiry > parent_expiry {
return Some(LeaseSubsetViolation::ExpiresAtBeyondParent);
}
}
None
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum LeaseSubsetViolation {
CostBudget(String),
ModelUse(String),
ExpiresAtBeyondParent,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::missing_panics_doc)]
mod cost_budget_tests {
use super::*;
#[test]
fn parse_amount_round_trips() {
let a = CostBudgetAmount::parse("USD:5.00").unwrap();
assert_eq!(a.currency, "USD");
assert!((a.amount - 5.00).abs() < f64::EPSILON);
assert_eq!(serde_json::to_string(&a).unwrap(), "\"USD:5\"");
}
#[test]
fn parse_amount_rejects_negative() {
assert!(matches!(
CostBudgetAmount::parse("USD:-1"),
Err(CostBudgetParseError::Negative(_))
));
}
#[test]
fn parse_amount_rejects_missing_separator() {
assert!(matches!(
CostBudgetAmount::parse("USD"),
Err(CostBudgetParseError::MissingSeparator(_))
));
}
#[test]
fn budget_subset_rejects_excess_child() {
let parent = CostBudget {
amounts: vec![CostBudgetAmount::parse("USD:5.00").unwrap()],
};
let child = CostBudget {
amounts: vec![CostBudgetAmount::parse("USD:6.00").unwrap()],
};
let remaining = std::collections::HashMap::new();
assert_eq!(parent.subset_violation(&child, &remaining), Some("USD"));
}
#[test]
fn budget_subset_uses_remaining_floor() {
let parent = CostBudget {
amounts: vec![CostBudgetAmount::parse("USD:5.00").unwrap()],
};
let child = CostBudget {
amounts: vec![CostBudgetAmount::parse("USD:3.00").unwrap()],
};
let mut remaining = std::collections::HashMap::new();
remaining.insert("USD".into(), 2.0);
assert_eq!(parent.subset_violation(&child, &remaining), Some("USD"));
}
#[test]
fn budget_amounts_serialize_as_list_of_strings() {
let b = CostBudget {
amounts: vec![
CostBudgetAmount::parse("USD:5.00").unwrap(),
CostBudgetAmount::parse("credits:1000").unwrap(),
],
};
let j = serde_json::to_value(&b).unwrap();
assert_eq!(j, serde_json::json!(["USD:5", "credits:1000"]));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::missing_panics_doc)]
mod model_use_tests {
use super::*;
#[test]
fn parse_round_trips_through_serde() {
let model_use = ModelUse {
patterns: vec!["tier-fast/*".into()],
};
let json = serde_json::to_string(&model_use).unwrap();
assert_eq!(json, "[\"tier-fast/*\"]");
let back: ModelUse = serde_json::from_str(&json).unwrap();
assert_eq!(back, model_use);
}
#[test]
fn matches_exact_and_glob() {
let model_use = ModelUse {
patterns: vec!["tier-fast/*".into(), "anthropic/claude-3-haiku".into()],
};
assert!(model_use.matches("tier-fast/small"));
assert!(model_use.matches("anthropic/claude-3-haiku"));
assert!(!model_use.matches("tier-slow/small"));
}
#[test]
fn subset_rejects_expanded_set() {
let parent = ModelUse {
patterns: vec!["tier-fast/*".into()],
};
let child = ModelUse {
patterns: vec!["*".into()],
};
assert_eq!(parent.subset_violation(&child), Some("*"));
}
#[test]
fn subset_accepts_equal_or_narrower() {
let parent = ModelUse {
patterns: vec!["tier-fast/*".into()],
};
let equal = ModelUse {
patterns: vec!["tier-fast/*".into()],
};
let narrower = ModelUse {
patterns: vec!["tier-fast/small".into()],
};
assert!(parent.subset_violation(&equal).is_none());
assert!(parent.subset_violation(&narrower).is_none());
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::missing_panics_doc)]
mod lease_request_tests {
use super::*;
fn budget(amount: f64) -> CostBudget {
CostBudget {
amounts: vec![CostBudgetAmount {
currency: "USD".into(),
amount,
}],
}
}
#[test]
fn subset_rejects_cost_budget_overrun() {
let parent = LeaseRequest {
cost_budget: Some(budget(5.0)),
..LeaseRequest::default()
};
let child = LeaseRequest {
cost_budget: Some(budget(6.0)),
..LeaseRequest::default()
};
assert_eq!(
parent.subset_violation(&child, &HashMap::new()),
Some(LeaseSubsetViolation::CostBudget("USD".into()))
);
}
#[test]
fn subset_rejects_model_use_widening() {
let parent = LeaseRequest {
model_use: Some(ModelUse {
patterns: vec!["tier-fast/*".into()],
}),
..LeaseRequest::default()
};
let child = LeaseRequest {
model_use: Some(ModelUse {
patterns: vec!["*".into()],
}),
..LeaseRequest::default()
};
assert_eq!(
parent.subset_violation(&child, &HashMap::new()),
Some(LeaseSubsetViolation::ModelUse("*".into()))
);
}
#[test]
fn subset_rejects_expiry_beyond_parent() {
let now = Utc::now();
let parent = LeaseRequest {
expires_at: Some(now),
..LeaseRequest::default()
};
let child = LeaseRequest {
expires_at: Some(now + chrono::Duration::seconds(1)),
..LeaseRequest::default()
};
assert_eq!(
parent.subset_violation(&child, &HashMap::new()),
Some(LeaseSubsetViolation::ExpiresAtBeyondParent)
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustLevel {
Untrusted,
Constrained,
Trusted,
Privileged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionRequestPayload {
pub permission: String,
pub resource: String,
pub operation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_lease_seconds: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionGrantPayload {
pub permission: String,
pub resource: String,
pub operation: String,
pub lease_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionDenyPayload {
pub permission: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaseGrantedPayload {
pub lease_id: LeaseId,
pub permission: String,
pub resource: String,
pub operation: String,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaseRefreshPayload {
pub lease_id: LeaseId,
pub additional_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaseExtendedPayload {
pub lease_id: LeaseId,
pub expires_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaseRevokedPayload {
pub lease_id: LeaseId,
pub reason: String,
}