Skip to main content

nemo_relay_switchyard/
component.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Switchyard plugin configuration and Relay execution integration.
5
6use 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
47/// Plugin kind used in Relay plugin configuration.
48pub 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/// Supported provider wire protocols.
62#[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    /// OpenAI Chat Completions.
67    OpenaiChat,
68    /// OpenAI Responses.
69    OpenaiResponses,
70    /// Anthropic Messages.
71    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/// Routing rollout mode.
109#[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    /// Apply Switchyard decisions.
114    #[default]
115    Enforce,
116    /// Record decisions but dispatch trusted defaults.
117    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/// Whether the selected Switchyard profile depends on ATOF-derived history.
130#[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    /// The router uses only current request material.
135    PayloadOnly,
136    /// Stable identity and a configured ATOF endpoint are required.
137    AtofRequired,
138}
139
140/// Exact Relay-owned backend binding for one Switchyard backend ID.
141#[derive(Clone, Debug, Deserialize, Serialize)]
142#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
143pub struct TargetBinding {
144    /// Exact model expected in the Switchyard decision.
145    pub model: String,
146    /// Exact protocol expected in the Switchyard decision.
147    pub protocol: WireProtocol,
148    /// Exact endpoint expected in the Switchyard decision.
149    pub endpoint: String,
150    /// Relay-owned backend base URL.
151    pub base_url: String,
152    /// Static non-sensitive backend headers.
153    #[serde(default)]
154    pub headers: BTreeMap<String, String>,
155    /// Backend headers resolved from environment variables.
156    #[serde(default)]
157    pub header_env: BTreeMap<String, String>,
158}
159
160/// Trusted fallback target IDs for each inbound protocol.
161#[derive(Clone, Debug, Deserialize, Serialize)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163pub struct ProtocolDefaults {
164    /// OpenAI Chat fallback target.
165    #[serde(default)]
166    pub openai_chat: String,
167    /// OpenAI Responses fallback target.
168    #[serde(default)]
169    pub openai_responses: String,
170    /// Anthropic Messages fallback target.
171    #[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/// Versioned Switchyard plugin configuration.
186#[derive(Clone, Debug, Deserialize, Serialize)]
187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
188pub struct SwitchyardConfig {
189    /// Config schema version.
190    #[serde(default = "default_version")]
191    pub version: u32,
192    /// Enforce or observe-only rollout mode.
193    #[serde(default)]
194    pub mode: RoutingMode,
195    /// Execution-intercept priority.
196    #[serde(default)]
197    pub priority: i32,
198    /// Switchyard Decision API URL.
199    pub decision_api_url: String,
200    /// Switchyard profile ID.
201    pub decision_profile_id: String,
202    /// Current-request materialization.
203    pub request_materialization: RequestMaterialization,
204    /// Profile context requirement.
205    pub context_mode: ContextMode,
206    /// Decision call timeout.
207    #[serde(default = "default_decision_timeout_millis")]
208    pub decision_timeout_millis: u64,
209    /// Provider retries after the initial attempt.
210    #[serde(default = "default_max_retries")]
211    pub max_retries: u32,
212    /// Number of messages in recent-message materialization.
213    #[serde(default = "default_recent_message_count")]
214    pub recent_message_count: usize,
215    /// Static non-sensitive Decision API headers.
216    #[serde(default)]
217    pub decision_headers: BTreeMap<String, String>,
218    /// Decision API headers resolved from environment variables.
219    #[serde(default)]
220    pub decision_header_env: BTreeMap<String, String>,
221    /// Enabled inbound protocols.
222    #[serde(default = "default_enabled_protocols")]
223    pub enabled_inbound_profiles: BTreeSet<WireProtocol>,
224    /// Exact backend bindings keyed by Switchyard backend ID.
225    pub targets: BTreeMap<String, TargetBinding>,
226    /// Trusted per-protocol fallbacks.
227    pub default_targets: ProtocolDefaults,
228    /// Named observability ATOF endpoint used by history-backed profiles.
229    #[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
387/// Register the first-party Switchyard component kind.
388pub 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
398/// Deregister the first-party Switchyard component kind.
399pub fn deregister_switchyard_component() -> bool {
400    deregister_plugin(SWITCHYARD_PLUGIN_KIND)
401}
402
403/// Validate the cross-component ATOF requirement for enabled history-backed profiles.
404pub 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    // Skip the portability guard when no configured target uses a different protocol: with no
549    // possible cross-protocol translation, provider-specific fields never need to be portable.
550    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        // The backend ID is Relay-owned cache-partition metadata. Remove source or
1164        // target-header case variants before publishing the canonical value.
1165        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;