vtcode-llm 0.181.0

LLM provider abstraction, client implementations, and streaming for VT Code
Documentation
use super::*;

#[test]
fn chat_payload_rejects_file_url_content_parts() {
    let provider = native_openai_provider(models::openai::DEFAULT_MODEL);
    let request = provider::LLMRequest {
        messages: vec![provider::Message::user_with_parts(vec![
            provider::ContentPart::file_from_url("https://example.com/doc.pdf".to_string()),
        ])]
        .into(),
        model: models::openai::DEFAULT_MODEL.to_string(),
        ..Default::default()
    };
    let err = provider
        .convert_to_openai_format(&request)
        .expect_err("chat payload should reject file_url");
    match err {
        provider::LLMError::InvalidRequest { message, .. } => {
            assert!(message.contains("does not support file_url"));
        }
        other => panic!("unexpected error variant: {other:?}"),
    }
}

#[test]
fn responses_payload_serializes_user_input_file_by_id() {
    let provider = native_openai_provider(models::openai::GPT_5);
    let request = provider::LLMRequest {
        messages: vec![provider::Message::user_with_parts(vec![
            provider::ContentPart::text("Summarize this file".to_string()),
            provider::ContentPart::file_from_id("file-abc123".to_string()),
        ])]
        .into(),
        model: models::openai::GPT_5.to_string(),
        ..Default::default()
    };
    let payload = provider
        .convert_to_openai_responses_format(&request)
        .expect("conversion should succeed");
    let input = get_input_array(&payload);
    let content = input[0]
        .get("content")
        .and_then(Value::as_array)
        .expect("user content should be an array");
    assert!(content.iter().any(|part| {
        part.get("type").and_then(Value::as_str) == Some("input_file")
            && part.get("file_id").and_then(Value::as_str) == Some("file-abc123")
    }));
}

#[test]
fn responses_payload_serializes_user_input_file_data() {
    let provider = native_openai_provider(models::openai::GPT_5);
    let request = provider::LLMRequest {
        messages: vec![provider::Message::user_with_parts(vec![
            provider::ContentPart::text("Summarize this file".to_string()),
            provider::ContentPart::file_from_data("report.pdf".to_string(), "aGVsbG8=".to_string()),
        ])]
        .into(),
        model: models::openai::GPT_5.to_string(),
        ..Default::default()
    };
    let payload = provider
        .convert_to_openai_responses_format(&request)
        .expect("conversion should succeed");
    let input = get_input_array(&payload);
    let content = input[0]
        .get("content")
        .and_then(Value::as_array)
        .expect("user content should be an array");
    assert!(content.iter().any(|part| {
        part.get("type").and_then(Value::as_str) == Some("input_file")
            && part.get("filename").and_then(Value::as_str) == Some("report.pdf")
            && part.get("file_data").and_then(Value::as_str) == Some("data:application/pdf;base64,aGVsbG8=")
    }));
}

#[test]
fn responses_payload_normalizes_inline_files_without_mutating_history() {
    let provider = native_openai_provider(models::openai::GPT_6_LUNA);
    for (filename, data, expected) in [
        ("README.md", "IyBSZWFkbWUK", "data:text/markdown;base64,IyBSZWFkbWUK"),
        ("REPORT.PDF", "JVBERi0xLjcK", "data:application/pdf;base64,JVBERi0xLjcK"),
        ("main.rs", "Zm4gbWFpbigpIHt9", "data:text/plain;base64,Zm4gbWFpbigpIHt9"),
        ("data.csv", "YSxiCjEsMgo=", "data:text/csv;base64,YSxiCjEsMgo="),
        ("notes.txt", "data:text/plain;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="),
        ("notes.txt", " aGVsbG8=\n", "data:text/plain;base64,aGVsbG8="),
        (
            "report.docx",
            "UEsDBA==",
            "data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,UEsDBA==",
        ),
        (
            "deck.pptx",
            "UEsDBA==",
            "data:application/vnd.openxmlformats-officedocument.presentationml.presentation;base64,UEsDBA==",
        ),
        (
            "sheet.xlsx",
            "UEsDBA==",
            "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,UEsDBA==",
        ),
    ] {
        let request = provider::LLMRequest {
            messages: vec![provider::Message::user_with_parts(vec![
                provider::ContentPart::text("use plan mode, refine".to_string()),
                provider::ContentPart::file_from_data(filename.to_string(), data.to_string()),
            ])]
            .into(),
            model: models::openai::GPT_6_LUNA.to_string(),
            ..Default::default()
        };
        let original_messages = request.messages.clone();
        let payload = provider.convert_to_openai_responses_format(&request).expect("valid file input");
        let content = &get_input_array(&payload)[0]["content"];
        assert_eq!(content[0]["text"], "use plan mode, refine");
        assert_eq!(content[1]["type"], "input_file");
        assert_eq!(content[1]["filename"], filename);
        assert_eq!(content[1]["file_data"], expected);
        assert_eq!(request.messages, original_messages);
    }
}

#[test]
fn responses_payload_preserves_remote_file_url() {
    let provider = native_openai_provider(models::openai::GPT_6_LUNA);
    let request = provider::LLMRequest {
        messages: vec![provider::Message::user_with_parts(vec![
            provider::ContentPart::file_from_url("https://example.com/report.pdf".to_string()),
        ])]
        .into(),
        model: models::openai::GPT_6_LUNA.to_string(),
        ..Default::default()
    };
    let payload = provider.convert_to_openai_responses_format(&request).expect("valid URL input");
    let part = &get_input_array(&payload)[0]["content"][0];
    assert_eq!(part["type"], "input_file");
    assert_eq!(part["file_url"], "https://example.com/report.pdf");
    assert!(part.get("file_data").is_none());
    assert!(part.get("file_id").is_none());
}

#[test]
fn responses_validation_counts_data_url_bytes_without_the_prefix() {
    let request = provider::LLMRequest {
        messages: vec![provider::Message::user_with_parts(vec![
            provider::ContentPart::file_from_data(
                "notes.txt".to_string(),
                "data:text/plain;base64,aGVsbG8=".to_string(),
            ),
        ])]
        .into(),
        model: models::openai::GPT_6_LUNA.to_string(),
        ..Default::default()
    };
    OpenAIProvider::validate_inline_file_inputs_with_limit(&request, 5).expect("five decoded bytes fit");
    assert!(OpenAIProvider::validate_inline_file_inputs_with_limit(&request, 4).is_err());
}

#[test]
fn responses_validation_rejects_malformed_inline_file_data() {
    let provider = native_openai_provider(models::openai::GPT_6_LUNA);
    for data in ["not base64!", "data:text/markdown;base64,not base64!"] {
        let request = provider::LLMRequest {
            messages: vec![provider::Message::user_with_parts(vec![
                provider::ContentPart::file_from_data("README.md".to_string(), data.to_string()),
            ])]
            .into(),
            model: models::openai::GPT_6_LUNA.to_string(),
            ..Default::default()
        };
        assert!(matches!(
            provider.convert_to_openai_responses_format(&request),
            Err(provider::LLMError::InvalidRequest { .. })
        ));
    }
}

#[test]
fn responses_validation_rejects_single_inline_file_over_limit() {
    let request = provider::LLMRequest {
        messages: vec![provider::Message::user_with_parts(vec![
            provider::ContentPart::file_from_data("report.pdf".to_string(), "aGVsbG8=".to_string()),
        ])]
        .into(),
        model: models::openai::GPT_5.to_string(),
        ..Default::default()
    };
    let err = OpenAIProvider::validate_inline_file_inputs_with_limit(&request, 4)
        .expect_err("inline file should exceed limit");
    match err {
        provider::LLMError::InvalidRequest { message, .. } => {
            assert!(message.contains("50 MB request limit"));
            assert!(message.contains("report.pdf"));
        }
        other => panic!("unexpected error variant: {other:?}"),
    }
}

#[test]
fn responses_validation_rejects_combined_inline_files_over_limit() {
    let request = provider::LLMRequest {
        messages: vec![provider::Message::user_with_parts(vec![
            provider::ContentPart::file_from_data("a.txt".to_string(), "YWJj".to_string()),
            provider::ContentPart::file_from_data("b.txt".to_string(), "ZGVm".to_string()),
        ])]
        .into(),
        model: models::openai::GPT_5.to_string(),
        ..Default::default()
    };
    let err = OpenAIProvider::validate_inline_file_inputs_with_limit(&request, 5)
        .expect_err("combined inline files should exceed limit");
    match err {
        provider::LLMError::InvalidRequest { message, .. } => {
            assert!(message.contains("50 MB request limit"));
            assert!(message.contains("total inline file bytes = 6"));
        }
        other => panic!("unexpected error variant: {other:?}"),
    }
}