1use crate::brain::LlmProvider;
14use crate::config::{CouncilConfig, CouncilMemberConfig, VotingStrategy};
15use crate::error::LlmError;
16use crate::types::{CompletionRequest, Message, TokenUsage};
17use std::sync::Arc;
18use std::time::Instant;
19use tracing::{debug, info, warn};
20
21#[derive(Debug, Clone)]
23pub struct DetectedProvider {
24 pub provider_type: String,
26 pub model: String,
28 pub api_key_env: String,
30 pub is_local: bool,
32 pub base_url: Option<String>,
34}
35
36#[derive(Debug, Clone)]
38pub struct CouncilMemberResponse {
39 pub model_name: String,
41 pub provider: String,
43 pub response_text: String,
45 pub usage: TokenUsage,
47 pub cost: f64,
49 pub latency_ms: u64,
51}
52
53#[derive(Debug, Clone)]
55pub struct PeerReview {
56 pub reviewer_model: String,
58 pub reviewed_index: usize,
60 pub score: u8,
62 pub reasoning: String,
64 pub strengths: Vec<String>,
66 pub weaknesses: Vec<String>,
68}
69
70#[derive(Debug, Clone)]
72pub struct CouncilResult {
73 pub synthesis: String,
75 pub member_responses: Vec<CouncilMemberResponse>,
77 pub peer_reviews: Vec<PeerReview>,
79 pub total_usage: TokenUsage,
81 pub total_cost: f64,
83 pub total_latency_ms: u64,
85}
86
87pub struct PlanningCouncil {
89 members: Vec<(Arc<dyn LlmProvider>, CouncilMemberConfig)>,
91 chairman_index: usize,
93 config: CouncilConfig,
95}
96
97impl PlanningCouncil {
98 pub fn new(
104 members: Vec<(Arc<dyn LlmProvider>, CouncilMemberConfig)>,
105 config: CouncilConfig,
106 ) -> Result<Self, LlmError> {
107 if members.len() < 2 {
108 return Err(LlmError::ApiRequest {
109 message: format!("Council requires at least 2 members, got {}", members.len()),
110 });
111 }
112
113 let chairman_index = if let Some(ref chairman_model) = config.chairman_model {
115 members
116 .iter()
117 .position(|(_, cfg)| cfg.model == *chairman_model)
118 .unwrap_or(0)
119 } else {
120 members
121 .iter()
122 .enumerate()
123 .max_by_key(|(_, (provider, _))| provider.context_window())
124 .map(|(i, _)| i)
125 .unwrap_or(0)
126 };
127
128 info!(
129 members = members.len(),
130 chairman = members[chairman_index].1.model.as_str(),
131 strategy = %config.voting_strategy,
132 "Planning council created"
133 );
134
135 Ok(Self {
136 members,
137 chairman_index,
138 config,
139 })
140 }
141
142 pub async fn deliberate(&self, question: &str) -> Result<CouncilResult, LlmError> {
144 let start = Instant::now();
145
146 info!(
148 members = self.members.len(),
149 "Stage 1: Querying council members"
150 );
151 let member_responses = self.stage_query(question).await?;
152
153 let peer_reviews = if self.config.enable_peer_review && self.members.len() > 2 {
155 info!("Stage 2: Running peer review");
156 self.stage_peer_review(question, &member_responses).await
157 } else {
158 debug!("Skipping peer review (disabled or < 3 members)");
159 Vec::new()
160 };
161
162 info!(
164 chairman = self.members[self.chairman_index].1.model.as_str(),
165 "Stage 3: Chairman synthesis"
166 );
167 let synthesis = self
168 .stage_synthesis(question, &member_responses, &peer_reviews)
169 .await?;
170
171 let mut total_usage = TokenUsage {
173 input_tokens: 0,
174 output_tokens: 0,
175 };
176 let mut total_cost = 0.0;
177
178 for resp in &member_responses {
179 total_usage.input_tokens += resp.usage.input_tokens;
180 total_usage.output_tokens += resp.usage.output_tokens;
181 total_cost += resp.cost;
182 }
183
184 total_usage.output_tokens += synthesis.len() / 4; let total_latency_ms = start.elapsed().as_millis() as u64;
188
189 info!(
190 total_cost = format!("${:.4}", total_cost),
191 total_latency_ms,
192 responses = member_responses.len(),
193 reviews = peer_reviews.len(),
194 "Council deliberation complete"
195 );
196
197 Ok(CouncilResult {
198 synthesis,
199 member_responses,
200 peer_reviews,
201 total_usage,
202 total_cost,
203 total_latency_ms,
204 })
205 }
206
207 async fn stage_query(&self, question: &str) -> Result<Vec<CouncilMemberResponse>, LlmError> {
209 let futures: Vec<_> = self
210 .members
211 .iter()
212 .map(|(provider, cfg)| {
213 let provider = Arc::clone(provider);
214 let model = cfg.model.clone();
215 let provider_name = cfg.provider.clone();
216 let max_tokens = self.config.max_member_tokens;
217 let question = question.to_string();
218
219 async move {
220 let start = Instant::now();
221 let request = CompletionRequest {
222 messages: vec![
223 Message::system(
224 "You are a council member deliberating on a planning question. \
225 Provide your best analysis with concrete, actionable recommendations.",
226 ),
227 Message::user(&question),
228 ],
229 tools: None,
230 temperature: 0.7,
231 max_tokens: Some(max_tokens),
232 stop_sequences: vec![],
233 model: Some(model.clone()),
234 };
235
236 let result = provider.complete(request).await;
237 let latency_ms = start.elapsed().as_millis() as u64;
238
239 match result {
240 Ok(response) => {
241 let (cost_in, cost_out) = provider.cost_per_token();
242 let cost = (response.usage.input_tokens as f64 * cost_in)
243 + (response.usage.output_tokens as f64 * cost_out);
244 let text = response
245 .message
246 .content
247 .as_text()
248 .unwrap_or("")
249 .to_string();
250
251 Ok(CouncilMemberResponse {
252 model_name: model,
253 provider: provider_name,
254 response_text: text,
255 usage: response.usage,
256 cost,
257 latency_ms,
258 })
259 }
260 Err(e) => {
261 warn!(
262 model = model.as_str(),
263 error = %e,
264 "Council member failed to respond"
265 );
266 Err(e)
267 }
268 }
269 }
270 })
271 .collect();
272
273 let results = futures::future::join_all(futures).await;
274
275 let mut responses = Vec::new();
277 for result in results {
278 match result {
279 Ok(resp) => responses.push(resp),
280 Err(e) => {
281 warn!(error = %e, "Skipping failed council member");
282 }
283 }
284 }
285
286 if responses.is_empty() {
287 return Err(LlmError::ApiRequest {
288 message: "All council members failed to respond".to_string(),
289 });
290 }
291
292 Ok(responses)
293 }
294
295 async fn stage_peer_review(
297 &self,
298 question: &str,
299 responses: &[CouncilMemberResponse],
300 ) -> Vec<PeerReview> {
301 let mut reviews = Vec::new();
302
303 let labels: Vec<String> = (0..responses.len())
305 .map(|i| format!("Response {}", (b'A' + i as u8) as char))
306 .collect();
307
308 let mut responses_text = String::new();
309 for (i, resp) in responses.iter().enumerate() {
310 responses_text.push_str(&format!(
311 "\n--- {} ---\n{}\n",
312 labels[i], resp.response_text
313 ));
314 }
315
316 for (reviewer_idx, (provider, cfg)) in self.members.iter().enumerate() {
317 for (reviewed_idx, _) in responses.iter().enumerate() {
318 if reviewer_idx == reviewed_idx {
319 continue; }
321
322 let prompt = format!(
323 "You are reviewing responses to this question:\n\n\"{}\"\n\n\
324 Here are all the responses:\n{}\n\n\
325 Please review {} specifically.\n\
326 Rate it 1-10 and provide:\n\
327 - Score (1-10)\n\
328 - Brief reasoning\n\
329 - Key strengths (bullet points)\n\
330 - Key weaknesses (bullet points)\n\n\
331 Format your response as:\n\
332 SCORE: <number>\n\
333 REASONING: <text>\n\
334 STRENGTHS:\n- <point>\n\
335 WEAKNESSES:\n- <point>",
336 question, responses_text, labels[reviewed_idx]
337 );
338
339 let request = CompletionRequest {
340 messages: vec![
341 Message::system(
342 "You are an impartial reviewer evaluating LLM responses. \
343 Be objective and constructive.",
344 ),
345 Message::user(&prompt),
346 ],
347 tools: None,
348 temperature: 0.3,
349 max_tokens: Some(512),
350 stop_sequences: vec![],
351 model: Some(cfg.model.clone()),
352 };
353
354 match provider.complete(request).await {
355 Ok(response) => {
356 let text = response.message.content.as_text().unwrap_or("").to_string();
357 let review = parse_peer_review(&cfg.model, reviewed_idx, &text);
358 reviews.push(review);
359 }
360 Err(e) => {
361 warn!(
362 reviewer = cfg.model.as_str(),
363 error = %e,
364 "Peer review failed"
365 );
366 }
367 }
368 }
369 }
370
371 reviews
372 }
373
374 async fn stage_synthesis(
376 &self,
377 question: &str,
378 responses: &[CouncilMemberResponse],
379 reviews: &[PeerReview],
380 ) -> Result<String, LlmError> {
381 let (chairman_provider, chairman_cfg) = &self.members[self.chairman_index];
382
383 let mut prompt = format!(
385 "You are the chairman of an LLM council. Multiple models have responded \
386 to the following question:\n\n\"{}\"\n\n",
387 question
388 );
389
390 for (i, resp) in responses.iter().enumerate() {
392 let label = (b'A' + i as u8) as char;
393 prompt.push_str(&format!(
394 "--- Response {} ---\n{}\n\n",
395 label, resp.response_text
396 ));
397 }
398
399 if !reviews.is_empty() {
401 prompt.push_str("--- Peer Reviews ---\n");
402 for review in reviews {
403 let reviewed_label = (b'A' + review.reviewed_index as u8) as char;
404 prompt.push_str(&format!(
405 "Review of Response {} (score: {}/10): {}\n",
406 reviewed_label, review.score, review.reasoning
407 ));
408 }
409 prompt.push('\n');
410 }
411
412 let strategy_instruction = match self.config.voting_strategy {
413 VotingStrategy::ChairmanSynthesis => {
414 "Synthesize the best elements from all responses into a comprehensive, \
415 well-structured final answer. Resolve any contradictions and add your own insights."
416 }
417 VotingStrategy::HighestScore => {
418 "Identify the highest-quality response based on peer reviews. \
419 Present it as the final answer with minimal modifications."
420 }
421 VotingStrategy::MajorityConsensus => {
422 "Identify the points where most responses agree. \
423 Present the consensus view, noting any significant dissenting perspectives."
424 }
425 };
426
427 prompt.push_str(&format!(
428 "Your task: {}\n\nProvide your final synthesized answer:",
429 strategy_instruction
430 ));
431
432 let request = CompletionRequest {
433 messages: vec![
434 Message::system(
435 "You are the chairman of an LLM council, responsible for producing \
436 a final synthesized answer from multiple model responses.",
437 ),
438 Message::user(&prompt),
439 ],
440 tools: None,
441 temperature: 0.5,
442 max_tokens: Some(self.config.max_member_tokens * 2),
443 stop_sequences: vec![],
444 model: Some(chairman_cfg.model.clone()),
445 };
446
447 let response = chairman_provider.complete(request).await?;
448 Ok(response.message.content.as_text().unwrap_or("").to_string())
449 }
450}
451
452fn parse_peer_review(reviewer_model: &str, reviewed_index: usize, text: &str) -> PeerReview {
454 let mut score: u8 = 5;
455 let mut reasoning = String::new();
456 let mut strengths = Vec::new();
457 let mut weaknesses = Vec::new();
458
459 let mut in_strengths = false;
460 let mut in_weaknesses = false;
461
462 for line in text.lines() {
463 let trimmed = line.trim();
464
465 if let Some(s) = trimmed.strip_prefix("SCORE:") {
466 if let Ok(n) = s.trim().parse::<u8>() {
467 score = n.clamp(1, 10);
468 }
469 in_strengths = false;
470 in_weaknesses = false;
471 } else if let Some(r) = trimmed.strip_prefix("REASONING:") {
472 reasoning = r.trim().to_string();
473 in_strengths = false;
474 in_weaknesses = false;
475 } else if trimmed == "STRENGTHS:" {
476 in_strengths = true;
477 in_weaknesses = false;
478 } else if trimmed == "WEAKNESSES:" {
479 in_strengths = false;
480 in_weaknesses = true;
481 } else if let Some(item) = trimmed.strip_prefix("- ") {
482 if in_strengths {
483 strengths.push(item.to_string());
484 } else if in_weaknesses {
485 weaknesses.push(item.to_string());
486 }
487 }
488 }
489
490 PeerReview {
491 reviewer_model: reviewer_model.to_string(),
492 reviewed_index,
493 score,
494 reasoning,
495 strengths,
496 weaknesses,
497 }
498}
499
500pub fn should_use_council(task: &str) -> bool {
504 let lower = task.to_lowercase();
505
506 let planning_keywords = [
508 "plan",
509 "design",
510 "architect",
511 "strategy",
512 "approach",
513 "compare",
514 "evaluate",
515 "trade-off",
516 "tradeoff",
517 "pros and cons",
518 "best way to",
519 "how should",
520 "what approach",
521 "recommend",
522 "analyze",
523 "brainstorm",
524 "review my",
525 "help me decide",
526 "which is better",
527 ];
528
529 let concrete_keywords = [
531 "fix",
532 "write",
533 "create file",
534 "delete",
535 "run",
536 "execute",
537 "install",
538 "commit",
539 "push",
540 "deploy",
541 "read file",
542 "open",
543 "close",
544 "set",
545 "update",
546 ];
547
548 let has_planning = planning_keywords.iter().any(|kw| lower.contains(kw));
550
551 let has_concrete = concrete_keywords.iter().any(|kw| lower.contains(kw));
553
554 has_planning && !has_concrete
556}
557
558pub async fn detect_available_providers() -> Vec<DetectedProvider> {
566 let mut providers = Vec::new();
567
568 if std::env::var("OPENAI_API_KEY").is_ok() {
570 providers.push(DetectedProvider {
571 provider_type: "openai".to_string(),
572 model: "gpt-4o".to_string(),
573 api_key_env: "OPENAI_API_KEY".to_string(),
574 is_local: false,
575 base_url: None,
576 });
577 }
578
579 if std::env::var("ANTHROPIC_API_KEY").is_ok() {
580 providers.push(DetectedProvider {
581 provider_type: "anthropic".to_string(),
582 model: "claude-sonnet-4-20250514".to_string(),
583 api_key_env: "ANTHROPIC_API_KEY".to_string(),
584 is_local: false,
585 base_url: None,
586 });
587 }
588
589 if std::env::var("GEMINI_API_KEY")
590 .or_else(|_| std::env::var("GOOGLE_API_KEY"))
591 .is_ok()
592 {
593 let env_key = if std::env::var("GEMINI_API_KEY").is_ok() {
594 "GEMINI_API_KEY"
595 } else {
596 "GOOGLE_API_KEY"
597 };
598 providers.push(DetectedProvider {
599 provider_type: "gemini".to_string(),
600 model: "gemini-2.0-flash".to_string(),
601 api_key_env: env_key.to_string(),
602 is_local: false,
603 base_url: None,
604 });
605 }
606
607 match detect_ollama_models().await {
609 Ok(models) => {
610 for model in models {
611 providers.push(DetectedProvider {
612 provider_type: "ollama".to_string(),
613 model,
614 api_key_env: String::new(),
615 is_local: true,
616 base_url: Some("http://127.0.0.1:11434/v1".to_string()),
617 });
618 }
619 }
620 Err(e) => {
621 debug!(error = %e, "Ollama not detected");
622 }
623 }
624
625 providers
626}
627
628pub async fn detect_ollama_models() -> Result<Vec<String>, LlmError> {
632 let client = reqwest::Client::builder()
633 .timeout(std::time::Duration::from_secs(3))
634 .build()
635 .map_err(|e| LlmError::Connection {
636 message: format!("Failed to build HTTP client: {}", e),
637 })?;
638
639 let response = client
640 .get("http://127.0.0.1:11434/api/tags")
641 .send()
642 .await
643 .map_err(|e| LlmError::Connection {
644 message: format!("Ollama not available: {}", e),
645 })?;
646
647 let body: serde_json::Value = response.json().await.map_err(|e| LlmError::ResponseParse {
648 message: format!("Failed to parse Ollama response: {}", e),
649 })?;
650
651 let models = body["models"]
652 .as_array()
653 .map(|arr| {
654 arr.iter()
655 .filter_map(|m| m["name"].as_str().map(|s| s.to_string()))
656 .collect()
657 })
658 .unwrap_or_default();
659
660 Ok(models)
661}
662
663pub fn providers_to_council_members(providers: &[DetectedProvider]) -> Vec<CouncilMemberConfig> {
665 providers
666 .iter()
667 .map(|p| CouncilMemberConfig {
668 provider: p.provider_type.clone(),
669 model: p.model.clone(),
670 api_key_env: p.api_key_env.clone(),
671 base_url: p.base_url.clone(),
672 weight: 1.0,
673 })
674 .collect()
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use crate::brain::MockLlmProvider;
681
682 #[test]
683 fn test_should_use_council_planning_tasks() {
684 assert!(should_use_council(
685 "Help me plan the architecture for a new API"
686 ));
687 assert!(should_use_council("What's the best approach for caching?"));
688 assert!(should_use_council(
689 "Compare REST vs GraphQL for this project"
690 ));
691 assert!(should_use_council(
692 "Analyze the trade-offs of microservices"
693 ));
694 assert!(should_use_council("Help me design a database schema"));
695 assert!(should_use_council(
696 "What strategy should I use for testing?"
697 ));
698 assert!(should_use_council("Brainstorm ideas for the landing page"));
699 }
700
701 #[test]
702 fn test_should_use_council_concrete_tasks() {
703 assert!(!should_use_council("Fix the bug in main.rs"));
704 assert!(!should_use_council("Write a function to sort an array"));
705 assert!(!should_use_council("Create file config.toml"));
706 assert!(!should_use_council("Delete the old test file"));
707 assert!(!should_use_council("Run cargo test"));
708 assert!(!should_use_council("Install the reqwest dependency"));
709 assert!(!should_use_council("Read file src/main.rs"));
710 }
711
712 #[test]
713 fn test_should_use_council_mixed() {
714 assert!(!should_use_council("Plan and write the implementation"));
716 assert!(should_use_council(
718 "Help me plan the implementation approach"
719 ));
720 }
721
722 #[test]
723 fn test_council_requires_at_least_two_members() {
724 let provider = Arc::new(MockLlmProvider::new()) as Arc<dyn LlmProvider>;
725 let config = CouncilConfig::default();
726
727 let result = PlanningCouncil::new(
729 vec![(provider.clone(), CouncilMemberConfig::default())],
730 config.clone(),
731 );
732 assert!(result.is_err());
733
734 let result = PlanningCouncil::new(
736 vec![
737 (provider.clone(), CouncilMemberConfig::default()),
738 (
739 provider.clone(),
740 CouncilMemberConfig {
741 model: "model-b".to_string(),
742 ..Default::default()
743 },
744 ),
745 ],
746 config,
747 );
748 assert!(result.is_ok());
749 }
750
751 #[tokio::test]
752 async fn test_council_deliberation_with_mocks() {
753 let provider_a = Arc::new(MockLlmProvider::with_response("Response from model A"))
754 as Arc<dyn LlmProvider>;
755 let provider_b = Arc::new(MockLlmProvider::with_response("Response from model B"))
756 as Arc<dyn LlmProvider>;
757 let provider_c = Arc::new(MockLlmProvider::with_response("Response from model C"))
758 as Arc<dyn LlmProvider>;
759
760 let config = CouncilConfig {
761 enabled: true,
762 enable_peer_review: false, ..Default::default()
764 };
765
766 let council = PlanningCouncil::new(
767 vec![
768 (
769 provider_a,
770 CouncilMemberConfig {
771 model: "model-a".to_string(),
772 ..Default::default()
773 },
774 ),
775 (
776 provider_b,
777 CouncilMemberConfig {
778 model: "model-b".to_string(),
779 ..Default::default()
780 },
781 ),
782 (
783 provider_c,
784 CouncilMemberConfig {
785 model: "model-c".to_string(),
786 ..Default::default()
787 },
788 ),
789 ],
790 config,
791 )
792 .unwrap();
793
794 let result = council
795 .deliberate("What architecture should we use?")
796 .await
797 .unwrap();
798
799 assert_eq!(result.member_responses.len(), 3);
801 assert!(result.peer_reviews.is_empty());
803 assert!(!result.synthesis.is_empty());
805 let _ = result.total_cost;
807 let _ = result.total_latency_ms;
808 }
809
810 #[tokio::test]
811 async fn test_detect_available_providers_no_panic() {
812 let providers = detect_available_providers().await;
814 let _ = providers;
817 }
818
819 #[test]
820 fn test_parse_peer_review() {
821 let text = "SCORE: 8\n\
822 REASONING: Good analysis with practical recommendations.\n\
823 STRENGTHS:\n\
824 - Clear structure\n\
825 - Actionable steps\n\
826 WEAKNESSES:\n\
827 - Missing error handling consideration\n\
828 - No cost analysis";
829
830 let review = parse_peer_review("reviewer-model", 0, text);
831 assert_eq!(review.score, 8);
832 assert_eq!(review.reviewed_index, 0);
833 assert!(review.reasoning.contains("Good analysis"));
834 assert_eq!(review.strengths.len(), 2);
835 assert_eq!(review.weaknesses.len(), 2);
836 assert_eq!(review.strengths[0], "Clear structure");
837 assert_eq!(review.weaknesses[0], "Missing error handling consideration");
838 }
839
840 #[test]
841 fn test_parse_peer_review_malformed() {
842 let review = parse_peer_review("reviewer", 1, "Just some random text without format");
844 assert_eq!(review.score, 5); assert!(review.reasoning.is_empty());
846 assert!(review.strengths.is_empty());
847 assert!(review.weaknesses.is_empty());
848 }
849
850 #[test]
851 fn test_council_peer_review_anonymization() {
852 let labels: Vec<String> = (0..3)
856 .map(|i| format!("Response {}", (b'A' + i as u8) as char))
857 .collect();
858 assert_eq!(labels, vec!["Response A", "Response B", "Response C"]);
859 }
860
861 #[test]
862 fn test_providers_to_council_members() {
863 let providers = vec![
864 DetectedProvider {
865 provider_type: "openai".to_string(),
866 model: "gpt-4o".to_string(),
867 api_key_env: "OPENAI_API_KEY".to_string(),
868 is_local: false,
869 base_url: None,
870 },
871 DetectedProvider {
872 provider_type: "ollama".to_string(),
873 model: "llama3.2".to_string(),
874 api_key_env: String::new(),
875 is_local: true,
876 base_url: Some("http://127.0.0.1:11434/v1".to_string()),
877 },
878 ];
879
880 let members = providers_to_council_members(&providers);
881 assert_eq!(members.len(), 2);
882 assert_eq!(members[0].provider, "openai");
883 assert_eq!(members[0].model, "gpt-4o");
884 assert_eq!(members[1].provider, "ollama");
885 assert_eq!(members[1].model, "llama3.2");
886 assert_eq!(
887 members[1].base_url,
888 Some("http://127.0.0.1:11434/v1".to_string())
889 );
890 }
891
892 #[tokio::test]
893 async fn test_council_with_two_members_no_peer_review() {
894 let provider_a =
896 Arc::new(MockLlmProvider::with_response("Answer A")) as Arc<dyn LlmProvider>;
897 let provider_b =
898 Arc::new(MockLlmProvider::with_response("Answer B")) as Arc<dyn LlmProvider>;
899
900 let config = CouncilConfig {
901 enabled: true,
902 enable_peer_review: true, ..Default::default()
904 };
905
906 let council = PlanningCouncil::new(
907 vec![
908 (
909 provider_a,
910 CouncilMemberConfig {
911 model: "model-a".to_string(),
912 ..Default::default()
913 },
914 ),
915 (
916 provider_b,
917 CouncilMemberConfig {
918 model: "model-b".to_string(),
919 ..Default::default()
920 },
921 ),
922 ],
923 config,
924 )
925 .unwrap();
926
927 let result = council.deliberate("Compare approaches").await.unwrap();
928
929 assert_eq!(result.member_responses.len(), 2);
930 assert!(result.peer_reviews.is_empty());
932 assert!(!result.synthesis.is_empty());
933 }
934
935 #[tokio::test]
936 async fn test_council_cost_tracking() {
937 let provider_a =
938 Arc::new(MockLlmProvider::with_response("Cost test A")) as Arc<dyn LlmProvider>;
939 let provider_b =
940 Arc::new(MockLlmProvider::with_response("Cost test B")) as Arc<dyn LlmProvider>;
941 let provider_c =
942 Arc::new(MockLlmProvider::with_response("Cost test C")) as Arc<dyn LlmProvider>;
943
944 let config = CouncilConfig {
945 enabled: true,
946 enable_peer_review: false,
947 ..Default::default()
948 };
949
950 let council = PlanningCouncil::new(
951 vec![
952 (
953 provider_a,
954 CouncilMemberConfig {
955 model: "model-a".to_string(),
956 ..Default::default()
957 },
958 ),
959 (
960 provider_b,
961 CouncilMemberConfig {
962 model: "model-b".to_string(),
963 ..Default::default()
964 },
965 ),
966 (
967 provider_c,
968 CouncilMemberConfig {
969 model: "model-c".to_string(),
970 ..Default::default()
971 },
972 ),
973 ],
974 config,
975 )
976 .unwrap();
977
978 let result = council.deliberate("Test cost tracking").await.unwrap();
979
980 let member_cost_sum: f64 = result.member_responses.iter().map(|r| r.cost).sum();
982 assert!((result.total_cost - member_cost_sum).abs() < f64::EPSILON);
983 }
984}