o402 0.1.3

OpenAI-compatible gateway, paid with x402.
//! OpenAI-shaped JSON errors.

use axum::Json;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;

/// OpenAI error envelope.
#[derive(Serialize)]
struct OpenAiErrorBody {
    /// Nested error object.
    error: OpenAiError,
}

/// OpenAI `error` object.
#[derive(Serialize)]
struct OpenAiError {
    /// Human-readable message.
    message: String,
    /// OpenAI error class.
    #[serde(rename = "type")]
    kind: &'static str,
    /// Machine-readable code.
    #[serde(skip_serializing_if = "Option::is_none")]
    code: Option<&'static str>,
}

/// 404 for an unknown catalog model. Not proxied.
#[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"),
    )
}

/// 400 for malformed JSON or missing fields.
#[must_use]
pub(crate) fn invalid_request(message: impl Into<String>) -> Response {
    openai_error(
        StatusCode::BAD_REQUEST,
        message.into(),
        "invalid_request_error",
        None,
    )
}

/// 404 for paths that are not exported.
#[must_use]
pub(crate) fn not_found(message: impl Into<String>) -> Response {
    openai_error(
        StatusCode::NOT_FOUND,
        message.into(),
        "invalid_request_error",
        None,
    )
}

/// 502 when the upstream request fails before a response exists.
#[must_use]
pub(crate) fn bad_gateway(message: impl Into<String>) -> Response {
    openai_error(
        StatusCode::BAD_GATEWAY,
        message.into(),
        "server_error",
        None,
    )
}

/// 500 for invariant failures (missing request context).
#[must_use]
pub(crate) fn server_error(message: impl Into<String>) -> Response {
    openai_error(
        StatusCode::INTERNAL_SERVER_ERROR,
        message.into(),
        "server_error",
        None,
    )
}

/// Fallback for unmatched `/v1` methods and paths.
#[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}"
        );
    }
}