Skip to main content

codex_helper_core/
relay_target.rs

1use anyhow::{Result, anyhow};
2
3use crate::codex_integration::{CodexCompactionStrategy, CodexPatchMode, CodexSwitchOptions};
4use crate::config::{ProxyConfig, RelayTargetConfig, ServiceKind};
5use crate::control_plane_client::normalize_base_url;
6use crate::proxy::{
7    admin_base_url_from_proxy_base_url, local_admin_base_url_for_proxy_port, local_proxy_base_url,
8};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct ResolvedRelayTarget {
12    pub name: String,
13    pub service: ServiceKind,
14    pub proxy_url: String,
15    pub admin_url: Option<String>,
16    pub admin_token_env: Option<String>,
17    pub client_preset: CodexPatchMode,
18    pub client_options: CodexSwitchOptions,
19    pub built_in_local: bool,
20}
21
22pub fn default_proxy_port_for_service_kind(service: ServiceKind) -> u16 {
23    match service {
24        ServiceKind::Codex => 3211,
25        ServiceKind::Claude => 3210,
26    }
27}
28
29impl ResolvedRelayTarget {
30    pub fn is_local(&self) -> bool {
31        self.built_in_local
32    }
33}
34
35pub fn resolve_relay_target(cfg: &ProxyConfig, name: &str) -> Result<ResolvedRelayTarget> {
36    let name = normalize_relay_target_name(name)?;
37    if name == "local" {
38        let service = cfg.default_service.unwrap_or(ServiceKind::Codex);
39        let port = default_proxy_port_for_service_kind(service);
40        return Ok(ResolvedRelayTarget {
41            name,
42            service,
43            proxy_url: local_proxy_base_url(port),
44            admin_url: Some(local_admin_base_url_for_proxy_port(port)),
45            admin_token_env: None,
46            client_preset: cfg
47                .relay_targets
48                .get("local")
49                .and_then(|target| target.client_preset)
50                .unwrap_or(CodexPatchMode::Default),
51            client_options: CodexSwitchOptions {
52                responses_websocket: cfg
53                    .relay_targets
54                    .get("local")
55                    .and_then(|target| target.responses_websocket)
56                    .unwrap_or(false),
57                compaction: CodexCompactionStrategy::Auto,
58            },
59            built_in_local: true,
60        });
61    }
62
63    let target = cfg
64        .relay_targets
65        .get(&name)
66        .ok_or_else(|| anyhow!("relay target '{name}' is not configured"))?;
67    resolve_configured_relay_target(&name, target)
68}
69
70pub fn relay_target_names(cfg: &ProxyConfig) -> Vec<String> {
71    let mut names = vec!["local".to_string()];
72    for name in cfg.relay_targets.keys() {
73        if name != "local" {
74            names.push(name.clone());
75        }
76    }
77    names
78}
79
80pub fn relay_target_config_from_args(
81    service: Option<ServiceKind>,
82    proxy_url: String,
83    admin_url: Option<String>,
84    admin_token_env: Option<String>,
85    client_preset: Option<CodexPatchMode>,
86    responses_websocket: Option<bool>,
87) -> Result<RelayTargetConfig> {
88    let proxy_url = normalize_base_url(&proxy_url)
89        .ok_or_else(|| anyhow!("relay proxy URL must start with http:// or https://"))?;
90    let admin_url = match admin_url {
91        Some(url) => Some(
92            normalize_base_url(&url)
93                .ok_or_else(|| anyhow!("relay admin URL must start with http:// or https://"))?,
94        ),
95        None => admin_base_url_from_proxy_base_url(&proxy_url),
96    };
97    Ok(RelayTargetConfig {
98        service,
99        proxy_url,
100        admin_url,
101        admin_token_env: admin_token_env
102            .map(|value| value.trim().to_string())
103            .filter(|value| !value.is_empty()),
104        client_preset,
105        responses_websocket,
106    })
107}
108
109fn resolve_configured_relay_target(
110    name: &str,
111    target: &RelayTargetConfig,
112) -> Result<ResolvedRelayTarget> {
113    let proxy_url = normalize_base_url(&target.proxy_url)
114        .ok_or_else(|| anyhow!("relay target '{name}' has an invalid proxy_url"))?;
115    let admin_url = match target.admin_url.as_deref() {
116        Some(url) => Some(
117            normalize_base_url(url)
118                .ok_or_else(|| anyhow!("relay target '{name}' has an invalid admin_url"))?,
119        ),
120        None => admin_base_url_from_proxy_base_url(&proxy_url),
121    };
122    Ok(ResolvedRelayTarget {
123        name: name.to_string(),
124        service: target.service.unwrap_or(ServiceKind::Codex),
125        proxy_url,
126        admin_url,
127        admin_token_env: target.admin_token_env.clone(),
128        client_preset: target.client_preset.unwrap_or(CodexPatchMode::Default),
129        client_options: CodexSwitchOptions {
130            responses_websocket: target.responses_websocket.unwrap_or(false),
131            compaction: CodexCompactionStrategy::Auto,
132        },
133        built_in_local: false,
134    })
135}
136
137pub fn normalize_relay_target_name(name: &str) -> Result<String> {
138    let name = name.trim();
139    if name.is_empty() {
140        return Err(anyhow!("relay target name is required"));
141    }
142    if name
143        .chars()
144        .any(|ch| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'))
145    {
146        return Err(anyhow!(
147            "relay target name may contain only ASCII letters, numbers, '-' and '_'"
148        ));
149    }
150    Ok(name.to_ascii_lowercase())
151}
152
153#[cfg(test)]
154mod tests {
155    use std::collections::BTreeMap;
156
157    use super::*;
158
159    #[test]
160    fn relay_target_resolves_builtin_local_from_default_service() {
161        let cfg = ProxyConfig {
162            default_service: Some(ServiceKind::Codex),
163            ..ProxyConfig::default()
164        };
165
166        let target = resolve_relay_target(&cfg, "local").expect("local target");
167
168        assert_eq!(target.proxy_url, "http://127.0.0.1:3211");
169        assert_eq!(target.admin_url.as_deref(), Some("http://127.0.0.1:4211"));
170        assert!(target.is_local());
171    }
172
173    #[test]
174    fn relay_target_resolves_named_remote() {
175        let mut targets = BTreeMap::new();
176        targets.insert(
177            "nas".to_string(),
178            RelayTargetConfig {
179                service: Some(ServiceKind::Codex),
180                proxy_url: "http://nas.local:3211/".to_string(),
181                admin_url: Some("http://nas.local:4211/".to_string()),
182                admin_token_env: Some("NAS_TOKEN".to_string()),
183                client_preset: Some(CodexPatchMode::ChatGptBridge),
184                responses_websocket: Some(true),
185            },
186        );
187        let cfg = ProxyConfig {
188            relay_targets: targets,
189            ..ProxyConfig::default()
190        };
191
192        let target = resolve_relay_target(&cfg, "nas").expect("nas target");
193
194        assert_eq!(target.proxy_url, "http://nas.local:3211");
195        assert_eq!(target.admin_url.as_deref(), Some("http://nas.local:4211"));
196        assert_eq!(target.admin_token_env.as_deref(), Some("NAS_TOKEN"));
197        assert_eq!(target.client_preset, CodexPatchMode::ChatGptBridge);
198        assert!(target.client_options.responses_websocket);
199        assert!(!target.is_local());
200    }
201
202    #[test]
203    fn relay_target_name_rejects_whitespace() {
204        let err = normalize_relay_target_name("nas home")
205            .expect_err("target names with spaces should be rejected");
206
207        assert!(err.to_string().contains("may contain only ASCII"));
208    }
209}