Skip to main content

imagegen_bridge_core/
negotiation.rs

1//! Capability negotiation with explicit, machine-readable normalization.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    Background, BridgeError, CompatibilityMode, ErrorCode, GenerationParameters, ImageOperation,
8    ImageRequest, ImageSize, Moderation, NegativePromptMode, Normalization, OutputFormat,
9    ProviderCapabilities, Quality, RevisedPromptPolicy, SessionMode, SupportLevel,
10};
11
12/// Request after provider capability negotiation.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
14#[serde(deny_unknown_fields)]
15pub struct NegotiatedRequest {
16    /// Original generation parameters.
17    pub requested: GenerationParameters,
18    /// Request containing effective parameters and prompt policy transforms.
19    pub effective_request: ImageRequest,
20    /// Every explicit transformation applied during negotiation.
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub normalizations: Vec<Normalization>,
23}
24
25/// Negotiates one intrinsically valid request against provider capabilities.
26///
27/// # Errors
28///
29/// Returns [`ErrorCode::UnsupportedCapability`] when an operation or parameter
30/// cannot be honored under the request's compatibility policy.
31pub fn negotiate_request(
32    request: &ImageRequest,
33    capabilities: &ProviderCapabilities,
34) -> Result<NegotiatedRequest, BridgeError> {
35    capabilities.validate()?;
36    let requested = request.parameters.clone();
37    let mut effective_request = request.clone();
38    let mut normalizations = Vec::new();
39    let mode = request.policies.compatibility;
40
41    check_model(request, capabilities)?;
42    check_operation(request, capabilities)?;
43    check_input_counts(request, capabilities)?;
44    check_sessions(request, capabilities)?;
45    check_user_attribution(request, capabilities)?;
46    check_input_fidelity(request, capabilities)?;
47    negotiate_action(&mut effective_request, capabilities, &mut normalizations)?;
48    negotiate_count(&mut effective_request, capabilities, &mut normalizations)?;
49    negotiate_size(&mut effective_request, capabilities, &mut normalizations)?;
50    negotiate_hints(&mut effective_request, capabilities, &mut normalizations)?;
51    negotiate_quality(&mut effective_request, capabilities, &mut normalizations)?;
52    negotiate_format(&mut effective_request, capabilities, &mut normalizations)?;
53    negotiate_background(&mut effective_request, capabilities, &mut normalizations)?;
54    negotiate_moderation(&mut effective_request, capabilities, &mut normalizations)?;
55    negotiate_partial_images(&mut effective_request, capabilities, &mut normalizations)?;
56    negotiate_negative_prompt(&mut effective_request, capabilities, &mut normalizations)?;
57
58    if request.policies.revised_prompt == RevisedPromptPolicy::Require
59        && capabilities.revised_prompt == SupportLevel::Unsupported
60    {
61        return Err(unsupported(
62            capabilities,
63            "policies.revised_prompt",
64            "provider cannot return a revised prompt",
65        ));
66    }
67
68    debug_assert_eq!(mode, effective_request.policies.compatibility);
69    Ok(NegotiatedRequest {
70        requested,
71        effective_request,
72        normalizations,
73    })
74}
75
76fn check_user_attribution(
77    request: &ImageRequest,
78    capabilities: &ProviderCapabilities,
79) -> Result<(), BridgeError> {
80    if request.user.is_none() || capabilities.user_attribution != SupportLevel::Unsupported {
81        return Ok(());
82    }
83    Err(unsupported(
84        capabilities,
85        "user",
86        "provider cannot consume the requested end-user attribution",
87    ))
88}
89
90fn check_input_fidelity(
91    request: &ImageRequest,
92    capabilities: &ProviderCapabilities,
93) -> Result<(), BridgeError> {
94    let Some(requested) = request.parameters.input_fidelity else {
95        return Ok(());
96    };
97    if capabilities.input_fidelities.contains(&requested) {
98        return Ok(());
99    }
100    Err(unsupported(
101        capabilities,
102        "parameters.input_fidelity",
103        "provider cannot honor the requested input fidelity",
104    )
105    .with_detail("requested", requested)
106    .with_detail("supported", &capabilities.input_fidelities))
107}
108
109fn negotiate_action(
110    request: &mut ImageRequest,
111    capabilities: &ProviderCapabilities,
112    normalizations: &mut Vec<Normalization>,
113) -> Result<(), BridgeError> {
114    let requested = request.parameters.action;
115    if capabilities.actions.contains(&requested) {
116        return Ok(());
117    }
118    if requested != crate::ImageAction::Auto
119        && capabilities.actions.contains(&crate::ImageAction::Auto)
120    {
121        record(
122            normalizations,
123            "parameters.action",
124            requested,
125            crate::ImageAction::Auto,
126            "provider_uses_automatic_action",
127        );
128        request.parameters.action = crate::ImageAction::Auto;
129        return Ok(());
130    }
131    Err(unsupported(
132        capabilities,
133        "parameters.action",
134        "provider cannot honor the requested image action",
135    )
136    .with_detail("requested", requested)
137    .with_detail("supported", &capabilities.actions))
138}
139
140fn check_model(
141    request: &ImageRequest,
142    capabilities: &ProviderCapabilities,
143) -> Result<(), BridgeError> {
144    let (Some(requested), Some(effective)) = (
145        request.routing.model.as_deref(),
146        capabilities.model.as_deref(),
147    ) else {
148        return Ok(());
149    };
150    if requested == effective {
151        return Ok(());
152    }
153    Err(unsupported(
154        capabilities,
155        "routing.model",
156        "provider cannot honor the requested image model",
157    )
158    .with_detail("requested_model", requested)
159    .with_detail("effective_model", effective))
160}
161
162fn check_operation(
163    request: &ImageRequest,
164    capabilities: &ProviderCapabilities,
165) -> Result<(), BridgeError> {
166    let (supported, field, message) = match request.operation {
167        ImageOperation::Generate { .. } => (
168            capabilities.generation,
169            "operation",
170            "provider does not support image generation",
171        ),
172        ImageOperation::Edit { .. } => (
173            capabilities.edits,
174            "operation",
175            "provider does not support image edits",
176        ),
177    };
178    if supported {
179        Ok(())
180    } else {
181        Err(unsupported(capabilities, field, message))
182    }
183}
184
185fn check_input_counts(
186    request: &ImageRequest,
187    capabilities: &ProviderCapabilities,
188) -> Result<(), BridgeError> {
189    let reference_count = request.operation.reference_images().len();
190    if reference_count > usize::from(capabilities.reference_images.max_count) {
191        return Err(unsupported(
192            capabilities,
193            "reference_images",
194            "reference image count exceeds provider capability",
195        ));
196    }
197    if reference_count > 0 && capabilities.reference_images.support == SupportLevel::Unsupported {
198        return Err(unsupported(
199            capabilities,
200            "reference_images",
201            "provider does not support reference images",
202        ));
203    }
204    if let ImageOperation::Edit { images, mask, .. } = &request.operation {
205        if images.len() > usize::from(capabilities.edit_images.max_count)
206            || capabilities.edit_images.support == SupportLevel::Unsupported
207        {
208            return Err(unsupported(
209                capabilities,
210                "images",
211                "edit images exceed provider capability",
212            ));
213        }
214        if mask.is_some()
215            && (capabilities.masks.support == SupportLevel::Unsupported
216                || capabilities.masks.max_count == 0)
217        {
218            return Err(unsupported(
219                capabilities,
220                "mask",
221                "provider does not support edit masks",
222            ));
223        }
224    }
225    Ok(())
226}
227
228fn check_sessions(
229    request: &ImageRequest,
230    capabilities: &ProviderCapabilities,
231) -> Result<(), BridgeError> {
232    match request.session.mode {
233        SessionMode::Isolated => Ok(()),
234        SessionMode::Persistent if capabilities.persistent_sessions => Ok(()),
235        SessionMode::Thread if capabilities.explicit_threads => Ok(()),
236        SessionMode::Persistent => Err(unsupported(
237            capabilities,
238            "session.mode",
239            "provider does not support persistent sessions",
240        )),
241        SessionMode::Thread => Err(unsupported(
242            capabilities,
243            "session.mode",
244            "provider does not support explicit threads",
245        )),
246    }
247}
248
249fn negotiate_count(
250    request: &mut ImageRequest,
251    capabilities: &ProviderCapabilities,
252    normalizations: &mut Vec<Normalization>,
253) -> Result<(), BridgeError> {
254    let requested = request.parameters.n;
255    if capabilities.count.contains(requested) {
256        return Ok(());
257    }
258    if request.policies.compatibility == CompatibilityMode::Strict {
259        return Err(unsupported(
260            capabilities,
261            "parameters.n",
262            "output count is outside provider capability",
263        ));
264    }
265    let effective = requested.clamp(capabilities.count.min, capabilities.count.max);
266    record(
267        normalizations,
268        "parameters.n",
269        requested,
270        effective,
271        "clamped_to_provider_range",
272    );
273    request.parameters.n = effective;
274    Ok(())
275}
276
277fn negotiate_size(
278    request: &mut ImageRequest,
279    capabilities: &ProviderCapabilities,
280    normalizations: &mut Vec<Normalization>,
281) -> Result<(), BridgeError> {
282    let requested = request.parameters.size.clone();
283    if size_supported(&requested, capabilities) {
284        return Ok(());
285    }
286    if request.policies.compatibility == CompatibilityMode::Strict {
287        return Err(unsupported(
288            capabilities,
289            "parameters.size",
290            "requested size is outside provider capability",
291        ));
292    }
293
294    let effective = if !requested.is_auto() && capabilities.sizes.auto {
295        ImageSize::default()
296    } else if let Some(size) = capabilities.sizes.allowed.iter().next() {
297        size.clone()
298    } else {
299        return Err(unsupported(
300            capabilities,
301            "parameters.size",
302            "provider has no usable size fallback",
303        ));
304    };
305    record(
306        normalizations,
307        "parameters.size",
308        requested.to_string(),
309        effective.to_string(),
310        "normalized_to_provider_size",
311    );
312    request.parameters.size = effective;
313    Ok(())
314}
315
316fn size_supported(size: &ImageSize, capabilities: &ProviderCapabilities) -> bool {
317    if size.is_auto() {
318        return capabilities.sizes.auto;
319    }
320    if capabilities.sizes.allowed.contains(size) {
321        return true;
322    }
323    let Some((width, height)) = size.dimensions() else {
324        return false;
325    };
326    if !capabilities.sizes.arbitrary {
327        return false;
328    }
329    let short = width.min(height);
330    let long = width.max(height);
331    let pixels = u64::from(width) * u64::from(height);
332    capabilities.sizes.min_edge.is_none_or(|min| short >= min)
333        && capabilities.sizes.max_edge.is_none_or(|max| long <= max)
334        && capabilities
335            .sizes
336            .edge_multiple
337            .is_none_or(|multiple| width % multiple == 0 && height % multiple == 0)
338        && capabilities
339            .sizes
340            .min_pixels
341            .is_none_or(|min| pixels >= min)
342        && capabilities
343            .sizes
344            .max_pixels
345            .is_none_or(|max| pixels <= max)
346        && capabilities
347            .sizes
348            .max_aspect_ratio
349            .is_none_or(|max| f64::from(long) / f64::from(short) <= max)
350}
351
352fn negotiate_hints(
353    request: &mut ImageRequest,
354    capabilities: &ProviderCapabilities,
355    normalizations: &mut Vec<Normalization>,
356) -> Result<(), BridgeError> {
357    if request.parameters.aspect_ratio.is_some()
358        && capabilities.aspect_ratio == SupportLevel::Unsupported
359    {
360        if request.policies.compatibility == CompatibilityMode::Strict {
361            return Err(unsupported(
362                capabilities,
363                "parameters.aspect_ratio",
364                "provider cannot honor an aspect-ratio hint",
365            ));
366        }
367        let requested = request
368            .parameters
369            .aspect_ratio
370            .take()
371            .map(|value| value.to_string());
372        record_optional(
373            normalizations,
374            "parameters.aspect_ratio",
375            requested,
376            None::<String>,
377            "provider_does_not_support_hint",
378        );
379    }
380    if request.parameters.resolution.is_some()
381        && capabilities.resolution == SupportLevel::Unsupported
382    {
383        if request.policies.compatibility == CompatibilityMode::Strict {
384            return Err(unsupported(
385                capabilities,
386                "parameters.resolution",
387                "provider cannot honor a resolution hint",
388            ));
389        }
390        let requested = request
391            .parameters
392            .resolution
393            .take()
394            .map(|value| value.to_string());
395        record_optional(
396            normalizations,
397            "parameters.resolution",
398            requested,
399            None::<String>,
400            "provider_does_not_support_hint",
401        );
402    }
403    Ok(())
404}
405
406fn negotiate_quality(
407    request: &mut ImageRequest,
408    capabilities: &ProviderCapabilities,
409    normalizations: &mut Vec<Normalization>,
410) -> Result<(), BridgeError> {
411    let requested = request.parameters.quality;
412    if capabilities.qualities.contains(&requested) {
413        return Ok(());
414    }
415    let effective = capabilities
416        .qualities
417        .get(&Quality::Auto)
418        .copied()
419        .or_else(|| capabilities.qualities.iter().next().copied())
420        .ok_or_else(|| {
421            unsupported(
422                capabilities,
423                "parameters.quality",
424                "provider declares no supported quality",
425            )
426        })?;
427    normalize_or_reject(
428        request.policies.compatibility,
429        capabilities,
430        normalizations,
431        "parameters.quality",
432        requested,
433        effective,
434    )?;
435    request.parameters.quality = effective;
436    Ok(())
437}
438
439fn negotiate_format(
440    request: &mut ImageRequest,
441    capabilities: &ProviderCapabilities,
442    normalizations: &mut Vec<Normalization>,
443) -> Result<(), BridgeError> {
444    let requested = request.parameters.output_format;
445    if !capabilities.output_formats.contains(&requested) {
446        let effective = capabilities
447            .output_formats
448            .get(&OutputFormat::Png)
449            .copied()
450            .or_else(|| capabilities.output_formats.iter().next().copied())
451            .ok_or_else(|| {
452                unsupported(
453                    capabilities,
454                    "parameters.output_format",
455                    "provider declares no output format",
456                )
457            })?;
458        normalize_or_reject(
459            request.policies.compatibility,
460            capabilities,
461            normalizations,
462            "parameters.output_format",
463            requested,
464            effective,
465        )?;
466        request.parameters.output_format = effective;
467    }
468    if request.parameters.output_compression.is_some()
469        && !matches!(
470            request.parameters.output_format,
471            OutputFormat::Jpeg | OutputFormat::Webp
472        )
473    {
474        if request.policies.compatibility == CompatibilityMode::Strict {
475            return Err(unsupported(
476                capabilities,
477                "parameters.output_compression",
478                "effective output format does not support compression",
479            ));
480        }
481        let requested_compression = request.parameters.output_compression.take();
482        record_optional(
483            normalizations,
484            "parameters.output_compression",
485            requested_compression,
486            None::<u8>,
487            "effective_format_has_no_compression",
488        );
489    }
490    Ok(())
491}
492
493fn negotiate_background(
494    request: &mut ImageRequest,
495    capabilities: &ProviderCapabilities,
496    normalizations: &mut Vec<Normalization>,
497) -> Result<(), BridgeError> {
498    let requested = request.parameters.background;
499    if capabilities.backgrounds.contains(&requested) {
500        return Ok(());
501    }
502    let effective = capabilities
503        .backgrounds
504        .get(&Background::Auto)
505        .copied()
506        .or_else(|| capabilities.backgrounds.iter().next().copied())
507        .ok_or_else(|| {
508            unsupported(
509                capabilities,
510                "parameters.background",
511                "provider declares no background behavior",
512            )
513        })?;
514    normalize_or_reject(
515        request.policies.compatibility,
516        capabilities,
517        normalizations,
518        "parameters.background",
519        requested,
520        effective,
521    )?;
522    request.parameters.background = effective;
523    Ok(())
524}
525
526fn negotiate_moderation(
527    request: &mut ImageRequest,
528    capabilities: &ProviderCapabilities,
529    normalizations: &mut Vec<Normalization>,
530) -> Result<(), BridgeError> {
531    let requested = request.parameters.moderation;
532    if capabilities.moderation.contains(&requested) {
533        return Ok(());
534    }
535    let effective = capabilities
536        .moderation
537        .get(&Moderation::Auto)
538        .copied()
539        .or_else(|| capabilities.moderation.iter().next().copied())
540        .ok_or_else(|| {
541            unsupported(
542                capabilities,
543                "parameters.moderation",
544                "provider declares no moderation behavior",
545            )
546        })?;
547    normalize_or_reject(
548        request.policies.compatibility,
549        capabilities,
550        normalizations,
551        "parameters.moderation",
552        requested,
553        effective,
554    )?;
555    request.parameters.moderation = effective;
556    Ok(())
557}
558
559fn negotiate_partial_images(
560    request: &mut ImageRequest,
561    capabilities: &ProviderCapabilities,
562    normalizations: &mut Vec<Normalization>,
563) -> Result<(), BridgeError> {
564    let requested = request.parameters.partial_images;
565    if capabilities.partial_images.contains(requested) {
566        return Ok(());
567    }
568    if request.policies.compatibility == CompatibilityMode::Strict {
569        return Err(unsupported(
570            capabilities,
571            "parameters.partial_images",
572            "partial image count is outside provider capability",
573        ));
574    }
575    let effective = requested.clamp(
576        capabilities.partial_images.min,
577        capabilities.partial_images.max,
578    );
579    record(
580        normalizations,
581        "parameters.partial_images",
582        requested,
583        effective,
584        "clamped_to_provider_range",
585    );
586    request.parameters.partial_images = effective;
587    Ok(())
588}
589
590fn negotiate_negative_prompt(
591    request: &mut ImageRequest,
592    capabilities: &ProviderCapabilities,
593    normalizations: &mut Vec<Normalization>,
594) -> Result<(), BridgeError> {
595    let Some(negative_prompt) = request.negative_prompt.clone() else {
596        return Ok(());
597    };
598    let mode = request.policies.negative_prompt;
599    let merge = match mode {
600        NegativePromptMode::Reject => {
601            return Err(unsupported(
602                capabilities,
603                "negative_prompt",
604                "negative prompt is rejected by request policy",
605            ));
606        }
607        NegativePromptMode::Native => {
608            if capabilities.negative_prompt != SupportLevel::Native {
609                return Err(unsupported(
610                    capabilities,
611                    "negative_prompt",
612                    "provider does not support a native negative prompt",
613                ));
614            }
615            false
616        }
617        NegativePromptMode::Merge => true,
618        NegativePromptMode::Auto => match capabilities.negative_prompt {
619            SupportLevel::Native => false,
620            SupportLevel::Emulated => true,
621            SupportLevel::Unsupported => {
622                return Err(unsupported(
623                    capabilities,
624                    "negative_prompt",
625                    "provider cannot honor a negative prompt under auto policy",
626                ));
627            }
628        },
629    };
630    if merge {
631        request
632            .prompt
633            .push_str("\n\nNegative constraints (avoid these elements): ");
634        request.prompt.push_str(&negative_prompt);
635        request.negative_prompt = None;
636        normalizations.push(Normalization {
637            field: "negative_prompt".to_owned(),
638            requested: Some(serde_json::Value::Bool(true)),
639            effective: Some(serde_json::Value::String("merged_into_prompt".to_owned())),
640            reason: "negative_prompt_prompt_merge".to_owned(),
641        });
642    }
643    Ok(())
644}
645
646fn normalize_or_reject<T>(
647    mode: CompatibilityMode,
648    capabilities: &ProviderCapabilities,
649    normalizations: &mut Vec<Normalization>,
650    field: &str,
651    requested: T,
652    effective: T,
653) -> Result<(), BridgeError>
654where
655    T: Serialize + Copy,
656{
657    if mode == CompatibilityMode::Strict {
658        return Err(unsupported(
659            capabilities,
660            field,
661            "requested value is outside provider capability",
662        ));
663    }
664    record(
665        normalizations,
666        field,
667        requested,
668        effective,
669        "normalized_to_provider_value",
670    );
671    Ok(())
672}
673
674fn record<T: Serialize, U: Serialize>(
675    normalizations: &mut Vec<Normalization>,
676    field: &str,
677    requested: T,
678    effective: U,
679    reason: &str,
680) {
681    record_optional(
682        normalizations,
683        field,
684        Some(requested),
685        Some(effective),
686        reason,
687    );
688}
689
690fn record_optional<T: Serialize, U: Serialize>(
691    normalizations: &mut Vec<Normalization>,
692    field: &str,
693    requested: Option<T>,
694    effective: Option<U>,
695    reason: &str,
696) {
697    normalizations.push(Normalization {
698        field: field.to_owned(),
699        requested: requested.and_then(|value| serde_json::to_value(value).ok()),
700        effective: effective.and_then(|value| serde_json::to_value(value).ok()),
701        reason: reason.to_owned(),
702    });
703}
704
705fn unsupported(capabilities: &ProviderCapabilities, field: &str, message: &str) -> BridgeError {
706    BridgeError::new(ErrorCode::UnsupportedCapability, message)
707        .with_provider(&capabilities.provider)
708        .with_detail("field", field)
709        .with_detail("model", &capabilities.model)
710}
711
712#[cfg(test)]
713mod tests {
714    #![allow(clippy::unwrap_used)]
715
716    use std::collections::BTreeSet;
717
718    use super::*;
719    use crate::{
720        BatchCapabilities, BatchMode, InputCapabilities, RequestPolicies, SizeCapabilities, U8Range,
721    };
722
723    fn capabilities() -> ProviderCapabilities {
724        ProviderCapabilities {
725            provider: "test".to_owned(),
726            implementation_version: "1".to_owned(),
727            model: Some("test-image".to_owned()),
728            experimental: false,
729            generation: true,
730            edits: false,
731            count: U8Range { min: 1, max: 2 },
732            batching: BatchCapabilities {
733                mode: BatchMode::Native,
734                native_count: U8Range { min: 1, max: 2 },
735                max_parallel_outputs: 1,
736            },
737            sizes: SizeCapabilities {
738                auto: true,
739                allowed: BTreeSet::new(),
740                arbitrary: false,
741                min_edge: None,
742                max_edge: None,
743                edge_multiple: None,
744                min_pixels: None,
745                max_pixels: None,
746                max_aspect_ratio: None,
747            },
748            aspect_ratio: SupportLevel::Unsupported,
749            resolution: SupportLevel::Unsupported,
750            qualities: BTreeSet::from([Quality::Auto]),
751            output_formats: BTreeSet::from([OutputFormat::Png]),
752            backgrounds: BTreeSet::from([Background::Auto]),
753            transparent_background: SupportLevel::Unsupported,
754            moderation: BTreeSet::from([Moderation::Auto]),
755            negative_prompt: SupportLevel::Emulated,
756            revised_prompt: SupportLevel::Unsupported,
757            user_attribution: SupportLevel::Unsupported,
758            input_fidelities: BTreeSet::new(),
759            actions: BTreeSet::from([crate::ImageAction::Auto]),
760            reference_images: no_inputs(),
761            edit_images: no_inputs(),
762            masks: no_inputs(),
763            partial_images: U8Range { min: 0, max: 0 },
764            persistent_sessions: true,
765            explicit_threads: true,
766        }
767    }
768
769    #[test]
770    fn capability_validation_rejects_inconsistent_batching() {
771        let mut value = capabilities();
772        value.batching.mode = BatchMode::FanOut;
773        value.batching.native_count = value.count;
774        let error = value.validate().unwrap_err();
775        assert_eq!(error.code, ErrorCode::Protocol);
776
777        let mut value = capabilities();
778        value.batching.max_parallel_outputs = 0;
779        assert!(value.validate().is_err());
780    }
781
782    fn no_inputs() -> InputCapabilities {
783        InputCapabilities {
784            support: SupportLevel::Unsupported,
785            max_count: 0,
786            max_bytes_each: 0,
787            max_bytes_total: 0,
788        }
789    }
790
791    #[test]
792    fn strict_mode_rejects_unsupported_quality() {
793        let mut request = ImageRequest::generate("test");
794        request.parameters.quality = Quality::High;
795        let error = negotiate_request(&request, &capabilities()).unwrap_err();
796        assert_eq!(error.code, ErrorCode::UnsupportedCapability);
797        assert_eq!(
798            error.details.get("field"),
799            Some(&serde_json::Value::String("parameters.quality".to_owned()))
800        );
801    }
802
803    #[test]
804    fn explicit_model_mismatch_is_never_silently_normalized() {
805        let mut request = ImageRequest::generate("test");
806        request.routing.model = Some("another-image-model".to_owned());
807        request.policies.compatibility = CompatibilityMode::BestEffort;
808        let error = negotiate_request(&request, &capabilities()).unwrap_err();
809        assert_eq!(error.code, ErrorCode::UnsupportedCapability);
810        assert_eq!(
811            error.details.get("field"),
812            Some(&serde_json::Value::String("routing.model".to_owned()))
813        );
814    }
815
816    #[test]
817    fn unsupported_user_attribution_is_never_silently_dropped() {
818        let mut request = ImageRequest::generate("test");
819        request.user = Some("opaque-caller".to_owned());
820        request.policies.compatibility = CompatibilityMode::BestEffort;
821        let error = negotiate_request(&request, &capabilities()).unwrap_err();
822        assert_eq!(error.code, ErrorCode::UnsupportedCapability);
823        assert_eq!(
824            error.details.get("field"),
825            Some(&serde_json::Value::String("user".to_owned()))
826        );
827    }
828
829    #[test]
830    fn explicit_fidelity_is_checked_and_semantic_action_can_use_auto() {
831        let mut request = ImageRequest::generate("test");
832        request.parameters.input_fidelity = Some(crate::InputFidelity::High);
833        let error = negotiate_request(&request, &capabilities()).unwrap_err();
834        assert_eq!(error.details["field"], "parameters.input_fidelity");
835
836        request.parameters.input_fidelity = None;
837        request.parameters.action = crate::ImageAction::Generate;
838        let negotiated = negotiate_request(&request, &capabilities()).unwrap();
839        assert_eq!(
840            negotiated.effective_request.parameters.action,
841            crate::ImageAction::Auto
842        );
843        assert_eq!(negotiated.normalizations.len(), 1);
844        assert_eq!(negotiated.normalizations[0].field, "parameters.action");
845        assert_eq!(
846            negotiated.normalizations[0].reason,
847            "provider_uses_automatic_action"
848        );
849    }
850
851    #[test]
852    fn normalize_mode_reports_every_changed_parameter() {
853        let mut request = ImageRequest::generate("test");
854        request.policies = RequestPolicies {
855            compatibility: CompatibilityMode::Normalize,
856            ..RequestPolicies::default()
857        };
858        request.parameters.n = 3;
859        request.parameters.quality = Quality::High;
860        request.parameters.size = "1536x1024".parse().unwrap();
861
862        let negotiated = negotiate_request(&request, &capabilities()).unwrap();
863        assert_eq!(negotiated.effective_request.parameters.n, 2);
864        assert_eq!(
865            negotiated.effective_request.parameters.quality,
866            Quality::Auto
867        );
868        assert!(negotiated.effective_request.parameters.size.is_auto());
869        assert_eq!(negotiated.normalizations.len(), 3);
870    }
871
872    #[test]
873    fn emulated_negative_prompt_is_merged_without_echoing_its_text_in_metadata() {
874        let mut request = ImageRequest::generate("a portrait");
875        request.negative_prompt = Some("watermark".to_owned());
876        let negotiated = negotiate_request(&request, &capabilities()).unwrap();
877        assert!(negotiated.effective_request.prompt.contains("watermark"));
878        assert!(negotiated.effective_request.negative_prompt.is_none());
879        let metadata = serde_json::to_string(&negotiated.normalizations).unwrap();
880        assert!(!metadata.contains("watermark"));
881    }
882
883    #[test]
884    fn arbitrary_size_constraints_are_enforced() {
885        let mut capabilities = capabilities();
886        capabilities.sizes = SizeCapabilities {
887            auto: true,
888            allowed: BTreeSet::new(),
889            arbitrary: true,
890            min_edge: None,
891            max_edge: Some(3840),
892            edge_multiple: Some(16),
893            min_pixels: Some(655_360),
894            max_pixels: Some(8_294_400),
895            max_aspect_ratio: Some(3.0),
896        };
897        let mut request = ImageRequest::generate("test");
898        request.parameters.size = "1024x1024".parse().unwrap();
899        assert!(negotiate_request(&request, &capabilities).is_ok());
900
901        request.parameters.size = "4000x1024".parse().unwrap();
902        assert!(negotiate_request(&request, &capabilities).is_err());
903    }
904
905    #[test]
906    fn malformed_provider_ranges_are_rejected_without_panicking() {
907        for mode in [
908            CompatibilityMode::Strict,
909            CompatibilityMode::Normalize,
910            CompatibilityMode::BestEffort,
911        ] {
912            let mut request = ImageRequest::generate("test");
913            request.policies.compatibility = mode;
914
915            let mut invalid_count = capabilities();
916            invalid_count.count = U8Range { min: 2, max: 1 };
917            let error = negotiate_request(&request, &invalid_count).unwrap_err();
918            assert_eq!(error.code, ErrorCode::Protocol);
919
920            let mut invalid_partials = capabilities();
921            invalid_partials.partial_images = U8Range { min: 2, max: 1 };
922            let error = negotiate_request(&request, &invalid_partials).unwrap_err();
923            assert_eq!(error.code, ErrorCode::Protocol);
924        }
925    }
926}