use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{
BridgeError, ErrorCode, GenerationParameters, ImageInput, ImageOperation, ImageRequest,
ImageSource, OutputOptions, RequestLimits, RequestPolicies, RoutingOptions, SessionOptions,
validate_request,
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PresetOperation {
#[default]
Generate,
Edit,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ImagePresetTemplate {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
pub operation: PresetOperation,
pub parameters: GenerationParameters,
pub routing: RoutingOptions,
pub session: SessionOptions,
pub output: OutputOptions,
pub policies: RequestPolicies,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
impl Default for ImagePresetTemplate {
fn default() -> Self {
Self {
prompt: None,
negative_prompt: None,
operation: PresetOperation::Generate,
parameters: GenerationParameters::default(),
routing: RoutingOptions::default(),
session: SessionOptions::default(),
output: OutputOptions::default(),
policies: RequestPolicies::default(),
timeout_ms: None,
user: None,
}
}
}
impl ImagePresetTemplate {
#[must_use]
pub fn from_request(request: &ImageRequest) -> Self {
Self {
prompt: (!request.prompt.is_empty()).then(|| request.prompt.clone()),
negative_prompt: request.negative_prompt.clone(),
operation: match request.operation {
ImageOperation::Generate { .. } => PresetOperation::Generate,
ImageOperation::Edit { .. } => PresetOperation::Edit,
},
parameters: request.parameters.clone(),
routing: request.routing.clone(),
session: request.session.clone(),
output: request.output.clone(),
policies: request.policies.clone(),
timeout_ms: request.timeout_ms,
user: request.user.clone(),
}
}
pub fn request(&self, prompt: Option<String>) -> Result<ImageRequest, BridgeError> {
let prompt = prompt
.or_else(|| self.prompt.clone())
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
BridgeError::new(
ErrorCode::InvalidRequest,
"preset application requires a prompt or a stored preset prompt",
)
.with_detail("field", "prompt")
})?;
let operation = match self.operation {
PresetOperation::Generate => ImageOperation::Generate {
reference_images: Vec::new(),
},
PresetOperation::Edit => ImageOperation::Edit {
images: Vec::new(),
mask: None,
reference_images: Vec::new(),
},
};
Ok(ImageRequest {
version: crate::CONTRACT_VERSION.to_owned(),
prompt,
negative_prompt: self.negative_prompt.clone(),
operation,
parameters: self.parameters.clone(),
routing: self.routing.clone(),
session: self.session.clone(),
output: self.output.clone(),
policies: self.policies.clone(),
idempotency_key: None,
timeout_ms: self.timeout_ms,
user: self.user.clone(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImagePresetCreate {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub template: ImagePresetTemplate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImagePresetWrite {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub template: ImagePresetTemplate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImagePreset {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub template: ImagePresetTemplate,
pub created: u64,
pub updated: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImagePresetPage {
pub items: Vec<ImagePreset>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
pub fn validate_preset_name(name: &str) -> Result<(), BridgeError> {
let valid = (1..=64).contains(&name.len())
&& name.bytes().enumerate().all(|(index, byte)| match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' => true,
b'.' | b'_' | b'-' => index > 0,
_ => false,
});
if valid {
Ok(())
} else {
Err(BridgeError::new(
ErrorCode::InvalidRequest,
"preset name must be 1-64 characters using letters, numbers, dot, underscore, or hyphen",
)
.with_detail("field", "name"))
}
}
pub fn validate_preset_write(write: &ImagePresetWrite) -> Result<(), BridgeError> {
if write.description.as_ref().is_some_and(|description| {
description.len() > 512 || description.chars().any(char::is_control)
}) {
return Err(BridgeError::new(
ErrorCode::InvalidRequest,
"preset description must contain at most 512 bytes and no control characters",
)
.with_detail("field", "description"));
}
if write
.template
.prompt
.as_ref()
.is_some_and(|prompt| prompt.trim().is_empty())
{
return Err(BridgeError::new(
ErrorCode::InvalidRequest,
"stored preset prompt cannot be empty",
)
.with_detail("field", "template.prompt"));
}
let mut request = write
.template
.request(Some("preset validation".to_owned()))?;
let placeholder = || ImageInput {
source: ImageSource::Base64 {
data: "AA==".to_owned(),
},
media_type: Some("image/png".to_owned()),
filename: Some("preset-input.png".to_owned()),
};
match &mut request.operation {
ImageOperation::Generate { reference_images }
if request.parameters.input_fidelity.is_some()
|| request.parameters.action == crate::ImageAction::Edit =>
{
reference_images.push(placeholder());
}
ImageOperation::Edit { images, .. } => images.push(placeholder()),
ImageOperation::Generate { .. } => {}
}
validate_request(&request, RequestLimits::default())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preset_round_trip_drops_inputs_and_idempotency() -> Result<(), BridgeError> {
let mut request = ImageRequest::generate("portrait");
request.idempotency_key = Some("one-shot".to_owned());
let template = ImagePresetTemplate::from_request(&request);
let restored = template.request(None)?;
assert_eq!(restored.prompt, "portrait");
assert_eq!(restored.idempotency_key, None);
assert!(restored.operation.reference_images().is_empty());
Ok(())
}
#[test]
fn preset_names_are_portable() {
assert!(validate_preset_name("portrait.high-v2").is_ok());
assert!(validate_preset_name("../escape").is_err());
assert!(validate_preset_name("spaces are not portable").is_err());
}
#[test]
fn preset_validation_rejects_intrinsically_invalid_configuration() {
let mut template = ImagePresetTemplate::default();
template.parameters.n = 0;
let write = ImagePresetWrite {
description: None,
template,
};
assert!(validate_preset_write(&write).is_err());
}
}