use std::path::PathBuf;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, de};
use crate::{
ArtifactCollisionPolicy, ArtifactMetadataPolicy, AspectRatio, Background, BatchExecution,
CompatibilityMode, FallbackPolicy, ImageAction, ImageSize, InputFidelity, Moderation,
MultiImageFailurePolicy, NegativePromptMode, OutputFormat, Quality, Resolution, ResponseFormat,
RevisedPromptPolicy, SessionMode, TransparencyMode,
};
const COMMON_REQUEST_FIELDS: &[&str] = &[
"version",
"prompt",
"negative_prompt",
"parameters",
"routing",
"session",
"output",
"policies",
"idempotency_key",
"timeout_ms",
"user",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct ImageRequest {
#[serde(default = "default_contract_version")]
pub version: String,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub negative_prompt: Option<String>,
#[serde(flatten)]
pub operation: ImageOperation,
#[serde(default)]
pub parameters: GenerationParameters,
#[serde(default)]
pub routing: RoutingOptions,
#[serde(default)]
pub session: SessionOptions,
#[serde(default)]
pub output: OutputOptions,
#[serde(default)]
pub policies: RequestPolicies,
#[serde(skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
impl<'de> Deserialize<'de> for ImageRequest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let mut object = serde_json::Map::<String, serde_json::Value>::deserialize(deserializer)?;
for field in object.keys() {
if field != "operation"
&& field != "reference_images"
&& field != "images"
&& field != "mask"
&& !COMMON_REQUEST_FIELDS.contains(&field.as_str())
{
return Err(de::Error::unknown_field(
field,
&[
"version",
"prompt",
"negative_prompt",
"operation",
"reference_images",
"images",
"mask",
"parameters",
"routing",
"session",
"output",
"policies",
"idempotency_key",
"timeout_ms",
"user",
],
));
}
}
let operation_tag = object
.remove("operation")
.ok_or_else(|| de::Error::missing_field("operation"))?;
let operation_name = operation_tag
.as_str()
.ok_or_else(|| de::Error::custom("operation must be a string"))?;
let operation_fields: &[&str] = match operation_name {
"generate" => &["reference_images"],
"edit" => &["images", "mask", "reference_images"],
_ => {
return Err(de::Error::unknown_variant(
operation_name,
&["generate", "edit"],
));
}
};
for forbidden in ["images", "mask", "reference_images"] {
if object.contains_key(forbidden) && !operation_fields.contains(&forbidden) {
return Err(de::Error::custom(format!(
"field '{forbidden}' is invalid for operation '{operation_name}'"
)));
}
}
let mut operation = serde_json::Map::new();
operation.insert("operation".to_owned(), operation_tag);
for field in operation_fields {
if let Some(value) = object.remove(*field) {
operation.insert((*field).to_owned(), value);
}
}
let operation = serde_json::from_value(serde_json::Value::Object(operation))
.map_err(de::Error::custom)?;
let fields: RequestFields =
serde_json::from_value(serde_json::Value::Object(object)).map_err(de::Error::custom)?;
Ok(Self {
version: fields.version,
prompt: fields.prompt,
negative_prompt: fields.negative_prompt,
operation,
parameters: fields.parameters,
routing: fields.routing,
session: fields.session,
output: fields.output,
policies: fields.policies,
idempotency_key: fields.idempotency_key,
timeout_ms: fields.timeout_ms,
user: fields.user,
})
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RequestFields {
#[serde(default = "default_contract_version")]
version: String,
prompt: String,
negative_prompt: Option<String>,
#[serde(default)]
parameters: GenerationParameters,
#[serde(default)]
routing: RoutingOptions,
#[serde(default)]
session: SessionOptions,
#[serde(default)]
output: OutputOptions,
#[serde(default)]
policies: RequestPolicies,
idempotency_key: Option<String>,
timeout_ms: Option<u64>,
user: Option<String>,
}
impl ImageRequest {
#[must_use]
pub fn generate(prompt: impl Into<String>) -> Self {
Self {
version: default_contract_version(),
prompt: prompt.into(),
negative_prompt: None,
operation: ImageOperation::Generate {
reference_images: Vec::new(),
},
parameters: GenerationParameters::default(),
routing: RoutingOptions::default(),
session: SessionOptions::default(),
output: OutputOptions::default(),
policies: RequestPolicies::default(),
idempotency_key: None,
timeout_ms: None,
user: None,
}
}
}
fn default_contract_version() -> String {
crate::CONTRACT_VERSION.to_owned()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
pub enum ImageOperation {
Generate {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
reference_images: Vec<ImageInput>,
},
Edit {
images: Vec<ImageInput>,
#[serde(skip_serializing_if = "Option::is_none")]
mask: Option<Box<ImageInput>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
reference_images: Vec<ImageInput>,
},
}
impl ImageOperation {
#[must_use]
pub fn reference_images(&self) -> &[ImageInput] {
match self {
Self::Generate { reference_images }
| Self::Edit {
reference_images, ..
} => reference_images,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum ImageSource {
File {
path: PathBuf,
},
Url {
url: String,
},
DataUrl {
data_url: String,
},
Base64 {
data: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ImageInput {
#[serde(flatten)]
pub source: ImageSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
impl<'de> Deserialize<'de> for ImageInput {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let mut fields = serde_json::Map::<String, serde_json::Value>::deserialize(deserializer)?;
let media_type = fields
.remove("media_type")
.map(serde_json::from_value::<Option<String>>)
.transpose()
.map_err(de::Error::custom)?
.flatten();
let filename = fields
.remove("filename")
.map(serde_json::from_value::<Option<String>>)
.transpose()
.map_err(de::Error::custom)?
.flatten();
let source =
serde_json::from_value(serde_json::Value::Object(fields)).map_err(de::Error::custom)?;
Ok(Self {
source,
media_type,
filename,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct GenerationParameters {
pub n: u8,
pub size: ImageSize,
#[serde(skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<AspectRatio>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<Resolution>,
pub quality: Quality,
pub output_format: OutputFormat,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_compression: Option<u8>,
pub background: Background,
pub moderation: Moderation,
pub partial_images: u8,
pub failure_policy: MultiImageFailurePolicy,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_fidelity: Option<InputFidelity>,
pub action: ImageAction,
}
impl Default for GenerationParameters {
fn default() -> Self {
Self {
n: 1,
size: ImageSize::default(),
aspect_ratio: None,
resolution: None,
quality: Quality::default(),
output_format: OutputFormat::default(),
output_compression: None,
background: Background::default(),
moderation: Moderation::default(),
partial_images: 0,
failure_policy: MultiImageFailurePolicy::default(),
input_fidelity: None,
action: ImageAction::default(),
}
}
}
#[cfg(test)]
mod serde_tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn image_request_round_trips_with_flattened_operation() {
let request = ImageRequest::generate("test");
let encoded = serde_json::to_value(&request).unwrap();
assert_eq!(encoded["operation"], "generate");
let decoded: ImageRequest = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, request);
}
#[test]
fn image_request_rejects_unknown_and_operation_specific_fields() {
let unknown = serde_json::json!({
"prompt": "test",
"operation": "generate",
"surprise": true
});
assert!(serde_json::from_value::<ImageRequest>(unknown).is_err());
let inconsistent = serde_json::json!({
"prompt": "test",
"operation": "generate",
"images": []
});
assert!(serde_json::from_value::<ImageRequest>(inconsistent).is_err());
}
#[test]
fn every_image_input_source_round_trips_and_rejects_unknown_fields() {
let inputs = [
ImageInput {
source: ImageSource::File {
path: PathBuf::from("fixture.png"),
},
media_type: Some("image/png".to_owned()),
filename: Some("fixture.png".to_owned()),
},
ImageInput {
source: ImageSource::Url {
url: "https://example.test/fixture.png".to_owned(),
},
media_type: None,
filename: None,
},
ImageInput {
source: ImageSource::DataUrl {
data_url: "data:image/png;base64,aW1hZ2U=".to_owned(),
},
media_type: None,
filename: None,
},
ImageInput {
source: ImageSource::Base64 {
data: "aW1hZ2U=".to_owned(),
},
media_type: Some("image/png".to_owned()),
filename: None,
},
];
for input in inputs {
let encoded = serde_json::to_value(&input).unwrap();
assert_eq!(
serde_json::from_value::<ImageInput>(encoded).unwrap(),
input
);
}
let unknown = serde_json::json!({
"type": "url",
"url": "https://example.test/fixture.png",
"unexpected": true
});
assert!(serde_json::from_value::<ImageInput>(unknown).is_err());
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct RoutingOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallbacks: Vec<ProviderRoute>,
pub fallback_policy: FallbackPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProviderRoute {
pub provider: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct SessionOptions {
pub mode: SessionMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct OutputOptions {
pub response_format: ResponseFormat,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
pub collision: ArtifactCollisionPolicy,
pub metadata: ArtifactMetadataPolicy,
pub transparency: TransparencyOptions,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct TransparencyOptions {
pub mode: TransparencyMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_color: Option<String>,
pub transparent_threshold: u8,
pub opaque_threshold: u8,
pub despill: bool,
}
impl Default for TransparencyOptions {
fn default() -> Self {
Self {
mode: TransparencyMode::Auto,
key_color: None,
transparent_threshold: 12,
opaque_threshold: 96,
despill: true,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct RequestPolicies {
pub compatibility: CompatibilityMode,
pub negative_prompt: NegativePromptMode,
pub revised_prompt: RevisedPromptPolicy,
pub batch_execution: BatchExecution,
}