Skip to main content

imagegen_bridge_core/
preset.rs

1//! Reusable, input-free image request presets.
2
3use 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/// Operation selected by a preset without retaining source image bytes.
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
14#[serde(rename_all = "snake_case")]
15pub enum PresetOperation {
16    /// Generate a new image.
17    #[default]
18    Generate,
19    /// Edit caller-supplied images.
20    Edit,
21}
22
23/// Complete reusable request configuration, excluding image inputs and idempotency keys.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25#[serde(default, deny_unknown_fields)]
26pub struct ImagePresetTemplate {
27    /// Optional reusable prompt. A caller may replace it when applying the preset.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub prompt: Option<String>,
30    /// Optional reusable negative prompt.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub negative_prompt: Option<String>,
33    /// Generate or edit operation; image inputs are always supplied at execution time.
34    pub operation: PresetOperation,
35    /// Image generation parameters.
36    pub parameters: GenerationParameters,
37    /// Provider and fallback routing.
38    pub routing: RoutingOptions,
39    /// Conversation behavior.
40    pub session: SessionOptions,
41    /// Artifact and response configuration.
42    pub output: OutputOptions,
43    /// Compatibility and prompt policies.
44    pub policies: RequestPolicies,
45    /// Optional request deadline in milliseconds.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub timeout_ms: Option<u64>,
48    /// Optional opaque end-user identifier.
49    #[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    /// Captures reusable configuration from a request while dropping image inputs and idempotency.
72    #[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    /// Builds an input-free request, using `prompt` before the stored prompt when supplied.
92    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/// Create payload for a named preset.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132#[serde(deny_unknown_fields)]
133pub struct ImagePresetCreate {
134    /// Stable URL- and CLI-safe preset name.
135    pub name: String,
136    /// Optional human explanation.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub description: Option<String>,
139    /// Complete reusable configuration.
140    pub template: ImagePresetTemplate,
141}
142
143/// Full replacement payload for a preset.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
145#[serde(deny_unknown_fields)]
146pub struct ImagePresetWrite {
147    /// Optional human explanation.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub description: Option<String>,
150    /// Complete reusable configuration.
151    pub template: ImagePresetTemplate,
152}
153
154/// Persisted named preset.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
156#[serde(deny_unknown_fields)]
157pub struct ImagePreset {
158    /// Stable preset name.
159    pub name: String,
160    /// Optional human explanation.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub description: Option<String>,
163    /// Complete reusable configuration.
164    pub template: ImagePresetTemplate,
165    /// Unix creation timestamp in seconds.
166    pub created: u64,
167    /// Unix last-update timestamp in seconds.
168    pub updated: u64,
169}
170
171/// Cursor-paginated preset collection.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
173#[serde(deny_unknown_fields)]
174pub struct ImagePresetPage {
175    /// Presets in stable name order.
176    pub items: Vec<ImagePreset>,
177    /// Opaque cursor for the next page.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub next_cursor: Option<String>,
180}
181
182/// Validates a preset resource name.
183pub 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
201/// Validates bounded preset metadata independent of provider capabilities.
202pub 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}