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