va-ai-api-bridge 0.1.7

Protocol translation primitives for AI API request and response shapes.
Documentation
use serde_json::{json, Map, Value};

mod content;

#[derive(Debug, Clone)]
pub struct DashScopeBridgeAdapter {
    thinking_enabled: bool,
}

impl DashScopeBridgeAdapter {
    pub fn new(thinking_enabled: Option<bool>) -> Self {
        let thinking_enabled = thinking_enabled.unwrap_or(true);
        Self { thinking_enabled }
    }

    pub fn prepare_chat_request(&mut self, original_request: &Value, chat_request: &mut Value) {
        content::convert_text_file_parts_to_text(chat_request);

        let Some(object) = chat_request.as_object_mut() else {
            return;
        };

        object.remove("reasoning");
        object.remove("reasoning_effort");
        object.remove("reasoningEffort");
        let forced_tool_choice = normalize_tool_choice_for_dashscope(object);

        let Some(model) = object.get("model").and_then(Value::as_str) else {
            return;
        };
        if !model_uses_dashscope_enable_thinking(model) {
            return;
        }

        let enabled = if forced_tool_choice {
            false
        } else {
            thinking_from_original_request(original_request).unwrap_or(self.thinking_enabled)
        };
        object.insert("enable_thinking".to_string(), Value::Bool(enabled));
    }
}

fn normalize_tool_choice_for_dashscope(object: &mut Map<String, Value>) -> bool {
    let Some(tool_choice) = object.get("tool_choice") else {
        return false;
    };

    if is_specific_function_tool_choice(tool_choice) {
        return true;
    }

    if tool_choice.as_str() != Some("required") {
        return false;
    }

    if let Some(name) = single_function_tool_name(object.get("tools")) {
        object.insert(
            "tool_choice".to_string(),
            json!({
                "type": "function",
                "function": { "name": name }
            }),
        );
        return true;
    }

    object.insert("tool_choice".to_string(), Value::String("auto".to_string()));
    false
}

fn is_specific_function_tool_choice(value: &Value) -> bool {
    let Some(object) = value.as_object() else {
        return false;
    };
    object.get("type").and_then(Value::as_str) == Some("function")
        && object
            .get("function")
            .and_then(Value::as_object)
            .and_then(|function| function.get("name"))
            .or_else(|| object.get("name"))
            .and_then(Value::as_str)
            .is_some_and(|name| !name.trim().is_empty())
}

fn single_function_tool_name(tools: Option<&Value>) -> Option<String> {
    let tools = tools?.as_array()?;
    let mut names = tools.iter().filter_map(function_tool_name);
    let name = names.next()?;
    names.next().is_none().then_some(name)
}

fn function_tool_name(tool: &Value) -> Option<String> {
    let object = tool.as_object()?;
    if object.get("type").and_then(Value::as_str) != Some("function") {
        return None;
    }
    object
        .get("function")
        .and_then(Value::as_object)
        .and_then(|function| function.get("name"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .map(ToOwned::to_owned)
}

fn model_uses_dashscope_enable_thinking(model: &str) -> bool {
    let model = model.trim().to_ascii_lowercase();
    model.starts_with("qwen3.5")
        || model.starts_with("qwen3.6")
        || model.starts_with("qwen3-max")
        || model.starts_with("deepseek-v4")
        || matches!(
            model.as_str(),
            "glm-5.1"
                | "glm-5"
                | "glm-4.7"
                | "kimi-k2.6"
                | "kimi-k2.5"
                | "minimax-m2.5"
                | "minimax-m2.5-highspeed"
        )
}

fn thinking_from_original_request(request: &Value) -> Option<bool> {
    request
        .get("reasoning")
        .and_then(|reasoning| {
            reasoning
                .get("effort")
                .or_else(|| reasoning.get("reasoning_effort"))
                .or_else(|| reasoning.get("reasoningEffort"))
                .and_then(Value::as_str)
        })
        .or_else(|| request.get("reasoning_effort").and_then(Value::as_str))
        .or_else(|| request.get("reasoningEffort").and_then(Value::as_str))
        .map(reasoning_effort_enabled)
}

fn reasoning_effort_enabled(value: &str) -> bool {
    !matches!(
        value.trim().to_ascii_lowercase().as_str(),
        "off" | "none" | "disabled" | "disable" | "false"
    )
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn maps_reasoning_effort_to_dashscope_enable_thinking_for_reasoning_models() {
        let mut adapter = DashScopeBridgeAdapter::new(None);
        let mut chat_request = json!({ "model": "qwen3.5-plus", "messages": [] });

        adapter.prepare_chat_request(
            &json!({ "reasoning": { "effort": "none" } }),
            &mut chat_request,
        );

        assert_eq!(chat_request["enable_thinking"], false);
    }

    #[test]
    fn leaves_non_reasoning_qwen_models_without_enable_thinking() {
        let mut adapter = DashScopeBridgeAdapter::new(None);
        let mut chat_request = json!({ "model": "qwen3-coder-plus", "messages": [] });

        adapter.prepare_chat_request(
            &json!({ "reasoning": { "effort": "high" } }),
            &mut chat_request,
        );

        assert!(chat_request.get("enable_thinking").is_none());
    }

    #[test]
    fn maps_reasoning_effort_to_dashscope_partner_reasoning_models() {
        let mut adapter = DashScopeBridgeAdapter::new(None);
        let mut chat_request = json!({ "model": "glm-5", "messages": [] });

        adapter.prepare_chat_request(
            &json!({ "reasoning": { "effort": "high" } }),
            &mut chat_request,
        );

        assert_eq!(chat_request["enable_thinking"], true);
    }

    #[test]
    fn disables_thinking_for_specific_tool_choice() {
        let mut adapter = DashScopeBridgeAdapter::new(None);
        let mut chat_request = json!({
            "model": "qwen3.6-plus",
            "messages": [],
            "tools": [{
                "type": "function",
                "function": { "name": "lookup" }
            }],
            "tool_choice": {
                "type": "function",
                "function": { "name": "lookup" }
            }
        });

        adapter.prepare_chat_request(&json!({}), &mut chat_request);

        assert_eq!(chat_request["enable_thinking"], false);
        assert_eq!(chat_request["tool_choice"]["function"]["name"], "lookup");
    }

    #[test]
    fn rewrites_required_tool_choice_to_single_function() {
        let mut adapter = DashScopeBridgeAdapter::new(None);
        let mut chat_request = json!({
            "model": "qwen3.6-plus",
            "messages": [],
            "tools": [{
                "type": "function",
                "function": { "name": "lookup" }
            }],
            "tool_choice": "required"
        });

        adapter.prepare_chat_request(&json!({}), &mut chat_request);

        assert_eq!(chat_request["enable_thinking"], false);
        assert_eq!(
            chat_request["tool_choice"],
            json!({
                "type": "function",
                "function": { "name": "lookup" }
            })
        );
    }

    #[test]
    fn rewrites_ambiguous_required_tool_choice_to_auto() {
        let mut adapter = DashScopeBridgeAdapter::new(None);
        let mut chat_request = json!({
            "model": "qwen3.6-plus",
            "messages": [],
            "tools": [
                { "type": "function", "function": { "name": "lookup" } },
                { "type": "function", "function": { "name": "search" } }
            ],
            "tool_choice": "required"
        });

        adapter.prepare_chat_request(&json!({}), &mut chat_request);

        assert_eq!(chat_request["enable_thinking"], true);
        assert_eq!(chat_request["tool_choice"], "auto");
    }
}