1use crate::error::{ProviderError, Result, UserError};
4use reqwest::{Client, Method, RequestBuilder, StatusCode};
5use std::time::Duration;
6use url::Url;
7
8pub const DEFAULT_OPENROUTER_ORIGIN: &str = "https://openrouter.ai";
10
11#[derive(Debug, Clone)]
13pub struct RemoteEndpoint {
14 pub base_url: String,
15 pub is_official: bool,
17 pub credentials_allowed: bool,
19}
20
21#[derive(Debug, Clone)]
23pub struct RemotePolicy {
24 pub connect_timeout: Duration,
26 pub total_timeout: Duration,
28 pub use_system_proxy: bool,
30 pub allow_custom_credentialed_endpoint: bool,
33 pub allow_loopback_http: bool,
35}
36
37impl Default for RemotePolicy {
38 fn default() -> Self {
39 Self {
40 connect_timeout: Duration::from_secs(30),
41 total_timeout: Duration::from_secs(600),
42 use_system_proxy: false,
43 allow_custom_credentialed_endpoint: false,
44 allow_loopback_http: false,
45 }
46 }
47}
48
49pub fn validate_endpoint(raw: &str, policy: &RemotePolicy) -> Result<RemoteEndpoint> {
51 let trimmed = raw.trim().trim_end_matches('/');
52 if trimmed.is_empty() {
53 return Err(UserError::InvalidConfig {
54 reason: "remote base URL is empty".into(),
55 }
56 .into());
57 }
58 let url = Url::parse(trimmed).map_err(|e| UserError::InvalidConfig {
59 reason: format!("invalid remote base URL: {e}"),
60 })?;
61
62 if url.username() != "" || url.password().is_some() {
63 return Err(UserError::InvalidConfig {
64 reason: "remote base URL must not embed userinfo/credentials".into(),
65 }
66 .into());
67 }
68
69 let scheme = url.scheme();
70 let host = url.host_str().unwrap_or("").to_ascii_lowercase();
71 let is_loopback = matches!(host.as_str(), "127.0.0.1" | "localhost" | "::1");
72 let is_official = host == "openrouter.ai" && scheme == "https";
73
74 match scheme {
75 "https" => {}
76 "http" if policy.allow_loopback_http && is_loopback => {}
77 "http" => {
78 return Err(UserError::InvalidConfig {
79 reason: format!(
80 "HTTP remote endpoints are only allowed for loopback test mode (got {trimmed})"
81 ),
82 }
83 .into());
84 }
85 other => {
86 return Err(UserError::InvalidConfig {
87 reason: format!("unsupported URL scheme '{other}' (use https)"),
88 }
89 .into());
90 }
91 }
92
93 let credentials_allowed = is_official
94 || (is_loopback && policy.allow_loopback_http)
95 || (policy.allow_custom_credentialed_endpoint && scheme == "https");
96 if !credentials_allowed {
97 return Err(UserError::InvalidConfig {
98 reason: format!(
99 "credentialed remote endpoint '{trimmed}' is not the official OpenRouter origin.\n \
100 Hint: set openrouter.allow_custom_endpoint = true only for trusted compatible APIs, \
101 or use {DEFAULT_OPENROUTER_ORIGIN}."
102 ),
103 }
104 .into());
105 }
106
107 Ok(RemoteEndpoint {
108 base_url: trimmed.to_string(),
109 is_official,
110 credentials_allowed,
111 })
112}
113
114#[derive(Clone)]
116pub struct HardenedHttpClient {
117 http: Client,
118 endpoint: RemoteEndpoint,
119 policy: RemotePolicy,
120}
121
122impl std::fmt::Debug for HardenedHttpClient {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("HardenedHttpClient")
125 .field("base_url", &self.endpoint.base_url)
126 .field("is_official", &self.endpoint.is_official)
127 .finish()
128 }
129}
130
131impl HardenedHttpClient {
132 pub fn build(base_url: Option<&str>, policy: RemotePolicy) -> Result<Self> {
133 let raw = base_url
134 .map(|s| s.trim())
135 .filter(|s| !s.is_empty())
136 .unwrap_or("https://openrouter.ai/api/v1");
137 let endpoint = validate_endpoint(raw, &policy)?;
138
139 let mut builder = Client::builder()
140 .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
141 .connect_timeout(policy.connect_timeout)
142 .timeout(policy.total_timeout)
143 .redirect(reqwest::redirect::Policy::none());
145
146 if !policy.use_system_proxy {
147 builder = builder.no_proxy();
148 }
149
150 let http = builder.build().map_err(|e| ProviderError::Network {
151 provider: "remote".into(),
152 reason: e.to_string(),
153 })?;
154
155 Ok(Self {
156 http,
157 endpoint,
158 policy,
159 })
160 }
161
162 pub fn endpoint(&self) -> &RemoteEndpoint {
163 &self.endpoint
164 }
165
166 pub fn base_url(&self) -> &str {
167 &self.endpoint.base_url
168 }
169
170 pub fn policy(&self) -> &RemotePolicy {
171 &self.policy
172 }
173
174 pub fn request(&self, method: Method, path: &str, api_key: &str) -> Result<RequestBuilder> {
176 if !self.endpoint.credentials_allowed {
177 return Err(UserError::InvalidConfig {
178 reason: "credentials are not allowed for this endpoint under current policy".into(),
179 }
180 .into());
181 }
182 let path = path.trim_start_matches('/');
183 let url = format!("{}/{}", self.endpoint.base_url, path);
184 if let Ok(u) = Url::parse(&url) {
186 let base = Url::parse(&self.endpoint.base_url).ok();
187 if let Some(b) = base {
188 if u.origin() != b.origin() {
189 return Err(UserError::InvalidConfig {
190 reason: "request URL origin diverged from validated endpoint".into(),
191 }
192 .into());
193 }
194 }
195 }
196 Ok(self
197 .http
198 .request(method, url)
199 .header("Authorization", format!("Bearer {api_key}"))
200 .header("HTTP-Referer", "https://github.com/joe-broadhead/aurum")
201 .header("X-Title", "Aurum")
202 .header(
203 "X-Request-Id",
204 format!(
205 "aurum-{}",
206 std::time::SystemTime::now()
207 .duration_since(std::time::UNIX_EPOCH)
208 .map(|d| d.as_millis())
209 .unwrap_or(0)
210 ),
211 ))
212 }
213
214 pub fn get_raw(&self) -> &Client {
215 &self.http
216 }
217}
218
219pub fn map_http_status(provider: &str, status: StatusCode, body: &str) -> Result<()> {
224 use super::status::public_http_reason;
225 let code = status.as_u16();
226 let reason = public_http_reason(code, body);
228 match code {
229 200..=299 => Ok(()),
230 401 | 403 => Err(ProviderError::Auth {
231 provider: provider.into(),
232 reason,
233 }
234 .into()),
235 429 => Err(ProviderError::RateLimited {
236 provider: provider.into(),
237 }
238 .into()),
239 402 => Err(ProviderError::QuotaExceeded {
240 provider: provider.into(),
241 reason,
242 }
243 .into()),
244 _ => Err(ProviderError::Remote {
245 provider: provider.into(),
246 reason,
247 }
248 .into()),
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn official_endpoint_ok() {
258 let ep =
259 validate_endpoint("https://openrouter.ai/api/v1", &RemotePolicy::default()).unwrap();
260 assert!(ep.is_official);
261 assert!(ep.credentials_allowed);
262 }
263
264 #[test]
265 fn foreign_host_rejected_by_default() {
266 let err =
267 validate_endpoint("https://evil.example/api", &RemotePolicy::default()).unwrap_err();
268 assert_eq!(err.exit_code(), 2);
269 assert!(
270 err.to_string().contains("allow_custom_endpoint")
271 || err.to_string().contains("official")
272 );
273 }
274
275 #[test]
276 fn map_http_status_never_echoes_body_payload() {
277 let body = r#"{"error":{"message":"sk-or-v1-canary-should-not-appear","code":401}}"#;
278 let err =
279 map_http_status("openrouter", reqwest::StatusCode::UNAUTHORIZED, body).unwrap_err();
280 let msg = err.to_string();
281 assert!(!msg.contains("canary"));
282 assert!(!msg.contains("sk-or-v1"));
283 assert!(msg.contains("401") || msg.to_ascii_lowercase().contains("auth"));
284 }
285
286 #[test]
287 fn map_http_status_drops_unknown_string_provider_code() {
288 let body =
289 r#"{"error":{"message":"transcript: hello world secret","code":"no_endpoints"}}"#;
290 let err = map_http_status("openrouter", reqwest::StatusCode::NOT_FOUND, body).unwrap_err();
291 let msg = err.to_string();
292 assert!(!msg.contains("transcript"));
293 assert!(!msg.contains("hello world"));
294 assert!(!msg.contains("no_endpoints"));
295 assert!(msg.contains("404"));
296 }
297
298 #[test]
299 fn map_http_status_drops_credential_shaped_provider_code() {
300 let body =
301 r#"{"error":{"message":"x","code":"sk-or-v1-TESTCANARY-JOE1920-DO-NOT-USE-001"}}"#;
302 let err =
303 map_http_status("openrouter", reqwest::StatusCode::UNAUTHORIZED, body).unwrap_err();
304 let msg = err.to_string();
305 assert!(!msg.contains("TESTCANARY"));
306 assert!(!msg.contains("sk-or-v1"));
307 }
308
309 #[test]
310 fn custom_allowed_with_opt_in() {
311 let policy = RemotePolicy {
312 allow_custom_credentialed_endpoint: true,
313 ..Default::default()
314 };
315 let ep = validate_endpoint("https://compatible.example/v1", &policy).unwrap();
316 assert!(!ep.is_official);
317 assert!(ep.credentials_allowed);
318 }
319
320 #[test]
321 fn rejects_userinfo() {
322 let err = validate_endpoint(
323 "https://user:pass@openrouter.ai/api/v1",
324 &RemotePolicy::default(),
325 )
326 .unwrap_err();
327 assert!(err.to_string().contains("userinfo") || err.to_string().contains("credential"));
328 }
329
330 #[test]
331 fn loopback_http_for_tests() {
332 let policy = RemotePolicy {
333 allow_loopback_http: true,
334 ..Default::default()
335 };
336 let ep = validate_endpoint("http://127.0.0.1:9", &policy).unwrap();
337 assert!(ep.credentials_allowed);
338 }
339
340 #[test]
341 fn http_non_loopback_rejected() {
342 assert!(validate_endpoint("http://evil.example", &RemotePolicy::default()).is_err());
343 }
344}