Skip to main content

cc_switch_lib/cli/commands/
proxy.rs

1use clap::Subcommand;
2
3use crate::app_config::AppType;
4use crate::cli::ui::{highlight, info, success};
5use crate::error::AppError;
6use crate::{AppState, ProxyConfig};
7
8#[derive(Subcommand, Debug, Clone)]
9pub enum ProxyCommand {
10    /// Show current proxy configuration and routes
11    Show,
12
13    /// Enable the persisted proxy switch
14    Enable,
15
16    /// Disable the persisted proxy switch
17    Disable,
18
19    /// Start the local proxy in the foreground for debugging
20    Serve {
21        /// Override listen address for this run only
22        #[arg(long)]
23        listen_address: Option<String>,
24
25        /// Override listen port for this run only
26        #[arg(long)]
27        listen_port: Option<u16>,
28
29        /// Enable manual takeover for the given app while this foreground session is running
30        #[arg(long = "takeover", value_enum)]
31        takeovers: Vec<AppType>,
32    },
33}
34
35pub fn execute(cmd: ProxyCommand) -> Result<(), AppError> {
36    match cmd {
37        ProxyCommand::Show => show_proxy(),
38        ProxyCommand::Enable => set_proxy_enabled(true),
39        ProxyCommand::Disable => set_proxy_enabled(false),
40        ProxyCommand::Serve {
41            listen_address,
42            listen_port,
43            takeovers,
44        } => serve_proxy(listen_address, listen_port, takeovers),
45    }
46}
47
48fn get_state() -> Result<AppState, AppError> {
49    AppState::try_new()
50}
51
52fn create_runtime() -> Result<tokio::runtime::Runtime, AppError> {
53    tokio::runtime::Builder::new_current_thread()
54        .enable_all()
55        .build()
56        .map_err(|e| AppError::Message(format!("failed to create async runtime: {e}")))
57}
58
59fn show_proxy() -> Result<(), AppError> {
60    let state = get_state()?;
61    let runtime = create_runtime()?;
62    let global = runtime.block_on(state.proxy_service.get_global_config())?;
63    let config = runtime.block_on(state.proxy_service.get_config())?;
64    let status = runtime.block_on(state.proxy_service.get_status());
65    let takeovers = runtime
66        .block_on(state.proxy_service.get_takeover_status())
67        .map_err(AppError::Message)?;
68
69    println!("{}", highlight(crate::t!("Local Proxy", "本地代理")));
70    for line in build_proxy_overview_lines(&state, &global, &config, &status, &takeovers) {
71        println!("{line}");
72    }
73
74    Ok(())
75}
76
77fn set_proxy_enabled(enabled: bool) -> Result<(), AppError> {
78    let state = get_state()?;
79    let runtime = create_runtime()?;
80    let config = runtime.block_on(state.proxy_service.set_global_enabled(enabled))?;
81
82    println!(
83        "{}",
84        success(&format!(
85            "{}: {}",
86            crate::t!("Proxy switch", "代理开关"),
87            if config.proxy_enabled {
88                crate::t!("enabled", "开启")
89            } else {
90                crate::t!("disabled", "关闭")
91            }
92        ))
93    );
94
95    Ok(())
96}
97
98fn serve_proxy(
99    listen_address: Option<String>,
100    listen_port: Option<u16>,
101    takeovers: Vec<AppType>,
102) -> Result<(), AppError> {
103    let state = get_state()?;
104    let runtime = create_runtime()?;
105
106    runtime.block_on(async move {
107        let service = state.proxy_service.clone();
108        let base_config = service.get_config().await?;
109        let effective_config = apply_overrides(&base_config, listen_address, listen_port);
110
111        let result = async {
112            let server_info = service
113                .start_with_runtime_config(effective_config)
114                .await
115                .map_err(AppError::Message)?;
116
117            if let Err(err) = apply_takeovers(&service, &takeovers).await {
118                let _ = service.stop_with_restore().await;
119                return Err(AppError::Message(err));
120            }
121
122            if let Err(err) = service.publish_runtime_session_if_needed(&server_info) {
123                let _ = service.stop_with_restore().await;
124                return Err(AppError::Message(err));
125            }
126            crate::services::state_coordination::clear_restore_mutation_guard_bypass_env();
127
128            println!("{}", highlight(crate::t!("Local Proxy Running", "本地代理已启动")));
129            println!(
130                "{}",
131                success(&format!(
132                    "{} http://{}:{}",
133                    crate::t!("Listening on", "监听地址"),
134                    server_info.address,
135                    server_info.port
136                ))
137            );
138            println!(
139                "{}",
140                info(crate::t!(
141                    "Claude: /v1/messages · Codex: /v1/chat/completions + /v1/responses · Gemini: /v1beta/*",
142                    "Claude: /v1/messages · Codex: /v1/chat/completions + /v1/responses · Gemini: /v1beta/*"
143                ))
144            );
145            if !takeovers.is_empty() {
146                println!(
147                    "{}",
148                    success(&format!(
149                        "{} {}",
150                        crate::t!("Manual takeover enabled for:", "已为以下应用开启手动接管:"),
151                        takeovers
152                            .iter()
153                            .map(AppType::as_str)
154                            .collect::<Vec<_>>()
155                            .join(", ")
156                    ))
157                );
158            }
159            for line in build_auto_failover_status_lines(&state) {
160                println!("{}", info(&line));
161            }
162            println!(
163                "{}",
164                info(crate::t!(
165                    "Press Ctrl-C to stop the proxy.",
166                    "按 Ctrl-C 停止代理。"
167                ))
168            );
169
170            tokio::signal::ctrl_c()
171                .await
172                .map_err(|e| AppError::Message(format!("failed to listen for Ctrl-C: {e}")))?;
173
174            service
175                .stop_with_restore()
176                .await
177                .map_err(AppError::Message)?;
178            println!(
179                "{}",
180                success(crate::t!("✓ Proxy stopped.", "✓ 代理已停止。"))
181            );
182
183            Ok(())
184        }
185        .await;
186
187        result
188    })
189}
190
191async fn apply_takeovers(
192    service: &crate::ProxyService,
193    takeovers: &[AppType],
194) -> Result<(), String> {
195    for app in takeovers {
196        match app {
197            AppType::Claude | AppType::Codex | AppType::Gemini => {
198                service.set_takeover_for_app(app.as_str(), true).await?;
199            }
200            _ => {
201                return Err(format!(
202                    "proxy takeover is not supported for {}",
203                    app.as_str()
204                ));
205            }
206        }
207    }
208
209    Ok(())
210}
211
212fn apply_overrides(
213    original: &ProxyConfig,
214    listen_address: Option<String>,
215    listen_port: Option<u16>,
216) -> ProxyConfig {
217    let mut config = original.clone();
218    if let Some(address) = listen_address {
219        config.listen_address = address;
220    }
221    if let Some(port) = listen_port {
222        config.listen_port = port;
223    }
224    config
225}
226
227fn build_proxy_overview_lines(
228    state: &AppState,
229    global: &crate::proxy::types::GlobalProxyConfig,
230    config: &ProxyConfig,
231    status: &crate::ProxyStatus,
232    takeovers: &crate::proxy::types::ProxyTakeoverStatus,
233) -> Vec<String> {
234    let current_providers = AppType::all()
235        .map(|app| {
236            let current = state
237                .db
238                .get_current_provider(app.as_str())
239                .unwrap_or(None)
240                .filter(|id| !id.trim().is_empty())
241                .unwrap_or_else(|| crate::t!("(not set)", "(未设置)").to_string());
242            format!("- {}: {}", app.as_str(), current)
243        })
244        .collect::<Vec<_>>();
245
246    let listen_host = if status.running && !status.address.is_empty() {
247        status.address.as_str()
248    } else {
249        config.listen_address.as_str()
250    };
251    let listen_port = if status.running && status.port > 0 {
252        status.port
253    } else {
254        config.listen_port
255    };
256
257    let mut lines = vec![
258        format!(
259            "{}: {}",
260            crate::t!("Running", "运行中"),
261            if status.running {
262                crate::t!("yes", "是")
263            } else {
264                crate::t!("no", "否")
265            }
266        ),
267        format!(
268            "{}: {}",
269            crate::t!("Enabled", "启用状态"),
270            if global.proxy_enabled {
271                crate::t!("enabled", "开启")
272            } else {
273                crate::t!("disabled", "关闭")
274            }
275        ),
276        format!(
277            "{}: {}:{}",
278            crate::t!("Listen", "监听"),
279            listen_host,
280            listen_port
281        ),
282        crate::t!(
283            "Mode: local proxy (manual takeover and automatic failover follow app settings)",
284            "模式:本地代理(手动接管和自动故障转移遵循各应用配置)"
285        )
286        .to_string(),
287        format!(
288            "{}: {}",
289            crate::t!("Retries", "重试次数"),
290            config.max_retries
291        ),
292        format!(
293            "{}: {}",
294            crate::t!("Logging", "日志"),
295            if config.enable_logging {
296                crate::t!("enabled", "开启")
297            } else {
298                crate::t!("disabled", "关闭")
299            }
300        ),
301        format!(
302            "{}: {}s / {}s / {}s",
303            crate::t!(
304                "Timeouts (first-byte / idle / non-stream)",
305                "超时(首字 / 空闲 / 非流式)"
306            ),
307            config.streaming_first_byte_timeout,
308            config.streaming_idle_timeout,
309            config.non_streaming_timeout
310        ),
311        String::new(),
312        crate::t!("Takeovers:", "接管状态:").to_string(),
313        format!(
314            "- Claude: {}",
315            if takeovers.claude {
316                crate::t!("takeover on", "已接管")
317            } else {
318                crate::t!("takeover off", "未接管")
319            }
320        ),
321        format!(
322            "- Codex: {}",
323            if takeovers.codex {
324                crate::t!("takeover on", "已接管")
325            } else {
326                crate::t!("takeover off", "未接管")
327            }
328        ),
329        format!(
330            "- Gemini: {}",
331            if takeovers.gemini {
332                crate::t!("takeover on", "已接管")
333            } else {
334                crate::t!("takeover off", "未接管")
335            }
336        ),
337        String::new(),
338        crate::t!("Auto failover:", "自动故障转移:").to_string(),
339    ];
340    lines.extend(build_auto_failover_status_lines(state));
341    lines.extend([
342        String::new(),
343        crate::t!("Current providers:", "当前供应商:").to_string(),
344    ]);
345    lines.extend(current_providers);
346    lines.extend([
347        String::new(),
348        crate::t!("Routes:", "路由:").to_string(),
349        "- Claude: /v1/messages, /claude/v1/messages".to_string(),
350        "- Codex: /chat/completions, /v1/chat/completions, /responses, /v1/responses".to_string(),
351        "- Gemini: /v1beta/*, /gemini/v1beta/*".to_string(),
352        String::new(),
353        crate::t!(
354            "Issue #49 manual Claude setup:",
355            "Issue #49 的 Claude 手动接线:"
356        )
357        .to_string(),
358        format!(
359            "- ANTHROPIC_BASE_URL=http://{}:{}",
360            listen_host, listen_port
361        ),
362        "- ANTHROPIC_AUTH_TOKEN=proxy-placeholder".to_string(),
363        crate::t!(
364            "- Keep the real upstream base URL and API key in the selected Claude provider inside cc-switch.",
365            "- 真实上游 base URL 和 API key 仍保存在 cc-switch 里选中的 Claude provider 中。"
366        )
367        .to_string(),
368        String::new(),
369        crate::t!(
370            "Manual takeover is controlled with --takeover; automatic failover uses each app's proxy settings and failover queue.",
371            "手动接管通过 --takeover 控制;自动故障转移使用各应用的代理配置和故障转移队列。"
372        )
373        .to_string(),
374        String::new(),
375        format!(
376            "{}: cc-switch proxy serve --listen-address {} --listen-port {}",
377            crate::t!("Debug command", "调试命令"),
378            config.listen_address,
379            config.listen_port
380        ),
381        format!(
382            "{}: cc-switch proxy serve --takeover claude",
383            crate::t!("Takeover command", "接管命令")
384        ),
385    ]);
386
387    lines
388}
389
390fn build_auto_failover_status_lines(state: &AppState) -> Vec<String> {
391    [
392        (AppType::Claude, "Claude"),
393        (AppType::Codex, "Codex"),
394        (AppType::Gemini, "Gemini"),
395    ]
396    .into_iter()
397    .map(|(app, label)| {
398        let (_, auto_failover_enabled) = state.db.get_proxy_flags_sync(app.as_str());
399        format!(
400            "- {}: {}",
401            label,
402            if auto_failover_enabled {
403                crate::t!("auto failover on", "自动故障转移开启")
404            } else {
405                crate::t!("auto failover off", "自动故障转移关闭")
406            }
407        )
408    })
409    .collect()
410}
411
412#[cfg(test)]
413mod tests {
414    use std::sync::{Arc, RwLock};
415
416    use crate::{
417        proxy::types::{GlobalProxyConfig, ProxyStatus, ProxyTakeoverStatus},
418        Database, MultiAppConfig, ProxyService,
419    };
420
421    use super::build_proxy_overview_lines;
422
423    #[test]
424    fn proxy_overview_lines_include_runtime_status_and_takeover_state() {
425        let db = Arc::new(Database::memory().expect("create database"));
426        let state = crate::AppState {
427            db: db.clone(),
428            config: RwLock::new(MultiAppConfig::default()),
429            proxy_service: ProxyService::new(db),
430        };
431        let global = GlobalProxyConfig {
432            proxy_enabled: true,
433            listen_address: "127.0.0.1".to_string(),
434            listen_port: 15721,
435            enable_logging: true,
436        };
437        let config = crate::ProxyConfig::default();
438        let status = ProxyStatus {
439            running: true,
440            address: "127.0.0.1".to_string(),
441            port: 24567,
442            ..Default::default()
443        };
444        let takeover = ProxyTakeoverStatus {
445            claude: true,
446            codex: false,
447            gemini: true,
448        };
449
450        let lines = build_proxy_overview_lines(&state, &global, &config, &status, &takeover);
451        let output = lines.join("\n");
452
453        assert!(
454            output.contains("Running: yes") || output.contains("运行中: 是"),
455            "proxy show output should include foreground runtime status"
456        );
457        assert!(
458            output.contains("127.0.0.1:24567"),
459            "proxy show output should prefer the active runtime listen address when the proxy is running"
460        );
461        assert!(
462            output.contains("Claude: takeover on") || output.contains("Claude: 已接管"),
463            "proxy show output should include Claude manual takeover state"
464        );
465        assert!(
466            output.contains("Gemini: takeover on") || output.contains("Gemini: 已接管"),
467            "proxy show output should include Gemini manual takeover state"
468        );
469    }
470
471    #[test]
472    fn proxy_overview_lines_report_configured_auto_failover_state() {
473        let db = Arc::new(Database::memory().expect("create database"));
474        db.set_proxy_flags_sync("codex", false, true)
475            .expect("enable codex auto failover");
476        let state = crate::AppState {
477            db: db.clone(),
478            config: RwLock::new(MultiAppConfig::default()),
479            proxy_service: ProxyService::new(db),
480        };
481        let global = GlobalProxyConfig {
482            proxy_enabled: true,
483            listen_address: "127.0.0.1".to_string(),
484            listen_port: 15721,
485            enable_logging: true,
486        };
487        let config = crate::ProxyConfig::default();
488        let status = ProxyStatus::default();
489        let takeover = ProxyTakeoverStatus::default();
490
491        let lines = build_proxy_overview_lines(&state, &global, &config, &status, &takeover);
492        let output = lines.join("\n");
493
494        assert!(
495            output.contains("Codex: auto failover on")
496                || output.contains("Codex: 自动故障转移开启"),
497            "proxy show output should reflect app-specific auto failover settings"
498        );
499        assert!(
500            !output.contains("automatic failover disabled"),
501            "proxy show output should not hard-code automatic failover as disabled"
502        );
503    }
504}