Files
codecraft/server/src/images.rs
T
Richard ZinkandClaude Opus 5.5 bfa1f641d7 opencode prices and Go limits, settings out of .env
- Fetch prices for opencode (Zen and Go) incl. limits, image input, reasoning,
  long-context tiers and peak-hour/date factors; costs apply them per request
- Record the cost opencode reports per response; track Go allowance in 5h/7d/month
  windows against a provider plan limit (budget table, status overlay)
- Settings → Config file: move values of ${NAME} references into the config
- Examples, presets and forms keep keys in the config instead of .env

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:19:15 +01:00

402 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Image generation via fal.ai – as an agent tool and for the manual image page.
use crate::app::App;
use crate::config::{ImageModel, expand_env};
use crate::store::{ImageRecord, UsageRecord};
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
use crate::types::{ImageData, new_id, now_ms};
use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait;
use base64::Engine;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Size presets offered in the UI and to the model (FLUX-style names).
pub const SIZES: &[&str] = &["square_hd", "square", "landscape_4_3", "landscape_16_9", "portrait_4_3", "portrait_16_9"];
fn aspect_ratio(size: &str) -> &'static str {
match size {
"landscape_4_3" => "4:3",
"landscape_16_9" => "16:9",
"portrait_4_3" => "3:4",
"portrait_16_9" => "9:16",
_ => "1:1",
}
}
pub struct GenerateRequest {
pub prompt: String,
pub model: Option<String>,
pub size: Option<String>,
pub count: u32,
/// Directory the files are written to.
pub dir: PathBuf,
pub user_id: Option<String>,
pub session_id: Option<String>,
/// Images to refine (sent to models with a `reference_param`).
pub references: Vec<PathBuf>,
}
fn slug(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect();
while out.contains("--") {
out = out.replace("--", "-");
}
out.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string()
}
/// Purposes the agent can ask for; models list theirs in `good_for`.
pub const PURPOSES: &[&str] = &["photo", "illustration", "text", "design"];
/// Defaults for well-known fal.ai models: (id, stage, good_for, reference_param, size_param, price).
const KNOWN: &[(&str, &str, &[&str], Option<&str>, Option<&str>, f64)] = &[
("fal-ai/flux/schnell", "preview", &["photo", "illustration"], None, None, 0.003),
("fal-ai/nano-banana-pro", "final", &["photo", "illustration", "text"], None, Some("aspect_ratio"), 0.15),
("fal-ai/nano-banana-pro/edit", "final", &["photo", "illustration", "text", "design"], Some("image_urls"), Some("aspect_ratio"), 0.15),
("fal-ai/flux-2-pro", "final", &["photo"], None, None, 0.03),
("fal-ai/ideogram/v3", "final", &["text", "design"], None, Some("aspect_ratio"), 0.06),
("fal-ai/recraft/v4/pro/text-to-image", "final", &["design", "illustration"], None, None, 0.05),
];
/// A configured model with the known defaults filled in where the config leaves them out.
fn with_defaults(mut m: ImageModel) -> ImageModel {
if let Some((_, stage, good_for, reference, size, price)) = KNOWN.iter().find(|k| k.0 == m.id) {
m.stage.get_or_insert_with(|| stage.to_string());
if m.good_for.is_empty() {
m.good_for = good_for.iter().map(|s| s.to_string()).collect();
}
if m.reference_param.is_none() {
m.reference_param = reference.map(String::from);
}
if m.size_param.is_none() {
m.size_param = size.map(String::from);
}
m.price.get_or_insert(*price);
}
m
}
fn models(app: &App) -> Vec<ImageModel> {
app.config().images.models.iter().cloned().map(with_defaults).collect()
}
fn model_config(app: &App, id: &str) -> ImageModel {
models(app).into_iter().find(|m| m.id == id).unwrap_or_else(|| with_defaults(ImageModel { id: id.to_string(), ..Default::default() }))
}
/// Pick the model for a request: an explicit model wins; previews use the first preview model
/// (else the default); final images use the best-matching final model, preferring one that can
/// take the reference image when there is one.
pub fn choose_model(app: &App, stage: &str, purpose: Option<&str>, with_reference: bool) -> ImageModel {
let default = app.config().images.default_model.clone();
pick(&models(app), stage, purpose, with_reference).unwrap_or_else(|| model_config(app, &default))
}
fn pick(all: &[ImageModel], stage: &str, purpose: Option<&str>, with_reference: bool) -> Option<ImageModel> {
if stage == "preview" && !with_reference {
return all.iter().find(|m| m.stage.as_deref() == Some("preview")).cloned();
}
let score = |m: &ImageModel| {
let mut s = 0;
if m.stage.as_deref() == Some("final") {
s += 4;
}
match (with_reference, m.reference_param.is_some()) {
(true, true) => s += 8,
// Without a reference, plain text-to-image endpoints come first.
(false, true) => s -= 1,
_ => {}
}
if purpose.is_some_and(|p| m.good_for.iter().any(|g| g == p)) {
s += 2;
}
s
};
// max_by_key keeps the last of equal scores; reverse so the config order wins ties.
all.iter().rev().max_by_key(|m| score(m)).cloned()
}
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
let mut out = Vec::new();
for p in paths {
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
let mime = match p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
_ => "image/png",
};
out.push(format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)));
}
Ok(out)
}
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
if let Some(rest) = url.strip_prefix("data:") {
let (meta, data) = rest.split_once(',').context("malformed data URI")?;
let ctype = meta.split(';').next().unwrap_or("image/png").to_string();
return Ok((base64::engine::general_purpose::STANDARD.decode(data)?, ctype));
}
let resp = crate::provider::http_client().get(url).timeout(Duration::from_secs(120)).send().await?.error_for_status()?;
let ctype = resp.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("image/png").to_string();
Ok((resp.bytes().await?.to_vec(), ctype))
}
/// Generate images, store them in `req.dir` and record them (history + cost).
pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord>> {
let cfg = app.config();
let ic = &cfg.images;
if !ic.enabled {
bail!("image generation is disabled – enable [images] in the settings");
}
let key = expand_env(&ic.api_key);
if key.is_empty() {
bail!("no fal.ai API key configured (images.api_key, e.g. ${{FAL_KEY}})");
}
crate::budget::check(app, "fal", req.user_id.as_deref())?;
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
let model = model_config(app, &model_id);
let explicit_size = req.size.clone().filter(|s| SIZES.contains(&s.as_str()));
let size = explicit_size.clone().unwrap_or_else(|| "landscape_4_3".into());
let count = req.count.clamp(1, 4);
let mut body = json!({"prompt": req.prompt, "num_images": count, "output_format": "png"});
let refining = !req.references.is_empty() && model.reference_param.is_some();
match model.size_param.as_deref() {
// When refining without an explicit size, keep the reference's aspect ratio.
Some("aspect_ratio") if refining && explicit_size.is_none() => body["aspect_ratio"] = json!("auto"),
Some("aspect_ratio") => body["aspect_ratio"] = json!(aspect_ratio(&size)),
Some("none") => {}
_ => body["image_size"] = json!(size),
}
if refining && let Some(param) = &model.reference_param {
body[param.as_str()] = json!(reference_uris(&req.references).await?);
}
for (k, v) in &model.options {
body[k] = v.clone();
}
let url = format!("{}/{}", expand_env(&ic.base_url).trim_end_matches('/'), model_id.trim_matches('/'));
let resp = crate::provider::http_client()
.post(&url)
.header("Authorization", format!("Key {key}"))
.timeout(Duration::from_secs(300))
.json(&body)
.send()
.await
.context("fal.ai request failed")?;
if !resp.status().is_success() {
return Err(crate::provider::http_error(resp).await);
}
let out: Value = resp.json().await?;
let images = out["images"].as_array().cloned().or_else(|| out["image"].as_object().map(|o| vec![Value::Object(o.clone())])).unwrap_or_default();
if images.is_empty() {
bail!("fal.ai returned no images: {}", out.to_string().chars().take(300).collect::<String>());
}
tokio::fs::create_dir_all(&req.dir).await?;
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
let base = slug(&req.prompt);
let mut records = Vec::new();
for (i, img) in images.iter().enumerate() {
let src = img["url"].as_str().context("image without url")?;
let (bytes, ctype) = download(src).await?;
let ext = match ctype.as_str() {
"image/jpeg" => "jpg",
"image/webp" => "webp",
_ => "png",
};
let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
let path = req.dir.join(name);
tokio::fs::write(&path, &bytes).await?;
let rec = ImageRecord {
id: new_id(),
user_id: req.user_id.clone(),
session_id: req.session_id.clone(),
prompt: req.prompt.clone(),
model: model_id.clone(),
path: path.display().to_string(),
width: img["width"].as_i64().unwrap_or(0),
height: img["height"].as_i64().unwrap_or(0),
created_at: now_ms(),
};
app.store.add_image(&rec)?;
records.push(rec);
}
// Costs go into the normal statistics (provider "fal").
let cost = model.price.unwrap_or(0.0) * records.len() as f64;
let session = req.session_id.clone().unwrap_or_default();
let _ = app.store.add_usage(&UsageRecord {
ts: now_ms(),
session_id: session.clone(),
root_session_id: if session.is_empty() { String::new() } else { app.root_session_id(&session) },
provider: "fal".into(),
model: model_id,
agent: "image".into(),
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
ttft_ms: 0,
duration_ms: 0,
cost,
error: false,
user_id: None,
allowance: None,
});
Ok(records)
}
/// Downscaled copy for the model's context.
fn preview_for_model(path: &Path) -> Option<ImageData> {
let img = image::open(path).ok()?;
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
}
pub struct GenerateImage;
#[async_trait]
impl Tool for GenerateImage {
fn name(&self) -> &str {
"generate_image"
}
fn description(&self) -> String {
"Generate images from a text prompt (fal.ai) and save them in the project. Use for \
illustrations, icons, backgrounds or photos the user needs. Write a detailed visual prompt \
in English (subject, style, composition, colours, lighting). The server picks the model. \
Work in two steps: first stage \"preview\" (cheap drafts, count 2-4); show them and ask the \
user with ask_user which one to refine – include the final model and price the preview \
result names. Then call again with stage \"final\", the chosen file in \"reference\" and \
the same or an improved prompt. Skip the preview only if the user asks for a final image \
directly. Each image costs money – generate only what is needed."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"prompt": {"type": "string"},
"stage": {"type": "string", "enum": ["preview", "final"], "description": "preview = cheap drafts (default), final = best model for the purpose"},
"purpose": {"type": "string", "enum": PURPOSES, "description": "What the image is: photo (realistic), illustration, text (lettering, posters, signs), design (logos, icons, UI, vector style)"},
"reference": {"type": "array", "items": {"type": "string"}, "maxItems": 4, "description": "Image files (relative paths) to refine, e.g. the preview the user chose"},
"size": {"type": "string", "enum": SIZES, "description": "Default: landscape_4_3; when refining, the reference's aspect ratio"},
"count": {"type": "integer", "minimum": 1, "maximum": 4},
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: images.output_dir)"},
"model": {"type": "string", "description": "fal.ai model id – only if the user asks for a specific model"}
},
"required": ["prompt"]
})
}
fn title(&self, input: &Value) -> String {
let stage = if input["stage"].as_str() == Some("final") { "Generate final image" } else { "Generate image" };
format!("{stage}: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let prompt = arg_str(&input, "prompt")?.to_string();
let stage = if input["stage"].as_str() == Some("final") { "final" } else { "preview" };
let purpose = input["purpose"].as_str().filter(|p| PURPOSES.contains(p));
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| ctx.app.config().images.output_dir.clone());
let dir = ctx.resolve(&dir_arg)?;
let mut references = Vec::new();
for r in input["reference"].as_array().into_iter().flatten().filter_map(|v| v.as_str()).take(4) {
references.push(ctx.resolve(r)?);
}
let model = match input["model"].as_str().filter(|m| !m.is_empty()) {
Some(m) => model_config(&ctx.app, m),
None => choose_model(&ctx.app, stage, purpose, !references.is_empty()),
};
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
let refined = !references.is_empty() && model.reference_param.is_some();
let records = generate(
&ctx.app,
GenerateRequest {
prompt,
model: Some(model.id.clone()),
size: input["size"].as_str().map(String::from),
count: input["count"].as_u64().unwrap_or(if stage == "preview" { 2 } else { 1 }) as u32,
dir,
user_id: owner,
session_id: Some(ctx.session_id.clone()),
references,
},
)
.await
.map_err(|e| anyhow!("{e:#}"))?;
let price = |m: &ImageModel| m.price.map(|p| format!(" (${p:.3} per image)")).unwrap_or_default();
let label = |m: &ImageModel| m.name.clone().unwrap_or_else(|| m.id.clone());
let mut text = format!("Model: {}{}\nGenerated:\n", label(&model), price(&model));
let mut images = Vec::new();
for r in &records {
let rel = Path::new(&r.path).strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| r.path.clone());
text.push_str(&format!("- {rel} ({}×{})\n", r.width, r.height));
if let Some(p) = preview_for_model(Path::new(&r.path)) {
images.push(p);
}
}
if stage == "preview" && !refined {
let fin = choose_model(&ctx.app, "final", purpose, true);
if fin.id != model.id {
let how = if fin.reference_param.is_some() { "refines the chosen preview" } else { "generates it again from the prompt" };
text.push_str(&format!(
"\nThese are previews. Ask the user (ask_user) which one to refine. The final image would use {}{} and {how}.",
label(&fin),
price(&fin)
));
}
} else if !input["reference"].as_array().is_none_or(|a| a.is_empty()) && !refined {
text.push_str("\nNote: this model takes no reference image, so it was generated from the prompt only.");
}
let ids: Vec<&str> = records.iter().map(|r| r.id.as_str()).collect();
Ok(ToolOutput { content: text, images, meta: Some(json!({"image_ids": ids})), ..Default::default() })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn m(id: &str) -> ImageModel {
with_defaults(ImageModel { id: id.into(), ..Default::default() })
}
#[test]
fn picks_by_stage_purpose_and_reference() {
let all = vec![
m("fal-ai/flux/schnell"),
m("fal-ai/nano-banana-pro/edit"),
m("fal-ai/nano-banana-pro"),
m("fal-ai/flux-2-pro"),
m("fal-ai/ideogram/v3"),
m("fal-ai/recraft/v4/pro/text-to-image"),
];
let id = |stage, purpose, r| pick(&all, stage, purpose, r).unwrap().id;
assert_eq!(id("preview", Some("photo"), false), "fal-ai/flux/schnell");
// Refining a preview: the edit model, whatever the purpose.
assert_eq!(id("final", Some("photo"), true), "fal-ai/nano-banana-pro/edit");
assert_eq!(id("final", Some("design"), true), "fal-ai/nano-banana-pro/edit");
// From the prompt only: the first fitting text-to-image model (config order).
assert_eq!(id("final", Some("photo"), false), "fal-ai/nano-banana-pro");
assert_eq!(id("final", Some("text"), false), "fal-ai/nano-banana-pro");
assert_eq!(id("final", Some("design"), false), "fal-ai/ideogram/v3");
let no_edit: Vec<_> = all.iter().filter(|m| m.reference_param.is_none()).cloned().collect();
assert_eq!(pick(&no_edit, "final", Some("design"), true).unwrap().id, "fal-ai/ideogram/v3");
assert_eq!(pick(&no_edit, "final", Some("photo"), false).unwrap().id, "fal-ai/nano-banana-pro");
}
#[test]
fn config_overrides_known_defaults() {
let custom = with_defaults(ImageModel { id: "fal-ai/flux/schnell".into(), stage: Some("final".into()), price: Some(0.01), ..Default::default() });
assert_eq!(custom.stage.as_deref(), Some("final"));
assert_eq!(custom.price, Some(0.01));
assert!(m("some/unknown-model").stage.is_none());
}
}