Skip to main content

cc_switch_lib/
codex_config.rs

1// unused imports removed
2use 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
15/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
16/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
17/// removed provider aliases.
18const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
19    "amazon-bedrock",
20    "openai",
21    "ollama",
22    "lmstudio",
23    "oss",
24    "ollama-chat",
25];
26
27/// 获取 Codex 配置目录路径
28///
29/// Priority: `CODEX_HOME` env var > cc-switch settings override > `$HOME/.codex`
30pub 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
63/// 获取 Codex auth.json 路径
64pub fn get_codex_auth_path() -> PathBuf {
65    get_codex_config_dir().join("auth.json")
66}
67
68/// 获取 Codex config.toml 路径
69pub fn get_codex_config_path() -> PathBuf {
70    get_codex_config_dir().join("config.toml")
71}
72
73/// 获取 Codex 供应商配置文件路径
74pub 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
88/// 删除 Codex 供应商配置文件
89pub 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
101/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
102pub 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    // 读取旧内容用于回滚
121    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    // 准备写入内容
133    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    // 第一步:写 auth.json
142    if let Some(auth) = auth {
143        write_json_file(&auth_path, auth)?;
144    } else {
145        delete_file(&auth_path)?;
146    }
147
148    // 第二步:写 config.toml(失败则回滚 auth.json)
149    if let Err(e) = write_text_file(&config_path, &cfg_text) {
150        // 回滚 auth.json
151        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
162/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
163pub 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
172/// 对非空的 TOML 文本进行语法校验
173pub 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
182/// Remove provider-specific Codex TOML keys and keep only shared/global settings.
183///
184/// This matches upstream "OpenAI Official" snapshot semantics where the official
185/// provider does not persist a provider-local `base_url` / `model_provider`
186/// section, but may still carry root-level shared settings.
187pub 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
220/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
221pub 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
382/// Keep Codex's active `model_provider` stable across CC Switch provider changes.
383///
384/// Codex stores and filters resume history by `model_provider`, so switching between
385/// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move.
386/// We preserve an existing custom provider id when possible and only rewrite the
387/// live config text that Codex sees at provider-driven write boundaries.
388pub 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
457/// Convert a Codex live config that was normalized for history stability back
458/// to the provider-specific id used by the stored provider template.
459pub 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
485/// Rewrite a stored Codex provider snapshot to use a specific provider key.
486///
487/// This updates both the root `model_provider` and the matching
488/// `[model_providers.<key>]` table, while preserving the provider table body and
489/// profile references.
490pub 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
530/// Atomically write Codex live config after normalizing provider-specific ids.
531///
532/// Use this for provider-driven live writes. Keep `write_codex_live_atomic` available
533/// for exact restore/backup paths that must preserve the config text semantically as saved.
534pub 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
561/// Generate a clean TOML key from a raw string for use as `model_provider` and `[model_providers.<key>]`.
562///
563/// Lowercases ASCII alphanumerics, replaces everything else with `_`, trims leading/trailing `_`.
564/// Falls back to `"custom"` if the result is empty.
565pub 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}