Skip to main content

codex_helper_core/
runtime_host.rs

1use std::collections::HashMap;
2use std::net::{IpAddr, SocketAddr};
3use std::sync::{Arc, Mutex};
4
5use anyhow::Result;
6use axum::Router;
7use reqwest::Client;
8use tokio::sync::watch;
9use tokio::task::JoinHandle;
10
11use crate::config::{
12    LoadedProxyConfig, ProxyConfig, ServiceKind, load_or_bootstrap_for_service_with_v4_source,
13    model_routing_warnings,
14};
15use crate::host_local::HostLocalSessionHistoryMode;
16use crate::lb::LbState;
17use crate::proxy::{
18    ProxyService, admin_listener_router, admin_loopback_addr_for_proxy_port,
19    proxy_only_router_with_admin_base_url,
20};
21use crate::state::ProxyState;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ProxyLifetimeMode {
25    Ephemeral,
26    Resident,
27}
28
29impl ProxyLifetimeMode {
30    pub fn is_resident(self) -> bool {
31        matches!(self, Self::Resident)
32    }
33}
34
35pub struct ProxyRuntime {
36    pub service_name: &'static str,
37    pub host: IpAddr,
38    pub port: u16,
39    pub admin_addr: SocketAddr,
40    pub config: Arc<ProxyConfig>,
41    pub proxy: ProxyService,
42    pub state: Arc<ProxyState>,
43    pub shutdown_tx: watch::Sender<bool>,
44    shutdown_rx: watch::Receiver<bool>,
45    listener: Option<tokio::net::TcpListener>,
46    admin_listener: Option<tokio::net::TcpListener>,
47    app: Option<Router>,
48    admin_app: Option<Router>,
49}
50
51pub struct RunningProxyRuntime {
52    pub service_name: &'static str,
53    pub host: IpAddr,
54    pub port: u16,
55    pub admin_addr: SocketAddr,
56    pub config: Arc<ProxyConfig>,
57    pub proxy: ProxyService,
58    pub state: Arc<ProxyState>,
59    pub shutdown_tx: watch::Sender<bool>,
60    pub server_handle: JoinHandle<Result<()>>,
61}
62
63#[derive(Debug, Clone)]
64pub struct ProxyRuntimeOptions {
65    pub admin_addr: SocketAddr,
66    pub advertised_admin_base_url: Option<String>,
67    pub host_local_session_history_mode: HostLocalSessionHistoryMode,
68}
69
70impl ProxyRuntimeOptions {
71    pub fn for_proxy_port(port: u16) -> Self {
72        let admin_addr = admin_loopback_addr_for_proxy_port(port);
73        Self {
74            admin_addr,
75            advertised_admin_base_url: admin_discovery_base_url(admin_addr, None),
76            host_local_session_history_mode: HostLocalSessionHistoryMode::Auto,
77        }
78    }
79
80    pub fn with_admin_addr(mut self, admin_addr: SocketAddr) -> Self {
81        self.admin_addr = admin_addr;
82        self.advertised_admin_base_url = admin_discovery_base_url(admin_addr, None);
83        self
84    }
85
86    pub fn with_advertised_admin_base_url(
87        mut self,
88        advertised_admin_base_url: Option<String>,
89    ) -> Self {
90        self.advertised_admin_base_url =
91            admin_discovery_base_url(self.admin_addr, advertised_admin_base_url.as_deref());
92        self
93    }
94
95    pub fn with_host_local_session_history_mode(
96        mut self,
97        mode: HostLocalSessionHistoryMode,
98    ) -> Self {
99        self.host_local_session_history_mode = mode;
100        self
101    }
102}
103
104impl ProxyRuntime {
105    pub fn shutdown_receiver(&self) -> watch::Receiver<bool> {
106        self.shutdown_rx.clone()
107    }
108
109    pub fn start(mut self) -> RunningProxyRuntime {
110        let listener = self
111            .listener
112            .take()
113            .expect("proxy runtime listener should exist before start");
114        let admin_listener = self
115            .admin_listener
116            .take()
117            .expect("proxy runtime admin listener should exist before start");
118        let app = self
119            .app
120            .take()
121            .expect("proxy runtime app should exist before start");
122        let admin_app = self
123            .admin_app
124            .take()
125            .expect("proxy runtime admin app should exist before start");
126        let shutdown_rx = self.shutdown_rx.clone();
127        let server_handle =
128            spawn_proxy_runtime_servers(listener, admin_listener, app, admin_app, shutdown_rx);
129
130        RunningProxyRuntime {
131            service_name: self.service_name,
132            host: self.host,
133            port: self.port,
134            admin_addr: self.admin_addr,
135            config: self.config,
136            proxy: self.proxy,
137            state: self.state,
138            shutdown_tx: self.shutdown_tx,
139            server_handle,
140        }
141    }
142}
143
144impl RunningProxyRuntime {
145    pub async fn wait(self) -> Result<()> {
146        self.server_handle
147            .await
148            .map_err(|error| anyhow::anyhow!("server task join error: {error}"))?
149    }
150}
151
152pub async fn build_proxy_runtime(
153    service_kind: ServiceKind,
154    host: IpAddr,
155    port: u16,
156) -> Result<ProxyRuntime> {
157    let service_name = service_name_for_kind(service_kind);
158    let loaded = load_or_bootstrap_for_service_with_v4_source(service_kind).await?;
159    build_proxy_runtime_from_loaded(service_name, host, port, loaded).await
160}
161
162pub async fn build_proxy_runtime_from_loaded(
163    service_name: &'static str,
164    host: IpAddr,
165    port: u16,
166    loaded: LoadedProxyConfig,
167) -> Result<ProxyRuntime> {
168    build_proxy_runtime_from_loaded_with_options(
169        service_name,
170        host,
171        port,
172        ProxyRuntimeOptions::for_proxy_port(port),
173        loaded,
174    )
175    .await
176}
177
178pub async fn build_proxy_runtime_from_loaded_with_admin_addr(
179    service_name: &'static str,
180    host: IpAddr,
181    port: u16,
182    admin_addr: SocketAddr,
183    loaded: LoadedProxyConfig,
184) -> Result<ProxyRuntime> {
185    build_proxy_runtime_from_loaded_with_options(
186        service_name,
187        host,
188        port,
189        ProxyRuntimeOptions::for_proxy_port(port).with_admin_addr(admin_addr),
190        loaded,
191    )
192    .await
193}
194
195pub async fn build_proxy_runtime_from_loaded_with_options(
196    service_name: &'static str,
197    host: IpAddr,
198    port: u16,
199    options: ProxyRuntimeOptions,
200    loaded: LoadedProxyConfig,
201) -> Result<ProxyRuntime> {
202    let addr: SocketAddr = SocketAddr::from((host, port));
203    let listener = tokio::net::TcpListener::bind(addr).await?;
204    let admin_listener = tokio::net::TcpListener::bind(options.admin_addr).await?;
205    build_proxy_runtime_from_bound_listeners_with_options(
206        service_name,
207        host,
208        port,
209        options,
210        loaded,
211        listener,
212        admin_listener,
213    )
214    .await
215}
216
217pub async fn build_proxy_runtime_from_bound_listeners(
218    service_name: &'static str,
219    host: IpAddr,
220    port: u16,
221    loaded: LoadedProxyConfig,
222    listener: tokio::net::TcpListener,
223    admin_listener: tokio::net::TcpListener,
224) -> Result<ProxyRuntime> {
225    build_proxy_runtime_from_bound_listeners_with_options(
226        service_name,
227        host,
228        port,
229        ProxyRuntimeOptions::for_proxy_port(port),
230        loaded,
231        listener,
232        admin_listener,
233    )
234    .await
235}
236
237pub async fn build_proxy_runtime_from_bound_listeners_with_admin_addr(
238    service_name: &'static str,
239    host: IpAddr,
240    port: u16,
241    admin_addr: SocketAddr,
242    loaded: LoadedProxyConfig,
243    listener: tokio::net::TcpListener,
244    admin_listener: tokio::net::TcpListener,
245) -> Result<ProxyRuntime> {
246    build_proxy_runtime_from_bound_listeners_with_options(
247        service_name,
248        host,
249        port,
250        ProxyRuntimeOptions::for_proxy_port(port).with_admin_addr(admin_addr),
251        loaded,
252        listener,
253        admin_listener,
254    )
255    .await
256}
257
258pub async fn build_proxy_runtime_from_bound_listeners_with_options(
259    service_name: &'static str,
260    host: IpAddr,
261    port: u16,
262    options: ProxyRuntimeOptions,
263    loaded: LoadedProxyConfig,
264    listener: tokio::net::TcpListener,
265    admin_listener: tokio::net::TcpListener,
266) -> Result<ProxyRuntime> {
267    let cfg = loaded.runtime;
268    validate_service_has_upstream(service_name, &cfg)?;
269    let v4_source = loaded.v4.map(Arc::new);
270
271    let warnings = model_routing_warnings(&cfg, service_name);
272    if !warnings.is_empty() {
273        tracing::warn!("======== Model routing config warnings ========");
274        for warning in warnings {
275            tracing::warn!("{}", warning);
276        }
277        tracing::warn!("==============================================");
278    }
279
280    let client = Client::builder()
281        .connect_timeout(std::time::Duration::from_secs(10))
282        .tcp_keepalive(std::time::Duration::from_secs(30))
283        .pool_idle_timeout(std::time::Duration::from_secs(30))
284        .build()?;
285
286    let lb_states = Arc::new(Mutex::new(HashMap::<String, LbState>::new()));
287    let cfg = Arc::new(cfg);
288    let (shutdown_tx, shutdown_rx) = watch::channel(false);
289
290    let proxy = ProxyService::new_with_v4_source_and_shutdown(
291        client,
292        cfg.clone(),
293        v4_source,
294        service_name,
295        lb_states,
296        Some(shutdown_tx.clone()),
297    )
298    .with_host_local_session_history_mode(options.host_local_session_history_mode);
299    let state = proxy.state_handle();
300    let app = proxy_only_router_with_admin_base_url(
301        proxy.clone(),
302        options.advertised_admin_base_url.clone(),
303    );
304    let admin_app = admin_listener_router(proxy.clone());
305
306    Ok(ProxyRuntime {
307        service_name,
308        host,
309        port,
310        admin_addr: options.admin_addr,
311        config: cfg,
312        proxy,
313        state,
314        shutdown_tx,
315        shutdown_rx,
316        listener: Some(listener),
317        admin_listener: Some(admin_listener),
318        app: Some(app),
319        admin_app: Some(admin_app),
320    })
321}
322
323fn admin_discovery_base_url(
324    admin_addr: SocketAddr,
325    advertised_admin_base_url: Option<&str>,
326) -> Option<String> {
327    if let Some(url) = advertised_admin_base_url
328        .map(str::trim)
329        .filter(|url| !url.is_empty())
330    {
331        return Some(url.trim_end_matches('/').to_string());
332    }
333    if admin_addr.ip().is_unspecified() {
334        None
335    } else {
336        Some(format!("http://{admin_addr}"))
337    }
338}
339
340pub fn service_name_for_kind(service_kind: ServiceKind) -> &'static str {
341    match service_kind {
342        ServiceKind::Codex => "codex",
343        ServiceKind::Claude => "claude",
344    }
345}
346
347pub fn service_kind_for_name(service_name: &str) -> ServiceKind {
348    match service_name {
349        "claude" => ServiceKind::Claude,
350        _ => ServiceKind::Codex,
351    }
352}
353
354pub fn validate_service_has_upstream(service_name: &str, cfg: &ProxyConfig) -> Result<()> {
355    match service_name {
356        "claude" => {
357            if !cfg.claude.has_stations() || cfg.claude.active_station().is_none() {
358                anyhow::bail!(
359                    "未找到任何可用的 Claude 上游配置,请先确保 ~/.claude/settings.json 配置完整,\
360或在 ~/.codex-helper/config.toml(或 config.json)的 `claude` 段下手动添加上游配置"
361                );
362            }
363        }
364        _ => {
365            if !cfg.codex.has_stations() || cfg.codex.active_station().is_none() {
366                anyhow::bail!(
367                    "未找到任何可用的 Codex 上游配置,请先确保 ~/.codex/config.toml 与 ~/.codex/auth.json 配置完整,或手动编辑 ~/.codex-helper/config.toml(或 config.json)添加配置"
368                );
369            }
370        }
371    }
372    Ok(())
373}
374
375fn spawn_proxy_runtime_servers(
376    listener: tokio::net::TcpListener,
377    admin_listener: tokio::net::TcpListener,
378    app: Router,
379    admin_app: Router,
380    shutdown_rx: watch::Receiver<bool>,
381) -> JoinHandle<Result<()>> {
382    let proxy_server_shutdown = {
383        let mut rx = shutdown_rx.clone();
384        async move {
385            let _ = rx.changed().await;
386        }
387    };
388    let admin_server_shutdown = {
389        let mut rx = shutdown_rx;
390        async move {
391            let _ = rx.changed().await;
392        }
393    };
394
395    tokio::spawn(async move {
396        tokio::try_join!(
397            axum::serve(
398                listener,
399                app.into_make_service_with_connect_info::<SocketAddr>(),
400            )
401            .with_graceful_shutdown(proxy_server_shutdown),
402            axum::serve(
403                admin_listener,
404                admin_app.into_make_service_with_connect_info::<SocketAddr>(),
405            )
406            .with_graceful_shutdown(admin_server_shutdown),
407        )?;
408        Ok(())
409    })
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn admin_discovery_url_is_not_advertised_for_unspecified_bind() {
418        let admin_addr = SocketAddr::from(([0, 0, 0, 0], 4211));
419
420        assert_eq!(admin_discovery_base_url(admin_addr, None), None);
421    }
422
423    #[test]
424    fn admin_discovery_url_uses_explicit_bind_address() {
425        let admin_addr = SocketAddr::from(([192, 168, 1, 10], 4211));
426
427        assert_eq!(
428            admin_discovery_base_url(admin_addr, None),
429            Some("http://192.168.1.10:4211".to_string())
430        );
431    }
432
433    #[test]
434    fn admin_discovery_url_uses_advertised_url_when_provided() {
435        let admin_addr = SocketAddr::from(([0, 0, 0, 0], 4211));
436
437        assert_eq!(
438            admin_discovery_base_url(admin_addr, Some("http://nas.local:4211/")),
439            Some("http://nas.local:4211".to_string())
440        );
441    }
442}