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    // The Responses API (`/v1/responses`) carries its turns in `input` instead
192    // of `messages` and its system prompt in `instructions`. Detect that shape
193    // and analyze it separately so introspection stays accurate for opencode and
194    // the OpenAI Agents SDK rather than reporting an empty breakdown.
195    if body.get("messages").is_none()
196        && (body.get("input").is_some() || body.get("instructions").is_some())
197    {
198        return analyze_openai_responses(body);
199    }
200
201    let raw_model = body
202        .get("model")
203        .and_then(|m| m.as_str())
204        .unwrap_or("unknown");
205    let model = normalize_model(raw_model, Provider::OpenAi);
206
207    let mut system_prompt_tokens = 0;
208    let mut rules_tokens = 0;
209    let mut skills_tokens = 0;
210    let mut mcp_config_tokens = 0;
211    let mut user_message_tokens = 0;
212    let mut assistant_message_tokens = 0;
213    let mut tool_result_tokens = 0;
214    let mut image_count = 0;
215    let mut message_count = 0;
216    let mut subagent_tokens = 0;
217    let mut summarized_conversation_tokens = 0;
218
219    if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
220        message_count = messages.len();
221        for msg in messages {
222            let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
223            let content_tokens = estimate_content_tokens(msg.get("content"));
224            image_count += count_images(msg.get("content"));
225
226            match role {
227                "system" | "developer" => {
228                    let text = extract_text_content(msg.get("content"));
229                    let sp = classify_system_prompt(&text);
230                    system_prompt_tokens += sp.base;
231                    rules_tokens += sp.rules;
232                    skills_tokens += sp.skills;
233                    mcp_config_tokens += sp.mcp;
234                }
235                "assistant" => assistant_message_tokens += content_tokens,
236                "tool" => tool_result_tokens += content_tokens,
237                _ => {
238                    if is_summary_message(msg.get("content")) {
239                        summarized_conversation_tokens += content_tokens;
240                    } else if is_subagent_message(msg.get("content")) {
241                        subagent_tokens += content_tokens;
242                    } else {
243                        user_message_tokens += content_tokens;
244                    }
245                }
246            }
247        }
248    }
249
250    let tool_definition_tokens = body
251        .get("tools")
252        .and_then(|t| t.as_array())
253        .map_or(0, |arr| json_chars(arr) / 4);
254
255    let tool_definition_count = body
256        .get("tools")
257        .and_then(|t| t.as_array())
258        .map_or(0, Vec::len);
259
260    let conversation_tokens = user_message_tokens + assistant_message_tokens;
261
262    let total_input_tokens = system_prompt_tokens
263        + rules_tokens
264        + skills_tokens
265        + mcp_config_tokens
266        + user_message_tokens
267        + assistant_message_tokens
268        + tool_definition_tokens
269        + tool_result_tokens
270        + subagent_tokens
271        + summarized_conversation_tokens;
272
273    RequestBreakdown {
274        provider: Provider::OpenAi,
275        model,
276        system_prompt_tokens,
277        user_message_tokens,
278        assistant_message_tokens,
279        tool_definition_tokens,
280        tool_definition_count,
281        tool_result_tokens,
282        image_count,
283        total_input_tokens,
284        message_count,
285        rules_tokens,
286        skills_tokens,
287        mcp_config_tokens,
288        subagent_tokens,
289        summarized_conversation_tokens,
290        conversation_tokens,
291    }
292}
293
294/// Analyze an OpenAI **Responses API** request (`/v1/responses`).
295///
296/// Shape differs from Chat Completions: the system prompt lives in
297/// `instructions`, and conversation turns live in `input` — either a bare string
298/// (single user turn) or an array of typed items (`message`, `function_call`,
299/// `function_call_output`, `reasoning`, …). We map those onto the same
300/// [`RequestBreakdown`] buckets the other providers use.
301fn analyze_openai_responses(body: &Value) -> RequestBreakdown {
302    let raw_model = body
303        .get("model")
304        .and_then(|m| m.as_str())
305        .unwrap_or("unknown");
306    let model = normalize_model(raw_model, Provider::OpenAi);
307
308    let mut system_prompt_tokens = 0;
309    let mut rules_tokens = 0;
310    let mut skills_tokens = 0;
311    let mut mcp_config_tokens = 0;
312    let mut user_message_tokens = 0;
313    let mut assistant_message_tokens = 0;
314    let mut tool_result_tokens = 0;
315    let mut image_count = 0;
316    let mut message_count = 0;
317    let mut subagent_tokens = 0;
318    let mut summarized_conversation_tokens = 0;
319
320    if let Some(instructions) = body.get("instructions").and_then(|i| i.as_str()) {
321        let sp = classify_system_prompt(instructions);
322        system_prompt_tokens += sp.base;
323        rules_tokens += sp.rules;
324        skills_tokens += sp.skills;
325        mcp_config_tokens += sp.mcp;
326    }
327
328    match body.get("input") {
329        Some(Value::String(s)) => {
330            message_count = 1;
331            user_message_tokens += chars_to_tokens(s.len());
332        }
333        Some(Value::Array(items)) => {
334            message_count = items.len();
335            for item in items {
336                // Items default to "message" when no explicit `type` is present.
337                let item_type = item
338                    .get("type")
339                    .and_then(|t| t.as_str())
340                    .unwrap_or("message");
341                match item_type {
342                    "function_call_output" => {
343                        tool_result_tokens += estimate_content_tokens(item.get("output"));
344                    }
345                    "function_call" | "custom_tool_call" | "reasoning" => {
346                        // The model's own tool invocations / reasoning.
347                        assistant_message_tokens += json_chars(std::slice::from_ref(item)) / 4;
348                    }
349                    _ => {
350                        let role = item.get("role").and_then(|r| r.as_str()).unwrap_or("user");
351                        let content = item.get("content");
352                        let content_tokens = estimate_content_tokens(content);
353                        image_count += count_images(content);
354                        match role {
355                            "system" | "developer" => {
356                                let text = extract_text_content(content);
357                                let sp = classify_system_prompt(&text);
358                                system_prompt_tokens += sp.base;
359                                rules_tokens += sp.rules;
360                                skills_tokens += sp.skills;
361                                mcp_config_tokens += sp.mcp;
362                            }
363                            "assistant" => assistant_message_tokens += content_tokens,
364                            _ => {
365                                if is_summary_message(content) {
366                                    summarized_conversation_tokens += content_tokens;
367                                } else if is_subagent_message(content) {
368                                    subagent_tokens += content_tokens;
369                                } else {
370                                    user_message_tokens += content_tokens;
371                                }
372                            }
373                        }
374                    }
375                }
376            }
377        }
378        _ => {}
379    }
380
381    let tool_definition_tokens = body
382        .get("tools")
383        .and_then(|t| t.as_array())
384        .map_or(0, |arr| json_chars(arr) / 4);
385
386    let tool_definition_count = body
387        .get("tools")
388        .and_then(|t| t.as_array())
389        .map_or(0, Vec::len);
390
391    let conversation_tokens = user_message_tokens + assistant_message_tokens;
392
393    let total_input_tokens = system_prompt_tokens
394        + rules_tokens
395        + skills_tokens
396        + mcp_config_tokens
397        + user_message_tokens
398        + assistant_message_tokens
399        + tool_definition_tokens
400        + tool_result_tokens
401        + subagent_tokens
402        + summarized_conversation_tokens;
403
404    RequestBreakdown {
405        provider: Provider::OpenAi,
406        model,
407        system_prompt_tokens,
408        user_message_tokens,
409        assistant_message_tokens,
410        tool_definition_tokens,
411        tool_definition_count,
412        tool_result_tokens,
413        image_count,
414        total_input_tokens,
415        message_count,
416        rules_tokens,
417        skills_tokens,
418        mcp_config_tokens,
419        subagent_tokens,
420        summarized_conversation_tokens,
421        conversation_tokens,
422    }
423}
424
425fn analyze_gemini(body: &Value) -> RequestBreakdown {
426    let model = "gemini".to_string();
427
428    let system_prompt_tokens = body
429        .get("systemInstruction")
430        .and_then(|si| si.get("parts"))
431        .and_then(|p| p.as_array())
432        .map_or(0, |parts| {
433            parts
434                .iter()
435                .map(|p| p.get("text").and_then(|t| t.as_str()).map_or(0, str::len))
436                .sum::<usize>()
437                / 4
438        });
439
440    let mut user_message_tokens = 0;
441    let mut assistant_message_tokens = 0;
442    let mut tool_result_tokens = 0;
443    let mut message_count = 0;
444
445    if let Some(contents) = body.get("contents").and_then(|c| c.as_array()) {
446        message_count = contents.len();
447        for content in contents {
448            let role = content
449                .get("role")
450                .and_then(|r| r.as_str())
451                .unwrap_or("user");
452            let parts_tokens = content
453                .get("parts")
454                .and_then(|p| p.as_array())
455                .map_or(0, |parts| {
456                    parts
457                        .iter()
458                        .map(|p| {
459                            if p.get("functionResponse").is_some() {
460                                json_chars(std::slice::from_ref(p)) / 4
461                            } else {
462                                p.get("text")
463                                    .and_then(|t| t.as_str())
464                                    .map_or(0, |s| chars_to_tokens(s.len()))
465                            }
466                        })
467                        .sum::<usize>()
468                });
469
470            let has_fn_response = content
471                .get("parts")
472                .and_then(|p| p.as_array())
473                .is_some_and(|parts| parts.iter().any(|p| p.get("functionResponse").is_some()));
474
475            if has_fn_response {
476                tool_result_tokens += parts_tokens;
477            } else {
478                match role {
479                    "model" => assistant_message_tokens += parts_tokens,
480                    _ => user_message_tokens += parts_tokens,
481                }
482            }
483        }
484    }
485
486    let tool_definition_tokens = body
487        .get("tools")
488        .and_then(|t| t.as_array())
489        .map_or(0, |arr| json_chars(arr) / 4);
490
491    let tool_definition_count = body
492        .get("tools")
493        .and_then(|t| t.as_array())
494        .map_or(0, |arr| {
495            arr.iter()
496                .filter_map(|t| t.get("functionDeclarations").and_then(|f| f.as_array()))
497                .map(Vec::len)
498                .sum()
499        });
500
501    let total_input_tokens = system_prompt_tokens
502        + user_message_tokens
503        + assistant_message_tokens
504        + tool_definition_tokens
505        + tool_result_tokens;
506
507    let conversation_tokens = user_message_tokens + assistant_message_tokens;
508
509    RequestBreakdown {
510        provider: Provider::Gemini,
511        model,
512        system_prompt_tokens,
513        user_message_tokens,
514        assistant_message_tokens,
515        tool_definition_tokens,
516        tool_definition_count,
517        tool_result_tokens,
518        image_count: 0,
519        total_input_tokens,
520        message_count,
521        rules_tokens: 0,
522        skills_tokens: 0,
523        mcp_config_tokens: 0,
524        subagent_tokens: 0,
525        summarized_conversation_tokens: 0,
526        conversation_tokens,
527    }
528}
529
530fn chars_to_tokens(chars: usize) -> usize {
531    chars / 4
532}
533
534fn json_chars(arr: &[Value]) -> usize {
535    arr.iter().map(|v| v.to_string().len()).sum()
536}
537
538fn estimate_content_tokens(content: Option<&Value>) -> usize {
539    match content {
540        Some(Value::String(s)) => chars_to_tokens(s.len()),
541        Some(Value::Array(arr)) => arr
542            .iter()
543            .map(|block| {
544                if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
545                    chars_to_tokens(text.len())
546                } else {
547                    block.to_string().len() / 4
548                }
549            })
550            .sum(),
551        Some(v) => v.to_string().len() / 4,
552        None => 0,
553    }
554}
555
556fn count_images(content: Option<&Value>) -> usize {
557    match content {
558        Some(Value::Array(arr)) => arr
559            .iter()
560            .filter(|block| {
561                matches!(
562                    block.get("type").and_then(|t| t.as_str()),
563                    // "image"/"image_url": Chat Completions; "input_image": Responses API.
564                    Some("image" | "image_url" | "input_image")
565                )
566            })
567            .count(),
568        _ => 0,
569    }
570}
571
572struct SystemPromptParts {
573    base: usize,
574    rules: usize,
575    skills: usize,
576    mcp: usize,
577}
578
579fn classify_system_prompt(text: &str) -> SystemPromptParts {
580    let mut rules = 0usize;
581    let mut skills = 0usize;
582    let mut mcp = 0usize;
583    let mut base = 0usize;
584
585    let rule_markers = [
586        "<always_applied_workspace_rule",
587        "<user_rule",
588        ".cursorrules",
589        "AGENTS.md",
590        ".mdc",
591        "workspace_rule",
592        "cursor_rules",
593        "CLAUDE.md",
594        "<rules>",
595    ];
596    let skill_markers = [
597        "<agent_skill",
598        "<available_skills",
599        "SKILL.md",
600        "skills-cursor",
601        "agent_skills",
602    ];
603    let mcp_markers = [
604        "<mcp_file_system",
605        "mcp_server",
606        "MCP server",
607        "CallMcpTool",
608        "FetchMcpResource",
609        "<mcp_file_system_server",
610    ];
611
612    for line in text.lines() {
613        let tok = chars_to_tokens(line.len() + 1);
614        let l = line.trim();
615
616        if rule_markers.iter().any(|m| l.contains(m)) {
617            rules += tok;
618        } else if skill_markers.iter().any(|m| l.contains(m)) {
619            skills += tok;
620        } else if mcp_markers.iter().any(|m| l.contains(m)) {
621            mcp += tok;
622        } else {
623            base += tok;
624        }
625    }
626
627    SystemPromptParts {
628        base,
629        rules,
630        skills,
631        mcp,
632    }
633}
634
635fn is_summary_message(content: Option<&Value>) -> bool {
636    let text = extract_text_content(content);
637    text.contains("[Previous conversation summary]")
638        || text.contains("conversation summary")
639        || text.contains("Here is a summary of the conversation")
640        || text.contains("summarized conversation")
641}
642
643fn is_subagent_message(content: Option<&Value>) -> bool {
644    let text = extract_text_content(content);
645    text.contains("subagent")
646        || text.contains("background agent")
647        || text.contains("<task>")
648        || text.contains("system_notification")
649}
650
651fn extract_text_content(content: Option<&Value>) -> String {
652    match content {
653        Some(Value::String(s)) => s.clone(),
654        Some(Value::Array(arr)) => arr
655            .iter()
656            .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
657            .collect::<Vec<_>>()
658            .join(" "),
659        _ => String::new(),
660    }
661}
662
663fn has_tool_results(content: Option<&Value>) -> bool {
664    match content {
665        Some(Value::Array(arr)) => arr
666            .iter()
667            .any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")),
668        _ => false,
669    }
670}
671
672pub struct IntrospectState {
673    pub last_breakdown: Mutex<Option<RequestBreakdown>>,
674    pub total_system_prompt_tokens: AtomicU64,
675    pub total_requests: AtomicU64,
676    last_persist_epoch: AtomicU64,
677}
678
679impl Default for IntrospectState {
680    fn default() -> Self {
681        Self {
682            last_breakdown: Mutex::new(None),
683            total_system_prompt_tokens: AtomicU64::new(0),
684            total_requests: AtomicU64::new(0),
685            last_persist_epoch: AtomicU64::new(0),
686        }
687    }
688}
689
690impl IntrospectState {
691    pub fn record(&self, breakdown: RequestBreakdown) {
692        self.total_system_prompt_tokens.fetch_add(
693            (breakdown.system_prompt_tokens
694                + breakdown.rules_tokens
695                + breakdown.skills_tokens
696                + breakdown.mcp_config_tokens) as u64,
697            Ordering::Relaxed,
698        );
699        self.total_requests.fetch_add(1, Ordering::Relaxed);
700        if let Ok(mut last) = self.last_breakdown.lock() {
701            *last = Some(breakdown);
702        }
703        self.maybe_persist();
704    }
705
706    fn maybe_persist(&self) {
707        let now = std::time::SystemTime::now()
708            .duration_since(std::time::UNIX_EPOCH)
709            .unwrap_or_default()
710            .as_secs();
711        let prev = self.last_persist_epoch.load(Ordering::Relaxed);
712        if now <= prev {
713            return;
714        }
715        if self
716            .last_persist_epoch
717            .compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed)
718            .is_err()
719        {
720            return;
721        }
722        self.persist(now);
723    }
724
725    fn persist(&self, ts: u64) {
726        let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
727            return;
728        };
729        let breakdown_val = self
730            .last_breakdown
731            .lock()
732            .ok()
733            .and_then(|guard| guard.as_ref().map(|b| serde_json::to_value(b).ok()))
734            .flatten();
735        let payload = serde_json::json!({
736            "ts": ts,
737            "proxy_active": true,
738            "last_breakdown": breakdown_val,
739            "cumulative": {
740                "total_requests": self.total_requests.load(Ordering::Relaxed),
741                "total_system_prompt_tokens": self.total_system_prompt_tokens.load(Ordering::Relaxed),
742            }
743        });
744
745        let target = data_dir.join("proxy-introspect.json");
746        let tmp = data_dir.join("proxy-introspect.json.tmp");
747        if let Ok(json) = serde_json::to_string_pretty(&payload) {
748            if std::fs::write(&tmp, &json).is_ok() {
749                let _ = std::fs::rename(&tmp, &target);
750            }
751        }
752    }
753}
754
755/// Load persisted proxy introspection data from disk.
756/// Returns `None` if the file doesn't exist or is stale (> `max_age_secs`).
757pub fn load_persisted(max_age_secs: u64) -> Option<serde_json::Value> {
758    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
759    let path = data_dir.join("proxy-introspect.json");
760    let content = std::fs::read_to_string(&path).ok()?;
761    let val: serde_json::Value = serde_json::from_str(&content).ok()?;
762
763    let ts = val
764        .get("ts")
765        .and_then(serde_json::Value::as_u64)
766        .unwrap_or(0);
767    let now = std::time::SystemTime::now()
768        .duration_since(std::time::UNIX_EPOCH)
769        .unwrap_or_default()
770        .as_secs();
771    if now.saturating_sub(ts) > max_age_secs {
772        return None;
773    }
774    Some(val)
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn anthropic_basic() {
783        let body = serde_json::json!({
784            "model": "claude-sonnet-4-20250514",
785            "system": "You are a helpful assistant.",
786            "messages": [
787                {"role": "user", "content": "Hello"},
788                {"role": "assistant", "content": "Hi there!"}
789            ],
790            "tools": [{"name": "read", "description": "Read a file", "input_schema": {}}]
791        });
792        let b = analyze_request(&body, Provider::Anthropic);
793        assert_eq!(b.provider, Provider::Anthropic);
794        assert!(b.system_prompt_tokens > 0);
795        assert_eq!(b.message_count, 2);
796        assert!(b.user_message_tokens > 0);
797        assert!(b.assistant_message_tokens > 0);
798        assert_eq!(b.tool_definition_count, 1);
799        assert!(b.tool_definition_tokens > 0);
800    }
801
802    #[test]
803    fn openai_system_message() {
804        let body = serde_json::json!({
805            "model": "gpt-4o",
806            "messages": [
807                {"role": "system", "content": "System prompt here"},
808                {"role": "user", "content": "Hello"},
809                {"role": "tool", "content": "tool result data", "tool_call_id": "x"}
810            ]
811        });
812        let b = analyze_request(&body, Provider::OpenAi);
813        assert!(b.system_prompt_tokens > 0);
814        assert!(b.user_message_tokens > 0);
815        assert!(b.tool_result_tokens > 0);
816        assert_eq!(b.message_count, 3);
817    }
818
819    #[test]
820    fn openai_responses_api_shape() {
821        // `/v1/responses`: system prompt in `instructions`, turns in `input`.
822        let body = serde_json::json!({
823            "model": "gpt-5",
824            "instructions": "You are a careful coding assistant.",
825            "input": [
826                {"type": "message", "role": "user", "content": [
827                    {"type": "input_text", "text": "List the files"},
828                    {"type": "input_image", "image_url": "data:image/png;base64,AAAA"}
829                ]},
830                {"type": "function_call", "call_id": "c1", "name": "ls", "arguments": "{}"},
831                {"type": "function_call_output", "call_id": "c1", "output": "a.rs\nb.rs\nc.rs"}
832            ],
833            "tools": [{"type": "function", "name": "ls", "parameters": {}}]
834        });
835        let b = analyze_request(&body, Provider::OpenAi);
836        assert_eq!(b.provider, Provider::OpenAi);
837        assert!(b.system_prompt_tokens > 0, "instructions → system prompt");
838        assert!(b.user_message_tokens > 0, "user input_text counted");
839        assert!(b.assistant_message_tokens > 0, "function_call → assistant");
840        assert!(
841            b.tool_result_tokens > 0,
842            "function_call_output → tool result"
843        );
844        assert_eq!(b.tool_definition_count, 1);
845        assert!(b.tool_definition_tokens > 0);
846        assert_eq!(b.image_count, 1, "input_image counted");
847        assert_eq!(b.message_count, 3);
848    }
849
850    #[test]
851    fn openai_responses_string_input() {
852        let body = serde_json::json!({"model": "gpt-5", "input": "just a question"});
853        let b = analyze_request(&body, Provider::OpenAi);
854        assert_eq!(b.provider, Provider::OpenAi);
855        assert!(b.user_message_tokens > 0);
856        assert_eq!(b.message_count, 1);
857    }
858
859    #[test]
860    fn gemini_system_instruction() {
861        let body = serde_json::json!({
862            "systemInstruction": {
863                "parts": [{"text": "Be concise and helpful to the user at all times."}]
864            },
865            "contents": [
866                {"role": "user", "parts": [{"text": "What is the meaning of life and everything?"}]},
867                {"role": "model", "parts": [{"text": "The answer is 42 according to Douglas Adams."}]}
868            ]
869        });
870        let b = analyze_request(&body, Provider::Gemini);
871        assert!(b.system_prompt_tokens > 0);
872        assert!(b.user_message_tokens > 0);
873        assert!(b.assistant_message_tokens > 0);
874        assert_eq!(b.message_count, 2);
875    }
876}