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