Skip to main content

bitrouter_config/
routing.rs

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
23/// The provider name used as fallback when the user has no explicit `models:`
24/// section configured.
25const DEFAULT_PROVIDER: &str = "bitrouter";
26
27/// A routing target with full resolution context including any per-endpoint overrides.
28#[derive(Debug, Clone)]
29pub struct ResolvedTarget {
30    pub provider_name: String,
31    /// Upstream service identifier: model ID for language models, tool ID for tools.
32    pub service_id: String,
33    /// The resolved API protocol for this endpoint.
34    ///
35    /// Resolution order: endpoint override > provider default.
36    pub api_protocol: ApiProtocol,
37    /// Per-endpoint API key override.
38    pub api_key_override: Option<String>,
39    /// Per-endpoint API base override.
40    pub api_base_override: Option<String>,
41}
42
43/// Resolves the API protocol for a given provider, with an optional
44/// per-endpoint override.
45fn 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
65/// Configuration-driven routing table.
66///
67/// Routes incoming model names to concrete provider targets using three strategies:
68///
69/// 1. **Direct routing**: `"provider:model_id"` routes directly to the named provider.
70/// 2. **Model lookup**: Names are looked up in the `models` map, which supports
71///    prioritised failover and round-robin load balancing.
72/// 3. **Default provider fallback**: When no explicit `models` section is
73///    configured and the default provider (`bitrouter`) exists, bare model
74///    names are forwarded to that provider.
75pub struct ConfigRoutingTable {
76    providers: HashMap<String, ProviderConfig>,
77    models: HashMap<String, ModelConfig>,
78    /// Per-model round-robin counters for load balancing.
79    counters: HashMap<String, AtomicUsize>,
80    /// Compiled content-based routing rules (empty when no `routing:` config).
81    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    /// Creates a routing table with content-based auto-routing rules.
93    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    /// Returns a reference to the resolved provider configurations.
112    pub fn providers(&self) -> &HashMap<String, ProviderConfig> {
113        &self.providers
114    }
115
116    /// Returns the model metadata for a given provider and model ID.
117    ///
118    /// Falls back to [`ModelInfo::default()`] for unknown providers or
119    /// unconfigured models.
120    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    /// Returns the token pricing for a given provider and model ID.
130    ///
131    /// Convenience wrapper around [`model_info`](Self::model_info) that
132    /// returns only the pricing component. Falls back to
133    /// [`ModelPricing::default()`] (all zeros) for unknown providers or
134    /// unconfigured models.
135    pub fn model_pricing(&self, provider_name: &str, model_id: &str) -> ModelPricing {
136        self.model_info(provider_name, model_id).pricing
137    }
138
139    /// Resolves an incoming model name to a full target with any per-endpoint overrides.
140    pub fn resolve(&self, incoming: &str) -> Result<ResolvedTarget> {
141        // Strategy 1: "provider:model_id" → direct route if provider is known
142        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        // Strategy 2: lookup in models section
156        if let Some(model_config) = self.models.get(incoming) {
157            return self.select_endpoint(incoming, model_config);
158        }
159
160        // Strategy 3: when no explicit models section is configured, fall back
161        // to the default provider (if it exists in the provider set).
162        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        // Content-based auto-routing: if the requested model name is a trigger
220        // and the caller supplied non-empty context, classify and resolve.
221        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            // Delegate the resolved name through normal routing with empty
226            // context to prevent recursive auto-routing.
227            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            // Fallback mode: surface the default provider's model catalog.
248            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            // Fallback mode: surface the default provider's model catalog.
289            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
365// ── Tool routing table ──────────────────────────────────────────────
366
367/// Configuration-driven tool routing table.
368///
369/// Routes incoming tool names to concrete provider targets using two strategies:
370///
371/// 1. **Direct routing**: `"provider:tool_id"` routes directly to the named provider.
372/// 2. **Tool lookup**: Names are looked up in the `tools` map, which supports
373///    prioritised failover and round-robin load balancing.
374///
375/// Unlike model routing, there is no default-provider fallback for tools.
376pub struct ConfigToolRoutingTable {
377    providers: HashMap<String, ProviderConfig>,
378    tools: HashMap<String, ToolConfig>,
379    /// Per-tool round-robin counters for load balancing.
380    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    /// Returns a reference to the resolved provider configurations.
400    pub fn providers(&self) -> &HashMap<String, ProviderConfig> {
401        &self.providers
402    }
403
404    /// Returns a reference to the tool configurations.
405    pub fn tools(&self) -> &HashMap<String, ToolConfig> {
406        &self.tools
407    }
408
409    /// Groups providers by their [`ApiProtocol`], considering only providers
410    /// that are actually referenced by at least one tool endpoint.
411    ///
412    /// Providers without a resolvable `api_protocol` are skipped with a
413    /// warning log.
414    pub fn providers_by_protocol(&self) -> HashMap<ApiProtocol, Vec<(String, ProviderConfig)>> {
415        // Track which (provider_name, protocol) pairs have already been added
416        // so we don't insert duplicates.
417        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                // Resolve the effective protocol: per-endpoint override or provider default.
432                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                // Build a provider config with per-endpoint overrides applied.
446                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    /// Returns the pricing configuration for a tool, if any.
464    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    /// Resolves an incoming tool name to a full target with any per-endpoint overrides.
469    pub fn resolve(&self, incoming: &str) -> Result<ResolvedTarget> {
470        // Strategy 1: "provider:tool_id" → direct route if provider is known
471        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        // Strategy 2: lookup in tools section by bare name
485        if let Some(tool_config) = self.tools.get(incoming) {
486            return self.select_endpoint(incoming, tool_config);
487        }
488
489        // Strategy 3: "provider/service_id" → namespaced format from MCP wire.
490        // Searches config tools for a matching endpoint.
491        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
620// ── Agent registry ──────────────────────────────────────────────
621
622/// Config-driven agent registry that implements [`AgentRegistry`] from
623/// the `agents:` configuration section.
624///
625/// Parallel to [`ConfigRoutingTable`] for models and
626/// [`ConfigToolRoutingTable`] for tools. This is a read-only discovery
627/// registry — agent sessions are managed by the runtime, not the config layer.
628pub struct ConfigAgentRegistry {
629    agents: HashMap<String, AgentConfig>,
630}
631
632impl ConfigAgentRegistry {
633    /// Create a new registry from the agents configuration map.
634    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        // "openai/gpt-4o" uses slash, not colon — should NOT match openai provider
767        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")); // wraps around
806    }
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    // ── Default provider fallback tests ──────────────────────────────
942
943    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        // A name NOT in the models map should error (no fallback).
1009        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        // test_providers() has no "bitrouter" provider
1016        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        // With colon syntax, should route to openai directly, not bitrouter
1025        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        // Verify metadata is surfaced
1056        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    // ── Auto-routing integration tests ─────────────────────────────
1075
1076    #[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        // Request with coding content → coding model
1123        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        // Empty context → skip auto-routing, use normal model lookup for "auto"
1165        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        // "my-model" is not a trigger → normal routing
1201        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    // ── ConfigToolRoutingTable tests ────────────────────────────────
1213
1214    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        // Slash-namespaced format used by MCP wire protocol.
1263        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        // "github-mcp/unknown" should fail when no matching endpoint exists.
1274        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        // Unlike models, tools don't have a default provider fallback
1338        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        // Sorted by id
1415        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    // ── ConfigAgentRegistry ──────────────────────────────────────
1467
1468    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); // default
1568    }
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    // ── ANSI escape code sanitization ────────────────────────────────
1578
1579    #[test]
1580    fn ansi_escape_stripped_from_fallback_routing() {
1581        // Reproduce: model name with ANSI bold code reaches fallback routing
1582        // (Strategy 3). The service_id in the resolved target must be clean.
1583        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        // Incoming model name with ANSI bold suffix: \x1b[1m
1595        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        // Strategy 1: "provider:model_id" with ANSI in the model_id suffix.
1602        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        // Strategy 2: model lookup where the endpoint service_id contains ANSI.
1613        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        // End-to-end: the RoutingTable::route method also strips ANSI.
1636        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}