1use std::collections::{BTreeMap, BTreeSet, VecDeque};
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10use std::task::{Context, Poll};
11use std::time::{Duration, Instant};
12
13use futures_util::StreamExt;
14use nemo_relay::api::event::{CategoryProfile, DataSchema, EventCategory};
15use nemo_relay::api::llm::LlmRequest;
16use nemo_relay::api::optimization::record_llm_optimization_contribution;
17use nemo_relay::api::runtime::{
18 LlmExecutionFn, LlmJsonStream, LlmStreamExecutionFn, LlmStreamInner,
19};
20use nemo_relay::api::scope::{EmitMarkEventParams, event};
21use nemo_relay::codec::optimization::{
22 LlmOptimizationContribution, LlmOptimizationKind, LlmOptimizationModel,
23 LlmOptimizationModelTransition,
24};
25use nemo_relay::error::{FlowError, Result as FlowResult};
26use nemo_relay::observability::atof::{AtofEndpointFieldNamePolicy, AtofEndpointTransport};
27use nemo_relay::plugin::{
28 ConfigDiagnostic, DiagnosticLevel, Plugin, PluginComponentSpec, PluginConfig, PluginError,
29 PluginRegistrationContext, Result as PluginResult, deregister_plugin, register_plugin,
30};
31use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
32use serde::{Deserialize, Serialize};
33use serde_json::{Map, Value as Json, json};
34use uuid::Uuid;
35
36use crate::contract::{
37 DecisionAttempt, DecisionProfile, ROUTING_DECISION_SCHEMA_VERSION,
38 ROUTING_REQUEST_SCHEMA_VERSION, RequestIdentity, RequestMaterialization, RequestProtocol,
39 RequestSummary, RoutingDecision, RoutingRequest, RoutingTarget,
40};
41use crate::stream_translation::StreamTranscoder;
42use crate::translation::{
43 decode_request, encode_request, latest_user_prompt, recent_message_window, translate_response,
44 translation_engine, validate_portable_request,
45};
46
47pub const SWITCHYARD_PLUGIN_KIND: &str = "switchyard";
49
50const SWITCHYARD_HEALTH_PATH: &str = "/health";
51const SWITCHYARD_HEALTH_TIMEOUT: Duration = Duration::from_secs(2);
52const SWITCHYARD_HEALTH_MAX_ATTEMPTS: usize = 3;
53const SWITCHYARD_HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100);
54const INTERNAL_DISPATCH_BACKEND_HEADER: &str = "x-nemo-relay-internal-dispatch-backend";
55const INTERNAL_DISPATCH_URL_HEADER: &str = "x-nemo-relay-internal-dispatch-url";
56const INTERNAL_DISPATCH_ROUTE_HEADER: &str = "x-nemo-relay-internal-dispatch-route";
57const INTERNAL_RETRY_AWARE_HEADER: &str = "x-nemo-relay-internal-retry-aware";
58const ROUTING_MARK_SCHEMA: &str = "switchyard.routing_mark";
59const ROUTING_CONTRIBUTION_SCHEMA: &str = "nvidia.switchyard.routing_optimization";
60
61#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64#[serde(rename_all = "snake_case")]
65pub enum WireProtocol {
66 OpenaiChat,
68 OpenaiResponses,
70 AnthropicMessages,
72}
73
74impl WireProtocol {
75 fn label(self) -> &'static str {
76 match self {
77 Self::OpenaiChat => "openai_chat",
78 Self::OpenaiResponses => "openai_responses",
79 Self::AnthropicMessages => "anthropic_messages",
80 }
81 }
82
83 fn endpoint(self) -> &'static str {
84 match self {
85 Self::OpenaiChat => "/v1/chat/completions",
86 Self::OpenaiResponses => "/v1/responses",
87 Self::AnthropicMessages => "/v1/messages",
88 }
89 }
90
91 fn from_call(name: &str, request: &LlmRequest) -> Option<Self> {
92 match name {
93 "openai.chat_completions" | "openai_chat" | "openai_chat_completions" => {
94 Some(Self::OpenaiChat)
95 }
96 "openai.responses" | "openai_responses" => Some(Self::OpenaiResponses),
97 "anthropic.messages" | "anthropic" | "anthropic_messages" => {
98 Some(Self::AnthropicMessages)
99 }
100 _ if request.content.get("input").is_some() => Some(Self::OpenaiResponses),
101 _ if request.content.get("system").is_some() => Some(Self::AnthropicMessages),
102 _ if request.content.get("messages").is_some() => Some(Self::OpenaiChat),
103 _ => None,
104 }
105 }
106}
107
108#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111#[serde(rename_all = "snake_case")]
112pub enum RoutingMode {
113 #[default]
115 Enforce,
116 ObserveOnly,
118}
119
120impl RoutingMode {
121 fn label(self) -> &'static str {
122 match self {
123 Self::Enforce => "enforce",
124 Self::ObserveOnly => "observe_only",
125 }
126 }
127}
128
129#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132#[serde(rename_all = "snake_case")]
133pub enum ContextMode {
134 PayloadOnly,
136 AtofRequired,
138}
139
140#[derive(Clone, Debug, Deserialize, Serialize)]
142#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
143pub struct TargetBinding {
144 pub model: String,
146 pub protocol: WireProtocol,
148 pub endpoint: String,
150 pub base_url: String,
152 #[serde(default)]
154 pub headers: BTreeMap<String, String>,
155 #[serde(default)]
157 pub header_env: BTreeMap<String, String>,
158}
159
160#[derive(Clone, Debug, Deserialize, Serialize)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163pub struct ProtocolDefaults {
164 #[serde(default)]
166 pub openai_chat: String,
167 #[serde(default)]
169 pub openai_responses: String,
170 #[serde(default)]
172 pub anthropic_messages: String,
173}
174
175impl ProtocolDefaults {
176 fn target(&self, protocol: WireProtocol) -> &str {
177 match protocol {
178 WireProtocol::OpenaiChat => &self.openai_chat,
179 WireProtocol::OpenaiResponses => &self.openai_responses,
180 WireProtocol::AnthropicMessages => &self.anthropic_messages,
181 }
182 }
183}
184
185#[derive(Clone, Debug, Deserialize, Serialize)]
187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
188pub struct SwitchyardConfig {
189 #[serde(default = "default_version")]
191 pub version: u32,
192 #[serde(default)]
194 pub mode: RoutingMode,
195 #[serde(default)]
197 pub priority: i32,
198 pub decision_api_url: String,
200 pub decision_profile_id: String,
202 pub request_materialization: RequestMaterialization,
204 pub context_mode: ContextMode,
206 #[serde(default = "default_decision_timeout_millis")]
208 pub decision_timeout_millis: u64,
209 #[serde(default = "default_max_retries")]
211 pub max_retries: u32,
212 #[serde(default = "default_recent_message_count")]
214 pub recent_message_count: usize,
215 #[serde(default)]
217 pub decision_headers: BTreeMap<String, String>,
218 #[serde(default)]
220 pub decision_header_env: BTreeMap<String, String>,
221 #[serde(default = "default_enabled_protocols")]
223 pub enabled_inbound_profiles: BTreeSet<WireProtocol>,
224 pub targets: BTreeMap<String, TargetBinding>,
226 pub default_targets: ProtocolDefaults,
228 #[serde(default)]
230 pub atof_endpoint_name: Option<String>,
231}
232
233impl Default for SwitchyardConfig {
234 fn default() -> Self {
235 Self {
236 version: default_version(),
237 mode: RoutingMode::default(),
238 priority: 0,
239 decision_api_url: "http://127.0.0.1:8080/v1/routing/decision".into(),
240 decision_profile_id: String::new(),
241 request_materialization: RequestMaterialization::SummaryOnly,
242 context_mode: ContextMode::PayloadOnly,
243 decision_timeout_millis: default_decision_timeout_millis(),
244 max_retries: default_max_retries(),
245 recent_message_count: default_recent_message_count(),
246 decision_headers: BTreeMap::new(),
247 decision_header_env: BTreeMap::new(),
248 enabled_inbound_profiles: default_enabled_protocols(),
249 targets: BTreeMap::new(),
250 default_targets: ProtocolDefaults {
251 openai_chat: String::new(),
252 openai_responses: String::new(),
253 anthropic_messages: String::new(),
254 },
255 atof_endpoint_name: None,
256 }
257 }
258}
259
260nemo_relay::editor_config! {
261 impl SwitchyardConfig {
262 mode => { label: "Rollout mode", kind: Enum, values: ["enforce", "observe_only"] },
263 priority => { label: "Intercept priority", kind: Integer },
264 decision_api_url => { label: "Decision API URL", kind: String },
265 decision_profile_id => { label: "Decision profile ID", kind: String },
266 request_materialization => {
267 label: "Request materialization",
268 kind: Enum,
269 values: ["none", "summary_only", "latest_user_prompt", "recent_message_window", "annotated_request", "full_body"]
270 },
271 context_mode => { label: "Context mode", kind: Enum, values: ["payload_only", "atof_required"] },
272 decision_timeout_millis => { label: "Decision timeout (ms)", kind: Integer },
273 max_retries => { label: "Maximum provider retries", kind: Integer },
274 recent_message_count => { label: "Recent message count", kind: Integer },
275 decision_headers => { label: "Decision API static headers", kind: StringMap },
276 decision_header_env => { label: "Decision API environment headers", kind: StringMap },
277 enabled_inbound_profiles => { label: "Enabled inbound profiles", kind: Json },
278 targets => { label: "Backend target bindings", kind: Json },
279 default_targets => { label: "Trusted protocol defaults", kind: Json },
280 atof_endpoint_name => { label: "ATOF endpoint name", kind: String, optional: true }
281 }
282}
283
284impl From<SwitchyardConfig> for PluginComponentSpec {
285 fn from(value: SwitchyardConfig) -> Self {
286 let Json::Object(config) =
287 serde_json::to_value(value).expect("Switchyard config should serialize to an object")
288 else {
289 unreachable!("Switchyard config must serialize to an object")
290 };
291 Self {
292 kind: SWITCHYARD_PLUGIN_KIND.into(),
293 enabled: true,
294 config,
295 }
296 }
297}
298
299fn default_version() -> u32 {
300 1
301}
302fn default_decision_timeout_millis() -> u64 {
303 25
304}
305fn default_max_retries() -> u32 {
306 3
307}
308fn default_recent_message_count() -> usize {
309 8
310}
311fn default_enabled_protocols() -> BTreeSet<WireProtocol> {
312 BTreeSet::from([
313 WireProtocol::OpenaiChat,
314 WireProtocol::OpenaiResponses,
315 WireProtocol::AnthropicMessages,
316 ])
317}
318
319struct SwitchyardPlugin;
320
321impl Plugin for SwitchyardPlugin {
322 fn plugin_kind(&self) -> &str {
323 SWITCHYARD_PLUGIN_KIND
324 }
325
326 fn allows_multiple_components(&self) -> bool {
327 false
328 }
329
330 fn validate(&self, plugin_config: &Map<String, Json>) -> Vec<ConfigDiagnostic> {
331 match parse_config(plugin_config).and_then(SwitchyardRuntime::new) {
332 Ok(_) => Vec::new(),
333 Err(error) => vec![ConfigDiagnostic {
334 level: DiagnosticLevel::Error,
335 code: "switchyard.invalid_config".into(),
336 component: Some(SWITCHYARD_PLUGIN_KIND.into()),
337 field: None,
338 message: error,
339 }],
340 }
341 }
342
343 fn register<'a>(
344 &'a self,
345 plugin_config: &Map<String, Json>,
346 ctx: &'a mut PluginRegistrationContext,
347 ) -> Pin<Box<dyn Future<Output = PluginResult<()>> + Send + 'a>> {
348 let parsed = parse_config(plugin_config);
349 Box::pin(async move {
350 let runtime = Arc::new(
351 parsed
352 .and_then(SwitchyardRuntime::new)
353 .map_err(PluginError::InvalidConfig)?,
354 );
355 runtime
356 .require_healthy_sidecar()
357 .await
358 .map_err(PluginError::RegistrationFailed)?;
359 let buffered = Arc::clone(&runtime);
360 let buffered_intercept: LlmExecutionFn = Arc::new(move |name, request, next| {
361 let runtime = Arc::clone(&buffered);
362 let name = name.to_string();
363 Box::pin(async move { runtime.execute_buffered(&name, request, next).await })
364 });
365 ctx.register_llm_execution_intercept(
366 "decision",
367 runtime.config.priority,
368 buffered_intercept,
369 )?;
370
371 let streaming = Arc::clone(&runtime);
372 let stream_intercept: LlmStreamExecutionFn = Arc::new(move |name, request, next| {
373 let runtime = Arc::clone(&streaming);
374 let name = name.to_string();
375 Box::pin(async move { runtime.execute_stream(&name, request, next).await })
376 });
377 ctx.register_llm_stream_execution_intercept(
378 "decision_stream",
379 runtime.config.priority,
380 stream_intercept,
381 )?;
382 Ok(())
383 })
384 }
385}
386
387pub fn register_switchyard_component() -> PluginResult<()> {
389 match register_plugin(Arc::new(SwitchyardPlugin)) {
390 Ok(()) => Ok(()),
391 Err(PluginError::RegistrationFailed(message)) if message.contains("already registered") => {
392 Ok(())
393 }
394 Err(error) => Err(error),
395 }
396}
397
398pub fn deregister_switchyard_component() -> bool {
400 deregister_plugin(SWITCHYARD_PLUGIN_KIND)
401}
402
403pub fn validate_switchyard_atof_configuration(config: &PluginConfig) -> Result<(), String> {
405 let Some(component) = config
406 .components
407 .iter()
408 .find(|component| component.enabled && component.kind == SWITCHYARD_PLUGIN_KIND)
409 else {
410 return Ok(());
411 };
412 let switchyard = parse_config(&component.config)?;
413 if switchyard.context_mode != ContextMode::AtofRequired {
414 return Ok(());
415 }
416 let required_name = validate_atof_endpoint_name(switchyard.atof_endpoint_name.as_deref())?
417 .ok_or_else(|| {
418 "atof_required Switchyard profiles require atof_endpoint_name".to_string()
419 })?;
420 let observability = config
421 .components
422 .iter()
423 .find(|component| component.enabled && component.kind == "observability")
424 .ok_or_else(|| "atof_required Switchyard profiles require observability".to_string())?;
425 let sinks = observability
426 .config
427 .get("atof")
428 .filter(|atof| atof.get("enabled").and_then(Json::as_bool) == Some(true))
429 .and_then(|atof| atof.get("sinks"))
430 .and_then(Json::as_array)
431 .ok_or_else(|| {
432 "atof_required Switchyard profiles require an enabled ATOF endpoint".to_string()
433 })?;
434 let matching_sinks = sinks
435 .iter()
436 .filter(|sink| {
437 sink.get("type").and_then(Json::as_str) == Some("stream")
438 && sink.get("name").and_then(Json::as_str) == Some(required_name)
439 })
440 .collect::<Vec<_>>();
441 let endpoint = match matching_sinks.as_slice() {
442 [sink] => *sink,
443 [] => {
444 return Err(format!(
445 "atof_required Switchyard profile requires named ATOF endpoint {required_name:?}"
446 ));
447 }
448 _ => {
449 return Err(format!(
450 "ATOF endpoint name {required_name:?} must resolve to exactly one endpoint"
451 ));
452 }
453 };
454 let transport = endpoint.get("transport").map_or_else(
455 || Some(AtofEndpointTransport::default()),
456 |value| value.as_str().and_then(AtofEndpointTransport::parse),
457 );
458 if transport != Some(AtofEndpointTransport::HttpPost) {
459 return Err(format!(
460 "Switchyard ATOF endpoint {required_name:?} must use transport = http_post"
461 ));
462 }
463 let field_name_policy = endpoint.get("field_name_policy").map_or_else(
464 || Some(AtofEndpointFieldNamePolicy::default()),
465 |value| value.as_str().and_then(AtofEndpointFieldNamePolicy::parse),
466 );
467 if field_name_policy != Some(AtofEndpointFieldNamePolicy::Preserve) {
468 return Err(format!(
469 "Switchyard ATOF endpoint {required_name:?} must use field_name_policy = preserve"
470 ));
471 }
472 if endpoint
473 .get("header_env")
474 .and_then(Json::as_object)
475 .is_none_or(Map::is_empty)
476 {
477 return Err(format!(
478 "Switchyard ATOF endpoint {required_name:?} authentication must use at least one environment-referenced header"
479 ));
480 }
481 Ok(())
482}
483
484fn parse_config(config: &Map<String, Json>) -> Result<SwitchyardConfig, String> {
485 serde_json::from_value(Json::Object(config.clone()))
486 .map_err(|error| format!("invalid Switchyard plugin config: {error}"))
487}
488
489struct SwitchyardRuntime {
490 config: SwitchyardConfig,
491 client: reqwest::Client,
492 target_headers: BTreeMap<String, Map<String, Json>>,
493 translation: switchyard_translation::TranslationEngine,
494}
495
496enum BufferedAttempt {
497 Complete(Json),
498 Retry((String, String)),
499 Fallback(&'static str),
500}
501
502enum StreamAttempt {
503 Committed(LlmJsonStream),
504 Retry((String, String)),
505 Fallback(&'static str),
506}
507
508struct StreamAttemptContext {
509 routing_request: RoutingRequest,
510 decision: RoutingDecision,
511 attempt: u32,
512 max_attempts: u32,
513}
514
515fn provider_fallback_reason(error: &FlowError) -> &'static str {
516 if error_is_retryable(error) {
517 "retry_exhausted"
518 } else {
519 "non_retryable_provider_error"
520 }
521}
522
523impl SwitchyardRuntime {
524 fn new(config: SwitchyardConfig) -> Result<Self, String> {
525 validate_config(&config)?;
526 let headers = resolve_headers(&config.decision_headers, &config.decision_header_env)?;
527 let client = reqwest::Client::builder()
528 .default_headers(headers)
529 .timeout(Duration::from_millis(config.decision_timeout_millis))
530 .build()
531 .map_err(|error| format!("failed to build Decision API client: {error}"))?;
532 let target_headers = config
533 .targets
534 .iter()
535 .map(|(id, target)| {
536 let headers = resolve_json_headers(&target.headers, &target.header_env)?;
537 Ok((id.clone(), headers))
538 })
539 .collect::<Result<_, String>>()?;
540 Ok(Self {
541 config,
542 client,
543 target_headers,
544 translation: translation_engine(),
545 })
546 }
547
548 fn may_translate_protocol(&self, inbound: WireProtocol) -> bool {
551 self.config
552 .targets
553 .values()
554 .any(|target| target.protocol != inbound)
555 }
556
557 async fn require_healthy_sidecar(&self) -> Result<(), String> {
558 let health_url = switchyard_health_url(&self.config.decision_api_url)?;
559 let client = reqwest::Client::builder()
560 .timeout(SWITCHYARD_HEALTH_TIMEOUT)
561 .build()
562 .map_err(|error| format!("failed to build Switchyard health client: {error}"))?;
563 let mut backoff = SWITCHYARD_HEALTH_INITIAL_BACKOFF;
564 let mut final_error = None;
565 for attempt in 1..=SWITCHYARD_HEALTH_MAX_ATTEMPTS {
566 match check_switchyard_health(&client, &health_url).await {
567 Ok(()) => return Ok(()),
568 Err(error) => final_error = Some(error),
569 }
570 if attempt < SWITCHYARD_HEALTH_MAX_ATTEMPTS {
571 tokio::time::sleep(backoff).await;
572 backoff *= 2;
573 }
574 }
575 Err(final_error.expect("at least one Switchyard health attempt is configured"))
576 }
577
578 async fn execute_buffered(
579 &self,
580 name: &str,
581 original: LlmRequest,
582 next: nemo_relay::api::runtime::LlmExecutionNextFn,
583 ) -> FlowResult<Json> {
584 let Some(inbound) = WireProtocol::from_call(name, &original) else {
585 return next(original).await;
586 };
587 if !self.config.enabled_inbound_profiles.contains(&inbound) {
588 return next(original).await;
589 }
590 if self.may_translate_protocol(inbound)
591 && let Err(error) = validate_portable_request(&self.translation, inbound, &original)
592 {
593 self.emit_error(
594 None,
595 0,
596 "unsupported_provider_extension",
597 &error.to_string(),
598 );
599 return self
600 .dispatch_fallback_buffered(
601 inbound,
602 original,
603 next,
604 "unsupported_provider_extension",
605 )
606 .await;
607 }
608
609 if self.config.mode == RoutingMode::ObserveOnly {
610 match self.decided_request(inbound, &original, 1, None).await {
611 Ok((_, decision, _)) => {
612 self.record_routing_contribution(&decision, 1, false);
613 }
614 Err(error) => self.emit_error(None, 1, "decision_api", &error),
615 }
616 return self
617 .dispatch_fallback_buffered(inbound, original, next, "observe_only")
618 .await;
619 }
620
621 let max_attempts = self.config.max_retries.saturating_add(1);
622 let mut previous = None;
623 for attempt in 1..=max_attempts {
624 match self
625 .buffered_attempt(inbound, &original, &next, attempt, previous, max_attempts)
626 .await?
627 {
628 BufferedAttempt::Complete(response) => return Ok(response),
629 BufferedAttempt::Retry(retry) => previous = Some(retry),
630 BufferedAttempt::Fallback(reason) => {
631 return self
632 .dispatch_fallback_buffered(inbound, original, next, reason)
633 .await;
634 }
635 }
636 }
637 unreachable!("routing attempt loop always returns")
638 }
639
640 async fn buffered_attempt(
641 &self,
642 inbound: WireProtocol,
643 original: &LlmRequest,
644 next: &nemo_relay::api::runtime::LlmExecutionNextFn,
645 attempt: u32,
646 previous: Option<(String, String)>,
647 max_attempts: u32,
648 ) -> FlowResult<BufferedAttempt> {
649 let (routing_request, decision, routed) = match self
650 .decided_request(inbound, original, attempt, previous)
651 .await
652 {
653 Ok(value) => value,
654 Err(error) => {
655 self.emit_error(None, attempt, "decision_api", &error);
656 return Ok(BufferedAttempt::Fallback("decision_error"));
657 }
658 };
659 let target_protocol = protocol_from_label(&decision.route.target_protocol_profile)?;
660 match next(routed).await {
661 Ok(response) => {
662 match translate_response(&self.translation, target_protocol, inbound, &response) {
663 Ok(response) => {
664 self.record_routing_contribution(&decision, attempt, true);
665 Ok(BufferedAttempt::Complete(response))
666 }
667 Err(error) => {
668 self.emit_error(
669 Some(&routing_request),
670 attempt,
671 "response_translation",
672 &error.to_string(),
673 );
674 Ok(BufferedAttempt::Fallback("translation_error"))
675 }
676 }
677 }
678 Err(error) if error_is_retryable(&error) && attempt < max_attempts => {
679 let retry_reason = provider_error_summary(&error);
680 self.emit_error(Some(&routing_request), attempt, "provider", &retry_reason);
681 self.emit_retry(&routing_request, &decision, attempt, &retry_reason);
682 Ok(BufferedAttempt::Retry((
683 decision.route.backend_id,
684 retry_reason,
685 )))
686 }
687 Err(error) => {
688 let summary = provider_error_summary(&error);
689 self.emit_error(Some(&routing_request), attempt, "provider", &summary);
690 Ok(BufferedAttempt::Fallback(provider_fallback_reason(&error)))
691 }
692 }
693 }
694
695 async fn execute_stream(
696 &self,
697 name: &str,
698 original: LlmRequest,
699 next: nemo_relay::api::runtime::LlmStreamExecutionNextFn,
700 ) -> FlowResult<LlmJsonStream> {
701 let Some(inbound) = WireProtocol::from_call(name, &original) else {
702 return next(original).await;
703 };
704 if !self.config.enabled_inbound_profiles.contains(&inbound) {
705 return next(original).await;
706 }
707 if self.may_translate_protocol(inbound)
708 && let Err(error) = validate_portable_request(&self.translation, inbound, &original)
709 {
710 self.emit_error(
711 None,
712 0,
713 "unsupported_provider_extension",
714 &error.to_string(),
715 );
716 return self
717 .dispatch_fallback_stream(inbound, original, next, "unsupported_provider_extension")
718 .await;
719 }
720 if self.config.mode == RoutingMode::ObserveOnly {
721 match self.decided_request(inbound, &original, 1, None).await {
722 Ok((_, decision, _)) => {
723 self.record_routing_contribution(&decision, 1, false);
724 }
725 Err(error) => self.emit_error(None, 1, "decision_api", &error),
726 }
727 return self
728 .dispatch_fallback_stream(inbound, original, next, "observe_only")
729 .await;
730 }
731
732 let max_attempts = self.config.max_retries.saturating_add(1);
733 let mut previous = None;
734 for attempt in 1..=max_attempts {
735 match self
736 .stream_attempt(inbound, &original, &next, attempt, previous, max_attempts)
737 .await?
738 {
739 StreamAttempt::Committed(stream) => return Ok(stream),
740 StreamAttempt::Retry(retry) => previous = Some(retry),
741 StreamAttempt::Fallback(reason) => {
742 return self
743 .dispatch_fallback_stream(inbound, original, next, reason)
744 .await;
745 }
746 }
747 }
748 unreachable!("stream routing attempt loop always returns")
749 }
750
751 async fn stream_attempt(
752 &self,
753 inbound: WireProtocol,
754 original: &LlmRequest,
755 next: &nemo_relay::api::runtime::LlmStreamExecutionNextFn,
756 attempt: u32,
757 previous: Option<(String, String)>,
758 max_attempts: u32,
759 ) -> FlowResult<StreamAttempt> {
760 let (routing_request, decision, routed) = match self
761 .decided_request(inbound, original, attempt, previous)
762 .await
763 {
764 Ok(value) => value,
765 Err(error) => {
766 self.emit_error(None, attempt, "decision_api", &error);
767 return Ok(StreamAttempt::Fallback("decision_error"));
768 }
769 };
770 let target_protocol = protocol_from_label(&decision.route.target_protocol_profile)?;
771 let context = StreamAttemptContext {
772 routing_request,
773 decision,
774 attempt,
775 max_attempts,
776 };
777 match next(routed).await {
778 Ok(mut upstream) => {
779 let first = upstream.next().await;
780 Ok(self.classify_open_stream(inbound, target_protocol, context, upstream, first))
781 }
782 Err(error) => Ok(self.classify_stream_setup_error(context, error)),
783 }
784 }
785
786 fn classify_open_stream(
787 &self,
788 inbound: WireProtocol,
789 target_protocol: WireProtocol,
790 context: StreamAttemptContext,
791 upstream: LlmJsonStream,
792 first: Option<FlowResult<Json>>,
793 ) -> StreamAttempt {
794 let StreamAttemptContext {
795 routing_request,
796 decision,
797 attempt,
798 max_attempts,
799 } = context;
800 match first {
801 Some(Ok(first)) => {
802 self.record_routing_contribution(&decision, attempt, true);
803 let committed = LlmJsonStream::from_closeable(PrefixedStream {
804 first: Some(Ok(first)),
805 upstream,
806 });
807 let output = if target_protocol == inbound {
808 committed
809 } else {
810 translated_stream(
811 target_protocol,
812 inbound,
813 decision.route.target_model.clone(),
814 committed,
815 )
816 };
817 StreamAttempt::Committed(mark_terminal_stream(
818 output,
819 "provider_stream_committed",
820 self.config.mode.label(),
821 identity_metadata(&routing_request),
822 ))
823 }
824 Some(Err(error)) if error_is_retryable(&error) && attempt < max_attempts => self
825 .retry_stream_attempt(
826 &routing_request,
827 decision,
828 attempt,
829 "provider_stream_open",
830 provider_error_summary(&error),
831 ),
832 None if attempt < max_attempts => self.retry_stream_attempt(
833 &routing_request,
834 decision,
835 attempt,
836 "provider_stream_open",
837 "empty_stream".into(),
838 ),
839 Some(Err(error)) => {
840 let summary = provider_error_summary(&error);
841 self.emit_error(
842 Some(&routing_request),
843 attempt,
844 "provider_stream_open",
845 &summary,
846 );
847 StreamAttempt::Fallback(provider_fallback_reason(&error))
848 }
849 None => StreamAttempt::Fallback("empty_stream"),
850 }
851 }
852
853 fn classify_stream_setup_error(
854 &self,
855 context: StreamAttemptContext,
856 error: FlowError,
857 ) -> StreamAttempt {
858 let StreamAttemptContext {
859 routing_request,
860 decision,
861 attempt,
862 max_attempts,
863 } = context;
864 let summary = provider_error_summary(&error);
865 if error_is_retryable(&error) && attempt < max_attempts {
866 return self.retry_stream_attempt(
867 &routing_request,
868 decision,
869 attempt,
870 "provider_stream_setup",
871 summary,
872 );
873 }
874 self.emit_error(
875 Some(&routing_request),
876 attempt,
877 "provider_stream_setup",
878 &summary,
879 );
880 StreamAttempt::Fallback(provider_fallback_reason(&error))
881 }
882
883 fn retry_stream_attempt(
884 &self,
885 routing_request: &RoutingRequest,
886 decision: RoutingDecision,
887 attempt: u32,
888 error_class: &str,
889 reason: String,
890 ) -> StreamAttempt {
891 if reason != "empty_stream" {
892 self.emit_error(Some(routing_request), attempt, error_class, &reason);
893 }
894 self.emit_retry(routing_request, &decision, attempt, &reason);
895 StreamAttempt::Retry((decision.route.backend_id, reason))
896 }
897
898 async fn decided_request(
899 &self,
900 inbound: WireProtocol,
901 original: &LlmRequest,
902 attempt: u32,
903 previous: Option<(String, String)>,
904 ) -> Result<(RoutingRequest, RoutingDecision, LlmRequest), String> {
905 let request = self.routing_request(inbound, original, attempt, previous)?;
906 self.emit_requested(&request);
907 let started = Instant::now();
908 let response = self
909 .client
910 .post(&self.config.decision_api_url)
911 .header("x-nemo-relay-session-id", &request.identity.session_id)
912 .json(&request)
913 .send()
914 .await
915 .map_err(|error| format!("Decision API request failed: {error}"))?;
916 let status = response.status();
917 if !status.is_success() {
918 let body = response.text().await.unwrap_or_default();
919 return Err(format!("Decision API returned HTTP {status}: {body}"));
920 }
921 let decision = response
922 .json::<RoutingDecision>()
923 .await
924 .map_err(|error| format!("Decision API returned invalid JSON: {error}"))?;
925 self.validate_decision(&decision)?;
926 if let Some(baseline) = decision.baseline_route.as_ref()
927 && let Err(error) = self.validate_target(baseline)
928 {
929 self.emit_error(Some(&request), attempt, "baseline_binding", &error);
930 }
931 let routed = self.apply_target(inbound, original.clone(), &decision)?;
932 let latency = started.elapsed().as_millis() as u64;
933 self.emit_decision(
934 &request,
935 &decision,
936 attempt,
937 self.config.mode == RoutingMode::ObserveOnly,
938 latency,
939 );
940 Ok((request, decision, routed))
941 }
942
943 fn routing_request(
944 &self,
945 inbound: WireProtocol,
946 request: &LlmRequest,
947 attempt: u32,
948 previous: Option<(String, String)>,
949 ) -> Result<RoutingRequest, String> {
950 let session = header(request, "x-nemo-relay-session-id");
951 let stable_request_id = header(request, "x-nemo-relay-request-id");
952 if self.config.context_mode == ContextMode::AtofRequired
953 && (session.is_none() || stable_request_id.is_none())
954 {
955 return Err("stable session and request identity are required for this profile".into());
956 }
957 let identity_is_stable = session.is_some() && stable_request_id.is_some();
958 let synthetic_session = format!("request-{}", Uuid::now_v7());
959 let session_id = session.unwrap_or_else(|| synthetic_session.clone());
960 let request_id = stable_request_id.unwrap_or_else(|| format!("request-{}", Uuid::now_v7()));
961 let annotated = decode_request(&self.translation, inbound, request)
962 .map_err(|error| format!("request translation decode failed: {error}"))?;
963 let current_request = self.materialize(inbound, request, &annotated)?;
964 let (previous_route, retry_reason) = previous.unzip();
965 Ok(RoutingRequest {
966 schema_version: ROUTING_REQUEST_SCHEMA_VERSION.into(),
967 decision_profile: DecisionProfile {
968 profile_id: self.config.decision_profile_id.clone(),
969 request_materialization: self.config.request_materialization,
970 },
971 identity: RequestIdentity {
972 session_id,
973 request_id,
974 turn_id: header(request, "x-nemo-relay-turn-id"),
975 parent_scope_id: header(request, "x-nemo-relay-parent-scope-id"),
976 root_scope_id: header(request, "x-nemo-relay-root-scope-id"),
977 harness: header(request, "x-nemo-relay-agent-kind")
978 .unwrap_or_else(|| "unknown".into()),
979 source: header(request, "x-nemo-relay-source")
980 .unwrap_or_else(|| "nemo-relay".into()),
981 owner_id: header(request, "x-nemo-relay-owner-id"),
982 quality: header(request, "x-nemo-relay-identity-quality").unwrap_or_else(|| {
983 if identity_is_stable {
984 "explicit".into()
985 } else {
986 "synthetic".into()
987 }
988 }),
989 },
990 protocol: RequestProtocol {
991 inbound_profile: inbound.label().into(),
992 inbound_endpoint: inbound.endpoint().into(),
993 desired_response_profile: inbound.label().into(),
994 },
995 request_summary: RequestSummary {
996 client_requested_model: request
997 .content
998 .get("model")
999 .and_then(Json::as_str)
1000 .map(ToOwned::to_owned),
1001 prompt_token_estimate: None,
1002 tool_count_in_payload: request
1003 .content
1004 .get("tools")
1005 .and_then(Json::as_array)
1006 .map(|tools| tools.len() as u64),
1007 has_system_prompt: Some(
1008 annotated.instructions.iter().any(|instruction| {
1009 instruction.role == switchyard_translation::Role::System
1010 }) || annotated
1011 .messages
1012 .iter()
1013 .any(|message| message.role == switchyard_translation::Role::System),
1014 ),
1015 },
1016 current_request,
1017 attempt: DecisionAttempt {
1018 routing_attempt: attempt,
1019 max_routing_attempts: self.config.max_retries.saturating_add(1),
1020 previous_route,
1021 retry_reason,
1022 },
1023 })
1024 }
1025
1026 fn materialize(
1027 &self,
1028 inbound: WireProtocol,
1029 request: &LlmRequest,
1030 annotated: &switchyard_translation::LlmRequest,
1031 ) -> Result<Option<Json>, String> {
1032 match self.config.request_materialization {
1033 RequestMaterialization::None | RequestMaterialization::SummaryOnly => Ok(None),
1034 RequestMaterialization::FullBody => Ok(Some(json!({"body": request.content}))),
1035 RequestMaterialization::AnnotatedRequest => Ok(Some(json!({
1036 "body": request.content,
1037 "annotated_request": annotated,
1038 }))),
1039 RequestMaterialization::LatestUserPrompt => {
1040 let prompt = latest_user_prompt(annotated)
1041 .ok_or_else(|| "latest_user_prompt requires a user message".to_string())?;
1042 let latest = recent_message_window(annotated, 1);
1043 let body = encode_request(&self.translation, inbound, &latest, Map::new())
1044 .map_err(|error| format!("latest user prompt encode failed: {error}"))?
1045 .content;
1046 Ok(Some(json!({"body": body, "latest_user_prompt": prompt})))
1047 }
1048 RequestMaterialization::RecentMessageWindow => {
1049 let window = recent_message_window(annotated, self.config.recent_message_count);
1050 let body = encode_request(&self.translation, inbound, &window, Map::new())
1051 .map_err(|error| format!("recent window encode failed: {error}"))?
1052 .content;
1053 Ok(Some(json!({"body": body, "annotated_request": window})))
1054 }
1055 }
1056 }
1057
1058 fn validate_decision(&self, decision: &RoutingDecision) -> Result<(), String> {
1059 if decision.schema_version != ROUTING_DECISION_SCHEMA_VERSION {
1060 return Err(format!(
1061 "unsupported decision schema {:?}",
1062 decision.schema_version
1063 ));
1064 }
1065 self.validate_target(&decision.route).map(|_| ())
1066 }
1067
1068 fn validate_target(&self, target: &RoutingTarget) -> Result<&TargetBinding, String> {
1069 let binding = self
1070 .config
1071 .targets
1072 .get(&target.backend_id)
1073 .ok_or_else(|| format!("unknown backend_id {:?}", target.backend_id))?;
1074 if binding.model != target.target_model
1075 || binding.protocol.label() != target.target_protocol_profile
1076 || binding.endpoint != target.target_endpoint
1077 {
1078 return Err(format!(
1079 "decision target {:?} does not match its exact Relay binding",
1080 target.backend_id
1081 ));
1082 }
1083 Ok(binding)
1084 }
1085
1086 fn record_routing_contribution(&self, decision: &RoutingDecision, attempt: u32, applied: bool) {
1087 let Some(contribution) = self.routing_contribution(decision, attempt, applied) else {
1088 return;
1089 };
1090 let _ = record_llm_optimization_contribution(contribution);
1091 }
1092
1093 fn routing_contribution(
1094 &self,
1095 decision: &RoutingDecision,
1096 attempt: u32,
1097 applied: bool,
1098 ) -> Option<LlmOptimizationContribution> {
1099 let baseline = decision
1100 .baseline_route
1101 .as_ref()
1102 .filter(|baseline| self.validate_target(baseline).is_ok())?;
1103 let mut contribution = LlmOptimizationContribution::new(
1104 SWITCHYARD_PLUGIN_KIND,
1105 LlmOptimizationKind::model_routing(),
1106 );
1107 contribution.applied = applied;
1108 contribution.model_transition = Some(LlmOptimizationModelTransition {
1109 baseline: Some(LlmOptimizationModel::new(&baseline.target_model)),
1110 effective: Some(LlmOptimizationModel::new(&decision.route.target_model)),
1111 });
1112 contribution.payload_schema = Some(DataSchema {
1113 name: ROUTING_CONTRIBUTION_SCHEMA.to_string(),
1114 version: "1".to_string(),
1115 });
1116 contribution.payload = Some(json!({
1117 "decision_id": decision.decision_id,
1118 "selected_backend_id": decision.route.backend_id,
1119 "selected_tier": decision.route.tier,
1120 "baseline_backend_id": baseline.backend_id,
1121 "baseline_tier": baseline.tier,
1122 "routing_attempt": attempt,
1123 "rollout_mode": self.config.mode.label(),
1124 "reason_code": decision.reason_code,
1125 "reason_summary": decision.reason_summary,
1126 "router_metadata": decision.metadata,
1127 }));
1128 Some(contribution)
1129 }
1130
1131 fn apply_target(
1132 &self,
1133 inbound: WireProtocol,
1134 request: LlmRequest,
1135 decision: &RoutingDecision,
1136 ) -> Result<LlmRequest, String> {
1137 let binding = self
1138 .config
1139 .targets
1140 .get(&decision.route.backend_id)
1141 .ok_or_else(|| format!("unknown backend_id {:?}", decision.route.backend_id))?;
1142 let annotated = decode_request(&self.translation, inbound, &request)
1143 .map_err(|error| format!("request decode failed: {error}"))?;
1144 let mut routed = if inbound == binding.protocol {
1145 request
1146 } else {
1147 encode_request(
1148 &self.translation,
1149 binding.protocol,
1150 &annotated,
1151 request.headers,
1152 )
1153 .map_err(|error| format!("request translation failed: {error}"))?
1154 };
1155 let object = routed
1156 .content
1157 .as_object_mut()
1158 .ok_or_else(|| "translated request body is not an object".to_string())?;
1159 object.insert("model".into(), Json::String(binding.model.clone()));
1160 if let Some(headers) = self.target_headers.get(&decision.route.backend_id) {
1161 routed.headers.extend(headers.clone());
1162 }
1163 routed
1166 .headers
1167 .retain(|name, _| !name.eq_ignore_ascii_case(INTERNAL_DISPATCH_BACKEND_HEADER));
1168 routed.headers.insert(
1169 INTERNAL_DISPATCH_BACKEND_HEADER.into(),
1170 Json::String(decision.route.backend_id.clone()),
1171 );
1172 routed.headers.insert(
1173 INTERNAL_DISPATCH_ROUTE_HEADER.into(),
1174 Json::String(binding.protocol.label().into()),
1175 );
1176 routed.headers.insert(
1177 INTERNAL_DISPATCH_URL_HEADER.into(),
1178 Json::String(dispatch_url(&binding.base_url, &binding.endpoint)),
1179 );
1180 routed.headers.insert(
1181 INTERNAL_RETRY_AWARE_HEADER.into(),
1182 Json::String("true".into()),
1183 );
1184 Ok(routed)
1185 }
1186
1187 fn fallback_request(
1188 &self,
1189 inbound: WireProtocol,
1190 request: LlmRequest,
1191 ) -> Result<LlmRequest, String> {
1192 let id = self.config.default_targets.target(inbound);
1193 let binding = self
1194 .config
1195 .targets
1196 .get(id)
1197 .ok_or_else(|| format!("unknown fallback target {id:?}"))?;
1198 let decision = RoutingDecision {
1199 schema_version: ROUTING_DECISION_SCHEMA_VERSION.into(),
1200 decision_id: "relay-fallback".into(),
1201 router: crate::contract::DecisionProvider {
1202 name: "relay-fallback".into(),
1203 version: "1".into(),
1204 },
1205 route: crate::contract::RoutingTarget {
1206 tier: "fallback".into(),
1207 target_model: binding.model.clone(),
1208 backend_id: id.to_string(),
1209 target_protocol_profile: binding.protocol.label().into(),
1210 target_endpoint: binding.endpoint.clone(),
1211 },
1212 baseline_route: None,
1213 confidence: None,
1214 reason_code: Some("relay_trusted_fallback".into()),
1215 reason_summary: None,
1216 metadata: BTreeMap::new(),
1217 extra: BTreeMap::new(),
1218 };
1219 self.apply_target(inbound, request, &decision)
1220 }
1221
1222 async fn dispatch_fallback_buffered(
1223 &self,
1224 inbound: WireProtocol,
1225 original: LlmRequest,
1226 next: nemo_relay::api::runtime::LlmExecutionNextFn,
1227 reason: &str,
1228 ) -> FlowResult<Json> {
1229 self.emit_fallback(inbound, reason, &original);
1230 let metadata = identity_metadata_from_request(&original);
1231 let request = self
1232 .fallback_request(inbound, original)
1233 .map_err(FlowError::Internal)?;
1234 match next(request).await {
1235 Ok(response) => Ok(response),
1236 Err(error) => {
1237 emit_terminal_error(
1238 &error,
1239 "fallback_buffered",
1240 self.config.mode.label(),
1241 metadata,
1242 );
1243 Err(error)
1244 }
1245 }
1246 }
1247
1248 async fn dispatch_fallback_stream(
1249 &self,
1250 inbound: WireProtocol,
1251 original: LlmRequest,
1252 next: nemo_relay::api::runtime::LlmStreamExecutionNextFn,
1253 reason: &str,
1254 ) -> FlowResult<LlmJsonStream> {
1255 self.emit_fallback(inbound, reason, &original);
1256 let metadata = identity_metadata_from_request(&original);
1257 let request = self
1258 .fallback_request(inbound, original)
1259 .map_err(FlowError::Internal)?;
1260 match next(request).await {
1261 Ok(stream) => Ok(mark_terminal_stream(
1262 stream,
1263 "fallback_stream",
1264 self.config.mode.label(),
1265 metadata.clone(),
1266 )),
1267 Err(error) => {
1268 emit_terminal_error(
1269 &error,
1270 "fallback_stream_setup",
1271 self.config.mode.label(),
1272 metadata,
1273 );
1274 Err(error)
1275 }
1276 }
1277 }
1278
1279 fn emit_requested(&self, request: &RoutingRequest) {
1280 emit_mark(
1281 "switchyard.routing.requested",
1282 json!({
1283 "session_id": request.identity.session_id,
1284 "request_id": request.identity.request_id,
1285 "routing_attempt": request.attempt.routing_attempt,
1286 "profile_id": request.decision_profile.profile_id,
1287 "rollout_mode": self.config.mode.label(),
1288 }),
1289 identity_metadata(request),
1290 );
1291 }
1292
1293 fn emit_decision(
1294 &self,
1295 request: &RoutingRequest,
1296 decision: &RoutingDecision,
1297 attempt: u32,
1298 observe_only: bool,
1299 latency_ms: u64,
1300 ) {
1301 emit_mark(
1302 "switchyard.routing.decision",
1303 json!({
1304 "decision_id": decision.decision_id,
1305 "profile_id": request.decision_profile.profile_id,
1306 "router": decision.router.name,
1307 "router_version": decision.router.version,
1308 "routing_attempt": attempt,
1309 "backend_id": decision.route.backend_id,
1310 "selected_tier": decision.route.tier,
1311 "selected_model": decision.route.target_model,
1312 "target_protocol_profile": decision.route.target_protocol_profile,
1313 "target_endpoint": decision.route.target_endpoint,
1314 "confidence": decision.confidence,
1315 "reason_code": decision.reason_code,
1316 "reason_summary": decision.reason_summary,
1317 "router_metadata": decision.metadata,
1318 "latency_ms": latency_ms,
1319 "observe_only": observe_only,
1320 "rollout_mode": self.config.mode.label(),
1321 }),
1322 identity_metadata(request),
1323 );
1324 }
1325
1326 fn emit_retry(
1327 &self,
1328 request: &RoutingRequest,
1329 decision: &RoutingDecision,
1330 attempt: u32,
1331 reason: &str,
1332 ) {
1333 emit_mark(
1334 "switchyard.routing.retry",
1335 json!({"routing_attempt": attempt, "previous_route": decision.route.backend_id, "retry_reason": reason, "rollout_mode": self.config.mode.label()}),
1336 identity_metadata(request),
1337 );
1338 }
1339
1340 fn emit_error(&self, request: Option<&RoutingRequest>, attempt: u32, class: &str, error: &str) {
1341 emit_mark(
1342 "switchyard.routing.error",
1343 json!({"routing_attempt": attempt, "error_class": class, "error": error, "rollout_mode": self.config.mode.label()}),
1344 request.map(identity_metadata).unwrap_or_else(|| json!({})),
1345 );
1346 }
1347
1348 fn emit_fallback(&self, inbound: WireProtocol, reason: &str, request: &LlmRequest) {
1349 emit_mark(
1350 "switchyard.routing.fallback",
1351 json!({
1352 "fallback_reason": reason,
1353 "fallback_route": self.config.default_targets.target(inbound),
1354 "inbound_profile": inbound.label(),
1355 "rollout_mode": self.config.mode.label(),
1356 }),
1357 identity_metadata_from_request(request),
1358 );
1359 }
1360}
1361
1362async fn check_switchyard_health(
1363 client: &reqwest::Client,
1364 health_url: &reqwest::Url,
1365) -> Result<(), String> {
1366 let response = client
1367 .get(health_url.clone())
1368 .send()
1369 .await
1370 .map_err(|error| {
1371 format!("Switchyard service is required but health check {health_url} failed: {error}")
1372 })?;
1373 let status = response.status();
1374 if !status.is_success() {
1375 return Err(format!(
1376 "Switchyard service is required but health check {health_url} returned HTTP {status}"
1377 ));
1378 }
1379 let body = response.json::<Json>().await.map_err(|error| {
1380 format!("Switchyard health check {health_url} returned invalid JSON: {error}")
1381 })?;
1382 if body.get("status").and_then(Json::as_str) != Some("ok") {
1383 return Err(format!(
1384 "Switchyard health check {health_url} did not report status=ok"
1385 ));
1386 }
1387 Ok(())
1388}
1389
1390fn switchyard_health_url(decision_api_url: &str) -> Result<reqwest::Url, String> {
1391 let mut url = reqwest::Url::parse(decision_api_url)
1392 .map_err(|error| format!("decision_api_url is invalid: {error}"))?;
1393 url.set_path(SWITCHYARD_HEALTH_PATH);
1394 url.set_query(None);
1395 url.set_fragment(None);
1396 Ok(url)
1397}
1398
1399fn validate_atof_endpoint_name(name: Option<&str>) -> Result<Option<&str>, String> {
1400 if let Some(name) = name {
1401 if name.trim().is_empty() {
1402 return Err("atof_endpoint_name must be non-empty when configured".into());
1403 }
1404 if name != name.trim() {
1405 return Err("atof_endpoint_name must not have leading or trailing whitespace".into());
1406 }
1407 }
1408 Ok(name)
1409}
1410
1411fn validate_config(config: &SwitchyardConfig) -> Result<(), String> {
1412 validate_scalar_config(config)?;
1413 validate_decision_api_url(&config.decision_api_url)?;
1414 validate_target_bindings(config)?;
1415 validate_default_targets(config)
1416}
1417
1418fn validate_scalar_config(config: &SwitchyardConfig) -> Result<(), String> {
1419 if config.version != 1 {
1420 return Err(format!(
1421 "unsupported Switchyard config version {}",
1422 config.version
1423 ));
1424 }
1425 if config.decision_profile_id.trim().is_empty() {
1426 return Err("decision_profile_id must be non-empty".into());
1427 }
1428 if config.decision_timeout_millis == 0 {
1429 return Err("decision_timeout_millis must be greater than zero".into());
1430 }
1431 if config.max_retries > 10 {
1432 return Err("max_retries must not exceed 10".into());
1433 }
1434 if config.recent_message_count == 0 {
1435 return Err("recent_message_count must be greater than zero".into());
1436 }
1437 let atof_endpoint_name = validate_atof_endpoint_name(config.atof_endpoint_name.as_deref())?;
1438 if config.context_mode == ContextMode::AtofRequired && atof_endpoint_name.is_none() {
1439 return Err("atof_required Switchyard profiles require atof_endpoint_name".into());
1440 }
1441 Ok(())
1442}
1443
1444fn validate_decision_api_url(decision_api_url: &str) -> Result<(), String> {
1445 let url = reqwest::Url::parse(decision_api_url)
1446 .map_err(|error| format!("decision_api_url is invalid: {error}"))?;
1447 if !matches!(url.scheme(), "http" | "https") {
1448 return Err("decision_api_url must use http or https".into());
1449 }
1450 Ok(())
1451}
1452
1453fn validate_target_bindings(config: &SwitchyardConfig) -> Result<(), String> {
1454 if config.targets.is_empty() {
1455 return Err("targets must not be empty".into());
1456 }
1457 if config.enabled_inbound_profiles.is_empty() {
1458 return Err("enabled_inbound_profiles must not be empty".into());
1459 }
1460 let mut exact_bindings = BTreeSet::new();
1461 for (id, target) in &config.targets {
1462 if id.trim().is_empty()
1463 || target.model.trim().is_empty()
1464 || target.endpoint.trim().is_empty()
1465 {
1466 return Err("target IDs, models, and endpoints must be non-empty".into());
1467 }
1468 let base_url = reqwest::Url::parse(&target.base_url)
1469 .map_err(|error| format!("target {id:?} base_url is invalid: {error}"))?;
1470 if !matches!(base_url.scheme(), "http" | "https") {
1471 return Err(format!("target {id:?} base_url must use http or https"));
1472 }
1473 if target.endpoint != target.protocol.endpoint() {
1474 return Err(format!(
1475 "target {id:?} endpoint must be {:?} for {}",
1476 target.protocol.endpoint(),
1477 target.protocol.label()
1478 ));
1479 }
1480 if !exact_bindings.insert((
1481 target.model.clone(),
1482 target.protocol,
1483 target.endpoint.clone(),
1484 target.base_url.trim_end_matches('/').to_string(),
1485 )) {
1486 return Err(format!(
1487 "target {id:?} conflicts with another exact backend binding"
1488 ));
1489 }
1490 }
1491 Ok(())
1492}
1493
1494fn validate_default_targets(config: &SwitchyardConfig) -> Result<(), String> {
1495 for &protocol in &config.enabled_inbound_profiles {
1496 let id = config.default_targets.target(protocol);
1497 let target = config
1498 .targets
1499 .get(id)
1500 .ok_or_else(|| format!("default target {id:?} is not configured"))?;
1501 if target.protocol != protocol {
1502 return Err(format!(
1503 "default target {id:?} must use protocol {}",
1504 protocol.label()
1505 ));
1506 }
1507 }
1508 Ok(())
1509}
1510
1511fn resolve_headers(
1512 static_headers: &BTreeMap<String, String>,
1513 environment_headers: &BTreeMap<String, String>,
1514) -> Result<HeaderMap, String> {
1515 let mut headers = HeaderMap::new();
1516 for (name, value) in static_headers {
1517 insert_http_header(&mut headers, name, value)?;
1518 }
1519 for (name, variable) in environment_headers {
1520 if static_headers
1521 .keys()
1522 .any(|configured| configured.eq_ignore_ascii_case(name))
1523 {
1524 return Err(format!(
1525 "header {name:?} cannot appear in both headers and header_env"
1526 ));
1527 }
1528 let value = std::env::var(variable)
1529 .map_err(|_| format!("environment variable {variable:?} is not set"))?;
1530 if value.trim().is_empty() {
1531 return Err(format!("environment variable {variable:?} is blank"));
1532 }
1533 insert_http_header(&mut headers, name, &value)?;
1534 }
1535 Ok(headers)
1536}
1537
1538fn resolve_json_headers(
1539 static_headers: &BTreeMap<String, String>,
1540 environment_headers: &BTreeMap<String, String>,
1541) -> Result<Map<String, Json>, String> {
1542 let mut headers = Map::new();
1543 for (name, value) in static_headers {
1544 headers.insert(name.clone(), Json::String(value.clone()));
1545 }
1546 for (name, variable) in environment_headers {
1547 if static_headers
1548 .keys()
1549 .any(|configured| configured.eq_ignore_ascii_case(name))
1550 {
1551 return Err(format!(
1552 "target header {name:?} cannot appear in both headers and header_env"
1553 ));
1554 }
1555 let value = std::env::var(variable)
1556 .map_err(|_| format!("environment variable {variable:?} is not set"))?;
1557 if value.trim().is_empty() {
1558 return Err(format!("environment variable {variable:?} is blank"));
1559 }
1560 headers.insert(name.clone(), Json::String(value));
1561 }
1562 Ok(headers)
1563}
1564
1565fn insert_http_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), String> {
1566 let name = HeaderName::from_bytes(name.as_bytes())
1567 .map_err(|error| format!("invalid header name: {error}"))?;
1568 let value =
1569 HeaderValue::from_str(value).map_err(|error| format!("invalid header value: {error}"))?;
1570 headers.insert(name, value);
1571 Ok(())
1572}
1573
1574fn protocol_from_label(label: &str) -> FlowResult<WireProtocol> {
1575 match label {
1576 "openai_chat" | "openai_chat_completions" | "openai_chat_completions.v1" => {
1577 Ok(WireProtocol::OpenaiChat)
1578 }
1579 "openai_responses" | "openai_responses.v1" => Ok(WireProtocol::OpenaiResponses),
1580 "anthropic_messages" | "anthropic_messages.v1" => Ok(WireProtocol::AnthropicMessages),
1581 value => Err(FlowError::InvalidArgument(format!(
1582 "unsupported Switchyard target protocol {value:?}"
1583 ))),
1584 }
1585}
1586
1587fn header(request: &LlmRequest, name: &str) -> Option<String> {
1588 request
1589 .headers
1590 .get(name)
1591 .and_then(Json::as_str)
1592 .map(str::trim)
1593 .filter(|value| !value.is_empty())
1594 .map(ToOwned::to_owned)
1595}
1596
1597fn dispatch_url(base_url: &str, endpoint: &str) -> String {
1598 let base = base_url.trim_end_matches('/');
1599 let endpoint = if base.ends_with("/v1") && endpoint.starts_with("/v1/") {
1600 &endpoint[3..]
1601 } else {
1602 endpoint
1603 };
1604 format!("{base}{endpoint}")
1605}
1606
1607fn identity_metadata(request: &RoutingRequest) -> Json {
1608 json!({
1609 "session_id": request.identity.session_id,
1610 "request_id": request.identity.request_id,
1611 "turn_id": request.identity.turn_id,
1612 "owner_id": request.identity.owner_id,
1613 })
1614}
1615
1616fn identity_metadata_from_request(request: &LlmRequest) -> Json {
1617 json!({
1618 "session_id": header(request, "x-nemo-relay-session-id"),
1619 "request_id": header(request, "x-nemo-relay-request-id"),
1620 "turn_id": header(request, "x-nemo-relay-turn-id"),
1621 "owner_id": header(request, "x-nemo-relay-owner-id"),
1622 })
1623}
1624
1625fn error_is_retryable(error: &FlowError) -> bool {
1626 matches!(error, FlowError::Upstream(failure) if failure.is_retryable())
1627}
1628
1629fn emit_mark(name: &str, data: Json, metadata: Json) {
1630 if let Err(error) = event(
1631 EmitMarkEventParams::builder()
1632 .name(name)
1633 .data(data)
1634 .data_schema(
1635 DataSchema::builder()
1636 .name(ROUTING_MARK_SCHEMA)
1637 .version("1")
1638 .build(),
1639 )
1640 .metadata(metadata)
1641 .category(EventCategory::custom())
1642 .category_profile(CategoryProfile::builder().subtype(name).build())
1643 .build(),
1644 ) {
1645 eprintln!("nemo-relay switchyard: failed to emit {name}: {error}");
1646 }
1647}
1648
1649fn emit_terminal_error(error: &FlowError, phase: &str, rollout_mode: &str, metadata: Json) {
1650 emit_mark(
1651 "switchyard.routing.terminal_error",
1652 json!({"error_class": provider_error_class(error), "error": provider_error_summary(error), "phase": phase, "rollout_mode": rollout_mode}),
1653 metadata,
1654 );
1655}
1656
1657fn provider_error_class(error: &FlowError) -> &'static str {
1658 match error {
1659 FlowError::Upstream(failure) => match failure.class {
1660 nemo_relay::error::UpstreamFailureClass::Connection => "connection",
1661 nemo_relay::error::UpstreamFailureClass::Timeout => "timeout",
1662 nemo_relay::error::UpstreamFailureClass::RetryableStatus => "retryable_status",
1663 nemo_relay::error::UpstreamFailureClass::ContextWindow => "context_window",
1664 nemo_relay::error::UpstreamFailureClass::ModelUnavailable => "model_unavailable",
1665 nemo_relay::error::UpstreamFailureClass::Authentication => "authentication",
1666 nemo_relay::error::UpstreamFailureClass::InvalidRequest => "invalid_request",
1667 nemo_relay::error::UpstreamFailureClass::Other => "other",
1668 },
1669 _ => "relay",
1670 }
1671}
1672
1673fn provider_error_summary(error: &FlowError) -> String {
1674 match error {
1675 FlowError::Upstream(failure) => match failure.status {
1676 Some(status) => format!("{}:http_{status}", provider_error_class(error)),
1677 None => provider_error_class(error).to_string(),
1678 },
1679 _ => error.to_string(),
1680 }
1681}
1682
1683struct PrefixedStream {
1684 first: Option<FlowResult<Json>>,
1685 upstream: LlmJsonStream,
1686}
1687
1688impl futures_util::Stream for PrefixedStream {
1689 type Item = FlowResult<Json>;
1690
1691 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1692 if let Some(first) = self.first.take() {
1693 Poll::Ready(Some(first))
1694 } else {
1695 Pin::new(&mut self.upstream).poll_next(cx)
1696 }
1697 }
1698}
1699
1700impl LlmStreamInner for PrefixedStream {
1701 fn close(self: Pin<&mut Self>) -> Pin<Box<dyn Future<Output = FlowResult<()>> + Send + '_>> {
1702 let this = self.get_mut();
1703 this.first = None;
1704 Box::pin(async move { this.upstream.close().await })
1705 }
1706}
1707
1708struct TerminalMarkedStream {
1709 upstream: LlmJsonStream,
1710 phase: &'static str,
1711 rollout_mode: &'static str,
1712 metadata: Json,
1713 finished: bool,
1714}
1715
1716impl futures_util::Stream for TerminalMarkedStream {
1717 type Item = FlowResult<Json>;
1718
1719 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1720 if self.finished {
1721 return Poll::Ready(None);
1722 }
1723 match Pin::new(&mut self.upstream).poll_next(cx) {
1724 Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(chunk))),
1725 Poll::Ready(Some(Err(error))) => {
1726 self.finished = true;
1727 emit_terminal_error(&error, self.phase, self.rollout_mode, self.metadata.clone());
1728 Poll::Ready(Some(Err(error)))
1729 }
1730 Poll::Ready(None) => {
1731 self.finished = true;
1732 Poll::Ready(None)
1733 }
1734 Poll::Pending => Poll::Pending,
1735 }
1736 }
1737}
1738
1739impl LlmStreamInner for TerminalMarkedStream {
1740 fn close(self: Pin<&mut Self>) -> Pin<Box<dyn Future<Output = FlowResult<()>> + Send + '_>> {
1741 Box::pin(async move { self.get_mut().upstream.close().await })
1742 }
1743}
1744
1745struct TranslatedStream {
1746 upstream: LlmJsonStream,
1747 transcoder: StreamTranscoder,
1748 buffered: VecDeque<FlowResult<Json>>,
1749 upstream_finished: bool,
1750}
1751
1752impl futures_util::Stream for TranslatedStream {
1753 type Item = FlowResult<Json>;
1754
1755 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1756 loop {
1757 if let Some(chunk) = self.buffered.pop_front() {
1758 return Poll::Ready(Some(chunk));
1759 }
1760 if self.upstream_finished {
1761 return Poll::Ready(None);
1762 }
1763
1764 match Pin::new(&mut self.upstream).poll_next(cx) {
1765 Poll::Ready(Some(Ok(chunk))) => match self.transcoder.transcode(&chunk) {
1766 Ok(chunks) => self.buffered.extend(chunks.into_iter().map(Ok)),
1767 Err(error) => {
1768 self.upstream_finished = true;
1769 return Poll::Ready(Some(Err(error)));
1770 }
1771 },
1772 Poll::Ready(Some(Err(error))) => {
1773 self.upstream_finished = true;
1774 return Poll::Ready(Some(Err(error)));
1775 }
1776 Poll::Ready(None) => {
1777 self.upstream_finished = true;
1778 match self.transcoder.finish() {
1779 Ok(chunks) => self.buffered.extend(chunks.into_iter().map(Ok)),
1780 Err(error) => return Poll::Ready(Some(Err(error))),
1781 }
1782 }
1783 Poll::Pending => return Poll::Pending,
1784 }
1785 }
1786 }
1787}
1788
1789impl LlmStreamInner for TranslatedStream {
1790 fn close(self: Pin<&mut Self>) -> Pin<Box<dyn Future<Output = FlowResult<()>> + Send + '_>> {
1791 let this = self.get_mut();
1792 this.buffered.clear();
1793 this.upstream_finished = true;
1794 Box::pin(async move { this.upstream.close().await })
1795 }
1796}
1797
1798fn mark_terminal_stream(
1799 upstream: LlmJsonStream,
1800 phase: &'static str,
1801 rollout_mode: &'static str,
1802 metadata: Json,
1803) -> LlmJsonStream {
1804 LlmJsonStream::from_closeable(TerminalMarkedStream {
1805 upstream,
1806 phase,
1807 rollout_mode,
1808 metadata,
1809 finished: false,
1810 })
1811}
1812
1813fn translated_stream(
1814 source: WireProtocol,
1815 target: WireProtocol,
1816 effective_model: String,
1817 upstream: LlmJsonStream,
1818) -> LlmJsonStream {
1819 LlmJsonStream::from_closeable(TranslatedStream {
1820 upstream,
1821 transcoder: StreamTranscoder::new(source, target, effective_model),
1822 buffered: VecDeque::new(),
1823 upstream_finished: false,
1824 })
1825}
1826
1827#[cfg(test)]
1828#[path = "../tests/unit/component_tests.rs"]
1829mod tests;