claux 20260730.0.1

Terminal AI coding assistant with tool execution
use reqwest::{Response, StatusCode};
use serde_json::Value;

pub(super) async fn http_error(response: Response, provider: &str, model: &str) -> anyhow::Error {
    let status = response.status();
    let retry_after = response
        .headers()
        .get(reqwest::header::RETRY_AFTER)
        .and_then(|value| value.to_str().ok())
        .map(str::to_string);
    let body = response.text().await.unwrap_or_default();
    let value = serde_json::from_str::<Value>(&body).ok();
    let (error_type, message) = value
        .as_ref()
        .map(extract_details)
        .unwrap_or((None, nonempty(&body)));
    let message = message.map(|message| crate::utils::truncate_str(message, 2_048));

    anyhow::anyhow!(format_error(
        provider,
        model,
        Some(status),
        retry_after.as_deref(),
        error_type,
        message,
    ))
}

pub(super) fn stream_error(event: &Value, provider: &str, model: &str) -> String {
    let (error_type, message) = extract_details(event);
    let status = extract_status(event).or_else(|| error_type.and_then(status_for_type));
    format_error(provider, model, status, None, error_type, message)
}

fn extract_details(value: &Value) -> (Option<&str>, Option<&str>) {
    let response = &value["response"];
    let error = if value["error"].is_object() {
        &value["error"]
    } else if response["error"].is_object() {
        &response["error"]
    } else {
        value
    };
    let error_type = error["metadata"]["error_type"]
        .as_str()
        .or_else(|| error["error_type"].as_str())
        .or_else(|| value["error_type"].as_str())
        .or_else(|| response["error_type"].as_str())
        .or_else(|| error["type"].as_str())
        .or_else(|| error["code"].as_str());
    let message = error["message"]
        .as_str()
        .or_else(|| value["message"].as_str())
        .or_else(|| response["incomplete_details"]["reason"].as_str());
    (error_type, message)
}

fn extract_status(value: &Value) -> Option<StatusCode> {
    let response = &value["response"];
    let error = if value["error"].is_object() {
        &value["error"]
    } else if response["error"].is_object() {
        &response["error"]
    } else {
        value
    };
    error["code"]
        .as_u64()
        .or_else(|| value["code"].as_u64())
        .and_then(|code| u16::try_from(code).ok())
        .and_then(|code| StatusCode::from_u16(code).ok())
}

fn status_for_type(error_type: &str) -> Option<StatusCode> {
    match error_type {
        "authentication" => Some(StatusCode::UNAUTHORIZED),
        "payment_required" => Some(StatusCode::PAYMENT_REQUIRED),
        "permission_denied" => Some(StatusCode::FORBIDDEN),
        "rate_limit_exceeded" => Some(StatusCode::TOO_MANY_REQUESTS),
        "provider_unavailable" => Some(StatusCode::BAD_GATEWAY),
        "provider_overloaded" => Some(StatusCode::SERVICE_UNAVAILABLE),
        "timeout" => Some(StatusCode::GATEWAY_TIMEOUT),
        _ => None,
    }
}

fn format_error(
    provider: &str,
    model: &str,
    status: Option<StatusCode>,
    retry_after: Option<&str>,
    error_type: Option<&str>,
    message: Option<&str>,
) -> String {
    let mut output = format!("{provider} API error");
    if let Some(status) = status {
        output.push_str(&format!(" ({status})"));
    }
    output.push_str(&format!(" for model '{model}'"));
    if let Some(error_type) = error_type {
        output.push_str(&format!(" [{error_type}]"));
    }
    if let Some(message) = message {
        output.push_str(": ");
        output.push_str(message);
    }

    match status {
        Some(StatusCode::TOO_MANY_REQUESTS) => {
            if let Some(retry_after) = retry_after {
                output.push_str(&format!(". Retry after {retry_after}"));
            } else {
                output.push_str(". Retry later or choose another model/provider");
            }
        }
        Some(StatusCode::SERVICE_UNAVAILABLE) | Some(StatusCode::BAD_GATEWAY) => {
            output.push_str(". The provider may be unavailable; retry or choose another model")
        }
        _ => {}
    }
    output
}

fn nonempty(value: &str) -> Option<&str> {
    let value = value.trim();
    (!value.is_empty()).then_some(value)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn formats_rate_limit_with_retry_after() {
        let message = format_error(
            "openrouter",
            "poolside/laguna",
            Some(StatusCode::TOO_MANY_REQUESTS),
            Some("60"),
            Some("rate_limit_exceeded"),
            Some("Rate limit exceeded"),
        );

        assert!(message.contains("429 Too Many Requests"));
        assert!(message.contains("poolside/laguna"));
        assert!(message.contains("rate_limit_exceeded"));
        assert!(message.contains("Retry after 60"));
    }

    #[test]
    fn extracts_openrouter_stream_error() {
        let event = serde_json::json!({
            "error": {
                "code": 429,
                "message": "upstream limit",
                "metadata": {"error_type": "rate_limit_exceeded"}
            }
        });

        let message = stream_error(&event, "openrouter", "model");

        assert!(message.contains("429 Too Many Requests"));
        assert!(message.contains("upstream limit"));
        assert!(message.contains("choose another model/provider"));
    }

    #[test]
    fn extracts_responses_error_type() {
        let event = serde_json::json!({
            "type": "response.failed",
            "response": {
                "error": {"code": "server_error", "message": "Rate limited"},
                "error_type": "rate_limit_exceeded"
            }
        });

        let message = stream_error(&event, "openrouter", "model");

        assert!(message.contains("429 Too Many Requests"));
        assert!(message.contains("rate_limit_exceeded"));
    }
}