openrouter-cli 0.2.0

CLI for OpenRouter account and SDK workflows
use std::{
    io::{Read, Write},
    net::TcpListener,
    sync::mpsc,
    thread,
    time::Duration,
};

use assert_cmd::cargo::cargo_bin_cmd;
use predicates::str::contains;
use serde_json::{Value, json};

struct CapturedRequest {
    request_line: 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];
        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 request_line = String::from_utf8_lossy(&request_bytes)
            .lines()
            .next()
            .unwrap_or_default()
            .to_string();
        tx.send(CapturedRequest { request_line })
            .expect("captured request should send");

        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)
}

fn base_cmd(base_url: &str, output: &str) -> assert_cmd::Command {
    let mut cmd = cargo_bin_cmd!("openrouter-cli");
    cmd.arg("--api-key")
        .arg("api-test-key")
        .arg("--base-url")
        .arg(base_url)
        .arg("--output")
        .arg(output)
        .env_remove("OPENROUTER_API_KEY")
        .env_remove("OPENROUTER_MANAGEMENT_KEY")
        .env_remove("OPENROUTER_BASE_URL")
        .env_remove("OPENROUTER_PROFILE")
        .env_remove("OPENROUTER_CLI_CONFIG");
    cmd
}

fn sample_models_response() -> &'static str {
    r#"{
      "data": [
        {
          "id": "openai/gpt-4.1",
          "name": "GPT-4.1",
          "created": 1710000000,
          "description": "Test model",
          "context_length": 128000,
          "architecture": {
            "modality": "text->text",
            "tokenizer": "GPT",
            "instruct_type": "chatml"
          },
          "top_provider": {
            "context_length": 128000,
            "max_completion_tokens": 16384,
            "is_moderated": true
          },
          "pricing": {
            "prompt": "0.000002",
            "completion": "0.000008",
            "image": null,
            "request": null,
            "input_cache_read": null,
            "input_cache_write": null,
            "web_search": null,
            "internal_reasoning": null
          },
          "per_request_limits": null
        }
      ]
    }"#
}

#[test]
fn test_models_list_json_snapshot() {
    let (base_url, rx, server) = spawn_json_server(sample_models_response());

    let mut cmd = base_cmd(&base_url, "json");
    cmd.arg("models")
        .arg("list")
        .arg("--category")
        .arg("programming");
    let output = cmd.assert().success().get_output().stdout.clone();
    let parsed: Value = serde_json::from_slice(&output).expect("stdout should be json");

    assert_eq!(
        parsed.get("schema_version").and_then(Value::as_str),
        Some("0.1")
    );

    let expected = json!([{
        "id": "openai/gpt-4.1",
        "name": "GPT-4.1",
        "created": 1710000000.0,
        "description": "Test model",
        "context_length": 128000.0,
        "architecture": {
            "modality": "text->text",
            "tokenizer": "GPT",
            "instruct_type": "chatml"
        },
        "top_provider": {
            "context_length": 128000.0,
            "max_completion_tokens": 16384.0,
            "is_moderated": true
        },
        "pricing": {
            "prompt": "0.000002",
            "completion": "0.000008",
            "image": null,
            "request": null,
            "input_cache_read": null,
            "input_cache_write": null,
            "web_search": null,
            "internal_reasoning": null
        },
        "per_request_limits": null
    }]);
    assert_eq!(parsed.get("data"), Some(&expected));

    let captured = rx
        .recv_timeout(Duration::from_secs(2))
        .expect("request should be captured");
    assert_eq!(
        captured.request_line,
        "GET /api/v1/models?category=programming HTTP/1.1"
    );

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

#[test]
fn test_models_list_supported_parameter_filter_path() {
    let (base_url, rx, server) = spawn_json_server(sample_models_response());

    let mut cmd = base_cmd(&base_url, "json");
    cmd.arg("models")
        .arg("list")
        .arg("--supported-parameter")
        .arg("tools");
    cmd.assert().success();

    let captured = rx
        .recv_timeout(Duration::from_secs(2))
        .expect("request should be captured");
    assert_eq!(
        captured.request_line,
        "GET /api/v1/models?supported_parameters=tools HTTP/1.1"
    );

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

#[test]
fn test_models_show_json_snapshot() {
    let (base_url, rx, server) = spawn_json_server(sample_models_response());

    let mut cmd = base_cmd(&base_url, "json");
    cmd.arg("models").arg("show").arg("openai/gpt-4.1");
    let output = cmd.assert().success().get_output().stdout.clone();
    let parsed: Value = serde_json::from_slice(&output).expect("stdout should be json");

    assert_eq!(
        parsed.get("schema_version").and_then(Value::as_str),
        Some("0.1")
    );
    assert_eq!(
        parsed
            .get("data")
            .and_then(|value| value.get("id"))
            .and_then(Value::as_str),
        Some("openai/gpt-4.1")
    );
    assert_eq!(
        parsed
            .get("data")
            .and_then(|value| value.get("name"))
            .and_then(Value::as_str),
        Some("GPT-4.1")
    );

    let captured = rx
        .recv_timeout(Duration::from_secs(2))
        .expect("request should be captured");
    assert_eq!(captured.request_line, "GET /api/v1/models HTTP/1.1");

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

#[test]
fn test_models_endpoints_json_snapshot() {
    let (base_url, rx, server) = spawn_json_server(
        r#"{
          "data": {
            "id": "openai/gpt-4.1",
            "name": "GPT-4.1",
            "created": 1710000000,
            "description": "Test model",
            "architecture": {
              "tokenizer": "GPT",
              "instruct_type": "chatml",
              "modality": "text->text"
            },
            "endpoints": [
              {
                "name": "OpenAI: GPT-4.1",
                "context_length": 128000,
                "pricing": {
                  "request": "0",
                  "image": "0",
                  "prompt": "0.000002",
                  "completion": "0.000008"
                },
                "provider_name": "OpenAI",
                "supported_parameters": ["tools", "temperature"],
                "quantization": null,
                "max_completion_tokens": 16384,
                "max_prompt_tokens": 128000,
                "status": {"state":"up"}
              }
            ]
          }
        }"#,
    );

    let mut cmd = base_cmd(&base_url, "json");
    cmd.arg("models").arg("endpoints").arg("openai/gpt-4.1");
    let output = cmd.assert().success().get_output().stdout.clone();
    let parsed: Value = serde_json::from_slice(&output).expect("stdout should be json");

    assert_eq!(
        parsed.get("schema_version").and_then(Value::as_str),
        Some("0.1")
    );
    assert_eq!(
        parsed
            .get("data")
            .and_then(|value| value.get("endpoints"))
            .and_then(Value::as_array)
            .and_then(|values| values.first())
            .and_then(|item| item.get("provider_name"))
            .and_then(Value::as_str),
        Some("OpenAI")
    );

    let captured = rx
        .recv_timeout(Duration::from_secs(2))
        .expect("request should be captured");
    assert_eq!(
        captured.request_line,
        "GET /api/v1/models/openai/gpt-4.1/endpoints HTTP/1.1"
    );

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

#[test]
fn test_providers_list_json_snapshot() {
    let (base_url, rx, server) = spawn_json_server(
        r#"{
          "data": [
            {
              "name": "OpenAI",
              "slug": "openai",
              "privacy_policy_url": "https://openai.com/privacy",
              "terms_of_service_url": "https://openai.com/terms",
              "status_page_url": "https://status.openai.com"
            }
          ]
        }"#,
    );

    let mut cmd = base_cmd(&base_url, "json");
    cmd.arg("providers").arg("list");
    let output = cmd.assert().success().get_output().stdout.clone();
    let parsed: Value = serde_json::from_slice(&output).expect("stdout should be json");
    assert_eq!(
        parsed.get("schema_version").and_then(Value::as_str),
        Some("0.1")
    );
    assert_eq!(
        parsed
            .get("data")
            .and_then(Value::as_array)
            .and_then(|values| values.first())
            .and_then(|item| item.get("slug")),
        Some(&json!("openai"))
    );

    let captured = rx
        .recv_timeout(Duration::from_secs(2))
        .expect("request should be captured");
    assert_eq!(captured.request_line, "GET /api/v1/providers HTTP/1.1");

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

#[test]
fn test_models_list_text_table_output() {
    let (base_url, _rx, server) = spawn_json_server(sample_models_response());

    let mut cmd = base_cmd(&base_url, "text");
    cmd.arg("models").arg("list");
    cmd.assert()
        .success()
        .stdout(contains("id"))
        .stdout(contains("prompt_price"))
        .stdout(contains("openai/gpt-4.1"));

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

#[test]
fn test_models_show_missing_model_exits_nonzero() {
    let (base_url, _rx, server) = spawn_json_server(r#"{"data":[]}"#);

    let mut cmd = base_cmd(&base_url, "json");
    cmd.arg("models").arg("show").arg("missing/model");
    cmd.assert()
        .failure()
        .code(1)
        .stderr(contains("model not found"));

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