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