mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-16 23:43:10 +00:00
The OpenAPI spec adopts the lithos request, response, content part, tool, usage, and cost schemas. The completions endpoint returns the lithos `Response` JSON verbatim and SSE carries lithos `StreamEvent`s verbatim. The models and providers endpoints serve the fabro-types catalog views, and the install and model-test flows probe providers through fabro-llm. The CLI builds its catalog from the operator overlay, drives `fabro exec` through the server gateway adapter, and parses reasoning effort with the shared controls. The web app reads content parts as lithos-tagged objects. The TypeScript client is regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
268 lines
7.7 KiB
Rust
268 lines
7.7 KiB
Rust
use axum::Json;
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use fabro_api::types::ErrorResponseEntry;
|
|
use fabro_vault::Error as VaultError;
|
|
use serde::Serialize;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum Error {
|
|
#[error(transparent)]
|
|
Workflow(#[from] fabro_workflow::Error),
|
|
|
|
#[error(transparent)]
|
|
Agent(#[from] fabro_agent::Error),
|
|
|
|
#[error(transparent)]
|
|
Llm(#[from] fabro_llm::Error),
|
|
|
|
#[error(transparent)]
|
|
Store(#[from] fabro_store::Error),
|
|
|
|
#[error(transparent)]
|
|
Config(#[from] fabro_config::Error),
|
|
|
|
#[error(transparent)]
|
|
Vault(#[from] VaultError),
|
|
|
|
#[error("bad request: {0}")]
|
|
BadRequest(String),
|
|
|
|
#[error("authentication required")]
|
|
Unauthorized,
|
|
|
|
#[error("access denied")]
|
|
Forbidden,
|
|
|
|
#[error("not found: {0}")]
|
|
NotFound(String),
|
|
|
|
#[error("conflict: {0}")]
|
|
Conflict(String),
|
|
|
|
#[error("service unavailable: {0}")]
|
|
ServiceUnavailable(String),
|
|
|
|
#[error("bad gateway: {0}")]
|
|
BadGateway(String),
|
|
|
|
#[error("internal server error: {0}")]
|
|
Internal(String),
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, Error>;
|
|
|
|
#[derive(Serialize)]
|
|
struct ErrorEntry {
|
|
status: String,
|
|
title: String,
|
|
detail: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
code: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ErrorBody {
|
|
errors: Vec<ErrorEntry>,
|
|
}
|
|
|
|
/// Uniform API error response.
|
|
///
|
|
/// Serializes to `{"errors": [{"status": "4xx", "title": "...", "detail":
|
|
/// "..."}]}`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ApiError {
|
|
status: StatusCode,
|
|
detail: String,
|
|
code: Option<String>,
|
|
}
|
|
|
|
impl ApiError {
|
|
pub fn new(status: StatusCode, detail: impl Into<String>) -> Self {
|
|
Self {
|
|
status,
|
|
detail: detail.into(),
|
|
code: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_code(
|
|
status: StatusCode,
|
|
detail: impl Into<String>,
|
|
code: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
status,
|
|
detail: detail.into(),
|
|
code: Some(code.into()),
|
|
}
|
|
}
|
|
|
|
pub fn not_found(detail: impl Into<String>) -> Self {
|
|
Self::new(StatusCode::NOT_FOUND, detail)
|
|
}
|
|
|
|
pub fn bad_request(detail: impl Into<String>) -> Self {
|
|
Self::new(StatusCode::BAD_REQUEST, detail)
|
|
}
|
|
|
|
pub fn unauthorized() -> Self {
|
|
Self::new(StatusCode::UNAUTHORIZED, "Authentication required.")
|
|
}
|
|
|
|
pub fn unauthorized_with_code(detail: impl Into<String>, code: impl Into<String>) -> Self {
|
|
Self::with_code(StatusCode::UNAUTHORIZED, detail, code)
|
|
}
|
|
|
|
pub fn forbidden() -> Self {
|
|
Self::new(StatusCode::FORBIDDEN, "Access denied.")
|
|
}
|
|
|
|
pub fn forbidden_with_code(detail: impl Into<String>, code: impl Into<String>) -> Self {
|
|
Self::with_code(StatusCode::FORBIDDEN, detail, code)
|
|
}
|
|
|
|
pub fn status(&self) -> StatusCode {
|
|
self.status
|
|
}
|
|
|
|
pub(crate) fn detail(&self) -> &str {
|
|
&self.detail
|
|
}
|
|
|
|
pub(crate) fn code(&self) -> Option<&str> {
|
|
self.code.as_deref()
|
|
}
|
|
|
|
/// Convert into the OpenAPI-generated `ErrorResponseEntry` wire form. This
|
|
/// is used by endpoints that return per-item errors inside a larger payload
|
|
/// (e.g. batch lifecycle responses), where the outer response is `200` but
|
|
/// individual items carry structured failures.
|
|
pub fn into_response_entry(self) -> ErrorResponseEntry {
|
|
ErrorResponseEntry {
|
|
status: self.status.as_u16().to_string(),
|
|
title: self
|
|
.status
|
|
.canonical_reason()
|
|
.unwrap_or("Unknown")
|
|
.to_string(),
|
|
detail: self.detail,
|
|
code: self.code,
|
|
request_id: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<Error> for ApiError {
|
|
fn from(err: Error) -> Self {
|
|
match err {
|
|
Error::BadRequest(msg) => Self::bad_request(msg),
|
|
Error::Unauthorized => Self::unauthorized(),
|
|
Error::Forbidden => Self::forbidden(),
|
|
Error::NotFound(msg) => Self::not_found(msg),
|
|
Error::Conflict(msg) => Self::new(StatusCode::CONFLICT, msg),
|
|
Error::ServiceUnavailable(msg) => Self::new(StatusCode::SERVICE_UNAVAILABLE, msg),
|
|
Error::BadGateway(msg) => Self::new(StatusCode::BAD_GATEWAY, msg),
|
|
Error::Workflow(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
|
Error::Agent(err) => Self::new(StatusCode::BAD_GATEWAY, err.to_string()),
|
|
Error::Llm(err) => Self::from(err),
|
|
Error::Store(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
|
Error::Config(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
|
Error::Vault(err) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
|
Error::Internal(msg) => Self::new(StatusCode::INTERNAL_SERVER_ERROR, msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// LLM errors split at the HTTP boundary: request-validation failures are the
|
|
/// caller's fault (400); non-validation LLM failures, including provider,
|
|
/// middleware, and local configuration failures, return 502.
|
|
impl From<fabro_llm::Error> for ApiError {
|
|
fn from(err: fabro_llm::Error) -> Self {
|
|
match err.kind() {
|
|
fabro_llm::ErrorKind::InvalidRequest | fabro_llm::ErrorKind::ModelSelection => {
|
|
Self::bad_request(err.message().to_string())
|
|
}
|
|
_ => Self::new(StatusCode::BAD_GATEWAY, format!("LLM error: {err}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ApiError {
|
|
fn into_response(self) -> Response {
|
|
let title = self
|
|
.status
|
|
.canonical_reason()
|
|
.unwrap_or("Unknown")
|
|
.to_string();
|
|
let body = ErrorBody {
|
|
errors: vec![ErrorEntry {
|
|
status: self.status.as_u16().to_string(),
|
|
title,
|
|
detail: self.detail,
|
|
code: self.code,
|
|
}],
|
|
};
|
|
(self.status, Json(body)).into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use axum::body::to_bytes;
|
|
use axum::http::StatusCode;
|
|
use axum::response::IntoResponse;
|
|
use serde_json::json;
|
|
|
|
use super::ApiError;
|
|
|
|
#[tokio::test]
|
|
async fn api_error_with_code_serializes_machine_readable_code() {
|
|
let response = ApiError::with_code(
|
|
StatusCode::UNAUTHORIZED,
|
|
"token expired",
|
|
"access_token_expired",
|
|
)
|
|
.into_response();
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should serialize");
|
|
let body: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
|
|
assert_eq!(
|
|
body,
|
|
json!({
|
|
"errors": [{
|
|
"status": "401",
|
|
"title": "Unauthorized",
|
|
"detail": "token expired",
|
|
"code": "access_token_expired"
|
|
}]
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unauthorized_without_code_omits_code_key() {
|
|
let response = ApiError::unauthorized().into_response();
|
|
|
|
let body = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("body should serialize");
|
|
let body: serde_json::Value =
|
|
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
|
|
assert_eq!(
|
|
body,
|
|
json!({
|
|
"errors": [{
|
|
"status": "401",
|
|
"title": "Unauthorized",
|
|
"detail": "Authentication required."
|
|
}]
|
|
})
|
|
);
|
|
}
|
|
}
|