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
49fn 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
611pub 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}