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://generativelanguage.googleapis.com/v1beta";
pub const DEFAULT_MODEL: &str = "gemini-3.1-flash-image";
pub const ASPECT_RATIOS: &[&str] = &[
"1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9",
];
pub const MODEL_ALIASES: &[(&str, &str)] = &[
("banana", "gemini-3.1-flash-image"),
("nano-banana", "gemini-3.1-flash-image"),
("flash", "gemini-3.1-flash-image"),
("banana-lite", "gemini-3.1-flash-lite-image"),
("lite", "gemini-3.1-flash-lite-image"),
("banana-pro", "gemini-3-pro-image"),
("nano-banana-pro", "gemini-3-pro-image"),
("pro", "gemini-3-pro-image"),
("banana-1", "gemini-2.5-flash-image"),
];
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 struct Client {
api_key: String,
http: reqwest::blocking::Client,
base: String,
}
impl Client {
pub fn from_env() -> Result<Self> {
let api_key = crate::config::var("GEMINI_API_KEY").ok_or_else(no_key)?;
let http = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(300))
.connect_timeout(crate::retry::CONNECT_TIMEOUT)
.build()
.context("building HTTP client")?;
Ok(Self {
api_key,
http,
base: API_ROOT.to_string(),
})
}
#[cfg(test)]
pub(crate) fn recorded(base: &str) -> Self {
Self {
api_key: "test-key".into(),
base: base.to_string(),
http: reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(crate::retry::CONNECT_TIMEOUT)
.no_proxy()
.build()
.unwrap(),
}
}
pub(crate) fn http(&self) -> &reqwest::blocking::Client {
&self.http
}
pub(crate) fn key(&self) -> &str {
&self.api_key
}
pub(crate) fn base(&self) -> &str {
&self.base
}
fn image_models(&self) -> Result<Vec<String>> {
let response = crate::retry::send_idempotent("listing models", || {
self.http
.get(format!("{}/models?pageSize=200", self.base))
.header("x-goog-api-key", &self.api_key)
})
.context("listing models")?;
if !response.status().is_success() {
let status = response.status().as_u16();
let text = response.text().unwrap_or_default();
bail!("{}", explain_error(status, &text));
}
let payload: Value = response.json().context("parsing model list")?;
let mut names: Vec<String> = payload["models"]
.as_array()
.map(|models| {
models
.iter()
.filter_map(|m| m["name"].as_str())
.filter_map(|n| n.strip_prefix("models/"))
.filter(|n| n.contains("image") || n.contains("imagen"))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
names.sort();
names.dedup();
Ok(names)
}
}
pub const CAPABILITIES: Capabilities = Capabilities {
provider: "google",
tagline: "Highest quality, costs money per image, seconds to render. The only provider whose output carries a pixel watermark that survives re-encoding.",
aspect: AspectSupport::Named(ASPECT_RATIOS),
size: true,
seed: false,
negative_prompt: false,
references: true,
mask: MaskSupport::No,
workflow: false,
steps: false,
guidance: false,
provenance: Provenance::SynthIdAndC2pa,
};
impl ImageProvider for Client {
fn list_models(&self) -> Result<Vec<String>> {
self.image_models()
}
fn generate(&self, req: &ImageRequest) -> Result<GeneratedImage> {
let model = resolve_model(&req.model);
if model.starts_with("imagen") {
let fate = crate::provider::retirement_note(&model)
.map(|note| format!("Imagen {note}. "))
.unwrap_or_default();
bail!(
"`{model}` belongs to the Imagen family, which uses a different API \
endpoint that lucida does not implement.\n\n\
{fate}Use a Gemini image model instead — `banana` (fast), \
`banana-pro` (highest quality), or `banana-lite` (cheapest)."
);
}
let mut parts: Vec<Value> = vec![json!({ "text": req.prompt })];
for path in &req.references {
let (mime, data) = read_image_as_inline(path)?;
parts.push(json!({ "inlineData": { "mimeType": mime, "data": data } }));
}
let mut image_config = serde_json::Map::new();
if let Some(aspect) = req.aspect {
image_config.insert("aspectRatio".into(), json!(aspect.to_string()));
}
if let Some(size) = req.size {
image_config.insert("imageSize".into(), json!(size.tier_name()));
}
let mut generation_config = serde_json::Map::new();
generation_config.insert("responseModalities".into(), json!(["TEXT", "IMAGE"]));
if !image_config.is_empty() {
generation_config.insert("imageConfig".into(), Value::Object(image_config));
}
let body = json!({
"contents": [{ "parts": parts }],
"generationConfig": generation_config,
});
let url = format!("{}/models/{model}:generateContent", self.base);
let response = self
.http
.post(&url)
.header("x-goog-api-key", &self.api_key)
.json(&body)
.send()
.context("calling the Gemini API")?;
let status = response.status();
let payload: Value = if status.is_success() {
response.json().context("parsing API response")?
} else {
let text = response.text().unwrap_or_default();
bail!("{}", explain_error(status.as_u16(), &text));
};
extract_image(&payload)
}
}
fn no_key() -> anyhow::Error {
let config_hint = match crate::config::source() {
Some(path) => format!(
"A config file was read from {}, but it sets neither key.",
path.display()
),
None => match crate::config::preferred_path() {
Some(path) => format!(
"No config file was found. Create one with `lucida config --init`, \
which writes {}.",
path.display()
),
None => "No config file was found.".to_string(),
},
};
if let Some(replacement) = crate::config::replacement_for("GOOGLE_API_KEY")
&& crate::config::origin("GOOGLE_API_KEY").is_some()
{
return anyhow!(
"GOOGLE_API_KEY is set, but Lucida no longer reads it — the setting \
was renamed to {replacement}.\n\n\
Rename it in your shell profile, or file it with:\n \
lucida config --set {replacement}\n\n\
Everything Lucida reaches on Google is the Gemini API, so one name \
covers images and Veo alike."
);
}
anyhow!(
"no API key found: set GEMINI_API_KEY.\n\n\
{config_hint}\n\n\
If the key IS exported in your shell profile and this still fails, the \
process was almost certainly not started from a shell — a GUI-launched \
app, and any MCP server it spawns, inherits no login environment. The \
config file exists for exactly that case. Run `lucida config` to see \
what this process can actually see."
)
}
fn read_image_as_inline(path: &str) -> Result<(String, String)> {
let bytes = std::fs::read(path).with_context(|| format!("reading reference image {path}"))?;
Ok((mime_of(path, &bytes).to_string(), STANDARD.encode(&bytes)))
}
fn mime_of(path: &str, bytes: &[u8]) -> &'static str {
if let Some(sniffed) = crate::sniff_mime(bytes) {
return sniffed;
}
match Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
_ => "image/png",
}
}
fn extract_image(payload: &Value) -> Result<GeneratedImage> {
let parts = payload["candidates"][0]["content"]["parts"]
.as_array()
.ok_or_else(|| {
let reason = payload["candidates"][0]["finishReason"]
.as_str()
.or_else(|| payload["promptFeedback"]["blockReason"].as_str());
match reason {
Some("IMAGE_RECITATION") => anyhow!(
"the model declined to return an image (IMAGE_RECITATION).\n\n\
This filter fires when the result would too closely reproduce \
training data, and simple, iconic prompts trip it most often — \
adding detail usually clears it. Describe materials, lighting, \
composition or style rather than naming the object alone."
),
Some(r) => anyhow!("the model returned no image (finish reason: {r})"),
None => anyhow!("unexpected API response shape: {payload}"),
}
})?;
let mut commentary = None;
for part in parts {
if let Some(text) = part["text"].as_str() {
commentary = Some(text.trim().to_string());
}
if let Some(data) = part["inlineData"]["data"].as_str() {
let bytes = STANDARD.decode(data).context("decoding image payload")?;
let mime_type = part["inlineData"]["mimeType"]
.as_str()
.unwrap_or("image/png")
.to_string();
return Ok(GeneratedImage {
bytes,
mime_type,
commentary,
seed: None,
});
}
}
match commentary {
Some(text) => bail!("the model replied with text instead of an image: {text}"),
None => bail!("no image data in the API response"),
}
}
pub(crate) fn explain_error(status: u16, body: &str) -> String {
let parsed: Value = serde_json::from_str(body).unwrap_or(Value::Null);
let message = parsed["error"]["message"].as_str().unwrap_or(body).trim();
match status {
429 if message.contains("limit: 0") => format!(
"HTTP 429 — image generation is not available on a free-tier project.\n\n\
The API reports `limit: 0`, which means no quota exists at all rather \
than a quota that was used up. Waiting will not help.\n\n\
Enable billing on the Google Cloud project behind this API key:\n \
https://aistudio.google.com/billing\n\n\
Original message: {message}"
),
429 => format!("HTTP 429 — rate limited. {message}"),
400 if message.contains("API key not valid") => {
format!("HTTP 400 — the API key was rejected. {message}")
}
403 => format!(
"HTTP 403 — the key is valid but lacks permission for this model. {message}"
),
404 => format!(
"HTTP 404 — no such model. Run `lucida models` to list what this key can see. \
Note that the Imagen 3 IDs are retired. {message}"
),
_ => format!("HTTP {status} — {message}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{Aspect, Size};
use crate::testserver::{Reply, serve};
#[test]
fn a_reference_image_is_typed_by_its_bytes_not_its_extension() {
let jpeg = [0xFFu8, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0];
assert_eq!(mime_of("screenshot.png", &jpeg), "image/jpeg");
assert_eq!(mime_of("no-extension", &jpeg), "image/jpeg");
let png = [0x89u8, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
assert_eq!(mime_of("mislabelled.jpg", &png), "image/png");
assert_eq!(mime_of("loop.gif", b"GIF89a...."), "image/gif");
assert_eq!(mime_of("mystery.dat", b"\x00\x01\x02\x03"), "image/png");
}
#[test]
fn the_key_travels_as_a_header_and_never_in_the_url() {
let reply = serde_json::json!({
"candidates": [{ "content": { "parts": [
{ "text": "Here you go" },
{ "inlineData": { "mimeType": "image/png",
"data": STANDARD.encode(b"png-bytes") } }
]}}]
})
.to_string();
let server = serve(vec![Reply::json(&reply)]);
let request = ImageRequest {
prompt: "a fox".into(),
model: "banana".into(),
aspect: Some(Aspect::parse("16:9").unwrap()),
size: Some(Size::TWO_K),
..Default::default()
};
let image = Client::recorded(server.url()).generate(&request).unwrap();
assert_eq!(image.bytes, b"png-bytes");
assert_eq!(image.mime_type, "image/png");
assert_eq!(image.commentary.as_deref(), Some("Here you go"));
let requests = server.finish();
let sent = &requests[0];
assert_eq!(sent.method, "POST");
assert_eq!(sent.path, "/models/gemini-3.1-flash-image:generateContent");
assert_eq!(sent.header("x-goog-api-key"), Some("test-key"));
assert!(!sent.path.contains("key="), "the key must never ride in the URL");
let body = sent.json();
assert_eq!(body["contents"][0]["parts"][0]["text"], "a fox");
let config = &body["generationConfig"];
assert_eq!(config["imageConfig"]["aspectRatio"], "16:9");
assert_eq!(config["imageConfig"]["imageSize"], "2K", "2048px translates to the tier name");
}
#[test]
fn a_recitation_block_is_explained_rather_than_dumped() {
let payload = serde_json::json!({
"candidates": [{ "finishReason": "IMAGE_RECITATION" }]
});
let error = extract_image(&payload).unwrap_err().to_string();
assert!(error.contains("IMAGE_RECITATION"));
assert!(error.contains("adding detail"), "must say what actually clears it: {error}");
}
#[test]
fn a_text_only_reply_surfaces_what_the_model_said() {
let payload = serde_json::json!({
"candidates": [{ "content": { "parts": [
{ "text": "I can't draw that." }
]}}]
});
let error = extract_image(&payload).unwrap_err().to_string();
assert!(error.contains("I can't draw that."));
}
}