codex_helper_core/
relay_target.rs1use anyhow::{Result, anyhow};
2
3use crate::codex_integration::{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 },
58 built_in_local: true,
59 });
60 }
61
62 let target = cfg
63 .relay_targets
64 .get(&name)
65 .ok_or_else(|| anyhow!("relay target '{name}' is not configured"))?;
66 resolve_configured_relay_target(&name, target)
67}
68
69pub fn relay_target_names(cfg: &ProxyConfig) -> Vec<String> {
70 let mut names = vec!["local".to_string()];
71 for name in cfg.relay_targets.keys() {
72 if name != "local" {
73 names.push(name.clone());
74 }
75 }
76 names
77}
78
79pub fn relay_target_config_from_args(
80 service: Option<ServiceKind>,
81 proxy_url: String,
82 admin_url: Option<String>,
83 admin_token_env: Option<String>,
84 client_preset: Option<CodexPatchMode>,
85 responses_websocket: Option<bool>,
86) -> Result<RelayTargetConfig> {
87 let proxy_url = normalize_base_url(&proxy_url)
88 .ok_or_else(|| anyhow!("relay proxy URL must start with http:// or https://"))?;
89 let admin_url = match admin_url {
90 Some(url) => Some(
91 normalize_base_url(&url)
92 .ok_or_else(|| anyhow!("relay admin URL must start with http:// or https://"))?,
93 ),
94 None => admin_base_url_from_proxy_base_url(&proxy_url),
95 };
96 Ok(RelayTargetConfig {
97 service,
98 proxy_url,
99 admin_url,
100 admin_token_env: admin_token_env
101 .map(|value| value.trim().to_string())
102 .filter(|value| !value.is_empty()),
103 client_preset,
104 responses_websocket,
105 })
106}
107
108fn resolve_configured_relay_target(
109 name: &str,
110 target: &RelayTargetConfig,
111) -> Result<ResolvedRelayTarget> {
112 let proxy_url = normalize_base_url(&target.proxy_url)
113 .ok_or_else(|| anyhow!("relay target '{name}' has an invalid proxy_url"))?;
114 let admin_url = match target.admin_url.as_deref() {
115 Some(url) => Some(
116 normalize_base_url(url)
117 .ok_or_else(|| anyhow!("relay target '{name}' has an invalid admin_url"))?,
118 ),
119 None => admin_base_url_from_proxy_base_url(&proxy_url),
120 };
121 Ok(ResolvedRelayTarget {
122 name: name.to_string(),
123 service: target.service.unwrap_or(ServiceKind::Codex),
124 proxy_url,
125 admin_url,
126 admin_token_env: target.admin_token_env.clone(),
127 client_preset: target.client_preset.unwrap_or(CodexPatchMode::Default),
128 client_options: CodexSwitchOptions {
129 responses_websocket: target.responses_websocket.unwrap_or(false),
130 },
131 built_in_local: false,
132 })
133}
134
135pub fn normalize_relay_target_name(name: &str) -> Result<String> {
136 let name = name.trim();
137 if name.is_empty() {
138 return Err(anyhow!("relay target name is required"));
139 }
140 if name
141 .chars()
142 .any(|ch| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'))
143 {
144 return Err(anyhow!(
145 "relay target name may contain only ASCII letters, numbers, '-' and '_'"
146 ));
147 }
148 Ok(name.to_ascii_lowercase())
149}
150
151#[cfg(test)]
152mod tests {
153 use std::collections::BTreeMap;
154
155 use super::*;
156
157 #[test]
158 fn relay_target_resolves_builtin_local_from_default_service() {
159 let cfg = ProxyConfig {
160 default_service: Some(ServiceKind::Codex),
161 ..ProxyConfig::default()
162 };
163
164 let target = resolve_relay_target(&cfg, "local").expect("local target");
165
166 assert_eq!(target.proxy_url, "http://127.0.0.1:3211");
167 assert_eq!(target.admin_url.as_deref(), Some("http://127.0.0.1:4211"));
168 assert!(target.is_local());
169 }
170
171 #[test]
172 fn relay_target_resolves_named_remote() {
173 let mut targets = BTreeMap::new();
174 targets.insert(
175 "nas".to_string(),
176 RelayTargetConfig {
177 service: Some(ServiceKind::Codex),
178 proxy_url: "http://nas.local:3211/".to_string(),
179 admin_url: Some("http://nas.local:4211/".to_string()),
180 admin_token_env: Some("NAS_TOKEN".to_string()),
181 client_preset: Some(CodexPatchMode::ChatGptBridge),
182 responses_websocket: Some(true),
183 },
184 );
185 let cfg = ProxyConfig {
186 relay_targets: targets,
187 ..ProxyConfig::default()
188 };
189
190 let target = resolve_relay_target(&cfg, "nas").expect("nas target");
191
192 assert_eq!(target.proxy_url, "http://nas.local:3211");
193 assert_eq!(target.admin_url.as_deref(), Some("http://nas.local:4211"));
194 assert_eq!(target.admin_token_env.as_deref(), Some("NAS_TOKEN"));
195 assert_eq!(target.client_preset, CodexPatchMode::ChatGptBridge);
196 assert!(target.client_options.responses_websocket);
197 assert!(!target.is_local());
198 }
199
200 #[test]
201 fn relay_target_name_rejects_whitespace() {
202 let err = normalize_relay_target_name("nas home")
203 .expect_err("target names with spaces should be rejected");
204
205 assert!(err.to_string().contains("may contain only ASCII"));
206 }
207}