use crate::provider::{
AspectSupport, Capabilities, GeneratedImage, ImageProvider, ImageRequest, MaskSupport,
Provenance,
};
use anyhow::{Context, Result, anyhow, bail};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
const API_ROOT: &str = "https://api.bfl.ai/v1";
pub const DEFAULT_MODEL: &str = "flux-2-pro";
const PIXEL_GRID: u32 = 32;
const DEFAULT_DIMENSIONS: (u32, u32) = (1024, 1024);
const MAX_REFERENCES: usize = 8;
pub const MODEL_ALIASES: &[(&str, &str)] = &[
("bfl", "flux-2-pro"),
("flux", "flux-2-pro"),
("flux-pro", "flux-2-pro"),
("flux-max", "flux-2-max"),
("flux-flex", "flux-2-flex"),
("flux-klein", "flux-2-klein-9b"),
("flux-1.1", "flux-pro-1.1"),
];
pub fn resolve_model(input: &str) -> String {
let key = input.trim().to_ascii_lowercase();
MODEL_ALIASES
.iter()
.find(|(alias, _)| *alias == key)
.map(|(_, id)| (*id).to_string())
.unwrap_or(key)
}
pub fn capabilities(model: &str) -> Capabilities {
let id = resolve_model(model);
let tunable = matches!(id.as_str(), "flux-2-flex" | "flux-dev");
let edits = id.starts_with("flux-2") || id.starts_with("flux-kontext");
Capabilities {
provider: "bfl",
tagline: "Hosted FLUX. Paid, fast, edits well. The only provider whose capabilities differ per MODEL: steps and guidance exist on flux-2-flex and flux-dev alone.",
aspect: AspectSupport::Free {
multiple_of: PIXEL_GRID,
},
size: true,
seed: true,
negative_prompt: false,
references: edits,
mask: MaskSupport::No,
workflow: false,
steps: tunable,
guidance: tunable,
provenance: Provenance::C2paOnly,
}
}
pub struct Client {
key: String,
http: reqwest::blocking::Client,
base: String,
}
impl Client {
pub fn from_env() -> Result<Self> {
let key = crate::config::var("BFL_API_KEY").ok_or_else(|| {
let where_to_put_it = match crate::config::preferred_path() {
Some(path) => format!(
"Set BFL_API_KEY, or add it to {} — `lucida config --set BFL_API_KEY` \
reads it from stdin so it stays out of your shell history.",
path.display()
),
None => "Set BFL_API_KEY.".to_string(),
};
anyhow!(
"no Black Forest Labs API key found.\n\n{where_to_put_it}\n\n\
Keys come from https://dashboard.bfl.ai — this is a paid API and \
every render costs credits."
)
})?;
let http = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(180))
.connect_timeout(crate::retry::CONNECT_TIMEOUT)
.build()
.context("building HTTP client")?;
Ok(Self {
key,
http,
base: API_ROOT.to_string(),
})
}
fn body(&self, req: &ImageRequest, model: &str) -> Result<Value> {
let mut body = serde_json::Map::new();
body.insert("prompt".into(), json!(req.prompt));
let asked_for_dimensions = req.aspect.is_some() || req.size.is_some();
if asked_for_dimensions || req.references.is_empty() {
let (width, height) = req.pixels(DEFAULT_DIMENSIONS, PIXEL_GRID);
body.insert("width".into(), json!(width));
body.insert("height".into(), json!(height));
}
body.insert("output_format".into(), json!("png"));
if let Some(seed) = req.seed {
body.insert("seed".into(), json!(seed));
}
if let Some(steps) = req.steps {
body.insert("steps".into(), json!(steps));
}
if let Some(guidance) = req.guidance {
body.insert("guidance".into(), json!(guidance));
}
if req.references.len() > MAX_REFERENCES {
bail!(
"`{model}` accepts at most {MAX_REFERENCES} reference images; {} were \
given.\n\n\
Note the FLUX.2 klein endpoints accept only 4, so a request the \
limit here allows may still be rejected by those.",
req.references.len()
);
}
for (index, reference) in req.references.iter().enumerate() {
let field = match index {
0 => "input_image".to_string(),
n => format!("input_image_{}", n + 1),
};
body.insert(field, json!(encode_reference(reference)?));
}
Ok(Value::Object(body))
}
fn submit(&self, req: &ImageRequest, model: &str) -> Result<(String, Option<f64>)> {
let response = self
.http
.post(format!("{}/{model}", self.base))
.header("x-key", &self.key)
.json(&self.body(req, model)?)
.send()
.context("calling the Black Forest Labs API")?;
let status = response.status();
if !status.is_success() {
let text = response.text().unwrap_or_default();
bail!("{}", explain_error(status.as_u16(), &text, model));
}
let payload: Value = response.json().context("parsing the submit response")?;
let polling_url = payload["polling_url"]
.as_str()
.map(str::to_string)
.or_else(|| {
payload["id"]
.as_str()
.map(|id| format!("{}/get_result?id={id}", self.base))
})
.ok_or_else(|| anyhow!("the API accepted the job but returned no id: {payload}"))?;
Ok((polling_url, payload["cost"].as_f64()))
}
fn await_image(&self, polling_url: &str) -> Result<Vec<u8>> {
let started = Instant::now();
let deadline = Duration::from_secs(600);
let mut interval = Duration::from_millis(1000);
let mut announced = String::new();
loop {
crate::cancel::check()?;
if started.elapsed() > deadline {
bail!(
"gave up after {} minutes. The job may still complete; its \
polling URL was {polling_url}",
deadline.as_secs() / 60
);
}
std::thread::sleep(interval);
interval = (interval * 2).min(Duration::from_secs(3));
let response = crate::retry::send_idempotent("polling the render", || {
self.http.get(polling_url).header("x-key", &self.key)
})
.context("polling the render")?;
let status = response.status();
if !status.is_success() {
let text = response.text().unwrap_or_default();
if status.as_u16() == 404 {
bail!(
"the render is no longer available at its polling URL — \
results expire shortly after completion. Submit the \
render again.\n\nOriginal message: {}",
text.trim()
);
}
bail!("{}", explain_error(status.as_u16(), &text, "get_result"));
}
let payload: Value = response.json().context("parsing the poll response")?;
let state = payload["status"].as_str().unwrap_or_default();
match state {
"Ready" => {
eprintln!("Render finished in {}s.", started.elapsed().as_secs());
return self.download(&payload);
}
"Request Moderated" => bail!(
"the prompt was rejected by content moderation before rendering.\n\n\
Rephrase it, or raise `safety_tolerance` if the subject is \
legitimate. Nothing was charged for a moderated request."
),
"Content Moderated" => bail!(
"the image was rendered but rejected by output moderation, so it \
cannot be retrieved. Rephrasing usually clears it."
),
"Error" => bail!(
"the render failed: {}",
payload["details"]
.as_str()
.or_else(|| payload["details"]["error"].as_str())
.unwrap_or(&payload["details"].to_string())
),
"Task not found" => bail!(
"the API no longer knows about this job. Results expire, so a \
long-delayed poll can see this."
),
other => {
if other != announced {
let progress = payload["progress"]
.as_f64()
.map(|p| format!(" ({:.0}%)", p * 100.0))
.unwrap_or_default();
eprintln!(" {other}{progress}…");
announced = other.to_string();
}
}
}
}
}
fn rescue(url: &str) -> String {
format!(
"The render finished and was billed. Its URL is signed and expires \
about 10 minutes after the render completed — fetch it by hand \
while it lasts:\n\n {url}"
)
}
fn download(&self, payload: &Value) -> Result<Vec<u8>> {
let url = payload["result"]["sample"]
.as_str()
.ok_or_else(|| anyhow!("the render is ready but carries no image: {payload}"))?;
let response = crate::retry::send_idempotent("downloading the image", || self.http.get(url))
.with_context(|| format!("downloading the finished image.\n\n{}", Self::rescue(url)))?;
if !response.status().is_success() {
bail!(
"the image URL returned HTTP {}.\n\n{}",
response.status().as_u16(),
Self::rescue(url)
);
}
Ok(response.bytes().context("reading image bytes")?.to_vec())
}
}
impl ImageProvider for Client {
fn generate(&self, req: &ImageRequest) -> Result<GeneratedImage> {
let model = resolve_model(&req.model);
let stated = req.aspect.is_some() || req.size.is_some();
let shape = if stated || req.references.is_empty() {
let (width, height) = req.pixels(DEFAULT_DIMENSIONS, PIXEL_GRID);
format!("{width}x{height}")
} else {
"at the source's shape".to_string()
};
let verb = if req.references.is_empty() {
"Rendering"
} else {
"Editing"
};
eprintln!("{verb} {shape} with {model}…");
let (polling_url, cost) = self.submit(req, &model)?;
if let Some(cost) = cost {
eprintln!(" cost: {cost} credits");
}
let bytes = self.await_image(&polling_url)?;
Ok(GeneratedImage {
bytes,
mime_type: "image/png".to_string(),
commentary: None,
seed: req.seed,
})
}
fn list_models(&self) -> Result<Vec<String>> {
let response = crate::retry::send_idempotent("checking the key", || {
self.http
.get(format!("{}/credits", self.base))
.header("x-key", &self.key)
})
.context("checking the API key against /v1/credits")?;
let status = response.status();
if !status.is_success() {
let text = response.text().unwrap_or_default();
bail!("{}", explain_error(status.as_u16(), &text, "credits"));
}
if let Ok(payload) = response.json::<Value>()
&& let Some(credits) = payload["credits"].as_f64()
{
eprintln!("Key is valid. Remaining credits: {credits}");
}
Ok(KNOWN_MODELS.iter().map(|m| (*m).to_string()).collect())
}
}
pub const KNOWN_MODELS: &[&str] = &[
"flux-2-pro",
"flux-2-max",
"flux-2-flex",
"flux-2-klein-9b",
"flux-2-klein-4b",
"flux-pro-1.1",
"flux-pro-1.1-ultra",
"flux-dev",
"flux-kontext-pro",
"flux-kontext-max",
];
fn encode_reference(reference: &str) -> Result<String> {
if reference.starts_with("http://") || reference.starts_with("https://") {
return Ok(reference.to_string());
}
let bytes = std::fs::read(reference)
.with_context(|| format!("reading the reference image {reference}"))?;
Ok(STANDARD.encode(&bytes))
}
pub fn explain_error(status: u16, body: &str, model: &str) -> String {
let parsed: Value = serde_json::from_str(body).unwrap_or(Value::Null);
let detail = parsed["detail"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| {
if parsed["detail"].is_null() {
body.trim().to_string()
} else {
parsed["detail"].to_string()
}
});
if detail.to_ascii_lowercase().contains("api key") {
return format!(
"HTTP {status} — the Black Forest Labs API key was not accepted: {detail}\n\n\
Note this arrives as a {status} rather than a 401. Check BFL_API_KEY, or \
`lucida config` to see which one this process can actually read. Keys \
come from https://dashboard.bfl.ai."
);
}
match status {
401 | 403 => format!(
"HTTP {status} — the Black Forest Labs API key was rejected.\n\n\
Check BFL_API_KEY (or `lucida config`). Keys come from \
https://dashboard.bfl.ai.\n\nOriginal message: {detail}"
),
402 => format!(
"HTTP 402 — out of credits. Top up at https://dashboard.bfl.ai.\n\n\
Original message: {detail}"
),
404 => format!(
"HTTP 404 — no such endpoint as `{model}`.\n\n\
Run `lucida models --provider bfl` for the ones Lucida knows about. \
Model ids here are URL paths, so a typo looks exactly like this."
),
422 => format!(
"HTTP 422 — the API rejected a parameter for `{model}`.\n\n\
Endpoints in this family differ: `steps` and `guidance` exist only on \
flux-2-flex and flux-dev, and no FLUX model takes a negative prompt.\n\n\
Original message: {detail}"
),
429 => format!(
"HTTP 429 — too many active requests. BFL limits how many renders can \
be in flight at once; wait for one to finish.\n\nOriginal message: {detail}"
),
_ => format!("HTTP {status} — {detail}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::Aspect;
#[test]
fn only_flex_and_dev_expose_the_sampler() {
assert!(!capabilities("flux-2-pro").steps);
assert!(!capabilities("flux-2-max").steps);
assert!(capabilities("flux-2-flex").steps);
assert!(capabilities("flux-2-flex").guidance);
assert!(capabilities("flux-dev").steps);
assert!(!capabilities("flux").steps);
assert!(capabilities("flux-flex").steps);
}
#[test]
fn no_flux_endpoint_takes_a_negative_prompt() {
for model in KNOWN_MODELS {
assert!(
!capabilities(model).negative_prompt,
"{model} must not claim a negative prompt"
);
}
}
#[test]
fn style_conditioning_models_do_not_claim_to_edit() {
assert!(capabilities("flux-2-pro").references);
assert!(capabilities("flux-kontext-pro").references);
assert!(!capabilities("flux-pro-1.1").references);
assert!(!capabilities("flux-dev").references);
}
#[test]
fn provenance_is_c2pa_without_a_pixel_watermark() {
assert_eq!(capabilities("flux-2-pro").provenance, Provenance::C2paOnly);
assert_ne!(capabilities("flux-2-pro").provenance, Provenance::Unmarked);
}
#[test]
fn references_become_numbered_fields() {
assert_eq!(encode_reference("https://example.com/a.png").unwrap(),
"https://example.com/a.png");
}
#[test]
fn an_edit_sends_no_dimensions_unless_asked() {
let client = Client {
key: "x".into(),
http: reqwest::blocking::Client::new(),
base: API_ROOT.into(),
};
let edit = ImageRequest {
references: vec!["https://example.com/a.png".into()],
..Default::default()
};
let body = client.body(&edit, "flux-2-pro").unwrap();
assert!(body.get("width").is_none(), "an edit must not force a size");
assert_eq!(body["input_image"], "https://example.com/a.png");
let reframed = ImageRequest {
aspect: Some(Aspect::parse("1:1").unwrap()),
..edit
};
assert_eq!(client.body(&reframed, "flux-2-pro").unwrap()["width"], 1024);
let fresh = ImageRequest::default();
assert_eq!(client.body(&fresh, "flux-2-pro").unwrap()["width"], 1024);
}
#[test]
fn dimensions_land_on_the_32_pixel_grid() {
let req = ImageRequest {
aspect: Some(Aspect::parse("3:2").unwrap()),
..Default::default()
};
let (w, h) = req.pixels(DEFAULT_DIMENSIONS, PIXEL_GRID);
assert_eq!(w % 32, 0);
assert_eq!(h % 32, 0);
}
#[test]
fn a_mistyped_model_is_explained_as_a_path() {
let message = explain_error(404, "{}", "flux-2-prooo");
assert!(message.contains("no such endpoint"));
assert!(message.contains("lucida models --provider bfl"));
}
#[test]
fn a_bad_key_is_recognised_even_though_it_arrives_as_a_422() {
let message = explain_error(422, r#"{"detail":"Invalid API key format"}"#, "credits");
assert!(message.contains("API key was not accepted"));
assert!(message.contains("lucida config"));
assert!(!message.contains("flux-2-flex"), "must not be read as a parameter problem");
}
#[test]
fn parameter_rejection_names_the_models_that_would_accept_it() {
let message = explain_error(422, r#"{"detail":"steps not permitted"}"#, "flux-2-pro");
assert!(message.contains("flux-2-flex"));
}
use crate::provider::ImageProvider;
use crate::testserver::{Reply, serve};
fn wired(server: &crate::testserver::Server) -> Client {
Client {
key: "test-key".into(),
base: server.url().to_string(),
http: reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(crate::retry::CONNECT_TIMEOUT)
.no_proxy()
.build()
.unwrap(),
}
}
#[test]
fn the_signed_download_url_never_receives_the_api_key() {
let submit = r#"{"id":"abc","polling_url":"{{server}}/v1/get_result?id=abc","cost":0.06}"#;
let ready = r#"{"status":"Ready","result":{"sample":"{{server}}/delivery/img.png"}}"#;
let server = serve(vec![
Reply::json(submit),
Reply::json(ready),
Reply::bytes("image/png", b"png-bytes"),
]);
let request = ImageRequest {
prompt: "a fox".into(),
model: "flux-2-pro".into(),
seed: Some(7),
..Default::default()
};
let image = wired(&server).generate(&request).unwrap();
assert_eq!(image.bytes, b"png-bytes");
assert_eq!(image.seed, Some(7), "the pinned seed is reported back");
let requests = server.finish();
assert_eq!(requests.len(), 3);
assert_eq!(requests[0].method, "POST");
assert_eq!(requests[0].path, "/flux-2-pro", "the model id is the path under /v1");
assert_eq!(requests[0].header("x-key"), Some("test-key"));
let body = requests[0].json();
assert_eq!(body["prompt"], "a fox");
assert_eq!(body["seed"], 7);
assert_eq!(body["width"], 1024, "generation always states its dimensions");
assert_eq!(body["output_format"], "png");
assert_eq!(requests[1].path, "/v1/get_result?id=abc");
assert_eq!(requests[1].header("x-key"), Some("test-key"));
assert_eq!(requests[2].path, "/delivery/img.png");
assert_eq!(requests[2].header("x-key"), None);
}
#[test]
fn a_moderated_prompt_stops_the_poll_with_a_clear_verdict() {
let submit = r#"{"id":"abc","polling_url":"{{server}}/v1/get_result?id=abc"}"#;
let moderated = r#"{"status":"Request Moderated"}"#;
let server = serve(vec![Reply::json(submit), Reply::json(moderated)]);
let request = ImageRequest {
prompt: "a fox".into(),
model: "flux-2-pro".into(),
..Default::default()
};
let error = wired(&server).generate(&request).unwrap_err().to_string();
assert!(error.contains("content moderation"), "{error}");
assert!(error.contains("Nothing was charged"));
assert_eq!(server.finish().len(), 2, "no further polling after a terminal state");
}
#[test]
fn running_out_of_credits_names_the_dashboard() {
let server = serve(vec![Reply::status(402, r#"{"detail":"Not enough credits"}"#)]);
let request = ImageRequest {
prompt: "a fox".into(),
model: "flux-2-pro".into(),
..Default::default()
};
let error = wired(&server).generate(&request).unwrap_err().to_string();
assert!(error.contains("out of credits"), "{error}");
assert!(error.contains("dashboard.bfl.ai"));
}
}