use crate::typed_id::{AgentId, HarnessId, SessionId};
use crate::user_facing_error::{
AttestationRequirement, UserFacingError, UserFacingErrorContext,
classify_runtime_error_message, codes as user_facing_error_codes,
is_attestation_required_message, is_provider_quota_message, is_usage_limit_message,
parse_attestation_requirement,
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use thiserror::Error;
pub type Result<T> = std::result::Result<T, AgentLoopError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmErrorKind {
Authentication,
QuotaExhausted,
RateLimited,
Unavailable,
AttestationRequired,
InvalidRequest,
Other,
}
impl LlmErrorKind {
pub fn from_provider_code(code: &str) -> Option<Self> {
let code = code.trim().to_ascii_lowercase();
match code.as_str() {
"insufficient_quota"
| "billing_hard_limit_reached"
| "credit_balance_too_low"
| "credit_balance_exhausted" => Some(Self::QuotaExhausted),
"authentication_error" | "invalid_api_key" | "permission_denied" => {
Some(Self::Authentication)
}
"rate_limit_exceeded" | "rate_limit_error" | "overloaded_error" => {
Some(Self::RateLimited)
}
"server_error"
| "internal_error"
| "processing_error"
| "service_unavailable"
| "timeout" => Some(Self::Unavailable),
"invalid_request_error" | "model_not_found" => Some(Self::InvalidRequest),
_ => None,
}
}
pub fn from_provider_status(status: u16, body: &str) -> Self {
if is_provider_quota_message(body) || is_usage_limit_message(body) {
return LlmErrorKind::QuotaExhausted;
}
if is_attestation_required_message(body) {
return LlmErrorKind::AttestationRequired;
}
match status {
401 | 403 => LlmErrorKind::Authentication,
429 => LlmErrorKind::RateLimited,
408 | 409 => LlmErrorKind::Unavailable,
501 => LlmErrorKind::Other,
500..=599 => LlmErrorKind::Unavailable,
400..=499 => LlmErrorKind::InvalidRequest,
_ => LlmErrorKind::Other,
}
}
pub fn from_error_text(text: &str) -> Self {
if is_provider_quota_message(text) || is_usage_limit_message(text) {
return LlmErrorKind::QuotaExhausted;
}
let lower = text.to_ascii_lowercase();
if lower.contains("throttlingexception")
|| lower.contains("toomanyrequestsexception")
|| lower.contains("rate limit")
|| lower.contains("too many requests")
{
return LlmErrorKind::RateLimited;
}
if lower.contains("accessdeniedexception")
|| lower.contains("unrecognizedclientexception")
|| lower.contains("expiredtokenexception")
|| lower.contains("invalidsignatureexception")
|| lower.contains("unauthorized")
{
return LlmErrorKind::Authentication;
}
if lower.contains("serviceunavailable")
|| lower.contains("service unavailable")
|| lower.contains("internalserverexception")
|| lower.contains("modelnotreadyexception")
{
return LlmErrorKind::Unavailable;
}
LlmErrorKind::Other
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmError {
pub kind: LlmErrorKind,
pub message: String,
#[serde(default)]
pub retry_attempts: u32,
#[serde(default)]
pub retry_wait_ms: u64,
#[serde(default)]
pub retry_handled: bool,
}
impl std::fmt::Display for LlmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
#[derive(Debug, Error)]
pub enum AgentLoopError {
#[error("LLM error: {0}")]
Llm(LlmError),
#[error("Request too large: {0}")]
RequestTooLarge(String),
#[error("Model not available: {0}")]
ModelNotAvailable(String),
#[error("Model not configured")]
ModelNotConfigured,
#[error("Tool execution error: {0}")]
ToolExecution(String),
#[error("Message store error: {0}")]
MessageStore(String),
#[error("Event emission error: {0}")]
EventEmission(String),
#[error("Configuration error: {0}")]
Configuration(String),
#[error("Max iterations ({0}) reached")]
MaxIterationsReached(usize),
#[error("Loop cancelled")]
Cancelled,
#[error("No messages to process")]
NoMessages,
#[error("Agent not found: {0}")]
AgentNotFound(AgentId),
#[error("Harness not found: {0}")]
HarnessNotFound(HarnessId),
#[error("Session not found: {0}")]
SessionNotFound(SessionId),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
#[error(
"No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
)]
DriverNotRegistered(String),
}
impl AgentLoopError {
pub fn with_provider(mut self, provider: &str) -> Self {
let prefix = format!("provider '{provider}': ");
match &mut self {
AgentLoopError::Llm(error) if !error.message.starts_with(&prefix) => {
error.message.insert_str(0, &prefix)
}
AgentLoopError::RequestTooLarge(message) | AgentLoopError::Configuration(message)
if !message.starts_with(&prefix) =>
{
message.insert_str(0, &prefix)
}
_ => {}
}
self
}
pub fn llm(msg: impl Into<String>) -> Self {
AgentLoopError::Llm(LlmError {
kind: LlmErrorKind::Other,
message: msg.into(),
retry_attempts: 0,
retry_wait_ms: 0,
retry_handled: false,
})
}
pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
AgentLoopError::Llm(LlmError {
kind,
message: msg.into(),
retry_attempts: 0,
retry_wait_ms: 0,
retry_handled: false,
})
}
pub fn with_retry_metadata(mut self, metadata: &crate::llm_retry::RetryMetadata) -> Self {
if let AgentLoopError::Llm(error) = &mut self {
error.retry_attempts = metadata.attempts;
error.retry_wait_ms = metadata.total_retry_wait.as_millis() as u64;
error.retry_handled = true;
}
self
}
pub fn llm_retry_attempts(&self) -> u32 {
match self {
AgentLoopError::Llm(error) => error.retry_attempts,
_ => 0,
}
}
pub fn llm_retry_handled(&self) -> bool {
matches!(self, AgentLoopError::Llm(error) if error.retry_handled)
}
pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
match self {
AgentLoopError::Llm(err) => Some(err.kind),
_ => None,
}
}
pub fn tool(msg: impl Into<String>) -> Self {
AgentLoopError::ToolExecution(msg.into())
}
pub fn store(msg: impl Into<String>) -> Self {
AgentLoopError::MessageStore(msg.into())
}
pub fn event(msg: impl Into<String>) -> Self {
AgentLoopError::EventEmission(msg.into())
}
pub fn config(msg: impl Into<String>) -> Self {
AgentLoopError::Configuration(msg.into())
}
pub fn agent_not_found(agent_id: AgentId) -> Self {
AgentLoopError::AgentNotFound(agent_id)
}
pub fn harness_not_found(harness_id: HarnessId) -> Self {
AgentLoopError::HarnessNotFound(harness_id)
}
pub fn session_not_found(session_id: SessionId) -> Self {
AgentLoopError::SessionNotFound(session_id)
}
pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
AgentLoopError::DriverNotRegistered(provider_type.into())
}
pub fn request_too_large(msg: impl Into<String>) -> Self {
AgentLoopError::RequestTooLarge(msg.into())
}
pub fn model_not_available(model_id: impl Into<String>) -> Self {
AgentLoopError::ModelNotAvailable(model_id.into())
}
pub fn model_not_configured() -> Self {
AgentLoopError::ModelNotConfigured
}
pub fn is_request_too_large(&self) -> bool {
matches!(self, AgentLoopError::RequestTooLarge(_))
}
pub fn is_model_not_available(&self) -> bool {
matches!(self, AgentLoopError::ModelNotAvailable(_))
}
pub fn model_not_available_id(&self) -> Option<&str> {
match self {
AgentLoopError::ModelNotAvailable(id) => Some(id),
_ => None,
}
}
pub fn is_rate_limited(&self) -> bool {
match self {
AgentLoopError::Llm(err) => match err.kind {
LlmErrorKind::RateLimited => true,
LlmErrorKind::Other => {
let msg_lower = err.message.to_ascii_lowercase();
msg_lower.contains("(429)")
|| msg_lower.contains("rate limit")
|| msg_lower.contains("too many requests")
}
_ => false,
},
_ => false,
}
}
pub fn is_auth_error(&self) -> bool {
match self {
AgentLoopError::Llm(err) => match err.kind {
LlmErrorKind::Authentication => true,
LlmErrorKind::Other => {
err.message.contains("(401)") || err.message.contains("(403)")
}
_ => false,
},
_ => false,
}
}
pub fn is_server_error(&self) -> bool {
match self {
AgentLoopError::Llm(err) => match err.kind {
LlmErrorKind::Unavailable => true,
LlmErrorKind::Other => {
let msg = &err.message;
msg.contains("(500)")
|| msg.contains("(502)")
|| msg.contains("(503)")
|| msg.contains("(504)")
|| msg.contains("(529)")
}
_ => false,
},
_ => false,
}
}
pub fn is_transient_llm_error(&self) -> bool {
match self {
AgentLoopError::Llm(err) => match err.kind {
LlmErrorKind::RateLimited | LlmErrorKind::Unavailable => true,
LlmErrorKind::Authentication
| LlmErrorKind::QuotaExhausted
| LlmErrorKind::AttestationRequired
| LlmErrorKind::InvalidRequest => false,
LlmErrorKind::Other => crate::llm_retry::is_transient_error_message(&err.message),
},
_ => false,
}
}
pub fn is_non_retryable(&self) -> bool {
match self {
AgentLoopError::AgentNotFound(_)
| AgentLoopError::HarnessNotFound(_)
| AgentLoopError::SessionNotFound(_)
| AgentLoopError::NoMessages
| AgentLoopError::ModelNotConfigured => true,
AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
_ => false,
}
}
pub fn user_facing_message(&self) -> String {
self.user_facing_error(UserFacingErrorContext::default())
.fallback_message()
}
pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
match self {
AgentLoopError::ModelNotConfigured => {
UserFacingError::new(user_facing_error_codes::MODEL_NOT_CONFIGURED)
}
AgentLoopError::ModelNotAvailable(model_id) => {
UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
.with_field("model_id", model_id)
.with_optional_field("provider", context.provider)
}
AgentLoopError::RequestTooLarge(_) => {
UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
.with_optional_field("provider", context.provider)
.with_optional_field("model_id", context.model_id)
}
AgentLoopError::MaxIterationsReached(max_iterations) => {
UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
.with_field("max_iterations", max_iterations)
}
AgentLoopError::Llm(err) => {
let code = match err.kind {
LlmErrorKind::Authentication => {
Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
}
LlmErrorKind::QuotaExhausted => {
Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
}
LlmErrorKind::RateLimited => {
Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
}
LlmErrorKind::Unavailable => {
Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
}
LlmErrorKind::AttestationRequired => {
Some(user_facing_error_codes::PROVIDER_ATTESTATION_REQUIRED)
}
LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
};
match code {
Some(code) => {
let error = UserFacingError::new(code)
.with_optional_field("provider", context.provider)
.with_optional_field("model_id", context.model_id);
if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
error.with_optional_field("retry_after", context.retry_after)
} else if code == user_facing_error_codes::PROVIDER_ATTESTATION_REQUIRED {
parse_attestation_requirement(&err.message)
.unwrap_or_else(AttestationRequirement::fallback)
.apply_fields(error)
} else {
error
}
}
None => classify_runtime_error_message(&err.message, &context),
}
}
_ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
.with_optional_field("provider", context.provider)
.with_optional_field("model_id", context.model_id),
}
}
}
pub trait StoreResultExt<T> {
fn store_err(self) -> Result<T>;
}
impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
fn store_err(self) -> Result<T> {
self.map_err(|e| AgentLoopError::store(e.to_string()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileSystemErrorClass {
NotFound,
ReadOnly,
IsADirectory,
NotADirectory,
NotEmpty,
Other,
}
#[derive(Debug, Error)]
pub enum FileSystemError {
#[error("{0}")]
NotFound(String),
#[error("{0}")]
ReadOnly(String),
#[error("{0}")]
IsADirectory(String),
#[error("{0}")]
NotADirectory(String),
#[error("{0}")]
NotEmpty(String),
}
impl FileSystemError {
fn class(&self) -> FileSystemErrorClass {
match self {
FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
}
}
}
pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
where
E: std::error::Error + 'static,
{
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(current) = source {
if let Some(typed) = current.downcast_ref::<FileSystemError>() {
return typed.class();
}
source = current.source();
}
let msg = err.to_string();
if msg.contains("readonly") {
FileSystemErrorClass::ReadOnly
} else if msg.contains("is a directory") {
FileSystemErrorClass::IsADirectory
} else if msg.contains("not a directory") {
FileSystemErrorClass::NotADirectory
} else if msg.contains("not empty") || msg.contains("recursive") {
FileSystemErrorClass::NotEmpty
} else if msg.contains("not found") {
FileSystemErrorClass::NotFound
} else {
FileSystemErrorClass::Other
}
}
pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
serde_json::to_value(value).unwrap_or_default()
}
pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
serde_json::from_value(value).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn filesystem_typed_errors_win_over_conflicting_messages_and_wrappers() {
for (error, expected) in [
(
FileSystemError::NotFound("readonly".into()),
FileSystemErrorClass::NotFound,
),
(
FileSystemError::ReadOnly("not found".into()),
FileSystemErrorClass::ReadOnly,
),
(
FileSystemError::IsADirectory("not empty".into()),
FileSystemErrorClass::IsADirectory,
),
(
FileSystemError::NotADirectory("is a directory".into()),
FileSystemErrorClass::NotADirectory,
),
(
FileSystemError::NotEmpty("not found".into()),
FileSystemErrorClass::NotEmpty,
),
] {
assert_eq!(classify_fs_error(&error), expected);
let wrapped = AgentLoopError::Internal(
anyhow::Error::new(error).context("readonly outer failure"),
);
assert_eq!(classify_fs_error(&wrapped), expected);
}
}
#[test]
fn filesystem_legacy_messages_preserve_routing_and_case_boundaries() {
for (message, expected) in [
(
"Cannot modify readonly file: /a",
FileSystemErrorClass::ReadOnly,
),
(
"Cannot delete readonly file: /a",
FileSystemErrorClass::ReadOnly,
),
(
"write target is a directory: /a",
FileSystemErrorClass::IsADirectory,
),
(
"Path is not a directory: /a",
FileSystemErrorClass::NotADirectory,
),
(
"workspace root is not a directory: /a",
FileSystemErrorClass::NotADirectory,
),
("Directory not found: /a", FileSystemErrorClass::NotFound),
(
"Directory is not empty. Use recursive=true to delete",
FileSystemErrorClass::NotEmpty,
),
(
"Cannot delete root directory without recursive flag",
FileSystemErrorClass::NotEmpty,
),
(
"recursive delete failed for /a: io",
FileSystemErrorClass::NotEmpty,
),
("readonly file not found", FileSystemErrorClass::ReadOnly),
("file is read-only: /a", FileSystemErrorClass::Other),
("NOT FOUND", FileSystemErrorClass::Other),
("disk full", FileSystemErrorClass::Other),
] {
assert_eq!(
classify_fs_error(&AgentLoopError::store(message)),
expected,
"{message}"
);
}
}
#[test]
fn typed_request_and_model_errors_preserve_identity_and_safe_user_payload() {
let context = || {
UserFacingErrorContext::default()
.with_provider("provider")
.with_model_id("context-model")
.with_retry_after(9)
};
let request = AgentLoopError::request_too_large("private payload");
assert_eq!(request.to_string(), "Request too large: private payload");
assert!(request.is_request_too_large());
assert!(!request.is_model_not_available());
assert_eq!(request.model_not_available_id(), None);
assert_eq!(
serde_json::to_value(request.user_facing_error(context())).unwrap(),
json!({"code":"request_too_large","fields":{"provider":"provider","model_id":"context-model"}})
);
assert_eq!(
request.user_facing_message(),
"The conversation has become too long for the model to process. Please start a new session or reduce the context size."
);
let model = AgentLoopError::model_not_available("gpt-99")
.with_provider("custom")
.with_provider("custom");
assert!(!model.is_request_too_large());
assert!(model.is_model_not_available());
assert_eq!(model.model_not_available_id(), Some("gpt-99"));
assert_eq!(model.to_string(), "Model not available: gpt-99");
assert_eq!(
model.user_facing_message(),
"The model `gpt-99` is not available. It may have been removed, renamed, or your API key may not have access to it. Please select a different model."
);
assert_eq!(
serde_json::to_value(model.user_facing_error(context())).unwrap(),
json!({"code":"model_unavailable","fields":{"provider":"provider","model_id":"gpt-99"}})
);
for other in [
AgentLoopError::llm("Request too large: Model not available: gpt-99"),
AgentLoopError::tool("failed"),
AgentLoopError::Cancelled,
] {
assert!(!other.is_request_too_large());
assert!(!other.is_model_not_available());
assert_eq!(other.model_not_available_id(), None);
}
}
#[test]
fn semantic_kinds_override_conflicting_text_for_predicates_and_payloads() {
for (kind, message, predicates, code) in [
(
LlmErrorKind::Authentication,
"(429) rate limit (503)",
(false, true, false, false),
"provider_misconfigured",
),
(
LlmErrorKind::QuotaExhausted,
"(401) (429) rate limit (503)",
(false, false, false, false),
"provider_quota_exhausted",
),
(
LlmErrorKind::RateLimited,
"(401) (503) insufficient_quota",
(true, false, false, true),
"provider_rate_limited",
),
(
LlmErrorKind::Unavailable,
"(401) (429) insufficient_quota",
(false, false, true, true),
"provider_unavailable",
),
(
LlmErrorKind::InvalidRequest,
"opaque private failure",
(false, false, false, false),
"processing_error",
),
] {
let error = AgentLoopError::llm_kind(kind, message);
assert_eq!(error.llm_error_kind(), Some(kind));
assert_eq!(
(
error.is_rate_limited(),
error.is_auth_error(),
error.is_server_error(),
error.is_transient_llm_error()
),
predicates,
"{kind:?}"
);
let mut fields = json!({"provider":"provider","model_id":"model"});
if kind == LlmErrorKind::RateLimited {
fields["retry_after"] = json!(12);
}
assert_eq!(
serde_json::to_value(
error.user_facing_error(
UserFacingErrorContext::default()
.with_provider("provider")
.with_model_id("model")
.with_retry_after(12)
)
)
.unwrap(),
json!({"code":code,"fields":fields})
);
}
}
#[test]
fn legacy_predicates_and_user_copy_use_independent_literal_cases() {
for (message, expected, copy) in [
(
"Anthropic API error (429): rate limit exceeded",
(true, false, false),
"Rate limited by the AI provider. Please wait a moment.",
),
(
"Rate limit exceeded (after 2 retries)",
(true, false, false),
"Rate limited by the AI provider. Please wait a moment.",
),
(
"too many requests",
(true, false, false),
"Rate limited by the AI provider. Please wait a moment.",
),
(
"Anthropic API error (401): invalid api key",
(false, true, false),
"There is a misconfiguration with the AI provider. Please contact support.",
),
(
"OpenAI API error (403): forbidden",
(false, true, false),
"There is a misconfiguration with the AI provider. Please contact support.",
),
(
"Anthropic API error (500): internal server error",
(false, false, true),
"The AI provider is experiencing issues. Please try again shortly.",
),
(
"OpenAI API error (503): service unavailable",
(false, false, true),
"The AI provider is experiencing issues. Please try again shortly.",
),
(
"Failed to send request: connection refused",
(false, false, false),
"I encountered an error while processing your request. Please try again later.",
),
] {
let error = AgentLoopError::llm(message);
assert_eq!(
(
error.is_rate_limited(),
error.is_auth_error(),
error.is_server_error()
),
expected,
"{message}"
);
assert_eq!(error.user_facing_message(), copy, "{message}");
}
for status in [502, 504, 529] {
assert!(AgentLoopError::llm(format!("error ({status})")).is_server_error());
}
let non_llm = AgentLoopError::tool("(401) (429) (503) rate limit");
assert_eq!(
(
non_llm.is_rate_limited(),
non_llm.is_auth_error(),
non_llm.is_server_error(),
non_llm.is_transient_llm_error()
),
(false, false, false, false)
);
}
#[test]
fn provider_status_classification_covers_boundaries_and_quota_precedence() {
for (status, expected) in [
(200, LlmErrorKind::Other),
(399, LlmErrorKind::Other),
(400, LlmErrorKind::InvalidRequest),
(401, LlmErrorKind::Authentication),
(403, LlmErrorKind::Authentication),
(404, LlmErrorKind::InvalidRequest),
(408, LlmErrorKind::Unavailable),
(409, LlmErrorKind::Unavailable),
(429, LlmErrorKind::RateLimited),
(499, LlmErrorKind::InvalidRequest),
(500, LlmErrorKind::Unavailable),
(501, LlmErrorKind::Other),
(502, LlmErrorKind::Unavailable),
(503, LlmErrorKind::Unavailable),
(529, LlmErrorKind::Unavailable),
(599, LlmErrorKind::Unavailable),
(600, LlmErrorKind::Other),
] {
assert_eq!(
LlmErrorKind::from_provider_status(status, "opaque"),
expected,
"{status}"
);
}
for message in [
r#"{"error":{"type":"insufficient_quota"}}"#,
r#"{"error":{"code":"credit_balance_exhausted"}}"#,
r#"{"error":{"type":"usage_limit_reached"}}"#,
"Your credit balance is too low to access the Anthropic API.",
] {
for status in [400, 401, 429, 503] {
assert_eq!(
LlmErrorKind::from_provider_status(status, message),
LlmErrorKind::QuotaExhausted,
"{status}: {message}"
);
}
}
}
const ATTESTATION_BODY: &str = r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation. Confirm at https://openrouter.ai/settings/preferences.","code":403,"metadata":{"missing_attestation_types":["age_18plus"],"routing_funnel":[{"step":"Initial Endpoints","endpoint_count":1}],"failed_routing_step":"Gate Endpoints with Attestations"}}}"#;
#[test]
fn attestation_gate_is_classified_apart_from_other_403s() {
assert_eq!(
LlmErrorKind::from_provider_status(403, ATTESTATION_BODY),
LlmErrorKind::AttestationRequired
);
assert_eq!(
LlmErrorKind::from_provider_status(429, ATTESTATION_BODY),
LlmErrorKind::AttestationRequired
);
for body in [
r#"{"error":{"message":"Invalid credentials","code":403}}"#,
r#"{"error":{"message":"Insufficient credits","code":403,"metadata":{"routing_funnel":[]}}}"#,
"opaque",
] {
assert_ne!(
LlmErrorKind::from_provider_status(403, body),
LlmErrorKind::AttestationRequired,
"{body}"
);
}
assert_eq!(
LlmErrorKind::from_provider_status(
403,
r#"{"error":{"message":"insufficient_quota; requires you to complete the following before use"}}"#
),
LlmErrorKind::QuotaExhausted
);
}
#[test]
fn attestation_gate_reaches_the_reader_with_the_types_and_the_confirm_url() {
let error = AgentLoopError::llm_kind(
LlmErrorKind::AttestationRequired,
format!("OpenAI Responses API error (403): {ATTESTATION_BODY}"),
)
.with_provider("openrouter");
assert!(!error.is_auth_error());
assert!(!error.is_transient_llm_error());
assert_eq!(
serde_json::to_value(
error.user_facing_error(
UserFacingErrorContext::default()
.with_provider("openrouter")
.with_model_id("meta/muse-spark-1.3-contributor")
)
)
.unwrap(),
json!({
"code": "provider_attestation_required",
"fields": {
"provider": "openrouter",
"model_id": "meta/muse-spark-1.3-contributor",
"missing_types": ["age_18plus"],
"confirm_url": "https://openrouter.ai/settings/preferences",
}
})
);
assert_eq!(
error.user_facing_message(),
"The AI provider account has not completed a confirmation this model requires (age_18plus). Complete it at https://openrouter.ai/settings/preferences, then try again."
);
}
#[test]
fn untyped_attestation_bodies_still_route_off_the_403_misconfiguration_copy() {
let error = AgentLoopError::llm(format!(
"provider 'openrouter': OpenAI Responses API error (403): {ATTESTATION_BODY}"
));
assert_eq!(
error
.user_facing_error(UserFacingErrorContext::default())
.code,
"provider_attestation_required"
);
}
#[test]
fn attestation_parsing_covers_multiple_types_escaped_bodies_and_a_missing_url() {
let requirement = |body: &str| {
parse_attestation_requirement(body).unwrap_or_else(|| panic!("no gate in {body}"))
};
let multiple = requirement(
r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation and identity verification. Confirm at https://openrouter.ai/settings/preferences.","metadata":{"missing_attestation_types":["age_18plus","identity_verified"]}}}"#,
);
assert_eq!(multiple.missing_types, ["age_18plus", "identity_verified"]);
assert_eq!(
multiple.confirm_url,
"https://openrouter.ai/settings/preferences"
);
let escaped = requirement(
r#"{"detail":"{\"error\":{\"message\":\"This model requires you to complete the following before use: 18+ age confirmation. Confirm at https:\/\/openrouter.ai\/settings\/gates.\",\"metadata\":{\"missing_attestation_types\":[\"age_18plus\"]}}}"}"#,
);
assert_eq!(escaped.missing_types, ["age_18plus"]);
assert_eq!(escaped.confirm_url, "https://openrouter.ai/settings/gates");
let no_url = requirement(
r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation.","metadata":{"missing_attestation_types":["age_18plus"]}}}"#,
);
assert_eq!(
no_url.confirm_url,
"https://openrouter.ai/settings/preferences"
);
let sentence_only = requirement(
"This model requires you to complete the following before use: 18+ age confirmation. Confirm at https://openrouter.ai/settings/preferences",
);
assert!(sentence_only.missing_types.is_empty());
assert_eq!(
sentence_only.confirm_url,
"https://openrouter.ai/settings/preferences"
);
assert_eq!(
AgentLoopError::llm_kind(
LlmErrorKind::AttestationRequired,
"This model requires you to complete the following before use: a confirmation."
)
.user_facing_message(),
"The AI provider account has not completed a confirmation this model requires. Complete it at https://openrouter.ai/settings/preferences, then try again."
);
assert_eq!(
requirement(&format!(
"POST https://openrouter.ai/api/v1/responses failed: {ATTESTATION_BODY}"
))
.confirm_url,
"https://openrouter.ai/settings/preferences"
);
for body in [
r#"{"error":{"message":"Invalid credentials"}}"#,
r#"{"error":{"metadata":{"missing_attestation_types":[]}}}"#,
"",
] {
assert!(parse_attestation_requirement(body).is_none(), "{body}");
}
}
#[test]
fn a_hostile_attestation_payload_cannot_choose_how_much_reaches_the_viewer() {
let types = (0..40)
.map(|index| format!(r#""gate_{index}""#))
.collect::<Vec<_>>()
.join(",");
let long_type = "x".repeat(65);
let long_url = format!("https://evil.example/{}", "a".repeat(400));
let requirement = parse_attestation_requirement(&format!(
r#"{{"error":{{"message":"This model requires you to complete the following before use: gates. Confirm at {long_url}","metadata":{{"missing_attestation_types":["{long_type}",{types}]}}}}}}"#
))
.expect("gate recognized");
assert_eq!(requirement.missing_types.len(), 8);
assert_eq!(requirement.missing_types[0], "gate_0");
assert_eq!(
requirement.confirm_url,
"https://openrouter.ai/settings/preferences"
);
for scheme in [
"javascript:alert(1)",
"data:text/html,<script>",
"file:///etc/passwd",
] {
assert_eq!(
parse_attestation_requirement(&format!(
"This model requires you to complete the following before use: a gate. Confirm at {scheme}"
))
.expect("gate recognized")
.confirm_url,
"https://openrouter.ai/settings/preferences",
"{scheme}"
);
}
}
#[test]
fn provider_text_classification_uses_independent_keywords_and_precedence() {
for (message, expected) in [
("ThrottlingException", LlmErrorKind::RateLimited),
("TooManyRequestsException", LlmErrorKind::RateLimited),
("RATE LIMIT", LlmErrorKind::RateLimited),
("too many requests", LlmErrorKind::RateLimited),
("AccessDeniedException", LlmErrorKind::Authentication),
("UnrecognizedClientException", LlmErrorKind::Authentication),
("ExpiredTokenException", LlmErrorKind::Authentication),
("InvalidSignatureException", LlmErrorKind::Authentication),
("unauthorized", LlmErrorKind::Authentication),
("ServiceUnavailableException", LlmErrorKind::Unavailable),
("service unavailable", LlmErrorKind::Unavailable),
("InternalServerException", LlmErrorKind::Unavailable),
("ModelNotReadyException", LlmErrorKind::Unavailable),
(
"usage_limit_reached; resets_at=1783767823; throttlingexception",
LlmErrorKind::QuotaExhausted,
),
("something else entirely", LlmErrorKind::Other),
] {
assert_eq!(
LlmErrorKind::from_error_text(message),
expected,
"{message}"
);
}
}
#[test]
fn provider_prefix_preserves_kind_and_retry_metadata_without_duplication() {
let metadata = crate::llm_retry::RetryMetadata {
attempts: 2,
total_retry_wait: std::time::Duration::from_millis(1234),
..Default::default()
};
let error = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "network failure")
.with_retry_metadata(&metadata)
.with_provider("custom")
.with_provider("custom");
assert_eq!(error.llm_retry_attempts(), 2);
assert!(error.llm_retry_handled());
let AgentLoopError::Llm(error) = error else {
panic!("lost LLM variant")
};
assert_eq!(
serde_json::to_value(error).unwrap(),
json!({"kind":"unavailable","message":"provider 'custom': network failure","retry_attempts":2,"retry_wait_ms":1234,"retry_handled":true})
);
let legacy: LlmError =
serde_json::from_value(json!({"kind":"other","message":"legacy"})).unwrap();
assert_eq!(
serde_json::to_value(legacy).unwrap(),
json!({"kind":"other","message":"legacy","retry_attempts":0,"retry_wait_ms":0,"retry_handled":false})
);
let non_llm = AgentLoopError::Cancelled
.with_retry_metadata(&metadata)
.with_provider("custom");
assert!(matches!(non_llm, AgentLoopError::Cancelled));
assert_eq!(non_llm.llm_retry_attempts(), 0);
assert!(!non_llm.llm_retry_handled());
}
#[test]
fn missing_model_and_iteration_limits_have_complete_safe_payloads() {
let missing = AgentLoopError::model_not_configured();
assert!(missing.is_non_retryable());
assert_eq!(
missing.user_facing_message(),
"No model is configured for this chat. Choose a model or configure a default model, then try again."
);
assert_eq!(
serde_json::to_value(missing.user_facing_error(UserFacingErrorContext::default()))
.unwrap(),
json!({"code":"model_not_configured"})
);
assert_eq!(
serde_json::to_value(
AgentLoopError::MaxIterationsReached(7)
.user_facing_error(UserFacingErrorContext::default())
)
.unwrap(),
json!({"code":"max_iterations","fields":{"max_iterations":7}})
);
}
#[test]
fn store_adapter_preserves_success_and_exact_error_variant_and_message() {
let success: std::result::Result<Vec<String>, String> =
Ok(vec!["first".into(), "second".into()]);
assert_eq!(success.store_err().unwrap(), ["first", "second"]);
let failure: std::result::Result<(), std::io::Error> =
Err(std::io::Error::other("db unavailable"));
let error = failure.store_err().unwrap_err();
assert_eq!(error.to_string(), "Message store error: db unavailable");
assert!(matches!(error,AgentLoopError::MessageStore(message) if message=="db unavailable"));
}
#[test]
fn json_helpers_preserve_structures_and_apply_documented_error_defaults() {
assert_eq!(json_val(&vec![1, 2, 3]), json!([1, 2, 3]));
assert_eq!(from_json::<Vec<String>>(json!(["a", "b"])), ["a", "b"]);
assert_eq!(from_json::<i32>(json!("not a number")), 0);
struct Fails;
impl Serialize for Fails {
fn serialize<S: serde::Serializer>(
&self,
_: S,
) -> std::result::Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("synthetic serialization failure"))
}
}
assert_eq!(json_val(&Fails), serde_json::Value::Null);
}
}