1use std::collections::BTreeMap;
2
3use crate::config::{RoutingAffinityPolicyV5, RoutingConditionV4};
4use crate::dashboard_core::ProviderCapacity;
5use crate::routing_ir::{
6 RouteCandidate, RoutePlanAttemptState, RoutePlanCandidateRuntimeSnapshot, RoutePlanExecutor,
7 RoutePlanRuntimeState, RoutePlanSkipReason, RoutePlanTemplate, RouteRef, RouteRequestContext,
8 request_matches_condition,
9};
10
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
12pub struct RoutingExplainResponse {
13 pub api_version: u32,
14 pub service_name: String,
15 pub runtime_loaded_at_ms: Option<u64>,
16 pub request_model: Option<String>,
17 pub session_id: Option<String>,
18 #[serde(
19 default,
20 skip_serializing_if = "RoutingExplainRequestContext::is_empty"
21 )]
22 pub request_context: RoutingExplainRequestContext,
23 pub selected_route: Option<RoutingExplainCandidate>,
24 pub candidates: Vec<RoutingExplainCandidate>,
25 pub affinity_policy: String,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub affinity: Option<RoutingExplainAffinity>,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub conditional_routes: Vec<RoutingExplainConditionalRoute>,
30}
31
32impl RoutingExplainResponse {
33 pub fn selected_route_compact_label(&self) -> String {
34 format_selected_route_compact(self.selected_route.as_ref())
35 }
36}
37
38#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
39pub struct RoutingExplainRequestContext {
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub model: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub service_tier: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub reasoning_effort: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub path: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub method: Option<String>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub headers: Vec<String>,
52}
53
54impl RoutingExplainRequestContext {
55 fn is_empty(&self) -> bool {
56 self.model.is_none()
57 && self.service_tier.is_none()
58 && self.reasoning_effort.is_none()
59 && self.path.is_none()
60 && self.method.is_none()
61 && self.headers.is_empty()
62 }
63}
64
65#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
66pub struct RoutingExplainConditionalRoute {
67 pub route_name: String,
68 pub condition: RoutingExplainCondition,
69 pub matched: bool,
70 pub selected_branch: RoutingExplainConditionalBranch,
71 pub selected_target: Option<RoutingExplainRouteRef>,
72 pub then: Option<RoutingExplainRouteRef>,
73 #[serde(rename = "default")]
74 pub default_route: Option<RoutingExplainRouteRef>,
75}
76
77#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
78#[serde(rename_all = "snake_case")]
79pub enum RoutingExplainConditionalBranch {
80 Then,
81 Default,
82}
83
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
85pub struct RoutingExplainRouteRef {
86 pub kind: RoutingExplainRouteRefKind,
87 pub name: String,
88}
89
90#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
91#[serde(rename_all = "snake_case")]
92pub enum RoutingExplainRouteRefKind {
93 Route,
94 Provider,
95 ProviderEndpoint,
96}
97
98#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
99pub struct RoutingExplainCondition {
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub model: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub service_tier: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub reasoning_effort: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub path: Option<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub method: Option<String>,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub headers: Vec<String>,
112}
113
114#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
115pub struct RoutingExplainCandidate {
116 pub provider_id: String,
117 pub provider_alias: Option<String>,
118 pub endpoint_id: String,
119 pub provider_endpoint_key: String,
120 pub route_path: Vec<String>,
121 pub preference_group: u32,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub compatibility: Option<RoutingExplainCompatibility>,
124 pub upstream_base_url: String,
125 #[serde(default, skip_serializing_if = "ProviderCapacity::is_empty")]
126 pub capacity: ProviderCapacity,
127 #[serde(default)]
128 pub availability: RoutingExplainAvailability,
129 pub selected: bool,
130 pub skip_reasons: Vec<RoutingExplainSkipReason>,
131}
132
133#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
134pub struct RoutingExplainAvailability {
135 pub available: bool,
136 pub runtime_available: bool,
137 pub routable_except_usage: bool,
138 pub hard_unavailable: bool,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub dominant_reason: Option<RoutingExplainSkipReason>,
141 pub runtime_disabled: bool,
142 pub cooldown_active: bool,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub cooldown_remaining_secs: Option<u64>,
145 pub breaker_open: bool,
146 pub failure_count: u32,
147 pub usage_exhausted: bool,
148 pub missing_auth: bool,
149 pub concurrency_saturated: bool,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub concurrency_active: Option<u32>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub concurrency_limit: Option<u32>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub effective_max_concurrent_requests: Option<u32>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub effective_limit_group: Option<String>,
158}
159
160#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
161pub struct RoutingExplainAffinity {
162 pub mode: String,
163 pub provider_endpoint_key: String,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub last_selected_at_ms: Option<u64>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub last_changed_at_ms: Option<u64>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub fallback_ttl_ms: Option<u64>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub reprobe_preferred_after_ms: Option<u64>,
172}
173
174#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
175pub struct RoutingExplainCompatibility {
176 pub station_name: String,
177 pub upstream_index: usize,
178}
179
180impl RoutingExplainCompatibility {
181 pub fn compact_label(&self) -> String {
182 format!(
183 "compat_station={} upstream#{}",
184 self.station_name, self.upstream_index
185 )
186 }
187}
188
189#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
190#[serde(tag = "code", rename_all = "snake_case")]
191pub enum RoutingExplainSkipReason {
192 UnsupportedModel {
193 requested_model: String,
194 },
195 RuntimeDisabled,
196 Cooldown,
197 BreakerOpen {
198 failure_count: u32,
199 },
200 UsageExhausted,
201 MissingAuth,
202 ConcurrencySaturated {
203 active: Option<u32>,
204 limit: Option<u32>,
205 },
206}
207
208pub fn build_routing_explain_response(
209 service_name: impl Into<String>,
210 runtime_loaded_at_ms: Option<u64>,
211 request_model: Option<String>,
212 session_id: Option<String>,
213 template: &RoutePlanTemplate,
214 runtime: &RoutePlanRuntimeState,
215) -> RoutingExplainResponse {
216 build_routing_explain_response_with_request(
217 service_name,
218 runtime_loaded_at_ms,
219 RouteRequestContext {
220 model: request_model,
221 ..RouteRequestContext::default()
222 },
223 session_id,
224 template,
225 runtime,
226 )
227}
228
229pub fn build_routing_explain_response_with_request(
230 service_name: impl Into<String>,
231 runtime_loaded_at_ms: Option<u64>,
232 request: RouteRequestContext,
233 session_id: Option<String>,
234 template: &RoutePlanTemplate,
235 runtime: &RoutePlanRuntimeState,
236) -> RoutingExplainResponse {
237 let service_name = service_name.into();
238 let executor = RoutePlanExecutor::new(template);
239 let mut state = RoutePlanAttemptState::default();
240 let selection = executor.select_supported_candidate_with_runtime_state(
241 &mut state,
242 runtime,
243 request.model.as_deref(),
244 );
245 let selected_key = selection
246 .selected
247 .as_ref()
248 .map(|selected| selected.provider_endpoint.stable_key());
249 let skip_reasons_by_candidate = executor
250 .explain_candidate_skip_reasons_with_runtime_state(runtime, request.model.as_deref())
251 .into_iter()
252 .map(|explanation| {
253 (
254 explanation.provider_endpoint.stable_key(),
255 explanation
256 .reasons
257 .iter()
258 .map(RoutingExplainSkipReason::from)
259 .collect::<Vec<_>>(),
260 )
261 })
262 .collect::<BTreeMap<_, _>>();
263
264 let candidates = executor
265 .iter_candidates()
266 .map(|candidate| {
267 let key = template
268 .candidate_provider_endpoint_key(candidate)
269 .stable_key();
270 routing_explain_candidate(
271 template,
272 candidate,
273 runtime.candidate_runtime_snapshot(template, candidate),
274 service_name.as_str(),
275 selected_key.as_deref() == Some(key.as_str()),
276 skip_reasons_by_candidate
277 .get(&key)
278 .cloned()
279 .unwrap_or_default(),
280 )
281 })
282 .collect::<Vec<_>>();
283 let selected_route = candidates
284 .iter()
285 .find(|candidate| candidate.selected)
286 .cloned();
287
288 RoutingExplainResponse {
289 api_version: 1,
290 service_name,
291 runtime_loaded_at_ms,
292 request_model: request.model.clone(),
293 session_id,
294 request_context: RoutingExplainRequestContext::from(&request),
295 selected_route,
296 candidates,
297 affinity_policy: routing_affinity_policy_label(template.affinity_policy).to_string(),
298 affinity: runtime
299 .affinity_provider_endpoint()
300 .map(|key| RoutingExplainAffinity {
301 mode: routing_affinity_policy_label(template.affinity_policy).to_string(),
302 provider_endpoint_key: key.stable_key(),
303 last_selected_at_ms: runtime.affinity_last_selected_at_ms(),
304 last_changed_at_ms: runtime.affinity_last_changed_at_ms(),
305 fallback_ttl_ms: template.fallback_ttl_ms,
306 reprobe_preferred_after_ms: template.reprobe_preferred_after_ms,
307 }),
308 conditional_routes: routing_explain_conditional_routes(template, &request),
309 }
310}
311
312fn routing_affinity_policy_label(policy: RoutingAffinityPolicyV5) -> &'static str {
313 match policy {
314 RoutingAffinityPolicyV5::Off => "off",
315 RoutingAffinityPolicyV5::PreferredGroup => "preferred_group",
316 RoutingAffinityPolicyV5::FallbackSticky => "fallback_sticky",
317 RoutingAffinityPolicyV5::Hard => "hard",
318 }
319}
320
321pub fn parse_routing_explain_headers(
322 headers: &[String],
323) -> Result<BTreeMap<String, String>, String> {
324 let mut out = BTreeMap::new();
325 for header in headers {
326 let Some((name, value)) = header.split_once('=') else {
327 return Err(format!("header condition '{header}' must use NAME=VALUE"));
328 };
329 let name = name.trim();
330 if name.is_empty() {
331 return Err("header condition name cannot be empty".to_string());
332 }
333 out.insert(name.to_string(), value.trim().to_string());
334 }
335 Ok(out)
336}
337
338impl From<&RoutePlanSkipReason> for RoutingExplainSkipReason {
339 fn from(reason: &RoutePlanSkipReason) -> Self {
340 match reason {
341 RoutePlanSkipReason::UnsupportedModel { requested_model } => {
342 RoutingExplainSkipReason::UnsupportedModel {
343 requested_model: requested_model.clone(),
344 }
345 }
346 RoutePlanSkipReason::RuntimeDisabled => RoutingExplainSkipReason::RuntimeDisabled,
347 RoutePlanSkipReason::Cooldown => RoutingExplainSkipReason::Cooldown,
348 RoutePlanSkipReason::BreakerOpen { failure_count } => {
349 RoutingExplainSkipReason::BreakerOpen {
350 failure_count: *failure_count,
351 }
352 }
353 RoutePlanSkipReason::UsageExhausted => RoutingExplainSkipReason::UsageExhausted,
354 RoutePlanSkipReason::MissingAuth => RoutingExplainSkipReason::MissingAuth,
355 RoutePlanSkipReason::ConcurrencySaturated { active, limit } => {
356 RoutingExplainSkipReason::ConcurrencySaturated {
357 active: *active,
358 limit: *limit,
359 }
360 }
361 }
362 }
363}
364
365impl RoutingExplainAvailability {
366 fn from_snapshot(
367 snapshot: &RoutePlanCandidateRuntimeSnapshot,
368 skip_reasons: &[RoutingExplainSkipReason],
369 ) -> Self {
370 let unsupported_model = skip_reasons
371 .iter()
372 .any(|reason| matches!(reason, RoutingExplainSkipReason::UnsupportedModel { .. }));
373 Self {
374 available: snapshot.runtime_available && !unsupported_model,
375 runtime_available: snapshot.runtime_available,
376 routable_except_usage: snapshot.routable_except_usage,
377 hard_unavailable: snapshot.hard_unavailable,
378 dominant_reason: skip_reasons.first().cloned(),
379 runtime_disabled: snapshot.runtime_disabled,
380 cooldown_active: snapshot.cooldown_active,
381 cooldown_remaining_secs: snapshot.cooldown_remaining_secs,
382 breaker_open: snapshot.breaker_open,
383 failure_count: snapshot.failure_count,
384 usage_exhausted: snapshot.usage_exhausted,
385 missing_auth: snapshot.missing_auth,
386 concurrency_saturated: snapshot.concurrency_saturated,
387 concurrency_active: snapshot.concurrency_active,
388 concurrency_limit: snapshot.concurrency_limit,
389 effective_max_concurrent_requests: snapshot.effective_max_concurrent_requests,
390 effective_limit_group: snapshot.effective_limit_group.clone(),
391 }
392 }
393
394 pub fn summary(&self) -> String {
395 if self.available {
396 return "available".to_string();
397 }
398 let reason = self
399 .dominant_reason
400 .as_ref()
401 .map(RoutingExplainSkipReason::code)
402 .unwrap_or("unknown");
403 format!("unavailable({reason})")
404 }
405}
406
407impl RoutingExplainSkipReason {
408 pub fn code(&self) -> &'static str {
409 match self {
410 RoutingExplainSkipReason::UnsupportedModel { .. } => "unsupported_model",
411 RoutingExplainSkipReason::RuntimeDisabled => "runtime_disabled",
412 RoutingExplainSkipReason::Cooldown => "cooldown",
413 RoutingExplainSkipReason::BreakerOpen { .. } => "breaker_open",
414 RoutingExplainSkipReason::UsageExhausted => "usage_exhausted",
415 RoutingExplainSkipReason::MissingAuth => "missing_auth",
416 RoutingExplainSkipReason::ConcurrencySaturated { .. } => "concurrency_saturated",
417 }
418 }
419
420 pub fn compact_label(&self) -> String {
421 match self {
422 RoutingExplainSkipReason::ConcurrencySaturated {
423 active: Some(active),
424 limit: Some(limit),
425 } => format!("concurrency_saturated(active={active}/limit={limit})"),
426 _ => self.code().to_string(),
427 }
428 }
429}
430
431pub fn format_skip_reasons_compact(reasons: &[RoutingExplainSkipReason]) -> String {
432 if reasons.is_empty() {
433 return "-".to_string();
434 }
435 reasons
436 .iter()
437 .map(RoutingExplainSkipReason::compact_label)
438 .collect::<Vec<_>>()
439 .join(",")
440}
441
442pub fn format_compatibility_compact(compatibility: Option<&RoutingExplainCompatibility>) -> String {
443 compatibility
444 .map(RoutingExplainCompatibility::compact_label)
445 .unwrap_or_else(|| "compatibility=-".to_string())
446}
447
448impl RoutingExplainCandidate {
449 pub fn selected_route_label(&self) -> String {
450 format!(
451 "selected={} endpoint={} path={} {}",
452 self.provider_id,
453 self.endpoint_id,
454 self.route_path.join(" > "),
455 format_compatibility_compact(self.compatibility.as_ref())
456 )
457 }
458
459 pub fn compact_label(&self) -> String {
460 let marker = if self.selected { "*" } else { " " };
461 format!(
462 "{} {} endpoint={} path={} availability={} {} skip={} {}",
463 marker,
464 self.provider_id,
465 self.endpoint_id,
466 self.route_path.join(" > "),
467 self.availability.summary(),
468 self.capacity.compact_runtime_label(),
469 format_skip_reasons_compact(&self.skip_reasons),
470 format_compatibility_compact(self.compatibility.as_ref())
471 )
472 }
473}
474
475pub fn format_selected_route_compact(selected: Option<&RoutingExplainCandidate>) -> String {
476 selected
477 .map(RoutingExplainCandidate::selected_route_label)
478 .unwrap_or_else(|| "selected=<none>".to_string())
479}
480
481fn routing_explain_candidate(
482 template: &RoutePlanTemplate,
483 candidate: &RouteCandidate,
484 runtime_snapshot: RoutePlanCandidateRuntimeSnapshot,
485 service_name: &str,
486 selected: bool,
487 skip_reasons: Vec<RoutingExplainSkipReason>,
488) -> RoutingExplainCandidate {
489 let provider_endpoint = template.candidate_provider_endpoint_key(candidate);
490 let provider_endpoint_key = provider_endpoint.stable_key();
491 let compatibility = candidate
492 .compatibility_station_name
493 .as_ref()
494 .and_then(|station_name| {
495 candidate
496 .compatibility_upstream_index
497 .map(|upstream_index| RoutingExplainCompatibility {
498 station_name: station_name.clone(),
499 upstream_index,
500 })
501 });
502 RoutingExplainCandidate {
503 provider_id: candidate.provider_id.clone(),
504 provider_alias: candidate.provider_alias.clone(),
505 endpoint_id: candidate.endpoint_id.clone(),
506 provider_endpoint_key,
507 route_path: candidate.route_path.clone(),
508 preference_group: candidate.preference_group,
509 compatibility,
510 upstream_base_url: candidate.base_url.clone(),
511 capacity: routing_explain_candidate_capacity(
512 candidate,
513 &runtime_snapshot,
514 service_name,
515 &provider_endpoint,
516 ),
517 availability: RoutingExplainAvailability::from_snapshot(&runtime_snapshot, &skip_reasons),
518 selected,
519 skip_reasons,
520 }
521}
522
523fn routing_explain_candidate_capacity(
524 candidate: &RouteCandidate,
525 runtime_snapshot: &RoutePlanCandidateRuntimeSnapshot,
526 service_name: &str,
527 provider_endpoint: &crate::runtime_identity::ProviderEndpointKey,
528) -> ProviderCapacity {
529 let limit_key = candidate
530 .concurrency
531 .limit_key(service_name, provider_endpoint);
532 ProviderCapacity {
533 configured_max_concurrent_requests: None,
534 configured_limit_group: None,
535 effective_max_concurrent_requests: runtime_snapshot.effective_max_concurrent_requests,
536 effective_limit_group: runtime_snapshot.effective_limit_group.clone(),
537 active: runtime_snapshot.concurrency_active,
538 limit: runtime_snapshot
539 .concurrency_limit
540 .or(runtime_snapshot.effective_max_concurrent_requests),
541 limit_key,
542 saturated: runtime_snapshot.concurrency_saturated,
543 inherited_from_provider: None,
544 }
545}
546
547fn routing_explain_conditional_routes(
548 template: &RoutePlanTemplate,
549 request: &RouteRequestContext,
550) -> Vec<RoutingExplainConditionalRoute> {
551 template
552 .nodes
553 .values()
554 .filter_map(|node| {
555 let condition = node.when.as_ref()?;
556 let matched = request_matches_condition(request, condition);
557 let selected_branch = if matched {
558 RoutingExplainConditionalBranch::Then
559 } else {
560 RoutingExplainConditionalBranch::Default
561 };
562 let selected_target = match selected_branch {
563 RoutingExplainConditionalBranch::Then => node.then.as_ref(),
564 RoutingExplainConditionalBranch::Default => node.default_route.as_ref(),
565 }
566 .map(RoutingExplainRouteRef::from);
567
568 Some(RoutingExplainConditionalRoute {
569 route_name: node.name.clone(),
570 condition: RoutingExplainCondition::from(condition),
571 matched,
572 selected_branch,
573 selected_target,
574 then: node.then.as_ref().map(RoutingExplainRouteRef::from),
575 default_route: node
576 .default_route
577 .as_ref()
578 .map(RoutingExplainRouteRef::from),
579 })
580 })
581 .collect()
582}
583
584impl From<&RouteRequestContext> for RoutingExplainRequestContext {
585 fn from(request: &RouteRequestContext) -> Self {
586 Self {
587 model: request.model.clone(),
588 service_tier: request.service_tier.clone(),
589 reasoning_effort: request.reasoning_effort.clone(),
590 path: request.path.clone(),
591 method: request.method.clone(),
592 headers: request.headers.keys().cloned().collect(),
593 }
594 }
595}
596
597impl From<&RoutingConditionV4> for RoutingExplainCondition {
598 fn from(condition: &RoutingConditionV4) -> Self {
599 Self {
600 model: condition.model.clone(),
601 service_tier: condition.service_tier.clone(),
602 reasoning_effort: condition.reasoning_effort.clone(),
603 path: condition.path.clone(),
604 method: condition.method.clone(),
605 headers: condition.headers.keys().cloned().collect(),
606 }
607 }
608}
609
610impl From<&RouteRef> for RoutingExplainRouteRef {
611 fn from(route_ref: &RouteRef) -> Self {
612 match route_ref {
613 RouteRef::Route(name) => Self {
614 kind: RoutingExplainRouteRefKind::Route,
615 name: name.clone(),
616 },
617 RouteRef::Provider(name) => Self {
618 kind: RoutingExplainRouteRefKind::Provider,
619 name: name.clone(),
620 },
621 RouteRef::ProviderEndpoint {
622 provider_id,
623 endpoint_id,
624 } => Self {
625 kind: RoutingExplainRouteRefKind::ProviderEndpoint,
626 name: format!("{provider_id}.{endpoint_id}"),
627 },
628 }
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use std::collections::BTreeMap;
635
636 use serde_json::Value;
637
638 use super::*;
639 use crate::config::{
640 ProviderConfigV4, RoutingConditionV4, RoutingConfigV4, RoutingExhaustedActionV4,
641 RoutingNodeV4, RoutingPolicyV4, ServiceViewV4, UpstreamAuth,
642 };
643 use crate::routing_ir::compile_v4_route_plan_template_with_request;
644 use crate::runtime_identity::ProviderEndpointKey;
645
646 fn provider(base_url: &str) -> ProviderConfigV4 {
647 ProviderConfigV4 {
648 base_url: Some(base_url.to_string()),
649 inline_auth: UpstreamAuth::default(),
650 ..ProviderConfigV4::default()
651 }
652 }
653
654 #[test]
655 fn format_skip_reasons_compact_includes_concurrency_counts() {
656 let reasons = [
657 RoutingExplainSkipReason::ConcurrencySaturated {
658 active: Some(5),
659 limit: Some(5),
660 },
661 RoutingExplainSkipReason::MissingAuth,
662 ];
663
664 assert_eq!(
665 format_skip_reasons_compact(&reasons),
666 "concurrency_saturated(active=5/limit=5),missing_auth"
667 );
668 }
669
670 #[test]
671 fn format_skip_reasons_compact_falls_back_to_code() {
672 assert_eq!(format_skip_reasons_compact(&[]), "-");
673 assert_eq!(
674 RoutingExplainSkipReason::ConcurrencySaturated {
675 active: None,
676 limit: Some(5),
677 }
678 .compact_label(),
679 "concurrency_saturated"
680 );
681 }
682
683 #[test]
684 fn routing_explain_candidate_compact_label_includes_route_runtime_and_compatibility() {
685 let candidate = RoutingExplainCandidate {
686 provider_id: "alpha".to_string(),
687 provider_alias: None,
688 endpoint_id: "default".to_string(),
689 provider_endpoint_key: "codex/alpha/default".to_string(),
690 route_path: vec!["root".to_string(), "alpha".to_string()],
691 preference_group: 0,
692 compatibility: Some(RoutingExplainCompatibility {
693 station_name: "alpha-station".to_string(),
694 upstream_index: 2,
695 }),
696 upstream_base_url: "https://example.invalid/v1".to_string(),
697 capacity: ProviderCapacity {
698 effective_max_concurrent_requests: Some(2),
699 effective_limit_group: Some("shared".to_string()),
700 active: Some(2),
701 limit: Some(2),
702 saturated: true,
703 ..ProviderCapacity::default()
704 },
705 availability: RoutingExplainAvailability {
706 available: false,
707 dominant_reason: Some(RoutingExplainSkipReason::ConcurrencySaturated {
708 active: Some(2),
709 limit: Some(2),
710 }),
711 ..RoutingExplainAvailability::default()
712 },
713 selected: true,
714 skip_reasons: vec![RoutingExplainSkipReason::ConcurrencySaturated {
715 active: Some(2),
716 limit: Some(2),
717 }],
718 };
719
720 assert_eq!(
721 candidate.compact_label(),
722 "* alpha endpoint=default path=root > alpha availability=unavailable(concurrency_saturated) capacity=active=2/2,group=shared,saturated skip=concurrency_saturated(active=2/limit=2) compat_station=alpha-station upstream#2"
723 );
724 }
725
726 #[test]
727 fn selected_route_compact_label_includes_path_and_compatibility() {
728 let candidate = RoutingExplainCandidate {
729 provider_id: "alpha".to_string(),
730 provider_alias: None,
731 endpoint_id: "default".to_string(),
732 provider_endpoint_key: "codex/alpha/default".to_string(),
733 route_path: vec!["root".to_string(), "alpha".to_string()],
734 preference_group: 0,
735 compatibility: Some(RoutingExplainCompatibility {
736 station_name: "alpha-station".to_string(),
737 upstream_index: 1,
738 }),
739 upstream_base_url: "https://example.invalid/v1".to_string(),
740 capacity: ProviderCapacity::default(),
741 availability: RoutingExplainAvailability::default(),
742 selected: true,
743 skip_reasons: vec![],
744 };
745
746 assert_eq!(
747 format_selected_route_compact(Some(&candidate)),
748 "selected=alpha endpoint=default path=root > alpha compat_station=alpha-station upstream#1"
749 );
750 assert_eq!(format_selected_route_compact(None), "selected=<none>");
751 }
752
753 #[test]
754 fn routing_explain_reports_conditional_route_without_header_values() {
755 let request = RouteRequestContext {
756 model: Some("gpt-5".to_string()),
757 headers: BTreeMap::from([("Authorization".to_string(), "secret-token".to_string())]),
758 ..RouteRequestContext::default()
759 };
760 let view = ServiceViewV4 {
761 providers: BTreeMap::from([
762 ("small".to_string(), provider("https://small.example/v1")),
763 ("large".to_string(), provider("https://large.example/v1")),
764 ]),
765 routing: Some(RoutingConfigV4 {
766 entry: "root".to_string(),
767 routes: BTreeMap::from([(
768 "root".to_string(),
769 RoutingNodeV4 {
770 strategy: RoutingPolicyV4::Conditional,
771 when: Some(RoutingConditionV4 {
772 model: Some("gpt-5".to_string()),
773 headers: BTreeMap::from([(
774 "Authorization".to_string(),
775 "secret-token".to_string(),
776 )]),
777 ..RoutingConditionV4::default()
778 }),
779 then: Some("large".to_string()),
780 default_route: Some("small".to_string()),
781 ..RoutingNodeV4::default()
782 },
783 )]),
784 ..RoutingConfigV4::default()
785 }),
786 ..ServiceViewV4::default()
787 };
788 let template = compile_v4_route_plan_template_with_request("codex", &view, &request)
789 .expect("conditional route template");
790
791 let explain = build_routing_explain_response_with_request(
792 "codex",
793 None,
794 request,
795 None,
796 &template,
797 &RoutePlanRuntimeState::default(),
798 );
799 let value = serde_json::to_value(&explain).expect("serialize explain");
800
801 assert_eq!(
802 value["conditional_routes"][0]["selected_branch"].as_str(),
803 Some("then")
804 );
805 assert_eq!(
806 value["conditional_routes"][0]["selected_target"]["kind"].as_str(),
807 Some("provider")
808 );
809 assert_eq!(
810 value["conditional_routes"][0]["selected_target"]["name"].as_str(),
811 Some("large")
812 );
813 assert_eq!(
814 value["conditional_routes"][0]["condition"]["headers"]
815 .as_array()
816 .map(|headers| headers.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
817 Some(vec!["Authorization"])
818 );
819 assert_eq!(
820 value["request_context"]["headers"]
821 .as_array()
822 .map(|headers| headers.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
823 Some(vec!["Authorization"])
824 );
825
826 let text = serde_json::to_string(&value).expect("serialize value");
827 assert!(!text.contains("secret-token"));
828 }
829
830 #[test]
831 fn routing_explain_reports_affinity_and_preference_group() {
832 let request = RouteRequestContext::default();
833 let view = ServiceViewV4 {
834 providers: BTreeMap::from([
835 (
836 "monthly".to_string(),
837 ProviderConfigV4 {
838 base_url: Some("https://monthly.example/v1".to_string()),
839 tags: BTreeMap::from([("billing".to_string(), "monthly".to_string())]),
840 ..ProviderConfigV4::default()
841 },
842 ),
843 (
844 "chili".to_string(),
845 ProviderConfigV4 {
846 base_url: Some("https://chili.example/v1".to_string()),
847 tags: BTreeMap::from([("billing".to_string(), "paygo".to_string())]),
848 ..ProviderConfigV4::default()
849 },
850 ),
851 ]),
852 routing: Some(RoutingConfigV4::tag_preferred(
853 vec!["chili".to_string(), "monthly".to_string()],
854 vec![BTreeMap::from([(
855 "billing".to_string(),
856 "monthly".to_string(),
857 )])],
858 RoutingExhaustedActionV4::Continue,
859 )),
860 ..ServiceViewV4::default()
861 };
862 let template = compile_v4_route_plan_template_with_request("codex", &view, &request)
863 .expect("route template");
864 let mut runtime = RoutePlanRuntimeState::default();
865 runtime.set_affinity_provider_endpoint(Some(ProviderEndpointKey::new(
866 "codex", "chili", "default",
867 )));
868
869 let explain = build_routing_explain_response_with_request(
870 "codex", None, request, None, &template, &runtime,
871 );
872 let value = serde_json::to_value(&explain).expect("serialize explain");
873
874 assert_eq!(
875 value["affinity"]["provider_endpoint_key"].as_str(),
876 Some("codex/chili/default")
877 );
878 assert_eq!(value["affinity_policy"].as_str(), Some("fallback_sticky"));
879 assert_eq!(value["affinity"]["mode"].as_str(), Some("fallback_sticky"));
880 assert_eq!(
881 value["selected_route"]["provider_endpoint_key"].as_str(),
882 Some("codex/chili/default")
883 );
884 assert_eq!(
885 value["selected_route"]["preference_group"].as_u64(),
886 Some(1)
887 );
888 assert_eq!(
889 value["candidates"][1]["provider_endpoint_key"].as_str(),
890 Some("codex/chili/default")
891 );
892 assert_eq!(value["candidates"][1]["preference_group"].as_u64(), Some(1));
893 }
894}