1use serde::Serialize;
2use serde_json::Value;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Mutex;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
7#[serde(rename_all = "snake_case")]
8pub enum Provider {
9 Anthropic,
10 OpenAi,
11 Gemini,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct RequestBreakdown {
16 pub provider: Provider,
17 pub model: String,
18 pub system_prompt_tokens: usize,
19 pub user_message_tokens: usize,
20 pub assistant_message_tokens: usize,
21 pub tool_definition_tokens: usize,
22 pub tool_definition_count: usize,
23 pub tool_result_tokens: usize,
24 pub image_count: usize,
25 pub total_input_tokens: usize,
26 pub message_count: usize,
27 #[serde(default)]
28 pub rules_tokens: usize,
29 #[serde(default)]
30 pub skills_tokens: usize,
31 #[serde(default)]
32 pub mcp_config_tokens: usize,
33 #[serde(default)]
34 pub subagent_tokens: usize,
35 #[serde(default)]
36 pub summarized_conversation_tokens: usize,
37 #[serde(default)]
38 pub conversation_tokens: usize,
39}
40
41pub fn analyze_request(body: &Value, provider: Provider) -> RequestBreakdown {
42 match provider {
43 Provider::Anthropic => analyze_anthropic(body),
44 Provider::OpenAi => analyze_openai(body),
45 Provider::Gemini => analyze_gemini(body),
46 }
47}
48
49fn normalize_model(raw: &str, provider: Provider) -> String {
53 use std::sync::Mutex;
54 static LAST_REAL: Mutex<[Option<String>; 3]> = Mutex::new([None, None, None]);
55
56 let is_routing_id = raw.starts_with("model-") || raw == "unknown" || raw.is_empty();
57
58 let idx = match provider {
59 Provider::Anthropic => 0,
60 Provider::OpenAi => 1,
61 Provider::Gemini => 2,
62 };
63
64 if is_routing_id {
65 if let Ok(guard) = LAST_REAL.lock() {
66 if let Some(ref real) = guard[idx] {
67 return real.clone();
68 }
69 }
70 return raw.to_string();
71 }
72
73 if let Ok(mut guard) = LAST_REAL.lock() {
74 guard[idx] = Some(raw.to_string());
75 }
76 raw.to_string()
77}
78
79fn analyze_anthropic(body: &Value) -> RequestBreakdown {
80 let raw_model = body
81 .get("model")
82 .and_then(|m| m.as_str())
83 .unwrap_or("unknown");
84 let model = normalize_model(raw_model, Provider::Anthropic);
85
86 let mut system_prompt_tokens = 0;
87 let mut rules_tokens = 0;
88 let mut skills_tokens = 0;
89 let mut mcp_config_tokens = 0;
90
91 match body.get("system") {
92 Some(Value::String(s)) => {
93 let sp = classify_system_prompt(s);
94 system_prompt_tokens = sp.base;
95 rules_tokens = sp.rules;
96 skills_tokens = sp.skills;
97 mcp_config_tokens = sp.mcp;
98 }
99 Some(Value::Array(arr)) => {
100 for block in arr {
101 let text = block.get("text").and_then(|t| t.as_str()).unwrap_or("");
102 let sp = classify_system_prompt(text);
103 system_prompt_tokens += sp.base;
104 rules_tokens += sp.rules;
105 skills_tokens += sp.skills;
106 mcp_config_tokens += sp.mcp;
107 }
108 }
109 _ => {}
110 }
111
112 let tool_definition_tokens = body
113 .get("tools")
114 .and_then(|t| t.as_array())
115 .map_or(0, |arr| json_chars(arr) / 4);
116
117 let tool_definition_count = body
118 .get("tools")
119 .and_then(|t| t.as_array())
120 .map_or(0, Vec::len);
121
122 let mut user_message_tokens = 0;
123 let mut assistant_message_tokens = 0;
124 let mut tool_result_tokens = 0;
125 let mut image_count = 0;
126 let mut message_count = 0;
127 let mut subagent_tokens = 0;
128 let mut summarized_conversation_tokens = 0;
129
130 if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
131 message_count = messages.len();
132 for msg in messages {
133 let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
134 let content_tokens = estimate_content_tokens(msg.get("content"));
135 let has_images = count_images(msg.get("content"));
136 image_count += has_images;
137
138 match role {
139 "user" => {
140 if has_tool_results(msg.get("content")) {
141 tool_result_tokens += content_tokens;
142 } else if is_summary_message(msg.get("content")) {
143 summarized_conversation_tokens += content_tokens;
144 } else if is_subagent_message(msg.get("content")) {
145 subagent_tokens += content_tokens;
146 } else {
147 user_message_tokens += content_tokens;
148 }
149 }
150 "assistant" => assistant_message_tokens += content_tokens,
151 _ => user_message_tokens += content_tokens,
152 }
153 }
154 }
155
156 let conversation_tokens = user_message_tokens + assistant_message_tokens;
157
158 let total_input_tokens = system_prompt_tokens
159 + rules_tokens
160 + skills_tokens
161 + mcp_config_tokens
162 + user_message_tokens
163 + assistant_message_tokens
164 + tool_definition_tokens
165 + tool_result_tokens
166 + subagent_tokens
167 + summarized_conversation_tokens;
168
169 RequestBreakdown {
170 provider: Provider::Anthropic,
171 model,
172 system_prompt_tokens,
173 user_message_tokens,
174 assistant_message_tokens,
175 tool_definition_tokens,
176 tool_definition_count,
177 tool_result_tokens,
178 image_count,
179 total_input_tokens,
180 message_count,
181 rules_tokens,
182 skills_tokens,
183 mcp_config_tokens,
184 subagent_tokens,
185 summarized_conversation_tokens,
186 conversation_tokens,
187 }
188}
189
190fn analyze_openai(body: &Value) -> RequestBreakdown {
191 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
294fn 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 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 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 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
755pub 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 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}