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