use axum::Json;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
#[derive(Serialize)]
struct OpenAiErrorBody {
error: OpenAiError,
}
#[derive(Serialize)]
struct OpenAiError {
message: String,
#[serde(rename = "type")]
kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
code: Option<&'static str>,
}
#[must_use]
#[allow(dead_code, reason = "kept for OpenAI-shaped catalog errors")]
pub(crate) fn model_not_found(model: &str) -> Response {
openai_error(
StatusCode::NOT_FOUND,
format!("The model `{model}` does not exist"),
"invalid_request_error",
Some("model_not_found"),
)
}
#[must_use]
pub(crate) fn invalid_request(message: impl Into<String>) -> Response {
openai_error(
StatusCode::BAD_REQUEST,
message.into(),
"invalid_request_error",
None,
)
}
#[must_use]
pub(crate) fn not_found(message: impl Into<String>) -> Response {
openai_error(
StatusCode::NOT_FOUND,
message.into(),
"invalid_request_error",
None,
)
}
#[must_use]
pub(crate) fn bad_gateway(message: impl Into<String>) -> Response {
openai_error(
StatusCode::BAD_GATEWAY,
message.into(),
"server_error",
None,
)
}
#[must_use]
pub(crate) fn server_error(message: impl Into<String>) -> Response {
openai_error(
StatusCode::INTERNAL_SERVER_ERROR,
message.into(),
"server_error",
None,
)
}
#[allow(clippy::unused_async, reason = "axum handler")]
pub(crate) async fn unlisted(request: Request) -> Response {
not_found(format!(
"Invalid URL ({} {})",
request.method(),
request.uri().path()
))
}
fn openai_error(
status: StatusCode,
message: String,
kind: &'static str,
code: Option<&'static str>,
) -> Response {
let mut response = Json(OpenAiErrorBody {
error: OpenAiError {
message,
kind,
code,
},
})
.into_response();
*response.status_mut() = status;
response
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use crate::config::Config;
use crate::http::app;
const CATALOG: &str = r#"
[payment]
enabled = false
[[upstreams]]
name = "stub"
base_url = "http://127.0.0.1:9"
api_key = "sk-test"
[[models]]
id = "gpt-4o-mini"
upstream = "stub"
"#;
fn app_with_catalog() -> axum::Router {
let cfg = Config::from_toml_str(CATALOG).expect("config");
app(cfg).expect("router")
}
async fn json(response: axum::http::Response<Body>) -> serde_json::Value {
let bytes = axum::body::to_bytes(response.into_body(), 4096)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json")
}
fn error_field<'a>(body: &'a serde_json::Value, field: &str) -> Option<&'a str> {
body.get("error")
.and_then(|error| error.get(field))
.and_then(serde_json::Value::as_str)
}
#[tokio::test]
async fn realtime_is_openai_404() {
let response = app_with_catalog()
.oneshot(
Request::get("/v1/realtime")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND, "status");
let body = json(response).await;
assert_eq!(
error_field(&body, "type"),
Some("invalid_request_error"),
"type"
);
let message = error_field(&body, "message").unwrap_or("");
assert!(
message.contains("/v1/realtime"),
"path in message: {message}"
);
}
#[tokio::test]
async fn admin_path_is_openai_404() {
let response = app_with_catalog()
.oneshot(
Request::post("/api/providers")
.header("content-type", "application/json")
.body(Body::from("{}"))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NOT_FOUND, "status");
let body = json(response).await;
let message = error_field(&body, "message").unwrap_or("");
assert!(
message.contains("/api/providers"),
"path in message: {message}"
);
}
}