Skip to main content

oxicode_catalog/catalog/
override_.rs

1//! User override layer of the dynamic catalog.
2//!
3//! Allows users to:
4//! - Override prices for built-in models (e.g., negotiated enterprise rates)
5//! - Add custom models not in the built-in catalog
6//! - Add custom providers (e.g., internal AI gateway)
7//!
8//! Override files are TOML with the same schema as built-in files.
9//! They are loaded at runtime from:
10//!
11//! 1. `OXICODE_CATALOG_OVERRIDE` environment variable (if set) — path to a TOML file
12//! 2. `$OXICODE_HOME/catalog/overrides.toml` (or `~/.oxicode/catalog/overrides.toml`) — global user overrides
13//! 3. `.oxicode/catalog.local.toml` — project-local overrides (relative to cwd)
14//!
15//! Later layers override earlier ones. The order is:
16//!   Built-in (Layer 1) → Global override (Layer 2a) → Project override (Layer 2b) → Runtime (Layer 3)
17//!
18//! ## Merge semantics
19//!
20//! - **Providers**: merged by id. If the same provider id exists in both built-in
21//!   and override, the override REPLACES the built-in entry (full replacement,
22//!   not field-level merge — this is simpler and matches user intent).
23//! - **Models**: merged by `(provider, id)` pair. If a model with the same
24//!   `(provider, id)` exists, the override REPLACES it. New models are appended.
25//!
26//! ## Failure handling
27//!
28//! Override files that fail to parse or have wrong types are **silently
29//! ignored** with a warning log. The user can check by running with
30//! `OXICODE_CATALOG_DEBUG=1` to see the resolution path.
31
32use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34
35use crate::catalog::{BuiltinModelEntry, BuiltinProviderEntry};
36
37/// User override container.
38///
39/// `None` for a field means "do not override" — the built-in value is kept.
40/// `Some(value)` means "replace with this value".
41#[derive(Debug, Default, Clone, serde::Deserialize)]
42pub struct OverrideFile {
43    /// Override provider entries (replace built-in by id).
44    #[serde(default)]
45    pub provider: Vec<BuiltinProviderEntry>,
46    /// Override model entries (replace by `(provider, id)`, append new).
47    #[serde(default)]
48    pub model: Vec<BuiltinModelEntry>,
49}
50
51/// Find all override files in priority order (lowest to highest).
52///
53/// Returns `(path, content)` pairs. Files that don't exist are skipped.
54/// The caller decides how to merge them.
55///
56/// Delegates to its testable core (`find_override_files_at`) with the
57/// product-home catalog dir ([`crate::product_env::catalog_override_dir`]).
58pub fn find_override_files() -> Vec<(PathBuf, String)> {
59    find_override_files_at(crate::product_env::catalog_override_dir().as_deref())
60}
61
62/// Testable core of [`find_override_files`]: resolve override files given an
63/// explicit global override directory.
64///
65/// `global_dir` is source 2 (the product-home catalog dir, e.g.
66/// `$OXICODE_HOME/catalog`). Sources 1 (`OXICODE_CATALOG_OVERRIDE` env) and 3
67/// (cwd-relative `.oxicode/catalog.local.toml`) are resolved internally as before
68/// — only source 2 depends on the product home, so it is the sole parameter.
69fn find_override_files_at(global_dir: Option<&Path>) -> Vec<(PathBuf, String)> {
70    let mut out = Vec::new();
71
72    // 1. Explicit env var
73    if let Ok(path) = std::env::var("OXICODE_CATALOG_OVERRIDE")
74        && let Some(pair) = read_override(&PathBuf::from(path))
75    {
76        out.push(pair);
77    }
78
79    // 2. Global: <global_dir>/overrides.toml (product-home catalog dir).
80    if let Some(dir) = global_dir {
81        let path = dir.join("overrides.toml");
82        if let Some(pair) = read_override(&path) {
83            out.push(pair);
84        }
85    }
86
87    // 3. Project-local: .oxicode/catalog.local.toml (cwd)
88    let path = PathBuf::from(".oxicode/catalog.local.toml");
89    if let Some(pair) = read_override(&path) {
90        out.push(pair);
91    }
92
93    out
94}
95
96fn read_override(path: &Path) -> Option<(PathBuf, String)> {
97    if !path.exists() {
98        return None;
99    }
100    match std::fs::read_to_string(path) {
101        Ok(content) => Some((path.to_path_buf(), content)),
102        Err(e) => {
103            tracing::warn!(?path, error = %e, "Failed to read override file");
104            None
105        }
106    }
107}
108
109/// Apply a list of override files to a catalog snapshot.
110///
111/// Returns a new [`OverrideFile`] that is the union of all overrides.
112/// In a real merge step the caller would apply this to the built-in
113/// `BuiltinProviderEntry` and `BuiltinModelEntry` lists.
114///
115/// Returns `None` if no override files were found, or all failed to parse.
116pub fn load_overrides() -> Option<OverrideFile> {
117    let files = find_override_files();
118    if files.is_empty() {
119        return None;
120    }
121
122    let mut merged = OverrideFile::default();
123    for (path, content) in files {
124        match toml::from_str::<OverrideFile>(&content) {
125            Ok(file) => {
126                tracing::info!(
127                    ?path,
128                    providers = file.provider.len(),
129                    models = file.model.len(),
130                    "Loaded catalog override"
131                );
132                merged.provider.extend(file.provider);
133                merged.model.extend(file.model);
134            }
135            Err(e) => {
136                tracing::warn!(?path, error = %e, "Failed to parse override file; skipping");
137            }
138        }
139    }
140
141    if merged.provider.is_empty() && merged.model.is_empty() {
142        None
143    } else {
144        Some(merged)
145    }
146}
147
148/// Apply user overrides to a provider list in-place.
149///
150/// - Built-in providers with the same id as an override are REPLACED.
151/// - Override providers with new ids are APPENDED.
152pub fn apply_provider_overrides(
153    providers: &mut Vec<BuiltinProviderEntry>,
154    overrides: &[BuiltinProviderEntry],
155) {
156    for ov in overrides {
157        if let Some(existing) = providers.iter_mut().find(|p| p.id == ov.id) {
158            tracing::debug!(provider = %ov.id, "Replacing built-in provider with override");
159            *existing = ov.clone();
160        } else {
161            tracing::debug!(provider = %ov.id, "Adding new provider from override");
162            providers.push(ov.clone());
163        }
164    }
165}
166
167/// Apply user overrides to a model map in-place.
168///
169/// - Built-in models with the same `(provider, id)` are REPLACED.
170/// - Override models with new `(provider, id)` are APPENDED to the provider's list.
171pub fn apply_model_overrides(
172    models: &mut BTreeMap<String, Vec<BuiltinModelEntry>>,
173    overrides: &[BuiltinModelEntry],
174) {
175    for ov in overrides {
176        let entry = models.entry(ov.provider.clone()).or_default();
177        if let Some(existing) = entry.iter_mut().find(|m| m.id == ov.id) {
178            tracing::debug!(provider = %ov.provider, model = %ov.id,
179                "Replacing built-in model with override");
180            *existing = ov.clone();
181        } else {
182            tracing::debug!(provider = %ov.provider, model = %ov.id,
183                "Adding new model from override");
184            entry.push(ov.clone());
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn parse_minimal_override() {
195        let toml = r#"
196            [[provider]]
197            id = "my-company-gateway"
198            display_name = "My Company AI Gateway"
199            env_key = "MY_GATEWAY_API_KEY"
200            api = "openai-completions"
201            auth_method = "bearer"
202            category = "enterprise"
203            description = "Internal AI gateway"
204
205            [[model]]
206            id = "my-company-gpt"
207            name = "Internal GPT-4 variant"
208            api = "openai-completions"
209            provider = "my-company-gateway"
210            context_window = 128000
211            max_tokens = 8192
212            cost_input = 1.0
213            cost_output = 2.0
214        "#;
215        let parsed: OverrideFile = toml::from_str(toml).expect("parse");
216        assert_eq!(parsed.provider.len(), 1);
217        assert_eq!(parsed.model.len(), 1);
218        assert_eq!(parsed.provider[0].id, "my-company-gateway");
219        assert_eq!(parsed.model[0].id, "my-company-gpt");
220    }
221
222    #[test]
223    fn apply_provider_override_replaces() {
224        let mut providers = vec![BuiltinProviderEntry {
225            id: "anthropic".into(),
226            display_name: "Anthropic".into(),
227            api: "anthropic-messages".into(),
228            env_key: "ANTHROPIC_API_KEY".into(),
229            category: "primary".into(),
230            description: "Old".into(),
231            auth_method: crate::catalog::AuthMethod::XApiKey,
232            aliases: vec![],
233            extra_env_keys: vec![],
234            base_url: "".into(),
235            extra_headers: vec![],
236            default_enabled: true,
237        }];
238        let overrides = vec![BuiltinProviderEntry {
239            id: "anthropic".into(),
240            display_name: "Anthropic (Custom Pricing)".into(),
241            api: "anthropic-messages".into(),
242            env_key: "ANTHROPIC_API_KEY".into(),
243            category: "primary".into(),
244            description: "New".into(),
245            auth_method: crate::catalog::AuthMethod::XApiKey,
246            aliases: vec![],
247            extra_env_keys: vec![],
248            base_url: "".into(),
249            extra_headers: vec![],
250            default_enabled: true,
251        }];
252        apply_provider_overrides(&mut providers, &overrides);
253        assert_eq!(providers.len(), 1);
254        assert_eq!(providers[0].display_name, "Anthropic (Custom Pricing)");
255    }
256
257    #[test]
258    fn apply_model_override_appends_new() {
259        let mut models: BTreeMap<String, Vec<BuiltinModelEntry>> = BTreeMap::new();
260        models.insert("anthropic".into(), vec![]);
261        let overrides = vec![BuiltinModelEntry {
262            id: "claude-test".into(),
263            name: "Test".into(),
264            api: "anthropic-messages".into(),
265            provider: "anthropic".into(),
266            reasoning: false,
267            input: vec!["text".into()],
268            cost_input: 1.0,
269            cost_output: 2.0,
270            cost_cache_read: 0.0,
271            cost_cache_write: 0.0,
272            context_window: 200000,
273            max_tokens: 8192,
274            auth_method: crate::catalog::provider::AuthMethod::Bearer,
275            base_url: None,
276        }];
277        apply_model_overrides(&mut models, &overrides);
278        assert_eq!(models.get("anthropic").unwrap().len(), 1);
279    }
280    /// Regression: the global override source must honor the product-home
281    /// catalog dir (resolved from `$OXICODE_HOME`), so embedders (oxios, forks)
282    /// can isolate their catalog override namespace from `~/.oxicode/`. Tests the
283    /// testable core [`find_override_files_at`] directly — no env mutation,
284    /// parallel-safe.
285    #[test]
286    fn find_override_files_at_reads_global_dir() {
287        let tmp = tempfile::TempDir::new().expect("tempdir");
288        let catalog_dir = tmp.path().join("catalog");
289        std::fs::create_dir_all(&catalog_dir).expect("mkdir catalog");
290        let override_path = catalog_dir.join("overrides.toml");
291        std::fs::write(
292            &override_path,
293            "[[provider]]\nid = \"oxicode-home-regression\"\napi = \"openai-completions\"\n",
294        )
295        .expect("write override");
296
297        let files = find_override_files_at(Some(&catalog_dir));
298        let found = files.iter().any(|(p, _)| p == &override_path);
299        assert!(
300            found,
301            "global-dir override must be discovered; got {:?}",
302            files.iter().map(|(p, _)| p).collect::<Vec<_>>()
303        );
304    }
305
306    /// `None` global dir skips source 2 entirely — proves the product-home dir
307    /// is the *only* home-dependent input to override resolution.
308    #[test]
309    fn find_override_files_at_none_skips_global() {
310        let files = find_override_files_at(None);
311        // No panic; returns a Vec (contents depend on env/cwd, which are
312        // absent in the test environment).
313        let _ = files;
314    }
315}