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}