1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::{ProviderCapabilities, SCHEMA_VERSION, TokenStatus, hash_json};
7
8pub const ROUTING_ALGORITHM_VERSION: &str = "proofborne.routing.deterministic.v1";
10pub const MODEL_CATALOG_VERSION: &str = "proofborne.model-catalog.v1";
12pub const ROUTING_PRICE_TOKEN_SCALE: u64 = 1_000_000;
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
17#[serde(rename_all = "snake_case")]
18pub enum RoutingCapability {
19 Streaming,
21 Tools,
23 StructuredOutput,
25 MultimodalInput,
27}
28
29impl RoutingCapability {
30 fn is_supported_by(self, capabilities: &ProviderCapabilities) -> bool {
31 match self {
32 Self::Streaming => capabilities.streaming,
33 Self::Tools => capabilities.tools,
34 Self::StructuredOutput => capabilities.structured_output,
35 Self::MultimodalInput => capabilities.multimodal_input,
36 }
37 }
38}
39
40#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum RoutingContextSource {
44 Provider,
46 Profile,
48 RuntimeFallback,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct ModelPricing {
56 pub input_microusd_per_million_tokens: u64,
58 pub output_microusd_per_million_tokens: u64,
60}
61
62impl ModelPricing {
63 pub fn estimate(
65 &self,
66 max_input_tokens: u64,
67 reserved_output_tokens: u64,
68 ) -> Result<u64, RoutingError> {
69 let input = estimate_component(max_input_tokens, self.input_microusd_per_million_tokens)?;
70 let output = estimate_component(
71 reserved_output_tokens,
72 self.output_microusd_per_million_tokens,
73 )?;
74 input.checked_add(output).ok_or(RoutingError::CostOverflow)
75 }
76}
77
78fn estimate_component(tokens: u64, rate: u64) -> Result<u64, RoutingError> {
79 let numerator = u128::from(tokens)
80 .checked_mul(u128::from(rate))
81 .ok_or(RoutingError::CostOverflow)?;
82 let scale = u128::from(ROUTING_PRICE_TOKEN_SCALE);
83 let estimate = numerator
84 .checked_add(scale.saturating_sub(1))
85 .ok_or(RoutingError::CostOverflow)?
86 / scale;
87 u64::try_from(estimate).map_err(|_| RoutingError::CostOverflow)
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93pub struct ModelDescriptor {
94 pub id: String,
96 pub provider: String,
98 pub model: String,
100 pub capabilities: ProviderCapabilities,
102 pub context_tokens: u64,
104 pub context_source: RoutingContextSource,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub pricing: Option<ModelPricing>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub expected_latency_ms: Option<u64>,
112}
113
114impl ModelDescriptor {
115 #[allow(clippy::too_many_arguments)]
117 pub fn new(
118 provider: impl Into<String>,
119 model: impl Into<String>,
120 capabilities: ProviderCapabilities,
121 context_tokens: u64,
122 context_source: RoutingContextSource,
123 pricing: Option<ModelPricing>,
124 expected_latency_ms: Option<u64>,
125 ) -> Result<Self, RoutingError> {
126 let mut descriptor = Self {
127 id: String::new(),
128 provider: provider.into(),
129 model: model.into(),
130 capabilities,
131 context_tokens,
132 context_source,
133 pricing,
134 expected_latency_ms,
135 };
136 descriptor.validate_material()?;
137 descriptor.id = descriptor.material_digest()?;
138 Ok(descriptor)
139 }
140
141 pub fn material_digest(&self) -> Result<String, RoutingError> {
143 Ok(hash_json(&self.material_value()))
144 }
145
146 pub fn validate(&self) -> Result<(), RoutingError> {
148 self.validate_material()?;
149 if !valid_digest(&self.id) || self.id != self.material_digest()? {
150 return Err(RoutingError::DescriptorDigest(self.id.clone()));
151 }
152 Ok(())
153 }
154
155 fn material_value(&self) -> serde_json::Value {
156 serde_json::json!({
157 "provider": self.provider,
158 "model": self.model,
159 "capabilities": self.capabilities,
160 "contextTokens": self.context_tokens,
161 "contextSource": self.context_source,
162 "pricing": self.pricing,
163 "expectedLatencyMs": self.expected_latency_ms,
164 })
165 }
166
167 fn validate_material(&self) -> Result<(), RoutingError> {
168 if self.provider.trim().is_empty() || self.model.trim().is_empty() {
169 return Err(RoutingError::EmptyIdentity);
170 }
171 if self.context_tokens == 0 || self.expected_latency_ms == Some(0) {
172 return Err(RoutingError::InvalidDescriptor(self.model.clone()));
173 }
174 Ok(())
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181pub struct ModelCatalog {
182 pub schema_version: String,
184 pub catalog_version: String,
186 pub descriptors: Vec<ModelDescriptor>,
188}
189
190impl ModelCatalog {
191 pub fn new(mut descriptors: Vec<ModelDescriptor>) -> Result<Self, RoutingError> {
193 descriptors.sort_by(|left, right| left.id.cmp(&right.id));
194 let catalog = Self {
195 schema_version: SCHEMA_VERSION.to_owned(),
196 catalog_version: MODEL_CATALOG_VERSION.to_owned(),
197 descriptors,
198 };
199 catalog.validate()?;
200 Ok(catalog)
201 }
202
203 pub fn validate(&self) -> Result<(), RoutingError> {
205 if self.schema_version != SCHEMA_VERSION {
206 return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
207 }
208 if self.catalog_version != MODEL_CATALOG_VERSION {
209 return Err(RoutingError::UnsupportedCatalog(
210 self.catalog_version.clone(),
211 ));
212 }
213 let mut previous = None;
214 for descriptor in &self.descriptors {
215 descriptor.validate()?;
216 if previous.is_some_and(|id: &str| id >= descriptor.id.as_str()) {
217 return Err(RoutingError::CatalogOrder);
218 }
219 previous = Some(descriptor.id.as_str());
220 }
221 if self.descriptors.is_empty() {
222 return Err(RoutingError::EmptyCatalog);
223 }
224 Ok(())
225 }
226
227 pub fn digest(&self) -> Result<String, RoutingError> {
229 self.validate()?;
230 let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
231 Ok(hash_json(&value))
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237#[serde(rename_all = "camelCase", deny_unknown_fields)]
238pub struct RoutingCandidate {
239 pub id: String,
241 pub descriptor_id: String,
243 pub preference_rank: u32,
245 pub profile: String,
247 pub authority_hash: String,
249 pub credential_available: bool,
251 pub enabled: bool,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub token_status: Option<TokenStatus>,
256 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
258 pub scopes: BTreeSet<String>,
259}
260
261impl RoutingCandidate {
262 pub fn new(
264 descriptor_id: impl Into<String>,
265 preference_rank: u32,
266 profile: impl Into<String>,
267 authority_hash: impl Into<String>,
268 credential_available: bool,
269 enabled: bool,
270 ) -> Result<Self, RoutingError> {
271 Self::with_auth(
272 descriptor_id,
273 preference_rank,
274 profile,
275 authority_hash,
276 credential_available,
277 enabled,
278 None,
279 BTreeSet::new(),
280 )
281 }
282
283 pub fn with_auth(
285 descriptor_id: impl Into<String>,
286 preference_rank: u32,
287 profile: impl Into<String>,
288 authority_hash: impl Into<String>,
289 credential_available: bool,
290 enabled: bool,
291 token_status: Option<TokenStatus>,
292 scopes: BTreeSet<String>,
293 ) -> Result<Self, RoutingError> {
294 let mut candidate = Self {
295 id: String::new(),
296 descriptor_id: descriptor_id.into(),
297 preference_rank,
298 profile: profile.into(),
299 authority_hash: authority_hash.into(),
300 credential_available,
301 enabled,
302 token_status,
303 scopes,
304 };
305 candidate.validate_material()?;
306 candidate.id = candidate.material_digest()?;
307 Ok(candidate)
308 }
309
310 pub fn material_digest(&self) -> Result<String, RoutingError> {
312 Ok(hash_json(&self.material_value()))
313 }
314
315 pub fn validate(&self) -> Result<(), RoutingError> {
317 self.validate_material()?;
318 if !valid_digest(&self.id) || self.id != self.material_digest()? {
319 return Err(RoutingError::CandidateDigest(self.id.clone()));
320 }
321 Ok(())
322 }
323
324 fn material_value(&self) -> serde_json::Value {
325 let mut map = serde_json::Map::new();
326 map.insert(
327 "descriptorId".to_owned(),
328 serde_json::json!(self.descriptor_id),
329 );
330 map.insert(
331 "preferenceRank".to_owned(),
332 serde_json::json!(self.preference_rank),
333 );
334 map.insert("profile".to_owned(), serde_json::json!(self.profile));
335 map.insert(
336 "authorityHash".to_owned(),
337 serde_json::json!(self.authority_hash),
338 );
339 map.insert(
340 "credentialAvailable".to_owned(),
341 serde_json::json!(self.credential_available),
342 );
343 map.insert("enabled".to_owned(), serde_json::json!(self.enabled));
344 if let Some(status) = self.token_status {
347 map.insert("tokenStatus".to_owned(), serde_json::json!(status));
348 }
349 if !self.scopes.is_empty() {
350 map.insert("scopes".to_owned(), serde_json::json!(self.scopes));
351 }
352 serde_json::Value::Object(map)
353 }
354
355 fn validate_material(&self) -> Result<(), RoutingError> {
356 if self.profile.trim().is_empty()
357 || !valid_digest(&self.descriptor_id)
358 || !valid_digest(&self.authority_hash)
359 || self.scopes.iter().any(|scope| !valid_scope(scope))
360 {
361 return Err(RoutingError::InvalidCandidate(self.profile.clone()));
362 }
363 Ok(())
364 }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369#[serde(rename_all = "camelCase", deny_unknown_fields)]
370pub struct RoutingRequirements {
371 #[serde(default)]
373 pub required_capabilities: BTreeSet<RoutingCapability>,
374 pub min_context_tokens: u64,
376 pub max_input_tokens: u64,
378 pub reserved_output_tokens: u64,
380 #[serde(skip_serializing_if = "Option::is_none")]
382 pub max_estimated_cost_microusd: Option<u64>,
383 #[serde(skip_serializing_if = "Option::is_none")]
385 pub max_latency_ms: Option<u64>,
386 #[serde(default)]
388 pub require_known_pricing: bool,
389 #[serde(default)]
391 pub require_known_latency: bool,
392 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
394 pub required_scopes: BTreeSet<String>,
395}
396
397impl RoutingRequirements {
398 pub fn validate(&self) -> Result<(), RoutingError> {
400 if self.min_context_tokens == 0
401 || self.max_input_tokens == 0
402 || self.reserved_output_tokens == 0
403 || self.reserved_output_tokens == u64::MAX
404 || self.max_estimated_cost_microusd == Some(0)
405 || self.max_latency_ms == Some(0)
406 || self.required_scopes.iter().any(|scope| !valid_scope(scope))
407 {
408 return Err(RoutingError::InvalidRequirements);
409 }
410 Ok(())
411 }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
416#[serde(rename_all = "camelCase", deny_unknown_fields)]
417pub struct RoutingBindings {
418 pub contract_hash: String,
420 pub policy_hash: String,
422 #[serde(skip_serializing_if = "Option::is_none")]
424 pub tool_authority_hash: Option<String>,
425 pub catalog_hash: String,
427 pub run_controls_hash: String,
429 pub step: u64,
431 pub workspace_generation: u64,
433 pub state_binding: String,
435 pub side_effect_watermark: u64,
437 #[serde(skip_serializing_if = "Option::is_none")]
439 pub locked_candidate_id: Option<String>,
440}
441
442impl RoutingBindings {
443 fn validate(&self) -> Result<(), RoutingError> {
444 for digest in [
445 Some(self.contract_hash.as_str()),
446 Some(self.policy_hash.as_str()),
447 self.tool_authority_hash.as_deref(),
448 Some(self.catalog_hash.as_str()),
449 Some(self.run_controls_hash.as_str()),
450 Some(self.state_binding.as_str()),
451 self.locked_candidate_id.as_deref(),
452 ]
453 .into_iter()
454 .flatten()
455 {
456 if !valid_digest(digest) {
457 return Err(RoutingError::InvalidBinding(digest.to_owned()));
458 }
459 }
460 Ok(())
461 }
462}
463
464#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
466#[serde(rename_all = "snake_case")]
467pub enum RoutingExclusionReason {
468 Disabled,
470 CredentialUnavailable,
472 MissingCapability,
474 ContextWindowTooSmall,
476 PricingUnknown,
478 CostBudgetExceeded,
480 LatencyUnknown,
482 LatencyBudgetExceeded,
484 SessionRouteLocked,
486 FallbackBlockedAfterSideEffect,
488 TokenExpired,
490 TokenRevoked,
492 TokenNeedsRefresh,
494 ScopeInsufficient,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
500#[serde(rename_all = "camelCase", deny_unknown_fields)]
501pub struct RoutingSelection {
502 pub candidate: RoutingCandidate,
504 pub eligible: bool,
506 #[serde(default)]
508 pub reasons: BTreeSet<RoutingExclusionReason>,
509 #[serde(default)]
511 pub missing_capabilities: BTreeSet<RoutingCapability>,
512 #[serde(skip_serializing_if = "Option::is_none")]
514 pub estimated_max_cost_microusd: Option<u64>,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
519#[serde(rename_all = "camelCase", deny_unknown_fields)]
520pub struct RoutingPlan {
521 pub schema_version: String,
523 pub algorithm_version: String,
525 pub catalog: ModelCatalog,
527 pub requirements: RoutingRequirements,
529 pub bindings: RoutingBindings,
531 pub selections: Vec<RoutingSelection>,
533}
534
535impl RoutingPlan {
536 pub fn build(
538 catalog: ModelCatalog,
539 requirements: RoutingRequirements,
540 bindings: RoutingBindings,
541 mut candidates: Vec<RoutingCandidate>,
542 ) -> Result<Self, RoutingError> {
543 catalog.validate()?;
544 requirements.validate()?;
545 bindings.validate()?;
546 if catalog.digest()? != bindings.catalog_hash {
547 return Err(RoutingError::CatalogBinding);
548 }
549 candidates.sort_by(|left, right| {
550 (left.preference_rank, &left.id).cmp(&(right.preference_rank, &right.id))
551 });
552 validate_candidate_set(
553 &catalog,
554 &candidates,
555 bindings.locked_candidate_id.as_deref(),
556 )?;
557 let selections = compute_selections(&catalog, &requirements, &bindings, candidates)?;
558 let plan = Self {
559 schema_version: SCHEMA_VERSION.to_owned(),
560 algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
561 catalog,
562 requirements,
563 bindings,
564 selections,
565 };
566 plan.validate()?;
567 Ok(plan)
568 }
569
570 pub fn validate(&self) -> Result<(), RoutingError> {
572 if self.schema_version != SCHEMA_VERSION {
573 return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
574 }
575 if self.algorithm_version != ROUTING_ALGORITHM_VERSION {
576 return Err(RoutingError::UnsupportedAlgorithm(
577 self.algorithm_version.clone(),
578 ));
579 }
580 self.catalog.validate()?;
581 self.requirements.validate()?;
582 self.bindings.validate()?;
583 if self.catalog.digest()? != self.bindings.catalog_hash {
584 return Err(RoutingError::CatalogBinding);
585 }
586 let candidates = self
587 .selections
588 .iter()
589 .map(|selection| selection.candidate.clone())
590 .collect::<Vec<_>>();
591 validate_candidate_set(
592 &self.catalog,
593 &candidates,
594 self.bindings.locked_candidate_id.as_deref(),
595 )?;
596 let expected = compute_selections(
597 &self.catalog,
598 &self.requirements,
599 &self.bindings,
600 candidates,
601 )?;
602 if expected != self.selections {
603 return Err(RoutingError::SelectionMismatch);
604 }
605 Ok(())
606 }
607
608 pub fn digest(&self) -> Result<String, RoutingError> {
610 self.validate()?;
611 let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
612 Ok(hash_json(&value))
613 }
614
615 pub fn eligible_candidate_ids(&self) -> Vec<String> {
617 self.selections
618 .iter()
619 .filter(|selection| selection.eligible)
620 .map(|selection| selection.candidate.id.clone())
621 .collect()
622 }
623
624 pub fn selected_candidate_id(&self) -> Option<&str> {
626 self.selections
627 .iter()
628 .find(|selection| selection.eligible)
629 .map(|selection| selection.candidate.id.as_str())
630 }
631
632 pub fn minimum_eligible_context_tokens(&self) -> Option<u64> {
634 let descriptors = self
635 .catalog
636 .descriptors
637 .iter()
638 .map(|descriptor| (descriptor.id.as_str(), descriptor.context_tokens))
639 .collect::<BTreeMap<_, _>>();
640 self.selections
641 .iter()
642 .filter(|selection| selection.eligible)
643 .filter_map(|selection| {
644 descriptors
645 .get(selection.candidate.descriptor_id.as_str())
646 .copied()
647 })
648 .min()
649 }
650}
651
652fn validate_candidate_set(
653 catalog: &ModelCatalog,
654 candidates: &[RoutingCandidate],
655 locked_candidate_id: Option<&str>,
656) -> Result<(), RoutingError> {
657 if candidates.is_empty() {
658 return Err(RoutingError::EmptyCandidates);
659 }
660 let descriptors = catalog
661 .descriptors
662 .iter()
663 .map(|descriptor| descriptor.id.as_str())
664 .collect::<BTreeSet<_>>();
665 let mut ids = BTreeSet::new();
666 let mut previous = None;
667 for candidate in candidates {
668 candidate.validate()?;
669 if !descriptors.contains(candidate.descriptor_id.as_str()) {
670 return Err(RoutingError::UnknownDescriptor(
671 candidate.descriptor_id.clone(),
672 ));
673 }
674 if !ids.insert(candidate.id.as_str()) {
675 return Err(RoutingError::DuplicateCandidate(candidate.id.clone()));
676 }
677 let key = (candidate.preference_rank, candidate.id.as_str());
678 if previous.is_some_and(|prior| prior >= key) {
679 return Err(RoutingError::CandidateOrder);
680 }
681 previous = Some(key);
682 }
683 if let Some(locked) = locked_candidate_id
684 && !ids.contains(locked)
685 {
686 return Err(RoutingError::UnknownLockedCandidate(locked.to_owned()));
687 }
688 Ok(())
689}
690
691fn compute_selections(
692 catalog: &ModelCatalog,
693 requirements: &RoutingRequirements,
694 bindings: &RoutingBindings,
695 candidates: Vec<RoutingCandidate>,
696) -> Result<Vec<RoutingSelection>, RoutingError> {
697 let descriptors = catalog
698 .descriptors
699 .iter()
700 .map(|descriptor| (descriptor.id.as_str(), descriptor))
701 .collect::<BTreeMap<_, _>>();
702 let minimum_context_tokens = requirements
703 .min_context_tokens
704 .max(requirements.reserved_output_tokens + 1);
705 let mut selections = Vec::with_capacity(candidates.len());
706 for candidate in candidates {
707 let descriptor = descriptors
708 .get(candidate.descriptor_id.as_str())
709 .copied()
710 .ok_or_else(|| RoutingError::UnknownDescriptor(candidate.descriptor_id.clone()))?;
711 let missing_capabilities = requirements
712 .required_capabilities
713 .iter()
714 .copied()
715 .filter(|capability| !capability.is_supported_by(&descriptor.capabilities))
716 .collect::<BTreeSet<_>>();
717 let estimated_max_cost_microusd = descriptor
718 .pricing
719 .as_ref()
720 .map(|pricing| {
721 pricing.estimate(
722 requirements.max_input_tokens,
723 requirements.reserved_output_tokens,
724 )
725 })
726 .transpose()?;
727 let mut reasons = BTreeSet::new();
728 if !candidate.enabled {
729 reasons.insert(RoutingExclusionReason::Disabled);
730 }
731 if !candidate.credential_available {
732 reasons.insert(RoutingExclusionReason::CredentialUnavailable);
733 }
734 match candidate.token_status {
735 Some(TokenStatus::Expired) => {
736 reasons.insert(RoutingExclusionReason::TokenExpired);
737 }
738 Some(TokenStatus::Revoked) => {
739 reasons.insert(RoutingExclusionReason::TokenRevoked);
740 }
741 Some(TokenStatus::NeedsRefresh) => {
742 reasons.insert(RoutingExclusionReason::TokenNeedsRefresh);
743 }
744 Some(TokenStatus::Active) | None => {}
745 }
746 if !requirements.required_scopes.is_subset(&candidate.scopes) {
747 reasons.insert(RoutingExclusionReason::ScopeInsufficient);
748 }
749 if !missing_capabilities.is_empty() {
750 reasons.insert(RoutingExclusionReason::MissingCapability);
751 }
752 if descriptor.context_tokens < minimum_context_tokens {
753 reasons.insert(RoutingExclusionReason::ContextWindowTooSmall);
754 }
755 if requirements.require_known_pricing && descriptor.pricing.is_none() {
756 reasons.insert(RoutingExclusionReason::PricingUnknown);
757 }
758 if let Some(maximum) = requirements.max_estimated_cost_microusd {
759 match estimated_max_cost_microusd {
760 Some(estimate) if estimate > maximum => {
761 reasons.insert(RoutingExclusionReason::CostBudgetExceeded);
762 }
763 None => {
764 reasons.insert(RoutingExclusionReason::PricingUnknown);
765 }
766 Some(_) => {}
767 }
768 }
769 if requirements.require_known_latency && descriptor.expected_latency_ms.is_none() {
770 reasons.insert(RoutingExclusionReason::LatencyUnknown);
771 }
772 if let Some(maximum) = requirements.max_latency_ms {
773 match descriptor.expected_latency_ms {
774 Some(estimate) if estimate > maximum => {
775 reasons.insert(RoutingExclusionReason::LatencyBudgetExceeded);
776 }
777 None => {
778 reasons.insert(RoutingExclusionReason::LatencyUnknown);
779 }
780 Some(_) => {}
781 }
782 }
783 if let Some(locked) = bindings.locked_candidate_id.as_deref()
784 && candidate.id != locked
785 {
786 reasons.insert(RoutingExclusionReason::SessionRouteLocked);
787 }
788 selections.push(RoutingSelection {
789 candidate,
790 eligible: reasons.is_empty(),
791 reasons,
792 missing_capabilities,
793 estimated_max_cost_microusd,
794 });
795 }
796
797 if bindings.locked_candidate_id.is_none() && bindings.side_effect_watermark > 0 {
798 let mut retained_one = false;
799 for selection in &mut selections {
800 if selection.eligible && !retained_one {
801 retained_one = true;
802 } else if selection.eligible {
803 selection
804 .reasons
805 .insert(RoutingExclusionReason::FallbackBlockedAfterSideEffect);
806 selection.eligible = false;
807 }
808 }
809 }
810 Ok(selections)
811}
812
813#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
815#[serde(rename_all = "snake_case")]
816pub enum RoutingAttemptOutcome {
817 FailedBeforeVisibleOutput,
819 FailedAfterVisibleOutput,
821 Succeeded,
823 Cancelled,
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
829#[serde(rename_all = "camelCase", deny_unknown_fields)]
830pub struct RoutingAttemptReceipt {
831 pub candidate_id: String,
833 pub outcome: RoutingAttemptOutcome,
835 #[serde(skip_serializing_if = "Option::is_none")]
837 pub error_hash: Option<String>,
838}
839
840#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
842#[serde(rename_all = "snake_case")]
843pub enum RoutingReceiptOutcome {
844 Selected,
846 Exhausted,
848 Blocked,
850 NotInvoked,
852 Cancelled,
854 Ambiguous,
856}
857
858#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
860#[serde(rename_all = "camelCase", deny_unknown_fields)]
861pub struct RoutingReceipt {
862 pub schema_version: String,
864 pub algorithm_version: String,
866 pub plan_hash: String,
868 pub step: u64,
870 pub workspace_generation: u64,
872 pub state_binding: String,
874 pub side_effect_watermark: u64,
876 pub attempts: Vec<RoutingAttemptReceipt>,
878 #[serde(skip_serializing_if = "Option::is_none")]
880 pub selected_candidate_id: Option<String>,
881 pub outcome: RoutingReceiptOutcome,
883}
884
885impl RoutingReceipt {
886 pub fn new(
888 plan: &RoutingPlan,
889 attempts: Vec<RoutingAttemptReceipt>,
890 selected_candidate_id: Option<String>,
891 outcome: RoutingReceiptOutcome,
892 ) -> Result<Self, RoutingError> {
893 let receipt = Self {
894 schema_version: SCHEMA_VERSION.to_owned(),
895 algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
896 plan_hash: plan.digest()?,
897 step: plan.bindings.step,
898 workspace_generation: plan.bindings.workspace_generation,
899 state_binding: plan.bindings.state_binding.clone(),
900 side_effect_watermark: plan.bindings.side_effect_watermark,
901 attempts,
902 selected_candidate_id,
903 outcome,
904 };
905 receipt.validate(plan)?;
906 Ok(receipt)
907 }
908
909 pub fn validate(&self, plan: &RoutingPlan) -> Result<(), RoutingError> {
911 plan.validate()?;
912 if self.schema_version != SCHEMA_VERSION {
913 return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
914 }
915 if self.algorithm_version != ROUTING_ALGORITHM_VERSION {
916 return Err(RoutingError::UnsupportedAlgorithm(
917 self.algorithm_version.clone(),
918 ));
919 }
920 if self.plan_hash != plan.digest()?
921 || self.step != plan.bindings.step
922 || self.workspace_generation != plan.bindings.workspace_generation
923 || self.state_binding != plan.bindings.state_binding
924 || self.side_effect_watermark != plan.bindings.side_effect_watermark
925 {
926 return Err(RoutingError::ReceiptBinding);
927 }
928 let eligible = plan.eligible_candidate_ids();
929 if self.attempts.len() > eligible.len() {
930 return Err(RoutingError::ReceiptAttempts);
931 }
932 for (index, attempt) in self.attempts.iter().enumerate() {
933 if eligible.get(index) != Some(&attempt.candidate_id)
934 || !valid_digest(&attempt.candidate_id)
935 {
936 return Err(RoutingError::ReceiptAttempts);
937 }
938 match attempt.outcome {
939 RoutingAttemptOutcome::Succeeded => {
940 if attempt.error_hash.is_some() || index + 1 != self.attempts.len() {
941 return Err(RoutingError::ReceiptAttempts);
942 }
943 }
944 RoutingAttemptOutcome::FailedBeforeVisibleOutput
945 | RoutingAttemptOutcome::FailedAfterVisibleOutput
946 | RoutingAttemptOutcome::Cancelled => {
947 if !attempt.error_hash.as_deref().is_some_and(valid_digest) {
948 return Err(RoutingError::ReceiptAttempts);
949 }
950 }
951 }
952 }
953 let last = self.attempts.last().map(|attempt| attempt.outcome);
954 match self.outcome {
955 RoutingReceiptOutcome::Selected => {
956 let Some(selected) = self.selected_candidate_id.as_deref() else {
957 return Err(RoutingError::ReceiptOutcome);
958 };
959 if last != Some(RoutingAttemptOutcome::Succeeded)
960 || self
961 .attempts
962 .last()
963 .map(|attempt| attempt.candidate_id.as_str())
964 != Some(selected)
965 {
966 return Err(RoutingError::ReceiptOutcome);
967 }
968 }
969 RoutingReceiptOutcome::Exhausted => {
970 if self.selected_candidate_id.is_some()
971 || self.attempts.len() != eligible.len()
972 || self.attempts.iter().any(|attempt| {
973 attempt.outcome != RoutingAttemptOutcome::FailedBeforeVisibleOutput
974 })
975 {
976 return Err(RoutingError::ReceiptOutcome);
977 }
978 }
979 RoutingReceiptOutcome::Blocked => {
980 let blocked_without_attempt = eligible.is_empty() && self.attempts.is_empty();
981 let blocked_after_visible = last
982 == Some(RoutingAttemptOutcome::FailedAfterVisibleOutput)
983 && self.selected_candidate_id.is_none();
984 if !blocked_without_attempt && !blocked_after_visible {
985 return Err(RoutingError::ReceiptOutcome);
986 }
987 }
988 RoutingReceiptOutcome::NotInvoked | RoutingReceiptOutcome::Ambiguous => {
989 if eligible.is_empty()
990 || !self.attempts.is_empty()
991 || self.selected_candidate_id.is_some()
992 {
993 return Err(RoutingError::ReceiptOutcome);
994 }
995 }
996 RoutingReceiptOutcome::Cancelled => {
997 let cancelled_before_attempt = eligible.len() > self.attempts.len()
998 && self.attempts.iter().all(|attempt| {
999 attempt.outcome == RoutingAttemptOutcome::FailedBeforeVisibleOutput
1000 });
1001 let cancelled_during_attempt = last == Some(RoutingAttemptOutcome::Cancelled);
1002 if self.selected_candidate_id.is_some()
1003 || (!cancelled_before_attempt && !cancelled_during_attempt)
1004 {
1005 return Err(RoutingError::ReceiptOutcome);
1006 }
1007 }
1008 }
1009 Ok(())
1010 }
1011
1012 pub fn digest(&self, plan: &RoutingPlan) -> Result<String, RoutingError> {
1014 self.validate(plan)?;
1015 let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
1016 Ok(hash_json(&value))
1017 }
1018}
1019
1020fn valid_digest(value: &str) -> bool {
1021 value.len() == 64
1022 && value
1023 .bytes()
1024 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1025}
1026
1027fn valid_scope(scope: &str) -> bool {
1028 !scope.is_empty()
1029 && scope.len() <= 256
1030 && !scope
1031 .chars()
1032 .any(|character| character.is_control() || character.is_whitespace())
1033}
1034
1035#[derive(Debug, Clone, Error, PartialEq, Eq)]
1037pub enum RoutingError {
1038 #[error("unsupported routing schema: {0}")]
1040 UnsupportedSchema(String),
1041 #[error("unsupported routing algorithm: {0}")]
1043 UnsupportedAlgorithm(String),
1044 #[error("unsupported model catalog: {0}")]
1046 UnsupportedCatalog(String),
1047 #[error("routing identity is empty")]
1049 EmptyIdentity,
1050 #[error("invalid model descriptor: {0}")]
1052 InvalidDescriptor(String),
1053 #[error("model descriptor digest is invalid: {0}")]
1055 DescriptorDigest(String),
1056 #[error("model catalog is empty")]
1058 EmptyCatalog,
1059 #[error("model catalog descriptors are not strictly digest-sorted")]
1061 CatalogOrder,
1062 #[error("routing candidate is invalid: {0}")]
1064 InvalidCandidate(String),
1065 #[error("routing candidate digest is invalid: {0}")]
1067 CandidateDigest(String),
1068 #[error("routing candidate list is empty")]
1070 EmptyCandidates,
1071 #[error("routing candidates are not in deterministic preference order")]
1073 CandidateOrder,
1074 #[error("duplicate routing candidate: {0}")]
1076 DuplicateCandidate(String),
1077 #[error("routing candidate references an unknown descriptor: {0}")]
1079 UnknownDescriptor(String),
1080 #[error("locked routing candidate is absent: {0}")]
1082 UnknownLockedCandidate(String),
1083 #[error("routing requirements are invalid")]
1085 InvalidRequirements,
1086 #[error("routing binding is not a canonical digest: {0}")]
1088 InvalidBinding(String),
1089 #[error("model catalog digest does not match the routing binding")]
1091 CatalogBinding,
1092 #[error("routing selections disagree with deterministic reduction")]
1094 SelectionMismatch,
1095 #[error("routing cost estimate overflowed")]
1097 CostOverflow,
1098 #[error("routing contract serialization failed")]
1100 Serialization,
1101 #[error("routing receipt does not match plan bindings")]
1103 ReceiptBinding,
1104 #[error("routing receipt attempt sequence is invalid")]
1106 ReceiptAttempts,
1107 #[error("routing receipt outcome is inconsistent")]
1109 ReceiptOutcome,
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115 use crate::hash_bytes;
1116
1117 fn descriptor(model: &str, tools: bool, price: Option<u64>) -> ModelDescriptor {
1118 ModelDescriptor::new(
1119 "mock",
1120 model,
1121 ProviderCapabilities {
1122 streaming: false,
1123 tools,
1124 structured_output: true,
1125 multimodal_input: false,
1126 context_tokens: Some(32_768),
1127 },
1128 32_768,
1129 RoutingContextSource::Provider,
1130 price.map(|rate| ModelPricing {
1131 input_microusd_per_million_tokens: rate,
1132 output_microusd_per_million_tokens: rate,
1133 }),
1134 Some(1_000),
1135 )
1136 .unwrap()
1137 }
1138
1139 fn plan(lock: Option<String>, side_effects: u64) -> RoutingPlan {
1140 let first = descriptor("a", true, Some(1_000));
1141 let second = descriptor("b", true, Some(2_000));
1142 let catalog = ModelCatalog::new(vec![second.clone(), first.clone()]).unwrap();
1143 let candidates = vec![
1144 RoutingCandidate::new(
1145 first.id.clone(),
1146 0,
1147 "primary",
1148 hash_bytes(b"primary-authority"),
1149 true,
1150 true,
1151 )
1152 .unwrap(),
1153 RoutingCandidate::new(
1154 second.id.clone(),
1155 1,
1156 "fallback",
1157 hash_bytes(b"fallback-authority"),
1158 true,
1159 true,
1160 )
1161 .unwrap(),
1162 ];
1163 let bindings = RoutingBindings {
1164 contract_hash: hash_bytes(b"contract"),
1165 policy_hash: hash_bytes(b"policy"),
1166 tool_authority_hash: None,
1167 catalog_hash: catalog.digest().unwrap(),
1168 run_controls_hash: hash_bytes(b"resume"),
1169 step: 0,
1170 workspace_generation: 0,
1171 state_binding: hash_bytes(b"workspace"),
1172 side_effect_watermark: side_effects,
1173 locked_candidate_id: lock,
1174 };
1175 RoutingPlan::build(
1176 catalog,
1177 RoutingRequirements {
1178 required_capabilities: [RoutingCapability::Tools].into_iter().collect(),
1179 min_context_tokens: 8_192,
1180 max_input_tokens: 8_192,
1181 reserved_output_tokens: 1_024,
1182 max_estimated_cost_microusd: Some(20),
1183 max_latency_ms: Some(2_000),
1184 require_known_pricing: true,
1185 require_known_latency: true,
1186 required_scopes: BTreeSet::new(),
1187 },
1188 bindings,
1189 candidates,
1190 )
1191 .unwrap()
1192 }
1193
1194 #[test]
1195 fn plan_is_canonical_and_content_addressed() {
1196 let first = plan(None, 0);
1197 let second = plan(None, 0);
1198 assert_eq!(first, second);
1199 assert_eq!(first.digest().unwrap(), second.digest().unwrap());
1200 assert_eq!(first.eligible_candidate_ids().len(), 2);
1201 }
1202
1203 #[test]
1204 fn side_effects_and_route_lock_remove_fallbacks() {
1205 let side_effect_plan = plan(None, 1);
1206 assert_eq!(side_effect_plan.eligible_candidate_ids().len(), 1);
1207 assert!(
1208 side_effect_plan.selections[1]
1209 .reasons
1210 .contains(&RoutingExclusionReason::FallbackBlockedAfterSideEffect)
1211 );
1212 let lock = side_effect_plan.selections[1].candidate.id.clone();
1213 let locked = plan(Some(lock.clone()), 0);
1214 assert_eq!(locked.eligible_candidate_ids(), vec![lock]);
1215 assert!(
1216 locked.selections[0]
1217 .reasons
1218 .contains(&RoutingExclusionReason::SessionRouteLocked)
1219 );
1220 }
1221
1222 #[test]
1223 fn output_reserve_that_consumes_the_context_excludes_every_candidate() {
1224 let base = plan(None, 0);
1225 let candidates = base
1226 .selections
1227 .iter()
1228 .map(|selection| selection.candidate.clone())
1229 .collect();
1230 let routed = RoutingPlan::build(
1231 base.catalog,
1232 RoutingRequirements {
1233 min_context_tokens: 1,
1234 max_input_tokens: 1,
1235 reserved_output_tokens: 32_768,
1236 ..base.requirements
1237 },
1238 base.bindings,
1239 candidates,
1240 )
1241 .unwrap();
1242
1243 assert!(routed.eligible_candidate_ids().is_empty());
1244 assert!(routed.selections.iter().all(|selection| {
1245 selection
1246 .reasons
1247 .contains(&RoutingExclusionReason::ContextWindowTooSmall)
1248 }));
1249 }
1250
1251 #[test]
1252 fn priced_budget_excludes_unknown_and_expensive_models() {
1253 let cheap = descriptor("cheap", true, Some(1));
1254 let unknown = descriptor("unknown", true, None);
1255 let expensive = descriptor("expensive", true, Some(1_000_000));
1256 let catalog =
1257 ModelCatalog::new(vec![cheap.clone(), unknown.clone(), expensive.clone()]).unwrap();
1258 let candidates = [cheap, unknown, expensive]
1259 .into_iter()
1260 .enumerate()
1261 .map(|(rank, descriptor)| {
1262 RoutingCandidate::new(
1263 descriptor.id,
1264 u32::try_from(rank).unwrap(),
1265 format!("p{rank}"),
1266 hash_bytes(format!("authority-{rank}")),
1267 true,
1268 true,
1269 )
1270 .unwrap()
1271 })
1272 .collect();
1273 let bindings = RoutingBindings {
1274 contract_hash: hash_bytes(b"contract"),
1275 policy_hash: hash_bytes(b"policy"),
1276 tool_authority_hash: None,
1277 catalog_hash: catalog.digest().unwrap(),
1278 run_controls_hash: hash_bytes(b"resume"),
1279 step: 0,
1280 workspace_generation: 0,
1281 state_binding: hash_bytes(b"workspace"),
1282 side_effect_watermark: 0,
1283 locked_candidate_id: None,
1284 };
1285 let routed = RoutingPlan::build(
1286 catalog,
1287 RoutingRequirements {
1288 required_capabilities: BTreeSet::new(),
1289 min_context_tokens: 1,
1290 max_input_tokens: 10_000,
1291 reserved_output_tokens: 1_000,
1292 max_estimated_cost_microusd: Some(100),
1293 max_latency_ms: None,
1294 require_known_pricing: true,
1295 require_known_latency: false,
1296 required_scopes: BTreeSet::new(),
1297 },
1298 bindings,
1299 candidates,
1300 )
1301 .unwrap();
1302 assert_eq!(routed.eligible_candidate_ids().len(), 1);
1303 assert!(routed.selections.iter().any(|selection| {
1304 selection
1305 .reasons
1306 .contains(&RoutingExclusionReason::PricingUnknown)
1307 }));
1308 assert!(routed.selections.iter().any(|selection| {
1309 selection
1310 .reasons
1311 .contains(&RoutingExclusionReason::CostBudgetExceeded)
1312 }));
1313 }
1314
1315 #[test]
1316 fn receipt_commits_exact_attempt_prefix_and_selection() {
1317 let plan = plan(None, 0);
1318 let eligible = plan.eligible_candidate_ids();
1319 let receipt = RoutingReceipt::new(
1320 &plan,
1321 vec![
1322 RoutingAttemptReceipt {
1323 candidate_id: eligible[0].clone(),
1324 outcome: RoutingAttemptOutcome::FailedBeforeVisibleOutput,
1325 error_hash: Some(hash_bytes(b"transport")),
1326 },
1327 RoutingAttemptReceipt {
1328 candidate_id: eligible[1].clone(),
1329 outcome: RoutingAttemptOutcome::Succeeded,
1330 error_hash: None,
1331 },
1332 ],
1333 Some(eligible[1].clone()),
1334 RoutingReceiptOutcome::Selected,
1335 )
1336 .unwrap();
1337 receipt.validate(&plan).unwrap();
1338 assert!(valid_digest(&receipt.digest(&plan).unwrap()));
1339 }
1340
1341 #[test]
1342 fn plan_and_receipt_tampering_fail_closed() {
1343 let mut routed = plan(None, 0);
1344 routed.selections[0].eligible = false;
1345 assert_eq!(routed.validate(), Err(RoutingError::SelectionMismatch));
1346
1347 let routed = plan(None, 0);
1348 let error =
1349 RoutingReceipt::new(&routed, Vec::new(), None, RoutingReceiptOutcome::Exhausted)
1350 .unwrap_err();
1351 assert_eq!(error, RoutingError::ReceiptOutcome);
1352
1353 let mut unknown_field = serde_json::to_value(&routed).unwrap();
1354 unknown_field["uncommittedDiagnostic"] = serde_json::json!(true);
1355 assert!(serde_json::from_value::<RoutingPlan>(unknown_field).is_err());
1356 }
1357
1358 #[test]
1359 fn credential_only_candidate_digest_is_stable_without_auth() {
1360 let legacy = RoutingCandidate::new(
1364 hash_bytes(b"descriptor"),
1365 0,
1366 "primary",
1367 hash_bytes(b"authority"),
1368 true,
1369 true,
1370 )
1371 .unwrap();
1372 let with_absent_auth = RoutingCandidate::with_auth(
1373 hash_bytes(b"descriptor"),
1374 0,
1375 "primary",
1376 hash_bytes(b"authority"),
1377 true,
1378 true,
1379 None,
1380 BTreeSet::new(),
1381 )
1382 .unwrap();
1383 assert_eq!(legacy.id, with_absent_auth.id);
1384 let expected = hash_json(&serde_json::json!({
1386 "descriptorId": hash_bytes(b"descriptor"),
1387 "preferenceRank": 0,
1388 "profile": "primary",
1389 "authorityHash": hash_bytes(b"authority"),
1390 "credentialAvailable": true,
1391 "enabled": true,
1392 }));
1393 assert_eq!(legacy.id, expected);
1394 }
1395
1396 #[test]
1397 fn account_aware_candidate_digest_binds_auth_state() {
1398 let base = RoutingCandidate::new(
1399 hash_bytes(b"descriptor"),
1400 0,
1401 "primary",
1402 hash_bytes(b"auth"),
1403 true,
1404 true,
1405 )
1406 .unwrap();
1407 let expired = RoutingCandidate::with_auth(
1408 hash_bytes(b"descriptor"),
1409 0,
1410 "primary",
1411 hash_bytes(b"auth"),
1412 true,
1413 true,
1414 Some(TokenStatus::Expired),
1415 BTreeSet::new(),
1416 )
1417 .unwrap();
1418 assert_ne!(base.id, expired.id);
1419 }
1420
1421 fn auth_plan(requirements: RoutingRequirements) -> RoutingPlan {
1422 let descriptor = descriptor("model", true, Some(1));
1423 let catalog = ModelCatalog::new(vec![descriptor.clone()]).unwrap();
1424 let candidate = RoutingCandidate::with_auth(
1425 descriptor.id,
1426 0,
1427 "primary",
1428 hash_bytes(b"authority"),
1429 true,
1430 true,
1431 Some(TokenStatus::Active),
1432 BTreeSet::from(["chat.completions".to_owned()]),
1433 )
1434 .unwrap();
1435 let catalog_hash = catalog.digest().unwrap();
1436 RoutingPlan::build(
1437 catalog,
1438 requirements,
1439 RoutingBindings {
1440 contract_hash: hash_bytes(b"contract"),
1441 policy_hash: hash_bytes(b"policy"),
1442 tool_authority_hash: None,
1443 catalog_hash,
1444 run_controls_hash: hash_bytes(b"resume"),
1445 step: 0,
1446 workspace_generation: 0,
1447 state_binding: hash_bytes(b"workspace"),
1448 side_effect_watermark: 0,
1449 locked_candidate_id: None,
1450 },
1451 vec![candidate],
1452 )
1453 .unwrap()
1454 }
1455
1456 fn base_requirements() -> RoutingRequirements {
1457 RoutingRequirements {
1458 required_capabilities: BTreeSet::new(),
1459 min_context_tokens: 1,
1460 max_input_tokens: 10_000,
1461 reserved_output_tokens: 1_000,
1462 max_estimated_cost_microusd: None,
1463 max_latency_ms: None,
1464 require_known_pricing: false,
1465 require_known_latency: false,
1466 required_scopes: BTreeSet::new(),
1467 }
1468 }
1469
1470 #[test]
1471 fn token_lifecycle_excludes_candidate() {
1472 for (status, reason) in [
1473 (TokenStatus::Expired, RoutingExclusionReason::TokenExpired),
1474 (TokenStatus::Revoked, RoutingExclusionReason::TokenRevoked),
1475 (
1476 TokenStatus::NeedsRefresh,
1477 RoutingExclusionReason::TokenNeedsRefresh,
1478 ),
1479 ] {
1480 let descriptor = descriptor("model", true, Some(1));
1481 let catalog = ModelCatalog::new(vec![descriptor.clone()]).unwrap();
1482 let candidate = RoutingCandidate::with_auth(
1483 descriptor.id,
1484 0,
1485 "primary",
1486 hash_bytes(b"authority"),
1487 true,
1488 true,
1489 Some(status),
1490 BTreeSet::new(),
1491 )
1492 .unwrap();
1493 let cred = RoutingExclusionReason::CredentialUnavailable;
1494 let catalog_hash = catalog.digest().unwrap();
1495 let plan = RoutingPlan::build(
1496 catalog,
1497 base_requirements(),
1498 RoutingBindings {
1499 contract_hash: hash_bytes(b"contract"),
1500 policy_hash: hash_bytes(b"policy"),
1501 tool_authority_hash: None,
1502 catalog_hash,
1503 run_controls_hash: hash_bytes(b"resume"),
1504 step: 0,
1505 workspace_generation: 0,
1506 state_binding: hash_bytes(b"workspace"),
1507 side_effect_watermark: 0,
1508 locked_candidate_id: None,
1509 },
1510 vec![candidate],
1511 )
1512 .unwrap();
1513 assert!(plan.eligible_candidate_ids().is_empty());
1514 assert!(plan.selections[0].reasons.contains(&reason));
1515 assert!(!plan.selections[0].reasons.contains(&cred));
1516 }
1517 }
1518
1519 #[test]
1520 fn required_scope_excludes_candidate_without_scope() {
1521 let satisfied = auth_plan(base_requirements());
1522 assert_eq!(satisfied.eligible_candidate_ids().len(), 1);
1523
1524 let mut requirements = base_requirements();
1525 requirements.required_scopes =
1526 BTreeSet::from(["chat.completions".to_owned(), "models.read".to_owned()]);
1527 let denied = auth_plan(requirements);
1528 assert!(denied.eligible_candidate_ids().is_empty());
1529 assert!(
1530 denied.selections[0]
1531 .reasons
1532 .contains(&RoutingExclusionReason::ScopeInsufficient)
1533 );
1534 }
1535
1536 #[test]
1537 fn requirements_reject_invalid_scope_string() {
1538 let mut valid = base_requirements();
1539 assert!(valid.validate().is_ok());
1540 valid.required_scopes.insert("bad scope".to_owned());
1541 assert_eq!(valid.validate(), Err(RoutingError::InvalidRequirements));
1542 }
1543
1544 #[test]
1545 fn candidate_rejects_invalid_scope_string() {
1546 let descriptor = descriptor("model", true, Some(1));
1547 assert!(
1548 RoutingCandidate::with_auth(
1549 descriptor.id,
1550 0,
1551 "primary",
1552 hash_bytes(b"authority"),
1553 true,
1554 true,
1555 Some(TokenStatus::Active),
1556 BTreeSet::from(["bad scope".to_owned()]),
1557 )
1558 .is_err()
1559 );
1560 }
1561}