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