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 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
291pub 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
306pub 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}