lean_ctx/proxy/
counterfactual.rs1use std::sync::Arc;
24use std::sync::atomic::{AtomicU64, Ordering};
25
26use axum::http::request::Parts;
27use serde_json::Value;
28
29const PROBE_TIMEOUT_SECS: u64 = 10;
33
34#[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
64const COUNT_TOKENS_FIELDS: &[&str] = &[
69 "model",
70 "messages",
71 "system",
72 "tools",
73 "tool_choice",
74 "thinking",
75];
76
77pub(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
100const PROBE_HEADERS: &[&str] = &[
104 "x-api-key",
105 "authorization",
106 "anthropic-version",
107 "anthropic-beta",
108];
109
110pub(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 for field in COUNT_TOKENS_FIELDS {
204 assert!(obj.contains_key(*field), "{field} must be projected");
205 }
206 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 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 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 let slot = CounterfactualSlot::new();
248 let clone = slot.clone();
249 clone.set(77);
250 assert_eq!(slot.get(), Some(77));
251 }
252}