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}