Skip to main content

switchyard_runner/
algorithm_factory.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Algorithm construction from deployment TOML without provider clients.
5
6use std::collections::BTreeMap;
7use std::sync::Arc;
8
9use libsy::{Algorithm, RuntimeModels, State, StepStream};
10use parking_lot::Mutex;
11use switchyard_protocol::{LlmClientError, ModelId, Request};
12
13use crate::config::{DeploymentConfig, MAX_ELIGIBLE_LANES, TargetBindings};
14use crate::eligibility::{EligibilityPolicy, has_target_requirements};
15use crate::{RunnerError, TargetModel};
16
17pub(crate) type AlgorithmAndModels = (Arc<dyn Algorithm>, RuntimeModels);
18
19/// Parsed deployment configuration for building algorithms with host-owned execution.
20pub struct AlgorithmFactory {
21    pub(crate) config: Arc<DeploymentConfig>,
22}
23
24impl AlgorithmFactory {
25    /// Parses deployment TOML without resolving credentials or constructing provider clients.
26    /// Invalid TOML returns a configuration error; routing settings are checked when building.
27    pub fn from_toml(source: &str) -> Result<Self, RunnerError> {
28        let mut config: DeploymentConfig = toml::from_str(source).map_err(|error| {
29            RunnerError::configuration_source(format!("failed to parse TOML: {error}"), error)
30        })?;
31        config.infer_decision_judges();
32        Ok(Self {
33            config: Arc::new(config),
34        })
35    }
36
37    /// Builds algorithms and their model groups, keyed by configured route `id`.
38    ///
39    /// LLM calls and outcomes use target names; the host resolves their provider IDs and
40    /// owns target prompts, provider settings and transport. Decision calls use provider IDs.
41    /// Reuse the returned algorithms across requests to retain algorithm-owned state.
42    pub fn build(&self) -> Result<BTreeMap<String, AlgorithmAndModels>, RunnerError> {
43        self.config.validate()?;
44        if self.config.requires_target_admission() {
45            return Err(RunnerError::configuration(
46                "target eligibility requires Runner admission; use Runner instead of AlgorithmFactory",
47            ));
48        }
49        self.config
50            .build_algorithms()
51            .map(|entry| {
52                let setup = entry?;
53                Ok((setup.config.id.to_string(), (setup.algorithm, setup.models)))
54            })
55            .collect()
56    }
57
58    /// Builds reusable algorithms keyed by route ID that check target requirements.
59    /// Each Decision model ID must identify one client across a route's alternatives.
60    /// The host handles model/Decision calls, approved answer/fallback targets, prompts,
61    /// provider settings, credentials and transport. No provider clients are built or credentials read.
62    pub fn build_configured(&self) -> Result<BTreeMap<String, ConfiguredAlgorithm>, RunnerError> {
63        self.config.validate()?;
64        self.config.validate_target_approvals()?;
65        self.config
66            .build_algorithms()
67            .map(|entry| {
68                let mut setup = entry?;
69                self.config.validate_decision_models(setup.route_name)?;
70                let policy = self.config.eligibility_policy(setup.route_name);
71                if policy.is_some() {
72                    setup.models = setup.models.with_target_restriction();
73                }
74                Ok((
75                    setup.config.id.to_string(),
76                    ConfiguredAlgorithm {
77                        target_models: self.config.route_target_models(setup.route_name)?,
78                        standard: AlgorithmSetup {
79                            algorithm: setup.algorithm,
80                            models: Arc::new(setup.models),
81                        },
82                        eligibility: policy.map(|policy| ConfiguredEligibility {
83                            policy,
84                            deployment: Arc::clone(&self.config),
85                            route_name: setup.route_name.to_owned(),
86                            setups: Mutex::default(),
87                        }),
88                    },
89                ))
90            })
91            .collect()
92    }
93}
94
95#[derive(Clone)]
96struct AlgorithmSetup {
97    algorithm: Arc<dyn Algorithm>,
98    models: Arc<RuntimeModels>,
99}
100
101struct ConfiguredEligibility {
102    policy: EligibilityPolicy,
103    deployment: Arc<DeploymentConfig>,
104    route_name: String,
105    setups: Mutex<BTreeMap<TargetBindings, AlgorithmSetup>>,
106}
107
108impl ConfiguredEligibility {
109    fn select(
110        &self,
111        request: &Request,
112        standard: &AlgorithmSetup,
113    ) -> Result<AlgorithmSetup, RunnerError> {
114        let requirements = self.policy.admit(request)?;
115        let Some(bindings) = self
116            .deployment
117            .select_eligible_targets(&self.route_name, requirements.required_labels())?
118        else {
119            return Ok(standard.clone());
120        };
121        // No model I/O under this lock.
122        let mut setups = self.setups.lock();
123        if let Some(setup) = setups.get(&bindings) {
124            return Ok(setup.clone());
125        }
126        if setups.len() >= MAX_ELIGIBLE_LANES {
127            return Err(LlmClientError::TemporarilyUnavailable.into());
128        }
129        let (algorithm, models) = self
130            .deployment
131            .build_bound_algorithm(&self.route_name, &bindings)?;
132        let setup = AlgorithmSetup {
133            algorithm,
134            models: Arc::new(models),
135        };
136        setups.insert(bindings, setup.clone());
137        Ok(setup)
138    }
139}
140
141/// A reusable algorithm with target requirement checks.
142pub struct ConfiguredAlgorithm {
143    standard: AlgorithmSetup,
144    eligibility: Option<ConfiguredEligibility>,
145    target_models: BTreeMap<ModelId, TargetModel>,
146}
147
148impl ConfiguredAlgorithm {
149    /// All possible call targets, including judges, subagents and ordered alternatives.
150    /// Keys are LLM target names or Decision provider model IDs, matching emitted calls.
151    /// This is a lookup, not an eligible pool; execute only returned call/outcome candidates.
152    pub fn target_models(&self) -> &BTreeMap<ModelId, TargetModel> {
153        &self.target_models
154    }
155
156    /// Checks requirements before yielding model or Decision calls.
157    /// Invalid requirements or unavailable approved targets fail before the algorithm starts.
158    /// Requires Tokio. Reuse the handle to retain task requirements and algorithm-owned state;
159    /// `state` is per-run routing state. Answer/fallback attempts must stay within returned candidates.
160    pub fn run_stream(&self, request: Request, state: State) -> Result<StepStream, RunnerError> {
161        let setup = match &self.eligibility {
162            Some(eligibility) => eligibility.select(&request, &self.standard)?,
163            None if has_target_requirements(&request) => {
164                return Err(LlmClientError::InvalidRequest {
165                    message: "route does not accept target requirements".into(),
166                }
167                .into());
168            }
169            None => self.standard.clone(),
170        };
171        Ok(setup.algorithm.run_stream(request, setup.models, state))
172    }
173}