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
111fn 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}