1use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use crate::backend::BackendKind;
10use crate::capability::Capability;
11use crate::chat::LlmError;
12use crate::error::BackendError;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BackendRequirements {
17 pub kind: BackendKind,
18 pub required_capabilities: Vec<Capability>,
19 pub max_cost_class: CostClass,
20 pub max_latency_ms: u32,
21 pub data_sovereignty: DataSovereignty,
22 pub compliance: ComplianceLevel,
23 pub requires_replay: bool,
24 pub requires_offline: bool,
25}
26
27impl BackendRequirements {
28 #[must_use]
29 pub fn new(kind: BackendKind) -> Self {
30 Self {
31 kind,
32 required_capabilities: Vec::new(),
33 max_cost_class: CostClass::VeryHigh,
34 max_latency_ms: 0,
35 data_sovereignty: DataSovereignty::Any,
36 compliance: ComplianceLevel::None,
37 requires_replay: false,
38 requires_offline: false,
39 }
40 }
41
42 #[must_use]
43 pub fn with_capability(mut self, capability: Capability) -> Self {
44 self.required_capabilities.push(capability);
45 self
46 }
47
48 #[must_use]
49 pub fn with_max_cost(mut self, cost: CostClass) -> Self {
50 self.max_cost_class = cost;
51 self
52 }
53
54 #[must_use]
55 pub fn with_max_latency_ms(mut self, ms: u32) -> Self {
56 self.max_latency_ms = ms;
57 self
58 }
59
60 #[must_use]
61 pub fn with_data_sovereignty(mut self, sovereignty: DataSovereignty) -> Self {
62 self.data_sovereignty = sovereignty;
63 self
64 }
65
66 #[must_use]
67 pub fn with_compliance(mut self, compliance: ComplianceLevel) -> Self {
68 self.compliance = compliance;
69 self
70 }
71
72 #[must_use]
73 pub fn with_replay(mut self) -> Self {
74 self.requires_replay = true;
75 self
76 }
77
78 #[must_use]
79 pub fn with_offline(mut self) -> Self {
80 self.requires_offline = true;
81 self
82 }
83
84 #[must_use]
85 pub fn fast_llm() -> Self {
86 Self::new(BackendKind::Llm)
87 .with_capability(Capability::TextGeneration)
88 .with_max_cost(CostClass::Low)
89 .with_max_latency_ms(2_000)
90 }
91
92 #[must_use]
93 pub fn reasoning_llm() -> Self {
94 Self::new(BackendKind::Llm)
95 .with_capability(Capability::TextGeneration)
96 .with_capability(Capability::Reasoning)
97 .with_max_cost(CostClass::High)
98 .with_max_latency_ms(30_000)
99 }
100
101 #[must_use]
102 pub fn access_policy() -> Self {
103 Self::new(BackendKind::Policy)
104 .with_capability(Capability::AccessControl)
105 .with_max_latency_ms(100)
106 }
107
108 #[must_use]
109 pub fn constraint_solver() -> Self {
110 Self::new(BackendKind::Optimization).with_capability(Capability::ConstraintSolving)
111 }
112
113 #[must_use]
114 pub fn embedding_pipeline() -> Self {
115 Self::new(BackendKind::Analytics).with_capability(Capability::Embedding)
116 }
117
118 #[must_use]
119 pub fn vector_search() -> Self {
120 Self::new(BackendKind::Search).with_capability(Capability::VectorSearch)
121 }
122}
123
124pub trait BackendSelector: Send + Sync {
126 fn select(&self, requirements: &BackendRequirements) -> Result<String, BackendError>;
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct ChatBackendSelectionConfig {
136 pub criteria: SelectionCriteria,
137 pub provider_override: Option<String>,
138}
139
140impl Default for ChatBackendSelectionConfig {
141 fn default() -> Self {
142 Self {
143 criteria: SelectionCriteria::interactive(),
144 provider_override: None,
145 }
146 }
147}
148
149impl ChatBackendSelectionConfig {
150 #[must_use]
151 pub fn with_criteria(mut self, criteria: SelectionCriteria) -> Self {
152 self.criteria = criteria;
153 self
154 }
155
156 #[must_use]
157 pub fn with_provider_override(mut self, provider: impl Into<String>) -> Self {
158 self.provider_override = Some(provider.into());
159 self
160 }
161
162 pub fn from_env() -> Result<Self, ChatBackendSelectionConfigError> {
171 let mut criteria = std::env::var("CONVERGE_LLM_PROFILE")
172 .ok()
173 .map(|value| parse_profile(&value))
174 .transpose()?
175 .unwrap_or_else(SelectionCriteria::interactive);
176
177 if let Ok(value) = std::env::var("CONVERGE_LLM_JURISDICTION") {
178 criteria = criteria.with_jurisdiction(parse_jurisdiction(&value)?);
179 }
180 if let Ok(value) = std::env::var("CONVERGE_LLM_LATENCY") {
181 criteria = criteria.with_latency(parse_latency(&value)?);
182 }
183 if let Ok(value) = std::env::var("CONVERGE_LLM_COST") {
184 criteria = criteria.with_cost(parse_cost(&value)?);
185 }
186 if let Ok(value) = std::env::var("CONVERGE_LLM_COMPLEXITY") {
187 criteria = criteria.with_complexity(parse_complexity(&value)?);
188 }
189 if let Ok(value) = std::env::var("CONVERGE_LLM_COMPLIANCE") {
190 criteria = criteria.with_compliance(parse_compliance(&value)?);
191 }
192
193 let mut capabilities = criteria.capabilities.clone();
194 if env_flag("CONVERGE_LLM_TOOL_USE")? {
195 capabilities.tool_use = true;
196 }
197 if env_flag("CONVERGE_LLM_VISION")? {
198 capabilities.vision = true;
199 }
200 if env_flag("CONVERGE_LLM_STRUCTURED_OUTPUT")? {
201 capabilities.structured_output = true;
202 }
203 if env_flag("CONVERGE_LLM_CODE")? {
204 capabilities.code = true;
205 }
206 if env_flag("CONVERGE_LLM_MULTILINGUAL")? {
207 capabilities.multilingual = true;
208 }
209 if env_flag("CONVERGE_LLM_WEB_SEARCH")? {
210 capabilities.web_search = true;
211 }
212 if env_flag("CONVERGE_LLM_CONTENT_GENERATION")? {
213 capabilities.content_generation = true;
214 }
215 if env_flag("CONVERGE_LLM_BUSINESS_ACUMEN")? {
216 capabilities.business_acumen = true;
217 }
218 if let Ok(value) = std::env::var("CONVERGE_LLM_CONTEXT_TOKENS") {
219 capabilities.min_context_tokens = Some(value.parse::<usize>().map_err(|_| {
220 ChatBackendSelectionConfigError::invalid(
221 "CONVERGE_LLM_CONTEXT_TOKENS",
222 value,
223 "positive integer",
224 )
225 })?);
226 }
227 criteria = criteria.with_capabilities(capabilities);
228
229 if let (Ok(country), Ok(region)) = (
230 std::env::var("CONVERGE_LLM_USER_COUNTRY"),
231 std::env::var("CONVERGE_LLM_USER_REGION"),
232 ) {
233 criteria = criteria.with_user_location(country, region);
234 }
235
236 Ok(Self {
237 criteria,
238 provider_override: std::env::var("CONVERGE_LLM_FORCE_PROVIDER").ok(),
239 })
240 }
241}
242
243#[derive(Debug, Error)]
244pub enum ChatBackendSelectionConfigError {
245 #[error("invalid value for {key}: {value} (expected {expected})")]
246 InvalidValue {
247 key: &'static str,
248 value: String,
249 expected: &'static str,
250 },
251}
252
253impl ChatBackendSelectionConfigError {
254 fn invalid(key: &'static str, value: impl Into<String>, expected: &'static str) -> Self {
255 Self::InvalidValue {
256 key,
257 value: value.into(),
258 expected,
259 }
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ProviderRequest {
266 pub id: String,
268 pub required_capabilities: Vec<Capability>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub backend_requirements: Option<BackendRequirements>,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct ProviderAssignment {
283 pub request_id: String,
285 pub assignments: Vec<CapabilityAssignment>,
287 pub unmatched: Vec<Capability>,
289 pub coverage_ratio: f64,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct CapabilityAssignment {
296 pub capability: Capability,
297 pub backend_name: String,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
302pub enum Jurisdiction {
303 #[default]
304 Unrestricted,
305 Trusted,
306 SameRegion,
307 SameCountry,
308}
309
310impl Jurisdiction {
311 #[must_use]
312 pub fn satisfied_by(
313 self,
314 provider_country: &str,
315 provider_region: &str,
316 user_country: &str,
317 user_region: &str,
318 ) -> bool {
319 match self {
320 Self::Unrestricted => true,
321 Self::Trusted => is_trusted_jurisdiction(provider_region),
322 Self::SameRegion => provider_region == user_region,
323 Self::SameCountry => provider_country == user_country,
324 }
325 }
326}
327
328fn is_trusted_jurisdiction(region: &str) -> bool {
329 matches!(
330 region.to_uppercase().as_str(),
331 "EU" | "EEA" | "CH" | "UK" | "JP" | "CA" | "NZ" | "IL" | "KR" | "AR" | "UY"
332 )
333}
334
335#[derive(
337 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
338)]
339pub enum LatencyClass {
340 Realtime,
341 #[default]
342 Interactive,
343 Background,
344 Batch,
345}
346
347impl LatencyClass {
348 #[must_use]
349 pub fn max_latency_ms(self) -> u32 {
350 match self {
351 Self::Realtime => 100,
352 Self::Interactive => 2_000,
353 Self::Background => 30_000,
354 Self::Batch => 300_000,
355 }
356 }
357
358 #[must_use]
359 pub fn satisfied_by(self, provider_latency_ms: u32) -> bool {
360 provider_latency_ms <= self.max_latency_ms()
361 }
362}
363
364#[derive(
366 Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
367)]
368pub enum CostTier {
369 Minimal,
370 #[default]
371 Standard,
372 Premium,
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
377pub enum TaskComplexity {
378 Extraction,
379 #[default]
380 Classification,
381 Reasoning,
382 Generation,
383}
384
385impl TaskComplexity {
386 #[must_use]
387 pub fn min_quality_hint(self) -> f64 {
388 match self {
389 Self::Extraction => 0.5,
390 Self::Classification => 0.6,
391 Self::Reasoning => 0.8,
392 Self::Generation => 0.7,
393 }
394 }
395
396 #[must_use]
397 pub fn requires_reasoning(self) -> bool {
398 matches!(self, Self::Reasoning)
399 }
400}
401
402#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
404#[allow(clippy::struct_excessive_bools)]
405pub struct RequiredCapabilities {
406 pub tool_use: bool,
407 pub vision: bool,
408 pub min_context_tokens: Option<usize>,
409 pub structured_output: bool,
410 pub code: bool,
411 pub multilingual: bool,
412 pub web_search: bool,
413 pub content_generation: bool,
414 pub business_acumen: bool,
415}
416
417impl RequiredCapabilities {
418 #[must_use]
419 pub fn none() -> Self {
420 Self::default()
421 }
422
423 #[must_use]
424 pub fn with_tool_use(mut self) -> Self {
425 self.tool_use = true;
426 self
427 }
428
429 #[must_use]
430 pub fn with_vision(mut self) -> Self {
431 self.vision = true;
432 self
433 }
434
435 #[must_use]
436 pub fn with_min_context(mut self, tokens: usize) -> Self {
437 self.min_context_tokens = Some(tokens);
438 self
439 }
440
441 #[must_use]
442 pub fn with_structured_output(mut self) -> Self {
443 self.structured_output = true;
444 self
445 }
446
447 #[must_use]
448 pub fn with_code(mut self) -> Self {
449 self.code = true;
450 self
451 }
452
453 #[must_use]
454 pub fn with_multilingual(mut self) -> Self {
455 self.multilingual = true;
456 self
457 }
458
459 #[must_use]
460 pub fn with_web_search(mut self) -> Self {
461 self.web_search = true;
462 self
463 }
464
465 #[must_use]
466 pub fn with_content_generation(mut self) -> Self {
467 self.content_generation = true;
468 self
469 }
470
471 #[must_use]
472 pub fn with_business_acumen(mut self) -> Self {
473 self.business_acumen = true;
474 self
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
480pub enum CostClass {
481 Free,
482 VeryLow,
483 Low,
484 Medium,
485 High,
486 VeryHigh,
487}
488
489impl CostClass {
490 #[must_use]
491 pub fn allowed_classes(self) -> Vec<CostClass> {
492 let all = [
493 CostClass::Free,
494 CostClass::VeryLow,
495 CostClass::Low,
496 CostClass::Medium,
497 CostClass::High,
498 CostClass::VeryHigh,
499 ];
500 all.iter().copied().filter(|&c| c <= self).collect()
501 }
502
503 #[must_use]
504 pub fn from_tier(tier: CostTier) -> Self {
505 match tier {
506 CostTier::Minimal => Self::Low,
507 CostTier::Standard => Self::Medium,
508 CostTier::Premium => Self::VeryHigh,
509 }
510 }
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
515pub enum DataSovereignty {
516 Any,
517 EU,
518 US,
519 Switzerland,
520 China,
521 OnPremises,
522}
523
524impl DataSovereignty {
525 #[must_use]
526 pub fn from_jurisdiction(jurisdiction: Jurisdiction, user_region: &str) -> Self {
527 match jurisdiction {
528 Jurisdiction::Unrestricted | Jurisdiction::Trusted => Self::Any,
529 Jurisdiction::SameRegion => match user_region.to_uppercase().as_str() {
530 "EU" | "EEA" => Self::EU,
531 "CH" => Self::Switzerland,
532 "CN" => Self::China,
533 "US" => Self::US,
534 _ => Self::Any,
535 },
536 Jurisdiction::SameCountry => Self::OnPremises,
537 }
538 }
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
543pub enum ComplianceLevel {
544 None,
545 GDPR,
546 HIPAA,
547 SOC2,
548 HighExplainability,
549}
550
551#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
553pub struct SelectionCriteria {
554 pub jurisdiction: Jurisdiction,
555 pub latency: LatencyClass,
556 pub cost: CostTier,
557 pub complexity: TaskComplexity,
558 pub capabilities: RequiredCapabilities,
559 pub compliance: Option<ComplianceLevel>,
560 pub user_country: Option<String>,
561 pub user_region: Option<String>,
562}
563
564impl SelectionCriteria {
565 #[must_use]
566 pub fn high_volume() -> Self {
567 Self {
568 latency: LatencyClass::Interactive,
569 cost: CostTier::Minimal,
570 complexity: TaskComplexity::Extraction,
571 ..Default::default()
572 }
573 }
574
575 #[must_use]
576 pub fn interactive() -> Self {
577 Self {
578 latency: LatencyClass::Interactive,
579 cost: CostTier::Minimal,
580 complexity: TaskComplexity::Classification,
581 ..Default::default()
582 }
583 }
584
585 #[must_use]
586 pub fn analysis() -> Self {
587 Self {
588 latency: LatencyClass::Background,
589 cost: CostTier::Premium,
590 complexity: TaskComplexity::Reasoning,
591 ..Default::default()
592 }
593 }
594
595 #[must_use]
596 pub fn batch() -> Self {
597 Self {
598 latency: LatencyClass::Batch,
599 cost: CostTier::Minimal,
600 complexity: TaskComplexity::Extraction,
601 ..Default::default()
602 }
603 }
604
605 #[must_use]
606 pub fn with_jurisdiction(mut self, jurisdiction: Jurisdiction) -> Self {
607 self.jurisdiction = jurisdiction;
608 self
609 }
610
611 #[must_use]
612 pub fn with_latency(mut self, latency: LatencyClass) -> Self {
613 self.latency = latency;
614 self
615 }
616
617 #[must_use]
618 pub fn with_cost(mut self, cost: CostTier) -> Self {
619 self.cost = cost;
620 self
621 }
622
623 #[must_use]
624 pub fn with_complexity(mut self, complexity: TaskComplexity) -> Self {
625 self.complexity = complexity;
626 self
627 }
628
629 #[must_use]
630 pub fn with_capabilities(mut self, capabilities: RequiredCapabilities) -> Self {
631 self.capabilities = capabilities;
632 self
633 }
634
635 #[must_use]
636 pub fn with_compliance(mut self, compliance: ComplianceLevel) -> Self {
637 self.compliance = Some(compliance);
638 self
639 }
640
641 #[must_use]
642 pub fn with_user_location(
643 mut self,
644 country: impl Into<String>,
645 region: impl Into<String>,
646 ) -> Self {
647 self.user_country = Some(country.into());
648 self.user_region = Some(region.into());
649 self
650 }
651
652 #[must_use]
653 pub fn to_agent_requirements(&self) -> AgentRequirements {
654 let user_region = self.user_region.as_deref().unwrap_or("US");
655 AgentRequirements {
656 max_cost_class: CostClass::from_tier(self.cost),
657 max_latency_ms: self.latency.max_latency_ms(),
658 requires_reasoning: self.complexity.requires_reasoning(),
659 requires_web_search: self.capabilities.web_search,
660 requires_tool_use: self.capabilities.tool_use,
661 requires_vision: self.capabilities.vision,
662 min_context_tokens: self.capabilities.min_context_tokens,
663 requires_structured_output: self.capabilities.structured_output,
664 requires_code: self.capabilities.code,
665 min_quality: self.complexity.min_quality_hint(),
666 data_sovereignty: DataSovereignty::from_jurisdiction(self.jurisdiction, user_region),
667 compliance: self.compliance.unwrap_or(ComplianceLevel::None),
668 requires_multilingual: self.capabilities.multilingual,
669 requires_content_generation: self.capabilities.content_generation,
670 requires_business_acumen: self.capabilities.business_acumen,
671 }
672 }
673}
674
675#[derive(Debug, Clone, PartialEq)]
677pub struct AgentRequirements {
678 pub max_cost_class: CostClass,
679 pub max_latency_ms: u32,
680 pub requires_reasoning: bool,
681 pub requires_web_search: bool,
682 pub requires_tool_use: bool,
683 pub requires_vision: bool,
684 pub min_context_tokens: Option<usize>,
685 pub requires_structured_output: bool,
686 pub requires_code: bool,
687 pub min_quality: f64,
688 pub data_sovereignty: DataSovereignty,
689 pub compliance: ComplianceLevel,
690 pub requires_multilingual: bool,
691 pub requires_content_generation: bool,
692 pub requires_business_acumen: bool,
693}
694
695impl AgentRequirements {
696 #[must_use]
697 pub fn fast_cheap() -> Self {
698 Self {
699 max_cost_class: CostClass::VeryLow,
700 max_latency_ms: 2_000,
701 requires_reasoning: false,
702 requires_web_search: false,
703 requires_tool_use: false,
704 requires_vision: false,
705 min_context_tokens: None,
706 requires_structured_output: false,
707 requires_code: false,
708 min_quality: 0.6,
709 data_sovereignty: DataSovereignty::Any,
710 compliance: ComplianceLevel::None,
711 requires_multilingual: false,
712 requires_content_generation: false,
713 requires_business_acumen: false,
714 }
715 }
716
717 #[must_use]
718 pub fn deep_research() -> Self {
719 Self {
720 max_cost_class: CostClass::High,
721 max_latency_ms: 30_000,
722 requires_reasoning: true,
723 requires_web_search: true,
724 requires_tool_use: false,
725 requires_vision: false,
726 min_context_tokens: None,
727 requires_structured_output: false,
728 requires_code: false,
729 min_quality: 0.9,
730 data_sovereignty: DataSovereignty::Any,
731 compliance: ComplianceLevel::None,
732 requires_multilingual: false,
733 requires_content_generation: false,
734 requires_business_acumen: false,
735 }
736 }
737
738 #[must_use]
739 pub fn balanced() -> Self {
740 Self {
741 max_cost_class: CostClass::Medium,
742 max_latency_ms: 5_000,
743 requires_reasoning: false,
744 requires_web_search: false,
745 requires_tool_use: false,
746 requires_vision: false,
747 min_context_tokens: None,
748 requires_structured_output: false,
749 requires_code: false,
750 min_quality: 0.7,
751 data_sovereignty: DataSovereignty::Any,
752 compliance: ComplianceLevel::None,
753 requires_multilingual: false,
754 requires_content_generation: false,
755 requires_business_acumen: false,
756 }
757 }
758
759 #[must_use]
760 pub fn new(max_cost_class: CostClass, max_latency_ms: u32, requires_reasoning: bool) -> Self {
761 Self {
762 max_cost_class,
763 max_latency_ms,
764 requires_reasoning,
765 requires_web_search: false,
766 requires_tool_use: false,
767 requires_vision: false,
768 min_context_tokens: None,
769 requires_structured_output: false,
770 requires_code: false,
771 min_quality: 0.7,
772 data_sovereignty: DataSovereignty::Any,
773 compliance: ComplianceLevel::None,
774 requires_multilingual: false,
775 requires_content_generation: false,
776 requires_business_acumen: false,
777 }
778 }
779
780 #[must_use]
781 pub fn powerful() -> Self {
782 Self {
783 max_cost_class: CostClass::High,
784 max_latency_ms: 10_000,
785 requires_reasoning: true,
786 requires_web_search: false,
787 requires_tool_use: false,
788 requires_vision: false,
789 min_context_tokens: None,
790 requires_structured_output: false,
791 requires_code: false,
792 min_quality: 0.9,
793 data_sovereignty: DataSovereignty::Any,
794 compliance: ComplianceLevel::None,
795 requires_multilingual: false,
796 requires_content_generation: false,
797 requires_business_acumen: false,
798 }
799 }
800
801 #[must_use]
802 pub fn with_quality(self, quality: f64) -> Self {
803 self.with_min_quality(quality)
804 }
805
806 #[must_use]
807 pub fn with_web_search(mut self, requires: bool) -> Self {
808 self.requires_web_search = requires;
809 self
810 }
811
812 #[must_use]
813 pub fn with_tool_use(mut self, requires: bool) -> Self {
814 self.requires_tool_use = requires;
815 self
816 }
817
818 #[must_use]
819 pub fn with_vision(mut self, requires: bool) -> Self {
820 self.requires_vision = requires;
821 self
822 }
823
824 #[must_use]
825 pub fn with_min_context(mut self, tokens: usize) -> Self {
826 self.min_context_tokens = Some(tokens);
827 self
828 }
829
830 #[must_use]
831 pub fn with_structured_output(mut self, requires: bool) -> Self {
832 self.requires_structured_output = requires;
833 self
834 }
835
836 #[must_use]
837 pub fn with_code(mut self, requires: bool) -> Self {
838 self.requires_code = requires;
839 self
840 }
841
842 #[must_use]
843 pub fn with_min_quality(mut self, quality: f64) -> Self {
844 self.min_quality = quality.clamp(0.0, 1.0);
845 self
846 }
847
848 #[must_use]
849 pub fn with_data_sovereignty(mut self, sovereignty: DataSovereignty) -> Self {
850 self.data_sovereignty = sovereignty;
851 self
852 }
853
854 #[must_use]
855 pub fn with_compliance(mut self, compliance: ComplianceLevel) -> Self {
856 self.compliance = compliance;
857 self
858 }
859
860 #[must_use]
861 pub fn with_multilingual(mut self, requires: bool) -> Self {
862 self.requires_multilingual = requires;
863 self
864 }
865
866 #[must_use]
867 pub fn with_content_generation(mut self, requires: bool) -> Self {
868 self.requires_content_generation = requires;
869 self
870 }
871
872 #[must_use]
873 pub fn with_business_acumen(mut self, requires: bool) -> Self {
874 self.requires_business_acumen = requires;
875 self
876 }
877
878 #[must_use]
879 pub fn from_criteria(criteria: &SelectionCriteria) -> Self {
880 criteria.to_agent_requirements()
881 }
882}
883
884pub trait ModelSelectorTrait: Send + Sync {
886 fn select(&self, requirements: &AgentRequirements) -> Result<(String, String), LlmError>;
887}
888
889fn parse_profile(value: &str) -> Result<SelectionCriteria, ChatBackendSelectionConfigError> {
890 match value.trim().to_ascii_lowercase().as_str() {
891 "high_volume" | "high-volume" => Ok(SelectionCriteria::high_volume()),
892 "interactive" => Ok(SelectionCriteria::interactive()),
893 "analysis" | "research" => Ok(SelectionCriteria::analysis()),
894 "batch" => Ok(SelectionCriteria::batch()),
895 _ => Err(ChatBackendSelectionConfigError::invalid(
896 "CONVERGE_LLM_PROFILE",
897 value,
898 "high_volume, interactive, analysis, or batch",
899 )),
900 }
901}
902
903fn parse_jurisdiction(value: &str) -> Result<Jurisdiction, ChatBackendSelectionConfigError> {
904 match value.trim().to_ascii_lowercase().as_str() {
905 "unrestricted" => Ok(Jurisdiction::Unrestricted),
906 "trusted" => Ok(Jurisdiction::Trusted),
907 "same_region" | "same-region" => Ok(Jurisdiction::SameRegion),
908 "same_country" | "same-country" => Ok(Jurisdiction::SameCountry),
909 _ => Err(ChatBackendSelectionConfigError::invalid(
910 "CONVERGE_LLM_JURISDICTION",
911 value,
912 "unrestricted, trusted, same_region, or same_country",
913 )),
914 }
915}
916
917fn parse_latency(value: &str) -> Result<LatencyClass, ChatBackendSelectionConfigError> {
918 match value.trim().to_ascii_lowercase().as_str() {
919 "realtime" => Ok(LatencyClass::Realtime),
920 "interactive" => Ok(LatencyClass::Interactive),
921 "background" => Ok(LatencyClass::Background),
922 "batch" => Ok(LatencyClass::Batch),
923 _ => Err(ChatBackendSelectionConfigError::invalid(
924 "CONVERGE_LLM_LATENCY",
925 value,
926 "realtime, interactive, background, or batch",
927 )),
928 }
929}
930
931fn parse_cost(value: &str) -> Result<CostTier, ChatBackendSelectionConfigError> {
932 match value.trim().to_ascii_lowercase().as_str() {
933 "minimal" | "cheap" => Ok(CostTier::Minimal),
934 "standard" | "balanced" => Ok(CostTier::Standard),
935 "premium" => Ok(CostTier::Premium),
936 _ => Err(ChatBackendSelectionConfigError::invalid(
937 "CONVERGE_LLM_COST",
938 value,
939 "minimal, standard, or premium",
940 )),
941 }
942}
943
944fn parse_complexity(value: &str) -> Result<TaskComplexity, ChatBackendSelectionConfigError> {
945 match value.trim().to_ascii_lowercase().as_str() {
946 "extraction" => Ok(TaskComplexity::Extraction),
947 "classification" => Ok(TaskComplexity::Classification),
948 "reasoning" | "research" => Ok(TaskComplexity::Reasoning),
949 "generation" => Ok(TaskComplexity::Generation),
950 _ => Err(ChatBackendSelectionConfigError::invalid(
951 "CONVERGE_LLM_COMPLEXITY",
952 value,
953 "extraction, classification, reasoning, or generation",
954 )),
955 }
956}
957
958fn parse_compliance(value: &str) -> Result<ComplianceLevel, ChatBackendSelectionConfigError> {
959 match value.trim().to_ascii_lowercase().as_str() {
960 "none" => Ok(ComplianceLevel::None),
961 "gdpr" => Ok(ComplianceLevel::GDPR),
962 "soc2" => Ok(ComplianceLevel::SOC2),
963 "hipaa" => Ok(ComplianceLevel::HIPAA),
964 "high_explainability" | "high-explainability" => Ok(ComplianceLevel::HighExplainability),
965 _ => Err(ChatBackendSelectionConfigError::invalid(
966 "CONVERGE_LLM_COMPLIANCE",
967 value,
968 "none, gdpr, soc2, hipaa, or high_explainability",
969 )),
970 }
971}
972
973fn env_flag(key: &'static str) -> Result<bool, ChatBackendSelectionConfigError> {
974 match std::env::var(key) {
975 Ok(value) => parse_bool(key, &value),
976 Err(_) => Ok(false),
977 }
978}
979
980fn parse_bool(key: &'static str, value: &str) -> Result<bool, ChatBackendSelectionConfigError> {
981 match value.trim().to_ascii_lowercase().as_str() {
982 "1" | "true" | "yes" | "on" => Ok(true),
983 "0" | "false" | "no" | "off" => Ok(false),
984 _ => Err(ChatBackendSelectionConfigError::invalid(
985 key,
986 value,
987 "boolean (true/false/1/0/yes/no/on/off)",
988 )),
989 }
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995
996 #[test]
997 fn cost_class_ordering() {
998 assert!(CostClass::Free < CostClass::VeryLow);
999 assert!(CostClass::VeryLow < CostClass::Low);
1000 assert!(CostClass::Low < CostClass::Medium);
1001 assert!(CostClass::Medium < CostClass::High);
1002 assert!(CostClass::High < CostClass::VeryHigh);
1003 }
1004
1005 #[test]
1006 fn requirements_builder() {
1007 let reqs = BackendRequirements::new(BackendKind::Llm)
1008 .with_capability(Capability::TextGeneration)
1009 .with_capability(Capability::Reasoning)
1010 .with_max_cost(CostClass::Medium)
1011 .with_max_latency_ms(5_000);
1012
1013 assert_eq!(reqs.kind, BackendKind::Llm);
1014 assert_eq!(reqs.required_capabilities.len(), 2);
1015 assert_eq!(reqs.max_cost_class, CostClass::Medium);
1016 assert_eq!(reqs.max_latency_ms, 5_000);
1017 }
1018
1019 #[test]
1020 fn selection_criteria_presets() {
1021 let high_vol = SelectionCriteria::high_volume();
1022 assert_eq!(high_vol.cost, CostTier::Minimal);
1023 assert_eq!(high_vol.complexity, TaskComplexity::Extraction);
1024
1025 let analysis = SelectionCriteria::analysis();
1026 assert_eq!(analysis.cost, CostTier::Premium);
1027 assert_eq!(analysis.complexity, TaskComplexity::Reasoning);
1028 }
1029
1030 #[test]
1031 fn selection_criteria_to_agent_requirements() {
1032 let criteria = SelectionCriteria::default()
1033 .with_latency(LatencyClass::Background)
1034 .with_cost(CostTier::Premium)
1035 .with_complexity(TaskComplexity::Reasoning)
1036 .with_capabilities(
1037 RequiredCapabilities::none()
1038 .with_tool_use()
1039 .with_vision()
1040 .with_min_context(128_000)
1041 .with_structured_output()
1042 .with_code(),
1043 );
1044 let requirements = criteria.to_agent_requirements();
1045 assert_eq!(requirements.max_latency_ms, 30_000);
1046 assert!(requirements.requires_reasoning);
1047 assert!(requirements.min_quality >= 0.8);
1048 assert!(requirements.requires_tool_use);
1049 assert!(requirements.requires_vision);
1050 assert_eq!(requirements.min_context_tokens, Some(128_000));
1051 assert!(requirements.requires_structured_output);
1052 assert!(requirements.requires_code);
1053 }
1054
1055 #[test]
1056 fn chat_backend_selection_config_preserves_criteria_and_override() {
1057 let config = ChatBackendSelectionConfig::default()
1058 .with_criteria(SelectionCriteria::analysis())
1059 .with_provider_override("gemini");
1060
1061 assert_eq!(config.criteria, SelectionCriteria::analysis());
1062 assert_eq!(config.provider_override.as_deref(), Some("gemini"));
1063 }
1064
1065 #[test]
1066 fn parse_profile_valid_values() {
1067 assert!(parse_profile("interactive").is_ok());
1068 assert!(parse_profile("high_volume").is_ok());
1069 assert!(parse_profile("high-volume").is_ok());
1070 assert!(parse_profile("analysis").is_ok());
1071 assert!(parse_profile("research").is_ok());
1072 assert!(parse_profile("batch").is_ok());
1073 assert!(parse_profile("INTERACTIVE").is_ok());
1074 }
1075
1076 #[test]
1077 fn parse_profile_invalid_value() {
1078 let err = parse_profile("turbo").unwrap_err();
1079 assert!(err.to_string().contains("turbo"));
1080 assert!(err.to_string().contains("CONVERGE_LLM_PROFILE"));
1081 }
1082
1083 #[test]
1084 fn parse_jurisdiction_valid() {
1085 assert!(parse_jurisdiction("unrestricted").is_ok());
1086 assert!(parse_jurisdiction("trusted").is_ok());
1087 assert!(parse_jurisdiction("same_region").is_ok());
1088 assert!(parse_jurisdiction("same-region").is_ok());
1089 assert!(parse_jurisdiction("same_country").is_ok());
1090 assert!(parse_jurisdiction("same-country").is_ok());
1091 }
1092
1093 #[test]
1094 fn parse_jurisdiction_invalid() {
1095 assert!(parse_jurisdiction("local").is_err());
1096 }
1097
1098 #[test]
1099 fn parse_latency_valid() {
1100 assert!(parse_latency("realtime").is_ok());
1101 assert!(parse_latency("interactive").is_ok());
1102 assert!(parse_latency("background").is_ok());
1103 assert!(parse_latency("batch").is_ok());
1104 }
1105
1106 #[test]
1107 fn parse_cost_valid() {
1108 assert!(parse_cost("minimal").is_ok());
1109 assert!(parse_cost("cheap").is_ok());
1110 assert!(parse_cost("standard").is_ok());
1111 assert!(parse_cost("balanced").is_ok());
1112 assert!(parse_cost("premium").is_ok());
1113 }
1114
1115 #[test]
1116 fn parse_cost_invalid() {
1117 assert!(parse_cost("free").is_err());
1118 }
1119
1120 #[test]
1121 fn parse_complexity_valid() {
1122 assert!(parse_complexity("extraction").is_ok());
1123 assert!(parse_complexity("classification").is_ok());
1124 assert!(parse_complexity("reasoning").is_ok());
1125 assert!(parse_complexity("research").is_ok());
1126 assert!(parse_complexity("generation").is_ok());
1127 }
1128
1129 #[test]
1130 fn parse_compliance_valid() {
1131 assert!(parse_compliance("none").is_ok());
1132 assert!(parse_compliance("gdpr").is_ok());
1133 assert!(parse_compliance("soc2").is_ok());
1134 assert!(parse_compliance("hipaa").is_ok());
1135 assert!(parse_compliance("high_explainability").is_ok());
1136 assert!(parse_compliance("high-explainability").is_ok());
1137 }
1138
1139 #[test]
1140 fn parse_bool_valid_values() {
1141 for value in &["1", "true", "yes", "on", "TRUE", "Yes", "ON"] {
1142 assert!(parse_bool("KEY", value).unwrap());
1143 }
1144 for value in &["0", "false", "no", "off", "FALSE", "No", "OFF"] {
1145 assert!(!parse_bool("KEY", value).unwrap());
1146 }
1147 }
1148
1149 #[test]
1150 fn parse_bool_invalid() {
1151 assert!(parse_bool("KEY", "maybe").is_err());
1152 assert!(parse_bool("KEY", "2").is_err());
1153 }
1154
1155 #[test]
1156 fn preset_constructors() {
1157 let fast = BackendRequirements::fast_llm();
1158 assert_eq!(fast.kind, BackendKind::Llm);
1159 assert_eq!(fast.max_cost_class, CostClass::Low);
1160
1161 let policy = BackendRequirements::access_policy();
1162 assert_eq!(policy.kind, BackendKind::Policy);
1163 assert!(
1164 policy
1165 .required_capabilities
1166 .contains(&Capability::AccessControl)
1167 );
1168
1169 let solver = BackendRequirements::constraint_solver();
1170 assert_eq!(solver.kind, BackendKind::Optimization);
1171 }
1172
1173 #[test]
1174 fn preset_reasoning_llm() {
1175 let r = BackendRequirements::reasoning_llm();
1176 assert_eq!(r.kind, BackendKind::Llm);
1177 assert_eq!(r.max_cost_class, CostClass::High);
1178 assert_eq!(r.max_latency_ms, 30_000);
1179 assert!(
1180 r.required_capabilities
1181 .contains(&Capability::TextGeneration)
1182 );
1183 assert!(r.required_capabilities.contains(&Capability::Reasoning));
1184 }
1185
1186 #[test]
1187 fn preset_embedding_pipeline() {
1188 let r = BackendRequirements::embedding_pipeline();
1189 assert_eq!(r.kind, BackendKind::Analytics);
1190 assert!(r.required_capabilities.contains(&Capability::Embedding));
1191 }
1192
1193 #[test]
1194 fn preset_vector_search() {
1195 let r = BackendRequirements::vector_search();
1196 assert_eq!(r.kind, BackendKind::Search);
1197 assert!(r.required_capabilities.contains(&Capability::VectorSearch));
1198 }
1199
1200 #[test]
1201 fn jurisdiction_unrestricted_always_satisfied() {
1202 assert!(Jurisdiction::Unrestricted.satisfied_by("US", "US", "SE", "EU"));
1203 }
1204
1205 #[test]
1206 fn jurisdiction_trusted_eu() {
1207 assert!(Jurisdiction::Trusted.satisfied_by("SE", "EU", "SE", "EU"));
1208 }
1209
1210 #[test]
1211 fn jurisdiction_trusted_us_not_trusted() {
1212 assert!(!Jurisdiction::Trusted.satisfied_by("US", "US", "SE", "EU"));
1213 }
1214
1215 #[test]
1216 fn jurisdiction_trusted_various() {
1217 for region in &["CH", "UK", "JP", "CA", "NZ", "IL", "KR", "AR", "UY", "EEA"] {
1218 assert!(
1219 Jurisdiction::Trusted.satisfied_by("X", region, "Y", "Z"),
1220 "expected {region} to be trusted"
1221 );
1222 }
1223 }
1224
1225 #[test]
1226 fn jurisdiction_same_region() {
1227 assert!(Jurisdiction::SameRegion.satisfied_by("SE", "EU", "DE", "EU"));
1228 assert!(!Jurisdiction::SameRegion.satisfied_by("SE", "EU", "US", "US"));
1229 }
1230
1231 #[test]
1232 fn jurisdiction_same_country() {
1233 assert!(Jurisdiction::SameCountry.satisfied_by("SE", "EU", "SE", "EU"));
1234 assert!(!Jurisdiction::SameCountry.satisfied_by("SE", "EU", "DE", "EU"));
1235 }
1236
1237 #[test]
1238 fn latency_class_max_values() {
1239 assert_eq!(LatencyClass::Realtime.max_latency_ms(), 100);
1240 assert_eq!(LatencyClass::Interactive.max_latency_ms(), 2_000);
1241 assert_eq!(LatencyClass::Background.max_latency_ms(), 30_000);
1242 assert_eq!(LatencyClass::Batch.max_latency_ms(), 300_000);
1243 }
1244
1245 #[test]
1246 fn latency_class_satisfied_by() {
1247 assert!(LatencyClass::Realtime.satisfied_by(50));
1248 assert!(LatencyClass::Realtime.satisfied_by(100));
1249 assert!(!LatencyClass::Realtime.satisfied_by(101));
1250
1251 assert!(LatencyClass::Interactive.satisfied_by(2_000));
1252 assert!(!LatencyClass::Interactive.satisfied_by(2_001));
1253 }
1254
1255 #[test]
1256 fn latency_class_ordering() {
1257 assert!(LatencyClass::Realtime < LatencyClass::Interactive);
1258 assert!(LatencyClass::Interactive < LatencyClass::Background);
1259 assert!(LatencyClass::Background < LatencyClass::Batch);
1260 }
1261
1262 #[test]
1263 fn data_sovereignty_from_jurisdiction_unrestricted() {
1264 assert_eq!(
1265 DataSovereignty::from_jurisdiction(Jurisdiction::Unrestricted, "EU"),
1266 DataSovereignty::Any
1267 );
1268 }
1269
1270 #[test]
1271 fn data_sovereignty_from_jurisdiction_trusted() {
1272 assert_eq!(
1273 DataSovereignty::from_jurisdiction(Jurisdiction::Trusted, "EU"),
1274 DataSovereignty::Any
1275 );
1276 }
1277
1278 #[test]
1279 fn data_sovereignty_from_jurisdiction_same_region_eu() {
1280 assert_eq!(
1281 DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "EU"),
1282 DataSovereignty::EU
1283 );
1284 assert_eq!(
1285 DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "eea"),
1286 DataSovereignty::EU
1287 );
1288 }
1289
1290 #[test]
1291 fn data_sovereignty_from_jurisdiction_same_region_other() {
1292 assert_eq!(
1293 DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "CH"),
1294 DataSovereignty::Switzerland
1295 );
1296 assert_eq!(
1297 DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "CN"),
1298 DataSovereignty::China
1299 );
1300 assert_eq!(
1301 DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "US"),
1302 DataSovereignty::US
1303 );
1304 assert_eq!(
1305 DataSovereignty::from_jurisdiction(Jurisdiction::SameRegion, "XX"),
1306 DataSovereignty::Any
1307 );
1308 }
1309
1310 #[test]
1311 fn data_sovereignty_from_jurisdiction_same_country() {
1312 assert_eq!(
1313 DataSovereignty::from_jurisdiction(Jurisdiction::SameCountry, "SE"),
1314 DataSovereignty::OnPremises
1315 );
1316 }
1317
1318 #[test]
1319 fn cost_class_allowed_contains_self() {
1320 for class in [
1321 CostClass::Free,
1322 CostClass::VeryLow,
1323 CostClass::Low,
1324 CostClass::Medium,
1325 CostClass::High,
1326 CostClass::VeryHigh,
1327 ] {
1328 let allowed = class.allowed_classes();
1329 assert!(
1330 allowed.contains(&class),
1331 "{class:?} should contain itself in allowed_classes"
1332 );
1333 }
1334 }
1335
1336 #[test]
1337 fn cost_class_allowed_counts() {
1338 assert_eq!(CostClass::Free.allowed_classes().len(), 1);
1339 assert_eq!(CostClass::VeryLow.allowed_classes().len(), 2);
1340 assert_eq!(CostClass::Low.allowed_classes().len(), 3);
1341 assert_eq!(CostClass::Medium.allowed_classes().len(), 4);
1342 assert_eq!(CostClass::High.allowed_classes().len(), 5);
1343 assert_eq!(CostClass::VeryHigh.allowed_classes().len(), 6);
1344 }
1345
1346 #[test]
1347 fn cost_class_from_tier() {
1348 assert_eq!(CostClass::from_tier(CostTier::Minimal), CostClass::Low);
1349 assert_eq!(CostClass::from_tier(CostTier::Standard), CostClass::Medium);
1350 assert_eq!(CostClass::from_tier(CostTier::Premium), CostClass::VeryHigh);
1351 }
1352
1353 #[test]
1354 fn task_complexity_min_quality() {
1355 assert!((TaskComplexity::Extraction.min_quality_hint() - 0.5).abs() < f64::EPSILON);
1356 assert!((TaskComplexity::Classification.min_quality_hint() - 0.6).abs() < f64::EPSILON);
1357 assert!((TaskComplexity::Reasoning.min_quality_hint() - 0.8).abs() < f64::EPSILON);
1358 assert!((TaskComplexity::Generation.min_quality_hint() - 0.7).abs() < f64::EPSILON);
1359 }
1360
1361 #[test]
1362 fn task_complexity_requires_reasoning() {
1363 assert!(!TaskComplexity::Extraction.requires_reasoning());
1364 assert!(!TaskComplexity::Classification.requires_reasoning());
1365 assert!(TaskComplexity::Reasoning.requires_reasoning());
1366 assert!(!TaskComplexity::Generation.requires_reasoning());
1367 }
1368
1369 #[test]
1370 fn agent_requirements_fast_cheap() {
1371 let r = AgentRequirements::fast_cheap();
1372 assert_eq!(r.max_cost_class, CostClass::VeryLow);
1373 assert_eq!(r.max_latency_ms, 2_000);
1374 assert!(!r.requires_reasoning);
1375 }
1376
1377 #[test]
1378 fn agent_requirements_deep_research() {
1379 let r = AgentRequirements::deep_research();
1380 assert!(r.requires_reasoning);
1381 assert!(r.requires_web_search);
1382 assert!(r.min_quality >= 0.9);
1383 }
1384
1385 #[test]
1386 fn agent_requirements_with_min_quality_clamped() {
1387 let r = AgentRequirements::fast_cheap().with_min_quality(2.0);
1388 assert!((r.min_quality - 1.0).abs() < f64::EPSILON);
1389
1390 let r = AgentRequirements::fast_cheap().with_min_quality(-1.0);
1391 assert!(r.min_quality.abs() < f64::EPSILON);
1392 }
1393
1394 #[test]
1395 fn provider_request_and_assignment_roundtrip() {
1396 let request = ProviderRequest {
1397 id: "req-1".to_string(),
1398 required_capabilities: vec![Capability::Reasoning, Capability::Scheduling],
1399 backend_requirements: None,
1400 };
1401 let assignment = ProviderAssignment {
1402 request_id: request.id.clone(),
1403 assignments: vec![CapabilityAssignment {
1404 capability: Capability::Reasoning,
1405 backend_name: "solver-a".to_string(),
1406 }],
1407 unmatched: vec![Capability::Scheduling],
1408 coverage_ratio: 0.5,
1409 };
1410
1411 let request_back: ProviderRequest =
1412 serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
1413 let assignment_back: ProviderAssignment =
1414 serde_json::from_str(&serde_json::to_string(&assignment).unwrap()).unwrap();
1415
1416 assert!(request_back.backend_requirements.is_none());
1417 assert_eq!(
1418 request_back.required_capabilities,
1419 request.required_capabilities
1420 );
1421 assert_eq!(assignment_back.assignments, assignment.assignments);
1422 assert_eq!(assignment_back.unmatched, assignment.unmatched);
1423 }
1424
1425 #[test]
1426 fn provider_request_defaults_missing_backend_requirements() {
1427 let legacy_json = r#"{"id":"legacy","required_capabilities":["Reasoning"]}"#;
1428
1429 let request: ProviderRequest = serde_json::from_str(legacy_json).unwrap();
1430
1431 assert_eq!(request.id, "legacy");
1432 assert_eq!(request.required_capabilities, vec![Capability::Reasoning]);
1433 assert!(request.backend_requirements.is_none());
1434 }
1435}