use crate::clock;
use crate::provider::Backend;
use anyhow::Result;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Price {
Free,
PerImage { usd: f64, verified: &'static str },
PerSecond { usd: f64, verified: &'static str, seconds: u32 },
Unverified,
}
pub const CEILING: f64 = 0.25;
impl Price {
pub fn against_budget(self) -> f64 {
match self {
Price::Free => 0.0,
Price::PerImage { usd, .. } => usd,
Price::PerSecond { usd, seconds, .. } => usd * f64::from(seconds),
Price::Unverified => CEILING,
}
}
pub fn describe(self) -> String {
match self {
Price::Free => "free — renders on your own hardware".to_string(),
Price::PerImage { usd, verified } => {
format!("about ${usd:.3} per image (published rate, checked {verified})")
}
Price::PerSecond { usd, verified, seconds } => format!(
"about ${:.2} for {seconds}s at ${usd:.2}/second (published rate, \
checked {verified})",
usd * f64::from(seconds)
),
Price::Unverified => {
"billed, at a rate this table has not verified — see the provider's \
own pricing"
.to_string()
}
}
}
}
pub fn price_for(backend: Backend, model: &str) -> Price {
const CHECKED: &str = "2026-08-09";
match backend {
Backend::ComfyUi => Price::Free,
Backend::Google => {
match crate::genai::resolve_model(model).as_str() {
m if m.starts_with("gemini-3-pro-image") => Price::PerImage {
usd: 0.134,
verified: CHECKED,
},
m if m.starts_with("gemini-3.1-flash-image") => Price::PerImage {
usd: 0.067,
verified: CHECKED,
},
_ => Price::Unverified,
}
}
Backend::Bfl | Backend::Stability | Backend::OpenAi => Price::Unverified,
}
}
pub fn video_price(backend: crate::provider::VideoBackend, model: &str, duration: Option<u32>) -> Price {
const CHECKED: &str = "2026-08-09";
let per_second = match backend {
crate::provider::VideoBackend::Google => {
if model.contains("lite") {
0.05
} else if model.contains("fast") {
0.15
} else {
0.40
}
}
crate::provider::VideoBackend::Runway | crate::provider::VideoBackend::Kling => {
return Price::Unverified;
}
};
Price::PerSecond {
usd: per_second,
verified: CHECKED,
seconds: duration.unwrap_or(8),
}
}
pub const WINDOW_SECONDS: i64 = 24 * 60 * 60;
pub fn budget() -> Option<f64> {
crate::config::var("LUCIDA_BUDGET")?.trim().parse().ok()
}
pub fn spent_recently() -> f64 {
let since = clock::now() - WINDOW_SECONDS;
let total: f64 = crate::ledger::entries()
.iter()
.filter(|e| e["at"].as_i64().unwrap_or(0) >= since)
.filter_map(|e| e["estimated_usd"].as_f64())
.sum();
total.max(0.0)
}
pub fn check(price: Price, what: &str) -> Result<()> {
check_batch(price, 1, what)
}
pub fn check_batch(price: Price, count: usize, what: &str) -> Result<()> {
let estimate = price.against_budget() * count as f64;
if estimate <= 0.0 {
return Ok(());
}
let Some(budget) = budget() else {
return Ok(());
};
let spent = spent_recently();
if spent + estimate <= budget {
return Ok(());
}
let assumption = match price {
Price::Unverified => format!(
"\n\nThis provider's rate is not verified here, so it is counted at \
${CEILING:.2} — an assumed upper bound, not a price."
),
_ => String::new(),
};
Err(anyhow::Error::new(crate::out::Refused(format!(
"LUCIDA_BUDGET is ${budget:.2} for a rolling 24 hours, and about \
${spent:.2} of that is already spent. This {what} would add roughly \
${estimate:.2}.{assumption}\n\n\
Raise or unset LUCIDA_BUDGET, wait for the window to roll, or use \
comfyui, which renders locally and costs nothing. `lucida history` \
shows what the estimate is made of."
))))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_stated_price_carries_the_date_it_was_checked() {
for backend in Backend::ALL {
match price_for(*backend, backend.default_model()) {
Price::PerImage { verified, .. } | Price::PerSecond { verified, .. } => {
assert!(
crate::clock::unix_time(verified).is_some(),
"{}: `{verified}` is not a date",
backend.name()
);
}
Price::Free | Price::Unverified => {}
}
}
}
#[test]
fn a_batch_costs_its_count_rather_than_one_render() {
let price = Price::PerImage { usd: 0.134, verified: "2026-08-09" };
let one = price.against_budget();
let three = price.against_budget() * 3.0;
assert!(
three > one * 2.5,
"a batch of three must be estimated at three renders, not one"
);
assert!(check_batch(Price::Free, 100, "render").is_ok());
}
#[test]
fn nothing_spent_reads_as_zero_rather_than_minus_zero() {
let empty: f64 = Vec::<f64>::new().into_iter().sum();
assert!(
empty.is_sign_negative(),
"std stopped folding from -0.0; the guard in spent_recently may be \
removable, but check before removing it"
);
assert_eq!(format!("{:.2}", empty.max(0.0)), "0.00");
assert!(spent_recently() >= 0.0);
}
#[test]
fn an_alias_is_priced_like_the_model_it_names() {
for (alias, id) in crate::genai::MODEL_ALIASES {
assert_eq!(
price_for(Backend::Google, alias),
price_for(Backend::Google, id),
"`{alias}` and `{id}` are the same model and must cost the same"
);
}
assert!(matches!(
price_for(Backend::Google, "banana-pro"),
Price::PerImage { .. }
));
}
#[test]
fn the_local_lane_is_free_and_the_hosted_ones_are_not() {
assert_eq!(price_for(Backend::ComfyUi, "klein"), Price::Free);
assert_eq!(price_for(Backend::ComfyUi, "klein").against_budget(), 0.0);
for backend in [Backend::Google, Backend::Bfl, Backend::Stability, Backend::OpenAi] {
let price = price_for(backend, backend.default_model());
assert_ne!(price, Price::Free, "{} is not free", backend.name());
assert!(price.against_budget() > 0.0);
}
}
#[test]
fn an_unverified_price_still_counts_against_a_budget() {
assert_eq!(Price::Unverified.against_budget(), CEILING);
let highest = ["gemini-3-pro-image", "gemini-3.1-flash-image"]
.iter()
.filter_map(|model| match price_for(Backend::Google, model) {
Price::PerImage { usd, .. } => Some(usd),
_ => None,
})
.fold(0.0_f64, f64::max);
assert!(
CEILING >= highest,
"the ceiling (${CEILING}) is below a price this table states \
(${highest}), so it is not an upper bound"
);
}
#[test]
fn a_price_never_presents_itself_as_a_charge() {
for price in [
Price::Free,
Price::PerImage { usd: 0.067, verified: "2026-08-09" },
Price::PerSecond { usd: 0.15, verified: "2026-08-09", seconds: 8 },
Price::Unverified,
] {
let described = price.describe().to_lowercase();
assert!(
described.contains("about")
|| described.contains("free")
|| described.contains("not verified"),
"reads as a charge rather than an estimate: {described}"
);
}
}
#[test]
fn the_video_tiers_are_priced_apart() {
use crate::provider::VideoBackend;
let rate = |model: &str| match video_price(VideoBackend::Google, model, Some(8)) {
Price::PerSecond { usd, .. } => usd,
other => panic!("video priced as {other:?}"),
};
assert!(rate("veo-3.1-lite-generate-preview") < rate("veo-3.1-fast-generate-preview"));
assert!(rate("veo-3.1-fast-generate-preview") < rate("veo-3.1-generate-preview"));
}
#[test]
fn no_budget_means_no_refusal() {
if budget().is_none() {
assert!(check(Price::Unverified, "render").is_ok());
}
}
#[test]
fn a_free_render_is_never_refused() {
assert!(check(Price::Free, "render").is_ok());
assert_eq!(price_for(Backend::ComfyUi, "klein").against_budget(), 0.0);
}
}