diff --git a/common/chat.cpp b/common/chat.cpp index 743ecde0a77e..04eb2080bdb5 100644 --- a/common/chat.cpp +++ b/common/chat.cpp @@ -908,12 +908,11 @@ static void foreach_function(const json & tools, const std::function & 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; } @@ -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 required_args; - std::vector 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("\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 required_args; + std::vector 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("\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())) + 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()) + p.peek(p.literal("\n\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("\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("\n") + diff --git a/docs/function-calling.md b/docs/function-calling.md index 850b59ce7aa7..7ac0e8bc2eb8 100644 --- a/docs/function-calling.md +++ b/docs/function-calling.md @@ -279,6 +279,14 @@ This table can be generated with: +## 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`). diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp index 7918f0ffcf48..8baa29325881 100644 --- a/tests/test-chat.cpp +++ b/tests/test-chat.cpp @@ -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 "\n\n" + body + "\n"; + }; + const auto arg = [](const std::string & name, const std::string & value) { + return "\n" + value + "\n\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> 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\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 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 ? "\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("\n\n\n\n") + + "\n42\n\n\n" + std::string(kind) + + "\n\n\n"; + 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; @@ -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();