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