Skip to main content

edgeguard/
llm.rs

1//! LLM token metering (gateway L0).
2//!
3//! When `[llm]` is enabled, EdgeGuard parses OpenAI-compatible traffic to **meter tokens and
4//! cost** — the substrate every later level (budgets, governance, cost accounting) builds on. L0 is
5//! *metering only*: it never blocks, rewrites, or delays a request. Token counts come from the
6//! upstream's own `usage` object (authoritative), so the proxy does not tokenize anything itself.
7//!
8//! Two response shapes are handled:
9//!   * **non-streaming** — a JSON body carrying `usage.{prompt,completion}_tokens` ([`parse_response_usage`]);
10//!   * **streaming (SSE)** — the terminal `data:` frame carries `usage` when the client sets
11//!     `stream_options.include_usage` ([`parse_sse_usage`]); without it, no usage is emitted and the
12//!     request is metered as `no_usage`.
13//!
14//! Pricing is a per-model book ([`LlmRuntime`]). What happens to a request for an **unmapped** model
15//! is a config choice ([`UnpricedPolicy`]): `count` keeps the historical fail-open behaviour (tokens
16//! counted, cost omitted — surfaced as the `unpriced` result), while `block` fails *closed* (`402`,
17//! the request never reaches the upstream) so a mispriced/unknown model can't be served at a silent
18//! `$0`. Cost is accumulated in **micro-dollars**
19//! (1e-6 USD) as an integer to avoid float drift in a monotonic counter.
20//!
21//! Token accounting captures four dimensions, not two: `prompt` and `completion`, plus the
22//! **`cached`** prompt tokens (`prompt_tokens_details.cached_tokens`) and the **`reasoning`**
23//! completion tokens (`completion_tokens_details.reasoning_tokens`) that OpenAI-compatible providers
24//! bill differently. Pricing them separately is what fixes the "~7× undercount" on reasoning/cached
25//! traffic; when a model leaves the cached/reasoning rate unset they inherit the input/output rate,
26//! so a book that predates those knobs prices exactly as before.
27
28use std::collections::BTreeMap;
29
30use serde::{Deserialize, Deserializer};
31
32use crate::config::LlmCfg;
33
34/// Token usage as reported by the upstream's `usage` object. `cached_tokens` is the subset of
35/// `prompt_tokens` served from the provider's prompt cache; `reasoning_tokens` is the subset of
36/// `completion_tokens` spent on hidden reasoning. Both are carried separately so they can be priced
37/// (and metered) at their own rate rather than folded into the base input/output totals.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub struct Usage {
40    pub prompt_tokens: u64,
41    pub completion_tokens: u64,
42    /// Cached prompt tokens (⊆ `prompt_tokens`), from `prompt_tokens_details.cached_tokens`.
43    pub cached_tokens: u64,
44    /// Reasoning completion tokens (⊆ `completion_tokens`), from
45    /// `completion_tokens_details.reasoning_tokens`.
46    pub reasoning_tokens: u64,
47}
48
49/// The raw `usage` wire shape, including the nested detail objects OpenAI added for cached/reasoning
50/// accounting. Flattened into [`Usage`] on deserialize so the rest of the crate sees four flat dims.
51#[derive(Deserialize)]
52struct UsageWire {
53    #[serde(default)]
54    prompt_tokens: u64,
55    #[serde(default)]
56    completion_tokens: u64,
57    #[serde(default)]
58    prompt_tokens_details: Option<PromptTokensDetails>,
59    #[serde(default)]
60    completion_tokens_details: Option<CompletionTokensDetails>,
61}
62
63#[derive(Deserialize)]
64struct PromptTokensDetails {
65    #[serde(default)]
66    cached_tokens: u64,
67}
68
69#[derive(Deserialize)]
70struct CompletionTokensDetails {
71    #[serde(default)]
72    reasoning_tokens: u64,
73}
74
75impl<'de> Deserialize<'de> for Usage {
76    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77    where
78        D: Deserializer<'de>,
79    {
80        let w = UsageWire::deserialize(deserializer)?;
81        // Clamp the sub-dimensions to their parent so a malformed upstream (cached > prompt) can't
82        // make the priced "uncached" remainder underflow later.
83        let cached = w
84            .prompt_tokens_details
85            .map(|d| d.cached_tokens)
86            .unwrap_or(0)
87            .min(w.prompt_tokens);
88        let reasoning = w
89            .completion_tokens_details
90            .map(|d| d.reasoning_tokens)
91            .unwrap_or(0)
92            .min(w.completion_tokens);
93        Ok(Usage {
94            prompt_tokens: w.prompt_tokens,
95            completion_tokens: w.completion_tokens,
96            cached_tokens: cached,
97            reasoning_tokens: reasoning,
98        })
99    }
100}
101
102impl Usage {
103    fn is_empty(&self) -> bool {
104        self.prompt_tokens == 0 && self.completion_tokens == 0
105    }
106
107    /// Total tokens across prompt + completion (the budget's `Tokens` unit basis).
108    pub fn total_tokens(&self) -> u64 {
109        self.prompt_tokens.saturating_add(self.completion_tokens)
110    }
111}
112
113/// What to do with a request whose model is **not** in the price book.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum UnpricedPolicy {
116    /// Meter tokens, omit cost (`unpriced` result), forward the request — the historical default.
117    Count,
118    /// Reject the request `402` before it reaches the upstream, so an unpriced model is never served
119    /// at a silent `$0`. Only bites when a price book is configured.
120    Block,
121}
122
123impl UnpricedPolicy {
124    pub fn parse(s: &str) -> anyhow::Result<UnpricedPolicy> {
125        match s.trim().to_ascii_lowercase().as_str() {
126            "count" | "" => Ok(UnpricedPolicy::Count),
127            "block" | "reject" | "deny" => Ok(UnpricedPolicy::Block),
128            other => {
129                anyhow::bail!("invalid llm.on_unpriced_model {other:?} (expected count|block)")
130            }
131        }
132    }
133}
134
135/// Per-model price, in **micro-dollars per 1,000,000 tokens** (compiled from the config's USD
136/// floats once at load, so the hot path does integer math only). `cached`/`reasoning` default to the
137/// base input/output rate when the config leaves them unset.
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139struct ModelRate {
140    input_micros_per_m: u64,
141    output_micros_per_m: u64,
142    cached_micros_per_m: u64,
143    reasoning_micros_per_m: u64,
144}
145
146/// The compiled LLM runtime: whether metering is on, the API style, and the price book. Built once
147/// per config (re)load and carried on the proxy [`Runtime`](crate::proxy::Runtime).
148#[derive(Clone, Debug)]
149pub struct LlmRuntime {
150    pub enabled: bool,
151    /// Request/response wire format. Only `"openai"` is understood today; anything else still
152    /// meters (the OpenAI shape is a superset of most), but is recorded for forward-compat.
153    pub api_style: String,
154    /// What to do with a request for a model absent from the price book (`count` / `block`).
155    pub unpriced: UnpricedPolicy,
156    prices: BTreeMap<String, ModelRate>,
157}
158
159impl LlmRuntime {
160    /// Compile an [`LlmRuntime`] from config. USD-per-million floats become integer micro-dollars
161    /// per million; a negative price is clamped to 0 (free) rather than rejected, so a typo never
162    /// stops the proxy booting. An invalid `on_unpriced_model` falls back to `count` (never a hard
163    /// boot failure) — the value is re-validated at config load where a typo is surfaced.
164    pub fn build(cfg: &LlmCfg) -> Self {
165        let prices = cfg
166            .models
167            .iter()
168            .map(|(name, p)| {
169                let input = usd_per_m_to_micros(p.input_per_1m);
170                let output = usd_per_m_to_micros(p.output_per_1m);
171                let rate = ModelRate {
172                    input_micros_per_m: input,
173                    output_micros_per_m: output,
174                    // Cached/reasoning inherit the base input/output rate unless the book sets an
175                    // explicit (positive) rate — so an existing book prices unchanged.
176                    cached_micros_per_m: if p.cached_per_1m > 0.0 {
177                        usd_per_m_to_micros(p.cached_per_1m)
178                    } else {
179                        input
180                    },
181                    reasoning_micros_per_m: if p.reasoning_per_1m > 0.0 {
182                        usd_per_m_to_micros(p.reasoning_per_1m)
183                    } else {
184                        output
185                    },
186                };
187                (name.clone(), rate)
188            })
189            .collect();
190        LlmRuntime {
191            enabled: cfg.enabled,
192            api_style: if cfg.api_style.trim().is_empty() {
193                "openai".to_string()
194            } else {
195                cfg.api_style.trim().to_ascii_lowercase()
196            },
197            unpriced: UnpricedPolicy::parse(&cfg.on_unpriced_model)
198                .unwrap_or(UnpricedPolicy::Count),
199            prices,
200        }
201    }
202
203    /// An inert runtime (metering off) — the default carried when `[llm]` is absent.
204    pub fn disabled() -> Self {
205        LlmRuntime {
206            enabled: false,
207            api_style: "openai".to_string(),
208            unpriced: UnpricedPolicy::Count,
209            prices: BTreeMap::new(),
210        }
211    }
212
213    /// Whether a price book is configured at all. `block` on an unpriced model only bites when true —
214    /// a metering-only deployment (no `[llm.models]`) must not reject every request.
215    pub fn has_price_book(&self) -> bool {
216        !self.prices.is_empty()
217    }
218
219    /// Resolve the price for `model`: an exact book entry wins; otherwise a provider-prefixed alias
220    /// (OpenTelemetry-style `"openai/gpt-4o"`) falls back to the bare model name. Exact
221    /// entries always take precedence, so a book that prices the prefixed name explicitly is never
222    /// overridden — this only rescues a prefixed request that would otherwise read `$0`/`unpriced`
223    /// because the book is keyed on the bare name.
224    fn resolve_rate(&self, model: &str) -> Option<&ModelRate> {
225        if let Some(rate) = self.prices.get(model) {
226            return Some(rate);
227        }
228        strip_provider_prefix(model).and_then(|bare| self.prices.get(bare))
229    }
230
231    /// Whether `model` carries a price (exact entry or a provider-prefixed alias of one).
232    pub fn is_priced(&self, model: &str) -> bool {
233        self.resolve_rate(model).is_some()
234    }
235
236    /// Whether this request must be rejected `402` for an unpriced model: policy is `block`, a price
237    /// book exists, and `model` is not in it. A metering-only setup (empty book) never rejects.
238    pub fn reject_unpriced(&self, model: &str) -> bool {
239        self.unpriced == UnpricedPolicy::Block && self.has_price_book() && !self.is_priced(model)
240    }
241
242    /// Cost of `usage` for `model` in micro-dollars, or `None` if the model has no price (the caller
243    /// still counts the tokens; whether to serve the request is governed by [`Self::reject_unpriced`]).
244    /// Cached prompt tokens and reasoning completion tokens are billed at their own rate (each
245    /// defaulting to the base input/output rate), and the remaining prompt/completion tokens at the
246    /// base rate — so the four dimensions never double-count.
247    pub fn cost_micros(&self, model: &str, usage: &Usage) -> Option<u64> {
248        let rate = self.resolve_rate(model)?;
249        let cached = usage.cached_tokens.min(usage.prompt_tokens);
250        let uncached_input = usage.prompt_tokens - cached;
251        let reasoning = usage.reasoning_tokens.min(usage.completion_tokens);
252        let base_output = usage.completion_tokens - reasoning;
253        let total = uncached_input as u128 * rate.input_micros_per_m as u128
254            + cached as u128 * rate.cached_micros_per_m as u128
255            + base_output as u128 * rate.output_micros_per_m as u128
256            + reasoning as u128 * rate.reasoning_micros_per_m as u128;
257        Some((total / 1_000_000).min(u64::MAX as u128) as u64)
258    }
259}
260
261/// Provider prefixes used by OpenTelemetry-style model ids (`"openai/gpt-4o"`). Stripping a
262/// known prefix lets a price book keyed by the bare model name still price a prefixed request instead
263/// of reading `$0`. Deliberately a **curated** list — not "everything
264/// before the first slash" — so HuggingFace-style ids like `"meta-llama/Llama-3"` are left
265/// intact and only unambiguous single-provider prefixes are stripped.
266const PROVIDER_PREFIXES: &[&str] = &[
267    "openai/",
268    "anthropic/",
269    "azure/",
270    "azure_ai/",
271    "vertex_ai/",
272    "vertex/",
273    "bedrock/",
274    "gemini/",
275    "google/",
276    "mistral/",
277    "codestral/",
278    "cohere/",
279    "groq/",
280    "together_ai/",
281    "together/",
282    "fireworks_ai/",
283    "fireworks/",
284    "deepseek/",
285    "xai/",
286    "perplexity/",
287    "replicate/",
288    "anyscale/",
289    "deepinfra/",
290    "cloudflare/",
291    "watsonx/",
292    "sagemaker/",
293    "ollama_chat/",
294    "ollama/",
295];
296
297/// The canonical model name for **attribution** (budgets, per-model rollups): the bare name with a
298/// known provider prefix stripped, else the name unchanged. So a per-model budget and its rollups
299/// aggregate `"openai/gpt-4o"` and `"gpt-4o"` as one model instead of splitting spend across two
300/// buckets (a prefixed request otherwise silently escaping a bare-named budget).
301pub fn canonical_model(model: &str) -> &str {
302    strip_provider_prefix(model).unwrap_or(model)
303}
304
305/// If `model` begins with a known [`PROVIDER_PREFIXES`] entry (case-insensitive), return the bare
306/// model name after it; else `None`. Only the first prefix is stripped. Compares bytes so a
307/// multi-byte model name can never panic on a non-char-boundary slice.
308fn strip_provider_prefix(model: &str) -> Option<&str> {
309    for p in PROVIDER_PREFIXES {
310        if model.len() > p.len() && model.as_bytes()[..p.len()].eq_ignore_ascii_case(p.as_bytes()) {
311            // The matched prefix is ASCII, so `p.len()` is a valid UTF-8 boundary.
312            return Some(&model[p.len()..]);
313        }
314    }
315    None
316}
317
318/// USD-per-1M-tokens (float) → micro-dollars-per-1M-tokens (integer). `$0.50` → `500_000`.
319fn usd_per_m_to_micros(usd: f64) -> u64 {
320    if !usd.is_finite() || usd <= 0.0 {
321        return 0;
322    }
323    (usd * 1_000_000.0).round() as u64
324}
325
326#[derive(Deserialize)]
327struct ModelField {
328    model: Option<String>,
329}
330
331/// Extract the `model` field from an OpenAI-style request body. `None` if the body isn't JSON or
332/// has no `model` (then the request isn't metered as LLM traffic). Other fields are ignored, so a
333/// large `messages` array is not materialized beyond what serde must scan.
334pub fn parse_request_model(body: &[u8]) -> Option<String> {
335    let parsed: ModelField = serde_json::from_slice(body).ok()?;
336    let model = parsed.model?;
337    (!model.trim().is_empty()).then_some(model)
338}
339
340#[derive(Deserialize)]
341struct MaxTokensField {
342    /// OpenAI's completion ceiling. The newer `max_completion_tokens` is accepted as a fallback.
343    max_tokens: Option<u64>,
344    max_completion_tokens: Option<u64>,
345}
346
347/// Extract the request's completion-token ceiling (`max_tokens`, or `max_completion_tokens`). Used
348/// only to size the budget *reserve* estimate; the reservation is reconciled to actual usage after.
349pub fn parse_request_max_tokens(body: &[u8]) -> Option<u64> {
350    let parsed: MaxTokensField = serde_json::from_slice(body).ok()?;
351    parsed.max_tokens.or(parsed.max_completion_tokens)
352}
353
354/// A rough prompt-token estimate from the raw request size (~4 bytes/token, the common English
355/// heuristic). Deliberately an over-estimate — the JSON envelope inflates it — so the budget
356/// *reserve* errs toward caution (a hard cap should never admit past the limit); the reservation is
357/// reconciled down to the upstream's exact `usage` afterward.
358pub fn estimate_prompt_tokens(body_len: usize) -> u64 {
359    (body_len / 4) as u64
360}
361
362#[derive(Deserialize)]
363struct UsageField {
364    usage: Option<Usage>,
365}
366
367/// Extract `usage` from a non-streaming OpenAI-style response body. `None` if absent/zero (an error
368/// response or a stream that didn't include usage).
369pub fn parse_response_usage(body: &[u8]) -> Option<Usage> {
370    let parsed: UsageField = serde_json::from_slice(body).ok()?;
371    parsed.usage.filter(|u| !u.is_empty())
372}
373
374/// Extract the terminal `usage` from an SSE stream's bytes. OpenAI emits a final
375/// `data: {…, "usage": {…}}` frame when the client sets `stream_options.include_usage`; earlier
376/// frames carry `"usage": null`. Returns the **last** non-empty usage seen (the authoritative
377/// totals), or `None` if the stream never reported usage.
378pub fn parse_sse_usage(bytes: &[u8]) -> Option<Usage> {
379    let text = std::str::from_utf8(bytes).ok()?;
380    let mut last = None;
381    for line in text.lines() {
382        let line = line.trim_start();
383        let Some(payload) = line.strip_prefix("data:") else {
384            continue;
385        };
386        let payload = payload.trim();
387        if payload.is_empty() || payload == "[DONE]" {
388            continue;
389        }
390        if let Ok(parsed) = serde_json::from_str::<UsageField>(payload) {
391            if let Some(u) = parsed.usage.filter(|u| !u.is_empty()) {
392                last = Some(u);
393            }
394        }
395    }
396    last
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::config::ModelPrice;
403
404    fn runtime() -> LlmRuntime {
405        let mut models = BTreeMap::new();
406        models.insert(
407            "gpt-4o".to_string(),
408            ModelPrice {
409                input_per_1m: 2.50,
410                output_per_1m: 10.00,
411                ..Default::default()
412            },
413        );
414        LlmRuntime::build(&LlmCfg {
415            enabled: true,
416            api_style: "openai".into(),
417            models,
418            ..Default::default()
419        })
420    }
421
422    #[test]
423    fn parses_request_model() {
424        let body = br#"{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}"#;
425        assert_eq!(parse_request_model(body), Some("gpt-4o".to_string()));
426        assert_eq!(parse_request_model(b"not json"), None);
427        assert_eq!(parse_request_model(br#"{"messages":[]}"#), None);
428        assert_eq!(parse_request_model(br#"{"model":""}"#), None);
429    }
430
431    #[test]
432    fn parses_non_streaming_usage() {
433        let body = br#"{"id":"x","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":34,"total_tokens":46}}"#;
434        assert_eq!(
435            parse_response_usage(body),
436            Some(Usage {
437                prompt_tokens: 12,
438                completion_tokens: 34,
439                ..Default::default()
440            })
441        );
442        // No usage / error body → None.
443        assert_eq!(parse_response_usage(br#"{"error":"nope"}"#), None);
444        // Zeroed usage is treated as absent.
445        assert_eq!(
446            parse_response_usage(br#"{"usage":{"prompt_tokens":0,"completion_tokens":0}}"#),
447            None
448        );
449    }
450
451    #[test]
452    fn parses_terminal_sse_usage() {
453        // Mid-stream frames carry usage:null; the final frame before [DONE] carries the totals.
454        let stream = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"usage\":null}\n\n\
455                      data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":5,\"total_tokens\":12}}\n\n\
456                      data: [DONE]\n\n";
457        assert_eq!(
458            parse_sse_usage(stream.as_bytes()),
459            Some(Usage {
460                prompt_tokens: 7,
461                completion_tokens: 5,
462                ..Default::default()
463            })
464        );
465        // A stream that never reported usage (client didn't opt in).
466        let no_usage = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n";
467        assert_eq!(parse_sse_usage(no_usage.as_bytes()), None);
468    }
469
470    #[test]
471    fn prices_known_model_and_fails_open_on_unknown() {
472        let rt = runtime();
473        let usage = Usage {
474            prompt_tokens: 1_000_000,
475            completion_tokens: 1_000_000,
476            ..Default::default()
477        };
478        // 1M input @ $2.50/M = $2.50 = 2_500_000 micro; 1M output @ $10/M = 10_000_000 micro.
479        assert_eq!(rt.cost_micros("gpt-4o", &usage), Some(12_500_000));
480        // Unknown model → None (caller still counts tokens; serving is governed by the policy).
481        assert_eq!(rt.cost_micros("mystery-model", &usage), None);
482    }
483
484    #[test]
485    fn cost_is_proportional_for_small_counts() {
486        let rt = runtime();
487        // 1000 input tokens @ $2.50/M = 1000 * 2_500_000 / 1_000_000 = 2500 micro-USD.
488        let usage = Usage {
489            prompt_tokens: 1_000,
490            completion_tokens: 0,
491            ..Default::default()
492        };
493        assert_eq!(rt.cost_micros("gpt-4o", &usage), Some(2_500));
494    }
495
496    #[test]
497    fn parses_llm_toml_models_map() {
498        // Locks the `[llm.models."name"]` table-map shape used in the example config, so the docs
499        // and the serde mapping can't drift apart.
500        let toml = r#"
501[llm]
502enabled = true
503api_style = "openai"
504
505[llm.models."gpt-4o"]
506input_per_1m = 2.5
507output_per_1m = 10.0
508"#;
509        let cfg: crate::config::Config = toml::from_str(toml).unwrap();
510        assert!(cfg.llm.enabled);
511        assert_eq!(cfg.llm.models.len(), 1);
512        let rt = LlmRuntime::build(&cfg.llm);
513        let usage = Usage {
514            prompt_tokens: 1_000_000,
515            completion_tokens: 0,
516            ..Default::default()
517        };
518        assert_eq!(rt.cost_micros("gpt-4o", &usage), Some(2_500_000));
519    }
520
521    #[test]
522    fn negative_or_zero_price_is_free_not_an_error() {
523        assert_eq!(usd_per_m_to_micros(-1.0), 0);
524        assert_eq!(usd_per_m_to_micros(0.0), 0);
525        assert_eq!(usd_per_m_to_micros(0.5), 500_000);
526    }
527
528    #[test]
529    fn parses_cached_and_reasoning_detail_dims() {
530        // OpenAI nests the sub-dimensions under *_tokens_details; they flatten onto Usage.
531        let body = br#"{"usage":{"prompt_tokens":100,"completion_tokens":80,
532            "prompt_tokens_details":{"cached_tokens":40},
533            "completion_tokens_details":{"reasoning_tokens":30}}}"#;
534        assert_eq!(
535            parse_response_usage(body),
536            Some(Usage {
537                prompt_tokens: 100,
538                completion_tokens: 80,
539                cached_tokens: 40,
540                reasoning_tokens: 30,
541            })
542        );
543        // A malformed upstream (cached > prompt) is clamped to the parent, not left to underflow.
544        let bad = br#"{"usage":{"prompt_tokens":10,"completion_tokens":5,
545            "prompt_tokens_details":{"cached_tokens":9999}}}"#;
546        assert_eq!(parse_response_usage(bad).unwrap().cached_tokens, 10);
547    }
548
549    #[test]
550    fn cached_reasoning_default_to_base_rate_so_totals_are_unchanged() {
551        // With no explicit cached/reasoning rate, splitting the totals into sub-dims must not change
552        // the price: a purely-cached prompt costs the same as a plain prompt of the same size.
553        let rt = runtime();
554        let plain = Usage {
555            prompt_tokens: 1_000_000,
556            completion_tokens: 1_000_000,
557            ..Default::default()
558        };
559        let with_dims = Usage {
560            prompt_tokens: 1_000_000,
561            completion_tokens: 1_000_000,
562            cached_tokens: 500_000,
563            reasoning_tokens: 400_000,
564        };
565        assert_eq!(
566            rt.cost_micros("gpt-4o", &plain),
567            rt.cost_micros("gpt-4o", &with_dims)
568        );
569    }
570
571    #[test]
572    fn explicit_cached_reasoning_rates_are_applied() {
573        let mut models = BTreeMap::new();
574        models.insert(
575            "gpt-4o".to_string(),
576            ModelPrice {
577                input_per_1m: 2.50,
578                output_per_1m: 10.00,
579                cached_per_1m: 1.25,     // half the input rate
580                reasoning_per_1m: 20.00, // double the output rate
581            },
582        );
583        let rt = LlmRuntime::build(&LlmCfg {
584            enabled: true,
585            models,
586            ..Default::default()
587        });
588        let usage = Usage {
589            prompt_tokens: 1_000_000,     // 600k uncached @2.50 + 400k cached @1.25
590            completion_tokens: 1_000_000, // 700k base @10 + 300k reasoning @20
591            cached_tokens: 400_000,
592            reasoning_tokens: 300_000,
593        };
594        // 600k*2.5 = 1_500_000 ; 400k*1.25 = 500_000 ; 700k*10 = 7_000_000 ; 300k*20 = 6_000_000
595        assert_eq!(rt.cost_micros("gpt-4o", &usage), Some(15_000_000));
596    }
597
598    #[test]
599    fn unpriced_policy_block_only_bites_with_a_price_book() {
600        // Default: count (fail-open) — never rejects.
601        let count = runtime();
602        assert!(!count.reject_unpriced("mystery"));
603
604        // block + price book: an unknown model is rejected, a priced one is not.
605        let mut models = BTreeMap::new();
606        models.insert(
607            "gpt-4o".to_string(),
608            ModelPrice {
609                input_per_1m: 2.5,
610                output_per_1m: 10.0,
611                ..Default::default()
612            },
613        );
614        let block = LlmRuntime::build(&LlmCfg {
615            enabled: true,
616            models,
617            on_unpriced_model: "block".into(),
618            ..Default::default()
619        });
620        assert!(block.reject_unpriced("mystery"));
621        assert!(!block.reject_unpriced("gpt-4o"));
622
623        // block with an EMPTY book (metering-only) must never reject — else it breaks all traffic.
624        let block_no_book = LlmRuntime::build(&LlmCfg {
625            enabled: true,
626            on_unpriced_model: "block".into(),
627            ..Default::default()
628        });
629        assert!(!block_no_book.reject_unpriced("anything"));
630    }
631
632    #[test]
633    fn unpriced_policy_parse_rejects_typos() {
634        assert_eq!(
635            UnpricedPolicy::parse("count").unwrap(),
636            UnpricedPolicy::Count
637        );
638        assert_eq!(
639            UnpricedPolicy::parse("block").unwrap(),
640            UnpricedPolicy::Block
641        );
642        assert_eq!(UnpricedPolicy::parse("").unwrap(), UnpricedPolicy::Count);
643        assert!(UnpricedPolicy::parse("banana").is_err());
644    }
645
646    // --- correctness guards for the most common token/cost inflation bug ------------------
647    // These lock in behaviour that is correct by construction, so a future refactor can't
648    // reintroduce the inflation this class of bug produces.
649
650    #[test]
651    fn sse_usage_is_the_last_frame_never_the_sum_of_cumulative_chunks() {
652        // Some providers (notably Gemini) repeat CUMULATIVE usage on every streamed chunk.
653        // An accumulator that SUMS per-chunk usage then reports "massively inflated token
654        // counts". eggrd takes the LAST authoritative frame, never a sum.
655        let stream = "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":10}}\n\n\
656                      data: {\"choices\":[{\"delta\":{\"content\":\"b\"}}],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":25}}\n\n\
657                      data: {\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":60,\"total_tokens\":160}}\n\n\
658                      data: [DONE]\n\n";
659        let u = parse_sse_usage(stream.as_bytes()).expect("terminal usage");
660        assert_eq!(
661            u.prompt_tokens, 100,
662            "prompt must be the last frame, not 300 (summed)"
663        );
664        assert_eq!(
665            u.completion_tokens, 60,
666            "completion must be the last frame, not 95 (summed)"
667        );
668    }
669
670    #[test]
671    fn cached_prompt_tokens_are_never_billed_at_the_output_rate() {
672        // The overstatement this guards against (an order of magnitude on cache-heavy
673        // traffic): cached (and reasoning) tokens get folded into
674        // "completion = total - prompt" and billed at the (much higher) output rate. eggrd
675        // prices each of the four token tiers at its own rate, subtracted from the base, so a
676        // fully-cached prompt is billed at the cached rate — never the output rate.
677        let mut models = BTreeMap::new();
678        models.insert(
679            "m".to_string(),
680            ModelPrice {
681                input_per_1m: 3.00,
682                output_per_1m: 60.00, // 20x the input rate — must never touch cached tokens
683                cached_per_1m: 0.30,  // cached is a tenth of the input rate
684                ..Default::default()
685            },
686        );
687        let rt = LlmRuntime::build(&LlmCfg {
688            enabled: true,
689            models,
690            ..Default::default()
691        });
692        // 1M prompt tokens, entirely served from cache; no completion.
693        let usage = Usage {
694            prompt_tokens: 1_000_000,
695            completion_tokens: 0,
696            cached_tokens: 1_000_000,
697            reasoning_tokens: 0,
698        };
699        // Correct: 1M * $0.30/M = 300_000 micro. The output-rate bug would bill 60_000_000.
700        assert_eq!(rt.cost_micros("m", &usage), Some(300_000));
701    }
702
703    #[test]
704    fn metering_reads_the_usage_object_not_the_request_body_size() {
705        // Multimodal base64 in the *request* must not inflate metered tokens (counting base64
706        // image bytes as text tokens is a common miscount). eggrd meters from the upstream's
707        // authoritative `usage`; the body-length heuristic is only the pre-flight reserve.
708        let rt = runtime();
709        let resp = br#"{"usage":{"prompt_tokens":50,"completion_tokens":10}}"#;
710        let usage = parse_response_usage(resp).expect("usage");
711        assert_eq!(usage.prompt_tokens, 50);
712        // A ~1 MB base64 image would estimate hundreds of thousands of tokens for the RESERVE…
713        let huge_b64_body_len = 4_000_000usize;
714        assert!(estimate_prompt_tokens(huge_b64_body_len) > usage.prompt_tokens);
715        // …but the BILLED cost is the authoritative 50 in / 10 out: 125 + 100 = 225 micro.
716        assert_eq!(rt.cost_micros("gpt-4o", &usage), Some(225));
717    }
718
719    // --- provider/model-alias price normalization (top-20 #15) ---------------------------
720
721    #[test]
722    fn provider_prefixed_model_resolves_to_the_bare_price() {
723        // The #1 cross-competitor cost bug: "openai/gpt-4o" misses a book keyed by "gpt-4o" and
724        // reads $0. We resolve the bare name as a fallback.
725        let rt = runtime(); // book has "gpt-4o"
726        let usage = Usage {
727            prompt_tokens: 1_000,
728            completion_tokens: 0,
729            ..Default::default()
730        };
731        assert!(rt.is_priced("openai/gpt-4o"));
732        assert_eq!(
733            rt.cost_micros("openai/gpt-4o", &usage),
734            rt.cost_micros("gpt-4o", &usage)
735        );
736        // Case-insensitive on the prefix.
737        assert!(rt.is_priced("OpenAI/gpt-4o"));
738    }
739
740    #[test]
741    fn exact_prefixed_entry_wins_over_normalization() {
742        // If the book prices the prefixed id explicitly, that exact entry must win — normalization
743        // is only a fallback, never an override.
744        let mut models = BTreeMap::new();
745        models.insert(
746            "gpt-4o".to_string(),
747            ModelPrice {
748                input_per_1m: 2.50,
749                output_per_1m: 10.00,
750                ..Default::default()
751            },
752        );
753        models.insert(
754            "openai/gpt-4o".to_string(),
755            ModelPrice {
756                input_per_1m: 99.0, // deliberately different so we can tell which entry priced it
757                output_per_1m: 99.0,
758                ..Default::default()
759            },
760        );
761        let rt = LlmRuntime::build(&LlmCfg {
762            enabled: true,
763            models,
764            ..Default::default()
765        });
766        let usage = Usage {
767            prompt_tokens: 1_000_000,
768            completion_tokens: 0,
769            ..Default::default()
770        };
771        assert_eq!(rt.cost_micros("openai/gpt-4o", &usage), Some(99_000_000));
772        assert_eq!(rt.cost_micros("gpt-4o", &usage), Some(2_500_000));
773    }
774
775    #[test]
776    fn unknown_or_huggingface_style_prefix_is_left_unpriced() {
777        // A curated prefix list: an org/model id that isn't a known provider prefix
778        // (HuggingFace-style) must NOT be stripped, so it stays unpriced rather than mis-resolving.
779        let rt = runtime();
780        assert!(!rt.is_priced("meta-llama/Llama-3-8b"));
781        assert_eq!(
782            rt.cost_micros("meta-llama/Llama-3-8b", &Usage::default()),
783            None
784        );
785        // A known prefix over an unknown bare name is still unpriced (nothing to resolve to).
786        assert!(!rt.is_priced("openai/mystery-model"));
787    }
788
789    #[test]
790    fn canonical_model_strips_known_prefixes_for_attribution() {
791        // Budget/rollup attribution: a prefixed request maps to the bare model so it can't escape a
792        // bare-named per-model budget (top-20 #19).
793        assert_eq!(canonical_model("openai/gpt-4o"), "gpt-4o");
794        assert_eq!(canonical_model("azure/gpt-4o"), "gpt-4o");
795        assert_eq!(canonical_model("gpt-4o"), "gpt-4o"); // already bare
796                                                         // An unknown / HuggingFace-style prefix is left intact (not a known provider).
797        assert_eq!(canonical_model("meta-llama/Llama-3"), "meta-llama/Llama-3");
798    }
799
800    #[test]
801    fn block_policy_does_not_reject_a_prefixed_priced_model() {
802        // block + price book: a prefixed alias of a priced model must be served, not 402'd — the
803        // whole point is that "openai/gpt-4o" IS priced under "gpt-4o".
804        let mut models = BTreeMap::new();
805        models.insert(
806            "gpt-4o".to_string(),
807            ModelPrice {
808                input_per_1m: 2.5,
809                output_per_1m: 10.0,
810                ..Default::default()
811            },
812        );
813        let rt = LlmRuntime::build(&LlmCfg {
814            enabled: true,
815            models,
816            on_unpriced_model: "block".into(),
817            ..Default::default()
818        });
819        assert!(!rt.reject_unpriced("openai/gpt-4o"));
820        // A truly-unknown model (prefixed or not) is still rejected.
821        assert!(rt.reject_unpriced("openai/mystery-model"));
822        assert!(rt.reject_unpriced("mystery-model"));
823    }
824}