Skip to main content

lean_ctx/proxy/
counterfactual.rs

1//! Counterfactual savings metering (#701) — provider-authoritative receipts.
2//!
3//! Local tokenizer counts (`o200k_base`) are an *estimate* of what a request
4//! would have cost without lean-ctx. Anthropic's `count_tokens` endpoint is
5//! **free** and takes the identical body shape, so for each rewritten
6//! `/v1/messages` request the proxy can fire a probe with the original,
7//! uncompressed body concurrently with the real forward and read back the
8//! provider-counted answer: "this exact request would have billed N input
9//! tokens". Paired with the billed `usage` block of the same response, that is
10//! a confound-free, provider-authoritative saving per request — the
11//! methodology pxpipe's FAQ documents, adopted per #701.
12//!
13//! Isolation guarantees:
14//! - The probe **never** mutates, delays or fails the forwarded request: it is
15//!   spawned as a detached task and its result lands in a lock-free slot the
16//!   usage recorder reads at response end (streams end seconds later, so the
17//!   probe has long finished; a slow probe merely degrades that row to the
18//!   local estimate).
19//! - Opt-in (`proxy.counterfactual_metering`, default off) and fired only for
20//!   requests the proxy actually rewrote — an untouched body's billed input
21//!   *is* its counterfactual, no probe needed.
22
23use std::sync::Arc;
24use std::sync::atomic::{AtomicU64, Ordering};
25
26use axum::http::request::Parts;
27use serde_json::Value;
28
29/// Probe timeout: `count_tokens` typically answers in well under a second; a
30/// probe slower than the model's own response is useless (the row degrades to
31/// the estimate), so give up early and free the connection.
32const PROBE_TIMEOUT_SECS: u64 = 10;
33
34/// Lock-free result slot shared between the detached probe task and the usage
35/// recorder. `0` means "no provider count" (pending or failed) — a real
36/// `count_tokens` answer is never 0 (`model` + `messages` always tokenize to
37/// something), and [`CounterfactualSlot::set`] clamps to ≥ 1 regardless.
38#[derive(Clone, Debug, Default)]
39pub struct CounterfactualSlot(Arc<AtomicU64>);
40
41impl PartialEq for CounterfactualSlot {
42    fn eq(&self, other: &Self) -> bool {
43        self.get() == other.get()
44    }
45}
46
47impl CounterfactualSlot {
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    pub fn set(&self, tokens: u64) {
53        self.0.store(tokens.max(1), Ordering::Relaxed);
54    }
55
56    pub fn get(&self) -> Option<u64> {
57        match self.0.load(Ordering::Relaxed) {
58            0 => None,
59            n => Some(n),
60        }
61    }
62}
63
64/// The exact parameter set Anthropic's `count_tokens` accepts — it rejects
65/// unknown fields (`max_tokens`, `stream`, `metadata`, … → 400), so the probe
66/// body is a whitelist projection of the original request, never the request
67/// itself.
68const COUNT_TOKENS_FIELDS: &[&str] = &[
69    "model",
70    "messages",
71    "system",
72    "tools",
73    "tool_choice",
74    "thinking",
75];
76
77/// Project the original (pre-compression) request body onto the
78/// `count_tokens` parameter set. `original_model` restores the client's model
79/// when the router rewrote it in place — the counterfactual asks what the
80/// *original* request would have cost. Returns `None` when the body has no
81/// `model`/`messages` (nothing meaningful to count). Read-only: the forwarded
82/// request is never touched (#701 regression contract).
83pub(crate) fn probe_body(original: &Value, original_model: Option<&str>) -> Option<Vec<u8>> {
84    let obj = original.as_object()?;
85    if !obj.contains_key("model") || !obj.contains_key("messages") {
86        return None;
87    }
88    let mut probe = serde_json::Map::new();
89    for &field in COUNT_TOKENS_FIELDS {
90        if let Some(v) = obj.get(field) {
91            probe.insert(field.to_string(), v.clone());
92        }
93    }
94    if let Some(model) = original_model {
95        probe.insert("model".to_string(), Value::String(model.to_string()));
96    }
97    serde_json::to_vec(&Value::Object(probe)).ok()
98}
99
100/// Auth/version headers the probe replays from the client request. Everything
101/// else (content-encoding, content-length, tracing) is request-specific and
102/// must not leak onto the probe.
103const PROBE_HEADERS: &[&str] = &[
104    "x-api-key",
105    "authorization",
106    "anthropic-version",
107    "anthropic-beta",
108];
109
110/// Fire the free `count_tokens` probe for a rewritten Anthropic request, iff
111/// counterfactual metering is enabled. Returns the slot the usage recorder
112/// polls at response end, or `None` when no probe was spawned (feature off,
113/// non-messages path, unparseable body). Never blocks: the probe runs as a
114/// detached task; every failure mode just leaves the slot empty.
115pub(crate) fn maybe_spawn_probe(
116    client: &reqwest::Client,
117    parts: &Parts,
118    upstream_base: &str,
119    original: Option<&Value>,
120    original_model: Option<&str>,
121    request_was_rewritten: bool,
122) -> Option<CounterfactualSlot> {
123    if !request_was_rewritten
124        || !parts
125            .uri
126            .path()
127            .trim_end_matches('/')
128            .ends_with("/v1/messages")
129        || !crate::core::config::Config::load()
130            .proxy
131            .counterfactual_metering_enabled()
132    {
133        return None;
134    }
135    let body = probe_body(original?, original_model)?;
136
137    let url = format!(
138        "{}/v1/messages/count_tokens",
139        upstream_base.trim_end_matches('/')
140    );
141    let mut req = client
142        .post(&url)
143        .timeout(std::time::Duration::from_secs(PROBE_TIMEOUT_SECS))
144        .header("content-type", "application/json")
145        .body(body);
146    for &name in PROBE_HEADERS {
147        if let Some(v) = parts.headers.get(name) {
148            req = req.header(name, v.clone());
149        }
150    }
151
152    let slot = CounterfactualSlot::new();
153    let task_slot = slot.clone();
154    tokio::spawn(async move {
155        match req.send().await {
156            Ok(resp) if resp.status().is_success() => match resp.json::<Value>().await {
157                Ok(v) => {
158                    if let Some(tokens) = v.get("input_tokens").and_then(Value::as_u64) {
159                        task_slot.set(tokens);
160                    } else {
161                        tracing::debug!("counterfactual probe: response without input_tokens");
162                    }
163                }
164                Err(e) => tracing::debug!("counterfactual probe: unreadable response: {e}"),
165            },
166            Ok(resp) => tracing::debug!(
167                "counterfactual probe: count_tokens returned {}",
168                resp.status()
169            ),
170            Err(e) => tracing::debug!("counterfactual probe: {e}"),
171        }
172    });
173    Some(slot)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use serde_json::json;
180
181    fn full_request() -> Value {
182        json!({
183            "model": "claude-sonnet-4",
184            "messages": [{"role": "user", "content": "hello"}],
185            "system": "be terse",
186            "tools": [{"name": "get_weather", "input_schema": {"type": "object"}}],
187            "tool_choice": {"type": "auto"},
188            "thinking": {"type": "enabled", "budget_tokens": 1024},
189            "max_tokens": 4096,
190            "stream": true,
191            "temperature": 0.7,
192            "metadata": {"user_id": "u1"}
193        })
194    }
195
196    #[test]
197    fn probe_body_is_the_count_tokens_whitelist() {
198        let body = probe_body(&full_request(), None).expect("probe body");
199        let v: Value = serde_json::from_slice(&body).unwrap();
200        let obj = v.as_object().unwrap();
201
202        // Everything count_tokens accepts is carried over…
203        for field in COUNT_TOKENS_FIELDS {
204            assert!(obj.contains_key(*field), "{field} must be projected");
205        }
206        // …and everything it rejects with a 400 is dropped.
207        for rejected in ["max_tokens", "stream", "temperature", "metadata"] {
208            assert!(!obj.contains_key(rejected), "{rejected} must be stripped");
209        }
210        assert_eq!(obj["model"], "claude-sonnet-4");
211    }
212
213    #[test]
214    fn probe_body_restores_the_prerouting_model() {
215        // The router downgraded the model in the body; the counterfactual asks
216        // what the ORIGINAL request would have cost.
217        let mut req = full_request();
218        req["model"] = json!("claude-haiku-3.5");
219        let body = probe_body(&req, Some("claude-sonnet-4")).unwrap();
220        let v: Value = serde_json::from_slice(&body).unwrap();
221        assert_eq!(v["model"], "claude-sonnet-4");
222    }
223
224    #[test]
225    fn probe_body_requires_model_and_messages() {
226        assert!(probe_body(&json!({"messages": []}), None).is_none());
227        assert!(probe_body(&json!({"model": "m"}), None).is_none());
228        assert!(probe_body(&json!("not an object"), None).is_none());
229    }
230
231    #[test]
232    fn slot_roundtrip_and_zero_means_empty() {
233        let slot = CounterfactualSlot::new();
234        assert_eq!(slot.get(), None, "fresh slot is empty");
235        slot.set(1234);
236        assert_eq!(slot.get(), Some(1234));
237        // A pathological 0 from the provider is clamped, never read as empty.
238        let zero = CounterfactualSlot::new();
239        zero.set(0);
240        assert_eq!(zero.get(), Some(1));
241    }
242
243    #[test]
244    fn slots_share_state_across_clones() {
245        // The forward path clones the slot into WireContext; the probe task
246        // writes through its own clone. Both must observe the same cell.
247        let slot = CounterfactualSlot::new();
248        let clone = slot.clone();
249        clone.set(77);
250        assert_eq!(slot.get(), Some(77));
251    }
252}