use anyhow::{Result, bail};
use std::fmt;
#[derive(Debug, Clone, Default)]
pub struct ImageRequest {
pub prompt: String,
pub model: String,
pub aspect: Option<Aspect>,
pub size: Option<Size>,
pub references: Vec<String>,
pub negative_prompt: Option<String>,
pub mask: Option<String>,
pub workflow: Option<String>,
pub seed: Option<u64>,
pub steps: Option<u32>,
pub guidance: Option<f32>,
}
#[derive(Debug)]
pub struct GeneratedImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub commentary: Option<String>,
pub seed: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Aspect {
pub w: u32,
pub h: u32,
}
impl Aspect {
pub fn parse(text: &str) -> Result<Self> {
let (w, h) = text
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("aspect ratio `{text}` is not in W:H form, e.g. 16:9"))?;
let parse = |part: &str, which| -> Result<u32> {
part.trim()
.parse::<u32>()
.ok()
.filter(|n| *n > 0)
.ok_or_else(|| anyhow::anyhow!("the {which} of aspect ratio `{text}` is not a positive whole number"))
};
Ok(Self {
w: parse(w, "width")?,
h: parse(h, "height")?,
})
}
fn ratio(self) -> f64 {
f64::from(self.w) / f64::from(self.h)
}
}
impl fmt::Display for Aspect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.w, self.h)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Size(pub u32);
impl Size {
pub const ONE_K: Size = Size(1024);
pub const TWO_K: Size = Size(2048);
pub const FOUR_K: Size = Size(4096);
pub fn parse(text: &str) -> Result<Self> {
match text.trim().to_ascii_uppercase().as_str() {
"1K" => Ok(Self::ONE_K),
"2K" => Ok(Self::TWO_K),
"4K" => Ok(Self::FOUR_K),
other => other
.parse::<u32>()
.ok()
.filter(|n| (16..=16384).contains(n))
.map(Size)
.ok_or_else(|| {
anyhow::anyhow!(
"size `{text}` is neither a tier (1K, 2K, 4K) nor a pixel \
count between 16 and 16384"
)
}),
}
}
pub fn tier_name(self) -> &'static str {
match self.0 {
n if n <= 1536 => "1K",
n if n <= 3072 => "2K",
_ => "4K",
}
}
}
impl fmt::Display for Size {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}px", self.0)
}
}
impl ImageRequest {
pub fn pixels(&self, default: (u32, u32), multiple_of: u32) -> (u32, u32) {
let (w, h) = match (self.aspect, self.size) {
(None, None) => default,
(None, Some(size)) => {
let long = default.0.max(default.1).max(1);
let scale = f64::from(size.0) / f64::from(long);
(
(f64::from(default.0) * scale) as u32,
(f64::from(default.1) * scale) as u32,
)
}
(Some(aspect), size) => {
let long = size.unwrap_or(Size(default.0.max(default.1))).0;
if aspect.ratio() >= 1.0 {
(long, (f64::from(long) / aspect.ratio()) as u32)
} else {
((f64::from(long) * aspect.ratio()) as u32, long)
}
}
};
(round_to(w, multiple_of), round_to(h, multiple_of))
}
}
fn round_to(value: u32, multiple: u32) -> u32 {
if multiple <= 1 {
return value.max(1);
}
let rounded = ((value + multiple / 2) / multiple) * multiple;
rounded.max(multiple)
}
#[derive(Debug, Clone, Copy)]
pub enum AspectSupport {
Named(&'static [&'static str]),
Free { multiple_of: u32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provenance {
SynthIdAndC2pa,
C2paOnly,
Unmarked,
Unverified,
}
impl Provenance {
pub fn describe(self) -> &'static str {
match self {
Self::SynthIdAndC2pa => "invisible SynthID watermark + C2PA manifest",
Self::C2paOnly => "C2PA manifest only — no pixel watermark, so a re-encode removes it",
Self::Unmarked => "no watermark or provenance manifest",
Self::Unverified => "unverified — nobody has checked this provider's output",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaskSupport {
No,
Advisory,
Binding,
}
impl MaskSupport {
pub fn accepted(self) -> bool {
!matches!(self, Self::No)
}
pub fn kind(self) -> &'static str {
match self {
Self::No => "not accepted",
Self::Advisory => "advisory",
Self::Binding => "binding",
}
}
pub fn guarantee(self) -> &'static str {
match self {
Self::No => "no mask can be passed",
Self::Advisory => {
"the change is concentrated but not confined, and the rest of the \
picture is regenerated too — composite over the original yourself \
if untouched pixels matter"
}
Self::Binding => {
"Lucida composites the render back through the mask, so pixels \
outside it come back unchanged — measured at 0.00/255, which \
leaves nothing for the caller to composite"
}
}
}
pub fn describe(self) -> &'static str {
match self {
Self::No => "no",
Self::Advisory => "accepted, advisory — the change is concentrated, not confined",
Self::Binding => "accepted, binding — pixels outside it come back unchanged",
}
}
}
pub fn mask_providers(kind: MaskSupport) -> Vec<&'static str> {
Backend::ALL
.iter()
.filter(|b| capabilities_for(**b, b.default_model()).mask == kind)
.map(|b| b.name())
.collect()
}
pub fn mask_accepting_providers() -> Vec<&'static str> {
let mut names = mask_providers(MaskSupport::Binding);
names.extend(mask_providers(MaskSupport::Advisory));
names
}
pub fn mask_semantics() -> String {
let mut parts = Vec::new();
for kind in [MaskSupport::Binding, MaskSupport::Advisory] {
let names = mask_providers(kind);
if !names.is_empty() {
parts.push(format!(
"On {} the mask is {}: {}",
join_and(&names),
kind.kind(),
kind.guarantee()
));
}
}
if parts.is_empty() {
return "No provider currently accepts a mask.".to_string();
}
format!("{}.", parts.join(". "))
}
#[derive(Debug, Clone, Copy)]
pub enum DurationSupport {
Named(&'static [u32]),
Range { min: u32, max: u32 },
}
impl DurationSupport {
pub fn accepts(self, seconds: u32) -> bool {
match self {
DurationSupport::Named(lengths) => lengths.contains(&seconds),
DurationSupport::Range { min, max } => (min..=max).contains(&seconds),
}
}
pub fn describe(self) -> String {
match self {
DurationSupport::Named(lengths) => {
let seconds: Vec<String> = lengths.iter().map(u32::to_string).collect();
format!("{} seconds", join_and(&seconds.iter().map(String::as_str).collect::<Vec<_>>()))
}
DurationSupport::Range { min, max } => format!("{min}-{max} seconds"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct VideoCapabilities {
pub provider: &'static str,
pub tagline: &'static str,
pub aspect: AspectSupport,
pub duration: DurationSupport,
pub image_to_video: bool,
pub text_to_video: bool,
pub negative_prompt: bool,
pub resolution: bool,
pub seed: bool,
pub modes: &'static [&'static str],
pub provenance: Provenance,
}
impl VideoCapabilities {
pub fn check(&self, req: &crate::video::VideoRequest) -> Result<()> {
self.refuse(req)
.map_err(|e| anyhow::Error::new(crate::out::Refused(format!("{e:#}"))))
}
fn refuse(&self, req: &crate::video::VideoRequest) -> Result<()> {
let me = self.provider;
if req.image.is_some() && !self.image_to_video {
bail!("`{me}` cannot animate a still image; it renders from a prompt alone.");
}
if req.image.is_none() && !self.text_to_video {
bail!(
"`{me}` renders only from a still image, so it needs one to \
animate.\n\nPass an image, or use a model that starts from text."
);
}
if let Some(seconds) = req.duration
&& !self.duration.accepts(seconds)
{
bail!(
"`{me}` cannot render {seconds} seconds. It offers {}.",
self.duration.describe()
);
}
if req.negative_prompt.is_some() && !self.negative_prompt {
bail!("`{me}` has no negative prompt, so what to keep out cannot be honoured.");
}
if req.resolution.is_some() && !self.resolution {
bail!(
"`{me}` does not take a resolution; the shape you ask for decides \
the pixel count."
);
}
if req.seed.is_some() && !self.seed {
bail!("`{me}` has no concept of a seed, so a render there cannot be repeated.");
}
if let Some(mode) = &req.mode {
if self.modes.is_empty() {
bail!(
"`{me}` has no quality tiers, so `--mode` cannot be honoured. \
Its models differ by id rather than by tier."
);
}
if !self.modes.contains(&mode.as_str()) {
bail!("`{me}` has no `{mode}` tier. It offers: {}.", self.modes.join(", "));
}
}
if let Some(aspect) = req.aspect
&& let AspectSupport::Named(accepted) = self.aspect
&& !accepted.iter().any(|a| *a == aspect.to_string())
{
bail!(
"`{me}` does not offer {aspect}. It accepts: {}.",
accepted.join(", ")
);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoBackend {
Google,
Runway,
Kling,
}
impl VideoBackend {
pub const ALL: &'static [VideoBackend] =
&[VideoBackend::Google, VideoBackend::Runway, VideoBackend::Kling];
pub fn credential(self) -> Option<&'static str> {
match self {
Self::Google => Some("GEMINI_API_KEY"),
Self::Runway => Some("RUNWAY_API_KEY"),
Self::Kling => Some("KLINGAI_API_KEY"),
}
}
pub fn is_available(self) -> bool {
match self.credential() {
None => true,
Some(key) => crate::config::var(key).is_some(),
}
}
pub fn name(self) -> &'static str {
match self {
Self::Google => "google",
Self::Runway => "runway",
Self::Kling => "kling",
}
}
pub fn default_model(self) -> &'static str {
match self {
Self::Google => crate::video::DEFAULT_VIDEO_MODEL,
Self::Runway => crate::runway::DEFAULT_MODEL,
Self::Kling => crate::kling::DEFAULT_MODEL,
}
}
pub fn parse(name: &str) -> Result<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"google" | "veo" | "gemini" => Ok(Self::Google),
"runway" | "runwayml" => Ok(Self::Runway),
"kling" | "klingai" => Ok(Self::Kling),
other => bail!(
"`{other}` is not a video provider. Available: {}.",
Self::ALL.iter().map(|b| b.name()).collect::<Vec<_>>().join(", ")
),
}
}
}
pub fn video_capabilities_for(backend: VideoBackend, model: &str) -> VideoCapabilities {
match backend {
VideoBackend::Google => crate::video::CAPABILITIES,
VideoBackend::Runway => crate::runway::capabilities(model),
VideoBackend::Kling => crate::kling::capabilities(model),
}
}
pub fn infer_video_backend(model: &str) -> VideoBackend {
if crate::runway::is_runway_model(model) {
VideoBackend::Runway
} else if crate::kling::is_kling_model(model) {
VideoBackend::Kling
} else {
VideoBackend::Google
}
}
pub fn infer_video_backend_from_operation(operation: &str) -> VideoBackend {
if operation.starts_with("operations/") || operation.starts_with("models/") {
VideoBackend::Google
} else if looks_like_uuid(operation) {
VideoBackend::Runway
} else if operation.len() >= 12 && operation.chars().all(|c| c.is_ascii_digit()) {
VideoBackend::Kling
} else {
VideoBackend::Google
}
}
fn looks_like_uuid(text: &str) -> bool {
let groups: Vec<&str> = text.split('-').collect();
groups.len() == 5
&& [8, 4, 4, 4, 12] == groups.iter().map(|g| g.len()).collect::<Vec<_>>()[..]
&& text.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
}
pub trait VideoProvider {
fn start(&self, req: &crate::video::VideoRequest) -> Result<String>;
fn poll(&self, operation: &str) -> Result<crate::video::VideoStatus>;
}
pub struct Retirement {
pub prefix: &'static str,
pub date: &'static str,
}
pub const RETIREMENTS: &[Retirement] = &[
Retirement { prefix: "imagen", date: "2026-08-17" },
Retirement { prefix: "gemini-3.1-flash-image-preview", date: "2026-06-25" },
Retirement { prefix: "gemini-3-pro-image-preview", date: "2026-06-25" },
Retirement { prefix: "veo-2.0", date: "2026-06-30" },
Retirement { prefix: "veo-3.0", date: "2026-06-30" },
Retirement { prefix: "gpt-image-1.5", date: "2026-12-01" },
Retirement { prefix: "gpt-image-1-mini", date: "2026-12-01" },
Retirement { prefix: "chatgpt-image-latest", date: "2026-12-01" },
];
pub fn retirement_note(model: &str) -> Option<String> {
let retirement = RETIREMENTS.iter().find(|r| model.starts_with(r.prefix))?;
let verb = if past(retirement.date) { "retired" } else { "retires" };
Some(format!("{verb} {}", retirement.date))
}
fn past(date: &str) -> bool {
crate::clock::unix_time(date).is_some_and(|midnight| crate::clock::now() >= midnight)
}
pub fn join_and(names: &[&str]) -> String {
match names.split_last() {
None => "no providers".to_string(),
Some((last, [])) => (*last).to_string(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
}
}
#[derive(Debug, Clone, Copy)]
pub struct Capabilities {
pub provider: &'static str,
pub tagline: &'static str,
pub aspect: AspectSupport,
pub size: bool,
pub seed: bool,
pub negative_prompt: bool,
pub references: bool,
pub mask: MaskSupport,
pub workflow: bool,
pub steps: bool,
pub guidance: bool,
pub provenance: Provenance,
}
impl Capabilities {
pub fn check(&self, req: &ImageRequest) -> Result<()> {
self.refuse(req)
.map_err(|e| anyhow::Error::new(crate::out::Refused(format!("{e:#}"))))
}
fn refuse(&self, req: &ImageRequest) -> Result<()> {
let me = self.provider;
if req.size.is_some() && !self.size {
bail!(
"`{me}` does not let you choose the output size, so `--size` cannot \
be honoured.\n\n\
The size is fixed by the provider and follows from the shape you \
ask for — `--aspect 16:9` on stability returns 2016x1152, for \
instance. Use `--aspect` to control the shape, and `comfyui` or \
`bfl` if the pixel count itself matters. Lucida reports the size \
it actually wrote."
);
}
if req.seed.is_some() && !self.seed {
bail!(
"`{me}` has no concept of a seed, so `--seed` cannot be honoured.\n\n\
Google never exposes one, which means results there are not \
reproducible by any means. Use `comfyui` (a local model, e.g. \
`--model klein`) or `bfl` when you need to render the same image \
twice."
);
}
if req.negative_prompt.is_some() && !self.negative_prompt {
let remedy = match me {
"google" => {
"Describe what you do want instead — Gemini responds to positive \
description far better than to exclusions."
}
"bfl" => {
"No FLUX endpoint accepts one — not flux-2-*, not flux-dev, not \
flux-pro-1.1. That is a limit of the hosted API rather than of \
Lucida: the local lane has a negative prompt only because \
ComfyUI builds the graph and can wire the conditioning itself."
}
_ => "This provider exposes no negative conditioning.",
};
bail!(
"`{me}` does not accept a negative prompt for images.\n\n{remedy}\n\n\
Use the `comfyui` provider if you need one — there it is a real \
conditioning input."
);
}
if req.steps.is_some() && !self.steps {
bail!("{}", self.no_sampler("--steps", "a step count"));
}
if req.guidance.is_some() && !self.guidance {
bail!("{}", self.no_sampler("--guidance", "a guidance scale"));
}
if req.workflow.is_some() && !self.workflow {
bail!(
"`{me}` has no workflow format, so `--workflow` cannot be \
honoured.\n\n\
A workflow is a provider-native description of how to render — \
only `comfyui` has one, because only there does Lucida build a \
graph rather than fill in a request."
);
}
if req.mask.is_some() {
if !self.mask.accepted() {
bail!(
"`{me}` does not accept a mask, so `--mask` cannot be \
honoured.\n\n\
Use one of: {}. What a mask guarantees differs between them, \
and that difference is usually the reason to prefer one:\n\n{}",
join_and(&mask_accepting_providers()),
mask_semantics()
);
}
if req.references.is_empty() {
bail!(
"`--mask` names which part of an image to change, but no image \
was given to change.\n\n\
Use `lucida edit <image> <prompt> --mask <mask.png>`."
);
}
}
if !req.references.is_empty() && !self.references {
let editors: Vec<&str> = Backend::ALL
.iter()
.filter(|b| capabilities_for(**b, b.default_model()).references)
.map(|b| b.name())
.collect();
bail!(
"`{me}` cannot condition on reference images, so there is nothing \
for it to edit.\n\n\
Generate from a prompt instead, or edit with one of: {}.",
editors.join(", ")
);
}
if let (Some(aspect), AspectSupport::Named(allowed)) = (req.aspect, self.aspect) {
let asked = aspect.to_string();
if !allowed.contains(&asked.as_str()) {
bail!(
"`{me}` supports only these aspect ratios: {}.\n\n\
`{asked}` is not among them. Either pick the nearest, or use \
the `comfyui` provider, which takes free dimensions.",
allowed.join(", ")
);
}
}
Ok(())
}
fn no_sampler(&self, flag: &str, what: &str) -> String {
match self.provider {
"bfl" => format!(
"this FLUX model does not expose {what}, so `{flag}` cannot be \
honoured.\n\n\
Within Black Forest Labs only `flux-2-flex` and `flux-dev` do — try \
`--model flux-2-flex`. The others decide sampling for themselves. \
`lucida models --provider bfl` marks which is which."
),
"google" => format!(
"`google` does not expose {what}; the model decides how to sample.\n\n\
`{flag}` applies to `comfyui`, and to `bfl` on `flux-2-flex` or \
`flux-dev`."
),
other => format!("`{other}` does not expose {what}, so `{flag}` cannot be honoured."),
}
}
}
pub trait ImageProvider {
fn generate(&self, req: &ImageRequest) -> Result<GeneratedImage>;
fn list_models(&self) -> Result<Vec<String>>;
}
pub fn capabilities_for(backend: Backend, model: &str) -> Capabilities {
match backend {
Backend::Google => crate::genai::CAPABILITIES,
Backend::ComfyUi => crate::comfy::CAPABILITIES,
Backend::Bfl => crate::bfl::capabilities(model),
Backend::Stability => crate::stability::capabilities(model),
Backend::OpenAi => crate::openai::capabilities(model),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Google,
ComfyUi,
Bfl,
Stability,
OpenAi,
}
impl Backend {
pub fn parse(text: &str) -> Result<Self> {
match text.trim().to_ascii_lowercase().as_str() {
"google" | "gemini" => Ok(Self::Google),
"comfyui" | "comfy" | "local" => Ok(Self::ComfyUi),
"bfl" | "flux" | "blackforestlabs" => Ok(Self::Bfl),
"stability" | "stabilityai" | "sai" => Ok(Self::Stability),
"openai" | "oai" | "gpt" => Ok(Self::OpenAi),
other => bail!(
"unknown provider `{other}`. Known providers: {}",
Backend::ALL
.iter()
.map(|b| b.name())
.collect::<Vec<_>>()
.join(", ")
),
}
}
pub fn name(self) -> &'static str {
match self {
Self::Google => "google",
Self::ComfyUi => "comfyui",
Self::Bfl => "bfl",
Self::Stability => "stability",
Self::OpenAi => "openai",
}
}
#[cfg(test)]
pub fn product_name(self) -> &'static str {
match self {
Self::Google => "Gemini",
Self::ComfyUi => "ComfyUI",
Self::Bfl => "FLUX",
Self::Stability => "Stability",
Self::OpenAi => "OpenAI",
}
}
#[cfg(test)]
pub fn video_product_name(backend: VideoBackend) -> &'static str {
match backend {
VideoBackend::Google => "Veo",
VideoBackend::Runway => "Runway",
VideoBackend::Kling => "Kling",
}
}
pub fn default_model(self) -> &'static str {
match self {
Self::Google => crate::genai::DEFAULT_MODEL,
Self::ComfyUi => "klein",
Self::Bfl => crate::bfl::DEFAULT_MODEL,
Self::Stability => crate::stability::DEFAULT_MODEL,
Self::OpenAi => crate::openai::DEFAULT_MODEL,
}
}
pub const ALL: &'static [Backend] = &[
Backend::Google,
Backend::ComfyUi,
Backend::Bfl,
Backend::Stability,
Backend::OpenAi,
];
pub fn credential(self) -> Option<&'static str> {
match self {
Self::Google => Some("GEMINI_API_KEY"),
Self::ComfyUi => None,
Self::Bfl => Some("BFL_API_KEY"),
Self::Stability => Some("STABILITY_API_KEY"),
Self::OpenAi => Some("OPENAI_API_KEY"),
}
}
pub fn is_available(self) -> bool {
match self.credential() {
None => true,
Some(key) => crate::config::var(key).is_some(),
}
}
}
pub fn infer_backend(model: &str) -> Backend {
let key = model.trim().to_ascii_lowercase();
if crate::comfy::MODEL_ALIASES.iter().any(|(a, _)| *a == key) {
return Backend::ComfyUi;
}
if crate::stability::MODEL_ALIASES.iter().any(|(a, _)| *a == key)
|| crate::stability::KNOWN_MODELS.contains(&key.as_str())
|| crate::stability::SD3_VARIANTS.contains(&key.as_str())
{
return Backend::Stability;
}
if crate::openai::MODEL_ALIASES.iter().any(|(a, _)| *a == key)
|| crate::openai::KNOWN_MODELS.contains(&key.as_str())
{
return Backend::OpenAi;
}
if crate::bfl::MODEL_ALIASES.iter().any(|(a, _)| *a == key)
|| crate::bfl::KNOWN_MODELS.contains(&key.as_str())
{
return Backend::Bfl;
}
if key.ends_with(".safetensors") || key.ends_with(".gguf") {
return Backend::ComfyUi;
}
if key.starts_with("flux-") {
return Backend::Bfl;
}
Backend::Google
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DefaultSource {
Preference {
setting: &'static str,
position: usize,
of: usize,
},
BuiltIn,
}
impl DefaultSource {
pub fn describe(&self, chosen: &str) -> String {
match self {
Self::Preference {
setting,
position,
of,
} => format!("{chosen} (choice {position} of {of} in {setting})"),
Self::BuiltIn => format!("{chosen} (built-in default; no preference set)"),
}
}
pub fn tag(&self) -> &'static str {
match self {
Self::Preference { setting, .. } => setting,
Self::BuiltIn => "built-in",
}
}
}
pub trait Preferred: Copy + Sized + 'static {
const SETTING: &'static str;
const BUILT_IN: Self;
fn parse_name(text: &str) -> Result<Self>;
fn provider_name(self) -> &'static str;
fn credential_setting(self) -> Option<&'static str>;
fn available(self) -> bool;
fn every() -> &'static [Self];
}
impl Preferred for Backend {
const SETTING: &'static str = "LUCIDA_IMAGE_PROVIDERS";
const BUILT_IN: Self = Backend::Google;
fn parse_name(text: &str) -> Result<Self> {
Self::parse(text)
}
fn provider_name(self) -> &'static str {
self.name()
}
fn credential_setting(self) -> Option<&'static str> {
self.credential()
}
fn available(self) -> bool {
self.is_available()
}
fn every() -> &'static [Self] {
Self::ALL
}
}
impl Preferred for VideoBackend {
const SETTING: &'static str = "LUCIDA_VIDEO_PROVIDERS";
const BUILT_IN: Self = VideoBackend::Google;
fn parse_name(text: &str) -> Result<Self> {
Self::parse(text)
}
fn provider_name(self) -> &'static str {
self.name()
}
fn credential_setting(self) -> Option<&'static str> {
self.credential()
}
fn available(self) -> bool {
self.is_available()
}
fn every() -> &'static [Self] {
Self::ALL
}
}
fn preference_list<T: Preferred>() -> Result<Option<Vec<T>>> {
let Some(raw) = crate::config::var(T::SETTING) else {
return Ok(None);
};
let mut chain = Vec::new();
for entry in raw.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let parsed = T::parse_name(entry).map_err(|e| {
anyhow::anyhow!(
"{setting} lists `{entry}`, which is not a provider.\n\n{e}\n\n\
Fix the list rather than leaving it: a name nothing recognises \
would otherwise hand the render to whichever provider came \
next, which is not what you wrote down.",
setting = T::SETTING,
)
})?;
chain.push(parsed);
}
if chain.is_empty() {
return Ok(None);
}
Ok(Some(chain))
}
pub fn resolve_default<T: Preferred>() -> Result<(T, DefaultSource)> {
let Some(chain) = preference_list::<T>()? else {
return Ok((T::BUILT_IN, DefaultSource::BuiltIn));
};
let of = chain.len();
for (index, candidate) in chain.iter().copied().enumerate() {
if candidate.available() {
return Ok((
candidate,
DefaultSource::Preference {
setting: T::SETTING,
position: index + 1,
of,
},
));
}
}
let missing = chain
.iter()
.map(|c| match c.credential_setting() {
Some(key) => format!(" {} needs {key}", c.provider_name()),
None => format!(" {} needs no credential", c.provider_name()),
})
.collect::<Vec<_>>()
.join("\n");
let elsewhere = T::every()
.iter()
.copied()
.filter(|c| c.available() && !chain.iter().any(|listed| listed.provider_name() == c.provider_name()))
.map(|c| c.provider_name())
.collect::<Vec<_>>();
let aside = if elsewhere.is_empty() {
String::new()
} else {
format!(
"\n\nYou do have credentials for {}, which {setting} does not list.",
join_and(&elsewhere),
setting = T::SETTING,
)
};
bail!(
"no provider in {setting} has a credential configured.\n\n{missing}{aside}\n\n\
Set one of those keys, name a provider explicitly, or clear {setting} to \
return to the built-in default ({built_in}).",
setting = T::SETTING,
built_in = T::BUILT_IN.provider_name(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_provider_credential_is_a_setting_lucida_knows() {
let known: Vec<&str> = crate::config::KNOWN_KEYS.iter().map(|(k, _)| *k).collect();
for backend in Backend::ALL {
if let Some(key) = backend.credential() {
assert!(
known.contains(&key),
"image provider {} wants {key}, which is not in KNOWN_KEYS — \
it can never be available and `lucida config` will never name it",
backend.name()
);
}
}
for backend in VideoBackend::ALL {
if let Some(key) = backend.credential() {
assert!(
known.contains(&key),
"video provider {} wants {key}, which is not in KNOWN_KEYS",
backend.name()
);
}
}
}
#[test]
fn both_preference_settings_are_known_settings() {
let known: Vec<&str> = crate::config::KNOWN_KEYS.iter().map(|(k, _)| *k).collect();
for setting in [
<Backend as Preferred>::SETTING,
<VideoBackend as Preferred>::SETTING,
] {
assert!(
known.contains(&setting),
"{setting} decides which provider a render uses and is not in KNOWN_KEYS"
);
}
}
#[test]
fn each_video_provider_supplies_its_own_default_model() {
let mut seen = std::collections::BTreeSet::new();
for backend in VideoBackend::ALL {
let model = backend.default_model();
assert!(!model.trim().is_empty(), "{} has no default", backend.name());
assert!(
seen.insert(model),
"`{model}` is the default for two video providers"
);
assert_eq!(
infer_video_backend(model),
*backend,
"`{model}` is {}'s default but infers as another provider",
backend.name()
);
}
}
#[test]
fn an_operation_id_routes_to_the_provider_that_issued_it() {
assert_eq!(
infer_video_backend_from_operation("operations/abc123"),
VideoBackend::Google
);
assert_eq!(
infer_video_backend_from_operation("4f1a2b3c-0000-4000-8000-000000000000"),
VideoBackend::Runway
);
assert_eq!(
infer_video_backend_from_operation("915468728228253726"),
VideoBackend::Kling
);
}
#[test]
fn every_backend_names_a_default_model() {
let mut seen = std::collections::BTreeSet::new();
for backend in Backend::ALL {
let model = backend.default_model();
assert!(
!model.trim().is_empty(),
"{} has no default model",
backend.name()
);
assert!(
seen.insert(model),
"`{model}` is the default for two providers, so `--model` alone \
cannot say which was meant"
);
}
}
#[test]
fn every_capability_is_answerable_without_a_client() {
for backend in Backend::ALL {
for model in ["", "nonsense", backend.default_model()] {
let caps = capabilities_for(*backend, model);
assert!(
!caps.provider.is_empty() && !caps.tagline.is_empty(),
"{} answered emptily for `{model}`",
backend.name()
);
}
}
}
#[test]
fn a_retirement_note_reads_correctly_on_both_sides_of_its_date() {
let note = retirement_note("imagen-4.0-generate-001").expect("imagen retires");
assert!(note.contains("2026-08-17"), "{note}");
assert_eq!(note.starts_with("retired"), past("2026-08-17"), "{note}");
assert!(retirement_note("gpt-image-1.5").is_some());
assert!(retirement_note("chatgpt-image-latest").is_some());
}
#[test]
fn the_current_defaults_carry_no_retirement_note() {
for backend in Backend::ALL {
let model = backend.default_model();
assert_eq!(
retirement_note(model),
None,
"`{model}` is a default and is marked as retiring"
);
}
assert_eq!(retirement_note("gpt-image-1"), None);
for backend in VideoBackend::ALL {
let model = backend.default_model();
assert_eq!(
retirement_note(model),
None,
"`{model}` is a video default and is marked as retiring"
);
}
for ga in ["gemini-3.1-flash-image", "gemini-3-pro-image"] {
assert_eq!(
retirement_note(ga),
None,
"`{ga}` is current and its preview twin's prefix has caught it"
);
assert!(
retirement_note(&format!("{ga}-preview")).is_some(),
"`{ga}-preview` is retired and carries no note"
);
}
}
#[test]
fn every_retirement_matches_a_model_the_provider_lists() {
for retirement in RETIREMENTS {
assert!(
crate::clock::unix_time(retirement.date).is_some(),
"`{}` has an unparseable date: {}",
retirement.prefix,
retirement.date
);
assert!(
retirement_note(retirement.prefix).is_some(),
"`{}` matches no model id, not even its own prefix",
retirement.prefix
);
}
}
#[test]
fn aspect_round_trips_through_its_written_form() {
assert_eq!(Aspect::parse("16:9").unwrap().to_string(), "16:9");
assert!(Aspect::parse("16x9").is_err());
assert!(Aspect::parse("16:0").is_err());
}
#[test]
fn size_accepts_tiers_and_raw_pixels() {
assert_eq!(Size::parse("2k").unwrap(), Size::TWO_K);
assert_eq!(Size::parse("1536").unwrap(), Size(1536));
assert!(Size::parse("8").is_err());
}
#[test]
fn pixels_put_the_named_size_on_the_long_edge() {
let req = ImageRequest {
aspect: Some(Aspect::parse("16:9").unwrap()),
size: Some(Size::ONE_K),
..Default::default()
};
assert_eq!(req.pixels((1024, 1024), 16), (1024, 576));
let portrait = ImageRequest {
aspect: Some(Aspect::parse("9:16").unwrap()),
size: Some(Size::ONE_K),
..Default::default()
};
assert_eq!(portrait.pixels((1024, 1024), 16), (576, 1024));
}
#[test]
fn pixels_round_to_the_latent_grid() {
let req = ImageRequest {
aspect: Some(Aspect::parse("3:2").unwrap()),
size: Some(Size::ONE_K),
..Default::default()
};
let (w, h) = req.pixels((1024, 1024), 16);
assert_eq!((w, h), (1024, 688));
assert_eq!(h % 16, 0);
}
#[test]
fn an_empty_request_gets_the_providers_default() {
let req = ImageRequest::default();
assert_eq!(req.pixels((1024, 1024), 16), (1024, 1024));
}
#[test]
fn check_rejects_a_seed_the_provider_cannot_honour() {
let caps = Capabilities {
seed: false,
..crate::comfy::CAPABILITIES
};
let req = ImageRequest {
seed: Some(7),
..Default::default()
};
let error = caps.check(&req).unwrap_err().to_string();
assert!(error.contains("comfyui"), "the message must name a way forward: {error}");
}
#[test]
fn a_provider_without_size_control_rejects_size() {
let caps = capabilities_for(Backend::Stability, "core");
assert!(!caps.size);
let req = ImageRequest {
size: Some(Size::TWO_K),
..Default::default()
};
let error = caps.check(&req).unwrap_err().to_string();
assert!(error.contains("--size"));
assert!(error.contains("--aspect"), "must name what it does support");
for backend in [Backend::Google, Backend::ComfyUi, Backend::Bfl] {
assert!(capabilities_for(backend, "").size, "{backend:?} should take a size");
}
}
#[test]
fn backends_are_inferred_from_the_model_id() {
assert_eq!(infer_backend("banana"), Backend::Google);
assert_eq!(infer_backend("core"), Backend::Stability);
assert_eq!(infer_backend("ultra"), Backend::Stability);
assert_eq!(infer_backend("sd3.5-flash"), Backend::Stability);
assert_eq!(infer_backend("gpt-image-2"), Backend::OpenAi);
assert_eq!(infer_backend("gemini-3.1-flash-image"), Backend::Google);
assert_eq!(infer_backend("klein"), Backend::ComfyUi);
assert_eq!(infer_backend("some-model.safetensors"), Backend::ComfyUi);
assert_eq!(infer_backend("flux-2-pro"), Backend::Bfl);
assert_eq!(infer_backend("flux-max"), Backend::Bfl);
assert_eq!(infer_backend("gemini-9-image"), Backend::Google);
assert_eq!(infer_backend("flux-3-pro"), Backend::Bfl);
}
#[test]
fn hosted_model_ids_reach_the_wire_lowercased() {
assert_eq!(crate::stability::resolve_model("CORE"), "core");
assert_eq!(crate::openai::resolve_model("GPT-IMAGE-2"), "gpt-image-2");
assert_eq!(crate::bfl::resolve_model("FLUX-2-PRO"), "flux-2-pro");
assert_eq!(
crate::genai::resolve_model("GEMINI-3.1-FLASH-IMAGE"),
"gemini-3.1-flash-image"
);
assert_eq!(crate::bfl::resolve_model("FLUX"), crate::bfl::resolve_model("flux"));
}
#[test]
fn the_mask_paragraph_covers_every_masking_provider_and_names_its_kind() {
let text = mask_semantics();
for backend in Backend::ALL {
let caps = capabilities_for(*backend, backend.default_model());
if caps.mask.accepted() {
assert!(
text.contains(backend.name()),
"{} masks but is missing from the generated paragraph: {text}",
backend.name()
);
assert!(text.contains(caps.mask.kind()), "{text}");
}
}
let accepting = mask_accepting_providers();
assert_eq!(accepting.first(), Some(&"comfyui"), "{accepting:?}");
}
#[test]
fn refusing_a_mask_names_the_provider_whose_mask_binds() {
let caps = capabilities_for(Backend::Google, "");
let req = ImageRequest {
mask: Some("mask.png".into()),
..Default::default()
};
let error = caps.check(&req).unwrap_err().to_string();
assert!(error.contains("comfyui"), "{error}");
assert!(error.contains("binding"), "{error}");
}
#[test]
fn a_mask_without_a_reference_image_says_so() {
let caps = capabilities_for(Backend::OpenAi, "gpt-image-2");
let req = ImageRequest {
mask: Some("mask.png".into()),
..Default::default()
};
let error = caps.check(&req).unwrap_err().to_string();
assert!(error.contains("no image"), "{error}");
}
#[test]
fn local_and_hosted_flux_names_do_not_collide() {
assert_eq!(infer_backend("flux2"), Backend::ComfyUi);
assert_eq!(infer_backend("flux-2"), Backend::ComfyUi);
assert_eq!(infer_backend("flux2-klein"), Backend::ComfyUi);
assert_eq!(infer_backend("flux-2-klein-9b"), Backend::Bfl);
assert_eq!(infer_backend("flux-2-pro.safetensors"), Backend::ComfyUi);
}
}