211 lines
7.4 KiB
Rust
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'"));
|
|
}
|
|
}
|