1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 BridgeError, ErrorCode, GenerationParameters, ImageInput, ImageOperation, ImageRequest,
8 ImageSource, OutputOptions, RequestLimits, RequestPolicies, RoutingOptions, SessionOptions,
9 validate_request,
10};
11
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
14#[serde(rename_all = "snake_case")]
15pub enum PresetOperation {
16 #[default]
18 Generate,
19 Edit,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25#[serde(default, deny_unknown_fields)]
26pub struct ImagePresetTemplate {
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub prompt: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub negative_prompt: Option<String>,
33 pub operation: PresetOperation,
35 pub parameters: GenerationParameters,
37 pub routing: RoutingOptions,
39 pub session: SessionOptions,
41 pub output: OutputOptions,
43 pub policies: RequestPolicies,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub timeout_ms: Option<u64>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub user: Option<String>,
51}
52
53impl Default for ImagePresetTemplate {
54 fn default() -> Self {
55 Self {
56 prompt: None,
57 negative_prompt: None,
58 operation: PresetOperation::Generate,
59 parameters: GenerationParameters::default(),
60 routing: RoutingOptions::default(),
61 session: SessionOptions::default(),
62 output: OutputOptions::default(),
63 policies: RequestPolicies::default(),
64 timeout_ms: None,
65 user: None,
66 }
67 }
68}
69
70impl ImagePresetTemplate {
71 #[must_use]
73 pub fn from_request(request: &ImageRequest) -> Self {
74 Self {
75 prompt: (!request.prompt.is_empty()).then(|| request.prompt.clone()),
76 negative_prompt: request.negative_prompt.clone(),
77 operation: match request.operation {
78 ImageOperation::Generate { .. } => PresetOperation::Generate,
79 ImageOperation::Edit { .. } => PresetOperation::Edit,
80 },
81 parameters: request.parameters.clone(),
82 routing: request.routing.clone(),
83 session: request.session.clone(),
84 output: request.output.clone(),
85 policies: request.policies.clone(),
86 timeout_ms: request.timeout_ms,
87 user: request.user.clone(),
88 }
89 }
90
91 pub fn request(&self, prompt: Option<String>) -> Result<ImageRequest, BridgeError> {
93 let prompt = prompt
94 .or_else(|| self.prompt.clone())
95 .filter(|value| !value.trim().is_empty())
96 .ok_or_else(|| {
97 BridgeError::new(
98 ErrorCode::InvalidRequest,
99 "preset application requires a prompt or a stored preset prompt",
100 )
101 .with_detail("field", "prompt")
102 })?;
103 let operation = match self.operation {
104 PresetOperation::Generate => ImageOperation::Generate {
105 reference_images: Vec::new(),
106 },
107 PresetOperation::Edit => ImageOperation::Edit {
108 images: Vec::new(),
109 mask: None,
110 reference_images: Vec::new(),
111 },
112 };
113 Ok(ImageRequest {
114 version: crate::CONTRACT_VERSION.to_owned(),
115 prompt,
116 negative_prompt: self.negative_prompt.clone(),
117 operation,
118 parameters: self.parameters.clone(),
119 routing: self.routing.clone(),
120 session: self.session.clone(),
121 output: self.output.clone(),
122 policies: self.policies.clone(),
123 idempotency_key: None,
124 timeout_ms: self.timeout_ms,
125 user: self.user.clone(),
126 })
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132#[serde(deny_unknown_fields)]
133pub struct ImagePresetCreate {
134 pub name: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub description: Option<String>,
139 pub template: ImagePresetTemplate,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
145#[serde(deny_unknown_fields)]
146pub struct ImagePresetWrite {
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub description: Option<String>,
150 pub template: ImagePresetTemplate,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
156#[serde(deny_unknown_fields)]
157pub struct ImagePreset {
158 pub name: String,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub description: Option<String>,
163 pub template: ImagePresetTemplate,
165 pub created: u64,
167 pub updated: u64,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
173#[serde(deny_unknown_fields)]
174pub struct ImagePresetPage {
175 pub items: Vec<ImagePreset>,
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub next_cursor: Option<String>,
180}
181
182pub fn validate_preset_name(name: &str) -> Result<(), BridgeError> {
184 let valid = (1..=64).contains(&name.len())
185 && name.bytes().enumerate().all(|(index, byte)| match byte {
186 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' => true,
187 b'.' | b'_' | b'-' => index > 0,
188 _ => false,
189 });
190 if valid {
191 Ok(())
192 } else {
193 Err(BridgeError::new(
194 ErrorCode::InvalidRequest,
195 "preset name must be 1-64 characters using letters, numbers, dot, underscore, or hyphen",
196 )
197 .with_detail("field", "name"))
198 }
199}
200
201pub fn validate_preset_write(write: &ImagePresetWrite) -> Result<(), BridgeError> {
203 if write.description.as_ref().is_some_and(|description| {
204 description.len() > 512 || description.chars().any(char::is_control)
205 }) {
206 return Err(BridgeError::new(
207 ErrorCode::InvalidRequest,
208 "preset description must contain at most 512 bytes and no control characters",
209 )
210 .with_detail("field", "description"));
211 }
212 if write
213 .template
214 .prompt
215 .as_ref()
216 .is_some_and(|prompt| prompt.trim().is_empty())
217 {
218 return Err(BridgeError::new(
219 ErrorCode::InvalidRequest,
220 "stored preset prompt cannot be empty",
221 )
222 .with_detail("field", "template.prompt"));
223 }
224 let mut request = write
225 .template
226 .request(Some("preset validation".to_owned()))?;
227 let placeholder = || ImageInput {
228 source: ImageSource::Base64 {
229 data: "AA==".to_owned(),
230 },
231 media_type: Some("image/png".to_owned()),
232 filename: Some("preset-input.png".to_owned()),
233 };
234 match &mut request.operation {
235 ImageOperation::Generate { reference_images }
236 if request.parameters.input_fidelity.is_some()
237 || request.parameters.action == crate::ImageAction::Edit =>
238 {
239 reference_images.push(placeholder());
240 }
241 ImageOperation::Edit { images, .. } => images.push(placeholder()),
242 ImageOperation::Generate { .. } => {}
243 }
244 validate_request(&request, RequestLimits::default())?;
245 Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn preset_round_trip_drops_inputs_and_idempotency() -> Result<(), BridgeError> {
254 let mut request = ImageRequest::generate("portrait");
255 request.idempotency_key = Some("one-shot".to_owned());
256 let template = ImagePresetTemplate::from_request(&request);
257 let restored = template.request(None)?;
258 assert_eq!(restored.prompt, "portrait");
259 assert_eq!(restored.idempotency_key, None);
260 assert!(restored.operation.reference_images().is_empty());
261 Ok(())
262 }
263
264 #[test]
265 fn preset_names_are_portable() {
266 assert!(validate_preset_name("portrait.high-v2").is_ok());
267 assert!(validate_preset_name("../escape").is_err());
268 assert!(validate_preset_name("spaces are not portable").is_err());
269 }
270
271 #[test]
272 fn preset_validation_rejects_intrinsically_invalid_configuration() {
273 let mut template = ImagePresetTemplate::default();
274 template.parameters.n = 0;
275 let write = ImagePresetWrite {
276 description: None,
277 template,
278 };
279 assert!(validate_preset_write(&write).is_err());
280 }
281}