1use std::path::PathBuf;
3
4use crate::config::{
5 atomic_write, delete_file, home_dir, sanitize_provider_name, write_json_file, write_text_file,
6};
7use crate::error::AppError;
8use serde_json::Value;
9use std::fs;
10use std::path::Path;
11use toml_edit::DocumentMut;
12
13pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
14
15const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
19 "amazon-bedrock",
20 "openai",
21 "ollama",
22 "lmstudio",
23 "oss",
24 "ollama-chat",
25];
26
27pub fn get_codex_config_dir() -> PathBuf {
31 if let Some(dir) = std::env::var_os("CODEX_HOME") {
32 let dir = PathBuf::from(dir);
33 if !dir.as_os_str().is_empty() && !dir.to_string_lossy().trim().is_empty() {
34 return expand_codex_config_dir(dir);
35 }
36 }
37
38 if let Some(custom) = crate::settings::get_codex_override_dir() {
39 return custom;
40 }
41
42 home_dir().expect("无法获取用户主目录").join(".codex")
43}
44
45fn expand_codex_config_dir(path: PathBuf) -> PathBuf {
46 let lossy = path.to_string_lossy();
47 if lossy == "~" {
48 return home_dir().unwrap_or(path);
49 }
50
51 if let Some(rest) = lossy
52 .strip_prefix("~/")
53 .or_else(|| lossy.strip_prefix("~\\"))
54 {
55 if let Some(home) = home_dir() {
56 return home.join(rest);
57 }
58 }
59
60 path
61}
62
63pub fn get_codex_auth_path() -> PathBuf {
65 get_codex_config_dir().join("auth.json")
66}
67
68pub fn get_codex_config_path() -> PathBuf {
70 get_codex_config_dir().join("config.toml")
71}
72
73pub fn get_codex_provider_paths(
75 provider_id: &str,
76 provider_name: Option<&str>,
77) -> (PathBuf, PathBuf) {
78 let base_name = provider_name
79 .map(sanitize_provider_name)
80 .unwrap_or_else(|| sanitize_provider_name(provider_id));
81
82 let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
83 let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
84
85 (auth_path, config_path)
86}
87
88pub fn delete_codex_provider_config(
90 provider_id: &str,
91 provider_name: &str,
92) -> Result<(), AppError> {
93 let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
94
95 delete_file(&auth_path).ok();
96 delete_file(&config_path).ok();
97
98 Ok(())
99}
100
101pub fn write_codex_live_atomic(
103 auth: &Value,
104 config_text_opt: Option<&str>,
105) -> Result<(), AppError> {
106 write_codex_live_atomic_optional_auth(Some(auth), config_text_opt)
107}
108
109pub fn write_codex_live_atomic_optional_auth(
110 auth: Option<&Value>,
111 config_text_opt: Option<&str>,
112) -> Result<(), AppError> {
113 let auth_path = get_codex_auth_path();
114 let config_path = get_codex_config_path();
115
116 if let Some(parent) = auth_path.parent() {
117 std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
118 }
119
120 let old_auth = if auth_path.exists() {
122 Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
123 } else {
124 None
125 };
126 let _old_config = if config_path.exists() {
127 Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
128 } else {
129 None
130 };
131
132 let cfg_text = match config_text_opt {
134 Some(s) => s.to_string(),
135 None => String::new(),
136 };
137 if !cfg_text.trim().is_empty() {
138 toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
139 }
140
141 if let Some(auth) = auth {
143 write_json_file(&auth_path, auth)?;
144 } else {
145 delete_file(&auth_path)?;
146 }
147
148 if let Err(e) = write_text_file(&config_path, &cfg_text) {
150 if let Some(bytes) = old_auth {
152 let _ = atomic_write(&auth_path, &bytes);
153 } else {
154 let _ = delete_file(&auth_path);
155 }
156 return Err(e);
157 }
158
159 Ok(())
160}
161
162pub fn read_codex_config_text() -> Result<String, AppError> {
164 let path = get_codex_config_path();
165 if path.exists() {
166 std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
167 } else {
168 Ok(String::new())
169 }
170}
171
172pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
174 if text.trim().is_empty() {
175 return Ok(());
176 }
177 toml::from_str::<toml::Table>(text)
178 .map(|_| ())
179 .map_err(|e| AppError::toml(Path::new("config.toml"), e))
180}
181
182pub fn strip_codex_provider_config_text(config_toml: &str) -> Result<String, AppError> {
188 let config_toml = config_toml.trim();
189 if config_toml.is_empty() {
190 return Ok(String::new());
191 }
192
193 let mut doc = config_toml
194 .parse::<toml_edit::DocumentMut>()
195 .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
196 let root = doc.as_table_mut();
197 root.remove("model");
198 root.remove("model_provider");
199 root.remove("base_url");
200 root.remove("model_providers");
201
202 let mut cleaned = String::new();
203 let mut blank_run = 0usize;
204 for line in doc.to_string().lines() {
205 if line.trim().is_empty() {
206 blank_run += 1;
207 if blank_run <= 1 {
208 cleaned.push('\n');
209 }
210 continue;
211 }
212 blank_run = 0;
213 cleaned.push_str(line);
214 cleaned.push('\n');
215 }
216
217 Ok(cleaned.trim().to_string())
218}
219
220pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
222 let s = read_codex_config_text()?;
223 validate_config_toml(&s)?;
224 Ok(s)
225}
226
227fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
228 doc.get("model_provider")
229 .and_then(|item| item.as_str())
230 .map(str::trim)
231 .filter(|id| !id.is_empty())
232 .map(str::to_string)
233}
234
235fn is_custom_codex_model_provider_id(id: &str) -> bool {
236 let id = id.trim();
237 !id.is_empty()
238 && !CODEX_RESERVED_MODEL_PROVIDER_IDS
239 .iter()
240 .any(|reserved| reserved.eq_ignore_ascii_case(id))
241}
242
243fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
244 let doc = config_text.parse::<DocumentMut>().ok()?;
245 let provider_id = active_codex_model_provider_id(&doc)?;
246
247 if is_custom_codex_model_provider_id(&provider_id) {
248 Some(provider_id)
249 } else {
250 None
251 }
252}
253
254fn codex_model_provider_id_with_table_from_config(
255 config_text: &str,
256) -> Result<Option<String>, AppError> {
257 if config_text.trim().is_empty() {
258 return Ok(None);
259 }
260
261 let doc = config_text
262 .parse::<DocumentMut>()
263 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
264 let Some(provider_id) = active_codex_model_provider_id(&doc) else {
265 return Ok(None);
266 };
267
268 let has_provider_table = doc
269 .get("model_providers")
270 .and_then(|item| item.as_table())
271 .and_then(|table| table.get(provider_id.as_str()))
272 .is_some();
273
274 Ok(has_provider_table.then_some(provider_id))
275}
276
277fn primary_codex_model_provider_id_with_table(doc: &DocumentMut) -> Option<String> {
278 if let Some(provider_id) = active_codex_model_provider_id(doc) {
279 let has_provider_table = doc
280 .get("model_providers")
281 .and_then(|item| item.as_table_like())
282 .and_then(|table| table.get(provider_id.as_str()))
283 .is_some();
284 if has_provider_table {
285 return Some(provider_id);
286 }
287 }
288
289 let providers = doc
290 .get("model_providers")
291 .and_then(|item| item.as_table_like())?;
292 let mut keys = providers.iter().map(|(key, _)| key.to_string());
293 let first = keys.next()?;
294 keys.next().is_none().then_some(first)
295}
296
297fn normalize_codex_live_config_model_provider_with_anchors<'a>(
298 config_text: &str,
299 anchor_config_texts: impl IntoIterator<Item = &'a str>,
300) -> Result<String, AppError> {
301 if config_text.trim().is_empty() {
302 return Ok(config_text.to_string());
303 }
304
305 let mut doc = config_text
306 .parse::<DocumentMut>()
307 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
308
309 let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
310 return Ok(config_text.to_string());
311 };
312
313 let has_source_provider_table = doc
314 .get("model_providers")
315 .and_then(|item| item.as_table())
316 .and_then(|table| table.get(source_provider_id.as_str()))
317 .is_some();
318 if !has_source_provider_table {
319 return Ok(config_text.to_string());
320 }
321
322 let stable_provider_id = anchor_config_texts
323 .into_iter()
324 .find_map(stable_codex_model_provider_id_from_config)
325 .or_else(|| {
326 is_custom_codex_model_provider_id(&source_provider_id)
327 .then(|| source_provider_id.clone())
328 })
329 .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
330
331 if stable_provider_id == source_provider_id {
332 return Ok(config_text.to_string());
333 }
334
335 if let Some(model_providers) = doc
336 .get_mut("model_providers")
337 .and_then(|item| item.as_table_mut())
338 {
339 let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
340 return Ok(config_text.to_string());
341 };
342 model_providers[stable_provider_id.as_str()] = provider_table;
343 }
344
345 rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
346 doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
347
348 Ok(doc.to_string())
349}
350
351fn rewrite_codex_profile_model_provider_refs(
352 doc: &mut DocumentMut,
353 source_provider_id: &str,
354 stable_provider_id: &str,
355) {
356 let Some(profiles) = doc
357 .get_mut("profiles")
358 .and_then(|item| item.as_table_like_mut())
359 else {
360 return;
361 };
362
363 let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
364 for profile_key in profile_keys {
365 let Some(profile_table) = profiles
366 .get_mut(&profile_key)
367 .and_then(|item| item.as_table_like_mut())
368 else {
369 continue;
370 };
371
372 let references_source = profile_table
373 .get("model_provider")
374 .and_then(|item| item.as_str())
375 == Some(source_provider_id);
376 if references_source {
377 profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
378 }
379 }
380}
381
382pub fn normalize_codex_settings_config_model_provider(
389 settings: &mut Value,
390 anchor_config_text: Option<&str>,
391) -> Result<(), AppError> {
392 let Some(config_text) = settings
393 .get("config")
394 .and_then(|value| value.as_str())
395 .map(str::to_string)
396 else {
397 return Ok(());
398 };
399
400 let current_config_text = read_codex_config_text().ok();
401 let anchors = anchor_config_text
402 .into_iter()
403 .chain(current_config_text.as_deref());
404 let normalized =
405 normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
406
407 if let Some(obj) = settings.as_object_mut() {
408 obj.insert("config".to_string(), Value::String(normalized));
409 }
410
411 Ok(())
412}
413
414fn restore_codex_backfill_model_provider_id(
415 config_text: &str,
416 template_config_text: &str,
417) -> Result<String, AppError> {
418 let Some(template_provider_id) =
419 codex_model_provider_id_with_table_from_config(template_config_text)?
420 else {
421 return Ok(config_text.to_string());
422 };
423
424 if config_text.trim().is_empty() {
425 return Ok(config_text.to_string());
426 }
427
428 let mut doc = config_text
429 .parse::<DocumentMut>()
430 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
431 let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
432 return Ok(config_text.to_string());
433 };
434
435 if live_provider_id == template_provider_id {
436 return Ok(config_text.to_string());
437 }
438
439 if let Some(model_providers) = doc
440 .get_mut("model_providers")
441 .and_then(|item| item.as_table_mut())
442 {
443 let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
444 return Ok(config_text.to_string());
445 };
446 model_providers[template_provider_id.as_str()] = provider_table;
447 } else {
448 return Ok(config_text.to_string());
449 }
450
451 rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
452 doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
453
454 Ok(doc.to_string())
455}
456
457pub fn restore_codex_settings_config_model_provider_for_backfill(
460 settings: &mut Value,
461 template_settings: &Value,
462) -> Result<(), AppError> {
463 let Some(config_text) = settings
464 .get("config")
465 .and_then(|value| value.as_str())
466 .map(str::to_string)
467 else {
468 return Ok(());
469 };
470 let Some(template_config_text) = template_settings
471 .get("config")
472 .and_then(|value| value.as_str())
473 else {
474 return Ok(());
475 };
476
477 let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?;
478 if let Some(obj) = settings.as_object_mut() {
479 obj.insert("config".to_string(), Value::String(restored));
480 }
481
482 Ok(())
483}
484
485pub fn rewrite_codex_config_model_provider_key(
491 config_text: &str,
492 target_provider_id: &str,
493) -> Result<String, AppError> {
494 if config_text.trim().is_empty() {
495 return Ok(String::new());
496 }
497
498 let target_provider_id = clean_codex_provider_key(target_provider_id);
499 let mut doc = config_text
500 .parse::<DocumentMut>()
501 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
502 let Some(source_provider_id) = primary_codex_model_provider_id_with_table(&doc) else {
503 return Ok(config_text.to_string());
504 };
505
506 if let Some(model_providers) = doc
507 .get_mut("model_providers")
508 .and_then(|item| item.as_table_mut())
509 {
510 if source_provider_id != target_provider_id {
511 let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
512 return Ok(config_text.to_string());
513 };
514 model_providers[target_provider_id.as_str()] = provider_table;
515 rewrite_codex_profile_model_provider_refs(
516 &mut doc,
517 &source_provider_id,
518 &target_provider_id,
519 );
520 }
521 } else {
522 return Ok(config_text.to_string());
523 }
524
525 doc["model_provider"] = toml_edit::value(target_provider_id.as_str());
526
527 Ok(doc.to_string())
528}
529
530pub fn write_codex_live_atomic_with_stable_provider(
535 auth: &Value,
536 config_text_opt: Option<&str>,
537) -> Result<(), AppError> {
538 write_codex_live_atomic_optional_auth_with_stable_provider(Some(auth), config_text_opt)
539}
540
541pub fn write_codex_live_atomic_optional_auth_with_stable_provider(
542 auth: Option<&Value>,
543 config_text_opt: Option<&str>,
544) -> Result<(), AppError> {
545 match config_text_opt {
546 Some(config_text) => {
547 let mut settings = serde_json::Map::new();
548 settings.insert("config".to_string(), Value::String(config_text.to_string()));
549 let mut settings = Value::Object(settings);
550 normalize_codex_settings_config_model_provider(&mut settings, None)?;
551 let config_text = settings
552 .get("config")
553 .and_then(|value| value.as_str())
554 .unwrap_or(config_text);
555 write_codex_live_atomic_optional_auth(auth, Some(config_text))
556 }
557 None => write_codex_live_atomic_optional_auth(auth, None),
558 }
559}
560
561pub fn clean_codex_provider_key(raw: &str) -> String {
566 let mut key: String = raw
567 .chars()
568 .map(|c| {
569 if c.is_ascii_alphanumeric() {
570 c.to_ascii_lowercase()
571 } else {
572 '_'
573 }
574 })
575 .collect();
576
577 while key.starts_with('_') {
578 key.remove(0);
579 }
580 while key.ends_with('_') {
581 key.pop();
582 }
583
584 if key.is_empty() {
585 "custom".to_string()
586 } else {
587 key
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594 use crate::app_config::AppType;
595 use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
596 use std::ffi::OsString;
597 use std::path::Path;
598
599 struct CodexHomeEnvGuard {
600 original: Option<OsString>,
601 }
602
603 impl CodexHomeEnvGuard {
604 fn new(value: Option<&str>) -> Self {
605 let original = std::env::var_os("CODEX_HOME");
606 match value {
607 Some(v) => unsafe { std::env::set_var("CODEX_HOME", v) },
608 None => unsafe { std::env::remove_var("CODEX_HOME") },
609 }
610 Self { original }
611 }
612 }
613
614 impl Drop for CodexHomeEnvGuard {
615 fn drop(&mut self) {
616 match self.original.as_ref() {
617 Some(value) => unsafe { std::env::set_var("CODEX_HOME", value) },
618 None => unsafe { std::env::remove_var("CODEX_HOME") },
619 }
620 }
621 }
622
623 struct SettingsGuard {
624 original: crate::settings::AppSettings,
625 }
626
627 impl SettingsGuard {
628 fn with_codex_config_dir(dir: Option<&str>) -> Self {
629 let original = crate::settings::get_settings();
630 let mut settings = original.clone();
631 settings.codex_config_dir = dir.map(str::to_string);
632 crate::settings::update_settings(settings).unwrap();
633 Self { original }
634 }
635 }
636
637 impl Drop for SettingsGuard {
638 fn drop(&mut self) {
639 let _ = crate::settings::update_settings(self.original.clone());
640 }
641 }
642
643 #[test]
644 fn get_codex_config_dir_respects_codex_home_env_var_and_tilde() {
645 let _guard = lock_test_home_and_settings();
646 let _settings = SettingsGuard::with_codex_config_dir(None);
647 let _env = CodexHomeEnvGuard::new(Some("~/.config/codex"));
648 set_test_home_override(Some(Path::new("/tmp/codex-home-tilde")));
649
650 assert_eq!(
651 get_codex_config_dir(),
652 PathBuf::from("/tmp/codex-home-tilde")
653 .join(".config")
654 .join("codex")
655 );
656
657 set_test_home_override(None);
658 }
659
660 #[test]
661 fn get_codex_config_dir_env_overrides_settings_override() {
662 let _guard = lock_test_home_and_settings();
663 let _settings = SettingsGuard::with_codex_config_dir(Some("/tmp/settings-codex"));
664 let _env = CodexHomeEnvGuard::new(Some("/tmp/env-codex"));
665 set_test_home_override(Some(Path::new("/tmp/codex-home")));
666
667 assert_eq!(get_codex_config_dir(), PathBuf::from("/tmp/env-codex"));
668
669 set_test_home_override(None);
670 }
671
672 #[test]
673 fn codex_live_sync_detects_initialized_codex_home_from_env() {
674 let _guard = lock_test_home_and_settings();
675 let _settings = SettingsGuard::with_codex_config_dir(None);
676 let env_home = PathBuf::from("/tmp/codex-live-sync-env");
677 let _env = CodexHomeEnvGuard::new(Some(env_home.to_str().unwrap()));
678 set_test_home_override(Some(Path::new("/tmp/codex-live-sync-home")));
679
680 std::fs::create_dir_all(&env_home).expect("create CODEX_HOME");
681
682 assert!(crate::sync_policy::should_sync_live(&AppType::Codex));
683
684 let _ = std::fs::remove_dir_all(&env_home);
685 set_test_home_override(None);
686 }
687
688 #[test]
689 fn normalize_live_config_preserves_current_custom_model_provider_id() {
690 let current = r#"model_provider = "rightcode"
691
692[model_providers.rightcode]
693name = "RightCode"
694base_url = "https://rightcode.example/v1"
695wire_api = "responses"
696"#;
697 let target = r#"model_provider = "aihubmix"
698model = "gpt-5.4"
699
700[model_providers.aihubmix]
701name = "AiHubMix"
702base_url = "https://aihubmix.example/v1"
703wire_api = "responses"
704requires_openai_auth = true
705
706[mcp_servers.context7]
707command = "npx"
708"#;
709
710 let result =
711 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
712 let parsed: toml::Value = toml::from_str(&result).unwrap();
713
714 assert_eq!(
715 parsed.get("model_provider").and_then(|v| v.as_str()),
716 Some("rightcode")
717 );
718
719 let model_providers = parsed
720 .get("model_providers")
721 .and_then(|v| v.as_table())
722 .expect("model_providers should exist");
723 assert!(
724 model_providers.get("aihubmix").is_none(),
725 "source provider id should not remain in live config"
726 );
727
728 let stable_provider = model_providers
729 .get("rightcode")
730 .expect("stable provider table should exist");
731 assert_eq!(
732 stable_provider.get("base_url").and_then(|v| v.as_str()),
733 Some("https://aihubmix.example/v1")
734 );
735 assert!(
736 parsed.get("mcp_servers").is_some(),
737 "unrelated config should be preserved"
738 );
739 }
740
741 #[test]
742 fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
743 let current = r#"model_provider = "openai""#;
744 let target = r#"model_provider = "aihubmix"
745
746[model_providers.aihubmix]
747name = "AiHubMix"
748base_url = "https://aihubmix.example/v1"
749wire_api = "responses"
750"#;
751
752 let result =
753 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
754 let parsed: toml::Value = toml::from_str(&result).unwrap();
755
756 assert_eq!(
757 parsed.get("model_provider").and_then(|v| v.as_str()),
758 Some("aihubmix")
759 );
760 assert!(
761 parsed
762 .get("model_providers")
763 .and_then(|v| v.get("aihubmix"))
764 .is_some(),
765 "target provider id should be kept when there is no reusable live custom id"
766 );
767 }
768
769 #[test]
770 fn normalize_live_config_leaves_official_empty_config_unchanged() {
771 let current = r#"model_provider = "rightcode"
772
773[model_providers.rightcode]
774base_url = "https://rightcode.example/v1"
775"#;
776
777 let result =
778 normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
779
780 assert_eq!(result, "");
781 }
782
783 #[test]
784 fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
785 let current = r#"model_provider = "session_anchor"
786
787[model_providers.session_anchor]
788name = "Session Anchor"
789base_url = "https://anchor.example/v1"
790wire_api = "responses"
791"#;
792 let target = r#"model_provider = "vendor_alpha"
793model = "gpt-5.4"
794profile = "work"
795
796[model_providers.vendor_alpha]
797name = "Vendor Alpha"
798base_url = "https://alpha.example/v1"
799wire_api = "responses"
800
801[profiles.work]
802model_provider = "vendor_alpha"
803model = "gpt-5.4"
804"#;
805
806 let result =
807 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
808 let parsed: toml::Value = toml::from_str(&result).unwrap();
809
810 assert_eq!(
811 parsed.get("model_provider").and_then(|v| v.as_str()),
812 Some("session_anchor")
813 );
814 assert_eq!(
815 parsed
816 .get("profiles")
817 .and_then(|v| v.get("work"))
818 .and_then(|v| v.get("model_provider"))
819 .and_then(|v| v.as_str()),
820 Some("session_anchor"),
821 "profile override matching the rewritten provider should stay valid"
822 );
823 }
824
825 #[test]
826 fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
827 let current = r#"model_provider = "session_anchor"
828
829[model_providers.session_anchor]
830name = "Session Anchor"
831base_url = "https://anchor.example/v1"
832wire_api = "responses"
833"#;
834 let target = r#"model_provider = "vendor_alpha"
835model = "gpt-5.4"
836
837[model_providers.vendor_alpha]
838name = "Vendor Alpha"
839base_url = "https://alpha.example/v1"
840wire_api = "responses"
841
842[model_providers.local_profile]
843name = "Local Profile"
844base_url = "http://localhost:11434/v1"
845wire_api = "responses"
846
847[profiles.local]
848model_provider = "local_profile"
849model = "local-model"
850"#;
851
852 let result =
853 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
854 let parsed: toml::Value = toml::from_str(&result).unwrap();
855
856 assert_eq!(
857 parsed
858 .get("profiles")
859 .and_then(|v| v.get("local"))
860 .and_then(|v| v.get("model_provider"))
861 .and_then(|v| v.as_str()),
862 Some("local_profile"),
863 "unrelated profile provider references should be preserved"
864 );
865 assert!(
866 parsed
867 .get("model_providers")
868 .and_then(|v| v.get("local_profile"))
869 .is_some(),
870 "unrelated provider tables should also remain available"
871 );
872 }
873
874 #[test]
875 fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
876 let anchor = r#"model_provider = "session_anchor"
877
878[model_providers.session_anchor]
879name = "Session Anchor"
880base_url = "https://anchor.example/v1"
881wire_api = "responses"
882"#;
883 let first_target = r#"model_provider = "vendor_alpha"
884
885[model_providers.vendor_alpha]
886name = "Vendor Alpha"
887base_url = "https://alpha.example/v1"
888wire_api = "responses"
889"#;
890 let second_target = r#"model_provider = "vendor_beta"
891
892[model_providers.vendor_beta]
893name = "Vendor Beta"
894base_url = "https://beta.example/v1"
895wire_api = "responses"
896"#;
897
898 let first =
899 normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
900 .unwrap();
901 let second = normalize_codex_live_config_model_provider_with_anchors(
902 second_target,
903 Some(first.as_str()),
904 )
905 .unwrap();
906 let parsed: toml::Value = toml::from_str(&second).unwrap();
907
908 assert_eq!(
909 parsed.get("model_provider").and_then(|v| v.as_str()),
910 Some("session_anchor"),
911 "stable provider id should not drift across repeated switches"
912 );
913 assert_eq!(
914 parsed
915 .get("model_providers")
916 .and_then(|v| v.get("session_anchor"))
917 .and_then(|v| v.get("base_url"))
918 .and_then(|v| v.as_str()),
919 Some("https://beta.example/v1")
920 );
921 }
922
923 #[test]
924 fn restore_backfill_config_rewrites_live_id_to_template_provider_id() {
925 let mut settings = serde_json::json!({
926 "config": r#"model_provider = "session_anchor"
927model = "gpt-5.4"
928profile = "work"
929
930[model_providers.session_anchor]
931name = "AiHubMix"
932base_url = "https://aihubmix.example/v1"
933wire_api = "responses"
934
935[profiles.work]
936model_provider = "session_anchor"
937model = "gpt-5.4"
938"#
939 });
940 let template = serde_json::json!({
941 "config": r#"model_provider = "aihubmix"
942
943[model_providers.aihubmix]
944name = "AiHubMix"
945base_url = "https://aihubmix.example/v1"
946"#
947 });
948
949 restore_codex_settings_config_model_provider_for_backfill(&mut settings, &template)
950 .unwrap();
951
952 let config = settings.get("config").and_then(Value::as_str).unwrap();
953 let parsed: toml::Value = toml::from_str(config).unwrap();
954 assert_eq!(
955 parsed.get("model_provider").and_then(|v| v.as_str()),
956 Some("aihubmix")
957 );
958 assert!(parsed
959 .get("model_providers")
960 .and_then(|v| v.get("aihubmix"))
961 .is_some());
962 assert_eq!(
963 parsed
964 .get("profiles")
965 .and_then(|v| v.get("work"))
966 .and_then(|v| v.get("model_provider"))
967 .and_then(|v| v.as_str()),
968 Some("aihubmix")
969 );
970 }
971}