1use super::policy::{normalize_request_path, OpenRouterHttpPolicy, ProviderHttpPolicy};
8use crate::error::{ProviderError, Result, UserError};
9use reqwest::{Client, Method, RequestBuilder, StatusCode};
10use std::sync::Arc;
11use std::time::Duration;
12use url::Url;
13
14#[derive(Debug, Clone)]
16pub struct RemoteEndpoint {
17 pub base_url: String,
18 pub is_official: bool,
20 pub credentials_allowed: bool,
22 pub provider_id: String,
24}
25
26#[derive(Debug, Clone)]
31pub struct RemotePolicy {
32 pub connect_timeout: Duration,
34 pub total_timeout: Duration,
36 pub use_system_proxy: bool,
38 pub allow_custom_credentialed_endpoint: bool,
41 pub allow_loopback_http: bool,
43}
44
45impl Default for RemotePolicy {
46 fn default() -> Self {
47 Self {
48 connect_timeout: Duration::from_secs(30),
49 total_timeout: Duration::from_secs(600),
50 use_system_proxy: false,
51 allow_custom_credentialed_endpoint: false,
52 allow_loopback_http: false,
53 }
54 }
55}
56
57pub fn validate_endpoint(
59 raw: &str,
60 remote: &RemotePolicy,
61 provider: &dyn ProviderHttpPolicy,
62) -> Result<RemoteEndpoint> {
63 let trimmed = raw.trim().trim_end_matches('/');
64 if trimmed.is_empty() {
65 return Err(UserError::InvalidConfig {
66 reason: "remote base URL is empty".into(),
67 }
68 .into());
69 }
70 let url = Url::parse(trimmed).map_err(|e| UserError::InvalidConfig {
71 reason: format!("invalid remote base URL: {e}"),
72 })?;
73
74 if url.username() != "" || url.password().is_some() {
75 return Err(UserError::InvalidConfig {
76 reason: "remote base URL must not embed userinfo/credentials".into(),
77 }
78 .into());
79 }
80
81 let scheme = url.scheme();
82 let host = url.host_str().unwrap_or("").to_ascii_lowercase();
83 let is_loopback = matches!(host.as_str(), "127.0.0.1" | "localhost" | "::1");
84 let is_official = provider.is_official_origin(scheme, &host);
85
86 match scheme {
87 "https" => {}
88 "http" if remote.allow_loopback_http && is_loopback => {}
89 "http" => {
90 return Err(UserError::InvalidConfig {
91 reason: format!(
92 "HTTP remote endpoints are only allowed for loopback test mode (got {trimmed})"
93 ),
94 }
95 .into());
96 }
97 other => {
98 return Err(UserError::InvalidConfig {
99 reason: format!("unsupported URL scheme '{other}' (use https)"),
100 }
101 .into());
102 }
103 }
104
105 let custom_ok = remote.allow_custom_credentialed_endpoint
106 && scheme == "https"
107 && provider.allows_custom_credentialed_endpoint();
108 let credentials_allowed =
109 is_official || (is_loopback && remote.allow_loopback_http) || custom_ok;
110 if !credentials_allowed {
111 return Err(UserError::InvalidConfig {
112 reason: format!(
113 "credentialed remote endpoint '{trimmed}' is not an official {} origin.\n \
114 Hint: {}",
115 provider.provider_id(),
116 provider.custom_endpoint_hint()
117 ),
118 }
119 .into());
120 }
121
122 Ok(RemoteEndpoint {
123 base_url: trimmed.to_string(),
124 is_official,
125 credentials_allowed,
126 provider_id: provider.provider_id().to_string(),
127 })
128}
129
130#[derive(Clone)]
135pub struct HardenedHttpClient {
136 http: Client,
137 endpoint: RemoteEndpoint,
138 remote_policy: RemotePolicy,
139 provider: Arc<dyn ProviderHttpPolicy>,
140}
141
142impl std::fmt::Debug for HardenedHttpClient {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.debug_struct("HardenedHttpClient")
145 .field("base_url", &self.endpoint.base_url)
146 .field("is_official", &self.endpoint.is_official)
147 .field("provider_id", &self.endpoint.provider_id)
148 .finish()
149 }
150}
151
152impl HardenedHttpClient {
153 pub fn build(
155 base_url: Option<&str>,
156 remote: RemotePolicy,
157 provider: impl ProviderHttpPolicy + 'static,
158 ) -> Result<Self> {
159 Self::build_arc(base_url, remote, Arc::new(provider))
160 }
161
162 pub fn build_arc(
164 base_url: Option<&str>,
165 remote: RemotePolicy,
166 provider: Arc<dyn ProviderHttpPolicy>,
167 ) -> Result<Self> {
168 let raw = base_url
169 .map(|s| s.trim())
170 .filter(|s| !s.is_empty())
171 .unwrap_or_else(|| provider.default_base_url());
172 let endpoint = validate_endpoint(raw, &remote, provider.as_ref())?;
173
174 let mut builder = Client::builder()
175 .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
176 .connect_timeout(remote.connect_timeout)
177 .timeout(remote.total_timeout)
178 .redirect(reqwest::redirect::Policy::none());
180
181 if !remote.use_system_proxy {
182 builder = builder.no_proxy();
183 }
184
185 let http = builder.build().map_err(|e| ProviderError::Network {
186 provider: provider.provider_id().into(),
187 reason: super::status::public_network_reason(&e),
188 })?;
189
190 Ok(Self {
191 http,
192 endpoint,
193 remote_policy: remote,
194 provider,
195 })
196 }
197
198 pub fn openrouter(base_url: Option<&str>, remote: RemotePolicy) -> Result<Self> {
200 Self::build(base_url, remote, OpenRouterHttpPolicy)
201 }
202
203 pub fn endpoint(&self) -> &RemoteEndpoint {
204 &self.endpoint
205 }
206
207 pub fn base_url(&self) -> &str {
208 &self.endpoint.base_url
209 }
210
211 pub fn policy(&self) -> &RemotePolicy {
212 &self.remote_policy
213 }
214
215 pub fn provider_id(&self) -> &str {
216 self.provider.provider_id()
217 }
218
219 pub fn request(&self, method: Method, path: &str, api_key: &str) -> Result<RequestBuilder> {
221 if !self.endpoint.credentials_allowed {
222 return Err(UserError::InvalidConfig {
223 reason: "credentials are not allowed for this endpoint under current policy".into(),
224 }
225 .into());
226 }
227
228 let Some(path) = normalize_request_path(path) else {
229 return Err(UserError::InvalidConfig {
230 reason: format!(
231 "remote path is empty or contains disallowed segments ({})",
232 self.provider.provider_id()
233 ),
234 }
235 .into());
236 };
237
238 if !self.provider.allows_path(path) {
239 return Err(UserError::InvalidConfig {
240 reason: format!(
241 "path '{path}' is not allowed for provider {}",
242 self.provider.provider_id()
243 ),
244 }
245 .into());
246 }
247
248 let url = format!("{}/{}", self.endpoint.base_url, path);
249 if let Ok(u) = Url::parse(&url) {
251 let base = Url::parse(&self.endpoint.base_url).ok();
252 if let Some(b) = base {
253 if u.origin() != b.origin() {
254 return Err(UserError::InvalidConfig {
255 reason: "request URL origin diverged from validated endpoint".into(),
256 }
257 .into());
258 }
259 }
260 }
261
262 let mut req = self.http.request(method, url);
263 req = self.provider.apply_auth(req, api_key);
264 req = self.provider.apply_extra_headers(req);
265 Ok(req.header(
266 "X-Request-Id",
267 format!(
268 "aurum-{}",
269 std::time::SystemTime::now()
270 .duration_since(std::time::UNIX_EPOCH)
271 .map(|d| d.as_millis())
272 .unwrap_or(0)
273 ),
274 ))
275 }
276
277 pub fn get_raw(&self) -> &Client {
278 &self.http
279 }
280}
281
282pub fn map_http_status(provider: &str, status: StatusCode, body: &str) -> Result<()> {
287 use super::status::public_http_reason;
288 let code = status.as_u16();
289 let reason = public_http_reason(code, body);
291 match code {
292 200..=299 => Ok(()),
293 401 | 403 => Err(ProviderError::Auth {
294 provider: provider.into(),
295 reason,
296 }
297 .into()),
298 429 => Err(ProviderError::RateLimited {
299 provider: provider.into(),
300 }
301 .into()),
302 402 => Err(ProviderError::QuotaExceeded {
303 provider: provider.into(),
304 reason,
305 }
306 .into()),
307 _ => Err(ProviderError::Remote {
308 provider: provider.into(),
309 reason,
310 }
311 .into()),
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::remote::policy::{
319 ElevenLabsHttpPolicy, OpenAiHttpPolicy, OpenRouterHttpPolicy, XaiHttpPolicy,
320 };
321
322 #[test]
323 fn official_endpoint_ok() {
324 let ep = validate_endpoint(
325 "https://openrouter.ai/api/v1",
326 &RemotePolicy::default(),
327 &OpenRouterHttpPolicy,
328 )
329 .unwrap();
330 assert!(ep.is_official);
331 assert!(ep.credentials_allowed);
332 assert_eq!(ep.provider_id, "openrouter");
333 }
334
335 #[test]
336 fn foreign_host_rejected_by_default() {
337 let err = validate_endpoint(
338 "https://evil.example/api",
339 &RemotePolicy::default(),
340 &OpenRouterHttpPolicy,
341 )
342 .unwrap_err();
343 assert_eq!(err.exit_code(), 2);
344 assert!(
345 err.to_string().contains("allow_custom_endpoint")
346 || err.to_string().contains("official")
347 );
348 }
349
350 #[test]
351 fn openrouter_origin_not_official_for_openai_policy() {
352 let err = validate_endpoint(
353 "https://openrouter.ai/api/v1",
354 &RemotePolicy::default(),
355 &OpenAiHttpPolicy,
356 )
357 .unwrap_err();
358 assert!(err.to_string().contains("openai") || err.to_string().contains("official"));
359 }
360
361 #[test]
362 fn openai_official_ok() {
363 let ep = validate_endpoint(
364 "https://api.openai.com/v1",
365 &RemotePolicy::default(),
366 &OpenAiHttpPolicy,
367 )
368 .unwrap();
369 assert!(ep.is_official);
370 }
371
372 #[test]
373 fn elevenlabs_and_xai_official_ok() {
374 let el = validate_endpoint(
375 "https://api.elevenlabs.io",
376 &RemotePolicy::default(),
377 &ElevenLabsHttpPolicy,
378 )
379 .unwrap();
380 assert!(el.is_official);
381 let xai = validate_endpoint(
382 "https://api.x.ai/v1",
383 &RemotePolicy::default(),
384 &XaiHttpPolicy,
385 )
386 .unwrap();
387 assert!(xai.is_official);
388 }
389
390 #[test]
391 fn map_http_status_never_echoes_body_payload() {
392 let body = r#"{"error":{"message":"sk-or-v1-canary-should-not-appear","code":401}}"#;
393 let err =
394 map_http_status("openrouter", reqwest::StatusCode::UNAUTHORIZED, body).unwrap_err();
395 let msg = err.to_string();
396 assert!(!msg.contains("canary"));
397 assert!(!msg.contains("sk-or-v1"));
398 assert!(msg.contains("401") || msg.to_ascii_lowercase().contains("auth"));
399 }
400
401 #[test]
402 fn map_http_status_drops_unknown_string_provider_code() {
403 let body =
404 r#"{"error":{"message":"transcript: hello world secret","code":"no_endpoints"}}"#;
405 let err = map_http_status("openrouter", reqwest::StatusCode::NOT_FOUND, body).unwrap_err();
406 let msg = err.to_string();
407 assert!(!msg.contains("transcript"));
408 assert!(!msg.contains("hello world"));
409 assert!(!msg.contains("no_endpoints"));
410 assert!(msg.contains("404"));
411 }
412
413 #[test]
414 fn map_http_status_drops_credential_shaped_provider_code() {
415 let body =
416 r#"{"error":{"message":"x","code":"sk-or-v1-TESTCANARY-JOE1920-DO-NOT-USE-001"}}"#;
417 let err =
418 map_http_status("openrouter", reqwest::StatusCode::UNAUTHORIZED, body).unwrap_err();
419 let msg = err.to_string();
420 assert!(!msg.contains("TESTCANARY"));
421 assert!(!msg.contains("sk-or-v1"));
422 }
423
424 #[test]
425 fn custom_allowed_with_opt_in() {
426 let policy = RemotePolicy {
427 allow_custom_credentialed_endpoint: true,
428 ..Default::default()
429 };
430 let ep = validate_endpoint(
431 "https://compatible.example/v1",
432 &policy,
433 &OpenRouterHttpPolicy,
434 )
435 .unwrap();
436 assert!(!ep.is_official);
437 assert!(ep.credentials_allowed);
438 }
439
440 #[test]
441 fn rejects_userinfo() {
442 let err = validate_endpoint(
443 "https://user:pass@openrouter.ai/api/v1",
444 &RemotePolicy::default(),
445 &OpenRouterHttpPolicy,
446 )
447 .unwrap_err();
448 assert!(err.to_string().contains("userinfo") || err.to_string().contains("credential"));
449 }
450
451 #[test]
452 fn loopback_http_for_tests() {
453 let policy = RemotePolicy {
454 allow_loopback_http: true,
455 ..Default::default()
456 };
457 let ep = validate_endpoint("http://127.0.0.1:9", &policy, &OpenRouterHttpPolicy).unwrap();
458 assert!(ep.credentials_allowed);
459 }
460
461 #[test]
462 fn http_non_loopback_rejected() {
463 assert!(validate_endpoint(
464 "http://evil.example",
465 &RemotePolicy::default(),
466 &OpenRouterHttpPolicy
467 )
468 .is_err());
469 }
470
471 #[test]
472 fn request_applies_openrouter_headers_only() {
473 let client = HardenedHttpClient::openrouter(None, RemotePolicy::default()).unwrap();
474 let req = client
475 .request(Method::POST, "chat/completions", "sk-test")
476 .unwrap()
477 .build()
478 .unwrap();
479 assert!(req.headers().get("Authorization").is_some());
480 assert!(
481 req.headers().get("HTTP-Referer").is_some() || req.headers().get("Referer").is_some()
482 );
483 assert_eq!(
484 req.headers()
485 .get("X-OpenRouter-Title")
486 .unwrap()
487 .to_str()
488 .unwrap(),
489 "Aurum"
490 );
491 assert!(req.headers().get("X-Title").is_none());
492 assert!(req.headers().get("X-OpenRouter-Categories").is_some());
493 assert!(req.headers().get("X-Request-Id").is_some());
494 assert!(req.headers().get("xi-api-key").is_none());
495 }
496
497 #[test]
498 fn request_openai_has_no_openrouter_headers() {
499 let client =
500 HardenedHttpClient::build(None, RemotePolicy::default(), OpenAiHttpPolicy).unwrap();
501 let req = client
502 .request(Method::POST, "audio/transcriptions", "sk-test")
503 .unwrap()
504 .build()
505 .unwrap();
506 assert!(req.headers().get("Authorization").is_some());
507 assert!(req.headers().get("HTTP-Referer").is_none());
508 assert!(req.headers().get("X-Title").is_none());
509 assert!(req.headers().get("X-OpenRouter-Title").is_none());
510 assert!(req.headers().get("X-OpenRouter-Categories").is_none());
511 assert!(req.headers().get("xi-api-key").is_none());
512 }
513
514 #[test]
515 fn request_elevenlabs_uses_xi_api_key() {
516 let client =
517 HardenedHttpClient::build(None, RemotePolicy::default(), ElevenLabsHttpPolicy).unwrap();
518 let req = client
519 .request(Method::POST, "v1/text-to-speech/voice1", "el-key")
520 .unwrap()
521 .build()
522 .unwrap();
523 assert_eq!(
524 req.headers().get("xi-api-key").unwrap().to_str().unwrap(),
525 "el-key"
526 );
527 assert!(req.headers().get("Authorization").is_none());
528 assert!(req.headers().get("HTTP-Referer").is_none());
529 assert!(req.headers().get("X-Title").is_none());
530 assert!(req.headers().get("X-OpenRouter-Title").is_none());
531 assert!(req.headers().get("X-OpenRouter-Categories").is_none());
532 }
533
534 #[test]
535 fn request_rejects_disallowed_path() {
536 let client = HardenedHttpClient::openrouter(None, RemotePolicy::default()).unwrap();
537 let err = client
538 .request(Method::GET, "models", "sk-test")
539 .unwrap_err();
540 assert!(err.to_string().contains("not allowed") || err.to_string().contains("path"));
541 }
542
543 #[test]
544 fn default_base_url_per_provider() {
545 let or = HardenedHttpClient::openrouter(None, RemotePolicy::default()).unwrap();
546 assert!(or.base_url().contains("openrouter.ai"));
547 let oa =
548 HardenedHttpClient::build(None, RemotePolicy::default(), OpenAiHttpPolicy).unwrap();
549 assert!(oa.base_url().contains("api.openai.com"));
550 }
551}