codex-helper-core 0.17.0

Core library for codex-helper.
Documentation
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::{Arc, Mutex};

use anyhow::Result;
use axum::Router;
use reqwest::Client;
use tokio::sync::watch;
use tokio::task::JoinHandle;

use crate::config::{
    LoadedProxyConfig, ProxyConfig, ServiceKind, load_or_bootstrap_for_service_with_v4_source,
    model_routing_warnings,
};
use crate::lb::LbState;
use crate::proxy::{
    ProxyService, admin_listener_router, admin_loopback_addr_for_proxy_port, local_proxy_base_url,
    proxy_only_router_with_admin_base_url,
};
use crate::state::ProxyState;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyLifetimeMode {
    Ephemeral,
    Resident,
}

impl ProxyLifetimeMode {
    pub fn is_resident(self) -> bool {
        matches!(self, Self::Resident)
    }
}

pub struct ProxyRuntime {
    pub service_name: &'static str,
    pub host: IpAddr,
    pub port: u16,
    pub admin_addr: SocketAddr,
    pub config: Arc<ProxyConfig>,
    pub proxy: ProxyService,
    pub state: Arc<ProxyState>,
    pub shutdown_tx: watch::Sender<bool>,
    shutdown_rx: watch::Receiver<bool>,
    listener: Option<tokio::net::TcpListener>,
    admin_listener: Option<tokio::net::TcpListener>,
    app: Option<Router>,
    admin_app: Option<Router>,
}

pub struct RunningProxyRuntime {
    pub service_name: &'static str,
    pub host: IpAddr,
    pub port: u16,
    pub admin_addr: SocketAddr,
    pub config: Arc<ProxyConfig>,
    pub proxy: ProxyService,
    pub state: Arc<ProxyState>,
    pub shutdown_tx: watch::Sender<bool>,
    pub server_handle: JoinHandle<Result<()>>,
}

impl ProxyRuntime {
    pub fn shutdown_receiver(&self) -> watch::Receiver<bool> {
        self.shutdown_rx.clone()
    }

    pub fn start(mut self) -> RunningProxyRuntime {
        let listener = self
            .listener
            .take()
            .expect("proxy runtime listener should exist before start");
        let admin_listener = self
            .admin_listener
            .take()
            .expect("proxy runtime admin listener should exist before start");
        let app = self
            .app
            .take()
            .expect("proxy runtime app should exist before start");
        let admin_app = self
            .admin_app
            .take()
            .expect("proxy runtime admin app should exist before start");
        let shutdown_rx = self.shutdown_rx.clone();
        let server_handle =
            spawn_proxy_runtime_servers(listener, admin_listener, app, admin_app, shutdown_rx);

        RunningProxyRuntime {
            service_name: self.service_name,
            host: self.host,
            port: self.port,
            admin_addr: self.admin_addr,
            config: self.config,
            proxy: self.proxy,
            state: self.state,
            shutdown_tx: self.shutdown_tx,
            server_handle,
        }
    }
}

impl RunningProxyRuntime {
    pub async fn wait(self) -> Result<()> {
        self.server_handle
            .await
            .map_err(|error| anyhow::anyhow!("server task join error: {error}"))?
    }
}

pub async fn build_proxy_runtime(
    service_kind: ServiceKind,
    host: IpAddr,
    port: u16,
) -> Result<ProxyRuntime> {
    let service_name = service_name_for_kind(service_kind);
    let loaded = load_or_bootstrap_for_service_with_v4_source(service_kind).await?;
    build_proxy_runtime_from_loaded(service_name, host, port, loaded).await
}

pub async fn build_proxy_runtime_from_loaded(
    service_name: &'static str,
    host: IpAddr,
    port: u16,
    loaded: LoadedProxyConfig,
) -> Result<ProxyRuntime> {
    let addr: SocketAddr = SocketAddr::from((host, port));
    let admin_addr = admin_loopback_addr_for_proxy_port(port);
    let listener = tokio::net::TcpListener::bind(addr).await?;
    let admin_listener = tokio::net::TcpListener::bind(admin_addr).await?;
    build_proxy_runtime_from_bound_listeners(
        service_name,
        host,
        port,
        loaded,
        listener,
        admin_listener,
    )
    .await
}

pub async fn build_proxy_runtime_from_bound_listeners(
    service_name: &'static str,
    host: IpAddr,
    port: u16,
    loaded: LoadedProxyConfig,
    listener: tokio::net::TcpListener,
    admin_listener: tokio::net::TcpListener,
) -> Result<ProxyRuntime> {
    let cfg = loaded.runtime;
    validate_service_has_upstream(service_name, &cfg)?;
    let v4_source = loaded.v4.map(Arc::new);

    let warnings = model_routing_warnings(&cfg, service_name);
    if !warnings.is_empty() {
        tracing::warn!("======== Model routing config warnings ========");
        for warning in warnings {
            tracing::warn!("{}", warning);
        }
        tracing::warn!("==============================================");
    }

    let client = Client::builder()
        .connect_timeout(std::time::Duration::from_secs(10))
        .tcp_keepalive(std::time::Duration::from_secs(30))
        .pool_idle_timeout(std::time::Duration::from_secs(30))
        .build()?;

    let lb_states = Arc::new(Mutex::new(HashMap::<String, LbState>::new()));
    let admin_addr = admin_loopback_addr_for_proxy_port(port);
    let cfg = Arc::new(cfg);
    let (shutdown_tx, shutdown_rx) = watch::channel(false);

    let proxy = ProxyService::new_with_v4_source_and_shutdown(
        client,
        cfg.clone(),
        v4_source,
        service_name,
        lb_states,
        Some(shutdown_tx.clone()),
    );
    let state = proxy.state_handle();
    let app = proxy_only_router_with_admin_base_url(
        proxy.clone(),
        Some(local_proxy_base_url(admin_addr.port())),
    );
    let admin_app = admin_listener_router(proxy.clone());

    Ok(ProxyRuntime {
        service_name,
        host,
        port,
        admin_addr,
        config: cfg,
        proxy,
        state,
        shutdown_tx,
        shutdown_rx,
        listener: Some(listener),
        admin_listener: Some(admin_listener),
        app: Some(app),
        admin_app: Some(admin_app),
    })
}

pub fn service_name_for_kind(service_kind: ServiceKind) -> &'static str {
    match service_kind {
        ServiceKind::Codex => "codex",
        ServiceKind::Claude => "claude",
    }
}

pub fn service_kind_for_name(service_name: &str) -> ServiceKind {
    match service_name {
        "claude" => ServiceKind::Claude,
        _ => ServiceKind::Codex,
    }
}

pub fn validate_service_has_upstream(service_name: &str, cfg: &ProxyConfig) -> Result<()> {
    match service_name {
        "claude" => {
            if !cfg.claude.has_stations() || cfg.claude.active_station().is_none() {
                anyhow::bail!(
                    "未找到任何可用的 Claude 上游配置,请先确保 ~/.claude/settings.json 配置完整,\
或在 ~/.codex-helper/config.toml(或 config.json)的 `claude` 段下手动添加上游配置"
                );
            }
        }
        _ => {
            if !cfg.codex.has_stations() || cfg.codex.active_station().is_none() {
                anyhow::bail!(
                    "未找到任何可用的 Codex 上游配置,请先确保 ~/.codex/config.toml 与 ~/.codex/auth.json 配置完整,或手动编辑 ~/.codex-helper/config.toml(或 config.json)添加配置"
                );
            }
        }
    }
    Ok(())
}

fn spawn_proxy_runtime_servers(
    listener: tokio::net::TcpListener,
    admin_listener: tokio::net::TcpListener,
    app: Router,
    admin_app: Router,
    shutdown_rx: watch::Receiver<bool>,
) -> JoinHandle<Result<()>> {
    let proxy_server_shutdown = {
        let mut rx = shutdown_rx.clone();
        async move {
            let _ = rx.changed().await;
        }
    };
    let admin_server_shutdown = {
        let mut rx = shutdown_rx;
        async move {
            let _ = rx.changed().await;
        }
    };

    tokio::spawn(async move {
        tokio::try_join!(
            axum::serve(
                listener,
                app.into_make_service_with_connect_info::<SocketAddr>(),
            )
            .with_graceful_shutdown(proxy_server_shutdown),
            axum::serve(
                admin_listener,
                admin_app.into_make_service_with_connect_info::<SocketAddr>(),
            )
            .with_graceful_shutdown(admin_server_shutdown),
        )?;
        Ok(())
    })
}