openrouter-rs 0.14.0

A type-safe OpenRouter Rust SDK
Documentation
use std::{
    io::{Read, Write},
    net::TcpListener,
    sync::mpsc,
    thread,
    time::Duration,
};

use openrouter_rs::api::analytics::{
    self, AnalyticsClassifierDimensions, AnalyticsClassifierFilters, AnalyticsFilter,
    AnalyticsFilterValue, AnalyticsMeta, AnalyticsOrderBy, AnalyticsQueryRequest,
    AnalyticsQueryResponse, AnalyticsTimeRange,
};
use serde_json::json;

struct CapturedRequest {
    request_line: String,
    request_text: String,
    body_text: String,
}

fn spawn_json_server(
    response_body: &str,
) -> (
    String,
    mpsc::Receiver<CapturedRequest>,
    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 body = response_body.to_string();
    let (tx, rx) = mpsc::channel::<CapturedRequest>();

    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];
        let header_end = loop {
            let read = stream.read(&mut chunk).expect("server should read request");
            if read == 0 {
                break None;
            }
            request_bytes.extend_from_slice(&chunk[..read]);
            if let Some(pos) = request_bytes
                .windows(4)
                .position(|window| window == b"\r\n\r\n")
            {
                break Some(pos + 4);
            }
        }
        .expect("request should contain header terminator");

        let header_text = String::from_utf8_lossy(&request_bytes[..header_end]).to_string();
        let request_line = header_text.lines().next().unwrap_or_default().to_string();
        let content_length = header_text
            .lines()
            .find_map(|line| {
                let lower = line.to_ascii_lowercase();
                if lower.starts_with("content-length:") {
                    line.split(':').nth(1)?.trim().parse::<usize>().ok()
                } else {
                    None
                }
            })
            .unwrap_or(0);
        let mut body_bytes = request_bytes[header_end..].to_vec();
        while body_bytes.len() < content_length {
            let read = stream
                .read(&mut chunk)
                .expect("server should read request body");
            if read == 0 {
                break;
            }
            body_bytes.extend_from_slice(&chunk[..read]);
        }

        let body_text = String::from_utf8_lossy(&body_bytes[..content_length]).to_string();
        let request_text = format!("{header_text}{body_text}");
        tx.send(CapturedRequest {
            request_line,
            request_text,
            body_text,
        })
        .expect("server should send request");

        let response = format!(
            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
            body.len(),
            body
        );
        stream
            .write_all(response.as_bytes())
            .expect("server should write response");
    });

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

#[test]
fn test_analytics_meta_response_deserializes() {
    let raw = r#"{
        "data": {
            "metrics": [{
                "name": "request_count",
                "display_label": "Request Count",
                "is_rate": false,
                "display_format": "number"
            }],
            "dimensions": [{
                "name": "model",
                "display_label": "Model"
            }],
            "operators": [{
                "name": "eq",
                "value_type": "scalar"
            }],
            "granularities": [{
                "name": "day",
                "display_label": "Day"
            }]
        }
    }"#;

    let parsed: openrouter_rs::types::ApiResponse<AnalyticsMeta> =
        serde_json::from_str(raw).expect("analytics meta should deserialize");
    assert_eq!(parsed.data.metrics[0].name, "request_count");
    assert_eq!(parsed.data.granularities[0].name, "day");
}

#[test]
fn test_analytics_query_response_deserializes() {
    let raw = r#"{
        "data": {
            "cachedAt": 1780000000.0,
            "data": [{
                "date__day": "2026-06-15T00:00:00.000Z",
                "request_count": 1500
            }],
            "metadata": {
                "query_time_ms": 42,
                "row_count": 1,
                "truncated": false
            },
            "warnings": ["unresolved api_key_id hash"]
        }
    }"#;

    let parsed: openrouter_rs::types::ApiResponse<AnalyticsQueryResponse> =
        serde_json::from_str(raw).expect("analytics query response should deserialize");
    assert_eq!(parsed.data.metadata.row_count, 1);
    assert_eq!(parsed.data.data[0]["request_count"], json!(1500));
    assert_eq!(
        parsed.data.warnings.as_deref(),
        Some(["unresolved api_key_id hash".to_string()].as_slice())
    );
}

#[tokio::test]
async fn test_get_analytics_meta_path_and_auth_header() {
    let (base_url, rx, server) = spawn_json_server(
        r#"{"data":{"metrics":[],"dimensions":[],"operators":[],"granularities":[]}}"#,
    );

    let meta = analytics::get_analytics_meta(&base_url, "management-key")
        .await
        .expect("analytics meta request should succeed");
    assert!(meta.metrics.is_empty());

    let captured = rx
        .recv_timeout(Duration::from_secs(2))
        .expect("should capture request");
    assert_eq!(captured.request_line, "GET /api/v1/analytics/meta HTTP/1.1");
    let request_lower = captured.request_text.to_ascii_lowercase();
    assert!(
        request_lower.contains("authorization: bearer management-key")
            || request_lower.contains("authorization:bearer management-key"),
        "authorization header should include management key, request:\n{}",
        captured.request_text
    );

    server.join().expect("server thread should finish");
}

#[tokio::test]
async fn test_query_analytics_path_body_and_auth_header() {
    let (base_url, rx, server) = spawn_json_server(
        r#"{"data":{"data":[],"metadata":{"query_time_ms":5,"row_count":0,"truncated":false}}}"#,
    );
    let request = AnalyticsQueryRequest::builder()
        .metrics(vec!["request_count".to_string()])
        .dimensions(vec!["model".to_string()])
        .filters(vec![AnalyticsFilter {
            field: "model".to_string(),
            operator: "eq".to_string(),
            value: AnalyticsFilterValue::String("openai/gpt-5".to_string()),
        }])
        .granularity("day")
        .time_range(AnalyticsTimeRange {
            start: "2026-06-01T00:00:00Z".to_string(),
            end: "2026-06-15T00:00:00Z".to_string(),
        })
        .order_by(AnalyticsOrderBy {
            field: "request_count".to_string(),
            direction: "desc".to_string(),
        })
        .limit(100)
        .group_limit(10)
        .classifier_dimensions(
            AnalyticsClassifierDimensions::builder()
                .classifier_id("classifier-1")
                .dimension_names(["topic"])
                .include_nulls(true)
                .build()
                .expect("classifier dimensions should build"),
        )
        .classifier_filters(
            AnalyticsClassifierFilters::builder()
                .classifier_id("classifier-1")
                .filters([AnalyticsFilter {
                    field: "topic".to_string(),
                    operator: "eq".to_string(),
                    value: AnalyticsFilterValue::String("coding".to_string()),
                }])
                .build()
                .expect("classifier filters should build"),
        )
        .build()
        .expect("analytics query request should build");

    let response = analytics::query_analytics(&base_url, "management-key", &request)
        .await
        .expect("analytics query request should succeed");
    assert_eq!(response.metadata.row_count, 0);

    let captured = rx
        .recv_timeout(Duration::from_secs(2))
        .expect("should capture request");
    assert_eq!(
        captured.request_line,
        "POST /api/v1/analytics/query HTTP/1.1"
    );
    let body: serde_json::Value =
        serde_json::from_str(&captured.body_text).expect("request body should be json");
    assert_eq!(body["metrics"][0], "request_count");
    assert_eq!(body["dimensions"][0], "model");
    assert_eq!(body["filters"][0]["value"], "openai/gpt-5");
    assert_eq!(body["time_range"]["start"], "2026-06-01T00:00:00Z");
    assert_eq!(body["classifier_dimensions"]["dimension_names"][0], "topic");
    assert_eq!(body["classifier_filters"]["filters"][0]["value"], "coding");

    let request_lower = captured.request_text.to_ascii_lowercase();
    assert!(
        request_lower.contains("authorization: bearer management-key")
            || request_lower.contains("authorization:bearer management-key"),
        "authorization header should include management key, request:\n{}",
        captured.request_text
    );

    server.join().expect("server thread should finish");
}