Add initial llm-bridge project
This commit is contained in:
+210
@@ -0,0 +1,210 @@
|
||||
//! 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'"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user