Skip to main content

oxicode_catalog/catalog/
materialize.rs

1//! models.dev → oxicode catalog materialization.
2//!
3//! Converts a [`MdCatalog`] (from models.dev `api.json`) into
4//! [`BuiltinProviderEntry`] and [`BuiltinModelEntry`] vectors.
5//!
6//! This is the shared entry point for both **SNAP** (compile-time embedded
7//! snapshot) and **LIVE** (runtime cache) sources.
8//!
9//! # Protocol resolution
10//!
11//! The [`protocol_for`] function maps a models.dev `npm` string to oxicode's
12//! [`crate::Api`] enum + [`crate::catalog::provider::AuthMethod`]. This is the **only** protocol knowledge
13//! oxicode has — 7 match arms covering all known API protocols. Unknown npm
14//! values default to OpenAI-compatible (`OpenAiCompletions` + `Bearer`).
15//!
16//! # Model-level overrides
17//!
18//! Per-model [`crate::catalog::models_dev::MdModelProvider`] can override three things:
19//! - `npm` → protocol + auth (via `protocol_for`)
20//! - `api` → base URL (55+ models as of 2026-06-17)
21//!
22//! # Attribution
23//!
24//! Model data © [models.dev](https://models.dev) (MIT).
25
26use crate::catalog::BuiltinModelEntry;
27use crate::catalog::models_dev::{MdCatalog, protocol_for};
28use crate::catalog::override_::{OverrideFile, apply_model_overrides, apply_provider_overrides};
29use crate::catalog::provider::BuiltinProviderEntry;
30use std::collections::BTreeMap;
31
32/// Convert a [`MdCatalog`] into oxicode's internal provider + model entries.
33///
34/// Applies product-meta extra headers and user overrides (Layer 2) after
35/// the base conversion.
36///
37/// Returns `(providers, models)` sorted by provider id.
38pub fn materialize(
39    catalog: &MdCatalog,
40    product_meta: &ProductMeta,
41    user_overrides: &OverrideFile,
42) -> (
43    Vec<BuiltinProviderEntry>,
44    BTreeMap<String, Vec<BuiltinModelEntry>>,
45) {
46    let mut providers = Vec::new();
47    let mut models: BTreeMap<String, Vec<BuiltinModelEntry>> = BTreeMap::new();
48
49    for (pid, mdprov) in &catalog.0 {
50        let (api, auth) = protocol_for(mdprov.npm.as_deref().unwrap_or(""));
51        let extra = product_meta
52            .extra_headers
53            .get(pid)
54            .cloned()
55            .unwrap_or_default();
56
57        providers.push(BuiltinProviderEntry {
58            id: pid.clone(),
59            display_name: mdprov.name.clone(),
60            aliases: vec![], // models.dev id만 사용, 호환성 버림
61            api: api.to_string(),
62            env_key: mdprov.env.first().cloned().unwrap_or_default(),
63            extra_env_keys: mdprov.env[1..].to_vec(),
64            base_url: mdprov.api.clone().unwrap_or_default(),
65            auth_method: auth,
66            extra_headers: extra,
67            category: String::new(),    // 제거됨 (Phase 2에서 최종 제거)
68            description: String::new(), // 제품 메타 불필요
69            default_enabled: true,
70        });
71
72        for (mid, mdmodel) in &mdprov.models {
73            // Model-level override (v3): protocol + auth + base_url
74            let model_prov = mdmodel.provider.as_ref();
75            let model_npm = model_prov
76                .and_then(|p| p.npm.as_deref())
77                .unwrap_or_else(|| mdprov.npm.as_deref().unwrap_or(""));
78            let (model_api, model_auth) = protocol_for(model_npm);
79            let model_base_url = model_prov.and_then(|p| p.api.clone());
80
81            models
82                .entry(pid.clone())
83                .or_default()
84                .push(BuiltinModelEntry {
85                    id: mid.clone(),
86                    name: mdmodel.name.clone(),
87                    api: model_api.to_string(),
88                    provider: pid.clone(),
89                    reasoning: mdmodel.reasoning,
90                    auth_method: model_auth,
91                    base_url: model_base_url,
92                    input: normalize_modalities(&mdmodel.modalities),
93                    cost_input: mdmodel.cost.as_ref().map(|c| c.input).unwrap_or(0.0),
94                    cost_output: mdmodel.cost.as_ref().map(|c| c.output).unwrap_or(0.0),
95                    cost_cache_read: mdmodel
96                        .cost
97                        .as_ref()
98                        .and_then(|c| c.cache_read)
99                        .unwrap_or(0.0),
100                    cost_cache_write: mdmodel
101                        .cost
102                        .as_ref()
103                        .and_then(|c| c.cache_write)
104                        .unwrap_or(0.0),
105                    context_window: mdmodel.limit.context as u32,
106                    max_tokens: mdmodel.limit.output as u32,
107                });
108        }
109    }
110
111    // Apply Layer 2 user overrides (highest precedence)
112    apply_provider_overrides(&mut providers, &user_overrides.provider);
113    apply_model_overrides(&mut models, &user_overrides.model);
114
115    (providers, models)
116}
117
118/// Raw gzip bytes of the embedded models.dev snapshot.
119///
120/// Single source of truth for the compiled-in catalog snapshot. Sibling
121/// crates (notably `oxicode-sdk`) reference these bytes through this accessor
122/// instead of their own `include_bytes!`, so the snapshot file is packaged
123/// exactly once — here, inside `oxicode-ai` — and never escapes the crate root
124/// in a published artifact. (A cross-crate `include_bytes!` in `oxicode-sdk`
125/// pointed at `oxicode-ai/data/...` and made the published `oxicode-sdk` crate
126/// uncompilable for downstream consumers — see 0.37.1.)
127///
128/// The path is relative to this source file and stays within `oxicode-ai`'s
129/// own package, so it resolves correctly both in-tree and when `oxicode-ai` is
130/// downloaded from crates.io.
131pub fn snapshot_gzip_bytes() -> &'static [u8] {
132    include_bytes!("../../data/catalog/_snapshot.json.gz")
133}
134
135/// Load the embedded SNAP snapshot, decompress it, and parse as `MdCatalog`.
136///
137/// This is the single source for both the provider registry and the model
138/// database. No network access — the snapshot is embedded at compile time.
139pub fn load_snapshot_catalog() -> Option<MdCatalog> {
140    use std::io::Read;
141    let compressed: &[u8] = snapshot_gzip_bytes();
142    let mut decoder = flate2::read::GzDecoder::new(compressed);
143    let mut json = String::new();
144    decoder.read_to_string(&mut json).ok()?;
145    serde_json::from_str::<MdCatalog>(&json).ok()
146}
147
148/// Materialize providers from the embedded snapshot.
149///
150/// Returns the full provider list (145+ providers from models.dev) after
151/// applying product-meta overrides and Layer 2 user overrides.
152pub fn materialize_providers() -> Vec<BuiltinProviderEntry> {
153    let Some(catalog) = load_snapshot_catalog() else {
154        return Vec::new();
155    };
156    let product_meta = ProductMeta::builtin();
157    let overrides = crate::catalog::load_overrides().unwrap_or_default();
158    let (providers, _models) = materialize(&catalog, &product_meta, &overrides);
159    providers
160}
161
162/// Product-specific metadata: extra HTTP headers for a subset of providers.
163///
164/// Models.dev does not know oxicode-specific HTTP headers (e.g. `HTTP-Referer`
165/// for OpenRouter). These are maintained in `data/catalog/product-meta.toml`.
166#[derive(Default)]
167pub struct ProductMeta {
168    /// Extra HTTP headers keyed by models.dev provider id.
169    pub extra_headers: std::collections::HashMap<String, Vec<(String, String)>>,
170}
171
172impl ProductMeta {
173    /// Parse from the built-in product-meta.toml.
174    pub fn builtin() -> Self {
175        include_str!("../../data/catalog/product-meta.toml")
176            .parse()
177            .unwrap_or_default()
178    }
179}
180
181impl std::str::FromStr for ProductMeta {
182    type Err = String;
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        #[derive(serde::Deserialize)]
185        struct Raw {
186            #[serde(default)]
187            provider: Vec<RawProvider>,
188        }
189        #[derive(serde::Deserialize)]
190        struct RawProvider {
191            id: String,
192            #[serde(default)]
193            extra_headers: Vec<(String, String)>,
194        }
195        let raw: Raw = toml::from_str(s).map_err(|e| e.to_string())?;
196        let mut extra_headers = std::collections::HashMap::new();
197        for p in raw.provider {
198            if !p.extra_headers.is_empty() {
199                extra_headers.insert(p.id, p.extra_headers);
200            }
201        }
202        Ok(Self { extra_headers })
203    }
204}
205
206/// Normalize models.dev modalities to oxicode input string list.
207fn normalize_modalities(md: &Option<crate::catalog::models_dev::MdModalities>) -> Vec<String> {
208    match md {
209        Some(m) => match &m.input {
210            Some(input) if !input.is_empty() => input.clone(),
211            _ => vec!["text".to_string()],
212        },
213        _ => vec!["text".to_string()],
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::Api;
221
222    #[test]
223    fn protocol_for_anthropic() {
224        let (api, auth) = protocol_for("@ai-sdk/anthropic");
225        assert_eq!(api, Api::AnthropicMessages);
226        assert_eq!(auth, crate::catalog::provider::AuthMethod::XApiKey);
227    }
228
229    #[test]
230    fn protocol_for_google() {
231        let (api, auth) = protocol_for("@ai-sdk/google");
232        assert_eq!(api, Api::GoogleGenerativeAi);
233        assert_eq!(auth, crate::catalog::provider::AuthMethod::None);
234    }
235
236    #[test]
237    fn protocol_for_openai_compatible() {
238        let (api, auth) = protocol_for("@ai-sdk/openai-compatible");
239        assert_eq!(api, Api::OpenAiCompletions);
240        assert_eq!(auth, crate::catalog::provider::AuthMethod::Bearer);
241    }
242
243    #[test]
244    fn protocol_for_unknown() {
245        let (api, auth) = protocol_for("some-new-sdk");
246        assert_eq!(api, Api::OpenAiCompletions);
247        assert_eq!(auth, crate::catalog::provider::AuthMethod::Bearer);
248    }
249
250    #[test]
251    fn protocol_for_empty() {
252        let (api, auth) = protocol_for("");
253        assert_eq!(api, Api::OpenAiCompletions);
254        assert_eq!(auth, crate::catalog::provider::AuthMethod::Bearer);
255    }
256
257    #[test]
258    fn protocol_for_mistral() {
259        // omp treats Mistral as openai-completions-compatible (no separate
260        // dialect); it falls through to the default Bearer/openai-completions.
261        let (api, auth) = protocol_for("@ai-sdk/mistral");
262        assert_eq!(api, Api::OpenAiCompletions);
263        assert_eq!(auth, crate::catalog::provider::AuthMethod::Bearer);
264    }
265
266    #[test]
267    fn protocol_for_azure() {
268        let (api, auth) = protocol_for("@ai-sdk/azure");
269        assert_eq!(api, Api::AzureOpenAiResponses);
270        assert_eq!(auth, crate::catalog::provider::AuthMethod::ApiKey);
271    }
272
273    #[test]
274    fn protocol_for_amazon_bedrock() {
275        let (api, auth) = protocol_for("@ai-sdk/amazon-bedrock");
276        assert_eq!(api, Api::BedrockConverseStream);
277        assert_eq!(auth, crate::catalog::provider::AuthMethod::None);
278    }
279
280    #[test]
281    fn product_meta_parses() {
282        let meta = ProductMeta::builtin();
283        // OpenRouter must have its HTTP-Referer header
284        let openrouter_headers = meta.extra_headers.get("openrouter");
285        assert!(
286            openrouter_headers.is_some(),
287            "product-meta.toml should include openrouter"
288        );
289        if let Some(headers) = openrouter_headers {
290            assert!(headers.iter().any(|(k, _)| k == "HTTP-Referer"));
291        }
292    }
293
294    #[test]
295    fn materialize_snapshot_counts() {
296        use std::io::Read;
297
298        // Decompress the embedded SNAP snapshot
299        let compressed = include_bytes!("../../data/catalog/_snapshot.json.gz");
300        let mut decoder = flate2::read::GzDecoder::new(&compressed[..]);
301        let mut json = String::new();
302        decoder.read_to_string(&mut json).unwrap();
303
304        let catalog: crate::catalog::MdCatalog = serde_json::from_str(&json).unwrap();
305        let (providers, models) =
306            super::materialize(&catalog, &ProductMeta::default(), &Default::default());
307
308        assert!(!providers.is_empty(), "providers should not be empty");
309        assert_eq!(providers.len(), 145, "expected 145 providers");
310        let model_count: usize = models.values().map(|v| v.len()).sum();
311        assert_eq!(model_count, 5277, "expected 5277 models");
312    }
313}