1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::hash::{Hash, Hasher};
3
4use anyhow::{Context, Result};
5
6use crate::config::{
7 ProviderConcurrencyLimits, ProviderConfigV4, RoutingAffinityPolicyV5, RoutingConditionV4,
8 RoutingConfigV4, RoutingExhaustedActionV4, RoutingNodeV4, RoutingPolicyV4, ServiceConfig,
9 ServiceViewV4, UpstreamAuth, UpstreamConfig, effective_v4_routing,
10};
11use crate::lb::{FAILURE_THRESHOLD, SelectedUpstream};
12use crate::model_routing;
13use crate::runtime_identity::{
14 ContinuityDomainKey, LegacyUpstreamKey, ProviderEndpointKey, RuntimeUpstreamIdentity,
15};
16
17const V4_COMPATIBILITY_STATION_NAME: &str = "routing";
18
19#[derive(Debug, Clone)]
20pub struct RoutePlanTemplate {
21 pub service_name: String,
22 pub entry: String,
23 pub affinity_policy: RoutingAffinityPolicyV5,
24 pub fallback_ttl_ms: Option<u64>,
25 pub reprobe_preferred_after_ms: Option<u64>,
26 pub nodes: BTreeMap<String, RouteNodePlan>,
27 pub expanded_provider_order: Vec<String>,
28 pub candidates: Vec<RouteCandidate>,
29 pub compatibility_station_name: Option<String>,
30}
31
32impl RoutePlanTemplate {
33 pub fn route_graph_key(&self) -> String {
34 let mut hasher = std::collections::hash_map::DefaultHasher::new();
35 self.service_name.hash(&mut hasher);
36 self.entry.hash(&mut hasher);
37 self.affinity_policy.hash(&mut hasher);
38 self.fallback_ttl_ms.hash(&mut hasher);
39 self.reprobe_preferred_after_ms.hash(&mut hasher);
40 self.compatibility_station_name.hash(&mut hasher);
41 self.expanded_provider_order.hash(&mut hasher);
42 hash_route_nodes(&self.nodes, &mut hasher);
43 for candidate in &self.candidates {
44 hash_route_candidate(candidate, &mut hasher);
45 }
46 format!("v4:{:016x}", hasher.finish())
47 }
48
49 pub fn contains_provider_endpoint(&self, key: &ProviderEndpointKey, base_url: &str) -> bool {
50 key.service_name == self.service_name
51 && self.candidates.iter().any(|candidate| {
52 candidate.provider_id == key.provider_id
53 && candidate.endpoint_id == key.endpoint_id
54 && candidate.base_url == base_url
55 })
56 }
57
58 pub fn candidate_provider_endpoint_key(
59 &self,
60 candidate: &RouteCandidate,
61 ) -> ProviderEndpointKey {
62 candidate_provider_endpoint_key(self, candidate)
63 }
64
65 pub fn candidate_continuity_domain_key(
66 &self,
67 candidate: &RouteCandidate,
68 ) -> ContinuityDomainKey {
69 let provider_endpoint = candidate_provider_endpoint_key(self, candidate);
70 candidate
71 .continuity_domain
72 .as_ref()
73 .and_then(|domain| ContinuityDomainKey::explicit(self.service_name.clone(), domain))
74 .unwrap_or_else(|| ContinuityDomainKey::provider_endpoint(provider_endpoint))
75 }
76
77 pub fn candidate_identity(&self, candidate: &RouteCandidate) -> RuntimeUpstreamIdentity {
78 RuntimeUpstreamIdentity::new_with_continuity_domain(
79 candidate_provider_endpoint_key(self, candidate),
80 self.candidate_compatibility_key(candidate),
81 candidate.base_url.clone(),
82 candidate.continuity_domain.clone(),
83 )
84 }
85
86 pub fn candidate_identities(&self) -> Vec<RuntimeUpstreamIdentity> {
87 self.candidates
88 .iter()
89 .map(|candidate| self.candidate_identity(candidate))
90 .collect()
91 }
92
93 pub fn continuity_topology(&self) -> RoutePlanContinuityTopology<'_> {
94 RoutePlanContinuityTopology { template: self }
95 }
96
97 pub fn candidate_compatibility_key(
98 &self,
99 candidate: &RouteCandidate,
100 ) -> Option<LegacyUpstreamKey> {
101 candidate
102 .compatibility_station_name
103 .as_ref()
104 .or(self.compatibility_station_name.as_ref())
105 .and_then(|station_name| {
106 candidate
107 .compatibility_upstream_index
108 .map(|upstream_index| {
109 LegacyUpstreamKey::new(
110 self.service_name.clone(),
111 station_name.clone(),
112 upstream_index,
113 )
114 })
115 })
116 }
117}
118
119#[derive(Debug, Clone, Copy)]
120pub struct RoutePlanContinuityTopology<'a> {
121 template: &'a RoutePlanTemplate,
122}
123
124impl RoutePlanContinuityTopology<'_> {
125 pub fn configured_provider_endpoint_count(&self) -> usize {
126 self.template
127 .candidates
128 .iter()
129 .map(|candidate| self.template.candidate_provider_endpoint_key(candidate))
130 .collect::<BTreeSet<_>>()
131 .len()
132 }
133
134 pub fn candidate_domain(&self, candidate: &RouteCandidate) -> ContinuityDomainKey {
135 self.template.candidate_continuity_domain_key(candidate)
136 }
137
138 pub fn find_candidate_by_provider_endpoint_stable_key(
139 &self,
140 provider_endpoint_stable_key: &str,
141 ) -> Option<&RouteCandidate> {
142 self.template.candidates.iter().find(|candidate| {
143 self.template
144 .candidate_provider_endpoint_key(candidate)
145 .stable_key()
146 == provider_endpoint_stable_key
147 })
148 }
149
150 pub fn find_candidate_by_provider_endpoint(
151 &self,
152 provider_endpoint: &ProviderEndpointKey,
153 ) -> Option<&RouteCandidate> {
154 self.template.candidates.iter().find(|candidate| {
155 self.template.candidate_provider_endpoint_key(candidate) == *provider_endpoint
156 })
157 }
158
159 pub fn same_domain_provider_endpoint_count(&self, domain: &ContinuityDomainKey) -> usize {
160 self.template
161 .candidates
162 .iter()
163 .filter(|candidate| self.template.candidate_continuity_domain_key(candidate) == *domain)
164 .map(|candidate| self.template.candidate_provider_endpoint_key(candidate))
165 .collect::<BTreeSet<_>>()
166 .len()
167 }
168
169 pub fn selected_domain_summary(
170 &self,
171 provider_endpoint_stable_key: &str,
172 ) -> Option<RoutePlanContinuityDomainSummary> {
173 let selected =
174 self.find_candidate_by_provider_endpoint_stable_key(provider_endpoint_stable_key)?;
175 let domain = self.candidate_domain(selected);
176 Some(RoutePlanContinuityDomainSummary {
177 domain: domain.clone(),
178 same_domain_endpoint_count: self.same_domain_provider_endpoint_count(&domain).max(1),
179 })
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct RoutePlanContinuityDomainSummary {
185 pub domain: ContinuityDomainKey,
186 pub same_domain_endpoint_count: usize,
187}
188
189fn candidate_provider_endpoint_key(
190 template: &RoutePlanTemplate,
191 candidate: &RouteCandidate,
192) -> ProviderEndpointKey {
193 ProviderEndpointKey::new(
194 template.service_name.clone(),
195 candidate.provider_id.clone(),
196 candidate.endpoint_id.clone(),
197 )
198}
199
200fn hash_route_nodes<H: Hasher>(nodes: &BTreeMap<String, RouteNodePlan>, hasher: &mut H) {
204 for (name, node) in nodes {
205 name.hash(hasher);
206 hash_route_node(node, hasher);
207 }
208}
209
210fn hash_route_node<H: Hasher>(node: &RouteNodePlan, hasher: &mut H) {
211 node.strategy.hash(hasher);
212 node.children.hash(hasher);
213 node.target.hash(hasher);
214 node.prefer_tags.hash(hasher);
215 node.on_exhausted.hash(hasher);
216 node.when.hash(hasher);
217 node.then.hash(hasher);
218 node.default_route.hash(hasher);
219}
220
221fn hash_route_candidate<H: Hasher>(candidate: &RouteCandidate, hasher: &mut H) {
222 candidate.provider_id.hash(hasher);
223 candidate.endpoint_id.hash(hasher);
224 candidate.base_url.hash(hasher);
225 candidate.continuity_domain.hash(hasher);
226 candidate.tags.hash(hasher);
227 candidate.supported_models.hash(hasher);
228 candidate.model_mapping.hash(hasher);
229 candidate.route_path.hash(hasher);
230 candidate.preference_group.hash(hasher);
231 candidate.stable_index.hash(hasher);
232 candidate.concurrency.hash(hasher);
233 candidate.compatibility_station_name.hash(hasher);
234 candidate.compatibility_upstream_index.hash(hasher);
235}
236
237#[derive(Debug, Clone)]
238pub struct RoutePlan {
239 pub service_name: String,
240 pub entry: String,
241 pub candidates: Vec<RouteCandidate>,
242 pub decision_trace: RouteDecisionTrace,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct RouteNodePlan {
247 pub name: String,
248 pub strategy: RoutingPolicyV4,
249 pub children: Vec<RouteRef>,
250 pub target: Option<RouteRef>,
251 pub prefer_tags: Vec<BTreeMap<String, String>>,
252 pub on_exhausted: RoutingExhaustedActionV4,
253 pub metadata: BTreeMap<String, String>,
254 pub when: Option<RoutingConditionV4>,
255 pub then: Option<RouteRef>,
256 pub default_route: Option<RouteRef>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Hash)]
260pub enum RouteRef {
261 Route(String),
262 Provider(String),
263 ProviderEndpoint {
264 provider_id: String,
265 endpoint_id: String,
266 },
267}
268
269#[derive(Debug, Clone)]
270pub struct RouteCandidate {
271 pub provider_id: String,
272 pub provider_alias: Option<String>,
273 pub endpoint_id: String,
274 pub base_url: String,
275 pub continuity_domain: Option<String>,
276 pub auth: UpstreamAuth,
277 pub tags: BTreeMap<String, String>,
278 pub supported_models: BTreeMap<String, bool>,
279 pub model_mapping: BTreeMap<String, String>,
280 pub route_path: Vec<String>,
281 pub preference_group: u32,
282 pub stable_index: usize,
283 pub concurrency: RouteCandidateConcurrency,
284 pub compatibility_station_name: Option<String>,
285 pub compatibility_upstream_index: Option<usize>,
286}
287
288#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
289pub struct RouteCandidateConcurrency {
290 pub max_concurrent_requests: Option<u32>,
291 pub limit_group: Option<String>,
292}
293
294impl RouteCandidateConcurrency {
295 pub fn is_limited(&self) -> bool {
296 self.max_concurrent_requests.is_some()
297 }
298
299 pub fn limit_key(
300 &self,
301 service_name: &str,
302 provider_endpoint: &ProviderEndpointKey,
303 ) -> Option<String> {
304 self.max_concurrent_requests?;
305 if let Some(scope) = self
306 .limit_group
307 .as_deref()
308 .map(str::trim)
309 .filter(|value| !value.is_empty())
310 {
311 return Some(format!("{service_name}/{scope}"));
312 }
313 Some(provider_endpoint.stable_key())
314 }
315}
316
317impl RouteCandidate {
318 pub fn to_upstream_config(&self) -> UpstreamConfig {
319 let mut tags = self.tags.clone();
320 tags.insert("endpoint_id".to_string(), self.endpoint_id.clone());
321 if let Ok(route_path) = serde_json::to_string(&self.route_path) {
322 tags.insert("route_path".to_string(), route_path);
323 }
324 tags.insert(
325 "preference_group".to_string(),
326 self.preference_group.to_string(),
327 );
328 UpstreamConfig {
329 base_url: self.base_url.clone(),
330 auth: self.auth.clone(),
331 tags: btree_string_map_to_hash_map(&tags),
332 supported_models: btree_bool_map_to_hash_map(&self.supported_models),
333 model_mapping: btree_string_map_to_hash_map(&self.model_mapping),
334 }
335 }
336}
337
338#[derive(Debug, Clone)]
339pub struct SelectedRouteCandidate<'a> {
340 pub candidate: &'a RouteCandidate,
341 pub provider_endpoint: ProviderEndpointKey,
342}
343
344#[derive(Debug, Clone, Default, PartialEq, Eq)]
345pub struct RoutePlanAttemptState {
346 avoided_provider_endpoints: BTreeSet<ProviderEndpointKey>,
347 allowed_continuity_domain: Option<ContinuityDomainKey>,
348 avoid_by_station: BTreeMap<String, BTreeSet<usize>>,
349 avoided_total: usize,
350}
351
352impl RoutePlanAttemptState {
353 pub fn avoid_provider_endpoint(&mut self, key: ProviderEndpointKey) -> bool {
354 if self.avoided_provider_endpoints.insert(key) {
355 self.avoided_total = self.avoided_total.saturating_add(1);
356 return true;
357 }
358 false
359 }
360
361 pub fn avoids_provider_endpoint(&self, key: &ProviderEndpointKey) -> bool {
362 self.avoided_provider_endpoints.contains(key)
363 }
364
365 pub fn restrict_to_continuity_domain(&mut self, continuity_domain: ContinuityDomainKey) {
366 self.allowed_continuity_domain = Some(continuity_domain);
367 }
368
369 pub fn allowed_continuity_domain(&self) -> Option<&ContinuityDomainKey> {
370 self.allowed_continuity_domain.as_ref()
371 }
372
373 pub fn allows_explicit_continuity_domain_failover(&self) -> bool {
374 self.allowed_continuity_domain
375 .as_ref()
376 .is_some_and(ContinuityDomainKey::is_explicit)
377 }
378
379 pub fn avoid_candidate(
380 &mut self,
381 template: &RoutePlanTemplate,
382 candidate: &RouteCandidate,
383 ) -> bool {
384 self.avoid_provider_endpoint(candidate_provider_endpoint_key(template, candidate))
385 }
386
387 pub fn avoids_candidate(
388 &self,
389 template: &RoutePlanTemplate,
390 candidate: &RouteCandidate,
391 ) -> bool {
392 self.avoids_provider_endpoint(&candidate_provider_endpoint_key(template, candidate))
393 || self
394 .allowed_continuity_domain
395 .as_ref()
396 .is_some_and(|domain| {
397 template.candidate_continuity_domain_key(candidate) != *domain
398 })
399 }
400
401 pub fn avoid_upstream(&mut self, station_name: &str, upstream_index: usize) -> bool {
402 if self
403 .avoid_by_station
404 .entry(station_name.to_string())
405 .or_default()
406 .insert(upstream_index)
407 {
408 self.avoided_total = self.avoided_total.saturating_add(1);
409 return true;
410 }
411 false
412 }
413
414 pub fn avoid_selected(&mut self, selected: &SelectedRouteCandidate<'_>) -> bool {
415 self.avoid_provider_endpoint(selected.provider_endpoint.clone())
416 }
417
418 pub fn avoid_selected_upstream(&mut self, selected: &SelectedUpstream) -> bool {
419 self.avoid_upstream(selected.station_name.as_str(), selected.index)
420 }
421
422 pub fn avoids_upstream(&self, station_name: &str, upstream_index: usize) -> bool {
423 self.avoid_by_station
424 .get(station_name)
425 .is_some_and(|indices| indices.contains(&upstream_index))
426 }
427
428 pub fn avoid_for_station_name(&self, station_name: &str) -> Vec<usize> {
429 self.avoid_by_station
430 .get(station_name)
431 .map(|indices| indices.iter().copied().collect())
432 .unwrap_or_default()
433 }
434
435 pub fn avoided_total(&self) -> usize {
436 self.avoided_total
437 }
438
439 pub fn station_exhausted_for(&self, station_name: &str, upstream_total: usize) -> bool {
440 upstream_total > 0
441 && self
442 .avoid_by_station
443 .get(station_name)
444 .map(|indices| indices.iter().filter(|&&idx| idx < upstream_total).count())
445 .unwrap_or_default()
446 >= upstream_total
447 }
448
449 pub fn route_candidates_exhausted(&self, template: &RoutePlanTemplate) -> bool {
450 !template.candidates.is_empty()
451 && template
452 .candidates
453 .iter()
454 .all(|candidate| self.avoids_candidate(template, candidate))
455 }
456
457 pub fn route_avoid_candidate_indices(&self, template: &RoutePlanTemplate) -> Vec<usize> {
458 template
459 .candidates
460 .iter()
461 .filter(|candidate| self.avoids_candidate(template, candidate))
462 .map(|candidate| candidate.stable_index)
463 .collect()
464 }
465}
466
467#[derive(Debug, Clone, Default, PartialEq, Eq)]
468pub struct RoutePlanRuntimeState {
469 provider_endpoints: BTreeMap<ProviderEndpointKey, RoutePlanUpstreamRuntimeState>,
470 affinity_provider_endpoint: Option<ProviderEndpointKey>,
471 affinity_last_selected_at_ms: Option<u64>,
472 affinity_last_changed_at_ms: Option<u64>,
473}
474
475impl RoutePlanRuntimeState {
476 pub fn set_provider_endpoint(
477 &mut self,
478 key: ProviderEndpointKey,
479 state: RoutePlanUpstreamRuntimeState,
480 ) {
481 self.provider_endpoints.insert(key, state);
482 }
483
484 pub fn provider_endpoint(&self, key: &ProviderEndpointKey) -> RoutePlanUpstreamRuntimeState {
485 self.provider_endpoints
486 .get(key)
487 .copied()
488 .unwrap_or_default()
489 }
490
491 pub fn set_affinity_provider_endpoint(&mut self, key: Option<ProviderEndpointKey>) {
492 self.affinity_provider_endpoint = key;
493 self.affinity_last_selected_at_ms = None;
494 self.affinity_last_changed_at_ms = None;
495 }
496
497 pub fn set_affinity_provider_endpoint_with_observed_at(
498 &mut self,
499 key: Option<ProviderEndpointKey>,
500 last_selected_at_ms: Option<u64>,
501 last_changed_at_ms: Option<u64>,
502 ) {
503 self.affinity_provider_endpoint = key;
504 self.affinity_last_selected_at_ms = last_selected_at_ms;
505 self.affinity_last_changed_at_ms = last_changed_at_ms;
506 }
507
508 pub fn affinity_provider_endpoint(&self) -> Option<&ProviderEndpointKey> {
509 self.affinity_provider_endpoint.as_ref()
510 }
511
512 pub fn affinity_last_selected_at_ms(&self) -> Option<u64> {
513 self.affinity_last_selected_at_ms
514 }
515
516 pub fn affinity_last_changed_at_ms(&self) -> Option<u64> {
517 self.affinity_last_changed_at_ms
518 }
519
520 pub fn clear_affinity_provider_endpoint(&mut self) {
521 self.affinity_provider_endpoint = None;
522 self.affinity_last_selected_at_ms = None;
523 self.affinity_last_changed_at_ms = None;
524 }
525
526 fn runtime_state_for_candidate(
527 &self,
528 template: &RoutePlanTemplate,
529 candidate: &RouteCandidate,
530 ) -> RoutePlanUpstreamRuntimeState {
531 self.provider_endpoint(&candidate_provider_endpoint_key(template, candidate))
532 }
533
534 pub fn candidate_runtime_snapshot(
535 &self,
536 template: &RoutePlanTemplate,
537 candidate: &RouteCandidate,
538 ) -> RoutePlanCandidateRuntimeSnapshot {
539 RoutePlanCandidateRuntimeSnapshot::from_candidate_runtime(
540 candidate,
541 self.runtime_state_for_candidate(template, candidate),
542 )
543 }
544}
545
546#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
547pub struct RoutePlanUpstreamRuntimeState {
548 pub runtime_disabled: bool,
549 pub failure_count: u32,
550 pub cooldown_active: bool,
551 pub cooldown_remaining_secs: Option<u64>,
552 pub usage_exhausted: bool,
553 pub missing_auth: bool,
554 pub concurrency_saturated: bool,
555 pub concurrency_active: Option<u32>,
556 pub concurrency_limit: Option<u32>,
557}
558
559impl RoutePlanUpstreamRuntimeState {
560 fn breaker_open(self) -> bool {
561 self.cooldown_active || self.failure_count >= FAILURE_THRESHOLD
562 }
563
564 fn hard_unavailable(self) -> bool {
565 self.runtime_disabled || self.missing_auth || self.breaker_open()
566 }
567}
568
569#[derive(Debug, Clone, Default, PartialEq, Eq)]
570pub struct RoutePlanCandidateRuntimeSnapshot {
571 pub runtime_available: bool,
572 pub routable_except_usage: bool,
573 pub hard_unavailable: bool,
574 pub runtime_disabled: bool,
575 pub cooldown_active: bool,
576 pub cooldown_remaining_secs: Option<u64>,
577 pub breaker_open: bool,
578 pub failure_count: u32,
579 pub usage_exhausted: bool,
580 pub missing_auth: bool,
581 pub concurrency_saturated: bool,
582 pub concurrency_active: Option<u32>,
583 pub concurrency_limit: Option<u32>,
584 pub effective_max_concurrent_requests: Option<u32>,
585 pub effective_limit_group: Option<String>,
586}
587
588impl RoutePlanCandidateRuntimeSnapshot {
589 fn from_candidate_runtime(
590 candidate: &RouteCandidate,
591 runtime_state: RoutePlanUpstreamRuntimeState,
592 ) -> Self {
593 let breaker_open = runtime_state.breaker_open();
594 let hard_unavailable = runtime_state.hard_unavailable();
595 let routable_except_usage = !hard_unavailable && !runtime_state.concurrency_saturated;
596 let runtime_available = routable_except_usage && !runtime_state.usage_exhausted;
597
598 Self {
599 runtime_available,
600 routable_except_usage,
601 hard_unavailable,
602 runtime_disabled: runtime_state.runtime_disabled,
603 cooldown_active: runtime_state.cooldown_active,
604 cooldown_remaining_secs: runtime_state.cooldown_remaining_secs,
605 breaker_open,
606 failure_count: runtime_state.failure_count,
607 usage_exhausted: runtime_state.usage_exhausted,
608 missing_auth: runtime_state.missing_auth,
609 concurrency_saturated: runtime_state.concurrency_saturated,
610 concurrency_active: runtime_state.concurrency_active,
611 concurrency_limit: runtime_state.concurrency_limit,
612 effective_max_concurrent_requests: candidate.concurrency.max_concurrent_requests,
613 effective_limit_group: candidate.concurrency.limit_group.clone(),
614 }
615 }
616
617 pub fn skip_reasons_for_candidate(
618 &self,
619 candidate: &RouteCandidate,
620 request_model: Option<&str>,
621 ) -> Vec<RoutePlanSkipReason> {
622 let mut reasons = Vec::new();
623 if let Some(requested_model) = request_model
624 && !candidate_supports_model(candidate, requested_model)
625 {
626 reasons.push(RoutePlanSkipReason::UnsupportedModel {
627 requested_model: requested_model.to_string(),
628 });
629 }
630 reasons.extend(self.runtime_skip_reasons());
631 reasons
632 }
633
634 pub fn runtime_skip_reasons(&self) -> Vec<RoutePlanSkipReason> {
635 let mut reasons = Vec::new();
636 if self.runtime_disabled {
637 reasons.push(RoutePlanSkipReason::RuntimeDisabled);
638 }
639 if self.cooldown_active {
640 reasons.push(RoutePlanSkipReason::Cooldown);
641 } else if self.failure_count >= FAILURE_THRESHOLD {
642 reasons.push(RoutePlanSkipReason::BreakerOpen {
643 failure_count: self.failure_count,
644 });
645 }
646 if self.usage_exhausted {
647 reasons.push(RoutePlanSkipReason::UsageExhausted);
648 }
649 if self.missing_auth {
650 reasons.push(RoutePlanSkipReason::MissingAuth);
651 }
652 if self.concurrency_saturated {
653 reasons.push(RoutePlanSkipReason::ConcurrencySaturated {
654 active: self.concurrency_active,
655 limit: self.concurrency_limit,
656 });
657 }
658 reasons
659 }
660
661 pub fn dominant_runtime_skip_reason(&self) -> Option<RoutePlanSkipReason> {
662 self.runtime_skip_reasons().into_iter().next()
663 }
664}
665
666#[derive(Debug, Clone, PartialEq, Eq)]
667pub enum RoutePlanSkipReason {
668 UnsupportedModel {
669 requested_model: String,
670 },
671 RuntimeDisabled,
672 Cooldown,
673 BreakerOpen {
674 failure_count: u32,
675 },
676 UsageExhausted,
677 MissingAuth,
678 ConcurrencySaturated {
679 active: Option<u32>,
680 limit: Option<u32>,
681 },
682}
683
684impl RoutePlanSkipReason {
685 pub fn code(&self) -> &'static str {
686 match self {
687 RoutePlanSkipReason::UnsupportedModel { .. } => "unsupported_model",
688 RoutePlanSkipReason::RuntimeDisabled => "runtime_disabled",
689 RoutePlanSkipReason::Cooldown => "cooldown",
690 RoutePlanSkipReason::BreakerOpen { .. } => "breaker_open",
691 RoutePlanSkipReason::UsageExhausted => "usage_exhausted",
692 RoutePlanSkipReason::MissingAuth => "missing_auth",
693 RoutePlanSkipReason::ConcurrencySaturated { .. } => "concurrency_saturated",
694 }
695 }
696}
697
698#[derive(Debug, Clone)]
699pub struct SkippedRouteCandidate<'a> {
700 pub candidate: &'a RouteCandidate,
701 pub provider_endpoint: ProviderEndpointKey,
702 pub reason: RoutePlanSkipReason,
703 pub avoided_candidate_indices: Vec<usize>,
704 pub avoided_total: usize,
705 pub total_upstreams: usize,
706}
707
708#[derive(Debug, Clone)]
709pub struct RouteCandidateSkipExplanation<'a> {
710 pub candidate: &'a RouteCandidate,
711 pub provider_endpoint: ProviderEndpointKey,
712 pub reasons: Vec<RoutePlanSkipReason>,
713}
714
715#[derive(Debug, Clone)]
716pub struct SelectedStationRouteCandidate<'a> {
717 pub candidate: &'a RouteCandidate,
718 pub selected_upstream: SelectedUpstream,
719}
720
721#[derive(Debug, Clone)]
722pub struct SkippedStationRouteCandidate<'a> {
723 pub candidate: &'a RouteCandidate,
724 pub selected_upstream: SelectedUpstream,
725 pub reason: RoutePlanSkipReason,
726 pub avoid_for_station: Vec<usize>,
727 pub avoided_total: usize,
728 pub total_upstreams: usize,
729}
730
731#[derive(Debug, Clone)]
732pub struct RoutePlanAttemptSelection<'a> {
733 pub selected: Option<SelectedRouteCandidate<'a>>,
734 pub skipped: Vec<SkippedRouteCandidate<'a>>,
735 pub avoided_candidate_indices: Vec<usize>,
736 pub avoided_total: usize,
737 pub total_upstreams: usize,
738}
739
740#[derive(Debug, Clone)]
741pub struct RoutePlanStationAttemptSelection<'a> {
742 pub selected: Option<SelectedStationRouteCandidate<'a>>,
743 pub skipped: Vec<SkippedStationRouteCandidate<'a>>,
744 pub avoid_for_station: Vec<usize>,
745 pub avoided_total: usize,
746 pub total_upstreams: usize,
747}
748
749pub struct RoutePlanExecutor<'a> {
750 template: &'a RoutePlanTemplate,
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
754enum RoutePlanAffinitySelectionMode {
755 Configured,
756 SoftSessionPreferred,
757}
758
759impl<'a> RoutePlanExecutor<'a> {
760 pub fn new(template: &'a RoutePlanTemplate) -> Self {
761 Self { template }
762 }
763
764 pub fn iter_candidates(&self) -> impl Iterator<Item = &RouteCandidate> + '_ {
765 self.template.candidates.iter()
766 }
767
768 pub fn template(&self) -> &'a RoutePlanTemplate {
769 self.template
770 }
771
772 pub fn iter_selected_upstreams(
773 &self,
774 ) -> impl Iterator<Item = SelectedStationRouteCandidate<'_>> + '_ {
775 self.template
776 .candidates
777 .iter()
778 .map(|candidate| self.selected_station_route_candidate_for_candidate(candidate))
779 }
780
781 pub fn explain_candidate_skip_reasons_with_runtime_state(
782 &self,
783 runtime: &RoutePlanRuntimeState,
784 request_model: Option<&str>,
785 ) -> Vec<RouteCandidateSkipExplanation<'_>> {
786 self.template
787 .candidates
788 .iter()
789 .filter_map(|candidate| {
790 let provider_endpoint = candidate_provider_endpoint_key(self.template, candidate);
791 let snapshot = runtime.candidate_runtime_snapshot(self.template, candidate);
792 let reasons = snapshot.skip_reasons_for_candidate(candidate, request_model);
793 (!reasons.is_empty()).then_some(RouteCandidateSkipExplanation {
794 candidate,
795 provider_endpoint,
796 reasons,
797 })
798 })
799 .collect()
800 }
801
802 pub fn select_supported_candidate(
803 &self,
804 state: &mut RoutePlanAttemptState,
805 request_model: Option<&str>,
806 ) -> RoutePlanAttemptSelection<'_> {
807 self.select_supported_candidate_with_runtime_state(
808 state,
809 &RoutePlanRuntimeState::default(),
810 request_model,
811 )
812 }
813
814 pub fn select_supported_candidate_with_runtime_state(
815 &self,
816 state: &mut RoutePlanAttemptState,
817 runtime: &RoutePlanRuntimeState,
818 request_model: Option<&str>,
819 ) -> RoutePlanAttemptSelection<'_> {
820 self.select_supported_candidate_with_affinity_mode(
821 state,
822 runtime,
823 request_model,
824 RoutePlanAffinitySelectionMode::Configured,
825 )
826 }
827
828 pub fn select_supported_candidate_with_soft_affinity_runtime_state(
829 &self,
830 state: &mut RoutePlanAttemptState,
831 runtime: &RoutePlanRuntimeState,
832 request_model: Option<&str>,
833 ) -> RoutePlanAttemptSelection<'_> {
834 self.select_supported_candidate_with_affinity_mode(
835 state,
836 runtime,
837 request_model,
838 RoutePlanAffinitySelectionMode::SoftSessionPreferred,
839 )
840 }
841
842 fn select_supported_candidate_with_affinity_mode(
843 &self,
844 state: &mut RoutePlanAttemptState,
845 runtime: &RoutePlanRuntimeState,
846 request_model: Option<&str>,
847 affinity_mode: RoutePlanAffinitySelectionMode,
848 ) -> RoutePlanAttemptSelection<'_> {
849 let total_upstreams = self.template.candidates.len();
850 let mut skipped = Vec::new();
851
852 loop {
853 if self.candidates_exhausted(state) {
854 return RoutePlanAttemptSelection {
855 selected: None,
856 skipped,
857 avoided_candidate_indices: state.route_avoid_candidate_indices(self.template),
858 avoided_total: state.avoided_total(),
859 total_upstreams,
860 };
861 }
862
863 let Some(candidate) = self.next_unavoided_candidate(state, runtime, affinity_mode)
864 else {
865 return RoutePlanAttemptSelection {
866 selected: None,
867 skipped,
868 avoided_candidate_indices: state.route_avoid_candidate_indices(self.template),
869 avoided_total: state.avoided_total(),
870 total_upstreams,
871 };
872 };
873 if let Some(requested_model) = request_model
874 && !candidate_supports_model(candidate, requested_model)
875 {
876 state.avoid_candidate(self.template, candidate);
877 let avoided_candidate_indices = state.route_avoid_candidate_indices(self.template);
878 skipped.push(SkippedRouteCandidate {
879 candidate,
880 provider_endpoint: candidate_provider_endpoint_key(self.template, candidate),
881 reason: RoutePlanSkipReason::UnsupportedModel {
882 requested_model: requested_model.to_string(),
883 },
884 avoided_candidate_indices,
885 avoided_total: state.avoided_total(),
886 total_upstreams,
887 });
888 continue;
889 }
890
891 let avoided_candidate_indices = state.route_avoid_candidate_indices(self.template);
892 return RoutePlanAttemptSelection {
893 selected: Some(self.selected_route_candidate_for_candidate(candidate)),
894 skipped,
895 avoided_candidate_indices,
896 avoided_total: state.avoided_total(),
897 total_upstreams,
898 };
899 }
900 }
901
902 pub fn select_affinity_candidate_with_runtime_state(
903 &self,
904 state: &mut RoutePlanAttemptState,
905 runtime: &RoutePlanRuntimeState,
906 request_model: Option<&str>,
907 ) -> RoutePlanAttemptSelection<'_> {
908 let total_upstreams = self.template.candidates.len();
909 let mut skipped = Vec::new();
910
911 loop {
912 if self.candidates_exhausted(state) {
913 return RoutePlanAttemptSelection {
914 selected: None,
915 skipped,
916 avoided_candidate_indices: state.route_avoid_candidate_indices(self.template),
917 avoided_total: state.avoided_total(),
918 total_upstreams,
919 };
920 }
921
922 let route_candidates = self
923 .template
924 .candidates
925 .iter()
926 .filter(|candidate| !state.avoids_candidate(self.template, candidate))
927 .collect::<Vec<_>>();
928 let Some(candidate) = affinity_candidate(self.template, runtime, &route_candidates)
929 else {
930 return RoutePlanAttemptSelection {
931 selected: None,
932 skipped,
933 avoided_candidate_indices: state.route_avoid_candidate_indices(self.template),
934 avoided_total: state.avoided_total(),
935 total_upstreams,
936 };
937 };
938 if let Some(requested_model) = request_model
939 && !candidate_supports_model(candidate, requested_model)
940 {
941 state.avoid_candidate(self.template, candidate);
942 let avoided_candidate_indices = state.route_avoid_candidate_indices(self.template);
943 skipped.push(SkippedRouteCandidate {
944 candidate,
945 provider_endpoint: candidate_provider_endpoint_key(self.template, candidate),
946 reason: RoutePlanSkipReason::UnsupportedModel {
947 requested_model: requested_model.to_string(),
948 },
949 avoided_candidate_indices,
950 avoided_total: state.avoided_total(),
951 total_upstreams,
952 });
953 continue;
954 }
955
956 let avoided_candidate_indices = state.route_avoid_candidate_indices(self.template);
957 return RoutePlanAttemptSelection {
958 selected: Some(self.selected_route_candidate_for_candidate(candidate)),
959 skipped,
960 avoided_candidate_indices,
961 avoided_total: state.avoided_total(),
962 total_upstreams,
963 };
964 }
965 }
966
967 pub fn select_supported_station_candidate_with_runtime_state(
968 &self,
969 state: &mut RoutePlanAttemptState,
970 runtime: &RoutePlanRuntimeState,
971 station_name: &str,
972 request_model: Option<&str>,
973 ) -> RoutePlanStationAttemptSelection<'_> {
974 self.select_supported_station_candidate_with_affinity_mode(
975 state,
976 runtime,
977 station_name,
978 request_model,
979 RoutePlanAffinitySelectionMode::Configured,
980 )
981 }
982
983 pub fn select_supported_station_candidate_with_soft_affinity_runtime_state(
984 &self,
985 state: &mut RoutePlanAttemptState,
986 runtime: &RoutePlanRuntimeState,
987 station_name: &str,
988 request_model: Option<&str>,
989 ) -> RoutePlanStationAttemptSelection<'_> {
990 self.select_supported_station_candidate_with_affinity_mode(
991 state,
992 runtime,
993 station_name,
994 request_model,
995 RoutePlanAffinitySelectionMode::SoftSessionPreferred,
996 )
997 }
998
999 fn select_supported_station_candidate_with_affinity_mode(
1000 &self,
1001 state: &mut RoutePlanAttemptState,
1002 runtime: &RoutePlanRuntimeState,
1003 station_name: &str,
1004 request_model: Option<&str>,
1005 affinity_mode: RoutePlanAffinitySelectionMode,
1006 ) -> RoutePlanStationAttemptSelection<'_> {
1007 let total_upstreams = self.template.candidates.len();
1008 let mut skipped = Vec::new();
1009
1010 loop {
1011 if state.station_exhausted_for(station_name, self.station_candidate_count(station_name))
1012 {
1013 return RoutePlanStationAttemptSelection {
1014 selected: None,
1015 skipped,
1016 avoid_for_station: state.avoid_for_station_name(station_name),
1017 avoided_total: state.avoided_total(),
1018 total_upstreams,
1019 };
1020 }
1021
1022 let Some(candidate) =
1023 self.next_unavoided_station_candidate(state, runtime, station_name, affinity_mode)
1024 else {
1025 return RoutePlanStationAttemptSelection {
1026 selected: None,
1027 skipped,
1028 avoid_for_station: state.avoid_for_station_name(station_name),
1029 avoided_total: state.avoided_total(),
1030 total_upstreams,
1031 };
1032 };
1033 let selected_upstream = self.legacy_selected_upstream_for_candidate(candidate);
1034
1035 if let Some(requested_model) = request_model
1036 && !candidate_supports_model(candidate, requested_model)
1037 {
1038 state.avoid_selected_upstream(&selected_upstream);
1039 let avoid_for_station =
1040 state.avoid_for_station_name(selected_upstream.station_name.as_str());
1041 skipped.push(SkippedStationRouteCandidate {
1042 candidate,
1043 selected_upstream,
1044 reason: RoutePlanSkipReason::UnsupportedModel {
1045 requested_model: requested_model.to_string(),
1046 },
1047 avoid_for_station,
1048 avoided_total: state.avoided_total(),
1049 total_upstreams,
1050 });
1051 continue;
1052 }
1053
1054 let avoid_for_station =
1055 state.avoid_for_station_name(selected_upstream.station_name.as_str());
1056 return RoutePlanStationAttemptSelection {
1057 selected: Some(self.selected_station_route_candidate_for_candidate(candidate)),
1058 skipped,
1059 avoid_for_station,
1060 avoided_total: state.avoided_total(),
1061 total_upstreams,
1062 };
1063 }
1064 }
1065
1066 pub fn legacy_selected_upstream_for_candidate(
1067 &self,
1068 candidate: &RouteCandidate,
1069 ) -> SelectedUpstream {
1070 let mut upstream = candidate.to_upstream_config();
1071 upstream.tags.insert(
1072 "provider_endpoint_key".to_string(),
1073 candidate_provider_endpoint_key(self.template, candidate).stable_key(),
1074 );
1075 SelectedUpstream {
1076 station_name: candidate_compatibility_station_name(self.template, candidate),
1077 index: candidate_compatibility_upstream_index(candidate),
1078 upstream,
1079 }
1080 }
1081
1082 fn selected_route_candidate_for_candidate(
1083 &self,
1084 candidate: &'a RouteCandidate,
1085 ) -> SelectedRouteCandidate<'a> {
1086 SelectedRouteCandidate {
1087 candidate,
1088 provider_endpoint: candidate_provider_endpoint_key(self.template, candidate),
1089 }
1090 }
1091
1092 fn selected_station_route_candidate_for_candidate(
1093 &self,
1094 candidate: &'a RouteCandidate,
1095 ) -> SelectedStationRouteCandidate<'a> {
1096 SelectedStationRouteCandidate {
1097 candidate,
1098 selected_upstream: self.legacy_selected_upstream_for_candidate(candidate),
1099 }
1100 }
1101
1102 fn next_unavoided_candidate(
1103 &self,
1104 state: &RoutePlanAttemptState,
1105 runtime: &RoutePlanRuntimeState,
1106 affinity_mode: RoutePlanAffinitySelectionMode,
1107 ) -> Option<&'a RouteCandidate> {
1108 let route_candidates = self
1109 .template
1110 .candidates
1111 .iter()
1112 .filter(|candidate| !state.avoids_candidate(self.template, candidate))
1113 .collect::<Vec<_>>();
1114
1115 best_candidate_by_affinity_selection_mode(
1116 self.template,
1117 runtime,
1118 &route_candidates,
1119 affinity_mode,
1120 )
1121 }
1122
1123 fn candidates_exhausted(&self, state: &RoutePlanAttemptState) -> bool {
1124 state.route_candidates_exhausted(self.template)
1125 }
1126
1127 fn station_candidate_count(&self, station_name: &str) -> usize {
1128 self.template
1129 .candidates
1130 .iter()
1131 .filter(|candidate| {
1132 candidate_compatibility_station_name(self.template, candidate) == station_name
1133 })
1134 .count()
1135 }
1136
1137 fn next_unavoided_station_candidate(
1138 &self,
1139 state: &RoutePlanAttemptState,
1140 runtime: &RoutePlanRuntimeState,
1141 station_name: &str,
1142 affinity_mode: RoutePlanAffinitySelectionMode,
1143 ) -> Option<&'a RouteCandidate> {
1144 let station_candidates = self
1145 .template
1146 .candidates
1147 .iter()
1148 .filter(|candidate| {
1149 candidate_compatibility_station_name(self.template, candidate) == station_name
1150 })
1151 .filter(|candidate| {
1152 !state.avoids_upstream(
1153 station_name,
1154 candidate_compatibility_upstream_index(candidate),
1155 )
1156 })
1157 .collect::<Vec<_>>();
1158
1159 best_candidate_by_affinity_selection_mode(
1160 self.template,
1161 runtime,
1162 &station_candidates,
1163 affinity_mode,
1164 )
1165 }
1166}
1167
1168fn best_candidate_by_affinity_selection_mode<'a>(
1169 template: &RoutePlanTemplate,
1170 runtime: &RoutePlanRuntimeState,
1171 station_candidates: &[&'a RouteCandidate],
1172 affinity_mode: RoutePlanAffinitySelectionMode,
1173) -> Option<&'a RouteCandidate> {
1174 let affinity_policy = affinity_policy_for_selection(template.affinity_policy, affinity_mode);
1175 match affinity_policy {
1176 RoutingAffinityPolicyV5::Off => {
1177 first_candidate_in_best_preference_group(template, runtime, station_candidates)
1178 }
1179 RoutingAffinityPolicyV5::PreferredGroup => {
1180 best_candidate_in_preference_group(template, runtime, station_candidates)
1181 }
1182 RoutingAffinityPolicyV5::FallbackSticky => {
1183 affinity_candidate(template, runtime, station_candidates)
1184 .filter(|candidate| {
1185 fallback_affinity_within_configured_window(
1186 template,
1187 runtime,
1188 station_candidates,
1189 ) || first_candidate_in_best_preference_group(
1190 template,
1191 runtime,
1192 station_candidates,
1193 )
1194 .is_none_or(|best| best.preference_group >= candidate.preference_group)
1195 })
1196 .or_else(|| {
1197 first_candidate_in_best_preference_group(template, runtime, station_candidates)
1198 })
1199 }
1200 RoutingAffinityPolicyV5::Hard => {
1201 if runtime.affinity_provider_endpoint().is_some() {
1202 affinity_candidate(template, runtime, station_candidates)
1203 } else {
1204 first_candidate_in_best_preference_group(template, runtime, station_candidates)
1205 }
1206 }
1207 }
1208}
1209
1210fn affinity_policy_for_selection(
1211 configured: RoutingAffinityPolicyV5,
1212 affinity_mode: RoutePlanAffinitySelectionMode,
1213) -> RoutingAffinityPolicyV5 {
1214 match (configured, affinity_mode) {
1215 (RoutingAffinityPolicyV5::Hard, RoutePlanAffinitySelectionMode::SoftSessionPreferred) => {
1216 RoutingAffinityPolicyV5::FallbackSticky
1217 }
1218 _ => configured,
1219 }
1220}
1221
1222fn first_candidate_in_best_preference_group<'a>(
1223 template: &RoutePlanTemplate,
1224 runtime: &RoutePlanRuntimeState,
1225 station_candidates: &[&'a RouteCandidate],
1226) -> Option<&'a RouteCandidate> {
1227 let best_group = station_candidates
1228 .iter()
1229 .copied()
1230 .filter(|candidate| candidate_available_in_runtime(template, runtime, candidate))
1231 .map(|candidate| candidate.preference_group)
1232 .min()?;
1233
1234 station_candidates.iter().copied().find(|candidate| {
1235 candidate.preference_group == best_group
1236 && candidate_available_in_runtime(template, runtime, candidate)
1237 })
1238}
1239
1240fn best_candidate_in_preference_group<'a>(
1241 template: &RoutePlanTemplate,
1242 runtime: &RoutePlanRuntimeState,
1243 station_candidates: &[&'a RouteCandidate],
1244) -> Option<&'a RouteCandidate> {
1245 let best_group = station_candidates
1246 .iter()
1247 .copied()
1248 .filter(|candidate| candidate_available_in_runtime(template, runtime, candidate))
1249 .map(|candidate| candidate.preference_group)
1250 .min()?;
1251
1252 if let Some(candidate) =
1253 affinity_candidate_in_group(template, runtime, station_candidates, best_group)
1254 {
1255 return Some(candidate);
1256 }
1257
1258 station_candidates.iter().copied().find(|candidate| {
1259 candidate.preference_group == best_group
1260 && candidate_available_in_runtime(template, runtime, candidate)
1261 })
1262}
1263
1264fn affinity_candidate<'a>(
1265 template: &RoutePlanTemplate,
1266 runtime: &RoutePlanRuntimeState,
1267 station_candidates: &[&'a RouteCandidate],
1268) -> Option<&'a RouteCandidate> {
1269 let affinity_key = runtime.affinity_provider_endpoint()?;
1270 station_candidates.iter().copied().find(|candidate| {
1271 candidate_provider_endpoint_key(template, candidate) == *affinity_key
1272 && candidate_available_in_runtime(template, runtime, candidate)
1273 })
1274}
1275
1276fn affinity_candidate_in_group<'a>(
1277 template: &RoutePlanTemplate,
1278 runtime: &RoutePlanRuntimeState,
1279 station_candidates: &[&'a RouteCandidate],
1280 preference_group: u32,
1281) -> Option<&'a RouteCandidate> {
1282 let affinity_key = runtime.affinity_provider_endpoint()?;
1283 station_candidates.iter().copied().find(|candidate| {
1284 candidate.preference_group == preference_group
1285 && candidate_provider_endpoint_key(template, candidate) == *affinity_key
1286 && candidate_available_in_runtime(template, runtime, candidate)
1287 })
1288}
1289
1290fn fallback_affinity_within_configured_window(
1291 template: &RoutePlanTemplate,
1292 runtime: &RoutePlanRuntimeState,
1293 station_candidates: &[&RouteCandidate],
1294) -> bool {
1295 let Some(affinity_key) = runtime.affinity_provider_endpoint() else {
1296 return true;
1297 };
1298 let Some(affinity_candidate) = station_candidates
1299 .iter()
1300 .copied()
1301 .find(|candidate| candidate_provider_endpoint_key(template, candidate) == *affinity_key)
1302 else {
1303 return true;
1304 };
1305 let Some(best_group) = station_candidates
1306 .iter()
1307 .copied()
1308 .filter(|candidate| candidate_routable_except_usage(template, runtime, candidate))
1309 .map(|candidate| candidate.preference_group)
1310 .min()
1311 else {
1312 return true;
1313 };
1314 if affinity_candidate.preference_group <= best_group {
1315 return true;
1316 }
1317
1318 fallback_affinity_age_within_window(
1319 template.fallback_ttl_ms,
1320 runtime.affinity_last_changed_at_ms(),
1321 ) && fallback_affinity_age_within_window(
1322 template.reprobe_preferred_after_ms,
1323 runtime.affinity_last_changed_at_ms(),
1324 )
1325}
1326
1327fn fallback_affinity_age_within_window(
1328 window_ms: Option<u64>,
1329 observed_at_ms: Option<u64>,
1330) -> bool {
1331 let Some(window_ms) = window_ms else {
1332 return true;
1333 };
1334 if window_ms == 0 {
1335 return false;
1336 }
1337 let Some(observed_at_ms) = observed_at_ms else {
1338 return false;
1339 };
1340 crate::logging::now_ms().saturating_sub(observed_at_ms) < window_ms
1341}
1342
1343fn candidate_available_in_runtime(
1344 template: &RoutePlanTemplate,
1345 runtime: &RoutePlanRuntimeState,
1346 candidate: &RouteCandidate,
1347) -> bool {
1348 runtime
1349 .candidate_runtime_snapshot(template, candidate)
1350 .runtime_available
1351}
1352
1353fn candidate_routable_except_usage(
1354 template: &RoutePlanTemplate,
1355 runtime: &RoutePlanRuntimeState,
1356 candidate: &RouteCandidate,
1357) -> bool {
1358 runtime
1359 .candidate_runtime_snapshot(template, candidate)
1360 .routable_except_usage
1361}
1362
1363#[derive(Debug, Clone, Default, PartialEq, Eq)]
1364pub struct RouteDecisionTrace {
1365 pub events: Vec<RouteDecisionEvent>,
1366}
1367
1368#[derive(Debug, Clone, Default, PartialEq, Eq)]
1369pub struct RouteRequestContext {
1370 pub model: Option<String>,
1371 pub service_tier: Option<String>,
1372 pub reasoning_effort: Option<String>,
1373 pub path: Option<String>,
1374 pub method: Option<String>,
1375 pub headers: BTreeMap<String, String>,
1376}
1377
1378#[derive(Debug, Clone, PartialEq, Eq)]
1379pub struct RouteDecisionEvent {
1380 pub route_path: Vec<String>,
1381 pub provider_id: Option<String>,
1382 pub endpoint_id: Option<String>,
1383 pub decision: RouteDecision,
1384 pub reason: Option<String>,
1385}
1386
1387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1388pub enum RouteDecision {
1389 Candidate,
1390 Selected,
1391 Skipped,
1392}
1393
1394#[derive(Debug, Clone)]
1395struct RouteLeaf {
1396 provider_id: String,
1397 endpoint_id: Option<String>,
1398 route_path: Vec<String>,
1399 preference_group: u32,
1400}
1401
1402#[derive(Debug, Clone)]
1403struct EndpointParts {
1404 endpoint_id: String,
1405 base_url: String,
1406 continuity_domain: Option<String>,
1407 enabled: bool,
1408 priority: u32,
1409 tags: BTreeMap<String, String>,
1410 supported_models: BTreeMap<String, bool>,
1411 model_mapping: BTreeMap<String, String>,
1412 limits: ProviderConcurrencyLimits,
1413}
1414
1415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1416enum ConditionalExpansion {
1417 MatchRequest,
1418 AllBranchesForCompatibility,
1419}
1420
1421struct RouteExpansionContext<'a> {
1422 request: &'a RouteRequestContext,
1423 conditional: ConditionalExpansion,
1424}
1425
1426struct RouteExpansionFrame<'a> {
1427 route_name: &'a str,
1428 node: &'a RoutingNodeV4,
1429 node_path: &'a [String],
1430}
1431
1432pub fn compile_v4_route_plan_template(
1433 service_name: &str,
1434 view: &ServiceViewV4,
1435) -> Result<RoutePlanTemplate> {
1436 compile_v4_route_plan_template_with_request(service_name, view, &RouteRequestContext::default())
1437}
1438
1439pub fn compile_v4_route_plan_template_with_request(
1440 service_name: &str,
1441 view: &ServiceViewV4,
1442 request: &RouteRequestContext,
1443) -> Result<RoutePlanTemplate> {
1444 compile_v4_route_plan_template_with_expansion(
1445 service_name,
1446 view,
1447 request,
1448 ConditionalExpansion::MatchRequest,
1449 )
1450}
1451
1452pub fn compile_v4_route_plan_template_for_compat_runtime(
1453 service_name: &str,
1454 view: &ServiceViewV4,
1455) -> Result<RoutePlanTemplate> {
1456 compile_v4_route_plan_template_with_expansion(
1457 service_name,
1458 view,
1459 &RouteRequestContext::default(),
1460 ConditionalExpansion::AllBranchesForCompatibility,
1461 )
1462}
1463
1464fn compile_v4_route_plan_template_with_expansion(
1465 service_name: &str,
1466 view: &ServiceViewV4,
1467 request: &RouteRequestContext,
1468 conditional: ConditionalExpansion,
1469) -> Result<RoutePlanTemplate> {
1470 let routing = effective_v4_routing(view);
1471 validate_route_provider_name_conflicts(service_name, view, &routing)?;
1472
1473 let nodes = normalize_route_nodes(service_name, view, &routing)?;
1474 let expansion = RouteExpansionContext {
1475 request,
1476 conditional,
1477 };
1478 let leaves = expand_v4_route_leaves(service_name, view, &routing, &expansion)?;
1479 ensure_unique_route_leaves(service_name, &leaves)?;
1480
1481 let expanded_provider_order = leaves
1482 .iter()
1483 .map(|leaf| leaf.provider_id.clone())
1484 .collect::<Vec<_>>();
1485 let candidates = route_candidates_from_leaves(service_name, view, &leaves)?;
1486
1487 Ok(RoutePlanTemplate {
1488 service_name: service_name.to_string(),
1489 entry: routing.entry,
1490 affinity_policy: routing.affinity_policy,
1491 fallback_ttl_ms: routing.fallback_ttl_ms,
1492 reprobe_preferred_after_ms: routing.reprobe_preferred_after_ms,
1493 nodes,
1494 expanded_provider_order,
1495 compatibility_station_name: None,
1496 candidates,
1497 })
1498}
1499
1500pub fn compile_legacy_route_plan_template<'a>(
1501 service_name: &str,
1502 services: impl IntoIterator<Item = &'a ServiceConfig>,
1503) -> RoutePlanTemplate {
1504 let entry = "legacy".to_string();
1505 let mut candidates = Vec::new();
1506 let mut station_names = BTreeSet::new();
1507
1508 for service in services {
1509 station_names.insert(service.name.clone());
1510 for (upstream_index, upstream) in service.upstreams.iter().enumerate() {
1511 let provider_id = upstream
1512 .tags
1513 .get("provider_id")
1514 .cloned()
1515 .unwrap_or_else(|| format!("{}#{upstream_index}", service.name));
1516 let endpoint_id = upstream
1517 .tags
1518 .get("endpoint_id")
1519 .cloned()
1520 .unwrap_or_else(|| upstream_index.to_string());
1521 let stable_index = candidates.len();
1522 let route_path = vec![entry.clone(), service.name.clone(), provider_id.clone()];
1523 candidates.push(RouteCandidate {
1524 provider_id,
1525 provider_alias: service.alias.clone(),
1526 endpoint_id,
1527 base_url: upstream.base_url.clone(),
1528 continuity_domain: upstream
1529 .tags
1530 .get("continuity_domain")
1531 .map(|value| value.trim().to_string())
1532 .filter(|value| !value.is_empty()),
1533 auth: upstream.auth.clone(),
1534 tags: hash_string_map_to_btree(&upstream.tags),
1535 supported_models: hash_bool_map_to_btree(&upstream.supported_models),
1536 model_mapping: hash_string_map_to_btree(&upstream.model_mapping),
1537 route_path,
1538 preference_group: 0,
1539 stable_index,
1540 concurrency: RouteCandidateConcurrency::default(),
1541 compatibility_station_name: Some(service.name.clone()),
1542 compatibility_upstream_index: Some(upstream_index),
1543 });
1544 }
1545 }
1546
1547 RoutePlanTemplate {
1548 service_name: service_name.to_string(),
1549 entry,
1550 affinity_policy: RoutingAffinityPolicyV5::FallbackSticky,
1551 fallback_ttl_ms: None,
1552 reprobe_preferred_after_ms: None,
1553 nodes: BTreeMap::new(),
1554 expanded_provider_order: candidates
1555 .iter()
1556 .map(|candidate| candidate.provider_id.clone())
1557 .collect(),
1558 candidates,
1559 compatibility_station_name: if station_names.len() == 1 {
1560 station_names.into_iter().next()
1561 } else {
1562 None
1563 },
1564 }
1565}
1566
1567fn validate_route_provider_name_conflicts(
1568 service_name: &str,
1569 view: &ServiceViewV4,
1570 routing: &RoutingConfigV4,
1571) -> Result<()> {
1572 for route_name in routing.routes.keys() {
1573 if view.providers.contains_key(route_name.as_str()) {
1574 anyhow::bail!(
1575 "[{service_name}] route node '{route_name}' conflicts with a provider of the same name"
1576 );
1577 }
1578 }
1579 Ok(())
1580}
1581
1582fn normalize_route_nodes(
1583 service_name: &str,
1584 view: &ServiceViewV4,
1585 routing: &RoutingConfigV4,
1586) -> Result<BTreeMap<String, RouteNodePlan>> {
1587 let mut out = BTreeMap::new();
1588 for (route_name, node) in &routing.routes {
1589 validate_route_node_shape(service_name, view, routing, route_name, node)?;
1590 let children = node
1591 .children
1592 .iter()
1593 .map(|child| normalize_route_ref(service_name, view, routing, child))
1594 .collect::<Result<Vec<_>>>()?;
1595 let target = node
1596 .target
1597 .as_deref()
1598 .map(|target| normalize_route_ref(service_name, view, routing, target))
1599 .transpose()?;
1600 let then = node
1601 .then
1602 .as_deref()
1603 .map(|target| normalize_route_ref(service_name, view, routing, target))
1604 .transpose()?;
1605 let default_route = node
1606 .default_route
1607 .as_deref()
1608 .map(|target| normalize_route_ref(service_name, view, routing, target))
1609 .transpose()?;
1610 out.insert(
1611 route_name.clone(),
1612 RouteNodePlan {
1613 name: route_name.clone(),
1614 strategy: node.strategy,
1615 children,
1616 target,
1617 prefer_tags: node.prefer_tags.clone(),
1618 on_exhausted: node.on_exhausted,
1619 metadata: node.metadata.clone(),
1620 when: node.when.clone(),
1621 then,
1622 default_route,
1623 },
1624 );
1625 }
1626 Ok(out)
1627}
1628
1629fn validate_route_node_shape(
1630 service_name: &str,
1631 view: &ServiceViewV4,
1632 routing: &RoutingConfigV4,
1633 route_name: &str,
1634 node: &RoutingNodeV4,
1635) -> Result<()> {
1636 match node.strategy {
1637 RoutingPolicyV4::Conditional => {
1638 let Some(condition) = node.when.as_ref() else {
1639 anyhow::bail!("[{service_name}] conditional route '{route_name}' requires when");
1640 };
1641 if condition.is_empty() {
1642 anyhow::bail!(
1643 "[{service_name}] conditional route '{route_name}' requires at least one condition field"
1644 );
1645 }
1646 let then = node
1647 .then
1648 .as_deref()
1649 .filter(|value| !value.trim().is_empty());
1650 let default_route = node
1651 .default_route
1652 .as_deref()
1653 .filter(|value| !value.trim().is_empty());
1654 let Some(then) = then else {
1655 anyhow::bail!("[{service_name}] conditional route '{route_name}' requires then");
1656 };
1657 let Some(default_route) = default_route else {
1658 anyhow::bail!("[{service_name}] conditional route '{route_name}' requires default");
1659 };
1660 normalize_route_ref(service_name, view, routing, then)?;
1661 normalize_route_ref(service_name, view, routing, default_route)?;
1662 }
1663 _ => {
1664 if node.when.is_some() || node.then.is_some() || node.default_route.is_some() {
1665 anyhow::bail!(
1666 "[{service_name}] route '{route_name}' uses conditional fields but strategy is not conditional"
1667 );
1668 }
1669 }
1670 }
1671
1672 Ok(())
1673}
1674
1675fn normalize_route_ref(
1676 service_name: &str,
1677 view: &ServiceViewV4,
1678 routing: &RoutingConfigV4,
1679 name: &str,
1680) -> Result<RouteRef> {
1681 if view.providers.contains_key(name) {
1682 return Ok(RouteRef::Provider(name.to_string()));
1683 }
1684 if routing.routes.contains_key(name) {
1685 return Ok(RouteRef::Route(name.to_string()));
1686 }
1687 if let Some((provider_id, endpoint_id)) = split_provider_endpoint_ref(name)
1688 && let Some(provider) = view.providers.get(provider_id)
1689 && provider_endpoint_exists(provider, endpoint_id)
1690 {
1691 return Ok(RouteRef::ProviderEndpoint {
1692 provider_id: provider_id.to_string(),
1693 endpoint_id: endpoint_id.to_string(),
1694 });
1695 }
1696 anyhow::bail!("[{service_name}] routing references missing route or provider '{name}'");
1697}
1698
1699fn split_provider_endpoint_ref(name: &str) -> Option<(&str, &str)> {
1700 let (provider_id, endpoint_id) = name.split_once('.')?;
1701 let provider_id = provider_id.trim();
1702 let endpoint_id = endpoint_id.trim();
1703 if provider_id.is_empty() || endpoint_id.is_empty() {
1704 return None;
1705 }
1706 Some((provider_id, endpoint_id))
1707}
1708
1709fn provider_endpoint_exists(provider: &ProviderConfigV4, endpoint_id: &str) -> bool {
1710 if endpoint_id == "default" {
1711 return provider
1712 .base_url
1713 .as_deref()
1714 .map(str::trim)
1715 .is_some_and(|value| !value.is_empty())
1716 || provider.endpoints.contains_key(endpoint_id);
1717 }
1718 provider.endpoints.contains_key(endpoint_id)
1719}
1720
1721fn provider_endpoint_enabled(provider: &ProviderConfigV4, endpoint_id: &str) -> bool {
1722 if endpoint_id == "default"
1723 && provider
1724 .base_url
1725 .as_deref()
1726 .map(str::trim)
1727 .is_some_and(|value| !value.is_empty())
1728 {
1729 return true;
1730 }
1731 provider
1732 .endpoints
1733 .get(endpoint_id)
1734 .is_some_and(|endpoint| endpoint.enabled)
1735}
1736
1737fn expand_v4_route_leaves(
1738 service_name: &str,
1739 view: &ServiceViewV4,
1740 routing: &RoutingConfigV4,
1741 expansion: &RouteExpansionContext<'_>,
1742) -> Result<Vec<RouteLeaf>> {
1743 if view.providers.is_empty() && routing.routes.is_empty() {
1744 return Ok(Vec::new());
1745 }
1746 if routing.routes.is_empty() {
1747 return Ok(view
1748 .providers
1749 .keys()
1750 .map(|provider_id| RouteLeaf {
1751 provider_id: provider_id.clone(),
1752 endpoint_id: None,
1753 route_path: vec![provider_id.clone()],
1754 preference_group: 0,
1755 })
1756 .collect());
1757 }
1758
1759 let mut stack = Vec::new();
1760 expand_route_node(
1761 service_name,
1762 view,
1763 routing,
1764 routing.entry.as_str(),
1765 &[],
1766 expansion,
1767 &mut stack,
1768 )
1769}
1770
1771fn expand_route_ref(
1772 service_name: &str,
1773 view: &ServiceViewV4,
1774 routing: &RoutingConfigV4,
1775 child_name: &str,
1776 parent_path: &[String],
1777 expansion: &RouteExpansionContext<'_>,
1778 stack: &mut Vec<String>,
1779) -> Result<Vec<RouteLeaf>> {
1780 if view.providers.contains_key(child_name) {
1781 let mut route_path = parent_path.to_vec();
1782 route_path.push(child_name.to_string());
1783 return Ok(vec![RouteLeaf {
1784 provider_id: child_name.to_string(),
1785 endpoint_id: None,
1786 route_path,
1787 preference_group: 0,
1788 }]);
1789 }
1790 if routing.routes.contains_key(child_name) {
1791 return expand_route_node(
1792 service_name,
1793 view,
1794 routing,
1795 child_name,
1796 parent_path,
1797 expansion,
1798 stack,
1799 );
1800 }
1801 if let Some((provider_id, endpoint_id)) = split_provider_endpoint_ref(child_name)
1802 && view
1803 .providers
1804 .get(provider_id)
1805 .is_some_and(|provider| provider_endpoint_exists(provider, endpoint_id))
1806 {
1807 let mut route_path = parent_path.to_vec();
1808 route_path.push(child_name.to_string());
1809 return Ok(vec![RouteLeaf {
1810 provider_id: provider_id.to_string(),
1811 endpoint_id: Some(endpoint_id.to_string()),
1812 route_path,
1813 preference_group: 0,
1814 }]);
1815 }
1816
1817 expand_route_node(
1818 service_name,
1819 view,
1820 routing,
1821 child_name,
1822 parent_path,
1823 expansion,
1824 stack,
1825 )
1826}
1827
1828fn expand_route_node(
1829 service_name: &str,
1830 view: &ServiceViewV4,
1831 routing: &RoutingConfigV4,
1832 route_name: &str,
1833 parent_path: &[String],
1834 expansion: &RouteExpansionContext<'_>,
1835 stack: &mut Vec<String>,
1836) -> Result<Vec<RouteLeaf>> {
1837 if stack.iter().any(|name| name == route_name) {
1838 let mut cycle = stack.clone();
1839 cycle.push(route_name.to_string());
1840 anyhow::bail!(
1841 "[{service_name}] routing graph has a cycle: {}",
1842 cycle.join(" -> ")
1843 );
1844 }
1845
1846 let Some(node) = routing.routes.get(route_name) else {
1847 anyhow::bail!(
1848 "[{service_name}] routing entry references missing route node '{route_name}'"
1849 );
1850 };
1851
1852 stack.push(route_name.to_string());
1853 let mut node_path = parent_path.to_vec();
1854 node_path.push(route_name.to_string());
1855 let frame = RouteExpansionFrame {
1856 route_name,
1857 node,
1858 node_path: &node_path,
1859 };
1860 let result = match node.strategy {
1861 RoutingPolicyV4::OrderedFailover => {
1862 expand_ordered_route_children(service_name, view, routing, &frame, expansion, stack)
1863 }
1864 RoutingPolicyV4::ManualSticky => {
1865 expand_manual_sticky_route(service_name, view, routing, &frame, expansion, stack)
1866 }
1867 RoutingPolicyV4::TagPreferred => {
1868 expand_tag_preferred_route(service_name, view, routing, &frame, expansion, stack)
1869 }
1870 RoutingPolicyV4::Conditional => {
1871 expand_conditional_route(service_name, view, routing, &frame, expansion, stack)
1872 }
1873 };
1874 stack.pop();
1875 result
1876}
1877
1878fn expand_ordered_route_children(
1879 service_name: &str,
1880 view: &ServiceViewV4,
1881 routing: &RoutingConfigV4,
1882 frame: &RouteExpansionFrame<'_>,
1883 expansion: &RouteExpansionContext<'_>,
1884 stack: &mut Vec<String>,
1885) -> Result<Vec<RouteLeaf>> {
1886 if frame.node.children.is_empty() {
1887 anyhow::bail!(
1888 "[{service_name}] ordered-failover route '{}' requires at least one child",
1889 frame.route_name
1890 );
1891 }
1892
1893 let mut leaves = Vec::new();
1894 let mut next_preference_group = 0;
1895 for child_name in &frame.node.children {
1896 let child_leaves = expand_route_ref(
1897 service_name,
1898 view,
1899 routing,
1900 child_name.as_str(),
1901 frame.node_path,
1902 expansion,
1903 stack,
1904 )?;
1905 append_compacted_preference_groups(&mut leaves, &mut next_preference_group, child_leaves);
1906 }
1907 Ok(leaves)
1908}
1909
1910fn expand_manual_sticky_route(
1911 service_name: &str,
1912 view: &ServiceViewV4,
1913 routing: &RoutingConfigV4,
1914 frame: &RouteExpansionFrame<'_>,
1915 expansion: &RouteExpansionContext<'_>,
1916 stack: &mut Vec<String>,
1917) -> Result<Vec<RouteLeaf>> {
1918 let target = frame
1919 .node
1920 .target
1921 .as_deref()
1922 .or_else(|| frame.node.children.first().map(String::as_str))
1923 .with_context(|| {
1924 format!(
1925 "[{service_name}] manual-sticky route '{}' requires target",
1926 frame.route_name
1927 )
1928 })?;
1929 if let Some(provider) = view.providers.get(target)
1930 && !provider.enabled
1931 {
1932 anyhow::bail!(
1933 "[{service_name}] manual-sticky route '{}' targets disabled provider '{target}'",
1934 frame.route_name
1935 );
1936 }
1937 if !routing.routes.contains_key(target)
1938 && let Some((provider_id, endpoint_id)) = split_provider_endpoint_ref(target)
1939 && let Some(provider) = view.providers.get(provider_id)
1940 && (!provider.enabled || !provider_endpoint_enabled(provider, endpoint_id))
1941 {
1942 anyhow::bail!(
1943 "[{service_name}] manual-sticky route '{}' targets disabled provider endpoint '{target}'",
1944 frame.route_name
1945 );
1946 }
1947
1948 expand_route_ref(
1949 service_name,
1950 view,
1951 routing,
1952 target,
1953 frame.node_path,
1954 expansion,
1955 stack,
1956 )
1957}
1958
1959fn expand_tag_preferred_route(
1960 service_name: &str,
1961 view: &ServiceViewV4,
1962 routing: &RoutingConfigV4,
1963 frame: &RouteExpansionFrame<'_>,
1964 expansion: &RouteExpansionContext<'_>,
1965 stack: &mut Vec<String>,
1966) -> Result<Vec<RouteLeaf>> {
1967 if frame.node.children.is_empty() {
1968 anyhow::bail!(
1969 "[{service_name}] tag-preferred route '{}' requires at least one child",
1970 frame.route_name
1971 );
1972 }
1973 if frame.node.prefer_tags.is_empty() {
1974 anyhow::bail!(
1975 "[{service_name}] tag-preferred route '{}' requires prefer_tags",
1976 frame.route_name
1977 );
1978 }
1979
1980 let mut preferred = Vec::new();
1981 let mut fallback = Vec::new();
1982 let mut next_preferred_group = 0;
1983 let mut next_fallback_group = 0;
1984 for child_name in &frame.node.children {
1985 let child_leaves = expand_route_ref(
1986 service_name,
1987 view,
1988 routing,
1989 child_name.as_str(),
1990 frame.node_path,
1991 expansion,
1992 stack,
1993 )?;
1994 if child_route_matches_any_filter(view, &child_leaves, &frame.node.prefer_tags) {
1995 append_compacted_preference_groups(
1996 &mut preferred,
1997 &mut next_preferred_group,
1998 child_leaves,
1999 );
2000 } else {
2001 append_compacted_preference_groups(
2002 &mut fallback,
2003 &mut next_fallback_group,
2004 child_leaves,
2005 );
2006 }
2007 }
2008
2009 if matches!(frame.node.on_exhausted, RoutingExhaustedActionV4::Stop) {
2010 if preferred.is_empty() {
2011 anyhow::bail!(
2012 "[{service_name}] tag-preferred route '{}' with on_exhausted = 'stop' matched no providers",
2013 frame.route_name
2014 );
2015 }
2016 return Ok(preferred);
2017 }
2018
2019 offset_preference_groups(&mut fallback, next_preferred_group);
2020 preferred.extend(fallback);
2021 Ok(preferred)
2022}
2023
2024fn append_compacted_preference_groups(
2025 out: &mut Vec<RouteLeaf>,
2026 next_group: &mut u32,
2027 mut leaves: Vec<RouteLeaf>,
2028) {
2029 let Some(min_group) = leaves.iter().map(|leaf| leaf.preference_group).min() else {
2030 return;
2031 };
2032 for leaf in &mut leaves {
2033 leaf.preference_group = leaf
2034 .preference_group
2035 .saturating_sub(min_group)
2036 .saturating_add(*next_group);
2037 }
2038 let max_group = leaves
2039 .iter()
2040 .map(|leaf| leaf.preference_group)
2041 .max()
2042 .unwrap_or(*next_group);
2043 *next_group = max_group.saturating_add(1);
2044 out.extend(leaves);
2045}
2046
2047fn offset_preference_groups(leaves: &mut [RouteLeaf], offset: u32) {
2048 for leaf in leaves {
2049 leaf.preference_group = leaf.preference_group.saturating_add(offset);
2050 }
2051}
2052
2053fn expand_conditional_route(
2054 service_name: &str,
2055 view: &ServiceViewV4,
2056 routing: &RoutingConfigV4,
2057 frame: &RouteExpansionFrame<'_>,
2058 expansion: &RouteExpansionContext<'_>,
2059 stack: &mut Vec<String>,
2060) -> Result<Vec<RouteLeaf>> {
2061 let condition = frame.node.when.as_ref().with_context(|| {
2062 format!(
2063 "[{service_name}] conditional route '{}' requires when",
2064 frame.route_name
2065 )
2066 })?;
2067 if condition.is_empty() {
2068 anyhow::bail!(
2069 "[{service_name}] conditional route '{}' requires at least one condition field",
2070 frame.route_name
2071 );
2072 }
2073
2074 let then = frame.node.then.as_deref().with_context(|| {
2075 format!(
2076 "[{service_name}] conditional route '{}' requires then",
2077 frame.route_name
2078 )
2079 })?;
2080 let default_route = frame.node.default_route.as_deref().with_context(|| {
2081 format!(
2082 "[{service_name}] conditional route '{}' requires default",
2083 frame.route_name
2084 )
2085 })?;
2086 match expansion.conditional {
2087 ConditionalExpansion::MatchRequest => {
2088 let selected = if request_matches_condition(expansion.request, condition) {
2089 then
2090 } else {
2091 default_route
2092 };
2093 expand_route_ref(
2094 service_name,
2095 view,
2096 routing,
2097 selected,
2098 frame.node_path,
2099 expansion,
2100 stack,
2101 )
2102 }
2103 ConditionalExpansion::AllBranchesForCompatibility => {
2104 let mut leaves = Vec::new();
2105 leaves.extend(expand_route_ref(
2106 service_name,
2107 view,
2108 routing,
2109 then,
2110 frame.node_path,
2111 expansion,
2112 stack,
2113 )?);
2114 leaves.extend(expand_route_ref(
2115 service_name,
2116 view,
2117 routing,
2118 default_route,
2119 frame.node_path,
2120 expansion,
2121 stack,
2122 )?);
2123 dedupe_route_leaves_by_target(&mut leaves);
2124 Ok(leaves)
2125 }
2126 }
2127}
2128
2129fn dedupe_route_leaves_by_target(leaves: &mut Vec<RouteLeaf>) {
2130 let mut seen = BTreeSet::new();
2131 leaves.retain(|leaf| seen.insert((leaf.provider_id.clone(), leaf.endpoint_id.clone())));
2132}
2133
2134pub(crate) fn request_matches_condition(
2135 request: &RouteRequestContext,
2136 condition: &RoutingConditionV4,
2137) -> bool {
2138 optional_field_matches(request.model.as_deref(), condition.model.as_deref(), false)
2139 && optional_field_matches(
2140 request.service_tier.as_deref(),
2141 condition.service_tier.as_deref(),
2142 true,
2143 )
2144 && optional_field_matches(
2145 request.reasoning_effort.as_deref(),
2146 condition.reasoning_effort.as_deref(),
2147 true,
2148 )
2149 && optional_field_matches(request.path.as_deref(), condition.path.as_deref(), false)
2150 && optional_field_matches(request.method.as_deref(), condition.method.as_deref(), true)
2151 && condition.headers.iter().all(|(key, expected)| {
2152 request
2153 .headers
2154 .iter()
2155 .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key))
2156 .is_some_and(|(_, actual)| actual == expected)
2157 })
2158}
2159
2160fn optional_field_matches(
2161 actual: Option<&str>,
2162 expected: Option<&str>,
2163 ignore_ascii_case: bool,
2164) -> bool {
2165 let Some(expected) = expected.map(str::trim).filter(|value| !value.is_empty()) else {
2166 return true;
2167 };
2168 let Some(actual) = actual.map(str::trim).filter(|value| !value.is_empty()) else {
2169 return false;
2170 };
2171 if ignore_ascii_case {
2172 actual.eq_ignore_ascii_case(expected)
2173 } else {
2174 actual == expected
2175 }
2176}
2177
2178fn child_route_matches_any_filter(
2179 view: &ServiceViewV4,
2180 leaves: &[RouteLeaf],
2181 filters: &[BTreeMap<String, String>],
2182) -> bool {
2183 leaves.iter().any(|leaf| {
2184 view.providers
2185 .get(leaf.provider_id.as_str())
2186 .is_some_and(|provider| provider_matches_any_filter(&provider.tags, filters))
2187 })
2188}
2189
2190fn provider_matches_any_filter(
2191 tags: &BTreeMap<String, String>,
2192 filters: &[BTreeMap<String, String>],
2193) -> bool {
2194 filters.iter().any(|filter| {
2195 !filter.is_empty()
2196 && filter
2197 .iter()
2198 .all(|(key, value)| tags.get(key) == Some(value))
2199 })
2200}
2201
2202fn ensure_unique_route_leaves(service_name: &str, leaves: &[RouteLeaf]) -> Result<()> {
2203 let mut provider_all_endpoint_refs = BTreeSet::new();
2204 let mut provider_endpoint_refs = BTreeSet::new();
2205 for leaf in leaves {
2206 match leaf.endpoint_id.as_deref() {
2207 Some(endpoint_id) => {
2208 if provider_all_endpoint_refs.contains(leaf.provider_id.as_str())
2209 || !provider_endpoint_refs.insert((leaf.provider_id.as_str(), endpoint_id))
2210 {
2211 anyhow::bail!(
2212 "[{service_name}] routing graph expands provider endpoint '{}.{}' more than once; duplicate leaves are ambiguous",
2213 leaf.provider_id,
2214 endpoint_id
2215 );
2216 }
2217 }
2218 None => {
2219 if provider_endpoint_refs
2220 .iter()
2221 .any(|(provider_id, _)| *provider_id == leaf.provider_id.as_str())
2222 || !provider_all_endpoint_refs.insert(leaf.provider_id.as_str())
2223 {
2224 anyhow::bail!(
2225 "[{service_name}] routing graph expands provider '{}' more than once; duplicate leaves are ambiguous",
2226 leaf.provider_id
2227 );
2228 }
2229 }
2230 }
2231 }
2232 Ok(())
2233}
2234
2235fn route_candidates_from_leaves(
2236 service_name: &str,
2237 view: &ServiceViewV4,
2238 leaves: &[RouteLeaf],
2239) -> Result<Vec<RouteCandidate>> {
2240 let mut candidates = Vec::new();
2241 for leaf in leaves {
2242 let Some(provider) = view.providers.get(leaf.provider_id.as_str()) else {
2243 anyhow::bail!(
2244 "[{service_name}] routing references missing provider '{}'",
2245 leaf.provider_id
2246 );
2247 };
2248 if !provider.enabled {
2249 continue;
2250 }
2251
2252 let auth = merge_auth(&provider.auth, &provider.inline_auth);
2253 for endpoint in
2254 ordered_provider_endpoints(service_name, leaf.provider_id.as_str(), provider)?
2255 {
2256 if leaf
2257 .endpoint_id
2258 .as_deref()
2259 .is_some_and(|endpoint_id| endpoint_id != endpoint.endpoint_id)
2260 {
2261 continue;
2262 }
2263 if !endpoint.enabled {
2264 continue;
2265 }
2266 let stable_index = candidates.len();
2267 candidates.push(RouteCandidate {
2268 provider_id: leaf.provider_id.clone(),
2269 provider_alias: provider.alias.clone(),
2270 endpoint_id: endpoint.endpoint_id,
2271 base_url: endpoint.base_url,
2272 continuity_domain: endpoint.continuity_domain,
2273 auth: auth.clone(),
2274 tags: merge_string_maps_with_provider_id(
2275 leaf.provider_id.as_str(),
2276 &provider.tags,
2277 &endpoint.tags,
2278 ),
2279 supported_models: merge_bool_maps(
2280 &provider.supported_models,
2281 &endpoint.supported_models,
2282 ),
2283 model_mapping: merge_string_maps(&provider.model_mapping, &endpoint.model_mapping),
2284 route_path: leaf.route_path.clone(),
2285 preference_group: leaf.preference_group,
2286 stable_index,
2287 concurrency: effective_candidate_concurrency(&provider.limits, &endpoint.limits),
2288 compatibility_station_name: None,
2289 compatibility_upstream_index: None,
2290 });
2291 }
2292 }
2293 Ok(candidates)
2294}
2295
2296fn effective_candidate_concurrency(
2297 provider: &ProviderConcurrencyLimits,
2298 endpoint: &ProviderConcurrencyLimits,
2299) -> RouteCandidateConcurrency {
2300 RouteCandidateConcurrency {
2301 max_concurrent_requests: endpoint
2302 .max_concurrent_requests
2303 .or(provider.max_concurrent_requests),
2304 limit_group: endpoint
2305 .limit_group
2306 .as_ref()
2307 .or(provider.limit_group.as_ref())
2308 .map(|value| value.trim())
2309 .filter(|value| !value.is_empty())
2310 .map(ToOwned::to_owned),
2311 }
2312}
2313
2314fn ordered_provider_endpoints(
2315 service_name: &str,
2316 provider_name: &str,
2317 provider: &ProviderConfigV4,
2318) -> Result<Vec<EndpointParts>> {
2319 let mut endpoints = Vec::new();
2320 if let Some(base_url) = provider
2321 .base_url
2322 .as_deref()
2323 .map(str::trim)
2324 .filter(|value| !value.is_empty())
2325 {
2326 if provider.endpoints.contains_key("default") {
2327 anyhow::bail!(
2328 "[{service_name}] provider '{provider_name}' cannot define both base_url and endpoints.default"
2329 );
2330 }
2331 endpoints.push(EndpointParts {
2332 endpoint_id: "default".to_string(),
2333 base_url: base_url.to_string(),
2334 continuity_domain: provider
2335 .continuity_domain
2336 .as_ref()
2337 .map(|value| value.trim().to_string())
2338 .filter(|value| !value.is_empty()),
2339 enabled: true,
2340 priority: 0,
2341 tags: BTreeMap::new(),
2342 supported_models: BTreeMap::new(),
2343 model_mapping: BTreeMap::new(),
2344 limits: ProviderConcurrencyLimits::default(),
2345 });
2346 }
2347
2348 for (endpoint_id, endpoint) in &provider.endpoints {
2349 if endpoint.base_url.trim().is_empty() {
2350 anyhow::bail!(
2351 "[{service_name}] provider '{provider_name}' endpoint '{endpoint_id}' has an empty base_url"
2352 );
2353 }
2354 endpoints.push(EndpointParts {
2355 endpoint_id: endpoint_id.clone(),
2356 base_url: endpoint.base_url.trim().to_string(),
2357 continuity_domain: endpoint
2358 .continuity_domain
2359 .as_ref()
2360 .or(provider.continuity_domain.as_ref())
2361 .map(|value| value.trim().to_string())
2362 .filter(|value| !value.is_empty()),
2363 enabled: endpoint.enabled,
2364 priority: endpoint.priority,
2365 tags: endpoint.tags.clone(),
2366 supported_models: endpoint.supported_models.clone(),
2367 model_mapping: endpoint.model_mapping.clone(),
2368 limits: endpoint.limits.clone(),
2369 });
2370 }
2371
2372 if endpoints.is_empty() {
2373 anyhow::bail!("[{service_name}] provider '{provider_name}' has no base_url or endpoints");
2374 }
2375
2376 endpoints.sort_by(|left, right| {
2377 left.priority
2378 .cmp(&right.priority)
2379 .then_with(|| left.endpoint_id.cmp(&right.endpoint_id))
2380 .then_with(|| left.base_url.cmp(&right.base_url))
2381 });
2382 Ok(endpoints)
2383}
2384
2385fn merge_auth(block: &UpstreamAuth, inline: &UpstreamAuth) -> UpstreamAuth {
2386 UpstreamAuth {
2387 auth_token: inline
2388 .auth_token
2389 .clone()
2390 .or_else(|| block.auth_token.clone()),
2391 auth_token_env: inline
2392 .auth_token_env
2393 .clone()
2394 .or_else(|| block.auth_token_env.clone()),
2395 api_key: inline.api_key.clone().or_else(|| block.api_key.clone()),
2396 api_key_env: inline
2397 .api_key_env
2398 .clone()
2399 .or_else(|| block.api_key_env.clone()),
2400 }
2401}
2402
2403fn merge_string_maps(
2404 provider_values: &BTreeMap<String, String>,
2405 endpoint_values: &BTreeMap<String, String>,
2406) -> BTreeMap<String, String> {
2407 let mut merged = provider_values.clone();
2408 for (key, value) in endpoint_values {
2409 merged.insert(key.clone(), value.clone());
2410 }
2411 merged
2412}
2413
2414fn merge_string_maps_with_provider_id(
2415 provider_id: &str,
2416 provider_values: &BTreeMap<String, String>,
2417 endpoint_values: &BTreeMap<String, String>,
2418) -> BTreeMap<String, String> {
2419 let mut provider_values = provider_values.clone();
2420 provider_values.insert("provider_id".to_string(), provider_id.to_string());
2421 merge_string_maps(&provider_values, endpoint_values)
2422}
2423
2424fn merge_bool_maps(
2425 provider_values: &BTreeMap<String, bool>,
2426 endpoint_values: &BTreeMap<String, bool>,
2427) -> BTreeMap<String, bool> {
2428 let mut merged = provider_values.clone();
2429 for (key, value) in endpoint_values {
2430 merged.insert(key.clone(), *value);
2431 }
2432 merged
2433}
2434
2435fn candidate_compatibility_upstream_index(candidate: &RouteCandidate) -> usize {
2436 candidate
2437 .compatibility_upstream_index
2438 .unwrap_or(candidate.stable_index)
2439}
2440
2441fn candidate_compatibility_station_name(
2442 template: &RoutePlanTemplate,
2443 candidate: &RouteCandidate,
2444) -> String {
2445 candidate
2446 .compatibility_station_name
2447 .clone()
2448 .or_else(|| template.compatibility_station_name.clone())
2449 .unwrap_or_else(|| V4_COMPATIBILITY_STATION_NAME.to_string())
2450}
2451
2452fn candidate_supports_model(candidate: &RouteCandidate, requested_model: &str) -> bool {
2453 model_routing::is_model_supported(
2454 &btree_bool_map_to_hash_map(&candidate.supported_models),
2455 &btree_string_map_to_hash_map(&candidate.model_mapping),
2456 requested_model,
2457 )
2458}
2459
2460fn hash_string_map_to_btree(values: &HashMap<String, String>) -> BTreeMap<String, String> {
2461 values
2462 .iter()
2463 .map(|(key, value)| (key.clone(), value.clone()))
2464 .collect()
2465}
2466
2467fn hash_bool_map_to_btree(values: &HashMap<String, bool>) -> BTreeMap<String, bool> {
2468 values
2469 .iter()
2470 .map(|(key, value)| (key.clone(), *value))
2471 .collect()
2472}
2473
2474fn btree_string_map_to_hash_map(values: &BTreeMap<String, String>) -> HashMap<String, String> {
2475 values
2476 .iter()
2477 .map(|(key, value)| (key.clone(), value.clone()))
2478 .collect()
2479}
2480
2481fn btree_bool_map_to_hash_map(values: &BTreeMap<String, bool>) -> HashMap<String, bool> {
2482 values
2483 .iter()
2484 .map(|(key, value)| (key.clone(), *value))
2485 .collect()
2486}
2487
2488#[cfg(test)]
2489mod tests {
2490 use super::*;
2491 use crate::config::{
2492 ProviderConcurrencyLimits, ProviderEndpointV4, ProxyConfigV4, RoutingAffinityPolicyV5,
2493 RoutingConditionV4, RoutingConfigV4, RoutingExhaustedActionV4, RoutingNodeV4,
2494 RoutingPolicyV4, compile_v4_to_runtime, resolved_v4_provider_order,
2495 };
2496 use crate::lb::{LbState, LoadBalancer, SelectedUpstream};
2497 use std::collections::{HashMap, HashSet};
2498 use std::sync::{Arc, Mutex};
2499
2500 fn provider(base_url: &str) -> ProviderConfigV4 {
2501 ProviderConfigV4 {
2502 base_url: Some(base_url.to_string()),
2503 ..ProviderConfigV4::default()
2504 }
2505 }
2506
2507 fn tagged_provider(base_url: &str, key: &str, value: &str) -> ProviderConfigV4 {
2508 ProviderConfigV4 {
2509 base_url: Some(base_url.to_string()),
2510 tags: BTreeMap::from([(key.to_string(), value.to_string())]),
2511 ..ProviderConfigV4::default()
2512 }
2513 }
2514
2515 fn limited_provider(base_url: &str, max_concurrent_requests: u32) -> ProviderConfigV4 {
2516 ProviderConfigV4 {
2517 base_url: Some(base_url.to_string()),
2518 limits: ProviderConcurrencyLimits {
2519 max_concurrent_requests: Some(max_concurrent_requests),
2520 limit_group: None,
2521 },
2522 ..ProviderConfigV4::default()
2523 }
2524 }
2525
2526 fn provider_ids(template: &RoutePlanTemplate) -> Vec<String> {
2527 template
2528 .candidates
2529 .iter()
2530 .map(|candidate| candidate.provider_id.clone())
2531 .collect()
2532 }
2533
2534 fn provider_preference_groups(template: &RoutePlanTemplate) -> Vec<(String, u32)> {
2535 template
2536 .candidates
2537 .iter()
2538 .map(|candidate| (candidate.provider_id.clone(), candidate.preference_group))
2539 .collect()
2540 }
2541
2542 fn endpoint_key(
2543 service_name: &str,
2544 provider_id: &str,
2545 endpoint_id: &str,
2546 ) -> ProviderEndpointKey {
2547 ProviderEndpointKey::new(service_name, provider_id, endpoint_id)
2548 }
2549
2550 fn provider_endpoint_keys(template: &RoutePlanTemplate) -> Vec<String> {
2551 template
2552 .candidate_identities()
2553 .into_iter()
2554 .map(|identity| identity.provider_endpoint.stable_key())
2555 .collect()
2556 }
2557
2558 fn legacy_upstream_keys(template: &RoutePlanTemplate) -> Vec<String> {
2559 template
2560 .candidate_identities()
2561 .into_iter()
2562 .filter_map(|identity| {
2563 identity
2564 .compatibility
2565 .as_ref()
2566 .map(LegacyUpstreamKey::stable_key)
2567 })
2568 .collect()
2569 }
2570
2571 fn assert_provider_order_parity(view: &ServiceViewV4, template: &RoutePlanTemplate) {
2572 let resolved = resolved_v4_provider_order("routing-ir-test", view).expect("resolved order");
2573 assert_eq!(template.expanded_provider_order, resolved);
2574 assert_eq!(provider_ids(template), resolved);
2575 }
2576
2577 #[test]
2578 fn route_graph_key_changes_when_route_rules_change() {
2579 let providers = BTreeMap::from([
2580 (
2581 "a".to_string(),
2582 ProviderConfigV4 {
2583 base_url: Some("http://a.example/v1".to_string()),
2584 tags: BTreeMap::from([("billing".to_string(), "monthly".to_string())]),
2585 ..ProviderConfigV4::default()
2586 },
2587 ),
2588 (
2589 "b".to_string(),
2590 ProviderConfigV4 {
2591 base_url: Some("http://b.example/v1".to_string()),
2592 tags: BTreeMap::from([("billing".to_string(), "monthly".to_string())]),
2593 ..ProviderConfigV4::default()
2594 },
2595 ),
2596 ]);
2597 let request = RouteRequestContext::default();
2598 let ordered = ServiceViewV4 {
2599 providers: providers.clone(),
2600 routing: Some(RoutingConfigV4 {
2601 entry: "root".to_string(),
2602 routes: BTreeMap::from([(
2603 "root".to_string(),
2604 RoutingNodeV4 {
2605 strategy: RoutingPolicyV4::OrderedFailover,
2606 children: vec!["a".to_string(), "b".to_string()],
2607 ..RoutingNodeV4::default()
2608 },
2609 )]),
2610 ..RoutingConfigV4::default()
2611 }),
2612 ..ServiceViewV4::default()
2613 };
2614 let tag_preferred = ServiceViewV4 {
2615 providers,
2616 routing: Some(RoutingConfigV4 {
2617 entry: "root".to_string(),
2618 routes: BTreeMap::from([(
2619 "root".to_string(),
2620 RoutingNodeV4 {
2621 strategy: RoutingPolicyV4::TagPreferred,
2622 children: vec!["a".to_string(), "b".to_string()],
2623 prefer_tags: vec![BTreeMap::from([(
2624 "billing".to_string(),
2625 "monthly".to_string(),
2626 )])],
2627 on_exhausted: RoutingExhaustedActionV4::Continue,
2628 ..RoutingNodeV4::default()
2629 },
2630 )]),
2631 ..RoutingConfigV4::default()
2632 }),
2633 ..ServiceViewV4::default()
2634 };
2635
2636 let ordered_template =
2637 compile_v4_route_plan_template_with_request("routing-ir-test", &ordered, &request)
2638 .expect("ordered template");
2639 let tag_preferred_template = compile_v4_route_plan_template_with_request(
2640 "routing-ir-test",
2641 &tag_preferred,
2642 &request,
2643 )
2644 .expect("tag-preferred template");
2645
2646 assert_eq!(
2647 provider_ids(&ordered_template),
2648 provider_ids(&tag_preferred_template)
2649 );
2650 assert_ne!(
2651 ordered_template.route_graph_key(),
2652 tag_preferred_template.route_graph_key()
2653 );
2654 }
2655
2656 #[derive(Debug, Clone, PartialEq, Eq)]
2657 struct UpstreamSignature {
2658 station_name: String,
2659 index: usize,
2660 base_url: String,
2661 tags: BTreeMap<String, String>,
2662 supported_models: BTreeMap<String, bool>,
2663 model_mapping: BTreeMap<String, String>,
2664 }
2665
2666 #[derive(Debug, PartialEq, Eq)]
2667 struct AttemptOrderEvent {
2668 decision: &'static str,
2669 upstream: UpstreamSignature,
2670 avoid_for_station: Vec<usize>,
2671 avoided_total: usize,
2672 total_upstreams: usize,
2673 reason: Option<&'static str>,
2674 }
2675
2676 fn hash_string_map_to_btree(values: &HashMap<String, String>) -> BTreeMap<String, String> {
2677 values
2678 .iter()
2679 .map(|(key, value)| (key.clone(), value.clone()))
2680 .collect()
2681 }
2682
2683 fn legacy_parity_tags(values: &HashMap<String, String>) -> BTreeMap<String, String> {
2684 values
2685 .iter()
2686 .filter(|(key, _)| {
2687 !matches!(
2688 key.as_str(),
2689 "endpoint_id" | "provider_endpoint_key" | "route_path" | "preference_group"
2690 )
2691 })
2692 .map(|(key, value)| (key.clone(), value.clone()))
2693 .collect()
2694 }
2695
2696 fn hash_bool_map_to_btree(values: &HashMap<String, bool>) -> BTreeMap<String, bool> {
2697 values
2698 .iter()
2699 .map(|(key, value)| (key.clone(), *value))
2700 .collect()
2701 }
2702
2703 fn upstream_signature(selected: &SelectedUpstream) -> UpstreamSignature {
2704 UpstreamSignature {
2705 station_name: selected.station_name.clone(),
2706 index: selected.index,
2707 base_url: selected.upstream.base_url.clone(),
2708 tags: legacy_parity_tags(&selected.upstream.tags),
2709 supported_models: hash_bool_map_to_btree(&selected.upstream.supported_models),
2710 model_mapping: hash_string_map_to_btree(&selected.upstream.model_mapping),
2711 }
2712 }
2713
2714 fn provider_ids_from_attempt_events(events: &[AttemptOrderEvent]) -> Vec<String> {
2715 events
2716 .iter()
2717 .map(|event| {
2718 event
2719 .upstream
2720 .tags
2721 .get("provider_id")
2722 .expect("provider_id tag")
2723 .clone()
2724 })
2725 .collect()
2726 }
2727
2728 fn skip_reason(reason: &RoutePlanSkipReason) -> &'static str {
2729 reason.code()
2730 }
2731
2732 fn sorted_hash_set(values: &HashSet<usize>) -> Vec<usize> {
2733 let mut sorted = values.iter().copied().collect::<Vec<_>>();
2734 sorted.sort_unstable();
2735 sorted
2736 }
2737
2738 fn station_exhausted(upstream_total: usize, avoid: &HashSet<usize>) -> bool {
2739 upstream_total > 0
2740 && avoid.iter().filter(|&&idx| idx < upstream_total).count() >= upstream_total
2741 }
2742
2743 fn executor_selected_upstream_signatures(
2744 template: &RoutePlanTemplate,
2745 ) -> Vec<UpstreamSignature> {
2746 RoutePlanExecutor::new(template)
2747 .iter_selected_upstreams()
2748 .map(|selected| upstream_signature(&selected.selected_upstream))
2749 .collect()
2750 }
2751
2752 fn legacy_routing_load_balancer(view: ServiceViewV4) -> LoadBalancer {
2753 let runtime = compile_v4_to_runtime(&ProxyConfigV4 {
2754 codex: view,
2755 ..ProxyConfigV4::default()
2756 })
2757 .expect("compile v4 runtime");
2758 let service = runtime
2759 .codex
2760 .station("routing")
2761 .expect("routing station")
2762 .clone();
2763 LoadBalancer::new(
2764 Arc::new(service),
2765 Arc::new(Mutex::new(HashMap::<String, LbState>::new())),
2766 )
2767 }
2768
2769 fn legacy_load_balancer_selected_upstream_signatures(
2770 view: ServiceViewV4,
2771 ) -> Vec<UpstreamSignature> {
2772 let lb = legacy_routing_load_balancer(view);
2773 let upstream_count = lb.service.upstreams.len();
2774 let mut avoid = HashSet::new();
2775 let mut selected = Vec::new();
2776 while selected.len() < upstream_count {
2777 let next = lb
2778 .select_upstream_avoiding_strict(&avoid)
2779 .expect("legacy selected upstream");
2780 avoid.insert(next.index);
2781 selected.push(upstream_signature(&next));
2782 }
2783 selected
2784 }
2785
2786 fn legacy_shadow_attempt_order_signatures(
2787 view: ServiceViewV4,
2788 request_model: Option<&str>,
2789 ) -> Vec<AttemptOrderEvent> {
2790 let lb = legacy_routing_load_balancer(view);
2791 let total_upstreams = lb.service.upstreams.len();
2792 let mut avoid = HashSet::new();
2793 let mut avoided_total = 0usize;
2794 let mut events = Vec::new();
2795
2796 while !station_exhausted(total_upstreams, &avoid) {
2797 let Some(selected) = lb.select_upstream_avoiding_strict(&avoid) else {
2798 break;
2799 };
2800
2801 if let Some(requested_model) = request_model {
2802 let supported = model_routing::is_model_supported(
2803 &selected.upstream.supported_models,
2804 &selected.upstream.model_mapping,
2805 requested_model,
2806 );
2807 if !supported {
2808 if avoid.insert(selected.index) {
2809 avoided_total = avoided_total.saturating_add(1);
2810 }
2811 events.push(AttemptOrderEvent {
2812 decision: "skipped_capability_mismatch",
2813 upstream: upstream_signature(&selected),
2814 avoid_for_station: sorted_hash_set(&avoid),
2815 avoided_total,
2816 total_upstreams,
2817 reason: Some("unsupported_model"),
2818 });
2819 continue;
2820 }
2821 }
2822
2823 events.push(AttemptOrderEvent {
2824 decision: "selected",
2825 upstream: upstream_signature(&selected),
2826 avoid_for_station: sorted_hash_set(&avoid),
2827 avoided_total,
2828 total_upstreams,
2829 reason: None,
2830 });
2831
2832 if avoid.insert(selected.index) {
2833 avoided_total = avoided_total.saturating_add(1);
2834 }
2835 }
2836
2837 events
2838 }
2839
2840 fn executor_shadow_attempt_order_signatures(
2841 view: &ServiceViewV4,
2842 request_model: Option<&str>,
2843 ) -> Vec<AttemptOrderEvent> {
2844 let template = compile_v4_route_plan_template("codex", view).expect("route template");
2845 let executor = RoutePlanExecutor::new(&template);
2846 let mut state = RoutePlanAttemptState::default();
2847 let mut events = Vec::new();
2848
2849 loop {
2850 let selection = executor.select_supported_candidate(&mut state, request_model);
2851 events.extend(
2852 selection
2853 .skipped
2854 .into_iter()
2855 .map(|skipped| AttemptOrderEvent {
2856 decision: "skipped_capability_mismatch",
2857 upstream: upstream_signature(
2858 &executor.legacy_selected_upstream_for_candidate(skipped.candidate),
2859 ),
2860 avoid_for_station: skipped.avoided_candidate_indices,
2861 avoided_total: skipped.avoided_total,
2862 total_upstreams: skipped.total_upstreams,
2863 reason: Some(skip_reason(&skipped.reason)),
2864 }),
2865 );
2866
2867 let Some(selected) = selection.selected else {
2868 break;
2869 };
2870 events.push(AttemptOrderEvent {
2871 decision: "selected",
2872 upstream: upstream_signature(
2873 &executor.legacy_selected_upstream_for_candidate(selected.candidate),
2874 ),
2875 avoid_for_station: selection.avoided_candidate_indices,
2876 avoided_total: selection.avoided_total,
2877 total_upstreams: selection.total_upstreams,
2878 reason: None,
2879 });
2880 state.avoid_selected(&selected);
2881 }
2882
2883 events
2884 }
2885
2886 fn assert_executor_matches_legacy_load_balancer(view: ServiceViewV4) {
2887 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
2888 assert_eq!(
2889 executor_selected_upstream_signatures(&template),
2890 legacy_load_balancer_selected_upstream_signatures(view)
2891 );
2892 }
2893
2894 #[test]
2895 fn routing_ir_one_provider_matches_resolved_order() {
2896 let view = ServiceViewV4 {
2897 providers: BTreeMap::from([(
2898 "input".to_string(),
2899 provider("https://input.example/v1"),
2900 )]),
2901 ..ServiceViewV4::default()
2902 };
2903
2904 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
2905
2906 assert_provider_order_parity(&view, &template);
2907 assert_eq!(template.entry, "main");
2908 assert_eq!(template.candidates[0].endpoint_id, "default");
2909 assert_eq!(template.candidates[0].base_url, "https://input.example/v1");
2910 assert_eq!(template.candidates[0].route_path, vec!["main", "input"]);
2911 assert_eq!(
2912 template.candidates[0]
2913 .tags
2914 .get("provider_id")
2915 .map(String::as_str),
2916 Some("input")
2917 );
2918 }
2919
2920 #[test]
2921 fn routing_ir_v4_candidate_identity_retains_provider_endpoint_without_synthetic_legacy_key() {
2922 let view = ServiceViewV4 {
2923 providers: BTreeMap::from([(
2924 "input".to_string(),
2925 provider("https://input.example/v1"),
2926 )]),
2927 ..ServiceViewV4::default()
2928 };
2929
2930 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
2931 let identities = template.candidate_identities();
2932
2933 assert_eq!(identities.len(), 1);
2934 assert_eq!(
2935 identities[0].provider_endpoint.stable_key(),
2936 "codex/input/default"
2937 );
2938 assert!(identities[0].compatibility.is_none());
2939 assert_eq!(identities[0].base_url, "https://input.example/v1");
2940 }
2941
2942 #[test]
2943 fn routing_ir_ordered_failover_matches_resolved_order() {
2944 let view = ServiceViewV4 {
2945 providers: BTreeMap::from([
2946 (
2947 "primary".to_string(),
2948 provider("https://primary.example/v1"),
2949 ),
2950 ("backup".to_string(), provider("https://backup.example/v1")),
2951 ]),
2952 routing: Some(RoutingConfigV4::ordered_failover(vec![
2953 "backup".to_string(),
2954 "primary".to_string(),
2955 ])),
2956 ..ServiceViewV4::default()
2957 };
2958
2959 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
2960
2961 assert_provider_order_parity(&view, &template);
2962 assert_eq!(provider_ids(&template), vec!["backup", "primary"]);
2963 }
2964
2965 #[test]
2966 fn routing_ir_nested_route_graph_preserves_candidate_order_and_path() {
2967 let view = ServiceViewV4 {
2968 providers: BTreeMap::from([
2969 (
2970 "input".to_string(),
2971 tagged_provider("https://input.example/v1", "billing", "monthly"),
2972 ),
2973 (
2974 "input1".to_string(),
2975 tagged_provider("https://input1.example/v1", "billing", "monthly"),
2976 ),
2977 (
2978 "paygo".to_string(),
2979 tagged_provider("https://paygo.example/v1", "billing", "paygo"),
2980 ),
2981 ]),
2982 routing: Some(RoutingConfigV4 {
2983 entry: "monthly_first".to_string(),
2984 routes: BTreeMap::from([
2985 (
2986 "monthly_pool".to_string(),
2987 RoutingNodeV4 {
2988 strategy: RoutingPolicyV4::OrderedFailover,
2989 children: vec!["input".to_string(), "input1".to_string()],
2990 ..RoutingNodeV4::default()
2991 },
2992 ),
2993 (
2994 "monthly_first".to_string(),
2995 RoutingNodeV4 {
2996 strategy: RoutingPolicyV4::OrderedFailover,
2997 children: vec!["monthly_pool".to_string(), "paygo".to_string()],
2998 ..RoutingNodeV4::default()
2999 },
3000 ),
3001 ]),
3002 ..RoutingConfigV4::default()
3003 }),
3004 ..ServiceViewV4::default()
3005 };
3006
3007 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3008
3009 assert_provider_order_parity(&view, &template);
3010 assert_eq!(provider_ids(&template), vec!["input", "input1", "paygo"]);
3011 assert_eq!(
3012 template.candidates[1].route_path,
3013 vec!["monthly_first", "monthly_pool", "input1"]
3014 );
3015 assert_eq!(
3016 template.candidates[2].route_path,
3017 vec!["monthly_first", "paygo"]
3018 );
3019 assert_eq!(
3020 provider_preference_groups(&template),
3021 vec![
3022 ("input".to_string(), 0),
3023 ("input1".to_string(), 1),
3024 ("paygo".to_string(), 2),
3025 ]
3026 );
3027 }
3028
3029 #[test]
3030 fn routing_ir_manual_sticky_matches_resolved_order() {
3031 let view = ServiceViewV4 {
3032 providers: BTreeMap::from([
3033 (
3034 "primary".to_string(),
3035 provider("https://primary.example/v1"),
3036 ),
3037 ("backup".to_string(), provider("https://backup.example/v1")),
3038 ]),
3039 routing: Some(RoutingConfigV4::manual_sticky(
3040 "backup".to_string(),
3041 vec!["backup".to_string(), "primary".to_string()],
3042 )),
3043 ..ServiceViewV4::default()
3044 };
3045
3046 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3047
3048 assert_provider_order_parity(&view, &template);
3049 assert_eq!(provider_ids(&template), vec!["backup"]);
3050 assert_eq!(template.candidates[0].route_path, vec!["main", "backup"]);
3051 }
3052
3053 #[test]
3054 fn routing_ir_tag_preferred_continue_matches_resolved_order() {
3055 let view = ServiceViewV4 {
3056 providers: BTreeMap::from([
3057 (
3058 "monthly".to_string(),
3059 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
3060 ),
3061 (
3062 "paygo".to_string(),
3063 tagged_provider("https://paygo.example/v1", "billing", "paygo"),
3064 ),
3065 ]),
3066 routing: Some(RoutingConfigV4::tag_preferred(
3067 vec!["paygo".to_string(), "monthly".to_string()],
3068 vec![BTreeMap::from([(
3069 "billing".to_string(),
3070 "monthly".to_string(),
3071 )])],
3072 RoutingExhaustedActionV4::Continue,
3073 )),
3074 ..ServiceViewV4::default()
3075 };
3076
3077 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3078
3079 assert_provider_order_parity(&view, &template);
3080 assert_eq!(provider_ids(&template), vec!["monthly", "paygo"]);
3081 assert_eq!(
3082 provider_preference_groups(&template),
3083 vec![("monthly".to_string(), 0), ("paygo".to_string(), 1)]
3084 );
3085 assert_eq!(
3086 template.candidates[0]
3087 .to_upstream_config()
3088 .tags
3089 .get("preference_group")
3090 .map(String::as_str),
3091 Some("0")
3092 );
3093 assert_eq!(
3094 template.candidates[1]
3095 .to_upstream_config()
3096 .tags
3097 .get("preference_group")
3098 .map(String::as_str),
3099 Some("1")
3100 );
3101 }
3102
3103 #[test]
3104 fn routing_ir_tag_preferred_stop_matches_resolved_order() {
3105 let view = ServiceViewV4 {
3106 providers: BTreeMap::from([
3107 (
3108 "monthly".to_string(),
3109 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
3110 ),
3111 (
3112 "paygo".to_string(),
3113 tagged_provider("https://paygo.example/v1", "billing", "paygo"),
3114 ),
3115 ]),
3116 routing: Some(RoutingConfigV4::tag_preferred(
3117 vec!["paygo".to_string(), "monthly".to_string()],
3118 vec![BTreeMap::from([(
3119 "billing".to_string(),
3120 "monthly".to_string(),
3121 )])],
3122 RoutingExhaustedActionV4::Stop,
3123 )),
3124 ..ServiceViewV4::default()
3125 };
3126
3127 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3128
3129 assert_provider_order_parity(&view, &template);
3130 assert_eq!(provider_ids(&template), vec!["monthly"]);
3131 }
3132
3133 #[test]
3134 fn routing_ir_tag_preferred_marks_nested_preference_groups() {
3135 let view = ServiceViewV4 {
3136 providers: BTreeMap::from([
3137 (
3138 "monthly-a".to_string(),
3139 tagged_provider("https://monthly-a.example/v1", "billing", "monthly"),
3140 ),
3141 (
3142 "monthly-b".to_string(),
3143 tagged_provider("https://monthly-b.example/v1", "billing", "monthly"),
3144 ),
3145 (
3146 "chili".to_string(),
3147 tagged_provider("https://chili.example/v1", "billing", "paygo"),
3148 ),
3149 ]),
3150 routing: Some(RoutingConfigV4 {
3151 entry: "monthly_first".to_string(),
3152 routes: BTreeMap::from([
3153 (
3154 "monthly_pool".to_string(),
3155 RoutingNodeV4 {
3156 strategy: RoutingPolicyV4::OrderedFailover,
3157 children: vec!["monthly-a".to_string(), "monthly-b".to_string()],
3158 ..RoutingNodeV4::default()
3159 },
3160 ),
3161 (
3162 "monthly_first".to_string(),
3163 RoutingNodeV4 {
3164 strategy: RoutingPolicyV4::TagPreferred,
3165 children: vec!["chili".to_string(), "monthly_pool".to_string()],
3166 prefer_tags: vec![BTreeMap::from([(
3167 "billing".to_string(),
3168 "monthly".to_string(),
3169 )])],
3170 on_exhausted: RoutingExhaustedActionV4::Continue,
3171 ..RoutingNodeV4::default()
3172 },
3173 ),
3174 ]),
3175 ..RoutingConfigV4::default()
3176 }),
3177 ..ServiceViewV4::default()
3178 };
3179
3180 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3181
3182 assert_provider_order_parity(&view, &template);
3183 assert_eq!(
3184 provider_ids(&template),
3185 vec!["monthly-a", "monthly-b", "chili"]
3186 );
3187 assert_eq!(
3188 provider_preference_groups(&template),
3189 vec![
3190 ("monthly-a".to_string(), 0),
3191 ("monthly-b".to_string(), 1),
3192 ("chili".to_string(), 2),
3193 ]
3194 );
3195 }
3196
3197 #[test]
3198 fn routing_ir_conditional_route_selects_then_branch_for_matching_request() {
3199 let view = ServiceViewV4 {
3200 providers: BTreeMap::from([
3201 ("small".to_string(), provider("https://small.example/v1")),
3202 ("large".to_string(), provider("https://large.example/v1")),
3203 ]),
3204 routing: Some(RoutingConfigV4 {
3205 entry: "root".to_string(),
3206 routes: BTreeMap::from([(
3207 "root".to_string(),
3208 RoutingNodeV4 {
3209 strategy: RoutingPolicyV4::Conditional,
3210 when: Some(RoutingConditionV4 {
3211 model: Some("gpt-5".to_string()),
3212 ..RoutingConditionV4::default()
3213 }),
3214 then: Some("large".to_string()),
3215 default_route: Some("small".to_string()),
3216 ..RoutingNodeV4::default()
3217 },
3218 )]),
3219 ..RoutingConfigV4::default()
3220 }),
3221 ..ServiceViewV4::default()
3222 };
3223
3224 let template = compile_v4_route_plan_template_with_request(
3225 "codex",
3226 &view,
3227 &RouteRequestContext {
3228 model: Some("gpt-5".to_string()),
3229 ..RouteRequestContext::default()
3230 },
3231 )
3232 .expect("conditional route template");
3233
3234 assert_eq!(provider_ids(&template), vec!["large"]);
3235 assert_eq!(template.candidates[0].route_path, vec!["root", "large"]);
3236 }
3237
3238 #[test]
3239 fn routing_ir_conditional_route_uses_default_branch_for_no_match() {
3240 let view = ServiceViewV4 {
3241 providers: BTreeMap::from([
3242 ("small".to_string(), provider("https://small.example/v1")),
3243 ("large".to_string(), provider("https://large.example/v1")),
3244 ]),
3245 routing: Some(RoutingConfigV4 {
3246 entry: "root".to_string(),
3247 routes: BTreeMap::from([(
3248 "root".to_string(),
3249 RoutingNodeV4 {
3250 strategy: RoutingPolicyV4::Conditional,
3251 when: Some(RoutingConditionV4 {
3252 service_tier: Some("priority".to_string()),
3253 ..RoutingConditionV4::default()
3254 }),
3255 then: Some("large".to_string()),
3256 default_route: Some("small".to_string()),
3257 ..RoutingNodeV4::default()
3258 },
3259 )]),
3260 ..RoutingConfigV4::default()
3261 }),
3262 ..ServiceViewV4::default()
3263 };
3264
3265 let template = compile_v4_route_plan_template_with_request(
3266 "codex",
3267 &view,
3268 &RouteRequestContext {
3269 service_tier: Some("default".to_string()),
3270 ..RouteRequestContext::default()
3271 },
3272 )
3273 .expect("conditional route template");
3274
3275 assert_eq!(provider_ids(&template), vec!["small"]);
3276 assert_eq!(template.candidates[0].route_path, vec!["root", "small"]);
3277 }
3278
3279 #[test]
3280 fn routing_ir_conditional_route_composes_with_ordered_fallback_branch() {
3281 let view = ServiceViewV4 {
3282 providers: BTreeMap::from([
3283 (
3284 "large-primary".to_string(),
3285 provider("https://large-primary.example/v1"),
3286 ),
3287 (
3288 "large-backup".to_string(),
3289 provider("https://large-backup.example/v1"),
3290 ),
3291 ("small".to_string(), provider("https://small.example/v1")),
3292 ]),
3293 routing: Some(RoutingConfigV4 {
3294 entry: "root".to_string(),
3295 routes: BTreeMap::from([
3296 (
3297 "root".to_string(),
3298 RoutingNodeV4 {
3299 strategy: RoutingPolicyV4::Conditional,
3300 when: Some(RoutingConditionV4 {
3301 model: Some("gpt-5".to_string()),
3302 ..RoutingConditionV4::default()
3303 }),
3304 then: Some("large_pool".to_string()),
3305 default_route: Some("small".to_string()),
3306 ..RoutingNodeV4::default()
3307 },
3308 ),
3309 (
3310 "large_pool".to_string(),
3311 RoutingNodeV4 {
3312 strategy: RoutingPolicyV4::OrderedFailover,
3313 children: vec!["large-primary".to_string(), "large-backup".to_string()],
3314 ..RoutingNodeV4::default()
3315 },
3316 ),
3317 ]),
3318 ..RoutingConfigV4::default()
3319 }),
3320 ..ServiceViewV4::default()
3321 };
3322
3323 let template = compile_v4_route_plan_template_with_request(
3324 "codex",
3325 &view,
3326 &RouteRequestContext {
3327 model: Some("gpt-5".to_string()),
3328 ..RouteRequestContext::default()
3329 },
3330 )
3331 .expect("conditional route template");
3332
3333 assert_eq!(
3334 provider_ids(&template),
3335 vec!["large-primary", "large-backup"]
3336 );
3337 assert_eq!(
3338 template.candidates[1].route_path,
3339 vec!["root", "large_pool", "large-backup"]
3340 );
3341 }
3342
3343 #[test]
3344 fn routing_ir_conditional_route_rejects_missing_default() {
3345 let view = ServiceViewV4 {
3346 providers: BTreeMap::from([
3347 ("small".to_string(), provider("https://small.example/v1")),
3348 ("large".to_string(), provider("https://large.example/v1")),
3349 ]),
3350 routing: Some(RoutingConfigV4 {
3351 entry: "root".to_string(),
3352 routes: BTreeMap::from([(
3353 "root".to_string(),
3354 RoutingNodeV4 {
3355 strategy: RoutingPolicyV4::Conditional,
3356 when: Some(RoutingConditionV4 {
3357 model: Some("gpt-5".to_string()),
3358 ..RoutingConditionV4::default()
3359 }),
3360 then: Some("large".to_string()),
3361 ..RoutingNodeV4::default()
3362 },
3363 )]),
3364 ..RoutingConfigV4::default()
3365 }),
3366 ..ServiceViewV4::default()
3367 };
3368
3369 let err = compile_v4_route_plan_template_with_request(
3370 "codex",
3371 &view,
3372 &RouteRequestContext {
3373 model: Some("gpt-5".to_string()),
3374 ..RouteRequestContext::default()
3375 },
3376 )
3377 .expect_err("missing default should fail");
3378
3379 assert!(err.to_string().contains("requires default"));
3380 }
3381
3382 #[test]
3383 fn routing_ir_conditional_route_rejects_empty_condition() {
3384 let view = ServiceViewV4 {
3385 providers: BTreeMap::from([
3386 ("small".to_string(), provider("https://small.example/v1")),
3387 ("large".to_string(), provider("https://large.example/v1")),
3388 ]),
3389 routing: Some(RoutingConfigV4 {
3390 entry: "root".to_string(),
3391 routes: BTreeMap::from([(
3392 "root".to_string(),
3393 RoutingNodeV4 {
3394 strategy: RoutingPolicyV4::Conditional,
3395 when: Some(RoutingConditionV4::default()),
3396 then: Some("large".to_string()),
3397 default_route: Some("small".to_string()),
3398 ..RoutingNodeV4::default()
3399 },
3400 )]),
3401 ..RoutingConfigV4::default()
3402 }),
3403 ..ServiceViewV4::default()
3404 };
3405
3406 let err = compile_v4_route_plan_template_with_request(
3407 "codex",
3408 &view,
3409 &RouteRequestContext::default(),
3410 )
3411 .expect_err("empty condition should fail");
3412
3413 assert!(
3414 err.to_string()
3415 .contains("requires at least one condition field")
3416 );
3417 }
3418
3419 #[test]
3420 fn routing_ir_conditional_route_flattens_only_for_compat_runtime_path() {
3421 let view = ServiceViewV4 {
3422 providers: BTreeMap::from([
3423 ("small".to_string(), provider("https://small.example/v1")),
3424 ("large".to_string(), provider("https://large.example/v1")),
3425 ]),
3426 routing: Some(RoutingConfigV4 {
3427 entry: "root".to_string(),
3428 routes: BTreeMap::from([(
3429 "root".to_string(),
3430 RoutingNodeV4 {
3431 strategy: RoutingPolicyV4::Conditional,
3432 when: Some(RoutingConditionV4 {
3433 model: Some("gpt-5".to_string()),
3434 ..RoutingConditionV4::default()
3435 }),
3436 then: Some("large".to_string()),
3437 default_route: Some("small".to_string()),
3438 ..RoutingNodeV4::default()
3439 },
3440 )]),
3441 ..RoutingConfigV4::default()
3442 }),
3443 ..ServiceViewV4::default()
3444 };
3445 let cfg = ProxyConfigV4 {
3446 version: 4,
3447 codex: view,
3448 ..ProxyConfigV4::default()
3449 };
3450
3451 let runtime = compile_v4_to_runtime(&cfg).expect("compat runtime");
3452 let routing = runtime
3453 .codex
3454 .station("routing")
3455 .expect("compat routing station");
3456
3457 assert_eq!(
3458 routing
3459 .upstreams
3460 .iter()
3461 .map(|upstream| upstream
3462 .tags
3463 .get("provider_id")
3464 .map(String::as_str)
3465 .unwrap_or(""))
3466 .collect::<Vec<_>>(),
3467 vec!["large", "small"]
3468 );
3469 }
3470
3471 #[test]
3472 fn routing_ir_candidate_expands_provider_endpoints_in_runtime_order() {
3473 let mut endpoints = BTreeMap::new();
3474 endpoints.insert(
3475 "slow".to_string(),
3476 ProviderEndpointV4 {
3477 base_url: "https://slow.example/v1".to_string(),
3478 continuity_domain: None,
3479 enabled: true,
3480 priority: 10,
3481 tags: BTreeMap::from([("region".to_string(), "us".to_string())]),
3482 supported_models: BTreeMap::from([("gpt-4.1".to_string(), true)]),
3483 model_mapping: BTreeMap::new(),
3484 limits: ProviderConcurrencyLimits::default(),
3485 },
3486 );
3487 endpoints.insert(
3488 "fast".to_string(),
3489 ProviderEndpointV4 {
3490 base_url: "https://fast.example/v1".to_string(),
3491 continuity_domain: None,
3492 enabled: true,
3493 priority: 0,
3494 tags: BTreeMap::from([("region".to_string(), "hk".to_string())]),
3495 supported_models: BTreeMap::new(),
3496 model_mapping: BTreeMap::from([(
3497 "gpt-5".to_string(),
3498 "provider-gpt-5".to_string(),
3499 )]),
3500 limits: ProviderConcurrencyLimits::default(),
3501 },
3502 );
3503 let view = ServiceViewV4 {
3504 providers: BTreeMap::from([(
3505 "input".to_string(),
3506 ProviderConfigV4 {
3507 tags: BTreeMap::from([("billing".to_string(), "monthly".to_string())]),
3508 supported_models: BTreeMap::from([("gpt-5".to_string(), true)]),
3509 endpoints,
3510 ..ProviderConfigV4::default()
3511 },
3512 )]),
3513 ..ServiceViewV4::default()
3514 };
3515
3516 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3517
3518 assert_eq!(provider_ids(&template), vec!["input", "input"]);
3519 assert_eq!(template.candidates[0].endpoint_id, "fast");
3520 assert_eq!(template.candidates[1].endpoint_id, "slow");
3521 assert_eq!(
3522 provider_endpoint_keys(&template),
3523 vec!["codex/input/fast", "codex/input/slow"]
3524 );
3525 assert!(legacy_upstream_keys(&template).is_empty());
3526 assert_eq!(
3527 template.candidates[0]
3528 .tags
3529 .get("billing")
3530 .map(String::as_str),
3531 Some("monthly")
3532 );
3533 assert_eq!(
3534 template.candidates[0]
3535 .tags
3536 .get("region")
3537 .map(String::as_str),
3538 Some("hk")
3539 );
3540 assert_eq!(
3541 template.candidates[0]
3542 .model_mapping
3543 .get("gpt-5")
3544 .map(String::as_str),
3545 Some("provider-gpt-5")
3546 );
3547 assert_eq!(
3548 template.candidates[1].supported_models.get("gpt-5"),
3549 Some(&true)
3550 );
3551 assert_eq!(
3552 template.candidates[1].supported_models.get("gpt-4.1"),
3553 Some(&true)
3554 );
3555 }
3556
3557 #[test]
3558 fn routing_ir_provider_concurrency_limit_compiles_to_default_endpoint() {
3559 let view = ServiceViewV4 {
3560 providers: BTreeMap::from([(
3561 "relay".to_string(),
3562 ProviderConfigV4 {
3563 base_url: Some("https://relay.example/v1".to_string()),
3564 limits: ProviderConcurrencyLimits {
3565 max_concurrent_requests: Some(5),
3566 limit_group: Some(" relay-account ".to_string()),
3567 },
3568 ..ProviderConfigV4::default()
3569 },
3570 )]),
3571 ..ServiceViewV4::default()
3572 };
3573
3574 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3575 let concurrency = &template.candidates[0].concurrency;
3576
3577 assert_eq!(concurrency.max_concurrent_requests, Some(5));
3578 assert_eq!(concurrency.limit_group.as_deref(), Some("relay-account"));
3579 assert_eq!(
3580 concurrency.limit_key(
3581 "codex",
3582 &template.candidate_provider_endpoint_key(&template.candidates[0])
3583 ),
3584 Some("codex/relay-account".to_string())
3585 );
3586 }
3587
3588 #[test]
3589 fn routing_ir_continuity_domain_defaults_to_endpoint_and_supports_explicit_overrides() {
3590 let view = ServiceViewV4 {
3591 providers: BTreeMap::from([
3592 (
3593 "opaque".to_string(),
3594 ProviderConfigV4 {
3595 base_url: Some("https://same.example/v1".to_string()),
3596 ..ProviderConfigV4::default()
3597 },
3598 ),
3599 (
3600 "shared".to_string(),
3601 ProviderConfigV4 {
3602 continuity_domain: Some(" shared-cluster ".to_string()),
3603 endpoints: BTreeMap::from([
3604 (
3605 "default".to_string(),
3606 ProviderEndpointV4 {
3607 base_url: "https://shared-default.example/v1".to_string(),
3608 continuity_domain: None,
3609 enabled: true,
3610 priority: 0,
3611 tags: BTreeMap::new(),
3612 supported_models: BTreeMap::new(),
3613 model_mapping: BTreeMap::new(),
3614 limits: ProviderConcurrencyLimits::default(),
3615 },
3616 ),
3617 (
3618 "isolated".to_string(),
3619 ProviderEndpointV4 {
3620 base_url: "https://shared-isolated.example/v1".to_string(),
3621 continuity_domain: Some("isolated-cluster".to_string()),
3622 enabled: true,
3623 priority: 1,
3624 tags: BTreeMap::new(),
3625 supported_models: BTreeMap::new(),
3626 model_mapping: BTreeMap::new(),
3627 limits: ProviderConcurrencyLimits::default(),
3628 },
3629 ),
3630 ]),
3631 ..ProviderConfigV4::default()
3632 },
3633 ),
3634 ]),
3635 routing: Some(RoutingConfigV4::ordered_failover(vec![
3636 "opaque".to_string(),
3637 "shared".to_string(),
3638 ])),
3639 ..ServiceViewV4::default()
3640 };
3641 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3642 let domains = template
3643 .candidates
3644 .iter()
3645 .map(|candidate| {
3646 (
3647 format!("{}/{}", candidate.provider_id, candidate.endpoint_id),
3648 template
3649 .candidate_continuity_domain_key(candidate)
3650 .stable_key(),
3651 candidate.continuity_domain.as_deref(),
3652 )
3653 })
3654 .collect::<Vec<_>>();
3655
3656 assert_eq!(
3657 domains,
3658 vec![
3659 (
3660 "opaque/default".to_string(),
3661 "provider_endpoint:codex/opaque/default".to_string(),
3662 None,
3663 ),
3664 (
3665 "shared/default".to_string(),
3666 "explicit:codex/shared-cluster".to_string(),
3667 Some("shared-cluster"),
3668 ),
3669 (
3670 "shared/isolated".to_string(),
3671 "explicit:codex/isolated-cluster".to_string(),
3672 Some("isolated-cluster"),
3673 ),
3674 ]
3675 );
3676 }
3677
3678 #[test]
3679 fn routing_ir_continuity_topology_summarizes_endpoint_and_domain_counts() {
3680 let view = ServiceViewV4 {
3681 providers: BTreeMap::from([
3682 (
3683 "alpha".to_string(),
3684 ProviderConfigV4 {
3685 continuity_domain: Some("shared-relay".to_string()),
3686 endpoints: BTreeMap::from([
3687 (
3688 "one".to_string(),
3689 ProviderEndpointV4 {
3690 base_url: "https://alpha-one.example/v1".to_string(),
3691 continuity_domain: None,
3692 enabled: true,
3693 priority: 0,
3694 tags: BTreeMap::new(),
3695 supported_models: BTreeMap::new(),
3696 model_mapping: BTreeMap::new(),
3697 limits: ProviderConcurrencyLimits::default(),
3698 },
3699 ),
3700 (
3701 "two".to_string(),
3702 ProviderEndpointV4 {
3703 base_url: "https://alpha-two.example/v1".to_string(),
3704 continuity_domain: None,
3705 enabled: true,
3706 priority: 1,
3707 tags: BTreeMap::new(),
3708 supported_models: BTreeMap::new(),
3709 model_mapping: BTreeMap::new(),
3710 limits: ProviderConcurrencyLimits::default(),
3711 },
3712 ),
3713 ]),
3714 ..ProviderConfigV4::default()
3715 },
3716 ),
3717 (
3718 "beta".to_string(),
3719 ProviderConfigV4 {
3720 base_url: Some("https://beta.example/v1".to_string()),
3721 ..ProviderConfigV4::default()
3722 },
3723 ),
3724 ]),
3725 routing: Some(RoutingConfigV4::ordered_failover(vec![
3726 "alpha".to_string(),
3727 "beta".to_string(),
3728 ])),
3729 ..ServiceViewV4::default()
3730 };
3731 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3732 let topology = template.continuity_topology();
3733
3734 assert_eq!(topology.configured_provider_endpoint_count(), 3);
3735
3736 let alpha_summary = topology
3737 .selected_domain_summary("codex/alpha/one")
3738 .expect("alpha domain summary");
3739 assert_eq!(
3740 alpha_summary.domain.stable_key(),
3741 "explicit:codex/shared-relay"
3742 );
3743 assert_eq!(alpha_summary.same_domain_endpoint_count, 2);
3744
3745 let beta_summary = topology
3746 .selected_domain_summary("codex/beta/default")
3747 .expect("beta domain summary");
3748 assert_eq!(
3749 beta_summary.domain.stable_key(),
3750 "provider_endpoint:codex/beta/default"
3751 );
3752 assert_eq!(beta_summary.same_domain_endpoint_count, 1);
3753 assert!(
3754 topology
3755 .selected_domain_summary("codex/missing/default")
3756 .is_none()
3757 );
3758 }
3759
3760 #[test]
3761 fn routing_ir_continuity_topology_counts_unique_provider_endpoints_not_route_occurrences() {
3762 let template = RoutePlanTemplate {
3763 service_name: "codex".to_string(),
3764 entry: "main".to_string(),
3765 affinity_policy: RoutingAffinityPolicyV5::FallbackSticky,
3766 fallback_ttl_ms: None,
3767 reprobe_preferred_after_ms: None,
3768 nodes: BTreeMap::new(),
3769 expanded_provider_order: vec!["relay".to_string()],
3770 compatibility_station_name: None,
3771 candidates: vec![
3772 RouteCandidate {
3773 provider_id: "relay".to_string(),
3774 provider_alias: None,
3775 endpoint_id: "default".to_string(),
3776 base_url: "https://relay.example/v1".to_string(),
3777 continuity_domain: Some("relay-cluster".to_string()),
3778 auth: UpstreamAuth::default(),
3779 tags: BTreeMap::new(),
3780 supported_models: BTreeMap::new(),
3781 model_mapping: BTreeMap::new(),
3782 route_path: vec!["main".to_string(), "preferred".to_string()],
3783 preference_group: 0,
3784 stable_index: 0,
3785 concurrency: RouteCandidateConcurrency::default(),
3786 compatibility_station_name: None,
3787 compatibility_upstream_index: None,
3788 },
3789 RouteCandidate {
3790 provider_id: "relay".to_string(),
3791 provider_alias: None,
3792 endpoint_id: "default".to_string(),
3793 base_url: "https://relay.example/v1".to_string(),
3794 continuity_domain: Some("relay-cluster".to_string()),
3795 auth: UpstreamAuth::default(),
3796 tags: BTreeMap::new(),
3797 supported_models: BTreeMap::new(),
3798 model_mapping: BTreeMap::new(),
3799 route_path: vec!["main".to_string(), "fallback".to_string()],
3800 preference_group: 1,
3801 stable_index: 1,
3802 concurrency: RouteCandidateConcurrency::default(),
3803 compatibility_station_name: None,
3804 compatibility_upstream_index: None,
3805 },
3806 ],
3807 };
3808 assert_eq!(
3809 template
3810 .candidates
3811 .iter()
3812 .filter(|candidate| candidate.provider_id == "relay")
3813 .count(),
3814 2,
3815 "route graph intentionally expands the same provider endpoint twice"
3816 );
3817
3818 let topology = template.continuity_topology();
3819 assert_eq!(topology.configured_provider_endpoint_count(), 1);
3820 let summary = topology
3821 .selected_domain_summary("codex/relay/default")
3822 .expect("relay summary");
3823 assert_eq!(summary.domain.stable_key(), "explicit:codex/relay-cluster");
3824 assert_eq!(summary.same_domain_endpoint_count, 1);
3825 }
3826
3827 #[test]
3828 fn routing_ir_endpoint_concurrency_limit_overrides_provider_limit() {
3829 let view = ServiceViewV4 {
3830 providers: BTreeMap::from([(
3831 "relay".to_string(),
3832 ProviderConfigV4 {
3833 limits: ProviderConcurrencyLimits {
3834 max_concurrent_requests: Some(5),
3835 limit_group: Some("relay-account".to_string()),
3836 },
3837 endpoints: BTreeMap::from([(
3838 "hk".to_string(),
3839 ProviderEndpointV4 {
3840 base_url: "https://hk.relay.example/v1".to_string(),
3841 continuity_domain: None,
3842 enabled: true,
3843 priority: 0,
3844 tags: BTreeMap::new(),
3845 supported_models: BTreeMap::new(),
3846 model_mapping: BTreeMap::new(),
3847 limits: ProviderConcurrencyLimits {
3848 max_concurrent_requests: Some(2),
3849 limit_group: Some("relay-hk".to_string()),
3850 },
3851 },
3852 )]),
3853 ..ProviderConfigV4::default()
3854 },
3855 )]),
3856 ..ServiceViewV4::default()
3857 };
3858
3859 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3860 let concurrency = &template.candidates[0].concurrency;
3861
3862 assert_eq!(concurrency.max_concurrent_requests, Some(2));
3863 assert_eq!(concurrency.limit_group.as_deref(), Some("relay-hk"));
3864 assert_eq!(
3865 concurrency.limit_key(
3866 "codex",
3867 &template.candidate_provider_endpoint_key(&template.candidates[0])
3868 ),
3869 Some("codex/relay-hk".to_string())
3870 );
3871 }
3872
3873 #[test]
3874 fn routing_ir_default_concurrency_is_unlimited_and_keyed_by_provider_endpoint_without_group() {
3875 let view = ServiceViewV4 {
3876 providers: BTreeMap::from([(
3877 "relay".to_string(),
3878 ProviderConfigV4 {
3879 base_url: Some("https://relay.example/v1".to_string()),
3880 ..ProviderConfigV4::default()
3881 },
3882 )]),
3883 ..ServiceViewV4::default()
3884 };
3885
3886 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3887 let concurrency = &template.candidates[0].concurrency;
3888 let provider_endpoint = template.candidate_provider_endpoint_key(&template.candidates[0]);
3889
3890 assert_eq!(concurrency.max_concurrent_requests, None);
3891 assert_eq!(concurrency.limit_group, None);
3892 assert_eq!(concurrency.limit_key("codex", &provider_endpoint), None);
3893
3894 let grouped = RouteCandidateConcurrency {
3895 max_concurrent_requests: Some(3),
3896 limit_group: None,
3897 };
3898 assert_eq!(
3899 grouped.limit_key("codex", &provider_endpoint),
3900 Some("codex/relay/default".to_string())
3901 );
3902 }
3903
3904 #[test]
3905 fn routing_ir_manual_sticky_can_target_provider_endpoint() {
3906 let view = ServiceViewV4 {
3907 providers: BTreeMap::from([(
3908 "input".to_string(),
3909 ProviderConfigV4 {
3910 endpoints: BTreeMap::from([
3911 (
3912 "fast".to_string(),
3913 ProviderEndpointV4 {
3914 base_url: "https://fast.example/v1".to_string(),
3915 continuity_domain: None,
3916 enabled: true,
3917 priority: 0,
3918 tags: BTreeMap::new(),
3919 supported_models: BTreeMap::new(),
3920 model_mapping: BTreeMap::new(),
3921 limits: ProviderConcurrencyLimits::default(),
3922 },
3923 ),
3924 (
3925 "slow".to_string(),
3926 ProviderEndpointV4 {
3927 base_url: "https://slow.example/v1".to_string(),
3928 continuity_domain: None,
3929 enabled: true,
3930 priority: 10,
3931 tags: BTreeMap::new(),
3932 supported_models: BTreeMap::new(),
3933 model_mapping: BTreeMap::new(),
3934 limits: ProviderConcurrencyLimits::default(),
3935 },
3936 ),
3937 ]),
3938 ..ProviderConfigV4::default()
3939 },
3940 )]),
3941 routing: Some(RoutingConfigV4 {
3942 entry: "root".to_string(),
3943 routes: BTreeMap::from([(
3944 "root".to_string(),
3945 RoutingNodeV4 {
3946 strategy: RoutingPolicyV4::ManualSticky,
3947 target: Some("input.slow".to_string()),
3948 children: vec!["input".to_string()],
3949 ..RoutingNodeV4::default()
3950 },
3951 )]),
3952 ..RoutingConfigV4::default()
3953 }),
3954 ..ServiceViewV4::default()
3955 };
3956
3957 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
3958
3959 assert_eq!(provider_ids(&template), vec!["input"]);
3960 assert_eq!(template.candidates[0].endpoint_id, "slow");
3961 assert_eq!(template.candidates[0].base_url, "https://slow.example/v1");
3962 assert_eq!(provider_endpoint_keys(&template), vec!["codex/input/slow"]);
3963 assert_eq!(
3964 template.candidates[0].route_path,
3965 vec!["root".to_string(), "input.slow".to_string()]
3966 );
3967 }
3968
3969 #[test]
3970 fn routing_ir_manual_sticky_rejects_disabled_provider_endpoint_target() {
3971 let view = ServiceViewV4 {
3972 providers: BTreeMap::from([(
3973 "input".to_string(),
3974 ProviderConfigV4 {
3975 endpoints: BTreeMap::from([(
3976 "fast".to_string(),
3977 ProviderEndpointV4 {
3978 base_url: "https://fast.example/v1".to_string(),
3979 continuity_domain: None,
3980 enabled: false,
3981 priority: 0,
3982 tags: BTreeMap::new(),
3983 supported_models: BTreeMap::new(),
3984 model_mapping: BTreeMap::new(),
3985 limits: ProviderConcurrencyLimits::default(),
3986 },
3987 )]),
3988 ..ProviderConfigV4::default()
3989 },
3990 )]),
3991 routing: Some(RoutingConfigV4 {
3992 entry: "root".to_string(),
3993 routes: BTreeMap::from([(
3994 "root".to_string(),
3995 RoutingNodeV4 {
3996 strategy: RoutingPolicyV4::ManualSticky,
3997 target: Some("input.fast".to_string()),
3998 children: vec!["input".to_string()],
3999 ..RoutingNodeV4::default()
4000 },
4001 )]),
4002 ..RoutingConfigV4::default()
4003 }),
4004 ..ServiceViewV4::default()
4005 };
4006
4007 let error = compile_v4_route_plan_template("codex", &view).expect_err("disabled endpoint");
4008
4009 assert!(
4010 error
4011 .to_string()
4012 .contains("targets disabled provider endpoint 'input.fast'")
4013 );
4014 }
4015
4016 #[test]
4017 fn routing_ir_legacy_template_identity_uses_tagged_provider_and_station_index() {
4018 let service = ServiceConfig {
4019 name: "legacy-station".to_string(),
4020 alias: None,
4021 enabled: true,
4022 level: 1,
4023 upstreams: vec![UpstreamConfig {
4024 base_url: "https://legacy.example/v1".to_string(),
4025 auth: UpstreamAuth::default(),
4026 tags: HashMap::from([("provider_id".to_string(), "tagged".to_string())]),
4027 supported_models: HashMap::new(),
4028 model_mapping: HashMap::new(),
4029 }],
4030 };
4031
4032 let template = compile_legacy_route_plan_template("codex", [&service]);
4033 let identities = template.candidate_identities();
4034
4035 assert_eq!(identities.len(), 1);
4036 assert_eq!(
4037 identities[0].provider_endpoint.stable_key(),
4038 "codex/tagged/0"
4039 );
4040 assert_eq!(
4041 identities[0]
4042 .compatibility
4043 .as_ref()
4044 .map(LegacyUpstreamKey::stable_key)
4045 .as_deref(),
4046 Some("codex/legacy-station/0")
4047 );
4048 assert_eq!(identities[0].base_url, "https://legacy.example/v1");
4049 }
4050
4051 #[test]
4052 fn route_plan_executor_matches_legacy_load_balancer_for_nested_route() {
4053 assert_executor_matches_legacy_load_balancer(ServiceViewV4 {
4054 providers: BTreeMap::from([
4055 (
4056 "input".to_string(),
4057 tagged_provider("https://input.example/v1", "billing", "monthly"),
4058 ),
4059 (
4060 "input1".to_string(),
4061 tagged_provider("https://input1.example/v1", "billing", "monthly"),
4062 ),
4063 (
4064 "paygo".to_string(),
4065 tagged_provider("https://paygo.example/v1", "billing", "paygo"),
4066 ),
4067 ]),
4068 routing: Some(RoutingConfigV4 {
4069 entry: "monthly_first".to_string(),
4070 routes: BTreeMap::from([
4071 (
4072 "monthly_pool".to_string(),
4073 RoutingNodeV4 {
4074 strategy: RoutingPolicyV4::OrderedFailover,
4075 children: vec!["input".to_string(), "input1".to_string()],
4076 ..RoutingNodeV4::default()
4077 },
4078 ),
4079 (
4080 "monthly_first".to_string(),
4081 RoutingNodeV4 {
4082 strategy: RoutingPolicyV4::OrderedFailover,
4083 children: vec!["monthly_pool".to_string(), "paygo".to_string()],
4084 ..RoutingNodeV4::default()
4085 },
4086 ),
4087 ]),
4088 ..RoutingConfigV4::default()
4089 }),
4090 ..ServiceViewV4::default()
4091 });
4092 }
4093
4094 #[test]
4095 fn route_plan_executor_matches_legacy_load_balancer_for_tag_preferred() {
4096 assert_executor_matches_legacy_load_balancer(ServiceViewV4 {
4097 providers: BTreeMap::from([
4098 (
4099 "monthly".to_string(),
4100 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4101 ),
4102 (
4103 "paygo".to_string(),
4104 tagged_provider("https://paygo.example/v1", "billing", "paygo"),
4105 ),
4106 ]),
4107 routing: Some(RoutingConfigV4::tag_preferred(
4108 vec!["paygo".to_string(), "monthly".to_string()],
4109 vec![BTreeMap::from([(
4110 "billing".to_string(),
4111 "monthly".to_string(),
4112 )])],
4113 RoutingExhaustedActionV4::Continue,
4114 )),
4115 ..ServiceViewV4::default()
4116 });
4117 }
4118
4119 #[test]
4120 fn route_plan_executor_matches_legacy_load_balancer_for_multi_endpoint_provider() {
4121 let mut endpoints = BTreeMap::new();
4122 endpoints.insert(
4123 "slow".to_string(),
4124 ProviderEndpointV4 {
4125 base_url: "https://slow.example/v1".to_string(),
4126 continuity_domain: None,
4127 enabled: true,
4128 priority: 10,
4129 tags: BTreeMap::from([("region".to_string(), "us".to_string())]),
4130 supported_models: BTreeMap::from([("gpt-4.1".to_string(), true)]),
4131 model_mapping: BTreeMap::new(),
4132 limits: ProviderConcurrencyLimits::default(),
4133 },
4134 );
4135 endpoints.insert(
4136 "fast".to_string(),
4137 ProviderEndpointV4 {
4138 base_url: "https://fast.example/v1".to_string(),
4139 continuity_domain: None,
4140 enabled: true,
4141 priority: 0,
4142 tags: BTreeMap::from([("region".to_string(), "hk".to_string())]),
4143 supported_models: BTreeMap::new(),
4144 model_mapping: BTreeMap::from([(
4145 "gpt-5".to_string(),
4146 "provider-gpt-5".to_string(),
4147 )]),
4148 limits: ProviderConcurrencyLimits::default(),
4149 },
4150 );
4151
4152 assert_executor_matches_legacy_load_balancer(ServiceViewV4 {
4153 providers: BTreeMap::from([(
4154 "input".to_string(),
4155 ProviderConfigV4 {
4156 tags: BTreeMap::from([("billing".to_string(), "monthly".to_string())]),
4157 supported_models: BTreeMap::from([("gpt-5".to_string(), true)]),
4158 endpoints,
4159 ..ProviderConfigV4::default()
4160 },
4161 )]),
4162 ..ServiceViewV4::default()
4163 });
4164 }
4165
4166 #[test]
4167 fn route_plan_executor_shadow_attempt_order_matches_legacy_failover_avoidance() {
4168 let view = ServiceViewV4 {
4169 providers: BTreeMap::from([
4170 (
4171 "primary".to_string(),
4172 provider("https://primary.example/v1"),
4173 ),
4174 ("backup".to_string(), provider("https://backup.example/v1")),
4175 ("paygo".to_string(), provider("https://paygo.example/v1")),
4176 ]),
4177 routing: Some(RoutingConfigV4::ordered_failover(vec![
4178 "backup".to_string(),
4179 "primary".to_string(),
4180 "paygo".to_string(),
4181 ])),
4182 ..ServiceViewV4::default()
4183 };
4184
4185 let executor_events = executor_shadow_attempt_order_signatures(&view, None);
4186 let legacy_events = legacy_shadow_attempt_order_signatures(view, None);
4187
4188 assert_eq!(executor_events, legacy_events);
4189 assert_eq!(
4190 provider_ids_from_attempt_events(&executor_events),
4191 vec!["backup", "primary", "paygo"]
4192 );
4193 assert_eq!(executor_events[0].avoid_for_station, Vec::<usize>::new());
4194 assert_eq!(executor_events[1].avoid_for_station, vec![0]);
4195 assert_eq!(executor_events[2].avoid_for_station, vec![0, 1]);
4196 }
4197
4198 #[test]
4199 fn route_plan_executor_shadow_attempt_order_matches_legacy_unsupported_model_skip() {
4200 let view = ServiceViewV4 {
4201 providers: BTreeMap::from([
4202 (
4203 "legacy".to_string(),
4204 ProviderConfigV4 {
4205 base_url: Some("https://legacy.example/v1".to_string()),
4206 supported_models: BTreeMap::from([("gpt-4.1".to_string(), true)]),
4207 ..ProviderConfigV4::default()
4208 },
4209 ),
4210 (
4211 "mapped".to_string(),
4212 ProviderConfigV4 {
4213 base_url: Some("https://mapped.example/v1".to_string()),
4214 model_mapping: BTreeMap::from([(
4215 "gpt-5".to_string(),
4216 "provider-gpt-5".to_string(),
4217 )]),
4218 ..ProviderConfigV4::default()
4219 },
4220 ),
4221 (
4222 "fallback".to_string(),
4223 provider("https://fallback.example/v1"),
4224 ),
4225 ]),
4226 routing: Some(RoutingConfigV4::ordered_failover(vec![
4227 "legacy".to_string(),
4228 "mapped".to_string(),
4229 "fallback".to_string(),
4230 ])),
4231 ..ServiceViewV4::default()
4232 };
4233
4234 let executor_events = executor_shadow_attempt_order_signatures(&view, Some("gpt-5"));
4235 let legacy_events = legacy_shadow_attempt_order_signatures(view, Some("gpt-5"));
4236
4237 assert_eq!(executor_events, legacy_events);
4238 assert_eq!(
4239 executor_events
4240 .iter()
4241 .map(|event| event.decision)
4242 .collect::<Vec<_>>(),
4243 vec!["skipped_capability_mismatch", "selected", "selected"]
4244 );
4245 assert_eq!(
4246 provider_ids_from_attempt_events(&executor_events),
4247 vec!["legacy", "mapped", "fallback"]
4248 );
4249 assert_eq!(executor_events[0].reason, Some("unsupported_model"));
4250 assert_eq!(executor_events[0].avoid_for_station, vec![0]);
4251 assert_eq!(executor_events[1].avoid_for_station, vec![0]);
4252 assert_eq!(executor_events[2].avoid_for_station, vec![0, 1]);
4253 }
4254
4255 #[test]
4256 fn route_plan_executor_shadow_attempt_order_matches_legacy_all_unsupported_exhaustion() {
4257 let view = ServiceViewV4 {
4258 providers: BTreeMap::from([
4259 (
4260 "old".to_string(),
4261 ProviderConfigV4 {
4262 base_url: Some("https://old.example/v1".to_string()),
4263 supported_models: BTreeMap::from([("gpt-4.1".to_string(), true)]),
4264 ..ProviderConfigV4::default()
4265 },
4266 ),
4267 (
4268 "older".to_string(),
4269 ProviderConfigV4 {
4270 base_url: Some("https://older.example/v1".to_string()),
4271 supported_models: BTreeMap::from([("gpt-4o".to_string(), true)]),
4272 ..ProviderConfigV4::default()
4273 },
4274 ),
4275 ]),
4276 routing: Some(RoutingConfigV4::ordered_failover(vec![
4277 "old".to_string(),
4278 "older".to_string(),
4279 ])),
4280 ..ServiceViewV4::default()
4281 };
4282
4283 let executor_events = executor_shadow_attempt_order_signatures(&view, Some("gpt-5"));
4284 let legacy_events = legacy_shadow_attempt_order_signatures(view.clone(), Some("gpt-5"));
4285
4286 assert_eq!(executor_events, legacy_events);
4287 assert_eq!(
4288 executor_events
4289 .iter()
4290 .map(|event| event.decision)
4291 .collect::<Vec<_>>(),
4292 vec!["skipped_capability_mismatch", "skipped_capability_mismatch"]
4293 );
4294
4295 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4296 let executor = RoutePlanExecutor::new(&template);
4297 let mut state = RoutePlanAttemptState::default();
4298 let selection = executor.select_supported_candidate(&mut state, Some("gpt-5"));
4299
4300 assert!(selection.selected.is_none());
4301 assert_eq!(selection.skipped.len(), 2);
4302 assert_eq!(selection.avoided_candidate_indices, vec![0, 1]);
4303 assert!(state.route_candidates_exhausted(&template));
4304 }
4305
4306 #[test]
4307 fn route_plan_executor_explains_structured_skip_reasons() {
4308 let view = ServiceViewV4 {
4309 providers: BTreeMap::from([
4310 (
4311 "unsupported".to_string(),
4312 ProviderConfigV4 {
4313 base_url: Some("https://unsupported.example/v1".to_string()),
4314 supported_models: BTreeMap::from([("gpt-4.1".to_string(), true)]),
4315 ..ProviderConfigV4::default()
4316 },
4317 ),
4318 (
4319 "disabled".to_string(),
4320 provider("https://disabled.example/v1"),
4321 ),
4322 (
4323 "cooldown".to_string(),
4324 provider("https://cooldown.example/v1"),
4325 ),
4326 (
4327 "breaker".to_string(),
4328 provider("https://breaker.example/v1"),
4329 ),
4330 ("usage".to_string(), provider("https://usage.example/v1")),
4331 (
4332 "missing-auth".to_string(),
4333 provider("https://missing-auth.example/v1"),
4334 ),
4335 (
4336 "healthy".to_string(),
4337 provider("https://healthy.example/v1"),
4338 ),
4339 ]),
4340 routing: Some(RoutingConfigV4::ordered_failover(vec![
4341 "unsupported".to_string(),
4342 "disabled".to_string(),
4343 "cooldown".to_string(),
4344 "breaker".to_string(),
4345 "usage".to_string(),
4346 "missing-auth".to_string(),
4347 "healthy".to_string(),
4348 ])),
4349 ..ServiceViewV4::default()
4350 };
4351 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4352 let executor = RoutePlanExecutor::new(&template);
4353 let mut runtime = RoutePlanRuntimeState::default();
4354 runtime.set_provider_endpoint(
4355 endpoint_key("codex", "disabled", "default"),
4356 RoutePlanUpstreamRuntimeState {
4357 runtime_disabled: true,
4358 ..RoutePlanUpstreamRuntimeState::default()
4359 },
4360 );
4361 runtime.set_provider_endpoint(
4362 endpoint_key("codex", "cooldown", "default"),
4363 RoutePlanUpstreamRuntimeState {
4364 cooldown_active: true,
4365 ..RoutePlanUpstreamRuntimeState::default()
4366 },
4367 );
4368 runtime.set_provider_endpoint(
4369 endpoint_key("codex", "breaker", "default"),
4370 RoutePlanUpstreamRuntimeState {
4371 failure_count: FAILURE_THRESHOLD,
4372 ..RoutePlanUpstreamRuntimeState::default()
4373 },
4374 );
4375 runtime.set_provider_endpoint(
4376 endpoint_key("codex", "usage", "default"),
4377 RoutePlanUpstreamRuntimeState {
4378 usage_exhausted: true,
4379 ..RoutePlanUpstreamRuntimeState::default()
4380 },
4381 );
4382 runtime.set_provider_endpoint(
4383 endpoint_key("codex", "missing-auth", "default"),
4384 RoutePlanUpstreamRuntimeState {
4385 missing_auth: true,
4386 ..RoutePlanUpstreamRuntimeState::default()
4387 },
4388 );
4389
4390 let explanations =
4391 executor.explain_candidate_skip_reasons_with_runtime_state(&runtime, Some("gpt-5"));
4392 let reasons_by_provider = explanations
4393 .iter()
4394 .map(|explanation| {
4395 (
4396 explanation.candidate.provider_id.as_str(),
4397 explanation
4398 .reasons
4399 .iter()
4400 .map(RoutePlanSkipReason::code)
4401 .collect::<Vec<_>>(),
4402 )
4403 })
4404 .collect::<BTreeMap<_, _>>();
4405
4406 assert_eq!(
4407 reasons_by_provider.get("unsupported").cloned(),
4408 Some(vec!["unsupported_model"])
4409 );
4410 assert_eq!(
4411 reasons_by_provider.get("disabled").cloned(),
4412 Some(vec!["runtime_disabled"])
4413 );
4414 assert_eq!(
4415 reasons_by_provider.get("cooldown").cloned(),
4416 Some(vec!["cooldown"])
4417 );
4418 assert_eq!(
4419 reasons_by_provider.get("breaker").cloned(),
4420 Some(vec!["breaker_open"])
4421 );
4422 assert_eq!(
4423 reasons_by_provider.get("usage").cloned(),
4424 Some(vec!["usage_exhausted"])
4425 );
4426 assert_eq!(
4427 reasons_by_provider.get("missing-auth").cloned(),
4428 Some(vec!["missing_auth"])
4429 );
4430 assert!(!reasons_by_provider.contains_key("healthy"));
4431
4432 let mut state = RoutePlanAttemptState::default();
4433 let selection = executor.select_supported_candidate_with_runtime_state(
4434 &mut state,
4435 &runtime,
4436 Some("gpt-5"),
4437 );
4438 let selected = selection.selected.expect("healthy candidate selected");
4439 assert_eq!(selected.candidate.provider_id, "healthy");
4440 }
4441
4442 #[test]
4443 fn route_plan_executor_skips_saturated_candidate_without_failure_penalty() {
4444 let view = ServiceViewV4 {
4445 providers: BTreeMap::from([
4446 (
4447 "primary".to_string(),
4448 limited_provider("https://primary.example/v1", 5),
4449 ),
4450 ("backup".to_string(), provider("https://backup.example/v1")),
4451 ]),
4452 routing: Some(RoutingConfigV4::ordered_failover(vec![
4453 "primary".to_string(),
4454 "backup".to_string(),
4455 ])),
4456 ..ServiceViewV4::default()
4457 };
4458 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4459 assert_eq!(
4460 template.candidates[0].concurrency.max_concurrent_requests,
4461 Some(5)
4462 );
4463
4464 let executor = RoutePlanExecutor::new(&template);
4465 let mut runtime = RoutePlanRuntimeState::default();
4466 runtime.set_provider_endpoint(
4467 endpoint_key("codex", "primary", "default"),
4468 RoutePlanUpstreamRuntimeState {
4469 concurrency_saturated: true,
4470 concurrency_active: Some(5),
4471 concurrency_limit: Some(5),
4472 ..RoutePlanUpstreamRuntimeState::default()
4473 },
4474 );
4475
4476 let explanations =
4477 executor.explain_candidate_skip_reasons_with_runtime_state(&runtime, None);
4478 assert_eq!(explanations.len(), 1);
4479 assert_eq!(explanations[0].candidate.provider_id, "primary");
4480 assert_eq!(
4481 explanations[0]
4482 .reasons
4483 .iter()
4484 .map(RoutePlanSkipReason::code)
4485 .collect::<Vec<_>>(),
4486 vec!["concurrency_saturated"]
4487 );
4488
4489 let mut state = RoutePlanAttemptState::default();
4490 let selection =
4491 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
4492 let selected = selection.selected.expect("fallback candidate selected");
4493
4494 assert_eq!(selected.candidate.provider_id, "backup");
4495 assert_eq!(selection.avoided_candidate_indices, Vec::<usize>::new());
4496 }
4497
4498 #[test]
4499 fn route_plan_candidate_runtime_snapshot_reports_structured_availability() {
4500 let view = ServiceViewV4 {
4501 providers: BTreeMap::from([(
4502 "primary".to_string(),
4503 ProviderConfigV4 {
4504 base_url: Some("https://primary.example/v1".to_string()),
4505 supported_models: BTreeMap::from([("gpt-4.1".to_string(), true)]),
4506 limits: ProviderConcurrencyLimits {
4507 max_concurrent_requests: Some(2),
4508 limit_group: Some("shared".to_string()),
4509 },
4510 ..ProviderConfigV4::default()
4511 },
4512 )]),
4513 routing: Some(RoutingConfigV4::ordered_failover(vec![
4514 "primary".to_string(),
4515 ])),
4516 ..ServiceViewV4::default()
4517 };
4518 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4519 let candidate = &template.candidates[0];
4520 let mut runtime = RoutePlanRuntimeState::default();
4521 runtime.set_provider_endpoint(
4522 endpoint_key("codex", "primary", "default"),
4523 RoutePlanUpstreamRuntimeState {
4524 cooldown_active: true,
4525 cooldown_remaining_secs: Some(30),
4526 usage_exhausted: true,
4527 missing_auth: true,
4528 concurrency_saturated: true,
4529 concurrency_active: Some(2),
4530 concurrency_limit: Some(2),
4531 ..RoutePlanUpstreamRuntimeState::default()
4532 },
4533 );
4534
4535 let snapshot = runtime.candidate_runtime_snapshot(&template, candidate);
4536
4537 assert!(!snapshot.runtime_available);
4538 assert!(!snapshot.routable_except_usage);
4539 assert!(snapshot.hard_unavailable);
4540 assert!(snapshot.cooldown_active);
4541 assert!(snapshot.breaker_open);
4542 assert_eq!(snapshot.cooldown_remaining_secs, Some(30));
4543 assert!(snapshot.usage_exhausted);
4544 assert!(snapshot.missing_auth);
4545 assert!(snapshot.concurrency_saturated);
4546 assert_eq!(snapshot.concurrency_active, Some(2));
4547 assert_eq!(snapshot.concurrency_limit, Some(2));
4548 assert_eq!(snapshot.effective_max_concurrent_requests, Some(2));
4549 assert_eq!(snapshot.effective_limit_group.as_deref(), Some("shared"));
4550 assert_eq!(
4551 snapshot
4552 .skip_reasons_for_candidate(candidate, Some("gpt-5"))
4553 .iter()
4554 .map(RoutePlanSkipReason::code)
4555 .collect::<Vec<_>>(),
4556 vec![
4557 "unsupported_model",
4558 "cooldown",
4559 "usage_exhausted",
4560 "missing_auth",
4561 "concurrency_saturated"
4562 ]
4563 );
4564 assert_eq!(
4565 snapshot
4566 .dominant_runtime_skip_reason()
4567 .as_ref()
4568 .map(RoutePlanSkipReason::code),
4569 Some("cooldown")
4570 );
4571 }
4572
4573 #[test]
4574 fn route_plan_executor_default_fallback_sticky_keeps_lower_preference_affinity() {
4575 let view = ServiceViewV4 {
4576 providers: BTreeMap::from([
4577 (
4578 "monthly".to_string(),
4579 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4580 ),
4581 (
4582 "chili".to_string(),
4583 tagged_provider("https://chili.example/v1", "billing", "paygo"),
4584 ),
4585 ]),
4586 routing: Some(RoutingConfigV4::tag_preferred(
4587 vec!["chili".to_string(), "monthly".to_string()],
4588 vec![BTreeMap::from([(
4589 "billing".to_string(),
4590 "monthly".to_string(),
4591 )])],
4592 RoutingExhaustedActionV4::Continue,
4593 )),
4594 ..ServiceViewV4::default()
4595 };
4596 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4597 let executor = RoutePlanExecutor::new(&template);
4598 let mut runtime = RoutePlanRuntimeState::default();
4599 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
4600 let mut state = RoutePlanAttemptState::default();
4601
4602 let selection =
4603 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
4604 let selected = selection
4605 .selected
4606 .expect("sticky fallback candidate selected");
4607
4608 assert_eq!(
4609 template.affinity_policy,
4610 RoutingAffinityPolicyV5::FallbackSticky
4611 );
4612 assert_eq!(
4613 provider_preference_groups(&template),
4614 vec![("monthly".to_string(), 0), ("chili".to_string(), 1),]
4615 );
4616 assert_eq!(selected.candidate.provider_id, "chili");
4617 assert_eq!(
4618 selected.provider_endpoint,
4619 endpoint_key("codex", "chili", "default")
4620 );
4621 }
4622
4623 #[test]
4624 fn route_plan_executor_default_fallback_sticky_keeps_lower_order_affinity() {
4625 let view = ServiceViewV4 {
4626 providers: BTreeMap::from([
4627 (
4628 "monthly".to_string(),
4629 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4630 ),
4631 (
4632 "chili".to_string(),
4633 tagged_provider("https://chili.example/v1", "billing", "paygo"),
4634 ),
4635 ]),
4636 routing: Some(RoutingConfigV4::ordered_failover(vec![
4637 "monthly".to_string(),
4638 "chili".to_string(),
4639 ])),
4640 ..ServiceViewV4::default()
4641 };
4642 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4643 let executor = RoutePlanExecutor::new(&template);
4644 let mut runtime = RoutePlanRuntimeState::default();
4645 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
4646 let mut state = RoutePlanAttemptState::default();
4647
4648 let selection =
4649 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
4650 let selected = selection
4651 .selected
4652 .expect("sticky fallback candidate selected");
4653
4654 assert_eq!(
4655 template.affinity_policy,
4656 RoutingAffinityPolicyV5::FallbackSticky
4657 );
4658 assert_eq!(
4659 provider_preference_groups(&template),
4660 vec![("monthly".to_string(), 0), ("chili".to_string(), 1)]
4661 );
4662 assert_eq!(selected.candidate.provider_id, "chili");
4663 assert_eq!(
4664 selected.provider_endpoint,
4665 endpoint_key("codex", "chili", "default")
4666 );
4667 }
4668
4669 #[test]
4670 fn route_plan_executor_preferred_group_opt_in_prefers_primary() {
4671 let mut routing =
4672 RoutingConfigV4::ordered_failover(vec!["monthly".to_string(), "chili".to_string()]);
4673 routing.affinity_policy = RoutingAffinityPolicyV5::PreferredGroup;
4674 let view = ServiceViewV4 {
4675 providers: BTreeMap::from([
4676 (
4677 "monthly".to_string(),
4678 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4679 ),
4680 (
4681 "chili".to_string(),
4682 tagged_provider("https://chili.example/v1", "billing", "paygo"),
4683 ),
4684 ]),
4685 routing: Some(routing),
4686 ..ServiceViewV4::default()
4687 };
4688 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4689 let executor = RoutePlanExecutor::new(&template);
4690 let mut runtime = RoutePlanRuntimeState::default();
4691 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
4692 let mut state = RoutePlanAttemptState::default();
4693
4694 let selection =
4695 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
4696 let selected = selection.selected.expect("ordered primary selected");
4697
4698 assert_eq!(
4699 template.affinity_policy,
4700 RoutingAffinityPolicyV5::PreferredGroup
4701 );
4702 assert_eq!(selected.candidate.provider_id, "monthly");
4703 assert_eq!(
4704 selected.provider_endpoint,
4705 endpoint_key("codex", "monthly", "default")
4706 );
4707 }
4708
4709 #[test]
4710 fn route_plan_executor_affinity_selection_ignores_preferred_group_primary() {
4711 let mut routing =
4712 RoutingConfigV4::ordered_failover(vec!["monthly".to_string(), "chili".to_string()]);
4713 routing.affinity_policy = RoutingAffinityPolicyV5::PreferredGroup;
4714 let view = ServiceViewV4 {
4715 providers: BTreeMap::from([
4716 (
4717 "monthly".to_string(),
4718 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4719 ),
4720 (
4721 "chili".to_string(),
4722 tagged_provider("https://chili.example/v1", "billing", "paygo"),
4723 ),
4724 ]),
4725 routing: Some(routing),
4726 ..ServiceViewV4::default()
4727 };
4728 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4729 let executor = RoutePlanExecutor::new(&template);
4730 let mut runtime = RoutePlanRuntimeState::default();
4731 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
4732
4733 let normal = executor.select_supported_candidate_with_runtime_state(
4734 &mut RoutePlanAttemptState::default(),
4735 &runtime,
4736 None,
4737 );
4738 let normal_selected = normal.selected.expect("normal primary selected");
4739 assert_eq!(normal_selected.candidate.provider_id, "monthly");
4740
4741 let affinity = executor.select_affinity_candidate_with_runtime_state(
4742 &mut RoutePlanAttemptState::default(),
4743 &runtime,
4744 None,
4745 );
4746 let affinity_selected = affinity.selected.expect("affinity candidate selected");
4747 assert_eq!(affinity_selected.candidate.provider_id, "chili");
4748 assert_eq!(
4749 affinity_selected.provider_endpoint,
4750 endpoint_key("codex", "chili", "default")
4751 );
4752 }
4753
4754 #[test]
4755 fn route_plan_executor_affinity_selection_stops_when_affinity_is_unavailable() {
4756 let mut routing =
4757 RoutingConfigV4::ordered_failover(vec!["monthly".to_string(), "chili".to_string()]);
4758 routing.affinity_policy = RoutingAffinityPolicyV5::PreferredGroup;
4759 let view = ServiceViewV4 {
4760 providers: BTreeMap::from([
4761 (
4762 "monthly".to_string(),
4763 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4764 ),
4765 (
4766 "chili".to_string(),
4767 tagged_provider("https://chili.example/v1", "billing", "paygo"),
4768 ),
4769 ]),
4770 routing: Some(routing),
4771 ..ServiceViewV4::default()
4772 };
4773 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4774 let executor = RoutePlanExecutor::new(&template);
4775 let mut runtime = RoutePlanRuntimeState::default();
4776 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
4777 runtime.set_provider_endpoint(
4778 endpoint_key("codex", "chili", "default"),
4779 RoutePlanUpstreamRuntimeState {
4780 cooldown_active: true,
4781 cooldown_remaining_secs: Some(5),
4782 ..RoutePlanUpstreamRuntimeState::default()
4783 },
4784 );
4785
4786 let normal = executor.select_supported_candidate_with_runtime_state(
4787 &mut RoutePlanAttemptState::default(),
4788 &runtime,
4789 None,
4790 );
4791 let normal_selected = normal.selected.expect("normal primary selected");
4792 assert_eq!(normal_selected.candidate.provider_id, "monthly");
4793
4794 let affinity = executor.select_affinity_candidate_with_runtime_state(
4795 &mut RoutePlanAttemptState::default(),
4796 &runtime,
4797 None,
4798 );
4799 assert!(affinity.selected.is_none());
4800 }
4801
4802 #[test]
4803 fn route_plan_executor_uses_fallback_group_only_when_preferred_usage_exhausted() {
4804 let view = ServiceViewV4 {
4805 providers: BTreeMap::from([
4806 (
4807 "monthly".to_string(),
4808 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4809 ),
4810 (
4811 "chili".to_string(),
4812 tagged_provider("https://chili.example/v1", "billing", "paygo"),
4813 ),
4814 ]),
4815 routing: Some(RoutingConfigV4::tag_preferred(
4816 vec!["chili".to_string(), "monthly".to_string()],
4817 vec![BTreeMap::from([(
4818 "billing".to_string(),
4819 "monthly".to_string(),
4820 )])],
4821 RoutingExhaustedActionV4::Continue,
4822 )),
4823 ..ServiceViewV4::default()
4824 };
4825 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4826 let executor = RoutePlanExecutor::new(&template);
4827 let mut runtime = RoutePlanRuntimeState::default();
4828 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
4829 runtime.set_provider_endpoint(
4830 endpoint_key("codex", "monthly", "default"),
4831 RoutePlanUpstreamRuntimeState {
4832 usage_exhausted: true,
4833 ..RoutePlanUpstreamRuntimeState::default()
4834 },
4835 );
4836 let mut state = RoutePlanAttemptState::default();
4837
4838 let selection =
4839 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
4840 let selected = selection.selected.expect("fallback candidate selected");
4841
4842 assert_eq!(selected.candidate.provider_id, "chili");
4843 assert_eq!(
4844 selected.provider_endpoint,
4845 endpoint_key("codex", "chili", "default")
4846 );
4847 }
4848
4849 #[test]
4850 fn route_plan_executor_stops_when_all_candidates_usage_exhausted() {
4851 let view = ServiceViewV4 {
4852 providers: BTreeMap::from([
4853 (
4854 "monthly".to_string(),
4855 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4856 ),
4857 (
4858 "chili".to_string(),
4859 tagged_provider("https://chili.example/v1", "billing", "paygo"),
4860 ),
4861 ]),
4862 routing: Some(RoutingConfigV4::ordered_failover(vec![
4863 "monthly".to_string(),
4864 "chili".to_string(),
4865 ])),
4866 ..ServiceViewV4::default()
4867 };
4868 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4869 let executor = RoutePlanExecutor::new(&template);
4870 let mut runtime = RoutePlanRuntimeState::default();
4871 runtime.set_provider_endpoint(
4872 endpoint_key("codex", "monthly", "default"),
4873 RoutePlanUpstreamRuntimeState {
4874 usage_exhausted: true,
4875 ..RoutePlanUpstreamRuntimeState::default()
4876 },
4877 );
4878 runtime.set_provider_endpoint(
4879 endpoint_key("codex", "chili", "default"),
4880 RoutePlanUpstreamRuntimeState {
4881 usage_exhausted: true,
4882 ..RoutePlanUpstreamRuntimeState::default()
4883 },
4884 );
4885 let mut state = RoutePlanAttemptState::default();
4886
4887 let selection =
4888 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
4889
4890 assert!(
4891 selection.selected.is_none(),
4892 "trusted usage exhaustion should make the route temporarily unroutable"
4893 );
4894 let reasons = executor
4895 .explain_candidate_skip_reasons_with_runtime_state(&runtime, None)
4896 .iter()
4897 .map(|explanation| {
4898 (
4899 explanation.candidate.provider_id.as_str(),
4900 explanation
4901 .reasons
4902 .iter()
4903 .map(RoutePlanSkipReason::code)
4904 .collect::<Vec<_>>(),
4905 )
4906 })
4907 .collect::<BTreeMap<_, _>>();
4908 assert_eq!(
4909 reasons.get("monthly").cloned(),
4910 Some(vec!["usage_exhausted"])
4911 );
4912 assert_eq!(reasons.get("chili").cloned(), Some(vec!["usage_exhausted"]));
4913 }
4914
4915 #[test]
4916 fn route_plan_executor_uses_fallback_group_when_preferred_is_hard_unavailable() {
4917 let view = ServiceViewV4 {
4918 providers: BTreeMap::from([
4919 (
4920 "monthly".to_string(),
4921 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4922 ),
4923 (
4924 "chili".to_string(),
4925 tagged_provider("https://chili.example/v1", "billing", "paygo"),
4926 ),
4927 ]),
4928 routing: Some(RoutingConfigV4::tag_preferred(
4929 vec!["chili".to_string(), "monthly".to_string()],
4930 vec![BTreeMap::from([(
4931 "billing".to_string(),
4932 "monthly".to_string(),
4933 )])],
4934 RoutingExhaustedActionV4::Continue,
4935 )),
4936 ..ServiceViewV4::default()
4937 };
4938 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4939 let executor = RoutePlanExecutor::new(&template);
4940 let mut runtime = RoutePlanRuntimeState::default();
4941 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
4942 runtime.set_provider_endpoint(
4943 endpoint_key("codex", "monthly", "default"),
4944 RoutePlanUpstreamRuntimeState {
4945 runtime_disabled: true,
4946 ..RoutePlanUpstreamRuntimeState::default()
4947 },
4948 );
4949 let mut state = RoutePlanAttemptState::default();
4950
4951 let selection =
4952 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
4953 let selected = selection.selected.expect("fallback candidate selected");
4954
4955 assert_eq!(selected.candidate.provider_id, "chili");
4956 assert_eq!(
4957 selected.provider_endpoint,
4958 endpoint_key("codex", "chili", "default")
4959 );
4960 }
4961
4962 #[test]
4963 fn route_plan_executor_fallback_sticky_policy_can_keep_lower_preference_affinity() {
4964 let mut routing = RoutingConfigV4::tag_preferred(
4965 vec!["chili".to_string(), "monthly".to_string()],
4966 vec![BTreeMap::from([(
4967 "billing".to_string(),
4968 "monthly".to_string(),
4969 )])],
4970 RoutingExhaustedActionV4::Continue,
4971 );
4972 routing.affinity_policy = RoutingAffinityPolicyV5::FallbackSticky;
4973 let view = ServiceViewV4 {
4974 providers: BTreeMap::from([
4975 (
4976 "monthly".to_string(),
4977 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
4978 ),
4979 (
4980 "chili".to_string(),
4981 tagged_provider("https://chili.example/v1", "billing", "paygo"),
4982 ),
4983 ]),
4984 routing: Some(routing),
4985 ..ServiceViewV4::default()
4986 };
4987 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
4988 let executor = RoutePlanExecutor::new(&template);
4989 let mut runtime = RoutePlanRuntimeState::default();
4990 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
4991 let mut state = RoutePlanAttemptState::default();
4992
4993 let selection =
4994 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
4995 let selected = selection
4996 .selected
4997 .expect("fallback affinity candidate selected");
4998
4999 assert_eq!(
5000 template.affinity_policy,
5001 RoutingAffinityPolicyV5::FallbackSticky
5002 );
5003 assert_eq!(selected.candidate.provider_id, "chili");
5004 assert_eq!(
5005 selected.provider_endpoint,
5006 endpoint_key("codex", "chili", "default")
5007 );
5008 }
5009
5010 #[test]
5011 fn route_plan_executor_fallback_sticky_reprobes_preferred_after_window() {
5012 let mut routing = RoutingConfigV4::tag_preferred(
5013 vec!["chili".to_string(), "monthly".to_string()],
5014 vec![BTreeMap::from([(
5015 "billing".to_string(),
5016 "monthly".to_string(),
5017 )])],
5018 RoutingExhaustedActionV4::Continue,
5019 );
5020 routing.affinity_policy = RoutingAffinityPolicyV5::FallbackSticky;
5021 routing.reprobe_preferred_after_ms = Some(1);
5022 let view = ServiceViewV4 {
5023 providers: BTreeMap::from([
5024 (
5025 "monthly".to_string(),
5026 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
5027 ),
5028 (
5029 "chili".to_string(),
5030 tagged_provider("https://chili.example/v1", "billing", "paygo"),
5031 ),
5032 ]),
5033 routing: Some(routing),
5034 ..ServiceViewV4::default()
5035 };
5036 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
5037 let executor = RoutePlanExecutor::new(&template);
5038 let now = crate::logging::now_ms();
5039 let mut runtime = RoutePlanRuntimeState::default();
5040 runtime.set_affinity_provider_endpoint_with_observed_at(
5041 Some(endpoint_key("codex", "chili", "default")),
5042 Some(now),
5043 Some(now.saturating_sub(2)),
5044 );
5045 let mut state = RoutePlanAttemptState::default();
5046
5047 let selection =
5048 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
5049 let selected = selection
5050 .selected
5051 .expect("preferred candidate selected after reprobe window");
5052
5053 assert_eq!(selected.candidate.provider_id, "monthly");
5054 }
5055
5056 #[test]
5057 fn route_plan_executor_fallback_sticky_reprobes_preferred_after_fallback_ttl() {
5058 let mut routing = RoutingConfigV4::tag_preferred(
5059 vec!["chili".to_string(), "monthly".to_string()],
5060 vec![BTreeMap::from([(
5061 "billing".to_string(),
5062 "monthly".to_string(),
5063 )])],
5064 RoutingExhaustedActionV4::Continue,
5065 );
5066 routing.affinity_policy = RoutingAffinityPolicyV5::FallbackSticky;
5067 routing.fallback_ttl_ms = Some(1);
5068 let view = ServiceViewV4 {
5069 providers: BTreeMap::from([
5070 (
5071 "monthly".to_string(),
5072 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
5073 ),
5074 (
5075 "chili".to_string(),
5076 tagged_provider("https://chili.example/v1", "billing", "paygo"),
5077 ),
5078 ]),
5079 routing: Some(routing),
5080 ..ServiceViewV4::default()
5081 };
5082 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
5083 let executor = RoutePlanExecutor::new(&template);
5084 let now = crate::logging::now_ms();
5085 let mut runtime = RoutePlanRuntimeState::default();
5086 runtime.set_affinity_provider_endpoint_with_observed_at(
5087 Some(endpoint_key("codex", "chili", "default")),
5088 Some(now),
5089 Some(now.saturating_sub(2)),
5090 );
5091 let mut state = RoutePlanAttemptState::default();
5092
5093 let selection =
5094 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
5095 let selected = selection
5096 .selected
5097 .expect("preferred candidate selected after fallback ttl");
5098
5099 assert_eq!(selected.candidate.provider_id, "monthly");
5100 }
5101
5102 #[test]
5103 fn route_plan_executor_off_policy_ignores_affinity_inside_best_group() {
5104 let mut routing = RoutingConfigV4::tag_preferred(
5105 vec!["monthly-a".to_string(), "monthly-b".to_string()],
5106 vec![BTreeMap::from([(
5107 "billing".to_string(),
5108 "monthly".to_string(),
5109 )])],
5110 RoutingExhaustedActionV4::Continue,
5111 );
5112 routing.affinity_policy = RoutingAffinityPolicyV5::Off;
5113 let view = ServiceViewV4 {
5114 providers: BTreeMap::from([
5115 (
5116 "monthly-a".to_string(),
5117 tagged_provider("https://monthly-a.example/v1", "billing", "monthly"),
5118 ),
5119 (
5120 "monthly-b".to_string(),
5121 tagged_provider("https://monthly-b.example/v1", "billing", "monthly"),
5122 ),
5123 ]),
5124 routing: Some(routing),
5125 ..ServiceViewV4::default()
5126 };
5127 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
5128 let executor = RoutePlanExecutor::new(&template);
5129 let mut runtime = RoutePlanRuntimeState::default();
5130 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "monthly-b", "default")));
5131 let mut state = RoutePlanAttemptState::default();
5132
5133 let selection =
5134 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
5135 let selected = selection.selected.expect("first candidate selected");
5136
5137 assert_eq!(template.affinity_policy, RoutingAffinityPolicyV5::Off);
5138 assert_eq!(selected.candidate.provider_id, "monthly-a");
5139 assert_eq!(
5140 selected.provider_endpoint,
5141 endpoint_key("codex", "monthly-a", "default")
5142 );
5143 }
5144
5145 #[test]
5146 fn route_plan_executor_hard_policy_stops_when_affinity_is_unavailable() {
5147 let mut routing = RoutingConfigV4::tag_preferred(
5148 vec!["monthly".to_string(), "chili".to_string()],
5149 vec![BTreeMap::from([(
5150 "billing".to_string(),
5151 "monthly".to_string(),
5152 )])],
5153 RoutingExhaustedActionV4::Continue,
5154 );
5155 routing.affinity_policy = RoutingAffinityPolicyV5::Hard;
5156 let view = ServiceViewV4 {
5157 providers: BTreeMap::from([
5158 (
5159 "monthly".to_string(),
5160 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
5161 ),
5162 (
5163 "chili".to_string(),
5164 tagged_provider("https://chili.example/v1", "billing", "paygo"),
5165 ),
5166 ]),
5167 routing: Some(routing),
5168 ..ServiceViewV4::default()
5169 };
5170 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
5171 let executor = RoutePlanExecutor::new(&template);
5172 let mut runtime = RoutePlanRuntimeState::default();
5173 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
5174 runtime.set_provider_endpoint(
5175 endpoint_key("codex", "chili", "default"),
5176 RoutePlanUpstreamRuntimeState {
5177 runtime_disabled: true,
5178 ..RoutePlanUpstreamRuntimeState::default()
5179 },
5180 );
5181 let mut state = RoutePlanAttemptState::default();
5182
5183 let selection =
5184 executor.select_supported_candidate_with_runtime_state(&mut state, &runtime, None);
5185
5186 assert_eq!(template.affinity_policy, RoutingAffinityPolicyV5::Hard);
5187 assert!(selection.selected.is_none());
5188 }
5189
5190 #[test]
5191 fn route_plan_executor_soft_affinity_escapes_unavailable_hard_affinity() {
5192 let mut routing = RoutingConfigV4::tag_preferred(
5193 vec!["monthly".to_string(), "chili".to_string()],
5194 vec![BTreeMap::from([(
5195 "billing".to_string(),
5196 "monthly".to_string(),
5197 )])],
5198 RoutingExhaustedActionV4::Continue,
5199 );
5200 routing.affinity_policy = RoutingAffinityPolicyV5::Hard;
5201 let view = ServiceViewV4 {
5202 providers: BTreeMap::from([
5203 (
5204 "monthly".to_string(),
5205 tagged_provider("https://monthly.example/v1", "billing", "monthly"),
5206 ),
5207 (
5208 "chili".to_string(),
5209 tagged_provider("https://chili.example/v1", "billing", "paygo"),
5210 ),
5211 ]),
5212 routing: Some(routing),
5213 ..ServiceViewV4::default()
5214 };
5215 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
5216 let executor = RoutePlanExecutor::new(&template);
5217 let mut runtime = RoutePlanRuntimeState::default();
5218 runtime.set_affinity_provider_endpoint(Some(endpoint_key("codex", "chili", "default")));
5219 runtime.set_provider_endpoint(
5220 endpoint_key("codex", "chili", "default"),
5221 RoutePlanUpstreamRuntimeState {
5222 runtime_disabled: true,
5223 ..RoutePlanUpstreamRuntimeState::default()
5224 },
5225 );
5226 let mut state = RoutePlanAttemptState::default();
5227
5228 let selection = executor.select_supported_candidate_with_soft_affinity_runtime_state(
5229 &mut state, &runtime, None,
5230 );
5231 let selected = selection.selected.expect("soft fallback selected");
5232
5233 assert_eq!(template.affinity_policy, RoutingAffinityPolicyV5::Hard);
5234 assert_eq!(selected.candidate.provider_id, "monthly");
5235 assert_eq!(
5236 selected.provider_endpoint,
5237 endpoint_key("codex", "monthly", "default")
5238 );
5239 }
5240
5241 #[test]
5242 fn route_plan_executor_shadow_keeps_same_candidate_until_caller_marks_avoidance() {
5243 let view = ServiceViewV4 {
5244 providers: BTreeMap::from([
5245 ("first".to_string(), provider("https://first.example/v1")),
5246 ("second".to_string(), provider("https://second.example/v1")),
5247 ]),
5248 routing: Some(RoutingConfigV4::ordered_failover(vec![
5249 "first".to_string(),
5250 "second".to_string(),
5251 ])),
5252 ..ServiceViewV4::default()
5253 };
5254 let template = compile_v4_route_plan_template("codex", &view).expect("route template");
5255 let executor = RoutePlanExecutor::new(&template);
5256 let mut state = RoutePlanAttemptState::default();
5257
5258 let first = executor.select_supported_candidate(&mut state, None);
5259 let first_selected = first.selected.as_ref().expect("first selected");
5260 assert_eq!(first_selected.candidate.provider_id, "first");
5261
5262 let same = executor.select_supported_candidate(&mut state, None);
5263 let same_selected = same.selected.as_ref().expect("same selected");
5264 assert_eq!(same_selected.candidate.provider_id, "first");
5265
5266 assert!(state.avoid_selected(same_selected));
5267 let next = executor.select_supported_candidate(&mut state, None);
5268 let next_selected = next.selected.as_ref().expect("next selected");
5269
5270 assert_eq!(next_selected.candidate.provider_id, "second");
5271 assert_eq!(next.avoided_candidate_indices, vec![0]);
5272 }
5273}