Skip to main content

yallm_config/
lib.rs

1//! Layered configuration loading for yallm.
2//!
3//! Priority (high to low), each layer overwrites lower layers:
4//! 1. `.yallm/secrets.toml` - project secrets (gitignored)
5//! 2. `.yallm/*.local.toml` - per-machine overrides (gitignored)
6//! 3. `.yallm/config.toml` - project config (committed)
7//! 4. `~/.yallm/config.toml` - user defaults
8//! 5. OS env vars
9//! 6. `.env` file (only fills keys absent from OS env)
10//!
11//! Config files can have an `[env]` section. Values are returned in a merged
12//! map instead of being written back into the process environment.
13
14use std::{
15    collections::{HashMap, HashSet},
16    env, fs,
17    path::{Path, PathBuf},
18};
19
20use serde::{Deserialize, Deserializer};
21
22#[derive(Debug, Clone, Default)]
23pub struct LoadOptions {
24    pub litellm_config: Option<PathBuf>,
25}
26
27#[derive(Debug, Clone, Default)]
28pub struct LoadedConfig {
29    pub env: HashMap<String, String>,
30    pub litellm_models: Vec<LiteLlmModel>,
31    pub warnings: Vec<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LiteLlmModel {
36    pub model_name: String,
37    pub provider: LiteLlmProvider,
38    pub upstream_model: String,
39    pub api_base: Option<String>,
40    pub api_key: Option<String>,
41    pub api_version: Option<String>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum LiteLlmProvider {
46    OpenAI,
47    Anthropic,
48    Ollama,
49}
50
51/// Top-level yallm TOML structure.
52#[derive(Debug, Deserialize, Default)]
53pub struct ConfigFile {
54    /// Env vars to inject into the merged config map.
55    #[serde(default)]
56    pub env: HashMap<String, String>,
57}
58
59/// Load config from all layers using default options.
60pub fn load() -> LoadedConfig {
61    load_with_options(LoadOptions::default())
62}
63
64/// Load config from all layers.
65pub fn load_with_options(options: LoadOptions) -> LoadedConfig {
66    let current_dir = env::current_dir().unwrap_or_default();
67    let mut merged_env: HashMap<String, String> = env::vars().collect();
68    let mut warnings = Vec::new();
69
70    // .env only fills keys absent from OS env (matches dotenvy/python-dotenv default).
71    apply_dotenv(&current_dir.join(".env"), &mut merged_env, &mut warnings);
72
73    if let Some(path) = user_config_path(&merged_env) {
74        apply_toml_file(&path, &mut merged_env, "user config", &mut warnings);
75    }
76    let project_dir = current_dir.join(".yallm");
77    apply_toml_file(
78        &project_dir.join("config.toml"),
79        &mut merged_env,
80        "project config",
81        &mut warnings,
82    );
83    apply_toml_glob(
84        &project_dir,
85        ".local.toml",
86        &mut merged_env,
87        "local override",
88        &mut warnings,
89    );
90    apply_toml_file(
91        &project_dir.join("secrets.toml"),
92        &mut merged_env,
93        "project secrets",
94        &mut warnings,
95    );
96
97    let litellm_path = options.litellm_config.or_else(|| {
98        merged_env
99            .get("YALLM_LITELLM_CONFIG")
100            .map(String::as_str)
101            .map(str::trim)
102            .filter(|s| !s.is_empty())
103            .map(PathBuf::from)
104    });
105
106    let litellm_models = litellm_path
107        .as_deref()
108        .map(|path| parse_litellm_config_file(path, &merged_env, &mut warnings))
109        .unwrap_or_default();
110
111    LoadedConfig {
112        env: merged_env,
113        litellm_models,
114        warnings,
115    }
116}
117
118fn apply_toml_file(
119    path: &Path,
120    env_map: &mut HashMap<String, String>,
121    label: &str,
122    warnings: &mut Vec<String>,
123) {
124    let raw = match fs::read_to_string(path) {
125        Ok(s) => s,
126        Err(_) => return,
127    };
128    let cfg: ConfigFile = match toml::from_str(&raw) {
129        Ok(c) => c,
130        Err(e) => {
131            warnings.push(format!("yallm-config: {label} parse error: {e}"));
132            return;
133        }
134    };
135    for (key, val) in cfg.env {
136        env_map.insert(key, val);
137    }
138}
139
140fn apply_dotenv(path: &Path, env_map: &mut HashMap<String, String>, warnings: &mut Vec<String>) {
141    let raw = match fs::read_to_string(path) {
142        Ok(s) => s,
143        Err(_) => return,
144    };
145    for (key, val) in parse_dotenv(&raw, warnings) {
146        // Only fill keys absent from OS env (or earlier layers); never override.
147        env_map.entry(key).or_insert(val);
148    }
149}
150
151/// Apply every TOML file in `dir` whose name ends with `suffix`.
152/// Files are sorted by name for deterministic ordering; later files overwrite earlier ones.
153fn apply_toml_glob(
154    dir: &Path,
155    suffix: &str,
156    env_map: &mut HashMap<String, String>,
157    label: &str,
158    warnings: &mut Vec<String>,
159) {
160    let entries = match fs::read_dir(dir) {
161        Ok(e) => e,
162        Err(_) => return,
163    };
164    let mut files: Vec<PathBuf> = entries
165        .filter_map(Result::ok)
166        .map(|e| e.path())
167        .filter(|p| {
168            p.is_file()
169                && p.file_name()
170                    .and_then(|n| n.to_str())
171                    .is_some_and(|n| n.ends_with(suffix))
172        })
173        .collect();
174    files.sort();
175    for path in files {
176        apply_toml_file(&path, env_map, label, warnings);
177    }
178}
179
180fn parse_dotenv(raw: &str, warnings: &mut Vec<String>) -> HashMap<String, String> {
181    let mut out = HashMap::new();
182    for (idx, line) in raw.lines().enumerate() {
183        let line = line.trim();
184        if line.is_empty() || line.starts_with('#') {
185            continue;
186        }
187        let Some((key, val)) = line.split_once('=') else {
188            warnings.push(format!(
189                "yallm-config: ignored malformed .env line {}",
190                idx + 1
191            ));
192            continue;
193        };
194        let key = key.trim();
195        if key.is_empty() {
196            warnings.push(format!(
197                "yallm-config: ignored empty .env key on line {}",
198                idx + 1
199            ));
200            continue;
201        }
202        out.insert(key.to_string(), unquote_env_value(val.trim()));
203    }
204    out
205}
206
207fn unquote_env_value(value: &str) -> String {
208    if value.len() >= 2
209        && ((value.starts_with('"') && value.ends_with('"'))
210            || (value.starts_with('\'') && value.ends_with('\'')))
211    {
212        value[1..value.len() - 1].to_string()
213    } else {
214        value.to_string()
215    }
216}
217
218fn user_config_path(env_map: &HashMap<String, String>) -> Option<PathBuf> {
219    let home = env_map
220        .get("HOME")
221        .or_else(|| env_map.get("USERPROFILE"))
222        .filter(|s| !s.is_empty())?;
223    Some(PathBuf::from(home).join(".yallm").join("config.toml"))
224}
225
226fn parse_litellm_config_file(
227    path: &Path,
228    env_map: &HashMap<String, String>,
229    warnings: &mut Vec<String>,
230) -> Vec<LiteLlmModel> {
231    let raw = match fs::read_to_string(path) {
232        Ok(s) => s,
233        Err(e) => {
234            warnings.push(format!(
235                "yallm-config: failed to read LiteLLM config {}: {e}",
236                path.display()
237            ));
238            return Vec::new();
239        }
240    };
241    parse_litellm_config_str(&raw, env_map, warnings)
242}
243
244pub fn parse_litellm_config_str(
245    raw: &str,
246    env_map: &HashMap<String, String>,
247    warnings: &mut Vec<String>,
248) -> Vec<LiteLlmModel> {
249    let cfg: LiteLlmConfigFile = match serde_yaml_ng::from_str(raw) {
250        Ok(cfg) => cfg,
251        Err(e) => {
252            warnings.push(format!("yallm-config: LiteLLM config parse error: {e}"));
253            return Vec::new();
254        }
255    };
256
257    let mut seen = HashSet::new();
258    let mut out = Vec::new();
259    for entry in cfg.model_list {
260        let model_name = entry.model_name.unwrap_or_default().trim().to_string();
261        if model_name.is_empty() {
262            warnings.push("yallm-config: skipped LiteLLM model with empty model_name".to_string());
263            continue;
264        }
265        if model_name == "*" {
266            warnings.push("yallm-config: skipped LiteLLM wildcard model_name '*'".to_string());
267            continue;
268        }
269
270        // yallm_params wins over litellm_params when both are present.
271        let resolved = match entry.yallm_params {
272            Some(yp) => resolve_from_yallm_params(&model_name, yp, env_map, warnings),
273            None => {
274                resolve_from_litellm_params(&model_name, entry.litellm_params, env_map, warnings)
275            }
276        };
277        let Some(model) = resolved else {
278            continue;
279        };
280
281        if !seen.insert(model.model_name.clone()) {
282            warnings.push(format!(
283                "yallm-config: skipped duplicate model_name '{}'",
284                model.model_name
285            ));
286            continue;
287        }
288
289        out.push(model);
290    }
291    out
292}
293
294fn resolve_from_yallm_params(
295    model_name: &str,
296    params: YallmParams,
297    env_map: &HashMap<String, String>,
298    warnings: &mut Vec<String>,
299) -> Option<LiteLlmModel> {
300    let upstream_model = params.model.unwrap_or_default().trim().to_string();
301    if upstream_model.is_empty() || upstream_model == "*" {
302        warnings.push(format!(
303            "yallm-config: skipped model '{model_name}' with empty or wildcard yallm_params.model"
304        ));
305        return None;
306    }
307    // Provider must be explicit in yallm_params — no prefix-encoded fallback.
308    let Some(provider_str) = params
309        .provider
310        .as_deref()
311        .map(str::trim)
312        .filter(|s| !s.is_empty())
313    else {
314        warnings.push(format!(
315            "yallm-config: skipped model '{model_name}' with missing yallm_params.provider"
316        ));
317        return None;
318    };
319    let Some(provider) = parse_yallm_provider(provider_str) else {
320        warnings.push(format!(
321            "yallm-config: skipped model '{model_name}' with unsupported yallm_params.provider '{provider_str}'"
322        ));
323        return None;
324    };
325
326    let api_base = params
327        .api_base
328        .as_deref()
329        .and_then(|v| resolve_config_value(v, env_map, model_name, "api_base", false, warnings));
330    let api_key = params
331        .api_key
332        .as_deref()
333        .and_then(|v| resolve_config_value(v, env_map, model_name, "api_key", true, warnings));
334    let api_version = params
335        .api_version
336        .as_deref()
337        .and_then(|v| resolve_config_value(v, env_map, model_name, "api_version", false, warnings));
338
339    Some(LiteLlmModel {
340        model_name: model_name.to_string(),
341        provider,
342        upstream_model,
343        api_base,
344        api_key,
345        api_version,
346    })
347}
348
349fn resolve_from_litellm_params(
350    model_name: &str,
351    params: LiteLlmParams,
352    env_map: &HashMap<String, String>,
353    warnings: &mut Vec<String>,
354) -> Option<LiteLlmModel> {
355    let litellm_model = params.model.unwrap_or_default();
356    if litellm_model.trim().is_empty() || litellm_model.trim() == "*" {
357        warnings.push(format!(
358            "yallm-config: skipped LiteLLM model '{model_name}' with empty or wildcard upstream model"
359        ));
360        return None;
361    }
362
363    let Some((provider, upstream_model)) =
364        infer_provider_and_model(&litellm_model, params.custom_llm_provider.as_deref())
365    else {
366        warnings.push(format!(
367            "yallm-config: skipped unsupported LiteLLM model '{model_name}' ({litellm_model})"
368        ));
369        return None;
370    };
371
372    let api_base = params
373        .api_base
374        .as_deref()
375        .and_then(|v| resolve_config_value(v, env_map, model_name, "api_base", false, warnings));
376    let api_key = params
377        .api_key
378        .as_deref()
379        .and_then(|v| resolve_config_value(v, env_map, model_name, "api_key", true, warnings));
380    let api_version = params
381        .api_version
382        .as_deref()
383        .and_then(|v| resolve_config_value(v, env_map, model_name, "api_version", false, warnings));
384
385    Some(LiteLlmModel {
386        model_name: model_name.to_string(),
387        provider,
388        upstream_model,
389        api_base,
390        api_key,
391        api_version,
392    })
393}
394
395fn parse_yallm_provider(s: &str) -> Option<LiteLlmProvider> {
396    match s.trim().to_ascii_lowercase().as_str() {
397        "openai" => Some(LiteLlmProvider::OpenAI),
398        "anthropic" => Some(LiteLlmProvider::Anthropic),
399        "ollama" => Some(LiteLlmProvider::Ollama),
400        _ => None,
401    }
402}
403
404fn infer_provider_and_model(
405    model: &str,
406    custom_provider: Option<&str>,
407) -> Option<(LiteLlmProvider, String)> {
408    let model = model.trim();
409    let model_prefix = model.split_once('/').map(|(prefix, _)| prefix);
410    let provider = custom_provider
411        .map(str::trim)
412        .filter(|s| !s.is_empty())
413        .or(model_prefix)
414        .unwrap_or("openai")
415        .to_ascii_lowercase();
416
417    match provider.as_str() {
418        "openai" | "openai_compatible" | "openai-compatible" | "openai_like" | "openai-like" => {
419            Some((LiteLlmProvider::OpenAI, strip_model_prefix(model, "openai")))
420        }
421        "anthropic" => Some((
422            LiteLlmProvider::Anthropic,
423            strip_model_prefix(model, "anthropic"),
424        )),
425        "ollama" => Some((LiteLlmProvider::Ollama, strip_model_prefix(model, "ollama"))),
426        _ => None,
427    }
428}
429
430fn strip_model_prefix(model: &str, provider: &str) -> String {
431    model
432        .strip_prefix(&format!("{provider}/"))
433        .unwrap_or(model)
434        .to_string()
435}
436
437fn resolve_config_value(
438    value: &str,
439    env_map: &HashMap<String, String>,
440    model_name: &str,
441    field: &str,
442    warn_literal_secret: bool,
443    warnings: &mut Vec<String>,
444) -> Option<String> {
445    let value = value.trim();
446    if value.is_empty() || value.eq_ignore_ascii_case("none") {
447        return None;
448    }
449
450    if let Some(name) = value.strip_prefix("os.environ/") {
451        return env_lookup(name, env_map, model_name, field, warnings);
452    }
453
454    if let Some(name) = value.strip_prefix("${").and_then(|v| v.strip_suffix('}')) {
455        return env_lookup(name, env_map, model_name, field, warnings);
456    }
457
458    if warn_literal_secret {
459        warnings.push(format!(
460            "yallm-config: LiteLLM model '{model_name}' uses a literal {field}; prefer os.environ/VAR"
461        ));
462    }
463    Some(value.to_string())
464}
465
466fn env_lookup(
467    name: &str,
468    env_map: &HashMap<String, String>,
469    model_name: &str,
470    field: &str,
471    warnings: &mut Vec<String>,
472) -> Option<String> {
473    let name = name.trim();
474    match env_map.get(name).map(String::as_str).map(str::trim) {
475        Some(value) if !value.is_empty() => Some(value.to_string()),
476        _ => {
477            warnings.push(format!(
478                "yallm-config: LiteLLM model '{model_name}' references missing env var {name} for {field}"
479            ));
480            None
481        }
482    }
483}
484
485#[derive(Debug, Deserialize, Default)]
486struct LiteLlmConfigFile {
487    #[serde(default)]
488    model_list: Vec<LiteLlmEntry>,
489}
490
491#[derive(Debug, Deserialize, Default)]
492struct LiteLlmEntry {
493    #[serde(default, deserialize_with = "deserialize_optional_string")]
494    model_name: Option<String>,
495    /// yallm-native parameters. Takes priority over `litellm_params` when both
496    /// are present on the same entry. Provider must be specified explicitly via
497    /// the `provider` field instead of as a `provider/model` prefix.
498    #[serde(default)]
499    yallm_params: Option<YallmParams>,
500    /// LiteLLM-compatible parameters. Used as a fallback when `yallm_params` is
501    /// absent. Provider may be encoded as a `provider/model` prefix or via
502    /// `custom_llm_provider`.
503    #[serde(default)]
504    litellm_params: LiteLlmParams,
505}
506
507#[derive(Debug, Deserialize, Default)]
508struct YallmParams {
509    #[serde(default, deserialize_with = "deserialize_optional_string")]
510    provider: Option<String>,
511    #[serde(default, deserialize_with = "deserialize_optional_string")]
512    model: Option<String>,
513    #[serde(default, deserialize_with = "deserialize_optional_string")]
514    api_base: Option<String>,
515    #[serde(default, deserialize_with = "deserialize_optional_string")]
516    api_key: Option<String>,
517    #[serde(default, deserialize_with = "deserialize_optional_string")]
518    api_version: Option<String>,
519}
520
521#[derive(Debug, Deserialize, Default)]
522struct LiteLlmParams {
523    #[serde(default, deserialize_with = "deserialize_optional_string")]
524    model: Option<String>,
525    #[serde(default, deserialize_with = "deserialize_optional_string")]
526    api_base: Option<String>,
527    #[serde(default, deserialize_with = "deserialize_optional_string")]
528    api_key: Option<String>,
529    #[serde(default, deserialize_with = "deserialize_optional_string")]
530    api_version: Option<String>,
531    #[serde(default, deserialize_with = "deserialize_optional_string")]
532    custom_llm_provider: Option<String>,
533}
534
535fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
536where
537    D: Deserializer<'de>,
538{
539    let value = Option::<serde_yaml_ng::Value>::deserialize(deserializer)?;
540    Ok(value.and_then(yaml_value_to_string))
541}
542
543fn yaml_value_to_string(value: serde_yaml_ng::Value) -> Option<String> {
544    match value {
545        serde_yaml_ng::Value::Null => None,
546        serde_yaml_ng::Value::Bool(v) => Some(v.to_string()),
547        serde_yaml_ng::Value::Number(v) => Some(v.to_string()),
548        serde_yaml_ng::Value::String(v) => Some(v),
549        _ => None,
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn parse_empty_toml() {
559        let c: ConfigFile = toml::from_str("").unwrap();
560        assert!(c.env.is_empty());
561    }
562
563    #[test]
564    fn parse_env_section() {
565        let c: ConfigFile = toml::from_str(
566            r#"
567[env]
568ANTHROPIC_API_KEY = "sk-ant-test"
569YALLM_MODE = "proxy"
570"#,
571        )
572        .unwrap();
573        assert_eq!(c.env.get("ANTHROPIC_API_KEY").unwrap(), "sk-ant-test");
574        assert_eq!(c.env.get("YALLM_MODE").unwrap(), "proxy");
575    }
576
577    #[test]
578    fn dotenv_parses_simple_values() {
579        let mut warnings = Vec::new();
580        let env = parse_dotenv(
581            r#"TEST_DOTENV_KEY=dotenv_value
582ANOTHER_KEY="quoted val"
583"#,
584            &mut warnings,
585        );
586
587        assert_eq!(env.get("TEST_DOTENV_KEY").unwrap(), "dotenv_value");
588        assert_eq!(env.get("ANOTHER_KEY").unwrap(), "quoted val");
589        assert!(warnings.is_empty());
590    }
591
592    #[test]
593    fn litellm_parses_supported_models_and_env_key() {
594        let mut env = HashMap::new();
595        env.insert("OPENAI_KEY".to_string(), "sk-env".to_string());
596        let mut warnings = Vec::new();
597        let models = parse_litellm_config_str(
598            r#"
599model_list:
600  - model_name: gpt-alias
601    litellm_params:
602      model: openai/gpt-4o
603      api_base: https://openai-compatible.test/v1
604      api_key: os.environ/OPENAI_KEY
605      ignored_field: true
606  - model_name: claude-alias
607    litellm_params:
608      model: anthropic/claude-3-haiku-20240307
609      api_key: none
610  - model_name: llama-alias
611    litellm_params:
612      model: ollama/llama3
613"#,
614            &env,
615            &mut warnings,
616        );
617
618        assert_eq!(models.len(), 3);
619        assert_eq!(models[0].model_name, "gpt-alias");
620        assert_eq!(models[0].provider, LiteLlmProvider::OpenAI);
621        assert_eq!(models[0].upstream_model, "gpt-4o");
622        assert_eq!(models[0].api_key.as_deref(), Some("sk-env"));
623        assert_eq!(models[1].provider, LiteLlmProvider::Anthropic);
624        assert_eq!(models[1].api_key, None);
625        assert_eq!(models[2].provider, LiteLlmProvider::Ollama);
626        assert!(warnings.is_empty());
627    }
628
629    #[test]
630    fn litellm_warns_for_literal_api_key() {
631        let mut warnings = Vec::new();
632        let models = parse_litellm_config_str(
633            r#"
634model_list:
635  - model_name: literal
636    litellm_params:
637      model: gpt-4o
638      api_key: sk-literal
639"#,
640            &HashMap::new(),
641            &mut warnings,
642        );
643
644        assert_eq!(models.len(), 1);
645        assert_eq!(models[0].api_key.as_deref(), Some("sk-literal"));
646        assert!(warnings.iter().any(|w| w.contains("literal api_key")));
647    }
648
649    #[test]
650    fn litellm_missing_env_reference_leaves_key_unset() {
651        let mut warnings = Vec::new();
652        let models = parse_litellm_config_str(
653            r#"
654model_list:
655  - model_name: missing
656    litellm_params:
657      model: gpt-4o
658      api_key: ${MISSING_OPENAI_KEY}
659"#,
660            &HashMap::new(),
661            &mut warnings,
662        );
663
664        assert_eq!(models.len(), 1);
665        assert_eq!(models[0].api_key, None);
666        assert!(warnings.iter().any(|w| w.contains("MISSING_OPENAI_KEY")));
667    }
668
669    #[test]
670    fn litellm_skips_unsupported_and_duplicate_models() {
671        let mut warnings = Vec::new();
672        let models = parse_litellm_config_str(
673            r#"
674model_list:
675  - model_name: gpt
676    litellm_params:
677      model: gpt-4o
678  - model_name: gpt
679    litellm_params:
680      model: openai/gpt-4o-mini
681  - model_name: azure-gpt
682    litellm_params:
683      model: azure/gpt-4o
684      api_key: os.environ/AZURE_API_KEY
685      extra: ignored
686  - model_name: mixed
687    litellm_params:
688      model: azure/gpt-4o
689  - model_name: mixed
690    litellm_params:
691      model: gpt-4o
692"#,
693            &HashMap::new(),
694            &mut warnings,
695        );
696
697        assert_eq!(models.len(), 2);
698        assert_eq!(models[0].model_name, "gpt");
699        assert_eq!(models[1].model_name, "mixed");
700        assert!(warnings.iter().any(|w| w.contains("duplicate")));
701        assert!(warnings.iter().any(|w| w.contains("unsupported")));
702    }
703
704    #[test]
705    fn yallm_params_takes_priority_over_litellm_params() {
706        // Same entry has both blocks; yallm_params wins for every field.
707        let mut env = HashMap::new();
708        env.insert("YALLM_KEY".to_string(), "yallm-secret".to_string());
709        env.insert("LITELLM_KEY".to_string(), "litellm-secret".to_string());
710        let mut warnings = Vec::new();
711        let models = parse_litellm_config_str(
712            r#"
713model_list:
714  - model_name: dual
715    yallm_params:
716      provider: anthropic
717      model: claude-yallm
718      api_base: https://yallm.test
719      api_key: os.environ/YALLM_KEY
720      api_version: "2025-01-01"
721    litellm_params:
722      model: openai/gpt-litellm
723      api_base: https://litellm.test
724      api_key: os.environ/LITELLM_KEY
725      api_version: "2020-01-01"
726"#,
727            &env,
728            &mut warnings,
729        );
730
731        assert_eq!(models.len(), 1);
732        assert_eq!(models[0].provider, LiteLlmProvider::Anthropic);
733        assert_eq!(models[0].upstream_model, "claude-yallm");
734        assert_eq!(models[0].api_base.as_deref(), Some("https://yallm.test"));
735        assert_eq!(models[0].api_key.as_deref(), Some("yallm-secret"));
736        assert_eq!(models[0].api_version.as_deref(), Some("2025-01-01"));
737        assert!(warnings.is_empty());
738    }
739
740    #[test]
741    fn yallm_params_requires_explicit_provider() {
742        let mut warnings = Vec::new();
743        let models = parse_litellm_config_str(
744            r#"
745model_list:
746  - model_name: bad
747    yallm_params:
748      model: gpt-4o
749"#,
750            &HashMap::new(),
751            &mut warnings,
752        );
753
754        assert_eq!(models.len(), 0);
755        assert!(
756            warnings
757                .iter()
758                .any(|w| w.contains("missing yallm_params.provider"))
759        );
760    }
761
762    #[test]
763    fn yallm_params_rejects_unknown_provider() {
764        let mut warnings = Vec::new();
765        let models = parse_litellm_config_str(
766            r#"
767model_list:
768  - model_name: nope
769    yallm_params:
770      provider: bedrock
771      model: anthropic.claude-3
772"#,
773            &HashMap::new(),
774            &mut warnings,
775        );
776
777        assert_eq!(models.len(), 0);
778        assert!(warnings.iter().any(|w| w.contains("'bedrock'")));
779    }
780
781    #[test]
782    fn entry_without_yallm_params_falls_back_to_litellm_params() {
783        let mut warnings = Vec::new();
784        let models = parse_litellm_config_str(
785            r#"
786model_list:
787  - model_name: legacy
788    litellm_params:
789      model: anthropic/claude-x
790"#,
791            &HashMap::new(),
792            &mut warnings,
793        );
794
795        assert_eq!(models.len(), 1);
796        assert_eq!(models[0].provider, LiteLlmProvider::Anthropic);
797        assert_eq!(models[0].upstream_model, "claude-x");
798    }
799
800    #[test]
801    fn dotenv_does_not_override_existing_env() {
802        let dir = tmpdir("yallm_dotenv_no_override");
803        let env_path = dir.join(".env");
804        fs::write(&env_path, "FOO=from_dotenv\nBAR=from_dotenv\n").unwrap();
805
806        let mut env_map = HashMap::new();
807        env_map.insert("FOO".to_string(), "from_os".to_string());
808        let mut warnings = Vec::new();
809        apply_dotenv(&env_path, &mut env_map, &mut warnings);
810
811        assert_eq!(env_map.get("FOO").unwrap(), "from_os");
812        assert_eq!(env_map.get("BAR").unwrap(), "from_dotenv");
813        assert!(warnings.is_empty());
814        let _ = fs::remove_dir_all(&dir);
815    }
816
817    #[test]
818    fn toml_glob_applies_local_overrides_in_order() {
819        let dir = tmpdir("yallm_toml_glob");
820        fs::write(
821            dir.join("01.local.toml"),
822            "[env]\nFOO = \"layer1\"\nBAR = \"layer1\"\n",
823        )
824        .unwrap();
825        fs::write(dir.join("02.local.toml"), "[env]\nFOO = \"layer2\"\n").unwrap();
826        // Non-matching file ignored.
827        fs::write(dir.join("ignore.toml"), "[env]\nFOO = \"ignored\"\n").unwrap();
828
829        let mut env_map = HashMap::new();
830        let mut warnings = Vec::new();
831        apply_toml_glob(&dir, ".local.toml", &mut env_map, "local", &mut warnings);
832
833        assert_eq!(env_map.get("FOO").unwrap(), "layer2");
834        assert_eq!(env_map.get("BAR").unwrap(), "layer1");
835        assert!(warnings.is_empty());
836        let _ = fs::remove_dir_all(&dir);
837    }
838
839    #[test]
840    fn secrets_toml_overrides_config_toml() {
841        let dir = tmpdir("yallm_secrets_layer");
842        fs::write(
843            dir.join("config.toml"),
844            "[env]\nANTHROPIC_API_KEY = \"public\"\n",
845        )
846        .unwrap();
847        fs::write(
848            dir.join("secrets.toml"),
849            "[env]\nANTHROPIC_API_KEY = \"sk-real\"\n",
850        )
851        .unwrap();
852
853        let mut env_map = HashMap::new();
854        let mut warnings = Vec::new();
855        apply_toml_file(
856            &dir.join("config.toml"),
857            &mut env_map,
858            "project",
859            &mut warnings,
860        );
861        apply_toml_file(
862            &dir.join("secrets.toml"),
863            &mut env_map,
864            "secrets",
865            &mut warnings,
866        );
867
868        assert_eq!(env_map.get("ANTHROPIC_API_KEY").unwrap(), "sk-real");
869        let _ = fs::remove_dir_all(&dir);
870    }
871
872    fn tmpdir(name: &str) -> PathBuf {
873        let nonce = std::time::SystemTime::now()
874            .duration_since(std::time::UNIX_EPOCH)
875            .map(|d| d.as_nanos())
876            .unwrap_or(0);
877        let dir = env::temp_dir().join(format!("{name}_{nonce}_{}", std::process::id()));
878        fs::create_dir_all(&dir).unwrap();
879        dir
880    }
881}