1pub mod usage;
46
47use std::collections::HashMap;
48use std::sync::{Arc, Mutex};
49use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
50
51use crate::application::Application;
52use crate::header::Header;
53use crate::mcp::json_rpc;
54use crate::mcp::json_escape;
55use crate::request::Request;
56use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
57use crate::server::ConnectionInfo;
58
59pub use usage::{Usage, UsageParser};
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Api {
64 OpenAi,
67 Anthropic,
69}
70
71impl Api {
72 pub fn parse(name: &str) -> Option<Api> {
74 match name.to_ascii_lowercase().as_str() {
75 "openai" => Some(Api::OpenAi),
76 "anthropic" => Some(Api::Anthropic),
77 _ => None,
78 }
79 }
80
81 pub fn of_path(path: &str) -> Api {
83 let path = path.split(['?', '#']).next().unwrap_or_default().trim_end_matches('/');
84 if path.ends_with("/messages") || path.ends_with("/messages/count_tokens") {
85 Api::Anthropic
86 } else {
87 Api::OpenAi
88 }
89 }
90}
91
92#[derive(Clone)]
94pub struct Provider {
95 name: String,
96 api: Api,
97 tls: bool,
98 host: String,
99 port: u16,
100 base_path: String,
101 api_key: String,
102}
103
104impl std::fmt::Debug for Provider {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("Provider")
107 .field("name", &self.name)
108 .field("api", &self.api)
109 .field("url", &format!("{}://{}:{}{}", if self.tls { "https" } else { "http" }, self.host, self.port, self.base_path))
110 .finish_non_exhaustive()
111 }
112}
113
114impl Provider {
115 pub fn new(name: impl Into<String>, api: Api, url: &str) -> Result<Provider, String> {
118 let (tls, rest) = if let Some(rest) = url.strip_prefix("https://") {
119 (true, rest)
120 } else if let Some(rest) = url.strip_prefix("http://") {
121 (false, rest)
122 } else {
123 return Err(format!("provider url {url:?} must start with http:// or https://"));
124 };
125 let (authority, path) = rest.split_at(rest.find('/').unwrap_or(rest.len()));
126 let (host, port) = match authority.rsplit_once(':') {
127 Some((host, port)) if !host.ends_with(']') || authority.starts_with('[') => {
128 (host, port.parse::<u16>().map_err(|_| format!("provider url {url:?}: bad port"))?)
129 }
130 _ => (authority, if tls { 443 } else { 80 }),
131 };
132 let host = host.trim_start_matches('[').trim_end_matches(']');
133 if host.is_empty() {
134 return Err(format!("provider url {url:?} has no host"));
135 }
136 Ok(Provider {
137 name: name.into(),
138 api,
139 tls,
140 host: host.to_string(),
141 port,
142 base_path: path.trim_end_matches('/').to_string(),
143 api_key: String::new(),
144 })
145 }
146
147 pub fn api_key(mut self, key: impl Into<String>) -> Provider {
149 self.api_key = key.into();
150 self
151 }
152
153 pub fn name(&self) -> &str {
154 &self.name
155 }
156}
157
158#[derive(Debug, Clone, Copy)]
159struct Budget {
160 tokens: u64,
161 window_secs: u64,
162}
163
164enum Store {
166 Memory(Mutex<HashMap<String, (u64, u64)>>),
167 #[cfg(not(target_arch = "wasm32"))]
168 Redis { conn: crate::redis_protocol::RespConn, prefix: String },
169}
170
171fn now_secs() -> u64 {
172 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
173}
174
175impl Store {
176 fn spent(&self, identity: &str, window_secs: u64) -> u64 {
178 let window = now_secs() / window_secs;
179 match self {
180 Store::Memory(map) => match map.lock().unwrap_or_else(|e| e.into_inner()).get(identity) {
181 Some((w, spent)) if *w == window => *spent,
182 _ => 0,
183 },
184 #[cfg(not(target_arch = "wasm32"))]
185 Store::Redis { conn, prefix } => {
186 let key = format!("{prefix}{identity}:{window_secs}:{window}");
187 match conn.cmd(&[b"GET", key.as_bytes()]) {
188 Ok(crate::redis_protocol::RespReply::Bulk(Some(v))) => std::str::from_utf8(&v).ok().and_then(|s| s.parse().ok()).unwrap_or(0),
189 Ok(_) => 0,
190 Err(e) => {
191 eprintln!("llm proxy: budget read from Redis failed ({e}); letting the request through");
192 0
193 }
194 }
195 }
196 }
197 }
198
199 fn charge(&self, identity: &str, window_secs: u64, tokens: u64) {
200 if tokens == 0 {
201 return;
202 }
203 let window = now_secs() / window_secs;
204 match self {
205 Store::Memory(map) => {
206 let mut map = map.lock().unwrap_or_else(|e| e.into_inner());
207 let entry = map.entry(identity.to_string()).or_insert((window, 0));
208 if entry.0 != window {
209 *entry = (window, 0);
210 }
211 entry.1 = entry.1.saturating_add(tokens);
212 }
213 #[cfg(not(target_arch = "wasm32"))]
214 Store::Redis { conn, prefix } => {
215 let key = format!("{prefix}{identity}:{window_secs}:{window}");
216 let n = tokens.to_string();
217 match conn.cmd(&[b"INCRBY", key.as_bytes(), n.as_bytes()]) {
218 Ok(crate::redis_protocol::RespReply::Int(total)) if total as u64 == tokens => {
220 let ttl = (window_secs + 60).to_string();
221 if let Err(e) = conn.cmd(&[b"EXPIRE", key.as_bytes(), ttl.as_bytes()]) {
222 eprintln!("llm proxy: setting the budget key's expiry in Redis failed: {e}");
223 }
224 }
225 Ok(_) => {}
226 Err(e) => eprintln!("llm proxy: charging {tokens} tokens to {identity} in Redis failed: {e}"),
227 }
228 }
229 }
230 }
231}
232
233struct Inner {
234 providers: Vec<Provider>,
235 keys: Vec<(String, String)>,
237 anonymous: bool,
238 budgets: HashMap<String, Budget>,
239 store: Store,
240 audit_log: bool,
241 connect_timeout: Duration,
242 read_timeout: Duration,
243}
244
245#[derive(Clone)]
247pub struct LlmProxy {
248 inner: Arc<Inner>,
249}
250
251impl Default for LlmProxy {
252 fn default() -> Self {
253 LlmProxy::new()
254 }
255}
256
257impl LlmProxy {
258 pub fn new() -> LlmProxy {
259 LlmProxy {
260 inner: Arc::new(Inner {
261 providers: Vec::new(),
262 keys: Vec::new(),
263 anonymous: false,
264 budgets: HashMap::new(),
265 store: Store::Memory(Mutex::default()),
266 audit_log: false,
267 connect_timeout: Duration::from_secs(10),
268 read_timeout: Duration::from_secs(300),
269 }),
270 }
271 }
272
273 fn edit(mut self, f: impl FnOnce(&mut Inner)) -> LlmProxy {
274 match Arc::get_mut(&mut self.inner) {
275 Some(inner) => f(inner),
276 None => panic!("LlmProxy: configure it before cloning"),
277 }
278 self
279 }
280
281 pub fn provider(self, provider: Provider) -> LlmProxy {
283 self.edit(|i| i.providers.push(provider))
284 }
285
286 pub fn virtual_key(self, key: impl Into<String>, identity: impl Into<String>) -> LlmProxy {
288 self.edit(|i| i.keys.push((key.into(), identity.into())))
289 }
290
291 pub fn allow_anonymous(self) -> LlmProxy {
294 self.edit(|i| i.anonymous = true)
295 }
296
297 pub fn budget(self, identity: impl Into<String>, tokens: u64, window_secs: u64) -> LlmProxy {
299 let budget = Budget { tokens, window_secs: window_secs.max(1) };
300 self.edit(|i| {
301 i.budgets.insert(identity.into(), budget);
302 })
303 }
304
305 #[cfg(not(target_arch = "wasm32"))]
308 pub fn redis(self, addr: impl Into<String>, password: Option<String>, prefix: impl Into<String>) -> LlmProxy {
309 let store = Store::Redis { conn: crate::redis_protocol::RespConn::new(addr, password), prefix: prefix.into() };
310 self.edit(|i| i.store = store)
311 }
312
313 pub fn audit_log(self, on: bool) -> LlmProxy {
316 self.edit(|i| i.audit_log = on)
317 }
318
319 pub fn timeouts(self, connect: Duration, read: Duration) -> LlmProxy {
322 self.edit(|i| {
323 i.connect_timeout = connect;
324 i.read_timeout = read;
325 })
326 }
327
328 fn identity(&self, request: &Request) -> Option<String> {
329 let sent = request
330 .header("Authorization")
331 .and_then(|h| h.value.strip_prefix("Bearer ").map(|t| t.trim().to_string()))
332 .or_else(|| request.header("x-api-key").map(|h| h.value.trim().to_string()));
333 if let Some(sent) = sent.filter(|s| !s.is_empty()) {
334 let mut found = None;
335 for (key, identity) in &self.inner.keys {
336 if crate::proxy_config::constant_time_eq(key.as_bytes(), sent.as_bytes()) {
337 found = Some(identity.clone());
338 }
339 }
340 if found.is_some() {
341 return found;
342 }
343 }
344 self.inner.anonymous.then(|| "anonymous".to_string())
345 }
346}
347
348fn error(status: &crate::response::StatusCodeReasonPhrase, kind: &str, message: &str) -> Response {
349 let body = format!(r#"{{"type":"error","error":{{"type":"{kind}","message":"{}"}}}}"#, json_escape(message));
350 Response::json(status, body.into_bytes())
351}
352
353struct Asked {
355 model: String,
356 stream: bool,
357 has_stream_options: bool,
358}
359
360fn asked(body: &[u8]) -> Asked {
361 let members = std::str::from_utf8(body).ok().and_then(json_rpc::object_members).unwrap_or_default();
362 let mut asked = Asked { model: String::new(), stream: false, has_stream_options: false };
363 for (key, value) in members {
364 match key.as_str() {
365 "model" if value.starts_with('"') => {
366 asked.model = json_rpc::extract_str(&format!(r#"{{"v":{value}}}"#), "v").unwrap_or_default();
367 }
368 "stream" => asked.stream = value == "true",
369 "stream_options" => asked.has_stream_options = true,
370 _ => {}
371 }
372 }
373 asked
374}
375
376fn with_usage_option(body: &[u8]) -> Option<Vec<u8>> {
378 let start = body.iter().position(|b| !b.is_ascii_whitespace())?;
379 if body[start] != b'{' {
380 return None;
381 }
382 let rest = &body[start + 1..];
383 let empty = rest.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'}');
384 let mut out = Vec::with_capacity(body.len() + 40);
385 out.extend_from_slice(b"{\"stream_options\":{\"include_usage\":true}");
386 if !empty {
387 out.push(b',');
388 }
389 out.extend_from_slice(rest);
390 Some(out)
391}
392
393struct Done {
395 inner: Arc<Inner>,
396 provider: String,
397 model: String,
398 identity: String,
399 status: u16,
400 started: Instant,
401}
402
403impl Done {
404 fn record(self, usage: Option<Usage>, ttft: Option<Duration>) {
405 let ok = (200..300).contains(&self.status);
406 let usage = usage.filter(|_| ok);
407 crate::metrics::llm::observe(&self.provider, &self.model, &self.identity, &self.status.to_string(), usage, ttft);
408 if let (Some(usage), Some(budget)) = (usage, self.inner.budgets.get(&self.identity)) {
409 self.inner.store.charge(&self.identity, budget.window_secs, usage.total());
410 }
411 if self.inner.audit_log {
412 let mut time = String::new();
413 #[cfg(not(target_arch = "wasm32"))]
414 crate::log::iso8601_now(|iso| time.push_str(iso));
415 let usage = usage.unwrap_or_default();
416 let line = format!(
417 r#"{{"time":"{time}","event":"llm_request","sub":"{}","provider":"{}","model":"{}","status":{},"input_tokens":{},"output_tokens":{},"ttft_ms":{},"duration_ms":{:.3}}}"#,
418 json_escape(&self.identity),
419 json_escape(&self.provider),
420 json_escape(&self.model),
421 self.status,
422 usage.input_tokens,
423 usage.output_tokens,
424 ttft.map_or("null".to_string(), |t| format!("{:.3}", t.as_secs_f64() * 1000.0)),
425 self.started.elapsed().as_secs_f64() * 1000.0,
426 );
427 #[cfg(not(target_arch = "wasm32"))]
428 crate::log::access_sink::write_line(|out| out.extend_from_slice(line.as_bytes()));
429 #[cfg(target_arch = "wasm32")]
430 let _ = line;
431 }
432 }
433}
434
435fn hidden(name: &str) -> bool {
437 ["authorization", "x-api-key", "accept-encoding", "content-length"].iter().any(|h| name.eq_ignore_ascii_case(h))
438}
439
440fn upstream_request(request: &Request, provider: &Provider, body: &[u8]) -> Request {
441 let mut out = Request {
442 method: request.method.clone(),
443 request_uri: format!("{}{}", provider.base_path, request.request_uri),
444 http_version: request.http_version.clone(),
445 headers: request.headers.iter().filter(|h| !hidden(&h.name)).cloned().collect(),
446 body: body.to_vec().into(),
447 };
448 match provider.api {
449 Api::OpenAi => out.headers.push(Header::new("Authorization", format!("Bearer {}", provider.api_key))),
450 Api::Anthropic => {
451 out.headers.push(Header::new("x-api-key", provider.api_key.clone()));
452 if request.header("anthropic-version").is_none() {
453 out.headers.push(Header::new("anthropic-version", "2023-06-01"));
454 }
455 }
456 }
457 if !body.is_empty() || request.method.eq_ignore_ascii_case("POST") || request.method.eq_ignore_ascii_case("PUT") {
458 out.headers.push(Header::new("Content-Length", body.len().to_string()));
459 }
460 out
461}
462
463fn content_type(response: &Response) -> String {
464 response
465 .headers
466 .iter()
467 .find(|h| h.name.eq_ignore_ascii_case("content-type"))
468 .map(|h| h.value.to_string())
469 .or_else(|| response.content_range_list.first().map(|p| p.content_type.clone()))
470 .unwrap_or_default()
471}
472
473impl Application for LlmProxy {
474 fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
475 let Some(identity) = self.identity(request) else {
476 return Ok(error(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "authentication_error", "invalid or missing API key"));
477 };
478 if let Some(budget) = self.inner.budgets.get(&identity) {
479 if self.inner.store.spent(&identity, budget.window_secs) >= budget.tokens {
480 crate::metrics::llm::observe_budget_rejection(&identity);
481 let mut response = error(STATUS_CODE_REASON_PHRASE.n429_too_many_requests, "rate_limit_error", "token budget spent for this window");
482 let retry = budget.window_secs - now_secs() % budget.window_secs;
483 response.headers.push(Header::new("Retry-After", retry.to_string()));
484 return Ok(response);
485 }
486 }
487 let api = Api::of_path(&request.request_uri);
488 let providers: Vec<&Provider> = self.inner.providers.iter().filter(|p| p.api == api).collect();
489 if providers.is_empty() {
490 return Ok(error(STATUS_CODE_REASON_PHRASE.n404_not_found, "not_found_error", "no provider serves this API"));
491 }
492
493 let asked = asked(&request.body);
494 let rewritten = (api == Api::OpenAi && asked.stream && !asked.has_stream_options).then(|| with_usage_option(&request.body)).flatten();
495 let body: &[u8] = rewritten.as_deref().unwrap_or(&request.body);
496
497 let mut last_error = String::new();
498 for (i, provider) in providers.iter().enumerate() {
499 let last = i + 1 == providers.len();
500 let started = Instant::now();
501 let upstream = upstream_request(request, provider, body);
502 let result = crate::proxy::forward_upstream_trusting(
503 crate::proxy::shared_pool(),
504 &upstream,
505 crate::proxy::Peer::of(connection),
506 &provider.host,
507 provider.port,
508 provider.tls,
509 None,
510 self.inner.connect_timeout,
511 self.inner.read_timeout,
512 );
513 let response = match result {
514 Ok(response) if last || !(response.status_code == 429 || response.status_code >= 500) => response,
515 Ok(response) => {
516 last_error = format!("{} answered {}", provider.name, response.status_code);
517 crate::metrics::llm::observe(&provider.name, &asked.model, &identity, &response.status_code.to_string(), None, None);
518 crate::metrics::llm::observe_fallback(&provider.name);
519 continue;
520 }
521 Err(e) => {
522 last_error = format!("{}: {e}", provider.name);
523 crate::metrics::llm::observe(&provider.name, &asked.model, &identity, "error", None, None);
524 if !last {
525 crate::metrics::llm::observe_fallback(&provider.name);
526 }
527 continue;
528 }
529 };
530 return Ok(self.finish(response, provider, &asked.model, &identity, started));
531 }
532 eprintln!("llm proxy: no provider answered ({last_error})");
533 Ok(error(STATUS_CODE_REASON_PHRASE.n502_bad_gateway, "api_error", "no provider answered"))
534 }
535}
536
537impl LlmProxy {
538 fn finish(&self, mut response: Response, provider: &Provider, model: &str, identity: &str, started: Instant) -> Response {
541 let done = Done {
542 inner: Arc::clone(&self.inner),
543 provider: provider.name.clone(),
544 model: model.to_string(),
545 identity: identity.to_string(),
546 status: response.status_code as u16,
547 started,
548 };
549 let content_type = content_type(&response);
550 match response.stream_pipe.take() {
551 Some(pipe) => {
552 let chunked = response
553 .headers
554 .iter()
555 .any(|h| h.name.eq_ignore_ascii_case("transfer-encoding") && h.value.to_ascii_lowercase().contains("chunked"));
556 response.headers.retain(|h| !h.name.eq_ignore_ascii_case("transfer-encoding"));
558 let reader = usage::UsageReader::new(pipe, &content_type, chunked, started, Box::new(move |usage, ttft| done.record(usage, ttft)));
559 response.stream_pipe = Some(Box::new(reader));
560 }
561 None => {
562 let ttft = started.elapsed();
563 let mut parser = UsageParser::new(&content_type);
564 for part in &response.content_range_list {
565 parser.feed(&part.body);
566 }
567 done.record(parser.finish(), Some(ttft));
568 }
569 }
570 response
571 }
572}
573
574#[cfg(test)]
575mod tests;