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}