Skip to main content

cc_switch_lib/services/provider/
codex.rs

1use super::*;
2use indexmap::IndexMap;
3use std::fs;
4use std::path::Path;
5use uuid::Uuid;
6
7#[derive(Debug, Clone)]
8struct LiveCodexCatalogProvider {
9    key: String,
10    name: String,
11    settings_config: Value,
12    is_active: bool,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum CodexImportMatchKind {
17    Key,
18    Name,
19}
20
21impl ProviderService {
22    pub(crate) fn capture_codex_temp_launch_snapshot(
23        state: &AppState,
24        provider_id: &str,
25        codex_home: &Path,
26    ) -> Result<(), AppError> {
27        let (provider, common_snippet) = {
28            let guard = state.config.read().map_err(AppError::from)?;
29            let provider = guard
30                .get_manager(&AppType::Codex)
31                .and_then(|manager| manager.providers.get(provider_id))
32                .cloned()
33                .ok_or_else(|| {
34                    AppError::localized(
35                        "provider.not_found",
36                        format!("供应商不存在: {provider_id}"),
37                        format!("Provider not found: {provider_id}"),
38                    )
39                })?;
40            (provider, guard.common_config_snippets.codex.clone())
41        };
42
43        let config_path = codex_home.join("config.toml");
44        let cfg_text = if config_path.exists() {
45            fs::read_to_string(&config_path).map_err(|err| AppError::io(&config_path, err))?
46        } else {
47            provider
48                .settings_config
49                .get("config")
50                .and_then(Value::as_str)
51                .unwrap_or_default()
52                .to_string()
53        };
54        crate::codex_config::validate_config_toml(&cfg_text)?;
55        let cfg_text_for_storage = Self::strip_codex_mcp_servers_from_snapshot_config(&cfg_text)?;
56
57        let auth_path = codex_home.join("auth.json");
58        let auth = if auth_path.exists() {
59            read_json_file::<Value>(&auth_path)?
60        } else {
61            Value::Object(serde_json::Map::new())
62        };
63
64        let mut raw_settings = serde_json::Map::new();
65        raw_settings.insert("auth".to_string(), auth);
66        raw_settings.insert("config".to_string(), Value::String(cfg_text_for_storage));
67
68        let mut settings_to_store = Self::normalize_settings_config_for_storage(
69            &AppType::Codex,
70            &provider,
71            Value::Object(raw_settings),
72            common_snippet.as_deref(),
73        )?;
74        Self::restore_codex_model_provider_for_storage_best_effort(
75            &provider,
76            &mut settings_to_store,
77        );
78
79        {
80            let mut guard = state.config.write().map_err(AppError::from)?;
81            if let Some(manager) = guard.get_manager_mut(&AppType::Codex) {
82                if let Some(target) = manager.providers.get_mut(provider_id) {
83                    target.settings_config = settings_to_store;
84                }
85            }
86        }
87
88        state.save()
89    }
90
91    pub(super) fn extract_codex_common_config_from_config_toml(
92        config_toml: &str,
93    ) -> Result<String, AppError> {
94        let config_toml = config_toml.trim();
95        if config_toml.is_empty() {
96            return Ok(String::new());
97        }
98
99        let mut doc = config_toml
100            .parse::<toml_edit::DocumentMut>()
101            .map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
102
103        // Remove provider-specific fields.
104        let root = doc.as_table_mut();
105        root.remove("model");
106        root.remove("model_provider");
107        // Legacy/alt formats might use a top-level base_url.
108        root.remove("base_url");
109        // Remove entire model_providers table (provider-specific configuration)
110        root.remove("model_providers");
111        // Codex writes trust decisions for local workspaces at runtime. These
112        // must stay with the provider snapshot being backfilled, not become
113        // common config that is merged into every provider.
114        root.remove("projects");
115        root.remove("trusted_workspaces");
116
117        // Clean up multiple empty lines (keep at most one blank line).
118        let mut cleaned = String::new();
119        let mut blank_run = 0usize;
120        for line in doc.to_string().lines() {
121            if line.trim().is_empty() {
122                blank_run += 1;
123                if blank_run <= 1 {
124                    cleaned.push('\n');
125                }
126                continue;
127            }
128            blank_run = 0;
129            cleaned.push_str(line);
130            cleaned.push('\n');
131        }
132
133        Ok(cleaned.trim().to_string())
134    }
135
136    pub(super) fn maybe_update_codex_common_config_snippet(
137        config: &mut MultiAppConfig,
138        config_toml: &str,
139    ) -> Result<(), AppError> {
140        let existing = config
141            .common_config_snippets
142            .codex
143            .as_deref()
144            .unwrap_or_default()
145            .trim();
146        if !existing.is_empty() {
147            return Ok(());
148        }
149
150        let extracted = Self::extract_codex_common_config_from_config_toml(config_toml)?;
151        if extracted.trim().is_empty() {
152            return Ok(());
153        }
154
155        config.common_config_snippets.codex = Some(extracted.clone());
156        Self::normalize_existing_provider_snapshots_for_storage_best_effort(
157            config,
158            &AppType::Codex,
159            Some(extracted.as_str()),
160        );
161        Ok(())
162    }
163
164    pub(super) fn strip_codex_mcp_servers_from_snapshot_config(
165        config_toml: &str,
166    ) -> Result<String, AppError> {
167        let config_toml = config_toml.trim();
168        if config_toml.is_empty() {
169            return Ok(String::new());
170        }
171
172        let mut doc = config_toml
173            .parse::<toml_edit::DocumentMut>()
174            .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
175        let root = doc.as_table_mut();
176        root.remove("mcp_servers");
177
178        if let Some(mcp_item) = root.get_mut("mcp") {
179            if let Some(mcp_table) = mcp_item.as_table_like_mut() {
180                mcp_table.remove("servers");
181                if mcp_table.iter().next().is_none() {
182                    root.remove("mcp");
183                }
184            }
185        }
186
187        Ok(doc.to_string())
188    }
189
190    fn codex_provider_key_from_config_text(config_toml: &str) -> Option<String> {
191        let doc = config_toml.parse::<toml_edit::DocumentMut>().ok()?;
192        let provider_key = doc
193            .get("model_provider")
194            .and_then(|value| value.as_str())
195            .map(str::trim)
196            .filter(|value| !value.is_empty())?;
197
198        doc.get("model_providers")
199            .and_then(|value| value.as_table_like())
200            .and_then(|providers| providers.get(provider_key))
201            .map(|_| provider_key.to_string())
202    }
203
204    pub(super) fn provider_codex_model_provider_key(provider: &Provider) -> Option<String> {
205        provider
206            .meta
207            .as_ref()
208            .and_then(|meta| meta.codex_model_provider_key.as_deref())
209            .map(str::trim)
210            .filter(|value| !value.is_empty())
211            .map(str::to_string)
212            .or_else(|| {
213                provider
214                    .settings_config
215                    .get("config")
216                    .and_then(Value::as_str)
217                    .and_then(Self::codex_provider_key_from_config_text)
218            })
219    }
220
221    fn compact_codex_key_suffix(raw: &str) -> String {
222        raw.chars()
223            .filter(|ch| ch.is_ascii_alphanumeric())
224            .map(|ch| ch.to_ascii_lowercase())
225            .take(8)
226            .collect()
227    }
228
229    fn unique_codex_provider_key_for_conflict(
230        provider: &Provider,
231        occupied: &std::collections::HashSet<String>,
232        conflicting_key: &str,
233    ) -> String {
234        let mut candidates = Vec::new();
235        for raw in [provider.id.trim(), provider.name.trim()] {
236            if raw.is_empty() {
237                continue;
238            }
239            let candidate = crate::codex_config::clean_codex_provider_key(raw);
240            if candidate != conflicting_key && !candidates.contains(&candidate) {
241                candidates.push(candidate);
242            }
243        }
244
245        if candidates.is_empty() {
246            let suffix = Self::compact_codex_key_suffix(&provider.id);
247            if !suffix.is_empty() {
248                candidates.push(format!("{conflicting_key}_{suffix}"));
249            }
250        }
251
252        let base = candidates
253            .first()
254            .cloned()
255            .unwrap_or_else(|| format!("{conflicting_key}_provider"));
256        let suffix = Self::compact_codex_key_suffix(&provider.id);
257        if !suffix.is_empty() {
258            let suffixed = format!("{base}_{suffix}");
259            if !candidates.contains(&suffixed) {
260                candidates.push(suffixed);
261            }
262        }
263
264        for candidate in candidates {
265            if !occupied.contains(&candidate) && candidate != conflicting_key {
266                return candidate;
267            }
268        }
269
270        let mut index = 2usize;
271        loop {
272            let candidate = format!("{base}_{index}");
273            if !occupied.contains(&candidate) && candidate != conflicting_key {
274                return candidate;
275            }
276            index += 1;
277        }
278    }
279
280    fn rewrite_provider_codex_model_provider_key(
281        provider: &mut Provider,
282        target_key: &str,
283    ) -> Result<bool, AppError> {
284        let target_key = crate::codex_config::clean_codex_provider_key(target_key);
285        let current_key = Self::provider_codex_model_provider_key(provider);
286        let mut changed = current_key.as_deref() != Some(target_key.as_str());
287
288        if let Some(config_text) = provider
289            .settings_config
290            .get("config")
291            .and_then(Value::as_str)
292            .map(str::to_string)
293        {
294            let rewritten = crate::codex_config::rewrite_codex_config_model_provider_key(
295                &config_text,
296                &target_key,
297            )?;
298            if rewritten != config_text {
299                changed = true;
300                if let Some(settings_obj) = provider.settings_config.as_object_mut() {
301                    settings_obj.insert("config".to_string(), Value::String(rewritten));
302                }
303            }
304        }
305
306        provider
307            .meta
308            .get_or_insert_with(Default::default)
309            .codex_model_provider_key = Some(target_key);
310
311        Ok(changed)
312    }
313
314    fn repair_conflicting_custom_codex_provider_keys(
315        manager: &mut crate::provider::ProviderManager,
316    ) -> bool {
317        let provider_ids = manager.providers.keys().cloned().collect::<Vec<_>>();
318        let mut key_groups = std::collections::HashMap::<String, Vec<String>>::new();
319        for provider_id in &provider_ids {
320            let Some(provider) = manager.providers.get(provider_id) else {
321                continue;
322            };
323            if Self::is_codex_official_provider(provider) {
324                continue;
325            }
326            let Some(key) = Self::provider_codex_model_provider_key(provider) else {
327                continue;
328            };
329            key_groups.entry(key).or_default().push(provider_id.clone());
330        }
331
332        let mut occupied = key_groups
333            .keys()
334            .cloned()
335            .collect::<std::collections::HashSet<_>>();
336        let mut changed = false;
337        for (key, provider_ids) in key_groups {
338            if key != "custom" || provider_ids.len() < 2 {
339                continue;
340            }
341
342            for provider_id in provider_ids {
343                let Some(provider) = manager.providers.get_mut(&provider_id) else {
344                    continue;
345                };
346                let new_key =
347                    Self::unique_codex_provider_key_for_conflict(provider, &occupied, &key);
348                match Self::rewrite_provider_codex_model_provider_key(provider, &new_key) {
349                    Ok(true) => {
350                        log::warn!(
351                            "auto-repaired conflicting Codex provider key for '{}' from '{}' to '{}'",
352                            provider_id,
353                            key,
354                            new_key
355                        );
356                        occupied.insert(new_key);
357                        changed = true;
358                    }
359                    Ok(false) => {
360                        occupied.insert(new_key);
361                    }
362                    Err(err) => {
363                        log::warn!(
364                            "skip auto-repair for conflicting Codex provider '{}' (key '{}'): {}",
365                            provider_id,
366                            key,
367                            err
368                        );
369                    }
370                }
371            }
372        }
373
374        changed
375    }
376
377    fn collect_codex_providers_for_live_sync(config: &mut MultiAppConfig) -> (Vec<Provider>, bool) {
378        let Some(manager) = config.get_manager_mut(&AppType::Codex) else {
379            return (Vec::new(), false);
380        };
381
382        let repaired = Self::repair_conflicting_custom_codex_provider_keys(manager);
383        let providers = manager.providers.values().cloned().collect::<Vec<_>>();
384        (providers, repaired)
385    }
386
387    fn codex_catalog_entry_from_provider(
388        provider: &Provider,
389    ) -> Result<Option<(String, toml_edit::Item)>, AppError> {
390        let Some(config_toml) = provider
391            .settings_config
392            .get("config")
393            .and_then(Value::as_str)
394        else {
395            return Ok(None);
396        };
397        if config_toml.trim().is_empty() {
398            return Ok(None);
399        }
400
401        let doc = config_toml.parse::<toml_edit::DocumentMut>().map_err(|e| {
402            AppError::Config(format!(
403                "Codex provider '{}' TOML 无法解析: {e}",
404                provider.id
405            ))
406        })?;
407
408        let configured_key = Self::provider_codex_model_provider_key(provider);
409        let model_providers = doc
410            .get("model_providers")
411            .and_then(|item| item.as_table_like());
412        let source_key = configured_key
413            .as_deref()
414            .filter(|key| {
415                model_providers
416                    .and_then(|providers| providers.get(*key))
417                    .is_some()
418            })
419            .map(str::to_string)
420            .or_else(|| Self::codex_provider_key_from_config_text(config_toml))
421            .or_else(|| {
422                model_providers.and_then(|providers| {
423                    let mut keys = providers.iter().map(|(key, _)| key.to_string());
424                    let first = keys.next()?;
425                    keys.next().is_none().then_some(first)
426                })
427            });
428
429        let Some(source_key) = source_key else {
430            return Ok(None);
431        };
432        let resolved_key = configured_key.unwrap_or_else(|| source_key.clone());
433        let Some(provider_item) = model_providers.and_then(|providers| providers.get(&source_key))
434        else {
435            return Ok(None);
436        };
437
438        Ok(Some((resolved_key, provider_item.clone())))
439    }
440
441    fn codex_catalog_item_signature(item: &toml_edit::Item) -> String {
442        item.to_string()
443            .lines()
444            .map(str::trim_end)
445            .collect::<Vec<_>>()
446            .join("\n")
447            .trim()
448            .to_string()
449    }
450
451    fn merge_codex_catalog_into_config_text(
452        base_config_toml: &str,
453        catalog_entries: &IndexMap<String, toml_edit::Item>,
454        stale_keys: &[String],
455    ) -> Result<String, AppError> {
456        let mut doc = if base_config_toml.trim().is_empty() {
457            toml_edit::DocumentMut::new()
458        } else {
459            base_config_toml
460                .parse::<toml_edit::DocumentMut>()
461                .map_err(|e| AppError::Config(format!("Codex live config TOML 无法解析: {e}")))?
462        };
463
464        if doc.get("model_providers").is_none() {
465            doc["model_providers"] = toml_edit::Item::Table(toml_edit::Table::new());
466        }
467        let providers = doc["model_providers"].as_table_like_mut().ok_or_else(|| {
468            AppError::Config("Codex live `model_providers` 必须是 TOML table".into())
469        })?;
470
471        for stale_key in stale_keys {
472            providers.remove(stale_key);
473        }
474        for (key, item) in catalog_entries {
475            providers.insert(key, item.clone());
476        }
477
478        if providers.iter().next().is_none() {
479            doc.as_table_mut().remove("model_providers");
480        }
481
482        Ok(doc.to_string())
483    }
484
485    fn sync_codex_provider_catalog_entries_to_live(
486        providers: &[Provider],
487        stale_keys: &[String],
488    ) -> Result<(), AppError> {
489        let mut catalog_entries = IndexMap::new();
490        let mut owners = std::collections::HashMap::<String, String>::new();
491        for provider in providers {
492            if Self::is_codex_official_provider(provider) {
493                continue;
494            }
495            let catalog_entry = match Self::codex_catalog_entry_from_provider(provider) {
496                Ok(entry) => entry,
497                Err(err) => {
498                    log::warn!(
499                        "skip syncing broken Codex provider snapshot '{}' into live catalog: {err}",
500                        provider.id
501                    );
502                    continue;
503                }
504            };
505            let Some((key, item)) = catalog_entry else {
506                continue;
507            };
508            if let Some(previous_owner) = owners.insert(key.clone(), provider.id.clone()) {
509                return Err(AppError::Config(format!(
510                    "Codex provider key 冲突: `{key}` 同时属于 `{previous_owner}` 和 `{}`",
511                    provider.id
512                )));
513            }
514            catalog_entries.insert(key, item);
515        }
516
517        let auth = if get_codex_auth_path().exists() {
518            Some(read_json_file::<Value>(&get_codex_auth_path())?)
519        } else {
520            None
521        };
522        let current_text = crate::codex_config::read_and_validate_codex_config_text()?;
523        let merged_text = Self::merge_codex_catalog_into_config_text(
524            &current_text,
525            &catalog_entries,
526            stale_keys,
527        )?;
528        if merged_text == current_text {
529            return Ok(());
530        }
531
532        crate::codex_config::write_codex_live_atomic_optional_auth(
533            auth.as_ref(),
534            Some(&merged_text),
535        )
536    }
537
538    pub(super) fn sync_codex_provider_catalog_to_live(
539        state: &AppState,
540        stale_keys: &[String],
541    ) -> Result<(), AppError> {
542        let (providers, repaired) = {
543            let mut guard = state.config.write().map_err(AppError::from)?;
544            Self::collect_codex_providers_for_live_sync(&mut guard)
545        };
546        if repaired {
547            state.save()?;
548        }
549        Self::sync_codex_provider_catalog_entries_to_live(&providers, stale_keys)
550    }
551
552    pub(crate) fn sync_codex_provider_catalog_to_live_from_config(
553        config: &mut MultiAppConfig,
554        stale_keys: &[String],
555    ) -> Result<(), AppError> {
556        let (providers, _repaired) = Self::collect_codex_providers_for_live_sync(config);
557        Self::sync_codex_provider_catalog_entries_to_live(&providers, stale_keys)
558    }
559
560    fn build_codex_catalog_snapshot_config(
561        provider_key: &str,
562        provider_item: &toml_edit::Item,
563        model: Option<&str>,
564    ) -> String {
565        let mut doc = toml_edit::DocumentMut::new();
566        doc["model_provider"] = toml_edit::value(provider_key);
567        if let Some(model) = model.map(str::trim).filter(|value| !value.is_empty()) {
568            doc["model"] = toml_edit::value(model);
569        }
570        doc["model_providers"] = toml_edit::Item::Table(toml_edit::Table::new());
571        if let Some(providers) = doc["model_providers"].as_table_like_mut() {
572            providers.insert(provider_key, provider_item.clone());
573        }
574        doc.to_string().trim().to_string()
575    }
576
577    fn parse_codex_catalog_from_live(
578        config_toml: &str,
579        auth: &Value,
580    ) -> Result<Vec<LiveCodexCatalogProvider>, AppError> {
581        if config_toml.trim().is_empty() {
582            return Ok(Vec::new());
583        }
584
585        let doc = config_toml
586            .parse::<toml_edit::DocumentMut>()
587            .map_err(|e| AppError::Config(format!("Codex live config TOML 无法解析: {e}")))?;
588        let active_key = doc
589            .get("model_provider")
590            .and_then(|value| value.as_str())
591            .map(str::trim)
592            .filter(|value| !value.is_empty())
593            .map(str::to_string);
594        let model = doc
595            .get("model")
596            .and_then(|value| value.as_str())
597            .map(str::to_string);
598        let Some(model_providers) = doc
599            .get("model_providers")
600            .and_then(|item| item.as_table_like())
601        else {
602            return Ok(Vec::new());
603        };
604        let canonical_active_key = active_key.as_ref().and_then(|active_key| {
605            let active_item = model_providers.get(active_key.as_str())?;
606            let active_signature = Self::codex_catalog_item_signature(active_item);
607            model_providers.iter().find_map(|(key, item)| {
608                (key != active_key && Self::codex_catalog_item_signature(item) == active_signature)
609                    .then(|| key.to_string())
610            })
611        });
612        let effective_active_key = canonical_active_key.as_deref().or(active_key.as_deref());
613
614        let mut providers = Vec::new();
615        for (key, item) in model_providers.iter() {
616            if active_key.as_deref() == Some(key) && canonical_active_key.is_some() {
617                continue;
618            }
619            let name = item
620                .as_table_like()
621                .and_then(|table| table.get("name"))
622                .and_then(|value| value.as_str())
623                .map(str::trim)
624                .filter(|value| !value.is_empty())
625                .unwrap_or(key)
626                .to_string();
627            let is_active = effective_active_key == Some(key);
628            let auth_value = if is_active {
629                auth.clone()
630            } else {
631                Value::Object(serde_json::Map::new())
632            };
633            providers.push(LiveCodexCatalogProvider {
634                key: key.to_string(),
635                name,
636                settings_config: json!({
637                    "auth": auth_value,
638                    "config": Self::build_codex_catalog_snapshot_config(key, item, model.as_deref()),
639                }),
640                is_active,
641            });
642        }
643
644        Ok(providers)
645    }
646
647    fn find_codex_import_target(
648        manager: &crate::provider::ProviderManager,
649        entry: &LiveCodexCatalogProvider,
650    ) -> Result<Option<(String, CodexImportMatchKind)>, ()> {
651        let key_matches = manager
652            .providers
653            .values()
654            .filter(|provider| {
655                Self::provider_codex_model_provider_key(provider).as_deref()
656                    == Some(entry.key.as_str())
657            })
658            .map(|provider| provider.id.clone())
659            .collect::<Vec<_>>();
660        match key_matches.len() {
661            0 => {}
662            1 => return Ok(Some((key_matches[0].clone(), CodexImportMatchKind::Key))),
663            _ => return Err(()),
664        }
665
666        let normalized_name = entry.name.trim();
667        if normalized_name.is_empty() {
668            return Ok(None);
669        }
670        let name_matches = manager
671            .providers
672            .values()
673            .filter(|provider| provider.name.trim() == normalized_name)
674            .map(|provider| provider.id.clone())
675            .collect::<Vec<_>>();
676        match name_matches.len() {
677            0 => Ok(None),
678            1 => Ok(Some((name_matches[0].clone(), CodexImportMatchKind::Name))),
679            _ => Err(()),
680        }
681    }
682
683    pub fn import_codex_providers_from_live(
684        state: &AppState,
685    ) -> Result<CodexImportReport, AppError> {
686        let auth = if get_codex_auth_path().exists() {
687            read_json_file::<Value>(&get_codex_auth_path())?
688        } else {
689            Value::Object(serde_json::Map::new())
690        };
691        let config_toml = crate::codex_config::read_and_validate_codex_config_text()?;
692        let live_providers = Self::parse_codex_catalog_from_live(&config_toml, &auth)?;
693        if live_providers.is_empty() {
694            let imported = Self::import_default_config(state, AppType::Codex)?;
695            return Ok(CodexImportReport {
696                created: usize::from(imported),
697                used_default_fallback: imported,
698                ..CodexImportReport::default()
699            });
700        }
701
702        let preserved_current = [AppType::Codex];
703        Self::run_transaction_preserving_current_providers(
704            state,
705            &preserved_current,
706            move |config| {
707                config.ensure_app(&AppType::Codex);
708                let manager = config
709                    .get_manager_mut(&AppType::Codex)
710                    .ok_or_else(|| Self::app_not_found(&AppType::Codex))?;
711                let mut report = CodexImportReport::default();
712
713                for entry in &live_providers {
714                    let target = match Self::find_codex_import_target(manager, entry) {
715                        Ok(target) => target,
716                        Err(()) => {
717                            report.conflicts += 1;
718                            continue;
719                        }
720                    };
721
722                    let mut settings_config = entry.settings_config.clone();
723                    let auth_is_empty = settings_config
724                        .get("auth")
725                        .and_then(Value::as_object)
726                        .is_some_and(|value| value.is_empty());
727
728                    match target {
729                        Some((provider_id, match_kind)) => {
730                            let existing = manager
731                                .providers
732                                .get(&provider_id)
733                                .cloned()
734                                .ok_or_else(|| {
735                                    AppError::localized(
736                                        "provider.not_found",
737                                        format!("供应商不存在: {provider_id}"),
738                                        format!("Provider not found: {provider_id}"),
739                                    )
740                                })?;
741
742                            if auth_is_empty {
743                                if let Some(existing_auth) =
744                                    existing.settings_config.get("auth").cloned()
745                                {
746                                    if let Some(obj) = settings_config.as_object_mut() {
747                                        obj.insert("auth".to_string(), existing_auth);
748                                    }
749                                }
750                            }
751
752                            let mut merged = existing.clone();
753                            merged.settings_config = settings_config;
754                            merged
755                                .meta
756                                .get_or_insert_with(Default::default)
757                                .codex_model_provider_key = Some(entry.key.clone());
758                            manager.providers.insert(provider_id.clone(), merged);
759
760                            match match_kind {
761                                CodexImportMatchKind::Key => report.merged_by_key += 1,
762                                CodexImportMatchKind::Name => report.merged_by_name += 1,
763                            }
764                        }
765                        None => {
766                            let mut provider = Provider::with_id(
767                                Uuid::new_v4().to_string(),
768                                entry.name.clone(),
769                                settings_config,
770                                None,
771                            );
772                            provider.category = Some("custom".to_string());
773                            provider.created_at = Some(current_timestamp());
774                            provider
775                                .meta
776                                .get_or_insert_with(Default::default)
777                                .codex_model_provider_key = Some(entry.key.clone());
778                            manager.providers.insert(provider.id.clone(), provider);
779                            report.created += 1;
780                        }
781                    }
782
783                    if !entry.is_active && auth_is_empty {
784                        report.needs_auth += 1;
785                    }
786                }
787
788                Ok((report, None))
789            },
790        )
791    }
792
793    #[cfg(test)]
794    pub(super) fn strip_toml_tables(dst: &mut toml_edit::Table, src: &toml_edit::Table) {
795        let mut keys_to_remove = Vec::new();
796
797        for (key, src_item) in src.iter() {
798            let Some(dst_item) = dst.get_mut(key) else {
799                continue;
800            };
801
802            match (dst_item, src_item) {
803                (toml_edit::Item::Table(dst_table), toml_edit::Item::Table(src_table)) => {
804                    Self::strip_toml_tables(dst_table, src_table);
805                    if dst_table.is_empty() {
806                        keys_to_remove.push(key.to_string());
807                    }
808                }
809                (dst_item, src_item) => {
810                    if Self::toml_items_equal(dst_item, src_item) {
811                        keys_to_remove.push(key.to_string());
812                    }
813                }
814            }
815        }
816
817        for key in keys_to_remove {
818            dst.remove(&key);
819        }
820    }
821
822    #[cfg(test)]
823    fn toml_items_equal(left: &toml_edit::Item, right: &toml_edit::Item) -> bool {
824        match (left.as_value(), right.as_value()) {
825            (Some(left_value), Some(right_value)) => {
826                left_value.to_string().trim() == right_value.to_string().trim()
827            }
828            _ => left.to_string().trim() == right.to_string().trim(),
829        }
830    }
831
832    pub(super) fn strip_common_codex_config_from_provider(
833        provider: &mut Provider,
834        common_config_snippet: Option<&str>,
835    ) -> Result<(), AppError> {
836        common_config::normalize_provider_common_config_for_storage(
837            &AppType::Codex,
838            provider,
839            common_config_snippet,
840        )
841    }
842
843    pub(super) fn migrate_codex_common_config_snippet(
844        config: &mut MultiAppConfig,
845        strict_current_provider_id: Option<&str>,
846        old_snippet: &str,
847    ) -> Result<(), AppError> {
848        let old_snippet = old_snippet.trim();
849        if old_snippet.is_empty() {
850            return Ok(());
851        }
852
853        let Some(current_provider_id) = strict_current_provider_id.and_then(|provider_id| {
854            config.get_manager(&AppType::Codex).and_then(|manager| {
855                manager
856                    .providers
857                    .contains_key(provider_id)
858                    .then(|| provider_id.to_string())
859            })
860        }) else {
861            let Some(manager) = config.get_manager_mut(&AppType::Codex) else {
862                return Ok(());
863            };
864
865            for provider in manager.providers.values_mut() {
866                Self::strip_common_codex_config_from_provider(provider, Some(old_snippet))?;
867            }
868
869            return Ok(());
870        };
871
872        let Some(manager) = config.get_manager_mut(&AppType::Codex) else {
873            return Ok(());
874        };
875
876        if let Some(current_provider) = manager.providers.get_mut(&current_provider_id) {
877            Self::strip_common_codex_config_from_provider(current_provider, Some(old_snippet))?;
878        }
879
880        for (provider_id, provider) in manager.providers.iter_mut() {
881            if provider_id == &current_provider_id {
882                continue;
883            }
884
885            if let Err(err) =
886                Self::strip_common_codex_config_from_provider(provider, Some(old_snippet))
887            {
888                log::warn!(
889                    "skip migrating Codex non-current provider snapshot '{provider_id}' from stored common config snippet: {err}"
890                );
891            }
892        }
893
894        Ok(())
895    }
896
897    pub(super) fn prepare_switch_codex(
898        config: &mut MultiAppConfig,
899        provider_id: &str,
900        effective_current_provider: Option<&str>,
901    ) -> Result<Provider, AppError> {
902        let provider = config
903            .get_manager(&AppType::Codex)
904            .ok_or_else(|| Self::app_not_found(&AppType::Codex))?
905            .providers
906            .get(provider_id)
907            .cloned()
908            .ok_or_else(|| {
909                AppError::localized(
910                    "provider.not_found",
911                    format!("供应商不存在: {provider_id}"),
912                    format!("Provider not found: {provider_id}"),
913                )
914            })?;
915
916        Self::backfill_codex_current(config, provider_id, effective_current_provider)?;
917
918        if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
919            manager.current = provider_id.to_string();
920        }
921
922        Ok(provider)
923    }
924
925    pub(super) fn backfill_codex_current(
926        config: &mut MultiAppConfig,
927        next_provider: &str,
928        effective_current_provider: Option<&str>,
929    ) -> Result<(), AppError> {
930        let current_id = effective_current_provider.unwrap_or_default();
931
932        if current_id.is_empty() || current_id == next_provider {
933            return Ok(());
934        }
935
936        let auth_path = get_codex_auth_path();
937        let config_path = get_codex_config_path();
938        if !auth_path.exists() && !config_path.exists() {
939            return Ok(());
940        }
941
942        let current_provider = config
943            .get_manager(&AppType::Codex)
944            .and_then(|manager| manager.providers.get(current_id))
945            .cloned();
946        let Some(current_provider) = current_provider else {
947            return Ok(());
948        };
949
950        // Read auth from disk; if absent, fall back to the DB snapshot's auth
951        // so that WebDAV-synced credentials are not overwritten with empty data.
952        let auth = if auth_path.exists() {
953            Some(read_json_file::<Value>(&auth_path)?)
954        } else {
955            current_provider.settings_config.get("auth").cloned()
956        };
957
958        let mut settings_config = if config_path.exists() {
959            let text =
960                std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
961            Self::maybe_update_codex_common_config_snippet(config, &text)?;
962
963            let mut raw_settings = serde_json::Map::new();
964            if let Some(auth) = auth.clone() {
965                raw_settings.insert("auth".to_string(), auth);
966            }
967            raw_settings.insert("config".to_string(), Value::String(text));
968            Self::normalize_settings_config_for_storage(
969                &AppType::Codex,
970                &current_provider,
971                Value::Object(raw_settings),
972                config.common_config_snippets.codex.as_deref(),
973            )?
974        } else {
975            let mut raw_settings = serde_json::Map::new();
976            if let Some(auth) = auth.clone() {
977                raw_settings.insert("auth".to_string(), auth);
978            }
979            Value::Object(raw_settings)
980        };
981        Self::restore_codex_model_provider_for_storage_best_effort(
982            &current_provider,
983            &mut settings_config,
984        );
985
986        if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
987            if let Some(current) = manager.providers.get_mut(current_id) {
988                current.settings_config = settings_config;
989            }
990        }
991
992        Ok(())
993    }
994
995    /// Write Codex live configuration.
996    ///
997    /// Aligned with upstream: the stored `settings_config.config` is the full config.toml text.
998    /// We write it directly to `~/.codex/config.toml`, optionally merging the common config snippet.
999    /// Auth is handled separately via auth.json.
1000    pub(super) fn write_codex_live(
1001        provider: &Provider,
1002        common_config_snippet: Option<&str>,
1003        apply_common_config: bool,
1004    ) -> Result<(), AppError> {
1005        if !crate::sync_policy::should_sync_live(&AppType::Codex) {
1006            return Ok(());
1007        }
1008
1009        let effective = Self::build_effective_live_snapshot(
1010            &AppType::Codex,
1011            provider,
1012            common_config_snippet,
1013            apply_common_config,
1014        )?;
1015        let settings = effective
1016            .as_object()
1017            .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
1018
1019        let auth = settings
1020            .get("auth")
1021            .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
1022        let cfg_text = settings
1023            .get("config")
1024            .and_then(Value::as_str)
1025            .ok_or_else(|| {
1026                AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
1027            })?;
1028
1029        let auth_to_write = if Self::is_codex_official_provider(provider)
1030            && auth.as_object().is_some_and(|auth| auth.is_empty())
1031        {
1032            None
1033        } else {
1034            Some(auth)
1035        };
1036        crate::codex_config::write_codex_live_atomic_optional_auth_with_stable_provider(
1037            auth_to_write,
1038            Some(cfg_text),
1039        )?;
1040
1041        Ok(())
1042    }
1043}