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