use std::sync::Arc;
use http_body_util::{BodyExt, Full, Limited};
use hyper::body::Incoming;
use hyper::header::{ACCEPT, CONTENT_TYPE};
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{json, Value};
use crate::{json_response, model_table, not_ready, BoxedBody, Shared};
const MAX_BODY: usize = 128 * 1024 * 1024;
fn upstream_url(base: &str, tail: &str) -> String {
let base = base.trim_end_matches('/');
match (base.strip_suffix("/v1"), tail.strip_prefix("/v1")) {
(Some(_), Some(rest)) => format!("{base}{rest}"),
(Some(root), None) => format!("{root}{tail}"),
(None, _) => format!("{base}{tail}"),
}
}
pub async fn forward(shared: &Arc<Shared>, req: Request<Incoming>) -> Response<BoxedBody> {
let (parts, body) = req.into_parts();
let bytes = match Limited::new(body, MAX_BODY).collect().await {
Ok(c) => c.to_bytes(),
Err(_) => {
return json_response(
StatusCode::PAYLOAD_TOO_LARGE,
&json!({"error": format!("request body over {MAX_BODY} bytes")}),
)
}
};
let parsed: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => {
return json_response(
StatusCode::BAD_REQUEST,
&json!({"error": "body is not JSON"}),
)
}
};
let Some(model) = parsed["model"].as_str() else {
return json_response(
StatusCode::BAD_REQUEST,
&json!({"error": "no model field in request"}),
);
};
let models = model_table(shared);
let Some((group, base)) = models.get(model) else {
let pending = not_ready(shared);
if let Some(why) = pending.get(model) {
return json_response(
StatusCode::SERVICE_UNAVAILABLE,
&json!({"error": format!("{model} is not ready: {why}")}),
);
}
return json_response(
StatusCode::NOT_FOUND,
&json!({
"error": format!("no model {model:?} is being served"),
"available": models.keys().collect::<Vec<_>>(),
"not_ready": pending,
}),
);
};
let tail = parts
.uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or_else(|| parts.uri.path());
let url = upstream_url(base, tail);
let build_up = || {
let mut up = Request::builder().method(Method::POST).uri(&url).header(
CONTENT_TYPE,
parts
.headers
.get(CONTENT_TYPE)
.cloned()
.unwrap_or_else(|| hyper::header::HeaderValue::from_static("application/json")),
);
if let Some(a) = parts.headers.get(ACCEPT) {
up = up.header(ACCEPT, a.clone());
}
up.body(Full::new(bytes.clone())).map_err(|e| e.to_string())
};
let up = match build_up() {
Ok(r) => r,
Err(e) => {
return json_response(
StatusCode::BAD_GATEWAY,
&json!({"error": format!("building upstream request: {e}")}),
)
}
};
let resp = match shared
.client
.send_once(up, shared.cfg.serving_timeout)
.await
{
Ok(r) => r,
Err(e) if e.starts_with("timeout after") => {
return json_response(
StatusCode::GATEWAY_TIMEOUT,
&json!({"error": format!(
"{group} gave no response within {:.0}s",
shared.cfg.serving_timeout.as_secs_f64()
)}),
)
}
Err(e) => {
return json_response(
StatusCode::BAD_GATEWAY,
&json!({"error": format!("{group} upstream error: {e}")}),
)
}
};
let mut builder = Response::builder().status(resp.status());
if let Some(ct) = resp.headers().get(CONTENT_TYPE) {
builder = builder.header(CONTENT_TYPE, ct.clone());
}
match builder.body(
resp.into_body()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
.boxed(),
) {
Ok(r) => r,
Err(e) => json_response(
StatusCode::BAD_GATEWAY,
&json!({"error": format!("relaying upstream response: {e}")}),
),
}
}
#[cfg(test)]
mod tests {
use super::upstream_url;
#[test]
fn root_and_v1_endpoints_both_resolve() {
let base = "http://10.0.0.1:8000/v1";
assert_eq!(
upstream_url(base, "/v1/chat/completions"),
"http://10.0.0.1:8000/v1/chat/completions"
);
assert_eq!(
upstream_url(base, "/tokenize"),
"http://10.0.0.1:8000/tokenize"
);
assert_eq!(
upstream_url(base, "/detokenize"),
"http://10.0.0.1:8000/detokenize"
);
}
#[test]
fn a_query_string_survives() {
assert_eq!(
upstream_url("http://h:8000/v1", "/v1/completions?stream=true"),
"http://h:8000/v1/completions?stream=true"
);
}
#[test]
fn a_trailing_slash_does_not_double() {
assert_eq!(
upstream_url("http://h:8000/v1/", "/tokenize"),
"http://h:8000/tokenize"
);
}
#[test]
fn a_base_without_v1_is_used_as_given() {
assert_eq!(
upstream_url("http://h:8000", "/v1/chat/completions"),
"http://h:8000/v1/chat/completions"
);
assert_eq!(
upstream_url("http://h:8000", "/tokenize"),
"http://h:8000/tokenize"
);
}
}