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