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("profile");
108 root.remove("base_url");
110 root.remove("model_providers");
112 root.remove("profiles");
115 root.remove("projects");
119 root.remove("trusted_workspaces");
120
121 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 ¤t_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(¤t_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 == ¤t_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 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 ¤t_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 ¤t_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 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 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}