//! Typed errors mapped onto the OpenAI error payload. use std::path::PathBuf; /// Convenience alias used across the crate. pub type Result = std::result::Result; /// 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) -> Self { Self::InvalidRequest(msg.into()) } pub fn unsupported_param(param: impl Into, detail: impl Into) -> Self { Self::UnsupportedParameter { param: param.into(), detail: detail.into(), } } pub fn internal(msg: impl Into) -> 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'")); } }