Skip to main content

va_ai_api_bridge/schema/
catalog.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::{ApiBridgeError, Extensions, GenerationConfig, ReasoningConfig, Result, WireProtocol};
5
6pub const PROVIDER_CATALOG_SCHEMA_VERSION: &str = "va.ai.api.bridge.catalog.v1";
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct ProviderCatalog {
11    #[serde(default = "default_catalog_schema_version")]
12    pub schema_version: String,
13    pub provider_id: String,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub display_name: Option<String>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub version: Option<String>,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub protocols: Vec<ProviderProtocol>,
20    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub models: Vec<ProviderModel>,
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub settings: Vec<ProviderSetting>,
24    #[serde(default, skip_serializing_if = "ProviderDefaults::is_empty")]
25    pub defaults: ProviderDefaults,
26    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
27    pub extensions: Extensions,
28}
29
30impl ProviderCatalog {
31    pub fn new(provider_id: impl Into<String>) -> Self {
32        Self {
33            schema_version: default_catalog_schema_version(),
34            provider_id: provider_id.into(),
35            display_name: None,
36            version: None,
37            protocols: Vec::new(),
38            models: Vec::new(),
39            settings: Vec::new(),
40            defaults: ProviderDefaults::default(),
41            extensions: Extensions::new(),
42        }
43    }
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct ProviderProtocol {
49    pub source_protocol: WireProtocol,
50    pub target_protocol: WireProtocol,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub upstream_path: Option<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub default_model: Option<String>,
55    #[serde(default, skip_serializing_if = "is_false")]
56    pub streaming: bool,
57    #[serde(default, skip_serializing_if = "is_false")]
58    pub tools: bool,
59    #[serde(default, skip_serializing_if = "ProviderDefaults::is_empty")]
60    pub defaults: ProviderDefaults,
61    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
62    pub extensions: Extensions,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct ProviderModel {
68    pub id: String,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub display_name: Option<String>,
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub aliases: Vec<String>,
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub protocols: Vec<WireProtocol>,
75    #[serde(default, skip_serializing_if = "ProviderDefaults::is_empty")]
76    pub defaults: ProviderDefaults,
77    #[serde(default, skip_serializing_if = "ModelCapabilities::is_empty")]
78    pub capabilities: ModelCapabilities,
79    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
80    pub extensions: Extensions,
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct ResolvedModelSpec {
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub provider_label: Option<String>,
88    #[serde(default, skip_serializing_if = "String::is_empty")]
89    pub model: String,
90    #[serde(default, skip_serializing_if = "ModelCapabilities::is_empty")]
91    pub capabilities: ModelCapabilities,
92    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
93    pub extensions: Extensions,
94}
95
96impl ResolvedModelSpec {
97    pub fn from_json(value: Value) -> Result<Self> {
98        serde_json::from_value(value).map_err(|error| {
99            ApiBridgeError::invalid_request(format!("invalid resolved model spec: {error}"))
100        })
101    }
102
103    pub fn provider_label(&self) -> &str {
104        self.provider_label
105            .as_deref()
106            .map(str::trim)
107            .filter(|value| !value.is_empty())
108            .unwrap_or("Target provider")
109    }
110
111    pub fn model_label(&self) -> &str {
112        self.model
113            .trim()
114            .is_empty()
115            .then_some("selected model")
116            .unwrap_or_else(|| self.model.trim())
117    }
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct ProviderDefaults {
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub model: Option<String>,
125    #[serde(default, skip_serializing_if = "GenerationConfig::is_empty")]
126    pub generation: GenerationConfig,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub reasoning: Option<ReasoningConfig>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub raw_request: Option<Value>,
131    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
132    pub extensions: Extensions,
133}
134
135impl ProviderDefaults {
136    pub fn is_empty(&self) -> bool {
137        self.model.is_none()
138            && self.generation.is_empty()
139            && self.reasoning.is_none()
140            && self.raw_request.is_none()
141            && self.extensions.is_empty()
142    }
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct ModelCapabilities {
148    #[serde(default, skip_serializing_if = "is_false")]
149    pub streaming: bool,
150    #[serde(default, skip_serializing_if = "is_false")]
151    pub tools: bool,
152    #[serde(default, skip_serializing_if = "is_false")]
153    pub vision: bool,
154    #[serde(default, skip_serializing_if = "is_false")]
155    pub files: bool,
156    #[serde(default, skip_serializing_if = "is_false")]
157    pub reasoning: bool,
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub input_modalities: Vec<String>,
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub output_modalities: Vec<String>,
162    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
163    pub extensions: Extensions,
164}
165
166impl ModelCapabilities {
167    pub fn is_empty(&self) -> bool {
168        !self.streaming
169            && !self.tools
170            && !self.vision
171            && !self.files
172            && !self.reasoning
173            && self.input_modalities.is_empty()
174            && self.output_modalities.is_empty()
175            && self.extensions.is_empty()
176    }
177
178    pub fn supports_image_input(&self) -> bool {
179        self.vision || self.has_input_modality(&["image", "images", "vision"])
180    }
181
182    pub fn supports_file_input(&self) -> bool {
183        self.files || self.has_input_modality(&["file", "files", "document", "documents"])
184    }
185
186    pub fn has_input_modality(&self, aliases: &[&str]) -> bool {
187        self.input_modalities
188            .iter()
189            .any(|modality| modality_matches(modality, aliases))
190    }
191
192    pub fn union(&self, other: &Self) -> Self {
193        let mut merged = Self {
194            streaming: self.streaming || other.streaming,
195            tools: self.tools || other.tools,
196            vision: self.vision || other.vision,
197            files: self.files || other.files,
198            reasoning: self.reasoning || other.reasoning,
199            input_modalities: union_strings(&self.input_modalities, &other.input_modalities),
200            output_modalities: union_strings(&self.output_modalities, &other.output_modalities),
201            extensions: self.extensions.clone(),
202        };
203        merged.extensions.extend(other.extensions.clone());
204        merged
205    }
206}
207
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct ProviderSetting {
211    pub key: String,
212    pub kind: SettingKind,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub label: Option<String>,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub description: Option<String>,
217    #[serde(default, skip_serializing_if = "is_false")]
218    pub required: bool,
219    #[serde(default, skip_serializing_if = "is_false")]
220    pub secret: bool,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub default: Option<Value>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub json_schema: Option<Value>,
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub options: Vec<SettingOption>,
227    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
228    pub extensions: Extensions,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum SettingKind {
234    String,
235    Number,
236    Integer,
237    Boolean,
238    Secret,
239    Select,
240    Object,
241    Array,
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct SettingOption {
247    pub value: Value,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub label: Option<String>,
250    #[serde(default, skip_serializing_if = "Extensions::is_empty")]
251    pub extensions: Extensions,
252}
253
254fn default_catalog_schema_version() -> String {
255    PROVIDER_CATALOG_SCHEMA_VERSION.to_string()
256}
257
258fn is_false(value: &bool) -> bool {
259    !*value
260}
261
262fn modality_matches(modality: &str, aliases: &[&str]) -> bool {
263    let modality = normalized_modality(modality);
264    aliases
265        .iter()
266        .any(|alias| modality == normalized_modality(alias))
267}
268
269fn normalized_modality(value: &str) -> String {
270    value.trim().to_ascii_lowercase().replace(['_', ' '], "-")
271}
272
273fn union_strings(left: &[String], right: &[String]) -> Vec<String> {
274    let mut out = left.to_vec();
275    for value in right {
276        if !out.iter().any(|existing| existing == value) {
277            out.push(value.clone());
278        }
279    }
280    out
281}
282
283#[cfg(test)]
284mod tests {
285    use serde_json::json;
286
287    use super::ModelCapabilities;
288
289    #[test]
290    fn image_support_can_come_from_boolean_or_input_modality() {
291        let boolean = ModelCapabilities {
292            vision: true,
293            ..ModelCapabilities::default()
294        };
295        let modality = ModelCapabilities {
296            input_modalities: vec!["text".to_string(), "image".to_string()],
297            ..ModelCapabilities::default()
298        };
299
300        assert!(boolean.supports_image_input());
301        assert!(modality.supports_image_input());
302        assert!(!ModelCapabilities::default().supports_image_input());
303    }
304
305    #[test]
306    fn file_support_can_come_from_boolean_or_input_modality() {
307        let boolean = ModelCapabilities {
308            files: true,
309            ..ModelCapabilities::default()
310        };
311        let modality = ModelCapabilities {
312            input_modalities: vec!["text".to_string(), "document".to_string()],
313            ..ModelCapabilities::default()
314        };
315
316        assert!(boolean.supports_file_input());
317        assert!(modality.supports_file_input());
318        assert!(!ModelCapabilities::default().supports_file_input());
319    }
320
321    #[test]
322    fn union_merges_capability_flags_modalities_and_extensions() {
323        let base = ModelCapabilities {
324            streaming: true,
325            input_modalities: vec!["text".to_string()],
326            extensions: [("base".to_string(), json!(true))].into_iter().collect(),
327            ..ModelCapabilities::default()
328        };
329        let model = ModelCapabilities {
330            tools: true,
331            vision: true,
332            input_modalities: vec!["text".to_string(), "image".to_string()],
333            extensions: [("model".to_string(), json!(true))].into_iter().collect(),
334            ..ModelCapabilities::default()
335        };
336
337        let merged = base.union(&model);
338
339        assert!(merged.streaming);
340        assert!(merged.tools);
341        assert!(merged.supports_image_input());
342        assert_eq!(merged.input_modalities, vec!["text", "image"]);
343        assert_eq!(merged.extensions["base"], json!(true));
344        assert_eq!(merged.extensions["model"], json!(true));
345    }
346
347    #[test]
348    fn resolved_model_spec_deserializes_from_json() {
349        let spec = super::ResolvedModelSpec::from_json(json!({
350            "providerLabel": "DeepSeek",
351            "model": "deepseek-v4-pro",
352            "capabilities": {
353                "inputModalities": ["text"]
354            }
355        }))
356        .expect("model spec deserializes");
357
358        assert_eq!(spec.provider_label(), "DeepSeek");
359        assert_eq!(spec.model_label(), "deepseek-v4-pro");
360        assert!(!spec.capabilities.supports_image_input());
361    }
362}