use crate::retry::RETRY_AFTER_MAX_MS;
use crate::tools::media_catalog::image::{ImageModelInfo, resolve_image_model};
use crate::util::error::retry_after_header;
use crate::util::http::{bearer_auth_header, read_error_body};
use crate::{Tool, Workspace};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde_json::json;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub struct ImageGenTool;
impl ImageGenTool {
const NAME: &'static str = "image_gen";
}
#[async_trait]
impl Tool for ImageGenTool {
fn name(&self) -> &'static str {
Self::NAME
}
fn media_marker(&self) -> Option<&'static str> {
Some("[IMAGE:")
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"prompt": {
"type": "string",
"description": "Text description of the image to generate"
},
"images": {
"type": "array",
"items": { "type": "string" },
"description": "Paths to reference images for image-to-image generation (local paths are accepted only from the workspace uploads dir — received attachments — or the generated dir — previously generated images)"
},
"aspect_ratio": {
"type": "string",
"description": "Aspect ratio (e.g. 16:9, 1:1, 4:3)"
},
"size": {
"type": "string",
"description": "Image size — a tier (e.g. 1K, 2K) or explicit pixels (e.g. 1024x1024); a model that cannot express the value is not sent the request"
}
}),
&["prompt"],
)
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let generated = self.generate(ws, &args, &HttpSender).await?;
Ok(self.result_text(&generated))
}
async fn execute_with_payloads(
&self,
ws: &Workspace,
args: serde_json::Value,
) -> anyhow::Result<super::ToolOutput> {
let generated = self.generate(ws, &args, &HttpSender).await?;
Ok(self.output(&generated).await)
}
}
struct Generated {
path: PathBuf,
note: Option<String>,
}
impl Generated {
async fn payload(&self) -> Option<super::ImagePayload> {
let meta = crate::util::local_image_to_compressed_data_uri_with_meta(&self.path)
.await
.ok()?;
let mut payload = super::ImagePayload::from_compressed_meta(
&self.path,
meta,
None,
super::ImagePayloadSource::Generated,
);
payload.note.clone_from(&self.note);
Some(payload)
}
}
impl ImageGenTool {
async fn generate(
&self,
ws: &Workspace,
args: &serde_json::Value,
sender: &dyn ImageSender,
) -> anyhow::Result<Generated> {
let prompt = super::get_str(args, "prompt")?;
let user_name = crate::agent::tool_user_name();
let own_model = crate::users::resolve_image_gen_model(&user_name).await;
let aspect_ratio_arg = super::get_opt_str(args, "aspect_ratio");
let size_arg = super::get_opt_str(args, "size");
let images: Vec<String> = super::get_str_array(args, "images")?;
let mut reference_paths = Vec::with_capacity(images.len());
for img_path in &images {
reference_paths
.push(super::resolve_local_media_path(img_path, ws, "reference image").await?);
}
let plan = plan_walk(
&own_model,
reference_paths.len(),
size_arg,
&crate::config::CONFIG.image_gen_models_raw(),
)
.await?;
if !reference_paths.is_empty() {
crate::util::check_reference_total_input(&reference_paths).await?;
}
let mut references = Vec::with_capacity(reference_paths.len());
for path in &reference_paths {
references.push(
crate::util::load_reference_image(path, super::MAX_REFERENCE_IMAGE_BYTES).await?,
);
}
let resolved_aspect_ratio =
request_aspect_ratio(aspect_ratio_arg, &reference_paths, plan.catalog_reached);
let api_base = crate::providers::ensure_base_url(crate::config::DEFAULT_PROVIDER_ENDPOINT);
let images_url = format!("{api_base}/images");
let auth = bearer_auth_header().ok_or_else(|| {
anyhow::anyhow!("Image generation: provider API key is not configured")
})?;
let mut walk = ImageWalk {
request: RequestMaterial {
prompt,
aspect_ratio: resolved_aspect_ratio,
size_arg,
references: &mut references,
},
own_model: &own_model,
url: &images_url,
auth: &auth,
sender,
trail: Vec::new(),
stopped: None,
};
let shutdown = crate::shutdown::shutdown_token();
let walked = tokio::select! {
() = shutdown.cancelled() => {
anyhow::bail!("{SHUTDOWN_ABORTED}");
}
walked = walk.run(plan.own_step, plan.candidates) => walked?,
};
let Walked::Served {
model: served_by,
response,
} = walked
else {
anyhow::bail!("{}", walk.failure_message());
};
let (b64_json, media_type) = match extract_response_image(&response) {
Ok(x) => x,
Err(err) => {
anyhow::bail!("Image generation response error: {err}");
}
};
let bytes = match STANDARD.decode(b64_json.as_bytes()) {
Ok(bytes) => bytes,
Err(e) => {
anyhow::bail!("Failed to decode base64 image data from response: {e}");
}
};
let output_path = super::save_generated_file(
ws,
&bytes,
"image",
extension_for_media_type(media_type.as_deref()),
)
.await?;
tracing::info!(
model = %served_by,
own_model = %own_model,
"Image generation produced a picture"
);
Ok(Generated {
path: output_path,
note: walk.fallback_note(&served_by),
})
}
async fn output(&self, generated: &Generated) -> super::ToolOutput {
super::ToolOutput {
text: self.result_text(generated),
image_payloads: generated.payload().await.into_iter().collect(),
text_is_content: false,
}
}
fn result_text(&self, generated: &Generated) -> String {
let marker = self.format_media_result(&generated.path);
match &generated.note {
Some(note) => format!("{marker}\n{note}"),
None => marker,
}
}
}
struct RequestMaterial<'a> {
prompt: &'a str,
aspect_ratio: Option<String>,
size_arg: Option<&'a str>,
references: &'a mut [crate::util::ReferenceImage],
}
enum Step {
Attempt {
info: Option<ImageModelInfo>,
size: Option<(&'static str, String)>,
},
Skip(String),
}
enum Visited {
Skipped(String),
Attempted(Vec<AttemptFailure>),
}
enum Walked {
Served {
model: String,
response: serde_json::Value,
},
Unserved,
}
struct WalkPlan {
own_step: Step,
candidates: Vec<String>,
catalog_reached: bool,
}
async fn plan_walk(
own_model: &str,
reference_count: usize,
size_arg: Option<&str>,
picker: &[String],
) -> anyhow::Result<WalkPlan> {
if reference_count > super::MAX_REFERENCE_IMAGES_PER_REQUEST {
anyhow::bail!(
"Image generation supports at most {} reference image(s), got {}. \
Retry with fewer images.",
super::MAX_REFERENCE_IMAGES_PER_REQUEST,
reference_count,
);
}
let capability = resolve_image_model(crate::config::DEFAULT_PROVIDER_ENDPOINT, own_model).await;
let catalog_reached = !matches!(capability, Ok(None));
let own_step = own_step(own_model, capability, reference_count, size_arg);
let candidates = if catalog_reached {
fallback_candidates(own_model, picker)
} else {
Vec::new()
};
if candidates.is_empty()
&& let Step::Skip(reason) = &own_step
{
anyhow::bail!("{reason}");
}
Ok(WalkPlan {
own_step,
candidates,
catalog_reached,
})
}
fn fallback_candidates(own_model: &str, picker_list: &[String]) -> Vec<String> {
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::from([own_model]);
picker_list
.iter()
.filter(|model| seen.insert(model.as_str()))
.cloned()
.collect()
}
fn own_step(
model: &str,
capability: anyhow::Result<Option<ImageModelInfo>>,
references: usize,
size_arg: Option<&str>,
) -> Step {
let info = match capability {
Ok(info) => info,
Err(e) => return Step::Skip(e.to_string()),
};
match info {
None => {
if let Some(size) = size_arg {
tracing::debug!(size, "Image generation: size dropped — catalog unavailable");
}
Step::Attempt {
info: None,
size: None,
}
}
Some(info) => match eligibility(model, &info, references, size_arg, RefusalVoice::OwnModel)
{
Err(reason) => Step::Skip(reason),
Ok(size) => Step::Attempt {
info: Some(info),
size,
},
},
}
}
#[derive(Clone, Copy)]
enum RefusalVoice {
OwnModel,
Candidate,
}
fn eligibility(
model: &str,
info: &ImageModelInfo,
references: usize,
size_arg: Option<&str>,
voice: RefusalVoice,
) -> Result<Option<(&'static str, String)>, String> {
if references > 0 {
validate_reference_count(model, info, references, voice)?;
}
resolve_size(model, size_arg, info, voice).map_err(|e| e.to_string())
}
fn request_aspect_ratio(
aspect_ratio_arg: Option<&str>,
reference_paths: &[PathBuf],
catalog_reached: bool,
) -> Option<String> {
if catalog_reached {
Some(resolve_aspect_ratio(aspect_ratio_arg, reference_paths))
} else {
aspect_ratio_arg.map(String::from)
}
}
const WALK_ENVELOPE_BYTES: usize =
crate::tools::tool_failure_envelope_bytes(ImageGenTool::NAME.len());
const WALK_MESSAGE_BYTES: usize = crate::util::TOOL_OUTPUT_BUDGET_BYTES - WALK_ENVELOPE_BYTES;
const WALK_ENTRY_MAX_BYTES: usize = 300;
fn walk_line_bytes(models: usize, fixed: usize) -> usize {
WALK_ENTRY_MAX_BYTES.min(WALK_MESSAGE_BYTES.saturating_sub(fixed) / models.max(1))
}
const WALK_STOPPED_PREFIX: &str = "The walk stopped there: ";
const SHUTDOWN_ABORTED: &str = "Shutting down — image generation aborted";
const CATALOG_UNREACHABLE: &str =
"the image-models catalog could not be reached, so the model's capability is unknown";
const NOT_AN_IMAGE_MODEL: &str = "the image-models catalog does not offer it for image output";
struct ImageWalk<'a> {
request: RequestMaterial<'a>,
own_model: &'a str,
url: &'a str,
auth: &'a str,
sender: &'a dyn ImageSender,
trail: Vec<(String, Visited)>,
stopped: Option<String>,
}
impl ImageWalk<'_> {
async fn run(&mut self, own_step: Step, candidates: Vec<String>) -> anyhow::Result<Walked> {
let own_model = self.own_model;
if let Some(walked) = self
.visit(own_model, own_step, !candidates.is_empty())
.await?
{
return Ok(walked);
}
for (index, model) in candidates.iter().enumerate() {
if self.stopped.is_some() {
break;
}
let more_models = index + 1 < candidates.len();
let Some(step) = self.candidate_step(model).await else {
self.trail.push((
model.clone(),
Visited::Skipped(CATALOG_UNREACHABLE.to_string()),
));
if more_models {
self.stopped = Some("the remaining models were not checked".to_string());
}
break;
};
if let Some(walked) = self.visit(model, step, more_models).await? {
return Ok(walked);
}
}
Ok(Walked::Unserved)
}
async fn candidate_step(&mut self, model: &str) -> Option<Step> {
let info = match resolve_image_model(crate::config::DEFAULT_PROVIDER_ENDPOINT, model).await
{
Ok(Some(info)) => info,
Err(_) => return Some(Step::Skip(NOT_AN_IMAGE_MODEL.to_string())),
Ok(None) => return None,
};
match eligibility(
model,
&info,
self.request.references.len(),
self.request.size_arg,
RefusalVoice::Candidate,
) {
Ok(size) => Some(Step::Attempt {
info: Some(info),
size,
}),
Err(reason) => Some(Step::Skip(reason)),
}
}
async fn visit(
&mut self,
model: &str,
step: Step,
more_models: bool,
) -> anyhow::Result<Option<Walked>> {
let (info, size) = match step {
Step::Skip(reason) => {
tracing::debug!(model, %reason, "Image generation: model cannot serve the request");
self.trail
.push((model.to_string(), Visited::Skipped(reason)));
return Ok(None);
}
Step::Attempt { info, size } => (info, size),
};
let body = match self.body(model, info.as_ref(), size) {
Ok(body) => body,
Err(e) if model == self.own_model => return Err(e),
Err(e) => {
let reason = e.to_string();
tracing::warn!(
model,
%reason,
"Image generation: request body too large — no other model is tried"
);
self.stopped = Some(format!("`{model}` was not sent to: {reason}"));
return Ok(None);
}
};
match generate_image_with_retries(self.url, &body, self.auth, self.sender).await {
Ok(response) => Ok(Some(Walked::Served {
model: model.to_string(),
response,
})),
Err(AttemptOutcome::WalkOn(failures)) => {
tracing::info!(
model,
reason = %describe_failure(&failures, self.has_references()),
"Image generation: model did not serve the request"
);
self.trail
.push((model.to_string(), Visited::Attempted(failures)));
Ok(None)
}
Err(AttemptOutcome::Unknown(failures)) => {
let reason = describe_failure(&failures, self.has_references());
tracing::warn!(
model,
%reason,
"Image generation: outcome unknown — no other model is tried"
);
if more_models {
self.stopped = Some(format!(
"`{model}` may have completed and been billed — its outcome is unknown, \
so no other model was tried"
));
}
self.trail
.push((model.to_string(), Visited::Attempted(failures)));
Ok(None)
}
Err(AttemptOutcome::Aborted) => {
anyhow::bail!("{SHUTDOWN_ABORTED}");
}
}
}
fn body(
&mut self,
model: &str,
info: Option<&ImageModelInfo>,
size: Option<(&'static str, String)>,
) -> anyhow::Result<serde_json::Value> {
let mut body = build_request_body(
model,
self.request.prompt,
self.request.aspect_ratio.as_deref(),
size,
self.request.references,
info,
);
super::fit_request_body_budget(
&mut body,
self.request.references,
super::MAX_REQUEST_BODY_BYTES,
)?;
Ok(body)
}
fn has_references(&self) -> bool {
!self.request.references.is_empty()
}
fn failure_message(&self) -> String {
let [(_, visited)] = &self.trail[..] else {
return self.walk_failure();
};
let message = match visited {
Visited::Skipped(reason) => reason.clone(),
Visited::Attempted(failures) => build_terminal_message(failures, self.has_references()),
};
match &self.stopped {
Some(stopped) => format!("{message}\n{WALK_STOPPED_PREFIX}{stopped}"),
None => message,
}
}
fn walk_failure(&self) -> String {
let header = "Image generation failed: no configured image model served the request.";
let stop = match &self.stopped {
Some(stopped) => format!("\n{WALK_STOPPED_PREFIX}{stopped}"),
None => String::new(),
};
let line_bytes = walk_line_bytes(self.trail.len(), header.len() + stop.len());
let mut out = String::from(header);
for (model, visited) in &self.trail {
out.push_str(&crate::util::cut_bytes(
&self.line(model, visited),
line_bytes,
));
}
out.push_str(&stop);
out
}
fn line(&self, model: &str, visited: &Visited) -> String {
let own = if model == self.own_model {
" (the account's image model)"
} else {
""
};
let described = match visited {
Visited::Skipped(reason) => format!("not sent to — {reason}"),
Visited::Attempted(failures) => describe_failure(failures, self.has_references()),
};
format!("\n- `{model}`{own}: {described}")
}
fn fallback_note(&self, served_by: &str) -> Option<String> {
if served_by == self.own_model {
return None;
}
let (_, own_visited) = self.trail.first()?;
let own_fate = match own_visited {
Visited::Skipped(reason) => format!("was never sent to: {reason}"),
Visited::Attempted(failures) => format!(
"did not serve the request: {}",
describe_failure(failures, self.has_references())
),
};
let fate = crate::util::cut_bytes(
&crate::util::scrub_credentials(&own_fate),
WALK_ENTRY_MAX_BYTES,
);
let full_stop = if fate.ends_with(['.', '…']) {
""
} else {
"."
};
Some(format!(
"`{served_by}` produced this picture — the account's image model `{}` {fate}{full_stop}",
self.own_model,
))
}
}
const DEFAULT_ASPECT_RATIO: &str = "9:16";
const IMAGE_GEN_MAX_ATTEMPTS: u32 = 2;
const IMAGE_GEN_QUICK_FAILURE_MS: u64 = 60_000;
const IMAGE_GEN_BACKOFF_MS: u64 = 5_000;
enum AttemptFailure {
Timeout { elapsed_ms: u64, connect: bool },
Transport { elapsed_ms: u64 },
Http {
status: u16,
body: String,
retry_after_ms: Option<u64>,
elapsed_ms: u64,
},
BodyRead,
Parse { message: String },
}
impl AttemptFailure {
fn retryable(&self) -> bool {
match self {
Self::Timeout {
elapsed_ms,
connect,
} => {
*connect && *elapsed_ms < IMAGE_GEN_QUICK_FAILURE_MS
}
Self::Transport { elapsed_ms } => *elapsed_ms < IMAGE_GEN_QUICK_FAILURE_MS,
Self::Http {
status, elapsed_ms, ..
} => {
matches!(status, 429 | 502 | 503 | 524 | 529)
&& *elapsed_ms < IMAGE_GEN_QUICK_FAILURE_MS
}
Self::BodyRead | Self::Parse { .. } => false,
}
}
fn retry_after_ms(&self) -> Option<u64> {
match self {
Self::Http { retry_after_ms, .. } => *retry_after_ms,
_ => None,
}
}
fn walkable(&self) -> bool {
match self {
Self::Http { .. } => true,
Self::Transport { elapsed_ms }
| Self::Timeout {
elapsed_ms,
connect: true,
} => *elapsed_ms < IMAGE_GEN_QUICK_FAILURE_MS,
Self::Timeout { connect: false, .. } | Self::BodyRead | Self::Parse { .. } => false,
}
}
}
enum AttemptOutcome {
WalkOn(Vec<AttemptFailure>),
Unknown(Vec<AttemptFailure>),
Aborted,
}
impl AttemptOutcome {
fn of(failures: Vec<AttemptFailure>) -> Self {
if failures.last().is_some_and(AttemptFailure::walkable) {
Self::WalkOn(failures)
} else {
Self::Unknown(failures)
}
}
}
#[async_trait]
trait ImageSender: Send + Sync {
async fn post(
&self,
url: &str,
body: &serde_json::Value,
auth: &str,
) -> Result<serde_json::Value, AttemptFailure>;
}
struct HttpSender;
#[async_trait]
impl ImageSender for HttpSender {
async fn post(
&self,
url: &str,
body: &serde_json::Value,
auth: &str,
) -> Result<serde_json::Value, AttemptFailure> {
attempt_image_generation(url, body, auth).await
}
}
async fn generate_image_with_retries(
url: &str,
body: &serde_json::Value,
auth: &str,
sender: &dyn ImageSender,
) -> Result<serde_json::Value, AttemptOutcome> {
let mut failures: Vec<AttemptFailure> = Vec::new();
for attempt in 1..=IMAGE_GEN_MAX_ATTEMPTS {
match sender.post(url, body, auth).await {
Ok(body) => return Ok(body),
Err(failure) => {
let retryable = failure.retryable();
failures.push(failure);
if !retryable || attempt == IMAGE_GEN_MAX_ATTEMPTS {
return Err(AttemptOutcome::of(failures));
}
let sleep_ms = failures
.last()
.and_then(AttemptFailure::retry_after_ms)
.map_or(IMAGE_GEN_BACKOFF_MS, |ms| {
ms.clamp(IMAGE_GEN_BACKOFF_MS, RETRY_AFTER_MAX_MS)
});
if !crate::shutdown::sleep_or_shutdown(Duration::from_millis(sleep_ms)).await {
return Err(AttemptOutcome::Aborted);
}
}
}
}
unreachable!("loop is bounded by IMAGE_GEN_MAX_ATTEMPTS")
}
async fn attempt_image_generation(
url: &str,
body: &serde_json::Value,
auth: &str,
) -> Result<serde_json::Value, AttemptFailure> {
let started = Instant::now();
let client = crate::util::http::image_gen_http_client();
let response = match client
.post(url)
.header("Authorization", auth)
.json(body)
.send()
.await
{
Ok(response) => response,
Err(e) => {
let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(0);
return Err(if e.is_timeout() {
AttemptFailure::Timeout {
elapsed_ms,
connect: e.is_connect(),
}
} else {
tracing::debug!(error = %e, "Image generation transport error");
AttemptFailure::Transport { elapsed_ms }
});
}
};
let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(0);
if !response.status().is_success() {
let status = response.status().as_u16();
let retry_after_ms = retry_after_header(response.headers());
let body = read_error_body(response, "Image generation").await;
return Err(AttemptFailure::Http {
status,
body,
retry_after_ms,
elapsed_ms,
});
}
let body_text = match response.text().await {
Ok(text) => text,
Err(e) => {
tracing::warn!(error = %e, "Failed to read image generation response body");
return Err(AttemptFailure::BodyRead);
}
};
let parsed =
crate::util::http::parse_json_response(&body_text, "Image generation").map_err(|e| {
AttemptFailure::Parse {
message: e.to_string(),
}
})?;
Ok(parsed)
}
fn build_terminal_message(failures: &[AttemptFailure], has_references: bool) -> String {
let last = failures.last().expect("at least one failure");
let cause = failure_cause(last, has_references);
let attempts = failures.len();
let prefix = if attempts > 1 {
format!("Image generation failed after {attempts} attempt(s): ")
} else if matches!(last, AttemptFailure::Parse { .. }) {
String::new()
} else {
"Image generation failed: ".to_string()
};
format!("{prefix}{cause}")
}
fn describe_failure(failures: &[AttemptFailure], has_references: bool) -> String {
let cause = failure_cause(
failures.last().expect("at least one failure"),
has_references,
);
if failures.len() > 1 {
format!("{cause} ({} attempts)", failures.len())
} else {
cause
}
}
fn failure_cause(last: &AttemptFailure, has_references: bool) -> String {
match last {
AttemptFailure::Parse { message } => message.clone(),
AttemptFailure::Timeout {
elapsed_ms,
connect,
} => {
let secs = elapsed_ms / 1000;
if *connect {
format!(
"connection timed out after {secs} s — the request never reached \
the provider"
)
} else {
format!(
"request timed out after {secs} s (client-side 10-minute limit) — \
the provider may still complete the generation server-side; \
not auto-retried"
)
}
}
AttemptFailure::Transport { elapsed_ms } => {
if *elapsed_ms >= IMAGE_GEN_QUICK_FAILURE_MS {
format!(
"transport error after {} s — the connection was lost mid-flight; the \
generation may have completed and been billed server-side; verify \
before retrying",
elapsed_ms / 1000,
)
} else {
"transport error — the request did not reach the provider or no response \
was received"
.to_string()
}
}
AttemptFailure::Http { status, body, .. } => {
describe_http_failure(*status, body, has_references)
}
AttemptFailure::BodyRead => {
"received a 2xx response but the body could not be read — the generation may \
have completed and been billed server-side; verify before retrying"
.to_string()
}
}
}
fn describe_http_failure(status: u16, body: &str, has_references: bool) -> String {
let typed = match status {
400 => "provider refused the request (HTTP 400)".to_string(),
402 => "insufficient credits — add credits to the provider account and retry (HTTP 402)"
.to_string(),
429 => "rate limit exceeded (HTTP 429)".to_string(),
502 => "provider unavailable (HTTP 502)".to_string(),
503 => "provider overloaded (HTTP 503)".to_string(),
504 => "provider timeout (HTTP 504)".to_string(),
524 | 529 => format!("edge timeout or overload (HTTP {status})"),
_ => format!("HTTP {status}"),
};
let provider_msg = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| crate::util::extract_provider_error_detail(&v));
let mut described = match provider_msg {
Some(msg) => format!("{typed}: {msg}"),
None => typed,
};
if status == 400 && has_references {
described.push_str(
" If the refusal is about the number of input images, the provider may \
accept fewer than the catalog declares — retry with fewer images.",
);
}
described
}
fn build_request_body(
model: &str,
prompt: &str,
aspect_ratio: Option<&str>,
size: Option<(&'static str, String)>,
references: &[crate::util::ReferenceImage],
info: Option<&ImageModelInfo>,
) -> serde_json::Value {
let mut body = json!({
"model": model,
"prompt": prompt,
});
if let Some(info) = info {
if info.declares("aspect_ratio")
&& let Some(ratio) = aspect_ratio
{
let ratio = if ratio == "auto" && !info.enum_contains("aspect_ratio", "auto") {
DEFAULT_ASPECT_RATIO
} else {
ratio
};
body["aspect_ratio"] = json!(ratio);
}
if !references.is_empty() {
body[super::INPUT_REFERENCES_KEY] = super::reference_json(references);
}
} else {
if let Some(ratio) = aspect_ratio {
body["aspect_ratio"] = json!(ratio);
}
if !references.is_empty() {
body[super::INPUT_REFERENCES_KEY] = super::reference_json(references);
}
}
if let Some((key, value)) = size {
body[key] = json!(value);
}
body
}
fn validate_reference_count(
model: &str,
info: &ImageModelInfo,
count: usize,
voice: RefusalVoice,
) -> Result<(), String> {
let Some(max) = info
.range_max("input_references")
.map(super::effective_reference_max)
else {
return Err(match voice {
RefusalVoice::OwnModel => format!(
"Model `{model}` does not support reference images. \
Retry without the `images` parameter."
),
RefusalVoice::Candidate => "does not support reference images".to_string(),
});
};
#[expect(clippy::cast_possible_wrap)]
if count as i64 <= max {
return Ok(());
}
Err(match voice {
RefusalVoice::OwnModel => format!(
"Model `{model}` supports at most {max} reference image(s), got {count}. \
Retry with fewer images.",
),
RefusalVoice::Candidate => {
format!("supports at most {max} reference image(s), got {count}")
}
})
}
fn resolve_size(
model: &str,
size: Option<&str>,
info: &ImageModelInfo,
voice: RefusalVoice,
) -> anyhow::Result<Option<(&'static str, String)>> {
let Some(size) = size.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(None);
};
let (key, value) = if size.contains(['x', 'X', '×']) {
("size", size.to_string())
} else if info.declares("resolution") {
("resolution", size.to_uppercase())
} else {
("size", size.to_uppercase())
};
if !info.declares(key) {
return Err(unsupported_size(model, size, info, voice));
}
if let Some(values) = info.enum_values(key)
&& !values.iter().any(|v| v.eq_ignore_ascii_case(&value))
{
return Err(unsupported_size(model, size, info, voice));
}
Ok(Some((key, value)))
}
fn unsupported_size(
model: &str,
size: &str,
info: &ImageModelInfo,
voice: RefusalVoice,
) -> anyhow::Error {
let supported: Vec<&str> = ["resolution", "size"]
.iter()
.filter_map(|param| info.enum_values(param))
.flatten()
.map(String::as_str)
.collect();
match (voice, supported.is_empty()) {
(RefusalVoice::OwnModel, true) => anyhow::anyhow!(
"usage: model `{model}` has no adjustable image size — \
hint: omit the `size` argument"
),
(RefusalVoice::OwnModel, false) => anyhow::anyhow!(
"usage: model `{model}` does not support size `{size}` — \
hint: pass a size it supports: {}",
supported.join(", ")
),
(RefusalVoice::Candidate, true) => anyhow::anyhow!("has no adjustable image size"),
(RefusalVoice::Candidate, false) => anyhow::anyhow!(
"does not support size `{size}` — \
it accepts: {}",
supported.join(", ")
),
}
}
static CANONICAL_ASPECT_RATIOS: &[(&str, f64)] = &[
("1:1", 1.0),
("16:9", 16.0 / 9.0),
("9:16", 9.0 / 16.0),
("4:3", 4.0 / 3.0),
("3:4", 3.0 / 4.0),
("3:2", 3.0 / 2.0),
("2:3", 2.0 / 3.0),
("4:5", 4.0 / 5.0),
("5:4", 5.0 / 4.0),
("1:2", 1.0 / 2.0),
("2:1", 2.0 / 1.0),
("1:4", 1.0 / 4.0),
("4:1", 4.0 / 1.0),
("21:9", 21.0 / 9.0),
("9:21", 9.0 / 21.0),
("1:8", 1.0 / 8.0),
("8:1", 8.0 / 1.0),
("9:19.5", 9.0 / 19.5),
("19.5:9", 19.5 / 9.0),
("9:20", 9.0 / 20.0),
("20:9", 20.0 / 9.0),
];
fn resolve_aspect_ratio(aspect_ratio: Option<&str>, reference_paths: &[PathBuf]) -> String {
match aspect_ratio {
Some(ar) => ar.to_string(),
None if !reference_paths.is_empty() => {
let first = &reference_paths[0];
if let Some(ratio) = detect_aspect_ratio_from_image(first) {
tracing::debug!(
"Auto-detected aspect ratio {ratio} from reference image `{}`",
first.display(),
);
ratio.to_string()
} else {
tracing::debug!(
"Could not detect aspect ratio from reference image `{}`, falling back to {DEFAULT_ASPECT_RATIO}",
first.display(),
);
DEFAULT_ASPECT_RATIO.to_string()
}
}
None => DEFAULT_ASPECT_RATIO.to_string(),
}
}
fn detect_aspect_ratio_from_image(path: &Path) -> Option<&'static str> {
let (width, height) = image::image_dimensions(path).ok()?;
find_closest_aspect_ratio(width, height)
}
fn find_closest_aspect_ratio(width: u32, height: u32) -> Option<&'static str> {
if width == 0 || height == 0 {
return None;
}
let ratio = f64::from(width) / f64::from(height);
CANONICAL_ASPECT_RATIOS
.iter()
.min_by(|a, b| {
let da = (ratio - a.1).abs();
let db = (ratio - b.1).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(name, _)| *name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ExtractImageError {
Provider { err_type: String, message: String },
NoImageData,
}
impl std::fmt::Display for ExtractImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Provider { err_type, message } => write!(f, "{err_type}: {message}"),
Self::NoImageData => {
write!(f, "response did not contain image data (data[].b64_json)")
}
}
}
}
fn extract_response_image(
body: &serde_json::Value,
) -> Result<(String, Option<String>), ExtractImageError> {
if let Some(err) = body.get("error") {
let err_type = err
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("provider error");
let message = err
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("no details");
return Err(ExtractImageError::Provider {
err_type: err_type.to_string(),
message: message.to_string(),
});
}
let Some(data) = body.get("data").and_then(|v| v.as_array()) else {
return Err(ExtractImageError::NoImageData);
};
for entry in data {
if let Some(b64) = entry.get("b64_json").and_then(|v| v.as_str())
&& !b64.is_empty()
{
let media_type = entry
.get("media_type")
.and_then(|v| v.as_str())
.map(String::from);
return Ok((b64.to_string(), media_type));
}
}
Err(ExtractImageError::NoImageData)
}
#[must_use]
fn extension_for_media_type(media_type: Option<&str>) -> &'static str {
match media_type {
Some("image/jpeg") => "jpg",
Some("image/webp") => "webp",
Some("image/svg+xml") => "svg",
_ => "png",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::media_catalog::image::{ImageCatalog, parse_catalog};
use crate::util::UnwrapPoison;
use RefusalVoice::OwnModel;
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
fn fixture_catalog() -> ImageCatalog {
parse_catalog(&json!({
"data": [
{
"id": "qwen/qwen-image-3-pro",
"architecture": { "output_modalities": ["image"] },
"supported_parameters": {
"resolution": { "type": "enum", "values": ["1K", "2K"] },
"aspect_ratio": { "type": "enum", "values": ["1:1", "9:16"] },
"input_references": { "type": "range", "min": 0, "max": 4 }
}
},
{
"id": "recraft/recraft-v4.1",
"architecture": { "output_modalities": ["image"] },
"supported_parameters": {
"aspect_ratio": { "type": "enum", "values": ["1:1", "9:16", "auto"] },
"input_references": { "type": "range", "min": 0, "max": 1 }
}
},
{
"id": "acme/pixels",
"architecture": { "output_modalities": ["image"] },
"supported_parameters": {
"size": { "type": "enum", "values": ["1024x1024", "2048x2048"] }
}
},
{
"id": "huge/refs",
"architecture": { "output_modalities": ["image"] },
"supported_parameters": {
"input_references": { "type": "range", "min": 0, "max": 20 }
}
},
{
"id": "text-only/model",
"architecture": { "output_modalities": ["text"] },
"supported_parameters": {}
}
]
}))
.expect("valid fixture")
}
struct Refs {
_dir: tempfile::TempDir,
paths: Vec<PathBuf>,
loaded: Vec<crate::util::ReferenceImage>,
}
impl Refs {
fn none() -> Self {
Self {
_dir: tempfile::TempDir::new().unwrap(),
paths: Vec::new(),
loaded: Vec::new(),
}
}
}
async fn refs(n: usize) -> Refs {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("ref.png");
std::fs::write(&path, crate::util::test::noisy_png(1, 1)).unwrap();
let mut loaded = Vec::with_capacity(n);
for _ in 0..n {
loaded.push(
crate::util::load_reference_image(&path, super::super::MAX_REFERENCE_IMAGE_BYTES)
.await
.unwrap(),
);
}
Refs {
_dir: dir,
paths: vec![path; n],
loaded,
}
}
#[tokio::test]
async fn test_build_request_body_catalog_driven() {
let catalog = fixture_catalog();
let qwen = catalog.find("qwen/qwen-image-3-pro");
let refs = refs(2).await.loaded;
let size = resolve_size(
"qwen/qwen-image-3-pro",
Some("2k"),
qwen.expect("fixture"),
OwnModel,
)
.unwrap();
assert_eq!(size, Some(("resolution", "2K".to_string())));
let body = build_request_body(
"qwen/qwen-image-3-pro",
"a cat",
Some("9:16"),
size,
&refs,
qwen,
);
assert_eq!(body["model"], "qwen/qwen-image-3-pro");
assert_eq!(body["prompt"], "a cat");
assert_eq!(body["resolution"], "2K");
assert_eq!(body["aspect_ratio"], "9:16");
assert_eq!(body["input_references"].as_array().unwrap().len(), 2);
assert!(
body["input_references"][0]["image_url"]["url"]
.as_str()
.unwrap()
.starts_with("data:image/png;base64,"),
"reference should be a data URI"
);
assert!(body.get("size").is_none());
assert!(body.get("n").is_none());
}
#[tokio::test]
async fn test_build_request_body_omits_undeclared_and_validates_caps() {
let catalog = fixture_catalog();
let recraft = catalog.find("recraft/recraft-v4.1");
let qwen = catalog.find("qwen/qwen-image-3-pro");
let acme = catalog.find("acme/pixels");
let err = resolve_size(
"recraft/recraft-v4.1",
Some("2K"),
recraft.expect("fixture"),
OwnModel,
)
.unwrap_err();
assert!(
err.to_string().contains("has no adjustable image size"),
"unexpected error: {err}"
);
let err = resolve_size(
"qwen/qwen-image-3-pro",
Some("512"),
qwen.expect("fixture"),
OwnModel,
)
.unwrap_err();
assert!(
err.to_string().contains("1K, 2K"),
"unexpected error: {err}"
);
let err = resolve_size(
"qwen/qwen-image-3-pro",
Some("1024X1024"),
qwen.expect("fixture"),
OwnModel,
)
.unwrap_err();
assert!(
err.to_string().contains("1K, 2K"),
"unexpected error: {err}"
);
assert_eq!(
resolve_size(
"qwen/qwen-image-3-pro",
Some("1k"),
qwen.expect("fixture"),
OwnModel
)
.unwrap(),
Some(("resolution", "1K".to_string()))
);
let size = resolve_size(
"acme/pixels",
Some("1024x1024"),
acme.expect("fixture"),
OwnModel,
)
.unwrap();
assert_eq!(size, Some(("size", "1024x1024".to_string())));
let body = build_request_body("acme/pixels", "p", None, size, &[], acme);
assert_eq!(body["size"], "1024x1024");
let err = resolve_size(
"acme/pixels",
Some("512x512"),
acme.expect("fixture"),
OwnModel,
)
.unwrap_err();
assert!(
err.to_string().contains("1024x1024, 2048x2048"),
"unexpected error: {err}"
);
let body = build_request_body("qwen/qwen-image-3-pro", "p", Some("auto"), None, &[], qwen);
assert_eq!(body["aspect_ratio"], "9:16");
let body = build_request_body(
"recraft/recraft-v4.1",
"p",
Some("auto"),
None,
&[],
recraft,
);
assert_eq!(body["aspect_ratio"], "auto");
let err = validate_reference_count("qwen/qwen-image-3-pro", qwen.unwrap(), 5, OwnModel)
.unwrap_err();
assert!(err.contains("at most 4 reference image(s)"));
let text_only = catalog.find("text-only/model").unwrap();
let err = validate_reference_count("text-only/model", text_only, 1, OwnModel).unwrap_err();
assert!(err.contains("does not support reference images"));
let err = validate_reference_count(
"qwen/qwen-image-3-pro",
qwen.unwrap(),
5,
RefusalVoice::Candidate,
)
.unwrap_err();
assert_eq!(err, "supports at most 4 reference image(s), got 5");
let huge = catalog.find("huge/refs").unwrap();
validate_reference_count("huge/refs", huge, 16, OwnModel).expect("16 references accepted");
let err = validate_reference_count("huge/refs", huge, 17, OwnModel).unwrap_err();
assert!(err.contains("at most 16 reference image(s)"));
}
#[tokio::test]
async fn test_build_request_body_fail_open_minimal() {
let refs = refs(1).await.loaded;
let body = build_request_body("any/model", "p", Some("16:9"), None, &refs, None);
assert_eq!(body["model"], "any/model");
assert_eq!(body["prompt"], "p");
assert_eq!(body["aspect_ratio"], "16:9");
assert_eq!(body["input_references"].as_array().unwrap().len(), 1);
assert!(body.get("resolution").is_none());
assert!(body.get("size").is_none());
let body = build_request_body("any/model", "p", None, None, &[], None);
assert!(body.get("aspect_ratio").is_none());
assert!(body.get("input_references").is_none());
}
#[test]
fn test_extract_response_image_variants() {
let body = json!({
"data": [{ "b64_json": "aGVsbG8=", "media_type": "image/jpeg" }],
"usage": { "cost": 0.001 }
});
let (b64, media_type) = extract_response_image(&body).expect("image");
assert_eq!(b64, "aGVsbG8=");
assert_eq!(media_type.as_deref(), Some("image/jpeg"));
assert_eq!(STANDARD.decode(b64.as_bytes()).unwrap(), b"hello");
let body = json!({
"data": [
{ "b64_json": "" },
{ "b64_json": "d29ybGQ=" }
]
});
let (b64, media_type) = extract_response_image(&body).expect("image");
assert_eq!(b64, "d29ybGQ=");
assert_eq!(media_type, None);
assert_eq!(
extract_response_image(&json!({"data": []})).unwrap_err(),
ExtractImageError::NoImageData
);
assert_eq!(
extract_response_image(&json!({})).unwrap_err(),
ExtractImageError::NoImageData
);
let err_body = json!({
"error": { "type": "provider_error", "message": "upstream refused" }
});
let err = extract_response_image(&err_body).unwrap_err();
assert_eq!(err.to_string(), "provider_error: upstream refused");
assert!(matches!(err, ExtractImageError::Provider { .. }));
}
#[test]
fn test_extension_for_media_type() {
assert_eq!(extension_for_media_type(Some("image/png")), "png");
assert_eq!(extension_for_media_type(Some("image/jpeg")), "jpg");
assert_eq!(extension_for_media_type(Some("image/webp")), "webp");
assert_eq!(extension_for_media_type(Some("image/svg+xml")), "svg");
assert_eq!(extension_for_media_type(None), "png");
assert_eq!(
extension_for_media_type(Some("application/octet-stream")),
"png"
);
}
#[test]
fn find_closest_aspect_ratio_cases() {
struct Case {
name: &'static str,
width: u32,
height: u32,
expected: Option<&'static str>,
}
let mut cases: Vec<Case> = CANONICAL_ASPECT_RATIOS
.iter()
.map(|&(ratio_str, ratio_val)| {
let (w, h) = ratio_tuple_from_f64(ratio_val);
Case {
name: ratio_str,
width: w,
height: h,
expected: Some(ratio_str),
}
})
.collect();
cases.extend([
Case {
name: "between_3x2",
width: 1400,
height: 900,
expected: Some("3:2"),
},
Case {
name: "between_16x9",
width: 1700,
height: 900,
expected: Some("16:9"),
},
Case {
name: "exact_5x4",
width: 5,
height: 4,
expected: Some("5:4"),
},
Case {
name: "between_4x5",
width: 17,
height: 20,
expected: Some("4:5"),
},
Case {
name: "zero_width",
width: 0,
height: 100,
expected: None,
},
Case {
name: "zero_height",
width: 100,
height: 0,
expected: None,
},
Case {
name: "zero_both",
width: 0,
height: 0,
expected: None,
},
]);
for case in &cases {
assert_eq!(
find_closest_aspect_ratio(case.width, case.height),
case.expected,
"case: {name} (w={width}, h={height})",
name = case.name,
width = case.width,
height = case.height,
);
}
}
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn ratio_tuple_from_f64(ratio: f64) -> (u32, u32) {
let scale = 10_000_000.0;
let w = (ratio * scale).round() as u32;
let h = scale as u32;
(w, h)
}
const MINI_2X1_PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAC0lEQVR4nGNgAAMAAAcAAbKGrPQAAAAASUVORK5CYII=";
const MINI_16X9_PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAJCAIAAAC0SDtlAAAADklEQVR4nGNgGAVDEgAAAbkAAftY4pIAAAAASUVORK5CYII=";
#[test]
fn test_detect_aspect_ratio_from_real_png() {
use base64::{Engine as _, engine::general_purpose::STANDARD};
let png_bytes = STANDARD.decode(MINI_2X1_PNG_B64).expect("valid base64");
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("test.png");
std::fs::write(&path, &png_bytes).expect("write");
assert_eq!(detect_aspect_ratio_from_image(&path), Some("2:1"));
}
#[test]
fn test_detect_aspect_ratio_16x9_png() {
use base64::{Engine as _, engine::general_purpose::STANDARD};
let png_bytes = STANDARD.decode(MINI_16X9_PNG_B64).expect("valid base64");
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("wide.png");
std::fs::write(&path, &png_bytes).expect("write");
assert_eq!(detect_aspect_ratio_from_image(&path), Some("16:9"));
}
#[test]
fn test_detect_aspect_ratio_missing_file() {
let result = detect_aspect_ratio_from_image(Path::new("/nonexistent/image.png"));
assert_eq!(result, None);
}
#[test]
fn test_attempt_failure_retryable_decisions() {
assert!(AttemptFailure::Transport { elapsed_ms: 5_000 }.retryable());
assert!(
AttemptFailure::Timeout {
elapsed_ms: 5_000,
connect: true
}
.retryable()
);
assert!(
!AttemptFailure::Timeout {
elapsed_ms: 600_000,
connect: false
}
.retryable()
);
assert!(
!AttemptFailure::Timeout {
elapsed_ms: 5_000,
connect: false
}
.retryable()
);
for status in [429, 502, 503, 524, 529] {
let f = AttemptFailure::Http {
status,
body: String::new(),
retry_after_ms: None,
elapsed_ms: 10_000,
};
assert!(f.retryable(), "status {status} should be retryable");
}
for status in [400, 401, 402, 404, 413, 500, 504] {
let f = AttemptFailure::Http {
status,
body: String::new(),
retry_after_ms: None,
elapsed_ms: 10_000,
};
assert!(!f.retryable(), "status {status} should not be retryable");
}
assert!(!AttemptFailure::BodyRead.retryable());
assert!(
!AttemptFailure::Parse {
message: "x".into()
}
.retryable()
);
}
#[test]
fn test_describe_http_failure_typed_codes() {
let msg = describe_http_failure(402, "{}", false);
assert!(msg.contains("insufficient credits"));
let msg = describe_http_failure(503, r#"{"error":{"message":"upstream busy"}}"#, false);
assert!(msg.contains("provider overloaded (HTTP 503)"));
assert!(msg.contains("upstream busy"));
let msg = describe_http_failure(504, "plain text", false);
assert!(msg.contains("provider timeout (HTTP 504)"));
assert_eq!(describe_http_failure(418, "{}", false), "HTTP 418");
}
#[test]
fn test_describe_http_failure_refusal_hint_only_with_references() {
let msg = describe_http_failure(
400,
r#"{"error":{"message":"Model qwen-image-3.0-pro supports 0~3 image content items. Got 4 image items."}}"#,
true,
);
assert!(msg.contains("provider refused the request (HTTP 400)"));
assert!(msg.contains("supports 0~3 image content items"));
assert!(msg.contains("retry with fewer images"));
assert!(!msg.contains("content policy"));
let msg = describe_http_failure(400, r#"{"error":{"message":"invalid size"}}"#, false);
assert!(msg.contains("provider refused the request (HTTP 400)"));
assert!(msg.contains("invalid size"));
assert!(!msg.contains("input images"));
assert!(!msg.contains("content policy"));
}
#[test]
fn test_describe_http_failure_nested_envelope_detail() {
let msg = describe_http_failure(
400,
r#"{"error":{"code":"data_inspection_failed","metadata":{"raw":"Input image data may contain inappropriate content."}}}"#,
true,
);
assert!(msg.contains("provider refused the request (HTTP 400)"));
assert!(
msg.contains("Input image data may contain inappropriate content."),
"nested metadata.raw must surface: {msg}"
);
let msg = describe_http_failure(
400,
r#"{"error":{"code":"data_inspection_failed","type":"invalid_request_error"}}"#,
false,
);
assert!(
msg.contains("data_inspection_failed"),
"code fallback: {msg}"
);
}
#[test]
fn test_build_terminal_message_states_true_cause() {
let msg = build_terminal_message(
&[AttemptFailure::Timeout {
elapsed_ms: 600_000,
connect: false,
}],
false,
);
assert!(msg.contains("timed out after 600 s"));
assert!(msg.contains("not auto-retried"));
let msg = build_terminal_message(
&[
AttemptFailure::Timeout {
elapsed_ms: 30_000,
connect: true,
},
AttemptFailure::Timeout {
elapsed_ms: 45_000,
connect: true,
},
],
false,
);
assert!(msg.contains("failed after 2 attempt(s)"));
assert!(msg.contains("connection timed out"));
let msg = build_terminal_message(
&[AttemptFailure::Timeout {
elapsed_ms: 10_000,
connect: true,
}],
false,
);
assert!(msg.contains("never reached the provider"));
assert!(!msg.contains("retried"));
let msg = build_terminal_message(
&[AttemptFailure::Http {
status: 503,
body: r#"{"error":{"message":"down"}}"#.into(),
retry_after_ms: None,
elapsed_ms: 1_000,
}],
false,
);
assert!(msg.contains("provider overloaded (HTTP 503)"));
let http_400 = AttemptFailure::Http {
status: 400,
body: r#"{"error":{"message":"too many images"}}"#.into(),
retry_after_ms: None,
elapsed_ms: 1_000,
};
let msg = build_terminal_message(&[http_400], true);
assert!(msg.contains("retry with fewer images"));
assert!(msg.contains("too many images"));
let msg = build_terminal_message(
&[AttemptFailure::Transport {
elapsed_ms: 300_000,
}],
false,
);
assert!(msg.contains("may have completed and been billed"));
let msg = build_terminal_message(
&[
AttemptFailure::Transport { elapsed_ms: 5_000 },
AttemptFailure::Parse {
message: "Image generation response parse error: bad".into(),
},
],
false,
);
assert!(msg.contains("failed after 2 attempt(s)"));
assert!(msg.contains("parse error"));
}
struct Scripted {
answers: Mutex<HashMap<String, VecDeque<Result<serde_json::Value, AttemptFailure>>>>,
sent: Mutex<Vec<(String, serde_json::Value)>>,
}
#[async_trait]
impl ImageSender for Scripted {
async fn post(
&self,
url: &str,
body: &serde_json::Value,
_auth: &str,
) -> Result<serde_json::Value, AttemptFailure> {
let model = body["model"]
.as_str()
.expect("every request body names its model")
.to_string();
self.sent
.lock()
.unwrap_poison()
.push((model.clone(), body.clone()));
let mut answers = self.answers.lock().unwrap_poison();
let queue = answers
.get_mut(&model)
.unwrap_or_else(|| panic!("unscripted image POST to `{model}` ({url})"));
queue
.pop_front()
.unwrap_or_else(|| panic!("scripted answers for `{model}` exhausted ({url})"))
}
}
impl Scripted {
fn new() -> Self {
Self {
answers: Mutex::new(HashMap::new()),
sent: Mutex::new(Vec::new()),
}
}
fn answer(&self, model: &str, answer: Result<serde_json::Value, AttemptFailure>) {
self.answers
.lock()
.unwrap_poison()
.entry(model.to_string())
.or_default()
.push_back(answer);
}
fn models_sent(&self) -> Vec<String> {
self.sent
.lock()
.unwrap_poison()
.iter()
.map(|(model, _)| model.clone())
.collect()
}
fn body_for(&self, model: &str) -> serde_json::Value {
self.sent
.lock()
.unwrap_poison()
.iter()
.rev()
.find(|(sent_to, _)| sent_to.as_str() == model)
.map_or_else(
|| panic!("no POST was made to `{model}`"),
|(_, body)| body.clone(),
)
}
}
fn http(status: u16, body: &str) -> AttemptFailure {
AttemptFailure::Http {
status,
body: body.to_string(),
retry_after_ms: None,
elapsed_ms: 1_000,
}
}
fn served(b64: &str) -> serde_json::Value {
json!({"data": [{"b64_json": b64}]})
}
fn picker(models: &[&str]) -> Vec<String> {
models.iter().map(|model| (*model).to_string()).collect()
}
fn seed_fixture() {
crate::tools::media_catalog::image::seed_cache(Some(Arc::new(fixture_catalog())));
}
async fn seed_fixture_catalog() {
seed_fixture();
let _ = resolve_image_model(crate::config::DEFAULT_PROVIDER_ENDPOINT, "probe/absent").await;
seed_fixture();
}
struct CatalogFixture;
impl Drop for CatalogFixture {
fn drop(&mut self) {
seed_fixture();
}
}
struct ConfigFixture(Option<crate::config::ConfigData>);
impl Drop for ConfigFixture {
fn drop(&mut self) {
if let Some(config) = self.0.take() {
crate::config::CONFIG.swap(config);
}
}
}
async fn plan_for(
own: &str,
refs: &Refs,
size_arg: Option<&str>,
candidates: &[&str],
) -> WalkPlan {
plan_walk(own, refs.loaded.len(), size_arg, &picker(candidates))
.await
.expect("the plan walks")
}
async fn planned_report(
own: &str,
size_arg: Option<&str>,
candidates: &[&str],
refs: Refs,
scripted: &Scripted,
) -> WalkReport {
let plan = plan_for(own, &refs, size_arg, candidates).await;
walk_report(own, plan, size_arg, refs, scripted).await
}
struct WalkReport {
walked_is_served: Option<String>,
failure: Option<String>,
note: Option<String>,
sent: Vec<String>,
}
async fn walk_report(
own: &str,
plan: WalkPlan,
size_arg: Option<&str>,
refs: Refs,
scripted: &Scripted,
) -> WalkReport {
let WalkPlan {
own_step,
candidates,
catalog_reached,
} = plan;
let Refs {
_dir,
paths,
loaded,
} = refs;
let mut references = loaded;
let aspect_ratio = request_aspect_ratio(None, &paths, catalog_reached);
let mut walk = ImageWalk {
request: RequestMaterial {
prompt: "a cat",
aspect_ratio,
size_arg,
references: &mut references,
},
own_model: own,
url: "https://example.invalid/images",
auth: "Bearer test",
sender: scripted,
trail: Vec::new(),
stopped: None,
};
let walked = walk
.run(own_step, candidates)
.await
.expect("the walk completes");
let (walked_is_served, failure, note) = match walked {
Walked::Served { model, .. } => {
let note = walk.fallback_note(&model);
(Some(model), None, note)
}
Walked::Unserved => (None, Some(walk.failure_message()), None),
};
WalkReport {
walked_is_served,
failure,
note,
sent: scripted.models_sent(),
}
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn walk_falls_back_to_another_picker_model_and_notes_it() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let fallback = "recraft/recraft-v4.1";
let scripted = Scripted::new();
scripted.answer(
own,
Err(http(400, r#"{"error":{"message":"unsupported"}}"#)),
);
scripted.answer(fallback, Ok(served("aGVsbG8=")));
let report = planned_report(own, None, &[fallback], Refs::none(), &scripted).await;
assert_eq!(report.walked_is_served.as_deref(), Some(fallback));
assert_eq!(report.sent, [own, fallback]);
assert_eq!(scripted.body_for(fallback)["model"], fallback);
let note = report.note.expect("a fallback carries the note");
assert!(
note.contains(fallback),
"the note names the model that served: {note}"
);
assert!(
note.contains(own),
"the note names the account's own model: {note}"
);
assert!(
note.contains("did not serve the request"),
"the note says what the own model did: {note}"
);
assert!(
note.contains("unsupported"),
"the note carries the provider's message: {note}"
);
let generated = Generated {
path: std::path::PathBuf::from("x.png"),
note: Some(note),
};
assert!(
ImageGenTool
.result_text(&generated)
.contains("did not serve the request")
);
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn the_fallback_note_scrubs_the_providers_own_words() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let fallback = "recraft/recraft-v4.1";
let scripted = Scripted::new();
scripted.answer(
own,
Err(http(
400,
r#"{"error":{"message":"rejected: api_key: sk-live-0123456789"}}"#,
)),
);
scripted.answer(fallback, Ok(served("aGVsbG8=")));
let report = planned_report(own, None, &[fallback], Refs::none(), &scripted).await;
let note = report.note.expect("a fallback carries the note");
assert!(
!note.contains("sk-live-0123456789"),
"a credential in the provider's answer is redacted: {note}"
);
assert!(note.contains("*[REDACTED]"), "{note}");
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn walk_starts_when_the_own_model_is_refused_before_sending() {
seed_fixture_catalog().await;
let own = "recraft/recraft-v4.1";
let fallback = "acme/pixels";
let size_arg = Some("2048x2048");
let scripted = Scripted::new();
scripted.answer(fallback, Ok(served("aGVsbG8=")));
let refs = Refs::none();
let plan = plan_for(own, &refs, size_arg, &[fallback]).await;
assert!(
matches!(plan.own_step, Step::Skip(_)),
"the own model must be refused before sending"
);
let report = walk_report(own, plan, size_arg, refs, &scripted).await;
assert_eq!(report.walked_is_served.as_deref(), Some(fallback));
assert_eq!(
report.sent,
[fallback],
"the refused own model is never POSTed to"
);
assert_eq!(scripted.body_for(fallback)["size"], "2048x2048");
let note = report.note.expect("a fallback carries the note");
assert!(
note.contains("was never sent to"),
"the note says the own model was never sent to: {note}"
);
assert!(
note.contains("no adjustable image size"),
"the note carries the refusal: {note}"
);
assert!(
note.contains("hint:"),
"the note carries the refusal's size hint: {note}"
);
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn walk_skips_candidates_that_cannot_serve_the_request() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let absent = "gone/model";
let text_only = "text-only/model";
let capped = "recraft/recraft-v4.1";
let scripted = Scripted::new();
scripted.answer(
own,
Err(http(400, r#"{"error":{"message":"unsupported"}}"#)),
);
let report = planned_report(
own,
None,
&[absent, text_only, capped],
refs(2).await,
&scripted,
)
.await;
assert_eq!(report.walked_is_served, None);
assert_eq!(report.sent, [own], "no candidate may be POSTed to");
let failure = report.failure.expect("nothing served");
assert!(
failure.starts_with(
"Image generation failed: no configured image model served the request."
),
"{failure}"
);
for (model, reason) in [
(absent, NOT_AN_IMAGE_MODEL),
(text_only, NOT_AN_IMAGE_MODEL),
(capped, "supports at most 1 reference image(s), got 2"),
] {
let line = failure
.lines()
.find(|line| line.contains(model))
.unwrap_or_else(|| panic!("`{model}` is missing from: {failure}"));
assert!(
line.contains(reason),
"`{model}` says why it was skipped: {line}"
);
}
assert!(
failure.contains(
"`recraft/recraft-v4.1`: not sent to — \
supports at most 1 reference image(s), got 2"
),
"a candidate's refusal is the fact alone: {failure}"
);
assert!(
!failure.contains("Set an image-capable model"),
"a candidate's reason drops the account's remediation: {failure}"
);
assert_eq!(crate::util::truncate_tool_output(&failure), failure);
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn a_size_refused_candidate_keeps_the_walks_own_voice() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let scripted = Scripted::new();
scripted.answer(
own,
Err(http(400, r#"{"error":{"message":"unsupported"}}"#)),
);
let report = planned_report(
own,
Some("1K"),
&["recraft/recraft-v4.1", "acme/pixels"],
Refs::none(),
&scripted,
)
.await;
assert_eq!(report.walked_is_served, None);
assert_eq!(report.sent, [own], "no candidate may be POSTed to");
let failure = report.failure.expect("nothing served");
assert!(
failure.contains("`recraft/recraft-v4.1`: not sent to — has no adjustable image size"),
"{failure}"
);
assert!(
failure.contains("`acme/pixels`: not sent to — does not support size `1K`"),
"{failure}"
);
assert!(
failure.contains("it accepts: 1024x1024, 2048x2048"),
"the fact names what the model does take: {failure}"
);
assert!(
!failure.contains("usage:") && !failure.contains("hint:"),
"the account's own refusal framing has no place in a candidate's line: {failure}"
);
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn walk_visits_each_model_once_in_picker_order() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let other = "recraft/recraft-v4.1";
let scripted = Scripted::new();
scripted.answer(own, Err(http(402, r#"{"error":{"message":"no credits"}}"#)));
scripted.answer(
other,
Err(http(402, r#"{"error":{"message":"no credits"}}"#)),
);
let report = planned_report(
own,
None,
&[own, other, own, other, other],
Refs::none(),
&scripted,
)
.await;
assert_eq!(report.walked_is_served, None);
assert_eq!(
report.sent,
[own, other],
"one POST per distinct model, in picker order, the own model first"
);
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn walk_never_reissues_an_unknown_outcome() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let fallback = "recraft/recraft-v4.1";
for (failure, cause) in [
(
AttemptFailure::Timeout {
elapsed_ms: 600_000,
connect: false,
},
"timed out after 600 s",
),
(AttemptFailure::BodyRead, "body could not be read"),
] {
let scripted = Scripted::new();
scripted.answer(own, Err(failure));
scripted.answer(fallback, Ok(served("aGVsbG8=")));
let report = planned_report(own, None, &[fallback], Refs::none(), &scripted).await;
assert_eq!(report.walked_is_served, None);
assert_eq!(report.sent, [own], "one POST: no retry, no walk");
let failure = report.failure.expect("nothing served");
assert!(
failure.starts_with("Image generation failed:"),
"the single-model formatter reports it: {failure}"
);
assert!(failure.contains(cause), "{failure}");
assert!(
failure.contains(own),
"the model that ran out is named: {failure}"
);
assert!(
failure.contains("outcome is unknown"),
"the stop reason is stated: {failure}"
);
assert!(failure.contains("The walk stopped there:"), "{failure}");
}
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn walk_stop_reason_appears_only_when_a_model_was_left_untried() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let fallback = "recraft/recraft-v4.1";
let timed_out = || AttemptFailure::Timeout {
elapsed_ms: 600_000,
connect: false,
};
let scripted = Scripted::new();
scripted.answer(own, Err(timed_out()));
let report = planned_report(own, None, &[], Refs::none(), &scripted).await;
let failure = report.failure.expect("nothing served");
assert_eq!(
failure,
build_terminal_message(std::slice::from_ref(&timed_out()), false),
"with nothing to walk to the failure is exactly as before the walk"
);
let scripted = Scripted::new();
scripted.answer(own, Err(timed_out()));
let report = planned_report(own, None, &[fallback], Refs::none(), &scripted).await;
let failure = report.failure.expect("nothing served");
assert!(failure.contains("The walk stopped there:"), "{failure}");
assert!(!failure.contains(fallback), "{failure}");
let scripted = Scripted::new();
scripted.answer(own, Err(http(400, r#"{"error":{"message":"nope"}}"#)));
scripted.answer(fallback, Err(timed_out()));
let report = planned_report(own, None, &[fallback], Refs::none(), &scripted).await;
let failure = report.failure.expect("nothing served");
assert!(
failure.contains(fallback),
"the last model is named: {failure}"
);
assert!(
!failure.contains("The walk stopped there:"),
"nothing was left untried, so there is nothing to explain: {failure}"
);
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn single_model_failure_is_exactly_as_before_the_walk() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let refusal = r#"{"error":{"message":"unsupported"}}"#;
let scripted = Scripted::new();
scripted.answer(own, Err(http(400, refusal)));
let report = planned_report(own, None, &[], Refs::none(), &scripted).await;
assert_eq!(
report.failure.as_deref(),
Some(build_terminal_message(&[http(400, refusal)], false).as_str())
);
assert_eq!(report.sent, [own]);
let overload = r#"{"error":{"message":"down"}}"#;
let scripted = Scripted::new();
scripted.answer(own, Err(http(503, overload)));
scripted.answer(own, Err(http(503, overload)));
let report = planned_report(own, None, &[], Refs::none(), &scripted).await;
assert_eq!(
report.failure.as_deref(),
Some(
build_terminal_message(&[http(503, overload), http(503, overload)], false).as_str()
)
);
assert_eq!(report.sent, [own, own], "the retry belongs to the attempt");
let reason = resolve_size(
"recraft/recraft-v4.1",
Some("2K"),
fixture_catalog()
.find("recraft/recraft-v4.1")
.expect("fixture"),
OwnModel,
)
.expect_err("a size recraft cannot express")
.to_string();
let scripted = Scripted::new();
let report = walk_report(
own,
WalkPlan {
own_step: Step::Skip(reason.clone()),
candidates: Vec::new(),
catalog_reached: true,
},
None,
Refs::none(),
&scripted,
)
.await;
assert_eq!(report.failure.as_deref(), Some(reason.as_str()));
assert!(report.sent.is_empty());
}
fn refused_walk<'a>(
sender: &'a Scripted,
models: usize,
message: &str,
stopped: Option<String>,
) -> ImageWalk<'a> {
let refusal = format!(r#"{{"error":{{"message":"{message}"}}}}"#);
ImageWalk {
request: RequestMaterial {
prompt: "a cat",
aspect_ratio: None,
size_arg: None,
references: &mut [],
},
own_model: "vendor/model-0",
url: "https://example.invalid/images",
auth: "Bearer test",
sender,
trail: (0..models)
.map(|index| {
(
format!("vendor/model-{index}"),
Visited::Attempted(vec![http(400, &refusal)]),
)
})
.collect(),
stopped,
}
}
fn delivered(failure: &str) -> String {
crate::tools::format_tool_failure_feedback(
"image_gen",
&json!({ "prompt": "生成".repeat(600) }),
&crate::tools::tool_failure_reason("image_gen", failure),
)
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn long_multi_byte_answers_do_not_crowd_out_other_models() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let candidates = ["recraft/recraft-v4.1", "acme/pixels", "huge/refs"];
let refusal = format!(
r#"{{"error":{{"message":"{}"}}}}"#,
"запрос отклонён провайдером. ".repeat(200)
);
let scripted = Scripted::new();
scripted.answer(own, Err(http(400, &refusal)));
for model in candidates {
scripted.answer(model, Err(http(400, &refusal)));
}
let report = planned_report(own, None, &candidates, Refs::none(), &scripted).await;
let failure = report.failure.expect("nothing served");
for model in std::iter::once(own).chain(candidates) {
assert!(
failure.contains(model),
"`{model}` keeps its line: {failure}"
);
}
assert!(failure.contains('…'), "a cut answer is marked: {failure}");
let envelope = delivered(&failure);
assert_eq!(
crate::util::truncate_tool_output(&envelope),
envelope,
"the whole result the model receives survives the truncation a tool result goes through"
);
for model in std::iter::once(own).chain(candidates) {
assert!(
envelope.contains(model),
"`{model}` keeps its line: {envelope}"
);
}
}
#[tokio::test]
async fn the_walk_failure_fits_the_tool_output_budget_it_is_delivered_in() {
let sender = Scripted::new();
let walk = refused_walk(
&sender,
20,
&"запрос отклонён провайдером. ".repeat(200),
Some("`vendor/model-18` was not sent to: request body too large".to_string()),
);
let failure = walk.failure_message();
assert!(
failure.len() <= WALK_MESSAGE_BYTES,
"{} bytes over the failure's own budget",
failure.len()
);
let envelope = delivered(&failure);
assert_eq!(
crate::util::truncate_tool_output(&envelope),
envelope,
"the composed result is not truncated: {} bytes",
envelope.len()
);
for index in 0..20 {
assert!(
envelope.contains(&format!("vendor/model-{index}")),
"every model keeps its line: {envelope}"
);
}
assert!(envelope.contains('…'), "a cut answer is marked: {envelope}");
}
#[tokio::test]
async fn a_picker_too_long_to_name_stays_inside_the_message_room() {
let sender = Scripted::new();
let walk = refused_walk(&sender, 200, "no", None);
let failure = walk.failure_message();
assert!(
failure.len() <= WALK_MESSAGE_BYTES,
"{} bytes over the failure's own budget",
failure.len()
);
let envelope = delivered(&failure);
assert_eq!(
crate::util::truncate_tool_output(&envelope),
envelope,
"the composed result is not truncated: {} bytes",
envelope.len()
);
}
#[test]
fn fallback_candidates_take_the_picker_list_without_the_tried_model() {
assert_eq!(
fallback_candidates("m", &picker(&["m", "a", "m", "b", "a"])),
picker(&["a", "b"])
);
assert!(fallback_candidates("m", &[]).is_empty());
assert!(fallback_candidates("m", &picker(&["m"])).is_empty());
assert_eq!(
fallback_candidates("m", &picker(&["b", "a", "b"])),
picker(&["b", "a"])
);
}
#[test]
fn walkable_failures_are_the_model_or_provider_side_ones() {
for status in [400, 401, 402, 404, 429, 500, 503] {
for elapsed_ms in [1_000, 90_000] {
let failure = AttemptFailure::Http {
status,
body: String::new(),
retry_after_ms: None,
elapsed_ms,
};
assert!(failure.walkable(), "HTTP {status} after {elapsed_ms} ms");
}
}
assert!(AttemptFailure::Transport { elapsed_ms: 1_000 }.walkable());
assert!(!AttemptFailure::Transport { elapsed_ms: 90_000 }.walkable());
assert!(
AttemptFailure::Timeout {
elapsed_ms: 10_000,
connect: true,
}
.walkable()
);
assert!(
!AttemptFailure::Timeout {
elapsed_ms: 1_000,
connect: false,
}
.walkable()
);
assert!(
!AttemptFailure::Timeout {
elapsed_ms: 600_000,
connect: false,
}
.walkable()
);
assert!(!AttemptFailure::BodyRead.walkable());
assert!(
!AttemptFailure::Parse {
message: "bad".into()
}
.walkable()
);
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn candidate_body_is_built_from_its_own_declarations() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let candidate = "huge/refs";
let scripted = Scripted::new();
scripted.answer(
own,
Err(http(400, r#"{"error":{"message":"unsupported"}}"#)),
);
scripted.answer(
candidate,
Err(http(400, r#"{"error":{"message":"unsupported"}}"#)),
);
let report = planned_report(own, None, &[candidate], refs(1).await, &scripted).await;
assert_eq!(report.walked_is_served, None);
assert_eq!(report.sent, [own, candidate]);
let own_body = scripted.body_for(own);
assert_eq!(own_body["aspect_ratio"], "1:1");
let body = scripted.body_for(candidate);
assert!(
body.get("aspect_ratio").is_none(),
"huge/refs declares no `aspect_ratio`: {body}"
);
assert!(
body.get("resolution").is_none(),
"huge/refs declares no `resolution`: {body}"
);
assert!(
body.get("size").is_none(),
"huge/refs declares no `size`: {body}"
);
assert_eq!(body["input_references"].as_array().unwrap().len(), 1);
assert_eq!(
body["input_references"], own_body["input_references"],
"the request keeps carrying the same references"
);
let own = "qwen/qwen-image-3-pro";
let candidate = "acme/pixels";
let size_arg = Some("1024x1024");
let scripted = Scripted::new();
scripted.answer(
candidate,
Err(http(400, r#"{"error":{"message":"unsupported"}}"#)),
);
let report = planned_report(own, size_arg, &[candidate], Refs::none(), &scripted).await;
assert_eq!(report.sent, [candidate]);
let body = scripted.body_for(candidate);
assert!(
body.get("aspect_ratio").is_none(),
"acme/pixels declares no aspect_ratio: {body}"
);
assert_eq!(body["size"], "1024x1024");
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn walk_stops_when_the_catalog_cannot_be_reached() {
let own = "qwen/qwen-image-3-pro";
let candidate = "recraft/recraft-v4.1";
let after = "acme/pixels";
let _restore = CatalogFixture;
crate::tools::media_catalog::image::seed_cache(None);
let scripted = Scripted::new();
scripted.answer(
own,
Err(http(400, r#"{"error":{"message":"unsupported"}}"#)),
);
let report = walk_report(
own,
WalkPlan {
own_step: Step::Attempt {
info: None,
size: None,
},
candidates: picker(&[candidate, after]),
catalog_reached: false,
},
None,
Refs::none(),
&scripted,
)
.await;
assert_eq!(report.walked_is_served, None);
assert_eq!(report.sent, [own], "no candidate may be POSTed to blindly");
let failure = report.failure.expect("nothing served");
assert!(failure.contains(candidate), "{failure}");
assert!(
failure.contains("catalog could not be reached"),
"{failure}"
);
assert!(
failure.contains("remaining models were not checked"),
"the walk says why it stopped there: {failure}"
);
assert!(
!failure.contains(after),
"a model the walk never reached is not reported on: {failure}"
);
assert_eq!(crate::util::truncate_tool_output(&failure), failure);
}
#[tokio::test]
async fn plan_refuses_a_request_over_the_providers_reference_ceiling() {
let refused = plan_walk(
"qwen/qwen-image-3-pro",
super::super::MAX_REFERENCE_IMAGES_PER_REQUEST + 1,
None,
&picker(&["recraft/recraft-v4.1"]),
)
.await;
let Err(err) = refused else {
panic!("a request over the provider's ceiling must be refused");
};
assert!(
err.to_string()
.contains("at most 16 reference image(s), got 17"),
"{err}"
);
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn plan_walks_only_when_the_picker_offers_another_model() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let size_arg = Some("1024x1024");
let picker_list = picker(&["acme/pixels"]);
let refused = plan_walk(own, 0, size_arg, &[]).await;
let Err(err) = refused else {
panic!("with nothing to walk to the refusal must be the plan's error");
};
assert!(err.to_string().contains("does not support size"), "{err}");
let plan = plan_walk(own, 0, size_arg, &picker_list)
.await
.expect("a picker model to walk to");
assert!(matches!(plan.own_step, Step::Skip(_)));
assert_eq!(plan.candidates, picker_list);
assert!(plan.catalog_reached);
let _restore = CatalogFixture;
crate::tools::media_catalog::image::seed_cache(None);
let plan = plan_walk(own, 0, size_arg, &picker_list)
.await
.expect("an unreachable catalog is fail-open");
assert!(plan.candidates.is_empty());
assert!(!plan.catalog_reached);
assert!(matches!(
plan.own_step,
Step::Attempt {
info: None,
size: None
}
));
}
#[tokio::test]
#[serial_test::serial(active_models)]
async fn note_is_absent_when_the_accounts_own_model_served() {
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let scripted = Scripted::new();
scripted.answer(own, Ok(served("aGVsbG8=")));
let report = planned_report(own, None, &[], Refs::none(), &scripted).await;
assert_eq!(report.walked_is_served.as_deref(), Some(own));
assert_eq!(report.sent, [own]);
assert_eq!(report.note, None);
}
#[test]
fn own_step_turns_each_pre_send_refusal_into_a_walk_reason() {
let model = "qwen/qwen-image-3-pro";
let info = fixture_catalog().find(model).cloned().expect("fixture");
let recraft = fixture_catalog()
.find("recraft/recraft-v4.1")
.cloned()
.expect("fixture");
assert!(
matches!(
own_step(model, Err(anyhow::anyhow!("not in the catalog")), 0, None),
Step::Skip(reason) if reason.contains("not in the catalog")
),
"a model the catalog confirms absent is refused before sending"
);
assert!(
matches!(
own_step("recraft/recraft-v4.1", Ok(Some(recraft)), 2, None),
Step::Skip(reason) if reason.contains("at most 1 reference image")
),
"its own declared reference cap is a pre-send refusal"
);
assert!(
matches!(
own_step(model, Ok(Some(info)), 0, Some("1024x1024")),
Step::Skip(reason) if reason.contains("does not support size")
),
"a size its declarations cannot express is a pre-send refusal"
);
assert!(
matches!(
own_step(model, Ok(None), 0, Some("1K")),
Step::Attempt {
info: None,
size: None
}
),
"an unreachable catalog stays fail-open: nothing is checked, no size sent"
);
}
#[tokio::test]
async fn generated_payload_carries_the_note_into_the_annotation() {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("picture.png");
std::fs::write(&path, crate::util::test::noisy_png(1, 1)).expect("fixture picture");
let note = "`recraft/recraft-v4.1` produced this picture".to_string();
let annotation = Generated {
path: path.clone(),
note: Some(note.clone()),
}
.payload()
.await
.expect("a raster picture is attached")
.attached_annotation();
assert!(
annotation.contains(¬e),
"the annotation the assistant reads carries the note: {annotation}"
);
let plain = Generated { path, note: None }
.payload()
.await
.expect("a raster picture is attached")
.attached_annotation();
assert!(
!plain.contains("produced this picture"),
"a picture with nothing to disclose carries no note: {plain}"
);
}
#[tokio::test]
#[serial_test::serial(active_models, config_persist)]
async fn fallback_reaches_the_assistant_through_the_real_generate_path() {
crate::util::test::init_test_stores().await;
seed_fixture_catalog().await;
let own = "qwen/qwen-image-3-pro";
let fallback = "recraft/recraft-v4.1";
let user = "image_gen_fallback_user";
crate::users::store()
.set_image_gen_model(user, own)
.await
.expect("the account's own model is set");
let _config = ConfigFixture(Some(crate::config::CONFIG.snapshot()));
let _ = crate::config::CONFIG.set_string_field("provider_key", "test-key");
let _ = crate::config::CONFIG.set_string_field("image_gen_models", fallback);
let scripted = Scripted::new();
scripted.answer(
own,
Err(http(400, r#"{"error":{"message":"unsupported"}}"#)),
);
scripted.answer(
fallback,
Ok(served(&STANDARD.encode(crate::util::test::noisy_png(4, 4)))),
);
let dir = tempfile::TempDir::new().expect("tempdir");
let ws =
crate::workspace::test_ws_named(&dir.path().to_string_lossy(), "image-gen-fallback");
let output = crate::agent::CURRENT_TOOL_USER_NAME
.scope(user.to_string(), async {
let generated = ImageGenTool
.generate(&ws, &json!({ "prompt": "a cat" }), &scripted)
.await?;
anyhow::Ok(ImageGenTool.output(&generated).await)
})
.await;
let output = output.expect("a fallback-covered request is a success");
assert_eq!(
scripted.models_sent(),
[own, fallback],
"the account's own model first, then the model that served"
);
assert!(
output.text.contains("[IMAGE:"),
"the marker still reaches the caller: {}",
output.text
);
assert!(
!output.text_is_content,
"the picture is offered as a payload, not as text"
);
let annotation = output
.image_payloads
.first()
.expect("the picture is attached")
.attached_annotation();
assert!(
annotation.contains(fallback),
"the assistant is told which model produced the picture: {annotation}"
);
assert!(
annotation.contains(own) && annotation.contains("did not serve the request"),
"and what happened with the account's own model: {annotation}"
);
assert_eq!(
crate::users::resolve_image_gen_model(user).await,
own,
"the account keeps its own stored model choice"
);
}
}