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-OpenRouter-Categories", OPENROUTER_APP_CATEGORIES)
163 }
164
165 fn allows_path(&self, path: &str) -> bool {
166 path_allowed(
168 path,
169 &["chat/completions", "audio/transcriptions", "audio/speech"],
170 &[],
171 )
172 }
173
174 fn custom_endpoint_hint(&self) -> String {
175 format!(
176 "set openrouter.allow_custom_endpoint = true only for trusted compatible APIs, \
177 or use {OPENROUTER_ORIGIN}."
178 )
179 }
180}
181
182pub const OPENAI_ORIGIN: &str = "https://api.openai.com";
185pub const OPENAI_DEFAULT_BASE: &str = "https://api.openai.com/v1";
186
187#[derive(Debug, Default, Clone, Copy)]
189pub struct OpenAiHttpPolicy;
190
191impl ProviderHttpPolicy for OpenAiHttpPolicy {
192 fn provider_id(&self) -> &'static str {
193 "openai"
194 }
195
196 fn official_origins(&self) -> &'static [&'static str] {
197 &[OPENAI_ORIGIN]
198 }
199
200 fn default_base_url(&self) -> &'static str {
201 OPENAI_DEFAULT_BASE
202 }
203
204 fn auth_scheme(&self) -> AuthScheme {
205 AuthScheme::Bearer
206 }
207
208 fn allows_path(&self, path: &str) -> bool {
209 path_allowed(
210 path,
211 &[
212 "chat/completions",
213 "audio/transcriptions",
214 "audio/translations",
215 "audio/speech",
216 ],
217 &[],
218 )
219 }
220}
221
222pub const ELEVENLABS_ORIGIN: &str = "https://api.elevenlabs.io";
225pub const ELEVENLABS_DEFAULT_BASE: &str = "https://api.elevenlabs.io";
226
227#[derive(Debug, Default, Clone, Copy)]
229pub struct ElevenLabsHttpPolicy;
230
231impl ProviderHttpPolicy for ElevenLabsHttpPolicy {
232 fn provider_id(&self) -> &'static str {
233 "elevenlabs"
234 }
235
236 fn official_origins(&self) -> &'static [&'static str] {
237 &[ELEVENLABS_ORIGIN]
238 }
239
240 fn default_base_url(&self) -> &'static str {
241 ELEVENLABS_DEFAULT_BASE
242 }
243
244 fn auth_scheme(&self) -> AuthScheme {
245 AuthScheme::XiApiKey
246 }
247
248 fn allows_path(&self, path: &str) -> bool {
249 path_allowed(
251 path,
252 &["v1/speech-to-text"],
253 &["v1/text-to-speech", "v1/speech-to-speech"],
254 )
255 }
256}
257
258pub const XAI_ORIGIN: &str = "https://api.x.ai";
261pub const XAI_DEFAULT_BASE: &str = "https://api.x.ai/v1";
262
263#[derive(Debug, Default, Clone, Copy)]
265pub struct XaiHttpPolicy;
266
267impl ProviderHttpPolicy for XaiHttpPolicy {
268 fn provider_id(&self) -> &'static str {
269 "xai"
270 }
271
272 fn official_origins(&self) -> &'static [&'static str] {
273 &[XAI_ORIGIN]
274 }
275
276 fn default_base_url(&self) -> &'static str {
277 XAI_DEFAULT_BASE
278 }
279
280 fn auth_scheme(&self) -> AuthScheme {
281 AuthScheme::Bearer
282 }
283
284 fn allows_path(&self, path: &str) -> bool {
285 path_allowed(path, &["stt", "tts"], &[])
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use reqwest::Client;
295
296 #[test]
297 fn openrouter_extra_headers_present() {
298 let p = OpenRouterHttpPolicy;
299 let req = p
300 .apply_extra_headers(Client::new().get("https://openrouter.ai/api/v1/x"))
301 .build()
302 .unwrap();
303 let headers = req.headers();
304 let names: Vec<_> = headers
305 .keys()
306 .map(|k| k.as_str().to_ascii_lowercase())
307 .collect();
308 assert!(names.iter().any(|n| n == "http-referer" || n == "referer"));
309 assert!(names.iter().any(|n| n == "x-openrouter-title"));
310 assert!(!names.iter().any(|n| n == "x-title"));
311 assert!(names.iter().any(|n| n == "x-openrouter-categories"));
312
313 let referer = headers
314 .get("HTTP-Referer")
315 .or_else(|| headers.get("Referer"))
316 .and_then(|v| v.to_str().ok())
317 .unwrap();
318 assert_eq!(referer, OPENROUTER_APP_REFERER);
319 assert_eq!(
320 headers.get("X-OpenRouter-Title").unwrap().to_str().unwrap(),
321 OPENROUTER_APP_TITLE
322 );
323 assert!(headers.get("X-Title").is_none());
324 assert_eq!(
325 headers
326 .get("X-OpenRouter-Categories")
327 .unwrap()
328 .to_str()
329 .unwrap(),
330 OPENROUTER_APP_CATEGORIES
331 );
332 }
333
334 #[test]
335 fn openrouter_headers_do_not_cross_to_openai() {
336 let p = OpenAiHttpPolicy;
337 let req = p
338 .apply_extra_headers(Client::new().get("https://api.openai.com/v1/x"))
339 .build()
340 .unwrap();
341 for name in req.headers().keys() {
342 let n = name.as_str().to_ascii_lowercase();
343 assert_ne!(n, "http-referer");
344 assert_ne!(n, "x-title");
345 assert_ne!(n, "x-openrouter-title");
346 assert_ne!(n, "x-openrouter-categories");
347 assert_ne!(n, "xi-api-key");
348 }
349 }
350
351 #[test]
352 fn openrouter_headers_do_not_cross_to_elevenlabs_or_xai() {
353 for req in [
354 ElevenLabsHttpPolicy
355 .apply_extra_headers(Client::new().get("https://api.elevenlabs.io/x"))
356 .build()
357 .unwrap(),
358 XaiHttpPolicy
359 .apply_extra_headers(Client::new().get("https://api.x.ai/v1/x"))
360 .build()
361 .unwrap(),
362 ] {
363 for name in req.headers().keys() {
364 let n = name.as_str().to_ascii_lowercase();
365 assert_ne!(n, "http-referer");
366 assert_ne!(n, "x-title");
367 assert_ne!(n, "x-openrouter-title");
368 assert_ne!(n, "x-openrouter-categories");
369 }
370 }
371 }
372
373 #[test]
374 fn auth_schemes_do_not_cross() {
375 let key = "test-key-canary";
376 let bearer = OpenAiHttpPolicy
377 .apply_auth(Client::new().get("https://api.openai.com/v1/x"), key)
378 .build()
379 .unwrap();
380 assert!(bearer.headers().get("Authorization").is_some());
381 assert!(bearer.headers().get("xi-api-key").is_none());
382
383 let xi = ElevenLabsHttpPolicy
384 .apply_auth(Client::new().get("https://api.elevenlabs.io/x"), key)
385 .build()
386 .unwrap();
387 assert!(xi.headers().get("xi-api-key").is_some());
388 assert!(xi.headers().get("Authorization").is_none());
389 assert!(!xi
390 .headers()
391 .get("xi-api-key")
392 .unwrap()
393 .to_str()
394 .unwrap()
395 .contains("Bearer"));
396 }
397
398 #[test]
399 fn official_origins_are_provider_scoped() {
400 assert!(OpenRouterHttpPolicy.is_official_origin("https", "openrouter.ai"));
401 assert!(!OpenRouterHttpPolicy.is_official_origin("https", "api.openai.com"));
402 assert!(!OpenRouterHttpPolicy.is_official_origin("http", "openrouter.ai"));
403
404 assert!(OpenAiHttpPolicy.is_official_origin("https", "api.openai.com"));
405 assert!(!OpenAiHttpPolicy.is_official_origin("https", "openrouter.ai"));
406
407 assert!(ElevenLabsHttpPolicy.is_official_origin("https", "api.elevenlabs.io"));
408 assert!(XaiHttpPolicy.is_official_origin("https", "api.x.ai"));
409 assert!(!XaiHttpPolicy.is_official_origin("https", "api.openai.com"));
410 }
411
412 #[test]
413 fn path_allowlists_reject_traversal_and_foreign() {
414 assert!(OpenRouterHttpPolicy.allows_path("chat/completions"));
415 assert!(OpenRouterHttpPolicy.allows_path("/audio/transcriptions"));
416 assert!(OpenRouterHttpPolicy.allows_path("audio/speech"));
417 assert!(!OpenRouterHttpPolicy.allows_path("../chat/completions"));
418 assert!(!OpenRouterHttpPolicy.allows_path("https://evil.example/x"));
419 assert!(!OpenRouterHttpPolicy.allows_path("models"));
420
421 assert!(OpenAiHttpPolicy.allows_path("audio/speech"));
422 assert!(!OpenAiHttpPolicy.allows_path("v1/text-to-speech/abc"));
423
424 assert!(ElevenLabsHttpPolicy.allows_path("v1/text-to-speech/voiceid"));
425 assert!(!ElevenLabsHttpPolicy.allows_path("chat/completions"));
426
427 assert!(XaiHttpPolicy.allows_path("stt"));
428 assert!(XaiHttpPolicy.allows_path("tts"));
429 assert!(!XaiHttpPolicy.allows_path("audio/transcriptions"));
430 assert!(!XaiHttpPolicy.allows_path("audio/speech"));
431 assert!(!XaiHttpPolicy.allows_path("chat/completions"));
432 assert!(!XaiHttpPolicy.allows_path("realtime"));
433 }
434
435 #[test]
436 fn provider_ids_stable() {
437 assert_eq!(OpenRouterHttpPolicy.provider_id(), "openrouter");
438 assert_eq!(OpenAiHttpPolicy.provider_id(), "openai");
439 assert_eq!(ElevenLabsHttpPolicy.provider_id(), "elevenlabs");
440 assert_eq!(XaiHttpPolicy.provider_id(), "xai");
441 }
442}