Skip to main content

oxicode_sdk/
bridge.rs

1//! Bridge layer: convert catalog port entries into `oxicode_ai` types.
2//!
3//! This module sits in `oxicode-sdk` (not `oxicode-ai`) to avoid a reverse
4//! dependency: `oxicode-ai` must not depend on `oxicode-sdk`. The catalog port
5//! owns the data; the bridge owns the conversion into the types that
6//! `Provider` implementations consume.
7//!
8//! See `docs/designs/2026-06-17-catalog-port-design.md` §7.5.
9
10use crate::ports::catalog::{CatalogModelEntry, ModelCatalog};
11use oxicode_ai::{Cost, InputModality, Model};
12
13/// Convert a `CatalogModelEntry` into an `oxicode_ai::Model`.
14///
15/// The `provider` argument is passed explicitly (rather than read from the
16/// entry) because callers sometimes resolve with a different provider name
17/// than the one stored in the catalog (e.g. aliases).
18///
19/// Base URL resolution: if the model entry has a `base_url` override it is
20/// used; otherwise the caller should resolve the provider's base URL
21/// separately. Here we default to an empty string when neither is available
22/// — callers that need a provider base URL should use
23/// [`provider_base_url`].
24pub fn catalog_entry_to_model(provider: &str, entry: &CatalogModelEntry) -> Model {
25    Model {
26        id: entry.model_id.clone(),
27        name: entry.name.clone(),
28        api: entry.protocol.as_oxicode_api(),
29        provider: provider.to_string(),
30        base_url: entry.base_url.clone().unwrap_or_default(),
31        reasoning: entry.reasoning,
32        input: modalities_from_catalog(&entry.input_modalities, entry.supports_vision),
33        cost: Cost {
34            input: entry.cost_input,
35            output: entry.cost_output,
36            cache_read: entry.cost_cache_read,
37            cache_write: entry.cost_cache_write,
38        },
39        context_window: entry.context_window as usize,
40        max_tokens: entry.max_tokens as usize,
41        headers: HashMap::new(),
42        compat: None,
43    }
44}
45
46/// Resolve a provider's base URL from the catalog port (sync read).
47///
48/// Returns `None` if the provider is unknown or has no base URL (e.g.
49/// providers that use environment-configured endpoints like Anthropic/OpenAI).
50pub fn provider_base_url(catalog: &dyn ModelCatalog, provider: &str) -> Option<String> {
51    catalog.get_provider_sync(provider).and_then(|p| p.base_url)
52}
53
54/// Convert catalog modalities (string list) into `InputModality`.
55///
56/// Falls back to `[Text]` if the list is empty. Adds `Image` if
57/// `supports_vision` is true and it isn't already present.
58fn modalities_from_catalog(modalities: &[String], supports_vision: bool) -> Vec<InputModality> {
59    let mut out: Vec<InputModality> = if modalities.is_empty() {
60        vec![InputModality::Text]
61    } else {
62        modalities
63            .iter()
64            .filter_map(|m| match m.to_lowercase().as_str() {
65                "text" => Some(InputModality::Text),
66                "image" | "images" | "video" | "audio" | "pdf" | "file" | "files" => {
67                    // Currently only Text/Image are supported; treat
68                    // multimedia as Image where vision is available.
69                    Some(InputModality::Image)
70                }
71                _ => None,
72            })
73            .collect()
74    };
75
76    // Ensure at least Text
77    if !out.iter().any(|m| matches!(m, InputModality::Text)) {
78        out.insert(0, InputModality::Text);
79    }
80
81    // Vision support
82    if supports_vision && !out.iter().any(|m| matches!(m, InputModality::Image)) {
83        out.push(InputModality::Image);
84    }
85
86    out
87}
88
89use std::collections::HashMap;
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::ports::catalog::{CatalogProtocol, CatalogSource};
95    use oxicode_ai::Api;
96
97    fn sample_entry() -> CatalogModelEntry {
98        CatalogModelEntry {
99            provider: "openai".to_string(),
100            model_id: "gpt-4o".to_string(),
101            name: "GPT-4o".to_string(),
102            protocol: CatalogProtocol::OpenAiCompletions,
103            source: CatalogSource::Embedded,
104            base_url: None,
105            reasoning: false,
106            supports_vision: true,
107            cost_input: 2.5,
108            cost_output: 10.0,
109            cost_cache_read: 1.25,
110            cost_cache_write: 0.0,
111            context_window: 128_000,
112            max_tokens: 16_384,
113            input_modalities: vec!["text".to_string(), "image".to_string()],
114            release_date: None,
115            status: None,
116        }
117    }
118
119    #[test]
120    fn converts_basic_fields() {
121        let entry = sample_entry();
122        let model = catalog_entry_to_model("openai", &entry);
123        assert_eq!(model.id, "gpt-4o");
124        assert_eq!(model.name, "GPT-4o");
125        assert_eq!(model.api, Api::OpenAiCompletions);
126        assert_eq!(model.provider, "openai");
127        assert_eq!(model.context_window, 128_000);
128        assert_eq!(model.max_tokens, 16_384);
129        assert!(!model.reasoning);
130    }
131
132    #[test]
133    fn converts_pricing() {
134        let entry = sample_entry();
135        let model = catalog_entry_to_model("openai", &entry);
136        assert!((model.cost.input - 2.5).abs() < f64::EPSILON);
137        assert!((model.cost.output - 10.0).abs() < f64::EPSILON);
138        assert!((model.cost.cache_read - 1.25).abs() < f64::EPSILON);
139    }
140
141    #[test]
142    fn converts_modalities_with_vision() {
143        let entry = sample_entry();
144        let model = catalog_entry_to_model("openai", &entry);
145        assert!(model.input.contains(&InputModality::Text));
146        assert!(model.input.contains(&InputModality::Image));
147    }
148
149    #[test]
150    fn adds_vision_from_flag() {
151        let mut entry = sample_entry();
152        entry.input_modalities = vec!["text".to_string()];
153        entry.supports_vision = true;
154        let model = catalog_entry_to_model("openai", &entry);
155        assert!(model.input.contains(&InputModality::Image));
156    }
157
158    #[test]
159    fn empty_modalities_defaults_to_text() {
160        let mut entry = sample_entry();
161        entry.input_modalities = vec![];
162        entry.supports_vision = false;
163        let model = catalog_entry_to_model("openai", &entry);
164        assert_eq!(model.input, vec![InputModality::Text]);
165    }
166
167    #[test]
168    fn base_url_override_used() {
169        let mut entry = sample_entry();
170        entry.base_url = Some("https://custom.api/v1".to_string());
171        let model = catalog_entry_to_model("openai", &entry);
172        assert_eq!(model.base_url, "https://custom.api/v1");
173    }
174
175    #[test]
176    fn protocol_maps_correctly() {
177        let cases = [
178            (CatalogProtocol::OpenAiCompletions, Api::OpenAiCompletions),
179            (CatalogProtocol::OpenAiResponses, Api::OpenAiResponses),
180            (CatalogProtocol::AnthropicMessages, Api::AnthropicMessages),
181            (CatalogProtocol::GoogleGenerativeAi, Api::GoogleGenerativeAi),
182        ];
183        for (proto, expected_api) in cases {
184            let mut entry = sample_entry();
185            entry.protocol = proto;
186            let model = catalog_entry_to_model("test", &entry);
187            assert_eq!(model.api, expected_api);
188        }
189    }
190}