Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,14 @@ use switchyard_protocol::{
LlmRequest, LlmResponse, LlmResponseChunk, LlmResponseStreamEvent, Metadata, ModelId, Request,
Response, RoutedLlmClient,
};
use switchyard_translation::{
WireFormat, decode_aggregated_response, decode_request, decode_stream,
encode_aggregated_response_with_extensions, encode_request, encode_stream_with_extensions,
};
use switchyard_translation::{WireFormat, decode_stream, encode_stream_with_extensions};
use tracing::Instrument;

use crate::backend::Backend;
use crate::error::{LlmClientError, Result};
use crate::metrics;
use crate::raw::RawResponse;
use crate::translation;

// Headers this client owns or that are hop-by-hop. Backends apply an explicitly
// enabled caller credential after generic metadata forwarding skips these.
Expand Down Expand Up @@ -240,7 +238,7 @@ impl TranslatingLlmClient {
model: &ModelId,
endpoint: UpstreamEndpoint,
) -> Result<EncodedResponse> {
let mut body = encode_request(&llm_request, wire_format)
let mut body = translation::encode_request(&llm_request, wire_format)
.map_err(|error| LlmClientError::RequestEncoding(error.to_string()))?;
// `encode_request` round-trips a preserved same-format body verbatim,
// which keeps the caller's original `model`; force the resolved model so
Expand Down Expand Up @@ -501,7 +499,7 @@ impl TranslatingLlmClient {
let body = serde_json::from_slice::<Value>(&body).map_err(|error| {
LlmClientError::ResponseTranslation(format!("invalid upstream JSON: {error}"))
})?;
let agg = decode_aggregated_response(&body, wire_format)
let agg = translation::decode_response(&body, wire_format)
.map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
LlmResponse::Agg(agg)
}
Expand Down Expand Up @@ -534,7 +532,7 @@ impl TranslatingLlmClient {
model: Option<&ModelId>,
wire_format: WireFormat,
) -> Result<RawResponse> {
let llm_request = decode_request(wire_format, &raw_http_request)
let llm_request = translation::decode_request(wire_format, &raw_http_request)
.map_err(|error| LlmClientError::RequestTranslation(error.to_string()))?;
let request_extensions = llm_request.extensions.clone();
// The model that serves the call — the rewrite target when the caller pinned
Expand Down Expand Up @@ -562,7 +560,7 @@ impl TranslatingLlmClient {

match response.llm_response {
LlmResponse::Agg(agg) => {
let body = encode_aggregated_response_with_extensions(
let body = translation::encode_response(
&agg,
wire_format,
served_model.as_deref(),
Expand Down
1 change: 1 addition & 0 deletions crates/libsy-llm-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ mod observability;
mod observation;
pub mod raw;
pub mod run;
mod translation;

pub use backend::{Backend, DEFAULT_MAX_RETRIES, HttpBackendConfig};
pub use client::{AuxiliaryOperation, ModelConfig, TranslatingLlmClient};
Expand Down
103 changes: 102 additions & 1 deletion crates/libsy-llm-client/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use std::{
use opentelemetry::metrics::ObservableGauge;
use opentelemetry::{KeyValue, global};
use switchyard_libsy::Result;
use switchyard_protocol::{ModelId, Response};
use switchyard_protocol::{ModelId, Response, WireFormat};
use switchyard_translation::{DiagnosticSeverity, TranslationDiagnostic};

static TOTAL_REQUESTS: AtomicU64 = AtomicU64::new(0);
static TOTAL_ERRORS: AtomicU64 = AtomicU64::new(0);
Expand Down Expand Up @@ -104,6 +105,47 @@ pub(crate) fn record_retry_recovered() {
.add(1, &[]);
}

// Records translation diagnostics without putting request-derived values in metric labels.
pub(crate) fn record_translation_diagnostics(
diagnostics: &[TranslationDiagnostic],
operation: &'static str,
format: WireFormat,
) {
for diagnostic in diagnostics {
let severity = diagnostic_severity_label(&diagnostic.severity);
global::meter("switchyard")
.u64_counter("switchyard.translation_diagnostics")
.build()
.add(
1,
&[
KeyValue::new("code", diagnostic.code.clone()),
KeyValue::new("format", format.as_str()),
KeyValue::new("operation", operation),
KeyValue::new("severity", severity),
],
);
tracing::warn!(
target: "libsy",
code = %diagnostic.code,
format = format.as_str(),
operation,
severity,
diagnostic = %diagnostic.message,
path = diagnostic.path.as_deref().unwrap_or(""),
"LLM protocol translation emitted a diagnostic"
);
}
}

const fn diagnostic_severity_label(severity: &DiagnosticSeverity) -> &'static str {
match severity {
DiagnosticSeverity::Info => "info",
DiagnosticSeverity::Warning => "warning",
DiagnosticSeverity::Error => "error",
}
}

/// Records the time needed to produce the routing outcome, including classifier calls,
/// target resolution, request rewrites, and decision publishing.
pub(crate) fn record_routing_overhead(algorithm: &str, overhead: Duration) {
Expand Down Expand Up @@ -170,8 +212,25 @@ pub(crate) fn record_routed_request(

#[cfg(test)]
mod tests {
use std::io::{self, Write};
use std::sync::{Arc, Mutex};

use super::*;

#[derive(Default)]
struct LogBuffer(Mutex<Vec<u8>>);

impl Write for &LogBuffer {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0.lock().expect("log buffer lock").extend(bytes);
Ok(bytes.len())
}

fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}

#[test]
fn outcome_labels_match_the_retry_policy() {
assert_eq!(http_outcome_label(Some(200)), "ok");
Expand All @@ -185,4 +244,46 @@ mod tests {
}
assert_eq!(http_outcome_label(None), "retryable_error");
}

// One diagnostic produces one warning containing its structured troubleshooting fields.
#[test]
fn translation_diagnostic_emits_one_structured_warning() {
let output = Arc::new(LogBuffer::default());
let subscriber = tracing_subscriber::fmt()
.with_ansi(false)
.without_time()
.with_writer(output.clone())
.finish();
tracing::subscriber::with_default(subscriber, || {
record_translation_diagnostics(
&[TranslationDiagnostic::warning(
"lossy_conversion",
"dropped unsupported JSON Schema constraints",
)
.at_path("$.response_format")],
"request_encode",
WireFormat::AnthropicMessages,
);
});

let bytes = output.0.lock().expect("log buffer lock").clone();
let logs = String::from_utf8(bytes).expect("captured log must be UTF-8");
let warnings = logs
.lines()
.filter(|line| line.contains("LLM protocol translation emitted a diagnostic"))
.collect::<Vec<_>>();
assert_eq!(warnings.len(), 1, "diagnostic warnings: {logs}");
for field in [
"WARN",
"libsy",
"lossy_conversion",
"anthropic_messages",
"request_encode",
"warning",
"dropped unsupported JSON Schema constraints",
"$.response_format",
] {
assert!(warnings[0].contains(field), "missing {field:?} in {logs}");
}
}
}
48 changes: 48 additions & 0 deletions crates/libsy-llm-client/src/translation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Buffered translation with runtime diagnostics for the HTTP client.

use std::sync::LazyLock;

use serde_json::Value;
use switchyard_protocol::{AggLlmResponse, LlmRequest, ProviderExtensions};
use switchyard_translation::{Result, TranslationEngine, TranslationPolicy, WireFormat};

use crate::metrics;

static ENGINE: LazyLock<TranslationEngine> = LazyLock::new(TranslationEngine::default);
static POLICY: LazyLock<TranslationPolicy> = LazyLock::new(TranslationPolicy::default);

pub(crate) fn decode_request(format: WireFormat, body: &Value) -> Result<LlmRequest> {
let decoded = ENGINE.decode_request(format, body, &POLICY)?;
metrics::record_translation_diagnostics(&decoded.diagnostics, "request_decode", format);
Ok(decoded.request)
}

pub(crate) fn encode_request(request: &LlmRequest, format: WireFormat) -> Result<Value> {
let encoded = ENGINE.encode_request(format, request, &POLICY)?;
metrics::record_translation_diagnostics(&encoded.diagnostics, "request_encode", format);
Ok(encoded.body)
}

pub(crate) fn decode_response(body: &Value, format: WireFormat) -> Result<AggLlmResponse> {
let decoded = ENGINE.decode_response(format, body, &POLICY)?;
metrics::record_translation_diagnostics(&decoded.diagnostics, "response_decode", format);
Ok(decoded.response)
}

pub(crate) fn encode_response(
response: &AggLlmResponse,
format: WireFormat,
served_model: Option<&str>,
request_extensions: &ProviderExtensions,
) -> Result<Value> {
let mut encoded =
ENGINE.encode_response_with_extensions(format, response, request_extensions, &POLICY)?;
metrics::record_translation_diagnostics(&encoded.diagnostics, "response_encode", format);
if let (Some(model), Value::Object(body)) = (served_model, &mut encoded.body) {
body.insert("model".to_string(), Value::String(model.to_string()));
}
Ok(encoded.body)
}
10 changes: 6 additions & 4 deletions crates/switchyard-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod routing_log;
mod shutdown;
mod sse;
mod stats;
mod translation;
mod usage_metrics;

use std::collections::BTreeMap;
Expand Down Expand Up @@ -38,15 +39,15 @@ use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use switchyard_llm_client::{AuxiliaryOperation, ClientRouter, RunObservation, RunObserver};
use switchyard_protocol::{LlmClientError, Metadata, ModelId, Request, Usage};
use switchyard_protocol::{LlmClientError, Metadata, ModelId, ProviderExtensions, Request, Usage};
use switchyard_runner::{
CallerAuthKind, DecisionTarget, ModelCapabilities, Route, RunOutput, Runner, RunnerError,
};
use tokio::net::{TcpListener, TcpSocket};
use tokio::task;
use tracing::{Instrument, Level};

use switchyard_translation::{WireFormat, decode_request, encode_aggregated_response};
use switchyard_translation::WireFormat;

use crate::response::into_http_response;
use crate::stats::{StatsAccumulator, StatsSnapshot, prefix_probe, tracking_enabled_from_env};
Expand Down Expand Up @@ -738,10 +739,11 @@ async fn decision(
// The request moved into the decision run, so its namespace mapping
// is gone by here. A Codex tool call in this preview keeps its
// qualified name.
match encode_aggregated_response(
match translation::encode_response(
&aggregate,
input_format,
outcome.selected_model_id().ok().map(ModelId::as_str),
&ProviderExtensions::default(),
) {
Ok(response) => Some(response),
Err(error) => return server_error(error.to_string()),
Expand Down Expand Up @@ -959,7 +961,7 @@ fn resolve_route(
body: Value,
wire_format: WireFormat,
) -> std::result::Result<(&Route, Request), Response> {
let llm_request = decode_request(wire_format, &body)
let llm_request = translation::decode_request(wire_format, &body)
.map_err(|error| invalid_body_error(StatusCode::BAD_REQUEST, error.to_string()))?;
let requested_model = llm_request
.model
Expand Down
39 changes: 39 additions & 0 deletions crates/switchyard-server/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use opentelemetry::{KeyValue, global};
use opentelemetry_sdk::metrics::{Aggregation, Instrument, SdkMeterProvider, Stream};
use prometheus::{Encoder, Registry, TextEncoder};
use switchyard_llm_client::metrics::{http_outcome_label, http_status_code_label};
use switchyard_protocol::WireFormat;
use switchyard_translation::{DiagnosticSeverity, TranslationDiagnostic};

pub(crate) const CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";

Expand Down Expand Up @@ -162,6 +164,43 @@ pub(crate) fn record_client_response(status: u16) {
);
}

// Records server-boundary diagnostics with bounded metric labels and request details only in logs.
pub(crate) fn record_translation_diagnostics(
diagnostics: &[TranslationDiagnostic],
operation: &'static str,
format: WireFormat,
) {
for diagnostic in diagnostics {
let severity = match diagnostic.severity {
DiagnosticSeverity::Info => "info",
DiagnosticSeverity::Warning => "warning",
DiagnosticSeverity::Error => "error",
};
global::meter("switchyard")
.u64_counter("switchyard.translation_diagnostics")
.build()
.add(
1,
&[
KeyValue::new("code", diagnostic.code.clone()),
KeyValue::new("format", format.as_str()),
KeyValue::new("operation", operation),
KeyValue::new("severity", severity),
],
);
tracing::warn!(
target: "libsy",
code = %diagnostic.code,
format = format.as_str(),
operation,
severity,
diagnostic = %diagnostic.message,
path = diagnostic.path.as_deref().unwrap_or(""),
"LLM protocol translation emitted a diagnostic"
);
}
}

/// Encodes the current cumulative metric values in Prometheus text format.
pub(crate) fn encode(registry: &Registry) -> Result<Vec<u8>, String> {
let mut body = Vec::new();
Expand Down
7 changes: 3 additions & 4 deletions crates/switchyard-server/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ use std::error::Error;
use axum::Json;
use axum::response::{IntoResponse, Response as HttpResponse};
use switchyard_protocol::{LlmResponse, ProviderExtensions, Response as AlgorithmResponse};
use switchyard_translation::{
WireFormat, encode_aggregated_response_with_extensions, encode_stream_with_extensions,
};
use switchyard_translation::{WireFormat, encode_stream_with_extensions};

use crate::sse::frame_stream;
use crate::translation;

type BoxError = Box<dyn Error + Send + Sync>;

Expand All @@ -27,7 +26,7 @@ pub(crate) fn into_http_response(
) -> Result<HttpResponse, BoxError> {
match response.llm_response {
LlmResponse::Agg(response) => {
let body = encode_aggregated_response_with_extensions(
let body = translation::encode_response(
&response,
target_format,
served_model.as_deref(),
Expand Down
Loading