Skip to main content

codex_helper_core/
routing_explain.rs

1use std::collections::BTreeMap;
2
3use crate::config::{RoutingAffinityPolicyV5, RoutingConditionV4};
4use crate::routing_ir::{
5    RouteCandidate, RoutePlanAttemptState, RoutePlanExecutor, RoutePlanRuntimeState,
6    RoutePlanSkipReason, RoutePlanTemplate, RouteRef, RouteRequestContext,
7    request_matches_condition,
8};
9
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
11pub struct RoutingExplainResponse {
12    pub api_version: u32,
13    pub service_name: String,
14    pub runtime_loaded_at_ms: Option<u64>,
15    pub request_model: Option<String>,
16    pub session_id: Option<String>,
17    #[serde(
18        default,
19        skip_serializing_if = "RoutingExplainRequestContext::is_empty"
20    )]
21    pub request_context: RoutingExplainRequestContext,
22    pub selected_route: Option<RoutingExplainCandidate>,
23    pub candidates: Vec<RoutingExplainCandidate>,
24    pub affinity_policy: String,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub affinity: Option<RoutingExplainAffinity>,
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub conditional_routes: Vec<RoutingExplainConditionalRoute>,
29}
30
31#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
32pub struct RoutingExplainRequestContext {
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub model: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub service_tier: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub reasoning_effort: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub path: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub method: Option<String>,
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub headers: Vec<String>,
45}
46
47impl RoutingExplainRequestContext {
48    fn is_empty(&self) -> bool {
49        self.model.is_none()
50            && self.service_tier.is_none()
51            && self.reasoning_effort.is_none()
52            && self.path.is_none()
53            && self.method.is_none()
54            && self.headers.is_empty()
55    }
56}
57
58#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
59pub struct RoutingExplainConditionalRoute {
60    pub route_name: String,
61    pub condition: RoutingExplainCondition,
62    pub matched: bool,
63    pub selected_branch: RoutingExplainConditionalBranch,
64    pub selected_target: Option<RoutingExplainRouteRef>,
65    pub then: Option<RoutingExplainRouteRef>,
66    #[serde(rename = "default")]
67    pub default_route: Option<RoutingExplainRouteRef>,
68}
69
70#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
71#[serde(rename_all = "snake_case")]
72pub enum RoutingExplainConditionalBranch {
73    Then,
74    Default,
75}
76
77#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
78pub struct RoutingExplainRouteRef {
79    pub kind: RoutingExplainRouteRefKind,
80    pub name: String,
81}
82
83#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum RoutingExplainRouteRefKind {
86    Route,
87    Provider,
88    ProviderEndpoint,
89}
90
91#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
92pub struct RoutingExplainCondition {
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub model: Option<String>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub service_tier: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub reasoning_effort: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub path: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub method: Option<String>,
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub headers: Vec<String>,
105}
106
107#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
108pub struct RoutingExplainCandidate {
109    pub provider_id: String,
110    pub provider_alias: Option<String>,
111    pub endpoint_id: String,
112    pub provider_endpoint_key: String,
113    pub route_path: Vec<String>,
114    pub preference_group: u32,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub compatibility: Option<RoutingExplainCompatibility>,
117    pub upstream_base_url: String,
118    pub selected: bool,
119    pub skip_reasons: Vec<RoutingExplainSkipReason>,
120}
121
122#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
123pub struct RoutingExplainAffinity {
124    pub mode: String,
125    pub provider_endpoint_key: String,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub last_selected_at_ms: Option<u64>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub last_changed_at_ms: Option<u64>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub fallback_ttl_ms: Option<u64>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub reprobe_preferred_after_ms: Option<u64>,
134}
135
136#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
137pub struct RoutingExplainCompatibility {
138    pub station_name: String,
139    pub upstream_index: usize,
140}
141
142#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
143#[serde(tag = "code", rename_all = "snake_case")]
144pub enum RoutingExplainSkipReason {
145    UnsupportedModel {
146        requested_model: String,
147    },
148    RuntimeDisabled,
149    Cooldown,
150    BreakerOpen {
151        failure_count: u32,
152    },
153    UsageExhausted,
154    MissingAuth,
155    ConcurrencySaturated {
156        active: Option<u32>,
157        limit: Option<u32>,
158    },
159}
160
161pub fn build_routing_explain_response(
162    service_name: impl Into<String>,
163    runtime_loaded_at_ms: Option<u64>,
164    request_model: Option<String>,
165    session_id: Option<String>,
166    template: &RoutePlanTemplate,
167    runtime: &RoutePlanRuntimeState,
168) -> RoutingExplainResponse {
169    build_routing_explain_response_with_request(
170        service_name,
171        runtime_loaded_at_ms,
172        RouteRequestContext {
173            model: request_model,
174            ..RouteRequestContext::default()
175        },
176        session_id,
177        template,
178        runtime,
179    )
180}
181
182pub fn build_routing_explain_response_with_request(
183    service_name: impl Into<String>,
184    runtime_loaded_at_ms: Option<u64>,
185    request: RouteRequestContext,
186    session_id: Option<String>,
187    template: &RoutePlanTemplate,
188    runtime: &RoutePlanRuntimeState,
189) -> RoutingExplainResponse {
190    let executor = RoutePlanExecutor::new(template);
191    let mut state = RoutePlanAttemptState::default();
192    let selection = executor.select_supported_candidate_with_runtime_state(
193        &mut state,
194        runtime,
195        request.model.as_deref(),
196    );
197    let selected_key = selection
198        .selected
199        .as_ref()
200        .map(|selected| selected.provider_endpoint.stable_key());
201    let skip_reasons_by_candidate = executor
202        .explain_candidate_skip_reasons_with_runtime_state(runtime, request.model.as_deref())
203        .into_iter()
204        .map(|explanation| {
205            (
206                explanation.provider_endpoint.stable_key(),
207                explanation
208                    .reasons
209                    .iter()
210                    .map(RoutingExplainSkipReason::from)
211                    .collect::<Vec<_>>(),
212            )
213        })
214        .collect::<BTreeMap<_, _>>();
215
216    let candidates = executor
217        .iter_candidates()
218        .map(|candidate| {
219            let key = template
220                .candidate_provider_endpoint_key(candidate)
221                .stable_key();
222            routing_explain_candidate(
223                template,
224                candidate,
225                selected_key.as_deref() == Some(key.as_str()),
226                skip_reasons_by_candidate
227                    .get(&key)
228                    .cloned()
229                    .unwrap_or_default(),
230            )
231        })
232        .collect::<Vec<_>>();
233    let selected_route = candidates
234        .iter()
235        .find(|candidate| candidate.selected)
236        .cloned();
237
238    RoutingExplainResponse {
239        api_version: 1,
240        service_name: service_name.into(),
241        runtime_loaded_at_ms,
242        request_model: request.model.clone(),
243        session_id,
244        request_context: RoutingExplainRequestContext::from(&request),
245        selected_route,
246        candidates,
247        affinity_policy: routing_affinity_policy_label(template.affinity_policy).to_string(),
248        affinity: runtime
249            .affinity_provider_endpoint()
250            .map(|key| RoutingExplainAffinity {
251                mode: routing_affinity_policy_label(template.affinity_policy).to_string(),
252                provider_endpoint_key: key.stable_key(),
253                last_selected_at_ms: runtime.affinity_last_selected_at_ms(),
254                last_changed_at_ms: runtime.affinity_last_changed_at_ms(),
255                fallback_ttl_ms: template.fallback_ttl_ms,
256                reprobe_preferred_after_ms: template.reprobe_preferred_after_ms,
257            }),
258        conditional_routes: routing_explain_conditional_routes(template, &request),
259    }
260}
261
262fn routing_affinity_policy_label(policy: RoutingAffinityPolicyV5) -> &'static str {
263    match policy {
264        RoutingAffinityPolicyV5::Off => "off",
265        RoutingAffinityPolicyV5::PreferredGroup => "preferred_group",
266        RoutingAffinityPolicyV5::FallbackSticky => "fallback_sticky",
267        RoutingAffinityPolicyV5::Hard => "hard",
268    }
269}
270
271pub fn parse_routing_explain_headers(
272    headers: &[String],
273) -> Result<BTreeMap<String, String>, String> {
274    let mut out = BTreeMap::new();
275    for header in headers {
276        let Some((name, value)) = header.split_once('=') else {
277            return Err(format!("header condition '{header}' must use NAME=VALUE"));
278        };
279        let name = name.trim();
280        if name.is_empty() {
281            return Err("header condition name cannot be empty".to_string());
282        }
283        out.insert(name.to_string(), value.trim().to_string());
284    }
285    Ok(out)
286}
287
288impl From<&RoutePlanSkipReason> for RoutingExplainSkipReason {
289    fn from(reason: &RoutePlanSkipReason) -> Self {
290        match reason {
291            RoutePlanSkipReason::UnsupportedModel { requested_model } => {
292                RoutingExplainSkipReason::UnsupportedModel {
293                    requested_model: requested_model.clone(),
294                }
295            }
296            RoutePlanSkipReason::RuntimeDisabled => RoutingExplainSkipReason::RuntimeDisabled,
297            RoutePlanSkipReason::Cooldown => RoutingExplainSkipReason::Cooldown,
298            RoutePlanSkipReason::BreakerOpen { failure_count } => {
299                RoutingExplainSkipReason::BreakerOpen {
300                    failure_count: *failure_count,
301                }
302            }
303            RoutePlanSkipReason::UsageExhausted => RoutingExplainSkipReason::UsageExhausted,
304            RoutePlanSkipReason::MissingAuth => RoutingExplainSkipReason::MissingAuth,
305            RoutePlanSkipReason::ConcurrencySaturated { active, limit } => {
306                RoutingExplainSkipReason::ConcurrencySaturated {
307                    active: *active,
308                    limit: *limit,
309                }
310            }
311        }
312    }
313}
314
315impl RoutingExplainSkipReason {
316    pub fn code(&self) -> &'static str {
317        match self {
318            RoutingExplainSkipReason::UnsupportedModel { .. } => "unsupported_model",
319            RoutingExplainSkipReason::RuntimeDisabled => "runtime_disabled",
320            RoutingExplainSkipReason::Cooldown => "cooldown",
321            RoutingExplainSkipReason::BreakerOpen { .. } => "breaker_open",
322            RoutingExplainSkipReason::UsageExhausted => "usage_exhausted",
323            RoutingExplainSkipReason::MissingAuth => "missing_auth",
324            RoutingExplainSkipReason::ConcurrencySaturated { .. } => "concurrency_saturated",
325        }
326    }
327}
328
329fn routing_explain_candidate(
330    template: &RoutePlanTemplate,
331    candidate: &RouteCandidate,
332    selected: bool,
333    skip_reasons: Vec<RoutingExplainSkipReason>,
334) -> RoutingExplainCandidate {
335    let provider_endpoint_key = template
336        .candidate_provider_endpoint_key(candidate)
337        .stable_key();
338    let compatibility = candidate
339        .compatibility_station_name
340        .as_ref()
341        .and_then(|station_name| {
342            candidate
343                .compatibility_upstream_index
344                .map(|upstream_index| RoutingExplainCompatibility {
345                    station_name: station_name.clone(),
346                    upstream_index,
347                })
348        });
349    RoutingExplainCandidate {
350        provider_id: candidate.provider_id.clone(),
351        provider_alias: candidate.provider_alias.clone(),
352        endpoint_id: candidate.endpoint_id.clone(),
353        provider_endpoint_key,
354        route_path: candidate.route_path.clone(),
355        preference_group: candidate.preference_group,
356        compatibility,
357        upstream_base_url: candidate.base_url.clone(),
358        selected,
359        skip_reasons,
360    }
361}
362
363fn routing_explain_conditional_routes(
364    template: &RoutePlanTemplate,
365    request: &RouteRequestContext,
366) -> Vec<RoutingExplainConditionalRoute> {
367    template
368        .nodes
369        .values()
370        .filter_map(|node| {
371            let condition = node.when.as_ref()?;
372            let matched = request_matches_condition(request, condition);
373            let selected_branch = if matched {
374                RoutingExplainConditionalBranch::Then
375            } else {
376                RoutingExplainConditionalBranch::Default
377            };
378            let selected_target = match selected_branch {
379                RoutingExplainConditionalBranch::Then => node.then.as_ref(),
380                RoutingExplainConditionalBranch::Default => node.default_route.as_ref(),
381            }
382            .map(RoutingExplainRouteRef::from);
383
384            Some(RoutingExplainConditionalRoute {
385                route_name: node.name.clone(),
386                condition: RoutingExplainCondition::from(condition),
387                matched,
388                selected_branch,
389                selected_target,
390                then: node.then.as_ref().map(RoutingExplainRouteRef::from),
391                default_route: node
392                    .default_route
393                    .as_ref()
394                    .map(RoutingExplainRouteRef::from),
395            })
396        })
397        .collect()
398}
399
400impl From<&RouteRequestContext> for RoutingExplainRequestContext {
401    fn from(request: &RouteRequestContext) -> Self {
402        Self {
403            model: request.model.clone(),
404            service_tier: request.service_tier.clone(),
405            reasoning_effort: request.reasoning_effort.clone(),
406            path: request.path.clone(),
407            method: request.method.clone(),
408            headers: request.headers.keys().cloned().collect(),
409        }
410    }
411}
412
413impl From<&RoutingConditionV4> for RoutingExplainCondition {
414    fn from(condition: &RoutingConditionV4) -> Self {
415        Self {
416            model: condition.model.clone(),
417            service_tier: condition.service_tier.clone(),
418            reasoning_effort: condition.reasoning_effort.clone(),
419            path: condition.path.clone(),
420            method: condition.method.clone(),
421            headers: condition.headers.keys().cloned().collect(),
422        }
423    }
424}
425
426impl From<&RouteRef> for RoutingExplainRouteRef {
427    fn from(route_ref: &RouteRef) -> Self {
428        match route_ref {
429            RouteRef::Route(name) => Self {
430                kind: RoutingExplainRouteRefKind::Route,
431                name: name.clone(),
432            },
433            RouteRef::Provider(name) => Self {
434                kind: RoutingExplainRouteRefKind::Provider,
435                name: name.clone(),
436            },
437            RouteRef::ProviderEndpoint {
438                provider_id,
439                endpoint_id,
440            } => Self {
441                kind: RoutingExplainRouteRefKind::ProviderEndpoint,
442                name: format!("{provider_id}.{endpoint_id}"),
443            },
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use std::collections::BTreeMap;
451
452    use serde_json::Value;
453
454    use super::*;
455    use crate::config::{
456        ProviderConfigV4, RoutingConditionV4, RoutingConfigV4, RoutingExhaustedActionV4,
457        RoutingNodeV4, RoutingPolicyV4, ServiceViewV4, UpstreamAuth,
458    };
459    use crate::routing_ir::compile_v4_route_plan_template_with_request;
460    use crate::runtime_identity::ProviderEndpointKey;
461
462    fn provider(base_url: &str) -> ProviderConfigV4 {
463        ProviderConfigV4 {
464            base_url: Some(base_url.to_string()),
465            inline_auth: UpstreamAuth::default(),
466            ..ProviderConfigV4::default()
467        }
468    }
469
470    #[test]
471    fn routing_explain_reports_conditional_route_without_header_values() {
472        let request = RouteRequestContext {
473            model: Some("gpt-5".to_string()),
474            headers: BTreeMap::from([("Authorization".to_string(), "secret-token".to_string())]),
475            ..RouteRequestContext::default()
476        };
477        let view = ServiceViewV4 {
478            providers: BTreeMap::from([
479                ("small".to_string(), provider("https://small.example/v1")),
480                ("large".to_string(), provider("https://large.example/v1")),
481            ]),
482            routing: Some(RoutingConfigV4 {
483                entry: "root".to_string(),
484                routes: BTreeMap::from([(
485                    "root".to_string(),
486                    RoutingNodeV4 {
487                        strategy: RoutingPolicyV4::Conditional,
488                        when: Some(RoutingConditionV4 {
489                            model: Some("gpt-5".to_string()),
490                            headers: BTreeMap::from([(
491                                "Authorization".to_string(),
492                                "secret-token".to_string(),
493                            )]),
494                            ..RoutingConditionV4::default()
495                        }),
496                        then: Some("large".to_string()),
497                        default_route: Some("small".to_string()),
498                        ..RoutingNodeV4::default()
499                    },
500                )]),
501                ..RoutingConfigV4::default()
502            }),
503            ..ServiceViewV4::default()
504        };
505        let template = compile_v4_route_plan_template_with_request("codex", &view, &request)
506            .expect("conditional route template");
507
508        let explain = build_routing_explain_response_with_request(
509            "codex",
510            None,
511            request,
512            None,
513            &template,
514            &RoutePlanRuntimeState::default(),
515        );
516        let value = serde_json::to_value(&explain).expect("serialize explain");
517
518        assert_eq!(
519            value["conditional_routes"][0]["selected_branch"].as_str(),
520            Some("then")
521        );
522        assert_eq!(
523            value["conditional_routes"][0]["selected_target"]["kind"].as_str(),
524            Some("provider")
525        );
526        assert_eq!(
527            value["conditional_routes"][0]["selected_target"]["name"].as_str(),
528            Some("large")
529        );
530        assert_eq!(
531            value["conditional_routes"][0]["condition"]["headers"]
532                .as_array()
533                .map(|headers| headers.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
534            Some(vec!["Authorization"])
535        );
536        assert_eq!(
537            value["request_context"]["headers"]
538                .as_array()
539                .map(|headers| headers.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
540            Some(vec!["Authorization"])
541        );
542
543        let text = serde_json::to_string(&value).expect("serialize value");
544        assert!(!text.contains("secret-token"));
545    }
546
547    #[test]
548    fn routing_explain_reports_affinity_and_preference_group() {
549        let request = RouteRequestContext::default();
550        let view = ServiceViewV4 {
551            providers: BTreeMap::from([
552                (
553                    "monthly".to_string(),
554                    ProviderConfigV4 {
555                        base_url: Some("https://monthly.example/v1".to_string()),
556                        tags: BTreeMap::from([("billing".to_string(), "monthly".to_string())]),
557                        ..ProviderConfigV4::default()
558                    },
559                ),
560                (
561                    "chili".to_string(),
562                    ProviderConfigV4 {
563                        base_url: Some("https://chili.example/v1".to_string()),
564                        tags: BTreeMap::from([("billing".to_string(), "paygo".to_string())]),
565                        ..ProviderConfigV4::default()
566                    },
567                ),
568            ]),
569            routing: Some(RoutingConfigV4::tag_preferred(
570                vec!["chili".to_string(), "monthly".to_string()],
571                vec![BTreeMap::from([(
572                    "billing".to_string(),
573                    "monthly".to_string(),
574                )])],
575                RoutingExhaustedActionV4::Continue,
576            )),
577            ..ServiceViewV4::default()
578        };
579        let template = compile_v4_route_plan_template_with_request("codex", &view, &request)
580            .expect("route template");
581        let mut runtime = RoutePlanRuntimeState::default();
582        runtime.set_affinity_provider_endpoint(Some(ProviderEndpointKey::new(
583            "codex", "chili", "default",
584        )));
585
586        let explain = build_routing_explain_response_with_request(
587            "codex", None, request, None, &template, &runtime,
588        );
589        let value = serde_json::to_value(&explain).expect("serialize explain");
590
591        assert_eq!(
592            value["affinity"]["provider_endpoint_key"].as_str(),
593            Some("codex/chili/default")
594        );
595        assert_eq!(value["affinity_policy"].as_str(), Some("fallback_sticky"));
596        assert_eq!(value["affinity"]["mode"].as_str(), Some("fallback_sticky"));
597        assert_eq!(
598            value["selected_route"]["provider_endpoint_key"].as_str(),
599            Some("codex/chili/default")
600        );
601        assert_eq!(
602            value["selected_route"]["preference_group"].as_u64(),
603            Some(1)
604        );
605        assert_eq!(
606            value["candidates"][1]["provider_endpoint_key"].as_str(),
607            Some("codex/chili/default")
608        );
609        assert_eq!(value["candidates"][1]["preference_group"].as_u64(), Some(1));
610    }
611}