liblloyal 1.0.0
Branched Inference for llama.cpp
Loading...
Searching...
No Matches
chat_in.hpp
Go to the documentation of this file.
1#pragma once
2
3// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
4// Copyright 2026 Lloyal Labs
5
6
40#include "common.hpp"
41#include "tokenizer.hpp"
42#include <llama/llama.h>
43#include <chat.h> // llama.cpp common library: common_chat_templates_*
44#include <json.h>
45#include <peg-parser.h>
46#include <nlohmann/json.hpp>
47#include <algorithm>
48#include <exception>
49#include <string>
50#include <vector>
51
59namespace lloyal::chat_in {
60
68 std::string messages_json;
69 std::string template_override = "";
71 std::string tools_json = "";
72 std::string tool_choice = "auto";
73 bool parallel_tool_calls = false;
74 std::string reasoning_format = "auto";
75 bool enable_thinking = true;
76 std::string json_schema = "";
77 std::string grammar = "";
78};
79
87 // Core output
88 std::string prompt;
89 std::vector<std::string> additional_stops;
90
91 // Format awareness (all fields from common_chat_params)
92 common_chat_format format = COMMON_CHAT_FORMAT_CONTENT_ONLY;
93 std::string grammar;
94 bool grammar_lazy = false;
95 std::string generation_prompt;
96 std::vector<common_grammar_trigger> grammar_triggers;
97 std::vector<std::string> preserved_tokens;
98 common_peg_arena parser;
99 bool supports_thinking = false;
100 std::string thinking_start_tag;
101 std::vector<std::string> thinking_end_tags;
102
103 // Carried through for chat_out pairing
104 common_reasoning_format reasoning_format = COMMON_REASONING_FORMAT_NONE;
105};
106
141inline FormatResult format(const llama_model *model, const FormatInputs& inputs) {
142 FormatResult result;
143
144 try {
145 // Initialize templates from model (or override)
146 common_chat_templates_ptr tmpls = common_chat_templates_init(model, inputs.template_override);
147 if (!tmpls) {
148 LLOYAL_LOG_DEBUG("[chat_in::format] Template init failed, using fallback");
149 goto fallback;
150 }
151
152 {
153 // Parse messages
154 std::vector<common_chat_msg> messages = common_chat_msgs_parse_oaicompat(common_json::parse(inputs.messages_json));
155
156 // Build full template inputs
157 common_chat_templates_inputs tmpl_inputs;
158 tmpl_inputs.messages = messages;
159 tmpl_inputs.add_generation_prompt = inputs.add_generation_prompt;
160 tmpl_inputs.use_jinja = true;
161
162 // Tools
163 if (!inputs.tools_json.empty()) {
164 tmpl_inputs.tools = common_chat_tools_parse_oaicompat(common_json::parse(inputs.tools_json));
165 tmpl_inputs.tool_choice = common_chat_tool_choice_parse_oaicompat(inputs.tool_choice);
166 tmpl_inputs.parallel_tool_calls = inputs.parallel_tool_calls;
167 }
168
169 // Reasoning
170 tmpl_inputs.reasoning_format = common_reasoning_format_from_name(inputs.reasoning_format);
171 tmpl_inputs.enable_thinking = inputs.enable_thinking;
172
173 // Structured output
174 tmpl_inputs.json_schema = inputs.json_schema;
175 tmpl_inputs.grammar = inputs.grammar;
176
177 // Apply template
178 common_chat_params params = common_chat_templates_apply(tmpls.get(), tmpl_inputs);
179
180 // Implicit empty system prompt stripping: if messages[0] is {system, ""},
181 // strip the resulting empty system block from the output. This lets callers
182 // suppress template auto-injection (e.g. SmolLM2/ChatML) by prepending an
183 // empty system message — the library completes the intent by removing the
184 // rendered empty block, leaving only the user+assistant portion.
185 // content_parts guard: a parts-based system message (e.g. text +
186 // media_marker) keeps its content STRING empty while carrying parts —
187 // that is a real system prompt, not a suppression request.
188 if (!messages.empty() && messages[0].role == "system" &&
189 messages[0].content.empty() && messages[0].content_parts.empty()) {
190 bool stripped = false;
191
192 // Primary: format [{system:""}] to learn the empty system prefix
193 try {
194 common_chat_msg sys_msg;
195 sys_msg.role = "system";
196 sys_msg.content = "";
197
198 common_chat_templates_inputs sys_inputs;
199 sys_inputs.messages = {sys_msg};
200 sys_inputs.add_generation_prompt = false;
201 sys_inputs.use_jinja = true;
202 auto sys_params = common_chat_templates_apply(tmpls.get(), sys_inputs);
203
204 const auto& sys_prefix = sys_params.prompt;
205 if (!sys_prefix.empty() &&
206 params.prompt.size() >= sys_prefix.size() &&
207 params.prompt.substr(0, sys_prefix.size()) == sys_prefix) {
208 params.prompt = params.prompt.substr(sys_prefix.size());
209 stripped = true;
210 LLOYAL_LOG_DEBUG("[chat_in::format] Stripped empty system prefix (%zu bytes)", sys_prefix.size());
211 }
212 } catch (const std::exception &e) {
213 LLOYAL_LOG_DEBUG("[chat_in::format] Primary stripping failed: %s", e.what());
214 }
215
216 // Sentinel fallback: template requires a user message (e.g. Qwen 3.5).
217 // Format [{system:""}, {user:SENTINEL}] and [{user:SENTINEL}], subtract
218 // to learn the empty system prefix.
219 if (!stripped) {
220 try {
221 static const std::string SENTINEL = "\x1F__LLOYAL_SYS_STRIP__\x1F";
222
223 common_chat_msg sys_msg;
224 sys_msg.role = "system";
225 sys_msg.content = "";
226 common_chat_msg user_msg;
227 user_msg.role = "user";
228 user_msg.content = SENTINEL;
229
230 common_chat_templates_inputs with_sys;
231 with_sys.messages = {sys_msg, user_msg};
232 with_sys.add_generation_prompt = false;
233 with_sys.use_jinja = true;
234 auto with_sys_params = common_chat_templates_apply(tmpls.get(), with_sys);
235
236 common_chat_templates_inputs without_sys;
237 without_sys.messages = {user_msg};
238 without_sys.add_generation_prompt = false;
239 without_sys.use_jinja = true;
240 auto without_sys_params = common_chat_templates_apply(tmpls.get(), without_sys);
241
242 const auto& with_prompt = with_sys_params.prompt;
243 const auto& without_prompt = without_sys_params.prompt;
244
245 // If with_sys ends with without_sys, the prefix is the difference
246 if (with_prompt.size() > without_prompt.size() &&
247 with_prompt.substr(with_prompt.size() - without_prompt.size()) == without_prompt) {
248 std::string sys_prefix = with_prompt.substr(0, with_prompt.size() - without_prompt.size());
249 if (!sys_prefix.empty() &&
250 params.prompt.size() >= sys_prefix.size() &&
251 params.prompt.substr(0, sys_prefix.size()) == sys_prefix) {
252 params.prompt = params.prompt.substr(sys_prefix.size());
253 LLOYAL_LOG_DEBUG("[chat_in::format] Stripped empty system prefix via sentinel (%zu bytes)", sys_prefix.size());
254 }
255 } else {
256 LLOYAL_LOG_DEBUG("[chat_in::format] Sentinel subtraction failed, skipping strip");
257 }
258 } catch (const std::exception &e) {
259 LLOYAL_LOG_DEBUG("[chat_in::format] Sentinel stripping also failed: %s", e.what());
260 }
261 }
262 }
263
264 // Populate ALL result fields from common_chat_params
265 result.prompt = params.prompt;
266 result.additional_stops = params.additional_stops;
267 result.format = params.format;
268 result.grammar = params.grammar;
269 result.grammar_lazy = params.grammar_lazy;
270 result.generation_prompt = params.generation_prompt;
271 result.grammar_triggers = params.grammar_triggers;
272 result.preserved_tokens = params.preserved_tokens;
273 result.parser = params.parser;
274 result.supports_thinking = params.supports_thinking;
275 result.thinking_start_tag = params.thinking_start_tag;
276 result.thinking_end_tags = params.thinking_end_tags;
277
278 // Carry reasoning_format through for chat_out pairing
279 result.reasoning_format = tmpl_inputs.reasoning_format;
280
282 "[chat_in::format] Successfully formatted with format=%d, %zu stop tokens, grammar=%zu bytes",
283 static_cast<int>(result.format),
284 result.additional_stops.size(),
285 result.grammar.size());
286 return result;
287 }
288
289 } catch (const std::exception &e) {
290 LLOYAL_LOG_DEBUG("[chat_in::format] Template processing failed: %s", e.what());
291
292 // Retry with synthetic user: templates like Qwen 3.5 require a user message.
293 // Inject a sentinel user message, re-apply the template, then strip the
294 // sentinel user turn from the output to recover just the system/tool portion.
295 try {
296 common_chat_templates_ptr tmpls = common_chat_templates_init(model, inputs.template_override);
297 if (tmpls) {
298 static const std::string SENTINEL = "\x1F__LLOYAL_RETRY__\x1F";
299
300 std::vector<common_chat_msg> messages = common_chat_msgs_parse_oaicompat(common_json::parse(inputs.messages_json));
301
302 // Check that no user role exists (otherwise the failure was something else)
303 bool has_user = false;
304 for (const auto& m : messages) {
305 if (m.role == "user") { has_user = true; break; }
306 }
307
308 if (!has_user) {
309 // Build augmented messages: original + synthetic user
310 std::vector<common_chat_msg> augmented = messages;
311 common_chat_msg sentinel_user;
312 sentinel_user.role = "user";
313 sentinel_user.content = SENTINEL;
314 augmented.push_back(sentinel_user);
315
316 common_chat_templates_inputs tmpl_inputs;
317 tmpl_inputs.messages = augmented;
318 tmpl_inputs.add_generation_prompt = false;
319 tmpl_inputs.use_jinja = true;
320
321 // Carry over tools so the system block includes tool definitions
322 if (!inputs.tools_json.empty()) {
323 tmpl_inputs.tools = common_chat_tools_parse_oaicompat(common_json::parse(inputs.tools_json));
324 tmpl_inputs.tool_choice = common_chat_tool_choice_parse_oaicompat(inputs.tool_choice);
325 tmpl_inputs.parallel_tool_calls = inputs.parallel_tool_calls;
326 }
327 tmpl_inputs.reasoning_format = common_reasoning_format_from_name(inputs.reasoning_format);
328 tmpl_inputs.enable_thinking = inputs.enable_thinking;
329 tmpl_inputs.json_schema = inputs.json_schema;
330 tmpl_inputs.grammar = inputs.grammar;
331
332 common_chat_params params = common_chat_templates_apply(tmpls.get(), tmpl_inputs);
333
334 // Also format just [{user:SENTINEL}] to learn its rendered form
335 common_chat_msg user_only_msg;
336 user_only_msg.role = "user";
337 user_only_msg.content = SENTINEL;
338
339 common_chat_templates_inputs user_only_inputs;
340 user_only_inputs.messages = {user_only_msg};
341 user_only_inputs.add_generation_prompt = false;
342 user_only_inputs.use_jinja = true;
343 auto user_only_params = common_chat_templates_apply(tmpls.get(), user_only_inputs);
344
345 const auto& full = params.prompt;
346 const auto& user_suffix = user_only_params.prompt;
347
348 // Strip the sentinel user turn from the end
349 if (full.size() > user_suffix.size() &&
350 full.substr(full.size() - user_suffix.size()) == user_suffix) {
351 params.prompt = full.substr(0, full.size() - user_suffix.size());
352
353 // Strip empty system block if messages[0] is {system, ""}.
354 // Same content_parts guard as the primary path: parts-based
355 // system messages are real content, never a strip request.
356 if (!messages.empty() && messages[0].role == "system" &&
357 messages[0].content.empty() && messages[0].content_parts.empty()) {
358 // Use sentinel subtraction: [{system:""}, {user:S}] minus [{user:S}]
359 common_chat_msg sys_msg; sys_msg.role = "system"; sys_msg.content = "";
360 common_chat_msg usr_msg; usr_msg.role = "user"; usr_msg.content = SENTINEL;
361
362 common_chat_templates_inputs with_sys_inputs;
363 with_sys_inputs.messages = {sys_msg, usr_msg};
364 with_sys_inputs.add_generation_prompt = false;
365 with_sys_inputs.use_jinja = true;
366
367 common_chat_templates_inputs without_sys_inputs;
368 without_sys_inputs.messages = {usr_msg};
369 without_sys_inputs.add_generation_prompt = false;
370 without_sys_inputs.use_jinja = true;
371
372 try {
373 auto with_sys = common_chat_templates_apply(tmpls.get(), with_sys_inputs);
374 auto without_sys = common_chat_templates_apply(tmpls.get(), without_sys_inputs);
375 if (with_sys.prompt.size() > without_sys.prompt.size() &&
376 with_sys.prompt.substr(with_sys.prompt.size() - without_sys.prompt.size()) == without_sys.prompt) {
377 std::string sys_prefix = with_sys.prompt.substr(0, with_sys.prompt.size() - without_sys.prompt.size());
378 if (!sys_prefix.empty() &&
379 params.prompt.size() >= sys_prefix.size() &&
380 params.prompt.substr(0, sys_prefix.size()) == sys_prefix) {
381 params.prompt = params.prompt.substr(sys_prefix.size());
382 LLOYAL_LOG_DEBUG("[chat_in::format] Retry: stripped empty system prefix (%zu bytes)", sys_prefix.size());
383 }
384 }
385 } catch (...) {
386 // Stripping failed — proceed without it
387 }
388 }
389
390 result.prompt = params.prompt;
391 result.additional_stops = params.additional_stops;
392 result.format = params.format;
393 result.grammar = params.grammar;
394 result.grammar_lazy = params.grammar_lazy;
395 result.generation_prompt = params.generation_prompt;
396 result.grammar_triggers = params.grammar_triggers;
397 result.preserved_tokens = params.preserved_tokens;
398 result.parser = params.parser;
399 // The template's reasoning declaration is a property of the TEMPLATE, not of the
400 // conversation shape that reached it: a system-only turn takes this retry, and
401 // dropping these left `supports_thinking` false beside a generation prompt that
402 // opens a reasoning block — a contradiction a consumer cannot repair.
403 result.supports_thinking = params.supports_thinking;
404 result.thinking_start_tag = params.thinking_start_tag;
405 result.thinking_end_tags = params.thinking_end_tags;
406 result.reasoning_format = tmpl_inputs.reasoning_format;
407
409 "[chat_in::format] Retry with synthetic user succeeded, format=%d (%zu bytes)",
410 static_cast<int>(result.format), result.prompt.size());
411 return result;
412 } else {
413 LLOYAL_LOG_DEBUG("[chat_in::format] Retry sentinel subtraction failed");
414 }
415 }
416 }
417 } catch (const std::exception &e2) {
418 LLOYAL_LOG_DEBUG("[chat_in::format] Retry also failed: %s", e2.what());
419 }
420 }
421
422fallback:
423 // Fallback to simple "role: content" format
424 try {
425 using json = nlohmann::ordered_json;
426 json messages = json::parse(inputs.messages_json);
427 std::string fallback_prompt;
428 for (const auto &msg : messages) {
429 if (msg.contains("role") && msg.contains("content")) {
430 std::string role = msg["role"].get<std::string>();
431 std::string content;
432 const auto& c = msg["content"];
433 if (c.is_null()) {
434 content = "";
435 } else if (c.is_string()) {
436 content = c.get<std::string>();
437 } else {
438 content = c.dump();
439 }
440 fallback_prompt += role + ": " + content + "\n";
441 }
442 }
443
444 result.prompt = fallback_prompt;
445 result.additional_stops = {};
446
448 "[chat_in::format] Using fallback format (%zu bytes)",
449 fallback_prompt.size());
450 return result;
451
452 } catch (const std::exception &e) {
454 "[chat_in::format] ERROR: Failed to parse messages JSON: %s",
455 e.what());
456 result.prompt = "";
457 result.additional_stops = {};
458 return result;
459 }
460}
461
473inline bool validate(const std::string &template_str) {
474 try {
475 bool isValid = common_chat_verify_template(template_str, /* use_jinja */ true);
476 LLOYAL_LOG_DEBUG("[chat_in::validate] Template validation: %s",
477 isValid ? "valid" : "invalid");
478 return isValid;
479 } catch (const std::exception &e) {
480 LLOYAL_LOG_DEBUG("[chat_in::validate] ERROR: %s", e.what());
481 return false;
482 }
483}
484
494inline std::vector<llama_token> fallback_to_eog(const llama_model* model) {
495 if (model == nullptr) {
496 return {};
497 }
498 const llama_vocab* vocab = llama_model_get_vocab(model);
499 llama_token eot = llama_vocab_eot(vocab);
500 if (eot == LLAMA_TOKEN_NULL) {
501 eot = llama_vocab_eos(vocab);
502 }
503 if (eot != LLAMA_TOKEN_NULL) {
504 return {eot};
505 }
506 return {};
507}
508
516inline std::string get_token_safe(const llama_model *model, llama_token token) {
517 if (!model || token == LLAMA_TOKEN_NULL) {
518 return "";
519 }
520 return lloyal::tokenizer::detokenize(model, token);
521}
522
563inline std::vector<llama_token> get_turn_separator(const llama_model* model) {
564 using json = nlohmann::ordered_json;
565
566 if (!model) return {};
567
568 // Collision-resistant sentinels
569 const std::string SENTINEL = "\x1F__LLOYAL_SEP__\x1F";
570 const std::string SENTINEL2 = "\x1F__LLOYAL_SEP2__\x1F";
571
572 try {
573 // Initialize templates from model
574 common_chat_templates_ptr tmpls = common_chat_templates_init(model, "");
575 if (!tmpls) {
576 return fallback_to_eog(model);
577 }
578
579 // 3-message probe: captures REAL assistant→user boundary
580 std::vector<common_chat_msg> messages = {
581 {.role = "user", .content = "X"},
582 {.role = "assistant", .content = SENTINEL},
583 {.role = "user", .content = SENTINEL2}
584 };
585
586 common_chat_templates_inputs inputs;
587 inputs.messages = messages;
588 inputs.add_generation_prompt = false; // Don't add assistant prompt at end
589 inputs.use_jinja = true;
590
591 auto params = common_chat_templates_apply(tmpls.get(), inputs);
592 const std::string& formatted = params.prompt;
593
594 // Extract substring between sentinels
595 size_t sep_start = formatted.rfind(SENTINEL);
596 if (sep_start == std::string::npos) {
597 return fallback_to_eog(model);
598 }
599 sep_start += SENTINEL.length();
600
601 size_t sep_end = formatted.find(SENTINEL2, sep_start);
602 if (sep_end == std::string::npos) {
603 return fallback_to_eog(model);
604 }
605
606 std::string between = formatted.substr(sep_start, sep_end - sep_start);
607 if (between.empty()) {
608 return fallback_to_eog(model);
609 }
610
611 // Tokenize with parse_special=true
612 const auto* vocab = llama_model_get_vocab(model);
613 std::vector<llama_token> tokens = lloyal::tokenizer::tokenize(vocab, between, false, true);
614 if (tokens.empty()) {
615 return fallback_to_eog(model);
616 }
617
618 // Extract: everything up to and including EOG + trailing whitespace
619 std::vector<llama_token> separator;
620 bool found_eog = false;
621
622 for (auto tok : tokens) {
623 if (!found_eog) {
624 separator.push_back(tok);
625 if (lloyal::tokenizer::is_eog(model, tok)) {
626 found_eog = true;
627 }
628 } else {
629 // After EOG, only keep whitespace tokens
630 std::string text = lloyal::tokenizer::detokenize(model, tok);
631 bool is_whitespace = !text.empty() && std::all_of(text.begin(), text.end(),
632 [](unsigned char c) { return c == ' ' || c == '\n' || c == '\r' || c == '\t'; });
633 if (is_whitespace) {
634 separator.push_back(tok);
635 } else {
636 break; // Non-whitespace = next message opener, stop
637 }
638 }
639 }
640
641 if (separator.empty() || !found_eog) {
642 return fallback_to_eog(model);
643 }
644
645 return separator;
646
647 } catch (const std::exception& e) {
648 LLOYAL_LOG_DEBUG("[chat_in::get_turn_separator] Error: %s", e.what());
649 return fallback_to_eog(model);
650 }
651}
652
653} // namespace lloyal::chat_in
#define LLOYAL_LOG_DEBUG(...)
liblloyal - Common definitions and logging
Definition common.hpp:48
Chat input formatting with full format awareness.
Definition chat_in.hpp:59
std::string get_token_safe(const llama_model *model, llama_token token)
Get token text safely.
Definition chat_in.hpp:516
std::vector< llama_token > get_turn_separator(const llama_model *model)
Get turn separator tokens for the model's chat template.
Definition chat_in.hpp:563
FormatResult format(const llama_model *model, const FormatInputs &inputs)
Format chat messages using model's chat template with full format awareness.
Definition chat_in.hpp:141
std::vector< llama_token > fallback_to_eog(const llama_model *model)
Get EOG token as fallback when template parsing fails.
Definition chat_in.hpp:494
bool validate(const std::string &template_str)
Validate chat template syntax.
Definition chat_in.hpp:473
std::vector< llama_token > tokenize(const llama_vocab *vocab, const std::string &text, bool add_special, bool parse_special)
Tokenize text to token array.
Definition tokenizer.hpp:39
std::string detokenize(const llama_vocab *vocab, llama_token token, bool special)
Detokenize SINGLE token to text (streaming use case)
Definition tokenizer.hpp:92
bool is_eog(const llama_vocab *vocab, llama_token token)
Check if token is end-of-generation marker.
Input parameters for chat formatting.
Definition chat_in.hpp:67
std::string messages_json
JSON array of OpenAI-format messages (required)
Definition chat_in.hpp:68
bool add_generation_prompt
Append assistant prompt prefix (set false for partial formatting)
Definition chat_in.hpp:70
std::string tools_json
JSON array of OpenAI-format tool definitions.
Definition chat_in.hpp:71
std::string json_schema
JSON schema for structured output.
Definition chat_in.hpp:76
std::string grammar
Explicit GBNF grammar string.
Definition chat_in.hpp:77
std::string template_override
Optional Jinja2 template override.
Definition chat_in.hpp:69
bool enable_thinking
Enable <think> blocks (pairs with reasoning_format)
Definition chat_in.hpp:75
std::string reasoning_format
"none" | "auto" | "deepseek" | "deepseek_legacy" — "auto" pairs with enable_thinking: reasoning parse...
Definition chat_in.hpp:74
std::string tool_choice
"auto" | "required" | "none"
Definition chat_in.hpp:72
bool parallel_tool_calls
Allow parallel tool calls.
Definition chat_in.hpp:73
Result from chat template formatting with full format awareness.
Definition chat_in.hpp:86
std::vector< common_grammar_trigger > grammar_triggers
Triggers for lazy grammar activation.
Definition chat_in.hpp:96
common_reasoning_format reasoning_format
Reasoning format for output parsing.
Definition chat_in.hpp:104
std::vector< std::string > additional_stops
Stop tokens extracted from template.
Definition chat_in.hpp:89
std::string prompt
Formatted prompt text ready for tokenization.
Definition chat_in.hpp:88
std::string generation_prompt
Generation prompt prefill (e.g. "<think>")
Definition chat_in.hpp:95
common_peg_arena parser
PEG parser (for PEG formats)
Definition chat_in.hpp:98
bool supports_thinking
Template renders a reasoning section.
Definition chat_in.hpp:99
bool grammar_lazy
Whether grammar should use lazy compilation.
Definition chat_in.hpp:94
std::vector< std::string > thinking_end_tags
Tags it closes with — Qwen "</think>", Magistral "[/THINK]".
Definition chat_in.hpp:101
std::string thinking_start_tag
Tag the template opens reasoning with (empty if none)
Definition chat_in.hpp:100
std::string grammar
GBNF grammar for constrained sampling.
Definition chat_in.hpp:93
std::vector< std::string > preserved_tokens
Tokens to preserve during grammar constraining.
Definition chat_in.hpp:97
common_chat_format format
Detected chat format.
Definition chat_in.hpp:92
Text Tokenization Operations.