Skip to main content

memra_server/
metering.rs

1//! The admission/accounting seam (lane engine-billing-extraction-20260829).
2//!
3//! The server's job at this boundary is to ADMIT, DENY, and REPORT COUNTS. What
4//! admission means — budgets, prices, tenancy policy — is the deployment's business,
5//! supplied behind these traits. The stock binary wires the in-repo reference
6//! implementation (`ledger::ReferenceMetering`); a deployment-owned binary can wire its own.
7//! Everything here speaks tokens and verdicts, never money: the vocabulary is the
8//! boundary, and it is what lets the policy half live outside this crate.
9//!
10//! The seam is `pub`: a deployment-owned binary wires its implementation through
11//! [`crate::ServerWiring`] and runs the same server.
12
13use std::any::Any;
14
15/// An opaque reservation handle minted by [`Metering::reserve`] and consumed by
16/// [`Metering::open`] of the SAME implementation. The server carries it between the
17/// two calls without looking inside; dropping it un-consumed must release whatever
18/// it holds (the reference implementation refunds on drop).
19pub type Permit = Box<dyn Any + Send>;
20
21/// The request identity a receipt is opened with. All borrowed: this is a view of
22/// state the handler already owns, taken for the duration of one `open` call.
23pub struct RequestMeta<'a> {
24    pub request_id: &'a str,
25    pub tenant: &'a str,
26    /// The authenticated key's non-secret identification prefix, when the request
27    /// carries per-key identity (multi-key ring). What per-PRINCIPAL policy means —
28    /// spend caps, quotas — is the implementation's business.
29    pub principal: Option<&'a str>,
30    pub model: &'a str,
31    pub route: &'static str,
32    pub lane: &'static str,
33    pub stream: bool,
34}
35
36/// Token counts as the worker measured them. The one usage shape that crosses the
37/// seam; anything priced is derived from these on the implementation's side.
38#[derive(Debug, Clone, Copy, Default)]
39pub struct UsageCounts {
40    pub prompt_tokens: u64,
41    pub cached_prompt_tokens: u64,
42    pub completion_tokens: u64,
43}
44
45/// Why admission said no. Mirrors the HTTP contract the handlers already speak:
46/// `Insufficient`/`Blocked` answer 402 (one recovery action for callers),
47/// `Unenrolled` answers 402 with its own code, `Unavailable` is the fail-closed 500.
48#[derive(Debug, PartialEq, Eq)]
49pub enum AdmitError {
50    Insufficient,
51    Blocked,
52    Unenrolled,
53    /// The PRINCIPAL (per-key) spend ceiling is reached while the tenant itself may
54    /// still have balance. Its own 402 code: the caller's recovery is raising or
55    /// clearing the key's cap, not adding credit.
56    PrincipalCapped,
57    Unavailable(String),
58}
59
60/// Limits-source health for the operator metrics surface. Counts only.
61#[derive(Debug, Clone, Copy)]
62pub struct LimitsHealth {
63    pub source_reload_failed: u64,
64    pub source_reload_consecutive: u32,
65    pub source_available: bool,
66}
67
68/// Per-deployment admission + usage accounting. One object, present iff the
69/// deployment configured accounting at all (`AppState.metering: Option<Arc<dyn ..>>`
70/// mirrors the old `request_ledger: Option<Ledger>` exactly).
71pub trait Metering: Send + Sync {
72    /// Whether per-tenant admission limits are configured at all. `false` = every
73    /// authenticated tenant is admitted without reservation (counting may still run).
74    fn enforces_limits(&self) -> bool;
75
76    /// Whether this tenant is subject to limits. With limits enforced, an unknown
77    /// tenant is NOT a free pass — the caller rejects it as unenrolled.
78    fn is_limited(&self, tenant: &str) -> Result<bool, AdmitError>;
79
80    /// Reserve headroom for a request's worst case, in tokens. `Ok(Some(permit))`
81    /// rides the receipt and is settled to worker-truth usage; `Ok(None)` means the
82    /// implementation needs no per-request hold. `principal` is the authenticated
83    /// key's non-secret prefix when the request carries one — the hook for per-key
84    /// policy (spend caps) on the implementation's side.
85    fn reserve(
86        &self,
87        tenant: &str,
88        principal: Option<&str>,
89        model: &str,
90        prompt_tokens: u64,
91        completion_bound: u64,
92    ) -> Result<Option<Permit>, AdmitError>;
93
94    /// Open the request's usage receipt. Every terminal outcome settles it through
95    /// one of the [`Receipt`] methods; dropping it unfinalized is the abandoned-client
96    /// path and must stay safe (the reference implementation prices the partial).
97    fn open(&self, meta: &RequestMeta<'_>, permit: Option<Permit>) -> Box<dyn Receipt>;
98
99    /// Whether this tenant's requests are captured (a retention policy the
100    /// implementation owns). The one pre-receipt check handlers make so an unmarked
101    /// tenant never pays for a prompt copy; the receipt's own [`Receipt::wants_capture`]
102    /// is the post-open gate and the implementation's settle-time re-check stays
103    /// authoritative.
104    fn captures(&self, _tenant: &str) -> bool {
105        false
106    }
107
108    /// Limits-source health for the operator metrics surface, when limits exist.
109    fn limits_health(&self) -> Option<LimitsHealth>;
110
111    /// The graceful-drain deadline expired with requests still in flight: everything
112    /// dropped from this moment on was killed by OUR shutdown, not abandoned by its
113    /// client. Fault attribution (owner ruling 2026-08-23): the implementation must
114    /// settle those drops without billing the caller. Latched — the process is exiting.
115    fn drain_kill(&self) {}
116}
117
118/// One request's accounting record, admission row to terminal row. Method names
119/// deliberately match the reference implementation's inherent methods so the
120/// handler code reads identically through the seam.
121pub trait Receipt: Send {
122    /// Whether this receipt was opened captured. Gates the caller's lazy prompt build;
123    /// `false` makes `arm_capture` a no-op.
124    fn wants_capture(&self) -> bool {
125        false
126    }
127    /// Attach the prompt payload to a captured request. Where it goes and the
128    /// settle-time consent re-check belong to the implementation; the seam never
129    /// names a storage type.
130    fn arm_capture(&mut self, prompt: serde_json::Value);
131    fn capture_completion_delta(&mut self, text: &str);
132    fn record_prompt_usage(
133        &mut self,
134        prompt_tokens: u64,
135        cached_prompt_tokens: u64,
136    ) -> Result<(), String>;
137    fn record_completion_token(&mut self) -> Result<(), String>;
138    fn complete(&mut self, usage: UsageCounts, worker_elapsed_s: f64) -> Result<(), String>;
139    /// Deadline-partial: billed like `complete` but census-distinct.
140    fn complete_deadline_partial(
141        &mut self,
142        usage: UsageCounts,
143        worker_elapsed_s: f64,
144    ) -> Result<(), String>;
145    fn reject(&mut self, status: u16, error_code: &str) -> Result<(), String>;
146    /// Terminal rows with a NAMED zero-debit outcome (`deadline_exceeded`,
147    /// `shed_deadline`, `shed_queue`) — `reject`'s twin for outcomes the census
148    /// distinguishes. Never bills.
149    fn settle_unbilled(
150        &mut self,
151        outcome: &'static str,
152        status: u16,
153        error_code: &str,
154    ) -> Result<(), String>;
155}
156
157/// What a metering factory gets to see at construction time: the server's loaded
158/// model roster. Deliberately small — an implementation brings its own prices,
159/// policies, and storage; the server only vouches for what it serves.
160pub struct MeteringInit<'a> {
161    pub models: &'a [String],
162}
163
164/// Deployment hook: build the metering implementation once models are loaded.
165/// `Ok(None)` = no accounting (the stock no-ledger shape). An `Err` is a startup
166/// FATAL — accounting configuration never fails open.
167pub type MeteringFactory = Box<
168    dyn FnOnce(&MeteringInit<'_>) -> Result<Option<std::sync::Arc<dyn Metering>>, String> + Send,
169>;