Skip to content
Merged
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
36 changes: 35 additions & 1 deletion common/arg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,10 @@ common_models_handler common_models_handler_init(const common_params & params, l
params.speculative.types.end(),
COMMON_SPECULATIVE_TYPE_DRAFT_EAGLE3) != params.speculative.types.end();

const bool spec_type_draft_dspark = std::find(params.speculative.types.begin(),
params.speculative.types.end(),
COMMON_SPECULATIVE_TYPE_DRAFT_DSPARK) != params.speculative.types.end();

// only download mmproj if the current example is using it
bool use_mmproj = false;
for (const auto & ex : mmproj_examples) {
Expand All @@ -391,6 +395,7 @@ common_models_handler common_models_handler_init(const common_params & params, l
opts.download_mtp = spec_type_draft_mtp;
opts.download_eagle3 = spec_type_draft_eagle3;
opts.download_dflash = spec_type_draft_dflash;
opts.download_dspark = spec_type_draft_dspark;
opts.download_mmproj = use_mmproj && !params.no_mmproj
&& params.mmproj.path.empty() && params.mmproj.url.empty();

Expand All @@ -405,6 +410,7 @@ common_models_handler common_models_handler_init(const common_params & params, l
opts_spec.download_mtp = true;
opts_spec.download_dflash = true;
opts_spec.download_eagle3 = true;
opts_spec.download_dspark = true;
}
plan_spec = common_download_get_hf_plan(params.speculative.draft.mparams, opts_spec);
}
Expand Down Expand Up @@ -547,12 +553,19 @@ void common_models_handler_apply(common_models_handler & handler, common_params
plan_spec.mtp = {};
plan_spec.dflash = {};
plan_spec.eagle3 = {};
plan_spec.dspark = {};
}

// infer the speculative type from the sidecar shipped by the draft repo when none is requested
if (spec_types_is_default(params)) {
if (!plan_spec.mtp.local_path.empty()) {
params.speculative.types = { COMMON_SPECULATIVE_TYPE_DRAFT_MTP };
plan_spec.dspark = {};
plan_spec.dflash = {};
plan_spec.eagle3 = {};
} else if (!plan_spec.dspark.local_path.empty()) {
// dspark outranks dflash, its sidecar carries the extra Markov head
params.speculative.types = { COMMON_SPECULATIVE_TYPE_DRAFT_DSPARK };
plan_spec.dflash = {};
plan_spec.eagle3 = {};
} else if (!plan_spec.dflash.local_path.empty()) {
Expand All @@ -566,7 +579,8 @@ void common_models_handler_apply(common_models_handler & handler, common_params
// when a sidecar type is requested, the draft repo resolves to its sidecar instead of a full model
const bool spec_sidecar_found = !plan_spec.mtp.local_path.empty() ||
!plan_spec.dflash.local_path.empty() ||
!plan_spec.eagle3.local_path.empty();
!plan_spec.eagle3.local_path.empty() ||
!plan_spec.dspark.local_path.empty();
if (!plan_spec.mtp.local_path.empty() && !had_spec_url) {
tasks.emplace_back(plan_spec.mtp, opts, [&]() {
// only use the discovered MTP head when no draft path is set yet
Expand Down Expand Up @@ -597,6 +611,16 @@ void common_models_handler_apply(common_models_handler & handler, common_params
}
});
}
if (!plan_spec.dspark.local_path.empty() && !had_spec_url) {
tasks.emplace_back(plan_spec.dspark, opts, [&]() {
// only use the discovered DSpark sidecar when no draft path is set yet
if (params.speculative.draft.mparams.path.empty()) {
params.speculative.draft.mparams.path = hf_cache::finalize_file(plan_spec.dspark);
} else {
hf_cache::finalize_file(plan_spec.dspark);
}
});
}

// a wired draft sidecar counts as an explicit draft for the main plan fallback below
if (spec_sidecar_found) {
Expand Down Expand Up @@ -652,6 +676,16 @@ void common_models_handler_apply(common_models_handler & handler, common_params
}
});
}
if (!plan.dspark.local_path.empty() && !had_spec_url) {
tasks.emplace_back(plan.dspark, opts, [&]() {
// only fall back to the discovered DSpark sidecar when no draft was explicitly provided
if (params.speculative.draft.mparams.empty()) {
params.speculative.draft.mparams.path = hf_cache::finalize_file(plan.dspark);
} else {
hf_cache::finalize_file(plan.dspark);
}
});
}
if (!plan.preset.local_path.empty()) {
tasks.emplace_back(plan.preset, opts, [&]() {
// if HF repo is a preset repo, we simply run server in router mode with the preset.ini file
Expand Down
40 changes: 40 additions & 0 deletions common/chat-peg-parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

#include <nlohmann/json.hpp>

#include <cstdint>
#include <functional>

using ordered_json = nlohmann::ordered_json;

static std::string_view trim_trailing_space(std::string_view sv, int max = -1) {
Expand Down Expand Up @@ -235,6 +238,43 @@ common_peg_parser common_chat_peg_builder::tag_with_safe_content(const std::stri
return zero_or_more(choice({ p, content_chunk }));
}

common_peg_parser common_chat_peg_builder::permute(const std::string & rule_prefix,
const std::vector<common_peg_parser> & parsers) {
if (parsers.empty()) {
return eps();
}

if (parsers.size() == 1 || parsers.size() > COMMON_CHAT_MAX_PERMUTE) {
return sequence(parsers);
}

std::map<uint32_t, common_peg_parser> rules;
std::function<common_peg_parser(uint32_t)> remaining_of;

remaining_of = [&](uint32_t remaining) -> common_peg_parser {
if (remaining == 0) {
return eps();
}

auto cached = rules.find(remaining);
if (cached != rules.end()) {
return cached->second;
}

auto alternatives = choice();
for (size_t i = 0; i < parsers.size(); i++) {
const uint32_t bit = 1u << i;
if (remaining & bit) {
alternatives |= parsers[i] + remaining_of(remaining & ~bit);
}
}

return rules.emplace(remaining, rule(rule_prefix + "-" + std::to_string(remaining), alternatives)).first->second;
};

return remaining_of((1u << parsers.size()) - 1);
}

std::string & common_chat_peg_mapper::args_target() {
return (current_tool && !current_tool->name.empty()) ? current_tool->arguments : args_buffer;
}
Expand Down
5 changes: 5 additions & 0 deletions common/chat-peg-parser.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class common_chat_peg_minimax_m3_mapper : public common_chat_peg_mapper {
struct content_structure;
struct tool_call_structure;

constexpr size_t COMMON_CHAT_MAX_PERMUTE = 6;

class common_chat_peg_builder : public common_peg_parser_builder {
public:
// Tag constants (from former common_chat_peg_base_builder)
Expand Down Expand Up @@ -105,6 +107,9 @@ class common_chat_peg_builder : public common_peg_parser_builder {
common_peg_parser tool_arg_json_value(const common_peg_parser & p) { return tag(TOOL_ARG_VALUE, p); }


// Matches every parser exactly once, in any order.
common_peg_parser permute(const std::string & rule_prefix, const std::vector<common_peg_parser> & parsers);

// Return a parser that parses the prefix of a string, up to a given delimiter.
common_peg_parser prefix(const std::string & s, const std::string & delimiter = {});

Expand Down
174 changes: 174 additions & 0 deletions common/chat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1110,6 +1110,172 @@ static common_chat_params common_chat_params_init_ministral_3(const common_chat_
return data;
}

static common_chat_params common_chat_params_init_qwen3_coder(const common_chat_template & tmpl,
const autoparser::generation_params & inputs) {
common_chat_params data;

const std::string GEN_PREFIX = "<|im_start|>assistant\n";

data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;

auto supports_reasoning = tmpl.source().find("<think>") != std::string::npos;

data.supports_thinking = supports_reasoning;
data.preserved_tokens = {
"<tool_call>",
"</tool_call>",
};

if (supports_reasoning) {
data.thinking_start_tag = "<think>";
// Support both </think> and <tool_call> as reasoning end sequences.
// <function= is omitted, as it is a workaround for Qwen3-Coder which is not a thinking model
data.thinking_end_tags = { "</think>", "<tool_call>" };
data.preserved_tokens.insert(data.preserved_tokens.end(), { "<think>", "</think>" });
}

data.message_delimiters = {
{ COMMON_CHAT_ROLE_ASSISTANT, "<|im_start|>assistant" },
{ COMMON_CHAT_ROLE_TOOL, "<|im_start|>user\n<tool_response>" }, // Qwen3-Coder, Qwen3.5, Nemotron Nano 3
{ COMMON_CHAT_ROLE_TOOL, "<|im_start|>tool_response" }, // StepFun-3.5-Flash
{ COMMON_CHAT_ROLE_USER, "<|im_start|>user" },
{ COMMON_CHAT_ROLE_SYSTEM, "<|im_start|>system" },
};

auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
auto has_response_format = inputs.json_schema.is_object() && !inputs.json_schema.empty();
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
auto include_grammar = has_response_format || (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE);

if (inputs.has_continuation()) {
const auto & msg = inputs.continue_msg;

data.generation_prompt = GEN_PREFIX;
if (supports_reasoning) {
data.generation_prompt += "<think>\n" + msg.reasoning_content;
if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
data.generation_prompt += "\n</think>\n\n";
}
}
if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
data.generation_prompt += msg.render_content();
}

data.prompt += data.generation_prompt;
}

auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
auto generation_prompt = p.literal(GEN_PREFIX);

auto reasoning = p.eps();
if (supports_reasoning && extract_reasoning) {
reasoning = p.optional("<think>" + p.space() +
p.reasoning(p.until_one_of({ "</think>", "<tool_call>" })) +
(p.literal("</think>") | p.peek(p.literal("<tool_call>"))));
}

// Response format parser
if (has_response_format) {
return generation_prompt + (reasoning << p.content(p.schema(p.json(), "response-format", inputs.json_schema)));
}

// Tool call parser
if (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE) {
auto arg_close = p.tool_arg_close(p.literal("\n</parameter>\n"));
auto arg_string = p.rule("xml-arg-string",
p.ac(p.tool_arg_string_value(p.until("\n</parameter>\n")) + arg_close, "\n</parameter>\n"));

auto tool_choice = p.choice();
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
std::string name = function.at("name");
auto parameters = function.contains("parameters") ? function.at("parameters") : json::object();

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 arg_rule = p.rule(rule_name, p.tool_arg(arg_open + arg_value));

(is_required ? required_args : optional_args).push_back(arg_rule);
});

// 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 func = p.tool(p.tool_open("<function=" + p.tool_name(p.literal(name)) + ">\n") +
p.tool_args(args) +
p.tool_close(p.literal("</function>\n")));

tool_choice |= p.rule("tool-" + name, func);
});

auto min_calls = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED ? 1 : 0;

// Qwen3-Coder models may occasionally omit the <tool_call> token.
auto tool_call_body = tool_choice + "</tool_call>" + p.space();
auto tool_call_first = p.rule("tool-call-first", p.optional(p.literal("<tool_call>\n")) + tool_call_body);
auto tool_call = p.rule("tool-call", "<tool_call>\n" + tool_call_body);

auto calls = inputs.parallel_tool_calls ? tool_call_first + p.zero_or_more(tool_call) : tool_call_first;
auto tool_calls = p.trigger_rule("tool-call-root", p.repeat(calls, min_calls, 1));

return generation_prompt +
(reasoning << p.content(p.until_one_of({ "<tool_call>", "<function=" })) << tool_calls);
}

// Content only parser
return generation_prompt + (reasoning << p.content(p.rest()));
});

data.parser = parser.save();

if (include_grammar) {
data.grammar_lazy = has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;

data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});

if (data.grammar_lazy) {
data.grammar_triggers = {
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "<tool_call>" },
// Trigger on "<function" and not "<function=" because the trailing "=" is part of
// the token with the function name e.g. "=read"
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, "<function" },
};
}
}

return data;
}

static common_chat_params common_chat_params_init_gpt_oss(const common_chat_template & tmpl,
const autoparser::generation_params & inputs) {
common_chat_params data;
Expand Down Expand Up @@ -3016,6 +3182,14 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
return common_chat_params_init_minicpm5(tmpl, params);
}

// Qwen3-Coder XML tool calls, also used by Nemotron Nano 3, Qwen3.5 and StepFun-3.5-Flash
if (src.find("<tool_call>") != std::string::npos &&
src.find("<function=") != std::string::npos &&
src.find("<parameter=") != std::string::npos) {
LOG_DBG("Using specialized template: Qwen3-Coder\n");
return common_chat_params_init_qwen3_coder(tmpl, params);
}

return std::nullopt;
}

Expand Down
Loading
Loading