Skip to main content

codex_helper_core/
routing_explain.rs

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}