1use std::path::PathBuf;
3
4use crate::config::{
5 atomic_write, delete_file, home_dir, sanitize_provider_name, write_json_file, write_text_file,
6};
7use crate::error::AppError;
8use serde_json::Value;
9use std::fs;
10use std::path::Path;
11use toml_edit::DocumentMut;
12
13pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
14
15const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
19 "amazon-bedrock",
20 "openai",
21 "ollama",
22 "lmstudio",
23 "oss",
24 "ollama-chat",
25];
26
27pub fn get_codex_config_dir() -> PathBuf {
29 if let Some(custom) = crate::settings::get_codex_override_dir() {
30 return custom;
31 }
32
33 home_dir().expect("无法获取用户主目录").join(".codex")
34}
35
36pub fn get_codex_auth_path() -> PathBuf {
38 get_codex_config_dir().join("auth.json")
39}
40
41pub fn get_codex_config_path() -> PathBuf {
43 get_codex_config_dir().join("config.toml")
44}
45
46pub fn get_codex_provider_paths(
48 provider_id: &str,
49 provider_name: Option<&str>,
50) -> (PathBuf, PathBuf) {
51 let base_name = provider_name
52 .map(sanitize_provider_name)
53 .unwrap_or_else(|| sanitize_provider_name(provider_id));
54
55 let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
56 let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
57
58 (auth_path, config_path)
59}
60
61pub fn delete_codex_provider_config(
63 provider_id: &str,
64 provider_name: &str,
65) -> Result<(), AppError> {
66 let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
67
68 delete_file(&auth_path).ok();
69 delete_file(&config_path).ok();
70
71 Ok(())
72}
73
74pub fn write_codex_live_atomic(
76 auth: &Value,
77 config_text_opt: Option<&str>,
78) -> Result<(), AppError> {
79 write_codex_live_atomic_optional_auth(Some(auth), config_text_opt)
80}
81
82pub fn write_codex_live_atomic_optional_auth(
83 auth: Option<&Value>,
84 config_text_opt: Option<&str>,
85) -> Result<(), AppError> {
86 let auth_path = get_codex_auth_path();
87 let config_path = get_codex_config_path();
88
89 if let Some(parent) = auth_path.parent() {
90 std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
91 }
92
93 let old_auth = if auth_path.exists() {
95 Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
96 } else {
97 None
98 };
99 let _old_config = if config_path.exists() {
100 Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
101 } else {
102 None
103 };
104
105 let cfg_text = match config_text_opt {
107 Some(s) => s.to_string(),
108 None => String::new(),
109 };
110 if !cfg_text.trim().is_empty() {
111 toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
112 }
113
114 if let Some(auth) = auth {
116 write_json_file(&auth_path, auth)?;
117 } else {
118 delete_file(&auth_path)?;
119 }
120
121 if let Err(e) = write_text_file(&config_path, &cfg_text) {
123 if let Some(bytes) = old_auth {
125 let _ = atomic_write(&auth_path, &bytes);
126 } else {
127 let _ = delete_file(&auth_path);
128 }
129 return Err(e);
130 }
131
132 Ok(())
133}
134
135pub fn read_codex_config_text() -> Result<String, AppError> {
137 let path = get_codex_config_path();
138 if path.exists() {
139 std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
140 } else {
141 Ok(String::new())
142 }
143}
144
145pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
147 if text.trim().is_empty() {
148 return Ok(());
149 }
150 toml::from_str::<toml::Table>(text)
151 .map(|_| ())
152 .map_err(|e| AppError::toml(Path::new("config.toml"), e))
153}
154
155pub fn strip_codex_provider_config_text(config_toml: &str) -> Result<String, AppError> {
161 let config_toml = config_toml.trim();
162 if config_toml.is_empty() {
163 return Ok(String::new());
164 }
165
166 let mut doc = config_toml
167 .parse::<toml_edit::DocumentMut>()
168 .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
169 let root = doc.as_table_mut();
170 root.remove("model");
171 root.remove("model_provider");
172 root.remove("base_url");
173 root.remove("model_providers");
174
175 let mut cleaned = String::new();
176 let mut blank_run = 0usize;
177 for line in doc.to_string().lines() {
178 if line.trim().is_empty() {
179 blank_run += 1;
180 if blank_run <= 1 {
181 cleaned.push('\n');
182 }
183 continue;
184 }
185 blank_run = 0;
186 cleaned.push_str(line);
187 cleaned.push('\n');
188 }
189
190 Ok(cleaned.trim().to_string())
191}
192
193pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
195 let s = read_codex_config_text()?;
196 validate_config_toml(&s)?;
197 Ok(s)
198}
199
200fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
201 doc.get("model_provider")
202 .and_then(|item| item.as_str())
203 .map(str::trim)
204 .filter(|id| !id.is_empty())
205 .map(str::to_string)
206}
207
208fn is_custom_codex_model_provider_id(id: &str) -> bool {
209 let id = id.trim();
210 !id.is_empty()
211 && !CODEX_RESERVED_MODEL_PROVIDER_IDS
212 .iter()
213 .any(|reserved| reserved.eq_ignore_ascii_case(id))
214}
215
216fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
217 let doc = config_text.parse::<DocumentMut>().ok()?;
218 let provider_id = active_codex_model_provider_id(&doc)?;
219
220 if is_custom_codex_model_provider_id(&provider_id) {
221 Some(provider_id)
222 } else {
223 None
224 }
225}
226
227fn codex_model_provider_id_with_table_from_config(
228 config_text: &str,
229) -> Result<Option<String>, AppError> {
230 if config_text.trim().is_empty() {
231 return Ok(None);
232 }
233
234 let doc = config_text
235 .parse::<DocumentMut>()
236 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
237 let Some(provider_id) = active_codex_model_provider_id(&doc) else {
238 return Ok(None);
239 };
240
241 let has_provider_table = doc
242 .get("model_providers")
243 .and_then(|item| item.as_table())
244 .and_then(|table| table.get(provider_id.as_str()))
245 .is_some();
246
247 Ok(has_provider_table.then_some(provider_id))
248}
249
250fn normalize_codex_live_config_model_provider_with_anchors<'a>(
251 config_text: &str,
252 anchor_config_texts: impl IntoIterator<Item = &'a str>,
253) -> Result<String, AppError> {
254 if config_text.trim().is_empty() {
255 return Ok(config_text.to_string());
256 }
257
258 let mut doc = config_text
259 .parse::<DocumentMut>()
260 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
261
262 let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
263 return Ok(config_text.to_string());
264 };
265
266 let has_source_provider_table = doc
267 .get("model_providers")
268 .and_then(|item| item.as_table())
269 .and_then(|table| table.get(source_provider_id.as_str()))
270 .is_some();
271 if !has_source_provider_table {
272 return Ok(config_text.to_string());
273 }
274
275 let stable_provider_id = anchor_config_texts
276 .into_iter()
277 .find_map(stable_codex_model_provider_id_from_config)
278 .or_else(|| {
279 is_custom_codex_model_provider_id(&source_provider_id)
280 .then(|| source_provider_id.clone())
281 })
282 .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
283
284 if stable_provider_id == source_provider_id {
285 return Ok(config_text.to_string());
286 }
287
288 if let Some(model_providers) = doc
289 .get_mut("model_providers")
290 .and_then(|item| item.as_table_mut())
291 {
292 let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
293 return Ok(config_text.to_string());
294 };
295 model_providers[stable_provider_id.as_str()] = provider_table;
296 }
297
298 rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
299 doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
300
301 Ok(doc.to_string())
302}
303
304fn rewrite_codex_profile_model_provider_refs(
305 doc: &mut DocumentMut,
306 source_provider_id: &str,
307 stable_provider_id: &str,
308) {
309 let Some(profiles) = doc
310 .get_mut("profiles")
311 .and_then(|item| item.as_table_like_mut())
312 else {
313 return;
314 };
315
316 let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
317 for profile_key in profile_keys {
318 let Some(profile_table) = profiles
319 .get_mut(&profile_key)
320 .and_then(|item| item.as_table_like_mut())
321 else {
322 continue;
323 };
324
325 let references_source = profile_table
326 .get("model_provider")
327 .and_then(|item| item.as_str())
328 == Some(source_provider_id);
329 if references_source {
330 profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
331 }
332 }
333}
334
335pub fn normalize_codex_settings_config_model_provider(
342 settings: &mut Value,
343 anchor_config_text: Option<&str>,
344) -> Result<(), AppError> {
345 let Some(config_text) = settings
346 .get("config")
347 .and_then(|value| value.as_str())
348 .map(str::to_string)
349 else {
350 return Ok(());
351 };
352
353 let current_config_text = read_codex_config_text().ok();
354 let anchors = anchor_config_text
355 .into_iter()
356 .chain(current_config_text.as_deref());
357 let normalized =
358 normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
359
360 if let Some(obj) = settings.as_object_mut() {
361 obj.insert("config".to_string(), Value::String(normalized));
362 }
363
364 Ok(())
365}
366
367fn restore_codex_backfill_model_provider_id(
368 config_text: &str,
369 template_config_text: &str,
370) -> Result<String, AppError> {
371 let Some(template_provider_id) =
372 codex_model_provider_id_with_table_from_config(template_config_text)?
373 else {
374 return Ok(config_text.to_string());
375 };
376
377 if config_text.trim().is_empty() {
378 return Ok(config_text.to_string());
379 }
380
381 let mut doc = config_text
382 .parse::<DocumentMut>()
383 .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
384 let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
385 return Ok(config_text.to_string());
386 };
387
388 if live_provider_id == template_provider_id {
389 return Ok(config_text.to_string());
390 }
391
392 if let Some(model_providers) = doc
393 .get_mut("model_providers")
394 .and_then(|item| item.as_table_mut())
395 {
396 let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
397 return Ok(config_text.to_string());
398 };
399 model_providers[template_provider_id.as_str()] = provider_table;
400 } else {
401 return Ok(config_text.to_string());
402 }
403
404 rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
405 doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
406
407 Ok(doc.to_string())
408}
409
410pub fn restore_codex_settings_config_model_provider_for_backfill(
413 settings: &mut Value,
414 template_settings: &Value,
415) -> Result<(), AppError> {
416 let Some(config_text) = settings
417 .get("config")
418 .and_then(|value| value.as_str())
419 .map(str::to_string)
420 else {
421 return Ok(());
422 };
423 let Some(template_config_text) = template_settings
424 .get("config")
425 .and_then(|value| value.as_str())
426 else {
427 return Ok(());
428 };
429
430 let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?;
431 if let Some(obj) = settings.as_object_mut() {
432 obj.insert("config".to_string(), Value::String(restored));
433 }
434
435 Ok(())
436}
437
438pub fn write_codex_live_atomic_with_stable_provider(
443 auth: &Value,
444 config_text_opt: Option<&str>,
445) -> Result<(), AppError> {
446 write_codex_live_atomic_optional_auth_with_stable_provider(Some(auth), config_text_opt)
447}
448
449pub fn write_codex_live_atomic_optional_auth_with_stable_provider(
450 auth: Option<&Value>,
451 config_text_opt: Option<&str>,
452) -> Result<(), AppError> {
453 match config_text_opt {
454 Some(config_text) => {
455 let mut settings = serde_json::Map::new();
456 settings.insert("config".to_string(), Value::String(config_text.to_string()));
457 let mut settings = Value::Object(settings);
458 normalize_codex_settings_config_model_provider(&mut settings, None)?;
459 let config_text = settings
460 .get("config")
461 .and_then(|value| value.as_str())
462 .unwrap_or(config_text);
463 write_codex_live_atomic_optional_auth(auth, Some(config_text))
464 }
465 None => write_codex_live_atomic_optional_auth(auth, None),
466 }
467}
468
469pub fn clean_codex_provider_key(raw: &str) -> String {
474 let mut key: String = raw
475 .chars()
476 .map(|c| {
477 if c.is_ascii_alphanumeric() {
478 c.to_ascii_lowercase()
479 } else {
480 '_'
481 }
482 })
483 .collect();
484
485 while key.starts_with('_') {
486 key.remove(0);
487 }
488 while key.ends_with('_') {
489 key.pop();
490 }
491
492 if key.is_empty() {
493 "custom".to_string()
494 } else {
495 key
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn normalize_live_config_preserves_current_custom_model_provider_id() {
505 let current = r#"model_provider = "rightcode"
506
507[model_providers.rightcode]
508name = "RightCode"
509base_url = "https://rightcode.example/v1"
510wire_api = "responses"
511"#;
512 let target = r#"model_provider = "aihubmix"
513model = "gpt-5.4"
514
515[model_providers.aihubmix]
516name = "AiHubMix"
517base_url = "https://aihubmix.example/v1"
518wire_api = "responses"
519requires_openai_auth = true
520
521[mcp_servers.context7]
522command = "npx"
523"#;
524
525 let result =
526 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
527 let parsed: toml::Value = toml::from_str(&result).unwrap();
528
529 assert_eq!(
530 parsed.get("model_provider").and_then(|v| v.as_str()),
531 Some("rightcode")
532 );
533
534 let model_providers = parsed
535 .get("model_providers")
536 .and_then(|v| v.as_table())
537 .expect("model_providers should exist");
538 assert!(
539 model_providers.get("aihubmix").is_none(),
540 "source provider id should not remain in live config"
541 );
542
543 let stable_provider = model_providers
544 .get("rightcode")
545 .expect("stable provider table should exist");
546 assert_eq!(
547 stable_provider.get("base_url").and_then(|v| v.as_str()),
548 Some("https://aihubmix.example/v1")
549 );
550 assert!(
551 parsed.get("mcp_servers").is_some(),
552 "unrelated config should be preserved"
553 );
554 }
555
556 #[test]
557 fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
558 let current = r#"model_provider = "openai""#;
559 let target = r#"model_provider = "aihubmix"
560
561[model_providers.aihubmix]
562name = "AiHubMix"
563base_url = "https://aihubmix.example/v1"
564wire_api = "responses"
565"#;
566
567 let result =
568 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
569 let parsed: toml::Value = toml::from_str(&result).unwrap();
570
571 assert_eq!(
572 parsed.get("model_provider").and_then(|v| v.as_str()),
573 Some("aihubmix")
574 );
575 assert!(
576 parsed
577 .get("model_providers")
578 .and_then(|v| v.get("aihubmix"))
579 .is_some(),
580 "target provider id should be kept when there is no reusable live custom id"
581 );
582 }
583
584 #[test]
585 fn normalize_live_config_leaves_official_empty_config_unchanged() {
586 let current = r#"model_provider = "rightcode"
587
588[model_providers.rightcode]
589base_url = "https://rightcode.example/v1"
590"#;
591
592 let result =
593 normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
594
595 assert_eq!(result, "");
596 }
597
598 #[test]
599 fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
600 let current = r#"model_provider = "session_anchor"
601
602[model_providers.session_anchor]
603name = "Session Anchor"
604base_url = "https://anchor.example/v1"
605wire_api = "responses"
606"#;
607 let target = r#"model_provider = "vendor_alpha"
608model = "gpt-5.4"
609profile = "work"
610
611[model_providers.vendor_alpha]
612name = "Vendor Alpha"
613base_url = "https://alpha.example/v1"
614wire_api = "responses"
615
616[profiles.work]
617model_provider = "vendor_alpha"
618model = "gpt-5.4"
619"#;
620
621 let result =
622 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
623 let parsed: toml::Value = toml::from_str(&result).unwrap();
624
625 assert_eq!(
626 parsed.get("model_provider").and_then(|v| v.as_str()),
627 Some("session_anchor")
628 );
629 assert_eq!(
630 parsed
631 .get("profiles")
632 .and_then(|v| v.get("work"))
633 .and_then(|v| v.get("model_provider"))
634 .and_then(|v| v.as_str()),
635 Some("session_anchor"),
636 "profile override matching the rewritten provider should stay valid"
637 );
638 }
639
640 #[test]
641 fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
642 let current = r#"model_provider = "session_anchor"
643
644[model_providers.session_anchor]
645name = "Session Anchor"
646base_url = "https://anchor.example/v1"
647wire_api = "responses"
648"#;
649 let target = r#"model_provider = "vendor_alpha"
650model = "gpt-5.4"
651
652[model_providers.vendor_alpha]
653name = "Vendor Alpha"
654base_url = "https://alpha.example/v1"
655wire_api = "responses"
656
657[model_providers.local_profile]
658name = "Local Profile"
659base_url = "http://localhost:11434/v1"
660wire_api = "responses"
661
662[profiles.local]
663model_provider = "local_profile"
664model = "local-model"
665"#;
666
667 let result =
668 normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
669 let parsed: toml::Value = toml::from_str(&result).unwrap();
670
671 assert_eq!(
672 parsed
673 .get("profiles")
674 .and_then(|v| v.get("local"))
675 .and_then(|v| v.get("model_provider"))
676 .and_then(|v| v.as_str()),
677 Some("local_profile"),
678 "unrelated profile provider references should be preserved"
679 );
680 assert!(
681 parsed
682 .get("model_providers")
683 .and_then(|v| v.get("local_profile"))
684 .is_some(),
685 "unrelated provider tables should also remain available"
686 );
687 }
688
689 #[test]
690 fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
691 let anchor = r#"model_provider = "session_anchor"
692
693[model_providers.session_anchor]
694name = "Session Anchor"
695base_url = "https://anchor.example/v1"
696wire_api = "responses"
697"#;
698 let first_target = r#"model_provider = "vendor_alpha"
699
700[model_providers.vendor_alpha]
701name = "Vendor Alpha"
702base_url = "https://alpha.example/v1"
703wire_api = "responses"
704"#;
705 let second_target = r#"model_provider = "vendor_beta"
706
707[model_providers.vendor_beta]
708name = "Vendor Beta"
709base_url = "https://beta.example/v1"
710wire_api = "responses"
711"#;
712
713 let first =
714 normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
715 .unwrap();
716 let second = normalize_codex_live_config_model_provider_with_anchors(
717 second_target,
718 Some(first.as_str()),
719 )
720 .unwrap();
721 let parsed: toml::Value = toml::from_str(&second).unwrap();
722
723 assert_eq!(
724 parsed.get("model_provider").and_then(|v| v.as_str()),
725 Some("session_anchor"),
726 "stable provider id should not drift across repeated switches"
727 );
728 assert_eq!(
729 parsed
730 .get("model_providers")
731 .and_then(|v| v.get("session_anchor"))
732 .and_then(|v| v.get("base_url"))
733 .and_then(|v| v.as_str()),
734 Some("https://beta.example/v1")
735 );
736 }
737
738 #[test]
739 fn restore_backfill_config_rewrites_live_id_to_template_provider_id() {
740 let mut settings = serde_json::json!({
741 "config": r#"model_provider = "session_anchor"
742model = "gpt-5.4"
743profile = "work"
744
745[model_providers.session_anchor]
746name = "AiHubMix"
747base_url = "https://aihubmix.example/v1"
748wire_api = "responses"
749
750[profiles.work]
751model_provider = "session_anchor"
752model = "gpt-5.4"
753"#
754 });
755 let template = serde_json::json!({
756 "config": r#"model_provider = "aihubmix"
757
758[model_providers.aihubmix]
759name = "AiHubMix"
760base_url = "https://aihubmix.example/v1"
761"#
762 });
763
764 restore_codex_settings_config_model_provider_for_backfill(&mut settings, &template)
765 .unwrap();
766
767 let config = settings.get("config").and_then(Value::as_str).unwrap();
768 let parsed: toml::Value = toml::from_str(config).unwrap();
769 assert_eq!(
770 parsed.get("model_provider").and_then(|v| v.as_str()),
771 Some("aihubmix")
772 );
773 assert!(parsed
774 .get("model_providers")
775 .and_then(|v| v.get("aihubmix"))
776 .is_some());
777 assert_eq!(
778 parsed
779 .get("profiles")
780 .and_then(|v| v.get("work"))
781 .and_then(|v| v.get("model_provider"))
782 .and_then(|v| v.as_str()),
783 Some("aihubmix")
784 );
785 }
786}