Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
orchestrator.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
17
18#include "llama_cpp_backend.h"
20#include "device_memory.h"
21#include "partial_offload.h" // gh#148 refusal predicates
22#include "vram_footprint.h"
23#include "gguf_metadata.h" // v2.13.1: auto reads the model's real shape
24#include "auto_placement.h" // v2.13.1: auto solves the footprint estimator
25#include "response_parse.h"
26#include "grammar_source.h" // gh#154: provenance for the result record
27#include "mtp_envelope.h"
30#include <entropic/inference/adapters/adapter_base.h> // gh#88 recovery
31
32#include <llama.h>
33#include <nlohmann/json.hpp>
34
35#include <cstdlib>
36#include <filesystem>
37
38namespace entropic {
39
40namespace {
41auto logger = entropic::log::get("inference.orchestrator");
44
52std::string extract_latest_user_message(const std::vector<Message>& messages) {
53 for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
54 if (it->role == "user") {
55 return it->content;
56 }
57 }
58 return "";
59}
60
61} // anonymous namespace
62
63// ── Initialization ─────────────────────────────────────────
64
78bool ModelOrchestrator::create_tier_backends(const ParsedConfig& config) {
79 for (const auto& [name, tier_config] : config.models.tiers) {
80 std::string path_key = tier_config.path.string();
81 if (!std::filesystem::exists(tier_config.path)) {
82 logger->error("Model file not found for tier '{}': {}",
83 name, path_key);
84 logger->error("Place a GGUF file at the path above, or set "
85 "ENTROPIC_MODEL_DIR to a directory containing "
86 "it. Run `entropic download --list` to see "
87 "bundled model keys, then "
88 "`entropic download <key>` to fetch one.");
89 return false;
90 }
91 if (model_pool_.find(path_key) == model_pool_.end()) {
92 model_pool_[path_key] = std::make_shared<LlamaCppBackend>();
93 }
94 tiers_[name] = model_pool_[path_key];
95 adapters_[name] = create_adapter(
96 tier_config.adapter, name, "" /* prompt resolved later */);
97 }
98 // Router backend instantiation moved to SecondaryModelLoader
99 // (gh#27, v2.1.11). The loader allocates the role slot lazily on
100 // first ensure_loaded() call from activate_router().
101 logger->info("Created {} unique backend(s) for {} tier(s)",
102 model_pool_.size(), tiers_.size());
103 return true;
104}
105
116void ModelOrchestrator::build_routing_tables(const ParsedConfig& config) {
117 for (const auto& [digit, tier_name] : config.routing.tier_map) {
118 tier_map_[digit] = tier_name;
119 }
120 for (const auto& [src, targets] : config.routing.handoff_rules) {
121 handoff_rules_[src] = std::unordered_set<std::string>(
122 targets.begin(), targets.end());
123 }
124}
125
145bool ModelOrchestrator::activate_default_tier(const ParsedConfig& config) {
146 // gh#157: `models.defer_load` leaves the default tier COLD until the
147 // first caller needs it. Two idle hosts previously held two copies of
148 // the same 4.8 GB GGUF in VRAM without ever being sent a run.
149 const bool skip = config.models.defer_load
150 || tiers_.find(default_tier_) == tiers_.end();
151 if (skip) {
152 if (config.models.defer_load) {
153 logger->info("[residency] models.defer_load=true — default tier "
154 "'{}' loads on first use", default_tier_);
155 }
156 return true;
157 }
158 if (get_model(default_tier_) == nullptr) {
159 logger->error("Failed to activate default tier: {}", default_tier_);
160 return false;
161 }
162 logger->info("Activated default tier: {}", default_tier_);
163 return true;
164}
165
177void ModelOrchestrator::activate_router(const ParsedConfig& config) {
178 if (!config.models.router) { return; }
179 // Lifecycle now lives on SecondaryModelLoader (gh#27, v2.1.11).
180 // Diagnostic-level logging is emitted by the loader itself.
181 secondary_loader_.ensure_loaded("router", *config.models.router);
182}
183
197void ModelOrchestrator::activate_draft(const ParsedConfig& config) {
198 const auto& spec = config.inference.speculative;
199 if (!spec.enabled || spec.draft.path.empty()) { return; }
200 // gh#106 (v2.9.0): under MTP the target owns the head (lazily, via
201 // generate_mtp → setup_mtp_draft). Loading draft.path as a standalone
202 // secondary backend here would double-load the head GGUF and never use
203 // it — skip the gh#36 separate-draft activation entirely.
204 if (spec.mtp) {
205 logger->info("Speculative MTP: head '{}' is target-owned; skipping "
206 "separate draft activation", spec.draft.path.string());
207 return;
208 }
209 // Full ModelConfig comes from the YAML's
210 // `inference.speculative.draft:` block — every llama.cpp knob is
211 // consumer-tunable. Defaults come from
212 // `make_default_draft_model_config()` (gpu_layers=0,
213 // flash_attn=false, context_length=8192, n_threads=4).
214 secondary_loader_.ensure_loaded("draft", spec.draft);
215}
216
223void ModelOrchestrator::ensure_secondary_roles() {
224 activate_router(config_);
225 activate_draft(config_);
226}
227
240void ModelOrchestrator::route_ggml_logs(const ParsedConfig& config) {
241 if (!config.ggml_logging) { return; }
242 std::string path;
243 if (!config.llama_log_path.empty()) {
244 path = config.llama_log_path.string();
245 } else if (!config.log_dir.empty()) {
246 path = (config.log_dir / "llama_ggml.log").string();
247 }
248 if (!path.empty()) {
249 entropic_inference_log_to_file(path.c_str());
250 logger->info("ggml logging: {}", path);
251 }
252}
253
269 config_ = config;
270 default_tier_ = config.models.default_tier;
271 // gh#198: a deferred handle leaves the device alone until it loads.
272 if (budget_resolves_at_init(config)) {
273 ensure_vram_budget();
274 } else {
275 vram_budget_deferred_ = true;
276 logger->info("[residency] defer_load: VRAM budget will be measured "
277 "at first admission, not now");
278 }
279
280 route_ggml_logs(config); // before any model loading
281
282 logger->info("Initializing model orchestrator");
283
284 if (!create_tier_backends(config)) { return false; }
285 build_routing_tables(config);
286 // gh#157 (v2.13.0): a no-op when `models.defer_load` is set.
287 if (!activate_default_tier(config)) { return false; }
288 // gh#198: under defer_load the router and a separate draft were still
289 // loaded here — whole models on an idle handle. The router now loads on
290 // the first classification (classify_task) and the draft with the first
291 // tier activation (get_model -> ensure_secondary_roles).
292 if (!config.models.defer_load) {
293 activate_router(config);
294 activate_draft(config); // Speculative draft slot (v2.1.11)
295 }
296
297 // LoRA adapter preload moved to first activation of the owning model
298 // (gh#157): preloading here required the base model to already be
299 // loaded, so it silently warned-and-skipped for every tier whose GGUF
300 // was not the default one's — and `ensure_adapter_for_tier` then failed
301 // with "not found or COLD" on the swap that needed it.
302 load_bundled_grammars(); // Bundled grammars (v1.9.3)
303 return true;
304}
305
317 logger->info("Shutting down model orchestrator");
318
319 for (auto& [path, backend] : model_pool_) {
320 if (backend->is_loaded()) {
321 backend->unload();
322 }
323 }
324
325 secondary_loader_.shutdown();
326}
327
339 // Order matters (gh#58 close-out, v2.3.0):
340 // 1. Backends first → frees llama_contexts.
341 // 2. LoRA adapter handles after → safe because the contexts
342 // that may have held HOT adapter references are gone.
343 shutdown();
344 lora_manager_.unload_all();
345}
346
358bool ModelOrchestrator::resolve_mtp_effective(const std::string& tier_name) const {
359 auto it = config_.models.tiers.find(tier_name);
360 if (it != config_.models.tiers.end() && it->second.speculative_mtp) {
361 return *it->second.speculative_mtp;
362 }
363 return config_.inference.speculative.mtp;
364}
365
372GenerationResult ModelOrchestrator::run_generate_dispatch(
373 InferenceBackend* model,
374 const std::vector<Message>& messages,
375 const GenerationParams& params,
376 const std::string& tier_name) {
377 GenerationResult result;
379 && try_speculative_route(model, messages, params, tier_name, result);
380 if (!kernel_ran) {
381 result = model->generate(messages, params);
382 }
383 return result;
384}
385
410bool ModelOrchestrator::try_mtp_route(
411 InferenceBackend* model,
412 const std::vector<Message>& messages,
413 const GenerationParams& params,
414 std::function<void(std::string_view)> on_token,
415 std::atomic<bool>& cancel,
416 GenerationResult& result)
417{
418 auto* llama_target = dynamic_cast<LlamaCppBackend*>(model);
419 if (llama_target == nullptr) {
420 // Fail loud — no silent plain-decode fallback (gh#108).
421 result = GenerationResult{};
422 result.error_code = ENTROPIC_ERROR_NOT_SUPPORTED;
423 result.error_message = "speculative.mtp enabled but the target backend "
424 "is not llama.cpp; disable speculative.mtp";
425 result.finish_reason = "error";
426 logger->error("{}", result.error_message);
427 } else {
428 result = llama_target->generate_mtp(
429 messages, params, on_token, cancel,
430 config_.inference.speculative.draft.path.string(),
432 }
433 return true; // MTP owns the outcome — never fall back to plain decode
434}
435
453 GenerationResult& result) {
454 auto* dm = draft->llama_model_ptr();
455 if (dm == nullptr) { return false; }
456 int n = llama_model_n_layer(dm);
457 if (!looks_like_mtp_head(n)) { return false; }
460 result.finish_reason = "error";
461 return true;
462}
463
485bool ModelOrchestrator::try_speculative_route_streaming(
486 InferenceBackend* model,
487 const std::vector<Message>& messages,
488 const GenerationParams& params,
489 const std::string& tier_name,
490 std::function<void(std::string_view)> on_token,
491 std::atomic<bool>& cancel,
492 GenerationResult& result)
493{
494 // gh#106 (v2.9.0): MTP routes BEFORE the gh#36 compat/pair path — the
495 // target owns the head (no separate draft backend), and MTP tolerates
496 // shared-KV gemma4 archs the gh#36 compat gate rejects.
497 if (resolve_mtp_effective(tier_name)) { // gh#108 v2.10.0: grammar no longer blocks MTP
498 return try_mtp_route(model, messages, params, on_token, cancel,
499 result);
500 }
502 bool kernel_ran = false;
503 if (!compat.compatible) {
504 logger->info("Speculative requested but pair incompatible "
505 "({}); using plain decode", compat.diagnostic);
506 } else {
507 auto* llama_target = dynamic_cast<LlamaCppBackend*>(model);
508 auto* draft_be = secondary_loader_.get("draft");
509 auto* llama_draft = dynamic_cast<LlamaCppBackend*>(draft_be);
510 if (llama_target == nullptr || llama_draft == nullptr) {
511 logger->info("Speculative compat passed but target/draft "
512 "is not llama.cpp; using plain decode");
513 } else {
514 // gh#107 (v2.10.0): guard against MTP head GGUFs on classical path.
515 if (mtp_head_guard_fires(llama_draft, result)) {
516 logger->error("{}", result.error_message);
517 return true;
518 }
519 auto spec = llama_target->generate_speculative_with_draft(
520 messages, params, on_token, cancel, *llama_draft,
522 config_.inference.speculative.draft.path.string());
523 if (spec.error_code == ENTROPIC_ERROR_NOT_SUPPORTED) {
524 logger->info("Speculative kernel returned NOT_SUPPORTED "
525 "({}); falling back", spec.error_message);
526 } else {
527 result = std::move(spec);
528 kernel_ran = true;
529 }
530 }
531 }
532 return kernel_ran;
533}
534
544bool ModelOrchestrator::try_speculative_route(
545 InferenceBackend* model,
546 const std::vector<Message>& messages,
547 const GenerationParams& params,
548 const std::string& tier_name,
549 GenerationResult& result)
550{
551 std::atomic<bool> local_cancel{false};
552 // gh#108: pass an EMPTY std::function (not a bound no-op lambda) so the MTP
553 // path can distinguish non-streaming from streaming via the callback's
554 // bound-ness. gh#36's emit guards `if (on_token)`, so empty is equivalent.
555 return try_speculative_route_streaming(
556 model, messages, params, tier_name,
557 std::function<void(std::string_view)>{}, local_cancel, result);
558}
559
560// ── Generation ─────────────────────────────────────────────
561
578 bool require_tool_call) {
579 if (auto* llama = dynamic_cast<LlamaCppBackend*>(model)) {
580 llama->set_active_tools(params.tools);
581 llama->set_require_tool_call(require_tool_call); // gh#134 (v2.10.4)
582 }
583}
584
602 ChatAdapter* adapter,
603 GenerationResult& result) {
604 if (result.content.empty()) { return; }
605 result.raw_content = result.content;
606 // gh#108 (v2.10.3): one template-first/adapter-second rule, shared with
607 // interface_factory. See response_parse.h for why content composes and
608 // tool calls do not.
610 dynamic_cast<LlamaCppBackend*>(model), adapter, result.content);
611 result.content = std::move(parsed.content);
612 result.tool_calls = std::move(parsed.tool_calls);
613}
614
615
642static void warn_if_content_vanished(const GenerationResult& result) {
643 const auto cause = diagnose_empty_content(
644 result.content.empty(), !result.raw_content.empty(),
645 result.finish_reason, !result.tool_calls.empty());
646 if (cause == EmptyContentCause::not_empty) { return; }
648 logger->info("Turn delivered {} tool call(s) and no prose. {}",
649 result.tool_calls.size(), explain_empty_content(cause));
650 return;
651 }
652 logger->warn("Turn produced {} raw chars but delivered no content. {}",
653 result.raw_content.size(), explain_empty_content(cause));
654}
655
679 const GenerationResult& result,
680 const std::string& tier_name,
681 const std::unordered_map<std::string, TierConfig>& tiers) {
682 if (!result.tool_calls.empty()) { return; }
683 auto it = tiers.find(tier_name);
684 if (it == tiers.end()
685 || !it->second.require_tool_call.value_or(false)) { return; }
686 if (result.finish_reason != "length") { return; }
687 logger->error(
688 "Tier '{}' requires a tool call but hit its token budget before "
689 "emitting one (finish_reason=length, 0 tool calls). The tool-call "
690 "grammar permits unbounded text BEFORE the mandated call, so the call "
691 "must fit inside max_tokens. Raise max_tokens, or disable "
692 "enable_thinking on this tier — the thinking channel is what consumes "
693 "the preamble. This is a budget misconfiguration, not a model failure.",
694 tier_name);
695}
696
711 const GenerationResult& result,
712 const std::string& tier_name,
713 const std::unordered_map<std::string, TierConfig>& tiers) {
715 warn_if_budget_starved_required_turn(result, tier_name, tiers);
716}
717
742GenerationParams ModelOrchestrator::resolve_and_stage(
743 InferenceBackend* model,
744 const GenerationParams& params,
745 const std::string& tier_name) {
746 GenerationParams resolved = params;
747 apply_tier_sampler_defaults(resolved, tier_name); // gh#82
748 // gh#134 (v2.10.4): per-tier, never global — front-office tiers
749 // legitimately answer in prose.
750 bool require_tc = false;
751 if (auto it = config_.models.tiers.find(tier_name);
752 it != config_.models.tiers.end()) {
753 require_tc = it->second.require_tool_call.value_or(false);
754 }
755 stage_active_tools(model, resolved, require_tc); // gh#87 3b, gh#134
756 return resolved;
757}
758
770static void log_orchestration(const GenerationResult& result,
771 const std::string& selected,
772 const std::string& adapter_name,
774 double routing_ms, double swap_ms) {
775 logger->info("Orchestration: tier={}, adapter={}, grammar={}",
776 selected, adapter_name,
777 params.grammar.empty() ? "unconstrained"
778 : params.grammar_key);
779 logger->info("Total: {:.0f}ms (route={:.0f}ms, swap={:.0f}ms, "
780 "gen={:.0f}ms)",
781 result.total_ms, routing_ms, swap_ms,
782 result.generation_time_ms);
783}
784
809 const std::vector<Message>& messages,
811 const std::string& tier_name)
812{
813 // gh#158 (v2.13.0): one lock, outermost on the generation path.
814 // See `generation_mutex_` in orchestrator.h for the full order.
815 std::lock_guard<std::recursive_mutex> gen_lock(generation_mutex_);
816 auto t_start = now();
817
818 // Route if no explicit tier
819 std::string selected = tier_name;
820 double routing_ms = 0.0;
821 if (selected.empty()) {
822 auto t_route = now();
823 selected = route(messages);
824 routing_ms = elapsed_ms(t_route, now());
825 }
826
827 // gh#154: an unregistered tier grammar refuses the run — before the
828 // model is resolved, so a doomed run costs no swap and decodes nothing.
830 auto refusal = refuse_unresolved_tier_grammar(resolved_params, selected);
831 if (refusal.has_value()) { return *refusal; }
832
833 // Get model (may trigger swap)
834 auto t_swap = now();
835 InferenceBackend* model = get_model(selected);
836 double swap_ms = elapsed_ms(t_swap, now());
837
838 if (!model) { return build_no_model_error(selected); }
839
841 resolve_and_stage(model, resolved_params, selected); // gh#87 3b
842
843 // Generate — speculative routing applies here too (v2.1.11, gh#36)
844 GenerationResult result = run_generate_dispatch(
845 model, messages, resolved_params, selected);
846
847 finish_generation(result, model, resolved_params, selected,
848 routing_ms, swap_ms, t_start);
849 return result;
850}
851
867 const std::vector<Message>& messages,
869 std::atomic<bool>& cancel,
870 const std::string& tier_name)
871{
872 // gh#158 (v2.13.0): one lock, outermost on the generation path.
873 // See `generation_mutex_` in orchestrator.h for the full order.
874 std::lock_guard<std::recursive_mutex> gen_lock(generation_mutex_);
875 auto t_start = now();
876
877 std::string selected = tier_name;
878 double routing_ms = 0.0;
879 if (selected.empty()) {
880 auto t_route = now();
881 selected = route(messages);
882 routing_ms = elapsed_ms(t_route, now());
883 }
884
885 // gh#154: same gate as the non-cancellable overload — one rule, every
886 // entry point, always before a model is resolved.
888 auto refusal = refuse_unresolved_tier_grammar(resolved_params, selected);
889 if (refusal.has_value()) { return *refusal; }
890
891 auto t_swap = now();
892 InferenceBackend* model = get_model(selected);
893 double swap_ms = elapsed_ms(t_swap, now());
894
895 if (!model) { return build_no_model_error(selected); }
896
898 resolve_and_stage(model, resolved_params, selected); // gh#87 3b
899
900 GenerationResult result = model->generate(
901 messages, resolved_params, cancel);
902
903 finish_generation(result, model, resolved_params, selected,
904 routing_ms, swap_ms, t_start);
905 return result;
906}
907
908namespace {
923const std::string& batch_arm_tier(const std::vector<std::string>& tiers,
924 std::size_t i,
925 const std::string& lead) {
926 return (i < tiers.size() && !tiers[i].empty()) ? tiers[i] : lead;
927}
928} // namespace
929
948std::vector<GenerationResult> ModelOrchestrator::generate_batch(
949 const std::vector<std::vector<Message>>& messages_list,
950 const std::vector<GenerationParams>& params_list,
951 const std::vector<std::string>& tiers,
952 std::atomic<bool>& cancel)
953{
954 // gh#158 (v2.13.0): one lock, outermost on the generation path.
955 // See `generation_mutex_` in orchestrator.h for the full order.
956 std::lock_guard<std::recursive_mutex> gen_lock(generation_mutex_);
957 const std::size_t n = messages_list.size();
958 const std::string lead =
959 (tiers.empty() || tiers[0].empty()) ? "default" : tiers[0];
960
961 // gh#154: every arm's grammar resolves before any model is touched. A
962 // shared prefill cannot run half-constrained, so one unresolved arm
963 // refuses the whole batch.
964 std::vector<GenerationParams> resolved;
965 auto refusal = refuse_unresolved_batch_grammars(
966 params_list, tiers, lead, resolved);
967 if (refusal.has_value()) {
968 return std::vector<GenerationResult>(n, *refusal);
969 }
970
971 InferenceBackend* model = get_model(lead);
972 if (model == nullptr) {
973 return std::vector<GenerationResult>(n, build_no_model_error(lead));
974 }
975 stage_batch_arms(model, tiers, lead, resolved);
976
977 auto results = model->generate_batch(messages_list, resolved, cancel);
978 for (std::size_t i = 0; i < results.size(); ++i) {
980 model, get_adapter(batch_arm_tier(tiers, i, lead)), results[i]);
981 // gh#154: a batch request is a generation. Each arm carries its
982 // OWN resolved params, so each gets its own provenance — a
983 // per-batch record would hide a tier whose grammar missed.
984 record_generation(results[i], resolved[i], model);
985 }
986 return results;
987}
988
1003void ModelOrchestrator::stage_batch_arms(
1004 InferenceBackend* model,
1005 const std::vector<std::string>& tiers,
1006 const std::string& lead,
1007 std::vector<GenerationParams>& resolved)
1008{
1009 for (std::size_t i = 0; i < resolved.size(); ++i) {
1010 resolved[i] = resolve_and_stage(
1011 model, resolved[i], batch_arm_tier(tiers, i, lead));
1012 }
1013}
1014
1023static void stream_token_trampoline(const char* data, std::size_t len,
1024 void* ud) {
1025 (*static_cast<std::function<void(std::string_view)>*>(ud))(
1026 std::string_view(data, len));
1027}
1028
1068 const std::vector<Message>& messages,
1069 const GenerationParams& params,
1070 std::function<void(std::string_view)> on_token,
1071 std::atomic<bool>& cancel,
1072 const std::string& tier_name)
1073{
1074 // gh#158 (v2.13.0): one lock, outermost on the generation path.
1075 // See `generation_mutex_` in orchestrator.h for the full order.
1076 std::lock_guard<std::recursive_mutex> gen_lock(generation_mutex_);
1077 std::string selected = tier_name.empty() ? route(messages) : tier_name;
1078
1079 // gh#154: refuse an unregistered tier grammar before the model is
1080 // resolved — no swap, no prefill, and not one token streamed.
1082 auto refusal = refuse_unresolved_tier_grammar(resolved_params, selected);
1083 if (refusal.has_value()) { return *refusal; }
1084
1085 InferenceBackend* model = get_model(selected);
1086
1087 if (!model) {
1090 err.error_message = "No model for tier: " + selected;
1091 err.finish_reason = "error";
1092 return err;
1093 }
1094
1096 resolve_and_stage(model, resolved_params, selected); // gh#87 3b
1097
1098 // gh#108 (v2.10.3): strip this family's reasoning blocks from the live
1099 // stream. v2.10.0 added the filter but left it on its hardcoded `<think>`
1100 // pair, so gemma4's `<|channel>` passed straight through. Markers now come
1101 // from the resolved adapter, the same source the buffered strip uses.
1102 //
1103 // This is not cosmetic: ResponseGenerator::generate_streaming builds
1104 // result.content from its OWN token accumulator and discards what
1105 // apply_adapter_parse produced, so on the agent-loop streaming path this
1106 // filter is the only thing standing between raw reasoning and the
1107 // conversation history.
1109 const auto markers = (stream_adapter != nullptr)
1110 ? stream_adapter->thinking_markers() : ThinkMarkers{};
1112 markers.open, markers.close);
1113 auto filtered = [&filter](std::string_view sv) {
1114 filter.on_token(sv.data(), sv.size());
1115 };
1116
1117 GenerationResult result;
1118 bool routed = config_.inference.speculative.enabled
1119 && try_speculative_route_streaming(
1120 model, messages, resolved_params, selected, filtered, cancel,
1121 result);
1122 if (!routed) {
1123 result = model->generate_streaming(
1124 messages, resolved_params, filtered, cancel);
1125 }
1126 filter.flush();
1127 apply_adapter_parse(model, get_adapter(selected), result);
1128 // gh#134 (v2.10.4): name a budget-starved mandatory-tool turn.
1130 config_.models.tiers);
1131 // gh#154: the streaming path runs the speculative/MTP kernels, so
1132 // leaving it unrecorded would omit exactly the decodes whose
1133 // drafted/accepted counts the record exists to carry.
1134 record_generation(result, resolved_params, model);
1135 return result;
1136}
1137
1138// ── Routing ────────────────────────────────────────────────
1139
1157std::string ModelOrchestrator::route(const std::vector<Message>& messages) {
1158 if (!config_.routing.enabled
1159 || !config_.models.router.has_value()) {
1160 logger->info("Route: routing disabled, using default '{}'",
1161 default_tier_);
1162 last_routing_result_ = {default_tier_, "", "", "none", 0.0};
1163 return default_tier_;
1164 }
1165
1166 auto [tier, raw] = classify_task(messages);
1167 last_routing_result_ = {tier, loaded_main_tier_, raw, "none", 0.0};
1168
1169 // Track history
1170 tier_history_.push_back(tier);
1171 if (tier_history_.size() > 5) {
1172 tier_history_.erase(tier_history_.begin());
1173 }
1174
1175 logger->info("[ROUTER] {} | raw='{}'", tier, raw);
1176 return tier;
1177}
1178
1202std::pair<std::string, std::string> ModelOrchestrator::classify_task(
1203 const std::vector<Message>& messages)
1204{
1205 // gh#158 (v2.13.0): the router decodes, so this is a generation entry
1206 // point and takes the same outermost lock. Reached from `route()` inside
1207 // an already-locked `generate*`, which is exactly why the lock is
1208 // recursive — see `generation_mutex_` in orchestrator.h.
1209 std::lock_guard<std::recursive_mutex> gen_lock(generation_mutex_);
1210 std::string user_msg = extract_latest_user_message(messages);
1211
1214 router_params.temperature = 0.0f;
1215
1216 // gh#198: a defer_load handle did not load the router at init. No-op
1217 // when it is already loaded or not configured; ensure_loaded locks its
1218 // own slots_mutex_, and this runs under generation_mutex_.
1219 activate_router(config_);
1220 auto* router_backend = secondary_loader_.get("router");
1221 if (router_backend == nullptr) {
1222 logger->warn("classify_task: router not loaded; returning empty");
1223 return {"", ""};
1224 }
1225 // audit task #71: a non-fine-tuned router fed the bare "<msg> ->" just
1226 // CONTINUES the text and never emits a routing digit, so classify_task
1227 // silently always fell back to the default tier. When the deployment
1228 // configures routing.classification_prompt, prepend it so a general
1229 // instruct model is actually told the digit scheme. (The trailing " ->"
1230 // still constrains it to a single digit, per build_classification_prompt.)
1231 std::string router_prompt = user_msg + " ->";
1232 const auto& cprompt = config_.routing.classification_prompt;
1233 if (cprompt.has_value() && !cprompt->empty()) {
1234 router_prompt = *cprompt + "\n" + user_msg + " ->";
1235 // A general instruct model emits a leading space before the digit;
1236 // max_tokens=1 would cut it off. 4 captures "<space>1"; the digit scan
1237 // below takes the first tier_map char. Only widened on the prompt path
1238 // so unconfigured deployments keep the original 1-token behavior.
1239 router_params.max_tokens = 4;
1240 // v2.8.1 (review #3): classification_prompt was parsed-but-never-read
1241 // before the v2.8.0 fix. Log when the active (prompt) path is taken so
1242 // a deployment carrying a stale prompt sees the inert->active switch +
1243 // the widened token budget instead of a silent behavior change.
1244 logger->info("classify_task: using configured classification_prompt "
1245 "(router instructed; max_tokens widened to 4)");
1246 }
1247 auto result = router_backend->complete(router_prompt, router_params);
1248 std::string raw = result.content;
1249
1250 // Trim whitespace
1251 auto start = raw.find_first_not_of(" \t\n\r");
1252 if (start != std::string::npos) {
1253 raw = raw.substr(start);
1254 }
1255
1256 // Find matching tier
1257 for (char c : raw) {
1258 std::string digit(1, c);
1259 auto it = tier_map_.find(digit);
1260 if (it != tier_map_.end()) {
1261 logger->info("Route: digit='{}' -> tier='{}'",
1262 digit, it->second);
1263 return {it->second, digit};
1264 }
1265 }
1266
1267 logger->warn("Route: no valid digit in '{}', defaulting to {}",
1268 raw, default_tier_);
1269 return {default_tier_, ""};
1270}
1271
1272// ── Model access ───────────────────────────────────────────
1273
1292void ModelOrchestrator::record_activation_reuse(
1293 const std::string& tier_name) {
1294 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
1295 std::chrono::steady_clock::now() - start_time_).count();
1296 bool tier_changed = (loaded_main_tier_ != tier_name);
1297 tier_last_activation_ms_[tier_name] = now_ms;
1298 if (!tier_changed) { return; }
1299 auto tier_it = config_.models.tiers.find(tier_name);
1300 std::string path = tier_it != config_.models.tiers.end()
1301 ? tier_it->second.path.string() : "";
1302 size_t footprint = tier_footprint_bytes_.count(tier_name)
1303 ? tier_footprint_bytes_[tier_name]
1304 : estimate_footprint_bytes(tier_name);
1305 tier_footprint_bytes_[tier_name] = footprint;
1306 loaded_main_tier_ = tier_name;
1307 fire_residency_observer(ResidencyEvent::ActivationSwap,
1308 tier_name, path, footprint);
1309}
1310
1311// Defined lower down, beside estimate_footprint_bytes which shares it.
1312static FootprintInputs footprint_inputs_for(
1313 const TierConfig& tier_cfg, uint64_t weights_bytes, int vram_reserve_mb);
1314
1327void ModelOrchestrator::log_fit_recommendation(
1328 const std::string& tier_name) const {
1329 auto tier_it = config_.models.tiers.find(tier_name);
1330 if (tier_it == config_.models.tiers.end()) { return; }
1331 const auto& tier_cfg = tier_it->second;
1332 std::error_code ec;
1333 auto weights = std::filesystem::file_size(tier_cfg.path, ec);
1334 if (ec) { return; }
1335 FootprintInputs in = footprint_inputs_for(
1336 tier_cfg, weights, config_.vram_reserve_mb);
1337 int fits = recommend_context_length(in, vram_budget_bytes_);
1338 if (fits > 0) {
1339 logger->error("[residency] tier '{}' fits at context_length={} "
1340 "(requested {}) — lower it, or reduce gpu_layers to "
1341 "keep the requested context",
1342 tier_name, fits, tier_cfg.context_length);
1343 } else {
1344 logger->error("[residency] tier '{}' does not fit at ANY context "
1345 "length: weights{} alone exceed the {} MiB budget. "
1346 "Reduce gpu_layers, or use a smaller quantization.",
1347 tier_name,
1348 in.mmproj_bytes > 0 ? " + vision projector" : "",
1349 vram_budget_bytes_ / (1024 * 1024));
1350 }
1351}
1352
1372void ModelOrchestrator::resolve_auto_gpu_layers(const std::string& tier_name) {
1373 auto it = config_.models.tiers.find(tier_name);
1374 if (it == config_.models.tiers.end() || !it->second.gpu_layers_auto) {
1375 return;
1376 }
1377 std::error_code ec;
1378 auto file_bytes = std::filesystem::file_size(it->second.path, ec);
1379 if (ec || vram_budget_bytes_ == 0) {
1380 logger->warn("[residency] tier '{}': gpu_layers=auto cannot be "
1381 "resolved ({}) — keeping gpu_layers={}", tier_name,
1382 ec ? "model file unreadable" : "free VRAM unknown",
1383 it->second.gpu_layers);
1384 return;
1385 }
1386
1387 // v2.13.1: read the model's actual shape instead of assuming 30 layers
1388 // and `file_bytes / 30` per layer. A metadata-only open costs the header
1389 // and the tensor index — no tensor data — regardless of file size.
1390 const GgufShape shape = read_gguf_shape(it->second.path);
1391 FootprintInputs base = footprint_inputs_for(
1392 it->second, static_cast<uint64_t>(file_bytes),
1393 config_.vram_reserve_mb);
1394 base.draft_bytes = resident_draft_bytes();
1395
1396 const AutoPlacement placement =
1397 derive_auto_placement(shape, base, vram_budget_bytes_,
1398 it->second.n_ubatch);
1399 if (!placement.known) {
1400 fall_back_to_weights_estimate(tier_name, file_bytes, placement.reason);
1401 return;
1402 }
1403
1404 log_auto_placement(tier_name, shape, placement, file_bytes);
1405 it->second.gpu_layers = placement.gpu_layers;
1406 it->second.cpu_moe_layers = placement.cpu_moe_layers;
1407 // v2.13.2: only ever LOWERED, and only when that is what bought full
1408 // residency. 0 means auto did not touch it, so an operator's own value
1409 // is preserved unless it was the thing standing between them and the
1410 // whole model on the card.
1411 if (placement.n_ubatch > 0) { it->second.n_ubatch = placement.n_ubatch; }
1412}
1413
1424uint64_t ModelOrchestrator::resident_draft_bytes() const {
1425 if (!config_.inference.speculative.enabled) { return 0; }
1426 std::error_code ec;
1427 const auto bytes = std::filesystem::file_size(
1428 config_.inference.speculative.draft.path, ec);
1429 return ec ? 0ull : static_cast<uint64_t>(bytes);
1430}
1431
1445void ModelOrchestrator::fall_back_to_weights_estimate(
1446 const std::string& tier_name, uint64_t file_bytes, const char* why) {
1447 auto it = config_.models.tiers.find(tier_name);
1448 if (it == config_.models.tiers.end()) { return; }
1449 const int layers = partial_gpu_layers_for(file_bytes, vram_budget_bytes_);
1450 logger->warn("[residency] tier '{}': gpu_layers=auto could not read the "
1451 "model's shape ({}) — falling back to the weights-only "
1452 "estimate, {} layers", tier_name, why, layers);
1453 it->second.gpu_layers = layers;
1454}
1455
1470void ModelOrchestrator::log_auto_placement(
1471 const std::string& tier_name, const GgufShape& shape,
1472 const AutoPlacement& placement, uint64_t file_bytes) const {
1473 const std::string experts = placement.cpu_moe_layers > 0
1474 ? fmt::format(", {} layers' experts host-side",
1475 placement.cpu_moe_layers)
1476 : std::string();
1477 // v2.13.2: an operator who reads "30 of 30 layers" and then measures a
1478 // prefill rate half what they expected is owed the reason in the same
1479 // line that made the trade.
1480 const std::string ubatch = placement.n_ubatch > 0
1481 ? fmt::format(", n_ubatch lowered to {}", placement.n_ubatch)
1482 : std::string();
1483 logger->info("[residency] tier '{}': gpu_layers=auto -> {} of {} layers{}{} "
1484 "— {} ({} MiB estimated of {} MiB free; {} MiB model, "
1485 "{} experts)",
1486 tier_name,
1487 placement.fully_resident ? shape.block_count
1488 : placement.gpu_layers,
1489 shape.block_count, experts, ubatch,
1490 placement.reason, placement.bytes / (1024 * 1024),
1491 vram_budget_bytes_ / (1024 * 1024),
1492 file_bytes / (1024 * 1024), shape.expert_count);
1493 // Stated separately and always, because it is the figure that decides
1494 // whether the placement survives a longer prompt or one more tool
1495 // schema — and auto accepts a placement that merely fits.
1496 logger->info("[residency] tier '{}': {} MiB of VRAM left free after this "
1497 "placement{}", tier_name,
1498 placement.margin_bytes / (1024 * 1024),
1499 placement.margin_bytes < 256ull * 1024 * 1024
1500 ? " — thin; growth in prompt or tool schemas may push"
1501 " this tier off the card"
1502 : "");
1503}
1504
1527bool ModelOrchestrator::config_admits(const std::string& tier_name) {
1528 auto it = config_.models.tiers.find(tier_name);
1529 if (it == config_.models.tiers.end()) { return true; }
1530 const auto& cfg = it->second;
1531 std::error_code ec;
1532 auto file_bytes = std::filesystem::file_size(cfg.path, ec);
1533 if (ec) { return true; }
1534
1536 bool refused = true;
1537 // v2.13.1: an expert split leaves expert weights HOST-resident while
1538 // gpu_layers reports -1, so the pinned bytes are theirs, not the file's.
1539 const uint64_t host_experts = host_expert_bytes_for(cfg);
1540 if (mlock_refused(cfg.use_mlock, file_bytes, cfg.gpu_layers, memlock,
1541 host_experts)) {
1543 refuse_residency(tier_name, ENTROPIC_ERROR_MLOCK_LIMIT_EXCEEDED,
1544 "use_mlock would pin " + std::to_string(pinned)
1545 + " bytes against an RLIMIT_MEMLOCK of "
1546 + std::to_string(memlock)
1547 + " bytes. Pinned pages cannot be reclaimed under pressure, so "
1548 "a partially offloaded model of this size is killed rather "
1549 "than paged. Set use_mlock: false for this tier.");
1550 } else if (gpu_offload_refused(file_bytes, cfg.gpu_layers,
1551 vram_budget_bytes_)) {
1552 refuse_residency(tier_name, ENTROPIC_ERROR_TIER_MODEL_TOO_LARGE,
1553 "gpu_layers=" + std::to_string(cfg.gpu_layers) + " cannot fit: "
1554 + std::to_string(vram_budget_bytes_ / (1024 * 1024))
1555 + " MiB of free VRAM is not enough for the compute buffers "
1556 "alone, so no positive layer count can work. Free VRAM, or "
1557 "set gpu_layers: 0 to run on the CPU.");
1558 } else {
1559 refused = false;
1560 }
1561 return !refused;
1562}
1563
1573void ModelOrchestrator::refuse_residency(const std::string& tier_name,
1575 const std::string& why) {
1576 logger->error("[residency] tier '{}' refused: {}", tier_name, why);
1577 last_residency_error_ = code;
1578 last_residency_message_ = "Tier '" + tier_name + "': " + why;
1579}
1580
1594uint64_t ModelOrchestrator::host_expert_bytes_for(const ModelConfig& cfg) {
1595 if (cfg.cpu_moe_layers <= 0) { return 0; }
1596 const GgufShape shape = read_gguf_shape(cfg.path);
1597 if (!shape.known) { return 0; }
1598 return shape.expert_bytes_per_block()
1599 * static_cast<uint64_t>(cfg.cpu_moe_layers);
1600}
1601
1612bool ModelOrchestrator::residency_admits(const std::string& tier_name) {
1613 // gh#198: a deferred handle measures here, on its first admission.
1614 ensure_vram_budget();
1615 // gh#148: resolve `gpu_layers: auto` and refuse a config that provably
1616 // cannot work, BEFORE pricing the footprint — both change what is being
1617 // priced, and both are loud rather than silently corrected.
1618 resolve_auto_gpu_layers(tier_name);
1619 if (!config_admits(tier_name)) { return false; }
1620
1621 size_t footprint = estimate_footprint_bytes(tier_name);
1622 if (footprint > 0) {
1623 tier_footprint_bytes_[tier_name] = footprint;
1624 }
1625 if (vram_budget_bytes_ > 0 && footprint > vram_budget_bytes_) {
1626 logger->error("[residency] tier '{}' footprint {} bytes "
1627 "exceeds VRAM budget {} bytes — "
1628 "TIER_MODEL_TOO_LARGE (gh#57)",
1629 tier_name, footprint, vram_budget_bytes_);
1630 // gh#142: a bare refusal leaves the operator with nothing to act on.
1631 // Say what WOULD fit, so the answer is a setting they can apply rather
1632 // than a wall. 0 means even an empty context does not fit, in which
1633 // case the context length is not the lever and saying so is honest.
1634 log_fit_recommendation(tier_name);
1635 last_residency_error_ = ENTROPIC_ERROR_TIER_MODEL_TOO_LARGE;
1636 return false;
1637 }
1638 return true;
1639}
1640
1660GenerationResult ModelOrchestrator::build_no_model_error(
1661 const std::string& tier_name) {
1662 GenerationResult err;
1663 err.finish_reason = "error";
1664 if (last_residency_error_ != ENTROPIC_OK) {
1665 err.error_code = last_residency_error_;
1666 // gh#148: a typed refusal carries the setting to change. Fall back
1667 // to the gh#57 budget wording when nothing was stashed.
1668 err.error_message = !last_residency_message_.empty()
1669 ? last_residency_message_
1670 : "Tier '" + tier_name + "' model exceeds the "
1671 "engine's VRAM budget (gh#57)";
1672 last_residency_error_ = ENTROPIC_OK;
1673 last_residency_message_.clear();
1674 } else {
1676 err.error_message = "No model available for tier: " + tier_name;
1677 }
1678 return err;
1679}
1680
1694InferenceBackend* ModelOrchestrator::activate_and_track(
1695 const std::string& tier_name,
1696 const std::shared_ptr<InferenceBackend>& backend) {
1697 auto tier_it = config_.models.tiers.find(tier_name);
1698 bool activated = tier_it != config_.models.tiers.end()
1699 && backend->load_and_activate(tier_it->second);
1700 if (!activated) {
1701 logger->error("Failed to activate tier: {}", tier_name);
1702 return nullptr;
1703 }
1704 loaded_main_tier_ = tier_name;
1705 last_routing_result_.swap_action = "loaded";
1706 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
1707 std::chrono::steady_clock::now() - start_time_).count();
1708 tier_last_activation_ms_[tier_name] = now_ms;
1709 size_t footprint = tier_footprint_bytes_.count(tier_name)
1710 ? tier_footprint_bytes_[tier_name] : 0;
1711 // gh#157: the model exists now, so its adapters can bind. This is the
1712 // only moment at which that is true for a non-default tier.
1713 preload_adapters_for_model(backend.get());
1714 fire_residency_observer(ResidencyEvent::Loaded,
1715 tier_name, tier_it->second.path.string(),
1716 footprint);
1717 return backend.get();
1718}
1719
1733InferenceBackend* ModelOrchestrator::get_model(const std::string& tier_name) {
1734 std::lock_guard<std::mutex> lock(swap_mutex_);
1735
1736 auto it = tiers_.find(tier_name);
1737 std::string effective_tier = tier_name;
1738 if (it == tiers_.end()) {
1739 it = tiers_.find(config_.routing.fallback_tier);
1740 if (it != tiers_.end()) {
1742 }
1743 }
1744
1745 InferenceBackend* result = nullptr;
1746 if (it != tiers_.end() && it->second->is_active()) {
1747 last_routing_result_.swap_action = "reused";
1748 record_activation_reuse(effective_tier);
1749 result = it->second.get();
1750 } else if (it != tiers_.end() && residency_admits(effective_tier)) {
1751 deactivate_current_if_needed(it->second.get());
1752 result = activate_and_track(effective_tier, it->second);
1753 }
1754
1755 // Ensure correct LoRA adapter for this tier (v1.9.2)
1756 if (result) {
1757 ensure_tier_lora(tier_name, result);
1758 // gh#164: `entropic_release_model(NULL)` drops the router and draft
1759 // too, and neither reloads itself — routing would degrade to the
1760 // default tier silently and permanently. No-op when they are loaded.
1761 ensure_secondary_roles();
1762 }
1763
1764 return result;
1765}
1766
1774void ModelOrchestrator::ensure_tier_lora(const std::string& tier_name,
1775 InferenceBackend* result) {
1776 auto* llama_backend = dynamic_cast<LlamaCppBackend*>(result);
1778 ? llama_backend->llama_context_ptr() : nullptr;
1779 double adapter_ms = ensure_adapter_for_tier(tier_name, ctx);
1780 last_routing_result_.adapter_swap_ms = adapter_ms;
1781 last_routing_result_.adapter_name = lora_manager_.active_adapter();
1782}
1783
1795void ModelOrchestrator::deactivate_current_if_needed(InferenceBackend* incoming) {
1796 auto it = loaded_main_tier_.empty()
1797 ? tiers_.end() : tiers_.find(loaded_main_tier_);
1798
1799 bool should_swap = it != tiers_.end()
1800 && it->second.get() != incoming
1801 && it->second->is_loaded();
1802
1803 if (!should_swap) {
1804 return;
1805 }
1806
1807 // Cascade: unload adapters for this base model (v1.9.2)
1808 auto* llama_backend = dynamic_cast<LlamaCppBackend*>(it->second.get());
1809 if (llama_backend) {
1810 lora_manager_.unload_all_for_model(
1811 llama_backend->llama_model_ptr(),
1812 llama_backend->llama_context_ptr());
1813 }
1814
1815 unload_or_warm_current(it->second.get());
1816}
1817
1824void ModelOrchestrator::unload_or_warm_current(InferenceBackend* current) {
1825 auto cfg_it = config_.models.tiers.find(loaded_main_tier_);
1826 bool keep_warm = cfg_it != config_.models.tiers.end()
1827 && cfg_it->second.keep_warm;
1828
1829 if (keep_warm) {
1830 logger->info("Deactivating {} (keep_warm=true)", loaded_main_tier_);
1831 current->deactivate();
1832 return;
1833 }
1834 logger->info("Unloading {} (keep_warm=false)", loaded_main_tier_);
1835 std::string path = cfg_it != config_.models.tiers.end()
1836 ? cfg_it->second.path.string() : "";
1837 size_t footprint = tier_footprint_bytes_.count(loaded_main_tier_)
1838 ? tier_footprint_bytes_[loaded_main_tier_] : 0;
1839 std::string evicted_tier = loaded_main_tier_;
1840 current->unload();
1841 fire_residency_observer(ResidencyEvent::Evicted,
1842 evicted_tier, path, footprint);
1843}
1844
1845// ── Queries ────────────────────────────────────────────────
1846
1856 return last_routing_result_;
1857}
1858
1865 return loaded_main_tier_;
1866}
1867
1880std::vector<std::string> ModelOrchestrator::loaded_models() const {
1881 std::vector<std::string> result;
1882 for (const auto& [name, backend] : tiers_) {
1883 if (backend->is_loaded()) {
1884 result.push_back(name);
1885 }
1886 }
1887 if (secondary_loader_.is_loaded("router")) {
1888 result.push_back("router");
1889 }
1890 return result;
1891}
1892
1898std::vector<std::string> ModelOrchestrator::available_models() const {
1899 std::vector<std::string> result;
1900 for (const auto& [name, _] : tiers_) {
1901 result.push_back(name);
1902 }
1903 if (config_.models.router.has_value()) {
1904 result.push_back("router");
1905 }
1906 return result;
1907}
1908
1917 const std::string& tier_name) const {
1918 auto it = tiers_.find(tier_name);
1919 if (it == tiers_.end()) { return nullptr; }
1920 return it->second.get();
1921}
1922
1932 const std::string& tier_name) {
1933 return get_model(tier_name);
1934}
1935
1945 const std::string& tier_name) {
1946 std::lock_guard<std::mutex> lock(swap_mutex_);
1947
1948 if (!tier_name.empty()) {
1949 auto it = tiers_.find(tier_name);
1950 if (it == tiers_.end()) {
1951 logger->error("[residency] release: unknown tier '{}'",
1952 tier_name);
1954 }
1955 release_backend(it->second.get());
1956 return ENTROPIC_OK;
1957 }
1958
1959 std::unordered_set<const InferenceBackend*> seen;
1960 for (const auto& [name, backend] : tiers_) {
1961 (void)name;
1962 if (backend && seen.insert(backend.get()).second) {
1963 release_backend(backend.get());
1964 }
1965 }
1966 // Secondary roles (router, speculative draft). They re-ensure on the
1967 // next cold activation — see `activate_and_track`.
1968 secondary_loader_.shutdown();
1969 return ENTROPIC_OK;
1970}
1971
1984void ModelOrchestrator::release_backend(InferenceBackend* backend) {
1985 if (backend == nullptr || !backend->is_loaded()) { return; }
1986
1987 auto* llama_backend = dynamic_cast<LlamaCppBackend*>(backend);
1988 if (llama_backend != nullptr) {
1989 // KEEPS the registrations, unlike the swap path's
1990 // `unload_all_for_model` — see AdapterManager (gh#164).
1991 lora_manager_.release_handles_for_model(
1992 llama_backend->llama_model_ptr(),
1993 llama_backend->llama_context_ptr());
1994 }
1995 backend->unload();
1996 announce_eviction(backend);
1997}
1998
2011void ModelOrchestrator::announce_eviction(const InferenceBackend* backend) {
2012 for (const auto& [name, bound] : tiers_) {
2013 if (bound.get() != backend) { continue; }
2014 auto cfg_it = config_.models.tiers.find(name);
2015 std::string path = cfg_it != config_.models.tiers.end()
2016 ? cfg_it->second.path.string() : "";
2017 auto fp_it = tier_footprint_bytes_.find(name);
2018 size_t footprint = fp_it != tier_footprint_bytes_.end()
2019 ? fp_it->second : 0;
2020 // Leave no stale incumbent: the next activation reads this to
2021 // decide whether a swap-out is needed.
2022 if (loaded_main_tier_ == name) { loaded_main_tier_.clear(); }
2023 fire_residency_observer(ResidencyEvent::Evicted, name, path,
2024 footprint);
2025 }
2026}
2027
2037 const std::string& tier_name) const {
2038 auto it = config_.models.tiers.find(tier_name);
2039 if (it == config_.models.tiers.end()) { return false; }
2040 return it->second.has_capability("vision")
2041 || !it->second.mmproj_path.empty();
2042}
2043
2054 const std::string& from, const std::string& to) const
2055{
2056 auto it = handoff_rules_.find(from);
2057 if (it == handoff_rules_.end()) {
2058 return false;
2059 }
2060 return it->second.count(to) > 0;
2061}
2062
2068ChatAdapter* ModelOrchestrator::get_adapter(const std::string& tier_name) const {
2069 auto it = adapters_.find(tier_name);
2070 if (it != adapters_.end()) {
2071 return it->second.get();
2072 }
2073 return nullptr;
2074}
2075
2076// ── LoRA adapter management (v1.9.2) ──────────────────────
2077
2098bool ModelOrchestrator::deactivate_if_active(llama_context* ctx) {
2099 if (lora_manager_.active_adapter().empty()) {
2100 return false;
2101 }
2102 lora_manager_.deactivate(ctx);
2103 return true;
2104}
2105
2114double ModelOrchestrator::ensure_adapter_for_tier(
2115 const std::string& tier_name, llama_context* ctx)
2116{
2117 auto tier_it = config_.models.tiers.find(tier_name);
2118 if (tier_it == config_.models.tiers.end()) {
2119 return 0.0;
2120 }
2121
2122 const auto& tier_cfg = tier_it->second;
2123 auto t_start = now();
2124 bool needs_kv_clear = false;
2125
2126 if (!tier_cfg.adapter_path) {
2127 needs_kv_clear = deactivate_if_active(ctx);
2128 } else if (lora_manager_.active_adapter() != tier_name) {
2129 needs_kv_clear = lora_manager_.swap(tier_name, ctx);
2130 if (!needs_kv_clear) {
2131 logger->warn("Adapter swap to '{}' failed", tier_name);
2132 }
2133 }
2134
2135 if (needs_kv_clear && ctx) {
2137 logger->info("Adapter swap for tier '{}' in {:.1f}ms",
2138 tier_name, elapsed_ms(t_start, now()));
2139 }
2140
2141 return elapsed_ms(t_start, now());
2142}
2143
2163void ModelOrchestrator::preload_adapters_for_model(
2164 InferenceBackend* backend) {
2165 auto* llama_backend = dynamic_cast<LlamaCppBackend*>(backend);
2166 if (!llama_backend || !llama_backend->llama_model_ptr()) { return; }
2167
2168 int loaded = 0;
2169 for (const auto& [name, tier_cfg] : config_.models.tiers) {
2170 auto tier_it = tiers_.find(name);
2171 bool mine = tier_cfg.adapter_path.has_value()
2172 && tier_it != tiers_.end()
2173 && tier_it->second.get() == backend
2174 && lora_manager_.state(name) == AdapterState::COLD;
2175 if (mine && lora_manager_.load(name, *tier_cfg.adapter_path,
2176 llama_backend->llama_model_ptr(),
2177 tier_cfg.adapter_scale)) {
2178 ++loaded;
2179 }
2180 }
2181
2182 if (loaded > 0) {
2183 logger->info("Preloaded {} LoRA adapter(s) to WARM", loaded);
2184 }
2185}
2186
2187// ── Grammar registry (v1.9.3) ──────────────────────────────
2188
2198void ModelOrchestrator::load_bundled_grammars() {
2199 std::filesystem::path grammar_dir;
2200 if (!config_.config_dir.empty()) {
2201 grammar_dir = config_.config_dir / "grammars";
2202 }
2203 if (grammar_dir.empty() || !std::filesystem::is_directory(grammar_dir)) {
2204 // Fallback set by facade via load_grammars_from() if config_dir
2205 // doesn't have a grammars subdir. Check if already loaded.
2206 logger->info("No bundled grammar directory found, skipping");
2207 return;
2208 }
2209
2210 size_t count = grammar_registry_.load_bundled(grammar_dir);
2211 logger->info("Grammar registry: {} grammar(s) loaded from {}",
2212 count, grammar_dir.string());
2213}
2214
2228 const std::filesystem::path& grammar_dir) {
2229 if (!std::filesystem::is_directory(grammar_dir)) {
2230 return 0;
2231 }
2232 auto count = grammar_registry_.load_bundled(grammar_dir);
2233 logger->info("Grammar registry: {} grammar(s) loaded from {}",
2234 count, grammar_dir.string());
2235 return count;
2236}
2237
2249 for (auto& [_, backend] : model_pool_) {
2250 if (backend) { backend->clear_prompt_cache(); }
2251 }
2252 secondary_loader_.clear_all_prompt_caches();
2253 logger->info("Prompt caches invalidated across all backends "
2254 "(identity change)");
2255}
2256
2266 for (const auto& [_, tier] : config_.models.tiers) {
2267 if (tier.has_capability("vision")) { return true; }
2268 }
2269 return false;
2270}
2271
2280 for (const auto& [name, tier] : config_.models.tiers) {
2281 if (tier.has_capability("vision")) { return name; }
2282 }
2283 return "";
2284}
2285
2295 const std::shared_ptr<InferenceBackend>& tier_backend) {
2296 if (!tier_backend || !tier_backend->is_loaded()) {
2297 return nullptr;
2298 }
2299 auto* llama_be = dynamic_cast<LlamaCppBackend*>(tier_backend.get());
2300 return (llama_be == nullptr) ? nullptr : llama_be->llama_model_ptr();
2301}
2302
2313std::string ModelOrchestrator::resolve_speculative_pair(
2315 target_out = nullptr;
2316 draft_out = nullptr;
2317 std::string err;
2318
2319 auto tier_it = tiers_.find(loaded_main_tier_);
2320 if (tier_it == tiers_.end()) {
2321 err = "no main tier loaded";
2322 } else {
2324 if (target_out == nullptr) {
2325 err = "main tier backend is not a llama.cpp backend or "
2326 "is not loaded";
2327 } else {
2328 auto* draft_backend = secondary_loader_.get("draft");
2329 if (draft_backend == nullptr || !draft_backend->is_loaded()) {
2330 err = "no draft model configured for speculative "
2331 "decoding "
2332 "(set inference.speculative.draft_model)";
2333 } else {
2334 auto* d = dynamic_cast<LlamaCppBackend*>(draft_backend);
2335 draft_out = (d == nullptr) ? nullptr : d->llama_model_ptr();
2336 if (draft_out == nullptr) {
2337 err = "draft backend is not a llama.cpp backend";
2338 }
2339 }
2340 }
2341 }
2342 return err;
2343}
2344
2356ModelOrchestrator::SpeculativeCompatInfo
2359 llama_model* target_model = nullptr;
2360 llama_model* draft_model = nullptr;
2361 info.diagnostic = resolve_speculative_pair(target_model, draft_model);
2362 if (info.diagnostic.empty()) {
2363 auto result = entropic::speculative::check_compat(
2365 info.compatible = result.compatible;
2366 info.diagnostic = std::move(result.diagnostic);
2367 }
2368 return info;
2369}
2370
2383void ModelOrchestrator::finish_generation(
2384 GenerationResult& result,
2385 InferenceBackend* model,
2387 const std::string& selected,
2388 double routing_ms,
2389 double swap_ms,
2390 std::chrono::steady_clock::time_point t_start)
2391{
2392 apply_adapter_parse(model, get_adapter(selected), result);
2393 // gh#134 (v2.10.4): name a budget-starved mandatory-tool turn.
2394 warn_turn_diagnostics(result, selected, config_.models.tiers);
2395
2396 result.routing_ms = routing_ms;
2397 result.swap_ms = swap_ms;
2398 result.total_ms = elapsed_ms(t_start, now());
2399 // gh#154: one record per generation, at the tail every orchestrated
2400 // path already shares — so a new decode path cannot ship unrecorded.
2401 record_generation(result, resolved_params, model);
2402 log_orchestration(result, selected, last_routing_result_.adapter_name,
2403 resolved_params, routing_ms, swap_ms);
2404}
2405
2414void ModelOrchestrator::record_generation(
2415 GenerationResult& result,
2416 const GenerationParams& resolved_params,
2417 const InferenceBackend* model)
2418{
2419 const std::string tool_grammar =
2420 model != nullptr ? model->active_tool_grammar() : std::string{};
2422
2423 std::lock_guard<std::mutex> lock(records_mutex_);
2424 if (generation_records_.size() >= kMaxGenerationRecords) {
2425 generation_records_.erase(generation_records_.begin());
2426 }
2427 generation_records_.push_back(make_generation_record(result));
2428}
2429
2436std::vector<GenerationRecord> ModelOrchestrator::generation_records() const
2437{
2438 std::lock_guard<std::mutex> lock(records_mutex_);
2439 return generation_records_;
2440}
2441
2453static std::string normalize_grammar_key(const std::string& grammar_value) {
2454 std::filesystem::path p(grammar_value);
2455 if (p.extension() == ".gbnf") {
2456 return p.stem().string();
2457 }
2458 return grammar_value;
2459}
2460
2490void ModelOrchestrator::resolve_grammar_key(
2491 GenerationParams& params, const std::string& tier_name)
2492{
2493 if (!params.grammar.empty()) {
2494 return;
2495 }
2496
2497 // Try explicit grammar_key
2498 std::string key = params.grammar_key;
2499
2500 // Fall back to tier config grammar field (frontmatter)
2501 if (key.empty()) {
2502 auto it = config_.models.tiers.find(tier_name);
2503 if (it != config_.models.tiers.end() && it->second.grammar) {
2504 key = normalize_grammar_key(it->second.grammar->string());
2505 // gh#154: remember WHERE the key came from. All three
2506 // request-side sources arrive as params.grammar, so without
2507 // this the result cannot say whether the tier asked or the
2508 // caller did.
2509 params.grammar_from_tier = true;
2510 }
2511 }
2512
2513 if (key.empty()) {
2514 return;
2515 }
2516
2517 // Recorded BEFORE the lookup, so a key that resolves to nothing is
2518 // still named on the result. A missing grammar leaves the decode
2519 // unconstrained (REQ-INFER-007) and the absence of a log line was,
2520 // until gh#154, the only signal that it had happened.
2521 params.resolved_grammar_key = key;
2522
2523 std::string content = grammar_registry_.get(key);
2524 if (content.empty()) {
2525 logger->warn("Grammar key '{}' not found in registry — this decode "
2526 "is UNCONSTRAINED; read generations[].grammar.resolved "
2527 "from entropic_metrics_json to detect it", key);
2528 return;
2529 }
2530
2531 logger->info("Grammar resolved: key='{}', {} bytes",
2532 key, content.size());
2533 params.grammar = std::move(content);
2534}
2535
2558std::optional<GenerationResult>
2559ModelOrchestrator::refuse_unresolved_tier_grammar(
2560 GenerationParams& params, const std::string& tier_name)
2561{
2562 resolve_grammar_key(params, tier_name); // v1.9.3
2564 return std::nullopt;
2565 }
2566
2567 GenerationResult err;
2568 err.finish_reason = "error";
2570 err.error_message =
2571 "Tier '" + tier_name + "' declares grammar '"
2572 + params.resolved_grammar_key + "' and no grammar is registered "
2573 "under that key. Register it with entropic_grammar_register_file() "
2574 "or entropic_grammar_register() before running this tier, or place "
2575 + params.resolved_grammar_key + ".gbnf in a grammar search path. "
2576 "Refused rather than decoded unconstrained (gh#154): an "
2577 "unconstrained decode is indistinguishable from a constrained one "
2578 "in the output.";
2579 logger->error("{}", err.error_message);
2580 return err;
2581}
2582
2597std::optional<GenerationResult>
2598ModelOrchestrator::refuse_unresolved_batch_grammars(
2599 const std::vector<GenerationParams>& params_list,
2600 const std::vector<std::string>& tiers,
2601 const std::string& lead,
2602 std::vector<GenerationParams>& out)
2603{
2604 out.clear();
2605 out.reserve(params_list.size());
2606 for (std::size_t i = 0; i < params_list.size(); ++i) {
2607 GenerationParams resolved = params_list[i];
2608 auto refusal = refuse_unresolved_tier_grammar(
2609 resolved, batch_arm_tier(tiers, i, lead));
2610 if (refusal.has_value()) {
2611 return refusal;
2612 }
2613 out.push_back(std::move(resolved));
2614 }
2615 return std::nullopt;
2616}
2617
2618namespace {
2625template <typename T>
2626inline void apply_if_default(T& field, const std::optional<T>& ov, T dflt) {
2627 if (ov.has_value() && field == dflt) { field = *ov; }
2628}
2629} // namespace
2630
2645{
2646 // GenerationParams struct defaults (see types/config.h).
2647 apply_if_default(params.temperature, ov.temperature, 0.7f);
2648 apply_if_default(params.max_tokens, ov.max_output_tokens, 4096);
2649 apply_if_default(params.top_p, ov.top_p, 0.9f);
2650 apply_if_default(params.top_k, ov.top_k, 40);
2651 apply_if_default(params.min_p, ov.min_p, 0.0f);
2652 apply_if_default(params.presence_penalty, ov.presence_penalty, 0.0f);
2653 apply_if_default(params.frequency_penalty, ov.frequency_penalty, 0.0f);
2654 apply_if_default(params.repeat_penalty, ov.repeat_penalty, 1.1f); // gh#86
2655 apply_if_default(params.enable_thinking, ov.enable_thinking, true); // gh#86
2656 apply_if_default(params.tool_call_mode, ov.tool_call_mode, std::string{}); // gh#103
2657}
2658
2673void ModelOrchestrator::apply_tier_sampler_defaults(
2674 GenerationParams& params, const std::string& tier_name)
2675{
2676 auto it = config_.models.tiers.find(tier_name);
2677 if (it == config_.models.tiers.end()) { return; }
2678 const auto& tier = it->second;
2679 TierSamplerOverrides ov;
2680 ov.temperature = tier.temperature;
2681 ov.max_output_tokens = tier.max_output_tokens;
2682 ov.top_p = tier.top_p; // gh#85
2683 ov.top_k = tier.top_k; // gh#85
2684 ov.min_p = tier.min_p; // gh#85
2685 ov.presence_penalty = tier.presence_penalty; // gh#85
2686 ov.frequency_penalty = tier.frequency_penalty; // gh#85
2687 ov.repeat_penalty = tier.repeat_penalty; // gh#86
2688 ov.enable_thinking = tier.enable_thinking; // gh#86
2689 ov.tool_call_mode = tier.tool_call_mode; // gh#103
2690 float before_temp = params.temperature;
2691 int before_max = params.max_tokens;
2693 if (params.temperature != before_temp) {
2694 logger->info("Tier '{}' temperature applied: {}",
2695 tier_name, params.temperature);
2696 }
2697 if (params.max_tokens != before_max) {
2698 logger->info("Tier '{}' max_output_tokens applied: {}",
2699 tier_name, params.max_tokens);
2700 }
2701}
2702
2703// ── VRAM-aware tier residency (v2.2.4, gh#57) ──────────────
2704
2719size_t ModelOrchestrator::resolve_vram_budget_bytes() {
2720 const char* env = std::getenv("ENTROPIC_VRAM_BUDGET_BYTES");
2721 if (env == nullptr) {
2722 // Not set at all: fall through to the device.
2723 return static_cast<size_t>(query_device_free_vram_bytes());
2724 }
2725 // SET, so it takes control — including when it is empty or unparseable,
2726 // which resolve to 0 and therefore DISABLE the gate. That is the escape
2727 // hatch for an operator who wants the pre-gh#142 behaviour back, and it is
2728 // what the v2.3.10 contract already specified; the device fallback must not
2729 // quietly override an explicit setting.
2730 size_t budget = 0;
2731 if (*env != '\0') {
2732 try {
2733 long long v = std::stoll(env);
2735 } catch (...) {
2736 budget = 0;
2737 }
2738 }
2739 return budget;
2740}
2741
2748void ModelOrchestrator::ensure_vram_budget() {
2749 if (vram_budget_resolved_) { return; }
2750 const bool from_env = std::getenv("ENTROPIC_VRAM_BUDGET_BYTES") != nullptr;
2751 vram_budget_bytes_ = resolve_vram_budget_bytes();
2752 vram_budget_resolved_ = true;
2753 // The pre-2.14.0 line labelled every budget "(ENTROPIC_VRAM_BUDGET_BYTES)",
2754 // including the far more common device-measured one.
2755 logger->info("[residency] VRAM budget: {} bytes ({})", vram_budget_bytes_,
2756 from_env ? "ENTROPIC_VRAM_BUDGET_BYTES" : "device free VRAM");
2757}
2758
2766bool ModelOrchestrator::budget_resolves_at_init(const ParsedConfig& config) {
2767 return !config.models.defer_load
2768 || std::getenv("ENTROPIC_VRAM_BUDGET_BYTES") != nullptr;
2769}
2770
2771
2787 const TierConfig& tier_cfg, uint64_t weights_bytes, int vram_reserve_mb) {
2789 in.weights_bytes = weights_bytes;
2790 in.gpu_layers = tier_cfg.gpu_layers;
2791 in.context_length = tier_cfg.context_length;
2792 in.cache_type_k = tier_cfg.cache_type_k;
2793 in.cache_type_v = tier_cfg.cache_type_v;
2794 in.vram_reserve_mb = vram_reserve_mb;
2795 // gh#144 (v2.12.0): context_length is per session, so the pool's KV term
2796 // multiplies. Omitting this under-counts by exactly N and can admit a
2797 // configuration that aborts the host — what this gate exists to prevent.
2798 in.max_sessions = tier_cfg.max_sessions;
2799 if (!tier_cfg.mmproj_path.empty()) {
2800 std::error_code proj_ec;
2801 auto proj = std::filesystem::file_size(tier_cfg.mmproj_path, proj_ec);
2802 if (!proj_ec) { in.mmproj_bytes = proj; }
2803 }
2804 // v2.13.1: the GGUF's real shape, and what an expert split moves off the
2805 // card. Without these the budget gate re-prices a placement `auto` just
2806 // derived WITHOUT knowing about the split: it sees gpu_layers == -1,
2807 // calls that full residency, charges the whole file, and refuses a
2808 // configuration auto had already measured as fitting. Two estimators
2809 // disagreeing about one tier is the defect this release exists to
2810 // remove, so both go through this builder and both see the same shape.
2812 if (shape.known) {
2813 in.block_count = shape.block_count;
2814 in.block_bytes = shape.block_bytes;
2815 in.non_block_bytes = shape.non_block_bytes;
2816 if (tier_cfg.cpu_moe_layers > 0) {
2817 in.host_expert_bytes =
2818 shape.expert_bytes_per_block()
2819 * static_cast<uint64_t>(tier_cfg.cpu_moe_layers);
2820 }
2821 }
2822 return in;
2823}
2824
2838size_t ModelOrchestrator::estimate_footprint_bytes(
2839 const std::string& tier_name) const {
2840 auto tier_it = config_.models.tiers.find(tier_name);
2841 if (tier_it == config_.models.tiers.end()) { return 0; }
2842 const auto& tier_cfg = tier_it->second;
2843 std::error_code ec;
2844 auto weights = std::filesystem::file_size(tier_cfg.path, ec);
2845 if (ec) { return 0; }
2846 FootprintInputs in = footprint_inputs_for(
2847 tier_cfg, weights, config_.vram_reserve_mb);
2848 // gh#142: an unpriceable placement returns 0 = "unknown", which leaves the
2849 // gate open. Guessing here would refuse working configurations — a 13 GB
2850 // model at gpu_layers=15 runs fine on an 11 GB card.
2851 return static_cast<size_t>(estimate_vram_footprint(in).bytes);
2852}
2853
2860 const std::string& tier_name) const {
2861 std::lock_guard<std::mutex> lock(swap_mutex_);
2862 auto it = tier_footprint_bytes_.find(tier_name);
2863 if (it != tier_footprint_bytes_.end()) { return it->second; }
2864 size_t v = estimate_footprint_bytes(tier_name);
2865 if (v > 0) {
2866 tier_footprint_bytes_[tier_name] = v;
2867 }
2868 return v;
2869}
2870
2877 std::lock_guard<std::mutex> lock(swap_mutex_);
2878 residency_observer_ = std::move(cb);
2879}
2880
2886void ModelOrchestrator::fire_residency_observer(
2887 ResidencyEvent event,
2888 const std::string& tier_name,
2889 const std::string& model_path,
2890 size_t footprint) {
2891 const char* event_name = "unknown";
2892 switch (event) {
2893 case ResidencyEvent::Loaded: event_name = "loaded"; break;
2894 case ResidencyEvent::Evicted: event_name = "evicted"; break;
2895 case ResidencyEvent::ActivationSwap: event_name = "activation_swap"; break;
2896 }
2897 logger->info("[residency] {} tier='{}' path='{}' footprint={} bytes",
2898 event_name, tier_name, model_path, footprint);
2899 if (residency_observer_) {
2900 residency_observer_(event, tier_name, model_path, footprint);
2901 }
2902}
2903
2921static nlohmann::json make_residency_entry(
2922 const std::string& name, const std::filesystem::path& path,
2923 int context_length, size_t footprint, int vram_reserve_mb,
2924 long long last_ms) {
2925 std::error_code ec;
2926 auto weights = std::filesystem::file_size(path, ec);
2927 size_t weights_b = ec ? 0u : static_cast<size_t>(weights);
2928 size_t kv = static_cast<size_t>(context_length) * 16ull * 1024ull;
2929 size_t headroom = static_cast<size_t>(vram_reserve_mb)
2930 * 1024ull * 1024ull;
2931 return {
2932 {"tier", name},
2933 {"model_path", path.string()},
2934 {"footprint_bytes", footprint},
2935 {"weights_bytes", weights_b},
2936 {"kv_cache_bytes", kv},
2937 {"headroom_bytes", headroom},
2938 {"last_activation_ms", last_ms}
2939 };
2940}
2941
2951void ModelOrchestrator::forget_session_kv(const std::string& session_key) {
2952 // Outermost lock: this mutates the live llama_context's KV cells, which
2953 // is precisely what a concurrent decode is reading. See the declaration
2954 // for why api_mutex -> generation_mutex_ closes no cycle.
2955 std::lock_guard<std::recursive_mutex> gen_lock(generation_mutex_);
2956 std::unordered_set<const InferenceBackend*> seen;
2957 for (const auto& [name, backend] : tiers_) {
2958 (void)name;
2959 if (!backend || !backend->is_loaded()) { continue; }
2960 if (!seen.insert(backend.get()).second) { continue; }
2961 backend->forget_session_kv(session_key);
2962 }
2963}
2964
2972 std::lock_guard<std::mutex> lock(swap_mutex_);
2973 nlohmann::json j;
2974 j["vram_total_bytes"] = vram_budget_bytes_;
2975 j["vram_budget_bytes"] = vram_budget_bytes_;
2976 size_t in_use = 0;
2977 nlohmann::json arr = nlohmann::json::array();
2978 for (const auto& [name, backend] : tiers_) {
2979 if (!backend || !backend->is_loaded()) { continue; }
2980 auto tier_it = config_.models.tiers.find(name);
2981 if (tier_it == config_.models.tiers.end()) { continue; }
2982 auto fp_it = tier_footprint_bytes_.find(name);
2983 size_t footprint = (fp_it != tier_footprint_bytes_.end())
2984 ? fp_it->second : estimate_footprint_bytes(name);
2985 in_use += footprint;
2986 auto la = tier_last_activation_ms_.find(name);
2987 long long last_ms = (la != tier_last_activation_ms_.end())
2988 ? la->second : 0;
2989 arr.push_back(make_residency_entry(
2990 name, tier_it->second.path, tier_it->second.context_length,
2991 footprint, config_.vram_reserve_mb, last_ms));
2992 }
2993 j["residency"] = std::move(arr);
2994 j["vram_headroom_bytes"] = vram_budget_bytes_ > in_use
2995 ? vram_budget_bytes_ - in_use
2996 : 0u;
2997 // gh#198: a status read must never be the thing that creates the CUDA
2998 // context, so an unresolved budget reports as deferred rather than probing.
2999 const bool deferred = vram_budget_deferred_ && !vram_budget_resolved_;
3000 j["backend"] = deferred ? "deferred"
3001 : (vram_budget_bytes_ > 0 ? "configured" : "unknown");
3002 return j.dump();
3003}
3004
3005} // namespace entropic
ChatAdapter concrete base class.
Adapter factory — create adapters by name.
Derive a model's placement from its shape and the free VRAM.
long long elapsed_ms
Wall time, spawn to reap.
Definition bash.cpp:102
bool swap(const std::string &name, llama_context *ctx)
Swap to a different adapter atomically.
std::string active_adapter() const
Get the currently HOT adapter name.
void unload_all_for_model(llama_model *model, llama_context *ctx)
Unload all adapters for a given base model.
void release_handles_for_model(llama_model *model, llama_context *ctx)
Free a base model's adapter handles, KEEPING the registrations (gh#164).
void deactivate(llama_context *ctx)
Deactivate current HOT adapter (HOT -> WARM).
bool load(const std::string &name, const std::filesystem::path &adapter_path, llama_model *model, float scale=1.0f)
Load a LoRA adapter into RAM (COLD -> WARM).
void unload_all()
Free every loaded adapter handle (gh#58 close-out, v2.3.0).
AdapterState state(const std::string &name) const
Get adapter state.
Concrete base class for chat format adapters (80% logic).
size_t load_bundled(const std::filesystem::path &grammar_dir)
Load all bundled grammars from a directory.
std::string get(const std::string &key) const
Get GBNF content string for a grammar key.
Concrete base class for inference backends (80% logic).
Definition backend.h:69
BackendInfo info() const
Get backend metadata.
Definition backend.cpp:610
std::vector< GenerationResult > generate_batch(const std::vector< std::vector< Message > > &requests, const std::vector< GenerationParams > &params, std::atomic< bool > &cancel)
Generate N independent same-prefix requests together.
Definition backend.cpp:293
GenerationResult generate(const std::vector< Message > &messages, const GenerationParams &params)
Generate a complete response.
Definition backend.cpp:234
GenerationResult generate_streaming(const std::vector< Message > &messages, const GenerationParams &params, std::function< void(std::string_view token)> on_token, std::atomic< bool > &cancel)
Generate with per-token streaming callback.
Definition backend.cpp:325
LlamaCppBackend — common llama.cpp patterns (15% layer).
llama_model * llama_model_ptr()
Get the loaded llama_model pointer.
SpeculativeCompatInfo check_speculative_compat() const
Check whether the currently-configured target/draft pair is compatible for speculative decoding.
std::vector< std::string > available_models() const
All configured tier names.
size_t load_grammars_from(const std::filesystem::path &grammar_dir)
Load grammars from an explicit directory path.
GenerationResult generate_streaming(const std::vector< Message > &messages, const GenerationParams &params, std::function< void(std::string_view)> on_token, std::atomic< bool > &cancel, const std::string &tier_name="")
Streaming generation.
entropic_error_t release_models(const std::string &tier_name)
Release resident model(s), keeping every registration (gh#164).
std::vector< std::string > loaded_models() const
Currently loaded model tier names.
bool initialize(const ParsedConfig &config)
Initialize from parsed config.
bool has_vision_capable_tier() const
Return true if any configured tier declares the "vision" capability (gh#41, v2.1.8).
void forget_session_kv(const std::string &session_key)
Drop one session's resident KV on every loaded tier (gh#165).
size_t tier_footprint_bytes(const std::string &tier_name) const
Estimated VRAM footprint for a given tier in bytes.
void shutdown()
Shutdown — unload all models.
RoutingResult last_routing_result() const
Last routing result.
std::vector< GenerationRecord > generation_records() const
Per-generation metric records, oldest first (gh#154).
std::function< void(ResidencyEvent event, const std::string &tier_name, const std::string &model_path, size_t footprint)> ResidencyObserverFn
Residency observer callback type (internal C++ form).
GenerationResult generate(const std::vector< Message > &messages, const GenerationParams &params, const std::string &tier_name="")
Generate using routed or explicit tier.
void clear_all_prompt_caches()
Invalidate prompt/KV caches across every pooled backend.
static constexpr size_t kMaxGenerationRecords
Ring capacity for generation_records().
std::string route(const std::vector< Message > &messages)
Route to tier using router model.
ChatAdapter * get_adapter(const std::string &tier_name) const
Get adapter for a tier.
void set_residency_observer(ResidencyObserverFn cb)
Register a residency observer.
std::string last_used_tier() const
Last used tier name.
bool tier_declares_vision(const std::string &tier_name) const
Whether a tier is configured for vision, per CONFIG (gh#157).
~ModelOrchestrator()
Destructor — invokes shutdown() and AdapterManager::unload_all().
std::vector< GenerationResult > generate_batch(const std::vector< std::vector< Message > > &messages_list, const std::vector< GenerationParams > &params_list, const std::vector< std::string > &tiers, std::atomic< bool > &cancel)
Same-prefix batch generation on a shared resident model (gh#98).
InferenceBackend * ensure_model(const std::string &tier_name)
Get a tier's backend, loading it if it is not resident (gh#157).
std::string select_vision_tier() const
Pick the canonical vision-capable tier name (gh#41).
bool can_handoff(const std::string &from, const std::string &to) const
Check if handoff is permitted.
std::string residency_snapshot_json() const
Serialize the current residency set as a JSON string.
InferenceBackend * get_backend(const std::string &tier_name) const
Get the inference backend for a tier (for evaluation APIs).
void clear_all_prompt_caches()
Fanout: clear prompt/KV cache on every loaded backend.
bool is_loaded(const std::string &role) const
Check whether a role is currently loaded and active.
InferenceBackend * get(const std::string &role) const
Get the backend for a role.
bool ensure_loaded(const std::string &role, const ModelConfig &config)
Lazily load and activate a model for a role.
Streaming filter that removes <think> blocks from output.
gh#142: ask the GPU how much VRAM is actually free.
gh#137: explain an empty turn without misattributing its cause.
int * cancel
Consumer's cancel flag, nullable.
Definition engine.cpp:4074
StreamThinkFilter * filter
Consumer-facing filter.
Definition engine.cpp:4073
entropic_error_t
Error codes returned by all C API functions.
Definition error.h:37
@ ENTROPIC_OK
Success.
Definition error.h:38
@ ENTROPIC_ERROR_TIER_MODEL_TOO_LARGE
A single tier's model weights+KV exceed the engine's VRAM budget; eviction cannot help (v2....
Definition error.h:91
@ ENTROPIC_ERROR_MLOCK_LIMIT_EXCEEDED
use_mlock would pin more than RLIMIT_MEMLOCK allows for a model too large for the card; the pinned pa...
Definition error.h:93
@ ENTROPIC_ERROR_GRAMMAR_NOT_FOUND
Grammar key not in registry (v1.9.3)
Definition error.h:69
@ 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_MODEL_NOT_FOUND
Model file does not exist at resolved path.
Definition error.h:42
std::string err
size_exceeded, when set
long budget
Max content bytes; <= 0 = unbounded.
long bytes
Content bytes taken.
Read a GGUF's shape without loading its tensors (v2.13.1).
Which grammar source constrains a decode (gh#134).
Pure C interface contract for inference backends.
void entropic_inference_log_to_file(const char *path)
Redirect llama/ggml logs to a file.
LlamaCppBackend — llama.cpp C API integration.
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).
static FootprintInputs footprint_inputs_for(const TierConfig &tier_cfg, uint64_t weights_bytes, int vram_reserve_mb)
Gather a tier's footprint inputs for the pure estimator.
static bool mtp_head_guard_fires(LlamaCppBackend *draft, GenerationResult &result)
gh#107: return true (and populate result) when draft looks like an MTP head GGUF routed to the classi...
bool tier_grammar_unresolved(const GenerationParams &params)
A TIER named a grammar and nothing resolved it (gh#154).
static nlohmann::json make_residency_entry(const std::string &name, const std::filesystem::path &path, int context_length, size_t footprint, int vram_reserve_mb, long long last_ms)
JSON serialization of the current residency set.
bool gpu_offload_refused(uint64_t file_bytes, int gpu_layers, uint64_t free_vram_bytes)
Whether a requested GPU offload provably cannot fit free VRAM.
FootprintEstimate estimate_vram_footprint(const FootprintInputs &in)
Estimate the VRAM a tier will occupy, or report that it cannot.
uint64_t host_memlock_limit_bytes()
This process's RLIMIT_MEMLOCK soft limit (gh#148).
@ optional
optional: every escape asks the host's path approver
int partial_gpu_layers_for(uint64_t file_bytes, uint64_t free_vram_bytes, int n_layers=30)
Layers to place on the GPU for an oversized model.
EmptyContentCause diagnose_empty_content(bool content_empty, bool produced_tokens, const std::string &finish_reason, bool has_tool_calls)
Classify an empty-content turn from what the decode reported.
static void log_orchestration(const GenerationResult &result, const std::string &selected, const std::string &adapter_name, const GenerationParams &params, double routing_ms, double swap_ms)
Log the per-orchestration tier/adapter/timing summary.
uint64_t query_device_free_vram_bytes()
Free VRAM on the first GPU device ggml reports, in bytes.
static llama_model * resolve_target_model(const std::shared_ptr< InferenceBackend > &tier_backend)
Resolve the active main-tier llama_model* for compat lookup.
std::string explain_empty_content(EmptyContentCause cause)
Operator-facing explanation for an empty-content turn.
@ tier
The TIER's identity frontmatter grammar: stem (gh#154).
@ count
Sentinel — MUST remain last.
static void warn_if_content_vanished(const GenerationResult &result)
Explain a turn that produced tokens but delivered no content (gh#137).
std::unique_ptr< ChatAdapter > create_adapter(const std::string &name, const std::string &tier_name, const std::string &identity_prompt)
Create adapter by name (gh#87 Phase D hybrid).
std::string mtp_head_classical_path_error(int n_layer)
Actionable error message for gh#107: MTP head on classical path.
ENTROPIC_EXPORT void apply_tier_sampler_overrides(GenerationParams &params, const TierSamplerOverrides &ov)
Apply per-tier sampler overrides to params.
static void warn_turn_diagnostics(const GenerationResult &result, const std::string &tier_name, const std::unordered_map< std::string, TierConfig > &tiers)
Report every post-turn diagnostic from one call site (gh#137).
static void apply_adapter_parse(InferenceBackend *model, ChatAdapter *adapter, GenerationResult &result)
Split tool calls out of a result (gh#87: common_chat or adapter).
static void warn_if_budget_starved_required_turn(const GenerationResult &result, const std::string &tier_name, const std::unordered_map< std::string, TierConfig > &tiers)
Diagnose a mandatory-tool turn that ran out of budget (gh#134).
bool looks_like_mtp_head(int n_layer)
True when the model's layer count is consistent with an MTP head GGUF.
bool mlock_refused(bool use_mlock, uint64_t file_bytes, int gpu_layers, uint64_t memlock_limit_bytes, uint64_t host_expert_bytes=0)
Whether an explicit use_mlock would be fatal for this model.
static std::string normalize_grammar_key(const std::string &grammar_value)
Normalize a frontmatter grammar value to a registry key.
GenerationRecord make_generation_record(const GenerationResult &r)
Project a result down to the record kept for metrics.
GrammarProvenance describe_grammar(const GenerationParams &params, const std::string &tool_grammar)
Describe what constrained one decode, for the result record (gh#154).
@ not_empty
Content survived; nothing to explain.
@ tool_call_only
The whole turn was tool calls — normal (gh#159).
AutoPlacement derive_auto_placement(const GgufShape &shape, const FootprintInputs &base, uint64_t free_vram_bytes, int n_ubatch=0)
Choose the placement that uses the card best without overcommitting.
static void stream_token_trampoline(const char *data, std::size_t len, void *ud)
Trampoline: bridges TokenCallback C signature to std::function.
GgufShape read_gguf_shape(const std::string &path)
Read a GGUF's shape from its metadata, without loading tensor data.
@ COLD
Not loaded. No resources consumed.
ParsedModelResponse parse_model_response(LlamaCppBackend *llama, ChatAdapter *adapter, const std::string &raw)
Parse a raw emission: template first, adapter second.
int recommend_context_length(const FootprintInputs &in, uint64_t available_bytes)
Largest context length that fits, for the "won't fit" recommendation.
static void stage_active_tools(InferenceBackend *model, const GenerationParams &params, bool require_tool_call)
Stage the turn's tool defs on the backend for common_chat (gh#87).
ModelOrchestrator — multi-model lifecycle and routing.
How many layers of an oversized GGUF fit the free VRAM, purely.
One template-first / adapter-second parse rule for raw model output.
std::string config
–config FILE
Definition serve.cpp:38
Tokenizer/architecture compatibility check for speculative decoding draft pairing.
Streaming filter that strips a model family's reasoning blocks.
uint64_t bytes
Estimated VRAM bytes; 0 when not known.
Everything the estimate needs, with no orchestrator or filesystem.
uint64_t weights_bytes
Size of the tier's GGUF on disk.
Generation parameters for a single inference call.
Definition config.h:389
int max_tokens
Maximum tokens to generate.
Definition config.h:447
Result of a single generation call.
entropic_error_t error_code
Error code (ENTROPIC_OK if no error)
double swap_ms
Model swap time.
double routing_ms
Router classification time.
double generation_time_ms
Wall-clock generation time.
std::string raw_content
Raw model output before adapter processing.
std::string finish_reason
Finish reason: "stop", "length", "error".
std::string content
Generated text (cleaned by adapter)
std::vector< ToolCall > tool_calls
Tool calls parsed from content.
std::string error_message
Error description (empty if no error)
double total_ms
Total end-to-end time.
A GGUF's shape, as far as its metadata states it.
SpeculativeConfig speculative
Speculative decoding (gh#36)
Definition config.h:1327
std::filesystem::path path
Resolved model file path.
Definition config.h:155
Result of a speculative-decoding compatibility check.
std::optional< ModelConfig > router
Router model (separate from tiers)
Definition config.h:691
std::unordered_map< std::string, TierConfig > tiers
Tier name → config.
Definition config.h:690
Full parsed configuration.
Definition config.h:1338
int vram_reserve_mb
Reserved VRAM headroom (MB, 0–65536)
Definition config.h:1409
RoutingConfig routing
Routing rules.
Definition config.h:1340
InferenceConfig inference
Inference-side knobs (currently speculative decoding only).
Definition config.h:1444
ModelsConfig models
Tiers + router.
Definition config.h:1339
std::filesystem::path config_dir
Config dir — base for bundled data discovery.
Definition config.h:1412
std::string fallback_tier
Fallback when routing fails.
Definition config.h:744
bool enabled
Enable routing.
Definition config.h:743
std::optional< std::string > classification_prompt
Custom prompt (nullopt = auto)
Definition config.h:745
Result metadata from a routing decision.
std::string adapter_name
Active adapter (empty = base model) (v1.9.2)
std::string swap_action
"none", "reused", "loaded"
double adapter_swap_ms
Adapter swap latency (v1.9.2)
bool enabled
Master switch (off by default)
Definition config.h:1286
bool mtp
gh#106 (v2.9.0): drive MTP (the draft is a trunk-sharing head via ctx_other) instead of the gh#36 sep...
Definition config.h:1293
int n_draft
Window size (proposed tokens).
Definition config.h:1287
ModelConfig draft
Full ModelConfig for the draft model.
Definition config.h:1314
The reasoning-block delimiters a model family emits (gh#108).
Tier-specific model configuration.
Definition config.h:547
Per-tier sampler overrides parsed from identity frontmatter.
gh#142: a VRAM footprint estimate honest enough to refuse a load on.