1use reqwest::RequestBuilder;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AuthScheme {
12 Bearer,
14 XiApiKey,
16}
17
18pub trait ProviderHttpPolicy: Send + Sync {
24 fn provider_id(&self) -> &'static str;
26
27 fn official_origins(&self) -> &'static [&'static str];
29
30 fn default_base_url(&self) -> &'static str;
32
33 fn auth_scheme(&self) -> AuthScheme;
35
36 fn apply_auth(&self, req: RequestBuilder, api_key: &str) -> RequestBuilder {
38 match self.auth_scheme() {
39 AuthScheme::Bearer => req.header("Authorization", format!("Bearer {api_key}")),
40 AuthScheme::XiApiKey => req.header("xi-api-key", api_key.to_string()),
41 }
42 }
43
44 fn apply_extra_headers(&self, req: RequestBuilder) -> RequestBuilder {
46 req
47 }
48
49 fn allows_path(&self, path: &str) -> bool;
53
54 fn is_official_origin(&self, scheme: &str, host: &str) -> bool {
56 if !scheme.eq_ignore_ascii_case("https") {
57 return false;
58 }
59 let host = host.to_ascii_lowercase();
60 self.official_origins().iter().any(|origin| {
61 url::Url::parse(origin)
62 .ok()
63 .and_then(|u| {
64 let o_host = u.host_str()?.to_ascii_lowercase();
65 Some(u.scheme() == "https" && o_host == host)
66 })
67 .unwrap_or(false)
68 })
69 }
70
71 fn allows_custom_credentialed_endpoint(&self) -> bool {
74 true
75 }
76
77 fn custom_endpoint_hint(&self) -> String {
79 let id = self.provider_id();
80 let default = self.default_base_url();
81 format!(
82 "set {id}.allow_custom_endpoint = true only for trusted compatible APIs, \
83 or use {default}"
84 )
85 }
86}
87
88pub fn normalize_request_path(path: &str) -> Option<&str> {
92 let path = path.trim().trim_start_matches('/');
93 if path.is_empty() {
94 return None;
95 }
96 if path.contains("..") || path.contains('\\') || path.contains('\0') {
97 return None;
98 }
99 if path.contains("://") {
101 return None;
102 }
103 Some(path)
104}
105
106fn path_allowed(path: &str, exact: &[&str], prefixes: &[&str]) -> bool {
107 let Some(path) = normalize_request_path(path) else {
108 return false;
109 };
110 if exact.contains(&path) {
111 return true;
112 }
113 prefixes.iter().any(|p| {
114 path == *p
115 || path
116 .strip_prefix(p)
117 .is_some_and(|rest| rest.starts_with('/'))
118 })
119}
120
121pub const OPENROUTER_ORIGIN: &str = "https://openrouter.ai";
125pub const OPENROUTER_DEFAULT_BASE: &str = "https://openrouter.ai/api/v1";
126
127pub const OPENROUTER_APP_REFERER: &str = "https://github.com/joe-broadhead/aurum";
131pub const OPENROUTER_APP_TITLE: &str = "Aurum";
133pub const OPENROUTER_APP_CATEGORIES: &str = "audio-gen,cli-agent";
135
136#[derive(Debug, Default, Clone, Copy)]
138pub struct OpenRouterHttpPolicy;
139
140impl ProviderHttpPolicy for OpenRouterHttpPolicy {
141 fn provider_id(&self) -> &'static str {
142 "openrouter"
143 }
144
145 fn official_origins(&self) -> &'static [&'static str] {
146 &[OPENROUTER_ORIGIN]
147 }
148
149 fn default_base_url(&self) -> &'static str {
150 OPENROUTER_DEFAULT_BASE
151 }
152
153 fn auth_scheme(&self) -> AuthScheme {
154 AuthScheme::Bearer
155 }
156
157 fn apply_extra_headers(&self, req: RequestBuilder) -> RequestBuilder {
158 req.header("HTTP-Referer", OPENROUTER_APP_REFERER)
161 .header("X-OpenRouter-Title", OPENROUTER_APP_TITLE)
162 .header("X-Title", OPENROUTER_APP_TITLE)
164 .header("X-OpenRouter-Categories", OPENROUTER_APP_CATEGORIES)
165 }
166
167 fn allows_path(&self, path: &str) -> bool {
168 path_allowed(
170 path,
171 &["chat/completions", "audio/transcriptions", "audio/speech"],
172 &[],
173 )
174 }
175
176 fn custom_endpoint_hint(&self) -> String {
177 format!(
178 "set openrouter.allow_custom_endpoint = true only for trusted compatible APIs, \
179 or use {OPENROUTER_ORIGIN}."
180 )
181 }
182}
183
184pub const OPENAI_ORIGIN: &str = "https://api.openai.com";
187pub const OPENAI_DEFAULT_BASE: &str = "https://api.openai.com/v1";
188
189#[derive(Debug, Default, Clone, Copy)]
191pub struct OpenAiHttpPolicy;
192
193impl ProviderHttpPolicy for OpenAiHttpPolicy {
194 fn provider_id(&self) -> &'static str {
195 "openai"
196 }
197
198 fn official_origins(&self) -> &'static [&'static str] {
199 &[OPENAI_ORIGIN]
200 }
201
202 fn default_base_url(&self) -> &'static str {
203 OPENAI_DEFAULT_BASE
204 }
205
206 fn auth_scheme(&self) -> AuthScheme {
207 AuthScheme::Bearer
208 }
209
210 fn allows_path(&self, path: &str) -> bool {
211 path_allowed(
212 path,
213 &[
214 "chat/completions",
215 "audio/transcriptions",
216 "audio/translations",
217 "audio/speech",
218 ],
219 &[],
220 )
221 }
222}
223
224pub const ELEVENLABS_ORIGIN: &str = "https://api.elevenlabs.io";
227pub const ELEVENLABS_DEFAULT_BASE: &str = "https://api.elevenlabs.io";
228
229#[derive(Debug, Default, Clone, Copy)]
231pub struct ElevenLabsHttpPolicy;
232
233impl ProviderHttpPolicy for ElevenLabsHttpPolicy {
234 fn provider_id(&self) -> &'static str {
235 "elevenlabs"
236 }
237
238 fn official_origins(&self) -> &'static [&'static str] {
239 &[ELEVENLABS_ORIGIN]
240 }
241
242 fn default_base_url(&self) -> &'static str {
243 ELEVENLABS_DEFAULT_BASE
244 }
245
246 fn auth_scheme(&self) -> AuthScheme {
247 AuthScheme::XiApiKey
248 }
249
250 fn allows_path(&self, path: &str) -> bool {
251 path_allowed(
253 path,
254 &["v1/speech-to-text"],
255 &["v1/text-to-speech", "v1/speech-to-speech"],
256 )
257 }
258}
259
260pub const XAI_ORIGIN: &str = "https://api.x.ai";
263pub const XAI_DEFAULT_BASE: &str = "https://api.x.ai/v1";
264
265#[derive(Debug, Default, Clone, Copy)]
267pub struct XaiHttpPolicy;
268
269impl ProviderHttpPolicy for XaiHttpPolicy {
270 fn provider_id(&self) -> &'static str {
271 "xai"
272 }
273
274 fn official_origins(&self) -> &'static [&'static str] {
275 &[XAI_ORIGIN]
276 }
277
278 fn default_base_url(&self) -> &'static str {
279 XAI_DEFAULT_BASE
280 }
281
282 fn auth_scheme(&self) -> AuthScheme {
283 AuthScheme::Bearer
284 }
285
286 fn allows_path(&self, path: &str) -> bool {
287 path_allowed(path, &["stt", "tts"], &[])
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use reqwest::Client;
297
298 #[test]
299 fn openrouter_extra_headers_present() {
300 let p = OpenRouterHttpPolicy;
301 let req = p
302 .apply_extra_headers(Client::new().get("https://openrouter.ai/api/v1/x"))
303 .build()
304 .unwrap();
305 let headers = req.headers();
306 let names: Vec<_> = headers
307 .keys()
308 .map(|k| k.as_str().to_ascii_lowercase())
309 .collect();
310 assert!(names.iter().any(|n| n == "http-referer" || n == "referer"));
311 assert!(names.iter().any(|n| n == "x-openrouter-title"));
312 assert!(names.iter().any(|n| n == "x-title"));
313 assert!(names.iter().any(|n| n == "x-openrouter-categories"));
314
315 let referer = headers
316 .get("HTTP-Referer")
317 .or_else(|| headers.get("Referer"))
318 .and_then(|v| v.to_str().ok())
319 .unwrap();
320 assert_eq!(referer, OPENROUTER_APP_REFERER);
321 assert_eq!(
322 headers.get("X-OpenRouter-Title").unwrap().to_str().unwrap(),
323 OPENROUTER_APP_TITLE
324 );
325 assert_eq!(
326 headers.get("X-Title").unwrap().to_str().unwrap(),
327 OPENROUTER_APP_TITLE
328 );
329 assert_eq!(
330 headers
331 .get("X-OpenRouter-Categories")
332 .unwrap()
333 .to_str()
334 .unwrap(),
335 OPENROUTER_APP_CATEGORIES
336 );
337 }
338
339 #[test]
340 fn openrouter_headers_do_not_cross_to_openai() {
341 let p = OpenAiHttpPolicy;
342 let req = p
343 .apply_extra_headers(Client::new().get("https://api.openai.com/v1/x"))
344 .build()
345 .unwrap();
346 for name in req.headers().keys() {
347 let n = name.as_str().to_ascii_lowercase();
348 assert_ne!(n, "http-referer");
349 assert_ne!(n, "x-title");
350 assert_ne!(n, "x-openrouter-title");
351 assert_ne!(n, "x-openrouter-categories");
352 assert_ne!(n, "xi-api-key");
353 }
354 }
355
356 #[test]
357 fn openrouter_headers_do_not_cross_to_elevenlabs_or_xai() {
358 for req in [
359 ElevenLabsHttpPolicy
360 .apply_extra_headers(Client::new().get("https://api.elevenlabs.io/x"))
361 .build()
362 .unwrap(),
363 XaiHttpPolicy
364 .apply_extra_headers(Client::new().get("https://api.x.ai/v1/x"))
365 .build()
366 .unwrap(),
367 ] {
368 for name in req.headers().keys() {
369 let n = name.as_str().to_ascii_lowercase();
370 assert_ne!(n, "http-referer");
371 assert_ne!(n, "x-title");
372 assert_ne!(n, "x-openrouter-title");
373 assert_ne!(n, "x-openrouter-categories");
374 }
375 }
376 }
377
378 #[test]
379 fn auth_schemes_do_not_cross() {
380 let key = "test-key-canary";
381 let bearer = OpenAiHttpPolicy
382 .apply_auth(Client::new().get("https://api.openai.com/v1/x"), key)
383 .build()
384 .unwrap();
385 assert!(bearer.headers().get("Authorization").is_some());
386 assert!(bearer.headers().get("xi-api-key").is_none());
387
388 let xi = ElevenLabsHttpPolicy
389 .apply_auth(Client::new().get("https://api.elevenlabs.io/x"), key)
390 .build()
391 .unwrap();
392 assert!(xi.headers().get("xi-api-key").is_some());
393 assert!(xi.headers().get("Authorization").is_none());
394 assert!(!xi
395 .headers()
396 .get("xi-api-key")
397 .unwrap()
398 .to_str()
399 .unwrap()
400 .contains("Bearer"));
401 }
402
403 #[test]
404 fn official_origins_are_provider_scoped() {
405 assert!(OpenRouterHttpPolicy.is_official_origin("https", "openrouter.ai"));
406 assert!(!OpenRouterHttpPolicy.is_official_origin("https", "api.openai.com"));
407 assert!(!OpenRouterHttpPolicy.is_official_origin("http", "openrouter.ai"));
408
409 assert!(OpenAiHttpPolicy.is_official_origin("https", "api.openai.com"));
410 assert!(!OpenAiHttpPolicy.is_official_origin("https", "openrouter.ai"));
411
412 assert!(ElevenLabsHttpPolicy.is_official_origin("https", "api.elevenlabs.io"));
413 assert!(XaiHttpPolicy.is_official_origin("https", "api.x.ai"));
414 assert!(!XaiHttpPolicy.is_official_origin("https", "api.openai.com"));
415 }
416
417 #[test]
418 fn path_allowlists_reject_traversal_and_foreign() {
419 assert!(OpenRouterHttpPolicy.allows_path("chat/completions"));
420 assert!(OpenRouterHttpPolicy.allows_path("/audio/transcriptions"));
421 assert!(OpenRouterHttpPolicy.allows_path("audio/speech"));
422 assert!(!OpenRouterHttpPolicy.allows_path("../chat/completions"));
423 assert!(!OpenRouterHttpPolicy.allows_path("https://evil.example/x"));
424 assert!(!OpenRouterHttpPolicy.allows_path("models"));
425
426 assert!(OpenAiHttpPolicy.allows_path("audio/speech"));
427 assert!(!OpenAiHttpPolicy.allows_path("v1/text-to-speech/abc"));
428
429 assert!(ElevenLabsHttpPolicy.allows_path("v1/text-to-speech/voiceid"));
430 assert!(!ElevenLabsHttpPolicy.allows_path("chat/completions"));
431
432 assert!(XaiHttpPolicy.allows_path("stt"));
433 assert!(XaiHttpPolicy.allows_path("tts"));
434 assert!(!XaiHttpPolicy.allows_path("audio/transcriptions"));
435 assert!(!XaiHttpPolicy.allows_path("audio/speech"));
436 assert!(!XaiHttpPolicy.allows_path("chat/completions"));
437 assert!(!XaiHttpPolicy.allows_path("realtime"));
438 }
439
440 #[test]
441 fn provider_ids_stable() {
442 assert_eq!(OpenRouterHttpPolicy.provider_id(), "openrouter");
443 assert_eq!(OpenAiHttpPolicy.provider_id(), "openai");
444 assert_eq!(ElevenLabsHttpPolicy.provider_id(), "elevenlabs");
445 assert_eq!(XaiHttpPolicy.provider_id(), "xai");
446 }
447}