Skip to main content

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