use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
#[derive(Clone, Copy, Debug)]
pub struct PresentedError<'a> {
pub status: StatusCode,
pub error_type: &'a str,
pub message: &'a str,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ApiDialect {
Anthropic,
OpenAi,
Gemini,
GitHub,
}
#[must_use]
pub fn dialect_for_path(path: &str) -> ApiDialect {
if path.starts_with("/api/gemini/") || path.starts_with("/api/vertex/") {
ApiDialect::Gemini
} else if path.starts_with("/api/v3") || path == "/api/graphql" || path == "/graphql" {
ApiDialect::GitHub
} else if path.ends_with("/chat/completions") || path.ends_with("/responses") {
ApiDialect::OpenAi
} else {
ApiDialect::Anthropic
}
}
impl PresentedError<'_> {
pub fn render(self, dialect: ApiDialect) -> Response {
let body = match dialect {
ApiDialect::Anthropic => serde_json::json!({
"type": "error",
"error": {"type": self.error_type, "message": self.message}
}),
ApiDialect::OpenAi => serde_json::json!({
"error": {"type": self.error_type, "message": self.message}
}),
ApiDialect::Gemini => serde_json::json!({
"error": {
"code": self.status.as_u16(),
"message": self.message,
"status": google_status(self.status),
}
}),
ApiDialect::GitHub => serde_json::json!({"message": self.message}),
};
(self.status, axum::Json(body)).into_response()
}
}
const fn google_status(status: StatusCode) -> &'static str {
match status.as_u16() {
400 => "INVALID_ARGUMENT",
401 => "UNAUTHENTICATED",
403 => "PERMISSION_DENIED",
404 => "NOT_FOUND",
429 => "RESOURCE_EXHAUSTED",
503 => "UNAVAILABLE",
_ => "INTERNAL",
}
}
pub fn error_response(status: StatusCode, error_type: &str, message: &str) -> Response {
PresentedError {
status,
error_type,
message,
}
.render(ApiDialect::Anthropic)
}
pub fn error_response_for_surface(
surface: crate::metrics::Surface,
status: StatusCode,
error_type: &str,
message: &str,
) -> Response {
let dialect = match surface {
crate::metrics::Surface::Anthropic => ApiDialect::Anthropic,
crate::metrics::Surface::OpenAIChat | crate::metrics::Surface::OpenAIResponses => {
ApiDialect::OpenAi
}
};
PresentedError {
status,
error_type,
message,
}
.render(dialect)
}
const fn openai_error_type(status: u16) -> &'static str {
match status {
400 | 404 | 422 => "invalid_request_error",
401 => "authentication_error",
403 => "permission_error",
429 => "rate_limit_error",
_ => "api_error",
}
}
const fn openai_error_code(status: u16) -> Option<&'static str> {
match status {
401 => Some("invalid_api_key"),
403 => Some("permission_denied"),
404 => Some("model_not_found"),
429 => Some("rate_limit_exceeded"),
_ => None,
}
}
#[must_use]
pub fn openai_error_body(status: u16, upstream: &[u8]) -> serde_json::Value {
let parsed = serde_json::from_slice::<serde_json::Value>(upstream).ok();
let vendor_message = parsed.as_ref().and_then(|value| {
value
.pointer("/error/message")
.or_else(|| value.pointer("/message"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
});
let message = vendor_message.unwrap_or_else(|| {
let text = String::from_utf8_lossy(upstream);
let text = text.trim();
if text.is_empty() {
"upstream request failed".to_string()
} else {
text.to_string()
}
});
serde_json::json!({
"error": {
"message": message,
"type": openai_error_type(status),
"param": serde_json::Value::Null,
"code": openai_error_code(status),
}
})
}
pub fn malformed_json_response(error: &str) -> Response {
error_response(
StatusCode::BAD_REQUEST,
"invalid_request_error",
&format!("Failed to parse request body as JSON: {error}"),
)
}
pub fn malformed_json_response_for_surface(
surface: crate::metrics::Surface,
error: &str,
) -> Response {
error_response_for_surface(
surface,
StatusCode::BAD_REQUEST,
"invalid_request_error",
&format!("Failed to parse request body as JSON: {error}"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn one_error_renders_in_each_vendor_dialect() {
for (dialect, required, forbidden) in [
(ApiDialect::Anthropic, "\"type\":\"error\"", "\"code\":"),
(ApiDialect::OpenAi, "\"error\":", "\"type\":\"error\""),
(ApiDialect::Gemini, "INVALID_ARGUMENT", "documentation_url"),
(ApiDialect::GitHub, "\"message\":\"bad\"", "\"error\":"),
] {
let response = PresentedError {
status: StatusCode::BAD_REQUEST,
error_type: "invalid_request_error",
message: "bad",
}
.render(dialect);
let body = axum::body::to_bytes(response.into_body(), 4096)
.await
.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains(required), "{dialect:?}: {body}");
assert!(!body.contains(forbidden), "{dialect:?}: {body}");
}
}
}