Skip to main content

converge_provider/
selection.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Canonical backend selection vocabulary for provider consumers and adapters.
5
6use 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/// Requirements for generic backend selection.
15#[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
124/// Trait for selecting a backend that satisfies generic requirements.
125pub trait BackendSelector: Send + Sync {
126    fn select(&self, requirements: &BackendRequirements) -> Result<String, BackendError>;
127}
128
129/// Contract-level configuration for selecting a chat backend.
130///
131/// This type is pure selection input. It does not instantiate providers,
132/// inspect API keys, or import adapter crates. Runtimes and products may build
133/// it from config/env, then pass it to an adapter registry supplied by the host.
134#[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    /// Build selection config from the conventional Converge LLM environment.
163    ///
164    /// This only parses typed selection inputs. Provider availability and API
165    /// key inspection stay in adapter or product assembly.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error when an environment variable has an unsupported value.
170    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/// Structured request for provider selection inside a convergence loop.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ProviderRequest {
266    /// Stable request identifier used for idempotency.
267    pub id: String,
268    /// Capabilities that must each be covered by at least one backend.
269    /// Duplicates request multiple independent backends for the same capability.
270    pub required_capabilities: Vec<Capability>,
271    /// Rich backend requirements for a single role-scoped backend selection.
272    ///
273    /// When present, provider selection should choose one backend satisfying
274    /// the requirement envelope rather than only covering independent
275    /// capability slots.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub backend_requirements: Option<BackendRequirements>,
278}
279
280/// Structured result of provider selection.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct ProviderAssignment {
283    /// The request this assignment answers.
284    pub request_id: String,
285    /// Matched capability-to-backend assignments.
286    pub assignments: Vec<CapabilityAssignment>,
287    /// Capabilities that no registered backend could satisfy.
288    pub unmatched: Vec<Capability>,
289    /// `assignments.len() / required_capabilities.len()` — 1.0 is full coverage.
290    pub coverage_ratio: f64,
291}
292
293/// A single capability-to-backend assignment.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct CapabilityAssignment {
296    pub capability: Capability,
297    pub backend_name: String,
298}
299
300/// Data jurisdiction requirements.
301#[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/// Latency class requirements.
336#[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/// Cost tier preference.
365#[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/// Task complexity hint.
376#[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/// Required model capabilities.
403#[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/// Cost classification — how expensive is this backend to use?
479#[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/// Data sovereignty requirements — where can data legally reside?
514#[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/// Compliance level requirements.
542#[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/// Selection criteria using orthogonal dimensions.
552#[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/// Requirements for an agent's LLM usage.
676#[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
884/// Trait for model selection based on LLM requirements.
885pub 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}