1use std::collections::HashMap;
2use std::sync::atomic::{AtomicUsize, Ordering};
3
4use bitrouter_core::{
5 errors::{BitrouterError, Result},
6 routers::content::RouteContext,
7 routers::registry::{
8 AgentCapabilityFlags, AgentEntry, AgentEntryStatus, AgentRegistry, ModelEntry,
9 ModelRegistry, ToolEntry, ToolRegistry,
10 },
11 routers::routing_table::{
12 ApiProtocol, ModelPricing, RouteEntry, RoutingTable, RoutingTarget, strip_ansi_escapes,
13 },
14 tools::definition::ToolDefinition,
15};
16
17use crate::config::{
18 AgentConfig, ModelConfig, ModelInfo, ProviderConfig, RoutingRuleConfig, RoutingStrategy,
19 ToolConfig,
20};
21use crate::content_routing::ContentRoutingRules;
22
23const DEFAULT_PROVIDER: &str = "bitrouter";
26
27#[derive(Debug, Clone)]
29pub struct ResolvedTarget {
30 pub provider_name: String,
31 pub service_id: String,
33 pub api_protocol: ApiProtocol,
37 pub api_key_override: Option<String>,
39 pub api_base_override: Option<String>,
41}
42
43fn resolve_protocol(
46 providers: &HashMap<String, ProviderConfig>,
47 provider_name: &str,
48 endpoint_override: Option<ApiProtocol>,
49) -> Result<ApiProtocol> {
50 if let Some(proto) = endpoint_override {
51 return Ok(proto);
52 }
53 providers
54 .get(provider_name)
55 .and_then(|p| p.api_protocol)
56 .ok_or_else(|| {
57 BitrouterError::invalid_request(
58 Some(provider_name),
59 format!("provider '{provider_name}' has no api_protocol configured"),
60 None,
61 )
62 })
63}
64
65pub struct ConfigRoutingTable {
76 providers: HashMap<String, ProviderConfig>,
77 models: HashMap<String, ModelConfig>,
78 counters: HashMap<String, AtomicUsize>,
80 content_rules: ContentRoutingRules,
82}
83
84impl ConfigRoutingTable {
85 pub fn new(
86 providers: HashMap<String, ProviderConfig>,
87 models: HashMap<String, ModelConfig>,
88 ) -> Self {
89 Self::with_routing(providers, models, &HashMap::new())
90 }
91
92 pub fn with_routing(
94 providers: HashMap<String, ProviderConfig>,
95 models: HashMap<String, ModelConfig>,
96 routing: &HashMap<String, RoutingRuleConfig>,
97 ) -> Self {
98 let counters = models
99 .keys()
100 .map(|k| (k.clone(), AtomicUsize::new(0)))
101 .collect();
102 let content_rules = ContentRoutingRules::compile(routing);
103 Self {
104 providers,
105 models,
106 counters,
107 content_rules,
108 }
109 }
110
111 pub fn providers(&self) -> &HashMap<String, ProviderConfig> {
113 &self.providers
114 }
115
116 pub fn model_info(&self, provider_name: &str, model_id: &str) -> ModelInfo {
121 self.providers
122 .get(provider_name)
123 .and_then(|p| p.models.as_ref())
124 .and_then(|models| models.get(model_id))
125 .cloned()
126 .unwrap_or_default()
127 }
128
129 pub fn model_pricing(&self, provider_name: &str, model_id: &str) -> ModelPricing {
136 self.model_info(provider_name, model_id).pricing
137 }
138
139 pub fn resolve(&self, incoming: &str) -> Result<ResolvedTarget> {
141 if let Some((prefix, suffix)) = incoming.split_once(':')
143 && self.providers.contains_key(prefix)
144 {
145 let api_protocol = resolve_protocol(&self.providers, prefix, None)?;
146 return Ok(ResolvedTarget {
147 provider_name: prefix.to_owned(),
148 service_id: strip_ansi_escapes(suffix),
149 api_protocol,
150 api_key_override: None,
151 api_base_override: None,
152 });
153 }
154
155 if let Some(model_config) = self.models.get(incoming) {
157 return self.select_endpoint(incoming, model_config);
158 }
159
160 if self.models.is_empty() && self.providers.contains_key(DEFAULT_PROVIDER) {
163 let api_protocol = resolve_protocol(&self.providers, DEFAULT_PROVIDER, None)?;
164 return Ok(ResolvedTarget {
165 provider_name: DEFAULT_PROVIDER.to_owned(),
166 service_id: strip_ansi_escapes(incoming),
167 api_protocol,
168 api_key_override: None,
169 api_base_override: None,
170 });
171 }
172
173 Err(BitrouterError::invalid_request(
174 None,
175 format!("no route found for model: {incoming}"),
176 None,
177 ))
178 }
179
180 fn select_endpoint(&self, model_name: &str, config: &ModelConfig) -> Result<ResolvedTarget> {
181 if config.endpoints.is_empty() {
182 return Err(BitrouterError::invalid_request(
183 None,
184 format!("model '{model_name}' has no configured endpoints"),
185 None,
186 ));
187 }
188
189 let endpoint = match config.strategy {
190 RoutingStrategy::Priority => &config.endpoints[0],
191 RoutingStrategy::LoadBalance => {
192 let Some(counter) = self.counters.get(model_name) else {
193 return Err(BitrouterError::invalid_request(
194 None,
195 format!("load-balance counter missing for model '{model_name}'"),
196 None,
197 ));
198 };
199 let idx = counter.fetch_add(1, Ordering::Relaxed) % config.endpoints.len();
200 &config.endpoints[idx]
201 }
202 };
203
204 let api_protocol =
205 resolve_protocol(&self.providers, &endpoint.provider, endpoint.api_protocol)?;
206
207 Ok(ResolvedTarget {
208 provider_name: endpoint.provider.clone(),
209 service_id: strip_ansi_escapes(&endpoint.service_id),
210 api_protocol,
211 api_key_override: endpoint.api_key.clone(),
212 api_base_override: endpoint.api_base.clone(),
213 })
214 }
215}
216
217impl RoutingTable for ConfigRoutingTable {
218 async fn route(&self, incoming_name: &str, context: &RouteContext) -> Result<RoutingTarget> {
219 if !context.is_empty()
222 && self.content_rules.is_trigger(incoming_name)
223 && let Some(resolved_name) = self.content_rules.resolve(incoming_name, context)
224 {
225 let resolved = self.resolve(&resolved_name)?;
228 return Ok(RoutingTarget {
229 provider_name: resolved.provider_name,
230 service_id: resolved.service_id,
231 api_protocol: resolved.api_protocol,
232 });
233 }
234
235 let resolved = self.resolve(incoming_name)?;
236 Ok(RoutingTarget {
237 provider_name: resolved.provider_name,
238 service_id: resolved.service_id,
239 api_protocol: resolved.api_protocol,
240 })
241 }
242
243 fn list_routes(&self) -> Vec<RouteEntry> {
244 let mut entries = Vec::new();
245
246 if self.models.is_empty() {
247 if let Some(provider) = self.providers.get(DEFAULT_PROVIDER) {
249 let protocol = provider.api_protocol.unwrap_or(ApiProtocol::Openai);
250 if let Some(models) = &provider.models {
251 for model_id in models.keys() {
252 entries.push(RouteEntry {
253 name: model_id.clone(),
254 provider: DEFAULT_PROVIDER.to_owned(),
255 protocol,
256 });
257 }
258 }
259 }
260 } else {
261 for (model_name, model_config) in &self.models {
262 if let Some(endpoint) = model_config.endpoints.first() {
263 let protocol = endpoint
264 .api_protocol
265 .or_else(|| {
266 self.providers
267 .get(&endpoint.provider)
268 .and_then(|p| p.api_protocol)
269 })
270 .unwrap_or(ApiProtocol::Openai);
271 entries.push(RouteEntry {
272 name: model_name.clone(),
273 provider: endpoint.provider.clone(),
274 protocol,
275 });
276 }
277 }
278 }
279
280 entries.sort_by(|a, b| a.name.cmp(&b.name));
281 entries
282 }
283}
284
285impl ModelRegistry for ConfigRoutingTable {
286 fn list_models(&self) -> Vec<ModelEntry> {
287 let mut entries: Vec<ModelEntry> = if self.models.is_empty() {
288 self.providers
290 .get(DEFAULT_PROVIDER)
291 .and_then(|p| p.models.as_ref())
292 .into_iter()
293 .flat_map(|models| {
294 models.iter().map(|(model_id, info)| {
295 let pricing = info.pricing.clone();
296 let pricing = if pricing.is_empty() {
297 None
298 } else {
299 Some(pricing)
300 };
301 ModelEntry {
302 id: model_id.clone(),
303 providers: vec![DEFAULT_PROVIDER.to_owned()],
304 name: info.name.clone(),
305 description: info.description.clone(),
306 max_input_tokens: info.max_input_tokens,
307 max_output_tokens: info.max_output_tokens,
308 input_modalities: info
309 .input_modalities
310 .iter()
311 .map(|m| m.to_string())
312 .collect(),
313 output_modalities: info
314 .output_modalities
315 .iter()
316 .map(|m| m.to_string())
317 .collect(),
318 pricing,
319 }
320 })
321 })
322 .collect()
323 } else {
324 self.models
325 .iter()
326 .map(|(model_name, model_config)| {
327 let providers: Vec<String> = model_config
328 .endpoints
329 .iter()
330 .map(|ep| ep.provider.clone())
331 .collect();
332 let pricing = model_config.pricing.clone();
333 let pricing = if pricing.is_empty() {
334 None
335 } else {
336 Some(pricing)
337 };
338 ModelEntry {
339 id: model_name.clone(),
340 providers,
341 name: model_config.name.clone(),
342 description: None,
343 max_input_tokens: model_config.max_input_tokens,
344 max_output_tokens: model_config.max_output_tokens,
345 input_modalities: model_config
346 .input_modalities
347 .iter()
348 .map(|m| m.to_string())
349 .collect(),
350 output_modalities: model_config
351 .output_modalities
352 .iter()
353 .map(|m| m.to_string())
354 .collect(),
355 pricing,
356 }
357 })
358 .collect()
359 };
360 entries.sort_by(|a, b| a.id.cmp(&b.id));
361 entries
362 }
363}
364
365pub struct ConfigToolRoutingTable {
377 providers: HashMap<String, ProviderConfig>,
378 tools: HashMap<String, ToolConfig>,
379 counters: HashMap<String, AtomicUsize>,
381}
382
383impl ConfigToolRoutingTable {
384 pub fn new(
385 providers: HashMap<String, ProviderConfig>,
386 tools: HashMap<String, ToolConfig>,
387 ) -> Self {
388 let counters = tools
389 .keys()
390 .map(|k| (k.clone(), AtomicUsize::new(0)))
391 .collect();
392 Self {
393 providers,
394 tools,
395 counters,
396 }
397 }
398
399 pub fn providers(&self) -> &HashMap<String, ProviderConfig> {
401 &self.providers
402 }
403
404 pub fn tools(&self) -> &HashMap<String, ToolConfig> {
406 &self.tools
407 }
408
409 pub fn providers_by_protocol(&self) -> HashMap<ApiProtocol, Vec<(String, ProviderConfig)>> {
415 let mut seen: std::collections::HashSet<(String, ApiProtocol)> =
418 std::collections::HashSet::new();
419 let mut map: HashMap<ApiProtocol, Vec<(String, ProviderConfig)>> = HashMap::new();
420
421 for tool_config in self.tools.values() {
422 for endpoint in &tool_config.endpoints {
423 let Some(provider) = self.providers.get(&endpoint.provider) else {
424 eprintln!(
425 "warning: tool endpoint references unknown provider '{}' — skipping",
426 endpoint.provider
427 );
428 continue;
429 };
430
431 let protocol = endpoint.api_protocol.or(provider.api_protocol);
433 let Some(protocol) = protocol else {
434 eprintln!(
435 "warning: provider '{}' has no api_protocol configured — skipping",
436 endpoint.provider
437 );
438 continue;
439 };
440
441 if !seen.insert((endpoint.provider.clone(), protocol)) {
442 continue;
443 }
444
445 let mut config = provider.clone();
447 if let Some(ref base) = endpoint.api_base {
448 config.api_base = Some(base.clone());
449 }
450 if let Some(ref key) = endpoint.api_key {
451 config.api_key = Some(key.clone());
452 }
453 config.api_protocol = Some(protocol);
454
455 map.entry(protocol)
456 .or_default()
457 .push((endpoint.provider.clone(), config));
458 }
459 }
460 map
461 }
462
463 pub fn tool_pricing(&self, tool_name: &str) -> Option<&bitrouter_core::pricing::FlatPricing> {
465 self.tools.get(tool_name)?.pricing.as_ref()
466 }
467
468 pub fn resolve(&self, incoming: &str) -> Result<ResolvedTarget> {
470 if let Some((prefix, suffix)) = incoming.split_once(':')
472 && self.providers.contains_key(prefix)
473 {
474 let api_protocol = resolve_protocol(&self.providers, prefix, None)?;
475 return Ok(ResolvedTarget {
476 provider_name: prefix.to_owned(),
477 service_id: suffix.to_owned(),
478 api_protocol,
479 api_key_override: None,
480 api_base_override: None,
481 });
482 }
483
484 if let Some(tool_config) = self.tools.get(incoming) {
486 return self.select_endpoint(incoming, tool_config);
487 }
488
489 if let Some((provider, service_id)) = incoming.split_once('/') {
492 for tool_config in self.tools.values() {
493 if let Some(ep) = tool_config
494 .endpoints
495 .iter()
496 .find(|ep| ep.provider == provider && ep.service_id == service_id)
497 {
498 let api_protocol =
499 resolve_protocol(&self.providers, &ep.provider, ep.api_protocol)?;
500 return Ok(ResolvedTarget {
501 provider_name: ep.provider.clone(),
502 service_id: strip_ansi_escapes(&ep.service_id),
503 api_protocol,
504 api_key_override: ep.api_key.clone(),
505 api_base_override: ep.api_base.clone(),
506 });
507 }
508 }
509 }
510
511 Err(BitrouterError::invalid_request(
512 None,
513 format!("no route found for tool: {incoming}"),
514 None,
515 ))
516 }
517
518 fn select_endpoint(&self, tool_name: &str, config: &ToolConfig) -> Result<ResolvedTarget> {
519 if config.endpoints.is_empty() {
520 return Err(BitrouterError::invalid_request(
521 None,
522 format!("tool '{tool_name}' has no configured endpoints"),
523 None,
524 ));
525 }
526
527 let endpoint = match config.strategy {
528 RoutingStrategy::Priority => &config.endpoints[0],
529 RoutingStrategy::LoadBalance => {
530 let Some(counter) = self.counters.get(tool_name) else {
531 return Err(BitrouterError::invalid_request(
532 None,
533 format!("load-balance counter missing for tool '{tool_name}'"),
534 None,
535 ));
536 };
537 let idx = counter.fetch_add(1, Ordering::Relaxed) % config.endpoints.len();
538 &config.endpoints[idx]
539 }
540 };
541
542 let api_protocol =
543 resolve_protocol(&self.providers, &endpoint.provider, endpoint.api_protocol)?;
544
545 Ok(ResolvedTarget {
546 provider_name: endpoint.provider.clone(),
547 service_id: strip_ansi_escapes(&endpoint.service_id),
548 api_protocol,
549 api_key_override: endpoint.api_key.clone(),
550 api_base_override: endpoint.api_base.clone(),
551 })
552 }
553}
554
555impl RoutingTable for ConfigToolRoutingTable {
556 async fn route(&self, incoming_name: &str, _context: &RouteContext) -> Result<RoutingTarget> {
557 let resolved = self.resolve(incoming_name)?;
558 Ok(RoutingTarget {
559 provider_name: resolved.provider_name,
560 service_id: resolved.service_id,
561 api_protocol: resolved.api_protocol,
562 })
563 }
564
565 fn list_routes(&self) -> Vec<RouteEntry> {
566 let mut entries: Vec<RouteEntry> = self
567 .tools
568 .iter()
569 .filter_map(|(tool_name, tool_config)| {
570 let endpoint = tool_config.endpoints.first()?;
571 let protocol = endpoint
572 .api_protocol
573 .or_else(|| {
574 self.providers
575 .get(&endpoint.provider)
576 .and_then(|p| p.api_protocol)
577 })
578 .unwrap_or(ApiProtocol::Mcp);
579 Some(RouteEntry {
580 name: tool_name.clone(),
581 provider: endpoint.provider.clone(),
582 protocol,
583 })
584 })
585 .collect();
586 entries.sort_by(|a, b| a.name.cmp(&b.name));
587 entries
588 }
589}
590
591impl ToolRegistry for ConfigToolRoutingTable {
592 async fn list_tools(&self) -> Vec<ToolEntry> {
593 let mut entries: Vec<ToolEntry> = self
594 .tools
595 .iter()
596 .filter_map(|(tool_name, config)| {
597 let ep = config.endpoints.first()?;
598 let input_schema = config
599 .input_schema
600 .as_ref()
601 .and_then(|v| serde_json::from_value(v.clone()).ok());
602 Some(ToolEntry {
603 id: format!("{}/{}", ep.provider, ep.service_id),
604 provider: ep.provider.clone(),
605 definition: ToolDefinition {
606 name: tool_name.clone(),
607 description: config.description.clone(),
608 input_schema,
609 annotations: None,
610 input_examples: Vec::new(),
611 },
612 })
613 })
614 .collect();
615 entries.sort_by(|a, b| a.id.cmp(&b.id));
616 entries
617 }
618}
619
620pub struct ConfigAgentRegistry {
629 agents: HashMap<String, AgentConfig>,
630}
631
632impl ConfigAgentRegistry {
633 pub fn new(agents: HashMap<String, AgentConfig>) -> Self {
635 Self { agents }
636 }
637}
638
639impl AgentRegistry for ConfigAgentRegistry {
640 async fn list_agents(&self) -> Vec<AgentEntry> {
641 let mut entries: Vec<AgentEntry> = self
642 .agents
643 .iter()
644 .map(|(name, config)| {
645 let status = if config.enabled {
646 AgentEntryStatus::Idle
647 } else {
648 AgentEntryStatus::Unavailable
649 };
650 AgentEntry {
651 name: name.clone(),
652 protocol: config.protocol.to_string(),
653 description: None,
654 capabilities: AgentCapabilityFlags::default(),
655 status,
656 }
657 })
658 .collect();
659 entries.sort_by(|a, b| a.name.cmp(&b.name));
660 entries
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use bitrouter_core::routers::routing_table::ApiProtocol;
668
669 use crate::config::{Endpoint, InputTokenPricing, Modality, OutputTokenPricing};
670
671 fn test_providers() -> HashMap<String, ProviderConfig> {
672 let mut p = HashMap::new();
673 p.insert(
674 "openai".into(),
675 ProviderConfig {
676 api_protocol: Some(ApiProtocol::Openai),
677 api_base: Some("https://api.openai.com/v1".into()),
678 ..Default::default()
679 },
680 );
681 p.insert(
682 "anthropic".into(),
683 ProviderConfig {
684 api_protocol: Some(ApiProtocol::Anthropic),
685 api_base: Some("https://api.anthropic.com".into()),
686 ..Default::default()
687 },
688 );
689 p
690 }
691
692 #[test]
693 fn direct_provider_routing() {
694 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
695 let target = table.resolve("openai:gpt-4o").unwrap();
696 assert_eq!(target.provider_name, "openai");
697 assert_eq!(target.service_id, "gpt-4o");
698 }
699
700 #[test]
701 fn direct_provider_routing_with_slash_in_model() {
702 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
703 let target = table.resolve("openai:deepseek/deepseek-v3").unwrap();
704 assert_eq!(target.provider_name, "openai");
705 assert_eq!(target.service_id, "deepseek/deepseek-v3");
706 }
707
708 #[test]
709 fn anthropic_direct_routing() {
710 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
711 let target = table.resolve("anthropic:claude-opus-4-6").unwrap();
712 assert_eq!(target.provider_name, "anthropic");
713 assert_eq!(target.service_id, "claude-opus-4-6");
714 }
715
716 #[test]
717 fn unknown_provider_prefix_falls_through_to_models() {
718 let mut models = HashMap::new();
719 models.insert(
720 "unknown:custom-model".into(),
721 ModelConfig {
722 strategy: RoutingStrategy::Priority,
723 endpoints: vec![Endpoint {
724 provider: "openai".into(),
725 service_id: "custom-model".into(),
726 api_protocol: None,
727 api_key: None,
728 api_base: None,
729 }],
730 ..Default::default()
731 },
732 );
733 let table = ConfigRoutingTable::new(test_providers(), models);
734 let target = table.resolve("unknown:custom-model").unwrap();
735 assert_eq!(target.provider_name, "openai");
736 assert_eq!(target.service_id, "custom-model");
737 }
738
739 #[test]
740 fn model_lookup_without_colon() {
741 let mut models = HashMap::new();
742 models.insert(
743 "my-gpt4".into(),
744 ModelConfig {
745 strategy: RoutingStrategy::Priority,
746 endpoints: vec![Endpoint {
747 provider: "openai".into(),
748 service_id: "gpt-4o".into(),
749 api_protocol: None,
750 api_key: Some("sk-override".into()),
751 api_base: None,
752 }],
753 ..Default::default()
754 },
755 );
756 let table = ConfigRoutingTable::new(test_providers(), models);
757 let target = table.resolve("my-gpt4").unwrap();
758 assert_eq!(target.provider_name, "openai");
759 assert_eq!(target.service_id, "gpt-4o");
760 assert_eq!(target.api_key_override.as_deref(), Some("sk-override"));
761 }
762
763 #[test]
764 fn slash_separator_does_not_match_provider() {
765 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
766 let result = table.resolve("openai/gpt-4o");
768 assert!(result.is_err());
769 }
770
771 #[test]
772 fn load_balance_round_robin() {
773 let mut models = HashMap::new();
774 models.insert(
775 "balanced".into(),
776 ModelConfig {
777 strategy: RoutingStrategy::LoadBalance,
778 endpoints: vec![
779 Endpoint {
780 provider: "openai".into(),
781 service_id: "gpt-4o".into(),
782 api_protocol: None,
783 api_key: Some("key-a".into()),
784 api_base: None,
785 },
786 Endpoint {
787 provider: "openai".into(),
788 service_id: "gpt-4o".into(),
789 api_protocol: None,
790 api_key: Some("key-b".into()),
791 api_base: None,
792 },
793 ],
794 ..Default::default()
795 },
796 );
797 let table = ConfigRoutingTable::new(test_providers(), models);
798
799 let t1 = table.resolve("balanced").unwrap();
800 let t2 = table.resolve("balanced").unwrap();
801 let t3 = table.resolve("balanced").unwrap();
802
803 assert_eq!(t1.api_key_override.as_deref(), Some("key-a"));
804 assert_eq!(t2.api_key_override.as_deref(), Some("key-b"));
805 assert_eq!(t3.api_key_override.as_deref(), Some("key-a")); }
807
808 #[test]
809 fn priority_always_picks_first() {
810 let mut models = HashMap::new();
811 models.insert(
812 "primary".into(),
813 ModelConfig {
814 strategy: RoutingStrategy::Priority,
815 endpoints: vec![
816 Endpoint {
817 provider: "openai".into(),
818 service_id: "gpt-4o".into(),
819 api_protocol: None,
820 api_key: Some("primary-key".into()),
821 api_base: None,
822 },
823 Endpoint {
824 provider: "openai".into(),
825 service_id: "gpt-4o".into(),
826 api_protocol: None,
827 api_key: Some("fallback-key".into()),
828 api_base: None,
829 },
830 ],
831 ..Default::default()
832 },
833 );
834 let table = ConfigRoutingTable::new(test_providers(), models);
835
836 for _ in 0..5 {
837 let t = table.resolve("primary").unwrap();
838 assert_eq!(t.api_key_override.as_deref(), Some("primary-key"));
839 }
840 }
841
842 #[test]
843 fn no_route_found() {
844 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
845 let result = table.resolve("nonexistent-model");
846 assert!(result.is_err());
847 }
848
849 #[test]
850 fn empty_endpoints_is_error() {
851 let mut models = HashMap::new();
852 models.insert(
853 "empty".into(),
854 ModelConfig {
855 strategy: RoutingStrategy::Priority,
856 endpoints: vec![],
857 ..Default::default()
858 },
859 );
860 let table = ConfigRoutingTable::new(test_providers(), models);
861 let result = table.resolve("empty");
862 assert!(result.is_err());
863 }
864
865 #[test]
866 fn model_pricing_returns_configured_values() {
867 let mut providers = test_providers();
868 providers.get_mut("openai").unwrap().models = Some(HashMap::from([(
869 "gpt-4o".into(),
870 ModelInfo {
871 pricing: ModelPricing {
872 input_tokens: InputTokenPricing {
873 no_cache: Some(2.50),
874 cache_read: Some(1.25),
875 cache_write: Some(2.50),
876 },
877 output_tokens: OutputTokenPricing {
878 text: Some(10.00),
879 reasoning: Some(10.00),
880 },
881 },
882 ..Default::default()
883 },
884 )]));
885 let table = ConfigRoutingTable::new(providers, HashMap::new());
886
887 let pricing = table.model_pricing("openai", "gpt-4o");
888 assert_eq!(pricing.input_tokens.no_cache, Some(2.50));
889 assert_eq!(pricing.input_tokens.cache_read, Some(1.25));
890 assert_eq!(pricing.output_tokens.text, Some(10.00));
891 }
892
893 #[test]
894 fn model_pricing_unknown_model_returns_defaults() {
895 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
896 let pricing = table.model_pricing("openai", "nonexistent");
897 assert_eq!(pricing.input_tokens.no_cache, None);
898 assert_eq!(pricing.output_tokens.text, None);
899 }
900
901 #[test]
902 fn model_pricing_unknown_provider_returns_defaults() {
903 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
904 let pricing = table.model_pricing("unknown-provider", "gpt-4o");
905 assert_eq!(pricing.input_tokens.no_cache, None);
906 }
907
908 #[test]
909 fn model_info_returns_full_metadata() {
910 let mut providers = test_providers();
911 providers.get_mut("openai").unwrap().models = Some(HashMap::from([(
912 "gpt-4o".into(),
913 ModelInfo {
914 name: Some("GPT-4o".into()),
915 description: Some("Multimodal model".into()),
916 max_input_tokens: Some(128000),
917 max_output_tokens: Some(16384),
918 input_modalities: vec![Modality::Text, Modality::Image],
919 output_modalities: vec![Modality::Text],
920 ..Default::default()
921 },
922 )]));
923 let table = ConfigRoutingTable::new(providers, HashMap::new());
924
925 let info = table.model_info("openai", "gpt-4o");
926 assert_eq!(info.name.as_deref(), Some("GPT-4o"));
927 assert_eq!(info.max_input_tokens, Some(128000));
928 assert_eq!(info.max_output_tokens, Some(16384));
929 assert_eq!(info.input_modalities, vec![Modality::Text, Modality::Image]);
930 }
931
932 #[test]
933 fn model_info_unknown_returns_defaults() {
934 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
935 let info = table.model_info("openai", "nonexistent");
936 assert!(info.name.is_none());
937 assert!(info.max_input_tokens.is_none());
938 assert!(info.input_modalities.is_empty());
939 }
940
941 fn providers_with_bitrouter() -> HashMap<String, ProviderConfig> {
944 let mut p = test_providers();
945 p.insert(
946 "bitrouter".into(),
947 ProviderConfig {
948 api_protocol: Some(ApiProtocol::Openai),
949 api_base: Some("https://api.bitrouter.ai/v1".into()),
950 models: Some(HashMap::from([
951 (
952 "openai/gpt-4o".into(),
953 ModelInfo {
954 name: Some("GPT-4o".into()),
955 max_input_tokens: Some(128000),
956 ..Default::default()
957 },
958 ),
959 (
960 "anthropic/claude-sonnet-4".into(),
961 ModelInfo {
962 name: Some("Claude Sonnet 4".into()),
963 max_input_tokens: Some(200000),
964 ..Default::default()
965 },
966 ),
967 ])),
968 ..Default::default()
969 },
970 );
971 p
972 }
973
974 #[test]
975 fn fallback_routes_bare_name_to_default_provider() {
976 let table = ConfigRoutingTable::new(providers_with_bitrouter(), HashMap::new());
977 let target = table.resolve("openai/gpt-4o").unwrap();
978 assert_eq!(target.provider_name, "bitrouter");
979 assert_eq!(target.service_id, "openai/gpt-4o");
980 }
981
982 #[test]
983 fn fallback_routes_arbitrary_bare_name() {
984 let table = ConfigRoutingTable::new(providers_with_bitrouter(), HashMap::new());
985 let target = table.resolve("some-unknown-model").unwrap();
986 assert_eq!(target.provider_name, "bitrouter");
987 assert_eq!(target.service_id, "some-unknown-model");
988 }
989
990 #[test]
991 fn fallback_does_not_fire_when_models_configured() {
992 let mut models = HashMap::new();
993 models.insert(
994 "my-gpt4".into(),
995 ModelConfig {
996 strategy: RoutingStrategy::Priority,
997 endpoints: vec![Endpoint {
998 provider: "openai".into(),
999 service_id: "gpt-4o".into(),
1000 api_protocol: None,
1001 api_key: None,
1002 api_base: None,
1003 }],
1004 ..Default::default()
1005 },
1006 );
1007 let table = ConfigRoutingTable::new(providers_with_bitrouter(), models);
1008 let result = table.resolve("openai/gpt-4o");
1010 assert!(result.is_err());
1011 }
1012
1013 #[test]
1014 fn fallback_not_active_without_default_provider() {
1015 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
1017 let result = table.resolve("openai/gpt-4o");
1018 assert!(result.is_err());
1019 }
1020
1021 #[test]
1022 fn direct_routing_takes_precedence_over_fallback() {
1023 let table = ConfigRoutingTable::new(providers_with_bitrouter(), HashMap::new());
1024 let target = table.resolve("openai:gpt-4o").unwrap();
1026 assert_eq!(target.provider_name, "openai");
1027 assert_eq!(target.service_id, "gpt-4o");
1028 }
1029
1030 #[test]
1031 fn explicit_bitrouter_prefix_routes_directly() {
1032 let table = ConfigRoutingTable::new(providers_with_bitrouter(), HashMap::new());
1033 let target = table.resolve("bitrouter:openai/gpt-4o").unwrap();
1034 assert_eq!(target.provider_name, "bitrouter");
1035 assert_eq!(target.service_id, "openai/gpt-4o");
1036 }
1037
1038 #[test]
1039 fn fallback_list_routes_surfaces_default_catalog() {
1040 let table = ConfigRoutingTable::new(providers_with_bitrouter(), HashMap::new());
1041 let routes = table.list_routes();
1042 assert_eq!(routes.len(), 2);
1043 assert!(routes.iter().all(|r| r.provider == "bitrouter"));
1044 assert!(routes.iter().any(|r| r.name == "openai/gpt-4o"));
1045 assert!(routes.iter().any(|r| r.name == "anthropic/claude-sonnet-4"));
1046 }
1047
1048 #[test]
1049 fn fallback_list_models_surfaces_default_catalog() {
1050 let table = ConfigRoutingTable::new(providers_with_bitrouter(), HashMap::new());
1051 let models = table.list_models();
1052 assert_eq!(models.len(), 2);
1053 assert!(models.iter().any(|m| m.id == "openai/gpt-4o"));
1054 assert!(models.iter().any(|m| m.id == "anthropic/claude-sonnet-4"));
1055 let gpt = models.iter().find(|m| m.id == "openai/gpt-4o").unwrap();
1057 assert_eq!(gpt.name.as_deref(), Some("GPT-4o"));
1058 assert_eq!(gpt.max_input_tokens, Some(128000));
1059 assert_eq!(gpt.providers, vec!["bitrouter"]);
1060 }
1061
1062 #[test]
1063 fn no_fallback_list_routes_empty_without_default_provider() {
1064 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
1065 assert!(table.list_routes().is_empty());
1066 }
1067
1068 #[test]
1069 fn no_fallback_list_models_empty_without_default_provider() {
1070 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
1071 assert!(table.list_models().is_empty());
1072 }
1073
1074 #[tokio::test]
1077 async fn auto_route_coding_signal_resolves() {
1078 use crate::config::RoutingRuleConfig;
1079 let mut models = HashMap::new();
1080 models.insert(
1081 "code-model".to_owned(),
1082 ModelConfig {
1083 endpoints: vec![crate::config::Endpoint {
1084 provider: "openai".into(),
1085 service_id: "gpt-4o".into(),
1086 api_protocol: None,
1087 api_key: None,
1088 api_base: None,
1089 }],
1090 ..Default::default()
1091 },
1092 );
1093 models.insert(
1094 "general".to_owned(),
1095 ModelConfig {
1096 endpoints: vec![crate::config::Endpoint {
1097 provider: "anthropic".into(),
1098 service_id: "claude-sonnet".into(),
1099 api_protocol: None,
1100 api_key: None,
1101 api_base: None,
1102 }],
1103 ..Default::default()
1104 },
1105 );
1106
1107 let mut routing = HashMap::new();
1108 routing.insert(
1109 "auto".to_owned(),
1110 RoutingRuleConfig {
1111 inherit_defaults: true,
1112 models: HashMap::from([
1113 ("coding".into(), "code-model".into()),
1114 ("default".into(), "general".into()),
1115 ]),
1116 ..Default::default()
1117 },
1118 );
1119
1120 let table = ConfigRoutingTable::with_routing(test_providers(), models, &routing);
1121
1122 let ctx = RouteContext {
1124 text: "help me debug this function and fix the compile error".into(),
1125 char_count: 52,
1126 turn_count: 1,
1127 ..Default::default()
1128 };
1129 let target = table.route("auto", &ctx).await.unwrap();
1130 assert_eq!(target.provider_name, "openai");
1131 assert_eq!(target.service_id, "gpt-4o");
1132 }
1133
1134 #[tokio::test]
1135 async fn auto_route_empty_context_falls_through() {
1136 use crate::config::RoutingRuleConfig;
1137 let mut models = HashMap::new();
1138 models.insert(
1139 "auto".to_owned(),
1140 ModelConfig {
1141 endpoints: vec![crate::config::Endpoint {
1142 provider: "openai".into(),
1143 service_id: "gpt-4o".into(),
1144 api_protocol: None,
1145 api_key: None,
1146 api_base: None,
1147 }],
1148 ..Default::default()
1149 },
1150 );
1151
1152 let mut routing = HashMap::new();
1153 routing.insert(
1154 "auto".to_owned(),
1155 RoutingRuleConfig {
1156 inherit_defaults: true,
1157 models: HashMap::from([("default".into(), "general".into())]),
1158 ..Default::default()
1159 },
1160 );
1161
1162 let table = ConfigRoutingTable::with_routing(test_providers(), models, &routing);
1163
1164 let target = table.route("auto", &RouteContext::default()).await.unwrap();
1166 assert_eq!(target.provider_name, "openai");
1167 assert_eq!(target.service_id, "gpt-4o");
1168 }
1169
1170 #[tokio::test]
1171 async fn auto_route_non_trigger_passes_through() {
1172 use crate::config::RoutingRuleConfig;
1173 let mut models = HashMap::new();
1174 models.insert(
1175 "my-model".to_owned(),
1176 ModelConfig {
1177 endpoints: vec![crate::config::Endpoint {
1178 provider: "anthropic".into(),
1179 service_id: "claude-sonnet".into(),
1180 api_protocol: None,
1181 api_key: None,
1182 api_base: None,
1183 }],
1184 ..Default::default()
1185 },
1186 );
1187
1188 let mut routing = HashMap::new();
1189 routing.insert(
1190 "auto".to_owned(),
1191 RoutingRuleConfig {
1192 inherit_defaults: true,
1193 models: HashMap::from([("default".into(), "my-model".into())]),
1194 ..Default::default()
1195 },
1196 );
1197
1198 let table = ConfigRoutingTable::with_routing(test_providers(), models, &routing);
1199
1200 let ctx = RouteContext {
1202 text: "help me code".into(),
1203 char_count: 12,
1204 turn_count: 1,
1205 ..Default::default()
1206 };
1207 let target = table.route("my-model", &ctx).await.unwrap();
1208 assert_eq!(target.provider_name, "anthropic");
1209 assert_eq!(target.service_id, "claude-sonnet");
1210 }
1211
1212 fn tool_providers() -> HashMap<String, ProviderConfig> {
1215 let mut p = HashMap::new();
1216 p.insert(
1217 "github-mcp".into(),
1218 ProviderConfig {
1219 api_protocol: Some(ApiProtocol::Mcp),
1220 api_base: Some("https://api.githubcopilot.com/mcp".into()),
1221 ..Default::default()
1222 },
1223 );
1224 p.insert(
1225 "anthropic".into(),
1226 ProviderConfig {
1227 api_protocol: Some(ApiProtocol::Anthropic),
1228 api_base: Some("https://api.anthropic.com".into()),
1229 ..Default::default()
1230 },
1231 );
1232 p
1233 }
1234
1235 #[test]
1236 fn tool_direct_provider_routing() {
1237 let table = ConfigToolRoutingTable::new(tool_providers(), HashMap::new());
1238 let target = table.resolve("github-mcp:create_issue").unwrap();
1239 assert_eq!(target.provider_name, "github-mcp");
1240 assert_eq!(target.service_id, "create_issue");
1241 assert_eq!(target.api_protocol, ApiProtocol::Mcp);
1242 }
1243
1244 #[test]
1245 fn tool_slash_namespaced_routing() {
1246 let mut tools = HashMap::new();
1247 tools.insert(
1248 "create_issue".into(),
1249 ToolConfig {
1250 strategy: RoutingStrategy::Priority,
1251 endpoints: vec![Endpoint {
1252 provider: "github-mcp".into(),
1253 service_id: "create_issue".into(),
1254 api_protocol: None,
1255 api_key: None,
1256 api_base: None,
1257 }],
1258 ..Default::default()
1259 },
1260 );
1261 let table = ConfigToolRoutingTable::new(tool_providers(), tools);
1262 let target = table.resolve("github-mcp/create_issue").ok();
1264 assert!(target.is_some());
1265 let target = target.as_ref();
1266 assert_eq!(target.map(|t| t.provider_name.as_str()), Some("github-mcp"));
1267 assert_eq!(target.map(|t| t.service_id.as_str()), Some("create_issue"));
1268 assert_eq!(target.map(|t| t.api_protocol), Some(ApiProtocol::Mcp));
1269 }
1270
1271 #[test]
1272 fn tool_slash_no_match_when_no_config() {
1273 let table = ConfigToolRoutingTable::new(tool_providers(), HashMap::new());
1275 assert!(table.resolve("github-mcp/unknown").is_err());
1276 }
1277
1278 #[test]
1279 fn tool_lookup() {
1280 let mut tools = HashMap::new();
1281 tools.insert(
1282 "create_issue".into(),
1283 ToolConfig {
1284 strategy: RoutingStrategy::Priority,
1285 endpoints: vec![Endpoint {
1286 provider: "github-mcp".into(),
1287 service_id: "create_issue".into(),
1288 api_protocol: None,
1289 api_key: None,
1290 api_base: None,
1291 }],
1292 ..Default::default()
1293 },
1294 );
1295 let table = ConfigToolRoutingTable::new(tool_providers(), tools);
1296 let target = table.resolve("create_issue").unwrap();
1297 assert_eq!(target.provider_name, "github-mcp");
1298 assert_eq!(target.service_id, "create_issue");
1299 assert_eq!(target.api_protocol, ApiProtocol::Mcp);
1300 }
1301
1302 #[test]
1303 fn tool_endpoint_protocol_override() {
1304 let mut tools = HashMap::new();
1305 tools.insert(
1306 "web_search".into(),
1307 ToolConfig {
1308 endpoints: vec![Endpoint {
1309 provider: "anthropic".into(),
1310 service_id: "web_search".into(),
1311 api_protocol: Some(ApiProtocol::Mcp),
1312 api_key: None,
1313 api_base: Some("https://mcp.anthropic.com".into()),
1314 }],
1315 ..Default::default()
1316 },
1317 );
1318 let table = ConfigToolRoutingTable::new(tool_providers(), tools);
1319 let target = table.resolve("web_search").unwrap();
1320 assert_eq!(target.provider_name, "anthropic");
1321 assert_eq!(target.api_protocol, ApiProtocol::Mcp);
1322 assert_eq!(
1323 target.api_base_override.as_deref(),
1324 Some("https://mcp.anthropic.com")
1325 );
1326 }
1327
1328 #[test]
1329 fn tool_no_route_found() {
1330 let table = ConfigToolRoutingTable::new(tool_providers(), HashMap::new());
1331 let result = table.resolve("nonexistent-tool");
1332 assert!(result.is_err());
1333 }
1334
1335 #[test]
1336 fn tool_no_fallback_for_bare_names() {
1337 let table = ConfigToolRoutingTable::new(tool_providers(), HashMap::new());
1339 let result = table.resolve("some-tool");
1340 assert!(result.is_err());
1341 }
1342
1343 #[test]
1344 fn tool_load_balance_round_robin() {
1345 let mut tools = HashMap::new();
1346 tools.insert(
1347 "search".into(),
1348 ToolConfig {
1349 strategy: RoutingStrategy::LoadBalance,
1350 endpoints: vec![
1351 Endpoint {
1352 provider: "github-mcp".into(),
1353 service_id: "search".into(),
1354 api_protocol: None,
1355 api_key: Some("key-a".into()),
1356 api_base: None,
1357 },
1358 Endpoint {
1359 provider: "github-mcp".into(),
1360 service_id: "search".into(),
1361 api_protocol: None,
1362 api_key: Some("key-b".into()),
1363 api_base: None,
1364 },
1365 ],
1366 ..Default::default()
1367 },
1368 );
1369 let table = ConfigToolRoutingTable::new(tool_providers(), tools);
1370
1371 let t1 = table.resolve("search").unwrap();
1372 let t2 = table.resolve("search").unwrap();
1373 let t3 = table.resolve("search").unwrap();
1374
1375 assert_eq!(t1.api_key_override.as_deref(), Some("key-a"));
1376 assert_eq!(t2.api_key_override.as_deref(), Some("key-b"));
1377 assert_eq!(t3.api_key_override.as_deref(), Some("key-a"));
1378 }
1379
1380 #[tokio::test]
1381 async fn tool_list_tools_from_config() {
1382 let mut tools = HashMap::new();
1383 tools.insert(
1384 "search".into(),
1385 ToolConfig {
1386 endpoints: vec![Endpoint {
1387 provider: "github-mcp".into(),
1388 service_id: "search_code".into(),
1389 api_protocol: None,
1390 api_key: None,
1391 api_base: None,
1392 }],
1393 description: Some("Search GitHub code".into()),
1394 ..Default::default()
1395 },
1396 );
1397 tools.insert(
1398 "web_search".into(),
1399 ToolConfig {
1400 endpoints: vec![Endpoint {
1401 provider: "github-mcp".into(),
1402 service_id: "web_search".into(),
1403 api_protocol: None,
1404 api_key: None,
1405 api_base: None,
1406 }],
1407 ..Default::default()
1408 },
1409 );
1410 let table = ConfigToolRoutingTable::new(tool_providers(), tools);
1411 let entries = table.list_tools().await;
1412 assert_eq!(entries.len(), 2);
1413
1414 assert_eq!(entries[0].id, "github-mcp/search_code");
1416 assert_eq!(entries[0].provider, "github-mcp");
1417 assert_eq!(entries[0].definition.name, "search");
1418 assert_eq!(
1419 entries[0].definition.description.as_deref(),
1420 Some("Search GitHub code")
1421 );
1422
1423 assert_eq!(entries[1].id, "github-mcp/web_search");
1424 assert!(entries[1].definition.description.is_none());
1425 }
1426
1427 #[tokio::test]
1428 async fn tool_list_tools_empty_endpoints_skipped() {
1429 let mut tools = HashMap::new();
1430 tools.insert(
1431 "empty".into(),
1432 ToolConfig {
1433 endpoints: vec![],
1434 ..Default::default()
1435 },
1436 );
1437 let table = ConfigToolRoutingTable::new(tool_providers(), tools);
1438 let entries = table.list_tools().await;
1439 assert!(entries.is_empty());
1440 }
1441
1442 #[test]
1443 fn tool_list_routes() {
1444 let mut tools = HashMap::new();
1445 tools.insert(
1446 "create_issue".into(),
1447 ToolConfig {
1448 endpoints: vec![Endpoint {
1449 provider: "github-mcp".into(),
1450 service_id: "create_issue".into(),
1451 api_protocol: None,
1452 api_key: None,
1453 api_base: None,
1454 }],
1455 ..Default::default()
1456 },
1457 );
1458 let table = ConfigToolRoutingTable::new(tool_providers(), tools);
1459 let routes = table.list_routes();
1460 assert_eq!(routes.len(), 1);
1461 assert_eq!(routes[0].name, "create_issue");
1462 assert_eq!(routes[0].provider, "github-mcp");
1463 assert_eq!(routes[0].protocol, ApiProtocol::Mcp);
1464 }
1465
1466 fn test_agent_config(enabled: bool) -> AgentConfig {
1469 AgentConfig {
1470 protocol: crate::config::AgentProtocol::Acp,
1471 binary: "test-agent".to_owned(),
1472 args: Vec::new(),
1473 enabled,
1474 distribution: Vec::new(),
1475 session: None,
1476 a2a: None,
1477 }
1478 }
1479
1480 #[tokio::test]
1481 async fn agent_registry_lists_enabled_as_idle() {
1482 let mut agents = HashMap::new();
1483 agents.insert("claude".to_owned(), test_agent_config(true));
1484 let registry = ConfigAgentRegistry::new(agents);
1485
1486 let entries = registry.list_agents().await;
1487 assert_eq!(entries.len(), 1);
1488 assert_eq!(entries[0].name, "claude");
1489 assert_eq!(entries[0].protocol, "acp");
1490 assert_eq!(entries[0].status, AgentEntryStatus::Idle);
1491 }
1492
1493 #[tokio::test]
1494 async fn agent_registry_lists_disabled_as_unavailable() {
1495 let mut agents = HashMap::new();
1496 agents.insert("disabled-agent".to_owned(), test_agent_config(false));
1497 let registry = ConfigAgentRegistry::new(agents);
1498
1499 let entries = registry.list_agents().await;
1500 assert_eq!(entries.len(), 1);
1501 assert_eq!(entries[0].status, AgentEntryStatus::Unavailable);
1502 }
1503
1504 #[tokio::test]
1505 async fn agent_registry_sorted_by_name() {
1506 let mut agents = HashMap::new();
1507 agents.insert("zeta".to_owned(), test_agent_config(true));
1508 agents.insert("alpha".to_owned(), test_agent_config(true));
1509 let registry = ConfigAgentRegistry::new(agents);
1510
1511 let entries = registry.list_agents().await;
1512 assert_eq!(entries[0].name, "alpha");
1513 assert_eq!(entries[1].name, "zeta");
1514 }
1515
1516 #[tokio::test]
1517 async fn agent_registry_empty() {
1518 let registry = ConfigAgentRegistry::new(HashMap::new());
1519 assert!(registry.list_agents().await.is_empty());
1520 }
1521
1522 #[test]
1523 fn agent_config_backward_compat_no_session_no_a2a() {
1524 let yaml = "binary: claude\nargs: [\"--agent\"]\n";
1525 let config: AgentConfig = serde_saphyr::from_str(yaml).expect("should parse");
1526 assert_eq!(config.binary, "claude");
1527 assert!(config.session.is_none());
1528 assert!(config.a2a.is_none());
1529 }
1530
1531 #[test]
1532 fn agent_config_with_session_and_a2a() {
1533 let yaml = r#"
1534binary: claude
1535session:
1536 idle_timeout_secs: 300
1537 max_concurrent: 5
1538a2a:
1539 enabled: true
1540 skills:
1541 - coding
1542 - review
1543"#;
1544 let config: AgentConfig = serde_saphyr::from_str(yaml).expect("should parse");
1545 let session = config.session.expect("session should be present");
1546 assert_eq!(session.idle_timeout_secs, 300);
1547 assert_eq!(session.max_concurrent, 5);
1548
1549 let a2a = config.a2a.expect("a2a should be present");
1550 assert!(a2a.enabled);
1551 assert_eq!(a2a.skills, vec!["coding", "review"]);
1552 }
1553
1554 #[test]
1555 fn agent_session_config_defaults() {
1556 let config = crate::config::AgentSessionConfig::default();
1557 assert_eq!(config.idle_timeout_secs, 600);
1558 assert_eq!(config.max_concurrent, 1);
1559 }
1560
1561 #[test]
1562 fn agent_session_config_partial_defaults() {
1563 let yaml = "idle_timeout_secs: 120\n";
1564 let config: crate::config::AgentSessionConfig =
1565 serde_saphyr::from_str(yaml).expect("should parse");
1566 assert_eq!(config.idle_timeout_secs, 120);
1567 assert_eq!(config.max_concurrent, 1); }
1569
1570 #[test]
1571 fn agent_a2a_config_defaults() {
1572 let config = crate::config::AgentA2aConfig::default();
1573 assert!(!config.enabled);
1574 assert!(config.skills.is_empty());
1575 }
1576
1577 #[test]
1580 fn ansi_escape_stripped_from_fallback_routing() {
1581 let mut providers = test_providers();
1584 providers.insert(
1585 "bitrouter".into(),
1586 ProviderConfig {
1587 api_protocol: Some(ApiProtocol::Openai),
1588 api_base: Some("https://api.bitrouter.ai/v1".into()),
1589 ..Default::default()
1590 },
1591 );
1592 let table = ConfigRoutingTable::new(providers, HashMap::new());
1593
1594 let target = table.resolve("claude-opus-4-6\x1b[1m").unwrap();
1596 assert_eq!(target.service_id, "claude-opus-4-6");
1597 }
1598
1599 #[test]
1600 fn ansi_escape_stripped_from_direct_routing() {
1601 let table = ConfigRoutingTable::new(test_providers(), HashMap::new());
1603 let target = table
1604 .resolve("anthropic:\x1b[1mclaude-opus-4-6\x1b[0m")
1605 .unwrap();
1606 assert_eq!(target.provider_name, "anthropic");
1607 assert_eq!(target.service_id, "claude-opus-4-6");
1608 }
1609
1610 #[test]
1611 fn ansi_escape_stripped_from_endpoint_service_id() {
1612 let mut models = HashMap::new();
1614 models.insert(
1615 "fast".into(),
1616 ModelConfig {
1617 strategy: RoutingStrategy::Priority,
1618 endpoints: vec![Endpoint {
1619 provider: "anthropic".into(),
1620 service_id: "claude-opus-4-6\x1b[1m".into(),
1621 api_protocol: None,
1622 api_key: None,
1623 api_base: None,
1624 }],
1625 ..Default::default()
1626 },
1627 );
1628 let table = ConfigRoutingTable::new(test_providers(), models);
1629 let target = table.resolve("fast").unwrap();
1630 assert_eq!(target.service_id, "claude-opus-4-6");
1631 }
1632
1633 #[tokio::test]
1634 async fn ansi_escape_stripped_in_route_trait() {
1635 let mut providers = test_providers();
1637 providers.insert(
1638 "bitrouter".into(),
1639 ProviderConfig {
1640 api_protocol: Some(ApiProtocol::Openai),
1641 api_base: Some("https://api.bitrouter.ai/v1".into()),
1642 ..Default::default()
1643 },
1644 );
1645 let table = ConfigRoutingTable::new(providers, HashMap::new());
1646
1647 let target = table
1648 .route("claude-opus-4-6\x1b[1m", &RouteContext::default())
1649 .await
1650 .unwrap();
1651 assert_eq!(target.service_id, "claude-opus-4-6");
1652 }
1653}