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}