Skip to content

Commit 0e6b8bd

Browse files
feat(server): proxy auxiliary OpenAI endpoints
Signed-off-by: nachiketb <nachiketb@nvidia.com>
1 parent c665174 commit 0e6b8bd

10 files changed

Lines changed: 285 additions & 23 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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,11 @@ impl Backend {
268268
format!("{}/count_tokens", anthropic_url(base_url))
269269
}
270270

271+
// Resolves an OpenAI endpoint from the same configured base URL as Responses calls.
272+
pub(crate) fn openai_endpoint_url(&self, suffix: &str) -> String {
273+
openai_url(self.config().base_url.trim_end_matches('/'), suffix)
274+
}
275+
271276
/// Whether an upstream 400 `body` looks like a context-window overflow for
272277
/// this backend's provider.
273278
pub(crate) fn is_context_overflow(&self, body: &str) -> bool {

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

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use async_trait::async_trait;
1111
use futures_util::StreamExt;
1212
use http::StatusCode;
1313
use reqwest::RequestBuilder;
14-
use reqwest::header::{HeaderMap, RETRY_AFTER};
14+
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue, RETRY_AFTER};
1515
use serde_json::{Map, Value};
1616
use switchyard_protocol::{
1717
LlmRequest, LlmResponse, Metadata, ModelId, Request, Response, RoutedLlmClient,
@@ -94,6 +94,31 @@ pub struct TranslatingLlmClient {
9494
forward_auth_client: reqwest::Client,
9595
}
9696

97+
/// Provider-native OpenAI request proxied without translation.
98+
pub enum OpenAiPassthroughRequest {
99+
/// Count input tokens using `POST /v1/responses/input_tokens`.
100+
ResponsesInputTokens(Value),
101+
/// Compact conversation input using `POST /v1/responses/compact`.
102+
ResponsesCompact(Value),
103+
/// Upload multipart form data using `POST /v1/files`.
104+
File {
105+
/// Uninspected multipart request body.
106+
body: reqwest::Body,
107+
/// Multipart content type, including its boundary.
108+
content_type: HeaderValue,
109+
},
110+
}
111+
112+
impl OpenAiPassthroughRequest {
113+
const fn suffix(&self) -> &'static str {
114+
match self {
115+
Self::ResponsesInputTokens(_) => "/responses/input_tokens",
116+
Self::ResponsesCompact(_) => "/responses/compact",
117+
Self::File { .. } => "/files",
118+
}
119+
}
120+
}
121+
97122
impl TranslatingLlmClient {
98123
/// Builds a client over the given [`ModelConfig`]s, with a fresh shared HTTP
99124
/// client and the built-in translation codecs.
@@ -150,6 +175,62 @@ impl TranslatingLlmClient {
150175
.is_some()
151176
}
152177

178+
/// Proxies an auxiliary OpenAI request through `model`'s Responses backend.
179+
///
180+
/// Responses JSON remains provider-native except that `model` is replaced with
181+
/// the configured upstream model id. File bodies and all responses remain uninspected.
182+
pub async fn passthrough_openai(
183+
&self,
184+
model: &ModelId,
185+
request: OpenAiPassthroughRequest,
186+
metadata: Option<&Metadata>,
187+
) -> Result<reqwest::Response> {
188+
let backend = self
189+
.backend_for(model, WireFormat::OpenAiResponses)
190+
.ok_or_else(|| LlmClientError::Configuration {
191+
message: format!("model {model} has no OpenAI Responses backend"),
192+
})?;
193+
let url = backend.openai_endpoint_url(request.suffix());
194+
let builder = match request {
195+
OpenAiPassthroughRequest::ResponsesInputTokens(mut body)
196+
| OpenAiPassthroughRequest::ResponsesCompact(mut body) => {
197+
if !body.is_object() {
198+
return Err(LlmClientError::InvalidRequest {
199+
message: "request body must be a JSON object".to_string(),
200+
});
201+
}
202+
set_json_model(&mut body, model);
203+
self.http_client(backend).post(url).json(&body)
204+
}
205+
OpenAiPassthroughRequest::File { body, content_type } => self
206+
.http_client(backend)
207+
.post(url)
208+
.header(CONTENT_TYPE, content_type)
209+
.body(body),
210+
};
211+
let builder = forward_metadata_headers(builder, metadata);
212+
let builder = backend.apply_forwarded_auth(builder, metadata);
213+
let builder = apply_extra_headers(builder, backend);
214+
let builder = backend.apply_auth(builder);
215+
let response = match builder.send().await {
216+
Ok(response) => response,
217+
Err(error) => {
218+
metrics::record_upstream_attempt(None);
219+
return Err(convert_reqwest_error(error));
220+
}
221+
};
222+
metrics::record_upstream_attempt(Some(response.status().as_u16()));
223+
Ok(response)
224+
}
225+
226+
fn http_client(&self, backend: &Backend) -> &reqwest::Client {
227+
if backend.is_forwarding_auth() {
228+
&self.forward_auth_client
229+
} else {
230+
&self.client
231+
}
232+
}
233+
153234
/// Counts input tokens with `model`'s Anthropic backend.
154235
///
155236
/// Returns an error when the model has no Anthropic backend or the upstream
@@ -298,11 +379,7 @@ impl TranslatingLlmClient {
298379
model: &ModelId,
299380
streaming: bool,
300381
) -> std::result::Result<EncodedResponse, AttemptFailure> {
301-
let client = if backend.is_forwarding_auth() {
302-
&self.forward_auth_client
303-
} else {
304-
&self.client
305-
};
382+
let client = self.http_client(backend);
306383
let builder = client.post(url).json(body);
307384
let builder = forward_metadata_headers(builder, metadata);
308385
let builder = backend.apply_forwarded_auth(builder, metadata);

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pub mod raw;
2626
pub mod run;
2727

2828
pub use backend::{Backend, DEFAULT_MAX_RETRIES, HttpBackendConfig};
29-
pub use client::{ModelConfig, TranslatingLlmClient};
29+
pub use client::{ModelConfig, OpenAiPassthroughRequest, TranslatingLlmClient};
3030
pub use error::{LlmClientError, Result};
3131
pub use observation::{LlmCallObservation, RunObservation, RunObserver};
3232
pub use raw::RawResponse;

crates/switchyard-runner/src/config.rs

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

20+
use crate::route::ResponsesTarget;
2021
use crate::{
2122
AlgorithmSpec, CallerAuthKind, CountTokensTarget, DecisionTarget, ModelCapabilities, Route,
2223
Runner, RunnerError,
@@ -189,6 +190,7 @@ impl DeploymentConfig {
189190
let (route_clients, caller_auth) =
190191
self.build_route_clients(route_name, config, &clients)?;
191192
let count_tokens_target = self.build_count_tokens_target(config, &clients);
193+
let responses_target = self.build_responses_target(config, &clients);
192194
let decision_targets = config
193195
.routing_target_names()
194196
.into_iter()
@@ -201,7 +203,8 @@ impl DeploymentConfig {
201203
capabilities,
202204
count_tokens_target,
203205
decision_targets,
204-
);
206+
)
207+
.with_responses_target(responses_target);
205208
routes.push((config.id.clone(), route));
206209
}
207210
Ok(Runner::new(routes))
@@ -316,6 +319,24 @@ impl DeploymentConfig {
316319
client: client.clone(),
317320
})
318321
}
322+
323+
fn build_responses_target(
324+
&self,
325+
route: &RouteConfig,
326+
clients: &BTreeMap<String, Arc<TranslatingLlmClient>>,
327+
) -> Option<ResponsesTarget> {
328+
route.routing_target_names().into_iter().find_map(|name| {
329+
let target = self.targets.get(name)?;
330+
let client = clients.get(&target.llm_client)?;
331+
client
332+
.backend_for(&target.id, WireFormat::OpenAiResponses)
333+
.is_some()
334+
.then(|| ResponsesTarget {
335+
model: target.id.clone(),
336+
client: client.clone(),
337+
})
338+
})
339+
}
319340
}
320341

321342
fn count_tokens_priority(target_name: &str, model_id: &ModelId) -> usize {

crates/switchyard-runner/src/failure.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ impl RunnerError {
100100
),
101101
Self::UnknownRouteModel(_)
102102
| Self::IncompatibleCallerFormat(_)
103-
| Self::CountTokensUnsupported => summary(
103+
| Self::CountTokensUnsupported
104+
| Self::ResponsesPassthroughUnsupported => summary(
104105
RouteErrorKind::InvalidRequest,
105106
RouteErrorPhase::BeforeResponse,
106107
None,

crates/switchyard-runner/src/route.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ use std::sync::Arc;
88

99
use libsy::{Algorithm, CallModel, LibsyError, RoutingOutcome, drive};
1010
use serde_json::Value;
11-
use switchyard_llm_client::{ClientRouter, RunObserver, TranslatingLlmClient};
12-
use switchyard_protocol::{LlmClientError, ModelId, Request, Response, WireFormat};
11+
use switchyard_llm_client::{
12+
ClientRouter, OpenAiPassthroughRequest, RunObserver, TranslatingLlmClient,
13+
};
14+
use switchyard_protocol::{LlmClientError, Metadata, ModelId, Request, Response, WireFormat};
1315
use thiserror::Error;
1416

1517
use crate::DecisionTarget;
@@ -63,6 +65,11 @@ pub struct CountTokensTarget {
6365
pub client: Arc<TranslatingLlmClient>,
6466
}
6567

68+
pub(crate) struct ResponsesTarget {
69+
pub(crate) model: ModelId,
70+
pub(crate) client: Arc<TranslatingLlmClient>,
71+
}
72+
6673
/// Error returned while loading or executing configured routes.
6774
#[derive(Debug, Error)]
6875
pub enum RunnerError {
@@ -78,6 +85,8 @@ pub enum RunnerError {
7885
IncompatibleCallerFormat(CallerAuthKind),
7986
#[error("route has no Anthropic target for token counting")]
8087
CountTokensUnsupported,
88+
#[error("no OpenAI Responses target is available for auxiliary endpoints")]
89+
ResponsesPassthroughUnsupported,
8190
#[error(transparent)]
8291
Algorithm(#[from] LibsyError),
8392
#[error(transparent)]
@@ -113,6 +122,7 @@ pub struct Route {
113122
caller_auth: Option<CallerAuthKind>,
114123
capabilities: ModelCapabilities,
115124
count_tokens_target: Option<CountTokensTarget>,
125+
responses_target: Option<ResponsesTarget>,
116126
decision_targets: Vec<DecisionTarget>,
117127
}
118128

@@ -138,10 +148,20 @@ impl Route {
138148
caller_auth,
139149
capabilities,
140150
count_tokens_target,
151+
responses_target: None,
141152
decision_targets,
142153
}
143154
}
144155

156+
pub(crate) fn with_responses_target(mut self, target: Option<ResponsesTarget>) -> Self {
157+
self.responses_target = target;
158+
self
159+
}
160+
161+
pub(crate) fn supports_responses_passthrough(&self) -> bool {
162+
self.responses_target.is_some()
163+
}
164+
145165
/// Returns the configured libsy algorithm name.
146166
pub fn algorithm_name(&self) -> &str {
147167
self.algorithm.name()
@@ -215,6 +235,23 @@ impl Route {
215235
.await
216236
.map_err(Into::into)
217237
}
238+
239+
/// Proxies an auxiliary OpenAI request through this route's Responses-capable target.
240+
pub async fn passthrough_openai(
241+
&self,
242+
request: OpenAiPassthroughRequest,
243+
metadata: Metadata,
244+
) -> Result<reqwest::Response, RunnerError> {
245+
let target = self
246+
.responses_target
247+
.as_ref()
248+
.ok_or(RunnerError::ResponsesPassthroughUnsupported)?;
249+
target
250+
.client
251+
.passthrough_openai(&target.model, request, Some(&metadata))
252+
.await
253+
.map_err(Into::into)
254+
}
218255
}
219256

220257
async fn serve_decision_dependency(clients: ClientRouter, call: CallModel) -> libsy::Result<()> {

crates/switchyard-runner/src/runner.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use std::path::Path;
88

99
use libsy::RoutingOutcome;
1010
use serde_json::Value;
11-
use switchyard_protocol::{ModelId, WireFormat};
11+
use switchyard_llm_client::OpenAiPassthroughRequest;
12+
use switchyard_protocol::{Metadata, ModelId, WireFormat};
1213

1314
use crate::config;
1415
use crate::{ModelCapabilities, Route, RunnerError};
@@ -77,6 +78,21 @@ impl Runner {
7778
})
7879
}
7980

81+
/// Proxies an auxiliary OpenAI request through the first Responses-capable route.
82+
pub async fn passthrough_openai(
83+
&self,
84+
request: OpenAiPassthroughRequest,
85+
metadata: Metadata,
86+
) -> Result<reqwest::Response, RunnerError> {
87+
let route = self
88+
.routes
89+
.iter()
90+
.map(|(_, route)| route)
91+
.find(|route| route.supports_responses_passthrough())
92+
.ok_or(RunnerError::ResponsesPassthroughUnsupported)?;
93+
route.passthrough_openai(request, metadata).await
94+
}
95+
8096
/// Resolves an outcome to configured target names and non-secret client settings.
8197
pub fn describe_decision(
8298
&self,

crates/switchyard-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ opentelemetry-prometheus = "0.32"
3232
opentelemetry_sdk = { version = "0.32", default-features = false, features = ["metrics", "trace"] }
3333
parking_lot.workspace = true
3434
prometheus = "0.14"
35+
reqwest.workspace = true
3536
serde.workspace = true
3637
switchyard-llm-client.workspace = true
3738
switchyard-protocol.workspace = true

0 commit comments

Comments
 (0)