Skip to main content

lean_ctx/proxy/
introspect.rs

1use serde::Serialize;
2use serde_json::Value;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Mutex;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
7#[serde(rename_all = "snake_case")]
8pub enum Provider {
9    Anthropic,
10    OpenAi,
11    Gemini,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct RequestBreakdown {
16    pub provider: Provider,
17    pub model: String,
18    pub system_prompt_tokens: usize,
19    pub user_message_tokens: usize,
20    pub assistant_message_tokens: usize,
21    pub tool_definition_tokens: usize,
22    pub tool_definition_count: usize,
23    pub tool_result_tokens: usize,
24    pub image_count: usize,
25    pub total_input_tokens: usize,
26    pub message_count: usize,
27    #[serde(default)]
28    pub rules_tokens: usize,
29    #[serde(default)]
30    pub skills_tokens: usize,
31    #[serde(default)]
32    pub mcp_config_tokens: usize,
33    #[serde(default)]
34    pub subagent_tokens: usize,
35    #[serde(default)]
36    pub summarized_conversation_tokens: usize,
37    #[serde(default)]
38    pub conversation_tokens: usize,
39}
40
41pub fn analyze_request(body: &Value, provider: Provider) -> RequestBreakdown {
42    match provider {
43        Provider::Anthropic => analyze_anthropic(body),
44        Provider::OpenAi => analyze_openai(body),
45        Provider::Gemini => analyze_gemini(body),
46    }
47}
48
49/// IDE clients (Cursor, Copilot) often send routing IDs like "model-0", "model-4"
50/// instead of real model names. We keep track of the last real model name per provider
51/// and fall back to it when we see a generic routing ID.
52fn normalize_model(raw: &str, provider: Provider) -> String {
53    use std::sync::Mutex;
54    static LAST_REAL: Mutex<[Option<String>; 3]> = Mutex::new([None, None, None]);
55
56    let is_routing_id = raw.starts_with("model-") || raw == "unknown" || raw.is_empty();
57
58    let idx = match provider {
59        Provider::Anthropic => 0,
60        Provider::OpenAi => 1,
61        Provider::Gemini => 2,
62    };
63
64    if is_routing_id {
65        if let Ok(guard) = LAST_REAL.lock() {
66            if let Some(ref real) = guard[idx] {
67                return real.clone();
68            }
69        }
70        return raw.to_string();
71    }
72
73    if let Ok(mut guard) = LAST_REAL.lock() {
74        guard[idx] = Some(raw.to_string());
75    }
76    raw.to_string()
77}
78
79fn analyze_anthropic(body: &Value) -> RequestBreakdown {
80    let raw_model = body
81        .get("model")
82        .and_then(|m| m.as_str())
83        .unwrap_or("unknown");
84    let model = normalize_model(raw_model, Provider::Anthropic);
85
86    let mut system_prompt_tokens = 0;
87    let mut rules_tokens = 0;
88    let mut skills_tokens = 0;
89    let mut mcp_config_tokens = 0;
90
91    match body.get("system") {
92        Some(Value::String(s)) => {
93            let sp = classify_system_prompt(s);
94            system_prompt_tokens = sp.base;
95            rules_tokens = sp.rules;
96            skills_tokens = sp.skills;
97            mcp_config_tokens = sp.mcp;
98        }
99        Some(Value::Array(arr)) => {
100            for block in arr {
101                let text = block.get("text").and_then(|t| t.as_str()).unwrap_or("");
102                let sp = classify_system_prompt(text);
103                system_prompt_tokens += sp.base;
104                rules_tokens += sp.rules;
105                skills_tokens += sp.skills;
106                mcp_config_tokens += sp.mcp;
107            }
108        }
109        _ => {}
110    }
111
112    let tool_definition_tokens = body
113        .get("tools")
114        .and_then(|t| t.as_array())
115        .map_or(0, |arr| json_chars(arr) / 4);
116
117    let tool_definition_count = body
118        .get("tools")
119        .and_then(|t| t.as_array())
120        .map_or(0, Vec::len);
121
122    let mut user_message_tokens = 0;
123    let mut assistant_message_tokens = 0;
124    let mut tool_result_tokens = 0;
125    let mut image_count = 0;
126    let mut message_count = 0;
127    let mut subagent_tokens = 0;
128    let mut summarized_conversation_tokens = 0;
129
130    if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
131        message_count = messages.len();
132        for msg in messages {
133            let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
134            let content_tokens = estimate_content_tokens(msg.get("content"));
135            let has_images = count_images(msg.get("content"));
136            image_count += has_images;
137
138            match role {
139                "user" => {
140                    if has_tool_results(msg.get("content")) {
141                        tool_result_tokens += content_tokens;
142                    } else if is_summary_message(msg.get("content")) {
143                        summarized_conversation_tokens += content_tokens;
144                    } else if is_subagent_message(msg.get("content")) {
145                        subagent_tokens += content_tokens;
146                    } else {
147                        user_message_tokens += content_tokens;
148                    }
149                }
150                "assistant" => assistant_message_tokens += content_tokens,
151                _ => user_message_tokens += content_tokens,
152            }
153        }
154    }
155
156    let conversation_tokens = user_message_tokens + assistant_message_tokens;
157
158    let total_input_tokens = system_prompt_tokens
159        + rules_tokens
160        + skills_tokens
161        + mcp_config_tokens
162        + user_message_tokens
163        + assistant_message_tokens
164        + tool_definition_tokens
165        + tool_result_tokens
166        + subagent_tokens
167        + summarized_conversation_tokens;
168
169    RequestBreakdown {
170        provider: Provider::Anthropic,
171        model,
172        system_prompt_tokens,
173        user_message_tokens,
174        assistant_message_tokens,
175        tool_definition_tokens,
176        tool_definition_count,
177        tool_result_tokens,
178        image_count,
179        total_input_tokens,
180        message_count,
181        rules_tokens,
182        skills_tokens,
183        mcp_config_tokens,
184        subagent_tokens,
185        summarized_conversation_tokens,
186        conversation_tokens,
187    }
188}
189
190fn analyze_openai(body: &Value) -> RequestBreakdown {
191    let raw_model = body
192        .get("model")
193        .and_then(|m| m.as_str())
194        .unwrap_or("unknown");
195    let model = normalize_model(raw_model, Provider::OpenAi);
196
197    let mut system_prompt_tokens = 0;
198    let mut rules_tokens = 0;
199    let mut skills_tokens = 0;
200    let mut mcp_config_tokens = 0;
201    let mut user_message_tokens = 0;
202    let mut assistant_message_tokens = 0;
203    let mut tool_result_tokens = 0;
204    let mut image_count = 0;
205    let mut message_count = 0;
206    let mut subagent_tokens = 0;
207    let mut summarized_conversation_tokens = 0;
208
209    if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
210        message_count = messages.len();
211        for msg in messages {
212            let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
213            let content_tokens = estimate_content_tokens(msg.get("content"));
214            image_count += count_images(msg.get("content"));
215
216            match role {
217                "system" | "developer" => {
218                    let text = extract_text_content(msg.get("content"));
219                    let sp = classify_system_prompt(&text);
220                    system_prompt_tokens += sp.base;
221                    rules_tokens += sp.rules;
222                    skills_tokens += sp.skills;
223                    mcp_config_tokens += sp.mcp;
224                }
225                "assistant" => assistant_message_tokens += content_tokens,
226                "tool" => tool_result_tokens += content_tokens,
227                _ => {
228                    if is_summary_message(msg.get("content")) {
229                        summarized_conversation_tokens += content_tokens;
230                    } else if is_subagent_message(msg.get("content")) {
231                        subagent_tokens += content_tokens;
232                    } else {
233                        user_message_tokens += content_tokens;
234                    }
235                }
236            }
237        }
238    }
239
240    let tool_definition_tokens = body
241        .get("tools")
242        .and_then(|t| t.as_array())
243        .map_or(0, |arr| json_chars(arr) / 4);
244
245    let tool_definition_count = body
246        .get("tools")
247        .and_then(|t| t.as_array())
248        .map_or(0, Vec::len);
249
250    let conversation_tokens = user_message_tokens + assistant_message_tokens;
251
252    let total_input_tokens = system_prompt_tokens
253        + rules_tokens
254        + skills_tokens
255        + mcp_config_tokens
256        + user_message_tokens
257        + assistant_message_tokens
258        + tool_definition_tokens
259        + tool_result_tokens
260        + subagent_tokens
261        + summarized_conversation_tokens;
262
263    RequestBreakdown {
264        provider: Provider::OpenAi,
265        model,
266        system_prompt_tokens,
267        user_message_tokens,
268        assistant_message_tokens,
269        tool_definition_tokens,
270        tool_definition_count,
271        tool_result_tokens,
272        image_count,
273        total_input_tokens,
274        message_count,
275        rules_tokens,
276        skills_tokens,
277        mcp_config_tokens,
278        subagent_tokens,
279        summarized_conversation_tokens,
280        conversation_tokens,
281    }
282}
283
284fn analyze_gemini(body: &Value) -> RequestBreakdown {
285    let model = "gemini".to_string();
286
287    let system_prompt_tokens = body
288        .get("systemInstruction")
289        .and_then(|si| si.get("parts"))
290        .and_then(|p| p.as_array())
291        .map_or(0, |parts| {
292            parts
293                .iter()
294                .map(|p| p.get("text").and_then(|t| t.as_str()).map_or(0, str::len))
295                .sum::<usize>()
296                / 4
297        });
298
299    let mut user_message_tokens = 0;
300    let mut assistant_message_tokens = 0;
301    let mut tool_result_tokens = 0;
302    let mut message_count = 0;
303
304    if let Some(contents) = body.get("contents").and_then(|c| c.as_array()) {
305        message_count = contents.len();
306        for content in contents {
307            let role = content
308                .get("role")
309                .and_then(|r| r.as_str())
310                .unwrap_or("user");
311            let parts_tokens = content
312                .get("parts")
313                .and_then(|p| p.as_array())
314                .map_or(0, |parts| {
315                    parts
316                        .iter()
317                        .map(|p| {
318                            if p.get("functionResponse").is_some() {
319                                json_chars(std::slice::from_ref(p)) / 4
320                            } else {
321                                p.get("text")
322                                    .and_then(|t| t.as_str())
323                                    .map_or(0, |s| chars_to_tokens(s.len()))
324                            }
325                        })
326                        .sum::<usize>()
327                });
328
329            let has_fn_response = content
330                .get("parts")
331                .and_then(|p| p.as_array())
332                .is_some_and(|parts| parts.iter().any(|p| p.get("functionResponse").is_some()));
333
334            if has_fn_response {
335                tool_result_tokens += parts_tokens;
336            } else {
337                match role {
338                    "model" => assistant_message_tokens += parts_tokens,
339                    _ => user_message_tokens += parts_tokens,
340                }
341            }
342        }
343    }
344
345    let tool_definition_tokens = body
346        .get("tools")
347        .and_then(|t| t.as_array())
348        .map_or(0, |arr| json_chars(arr) / 4);
349
350    let tool_definition_count = body
351        .get("tools")
352        .and_then(|t| t.as_array())
353        .map_or(0, |arr| {
354            arr.iter()
355                .filter_map(|t| t.get("functionDeclarations").and_then(|f| f.as_array()))
356                .map(Vec::len)
357                .sum()
358        });
359
360    let total_input_tokens = system_prompt_tokens
361        + user_message_tokens
362        + assistant_message_tokens
363        + tool_definition_tokens
364        + tool_result_tokens;
365
366    let conversation_tokens = user_message_tokens + assistant_message_tokens;
367
368    RequestBreakdown {
369        provider: Provider::Gemini,
370        model,
371        system_prompt_tokens,
372        user_message_tokens,
373        assistant_message_tokens,
374        tool_definition_tokens,
375        tool_definition_count,
376        tool_result_tokens,
377        image_count: 0,
378        total_input_tokens,
379        message_count,
380        rules_tokens: 0,
381        skills_tokens: 0,
382        mcp_config_tokens: 0,
383        subagent_tokens: 0,
384        summarized_conversation_tokens: 0,
385        conversation_tokens,
386    }
387}
388
389fn chars_to_tokens(chars: usize) -> usize {
390    chars / 4
391}
392
393fn json_chars(arr: &[Value]) -> usize {
394    arr.iter().map(|v| v.to_string().len()).sum()
395}
396
397fn estimate_content_tokens(content: Option<&Value>) -> usize {
398    match content {
399        Some(Value::String(s)) => chars_to_tokens(s.len()),
400        Some(Value::Array(arr)) => arr
401            .iter()
402            .map(|block| {
403                if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
404                    chars_to_tokens(text.len())
405                } else {
406                    block.to_string().len() / 4
407                }
408            })
409            .sum(),
410        Some(v) => v.to_string().len() / 4,
411        None => 0,
412    }
413}
414
415fn count_images(content: Option<&Value>) -> usize {
416    match content {
417        Some(Value::Array(arr)) => arr
418            .iter()
419            .filter(|block| {
420                block.get("type").and_then(|t| t.as_str()) == Some("image")
421                    || block.get("type").and_then(|t| t.as_str()) == Some("image_url")
422            })
423            .count(),
424        _ => 0,
425    }
426}
427
428struct SystemPromptParts {
429    base: usize,
430    rules: usize,
431    skills: usize,
432    mcp: usize,
433}
434
435fn classify_system_prompt(text: &str) -> SystemPromptParts {
436    let mut rules = 0usize;
437    let mut skills = 0usize;
438    let mut mcp = 0usize;
439    let mut base = 0usize;
440
441    let rule_markers = [
442        "<always_applied_workspace_rule",
443        "<user_rule",
444        ".cursorrules",
445        "AGENTS.md",
446        ".mdc",
447        "workspace_rule",
448        "cursor_rules",
449        "CLAUDE.md",
450        "<rules>",
451    ];
452    let skill_markers = [
453        "<agent_skill",
454        "<available_skills",
455        "SKILL.md",
456        "skills-cursor",
457        "agent_skills",
458    ];
459    let mcp_markers = [
460        "<mcp_file_system",
461        "mcp_server",
462        "MCP server",
463        "CallMcpTool",
464        "FetchMcpResource",
465        "<mcp_file_system_server",
466    ];
467
468    for line in text.lines() {
469        let tok = chars_to_tokens(line.len() + 1);
470        let l = line.trim();
471
472        if rule_markers.iter().any(|m| l.contains(m)) {
473            rules += tok;
474        } else if skill_markers.iter().any(|m| l.contains(m)) {
475            skills += tok;
476        } else if mcp_markers.iter().any(|m| l.contains(m)) {
477            mcp += tok;
478        } else {
479            base += tok;
480        }
481    }
482
483    SystemPromptParts {
484        base,
485        rules,
486        skills,
487        mcp,
488    }
489}
490
491fn is_summary_message(content: Option<&Value>) -> bool {
492    let text = extract_text_content(content);
493    text.contains("[Previous conversation summary]")
494        || text.contains("conversation summary")
495        || text.contains("Here is a summary of the conversation")
496        || text.contains("summarized conversation")
497}
498
499fn is_subagent_message(content: Option<&Value>) -> bool {
500    let text = extract_text_content(content);
501    text.contains("subagent")
502        || text.contains("background agent")
503        || text.contains("<task>")
504        || text.contains("system_notification")
505}
506
507fn extract_text_content(content: Option<&Value>) -> String {
508    match content {
509        Some(Value::String(s)) => s.clone(),
510        Some(Value::Array(arr)) => arr
511            .iter()
512            .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
513            .collect::<Vec<_>>()
514            .join(" "),
515        _ => String::new(),
516    }
517}
518
519fn has_tool_results(content: Option<&Value>) -> bool {
520    match content {
521        Some(Value::Array(arr)) => arr
522            .iter()
523            .any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")),
524        _ => false,
525    }
526}
527
528pub struct IntrospectState {
529    pub last_breakdown: Mutex<Option<RequestBreakdown>>,
530    pub total_system_prompt_tokens: AtomicU64,
531    pub total_requests: AtomicU64,
532    last_persist_epoch: AtomicU64,
533}
534
535impl Default for IntrospectState {
536    fn default() -> Self {
537        Self {
538            last_breakdown: Mutex::new(None),
539            total_system_prompt_tokens: AtomicU64::new(0),
540            total_requests: AtomicU64::new(0),
541            last_persist_epoch: AtomicU64::new(0),
542        }
543    }
544}
545
546impl IntrospectState {
547    pub fn record(&self, breakdown: RequestBreakdown) {
548        self.total_system_prompt_tokens.fetch_add(
549            (breakdown.system_prompt_tokens
550                + breakdown.rules_tokens
551                + breakdown.skills_tokens
552                + breakdown.mcp_config_tokens) as u64,
553            Ordering::Relaxed,
554        );
555        self.total_requests.fetch_add(1, Ordering::Relaxed);
556        if let Ok(mut last) = self.last_breakdown.lock() {
557            *last = Some(breakdown);
558        }
559        self.maybe_persist();
560    }
561
562    fn maybe_persist(&self) {
563        let now = std::time::SystemTime::now()
564            .duration_since(std::time::UNIX_EPOCH)
565            .unwrap_or_default()
566            .as_secs();
567        let prev = self.last_persist_epoch.load(Ordering::Relaxed);
568        if now <= prev {
569            return;
570        }
571        if self
572            .last_persist_epoch
573            .compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed)
574            .is_err()
575        {
576            return;
577        }
578        self.persist(now);
579    }
580
581    fn persist(&self, ts: u64) {
582        let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
583            return;
584        };
585        let breakdown_val = self
586            .last_breakdown
587            .lock()
588            .ok()
589            .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
590            .flatten();
591        let payload = serde_json::json!({
592            "ts": ts,
593            "proxy_active": true,
594            "last_breakdown": breakdown_val,
595            "cumulative": {
596                "total_requests": self.total_requests.load(Ordering::Relaxed),
597                "total_system_prompt_tokens": self.total_system_prompt_tokens.load(Ordering::Relaxed),
598            }
599        });
600
601        let target = data_dir.join("proxy-introspect.json");
602        let tmp = data_dir.join("proxy-introspect.json.tmp");
603        if let Ok(json) = serde_json::to_string_pretty(&payload) {
604            if std::fs::write(&tmp, &json).is_ok() {
605                let _ = std::fs::rename(&tmp, &target);
606            }
607        }
608    }
609}
610
611/// Load persisted proxy introspection data from disk.
612/// Returns `None` if the file doesn't exist or is stale (> `max_age_secs`).
613pub fn load_persisted(max_age_secs: u64) -> Option<serde_json::Value> {
614    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
615    let path = data_dir.join("proxy-introspect.json");
616    let content = std::fs::read_to_string(&path).ok()?;
617    let val: serde_json::Value = serde_json::from_str(&content).ok()?;
618
619    let ts = val
620        .get("ts")
621        .and_then(serde_json::Value::as_u64)
622        .unwrap_or(0);
623    let now = std::time::SystemTime::now()
624        .duration_since(std::time::UNIX_EPOCH)
625        .unwrap_or_default()
626        .as_secs();
627    if now.saturating_sub(ts) > max_age_secs {
628        return None;
629    }
630    Some(val)
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    #[test]
638    fn anthropic_basic() {
639        let body = serde_json::json!({
640            "model": "claude-sonnet-4-20250514",
641            "system": "You are a helpful assistant.",
642            "messages": [
643                {"role": "user", "content": "Hello"},
644                {"role": "assistant", "content": "Hi there!"}
645            ],
646            "tools": [{"name": "read", "description": "Read a file", "input_schema": {}}]
647        });
648        let b = analyze_request(&body, Provider::Anthropic);
649        assert_eq!(b.provider, Provider::Anthropic);
650        assert!(b.system_prompt_tokens > 0);
651        assert_eq!(b.message_count, 2);
652        assert!(b.user_message_tokens > 0);
653        assert!(b.assistant_message_tokens > 0);
654        assert_eq!(b.tool_definition_count, 1);
655        assert!(b.tool_definition_tokens > 0);
656    }
657
658    #[test]
659    fn openai_system_message() {
660        let body = serde_json::json!({
661            "model": "gpt-4o",
662            "messages": [
663                {"role": "system", "content": "System prompt here"},
664                {"role": "user", "content": "Hello"},
665                {"role": "tool", "content": "tool result data", "tool_call_id": "x"}
666            ]
667        });
668        let b = analyze_request(&body, Provider::OpenAi);
669        assert!(b.system_prompt_tokens > 0);
670        assert!(b.user_message_tokens > 0);
671        assert!(b.tool_result_tokens > 0);
672        assert_eq!(b.message_count, 3);
673    }
674
675    #[test]
676    fn gemini_system_instruction() {
677        let body = serde_json::json!({
678            "systemInstruction": {
679                "parts": [{"text": "Be concise and helpful to the user at all times."}]
680            },
681            "contents": [
682                {"role": "user", "parts": [{"text": "What is the meaning of life and everything?"}]},
683                {"role": "model", "parts": [{"text": "The answer is 42 according to Douglas Adams."}]}
684            ]
685        });
686        let b = analyze_request(&body, Provider::Gemini);
687        assert!(b.system_prompt_tokens > 0);
688        assert!(b.user_message_tokens > 0);
689        assert!(b.assistant_message_tokens > 0);
690        assert_eq!(b.message_count, 2);
691    }
692}