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