Skip to main content

lucy/
config.rs

1use std::fs::{self, OpenOptions};
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7#[cfg(unix)]
8use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
9
10pub const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
11pub const DEFAULT_API_KEY_ENV: &str = "OPENAI_API_KEY";
12pub const GENERATED_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
13pub const CODEX_SUBSCRIPTION_PROVIDER: &str = "codex_subscription";
14pub const OPENROUTER_PROVIDER: &str = "openrouter";
15pub const CODEX_API_KEY_ENV_SENTINEL: &str = "LUCY_CODEX_SUBSCRIPTION_TOKEN";
16#[deprecated(note = "system prompts are Lucy-owned and this compatibility constant is ignored")]
17pub const DEFAULT_SYSTEM_PROMPT: &str = "You can access computer resources. Use the provided tools to achieve the user's requirements. When needed, use cmd to read a relevant skill's SKILL.md.";
18
19const GENERATED_CONFIG: &str = r#"[auth]
20provider = "openrouter"
21api_key_env = "OPENROUTER_API_KEY"
22
23[llm]
24base_url = "https://openrouter.ai/api/v1"
25model = ""
26# Optional reasoning effort sent as the OpenAI Chat Completions "reasoning_effort"
27# field, e.g. "low", "medium", "high". Omit or leave unset to send no effort.
28# Use a value your provider and model support; an unsupported value fails at runtime.
29# effort = "medium"
30"#;
31
32#[derive(Debug)]
33pub struct ConfigError(String);
34
35impl ConfigError {
36    fn new(message: impl Into<String>) -> Self {
37        Self(message.into())
38    }
39}
40
41impl std::fmt::Display for ConfigError {
42    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        formatter.write_str(&self.0)
44    }
45}
46
47impl std::error::Error for ConfigError {}
48
49impl From<io::Error> for ConfigError {
50    fn from(_error: io::Error) -> Self {
51        Self::new("configuration file error")
52    }
53}
54
55#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
56pub struct Config {
57    #[serde(skip)]
58    #[deprecated(note = "system prompts are Lucy-owned; this compatibility field is ignored")]
59    pub system_prompt: String,
60    #[serde(default)]
61    pub auth: AuthConfig,
62    #[serde(default)]
63    pub llm: LlmConfig,
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
67pub struct AuthConfig {
68    #[serde(default)]
69    pub provider: AuthProvider,
70    #[serde(default)]
71    pub api_key_env: Option<String>,
72}
73
74#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
75#[serde(rename_all = "snake_case")]
76pub enum AuthProvider {
77    #[default]
78    Openrouter,
79    CodexSubscription,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct AuthSettings {
84    pub provider: AuthProvider,
85    pub api_key_env: Option<String>,
86}
87
88#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
89pub struct LlmConfig {
90    #[serde(default = "default_base_url")]
91    pub base_url: String,
92    #[serde(default)]
93    pub model: String,
94    #[serde(default)]
95    pub api_key_env: Option<String>,
96    #[serde(default)]
97    pub effort: Option<String>,
98}
99
100#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
101pub struct LlmSettings {
102    pub base_url: String,
103    pub model: String,
104    pub api_key_env: String,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub effort: Option<String>,
107}
108
109impl Default for AuthConfig {
110    fn default() -> Self {
111        Self {
112            provider: AuthProvider::Openrouter,
113            api_key_env: None,
114        }
115    }
116}
117
118impl Default for LlmConfig {
119    fn default() -> Self {
120        Self {
121            base_url: DEFAULT_BASE_URL.to_owned(),
122            model: String::new(),
123            api_key_env: None,
124            effort: None,
125        }
126    }
127}
128
129fn default_base_url() -> String {
130    DEFAULT_BASE_URL.to_owned()
131}
132
133impl Config {
134    pub fn load_or_create(home: &Path) -> Result<Self, ConfigError> {
135        Self::ensure_exists(home)?;
136        Self::load_from_path(&config_path(home))
137    }
138
139    pub fn ensure_exists(home: &Path) -> Result<(), ConfigError> {
140        let path = config_path(home);
141        ensure_private_dir(&config_dir(home))?;
142        ensure_not_symlink(&path)?;
143
144        if !path.exists() {
145            migrate_legacy_config(home, &path)?;
146        }
147        if path.exists() {
148            ensure_private_file(&path)?;
149            return Ok(());
150        }
151        if generated_config_contains_active_key() {
152            return Err(ConfigError::new("configuration bootstrap rejected"));
153        }
154
155        let mut options = OpenOptions::new();
156        options.write(true).create_new(true);
157        #[cfg(unix)]
158        options.mode(0o600);
159        match options.open(&path) {
160            Ok(mut file) => {
161                file.write_all(GENERATED_CONFIG.as_bytes())?;
162                file.flush()?;
163                ensure_private_file(&path)?;
164                Ok(())
165            }
166            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
167                ensure_private_file(&path)?;
168                Ok(())
169            }
170            Err(_error) => Err(ConfigError::new("unable to create config.toml")),
171        }
172    }
173
174    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
175        ensure_not_symlink(path)
176            .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
177        let bytes = fs::read_to_string(path)
178            .map_err(|_error| ConfigError::new("unable to read config.toml"))?;
179        ensure_private_file(path)
180            .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
181        toml::from_str(&bytes)
182            .map_err(|_| ConfigError::new("unable to parse config.toml: invalid TOML"))
183    }
184
185    /// Update only the user-selected fields while preserving other TOML content.
186    pub fn save_selection(
187        home: &Path,
188        model: &str,
189        effort: Option<&str>,
190    ) -> Result<(), ConfigError> {
191        let path = config_path(home);
192        let source = fs::read_to_string(&path)
193            .map_err(|_| ConfigError::new("unable to read config.toml"))?;
194        let mut document: toml::Value = toml::from_str(&source)
195            .map_err(|_| ConfigError::new("unable to parse config.toml: invalid TOML"))?;
196        let llm = document
197            .as_table_mut()
198            .and_then(|root| root.get_mut("llm"))
199            .and_then(toml::Value::as_table_mut)
200            .ok_or_else(|| ConfigError::new("config.toml is missing [llm]"))?;
201        llm.insert("model".to_owned(), toml::Value::String(model.to_owned()));
202        match effort.filter(|value| !value.trim().is_empty()) {
203            Some(value) => {
204                llm.insert(
205                    "effort".to_owned(),
206                    toml::Value::String(value.trim().to_owned()),
207                );
208            }
209            None => {
210                llm.remove("effort");
211            }
212        }
213        let rendered = toml::to_string_pretty(&document)
214            .map_err(|_| ConfigError::new("unable to write config.toml"))?;
215        fs::write(&path, rendered).map_err(|_| ConfigError::new("unable to write config.toml"))?;
216        ensure_private_file(&path).map_err(|_| ConfigError::new("unable to secure config.toml"))
217    }
218
219    pub fn resolved_auth(&self) -> Result<AuthSettings, ConfigError> {
220        let legacy_api_key_env = self.llm.api_key_env.as_deref().map(str::trim);
221        let configured_api_key_env = self.auth.api_key_env.as_deref().map(str::trim);
222        if configured_api_key_env.is_some_and(str::is_empty) {
223            return Err(ConfigError::new("auth.api_key_env must not be empty"));
224        }
225        if legacy_api_key_env.is_some_and(str::is_empty) {
226            return Err(ConfigError::new("llm.api_key_env must not be empty"));
227        }
228        match self.auth.provider {
229            AuthProvider::Openrouter => {
230                if configured_api_key_env.is_some()
231                    && legacy_api_key_env.is_some()
232                    && configured_api_key_env != legacy_api_key_env
233                {
234                    return Err(ConfigError::new(
235                        "auth.api_key_env and llm.api_key_env must match",
236                    ));
237                }
238                let api_key_env = configured_api_key_env
239                    .or(legacy_api_key_env)
240                    .unwrap_or(DEFAULT_API_KEY_ENV)
241                    .to_owned();
242                if api_key_env == CODEX_API_KEY_ENV_SENTINEL {
243                    return Err(ConfigError::new(
244                        "auth.api_key_env is reserved for Codex subscription authentication",
245                    ));
246                }
247                Ok(AuthSettings {
248                    provider: AuthProvider::Openrouter,
249                    api_key_env: Some(api_key_env),
250                })
251            }
252            AuthProvider::CodexSubscription => {
253                if configured_api_key_env.is_some() || legacy_api_key_env.is_some() {
254                    return Err(ConfigError::new(
255                        "codex_subscription cannot be combined with an API-key environment",
256                    ));
257                }
258                Ok(AuthSettings {
259                    provider: AuthProvider::CodexSubscription,
260                    api_key_env: None,
261                })
262            }
263        }
264    }
265
266    pub fn resolved_llm(&self) -> Result<LlmSettings, ConfigError> {
267        let base_url = self.llm.base_url.trim().to_owned();
268        if base_url.is_empty() {
269            return Err(ConfigError::new("llm.base_url must not be empty"));
270        }
271
272        let auth = self.resolved_auth()?;
273        let api_key_env = match auth.provider {
274            AuthProvider::Openrouter => auth
275                .api_key_env
276                .unwrap_or_else(|| DEFAULT_API_KEY_ENV.to_owned()),
277            AuthProvider::CodexSubscription => CODEX_API_KEY_ENV_SENTINEL.to_owned(),
278        };
279
280        let effort = self.llm.effort.as_deref().map(str::trim).map(str::to_owned);
281
282        Ok(LlmSettings {
283            base_url,
284            model: self.llm.model.trim().to_owned(),
285            api_key_env,
286            effort,
287        })
288    }
289}
290
291/// Return Lucy's XDG configuration directory. An unset, empty, or relative
292/// `XDG_CONFIG_HOME` falls back to the standard `$HOME/.config` location.
293pub fn config_dir(home: &Path) -> PathBuf {
294    config_dir_from_xdg_home(home, std::env::var_os("XDG_CONFIG_HOME").as_deref())
295}
296
297fn config_dir_from_xdg_home(home: &Path, xdg_config_home: Option<&std::ffi::OsStr>) -> PathBuf {
298    xdg_config_home
299        .filter(|path| !path.is_empty())
300        .map(PathBuf::from)
301        .filter(|path| path.is_absolute())
302        .unwrap_or_else(|| home.join(".config"))
303        .join("lucy")
304}
305
306/// Lucy's legacy state directory. Sessions intentionally remain here while
307/// user-editable configuration moves to the XDG configuration directory.
308pub fn lucy_dir(home: &Path) -> PathBuf {
309    home.join(".lucy")
310}
311
312pub fn config_path(home: &Path) -> PathBuf {
313    config_dir(home).join("config.toml")
314}
315
316fn legacy_config_path(home: &Path) -> PathBuf {
317    home.join(".lucy").join("config.toml")
318}
319
320fn migrate_legacy_config(home: &Path, destination: &Path) -> Result<(), ConfigError> {
321    let legacy = legacy_config_path(home);
322    let legacy_dir = legacy.parent().expect("legacy config has a parent");
323    ensure_not_symlink(legacy_dir)
324        .map_err(|_error| ConfigError::new("unable to secure legacy config.toml"))?;
325
326    let metadata = match fs::symlink_metadata(&legacy) {
327        Ok(metadata) => metadata,
328        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
329        Err(_error) => return Err(ConfigError::new("unable to inspect legacy config.toml")),
330    };
331    if metadata.file_type().is_symlink() || !metadata.is_file() {
332        return Err(ConfigError::new("unable to secure legacy config.toml"));
333    }
334    let bytes = fs::read(&legacy)
335        .map_err(|_error| ConfigError::new("unable to read legacy config.toml"))?;
336    ensure_private_file(&legacy)
337        .map_err(|_error| ConfigError::new("unable to secure legacy config.toml"))?;
338
339    let mut options = OpenOptions::new();
340    options.write(true).create_new(true);
341    #[cfg(unix)]
342    options.mode(0o600);
343    let mut destination_file = options
344        .open(destination)
345        .map_err(|_error| ConfigError::new("unable to migrate legacy config.toml"))?;
346    destination_file
347        .write_all(&bytes)
348        .and_then(|()| destination_file.flush())
349        .map_err(|_error| ConfigError::new("unable to migrate legacy config.toml"))?;
350    ensure_private_file(destination)
351        .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
352    fs::remove_file(legacy)
353        .map_err(|_error| ConfigError::new("unable to remove legacy config.toml"))?;
354    Ok(())
355}
356
357fn generated_config_contains_active_key() -> bool {
358    std::env::var(GENERATED_API_KEY_ENV)
359        .ok()
360        .filter(|secret| !secret.is_empty())
361        .is_some_and(|secret| GENERATED_CONFIG.contains(&secret))
362}
363
364pub(crate) fn ensure_not_symlink(path: &Path) -> io::Result<()> {
365    match fs::symlink_metadata(path) {
366        Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new(
367            io::ErrorKind::InvalidInput,
368            "symlinks are not allowed for protected paths",
369        )),
370        Ok(_) => Ok(()),
371        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
372        Err(error) => Err(error),
373    }
374}
375
376pub(crate) fn ensure_private_dir(path: &Path) -> io::Result<()> {
377    ensure_not_symlink(path)?;
378    fs::create_dir_all(path)?;
379    let metadata = fs::symlink_metadata(path)?;
380    if metadata.file_type().is_symlink() || !metadata.is_dir() {
381        return Err(io::Error::new(
382            io::ErrorKind::InvalidInput,
383            "protected path is not a directory",
384        ));
385    }
386    #[cfg(unix)]
387    fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
388    Ok(())
389}
390
391pub(crate) fn ensure_private_file(path: &Path) -> io::Result<()> {
392    ensure_not_symlink(path)?;
393    #[cfg(unix)]
394    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
395    #[cfg(not(unix))]
396    let _ = path;
397    Ok(())
398}
399
400#[cfg(test)]
401#[allow(deprecated)]
402mod tests {
403    use super::*;
404    #[cfg(unix)]
405    use std::os::unix::fs::{symlink, PermissionsExt};
406    use std::sync::atomic::{AtomicU64, Ordering};
407    use std::time::{SystemTime, UNIX_EPOCH};
408
409    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
410
411    fn temporary_home() -> PathBuf {
412        loop {
413            let stamp = SystemTime::now()
414                .duration_since(UNIX_EPOCH)
415                .expect("clock")
416                .as_nanos();
417            let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
418            let path = std::env::temp_dir().join(format!(
419                "lucy-config-{stamp}-{}-{counter}",
420                std::process::id()
421            ));
422            match fs::create_dir(&path) {
423                Ok(()) => return path,
424                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
425                Err(error) => panic!("temp home: {error}"),
426            }
427        }
428    }
429
430    #[test]
431    fn bootstraps_config_without_overwriting_existing_bytes() {
432        let home = temporary_home();
433        let first = Config::load_or_create(&home).expect("create config");
434        assert_eq!(first.llm.model, "");
435        assert_eq!(first.llm.base_url, DEFAULT_BASE_URL);
436        assert_eq!(first.auth.provider, AuthProvider::Openrouter);
437        assert_eq!(
438            first.auth.api_key_env.as_deref(),
439            Some(GENERATED_API_KEY_ENV)
440        );
441
442        let path = config_path(&home);
443        let generated = fs::read(&path).expect("generated bytes");
444        #[cfg(unix)]
445        assert_eq!(
446            fs::metadata(&path)
447                .expect("config metadata")
448                .permissions()
449                .mode()
450                & 0o777,
451            0o600
452        );
453        #[cfg(unix)]
454        assert_eq!(
455            fs::metadata(config_dir(&home))
456                .expect("Lucy directory metadata")
457                .permissions()
458                .mode()
459                & 0o777,
460            0o700
461        );
462        let custom = b"system_prompt = \"custom\"\n[llm]\nmodel = \"local\"\n";
463        fs::write(&path, custom).expect("custom config");
464        let loaded = Config::load_or_create(&home).expect("load custom config");
465        assert_eq!(loaded.llm.model, "local");
466        assert_ne!(generated, custom);
467        assert_eq!(fs::read(path).expect("bytes after load"), custom);
468
469        fs::remove_dir_all(home).expect("remove temp home");
470    }
471
472    #[test]
473    fn generated_config_omits_system_prompt() {
474        let home = temporary_home();
475        Config::ensure_exists(&home).expect("generate config");
476        let generated = fs::read_to_string(config_path(&home)).expect("generated config");
477        let document: toml::Value = toml::from_str(&generated).expect("generated TOML");
478        assert!(document.get("system_prompt").is_none());
479        fs::remove_dir_all(home).expect("remove temp home");
480    }
481
482    #[test]
483    fn legacy_system_prompt_is_ignored_without_rewriting_config_bytes() {
484        let home = temporary_home();
485        let path = config_path(&home);
486        fs::create_dir_all(path.parent().expect("config parent")).expect("config parent");
487        let legacy = b"system_prompt = \"legacy sentinel\"\n\n[llm]\nmodel = \"legacy-model\"\n";
488        fs::write(&path, legacy).expect("legacy config");
489
490        let loaded = Config::load_or_create(&home).expect("load legacy config");
491        assert_eq!(loaded.system_prompt, "");
492        assert_eq!(loaded.llm.model, "legacy-model");
493        assert_eq!(fs::read(&path).expect("config bytes after load"), legacy);
494
495        fs::remove_dir_all(home).expect("remove temp home");
496    }
497
498    #[test]
499    fn settings_updates_preserve_legacy_system_prompt() {
500        let home = temporary_home();
501        let path = config_path(&home);
502        fs::create_dir_all(path.parent().expect("config parent")).expect("config parent");
503        fs::write(
504            &path,
505            "system_prompt = \"legacy sentinel\"\n\n[llm]\nmodel = \"old\"\n",
506        )
507        .expect("legacy config");
508
509        Config::save_selection(&home, "new", Some("high")).expect("save settings");
510        let updated = fs::read_to_string(&path).expect("updated config");
511        let document: toml::Value = toml::from_str(&updated).expect("updated TOML");
512        assert_eq!(
513            document.get("system_prompt").and_then(toml::Value::as_str),
514            Some("legacy sentinel")
515        );
516        assert_eq!(
517            document
518                .get("llm")
519                .and_then(|llm| llm.get("model"))
520                .and_then(toml::Value::as_str),
521            Some("new")
522        );
523
524        fs::remove_dir_all(home).expect("remove temp home");
525    }
526
527    #[cfg(unix)]
528    #[test]
529    fn rejects_symlinked_config_files_and_directories() {
530        let home = temporary_home();
531        let lucy = config_dir(&home);
532        fs::create_dir_all(&lucy).expect("Lucy directory");
533        let target = home.join("config-target.toml");
534        fs::write(&target, "system_prompt = \"target\"\n").expect("target config");
535        let path = config_path(&home);
536        symlink(&target, &path).expect("config symlink");
537        assert!(Config::load_or_create(&home).is_err());
538        assert!(Config::load_from_path(&path).is_err());
539        fs::remove_file(path).expect("remove config symlink");
540        fs::remove_dir(lucy).expect("remove Lucy directory");
541        fs::remove_file(target).expect("remove target config");
542        fs::remove_dir_all(&home).expect("remove temp home");
543
544        let home = temporary_home();
545        let target = home.join("lucy-target");
546        fs::create_dir(&target).expect("target directory");
547        fs::create_dir_all(config_dir(&home).parent().expect("config parent"))
548            .expect("config parent");
549        symlink(&target, config_dir(&home)).expect("Lucy directory symlink");
550        assert!(Config::ensure_exists(&home).is_err());
551        fs::remove_file(config_dir(&home)).expect("remove Lucy directory symlink");
552        fs::remove_dir(target).expect("remove target directory");
553        fs::remove_dir_all(home).expect("remove temp home");
554    }
555
556    #[test]
557    fn migrates_a_legacy_config_once_without_overwriting_xdg_config() {
558        let home = temporary_home();
559        let legacy = legacy_config_path(&home);
560        fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy parent");
561        let legacy_bytes = b"system_prompt = \"legacy\"\n[llm]\nmodel = \"old-model\"\n";
562        fs::write(&legacy, legacy_bytes).expect("legacy config");
563
564        let config = Config::load_or_create(&home).expect("migrate legacy config");
565        assert_eq!(config.llm.model, "old-model");
566        assert_eq!(
567            fs::read(config_path(&home)).expect("migrated bytes"),
568            legacy_bytes
569        );
570        assert!(!legacy.exists());
571
572        fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy parent");
573        fs::write(&legacy, b"system_prompt = \"stale\"\n").expect("stale legacy config");
574        let loaded = Config::load_or_create(&home).expect("retain XDG config");
575        assert_eq!(loaded.llm.model, "old-model");
576        assert_eq!(
577            fs::read(config_path(&home)).expect("XDG bytes"),
578            legacy_bytes
579        );
580        assert_eq!(
581            fs::read(&legacy).expect("legacy bytes"),
582            b"system_prompt = \"stale\"\n"
583        );
584
585        fs::remove_dir_all(home).expect("remove temp home");
586    }
587
588    #[test]
589    fn config_dir_uses_absolute_xdg_home_and_defaults_otherwise() {
590        let home = temporary_home();
591        let xdg_home = home.join("custom-xdg");
592        assert_eq!(
593            config_dir_from_xdg_home(&home, Some(xdg_home.as_os_str())),
594            xdg_home.join("lucy")
595        );
596        assert_eq!(
597            config_dir_from_xdg_home(&home, None),
598            home.join(".config/lucy")
599        );
600        assert_eq!(
601            config_dir_from_xdg_home(&home, Some(std::ffi::OsStr::new("relative"))),
602            home.join(".config/lucy")
603        );
604        fs::remove_dir_all(home).expect("remove temp home");
605    }
606
607    #[test]
608    fn malformed_toml_error_does_not_include_source_details() {
609        let home = temporary_home();
610        let path = config_path(&home);
611        fs::create_dir_all(path.parent().expect("config parent")).expect("config parent");
612        fs::write(
613            &path,
614            "system_prompt = \"provider-secret\n[llm]\nmodel = [\n",
615        )
616        .expect("malformed config");
617
618        let error = Config::load_from_path(&path).expect_err("malformed TOML");
619        let message = error.to_string();
620        assert!(message.contains("invalid TOML"));
621        assert!(!message.contains("provider-secret"));
622        assert!(!message.contains("system_prompt"));
623        assert!(!message.contains(&path.display().to_string()));
624        fs::remove_dir_all(home).expect("remove temp home");
625    }
626
627    #[test]
628    fn omitted_api_key_environment_uses_openai_default() {
629        let config = Config {
630            system_prompt: String::new(),
631            auth: AuthConfig::default(),
632            llm: LlmConfig {
633                base_url: "http://localhost".to_owned(),
634                model: "model".to_owned(),
635                api_key_env: None,
636                effort: None,
637            },
638        };
639        assert_eq!(
640            config.resolved_llm().expect("settings").api_key_env,
641            DEFAULT_API_KEY_ENV
642        );
643    }
644
645    #[test]
646    fn auth_provider_rejects_mixed_credentials() {
647        let config = Config {
648            system_prompt: String::new(),
649            auth: AuthConfig {
650                provider: AuthProvider::CodexSubscription,
651                api_key_env: None,
652            },
653            llm: LlmConfig {
654                base_url: DEFAULT_BASE_URL.to_owned(),
655                model: "model".to_owned(),
656                api_key_env: Some("OPENROUTER_API_KEY".to_owned()),
657                effort: None,
658            },
659        };
660        let error = config.resolved_auth().expect_err("mixed auth");
661        assert_eq!(
662            error.to_string(),
663            "codex_subscription cannot be combined with an API-key environment"
664        );
665    }
666
667    #[test]
668    fn openrouter_rejects_the_codex_auth_sentinel_environment() {
669        let config = Config {
670            system_prompt: String::new(),
671            auth: AuthConfig {
672                provider: AuthProvider::Openrouter,
673                api_key_env: Some(CODEX_API_KEY_ENV_SENTINEL.to_owned()),
674            },
675            llm: LlmConfig::default(),
676        };
677        assert!(config.resolved_auth().is_err());
678    }
679
680    #[test]
681    fn codex_auth_resolves_without_an_api_key_environment() {
682        let config = Config {
683            system_prompt: String::new(),
684            auth: AuthConfig {
685                provider: AuthProvider::CodexSubscription,
686                api_key_env: None,
687            },
688            llm: LlmConfig {
689                base_url: DEFAULT_BASE_URL.to_owned(),
690                model: "model".to_owned(),
691                api_key_env: None,
692                effort: None,
693            },
694        };
695        assert_eq!(
696            config.resolved_auth().expect("codex auth").provider,
697            AuthProvider::CodexSubscription
698        );
699    }
700
701    #[test]
702    fn resolved_effort_passes_through_and_trims() {
703        let config = |effort: Option<&str>| Config {
704            system_prompt: String::new(),
705            auth: AuthConfig::default(),
706            llm: LlmConfig {
707                base_url: "http://localhost".to_owned(),
708                model: "model".to_owned(),
709                api_key_env: Some("LUCY_KEY".to_owned()),
710                effort: effort.map(str::to_owned),
711            },
712        };
713        assert_eq!(config(None).resolved_llm().expect("none").effort, None);
714        assert_eq!(
715            config(Some("high"))
716                .resolved_llm()
717                .expect("set")
718                .effort
719                .as_deref(),
720            Some("high")
721        );
722        assert_eq!(
723            config(Some("  medium  "))
724                .resolved_llm()
725                .expect("trim")
726                .effort
727                .as_deref(),
728            Some("medium")
729        );
730    }
731}