Skip to main content

llm_dialect/
error.rs

1//! Error type shared by every translation entry point.
2
3/// Errors from request parsing, translation, and (in an embedder's runtime)
4/// upstream transport. `http_status`/`log_status` map each variant to the wire
5/// and log mappings an ingress surface would use; the variants are shared here
6/// so a mapped request carries one error type end to end.
7#[derive(Debug, thiserror::Error)]
8pub enum ProxyError {
9    #[error("unknown model: {0}")]
10    UnknownModel(String),
11    #[error("no healthy deployment for route {0}")]
12    NoHealthyDeployment(String),
13    #[error("upstream {status}: {body}")]
14    Upstream {
15        status: u16,
16        body: String,
17        /// Upstream-supplied backoff hint (Retry-After seconds), when present.
18        /// None on errors constructed without a response (timeouts, bad JSON).
19        retry_after_secs: Option<u64>,
20    },
21    #[error("rate limit exceeded")]
22    RateLimited,
23    #[error("budget exceeded")]
24    BudgetExceeded,
25    #[error("model not allowed for this key")]
26    ModelNotAllowed,
27    #[error("transport error: {0}")]
28    Transport(String),
29    #[error("invalid api key")]
30    Unauthorized,
31    #[error("bad request: {0}")]
32    BadRequest(String),
33    #[error("internal: {0}")]
34    Internal(#[from] anyhow::Error),
35}
36
37impl ProxyError {
38    pub fn upstream(status: u16, body: String) -> Self {
39        ProxyError::Upstream {
40            status,
41            body,
42            retry_after_secs: None,
43        }
44    }
45
46    /// Status recorded in a spend/request log for this failure. Unlike
47    /// http_status(), a client-side disconnect mid-stream is logged as 503
48    /// (the client's problem), and unknown-internal as 500 — the log table
49    /// intentionally diverges from the wire mapping.
50    pub fn log_status(&self) -> i64 {
51        match self {
52            ProxyError::Upstream { status, .. } => *status as i64,
53            ProxyError::RateLimited => 429,
54            ProxyError::Unauthorized => 401,
55            _ => 500,
56        }
57    }
58
59    /// HTTP status for this error on the wire. Individual dialects may
60    /// override (the Anthropic dialect maps BudgetExceeded to Anthropic-style
61    /// 429 — see dialect/anthropic/out.rs).
62    pub fn http_status(&self) -> u16 {
63        match self {
64            ProxyError::Unauthorized => 401,
65            ProxyError::RateLimited => 429,
66            ProxyError::BudgetExceeded => 403,
67            ProxyError::ModelNotAllowed => 403,
68            ProxyError::UnknownModel(_) => 404,
69            ProxyError::NoHealthyDeployment(_) => 503,
70            ProxyError::Transport(_) => 502,
71            ProxyError::Upstream { status, .. } => *status,
72            ProxyError::BadRequest(_) => 400,
73            ProxyError::Internal(_) => 500,
74        }
75    }
76}
77
78/// Render the OpenAI-style error envelope (`{error: {message, type, code}}`)
79/// for an out-of-band (pre-stream) failure. In-stream errors stay in-band as
80/// dialect error frames; this is for the non-streaming path only.
81///
82/// Client-facing messages for upstream failures stay generic: provider error
83/// bodies can echo request material and are not client-safe.
84#[cfg(feature = "axum")]
85pub fn error_response(err: &ProxyError) -> axum::response::Response {
86    use axum::Json;
87    use axum::http::StatusCode;
88    use axum::response::IntoResponse;
89
90    let status = StatusCode::from_u16(err.http_status()).unwrap_or(StatusCode::BAD_GATEWAY);
91    let code = match err {
92        ProxyError::Unauthorized => "invalid_api_key",
93        ProxyError::RateLimited => "rate_limit_exceeded",
94        ProxyError::BudgetExceeded => "budget_exceeded",
95        ProxyError::ModelNotAllowed => "model_not_allowed",
96        ProxyError::UnknownModel(_) => "model_not_found",
97        ProxyError::NoHealthyDeployment(_) => "no_healthy_deployment",
98        ProxyError::Transport(_) => "upstream_transport",
99        ProxyError::Upstream { .. } => "upstream_error",
100        ProxyError::BadRequest(_) => "bad_request",
101        ProxyError::Internal(_) => "internal_error",
102    };
103    let message = match err {
104        ProxyError::Upstream { status, .. } => {
105            format!("upstream returned status {status}")
106        }
107        _ => err.to_string(),
108    };
109    let ty = match err {
110        ProxyError::Upstream { .. } => "upstream_error",
111        ProxyError::Transport(_) => "server_error",
112        ProxyError::Internal(_) => "server_error",
113        ProxyError::BadRequest(_) | ProxyError::UnknownModel(_) => "invalid_request_error",
114        _ => "authentication_error",
115    };
116    (
117        status,
118        Json(serde_json::json!({
119            "error": { "message": message, "type": ty, "code": code, "param": null }
120        })),
121    )
122        .into_response()
123}