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