Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 98 additions & 22 deletions common/chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -908,12 +908,11 @@ static void foreach_function(const json & tools, const std::function<void(const
}
}

static void foreach_parameter(const json & function,
static void foreach_parameter(const json & params,
const std::function<void(const std::string &, const json &, bool)> & fn) {
if (!function.contains("parameters") || !function.at("parameters").is_object()) {
if (!params.is_object()) {
return;
}
const auto & params = function.at("parameters");
if (!params.contains("properties") || !params.at("properties").is_object()) {
return;
}
Expand Down Expand Up @@ -1259,28 +1258,105 @@ static common_chat_params common_chat_params_init_qwen3_coder(const common_chat_
auto schema_info = common_schema_info();
schema_info.resolve_refs(parameters);

std::vector<common_peg_parser> required_args;
std::vector<common_peg_parser> optional_args;

foreach_parameter(function, [&](const std::string & param_name, const json & param_schema, bool is_required) {
auto rule_name = "tool-" + name + "-arg-" + param_name;

auto arg_open = p.tool_arg_open("<parameter=" + p.tool_arg_name(p.literal(param_name)) + ">\n");

auto arg_value = schema_info.resolves_to_string(param_schema) ?
arg_string :
p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", param_schema)) + arg_close;
auto build_args = [&](const json & schema, const std::string & rule_prefix) {
std::vector<common_peg_parser> required_args;
std::vector<common_peg_parser> optional_args;

foreach_parameter(schema, [&](const std::string & param_name, const json & param_schema, bool is_required) {
auto rule_name = rule_prefix + "-arg-" + param_name;
auto arg_open = p.tool_arg_open("<parameter=" + p.tool_arg_name(p.literal(param_name)) + ">\n");
auto arg_value = arg_string;

if (param_schema.contains("const") && param_schema.at("const").is_string()) {
arg_value = p.tool_arg_string_value(p.literal(param_schema.at("const").get<std::string>())) + arg_close;
} else if (param_schema.contains("enum") && !param_schema.at("enum").empty() &&
std::all_of(param_schema.at("enum").begin(), param_schema.at("enum").end(),
[](const json & value) { return value.is_string(); })) {
auto values = p.choice();
for (const auto & value : param_schema.at("enum")) {
values |= p.literal(value.get<std::string>()) + p.peek(p.literal("\n</parameter>\n"));
}
arg_value = p.tool_arg_string_value(values) + arg_close;
} else if (!schema_info.resolves_to_string(param_schema)) {
arg_value = p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", param_schema)) + arg_close;
}

auto arg_rule = p.rule(rule_name, p.tool_arg(arg_open + arg_value));
auto arg_rule = p.rule(rule_name, p.tool_arg(arg_open + arg_value));
(is_required ? required_args : optional_args).push_back(arg_rule);
});

(is_required ? required_args : optional_args).push_back(arg_rule);
});
// Required arguments may arrive in any order; optional arguments follow them.
auto args = p.permute(rule_prefix + "-args", required_args);
if (!optional_args.empty()) {
args = args + p.zero_or_more(p.choice(optional_args));
}
return args;
};

// Accept required arguments in any order, as Qwen does not always adhere to the
// order provided.
auto args = p.permute("tool-" + name + "-args", required_args);
if (!optional_args.empty()) {
args = args + p.zero_or_more(p.choice(optional_args));
auto args = p.eps();
if (parameters.contains("oneOf") || parameters.contains("anyOf")) {
if (parameters.contains("properties") || parameters.contains("required") ||
parameters.contains("additionalProperties") || parameters.contains("$ref") ||
parameters.value("type", "object") != "object" || parameters.contains("allOf") ||
(parameters.contains("oneOf") && parameters.contains("anyOf"))) {
throw std::runtime_error("Qwen XML tool schema cannot mix root object constraints with a union");
}
const auto & variants = parameters.at(parameters.contains("oneOf") ? "oneOf" : "anyOf");
if (!variants.is_array() || variants.empty()) {
throw std::runtime_error("Qwen XML tool schema needs nonempty object alternatives");
}
// A plain grammar choice implements anyOf. For oneOf, prove that every
// pair is separated by a required finite-valued property before using it.
if (parameters.contains("oneOf")) {
for (size_t i = 0; i < variants.size(); ++i) {
for (size_t j = 0; j < i; ++j) {
bool disjoint = false;
foreach_parameter(variants.at(i), [&](const std::string & key, const json & prop, bool required) {
if (!required) {
return;
}
foreach_parameter(variants.at(j), [&](const std::string & other_key, const json & other, bool other_required) {
if (!other_required || key != other_key) {
return;
}
const auto values = prop.contains("const") ? json::array({prop.at("const")}) : prop.value("enum", json::array());
const auto other_values = other.contains("const") ? json::array({other.at("const")}) : other.value("enum", json::array());
if (!values.is_array() || !other_values.is_array() || values.empty() || other_values.empty() ||
!std::all_of(values.begin(), values.end(), [](const json & value) { return value.is_string(); }) ||
!std::all_of(other_values.begin(), other_values.end(), [](const json & value) { return value.is_string(); })) {
return;
}
bool overlap = false;
for (const auto & value : values) {
for (const auto & other_value : other_values) {
overlap |= value == other_value;
}
}
disjoint |= !overlap;
});
});
if (!disjoint) {
throw std::runtime_error("Qwen XML oneOf tool alternatives need disjoint required const/enum values");
}
}
}
}
auto alternatives = p.choice();
size_t index = 0;
for (const auto & variant : variants) {
if (!variant.is_object() || variant.value("type", "object") != "object" ||
variant.contains("$ref") || variant.contains("oneOf") || variant.contains("anyOf") ||
variant.contains("allOf")) {
throw std::runtime_error("Qwen XML tool schema requires direct object alternatives");
}
const auto rule_prefix = "tool-" + name + "-variant-" + std::to_string(index++);
alternatives |= p.rule(rule_prefix, build_args(variant, rule_prefix) + p.peek(p.literal("</function>\n")));
}
// A later discriminator can change an earlier argument's type. Do not stream
// a provisional branch, since emitted argument deltas cannot be retracted.
args = p.atomic(alternatives);
} else {
args = build_args(parameters, "tool-" + name);
}

auto func = p.tool(p.tool_open("<function=" + p.tool_name(p.literal(name)) + ">\n") +
Expand Down
8 changes: 8 additions & 0 deletions docs/function-calling.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,14 @@ This table can be generated with:

</details>

## Qwen XML tool schemas

The Qwen3-Coder XML handler also accepts root `anyOf` alternatives of direct object schemas. Root `oneOf` uses the same grammar construction only when every pair of alternatives has a required string `const` or `enum` property with disjoint values. The property name is not fixed. Each alternative retains its own required arguments and property schemas; string constants and enums constrain the raw XML parameter value.

Root `properties`, `required`, `additionalProperties` or `$ref` mixed with a union, a non-object root type, referenced or nested root alternatives, and `oneOf` alternatives without a provably disjoint string discriminator are not supported by this handler and return an error. This is not full JSON Schema conformance; nested JSON values retain the converter's existing limitations. Required parameters precede optional parameters, as with ordinary XML tool schemas.

For union schemas, argument deltas are held until the argument branch completes. A discriminator may follow a value whose type differs between branches, so publishing a provisional parse could emit JSON that later needs to be retracted. Reasoning and the tool name can still stream normally.

# Usage - need tool-aware Jinja template

First, start a server with any model, but make sure it has a tools-enabled template: you can verify this by inspecting the `chat_template` or `chat_template_tool_use` properties in `http://localhost:8080/props`).
Expand Down
117 changes: 117 additions & 0 deletions tests/test-chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7154,6 +7154,121 @@ static void test_msg_diffs_compute() {
}
}

static void test_qwen_root_tool_unions() {
const std::string schema = R"({
"oneOf": [
{"type":"object","properties":{"mode":{"type":"string","const":"list"},"limit":{"type":"integer","minimum":1,"maximum":5}},"required":["mode"],"additionalProperties":false},
{"type":"object","properties":{"mode":{"type":"string","enum":["load","reload"]},"paths":{"type":"array","minItems":1,"items":{"type":"string"}},"limit":{"type":"integer","minimum":10,"maximum":20}},"required":["mode","paths"],"additionalProperties":false}
]
})";
const auto xml = [](const std::string & body) {
return "<tool_call>\n<function=manage_records>\n" + body + "</function>\n</tool_call>";
};
const auto arg = [](const std::string & name, const std::string & value) {
return "<parameter=" + name + ">\n" + value + "\n</parameter>\n";
};
for (const std::string path : {"models/templates/Qwen3-Coder.jinja", "models/templates/Qwen3.5-4B.jinja"}) {
auto tmpls = read_templates(path);
for (const char * keyword : {"oneOf", "anyOf"}) {
auto parameters = json::parse(schema);
if (std::string(keyword) == "anyOf") {
parameters["anyOf"] = parameters.at("oneOf");
parameters.erase("oneOf");
}
const common_chat_tool tool = {"manage_records", "Manage records.", parameters.dump()};
common_chat_templates_inputs in;
in.messages = {message_user};
in.tools = {tool};
in.parallel_tool_calls = true;
in.reasoning_format = COMMON_REASONING_FORMAT_DEEPSEEK;
const auto params = common_chat_templates_apply(tmpls.get(), in);
const std::vector<std::pair<std::string, std::string>> valid = {
{arg("mode", "list"), R"({"mode":"list"})"},
{arg("mode", "list") + arg("limit", "3"), R"({"mode":"list","limit":3})"},
{arg("mode", "load") + arg("paths", "[\"example\"]") + arg("limit", "12"), R"({"mode":"load","paths":["example"],"limit":12})"},
{arg("paths", "[\"example\"]") + arg("mode", "reload"), R"({"paths":["example"],"mode":"reload"})"}
};
for (const auto & item : valid) {
auto grammar = build_grammar(params.grammar);
assert_equals(true, grammar != nullptr);
assert_equals(true, match_string(xml(item.first), grammar.get()));
test_peg_parser(tmpls.get(), [&](peg_test_case & tc) {
tc.params = in;
tc.input = "Inspect the records.\n</think>\n\n" + xml(item.first);
if (path.find("Qwen3-Coder") != std::string::npos) {
tc.input = xml(item.first);
}
tc.expect = simple_assist_msg("", path.find("Qwen3-Coder") == std::string::npos ? "Inspect the records." : "", "manage_records", item.second);
}, false);
}
const std::vector<std::string> invalid = {
"", arg("mode", "unknown"), arg("mode", "load"),
arg("mode", "list") + arg("paths", "[\"example\"]"),
arg("mode", "list") + arg("limit", "12"),
arg("mode", "load") + arg("paths", "[]"),
arg("mode", "load") + arg("paths", "[1]"),
arg("mode", "load") + arg("paths", "[\"example\"]") + arg("limit", "3")
};
for (const auto & item : invalid) {
auto grammar = build_grammar(params.grammar);
assert_equals(false, match_string(xml(item), grammar.get()));
}
test_peg_parser(tmpls.get(), [&](peg_test_case & tc) {
tc.params = in;
tc.input = std::string(path.find("Qwen3-Coder") == std::string::npos ? "</think>\n\n" : "") +
xml(valid[0].first) + "\n" + xml(valid[3].first);
tc.expect.role = "assistant";
tc.expect.tool_calls = {{"manage_records", valid[0].second, ""}, {"manage_records", valid[3].second, ""}};
}, false);
}
}
}

static void test_qwen_union_stream_types() {
auto tmpls = read_templates("models/templates/Qwen3.5-4B.jinja");
common_chat_templates_inputs in;
in.messages = {message_user};
in.reasoning_format = COMMON_REASONING_FORMAT_DEEPSEEK;
in.tools = {{"choose_value", "Choose a typed value.", R"({"oneOf":[
{"type":"object","properties":{"kind":{"type":"string","const":"text"},"value":{"type":"string"}},"required":["kind","value"],"additionalProperties":false},
{"type":"object","properties":{"kind":{"type":"string","enum":["int","integer"]},"value":{"type":"integer"}},"required":["kind","value"],"additionalProperties":false}
]})"}};
for (const auto & kind : {"int", "integer"}) {
test_peg_parser(tmpls.get(), [&](peg_test_case & tc) {
tc.params = in;
tc.input = std::string("</think>\n\n<tool_call>\n<function=choose_value>\n") +
"<parameter=value>\n42\n</parameter>\n<parameter=kind>\n" + std::string(kind) +
"\n</parameter>\n</function>\n</tool_call>";
tc.expect = simple_assist_msg("", "", "choose_value", "{\"value\":42,\"kind\":\"" + std::string(kind) + "\"}");
}, false);
}
auto parameters = json::parse(in.tools[0].parameters);
parameters["oneOf"][1]["properties"]["kind"] = json::parse(R"({"type":"string","const":"text"})");
in.tools[0].parameters = parameters.dump();
bool rejected = false;
try {
common_chat_templates_apply(tmpls.get(), in);
} catch (const std::runtime_error & error) {
rejected = std::string(error.what()).find("disjoint") != std::string::npos;
}
assert_equals(true, rejected);

parameters = json::parse(in.tools[0].parameters);
parameters["oneOf"][1]["properties"]["kind"] = json::parse(R"({"type":"string","const":"number"})");
for (const std::string key : {"properties", "required", "additionalProperties", "$ref", "type"}) {
auto mixed = parameters;
mixed[key] = key == "type" ? json("string") : json(false);
in.tools[0].parameters = mixed.dump();
rejected = false;
try {
common_chat_templates_apply(tmpls.get(), in);
} catch (const std::runtime_error &) {
rejected = true;
}
assert_equals(true, rejected);
}
}

int main(int argc, char ** argv) {
bool detailed_debug = false;
bool only_run_filtered = false;
Expand Down Expand Up @@ -7226,6 +7341,8 @@ int main(int argc, char ** argv) {
} else
#endif
{
test_qwen_root_tool_unions();
test_qwen_union_stream_types();
test_msg_diffs_compute();
test_msgs_oaicompat_json_conversion();
test_msg_token_delimiters_split();
Expand Down