openrouter-rust 0.1.0

A modular, type-safe Rust client for the OpenRouter API
Documentation
use mockito::Server;
use openrouter_rust::{
    OpenRouterClient,
    responses::ResponsesRequestBuilder,
    responses::{
        InputItem, InputRole, InputContent, OutputItem, OutputContent,
        ReasoningSummary
    },
};
use serde_json::json;

#[tokio::test]
async fn test_responses_api_basic() {
    let mut server = Server::new_async().await;
    
    let mock_response = json!({
        "id": "resp-test-123",
        "object": "response",
        "created_at": 1704067200.0,
        "model": "anthropic/claude-3.5-sonnet",
        "status": "completed",
        "output": [
            {
                "type": "message",
                "id": "msg-1",
                "role": "assistant",
                "content": [
                    {"type": "output_text", "text": "Rust is a systems programming language."}
                ],
                "status": "completed"
            }
        ],
        "output_text": "Rust is a systems programming language.",
        "usage": {
            "input_tokens": 20,
            "output_tokens": 10,
            "total_tokens": 30,
            "input_tokens_details": {"cached_tokens": 0},
            "output_tokens_details": {"reasoning_tokens": 0}
        }
    });

    let _m = server.mock("POST", "/responses")
        .match_header("authorization", "Bearer test-key")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(mock_response.to_string())
        .create_async()
        .await;

    let client = OpenRouterClient::builder()
        .api_key("test-key")
        .base_url(&server.url())
        .build()
        .unwrap();

    let request = ResponsesRequestBuilder::new("anthropic/claude-3.5-sonnet")
        .user_message("What is Rust?")
        .build();

    let response = client.create_response(request).await.unwrap();

    assert_eq!(response.id, "resp-test-123");
    assert_eq!(response.model, "anthropic/claude-3.5-sonnet");
    assert_eq!(response.status, "completed");
    assert_eq!(response.output.len(), 1);
    
    if let OutputItem::Message { content, .. } = &response.output[0] {
        if let OutputContent::Text { text, .. } = &content[0] {
            assert_eq!(text, "Rust is a systems programming language.");
        } else {
            panic!("Expected text content");
        }
    } else {
        panic!("Expected message output");
    }
    
    let usage = response.usage.unwrap();
    assert_eq!(usage.prompt_tokens, 20);
    assert_eq!(usage.completion_tokens, 10);
}

#[tokio::test]
async fn test_responses_api_with_reasoning() {
    let mut server = Server::new_async().await;
    
    let mock_response = json!({
        "id": "resp-reasoning-456",
        "object": "response",
        "created_at": 1704067200.0,
        "model": "anthropic/claude-3.5-sonnet",
        "status": "completed",
        "output": [
            {
                "type": "reasoning",
                "id": "reasoning-1",
                "summary": [
                    {"type": "summary_text", "text": "Analyzing the problem..."}
                ],
                "status": "completed"
            },
            {
                "type": "message",
                "id": "msg-1",
                "role": "assistant",
                "content": [
                    {"type": "output_text", "text": "The answer is 42."}
                ],
                "status": "completed"
            }
        ],
        "usage": {
            "input_tokens": 25,
            "output_tokens": 50,
            "total_tokens": 75,
            "input_tokens_details": {"cached_tokens": 0},
            "output_tokens_details": {"reasoning_tokens": 30}
        }
    });

    let _m = server.mock("POST", "/responses")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(mock_response.to_string())
        .create_async()
        .await;

    let client = OpenRouterClient::builder()
        .api_key("test-key")
        .base_url(&server.url())
        .build()
        .unwrap();

    let request = ResponsesRequestBuilder::new("anthropic/claude-3.5-sonnet")
        .user_message("Solve this complex problem")
        .reasoning("high")
        .build();

    let response = client.create_response(request).await.unwrap();

    assert_eq!(response.output.len(), 2);
    
    // Check reasoning output
    if let OutputItem::Reasoning { summary, .. } = &response.output[0] {
        if let ReasoningSummary::Text { text } = &summary[0] {
            assert_eq!(text, "Analyzing the problem...");
        }
    } else {
        panic!("Expected reasoning output");
    }
    
    // Check usage includes reasoning tokens
    let usage = response.usage.unwrap();
    assert_eq!(usage.completion_tokens_details.as_ref().unwrap().reasoning_tokens, Some(30));
}

#[tokio::test]
async fn test_responses_api_with_function_call() {
    let mut server = Server::new_async().await;
    
    let mock_response = json!({
        "id": "resp-func-789",
        "object": "response",
        "created_at": 1704067200.0,
        "model": "openai/gpt-4",
        "status": "completed",
        "output": [
            {
                "type": "function_call",
                "id": "call-1",
                "name": "get_weather",
                "arguments": "{\"location\":\"San Francisco\"}",
                "call_id": "call_abc123",
                "status": "completed"
            }
        ],
        "usage": {
            "input_tokens": 30,
            "output_tokens": 20,
            "total_tokens": 50
        }
    });

    let _m = server.mock("POST", "/responses")
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(mock_response.to_string())
        .create_async()
        .await;

    let client = OpenRouterClient::builder()
        .api_key("test-key")
        .base_url(&server.url())
        .build()
        .unwrap();

    let request = ResponsesRequestBuilder::new("openai/gpt-4")
        .system_message("You have access to weather functions")
        .user_message("What's the weather in San Francisco?")
        .build();

    let response = client.create_response(request).await.unwrap();

    if let OutputItem::FunctionCall { name, arguments, call_id, .. } = &response.output[0] {
        assert_eq!(name, "get_weather");
        assert_eq!(arguments, "{\"location\":\"San Francisco\"}");
        assert_eq!(call_id, "call_abc123");
    } else {
        panic!("Expected function call output");
    }
}

#[tokio::test]
async fn test_responses_api_error() {
    let mut server = Server::new_async().await;
    
    let _m = server.mock("POST", "/responses")
        .with_status(400)
        .with_header("content-type", "application/json")
        .with_body(json!({
            "error": {
                "code": "invalid_prompt",
                "message": "Missing required parameter: 'model'"
            }
        }).to_string())
        .create_async()
        .await;

    let client = OpenRouterClient::builder()
        .api_key("test-key")
        .base_url(&server.url())
        .build()
        .unwrap();

    let request = ResponsesRequestBuilder::new("invalid-model")
        .user_message("Test")
        .build();

    let result = client.create_response(request).await;
    
    assert!(result.is_err());
    match result.unwrap_err() {
        openrouter_rust::OpenRouterError::ApiError { code, .. } => {
            assert_eq!(code, 400);
        }
        _ => panic!("Expected ApiError with 400"),
    }
}

#[tokio::test]
async fn test_responses_api_with_system_message() {
    let mut server = Server::new_async().await;
    
    let _m = server.mock("POST", "/responses")
        .match_body(mockito::Matcher::Json(json!({
            "model": "anthropic/claude-3.5-sonnet",
            "input": [
                {"type": "message", "role": "system", "content": "You are a coding expert"},
                {"type": "message", "role": "user", "content": "How do I use closures in Rust?"}
            ],
            "temperature": 0.7
        })))
        .with_status(200)
        .with_header("content-type", "application/json")
        .with_body(json!({
            "id": "resp-system",
            "object": "response",
            "created_at": 1704067200.0,
            "model": "anthropic/claude-3.5-sonnet",
            "status": "completed",
            "output": [
                {
                    "type": "message",
                    "id": "msg-1",
                    "role": "assistant",
                    "content": [{"type": "output_text", "text": "Closures in Rust are anonymous functions..."}],
                    "status": "completed"
                }
            ],
            "usage": {"input_tokens": 30, "output_tokens": 50, "total_tokens": 80}
        }).to_string())
        .create_async()
        .await;

    let client = OpenRouterClient::builder()
        .api_key("test-key")
        .base_url(&server.url())
        .build()
        .unwrap();

    let request = ResponsesRequestBuilder::new("anthropic/claude-3.5-sonnet")
        .system_message("You are a coding expert")
        .user_message("How do I use closures in Rust?")
        .temperature(0.7)
        .build();

    let response = client.create_response(request).await.unwrap();
    
    assert_eq!(response.status, "completed");
}

#[test]
fn test_input_item_serialization() {
    let item = InputItem::Message {
        role: InputRole::User,
        content: InputContent::String("Hello".to_string()),
    };

    let json = serde_json::to_string(&item).unwrap();
    assert!(json.contains("\"type\":\"message\""));
    assert!(json.contains("\"role\":\"user\""));
}

#[test]
fn test_output_item_deserialization() {
    let json = r#"{"type":"message","id":"msg-1","role":"assistant","content":[{"type":"output_text","text":"Hello"}],"status":"completed"}"#;
    let item: OutputItem = serde_json::from_str(json).unwrap();
    
    match item {
        OutputItem::Message { id, role, status, .. } => {
            assert_eq!(id, "msg-1");
            assert_eq!(role, "assistant");
            assert_eq!(status, "completed");
        }
        _ => panic!("Expected Message variant"),
    }
}