1use std::path::Path;
2
3use crate::marked_block;
4
5const PROXY_ENV_START: &str = "# >>> lean-ctx proxy env >>>";
6const PROXY_ENV_END: &str = "# <<< lean-ctx proxy env <<<";
7
8const DEFAULT_PROXY_PORT: u16 = 4444;
9
10const ANTHROPIC_OMITTED_NOTE: &str = "ANTHROPIC_BASE_URL omitted: Claude Pro/Max subscription authenticates against api.anthropic.com directly (set ANTHROPIC_API_KEY to route Claude through the proxy)";
15
16pub fn install_proxy_env(home: &Path, port: u16, quiet: bool) {
17 let cfg = crate::core::config::Config::load();
18 if cfg.proxy_enabled != Some(true) {
19 if !quiet {
20 println!(" Proxy env skipped (not enabled in config)");
21 }
22 return;
23 }
24 install_shell_exports(home, port, quiet);
25 install_claude_env(home, port, quiet);
26 install_codex_env(home, port, quiet);
27 install_pi_env(home, port, quiet, false);
28}
29
30pub fn install_proxy_env_unchecked(home: &Path, port: u16, quiet: bool, force_endpoint: bool) {
33 install_shell_exports(home, port, quiet);
34 if force_endpoint {
35 install_claude_env_inner(home, port, quiet, true);
36 } else {
37 install_claude_env(home, port, quiet);
38 }
39 install_codex_env(home, port, quiet);
40 install_pi_env(home, port, quiet, force_endpoint);
41}
42
43pub fn preview_proxy_cleanup(home: &Path) {
44 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
45 let settings_path = settings_dir.join("settings.json");
46 if let Ok(content) = std::fs::read_to_string(&settings_path)
47 && content.contains("ANTHROPIC_BASE_URL")
48 {
49 let cfg = crate::core::config::Config::load();
50 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
51 println!(" Would restore ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
52 } else {
53 println!(" Would remove ANTHROPIC_BASE_URL from Claude Code settings");
54 }
55 }
56
57 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
58 let codex_path = codex_dir.join("config.toml");
59 if let Ok(content) = std::fs::read_to_string(codex_path)
60 && codex_config_has_local_proxy_entry(&content)
61 {
62 println!(" Would remove Codex proxy URL from config.toml");
63 }
64}
65
66pub fn cleanup_stale_proxy_env(home: &Path) -> usize {
69 let cfg = crate::core::config::Config::load();
70 if cfg.proxy_enabled == Some(true) {
71 return 0;
72 }
73
74 let mut cleaned = 0;
75
76 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
77 let settings_path = settings_dir.join("settings.json");
78 if let Ok(content) = std::fs::read_to_string(&settings_path)
79 && let Ok(mut doc) = crate::core::jsonc::parse_jsonc(&content)
80 && let Some(base_url) = doc
81 .get("env")
82 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
83 .and_then(|v| v.as_str())
84 .map(String::from)
85 && is_local_lean_ctx_url(&base_url)
86 && let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut())
87 {
88 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
89 env_obj.insert(
90 "ANTHROPIC_BASE_URL".to_string(),
91 serde_json::Value::String(upstream.clone()),
92 );
93 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
94 } else {
95 env_obj.remove("ANTHROPIC_BASE_URL");
96 if env_obj.is_empty() {
97 doc.as_object_mut().map(|o| o.remove("env"));
98 }
99 println!(" ✓ Removed stale ANTHROPIC_BASE_URL from Claude Code settings");
100 }
101 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
102 let _ = std::fs::write(&settings_path, out + "\n");
103 cleaned += 1;
104 }
105
106 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
107 let codex_path = codex_dir.join("config.toml");
108 if let Ok(content) = std::fs::read_to_string(&codex_path)
109 && codex_config_has_local_proxy_entry(&content)
110 {
111 let filtered = strip_codex_proxy_entries(&content);
112 let _ = std::fs::write(&codex_path, &filtered);
113 println!(" ✓ Removed stale Codex proxy URL from config.toml");
114 cleaned += 1;
115 }
116
117 cleaned
118}
119
120pub fn is_local_lean_ctx_url(url: &str) -> bool {
121 url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:")
122}
123
124pub fn has_stale_proxy_url(home: &Path) -> bool {
127 let cfg = crate::core::config::Config::load();
128 if cfg.proxy_enabled == Some(true) {
129 return false;
130 }
131
132 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
133 let settings_path = settings_dir.join("settings.json");
134 let Ok(content) = std::fs::read_to_string(&settings_path) else {
135 return false;
136 };
137 let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
138 return false;
139 };
140
141 let base_url = doc
142 .get("env")
143 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
144 .and_then(|v| v.as_str())
145 .unwrap_or("");
146
147 is_local_lean_ctx_url(base_url)
148}
149
150pub fn anthropic_api_key_available(home: &Path) -> bool {
161 for var in ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] {
163 if std::env::var(var).is_ok_and(|v| !v.trim().is_empty()) {
164 return true;
165 }
166 }
167
168 let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
171 let Ok(content) = std::fs::read_to_string(&settings_path) else {
172 return false;
173 };
174 let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
175 return false;
176 };
177
178 if doc
179 .get("apiKeyHelper")
180 .and_then(|v| v.as_str())
181 .is_some_and(|v| !v.trim().is_empty())
182 {
183 return true;
184 }
185
186 let env = doc.get("env");
187 ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
188 .iter()
189 .any(|key| {
190 env.and_then(|e| e.get(*key))
191 .and_then(|v| v.as_str())
192 .is_some_and(|v| !v.trim().is_empty())
193 })
194}
195
196fn warn_claude_subscription_skip() {
199 eprintln!(" \u{26a0} Claude Code: no ANTHROPIC_API_KEY detected (Pro/Max subscription?).");
200 eprintln!(" The proxy forwards your credential upstream but never injects one, and a");
201 eprintln!(" subscription token only authenticates against api.anthropic.com directly.");
202 eprintln!(" Leaving ANTHROPIC_BASE_URL untouched so Claude Code keeps working.");
203 eprintln!(" Savings on a subscription: use the lean-ctx MCP tools (ctx_read /");
204 eprintln!(" ctx_search / ctx_shell). Pay-as-you-go? Set ANTHROPIC_API_KEY, then run:");
205 eprintln!(" lean-ctx proxy enable");
206}
207
208pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
209 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
210 let label = format!(
211 "proxy env from ~/{}",
212 rc.file_name().unwrap_or_default().to_string_lossy()
213 );
214 marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
215 }
216
217 let fish_config = home.join(".config/fish/config.fish");
218 if fish_config.exists() {
219 marked_block::remove_from_file(
220 &fish_config,
221 PROXY_ENV_START,
222 PROXY_ENV_END,
223 quiet,
224 "proxy env from ~/.config/fish/config.fish",
225 );
226 }
227
228 let ps_profile =
229 dirs::home_dir().map(|h| crate::shell::platform::resolve_powershell_profile_path(&h));
230 if let Some(ref ps) = ps_profile
231 && ps.exists()
232 {
233 marked_block::remove_from_file(
234 ps,
235 PROXY_ENV_START,
236 PROXY_ENV_END,
237 quiet,
238 "proxy env from PowerShell profile",
239 );
240 }
241
242 uninstall_claude_env(home, quiet);
243 uninstall_codex_env(home, quiet);
244 uninstall_pi_env(home, quiet);
245}
246
247fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
248 if !is_proxy_reachable(port) {
249 if !quiet {
250 println!(" Skipping shell proxy exports (proxy not running on port {port})");
251 }
252 return;
253 }
254
255 let base = format!("http://127.0.0.1:{port}");
256 let openai_base = format!("{base}/v1");
264
265 let include_anthropic = anthropic_api_key_available(home);
269
270 let posix_anthropic = if include_anthropic {
271 format!(r#"export ANTHROPIC_BASE_URL="{base}""#)
272 } else {
273 format!("# {ANTHROPIC_OMITTED_NOTE}")
274 };
275 let posix_block = format!(
276 r#"{PROXY_ENV_START}
277{posix_anthropic}
278export OPENAI_BASE_URL="{openai_base}"
279export GEMINI_API_BASE_URL="{base}"
280{PROXY_ENV_END}"#
281 );
282
283 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
284 if rc.exists() {
285 let label = format!(
286 "proxy env in ~/{}",
287 rc.file_name().unwrap_or_default().to_string_lossy()
288 );
289 marked_block::upsert(
290 rc,
291 PROXY_ENV_START,
292 PROXY_ENV_END,
293 &posix_block,
294 quiet,
295 &label,
296 );
297 }
298 }
299
300 let fish_config = home.join(".config/fish/config.fish");
301 if fish_config.exists() {
302 let fish_anthropic = if include_anthropic {
303 format!(r#"set -gx ANTHROPIC_BASE_URL "{base}""#)
304 } else {
305 format!("# {ANTHROPIC_OMITTED_NOTE}")
306 };
307 let fish_block = format!(
308 r#"{PROXY_ENV_START}
309{fish_anthropic}
310set -gx OPENAI_BASE_URL "{openai_base}"
311set -gx GEMINI_API_BASE_URL "{base}"
312{PROXY_ENV_END}"#
313 );
314 marked_block::upsert(
315 &fish_config,
316 PROXY_ENV_START,
317 PROXY_ENV_END,
318 &fish_block,
319 quiet,
320 "proxy env in ~/.config/fish/config.fish",
321 );
322 }
323
324 let ps_profile =
325 dirs::home_dir().map(|h| crate::shell::platform::resolve_powershell_profile_path(&h));
326 if let Some(ref ps) = ps_profile
327 && ps.exists()
328 {
329 let ps_anthropic = if include_anthropic {
330 format!(r#"$env:ANTHROPIC_BASE_URL = "{base}""#)
331 } else {
332 format!("# {ANTHROPIC_OMITTED_NOTE}")
333 };
334 let ps_block = format!(
335 r#"{PROXY_ENV_START}
336{ps_anthropic}
337$env:OPENAI_BASE_URL = "{openai_base}"
338$env:GEMINI_API_BASE_URL = "{base}"
339{PROXY_ENV_END}"#
340 );
341 marked_block::upsert(
342 ps,
343 PROXY_ENV_START,
344 PROXY_ENV_END,
345 &ps_block,
346 quiet,
347 "proxy env in PowerShell profile",
348 );
349 }
350}
351
352fn uninstall_claude_env(home: &Path, quiet: bool) {
353 use crate::core::config::Config;
354
355 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
356 let settings_path = settings_dir.join("settings.json");
357 let existing = match std::fs::read_to_string(&settings_path) {
358 Ok(s) if !s.trim().is_empty() => s,
359 _ => return,
360 };
361 let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
362 Ok(v) => v,
363 Err(_) => return,
364 };
365
366 let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
367 return;
368 };
369
370 if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
371 return;
372 }
373
374 let cfg = Config::load();
375 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
376 env_obj.insert(
377 "ANTHROPIC_BASE_URL".to_string(),
378 serde_json::Value::String(upstream.clone()),
379 );
380 if !quiet {
381 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
382 }
383 } else {
384 env_obj.remove("ANTHROPIC_BASE_URL");
385 if env_obj.is_empty() {
386 doc.as_object_mut().map(|o| o.remove("env"));
387 }
388 if !quiet {
389 println!(" ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
390 }
391 }
392
393 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
394 let _ = std::fs::write(&settings_path, content + "\n");
395}
396
397fn uninstall_codex_env(home: &Path, quiet: bool) {
398 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
399 let config_path = codex_dir.join("config.toml");
400 let existing = match std::fs::read_to_string(&config_path) {
401 Ok(s) if !s.trim().is_empty() => s,
402 _ => return,
403 };
404
405 let has_local = codex_config_has_local_proxy_entry(&existing);
406 if !has_local {
407 return;
408 }
409
410 let cleaned = strip_codex_proxy_entries(&existing);
411 let _ = std::fs::write(&config_path, &cleaned);
412 if !quiet {
413 println!(" ✓ Removed Codex proxy URL(s) from Codex CLI config");
414 }
415}
416
417fn install_pi_env(home: &Path, port: u16, quiet: bool, force: bool) {
426 install_pi_env_at(&home.join(".pi/agent"), port, quiet, force);
427}
428
429fn uninstall_pi_env(home: &Path, quiet: bool) {
430 uninstall_pi_env_at(&home.join(".pi/agent"), quiet);
431}
432
433fn install_pi_env_at(agent_dir: &Path, port: u16, quiet: bool, force: bool) {
440 use crate::core::config::{is_local_proxy_url, normalize_url_opt};
441
442 if !agent_dir.exists() {
444 return;
445 }
446 if !is_proxy_reachable(port) {
447 if !quiet {
448 println!(" Skipping Pi proxy env (proxy not running on port {port})");
449 }
450 return;
451 }
452
453 let base = format!("http://127.0.0.1:{port}");
454 let models_path = agent_dir.join("models.json");
455 let existing = std::fs::read_to_string(&models_path).unwrap_or_default();
456 let mut doc: serde_json::Value = if existing.trim().is_empty() {
457 serde_json::json!({})
458 } else {
459 match crate::core::jsonc::parse_jsonc(&existing) {
460 Ok(v) => v,
461 Err(_) => return,
462 }
463 };
464
465 let mut changed = false;
466 let mut kept_custom: Vec<String> = Vec::new();
467 for (provider, proxy_url) in [
468 ("anthropic", base.clone()),
469 ("openai", format!("{base}/v1")),
470 ] {
471 let current = pi_provider_base_url(&doc, provider).to_string();
472 if current == proxy_url {
473 continue;
474 }
475 if !force
477 && let Some(custom) = normalize_url_opt(¤t)
478 && !is_local_proxy_url(&custom)
479 {
480 kept_custom.push(format!("{provider} → {custom}"));
481 continue;
482 }
483 set_pi_provider_base_url(&mut doc, provider, &proxy_url);
484 changed = true;
485 }
486
487 if changed {
488 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
489 let _ = std::fs::write(&models_path, out + "\n");
490 if !quiet {
491 println!(
492 " Configured Pi providers (anthropic/openai) → proxy in ~/.pi/agent/models.json"
493 );
494 }
495 }
496 if !quiet && !kept_custom.is_empty() {
497 eprintln!(
498 " \u{26a0} Pi: kept custom endpoint(s) {}; use `lean-ctx proxy enable --force` to override.",
499 kept_custom.join(", ")
500 );
501 }
502}
503
504fn uninstall_pi_env_at(agent_dir: &Path, quiet: bool) {
508 use crate::core::config::is_local_proxy_url;
509
510 let models_path = agent_dir.join("models.json");
511 let existing = match std::fs::read_to_string(&models_path) {
512 Ok(s) if !s.trim().is_empty() => s,
513 _ => return,
514 };
515 let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
516 Ok(v) => v,
517 Err(_) => return,
518 };
519
520 let mut changed = false;
521 for provider in ["anthropic", "openai"] {
522 if is_local_proxy_url(pi_provider_base_url(&doc, provider))
523 && remove_pi_provider_base_url(&mut doc, provider)
524 {
525 changed = true;
526 }
527 }
528
529 if changed {
530 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
531 let _ = std::fs::write(&models_path, out + "\n");
532 if !quiet {
533 println!(" \u{2713} Removed Pi proxy endpoints from ~/.pi/agent/models.json");
534 }
535 }
536}
537
538fn pi_provider_base_url<'a>(doc: &'a serde_json::Value, provider: &str) -> &'a str {
540 doc.get("providers")
541 .and_then(|p| p.get(provider))
542 .and_then(|p| p.get("baseUrl"))
543 .and_then(serde_json::Value::as_str)
544 .unwrap_or("")
545}
546
547fn set_pi_provider_base_url(doc: &mut serde_json::Value, provider: &str, url: &str) {
549 let Some(root) = doc.as_object_mut() else {
550 return;
551 };
552 let providers = root
553 .entry("providers")
554 .or_insert_with(|| serde_json::json!({}));
555 let Some(providers) = providers.as_object_mut() else {
556 return;
557 };
558 let entry = providers
559 .entry(provider.to_string())
560 .or_insert_with(|| serde_json::json!({}));
561 if let Some(entry) = entry.as_object_mut() {
562 entry.insert(
563 "baseUrl".to_string(),
564 serde_json::Value::String(url.to_string()),
565 );
566 }
567}
568
569fn remove_pi_provider_base_url(doc: &mut serde_json::Value, provider: &str) -> bool {
572 let Some(root) = doc.as_object_mut() else {
573 return false;
574 };
575 let Some(providers) = root.get_mut("providers").and_then(|p| p.as_object_mut()) else {
576 return false;
577 };
578 let Some(entry) = providers.get_mut(provider).and_then(|p| p.as_object_mut()) else {
579 return false;
580 };
581 if entry.remove("baseUrl").is_none() {
582 return false;
583 }
584 if entry.is_empty() {
585 providers.remove(provider);
586 }
587 if providers.is_empty() {
588 root.remove("providers");
589 }
590 true
591}
592
593fn install_claude_env(home: &Path, port: u16, quiet: bool) {
594 install_claude_env_inner(home, port, quiet, false);
595}
596
597fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
598 use crate::core::config::{Config, is_local_proxy_url, normalize_url_opt};
599
600 let base = format!("http://127.0.0.1:{port}");
601
602 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
603 let settings_path = settings_dir.join("settings.json");
604 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
605 let mut doc: serde_json::Value = if existing.trim().is_empty() {
606 serde_json::json!({})
607 } else {
608 match crate::core::jsonc::parse_jsonc(&existing) {
609 Ok(v) => v,
610 Err(_) => return,
611 }
612 };
613
614 let current_url = doc
615 .get("env")
616 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
617 .and_then(|v| v.as_str())
618 .unwrap_or("")
619 .to_string();
620
621 if !force && !anthropic_api_key_available(home) {
627 if is_local_lean_ctx_url(¤t_url) {
629 let cfg = Config::load();
630 if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
631 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
632 env_obj.insert(
633 "ANTHROPIC_BASE_URL".to_string(),
634 serde_json::Value::String(upstream.clone()),
635 );
636 } else {
637 env_obj.remove("ANTHROPIC_BASE_URL");
638 if env_obj.is_empty() {
639 doc.as_object_mut().map(|o| o.remove("env"));
640 }
641 }
642 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
643 let _ = std::fs::write(&settings_path, out + "\n");
644 }
645 }
646 if !quiet {
647 warn_claude_subscription_skip();
648 }
649 return;
650 }
651
652 if current_url == base {
653 if !quiet {
654 println!(" Claude Code proxy env already configured");
655 }
656 return;
657 }
658
659 if let Some(upstream) = normalize_url_opt(¤t_url)
661 && !is_local_proxy_url(&upstream)
662 {
663 if Config::load_global().proxy.anthropic_upstream.is_none()
664 && let Err(e) =
665 Config::update_global(|c| c.proxy.anthropic_upstream = Some(upstream.clone()))
666 {
667 tracing::warn!("could not persist proxy upstream: {e}");
668 }
669
670 if !force {
671 if !quiet {
672 eprintln!(" \u{26a0} Custom endpoint detected: {upstream}");
673 eprintln!(
674 " Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
675 );
676 }
677 return;
678 }
679 if !quiet {
680 println!(" Overriding custom endpoint (--force): {upstream}");
681 }
682 }
683
684 if !is_proxy_reachable(port) {
685 if !quiet {
686 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
687 }
688 return;
689 }
690
691 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
692 o.entry("env")
693 .or_insert(serde_json::json!({}))
694 .as_object_mut()
695 }) {
696 env_obj.insert(
697 "ANTHROPIC_BASE_URL".to_string(),
698 serde_json::Value::String(base),
699 );
700 }
701
702 let _ = std::fs::create_dir_all(&settings_dir);
703 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
704 let _ = std::fs::write(&settings_path, content + "\n");
705 if !quiet {
706 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
707 }
708}
709
710pub fn proxy_timeout() -> std::time::Duration {
712 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS")
713 && let Ok(ms) = val.parse::<u64>()
714 {
715 return std::time::Duration::from_millis(ms);
716 }
717 if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
718 return std::time::Duration::from_millis(ms);
719 }
720 std::time::Duration::from_millis(200)
721}
722
723pub(crate) fn is_proxy_reachable(port: u16) -> bool {
724 use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
725 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
726 TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
727}
728
729pub(crate) fn install_codex_env(home: &Path, port: u16, quiet: bool) {
736 let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
737 let mode = if codex_uses_chatgpt_login(home) {
738 CodexProxyMode::ChatGpt
739 } else {
740 CodexProxyMode::ApiKey
741 };
742 let chatgpt_proxy = crate::core::config::Config::load()
747 .proxy
748 .codex_chatgpt_proxy_enabled();
749 install_codex_env_at_mode(&config_dir, port, quiet, mode, chatgpt_proxy);
750}
751
752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
753enum CodexProxyMode {
754 ApiKey,
755 ChatGpt,
756}
757
758const CODEX_CHATGPT_PROVIDER_ID: &str = "leanctx-chatgpt";
759
760#[cfg(test)]
763fn install_codex_env_at(config_dir: &Path, port: u16, quiet: bool) {
764 install_codex_env_at_mode(config_dir, port, quiet, CodexProxyMode::ApiKey, false);
765}
766
767fn install_codex_env_at_mode(
768 config_dir: &Path,
769 port: u16,
770 quiet: bool,
771 mode: CodexProxyMode,
772 chatgpt_proxy: bool,
773) {
774 let base = format!("http://127.0.0.1:{port}");
791 let entries: Vec<(&str, String)> = match mode {
792 CodexProxyMode::ApiKey => vec![("openai_base_url", format!("{base}/v1"))],
793 CodexProxyMode::ChatGpt if chatgpt_proxy => vec![
794 ("model_provider", CODEX_CHATGPT_PROVIDER_ID.to_string()),
795 ("chatgpt_base_url", format!("{base}/backend-api/")),
796 ],
797 CodexProxyMode::ChatGpt => Vec::new(),
798 };
799 let provider_block = match mode {
800 CodexProxyMode::ChatGpt if chatgpt_proxy => {
801 Some(render_codex_chatgpt_provider_block(&base))
802 }
803 _ => None,
804 };
805
806 if !entries.is_empty() && !is_proxy_reachable(port) {
808 if !quiet {
809 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
810 }
811 return;
812 }
813
814 if !config_dir.exists() {
815 return;
816 }
817
818 let config_path = config_dir.join("config.toml");
819 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
820 let updated = render_codex_config(&existing, &entries, provider_block.as_deref());
821
822 if updated == existing {
823 if !quiet {
824 if entries.is_empty() {
828 println!(" Codex ChatGPT login — config left native (no lean-ctx proxy entries)");
829 } else {
830 println!(" Codex CLI proxy env already configured");
831 }
832 }
833 return;
834 }
835
836 let _ = std::fs::write(&config_path, &updated);
837 if !quiet {
838 match mode {
839 CodexProxyMode::ApiKey => {
840 println!(" Configured openai_base_url in Codex CLI config");
841 }
842 CodexProxyMode::ChatGpt if chatgpt_proxy => println!(
843 " Configured ChatGPT subscription provider in Codex CLI config (model turns compressed; history scoped to lean-ctx provider while enabled)"
844 ),
845 CodexProxyMode::ChatGpt => println!(
846 " Codex ChatGPT login — removed stale lean-ctx proxy entries (Codex now talks directly to ChatGPT)"
847 ),
848 }
849 }
850}
851
852fn render_codex_config(
861 existing: &str,
862 entries: &[(&str, String)],
863 append_block: Option<&str>,
864) -> String {
865 let mut cleaned = strip_codex_proxy_entries(existing);
866 if entries.iter().any(|(key, _)| *key == "model_provider") {
867 cleaned = strip_top_level_codex_config_key(&cleaned, "model_provider");
868 cleaned = strip_top_level_codex_config_key(&cleaned, "chatgpt_base_url");
869 }
870
871 let mut prefix = String::new();
872 for (key, value) in entries {
873 let has_remote_key = has_top_level_codex_config_key(&cleaned, key, |t| {
874 !(t.contains("127.0.0.1") || t.contains("localhost"))
875 });
876 if !has_remote_key {
877 prefix.push_str(&format!("{key} = \"{value}\"\n"));
878 }
879 }
880 let mut rendered = if prefix.is_empty() {
881 cleaned
882 } else {
883 format!("{prefix}{cleaned}")
886 };
887 if let Some(block) = append_block {
888 if !rendered.is_empty() && !rendered.ends_with("\n\n") {
889 rendered.push('\n');
890 }
891 rendered.push_str(block);
892 }
893 rendered
894}
895
896fn render_codex_chatgpt_provider_block(base: &str) -> String {
897 format!(
898 "[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]\n\
899 name = \"OpenAI\"\n\
900 base_url = \"{base}/backend-api/codex\"\n\
901 requires_openai_auth = true\n\
902 supports_websockets = false\n"
903 )
904}
905
906fn strip_top_level_codex_config_key(body: &str, key: &str) -> String {
907 let mut out = Vec::new();
908 let mut in_top_level = true;
909 for line in body.lines() {
910 let t = line.trim_start();
911 if t.starts_with('[') {
912 in_top_level = false;
913 }
914 if in_top_level && toml_assignment_key(t) == Some(key) {
915 continue;
916 }
917 out.push(line);
918 }
919 let s = out.join("\n");
920 if s.is_empty() { s } else { format!("{s}\n") }
921}
922
923fn strip_codex_proxy_entries(body: &str) -> String {
927 let lines: Vec<&str> = body.lines().collect();
928 let mut kept: Vec<&str> = Vec::with_capacity(lines.len());
929 let mut current_table: Option<&str> = None;
930 let mut i = 0;
931 while i < lines.len() {
932 let trimmed = lines[i].trim();
933 if is_generated_codex_chatgpt_provider_header(trimmed) {
934 i += 1;
935 while i < lines.len() && !lines[i].trim_start().starts_with('[') {
936 i += 1;
937 }
938 continue;
939 }
940
941 if lines[i].trim_start().starts_with('[') {
942 current_table = Some(trimmed);
943 kept.push(lines[i]);
944 i += 1;
945 continue;
946 }
947
948 if should_strip_codex_proxy_entry(lines[i].trim_start(), current_table) {
949 i += 1;
950 continue;
951 }
952
953 kept.push(lines[i]);
954 i += 1;
955 }
956
957 let mut out: Vec<&str> = Vec::with_capacity(kept.len());
959 let mut i = 0;
960 while i < kept.len() {
961 let trimmed = kept[i].trim();
962 if trimmed == "[env]" {
963 let mut j = i + 1;
964 while j < kept.len() && kept[j].trim().is_empty() {
965 j += 1;
966 }
967 if j >= kept.len() || kept[j].trim_start().starts_with('[') {
968 i = j;
969 continue;
970 }
971 }
972 out.push(kept[i]);
973 i += 1;
974 }
975
976 let mut s = out.join("\n");
977 while s.contains("\n\n\n") {
978 s = s.replace("\n\n\n", "\n\n");
979 }
980 let s = s.trim_end_matches('\n');
981 if s.is_empty() {
982 String::new()
983 } else {
984 format!("{s}\n")
985 }
986}
987
988fn has_top_level_codex_config_key(body: &str, key: &str, predicate: impl Fn(&str) -> bool) -> bool {
989 for line in body.lines() {
990 let t = line.trim_start();
991 if t.starts_with('[') {
992 break;
993 }
994 if toml_assignment_key(t) == Some(key) && predicate(t) {
995 return true;
996 }
997 }
998 false
999}
1000
1001fn should_strip_codex_proxy_entry(t: &str, current_table: Option<&str>) -> bool {
1002 match current_table {
1003 None => {
1004 is_local_codex_base_url_entry(t, &["openai_base_url", "chatgpt_base_url"])
1005 || is_codex_proxy_model_provider_entry(t)
1006 }
1007 Some("[env]") => is_local_codex_base_url_entry(t, &["OPENAI_BASE_URL", "CHATGPT_BASE_URL"]),
1008 _ => false,
1009 }
1010}
1011
1012fn is_local_codex_base_url_entry(t: &str, keys: &[&str]) -> bool {
1013 toml_assignment_key(t).is_some_and(|key| keys.contains(&key))
1014 && (t.contains("127.0.0.1") || t.contains("localhost"))
1015}
1016
1017fn toml_assignment_key(t: &str) -> Option<&str> {
1018 let key = t.split_once('=')?.0.trim();
1019 if key.is_empty() || key.starts_with('#') {
1020 None
1021 } else {
1022 Some(key)
1023 }
1024}
1025
1026fn is_codex_proxy_model_provider_entry(t: &str) -> bool {
1027 is_toml_string_assignment(t, "model_provider", CODEX_CHATGPT_PROVIDER_ID)
1028 || is_toml_string_assignment(t, "model_provider", "openai")
1029}
1030
1031fn is_toml_string_assignment(t: &str, key: &str, value: &str) -> bool {
1032 let Some((lhs, rhs)) = t.split_once('=') else {
1033 return false;
1034 };
1035 if lhs.trim() != key {
1036 return false;
1037 }
1038 let rhs = rhs.split('#').next().unwrap_or(rhs);
1039 let normalized: String = rhs.chars().filter(|c| !c.is_whitespace()).collect();
1040 normalized == format!("\"{value}\"")
1041}
1042
1043fn is_generated_codex_chatgpt_provider_header(t: &str) -> bool {
1044 t == format!("[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]")
1045}
1046
1047fn codex_config_has_local_proxy_entry(body: &str) -> bool {
1048 let mut current_table: Option<&str> = None;
1049 for line in body.lines() {
1050 let t = line.trim_start();
1051 if is_generated_codex_chatgpt_provider_header(line.trim()) {
1052 return true;
1053 }
1054 if t.starts_with('[') {
1055 current_table = Some(line.trim());
1056 continue;
1057 }
1058 match current_table {
1059 None => {
1060 if is_local_codex_base_url_entry(t, &["openai_base_url", "chatgpt_base_url"])
1061 || is_toml_string_assignment(t, "model_provider", CODEX_CHATGPT_PROVIDER_ID)
1062 {
1063 return true;
1064 }
1065 }
1066 Some("[env]")
1067 if is_local_codex_base_url_entry(t, &["OPENAI_BASE_URL", "CHATGPT_BASE_URL"]) =>
1068 {
1069 return true;
1070 }
1071 _ => {}
1072 }
1073 }
1074 false
1075}
1076
1077fn codex_uses_chatgpt_login(home: &Path) -> bool {
1081 if std::env::var("OPENAI_API_KEY").is_ok_and(|v| !v.trim().is_empty()) {
1082 return false;
1083 }
1084 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
1085 auth_is_chatgpt(&codex_dir)
1086}
1087
1088fn auth_is_chatgpt(codex_dir: &Path) -> bool {
1091 let Ok(content) = std::fs::read_to_string(codex_dir.join("auth.json")) else {
1092 return false;
1093 };
1094 let Ok(doc) = serde_json::from_str::<serde_json::Value>(&content) else {
1095 return false;
1096 };
1097 let Some(mode) = doc.get("auth_mode").and_then(|v| v.as_str()) else {
1098 return false;
1099 };
1100 let normalized = mode
1101 .chars()
1102 .filter(char::is_ascii_alphanumeric)
1103 .collect::<String>()
1104 .to_ascii_lowercase();
1105 matches!(
1106 normalized.as_str(),
1107 "chatgpt" | "chatgptauthtokens" | "personalaccesstoken" | "agentidentity"
1108 )
1109}
1110
1111pub fn default_port() -> u16 {
1112 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT")
1113 && let Ok(port) = val.parse::<u16>()
1114 {
1115 return port;
1116 }
1117 let cfg = crate::core::config::Config::load();
1118 if let Some(port) = cfg.proxy_port {
1119 return port;
1120 }
1121 uid_based_port()
1122}
1123
1124fn uid_based_port() -> u16 {
1128 #[cfg(unix)]
1129 {
1130 let uid = unsafe { libc::getuid() } as u16;
1133 let offset = uid.saturating_sub(1000) % 1000;
1134 DEFAULT_PROXY_PORT + offset
1135 }
1136 #[cfg(not(unix))]
1137 {
1138 DEFAULT_PROXY_PORT
1139 }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use super::*;
1145
1146 #[test]
1147 fn uid_port_first_regular_user() {
1148 assert_eq!(DEFAULT_PROXY_PORT, 4444);
1150 }
1151
1152 #[test]
1153 fn uid_port_no_overflow() {
1154 let port = DEFAULT_PROXY_PORT + 999;
1157 assert_eq!(port, 5443);
1158 assert!(port < u16::MAX);
1159 }
1160
1161 #[test]
1162 fn uid_port_system_accounts_get_base() {
1163 let uid: u16 = 500;
1165 let offset = uid.saturating_sub(1000) % 1000;
1166 assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
1167 }
1168
1169 #[test]
1170 fn proxy_timeout_default_200ms() {
1171 if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
1172 return;
1173 }
1174 assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
1175 }
1176
1177 #[test]
1178 fn proxy_timeout_is_non_zero() {
1179 let t = proxy_timeout();
1180 assert!(t.as_millis() > 0);
1181 }
1182
1183 #[test]
1184 fn is_proxy_reachable_returns_false_on_unused_port() {
1185 assert!(!is_proxy_reachable(19999));
1186 }
1187
1188 #[test]
1189 fn posix_block_contains_all_provider_env_vars() {
1190 let base = "http://127.0.0.1:4444";
1191 let block = format!(
1192 r#"{PROXY_ENV_START}
1193export ANTHROPIC_BASE_URL="{base}"
1194export OPENAI_BASE_URL="{base}/v1"
1195export GEMINI_API_BASE_URL="{base}"
1196{PROXY_ENV_END}"#
1197 );
1198 assert!(
1199 block.contains("ANTHROPIC_BASE_URL"),
1200 "shell exports must include ANTHROPIC_BASE_URL"
1201 );
1202 assert!(
1203 block.contains("OPENAI_BASE_URL"),
1204 "shell exports must include OPENAI_BASE_URL"
1205 );
1206 assert!(
1207 block.contains("GEMINI_API_BASE_URL"),
1208 "shell exports must include GEMINI_API_BASE_URL"
1209 );
1210 }
1211
1212 #[test]
1213 fn fish_block_contains_all_provider_env_vars() {
1214 let base = "http://127.0.0.1:4444";
1215 let block = format!(
1216 r#"{PROXY_ENV_START}
1217set -gx ANTHROPIC_BASE_URL "{base}"
1218set -gx OPENAI_BASE_URL "{base}/v1"
1219set -gx GEMINI_API_BASE_URL "{base}"
1220{PROXY_ENV_END}"#
1221 );
1222 assert!(block.contains("ANTHROPIC_BASE_URL"));
1223 assert!(block.contains("OPENAI_BASE_URL"));
1224 assert!(block.contains("GEMINI_API_BASE_URL"));
1225 }
1226
1227 #[test]
1228 fn powershell_block_contains_all_provider_env_vars() {
1229 let base = "http://127.0.0.1:4444";
1230 let block = format!(
1231 r#"{PROXY_ENV_START}
1232$env:ANTHROPIC_BASE_URL = "{base}"
1233$env:OPENAI_BASE_URL = "{base}/v1"
1234$env:GEMINI_API_BASE_URL = "{base}"
1235{PROXY_ENV_END}"#
1236 );
1237 assert!(block.contains("ANTHROPIC_BASE_URL"));
1238 assert!(block.contains("OPENAI_BASE_URL"));
1239 assert!(block.contains("GEMINI_API_BASE_URL"));
1240 }
1241
1242 fn env_provides_anthropic_key() -> bool {
1245 std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
1246 || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
1247 }
1248
1249 fn claude_dir_overridden() -> bool {
1252 std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
1253 }
1254
1255 fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
1256 let dir = home.join(".claude");
1257 std::fs::create_dir_all(&dir).unwrap();
1258 let path = dir.join("settings.json");
1259 std::fs::write(&path, json).unwrap();
1260 path
1261 }
1262
1263 #[test]
1264 fn api_key_available_true_with_api_key_helper() {
1265 if claude_dir_overridden() {
1266 return;
1267 }
1268 let home = tempfile::tempdir().unwrap();
1269 write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
1270 assert!(anthropic_api_key_available(home.path()));
1271 }
1272
1273 #[test]
1274 fn api_key_available_true_with_settings_env_key() {
1275 if claude_dir_overridden() {
1276 return;
1277 }
1278 let home = tempfile::tempdir().unwrap();
1279 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1280 assert!(anthropic_api_key_available(home.path()));
1281 }
1282
1283 #[test]
1284 fn api_key_available_false_without_key() {
1285 if env_provides_anthropic_key() || claude_dir_overridden() {
1286 return;
1287 }
1288 let home = tempfile::tempdir().unwrap();
1289 write_claude_settings(home.path(), r#"{"env": {}}"#);
1290 assert!(!anthropic_api_key_available(home.path()));
1291 }
1292
1293 #[test]
1294 fn api_key_available_false_when_no_settings_file() {
1295 if env_provides_anthropic_key() || claude_dir_overridden() {
1296 return;
1297 }
1298 let home = tempfile::tempdir().unwrap();
1299 assert!(!anthropic_api_key_available(home.path()));
1300 }
1301
1302 #[test]
1303 fn subscription_guard_skips_redirect_without_key() {
1304 if env_provides_anthropic_key() || claude_dir_overridden() {
1305 return;
1306 }
1307 let home = tempfile::tempdir().unwrap();
1308 install_claude_env_inner(home.path(), 4444, true, false);
1310 let settings = home.path().join(".claude/settings.json");
1311 assert!(
1312 !settings.exists(),
1313 "subscription mode must not write a proxy redirect"
1314 );
1315 }
1316
1317 #[test]
1318 fn subscription_guard_repairs_stale_local_redirect() {
1319 if env_provides_anthropic_key() || claude_dir_overridden() {
1320 return;
1321 }
1322 let home = tempfile::tempdir().unwrap();
1323 let path = write_claude_settings(
1324 home.path(),
1325 r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
1326 );
1327 install_claude_env_inner(home.path(), 4444, true, false);
1328 let after = std::fs::read_to_string(&path).unwrap();
1329 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1330 let base = doc
1331 .get("env")
1332 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1333 .and_then(|v| v.as_str())
1334 .unwrap_or("");
1335 assert!(
1336 !is_local_lean_ctx_url(base),
1337 "stale local redirect must be repaired in subscription mode, got {base:?}"
1338 );
1339 }
1340
1341 #[test]
1345 fn install_redirects_claude_when_api_key_present() {
1346 if claude_dir_overridden() {
1347 return;
1348 }
1349 let home = tempfile::tempdir().unwrap();
1350 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1352 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1353 let port = listener.local_addr().unwrap().port();
1354
1355 install_claude_env_inner(home.path(), port, true, false);
1356
1357 let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
1358 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1359 let base = doc
1360 .get("env")
1361 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1362 .and_then(|v| v.as_str())
1363 .unwrap_or("");
1364 assert_eq!(
1365 base,
1366 format!("http://127.0.0.1:{port}"),
1367 "API-key mode must route Claude through the proxy"
1368 );
1369 }
1370
1371 #[test]
1375 fn shell_export_omits_anthropic_without_key() {
1376 if env_provides_anthropic_key() || claude_dir_overridden() {
1377 return;
1378 }
1379 let home = tempfile::tempdir().unwrap();
1380 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1381 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1382 let port = listener.local_addr().unwrap().port();
1383
1384 install_shell_exports(home.path(), port, true);
1385
1386 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1387 assert!(
1388 rc.contains(&format!(
1389 "export OPENAI_BASE_URL=\"http://127.0.0.1:{port}/v1\""
1390 )),
1391 "OpenAI export must remain and carry the /v1 suffix (#366)"
1392 );
1393 assert!(
1394 rc.contains(&format!(
1395 "export GEMINI_API_BASE_URL=\"http://127.0.0.1:{port}\""
1396 )),
1397 "Gemini export must remain WITHOUT /v1 (SDK appends /v1beta itself)"
1398 );
1399 assert!(
1400 !rc.contains("export ANTHROPIC_BASE_URL="),
1401 "ANTHROPIC export must be omitted in subscription mode"
1402 );
1403 assert!(
1404 rc.contains(ANTHROPIC_OMITTED_NOTE),
1405 "omission must be explained in the RC block"
1406 );
1407 }
1408
1409 #[test]
1411 fn codex_env_writes_v1_suffixed_url() {
1412 let dir = tempfile::tempdir().unwrap();
1413 let codex_dir = dir.path().join(".codex");
1414 std::fs::create_dir_all(&codex_dir).unwrap();
1415 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1416 let port = listener.local_addr().unwrap().port();
1417
1418 install_codex_env_at(&codex_dir, port, true);
1419
1420 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1421 assert!(
1422 cfg.contains(&format!("openai_base_url = \"http://127.0.0.1:{port}/v1\"")),
1423 "Codex config must set top-level openai_base_url with the /v1 suffix, got:\n{cfg}"
1424 );
1425 assert!(
1426 !cfg.contains("[env]") && !cfg.contains("OPENAI_BASE_URL"),
1427 "must not write the dead [env] OPENAI_BASE_URL form (#554), got:\n{cfg}"
1428 );
1429 assert!(
1430 !cfg.contains(CODEX_CHATGPT_PROVIDER_ID),
1431 "API-key mode must not install the ChatGPT-only provider, got:\n{cfg}"
1432 );
1433 assert!(
1434 !cfg.contains("chatgpt_base_url"),
1435 "API-key mode must not install the ChatGPT backend rail, got:\n{cfg}"
1436 );
1437 }
1438
1439 #[test]
1443 fn codex_env_migrates_legacy_env_entry() {
1444 let dir = tempfile::tempdir().unwrap();
1445 let codex_dir = dir.path().join(".codex");
1446 std::fs::create_dir_all(&codex_dir).unwrap();
1447 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1448 let port = listener.local_addr().unwrap().port();
1449 std::fs::write(
1450 codex_dir.join("config.toml"),
1451 format!(
1452 "model = \"gpt-5.2\"\n\n[env]\nOPENAI_BASE_URL = \"http://127.0.0.1:{port}\"\n"
1453 ),
1454 )
1455 .unwrap();
1456
1457 install_codex_env_at(&codex_dir, port, true);
1458
1459 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1460 assert!(
1461 cfg.contains(&format!("openai_base_url = \"http://127.0.0.1:{port}/v1\"")),
1462 "legacy entry must become a top-level openai_base_url (/v1), got:\n{cfg}"
1463 );
1464 assert!(
1465 cfg.contains("model = \"gpt-5.2\""),
1466 "unrelated config must be preserved"
1467 );
1468 assert!(
1469 !cfg.contains("OPENAI_BASE_URL"),
1470 "dead legacy [env] OPENAI_BASE_URL must be removed, got:\n{cfg}"
1471 );
1472 assert!(
1473 !cfg.contains("[env]"),
1474 "empty [env] table must be collapsed, got:\n{cfg}"
1475 );
1476 }
1477
1478 #[test]
1480 fn codex_env_preserves_custom_remote_endpoint() {
1481 let dir = tempfile::tempdir().unwrap();
1482 let codex_dir = dir.path().join(".codex");
1483 std::fs::create_dir_all(&codex_dir).unwrap();
1484 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1485 let port = listener.local_addr().unwrap().port();
1486 let original = "openai_base_url = \"https://my-gateway.example.com/v1\"\n";
1487 std::fs::write(codex_dir.join("config.toml"), original).unwrap();
1488
1489 install_codex_env_at(&codex_dir, port, true);
1490
1491 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1492 assert!(
1493 cfg.contains("https://my-gateway.example.com/v1"),
1494 "custom remote endpoint must be preserved, got:\n{cfg}"
1495 );
1496 assert!(
1497 !cfg.contains("127.0.0.1"),
1498 "proxy URL must not be injected over a custom endpoint"
1499 );
1500 }
1501
1502 #[test]
1503 fn codex_env_chatgpt_mode_writes_subscription_provider() {
1504 let dir = tempfile::tempdir().unwrap();
1505 let codex_dir = dir.path().join(".codex");
1506 std::fs::create_dir_all(&codex_dir).unwrap();
1507 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1508 let port = listener.local_addr().unwrap().port();
1509 std::fs::write(
1510 codex_dir.join("config.toml"),
1511 "model_provider = \"custom\"\nchatgpt_base_url = \"https://chatgpt.example.com/backend-api/\"\nmodel = \"gpt-5.5\"\n",
1512 )
1513 .unwrap();
1514
1515 install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1516
1517 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1518 assert!(
1519 !cfg.contains("openai_base_url"),
1520 "ChatGPT mode must not write a proxy openai_base_url, got:\n{cfg}"
1521 );
1522 assert!(
1523 cfg.contains(&format!("model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"")),
1524 "ChatGPT mode must select the lean-ctx ChatGPT provider, got:\n{cfg}"
1525 );
1526 assert!(
1527 !cfg.contains("model_provider = \"custom\""),
1528 "ChatGPT mode must replace stale top-level model_provider, got:\n{cfg}"
1529 );
1530 assert!(
1531 cfg.contains(&format!(
1532 "chatgpt_base_url = \"http://127.0.0.1:{port}/backend-api/\""
1533 )),
1534 "ChatGPT mode must write the backend rail, got:\n{cfg}"
1535 );
1536 assert!(
1537 !cfg.contains("https://chatgpt.example.com"),
1538 "ChatGPT mode must replace stale top-level chatgpt_base_url, got:\n{cfg}"
1539 );
1540 assert!(
1541 cfg.contains(&format!("[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]")),
1542 "ChatGPT mode must install the generated provider block, got:\n{cfg}"
1543 );
1544 assert!(
1545 cfg.contains(&format!(
1546 "base_url = \"http://127.0.0.1:{port}/backend-api/codex\""
1547 )),
1548 "ChatGPT provider must target the Codex backend rail, got:\n{cfg}"
1549 );
1550 assert!(
1551 cfg.contains("model = \"gpt-5.5\""),
1552 "user keys are preserved, got:\n{cfg}"
1553 );
1554 }
1555
1556 #[test]
1560 fn codex_env_chatgpt_mode_optout_writes_nothing() {
1561 let dir = tempfile::tempdir().unwrap();
1562 let codex_dir = dir.path().join(".codex");
1563 std::fs::create_dir_all(&codex_dir).unwrap();
1564 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1565 let port = listener.local_addr().unwrap().port();
1566 std::fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
1567
1568 install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, false);
1570
1571 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1572 assert!(
1573 !cfg.contains("model_provider"),
1574 "opt-out must not pin a model_provider (#597), got:\n{cfg}"
1575 );
1576 assert!(
1577 !cfg.contains("chatgpt_base_url") && !cfg.contains("openai_base_url"),
1578 "opt-out must not write any proxy base URL, got:\n{cfg}"
1579 );
1580 assert!(
1581 !cfg.contains(CODEX_CHATGPT_PROVIDER_ID) && !cfg.contains("127.0.0.1"),
1582 "opt-out must not install the provider block or any proxy URL, got:\n{cfg}"
1583 );
1584 assert!(cfg.contains("model = \"gpt-5.5\""), "user keys preserved");
1585 }
1586
1587 #[test]
1590 fn codex_env_chatgpt_optin_toggle_off_restores_native() {
1591 let dir = tempfile::tempdir().unwrap();
1592 let codex_dir = dir.path().join(".codex");
1593 std::fs::create_dir_all(&codex_dir).unwrap();
1594 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1595 let port = listener.local_addr().unwrap().port();
1596 std::fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
1597
1598 install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1600 let on = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1601 assert!(
1602 on.contains(CODEX_CHATGPT_PROVIDER_ID),
1603 "opt-in writes provider"
1604 );
1605
1606 install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, false);
1608 let off = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1609 assert!(
1610 !off.contains("model_provider")
1611 && !off.contains("chatgpt_base_url")
1612 && !off.contains(CODEX_CHATGPT_PROVIDER_ID)
1613 && !off.contains("127.0.0.1"),
1614 "toggling opt-in off restores native config, got:\n{off}"
1615 );
1616 assert!(off.contains("model = \"gpt-5.5\""), "user keys preserved");
1617 }
1618
1619 #[test]
1622 fn codex_env_chatgpt_mode_writes_backend_url_idempotently() {
1623 let dir = tempfile::tempdir().unwrap();
1624 let codex_dir = dir.path().join(".codex");
1625 std::fs::create_dir_all(&codex_dir).unwrap();
1626 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1627 let port = listener.local_addr().unwrap().port();
1628 std::fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
1629
1630 install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1631
1632 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1633 assert!(
1634 cfg.contains(&format!("model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"")),
1635 "ChatGPT mode must pin the lean-ctx provider, got:\n{cfg}"
1636 );
1637 assert!(
1638 cfg.contains(&format!(
1639 "chatgpt_base_url = \"http://127.0.0.1:{port}/backend-api/\""
1640 )),
1641 "ChatGPT mode must point chatgpt_base_url at the proxy backend-api rail, got:\n{cfg}"
1642 );
1643 assert!(
1644 !cfg.contains("openai_base_url"),
1645 "ChatGPT mode routes via chatgpt_base_url, not the /v1 openai_base_url, got:\n{cfg}"
1646 );
1647 assert!(
1648 cfg.contains("model = \"gpt-5.5\""),
1649 "user keys are preserved, got:\n{cfg}"
1650 );
1651
1652 install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1654 let again = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1655 assert_eq!(cfg, again, "opt-in render must be idempotent");
1656
1657 install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ApiKey, false);
1659 let off = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1660 assert!(
1661 !off.contains("chatgpt_base_url") && !off.contains(CODEX_CHATGPT_PROVIDER_ID),
1662 "API-key mode must remove ChatGPT-only config, got:\n{off}"
1663 );
1664 assert!(off.contains(&format!("openai_base_url = \"http://127.0.0.1:{port}/v1\"")));
1665 assert!(off.contains("model = \"gpt-5.5\""));
1666 }
1667
1668 #[test]
1671 fn codex_chatgpt_upgrade_strips_legacy_leanctx_provider() {
1672 let dir = tempfile::tempdir().unwrap();
1673 let codex_dir = dir.path().join(".codex");
1674 std::fs::create_dir_all(&codex_dir).unwrap();
1675 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1676 let port = listener.local_addr().unwrap().port();
1677 let legacy = format!(
1680 "model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"\n\
1681 openai_base_url = \"http://127.0.0.1:{port}/backend-api/codex\"\n\
1682 chatgpt_base_url = \"http://127.0.0.1:{port}/backend-api\"\n\
1683 model = \"gpt-5.5\"\n\n\
1684 {LEGACY_CHATGPT_PROVIDER_BLOCK}"
1685 );
1686 std::fs::write(codex_dir.join("config.toml"), legacy).unwrap();
1687
1688 install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1689
1690 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1691 assert!(
1692 !cfg.contains("openai_base_url"),
1693 "backend-api openai_base_url override must be removed (breaks remote), got:\n{cfg}"
1694 );
1695 assert!(
1696 cfg.contains(&format!("model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"")),
1697 "current ChatGPT provider must be written, got:\n{cfg}"
1698 );
1699 assert!(
1700 cfg.contains(&format!(
1701 "chatgpt_base_url = \"http://127.0.0.1:{port}/backend-api/\""
1702 )),
1703 "current ChatGPT backend URL must be written, got:\n{cfg}"
1704 );
1705 assert!(cfg.contains("model = \"gpt-5.5\""));
1706 }
1707
1708 #[test]
1711 fn render_codex_config_is_idempotent() {
1712 let entries = vec![("openai_base_url", "http://127.0.0.1:4444/v1".to_string())];
1713 let once = render_codex_config("model = \"gpt-5.5\"\n", &entries, None);
1714 let twice = render_codex_config(&once, &entries, None);
1715 assert_eq!(once, twice, "render must be idempotent");
1716 assert!(once.starts_with("openai_base_url = \"http://127.0.0.1:4444/v1\"\n"));
1717 assert!(once.contains("model = \"gpt-5.5\""));
1718 }
1719
1720 const LEGACY_CHATGPT_PROVIDER_BLOCK: &str = "[model_providers.leanctx-chatgpt]\n\
1724 name = \"OpenAI\"\n\
1725 base_url = \"http://127.0.0.1:4444/backend-api/codex\"\n\
1726 requires_openai_auth = true\n\
1727 supports_websockets = false\n";
1728
1729 #[test]
1730 fn strip_codex_proxy_entries_preserves_nested_model_provider() {
1731 let body = format!(
1732 "model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"\n\
1733 openai_base_url = \"http://127.0.0.1:4444/backend-api/codex\"\n\
1734 chatgpt_base_url = \"http://127.0.0.1:4444/backend-api\"\n\n\
1735 {LEGACY_CHATGPT_PROVIDER_BLOCK}\n\
1736 [profiles.work]\n\
1737 model_provider = \"openai\"\n\
1738 openai_base_url = \"http://127.0.0.1:9999/v1\"\n"
1739 );
1740
1741 let out = strip_codex_proxy_entries(&body);
1742
1743 assert!(
1744 !out.contains(&format!("[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]")),
1745 "generated provider block must be removed, got:\n{out}"
1746 );
1747 assert!(
1748 out.contains(
1749 "[profiles.work]\nmodel_provider = \"openai\"\nopenai_base_url = \"http://127.0.0.1:9999/v1\""
1750 ),
1751 "profile provider config must be preserved, got:\n{out}"
1752 );
1753 }
1754
1755 #[test]
1756 fn codex_proxy_cleanup_detection_ignores_plain_openai_provider() {
1757 assert!(!codex_config_has_local_proxy_entry(
1758 "model_provider = \"openai\"\n"
1759 ));
1760 assert!(codex_config_has_local_proxy_entry(&format!(
1761 "model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"\n"
1762 )));
1763 }
1764
1765 #[test]
1768 fn render_codex_config_inserts_before_first_table() {
1769 let body = "model = \"gpt-5.5\"\n\n[features]\nhooks = true\n";
1770 let entries = vec![("openai_base_url", "http://127.0.0.1:4444/v1".to_string())];
1771 let out = render_codex_config(body, &entries, None);
1772 let key_idx = out.find("openai_base_url").expect("key present");
1773 let table_idx = out.find("[features]").expect("table present");
1774 assert!(
1775 key_idx < table_idx,
1776 "openai_base_url must precede the first table, got:\n{out}"
1777 );
1778 }
1779
1780 #[test]
1782 fn auth_is_chatgpt_detects_login_mode() {
1783 let dir = tempfile::tempdir().unwrap();
1784 let codex_dir = dir.path().join(".codex");
1785 std::fs::create_dir_all(&codex_dir).unwrap();
1786
1787 assert!(!auth_is_chatgpt(&codex_dir), "no auth.json => not chatgpt");
1788
1789 std::fs::write(
1790 codex_dir.join("auth.json"),
1791 r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-test"}"#,
1792 )
1793 .unwrap();
1794 assert!(!auth_is_chatgpt(&codex_dir), "apikey mode => not chatgpt");
1795
1796 std::fs::write(
1797 codex_dir.join("auth.json"),
1798 r#"{"auth_mode":"chatgpt","tokens":{"access_token":"x"}}"#,
1799 )
1800 .unwrap();
1801 assert!(auth_is_chatgpt(&codex_dir), "chatgpt mode => true");
1802
1803 for mode in ["chatgptAuthTokens", "personalAccessToken", "agentIdentity"] {
1804 std::fs::write(
1805 codex_dir.join("auth.json"),
1806 format!(r#"{{"auth_mode":"{mode}","tokens":{{"access_token":"x"}}}}"#),
1807 )
1808 .unwrap();
1809 assert!(auth_is_chatgpt(&codex_dir), "{mode} => true");
1810 }
1811 }
1812
1813 #[test]
1815 fn shell_export_includes_anthropic_with_key() {
1816 if claude_dir_overridden() {
1817 return;
1818 }
1819 let home = tempfile::tempdir().unwrap();
1820 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1821 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1822 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1823 let port = listener.local_addr().unwrap().port();
1824
1825 install_shell_exports(home.path(), port, true);
1826
1827 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1828 assert!(
1829 rc.contains(&format!(
1830 "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
1831 )),
1832 "API-key mode must export ANTHROPIC_BASE_URL"
1833 );
1834 }
1835
1836 fn read_pi_models(agent_dir: &Path) -> serde_json::Value {
1837 let raw = std::fs::read_to_string(agent_dir.join("models.json")).unwrap();
1838 crate::core::jsonc::parse_jsonc(&raw).unwrap()
1839 }
1840
1841 #[test]
1845 fn pi_env_fresh_install_writes_both_providers() {
1846 let dir = tempfile::tempdir().unwrap();
1847 let agent_dir = dir.path().join(".pi/agent");
1848 std::fs::create_dir_all(&agent_dir).unwrap();
1849 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1850 let port = listener.local_addr().unwrap().port();
1851
1852 install_pi_env_at(&agent_dir, port, true, false);
1853
1854 let doc = read_pi_models(&agent_dir);
1855 assert_eq!(
1856 pi_provider_base_url(&doc, "anthropic"),
1857 format!("http://127.0.0.1:{port}"),
1858 "Anthropic gets the bare origin (SDK appends /v1 itself)"
1859 );
1860 assert_eq!(
1861 pi_provider_base_url(&doc, "openai"),
1862 format!("http://127.0.0.1:{port}/v1"),
1863 "OpenAI gets the /v1-suffixed URL (#366)"
1864 );
1865 }
1866
1867 #[test]
1870 fn pi_env_preserves_custom_remote_endpoint_without_force() {
1871 let dir = tempfile::tempdir().unwrap();
1872 let agent_dir = dir.path().join(".pi/agent");
1873 std::fs::create_dir_all(&agent_dir).unwrap();
1874 std::fs::write(
1875 agent_dir.join("models.json"),
1876 r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1877 )
1878 .unwrap();
1879 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1880 let port = listener.local_addr().unwrap().port();
1881
1882 install_pi_env_at(&agent_dir, port, true, false);
1883
1884 let doc = read_pi_models(&agent_dir);
1885 assert_eq!(
1886 pi_provider_base_url(&doc, "anthropic"),
1887 "https://gw.example.com",
1888 "custom remote endpoint must be preserved without --force"
1889 );
1890 assert_eq!(
1891 pi_provider_base_url(&doc, "openai"),
1892 format!("http://127.0.0.1:{port}/v1"),
1893 "the untouched provider still gets the proxy"
1894 );
1895 }
1896
1897 #[test]
1899 fn pi_env_force_overrides_custom_endpoint() {
1900 let dir = tempfile::tempdir().unwrap();
1901 let agent_dir = dir.path().join(".pi/agent");
1902 std::fs::create_dir_all(&agent_dir).unwrap();
1903 std::fs::write(
1904 agent_dir.join("models.json"),
1905 r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1906 )
1907 .unwrap();
1908 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1909 let port = listener.local_addr().unwrap().port();
1910
1911 install_pi_env_at(&agent_dir, port, true, true);
1912
1913 let doc = read_pi_models(&agent_dir);
1914 assert_eq!(
1915 pi_provider_base_url(&doc, "anthropic"),
1916 format!("http://127.0.0.1:{port}"),
1917 "--force must override the custom endpoint"
1918 );
1919 }
1920
1921 #[test]
1923 fn pi_env_skips_when_agent_dir_absent() {
1924 let dir = tempfile::tempdir().unwrap();
1925 let agent_dir = dir.path().join(".pi/agent");
1926
1927 install_pi_env_at(&agent_dir, 19999, true, false);
1928
1929 assert!(
1930 !agent_dir.join("models.json").exists(),
1931 "no Pi config must be created when Pi is not configured"
1932 );
1933 }
1934
1935 #[test]
1938 fn pi_uninstall_removes_only_local_endpoints() {
1939 let dir = tempfile::tempdir().unwrap();
1940 let agent_dir = dir.path().join(".pi/agent");
1941 std::fs::create_dir_all(&agent_dir).unwrap();
1942 std::fs::write(
1943 agent_dir.join("models.json"),
1944 r#"{"providers":{"anthropic":{"baseUrl":"http://127.0.0.1:4444"},"openai":{"baseUrl":"https://api.openai.com/v1"}}}"#,
1945 )
1946 .unwrap();
1947
1948 uninstall_pi_env_at(&agent_dir, true);
1949
1950 let doc = read_pi_models(&agent_dir);
1951 assert_eq!(
1952 pi_provider_base_url(&doc, "anthropic"),
1953 "",
1954 "the local proxy endpoint we set must be removed"
1955 );
1956 assert_eq!(
1957 pi_provider_base_url(&doc, "openai"),
1958 "https://api.openai.com/v1",
1959 "a custom endpoint must be preserved on disable"
1960 );
1961 }
1962}