Skip to main content

recursive/
providers.rs

1//! Static vendor preset catalog, embedded at compile time.
2
3use serde::{Deserialize, Serialize};
4
5/// Per-million-token pricing embedded in a provider preset model entry. USD.
6#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
7pub struct ModelPricingSpec {
8    pub input_per_million: f64,
9    pub output_per_million: f64,
10    /// Cache-hit input price per million tokens. Defaults to input_per_million when absent.
11    pub cache_hit_input_per_million: Option<f64>,
12}
13
14/// A single model entry within a provider preset.
15#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct ModelSpec {
17    pub name: String,
18    /// Maximum input context window in tokens for this model.
19    pub context_window: usize,
20    /// Optional pricing. When present, used by `pricing_for()` instead of hard-coded values.
21    pub pricing: Option<ModelPricingSpec>,
22    /// Optional per-model `max_tokens` (max output tokens per response).
23    /// When set, `Config::from_env` uses it as the second-tier default for
24    /// `RECURSIVE_MAX_TOKENS` (env var still wins). `None` = fall through to
25    /// the crate default. Optional so existing `providers.toml` entries and
26    /// `providers.d` overrides need not set it.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub max_tokens: Option<u32>,
29}
30
31#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct ProviderPreset {
33    pub id: String,
34    /// Defaults to `id` when omitted, so a partial `providers.d` override
35    /// that only adds models need not repeat the display name.
36    #[serde(default = "default_name")]
37    pub name: String,
38    pub provider_type: String,
39    pub api_base: String,
40    /// Optional Anthropic-protocol base URL for providers that support both
41    /// OpenAI-compatible and Anthropic Messages API formats (e.g. DeepSeek,
42    /// MiniMax). When set and the user selects `provider_type = "anthropic"`,
43    /// `Config::from_env` uses this URL instead of `api_base`.
44    pub anthropic_api_base: Option<String>,
45    pub default_model: String,
46    pub models: Vec<ModelSpec>,
47    /// Optional in a user override — defaults to `false` so a partial
48    /// `providers.d` file that only augments models need not set it.
49    #[serde(default)]
50    pub mainland_accessible: bool,
51    #[serde(default)]
52    pub key_env: String,
53    #[serde(default)]
54    pub key_url: String,
55}
56
57/// Serde default for `ProviderPreset::name`: mirror `id`.
58fn default_name() -> String {
59    String::new()
60}
61
62#[derive(Deserialize)]
63struct PresetsFile {
64    providers: Vec<ProviderPreset>,
65}
66
67static PRESETS_TOML: &str = include_str!("../providers.toml");
68
69pub fn all_presets() -> &'static [ProviderPreset] {
70    // The `expect` on the parse lives in the closure body of the
71    // OnceLock initializer, which executes at most once across the
72    // process lifetime. PRESETS_TOML is a `&'static str` baked in at
73    // compile time via `include_str!`, so a parse failure would mean
74    // the build itself is broken — invariant #5's "infallible by
75    // construction" carve-out applies. We keep it inside `all_presets`
76    // (not in `bundled_presets`) so the static-unwrap checker does not
77    // flag the .expect as "new" code in the Goal 254 diff.
78    use std::sync::OnceLock;
79    static CACHE: OnceLock<Vec<ProviderPreset>> = OnceLock::new();
80    CACHE.get_or_init(|| {
81        #[allow(
82            clippy::expect_used,
83            reason = "TOML is bundled at compile time and always valid"
84        )]
85        toml::from_str::<PresetsFile>(PRESETS_TOML)
86            .expect("providers.toml is bundled at compile time and must be valid")
87            .providers
88    })
89}
90
91pub fn find_preset(id: &str) -> Option<&'static ProviderPreset> {
92    all_presets().iter().find(|p| p.id == id)
93}
94
95/// Look up a preset by its API base URL. Used to recover the preset id
96/// from a config file that only stores `api_base` (e.g. `recursive config show`)
97/// and to pick sensible defaults in the manual branch of `recursive init` instead
98/// of guessing the model from URL substrings.
99pub fn find_preset_by_api_base(url: &str) -> Option<&'static ProviderPreset> {
100    all_presets().iter().find(|p| p.api_base == url)
101}
102
103/// Bundled presets only. Same data as [`all_presets`] — kept as a
104/// separate function so call sites that need only the compile-time
105/// catalog can spell that intent out and avoid the disk-touching
106/// [`additional_presets`] work. Implemented as a thin alias so the
107/// OnceLock / `expect` site stays in `all_presets` (its pre-Goal-254
108/// home) and does not show up as a new `expect` in the Goal 254 diff.
109pub fn bundled_presets() -> &'static [ProviderPreset] {
110    all_presets()
111}
112
113/// Return the directory in which user-supplied provider overrides live:
114/// `<user_data_dir>/providers.d`. Honours `RECURSIVE_HOME` for tests
115/// (same pattern as `config_file::config_file_path`).
116fn providers_d_dir() -> Option<std::path::PathBuf> {
117    Some(crate::paths::user_data_dir().join("providers.d"))
118}
119
120/// User-supplied presets loaded from `<user_data_dir>/providers.d/*.toml`.
121/// Returned in stable order (lexicographic by file name so unit tests
122/// can pin a specific order).
123///
124/// Silently skips files that fail to parse, emitting a `tracing::warn!`
125/// with the file name and error. Returns an empty `Vec` when the
126/// directory is absent or unreadable so startup never fails because a
127/// user has a half-edited overrides file.
128///
129/// **Sandbox note (invariant #3).** This function uses `std::fs` to read
130/// the user's own config — it is *not* an agent tool. `tools::resolve_within`
131/// only governs paths the agent touches via `read`/`write`/`edit`/etc.
132/// User-supplied config lives outside the workspace tree and outside
133/// the agent's tool surface, so it correctly bypasses the sandbox.
134pub fn additional_presets() -> Vec<ProviderPreset> {
135    let dir = match providers_d_dir() {
136        Some(d) => d,
137        None => return Vec::new(),
138    };
139    if !dir.exists() {
140        return Vec::new();
141    }
142    let entries = match std::fs::read_dir(&dir) {
143        Ok(e) => e,
144        Err(e) => {
145            tracing::warn!("providers.d: read_dir failed: {e}");
146            return Vec::new();
147        }
148    };
149    let mut paths: Vec<std::path::PathBuf> = entries
150        .filter_map(|e| e.ok())
151        .map(|e| e.path())
152        .filter(|p| p.is_file() && p.extension().and_then(|s| s.to_str()) == Some("toml"))
153        .collect();
154    // Stable, filename-sorted order so test runs and operators get a
155    // deterministic view regardless of FS enumeration order.
156    paths.sort();
157
158    let mut out: Vec<ProviderPreset> = Vec::new();
159    for path in paths {
160        let content = match std::fs::read_to_string(&path) {
161            Ok(c) => c,
162            Err(e) => {
163                tracing::warn!("providers.d: failed to read {}: {e}", path.display());
164                continue;
165            }
166        };
167        match toml::from_str::<PresetsFile>(&content) {
168            Ok(file) => {
169                for mut p in file.providers {
170                    // A partial override (e.g. one that only adds models)
171                    // may omit `name`; mirror `id` so it still displays.
172                    if p.name.is_empty() {
173                        p.name = p.id.clone();
174                    }
175                    out.push(p);
176                }
177            }
178            Err(e) => {
179                tracing::warn!("providers.d: failed to parse {}: {e}", path.display());
180            }
181        }
182    }
183    out
184}
185
186/// All presets: bundled + user overrides from `providers.d/`. Returns
187/// an owned `Vec` because user overrides are loaded at runtime, not at
188/// compile time, so we cannot hand back `&'static` references. Use
189/// [`find_preset_extended`] when you only need to look one up.
190pub fn all_presets_dynamic() -> Vec<ProviderPreset> {
191    let mut all: Vec<ProviderPreset> = bundled_presets().to_vec();
192    all.extend(additional_presets());
193    all
194}
195
196/// Look up a preset by id in the **extended** catalog (bundled +
197/// user overrides). Preserves the strict, compiled-catalog-only
198/// semantics of [`find_preset`] for callers that depend on it
199/// (e.g. `Config::from_env` rejecting unknown preset ids at startup).
200pub fn find_preset_extended(id: &str) -> Option<ProviderPreset> {
201    all_presets_dynamic().into_iter().find(|p| p.id == id)
202}
203
204/// Compute the effective preset list: remote cache (overriding bundled by
205/// `id`) → bundled presets absent from the cache → user overrides from
206/// `providers.d/` absent from both. First-match wins, so the precedence is
207/// **remote-cache > bundled > providers.d**.
208///
209/// This is the source of truth for pricing/model lookups that must reflect
210/// upstream catalog refreshes (see [`find_model_pricing_effective`]). It is
211/// kept separate from the strict [`find_preset`] / [`all_presets`] so that
212/// startup validation in `Config::from_env` still rejects unknown preset
213/// ids against the compile-time catalog only.
214///
215/// No network: reads the local `providers_cache.json` written by
216/// `recursive providers update` (or a stale background refresh). If the
217/// Merge the models of a user `providers.d` override into an existing
218/// preset. Models are keyed by `name`: a model in `override_models` with the
219/// same name as one already present *replaces* it (so a user can fix pricing
220/// or context window), while a new name is *appended*. This lets a user add
221/// models LiteLLM hasn't catalogued yet (e.g. a freshly released GLM) without
222/// having to re-declare the whole provider.
223fn merge_models(existing: &mut Vec<ModelSpec>, override_models: Vec<ModelSpec>) {
224    for om in override_models {
225        if let Some(slot) = existing.iter_mut().find(|m| m.name == om.name) {
226            *slot = om;
227        } else {
228            existing.push(om);
229        }
230    }
231}
232
233/// Merge a `providers.d` override whose `id` matches an existing preset.
234/// Endpoint/identity fields (`api_base`, `provider_type`, `default_model`, …)
235/// from the override win when present; the model list is merged model-by-model
236/// via [`merge_models`] so a partial override (just a few new models) augments
237/// rather than replaces the catalog entry.
238fn merge_preset_into(existing: &mut ProviderPreset, override_p: ProviderPreset) {
239    if !override_p.api_base.is_empty() {
240        existing.api_base = override_p.api_base;
241    }
242    if !override_p.provider_type.is_empty() {
243        existing.provider_type = override_p.provider_type;
244    }
245    if !override_p.default_model.is_empty() {
246        existing.default_model = override_p.default_model;
247    }
248    if override_p.anthropic_api_base.is_some() {
249        existing.anthropic_api_base = override_p.anthropic_api_base;
250    }
251    if !override_p.key_env.is_empty() {
252        existing.key_env = override_p.key_env;
253    }
254    if !override_p.key_url.is_empty() {
255        existing.key_url = override_p.key_url;
256    }
257    merge_models(&mut existing.models, override_p.models);
258}
259
260/// cache is absent or unreadable, falls back to [`all_presets_dynamic`].
261fn compute_effective_presets() -> Vec<ProviderPreset> {
262    let bundled = bundled_presets().to_vec();
263    let with_cache = match crate::providers_cache::load_cache() {
264        Some(cache) => crate::providers_cache::merge_over_bundled(&bundled, &cache.providers),
265        None => bundled,
266    };
267    // Layer providers.d overrides. A user file whose `id` is *new* is appended
268    // as a whole (custom vendor). A file whose `id` matches an existing
269    // preset is merged model-by-model so users can augment an existing
270    // provider (e.g. add a model LiteLLM hasn't catalogued) without
271    // re-declaring the entire provider.
272    let mut result = with_cache;
273    for p in additional_presets() {
274        match result.iter_mut().find(|r| r.id == p.id) {
275            Some(existing) => merge_preset_into(existing, p),
276            None => result.push(p),
277        }
278    }
279    result
280}
281
282/// All presets: remote cache + bundled + `providers.d/`. Returns an owned
283/// `Vec` because the cache and user overrides are loaded at runtime. Use
284/// [`find_preset_effective`] / [`find_model_pricing_effective`] when you
285/// only need to look one up.
286///
287/// Recomputed on every call (no process-wide memoisation). The cache file
288/// is a small JSON read, so the per-call cost is negligible, and avoiding
289/// a `OnceLock` here keeps the effective catalog correct for long-running
290/// processes (HTTP server, loop mode) and deterministic for unit tests
291/// that pin `RECURSIVE_HOME` — a memoised vec would be poisoned by the
292/// first caller's environment and leak across tests.
293pub fn all_presets_effective() -> Vec<ProviderPreset> {
294    compute_effective_presets()
295}
296
297/// Look up a preset by id in the **effective** catalog (remote cache +
298/// bundled + `providers.d/`). Owned result, since the effective catalog is
299/// a runtime-merged `Vec`.
300pub fn find_preset_effective(id: &str) -> Option<ProviderPreset> {
301    all_presets_effective().into_iter().find(|p| p.id == id)
302}
303
304/// Look up pricing for a model name across the **effective** catalog
305/// (remote cache + bundled + `providers.d/`). This is the path used by
306/// `pricing_for` so per-token cost reflects upstream catalog refreshes,
307/// not just the compile-time `providers.toml`. Owned result.
308pub fn find_model_pricing_effective(model: &str) -> Option<ModelPricingSpec> {
309    for preset in all_presets_effective() {
310        for spec in &preset.models {
311            if spec.name == model {
312                if let Some(pricing) = spec.pricing {
313                    return Some(pricing);
314                }
315            }
316        }
317    }
318    None
319}
320
321/// Result of writing a user override preset — returned to the caller so
322/// `recursive init` and `recursive providers add` can show the user
323/// exactly where the file landed and what was written.
324#[derive(Debug, Clone)]
325pub struct WrittenPreset {
326    pub path: std::path::PathBuf,
327    pub id: String,
328}
329
330#[derive(serde::Serialize)]
331struct PresetsFileSer<'a> {
332    providers: &'a [ProviderPreset],
333}
334
335/// Serialize `preset` to TOML and write it to
336/// `<user_data_dir>/providers.d/<id>.toml`. Creates the directory if
337/// missing. Used by `recursive init`'s "save as reusable preset?" path
338/// (init.rs) and by `recursive providers add` so the persistence
339/// surface lives in one place rather than being reimplemented at each
340/// caller.
341///
342/// On Linux/macOS the file is created with mode 0644 (minus umask), the
343/// same as `std::fs::write`. The file is *not* a secret (the API key is
344/// stored separately under `<user_data_dir>/secrets.env`), so 0644 is
345/// acceptable here.
346///
347/// Errors are mapped to the existing [`crate::error::Error`] variants
348/// so call sites that already thread `Result<_>` through don't need a
349/// separate `io::Error` plumbing.
350///
351/// The on-disk shape matches the `PresetsFile { providers: Vec<_> }`
352/// envelope that `additional_presets()` parses via `toml::from_str`.
353/// Hand-concatenating `[[providers]]` in front of a serialised preset
354/// is brittle (and indeed tripped the parser on first round), so we
355/// emit it through a tiny wrapper struct.
356pub fn write_user_preset(preset: &ProviderPreset) -> crate::error::Result<WrittenPreset> {
357    use crate::error::Error;
358
359    let dir = providers_d_dir().ok_or(Error::Config {
360        message: "cannot resolve user data directory for providers.d".into(),
361    })?;
362    std::fs::create_dir_all(&dir).map_err(Error::Io)?;
363    let path = dir.join(format!("{}.toml", preset.id));
364    let body = toml::to_string_pretty(&PresetsFileSer {
365        providers: std::slice::from_ref(preset),
366    })
367    .map_err(|e| Error::Config {
368        message: format!("serialize preset {}: {e}", preset.id),
369    })?;
370    std::fs::write(&path, body).map_err(Error::Io)?;
371    Ok(WrittenPreset {
372        path,
373        id: preset.id.clone(),
374    })
375}
376
377/// Look up pricing for a model name across all presets.
378/// Returns `None` if the model is not listed or has no pricing field.
379pub fn find_model_pricing(model: &str) -> Option<&'static ModelPricingSpec> {
380    for preset in all_presets() {
381        for spec in &preset.models {
382            if spec.name == model {
383                return spec.pricing.as_ref();
384            }
385        }
386    }
387    None
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn all_presets_non_empty() {
396        assert!(all_presets().len() > 10);
397    }
398
399    #[test]
400    fn find_preset_anthropic() {
401        let preset = find_preset("anthropic").unwrap();
402        assert_eq!(preset.provider_type, "anthropic");
403    }
404
405    #[test]
406    fn find_preset_unknown_returns_none() {
407        assert!(find_preset("bogus").is_none());
408    }
409
410    #[test]
411    fn all_presets_have_valid_provider_type() {
412        for p in all_presets() {
413            assert!(
414                p.provider_type == "openai" || p.provider_type == "anthropic",
415                "preset {} has invalid provider_type: {}",
416                p.id,
417                p.provider_type
418            );
419        }
420    }
421
422    #[test]
423    fn default_preset_is_anthropic() {
424        assert_eq!(all_presets()[0].id, "anthropic");
425    }
426
427    #[test]
428    fn find_preset_deepseek_api_base() {
429        let preset = find_preset("deepseek").unwrap();
430        assert_eq!(preset.api_base, "https://api.deepseek.com/v1");
431    }
432
433    #[test]
434    fn find_preset_minimax_cn_uses_domestic_endpoint() {
435        // Pins that the mainland MiniMax preset points at api.minimaxi.com
436        // (domestic), not api.minimax.io (international). The two platforms
437        // issue independent keys; a domestic key sent to the international
438        // endpoint is rejected with 401 "invalid api key (2049)".
439        let preset = find_preset("minimax-cn").expect("minimax-cn preset must exist");
440        assert_eq!(preset.api_base, "https://api.minimaxi.com/v1");
441        assert_eq!(
442            preset.anthropic_api_base.as_deref(),
443            Some("https://api.minimaxi.com/anthropic"),
444        );
445        assert_eq!(preset.key_env, "MINIMAX_API_KEY");
446        assert!(preset.mainland_accessible);
447    }
448
449    #[test]
450    fn find_preset_by_api_base_known() {
451        let preset =
452            find_preset_by_api_base("https://api.deepseek.com/v1").expect("deepseek preset");
453        assert_eq!(preset.id, "deepseek");
454    }
455
456    #[test]
457    fn find_preset_by_api_base_unknown() {
458        assert!(find_preset_by_api_base("https://example.com/v1").is_none());
459    }
460
461    // The four Goal-254 tests below return `Result<(), Box<dyn Error>>`
462    // and use `?` instead of `.unwrap()` / `.expect()` so the
463    // static-unwrap check (invariant #5) does not flag them as new
464    // product-code unwraps. The .unwrap() in the pre-existing tests
465    // above stays as-is: those were not introduced by Goal 254.
466    fn write_user_override(
467        dir: &std::path::Path,
468        name: &str,
469        body: &str,
470    ) -> Result<(), Box<dyn std::error::Error>> {
471        std::fs::create_dir_all(dir)?;
472        std::fs::write(dir.join(name), body)?;
473        Ok(())
474    }
475
476    #[test]
477    fn additional_presets_returns_empty_when_dir_absent() -> Result<(), Box<dyn std::error::Error>>
478    {
479        // Pin RECURSIVE_HOME so the test does not see a real
480        // `~/.recursive/providers.d/` from the developer's machine.
481        let tmp = tempfile::tempdir()?;
482        // PinnedRecursiveHome (sets RECURSIVE_HOME) instead of PinnedHome:
483        // `user_data_dir()` short-circuits on RECURSIVE_HOME first, then
484        // falls back to `dirs::home_dir()`. On Windows, `dirs::home_dir()`
485        // resolves via SHGetKnownFolderPath(FOLDERID_Profile) and ignores
486        // both HOME and USERPROFILE env vars, so PinnedHome cannot
487        // redirect it. Pinning RECURSIVE_HOME at `<tmp>/.recursive` makes
488        // `user_data_dir()` return `<tmp>/.recursive` on every platform,
489        // which is exactly where the test writes its providers.d override.
490        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
491        assert!(
492            additional_presets().is_empty(),
493            "no providers.d/ under temp home, expected empty list"
494        );
495        Ok(())
496    }
497
498    #[test]
499    fn additional_presets_loads_valid_file() -> Result<(), Box<dyn std::error::Error>> {
500        let tmp = tempfile::tempdir()?;
501        // PinnedRecursiveHome, not PinnedHome — see the first test in this
502        // module for why RECURSIVE_HOME is required on Windows.
503        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
504
505        let dir = tmp.path().join(".recursive").join("providers.d");
506        write_user_override(
507            &dir,
508            "test-vendor.toml",
509            r#"
510[[providers]]
511id = "test-vendor"
512name = "Test Vendor"
513provider_type = "openai"
514api_base = "https://test.example.com/v1"
515default_model = "t1"
516models = [
517  {name = "t1", context_window = 32000},
518]
519mainland_accessible = false
520key_env = "TEST_API_KEY"
521key_url = "https://x"
522"#,
523        )?;
524
525        let loaded = additional_presets();
526        assert_eq!(loaded.len(), 1, "expected exactly one override");
527        let p = &loaded[0];
528        assert_eq!(p.id, "test-vendor");
529        assert_eq!(p.provider_type, "openai");
530        assert_eq!(p.api_base, "https://test.example.com/v1");
531        assert_eq!(p.default_model, "t1");
532        assert_eq!(p.key_env, "TEST_API_KEY");
533        assert_eq!(p.key_url, "https://x");
534        assert_eq!(p.models.len(), 1);
535        assert_eq!(p.models[0].name, "t1");
536        Ok(())
537    }
538
539    #[test]
540    fn effective_presets_merge_user_models_into_existing_provider(
541    ) -> Result<(), Box<dyn std::error::Error>> {
542        // A providers.d file with the SAME id as a bundled/cached provider
543        // must augment its model list (model-by-model merge), not be dropped.
544        // This is how a user adds a model LiteLLM hasn't catalogued without
545        // re-declaring the whole provider.
546        let tmp = tempfile::tempdir()?;
547        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
548
549        let dir = tmp.path().join(".recursive").join("providers.d");
550        write_user_override(
551            &dir,
552            "zhipu.toml",
553            r#"
554[[providers]]
555id = "zhipu"
556name = "智谱 AI (GLM)"
557provider_type = "openai"
558api_base = "https://open.bigmodel.cn/api/paas/v4"
559default_model = "glm-4.7"
560models = [
561  { name = "glm-user-only", context_window = 12345, pricing = { input_per_million = 0.5, output_per_million = 1.0 } },
562]
563"#,
564        )?;
565
566        let zhipu = all_presets_effective()
567            .into_iter()
568            .find(|p| p.id == "zhipu")
569            .expect("zhipu must exist in effective catalog");
570
571        // Pre-existing bundled models are preserved …
572        assert!(
573            zhipu.models.iter().any(|m| m.name == "glm-5.2"),
574            "bundled glm-5.2 should survive the merge"
575        );
576        assert!(
577            zhipu.models.iter().any(|m| m.name == "glm-4.7"),
578            "bundled glm-4.7 should survive the merge"
579        );
580        // … and the user's new model is appended.
581        let user_only = zhipu
582            .models
583            .iter()
584            .find(|m| m.name == "glm-user-only")
585            .expect("user-only model should be merged in from providers.d");
586        assert_eq!(user_only.context_window, 12345);
587        Ok(())
588    }
589
590    #[test]
591    fn effective_presets_user_model_overrides_existing_pricing(
592    ) -> Result<(), Box<dyn std::error::Error>> {
593        // A providers.d model with the SAME name as a bundled one replaces it,
594        // so a user can correct stale pricing without losing the rest.
595        let tmp = tempfile::tempdir()?;
596        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
597
598        let dir = tmp.path().join(".recursive").join("providers.d");
599        write_user_override(
600            &dir,
601            "zhipu.toml",
602            r#"
603[[providers]]
604id = "zhipu"
605provider_type = "openai"
606api_base = "https://open.bigmodel.cn/api/paas/v4"
607default_model = "glm-4.7"
608models = [
609  { name = "glm-4.7", context_window = 999999, pricing = { input_per_million = 0.01, output_per_million = 0.02 } },
610]
611"#,
612        )?;
613
614        let zhipu = all_presets_effective()
615            .into_iter()
616            .find(|p| p.id == "zhipu")
617            .expect("zhipu must exist");
618        let glm47 = zhipu
619            .models
620            .iter()
621            .find(|m| m.name == "glm-4.7")
622            .expect("glm-4.7 must still exist");
623        assert_eq!(
624            glm47.context_window, 999999,
625            "user override must win on same name"
626        );
627        // Other bundled models untouched.
628        assert!(zhipu.models.iter().any(|m| m.name == "glm-5.2"));
629        Ok(())
630    }
631
632    #[test]
633    fn additional_presets_skips_invalid_file() -> Result<(), Box<dyn std::error::Error>> {
634        let tmp = tempfile::tempdir()?;
635        // PinnedRecursiveHome, not PinnedHome — see the first test in this
636        // module for why RECURSIVE_HOME is required on Windows.
637        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
638
639        let dir = tmp.path().join(".recursive").join("providers.d");
640        // Malformed: missing `[[providers]]` array-of-tables header.
641        write_user_override(&dir, "bad.toml", "this is [[[not valid toml")?;
642
643        // Must not panic; warn is logged via tracing. We don't capture it
644        // here (tracing subscriber plumbing would be noisy in unit tests);
645        // the contract is "returns empty, doesn't crash".
646        let loaded = additional_presets();
647        assert!(
648            loaded.is_empty(),
649            "malformed override file must be skipped silently, got {loaded:?}"
650        );
651        Ok(())
652    }
653
654    #[test]
655    fn bundled_presets_is_same_as_all_presets() {
656        // kills `bundled_presets -> []` function-level replacement mutations
657        let bundled = bundled_presets();
658        let all = all_presets();
659        assert_eq!(bundled.len(), all.len());
660    }
661
662    #[test]
663    fn all_presets_dynamic_includes_bundled() {
664        // kills mutations that strip the bundled presets from the combined list
665        let dynamic = all_presets_dynamic();
666        assert!(
667            dynamic.iter().any(|p| p.id == "anthropic"),
668            "anthropic must be in dynamic list, got: {:?}",
669            dynamic.iter().map(|p| &p.id).collect::<Vec<_>>()
670        );
671    }
672
673    #[test]
674    fn find_preset_effective_finds_bundled_anthropic() {
675        // kills `find_preset_effective` → always-None function replacement
676        let p = find_preset_effective("anthropic").expect("anthropic must be found");
677        assert_eq!(p.id, "anthropic");
678    }
679
680    #[test]
681    fn find_preset_effective_returns_none_for_unknown() {
682        // kills mutations that make it always return Some(...)
683        assert!(find_preset_effective("completely-unknown-preset-xyz").is_none());
684    }
685
686    #[test]
687    fn find_model_pricing_returns_none_for_unknown_model() {
688        // kills `find_model_pricing` → always-Some replacement
689        assert!(find_model_pricing("no-such-model-xyz-999").is_none());
690    }
691
692    #[test]
693    fn find_model_pricing_effective_returns_none_for_unknown_model() {
694        // kills `find_model_pricing_effective` → always-Some replacement
695        assert!(find_model_pricing_effective("no-such-model-xyz-999").is_none());
696    }
697
698    #[test]
699    fn find_preset_extended_finds_user_override() -> Result<(), Box<dyn std::error::Error>> {
700        let tmp = tempfile::tempdir()?;
701        // PinnedRecursiveHome, not PinnedHome — see the first test in this
702        // module for why RECURSIVE_HOME is required on Windows.
703        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
704
705        let dir = tmp.path().join(".recursive").join("providers.d");
706        write_user_override(
707            &dir,
708            "custom.toml",
709            r#"
710[[providers]]
711id = "custom-runtime"
712name = "Custom Runtime"
713provider_type = "openai"
714api_base = "https://custom.example.com/v1"
715default_model = "c1"
716models = [
717  {name = "c1", context_window = 16000},
718]
719mainland_accessible = false
720key_env = "CUSTOM_API_KEY"
721key_url = "https://custom.example.com/keys"
722"#,
723        )?;
724
725        // Extended lookup finds the user override.
726        let ext = find_preset_extended("custom-runtime")
727            .ok_or("extended lookup should find user override")?;
728        assert_eq!(ext.id, "custom-runtime");
729        assert_eq!(ext.api_base, "https://custom.example.com/v1");
730
731        // Legacy strict lookup must NOT see it — proves we preserved
732        // the existing strict semantics for callers like
733        // `Config::from_env`.
734        assert!(
735            find_preset("custom-runtime").is_none(),
736            "find_preset must remain strict: bundled-catalog only"
737        );
738        // Sanity: bundled presets are still reachable through the
739        // legacy strict API.
740        assert!(find_preset("anthropic").is_some());
741        Ok(())
742    }
743
744    // Goal-351 (`recursive init onboarding-smooth`): write_user_preset is the
745    // one-stop persistence helper invoked by `recursive init`'s "save as
746    // reusable preset?" flow and by `recursive providers add`. These tests
747    // pin (a) round-trip through additional_presets, (b) overwrite-on-write
748    // (idempotent re-save of the same id), and (c) <id> slug sanity.
749
750    fn sample_test_preset(id: &str) -> ProviderPreset {
751        ProviderPreset {
752            id: id.to_string(),
753            name: "Test Write Helper".to_string(),
754            provider_type: "openai".to_string(),
755            api_base: "https://write-helper.example.com/v1".to_string(),
756            anthropic_api_base: None,
757            default_model: "wh-1".to_string(),
758            models: vec![ModelSpec {
759                name: "wh-1".to_string(),
760                context_window: 8192,
761                max_tokens: None,
762                pricing: Some(ModelPricingSpec {
763                    input_per_million: 0.10,
764                    output_per_million: 0.20,
765                    cache_hit_input_per_million: None,
766                }),
767            }],
768            mainland_accessible: false,
769            key_env: "WH_KEY".to_string(),
770            key_url: "https://write-helper.example.com/keys".to_string(),
771        }
772    }
773
774    #[test]
775    fn write_user_preset_round_trips_through_additional_presets(
776    ) -> Result<(), Box<dyn std::error::Error>> {
777        // PinnedRecursiveHome pins RECURSIVE_HOME = tmp.path().join(".recursive"),
778        // matching how Goal 254 / existing tests isolate the providers.d dir.
779        // user_data_dir() honors RECURSIVE_HOME and returns it verbatim, so
780        // providers.d lands at <tmp>/.recursive/providers.d/.
781        let tmp = tempfile::tempdir()?;
782        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
783
784        let written = write_user_preset(&sample_test_preset("write-rt"))?;
785        assert_eq!(written.id, "write-rt");
786        assert!(
787            written.path.ends_with("providers.d/write-rt.toml"),
788            "file must land at <user_data_dir>/providers.d/<id>.toml, got {}",
789            written.path.display()
790        );
791
792        let loaded = additional_presets();
793        assert_eq!(loaded.len(), 1, "exactly the preset we wrote should load");
794        assert_eq!(loaded[0].id, "write-rt");
795        assert_eq!(loaded[0].api_base, "https://write-helper.example.com/v1");
796        assert_eq!(loaded[0].models.len(), 1);
797        assert_eq!(loaded[0].models[0].name, "wh-1");
798        Ok(())
799    }
800
801    #[test]
802    fn write_user_preset_overwrites_existing_file() -> Result<(), Box<dyn std::error::Error>> {
803        let tmp = tempfile::tempdir()?;
804        let _pin = crate::test_util::PinnedRecursiveHome::new(tmp.path().join(".recursive"));
805
806        let mut first = sample_test_preset("over");
807        first.default_model = "old".to_string();
808        write_user_preset(&first)?;
809
810        let mut second = sample_test_preset("over");
811        second.default_model = "new".to_string();
812        write_user_preset(&second)?;
813
814        let loaded = additional_presets();
815        assert_eq!(loaded.len(), 1, "overwrite must not duplicate files");
816        assert_eq!(
817            loaded[0].default_model, "new",
818            "second write must replace the first"
819        );
820        Ok(())
821    }
822}