1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::{ProviderCapabilities, SCHEMA_VERSION, 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}
254
255impl RoutingCandidate {
256 pub fn new(
258 descriptor_id: impl Into<String>,
259 preference_rank: u32,
260 profile: impl Into<String>,
261 authority_hash: impl Into<String>,
262 credential_available: bool,
263 enabled: bool,
264 ) -> Result<Self, RoutingError> {
265 let mut candidate = Self {
266 id: String::new(),
267 descriptor_id: descriptor_id.into(),
268 preference_rank,
269 profile: profile.into(),
270 authority_hash: authority_hash.into(),
271 credential_available,
272 enabled,
273 };
274 candidate.validate_material()?;
275 candidate.id = candidate.material_digest()?;
276 Ok(candidate)
277 }
278
279 pub fn material_digest(&self) -> Result<String, RoutingError> {
281 Ok(hash_json(&self.material_value()))
282 }
283
284 pub fn validate(&self) -> Result<(), RoutingError> {
286 self.validate_material()?;
287 if !valid_digest(&self.id) || self.id != self.material_digest()? {
288 return Err(RoutingError::CandidateDigest(self.id.clone()));
289 }
290 Ok(())
291 }
292
293 fn material_value(&self) -> serde_json::Value {
294 serde_json::json!({
295 "descriptorId": self.descriptor_id,
296 "preferenceRank": self.preference_rank,
297 "profile": self.profile,
298 "authorityHash": self.authority_hash,
299 "credentialAvailable": self.credential_available,
300 "enabled": self.enabled,
301 })
302 }
303
304 fn validate_material(&self) -> Result<(), RoutingError> {
305 if self.profile.trim().is_empty()
306 || !valid_digest(&self.descriptor_id)
307 || !valid_digest(&self.authority_hash)
308 {
309 return Err(RoutingError::InvalidCandidate(self.profile.clone()));
310 }
311 Ok(())
312 }
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
317#[serde(rename_all = "camelCase", deny_unknown_fields)]
318pub struct RoutingRequirements {
319 #[serde(default)]
321 pub required_capabilities: BTreeSet<RoutingCapability>,
322 pub min_context_tokens: u64,
324 pub max_input_tokens: u64,
326 pub reserved_output_tokens: u64,
328 #[serde(skip_serializing_if = "Option::is_none")]
330 pub max_estimated_cost_microusd: Option<u64>,
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub max_latency_ms: Option<u64>,
334 #[serde(default)]
336 pub require_known_pricing: bool,
337 #[serde(default)]
339 pub require_known_latency: bool,
340}
341
342impl RoutingRequirements {
343 pub fn validate(&self) -> Result<(), RoutingError> {
345 if self.min_context_tokens == 0
346 || self.max_input_tokens == 0
347 || self.reserved_output_tokens == 0
348 || self.reserved_output_tokens == u64::MAX
349 || self.max_estimated_cost_microusd == Some(0)
350 || self.max_latency_ms == Some(0)
351 {
352 return Err(RoutingError::InvalidRequirements);
353 }
354 Ok(())
355 }
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
360#[serde(rename_all = "camelCase", deny_unknown_fields)]
361pub struct RoutingBindings {
362 pub contract_hash: String,
364 pub policy_hash: String,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 pub tool_authority_hash: Option<String>,
369 pub catalog_hash: String,
371 pub run_controls_hash: String,
373 pub step: u64,
375 pub workspace_generation: u64,
377 pub state_binding: String,
379 pub side_effect_watermark: u64,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub locked_candidate_id: Option<String>,
384}
385
386impl RoutingBindings {
387 fn validate(&self) -> Result<(), RoutingError> {
388 for digest in [
389 Some(self.contract_hash.as_str()),
390 Some(self.policy_hash.as_str()),
391 self.tool_authority_hash.as_deref(),
392 Some(self.catalog_hash.as_str()),
393 Some(self.run_controls_hash.as_str()),
394 Some(self.state_binding.as_str()),
395 self.locked_candidate_id.as_deref(),
396 ]
397 .into_iter()
398 .flatten()
399 {
400 if !valid_digest(digest) {
401 return Err(RoutingError::InvalidBinding(digest.to_owned()));
402 }
403 }
404 Ok(())
405 }
406}
407
408#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
410#[serde(rename_all = "snake_case")]
411pub enum RoutingExclusionReason {
412 Disabled,
414 CredentialUnavailable,
416 MissingCapability,
418 ContextWindowTooSmall,
420 PricingUnknown,
422 CostBudgetExceeded,
424 LatencyUnknown,
426 LatencyBudgetExceeded,
428 SessionRouteLocked,
430 FallbackBlockedAfterSideEffect,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436#[serde(rename_all = "camelCase", deny_unknown_fields)]
437pub struct RoutingSelection {
438 pub candidate: RoutingCandidate,
440 pub eligible: bool,
442 #[serde(default)]
444 pub reasons: BTreeSet<RoutingExclusionReason>,
445 #[serde(default)]
447 pub missing_capabilities: BTreeSet<RoutingCapability>,
448 #[serde(skip_serializing_if = "Option::is_none")]
450 pub estimated_max_cost_microusd: Option<u64>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
455#[serde(rename_all = "camelCase", deny_unknown_fields)]
456pub struct RoutingPlan {
457 pub schema_version: String,
459 pub algorithm_version: String,
461 pub catalog: ModelCatalog,
463 pub requirements: RoutingRequirements,
465 pub bindings: RoutingBindings,
467 pub selections: Vec<RoutingSelection>,
469}
470
471impl RoutingPlan {
472 pub fn build(
474 catalog: ModelCatalog,
475 requirements: RoutingRequirements,
476 bindings: RoutingBindings,
477 mut candidates: Vec<RoutingCandidate>,
478 ) -> Result<Self, RoutingError> {
479 catalog.validate()?;
480 requirements.validate()?;
481 bindings.validate()?;
482 if catalog.digest()? != bindings.catalog_hash {
483 return Err(RoutingError::CatalogBinding);
484 }
485 candidates.sort_by(|left, right| {
486 (left.preference_rank, &left.id).cmp(&(right.preference_rank, &right.id))
487 });
488 validate_candidate_set(
489 &catalog,
490 &candidates,
491 bindings.locked_candidate_id.as_deref(),
492 )?;
493 let selections = compute_selections(&catalog, &requirements, &bindings, candidates)?;
494 let plan = Self {
495 schema_version: SCHEMA_VERSION.to_owned(),
496 algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
497 catalog,
498 requirements,
499 bindings,
500 selections,
501 };
502 plan.validate()?;
503 Ok(plan)
504 }
505
506 pub fn validate(&self) -> Result<(), RoutingError> {
508 if self.schema_version != SCHEMA_VERSION {
509 return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
510 }
511 if self.algorithm_version != ROUTING_ALGORITHM_VERSION {
512 return Err(RoutingError::UnsupportedAlgorithm(
513 self.algorithm_version.clone(),
514 ));
515 }
516 self.catalog.validate()?;
517 self.requirements.validate()?;
518 self.bindings.validate()?;
519 if self.catalog.digest()? != self.bindings.catalog_hash {
520 return Err(RoutingError::CatalogBinding);
521 }
522 let candidates = self
523 .selections
524 .iter()
525 .map(|selection| selection.candidate.clone())
526 .collect::<Vec<_>>();
527 validate_candidate_set(
528 &self.catalog,
529 &candidates,
530 self.bindings.locked_candidate_id.as_deref(),
531 )?;
532 let expected = compute_selections(
533 &self.catalog,
534 &self.requirements,
535 &self.bindings,
536 candidates,
537 )?;
538 if expected != self.selections {
539 return Err(RoutingError::SelectionMismatch);
540 }
541 Ok(())
542 }
543
544 pub fn digest(&self) -> Result<String, RoutingError> {
546 self.validate()?;
547 let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
548 Ok(hash_json(&value))
549 }
550
551 pub fn eligible_candidate_ids(&self) -> Vec<String> {
553 self.selections
554 .iter()
555 .filter(|selection| selection.eligible)
556 .map(|selection| selection.candidate.id.clone())
557 .collect()
558 }
559
560 pub fn selected_candidate_id(&self) -> Option<&str> {
562 self.selections
563 .iter()
564 .find(|selection| selection.eligible)
565 .map(|selection| selection.candidate.id.as_str())
566 }
567
568 pub fn minimum_eligible_context_tokens(&self) -> Option<u64> {
570 let descriptors = self
571 .catalog
572 .descriptors
573 .iter()
574 .map(|descriptor| (descriptor.id.as_str(), descriptor.context_tokens))
575 .collect::<BTreeMap<_, _>>();
576 self.selections
577 .iter()
578 .filter(|selection| selection.eligible)
579 .filter_map(|selection| {
580 descriptors
581 .get(selection.candidate.descriptor_id.as_str())
582 .copied()
583 })
584 .min()
585 }
586}
587
588fn validate_candidate_set(
589 catalog: &ModelCatalog,
590 candidates: &[RoutingCandidate],
591 locked_candidate_id: Option<&str>,
592) -> Result<(), RoutingError> {
593 if candidates.is_empty() {
594 return Err(RoutingError::EmptyCandidates);
595 }
596 let descriptors = catalog
597 .descriptors
598 .iter()
599 .map(|descriptor| descriptor.id.as_str())
600 .collect::<BTreeSet<_>>();
601 let mut ids = BTreeSet::new();
602 let mut previous = None;
603 for candidate in candidates {
604 candidate.validate()?;
605 if !descriptors.contains(candidate.descriptor_id.as_str()) {
606 return Err(RoutingError::UnknownDescriptor(
607 candidate.descriptor_id.clone(),
608 ));
609 }
610 if !ids.insert(candidate.id.as_str()) {
611 return Err(RoutingError::DuplicateCandidate(candidate.id.clone()));
612 }
613 let key = (candidate.preference_rank, candidate.id.as_str());
614 if previous.is_some_and(|prior| prior >= key) {
615 return Err(RoutingError::CandidateOrder);
616 }
617 previous = Some(key);
618 }
619 if let Some(locked) = locked_candidate_id
620 && !ids.contains(locked)
621 {
622 return Err(RoutingError::UnknownLockedCandidate(locked.to_owned()));
623 }
624 Ok(())
625}
626
627fn compute_selections(
628 catalog: &ModelCatalog,
629 requirements: &RoutingRequirements,
630 bindings: &RoutingBindings,
631 candidates: Vec<RoutingCandidate>,
632) -> Result<Vec<RoutingSelection>, RoutingError> {
633 let descriptors = catalog
634 .descriptors
635 .iter()
636 .map(|descriptor| (descriptor.id.as_str(), descriptor))
637 .collect::<BTreeMap<_, _>>();
638 let minimum_context_tokens = requirements
639 .min_context_tokens
640 .max(requirements.reserved_output_tokens + 1);
641 let mut selections = Vec::with_capacity(candidates.len());
642 for candidate in candidates {
643 let descriptor = descriptors
644 .get(candidate.descriptor_id.as_str())
645 .copied()
646 .ok_or_else(|| RoutingError::UnknownDescriptor(candidate.descriptor_id.clone()))?;
647 let missing_capabilities = requirements
648 .required_capabilities
649 .iter()
650 .copied()
651 .filter(|capability| !capability.is_supported_by(&descriptor.capabilities))
652 .collect::<BTreeSet<_>>();
653 let estimated_max_cost_microusd = descriptor
654 .pricing
655 .as_ref()
656 .map(|pricing| {
657 pricing.estimate(
658 requirements.max_input_tokens,
659 requirements.reserved_output_tokens,
660 )
661 })
662 .transpose()?;
663 let mut reasons = BTreeSet::new();
664 if !candidate.enabled {
665 reasons.insert(RoutingExclusionReason::Disabled);
666 }
667 if !candidate.credential_available {
668 reasons.insert(RoutingExclusionReason::CredentialUnavailable);
669 }
670 if !missing_capabilities.is_empty() {
671 reasons.insert(RoutingExclusionReason::MissingCapability);
672 }
673 if descriptor.context_tokens < minimum_context_tokens {
674 reasons.insert(RoutingExclusionReason::ContextWindowTooSmall);
675 }
676 if requirements.require_known_pricing && descriptor.pricing.is_none() {
677 reasons.insert(RoutingExclusionReason::PricingUnknown);
678 }
679 if let Some(maximum) = requirements.max_estimated_cost_microusd {
680 match estimated_max_cost_microusd {
681 Some(estimate) if estimate > maximum => {
682 reasons.insert(RoutingExclusionReason::CostBudgetExceeded);
683 }
684 None => {
685 reasons.insert(RoutingExclusionReason::PricingUnknown);
686 }
687 Some(_) => {}
688 }
689 }
690 if requirements.require_known_latency && descriptor.expected_latency_ms.is_none() {
691 reasons.insert(RoutingExclusionReason::LatencyUnknown);
692 }
693 if let Some(maximum) = requirements.max_latency_ms {
694 match descriptor.expected_latency_ms {
695 Some(estimate) if estimate > maximum => {
696 reasons.insert(RoutingExclusionReason::LatencyBudgetExceeded);
697 }
698 None => {
699 reasons.insert(RoutingExclusionReason::LatencyUnknown);
700 }
701 Some(_) => {}
702 }
703 }
704 if let Some(locked) = bindings.locked_candidate_id.as_deref()
705 && candidate.id != locked
706 {
707 reasons.insert(RoutingExclusionReason::SessionRouteLocked);
708 }
709 selections.push(RoutingSelection {
710 candidate,
711 eligible: reasons.is_empty(),
712 reasons,
713 missing_capabilities,
714 estimated_max_cost_microusd,
715 });
716 }
717
718 if bindings.locked_candidate_id.is_none() && bindings.side_effect_watermark > 0 {
719 let mut retained_one = false;
720 for selection in &mut selections {
721 if selection.eligible && !retained_one {
722 retained_one = true;
723 } else if selection.eligible {
724 selection
725 .reasons
726 .insert(RoutingExclusionReason::FallbackBlockedAfterSideEffect);
727 selection.eligible = false;
728 }
729 }
730 }
731 Ok(selections)
732}
733
734#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
736#[serde(rename_all = "snake_case")]
737pub enum RoutingAttemptOutcome {
738 FailedBeforeVisibleOutput,
740 FailedAfterVisibleOutput,
742 Succeeded,
744 Cancelled,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
750#[serde(rename_all = "camelCase", deny_unknown_fields)]
751pub struct RoutingAttemptReceipt {
752 pub candidate_id: String,
754 pub outcome: RoutingAttemptOutcome,
756 #[serde(skip_serializing_if = "Option::is_none")]
758 pub error_hash: Option<String>,
759}
760
761#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
763#[serde(rename_all = "snake_case")]
764pub enum RoutingReceiptOutcome {
765 Selected,
767 Exhausted,
769 Blocked,
771 NotInvoked,
773 Cancelled,
775 Ambiguous,
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
781#[serde(rename_all = "camelCase", deny_unknown_fields)]
782pub struct RoutingReceipt {
783 pub schema_version: String,
785 pub algorithm_version: String,
787 pub plan_hash: String,
789 pub step: u64,
791 pub workspace_generation: u64,
793 pub state_binding: String,
795 pub side_effect_watermark: u64,
797 pub attempts: Vec<RoutingAttemptReceipt>,
799 #[serde(skip_serializing_if = "Option::is_none")]
801 pub selected_candidate_id: Option<String>,
802 pub outcome: RoutingReceiptOutcome,
804}
805
806impl RoutingReceipt {
807 pub fn new(
809 plan: &RoutingPlan,
810 attempts: Vec<RoutingAttemptReceipt>,
811 selected_candidate_id: Option<String>,
812 outcome: RoutingReceiptOutcome,
813 ) -> Result<Self, RoutingError> {
814 let receipt = Self {
815 schema_version: SCHEMA_VERSION.to_owned(),
816 algorithm_version: ROUTING_ALGORITHM_VERSION.to_owned(),
817 plan_hash: plan.digest()?,
818 step: plan.bindings.step,
819 workspace_generation: plan.bindings.workspace_generation,
820 state_binding: plan.bindings.state_binding.clone(),
821 side_effect_watermark: plan.bindings.side_effect_watermark,
822 attempts,
823 selected_candidate_id,
824 outcome,
825 };
826 receipt.validate(plan)?;
827 Ok(receipt)
828 }
829
830 pub fn validate(&self, plan: &RoutingPlan) -> Result<(), RoutingError> {
832 plan.validate()?;
833 if self.schema_version != SCHEMA_VERSION {
834 return Err(RoutingError::UnsupportedSchema(self.schema_version.clone()));
835 }
836 if self.algorithm_version != ROUTING_ALGORITHM_VERSION {
837 return Err(RoutingError::UnsupportedAlgorithm(
838 self.algorithm_version.clone(),
839 ));
840 }
841 if self.plan_hash != plan.digest()?
842 || self.step != plan.bindings.step
843 || self.workspace_generation != plan.bindings.workspace_generation
844 || self.state_binding != plan.bindings.state_binding
845 || self.side_effect_watermark != plan.bindings.side_effect_watermark
846 {
847 return Err(RoutingError::ReceiptBinding);
848 }
849 let eligible = plan.eligible_candidate_ids();
850 if self.attempts.len() > eligible.len() {
851 return Err(RoutingError::ReceiptAttempts);
852 }
853 for (index, attempt) in self.attempts.iter().enumerate() {
854 if eligible.get(index) != Some(&attempt.candidate_id)
855 || !valid_digest(&attempt.candidate_id)
856 {
857 return Err(RoutingError::ReceiptAttempts);
858 }
859 match attempt.outcome {
860 RoutingAttemptOutcome::Succeeded => {
861 if attempt.error_hash.is_some() || index + 1 != self.attempts.len() {
862 return Err(RoutingError::ReceiptAttempts);
863 }
864 }
865 RoutingAttemptOutcome::FailedBeforeVisibleOutput
866 | RoutingAttemptOutcome::FailedAfterVisibleOutput
867 | RoutingAttemptOutcome::Cancelled => {
868 if !attempt.error_hash.as_deref().is_some_and(valid_digest) {
869 return Err(RoutingError::ReceiptAttempts);
870 }
871 }
872 }
873 }
874 let last = self.attempts.last().map(|attempt| attempt.outcome);
875 match self.outcome {
876 RoutingReceiptOutcome::Selected => {
877 let Some(selected) = self.selected_candidate_id.as_deref() else {
878 return Err(RoutingError::ReceiptOutcome);
879 };
880 if last != Some(RoutingAttemptOutcome::Succeeded)
881 || self
882 .attempts
883 .last()
884 .map(|attempt| attempt.candidate_id.as_str())
885 != Some(selected)
886 {
887 return Err(RoutingError::ReceiptOutcome);
888 }
889 }
890 RoutingReceiptOutcome::Exhausted => {
891 if self.selected_candidate_id.is_some()
892 || self.attempts.len() != eligible.len()
893 || self.attempts.iter().any(|attempt| {
894 attempt.outcome != RoutingAttemptOutcome::FailedBeforeVisibleOutput
895 })
896 {
897 return Err(RoutingError::ReceiptOutcome);
898 }
899 }
900 RoutingReceiptOutcome::Blocked => {
901 let blocked_without_attempt = eligible.is_empty() && self.attempts.is_empty();
902 let blocked_after_visible = last
903 == Some(RoutingAttemptOutcome::FailedAfterVisibleOutput)
904 && self.selected_candidate_id.is_none();
905 if !blocked_without_attempt && !blocked_after_visible {
906 return Err(RoutingError::ReceiptOutcome);
907 }
908 }
909 RoutingReceiptOutcome::NotInvoked | RoutingReceiptOutcome::Ambiguous => {
910 if eligible.is_empty()
911 || !self.attempts.is_empty()
912 || self.selected_candidate_id.is_some()
913 {
914 return Err(RoutingError::ReceiptOutcome);
915 }
916 }
917 RoutingReceiptOutcome::Cancelled => {
918 let cancelled_before_attempt = eligible.len() > self.attempts.len()
919 && self.attempts.iter().all(|attempt| {
920 attempt.outcome == RoutingAttemptOutcome::FailedBeforeVisibleOutput
921 });
922 let cancelled_during_attempt = last == Some(RoutingAttemptOutcome::Cancelled);
923 if self.selected_candidate_id.is_some()
924 || (!cancelled_before_attempt && !cancelled_during_attempt)
925 {
926 return Err(RoutingError::ReceiptOutcome);
927 }
928 }
929 }
930 Ok(())
931 }
932
933 pub fn digest(&self, plan: &RoutingPlan) -> Result<String, RoutingError> {
935 self.validate(plan)?;
936 let value = serde_json::to_value(self).map_err(|_| RoutingError::Serialization)?;
937 Ok(hash_json(&value))
938 }
939}
940
941fn valid_digest(value: &str) -> bool {
942 value.len() == 64
943 && value
944 .bytes()
945 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
946}
947
948#[derive(Debug, Clone, Error, PartialEq, Eq)]
950pub enum RoutingError {
951 #[error("unsupported routing schema: {0}")]
953 UnsupportedSchema(String),
954 #[error("unsupported routing algorithm: {0}")]
956 UnsupportedAlgorithm(String),
957 #[error("unsupported model catalog: {0}")]
959 UnsupportedCatalog(String),
960 #[error("routing identity is empty")]
962 EmptyIdentity,
963 #[error("invalid model descriptor: {0}")]
965 InvalidDescriptor(String),
966 #[error("model descriptor digest is invalid: {0}")]
968 DescriptorDigest(String),
969 #[error("model catalog is empty")]
971 EmptyCatalog,
972 #[error("model catalog descriptors are not strictly digest-sorted")]
974 CatalogOrder,
975 #[error("routing candidate is invalid: {0}")]
977 InvalidCandidate(String),
978 #[error("routing candidate digest is invalid: {0}")]
980 CandidateDigest(String),
981 #[error("routing candidate list is empty")]
983 EmptyCandidates,
984 #[error("routing candidates are not in deterministic preference order")]
986 CandidateOrder,
987 #[error("duplicate routing candidate: {0}")]
989 DuplicateCandidate(String),
990 #[error("routing candidate references an unknown descriptor: {0}")]
992 UnknownDescriptor(String),
993 #[error("locked routing candidate is absent: {0}")]
995 UnknownLockedCandidate(String),
996 #[error("routing requirements are invalid")]
998 InvalidRequirements,
999 #[error("routing binding is not a canonical digest: {0}")]
1001 InvalidBinding(String),
1002 #[error("model catalog digest does not match the routing binding")]
1004 CatalogBinding,
1005 #[error("routing selections disagree with deterministic reduction")]
1007 SelectionMismatch,
1008 #[error("routing cost estimate overflowed")]
1010 CostOverflow,
1011 #[error("routing contract serialization failed")]
1013 Serialization,
1014 #[error("routing receipt does not match plan bindings")]
1016 ReceiptBinding,
1017 #[error("routing receipt attempt sequence is invalid")]
1019 ReceiptAttempts,
1020 #[error("routing receipt outcome is inconsistent")]
1022 ReceiptOutcome,
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027 use super::*;
1028 use crate::hash_bytes;
1029
1030 fn descriptor(model: &str, tools: bool, price: Option<u64>) -> ModelDescriptor {
1031 ModelDescriptor::new(
1032 "mock",
1033 model,
1034 ProviderCapabilities {
1035 streaming: false,
1036 tools,
1037 structured_output: true,
1038 multimodal_input: false,
1039 context_tokens: Some(32_768),
1040 },
1041 32_768,
1042 RoutingContextSource::Provider,
1043 price.map(|rate| ModelPricing {
1044 input_microusd_per_million_tokens: rate,
1045 output_microusd_per_million_tokens: rate,
1046 }),
1047 Some(1_000),
1048 )
1049 .unwrap()
1050 }
1051
1052 fn plan(lock: Option<String>, side_effects: u64) -> RoutingPlan {
1053 let first = descriptor("a", true, Some(1_000));
1054 let second = descriptor("b", true, Some(2_000));
1055 let catalog = ModelCatalog::new(vec![second.clone(), first.clone()]).unwrap();
1056 let candidates = vec![
1057 RoutingCandidate::new(
1058 first.id.clone(),
1059 0,
1060 "primary",
1061 hash_bytes(b"primary-authority"),
1062 true,
1063 true,
1064 )
1065 .unwrap(),
1066 RoutingCandidate::new(
1067 second.id.clone(),
1068 1,
1069 "fallback",
1070 hash_bytes(b"fallback-authority"),
1071 true,
1072 true,
1073 )
1074 .unwrap(),
1075 ];
1076 let bindings = RoutingBindings {
1077 contract_hash: hash_bytes(b"contract"),
1078 policy_hash: hash_bytes(b"policy"),
1079 tool_authority_hash: None,
1080 catalog_hash: catalog.digest().unwrap(),
1081 run_controls_hash: hash_bytes(b"resume"),
1082 step: 0,
1083 workspace_generation: 0,
1084 state_binding: hash_bytes(b"workspace"),
1085 side_effect_watermark: side_effects,
1086 locked_candidate_id: lock,
1087 };
1088 RoutingPlan::build(
1089 catalog,
1090 RoutingRequirements {
1091 required_capabilities: [RoutingCapability::Tools].into_iter().collect(),
1092 min_context_tokens: 8_192,
1093 max_input_tokens: 8_192,
1094 reserved_output_tokens: 1_024,
1095 max_estimated_cost_microusd: Some(20),
1096 max_latency_ms: Some(2_000),
1097 require_known_pricing: true,
1098 require_known_latency: true,
1099 },
1100 bindings,
1101 candidates,
1102 )
1103 .unwrap()
1104 }
1105
1106 #[test]
1107 fn plan_is_canonical_and_content_addressed() {
1108 let first = plan(None, 0);
1109 let second = plan(None, 0);
1110 assert_eq!(first, second);
1111 assert_eq!(first.digest().unwrap(), second.digest().unwrap());
1112 assert_eq!(first.eligible_candidate_ids().len(), 2);
1113 }
1114
1115 #[test]
1116 fn side_effects_and_route_lock_remove_fallbacks() {
1117 let side_effect_plan = plan(None, 1);
1118 assert_eq!(side_effect_plan.eligible_candidate_ids().len(), 1);
1119 assert!(
1120 side_effect_plan.selections[1]
1121 .reasons
1122 .contains(&RoutingExclusionReason::FallbackBlockedAfterSideEffect)
1123 );
1124 let lock = side_effect_plan.selections[1].candidate.id.clone();
1125 let locked = plan(Some(lock.clone()), 0);
1126 assert_eq!(locked.eligible_candidate_ids(), vec![lock]);
1127 assert!(
1128 locked.selections[0]
1129 .reasons
1130 .contains(&RoutingExclusionReason::SessionRouteLocked)
1131 );
1132 }
1133
1134 #[test]
1135 fn output_reserve_that_consumes_the_context_excludes_every_candidate() {
1136 let base = plan(None, 0);
1137 let candidates = base
1138 .selections
1139 .iter()
1140 .map(|selection| selection.candidate.clone())
1141 .collect();
1142 let routed = RoutingPlan::build(
1143 base.catalog,
1144 RoutingRequirements {
1145 min_context_tokens: 1,
1146 max_input_tokens: 1,
1147 reserved_output_tokens: 32_768,
1148 ..base.requirements
1149 },
1150 base.bindings,
1151 candidates,
1152 )
1153 .unwrap();
1154
1155 assert!(routed.eligible_candidate_ids().is_empty());
1156 assert!(routed.selections.iter().all(|selection| {
1157 selection
1158 .reasons
1159 .contains(&RoutingExclusionReason::ContextWindowTooSmall)
1160 }));
1161 }
1162
1163 #[test]
1164 fn priced_budget_excludes_unknown_and_expensive_models() {
1165 let cheap = descriptor("cheap", true, Some(1));
1166 let unknown = descriptor("unknown", true, None);
1167 let expensive = descriptor("expensive", true, Some(1_000_000));
1168 let catalog =
1169 ModelCatalog::new(vec![cheap.clone(), unknown.clone(), expensive.clone()]).unwrap();
1170 let candidates = [cheap, unknown, expensive]
1171 .into_iter()
1172 .enumerate()
1173 .map(|(rank, descriptor)| {
1174 RoutingCandidate::new(
1175 descriptor.id,
1176 u32::try_from(rank).unwrap(),
1177 format!("p{rank}"),
1178 hash_bytes(format!("authority-{rank}")),
1179 true,
1180 true,
1181 )
1182 .unwrap()
1183 })
1184 .collect();
1185 let bindings = RoutingBindings {
1186 contract_hash: hash_bytes(b"contract"),
1187 policy_hash: hash_bytes(b"policy"),
1188 tool_authority_hash: None,
1189 catalog_hash: catalog.digest().unwrap(),
1190 run_controls_hash: hash_bytes(b"resume"),
1191 step: 0,
1192 workspace_generation: 0,
1193 state_binding: hash_bytes(b"workspace"),
1194 side_effect_watermark: 0,
1195 locked_candidate_id: None,
1196 };
1197 let routed = RoutingPlan::build(
1198 catalog,
1199 RoutingRequirements {
1200 required_capabilities: BTreeSet::new(),
1201 min_context_tokens: 1,
1202 max_input_tokens: 10_000,
1203 reserved_output_tokens: 1_000,
1204 max_estimated_cost_microusd: Some(100),
1205 max_latency_ms: None,
1206 require_known_pricing: true,
1207 require_known_latency: false,
1208 },
1209 bindings,
1210 candidates,
1211 )
1212 .unwrap();
1213 assert_eq!(routed.eligible_candidate_ids().len(), 1);
1214 assert!(routed.selections.iter().any(|selection| {
1215 selection
1216 .reasons
1217 .contains(&RoutingExclusionReason::PricingUnknown)
1218 }));
1219 assert!(routed.selections.iter().any(|selection| {
1220 selection
1221 .reasons
1222 .contains(&RoutingExclusionReason::CostBudgetExceeded)
1223 }));
1224 }
1225
1226 #[test]
1227 fn receipt_commits_exact_attempt_prefix_and_selection() {
1228 let plan = plan(None, 0);
1229 let eligible = plan.eligible_candidate_ids();
1230 let receipt = RoutingReceipt::new(
1231 &plan,
1232 vec![
1233 RoutingAttemptReceipt {
1234 candidate_id: eligible[0].clone(),
1235 outcome: RoutingAttemptOutcome::FailedBeforeVisibleOutput,
1236 error_hash: Some(hash_bytes(b"transport")),
1237 },
1238 RoutingAttemptReceipt {
1239 candidate_id: eligible[1].clone(),
1240 outcome: RoutingAttemptOutcome::Succeeded,
1241 error_hash: None,
1242 },
1243 ],
1244 Some(eligible[1].clone()),
1245 RoutingReceiptOutcome::Selected,
1246 )
1247 .unwrap();
1248 receipt.validate(&plan).unwrap();
1249 assert!(valid_digest(&receipt.digest(&plan).unwrap()));
1250 }
1251
1252 #[test]
1253 fn plan_and_receipt_tampering_fail_closed() {
1254 let mut routed = plan(None, 0);
1255 routed.selections[0].eligible = false;
1256 assert_eq!(routed.validate(), Err(RoutingError::SelectionMismatch));
1257
1258 let routed = plan(None, 0);
1259 let error =
1260 RoutingReceipt::new(&routed, Vec::new(), None, RoutingReceiptOutcome::Exhausted)
1261 .unwrap_err();
1262 assert_eq!(error, RoutingError::ReceiptOutcome);
1263
1264 let mut unknown_field = serde_json::to_value(&routed).unwrap();
1265 unknown_field["uncommittedDiagnostic"] = serde_json::json!(true);
1266 assert!(serde_json::from_value::<RoutingPlan>(unknown_field).is_err());
1267 }
1268}