1use serde::Serialize;
3use serde_json::Value;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Mutex, OnceLock};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum Provider {
10 Anthropic,
11 OpenAi,
12 Gemini,
13}
14
15#[derive(Debug, Clone, Serialize)]
16pub struct RequestBreakdown {
17 pub provider: Provider,
18 pub model: String,
19 pub system_prompt_tokens: usize,
20 pub user_message_tokens: usize,
21 pub assistant_message_tokens: usize,
22 pub tool_definition_tokens: usize,
23 pub tool_definition_count: usize,
24 pub tool_result_tokens: usize,
25 pub image_count: usize,
26 pub total_input_tokens: usize,
27 pub message_count: usize,
28 #[serde(default)]
29 pub rules_tokens: usize,
30 #[serde(default)]
31 pub skills_tokens: usize,
32 #[serde(default)]
33 pub mcp_config_tokens: usize,
34 #[serde(default)]
35 pub subagent_tokens: usize,
36 #[serde(default)]
37 pub summarized_conversation_tokens: usize,
38 #[serde(default)]
39 pub conversation_tokens: usize,
40}
41
42pub fn analyze_request(body: &Value, provider: Provider) -> RequestBreakdown {
43 match provider {
44 Provider::Anthropic => analyze_anthropic(body),
45 Provider::OpenAi => analyze_openai(body),
46 Provider::Gemini => analyze_gemini(body),
47 }
48}
49
50fn normalize_model(raw: &str, provider: Provider) -> String {
54 use std::sync::Mutex;
55 static LAST_REAL: Mutex<[Option<String>; 3]> = Mutex::new([None, None, None]);
56
57 let is_routing_id = raw.starts_with("model-") || raw == "unknown" || raw.is_empty();
58
59 let idx = match provider {
60 Provider::Anthropic => 0,
61 Provider::OpenAi => 1,
62 Provider::Gemini => 2,
63 };
64
65 if is_routing_id {
66 if let Ok(guard) = LAST_REAL.lock()
67 && let Some(ref real) = guard[idx]
68 {
69 return real.clone();
70 }
71 return raw.to_string();
72 }
73
74 if let Ok(mut guard) = LAST_REAL.lock() {
75 guard[idx] = Some(raw.to_string());
76 }
77 raw.to_string()
78}
79
80fn analyze_anthropic(body: &Value) -> RequestBreakdown {
81 let raw_model = body
82 .get("model")
83 .and_then(|m| m.as_str())
84 .unwrap_or("unknown");
85 let model = normalize_model(raw_model, Provider::Anthropic);
86
87 let mut system_prompt_tokens = 0;
88 let mut rules_tokens = 0;
89 let mut skills_tokens = 0;
90 let mut mcp_config_tokens = 0;
91
92 match body.get("system") {
93 Some(Value::String(s)) => {
94 let sp = classify_system_prompt(s);
95 system_prompt_tokens = sp.base;
96 rules_tokens = sp.rules;
97 skills_tokens = sp.skills;
98 mcp_config_tokens = sp.mcp;
99 }
100 Some(Value::Array(arr)) => {
101 for block in arr {
102 let text = block.get("text").and_then(|t| t.as_str()).unwrap_or("");
103 let sp = classify_system_prompt(text);
104 system_prompt_tokens += sp.base;
105 rules_tokens += sp.rules;
106 skills_tokens += sp.skills;
107 mcp_config_tokens += sp.mcp;
108 }
109 }
110 _ => {}
111 }
112
113 let tool_definition_tokens = body
114 .get("tools")
115 .and_then(|t| t.as_array())
116 .map_or(0, |arr| json_chars(arr) / 4);
117
118 let tool_definition_count = body
119 .get("tools")
120 .and_then(|t| t.as_array())
121 .map_or(0, Vec::len);
122
123 let mut user_message_tokens = 0;
124 let mut assistant_message_tokens = 0;
125 let mut tool_result_tokens = 0;
126 let mut image_count = 0;
127 let mut message_count = 0;
128 let mut subagent_tokens = 0;
129 let mut summarized_conversation_tokens = 0;
130
131 if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
132 message_count = messages.len();
133 for msg in messages {
134 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
135 let content_tokens = estimate_content_tokens(msg.get("content"));
136 let has_images = count_images(msg.get("content"));
137 image_count += has_images;
138
139 match role {
140 "user" => {
141 if has_tool_results(msg.get("content")) {
142 tool_result_tokens += content_tokens;
143 } else if is_summary_message(msg.get("content")) {
144 summarized_conversation_tokens += content_tokens;
145 } else if is_subagent_message(msg.get("content")) {
146 subagent_tokens += content_tokens;
147 } else {
148 user_message_tokens += content_tokens;
149 }
150 }
151 "assistant" => assistant_message_tokens += content_tokens,
152 _ => user_message_tokens += content_tokens,
153 }
154 }
155 }
156
157 let conversation_tokens = user_message_tokens + assistant_message_tokens;
158
159 let total_input_tokens = system_prompt_tokens
160 + rules_tokens
161 + skills_tokens
162 + mcp_config_tokens
163 + user_message_tokens
164 + assistant_message_tokens
165 + tool_definition_tokens
166 + tool_result_tokens
167 + subagent_tokens
168 + summarized_conversation_tokens;
169
170 RequestBreakdown {
171 provider: Provider::Anthropic,
172 model,
173 system_prompt_tokens,
174 user_message_tokens,
175 assistant_message_tokens,
176 tool_definition_tokens,
177 tool_definition_count,
178 tool_result_tokens,
179 image_count,
180 total_input_tokens,
181 message_count,
182 rules_tokens,
183 skills_tokens,
184 mcp_config_tokens,
185 subagent_tokens,
186 summarized_conversation_tokens,
187 conversation_tokens,
188 }
189}
190
191fn analyze_openai(body: &Value) -> RequestBreakdown {
192 if body.get("messages").is_none()
197 && (body.get("input").is_some() || body.get("instructions").is_some())
198 {
199 return analyze_openai_responses(body);
200 }
201
202 let raw_model = body
203 .get("model")
204 .and_then(|m| m.as_str())
205 .unwrap_or("unknown");
206 let model = normalize_model(raw_model, Provider::OpenAi);
207
208 let mut system_prompt_tokens = 0;
209 let mut rules_tokens = 0;
210 let mut skills_tokens = 0;
211 let mut mcp_config_tokens = 0;
212 let mut user_message_tokens = 0;
213 let mut assistant_message_tokens = 0;
214 let mut tool_result_tokens = 0;
215 let mut image_count = 0;
216 let mut message_count = 0;
217 let mut subagent_tokens = 0;
218 let mut summarized_conversation_tokens = 0;
219
220 if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
221 message_count = messages.len();
222 for msg in messages {
223 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
224 let content_tokens = estimate_content_tokens(msg.get("content"));
225 image_count += count_images(msg.get("content"));
226
227 match role {
228 "system" | "developer" => {
229 let text = extract_text_content(msg.get("content"));
230 let sp = classify_system_prompt(&text);
231 system_prompt_tokens += sp.base;
232 rules_tokens += sp.rules;
233 skills_tokens += sp.skills;
234 mcp_config_tokens += sp.mcp;
235 }
236 "assistant" => assistant_message_tokens += content_tokens,
237 "tool" => tool_result_tokens += content_tokens,
238 _ => {
239 if is_summary_message(msg.get("content")) {
240 summarized_conversation_tokens += content_tokens;
241 } else if is_subagent_message(msg.get("content")) {
242 subagent_tokens += content_tokens;
243 } else {
244 user_message_tokens += content_tokens;
245 }
246 }
247 }
248 }
249 }
250
251 let tool_definition_tokens = body
252 .get("tools")
253 .and_then(|t| t.as_array())
254 .map_or(0, |arr| json_chars(arr) / 4);
255
256 let tool_definition_count = body
257 .get("tools")
258 .and_then(|t| t.as_array())
259 .map_or(0, Vec::len);
260
261 let conversation_tokens = user_message_tokens + assistant_message_tokens;
262
263 let total_input_tokens = system_prompt_tokens
264 + rules_tokens
265 + skills_tokens
266 + mcp_config_tokens
267 + user_message_tokens
268 + assistant_message_tokens
269 + tool_definition_tokens
270 + tool_result_tokens
271 + subagent_tokens
272 + summarized_conversation_tokens;
273
274 RequestBreakdown {
275 provider: Provider::OpenAi,
276 model,
277 system_prompt_tokens,
278 user_message_tokens,
279 assistant_message_tokens,
280 tool_definition_tokens,
281 tool_definition_count,
282 tool_result_tokens,
283 image_count,
284 total_input_tokens,
285 message_count,
286 rules_tokens,
287 skills_tokens,
288 mcp_config_tokens,
289 subagent_tokens,
290 summarized_conversation_tokens,
291 conversation_tokens,
292 }
293}
294
295fn analyze_openai_responses(body: &Value) -> RequestBreakdown {
303 let raw_model = body
304 .get("model")
305 .and_then(|m| m.as_str())
306 .unwrap_or("unknown");
307 let model = normalize_model(raw_model, Provider::OpenAi);
308
309 let mut system_prompt_tokens = 0;
310 let mut rules_tokens = 0;
311 let mut skills_tokens = 0;
312 let mut mcp_config_tokens = 0;
313 let mut user_message_tokens = 0;
314 let mut assistant_message_tokens = 0;
315 let mut tool_result_tokens = 0;
316 let mut image_count = 0;
317 let mut message_count = 0;
318 let mut subagent_tokens = 0;
319 let mut summarized_conversation_tokens = 0;
320
321 if let Some(instructions) = body.get("instructions").and_then(|i| i.as_str()) {
322 let sp = classify_system_prompt(instructions);
323 system_prompt_tokens += sp.base;
324 rules_tokens += sp.rules;
325 skills_tokens += sp.skills;
326 mcp_config_tokens += sp.mcp;
327 }
328
329 match body.get("input") {
330 Some(Value::String(s)) => {
331 message_count = 1;
332 user_message_tokens += chars_to_tokens(s.len());
333 }
334 Some(Value::Array(items)) => {
335 message_count = items.len();
336 for item in items {
337 let item_type = item
339 .get("type")
340 .and_then(|t| t.as_str())
341 .unwrap_or("message");
342 match item_type {
343 "function_call_output" => {
344 tool_result_tokens += estimate_content_tokens(item.get("output"));
345 }
346 "function_call" | "custom_tool_call" | "reasoning" => {
347 assistant_message_tokens += json_chars(std::slice::from_ref(item)) / 4;
349 }
350 _ => {
351 let role = item.get("role").and_then(|r| r.as_str()).unwrap_or("user");
352 let content = item.get("content");
353 let content_tokens = estimate_content_tokens(content);
354 image_count += count_images(content);
355 match role {
356 "system" | "developer" => {
357 let text = extract_text_content(content);
358 let sp = classify_system_prompt(&text);
359 system_prompt_tokens += sp.base;
360 rules_tokens += sp.rules;
361 skills_tokens += sp.skills;
362 mcp_config_tokens += sp.mcp;
363 }
364 "assistant" => assistant_message_tokens += content_tokens,
365 _ => {
366 if is_summary_message(content) {
367 summarized_conversation_tokens += content_tokens;
368 } else if is_subagent_message(content) {
369 subagent_tokens += content_tokens;
370 } else {
371 user_message_tokens += content_tokens;
372 }
373 }
374 }
375 }
376 }
377 }
378 }
379 _ => {}
380 }
381
382 let tool_definition_tokens = body
383 .get("tools")
384 .and_then(|t| t.as_array())
385 .map_or(0, |arr| json_chars(arr) / 4);
386
387 let tool_definition_count = body
388 .get("tools")
389 .and_then(|t| t.as_array())
390 .map_or(0, Vec::len);
391
392 let conversation_tokens = user_message_tokens + assistant_message_tokens;
393
394 let total_input_tokens = system_prompt_tokens
395 + rules_tokens
396 + skills_tokens
397 + mcp_config_tokens
398 + user_message_tokens
399 + assistant_message_tokens
400 + tool_definition_tokens
401 + tool_result_tokens
402 + subagent_tokens
403 + summarized_conversation_tokens;
404
405 RequestBreakdown {
406 provider: Provider::OpenAi,
407 model,
408 system_prompt_tokens,
409 user_message_tokens,
410 assistant_message_tokens,
411 tool_definition_tokens,
412 tool_definition_count,
413 tool_result_tokens,
414 image_count,
415 total_input_tokens,
416 message_count,
417 rules_tokens,
418 skills_tokens,
419 mcp_config_tokens,
420 subagent_tokens,
421 summarized_conversation_tokens,
422 conversation_tokens,
423 }
424}
425
426fn analyze_gemini(body: &Value) -> RequestBreakdown {
427 let model = "gemini".to_string();
428
429 let system_prompt_tokens = body
430 .get("systemInstruction")
431 .and_then(|si| si.get("parts"))
432 .and_then(|p| p.as_array())
433 .map_or(0, |parts| {
434 parts
435 .iter()
436 .map(|p| p.get("text").and_then(|t| t.as_str()).map_or(0, str::len))
437 .sum::<usize>()
438 / 4
439 });
440
441 let mut user_message_tokens = 0;
442 let mut assistant_message_tokens = 0;
443 let mut tool_result_tokens = 0;
444 let mut message_count = 0;
445
446 if let Some(contents) = body.get("contents").and_then(|c| c.as_array()) {
447 message_count = contents.len();
448 for content in contents {
449 let role = content
450 .get("role")
451 .and_then(|r| r.as_str())
452 .unwrap_or("user");
453 let parts_tokens = content
454 .get("parts")
455 .and_then(|p| p.as_array())
456 .map_or(0, |parts| {
457 parts
458 .iter()
459 .map(|p| {
460 if p.get("functionResponse").is_some() {
461 json_chars(std::slice::from_ref(p)) / 4
462 } else {
463 p.get("text")
464 .and_then(|t| t.as_str())
465 .map_or(0, |s| chars_to_tokens(s.len()))
466 }
467 })
468 .sum::<usize>()
469 });
470
471 let has_fn_response = content
472 .get("parts")
473 .and_then(|p| p.as_array())
474 .is_some_and(|parts| parts.iter().any(|p| p.get("functionResponse").is_some()));
475
476 if has_fn_response {
477 tool_result_tokens += parts_tokens;
478 } else {
479 match role {
480 "model" => assistant_message_tokens += parts_tokens,
481 _ => user_message_tokens += parts_tokens,
482 }
483 }
484 }
485 }
486
487 let tool_definition_tokens = body
488 .get("tools")
489 .and_then(|t| t.as_array())
490 .map_or(0, |arr| json_chars(arr) / 4);
491
492 let tool_definition_count = body
493 .get("tools")
494 .and_then(|t| t.as_array())
495 .map_or(0, |arr| {
496 arr.iter()
497 .filter_map(|t| t.get("functionDeclarations").and_then(|f| f.as_array()))
498 .map(Vec::len)
499 .sum()
500 });
501
502 let total_input_tokens = system_prompt_tokens
503 + user_message_tokens
504 + assistant_message_tokens
505 + tool_definition_tokens
506 + tool_result_tokens;
507
508 let conversation_tokens = user_message_tokens + assistant_message_tokens;
509
510 RequestBreakdown {
511 provider: Provider::Gemini,
512 model,
513 system_prompt_tokens,
514 user_message_tokens,
515 assistant_message_tokens,
516 tool_definition_tokens,
517 tool_definition_count,
518 tool_result_tokens,
519 image_count: 0,
520 total_input_tokens,
521 message_count,
522 rules_tokens: 0,
523 skills_tokens: 0,
524 mcp_config_tokens: 0,
525 subagent_tokens: 0,
526 summarized_conversation_tokens: 0,
527 conversation_tokens,
528 }
529}
530
531fn chars_to_tokens(chars: usize) -> usize {
532 chars / 4
533}
534
535fn json_chars(arr: &[Value]) -> usize {
536 arr.iter().map(|v| v.to_string().len()).sum()
537}
538
539fn estimate_content_tokens(content: Option<&Value>) -> usize {
540 match content {
541 Some(Value::String(s)) => chars_to_tokens(s.len()),
542 Some(Value::Array(arr)) => arr
543 .iter()
544 .map(|block| {
545 if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
546 chars_to_tokens(text.len())
547 } else {
548 block.to_string().len() / 4
549 }
550 })
551 .sum(),
552 Some(v) => v.to_string().len() / 4,
553 None => 0,
554 }
555}
556
557fn count_images(content: Option<&Value>) -> usize {
558 match content {
559 Some(Value::Array(arr)) => arr
560 .iter()
561 .filter(|block| {
562 matches!(
563 block.get("type").and_then(|t| t.as_str()),
564 Some("image" | "image_url" | "input_image")
566 )
567 })
568 .count(),
569 _ => 0,
570 }
571}
572
573struct SystemPromptParts {
574 base: usize,
575 rules: usize,
576 skills: usize,
577 mcp: usize,
578}
579
580fn classify_system_prompt(text: &str) -> SystemPromptParts {
581 let mut rules = 0usize;
582 let mut skills = 0usize;
583 let mut mcp = 0usize;
584 let mut base = 0usize;
585
586 let rule_markers = [
587 "<always_applied_workspace_rule",
588 "<user_rule",
589 ".cursorrules",
590 "AGENTS.md",
591 ".mdc",
592 "workspace_rule",
593 "cursor_rules",
594 "CLAUDE.md",
595 "<rules>",
596 ];
597 let skill_markers = [
598 "<agent_skill",
599 "<available_skills",
600 "SKILL.md",
601 "skills-cursor",
602 "agent_skills",
603 ];
604 let mcp_markers = [
605 "<mcp_file_system",
606 "mcp_server",
607 "MCP server",
608 "CallMcpTool",
609 "FetchMcpResource",
610 "<mcp_file_system_server",
611 ];
612
613 for line in text.lines() {
614 let tok = chars_to_tokens(line.len() + 1);
615 let l = line.trim();
616
617 if rule_markers.iter().any(|m| l.contains(m)) {
618 rules += tok;
619 } else if skill_markers.iter().any(|m| l.contains(m)) {
620 skills += tok;
621 } else if mcp_markers.iter().any(|m| l.contains(m)) {
622 mcp += tok;
623 } else {
624 base += tok;
625 }
626 }
627
628 SystemPromptParts {
629 base,
630 rules,
631 skills,
632 mcp,
633 }
634}
635
636fn is_summary_message(content: Option<&Value>) -> bool {
637 let text = extract_text_content(content);
638 text.contains("[Previous conversation summary]")
639 || text.contains("conversation summary")
640 || text.contains("Here is a summary of the conversation")
641 || text.contains("summarized conversation")
642}
643
644fn is_subagent_message(content: Option<&Value>) -> bool {
645 let text = extract_text_content(content);
646 text.contains("subagent")
647 || text.contains("background agent")
648 || text.contains("<task>")
649 || text.contains("system_notification")
650}
651
652fn extract_text_content(content: Option<&Value>) -> String {
653 match content {
654 Some(Value::String(s)) => s.clone(),
655 Some(Value::Array(arr)) => arr
656 .iter()
657 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
658 .collect::<Vec<_>>()
659 .join(" "),
660 _ => String::new(),
661 }
662}
663
664fn has_tool_results(content: Option<&Value>) -> bool {
665 match content {
666 Some(Value::Array(arr)) => arr
667 .iter()
668 .any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")),
669 _ => false,
670 }
671}
672
673pub struct IntrospectState {
674 pub last_breakdown: Mutex<Option<RequestBreakdown>>,
675 pub total_system_prompt_tokens: AtomicU64,
676 pub total_input_tokens: AtomicU64,
677 pub total_bulk_candidate_tokens: AtomicU64,
678 pub total_requests: AtomicU64,
679 measurement_window: OnceLock<(Option<u64>, std::time::Instant)>,
680 last_persist_epoch: AtomicU64,
681}
682
683impl Default for IntrospectState {
684 fn default() -> Self {
685 Self {
686 last_breakdown: Mutex::new(None),
687 total_system_prompt_tokens: AtomicU64::new(0),
688 total_input_tokens: AtomicU64::new(0),
689 total_bulk_candidate_tokens: AtomicU64::new(0),
690 total_requests: AtomicU64::new(0),
691 measurement_window: OnceLock::new(),
692 last_persist_epoch: AtomicU64::new(0),
693 }
694 }
695}
696
697impl IntrospectState {
698 pub fn record(&self, breakdown: RequestBreakdown) {
699 self.measurement_window.get_or_init(|| {
700 let started_at = std::time::SystemTime::now()
701 .duration_since(std::time::UNIX_EPOCH)
702 .ok()
703 .map(|duration| duration.as_secs());
704 (started_at, std::time::Instant::now())
705 });
706 let system_slab_tokens = breakdown.system_prompt_tokens
707 + breakdown.rules_tokens
708 + breakdown.skills_tokens
709 + breakdown.mcp_config_tokens;
710 let bulk_candidate_tokens =
711 system_slab_tokens + breakdown.assistant_message_tokens + breakdown.tool_result_tokens;
712 self.total_system_prompt_tokens
713 .fetch_add(system_slab_tokens as u64, Ordering::Relaxed);
714 self.total_input_tokens
715 .fetch_add(breakdown.total_input_tokens as u64, Ordering::Relaxed);
716 self.total_bulk_candidate_tokens
717 .fetch_add(bulk_candidate_tokens as u64, Ordering::Relaxed);
718 self.total_requests.fetch_add(1, Ordering::Relaxed);
719 if let Ok(mut last) = self.last_breakdown.lock() {
720 *last = Some(breakdown);
721 }
722 self.maybe_persist();
723 }
724
725 fn maybe_persist(&self) {
726 let now = std::time::SystemTime::now()
727 .duration_since(std::time::UNIX_EPOCH)
728 .unwrap_or_default()
729 .as_secs();
730 let prev = self.last_persist_epoch.load(Ordering::Relaxed);
731 if now <= prev {
732 return;
733 }
734 if self
735 .last_persist_epoch
736 .compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed)
737 .is_err()
738 {
739 return;
740 }
741 self.persist(now);
742 }
743
744 fn persist(&self, ts: u64) {
745 let Ok(data_dir) = lean_ctx::core::data_dir::lean_ctx_data_dir() else {
746 return;
747 };
748 let breakdown_val = self
749 .last_breakdown
750 .lock()
751 .ok()
752 .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
753 .flatten();
754 let total_input_tokens = self.total_input_tokens.load(Ordering::Relaxed);
755 let total_bulk_candidate_tokens = self.total_bulk_candidate_tokens.load(Ordering::Relaxed);
756 let bulk_share_bps =
757 token_share_basis_points(total_bulk_candidate_tokens, total_input_tokens);
758 let (measurement_started_at, measurement_elapsed_seconds) = self
759 .measurement_window
760 .get()
761 .map_or((None, None), |(started_at, start)| {
762 (*started_at, Some(start.elapsed().as_secs()))
763 });
764 let payload = serde_json::json!({
765 "ts": ts,
766 "proxy_active": true,
767 "last_breakdown": breakdown_val,
768 "cumulative": {
769 "measurement_scope": "process_lifetime",
770 "measurement_started_at_epoch_seconds": measurement_started_at,
771 "measurement_elapsed_seconds": measurement_elapsed_seconds,
772 "total_requests": self.total_requests.load(Ordering::Relaxed),
773 "total_input_tokens": total_input_tokens,
774 "total_system_prompt_tokens": self.total_system_prompt_tokens.load(Ordering::Relaxed),
775 "total_bulk_candidate_tokens": total_bulk_candidate_tokens,
776 "bulk_candidate_share_basis_points": bulk_share_bps,
777 "vision_encoding_decision_gate_met": bulk_share_bps.is_some_and(|share| share >= 2_000),
778 }
779 });
780
781 let target = data_dir.join("proxy-introspect.json");
782 let tmp = data_dir.join("proxy-introspect.json.tmp");
783 if let Ok(json) = serde_json::to_string_pretty(&payload)
784 && std::fs::write(&tmp, &json).is_ok()
785 {
786 let _ = std::fs::rename(&tmp, &target);
787 }
788 }
789}
790
791pub(crate) fn token_share_basis_points(part: u64, total: u64) -> Option<u64> {
792 (total > 0).then(|| {
793 let basis_points = u128::from(part) * 10_000 / u128::from(total);
794 u64::try_from(basis_points).unwrap_or(u64::MAX)
795 })
796}
797
798pub use lean_ctx::core::proxy_introspect::load_persisted;
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803
804 #[test]
805 fn cumulative_measurement_window_starts_once_and_resets_with_the_process() {
806 let _data = lean_ctx::core::data_dir::isolated_data_dir();
807 let snapshot = lean_ctx::core::data_dir::lean_ctx_data_dir()
808 .unwrap()
809 .join("proxy-introspect.json");
810 let read = || -> Value {
811 serde_json::from_str(&std::fs::read_to_string(&snapshot).unwrap()).unwrap()
812 };
813 let state = IntrospectState::default();
814 state.persist(1);
815 assert!(read()["cumulative"]["measurement_elapsed_seconds"].is_null());
816
817 let body = serde_json::json!({
818 "model": "test-model", "messages": [{"role": "user", "content": "window sample"}]
819 });
820 state.record(analyze_request(&body, Provider::OpenAi));
821 let first_start = *state.measurement_window.get().unwrap();
822 state.record(analyze_request(&body, Provider::OpenAi));
823 assert_eq!(*state.measurement_window.get().unwrap(), first_start);
824 state.persist(1);
825 let cumulative = &read()["cumulative"];
826 assert_eq!(cumulative["measurement_scope"], "process_lifetime");
827 assert_eq!(cumulative["total_requests"], 2);
828 assert!(cumulative["measurement_started_at_epoch_seconds"].is_u64());
829 assert!(cumulative["measurement_elapsed_seconds"].is_u64());
830
831 let aged = IntrospectState::default();
832 aged.measurement_window
833 .set((
834 Some(123),
835 std::time::Instant::now()
836 .checked_sub(std::time::Duration::from_secs(90))
837 .expect("monotonic clock is past 90 seconds"),
838 ))
839 .unwrap();
840 aged.record(analyze_request(&body, Provider::OpenAi));
841 aged.persist(1);
844 let cumulative = &read()["cumulative"];
845 assert_eq!(cumulative["measurement_started_at_epoch_seconds"], 123);
846 assert!(cumulative["measurement_elapsed_seconds"].as_u64().unwrap() >= 90);
847 assert!(
848 IntrospectState::default()
849 .measurement_window
850 .get()
851 .is_none()
852 );
853 }
854
855 #[test]
856 fn vision_experiment_gate_uses_post_funnel_bulk_share() {
857 assert_eq!(token_share_basis_points(19, 100), Some(1_900));
858 assert_eq!(token_share_basis_points(20, 100), Some(2_000));
859 assert_eq!(token_share_basis_points(1, 0), None);
860 assert_eq!(token_share_basis_points(u64::MAX, u64::MAX), Some(10_000));
861 }
862
863 #[test]
864 fn anthropic_basic() {
865 let body = serde_json::json!({
866 "model": "claude-sonnet-4-20250514",
867 "system": "You are a helpful assistant.",
868 "messages": [
869 {"role": "user", "content": "Hello"},
870 {"role": "assistant", "content": "Hi there!"}
871 ],
872 "tools": [{"name": "read", "description": "Read a file", "input_schema": {}}]
873 });
874 let b = analyze_request(&body, Provider::Anthropic);
875 assert_eq!(b.provider, Provider::Anthropic);
876 assert!(b.system_prompt_tokens > 0);
877 assert_eq!(b.message_count, 2);
878 assert!(b.user_message_tokens > 0);
879 assert!(b.assistant_message_tokens > 0);
880 assert_eq!(b.tool_definition_count, 1);
881 assert!(b.tool_definition_tokens > 0);
882 }
883
884 #[test]
885 fn openai_system_message() {
886 let body = serde_json::json!({
887 "model": "gpt-4o",
888 "messages": [
889 {"role": "system", "content": "System prompt here"},
890 {"role": "user", "content": "Hello"},
891 {"role": "tool", "content": "tool result data", "tool_call_id": "x"}
892 ]
893 });
894 let b = analyze_request(&body, Provider::OpenAi);
895 assert!(b.system_prompt_tokens > 0);
896 assert!(b.user_message_tokens > 0);
897 assert!(b.tool_result_tokens > 0);
898 assert_eq!(b.message_count, 3);
899 }
900
901 #[test]
902 fn openai_responses_api_shape() {
903 let body = serde_json::json!({
905 "model": "gpt-5",
906 "instructions": "You are a careful coding assistant.",
907 "input": [
908 {"type": "message", "role": "user", "content": [
909 {"type": "input_text", "text": "List the files"},
910 {"type": "input_image", "image_url": "data:image/png;base64,AAAA"}
911 ]},
912 {"type": "function_call", "call_id": "c1", "name": "ls", "arguments": "{}"},
913 {"type": "function_call_output", "call_id": "c1", "output": "a.rs\nb.rs\nc.rs"}
914 ],
915 "tools": [{"type": "function", "name": "ls", "parameters": {}}]
916 });
917 let b = analyze_request(&body, Provider::OpenAi);
918 assert_eq!(b.provider, Provider::OpenAi);
919 assert!(b.system_prompt_tokens > 0, "instructions → system prompt");
920 assert!(b.user_message_tokens > 0, "user input_text counted");
921 assert!(b.assistant_message_tokens > 0, "function_call → assistant");
922 assert!(
923 b.tool_result_tokens > 0,
924 "function_call_output → tool result"
925 );
926 assert_eq!(b.tool_definition_count, 1);
927 assert!(b.tool_definition_tokens > 0);
928 assert_eq!(b.image_count, 1, "input_image counted");
929 assert_eq!(b.message_count, 3);
930 }
931
932 #[test]
933 fn openai_responses_string_input() {
934 let body = serde_json::json!({"model": "gpt-5", "input": "just a question"});
935 let b = analyze_request(&body, Provider::OpenAi);
936 assert_eq!(b.provider, Provider::OpenAi);
937 assert!(b.user_message_tokens > 0);
938 assert_eq!(b.message_count, 1);
939 }
940
941 #[test]
942 fn gemini_system_instruction() {
943 let body = serde_json::json!({
944 "systemInstruction": {
945 "parts": [{"text": "Be concise and helpful to the user at all times."}]
946 },
947 "contents": [
948 {"role": "user", "parts": [{"text": "What is the meaning of life and everything?"}]},
949 {"role": "model", "parts": [{"text": "The answer is 42 according to Douglas Adams."}]}
950 ]
951 });
952 let b = analyze_request(&body, Provider::Gemini);
953 assert!(b.system_prompt_tokens > 0);
954 assert!(b.user_message_tokens > 0);
955 assert!(b.assistant_message_tokens > 0);
956 assert_eq!(b.message_count, 2);
957 }
958}