mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-09 14:29:15 +02:00
895c045fd1
* chat : split specialized parsers into common/parsers Move the 14 dedicated template parsers out of chat.cpp into one file each under common/parsers, mirroring the src/models split. chat.cpp keeps the template detection in common_chat_try_specialized_template() and drops from 3915 to 1513 lines. common/parsers/parsers.h holds the shared helpers and one declaration per parser. foreach_function/foreach_parameter become inline there since nothing in chat.cpp uses them any more; common_chat_template_direct_apply_impl and common_chat_template_generation_prompt_impl lose static and carry their default arguments in the header. Parser-specific helpers move with their parser: is_lfm2_template, deepseek_v4_sort_tool_results and the gemma4 turn builder. No functional change. Assisted-by: Claude Opus 5 * chat : enumerate parser sources instead of globbing file(GLOB) does not re-run CMake when a source file is added or removed, so an incremental build silently keeps building the old set. List the parsers in common/parsers/sources.cmake and include it from common/CMakeLists.txt. Assisted-by: Claude Opus 5 * split helpers, add newlines
1514 lines
62 KiB
C++
1514 lines
62 KiB
C++
#include "chat.h"
|
|
|
|
#include "chat-auto-parser-helpers.h"
|
|
#include "chat-auto-parser.h"
|
|
#include "chat-peg-parser.h"
|
|
#include "common.h"
|
|
#include "ggml.h"
|
|
#include "json-schema-to-grammar.h"
|
|
#include "json.h"
|
|
#include "log.h"
|
|
#include "parsers/parsers.h"
|
|
|
|
#include "jinja/value.h"
|
|
#include "jinja/runtime.h"
|
|
#include "jinja/caps.h"
|
|
#include "peg-parser.h"
|
|
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <ctime>
|
|
#include <exception>
|
|
#include <functional>
|
|
#include <iomanip>
|
|
#include <map>
|
|
|
|
#include <optional>
|
|
#include <sstream>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
using json = common_json;
|
|
|
|
static std::string format_time(const std::chrono::system_clock::time_point & now, const std::string & format) {
|
|
auto time = std::chrono::system_clock::to_time_t(now);
|
|
auto local_time = *std::localtime(&time);
|
|
std::ostringstream ss;
|
|
ss << std::put_time(&local_time, format.c_str());
|
|
auto res = ss.str();
|
|
return res;
|
|
}
|
|
|
|
static json safe_args_parse(const std::string & to_parse) {
|
|
std::string stripped = to_parse;
|
|
if (to_parse.at(0) == '"' && to_parse.at(to_parse.length() - 1) == '"') {
|
|
stripped = to_parse.substr(1, to_parse.length() - 1);
|
|
}
|
|
try {
|
|
return json::parse(stripped);
|
|
} catch (const common_json_error & e) {
|
|
return stripped;
|
|
}
|
|
}
|
|
|
|
static std::string string_diff(const std::string & last, const std::string & current) {
|
|
if (last.empty()) {
|
|
return current;
|
|
}
|
|
if (!string_starts_with(current, last)) {
|
|
if (string_starts_with(last, current)) {
|
|
// This happens if the last generation ended on a partial stop word (not erased),
|
|
// and the current ended on a stop word (erased).
|
|
return "";
|
|
}
|
|
throw std::runtime_error("Invalid diff: '" + last + "' not found at start of '" + current + "'");
|
|
}
|
|
return current.substr(last.size());
|
|
}
|
|
|
|
static bool has_content_or_tool_calls(const common_chat_msg & msg) {
|
|
return !msg.content.empty() || !msg.tool_calls.empty();
|
|
}
|
|
|
|
std::string common_chat_msg::render_content(const std::string & delimiter) const {
|
|
if (!content.empty() && !content_parts.empty()) {
|
|
throw std::runtime_error("Cannot specify both content and content_parts");
|
|
}
|
|
if (!content.empty()) {
|
|
return content;
|
|
}
|
|
|
|
std::string text;
|
|
for (const auto & part : content_parts) {
|
|
if (part.type == "text") {
|
|
if (!text.empty()) {
|
|
text += delimiter;
|
|
}
|
|
text += part.text;
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
common_chat_role common_chat_role_from_string(const std::string & role) {
|
|
if (role == "system") { return COMMON_CHAT_ROLE_SYSTEM; }
|
|
if (role == "assistant") { return COMMON_CHAT_ROLE_ASSISTANT; }
|
|
if (role == "user") { return COMMON_CHAT_ROLE_USER; }
|
|
if (role == "tool") { return COMMON_CHAT_ROLE_TOOL; }
|
|
return COMMON_CHAT_ROLE_UNKNOWN;
|
|
}
|
|
|
|
const char * common_chat_role_to_string(common_chat_role role) {
|
|
switch (role) {
|
|
case COMMON_CHAT_ROLE_SYSTEM: return "system";
|
|
case COMMON_CHAT_ROLE_ASSISTANT: return "assistant";
|
|
case COMMON_CHAT_ROLE_USER: return "user";
|
|
case COMMON_CHAT_ROLE_TOOL: return "tool";
|
|
case COMMON_CHAT_ROLE_UNKNOWN: return "";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
json common_chat_msg_delimiters::to_json() const {
|
|
json result = json::array();
|
|
for (const auto & d : delimiters) {
|
|
result.push_back({
|
|
{ "role", common_chat_role_to_string(d.role) },
|
|
{ "delimiter", d.delimiter },
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
common_chat_msg_delimiters common_chat_msg_delimiters_parse(const json & delimiters) {
|
|
common_chat_msg_delimiters result;
|
|
|
|
if (!delimiters.is_array()) {
|
|
return result;
|
|
}
|
|
|
|
result.delimiters.reserve(delimiters.size());
|
|
for (const auto & d : delimiters) {
|
|
if (!d.is_object()) {
|
|
continue;
|
|
}
|
|
result.delimiters.push_back({
|
|
common_chat_role_from_string(d.value("role", std::string())),
|
|
d.value("delimiter", std::string()),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
void common_chat_msg_delimiters::tokenize(const llama_vocab * vocab) {
|
|
for (auto & d : delimiters) {
|
|
d.tokens = common_tokenize(vocab, d.delimiter, false, true);
|
|
}
|
|
}
|
|
|
|
common_chat_msg_spans common_chat_msg_delimiters::split(const llama_tokens & tokens, const std::map<size_t, size_t> & skips) const {
|
|
std::vector<std::pair<common_chat_role, size_t>> matches;
|
|
|
|
auto skip = skips.begin();
|
|
for (size_t i = 0; i < tokens.size();) {
|
|
if (skip != skips.end() && i == skip->first) {
|
|
i += skip->second;
|
|
++skip;
|
|
continue;
|
|
}
|
|
for (const auto & d : delimiters) {
|
|
if (i + d.tokens.size() > tokens.size()) {
|
|
continue;
|
|
}
|
|
if (std::equal(d.tokens.begin(), d.tokens.end(), tokens.begin() + i)) {
|
|
matches.emplace_back(d.role, i);
|
|
break;
|
|
}
|
|
}
|
|
i++;
|
|
}
|
|
|
|
matches.emplace_back(COMMON_CHAT_ROLE_UNKNOWN, tokens.size());
|
|
|
|
common_chat_msg_spans spans;
|
|
for (size_t i = 0; i + 1 < matches.size(); i++) {
|
|
const auto & curr = matches[i];
|
|
const auto & next = matches[i + 1];
|
|
spans.add(curr.first, curr.second, next.second - curr.second);
|
|
}
|
|
|
|
return spans;
|
|
}
|
|
|
|
json common_chat_msg::to_json_oaicompat(bool concat_typed_text) const {
|
|
if (!content.empty() && !content_parts.empty()) {
|
|
throw std::runtime_error("Cannot specify both content and content_parts");
|
|
}
|
|
json jmsg {
|
|
{"role", role},
|
|
};
|
|
if (!content.empty()) {
|
|
jmsg["content"] = content;
|
|
} else if (!content_parts.empty()) {
|
|
if (concat_typed_text || contains_media()) {
|
|
std::string text;
|
|
bool last_was_media_marker = false;
|
|
// join parts with newline, do not add newline before or after media markers
|
|
for (const auto & part : content_parts) {
|
|
bool add_new_line = true;
|
|
if (part.type == "text") {
|
|
add_new_line = !last_was_media_marker && !text.empty();
|
|
last_was_media_marker = false;
|
|
} else if (part.type == "media_marker") {
|
|
add_new_line = false;
|
|
last_was_media_marker = true;
|
|
} else {
|
|
LOG_WRN("Ignoring content part type: %s\n", part.type.c_str());
|
|
continue;
|
|
}
|
|
|
|
if (add_new_line) {
|
|
text += '\n';
|
|
}
|
|
|
|
text += part.text;
|
|
}
|
|
jmsg["content"] = text;
|
|
} else {
|
|
auto & parts = jmsg["content"] = json::array();
|
|
for (const auto & part : content_parts) {
|
|
parts.push_back({
|
|
{"type", part.type},
|
|
{"text", part.text},
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
jmsg["content"] = "";
|
|
}
|
|
if (!reasoning_content.empty()) {
|
|
jmsg["reasoning_content"] = reasoning_content;
|
|
}
|
|
if (!tool_name.empty()) {
|
|
jmsg["name"] = tool_name;
|
|
}
|
|
if (!tool_call_id.empty()) {
|
|
jmsg["tool_call_id"] = tool_call_id;
|
|
}
|
|
if (!tool_calls.empty()) {
|
|
jmsg["tool_calls"] = json::array();
|
|
auto & jtool_calls = jmsg["tool_calls"];
|
|
for (const auto & tool_call : tool_calls) {
|
|
json tc {
|
|
{"type", "function"},
|
|
{"function", {
|
|
{"name", tool_call.name},
|
|
{"arguments", json(tool_call.arguments)},
|
|
}},
|
|
};
|
|
if (!tool_call.id.empty()) {
|
|
tc["id"] = tool_call.id;
|
|
}
|
|
// Some templates generate and require an id (sometimes in a very specific format, e.g. Mistral Nemo).
|
|
// We only generate a random id for the ones that don't generate one by themselves
|
|
// (they also won't get to see it as their template likely doesn't use it, so it's all for the client)
|
|
// {"id", tc.id.empty() ? gen_tool_call_id() : tc.id},
|
|
jtool_calls.push_back(tc);
|
|
}
|
|
}
|
|
|
|
return jmsg;
|
|
}
|
|
|
|
std::vector<common_chat_msg_diff> common_chat_msg_diff::compute_diffs(const common_chat_msg & msg_prv,
|
|
const common_chat_msg & msg_new) {
|
|
std::vector<common_chat_msg_diff> diffs;
|
|
if (msg_new.tool_calls.size() > msg_prv.tool_calls.size()) {
|
|
diffs.reserve(msg_new.tool_calls.size() - msg_prv.tool_calls.size() + 3);
|
|
} else {
|
|
diffs.reserve(3);
|
|
}
|
|
|
|
// TODO: these can become expensive for long messages - how to optimize?
|
|
if (msg_prv.reasoning_content != msg_new.reasoning_content) {
|
|
auto & diff = diffs.emplace_back();
|
|
diff.reasoning_content_delta = string_diff(msg_prv.reasoning_content, msg_new.reasoning_content);
|
|
}
|
|
if (msg_prv.content != msg_new.content) {
|
|
auto & diff = diffs.emplace_back();
|
|
diff.content_delta = string_diff(msg_prv.content, msg_new.content);
|
|
}
|
|
|
|
if (msg_new.tool_calls.size() < msg_prv.tool_calls.size()) {
|
|
std::string err = "Invalid diff: now finding less tool calls!\n";
|
|
err += " Previous (" + std::to_string(msg_prv.tool_calls.size()) + "):\n";
|
|
for (const auto & tc : msg_prv.tool_calls) {
|
|
err += " - name: '" + tc.name + "', args: '" + tc.arguments + "'\n";
|
|
}
|
|
err += " Current (" + std::to_string(msg_new.tool_calls.size()) + "):\n";
|
|
for (const auto & tc : msg_new.tool_calls) {
|
|
err += " - name: '" + tc.name + "', args: '" + tc.arguments + "'\n";
|
|
}
|
|
err += " Current msg text content:\n" + msg_new.content + "\n";
|
|
throw std::runtime_error(err);
|
|
}
|
|
|
|
if (!msg_prv.tool_calls.empty()) {
|
|
const auto idx = msg_prv.tool_calls.size() - 1;
|
|
const auto & pref = msg_prv.tool_calls[idx];
|
|
const auto & newf = msg_new.tool_calls[idx];
|
|
// Allow tool name to change during incremental parsing:
|
|
// - empty -> non-empty (initial discovery)
|
|
// - prefix -> longer string (name grows as more input is parsed)
|
|
if (pref.name != newf.name && !pref.name.empty() && !newf.name.empty()) {
|
|
// Check if one is a prefix of the other (for incremental parsing where names grow or shrink)
|
|
bool is_prefix = (newf.name.rfind(pref.name, 0) == 0);
|
|
if (!is_prefix) {
|
|
LOG_ERR("Tool call mismatch: prev='%s' new='%s'\n", pref.name.c_str(), newf.name.c_str());
|
|
throw std::runtime_error("Invalid diff: tool call mismatch!");
|
|
}
|
|
}
|
|
const auto args_diff = string_diff(pref.arguments, newf.arguments);
|
|
if (!args_diff.empty() || pref.id != newf.id || pref.name != newf.name) {
|
|
auto & diff = diffs.emplace_back();
|
|
diff.tool_call_index = idx;
|
|
if (pref.id != newf.id || pref.name != newf.name) {
|
|
diff.tool_call_delta.id = newf.id;
|
|
diff.tool_call_delta.name = newf.name;
|
|
}
|
|
diff.tool_call_delta.arguments = args_diff;
|
|
}
|
|
}
|
|
for (size_t idx = msg_prv.tool_calls.size(); idx < msg_new.tool_calls.size(); ++idx) {
|
|
auto & diff = diffs.emplace_back();
|
|
diff.tool_call_index = idx;
|
|
diff.tool_call_delta = msg_new.tool_calls[idx];
|
|
}
|
|
|
|
return diffs;
|
|
}
|
|
|
|
using chat_template_caps = jinja::caps;
|
|
|
|
struct common_chat_templates {
|
|
bool add_bos;
|
|
bool add_eos;
|
|
bool has_explicit_template; // Model had builtin template or template overridden was specified.
|
|
std::unique_ptr<common_chat_template> template_default; // always set (defaults to chatml)
|
|
std::unique_ptr<common_chat_template> template_tool_use;
|
|
};
|
|
|
|
common_chat_tool_choice common_chat_tool_choice_parse_oaicompat(const std::string & tool_choice) {
|
|
if (tool_choice == "auto") {
|
|
return COMMON_CHAT_TOOL_CHOICE_AUTO;
|
|
}
|
|
if (tool_choice == "none") {
|
|
return COMMON_CHAT_TOOL_CHOICE_NONE;
|
|
}
|
|
if (tool_choice == "required") {
|
|
return COMMON_CHAT_TOOL_CHOICE_REQUIRED;
|
|
}
|
|
throw std::invalid_argument("Invalid tool_choice: " + tool_choice);
|
|
}
|
|
|
|
bool common_chat_templates_support_enable_thinking(const common_chat_templates * chat_templates) {
|
|
common_chat_templates_inputs inputs;
|
|
inputs.reasoning_format = COMMON_REASONING_FORMAT_DEEPSEEK;
|
|
common_chat_msg msg;
|
|
msg.role = "user";
|
|
msg.content = "test";
|
|
inputs.messages = { msg };
|
|
inputs.enable_thinking = true;
|
|
inputs.add_generation_prompt = true;
|
|
inputs.reasoning_format = COMMON_REASONING_FORMAT_DEEPSEEK;
|
|
|
|
auto params = common_chat_templates_apply(chat_templates, inputs);
|
|
return params.supports_thinking;
|
|
}
|
|
|
|
std::vector<common_chat_msg> common_chat_msgs_parse_oaicompat(const json & messages) {
|
|
std::vector<common_chat_msg> msgs;
|
|
|
|
try {
|
|
if (!messages.is_array()) {
|
|
throw std::invalid_argument("Expected 'messages' to be an array, got " + messages.dump());
|
|
}
|
|
|
|
for (const auto & message : messages) {
|
|
if (!message.is_object()) {
|
|
throw std::invalid_argument("Expected 'message' to be an object, got " + message.dump());
|
|
}
|
|
|
|
common_chat_msg msg;
|
|
if (!message.contains("role")) {
|
|
throw std::invalid_argument("Missing 'role' in message: " + message.dump());
|
|
}
|
|
msg.role = message.at("role");
|
|
|
|
auto has_content = message.contains("content");
|
|
auto has_tool_calls = message.contains("tool_calls");
|
|
if (has_content) {
|
|
const auto & content = message.at("content");
|
|
if (content.is_string()) {
|
|
msg.content = content;
|
|
} else if (content.is_array()) {
|
|
for (const auto & part : content) {
|
|
if (!part.contains("type")) {
|
|
throw std::invalid_argument("Missing content part type: " + part.dump());
|
|
}
|
|
const auto & type = part.at("type");
|
|
if (type != "text" && type != "media_marker") {
|
|
throw std::invalid_argument("Unsupported content part type: " + type.dump());
|
|
}
|
|
common_chat_msg_content_part msg_part;
|
|
msg_part.type = type;
|
|
msg_part.text = part.at("text");
|
|
msg.content_parts.push_back(msg_part);
|
|
}
|
|
} else if (!content.is_null()) {
|
|
throw std::invalid_argument("Invalid 'content' type: expected string or array, got " +
|
|
content.dump() +
|
|
" (ref: https://github.com/ggml-org/llama.cpp/issues/8367)");
|
|
}
|
|
}
|
|
if (has_tool_calls) {
|
|
for (const auto & tool_call : message.at("tool_calls")) {
|
|
common_chat_tool_call tc;
|
|
if (!tool_call.contains("type")) {
|
|
throw std::invalid_argument("Missing tool call type: " + tool_call.dump());
|
|
}
|
|
const auto & type = tool_call.at("type");
|
|
if (type != "function") {
|
|
throw std::invalid_argument("Unsupported tool call type: " + tool_call.dump());
|
|
}
|
|
if (!tool_call.contains("function")) {
|
|
throw std::invalid_argument("Missing tool call function: " + tool_call.dump());
|
|
}
|
|
const auto & fc = tool_call.at("function");
|
|
if (!fc.contains("name")) {
|
|
throw std::invalid_argument("Missing tool call name: " + tool_call.dump());
|
|
}
|
|
tc.name = fc.at("name");
|
|
const auto & args = fc.at("arguments");
|
|
if (args.is_string()) {
|
|
tc.arguments = args;
|
|
} else {
|
|
tc.arguments = args.dump();
|
|
}
|
|
if (tool_call.contains("id")) {
|
|
tc.id = tool_call.at("id");
|
|
}
|
|
msg.tool_calls.push_back(tc);
|
|
}
|
|
}
|
|
if (!has_content && !has_tool_calls) {
|
|
throw std::invalid_argument(
|
|
"Expected 'content' or 'tool_calls' (ref: https://github.com/ggml-org/llama.cpp/issues/8367 & "
|
|
"https://github.com/ggml-org/llama.cpp/issues/12279)");
|
|
}
|
|
if (message.contains("reasoning_content")) {
|
|
msg.reasoning_content = message.at("reasoning_content");
|
|
}
|
|
if (message.contains("name")) {
|
|
msg.tool_name = message.at("name");
|
|
}
|
|
if (message.contains("tool_call_id")) {
|
|
msg.tool_call_id = message.at("tool_call_id");
|
|
}
|
|
|
|
msgs.push_back(msg);
|
|
}
|
|
} catch (const std::exception & e) {
|
|
// @ngxson : disable otherwise it's bloating the API response
|
|
// printf("%s\n", std::string("; messages = ") + messages.dump(2));
|
|
throw std::runtime_error("Failed to parse messages: " + std::string(e.what()));
|
|
}
|
|
|
|
return msgs;
|
|
}
|
|
|
|
struct messages_inp_normalizer {
|
|
const jinja::caps & caps;
|
|
|
|
messages_inp_normalizer(const jinja::caps & c) : caps(c) {}
|
|
|
|
// handle supports_string_content / supports_typed_content
|
|
// if string=true and array=false, convert array to string
|
|
// if string=false and array=true, convert string to array
|
|
// if both are true, do nothing
|
|
json normalize(const json & messages) {
|
|
bool only_string = caps.supports_string_content && !caps.supports_typed_content;
|
|
bool only_typed = !caps.supports_string_content && caps.supports_typed_content;
|
|
if ((!only_string && !only_typed) || !messages.is_array()) {
|
|
return messages;
|
|
}
|
|
json normalized = json::array();
|
|
for (const auto & msg : messages) {
|
|
json copy = msg;
|
|
if (copy.contains("content")) {
|
|
json & it = copy.at("content");
|
|
if (only_typed && it.is_string()) {
|
|
it = json::array({
|
|
json{
|
|
{"type", "text"},
|
|
{"text", it.get<std::string>()},
|
|
}
|
|
});
|
|
} else if (only_string && it.is_array()) {
|
|
it = concat_content_parts(it);
|
|
}
|
|
}
|
|
normalized.push_back(std::move(copy));
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
// join parts with newline, do not add newline before or after media markers
|
|
static std::string concat_content_parts(const json & parts) {
|
|
std::string text;
|
|
bool last_was_media_marker = false;
|
|
for (const auto & part : parts) {
|
|
std::string type = part.value("type", "");
|
|
bool add_new_line = true;
|
|
if (type == "text") {
|
|
add_new_line = !last_was_media_marker && !text.empty();
|
|
last_was_media_marker = false;
|
|
} else if (type == "media_marker") {
|
|
add_new_line = false;
|
|
last_was_media_marker = true;
|
|
} else {
|
|
LOG_WRN("Ignoring content part type: %s\n", type.c_str());
|
|
continue;
|
|
}
|
|
|
|
if (add_new_line) {
|
|
text += '\n';
|
|
}
|
|
|
|
text += part.value("text", "");
|
|
}
|
|
return text;
|
|
}
|
|
};
|
|
|
|
static json render_message_to_json(const std::vector<common_chat_msg> & msgs, const jinja::caps & c) {
|
|
if (!c.supports_string_content && !c.supports_typed_content) {
|
|
LOG_WRN("%s: Neither string content nor typed content is supported by the template. This is unexpected and may lead to issues.\n", __func__);
|
|
}
|
|
|
|
json messages = json::array();
|
|
for (const auto & msg : msgs) {
|
|
messages.push_back(msg.to_json_oaicompat(/* concat_typed_text= */ false));
|
|
}
|
|
return messages_inp_normalizer(c).normalize(messages);
|
|
}
|
|
|
|
// DEPRECATED: only used in tests
|
|
json common_chat_msgs_to_json_oaicompat(const std::vector<common_chat_msg> & msgs, bool concat_typed_text) {
|
|
jinja::caps c;
|
|
c.supports_string_content = true;
|
|
c.supports_typed_content = !concat_typed_text;
|
|
return render_message_to_json(msgs, c);
|
|
}
|
|
|
|
json common_chat_tools_to_json_oaicompat(const std::vector<common_chat_tool> & tools) {
|
|
if (tools.empty()) {
|
|
return json();
|
|
}
|
|
|
|
auto result = json::array();
|
|
for (const auto & tool : tools) {
|
|
result.push_back({
|
|
{ "type", "function" },
|
|
{ "function", {
|
|
{ "name", tool.name },
|
|
{ "description", tool.description },
|
|
{ "parameters", json::parse(tool.parameters) },
|
|
}},
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::vector<common_chat_tool> common_chat_tools_parse_oaicompat(const json & tools) {
|
|
std::vector<common_chat_tool> result;
|
|
|
|
try {
|
|
if (!tools.is_null()) {
|
|
if (!tools.is_array()) {
|
|
throw std::invalid_argument("Expected 'tools' to be an array, got " + tools.dump());
|
|
}
|
|
for (const auto & tool : tools) {
|
|
if (!tool.contains("type")) {
|
|
throw std::invalid_argument("Missing tool type: " + tool.dump());
|
|
}
|
|
const auto & type = tool.at("type");
|
|
if (!type.is_string() || type != "function") {
|
|
throw std::invalid_argument("Unsupported tool type: " + tool.dump());
|
|
}
|
|
if (!tool.contains("function")) {
|
|
throw std::invalid_argument("Missing tool function: " + tool.dump());
|
|
}
|
|
|
|
const auto & function = tool.at("function");
|
|
result.push_back({
|
|
/* .name = */ function.at("name"),
|
|
/* .description = */ function.value("description", ""),
|
|
/* .parameters = */ function.value("parameters", json::object()).dump(),
|
|
});
|
|
}
|
|
}
|
|
} catch (const std::exception & e) {
|
|
throw std::runtime_error("Failed to parse tools: " + std::string(e.what()) + "; tools = " + tools.dump(2));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
common_chat_continuation common_chat_continuation_parse(const common_json & value) {
|
|
if (value.is_boolean() && value.get<bool>()) {
|
|
return COMMON_CHAT_CONTINUATION_AUTO;
|
|
}
|
|
if (value.is_string()) {
|
|
auto value_str = value.get<std::string>();
|
|
if (value_str == "reasoning_content") {
|
|
return COMMON_CHAT_CONTINUATION_REASONING;
|
|
}
|
|
if (value_str == "content") {
|
|
return COMMON_CHAT_CONTINUATION_CONTENT;
|
|
}
|
|
}
|
|
return COMMON_CHAT_CONTINUATION_NONE;
|
|
}
|
|
|
|
bool common_chat_verify_template(const std::string & tmpl, bool use_jinja) {
|
|
if (use_jinja) {
|
|
try {
|
|
common_chat_msg msg;
|
|
msg.role = "user";
|
|
msg.content = "test";
|
|
|
|
auto tmpls = common_chat_templates_init(/* model= */ nullptr, tmpl);
|
|
|
|
common_chat_templates_inputs inputs;
|
|
inputs.messages = { msg };
|
|
|
|
common_chat_templates_apply(tmpls.get(), inputs);
|
|
return true;
|
|
} catch (const std::exception & e) {
|
|
LOG_ERR("%s: failed to apply template: %s\n", __func__, e.what());
|
|
return false;
|
|
}
|
|
}
|
|
llama_chat_message chat[] = {
|
|
{ "user", "test" }
|
|
};
|
|
const int res = llama_chat_apply_template(tmpl.c_str(), chat, 1, true, nullptr, 0);
|
|
return res >= 0;
|
|
}
|
|
|
|
std::string common_chat_format_single(const struct common_chat_templates * tmpls,
|
|
const std::vector<common_chat_msg> & past_msg,
|
|
const common_chat_msg & new_msg,
|
|
bool add_ass,
|
|
bool use_jinja) {
|
|
common_chat_templates_inputs inputs;
|
|
inputs.use_jinja = use_jinja;
|
|
inputs.add_bos = tmpls->add_bos;
|
|
inputs.add_eos = tmpls->add_eos;
|
|
|
|
std::string fmt_past_msg;
|
|
if (!past_msg.empty()) {
|
|
inputs.messages = past_msg;
|
|
inputs.add_generation_prompt = false;
|
|
fmt_past_msg = common_chat_templates_apply(tmpls, inputs).prompt;
|
|
}
|
|
std::ostringstream ss;
|
|
// if the past_msg ends with a newline, we must preserve it in the formatted version
|
|
if (add_ass && !fmt_past_msg.empty() && fmt_past_msg.back() == '\n') {
|
|
ss << "\n";
|
|
};
|
|
// format chat with new_msg
|
|
inputs.messages.push_back(new_msg);
|
|
inputs.add_generation_prompt = add_ass;
|
|
auto fmt_new_msg = common_chat_templates_apply(tmpls, inputs).prompt;
|
|
// get the diff part
|
|
ss << fmt_new_msg.substr(fmt_past_msg.size(), fmt_new_msg.size() - fmt_past_msg.size());
|
|
return ss.str();
|
|
}
|
|
|
|
std::string common_chat_format_example(const struct common_chat_templates * tmpls,
|
|
bool use_jinja,
|
|
const std::map<std::string, std::string> & chat_template_kwargs) {
|
|
common_chat_templates_inputs inputs;
|
|
inputs.use_jinja = use_jinja;
|
|
inputs.add_bos = tmpls->add_bos;
|
|
inputs.add_eos = tmpls->add_eos;
|
|
inputs.chat_template_kwargs = chat_template_kwargs;
|
|
auto add_simple_msg = [&](auto role, auto content) {
|
|
common_chat_msg msg;
|
|
msg.role = role;
|
|
msg.content = content;
|
|
inputs.messages.push_back(msg);
|
|
};
|
|
add_simple_msg("system", "You are a helpful assistant");
|
|
add_simple_msg("user", "Hello");
|
|
add_simple_msg("assistant", "Hi there");
|
|
add_simple_msg("user", "How are you?");
|
|
return common_chat_templates_apply(tmpls, inputs).prompt;
|
|
}
|
|
|
|
#define CHATML_TEMPLATE_SRC \
|
|
"{%- for message in messages -%}\n" \
|
|
" {{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>\n' -}}\n" \
|
|
"{%- endfor -%}\n" \
|
|
"{%- if add_generation_prompt -%}\n" \
|
|
" {{- '<|im_start|>assistant\n' -}}\n" \
|
|
"{%- endif -%}"
|
|
|
|
void common_chat_templates_free(struct common_chat_templates * tmpls) {
|
|
delete tmpls;
|
|
}
|
|
|
|
bool common_chat_templates_was_explicit(const struct common_chat_templates * tmpls) {
|
|
return tmpls->has_explicit_template;
|
|
}
|
|
|
|
common_chat_prompt_preset common_chat_get_asr_prompt(const common_chat_templates * chat_templates) {
|
|
common_chat_prompt_preset asr_preset;
|
|
asr_preset.system = "";
|
|
asr_preset.user = "Transcribe audio to text";
|
|
|
|
if (chat_templates && chat_templates->template_default && is_lfm2_template(chat_templates->template_default->source())) {
|
|
asr_preset.system = "Perform ASR.";
|
|
asr_preset.user = "";
|
|
}
|
|
|
|
return asr_preset;
|
|
}
|
|
|
|
std::string common_chat_templates_source(const struct common_chat_templates * tmpls, const std::string & variant) {
|
|
if (!variant.empty()) {
|
|
if (variant == "tool_use") {
|
|
if (tmpls->template_tool_use) {
|
|
return tmpls->template_tool_use->source();
|
|
}
|
|
return "";
|
|
}
|
|
LOG_DBG("%s: unknown template variant: %s\n", __func__, variant.c_str());
|
|
}
|
|
return tmpls->template_default->source();
|
|
}
|
|
|
|
common_chat_templates_ptr common_chat_templates_init(const struct llama_model * model,
|
|
const std::string & chat_template_override,
|
|
const std::string & bos_token_override,
|
|
const std::string & eos_token_override) {
|
|
std::string default_template_src;
|
|
std::string template_tool_use_src;
|
|
|
|
bool has_explicit_template = !chat_template_override.empty();
|
|
if (chat_template_override.empty()) {
|
|
GGML_ASSERT(model != nullptr);
|
|
const auto * str = llama_model_chat_template(model, /* name */ nullptr);
|
|
if (str) {
|
|
default_template_src = str;
|
|
has_explicit_template = true;
|
|
}
|
|
str = llama_model_chat_template(model, /* name */ "tool_use");
|
|
if (str) {
|
|
template_tool_use_src = str;
|
|
has_explicit_template = true;
|
|
}
|
|
} else {
|
|
default_template_src = chat_template_override;
|
|
}
|
|
if (default_template_src.empty() || default_template_src == "chatml") {
|
|
if (!template_tool_use_src.empty()) {
|
|
default_template_src = template_tool_use_src;
|
|
} else {
|
|
default_template_src = CHATML_TEMPLATE_SRC;
|
|
}
|
|
}
|
|
|
|
// TODO @ngxson : this is a temporary hack to prevent chat template from throwing an error
|
|
// Ref: https://github.com/ggml-org/llama.cpp/pull/15230#issuecomment-3173959633
|
|
if (default_template_src.find("<|channel|>") != std::string::npos
|
|
// search for the error message and patch it
|
|
&& default_template_src.find("in message.content or") != std::string::npos) {
|
|
string_replace_all(default_template_src,
|
|
"{%- if \"<|channel|>analysis<|message|>\" in message.content or "
|
|
"\"<|channel|>final<|message|>\" in message.content %}",
|
|
"{%- if false %}");
|
|
}
|
|
|
|
// TODO @aldehir : this is a temporary fix, pending Minja changes
|
|
// Ref: https://github.com/ggml-org/llama.cpp/pull/17713#issuecomment-3631342664
|
|
if (default_template_src.find("[TOOL_CALLS]") != std::string::npos
|
|
// search for the error message and patch it
|
|
&& default_template_src.find("if (message['content'] is none or") != std::string::npos) {
|
|
string_replace_all(default_template_src,
|
|
"{%- if (message['content'] is none or message['content'] == '' or "
|
|
"message['content']|length == 0) and (message['tool_calls'] is not defined or "
|
|
"message['tool_calls'] is none or message['tool_calls']|length == 0) %}",
|
|
"{%- if false %}");
|
|
}
|
|
|
|
std::string token_bos = bos_token_override;
|
|
std::string token_eos = eos_token_override;
|
|
bool add_bos = false;
|
|
bool add_eos = false;
|
|
if (model) {
|
|
const auto * vocab = llama_model_get_vocab(model);
|
|
const auto get_token = [&](llama_token token, const char * name, const char * jinja_variable_name) {
|
|
if (token == LLAMA_TOKEN_NULL) {
|
|
if (default_template_src.find(jinja_variable_name) != std::string::npos ||
|
|
template_tool_use_src.find(jinja_variable_name) != std::string::npos) {
|
|
LOG_WRN(
|
|
"common_chat_templates_init: warning: vocab does not have a %s token, jinja template won't "
|
|
"work as intended.\n",
|
|
name);
|
|
}
|
|
return std::string();
|
|
}
|
|
return common_token_to_piece(vocab, token, true);
|
|
};
|
|
token_bos = get_token(llama_vocab_bos(vocab), "BOS", "bos_token");
|
|
token_eos = get_token(llama_vocab_eos(vocab), "EOS", "eos_token");
|
|
add_bos = llama_vocab_get_add_bos(vocab);
|
|
add_eos = llama_vocab_get_add_eos(vocab);
|
|
}
|
|
common_chat_templates_ptr tmpls(new common_chat_templates());
|
|
tmpls->has_explicit_template = has_explicit_template;
|
|
tmpls->add_bos = add_bos;
|
|
tmpls->add_eos = add_eos;
|
|
try {
|
|
tmpls->template_default = std::make_unique<common_chat_template>(default_template_src, token_bos, token_eos);
|
|
} catch (const std::exception & e) {
|
|
LOG_ERR("%s: error: %s\n", __func__, e.what());
|
|
LOG_ERR("%s: failed to initialize chat template\n", __func__);
|
|
LOG_ERR("%s: please consider disabling jinja via --no-jinja, or using another chat template\n", __func__);
|
|
throw e;
|
|
}
|
|
if (!template_tool_use_src.empty()) {
|
|
try {
|
|
tmpls->template_tool_use = std::make_unique<common_chat_template>(template_tool_use_src, token_bos, token_eos);
|
|
} catch (const std::exception & e) {
|
|
LOG_ERR("%s: failed to parse tool use chat template (ignoring it): %s\n", __func__, e.what());
|
|
}
|
|
}
|
|
return tmpls;
|
|
}
|
|
|
|
const char * common_chat_format_name(common_chat_format format) {
|
|
switch (format) {
|
|
case COMMON_CHAT_FORMAT_CONTENT_ONLY:
|
|
return "Content-only";
|
|
case COMMON_CHAT_FORMAT_PEG_SIMPLE:
|
|
return "peg-simple";
|
|
case COMMON_CHAT_FORMAT_PEG_NATIVE:
|
|
return "peg-native";
|
|
case COMMON_CHAT_FORMAT_PEG_GEMMA4:
|
|
return "peg-gemma4";
|
|
case COMMON_CHAT_FORMAT_PEG_MINIMAX_M3:
|
|
return "peg-minimax-m3";
|
|
default:
|
|
throw std::runtime_error("Unknown chat format");
|
|
}
|
|
}
|
|
|
|
const char * common_reasoning_format_name(common_reasoning_format format) {
|
|
switch (format) {
|
|
case COMMON_REASONING_FORMAT_NONE:
|
|
return "none";
|
|
case COMMON_REASONING_FORMAT_AUTO:
|
|
return "auto";
|
|
case COMMON_REASONING_FORMAT_DEEPSEEK:
|
|
return "deepseek";
|
|
case COMMON_REASONING_FORMAT_DEEPSEEK_LEGACY:
|
|
return "deepseek-legacy";
|
|
default:
|
|
throw std::runtime_error("Unknown reasoning format");
|
|
}
|
|
}
|
|
|
|
common_reasoning_format common_reasoning_format_from_name(const std::string & format) {
|
|
if (format == "none") {
|
|
return COMMON_REASONING_FORMAT_NONE;
|
|
}
|
|
if (format == "auto") {
|
|
return COMMON_REASONING_FORMAT_AUTO;
|
|
}
|
|
if (format == "deepseek") {
|
|
return COMMON_REASONING_FORMAT_DEEPSEEK;
|
|
}
|
|
if (format == "deepseek-legacy") {
|
|
return COMMON_REASONING_FORMAT_DEEPSEEK_LEGACY;
|
|
}
|
|
throw std::runtime_error("Unknown reasoning format: " + format);
|
|
}
|
|
|
|
std::string common_chat_template_direct_apply_impl(
|
|
const common_chat_template & tmpl,
|
|
const autoparser::generation_params & inputs,
|
|
const std::optional<json> & messages_override,
|
|
const std::optional<json> & tools_override,
|
|
const std::optional<json> & additional_context) {
|
|
jinja::context ctx(tmpl.source());
|
|
|
|
// messages_override is already built for this template, do not touch its content parts
|
|
json inp = json{
|
|
{"messages", messages_override.has_value()
|
|
? *messages_override
|
|
: messages_inp_normalizer(tmpl.original_caps()).normalize(inputs.messages)},
|
|
{"bos_token", tmpl.bos_token()},
|
|
{"eos_token", tmpl.eos_token()},
|
|
{"enable_thinking", inputs.enable_thinking},
|
|
};
|
|
if (tools_override.has_value() || !inputs.tools.empty()) {
|
|
inp["tools"] = tools_override.has_value() ? *tools_override : inputs.tools;
|
|
}
|
|
if (inputs.extra_context.is_object()) {
|
|
// TODO: do we need to merge, or replacing is fine?
|
|
for (const auto & [k, v] : inputs.extra_context.items()) {
|
|
inp[k] = v;
|
|
}
|
|
}
|
|
if (additional_context.has_value()) {
|
|
// TODO: merge properly instead of overwriting (matching old behavior)
|
|
for (const auto & [k, v] : additional_context->items()) {
|
|
inp[k] = v;
|
|
}
|
|
}
|
|
if (inputs.add_generation_prompt) {
|
|
inp["add_generation_prompt"] = true;
|
|
}
|
|
if (inp.contains("preserve_reasoning") && inp["preserve_reasoning"].is_boolean()) {
|
|
bool enabled = inp["preserve_reasoning"].get<bool>();
|
|
jinja::caps_apply_preserve_reasoning(ctx, enabled);
|
|
}
|
|
if (inp.contains("reasoning_effort") && inp["reasoning_effort"].is_string() && !inp["reasoning_effort"].empty()) {
|
|
std::string reasoning_effort = inp["reasoning_effort"].get<std::string>();
|
|
jinja::caps_apply_reasoning_effort(ctx, reasoning_effort);
|
|
}
|
|
|
|
jinja::global_from_json(ctx, inp, inputs.mark_input);
|
|
|
|
// render
|
|
jinja::runtime runtime(ctx);
|
|
const jinja::value results = runtime.execute(tmpl.prog);
|
|
auto parts = jinja::runtime::gather_string_parts(results);
|
|
|
|
std::string result = parts->as_string().str();
|
|
|
|
// TODO: improve this later
|
|
if (inputs.add_bos && string_starts_with(result, tmpl.bos_token())) {
|
|
result = result.substr(tmpl.bos_token().size());
|
|
}
|
|
if (inputs.add_eos && string_ends_with(result, tmpl.eos_token())) {
|
|
result = result.substr(0, result.size() - tmpl.eos_token().size());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::string common_chat_template_direct_apply(
|
|
const common_chat_template & tmpl,
|
|
const autoparser::generation_params & inputs) {
|
|
return common_chat_template_direct_apply_impl(tmpl, inputs, std::nullopt, std::nullopt, std::nullopt);
|
|
}
|
|
|
|
std::string common_chat_template_generation_prompt_impl(
|
|
const common_chat_template & tmpl,
|
|
const autoparser::generation_params & inputs,
|
|
const std::optional<json> & messages_override,
|
|
const std::optional<json> & tools_override,
|
|
const std::optional<json> & additional_context) {
|
|
|
|
autoparser::generation_params params = inputs;
|
|
params.add_generation_prompt = false;
|
|
params.continue_final_message = COMMON_CHAT_CONTINUATION_NONE;
|
|
std::string no_gen_prompt = common_chat_template_direct_apply_impl(tmpl, params, messages_override, tools_override, additional_context);
|
|
params.add_generation_prompt = true;
|
|
std::string gen_prompt = common_chat_template_direct_apply_impl(tmpl, params, messages_override, tools_override, additional_context);
|
|
|
|
size_t prefix_len = 0;
|
|
size_t min_size = std::min(no_gen_prompt.size(), gen_prompt.size());
|
|
while (prefix_len < min_size && no_gen_prompt[prefix_len] == gen_prompt[prefix_len]) {
|
|
prefix_len++;
|
|
}
|
|
return gen_prompt.substr(prefix_len);
|
|
}
|
|
|
|
std::string common_chat_template_generation_prompt(
|
|
const common_chat_template & tmpl,
|
|
const autoparser::generation_params & inputs) {
|
|
return common_chat_template_generation_prompt_impl(tmpl, inputs, std::nullopt, std::nullopt, std::nullopt);
|
|
}
|
|
|
|
namespace workaround {
|
|
|
|
static void map_developer_role_to_system(json & messages) {
|
|
for (auto & message : messages) {
|
|
if (message.contains("role")) {
|
|
if (message["role"] == "developer") {
|
|
message["role"] = "system";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// if first message is system and template does not support it, merge it with next message
|
|
static void system_message_not_supported(json & messages) {
|
|
if (!messages.empty() && messages.front().at("role") == "system") {
|
|
if (messages.size() > 1) {
|
|
LOG_DBG("Merging system prompt into next message\n");
|
|
auto & first_msg = messages.front();
|
|
auto & second_msg = messages[1];
|
|
second_msg["content"] = first_msg.at("content").get<std::string>()
|
|
+ "\n" + second_msg.at("content").get<std::string>();
|
|
messages.erase(0);
|
|
} else {
|
|
LOG_WRN("Removing system prompt due to template not supporting system role\n");
|
|
messages.erase(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
static void requires_non_null_content(json & messages) {
|
|
GGML_ASSERT(messages.is_array());
|
|
for (auto & message : messages) {
|
|
if (message.contains("tool_calls") && !message.contains("content")) {
|
|
message["content"] = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
static void func_args_not_string(json & messages) {
|
|
GGML_ASSERT(messages.is_array());
|
|
for (auto & message : messages) {
|
|
if (message.contains("tool_calls")) {
|
|
for (auto & tool_call : message["tool_calls"]) {
|
|
if (tool_call.contains("function") && tool_call["function"].contains("arguments")) {
|
|
auto & args = tool_call["function"]["arguments"];
|
|
if (args.is_string()) {
|
|
try {
|
|
args = json::parse(args.get<std::string>());
|
|
} catch (const std::exception & e) {
|
|
throw std::runtime_error("Failed to parse tool call arguments as JSON: " + std::string(e.what()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trim leading/trailing whitespace from message contents before rendering. This
|
|
// has to run on the messages (not on the rendered JSON) because templates with
|
|
// string-only content caps concatenate typed content parts into a single string
|
|
// during rendering, after which the per-part whitespace can no longer be reached.
|
|
// Both the plain string content and the text of typed content parts are trimmed.
|
|
static void trim_all_content(std::vector<common_chat_msg> & messages) {
|
|
for (auto & message : messages) {
|
|
message.content = trim_whitespace(message.content);
|
|
message.reasoning_content = trim_whitespace(message.reasoning_content);
|
|
for (auto & part : message.content_parts) {
|
|
if (part.type == "text") {
|
|
part.text = trim_whitespace(part.text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
static json common_chat_extra_context() {
|
|
json ctx = json::object();
|
|
std::chrono::system_clock::time_point now = std::chrono::system_clock::now();
|
|
std::string datetime_str = format_time(now, "%b %d %Y");
|
|
std::string date_str = format_time(now, "%d %b %Y");
|
|
ctx["datetime"] = datetime_str;
|
|
ctx["date_string"] = date_str;
|
|
return ctx;
|
|
}
|
|
|
|
std::optional<common_chat_params> common_chat_try_specialized_template(
|
|
const common_chat_template & tmpl,
|
|
const std::string & src,
|
|
autoparser::generation_params & params) {
|
|
// Ministral/Mistral Large 3 - uses special reasoning structure fixes, can't use autoparser
|
|
// Note: Mistral Small 3.2 uses [CALL_ID] which Ministral doesn't have, so we can distinguish them
|
|
if (src.find("[SYSTEM_PROMPT]") != std::string::npos && src.find("[TOOL_CALLS]") != std::string::npos &&
|
|
src.find("[ARGS]") != std::string::npos && src.find("[CALL_ID]") == std::string::npos) {
|
|
LOG_DBG("Using specialized template: Ministral/Magistral Large 3\n");
|
|
return common_chat_params_init_ministral_3(tmpl, params);
|
|
}
|
|
|
|
// GPT-OSS - has unique channel-based structure that needs dedicated handler
|
|
if (src.find("<|channel|>") != std::string::npos) {
|
|
LOG_DBG("Using specialized template: GPT-OSS\n");
|
|
return common_chat_params_init_gpt_oss(tmpl, params);
|
|
}
|
|
|
|
// Muse Glimmer format using " to=<recipient>" recipients and <|eom|>/<|eot|> message terminators.
|
|
if (src.find("<atem:function_calls>") != std::string::npos && src.find("<|eom|>") != std::string::npos) {
|
|
LOG_DBG("Using specialized template: Muse Glimmer\n");
|
|
return common_chat_params_init_muse_glimmer(tmpl, params);
|
|
}
|
|
|
|
// Functionary v3.2 - uses recipient-based format with >>>recipient\n{content}
|
|
// Detection: template has ">>>all" for content and ">>>" prefix for tool calls
|
|
if (src.find(">>>all") != std::string::npos && src.find(">>>${recipient}") != std::string::npos) {
|
|
LOG_DBG("Using specialized template: Functionary v3.2\n");
|
|
return common_chat_params_init_functionary_v3_2(tmpl, params);
|
|
}
|
|
|
|
// Kimi K2 Thinking - uses unique tool call ID format: functions.<name>:<index>
|
|
// Detection: template has "<|tool_calls_section_begin|>" and "functions." prefix in tool call IDs
|
|
if (src.find("<|tool_calls_section_begin|>") != std::string::npos &&
|
|
src.find("<|tool_call_begin|>") != std::string::npos) {
|
|
LOG_DBG("Using specialized template: Kimi K2 Thinking\n");
|
|
return common_chat_params_init_kimi_k2(tmpl, params);
|
|
}
|
|
|
|
// Kimi K3 - the <|open|>/<|close|>/<|end_of_msg|> markers are unique to it
|
|
if (src.find("<|open|>") != std::string::npos && src.find("<|close|>") != std::string::npos &&
|
|
src.find("<|end_of_msg|>") != std::string::npos) {
|
|
LOG_DBG("Using specialized template: Kimi K3\n");
|
|
return common_chat_params_init_kimi_k3(tmpl, params);
|
|
}
|
|
|
|
// Cohere2 MoE / North Code - marker-wrapped format with <|START_TEXT|> content and
|
|
// <|START_ACTION|> JSON tool calls. <|START_TEXT|> is unique to this template (the older
|
|
// Command-R templates use <|START_RESPONSE|>).
|
|
if (src.find("<|START_TEXT|>") != std::string::npos &&
|
|
src.find("<|START_ACTION|>") != std::string::npos) {
|
|
LOG_DBG("Using specialized template: Cohere2 MoE\n");
|
|
return common_chat_params_init_cohere2moe(tmpl, params);
|
|
}
|
|
|
|
if (is_lfm2_template(src)) {
|
|
LOG_DBG("Using specialized template: LFM2\n");
|
|
return common_chat_params_init_lfm2(tmpl, params, /* tool_list_tokens = */ true);
|
|
}
|
|
|
|
// LFM2.5 format detection: template uses plain "List of tools: [...]" with no special tokens
|
|
if (src.find("List of tools: [") != std::string::npos &&
|
|
src.find("<|tool_list_start|>") == std::string::npos) {
|
|
LOG_DBG("Using specialized template: LFM2.5\n");
|
|
return common_chat_params_init_lfm2(tmpl, params, /* tool_list_tokens = */ false);
|
|
}
|
|
|
|
// GigaChatV3 format detection
|
|
if (src.find("<|role_sep|>") != std::string::npos &&
|
|
src.find("<|message_sep|>") != std::string::npos &&
|
|
src.find("<|function_call|>") == std::string::npos) {
|
|
LOG_DBG("Using specialized template: GigaChatV3\n");
|
|
return common_chat_params_init_gigachat_v3(tmpl, params);
|
|
}
|
|
|
|
// MiniMax-M3: the namespace token "]<]minimax[>[" collides with the autoparser's
|
|
// markup delimiters, so detect the template and use a dedicated parser.
|
|
if (src.find("]<]minimax[>[") != std::string::npos &&
|
|
src.find("<tool_call>") != std::string::npos &&
|
|
src.find("<invoke name=") != std::string::npos) {
|
|
LOG_DBG("Using specialized template: MiniMax-M3\n");
|
|
return common_chat_params_init_minimax_m3(tmpl, params);
|
|
}
|
|
|
|
// DeepSeek V3.2/V4 format detection: template defines dsml_token and uses it for tool calls.
|
|
// The template source contains the token as a variable assignment, not as a literal in markup.
|
|
// V3.2 names the tool call block "function_calls", V4 names it "tool_calls".
|
|
if (src.find("dsml_token") != std::string::npos &&
|
|
src.find("DSML") != std::string::npos &&
|
|
(src.find("function_calls") != std::string::npos ||
|
|
src.find("tool_calls") != std::string::npos)) {
|
|
LOG_DBG("Using specialized template: DeepSeek V3.2/V4\n");
|
|
return common_chat_params_init_deepseek_v3_2(tmpl, params);
|
|
}
|
|
|
|
// Gemma4 format detection
|
|
if (src.find("'<|tool_call>call:'") != std::string::npos) {
|
|
if (src.find("{#- OpenAI Chat Completions:") == std::string::npos) {
|
|
// apply workarounds if using the older gemma4 templates
|
|
LOG_WRN("%s: detected an outdated gemma4 chat template, applying compatibility workarounds. "
|
|
"Consider updating to the official template.\n", __func__);
|
|
workaround::convert_tool_responses_gemma4(params.messages);
|
|
}
|
|
return common_chat_params_init_gemma4(tmpl, params);
|
|
}
|
|
|
|
// MiniCPM5 - XML tool calls with <function name="..."><param name="...">...</param></function>
|
|
if (src.find("Tool usage guidelines:") != std::string::npos &&
|
|
src.find("<function name=\"") != std::string::npos &&
|
|
src.find("<param name=\"") != std::string::npos) {
|
|
LOG_DBG("Using specialized template: MiniCPM5\n");
|
|
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;
|
|
}
|
|
|
|
static common_chat_params common_chat_templates_apply_jinja(const struct common_chat_templates * tmpls,
|
|
const struct common_chat_templates_inputs & inputs) {
|
|
autoparser::generation_params params;
|
|
params.tools = common_chat_tools_to_json_oaicompat(inputs.tools);
|
|
const auto & tmpl =
|
|
params.tools.is_array() && tmpls->template_tool_use ? *tmpls->template_tool_use : *tmpls->template_default;
|
|
const auto & src = tmpl.source();
|
|
const auto & caps = tmpl.original_caps();
|
|
std::vector<common_chat_msg> trimmed_messages;
|
|
const std::vector<common_chat_msg> * messages_to_render = &inputs.messages;
|
|
if (src.find("You have access to the following functions in JSONSchema format") != std::string::npos) {
|
|
// StepFun: trim message contents (including typed content parts) before rendering,
|
|
// otherwise leftover whitespace drives the model into reasoning loops (issue #24181)
|
|
trimmed_messages = inputs.messages;
|
|
workaround::trim_all_content(trimmed_messages);
|
|
messages_to_render = &trimmed_messages;
|
|
}
|
|
params.messages = render_message_to_json(*messages_to_render, tmpl.original_caps());
|
|
params.tool_choice = inputs.tool_choice;
|
|
params.reasoning_format = inputs.reasoning_format;
|
|
params.enable_thinking = inputs.enable_thinking;
|
|
params.grammar = inputs.grammar;
|
|
params.now = inputs.now;
|
|
params.add_generation_prompt = inputs.add_generation_prompt;
|
|
params.add_bos = tmpls->add_bos;
|
|
params.add_eos = tmpls->add_eos;
|
|
|
|
params.continue_final_message = inputs.continue_final_message;
|
|
if (params.continue_final_message != COMMON_CHAT_CONTINUATION_NONE) {
|
|
params.add_generation_prompt = false;
|
|
|
|
if (!inputs.messages.empty()) {
|
|
// Render messages[:-1] and store continuation message separately
|
|
params.continue_msg = inputs.messages.back();
|
|
params.messages.erase(params.messages.size() - 1);
|
|
}
|
|
|
|
if (params.continue_final_message == COMMON_CHAT_CONTINUATION_AUTO && !inputs.messages.empty()) {
|
|
// Resolve based on message content
|
|
params.continue_final_message = COMMON_CHAT_CONTINUATION_CONTENT;
|
|
if (!params.continue_msg.reasoning_content.empty() &&
|
|
params.continue_msg.content.empty() &&
|
|
params.continue_msg.content_parts.empty()) {
|
|
params.continue_final_message = COMMON_CHAT_CONTINUATION_REASONING;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (src.find("<|channel|>") == std::string::npos) {
|
|
// map developer to system for all models except for GPT-OSS
|
|
workaround::map_developer_role_to_system(params.messages);
|
|
}
|
|
|
|
if (!tmpl.original_caps().supports_system_role) {
|
|
workaround::system_message_not_supported(params.messages);
|
|
}
|
|
|
|
if (tmpl.original_caps().supports_tool_calls) {
|
|
// some templates will require the content field in tool call messages
|
|
// to still be non-null, this puts an empty string everywhere where the
|
|
// content field is null
|
|
workaround::requires_non_null_content(params.messages);
|
|
}
|
|
|
|
if (tmpl.original_caps().supports_object_arguments) {
|
|
workaround::func_args_not_string(params.messages);
|
|
}
|
|
|
|
params.extra_context = common_chat_extra_context();
|
|
for (auto el : inputs.chat_template_kwargs) {
|
|
params.extra_context[el.first] = json::parse(el.second);
|
|
}
|
|
|
|
if (!inputs.json_schema.empty()) {
|
|
params.json_schema = json::parse(inputs.json_schema);
|
|
}
|
|
|
|
params.parallel_tool_calls = inputs.parallel_tool_calls;
|
|
|
|
if (params.tools.is_array()) {
|
|
if (params.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE && !params.grammar.empty()) {
|
|
throw std::runtime_error("Cannot specify grammar with tools");
|
|
}
|
|
if (caps.supports_tool_calls && !caps.supports_tools) {
|
|
LOG_WRN(
|
|
"Template supports tool calls but does not natively describe tools. The fallback behaviour used may "
|
|
"produce bad results, inspect prompt w/ --verbose & consider overriding the template.\n");
|
|
}
|
|
}
|
|
|
|
if (inputs.force_pure_content) {
|
|
LOG_WRN("Forcing pure content template, will not render reasoning or tools separately.");
|
|
// Create the result structure
|
|
common_chat_params data;
|
|
auto params_copy = params;
|
|
params_copy.reasoning_format = COMMON_REASONING_FORMAT_NONE;
|
|
data.prompt = common_chat_template_direct_apply_impl(tmpl, params_copy);
|
|
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, params);
|
|
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
|
|
auto parser = build_chat_peg_parser([&data](common_chat_peg_builder &p) {
|
|
return p.literal(data.generation_prompt) << p.content(p.rest());
|
|
});
|
|
data.parser = parser.save();
|
|
return data;
|
|
}
|
|
|
|
if (auto result = common_chat_try_specialized_template(tmpl, src, params)) {
|
|
return *result;
|
|
}
|
|
|
|
try {
|
|
LOG_DBG("%s: using differential autoparser\n", __func__);
|
|
struct autoparser::autoparser autoparser;
|
|
autoparser.analyze_template(tmpl);
|
|
auto auto_params = autoparser::peg_generator::generate_parser(tmpl, params, autoparser);
|
|
|
|
common_chat_msg_delimiters delimiters;
|
|
if (!autoparser.assistant_start.empty()) {
|
|
delimiters.add(COMMON_CHAT_ROLE_ASSISTANT, autoparser.assistant_start);
|
|
}
|
|
if (!autoparser.user_start.empty()) {
|
|
delimiters.add(COMMON_CHAT_ROLE_USER, autoparser.user_start);
|
|
}
|
|
|
|
auto_params.message_delimiters = std::move(delimiters);
|
|
|
|
auto_params.supports_thinking = autoparser.reasoning.mode != autoparser::reasoning_mode::NONE;
|
|
if (auto_params.supports_thinking) {
|
|
auto_params.thinking_start_tag = trim_whitespace(autoparser.reasoning.start);
|
|
auto end_tag = trim_whitespace(autoparser.reasoning.end);
|
|
if (!end_tag.empty()) {
|
|
auto_params.thinking_end_tags = {std::move(end_tag)};
|
|
}
|
|
}
|
|
common_peg_arena arena;
|
|
arena.load(auto_params.parser);
|
|
LOG_DBG("%s: generated parser:\n%s\n\nparser generation prompt: %s\n", __func__, arena.dump(arena.root()).c_str(), auto_params.generation_prompt.c_str());
|
|
return auto_params;
|
|
} catch (const std::exception & e) {
|
|
throw std::invalid_argument(std::string("Unable to generate parser for this template. Automatic parser generation failed: ") + e.what());
|
|
}
|
|
}
|
|
|
|
// Legacy template route (adhoc C++ implementation of known templates), forward to llama_chat_apply_template.
|
|
static common_chat_params common_chat_templates_apply_legacy(const struct common_chat_templates * tmpls,
|
|
const struct common_chat_templates_inputs & inputs) {
|
|
size_t alloc_size = 0;
|
|
std::vector<llama_chat_message> chat;
|
|
std::vector<std::string> contents;
|
|
|
|
for (const auto & msg : inputs.messages) {
|
|
auto content = msg.content;
|
|
for (const auto & part : msg.content_parts) {
|
|
if (part.type != "text" && part.type != "media_marker") {
|
|
LOG_WRN("Ignoring non-text content part: %s\n", part.type.c_str());
|
|
continue;
|
|
}
|
|
if (!content.empty()) {
|
|
content += "\n";
|
|
;
|
|
}
|
|
content += part.text;
|
|
}
|
|
contents.emplace_back(std::move(content));
|
|
}
|
|
for (size_t i = 0; i < contents.size(); ++i) {
|
|
const auto & msg = inputs.messages[i];
|
|
const auto & content = contents[i];
|
|
chat.push_back({ msg.role.c_str(), content.c_str() });
|
|
size_t msg_size = msg.role.size() + content.size();
|
|
alloc_size += msg_size + (msg_size / 4); // == msg_size * 1.25 but avoiding float ops
|
|
}
|
|
|
|
std::vector<char> buf(alloc_size);
|
|
|
|
// run the first time to get the total output length
|
|
const auto & src = tmpls->template_default->source();
|
|
int32_t res = llama_chat_apply_template(src.c_str(), chat.data(), chat.size(), inputs.add_generation_prompt,
|
|
buf.data(), buf.size());
|
|
|
|
// error: chat template is not supported
|
|
if (res < 0) {
|
|
// if the custom "tmpl" is not supported, we throw an error
|
|
// this is a bit redundant (for good), since we're not sure if user validated the custom template with llama_chat_verify_template()
|
|
throw std::runtime_error("this custom template is not supported, try using --jinja");
|
|
}
|
|
|
|
// if it turns out that our buffer is too small, we resize it
|
|
if ((size_t) res > buf.size()) {
|
|
buf.resize(res);
|
|
res = llama_chat_apply_template(src.c_str(), chat.data(), chat.size(), inputs.add_generation_prompt, buf.data(),
|
|
buf.size());
|
|
}
|
|
|
|
// for safety, we check the result again
|
|
if (res < 0 || (size_t) res > buf.size()) {
|
|
throw std::runtime_error("failed to apply chat template, try using --jinja");
|
|
}
|
|
|
|
common_chat_params params;
|
|
params.prompt = std::string(buf.data(), res);
|
|
if (!inputs.json_schema.empty()) {
|
|
params.grammar = json_schema_to_grammar(json::parse(inputs.json_schema));
|
|
} else {
|
|
params.grammar = inputs.grammar;
|
|
}
|
|
return params;
|
|
}
|
|
|
|
common_chat_params common_chat_templates_apply(const struct common_chat_templates * tmpls,
|
|
const struct common_chat_templates_inputs & inputs) {
|
|
GGML_ASSERT(tmpls != nullptr);
|
|
return inputs.use_jinja ? common_chat_templates_apply_jinja(tmpls, inputs) :
|
|
common_chat_templates_apply_legacy(tmpls, inputs);
|
|
}
|
|
|
|
common_chat_msg common_chat_parse(const std::string & input,
|
|
bool is_partial,
|
|
const common_chat_parser_params & params) {
|
|
return common_chat_peg_parse(params.parser, input, is_partial, params);
|
|
}
|
|
|
|
common_chat_msg common_chat_peg_parse(const common_peg_arena & src_parser,
|
|
const std::string & input,
|
|
bool is_partial,
|
|
const common_chat_parser_params & params) {
|
|
const common_peg_arena & parser = src_parser.empty() ?
|
|
build_chat_peg_parser([](common_chat_peg_builder & p) { return p.content(p.rest()) + p.end(); }) :
|
|
src_parser;
|
|
|
|
if (src_parser.empty()) {
|
|
LOG_DBG("No parser definition detected, assuming pure content parser.");
|
|
}
|
|
|
|
const std::string effective_input = params.generation_prompt.empty()
|
|
? input
|
|
: params.generation_prompt + input;
|
|
|
|
//LOG_DBG("Parsing PEG input with format %s: %s\n", common_chat_format_name(params.format), effective_input.c_str());
|
|
|
|
common_peg_parse_flags flags = COMMON_PEG_PARSE_FLAG_LENIENT;
|
|
if (params.debug) {
|
|
flags |= COMMON_PEG_PARSE_FLAG_DEBUG;
|
|
}
|
|
|
|
common_peg_parse_context ctx(effective_input, flags);
|
|
auto result = parser.parse(ctx);
|
|
|
|
if (result.fail()) {
|
|
// During partial parsing, return partial results if any AST nodes were captured
|
|
// This allows streaming to work correctly for formats like FUNC_MARKDOWN_CODE_BLOCK
|
|
if (is_partial && result.end > 0) {
|
|
// Try to extract any partial results from what was successfully parsed
|
|
common_chat_msg msg;
|
|
msg.role = "assistant";
|
|
std::unique_ptr<common_chat_peg_mapper> mapper;
|
|
if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) {
|
|
mapper = std::make_unique<common_chat_peg_gemma4_mapper>(msg);
|
|
} else if (params.format == COMMON_CHAT_FORMAT_PEG_MINIMAX_M3) {
|
|
mapper = std::make_unique<common_chat_peg_minimax_m3_mapper>(msg);
|
|
} else {
|
|
mapper = std::make_unique<common_chat_peg_mapper>(msg);
|
|
}
|
|
mapper->from_ast(ctx.ast, result);
|
|
|
|
if (ctx.is_debug()) {
|
|
fprintf(stderr, "\nAST for partial parse (fail):\n%s\n", ctx.ast.dump().c_str());
|
|
fflush(stderr);
|
|
}
|
|
return msg;
|
|
}
|
|
LOG_WRN("%s: unparsed %s output: %s\n", __func__, common_chat_format_name(params.format), effective_input.substr(result.end).c_str());
|
|
LOG_DBG("%s: full %s output triggering error:\n=== BEGIN ===\n%s\n=== END ===\n", __func__, common_chat_format_name(params.format), effective_input.c_str());
|
|
throw std::runtime_error(std::string("The model produced output that does not match the expected ") + common_chat_format_name(params.format) + " format");
|
|
}
|
|
|
|
common_chat_msg msg;
|
|
msg.role = "assistant";
|
|
|
|
std::unique_ptr<common_chat_peg_mapper> mapper;
|
|
if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) {
|
|
mapper = std::make_unique<common_chat_peg_gemma4_mapper>(msg);
|
|
} else if (params.format == COMMON_CHAT_FORMAT_PEG_MINIMAX_M3) {
|
|
mapper = std::make_unique<common_chat_peg_minimax_m3_mapper>(msg);
|
|
} else {
|
|
mapper = std::make_unique<common_chat_peg_mapper>(msg);
|
|
}
|
|
mapper->from_ast(ctx.ast, result);
|
|
|
|
if (ctx.is_debug()) {
|
|
fprintf(stderr, "\nAST for %s parse:\n%s\n", is_partial ? "partial" : "full", ctx.ast.dump().c_str());
|
|
fflush(stderr);
|
|
}
|
|
|
|
if (!is_partial) {
|
|
LOG_DBG("Parsed message: %s\n", common_chat_msgs_to_json_oaicompat({ msg }).at(0).dump().c_str());
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
std::map<std::string, bool> common_chat_templates_get_caps(const common_chat_templates * chat_templates) {
|
|
GGML_ASSERT(chat_templates != nullptr);
|
|
GGML_ASSERT(chat_templates->template_default != nullptr);
|
|
if (chat_templates->template_tool_use != nullptr) {
|
|
// take the more expressive template when available
|
|
return chat_templates->template_tool_use->caps.to_map();
|
|
}
|
|
return chat_templates->template_default->caps.to_map();
|
|
}
|