met 1.0.0-beta-preview

Metrale Engine: pure Rust LLM inference server (HTTP API, scheduler, TUI, CLI).
// SPDX-License-Identifier: MIT OR Apache-2.0

//! 2026-09-26: Render tests for the dense Qwen 27B templates in test_data/chat_templates,
//! through `render_chat` after `convert_python_jinja_to_minijinja`, as the server renders:
//! - `qwen3.6-27b-unsloth.jinja` (md5 a7f294a5f0be5f1903214304f259f87f);
//! - `qwen3.8-27b-unsloth.jinja` (md5 2a79880b328d0e0387c8ecb62c4c0c80);
//! - `retired-qwen3_5-override-2026-04.jinja`, the retired `qwen3_5` override
//!   (jinja-templates/README.md), kept as a parity reference.
//!
//! Variant goldens are built from [`Q36_GOLDEN`] with byte-delta helpers, so a failure
//! shows which bytes moved.
//!
//! Owner: server (tokenizer) tests.
//! Invariants: none beyond the types.

use super::super::chat_render::{RenderFlags, render_chat};
use super::super::jinja_helpers;
use serde_json::json;

pub(crate) fn render_fixture(
    fixture: &str,
    messages: &[serde_json::Value],
    tools: Option<&[serde_json::Value]>,
    flags: RenderFlags<'_>,
) -> anyhow::Result<String> {
    let path = format!(
        concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/test_data/chat_templates/{}.jinja"
        ),
        fixture
    );
    let raw = std::fs::read_to_string(&path).expect("checkpoint template fixture present");
    let converted = jinja_helpers::convert_python_jinja_to_minijinja(&raw);
    let env = jinja_helpers::build_jinja_env(&converted).expect("template compiles");
    render_chat(&env, messages, tools, flags)
}

/// 2026-09-26: A system prompt, a tool round-trip with `reasoning_content` on the earlier
/// assistant turns, and a new user query: the Qwen3.6 and Qwen3.8 templates differ on
/// whether that earlier reasoning is rendered.
pub(crate) fn fixture_messages() -> Vec<serde_json::Value> {
    vec![
        json!({"role": "system", "content": "You are a helpful assistant."}),
        json!({"role": "user", "content": "Check the weather in Paris, then summarize."}),
        json!({
            "role": "assistant",
            "content": "",
            "reasoning_content": "User wants live weather. I should call get_weather.",
            "tool_calls": [{
                "id": "c1",
                "type": "function",
                "function": {"name": "get_weather", "arguments": {"location": "Paris"}}
            }]
        }),
        json!({"role": "tool", "content": "18C, sunny"}),
        json!({
            "role": "assistant",
            "content": "It is 18C and sunny in Paris.",
            "reasoning_content": "Tool says 18C sunny; summarize."
        }),
        json!({"role": "user", "content": "Thanks - now in one word?"}),
    ]
}

pub(crate) fn fixture_tools() -> Vec<serde_json::Value> {
    vec![json!({
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get current weather for a location",
            "parameters": {
                "type": "object",
                "properties": {"location": {"type": "string"}},
                "required": ["location"]
            }
        }
    })]
}

pub(crate) fn thinking_on() -> RenderFlags<'static> {
    RenderFlags {
        enable_thinking: true,
        ..Default::default()
    }
}

/// 2026-09-26: Qwen3.6 render with thinking on, tools, and `preserve_thinking` unset. The
/// tool JSON is compact (the default `tojson`), and the earlier turns' reasoning is left
/// out, the template's default while `preserve_thinking` is undefined.
const Q36_GOLDEN: &str = "<|im_start|>system\n# Tools\n\nYou have access to the following functions:\n\n<tools>\n{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a location\",\"parameters\":{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\"}},\"required\":[\"location\"]}}}\n</tools>\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>\n\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\nCheck the weather in Paris, then summarize.<|im_end|>\n<|im_start|>assistant\n<tool_call>\n<function=get_weather>\n<parameter=location>\nParis\n</parameter>\n</function>\n</tool_call><|im_end|>\n<|im_start|>user\n<tool_response>\n18C, sunny\n</tool_response><|im_end|>\n<|im_start|>assistant\nIt is 18C and sunny in Paris.<|im_end|>\n<|im_start|>user\nThanks - now in one word?<|im_end|>\n<|im_start|>assistant\n<think>\n";

/// 2026-09-26: The Qwen3.8 template's instruction for `xhigh`, which it also writes for
/// `high` because it maps `high` to `xhigh`. The thinking budget still differs: `high` gets
/// twice and `xhigh` four times the model's `max_thinking_budget` (`effort_budget` in
/// api/chat/thinking.rs). A request without an effort renders `"medium"`, which writes no
/// instruction.
const XHIGH_SENTENCE: &str = "Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.";
const LOW_SENTENCE: &str = "Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.";

/// 2026-09-26: The reasoning blocks the templates write for the two earlier assistant turns.
const THINK1: &str = "<think>\nUser wants live weather. I should call get_weather.\n</think>\n\n";
const THINK2: &str = "<think>\nTool says 18C sunny; summarize.\n</think>\n\n";

/// 2026-09-26: Byte-delta helpers over the baseline, one template behaviour each.
fn with_history_think(base: &str) -> String {
    base.replacen(
        "assistant\n<tool_call>\n<function=get_weather>",
        &format!("assistant\n{THINK1}<tool_call>\n<function=get_weather>"),
        1,
    )
    .replacen(
        "assistant\nIt is 18C",
        &format!("assistant\n{THINK2}It is 18C"),
        1,
    )
}

fn with_effort_sentence(base: &str, sentence: &str) -> String {
    base.replacen(
        "<|im_start|>system\n# Tools",
        &format!("<|im_start|>system\n{sentence}\n\n# Tools"),
        1,
    )
}

fn with_closed_think_tail(base: &str) -> String {
    let open = "<|im_start|>assistant\n<think>\n";
    assert!(base.ends_with(open));
    format!(
        "{}<|im_start|>assistant\n<think>\n\n</think>\n\n",
        base.strip_suffix(open).unwrap()
    )
}

/// 2026-09-26: Qwen3.8 render for an explicit `xhigh` or `high`: the Qwen3.6 render plus the
/// xhigh instruction and the earlier turns' reasoning, which Qwen3.8 keeps by default.
fn q38_golden() -> String {
    with_history_think(&with_effort_sentence(Q36_GOLDEN, XHIGH_SENTENCE))
}

/// 2026-09-26: Qwen3.8 render for a request without an effort: `"medium"` writes no
/// instruction, so it differs from the Qwen3.6 render only by the earlier turns' reasoning.
fn q38_unset_golden() -> String {
    with_history_think(Q36_GOLDEN)
}

#[test]
fn q36_default_strips_history_think() {
    let r = render_fixture(
        "qwen3.6-27b-unsloth",
        &fixture_messages(),
        Some(&fixture_tools()),
        thinking_on(),
    )
    .unwrap();
    assert_eq!(r, Q36_GOLDEN);
}

/// 2026-09-26: The Qwen3.6 checkpoint template and the retired `qwen3_5` override render the
/// same bytes for three flag sets. Both go through the same conversion and preprocessing.
#[test]
fn q36_render_matches_retired_override_bytes() {
    let msgs = fixture_messages();
    let tools = fixture_tools();
    for (name, tools, flags) in [
        ("tools+thinking", Some(&tools[..]), thinking_on()),
        ("tools+nothink", Some(&tools[..]), RenderFlags::default()),
        ("plain+thinking", None, thinking_on()),
    ] {
        let retired =
            render_fixture("retired-qwen3_5-override-2026-04", &msgs, tools, flags).unwrap();
        let own = render_fixture("qwen3.6-27b-unsloth", &msgs, tools, flags).unwrap();
        assert_eq!(own, retired, "parity with retired override broke: {name}");
    }
}

#[test]
fn q36_preserve_thinking_true_rehydrates_history_think() {
    let r = render_fixture(
        "qwen3.6-27b-unsloth",
        &fixture_messages(),
        Some(&fixture_tools()),
        RenderFlags {
            enable_thinking: true,
            preserve_thinking: Some(true),
            ..Default::default()
        },
    )
    .unwrap();
    assert_eq!(r, with_history_think(Q36_GOLDEN));
}

#[test]
fn q36_thinking_off_closes_think_tail_only() {
    let r = render_fixture(
        "qwen3.6-27b-unsloth",
        &fixture_messages(),
        Some(&fixture_tools()),
        RenderFlags::default(),
    )
    .unwrap();
    // 2026-09-26: The Qwen3.6 template does not read `reasoning_effort`, so the `"none"`
    // sent with thinking off changes nothing; only the generation tail differs.
    assert_eq!(r, with_closed_think_tail(Q36_GOLDEN));
}

#[test]
fn q38_default_keeps_history_think_and_injects_no_effort_sentence() {
    let r = render_fixture(
        "qwen3.8-27b-unsloth",
        &fixture_messages(),
        Some(&fixture_tools()),
        thinking_on(),
    )
    .unwrap();
    assert_eq!(r, q38_unset_golden());
}

/// 2026-09-26: A request without an effort renders the same bytes as an explicit
/// `"medium"`. The budget side agrees: both get the model's `max_thinking_budget`.
#[test]
fn q38_unset_equals_explicit_medium() {
    let msgs = fixture_messages();
    let tools = fixture_tools();
    let unset = render_fixture("qwen3.8-27b-unsloth", &msgs, Some(&tools), thinking_on()).unwrap();
    let medium = render_fixture(
        "qwen3.8-27b-unsloth",
        &msgs,
        Some(&tools),
        RenderFlags {
            enable_thinking: true,
            reasoning_effort: Some("medium"),
            ..Default::default()
        },
    )
    .unwrap();
    assert_eq!(unset, medium);
}

#[test]
fn q38_preserve_thinking_false_strips_history_think() {
    let r = render_fixture(
        "qwen3.8-27b-unsloth",
        &fixture_messages(),
        Some(&fixture_tools()),
        RenderFlags {
            enable_thinking: true,
            preserve_thinking: Some(false),
            ..Default::default()
        },
    )
    .unwrap();
    assert_eq!(r, Q36_GOLDEN);
}

#[test]
fn q38_thinking_off_skips_effort_validator_and_sentence() {
    let r = render_fixture(
        "qwen3.8-27b-unsloth",
        &fixture_messages(),
        Some(&fixture_tools()),
        RenderFlags::default(),
    )
    .unwrap();
    // 2026-09-26: With thinking off the Qwen3.8 template skips its whole effort block, so the
    // `"none"` sent then never reaches its validator. The earlier turns' reasoning is still
    // kept: that default does not depend on `enable_thinking`.
    assert_eq!(r, with_closed_think_tail(&with_history_think(Q36_GOLDEN)));
}

#[test]
fn q38_explicit_efforts_render_their_sentences() {
    let msgs = fixture_messages();
    let tools = fixture_tools();
    let flags = |effort: &'static str| RenderFlags {
        enable_thinking: true,
        reasoning_effort: Some(effort),
        ..Default::default()
    };
    // 2026-09-26: "xhigh" is what `ir::ReasoningEffort::Max` renders as.
    let r = render_fixture("qwen3.8-27b-unsloth", &msgs, Some(&tools), flags("xhigh")).unwrap();
    assert_eq!(r, q38_golden());
    let r = render_fixture("qwen3.8-27b-unsloth", &msgs, Some(&tools), flags("high")).unwrap();
    assert_eq!(r, q38_golden());
    let r = render_fixture("qwen3.8-27b-unsloth", &msgs, Some(&tools), flags("low")).unwrap();
    assert_eq!(
        r,
        with_history_think(&with_effort_sentence(Q36_GOLDEN, LOW_SENTENCE))
    );
    let r = render_fixture("qwen3.8-27b-unsloth", &msgs, Some(&tools), flags("medium")).unwrap();
    assert_eq!(r, with_history_think(Q36_GOLDEN));
}

/// 2026-09-26: The Qwen3.8 template raises on the string `"max"`, which is why
/// `ir::ReasoningEffort::Max` renders as `"xhigh"`.
#[test]
fn q38_raw_max_effort_string_raises() {
    let err = render_fixture(
        "qwen3.8-27b-unsloth",
        &fixture_messages(),
        Some(&fixture_tools()),
        RenderFlags {
            enable_thinking: true,
            reasoning_effort: Some("max"),
            ..Default::default()
        },
    )
    .unwrap_err();
    assert!(
        format!("{err:#}").contains("Unexpected reasoning effort"),
        "expected the template's effort validator to fire, got: {err:#}"
    );
}

/// 2026-09-26: The Qwen3.8 template raises on tool-call `arguments` given as a non-empty
/// string. `render_chat` parses them first (`normalize_tool_call_arguments`), so the render
/// equals the one with structured arguments.
#[test]
fn q38_string_tool_args_are_normalized_before_strict_validation() {
    let mut msgs = fixture_messages();
    msgs[2]["tool_calls"][0]["function"]["arguments"] = json!("{\"location\":\"Paris\"}");
    let r = render_fixture(
        "qwen3.8-27b-unsloth",
        &msgs,
        Some(&fixture_tools()),
        thinking_on(),
    )
    .unwrap();
    assert_eq!(r, q38_unset_golden());
}