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