Skip to main content

rust_web_server/llm/
mod.rs

1//! An LLM proxy (18.9.0, #41): one endpoint in front of OpenAI-compatible
2//! and Anthropic providers that counts tokens, enforces budgets and fails
3//! over between providers.
4//!
5//! ```rust,no_run
6//! use rust_web_server::llm::{Api, LlmProxy, Provider};
7//!
8//! let proxy = LlmProxy::new()
9//!     .provider(Provider::new("openai", Api::OpenAi, "https://api.openai.com").unwrap().api_key("sk-…"))
10//!     .provider(Provider::new("azure", Api::OpenAi, "https://example.openai.azure.com").unwrap().api_key("…"))
11//!     .provider(Provider::new("anthropic", Api::Anthropic, "https://api.anthropic.com").unwrap().api_key("sk-ant-…"))
12//!     .virtual_key("vk-alice", "alice")
13//!     .budget("alice", 2_000_000, 86_400);
14//! // `proxy` is an `Application`: serve it, or mount it on a route.
15//! ```
16//!
17//! - **Which API.** A request to a path ending in `/messages` is Anthropic's
18//!   Messages API; anything else (`/v1/chat/completions`, `/v1/responses`,
19//!   `/v1/embeddings`, `/v1/models`, …) is OpenAI's. It goes to the
20//!   providers of that API, in the order added; the path is sent as is,
21//!   after the provider URL's own path.
22//! - **Virtual keys.** Clients authenticate with a key the proxy issued
23//!   (`Authorization: Bearer <key>` or `x-api-key: <key>`, as each SDK sends
24//!   it); it names an identity and never reaches a provider, which gets its
25//!   own key instead (`Authorization: Bearer` for OpenAI, `x-api-key` and a
26//!   default `anthropic-version` for Anthropic). Unknown keys get `401`.
27//! - **Fallback.** A provider that doesn't answer, or answers `429` or
28//!   `5xx`, is skipped for the next one of the same API — before anything
29//!   was sent to the client, so a stream never switches provider midway.
30//! - **Usage.** Tokens come from the provider's response, JSON or event
31//!   stream ([`usage`]). For OpenAI streams the proxy adds
32//!   `"stream_options":{"include_usage":true}` when the request has no
33//!   `stream_options`, so the stream ends with one extra chunk with empty
34//!   `choices` that carries the usage.
35//! - **Budgets.** Tokens per identity per fixed window, in memory or shared
36//!   between replicas through Redis ([`LlmProxy::redis`]). Checked before a
37//!   request and charged after it with what the provider reported, so the
38//!   request that crosses the budget completes; the next one gets `429`.
39//!   Redis errors are logged and let requests through (fail open).
40//! - **Metrics.** `rws_llm_requests_total`, `rws_llm_tokens_total`,
41//!   `rws_llm_time_to_first_token_seconds`, `rws_llm_fallbacks_total`,
42//!   `rws_llm_budget_rejections_total` on `/metrics`; with
43//!   [`LlmProxy::audit_log`] one JSON line per request in the access log.
44
45pub 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/// The API a provider speaks.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Api {
64    /// OpenAI's (and every OpenAI-compatible server's): Chat Completions,
65    /// Responses, embeddings, models.
66    OpenAi,
67    /// Anthropic's Messages API.
68    Anthropic,
69}
70
71impl Api {
72    /// `"openai"` or `"anthropic"`, as the config file spells it.
73    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    /// The API a request path belongs to.
82    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/// One upstream provider.
93#[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    /// `url` is `http(s)://host[:port][/path]`; request paths go after its
116    /// path (`https://api.openai.com` + `/v1/chat/completions`).
117    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    /// The provider's own API key.
148    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
164/// Where spent tokens are counted.
165enum 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    /// Tokens `identity` spent in the current window.
177    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                    // The first charge in this window: let the key expire with it.
219                    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    /// (virtual key, identity)
236    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/// The LLM proxy, an [`Application`]. See the [module docs](self).
246#[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    /// A provider; tried in the order added, per API.
282    pub fn provider(self, provider: Provider) -> LlmProxy {
283        self.edit(|i| i.providers.push(provider))
284    }
285
286    /// A key clients send, and the identity it stands for.
287    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    /// Let requests without a known key through, as identity `anonymous`
292    /// (a proxy behind its own authentication).
293    pub fn allow_anonymous(self) -> LlmProxy {
294        self.edit(|i| i.anonymous = true)
295    }
296
297    /// At most `tokens` (input + output) per `window_secs` for `identity`.
298    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    /// Count budgets in Redis at `addr` (`host:port`), under keys starting
306    /// with `prefix`, so every replica sees the same spend.
307    #[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    /// One JSON line per request (identity, provider, model, status, tokens,
314    /// time to first byte, duration) through the access log.
315    pub fn audit_log(self, on: bool) -> LlmProxy {
316        self.edit(|i| i.audit_log = on)
317    }
318
319    /// How long to wait for a provider's connection, and for each read of
320    /// its response (default 10 s and 300 s).
321    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
353/// What the request asked for, read at the top level of its JSON body.
354struct 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
376/// `body` with `"stream_options":{"include_usage":true}` as its first member.
377fn 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
393/// A request done: what to record once its body has been read.
394struct 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
435/// Headers of the client's request that never reach a provider.
436fn 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    /// Count the response's usage: now for a buffered body, as it streams
539    /// for a piped one.
540    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                // The reader removes the framing; the server frames it again.
557                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;