Entropic 2.11.1
Local-first agentic inference engine
Loading...
Searching...
No Matches
llama_cpp_backend.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
16#include "llama_cpp_backend.h"
18#include "grammar_source.h"
19#include "llama_cpp_sampler.h"
20#include "llama_cpp_tokenizer.h"
21#include "warm_keep_util.h" // gh#96: common_prefix_len / warm_keep_cut
22#include "tool_call_markers.h" // gh#103: family-aware tool-call close marker
23#include "batch_util.h" // gh#98: batch_shared_prefix_len / batch_is_viable
24#include "mtp_envelope.h" // gh#108: mtp_unsupported_reason (fail-loud envelope)
25
26#include <entropic/inference/adapters/adapter_base.h> // gh#90 coerce_string_typed_args
28
29#include <common.h>
30#include <chat.h>
31#include <sampling.h>
32#include <speculative.h>
33#include <mtmd.h>
34#include <mtmd-helper.h>
35
36#include <nlohmann/json.hpp>
37
38#include <cmath>
39#include <cstring>
40#include <optional>
41#include <stdexcept>
42
43namespace entropic {
44
45namespace {
46
47auto logger = entropic::log::get("inference.llama_cpp");
48
57bool ends_with(const std::string& text, const std::string& suffix) {
58 return text.size() >= suffix.size()
59 && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0;
60}
61
70bool check_stop_sequences(
71 const std::string& text,
72 const std::vector<std::string>& stop_sequences)
73{
74 for (const auto& stop : stop_sequences) {
75 if (!stop.empty() && ends_with(text, stop)) {
76 return true;
77 }
78 }
79 return false;
80}
81
88GenerationResult prefill_error() {
89 GenerationResult r;
90 r.error_code = ENTROPIC_ERROR_GENERATE_FAILED;
91 r.error_message = "Prefill decode failed";
92 r.finish_reason = "error";
93 return r;
94}
95
102void log_sampler_config(const GenerationParams& params) {
103 logger->info("Sampler: temp={:.2f}, top_k={}, top_p={:.2f}, "
104 "repeat_penalty={:.2f}, thinking={}",
105 params.temperature, params.top_k, params.top_p,
106 params.repeat_penalty, params.enable_thinking);
107}
108
116void finalize_result(GenerationResult& result,
117 std::chrono::steady_clock::time_point start_time)
118{
119 auto end = entropic::log::now();
120 result.generation_time_ms = entropic::log::elapsed_ms(
121 start_time, end);
122 if (result.token_count > 0 && result.generation_time_ms > 0.0) {
123 result.throughput_tok_s =
124 static_cast<double>(result.token_count)
125 / result.generation_time_ms * 1000.0;
126 }
127 logger->info("Generated: {} tokens, finish={}, {:.0f}ms, "
128 "{:.1f} tok/s",
129 result.token_count, result.finish_reason,
130 result.generation_time_ms, result.throughput_tok_s);
131 logger->info("Content:\n{}", result.content);
132}
133
153void finalize_generation(GenerationResult& result,
154 const std::string& generated, int n_generated,
155 const GenerationParams& params,
156 std::chrono::steady_clock::time_point t0)
157{
158 if (n_generated >= params.max_tokens
159 && result.finish_reason.empty()) {
160 result.finish_reason = "length";
161 }
162 // gh#136 (4th type_error.316 recurrence): sanitize at INGRESS, where model
163 // bytes first become a std::string, rather than at each of the ~18 .dump()
164 // sites downstream. gh#112/113 were closed as "permanent closure of the
165 // 316 family", then gh#114, gh#118, gh#132 and gh#136 each patched one more
166 // egress. The exits keep multiplying; the entries do not. Guarding here
167 // makes every downstream dump safe by construction, and a NEW .dump()
168 // anywhere cannot reintroduce the bug.
169 //
170 // Once at finalization, never per-token: a multi-byte codepoint can split
171 // across token boundaries and per-token sanitize would corrupt valid
172 // output (see the same reasoning at response_generator.cpp:360, which has
173 // guarded the streaming accumulator this way since v2.1.1).
174 //
175 // Safe for raw_content consumers: raw_content is a COPY of content
176 // (orchestrator.cpp:514), and sanitize only replaces bytes that are
177 // ALREADY invalid UTF-8 — which cannot form part of any valid JSON token,
178 // so the gh#88 envelope recovery and fenced-JSON fallbacks are unaffected.
179 result.content = entropic::mcp::sanitize_utf8(generated);
180 result.token_count = n_generated;
181 finalize_result(result, t0);
182}
183
196GenerationResult sampler_init_error(
197 std::chrono::steady_clock::time_point t0)
198{
199 GenerationResult r;
200 r.error_code = ENTROPIC_ERROR_GENERATE_FAILED;
201 r.error_message = "Sampler factory not initialized";
202 r.finish_reason = "error";
203 finalize_result(r, t0);
204 return r;
205}
206
216ggml_type parse_kv_cache_type(const std::string& s) {
217 static const std::pair<const char*, ggml_type> kTable[] = {
218 {"f16", GGML_TYPE_F16},
219 {"f32", GGML_TYPE_F32},
220 {"bf16", GGML_TYPE_BF16},
221 {"q8_0", GGML_TYPE_Q8_0},
222 {"q4_0", GGML_TYPE_Q4_0},
223 };
224 for (const auto& [name, type] : kTable) {
225 if (s == name) { return type; }
226 }
227 logger->warn("Unknown cache_type '{}' — defaulting to f16", s);
228 return GGML_TYPE_F16;
229}
230
240llama_split_mode parse_split_mode(const std::string& s) {
241 if (s.empty()) { return LLAMA_SPLIT_MODE_LAYER; }
242 static const std::pair<const char*, llama_split_mode> kTable[] = {
243 {"none", LLAMA_SPLIT_MODE_NONE},
244 {"layer", LLAMA_SPLIT_MODE_LAYER},
245 {"row", LLAMA_SPLIT_MODE_ROW},
246 };
247 for (const auto& [name, mode] : kTable) {
248 if (s == name) { return mode; }
249 }
250 logger->warn("Unknown split_mode '{}' — defaulting to layer", s);
251 return LLAMA_SPLIT_MODE_LAYER;
252}
253
264llama_model_params build_load_mparams(const entropic::ModelConfig& cfg) {
265 llama_model_params m = llama_model_default_params();
266 m.n_gpu_layers = cfg.gpu_layers;
267 m.use_mmap = true;
268 m.use_mlock = cfg.use_mlock;
269 m.split_mode = parse_split_mode(cfg.split_mode);
270 // gh#23 MVP item 7 (v2.3.19): main_gpu. Effective when split_mode
271 // is "none" (pin) or "row" (small-tensor placement). 0 keeps
272 // pre-v2.3.19 load bit-for-bit.
273 m.main_gpu = cfg.main_gpu;
274 return m;
275}
276
277} // anonymous namespace
278
279// ── Lifecycle ──────────────────────────────────────────────
280
293 llama_model_params mparams = llama_model_default_params();
294 mparams.n_gpu_layers = 0;
295 mparams.use_mmap = true;
296 mparams.use_mlock = config.use_mlock;
297
298 model_ = llama_model_load_from_file(config.path.c_str(), mparams);
299 if (!model_) {
300 last_error_ = "llama_model_load_from_file failed: " + config.path.string();
301 return false;
302 }
303
304 vocab_ = llama_model_get_vocab(model_);
305 is_recurrent_ = llama_model_is_recurrent(model_);
306 is_hybrid_ = llama_model_is_hybrid(model_); // gh#97: attn + recurrent/SSM
307 // v2.3.10: wire the Tokenizer seam now that vocab_ is valid.
308 // Lifetime: tokenizer_ borrows vocab_; do_unload resets
309 // tokenizer_ BEFORE freeing the model so the borrow never dangles.
310 tokenizer_ = std::make_unique<LlamaCppTokenizer>(vocab_);
311 logger->info("Model loaded (CPU): {} tokens in vocab, recurrent={}",
312 llama_vocab_n_tokens(vocab_), is_recurrent_);
313 return true;
314}
315
326namespace {
340llama_context_params build_cparams(const entropic::ModelConfig& cfg) {
341 llama_context_params c = llama_context_default_params();
342 c.n_ctx = static_cast<uint32_t>(cfg.context_length);
343 c.n_batch = static_cast<uint32_t>(cfg.n_batch);
344 // gh#23 MVP item 5 (v2.3.17): n_ubatch. 0 keeps llama.cpp's default
345 // (== n_batch in practice), preserving pre-v2.3.17 chunking.
346 if (cfg.n_ubatch > 0) {
347 c.n_ubatch = static_cast<uint32_t>(cfg.n_ubatch);
348 }
349 c.n_threads = cfg.n_threads > 0
350 ? static_cast<uint32_t>(cfg.n_threads)
351 : std::thread::hardware_concurrency();
352 c.flash_attn_type = cfg.flash_attn
353 ? LLAMA_FLASH_ATTN_TYPE_ENABLED
354 : LLAMA_FLASH_ATTN_TYPE_DISABLED;
355 c.type_k = parse_kv_cache_type(cfg.cache_type_k);
356 c.type_v = parse_kv_cache_type(cfg.cache_type_v);
357 // gh#23 MVP item 8 (v2.3.20): offload_kqv. true (default) matches
358 // llama.cpp's default — bit-identical for callers not opting out.
359 c.offload_kqv = cfg.offload_kqv;
360 // gh#23 MVP items 9 + 10 (v2.3.21 + v2.3.22): RoPE frequency
361 // overrides. Both 0.0 = use model's trained value — bit-identical.
362 c.rope_freq_base = cfg.rope_freq_base;
363 c.rope_freq_scale = cfg.rope_freq_scale;
364 // gh#23 MVP item 11 (v2.3.23): n_parallel maps to cparams.n_seq_max.
365 // 1 (default) matches llama.cpp's default — bit-identical.
366 c.n_seq_max = static_cast<uint32_t>(cfg.n_parallel);
367 // gh#98 (v2.8.0): a unified KV buffer is REQUIRED for llama_memory_seq_cp
368 // (the same-prefix batch fan-out) — seq_cp asserts on per-sequence buffers.
369 // llama.cpp also recommends kv_unified exactly when sequences share a large
370 // prefix (our case). Only enabled when batching is configured (n_parallel>1)
371 // so single-sequence handles keep llama.cpp's default.
372 c.kv_unified = (cfg.n_parallel > 1);
373 // gh#108 (v2.9.2): llama_context_default_params() returns swa_full=true (a
374 // full-context SWA cache), but the CLI default is false. For Gemma-4 (mostly
375 // sliding-window: window=512, 5:1 SWA:global) the un-windowed cache wastes
376 // ~5 GB at 128k. Set false — the memory-efficient windowed mode. Validated
377 // against warm-keep / prompt-cache reuse over a >window prefix (the SWA layers
378 // keep only the last `window` tokens, so KV reuse must not assume full-context
379 // SWA residency — covered by the long-context warm-keep model test).
380 c.swa_full = false;
381 return c;
382}
383} // anonymous namespace
384
398 if (!load_gpu_model()) { return false; }
399 if (!create_inference_context()) { return false; }
400 // v2.3.10: wire the Sampler seam once ctx_ / vocab_ are live.
401 // Lifetime: factory borrows ctx_ + vocab_; do_deactivate /
402 // do_unload reset sampler_factory_ BEFORE freeing those handles
403 // so the borrow never dangles.
404 sampler_factory_ = std::make_unique<LlamaCppSamplerFactory>(
405 ctx_, vocab_);
407 return true;
408}
409
428 llama_model_params mparams = build_load_mparams(config());
429
430 if (!config().tensor_split.empty()) {
431 // TODO: parse tensor_split string into float array for multi-GPU
432 logger->warn("tensor_split not yet implemented, ignoring");
433 }
434
435 // tokenizer_ borrows the old vocab_; reset it before the free so the
436 // borrow never dangles. Then free the WARM model and null the
437 // handles so a failed reload below leaves the backend in a clean,
438 // recoverable state rather than a dangling one.
439 tokenizer_.reset();
440 if (model_ != nullptr) {
441 llama_model_free(model_);
442 model_ = nullptr;
443 vocab_ = nullptr;
444 }
445
446 model_ = llama_model_load_from_file(config().path.c_str(), mparams);
447 if (model_ == nullptr) {
448 // llama.cpp returns null with no error string — the actual
449 // reason (OOM, CUDA init failure, GGUF parse error, etc.) only
450 // surfaces in ggml's log stream. Point the operator at it so
451 // multi-handle GPU failures (gh#58 v2.2.7 follow-up) are
452 // diagnosable without source-diving llama.cpp.
453 last_error_ = "Failed to reload model with GPU layers "
454 "(path=" + config().path.string()
455 + ", gpu_layers=" + std::to_string(config().gpu_layers)
456 + ") — check llama_ggml.log in the engine's log_dir "
457 "for the underlying llama.cpp/CUDA error";
458 return false;
459 }
460
461 vocab_ = llama_model_get_vocab(model_);
462 tokenizer_ = std::make_unique<LlamaCppTokenizer>(vocab_);
463 return true;
464}
465
473 llama_context_params cparams = build_cparams(config());
474
475 ctx_ = llama_init_from_model(model_, cparams);
476 if (!ctx_) {
477 last_error_ = "llama_init_from_model failed";
478 return false;
479 }
480
481 logger->info("Context created: n_ctx={}, n_batch={}, "
482 "flash_attn={}, type_k={}, type_v={}",
483 config().context_length, config().n_batch,
484 config().flash_attn,
485 config().cache_type_k, config().cache_type_v);
486
487 // Initialize prompt cache if not already created
488 if (!prompt_cache_) {
489 prompt_cache_ = std::make_unique<PromptCache>(
491 logger->info("Prompt cache initialized: max_bytes={}",
493 }
494 return true;
495}
496
510 if (config().mmproj_path.empty()) {
511 has_vision_ = false;
512 return;
513 }
514 auto ctx_params = mtmd_context_params_default();
515 ctx_params.use_gpu = (config().gpu_layers != 0);
516 ctx_params.flash_attn_type = config().flash_attn
517 ? LLAMA_FLASH_ATTN_TYPE_ENABLED
518 : LLAMA_FLASH_ATTN_TYPE_DISABLED;
519 ctx_params.print_timings = false;
520 mtmd_ctx_ = mtmd_init_from_file(
521 config().mmproj_path.c_str(), model_, ctx_params);
522 if (mtmd_ctx_ == nullptr) {
523 logger->error("mtmd_init_from_file failed for {} — "
524 "continuing in text-only mode",
525 config().mmproj_path.string());
526 has_vision_ = false;
527 return;
528 }
529 has_vision_ = mtmd_support_vision(mtmd_ctx_);
530 logger->info("mmproj loaded from {} — vision={}",
531 config().mmproj_path.string(), has_vision_);
532}
533
550 if (mtp_draft_ctx_ != nullptr) {
551 llama_free(mtp_draft_ctx_);
552 mtp_draft_ctx_ = nullptr;
553 }
554 if (mtp_draft_model_ != nullptr) {
555 llama_model_free(mtp_draft_model_);
556 mtp_draft_model_ = nullptr;
557 }
558 mtp_head_path_.clear();
559}
560
574static int effective_n_draft(int n_max) {
575 return (n_max > 0) ? n_max : 16;
576}
577
591bool LlamaCppBackend::setup_mtp_draft(const std::string& head_path, int n_max) {
593 if (mtp_draft_ctx_ != nullptr && mtp_head_path_ == head_path) {
594 return true; // live head already bound to this ctx_
595 }
597 return build_mtp_head(head_path);
598}
599
605bool LlamaCppBackend::build_mtp_head(const std::string& head_path) {
606 if (ctx_ == nullptr) {
607 last_error_ = "MTP setup requires an ACTIVE target context";
608 return false;
609 }
610 if (head_path.empty()) {
611 // gh#108: fail loud before llama_model_load_from_file("") — a bare
612 // mtp=true with no draft.path is a config error, not a load to attempt.
613 last_error_ = "MTP requires speculative.draft.path (the head GGUF); "
614 "none configured";
615 return false;
616 }
617 llama_model_params mparams = llama_model_default_params();
618 mparams.n_gpu_layers = config().gpu_layers; // head is tiny — follow target
619 mparams.use_mmap = true;
620 mtp_draft_model_ = llama_model_load_from_file(head_path.c_str(), mparams);
621 if (mtp_draft_model_ != nullptr) {
622 llama_context_params cparams = build_cparams(config());
623 cparams.ctx_type = LLAMA_CONTEXT_TYPE_MTP;
624 cparams.ctx_other = ctx_; // share the target's KV memory
625 cparams.n_rs_seq = 0;
626 mtp_draft_ctx_ = llama_init_from_model(mtp_draft_model_, cparams);
627 }
628 bool ok = (mtp_draft_ctx_ != nullptr);
629 if (ok) {
630 mtp_head_path_ = head_path;
631 logger->info("MTP head ready: {} (n_max={}, ctx_other=target, "
632 "shared-KV)", head_path, mtp_n_max_);
633 } else {
634 last_error_ = "MTP head setup failed: " + head_path;
636 }
637 return ok;
638}
639
656 // gh#108 (v2.9.1): serialise vs an in-flight generate_mtp — it holds
657 // mtp_mutex_ across its decode, so this blocks until that decode finishes
658 // before freeing the MTP head + ctx_ (no deactivate-during-generate UAF).
659 std::lock_guard<std::mutex> lk(mtp_mutex_);
660 // gh#106 (v2.9.0): the MTP head borrows ctx_ via ctx_other — free it
661 // FIRST so the borrow never dangles past the context.
663 // v2.3.10: sampler factory borrows ctx_ + vocab_. Release it
664 // BEFORE freeing the context so the borrow never dangles.
665 sampler_factory_.reset();
666 // v2.1.8: mtmd holds a reference to the live llama_model — free
667 // it before the GPU model is unloaded below.
668 if (mtmd_ctx_ != nullptr) {
669 mtmd_free(mtmd_ctx_);
670 mtmd_ctx_ = nullptr;
671 has_vision_ = false;
672 }
673 if (ctx_) {
674 llama_free(ctx_);
675 ctx_ = nullptr;
676 }
677 invalidate_resident_kv(); // gh#96: KV is gone with the context
678
679 // Free the GPU model FIRST (releasing VRAM — the point of
680 // deactivate), then reload CPU-only for the WARM state. tokenizer_
681 // borrows the old vocab_, so reset it before the free.
682 tokenizer_.reset();
683 if (model_ != nullptr) {
684 llama_model_free(model_);
685 model_ = nullptr;
686 vocab_ = nullptr;
687 }
689}
690
701 llama_model_params mparams = llama_model_default_params();
702 mparams.n_gpu_layers = 0;
703 mparams.use_mmap = true;
704 mparams.use_mlock = config().use_mlock;
705
706 model_ = llama_model_load_from_file(config().path.c_str(), mparams);
707 if (model_ != nullptr) {
708 vocab_ = llama_model_get_vocab(model_);
709 tokenizer_ = std::make_unique<LlamaCppTokenizer>(vocab_);
710 } else {
711 // VRAM is released, but the warm-reload failed: leave the handle
712 // null (state stays recoverable — the next activate reloads from
713 // scratch). Error, not warn: a same-file CPU reload failing here
714 // signals real trouble (disk/OOM).
715 logger->error("Failed to reload CPU model during deactivate "
716 "(path={}); backend left unloaded until next activate",
717 config().path.string());
718 }
719}
720
735
750 std::unique_ptr<Tokenizer> tokenizer)
751{
752 tokenizer_ = std::move(tokenizer);
753 state_.store(ModelState::WARM, std::memory_order_release);
754}
755
771 std::unique_ptr<SamplerFactory> factory)
772{
773 sampler_factory_ = std::move(factory);
774}
775
789 // gh#108 (v2.9.1): serialise vs in-flight generate_mtp (see do_deactivate).
790 std::lock_guard<std::mutex> lk(mtp_mutex_);
791
792 // gh#108 (v2.10.3): invalidate the sticky parse snapshot. parse_params_valid_
793 // was set once by a tooled render (gh#105) and never cleared by anything —
794 // not render_prompt (which clears only have_chat_params_), not unload. If a
795 // backend instance were reused across a load of a different GGUF, the stale
796 // PEG arena would survive AND parse_chat_format_ would still report the old
797 // family, so common_chat_parse_reliable() would answer true and parse the
798 // new model's output with the previous model's parser. Clearing here makes
799 // that unreachable by construction rather than by luck of instance reuse.
800 parse_params_valid_ = false;
803 parse_parser_.clear();
804 // gh#134 (v2.10.4): same reasoning — a reused backend must not apply the
805 // previous model's tool-call grammar to a new model's decode.
806 tool_grammar_.clear();
807 tool_grammar_lazy_ = false;
808 if (prompt_cache_) {
809 prompt_cache_->clear();
810 }
811 // gh#106 (v2.9.0): MTP head borrows ctx_ — free it before the context.
813 // v2.3.10: sampler factory borrows ctx_ + vocab_ — release it
814 // BEFORE the context/model are freed below so the borrow never
815 // points into freed memory. (do_deactivate normally releases
816 // this earlier; this reset is the WARM→COLD safety net.)
817 sampler_factory_.reset();
818 // v2.3.10: tokenizer borrows vocab_ — release it BEFORE the model
819 // is freed so the borrow never points into freed memory.
820 tokenizer_.reset();
821 // v2.1.8: mtmd must be freed before the underlying llama_model.
822 if (mtmd_ctx_ != nullptr) {
823 mtmd_free(mtmd_ctx_);
824 mtmd_ctx_ = nullptr;
825 has_vision_ = false;
826 }
827 if (ctx_) {
828 llama_free(ctx_);
829 ctx_ = nullptr;
830 }
831 invalidate_resident_kv(); // gh#96: KV is gone with the context
832 if (model_) {
833 llama_model_free(model_);
834 model_ = nullptr;
835 }
836 vocab_ = nullptr;
837}
838
839// ── Tokenization ───────────────────────────────────────────
840
849std::vector<llama_token> LlamaCppBackend::tokenize(
850 const std::string& text, bool add_special) const
851{
852 // v2.3.10: route through the Tokenizer seam. tokenizer_ is set
853 // in do_load (real impl) or via inject_tokenizer_for_test (mock).
854 // Returns empty when no tokenizer is wired — matches the prior
855 // failure-path return shape.
856 if (!tokenizer_) { return {}; }
857 auto ids = tokenizer_->tokenize(text, add_special);
858 // llama_token is int32_t; vector conversion is a copy through
859 // iterators since the value type matches.
860 return {ids.begin(), ids.end()};
861}
862
870std::string LlamaCppBackend::detokenize(llama_token token) const {
871 // v2.3.10: route through Tokenizer seam. The special=false /
872 // gh#68 history + defensive rationale now lives in
873 // LlamaCppTokenizer::detokenize. Returns empty when no
874 // tokenizer is wired — matches prior failure-path return.
875 if (!tokenizer_) { return {}; }
876 return tokenizer_->detokenize(static_cast<int32_t>(token));
877}
878
886int LlamaCppBackend::do_count_tokens(const std::string& text) const {
887 auto tokens = tokenize(text, false);
888 return static_cast<int>(tokens.size());
889}
890
899 const std::string& text) const {
900 auto tokens = tokenize(text, true);
901 return {tokens.begin(), tokens.end()};
902}
903
904// ── Evaluation (v1.9.10) ──────────────────────────────────
905
921 const int32_t* tokens,
922 int n_tokens)
923{
924 int n_vocab = llama_vocab_n_tokens(vocab_);
925 LogprobResult result;
926 result.tokens.assign(tokens, tokens + n_tokens);
927 result.n_tokens = n_tokens;
928 result.n_logprobs = n_tokens - 1;
929 result.logprobs.reserve(result.n_logprobs);
930
931 auto* mem = llama_get_memory(ctx_);
932 llama_memory_clear(mem, true);
933
934 for (int i = 0; i < n_tokens; i++) {
935 llama_token tok = tokens[i];
936 llama_batch batch = llama_batch_get_one(&tok, 1);
937 int rc = llama_decode(ctx_, batch);
938 if (rc != 0) {
939 llama_memory_clear(mem, true);
940 throw std::runtime_error("llama_decode failed at logprob pos");
941 }
942 if (i < n_tokens - 1) {
943 const float* logits = llama_get_logits_ith(ctx_, -1);
944 float lp = extract_token_logprob(
945 logits, tokens[i + 1], n_vocab);
946 result.logprobs.push_back(lp);
947 }
948 }
949
950 float sum = 0.0f;
951 for (float lp : result.logprobs) { sum += lp; }
952 result.total_logprob = sum;
953 result.perplexity = std::exp(
954 -sum / static_cast<float>(result.n_logprobs));
955
956 llama_memory_clear(mem, true);
957 return result;
958}
959
976 std::lock_guard<std::mutex> lock(seq_id_mutex_);
977 if (!free_seq_ids_.empty()) {
978 auto id = free_seq_ids_.back();
979 free_seq_ids_.pop_back();
980 return id;
981 }
982 return next_temp_seq_id_++;
983}
984
991void LlamaCppBackend::release_temp_seq_id(llama_seq_id seq_id) {
992 std::lock_guard<std::mutex> lock(seq_id_mutex_);
993 free_seq_ids_.push_back(seq_id);
994}
995
1010 const float* logits,
1011 int32_t next_token,
1012 int n_vocab)
1013{
1014 float max_logit = logits[0];
1015 for (int v = 1; v < n_vocab; v++) {
1016 if (logits[v] > max_logit) {
1017 max_logit = logits[v];
1018 }
1019 }
1020 float sum_exp = 0.0f;
1021 for (int v = 0; v < n_vocab; v++) {
1022 sum_exp += std::exp(logits[v] - max_logit);
1023 }
1024 float log_sum_exp = max_logit + std::log(sum_exp);
1025 return logits[next_token] - log_sum_exp;
1026}
1027
1028// ── Chat template ──────────────────────────────────────────
1029
1037static std::vector<llama_chat_message> to_llama_chat(
1038 const std::vector<Message>& messages) {
1039 std::vector<llama_chat_message> chat_msgs;
1040 chat_msgs.reserve(messages.size());
1041 for (const auto& msg : messages) {
1042 chat_msgs.push_back({msg.role.c_str(), msg.content.c_str()});
1043 }
1044 return chat_msgs;
1045}
1046
1060static std::vector<common_chat_msg> to_common_chat(
1061 const std::vector<Message>& messages) {
1062 std::vector<common_chat_msg> out;
1063 out.reserve(messages.size());
1064 for (const auto& msg : messages) {
1065 common_chat_msg cm;
1066 cm.role = msg.role;
1067 cm.content = msg.content;
1068 out.push_back(std::move(cm));
1069 }
1070 return out;
1071}
1072
1087static std::vector<common_chat_tool> mcp_tools_to_common_chat(
1088 const std::string& tools_json) {
1089 std::vector<common_chat_tool> out;
1090 if (tools_json.empty()) { return out; }
1091 auto arr = nlohmann::json::parse(tools_json, nullptr, false);
1092 if (!arr.is_array()) { return out; }
1093 for (const auto& t : arr) {
1094 common_chat_tool ct;
1095 ct.name = t.value("name", "");
1096 ct.description = t.value("description", "");
1097 if (t.contains("inputSchema")) {
1098 ct.parameters = t["inputSchema"].dump();
1099 }
1100 if (!ct.name.empty()) { out.push_back(std::move(ct)); }
1101 }
1102 return out;
1103}
1104
1117static ToolCall to_entropic_tool_call(const common_chat_tool_call& cc) {
1118 ToolCall tc;
1119 tc.id = cc.id;
1120 tc.name = cc.name;
1121 tc.arguments_json = cc.arguments;
1122 auto j = nlohmann::json::parse(cc.arguments, nullptr, false);
1123 if (j.is_object()) {
1124 for (auto it = j.begin(); it != j.end(); ++it) {
1125 tc.arguments[it.key()] =
1126 it->is_string() ? it->get<std::string>() : it->dump();
1127 }
1128 }
1129 return tc;
1130}
1131
1157static std::optional<common_chat_params> render_common_chat(
1158 llama_model* model,
1159 const std::vector<Message>& messages,
1160 const GenerationParams& params,
1161 const std::vector<common_chat_tool>& tools,
1162 bool require_tool_call) {
1163 if (model == nullptr) { return std::nullopt; }
1164 auto tmpls = common_chat_templates_init(model, "");
1165 std::optional<common_chat_params> out;
1166 if (tmpls) {
1167 common_chat_templates_inputs inputs;
1168 inputs.messages = to_common_chat(messages);
1169 inputs.add_generation_prompt = true;
1170 inputs.use_jinja = true;
1171 inputs.enable_thinking = params.enable_thinking; // gh#86
1172 inputs.tools = tools;
1173 if (!tools.empty()) {
1174 // gh#134 (v2.10.4): was hardcoded AUTO, so a mandatory-tool tier
1175 // could always answer in prose. REQUIRED makes upstream build the
1176 // tool-call grammar eagerly (grammar_lazy=false), which the
1177 // sampler now actually applies — see to_common_sampling.
1178 inputs.tool_choice = require_tool_call
1179 ? COMMON_CHAT_TOOL_CHOICE_REQUIRED
1180 : COMMON_CHAT_TOOL_CHOICE_AUTO;
1181 }
1182 try {
1183 out = common_chat_templates_apply(tmpls.get(), inputs);
1184 } catch (const std::exception& e) {
1185 logger->warn("jinja chat template apply failed ({}); "
1186 "falling back to low-level template", e.what());
1187 }
1188 }
1189 return out;
1190}
1191
1199static std::string concat_messages_fallback(
1200 const std::vector<Message>& messages) {
1201 std::string fallback;
1202 for (const auto& msg : messages) {
1203 fallback += msg.role + ": " + msg.content + "\n";
1204 }
1205 return fallback;
1206}
1207
1229 const std::vector<Message>& messages,
1230 const GenerationParams& params) const
1231{
1232 // No tools staged, so tool_choice is moot — pass false.
1233 auto rendered = render_common_chat(model_, messages, params, {}, false);
1234 return rendered ? rendered->prompt
1235 : apply_chat_template_lowlevel(messages);
1236}
1237
1252 const std::vector<Message>& messages,
1253 const GenerationParams& params)
1254{
1255 if (!active_tools_json_.empty()) {
1256 return render_with_tools(messages, params);
1257 }
1258 have_chat_params_ = false;
1259 return apply_chat_template(messages, params);
1260}
1261
1273void LlamaCppBackend::set_active_tools(const std::string& tools_json) {
1274 active_tools_json_ = tools_json;
1275 logger->info("Active tools staged for common_chat render: {} bytes",
1276 tools_json.size());
1277}
1278
1301 const std::vector<Message>& messages,
1302 const GenerationParams& params)
1303{
1304 have_chat_params_ = false;
1306 auto rendered = render_common_chat(model_, messages, params, tools,
1308 std::string prompt;
1309 if (rendered) {
1310 last_chat_format_ = static_cast<int>(rendered->format);
1311 last_generation_prompt_ = rendered->generation_prompt;
1312 last_parser_ = rendered->parser;
1313 have_chat_params_ = true;
1314 // gh#105: snapshot this TOOLED render for the engine's later re-parse.
1315 // A toolless interleave (validator critique) clears have_chat_params_
1316 // but NOT this snapshot, so parse_response still decodes the main call.
1320 parse_params_valid_ = true;
1321 // gh#134 (v2.10.4): keep the tool-call grammar the render derived from
1322 // the staged schemas. Discarding it is why tools-staged tiers decode
1323 // unconstrained today.
1324 tool_grammar_ = rendered->grammar;
1325 tool_grammar_lazy_ = rendered->grammar_lazy;
1326 prompt = rendered->prompt;
1327 logger->info("render_with_tools: format={}, {} tool(s), captured "
1328 "parser ({} bytes), grammar ({} bytes, lazy={})",
1329 last_chat_format_, tools.size(), last_parser_.size(),
1331 // gh#138: the silent failure a consumer cannot see. A tier asking for
1332 // tool_choice=REQUIRED whose render yields no grammar is unconstrained
1333 // — the flag is set, tools are staged, and nothing enforces the call.
1334 // Loud, because the alternative is what gh#138 actually experienced:
1335 // prose-only turns with no diagnostic anywhere.
1336 if (require_tool_call_ && tool_grammar_.empty()) {
1337 logger->error(
1338 "require_tool_call is set for this tier but the chat template "
1339 "derived NO tool-call grammar from {} staged tool(s) "
1340 "(format={}). The turn will decode unconstrained and may end "
1341 "in prose with no tool call. This template may not support "
1342 "tool_choice=REQUIRED.",
1343 tools.size(), last_chat_format_);
1344 }
1345 } else {
1346 // gh#137: this fallback CANNOT honor enable_thinking — the low-level
1347 // llama_chat_apply_template API has no slot for it. Saying so is the
1348 // difference between a diagnosable config problem and a mystery: a
1349 // tier that set enable_thinking:false still reasons here, and on
1350 // gemma4 the whole generation can be one unterminated <|channel>
1351 // block, which the reasoning strip then correctly reduces to zero
1352 // characters. The consumer sees "0 chars, 0 tool calls" and no cause.
1353 if (!params.enable_thinking) {
1354 logger->warn(
1355 "Falling back to the low-level chat template, which cannot "
1356 "honor enable_thinking:false — this tier WILL still emit "
1357 "reasoning, and a generation that is entirely reasoning is "
1358 "stripped to empty content. The jinja render was unavailable "
1359 "for this turn (no tools staged, or the template could not be "
1360 "applied).");
1361 }
1362 prompt = apply_chat_template_lowlevel(messages);
1363 }
1364 return prompt;
1365}
1366
1379 // gh#105: read the sticky last-TOOLED snapshot, not the live capture — the
1380 // engine queries this AFTER a toolless validator render would have cleared
1381 // have_chat_params_, so the live flag is unreliable here.
1382 return parse_params_valid_
1383 && parse_chat_format_ == COMMON_CHAT_FORMAT_PEG_GEMMA4;
1384}
1385
1395 // last_chat_format_ is stored as int (the captured common_chat_format).
1396 return have_chat_params_
1398 static_cast<common_chat_format>(last_chat_format_))
1399 : "";
1400}
1401
1420std::vector<std::string> LlamaCppBackend::effective_stop(
1421 const GenerationParams& params) const {
1422 GenerationParams p = params;
1423 const std::size_t before = p.stop.size();
1425 if (p.stop.size() > before) {
1426 logger->info("Sequential tier: tool-call close marker injected "
1427 "post-render (gh#105) — hard-stop at first tool call");
1428 }
1429 return p.stop;
1430}
1431
1457void strip_thinking_channels(std::string& content, std::string* reasoning_out) {
1458 static const std::string kOpen = "<|channel>";
1459 static const std::string kClose = "<channel|>";
1460 bool stripped = false;
1461 bool truncated_unclosed = false;
1462 std::size_t pos;
1463 while ((pos = content.find(kOpen)) != std::string::npos) {
1464 stripped = true;
1465 std::size_t end = content.find(kClose, pos + kOpen.size());
1466 if (end == std::string::npos) { truncated_unclosed = true; }
1467 std::size_t span_end =
1468 (end == std::string::npos) ? content.size() : end + kClose.size();
1469 if (reasoning_out != nullptr) {
1470 std::size_t inner = pos + kOpen.size();
1471 std::size_t inner_end =
1472 (end == std::string::npos) ? content.size() : end;
1473 reasoning_out->append(content, inner, inner_end - inner);
1474 }
1475 content.erase(pos, span_end - pos);
1476 }
1477 if (stripped) {
1478 std::size_t nb = content.find_first_not_of(" \t\r\n");
1479 content.erase(0, nb == std::string::npos ? content.size() : nb);
1480 }
1481 if (truncated_unclosed && content.empty()) {
1482 logger->warn("strip_thinking_channels: a <|channel> reasoning block "
1483 "was never closed, so the strip removed the whole "
1484 "generation and content is empty. Not a parse error. The "
1485 "orchestrator reports whether this was a token budget or "
1486 "the model ending its own turn — only it holds "
1487 "finish_reason. gh#137.");
1488 }
1489}
1490
1510 const std::string& raw) const
1511{
1512 CommonChatResult result;
1513 // gh#105: decode from the sticky last-TOOLED snapshot (parse_*), NOT the
1514 // live capture — a toolless validator render between the main generation
1515 // and this re-parse would have cleared the live params.
1516 if (!parse_params_valid_) {
1517 result.content = raw;
1518 return result;
1519 }
1520 common_chat_parser_params pp;
1521 pp.format = static_cast<common_chat_format>(parse_chat_format_);
1522 pp.generation_prompt = parse_generation_prompt_;
1523 pp.parser.load(parse_parser_); // mandatory — see header
1524 try {
1525 auto msg = common_chat_parse(raw, /*is_partial=*/false, pp);
1526 result.content = msg.content;
1527 result.reasoning_content = msg.reasoning_content;
1528 // gh#106: Gemma 4 QAT reasoning channels common_chat doesn't parse.
1530 for (const auto& tc : msg.tool_calls) {
1531 result.tool_calls.push_back(to_entropic_tool_call(tc));
1532 }
1533 // gh#90: gemma <|"|> string-escape loses type through PEG_GEMMA4 —
1534 // restore string typing for params the staged schema declares string.
1536 } catch (const std::exception& e) {
1537 logger->warn("common_chat_parse failed ({}); raw kept as content",
1538 e.what());
1539 result.content = raw;
1540 }
1541 return result;
1542}
1543
1557 const std::vector<Message>& messages) const
1558{
1559 auto chat_msgs = to_llama_chat(messages);
1560
1561 int n = llama_chat_apply_template(
1562 nullptr, chat_msgs.data(), chat_msgs.size(),
1563 true, nullptr, 0);
1564 if (n < 0) {
1565 logger->error("llama_chat_apply_template failed (size query)");
1566 return concat_messages_fallback(messages);
1567 }
1568
1569 std::vector<char> buf(static_cast<size_t>(n + 1));
1570 int written = llama_chat_apply_template(
1571 nullptr, chat_msgs.data(), chat_msgs.size(),
1572 true, buf.data(), static_cast<int32_t>(buf.size()));
1573 if (written < 0) {
1574 logger->error("llama_chat_apply_template failed (render)");
1575 return concat_messages_fallback(messages);
1576 }
1577
1578 return std::string(buf.data(), static_cast<size_t>(written));
1579}
1580
1581// ── Sampler ────────────────────────────────────────────────
1582
1604std::unique_ptr<Sampler> LlamaCppBackend::create_sampler(
1605 const GenerationParams& params) const
1606{
1607 if (!sampler_factory_) { return nullptr; }
1608 return sampler_factory_->create(params);
1609}
1610
1611// ── Decode loop ────────────────────────────────────────────
1612
1620bool LlamaCppBackend::run_prefill(const std::vector<llama_token>& tokens) {
1621 llama_memory_clear(llama_get_memory(ctx_), true);
1622
1623 const int n_batch = config().n_batch;
1624 const int n_tokens = static_cast<int>(tokens.size());
1625
1626 for (int i = 0; i < n_tokens; i += n_batch) {
1627 int chunk = std::min(n_batch, n_tokens - i);
1628 std::vector<llama_token> slice(
1629 tokens.begin() + i, tokens.begin() + i + chunk);
1630 llama_batch batch = llama_batch_get_one(
1631 slice.data(), static_cast<int32_t>(chunk));
1632 if (llama_decode(ctx_, batch) != 0) {
1633 logger->error("Prefill decode failed at offset {}", i);
1634 return false;
1635 }
1636 }
1637 last_prefill_tokens_ += n_tokens; // gh#96: count tokens decoded in prefill
1638 return true;
1639}
1640
1658 Sampler& sampler,
1659 std::string& generated,
1660 std::function<void(std::string_view)>& on_token,
1661 const std::vector<std::string>& stop)
1662{
1663 llama_token new_token = sampler.sample();
1664
1665 if (new_token == llama_vocab_eos(vocab_)
1666 || llama_vocab_is_eog(vocab_, new_token)) {
1667 return "eos";
1668 }
1669
1670 std::string piece = detokenize(new_token);
1671 generated += piece;
1672 if (on_token) {
1673 on_token(std::string_view(piece));
1674 }
1675 if (check_stop_sequences(generated, stop)) {
1676 return "stop";
1677 }
1678
1679 llama_token tok = new_token;
1680 llama_batch single = llama_batch_get_one(&tok, 1);
1681 return (llama_decode(ctx_, single) == 0) ? "continue" : "error";
1682}
1683
1699 const std::vector<llama_token>& tokens,
1700 const GenerationParams& params,
1701 std::function<void(std::string_view)> on_token,
1702 std::atomic<bool>* cancel)
1703{
1704 // v2.3.10: Sampler seam — factory installed in do_activate.
1705 auto sampler = create_sampler(params);
1706 if (!sampler) {
1707 GenerationResult result;
1709 result.error_message = "Sampler factory not initialized";
1710 result.finish_reason = "error";
1711 return result;
1712 }
1713
1714 if (!run_prefill(tokens)) {
1715 GenerationResult result;
1717 result.error_message = "Prefill decode failed";
1718 result.finish_reason = "error";
1719 return result;
1720 }
1721
1722 return generate_after_prefill(*sampler, params, std::move(on_token), cancel);
1723}
1724
1745 Sampler& sampler,
1746 const GenerationParams& params,
1747 std::function<void(std::string_view)> on_token,
1748 std::atomic<bool>* cancel)
1749{
1750 GenerationResult result;
1751 std::string generated;
1752 int n_generated = 0;
1753 const auto stop = effective_stop(params); // gh#105: per-call sequential marker
1754
1755 while (n_generated < params.max_tokens) {
1756 bool cancelled = cancel && cancel->load(std::memory_order_acquire);
1757 if (cancelled) {
1758 result.finish_reason = "cancelled";
1760 break;
1761 }
1762
1763 auto status = step_token(sampler, generated, on_token, stop);
1764 if (status == "continue") {
1765 ++n_generated;
1766 } else {
1767 result.finish_reason = (status == "error") ? "error" : "stop";
1768 if (status == "error") {
1770 }
1771 break;
1772 }
1773 }
1774
1775 if (n_generated >= params.max_tokens && result.finish_reason.empty()) {
1776 result.finish_reason = "length";
1777 }
1778
1779 // gh#136 (4th type_error.316 recurrence): sanitize at INGRESS, where model
1780 // bytes first become a std::string, rather than at each of the ~18 .dump()
1781 // sites downstream. gh#112/113 were closed as "permanent closure of the
1782 // 316 family", then gh#114, gh#118, gh#132 and gh#136 each patched one more
1783 // egress. The exits keep multiplying; the entries do not. Guarding here
1784 // makes every downstream dump safe by construction, and a NEW .dump()
1785 // anywhere cannot reintroduce the bug.
1786 //
1787 // Once at finalization, never per-token: a multi-byte codepoint can split
1788 // across token boundaries and per-token sanitize would corrupt valid
1789 // output (see the same reasoning at response_generator.cpp:360, which has
1790 // guarded the streaming accumulator this way since v2.1.1).
1791 //
1792 // Safe for raw_content consumers: raw_content is a COPY of content
1793 // (orchestrator.cpp:514), and sanitize only replaces bytes that are
1794 // ALREADY invalid UTF-8 — which cannot form part of any valid JSON token,
1795 // so the gh#88 envelope recovery and fenced-JSON fallbacks are unaffected.
1796 result.content = entropic::mcp::sanitize_utf8(generated);
1797 result.token_count = n_generated;
1798 return result;
1799}
1800
1801// ── gh#98: same-prefix multi-seq batched generation ────────
1802
1808static GenerationResult batch_error_result(const std::string& msg) {
1811 e.error_message = msg;
1812 e.finish_reason = "error";
1813 return e;
1814}
1815
1821static void fill_batch_cell(llama_batch& b, int k, llama_token tok,
1822 llama_pos pos, llama_seq_id seq, bool want_logits) {
1823 b.token[k] = tok;
1824 b.pos[k] = pos;
1825 b.n_seq_id[k] = 1;
1826 b.seq_id[k][0] = seq;
1827 b.logits[k] = want_logits ? 1 : 0;
1828}
1829
1837 std::vector<BatchSeq>& seqs,
1838 const std::vector<GenerationParams>& params) {
1839 for (std::size_t i = 0; i < seqs.size(); ++i) {
1840 seqs[i].sampler = create_sampler(params[i]);
1841 auto* ls = dynamic_cast<LlamaCppSampler*>(seqs[i].sampler.get());
1842 if (ls == nullptr) { return false; }
1843 seqs[i].chain = ls->native_chain();
1844 seqs[i].seq_id = (i == 0) ? 0 : allocate_temp_seq_id();
1845 seqs[i].max_tokens = params[i].max_tokens;
1846 }
1847 return true;
1848}
1849
1856 std::vector<BatchSeq>& seqs, const std::vector<llama_token>& seq0,
1857 std::size_t shared) {
1858 std::vector<llama_token> prefix(
1859 seq0.begin(), seq0.begin() + static_cast<long>(shared));
1860 if (!decode_tokens_from(prefix, 0)) { return false; } // into seq 0
1861 auto* mem = llama_get_memory(ctx_);
1862 for (std::size_t i = 1; i < seqs.size(); ++i) {
1863 llama_memory_seq_cp(mem, 0, seqs[i].seq_id, 0,
1864 static_cast<llama_pos>(shared));
1865 }
1866 for (auto& s : seqs) { s.pos = static_cast<int>(shared); }
1867 return true;
1868}
1869
1879 std::vector<BatchSeq>& seqs,
1880 const std::vector<std::vector<llama_token>>& toks,
1881 std::size_t shared) {
1882 int total = 0;
1883 // shared <= shortest-1 < every t.size() by batch_shared_prefix_len, but
1884 // guard the unsigned subtraction defensively (a bad `shared` would else
1885 // underflow to a huge alloc).
1886 for (const auto& t : toks) {
1887 total += static_cast<int>(t.size() - std::min(shared, t.size()));
1888 }
1889 llama_batch batch = llama_batch_init(total, 0,
1890 static_cast<int32_t>(seqs.size()));
1891 int k = 0;
1892 for (std::size_t i = 0; i < seqs.size(); ++i) {
1893 int len = static_cast<int>(toks[i].size());
1894 for (int p = static_cast<int>(shared); p < len; ++p) {
1895 fill_batch_cell(batch, k, toks[i][p], p, seqs[i].seq_id,
1896 p == len - 1);
1897 if (p == len - 1) { seqs[i].logits_idx = k; }
1898 ++k;
1899 }
1900 seqs[i].pos = len;
1901 }
1902 batch.n_tokens = k;
1904 bool ok = (llama_decode(ctx_, batch) == 0);
1905 llama_batch_free(batch);
1906 return ok;
1907}
1908
1914void LlamaCppBackend::sample_batch_active(std::vector<BatchSeq>& seqs) {
1915 for (auto& s : seqs) {
1916 if (!s.active) { continue; }
1917 // llama_sampler_sample() accepts the drawn token into the chain
1918 // internally (advancing grammar/penalties) — matching the single-seq
1919 // step_token path. A second accept would double-advance the grammar.
1920 llama_token tok = llama_sampler_sample(s.chain, ctx_, s.logits_idx);
1921 if (llama_vocab_is_eog(vocab_, tok)) {
1922 s.active = false;
1923 s.finish = "stop";
1924 continue;
1925 }
1926 s.out.push_back(tok);
1927 ++s.n_gen;
1928 if (s.n_gen >= s.max_tokens) { s.active = false; s.finish = "length"; }
1929 }
1930}
1931
1943 std::vector<BatchSeq>& seqs, int max_steps, std::atomic<bool>& cancel) {
1944 llama_batch batch = llama_batch_init(static_cast<int32_t>(seqs.size()), 0,
1945 static_cast<int32_t>(seqs.size()));
1946 for (int step = 0; step < max_steps; ++step) {
1947 if (cancel.load(std::memory_order_acquire)) { break; }
1948 sample_batch_active(seqs);
1949 int k = 0;
1950 for (auto& s : seqs) {
1951 if (!s.active) { continue; }
1952 fill_batch_cell(batch, k, s.out.back(), s.pos, s.seq_id, true);
1953 s.logits_idx = k;
1954 ++s.pos;
1955 ++k;
1956 }
1957 if (k == 0) { break; }
1958 batch.n_tokens = k;
1960 if (llama_decode(ctx_, batch) != 0) { break; }
1961 }
1962 llama_batch_free(batch);
1963}
1964
1970std::vector<GenerationResult> LlamaCppBackend::build_batch_results(
1971 std::vector<BatchSeq>& seqs) {
1972 std::vector<GenerationResult> out;
1973 out.reserve(seqs.size());
1974 for (auto& s : seqs) {
1976 for (llama_token t : s.out) { r.content += detokenize(t); }
1977 r.token_count = s.n_gen;
1978 r.finish_reason = s.finish;
1979 out.push_back(std::move(r));
1980 }
1981 return out;
1982}
1983
1989void LlamaCppBackend::release_temp_seqs(std::vector<BatchSeq>& seqs) {
1990 for (std::size_t i = 1; i < seqs.size(); ++i) {
1991 if (seqs[i].seq_id != 0) { release_temp_seq_id(seqs[i].seq_id); }
1992 }
1993}
1994
2006std::vector<GenerationResult> LlamaCppBackend::run_batched_decode(
2007 const std::vector<std::vector<llama_token>>& toks,
2008 const std::vector<GenerationParams>& params,
2009 std::size_t shared,
2010 std::atomic<bool>& cancel)
2011{
2012 const std::size_t n = toks.size();
2013 std::vector<BatchSeq> seqs(n);
2014 if (!prepare_batch_seqs(seqs, params)) {
2015 release_temp_seqs(seqs); // don't leak ids allocated before the failure
2016 return std::vector<GenerationResult>(
2017 n, batch_error_result("batch sampler init"));
2018 }
2019 int max_steps = 0;
2020 for (const auto& p : params) { max_steps = std::max(max_steps, p.max_tokens); }
2021
2022 llama_memory_clear(llama_get_memory(ctx_), true);
2026
2027 bool ok = prefill_shared_and_fanout(seqs, toks[0], shared)
2028 && prefill_batch_suffixes(seqs, toks, shared);
2029 if (ok) { run_batch_gen_loop(seqs, max_steps, cancel); }
2030
2031 auto out = ok ? build_batch_results(seqs)
2032 : std::vector<GenerationResult>(
2033 n, batch_error_result("batch prefill"));
2034 release_temp_seqs(seqs);
2036 logger->info("gh#98 batch: requests={} prefix.tokens_shared={} "
2037 "prefix.tokens_saved={} total_prefill_tokens={} gen_decodes={}",
2038 n, shared, shared * (n - 1), last_prefill_tokens_,
2040 return out;
2041}
2042
2060std::vector<GenerationResult> LlamaCppBackend::do_generate_batch(
2061 const std::vector<std::vector<Message>>& requests,
2062 const std::vector<GenerationParams>& params,
2063 std::atomic<bool>& cancel)
2064{
2065 const std::size_t n = requests.size();
2066 std::vector<std::vector<llama_token>> toks(n);
2067 for (std::size_t i = 0; i < n; ++i) {
2068 toks[i] = tokenize(render_prompt(requests[i], params[i]), true);
2069 }
2070 const std::size_t shared = batch_shared_prefix_len(toks);
2071 std::size_t total_suffix = 0;
2072 for (const auto& t : toks) { total_suffix += t.size() - shared; }
2073
2074 const bool hybrid = is_hybrid_ || is_recurrent_;
2075 if (!batch_is_viable(n, config().n_parallel, shared, hybrid,
2076 total_suffix, config().n_batch)) {
2077 return InferenceBackend::do_generate_batch(requests, params, cancel);
2078 }
2079 return run_batched_decode(toks, params, shared, cancel);
2080}
2081
2082// ── Prompt cache helpers ───────────────────────────────────
2083
2092 const std::vector<Message>& messages)
2093{
2094 for (const auto& msg : messages) {
2095 if (msg.role == "system") {
2096 return msg.content;
2097 }
2098 }
2099 return "";
2100}
2101
2115 const std::vector<llama_token>& tokens, int start_offset)
2116{
2117 int total = static_cast<int>(tokens.size());
2118 if (start_offset >= total) { return true; }
2119
2120 int n_batch = llama_n_batch(ctx_);
2121 int n_remaining = total - start_offset;
2122 last_prefill_tokens_ += n_remaining; // gh#96: count tokens decoded here
2123 for (int off = 0; off < n_remaining; off += n_batch) {
2124 int chunk = std::min(n_batch, n_remaining - off);
2125 llama_batch batch = llama_batch_get_one(
2126 const_cast<llama_token*>(tokens.data())
2127 + start_offset + off,
2128 chunk);
2129 if (llama_decode(ctx_, batch) != 0) {
2130 logger->error("Decode chunk failed (start={}, off={}, "
2131 "chunk={})", start_offset, off, chunk);
2132 return false;
2133 }
2134 }
2135 return true;
2136}
2137
2155 const CacheEntry* cached,
2156 const std::vector<llama_token>& tokens)
2157{
2158 auto* mem = llama_get_memory(ctx_);
2159 llama_memory_clear(mem, true);
2160
2161 size_t restored = llama_state_seq_set_data(
2162 ctx_, cached->data.data(), cached->data_size, 0);
2163 if (restored == 0) {
2164 logger->warn("KV state restore failed, falling back to full prefill");
2165 return false;
2166 }
2167
2168 return decode_tokens_from(tokens, cached->token_count);
2169}
2170
2184 const CacheKey& key, int prefix_tokens)
2185{
2186 size_t state_size = llama_state_seq_get_size(ctx_, 0);
2187 if (state_size == 0) {
2188 return;
2189 }
2190
2191 std::vector<uint8_t> buf(state_size);
2192 size_t written = llama_state_seq_get_data(
2193 ctx_, buf.data(), buf.size(), 0);
2194 if (written > 0) {
2195 buf.resize(written);
2196 prompt_cache_->store(key, std::move(buf), prefix_tokens);
2197 }
2198}
2199
2209 const std::vector<Message>& messages,
2210 const GenerationParams& params)
2211{
2212 std::vector<Message> sys_msgs;
2213 for (const auto& msg : messages) {
2214 if (msg.role == "system") {
2215 sys_msgs.push_back(msg);
2216 }
2217 }
2218 if (sys_msgs.empty()) {
2219 return 0;
2220 }
2221
2222 std::string sys_prompt = apply_chat_template(sys_msgs, params);
2223 auto sys_tokens = tokenize(sys_prompt, true);
2224 return static_cast<int>(sys_tokens.size());
2225}
2226
2249 const std::vector<llama_token>& tokens,
2250 int prefix_tokens,
2251 const CacheKey& key)
2252{
2253 int total = static_cast<int>(tokens.size());
2254 if (prefix_tokens <= 0 || prefix_tokens >= total) {
2255 return run_prefill(tokens);
2256 }
2257
2258 // Pass 1: prefill only the prefix — `run_prefill` calls
2259 // llama_memory_clear, so seq 0 ends up holding exactly
2260 // prefix_tokens positions.
2261 std::vector<llama_token> prefix(
2262 tokens.begin(), tokens.begin() + prefix_tokens);
2263 if (!run_prefill(prefix)) {
2264 return false;
2265 }
2266
2267 // Save now: state contains exactly the prefix.
2268 save_prefix_to_cache(key, prefix_tokens);
2269
2270 // Pass 2: continue prefilling the remainder. No clear — decode
2271 // appends after the saved prefix positions.
2272 return decode_tokens_from(tokens, prefix_tokens);
2273}
2274
2292 const std::vector<llama_token>& tokens,
2293 const std::string& system_prompt,
2294 const std::vector<Message>& messages,
2295 const GenerationParams& params)
2296{
2297 // gh#96 (v2.7.5): count tokens actually pushed through llama_decode during
2298 // prefill this turn. run_prefill / decode_tokens_from accumulate into
2299 // last_prefill_tokens_; a prompt-cache HIT restores the system prefix
2300 // without a decode, so this counts the re-decoded post-system remainder —
2301 // the per-turn waste that climbs today and should collapse to the appended
2302 // delta once warm-keep reuse lands. (llama_perf n_p_eval proved unreliable
2303 // across the state-restore boundary, so we count the decodes directly.)
2305 last_input_tokens_ = static_cast<int>(tokens.size()); // gh#97
2306 auto t_pre = entropic::log::now();
2307 bool ok;
2308 if (is_hybrid_ || is_recurrent_) {
2309 // gh#97 (v2.7.6): hybrid/recurrent (SSM) memory rejects the partial
2310 // seq_rm warm-keep needs (state can't be partially erased at the tail),
2311 // and the prompt-cache restore lands non-contiguous cells — both desync
2312 // KV positions (pos_max inflates → eventual decode slot-failure with the
2313 // cache mostly empty). Plain full prefill (clear + contiguous decode) is
2314 // the only correct path for these archs at this llama.cpp pin. Mirrors
2315 // the speculative-decoding guard. Forfeits the gh#96 reuse for them.
2318 } else {
2319 // gh#96 warm-keep: reuse the resident KV prefix + decode only the delta;
2320 // fall back to a cold prefill (clear + system-prefix cache) when reuse
2321 // is off, the prefix diverged, or the KV was mutated out-of-band.
2323 if (!ok) {
2324 ok = prefill_dispatch(tokens, system_prompt, messages, params);
2325 if (ok) {
2327 } else {
2329 }
2330 }
2331 }
2332 last_prefill_ms_ = entropic::log::elapsed_ms(t_pre, entropic::log::now());
2333 logger->info("Prefill (gh#96): {} tokens / {:.1f} ms decoded this turn",
2335 return ok;
2336}
2337
2357bool LlamaCppBackend::try_warm_reuse(const std::vector<llama_token>& tokens) {
2358 if (!prompt_cache_config_.warm_keep || ctx_ == nullptr) {
2359 return false;
2360 }
2361 auto* mem = llama_get_memory(ctx_);
2362 long pos_max = static_cast<long>(llama_memory_seq_pos_max(mem, 0));
2363 std::size_t cut = warm_keep_cut(resident_tokens_, tokens, pos_max);
2364 if (cut == 0) {
2365 return false; // nothing reusable — cold prefill
2366 }
2367 // Drop the divergent tail (and any prior generated tokens past `cut`),
2368 // then decode only the appended delta. A single exit (returns <= 3 gate):
2369 // success records the new resident set; failure invalidates and reports it.
2370 llama_memory_seq_rm(mem, 0, static_cast<llama_pos>(cut), -1);
2371 bool ok = decode_tokens_from(tokens, static_cast<int>(cut));
2372 if (ok) {
2375 logger->info("Warm-keep: reused {} resident tokens, decoded {} "
2376 "delta (of {} total)", cut, tokens.size() - cut,
2377 tokens.size());
2378 }
2379 } else {
2381 }
2382 return ok;
2383}
2384
2398
2416 const std::vector<llama_token>& tokens,
2417 const std::string& system_prompt,
2418 const std::vector<Message>& messages,
2419 const GenerationParams& params)
2420{
2421 bool cache_enabled = prompt_cache_
2423 && !system_prompt.empty();
2424
2425 if (!cache_enabled) {
2426 return run_prefill(tokens);
2427 }
2428
2430 system_prompt, config().path.string());
2431 const CacheEntry* cached = prompt_cache_->lookup(key);
2432
2433 if (cached != nullptr) {
2435 logger->info("Prompt cache HIT: {} bytes, {} prefix tokens",
2436 cached->data_size, cached->token_count);
2437 }
2438 if (restore_cached_prefix(cached, tokens)) {
2439 return true;
2440 }
2441 logger->warn("Cache restore failed, falling back to full prefill");
2442 } else if (prompt_cache_config_.log_hits) {
2443 logger->info("Prompt cache MISS: processing full prompt");
2444 }
2445
2446 int prefix_tokens = compute_prefix_token_count(messages, params);
2447 return prefill_and_cache_prefix(tokens, prefix_tokens, key);
2448}
2449
2450// ── Multimodal generation (v1.9.11 Phases 5–7 + v2.1.8) ────
2451
2452namespace {
2453
2459bool any_image_in(const std::vector<Message>& messages) {
2460 for (const auto& m : messages) {
2461 if (has_images(m.content_parts)) { return true; }
2462 }
2463 return false;
2464}
2465
2478std::vector<Message> strip_image_parts(
2479 const std::vector<Message>& messages) {
2480 std::vector<Message> out = messages;
2481 for (auto& m : out) {
2482 if (m.content_parts.empty()) { continue; }
2483 m.content = extract_text(m.content_parts);
2484 m.content_parts.clear();
2485 }
2486 return out;
2487}
2488
2506std::vector<Message> substitute_image_markers(
2507 const std::vector<Message>& messages,
2508 ::mtmd_context* ctx,
2509 std::vector<::mtmd_bitmap*>& bitmaps_out) {
2510 std::vector<Message> out;
2511 out.reserve(messages.size());
2512 const std::string marker = mtmd_default_marker();
2513 for (const auto& m : messages) {
2514 Message copy;
2515 copy.role = m.role;
2516 if (m.content_parts.empty()) {
2517 copy.content = m.content;
2518 out.push_back(std::move(copy));
2519 continue;
2520 }
2521 std::string built;
2522 for (const auto& p : m.content_parts) {
2523 if (p.type != ContentPartType::IMAGE) {
2524 built += p.text;
2525 continue;
2526 }
2527 ::mtmd_bitmap* bm = nullptr;
2528 if (!p.image_path.empty()) {
2529 bm = mtmd_helper_bitmap_init_from_file(
2530 ctx, p.image_path.c_str(), /*placeholder=*/false).bitmap;
2531 }
2532 if (bm == nullptr) { return {}; }
2533 bitmaps_out.push_back(bm);
2534 built += marker;
2535 }
2536 copy.content = std::move(built);
2537 out.push_back(std::move(copy));
2538 }
2539 return out;
2540}
2541
2542} // anonymous namespace
2543
2555 const std::string& prompt,
2556 const std::vector<::mtmd_bitmap*>& bitmaps,
2557 std::string& err_msg)
2558{
2559 llama_memory_clear(llama_get_memory(ctx_), true);
2560 ::mtmd_input_text mt{prompt.c_str(), true, true};
2561 auto* chunks = mtmd_input_chunks_init();
2562 std::vector<const ::mtmd_bitmap*> bm_cptrs(
2563 bitmaps.begin(), bitmaps.end());
2564 int32_t tok_rc = mtmd_tokenize(
2565 mtmd_ctx_, chunks, &mt, bm_cptrs.data(), bm_cptrs.size());
2566 if (tok_rc != 0) {
2567 mtmd_input_chunks_free(chunks);
2568 err_msg = "mtmd_tokenize failed (rc="
2569 + std::to_string(tok_rc) + ")";
2571 }
2572 llama_pos new_n_past = 0;
2573 int32_t eval_rc = mtmd_helper_eval_chunks(
2574 mtmd_ctx_, ctx_, chunks, 0, 0,
2575 static_cast<int32_t>(config().n_batch),
2576 true, &new_n_past);
2577 mtmd_input_chunks_free(chunks);
2578 if (eval_rc != 0) {
2579 err_msg = "mtmd_helper_eval_chunks failed (rc="
2580 + std::to_string(eval_rc) + ")";
2582 }
2583 logger->info("Multimodal prefill complete: n_past={}", new_n_past);
2584 return ENTROPIC_OK;
2585}
2586
2604 const GenerationParams& params,
2605 std::function<void(std::string_view token)> on_token,
2606 std::atomic<bool>* cancel,
2607 const std::chrono::steady_clock::time_point& t0)
2608{
2609 GenerationResult result;
2610 // v2.3.10: Sampler seam.
2611 auto sampler = create_sampler(params);
2612 if (!sampler) {
2614 result.error_message = "Sampler factory not initialized";
2615 result.finish_reason = "error";
2616 finalize_result(result, t0);
2617 return result;
2618 }
2619 std::string generated;
2620 int n_generated = 0;
2621 const auto stop = effective_stop(params); // gh#105: per-call sequential marker
2622 while (n_generated < params.max_tokens) {
2623 if (cancel != nullptr
2624 && cancel->load(std::memory_order_acquire)) {
2625 result.finish_reason = "cancelled";
2627 break;
2628 }
2629 auto status = step_token(
2630 *sampler, generated, on_token, stop);
2631 if (status == "continue") { ++n_generated; continue; }
2632 result.finish_reason = (status == "error") ? "error" : "stop";
2633 if (status == "error") {
2635 }
2636 break;
2637 }
2638 finalize_generation(result, generated, n_generated, params, t0);
2639 return result;
2640}
2641
2658 const std::vector<Message>& messages,
2659 const GenerationParams& params,
2660 std::function<void(std::string_view token)> on_token,
2661 std::atomic<bool>* cancel)
2662{
2663 auto t0 = entropic::log::now();
2664 invalidate_resident_kv(); // gh#96: mtmd_prefill mutates seq 0 out-of-band
2665 std::vector<::mtmd_bitmap*> bitmaps;
2666 auto marked = substitute_image_markers(
2667 messages, mtmd_ctx_, bitmaps);
2668 if (marked.empty()) {
2669 for (auto* b : bitmaps) { mtmd_bitmap_free(b); }
2670 GenerationResult err;
2672 err.error_message =
2673 "mtmd_helper_bitmap_init_from_file failed";
2674 return err;
2675 }
2676 auto prompt = render_prompt(marked, params);
2677 logger->info("Multimodal generate: {} images, prompt={} chars, max_tokens={}",
2678 bitmaps.size(), prompt.size(), params.max_tokens);
2679 std::string prefill_err;
2680 auto rc = mtmd_prefill(prompt, bitmaps, prefill_err);
2681 for (auto* b : bitmaps) { mtmd_bitmap_free(b); }
2682 if (rc != ENTROPIC_OK) {
2683 GenerationResult err;
2684 err.error_code = rc;
2685 err.error_message = std::move(prefill_err);
2686 return err;
2687 }
2688 return run_sampling_loop(params, on_token, cancel, t0);
2689}
2690
2691// ── Generation entry points ────────────────────────────────
2692
2709 const std::vector<Message>& messages,
2710 const GenerationParams& params)
2711{
2712 if (!any_image_in(messages)) {
2713 return do_generate_text_only(messages, params);
2714 }
2715 if (has_vision_ && mtmd_ctx_ != nullptr) {
2716 return generate_multimodal(messages, params, nullptr, nullptr);
2717 }
2718 logger->warn("Image content present but model has no vision "
2719 "capability — stripping image parts");
2720 return do_generate_text_only(strip_image_parts(messages), params);
2721}
2722
2729 const std::vector<Message>& messages,
2730 const GenerationParams& params)
2731{
2732 auto t0 = entropic::log::now();
2733 std::string prompt = render_prompt(messages, params);
2734 auto tokens = tokenize(prompt, true);
2735 std::string sys = extract_system_prompt(messages);
2736
2737 logger->info("Generate: {} input tokens, max_tokens={}",
2738 tokens.size(), params.max_tokens);
2739 log_sampler_config(params);
2740
2741 // v2.3.10: Sampler seam.
2742 auto sampler = create_sampler(params);
2743 if (!sampler) { return sampler_init_error(t0); }
2744
2745 if (!run_prefill_cached(tokens, sys, messages, params)) {
2746 return prefill_error();
2747 }
2748
2749 GenerationResult result;
2750 std::string generated;
2751 int n_generated = 0;
2752 std::function<void(std::string_view)> no_cb = nullptr;
2753 const auto stop = effective_stop(params); // gh#105: per-call sequential marker
2754
2755 while (n_generated < params.max_tokens) {
2756 auto status = step_token(
2757 *sampler, generated, no_cb, stop);
2758 if (status == "continue") { ++n_generated; }
2759 else {
2760 result.finish_reason =
2761 (status == "error") ? "error" : "stop";
2762 if (status == "error") {
2764 }
2765 break;
2766 }
2767 }
2768
2769 finalize_generation(result, generated, n_generated, params, t0);
2770 return result;
2771}
2772
2784 const std::vector<Message>& messages,
2785 const GenerationParams& params,
2786 std::atomic<bool>& cancel)
2787{
2788 if (!any_image_in(messages)) {
2789 return do_generate_text_only(messages, params, cancel);
2790 }
2791 if (has_vision_ && mtmd_ctx_ != nullptr) {
2792 return generate_multimodal(messages, params, nullptr, &cancel);
2793 }
2794 logger->warn("Image content present but model has no vision "
2795 "capability — stripping image parts");
2796 return do_generate_text_only(strip_image_parts(messages), params, cancel);
2797}
2798
2819 const std::vector<Message>& messages,
2820 const GenerationParams& params,
2821 std::atomic<bool>& cancel)
2822{
2823 auto t0 = entropic::log::now();
2824 std::string prompt = render_prompt(messages, params);
2825 auto tokens = tokenize(prompt, true);
2826 std::string sys = extract_system_prompt(messages);
2827
2828 logger->info("Generate (cancellable): {} input tokens, max_tokens={}",
2829 tokens.size(), params.max_tokens);
2830 log_sampler_config(params);
2831
2832 auto sampler = create_sampler(params);
2833 if (!sampler) { return sampler_init_error(t0); }
2834
2835 if (!run_prefill_cached(tokens, sys, messages, params)) {
2836 return prefill_error();
2837 }
2838
2839 GenerationResult result;
2840 std::string generated;
2841 int n_generated = 0;
2842 std::function<void(std::string_view)> no_cb = nullptr;
2843
2844 const auto stop = effective_stop(params); // gh#105: per-call sequential marker
2845 while (n_generated < params.max_tokens) {
2846 if (cancel.load(std::memory_order_acquire)) {
2847 result.finish_reason = "cancelled";
2849 break;
2850 }
2851 auto status = step_token(
2852 *sampler, generated, no_cb, stop);
2853 if (status == "continue") { ++n_generated; }
2854 else {
2855 result.finish_reason =
2856 (status == "error") ? "error" : "stop";
2857 if (status == "error") {
2859 }
2860 break;
2861 }
2862 }
2863
2864 finalize_generation(result, generated, n_generated, params, t0);
2865 return result;
2866}
2867
2879 const std::vector<Message>& messages,
2880 const GenerationParams& params,
2881 std::function<void(std::string_view token)> on_token,
2882 std::atomic<bool>& cancel)
2883{
2884 if (!any_image_in(messages)) {
2886 messages, params, on_token, cancel);
2887 }
2888 if (has_vision_ && mtmd_ctx_ != nullptr) {
2889 return generate_multimodal(messages, params, on_token, &cancel);
2890 }
2891 logger->warn("Image content present but model has no vision "
2892 "capability — stripping image parts");
2894 strip_image_parts(messages), params, on_token, cancel);
2895}
2896
2909 const std::vector<Message>& messages,
2910 const GenerationParams& params,
2911 std::function<void(std::string_view token)> on_token,
2912 std::atomic<bool>& cancel)
2913{
2914 auto t0 = entropic::log::now();
2915 auto prompt = render_prompt(messages, params);
2916 auto tokens = tokenize(prompt, true);
2917 auto sys = extract_system_prompt(messages);
2918 logger->info("Stream: {} input tokens, max_tokens={}",
2919 tokens.size(), params.max_tokens);
2920 log_sampler_config(params);
2921
2922 // v2.3.10: Sampler seam.
2923 auto sampler = create_sampler(params);
2924 if (!sampler) { return sampler_init_error(t0); }
2925 if (!run_prefill_cached(tokens, sys, messages, params)) {
2926 return prefill_error();
2927 }
2928 GenerationResult result;
2929 std::string generated;
2930 int n_generated = 0;
2931 const auto stop = effective_stop(params); // gh#105: per-call sequential marker
2932 while (n_generated < params.max_tokens) {
2933 if (cancel.load(std::memory_order_acquire)) {
2934 result.finish_reason = "cancelled";
2936 break;
2937 }
2938 auto status = step_token(
2939 *sampler, generated, on_token, stop);
2940 if (status == "continue") { ++n_generated; }
2941 else {
2942 result.finish_reason =
2943 (status == "error") ? "error" : "stop";
2944 if (status == "error") {
2946 }
2947 break;
2948 }
2949 }
2950 finalize_generation(result, generated, n_generated, params, t0);
2951 return result;
2952}
2953
2967 const std::vector<Message>& /*messages*/,
2968 const GenerationParams& /*params*/,
2969 std::function<void(std::string_view)> /*on_token*/,
2970 std::atomic<bool>& /*cancel*/)
2971{
2972 GenerationResult result;
2974 result.error_message =
2975 "LlamaCppBackend speculative requires an explicit draft "
2976 "backend handle — orchestrator dispatches via "
2977 "generate_speculative_with_draft";
2978 result.finish_reason = "error";
2979 return result;
2980}
2981
2982namespace {
2983
2999static void apply_grammar_source(
3000 common_params_sampling& cps,
3001 const GenerationParams& params,
3002 const std::string& tool_grammar,
3003 bool tool_grammar_lazy,
3004 const std::string& generation_prompt) {
3005 // gh#108 (v2.10.0): propagate GBNF grammar to the MTP sampler chain so
3006 // grammar-constrained tiers are correctly enforced under speculative.mtp.
3007 //
3008 // gh#134 (v2.10.4): a SECOND grammar source now reaches here — the
3009 // tool-call GBNF common_chat_templates_apply derives from the staged tool
3010 // schemas, which was previously discarded at the render. Precedence is
3011 // fail-loud rather than a silent pick: an explicit request grammar
3012 // alongside a tool-derived one is a config error, because the two
3013 // constrain the output to different languages and quietly honouring one
3014 // would make the other's absence undiagnosable.
3015 if (grammar_sources_collide(params.grammar, tool_grammar)) {
3016 logger->error(
3017 "Both a request grammar and a tool-call grammar are active. These "
3018 "constrain decoding to different languages and cannot compose; the "
3019 "request grammar is being used and the staged tools will NOT be "
3020 "structurally enforced. Drop one: clear params.grammar to let the "
3021 "tool schemas constrain, or unstage tools to use your grammar.");
3022 }
3023 const auto source = resolve_grammar_source(params.grammar, tool_grammar);
3024 if (source == GrammarSource::request) {
3025 cps.grammar = common_grammar(COMMON_GRAMMAR_TYPE_USER, params.grammar);
3026 } else if (source == GrammarSource::tool_call) {
3027 // TOOL_CALLS, not USER: common_grammar_needs_prefill() is true for this
3028 // type, so the sampler prefills generation_prompt into the grammar —
3029 // required because the model's output starts mid-template. The USER
3030 // type skips prefill and the grammar would reject from token one.
3031 cps.grammar = common_grammar(COMMON_GRAMMAR_TYPE_TOOL_CALLS,
3032 tool_grammar);
3033 cps.grammar_lazy = tool_grammar_lazy;
3034 // REQUIRED, not optional: common_sampler_init only prefills when
3035 // generation_prompt is non-empty (sampling.cpp:268, :285). Without it
3036 // the grammar sampler never accepts the tokens the chat template
3037 // already placed in the prompt, so it starts mid-rule and rejects the
3038 // very first sampled token — surfacing as GENERATE_FAILED with empty
3039 // content rather than as a grammar error. Measured exactly that on
3040 // gemma4 before this line existed.
3041 cps.generation_prompt = generation_prompt;
3042 // gh#138: the ONLY observable that the tool-call grammar is in force.
3043 // The orchestration line reports params.grammar, which is the REQUEST
3044 // grammar — it prints "unconstrained" even when this grammar is
3045 // active, so a consumer reading logs cannot tell the difference
3046 // between "tool grammar applied" and "nothing constrained the
3047 // decode". INFO, not DEBUG: this is the diagnostic that would have
3048 // answered gh#138 from the reporter's own logs.
3049 logger->info(
3050 "Tool-call grammar applied: {} bytes, lazy={}, prefill={} bytes",
3051 tool_grammar.size(), tool_grammar_lazy,
3052 generation_prompt.size());
3053 } else if (!tool_grammar.empty()) {
3054 // Tools were staged and a grammar derived, but the request grammar
3055 // won the precedence rule — say so rather than letting the tool
3056 // grammar vanish silently.
3057 logger->warn(
3058 "Tool-call grammar ({} bytes) NOT applied — an explicit request "
3059 "grammar takes precedence. Tool calls are not structurally "
3060 "enforced this turn.",
3061 tool_grammar.size());
3062 }
3063}
3064
3091common_params_sampling to_common_sampling(
3092 const GenerationParams& params,
3093 const std::string& tool_grammar,
3094 bool tool_grammar_lazy,
3095 const std::string& generation_prompt) {
3096 common_params_sampling cps;
3097 cps.temp = params.temperature;
3098 apply_grammar_source(cps, params, tool_grammar, tool_grammar_lazy,
3099 generation_prompt);
3100 cps.top_k = params.top_k;
3101 cps.top_p = params.top_p;
3102 cps.penalty_repeat = params.repeat_penalty;
3103 // gh#23 MVP items 2 + 3 (v2.3.14 + v2.3.15): wire presence +
3104 // frequency penalty into common-sampling. Counterparts of the
3105 // 3rd + 4th args to `llama_sampler_init_penalties` in the plain
3106 // decode path. Default 0.0f on both preserves bit-for-bit
3107 // speculative output.
3108 cps.penalty_freq = params.frequency_penalty;
3109 cps.penalty_present = params.presence_penalty;
3110 // gh#23 MVP item 4 (v2.3.16): forward logit_bias to common-sampling.
3111 // Empty (default) leaves the speculative chain bit-for-bit
3112 // identical to pre-v2.3.16.
3113 for (auto& [tok, val] : params.logit_bias) {
3114 cps.logit_bias.push_back({tok, val});
3115 }
3116 if (params.seed >= 0) {
3117 cps.seed = static_cast<uint32_t>(params.seed);
3118 }
3119 cps.no_perf = true;
3120 // Mirror entropic's standard sampler chain ordering so the
3121 // speculative path produces output bit-identical to plain decode
3122 // (the v2.1.11 correctness contract). Entropic's `create_sampler`
3123 // builds: penalties → top_k → top_p → min_p → temperature → dist,
3124 // AND SKIPS the temperature sampler when temp == 0 (greedy mode).
3125 // common_sampler appends an extended-temperature sampler that
3126 // differs subtly from "no temp at all" — we omit it for temp=0
3127 // to match entropic exactly. min_p (v2.3.10, gh#23) appended only
3128 // when caller opted in (params.min_p > 0); 0.0 preserves the
3129 // pre-v2.3.10 chain shape bit-for-bit. Other extended filters
3130 // (top_n_sigma, dry, xtc, typical_p) remain stripped.
3131 cps.samplers = {COMMON_SAMPLER_TYPE_PENALTIES,
3132 COMMON_SAMPLER_TYPE_TOP_K,
3133 COMMON_SAMPLER_TYPE_TOP_P};
3134 if (params.min_p > 0.0f) {
3135 cps.samplers.push_back(COMMON_SAMPLER_TYPE_MIN_P);
3136 }
3137 if (params.temperature > 0.0f) {
3138 cps.samplers.push_back(COMMON_SAMPLER_TYPE_TEMPERATURE);
3139 }
3140 cps.min_p = params.min_p;
3141 cps.dry_multiplier = 0.0f;
3142 cps.top_n_sigma = -1.0f;
3143 return cps;
3144}
3145
3162bool spec_prefill_minus_last(
3163 llama_context* ctx, const std::vector<llama_token>& tokens) {
3164 int total = static_cast<int>(tokens.size()) - 1;
3165 if (total <= 0) { return true; }
3166 int n_batch = llama_n_batch(ctx);
3167 for (int off = 0; off < total; off += n_batch) {
3168 int chunk = std::min(n_batch, total - off);
3169 llama_batch batch = llama_batch_get_one(
3170 const_cast<llama_token*>(tokens.data()) + off, chunk);
3171 if (llama_decode(ctx, batch) != 0) { return false; }
3172 }
3173 return true;
3174}
3175
3192GenerationResult spec_error(entropic_error_t code, std::string msg) {
3193 logger->error("Speculative decode failed ({}): {}",
3194 entropic_error_name(code), msg);
3195 GenerationResult r;
3196 r.error_code = code;
3197 r.error_message = std::move(msg);
3198 r.finish_reason = "error";
3199 return r;
3200}
3201
3202} // anonymous namespace
3203
3212 common_speculative* spec = nullptr;
3213 common_sampler* smpl = nullptr;
3214 llama_context* ctx_tgt = nullptr;
3215 llama_context* ctx_dft = nullptr;
3216 llama_batch batch_tgt{};
3217 bool batch_initialized = false;
3218 llama_seq_id seq_id = 0;
3219 int n_past = 0;
3220 llama_token id_last = 0;
3221 std::vector<llama_token> prompt_tgt;
3222 std::vector<llama_token> draft;
3223 std::string generated;
3224 std::vector<std::string> stop;
3225 int n_generated = 0;
3226 int n_drafted = 0;
3227 int n_accepted = 0;
3228 bool has_eos = false;
3229 std::string finish_reason;
3230 entropic_error_t error_code = ENTROPIC_OK;
3231 std::string error_message;
3232
3233 // ── Checkpoint state (v2.1.11) ──────────────────────────
3234 // Activated when either context reports FULL-only seq_rm
3235 // (no partial removal). The kernel saves+restores draft/target
3236 // state across each speculative round so the underlying
3237 // memory module never sees an attempted partial removal.
3238 // Mirrors the use_ckpt_tgt / use_ckpt_dft flow in upstream's
3239 // speculative-simple example.
3240 bool use_ckpt_tgt = false;
3241 bool use_ckpt_dft = false;
3242 common_prompt_checkpoint ckpt;
3243};
3244
3252 if (state.spec) { common_speculative_free(state.spec); }
3253 if (state.smpl) { common_sampler_free(state.smpl); }
3254 if (state.batch_initialized) {
3255 llama_batch_free(state.batch_tgt);
3256 }
3257}
3258
3266 common_batch_clear(state.batch_tgt);
3267 common_batch_add(state.batch_tgt, state.id_last,
3268 state.n_past, {state.seq_id}, true);
3269 int pos = state.n_past + 1;
3270 for (auto draft_token : state.draft) {
3271 common_batch_add(state.batch_tgt, draft_token, pos,
3272 {state.seq_id}, true);
3273 ++pos;
3274 }
3275}
3276
3285 spec_build_batch(state);
3286 int rc_tgt = llama_decode(state.ctx_tgt, state.batch_tgt);
3287 if (rc_tgt != 0) {
3288 logger->error("Speculative target decode failed: rc={}, "
3289 "n_past={}, draft_size={}",
3290 rc_tgt, state.n_past, state.draft.size());
3291 state.error_code = ENTROPIC_ERROR_GENERATE_FAILED;
3292 state.error_message = "target llama_decode failed";
3293 state.finish_reason = "error";
3294 return false;
3295 }
3296 int rc_dft = llama_decode(state.ctx_dft, state.batch_tgt);
3297 if (rc_dft != 0) {
3298 logger->error("Speculative draft decode failed: rc={}, "
3299 "n_past={}, draft_size={}",
3300 rc_dft, state.n_past, state.draft.size());
3301 state.error_code = ENTROPIC_ERROR_GENERATE_FAILED;
3302 state.error_message = "draft llama_decode failed";
3303 state.finish_reason = "error";
3304 return false;
3305 }
3306 return true;
3307}
3308
3316 auto& dp = common_speculative_get_draft_params(
3317 state.spec, state.seq_id);
3318 dp.drafting = true;
3319 dp.n_max = -1;
3320 dp.n_past = state.n_past;
3321 dp.id_last = state.id_last;
3322 dp.prompt = &state.prompt_tgt;
3323 dp.result = &state.draft;
3324 common_speculative_draft(state.spec);
3325 return static_cast<int>(state.draft.size());
3326}
3327
3341static std::string spec_emit_token(
3342 SpeculativeRunState& state, llama_token id,
3343 const llama_vocab* vocab, int max_tokens,
3344 std::function<void(std::string_view)>& on_token,
3345 std::atomic<bool>& cancel)
3346{
3347 std::string signal;
3348 state.prompt_tgt.push_back(state.id_last);
3349 state.id_last = id;
3350 state.n_generated++;
3351 if (llama_vocab_is_eog(vocab, id)) {
3352 state.has_eos = true;
3353 state.finish_reason = "stop";
3354 signal = "eos";
3355 } else {
3356 const std::string piece =
3357 common_token_to_piece(state.ctx_tgt, id);
3358 state.generated += piece;
3359 if (on_token) { on_token(piece); }
3360 // gh#108: honor stop sequences (params.stop + gh#103 sequential-tool
3361 // close marker) so MTP stops where plain decode would, instead of
3362 // over-generating past the first tool call. state.stop is empty for the
3363 // gh#36 path, so this is a no-op there.
3364 if (check_stop_sequences(state.generated, state.stop)) {
3365 state.finish_reason = "stop";
3366 signal = "stop";
3367 } else if (cancel.load(std::memory_order_acquire)) {
3368 state.error_code = ENTROPIC_ERROR_CANCELLED;
3369 state.finish_reason = "cancelled";
3370 signal = "cancel";
3371 } else if (state.n_generated >= max_tokens) {
3372 state.finish_reason = "length";
3373 signal = "length";
3374 }
3375 }
3376 return signal;
3377}
3378
3391 state.ckpt.update_pos(
3392 static_cast<int64_t>(state.prompt_tgt.size()),
3393 llama_memory_seq_pos_min(
3394 llama_get_memory(state.ctx_tgt), state.seq_id),
3395 llama_memory_seq_pos_max(
3396 llama_get_memory(state.ctx_tgt), state.seq_id));
3397 if (state.use_ckpt_dft) {
3398 state.ckpt.update_dft(state.ctx_dft, state.seq_id,
3399 LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY
3400 | LLAMA_STATE_SEQ_FLAGS_ON_DEVICE);
3401 }
3402}
3403
3411 if (state.use_ckpt_tgt && !state.draft.empty()) {
3412 state.ckpt.update_tgt(state.ctx_tgt, state.seq_id,
3413 LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY
3414 | LLAMA_STATE_SEQ_FLAGS_ON_DEVICE);
3415 }
3416}
3417
3425 constexpr auto flags = LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY
3426 | LLAMA_STATE_SEQ_FLAGS_ON_DEVICE;
3427 if (state.use_ckpt_dft) {
3428 state.ckpt.load_dft(state.ctx_dft, state.seq_id, flags);
3429 }
3430 llama_memory_seq_rm(llama_get_memory(state.ctx_dft),
3431 state.seq_id, state.ckpt.pos_max + 1, -1);
3432}
3433
3444 SpeculativeRunState& state, common_sampler* smpl_save,
3445 std::vector<llama_token>& ids) {
3446 constexpr auto flags = LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY
3447 | LLAMA_STATE_SEQ_FLAGS_ON_DEVICE;
3448 state.draft = std::move(ids);
3449 state.ckpt.load_tgt(state.ctx_tgt, state.seq_id, flags);
3450 llama_memory_seq_rm(llama_get_memory(state.ctx_tgt),
3451 state.seq_id, state.ckpt.pos_max + 1, -1);
3452 state.ckpt.load_dft(state.ctx_dft, state.seq_id, flags);
3453 llama_memory_seq_rm(llama_get_memory(state.ctx_dft),
3454 state.seq_id, state.ckpt.pos_max + 1, -1);
3455 state.prompt_tgt.resize(static_cast<size_t>(state.ckpt.n_tokens));
3456 state.n_past = static_cast<int>(state.prompt_tgt.size());
3457 // Sampler clone is non-null only when use_ckpt_tgt is set
3458 common_sampler_free(state.smpl);
3459 state.smpl = smpl_save;
3460}
3461
3487 llama_memory_seq_rm(llama_get_memory(state.ctx_tgt),
3488 state.seq_id, state.n_past, -1);
3489 llama_memory_seq_rm(llama_get_memory(state.ctx_dft),
3490 state.seq_id, state.n_past, -1);
3491}
3492
3500 SpeculativeRunState& state,
3501 const std::vector<llama_token>& ids,
3502 const llama_vocab* vocab, int max_tokens,
3503 std::function<void(std::string_view)>& on_token,
3504 std::atomic<bool>& cancel) {
3505 bool stop = false;
3506 for (auto id : ids) {
3507 auto signal = spec_emit_token(
3508 state, id, vocab, max_tokens, on_token, cancel);
3509 if (!signal.empty()) { stop = true; break; }
3510 }
3511 return stop;
3512}
3513
3535 // Skip drafting if the previous round restored a partial accept
3536 // into state.draft (carry-over from rollback).
3537 if (!state.draft.empty()) {
3538 return static_cast<int>(state.draft.size());
3539 }
3540 spec_ckpt_save_dft(state);
3541 int drafted = spec_run_draft(state);
3542 spec_ckpt_save_tgt(state);
3543 spec_ckpt_restore_dft(state);
3544 return drafted;
3545}
3546
3553 SpeculativeRunState& state,
3554 const llama_vocab* vocab,
3555 int max_tokens,
3556 std::function<void(std::string_view)>& on_token,
3557 std::atomic<bool>& cancel)
3558{
3559 int draft_size_before = spec_prepare_draft(state);
3560
3561 if (!spec_decode_both(state)) { return false; }
3562
3563 common_sampler* smpl_save = nullptr;
3564 if (state.use_ckpt_tgt) {
3565 smpl_save = common_sampler_clone(state.smpl);
3566 }
3567 auto ids = common_sampler_sample_and_accept_n(
3568 state.smpl, state.ctx_tgt, state.draft);
3569 int accepted = static_cast<int>(ids.size()) - 1;
3570 if (accepted < 0) { accepted = 0; }
3571
3572 // Partial acceptance on a FULL-seq_rm context: rollback to
3573 // checkpoint, set draft = accepted, re-loop without emitting.
3574 if (state.use_ckpt_tgt
3575 && static_cast<int>(ids.size()) - 1
3576 < static_cast<int>(state.draft.size())) {
3577 spec_rollback_partial(state, smpl_save, ids);
3578 return true;
3579 }
3580 if (smpl_save) { common_sampler_free(smpl_save); }
3581
3582 common_speculative_accept(state.spec, state.seq_id, accepted);
3583 state.n_drafted += draft_size_before;
3584 state.n_accepted += accepted;
3585 // n_past advances by ids.size() total: one slot for id_last
3586 // (the post-id_last position the next id will occupy), plus
3587 // `accepted` slots for the drafted tokens the sampler agreed
3588 // with. Matches speculative-simple's n_past++ in batch_add +
3589 // n_past += ids.size() - 1 sequence.
3590 state.n_past += static_cast<int>(ids.size());
3591
3592 bool stop = spec_commit_accepted(
3593 state, ids, vocab, max_tokens, on_token, cancel);
3594 state.draft.clear();
3596 return !stop;
3597}
3598
3611static std::string spec_check_preconditions(
3612 bool target_active, bool draft_active,
3613 llama_context* ctx_tgt, llama_context* ctx_dft) {
3614 // Defense-in-depth arch gate — orchestrator's
3615 // check_speculative_compat is the primary gate; a direct caller
3616 // into the kernel must also be refused on recurrent / hybrid
3617 // targets (Session 5 Gate A: hybrid SSM state diverges across
3618 // split-prefill boundaries; bit-identical unreachable at this pin).
3619 std::string err;
3620 const llama_model* model_tgt = llama_get_model(ctx_tgt);
3621 int cap_tgt = common_context_can_seq_rm(ctx_tgt);
3622 int cap_dft = common_context_can_seq_rm(ctx_dft);
3623 logger->info("Speculative seq_rm capability: target={}, draft={} "
3624 "(0=NO, 1=PART, 2=FULL)", cap_tgt, cap_dft);
3625 if (!target_active || !draft_active) {
3626 err = "speculative requires ACTIVE target + draft";
3627 } else if (llama_model_is_recurrent(model_tgt)
3628 || llama_model_is_hybrid(model_tgt)) {
3629 err = "speculative refused: architecture (target is "
3630 "recurrent or hybrid; see proposal Implementation "
3631 "Log Gate A)";
3632 } else if (cap_tgt == COMMON_CONTEXT_SEQ_RM_TYPE_NO
3633 || cap_dft == COMMON_CONTEXT_SEQ_RM_TYPE_NO) {
3634 // NO is the only unsupported seq_rm case — the kernel has
3635 // both PART fast-path and FULL checkpoint paths.
3636 err = "speculative kernel requires at least FULL seq_rm "
3637 "(target/draft reported NO seq_rm at all)";
3638 }
3639 return err;
3640}
3641
3671 SpeculativeRunState& state, llama_model* model_tgt,
3672 const GenerationParams& params, int n_draft_max,
3673 const std::string& draft_path,
3674 const std::string& tool_grammar, // gh#134 (v2.10.4)
3675 bool tool_grammar_lazy,
3676 const std::string& generation_prompt) {
3677 auto common_sampling =
3678 to_common_sampling(params, tool_grammar, tool_grammar_lazy,
3679 generation_prompt);
3680 state.smpl = common_sampler_init(model_tgt, common_sampling);
3681 if (!state.smpl) { return "common_sampler_init failed"; }
3682
3683 common_params_speculative spec_params;
3684 spec_params.types = {COMMON_SPECULATIVE_TYPE_DRAFT_SIMPLE};
3685 spec_params.draft.n_max = (n_draft_max > 0) ? n_draft_max : 16;
3686 spec_params.draft.ctx_tgt = state.ctx_tgt;
3687 spec_params.draft.ctx_dft = state.ctx_dft;
3688 // Upstream gates DRAFT_SIMPLE on a non-empty draft path
3689 // (see common/speculative.cpp:875). Required even though we
3690 // provide already-loaded contexts.
3691 spec_params.draft.mparams.path = draft_path;
3692 state.spec = common_speculative_init(spec_params, 1);
3693 if (!state.spec) {
3694 common_sampler_free(state.smpl);
3695 state.smpl = nullptr;
3696 return "common_speculative_init failed";
3697 }
3698
3699 common_speculative_begin(state.spec, state.seq_id, state.prompt_tgt);
3700 state.batch_tgt = llama_batch_init(llama_n_batch(state.ctx_tgt), 0, 1);
3701 state.batch_initialized = true;
3702 // Checkpoint flow lights up when either context can only do
3703 // FULL-sequence removal. Mirrors speculative-simple's
3704 // use_ckpt_{tgt,dft}.
3705 state.use_ckpt_tgt = common_context_can_seq_rm(state.ctx_tgt)
3706 == COMMON_CONTEXT_SEQ_RM_TYPE_FULL;
3707 state.use_ckpt_dft = common_context_can_seq_rm(state.ctx_dft)
3708 == COMMON_CONTEXT_SEQ_RM_TYPE_FULL;
3709 return "";
3710}
3711
3717static std::string spec_init_run(
3718 SpeculativeRunState& state, llama_model* model_tgt,
3719 const std::vector<llama_token>& tokens,
3720 const GenerationParams& params, int n_draft_max,
3721 const std::string& draft_path,
3722 const std::string& tool_grammar, bool tool_grammar_lazy,
3723 const std::string& generation_prompt) {
3724 state.id_last = tokens.back();
3725 state.prompt_tgt.assign(tokens.begin(), tokens.end() - 1);
3726 state.n_past = static_cast<int>(tokens.size()) - 1;
3727
3728 llama_memory_clear(llama_get_memory(state.ctx_tgt), true);
3729 llama_memory_clear(llama_get_memory(state.ctx_dft), true);
3730
3731 if (!spec_prefill_minus_last(state.ctx_tgt, tokens)
3732 || !spec_prefill_minus_last(state.ctx_dft, tokens)) {
3733 return "speculative prefill failed";
3734 }
3736 state, model_tgt, params, n_draft_max, draft_path,
3737 tool_grammar, tool_grammar_lazy, generation_prompt);
3738}
3739
3745static void spec_run_loop(
3746 SpeculativeRunState& state, const llama_vocab* vocab,
3747 int max_tokens,
3748 std::function<void(std::string_view)>& on_token,
3749 std::atomic<bool>& cancel) {
3750 while (state.n_generated < max_tokens) {
3751 if (cancel.load(std::memory_order_acquire)) {
3752 state.error_code = ENTROPIC_ERROR_CANCELLED;
3753 state.finish_reason = "cancelled";
3754 break;
3755 }
3756 if (!spec_accept_round(state, vocab, max_tokens,
3757 on_token, cancel)) {
3758 break;
3759 }
3760 }
3761 if (state.finish_reason.empty()) {
3762 state.finish_reason = (state.n_generated >= max_tokens)
3763 ? "length" : "stop";
3764 }
3765}
3766
3779 SpeculativeRunState& state,
3780 std::chrono::steady_clock::time_point t0) {
3781 GenerationResult result;
3782 // gh#136 (4th type_error.316 recurrence): sanitize at INGRESS, where model
3783 // bytes first become a std::string, rather than at each of the ~18 .dump()
3784 // sites downstream. gh#112/113 were closed as "permanent closure of the
3785 // 316 family", then gh#114, gh#118, gh#132 and gh#136 each patched one more
3786 // egress. The exits keep multiplying; the entries do not. Guarding here
3787 // makes every downstream dump safe by construction, and a NEW .dump()
3788 // anywhere cannot reintroduce the bug.
3789 //
3790 // Once at finalization, never per-token: a multi-byte codepoint can split
3791 // across token boundaries and per-token sanitize would corrupt valid
3792 // output (see the same reasoning at response_generator.cpp:360, which has
3793 // guarded the streaming accumulator this way since v2.1.1).
3794 //
3795 // Safe for raw_content consumers: raw_content is a COPY of content
3796 // (orchestrator.cpp:514), and sanitize only replaces bytes that are
3797 // ALREADY invalid UTF-8 — which cannot form part of any valid JSON token,
3798 // so the gh#88 envelope recovery and fenced-JSON fallbacks are unaffected.
3799 result.content = entropic::mcp::sanitize_utf8(state.generated);
3800 result.token_count = state.n_generated;
3801 result.finish_reason = state.finish_reason;
3802 result.error_code = state.error_code;
3803 result.error_message = state.error_message;
3804 // gh#106: surface the draft/accept counts so callers (and the MTP
3805 // engagement test) can verify the kernel actually ran + accepted.
3806 result.n_drafted = state.n_drafted;
3807 result.n_accepted = state.n_accepted;
3808 result.generation_time_ms =
3809 entropic::log::elapsed_ms(t0, entropic::log::now());
3810 // gh#108: the speculative path previously left throughput_tok_s=0.0 — the
3811 // one metric the feature exists for. Compute it like finalize_result.
3812 if (result.token_count > 0 && result.generation_time_ms > 0.0) {
3813 result.throughput_tok_s =
3814 static_cast<double>(result.token_count)
3815 / result.generation_time_ms * 1000.0;
3816 }
3817 if (state.n_drafted > 0) {
3818 const float accept_rate =
3819 static_cast<float>(state.n_accepted)
3820 / static_cast<float>(state.n_drafted);
3821 logger->info("Speculative: generated={}, drafted={}, "
3822 "accepted={}, accept_rate={:.3f}",
3823 state.n_generated, state.n_drafted,
3824 state.n_accepted, accept_rate);
3825 }
3826 spec_cleanup(state);
3827 return result;
3828}
3829
3871 llama_context* ctx_tgt, llama_context* ctx_dft, llama_model* model_tgt,
3872 const std::vector<llama_token>& tokens, const GenerationParams& params,
3873 std::function<void(std::string_view)>& on_token,
3874 std::atomic<bool>& cancel, int n_draft_max,
3875 const std::string& draft_path,
3876 std::chrono::steady_clock::time_point t0,
3877 const std::string& tool_grammar, bool tool_grammar_lazy,
3878 const std::string& generation_prompt) {
3879 SpeculativeRunState state;
3880 state.ctx_tgt = ctx_tgt;
3881 state.ctx_dft = ctx_dft;
3882 auto init_err = spec_init_run(state, model_tgt, tokens, params,
3883 n_draft_max, draft_path,
3884 tool_grammar, tool_grammar_lazy,
3885 generation_prompt);
3886 if (!init_err.empty()) {
3887 spec_cleanup(state);
3888 return spec_error(ENTROPIC_ERROR_GENERATE_FAILED,
3889 std::move(init_err));
3890 }
3891 spec_run_loop(state, llama_model_get_vocab(model_tgt),
3892 params.max_tokens, on_token, cancel);
3893 return spec_finalize(state, t0);
3894}
3895
3902 const std::vector<Message>& messages,
3903 const GenerationParams& params,
3904 std::function<void(std::string_view)> on_token,
3905 std::atomic<bool>& cancel,
3906 LlamaCppBackend& draft,
3907 int n_draft_max,
3908 const std::string& draft_path)
3909{
3910 auto t0 = entropic::log::now();
3911 invalidate_resident_kv(); // gh#96: speculative path manages seq 0 itself
3912 auto pre_err = spec_check_preconditions(
3913 is_active(), draft.is_active(), ctx_, draft.ctx_);
3914 GenerationResult result;
3915 if (!pre_err.empty()) {
3916 entropic_error_t code =
3917 (pre_err.find("requires ACTIVE") != std::string::npos)
3920 result = spec_error(code, std::move(pre_err));
3921 } else {
3922 auto prompt = render_prompt(messages, params);
3923 auto tokens = tokenize(prompt, true);
3924 if (tokens.size() < 2) {
3925 result = spec_error(ENTROPIC_ERROR_GENERATE_FAILED,
3926 "speculative prompt must have at least 2 tokens");
3927 } else {
3928 logger->info("Speculative: {} input tokens, max_tokens={}, "
3929 "n_draft_max={}",
3930 tokens.size(), params.max_tokens, n_draft_max);
3931 result = spec_run_from_tokens(
3932 ctx_, draft.ctx_, model_, tokens, params, on_token,
3933 cancel, n_draft_max, draft_path, t0,
3935 }
3936 }
3937 return result;
3938}
3939
3940// ── gh#106 (v2.9.0): target-owned MTP speculative kernel ───────────
3941//
3942// Distinct from the gh#36 separate-draft kernel above. The MTP head
3943// (ctx_dft) shares the target's KV via ctx_other, so the CALLER only
3944// ever decodes ctx_tgt; the impl owns every ctx_dft decode. The loop is
3945// draft → decode(ctx_tgt) → process → sample_and_accept_n → accept,
3946// mirroring extern/llama.cpp/tools/server/server-context.cpp. Reuses the
3947// gh#36 file-local helpers (spec_build_batch / spec_emit_token /
3948// spec_commit_accepted / spec_trim_rejected_drafts / spec_finalize /
3949// spec_cleanup / spec_error / to_common_sampling) — only the decode step
3950// and the prefill differ. NO checkpoint dance: shared-KV gemma4 targets
3951// are PART-seq_rm, so the FULL-only rollback path never applies.
3952
3953namespace {
3954
3965int mtp_run_draft(SpeculativeRunState& state, int n_max) {
3966 auto& dp = common_speculative_get_draft_params(state.spec, state.seq_id);
3967 dp.drafting = true;
3968 dp.n_max = n_max;
3969 dp.n_past = state.n_past;
3970 dp.id_last = state.id_last;
3971 dp.prompt = &state.prompt_tgt;
3972 dp.result = &state.draft;
3973 common_speculative_draft(state.spec);
3974 return static_cast<int>(state.draft.size());
3975}
3976
3988bool mtp_decode_and_process(SpeculativeRunState& state) {
3989 spec_build_batch(state); // [id_last@n_past, draft@n_past+1 ...]
3990 if (llama_decode(state.ctx_tgt, state.batch_tgt) != 0) {
3991 state.error_code = ENTROPIC_ERROR_GENERATE_FAILED;
3992 state.error_message = "MTP target decode failed";
3993 state.finish_reason = "error";
3994 return false;
3995 }
3996 if (!common_speculative_process(state.spec, state.batch_tgt)) {
3997 state.error_code = ENTROPIC_ERROR_GENERATE_FAILED;
3998 state.error_message = "common_speculative_process failed";
3999 state.finish_reason = "error";
4000 return false;
4001 }
4002 return true;
4003}
4004
4011bool mtp_accept_round(
4012 SpeculativeRunState& state, int n_max, const llama_vocab* vocab,
4013 int max_tokens, std::function<void(std::string_view)>& on_token,
4014 std::atomic<bool>& cancel) {
4015 int drafted = mtp_run_draft(state, n_max);
4016 if (!mtp_decode_and_process(state)) { return false; }
4017 auto ids = common_sampler_sample_and_accept_n(
4018 state.smpl, state.ctx_tgt, state.draft);
4019 int accepted = static_cast<int>(ids.size()) - 1;
4020 if (accepted < 0) { accepted = 0; }
4021 // gh#108: only accept into the spec when this round actually drafted.
4022 // common_speculative_accept asserts impl_last[seq] (speculative.cpp:1650),
4023 // which is set ONLY for a non-empty draft (1604/1614) — a zero-draft round
4024 // would abort. The round still progresses by one token (the bonus in ids),
4025 // and process() already updated pending_h, so skipping accept is equivalent.
4026 if (drafted > 0) {
4027 common_speculative_accept(state.spec, state.seq_id, accepted);
4028 }
4029 state.n_drafted += drafted;
4030 state.n_accepted += accepted;
4031 // Same layout as gh#36: id_last fills one slot at n_past, the
4032 // `accepted` drafts fill the next slots — n_past advances by ids.size().
4033 state.n_past += static_cast<int>(ids.size());
4034 bool stop = spec_commit_accepted(
4035 state, ids, vocab, max_tokens, on_token, cancel);
4036 state.draft.clear();
4038 return !stop;
4039}
4040
4049bool mtp_process_chunk(SpeculativeRunState& state, int off, int chunk) {
4050 common_batch_clear(state.batch_tgt);
4051 for (int j = 0; j < chunk; ++j) {
4052 common_batch_add(state.batch_tgt, state.prompt_tgt[off + j],
4053 off + j, {state.seq_id}, false);
4054 }
4055 if (llama_decode(state.ctx_tgt, state.batch_tgt) != 0) { return false; }
4056 return common_speculative_process(state.spec, state.batch_tgt);
4057}
4058
4066bool mtp_prefill_and_seed(SpeculativeRunState& state) {
4067 int total = static_cast<int>(state.prompt_tgt.size());
4068 if (total == 0) { return true; } // 1-token prompt: round 1 drafts cold
4069 int n_batch = llama_n_batch(state.ctx_tgt);
4070 for (int off = 0; off < total; off += n_batch) {
4071 int chunk = std::min(n_batch, total - off);
4072 if (!mtp_process_chunk(state, off, chunk)) { return false; }
4073 }
4074 return true;
4075}
4076
4086std::string mtp_init_decoder(
4087 SpeculativeRunState& state, llama_model* model_tgt,
4088 const GenerationParams& params, int n_max,
4089 const std::string& tool_grammar, // gh#134 (v2.10.4)
4090 bool tool_grammar_lazy,
4091 const std::string& generation_prompt) {
4092 auto common_sampling =
4093 to_common_sampling(params, tool_grammar, tool_grammar_lazy,
4094 generation_prompt);
4095 state.smpl = common_sampler_init(model_tgt, common_sampling);
4096 if (!state.smpl) { return "common_sampler_init failed"; }
4097 common_params_speculative sp;
4098 sp.types = {COMMON_SPECULATIVE_TYPE_DRAFT_MTP};
4099 sp.draft.n_max = n_max;
4100 sp.draft.ctx_tgt = state.ctx_tgt;
4101 sp.draft.ctx_dft = state.ctx_dft;
4102 state.spec = common_speculative_init(sp, 1);
4103 if (!state.spec) {
4104 common_sampler_free(state.smpl);
4105 state.smpl = nullptr;
4106 return "common_speculative_init (MTP) failed";
4107 }
4108 state.batch_tgt = llama_batch_init(llama_n_batch(state.ctx_tgt), 0, 1);
4109 state.batch_initialized = true;
4110 return "";
4111}
4112
4122std::string mtp_init_run(
4123 SpeculativeRunState& state, llama_model* model_tgt,
4124 const std::vector<llama_token>& tokens,
4125 const GenerationParams& params, int n_max,
4126 const std::string& tool_grammar, bool tool_grammar_lazy,
4127 const std::string& generation_prompt) {
4128 state.id_last = tokens.back();
4129 state.prompt_tgt.assign(tokens.begin(), tokens.end() - 1);
4130 state.n_past = static_cast<int>(tokens.size()) - 1;
4131 llama_memory_clear(llama_get_memory(state.ctx_tgt), true);
4132 auto err = mtp_init_decoder(state, model_tgt, params, n_max,
4133 tool_grammar, tool_grammar_lazy,
4134 generation_prompt);
4135 if (!err.empty()) { return err; }
4136 if (!mtp_prefill_and_seed(state)) { return "MTP prefill/process failed"; }
4137 common_speculative_begin(state.spec, state.seq_id, state.prompt_tgt);
4138 return "";
4139}
4140
4146void mtp_run_loop(
4147 SpeculativeRunState& state, int n_max, const llama_vocab* vocab,
4148 int max_tokens, std::function<void(std::string_view)>& on_token,
4149 std::atomic<bool>& cancel) {
4150 while (state.n_generated < max_tokens) {
4151 if (cancel.load(std::memory_order_acquire)) {
4152 state.error_code = ENTROPIC_ERROR_CANCELLED;
4153 state.finish_reason = "cancelled";
4154 break;
4155 }
4156 if (!mtp_accept_round(state, n_max, vocab, max_tokens,
4157 on_token, cancel)) {
4158 break;
4159 }
4160 }
4161 if (state.finish_reason.empty()) {
4162 state.finish_reason = (state.n_generated >= max_tokens)
4163 ? "length" : "stop";
4164 }
4165}
4166
4172GenerationResult mtp_run_from_tokens(
4173 llama_context* ctx_tgt, llama_context* ctx_dft, llama_model* model_tgt,
4174 const std::vector<llama_token>& tokens, const GenerationParams& params,
4175 std::function<void(std::string_view)>& on_token,
4176 std::atomic<bool>& cancel, int n_max,
4177 const std::vector<std::string>& stop,
4178 std::chrono::steady_clock::time_point t0,
4179 const std::string& tool_grammar, bool tool_grammar_lazy,
4180 const std::string& generation_prompt) {
4181 SpeculativeRunState state;
4182 state.ctx_tgt = ctx_tgt;
4183 state.ctx_dft = ctx_dft;
4184 state.stop = stop; // gh#108: MTP honors stop sequences (effective_stop)
4185 auto init_err = mtp_init_run(state, model_tgt, tokens, params, n_max,
4186 tool_grammar, tool_grammar_lazy,
4187 generation_prompt);
4188 if (!init_err.empty()) {
4189 spec_cleanup(state);
4190 return spec_error(ENTROPIC_ERROR_GENERATE_FAILED,
4191 std::move(init_err));
4192 }
4193 mtp_run_loop(state, n_max, llama_model_get_vocab(model_tgt),
4194 params.max_tokens, on_token, cancel);
4195 return spec_finalize(state, t0);
4196}
4197
4198} // anonymous namespace
4199
4228 const GenerationParams& params,
4229 const std::function<void(std::string_view)>& on_token,
4230 const std::string& head_path, int n_max) {
4231 GenerationResult r; // ENTROPIC_OK by default → proceed
4232 std::string reason = mtp_unsupported_reason(
4233 params.temperature, !params.grammar.empty(),
4234 static_cast<bool>(on_token));
4235 if (!is_active()) {
4236 r = spec_error(ENTROPIC_ERROR_INVALID_STATE,
4237 "MTP requires an ACTIVE target");
4238 } else if (!reason.empty()) {
4239 r = spec_error(ENTROPIC_ERROR_SPECULATIVE_INCOMPATIBLE_CONFIG, reason);
4240 } else if (1 + effective_n_draft(n_max) > llama_n_batch(ctx_)) {
4241 // Bound-check BEFORE setup_mtp_draft allocates. Ordering this the
4242 // other way made the guard's ANSWER depend on process history: an
4243 // out-of-envelope window (e.g. n_draft=100000) is still handed to
4244 // setup_mtp_draft, whose allocation SUCCEEDS in a fresh process —
4245 // so the guard fell through and returned the correct
4246 // SPECULATIVE_INCOMPATIBLE_CONFIG — but FAILS after a prior MTP
4247 // session, returning LOAD_FAILED for the same config. Same input,
4248 // two different typed errors, decided by allocator state. Measured
4249 // as `5 == 54` when a second MTP case ran in one process.
4250 //
4251 // Must use the same normalisation setup_mtp_draft applies, or the
4252 // check disagrees with the value actually used.
4254 "speculative.n_draft+1 ("
4255 + std::to_string(1 + effective_n_draft(n_max))
4256 + ") exceeds n_batch (" + std::to_string(llama_n_batch(ctx_))
4257 + "); reduce n_draft or raise n_batch");
4258 } else if (!setup_mtp_draft(head_path, n_max)) {
4259 r = spec_error(ENTROPIC_ERROR_LOAD_FAILED, last_error_);
4260 }
4261 return r;
4262}
4263
4285 const std::vector<Message>& messages,
4286 const GenerationParams& params,
4287 std::function<void(std::string_view)> on_token,
4288 std::atomic<bool>& cancel,
4289 const std::string& head_path,
4290 int n_max)
4291{
4292 auto t0 = entropic::log::now();
4293 std::lock_guard<std::mutex> lk(mtp_mutex_); // serialise vs teardown
4294 GenerationResult result = mtp_guard(params, on_token, head_path, n_max);
4295 if (result.error_code != ENTROPIC_OK) {
4296 return result;
4297 }
4298 invalidate_resident_kv(); // MTP kernel owns seq 0 itself
4299 auto tokens = tokenize(render_prompt(messages, params), true);
4300 if (tokens.size() < 2) {
4301 return spec_error(ENTROPIC_ERROR_GENERATE_FAILED,
4302 "MTP prompt must have at least 2 tokens");
4303 }
4304 logger->info("MTP: {} input tokens, max_tokens={}, n_max={}",
4305 tokens.size(), params.max_tokens, mtp_n_max_);
4306 return mtp_run_from_tokens(ctx_, mtp_draft_ctx_, model_, tokens, params,
4307 on_token, cancel, mtp_n_max_,
4308 effective_stop(params), t0, // gh#108: honor stops
4311}
4312
4322 const std::string& prompt,
4323 const GenerationParams& params)
4324{
4325 auto t0 = entropic::log::now();
4326 invalidate_resident_kv(); // gh#96: decode_loop/run_prefill mutate seq 0
4327 auto tokens = tokenize(prompt, false);
4328
4329 logger->info("Complete: {} input tokens, max_tokens={}",
4330 tokens.size(), params.max_tokens);
4331 log_sampler_config(params);
4332 auto result = decode_loop(tokens, params, nullptr, nullptr);
4333 finalize_result(result, t0);
4334 return result;
4335}
4336
4337// ── Architecture detection (v1.9.13) ───────────────────────
4338
4346 return is_recurrent_;
4347}
4348
4349// ── Capability overrides (v1.9.13) ─────────────────────────
4350
4367 int idx = static_cast<int>(cap);
4368 int count = static_cast<int>(BackendCapability::_COUNT);
4369 if (idx < 0 || idx >= count) {
4370 return false;
4371 }
4372
4373 // Static capabilities: true = always supported. Length must equal
4374 // BackendCapability::_COUNT — trailing entries get appended as new
4375 // capabilities are introduced (gh#53 added AUDIO at index 12).
4376 static constexpr bool always[] = {
4377 false, false, true, true, true, true,
4378 false, true, true, false, false, true,
4379 false, // AUDIO — dynamic only (mtmd_support_audio)
4380 };
4381
4382 // Dynamic capabilities override the static table
4383 bool result = always[idx];
4384 if (!result) {
4385 result = (cap == BackendCapability::KV_CACHE && !is_recurrent())
4387 || (cap == BackendCapability::VISION
4388 && !config().mmproj_path.empty())
4389 || (cap == BackendCapability::AUDIO
4390 && mtmd_ctx_ != nullptr
4391 && mtmd_support_audio(mtmd_ctx_))
4393 && !is_recurrent());
4394 }
4395 return result;
4396}
4397
4405 return "llama.cpp";
4406}
4407
4415 BackendInfo bi;
4416 bi.name = "llama.cpp";
4417#if defined(ENTROPIC_BACKEND_CUDA)
4418 bi.compute_device = "cuda";
4419#elif defined(ENTROPIC_BACKEND_VULKAN)
4420 bi.compute_device = "vulkan";
4421#else
4422 bi.compute_device = "cpu";
4423#endif
4424 bi.model_format = "gguf";
4425
4426 if (state() != ModelState::COLD && model_ != nullptr) {
4427 bi.architecture = is_recurrent() ? "recurrent" : "transformer";
4430 bi.parameter_count = llama_model_n_params(model_);
4431 bi.vram_bytes = 0;
4432 bi.ram_bytes = llama_model_size(model_);
4433
4434 char desc[256] = {};
4435 llama_model_desc(model_, desc, sizeof(desc));
4436 bi.quantization = desc;
4437 }
4438 return bi;
4439}
4440
4449 if (ctx_ == nullptr) {
4450 return false;
4451 }
4452 auto mem = llama_get_memory(ctx_);
4453 if (seq_id < 0) {
4454 llama_memory_clear(mem, true);
4455 } else {
4456 llama_memory_seq_rm(mem, seq_id, -1, -1);
4457 }
4458 return true;
4459}
4460
4478 int seq_id, std::vector<uint8_t>& buffer) const {
4479 if (ctx_ == nullptr) { return false; }
4480 size_t sz = llama_state_seq_get_size(
4481 ctx_, static_cast<llama_seq_id>(seq_id));
4482 if (sz == 0) { return false; }
4483 buffer.resize(sz);
4484 size_t written = llama_state_seq_get_data(
4485 ctx_, buffer.data(), sz,
4486 static_cast<llama_seq_id>(seq_id));
4487 return written == sz;
4488}
4489
4505 int seq_id, const std::vector<uint8_t>& buffer) {
4506 if (ctx_ == nullptr || buffer.empty()) { return false; }
4507 size_t result = llama_state_seq_set_data(
4508 ctx_, buffer.data(), buffer.size(),
4509 static_cast<llama_seq_id>(seq_id));
4510 return result > 0;
4511}
4512
4513} // namespace entropic
ChatAdapter concrete base class.
gh#98 (v2.8.0) same-prefix batch-generation decision logic.
virtual std::vector< GenerationResult > do_generate_batch(const std::vector< std::vector< Message > > &requests, const std::vector< GenerationParams > &params, std::atomic< bool > &cancel)
Subclass same-prefix batch generation (gh#98, v2.8.0).
Definition backend.h:536
std::string last_error_
Last error message for diagnostics.
Definition backend.h:727
bool is_active() const
True when state is ACTIVE.
Definition backend.h:249
ModelState state() const
Current lifecycle state (lock-free read).
Definition backend.h:241
const ModelConfig & config() const
Stored model config.
Definition backend.h:320
int context_length() const
Model's context window size.
Definition backend.h:282
std::atomic< ModelState > state_
State transition slot accessible to subclasses for test-only injection.
Definition backend.h:753
LlamaCppBackend — common llama.cpp patterns (15% layer).
bool parse_params_valid_
True once a tooled render snapshotted.
int last_gen_decode_calls_
gh#98: batched-decode step count of last batch
bool load_gpu_model()
Load the GGUF model onto the GPU (do_activate step 1).
bool do_load(const ModelConfig &config) override
Load model into CPU RAM (COLD → WARM).
bool do_supports(BackendCapability cap) const override
Declare llama.cpp backend capabilities.
std::vector< GenerationResult > build_batch_results(std::vector< BatchSeq > &seqs)
Detokenize each sequence into a GenerationResult.
std::vector< std::string > effective_stop(const GenerationParams &params) const
params.stop + the sequential tool-call close marker, if applicable.
double last_prefill_ms_
gh#96: prefill wall-clock ms of last generate()
int last_input_tokens_
gh#97: tokenized prompt size of last generate()
GenerationResult decode_loop(const std::vector< llama_token > &tokens, const GenerationParams &params, std::function< void(std::string_view)> on_token, std::atomic< bool > *cancel)
Core decode loop — shared by generate and streaming.
bool is_recurrent_
True if loaded model is recurrent (GDN/Mamba/RWKV).
bool try_warm_reuse(const std::vector< llama_token > &tokens)
gh#96 (v2.7.5): try incremental prefill against resident KV.
LogprobResult do_evaluate_logprobs(const int32_t *tokens, int n_tokens) override
Evaluate per-token log-probabilities via sequential decode.
std::string do_backend_name() const override
Return backend name.
bool is_hybrid_
gh#97: attention + recurrent/SSM memory
bool do_save_state(int seq_id, std::vector< uint8_t > &buffer) const override
Capture a sequence's KV cache into a byte buffer.
std::string render_prompt(const std::vector< Message > &messages, const GenerationParams &params)
Generation render seam: common_chat-with-tools or legacy (gh#87).
std::unique_ptr< PromptCache > prompt_cache_
KV prefix cache (v1.8.3)
void teardown_mtp_draft()
Free the MTP head context + model (gh#106 lifecycle).
std::string parse_generation_prompt_
Last TOOLED render's gen prompt.
std::vector< GenerationResult > run_batched_decode(const std::vector< std::vector< llama_token > > &toks, const std::vector< GenerationParams > &params, std::size_t shared, std::atomic< bool > &cancel)
Run the gh#98 multi-seq batched decode (v2.8.0).
GenerationResult do_generate(const std::vector< Message > &messages, const GenerationParams &params) override
Generate a complete response using chat template.
void reload_model_cpu_only()
Reload the model CPU-only for the WARM state (do_deactivate tail).
std::string render_with_tools(const std::vector< Message > &messages, const GenerationParams &params)
Render messages through common_chat WITH the active tools.
void sample_batch_active(std::vector< BatchSeq > &seqs)
Sample+accept+classify each still-active sequence.
bool common_chat_parse_reliable() const
True iff common_chat parsing is reliable for the last render (gh#87).
std::string active_tools_json_
MCP tool defs for next render.
GenerationResult do_complete(const std::string &prompt, const GenerationParams &params) override
Raw text completion without chat template.
int last_prefill_tokens_
gh#96: prompt tokens decoded by last generate()
std::vector< llama_token > tokenize(const std::string &text, bool add_special) const
Tokenize text using model vocabulary.
bool create_inference_context()
Create the llama context + prompt cache (do_activate step 2).
const llama_vocab * vocab_
Vocabulary (from model_)
std::string tool_call_close_marker() const override
Tool-call close marker for the captured chat format (gh#103).
bool have_chat_params_
True once a tool render captured params.
int compute_prefix_token_count(const std::vector< Message > &messages, const GenerationParams &params)
Compute token count of system messages only.
std::unique_ptr< SamplerFactory > sampler_factory_
Factory used by the decode loop to build per-generation samplers.
void release_temp_seqs(std::vector< BatchSeq > &seqs)
Release every batch sequence's temp seq_id (seq 0 excluded).
std::string detokenize(llama_token token) const
Detokenize a single token.
void set_active_tools(const std::string &tools_json)
Stage tool definitions for the next common_chat render (gh#87).
void init_mmproj_if_configured()
Initialize the libmtmd context if mmproj is configured.
int last_chat_format_
Captured common_chat_format.
GenerationResult generate_speculative_with_draft(const std::vector< Message > &messages, const GenerationParams &params, std::function< void(std::string_view token)> on_token, std::atomic< bool > &cancel, LlamaCppBackend &draft, int n_draft_max, const std::string &draft_path)
Speculative-decoding kernel with explicit draft backend.
llama_context * ctx_
Inference context (ACTIVE)
bool run_prefill(const std::vector< llama_token > &tokens)
Run batched prefill on input tokens.
GenerationResult run_sampling_loop(const GenerationParams &params, std::function< void(std::string_view token)> on_token, std::atomic< bool > *cancel, const std::chrono::steady_clock::time_point &t0)
Sample tokens until stop / max_tokens / cancel.
llama_seq_id next_temp_seq_id_
gh#98: monotonic high-water for NEW temp seq_ids (the old 1 + size() handed out duplicates when the p...
std::string last_generation_prompt_
Captured generation_prompt.
GenerationResult mtp_guard(const GenerationParams &params, const std::function< void(std::string_view)> &on_token, const std::string &head_path, int n_max)
Validate MTP run preconditions (gh#108, fail-fast/fail-loud).
bool restore_cached_prefix(const CacheEntry *cached, const std::vector< llama_token > &tokens)
Restore KV state from cache and decode remaining tokens.
void save_prefix_to_cache(const CacheKey &key, int prefix_tokens)
Capture seq 0 KV state and store under the given key.
std::vector< int32_t > tokenize_text(const std::string &text) const override
Tokenize text to token IDs using model vocabulary.
int mtp_n_max_
MTP draft window (n_max) of the live head.
bool is_recurrent() const
Check if loaded model is recurrent.
std::string step_token(Sampler &sampler, std::string &generated, std::function< void(std::string_view)> &on_token, const std::vector< std::string > &stop)
Generate one token and append to output.
GenerationResult generate_after_prefill(Sampler &sampler, const GenerationParams &params, std::function< void(std::string_view)> on_token, std::atomic< bool > *cancel)
The post-prefill sampling loop (extracted from decode_loop).
entropic_error_t mtmd_prefill(const std::string &prompt, const std::vector<::mtmd_bitmap * > &bitmaps, std::string &err_msg)
Run mtmd_tokenize + mtmd_helper_eval_chunks on a prompt.
void run_batch_gen_loop(std::vector< BatchSeq > &seqs, int max_steps, std::atomic< bool > &cancel)
Decode all sequences together until each finishes.
bool run_prefill_cached(const std::vector< llama_token > &tokens, const std::string &system_prompt, const std::vector< Message > &messages, const GenerationParams &params)
Run prefill with prompt cache integration.
std::string mtp_head_path_
Path the live mtp_draft_ctx_ was built from.
GenerationResult do_generate_text_only(const std::vector< Message > &messages, const GenerationParams &params)
Text-only batch generation (extracted from do_generate).
bool do_restore_state(int seq_id, const std::vector< uint8_t > &buffer) override
Restore a sequence's KV cache from a byte buffer.
std::string apply_chat_template(const std::vector< Message > &messages, const GenerationParams &params) const
Apply chat template to messages.
CommonChatResult parse_response(const std::string &raw) const
Parse a raw model emission via the last captured render params.
bool prefill_batch_suffixes(std::vector< BatchSeq > &seqs, const std::vector< std::vector< llama_token > > &toks, std::size_t shared)
Prefill each request's suffix; set per-seq logits_idx.
std::unique_ptr< Tokenizer > tokenizer_
Tokenizer used by tokenize_text / do_count_tokens / internal tokenize/detokenize.
GenerationResult do_generate_streaming(const std::vector< Message > &messages, const GenerationParams &params, std::function< void(std::string_view token)> on_token, std::atomic< bool > &cancel) override
Streaming generation with per-token callback.
bool has_vision_
Cached mtmd_support_vision(mtmd_ctx_) result.
void inject_tokenizer_for_test(std::unique_ptr< Tokenizer > tokenizer)
Inject a tokenizer for unit testing (v2.3.10).
bool decode_tokens_from(const std::vector< llama_token > &tokens, int start_offset)
Decode tokens starting at a given offset.
bool prefill_dispatch(const std::vector< llama_token > &tokens, const std::string &system_prompt, const std::vector< Message > &messages, const GenerationParams &params)
Cache-aware prefill dispatch (gh#96 v2.7.5: extracted body of run_prefill_cached so the wrapper owns ...
void release_temp_seq_id(llama_seq_id seq_id)
Release a temporary sequence ID back to the pool.
std::unique_ptr< Sampler > create_sampler(const GenerationParams &params) const
Build a Sampler for one generation from params.
int do_count_tokens(const std::string &text) const override
Count tokens in text.
::mtmd_context * mtmd_ctx_
libmtmd context, or nullptr if no mmproj loaded.
bool require_tool_call_
gh#134 (v2.10.4): when true the render asks llama.cpp for COMMON_CHAT_TOOL_CHOICE_REQUIRED,...
std::string tool_grammar_
Render-derived tool-call GBNF.
std::string last_parser_
Captured serialized PEG arena.
GenerationResult generate_multimodal(const std::vector< Message > &messages, const GenerationParams &params, std::function< void(std::string_view token)> on_token, std::atomic< bool > *cancel)
Multimodal generation core (v1.9.11 Phases 5–7).
std::mutex seq_id_mutex_
Guards temp seq_id pool (v1.9.10)
bool do_clear_state(int seq_id) override
Clear KV cache or recurrent hidden state.
bool prefill_shared_and_fanout(std::vector< BatchSeq > &seqs, const std::vector< llama_token > &seq0, std::size_t shared)
Prefill shared prefix into seq 0 + seq_cp fan-out.
std::string apply_chat_template_lowlevel(const std::vector< Message > &messages) const
Low-level GGUF template path (gh#86 fallback, v2.6.1).
static float extract_token_logprob(const float *logits, int32_t next_token, int n_vocab)
Extract log-probability for a token from logits.
void do_deactivate() override
Deactivate: free context, reload model CPU-only.
GenerationResult generate_mtp(const std::vector< Message > &messages, const GenerationParams &params, std::function< void(std::string_view token)> on_token, std::atomic< bool > &cancel, const std::string &head_path, int n_max)
Speculative generation via a target-owned MTP head (gh#106).
bool tool_grammar_lazy_
Arm on trigger vs bind eagerly.
BackendInfo do_info() const override
Populate backend metadata from llama.cpp model.
std::vector< GenerationResult > do_generate_batch(const std::vector< std::vector< Message > > &requests, const std::vector< GenerationParams > &params, std::atomic< bool > &cancel) override
Same-prefix batch generation (gh#98, v2.8.0).
std::string parse_parser_
Last TOOLED render's PEG arena.
bool do_activate() override
Activate model on GPU (WARM → ACTIVE).
bool prepare_batch_seqs(std::vector< BatchSeq > &seqs, const std::vector< GenerationParams > &params)
Build per-request sampler chains + seq ids.
bool build_mtp_head(const std::string &head_path)
Load the MTP head GGUF + create its shared-KV context (gh#106).
bool prefill_and_cache_prefix(const std::vector< llama_token > &tokens, int prefix_tokens, const CacheKey &key)
Two-pass prefill: prefix-only prefill → save → rest.
std::mutex mtp_mutex_
gh#108: serialises MTP head setup/teardown vs in-flight generate_mtp (no deactivate-during-generate U...
llama_seq_id allocate_temp_seq_id()
Allocate a temporary sequence ID for evaluation.
PromptCacheConfig prompt_cache_config_
Cache config (v1.8.3)
int parse_chat_format_
Last TOOLED render's format.
std::vector< llama_token > resident_tokens_
gh#96: tokens resident in KV seq 0 (warm-keep)
llama_model * mtp_draft_model_
MTP head GGUF (separate, trunk-sharing)
void do_unload() override
Full unload — free all resources, clear prompt cache.
llama_model * model_
Loaded model (WARM+)
~LlamaCppBackend() override
Free llama.cpp + mtmd resources on destruction.
void invalidate_resident_kv()
gh#96 (v2.7.5): drop the warm-keep resident-KV record.
std::vector< llama_seq_id > free_seq_ids_
Available temporary seq_ids (v1.9.10)
GenerationResult do_generate_speculative(const std::vector< Message > &messages, const GenerationParams &params, std::function< void(std::string_view token)> on_token, std::atomic< bool > &cancel) override
Speculative streaming via the abstract InferenceBackend interface (kept as NOT_SUPPORTED — see kernel...
GenerationResult do_generate_streaming_text_only(const std::vector< Message > &messages, const GenerationParams &params, std::function< void(std::string_view token)> on_token, std::atomic< bool > &cancel)
Text-only streaming generation (extracted from streaming).
static std::string extract_system_prompt(const std::vector< Message > &messages)
Extract the system prompt from messages.
llama_context * mtp_draft_ctx_
MTP context (ctx_type=MTP, ctx_other=ctx_)
void inject_sampler_factory_for_test(std::unique_ptr< SamplerFactory > factory)
Inject a SamplerFactory for unit testing (v2.3.10).
bool setup_mtp_draft(const std::string &head_path, int n_max)
Lazily build the MTP head context against the live ctx_ (gh#106).
Sampler adapter that wraps a llama_sampler* chain.
llama_sampler * native_chain() const
Expose the underlying chain for legacy call sites that have not yet been ported to the Sampler API.
static CacheKey make_key(std::string_view prompt_text, std::string_view model_path)
Compute a cache key from prompt text and model path.
Pure-virtual per-generation sampler used by the decode loop.
Definition sampler.h:48
virtual int32_t sample()=0
Sample one token from the current decode position.
ENTROPIC_EXPORT const char * entropic_error_name(entropic_error_t code)
Get the human-readable name for an error code.
Definition error.cpp:85
entropic_error_t
Error codes returned by all C API functions.
Definition error.h:37
@ ENTROPIC_OK
Success.
Definition error.h:38
@ ENTROPIC_ERROR_CANCELLED
Operation cancelled via cancel token.
Definition error.h:50
@ ENTROPIC_ERROR_IMAGE_LOAD_FAILED
Image file could not be read or decoded (v1.9.11)
Definition error.h:82
@ ENTROPIC_ERROR_SPECULATIVE_INCOMPATIBLE_CONFIG
MTP/speculative enabled but the request can't run correctly (temp>0, grammar, tools,...
Definition error.h:92
@ ENTROPIC_ERROR_NOT_SUPPORTED
Capability not supported by this backend (v1.9.13)
Definition error.h:86
@ ENTROPIC_ERROR_GENERATE_FAILED
Generation failed (context overflow, model error)
Definition error.h:44
@ ENTROPIC_ERROR_INVALID_STATE
Operation not valid in current state (e.g., generate before activate)
Definition error.h:41
@ ENTROPIC_ERROR_LOAD_FAILED
Model load failed (corrupt file, OOM, unsupported format)
Definition error.h:43
Which grammar source constrains a decode (gh#134).
LlamaCppBackend — llama.cpp C API integration.
Concrete llama.cpp Sampler + SamplerFactory (v2.3.10 seam impl).
Concrete llama.cpp tokenizer (v2.3.10 seam impl).
spdlog initialization and logger access.
auto now()
Get current time for timing measurements.
Definition logging.h:200
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
Definition logging.cpp:211
double elapsed_ms(std::chrono::steady_clock::time_point start, std::chrono::steady_clock::time_point end)
Compute elapsed milliseconds between two time points.
Definition logging.h:210
Pure envelope check for the MTP speculative path (gh#108).
Activate model on GPU (WARM → ACTIVE).
@ IMAGE
Image content (local path or data URI)
static GenerationResult spec_run_from_tokens(llama_context *ctx_tgt, llama_context *ctx_dft, llama_model *model_tgt, const std::vector< llama_token > &tokens, const GenerationParams &params, std::function< void(std::string_view)> &on_token, std::atomic< bool > &cancel, int n_draft_max, const std::string &draft_path, std::chrono::steady_clock::time_point t0, const std::string &tool_grammar, bool tool_grammar_lazy, const std::string &generation_prompt)
Public entry point for the speculative-decoding kernel.
BackendCapability
Capabilities that an inference backend may or may not support.
@ SPECULATIVE_DECODING
Speculative decoding compatibility.
@ HIDDEN_STATE
Recurrent hidden state management (save/load/reset)
@ VISION
Vision / multimodal input (v1.9.11)
@ KV_CACHE
KV cache state management (save/load/clear)
@ AUDIO
Audio input via mtmd audio projector (gh#53, v2.3.0)
@ _COUNT
Sentinel — must be last. Used for iteration/array sizing.
GrammarSource resolve_grammar_source(const std::string &request_grammar, const std::string &tool_grammar)
Resolve which grammar wins.
void append_sequential_stop(GenerationParams &params, const std::string &marker)
Append a tool-call close marker to params.stop for sequential mode.
void coerce_string_typed_args(std::vector< ToolCall > &calls, const std::string &tools_json)
gh#90: coerce numeric scalars back to strings for string-typed tool parameters.
bool batch_is_viable(std::size_t n, int n_parallel, std::size_t shared, bool hybrid, std::size_t total_suffix, int n_batch)
Decide whether the same-prefix batch fast-path is safe + worthwhile.
Definition batch_util.h:80
bool has_images(const std::vector< ContentPart > &parts)
Check if content parts contain any image parts.
Definition content.cpp:41
static int effective_n_draft(int n_max)
Draft-window size setup_mtp_draft will actually use (gh#106).
static bool spec_decode_both(SpeculativeRunState &state)
Decode the speculative batch on both contexts.
@ tokens
Gate on generated tokens since the last tool call.
@ off
Disabled (default) — no thinking-budget gating.
std::string extract_text(const std::vector< ContentPart > &parts)
Extract concatenated text from content parts.
Definition content.cpp:20
std::string close_marker_for_format(common_chat_format fmt)
Map a resolved common_chat format to its single-tool-call close marker.
static void spec_ckpt_save_tgt(SpeculativeRunState &state)
Snapshot target state right before the target decode of the speculative batch (when use_ckpt_tgt + no...
@ ok
Tool dispatched, returned non-empty content.
std::size_t batch_shared_prefix_len(const std::vector< std::vector< Tok > > &seqs)
Longest shared token prefix across N request sequences (gh#98).
Definition batch_util.h:43
static void spec_trim_rejected_drafts(SpeculativeRunState &state)
Clear any stale KV positions left by rejected draft tokens.
static bool spec_commit_accepted(SpeculativeRunState &state, const std::vector< llama_token > &ids, const llama_vocab *vocab, int max_tokens, std::function< void(std::string_view)> &on_token, std::atomic< bool > &cancel)
Walk accepted ids, emit tokens via callback, update state.
static std::vector< common_chat_tool > mcp_tools_to_common_chat(const std::string &tools_json)
Convert entropic MCP tool JSON to common_chat_tool defs (gh#87).
static std::string spec_emit_token(SpeculativeRunState &state, llama_token id, const llama_vocab *vocab, int max_tokens, std::function< void(std::string_view)> &on_token, std::atomic< bool > &cancel)
Emit on_token for one accepted id, updating state and returning a stop signal when terminating condit...
std::size_t warm_keep_cut(const std::vector< Tok > &resident, const std::vector< Tok > &incoming, long kv_pos_max)
Decide how many resident-KV tokens warm-keep may reuse this turn.
static void spec_rollback_partial(SpeculativeRunState &state, common_sampler *smpl_save, std::vector< llama_token > &ids)
Partial-acceptance rollback: restore both contexts and the sampler to their pre-draft state,...
static void fill_batch_cell(llama_batch &b, int k, llama_token tok, llama_pos pos, llama_seq_id seq, bool want_logits)
Fill one cell of a multi-seq llama_batch.
static std::string spec_check_preconditions(bool target_active, bool draft_active, llama_context *ctx_tgt, llama_context *ctx_dft)
Validate speculative preconditions and reject NO-seq_rm.
bool grammar_sources_collide(const std::string &request_grammar, const std::string &tool_grammar)
Whether both sources are active — a config error worth reporting.
@ request
GenerationParams::grammar (COMMON_GRAMMAR_TYPE_USER)
@ tool_call
Render-derived (COMMON_GRAMMAR_TYPE_TOOL_CALLS, needs prefill)
@ count
Sentinel — MUST remain last.
@ WARM
mmap'd + mlock'd in RAM
@ COLD
On disk only, no RAM consumed.
void strip_thinking_channels(std::string &content, std::string *reasoning_out)
Strip Gemma 4 QAT reasoning channels (<|channel>…<channel|>) from content, accumulating the stripped ...
static std::string concat_messages_fallback(const std::vector< Message > &messages)
Plain "role: content" join used when templating fails.
static std::optional< common_chat_params > render_common_chat(llama_model *model, const std::vector< Message > &messages, const GenerationParams &params, const std::vector< common_chat_tool > &tools, bool require_tool_call)
Shared common_chat render core for both template paths (gh#87).
static std::string spec_init_run(SpeculativeRunState &state, llama_model *model_tgt, const std::vector< llama_token > &tokens, const GenerationParams &params, int n_draft_max, const std::string &draft_path, const std::string &tool_grammar, bool tool_grammar_lazy, const std::string &generation_prompt)
Initialize speculative run state (prefill + sampler + decoder).
static int spec_run_draft(SpeculativeRunState &state)
Trigger draft generation via common_speculative_draft.
static void spec_run_loop(SpeculativeRunState &state, const llama_vocab *vocab, int max_tokens, std::function< void(std::string_view)> &on_token, std::atomic< bool > &cancel)
Run the accept-round loop until completion / EOS / cancel.
std::string mtp_unsupported_reason(float temperature, bool has_grammar, bool streaming)
Reason MTP cannot run for a request, or "" when the envelope is safe.
static std::vector< common_chat_msg > to_common_chat(const std::vector< Message > &messages)
Convert engine messages to common_chat_msg (gh#86, v2.6.1).
static ToolCall to_entropic_tool_call(const common_chat_tool_call &cc)
Map a common_chat_tool_call to entropic's ToolCall (gh#87).
static std::vector< llama_chat_message > to_llama_chat(const std::vector< Message > &messages)
Convert engine messages to llama_chat_message views.
static std::string spec_init_sampler_and_decoder(SpeculativeRunState &state, llama_model *model_tgt, const GenerationParams &params, int n_draft_max, const std::string &draft_path, const std::string &tool_grammar, bool tool_grammar_lazy, const std::string &generation_prompt)
Initialize the kernel state: clear KV, prefill, sampler, speculative context, batch,...
static GenerationResult spec_finalize(SpeculativeRunState &state, std::chrono::steady_clock::time_point t0)
Speculative kernel against an explicit draft backend.
static bool spec_accept_round(SpeculativeRunState &state, const llama_vocab *vocab, int max_tokens, std::function< void(std::string_view)> &on_token, std::atomic< bool > &cancel)
Run one speculative accept round; return false to stop.
static GenerationResult batch_error_result(const std::string &msg)
Build a single error GenerationResult (gh#98 batch failures).
static void spec_build_batch(SpeculativeRunState &state)
Build the target batch [id_last, draft0, ..., draftN-1].
static int spec_prepare_draft(SpeculativeRunState &state)
Drive one accept round: optional draft generation, decode on both contexts, sample-and-accept,...
static void spec_ckpt_save_dft(SpeculativeRunState &state)
Drive one accept round: draft → decode → sample-and-accept → emit tokens.
static void spec_cleanup(SpeculativeRunState &state)
Free everything allocated by the kernel.
static void spec_ckpt_restore_dft(SpeculativeRunState &state)
Restore the draft's pre-draft state so the upcoming target-batch decode on the draft re-fills cleanly...
Backend metadata for introspection.
size_t ram_bytes
RAM consumed by loaded model (bytes). 0 if COLD.
int max_context_length
Maximum context length.
size_t parameter_count
Number of parameters (from model metadata).
std::string architecture
Architecture family of the loaded model.
std::string compute_device
"cuda", "vulkan", "cpu", "npu"
std::string name
Backend identifier (e.g. "llama.cpp", "axcl")
std::string quantization
Quantization type (e.g. "IQ3_XXS", "Q8_0", "fp16").
size_t vram_bytes
VRAM consumed by loaded model (bytes). 0 if COLD.
std::string model_format
"gguf", "axmodel", "onnx", etc.
Single cached KV state snapshot.
std::vector< uint8_t > data
Raw KV cache bytes.
size_t data_size
data.size() for quick byte accounting
int token_count
Prompt tokens covered by this entry.
64-bit hash used as cache lookup key.
Generation parameters for a single inference call.
Definition config.h:313
std::string grammar
GBNF grammar string (empty = unconstrained)
Definition config.h:370
float temperature
Sampling temperature.
Definition config.h:314
bool enable_thinking
Enable <think> blocks (false if reasoning_budget == 0)
Definition config.h:369
int max_tokens
Maximum tokens to generate.
Definition config.h:362
std::vector< std::string > stop
Stop sequences.
Definition config.h:376
Result of a single generation call.
entropic_error_t error_code
Error code (ENTROPIC_OK if no error)
double generation_time_ms
Wall-clock generation time.
int n_drafted
Tokens proposed by the draft/MTP head across all rounds.
int seq_id
Sequence identifier for multi-sequence backends.
double throughput_tok_s
Measured throughput for this generation (tok/s).
std::string finish_reason
Finish reason: "stop", "length", "error".
std::string content
Generated text (cleaned by adapter)
int n_accepted
Draft tokens the target accepted (≤ n_drafted).
std::string error_message
Error description (empty if no error)
int token_count
Generated token count.
Result of a common_chat parse: native tool calls + split content.
std::vector< ToolCall > tool_calls
Extracted native tool calls.
std::string content
Content with calls + reasoning removed.
std::string reasoning_content
Extracted reasoning/thought block.
Per-token log-probability evaluation result.
std::vector< float > logprobs
Log-prob for each token transition (N-1 values)
int n_logprobs
Number of logprob values (n_tokens - 1)
int n_tokens
Number of input tokens.
float total_logprob
Sum of all logprob values.
float perplexity
exp(-mean(logprobs)) — lower = less surprising
std::vector< int32_t > tokens
Input tokens echoed back for verification.
Model configuration for a single tier.
Definition config.h:154
std::filesystem::path mmproj_path
Vision projector GGUF path.
Definition config.h:250
int gpu_layers
GPU offload layers (-1 = all)
Definition config.h:158
int n_ubatch
Physical micro-batch size for prompt processing (gh#23 MVP item 5).
Definition config.h:178
int context_length
Context window size (512–131072)
Definition config.h:157
std::filesystem::path path
Resolved model file path.
Definition config.h:155
float rope_freq_scale
RoPE frequency scaling factor (gh#23 MVP item 10).
Definition config.h:228
int main_gpu
Primary GPU index for model load (gh#23 MVP item 7).
Definition config.h:200
int n_threads
CPU threads (0 = auto-detect)
Definition config.h:180
bool offload_kqv
Offload KQV ops (incl.
Definition config.h:208
int n_parallel
Max parallel sequences per context (gh#23 MVP item 11).
Definition config.h:238
std::string cache_type_k
KV cache key quantization type.
Definition config.h:164
std::string cache_type_v
KV cache value quantization type.
Definition config.h:165
std::string split_mode
Multi-GPU split mode for model load (gh#23 MVP item 6).
Definition config.h:192
int n_batch
Batch size for prompt processing.
Definition config.h:166
bool flash_attn
Enable flash attention.
Definition config.h:239
bool use_mlock
Lock model in system RAM.
Definition config.h:160
float rope_freq_base
RoPE base frequency override (gh#23 MVP item 9).
Definition config.h:218
size_t max_bytes
Maximum cache RAM (512 MB default)
Definition config.h:273
bool log_hits
Log cache hit/miss at INFO level.
Definition config.h:275
bool enabled
Master switch (false = no caching)
Definition config.h:274
bool warm_keep
gh#96 (v2.7.5): keep the prior turn's KV resident and re-decode only the appended delta (warm-keep / ...
Definition config.h:280
Bundles per-kernel-run mutable state to keep the loop body focused on its responsibility (knots: cogn...
std::vector< std::string > stop
gh#108: stop seqs (effective_stop); empty for gh#36
A tool call request parsed from model output.
Definition tool_call.h:31
std::unordered_map< std::string, std::string > arguments
Tool arguments as string key-value pairs.
Definition tool_call.h:34
std::string id
Unique call ID (UUID)
Definition tool_call.h:32
std::string arguments_json
Original JSON string (for passthrough dispatch)
Definition tool_call.h:35
std::string name
Tool name (e.g. "filesystem.read_file")
Definition tool_call.h:33
gh#103 (v2.8.2): family-aware tool-call CLOSE markers, derived from the resolved common_chat format.
UTF-8 validation + replacement at every system boundary where bytes change ownership.
ENTROPIC_EXPORT std::string sanitize_utf8(std::string_view input)
Replace invalid UTF-8 byte sequences with U+FFFD.
gh#96 (v2.7.5) warm-keep / incremental-prefill decision logic.