Skip to main content

imagegen_bridge_core/
validation.rs

1//! Intrinsic request validation independent of provider capabilities.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    ArtifactCollisionPolicy, BridgeError, ErrorCode, ImageAction, ImageOperation, ImageRequest,
8    ImageSource, NegativePromptMode, OutputFormat, ResponseFormat, SessionMode,
9};
10
11const MAX_EMBEDDED_REQUEST_TEXT_BYTES: usize = 12 * 1024;
12const MAX_VALIDATION_ISSUES: usize = 64;
13const MAX_DETAILED_INPUT_VALIDATIONS: usize = 64;
14
15/// Configurable limits applied before provider negotiation.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(deny_unknown_fields)]
18pub struct RequestLimits {
19    /// Maximum UTF-8 prompt bytes.
20    pub max_prompt_bytes: usize,
21    /// Maximum UTF-8 negative-prompt bytes.
22    pub max_negative_prompt_bytes: usize,
23    /// Generic maximum output count before provider-specific validation.
24    pub max_outputs: u8,
25    /// Generic maximum input image count.
26    pub max_inputs: usize,
27    /// Maximum inline encoded characters before decoding.
28    pub max_inline_encoded_bytes: usize,
29    /// Maximum explicit edge before provider-specific validation.
30    pub max_edge: u32,
31    /// Maximum request timeout.
32    pub max_timeout_ms: u64,
33    /// Maximum user-controlled identifier bytes.
34    pub max_identifier_bytes: usize,
35}
36
37impl Default for RequestLimits {
38    fn default() -> Self {
39        Self {
40            max_prompt_bytes: 128 * 1024,
41            max_negative_prompt_bytes: 64 * 1024,
42            max_outputs: 16,
43            max_inputs: 16,
44            max_inline_encoded_bytes: 64 * 1024 * 1024,
45            max_edge: 16_384,
46            max_timeout_ms: 30 * 60 * 1_000,
47            max_identifier_bytes: 256,
48        }
49    }
50}
51
52/// One intrinsic validation issue.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(deny_unknown_fields)]
55pub struct ValidationIssue {
56    /// JSON-style field path.
57    pub field: String,
58    /// Stable issue code.
59    pub code: String,
60    /// Human-readable redaction-safe explanation.
61    pub message: String,
62}
63
64/// Validates a request before any provider or I/O work occurs.
65pub fn validate_request(request: &ImageRequest, limits: RequestLimits) -> Result<(), BridgeError> {
66    let issues = validation_issues(request, limits);
67    if issues.is_empty() {
68        return Ok(());
69    }
70    Err(
71        BridgeError::new(ErrorCode::InvalidRequest, "request validation failed")
72            .with_detail("issues", issues),
73    )
74}
75
76/// Returns all intrinsic issues in deterministic field order.
77#[must_use]
78pub fn validation_issues(request: &ImageRequest, limits: RequestLimits) -> Vec<ValidationIssue> {
79    let mut issues = Vec::with_capacity(MAX_VALIDATION_ISSUES);
80    let mut issues_truncated = false;
81    let mut issue = |field: &str, code: &str, message: &str| {
82        if issues.len() < MAX_VALIDATION_ISSUES - 1 {
83            issues.push(ValidationIssue {
84                field: field.to_owned(),
85                code: code.to_owned(),
86                message: message.to_owned(),
87            });
88        } else {
89            issues_truncated = true;
90        }
91    };
92
93    if request.version != crate::CONTRACT_VERSION {
94        issue(
95            "version",
96            "unsupported_version",
97            "only contract version 1 is supported",
98        );
99    }
100    if request.prompt.trim().is_empty() {
101        issue("prompt", "required", "prompt must not be empty");
102    } else if request.prompt.len() > limits.max_prompt_bytes {
103        issue(
104            "prompt",
105            "too_large",
106            "prompt exceeds the configured byte limit",
107        );
108    }
109    if let Some(negative_prompt) = &request.negative_prompt {
110        if negative_prompt.trim().is_empty() {
111            issue(
112                "negative_prompt",
113                "empty",
114                "negative prompt must be omitted instead of empty",
115            );
116        } else if negative_prompt.len() > limits.max_negative_prompt_bytes {
117            issue(
118                "negative_prompt",
119                "too_large",
120                "negative prompt exceeds the configured byte limit",
121            );
122        }
123        if request.policies.negative_prompt == NegativePromptMode::Reject {
124            issue(
125                "policies.negative_prompt",
126                "rejected_by_policy",
127                "negative prompt is present while its policy is reject",
128            );
129        }
130    }
131
132    if request.parameters.n == 0 || request.parameters.n > limits.max_outputs {
133        issue(
134            "parameters.n",
135            "out_of_range",
136            "output count is outside the configured generic range",
137        );
138    }
139    if let Some((width, height)) = request.parameters.size.dimensions()
140        && (width > limits.max_edge || height > limits.max_edge)
141    {
142        issue(
143            "parameters.size",
144            "out_of_range",
145            "an image edge exceeds the configured generic limit",
146        );
147    }
148    if !request.parameters.size.is_auto()
149        && (request.parameters.aspect_ratio.is_some() || request.parameters.resolution.is_some())
150    {
151        issue(
152            "parameters.size",
153            "ambiguous",
154            "explicit size cannot be combined with aspect_ratio or resolution hints",
155        );
156    }
157    if request
158        .parameters
159        .output_compression
160        .is_some_and(|value| value > 100)
161    {
162        issue(
163            "parameters.output_compression",
164            "out_of_range",
165            "output compression must be between 0 and 100",
166        );
167    }
168    if request.parameters.output_compression.is_some()
169        && !matches!(
170            request.parameters.output_format,
171            OutputFormat::Jpeg | OutputFormat::Webp
172        )
173    {
174        issue(
175            "parameters.output_compression",
176            "incompatible",
177            "output compression is valid only for jpeg or webp",
178        );
179    }
180    if request.parameters.partial_images > 3 {
181        issue(
182            "parameters.partial_images",
183            "out_of_range",
184            "partial image count must be between 0 and 3",
185        );
186    }
187
188    validate_identifier(
189        request.routing.provider.as_deref(),
190        "routing.provider",
191        limits,
192        &mut issue,
193    );
194    validate_identifier(
195        request.routing.model.as_deref(),
196        "routing.model",
197        limits,
198        &mut issue,
199    );
200    if request.routing.fallbacks.len() > 4 {
201        issue(
202            "routing.fallbacks",
203            "too_many",
204            "at most four fallback routes may be configured",
205        );
206    }
207    let mut fallback_names = std::collections::BTreeSet::new();
208    for (index, route) in request.routing.fallbacks.iter().enumerate() {
209        validate_identifier(
210            Some(&route.provider),
211            &format!("routing.fallbacks[{index}].provider"),
212            limits,
213            &mut issue,
214        );
215        validate_identifier(
216            route.model.as_deref(),
217            &format!("routing.fallbacks[{index}].model"),
218            limits,
219            &mut issue,
220        );
221        if !fallback_names.insert((&route.provider, route.model.as_deref())) {
222            issue(
223                &format!("routing.fallbacks[{index}]"),
224                "duplicate",
225                "fallback routes must be unique",
226            );
227        }
228        if request.routing.provider.as_deref() == Some(route.provider.as_str())
229            && request.routing.model.as_deref() == route.model.as_deref()
230        {
231            issue(
232                &format!("routing.fallbacks[{index}]"),
233                "duplicate",
234                "a fallback route must differ from the explicit primary route",
235            );
236        }
237    }
238    if !request.routing.fallbacks.is_empty() && request.session.mode != SessionMode::Isolated {
239        issue(
240            "routing.fallbacks",
241            "incompatible",
242            "provider fallback is supported only for isolated sessions",
243        );
244    }
245    if request.policies.batch_execution == crate::BatchExecution::Parallel
246        && request.session.mode != SessionMode::Isolated
247    {
248        issue(
249            "policies.batch_execution",
250            "incompatible",
251            "parallel batch execution requires an isolated session",
252        );
253    }
254    validate_identifier(
255        request.idempotency_key.as_deref(),
256        "idempotency_key",
257        limits,
258        &mut issue,
259    );
260    validate_identifier(request.user.as_deref(), "user", limits, &mut issue);
261
262    if request.output.transparency.transparent_threshold
263        >= request.output.transparency.opaque_threshold
264    {
265        issue(
266            "output.transparency.transparent_threshold",
267            "out_of_range",
268            "transparent threshold must be lower than opaque threshold",
269        );
270    }
271    if let Some(key) = request.output.transparency.key_color.as_deref()
272        && !valid_hex_color(key)
273    {
274        issue(
275            "output.transparency.key_color",
276            "invalid_format",
277            "chroma key must be a hex RGB color like #00ff00",
278        );
279    }
280
281    match request.session.mode {
282        SessionMode::Isolated => {
283            if request.session.key.is_some() || request.session.thread_id.is_some() {
284                issue(
285                    "session",
286                    "incompatible",
287                    "isolated mode cannot include a key or thread_id",
288                );
289            }
290        }
291        SessionMode::Persistent => {
292            if request.session.key.as_deref().is_none_or(str::is_empty) {
293                issue(
294                    "session.key",
295                    "required",
296                    "persistent mode requires a non-empty session key",
297                );
298            }
299            if request.session.thread_id.is_some() {
300                issue(
301                    "session.thread_id",
302                    "incompatible",
303                    "persistent mode uses a key, not an explicit thread_id",
304                );
305            }
306        }
307        SessionMode::Thread => {
308            if request
309                .session
310                .thread_id
311                .as_deref()
312                .is_none_or(str::is_empty)
313            {
314                issue(
315                    "session.thread_id",
316                    "required",
317                    "thread mode requires a non-empty thread_id",
318                );
319            }
320            if request.session.key.is_some() {
321                issue(
322                    "session.key",
323                    "incompatible",
324                    "thread mode cannot include a persistent key",
325                );
326            }
327        }
328    }
329    validate_identifier(
330        request.session.key.as_deref(),
331        "session.key",
332        limits,
333        &mut issue,
334    );
335    validate_identifier(
336        request.session.thread_id.as_deref(),
337        "session.thread_id",
338        limits,
339        &mut issue,
340    );
341
342    if let Some(timeout_ms) = request.timeout_ms
343        && (timeout_ms == 0 || timeout_ms > limits.max_timeout_ms)
344    {
345        issue(
346            "timeout_ms",
347            "out_of_range",
348            "timeout is outside the configured range",
349        );
350    }
351
352    let artifact_delivery = matches!(
353        request.output.response_format,
354        ResponseFormat::Artifact | ResponseFormat::Url
355    );
356    if !artifact_delivery && request.output.filename_prefix.is_some() {
357        issue(
358            "output.filename_prefix",
359            "incompatible",
360            "filename_prefix requires artifact response format",
361        );
362    }
363    if !artifact_delivery
364        && (request.output.directory.is_some() || request.output.filename.is_some())
365    {
366        issue(
367            "output",
368            "incompatible",
369            "artifact path controls require artifact or url response format",
370        );
371    }
372    if request.output.filename.is_some() && request.output.filename_prefix.is_some() {
373        issue(
374            "output.filename",
375            "incompatible",
376            "filename cannot be combined with filename_prefix",
377        );
378    }
379    if request.output.filename.is_some() && request.parameters.n != 1 {
380        issue(
381            "output.filename",
382            "incompatible",
383            "an exact filename requires a single output",
384        );
385    }
386    if request.output.filename.is_none()
387        && request.output.collision != ArtifactCollisionPolicy::Error
388    {
389        issue(
390            "output.collision",
391            "incompatible",
392            "collision policy applies only to an explicit filename",
393        );
394    }
395    if request.output.metadata.writes_sidecar()
396        && request.output.response_format != ResponseFormat::Artifact
397    {
398        issue(
399            "output.metadata",
400            "incompatible",
401            "metadata sidecars require artifact response format",
402        );
403    }
404    if request.output.metadata.embeds()
405        && request.output.response_format == ResponseFormat::Metadata
406    {
407        issue(
408            "output.metadata",
409            "incompatible",
410            "embedded metadata requires an image-bearing response format",
411        );
412    }
413    if request.output.metadata.embeds()
414        && request
415            .prompt
416            .len()
417            .saturating_add(request.negative_prompt.as_deref().map_or(0, str::len))
418            > MAX_EMBEDDED_REQUEST_TEXT_BYTES
419    {
420        issue(
421            "output.metadata",
422            "too_large",
423            "embedded metadata requires combined prompt text no larger than 12 KiB",
424        );
425    }
426    if let Some(directory) = request.output.directory.as_deref()
427        && !valid_portable_directory(directory)
428    {
429        issue(
430            "output.directory",
431            "invalid",
432            "output directory must be a safe portable relative path",
433        );
434    }
435    if let Some(filename) = request.output.filename.as_deref()
436        && !valid_output_filename(filename, request.parameters.output_format)
437    {
438        issue(
439            "output.filename",
440            "invalid",
441            "output filename must be a safe component with a matching image extension",
442        );
443    }
444
445    let (edit_images, mask, references) = match &request.operation {
446        ImageOperation::Generate { reference_images } => (0, None, reference_images.as_slice()),
447        ImageOperation::Edit {
448            images,
449            mask,
450            reference_images,
451        } => {
452            if images.is_empty() {
453                issue(
454                    "images",
455                    "required",
456                    "edit operation requires at least one source image",
457                );
458            }
459            (images.len(), mask.as_ref(), reference_images.as_slice())
460        }
461    };
462    let input_count = edit_images
463        .saturating_add(usize::from(mask.is_some()))
464        .saturating_add(references.len());
465    if input_count > limits.max_inputs {
466        issue(
467            "operation",
468            "too_many_inputs",
469            "total input image count exceeds the configured limit",
470        );
471    }
472    let mut detailed_inputs = 0_usize;
473    if let ImageOperation::Edit { images, .. } = &request.operation {
474        for (index, input) in images
475            .iter()
476            .take(MAX_DETAILED_INPUT_VALIDATIONS)
477            .enumerate()
478        {
479            validate_input(input, &format!("images[{index}]"), limits, &mut issue);
480            detailed_inputs += 1;
481        }
482    }
483    if let Some(mask) = mask
484        && detailed_inputs < MAX_DETAILED_INPUT_VALIDATIONS
485    {
486        validate_input(mask, "mask", limits, &mut issue);
487        detailed_inputs += 1;
488    }
489    for (index, input) in references
490        .iter()
491        .take(MAX_DETAILED_INPUT_VALIDATIONS.saturating_sub(detailed_inputs))
492        .enumerate()
493    {
494        validate_input(
495            input,
496            &format!("reference_images[{index}]"),
497            limits,
498            &mut issue,
499        );
500    }
501    if request.parameters.input_fidelity.is_some() && edit_images + references.len() == 0 {
502        issue(
503            "parameters.input_fidelity",
504            "incompatible",
505            "input_fidelity requires at least one source or reference image",
506        );
507    }
508    match (&request.operation, request.parameters.action) {
509        (ImageOperation::Generate { reference_images }, ImageAction::Edit)
510            if reference_images.is_empty() =>
511        {
512            issue(
513                "parameters.action",
514                "incompatible",
515                "edit action requires an image in context",
516            );
517        }
518        (ImageOperation::Edit { .. }, ImageAction::Generate) => issue(
519            "parameters.action",
520            "incompatible",
521            "generate action conflicts with the edit operation",
522        ),
523        _ => {}
524    }
525
526    if issues_truncated {
527        issues.push(ValidationIssue {
528            field: "$".to_owned(),
529            code: "too_many_issues".to_owned(),
530            message: "additional validation issues were omitted".to_owned(),
531        });
532    }
533    issues.sort_by(|left, right| {
534        left.field
535            .cmp(&right.field)
536            .then(left.code.cmp(&right.code))
537    });
538    issues
539}
540
541fn valid_hex_color(value: &str) -> bool {
542    let value = value.strip_prefix('#').unwrap_or(value);
543    value.len() == 6 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
544}
545
546fn valid_portable_directory(value: &str) -> bool {
547    !value.is_empty()
548        && value.len() <= 512
549        && value.split('/').all(|component| {
550            !component.is_empty()
551                && component != "."
552                && component != ".."
553                && component.len() <= 128
554                && !component.starts_with('.')
555                && component
556                    .bytes()
557                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
558        })
559}
560
561fn valid_output_filename(value: &str, format: OutputFormat) -> bool {
562    if value.is_empty()
563        || value.len() > 160
564        || value.starts_with('.')
565        || value.contains(['/', '\\'])
566        || !value
567            .bytes()
568            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
569    {
570        return false;
571    }
572    let Some((_, extension)) = value.rsplit_once('.') else {
573        return true;
574    };
575    match format {
576        OutputFormat::Png => extension.eq_ignore_ascii_case("png"),
577        OutputFormat::Jpeg => {
578            extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg")
579        }
580        OutputFormat::Webp => extension.eq_ignore_ascii_case("webp"),
581    }
582}
583
584fn validate_identifier(
585    value: Option<&str>,
586    field: &str,
587    limits: RequestLimits,
588    issue: &mut impl FnMut(&str, &str, &str),
589) {
590    let Some(value) = value else { return };
591    if value.trim().is_empty() {
592        issue(
593            field,
594            "empty",
595            "identifier must be omitted instead of empty",
596        );
597    } else if value.len() > limits.max_identifier_bytes {
598        issue(
599            field,
600            "too_large",
601            "identifier exceeds the configured byte limit",
602        );
603    } else if value.chars().any(char::is_control) {
604        issue(
605            field,
606            "invalid",
607            "identifier must not contain control characters",
608        );
609    }
610}
611
612fn validate_input(
613    input: &crate::ImageInput,
614    field: &str,
615    limits: RequestLimits,
616    issue: &mut impl FnMut(&str, &str, &str),
617) {
618    match &input.source {
619        ImageSource::File { path } if path.as_os_str().is_empty() => {
620            issue(field, "empty", "file path must not be empty");
621        }
622        ImageSource::Url { url } if url.trim().is_empty() => {
623            issue(field, "empty", "URL must not be empty");
624        }
625        ImageSource::DataUrl { data_url } if data_url.len() > limits.max_inline_encoded_bytes => {
626            issue(
627                field,
628                "too_large",
629                "data URL exceeds the encoded byte limit",
630            );
631        }
632        ImageSource::DataUrl { data_url } if !data_url.starts_with("data:image/") => {
633            issue(
634                field,
635                "invalid",
636                "data URL must contain an image media type",
637            );
638        }
639        ImageSource::Base64 { data } if data.len() > limits.max_inline_encoded_bytes => {
640            issue(
641                field,
642                "too_large",
643                "base64 input exceeds the encoded byte limit",
644            );
645        }
646        ImageSource::Base64 { data } if data.is_empty() => {
647            issue(field, "empty", "base64 input must not be empty");
648        }
649        _ => {}
650    }
651    if input.filename.as_deref().is_some_and(|name| {
652        name.is_empty()
653            || name.contains('/')
654            || name.contains('\\')
655            || name.chars().any(char::is_control)
656    }) {
657        issue(
658            field,
659            "invalid_filename",
660            "logical filename must be a single safe path component",
661        );
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use crate::{ArtifactMetadataPolicy, ImageInput, InputFidelity, SessionOptions};
669
670    #[test]
671    fn default_generation_request_is_valid() {
672        assert!(
673            validate_request(
674                &ImageRequest::generate("a lighthouse"),
675                RequestLimits::default()
676            )
677            .is_ok()
678        );
679    }
680
681    #[test]
682    fn validation_accumulates_and_sorts_issues() {
683        let mut request = ImageRequest::generate("  ");
684        request.parameters.n = 0;
685        request.parameters.output_compression = Some(80);
686        request.timeout_ms = Some(0);
687
688        let issues = validation_issues(&request, RequestLimits::default());
689        let fields: Vec<_> = issues.iter().map(|item| item.field.as_str()).collect();
690        assert_eq!(
691            fields,
692            [
693                "parameters.n",
694                "parameters.output_compression",
695                "prompt",
696                "timeout_ms"
697            ]
698        );
699    }
700
701    #[test]
702    fn validation_bounds_issue_and_input_amplification() {
703        let invalid = ImageInput {
704            source: ImageSource::Base64 {
705                data: String::new(),
706            },
707            media_type: None,
708            filename: Some("../invalid.png".to_owned()),
709        };
710        let mut request = ImageRequest::generate("test");
711        request.operation = ImageOperation::Edit {
712            images: vec![invalid; 10_000],
713            mask: None,
714            reference_images: Vec::new(),
715        };
716
717        let issues = validation_issues(&request, RequestLimits::default());
718        assert_eq!(issues.len(), MAX_VALIDATION_ISSUES);
719        assert!(issues.iter().any(|item| item.code == "too_many_inputs"));
720        assert!(issues.iter().any(|item| item.code == "too_many_issues"));
721    }
722
723    #[test]
724    fn persistent_session_requires_only_a_key() {
725        let mut request = ImageRequest::generate("test");
726        request.session = SessionOptions {
727            mode: SessionMode::Persistent,
728            key: None,
729            thread_id: Some("thread-1".to_owned()),
730        };
731        let issues = validation_issues(&request, RequestLimits::default());
732        assert!(issues.iter().any(|item| item.field == "session.key"));
733        assert!(issues.iter().any(|item| item.field == "session.thread_id"));
734    }
735
736    #[test]
737    fn edit_requires_an_image() {
738        let mut request = ImageRequest::generate("edit it");
739        request.operation = ImageOperation::Edit {
740            images: Vec::new(),
741            mask: None,
742            reference_images: Vec::new(),
743        };
744        assert!(
745            validation_issues(&request, RequestLimits::default())
746                .iter()
747                .any(|item| item.field == "images")
748        );
749    }
750
751    #[test]
752    fn unsafe_logical_filename_is_rejected() {
753        let mut request = ImageRequest::generate("test");
754        request.operation = ImageOperation::Generate {
755            reference_images: vec![ImageInput {
756                source: ImageSource::Base64 {
757                    data: "AA==".to_owned(),
758                },
759                media_type: Some("image/png".to_owned()),
760                filename: Some("../escape.png".to_owned()),
761            }],
762        };
763        assert!(
764            validation_issues(&request, RequestLimits::default())
765                .iter()
766                .any(|item| item.code == "invalid_filename")
767        );
768    }
769
770    #[test]
771    fn fidelity_and_edit_action_require_image_context() {
772        let mut request = ImageRequest::generate("edit it");
773        request.parameters.input_fidelity = Some(InputFidelity::High);
774        request.parameters.action = ImageAction::Edit;
775        let issues = validation_issues(&request, RequestLimits::default());
776        assert!(
777            issues
778                .iter()
779                .any(|item| item.field == "parameters.input_fidelity")
780        );
781        assert!(issues.iter().any(|item| item.field == "parameters.action"));
782    }
783
784    #[test]
785    fn artifact_paths_are_portable_and_match_the_image_format() {
786        let mut request = ImageRequest::generate("test");
787        request.output.response_format = ResponseFormat::Artifact;
788        request.output.directory = Some("../outside".to_owned());
789        request.output.filename = Some("portrait.jpg".to_owned());
790        let issues = validation_issues(&request, RequestLimits::default());
791        assert!(issues.iter().any(|item| item.field == "output.directory"));
792        assert!(issues.iter().any(|item| item.field == "output.filename"));
793    }
794
795    #[test]
796    fn exact_filename_requires_one_image_and_controls_collision() {
797        let mut request = ImageRequest::generate("test");
798        request.output.response_format = ResponseFormat::Artifact;
799        request.output.filename = Some("portrait.png".to_owned());
800        request.parameters.n = 2;
801        assert!(
802            validation_issues(&request, RequestLimits::default())
803                .iter()
804                .any(|item| item.field == "output.filename" && item.code == "incompatible")
805        );
806
807        request.output.filename = None;
808        request.parameters.n = 1;
809        request.output.collision = ArtifactCollisionPolicy::Suffix;
810        assert!(
811            validation_issues(&request, RequestLimits::default())
812                .iter()
813                .any(|item| item.field == "output.collision")
814        );
815    }
816
817    #[test]
818    fn metadata_sidecars_require_artifact_delivery() {
819        let mut request = ImageRequest::generate("test");
820        request.output.metadata = ArtifactMetadataPolicy::Sidecar;
821        assert!(
822            validation_issues(&request, RequestLimits::default())
823                .iter()
824                .any(|item| item.field == "output.metadata")
825        );
826        request.output.response_format = ResponseFormat::Artifact;
827        assert!(validate_request(&request, RequestLimits::default()).is_ok());
828
829        request.output.metadata = ArtifactMetadataPolicy::SidecarAndEmbedded;
830        assert!(validate_request(&request, RequestLimits::default()).is_ok());
831    }
832
833    #[test]
834    fn embedded_metadata_requires_image_bytes_but_not_artifact_delivery() {
835        let mut request = ImageRequest::generate("test");
836        request.output.metadata = ArtifactMetadataPolicy::Embedded;
837        assert!(validate_request(&request, RequestLimits::default()).is_ok());
838
839        request.output.response_format = ResponseFormat::Metadata;
840        assert!(
841            validation_issues(&request, RequestLimits::default())
842                .iter()
843                .any(|item| item.field == "output.metadata")
844        );
845
846        request.output.response_format = ResponseFormat::B64Json;
847        request.prompt = "x".repeat(MAX_EMBEDDED_REQUEST_TEXT_BYTES + 1);
848        assert!(
849            validation_issues(&request, RequestLimits::default())
850                .iter()
851                .any(|item| item.field == "output.metadata" && item.code == "too_large")
852        );
853    }
854
855    #[test]
856    fn validates_fallback_isolation_and_transparency_controls() {
857        let mut request = ImageRequest::generate("test");
858        request.routing.fallbacks.push(crate::ProviderRoute {
859            provider: "fallback".to_owned(),
860            model: Some("gpt-image-2".to_owned()),
861        });
862        request.session.mode = SessionMode::Persistent;
863        request.session.key = Some("character".to_owned());
864        request.output.transparency.key_color = Some("not-a-color".to_owned());
865        request.output.transparency.transparent_threshold = 100;
866        request.output.transparency.opaque_threshold = 50;
867        let issues = validation_issues(&request, RequestLimits::default());
868        assert!(
869            issues
870                .iter()
871                .any(|item| item.field == "routing.fallbacks" && item.code == "incompatible")
872        );
873        assert!(
874            issues
875                .iter()
876                .any(|item| item.field == "output.transparency.key_color")
877        );
878        assert!(
879            issues
880                .iter()
881                .any(|item| { item.field == "output.transparency.transparent_threshold" })
882        );
883    }
884}