use std::collections::BTreeSet;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{
Background, BridgeError, ErrorCode, ImageAction, ImageSize, InputFidelity, Moderation,
OutputFormat, Quality,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SupportLevel {
Native,
Emulated,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct U8Range {
pub min: u8,
pub max: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BatchMode {
Native,
FanOut,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BatchCapabilities {
pub mode: BatchMode,
pub native_count: U8Range,
pub max_parallel_outputs: u8,
}
impl U8Range {
#[must_use]
pub const fn contains(self, value: u8) -> bool {
value >= self.min && value <= self.max
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SizeCapabilities {
pub auto: bool,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub allowed: BTreeSet<ImageSize>,
pub arbitrary: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_edge: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_edge: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edge_multiple: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_pixels: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_pixels: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_aspect_ratio: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct InputCapabilities {
pub support: SupportLevel,
pub max_count: u16,
pub max_bytes_each: u64,
pub max_bytes_total: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProviderCapabilities {
pub provider: String,
pub implementation_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub experimental: bool,
pub generation: bool,
pub edits: bool,
pub count: U8Range,
pub batching: BatchCapabilities,
pub sizes: SizeCapabilities,
pub aspect_ratio: SupportLevel,
pub resolution: SupportLevel,
pub qualities: BTreeSet<Quality>,
pub output_formats: BTreeSet<OutputFormat>,
pub backgrounds: BTreeSet<Background>,
pub transparent_background: SupportLevel,
pub moderation: BTreeSet<Moderation>,
pub negative_prompt: SupportLevel,
pub revised_prompt: SupportLevel,
pub user_attribution: SupportLevel,
pub input_fidelities: BTreeSet<InputFidelity>,
pub actions: BTreeSet<ImageAction>,
pub reference_images: InputCapabilities,
pub edit_images: InputCapabilities,
pub masks: InputCapabilities,
pub partial_images: U8Range,
pub persistent_sessions: bool,
pub explicit_threads: bool,
}
impl ProviderCapabilities {
pub fn validate(&self) -> Result<(), BridgeError> {
if self.count.min == 0 || self.count.min > self.count.max {
return Err(invalid_capability(
self,
"provider output-count capability range is invalid",
));
}
if self.batching.native_count.min == 0
|| self.batching.native_count.min > self.batching.native_count.max
|| self.batching.native_count.min < self.count.min
|| self.batching.native_count.max > self.count.max
|| self.batching.max_parallel_outputs == 0
|| self.batching.max_parallel_outputs > self.count.max
|| (self.batching.mode == BatchMode::Native && self.batching.native_count != self.count)
|| (self.batching.mode == BatchMode::FanOut
&& self.batching.native_count.max >= self.count.max)
{
return Err(invalid_capability(
self,
"provider batching capability is inconsistent with its output-count range",
));
}
if self.partial_images.min > self.partial_images.max {
return Err(invalid_capability(
self,
"provider partial-image capability range is invalid",
));
}
let declares_native = self.backgrounds.contains(&Background::Transparent);
if declares_native != (self.transparent_background == SupportLevel::Native) {
return Err(invalid_capability(
self,
"provider transparent-background capability is inconsistent",
));
}
if self.transparent_background == SupportLevel::Emulated
&& (!self
.backgrounds
.iter()
.any(|background| matches!(background, Background::Auto | Background::Opaque))
|| !self
.output_formats
.iter()
.any(|format| matches!(format, OutputFormat::Png | OutputFormat::Webp)))
{
return Err(invalid_capability(
self,
"emulated transparent output requires a keyable background and alpha format",
));
}
Ok(())
}
}
fn invalid_capability(capabilities: &ProviderCapabilities, message: &str) -> BridgeError {
BridgeError::new(ErrorCode::Protocol, message).with_provider(&capabilities.provider)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProviderDescriptor {
pub name: String,
pub display_name: String,
pub version: String,
pub experimental: bool,
#[serde(default)]
pub models: Vec<String>,
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::ProviderDescriptor;
#[test]
fn provider_model_inventory_is_additive_for_older_payloads() {
let descriptor: ProviderDescriptor = serde_json::from_str(
r#"{"name":"test","display_name":"Test","version":"1","experimental":false}"#,
)
.unwrap();
assert!(descriptor.models.is_empty());
let encoded = serde_json::to_value(ProviderDescriptor {
models: vec!["gpt-image-2".to_owned()],
..descriptor
})
.unwrap();
assert_eq!(encoded["models"][0], "gpt-image-2");
}
}