Skip to main content

codex_helper_core/
routing_ir.rs

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