Skip to content

Commit db9f11e

Browse files
feat(server): add fallback client proxy
Signed-off-by: nachiketb <nachiketb@nvidia.com>
1 parent 463a62c commit db9f11e

15 files changed

Lines changed: 187 additions & 357 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/libsy-llm-client/src/backend.rs

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -255,17 +255,19 @@ impl Backend {
255255
self.config().max_retries
256256
}
257257

258-
/// Whether this backend speaks the Anthropic Messages wire format — the only
259-
/// one with a `count_tokens` endpoint.
260-
pub fn is_anthropic(&self) -> bool {
261-
matches!(self, Backend::Anthropic(_))
262-
}
263-
264-
/// The upstream `/v1/messages/count_tokens` URL, derived from the same base
265-
/// URL join as [`url`](Self::url).
266-
pub fn count_tokens_url(&self) -> String {
258+
/// Resolves an unmatched provider path against this backend's API root.
259+
pub(crate) fn forwarding_url(&self, path_and_query: &str) -> String {
267260
let base_url = self.config().base_url.trim_end_matches('/');
268-
format!("{}/count_tokens", anthropic_url(base_url))
261+
let root = [
262+
"/v1/chat/completions",
263+
"/v1/responses",
264+
"/v1/messages",
265+
"/v1",
266+
]
267+
.iter()
268+
.find_map(|suffix| base_url.strip_suffix(suffix))
269+
.unwrap_or(base_url);
270+
format!("{root}{path_and_query}")
269271
}
270272

271273
/// Whether an upstream 400 `body` looks like a context-window overflow for
@@ -391,34 +393,6 @@ mod tests {
391393
);
392394
}
393395

394-
#[test]
395-
fn count_tokens_url_joins_every_base_url_shape() {
396-
assert_eq!(
397-
Backend::Anthropic(config("https://host")).count_tokens_url(),
398-
"https://host/v1/messages/count_tokens"
399-
);
400-
assert_eq!(
401-
Backend::Anthropic(config("https://host/v1")).count_tokens_url(),
402-
"https://host/v1/messages/count_tokens"
403-
);
404-
assert_eq!(
405-
Backend::Anthropic(config("https://host/v1/messages")).count_tokens_url(),
406-
"https://host/v1/messages/count_tokens"
407-
);
408-
// Trailing slash is trimmed before the join.
409-
assert_eq!(
410-
Backend::Anthropic(config("https://host/v1/")).count_tokens_url(),
411-
"https://host/v1/messages/count_tokens"
412-
);
413-
}
414-
415-
#[test]
416-
fn only_anthropic_backend_is_anthropic() {
417-
assert!(Backend::Anthropic(config("x")).is_anthropic());
418-
assert!(!Backend::OpenAiChat(config("x")).is_anthropic());
419-
assert!(!Backend::OpenAiResponses(config("x")).is_anthropic());
420-
}
421-
422396
#[test]
423397
fn wire_format_matches_variant() {
424398
assert_eq!(

crates/libsy-llm-client/src/client.rs

Lines changed: 34 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ use std::time::{Duration, SystemTime};
1010

1111
use async_trait::async_trait;
1212
use futures_util::{StreamExt, stream};
13-
use http::StatusCode;
13+
use http::{Method, StatusCode};
1414
use reqwest::RequestBuilder;
15-
use reqwest::header::{HeaderMap, RETRY_AFTER};
15+
use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, RETRY_AFTER};
1616
use serde_json::{Map, Value};
1717
use switchyard_protocol::{
1818
LlmRequest, LlmResponse, LlmResponseChunk, LlmResponseStreamEvent, Metadata, ModelId, Request,
@@ -146,49 +146,35 @@ impl TranslatingLlmClient {
146146
})
147147
}
148148

149-
/// Whether `model` has an Anthropic backend that supports token counting.
150-
pub fn supports_count_tokens(&self, model: &ModelId) -> bool {
151-
self.backend_for(model, WireFormat::AnthropicMessages)
152-
.is_some()
153-
}
154-
155-
/// Counts input tokens with `model`'s Anthropic backend.
156-
///
157-
/// Returns an error when the model has no Anthropic backend or the upstream
158-
/// request fails or returns invalid JSON.
159-
pub async fn count_tokens(&self, model: &ModelId, request: Request) -> Result<Value> {
160-
let backend = self
161-
.backend_for(model, WireFormat::AnthropicMessages)
162-
.ok_or_else(|| LlmClientError::Configuration {
163-
message: format!("model {model} has no Anthropic backend for count_tokens"),
164-
})?;
165-
let Request {
166-
mut llm_request,
167-
metadata,
168-
..
169-
} = request;
170-
llm_request.model = Some(model.to_string());
171-
let http_response = self
172-
.send_encoded(
173-
backend,
174-
WireFormat::AnthropicMessages,
175-
llm_request,
176-
metadata.as_ref(),
177-
model,
178-
UpstreamEndpoint::CountTokens,
179-
)
180-
.await?;
181-
let body = match http_response {
182-
EncodedResponse::Buffered { body, .. } => body,
183-
EncodedResponse::Streaming(_) => {
184-
return Err(LlmClientError::InvalidRequest {
185-
message: "count_tokens does not support streaming requests".to_string(),
186-
});
187-
}
149+
/// Forwards a provider-native request through `backend` without translation.
150+
pub async fn forward(
151+
&self,
152+
backend: &Backend,
153+
method: Method,
154+
path_and_query: &str,
155+
body: reqwest::Body,
156+
metadata: Option<&Metadata>,
157+
) -> Result<reqwest::Response> {
158+
let client = if backend.is_forwarding_auth() {
159+
&self.forward_auth_client
160+
} else {
161+
&self.client
188162
};
189-
serde_json::from_slice(&body).map_err(|error| LlmClientError::InvalidResponse {
190-
source: Box::new(error),
191-
})
163+
let mut builder = client
164+
.request(method, backend.forwarding_url(path_and_query))
165+
.body(body);
166+
builder = forward_metadata_headers(builder, metadata);
167+
if let Some(headers) = metadata.and_then(|metadata| metadata.http_headers.as_ref()) {
168+
for name in [CONTENT_TYPE, CONTENT_LENGTH] {
169+
if let Some(value) = headers.get(&name) {
170+
builder = builder.header(name, value);
171+
}
172+
}
173+
}
174+
builder = backend.apply_forwarded_auth(builder, metadata);
175+
builder = apply_extra_headers(builder, backend);
176+
builder = backend.apply_auth(builder);
177+
builder.send().await.map_err(convert_reqwest_error)
192178
}
193179

194180
/// Encode `llm_request` for `wire_format`, POST it to `url` with the request's
@@ -198,18 +184,15 @@ impl TranslatingLlmClient {
198184
/// response is returned as soon as its successful headers arrive. A non-success
199185
/// status maps to a typed error — a 400 is classified as a context-window
200186
/// overflow via the backend's provider rules. Shared by
201-
/// [`call_rewrite_model`](Self::call_rewrite_model) (which POSTs to the
202-
/// backend's completion URL and decodes a response) and
203-
/// [`count_tokens`](Self::count_tokens) (which POSTs to the `count_tokens`
204-
/// URL and returns the raw JSON).
187+
/// [`call_rewrite_model`](Self::call_rewrite_model), which POSTs to the
188+
/// backend's completion URL and decodes a response.
205189
async fn send_encoded(
206190
&self,
207191
backend: &Backend,
208192
wire_format: WireFormat,
209193
llm_request: LlmRequest,
210194
metadata: Option<&Metadata>,
211195
model: &ModelId,
212-
endpoint: UpstreamEndpoint,
213196
) -> Result<EncodedResponse> {
214197
let mut body = encode_request(&llm_request, wire_format)
215198
.map_err(|error| LlmClientError::RequestEncoding(error.to_string()))?;
@@ -230,9 +213,8 @@ impl TranslatingLlmClient {
230213
if matches!(backend, Backend::OpenAiChat(_)) {
231214
ensure_openai_stream_usage(&mut body);
232215
}
233-
let streaming = endpoint.allows_streaming()
234-
&& body.get("stream").and_then(Value::as_bool).unwrap_or(false);
235-
let url = endpoint.url(backend);
216+
let streaming = body.get("stream").and_then(Value::as_bool).unwrap_or(false);
217+
let url = backend.url();
236218
record_gen_ai_request(&url, model, streaming);
237219

238220
let max_retries = u64::from(backend.max_retries());
@@ -426,7 +408,6 @@ impl TranslatingLlmClient {
426408
llm_request,
427409
metadata.as_ref(),
428410
&model_id,
429-
UpstreamEndpoint::Completion,
430411
)
431412
.await?;
432413

@@ -559,25 +540,6 @@ impl RoutedLlmClient for TranslatingLlmClient {
559540
}
560541
}
561542

562-
#[derive(Clone, Copy)]
563-
enum UpstreamEndpoint {
564-
Completion,
565-
CountTokens,
566-
}
567-
568-
impl UpstreamEndpoint {
569-
fn url(self, backend: &Backend) -> String {
570-
match self {
571-
UpstreamEndpoint::Completion => backend.url(),
572-
UpstreamEndpoint::CountTokens => backend.count_tokens_url(),
573-
}
574-
}
575-
576-
fn allows_streaming(self) -> bool {
577-
matches!(self, UpstreamEndpoint::Completion)
578-
}
579-
}
580-
581543
enum EncodedResponse {
582544
Buffered { status: u16, body: Vec<u8> },
583545
Streaming(reqwest::Response),

crates/switchyard-runner/src/algorithm.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,7 @@ impl AlgorithmSpec {
467467
names
468468
}
469469
// The advisor is judge-only: reviews go through its own client,
470-
// so it is not a completion (or count_tokens) destination.
470+
// so it is not a completion destination.
471471
Self::Advisor {
472472
executor_target, ..
473473
} => vec![executor_target],

crates/switchyard-runner/src/config.rs

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ use switchyard_llm_client::{
1717
};
1818
use switchyard_protocol::{ModelId, RoutedLlmClient, WireFormat};
1919

20+
use crate::runner::FallbackClient;
2021
use crate::{
21-
AlgorithmSpec, CallerAuthKind, CountTokensTarget, DecisionTarget, ModelCapabilities, Route,
22-
Runner, RunnerError,
22+
AlgorithmSpec, CallerAuthKind, DecisionTarget, ModelCapabilities, Route, Runner, RunnerError,
2323
};
2424

2525
const SUPPORTED_SCHEMA_VERSION: u32 = 1;
@@ -54,6 +54,7 @@ pub(crate) fn runner_from_toml(source: &str) -> RunnerResult<Runner> {
5454
#[serde(deny_unknown_fields)]
5555
pub(crate) struct DeploymentConfig {
5656
schema_version: u32,
57+
fallback_client: Option<String>,
5758
#[serde(default)]
5859
llm_clients: BTreeMap<String, LlmClientConfig>,
5960
targets: BTreeMap<String, TargetConfig>,
@@ -165,6 +166,7 @@ impl DeploymentConfig {
165166

166167
let clients = self.build_clients()?;
167168
let targets = self.build_targets();
169+
let fallback_client = self.build_fallback_client(&clients)?;
168170
let mut routes = Vec::with_capacity(self.routes.len());
169171
for (route_name, config) in &self.routes {
170172
validate_value("route name", route_name)?;
@@ -188,7 +190,6 @@ impl DeploymentConfig {
188190
.map_err(|error| RunnerError::configuration_source(error.to_string(), error))?;
189191
let (route_clients, caller_auth) =
190192
self.build_route_clients(route_name, config, &clients)?;
191-
let count_tokens_target = self.build_count_tokens_target(config, &clients);
192193
let decision_targets = config
193194
.routing_target_names()
194195
.into_iter()
@@ -199,12 +200,11 @@ impl DeploymentConfig {
199200
route_clients,
200201
caller_auth,
201202
capabilities,
202-
count_tokens_target,
203203
decision_targets,
204204
);
205205
routes.push((config.id.clone(), route));
206206
}
207-
Ok(Runner::new(routes))
207+
Ok(Runner::new(routes).with_fallback_client(fallback_client))
208208
}
209209

210210
fn build_clients(&self) -> RunnerResult<BTreeMap<String, Arc<TranslatingLlmClient>>> {
@@ -291,42 +291,28 @@ impl DeploymentConfig {
291291
Ok((ClientRouter::new(by_model), caller_auth))
292292
}
293293

294-
fn build_count_tokens_target(
294+
fn build_fallback_client(
295295
&self,
296-
route: &RouteConfig,
297296
clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
298-
) -> Option<CountTokensTarget> {
299-
route
300-
.routing_target_names()
301-
.into_iter()
302-
.enumerate()
303-
.filter_map(|(index, name)| {
304-
let target = self.targets.get(name)?;
305-
let client = clients.get(&target.llm_client)?;
306-
client.supports_count_tokens(&target.id).then_some((
307-
count_tokens_priority(name, &target.id),
308-
index,
309-
target,
310-
client,
311-
))
312-
})
313-
.min_by_key(|(priority, index, _, _)| (*priority, *index))
314-
.map(|(_, _, target, client)| CountTokensTarget {
315-
model: target.id.clone(),
316-
client: client.clone(),
317-
})
297+
) -> RunnerResult<Option<FallbackClient>> {
298+
let Some(name) = &self.fallback_client else {
299+
return Ok(None);
300+
};
301+
let config = self.llm_clients.get(name).ok_or_else(|| {
302+
RunnerError::configuration(format!(
303+
"fallback_client references unknown llm client {name}"
304+
))
305+
})?;
306+
let client = clients.get(name).ok_or_else(|| {
307+
RunnerError::configuration("validated fallback client was not initialized")
308+
})?;
309+
Ok(Some(FallbackClient {
310+
backend: build_backend(name, config, &BTreeMap::new())?,
311+
client: client.clone(),
312+
}))
318313
}
319314
}
320315

321-
fn count_tokens_priority(target_name: &str, model_id: &ModelId) -> usize {
322-
let target_name = target_name.to_ascii_lowercase();
323-
let model_id = model_id.to_ascii_lowercase();
324-
["opus", "sonnet", "haiku"]
325-
.iter()
326-
.position(|hint| target_name.contains(hint) || model_id.contains(hint))
327-
.unwrap_or(3)
328-
}
329-
330316
/// A client endpoint, parsed when the config loads rather than checked afterwards.
331317
///
332318
/// Holding a `HttpBaseUrl` is proof the value is an absolute HTTP(S) URL, so no

crates/switchyard-runner/src/failure.rs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,7 @@ impl RunnerError {
9898
None,
9999
None,
100100
),
101-
Self::UnknownRouteModel(_)
102-
| Self::IncompatibleCallerFormat(_)
103-
| Self::CountTokensUnsupported => summary(
101+
Self::UnknownRouteModel(_) | Self::IncompatibleCallerFormat(_) => summary(
104102
RouteErrorKind::InvalidRequest,
105103
RouteErrorPhase::BeforeResponse,
106104
None,
@@ -328,11 +326,5 @@ mod tests {
328326
configuration.execution_error_summary().kind,
329327
RouteErrorKind::Configuration
330328
));
331-
332-
let unsupported = RunnerError::CountTokensUnsupported;
333-
assert!(matches!(
334-
unsupported.execution_error_summary().kind,
335-
RouteErrorKind::InvalidRequest
336-
));
337329
}
338330
}

crates/switchyard-runner/src/lib.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,5 @@ pub use algorithm::{
1414
ClassifierPolicyConfig, LlmClassifierRouteConfig, StageClassifierConfig, SubagentRouteConfig,
1515
};
1616
pub use failure::{RouteErrorKind, RouteErrorPhase, RouteErrorSummary, stream_error_summary};
17-
pub use route::{
18-
CallerAuthKind, CountTokensTarget, ModelCapabilities, Route, RunOutput, RunnerError,
19-
};
17+
pub use route::{CallerAuthKind, ModelCapabilities, Route, RunOutput, RunnerError};
2018
pub use runner::{DecisionDescription, DecisionTarget, ModelInfo, Runner};

0 commit comments

Comments
 (0)