codex_helper_core/
control_plane_client.rs1use std::time::Duration;
2
3use anyhow::{Context, Result, anyhow};
4use reqwest::{Client, Url};
5use serde::Deserialize;
6use thiserror::Error;
7
8use crate::dashboard_core::{
9 ApiV1Capabilities, ApiV1OperatorSummary, ApiV1Snapshot, ProviderOption,
10};
11use crate::fleet::FleetSnapshot;
12use crate::proxy::{ADMIN_TOKEN_ENV_VAR, ADMIN_TOKEN_HEADER, RuntimeStatusResponse};
13use crate::request_chain::{RequestChainExport, RequestChainSelector};
14
15const ADMIN_DISCOVERY_PATH: &str = "/.well-known/codex-helper-admin";
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ControlPlaneEndpoint {
19 pub admin_base_url: String,
20 pub admin_token_env: Option<String>,
21}
22
23#[derive(Debug, Clone)]
24pub struct ControlPlaneClient {
25 endpoint: ControlPlaneEndpoint,
26 client: Client,
27}
28
29#[derive(Debug, Error)]
30pub enum ControlPlaneError {
31 #[error("admin API not reachable at {base_url}: {source}")]
32 Transport {
33 base_url: String,
34 #[source]
35 source: reqwest::Error,
36 },
37 #[error("admin API returned {status}: {body}")]
38 HttpStatus { status: u16, body: String },
39 #[error("admin API response is not valid JSON: {source}")]
40 Decode {
41 #[source]
42 source: reqwest::Error,
43 },
44}
45
46#[derive(Debug, Clone, Deserialize)]
47pub struct AdminDiscoveryDocument {
48 pub api_version: u32,
49 pub service_name: String,
50 pub admin_base_url: String,
51}
52
53impl ControlPlaneEndpoint {
54 pub fn new(
55 admin_base_url: impl Into<String>,
56 admin_token_env: Option<impl Into<String>>,
57 ) -> Result<Self> {
58 let admin_base_url = normalize_base_url(&admin_base_url.into())
59 .ok_or_else(|| anyhow!("admin base URL is required"))?;
60 Ok(Self {
61 admin_base_url,
62 admin_token_env: admin_token_env.map(Into::into),
63 })
64 }
65}
66
67impl ControlPlaneClient {
68 pub fn new(endpoint: ControlPlaneEndpoint) -> Result<Self> {
69 let client = Client::builder()
70 .timeout(Duration::from_millis(1200))
71 .build()
72 .context("failed to build control-plane client")?;
73 Ok(Self { endpoint, client })
74 }
75
76 pub fn endpoint(&self) -> &ControlPlaneEndpoint {
77 &self.endpoint
78 }
79
80 pub async fn discover_admin_base_url(proxy_base_url: &str) -> Result<AdminDiscoveryDocument> {
81 let proxy_base_url = normalize_base_url(proxy_base_url)
82 .ok_or_else(|| anyhow!("proxy base URL is required"))?;
83 let url = format!("{proxy_base_url}{ADMIN_DISCOVERY_PATH}");
84 let response = Client::builder()
85 .timeout(Duration::from_millis(1200))
86 .build()
87 .context("failed to build discovery client")?
88 .get(url)
89 .send()
90 .await
91 .context("admin discovery request failed")?;
92 let status = response.status();
93 if !status.is_success() {
94 let body = response.text().await.unwrap_or_default();
95 anyhow::bail!("admin discovery returned {status}: {}", body.trim());
96 }
97 response
98 .json::<AdminDiscoveryDocument>()
99 .await
100 .context("admin discovery response is not valid JSON")
101 }
102
103 pub async fn fetch_json<T>(&self, path: &str) -> Result<T>
104 where
105 T: serde::de::DeserializeOwned,
106 {
107 self.fetch_json_classified(path).await.map_err(Into::into)
108 }
109
110 pub async fn fetch_json_classified<T>(&self, path: &str) -> Result<T, ControlPlaneError>
111 where
112 T: serde::de::DeserializeOwned,
113 {
114 let url = format!("{}{}", self.endpoint.admin_base_url, path);
115 let mut request = self.client.get(url);
116 if let Some(token) = self.admin_token() {
117 request = request.header(ADMIN_TOKEN_HEADER, token);
118 }
119
120 let response = request
121 .send()
122 .await
123 .map_err(|source| ControlPlaneError::Transport {
124 base_url: self.endpoint.admin_base_url.clone(),
125 source,
126 })?;
127 let status = response.status();
128 if !status.is_success() {
129 let body = response.text().await.unwrap_or_default();
130 return Err(ControlPlaneError::HttpStatus {
131 status: status.as_u16(),
132 body: body.trim().to_string(),
133 });
134 }
135 response
136 .json::<T>()
137 .await
138 .map_err(|source| ControlPlaneError::Decode { source })
139 }
140
141 pub async fn runtime_status(&self) -> Result<RuntimeStatusResponse> {
142 self.fetch_json("/__codex_helper/api/v1/runtime/status")
143 .await
144 }
145
146 pub async fn capabilities(&self) -> Result<ApiV1Capabilities> {
147 self.fetch_json("/__codex_helper/api/v1/capabilities").await
148 }
149
150 pub async fn operator_summary(&self) -> Result<ApiV1OperatorSummary> {
151 self.fetch_json("/__codex_helper/api/v1/operator/summary")
152 .await
153 }
154
155 pub async fn snapshot(&self, recent_limit: usize, stats_days: usize) -> Result<ApiV1Snapshot> {
156 self.fetch_json(&format!(
157 "/__codex_helper/api/v1/snapshot?recent_limit={recent_limit}&stats_days={stats_days}"
158 ))
159 .await
160 }
161
162 pub async fn fleet_snapshot(&self) -> Result<FleetSnapshot, ControlPlaneError> {
163 self.fetch_json_classified("/__codex_helper/api/v1/fleet/snapshot")
164 .await
165 }
166
167 pub async fn request_chain(
168 &self,
169 selector: RequestChainSelector,
170 limit: usize,
171 ) -> Result<RequestChainExport> {
172 self.fetch_json(&request_chain_path(selector, limit)).await
173 }
174
175 pub async fn providers(&self) -> Result<Vec<ProviderOption>> {
176 self.fetch_json("/__codex_helper/api/v1/providers").await
177 }
178
179 fn admin_token(&self) -> Option<String> {
180 let env_name = self
181 .endpoint
182 .admin_token_env
183 .as_deref()
184 .unwrap_or(ADMIN_TOKEN_ENV_VAR);
185 std::env::var(env_name)
186 .ok()
187 .map(|value| value.trim().to_string())
188 .filter(|value| !value.is_empty())
189 }
190}
191
192pub fn normalize_base_url(value: &str) -> Option<String> {
193 let value = value.trim().trim_end_matches('/');
194 if value.is_empty() {
195 return None;
196 }
197 let url = Url::parse(value).ok()?;
198 match url.scheme() {
199 "http" | "https" => Some(value.to_string()),
200 _ => None,
201 }
202}
203
204fn request_chain_path(selector: RequestChainSelector, limit: usize) -> String {
205 let selector = selector.normalized();
206 let mut url =
207 Url::parse("http://localhost/__codex_helper/api/v1/request-ledger/chain").expect("url");
208 {
209 let mut pairs = url.query_pairs_mut();
210 pairs.append_pair("limit", &limit.to_string());
211 if let Some(trace_id) = selector.trace_id {
212 pairs.append_pair("trace_id", &trace_id);
213 }
214 if let Some(request_id) = selector.request_id {
215 pairs.append_pair("request_id", &request_id.to_string());
216 }
217 if let Some(session_id) = selector.session_id {
218 pairs.append_pair("session", &session_id);
219 }
220 }
221 match url.query() {
222 Some(query) => format!("{}?{query}", url.path()),
223 None => url.path().to_string(),
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn control_plane_endpoint_normalizes_admin_base_url() {
233 let endpoint = ControlPlaneEndpoint::new(" http://nas.local:4211/ ", Some("TOKEN_ENV"))
234 .expect("endpoint");
235
236 assert_eq!(endpoint.admin_base_url, "http://nas.local:4211");
237 assert_eq!(endpoint.admin_token_env.as_deref(), Some("TOKEN_ENV"));
238 }
239
240 #[test]
241 fn control_plane_endpoint_rejects_non_http_url() {
242 let err = ControlPlaneEndpoint::new("file:///tmp/socket", None::<String>)
243 .expect_err("non-http admin url should fail");
244
245 assert!(err.to_string().contains("admin base URL"));
246 }
247
248 #[test]
249 fn request_chain_path_encodes_selector_query_values() {
250 let path = request_chain_path(
251 RequestChainSelector {
252 trace_id: Some("trace/with space".to_string()),
253 request_id: Some(42),
254 session_id: Some("session a".to_string()),
255 },
256 20,
257 );
258
259 assert_eq!(
260 path,
261 "/__codex_helper/api/v1/request-ledger/chain?limit=20&trace_id=trace%2Fwith+space&request_id=42&session=session+a"
262 );
263 }
264}