Skip to main content

cc_switch_lib/services/provider/
codex.rs

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