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::path::Path;
use std::time::Duration;
const API_ROOT: &str = "https://api.openai.com/v1";
pub const DEFAULT_MODEL: &str = "gpt-image-2";
const PIXEL_GRID: u32 = 16;
const TARGET_AREA: u32 = 1024 * 1024;
pub const ASPECT_RATIOS: &[&str] = &["1:1", "2:3", "3:2"];
const DEFAULT_QUALITY: &str = "medium";
pub const MODEL_ALIASES: &[(&str, &str)] = &[
("openai", "gpt-image-2"),
("gpt-image", "gpt-image-2"),
("oai", "gpt-image-2"),
];
pub const KNOWN_MODELS: &[&str] = &[
"gpt-image-2",
"chatgpt-image-latest",
"gpt-image-1.5",
"gpt-image-1-mini",
"gpt-image-1",
];
fn free_dimensions(model: &str) -> bool {
model == "gpt-image-2"
}
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 free = free_dimensions(&id);
Capabilities {
provider: "openai",
tagline: "gpt-image. Paid, seconds, and the fastest way to mask an edit — \
no local server, no model download. No seed and no negative \
prompt at all; gpt-image-2 takes free dimensions while the rest \
take three fixed sizes.",
aspect: if free {
AspectSupport::Free {
multiple_of: PIXEL_GRID,
}
} else {
AspectSupport::Named(ASPECT_RATIOS)
},
size: free,
seed: false,
negative_prompt: false,
references: true,
mask: MaskSupport::Advisory,
workflow: false,
steps: false,
guidance: false,
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("OPENAI_API_KEY").ok_or_else(|| {
let where_to_put_it = match crate::config::preferred_path() {
Some(path) => format!(
"Set OPENAI_API_KEY, or add it to {} — \
`lucida config --set OPENAI_API_KEY` prompts for it and shows \
asterisks rather than the value.",
path.display()
),
None => "Set OPENAI_API_KEY.".to_string(),
};
anyhow!("no OpenAI API key found.\n\n{where_to_put_it}")
})?;
let http = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(300))
.connect_timeout(crate::retry::CONNECT_TIMEOUT)
.build()
.context("building HTTP client")?;
Ok(Self {
key,
http,
base: API_ROOT.to_string(),
})
}
fn implied_aspect(req: &ImageRequest) -> Option<crate::provider::Aspect> {
if req.aspect.is_some() || req.references.is_empty() {
return req.aspect;
}
let first = req.references.first()?;
let bytes = std::fs::read(first).ok()?;
let mime = crate::sniff_mime(&bytes)?;
let (w, h) = crate::image_dimensions(&bytes, mime)?;
Some(crate::provider::Aspect { w, h })
}
fn size_for(req: &ImageRequest, model: &str) -> String {
let asked = Self::implied_aspect(req);
if free_dimensions(model) {
if asked.is_none() && req.size.is_none() {
return "auto".to_string();
}
let scoped = ImageRequest {
aspect: asked,
..req.clone()
};
let (w, h) = area_dimensions(&scoped, TARGET_AREA);
return format!("{w}x{h}");
}
match asked.map(|a| (a.w, a.h)) {
None => "auto".to_string(),
Some((w, h)) if w == h => "1024x1024".to_string(),
Some((w, h)) if w > h => "1536x1024".to_string(),
Some(_) => "1024x1536".to_string(),
}
}
fn generate_fresh(&self, req: &ImageRequest, model: &str) -> Result<Vec<u8>> {
let body = json!({
"model": model,
"prompt": req.prompt,
"size": Self::size_for(req, model),
"quality": DEFAULT_QUALITY,
"output_format": "png",
"n": 1,
});
let response = self
.http
.post(format!("{}/images/generations", self.base))
.header("Authorization", format!("Bearer {}", self.key))
.json(&body)
.send()
.context("calling the OpenAI image API")?;
self.decode(response, model)
}
fn edit(&self, req: &ImageRequest, model: &str) -> Result<Vec<u8>> {
let mut form = reqwest::blocking::multipart::Form::new()
.text("model", model.to_string())
.text("prompt", req.prompt.clone())
.text("size", Self::size_for(req, model))
.text("quality", DEFAULT_QUALITY)
.text("output_format", "png");
for path in &req.references {
form = form.part("image[]", file_part(path)?);
}
if let Some(mask) = &req.mask {
form = form.part("mask", file_part(mask)?);
}
let response = self
.http
.post(format!("{}/images/edits", self.base))
.header("Authorization", format!("Bearer {}", self.key))
.multipart(form)
.send()
.context("calling the OpenAI image edit API")?;
self.decode(response, model)
}
fn decode(&self, response: reqwest::blocking::Response, model: &str) -> Result<Vec<u8>> {
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 API response")?;
let first = &payload["data"][0];
if let Some(encoded) = first["b64_json"].as_str() {
return STANDARD.decode(encoded).context("decoding the image");
}
if let Some(url) = first["url"].as_str() {
let bytes = crate::retry::send_idempotent("downloading the image", || self.http.get(url))
.with_context(|| {
format!(
"downloading the generated image. The render was billed; \
its URL expires shortly:\n\n {url}"
)
})?
.bytes()
.context("reading image bytes")?;
return Ok(bytes.to_vec());
}
bail!("the response contained no image: {payload}")
}
}
impl ImageProvider for Client {
fn list_models(&self) -> Result<Vec<String>> {
let response = crate::retry::send_idempotent("checking the key", || {
self.http
.get(format!("{}/models", self.base))
.header("Authorization", format!("Bearer {}", self.key))
})
.context("checking the OpenAI key")?;
let status = response.status();
if !status.is_success() {
let text = response.text().unwrap_or_default();
bail!("{}", explain_error(status.as_u16(), &text, "models"));
}
eprintln!(
"Key is valid. Note /v1/models does not list image models even when \
they are usable, so the list below is Lucida's own."
);
Ok(KNOWN_MODELS.iter().map(|m| (*m).to_string()).collect())
}
fn generate(&self, req: &ImageRequest) -> Result<GeneratedImage> {
let model = resolve_model(&req.model);
let size = Self::size_for(req, &model);
let bytes = if req.references.is_empty() {
eprintln!("Rendering {size} with {model} (quality {DEFAULT_QUALITY})…");
self.generate_fresh(req, &model)?
} else {
let scope = match &req.mask {
Some(mask) => format!("masked by {mask}"),
None => "whole image".to_string(),
};
eprintln!(
"Editing {} reference(s), {scope}, {size} with {model}…",
req.references.len()
);
self.edit(req, &model)?
};
Ok(GeneratedImage {
bytes,
mime_type: "image/png".to_string(),
commentary: None,
seed: None,
})
}
}
fn area_dimensions(req: &ImageRequest, target: u32) -> (u32, u32) {
let ratio = req.aspect.map_or(1.0, |a| f64::from(a.w) / f64::from(a.h));
let target = match req.size {
Some(size) => {
let scale = f64::from(size.0) / 1024.0;
(f64::from(target) * scale * scale) as u32
}
None => target,
};
let height = (f64::from(target) / ratio).sqrt();
let width = height * ratio;
let round = |v: f64| {
let n = ((v / f64::from(PIXEL_GRID)).round() as u32).max(1);
n * PIXEL_GRID
};
(round(width), round(height))
}
fn file_part(path: &str) -> Result<reqwest::blocking::multipart::Part> {
let bytes = std::fs::read(path).with_context(|| format!("reading {path}"))?;
let name = Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("image.png")
.to_string();
let mime = if name.to_ascii_lowercase().ends_with(".webp") {
"image/webp"
} else if name.to_ascii_lowercase().ends_with(".jpg")
|| name.to_ascii_lowercase().ends_with(".jpeg")
{
"image/jpeg"
} else {
"image/png"
};
reqwest::blocking::multipart::Part::bytes(bytes)
.file_name(name)
.mime_str(mime)
.context("attaching the image")
}
pub fn explain_error(status: u16, body: &str, model: &str) -> String {
let parsed: Value = serde_json::from_str(body).unwrap_or(Value::Null);
let error = &parsed["error"];
let message = error["message"].as_str().unwrap_or(body.trim());
let param = error["param"].as_str().unwrap_or_default();
match status {
401 => format!(
"HTTP 401 — the OpenAI API key was rejected: {message}\n\n\
Check OPENAI_API_KEY, or run `lucida config` to see what this process \
can read."
),
403 => format!(
"HTTP 403 — this project may not use `{model}`.\n\n{message}\n\n\
Model access on OpenAI is granted PER PROJECT, not per organisation \
or per key. In the console open the project named above, then \
Limits (or Model permissions) and enable `{model}` for that project \
specifically. Enabling it org-wide, or having billing active, does \
not do this on its own.\n\n\
If the project name is not one you recognise, the key belongs to a \
different project than you edited — check which key `lucida config` \
is reading."
),
429 => format!(
"HTTP 429 — rate limited or out of quota: {message}\n\n\
Check the billing dashboard; OpenAI reports both conditions here."
),
400 if param == "mask" => format!(
"HTTP 400 — the mask was rejected: {message}\n\n\
A mask must be a PNG with an alpha channel, the same dimensions as \
the image it applies to, and under 4 MB. Note the sense of it: the \
**transparent** pixels are the part that gets changed."
),
400 => format!("HTTP 400 — {message}"),
_ => format!("HTTP {status} — {message}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::Aspect;
fn with_aspect(text: &str) -> ImageRequest {
ImageRequest {
aspect: Some(Aspect::parse(text).unwrap()),
..Default::default()
}
}
#[test]
fn ratios_map_onto_the_three_supported_sizes() {
let m = "gpt-image-1.5";
assert_eq!(Client::size_for(&with_aspect("1:1"), m), "1024x1024");
assert_eq!(Client::size_for(&with_aspect("3:2"), m), "1536x1024");
assert_eq!(Client::size_for(&with_aspect("16:9"), m), "1536x1024");
assert_eq!(Client::size_for(&with_aspect("2:3"), m), "1024x1536");
assert_eq!(Client::size_for(&with_aspect("9:16"), m), "1024x1536");
assert_eq!(Client::size_for(&ImageRequest::default(), m), "auto");
}
#[test]
fn gpt_image_2_takes_free_dimensions() {
assert_eq!(Client::size_for(&ImageRequest::default(), "gpt-image-2"), "auto");
assert!(capabilities("gpt-image-2").size);
assert!(!capabilities("gpt-image-1.5").size);
assert!(!capabilities("gpt-image-1").size);
}
#[test]
fn every_ratio_holds_roughly_the_same_area() {
for ratio in ["1:1", "16:9", "9:16", "21:9", "3:2", "2:3"] {
let (w, h) = area_dimensions(&with_aspect(ratio), TARGET_AREA);
let area = w * h;
assert_eq!(w % 16, 0, "{ratio} width off-grid");
assert_eq!(h % 16, 0, "{ratio} height off-grid");
assert!(
area > 900_000,
"{ratio} came out {w}x{h} = {area}px, under the budget that 16:9 \
originally tripped"
);
assert!(area < 1_250_000, "{ratio} came out {w}x{h} = {area}px");
}
}
#[test]
fn an_edit_keeps_the_sources_shape() {
let dir = std::env::temp_dir().join("lucida-openai-shape-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("wide.png");
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
png.extend_from_slice(&13u32.to_be_bytes());
png.extend_from_slice(b"IHDR");
png.extend_from_slice(&1536u32.to_be_bytes());
png.extend_from_slice(&1024u32.to_be_bytes());
std::fs::write(&path, &png).unwrap();
let edit = ImageRequest {
references: vec![path.to_string_lossy().into_owned()],
..Default::default()
};
assert_eq!(Client::size_for(&edit, "gpt-image-1.5"), "1536x1024");
assert_eq!(
Client::size_for(&ImageRequest::default(), "gpt-image-1.5"),
"auto"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_sources_shape_survives_a_lying_or_webp_filename() {
let dir = std::env::temp_dir().join("lucida-openai-sniff-test");
std::fs::create_dir_all(&dir).unwrap();
let misnamed = dir.join("wide.webp");
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
png.extend_from_slice(&13u32.to_be_bytes());
png.extend_from_slice(b"IHDR");
png.extend_from_slice(&1536u32.to_be_bytes());
png.extend_from_slice(&1024u32.to_be_bytes());
std::fs::write(&misnamed, &png).unwrap();
let edit = ImageRequest {
references: vec![misnamed.to_string_lossy().into_owned()],
..Default::default()
};
assert_eq!(Client::size_for(&edit, "gpt-image-1.5"), "1536x1024");
let real = dir.join("tall.webp");
let mut webp = b"RIFF\0\0\0\0WEBPVP8 ".to_vec();
webp.extend_from_slice(&[0; 4]);
webp.extend_from_slice(&[0; 3]);
webp.extend_from_slice(&[0x9D, 0x01, 0x2A]);
webp.extend_from_slice(&1024u16.to_le_bytes());
webp.extend_from_slice(&1536u16.to_le_bytes());
std::fs::write(&real, &webp).unwrap();
let edit = ImageRequest {
references: vec![real.to_string_lossy().into_owned()],
..Default::default()
};
assert_eq!(Client::size_for(&edit, "gpt-image-1.5"), "1024x1536");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn size_scales_the_area_budget() {
let big = ImageRequest {
size: Some(crate::provider::Size::TWO_K),
..with_aspect("1:1")
};
let (w, h) = area_dimensions(&big, TARGET_AREA);
assert!((w as f64 - 2048.0).abs() < 32.0, "got {w}x{h}");
}
#[test]
fn dall_e_is_not_offered() {
assert!(!KNOWN_MODELS.iter().any(|m| m.contains("dall")));
assert!(!MODEL_ALIASES.iter().any(|(_, t)| t.contains("dall")));
}
#[test]
fn exactly_two_providers_mask_and_they_differ_in_kind() {
use crate::provider::{Backend, MaskSupport, capabilities_for, mask_providers};
let masking: Vec<&str> = Backend::ALL
.iter()
.filter(|b| capabilities_for(**b, b.default_model()).mask.accepted())
.map(|b| b.name())
.collect();
assert_eq!(
masking,
vec!["comfyui", "openai"],
"the set of masking providers changed; is the new one advisory or binding?"
);
assert_eq!(mask_providers(MaskSupport::Binding), vec!["comfyui"]);
assert_eq!(mask_providers(MaskSupport::Advisory), vec!["openai"]);
assert_eq!(capabilities("gpt-image-1").mask, MaskSupport::Advisory);
}
#[test]
fn neither_seed_nor_negative_prompt_exists() {
let caps = capabilities("gpt-image-1");
assert!(!caps.seed);
assert!(!caps.negative_prompt);
assert!(!caps.size);
}
#[test]
fn a_403_points_at_per_project_model_access() {
let body = r#"{"error":{"message":"Project `proj_x` does not have access to model `gpt-image-1`","type":"x"}}"#;
let message = explain_error(403, body, "gpt-image-1");
assert!(message.contains("PER PROJECT"));
assert!(message.contains("proj_x"), "must echo the project it named");
assert!(!message.contains("organisation to be verified"));
}
#[test]
fn a_rejected_mask_explains_which_pixels_change() {
let body = r#"{"error":{"message":"bad mask","param":"mask","type":"x"}}"#;
let message = explain_error(400, body, "gpt-image-1");
assert!(message.contains("transparent"));
assert!(message.contains("alpha"));
}
#[test]
fn aliases_resolve() {
assert_eq!(resolve_model("openai"), "gpt-image-2");
assert_eq!(resolve_model("something-new"), "something-new");
}
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(),
}
}
fn b64_reply(bytes: &[u8]) -> String {
format!(r#"{{"data":[{{"b64_json":"{}"}}]}}"#, STANDARD.encode(bytes))
}
#[test]
fn generation_is_json_with_the_stated_quality_default() {
let server = serve(vec![Reply::json(&b64_reply(b"png-bytes"))]);
let request = ImageRequest {
prompt: "a fox".into(),
model: "gpt-image-2".into(),
..Default::default()
};
let image = wired(&server).generate(&request).unwrap();
assert_eq!(image.bytes, b"png-bytes");
assert_eq!(image.seed, None, "no seed exists to report");
let requests = server.finish();
assert_eq!(requests[0].method, "POST");
assert_eq!(requests[0].path, "/images/generations");
assert_eq!(requests[0].header("authorization"), Some("Bearer test-key"));
let body = requests[0].json();
assert_eq!(body["model"], "gpt-image-2");
assert_eq!(body["quality"], "medium");
assert_eq!(body["output_format"], "png");
assert_eq!(body["n"], 1);
assert_eq!(body["size"], "auto", "nothing asked for lets the model choose");
}
#[test]
fn an_edit_is_multipart_with_the_sources_implied_size() {
let dir = std::env::temp_dir().join("lucida-openai-wire-test");
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("square.png");
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
png.extend_from_slice(&13u32.to_be_bytes());
png.extend_from_slice(b"IHDR");
png.extend_from_slice(&1024u32.to_be_bytes());
png.extend_from_slice(&1024u32.to_be_bytes());
std::fs::write(&source, &png).unwrap();
let mask = dir.join("mask.png");
std::fs::write(&mask, b"mask-bytes").unwrap();
let server = serve(vec![Reply::json(&b64_reply(b"edited"))]);
let request = ImageRequest {
prompt: "make it night".into(),
model: "gpt-image-1.5".into(),
references: vec![source.to_string_lossy().into_owned()],
mask: Some(mask.to_string_lossy().into_owned()),
..Default::default()
};
let image = wired(&server).generate(&request).unwrap();
assert_eq!(image.bytes, b"edited");
let requests = server.finish();
assert_eq!(requests[0].path, "/images/edits");
let body = requests[0].body_text();
assert!(body.contains("name=\"image[]\""), "plural field, or extras are dropped");
assert!(body.contains("name=\"mask\""));
assert!(body.contains("filename=\"square.png\""));
assert!(body.contains("name=\"output_format\""));
assert!(body.contains("name=\"size\""));
assert!(body.contains("1024x1024"), "a square source implies the square size");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_url_response_is_downloaded_without_the_key() {
let listing = r#"{"data":[{"url":"{{server}}/dl/img.png"}]}"#;
let server = serve(vec![
Reply::json(listing),
Reply::bytes("image/png", b"downloaded"),
]);
let request = ImageRequest {
prompt: "a fox".into(),
model: "gpt-image-1".into(),
..Default::default()
};
let image = wired(&server).generate(&request).unwrap();
assert_eq!(image.bytes, b"downloaded");
let requests = server.finish();
assert_eq!(requests[1].path, "/dl/img.png");
assert_eq!(requests[1].header("authorization"), None);
}
}