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