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