use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use serde_json::Number;
use crate::PROVIDES;
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct RoleDescribe {
pub role: String,
pub limits: Limits,
pub models: Vec<ModelEntry>,
}
impl RoleDescribe {
pub fn new(limits: Limits, models: Vec<ModelEntry>) -> Self {
Self {
role: PROVIDES.to_owned(),
limits,
models,
}
}
pub fn model(&self, model: &str) -> Option<&ModelEntry> {
self.models.iter().find(|entry| entry.model == model)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct Limits {
pub max_items: u64,
pub max_request_bytes: u64,
}
impl Limits {
pub fn new(max_items: u64, max_request_bytes: u64) -> Self {
Self {
max_items,
max_request_bytes,
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ModelEntry {
pub model: String,
pub max_questions: u64,
pub max_items: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_tokens: Option<u64>,
pub images: Option<ImageSupport>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub price: Option<Price>,
pub egress_host: String,
}
impl ModelEntry {
pub fn new(
model: impl Into<String>,
max_questions: u64,
max_items: u64,
context_tokens: impl Into<Option<u64>>,
egress_host: impl Into<String>,
) -> Self {
Self {
model: model.into(),
max_questions,
max_items,
context_tokens: context_tokens.into(),
images: None,
price: None,
egress_host: egress_host.into(),
}
}
pub fn with_images(mut self, images: ImageSupport) -> Self {
self.images = Some(images);
self
}
pub fn with_price(mut self, price: Price) -> Self {
self.price = Some(price);
self
}
pub fn is_priced(&self) -> bool {
self.price.as_ref().is_some_and(|price| {
price.input_per_mtok_usd.is_some() || price.output_per_mtok_usd.is_some()
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ImageSupport {
pub max: u64,
}
impl ImageSupport {
pub fn new(max: u64) -> Self {
Self { max }
}
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
#[non_exhaustive]
pub struct Price {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_per_mtok_usd: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_per_mtok_usd: Option<Number>,
}
impl Price {
pub fn new() -> Self {
Self::default()
}
pub fn with_input_per_mtok_usd(mut self, usd: Number) -> Self {
self.input_per_mtok_usd = Some(usd);
self
}
pub fn with_output_per_mtok_usd(mut self, usd: Number) -> Self {
self.output_per_mtok_usd = Some(usd);
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DescribeProblem {
WrongRole(String),
NoModels,
DuplicateModel(String),
BadModel { model: String, member: &'static str },
BadLimits(&'static str),
}
impl std::fmt::Display for DescribeProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WrongRole(role) => write!(f, "role is {role}, not {PROVIDES}"),
Self::NoModels => f.write_str("models is empty"),
Self::DuplicateModel(model) => write!(f, "{model} is listed twice"),
Self::BadModel { model, member } => write!(f, "{model}: bad {member}"),
Self::BadLimits(member) => write!(f, "limits.{member} is zero"),
}
}
}
pub fn check_describe(describe: &RoleDescribe) -> Result<(), DescribeProblem> {
if describe.role != PROVIDES {
return Err(DescribeProblem::WrongRole(describe.role.clone()));
}
if describe.limits.max_items == 0 {
return Err(DescribeProblem::BadLimits("max_items"));
}
if describe.limits.max_request_bytes == 0 {
return Err(DescribeProblem::BadLimits("max_request_bytes"));
}
if describe.models.is_empty() {
return Err(DescribeProblem::NoModels);
}
let mut seen = BTreeSet::new();
for entry in &describe.models {
if !seen.insert(entry.model.as_str()) {
return Err(DescribeProblem::DuplicateModel(entry.model.clone()));
}
let bad = |member| {
Err(DescribeProblem::BadModel {
model: entry.model.clone(),
member,
})
};
if entry.model.is_empty() {
return bad("model");
}
if entry.max_questions == 0 {
return bad("max_questions");
}
if entry.max_items == 0 || entry.max_items > describe.limits.max_items {
return bad("max_items");
}
if entry.context_tokens == Some(0) {
return bad("context_tokens");
}
if entry.images.is_some_and(|images| images.max == 0) {
return bad("images");
}
if entry.egress_host.is_empty() {
return bad("egress_host");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn describe() -> RoleDescribe {
RoleDescribe::new(
Limits::new(100, 1 << 20),
vec![
ModelEntry::new(
"cloudflare-workers-ai/@cf/cloudflare/clef",
64,
100,
8192,
"api.cloudflare.com",
)
.with_images(ImageSupport::new(4)),
ModelEntry::new("typesafe/jev-1.13.0", 32, 50, 8192, "api.typesafe.ai"),
],
)
}
#[test]
fn a_complete_answer_passes() {
assert_eq!(check_describe(&describe()), Ok(()));
}
#[test]
fn missing_images_encodes_as_null() {
let text = serde_json::to_string(&describe().models[1]).unwrap();
assert!(text.contains(r#""images":null"#), "{text}");
assert!(!text.contains("price"), "{text}");
}
#[test]
fn an_absent_context_is_omitted_and_passes() {
let mut answer = describe();
answer.models[1].context_tokens = None;
assert_eq!(check_describe(&answer), Ok(()));
let text = serde_json::to_string(&answer.models[1]).unwrap();
assert!(!text.contains("context_tokens"), "{text}");
let decoded: ModelEntry = serde_json::from_str(&text).unwrap();
assert_eq!(decoded.context_tokens, None);
}
#[test]
fn a_model_allowing_more_items_than_the_module_is_refused() {
let mut answer = describe();
answer.models[1].max_items = 101;
assert_eq!(
check_describe(&answer),
Err(DescribeProblem::BadModel {
model: "typesafe/jev-1.13.0".into(),
member: "max_items"
})
);
let mut answer = describe();
answer.models[0].context_tokens = Some(0);
assert!(matches!(
check_describe(&answer),
Err(DescribeProblem::BadModel {
member: "context_tokens",
..
})
));
}
}