1mod codex_toml;
2
3use std::{
4 collections::HashMap,
5 future::Future,
6 process::{Command, Stdio},
7 sync::{Arc, Mutex as StdMutex, OnceLock, Weak},
8 time::Duration,
9};
10
11#[cfg(unix)]
12use std::os::unix::process::CommandExt;
13
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16use tokio::sync::RwLock;
17
18use crate::{
19 app_config::AppType,
20 codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic},
21 config::{get_claude_settings_path, read_json_file, write_json_file, write_text_file},
22 database::Database,
23 gemini_config::{
24 env_to_json, get_gemini_env_path, json_to_env, read_gemini_env, write_gemini_env_atomic,
25 },
26 provider::Provider,
27 proxy::{
28 switch_lock::SwitchLockManager,
29 types::{GlobalProxyConfig, ProxyTakeoverStatus},
30 ProxyConfig, ProxyServer, ProxyServerInfo, ProxyStatus,
31 },
32 AppError,
33};
34
35const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
36const PROXY_RUNTIME_SESSION_KEY: &str = "proxy_runtime_session";
37const PROXY_RUNTIME_KIND_ENV_KEY: &str = "CC_SWITCH_PROXY_RUNTIME_KIND";
38const PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY: &str = "CC_SWITCH_PROXY_SESSION_TOKEN";
39
40const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [
41 "ANTHROPIC_MODEL",
42 "ANTHROPIC_REASONING_MODEL",
43 "ANTHROPIC_DEFAULT_HAIKU_MODEL",
44 "ANTHROPIC_DEFAULT_SONNET_MODEL",
45 "ANTHROPIC_DEFAULT_OPUS_MODEL",
46 "ANTHROPIC_SMALL_FAST_MODEL",
47];
48
49#[derive(Clone)]
50pub struct ProxyService {
51 db: Arc<Database>,
52 runtime: Arc<ProxyRuntimeState>,
53 switch_locks: SwitchLockManager,
54}
55
56struct ProxyRuntimeState {
57 server: RwLock<Option<ProxyServer>>,
58}
59
60#[derive(Debug, Clone, Copy, Default)]
61pub struct HotSwitchOutcome {
62 pub logical_target_changed: bool,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67enum PersistedProxyRuntimeSessionKind {
68 #[serde(alias = "foreground")]
69 Foreground,
70 ManagedExternal,
71}
72
73impl Default for PersistedProxyRuntimeSessionKind {
74 fn default() -> Self {
75 Self::Foreground
76 }
77}
78
79impl PersistedProxyRuntimeSessionKind {
80 fn from_env() -> Self {
81 match std::env::var(PROXY_RUNTIME_KIND_ENV_KEY).ok().as_deref() {
82 Some("managed_external") => Self::ManagedExternal,
83 _ => Self::Foreground,
84 }
85 }
86
87 fn as_env_value(&self) -> &'static str {
88 match self {
89 Self::Foreground => "foreground",
90 Self::ManagedExternal => "managed_external",
91 }
92 }
93
94 fn is_managed_external(&self) -> bool {
95 matches!(self, Self::ManagedExternal)
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100struct PersistedProxyRuntimeSession {
101 pid: u32,
102 address: String,
103 port: u16,
104 started_at: String,
105 #[serde(default)]
106 kind: PersistedProxyRuntimeSessionKind,
107 #[serde(default)]
108 session_token: Option<String>,
109}
110
111enum ExternalProxyStatusProbe {
112 Matched(ProxyStatus),
113 Mismatched,
114 Unreachable,
115}
116
117fn proxy_runtime_registry() -> &'static StdMutex<HashMap<String, Weak<ProxyRuntimeState>>> {
118 static REGISTRY: OnceLock<StdMutex<HashMap<String, Weak<ProxyRuntimeState>>>> = OnceLock::new();
119 REGISTRY.get_or_init(|| StdMutex::new(HashMap::new()))
120}
121
122impl ProxyService {
123 fn run_in_blocking_runtime<T, F, Fut>(&self, task: F) -> Result<T, String>
124 where
125 T: Send + 'static,
126 F: FnOnce(ProxyService) -> Fut + Send + 'static,
127 Fut: Future<Output = Result<T, String>> + Send + 'static,
128 {
129 let service = self.clone();
130 let handle = std::thread::Builder::new()
131 .name("cc-switch-proxy-blocking".to_string())
132 .spawn(move || {
133 let runtime = tokio::runtime::Builder::new_current_thread()
134 .enable_all()
135 .build()
136 .map_err(|e| format!("failed to create async runtime: {e}"))?;
137 runtime.block_on(task(service))
138 })
139 .map_err(|e| format!("spawn proxy runtime helper failed: {e}"))?;
140
141 handle
142 .join()
143 .map_err(|_| "proxy runtime helper panicked".to_string())?
144 }
145
146 pub fn is_running_blocking(&self) -> Result<bool, String> {
147 self.run_in_blocking_runtime(|service| async move { Ok(service.is_running().await) })
148 }
149
150 pub fn is_app_takeover_active_blocking(&self, app_type: &AppType) -> Result<bool, String> {
151 let app_type = app_type.clone();
152 self.run_in_blocking_runtime(move |service| async move {
153 service.is_app_takeover_active(&app_type).await
154 })
155 }
156
157 pub fn recover_takeovers_on_startup_blocking(&self) -> Result<(), String> {
158 self.run_in_blocking_runtime(|service| async move {
159 service.recover_takeovers_on_startup().await
160 })
161 }
162
163 pub fn new(db: Arc<Database>) -> Self {
164 let runtime = Self::shared_runtime_state(db.runtime_key());
165 Self {
166 db,
167 runtime,
168 switch_locks: SwitchLockManager::new(),
169 }
170 }
171
172 fn shared_runtime_state(runtime_key: &str) -> Arc<ProxyRuntimeState> {
173 let mut registry = proxy_runtime_registry()
174 .lock()
175 .unwrap_or_else(|poisoned| poisoned.into_inner());
176
177 if let Some(existing) = registry.get(runtime_key).and_then(Weak::upgrade) {
178 return existing;
179 }
180
181 let runtime = Arc::new(ProxyRuntimeState {
182 server: RwLock::new(None),
183 });
184 registry.insert(runtime_key.to_string(), Arc::downgrade(&runtime));
185 runtime
186 }
187
188 pub async fn start(&self) -> Result<ProxyServerInfo, String> {
189 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
190 let config = self.get_config().await.map_err(|e| e.to_string())?;
191 self.start_with_resolved_config_unlocked(config).await
192 }
193
194 pub async fn start_with_runtime_config(
195 &self,
196 config: ProxyConfig,
197 ) -> Result<ProxyServerInfo, String> {
198 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
199 self.start_with_resolved_config_unlocked(config).await
200 }
201
202 pub async fn start_managed_session(&self, app_type: &str) -> Result<ProxyServerInfo, String> {
203 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
204 self.start_managed_session_unlocked(app_type).await
205 }
206
207 async fn start_managed_session_unlocked(
208 &self,
209 app_type: &str,
210 ) -> Result<ProxyServerInfo, String> {
211 Self::ensure_managed_sessions_supported()?;
212
213 let app_type = Self::takeover_app_from_str(app_type)?;
214 self.validate_app_proxy_activation(&app_type).await?;
215 let current_status = self.get_status().await;
216 if current_status.running {
217 return Err(
218 "proxy is already running; stop the current runtime before starting a managed session"
219 .to_string(),
220 );
221 }
222
223 let executable = Self::resolve_managed_proxy_executable()?;
224 let session_token = uuid::Uuid::new_v4().to_string();
225 let mut child = Command::new(executable);
226 child
227 .arg("proxy")
228 .arg("serve")
229 .arg("--takeover")
230 .arg(app_type.as_str())
231 .env(
232 PROXY_RUNTIME_KIND_ENV_KEY,
233 PersistedProxyRuntimeSessionKind::ManagedExternal.as_env_value(),
234 )
235 .env(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY, &session_token)
236 .env(
237 crate::services::state_coordination::RESTORE_GUARD_BYPASS_ENV_KEY,
238 "1",
239 )
240 .stdin(Stdio::null())
241 .stdout(Stdio::null())
242 .stderr(Stdio::null());
243
244 #[cfg(unix)]
245 unsafe {
246 child.pre_exec(|| {
247 if libc::setsid() == -1 {
248 return Err(std::io::Error::last_os_error());
249 }
250 Ok(())
251 });
252 }
253
254 let mut child = child
255 .spawn()
256 .map_err(|error| format!("spawn managed proxy session failed: {error}"))?;
257 let child_pid = child.id();
258
259 let start_deadline = tokio::time::Instant::now() + Duration::from_secs(10);
260 loop {
261 if let Some(status) = child
262 .try_wait()
263 .map_err(|error| format!("poll managed proxy process failed: {error}"))?
264 {
265 return Err(format!(
266 "managed proxy session exited before becoming ready: {}",
267 status
268 ));
269 }
270
271 if let Some(info) = self
272 .managed_session_ready_info(child_pid, session_token.as_str())
273 .await
274 {
275 Self::spawn_managed_child_reaper(child);
276 return Ok(info);
277 }
278
279 if tokio::time::Instant::now() >= start_deadline {
280 let _ = child.kill();
281 let _ = child.wait();
282 let _ = self.clear_persisted_runtime_session();
283 return Err("managed proxy session did not become ready in time".to_string());
284 }
285
286 tokio::time::sleep(Duration::from_millis(100)).await;
287 }
288 }
289
290 pub async fn set_managed_session_for_app(
291 &self,
292 app_type: &str,
293 enabled: bool,
294 ) -> Result<(), String> {
295 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
296 self.set_managed_session_for_app_unlocked(app_type, enabled)
297 .await
298 }
299
300 async fn set_managed_session_for_app_unlocked(
301 &self,
302 app_type: &str,
303 enabled: bool,
304 ) -> Result<(), String> {
305 let app_type = Self::takeover_app_from_str(app_type)?;
306
307 if enabled {
308 let status = self.get_status().await;
309 if !status.running {
310 self.start_managed_session_unlocked(app_type.as_str())
311 .await?;
312 return Ok(());
313 }
314
315 if self
316 .load_persisted_runtime_session()
317 .is_some_and(|session| session.kind.is_managed_external())
318 {
319 self.enable_takeover_for_app_unlocked(&app_type).await?;
320 return Ok(());
321 }
322
323 return Err(
324 "proxy is already running in foreground mode; stop the current runtime before attaching another app to a managed session"
325 .to_string(),
326 );
327 }
328
329 let stop_server_when_last = self
330 .load_persisted_runtime_session()
331 .map(|session| session.kind.is_managed_external())
332 .unwrap_or(false);
333 self.disable_takeover_for_app_unlocked(&app_type, stop_server_when_last)
334 .await
335 }
336
337 async fn start_with_resolved_config_unlocked(
338 &self,
339 config: ProxyConfig,
340 ) -> Result<ProxyServerInfo, String> {
341 self.sync_persisted_global_proxy_enabled(true).await?;
342
343 if let Some(server) = self.runtime.server.read().await.as_ref() {
344 let status = server.get_status().await;
345 if status.running {
346 return Ok(ProxyServerInfo {
347 address: status.address,
348 port: status.port,
349 started_at: chrono::Utc::now().to_rfc3339(),
350 });
351 }
352 }
353
354 let server = ProxyServer::new(config, self.db.clone());
355 let info = server.start().await?;
356 if !Self::defer_runtime_session_publish() {
357 if let Err(error) = self.persist_runtime_session(&info) {
358 let _ = server.stop().await;
359 return Err(error);
360 }
361 }
362 *self.runtime.server.write().await = Some(server);
363 Ok(info)
364 }
365
366 pub(crate) fn publish_runtime_session_if_needed(
367 &self,
368 info: &ProxyServerInfo,
369 ) -> Result<(), String> {
370 if Self::defer_runtime_session_publish() && self.load_persisted_runtime_session().is_none()
371 {
372 self.persist_runtime_session(info)?;
373 }
374 Ok(())
375 }
376
377 pub async fn recover_takeovers_on_startup(&self) -> Result<(), String> {
378 for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
379 let app_key = app_type.as_str();
380 let app_proxy = self
381 .db
382 .get_proxy_config_for_app(app_key)
383 .await
384 .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
385 let has_backup = self
386 .db
387 .get_live_backup(app_key)
388 .await
389 .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
390 .is_some();
391 let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type);
392
393 if !app_proxy.enabled && !has_backup && !live_taken_over {
394 continue;
395 }
396
397 if has_backup {
398 self.restore_live_config_for_app(&app_type).await?;
399 self.db
400 .delete_live_backup(app_key)
401 .await
402 .map_err(|error| format!("delete live backup for {app_key} failed: {error}"))?;
403 } else if live_taken_over {
404 self.restore_live_from_current_provider(&app_type).await?;
405 }
406
407 if app_proxy.enabled {
408 let mut cleared = app_proxy;
409 cleared.enabled = false;
410 self.db
411 .update_proxy_config_for_app(cleared)
412 .await
413 .map_err(|error| {
414 format!("clear takeover flag for {app_key} failed: {error}")
415 })?;
416 }
417 }
418
419 Ok(())
420 }
421
422 pub async fn stop(&self) -> Result<(), String> {
423 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
424 self.stop_server_unlocked().await
425 }
426
427 pub async fn stop_with_restore(&self) -> Result<(), String> {
428 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
429
430 if let Err(error) = self.stop_server_unlocked().await {
431 log::warn!("stop proxy runtime before restore failed: {error}");
432 }
433
434 self.restore_active_takeovers_on_shutdown_unlocked().await
435 }
436
437 async fn stop_server_unlocked(&self) -> Result<(), String> {
438 let mut stopped_runtime = false;
439
440 if let Some(server) = self.runtime.server.read().await.as_ref() {
441 server.stop().await?;
442 self.clear_persisted_runtime_session()?;
443 self.sync_persisted_global_proxy_enabled(false).await?;
444 return Ok(());
445 }
446
447 if let Some(session) = self.load_persisted_runtime_session() {
448 if session.kind.is_managed_external() {
449 let probe = Self::probe_external_proxy_status(&session).await;
450 let should_terminate = match probe {
451 ExternalProxyStatusProbe::Matched(_) => true,
452 ExternalProxyStatusProbe::Mismatched => false,
453 ExternalProxyStatusProbe::Unreachable => {
454 Self::has_managed_external_ownership_signal(&session)
455 }
456 };
457
458 if Self::is_process_alive(session.pid) && should_terminate {
459 Self::terminate_external_process(session.pid).await?;
460 stopped_runtime = true;
461 }
462 }
463 }
464
465 self.clear_persisted_runtime_session()?;
466 self.sync_persisted_global_proxy_enabled(false).await?;
467 if stopped_runtime {
468 Ok(())
469 } else {
470 Err("proxy server is not running".to_string())
471 }
472 }
473
474 pub async fn is_running(&self) -> bool {
475 self.get_status().await.running
476 }
477
478 pub async fn get_status(&self) -> ProxyStatus {
479 if let Some(server) = self.runtime.server.read().await.as_ref() {
480 return server.get_status().await;
481 }
482
483 if let Some(session) = self.load_persisted_runtime_session() {
484 if session.kind.is_managed_external() {
485 if Self::is_process_alive(session.pid) {
486 match Self::probe_external_proxy_status(&session).await {
487 ExternalProxyStatusProbe::Matched(status) => return status,
488 ExternalProxyStatusProbe::Mismatched => {
489 let _ = self.clear_persisted_runtime_session();
490 return ProxyStatus::default();
491 }
492 ExternalProxyStatusProbe::Unreachable => {
493 if Self::has_managed_external_ownership_signal(&session) {
494 let uptime_seconds =
495 chrono::DateTime::parse_from_rfc3339(&session.started_at)
496 .ok()
497 .map(|started_at| {
498 let started_at = started_at.with_timezone(&chrono::Utc);
499 (chrono::Utc::now() - started_at).num_seconds().max(0)
500 as u64
501 })
502 .unwrap_or(0);
503
504 return ProxyStatus {
505 running: true,
506 address: session.address.clone(),
507 port: session.port,
508 uptime_seconds,
509 managed_session_token: session.session_token.clone(),
510 ..ProxyStatus::default()
511 };
512 }
513 }
514 }
515 }
516
517 let _ = self.clear_persisted_runtime_session();
518 return ProxyStatus::default();
519 }
520
521 if Self::is_process_alive(session.pid) {
522 let uptime_seconds = chrono::DateTime::parse_from_rfc3339(&session.started_at)
523 .ok()
524 .map(|started_at| {
525 let started_at = started_at.with_timezone(&chrono::Utc);
526 (chrono::Utc::now() - started_at).num_seconds().max(0) as u64
527 })
528 .unwrap_or(0);
529
530 return ProxyStatus {
531 running: true,
532 address: session.address,
533 port: session.port,
534 uptime_seconds,
535 ..ProxyStatus::default()
536 };
537 }
538
539 let _ = self.clear_persisted_runtime_session();
540 }
541
542 ProxyStatus::default()
543 }
544
545 pub async fn get_config(&self) -> Result<ProxyConfig, AppError> {
546 self.db.get_proxy_config().await
547 }
548
549 pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), AppError> {
550 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard()
551 .await
552 .map_err(AppError::Message)?;
553 self.db.update_proxy_config(config.clone()).await
554 }
555
556 pub async fn update_circuit_breaker_configs(
557 &self,
558 config: crate::proxy::circuit_breaker::CircuitBreakerConfig,
559 ) -> Result<(), String> {
560 if let Some(server) = self.runtime.server.read().await.as_ref() {
561 server.update_circuit_breaker_configs(config).await;
562 }
563
564 Ok(())
565 }
566
567 pub async fn reset_provider_circuit_breaker(
568 &self,
569 provider_id: &str,
570 app_type: &str,
571 ) -> Result<(), String> {
572 if let Some(server) = self.runtime.server.read().await.as_ref() {
573 server
574 .reset_provider_circuit_breaker(provider_id, app_type)
575 .await;
576 }
577
578 Ok(())
579 }
580
581 pub async fn get_global_config(&self) -> Result<GlobalProxyConfig, AppError> {
582 self.db.get_global_proxy_config().await
583 }
584
585 pub async fn set_global_enabled(&self, enabled: bool) -> Result<GlobalProxyConfig, AppError> {
586 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard()
587 .await
588 .map_err(AppError::Message)?;
589 let mut config = self.get_global_config().await?;
590 config.proxy_enabled = enabled;
591 self.db.update_global_proxy_config(config.clone()).await?;
592 Ok(config)
593 }
594
595 pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
596 Ok(ProxyTakeoverStatus {
597 claude: self
598 .db
599 .get_proxy_config_for_app("claude")
600 .await
601 .map_err(|error| format!("load claude proxy config failed: {error}"))?
602 .enabled,
603 codex: self
604 .db
605 .get_proxy_config_for_app("codex")
606 .await
607 .map_err(|error| format!("load codex proxy config failed: {error}"))?
608 .enabled,
609 gemini: self
610 .db
611 .get_proxy_config_for_app("gemini")
612 .await
613 .map_err(|error| format!("load gemini proxy config failed: {error}"))?
614 .enabled,
615 })
616 }
617
618 pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {
619 let app_type = Self::takeover_app_from_str(app_type)?;
620 let _guard = crate::services::state_coordination::acquire_restore_mutation_guard().await?;
621
622 if enabled {
623 self.enable_takeover_for_app_unlocked(&app_type).await
624 } else {
625 self.disable_takeover_for_app_unlocked(&app_type, true)
626 .await
627 }
628 }
629
630 pub async fn is_app_takeover_active(&self, app_type: &AppType) -> Result<bool, String> {
631 let app_key = app_type.as_str();
632 let app_proxy = self
633 .db
634 .get_proxy_config_for_app(app_key)
635 .await
636 .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
637 if app_proxy.enabled {
638 return Ok(true);
639 }
640
641 if self
642 .db
643 .get_live_backup(app_key)
644 .await
645 .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
646 .is_some()
647 {
648 return Ok(true);
649 }
650
651 Ok(self.detect_takeover_in_live_config_for_app(app_type))
652 }
653
654 pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
655 match app_type {
656 AppType::Claude => self
657 .read_claude_live()
658 .ok()
659 .is_some_and(|live| Self::is_claude_live_taken_over(&live)),
660 AppType::Codex => self
661 .read_codex_live()
662 .ok()
663 .is_some_and(|live| Self::is_codex_live_taken_over(&live)),
664 AppType::Gemini => self
665 .read_gemini_live()
666 .ok()
667 .is_some_and(|live| Self::is_gemini_live_taken_over(&live)),
668 _ => false,
669 }
670 }
671
672 fn is_claude_live_taken_over(config: &Value) -> bool {
673 let Some(env) = config.get("env").and_then(Value::as_object) else {
674 return false;
675 };
676
677 [
678 "ANTHROPIC_AUTH_TOKEN",
679 "ANTHROPIC_API_KEY",
680 "OPENROUTER_API_KEY",
681 "OPENAI_API_KEY",
682 ]
683 .iter()
684 .any(|key| {
685 env.get(*key)
686 .and_then(Value::as_str)
687 .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
688 })
689 }
690
691 fn is_codex_live_taken_over(config: &Value) -> bool {
692 config
693 .get("auth")
694 .and_then(|auth| auth.get("OPENAI_API_KEY"))
695 .and_then(Value::as_str)
696 .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
697 }
698
699 fn is_gemini_live_taken_over(config: &Value) -> bool {
700 config
701 .get("env")
702 .and_then(|env| env.get("GEMINI_API_KEY"))
703 .and_then(Value::as_str)
704 .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
705 }
706
707 pub async fn update_live_backup_from_provider(
708 &self,
709 app_type: &str,
710 provider: &Provider,
711 ) -> Result<(), String> {
712 let app_type = Self::takeover_app_from_str(app_type)?;
713 let mut backup_snapshot = self.build_live_snapshot_from_provider(&app_type, provider)?;
714
715 if matches!(app_type, AppType::Codex) {
716 let existing_backup_value = self
717 .db
718 .get_live_backup(app_type.as_str())
719 .await
720 .map_err(|error| {
721 format!(
722 "load {} existing live backup failed: {error}",
723 app_type.as_str()
724 )
725 })?
726 .map(|backup| {
727 serde_json::from_str::<Value>(&backup.original_config).map_err(|error| {
728 format!(
729 "parse {} existing live backup failed: {error}",
730 app_type.as_str()
731 )
732 })
733 })
734 .transpose()?;
735
736 if let Some(existing_value) = existing_backup_value.as_ref() {
737 Self::preserve_codex_mcp_servers_in_backup(&mut backup_snapshot, existing_value)?;
738 }
739
740 let anchor_config_text = existing_backup_value
741 .as_ref()
742 .and_then(|value| value.get("config"))
743 .and_then(Value::as_str);
744 crate::codex_config::normalize_codex_settings_config_model_provider(
745 &mut backup_snapshot,
746 anchor_config_text,
747 )
748 .map_err(|error| format!("normalize Codex live backup failed: {error}"))?;
749 }
750
751 if matches!(app_type, AppType::Gemini) {
752 backup_snapshot = json!({
753 "env": backup_snapshot
754 .get("env")
755 .cloned()
756 .unwrap_or_else(|| json!({}))
757 });
758 }
759
760 self.save_live_backup_snapshot(app_type.as_str(), &backup_snapshot)
761 .await
762 }
763
764 pub async fn hot_switch_provider(
765 &self,
766 app_type: &str,
767 provider_id: &str,
768 ) -> Result<HotSwitchOutcome, String> {
769 let _guard = self.switch_locks.lock_for_app(app_type).await;
770
771 let app_type_enum = Self::takeover_app_from_str(app_type)?;
772 let provider = self
773 .db
774 .get_provider_by_id(provider_id, app_type)
775 .map_err(|e| format!("读取供应商失败: {e}"))?
776 .ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
777
778 let logical_target_changed =
779 crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
780 .map_err(|e| format!("读取当前供应商失败: {e}"))?
781 .as_deref()
782 != Some(provider_id);
783
784 let has_backup = self
785 .db
786 .get_live_backup(app_type_enum.as_str())
787 .await
788 .map_err(|e| format!("读取 {app_type} 备份失败: {e}"))?
789 .is_some();
790 let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
791 let should_sync_backup = has_backup || live_taken_over;
792
793 self.db
794 .set_current_provider(app_type_enum.as_str(), provider_id)
795 .map_err(|e| format!("更新当前供应商失败: {e}"))?;
796 crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
797 .map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
798
799 if should_sync_backup {
800 self.update_live_backup_from_provider(app_type, &provider)
801 .await?;
802 }
803
804 if let Some(server) = self.runtime.server.read().await.as_ref() {
805 server
806 .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
807 .await;
808 }
809
810 Ok(HotSwitchOutcome {
811 logical_target_changed,
812 })
813 }
814
815 pub async fn switch_proxy_target(
816 &self,
817 app_type: &str,
818 provider_id: &str,
819 ) -> Result<(), String> {
820 let outcome = self.hot_switch_provider(app_type, provider_id).await?;
821
822 if outcome.logical_target_changed {
823 log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
824 } else {
825 log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
826 }
827
828 Ok(())
829 }
830
831 fn preserve_codex_mcp_servers_in_backup(
832 target_settings: &mut Value,
833 existing_backup: &Value,
834 ) -> Result<(), String> {
835 let target_obj = target_settings
836 .as_object_mut()
837 .ok_or_else(|| "Codex live backup must be a JSON object".to_string())?;
838
839 let target_config = target_obj
840 .get("config")
841 .and_then(Value::as_str)
842 .unwrap_or("");
843 let mut target_doc = if target_config.trim().is_empty() {
844 toml_edit::DocumentMut::new()
845 } else {
846 target_config
847 .parse::<toml_edit::DocumentMut>()
848 .map_err(|error| format!("parse new Codex config.toml failed: {error}"))?
849 };
850
851 let existing_config = existing_backup
852 .get("config")
853 .and_then(Value::as_str)
854 .unwrap_or("");
855 if existing_config.trim().is_empty() {
856 target_obj.insert("config".to_string(), json!(target_doc.to_string()));
857 return Ok(());
858 }
859
860 let existing_doc = existing_config
861 .parse::<toml_edit::DocumentMut>()
862 .map_err(|error| format!("parse existing Codex backup failed: {error}"))?;
863
864 if let Some(existing_mcp_servers) = existing_doc.get("mcp_servers") {
865 match target_doc.get_mut("mcp_servers") {
866 Some(target_mcp_servers) => {
867 if let (Some(target_table), Some(existing_table)) = (
868 target_mcp_servers.as_table_like_mut(),
869 existing_mcp_servers.as_table_like(),
870 ) {
871 for (server_id, server_item) in existing_table.iter() {
872 if target_table.get(server_id).is_none() {
873 target_table.insert(server_id, server_item.clone());
874 }
875 }
876 }
877 }
878 None => {
879 target_doc["mcp_servers"] = existing_mcp_servers.clone();
880 }
881 }
882 }
883
884 target_obj.insert("config".to_string(), json!(target_doc.to_string()));
885 Ok(())
886 }
887
888 async fn validate_app_proxy_activation(&self, app_type: &AppType) -> Result<(), String> {
889 let app_key = app_type.as_str();
890 let app_proxy = self
891 .db
892 .get_proxy_config_for_app(app_key)
893 .await
894 .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
895
896 if app_proxy.auto_failover_enabled {
897 if self
898 .db
899 .get_failover_queue(app_key)
900 .map_err(|error| format!("load failover queue for {app_key} failed: {error}"))?
901 .is_empty()
902 {
903 return Err(
904 "cannot enable proxy because automatic failover is enabled and the failover queue is empty"
905 .to_string(),
906 );
907 }
908 return Ok(());
909 }
910
911 let Some(provider_id) =
912 crate::settings::get_effective_current_provider(self.db.as_ref(), app_type).map_err(
913 |error| {
914 format!(
915 "load effective current provider for {} failed: {error}",
916 app_type.as_str()
917 )
918 },
919 )?
920 else {
921 return Err("cannot enable proxy because no active provider is selected".to_string());
922 };
923
924 if self
925 .db
926 .get_provider_by_id(&provider_id, app_key)
927 .map_err(|error| format!("load provider {provider_id} for {app_key} failed: {error}"))?
928 .is_none()
929 {
930 return Err("cannot enable proxy because no active provider is selected".to_string());
931 }
932
933 Ok(())
934 }
935
936 pub async fn save_live_backup_snapshot(
937 &self,
938 app_type: &str,
939 snapshot: &Value,
940 ) -> Result<(), String> {
941 let app_type = Self::takeover_app_from_str(app_type)?;
942 let backup = serde_json::to_string(snapshot).map_err(|error| {
943 format!(
944 "serialize {} live backup failed: {error}",
945 app_type.as_str()
946 )
947 })?;
948 self.db
949 .save_live_backup(app_type.as_str(), &backup)
950 .await
951 .map_err(|error| format!("save {} live backup failed: {error}", app_type.as_str()))
952 }
953
954 async fn restore_active_takeovers_on_shutdown_unlocked(&self) -> Result<(), String> {
955 for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
956 self.disable_takeover_for_app_unlocked(&app_type, false)
957 .await?;
958 }
959
960 Ok(())
961 }
962
963 async fn enable_takeover_for_app_unlocked(&self, app_type: &AppType) -> Result<(), String> {
964 self.validate_app_proxy_activation(app_type).await?;
965
966 if !self.is_running().await {
967 let config = self.get_config().await.map_err(|e| e.to_string())?;
968 self.start_with_resolved_config_unlocked(config).await?;
969 }
970
971 let app_key = app_type.as_str();
972 let app_proxy = self
973 .db
974 .get_proxy_config_for_app(app_key)
975 .await
976 .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
977 let has_backup = self
978 .db
979 .get_live_backup(app_key)
980 .await
981 .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
982 .is_some();
983 let live_taken_over = self.detect_takeover_in_live_config_for_app(app_type);
984
985 if app_proxy.enabled && has_backup && live_taken_over {
986 return Ok(());
987 }
988
989 let live = self.read_or_current_provider_live(app_type).await?;
990 if !has_backup {
991 let backup = serde_json::to_string(&live)
992 .map_err(|error| format!("serialize {app_key} live backup failed: {error}"))?;
993 self.db
994 .save_live_backup(app_key, &backup)
995 .await
996 .map_err(|error| format!("save {app_key} live backup failed: {error}"))?;
997 }
998 self.sync_live_config_to_current_provider(app_type, &live)
999 .await?;
1000
1001 let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;
1002 let mut taken_over = live;
1003 self.rewrite_live_for_proxy(app_type, &mut taken_over, &proxy_url, &proxy_codex_base_url)?;
1004 self.write_live_config_for_app(app_type, &taken_over)?;
1005
1006 if !app_proxy.enabled {
1007 let mut updated = app_proxy;
1008 updated.enabled = true;
1009 self.db
1010 .update_proxy_config_for_app(updated)
1011 .await
1012 .map_err(|error| format!("set takeover flag for {app_key} failed: {error}"))?;
1013 }
1014
1015 Ok(())
1016 }
1017
1018 async fn disable_takeover_for_app_unlocked(
1019 &self,
1020 app_type: &AppType,
1021 stop_server_when_last: bool,
1022 ) -> Result<(), String> {
1023 let app_key = app_type.as_str();
1024 let app_proxy = self
1025 .db
1026 .get_proxy_config_for_app(app_key)
1027 .await
1028 .map_err(|error| format!("load proxy config for {app_key} failed: {error}"))?;
1029 let has_backup = self
1030 .db
1031 .get_live_backup(app_key)
1032 .await
1033 .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
1034 .is_some();
1035 let live_taken_over = self.detect_takeover_in_live_config_for_app(app_type);
1036
1037 if !app_proxy.enabled && !has_backup && !live_taken_over {
1038 return Ok(());
1039 }
1040
1041 if has_backup {
1042 self.restore_live_config_for_app(app_type).await?;
1043 self.db
1044 .delete_live_backup(app_key)
1045 .await
1046 .map_err(|error| format!("delete live backup for {app_key} failed: {error}"))?;
1047 } else if live_taken_over {
1048 self.restore_live_from_current_provider(app_type).await?;
1049 }
1050
1051 if app_proxy.enabled {
1052 let mut cleared = app_proxy;
1053 cleared.enabled = false;
1054 self.db
1055 .update_proxy_config_for_app(cleared)
1056 .await
1057 .map_err(|error| format!("clear takeover flag for {app_key} failed: {error}"))?;
1058 }
1059
1060 self.db
1061 .clear_provider_health_for_app(app_key)
1062 .await
1063 .map_err(|error| format!("clear provider health for {app_key} failed: {error}"))?;
1064
1065 if stop_server_when_last
1066 && !self
1067 .db
1068 .is_live_takeover_active()
1069 .await
1070 .map_err(|error| format!("check active takeovers failed: {error}"))?
1071 {
1072 self.stop_server_unlocked().await?;
1073 }
1074
1075 Ok(())
1076 }
1077
1078 async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
1079 let app_key = app_type.as_str();
1080 let Some(backup) = self
1081 .db
1082 .get_live_backup(app_key)
1083 .await
1084 .map_err(|error| format!("load live backup for {app_key} failed: {error}"))?
1085 else {
1086 return Ok(());
1087 };
1088
1089 let restored: Value = serde_json::from_str(&backup.original_config)
1090 .map_err(|error| format!("parse {app_key} live backup failed: {error}"))?;
1091 self.write_live_config_for_app(app_type, &restored)
1092 }
1093
1094 async fn restore_live_from_current_provider(&self, app_type: &AppType) -> Result<(), String> {
1095 let Some(settings) = self.current_provider_settings(app_type).await? else {
1096 return self.clear_stale_takeover_from_live_config(app_type);
1097 };
1098 self.write_live_config_for_app(app_type, &settings)
1099 }
1100
1101 async fn current_provider_settings(&self, app_type: &AppType) -> Result<Option<Value>, String> {
1102 let Some(current_provider) =
1103 crate::settings::get_effective_current_provider(self.db.as_ref(), app_type).map_err(
1104 |error| {
1105 format!(
1106 "load effective current provider for {} failed: {error}",
1107 app_type.as_str()
1108 )
1109 },
1110 )?
1111 else {
1112 return Ok(None);
1113 };
1114
1115 self.db
1116 .get_provider_by_id(¤t_provider, app_type.as_str())
1117 .map_err(|error| {
1118 format!(
1119 "load provider {} for {} failed: {error}",
1120 current_provider,
1121 app_type.as_str()
1122 )
1123 })
1124 .and_then(|provider| {
1125 provider
1126 .map(|provider| {
1127 self.build_current_provider_restore_snapshot(app_type, &provider)
1128 })
1129 .transpose()
1130 })
1131 }
1132
1133 fn build_current_provider_restore_snapshot(
1134 &self,
1135 app_type: &AppType,
1136 provider: &Provider,
1137 ) -> Result<Value, String> {
1138 let common_config_snippet =
1139 self.db
1140 .get_config_snippet(app_type.as_str())
1141 .map_err(|error| {
1142 format!(
1143 "load common config snippet for {} failed: {error}",
1144 app_type.as_str()
1145 )
1146 })?;
1147 let apply_common_config = provider
1148 .meta
1149 .as_ref()
1150 .and_then(|meta| meta.apply_common_config)
1151 .unwrap_or(true);
1152
1153 crate::services::provider::ProviderService::build_live_backup_snapshot(
1154 app_type,
1155 provider,
1156 common_config_snippet.as_deref(),
1157 apply_common_config,
1158 )
1159 .and_then(|mut snapshot| {
1160 if matches!(app_type, AppType::Codex) {
1161 crate::codex_config::normalize_codex_settings_config_model_provider(
1162 &mut snapshot,
1163 None,
1164 )?;
1165 }
1166 Ok(snapshot)
1167 })
1168 .map_err(|error| {
1169 format!(
1170 "build {} current-provider restore snapshot failed: {error}",
1171 app_type.as_str()
1172 )
1173 })
1174 }
1175
1176 async fn sync_live_config_to_current_provider(
1177 &self,
1178 app_type: &AppType,
1179 live_config: &Value,
1180 ) -> Result<(), String> {
1181 enum LiveTokenSync {
1182 Claude(&'static str, String),
1183 Codex(String),
1184 Gemini(String),
1185 }
1186
1187 let Some(token_sync) = (match app_type {
1188 AppType::Claude => live_config
1189 .get("env")
1190 .and_then(Value::as_object)
1191 .and_then(|env| {
1192 [
1193 "ANTHROPIC_AUTH_TOKEN",
1194 "ANTHROPIC_API_KEY",
1195 "OPENROUTER_API_KEY",
1196 "OPENAI_API_KEY",
1197 ]
1198 .into_iter()
1199 .find_map(|key| {
1200 env.get(key)
1201 .and_then(Value::as_str)
1202 .map(|value| (key, value.trim().to_string()))
1203 })
1204 })
1205 .filter(|(_, token)| !token.is_empty() && token != PROXY_TOKEN_PLACEHOLDER)
1206 .map(|(key, token)| LiveTokenSync::Claude(key, token)),
1207 AppType::Codex => live_config
1208 .get("auth")
1209 .and_then(|auth| auth.get("OPENAI_API_KEY"))
1210 .and_then(Value::as_str)
1211 .map(str::trim)
1212 .filter(|token| !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER)
1213 .map(|token| LiveTokenSync::Codex(token.to_string())),
1214 AppType::Gemini => live_config
1215 .get("env")
1216 .and_then(|env| env.get("GEMINI_API_KEY"))
1217 .and_then(Value::as_str)
1218 .map(str::trim)
1219 .filter(|token| !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER)
1220 .map(|token| LiveTokenSync::Gemini(token.to_string())),
1221 _ => None,
1222 }) else {
1223 return Ok(());
1224 };
1225
1226 let Some(provider_id) =
1227 crate::settings::get_effective_current_provider(self.db.as_ref(), app_type).map_err(
1228 |error| {
1229 format!(
1230 "load effective current provider for {} failed: {error}",
1231 app_type.as_str()
1232 )
1233 },
1234 )?
1235 else {
1236 return Ok(());
1237 };
1238
1239 let Some(mut provider) = self
1240 .db
1241 .get_provider_by_id(&provider_id, app_type.as_str())
1242 .map_err(|error| {
1243 format!(
1244 "load provider {} for {} failed: {error}",
1245 provider_id,
1246 app_type.as_str()
1247 )
1248 })?
1249 else {
1250 return Ok(());
1251 };
1252
1253 let Some(root) = (match &mut provider.settings_config {
1254 Value::Object(root) => Some(root),
1255 Value::Null => {
1256 provider.settings_config = json!({});
1257 provider.settings_config.as_object_mut()
1258 }
1259 _ => None,
1260 }) else {
1261 log::warn!(
1262 "skip syncing {} live token because provider {} settings root is not an object",
1263 app_type.as_str(),
1264 provider_id
1265 );
1266 return Ok(());
1267 };
1268
1269 match token_sync {
1270 LiveTokenSync::Claude(token_key, token) => {
1271 let env_value = root.entry("env".to_string()).or_insert_with(|| json!({}));
1272 if !env_value.is_object() {
1273 *env_value = json!({});
1274 }
1275
1276 let Some(env) = env_value.as_object_mut() else {
1277 log::warn!(
1278 "skip syncing {} live token because provider {} env is not an object",
1279 app_type.as_str(),
1280 provider_id
1281 );
1282 return Ok(());
1283 };
1284
1285 if matches!(token_key, "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY") {
1286 let mut updated = false;
1287 if env.contains_key("ANTHROPIC_AUTH_TOKEN") {
1288 env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), json!(token));
1289 updated = true;
1290 }
1291 if env.contains_key("ANTHROPIC_API_KEY") {
1292 env.insert("ANTHROPIC_API_KEY".to_string(), json!(token));
1293 updated = true;
1294 }
1295 if !updated {
1296 env.insert(token_key.to_string(), json!(token));
1297 }
1298 } else {
1299 env.insert(token_key.to_string(), json!(token));
1300 }
1301 }
1302 LiveTokenSync::Codex(token) => {
1303 let auth_value = root.entry("auth".to_string()).or_insert_with(|| json!({}));
1304 if !auth_value.is_object() {
1305 *auth_value = json!({});
1306 }
1307
1308 let Some(auth) = auth_value.as_object_mut() else {
1309 log::warn!(
1310 "skip syncing {} live token because provider {} auth is not an object",
1311 app_type.as_str(),
1312 provider_id
1313 );
1314 return Ok(());
1315 };
1316
1317 auth.insert("OPENAI_API_KEY".to_string(), json!(token));
1318 }
1319 LiveTokenSync::Gemini(token) => {
1320 let env_value = root.entry("env".to_string()).or_insert_with(|| json!({}));
1321 if !env_value.is_object() {
1322 *env_value = json!({});
1323 }
1324
1325 let Some(env) = env_value.as_object_mut() else {
1326 log::warn!(
1327 "skip syncing {} live token because provider {} env is not an object",
1328 app_type.as_str(),
1329 provider_id
1330 );
1331 return Ok(());
1332 };
1333
1334 env.insert("GEMINI_API_KEY".to_string(), json!(token));
1335 }
1336 }
1337
1338 if let Err(error) = self.db.update_provider_settings_config(
1339 app_type.as_str(),
1340 &provider_id,
1341 &provider.settings_config,
1342 ) {
1343 log::warn!(
1344 "sync {} live token to provider {} failed: {error}",
1345 app_type.as_str(),
1346 provider_id
1347 );
1348 }
1349
1350 Ok(())
1351 }
1352
1353 async fn read_or_current_provider_live(&self, app_type: &AppType) -> Result<Value, String> {
1354 match self.read_live_config_for_app(app_type) {
1355 Ok(live) => Ok(live),
1356 Err(_) => self
1357 .current_provider_settings(app_type)
1358 .await?
1359 .ok_or_else(|| {
1360 format!(
1361 "missing live config and current provider for {}",
1362 app_type.as_str()
1363 )
1364 }),
1365 }
1366 }
1367
1368 async fn build_proxy_urls(&self) -> Result<(String, String), String> {
1369 let runtime_status = self.get_status().await;
1370 let persisted = self.get_config().await.map_err(|e| e.to_string())?;
1371 let listen_address = if runtime_status.running && !runtime_status.address.is_empty() {
1372 runtime_status.address
1373 } else {
1374 persisted.listen_address.clone()
1375 };
1376 let listen_port = if runtime_status.running && runtime_status.port != 0 {
1377 runtime_status.port
1378 } else {
1379 persisted.listen_port
1380 };
1381
1382 let connect_host = match listen_address.as_str() {
1383 "0.0.0.0" => "127.0.0.1".to_string(),
1384 "::" => "::1".to_string(),
1385 _ => listen_address,
1386 };
1387 let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {
1388 format!("[{connect_host}]")
1389 } else {
1390 connect_host
1391 };
1392
1393 let proxy_origin = format!("http://{}:{}", connect_host_for_url, listen_port);
1394 let proxy_codex_base_url = format!("{}/v1", proxy_origin.trim_end_matches('/'));
1395 Ok((proxy_origin, proxy_codex_base_url))
1396 }
1397
1398 fn rewrite_live_for_proxy(
1399 &self,
1400 app_type: &AppType,
1401 live: &mut Value,
1402 proxy_url: &str,
1403 proxy_codex_base_url: &str,
1404 ) -> Result<(), String> {
1405 match app_type {
1406 AppType::Claude => {
1407 if !live.is_object() {
1408 *live = json!({});
1409 }
1410
1411 let root = live
1412 .as_object_mut()
1413 .ok_or_else(|| "claude live config root must be an object".to_string())?;
1414 if !root.get("env").is_some_and(Value::is_object) {
1415 root.insert("env".to_string(), json!({}));
1416 }
1417
1418 let env = root
1419 .get_mut("env")
1420 .and_then(Value::as_object_mut)
1421 .ok_or_else(|| "claude env must be an object".to_string())?;
1422 env.insert("ANTHROPIC_BASE_URL".to_string(), json!(proxy_url));
1423 for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
1424 env.remove(key);
1425 }
1426
1427 let token_keys = [
1428 "ANTHROPIC_AUTH_TOKEN",
1429 "ANTHROPIC_API_KEY",
1430 "OPENROUTER_API_KEY",
1431 "OPENAI_API_KEY",
1432 ];
1433 let mut replaced_any = false;
1434 for key in token_keys {
1435 if env.contains_key(key) {
1436 env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
1437 replaced_any = true;
1438 }
1439 }
1440
1441 if !replaced_any {
1442 env.insert(
1443 "ANTHROPIC_AUTH_TOKEN".to_string(),
1444 json!(PROXY_TOKEN_PLACEHOLDER),
1445 );
1446 }
1447 }
1448 AppType::Codex => {
1449 if !live.is_object() {
1450 *live = json!({});
1451 }
1452
1453 let root = live
1454 .as_object_mut()
1455 .ok_or_else(|| "codex live config root must be an object".to_string())?;
1456 if !root.get("auth").is_some_and(Value::is_object) {
1457 root.insert("auth".to_string(), json!({}));
1458 }
1459
1460 let auth = root
1461 .get_mut("auth")
1462 .and_then(Value::as_object_mut)
1463 .ok_or_else(|| "codex auth must be an object".to_string())?;
1464 auth.insert("OPENAI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
1465
1466 let config_text = root
1467 .get("config")
1468 .and_then(Value::as_str)
1469 .unwrap_or_default()
1470 .to_string();
1471 root.insert(
1472 "config".to_string(),
1473 json!(codex_toml::update_toml_base_url(
1474 &config_text,
1475 proxy_codex_base_url
1476 )),
1477 );
1478 }
1479 AppType::Gemini => {
1480 if !live.is_object() {
1481 *live = json!({});
1482 }
1483
1484 let root = live
1485 .as_object_mut()
1486 .ok_or_else(|| "gemini live config root must be an object".to_string())?;
1487 if !root.get("env").is_some_and(Value::is_object) {
1488 root.insert("env".to_string(), json!({}));
1489 }
1490
1491 let env = root
1492 .get_mut("env")
1493 .and_then(Value::as_object_mut)
1494 .ok_or_else(|| "gemini env must be an object".to_string())?;
1495 env.insert("GOOGLE_GEMINI_BASE_URL".to_string(), json!(proxy_url));
1496 env.insert("GEMINI_API_KEY".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));
1497 }
1498 _ => {
1499 return Err(format!(
1500 "proxy takeover not supported for {}",
1501 app_type.as_str()
1502 ));
1503 }
1504 }
1505
1506 Ok(())
1507 }
1508
1509 fn read_live_config_for_app(&self, app_type: &AppType) -> Result<Value, String> {
1510 match app_type {
1511 AppType::Claude => self.read_claude_live(),
1512 AppType::Codex => self.read_codex_live(),
1513 AppType::Gemini => self.read_gemini_live(),
1514 _ => Err(format!(
1515 "proxy takeover not supported for {}",
1516 app_type.as_str()
1517 )),
1518 }
1519 }
1520
1521 fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {
1522 match app_type {
1523 AppType::Claude => self.write_claude_live(config),
1524 AppType::Codex => self.write_codex_live(config),
1525 AppType::Gemini => self.write_gemini_live(config),
1526 _ => Err(format!(
1527 "proxy takeover not supported for {}",
1528 app_type.as_str()
1529 )),
1530 }
1531 }
1532
1533 fn clear_stale_takeover_from_live_config(&self, app_type: &AppType) -> Result<(), String> {
1534 let mut live = match self.read_live_config_for_app(app_type) {
1535 Ok(live) => live,
1536 Err(_) => return Ok(()),
1537 };
1538
1539 match app_type {
1540 AppType::Claude => {
1541 if let Some(env) = live.get_mut("env").and_then(Value::as_object_mut) {
1542 if env
1543 .get("ANTHROPIC_BASE_URL")
1544 .and_then(Value::as_str)
1545 .is_some_and(codex_toml::is_loopback_proxy_url)
1546 {
1547 env.remove("ANTHROPIC_BASE_URL");
1548 }
1549
1550 for key in [
1551 "ANTHROPIC_AUTH_TOKEN",
1552 "ANTHROPIC_API_KEY",
1553 "OPENROUTER_API_KEY",
1554 "OPENAI_API_KEY",
1555 ] {
1556 if env
1557 .get(key)
1558 .and_then(Value::as_str)
1559 .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
1560 {
1561 env.remove(key);
1562 }
1563 }
1564 }
1565 }
1566 AppType::Codex => {
1567 if let Some(auth) = live.get_mut("auth").and_then(Value::as_object_mut) {
1568 if auth
1569 .get("OPENAI_API_KEY")
1570 .and_then(Value::as_str)
1571 .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
1572 {
1573 auth.remove("OPENAI_API_KEY");
1574 }
1575 }
1576
1577 if let Some(config_text) = live.get("config").and_then(Value::as_str) {
1578 live["config"] =
1579 json!(codex_toml::remove_loopback_base_url_from_toml(config_text));
1580 }
1581 }
1582 AppType::Gemini => {
1583 if let Some(env) = live.get_mut("env").and_then(Value::as_object_mut) {
1584 if env
1585 .get("GOOGLE_GEMINI_BASE_URL")
1586 .and_then(Value::as_str)
1587 .is_some_and(codex_toml::is_loopback_proxy_url)
1588 {
1589 env.remove("GOOGLE_GEMINI_BASE_URL");
1590 }
1591 if env
1592 .get("GEMINI_API_KEY")
1593 .and_then(Value::as_str)
1594 .is_some_and(|value| value == PROXY_TOKEN_PLACEHOLDER)
1595 {
1596 env.remove("GEMINI_API_KEY");
1597 }
1598 }
1599 }
1600 _ => {
1601 return Err(format!(
1602 "proxy takeover not supported for {}",
1603 app_type.as_str()
1604 ));
1605 }
1606 }
1607
1608 self.write_live_config_for_app(app_type, &live)
1609 }
1610
1611 fn read_claude_live(&self) -> Result<Value, String> {
1612 let path = get_claude_settings_path();
1613 if !path.exists() {
1614 return Err("Claude settings.json does not exist".to_string());
1615 }
1616
1617 let value: Value = read_json_file(&path)
1618 .map_err(|error| format!("read Claude settings.json failed: {error}"))?;
1619 if value.is_object() {
1620 Ok(value)
1621 } else {
1622 Err("Claude settings.json root must be an object".to_string())
1623 }
1624 }
1625
1626 fn write_claude_live(&self, config: &Value) -> Result<(), String> {
1627 write_json_file(&get_claude_settings_path(), config)
1628 .map_err(|error| format!("write Claude settings.json failed: {error}"))
1629 }
1630
1631 fn read_codex_live(&self) -> Result<Value, String> {
1632 let auth_path = get_codex_auth_path();
1633 let config_path = get_codex_config_path();
1634 if !auth_path.exists() && !config_path.exists() {
1635 return Err("Codex live config does not exist".to_string());
1636 }
1637
1638 let auth = if auth_path.exists() {
1639 read_json_file(&auth_path)
1640 .map_err(|error| format!("read Codex auth.json failed: {error}"))?
1641 } else {
1642 json!({})
1643 };
1644 let config = if config_path.exists() {
1645 std::fs::read_to_string(&config_path)
1646 .map_err(|error| format!("read Codex config.toml failed: {error}"))?
1647 } else {
1648 String::new()
1649 };
1650
1651 Ok(json!({
1652 "auth": auth,
1653 "config": config,
1654 }))
1655 }
1656
1657 fn write_codex_live(&self, config: &Value) -> Result<(), String> {
1658 let auth = config
1659 .get("auth")
1660 .filter(|value| !value.as_object().is_some_and(|object| object.is_empty()));
1661 let config_text = config.get("config").and_then(Value::as_str);
1662 let auth_path = get_codex_auth_path();
1663
1664 match (auth, config_text) {
1666 (Some(auth), Some(config_text)) => write_codex_live_atomic(auth, Some(config_text))
1667 .map_err(|error| format!("write Codex live config failed: {error}")),
1668 (Some(auth), None) => write_json_file(&get_codex_auth_path(), auth)
1669 .map_err(|error| format!("write Codex auth.json failed: {error}")),
1670 (None, Some(config_text)) => {
1671 if auth_path.exists() {
1672 std::fs::remove_file(&auth_path)
1673 .map_err(|error| format!("remove Codex auth.json failed: {error}"))?;
1674 }
1675 write_text_file(&get_codex_config_path(), config_text)
1676 .map_err(|error| format!("write Codex config.toml failed: {error}"))
1677 }
1678 (None, None) => {
1679 if auth_path.exists() {
1680 std::fs::remove_file(&auth_path)
1681 .map_err(|error| format!("remove Codex auth.json failed: {error}"))?;
1682 }
1683 Ok(())
1684 }
1685 }
1686 }
1687
1688 fn build_live_snapshot_from_provider(
1689 &self,
1690 app_type: &AppType,
1691 provider: &Provider,
1692 ) -> Result<Value, String> {
1693 let common_config_snippet =
1694 self.db
1695 .get_config_snippet(app_type.as_str())
1696 .map_err(|error| {
1697 format!(
1698 "load common config snippet for {} failed: {error}",
1699 app_type.as_str()
1700 )
1701 })?;
1702 let apply_common_config = provider
1703 .meta
1704 .as_ref()
1705 .and_then(|meta| meta.apply_common_config)
1706 .unwrap_or(true);
1707
1708 crate::services::provider::ProviderService::build_live_backup_snapshot(
1709 app_type,
1710 provider,
1711 common_config_snippet.as_deref(),
1712 apply_common_config,
1713 )
1714 .map_err(|error| {
1715 format!(
1716 "build {} live snapshot from provider failed: {error}",
1717 app_type.as_str()
1718 )
1719 })
1720 }
1721
1722 fn persist_runtime_session(&self, info: &ProxyServerInfo) -> Result<(), String> {
1723 let session = PersistedProxyRuntimeSession {
1724 pid: std::process::id(),
1725 address: info.address.clone(),
1726 port: info.port,
1727 started_at: info.started_at.clone(),
1728 kind: PersistedProxyRuntimeSessionKind::from_env(),
1729 session_token: std::env::var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY)
1730 .ok()
1731 .filter(|value| !value.trim().is_empty()),
1732 };
1733 let serialized = serde_json::to_string(&session)
1734 .map_err(|error| format!("serialize proxy runtime session failed: {error}"))?;
1735 self.db
1736 .set_setting(PROXY_RUNTIME_SESSION_KEY, &serialized)
1737 .map_err(|error| format!("persist proxy runtime session failed: {error}"))
1738 }
1739
1740 fn defer_runtime_session_publish() -> bool {
1741 PersistedProxyRuntimeSessionKind::from_env().is_managed_external()
1742 }
1743
1744 fn clear_persisted_runtime_session(&self) -> Result<(), String> {
1745 self.db
1746 .delete_setting(PROXY_RUNTIME_SESSION_KEY)
1747 .map_err(|error| format!("clear proxy runtime session failed: {error}"))
1748 }
1749
1750 fn load_persisted_runtime_session(&self) -> Option<PersistedProxyRuntimeSession> {
1751 let raw = self
1752 .db
1753 .get_setting(PROXY_RUNTIME_SESSION_KEY)
1754 .ok()
1755 .flatten()?;
1756 let raw = raw.trim();
1757 if raw.is_empty() {
1758 return None;
1759 }
1760
1761 match serde_json::from_str(raw) {
1762 Ok(session) => Some(session),
1763 Err(_) => {
1764 let _ = self.clear_persisted_runtime_session();
1765 None
1766 }
1767 }
1768 }
1769
1770 fn is_process_alive(pid: u32) -> bool {
1771 if pid == 0 {
1772 return false;
1773 }
1774
1775 #[cfg(unix)]
1776 {
1777 let rc = unsafe { libc::kill(pid as i32, 0) };
1778 return rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
1779 }
1780
1781 #[cfg(not(unix))]
1782 {
1783 pid == std::process::id()
1784 }
1785 }
1786
1787 fn has_managed_external_ownership_signal(session: &PersistedProxyRuntimeSession) -> bool {
1788 session.session_token.is_some() && Self::is_detached_session_leader(session.pid)
1789 }
1790
1791 #[cfg(unix)]
1792 fn is_detached_session_leader(pid: u32) -> bool {
1793 if pid == 0 {
1794 return false;
1795 }
1796
1797 let sid = unsafe { libc::getsid(pid as i32) };
1798 let pgid = unsafe { libc::getpgid(pid as i32) };
1799 sid == pid as i32 && pgid == pid as i32
1800 }
1801
1802 #[cfg(not(unix))]
1803 fn is_detached_session_leader(_pid: u32) -> bool {
1804 false
1805 }
1806
1807 async fn managed_session_ready_info(
1808 &self,
1809 child_pid: u32,
1810 session_token: &str,
1811 ) -> Option<ProxyServerInfo> {
1812 let session = self
1813 .load_persisted_runtime_session()
1814 .filter(|session| session.pid == child_pid)
1815 .filter(|session| session.kind.is_managed_external())
1816 .filter(|session| session.session_token.as_deref() == Some(session_token))?;
1817
1818 match Self::probe_external_proxy_status(&session).await {
1819 ExternalProxyStatusProbe::Matched(status) => Some(ProxyServerInfo {
1820 address: status.address,
1821 port: status.port,
1822 started_at: session.started_at,
1823 }),
1824 ExternalProxyStatusProbe::Unreachable => Some(ProxyServerInfo {
1825 address: session.address,
1826 port: session.port,
1827 started_at: session.started_at,
1828 }),
1829 ExternalProxyStatusProbe::Mismatched => None,
1830 }
1831 }
1832
1833 async fn probe_external_proxy_status(
1834 session: &PersistedProxyRuntimeSession,
1835 ) -> ExternalProxyStatusProbe {
1836 let Some(expected_session_token) = session.session_token.as_deref() else {
1837 return ExternalProxyStatusProbe::Unreachable;
1838 };
1839 let client = reqwest::Client::builder()
1840 .timeout(Duration::from_millis(500))
1841 .build();
1842 let Ok(client) = client else {
1843 return ExternalProxyStatusProbe::Unreachable;
1844 };
1845
1846 let response = client
1847 .get(Self::build_session_status_url(session))
1848 .send()
1849 .await;
1850 let Ok(response) = response else {
1851 return ExternalProxyStatusProbe::Unreachable;
1852 };
1853 if !response.status().is_success() {
1854 return ExternalProxyStatusProbe::Unreachable;
1855 }
1856
1857 let mut status = match response.json::<ProxyStatus>().await {
1858 Ok(status) => status,
1859 Err(_) => return ExternalProxyStatusProbe::Unreachable,
1860 };
1861 if status.managed_session_token.as_deref() != Some(expected_session_token) {
1862 return ExternalProxyStatusProbe::Mismatched;
1863 }
1864 status.running = true;
1865 if status.address.trim().is_empty() {
1866 status.address = session.address.clone();
1867 }
1868 if status.port == 0 {
1869 status.port = session.port;
1870 }
1871 ExternalProxyStatusProbe::Matched(status)
1872 }
1873
1874 fn build_session_status_url(session: &PersistedProxyRuntimeSession) -> String {
1875 let connect_host = match session.address.as_str() {
1876 "0.0.0.0" => "127.0.0.1".to_string(),
1877 "::" => "::1".to_string(),
1878 value => value.to_string(),
1879 };
1880 let connect_host = if connect_host.contains(':') && !connect_host.starts_with('[') {
1881 format!("[{connect_host}]")
1882 } else {
1883 connect_host
1884 };
1885
1886 format!("http://{}:{}/status", connect_host, session.port)
1887 }
1888
1889 async fn terminate_external_process(pid: u32) -> Result<(), String> {
1890 if pid == 0 || pid == std::process::id() || !Self::is_process_alive(pid) {
1891 return Ok(());
1892 }
1893
1894 #[cfg(unix)]
1895 {
1896 let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
1897 if rc != 0 {
1898 let error = std::io::Error::last_os_error();
1899 if error.raw_os_error() != Some(libc::ESRCH) {
1900 return Err(format!("stop managed proxy session failed: {error}"));
1901 }
1902 }
1903
1904 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1905 while tokio::time::Instant::now() < deadline {
1906 if !Self::is_process_alive(pid) {
1907 return Ok(());
1908 }
1909 tokio::time::sleep(Duration::from_millis(100)).await;
1910 }
1911
1912 let rc = unsafe { libc::kill(pid as i32, libc::SIGKILL) };
1913 if rc != 0 {
1914 let error = std::io::Error::last_os_error();
1915 if error.raw_os_error() != Some(libc::ESRCH) {
1916 return Err(format!("force stop managed proxy session failed: {error}"));
1917 }
1918 }
1919
1920 let deadline = tokio::time::Instant::now() + Duration::from_secs(1);
1921 while tokio::time::Instant::now() < deadline {
1922 if !Self::is_process_alive(pid) {
1923 return Ok(());
1924 }
1925 tokio::time::sleep(Duration::from_millis(50)).await;
1926 }
1927
1928 return Err(format!(
1929 "managed proxy session did not exit after termination signal: pid {}",
1930 pid
1931 ));
1932 }
1933
1934 #[cfg(not(unix))]
1935 {
1936 let _ = pid;
1937 Err("managed proxy session stop is only supported on unix in this build".to_string())
1938 }
1939 }
1940
1941 fn spawn_managed_child_reaper(mut child: std::process::Child) {
1942 tokio::task::spawn_blocking(move || {
1943 let _ = child.wait();
1944 });
1945 }
1946
1947 fn ensure_managed_sessions_supported() -> Result<(), String> {
1948 #[cfg(unix)]
1949 {
1950 Ok(())
1951 }
1952
1953 #[cfg(not(unix))]
1954 {
1955 Err("managed proxy sessions are unsupported on non-unix platforms".to_string())
1956 }
1957 }
1958
1959 fn resolve_managed_proxy_executable() -> Result<std::path::PathBuf, String> {
1960 if let Some(path) = std::env::var_os("CARGO_BIN_EXE_cc-switch-tui") {
1961 return Ok(path.into());
1962 }
1963
1964 let current_exe = std::env::current_exe()
1965 .map_err(|error| format!("resolve managed proxy executable failed: {error}"))?;
1966
1967 if current_exe
1968 .file_stem()
1969 .and_then(|value| value.to_str())
1970 .is_some_and(|value| value.starts_with("cc-switch-tui"))
1971 {
1972 return Ok(current_exe);
1973 }
1974
1975 if let Some(debug_dir) = current_exe.parent().and_then(|parent| parent.parent()) {
1976 let candidate = debug_dir.join(format!("cc-switch{}", std::env::consts::EXE_SUFFIX));
1977 if candidate.exists() {
1978 return Ok(candidate);
1979 }
1980 }
1981
1982 Ok(current_exe)
1983 }
1984
1985 async fn sync_persisted_global_proxy_enabled(&self, enabled: bool) -> Result<(), String> {
1986 let mut config = self
1987 .db
1988 .get_global_proxy_config()
1989 .await
1990 .map_err(|error| format!("load global proxy config failed: {error}"))?;
1991 if config.proxy_enabled == enabled {
1992 return Ok(());
1993 }
1994
1995 config.proxy_enabled = enabled;
1996 self.db
1997 .update_global_proxy_config(config)
1998 .await
1999 .map_err(|error| format!("update global proxy switch failed: {error}"))
2000 }
2001
2002 fn read_gemini_live(&self) -> Result<Value, String> {
2003 let env_path = get_gemini_env_path();
2004 if !env_path.exists() {
2005 return Err("Gemini .env does not exist".to_string());
2006 }
2007
2008 let env = read_gemini_env().map_err(|error| format!("read Gemini .env failed: {error}"))?;
2009 Ok(env_to_json(&env))
2010 }
2011
2012 fn write_gemini_live(&self, config: &Value) -> Result<(), String> {
2013 let env =
2014 json_to_env(config).map_err(|error| format!("build Gemini .env failed: {error}"))?;
2015 write_gemini_env_atomic(&env).map_err(|error| format!("write Gemini .env failed: {error}"))
2016 }
2017
2018 fn takeover_app_from_str(app_type: &str) -> Result<AppType, String> {
2019 match app_type {
2020 "claude" => Ok(AppType::Claude),
2021 "codex" => Ok(AppType::Codex),
2022 "gemini" => Ok(AppType::Gemini),
2023 _ => Err(format!("proxy takeover not supported for app: {app_type}")),
2024 }
2025 }
2026}
2027
2028#[cfg(test)]
2029mod tests {
2030 use super::*;
2031 use crate::provider::ProviderMeta;
2032 use crate::proxy::circuit_breaker::CircuitBreakerConfig;
2033 use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
2034 use serial_test::serial;
2035 use std::ffi::OsString;
2036 use std::path::Path;
2037 use std::sync::atomic::{AtomicBool, Ordering};
2038 use tempfile::TempDir;
2039
2040 struct ManagedRuntimeEnvGuard {
2041 old_kind: Option<OsString>,
2042 old_token: Option<OsString>,
2043 }
2044
2045 impl ManagedRuntimeEnvGuard {
2046 fn set(token: &str) -> Self {
2047 let old_kind = std::env::var_os(PROXY_RUNTIME_KIND_ENV_KEY);
2048 let old_token = std::env::var_os(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY);
2049 unsafe {
2050 std::env::set_var(
2051 PROXY_RUNTIME_KIND_ENV_KEY,
2052 PersistedProxyRuntimeSessionKind::ManagedExternal.as_env_value(),
2053 );
2054 std::env::set_var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY, token);
2055 }
2056 Self {
2057 old_kind,
2058 old_token,
2059 }
2060 }
2061 }
2062
2063 impl Drop for ManagedRuntimeEnvGuard {
2064 fn drop(&mut self) {
2065 match &self.old_kind {
2066 Some(value) => unsafe { std::env::set_var(PROXY_RUNTIME_KIND_ENV_KEY, value) },
2067 None => unsafe { std::env::remove_var(PROXY_RUNTIME_KIND_ENV_KEY) },
2068 }
2069 match &self.old_token {
2070 Some(value) => unsafe {
2071 std::env::set_var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY, value)
2072 },
2073 None => unsafe { std::env::remove_var(PROXY_RUNTIME_SESSION_TOKEN_ENV_KEY) },
2074 }
2075 }
2076 }
2077
2078 struct TestHomeEnvGuard {
2079 _lock: crate::test_support::TestHomeSettingsLock,
2080 old_home: Option<OsString>,
2081 old_userprofile: Option<OsString>,
2082 old_config_dir: Option<OsString>,
2083 }
2084
2085 impl TestHomeEnvGuard {
2086 fn set(home: &Path) -> Self {
2087 let lock = lock_test_home_and_settings();
2088 let old_home = std::env::var_os("HOME");
2089 let old_userprofile = std::env::var_os("USERPROFILE");
2090 let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
2091 unsafe {
2092 std::env::set_var("HOME", home);
2093 std::env::set_var("USERPROFILE", home);
2094 std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
2095 }
2096 set_test_home_override(Some(home));
2097 crate::settings::reload_test_settings();
2098 Self {
2099 _lock: lock,
2100 old_home,
2101 old_userprofile,
2102 old_config_dir,
2103 }
2104 }
2105 }
2106
2107 impl Drop for TestHomeEnvGuard {
2108 fn drop(&mut self) {
2109 match &self.old_home {
2110 Some(value) => unsafe { std::env::set_var("HOME", value) },
2111 None => unsafe { std::env::remove_var("HOME") },
2112 }
2113 match &self.old_userprofile {
2114 Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
2115 None => unsafe { std::env::remove_var("USERPROFILE") },
2116 }
2117 match &self.old_config_dir {
2118 Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
2119 None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
2120 }
2121 set_test_home_override(self.old_home.as_deref().map(Path::new));
2122 crate::settings::reload_test_settings();
2123 }
2124 }
2125
2126 #[tokio::test]
2127 #[serial]
2128 async fn takeover_activation_rejects_missing_current_provider_when_failover_disabled() {
2129 let temp_home = TempDir::new().expect("create temp home");
2130 let _env = TestHomeEnvGuard::set(temp_home.path());
2131
2132 let db = Arc::new(Database::memory().expect("create database"));
2133 let service = ProxyService::new(db.clone());
2134
2135 let error = service
2136 .set_takeover_for_app("claude", true)
2137 .await
2138 .expect_err("takeover should require an active provider when failover is disabled");
2139
2140 assert!(
2141 error.contains("cannot enable proxy because no active provider is selected"),
2142 "{error}"
2143 );
2144 assert!(
2145 !db.get_proxy_config_for_app("claude")
2146 .await
2147 .expect("load claude proxy config")
2148 .enabled,
2149 "failed validation should not enable takeover state"
2150 );
2151 }
2152
2153 #[tokio::test]
2154 #[serial]
2155 async fn takeover_activation_allows_current_provider_when_failover_disabled() {
2156 let temp_home = TempDir::new().expect("create temp home");
2157 let _env = TestHomeEnvGuard::set(temp_home.path());
2158 std::fs::create_dir_all(
2159 get_claude_settings_path()
2160 .parent()
2161 .expect("claude settings parent dir"),
2162 )
2163 .expect("create ~/.claude");
2164 write_json_file(
2165 &get_claude_settings_path(),
2166 &json!({
2167 "env": {
2168 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2169 "ANTHROPIC_AUTH_TOKEN": "fresh-live-token"
2170 }
2171 }),
2172 )
2173 .expect("seed claude live config");
2174
2175 let db = Arc::new(Database::memory().expect("create database"));
2176 let service = ProxyService::new(db.clone());
2177 let provider = Provider::with_id(
2178 "claude-provider".to_string(),
2179 "Claude Provider".to_string(),
2180 json!({
2181 "env": {
2182 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2183 "ANTHROPIC_AUTH_TOKEN": "stale-provider-token"
2184 }
2185 }),
2186 None,
2187 );
2188 db.save_provider("claude", &provider)
2189 .expect("save claude provider");
2190 db.set_current_provider("claude", &provider.id)
2191 .expect("set current claude provider");
2192 let mut runtime_config = service.get_config().await.expect("get proxy config");
2193 runtime_config.listen_port = 0;
2194 service
2195 .update_config(&runtime_config)
2196 .await
2197 .expect("persist runtime config");
2198
2199 service
2200 .set_takeover_for_app("claude", true)
2201 .await
2202 .expect("takeover should allow an active provider when failover is disabled");
2203
2204 assert!(
2205 db.get_proxy_config_for_app("claude")
2206 .await
2207 .expect("load claude proxy config")
2208 .enabled
2209 );
2210 service.stop().await.expect("stop proxy runtime");
2211 }
2212
2213 #[tokio::test]
2214 #[serial]
2215 async fn takeover_activation_rejects_empty_queue_when_failover_enabled() {
2216 let temp_home = TempDir::new().expect("create temp home");
2217 let _env = TestHomeEnvGuard::set(temp_home.path());
2218
2219 let db = Arc::new(Database::memory().expect("create database"));
2220 let service = ProxyService::new(db.clone());
2221 let provider = Provider::with_id(
2222 "claude-provider".to_string(),
2223 "Claude Provider".to_string(),
2224 json!({
2225 "env": {
2226 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2227 "ANTHROPIC_AUTH_TOKEN": "token"
2228 }
2229 }),
2230 None,
2231 );
2232 db.save_provider("claude", &provider)
2233 .expect("save claude provider");
2234 db.set_current_provider("claude", &provider.id)
2235 .expect("set current claude provider");
2236 let mut app_proxy = db
2237 .get_proxy_config_for_app("claude")
2238 .await
2239 .expect("load claude proxy config");
2240 app_proxy.auto_failover_enabled = true;
2241 db.update_proxy_config_for_app(app_proxy)
2242 .await
2243 .expect("enable auto failover");
2244
2245 let error = service
2246 .set_takeover_for_app("claude", true)
2247 .await
2248 .expect_err("takeover should require a non-empty failover queue");
2249
2250 assert!(
2251 error.contains(
2252 "cannot enable proxy because automatic failover is enabled and the failover queue is empty"
2253 ),
2254 "{error}"
2255 );
2256 assert!(
2257 !db.get_proxy_config_for_app("claude")
2258 .await
2259 .expect("load claude proxy config")
2260 .enabled,
2261 "failed validation should not enable takeover state"
2262 );
2263 }
2264
2265 #[tokio::test]
2266 #[serial]
2267 async fn takeover_activation_allows_non_empty_queue_when_failover_enabled() {
2268 let temp_home = TempDir::new().expect("create temp home");
2269 let _env = TestHomeEnvGuard::set(temp_home.path());
2270 std::fs::create_dir_all(
2271 get_claude_settings_path()
2272 .parent()
2273 .expect("claude settings parent dir"),
2274 )
2275 .expect("create ~/.claude");
2276 write_json_file(
2277 &get_claude_settings_path(),
2278 &json!({
2279 "env": {
2280 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2281 "ANTHROPIC_AUTH_TOKEN": "fresh-live-token"
2282 }
2283 }),
2284 )
2285 .expect("seed claude live config");
2286
2287 let db = Arc::new(Database::memory().expect("create database"));
2288 let service = ProxyService::new(db.clone());
2289 let provider = Provider::with_id(
2290 "claude-provider".to_string(),
2291 "Claude Provider".to_string(),
2292 json!({
2293 "env": {
2294 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2295 "ANTHROPIC_AUTH_TOKEN": "stale-provider-token"
2296 }
2297 }),
2298 None,
2299 );
2300 db.save_provider("claude", &provider)
2301 .expect("save claude provider");
2302 db.add_to_failover_queue("claude", &provider.id)
2303 .expect("queue claude provider");
2304 let mut app_proxy = db
2305 .get_proxy_config_for_app("claude")
2306 .await
2307 .expect("load claude proxy config");
2308 app_proxy.auto_failover_enabled = true;
2309 db.update_proxy_config_for_app(app_proxy)
2310 .await
2311 .expect("enable auto failover");
2312 let mut runtime_config = service.get_config().await.expect("get proxy config");
2313 runtime_config.listen_port = 0;
2314 service
2315 .update_config(&runtime_config)
2316 .await
2317 .expect("persist runtime config");
2318
2319 service
2320 .set_takeover_for_app("claude", true)
2321 .await
2322 .expect("takeover should allow a non-empty failover queue");
2323
2324 assert!(
2325 db.get_proxy_config_for_app("claude")
2326 .await
2327 .expect("load claude proxy config")
2328 .enabled
2329 );
2330 service.stop().await.expect("stop proxy runtime");
2331 }
2332
2333 #[tokio::test]
2334 #[serial]
2335 async fn recover_takeovers_on_startup_cleans_claude_placeholder_only_residue_without_backup() {
2336 let temp_home = TempDir::new().expect("create temp home");
2337 let _env = TestHomeEnvGuard::set(temp_home.path());
2338 std::fs::create_dir_all(
2339 get_claude_settings_path()
2340 .parent()
2341 .expect("claude settings parent dir"),
2342 )
2343 .expect("create ~/.claude");
2344
2345 write_json_file(
2346 &get_claude_settings_path(),
2347 &json!({
2348 "env": {
2349 "ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
2350 "ANTHROPIC_BASE_URL": "https://api.anthropic.com"
2351 }
2352 }),
2353 )
2354 .expect("seed placeholder-only claude live config");
2355
2356 let db = Arc::new(Database::memory().expect("create database"));
2357 let service = ProxyService::new(db);
2358
2359 service
2360 .recover_takeovers_on_startup()
2361 .await
2362 .expect("startup recovery should clean placeholder-only residue");
2363
2364 let live: Value =
2365 read_json_file(&get_claude_settings_path()).expect("read claude live config");
2366 let env = live
2367 .get("env")
2368 .and_then(Value::as_object)
2369 .expect("claude env should be object");
2370 assert!(
2371 !env.contains_key("ANTHROPIC_AUTH_TOKEN"),
2372 "startup recovery should remove stale proxy placeholder tokens even without loopback base_url"
2373 );
2374 assert_eq!(
2375 env.get("ANTHROPIC_BASE_URL").and_then(Value::as_str),
2376 Some("https://api.anthropic.com"),
2377 "startup recovery should keep a non-proxy base_url when only clearing stale placeholder residue"
2378 );
2379 }
2380
2381 #[tokio::test]
2382 #[serial]
2383 async fn enabling_claude_takeover_syncs_live_token_back_to_current_provider() {
2384 let temp_home = TempDir::new().expect("create temp home");
2385 let _env = TestHomeEnvGuard::set(temp_home.path());
2386 std::fs::create_dir_all(
2387 get_claude_settings_path()
2388 .parent()
2389 .expect("claude settings parent dir"),
2390 )
2391 .expect("create ~/.claude");
2392
2393 write_json_file(
2394 &get_claude_settings_path(),
2395 &json!({
2396 "env": {
2397 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2398 "ANTHROPIC_AUTH_TOKEN": "fresh-live-token"
2399 }
2400 }),
2401 )
2402 .expect("seed claude live config");
2403
2404 let db = Arc::new(Database::memory().expect("create database"));
2405 let service = ProxyService::new(db.clone());
2406
2407 let provider = Provider::with_id(
2408 "claude-provider".to_string(),
2409 "Claude Provider".to_string(),
2410 json!({
2411 "env": {
2412 "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
2413 "ANTHROPIC_AUTH_TOKEN": "stale-provider-token"
2414 }
2415 }),
2416 None,
2417 );
2418 db.save_provider("claude", &provider)
2419 .expect("save claude provider");
2420 db.set_current_provider("claude", &provider.id)
2421 .expect("set current claude provider");
2422
2423 let mut runtime_config = service.get_config().await.expect("get proxy config");
2424 runtime_config.listen_port = 0;
2425 service
2426 .update_config(&runtime_config)
2427 .await
2428 .expect("persist runtime config");
2429
2430 service
2431 .set_takeover_for_app("claude", true)
2432 .await
2433 .expect("enable claude takeover");
2434 service.stop().await.expect("stop proxy runtime");
2435
2436 let updated = db
2437 .get_provider_by_id("claude-provider", "claude")
2438 .expect("read claude provider")
2439 .expect("claude provider exists");
2440 let env = updated
2441 .settings_config
2442 .get("env")
2443 .and_then(Value::as_object)
2444 .expect("provider env should exist");
2445
2446 assert_eq!(
2447 env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str),
2448 Some("fresh-live-token"),
2449 "enabling takeover should sync the live Claude token back to the current provider before takeover rewrites it"
2450 );
2451 assert!(
2452 !env.contains_key("ANTHROPIC_API_KEY"),
2453 "sync should not introduce ANTHROPIC_API_KEY when the provider only used ANTHROPIC_AUTH_TOKEN"
2454 );
2455 }
2456
2457 #[tokio::test]
2458 #[serial]
2459 async fn enabling_codex_takeover_syncs_live_token_back_to_current_provider() {
2460 let temp_home = TempDir::new().expect("create temp home");
2461 let _env = TestHomeEnvGuard::set(temp_home.path());
2462 std::fs::create_dir_all(
2463 get_codex_auth_path()
2464 .parent()
2465 .expect("codex auth parent dir"),
2466 )
2467 .expect("create ~/.codex");
2468
2469 write_json_file(
2470 &get_codex_auth_path(),
2471 &json!({
2472 "OPENAI_API_KEY": "fresh-live-token"
2473 }),
2474 )
2475 .expect("seed codex auth.json");
2476 write_text_file(
2477 &get_codex_config_path(),
2478 r#"model_provider = "default"
2479
2480[model_providers.default]
2481base_url = "https://api.openai.com/v1"
2482"#,
2483 )
2484 .expect("seed codex config.toml");
2485
2486 let db = Arc::new(Database::memory().expect("create database"));
2487 let service = ProxyService::new(db.clone());
2488
2489 let provider = Provider::with_id(
2490 "codex-provider".to_string(),
2491 "Codex Provider".to_string(),
2492 json!({
2493 "auth": {
2494 "OPENAI_API_KEY": "stale-provider-token"
2495 },
2496 "config": r#"model_provider = "default"
2497
2498[model_providers.default]
2499base_url = "https://api.openai.com/v1"
2500"#
2501 }),
2502 None,
2503 );
2504 db.save_provider("codex", &provider)
2505 .expect("save codex provider");
2506 db.set_current_provider("codex", &provider.id)
2507 .expect("set current codex provider");
2508
2509 let mut runtime_config = service.get_config().await.expect("get proxy config");
2510 runtime_config.listen_port = 0;
2511 service
2512 .update_config(&runtime_config)
2513 .await
2514 .expect("persist runtime config");
2515
2516 service
2517 .set_takeover_for_app("codex", true)
2518 .await
2519 .expect("enable codex takeover");
2520 service.stop().await.expect("stop proxy runtime");
2521
2522 let updated = db
2523 .get_provider_by_id("codex-provider", "codex")
2524 .expect("read codex provider")
2525 .expect("codex provider exists");
2526 let auth = updated
2527 .settings_config
2528 .get("auth")
2529 .and_then(Value::as_object)
2530 .expect("provider auth should exist");
2531
2532 assert_eq!(
2533 auth.get("OPENAI_API_KEY").and_then(Value::as_str),
2534 Some("fresh-live-token"),
2535 "enabling takeover should sync the live Codex token back to the current provider before takeover rewrites it"
2536 );
2537 }
2538
2539 #[tokio::test]
2540 #[serial]
2541 async fn enabling_codex_takeover_syncs_live_token_to_effective_current_provider() {
2542 let temp_home = TempDir::new().expect("create temp home");
2543 let _env = TestHomeEnvGuard::set(temp_home.path());
2544 std::fs::create_dir_all(
2545 get_codex_auth_path()
2546 .parent()
2547 .expect("codex auth parent dir"),
2548 )
2549 .expect("create ~/.codex");
2550
2551 write_json_file(
2552 &get_codex_auth_path(),
2553 &json!({
2554 "OPENAI_API_KEY": "fresh-live-token"
2555 }),
2556 )
2557 .expect("seed codex auth.json");
2558 write_text_file(
2559 &get_codex_config_path(),
2560 r#"model_provider = "default"
2561
2562[model_providers.default]
2563base_url = "https://api.openai.com/v1"
2564"#,
2565 )
2566 .expect("seed codex config.toml");
2567
2568 let db = Arc::new(Database::memory().expect("create database"));
2569 let service = ProxyService::new(db.clone());
2570
2571 let db_current = Provider::with_id(
2572 "codex-db-current".to_string(),
2573 "Codex DB Current".to_string(),
2574 json!({
2575 "auth": {
2576 "OPENAI_API_KEY": "db-current-token"
2577 },
2578 "config": r#"model_provider = "default"
2579
2580[model_providers.default]
2581base_url = "https://api.openai.com/v1"
2582"#
2583 }),
2584 None,
2585 );
2586 let local_current = Provider::with_id(
2587 "codex-local-current".to_string(),
2588 "Codex Local Current".to_string(),
2589 json!({
2590 "auth": {
2591 "OPENAI_API_KEY": "local-current-token"
2592 },
2593 "config": r#"model_provider = "default"
2594
2595[model_providers.default]
2596base_url = "https://api.openai.com/v1"
2597"#
2598 }),
2599 None,
2600 );
2601 db.save_provider("codex", &db_current)
2602 .expect("save db-current codex provider");
2603 db.save_provider("codex", &local_current)
2604 .expect("save local-current codex provider");
2605 db.set_current_provider("codex", &db_current.id)
2606 .expect("set db current codex provider");
2607 crate::settings::set_current_provider(&AppType::Codex, Some(&local_current.id))
2608 .expect("set local current codex provider");
2609
2610 let mut runtime_config = service.get_config().await.expect("get proxy config");
2611 runtime_config.listen_port = 0;
2612 service
2613 .update_config(&runtime_config)
2614 .await
2615 .expect("persist runtime config");
2616
2617 service
2618 .set_takeover_for_app("codex", true)
2619 .await
2620 .expect("enable codex takeover");
2621 service.stop().await.expect("stop proxy runtime");
2622
2623 let updated_local = db
2624 .get_provider_by_id("codex-local-current", "codex")
2625 .expect("read local-current codex provider")
2626 .expect("local-current codex provider exists");
2627 let local_auth = updated_local
2628 .settings_config
2629 .get("auth")
2630 .and_then(Value::as_object)
2631 .expect("local-current provider auth should exist");
2632 assert_eq!(
2633 local_auth.get("OPENAI_API_KEY").and_then(Value::as_str),
2634 Some("fresh-live-token"),
2635 "takeover sync should follow the effective current provider from local settings"
2636 );
2637
2638 let unchanged_db = db
2639 .get_provider_by_id("codex-db-current", "codex")
2640 .expect("read db-current codex provider")
2641 .expect("db-current codex provider exists");
2642 let db_auth = unchanged_db
2643 .settings_config
2644 .get("auth")
2645 .and_then(Value::as_object)
2646 .expect("db-current provider auth should exist");
2647 assert_eq!(
2648 db_auth.get("OPENAI_API_KEY").and_then(Value::as_str),
2649 Some("db-current-token"),
2650 "DB current provider should remain unchanged when local settings override it"
2651 );
2652 }
2653
2654 #[tokio::test]
2655 #[serial]
2656 async fn enabling_gemini_takeover_syncs_live_token_to_effective_current_provider() {
2657 let temp_home = TempDir::new().expect("create temp home");
2658 let _env = TestHomeEnvGuard::set(temp_home.path());
2659
2660 let db = Arc::new(Database::memory().expect("create database"));
2661 let service = ProxyService::new(db.clone());
2662
2663 service
2664 .write_gemini_live(&json!({
2665 "env": {
2666 "GEMINI_API_KEY": "fresh-live-token"
2667 }
2668 }))
2669 .expect("seed gemini env");
2670
2671 let db_current = Provider::with_id(
2672 "gemini-db-current".to_string(),
2673 "Gemini DB Current".to_string(),
2674 json!({
2675 "env": {
2676 "GEMINI_API_KEY": "db-current-token"
2677 }
2678 }),
2679 None,
2680 );
2681 let local_current = Provider::with_id(
2682 "gemini-local-current".to_string(),
2683 "Gemini Local Current".to_string(),
2684 json!({
2685 "env": {
2686 "GEMINI_API_KEY": "local-current-token"
2687 }
2688 }),
2689 None,
2690 );
2691 db.save_provider("gemini", &db_current)
2692 .expect("save db-current gemini provider");
2693 db.save_provider("gemini", &local_current)
2694 .expect("save local-current gemini provider");
2695 db.set_current_provider("gemini", &db_current.id)
2696 .expect("set db current gemini provider");
2697 crate::settings::set_current_provider(&AppType::Gemini, Some(&local_current.id))
2698 .expect("set local current gemini provider");
2699
2700 let mut runtime_config = service.get_config().await.expect("get proxy config");
2701 runtime_config.listen_port = 0;
2702 service
2703 .update_config(&runtime_config)
2704 .await
2705 .expect("persist runtime config");
2706
2707 service
2708 .set_takeover_for_app("gemini", true)
2709 .await
2710 .expect("enable gemini takeover");
2711 service.stop().await.expect("stop proxy runtime");
2712
2713 let updated_local = db
2714 .get_provider_by_id("gemini-local-current", "gemini")
2715 .expect("read local-current gemini provider")
2716 .expect("local-current gemini provider exists");
2717 let local_env = updated_local
2718 .settings_config
2719 .get("env")
2720 .and_then(Value::as_object)
2721 .expect("local-current provider env should exist");
2722 assert_eq!(
2723 local_env.get("GEMINI_API_KEY").and_then(Value::as_str),
2724 Some("fresh-live-token"),
2725 "takeover sync should follow the effective current provider from local settings"
2726 );
2727
2728 let unchanged_db = db
2729 .get_provider_by_id("gemini-db-current", "gemini")
2730 .expect("read db-current gemini provider")
2731 .expect("db-current gemini provider exists");
2732 let db_env = unchanged_db
2733 .settings_config
2734 .get("env")
2735 .and_then(Value::as_object)
2736 .expect("db-current provider env should exist");
2737 assert_eq!(
2738 db_env.get("GEMINI_API_KEY").and_then(Value::as_str),
2739 Some("db-current-token"),
2740 "DB current provider should remain unchanged when local settings override it"
2741 );
2742 }
2743
2744 #[tokio::test]
2745 #[serial]
2746 async fn foreground_runtime_start_and_stop_syncs_global_proxy_switch() {
2747 let db = Arc::new(Database::memory().expect("create database"));
2748 let service = ProxyService::new(db);
2749
2750 assert!(
2751 !service
2752 .get_global_config()
2753 .await
2754 .expect("read initial global proxy config")
2755 .proxy_enabled,
2756 "precondition: global proxy switch should start disabled"
2757 );
2758
2759 let mut runtime_config = service.get_config().await.expect("get proxy config");
2760 runtime_config.listen_port = 0;
2761 service
2762 .start_with_runtime_config(runtime_config)
2763 .await
2764 .expect("start foreground proxy runtime");
2765
2766 assert!(
2767 service
2768 .get_global_config()
2769 .await
2770 .expect("read global proxy config after start")
2771 .proxy_enabled,
2772 "starting the foreground proxy runtime should enable the persisted global proxy switch"
2773 );
2774
2775 service.stop().await.expect("stop foreground proxy runtime");
2776
2777 assert!(
2778 !service
2779 .get_global_config()
2780 .await
2781 .expect("read global proxy config after stop")
2782 .proxy_enabled,
2783 "stopping the foreground proxy runtime should disable the persisted global proxy switch"
2784 );
2785 }
2786
2787 #[tokio::test]
2788 #[serial]
2789 async fn managed_external_runtime_does_not_publish_session_before_ready_signal() {
2790 let _env = ManagedRuntimeEnvGuard::set("test-managed-session-token");
2791 let db = Arc::new(Database::memory().expect("create database"));
2792 let service = ProxyService::new(db.clone());
2793
2794 let mut runtime_config = service.get_config().await.expect("get proxy config");
2795 runtime_config.listen_port = 0;
2796 service
2797 .start_with_runtime_config(runtime_config)
2798 .await
2799 .expect("start proxy runtime");
2800
2801 assert!(
2802 db.get_setting(PROXY_RUNTIME_SESSION_KEY)
2803 .expect("read runtime session")
2804 .is_none(),
2805 "managed external runtime should wait to publish its session until takeover is finished"
2806 );
2807
2808 service.stop().await.expect("stop proxy runtime");
2809 }
2810
2811 #[tokio::test]
2812 #[serial]
2813 async fn managed_external_runtime_publishes_session_when_ready_signal_is_sent() {
2814 let _env = ManagedRuntimeEnvGuard::set("test-managed-session-token");
2815 let db = Arc::new(Database::memory().expect("create database"));
2816 let service = ProxyService::new(db.clone());
2817
2818 let mut runtime_config = service.get_config().await.expect("get proxy config");
2819 runtime_config.listen_port = 0;
2820 let info = service
2821 .start_with_runtime_config(runtime_config)
2822 .await
2823 .expect("start proxy runtime");
2824
2825 service
2826 .publish_runtime_session_if_needed(&info)
2827 .expect("publish managed runtime session");
2828
2829 let session: PersistedProxyRuntimeSession = serde_json::from_str(
2830 &db.get_setting(PROXY_RUNTIME_SESSION_KEY)
2831 .expect("read runtime session")
2832 .expect("persisted runtime session"),
2833 )
2834 .expect("parse runtime session");
2835 assert!(session.kind.is_managed_external());
2836 assert_eq!(
2837 session.session_token.as_deref(),
2838 Some("test-managed-session-token")
2839 );
2840
2841 service.stop().await.expect("stop proxy runtime");
2842 }
2843
2844 #[tokio::test]
2845 #[serial]
2846 async fn takeover_mutation_waits_for_shared_restore_guard() {
2847 let temp_home = TempDir::new().expect("create temp home");
2848 let _env = TestHomeEnvGuard::set(temp_home.path());
2849 std::fs::create_dir_all(
2850 get_claude_settings_path()
2851 .parent()
2852 .expect("claude settings parent dir"),
2853 )
2854 .expect("create claude settings dir");
2855 write_json_file(
2856 &get_claude_settings_path(),
2857 &json!({
2858 "env": {
2859 "ANTHROPIC_API_KEY": "live-key"
2860 }
2861 }),
2862 )
2863 .expect("seed claude live config");
2864
2865 let db = Arc::new(Database::init().expect("create database"));
2866 let provider = Provider::with_id(
2867 "claude-provider".to_string(),
2868 "Claude Provider".to_string(),
2869 json!({
2870 "env": {
2871 "ANTHROPIC_API_KEY": "db-key"
2872 }
2873 }),
2874 Some("claude".to_string()),
2875 );
2876 db.save_provider("claude", &provider)
2877 .expect("save claude provider");
2878 db.set_current_provider("claude", &provider.id)
2879 .expect("set current claude provider");
2880
2881 let service = ProxyService::new(db.clone());
2882 let mut config = service.get_config().await.expect("read proxy config");
2883 config.listen_port = 0;
2884 service
2885 .update_config(&config)
2886 .await
2887 .expect("update proxy config");
2888
2889 let guard = crate::services::state_coordination::acquire_restore_mutation_guard()
2890 .await
2891 .expect("acquire shared restore guard");
2892 let completed = Arc::new(AtomicBool::new(false));
2893 let completed_bg = Arc::clone(&completed);
2894 let service_bg = service.clone();
2895 let handle = tokio::spawn(async move {
2896 service_bg
2897 .set_takeover_for_app("claude", true)
2898 .await
2899 .expect("enable takeover after guard release");
2900 completed_bg.store(true, Ordering::SeqCst);
2901 });
2902
2903 tokio::time::sleep(Duration::from_millis(150)).await;
2904 assert!(
2905 !completed.load(Ordering::SeqCst),
2906 "takeover mutation should wait behind the shared restore guard"
2907 );
2908 assert!(
2909 !handle.is_finished(),
2910 "spawned takeover task should still be blocked on the shared guard"
2911 );
2912
2913 drop(guard);
2914
2915 tokio::time::timeout(Duration::from_secs(5), handle)
2916 .await
2917 .expect("takeover task should complete after guard release")
2918 .expect("takeover task should succeed");
2919 assert!(
2920 completed.load(Ordering::SeqCst),
2921 "takeover mutation should complete once the shared guard is released"
2922 );
2923
2924 service
2925 .set_takeover_for_app("claude", false)
2926 .await
2927 .expect("disable takeover for cleanup");
2928 }
2929
2930 #[tokio::test]
2931 #[serial]
2932 async fn proxy_config_update_waits_for_shared_restore_guard() {
2933 let db = Arc::new(Database::memory().expect("create database"));
2934 let service = ProxyService::new(db.clone());
2935
2936 let initial = service.get_config().await.expect("read initial config");
2937 let mut updated = initial.clone();
2938 updated.listen_port = if initial.listen_port == 15721 {
2939 15722
2940 } else {
2941 initial.listen_port.saturating_add(1)
2942 };
2943 let expected_port = updated.listen_port;
2944
2945 let guard = crate::services::state_coordination::acquire_restore_mutation_guard()
2946 .await
2947 .expect("acquire shared restore guard");
2948 let completed = Arc::new(AtomicBool::new(false));
2949 let completed_bg = Arc::clone(&completed);
2950 let service_bg = service.clone();
2951 let handle = tokio::spawn(async move {
2952 service_bg
2953 .update_config(&updated)
2954 .await
2955 .expect("update proxy config after guard release");
2956 completed_bg.store(true, Ordering::SeqCst);
2957 });
2958
2959 tokio::time::sleep(Duration::from_millis(150)).await;
2960 assert!(
2961 !completed.load(Ordering::SeqCst),
2962 "proxy config update should wait behind the shared restore guard"
2963 );
2964 assert!(
2965 !handle.is_finished(),
2966 "spawned config-update task should still be blocked on the shared guard"
2967 );
2968
2969 drop(guard);
2970
2971 tokio::time::timeout(Duration::from_secs(5), handle)
2972 .await
2973 .expect("config update task should complete after guard release")
2974 .expect("config update task should succeed");
2975 assert!(
2976 completed.load(Ordering::SeqCst),
2977 "proxy config update should complete once the shared guard is released"
2978 );
2979
2980 assert_eq!(
2981 service
2982 .get_config()
2983 .await
2984 .expect("read config after guard release")
2985 .listen_port,
2986 expected_port
2987 );
2988 }
2989
2990 #[tokio::test]
2991 #[serial]
2992 async fn managed_session_ready_info_accepts_persisted_session_without_status_probe() {
2993 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
2994 .await
2995 .expect("bind unused proxy status port");
2996 let port = listener
2997 .local_addr()
2998 .expect("read unused proxy status port")
2999 .port();
3000 drop(listener);
3001
3002 let db = Arc::new(Database::memory().expect("create database"));
3003 let service = ProxyService::new(db.clone());
3004
3005 db.set_setting(
3006 PROXY_RUNTIME_SESSION_KEY,
3007 &serde_json::to_string(&PersistedProxyRuntimeSession {
3008 pid: 4242,
3009 address: "127.0.0.1".to_string(),
3010 port,
3011 started_at: "2026-03-10T00:00:00Z".to_string(),
3012 kind: PersistedProxyRuntimeSessionKind::ManagedExternal,
3013 session_token: Some("expected-session-token".to_string()),
3014 })
3015 .expect("serialize runtime session"),
3016 )
3017 .expect("persist runtime session");
3018
3019 let info = service
3020 .managed_session_ready_info(4242, "expected-session-token")
3021 .await
3022 .expect("persisted managed runtime marker should be treated as ready");
3023
3024 assert_eq!(info.address, "127.0.0.1");
3025 assert_eq!(info.port, port);
3026 assert_eq!(info.started_at, "2026-03-10T00:00:00Z");
3027 }
3028
3029 #[tokio::test]
3030 async fn managed_session_ready_info_prefers_matching_status_snapshot_when_available() {
3031 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
3032 .await
3033 .expect("bind fake proxy status listener");
3034 let port = listener
3035 .local_addr()
3036 .expect("read fake proxy listener addr")
3037 .port();
3038 let status = serde_json::json!({
3039 "running": true,
3040 "address": "127.0.0.1",
3041 "port": port,
3042 "active_connections": 0,
3043 "total_requests": 0,
3044 "success_requests": 0,
3045 "failed_requests": 0,
3046 "success_rate": 0.0,
3047 "uptime_seconds": 12,
3048 "current_provider": null,
3049 "current_provider_id": null,
3050 "last_request_at": null,
3051 "last_error": null,
3052 "failover_count": 0,
3053 "managed_session_token": "expected-session-token"
3054 });
3055
3056 let server = tokio::spawn(async move {
3057 let (mut socket, _) = listener.accept().await.expect("accept status request");
3058 let response = format!(
3059 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
3060 status.to_string().len(),
3061 status
3062 );
3063 use tokio::io::AsyncWriteExt;
3064 socket
3065 .write_all(response.as_bytes())
3066 .await
3067 .expect("write fake status response");
3068 });
3069
3070 let db = Arc::new(Database::memory().expect("create database"));
3071 let service = ProxyService::new(db.clone());
3072 db.set_setting(
3073 PROXY_RUNTIME_SESSION_KEY,
3074 &serde_json::to_string(&PersistedProxyRuntimeSession {
3075 pid: 4242,
3076 address: "127.0.0.1".to_string(),
3077 port,
3078 started_at: "2026-03-10T00:00:00Z".to_string(),
3079 kind: PersistedProxyRuntimeSessionKind::ManagedExternal,
3080 session_token: Some("expected-session-token".to_string()),
3081 })
3082 .expect("serialize runtime session"),
3083 )
3084 .expect("persist runtime session");
3085
3086 let info = service
3087 .managed_session_ready_info(4242, "expected-session-token")
3088 .await
3089 .expect("matching external status should make the session ready");
3090
3091 assert_eq!(info.address, "127.0.0.1");
3092 assert_eq!(info.port, port);
3093
3094 server.await.expect("fake status server should finish");
3095 }
3096
3097 #[tokio::test]
3098 async fn managed_session_ready_info_rejects_mismatched_status_snapshot() {
3099 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
3100 .await
3101 .expect("bind fake proxy status listener");
3102 let port = listener
3103 .local_addr()
3104 .expect("read fake proxy listener addr")
3105 .port();
3106 let status = serde_json::json!({
3107 "running": true,
3108 "address": "127.0.0.1",
3109 "port": port,
3110 "active_connections": 0,
3111 "total_requests": 0,
3112 "success_requests": 0,
3113 "failed_requests": 0,
3114 "success_rate": 0.0,
3115 "uptime_seconds": 12,
3116 "current_provider": null,
3117 "current_provider_id": null,
3118 "last_request_at": null,
3119 "last_error": null,
3120 "failover_count": 0,
3121 "managed_session_token": "other-session-token"
3122 });
3123
3124 let server = tokio::spawn(async move {
3125 let (mut socket, _) = listener.accept().await.expect("accept status request");
3126 let response = format!(
3127 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
3128 status.to_string().len(),
3129 status
3130 );
3131 use tokio::io::AsyncWriteExt;
3132 socket
3133 .write_all(response.as_bytes())
3134 .await
3135 .expect("write fake status response");
3136 });
3137
3138 let db = Arc::new(Database::memory().expect("create database"));
3139 let service = ProxyService::new(db.clone());
3140 db.set_setting(
3141 PROXY_RUNTIME_SESSION_KEY,
3142 &serde_json::to_string(&PersistedProxyRuntimeSession {
3143 pid: 4242,
3144 address: "127.0.0.1".to_string(),
3145 port,
3146 started_at: "2026-03-10T00:00:00Z".to_string(),
3147 kind: PersistedProxyRuntimeSessionKind::ManagedExternal,
3148 session_token: Some("expected-session-token".to_string()),
3149 })
3150 .expect("serialize runtime session"),
3151 )
3152 .expect("persist runtime session");
3153
3154 assert!(
3155 service
3156 .managed_session_ready_info(4242, "expected-session-token")
3157 .await
3158 .is_none(),
3159 "startup should keep waiting when /status reports a different managed session token"
3160 );
3161
3162 server.await.expect("fake status server should finish");
3163 }
3164
3165 #[tokio::test]
3166 #[serial]
3167 async fn hot_updating_running_breaker_configs_refreshes_existing_breakers() {
3168 let db = Arc::new(Database::memory().expect("create database"));
3169 let service = ProxyService::new(db.clone());
3170
3171 let provider = Provider::with_id(
3172 "p1".to_string(),
3173 "Provider One".to_string(),
3174 json!({}),
3175 None,
3176 );
3177 db.save_provider("claude", &provider)
3178 .expect("save provider");
3179
3180 let mut app_proxy = db
3181 .get_proxy_config_for_app("claude")
3182 .await
3183 .expect("load app proxy config");
3184 app_proxy.circuit_failure_threshold = 1;
3185 app_proxy.circuit_timeout_seconds = 3600;
3186 db.update_proxy_config_for_app(app_proxy)
3187 .await
3188 .expect("persist initial breaker config");
3189
3190 let mut runtime_config = service.get_config().await.expect("get proxy config");
3191 runtime_config.listen_port = 0;
3192 service
3193 .start_with_runtime_config(runtime_config)
3194 .await
3195 .expect("start proxy");
3196
3197 let router = {
3198 let server_guard = service.runtime.server.read().await;
3199 server_guard
3200 .as_ref()
3201 .expect("running server")
3202 .provider_router()
3203 };
3204
3205 router
3206 .record_result("p1", "claude", false, false, Some("fail".to_string()))
3207 .await
3208 .expect("open breaker");
3209 assert!(!router.allow_provider_request("p1", "claude").await.allowed);
3210
3211 let updated = CircuitBreakerConfig {
3212 failure_threshold: 1,
3213 success_threshold: 2,
3214 timeout_seconds: 0,
3215 error_rate_threshold: 1.0,
3216 min_requests: u32::MAX,
3217 };
3218 db.update_circuit_breaker_config(&updated)
3219 .await
3220 .expect("persist updated breaker config");
3221 service
3222 .update_circuit_breaker_configs(updated)
3223 .await
3224 .expect("hot update running breaker config");
3225
3226 let permit = router.allow_provider_request("p1", "claude").await;
3227 assert!(permit.allowed);
3228 assert!(permit.used_half_open_permit);
3229
3230 service.stop().await.expect("stop proxy");
3231 }
3232
3233 #[tokio::test]
3234 #[serial]
3235 async fn resetting_running_provider_breaker_clears_existing_breaker_state() {
3236 let db = Arc::new(Database::memory().expect("create database"));
3237 let service = ProxyService::new(db.clone());
3238
3239 let provider = Provider::with_id(
3240 "p1".to_string(),
3241 "Provider One".to_string(),
3242 json!({}),
3243 None,
3244 );
3245 db.save_provider("claude", &provider)
3246 .expect("save provider");
3247
3248 let mut app_proxy = db
3249 .get_proxy_config_for_app("claude")
3250 .await
3251 .expect("load app proxy config");
3252 app_proxy.circuit_failure_threshold = 1;
3253 app_proxy.circuit_timeout_seconds = 3600;
3254 db.update_proxy_config_for_app(app_proxy)
3255 .await
3256 .expect("persist breaker config");
3257
3258 let mut runtime_config = service.get_config().await.expect("get proxy config");
3259 runtime_config.listen_port = 0;
3260 service
3261 .start_with_runtime_config(runtime_config)
3262 .await
3263 .expect("start proxy");
3264
3265 let router = {
3266 let server_guard = service.runtime.server.read().await;
3267 server_guard
3268 .as_ref()
3269 .expect("running server")
3270 .provider_router()
3271 };
3272
3273 router
3274 .record_result("p1", "claude", false, false, Some("fail".to_string()))
3275 .await
3276 .expect("open breaker");
3277 assert!(!router.allow_provider_request("p1", "claude").await.allowed);
3278
3279 service
3280 .reset_provider_circuit_breaker("p1", "claude")
3281 .await
3282 .expect("reset running breaker");
3283
3284 let permit = router.allow_provider_request("p1", "claude").await;
3285 assert!(permit.allowed);
3286 assert!(!permit.used_half_open_permit);
3287
3288 service.stop().await.expect("stop proxy");
3289 }
3290
3291 #[tokio::test]
3292 #[serial]
3293 async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
3294 let db = Arc::new(Database::memory().expect("init db"));
3295 let service = ProxyService::new(db.clone());
3296
3297 db.save_live_backup(
3298 "codex",
3299 &serde_json::to_string(&json!({
3300 "auth": {
3301 "OPENAI_API_KEY": "old-token"
3302 },
3303 "config": r#"model_provider = "any"
3304model = "gpt-4"
3305
3306[model_providers.any]
3307base_url = "https://old.example/v1"
3308
3309[mcp_servers.echo]
3310command = "npx"
3311args = ["echo-server"]
3312"#
3313 }))
3314 .expect("serialize seed backup"),
3315 )
3316 .await
3317 .expect("seed live backup");
3318
3319 let provider = Provider::with_id(
3320 "p2".to_string(),
3321 "P2".to_string(),
3322 json!({
3323 "auth": {
3324 "OPENAI_API_KEY": "new-token"
3325 },
3326 "config": r#"model_provider = "any"
3327model = "gpt-5"
3328
3329[model_providers.any]
3330base_url = "https://new.example/v1"
3331"#
3332 }),
3333 None,
3334 );
3335
3336 service
3337 .update_live_backup_from_provider("codex", &provider)
3338 .await
3339 .expect("update live backup");
3340
3341 let backup = db
3342 .get_live_backup("codex")
3343 .await
3344 .expect("get live backup")
3345 .expect("backup exists");
3346 let stored: Value =
3347 serde_json::from_str(&backup.original_config).expect("parse backup json");
3348 let config = stored
3349 .get("config")
3350 .and_then(|v| v.as_str())
3351 .expect("config string");
3352
3353 assert!(
3354 config.contains("[mcp_servers.echo]"),
3355 "existing Codex MCP section should survive proxy hot-switch backup update"
3356 );
3357 assert!(
3358 config.contains("https://new.example/v1"),
3359 "provider-specific base_url should still update to the new provider"
3360 );
3361 }
3362
3363 #[tokio::test]
3364 #[serial]
3365 async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {
3366 let temp_home = TempDir::new().expect("create temp home");
3367 let _env = TestHomeEnvGuard::set(temp_home.path());
3368
3369 let db = Arc::new(Database::memory().expect("init db"));
3370 let service = ProxyService::new(db.clone());
3371
3372 let provider_a = Provider::with_id(
3373 "a".to_string(),
3374 "RightCode".to_string(),
3375 json!({
3376 "auth": {
3377 "OPENAI_API_KEY": "rightcode-key"
3378 },
3379 "config": r#"model_provider = "rightcode"
3380model = "gpt-5.4"
3381
3382[model_providers.rightcode]
3383name = "RightCode"
3384base_url = "https://rightcode.example/v1"
3385wire_api = "responses"
3386requires_openai_auth = true
3387"#
3388 }),
3389 None,
3390 );
3391 let provider_b = Provider::with_id(
3392 "b".to_string(),
3393 "AiHubMix".to_string(),
3394 json!({
3395 "auth": {
3396 "OPENAI_API_KEY": "aihubmix-key"
3397 },
3398 "config": r#"model_provider = "aihubmix"
3399model = "gpt-5.4"
3400
3401[model_providers.aihubmix]
3402name = "AiHubMix"
3403base_url = "https://aihubmix.example/v1"
3404wire_api = "responses"
3405requires_openai_auth = true
3406"#
3407 }),
3408 None,
3409 );
3410
3411 db.save_provider("codex", &provider_a)
3412 .expect("save provider a");
3413 db.save_provider("codex", &provider_b)
3414 .expect("save provider b");
3415 db.set_current_provider("codex", "a")
3416 .expect("set current provider");
3417 crate::settings::set_current_provider(&AppType::Codex, Some("a"))
3418 .expect("set local current provider");
3419 db.save_live_backup(
3420 "codex",
3421 &serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
3422 )
3423 .await
3424 .expect("seed live backup");
3425 service
3426 .write_codex_live(&json!({
3427 "auth": {
3428 "OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER
3429 },
3430 "config": r#"model_provider = "rightcode"
3431model = "gpt-5.4"
3432
3433[model_providers.rightcode]
3434name = "RightCode"
3435base_url = "http://127.0.0.1:15721/v1"
3436wire_api = "responses"
3437requires_openai_auth = true
3438"#
3439 }))
3440 .expect("seed taken-over Codex live config");
3441
3442 service
3443 .hot_switch_provider("codex", "b")
3444 .await
3445 .expect("hot switch Codex provider");
3446
3447 let backup = db
3448 .get_live_backup("codex")
3449 .await
3450 .expect("get live backup")
3451 .expect("backup exists");
3452 let stored: Value =
3453 serde_json::from_str(&backup.original_config).expect("parse backup json");
3454 let backup_config = stored
3455 .get("config")
3456 .and_then(Value::as_str)
3457 .expect("backup config string");
3458 let parsed_backup: toml::Value =
3459 toml::from_str(backup_config).expect("parse backup config");
3460 assert_eq!(
3461 parsed_backup.get("model_provider").and_then(|v| v.as_str()),
3462 Some("rightcode"),
3463 "provider-derived restore backup should retain stable Codex model_provider"
3464 );
3465 let backup_model_providers = parsed_backup
3466 .get("model_providers")
3467 .and_then(|v| v.as_table())
3468 .expect("backup model_providers");
3469 assert!(backup_model_providers.get("aihubmix").is_none());
3470 assert_eq!(
3471 backup_model_providers
3472 .get("rightcode")
3473 .and_then(|v| v.get("base_url"))
3474 .and_then(|v| v.as_str()),
3475 Some("https://aihubmix.example/v1"),
3476 "stable provider id should point at the hot-switched provider endpoint"
3477 );
3478
3479 service
3480 .restore_live_config_for_app(&AppType::Codex)
3481 .await
3482 .expect("restore Codex live config");
3483
3484 let live = service.read_codex_live().expect("read Codex live config");
3485 let live_config = live
3486 .get("config")
3487 .and_then(Value::as_str)
3488 .expect("live config string");
3489 let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config");
3490 assert_eq!(
3491 parsed_live.get("model_provider").and_then(|v| v.as_str()),
3492 Some("rightcode"),
3493 "restored Codex live config should not switch history buckets"
3494 );
3495 assert_eq!(
3496 live.get("auth")
3497 .and_then(|auth| auth.get("OPENAI_API_KEY"))
3498 .and_then(Value::as_str),
3499 Some("aihubmix-key"),
3500 "restore should still use the hot-switched provider auth"
3501 );
3502 }
3503
3504 #[tokio::test]
3505 #[serial]
3506 async fn update_live_backup_from_provider_for_gemini_keeps_only_env_snapshot() {
3507 let temp_home = TempDir::new().expect("create temp home");
3508 let _env = TestHomeEnvGuard::set(temp_home.path());
3509
3510 let settings_path = crate::gemini_config::get_gemini_settings_path();
3511 std::fs::create_dir_all(
3512 settings_path
3513 .parent()
3514 .expect("gemini settings parent directory"),
3515 )
3516 .expect("create gemini settings directory");
3517 write_json_file(
3518 &settings_path,
3519 &json!({
3520 "mcpServers": {
3521 "echo": {
3522 "command": "npx",
3523 "args": ["echo-server"]
3524 }
3525 }
3526 }),
3527 )
3528 .expect("seed gemini settings.json");
3529
3530 let db = Arc::new(Database::memory().expect("init db"));
3531 let service = ProxyService::new(db.clone());
3532 db.save_live_backup("gemini", r#"{"env":{"GEMINI_API_KEY":"stale-token"}}"#)
3533 .await
3534 .expect("seed active gemini backup");
3535 let provider = Provider::with_id(
3536 "gemini-provider".to_string(),
3537 "Gemini Provider".to_string(),
3538 json!({
3539 "env": {
3540 "GEMINI_API_KEY": "gemini-token"
3541 },
3542 "config": {
3543 "theme": "solarized"
3544 }
3545 }),
3546 None,
3547 );
3548
3549 service
3550 .update_live_backup_from_provider("gemini", &provider)
3551 .await
3552 .expect("update gemini live backup");
3553
3554 let backup = db
3555 .get_live_backup("gemini")
3556 .await
3557 .expect("get gemini live backup")
3558 .expect("gemini backup exists");
3559 let stored: Value =
3560 serde_json::from_str(&backup.original_config).expect("parse gemini backup json");
3561
3562 assert_eq!(
3563 stored.get("env"),
3564 Some(&json!({
3565 "GEMINI_API_KEY": "gemini-token"
3566 })),
3567 "Gemini live backup should keep the provider env for takeover restore"
3568 );
3569 assert!(
3570 stored.get("config").is_none(),
3571 "Gemini live backup should not snapshot settings.json/config; upstream keeps only env"
3572 );
3573 }
3574
3575 #[tokio::test]
3576 #[serial]
3577 async fn update_live_backup_from_provider_applies_claude_common_config_without_takeover() {
3578 let temp_home = TempDir::new().expect("create temp home");
3579 let _env = TestHomeEnvGuard::set(temp_home.path());
3580
3581 let db = Arc::new(Database::memory().expect("init db"));
3582 db.set_config_snippet(
3583 "claude",
3584 Some(
3585 serde_json::json!({
3586 "includeCoAuthoredBy": false
3587 })
3588 .to_string(),
3589 ),
3590 )
3591 .expect("set common config snippet");
3592
3593 let service = ProxyService::new(db.clone());
3594
3595 let mut provider = Provider::with_id(
3596 "claude-provider".to_string(),
3597 "Claude Provider".to_string(),
3598 json!({
3599 "env": {
3600 "ANTHROPIC_AUTH_TOKEN": "token",
3601 "ANTHROPIC_BASE_URL": "https://claude.example"
3602 }
3603 }),
3604 None,
3605 );
3606 provider.meta = Some(ProviderMeta {
3607 apply_common_config: Some(true),
3608 ..Default::default()
3609 });
3610
3611 service
3612 .update_live_backup_from_provider("claude", &provider)
3613 .await
3614 .expect("update claude live backup");
3615
3616 let backup = db
3617 .get_live_backup("claude")
3618 .await
3619 .expect("get claude live backup")
3620 .expect("claude backup exists");
3621 let stored: Value =
3622 serde_json::from_str(&backup.original_config).expect("parse claude backup json");
3623
3624 assert_eq!(
3625 stored.get("includeCoAuthoredBy").and_then(Value::as_bool),
3626 Some(false),
3627 "common config should be applied into Claude restore backup even before takeover is active"
3628 );
3629 }
3630
3631 #[tokio::test]
3632 #[serial]
3633 async fn switch_proxy_target_updates_live_backup_when_taken_over() {
3634 let temp_home = TempDir::new().expect("create temp home");
3635 let _env = TestHomeEnvGuard::set(temp_home.path());
3636
3637 let db = Arc::new(Database::memory().expect("init db"));
3638 let service = ProxyService::new(db.clone());
3639
3640 let provider_a = Provider::with_id(
3641 "a".to_string(),
3642 "A".to_string(),
3643 json!({
3644 "env": {
3645 "ANTHROPIC_API_KEY": "a-key"
3646 }
3647 }),
3648 None,
3649 );
3650 let provider_b = Provider::with_id(
3651 "b".to_string(),
3652 "B".to_string(),
3653 json!({
3654 "env": {
3655 "ANTHROPIC_API_KEY": "b-key"
3656 }
3657 }),
3658 None,
3659 );
3660 db.save_provider("claude", &provider_a)
3661 .expect("save provider a");
3662 db.save_provider("claude", &provider_b)
3663 .expect("save provider b");
3664 db.set_current_provider("claude", "a")
3665 .expect("set current provider");
3666
3667 db.save_live_backup("claude", "{\"env\":{}}")
3668 .await
3669 .expect("seed live backup");
3670
3671 service
3672 .switch_proxy_target("claude", "b")
3673 .await
3674 .expect("switch proxy target");
3675
3676 assert_eq!(
3677 crate::settings::get_current_provider(&AppType::Claude).as_deref(),
3678 Some("b")
3679 );
3680
3681 let backup = db
3682 .get_live_backup("claude")
3683 .await
3684 .expect("get live backup")
3685 .expect("backup exists");
3686 let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
3687 assert_eq!(backup.original_config, expected);
3688 }
3689
3690 #[tokio::test]
3691 #[serial]
3692 async fn restore_live_from_current_provider_applies_codex_common_config() {
3693 let temp_home = TempDir::new().expect("create temp home");
3694 let _env = TestHomeEnvGuard::set(temp_home.path());
3695
3696 let db = Arc::new(Database::memory().expect("init db"));
3697 db.set_config_snippet(
3698 "codex",
3699 Some("disable_response_storage = true\n".to_string()),
3700 )
3701 .expect("set codex common config snippet");
3702
3703 let service = ProxyService::new(db.clone());
3704 let mut provider = Provider::with_id(
3705 "codex-provider".to_string(),
3706 "Codex Provider".to_string(),
3707 json!({
3708 "auth": {
3709 "OPENAI_API_KEY": "codex-token"
3710 },
3711 "config": r#"model_provider = "default"
3712model = "gpt-5.2-codex"
3713
3714[model_providers.default]
3715base_url = "https://api.example/v1"
3716"#
3717 }),
3718 None,
3719 );
3720 provider.meta = Some(ProviderMeta {
3721 apply_common_config: Some(true),
3722 ..Default::default()
3723 });
3724 db.save_provider("codex", &provider)
3725 .expect("save codex provider");
3726 db.set_current_provider("codex", &provider.id)
3727 .expect("set current codex provider");
3728
3729 service
3730 .restore_live_from_current_provider(&AppType::Codex)
3731 .await
3732 .expect("restore codex live from current provider");
3733
3734 let auth: Value = read_json_file(&get_codex_auth_path()).expect("read codex auth.json");
3735 assert_eq!(
3736 auth.get("OPENAI_API_KEY").and_then(Value::as_str),
3737 Some("codex-token"),
3738 "Codex fallback restore should write auth.json from the current provider"
3739 );
3740
3741 let config_text =
3742 std::fs::read_to_string(get_codex_config_path()).expect("read codex config.toml");
3743 assert!(
3744 config_text.contains("disable_response_storage = true"),
3745 "Codex fallback restore should apply the common config snippet like upstream"
3746 );
3747 }
3748}