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    #[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}