1use crate::capabilities::{CapabilityRequirements, ModelCapabilities};
36use crate::client::{LLMClient, TokenUsage};
37#[cfg(test)]
38use ares_tools::Tool;
39use ares_tools::Tools;
40use ares_types::types::{ContentPart, Result, ToolCall};
41use crate::client::CacheControl;
42use cordis::Context;
43use futures::future::join_all;
44use serde::{Deserialize, Serialize};
45use std::sync::Arc;
46use std::time::{Duration, Instant};
47use tokio::time::timeout;
48
49use serde_json::Value;
50use std::collections::HashSet;
51use std::fmt;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
56#[serde(rename_all = "snake_case")]
57pub enum LoadBalanceStrategy {
58 #[default]
59 RoundRobin,
60 LeastLoaded,
61 Affinity,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct ProviderEndpoint {
66 pub id: String,
67 pub model: String,
68 pub capabilities: ModelCapabilities,
69 #[serde(default)]
70 pub in_flight_requests: u32,
71 #[serde(default)]
72 pub affinity_group: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct CoordinatorConfig {
77 pub providers: Vec<ProviderEndpoint>,
78 #[serde(default)]
79 pub fallback_chain: Vec<String>,
80 #[serde(default)]
81 pub load_balance: LoadBalanceStrategy,
82 #[serde(default)]
83 pub requirements: CapabilityRequirements,
84 #[serde(default)]
85 pub affinity_key: Option<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct RouteDecision {
90 pub provider_id: String,
91 pub model: String,
92 pub reason: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct DispatchPlan {
97 pub primary: RouteDecision,
98 pub fallbacks: Vec<RouteDecision>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub struct DispatchState {
103 pub round_robin_cursor: usize,
104 pub session_affinity: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum CoordinatorDispatchError {
109 NoAvailableProvider(String),
110 AllFailed(Vec<String>),
111 InvalidConfig(String),
112}
113
114impl fmt::Display for CoordinatorDispatchError {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 Self::NoAvailableProvider(msg) => write!(f, "no available provider: {msg}"),
118 Self::AllFailed(errors) => write!(
119 f,
120 "all providers failed ({} attempts): {}",
121 errors.len(),
122 errors.join("; ")
123 ),
124 Self::InvalidConfig(msg) => write!(f, "invalid coordinator config: {msg}"),
125 }
126 }
127}
128
129impl std::error::Error for CoordinatorDispatchError {}
130
131impl fmt::Display for RouteDecision {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 write!(
134 f,
135 "route {} ({}) — {}",
136 self.provider_id, self.model, self.reason
137 )
138 }
139}
140
141impl fmt::Display for DispatchPlan {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 write!(f, "dispatch primary={}", self.primary)?;
144 if !self.fallbacks.is_empty() {
145 let ids: Vec<_> = self
146 .fallbacks
147 .iter()
148 .map(|r| r.provider_id.as_str())
149 .collect();
150 write!(f, ", fallbacks=[{}]", ids.join(", "))?;
151 }
152 Ok(())
153 }
154}
155
156impl fmt::Display for LoadBalanceStrategy {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 match self {
159 Self::RoundRobin => write!(f, "round_robin"),
160 Self::LeastLoaded => write!(f, "least_loaded"),
161 Self::Affinity => write!(f, "affinity"),
162 }
163 }
164}
165
166pub fn validate_coordinator_config(
167 config: &CoordinatorConfig,
168) -> std::result::Result<(), CoordinatorDispatchError> {
169 if config.providers.is_empty() {
170 return Err(CoordinatorDispatchError::InvalidConfig(
171 "providers list must not be empty".to_string(),
172 ));
173 }
174 let mut seen = HashSet::new();
175 for provider in &config.providers {
176 if !seen.insert(provider.id.clone()) {
177 return Err(CoordinatorDispatchError::InvalidConfig(format!(
178 "duplicate provider id '{}'",
179 provider.id
180 )));
181 }
182 if provider.id.trim().is_empty() {
183 return Err(CoordinatorDispatchError::InvalidConfig(
184 "provider id must not be empty".to_string(),
185 ));
186 }
187 }
188 for fallback_id in &config.fallback_chain {
189 if !config.providers.iter().any(|p| &p.id == fallback_id) {
190 return Err(CoordinatorDispatchError::InvalidConfig(format!(
191 "fallback_chain references unknown provider '{fallback_id}'"
192 )));
193 }
194 }
195 Ok(())
196}
197
198fn eligible_providers<'a>(
199 config: &'a CoordinatorConfig,
200 exclude: &HashSet<&str>,
201) -> Vec<&'a ProviderEndpoint> {
202 let mut eligible: Vec<_> = config
203 .providers
204 .iter()
205 .filter(|p| !exclude.contains(p.id.as_str()))
206 .filter(|p| p.capabilities.satisfies(&config.requirements))
207 .collect();
208 eligible.sort_by(|a, b| {
209 let score_a = a.capabilities.score(&config.requirements);
210 let score_b = b.capabilities.score(&config.requirements);
211 score_b.cmp(&score_a).then_with(|| a.id.cmp(&b.id))
212 });
213 eligible
214}
215
216fn pick_by_strategy<'a>(
217 config: &CoordinatorConfig,
218 state: &mut DispatchState,
219 eligible: &'a [&'a ProviderEndpoint],
220) -> &'a ProviderEndpoint {
221 match config.load_balance {
222 LoadBalanceStrategy::RoundRobin => {
223 let idx = state.round_robin_cursor % eligible.len();
224 state.round_robin_cursor = state.round_robin_cursor.saturating_add(1);
225 eligible[idx]
226 }
227 LoadBalanceStrategy::LeastLoaded => eligible
228 .iter()
229 .copied()
230 .min_by_key(|p| (p.in_flight_requests, p.id.as_str()))
231 .expect("eligible is non-empty"),
232 LoadBalanceStrategy::Affinity => {
233 let key = state
234 .session_affinity
235 .as_deref()
236 .or(config.affinity_key.as_deref());
237 if let Some(affinity) = key {
238 if let Some(match_provider) = eligible
239 .iter()
240 .copied()
241 .find(|p| p.affinity_group.as_deref() == Some(affinity))
242 {
243 return match_provider;
244 }
245 }
246 eligible[0]
247 }
248 }
249}
250
251fn endpoint_to_decision(endpoint: &ProviderEndpoint, reason: impl Into<String>) -> RouteDecision {
252 RouteDecision {
253 provider_id: endpoint.id.clone(),
254 model: endpoint.model.clone(),
255 reason: reason.into(),
256 }
257}
258
259pub fn route_request(
260 config: &CoordinatorConfig,
261 state: &mut DispatchState,
262 exclude: &[&str],
263) -> std::result::Result<RouteDecision, CoordinatorDispatchError> {
264 validate_coordinator_config(config)?;
265 let exclude_set: HashSet<&str> = exclude.iter().copied().collect();
266 let eligible = eligible_providers(config, &exclude_set);
267 if eligible.is_empty() {
268 return Err(CoordinatorDispatchError::NoAvailableProvider(
269 "no provider satisfies requirements".to_string(),
270 ));
271 }
272 let selected = pick_by_strategy(config, state, &eligible);
273 let reason = format!(
274 "selected via {} (score {})",
275 config.load_balance,
276 selected.capabilities.score(&config.requirements)
277 );
278 Ok(endpoint_to_decision(selected, reason))
279}
280
281pub fn select_fallback(
282 config: &CoordinatorConfig,
283 plan: &DispatchPlan,
284 failed_provider_id: &str,
285 attempt_errors: &[String],
286) -> std::result::Result<Option<RouteDecision>, CoordinatorDispatchError> {
287 validate_coordinator_config(config)?;
288 let mut tried: HashSet<&str> = HashSet::new();
289 tried.insert(plan.primary.provider_id.as_str());
290 for fb in &plan.fallbacks {
291 tried.insert(fb.provider_id.as_str());
292 }
293 tried.insert(failed_provider_id);
294 for fallback_id in &config.fallback_chain {
295 if tried.contains(fallback_id.as_str()) {
296 continue;
297 }
298 let Some(endpoint) = config.providers.iter().find(|p| &p.id == fallback_id) else {
299 continue;
300 };
301 if !endpoint.capabilities.satisfies(&config.requirements) {
302 continue;
303 }
304 return Ok(Some(endpoint_to_decision(
305 endpoint,
306 format!("fallback after failure of '{failed_provider_id}'"),
307 )));
308 }
309 let remaining: Vec<_> = config
310 .providers
311 .iter()
312 .filter(|p| !tried.contains(p.id.as_str()))
313 .filter(|p| p.capabilities.satisfies(&config.requirements))
314 .collect();
315 if let Some(endpoint) = remaining.first() {
316 return Ok(Some(endpoint_to_decision(
317 endpoint,
318 format!("secondary fallback after '{failed_provider_id}'"),
319 )));
320 }
321 if attempt_errors.is_empty() {
322 Ok(None)
323 } else {
324 Err(CoordinatorDispatchError::AllFailed(attempt_errors.to_vec()))
325 }
326}
327
328pub fn parse_coordinator_response(
329 payload: &str,
330) -> std::result::Result<DispatchPlan, CoordinatorDispatchError> {
331 let value: Value = serde_json::from_str(payload)
332 .map_err(|e| CoordinatorDispatchError::InvalidConfig(format!("invalid JSON: {e}")))?;
333 let primary_value = value.get("primary").ok_or_else(|| {
334 CoordinatorDispatchError::InvalidConfig("missing 'primary' field".to_string())
335 })?;
336 let primary: RouteDecision = serde_json::from_value(primary_value.clone()).map_err(|e| {
337 CoordinatorDispatchError::InvalidConfig(format!("invalid primary route: {e}"))
338 })?;
339 let fallbacks = match value.get("fallbacks") {
340 Some(Value::Array(items)) => items
341 .iter()
342 .map(|item| {
343 serde_json::from_value(item.clone()).map_err(|e| {
344 CoordinatorDispatchError::InvalidConfig(format!("invalid fallback route: {e}"))
345 })
346 })
347 .collect::<std::result::Result<Vec<_>, _>>()?,
348 Some(_) => {
349 return Err(CoordinatorDispatchError::InvalidConfig(
350 "'fallbacks' must be an array".to_string(),
351 ));
352 }
353 None => Vec::new(),
354 };
355 if primary.provider_id.trim().is_empty() {
356 return Err(CoordinatorDispatchError::InvalidConfig(
357 "primary provider_id must not be empty".to_string(),
358 ));
359 }
360 Ok(DispatchPlan { primary, fallbacks })
361}
362
363pub fn build_dispatch_plan(
364 config: &CoordinatorConfig,
365 state: &mut DispatchState,
366) -> std::result::Result<DispatchPlan, CoordinatorDispatchError> {
367 validate_coordinator_config(config)?;
368 let primary = route_request(config, state, &[])?;
369 let mut fallbacks = Vec::new();
370 let mut failed_id = primary.provider_id.clone();
371 loop {
372 let plan_snapshot = DispatchPlan {
373 primary: primary.clone(),
374 fallbacks: fallbacks.clone(),
375 };
376 match select_fallback(config, &plan_snapshot, &failed_id, &[]) {
377 Ok(Some(next)) => {
378 if fallbacks.iter().any(|f| f.provider_id == next.provider_id) {
379 break;
380 }
381 failed_id = next.provider_id.clone();
382 fallbacks.push(next);
383 }
384 Ok(None) => break,
385 Err(e) => return Err(e),
386 }
387 if !config.fallback_chain.is_empty() && fallbacks.len() >= config.fallback_chain.len() {
388 break;
389 }
390 }
391 Ok(DispatchPlan { primary, fallbacks })
392}
393
394#[derive(Debug, Clone)]
399pub struct ToolCallingConfig {
400 pub max_iterations: usize,
403
404 pub parallel_execution: bool,
407
408 pub tool_timeout: Duration,
410
411 pub include_tool_results: bool,
413
414 pub stop_on_error: bool,
416}
417
418impl Default for ToolCallingConfig {
419 fn default() -> Self {
420 Self {
421 max_iterations: 10,
422 parallel_execution: true,
423 tool_timeout: Duration::from_secs(30),
424 include_tool_results: true,
425 stop_on_error: false,
426 }
427 }
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct ToolCallRecord {
436 pub id: String,
438 pub name: String,
440 pub arguments: serde_json::Value,
442 pub result: serde_json::Value,
444 pub success: bool,
446 pub duration_ms: u64,
448 pub error: Option<String>,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
454pub enum FinishReason {
455 Stop,
457 MaxIterations,
459 Error(String),
461 UnknownTool(String),
463}
464
465impl std::fmt::Display for FinishReason {
466 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467 match self {
468 FinishReason::Stop => write!(f, "stop"),
469 FinishReason::MaxIterations => write!(f, "max_iterations"),
470 FinishReason::Error(e) => write!(f, "error: {}", e),
471 FinishReason::UnknownTool(t) => write!(f, "unknown_tool: {}", t),
472 }
473 }
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ConversationMessage {
482 pub role: MessageRole,
484 pub content: String,
486 #[serde(default, skip_serializing_if = "Vec::is_empty")]
488 pub tool_calls: Vec<ToolCall>,
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub tool_call_id: Option<String>,
492 #[serde(default, skip_serializing_if = "Vec::is_empty")]
494 pub parts: Vec<ContentPart>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub cache_control: Option<CacheControl>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub reasoning_content: Option<String>,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub previous_response_id: Option<String>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub store: Option<bool>,
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
511#[serde(rename_all = "lowercase")]
512pub enum MessageRole {
513 System,
515 User,
517 Assistant,
519 Tool,
521}
522
523impl ConversationMessage {
524 pub fn system(content: impl Into<String>) -> Self {
526 Self {
527 role: MessageRole::System,
528 content: content.into(),
529 tool_calls: Vec::new(),
530 tool_call_id: None,
531 parts: Vec::new(),
532 cache_control: None,
533 reasoning_content: None,
534 previous_response_id: None,
535 store: None,
536 }
537 }
538
539 pub fn user(content: impl Into<String>) -> Self {
541 Self {
542 role: MessageRole::User,
543 content: content.into(),
544 tool_calls: Vec::new(),
545 tool_call_id: None,
546 parts: Vec::new(),
547 cache_control: None,
548 reasoning_content: None,
549 previous_response_id: None,
550 store: None,
551 }
552 }
553
554 pub fn assistant(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
556 Self {
557 role: MessageRole::Assistant,
558 content: content.into(),
559 tool_calls,
560 tool_call_id: None,
561 parts: Vec::new(),
562 cache_control: None,
563 reasoning_content: None,
564 previous_response_id: None,
565 store: None,
566 }
567 }
568
569 pub fn tool_result(tool_call_id: impl Into<String>, result: &serde_json::Value) -> Self {
571 Self {
572 role: MessageRole::Tool,
573 content: serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()),
574 tool_calls: Vec::new(),
575 tool_call_id: Some(tool_call_id.into()),
576 parts: Vec::new(),
577 cache_control: None,
578 reasoning_content: None,
579 previous_response_id: None,
580 store: None,
581 }
582 }
583
584 pub fn to_role_content(&self) -> (String, String) {
586 let role = match self.role {
587 MessageRole::System => "system",
588 MessageRole::User => "user",
589 MessageRole::Assistant => "assistant",
590 MessageRole::Tool => "tool",
591 };
592 (role.to_string(), self.content.clone())
593 }
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
602pub struct CoordinatorResult {
603 pub content: String,
605
606 pub tool_calls: Vec<ToolCallRecord>,
608
609 pub iterations: usize,
611
612 pub finish_reason: FinishReason,
614
615 pub total_usage: TokenUsage,
617
618 pub message_history: Vec<ConversationMessage>,
620}
621
622pub struct ToolCoordinator {
636 client: Box<dyn LLMClient>,
637 #[allow(dead_code)]
640 fallback_chain: Vec<(String, Box<dyn LLMClient>)>,
641 tools: Arc<Tools>,
642 config: ToolCallingConfig,
643 observability: Option<Arc<dyn crate::observability::ObservabilitySink>>,
644}
645
646impl ToolCoordinator {
647 pub fn new(client: Box<dyn LLMClient>, tools: Arc<Tools>, config: ToolCallingConfig) -> Self {
649 Self {
650 client,
651 fallback_chain: Vec::new(),
652 tools,
653 config,
654 observability: None,
655 }
656 }
657
658 pub fn with_defaults(client: Box<dyn LLMClient>, tools: Arc<Tools>) -> Self {
660 Self::new(client, tools, ToolCallingConfig::default())
661 }
662
663 pub fn with_fallbacks(
665 client: Box<dyn LLMClient>,
666 fallback_chain: Vec<(String, Box<dyn LLMClient>)>,
667 tools: Arc<Tools>,
668 config: ToolCallingConfig,
669 ) -> Self {
670 Self {
671 client,
672 fallback_chain,
673 tools,
674 config,
675 observability: None,
676 }
677 }
678
679 pub fn with_observability(
681 mut self,
682 obs: Arc<dyn crate::observability::ObservabilitySink>,
683 ) -> Self {
684 self.observability = Some(obs);
685 self
686 }
687
688 pub async fn execute(
707 &self,
708 system: Option<&str>,
709 prompt: &str,
710 ctx: &Arc<Context>,
711 ) -> Result<CoordinatorResult> {
712 let tools = self.tools.list(ctx);
713 let mut messages: Vec<ConversationMessage> = Vec::new();
714 let mut all_tool_calls: Vec<ToolCallRecord> = Vec::new();
715 let mut total_usage = TokenUsage::default();
716
717 if let Some(sys) = system {
719 messages.push(ConversationMessage::system(sys));
720 }
721
722 messages.push(ConversationMessage::user(prompt));
724
725 for iteration in 0..self.config.max_iterations {
726 let llm_start = Instant::now();
728 let response = self
729 .client
730 .generate_with_tools_and_history(&messages, &tools)
731 .await?;
732 let llm_latency = llm_start.elapsed().as_millis() as i64;
733
734 if let Some(obs) = &self.observability {
736 let prompt_tok = response
737 .usage
738 .as_ref()
739 .map(|u| u.prompt_tokens as i64)
740 .unwrap_or(0);
741 let completion_tok = response
742 .usage
743 .as_ref()
744 .map(|u| u.completion_tokens as i64)
745 .unwrap_or(0);
746 let record = crate::observability::LlmCallRecord {
747 step_index: iteration as i32,
748 provider: "unknown".to_string(),
749 model: "unknown".to_string(),
750 prompt_tokens: prompt_tok,
751 completion_tokens: completion_tok,
752 latency_ms: llm_latency,
753 status: "success".to_string(),
754 cached_tokens: response.usage.as_ref().and_then(|u| u.cached_tokens),
755 total_time_ms: Some(llm_latency),
756 };
757 let _ = obs.log_llm_call(record).await;
758 }
759
760 if let Some(usage) = &response.usage {
762 total_usage = TokenUsage::new(
763 total_usage.prompt_tokens + usage.prompt_tokens,
764 total_usage.completion_tokens + usage.completion_tokens,
765 );
766 }
767
768 messages.push(ConversationMessage::assistant(
770 &response.content,
771 response.tool_calls.clone(),
772 ));
773
774 if response.tool_calls.is_empty() {
776 return Ok(CoordinatorResult {
777 content: response.content,
778 tool_calls: all_tool_calls,
779 iterations: iteration + 1,
780 finish_reason: FinishReason::Stop,
781 total_usage,
782 message_history: messages,
783 });
784 }
785
786 for tool_call in &response.tool_calls {
788 if self.tools.resolve(ctx, &tool_call.name).is_none() {
789 return Ok(CoordinatorResult {
790 content: response.content,
791 tool_calls: all_tool_calls,
792 iterations: iteration + 1,
793 finish_reason: FinishReason::UnknownTool(tool_call.name.clone()),
794 total_usage,
795 message_history: messages,
796 });
797 }
798 }
799
800 let tool_start = Instant::now();
802 let tool_results = self.execute_tool_calls(ctx, &response.tool_calls).await?;
803 let tool_latency = tool_start.elapsed().as_millis() as i64;
804
805 for record in tool_results.into_iter() {
807 if let Some(obs) = &self.observability {
809 let status = if record.success {
810 "success".to_string()
811 } else {
812 "error".to_string()
813 };
814 let tool_record = crate::observability::ToolCallRecord {
815 step_index: iteration as i32,
816 tool_name: record.name.clone(),
817 tool_type: "builtin".to_string(),
818 arguments: record.arguments.clone(),
819 result: Some(record.result.clone()),
820 latency_ms: tool_latency,
821 status,
822 };
823 let _ = obs.log_tool_call(tool_record).await;
824 }
825
826 messages.push(ConversationMessage::tool_result(&record.id, &record.result));
828 all_tool_calls.push(record);
829 }
830 }
831
832 Ok(CoordinatorResult {
834 content: messages
835 .last()
836 .map(|m| m.content.clone())
837 .unwrap_or_default(),
838 tool_calls: all_tool_calls,
839 iterations: self.config.max_iterations,
840 finish_reason: FinishReason::MaxIterations,
841 total_usage,
842 message_history: messages,
843 })
844 }
845
846 async fn execute_tool_calls(
848 &self,
849 ctx: &Arc<Context>,
850 calls: &[ToolCall],
851 ) -> Result<Vec<ToolCallRecord>> {
852 if self.config.parallel_execution {
853 self.execute_parallel(ctx, calls).await
854 } else {
855 self.execute_sequential(ctx, calls).await
856 }
857 }
858
859 async fn execute_parallel(
861 &self,
862 ctx: &Arc<Context>,
863 calls: &[ToolCall],
864 ) -> Result<Vec<ToolCallRecord>> {
865 let futures = calls.iter().map(|call| self.execute_single_tool(ctx, call));
866 let results = join_all(futures).await;
867
868 let mut records = Vec::with_capacity(results.len());
869 for result in results {
870 match result {
871 Ok(record) => records.push(record),
872 Err(e) if self.config.stop_on_error => return Err(e),
873 Err(e) => {
874 records.push(ToolCallRecord {
876 id: "error".to_string(),
877 name: "unknown".to_string(),
878 arguments: serde_json::Value::Null,
879 result: serde_json::json!({"error": e.to_string()}),
880 success: false,
881 duration_ms: 0,
882 error: Some(e.to_string()),
883 });
884 }
885 }
886 }
887 Ok(records)
888 }
889
890 async fn execute_sequential(
892 &self,
893 ctx: &Arc<Context>,
894 calls: &[ToolCall],
895 ) -> Result<Vec<ToolCallRecord>> {
896 let mut records = Vec::with_capacity(calls.len());
897 for call in calls {
898 match self.execute_single_tool(ctx, call).await {
899 Ok(record) => records.push(record),
900 Err(e) if self.config.stop_on_error => return Err(e),
901 Err(e) => {
902 records.push(ToolCallRecord {
903 id: call.id.clone(),
904 name: call.name.clone(),
905 arguments: call.arguments.clone(),
906 result: serde_json::json!({"error": e.to_string()}),
907 success: false,
908 duration_ms: 0,
909 error: Some(e.to_string()),
910 });
911 }
912 }
913 }
914 Ok(records)
915 }
916
917 async fn execute_single_tool(
919 &self,
920 ctx: &Arc<Context>,
921 call: &ToolCall,
922 ) -> Result<ToolCallRecord> {
923 let start = Instant::now();
924
925 let result = timeout(
926 self.config.tool_timeout,
927 self.tools.execute(ctx, &call.name, call.arguments.clone()),
928 )
929 .await;
930
931 let duration_ms = start.elapsed().as_millis() as u64;
932
933 match result {
934 Ok(Ok(value)) => Ok(ToolCallRecord {
935 id: call.id.clone(),
936 name: call.name.clone(),
937 arguments: call.arguments.clone(),
938 result: value,
939 success: true,
940 duration_ms,
941 error: None,
942 }),
943 Ok(Err(e)) => Ok(ToolCallRecord {
944 id: call.id.clone(),
945 name: call.name.clone(),
946 arguments: call.arguments.clone(),
947 result: serde_json::json!({"error": e.to_string()}),
948 success: false,
949 duration_ms,
950 error: Some(e.to_string()),
951 }),
952 Err(_) => Ok(ToolCallRecord {
953 id: call.id.clone(),
954 name: call.name.clone(),
955 arguments: call.arguments.clone(),
956 result: serde_json::json!({"error": "Tool execution timed out"}),
957 success: false,
958 duration_ms,
959 error: Some("Tool execution timed out".to_string()),
960 }),
961 }
962 }
963
964 pub fn client(&self) -> &dyn LLMClient {
966 self.client.as_ref()
967 }
968
969 pub fn tools(&self) -> &Arc<Tools> {
971 &self.tools
972 }
973
974 pub fn config(&self) -> &ToolCallingConfig {
976 &self.config
977 }
978}
979
980#[cfg(test)]
981mod tests {
982 use super::*;
983 use crate::capabilities::ModelCapabilities;
984 use crate::client::{LLMClient, LLMResponse, TokenUsage};
985 use ares_types::types::{Result, ToolCall, ToolDefinition};
986
987 fn serde_roundtrip<T>(value: &T) -> T
988 where
989 T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug,
990 {
991 let json = serde_json::to_string(value).unwrap();
992 let decoded: T = serde_json::from_str(&json).unwrap();
993 assert_eq!(*value, decoded);
994 decoded
995 }
996
997 #[test]
998 fn test_tool_calling_config_default() {
999 let config = ToolCallingConfig::default();
1000 assert_eq!(config.max_iterations, 10);
1001 assert!(config.parallel_execution);
1002 assert_eq!(config.tool_timeout, Duration::from_secs(30));
1003 assert!(config.include_tool_results);
1004 assert!(!config.stop_on_error);
1005 }
1006
1007 #[test]
1008 fn test_tool_calling_config_clone() {
1009 let original = ToolCallingConfig::default();
1010 let mut cloned = original.clone();
1011 cloned.max_iterations = 2;
1012 cloned.parallel_execution = false;
1013 cloned.tool_timeout = Duration::from_secs(5);
1014 cloned.include_tool_results = false;
1015 cloned.stop_on_error = true;
1016
1017 assert_eq!(original.max_iterations, 10);
1018 assert!(original.parallel_execution);
1019 assert_eq!(original.tool_timeout, Duration::from_secs(30));
1020 assert!(original.include_tool_results);
1021 assert!(!original.stop_on_error);
1022
1023 assert_eq!(cloned.max_iterations, 2);
1024 assert!(!cloned.parallel_execution);
1025 assert_eq!(cloned.tool_timeout, Duration::from_secs(5));
1026 assert!(!cloned.include_tool_results);
1027 assert!(cloned.stop_on_error);
1028 }
1029
1030 #[test]
1031 fn test_finish_reason_serialization() {
1032 for reason in [
1033 FinishReason::Stop,
1034 FinishReason::MaxIterations,
1035 FinishReason::Error("timeout".to_string()),
1036 FinishReason::UnknownTool("missing".to_string()),
1037 ] {
1038 serde_roundtrip(&reason);
1039 }
1040 }
1041
1042 #[test]
1043 fn test_message_role_serialization() {
1044 for role in [
1045 MessageRole::System,
1046 MessageRole::User,
1047 MessageRole::Assistant,
1048 MessageRole::Tool,
1049 ] {
1050 let json = serde_json::to_string(&role).unwrap();
1051 assert!(json.chars().all(|c| !c.is_uppercase()));
1052 serde_roundtrip(&role);
1053 }
1054 }
1055
1056 type SessionResult = CoordinatorResult;
1058
1059 #[test]
1060 fn test_coordinator_result_type_alias() {
1061 let result: SessionResult = CoordinatorResult {
1062 content: "All done".to_string(),
1063 tool_calls: vec![ToolCallRecord {
1064 id: "call_1".to_string(),
1065 name: "calculator".to_string(),
1066 arguments: serde_json::json!({"a": 1, "b": 1}),
1067 result: serde_json::json!({"sum": 2}),
1068 success: true,
1069 duration_ms: 12,
1070 error: None,
1071 }],
1072 iterations: 2,
1073 finish_reason: FinishReason::Stop,
1074 total_usage: TokenUsage::new(30, 15),
1075 message_history: vec![
1076 ConversationMessage::system("sys"),
1077 ConversationMessage::user("go"),
1078 ],
1079 };
1080
1081 assert_eq!(result.content, "All done");
1082 assert_eq!(result.tool_calls.len(), 1);
1083 assert_eq!(result.iterations, 2);
1084 assert_eq!(result.finish_reason, FinishReason::Stop);
1085 assert_eq!(result.total_usage.prompt_tokens, 30);
1086 }
1087
1088 #[test]
1089 fn test_coordinator_result_serde_roundtrip() {
1090 let result = CoordinatorResult {
1091 content: "done".to_string(),
1092 tool_calls: Vec::new(),
1093 iterations: 1,
1094 finish_reason: FinishReason::MaxIterations,
1095 total_usage: TokenUsage::default(),
1096 message_history: vec![ConversationMessage::user("ping")],
1097 };
1098 let json = serde_json::to_string(&result).unwrap();
1099 let decoded: CoordinatorResult = serde_json::from_str(&json).unwrap();
1100 assert_eq!(decoded.content, result.content);
1101 assert_eq!(decoded.tool_calls.len(), result.tool_calls.len());
1102 assert_eq!(decoded.iterations, result.iterations);
1103 assert_eq!(decoded.finish_reason, result.finish_reason);
1104 assert_eq!(decoded.total_usage, result.total_usage);
1105 assert_eq!(decoded.message_history.len(), result.message_history.len());
1106 assert_eq!(
1107 decoded.message_history[0].role,
1108 result.message_history[0].role
1109 );
1110 }
1111
1112 #[test]
1113 fn test_conversation_message_serde_roundtrip() {
1114 let tool_calls = vec![ToolCall {
1115 id: "call_1".to_string(),
1116 name: "search".to_string(),
1117 arguments: serde_json::json!({"q": "ares"}),
1118 }];
1119
1120 for msg in [
1121 ConversationMessage::system("system prompt"),
1122 ConversationMessage::user("hello"),
1123 ConversationMessage::assistant("thinking", tool_calls),
1124 ConversationMessage::tool_result("call_1", &serde_json::json!({"hits": 1})),
1125 ] {
1126 let json = serde_json::to_string(&msg).unwrap();
1127 let decoded: ConversationMessage = serde_json::from_str(&json).unwrap();
1128 assert_eq!(decoded.role, msg.role);
1129 assert_eq!(decoded.content, msg.content);
1130 assert_eq!(decoded.tool_calls.len(), msg.tool_calls.len());
1131 assert_eq!(decoded.tool_call_id, msg.tool_call_id);
1132 }
1133 }
1134
1135 #[test]
1136 fn test_conversation_message_system() {
1137 let msg = ConversationMessage::system("You are a helpful assistant.");
1138 assert_eq!(msg.role, MessageRole::System);
1139 assert_eq!(msg.content, "You are a helpful assistant.");
1140 assert!(msg.tool_calls.is_empty());
1141 assert!(msg.tool_call_id.is_none());
1142 }
1143
1144 #[test]
1145 fn test_conversation_message_user() {
1146 let msg = ConversationMessage::user("Hello!");
1147 assert_eq!(msg.role, MessageRole::User);
1148 assert_eq!(msg.content, "Hello!");
1149 }
1150
1151 #[test]
1152 fn test_conversation_message_assistant_with_tool_calls() {
1153 let tool_calls = vec![ToolCall {
1154 id: "call_1".to_string(),
1155 name: "calculator".to_string(),
1156 arguments: serde_json::json!({"a": 1, "b": 2}),
1157 }];
1158 let msg = ConversationMessage::assistant("Let me calculate that.", tool_calls.clone());
1159 assert_eq!(msg.role, MessageRole::Assistant);
1160 assert_eq!(msg.tool_calls.len(), 1);
1161 assert_eq!(msg.tool_calls[0].name, "calculator");
1162 }
1163
1164 #[test]
1165 fn test_conversation_message_tool_result() {
1166 let result = serde_json::json!({"result": 42});
1167 let msg = ConversationMessage::tool_result("call_1", &result);
1168 assert_eq!(msg.role, MessageRole::Tool);
1169 assert_eq!(msg.tool_call_id, Some("call_1".to_string()));
1170 assert!(msg.content.contains("42"));
1171 }
1172
1173 #[test]
1174 fn test_finish_reason_display() {
1175 assert_eq!(FinishReason::Stop.to_string(), "stop");
1176 assert_eq!(FinishReason::MaxIterations.to_string(), "max_iterations");
1177 assert_eq!(
1178 FinishReason::Error("test error".to_string()).to_string(),
1179 "error: test error"
1180 );
1181 assert_eq!(
1182 FinishReason::UnknownTool("unknown".to_string()).to_string(),
1183 "unknown_tool: unknown"
1184 );
1185 }
1186
1187 #[test]
1188 fn test_tool_call_record_serialization() {
1189 let record = ToolCallRecord {
1190 id: "call_1".to_string(),
1191 name: "test_tool".to_string(),
1192 arguments: serde_json::json!({"input": "test"}),
1193 result: serde_json::json!({"output": "result"}),
1194 success: true,
1195 duration_ms: 100,
1196 error: None,
1197 };
1198
1199 let json = serde_json::to_string(&record).unwrap();
1200 assert!(json.contains("test_tool"));
1201 assert!(json.contains("\"success\":true"));
1202 }
1203
1204 struct MockToolFlowClient {
1205 calls: std::sync::atomic::AtomicUsize,
1206 }
1207
1208 impl MockToolFlowClient {
1209 fn new() -> Self {
1210 Self {
1211 calls: std::sync::atomic::AtomicUsize::new(0),
1212 }
1213 }
1214 }
1215
1216 #[async_trait::async_trait]
1217 impl LLMClient for MockToolFlowClient {
1218 async fn generate(&self, _prompt: &str) -> Result<String> {
1219 Ok(String::new())
1220 }
1221
1222 async fn generate_with_system(&self, _system: &str, _prompt: &str) -> Result<String> {
1223 Ok(String::new())
1224 }
1225
1226 async fn generate_with_history(
1227 &self,
1228 _messages: &[(String, String)],
1229 ) -> Result<LLMResponse> {
1230 Ok(LLMResponse {
1231 content: String::new(),
1232 tool_calls: vec![],
1233 finish_reason: "stop".into(),
1234 usage: None,
1235 reasoning_content: None,
1236 response_id: None,
1237 })
1238 }
1239
1240 async fn generate_with_tools(
1241 &self,
1242 _prompt: &str,
1243 _tools: &[ToolDefinition],
1244 ) -> Result<LLMResponse> {
1245 Ok(LLMResponse {
1246 content: String::new(),
1247 tool_calls: vec![],
1248 finish_reason: "stop".into(),
1249 usage: None,
1250 reasoning_content: None,
1251 response_id: None,
1252 })
1253 }
1254
1255 async fn generate_with_tools_and_history(
1256 &self,
1257 _messages: &[ConversationMessage],
1258 _tools: &[ToolDefinition],
1259 ) -> Result<LLMResponse> {
1260 let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1261 if n == 0 {
1262 Ok(LLMResponse {
1263 content: "Let me calculate".to_string(),
1264 tool_calls: vec![ToolCall {
1265 id: "call_1".to_string(),
1266 name: "calculator".to_string(),
1267 arguments: serde_json::json!({"operation": "add", "a": 2, "b": 2}),
1268 }],
1269 finish_reason: "tool_calls".to_string(),
1270 usage: Some(TokenUsage::new(10, 5)),
1271 reasoning_content: None,
1272 response_id: None,
1273 })
1274 } else {
1275 Ok(LLMResponse {
1276 content: "The answer is 4".to_string(),
1277 tool_calls: vec![],
1278 finish_reason: "stop".to_string(),
1279 usage: Some(TokenUsage::new(5, 3)),
1280 reasoning_content: None,
1281 response_id: None,
1282 })
1283 }
1284 }
1285
1286 async fn stream(
1287 &self,
1288 _prompt: &str,
1289 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1290 Err(ares_types::types::AppError::Internal("not used".into()))
1291 }
1292
1293 async fn stream_with_system(
1294 &self,
1295 _system: &str,
1296 _prompt: &str,
1297 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1298 Err(ares_types::types::AppError::Internal("not used".into()))
1299 }
1300
1301 async fn stream_with_history(
1302 &self,
1303 _messages: &[(String, String)],
1304 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1305 Err(ares_types::types::AppError::Internal("not used".into()))
1306 }
1307
1308 fn model_name(&self) -> &str {
1309 "mock"
1310 }
1311 }
1312
1313 #[tokio::test]
1314 async fn test_tool_calling_flow_with_mock() {
1315 use ares_tools::calculator::Calculator;
1316 use std::sync::Arc;
1317
1318 let tools = Arc::new(Tools::from_static([Arc::new(Calculator) as Arc<dyn Tool>]));
1319 let ctx = Context::new_root();
1320
1321 let coordinator = ToolCoordinator::new(
1322 Box::new(MockToolFlowClient::new()),
1323 tools,
1324 ToolCallingConfig::default(),
1325 );
1326
1327 let result = coordinator
1328 .execute(None, "What is 2 + 2?", &ctx)
1329 .await
1330 .expect("coordinator should succeed");
1331
1332 assert_eq!(result.finish_reason, FinishReason::Stop);
1333 assert_eq!(result.tool_calls.len(), 1);
1334 assert_eq!(result.tool_calls[0].name, "calculator");
1335 assert!(result.tool_calls[0].success);
1336 assert_eq!(result.iterations, 2);
1337 assert!(result.content.contains('4'));
1338 }
1339
1340 #[tokio::test]
1341 async fn test_tool_calling_unknown_tool_stops() {
1342 struct UnknownToolClient;
1343
1344 #[async_trait::async_trait]
1345 impl LLMClient for UnknownToolClient {
1346 async fn generate(&self, _prompt: &str) -> Result<String> {
1347 Ok(String::new())
1348 }
1349 async fn generate_with_system(&self, _system: &str, _prompt: &str) -> Result<String> {
1350 Ok(String::new())
1351 }
1352 async fn generate_with_history(
1353 &self,
1354 _messages: &[(String, String)],
1355 ) -> Result<LLMResponse> {
1356 Ok(LLMResponse {
1357 content: String::new(),
1358 tool_calls: vec![],
1359 finish_reason: "stop".into(),
1360 usage: None,
1361 reasoning_content: None,
1362 response_id: None,
1363 })
1364 }
1365 async fn generate_with_tools(
1366 &self,
1367 _prompt: &str,
1368 _tools: &[ToolDefinition],
1369 ) -> Result<LLMResponse> {
1370 Ok(LLMResponse {
1371 content: String::new(),
1372 tool_calls: vec![],
1373 finish_reason: "stop".into(),
1374 usage: None,
1375 reasoning_content: None,
1376 response_id: None,
1377 })
1378 }
1379 async fn generate_with_tools_and_history(
1380 &self,
1381 _messages: &[ConversationMessage],
1382 _tools: &[ToolDefinition],
1383 ) -> Result<LLMResponse> {
1384 Ok(LLMResponse {
1385 content: "calling".into(),
1386 tool_calls: vec![ToolCall {
1387 id: "1".into(),
1388 name: "missing_tool".into(),
1389 arguments: serde_json::json!({}),
1390 }],
1391 finish_reason: "tool_calls".into(),
1392 usage: None,
1393 reasoning_content: None,
1394 response_id: None,
1395 })
1396 }
1397 async fn stream(
1398 &self,
1399 _prompt: &str,
1400 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>>
1401 {
1402 Err(ares_types::types::AppError::Internal("n/a".into()))
1403 }
1404 async fn stream_with_system(
1405 &self,
1406 _system: &str,
1407 _prompt: &str,
1408 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>>
1409 {
1410 Err(ares_types::types::AppError::Internal("n/a".into()))
1411 }
1412 async fn stream_with_history(
1413 &self,
1414 _messages: &[(String, String)],
1415 ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>>
1416 {
1417 Err(ares_types::types::AppError::Internal("n/a".into()))
1418 }
1419 fn model_name(&self) -> &str {
1420 "mock"
1421 }
1422 }
1423
1424 let tools = Arc::new(Tools::from_static(Vec::<Arc<dyn Tool>>::new()));
1425 let ctx = Context::new_root();
1426 let coordinator = ToolCoordinator::new(
1427 Box::new(UnknownToolClient),
1428 tools,
1429 ToolCallingConfig::default(),
1430 );
1431 let result = coordinator.execute(None, "go", &ctx).await.unwrap();
1432 assert!(matches!(result.finish_reason, FinishReason::UnknownTool(_)));
1433 }
1434
1435 fn dispatch_endpoint(id: &str, model: &str, caps: ModelCapabilities) -> ProviderEndpoint {
1436 ProviderEndpoint {
1437 id: id.to_string(),
1438 model: model.to_string(),
1439 capabilities: caps,
1440 in_flight_requests: 0,
1441 affinity_group: None,
1442 }
1443 }
1444
1445 fn dispatch_test_config(providers: Vec<ProviderEndpoint>) -> CoordinatorConfig {
1446 CoordinatorConfig {
1447 providers,
1448 fallback_chain: vec![],
1449 load_balance: LoadBalanceStrategy::RoundRobin,
1450 requirements: CapabilityRequirements::default(),
1451 affinity_key: None,
1452 }
1453 }
1454
1455 #[test]
1456 fn dispatch_coordinator_config_serde_roundtrip() {
1457 let config = dispatch_test_config(vec![dispatch_endpoint(
1458 "openai",
1459 "gpt-4o",
1460 ModelCapabilities::for_model("gpt-4o"),
1461 )]);
1462 let json = serde_json::to_string(&config).unwrap();
1463 let decoded: CoordinatorConfig = serde_json::from_str(&json).unwrap();
1464 assert_eq!(decoded.providers.len(), config.providers.len());
1465 assert_eq!(decoded.fallback_chain, config.fallback_chain);
1466 assert_eq!(decoded.load_balance, config.load_balance);
1467 }
1468
1469 #[test]
1470 fn dispatch_plan_serde_roundtrip() {
1471 let plan = DispatchPlan {
1472 primary: RouteDecision {
1473 provider_id: "openai".into(),
1474 model: "gpt-4o".into(),
1475 reason: "primary".into(),
1476 },
1477 fallbacks: vec![RouteDecision {
1478 provider_id: "ollama".into(),
1479 model: "llama3".into(),
1480 reason: "fallback".into(),
1481 }],
1482 };
1483 serde_roundtrip(&plan);
1484 }
1485
1486 #[test]
1487 fn dispatch_route_decision_serde_roundtrip() {
1488 let decision = RouteDecision {
1489 provider_id: "openai".into(),
1490 model: "gpt-4o".into(),
1491 reason: "best score".into(),
1492 };
1493 serde_roundtrip(&decision);
1494 }
1495
1496 #[test]
1497 fn dispatch_load_balance_strategy_serde_roundtrip() {
1498 serde_roundtrip(&LoadBalanceStrategy::LeastLoaded);
1499 serde_roundtrip(&LoadBalanceStrategy::Affinity);
1500 }
1501
1502 #[test]
1503 fn dispatch_route_request_picks_highest_scoring_provider() {
1504 let mut config = dispatch_test_config(vec![
1505 dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1506 dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1507 ]);
1508 config.requirements = CapabilityRequirements::for_agent();
1509 let mut state = DispatchState::default();
1510 let route = route_request(&config, &mut state, &[]).unwrap();
1511 assert_eq!(route.provider_id, "openai");
1512 }
1513
1514 #[test]
1515 fn dispatch_route_request_filters_by_capability_requirements() {
1516 let mut config = dispatch_test_config(vec![
1517 dispatch_endpoint(
1518 "basic",
1519 "basic",
1520 ModelCapabilities {
1521 supports_tools: false,
1522 production_ready: true,
1523 ..Default::default()
1524 },
1525 ),
1526 dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1527 ]);
1528 config.requirements = CapabilityRequirements::for_agent();
1529 let mut state = DispatchState::default();
1530 let route = route_request(&config, &mut state, &[]).unwrap();
1531 assert_eq!(route.provider_id, "openai");
1532 }
1533
1534 #[test]
1535 fn dispatch_route_request_no_available_provider() {
1536 let mut config = dispatch_test_config(vec![dispatch_endpoint(
1537 "basic",
1538 "basic",
1539 ModelCapabilities {
1540 supports_tools: false,
1541 ..Default::default()
1542 },
1543 )]);
1544 config.requirements = CapabilityRequirements::for_agent();
1545 let mut state = DispatchState::default();
1546 let err = route_request(&config, &mut state, &[]).unwrap_err();
1547 assert!(matches!(
1548 err,
1549 CoordinatorDispatchError::NoAvailableProvider(_)
1550 ));
1551 }
1552
1553 #[test]
1554 fn dispatch_route_request_excludes_providers() {
1555 let config = dispatch_test_config(vec![
1556 dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1557 dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1558 ]);
1559 let mut state = DispatchState::default();
1560 let route = route_request(&config, &mut state, &["openai"]).unwrap();
1561 assert_eq!(route.provider_id, "ollama");
1562 }
1563
1564 #[test]
1565 fn dispatch_round_robin_rotates_providers() {
1566 let mut config = dispatch_test_config(vec![
1567 dispatch_endpoint("a", "m1", ModelCapabilities::default()),
1568 dispatch_endpoint("b", "m2", ModelCapabilities::default()),
1569 ]);
1570 config.load_balance = LoadBalanceStrategy::RoundRobin;
1571 let mut state = DispatchState::default();
1572 let first = route_request(&config, &mut state, &[]).unwrap().provider_id;
1573 let second = route_request(&config, &mut state, &[]).unwrap().provider_id;
1574 let third = route_request(&config, &mut state, &[]).unwrap().provider_id;
1575 assert_ne!(first, second);
1576 assert_eq!(first, third);
1577 }
1578
1579 #[test]
1580 fn dispatch_least_loaded_prefers_lower_in_flight() {
1581 let mut config = dispatch_test_config(vec![
1582 {
1583 let mut p = dispatch_endpoint("busy", "m1", ModelCapabilities::default());
1584 p.in_flight_requests = 50;
1585 p
1586 },
1587 {
1588 let mut p = dispatch_endpoint("idle", "m2", ModelCapabilities::default());
1589 p.in_flight_requests = 1;
1590 p
1591 },
1592 ]);
1593 config.load_balance = LoadBalanceStrategy::LeastLoaded;
1594 let mut state = DispatchState::default();
1595 let route = route_request(&config, &mut state, &[]).unwrap();
1596 assert_eq!(route.provider_id, "idle");
1597 }
1598
1599 #[test]
1600 fn dispatch_affinity_prefers_matching_group() {
1601 let mut config = dispatch_test_config(vec![
1602 {
1603 let mut p = dispatch_endpoint("a", "m1", ModelCapabilities::default());
1604 p.affinity_group = Some("tenant-1".into());
1605 p
1606 },
1607 {
1608 let mut p = dispatch_endpoint("b", "m2", ModelCapabilities::default());
1609 p.affinity_group = Some("tenant-2".into());
1610 p
1611 },
1612 ]);
1613 config.load_balance = LoadBalanceStrategy::Affinity;
1614 config.affinity_key = Some("tenant-2".into());
1615 let mut state = DispatchState::default();
1616 let route = route_request(&config, &mut state, &[]).unwrap();
1617 assert_eq!(route.provider_id, "b");
1618 }
1619
1620 #[test]
1621 fn dispatch_affinity_session_overrides_config_key() {
1622 let mut config = dispatch_test_config(vec![
1623 {
1624 let mut p = dispatch_endpoint("a", "m1", ModelCapabilities::default());
1625 p.affinity_group = Some("tenant-1".into());
1626 p
1627 },
1628 {
1629 let mut p = dispatch_endpoint("b", "m2", ModelCapabilities::default());
1630 p.affinity_group = Some("tenant-2".into());
1631 p
1632 },
1633 ]);
1634 config.load_balance = LoadBalanceStrategy::Affinity;
1635 config.affinity_key = Some("tenant-2".into());
1636 let mut state = DispatchState {
1637 session_affinity: Some("tenant-1".into()),
1638 ..Default::default()
1639 };
1640 let route = route_request(&config, &mut state, &[]).unwrap();
1641 assert_eq!(route.provider_id, "a");
1642 }
1643
1644 #[test]
1645 fn dispatch_select_fallback_follows_chain_order() {
1646 let mut config = dispatch_test_config(vec![
1647 dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1648 dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1649 ]);
1650 config.fallback_chain = vec!["ollama".into(), "openai".into()];
1651 let plan = DispatchPlan {
1652 primary: RouteDecision {
1653 provider_id: "openai".into(),
1654 model: "gpt-4o".into(),
1655 reason: "primary".into(),
1656 },
1657 fallbacks: vec![],
1658 };
1659 let next = select_fallback(&config, &plan, "openai", &[])
1660 .unwrap()
1661 .unwrap();
1662 assert_eq!(next.provider_id, "ollama");
1663 }
1664
1665 #[test]
1666 fn dispatch_select_fallback_skips_already_tried() {
1667 let config = dispatch_test_config(vec![
1668 dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1669 dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1670 ]);
1671 let plan = DispatchPlan {
1672 primary: RouteDecision {
1673 provider_id: "openai".into(),
1674 model: "gpt-4o".into(),
1675 reason: "primary".into(),
1676 },
1677 fallbacks: vec![RouteDecision {
1678 provider_id: "ollama".into(),
1679 model: "llama3".into(),
1680 reason: "first fallback".into(),
1681 }],
1682 };
1683 assert!(
1684 select_fallback(&config, &plan, "ollama", &[])
1685 .unwrap()
1686 .is_none()
1687 );
1688 }
1689
1690 #[test]
1691 fn dispatch_select_fallback_all_failed_surfaces_errors() {
1692 let config = dispatch_test_config(vec![dispatch_endpoint(
1693 "only",
1694 "m",
1695 ModelCapabilities::default(),
1696 )]);
1697 let plan = DispatchPlan {
1698 primary: RouteDecision {
1699 provider_id: "only".into(),
1700 model: "m".into(),
1701 reason: "primary".into(),
1702 },
1703 fallbacks: vec![],
1704 };
1705 let err = select_fallback(
1706 &config,
1707 &plan,
1708 "only",
1709 &["timeout".into(), "rate limited".into()],
1710 )
1711 .unwrap_err();
1712 assert!(matches!(err, CoordinatorDispatchError::AllFailed(_)));
1713 if let CoordinatorDispatchError::AllFailed(errors) = err {
1714 assert_eq!(errors.len(), 2);
1715 }
1716 }
1717
1718 #[test]
1719 fn dispatch_build_dispatch_plan_includes_fallbacks() {
1720 let mut config = dispatch_test_config(vec![
1721 dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1722 dispatch_endpoint("ollama", "llama3", ModelCapabilities::for_model("llama3")),
1723 ]);
1724 config.fallback_chain = vec!["ollama".into()];
1725 let mut state = DispatchState::default();
1726 let plan = build_dispatch_plan(&config, &mut state).unwrap();
1727 assert!(!plan.fallbacks.is_empty());
1728 assert_eq!(plan.fallbacks[0].provider_id, "ollama");
1729 }
1730
1731 #[test]
1732 fn dispatch_parse_coordinator_response_valid() {
1733 let json = r#"{
1734 "primary": {"provider_id":"openai","model":"gpt-4o","reason":"best"},
1735 "fallbacks": [{"provider_id":"ollama","model":"llama3","reason":"backup"}]
1736 }"#;
1737 let plan = parse_coordinator_response(json).unwrap();
1738 assert_eq!(plan.primary.provider_id, "openai");
1739 assert_eq!(plan.fallbacks.len(), 1);
1740 }
1741
1742 #[test]
1743 fn dispatch_parse_coordinator_response_missing_primary() {
1744 let err = parse_coordinator_response(r#"{"fallbacks":[]}"#).unwrap_err();
1745 assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1746 }
1747
1748 #[test]
1749 fn dispatch_parse_coordinator_response_invalid_json() {
1750 let err = parse_coordinator_response("not-json").unwrap_err();
1751 assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1752 }
1753
1754 #[test]
1755 fn dispatch_parse_coordinator_response_invalid_fallbacks_type() {
1756 let err = parse_coordinator_response(
1757 r#"{"primary":{"provider_id":"a","model":"m","reason":"r"},"fallbacks":"nope"}"#,
1758 )
1759 .unwrap_err();
1760 assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1761 }
1762
1763 #[test]
1764 fn dispatch_validate_config_empty_providers() {
1765 let config = CoordinatorConfig {
1766 providers: vec![],
1767 fallback_chain: vec![],
1768 load_balance: LoadBalanceStrategy::RoundRobin,
1769 requirements: CapabilityRequirements::default(),
1770 affinity_key: None,
1771 };
1772 let err = validate_coordinator_config(&config).unwrap_err();
1773 assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1774 }
1775
1776 #[test]
1777 fn dispatch_validate_config_unknown_fallback() {
1778 let config = CoordinatorConfig {
1779 providers: vec![dispatch_endpoint(
1780 "openai",
1781 "gpt-4o",
1782 ModelCapabilities::default(),
1783 )],
1784 fallback_chain: vec!["missing".into()],
1785 load_balance: LoadBalanceStrategy::RoundRobin,
1786 requirements: CapabilityRequirements::default(),
1787 affinity_key: None,
1788 };
1789 let err = validate_coordinator_config(&config).unwrap_err();
1790 assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1791 }
1792
1793 #[test]
1794 fn dispatch_validate_config_duplicate_ids() {
1795 let config = CoordinatorConfig {
1796 providers: vec![
1797 dispatch_endpoint("dup", "m1", ModelCapabilities::default()),
1798 dispatch_endpoint("dup", "m2", ModelCapabilities::default()),
1799 ],
1800 fallback_chain: vec![],
1801 load_balance: LoadBalanceStrategy::RoundRobin,
1802 requirements: CapabilityRequirements::default(),
1803 affinity_key: None,
1804 };
1805 let err = validate_coordinator_config(&config).unwrap_err();
1806 assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1807 }
1808
1809 #[test]
1810 fn dispatch_coordinator_dispatch_error_display_no_available() {
1811 let err = CoordinatorDispatchError::NoAvailableProvider("none left".into());
1812 assert!(err.to_string().contains("no available provider"));
1813 assert!(err.to_string().contains("none left"));
1814 }
1815
1816 #[test]
1817 fn dispatch_coordinator_dispatch_error_display_all_failed() {
1818 let err = CoordinatorDispatchError::AllFailed(vec!["e1".into(), "e2".into()]);
1819 let s = err.to_string();
1820 assert!(s.contains("all providers failed"));
1821 assert!(s.contains("e1"));
1822 }
1823
1824 #[test]
1825 fn dispatch_coordinator_dispatch_error_display_invalid_config() {
1826 let err = CoordinatorDispatchError::InvalidConfig("bad chain".into());
1827 assert!(err.to_string().contains("invalid coordinator config"));
1828 }
1829
1830 #[test]
1831 fn dispatch_route_decision_display() {
1832 let decision = RouteDecision {
1833 provider_id: "openai".into(),
1834 model: "gpt-4o".into(),
1835 reason: "best".into(),
1836 };
1837 let s = decision.to_string();
1838 assert!(s.contains("openai"));
1839 assert!(s.contains("gpt-4o"));
1840 }
1841
1842 #[test]
1843 fn dispatch_plan_display() {
1844 let plan = DispatchPlan {
1845 primary: RouteDecision {
1846 provider_id: "openai".into(),
1847 model: "gpt-4o".into(),
1848 reason: "primary".into(),
1849 },
1850 fallbacks: vec![RouteDecision {
1851 provider_id: "ollama".into(),
1852 model: "llama3".into(),
1853 reason: "fb".into(),
1854 }],
1855 };
1856 let s = plan.to_string();
1857 assert!(s.contains("openai"));
1858 assert!(s.contains("ollama"));
1859 }
1860
1861 #[test]
1862 fn dispatch_load_balance_strategy_display() {
1863 assert_eq!(LoadBalanceStrategy::RoundRobin.to_string(), "round_robin");
1864 assert_eq!(LoadBalanceStrategy::LeastLoaded.to_string(), "least_loaded");
1865 assert_eq!(LoadBalanceStrategy::Affinity.to_string(), "affinity");
1866 }
1867
1868 #[test]
1869 fn dispatch_coordinator_config_clone() {
1870 let config = dispatch_test_config(vec![dispatch_endpoint(
1871 "openai",
1872 "gpt-4o",
1873 ModelCapabilities::default(),
1874 )]);
1875 let cloned = config.clone();
1876 assert_eq!(config.providers.len(), cloned.providers.len());
1877 assert_eq!(config.fallback_chain, cloned.fallback_chain);
1878 }
1879
1880 #[test]
1881 fn dispatch_dispatch_state_clone_default() {
1882 let state = DispatchState::default();
1883 let cloned = state.clone();
1884 assert_eq!(state, cloned);
1885 assert_eq!(state.round_robin_cursor, 0);
1886 }
1887
1888 #[test]
1889 fn dispatch_provider_endpoint_clone_debug() {
1890 let endpoint = dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::default());
1891 let cloned = endpoint.clone();
1892 assert_eq!(endpoint, cloned);
1893 assert!(format!("{endpoint:?}").contains("openai"));
1894 }
1895
1896 #[test]
1897 fn dispatch_route_decision_clone_eq() {
1898 let a = RouteDecision {
1899 provider_id: "a".into(),
1900 model: "m".into(),
1901 reason: "r".into(),
1902 };
1903 assert_eq!(a, a.clone());
1904 }
1905
1906 #[test]
1907 fn dispatch_plan_clone_eq() {
1908 let plan = DispatchPlan {
1909 primary: RouteDecision {
1910 provider_id: "a".into(),
1911 model: "m".into(),
1912 reason: "r".into(),
1913 },
1914 fallbacks: vec![],
1915 };
1916 assert_eq!(plan, plan.clone());
1917 }
1918
1919 #[test]
1920 fn dispatch_coordinator_dispatch_error_clone_eq() {
1921 let err = CoordinatorDispatchError::InvalidConfig("x".into());
1922 assert_eq!(err, err.clone());
1923 }
1924
1925 #[test]
1926 fn dispatch_build_dispatch_plan_invalid_config_propagates() {
1927 let config = CoordinatorConfig {
1928 providers: vec![],
1929 fallback_chain: vec![],
1930 load_balance: LoadBalanceStrategy::RoundRobin,
1931 requirements: CapabilityRequirements::default(),
1932 affinity_key: None,
1933 };
1934 let err = build_dispatch_plan(&config, &mut DispatchState::default()).unwrap_err();
1935 assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1936 }
1937
1938 #[test]
1939 fn dispatch_route_request_invalid_config_before_routing() {
1940 let config = CoordinatorConfig {
1941 providers: vec![],
1942 fallback_chain: vec![],
1943 load_balance: LoadBalanceStrategy::RoundRobin,
1944 requirements: CapabilityRequirements::default(),
1945 affinity_key: None,
1946 };
1947 let err = route_request(&config, &mut DispatchState::default(), &[]).unwrap_err();
1948 assert!(matches!(err, CoordinatorDispatchError::InvalidConfig(_)));
1949 }
1950
1951 #[test]
1952 fn dispatch_select_fallback_respects_capability_on_chain() {
1953 let mut config = dispatch_test_config(vec![
1954 dispatch_endpoint(
1955 "no-tools",
1956 "basic",
1957 ModelCapabilities {
1958 supports_tools: false,
1959 ..Default::default()
1960 },
1961 ),
1962 dispatch_endpoint("openai", "gpt-4o", ModelCapabilities::for_model("gpt-4o")),
1963 ]);
1964 config.fallback_chain = vec!["no-tools".into(), "openai".into()];
1965 config.requirements = CapabilityRequirements::for_agent();
1966 let plan = DispatchPlan {
1967 primary: RouteDecision {
1968 provider_id: "openai".into(),
1969 model: "gpt-4o".into(),
1970 reason: "primary".into(),
1971 },
1972 fallbacks: vec![],
1973 };
1974 assert!(
1975 select_fallback(&config, &plan, "openai", &[])
1976 .unwrap()
1977 .is_none()
1978 );
1979 }
1980 #[test]
1981 fn test_message_to_role_content() {
1982 let msg = ConversationMessage::user("Hello");
1983 let (role, content) = msg.to_role_content();
1984 assert_eq!(role, "user");
1985 assert_eq!(content, "Hello");
1986
1987 let msg = ConversationMessage::system("System prompt");
1988 let (role, content) = msg.to_role_content();
1989 assert_eq!(role, "system");
1990 assert_eq!(content, "System prompt");
1991 }
1992}