1use std::error::Error;
7use std::sync::Arc;
8
9use libsy::{Algorithm, LibsyError, RoutingOutcome, RuntimeModels};
10use serde_json::Value;
11use switchyard_llm_client::{AuxiliaryOperation, ClientRouter, RunObserver, TranslatingLlmClient};
12use switchyard_protocol::{
13 LlmClientError, ModelId, Request, Response, RoutedLlmClient, WireFormat,
14};
15use thiserror::Error;
16
17use crate::DecisionTarget;
18
19#[derive(Clone, Copy, Default)]
26pub struct ModelCapabilities {
27 pub context_window: Option<u32>,
28 pub tool_calling: Option<bool>,
29 pub reasoning: Option<bool>,
31 pub vision: Option<bool>,
33}
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum CallerAuthKind {
38 Anthropic,
39 OpenAi,
40}
41
42impl CallerAuthKind {
43 pub const fn as_str(self) -> &'static str {
45 match self {
46 Self::Anthropic => "anthropic",
47 Self::OpenAi => "openai",
48 }
49 }
50
51 const fn accepts(self, wire_format: WireFormat) -> bool {
52 matches!(
53 (self, wire_format),
54 (Self::Anthropic, WireFormat::AnthropicMessages)
55 | (
56 Self::OpenAi,
57 WireFormat::OpenAiChat | WireFormat::OpenAiResponses
58 )
59 )
60 }
61}
62
63#[derive(Clone)]
65pub struct AuxiliaryTarget {
66 pub model: ModelId,
67 pub client: Arc<TranslatingLlmClient>,
68}
69
70#[derive(Debug, Error)]
72pub enum RunnerError {
73 #[error("{message}")]
74 Configuration {
75 message: String,
76 #[source]
77 source: Option<Box<dyn Error + Send + Sync>>,
78 },
79 #[error("unknown route model {0:?}")]
80 UnknownRouteModel(String),
81 #[error("caller format is incompatible with {} credentials", .0.as_str())]
82 IncompatibleCallerFormat(CallerAuthKind),
83 #[error("route has no compatible target for the auxiliary operation")]
84 AuxiliaryUnsupported,
85 #[error(transparent)]
86 Algorithm(#[from] LibsyError),
87 #[error(transparent)]
88 Client(#[from] LlmClientError),
89}
90
91impl RunnerError {
92 pub(crate) fn configuration(message: impl Into<String>) -> Self {
93 Self::Configuration {
94 message: message.into(),
95 source: None,
96 }
97 }
98
99 pub(crate) fn configuration_source(
100 message: impl Into<String>,
101 source: impl Error + Send + Sync + 'static,
102 ) -> Self {
103 Self::Configuration {
104 message: message.into(),
105 source: Some(Box::new(source)),
106 }
107 }
108}
109
110pub struct Route {
112 algorithm: Arc<dyn Algorithm>,
113 clients: ClientRouter,
117 caller_auth: Option<CallerAuthKind>,
118 capabilities: ModelCapabilities,
119 anthropic_auxiliary_target: Option<AuxiliaryTarget>,
120 responses_auxiliary_target: Option<AuxiliaryTarget>,
121 decision_targets: Vec<DecisionTarget>,
122 models: Arc<RuntimeModels>,
123 infra: Option<libsy::InfraPolicy>,
124}
125
126pub struct RunOutput {
128 pub selected_model: ModelId,
129 pub response: Response,
130}
131
132impl Route {
133 #[allow(clippy::too_many_arguments)]
135 pub fn new(
136 algorithm: Arc<dyn Algorithm>,
137 clients: ClientRouter,
138 caller_auth: Option<CallerAuthKind>,
139 capabilities: ModelCapabilities,
140 anthropic_auxiliary_target: Option<AuxiliaryTarget>,
141 responses_auxiliary_target: Option<AuxiliaryTarget>,
142 decision_targets: Vec<DecisionTarget>,
143 models: RuntimeModels,
144 ) -> Self {
145 Self {
146 algorithm,
147 clients,
148 caller_auth,
149 capabilities,
150 anthropic_auxiliary_target,
151 responses_auxiliary_target,
152 decision_targets,
153 models: Arc::new(models),
154 infra: None,
155 }
156 }
157
158 pub fn algorithm_name(&self) -> &str {
160 self.algorithm.name()
161 }
162
163 pub fn capabilities(&self) -> ModelCapabilities {
165 self.capabilities
166 }
167
168 pub fn caller_auth(&self) -> Option<CallerAuthKind> {
170 self.caller_auth
171 }
172
173 pub(crate) fn decision_target(&self, model: &ModelId) -> Option<DecisionTarget> {
175 self.decision_targets
176 .iter()
177 .find(|target| target.model == *model)
178 .cloned()
179 }
180
181 pub fn models(&self) -> &RuntimeModels {
183 &self.models
184 }
185
186 pub fn check_caller_format(&self, input_format: WireFormat) -> Result<(), RunnerError> {
188 if let Some(kind) = self.caller_auth
189 && !kind.accepts(input_format)
190 {
191 return Err(RunnerError::IncompatibleCallerFormat(kind));
192 }
193 Ok(())
194 }
195
196 pub async fn execute(
198 &self,
199 request: Request,
200 observer: Option<RunObserver>,
201 ) -> Result<RunOutput, RunnerError> {
202 let (selected_model, response) = switchyard_llm_client::run(
203 Arc::clone(&self.algorithm),
204 self.clients.clone(),
205 request,
206 Arc::clone(&self.models),
207 observer,
208 )
209 .await?;
210 Ok(RunOutput {
211 selected_model,
212 response,
213 })
214 }
215
216 pub async fn decide(&self, request: Request) -> Result<RoutingOutcome, RunnerError> {
218 switchyard_llm_client::decide(
219 Arc::clone(&self.algorithm),
220 self.clients.clone(),
221 request,
222 Arc::clone(&self.models),
223 )
224 .await
225 .map_err(Into::into)
226 }
227
228 pub(crate) fn with_infra(mut self, infra: Option<libsy::InfraPolicy>) -> Self {
229 self.infra = infra;
230 self
231 }
232
233 pub fn apply_infra(
238 &self,
239 outcome: &mut RoutingOutcome,
240 signals: &switchyard_protocol::InfraSignals,
241 ) -> Result<bool, RunnerError> {
242 match &self.infra {
243 Some(policy) => policy
244 .apply(outcome, &self.models, signals)
245 .map_err(Into::into),
246 None => Ok(false),
247 }
248 }
249
250 pub async fn decide_with_client(
257 &self,
258 request: Request,
259 client: Arc<dyn RoutedLlmClient>,
260 ) -> Result<RoutingOutcome, RunnerError> {
261 switchyard_llm_client::decide(
262 Arc::clone(&self.algorithm),
263 ClientRouter::single(client),
264 request,
265 Arc::clone(&self.models),
266 )
267 .await
268 .map_err(Into::into)
269 }
270
271 pub async fn call_auxiliary(
273 &self,
274 request: Request,
275 operation: AuxiliaryOperation,
276 ) -> Result<Value, RunnerError> {
277 let target = match operation {
278 AuxiliaryOperation::AnthropicCountTokens => &self.anthropic_auxiliary_target,
279 AuxiliaryOperation::ResponsesInputTokens | AuxiliaryOperation::ResponsesCompact => {
280 &self.responses_auxiliary_target
281 }
282 }
283 .as_ref()
284 .ok_or(RunnerError::AuxiliaryUnsupported)?;
285 target
286 .client
287 .call_auxiliary(&target.model, request, operation)
288 .await
289 .map_err(Into::into)
290 }
291}