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            crate::codex_config::codex_config_text_from_settings(&provider.settings_config)?
48        };
49        crate::codex_config::validate_config_toml(&cfg_text)?;
50        let cfg_text_for_storage =
51            Self::strip_codex_runtime_local_keys_from_snapshot_config(&cfg_text)?;
52
53        let auth_path = codex_home.join("auth.json");
54        let auth = if auth_path.exists() {
55            read_json_file::<Value>(&auth_path)?
56        } else {
57            Value::Object(serde_json::Map::new())
58        };
59
60        let raw_settings = crate::codex_config::codex_settings_snapshot_from_toml(
61            Some(auth),
62            &cfg_text_for_storage,
63        )?;
64
65        let mut settings_to_store = Self::normalize_settings_config_for_storage(
66            &AppType::Codex,
67            &provider,
68            raw_settings,
69            common_snippet.as_deref(),
70        )?;
71        Self::restore_codex_model_provider_for_storage_best_effort(
72            &provider,
73            &mut settings_to_store,
74        );
75
76        {
77            let mut guard = state.config.write().map_err(AppError::from)?;
78            if let Some(manager) = guard.get_manager_mut(&AppType::Codex) {
79                if let Some(target) = manager.providers.get_mut(provider_id) {
80                    target.settings_config = settings_to_store;
81                }
82            }
83        }
84
85        state.save()
86    }
87
88    pub(super) fn extract_codex_common_config_from_config_toml(
89        config_toml: &str,
90    ) -> Result<String, AppError> {
91        let config_toml = config_toml.trim();
92        if config_toml.is_empty() {
93            return Ok(String::new());
94        }
95
96        let mut doc = config_toml
97            .parse::<toml_edit::DocumentMut>()
98            .map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
99
100        // Remove provider-specific fields.
101        let root = doc.as_table_mut();
102        root.remove("model");
103        root.remove("model_provider");
104        root.remove("profile");
105        // Legacy/alt formats might use a top-level base_url.
106        root.remove("base_url");
107        // Remove entire model_providers table (provider-specific configuration)
108        root.remove("model_providers");
109        // Profiles can reference provider-specific model_provider keys and must
110        // stay with the provider snapshot.
111        root.remove("profiles");
112        // Codex writes trust decisions for local workspaces at runtime. These
113        // must stay with the provider snapshot being backfilled, not become
114        // common config that is merged into every provider.
115        root.remove("projects");
116        root.remove("trusted_workspaces");
117
118        // Clean up multiple empty lines (keep at most one blank line).
119        let mut cleaned = String::new();
120        let mut blank_run = 0usize;
121        for line in doc.to_string().lines() {
122            if line.trim().is_empty() {
123                blank_run += 1;
124                if blank_run <= 1 {
125                    cleaned.push('\n');
126                }
127                continue;
128            }
129            blank_run = 0;
130            cleaned.push_str(line);
131            cleaned.push('\n');
132        }
133
134        Ok(cleaned.trim().to_string())
135    }
136
137    pub(super) fn maybe_update_codex_common_config_snippet(
138        config: &mut MultiAppConfig,
139        config_toml: &str,
140    ) -> Result<(), AppError> {
141        let existing = config
142            .common_config_snippets
143            .codex
144            .as_deref()
145            .unwrap_or_default()
146            .trim();
147        if !existing.is_empty() {
148            return Ok(());
149        }
150
151        let extracted = Self::extract_codex_common_config_from_config_toml(config_toml)?;
152        if extracted.trim().is_empty() {
153            return Ok(());
154        }
155
156        config.common_config_snippets.codex = Some(extracted.clone());
157        Self::normalize_existing_provider_snapshots_for_storage_best_effort(
158            config,
159            &AppType::Codex,
160            Some(extracted.as_str()),
161        );
162        Ok(())
163    }
164
165    pub(super) fn strip_codex_runtime_local_keys_from_snapshot_config(
166        config_toml: &str,
167    ) -> Result<String, AppError> {
168        let config_toml = config_toml.trim();
169        if config_toml.is_empty() {
170            return Ok(String::new());
171        }
172
173        let mut doc = config_toml
174            .parse::<toml_edit::DocumentMut>()
175            .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
176        let root = doc.as_table_mut();
177        root.remove("mcp_servers");
178        root.remove("projects");
179        root.remove("trusted_workspaces");
180
181        if let Some(mcp_item) = root.get_mut("mcp") {
182            if let Some(mcp_table) = mcp_item.as_table_like_mut() {
183                mcp_table.remove("servers");
184                if mcp_table.iter().next().is_none() {
185                    root.remove("mcp");
186                }
187            }
188        }
189
190        Ok(doc.to_string())
191    }
192
193    fn codex_provider_key_from_config_text(config_toml: &str) -> Option<String> {
194        let doc = config_toml.parse::<toml_edit::DocumentMut>().ok()?;
195        let provider_key = doc
196            .get("model_provider")
197            .and_then(|value| value.as_str())
198            .map(str::trim)
199            .filter(|value| !value.is_empty())?;
200
201        doc.get("model_providers")
202            .and_then(|value| value.as_table_like())
203            .and_then(|providers| providers.get(provider_key))
204            .map(|_| provider_key.to_string())
205    }
206
207    pub(super) fn provider_codex_model_provider_key(provider: &Provider) -> Option<String> {
208        provider
209            .meta
210            .as_ref()
211            .and_then(|meta| meta.codex_model_provider_key.as_deref())
212            .map(str::trim)
213            .filter(|value| !value.is_empty())
214            .map(str::to_string)
215            .or_else(|| {
216                crate::codex_config::codex_config_text_from_settings(&provider.settings_config)
217                    .ok()
218                    .and_then(|config| Self::codex_provider_key_from_config_text(&config))
219            })
220    }
221
222    fn provider_codex_model_provider_name(provider: &Provider) -> Option<String> {
223        let config =
224            crate::codex_config::codex_config_text_from_settings(&provider.settings_config).ok()?;
225        let key = Self::provider_codex_model_provider_key(provider)?;
226        let doc = config.parse::<toml_edit::DocumentMut>().ok()?;
227        doc.get("model_providers")
228            .and_then(|item| item.as_table_like())
229            .and_then(|providers| providers.get(&key))
230            .and_then(|item| item.as_table_like())
231            .and_then(|provider| provider.get("name"))
232            .and_then(|value| value.as_str())
233            .map(str::trim)
234            .filter(|name| !name.is_empty())
235            .map(str::to_string)
236    }
237
238    fn compact_codex_key_suffix(raw: &str) -> String {
239        raw.chars()
240            .filter(|ch| ch.is_ascii_alphanumeric())
241            .map(|ch| ch.to_ascii_lowercase())
242            .take(8)
243            .collect()
244    }
245
246    fn unique_codex_provider_key_for_conflict(
247        provider: &Provider,
248        occupied: &std::collections::HashSet<String>,
249        conflicting_key: &str,
250    ) -> String {
251        let mut candidates = Vec::new();
252        for raw in [provider.id.trim(), provider.name.trim()] {
253            if raw.is_empty() {
254                continue;
255            }
256            let candidate = crate::codex_config::clean_codex_provider_key(raw);
257            if candidate != conflicting_key && !candidates.contains(&candidate) {
258                candidates.push(candidate);
259            }
260        }
261
262        if candidates.is_empty() {
263            let suffix = Self::compact_codex_key_suffix(&provider.id);
264            if !suffix.is_empty() {
265                candidates.push(format!("{conflicting_key}_{suffix}"));
266            }
267        }
268
269        let base = candidates
270            .first()
271            .cloned()
272            .unwrap_or_else(|| format!("{conflicting_key}_provider"));
273        let suffix = Self::compact_codex_key_suffix(&provider.id);
274        if !suffix.is_empty() {
275            let suffixed = format!("{base}_{suffix}");
276            if !candidates.contains(&suffixed) {
277                candidates.push(suffixed);
278            }
279        }
280
281        for candidate in candidates {
282            if !occupied.contains(&candidate) && candidate != conflicting_key {
283                return candidate;
284            }
285        }
286
287        let mut index = 2usize;
288        loop {
289            let candidate = format!("{base}_{index}");
290            if !occupied.contains(&candidate) && candidate != conflicting_key {
291                return candidate;
292            }
293            index += 1;
294        }
295    }
296
297    fn rewrite_provider_codex_model_provider_key(
298        provider: &mut Provider,
299        target_key: &str,
300    ) -> Result<bool, AppError> {
301        let target_key = crate::codex_config::clean_codex_provider_display_key(target_key);
302        let current_key = Self::provider_codex_model_provider_key(provider);
303        let mut changed = current_key.as_deref() != Some(target_key.as_str());
304
305        if let Ok(config_text) =
306            crate::codex_config::codex_config_text_from_settings(&provider.settings_config)
307        {
308            let rewritten = crate::codex_config::rewrite_codex_config_model_provider_key(
309                &config_text,
310                &target_key,
311            )?;
312            if rewritten != config_text {
313                changed = true;
314                let prefer_structured = provider
315                    .settings_config
316                    .get(crate::codex_config::CODEX_STRUCTURED_CONFIG_KEY)
317                    .is_some()
318                    && provider
319                        .settings_config
320                        .get(crate::codex_config::CODEX_LEGACY_CONFIG_KEY)
321                        .is_none();
322                crate::codex_config::set_codex_config_text_in_settings(
323                    &mut provider.settings_config,
324                    &rewritten,
325                    prefer_structured,
326                )?;
327            }
328        }
329
330        provider
331            .meta
332            .get_or_insert_with(Default::default)
333            .codex_model_provider_key = Some(target_key);
334
335        Ok(changed)
336    }
337
338    pub(super) fn reconcile_codex_provider_name_key(
339        manager: &crate::provider::ProviderManager,
340        existing: &Provider,
341        updated: &mut Provider,
342    ) -> Result<(), AppError> {
343        if Self::is_codex_official_provider(updated) {
344            return Ok(());
345        }
346
347        let Some(old_key) = Self::provider_codex_model_provider_key(existing) else {
348            return Ok(());
349        };
350        let updated_name = updated.name.trim().to_string();
351        if updated_name.is_empty() {
352            return Ok(());
353        }
354        let target_key = crate::codex_config::clean_codex_provider_display_key(&updated_name);
355        if old_key == target_key {
356            return Ok(());
357        }
358
359        let display_name_changed = existing.name.trim() != updated.name.trim();
360        let table_name_is_default_key =
361            Self::provider_codex_model_provider_name(existing).as_deref() == Some(old_key.as_str());
362        let key_followed_old_display_name =
363            old_key == crate::codex_config::clean_codex_provider_display_key(existing.name.trim());
364        let should_rewrite = (display_name_changed && key_followed_old_display_name)
365            || (!display_name_changed && table_name_is_default_key);
366        if !should_rewrite {
367            return Ok(());
368        }
369
370        if let Some(owner) = manager.providers.values().find(|provider| {
371            provider.id != updated.id
372                && Self::provider_codex_model_provider_key(provider).as_deref()
373                    == Some(target_key.as_str())
374        }) {
375            return Err(AppError::Config(format!(
376                "Codex provider key `{target_key}` is already owned by `{}`",
377                owner.id
378            )));
379        }
380
381        Self::rewrite_provider_codex_model_provider_key(updated, &target_key)?;
382        let config =
383            crate::codex_config::codex_config_text_from_settings(&updated.settings_config)?;
384        let renamed = crate::codex_config::rewrite_codex_config_model_provider_name(
385            &config,
386            &target_key,
387            &updated_name,
388        )?;
389        let prefer_structured = updated
390            .settings_config
391            .get(crate::codex_config::CODEX_STRUCTURED_CONFIG_KEY)
392            .is_some()
393            && updated
394                .settings_config
395                .get(crate::codex_config::CODEX_LEGACY_CONFIG_KEY)
396                .is_none();
397        crate::codex_config::set_codex_config_text_in_settings(
398            &mut updated.settings_config,
399            &renamed,
400            prefer_structured,
401        )?;
402
403        Ok(())
404    }
405
406    fn repair_conflicting_custom_codex_provider_keys(
407        manager: &mut crate::provider::ProviderManager,
408    ) -> bool {
409        let provider_ids = manager.providers.keys().cloned().collect::<Vec<_>>();
410        let mut key_groups = std::collections::HashMap::<String, Vec<String>>::new();
411        for provider_id in &provider_ids {
412            let Some(provider) = manager.providers.get(provider_id) else {
413                continue;
414            };
415            if Self::is_codex_official_provider(provider) {
416                continue;
417            }
418            let Some(key) = Self::provider_codex_model_provider_key(provider) else {
419                continue;
420            };
421            key_groups.entry(key).or_default().push(provider_id.clone());
422        }
423
424        let mut occupied = key_groups
425            .keys()
426            .cloned()
427            .collect::<std::collections::HashSet<_>>();
428        let mut changed = false;
429        for (key, provider_ids) in key_groups {
430            if key != "custom" || provider_ids.len() < 2 {
431                continue;
432            }
433
434            for provider_id in provider_ids {
435                let Some(provider) = manager.providers.get_mut(&provider_id) else {
436                    continue;
437                };
438                let new_key =
439                    Self::unique_codex_provider_key_for_conflict(provider, &occupied, &key);
440                match Self::rewrite_provider_codex_model_provider_key(provider, &new_key) {
441                    Ok(true) => {
442                        log::warn!(
443                            "auto-repaired conflicting Codex provider key for '{}' from '{}' to '{}'",
444                            provider_id,
445                            key,
446                            new_key
447                        );
448                        occupied.insert(new_key);
449                        changed = true;
450                    }
451                    Ok(false) => {
452                        occupied.insert(new_key);
453                    }
454                    Err(err) => {
455                        log::warn!(
456                            "skip auto-repair for conflicting Codex provider '{}' (key '{}'): {}",
457                            provider_id,
458                            key,
459                            err
460                        );
461                    }
462                }
463            }
464        }
465
466        changed
467    }
468
469    fn current_live_codex_anchor_key() -> Option<String> {
470        let config_text = match crate::codex_config::read_codex_config_text() {
471            Ok(text) => text,
472            Err(err) => {
473                log::warn!("skip Codex live-anchor repair: failed to read live config: {err}");
474                return None;
475            }
476        };
477
478        Self::codex_provider_key_from_config_text(&config_text)
479    }
480
481    fn repair_live_anchor_conflicting_codex_provider_keys(
482        manager: &mut crate::provider::ProviderManager,
483        live_anchor_key: Option<&str>,
484    ) -> bool {
485        let Some(live_anchor_key) = live_anchor_key
486            .map(str::trim)
487            .filter(|value| !value.is_empty())
488        else {
489            return false;
490        };
491        let current_provider_id = manager.current.trim().to_string();
492        if current_provider_id.is_empty() {
493            return false;
494        }
495
496        let provider_ids = manager.providers.keys().cloned().collect::<Vec<_>>();
497        let mut occupied = manager
498            .providers
499            .values()
500            .filter_map(Self::provider_codex_model_provider_key)
501            .collect::<std::collections::HashSet<_>>();
502        let mut changed = false;
503
504        for provider_id in provider_ids {
505            if provider_id == current_provider_id {
506                continue;
507            }
508
509            let Some(existing) = manager.providers.get(&provider_id) else {
510                continue;
511            };
512            if Self::is_codex_official_provider(existing) {
513                continue;
514            }
515            if Self::provider_codex_model_provider_key(existing).as_deref() != Some(live_anchor_key)
516            {
517                continue;
518            }
519
520            let Some(provider) = manager.providers.get_mut(&provider_id) else {
521                continue;
522            };
523            let new_key =
524                Self::unique_codex_provider_key_for_conflict(provider, &occupied, live_anchor_key);
525            match Self::rewrite_provider_codex_model_provider_key(provider, &new_key) {
526                Ok(true) => {
527                    log::warn!(
528                        "auto-repaired Codex provider '{}' from live alias '{}' to '{}'",
529                        provider_id,
530                        live_anchor_key,
531                        new_key
532                    );
533                    occupied.insert(new_key);
534                    changed = true;
535                }
536                Ok(false) => {
537                    occupied.insert(new_key);
538                }
539                Err(err) => {
540                    log::warn!(
541                        "skip auto-repair for Codex provider '{}' colliding with live alias '{}': {}",
542                        provider_id,
543                        live_anchor_key,
544                        err
545                    );
546                }
547            }
548        }
549
550        changed
551    }
552
553    fn collect_codex_providers_for_live_sync(config: &mut MultiAppConfig) -> (Vec<Provider>, bool) {
554        let Some(manager) = config.get_manager_mut(&AppType::Codex) else {
555            return (Vec::new(), false);
556        };
557
558        let mut repaired = Self::repair_conflicting_custom_codex_provider_keys(manager);
559        repaired |= Self::repair_live_anchor_conflicting_codex_provider_keys(
560            manager,
561            Self::current_live_codex_anchor_key().as_deref(),
562        );
563        let providers = manager.providers.values().cloned().collect::<Vec<_>>();
564        (providers, repaired)
565    }
566
567    fn codex_catalog_entry_from_provider(
568        provider: &Provider,
569    ) -> Result<Option<(String, toml_edit::Item)>, AppError> {
570        let config_toml =
571            crate::codex_config::codex_config_text_from_settings(&provider.settings_config)?;
572        if config_toml.trim().is_empty() {
573            return Ok(None);
574        }
575
576        let doc = config_toml.parse::<toml_edit::DocumentMut>().map_err(|e| {
577            AppError::Config(format!(
578                "Codex provider '{}' TOML 无法解析: {e}",
579                provider.id
580            ))
581        })?;
582
583        let configured_key = Self::provider_codex_model_provider_key(provider);
584        let model_providers = doc
585            .get("model_providers")
586            .and_then(|item| item.as_table_like());
587        let source_key = configured_key
588            .as_deref()
589            .filter(|key| {
590                model_providers
591                    .and_then(|providers| providers.get(*key))
592                    .is_some()
593            })
594            .map(str::to_string)
595            .or_else(|| Self::codex_provider_key_from_config_text(&config_toml))
596            .or_else(|| {
597                model_providers.and_then(|providers| {
598                    let mut keys = providers.iter().map(|(key, _)| key.to_string());
599                    let first = keys.next()?;
600                    keys.next().is_none().then_some(first)
601                })
602            });
603
604        let Some(source_key) = source_key else {
605            return Ok(None);
606        };
607        let resolved_key = configured_key.unwrap_or_else(|| source_key.clone());
608        let Some(provider_item) = model_providers.and_then(|providers| providers.get(&source_key))
609        else {
610            return Ok(None);
611        };
612
613        Ok(Some((resolved_key, provider_item.clone())))
614    }
615
616    fn codex_catalog_item_signature(item: &toml_edit::Item) -> String {
617        item.to_string()
618            .lines()
619            .map(str::trim_end)
620            .collect::<Vec<_>>()
621            .join("\n")
622            .trim()
623            .to_string()
624    }
625
626    fn merge_codex_catalog_into_config_text(
627        base_config_toml: &str,
628        catalog_entries: &IndexMap<String, toml_edit::Item>,
629        stale_keys: &[String],
630    ) -> Result<String, AppError> {
631        let mut doc = if base_config_toml.trim().is_empty() {
632            toml_edit::DocumentMut::new()
633        } else {
634            base_config_toml
635                .parse::<toml_edit::DocumentMut>()
636                .map_err(|e| AppError::Config(format!("Codex live config TOML 无法解析: {e}")))?
637        };
638
639        let active_alias = doc
640            .get("model_provider")
641            .and_then(|value| value.as_str())
642            .map(str::to_string)
643            .filter(|key| !catalog_entries.contains_key(key))
644            .filter(|key| !stale_keys.contains(key))
645            .and_then(|key| {
646                let item = doc
647                    .get("model_providers")
648                    .and_then(|value| value.as_table_like())
649                    .and_then(|providers| providers.get(&key))?
650                    .clone();
651                let signature = Self::codex_catalog_item_signature(&item);
652                catalog_entries
653                    .values()
654                    .any(|managed| Self::codex_catalog_item_signature(managed) == signature)
655                    .then_some((key, item))
656            });
657
658        if doc.get("model_providers").is_none() {
659            doc["model_providers"] = toml_edit::Item::Table(toml_edit::Table::new());
660        }
661        let providers = doc["model_providers"].as_table_like_mut().ok_or_else(|| {
662            AppError::Config("Codex live `model_providers` 必须是 TOML table".into())
663        })?;
664
665        let existing_keys = providers
666            .iter()
667            .map(|(key, _)| key.to_string())
668            .collect::<Vec<_>>();
669        for key in existing_keys {
670            providers.remove(&key);
671        }
672        for (key, item) in catalog_entries {
673            providers.insert(key, item.clone());
674        }
675        if let Some((key, item)) = active_alias {
676            providers.insert(&key, item);
677        }
678
679        if providers.iter().next().is_none() {
680            doc.as_table_mut().remove("model_providers");
681        }
682
683        Ok(doc.to_string())
684    }
685
686    fn sync_codex_provider_catalog_entries_to_live(
687        providers: &[Provider],
688        stale_keys: &[String],
689    ) -> Result<(), AppError> {
690        let mut catalog_entries = IndexMap::new();
691        let mut owners = std::collections::HashMap::<String, String>::new();
692        for provider in providers {
693            if Self::is_codex_official_provider(provider) {
694                continue;
695            }
696            let catalog_entry = match Self::codex_catalog_entry_from_provider(provider) {
697                Ok(entry) => entry,
698                Err(err) => {
699                    log::warn!(
700                        "skip syncing broken Codex provider snapshot '{}' into live catalog: {err}",
701                        provider.id
702                    );
703                    continue;
704                }
705            };
706            let Some((key, item)) = catalog_entry else {
707                continue;
708            };
709            if let Some(previous_owner) = owners.insert(key.clone(), provider.id.clone()) {
710                return Err(AppError::Config(format!(
711                    "Codex provider key 冲突: `{key}` 同时属于 `{previous_owner}` 和 `{}`",
712                    provider.id
713                )));
714            }
715            catalog_entries.insert(key, item);
716        }
717
718        let auth = if get_codex_auth_path().exists() {
719            Some(read_json_file::<Value>(&get_codex_auth_path())?)
720        } else {
721            None
722        };
723        let current_text = crate::codex_config::read_and_validate_codex_config_text()?;
724        let merged_text = Self::merge_codex_catalog_into_config_text(
725            &current_text,
726            &catalog_entries,
727            stale_keys,
728        )?;
729        if merged_text == current_text {
730            return Ok(());
731        }
732
733        crate::codex_config::write_codex_live_atomic_optional_auth(
734            auth.as_ref(),
735            Some(&merged_text),
736        )
737    }
738
739    pub(super) fn sync_codex_provider_catalog_to_live(
740        state: &AppState,
741        stale_keys: &[String],
742    ) -> Result<(), AppError> {
743        let (providers, repaired) = {
744            let mut guard = state.config.write().map_err(AppError::from)?;
745            Self::collect_codex_providers_for_live_sync(&mut guard)
746        };
747        if repaired {
748            state.save()?;
749        }
750        Self::sync_codex_provider_catalog_entries_to_live(&providers, stale_keys)
751    }
752
753    pub(crate) fn sync_codex_provider_catalog_to_live_from_config(
754        config: &mut MultiAppConfig,
755        stale_keys: &[String],
756    ) -> Result<(), AppError> {
757        let (providers, _repaired) = Self::collect_codex_providers_for_live_sync(config);
758        Self::sync_codex_provider_catalog_entries_to_live(&providers, stale_keys)
759    }
760
761    fn build_codex_catalog_snapshot_config(
762        provider_key: &str,
763        provider_item: &toml_edit::Item,
764        model: Option<&str>,
765    ) -> String {
766        let mut doc = toml_edit::DocumentMut::new();
767        doc["model_provider"] = toml_edit::value(provider_key);
768        if let Some(model) = model.map(str::trim).filter(|value| !value.is_empty()) {
769            doc["model"] = toml_edit::value(model);
770        }
771        doc["model_providers"] = toml_edit::Item::Table(toml_edit::Table::new());
772        if let Some(providers) = doc["model_providers"].as_table_like_mut() {
773            providers.insert(provider_key, provider_item.clone());
774        }
775        doc.to_string().trim().to_string()
776    }
777
778    fn parse_codex_catalog_from_live(
779        config_toml: &str,
780        auth: &Value,
781    ) -> Result<Vec<LiveCodexCatalogProvider>, AppError> {
782        if config_toml.trim().is_empty() {
783            return Ok(Vec::new());
784        }
785
786        let doc = config_toml
787            .parse::<toml_edit::DocumentMut>()
788            .map_err(|e| AppError::Config(format!("Codex live config TOML 无法解析: {e}")))?;
789        let active_key = doc
790            .get("model_provider")
791            .and_then(|value| value.as_str())
792            .map(str::trim)
793            .filter(|value| !value.is_empty())
794            .map(str::to_string);
795        let model = doc
796            .get("model")
797            .and_then(|value| value.as_str())
798            .map(str::to_string);
799        let Some(model_providers) = doc
800            .get("model_providers")
801            .and_then(|item| item.as_table_like())
802        else {
803            return Ok(Vec::new());
804        };
805        let canonical_active_key = active_key.as_ref().and_then(|active_key| {
806            let active_item = model_providers.get(active_key.as_str())?;
807            let active_signature = Self::codex_catalog_item_signature(active_item);
808            model_providers.iter().find_map(|(key, item)| {
809                (key != active_key && Self::codex_catalog_item_signature(item) == active_signature)
810                    .then(|| key.to_string())
811            })
812        });
813        let effective_active_key = canonical_active_key.as_deref().or(active_key.as_deref());
814
815        let mut providers = Vec::new();
816        for (key, item) in model_providers.iter() {
817            if active_key.as_deref() == Some(key) && canonical_active_key.is_some() {
818                continue;
819            }
820            let name = item
821                .as_table_like()
822                .and_then(|table| table.get("name"))
823                .and_then(|value| value.as_str())
824                .map(str::trim)
825                .filter(|value| !value.is_empty())
826                .unwrap_or(key)
827                .to_string();
828            let is_active = effective_active_key == Some(key);
829            let auth_value = if is_active {
830                auth.clone()
831            } else {
832                Value::Object(serde_json::Map::new())
833            };
834            let snapshot_config =
835                Self::build_codex_catalog_snapshot_config(key, item, model.as_deref());
836            let structured_config =
837                crate::codex_config::codex_structured_config_from_toml(&snapshot_config)?;
838            providers.push(LiveCodexCatalogProvider {
839                key: key.to_string(),
840                name,
841                settings_config: json!({
842                    "auth": auth_value,
843                    "codex": structured_config,
844                }),
845                is_active,
846            });
847        }
848
849        Ok(providers)
850    }
851
852    fn parse_codex_active_catalog_provider_from_live(
853        config_toml: &str,
854        auth: &Value,
855    ) -> Result<Option<LiveCodexCatalogProvider>, AppError> {
856        if config_toml.trim().is_empty() {
857            return Ok(None);
858        }
859
860        let doc = config_toml
861            .parse::<toml_edit::DocumentMut>()
862            .map_err(|e| AppError::Config(format!("Codex live config TOML 无法解析: {e}")))?;
863        let Some(active_key) = doc
864            .get("model_provider")
865            .and_then(|value| value.as_str())
866            .map(str::trim)
867            .filter(|value| !value.is_empty())
868        else {
869            return Ok(None);
870        };
871        let Some(active_item) = doc
872            .get("model_providers")
873            .and_then(|item| item.as_table_like())
874            .and_then(|providers| providers.get(active_key))
875        else {
876            return Ok(None);
877        };
878        let name = active_item
879            .as_table_like()
880            .and_then(|table| table.get("name"))
881            .and_then(|value| value.as_str())
882            .map(str::trim)
883            .filter(|value| !value.is_empty())
884            .unwrap_or(active_key)
885            .to_string();
886        let model = doc
887            .get("model")
888            .and_then(|value| value.as_str())
889            .map(str::to_string);
890
891        let snapshot_config =
892            Self::build_codex_catalog_snapshot_config(active_key, active_item, model.as_deref());
893        let structured_config =
894            crate::codex_config::codex_structured_config_from_toml(&snapshot_config)?;
895
896        Ok(Some(LiveCodexCatalogProvider {
897            key: active_key.to_string(),
898            name,
899            settings_config: json!({
900                "auth": auth.clone(),
901                "codex": structured_config,
902            }),
903            is_active: true,
904        }))
905    }
906
907    fn find_codex_import_target(
908        manager: &crate::provider::ProviderManager,
909        entry: &LiveCodexCatalogProvider,
910    ) -> Result<Option<(String, CodexImportMatchKind)>, ()> {
911        let key_matches = manager
912            .providers
913            .values()
914            .filter(|provider| {
915                Self::provider_codex_model_provider_key(provider).as_deref()
916                    == Some(entry.key.as_str())
917            })
918            .map(|provider| provider.id.clone())
919            .collect::<Vec<_>>();
920        match key_matches.len() {
921            0 => {}
922            1 => return Ok(Some((key_matches[0].clone(), CodexImportMatchKind::Key))),
923            _ => return Err(()),
924        }
925
926        let normalized_name = entry.name.trim();
927        if normalized_name.is_empty() {
928            return Ok(None);
929        }
930        let name_matches = manager
931            .providers
932            .values()
933            .filter(|provider| provider.name.trim() == normalized_name)
934            .map(|provider| provider.id.clone())
935            .collect::<Vec<_>>();
936        match name_matches.len() {
937            0 => Ok(None),
938            1 => Ok(Some((name_matches[0].clone(), CodexImportMatchKind::Name))),
939            _ => Err(()),
940        }
941    }
942
943    pub fn import_codex_providers_from_live(
944        state: &AppState,
945    ) -> Result<CodexImportReport, AppError> {
946        let auth = if get_codex_auth_path().exists() {
947            read_json_file::<Value>(&get_codex_auth_path())?
948        } else {
949            Value::Object(serde_json::Map::new())
950        };
951        let config_toml = crate::codex_config::read_and_validate_codex_config_text()?;
952        let live_providers = Self::parse_codex_catalog_from_live(&config_toml, &auth)?;
953        if live_providers.is_empty() {
954            let imported = Self::import_default_config(state, AppType::Codex)?;
955            return Ok(CodexImportReport {
956                created: usize::from(imported),
957                used_default_fallback: imported,
958                ..CodexImportReport::default()
959            });
960        }
961
962        let preserved_current = [AppType::Codex];
963        Self::run_transaction_preserving_current_providers(
964            state,
965            &preserved_current,
966            move |config| {
967                config.ensure_app(&AppType::Codex);
968                let manager = config
969                    .get_manager_mut(&AppType::Codex)
970                    .ok_or_else(|| Self::app_not_found(&AppType::Codex))?;
971                let mut report = CodexImportReport::default();
972
973                for entry in &live_providers {
974                    let target = match Self::find_codex_import_target(manager, entry) {
975                        Ok(target) => target,
976                        Err(()) => {
977                            report.conflicts += 1;
978                            continue;
979                        }
980                    };
981
982                    let mut settings_config = entry.settings_config.clone();
983                    let auth_is_empty = settings_config
984                        .get("auth")
985                        .and_then(Value::as_object)
986                        .is_some_and(|value| value.is_empty());
987
988                    match target {
989                        Some((provider_id, match_kind)) => {
990                            let existing = manager
991                                .providers
992                                .get(&provider_id)
993                                .cloned()
994                                .ok_or_else(|| {
995                                    AppError::localized(
996                                        "provider.not_found",
997                                        format!("供应商不存在: {provider_id}"),
998                                        format!("Provider not found: {provider_id}"),
999                                    )
1000                                })?;
1001
1002                            if auth_is_empty {
1003                                if let Some(existing_auth) =
1004                                    existing.settings_config.get("auth").cloned()
1005                                {
1006                                    if let Some(obj) = settings_config.as_object_mut() {
1007                                        obj.insert("auth".to_string(), existing_auth);
1008                                    }
1009                                }
1010                            }
1011
1012                            let mut merged = existing.clone();
1013                            merged.settings_config = settings_config;
1014                            merged
1015                                .meta
1016                                .get_or_insert_with(Default::default)
1017                                .codex_model_provider_key = Some(entry.key.clone());
1018                            manager.providers.insert(provider_id.clone(), merged);
1019
1020                            match match_kind {
1021                                CodexImportMatchKind::Key => report.merged_by_key += 1,
1022                                CodexImportMatchKind::Name => report.merged_by_name += 1,
1023                            }
1024                        }
1025                        None => {
1026                            let mut provider = Provider::with_id(
1027                                Uuid::new_v4().to_string(),
1028                                entry.name.clone(),
1029                                settings_config,
1030                                None,
1031                            );
1032                            provider.category = Some("custom".to_string());
1033                            provider.created_at = Some(current_timestamp());
1034                            provider
1035                                .meta
1036                                .get_or_insert_with(Default::default)
1037                                .codex_model_provider_key = Some(entry.key.clone());
1038                            manager.providers.insert(provider.id.clone(), provider);
1039                            report.created += 1;
1040                        }
1041                    }
1042
1043                    if !entry.is_active && auth_is_empty {
1044                        report.needs_auth += 1;
1045                    }
1046                }
1047
1048                Ok((report, None))
1049            },
1050        )
1051    }
1052
1053    fn codex_live_current_provider_match(
1054        state: &AppState,
1055    ) -> Result<Option<(String, LiveCodexCatalogProvider)>, AppError> {
1056        let config_toml = crate::codex_config::read_and_validate_codex_config_text()?;
1057        let Some(active_entry) = Self::parse_codex_active_catalog_provider_from_live(
1058            &config_toml,
1059            &Value::Object(Default::default()),
1060        )?
1061        else {
1062            return Ok(None);
1063        };
1064
1065        let guard = state.config.read().map_err(AppError::from)?;
1066        let Some(manager) = guard.get_manager(&AppType::Codex) else {
1067            return Ok(None);
1068        };
1069
1070        match Self::find_codex_import_target(manager, &active_entry) {
1071            Ok(Some((provider_id, _))) => Ok(Some((provider_id, active_entry))),
1072            Ok(None) => Ok(None),
1073            Err(()) => {
1074                log::warn!(
1075                    "skip Codex live current resolution: active key '{}' matches multiple providers",
1076                    active_entry.key
1077                );
1078                Ok(None)
1079            }
1080        }
1081    }
1082
1083    pub(crate) fn codex_live_current_provider_id(
1084        state: &AppState,
1085    ) -> Result<Option<String>, AppError> {
1086        Ok(Self::codex_live_current_provider_match(state)?.map(|(provider_id, _)| provider_id))
1087    }
1088
1089    pub(crate) fn codex_current_provider_mismatch(
1090        state: &AppState,
1091    ) -> Result<Option<CodexCurrentProviderMismatch>, AppError> {
1092        let Some((live_provider_id, active_entry)) =
1093            Self::codex_live_current_provider_match(state)?
1094        else {
1095            return Ok(None);
1096        };
1097        let Some(stored_provider_id) =
1098            crate::settings::get_effective_current_provider(&state.db, &AppType::Codex)?
1099        else {
1100            return Ok(None);
1101        };
1102
1103        if stored_provider_id == live_provider_id {
1104            return Ok(None);
1105        }
1106
1107        let guard = state.config.read().map_err(AppError::from)?;
1108        let Some(manager) = guard.get_manager(&AppType::Codex) else {
1109            return Ok(None);
1110        };
1111
1112        let provider_name = |provider_id: &str| {
1113            manager
1114                .providers
1115                .get(provider_id)
1116                .map(|provider| provider.name.trim())
1117                .filter(|name| !name.is_empty())
1118                .unwrap_or(provider_id)
1119                .to_string()
1120        };
1121
1122        Ok(Some(CodexCurrentProviderMismatch {
1123            stored_provider_name: provider_name(&stored_provider_id),
1124            live_provider_name: provider_name(&live_provider_id),
1125            stored_provider_id,
1126            live_provider_id,
1127            live_model_provider_key: active_entry.key,
1128        }))
1129    }
1130
1131    pub(crate) fn accept_codex_live_current_provider(
1132        state: &AppState,
1133        provider_id: &str,
1134    ) -> Result<(), AppError> {
1135        let live_provider_id = Self::codex_live_current_provider_id(state)?.ok_or_else(|| {
1136            AppError::Config("Codex live config does not point at a known provider".to_string())
1137        })?;
1138        if live_provider_id != provider_id {
1139            return Err(AppError::Config(format!(
1140                "Codex live current provider changed from `{provider_id}` to `{live_provider_id}`"
1141            )));
1142        }
1143
1144        let previous_current = {
1145            let mut guard = state.config.write().map_err(AppError::from)?;
1146            let manager = guard
1147                .get_manager_mut(&AppType::Codex)
1148                .ok_or_else(|| Self::app_not_found(&AppType::Codex))?;
1149            if !manager.providers.contains_key(provider_id) {
1150                return Err(AppError::localized(
1151                    "provider.not_found",
1152                    format!("供应商不存在: {provider_id}"),
1153                    format!("Provider not found: {provider_id}"),
1154                ));
1155            }
1156            let previous = manager.current.clone();
1157            manager.current = provider_id.to_string();
1158            previous
1159        };
1160
1161        if let Err(err) = Self::refresh_provider_snapshot(state, &AppType::Codex, provider_id) {
1162            if let Ok(mut guard) = state.config.write() {
1163                if let Some(manager) = guard.get_manager_mut(&AppType::Codex) {
1164                    manager.current = previous_current;
1165                }
1166            }
1167            return Err(err);
1168        }
1169
1170        crate::settings::set_current_provider(&AppType::Codex, Some(provider_id))?;
1171        Ok(())
1172    }
1173
1174    #[cfg(test)]
1175    pub(super) fn strip_toml_tables(dst: &mut toml_edit::Table, src: &toml_edit::Table) {
1176        let mut keys_to_remove = Vec::new();
1177
1178        for (key, src_item) in src.iter() {
1179            let Some(dst_item) = dst.get_mut(key) else {
1180                continue;
1181            };
1182
1183            match (dst_item, src_item) {
1184                (toml_edit::Item::Table(dst_table), toml_edit::Item::Table(src_table)) => {
1185                    Self::strip_toml_tables(dst_table, src_table);
1186                    if dst_table.is_empty() {
1187                        keys_to_remove.push(key.to_string());
1188                    }
1189                }
1190                (dst_item, src_item) => {
1191                    if Self::toml_items_equal(dst_item, src_item) {
1192                        keys_to_remove.push(key.to_string());
1193                    }
1194                }
1195            }
1196        }
1197
1198        for key in keys_to_remove {
1199            dst.remove(&key);
1200        }
1201    }
1202
1203    #[cfg(test)]
1204    fn toml_items_equal(left: &toml_edit::Item, right: &toml_edit::Item) -> bool {
1205        match (left.as_value(), right.as_value()) {
1206            (Some(left_value), Some(right_value)) => {
1207                left_value.to_string().trim() == right_value.to_string().trim()
1208            }
1209            _ => left.to_string().trim() == right.to_string().trim(),
1210        }
1211    }
1212
1213    pub(super) fn strip_common_codex_config_from_provider(
1214        provider: &mut Provider,
1215        common_config_snippet: Option<&str>,
1216    ) -> Result<(), AppError> {
1217        common_config::normalize_provider_common_config_for_storage(
1218            &AppType::Codex,
1219            provider,
1220            common_config_snippet,
1221        )
1222    }
1223
1224    pub(super) fn migrate_codex_common_config_snippet(
1225        config: &mut MultiAppConfig,
1226        strict_current_provider_id: Option<&str>,
1227        old_snippet: &str,
1228    ) -> Result<(), AppError> {
1229        let old_snippet = old_snippet.trim();
1230        if old_snippet.is_empty() {
1231            return Ok(());
1232        }
1233
1234        let Some(current_provider_id) = strict_current_provider_id.and_then(|provider_id| {
1235            config.get_manager(&AppType::Codex).and_then(|manager| {
1236                manager
1237                    .providers
1238                    .contains_key(provider_id)
1239                    .then(|| provider_id.to_string())
1240            })
1241        }) else {
1242            let Some(manager) = config.get_manager_mut(&AppType::Codex) else {
1243                return Ok(());
1244            };
1245
1246            for provider in manager.providers.values_mut() {
1247                Self::strip_common_codex_config_from_provider(provider, Some(old_snippet))?;
1248            }
1249
1250            return Ok(());
1251        };
1252
1253        let Some(manager) = config.get_manager_mut(&AppType::Codex) else {
1254            return Ok(());
1255        };
1256
1257        if let Some(current_provider) = manager.providers.get_mut(&current_provider_id) {
1258            Self::strip_common_codex_config_from_provider(current_provider, Some(old_snippet))?;
1259        }
1260
1261        for (provider_id, provider) in manager.providers.iter_mut() {
1262            if provider_id == &current_provider_id {
1263                continue;
1264            }
1265
1266            if let Err(err) =
1267                Self::strip_common_codex_config_from_provider(provider, Some(old_snippet))
1268            {
1269                log::warn!(
1270                    "skip migrating Codex non-current provider snapshot '{provider_id}' from stored common config snippet: {err}"
1271                );
1272            }
1273        }
1274
1275        Ok(())
1276    }
1277
1278    pub(super) fn prepare_switch_codex(
1279        config: &mut MultiAppConfig,
1280        provider_id: &str,
1281        effective_current_provider: Option<&str>,
1282    ) -> Result<Provider, AppError> {
1283        let provider = config
1284            .get_manager(&AppType::Codex)
1285            .ok_or_else(|| Self::app_not_found(&AppType::Codex))?
1286            .providers
1287            .get(provider_id)
1288            .cloned()
1289            .ok_or_else(|| {
1290                AppError::localized(
1291                    "provider.not_found",
1292                    format!("供应商不存在: {provider_id}"),
1293                    format!("Provider not found: {provider_id}"),
1294                )
1295            })?;
1296
1297        Self::backfill_codex_current(config, provider_id, effective_current_provider)?;
1298
1299        if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
1300            manager.current = provider_id.to_string();
1301        }
1302
1303        Ok(provider)
1304    }
1305
1306    pub(super) fn backfill_codex_current(
1307        config: &mut MultiAppConfig,
1308        next_provider: &str,
1309        effective_current_provider: Option<&str>,
1310    ) -> Result<(), AppError> {
1311        let current_id = effective_current_provider.unwrap_or_default();
1312
1313        if current_id.is_empty() || current_id == next_provider {
1314            return Ok(());
1315        }
1316
1317        let auth_path = get_codex_auth_path();
1318        let config_path = get_codex_config_path();
1319        if !auth_path.exists() && !config_path.exists() {
1320            return Ok(());
1321        }
1322
1323        let current_provider = config
1324            .get_manager(&AppType::Codex)
1325            .and_then(|manager| manager.providers.get(current_id))
1326            .cloned();
1327        let Some(current_provider) = current_provider else {
1328            return Ok(());
1329        };
1330
1331        // Read auth from disk; if absent, fall back to the DB snapshot's auth
1332        // so that WebDAV-synced credentials are not overwritten with empty data.
1333        let auth = if auth_path.exists() {
1334            Some(read_json_file::<Value>(&auth_path)?)
1335        } else {
1336            current_provider.settings_config.get("auth").cloned()
1337        };
1338
1339        let mut settings_config = if config_path.exists() {
1340            let text =
1341                std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
1342            Self::maybe_update_codex_common_config_snippet(config, &text)?;
1343            let text_for_storage =
1344                Self::strip_codex_runtime_local_keys_from_snapshot_config(&text)?;
1345
1346            let raw_settings = crate::codex_config::codex_settings_snapshot_from_toml(
1347                auth.clone(),
1348                &text_for_storage,
1349            )?;
1350            Self::normalize_settings_config_for_storage(
1351                &AppType::Codex,
1352                &current_provider,
1353                raw_settings,
1354                config.common_config_snippets.codex.as_deref(),
1355            )?
1356        } else {
1357            let mut raw_settings = serde_json::Map::new();
1358            if let Some(auth) = auth.clone() {
1359                raw_settings.insert("auth".to_string(), auth);
1360            }
1361            Value::Object(raw_settings)
1362        };
1363        Self::restore_codex_model_provider_for_storage_best_effort(
1364            &current_provider,
1365            &mut settings_config,
1366        );
1367
1368        if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
1369            if let Some(current) = manager.providers.get_mut(current_id) {
1370                current.settings_config = settings_config;
1371            }
1372        }
1373
1374        Ok(())
1375    }
1376
1377    /// Write Codex live configuration.
1378    ///
1379    /// Instead of replacing the entire config.toml, we overlay only the
1380    /// provider-specific fields (model_provider, model, [model_providers])
1381    /// onto the current live config. This preserves user preferences like
1382    /// approval_mode, disable_response_storage, [mcp_servers], etc.
1383    pub(super) fn write_codex_live(
1384        provider: &Provider,
1385        common_config_snippet: Option<&str>,
1386        apply_common_config: bool,
1387        preserve_live_preferences: bool,
1388    ) -> Result<(), AppError> {
1389        if !crate::sync_policy::should_sync_live(&AppType::Codex) {
1390            return Ok(());
1391        }
1392
1393        let effective = Self::build_effective_live_snapshot(
1394            &AppType::Codex,
1395            provider,
1396            common_config_snippet,
1397            apply_common_config,
1398        )?;
1399        let settings = effective
1400            .as_object()
1401            .ok_or_else(|| AppError::Config("Codex 配置必须是 JSON 对象".into()))?;
1402
1403        let auth = settings
1404            .get("auth")
1405            .ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
1406        let cfg_text = settings
1407            .get(crate::codex_config::CODEX_STRUCTURED_CONFIG_KEY)
1408            .or_else(|| settings.get(crate::codex_config::CODEX_LEGACY_CONFIG_KEY))
1409            .ok_or_else(|| {
1410                AppError::Config("Codex 供应商配置缺少 'codex' 或 'config' 字段".to_string())
1411            })
1412            .and_then(|_| crate::codex_config::codex_config_text_from_settings(&effective))?;
1413
1414        let auth_to_write = if Self::is_codex_official_provider(provider)
1415            && auth.as_object().is_some_and(|auth| auth.is_empty())
1416        {
1417            None
1418        } else {
1419            Some(auth)
1420        };
1421
1422        // ## Read current live config and merge — only overlay provider fields
1423        let config_path = crate::codex_config::get_codex_config_path();
1424        let live_text = if config_path.exists() {
1425            std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?
1426        } else {
1427            String::new()
1428        };
1429        let merged = crate::codex_config::merge_provider_into_codex_live_config(
1430            &live_text,
1431            &cfg_text,
1432            preserve_live_preferences,
1433        )?;
1434
1435        crate::codex_config::write_codex_live_atomic_optional_auth(auth_to_write, Some(&merged))?;
1436
1437        Ok(())
1438    }
1439}