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