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