va-ai-api-bridge 0.1.7

Protocol translation primitives for AI API request and response shapes.
Documentation
use serde_json::json;

use super::{decode, encode};
use crate::{
    ContentBlock, OpenAiResponsesTranslator, Role, UniversalItem, UniversalRequest, WireTranslator,
};

#[test]
fn preserves_reasoning_content_on_assistant_tool_call_messages() {
    let universal = decode(json!({
        "model": "deepseek-v4-pro",
        "messages": [{
            "role": "assistant",
            "content": null,
            "reasoning_content": "I should inspect cwd.",
            "tool_calls": [{
                "id": "call_123",
                "type": "function",
                "function": {
                    "name": "exec_command",
                    "arguments": "{\"cmd\":\"pwd\"}"
                }
            }]
        }]
    }))
    .expect("request decodes");
    let encoded = encode(&universal).expect("request encodes");

    assert_eq!(
        encoded["messages"][0]["reasoning_content"],
        "I should inspect cwd."
    );
    assert_eq!(encoded["messages"][0]["tool_calls"][0]["id"], "call_123");
}

#[test]
fn encodes_developer_messages_as_system_for_chat_compatibility() {
    let request = UniversalRequest {
        model: Some("chat-model".to_string()),
        input: vec![
            UniversalItem::Message {
                role: Role::Developer,
                id: None,
                content: vec![ContentBlock::Text {
                    text: "Follow the dashboard contract.".to_string(),
                }],
                extensions: Default::default(),
            },
            UniversalItem::Message {
                role: Role::User,
                id: None,
                content: vec![ContentBlock::Text {
                    text: "Hello".to_string(),
                }],
                extensions: Default::default(),
            },
        ],
        ..UniversalRequest::default()
    };

    let encoded = encode(&request).expect("request encodes");

    let messages = encoded["messages"].as_array().unwrap();
    assert_eq!(messages.len(), 2);
    assert_eq!(messages[0]["role"], "system");
    assert_eq!(messages[0]["content"], "Follow the dashboard contract.");
    assert_eq!(messages[1]["role"], "user");
}

#[test]
fn skips_empty_assistant_message_between_tool_call_and_tool_result() {
    let request = UniversalRequest {
        model: Some("chat-model".to_string()),
        input: vec![
            UniversalItem::ToolCall {
                id: "call_pwd".to_string(),
                name: "exec_command".to_string(),
                arguments: json!({ "cmd": "pwd" }),
                extensions: Default::default(),
            },
            UniversalItem::Message {
                role: Role::Assistant,
                id: None,
                content: Vec::new(),
                extensions: Default::default(),
            },
            UniversalItem::ToolResult {
                tool_call_id: "call_pwd".to_string(),
                content: vec![ContentBlock::Text {
                    text: "/tmp/project".to_string(),
                }],
                is_error: false,
                extensions: Default::default(),
            },
        ],
        ..UniversalRequest::default()
    };

    let encoded = encode(&request).expect("request encodes");

    let messages = encoded["messages"].as_array().unwrap();
    assert_eq!(messages.len(), 2);
    assert_eq!(messages[0]["role"], "assistant");
    assert_eq!(messages[0]["tool_calls"][0]["id"], "call_pwd");
    assert_eq!(messages[1]["role"], "tool");
    assert_eq!(messages[1]["tool_call_id"], "call_pwd");
}

#[test]
fn encodes_assistant_tool_calls_with_required_content_field() {
    let request = UniversalRequest {
        model: Some("chat-model".to_string()),
        input: vec![UniversalItem::ToolCall {
            id: "call_pwd".to_string(),
            name: "exec_command".to_string(),
            arguments: json!({ "cmd": "pwd" }),
            extensions: Default::default(),
        }],
        ..UniversalRequest::default()
    };

    let encoded = encode(&request).expect("request encodes");

    let messages = encoded["messages"].as_array().unwrap();
    assert_eq!(messages.len(), 1);
    assert_eq!(messages[0]["role"], "assistant");
    assert_eq!(messages[0]["content"], "");
    assert_eq!(messages[0]["tool_calls"][0]["id"], "call_pwd");
}

#[test]
fn attaches_assistant_text_to_pending_tool_calls_before_tool_results() {
    let request = UniversalRequest {
        model: Some("chat-model".to_string()),
        input: vec![
            UniversalItem::ToolCall {
                id: "call_ls".to_string(),
                name: "exec_command".to_string(),
                arguments: json!({ "cmd": "ls" }),
                extensions: Default::default(),
            },
            UniversalItem::ToolCall {
                id: "call_pwd".to_string(),
                name: "exec_command".to_string(),
                arguments: json!({ "cmd": "pwd" }),
                extensions: Default::default(),
            },
            UniversalItem::Message {
                role: Role::Assistant,
                id: None,
                content: vec![ContentBlock::Text {
                    text: "I will inspect the project first.".to_string(),
                }],
                extensions: Default::default(),
            },
            UniversalItem::ToolResult {
                tool_call_id: "call_ls".to_string(),
                content: vec![ContentBlock::Text {
                    text: "Cargo.toml".to_string(),
                }],
                is_error: false,
                extensions: Default::default(),
            },
            UniversalItem::ToolResult {
                tool_call_id: "call_pwd".to_string(),
                content: vec![ContentBlock::Text {
                    text: "/tmp/project".to_string(),
                }],
                is_error: false,
                extensions: Default::default(),
            },
        ],
        ..UniversalRequest::default()
    };

    let encoded = encode(&request).expect("request encodes");

    let messages = encoded["messages"].as_array().unwrap();
    assert_eq!(messages.len(), 3);
    assert_eq!(messages[0]["role"], "assistant");
    assert_eq!(messages[0]["content"], "I will inspect the project first.");
    assert_eq!(messages[0]["tool_calls"].as_array().unwrap().len(), 2);
    assert_eq!(messages[1]["role"], "tool");
    assert_eq!(messages[1]["tool_call_id"], "call_ls");
    assert_eq!(messages[2]["role"], "tool");
    assert_eq!(messages[2]["tool_call_id"], "call_pwd");
}

#[test]
fn drops_encrypted_reasoning_assistant_message_for_chat_compatibility() {
    let request = UniversalRequest {
        model: Some("chat-model".to_string()),
        input: vec![UniversalItem::Message {
            role: Role::Assistant,
            id: None,
            content: vec![ContentBlock::Reasoning {
                text: None,
                encrypted: Some("opaque-reasoning".to_string()),
                extensions: Default::default(),
            }],
            extensions: Default::default(),
        }],
        ..UniversalRequest::default()
    };

    let encoded = encode(&request).expect("request encodes");

    let messages = encoded["messages"].as_array().unwrap();
    assert!(messages.is_empty());
}

#[test]
fn filters_non_chat_content_parts_from_messages() {
    let request = UniversalRequest {
        model: Some("chat-model".to_string()),
        input: vec![UniversalItem::Message {
            role: Role::Assistant,
            id: None,
            content: vec![
                ContentBlock::Text {
                    text: "Visible text.".to_string(),
                },
                ContentBlock::Reasoning {
                    text: Some("hidden reasoning".to_string()),
                    encrypted: None,
                    extensions: Default::default(),
                },
                ContentBlock::Unknown {
                    raw: json!({ "type": "reasoning_text", "text": "raw reasoning" }),
                },
            ],
            extensions: Default::default(),
        }],
        ..UniversalRequest::default()
    };

    let encoded = encode(&request).expect("request encodes");

    let messages = encoded["messages"].as_array().unwrap();
    assert_eq!(messages.len(), 1);
    assert_eq!(messages[0]["role"], "assistant");
    assert_eq!(messages[0]["content"], "Visible text.");
}

#[test]
fn responses_input_image_encodes_as_chat_image_url() {
    let universal = OpenAiResponsesTranslator
        .decode_request(json!({
            "model": "qwen3.6-plus",
            "input": [{
                "type": "message",
                "role": "user",
                "content": [
                    { "type": "input_text", "text": "What is in this image?" },
                    {
                        "type": "input_image",
                        "image_url": "data:image/png;base64,abc123"
                    }
                ]
            }]
        }))
        .expect("responses request decodes");

    let encoded = encode(&universal).expect("chat request encodes");

    assert_eq!(encoded["messages"][0]["content"][0]["type"], "text");
    assert_eq!(encoded["messages"][0]["content"][1]["type"], "image_url");
    assert_eq!(
        encoded["messages"][0]["content"][1]["image_url"]["url"],
        "data:image/png;base64,abc123"
    );
}

#[test]
fn drops_unknown_responses_items_in_chat_encoding() {
    let request = UniversalRequest {
        model: Some("chat-model".to_string()),
        input: vec![
            UniversalItem::Unknown {
                raw: json!({
                    "type": "reasoning",
                    "id": "rs_123",
                    "content": null,
                    "encrypted_content": "opaque"
                }),
            },
            UniversalItem::Message {
                role: Role::User,
                id: None,
                content: vec![ContentBlock::Text {
                    text: "Hello".to_string(),
                }],
                extensions: Default::default(),
            },
        ],
        ..UniversalRequest::default()
    };

    let encoded = encode(&request).expect("request encodes");

    let messages = encoded["messages"].as_array().unwrap();
    assert_eq!(messages.len(), 1);
    assert_eq!(messages[0]["role"], "user");
    assert_eq!(messages[0]["content"], "Hello");
}

#[test]
fn fills_content_on_passthrough_chat_messages_without_content() {
    let request = UniversalRequest {
        model: Some("chat-model".to_string()),
        input: vec![UniversalItem::Unknown {
            raw: json!({
                "role": "assistant",
                "tool_calls": [{
                    "id": "call_123",
                    "type": "function",
                    "function": {
                        "name": "read_file",
                        "arguments": "{\"path\":\"README.md\"}"
                    }
                }]
            }),
        }],
        ..UniversalRequest::default()
    };

    let encoded = encode(&request).expect("request encodes");

    let messages = encoded["messages"].as_array().unwrap();
    assert_eq!(messages.len(), 1);
    assert_eq!(messages[0]["role"], "assistant");
    assert_eq!(messages[0]["content"], "");
    assert_eq!(messages[0]["tool_calls"][0]["id"], "call_123");
}

#[test]
fn chat_function_named_web_search_stays_a_function_tool() {
    let request = decode(json!({
        "model": "chat-model",
        "messages": [{ "role": "user", "content": "Search the web." }],
        "tools": [{
            "type": "function",
            "function": {
                "name": "web_search",
                "description": "Host-provided search function.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "query": { "type": "string" }
                    },
                    "required": ["query"]
                }
            }
        }]
    }))
    .expect("request decodes");

    assert_eq!(request.tools.len(), 1);
    assert_eq!(request.tools[0].name, "web_search");
    assert!(request.server_tools.is_empty());
}