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