1pub mod config;
7mod metrics;
8mod observability;
9mod response;
10mod routing_log;
11mod shutdown;
12mod sse;
13mod stats;
14mod usage_metrics;
15
16use std::collections::BTreeMap;
17use std::error::Error;
18use std::fmt::{Display, Formatter};
19use std::future::Future;
20use std::io::IsTerminal;
21use std::net::SocketAddr;
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use axum::extract::rejection::{JsonRejection, QueryRejection};
27use axum::extract::{DefaultBodyLimit, Query, Request as HttpRequest, State};
28use axum::http::header::CONTENT_TYPE;
29use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
30use axum::middleware::Next;
31use axum::response::{IntoResponse, Response};
32use axum::routing::{get, post};
33use axum::{Extension, Json, Router};
34use axum_server::tls_rustls::RustlsConfig;
35use libsy::{Algorithm, LibsyError, RunObservation, RunObserver};
36use parking_lot::Mutex;
37use serde::Deserialize;
38use serde_json::{Value, json};
39use switchyard_llm_client::TranslatingLlmClient;
40use switchyard_protocol::{Context, Decision, LlmClientError, Metadata, Request, Usage};
41use tokio::net::{TcpListener, TcpSocket};
42use tokio::task;
43use tracing::{Instrument, Level};
44
45use switchyard_translation::{WireFormat, decode_request};
46
47use crate::response::into_http_response;
48use crate::stats::{StatsAccumulator, StatsSnapshot, prefix_probe, tracking_enabled_from_env};
49
50pub use observability::{flush_observability, initialize_observability};
51
52pub const DEFAULT_LISTEN_BACKLOG: u32 = 65_535;
54
55pub const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
57
58pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024 * 1024;
60
61const HEADER_SELECTED_MODEL: &str = "x-model-router-selected-model";
62const HEADER_RATIONALE: &str = "x-model-router-rationale";
63const MAX_ROUTING_HEADER_VALUE_LEN: usize = 512;
64const STARTUP_BANNER_ART: &str = include_str!("../assets/startup_banner.txt");
65
66#[derive(Debug)]
68pub struct ServerError {
69 message: String,
70}
71
72impl ServerError {
73 pub fn new(message: impl Into<String>) -> Self {
75 Self {
76 message: message.into(),
77 }
78 }
79}
80
81impl Display for ServerError {
82 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
83 formatter.write_str(&self.message)
84 }
85}
86
87impl Error for ServerError {}
88
89pub type ServerResult<T> = std::result::Result<T, ServerError>;
91
92#[derive(Clone, Copy, Default)]
97struct ModelCapabilities {
98 context_window: Option<u32>,
99 tool_calling: Option<bool>,
100 reasoning: Option<bool>,
104}
105
106struct RouteEntry {
108 algorithm: Arc<dyn Algorithm>,
109 capabilities: ModelCapabilities,
110 count_tokens_target: Option<CountTokensTarget>,
111}
112
113#[derive(Clone)]
115struct CountTokensTarget {
116 model: String,
117 client: Arc<TranslatingLlmClient>,
118}
119
120impl CountTokensTarget {
121 async fn count_tokens(&self, request: Request) -> Result<Value, LlmClientError> {
122 self.client.count_tokens(&self.model, request).await
123 }
124}
125
126#[derive(Clone)]
128pub struct ServerState {
129 routes: Arc<BTreeMap<String, RouteEntry>>,
130 metrics: prometheus::Registry,
131 stats: StatsAccumulator,
132 routing_log: Option<SharedRoutingLog>,
133 track_cache_eligibility: bool,
134}
135
136#[derive(Clone)]
137struct SharedRoutingLog {
138 writer: Arc<Mutex<routing_log::RoutingLog>>,
139 path: PathBuf,
140}
141
142impl SharedRoutingLog {
143 fn new(path: PathBuf) -> ServerResult<Self> {
144 Ok(Self {
145 writer: Arc::new(Mutex::new(routing_log::RoutingLog::new(path.clone())?)),
146 path,
147 })
148 }
149
150 fn append(
151 &self,
152 context: routing_log::RoutingLogContext,
153 model: &str,
154 tier: Option<&str>,
155 usage: &Usage,
156 ) {
157 if let Err(error) = self.writer.lock().append(context, model, tier, usage) {
158 tracing::warn!(path = %self.path.display(), %error, "routing log append failed");
159 }
160 }
161
162 fn snapshot_session(
163 &self,
164 session_id: &str,
165 ) -> std::io::Result<Option<routing_log::SessionStatsSnapshot>> {
166 routing_log::snapshot(&self.path, session_id)
167 }
168}
169
170impl ServerState {
171 pub fn new(
173 routes: impl IntoIterator<Item = (String, Arc<dyn Algorithm>)>,
174 ) -> ServerResult<Self> {
175 Self::new_with_capabilities(
176 routes
177 .into_iter()
178 .map(|(model, algorithm)| (model, algorithm, ModelCapabilities::default(), None)),
179 )
180 }
181
182 fn new_with_capabilities(
183 routes: impl IntoIterator<
184 Item = (
185 String,
186 Arc<dyn Algorithm>,
187 ModelCapabilities,
188 Option<CountTokensTarget>,
189 ),
190 >,
191 ) -> ServerResult<Self> {
192 let mut entries = BTreeMap::new();
193 for (model, algorithm, capabilities, count_tokens_target) in routes {
194 let model = model.trim();
195 if model.is_empty() {
196 return Err(ServerError::new("route model must not be empty"));
197 }
198 let entry = RouteEntry {
199 algorithm,
200 capabilities,
201 count_tokens_target,
202 };
203 if entries.insert(model.to_string(), entry).is_some() {
204 return Err(ServerError::new(format!("duplicate route model {model}")));
205 }
206 }
207 if entries.is_empty() {
208 return Err(ServerError::new("at least one algorithm route is required"));
209 }
210 let metrics = metrics::registry().map_err(ServerError::new)?;
211 Ok(Self {
212 routes: Arc::new(entries),
213 metrics,
214 stats: StatsAccumulator::default(),
215 routing_log: None,
216 track_cache_eligibility: tracking_enabled_from_env(),
217 })
218 }
219
220 pub fn with_routing_log(mut self, path: impl Into<PathBuf>) -> ServerResult<Self> {
222 self.routing_log = Some(SharedRoutingLog::new(path.into())?);
223 Ok(self)
224 }
225
226 pub fn models(&self) -> impl Iterator<Item = &str> {
228 self.routes.keys().map(String::as_str)
229 }
230
231 fn route_for_model(&self, model: &str) -> Option<&RouteEntry> {
232 self.routes.get(model)
233 }
234}
235
236#[derive(Clone, Debug)]
238pub struct ServerRunOptions {
239 pub addr: SocketAddr,
241 pub backlog: u32,
243 pub dry_run: bool,
245 pub shutdown_timeout: Duration,
247 pub tls: Option<TlsOptions>,
249}
250
251#[derive(Clone, Debug)]
253pub struct TlsOptions {
254 pub cert: PathBuf,
256 pub key: PathBuf,
258}
259
260impl ServerRunOptions {
261 fn is_tls(&self) -> bool {
262 self.tls.is_some()
263 }
264}
265
266pub async fn run_server(state: ServerState, options: ServerRunOptions) -> ServerResult<()> {
268 if options.dry_run {
269 println!("{}", dry_run_summary(&state));
270 return Ok(());
271 }
272
273 let server = BoundServer::bind(state, options)?;
274 println!("{}", server.startup_banner(std::io::stdout().is_terminal()));
275 server.serve(shutdown::signal()).await
276}
277
278pub struct BoundServer {
280 listener: TcpListener,
281 router: Router,
282 options: ServerRunOptions,
283 state: ServerState,
284}
285
286impl BoundServer {
287 pub fn bind(state: ServerState, options: ServerRunOptions) -> ServerResult<Self> {
289 let listener = bind_tcp_listener(options.addr, options.backlog)?;
290 let addr = listener.local_addr().map_err(server_io_error)?;
291 Ok(Self {
292 listener,
293 router: build_switchyard_router(state.clone()),
294 options: ServerRunOptions { addr, ..options },
295 state,
296 })
297 }
298
299 pub fn local_addr(&self) -> SocketAddr {
301 self.options.addr
302 }
303
304 pub async fn serve(
306 self,
307 shutdown: impl Future<Output = ()> + Send + 'static,
308 ) -> ServerResult<()> {
309 let shutdown_timeout = self.options.shutdown_timeout;
310 if let Some(tls) = self.options.tls {
311 serve_tls(self.listener, self.router, tls, shutdown_timeout, shutdown).await
312 } else {
313 serve(self.listener, self.router, shutdown_timeout, shutdown).await
314 }
315 }
316
317 fn startup_banner(&self, color: bool) -> String {
318 startup_banner(&self.options, &self.state, color)
319 }
320}
321
322async fn serve_tls(
323 listener: TcpListener,
324 router: Router,
325 tls: TlsOptions,
326 shutdown_timeout: Duration,
327 shutdown: impl Future<Output = ()> + Send + 'static,
328) -> ServerResult<()> {
329 if let Err(error) = rustls::crypto::aws_lc_rs::default_provider().install_default() {
330 tracing::debug!(?error, "TLS crypto provider was already installed");
331 }
332
333 let config = RustlsConfig::from_pem_file(tls.cert, tls.key)
334 .await
335 .map_err(server_io_error)?;
336 let std_listener = listener.into_std().map_err(server_io_error)?;
337 let server = axum_server::from_tcp_rustls(std_listener, config).map_err(server_io_error)?;
338 let handle = axum_server::Handle::new();
339 let server = server
340 .handle(handle.clone())
341 .serve(router.into_make_service());
342 serve_until_shutdown(server, handle, shutdown_timeout, shutdown).await
343}
344
345async fn serve(
346 listener: TcpListener,
347 router: Router,
348 shutdown_timeout: Duration,
349 shutdown: impl Future<Output = ()> + Send + 'static,
350) -> ServerResult<()> {
351 let std_listener = listener.into_std().map_err(server_io_error)?;
352 let server = axum_server::from_tcp(std_listener).map_err(server_io_error)?;
353 let handle = axum_server::Handle::new();
354 let server = server
355 .handle(handle.clone())
356 .serve(router.into_make_service());
357 serve_until_shutdown(server, handle, shutdown_timeout, shutdown).await
358}
359
360async fn serve_until_shutdown(
362 server: impl Future<Output = std::io::Result<()>>,
363 handle: axum_server::Handle<SocketAddr>,
364 timeout: Duration,
365 shutdown: impl Future<Output = ()> + Send + 'static,
366) -> ServerResult<()> {
367 tokio::pin!(server);
368 tokio::select! {
369 result = &mut server => result.map_err(server_io_error),
370 _ = shutdown => {
371 tracing::info!(
372 ?timeout,
373 "shutdown signal received; draining active requests"
374 );
375 handle.graceful_shutdown(Some(timeout));
376 server.await.map_err(server_io_error)
377 }
378 }
379}
380
381#[derive(Clone, Copy)]
383struct RequestStart(Instant);
384
385const CLASSIFIER_TIER: &str = "classifier";
388
389fn stats_observer(
397 stats: StatsAccumulator,
398 classifier_log: Option<(SharedRoutingLog, routing_log::RoutingLogContext)>,
399) -> RunObserver {
400 Arc::new(move |observation| match observation {
401 RunObservation::LlmCall(call) => {
402 let latency_ms = call.duration.as_secs_f64() * 1_000.0;
403 if call.is_routed {
404 if call.is_success {
405 stats.record_success(&call.selected_model, latency_ms, call.tier.as_deref());
406 } else {
407 stats.record_error(&call.selected_model, call.tier.as_deref());
408 }
409 } else if call.is_success {
410 if let (Some((log, context)), Some(usage)) =
411 (classifier_log.as_ref(), call.usage.as_ref())
412 {
413 log.append(
414 context.clone(),
415 &call.selected_model,
416 Some(CLASSIFIER_TIER),
417 usage,
418 );
419 }
420 stats.record_classifier_success(
421 call.selected_model,
422 call.usage.as_ref().map(usage_metrics::token_usage),
423 latency_ms,
424 );
425 } else {
426 stats.record_classifier_error(call.selected_model);
427 }
428 }
429 RunObservation::RoutingOverhead(duration) => {
430 stats.record_routing_overhead(duration.as_secs_f64() * 1_000.0);
431 }
432 })
433}
434
435async fn stamp_request_start(mut request: HttpRequest, next: Next) -> Response {
439 request
440 .extensions_mut()
441 .insert(RequestStart(Instant::now()));
442 next.run(request).await
443}
444
445pub fn build_switchyard_router(state: ServerState) -> Router {
447 let mut router = Router::new()
448 .route("/v1/chat/completions", post(openai_chat_completions))
449 .route("/v1/messages", post(anthropic_messages))
450 .route("/v1/responses", post(openai_responses))
451 .route("/v1/messages/count_tokens", post(anthropic_count_tokens))
452 .route("/v1/models", get(models))
453 .route("/v1/stats", get(get_stats))
454 .route("/v1/stats/reset", post(reset_stats))
455 .route("/metrics", get(prometheus_metrics))
456 .route("/health", get(health));
457 if state.routing_log.is_some() {
458 router = router.route("/v1/routing/session-stats", get(get_session_stats));
459 }
460 router
461 .fallback(not_found)
462 .layer(DefaultBodyLimit::max(DEFAULT_MAX_REQUEST_BODY_BYTES))
463 .layer(axum::middleware::from_fn(stamp_request_start))
465 .with_state(state)
466}
467
468fn bind_tcp_listener(addr: SocketAddr, backlog: u32) -> ServerResult<TcpListener> {
469 let socket = if addr.is_ipv4() {
470 TcpSocket::new_v4()
471 } else {
472 TcpSocket::new_v6()
473 }
474 .map_err(server_io_error)?;
475
476 socket.set_reuseaddr(true).map_err(server_io_error)?;
477 socket.bind(addr).map_err(server_io_error)?;
478 socket.listen(backlog).map_err(server_io_error)
479}
480
481fn server_io_error(error: std::io::Error) -> ServerError {
482 ServerError::new(error.to_string())
483}
484
485async fn openai_chat_completions(
486 State(state): State<ServerState>,
487 Extension(started): Extension<RequestStart>,
488 headers: HeaderMap,
489 body: std::result::Result<Json<Value>, JsonRejection>,
490) -> Response {
491 handle_endpoint(state, started, headers, body, WireFormat::OpenAiChat).await
492}
493
494async fn anthropic_messages(
495 State(state): State<ServerState>,
496 Extension(started): Extension<RequestStart>,
497 headers: HeaderMap,
498 body: std::result::Result<Json<Value>, JsonRejection>,
499) -> Response {
500 handle_endpoint(state, started, headers, body, WireFormat::AnthropicMessages).await
501}
502
503async fn openai_responses(
504 State(state): State<ServerState>,
505 Extension(started): Extension<RequestStart>,
506 headers: HeaderMap,
507 body: std::result::Result<Json<Value>, JsonRejection>,
508) -> Response {
509 handle_endpoint(state, started, headers, body, WireFormat::OpenAiResponses).await
510}
511
512async fn anthropic_count_tokens(
514 State(state): State<ServerState>,
515 headers: HeaderMap,
516 body: std::result::Result<Json<Value>, JsonRejection>,
517) -> Response {
518 let body = match llm_json_body(body) {
519 Ok(body) => body,
520 Err(message) => return anthropic_error_response(invalid_body_error(message)),
521 };
522 let (route, request) = match resolve_route(
523 &state,
524 metadata_from_headers(headers),
525 body,
526 WireFormat::AnthropicMessages,
527 ) {
528 Ok(resolved) => resolved,
529 Err(response) => return anthropic_error_response(response),
530 };
531 let Some(target) = route.count_tokens_target.as_ref() else {
532 return anthropic_error_response(error_response(
533 StatusCode::BAD_REQUEST,
534 "route has no Anthropic target for token counting",
535 "invalid_request_error",
536 "count_tokens_unsupported",
537 ));
538 };
539 anthropic_error_response(match target.count_tokens(request).await {
540 Ok(payload) => (StatusCode::OK, Json(payload)).into_response(),
541 Err(error) => count_tokens_error(error),
542 })
543}
544
545fn count_tokens_error(error: LlmClientError) -> Response {
547 client_error(&error)
548}
549
550async fn handle_endpoint(
551 state: ServerState,
552 started: RequestStart,
553 headers: HeaderMap,
554 body: std::result::Result<Json<Value>, JsonRejection>,
555 wire_format: WireFormat,
556) -> Response {
557 let span = observability::request_span(&headers);
558 handle_endpoint_inner(state, started, headers, body, wire_format)
559 .instrument(span)
560 .await
561}
562
563async fn handle_endpoint_inner(
564 state: ServerState,
565 started: RequestStart,
566 headers: HeaderMap,
567 body: std::result::Result<Json<Value>, JsonRejection>,
568 wire_format: WireFormat,
569) -> Response {
570 let routing_log_context = state
571 .routing_log
572 .as_ref()
573 .map(|_| routing_log::RoutingLogContext::from_headers(&headers));
574 let metadata = metadata_from_headers(headers);
575 let request_log = RequestLogContext {
576 started: started.0,
577 wire_format,
578 requested_model: body
579 .as_ref()
580 .ok()
581 .and_then(|body| body.0.get("model"))
582 .and_then(Value::as_str)
583 .map(str::to_string),
584 streaming: body
585 .as_ref()
586 .ok()
587 .and_then(|body| body.0.get("stream"))
588 .and_then(Value::as_bool)
589 .unwrap_or(false),
590 session_id: metadata.session_id.clone(),
591 correlation_id: metadata.correlation_id.clone(),
592 };
593
594 let response = match llm_json_body(body) {
595 Ok(body) => {
596 handle_llm_request(
597 state,
598 started,
599 metadata,
600 body,
601 wire_format,
602 routing_log_context,
603 )
604 .await
605 }
606 Err(message) => invalid_body_error(message),
607 };
608 let response = render_error_response(response, wire_format);
609 metrics::record_client_response(response.status().as_u16());
610 request_log.emit(&response);
611 response
612}
613
614fn llm_json_body(
615 body: std::result::Result<Json<Value>, JsonRejection>,
616) -> std::result::Result<Value, String> {
617 match body {
618 Ok(Json(value)) if value.is_object() => Ok(value),
619 Ok(_) => Err("Request body must be a JSON object".to_string()),
620 Err(error) => Err(format!("Request body must be valid JSON: {error}")),
621 }
622}
623
624#[allow(clippy::type_complexity, clippy::result_large_err)]
631fn resolve_route(
632 state: &ServerState,
633 metadata: Metadata,
634 body: Value,
635 wire_format: WireFormat,
636) -> std::result::Result<(&RouteEntry, Request), Response> {
637 let llm_request = decode_request(wire_format, &body)
638 .map_err(|error| invalid_body_error(error.to_string()))?;
639 let requested_model = llm_request
640 .model
641 .clone()
642 .filter(|model| !model.trim().is_empty())
643 .ok_or_else(|| {
644 error_response(
645 StatusCode::BAD_REQUEST,
646 "request body must include a non-empty string `model`",
647 "invalid_request_error",
648 "invalid_request_error",
649 )
650 })?;
651 let route = state.route_for_model(&requested_model).ok_or_else(|| {
652 error_response(
653 StatusCode::NOT_FOUND,
654 format!("No route registered for model {requested_model}"),
655 "model_not_found",
656 "model_not_found",
657 )
658 })?;
659 let request = Request {
660 llm_request,
661 raw_request: Some(body),
662 metadata: Some(metadata),
663 };
664 Ok((route, request))
665}
666
667async fn handle_llm_request(
668 state: ServerState,
669 started: RequestStart,
670 metadata: Metadata,
671 body: Value,
672 wire_format: WireFormat,
673 routing_log_context: Option<routing_log::RoutingLogContext>,
674) -> Response {
675 let cache_probe = state.track_cache_eligibility.then(|| prefix_probe(&body));
676 let (route, request) = match resolve_route(&state, metadata, body, wire_format) {
677 Ok(resolved) => resolved,
678 Err(response) => return response,
679 };
680 let algorithm = Arc::clone(&route.algorithm);
681 let observer = stats_observer(
682 state.stats.clone(),
683 state.routing_log.clone().zip(routing_log_context.clone()),
684 );
685 let (trace, response) = match algorithm
686 .run_observed(Context::default(), request, Some(observer))
687 .await
688 {
689 Ok(result) => result,
690 Err(error) => return algorithm_error(error),
691 };
692 for reason in trace
693 .iter()
694 .filter_map(|decision| decision.fallback_reason())
695 {
696 state.stats.record_routing_fallback(reason);
697 }
698
699 let decision = trace.last();
703 let response = if let Some(decision) = decision {
704 let routing_log_context = routing_log_context
705 .map(|context| context.with_fallback_reason(decision.fallback_reason()));
706 let cache_eligible = cache_probe
707 .as_ref()
708 .map(|probe| {
709 state
710 .stats
711 .prefix_eligibility(decision.selected_model(), probe)
712 })
713 .unwrap_or(0.0);
714 usage_metrics::observe(
715 response,
716 decision.selected_model(),
717 decision.routing_tier(),
718 started.0,
719 state.stats,
720 cache_eligible,
721 state.routing_log.zip(routing_log_context),
722 )
723 } else {
724 response
725 };
726
727 let served_model = decision.map(|decision| decision.selected_model().to_string());
728 let mut response = match into_http_response(response, wire_format, served_model) {
729 Ok(response) => response,
730 Err(error) => return server_error(error.to_string()),
731 };
732 if let Some(decision) = decision {
733 attach_routing_headers(&mut response, decision.as_ref());
734 }
735 response
736}
737
738struct RequestLogContext {
740 started: Instant,
741 wire_format: WireFormat,
742 requested_model: Option<String>,
743 streaming: bool,
744 session_id: Option<String>,
745 correlation_id: Option<String>,
746}
747
748#[derive(Clone)]
750struct RequestLogError(String);
751
752impl RequestLogContext {
753 fn emit(self, response: &Response) {
754 let selected_model = response
755 .headers()
756 .get(HEADER_SELECTED_MODEL)
757 .and_then(|value| value.to_str().ok())
758 .unwrap_or("");
759 let duration_ms = self.started.elapsed().as_secs_f64() * 1_000.0;
760 let error = response
761 .extensions()
762 .get::<RequestLogError>()
763 .map(|error| error.0.as_str())
764 .unwrap_or("");
765
766 macro_rules! emit {
767 ($level:expr, $message:literal) => {
768 tracing::event!(
769 target: "switchyard_server::request",
770 $level,
771 wire_format = %self.wire_format,
772 status = response.status().as_u16(),
773 requested_model = self.requested_model.as_deref().unwrap_or(""),
774 selected_model,
775 streaming = self.streaming,
776 session_id = self.session_id.as_deref().unwrap_or(""),
777 correlation_id = self.correlation_id.as_deref().unwrap_or(""),
778 handling_duration_ms = duration_ms,
779 error,
780 $message
781 )
782 };
783 }
784
785 match request_log_level(response.status()) {
786 Level::ERROR => emit!(Level::ERROR, "LLM request failed"),
787 Level::WARN => emit!(Level::WARN, "LLM request failed"),
788 _ => emit!(Level::INFO, "LLM request handled"),
789 }
790 }
791}
792
793fn request_log_level(status: StatusCode) -> Level {
794 if status.is_server_error() {
795 Level::ERROR
796 } else if status.is_success() {
797 Level::INFO
798 } else {
799 Level::WARN
800 }
801}
802
803fn metadata_from_headers(headers: HeaderMap) -> Metadata {
804 let mut metadata = Metadata::from_headers(&headers);
805 metadata.http_headers = Some(headers);
806 metadata
807}
808
809fn attach_routing_headers(response: &mut Response, decision: &dyn Decision) {
810 insert_routing_header(response, HEADER_SELECTED_MODEL, decision.selected_model());
811 if let Some(reasoning) = decision.reasoning() {
812 insert_routing_header(response, HEADER_RATIONALE, reasoning);
813 }
814}
815
816fn insert_routing_header(response: &mut Response, name: &'static str, value: &str) {
817 let Some(value) = sanitize_routing_header_value(value) else {
818 return;
819 };
820 let Ok(value) = HeaderValue::from_str(&value) else {
821 return;
822 };
823 response
824 .headers_mut()
825 .insert(HeaderName::from_static(name), value);
826}
827
828fn sanitize_routing_header_value(value: &str) -> Option<String> {
829 let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
830 (!value.is_empty()).then(|| value.chars().take(MAX_ROUTING_HEADER_VALUE_LEN).collect())
831}
832
833fn algorithm_error(error: LibsyError) -> Response {
834 let LibsyError::ClientCall { source, .. } = &error else {
835 return server_error(error.to_string());
836 };
837 client_error(source)
838}
839
840fn client_error(error: &LlmClientError) -> Response {
841 match error {
842 LlmClientError::InvalidRequest { message }
843 | LlmClientError::RequestTranslation(message) => error_response(
844 StatusCode::BAD_REQUEST,
845 message,
846 "invalid_request_error",
847 "invalid_request_error",
848 ),
849 LlmClientError::Configuration { message } => error_response(
850 StatusCode::BAD_GATEWAY,
851 message,
852 "upstream_error",
853 "upstream_configuration_error",
854 ),
855 LlmClientError::ContextWindowExceeded { message, .. } => error_response(
856 StatusCode::BAD_REQUEST,
857 message,
858 "invalid_request_error",
859 "context_length_exceeded",
860 ),
861 LlmClientError::UpstreamHttp { status, body } => error_response(
862 StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY),
863 upstream_error_message(body),
864 "upstream_error",
865 "upstream_error",
866 ),
867 LlmClientError::Transport { source } | LlmClientError::InvalidResponse { source } => {
868 error_response(
869 StatusCode::BAD_GATEWAY,
870 source.to_string(),
871 "upstream_error",
872 "upstream_error",
873 )
874 }
875 LlmClientError::ResponseTranslation(message) => error_response(
876 StatusCode::BAD_GATEWAY,
877 message,
878 "upstream_error",
879 "upstream_error",
880 ),
881 LlmClientError::Timeout { source } => error_response(
882 StatusCode::GATEWAY_TIMEOUT,
883 source.to_string(),
884 "upstream_error",
885 "upstream_timeout",
886 ),
887 LlmClientError::RequestEncoding(message) => server_error(message),
888 _ => server_error(error.to_string()),
889 }
890}
891
892fn upstream_error_message(body: &str) -> String {
895 serde_json::from_str::<Value>(body)
896 .ok()
897 .and_then(|body| {
898 body.pointer("/error/message")
899 .and_then(Value::as_str)
900 .map(str::to_string)
901 })
902 .unwrap_or_else(|| body.to_string())
903}
904
905#[derive(Clone)]
907struct ApiError {
908 status: StatusCode,
909 message: String,
910 error_type: &'static str,
911 code: &'static str,
912}
913
914impl ApiError {
915 fn new(
916 status: StatusCode,
917 message: impl Into<String>,
918 error_type: &'static str,
919 code: &'static str,
920 ) -> Self {
921 Self {
922 status,
923 message: message.into(),
924 error_type,
925 code,
926 }
927 }
928
929 fn into_response(self, wire_format: WireFormat) -> Response {
930 let body = match wire_format {
931 WireFormat::AnthropicMessages => json!({
932 "type": "error",
933 "error": {
934 "type": anthropic_error_type(self.status),
935 "message": self.message.clone(),
936 }
937 }),
938 WireFormat::OpenAiChat | WireFormat::OpenAiResponses => json!({
939 "error": {
940 "message": self.message.clone(),
941 "type": self.error_type,
942 "code": self.code,
943 }
944 }),
945 };
946 let mut response = (self.status, Json(body)).into_response();
947 response
948 .extensions_mut()
949 .insert(RequestLogError(self.message.clone()));
950 response.extensions_mut().insert(self);
951 response
952 }
953}
954
955fn render_error_response(response: Response, wire_format: WireFormat) -> Response {
956 let Some(error) = response.extensions().get::<ApiError>().cloned() else {
957 return response;
958 };
959 error.into_response(wire_format)
960}
961
962fn anthropic_error_response(response: Response) -> Response {
963 render_error_response(response, WireFormat::AnthropicMessages)
964}
965
966fn anthropic_error_type(status: StatusCode) -> &'static str {
967 match status {
968 StatusCode::BAD_REQUEST => "invalid_request_error",
969 StatusCode::UNAUTHORIZED => "authentication_error",
970 StatusCode::FORBIDDEN => "permission_error",
971 StatusCode::NOT_FOUND => "not_found_error",
972 StatusCode::PAYLOAD_TOO_LARGE => "request_too_large",
973 StatusCode::TOO_MANY_REQUESTS => "rate_limit_error",
974 status if status.as_u16() == 529 => "overloaded_error",
975 _ => "api_error",
976 }
977}
978
979fn server_error(message: impl Into<String>) -> Response {
980 error_response(
981 StatusCode::INTERNAL_SERVER_ERROR,
982 message,
983 "server_error",
984 "server_error",
985 )
986}
987
988fn invalid_body_error(message: impl Into<String>) -> Response {
989 error_response(
990 StatusCode::BAD_REQUEST,
991 message,
992 "invalid_request_error",
993 "invalid_body",
994 )
995}
996
997fn error_response(
998 status: StatusCode,
999 message: impl Into<String>,
1000 error_type: &'static str,
1001 code: &'static str,
1002) -> Response {
1003 ApiError::new(status, message, error_type, code).into_response(WireFormat::OpenAiChat)
1004}
1005
1006async fn models(State(state): State<ServerState>) -> Json<Value> {
1007 Json(model_list_payload(
1008 state
1009 .routes
1010 .iter()
1011 .map(|(model, entry)| (model.as_str(), entry.capabilities)),
1012 ))
1013}
1014
1015async fn get_stats(State(state): State<ServerState>) -> Json<StatsSnapshot> {
1016 Json(state.stats.snapshot())
1017}
1018
1019async fn reset_stats(State(state): State<ServerState>) -> Json<Value> {
1020 state.stats.reset();
1021 Json(json!({"status": "reset"}))
1022}
1023
1024#[derive(Deserialize)]
1025struct SessionStatsQuery {
1026 session_id: String,
1027}
1028
1029async fn get_session_stats(
1032 State(state): State<ServerState>,
1033 query: std::result::Result<Query<SessionStatsQuery>, QueryRejection>,
1034) -> Response {
1035 let Query(query) = match query {
1036 Ok(query) => query,
1037 Err(error) => {
1038 return error_response(
1039 StatusCode::BAD_REQUEST,
1040 error.to_string(),
1041 "invalid_request_error",
1042 "invalid_query",
1043 );
1044 }
1045 };
1046 let Some(routing_log) = state.routing_log else {
1047 return not_found().await;
1049 };
1050 let session_id = query.session_id.clone();
1051 let snapshot =
1053 match task::spawn_blocking(move || routing_log.snapshot_session(&session_id)).await {
1054 Ok(s) => s,
1055 Err(err) => {
1056 return server_error(format!("failed to snapshot: {err}"));
1057 }
1058 };
1059
1060 match snapshot {
1061 Ok(Some(snapshot)) => (StatusCode::OK, Json(snapshot)).into_response(),
1062 Ok(None) => error_response(
1063 StatusCode::NOT_FOUND,
1064 "Routing session not found",
1065 "not_found",
1066 "routing_session_not_found",
1067 ),
1068 Err(error) => server_error(format!("failed to read routing log: {error}")),
1069 }
1070}
1071
1072async fn health() -> Json<Value> {
1073 Json(json!({"status": "ok"}))
1074}
1075
1076async fn prometheus_metrics(State(state): State<ServerState>) -> Response {
1077 match metrics::encode(&state.metrics) {
1078 Ok(body) => ([(CONTENT_TYPE, metrics::CONTENT_TYPE)], body).into_response(),
1079 Err(error) => server_error(error),
1080 }
1081}
1082
1083async fn not_found() -> Response {
1084 error_response(
1085 StatusCode::NOT_FOUND,
1086 "Not Found",
1087 "not_found",
1088 "endpoint_not_found",
1089 )
1090}
1091
1092fn model_list_payload<'a>(
1093 entries: impl IntoIterator<Item = (&'a str, ModelCapabilities)>,
1094) -> Value {
1095 let entries = entries.into_iter().collect::<Vec<_>>();
1096 let model_ids = entries.iter().map(|(model, _)| *model).collect::<Vec<_>>();
1097 let first_id = model_ids.first().copied();
1098 let last_id = model_ids.last().copied();
1099 json!({
1100 "object": "list",
1101 "data": entries.iter().map(|(model, caps)| model_entry_json(model, *caps)).collect::<Vec<_>>(),
1102 "models": entries
1103 .iter()
1104 .enumerate()
1105 .map(|(priority, (model, caps))| codex_model_entry_json(model, *caps, priority))
1106 .collect::<Vec<_>>(),
1107 "first_id": first_id,
1108 "last_id": last_id,
1109 "has_more": false,
1110 "default_model": first_id,
1111 "model_pool": model_ids,
1112 })
1113}
1114
1115fn model_entry_json(model: &str, capabilities: ModelCapabilities) -> Value {
1116 json!({
1117 "id": model,
1118 "object": "model",
1119 "type": "model",
1120 "created": 0,
1121 "owned_by": "switchyard",
1122 "display_name": model,
1123 "capabilities": {
1124 "streaming": true,
1125 "tool_calling": capabilities.tool_calling,
1126 "context_window": capabilities.context_window,
1127 "supported_inbound_formats": [
1128 "openai-chat-completions",
1129 "openai-responses",
1130 "anthropic-messages",
1131 ],
1132 },
1133 })
1134}
1135
1136fn codex_model_entry_json(model: &str, capabilities: ModelCapabilities, priority: usize) -> Value {
1156 let tool_calling = capabilities.tool_calling.unwrap_or(true);
1160 let reasoning = capabilities.reasoning.unwrap_or(false);
1161 json!({
1162 "slug": model,
1163 "display_name": model,
1164 "description": "Switchyard-routed model.",
1165 "default_reasoning_level": if reasoning { json!("xhigh") } else { Value::Null },
1166 "supported_reasoning_levels": if reasoning { reasoning_levels() } else { json!([]) },
1167 "shell_type": if tool_calling { "shell_command" } else { "disabled" },
1168 "visibility": "list",
1169 "supported_in_api": true,
1170 "priority": priority,
1172 "additional_speed_tiers": [],
1173 "availability_nux": null,
1174 "upgrade": null,
1175 "base_instructions": "You are Codex, a coding agent.",
1178 "supports_reasoning_summaries": reasoning,
1179 "default_reasoning_summary": "none",
1180 "support_verbosity": reasoning,
1181 "default_verbosity": if reasoning { json!("low") } else { Value::Null },
1182 "apply_patch_tool_type": if tool_calling { Some("freeform") } else { None },
1183 "web_search_tool_type": "text",
1184 "truncation_policy": {"mode": "tokens", "limit": 10_000},
1185 "supports_parallel_tool_calls": tool_calling,
1186 "supports_image_detail_original": false,
1187 "context_window": capabilities.context_window,
1188 "max_context_window": capabilities.context_window,
1189 "effective_context_window_percent": 95,
1190 "experimental_supported_tools": [],
1191 "input_modalities": ["text"],
1192 "supports_search_tool": false,
1193 })
1194}
1195
1196fn reasoning_levels() -> Value {
1199 json!([
1200 {"effort": "low", "description": "Fast responses with lighter reasoning"},
1201 {"effort": "medium", "description": "Balances speed and reasoning depth"},
1202 {"effort": "high", "description": "Greater reasoning depth"},
1203 {"effort": "xhigh", "description": "Extra high reasoning depth"},
1204 ])
1205}
1206
1207fn startup_banner(options: &ServerRunOptions, state: &ServerState, color: bool) -> String {
1208 let scheme = if options.is_tls() { "https" } else { "http" };
1209 let listen_url = url_for_addr(scheme, options.addr);
1210 let request_url = request_url_for_addr(scheme, options.addr);
1211 let routes = state.models().collect::<Vec<_>>();
1212 let route_list = routes.join(", ");
1213 let example_model = routes.first().copied().unwrap_or("switchyard/route");
1214 let example_body = json!({
1215 "model": example_model,
1216 "messages": [{"role": "user", "content": "Hello from Switchyard"}],
1217 });
1218 let example_url = shell_quote(&format!("{request_url}/v1/chat/completions"));
1219 let example_body = shell_quote(&example_body.to_string());
1220 format!(
1221 "{}\nSwitchyard libsy server\n listening: {}\n routes: {}\n\nendpoints:\n{}\n\nexample:\n curl -s {} \\\n -H 'Content-Type: application/json' \\\n -d {}",
1222 render_startup_banner_art(color),
1223 listen_url,
1224 route_list,
1225 endpoint_listing(state.routing_log.is_some()),
1226 example_url,
1227 example_body,
1228 )
1229}
1230
1231fn render_startup_banner_art(color: bool) -> String {
1233 let banner = STARTUP_BANNER_ART.trim_end();
1234 if !color {
1235 return banner.to_string();
1236 }
1237
1238 let (red, green, blue) = (118, 185, 0);
1239 let mut rendered = String::new();
1240 for line in banner.lines() {
1241 rendered.push_str(&format!("\x1b[38;2;{red};{green};{blue}m{line}\x1b[0m\n"));
1242 }
1243 rendered.trim_end_matches('\n').to_string()
1244}
1245
1246fn dry_run_summary(state: &ServerState) -> String {
1247 format!(
1248 "server OK: {}",
1249 state.models().collect::<Vec<_>>().join(", ")
1250 )
1251}
1252
1253fn url_for_addr(scheme: &'static str, addr: SocketAddr) -> String {
1254 format!("{scheme}://{}:{}", host_for_url(addr.ip()), addr.port())
1255}
1256
1257fn request_url_for_addr(scheme: &'static str, addr: SocketAddr) -> String {
1259 let host = if addr.ip().is_unspecified() {
1260 match addr.ip() {
1261 std::net::IpAddr::V4(_) => "127.0.0.1".to_string(),
1262 std::net::IpAddr::V6(_) => "[::1]".to_string(),
1263 }
1264 } else {
1265 host_for_url(addr.ip())
1266 };
1267 format!("{scheme}://{host}:{}", addr.port())
1268}
1269
1270fn host_for_url(ip: std::net::IpAddr) -> String {
1272 match ip {
1273 std::net::IpAddr::V4(ip) => ip.to_string(),
1274 std::net::IpAddr::V6(ip) => format!("[{ip}]"),
1275 }
1276}
1277
1278fn shell_quote(value: &str) -> String {
1279 format!("'{}'", value.replace('\'', "'\\''"))
1280}
1281
1282fn endpoint_listing(has_routing_log: bool) -> String {
1283 let mut endpoints = vec![
1284 " POST /v1/chat/completions OpenAI Chat Completions",
1285 " POST /v1/messages Anthropic Messages",
1286 " POST /v1/responses OpenAI Responses",
1287 " POST /v1/messages/count_tokens",
1288 " GET /v1/models configured routes",
1289 " GET /v1/stats routing stats",
1290 " POST /v1/stats/reset",
1291 " GET /metrics Prometheus metrics",
1292 " GET /health",
1293 ];
1294 if has_routing_log {
1295 endpoints.push(" GET /v1/routing/session-stats");
1296 }
1297 endpoints.join("\n")
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use libsy::LlmCallObservation;
1303 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1304 use tokio::sync::{Notify, oneshot};
1305
1306 use super::*;
1307
1308 #[test]
1313 fn stats_observer_logs_judge_calls_to_the_routing_log() {
1314 let dir = tempfile::tempdir().expect("temp dir");
1315 let log = SharedRoutingLog::new(dir.path().join("routing.jsonl")).expect("routing log");
1316 let mut headers = HeaderMap::new();
1317 headers.insert("proxy_x_session_id", "session-1".parse().expect("header"));
1318 let context = routing_log::RoutingLogContext::from_headers(&headers);
1319 let observer = stats_observer(StatsAccumulator::default(), Some((log.clone(), context)));
1320
1321 let call = |model: &str, is_routed: bool| {
1322 RunObservation::LlmCall(LlmCallObservation {
1323 selected_model: model.to_string(),
1324 tier: None,
1325 is_routed,
1326 is_success: true,
1327 duration: Duration::from_millis(3),
1328 usage: Some(Usage {
1329 input_tokens: Some(100),
1330 output_tokens: Some(7),
1331 ..Usage::default()
1332 }),
1333 })
1334 };
1335 observer(call("judge-model", false));
1336 observer(call("routed-model", true));
1337
1338 let snapshot = log
1339 .snapshot_session("session-1")
1340 .expect("read log")
1341 .expect("session recorded");
1342 let snapshot = serde_json::to_value(&snapshot).expect("serializable snapshot");
1343 assert_eq!(snapshot["models"]["judge-model"]["calls"], 1);
1344 assert_eq!(snapshot["models"]["judge-model"]["prompt_tokens"], 100);
1345 assert_eq!(snapshot["models"]["judge-model"]["completion_tokens"], 7);
1346 assert!(snapshot["models"].get("routed-model").is_none());
1347 }
1348
1349 #[derive(Clone)]
1350 struct ShutdownTestState {
1351 started: Arc<Notify>,
1352 release: Arc<Notify>,
1353 }
1354
1355 struct ShutdownTestServer {
1356 state: ShutdownTestState,
1357 shutdown: oneshot::Sender<()>,
1358 server: task::JoinHandle<ServerResult<()>>,
1359 request: task::JoinHandle<std::io::Result<Vec<u8>>>,
1360 }
1361
1362 async fn blocked_request(State(state): State<ShutdownTestState>) -> &'static str {
1363 state.started.notify_one();
1364 state.release.notified().await;
1365 "done"
1366 }
1367
1368 async fn raw_request(addr: SocketAddr) -> std::io::Result<Vec<u8>> {
1369 let mut stream = tokio::net::TcpStream::connect(addr).await?;
1370 stream
1371 .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
1372 .await?;
1373 let mut response = Vec::new();
1374 stream.read_to_end(&mut response).await?;
1375 Ok(response)
1376 }
1377
1378 fn shutdown_test_server(shutdown_timeout: Duration) -> ShutdownTestServer {
1379 let state = ShutdownTestState {
1380 started: Arc::new(Notify::new()),
1381 release: Arc::new(Notify::new()),
1382 };
1383 let router = Router::new()
1384 .route("/", get(blocked_request))
1385 .with_state(state.clone());
1386 let listener = bind_tcp_listener("127.0.0.1:0".parse().expect("valid address"), 16)
1387 .expect("listener binds");
1388 let addr = listener.local_addr().expect("listener has an address");
1389 let (shutdown, shutdown_receiver) = oneshot::channel();
1390 let server = tokio::spawn(serve(listener, router, shutdown_timeout, async move {
1391 let _ = shutdown_receiver.await;
1392 }));
1393 let request = tokio::spawn(raw_request(addr));
1394 ShutdownTestServer {
1395 state,
1396 shutdown,
1397 server,
1398 request,
1399 }
1400 }
1401
1402 #[tokio::test]
1404 async fn shutdown_drains_until_configured_deadline() {
1405 let ShutdownTestServer {
1406 state,
1407 shutdown,
1408 mut server,
1409 request,
1410 } = shutdown_test_server(Duration::from_secs(1));
1411 state.started.notified().await;
1412 shutdown.send(()).expect("server receives shutdown");
1413 assert!(
1414 tokio::time::timeout(Duration::from_millis(25), &mut server)
1415 .await
1416 .is_err(),
1417 "server must wait for the active request"
1418 );
1419 state.release.notify_one();
1420 tokio::time::timeout(Duration::from_secs(1), server)
1421 .await
1422 .expect("server stops after request drains")
1423 .expect("server task completes")
1424 .expect("server exits cleanly");
1425 let response = request
1426 .await
1427 .expect("request task completes")
1428 .expect("request succeeds");
1429 assert!(response.windows(8).any(|part| part == b"200 OK\r\n"));
1430 assert!(response.ends_with(b"done"));
1431
1432 let ShutdownTestServer {
1433 state,
1434 shutdown,
1435 server,
1436 request,
1437 } = shutdown_test_server(Duration::from_millis(25));
1438 state.started.notified().await;
1439 shutdown.send(()).expect("server receives shutdown");
1440 tokio::time::timeout(Duration::from_secs(1), server)
1441 .await
1442 .expect("shutdown deadline is enforced")
1443 .expect("server task completes")
1444 .expect("server exits cleanly");
1445 state.release.notify_one();
1446 request.abort();
1447 }
1448
1449 #[test]
1451 fn request_log_level_follows_http_status() {
1452 assert_eq!(request_log_level(StatusCode::OK), Level::INFO);
1453 assert_eq!(request_log_level(StatusCode::BAD_REQUEST), Level::WARN);
1454 assert_eq!(
1455 request_log_level(StatusCode::INTERNAL_SERVER_ERROR),
1456 Level::ERROR
1457 );
1458 }
1459
1460 #[test]
1462 fn error_response_carries_request_log_error() {
1463 let response = error_response(
1464 StatusCode::BAD_REQUEST,
1465 "invalid request",
1466 "invalid_request_error",
1467 "invalid_request_error",
1468 );
1469
1470 assert_eq!(
1471 response
1472 .extensions()
1473 .get::<RequestLogError>()
1474 .map(|error| error.0.as_str()),
1475 Some("invalid request")
1476 );
1477 }
1478}