Files
llm-bridge/src/error.rs
T
bruno 7b5aa6fcc9
CI / fmt, clippy and tests (ubuntu-latest) (push) Successful in 21m0s
CI / browser end-to-end (Chromium + fixture) (push) Skipped
CI / fmt, clippy and tests (windows-latest) (push) Canceled after 0s
Add initial llm-bridge project
2026-09-18 18:01:07 -04:00

211 lines
7.4 KiB
Rust

//! Typed errors mapped onto the OpenAI error payload.
use std::path::PathBuf;
/// Convenience alias used across the crate.
pub type Result<T, E = GatewayError> = std::result::Result<T, E>;
/// Every failure mode the gateway can surface to a client.
#[derive(Debug, thiserror::Error)]
pub enum GatewayError {
#[error("model '{0}' does not match any configured provider")]
UnknownProvider(String),
/// The model string named a provider, but not one of its accounts.
#[error("{0}")]
UnknownAccount(String),
#[error("parameter '{param}' {detail}")]
UnsupportedParameter { param: String, detail: String },
#[error("{0}")]
UnsupportedContentType(String),
#[error("{0}")]
InvalidRequest(String),
#[error("invalid API key")]
InvalidApiKey,
#[error(
"provider '{provider}' is not signed in: run 'llm-gateway login {provider} --manual', sign in inside the window it opens, then close it (Google refuses browsers it sees as automated, so this path avoids that)"
)]
RequiresLogin { provider: String },
#[error(
"provider '{provider}' is showing a captcha challenge; solve it in the browser window"
)]
Captcha { provider: String },
#[error("provider '{provider}' is busy, retry later")]
ProviderBusy { provider: String, waited_ms: u64 },
#[error("provider '{provider}' is rate limiting: {detail}")]
UpstreamRateLimited { provider: String, detail: String },
#[error("browser profile {path} is locked by another browser instance; close it and retry")]
BrowserProfileLocked { path: PathBuf },
#[error("selector '{selector}' not found ({step})")]
SelectorMissing { selector: String, step: String },
#[error("provider '{provider}' reported an error: {message}")]
UpstreamError { provider: String, message: String },
#[error("browser engine unavailable: {0}")]
BrowserUnavailable(String),
#[error("timed out while {stage} after {timeout_s}s")]
CaptureTimeout { stage: String, timeout_s: u64 },
#[error("provider '{provider}' is misconfigured: {detail}")]
ProviderMisconfigured { provider: String, detail: String },
#[error("unsupported browser '{0}': only Chromium-based browsers speak CDP")]
UnsupportedBrowser(String),
#[error("provider '{0}' is not configured")]
ProviderNotConfigured(String),
#[error("HTTP request to {url} failed: {source}")]
Http {
url: String,
#[source]
source: reqwest::Error,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid configuration: {0}")]
Config(String),
#[error("internal error: {0}")]
Internal(String),
/// The caller went away before the answer was captured. Never serialised:
/// there is no client left to read it.
#[error("client disconnected before the answer was captured")]
Cancelled,
}
impl GatewayError {
pub fn invalid_request(msg: impl Into<String>) -> Self {
Self::InvalidRequest(msg.into())
}
pub fn unsupported_param(param: impl Into<String>, detail: impl Into<String>) -> Self {
Self::UnsupportedParameter {
param: param.into(),
detail: detail.into(),
}
}
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
/// HTTP status returned to the OpenAI-compatible client.
pub fn http_status(&self) -> u16 {
match self {
Self::UnknownProvider(_) | Self::UnknownAccount(_) => 404,
Self::UnsupportedParameter { .. }
| Self::UnsupportedContentType(_)
| Self::InvalidRequest(_) => 400,
Self::InvalidApiKey | Self::RequiresLogin { .. } => 401,
Self::Captcha { .. } => 403,
Self::ProviderBusy { .. } | Self::UpstreamRateLimited { .. } => 429,
Self::BrowserProfileLocked { .. } => 409,
Self::BrowserUnavailable(_) | Self::ProviderMisconfigured { .. } => 503,
Self::CaptureTimeout { .. } => 504,
Self::Cancelled => 499,
_ => 500,
}
}
/// OpenAI-compatible error type field.
pub fn error_type(&self) -> &'static str {
match self.http_status() {
400 | 401 | 403 | 404 => "invalid_request_error",
429 => "rate_limit_error",
_ => "api_error",
}
}
/// Stable machine-readable code, also used as a log field.
pub fn code(&self) -> &'static str {
match self {
Self::UnknownProvider(_) => "model_not_found",
Self::UnknownAccount(_) => "account_not_found",
Self::UnsupportedParameter { .. } => "unsupported_parameter",
Self::UnsupportedContentType(_) => "unsupported_content_type",
Self::InvalidRequest(_) => "invalid_request",
Self::InvalidApiKey => "invalid_api_key",
Self::RequiresLogin { .. } => "requires_login",
Self::Captcha { .. } => "captcha_required",
Self::ProviderBusy { .. } => "provider_busy",
Self::UpstreamRateLimited { .. } => "upstream_rate_limit",
Self::BrowserProfileLocked { .. } => "browser_profile_locked",
Self::SelectorMissing { .. } => "selector_missing",
Self::UpstreamError { .. } => "upstream_error",
Self::BrowserUnavailable(_) => "browser_unavailable",
Self::CaptureTimeout { .. } => "upstream_timeout",
Self::ProviderMisconfigured { .. } => "provider_misconfigured",
Self::UnsupportedBrowser(_) => "unsupported_browser",
Self::ProviderNotConfigured(_) => "provider_not_configured",
Self::Http { .. } => "upstream_http_error",
Self::Io(_) => "io_error",
Self::Config(_) => "invalid_configuration",
Self::Internal(_) => "internal_error",
Self::Cancelled => "client_disconnected",
}
}
/// Name of the offending parameter, when the error is parameter-specific.
pub fn param(&self) -> Option<&str> {
match self {
Self::UnsupportedParameter { param, .. } => Some(param.as_str()),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn codes_and_statuses_are_stable() {
let e = GatewayError::RequiresLogin {
provider: "chatgpt".into(),
};
assert_eq!(e.http_status(), 401);
assert_eq!(e.code(), "requires_login");
assert_eq!(e.error_type(), "invalid_request_error");
assert!(e.to_string().contains("llm-gateway login chatgpt"));
let e = GatewayError::CaptureTimeout {
stage: "waiting for the answer".into(),
timeout_s: 180,
};
assert_eq!(e.http_status(), 504);
assert_eq!(e.code(), "upstream_timeout");
let e = GatewayError::unsupported_param("tools", "is not supported by the browser backend");
assert_eq!(e.param(), Some("tools"));
assert_eq!(e.http_status(), 400);
let e = GatewayError::BrowserProfileLocked {
path: PathBuf::from("/p"),
};
assert_eq!(e.http_status(), 409);
let e = GatewayError::UnknownAccount("provider 'chatgpt' has no account 'x'".into());
assert_eq!(e.http_status(), 404);
assert_eq!(e.code(), "account_not_found");
assert_eq!(e.error_type(), "invalid_request_error");
assert!(e.to_string().contains("no account 'x'"));
}
}