openrouter-rs 0.14.0

A type-safe OpenRouter Rust SDK
Documentation
use http::StatusCode;
use std::{
    io::{Read, Write},
    net::TcpListener,
    thread,
};

use openrouter_rs::{
    api::models,
    error::{ApiErrorKind, HttpRequestError, OpenRouterError},
};

fn spawn_error_server(
    status_line: &str,
    body: &str,
    request_id: Option<&str>,
) -> (String, thread::JoinHandle<()>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
    let addr = listener
        .local_addr()
        .expect("listener should have local addr");
    let status_line = status_line.to_string();
    let body = body.to_string();
    let request_id = request_id.map(ToOwned::to_owned);

    let server = thread::spawn(move || {
        let (mut stream, _) = listener
            .accept()
            .expect("server should accept one connection");

        let mut request_bytes = Vec::new();
        let mut chunk = [0_u8; 1024];
        loop {
            let read = stream.read(&mut chunk).expect("server should read request");
            if read == 0 {
                break;
            }
            request_bytes.extend_from_slice(&chunk[..read]);
            if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
                break;
            }
        }

        let mut headers = format!(
            "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n",
            body.len()
        );
        if let Some(request_id) = request_id {
            headers.push_str(&format!("x-request-id: {request_id}\r\n"));
        }
        headers.push_str("\r\n");
        let response = format!("{headers}{body}");
        stream
            .write_all(response.as_bytes())
            .expect("server should write response");
    });

    (format!("http://{addr}/api/v1"), server)
}

#[tokio::test]
async fn test_normalized_generic_api_error_shape() {
    let (base_url, server) = spawn_error_server(
        "429 Too Many Requests",
        r#"{"error":{"code":429,"message":"Rate limit exceeded"}}"#,
        Some("req_123"),
    );

    let result = models::list_models(&base_url, "test-key", None, None).await;
    let error = result.expect_err("request should fail");
    match error {
        OpenRouterError::Api(api_error) => {
            assert_eq!(api_error.status, StatusCode::TOO_MANY_REQUESTS);
            assert_eq!(api_error.api_code, Some(429));
            assert_eq!(api_error.message, "Rate limit exceeded");
            assert_eq!(api_error.request_id.as_deref(), Some("req_123"));
            assert!(matches!(api_error.kind, ApiErrorKind::Generic));
            assert!(api_error.is_retryable());
        }
        other => panic!("expected Api error, got {other:?}"),
    }

    server
        .join()
        .expect("server thread should join in reasonable time");
}

#[tokio::test]
async fn test_api_error_preserves_top_level_openrouter_metadata() {
    let (base_url, server) = spawn_error_server(
        "403 Forbidden",
        r#"{
            "error": {
                "code": 403,
                "message": "Guardrail blocked request"
            },
            "openrouter_metadata": {
                "attempt": 1,
                "pipeline": [{
                    "type": "guardrail",
                    "guardrail_id": "gr_123",
                    "data": {
                        "action": "block",
                        "patterns": ["secret"]
                    }
                }]
            },
            "user_id": "user_123"
        }"#,
        Some("req_guardrail"),
    );

    let result = models::list_models(&base_url, "test-key", None, None).await;
    let error = result.expect_err("request should fail");
    match error {
        OpenRouterError::Api(api_error) => {
            assert_eq!(api_error.status, StatusCode::FORBIDDEN);
            assert_eq!(api_error.api_code, Some(403));
            assert_eq!(api_error.message, "Guardrail blocked request");
            assert_eq!(api_error.request_id.as_deref(), Some("req_guardrail"));
            assert_eq!(
                api_error
                    .metadata
                    .as_ref()
                    .and_then(|metadata| metadata.get("openrouter_metadata"))
                    .and_then(|metadata| metadata.get("pipeline"))
                    .and_then(|pipeline| pipeline.get(0))
                    .and_then(|stage| stage.get("guardrail_id"))
                    .and_then(|value| value.as_str()),
                Some("gr_123")
            );
            assert_eq!(
                api_error
                    .metadata
                    .as_ref()
                    .and_then(|metadata| metadata.get("user_id"))
                    .and_then(|value| value.as_str()),
                Some("user_123")
            );
        }
        other => panic!("expected Api error, got {other:?}"),
    }

    server
        .join()
        .expect("server thread should join in reasonable time");
}

#[tokio::test]
async fn test_unreadable_error_body_preserves_read_failure_context() {
    let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
    let addr = listener
        .local_addr()
        .expect("listener should have local addr");

    let server = thread::spawn(move || {
        let (mut stream, _) = listener
            .accept()
            .expect("server should accept one connection");
        let mut request_bytes = Vec::new();
        let mut chunk = [0_u8; 1024];
        loop {
            let read = stream.read(&mut chunk).expect("server should read request");
            if read == 0 {
                break;
            }
            request_bytes.extend_from_slice(&chunk[..read]);
            if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
                break;
            }
        }

        let partial_body = "{\"error\":";
        let response = format!(
            "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nx-request-id: req_truncated\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
            partial_body.len() + 32,
            partial_body
        );
        stream
            .write_all(response.as_bytes())
            .expect("server should write partial response");
    });

    let base_url = format!("http://{addr}/api/v1");
    let error = models::list_models(&base_url, "test-key", None, None)
        .await
        .expect_err("request should fail");

    match error {
        OpenRouterError::Api(api_error) => {
            assert_eq!(api_error.status, StatusCode::INTERNAL_SERVER_ERROR);
            assert_eq!(api_error.request_id.as_deref(), Some("req_truncated"));
            assert!(
                api_error
                    .message
                    .contains("Failed to read error response body")
            );
            assert_eq!(
                api_error
                    .metadata
                    .as_ref()
                    .and_then(|metadata| metadata.get("body_read_error"))
                    .and_then(|value| value.as_str())
                    .map(|value| !value.is_empty()),
                Some(true)
            );
        }
        other => panic!("expected Api error, got {other:?}"),
    }

    server
        .join()
        .expect("server thread should join in reasonable time");
}

#[tokio::test]
async fn test_normalized_provider_error_shape() {
    let (base_url, server) = spawn_error_server(
        "502 Bad Gateway",
        r#"{
            "error": {
                "code": 502,
                "message": "Provider overloaded",
                "metadata": {
                    "provider_name": "openai",
                    "raw": {"upstream_code":"E_OVERLOAD"}
                }
            }
        }"#,
        Some("req_provider"),
    );

    let result = models::list_models(&base_url, "test-key", None, None).await;
    let error = result.expect_err("request should fail");
    match error {
        OpenRouterError::Api(api_error) => {
            assert_eq!(api_error.status, StatusCode::BAD_GATEWAY);
            assert_eq!(api_error.api_code, Some(502));
            assert_eq!(api_error.request_id.as_deref(), Some("req_provider"));
            assert!(api_error.is_retryable());
            match api_error.kind {
                ApiErrorKind::Provider { provider_name, raw } => {
                    assert_eq!(provider_name, "openai");
                    assert_eq!(
                        raw.get("upstream_code").and_then(|value| value.as_str()),
                        Some("E_OVERLOAD")
                    );
                }
                other => panic!("expected provider kind, got {other:?}"),
            }
        }
        other => panic!("expected Api error, got {other:?}"),
    }

    server
        .join()
        .expect("server thread should join in reasonable time");
}

#[tokio::test]
async fn test_normalized_moderation_error_shape() {
    let (base_url, server) = spawn_error_server(
        "400 Bad Request",
        r#"{
            "error": {
                "code": 400,
                "message": "Moderation blocked",
                "metadata": {
                    "reasons": ["hate"],
                    "flagged_input": "bad text",
                    "provider_name": "openai",
                    "model_slug": "gpt-4.1"
                }
            }
        }"#,
        Some("req_mod"),
    );

    let result = models::list_models(&base_url, "test-key", None, None).await;
    let error = result.expect_err("request should fail");
    match error {
        OpenRouterError::Api(api_error) => {
            assert_eq!(api_error.status, StatusCode::BAD_REQUEST);
            assert_eq!(api_error.api_code, Some(400));
            assert_eq!(api_error.request_id.as_deref(), Some("req_mod"));
            assert!(!api_error.is_retryable());
            assert_eq!(
                api_error
                    .metadata
                    .as_ref()
                    .and_then(|metadata| metadata.get("provider_name"))
                    .and_then(|value| value.as_str()),
                Some("openai")
            );
            assert_eq!(
                api_error
                    .metadata
                    .as_ref()
                    .and_then(|metadata| metadata.get("model_slug"))
                    .and_then(|value| value.as_str()),
                Some("gpt-4.1")
            );
            match api_error.kind {
                ApiErrorKind::Moderation {
                    reasons,
                    flagged_input,
                    provider_name,
                    model_slug,
                } => {
                    assert_eq!(reasons, vec!["hate"]);
                    assert_eq!(flagged_input, "bad text");
                    assert_eq!(provider_name, "openai");
                    assert_eq!(model_slug, "gpt-4.1");
                }
                other => panic!("expected moderation kind, got {other:?}"),
            }
        }
        other => panic!("expected Api error, got {other:?}"),
    }

    server
        .join()
        .expect("server thread should join in reasonable time");
}

#[tokio::test]
async fn test_plain_text_error_is_still_normalized() {
    let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
    let addr = listener
        .local_addr()
        .expect("listener should have local addr");

    let server = thread::spawn(move || {
        let (mut stream, _) = listener
            .accept()
            .expect("server should accept one connection");
        let mut request_bytes = Vec::new();
        let mut chunk = [0_u8; 1024];
        loop {
            let read = stream.read(&mut chunk).expect("server should read request");
            if read == 0 {
                break;
            }
            request_bytes.extend_from_slice(&chunk[..read]);
            if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
                break;
            }
        }

        let body = "upstream timeout";
        let response = format!(
            "HTTP/1.1 504 Gateway Timeout\r\nContent-Type: text/plain\r\nx-request-id: req_plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
            body.len(),
            body
        );
        stream
            .write_all(response.as_bytes())
            .expect("server should write response");
    });

    let base_url = format!("http://{addr}/api/v1");
    let result = models::list_models(&base_url, "test-key", None, None).await;
    let error = result.expect_err("request should fail");
    match error {
        OpenRouterError::Api(api_error) => {
            assert_eq!(api_error.status, StatusCode::GATEWAY_TIMEOUT);
            assert_eq!(api_error.api_code, Some(504));
            assert_eq!(api_error.request_id.as_deref(), Some("req_plain"));
            assert_eq!(api_error.message, "upstream timeout");
            assert!(matches!(api_error.kind, ApiErrorKind::Generic));
            assert!(api_error.is_retryable());
        }
        other => panic!("expected Api error, got {other:?}"),
    }

    server
        .join()
        .expect("server thread should join in reasonable time");
}

#[test]
fn test_http_request_error_is_backend_neutral() {
    let error = HttpRequestError::new("connection refused");

    assert_eq!(error.message(), "connection refused");
    assert_eq!(error.to_string(), "connection refused");
}