Skip to main content

meerkat_core/
image_generation.rs

1//! Typed substrate for assistant image generation and scoped model routing.
2//!
3//! This module contains representation types only. Runtime policy, provider
4//! calls, blob I/O, and machine transitions live outside `meerkat-core`.
5
6use crate::approval::ApprovalId;
7use crate::blob::BlobRef;
8use crate::lifecycle::run_primitive::ModelId;
9use crate::model_profile::catalog::ImageGenerationModelProfile;
10use serde::{Deserialize, Deserializer, Serialize};
11use serde_json::Value;
12use std::num::NonZeroU32;
13use uuid::Uuid;
14
15pub const DEFAULT_PROMPT_TEXT_MAX_CHARS: usize = 32_000;
16pub const DEFAULT_SWITCH_TURN_REASON_MAX_CHARS: usize = 4_000;
17
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19pub enum ImageGenerationValidationError {
20    #[error("{field} must not be empty")]
21    EmptyText { field: &'static str },
22    #[error("{field} exceeds maximum length of {max_chars} characters")]
23    TextTooLong {
24        field: &'static str,
25        max_chars: usize,
26    },
27    #[error("edit image generation intent requires at least one source image")]
28    MissingEditSourceImages,
29}
30
31fn validate_text(
32    content: &str,
33    field: &'static str,
34    max_chars: usize,
35) -> Result<(), ImageGenerationValidationError> {
36    if content.trim().is_empty() {
37        return Err(ImageGenerationValidationError::EmptyText { field });
38    }
39    if content.chars().count() > max_chars {
40        return Err(ImageGenerationValidationError::TextTooLong { field, max_chars });
41    }
42    Ok(())
43}
44
45macro_rules! uuid_id {
46    ($name:ident) => {
47        #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48        #[derive(
49            Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
50        )]
51        #[serde(transparent)]
52        pub struct $name(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
53
54        impl $name {
55            pub fn new(id: Uuid) -> Self {
56                Self(id)
57            }
58        }
59    };
60}
61
62uuid_id!(AssistantImageId);
63uuid_id!(ImageOperationId);
64uuid_id!(SwitchTurnRequestId);
65uuid_id!(ScopedModelOverrideId);
66uuid_id!(ProjectionSnapshotId);
67uuid_id!(MessageId);
68uuid_id!(BlockId);
69
70#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(transparent)]
73pub struct ToolCallId(pub String);
74
75impl ToolCallId {
76    pub fn new(id: impl Into<String>) -> Self {
77        Self(id.into())
78    }
79}
80
81#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
82#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
83#[serde(transparent)]
84pub struct ProviderId(pub String);
85
86impl ProviderId {
87    pub fn new(id: impl Into<String>) -> Self {
88        Self(id.into())
89    }
90}
91
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[serde(transparent)]
95pub struct ProviderImageHandle(pub String);
96
97impl ProviderImageHandle {
98    pub fn new(handle: impl Into<String>) -> Self {
99        Self(handle.into())
100    }
101}
102
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(transparent)]
106pub struct ImageContinuityRef(pub String);
107
108impl ImageContinuityRef {
109    pub fn new(value: impl Into<String>) -> Self {
110        Self(value.into())
111    }
112}
113
114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(transparent)]
117pub struct TextArtifactRef(pub String);
118
119impl TextArtifactRef {
120    pub fn new(value: impl Into<String>) -> Self {
121        Self(value.into())
122    }
123}
124
125#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
126#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
127#[serde(transparent)]
128pub struct MediaType(pub String);
129
130/// Fail-closed media-type parse error.
131#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
132pub enum MediaTypeError {
133    /// The media-type string was empty after canonicalization.
134    #[error("media type must not be empty")]
135    Empty,
136    /// The media-type string was not a `type/subtype` shape.
137    #[error("media type '{0}' is not a valid type/subtype")]
138    Malformed(String),
139}
140
141impl MediaType {
142    pub fn new(media_type: impl Into<String>) -> Self {
143        Self(media_type.into())
144    }
145
146    pub fn as_str(&self) -> &str {
147        &self.0
148    }
149
150    /// Canonical form of a media-type string used for identity.
151    ///
152    /// Lowercases, trims surrounding whitespace, and drops any RFC parameters
153    /// (everything after the first `;`, e.g. `; charset=utf-8`). This is the
154    /// single canonicalization used by [`crate::blob::content_blob_id`] so that
155    /// cosmetic differences like `image/PNG` vs `image/png` never mint divergent
156    /// blob identities for identical bytes.
157    #[must_use]
158    pub fn canonical_str(raw: &str) -> String {
159        let without_params = raw.split_once(';').map_or(raw, |(head, _)| head);
160        without_params.trim().to_ascii_lowercase()
161    }
162
163    /// Canonicalize this media type in place into its identity form.
164    #[must_use]
165    pub fn normalize(&self) -> MediaType {
166        MediaType(Self::canonical_str(&self.0))
167    }
168
169    /// Fail-closed parse into a canonical, validated `type/subtype` media type.
170    ///
171    /// Rejects empty strings and values that are not a single `type/subtype`
172    /// pair (after dropping parameters), so unknown/garbage media-type strings
173    /// cannot silently become identity input.
174    pub fn parse(raw: &str) -> Result<MediaType, MediaTypeError> {
175        let canonical = Self::canonical_str(raw);
176        if canonical.is_empty() {
177            return Err(MediaTypeError::Empty);
178        }
179        let mut parts = canonical.split('/');
180        let (Some(kind), Some(subtype), None) = (parts.next(), parts.next(), parts.next()) else {
181            return Err(MediaTypeError::Malformed(canonical));
182        };
183        if kind.is_empty() || subtype.is_empty() {
184            return Err(MediaTypeError::Malformed(canonical));
185        }
186        Ok(MediaType(canonical))
187    }
188}
189
190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
191#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
192#[serde(transparent)]
193pub struct TopologyEpoch(pub u64);
194
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
197pub struct PromptText {
198    pub content: String,
199}
200
201impl PromptText {
202    pub fn new(content: impl Into<String>) -> Result<Self, ImageGenerationValidationError> {
203        Self::with_max_chars(content, DEFAULT_PROMPT_TEXT_MAX_CHARS)
204    }
205
206    pub fn with_max_chars(
207        content: impl Into<String>,
208        max_chars: usize,
209    ) -> Result<Self, ImageGenerationValidationError> {
210        let content = content.into();
211        validate_text(&content, "prompt_text", max_chars)?;
212        Ok(Self { content })
213    }
214}
215
216impl<'de> Deserialize<'de> for PromptText {
217    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
218    where
219        D: Deserializer<'de>,
220    {
221        #[derive(Deserialize)]
222        struct RawPromptText {
223            content: String,
224        }
225
226        let raw = RawPromptText::deserialize(deserializer)?;
227        PromptText::new(raw.content).map_err(serde::de::Error::custom)
228    }
229}
230
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(tag = "source", rename_all = "snake_case")]
234pub enum PromptSource {
235    UserProvided {
236        message_id: MessageId,
237    },
238    ModelDistilled {
239        tool_call_id: ToolCallId,
240    },
241    Hybrid {
242        user_message_ids: Vec<MessageId>,
243        tool_call_id: ToolCallId,
244    },
245}
246
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(tag = "kind", rename_all = "snake_case")]
250pub enum ImageSourceRef {
251    Blob {
252        blob_ref: BlobRef,
253    },
254    TranscriptBlock {
255        message_id: MessageId,
256        block_id: BlockId,
257    },
258    AssistantImage {
259        image_id: AssistantImageId,
260    },
261    ProviderNative {
262        provider: ProviderId,
263        handle: ProviderImageHandle,
264        continuity: ImageContinuityDisposition,
265        fallback_blob_ref: BlobRef,
266    },
267}
268
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(tag = "disposition", rename_all = "snake_case")]
272pub enum ImageContinuityDisposition {
273    NotProvided,
274    UnsupportedBySourceProvider,
275    Available { continuity_ref: ImageContinuityRef },
276}
277
278#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280#[serde(tag = "intent", rename_all = "snake_case")]
281pub enum ImageGenerationIntent {
282    Generate {
283        prompt: PromptText,
284        prompt_source: PromptSource,
285        #[serde(default, skip_serializing_if = "Vec::is_empty")]
286        reference_images: Vec<ImageSourceRef>,
287    },
288    Edit {
289        instruction: PromptText,
290        instruction_source: PromptSource,
291        source_images: Vec<ImageSourceRef>,
292    },
293}
294
295impl ImageGenerationIntent {
296    pub fn edit(
297        instruction: PromptText,
298        instruction_source: PromptSource,
299        source_images: Vec<ImageSourceRef>,
300    ) -> Result<Self, ImageGenerationValidationError> {
301        if source_images.is_empty() {
302            return Err(ImageGenerationValidationError::MissingEditSourceImages);
303        }
304        Ok(Self::Edit {
305            instruction,
306            instruction_source,
307            source_images,
308        })
309    }
310
311    pub fn validate(&self) -> Result<(), ImageGenerationValidationError> {
312        if matches!(self, Self::Edit { source_images, .. } if source_images.is_empty()) {
313            return Err(ImageGenerationValidationError::MissingEditSourceImages);
314        }
315        Ok(())
316    }
317}
318
319#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(tag = "target", rename_all = "snake_case")]
322pub enum ImageGenerationTargetPreference {
323    Auto,
324    ProviderDefault {
325        provider: ProviderId,
326    },
327    Model {
328        provider: ProviderId,
329        #[cfg_attr(feature = "schema", schemars(with = "String"))]
330        model: ModelId,
331    },
332}
333
334#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(tag = "size", rename_all = "snake_case")]
337pub enum ImageSizePreference {
338    Auto,
339    Square1024,
340    Portrait1024x1536,
341    Landscape1536x1024,
342    Custom {
343        width: NonZeroU32,
344        height: NonZeroU32,
345    },
346}
347
348#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum ImageQualityPreference {
352    Auto,
353    Low,
354    Medium,
355    High,
356}
357
358#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
360#[serde(rename_all = "snake_case")]
361pub enum ImageFormatPreference {
362    Auto,
363    Png,
364    Jpeg,
365    Webp,
366}
367
368#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
369#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
370pub struct GenerateImageRequest {
371    pub intent: ImageGenerationIntent,
372    pub target: ImageGenerationTargetPreference,
373    pub size: ImageSizePreference,
374    pub quality: ImageQualityPreference,
375    pub format: ImageFormatPreference,
376    pub count: NonZeroU32,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub provider_params: Option<Value>,
379}
380
381impl GenerateImageRequest {
382    pub fn new(
383        intent: ImageGenerationIntent,
384        target: ImageGenerationTargetPreference,
385        size: ImageSizePreference,
386        quality: ImageQualityPreference,
387        format: ImageFormatPreference,
388        count: NonZeroU32,
389    ) -> Result<Self, ImageGenerationValidationError> {
390        Self::with_provider_params(intent, target, size, quality, format, count, None)
391    }
392
393    pub fn with_provider_params(
394        intent: ImageGenerationIntent,
395        target: ImageGenerationTargetPreference,
396        size: ImageSizePreference,
397        quality: ImageQualityPreference,
398        format: ImageFormatPreference,
399        count: NonZeroU32,
400        provider_params: Option<Value>,
401    ) -> Result<Self, ImageGenerationValidationError> {
402        intent.validate()?;
403        Ok(Self {
404            intent,
405            target,
406            size,
407            quality,
408            format,
409            count,
410            provider_params,
411        })
412    }
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct ImageGenerationResolvedPlan {
417    pub provider_model: ModelId,
418    pub machine_routing_model: ModelId,
419    pub machine_routing_realtime_capable: bool,
420    pub execution_plan: GenerateImageExecutionPlan,
421    #[serde(default, skip_serializing_if = "Vec::is_empty")]
422    pub projected_messages: Vec<crate::Message>,
423}
424
425impl<'de> Deserialize<'de> for GenerateImageRequest {
426    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
427    where
428        D: Deserializer<'de>,
429    {
430        #[derive(Deserialize)]
431        struct RawGenerateImageRequest {
432            intent: ImageGenerationIntent,
433            target: ImageGenerationTargetPreference,
434            size: ImageSizePreference,
435            quality: ImageQualityPreference,
436            format: ImageFormatPreference,
437            count: NonZeroU32,
438            #[serde(default)]
439            provider_params: Option<Value>,
440        }
441
442        let raw = RawGenerateImageRequest::deserialize(deserializer)?;
443        GenerateImageRequest::with_provider_params(
444            raw.intent,
445            raw.target,
446            raw.size,
447            raw.quality,
448            raw.format,
449            raw.count,
450            raw.provider_params,
451        )
452        .map_err(serde::de::Error::custom)
453    }
454}
455
456#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
457#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(rename_all = "snake_case")]
459pub enum ImageContinuityTokenSupport {
460    Unsupported,
461    SameProviderOnly,
462    CrossProvider,
463}
464
465#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467pub struct ImageGenerationTargetCapabilities {
468    pub hosted_image_generation_tool: bool,
469    pub native_image_output: bool,
470    pub custom_tools: bool,
471    pub image_search_grounding: bool,
472    pub image_continuity_tokens: ImageContinuityTokenSupport,
473}
474
475#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub struct GenerateImageExecutionPlan {
478    pub provider: ProviderId,
479    pub backend: ImageGenerationBackendKind,
480    pub max_count: NonZeroU32,
481    pub capabilities: ImageGenerationTargetCapabilities,
482    pub requires_scoped_override: bool,
483    #[serde(default)]
484    pub provider_plan: Value,
485}
486
487impl GenerateImageExecutionPlan {
488    pub fn requires_scoped_override(&self) -> bool {
489        self.requires_scoped_override
490    }
491}
492
493#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "snake_case")]
496pub enum ImageGenerationBackendKind {
497    HostedTool,
498    ProviderApi,
499    NativeModel,
500}
501
502#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct ImageGenerationProviderResolution {
505    #[cfg_attr(feature = "schema", schemars(with = "String"))]
506    pub provider_call_model: ModelId,
507    pub execution_plan: GenerateImageExecutionPlan,
508}
509
510pub trait ImageGenerationProviderProfile: Send + Sync {
511    fn canonical_provider(&self) -> crate::Provider;
512
513    fn provider_aliases(&self) -> &'static [&'static str] {
514        &[]
515    }
516
517    fn matches_provider_id(&self, provider: &str) -> bool {
518        let provider = provider.to_ascii_lowercase();
519        provider == self.canonical_provider().as_str()
520            || self.provider_aliases().contains(&provider.as_str())
521    }
522
523    fn image_generation_documentation(&self) -> Option<&'static str> {
524        None
525    }
526
527    fn resolve_execution_plan(
528        &self,
529        operation_id: ImageOperationId,
530        model: &ImageGenerationModelProfile,
531        request: &GenerateImageRequest,
532        capabilities: ImageGenerationTargetCapabilities,
533        max_count: NonZeroU32,
534    ) -> Result<ImageGenerationProviderResolution, ImageOperationDenialReason>;
535}
536
537pub trait ImageGenerationPlanner: Send + Sync {
538    fn resolve_image_generation_plan(
539        &self,
540        status: &SessionModelRoutingStatus,
541        operation_id: ImageOperationId,
542        request: &GenerateImageRequest,
543    ) -> Result<ImageGenerationResolvedPlan, ImageOperationDenialReason>;
544
545    fn infer_provider_for_model(&self, model: &str) -> Option<ProviderId>;
546
547    fn provider_documentation(&self) -> Vec<String> {
548        Vec::new()
549    }
550}
551
552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554pub struct AssistantImageRef {
555    pub image_id: AssistantImageId,
556    pub blob_ref: BlobRef,
557    pub media_type: MediaType,
558    pub width: u32,
559    pub height: u32,
560}
561
562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
564#[serde(tag = "disposition", rename_all = "snake_case")]
565pub enum ProviderTextDisposition {
566    NotEmitted,
567    UnsupportedByBackend,
568    EmittedButNotStored,
569    Captured { text_artifact_ref: TextArtifactRef },
570}
571
572#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574#[serde(tag = "disposition", rename_all = "snake_case")]
575pub enum RevisedPromptDisposition {
576    NotRequested,
577    UnsupportedByBackend,
578    Unchanged,
579    Revised {
580        text: PromptText,
581        source: RevisedPromptSource,
582    },
583}
584
585#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(rename_all = "snake_case")]
588pub enum RevisedPromptSource {
589    Provider,
590    MeerkatProjection,
591}
592
593#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595#[serde(tag = "warning", rename_all = "snake_case")]
596pub enum ImageGenerationWarning {
597    ContinuityDegraded,
598    UnsupportedArtifactDropped,
599    ProviderReturnedFewerImages {
600        requested: NonZeroU32,
601        returned: NonZeroU32,
602    },
603    ProviderExecutionFailed {
604        message: String,
605    },
606    BlobCommitFailed {
607        message: String,
608    },
609    ProviderTextCaptureFailed {
610        message: String,
611    },
612}
613
614#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616#[serde(tag = "provider", rename_all = "snake_case")]
617pub enum ProviderImageMetadata {
618    NotEmitted,
619    // `rename_all = "snake_case"` tags this variant `"open_ai"`, diverging from
620    // the canonical provider name `"openai"` (see `Provider::as_str`). Pin the
621    // canonical discriminator so the `provider` tag matches every other seam.
622    #[serde(rename = "openai")]
623    OpenAi(OpenAiImageMetadata),
624    Gemini(GeminiImageMetadata),
625}
626
627#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
629pub struct OpenAiImageMetadata {
630    pub target_model: String,
631    #[serde(skip_serializing_if = "Option::is_none")]
632    pub response_id: Option<String>,
633    #[serde(skip_serializing_if = "Option::is_none")]
634    pub image_generation_call_id: Option<String>,
635}
636
637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
638#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
639pub struct GeminiImageMetadata {
640    pub target_model: String,
641    #[serde(skip_serializing_if = "Option::is_none")]
642    pub response_id: Option<String>,
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub continuity_ref: Option<ImageContinuityRef>,
645}
646
647#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649pub struct ImageGenerationToolResult {
650    pub operation_id: ImageOperationId,
651    pub terminal: ImageOperationTerminalClass,
652    #[serde(default, skip_serializing_if = "Vec::is_empty")]
653    pub images: Vec<AssistantImageRef>,
654    pub provider_text: ProviderTextDisposition,
655    pub revised_prompt: RevisedPromptDisposition,
656    pub native_metadata: ProviderImageMetadata,
657    #[serde(default, skip_serializing_if = "Vec::is_empty")]
658    pub warnings: Vec<ImageGenerationWarning>,
659}
660
661#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
663#[serde(tag = "phase", rename_all = "snake_case")]
664pub enum ImageOperationPhase {
665    Requested,
666    Validating,
667    AwaitingApproval {
668        approval_id: ApprovalId,
669    },
670    PlanResolved,
671    ProjectionSnapshotted,
672    ScopedOverrideActive,
673    ProviderCallInFlight,
674    ProviderResultCaptured,
675    BlobCommitPending,
676    ResultCommitted,
677    RestoringScopedOverride,
678    Terminal {
679        terminal: ImageOperationTerminalClass,
680    },
681}
682
683#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(tag = "terminal", rename_all = "snake_case")]
686pub enum ImageOperationTerminalClass {
687    Generated,
688    EmptyResult {
689        provider_text: ProviderTextDisposition,
690    },
691    Denied {
692        reason: ImageOperationDenialReason,
693    },
694    RefusedByProvider,
695    SafetyFiltered,
696    Failed,
697    Cancelled,
698    Timeout,
699    ScopedRestoreFailed {
700        trigger: PostActivationImageTerminal,
701    },
702}
703
704#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
705#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
706#[serde(tag = "observation", rename_all = "snake_case")]
707pub enum ImageProviderTerminalObservation {
708    Generated,
709    EmptyResult,
710    ProviderHttpError {
711        status_code: Option<u16>,
712        code: ImageProviderErrorCode,
713    },
714    ProviderNativeError {
715        code: ImageProviderErrorCode,
716    },
717    ExecutionFailed,
718    BlobCommitFailed,
719}
720
721#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
722#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
723#[serde(rename_all = "snake_case")]
724pub enum ImageProviderErrorCode {
725    Unknown,
726    OpenAiContentFilter,
727    OpenAiModelRefusal,
728    GeminiSafety,
729    GeminiModelRefusal,
730    GeminiDeadlineExceeded,
731}
732
733#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
734#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
735#[serde(tag = "reason", rename_all = "snake_case")]
736pub enum ImageOperationDenialReason {
737    UnsupportedTarget,
738    UnsupportedCount,
739    CapabilityPolicy,
740    CostPolicy,
741    SafetyPolicy,
742    ApprovalRequiredButUnavailable,
743    DeniedDuringApproval {
744        approvable: ImageOperationApprovalReason,
745    },
746    ScopedOverrideConflict,
747    RealtimeTransportConflict,
748    ProjectionUnsupported,
749}
750
751#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
752#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
753#[serde(tag = "terminal", rename_all = "snake_case")]
754pub enum PostActivationImageTerminal {
755    Generated,
756    EmptyResult,
757    Denied {
758        reason: PostActivationImageDenialReason,
759    },
760    RefusedByProvider,
761    SafetyFiltered,
762    Failed,
763    Cancelled,
764    Timeout,
765}
766
767#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
768#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
769#[serde(tag = "reason", rename_all = "snake_case")]
770pub enum PostActivationImageDenialReason {
771    CostPolicy,
772    SafetyPolicy,
773    DeniedDuringApproval {
774        approvable: ImageOperationApprovalReason,
775    },
776    ScopedOverrideConflict,
777    RealtimeTransportConflict,
778    ProjectionUnsupported,
779}
780
781#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
782#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783pub struct SwitchTurnIntent {
784    #[cfg_attr(feature = "schema", schemars(with = "String"))]
785    pub target_model: ModelId,
786    pub duration: SwitchTurnDuration,
787    pub origin: SwitchTurnOrigin,
788}
789
790#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
792#[serde(tag = "duration_type", rename_all = "snake_case")]
793pub enum SwitchTurnDuration {
794    Finite { duration: FiniteScopedTurnDuration },
795    UntilChanged,
796}
797
798#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
799#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
800#[serde(tag = "duration", rename_all = "snake_case")]
801pub enum FiniteScopedTurnDuration {
802    OneTurn,
803    Turns { turns: NonZeroU32 },
804}
805
806#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
807#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
808#[serde(tag = "origin", rename_all = "snake_case")]
809pub enum SwitchTurnOrigin {
810    User {
811        reason: SwitchTurnReasonTextDisposition,
812    },
813    Model {
814        reason: SwitchTurnReasonTextDisposition,
815    },
816    SystemPolicy {
817        reason: SwitchTurnPolicyReason,
818    },
819}
820
821#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
823#[serde(tag = "disposition", rename_all = "snake_case")]
824pub enum SwitchTurnReasonTextDisposition {
825    NotProvided,
826    Provided { reason: SwitchTurnReasonText },
827}
828
829#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
830#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
831pub struct SwitchTurnReasonText {
832    pub content: String,
833}
834
835impl SwitchTurnReasonText {
836    pub fn new(content: impl Into<String>) -> Result<Self, ImageGenerationValidationError> {
837        let content = content.into();
838        validate_text(
839            &content,
840            "switch_turn_reason_text",
841            DEFAULT_SWITCH_TURN_REASON_MAX_CHARS,
842        )?;
843        Ok(Self { content })
844    }
845}
846
847impl<'de> Deserialize<'de> for SwitchTurnReasonText {
848    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
849    where
850        D: Deserializer<'de>,
851    {
852        #[derive(Deserialize)]
853        struct RawSwitchTurnReasonText {
854            content: String,
855        }
856
857        let raw = RawSwitchTurnReasonText::deserialize(deserializer)?;
858        SwitchTurnReasonText::new(raw.content).map_err(serde::de::Error::custom)
859    }
860}
861
862#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
863#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
864#[serde(rename_all = "snake_case")]
865pub enum SwitchTurnPolicyReason {
866    BudgetDowngrade,
867    SafetyHandoff,
868    ReleaseTemporaryHold,
869}
870
871#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
873#[serde(tag = "result", rename_all = "snake_case")]
874pub enum SwitchTurnControlResult {
875    Applied {
876        request_id: SwitchTurnRequestId,
877        #[cfg_attr(feature = "schema", schemars(with = "String"))]
878        target_model: ModelId,
879        duration: SwitchTurnDuration,
880    },
881    AwaitingApproval {
882        approval_id: ApprovalId,
883        #[cfg_attr(feature = "schema", schemars(with = "String"))]
884        target_model: ModelId,
885        duration: SwitchTurnDuration,
886        reason: SwitchTurnApprovalReason,
887    },
888    Denied {
889        request_id: SwitchTurnRequestId,
890        reason: SwitchTurnDenialReason,
891    },
892}
893
894#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
896#[serde(tag = "reason", rename_all = "snake_case")]
897pub enum SwitchTurnDenialReason {
898    UnsupportedModel,
899    CapabilityPolicy,
900    CostPolicy,
901    SafetyPolicy,
902    ApprovalRequiredButUnavailable,
903    DeniedDuringApproval {
904        approvable: SwitchTurnApprovalReason,
905    },
906    ScopedOverrideConflict,
907    RealtimeTransportConflict,
908    ProjectionUnsupported,
909}
910
911#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
912#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
913#[serde(rename_all = "snake_case")]
914pub enum SwitchTurnApprovalReason {
915    CrossProvider,
916    CostExceedsThreshold,
917    SafetyHold,
918    UntilChangedFromModelOrigin,
919    RealtimeDetachRequired,
920}
921
922#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
924#[serde(rename_all = "snake_case")]
925pub enum ImageOperationApprovalReason {
926    CrossProvider,
927    CostExceedsThreshold,
928    SafetyHold,
929    RealtimeDetachRequired,
930}
931
932#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
933#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
934#[serde(rename_all = "snake_case")]
935pub enum ModelRoutingApprovalTerminalClass {
936    Approved,
937    DeniedByUser,
938    Expired,
939    Interrupted,
940    SessionArchived,
941    SurfaceDetached,
942}
943
944#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
945#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
946#[serde(tag = "phase", rename_all = "snake_case")]
947pub enum ModelRoutingApprovalPhase {
948    Pending,
949    PresentedToUser,
950    Terminal {
951        terminal: ModelRoutingApprovalTerminalClass,
952    },
953}
954
955#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
956#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
957#[serde(tag = "parent", rename_all = "snake_case")]
958pub enum ModelRoutingApprovalRequest {
959    SwitchTurn {
960        request_id: SwitchTurnRequestId,
961        reason: SwitchTurnApprovalReason,
962    },
963    ImageOperation {
964        operation_id: ImageOperationId,
965        reason: ImageOperationApprovalReason,
966    },
967}
968
969#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
970#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971#[serde(tag = "phase", rename_all = "snake_case")]
972pub enum SwitchTurnPhase {
973    Requested,
974    Validating,
975    PendingForBoundary,
976    AwaitingApproval { approval_id: ApprovalId },
977    ApplyingFiniteOverride,
978    ApplyingPersistentReconfigure,
979    ActiveFiniteOverride,
980    RestoringFiniteOverride,
981    Terminal { terminal: SwitchTurnTerminalClass },
982}
983
984#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
985#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
986#[serde(tag = "terminal", rename_all = "snake_case")]
987pub enum SwitchTurnTerminalClass {
988    Denied { reason: SwitchTurnDenialReason },
989    ConsumedAndRestored,
990    InterruptedAndRestored,
991    PersistentReconfigureApplied,
992    PersistentReconfigureFailed,
993    RestoreFailed { trigger: SwitchTurnRestoreTrigger },
994}
995
996#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
997#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
998#[serde(rename_all = "snake_case")]
999pub enum SwitchTurnRestoreTrigger {
1000    Consumed,
1001    Interrupted,
1002}
1003
1004#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1005#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1006pub struct ScopedModelOverride {
1007    pub id: ScopedModelOverrideId,
1008    pub kind: ScopedModelOverrideKind,
1009    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1010    pub previous_effective_model: ModelId,
1011    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1012    pub baseline_model_snapshot: ModelId,
1013    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1014    pub target_model: ModelId,
1015    pub topology_epoch: TopologyEpoch,
1016}
1017
1018#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1019#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1020#[serde(tag = "kind", rename_all = "snake_case")]
1021pub enum ScopedModelOverrideKind {
1022    ImageOperation {
1023        operation_id: ImageOperationId,
1024    },
1025    FiniteSwitchTurn {
1026        request_id: SwitchTurnRequestId,
1027        duration: FiniteScopedTurnDuration,
1028    },
1029}
1030
1031#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1032#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1033pub struct ScopedModelOverrideSummary {
1034    pub id: ScopedModelOverrideId,
1035    pub kind: ScopedModelOverrideKind,
1036    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1037    pub target_model: ModelId,
1038    pub topology_epoch: TopologyEpoch,
1039}
1040
1041#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1042#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1043pub struct SwitchTurnRequestSummary {
1044    pub request_id: SwitchTurnRequestId,
1045    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1046    pub target_model: ModelId,
1047    pub duration: SwitchTurnDuration,
1048    pub phase: SwitchTurnPhase,
1049}
1050
1051#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1053pub struct SessionModelRoutingStatus {
1054    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1055    pub baseline_model: ModelId,
1056    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1057    pub effective_model: ModelId,
1058    /// Typed provider of the session's current LLM identity, projected from
1059    /// the machine-owned `current_session_llm_identity`
1060    /// (`SessionLlmIdentity.provider`). This is the single owner of the
1061    /// "which provider is this session on" fact at planning time — it is
1062    /// hydrated at session construction and recommitted by the live
1063    /// identity hot-swap transition, so consumers (e.g. the image
1064    /// auto-planner) must read it instead of re-deriving a provider from
1065    /// the effective model string through the built-in catalog (which
1066    /// silently disagrees for `ModelRegistry`-owned custom models).
1067    ///
1068    /// `None` means the machine has no hydrated session LLM identity yet
1069    /// (pre-hydration); it is not a fallback signal to re-derive one.
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub session_provider: Option<crate::Provider>,
1072    #[serde(skip_serializing_if = "Option::is_none")]
1073    pub active_turn_override: Option<ScopedModelOverrideSummary>,
1074    #[serde(skip_serializing_if = "Option::is_none")]
1075    pub active_operation_override: Option<ScopedModelOverrideSummary>,
1076    #[serde(skip_serializing_if = "Option::is_none")]
1077    pub pending_switch_turn: Option<SwitchTurnRequestSummary>,
1078}
1079
1080impl SessionModelRoutingStatus {
1081    pub fn new(
1082        baseline_model: ModelId,
1083        active_turn_override: Option<ScopedModelOverrideSummary>,
1084        active_operation_override: Option<ScopedModelOverrideSummary>,
1085        pending_switch_turn: Option<SwitchTurnRequestSummary>,
1086    ) -> Self {
1087        let effective_model = active_operation_override
1088            .as_ref()
1089            .or(active_turn_override.as_ref())
1090            .map(|summary| summary.target_model.clone())
1091            .unwrap_or_else(|| baseline_model.clone());
1092
1093        Self {
1094            baseline_model,
1095            effective_model,
1096            session_provider: None,
1097            active_turn_override,
1098            active_operation_override,
1099            pending_switch_turn,
1100        }
1101    }
1102
1103    /// Attach the session's typed LLM provider identity to the status.
1104    ///
1105    /// The runtime projection sets this from the machine-owned
1106    /// `current_session_llm_identity`; `None` faithfully mirrors a machine
1107    /// state with no hydrated identity.
1108    pub fn with_session_provider(mut self, session_provider: Option<crate::Provider>) -> Self {
1109        self.session_provider = session_provider;
1110        self
1111    }
1112}
1113
1114#[cfg(test)]
1115#[allow(clippy::unwrap_used, clippy::redundant_clone)]
1116mod tests {
1117    use super::*;
1118    use crate::blob::{BlobId, BlobRef};
1119    use serde::de::DeserializeOwned;
1120    use std::fmt::Debug;
1121
1122    fn uuid(n: u128) -> Uuid {
1123        Uuid::from_u128(n)
1124    }
1125
1126    fn roundtrip<T>(value: T)
1127    where
1128        T: Serialize + DeserializeOwned + PartialEq + Debug,
1129    {
1130        let encoded = serde_json::to_string(&value).unwrap();
1131        let decoded: T = serde_json::from_str(&encoded).unwrap();
1132        assert_eq!(decoded, value);
1133    }
1134
1135    fn source_image() -> ImageSourceRef {
1136        ImageSourceRef::Blob {
1137            blob_ref: BlobRef {
1138                blob_id: BlobId::new("blob-1"),
1139                media_type: "image/png".to_string(),
1140            },
1141        }
1142    }
1143
1144    fn prompt_source() -> PromptSource {
1145        PromptSource::ModelDistilled {
1146            tool_call_id: ToolCallId::new("tool-call-1"),
1147        }
1148    }
1149
1150    #[test]
1151    fn media_type_canonicalizes_case_params_and_whitespace() {
1152        assert_eq!(MediaType::canonical_str("image/PNG"), "image/png");
1153        assert_eq!(
1154            MediaType::canonical_str("image/png; charset=binary"),
1155            "image/png"
1156        );
1157        assert_eq!(MediaType::canonical_str("  Image/JPEG  "), "image/jpeg");
1158        assert_eq!(
1159            MediaType::new("image/PNG").normalize(),
1160            MediaType::new("image/png")
1161        );
1162    }
1163
1164    #[test]
1165    fn media_type_parse_is_fail_closed() {
1166        assert_eq!(
1167            MediaType::parse("image/PNG"),
1168            Ok(MediaType::new("image/png"))
1169        );
1170        assert_eq!(MediaType::parse(""), Err(MediaTypeError::Empty));
1171        assert_eq!(MediaType::parse("   "), Err(MediaTypeError::Empty));
1172        assert!(matches!(
1173            MediaType::parse("not-a-media-type"),
1174            Err(MediaTypeError::Malformed(_))
1175        ));
1176        assert!(matches!(
1177            MediaType::parse("image/png/extra"),
1178            Err(MediaTypeError::Malformed(_))
1179        ));
1180        assert!(matches!(
1181            MediaType::parse("/png"),
1182            Err(MediaTypeError::Malformed(_))
1183        ));
1184    }
1185
1186    #[test]
1187    fn image_generation_request_serde_roundtrip() {
1188        let request = GenerateImageRequest::new(
1189            ImageGenerationIntent::Generate {
1190                prompt: PromptText::new("paint a small red bridge").unwrap(),
1191                prompt_source: prompt_source(),
1192                reference_images: vec![source_image()],
1193            },
1194            ImageGenerationTargetPreference::ProviderDefault {
1195                provider: ProviderId::new("openai"),
1196            },
1197            ImageSizePreference::Square1024,
1198            ImageQualityPreference::High,
1199            ImageFormatPreference::Png,
1200            NonZeroU32::new(2).unwrap(),
1201        )
1202        .unwrap();
1203
1204        let json = serde_json::to_string(&request).unwrap();
1205        let parsed: GenerateImageRequest = serde_json::from_str(&json).unwrap();
1206        assert_eq!(parsed, request);
1207    }
1208
1209    #[test]
1210    fn nonzero_count_validation_rejects_zero() {
1211        let json = serde_json::json!({
1212            "intent": {
1213                "intent": "generate",
1214                "prompt": { "content": "draw a circle" },
1215                "prompt_source": {
1216                    "source": "model_distilled",
1217                    "tool_call_id": "tool-call-1"
1218                }
1219            },
1220            "target": { "target": "auto" },
1221            "size": { "size": "auto" },
1222            "quality": "auto",
1223            "format": "png",
1224            "count": 0
1225        });
1226
1227        assert!(serde_json::from_value::<GenerateImageRequest>(json).is_err());
1228    }
1229
1230    #[test]
1231    fn edit_request_deserialization_rejects_empty_source_images() {
1232        let err = ImageGenerationIntent::edit(
1233            PromptText::new("make it brighter").unwrap(),
1234            prompt_source(),
1235            Vec::new(),
1236        )
1237        .unwrap_err();
1238        assert_eq!(err, ImageGenerationValidationError::MissingEditSourceImages);
1239
1240        let json = serde_json::json!({
1241            "intent": {
1242                "intent": "edit",
1243                "instruction": { "content": "make it brighter" },
1244                "instruction_source": {
1245                    "source": "model_distilled",
1246                    "tool_call_id": "tool-call-1"
1247                },
1248                "source_images": []
1249            },
1250            "target": { "target": "auto" },
1251            "size": { "size": "auto" },
1252            "quality": "auto",
1253            "format": "png",
1254            "count": 1
1255        });
1256        assert!(serde_json::from_value::<GenerateImageRequest>(json).is_err());
1257    }
1258
1259    #[test]
1260    fn execution_plan_and_result_variants_roundtrip() {
1261        let caps = ImageGenerationTargetCapabilities {
1262            hosted_image_generation_tool: true,
1263            native_image_output: false,
1264            custom_tools: true,
1265            image_search_grounding: false,
1266            image_continuity_tokens: ImageContinuityTokenSupport::SameProviderOnly,
1267        };
1268        let plans = vec![
1269            GenerateImageExecutionPlan {
1270                provider: ProviderId::new("openai"),
1271                backend: ImageGenerationBackendKind::HostedTool,
1272                max_count: NonZeroU32::new(4).unwrap(),
1273                capabilities: caps.clone(),
1274                requires_scoped_override: false,
1275                provider_plan: serde_json::json!({"tool_name": "image_generation"}),
1276            },
1277            GenerateImageExecutionPlan {
1278                provider: ProviderId::new("openai"),
1279                backend: ImageGenerationBackendKind::ProviderApi,
1280                max_count: NonZeroU32::new(1).unwrap(),
1281                capabilities: caps.clone(),
1282                requires_scoped_override: false,
1283                provider_plan: serde_json::json!({"endpoint": "generations"}),
1284            },
1285            GenerateImageExecutionPlan {
1286                provider: ProviderId::new("openai"),
1287                backend: ImageGenerationBackendKind::ProviderApi,
1288                max_count: NonZeroU32::new(1).unwrap(),
1289                capabilities: caps.clone(),
1290                requires_scoped_override: false,
1291                provider_plan: serde_json::json!({"endpoint": "edits"}),
1292            },
1293            GenerateImageExecutionPlan {
1294                provider: ProviderId::new("gemini"),
1295                backend: ImageGenerationBackendKind::NativeModel,
1296                max_count: NonZeroU32::new(1).unwrap(),
1297                capabilities: ImageGenerationTargetCapabilities {
1298                    hosted_image_generation_tool: false,
1299                    native_image_output: true,
1300                    custom_tools: false,
1301                    image_search_grounding: false,
1302                    image_continuity_tokens: ImageContinuityTokenSupport::Unsupported,
1303                },
1304                requires_scoped_override: true,
1305                provider_plan: serde_json::json!({
1306                    "projection_snapshot_id": ProjectionSnapshotId::new(uuid(50))
1307                }),
1308            },
1309        ];
1310
1311        for plan in plans {
1312            let encoded = serde_json::to_string(&plan).unwrap();
1313            let decoded: GenerateImageExecutionPlan = serde_json::from_str(&encoded).unwrap();
1314            assert_eq!(decoded, plan);
1315        }
1316
1317        let result = ImageGenerationToolResult {
1318            operation_id: ImageOperationId::new(uuid(51)),
1319            terminal: ImageOperationTerminalClass::Generated,
1320            images: vec![AssistantImageRef {
1321                image_id: AssistantImageId::new(uuid(52)),
1322                blob_ref: BlobRef {
1323                    blob_id: BlobId::new("blob-52"),
1324                    media_type: "image/png".into(),
1325                },
1326                media_type: MediaType::new("image/png"),
1327                width: 1024,
1328                height: 1024,
1329            }],
1330            provider_text: ProviderTextDisposition::Captured {
1331                text_artifact_ref: TextArtifactRef::new("text-artifact-1"),
1332            },
1333            revised_prompt: RevisedPromptDisposition::Unchanged,
1334            native_metadata: ProviderImageMetadata::Gemini(GeminiImageMetadata {
1335                target_model: "provider-native-image-model".into(),
1336                response_id: Some("image-response".into()),
1337                continuity_ref: Some(ImageContinuityRef::new("continuity-1")),
1338            }),
1339            warnings: vec![ImageGenerationWarning::ContinuityDegraded],
1340        };
1341        let encoded = serde_json::to_string(&result).unwrap();
1342        let decoded: ImageGenerationToolResult = serde_json::from_str(&encoded).unwrap();
1343        assert_eq!(decoded, result);
1344    }
1345
1346    #[test]
1347    fn effective_model_precedence_is_operation_then_turn_then_baseline() {
1348        let turn = ScopedModelOverrideSummary {
1349            id: ScopedModelOverrideId::new(uuid(10)),
1350            kind: ScopedModelOverrideKind::FiniteSwitchTurn {
1351                request_id: SwitchTurnRequestId::new(uuid(11)),
1352                duration: FiniteScopedTurnDuration::OneTurn,
1353            },
1354            target_model: ModelId::new("turn-model"),
1355            topology_epoch: TopologyEpoch(1),
1356        };
1357        let operation = ScopedModelOverrideSummary {
1358            id: ScopedModelOverrideId::new(uuid(20)),
1359            kind: ScopedModelOverrideKind::ImageOperation {
1360                operation_id: ImageOperationId::new(uuid(21)),
1361            },
1362            target_model: ModelId::new("operation-model"),
1363            topology_epoch: TopologyEpoch(2),
1364        };
1365
1366        let baseline_only =
1367            SessionModelRoutingStatus::new(ModelId::new("baseline"), None, None, None);
1368        assert_eq!(baseline_only.effective_model, ModelId::new("baseline"));
1369
1370        let turn_active = SessionModelRoutingStatus::new(
1371            ModelId::new("baseline"),
1372            Some(turn.clone()),
1373            None,
1374            None,
1375        );
1376        assert_eq!(turn_active.effective_model, ModelId::new("turn-model"));
1377
1378        let operation_active = SessionModelRoutingStatus::new(
1379            ModelId::new("baseline"),
1380            Some(turn),
1381            Some(operation),
1382            None,
1383        );
1384        assert_eq!(
1385            operation_active.effective_model,
1386            ModelId::new("operation-model")
1387        );
1388    }
1389
1390    #[test]
1391    fn session_model_routing_status_carries_typed_session_provider() {
1392        // The hydrated path: the provider is the typed identity fact, encoded
1393        // with the canonical Provider wire names and round-trip stable.
1394        let status =
1395            SessionModelRoutingStatus::new(ModelId::new("my-custom-model"), None, None, None)
1396                .with_session_provider(Some(crate::Provider::OpenAI));
1397        assert_eq!(status.session_provider, Some(crate::Provider::OpenAI));
1398        let encoded = serde_json::to_value(&status).unwrap();
1399        assert_eq!(encoded["session_provider"], "openai");
1400        roundtrip(status);
1401
1402        // The pre-hydration path: no identity fact exists, the field is
1403        // absent on the wire and reads back as None — never a minted default.
1404        let unhydrated = SessionModelRoutingStatus::new(ModelId::new("baseline"), None, None, None);
1405        assert_eq!(unhydrated.session_provider, None);
1406        let encoded = serde_json::to_value(&unhydrated).unwrap();
1407        assert!(encoded.get("session_provider").is_none());
1408        let decoded: SessionModelRoutingStatus = serde_json::from_value(encoded).unwrap();
1409        assert_eq!(decoded.session_provider, None);
1410    }
1411
1412    #[test]
1413    fn until_changed_is_not_encoded_as_zero_turns() {
1414        let duration = SwitchTurnDuration::UntilChanged;
1415        let json = serde_json::to_value(duration).unwrap();
1416        assert_eq!(
1417            json,
1418            serde_json::json!({ "duration_type": "until_changed" })
1419        );
1420        assert!(!json.to_string().contains("\"turns\":0"));
1421
1422        let zero_turns = serde_json::json!({
1423            "duration_type": "finite",
1424            "duration": {
1425                "duration": "turns",
1426                "turns": 0
1427            }
1428        });
1429        assert!(serde_json::from_value::<SwitchTurnDuration>(zero_turns).is_err());
1430    }
1431
1432    #[test]
1433    fn approval_parent_linkage_shapes_are_typed() {
1434        let switch = ModelRoutingApprovalRequest::SwitchTurn {
1435            request_id: SwitchTurnRequestId::new(uuid(30)),
1436            reason: SwitchTurnApprovalReason::UntilChangedFromModelOrigin,
1437        };
1438        let image = ModelRoutingApprovalRequest::ImageOperation {
1439            operation_id: ImageOperationId::new(uuid(31)),
1440            reason: ImageOperationApprovalReason::CrossProvider,
1441        };
1442
1443        let switch_json = serde_json::to_value(&switch).unwrap();
1444        assert_eq!(switch_json["parent"], "switch_turn");
1445        assert!(switch_json.get("request_id").is_some());
1446        assert!(switch_json.get("operation_id").is_none());
1447        let parsed_switch: ModelRoutingApprovalRequest =
1448            serde_json::from_value(switch_json).unwrap();
1449        assert_eq!(parsed_switch, switch);
1450
1451        let image_json = serde_json::to_value(&image).unwrap();
1452        assert_eq!(image_json["parent"], "image_operation");
1453        assert!(image_json.get("operation_id").is_some());
1454        assert!(image_json.get("request_id").is_none());
1455        let parsed_image: ModelRoutingApprovalRequest = serde_json::from_value(image_json).unwrap();
1456        assert_eq!(parsed_image, image);
1457    }
1458
1459    #[test]
1460    fn lifecycle_and_override_variants_roundtrip() {
1461        let approval_id = ApprovalId::new();
1462        let phases = vec![
1463            ImageOperationPhase::AwaitingApproval { approval_id },
1464            ImageOperationPhase::Terminal {
1465                terminal: ImageOperationTerminalClass::ScopedRestoreFailed {
1466                    trigger: PostActivationImageTerminal::Cancelled,
1467                },
1468            },
1469        ];
1470        for phase in phases {
1471            let encoded = serde_json::to_string(&phase).unwrap();
1472            let decoded: ImageOperationPhase = serde_json::from_str(&encoded).unwrap();
1473            assert_eq!(decoded, phase);
1474        }
1475
1476        let control = SwitchTurnControlResult::Denied {
1477            request_id: SwitchTurnRequestId::new(uuid(61)),
1478            reason: SwitchTurnDenialReason::ScopedOverrideConflict,
1479        };
1480        let encoded = serde_json::to_string(&control).unwrap();
1481        let decoded: SwitchTurnControlResult = serde_json::from_str(&encoded).unwrap();
1482        assert_eq!(decoded, control);
1483
1484        let switch_phase = SwitchTurnPhase::Terminal {
1485            terminal: SwitchTurnTerminalClass::RestoreFailed {
1486                trigger: SwitchTurnRestoreTrigger::Interrupted,
1487            },
1488        };
1489        let encoded = serde_json::to_string(&switch_phase).unwrap();
1490        let decoded: SwitchTurnPhase = serde_json::from_str(&encoded).unwrap();
1491        assert_eq!(decoded, switch_phase);
1492
1493        let approval_phase = ModelRoutingApprovalPhase::Terminal {
1494            terminal: ModelRoutingApprovalTerminalClass::SurfaceDetached,
1495        };
1496        let encoded = serde_json::to_string(&approval_phase).unwrap();
1497        let decoded: ModelRoutingApprovalPhase = serde_json::from_str(&encoded).unwrap();
1498        assert_eq!(decoded, approval_phase);
1499
1500        let override_state = ScopedModelOverride {
1501            id: ScopedModelOverrideId::new(uuid(62)),
1502            kind: ScopedModelOverrideKind::FiniteSwitchTurn {
1503                request_id: SwitchTurnRequestId::new(uuid(63)),
1504                duration: FiniteScopedTurnDuration::Turns {
1505                    turns: NonZeroU32::new(3).unwrap(),
1506                },
1507            },
1508            previous_effective_model: ModelId::new("old-effective"),
1509            baseline_model_snapshot: ModelId::new("baseline"),
1510            target_model: ModelId::new("target"),
1511            topology_epoch: TopologyEpoch(7),
1512        };
1513        let encoded = serde_json::to_string(&override_state).unwrap();
1514        let decoded: ScopedModelOverride = serde_json::from_str(&encoded).unwrap();
1515        assert_eq!(decoded, override_state);
1516    }
1517
1518    #[test]
1519    fn phase0_declared_enum_variants_roundtrip() {
1520        roundtrip(PromptSource::UserProvided {
1521            message_id: MessageId::new(uuid(70)),
1522        });
1523        roundtrip(PromptSource::ModelDistilled {
1524            tool_call_id: ToolCallId::new("tool-call-70"),
1525        });
1526        roundtrip(PromptSource::Hybrid {
1527            user_message_ids: vec![MessageId::new(uuid(71)), MessageId::new(uuid(72))],
1528            tool_call_id: ToolCallId::new("tool-call-71"),
1529        });
1530
1531        roundtrip(ImageSourceRef::Blob {
1532            blob_ref: BlobRef {
1533                blob_id: BlobId::new("blob-70"),
1534                media_type: "image/png".into(),
1535            },
1536        });
1537        roundtrip(ImageSourceRef::TranscriptBlock {
1538            message_id: MessageId::new(uuid(73)),
1539            block_id: BlockId::new(uuid(74)),
1540        });
1541        roundtrip(ImageSourceRef::AssistantImage {
1542            image_id: AssistantImageId::new(uuid(75)),
1543        });
1544        roundtrip(ImageSourceRef::ProviderNative {
1545            provider: ProviderId::new("gemini"),
1546            handle: ProviderImageHandle::new("provider-image-handle"),
1547            continuity: ImageContinuityDisposition::Available {
1548                continuity_ref: ImageContinuityRef::new("continuity-70"),
1549            },
1550            fallback_blob_ref: BlobRef {
1551                blob_id: BlobId::new("fallback-70"),
1552                media_type: "image/png".into(),
1553            },
1554        });
1555        roundtrip(ImageContinuityDisposition::NotProvided);
1556        roundtrip(ImageContinuityDisposition::UnsupportedBySourceProvider);
1557
1558        roundtrip(ImageGenerationTargetPreference::Auto);
1559        roundtrip(ImageGenerationTargetPreference::ProviderDefault {
1560            provider: ProviderId::new("openai"),
1561        });
1562        roundtrip(ImageGenerationTargetPreference::Model {
1563            provider: ProviderId::new("gemini"),
1564            model: ModelId::new("provider-native-image-model"),
1565        });
1566
1567        roundtrip(ImageSizePreference::Auto);
1568        roundtrip(ImageSizePreference::Square1024);
1569        roundtrip(ImageSizePreference::Portrait1024x1536);
1570        roundtrip(ImageSizePreference::Landscape1536x1024);
1571        roundtrip(ImageSizePreference::Custom {
1572            width: NonZeroU32::new(640).unwrap(),
1573            height: NonZeroU32::new(480).unwrap(),
1574        });
1575
1576        for quality in [
1577            ImageQualityPreference::Auto,
1578            ImageQualityPreference::Low,
1579            ImageQualityPreference::Medium,
1580            ImageQualityPreference::High,
1581        ] {
1582            roundtrip(quality);
1583        }
1584        for format in [
1585            ImageFormatPreference::Auto,
1586            ImageFormatPreference::Png,
1587            ImageFormatPreference::Jpeg,
1588            ImageFormatPreference::Webp,
1589        ] {
1590            roundtrip(format);
1591        }
1592
1593        for support in [
1594            ImageContinuityTokenSupport::Unsupported,
1595            ImageContinuityTokenSupport::SameProviderOnly,
1596            ImageContinuityTokenSupport::CrossProvider,
1597        ] {
1598            roundtrip(support);
1599        }
1600
1601        roundtrip(ProviderTextDisposition::NotEmitted);
1602        roundtrip(ProviderTextDisposition::UnsupportedByBackend);
1603        roundtrip(ProviderTextDisposition::Captured {
1604            text_artifact_ref: TextArtifactRef::new("text-70"),
1605        });
1606
1607        roundtrip(RevisedPromptDisposition::NotRequested);
1608        roundtrip(RevisedPromptDisposition::UnsupportedByBackend);
1609        roundtrip(RevisedPromptDisposition::Unchanged);
1610        roundtrip(RevisedPromptDisposition::Revised {
1611            text: PromptText::new("revised prompt").unwrap(),
1612            source: RevisedPromptSource::MeerkatProjection,
1613        });
1614        roundtrip(RevisedPromptSource::Provider);
1615        roundtrip(RevisedPromptSource::MeerkatProjection);
1616
1617        roundtrip(ImageGenerationWarning::ContinuityDegraded);
1618        roundtrip(ImageGenerationWarning::UnsupportedArtifactDropped);
1619        roundtrip(ImageGenerationWarning::ProviderReturnedFewerImages {
1620            requested: NonZeroU32::new(2).unwrap(),
1621            returned: NonZeroU32::new(1).unwrap(),
1622        });
1623        roundtrip(ImageGenerationWarning::ProviderExecutionFailed {
1624            message: "provider unavailable".into(),
1625        });
1626        roundtrip(ImageGenerationWarning::BlobCommitFailed {
1627            message: "blob store unavailable".into(),
1628        });
1629        roundtrip(ImageGenerationWarning::ProviderTextCaptureFailed {
1630            message: "text capture unavailable".into(),
1631        });
1632
1633        roundtrip(ProviderImageMetadata::NotEmitted);
1634        roundtrip(ProviderImageMetadata::OpenAi(OpenAiImageMetadata {
1635            target_model: "provider-api-image-model".into(),
1636            response_id: Some("resp-70".into()),
1637            image_generation_call_id: Some("ig-70".into()),
1638        }));
1639        roundtrip(ProviderImageMetadata::Gemini(GeminiImageMetadata {
1640            target_model: "provider-native-image-model".into(),
1641            response_id: Some("resp-70".into()),
1642            continuity_ref: Some(ImageContinuityRef::new("continuity-71")),
1643        }));
1644
1645        for phase in [
1646            ImageOperationPhase::Requested,
1647            ImageOperationPhase::Validating,
1648            ImageOperationPhase::PlanResolved,
1649            ImageOperationPhase::ProjectionSnapshotted,
1650            ImageOperationPhase::ScopedOverrideActive,
1651            ImageOperationPhase::ProviderCallInFlight,
1652            ImageOperationPhase::ProviderResultCaptured,
1653            ImageOperationPhase::BlobCommitPending,
1654            ImageOperationPhase::ResultCommitted,
1655            ImageOperationPhase::RestoringScopedOverride,
1656        ] {
1657            roundtrip(phase);
1658        }
1659        roundtrip(ImageOperationPhase::AwaitingApproval {
1660            approval_id: ApprovalId::new(),
1661        });
1662        roundtrip(ImageOperationPhase::Terminal {
1663            terminal: ImageOperationTerminalClass::Denied {
1664                reason: ImageOperationDenialReason::UnsupportedTarget,
1665            },
1666        });
1667
1668        roundtrip(ImageOperationTerminalClass::Generated);
1669        roundtrip(ImageOperationTerminalClass::EmptyResult {
1670            provider_text: ProviderTextDisposition::NotEmitted,
1671        });
1672        roundtrip(ImageOperationTerminalClass::RefusedByProvider);
1673        roundtrip(ImageOperationTerminalClass::SafetyFiltered);
1674        roundtrip(ImageOperationTerminalClass::Failed);
1675        roundtrip(ImageOperationTerminalClass::Cancelled);
1676        roundtrip(ImageOperationTerminalClass::Timeout);
1677        roundtrip(ImageOperationTerminalClass::ScopedRestoreFailed {
1678            trigger: PostActivationImageTerminal::Failed,
1679        });
1680
1681        roundtrip(ImageProviderTerminalObservation::Generated);
1682        roundtrip(ImageProviderTerminalObservation::EmptyResult);
1683        roundtrip(ImageProviderTerminalObservation::ProviderHttpError {
1684            status_code: Some(400),
1685            code: ImageProviderErrorCode::OpenAiContentFilter,
1686        });
1687        roundtrip(ImageProviderTerminalObservation::ProviderNativeError {
1688            code: ImageProviderErrorCode::GeminiModelRefusal,
1689        });
1690        roundtrip(ImageProviderTerminalObservation::ExecutionFailed);
1691        roundtrip(ImageProviderTerminalObservation::BlobCommitFailed);
1692        for code in [
1693            ImageProviderErrorCode::Unknown,
1694            ImageProviderErrorCode::OpenAiContentFilter,
1695            ImageProviderErrorCode::OpenAiModelRefusal,
1696            ImageProviderErrorCode::GeminiSafety,
1697            ImageProviderErrorCode::GeminiModelRefusal,
1698            ImageProviderErrorCode::GeminiDeadlineExceeded,
1699        ] {
1700            roundtrip(code);
1701        }
1702
1703        for reason in [
1704            ImageOperationDenialReason::UnsupportedTarget,
1705            ImageOperationDenialReason::UnsupportedCount,
1706            ImageOperationDenialReason::CapabilityPolicy,
1707            ImageOperationDenialReason::CostPolicy,
1708            ImageOperationDenialReason::SafetyPolicy,
1709            ImageOperationDenialReason::ApprovalRequiredButUnavailable,
1710            ImageOperationDenialReason::ScopedOverrideConflict,
1711            ImageOperationDenialReason::RealtimeTransportConflict,
1712            ImageOperationDenialReason::ProjectionUnsupported,
1713        ] {
1714            roundtrip(reason);
1715        }
1716        roundtrip(ImageOperationDenialReason::DeniedDuringApproval {
1717            approvable: ImageOperationApprovalReason::CostExceedsThreshold,
1718        });
1719
1720        for terminal in [
1721            PostActivationImageTerminal::Generated,
1722            PostActivationImageTerminal::EmptyResult,
1723            PostActivationImageTerminal::RefusedByProvider,
1724            PostActivationImageTerminal::SafetyFiltered,
1725            PostActivationImageTerminal::Failed,
1726            PostActivationImageTerminal::Cancelled,
1727            PostActivationImageTerminal::Timeout,
1728        ] {
1729            roundtrip(terminal);
1730        }
1731        roundtrip(PostActivationImageTerminal::Denied {
1732            reason: PostActivationImageDenialReason::ProjectionUnsupported,
1733        });
1734        for reason in [
1735            PostActivationImageDenialReason::CostPolicy,
1736            PostActivationImageDenialReason::SafetyPolicy,
1737            PostActivationImageDenialReason::ScopedOverrideConflict,
1738            PostActivationImageDenialReason::RealtimeTransportConflict,
1739            PostActivationImageDenialReason::ProjectionUnsupported,
1740        ] {
1741            roundtrip(reason);
1742        }
1743        roundtrip(PostActivationImageDenialReason::DeniedDuringApproval {
1744            approvable: ImageOperationApprovalReason::SafetyHold,
1745        });
1746
1747        roundtrip(SwitchTurnDuration::Finite {
1748            duration: FiniteScopedTurnDuration::OneTurn,
1749        });
1750        roundtrip(SwitchTurnDuration::Finite {
1751            duration: FiniteScopedTurnDuration::Turns {
1752                turns: NonZeroU32::new(2).unwrap(),
1753            },
1754        });
1755        roundtrip(SwitchTurnDuration::UntilChanged);
1756
1757        roundtrip(SwitchTurnOrigin::User {
1758            reason: SwitchTurnReasonTextDisposition::NotProvided,
1759        });
1760        roundtrip(SwitchTurnOrigin::Model {
1761            reason: SwitchTurnReasonTextDisposition::Provided {
1762                reason: SwitchTurnReasonText::new("use a stronger model").unwrap(),
1763            },
1764        });
1765        roundtrip(SwitchTurnOrigin::SystemPolicy {
1766            reason: SwitchTurnPolicyReason::SafetyHandoff,
1767        });
1768        for reason in [
1769            SwitchTurnPolicyReason::BudgetDowngrade,
1770            SwitchTurnPolicyReason::SafetyHandoff,
1771            SwitchTurnPolicyReason::ReleaseTemporaryHold,
1772        ] {
1773            roundtrip(reason);
1774        }
1775
1776        roundtrip(SwitchTurnControlResult::Applied {
1777            request_id: SwitchTurnRequestId::new(uuid(80)),
1778            target_model: ModelId::new("test-openai-default"),
1779            duration: SwitchTurnDuration::Finite {
1780                duration: FiniteScopedTurnDuration::OneTurn,
1781            },
1782        });
1783        roundtrip(SwitchTurnControlResult::AwaitingApproval {
1784            approval_id: ApprovalId::new(),
1785            target_model: ModelId::new("test-anthropic-default"),
1786            duration: SwitchTurnDuration::UntilChanged,
1787            reason: SwitchTurnApprovalReason::UntilChangedFromModelOrigin,
1788        });
1789        roundtrip(SwitchTurnControlResult::Denied {
1790            request_id: SwitchTurnRequestId::new(uuid(83)),
1791            reason: SwitchTurnDenialReason::RealtimeTransportConflict,
1792        });
1793
1794        for reason in [
1795            SwitchTurnDenialReason::UnsupportedModel,
1796            SwitchTurnDenialReason::CapabilityPolicy,
1797            SwitchTurnDenialReason::CostPolicy,
1798            SwitchTurnDenialReason::SafetyPolicy,
1799            SwitchTurnDenialReason::ApprovalRequiredButUnavailable,
1800            SwitchTurnDenialReason::ScopedOverrideConflict,
1801            SwitchTurnDenialReason::RealtimeTransportConflict,
1802            SwitchTurnDenialReason::ProjectionUnsupported,
1803        ] {
1804            roundtrip(reason);
1805        }
1806        roundtrip(SwitchTurnDenialReason::DeniedDuringApproval {
1807            approvable: SwitchTurnApprovalReason::RealtimeDetachRequired,
1808        });
1809
1810        for reason in [
1811            SwitchTurnApprovalReason::CrossProvider,
1812            SwitchTurnApprovalReason::CostExceedsThreshold,
1813            SwitchTurnApprovalReason::SafetyHold,
1814            SwitchTurnApprovalReason::UntilChangedFromModelOrigin,
1815            SwitchTurnApprovalReason::RealtimeDetachRequired,
1816        ] {
1817            roundtrip(reason);
1818        }
1819
1820        for reason in [
1821            ImageOperationApprovalReason::CrossProvider,
1822            ImageOperationApprovalReason::CostExceedsThreshold,
1823            ImageOperationApprovalReason::SafetyHold,
1824            ImageOperationApprovalReason::RealtimeDetachRequired,
1825        ] {
1826            roundtrip(reason);
1827        }
1828
1829        for phase in [
1830            SwitchTurnPhase::Requested,
1831            SwitchTurnPhase::Validating,
1832            SwitchTurnPhase::PendingForBoundary,
1833            SwitchTurnPhase::ApplyingFiniteOverride,
1834            SwitchTurnPhase::ApplyingPersistentReconfigure,
1835            SwitchTurnPhase::ActiveFiniteOverride,
1836            SwitchTurnPhase::RestoringFiniteOverride,
1837        ] {
1838            roundtrip(phase);
1839        }
1840        roundtrip(SwitchTurnPhase::AwaitingApproval {
1841            approval_id: ApprovalId::new(),
1842        });
1843        roundtrip(SwitchTurnPhase::Terminal {
1844            terminal: SwitchTurnTerminalClass::ConsumedAndRestored,
1845        });
1846
1847        for terminal in [
1848            SwitchTurnTerminalClass::ConsumedAndRestored,
1849            SwitchTurnTerminalClass::InterruptedAndRestored,
1850            SwitchTurnTerminalClass::PersistentReconfigureApplied,
1851            SwitchTurnTerminalClass::PersistentReconfigureFailed,
1852        ] {
1853            roundtrip(terminal);
1854        }
1855        roundtrip(SwitchTurnTerminalClass::Denied {
1856            reason: SwitchTurnDenialReason::UnsupportedModel,
1857        });
1858        roundtrip(SwitchTurnTerminalClass::RestoreFailed {
1859            trigger: SwitchTurnRestoreTrigger::Consumed,
1860        });
1861        roundtrip(SwitchTurnTerminalClass::RestoreFailed {
1862            trigger: SwitchTurnRestoreTrigger::Interrupted,
1863        });
1864
1865        roundtrip(ModelRoutingApprovalPhase::Pending);
1866        roundtrip(ModelRoutingApprovalPhase::PresentedToUser);
1867        for terminal in [
1868            ModelRoutingApprovalTerminalClass::Approved,
1869            ModelRoutingApprovalTerminalClass::DeniedByUser,
1870            ModelRoutingApprovalTerminalClass::Expired,
1871            ModelRoutingApprovalTerminalClass::Interrupted,
1872            ModelRoutingApprovalTerminalClass::SessionArchived,
1873            ModelRoutingApprovalTerminalClass::SurfaceDetached,
1874        ] {
1875            roundtrip(ModelRoutingApprovalPhase::Terminal { terminal });
1876            roundtrip(terminal);
1877        }
1878        roundtrip(ModelRoutingApprovalRequest::SwitchTurn {
1879            request_id: SwitchTurnRequestId::new(uuid(84)),
1880            reason: SwitchTurnApprovalReason::CrossProvider,
1881        });
1882        roundtrip(ModelRoutingApprovalRequest::ImageOperation {
1883            operation_id: ImageOperationId::new(uuid(81)),
1884            reason: ImageOperationApprovalReason::RealtimeDetachRequired,
1885        });
1886        roundtrip(ScopedModelOverrideKind::ImageOperation {
1887            operation_id: ImageOperationId::new(uuid(82)),
1888        });
1889        roundtrip(ScopedModelOverrideKind::FiniteSwitchTurn {
1890            request_id: SwitchTurnRequestId::new(uuid(85)),
1891            duration: FiniteScopedTurnDuration::Turns {
1892                turns: NonZeroU32::new(3).unwrap(),
1893            },
1894        });
1895    }
1896
1897    #[test]
1898    fn assistant_block_image_roundtrip() {
1899        let block = crate::types::AssistantBlock::Image {
1900            image_id: AssistantImageId::new(uuid(40)),
1901            blob_ref: BlobRef {
1902                blob_id: BlobId::new("generated-image"),
1903                media_type: "image/png".to_string(),
1904            },
1905            media_type: MediaType::new("image/png"),
1906            width: 1024,
1907            height: 1024,
1908            revised_prompt: RevisedPromptDisposition::Revised {
1909                text: PromptText::new("a red bridge at sunset").unwrap(),
1910                source: RevisedPromptSource::Provider,
1911            },
1912            meta: ProviderImageMetadata::OpenAi(OpenAiImageMetadata {
1913                target_model: "provider-api-image-model".to_string(),
1914                response_id: Some("resp_123".to_string()),
1915                image_generation_call_id: Some("ig_123".to_string()),
1916            }),
1917        };
1918
1919        let json = serde_json::to_string(&block).unwrap();
1920        let parsed: crate::types::AssistantBlock = serde_json::from_str(&json).unwrap();
1921        assert_eq!(parsed, block);
1922    }
1923}