1use 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}