Skip to main content

lean_ctx_proxy/
introspect.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
50/// IDE clients (Cursor, Copilot) often send routing IDs like "model-0", "model-4"
51/// instead of real model names. We keep track of the last real model name per provider
52/// and fall back to it when we see a generic routing ID.
53fn 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    // The Responses API (`/v1/responses`) carries its turns in `input` instead
193    // of `messages` and its system prompt in `instructions`. Detect that shape
194    // and analyze it separately so introspection stays accurate for opencode and
195    // the OpenAI Agents SDK rather than reporting an empty breakdown.
196    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
295/// Analyze an OpenAI **Responses API** request (`/v1/responses`).
296///
297/// Shape differs from Chat Completions: the system prompt lives in
298/// `instructions`, and conversation turns live in `input` — either a bare string
299/// (single user turn) or an array of typed items (`message`, `function_call`,
300/// `function_call_output`, `reasoning`, …). We map those onto the same
301/// [`RequestBreakdown`] buckets the other providers use.
302fn 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                // Items default to "message" when no explicit `type` is present.
338                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                        // The model's own tool invocations / reasoning.
348                        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                    // "image"/"image_url": Chat Completions; "input_image": Responses API.
565                    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        // The elapsed time comes from the monotonic start, not from the
842        // snapshot's save time (`ts` = 1 here, before the recorded start).
843        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        // `/v1/responses`: system prompt in `instructions`, turns in `input`.
904        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}