Entropic 2.11.1
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 "vram_footprint.h"
22#include "response_parse.h"
23#include "mtp_envelope.h"
26#include <entropic/inference/adapters/adapter_base.h> // gh#88 recovery
27
28#include <llama.h>
29#include <nlohmann/json.hpp>
30
31#include <cstdlib>
32#include <filesystem>
33
34namespace entropic {
35
36namespace {
37auto logger = entropic::log::get("inference.orchestrator");
40
48std::string extract_latest_user_message(const std::vector<Message>& messages) {
49 for (auto it = messages.rbegin(); it != messages.rend(); ++it) {
50 if (it->role == "user") {
51 return it->content;
52 }
53 }
54 return "";
55}
56
57} // anonymous namespace
58
59// ── Initialization ─────────────────────────────────────────
60
74bool ModelOrchestrator::create_tier_backends(const ParsedConfig& config) {
75 for (const auto& [name, tier_config] : config.models.tiers) {
76 std::string path_key = tier_config.path.string();
77 if (!std::filesystem::exists(tier_config.path)) {
78 logger->error("Model file not found for tier '{}': {}",
79 name, path_key);
80 logger->error("Place a GGUF file at the path above, or set "
81 "ENTROPIC_MODEL_DIR to a directory containing "
82 "it. Run `entropic download --list` to see "
83 "bundled model keys, then "
84 "`entropic download <key>` to fetch one.");
85 return false;
86 }
87 if (model_pool_.find(path_key) == model_pool_.end()) {
88 model_pool_[path_key] = std::make_shared<LlamaCppBackend>();
89 }
90 tiers_[name] = model_pool_[path_key];
91 adapters_[name] = create_adapter(
92 tier_config.adapter, name, "" /* prompt resolved later */);
93 }
94 // Router backend instantiation moved to SecondaryModelLoader
95 // (gh#27, v2.1.11). The loader allocates the role slot lazily on
96 // first ensure_loaded() call from activate_router().
97 logger->info("Created {} unique backend(s) for {} tier(s)",
98 model_pool_.size(), tiers_.size());
99 return true;
100}
101
112void ModelOrchestrator::build_routing_tables(const ParsedConfig& config) {
113 for (const auto& [digit, tier_name] : config.routing.tier_map) {
114 tier_map_[digit] = tier_name;
115 }
116 for (const auto& [src, targets] : config.routing.handoff_rules) {
117 handoff_rules_[src] = std::unordered_set<std::string>(
118 targets.begin(), targets.end());
119 }
120}
121
129bool ModelOrchestrator::activate_default_tier(const ParsedConfig& config) {
130 if (tiers_.find(default_tier_) == tiers_.end()) { return true; }
131 auto& backend = tiers_[default_tier_];
132 auto& tier_cfg = config.models.tiers.at(default_tier_);
133 if (!backend->load_and_activate(tier_cfg)) {
134 logger->error("Failed to activate default tier: {}", default_tier_);
135 return false;
136 }
137 loaded_main_tier_ = default_tier_;
138 logger->info("Activated default tier: {}", default_tier_);
139 return true;
140}
141
153void ModelOrchestrator::activate_router(const ParsedConfig& config) {
154 if (!config.models.router) { return; }
155 // Lifecycle now lives on SecondaryModelLoader (gh#27, v2.1.11).
156 // Diagnostic-level logging is emitted by the loader itself.
157 secondary_loader_.ensure_loaded("router", *config.models.router);
158}
159
173void ModelOrchestrator::activate_draft(const ParsedConfig& config) {
174 const auto& spec = config.inference.speculative;
175 if (!spec.enabled || spec.draft.path.empty()) { return; }
176 // gh#106 (v2.9.0): under MTP the target owns the head (lazily, via
177 // generate_mtp → setup_mtp_draft). Loading draft.path as a standalone
178 // secondary backend here would double-load the head GGUF and never use
179 // it — skip the gh#36 separate-draft activation entirely.
180 if (spec.mtp) {
181 logger->info("Speculative MTP: head '{}' is target-owned; skipping "
182 "separate draft activation", spec.draft.path.string());
183 return;
184 }
185 // Full ModelConfig comes from the YAML's
186 // `inference.speculative.draft:` block — every llama.cpp knob is
187 // consumer-tunable. Defaults come from
188 // `make_default_draft_model_config()` (gpu_layers=0,
189 // flash_attn=false, context_length=8192, n_threads=4).
190 secondary_loader_.ensure_loaded("draft", spec.draft);
191}
192
208 config_ = config;
209 default_tier_ = config.models.default_tier;
210 vram_budget_bytes_ = resolve_vram_budget_bytes();
211 if (vram_budget_bytes_ > 0) {
212 logger->info("[residency] VRAM budget: {} bytes "
213 "(ENTROPIC_VRAM_BUDGET_BYTES)",
214 vram_budget_bytes_);
215 }
216
217 // Route ggml/llama logs before any model loading.
218 // gh#23 v2.3.24: `llama_log_path` overrides the hardcoded
219 // `<log_dir>/llama_ggml.log` when non-empty. The non-empty-and-no-log-dir
220 // case is also supported so consumers that want llama logs but
221 // no session.log can opt in.
222 if (config.ggml_logging) {
223 std::string path;
224 if (!config.llama_log_path.empty()) {
225 path = config.llama_log_path.string();
226 } else if (!config.log_dir.empty()) {
227 path = (config.log_dir / "llama_ggml.log").string();
228 }
229 if (!path.empty()) {
230 entropic_inference_log_to_file(path.c_str());
231 logger->info("ggml logging: {}", path);
232 }
233 }
234
235 logger->info("Initializing model orchestrator");
236
237 if (!create_tier_backends(config)) { return false; }
238 build_routing_tables(config);
239 if (!activate_default_tier(config)) { return false; }
240 activate_router(config);
241 activate_draft(config); // Speculative draft slot (v2.1.11)
242
243 preload_adapters(); // LoRA adapters → WARM (v1.9.2)
244 load_bundled_grammars(); // Bundled grammars (v1.9.3)
245 return true;
246}
247
259 logger->info("Shutting down model orchestrator");
260
261 for (auto& [path, backend] : model_pool_) {
262 if (backend->is_loaded()) {
263 backend->unload();
264 }
265 }
266
267 secondary_loader_.shutdown();
268}
269
281 // Order matters (gh#58 close-out, v2.3.0):
282 // 1. Backends first → frees llama_contexts.
283 // 2. LoRA adapter handles after → safe because the contexts
284 // that may have held HOT adapter references are gone.
285 shutdown();
286 lora_manager_.unload_all();
287}
288
300bool ModelOrchestrator::resolve_mtp_effective(const std::string& tier_name) const {
301 auto it = config_.models.tiers.find(tier_name);
302 if (it != config_.models.tiers.end() && it->second.speculative_mtp) {
303 return *it->second.speculative_mtp;
304 }
305 return config_.inference.speculative.mtp;
306}
307
314GenerationResult ModelOrchestrator::run_generate_dispatch(
315 InferenceBackend* model,
316 const std::vector<Message>& messages,
317 const GenerationParams& params,
318 const std::string& tier_name) {
319 GenerationResult result;
320 bool kernel_ran = config_.inference.speculative.enabled
321 && try_speculative_route(model, messages, params, tier_name, result);
322 if (!kernel_ran) {
323 result = model->generate(messages, params);
324 }
325 return result;
326}
327
352bool ModelOrchestrator::try_mtp_route(
353 InferenceBackend* model,
354 const std::vector<Message>& messages,
355 const GenerationParams& params,
356 std::function<void(std::string_view)> on_token,
357 std::atomic<bool>& cancel,
358 GenerationResult& result)
359{
360 auto* llama_target = dynamic_cast<LlamaCppBackend*>(model);
361 if (llama_target == nullptr) {
362 // Fail loud — no silent plain-decode fallback (gh#108).
363 result = GenerationResult{};
364 result.error_code = ENTROPIC_ERROR_NOT_SUPPORTED;
365 result.error_message = "speculative.mtp enabled but the target backend "
366 "is not llama.cpp; disable speculative.mtp";
367 result.finish_reason = "error";
368 logger->error("{}", result.error_message);
369 } else {
370 result = llama_target->generate_mtp(
371 messages, params, on_token, cancel,
372 config_.inference.speculative.draft.path.string(),
374 }
375 return true; // MTP owns the outcome — never fall back to plain decode
376}
377
395 GenerationResult& result) {
396 auto* dm = draft->llama_model_ptr();
397 if (dm == nullptr) { return false; }
398 int n = llama_model_n_layer(dm);
399 if (!looks_like_mtp_head(n)) { return false; }
402 result.finish_reason = "error";
403 return true;
404}
405
427bool ModelOrchestrator::try_speculative_route_streaming(
428 InferenceBackend* model,
429 const std::vector<Message>& messages,
430 const GenerationParams& params,
431 const std::string& tier_name,
432 std::function<void(std::string_view)> on_token,
433 std::atomic<bool>& cancel,
434 GenerationResult& result)
435{
436 // gh#106 (v2.9.0): MTP routes BEFORE the gh#36 compat/pair path — the
437 // target owns the head (no separate draft backend), and MTP tolerates
438 // shared-KV gemma4 archs the gh#36 compat gate rejects.
439 if (resolve_mtp_effective(tier_name)) { // gh#108 v2.10.0: grammar no longer blocks MTP
440 return try_mtp_route(model, messages, params, on_token, cancel,
441 result);
442 }
443 auto compat = check_speculative_compat();
444 bool kernel_ran = false;
445 if (!compat.compatible) {
446 logger->info("Speculative requested but pair incompatible "
447 "({}); using plain decode", compat.diagnostic);
448 } else {
449 auto* llama_target = dynamic_cast<LlamaCppBackend*>(model);
450 auto* draft_be = secondary_loader_.get("draft");
451 auto* llama_draft = dynamic_cast<LlamaCppBackend*>(draft_be);
452 if (llama_target == nullptr || llama_draft == nullptr) {
453 logger->info("Speculative compat passed but target/draft "
454 "is not llama.cpp; using plain decode");
455 } else {
456 // gh#107 (v2.10.0): guard against MTP head GGUFs on classical path.
457 if (mtp_head_guard_fires(llama_draft, result)) {
458 logger->error("{}", result.error_message);
459 return true;
460 }
461 auto spec = llama_target->generate_speculative_with_draft(
462 messages, params, on_token, cancel, *llama_draft,
464 config_.inference.speculative.draft.path.string());
465 if (spec.error_code == ENTROPIC_ERROR_NOT_SUPPORTED) {
466 logger->info("Speculative kernel returned NOT_SUPPORTED "
467 "({}); falling back", spec.error_message);
468 } else {
469 result = std::move(spec);
470 kernel_ran = true;
471 }
472 }
473 }
474 return kernel_ran;
475}
476
486bool ModelOrchestrator::try_speculative_route(
487 InferenceBackend* model,
488 const std::vector<Message>& messages,
489 const GenerationParams& params,
490 const std::string& tier_name,
491 GenerationResult& result)
492{
493 std::atomic<bool> local_cancel{false};
494 // gh#108: pass an EMPTY std::function (not a bound no-op lambda) so the MTP
495 // path can distinguish non-streaming from streaming via the callback's
496 // bound-ness. gh#36's emit guards `if (on_token)`, so empty is equivalent.
497 return try_speculative_route_streaming(
498 model, messages, params, tier_name,
499 std::function<void(std::string_view)>{}, local_cancel, result);
500}
501
502// ── Generation ─────────────────────────────────────────────
503
519 const GenerationParams& params,
520 bool require_tool_call) {
521 if (auto* llama = dynamic_cast<LlamaCppBackend*>(model)) {
522 llama->set_active_tools(params.tools);
523 llama->set_require_tool_call(require_tool_call); // gh#134 (v2.10.4)
524 }
525}
526
544 ChatAdapter* adapter,
545 GenerationResult& result) {
546 if (result.content.empty()) { return; }
547 result.raw_content = result.content;
548 // gh#108 (v2.10.3): one template-first/adapter-second rule, shared with
549 // interface_factory. See response_parse.h for why content composes and
550 // tool calls do not.
551 auto parsed = parse_model_response(
552 dynamic_cast<LlamaCppBackend*>(model), adapter, result.content);
553 result.content = std::move(parsed.content);
554 result.tool_calls = std::move(parsed.tool_calls);
555}
556
557
575static void warn_if_content_vanished(const GenerationResult& result) {
576 const auto cause = diagnose_empty_content(
577 result.content.empty(), !result.raw_content.empty(),
578 result.finish_reason);
579 if (cause == EmptyContentCause::not_empty) { return; }
580 logger->warn("Turn produced {} raw chars but delivered no content. {}",
581 result.raw_content.size(), explain_empty_content(cause));
582}
583
607 const GenerationResult& result,
608 const std::string& tier_name,
609 const std::unordered_map<std::string, TierConfig>& tiers) {
610 if (!result.tool_calls.empty()) { return; }
611 auto it = tiers.find(tier_name);
612 if (it == tiers.end()
613 || !it->second.require_tool_call.value_or(false)) { return; }
614 if (result.finish_reason != "length") { return; }
615 logger->error(
616 "Tier '{}' requires a tool call but hit its token budget before "
617 "emitting one (finish_reason=length, 0 tool calls). The tool-call "
618 "grammar permits unbounded text BEFORE the mandated call, so the call "
619 "must fit inside max_tokens. Raise max_tokens, or disable "
620 "enable_thinking on this tier — the thinking channel is what consumes "
621 "the preamble. This is a budget misconfiguration, not a model failure.",
622 tier_name);
623}
624
639 const GenerationResult& result,
640 const std::string& tier_name,
641 const std::unordered_map<std::string, TierConfig>& tiers) {
643 warn_if_budget_starved_required_turn(result, tier_name, tiers);
644}
645
664GenerationParams ModelOrchestrator::resolve_and_stage(
665 InferenceBackend* model,
666 const GenerationParams& params,
667 const std::string& tier_name) {
668 GenerationParams resolved = params;
669 resolve_grammar_key(resolved, tier_name); // v1.9.3
670 apply_tier_sampler_defaults(resolved, tier_name); // gh#82
671 // gh#134 (v2.10.4): per-tier, never global — front-office tiers
672 // legitimately answer in prose.
673 bool require_tc = false;
674 if (auto it = config_.models.tiers.find(tier_name);
675 it != config_.models.tiers.end()) {
676 require_tc = it->second.require_tool_call.value_or(false);
677 }
678 stage_active_tools(model, resolved, require_tc); // gh#87 3b, gh#134
679 return resolved;
680}
681
693static void log_orchestration(const GenerationResult& result,
694 const std::string& selected,
695 const std::string& adapter_name,
696 const GenerationParams& params,
697 double routing_ms, double swap_ms) {
698 logger->info("Orchestration: tier={}, adapter={}, grammar={}",
699 selected, adapter_name,
700 params.grammar.empty() ? "unconstrained"
701 : params.grammar_key);
702 logger->info("Total: {:.0f}ms (route={:.0f}ms, swap={:.0f}ms, "
703 "gen={:.0f}ms)",
704 result.total_ms, routing_ms, swap_ms,
705 result.generation_time_ms);
706}
707
728 const std::vector<Message>& messages,
729 const GenerationParams& params,
730 const std::string& tier_name)
731{
732 auto t_start = now();
733
734 // Route if no explicit tier
735 std::string selected = tier_name;
736 double routing_ms = 0.0;
737 if (selected.empty()) {
738 auto t_route = now();
739 selected = route(messages);
740 routing_ms = elapsed_ms(t_route, now());
741 }
742
743 // Get model (may trigger swap)
744 auto t_swap = now();
745 InferenceBackend* model = get_model(selected);
746 double swap_ms = elapsed_ms(t_swap, now());
747
748 if (!model) { return build_no_model_error(selected); }
749
750 GenerationParams resolved_params =
751 resolve_and_stage(model, params, selected); // gh#87 3b
752
753 // Generate — speculative routing applies here too (v2.1.11, gh#36)
754 GenerationResult result = run_generate_dispatch(
755 model, messages, resolved_params, selected);
756
757 apply_adapter_parse(model, get_adapter(selected), result);
758 // gh#134 (v2.10.4): name a budget-starved mandatory-tool turn.
759 warn_turn_diagnostics(result, selected,
760 config_.models.tiers);
761
762 result.routing_ms = routing_ms;
763 result.swap_ms = swap_ms;
764 result.total_ms = elapsed_ms(t_start, now());
765 log_orchestration(result, selected, last_routing_result_.adapter_name,
766 resolved_params, routing_ms, swap_ms);
767 return result;
768}
769
782 const std::vector<Message>& messages,
783 const GenerationParams& params,
784 std::atomic<bool>& cancel,
785 const std::string& tier_name)
786{
787 auto t_start = now();
788
789 std::string selected = tier_name;
790 double routing_ms = 0.0;
791 if (selected.empty()) {
792 auto t_route = now();
793 selected = route(messages);
794 routing_ms = elapsed_ms(t_route, now());
795 }
796
797 auto t_swap = now();
798 InferenceBackend* model = get_model(selected);
799 double swap_ms = elapsed_ms(t_swap, now());
800
801 if (!model) { return build_no_model_error(selected); }
802
803 GenerationParams resolved_params =
804 resolve_and_stage(model, params, selected); // gh#87 3b
805
806 GenerationResult result = model->generate(
807 messages, resolved_params, cancel);
808
809 apply_adapter_parse(model, get_adapter(selected), result);
810 // gh#134 (v2.10.4): name a budget-starved mandatory-tool turn.
811 warn_turn_diagnostics(result, selected,
812 config_.models.tiers);
813
814 result.routing_ms = routing_ms;
815 result.swap_ms = swap_ms;
816 result.total_ms = elapsed_ms(t_start, now());
817 log_orchestration(result, selected, last_routing_result_.adapter_name,
818 resolved_params, routing_ms, swap_ms);
819 return result;
820}
821
835std::vector<GenerationResult> ModelOrchestrator::generate_batch(
836 const std::vector<std::vector<Message>>& messages_list,
837 const std::vector<GenerationParams>& params_list,
838 const std::vector<std::string>& tiers,
839 std::atomic<bool>& cancel)
840{
841 const std::size_t n = messages_list.size();
842 const std::string lead =
843 (tiers.empty() || tiers[0].empty()) ? "default" : tiers[0];
844 InferenceBackend* model = get_model(lead);
845 if (model == nullptr) {
846 return std::vector<GenerationResult>(n, build_no_model_error(lead));
847 }
848
849 std::vector<GenerationParams> resolved;
850 resolved.reserve(n);
851 for (std::size_t i = 0; i < n; ++i) {
852 const std::string& t = tiers[i].empty() ? lead : tiers[i];
853 resolved.push_back(resolve_and_stage(model, params_list[i], t));
854 }
855
856 auto results = model->generate_batch(messages_list, resolved, cancel);
857 for (std::size_t i = 0; i < results.size() && i < tiers.size(); ++i) {
858 const std::string& t = tiers[i].empty() ? lead : tiers[i];
859 apply_adapter_parse(model, get_adapter(t), results[i]);
860 }
861 return results;
862}
863
872static void stream_token_trampoline(const char* data, std::size_t len,
873 void* ud) {
874 (*static_cast<std::function<void(std::string_view)>*>(ud))(
875 std::string_view(data, len));
876}
877
911 const std::vector<Message>& messages,
912 const GenerationParams& params,
913 std::function<void(std::string_view)> on_token,
914 std::atomic<bool>& cancel,
915 const std::string& tier_name)
916{
917 std::string selected = tier_name.empty() ? route(messages) : tier_name;
918 InferenceBackend* model = get_model(selected);
919
920 if (!model) {
923 err.error_message = "No model for tier: " + selected;
924 err.finish_reason = "error";
925 return err;
926 }
927
928 GenerationParams resolved_params =
929 resolve_and_stage(model, params, selected); // gh#87 3b
930
931 // gh#108 (v2.10.3): strip this family's reasoning blocks from the live
932 // stream. v2.10.0 added the filter but left it on its hardcoded `<think>`
933 // pair, so gemma4's `<|channel>` passed straight through. Markers now come
934 // from the resolved adapter, the same source the buffered strip uses.
935 //
936 // This is not cosmetic: ResponseGenerator::generate_streaming builds
937 // result.content from its OWN token accumulator and discards what
938 // apply_adapter_parse produced, so on the agent-loop streaming path this
939 // filter is the only thing standing between raw reasoning and the
940 // conversation history.
941 auto* stream_adapter = get_adapter(selected);
942 const auto markers = (stream_adapter != nullptr)
943 ? stream_adapter->thinking_markers() : ThinkMarkers{};
945 markers.open, markers.close);
946 auto filtered = [&filter](std::string_view sv) {
947 filter.on_token(sv.data(), sv.size());
948 };
949
950 GenerationResult result;
951 bool routed = config_.inference.speculative.enabled
952 && try_speculative_route_streaming(
953 model, messages, resolved_params, selected, filtered, cancel,
954 result);
955 if (!routed) {
956 result = model->generate_streaming(
957 messages, resolved_params, filtered, cancel);
958 }
959 filter.flush();
960 apply_adapter_parse(model, get_adapter(selected), result);
961 // gh#134 (v2.10.4): name a budget-starved mandatory-tool turn.
962 warn_turn_diagnostics(result, selected,
963 config_.models.tiers);
964 return result;
965}
966
967// ── Routing ────────────────────────────────────────────────
968
986std::string ModelOrchestrator::route(const std::vector<Message>& messages) {
987 if (!config_.routing.enabled
988 || !config_.models.router.has_value()) {
989 logger->info("Route: routing disabled, using default '{}'",
990 default_tier_);
991 last_routing_result_ = {default_tier_, "", "", "none", 0.0};
992 return default_tier_;
993 }
994
995 auto [tier, raw] = classify_task(messages);
996 last_routing_result_ = {tier, loaded_main_tier_, raw, "none", 0.0};
997
998 // Track history
999 tier_history_.push_back(tier);
1000 if (tier_history_.size() > 5) {
1001 tier_history_.erase(tier_history_.begin());
1002 }
1003
1004 logger->info("[ROUTER] {} | raw='{}'", tier, raw);
1005 return tier;
1006}
1007
1031std::pair<std::string, std::string> ModelOrchestrator::classify_task(
1032 const std::vector<Message>& messages)
1033{
1034 std::string user_msg = extract_latest_user_message(messages);
1035
1036 GenerationParams router_params;
1037 router_params.max_tokens = 1;
1038 router_params.temperature = 0.0f;
1039
1040 auto* router_backend = secondary_loader_.get("router");
1041 if (router_backend == nullptr) {
1042 logger->warn("classify_task: router not loaded; returning empty");
1043 return {"", ""};
1044 }
1045 // audit task #71: a non-fine-tuned router fed the bare "<msg> ->" just
1046 // CONTINUES the text and never emits a routing digit, so classify_task
1047 // silently always fell back to the default tier. When the deployment
1048 // configures routing.classification_prompt, prepend it so a general
1049 // instruct model is actually told the digit scheme. (The trailing " ->"
1050 // still constrains it to a single digit, per build_classification_prompt.)
1051 std::string router_prompt = user_msg + " ->";
1052 const auto& cprompt = config_.routing.classification_prompt;
1053 if (cprompt.has_value() && !cprompt->empty()) {
1054 router_prompt = *cprompt + "\n" + user_msg + " ->";
1055 // A general instruct model emits a leading space before the digit;
1056 // max_tokens=1 would cut it off. 4 captures "<space>1"; the digit scan
1057 // below takes the first tier_map char. Only widened on the prompt path
1058 // so unconfigured deployments keep the original 1-token behavior.
1059 router_params.max_tokens = 4;
1060 // v2.8.1 (review #3): classification_prompt was parsed-but-never-read
1061 // before the v2.8.0 fix. Log when the active (prompt) path is taken so
1062 // a deployment carrying a stale prompt sees the inert->active switch +
1063 // the widened token budget instead of a silent behavior change.
1064 logger->info("classify_task: using configured classification_prompt "
1065 "(router instructed; max_tokens widened to 4)");
1066 }
1067 auto result = router_backend->complete(router_prompt, router_params);
1068 std::string raw = result.content;
1069
1070 // Trim whitespace
1071 auto start = raw.find_first_not_of(" \t\n\r");
1072 if (start != std::string::npos) {
1073 raw = raw.substr(start);
1074 }
1075
1076 // Find matching tier
1077 for (char c : raw) {
1078 std::string digit(1, c);
1079 auto it = tier_map_.find(digit);
1080 if (it != tier_map_.end()) {
1081 logger->info("Route: digit='{}' -> tier='{}'",
1082 digit, it->second);
1083 return {it->second, digit};
1084 }
1085 }
1086
1087 logger->warn("Route: no valid digit in '{}', defaulting to {}",
1088 raw, default_tier_);
1089 return {default_tier_, ""};
1090}
1091
1092// ── Model access ───────────────────────────────────────────
1093
1112void ModelOrchestrator::record_activation_reuse(
1113 const std::string& tier_name) {
1114 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
1115 std::chrono::steady_clock::now() - start_time_).count();
1116 bool tier_changed = (loaded_main_tier_ != tier_name);
1117 tier_last_activation_ms_[tier_name] = now_ms;
1118 if (!tier_changed) { return; }
1119 auto tier_it = config_.models.tiers.find(tier_name);
1120 std::string path = tier_it != config_.models.tiers.end()
1121 ? tier_it->second.path.string() : "";
1122 size_t footprint = tier_footprint_bytes_.count(tier_name)
1123 ? tier_footprint_bytes_[tier_name]
1124 : estimate_footprint_bytes(tier_name);
1125 tier_footprint_bytes_[tier_name] = footprint;
1126 loaded_main_tier_ = tier_name;
1127 fire_residency_observer(ResidencyEvent::ActivationSwap,
1128 tier_name, path, footprint);
1129}
1130
1131// Defined lower down, beside estimate_footprint_bytes which shares it.
1132static FootprintInputs footprint_inputs_for(
1133 const TierConfig& tier_cfg, uint64_t weights_bytes, int vram_reserve_mb);
1134
1147void ModelOrchestrator::log_fit_recommendation(
1148 const std::string& tier_name) const {
1149 auto tier_it = config_.models.tiers.find(tier_name);
1150 if (tier_it == config_.models.tiers.end()) { return; }
1151 const auto& tier_cfg = tier_it->second;
1152 std::error_code ec;
1153 auto weights = std::filesystem::file_size(tier_cfg.path, ec);
1154 if (ec) { return; }
1155 FootprintInputs in = footprint_inputs_for(
1156 tier_cfg, weights, config_.vram_reserve_mb);
1157 int fits = recommend_context_length(in, vram_budget_bytes_);
1158 if (fits > 0) {
1159 logger->error("[residency] tier '{}' fits at context_length={} "
1160 "(requested {}) — lower it, or reduce gpu_layers to "
1161 "keep the requested context",
1162 tier_name, fits, tier_cfg.context_length);
1163 } else {
1164 logger->error("[residency] tier '{}' does not fit at ANY context "
1165 "length: weights{} alone exceed the {} MiB budget. "
1166 "Reduce gpu_layers, or use a smaller quantization.",
1167 tier_name,
1168 in.mmproj_bytes > 0 ? " + vision projector" : "",
1169 vram_budget_bytes_ / (1024 * 1024));
1170 }
1171}
1172
1183bool ModelOrchestrator::residency_admits(const std::string& tier_name) {
1184 size_t footprint = estimate_footprint_bytes(tier_name);
1185 if (footprint > 0) {
1186 tier_footprint_bytes_[tier_name] = footprint;
1187 }
1188 if (vram_budget_bytes_ > 0 && footprint > vram_budget_bytes_) {
1189 logger->error("[residency] tier '{}' footprint {} bytes "
1190 "exceeds VRAM budget {} bytes — "
1191 "TIER_MODEL_TOO_LARGE (gh#57)",
1192 tier_name, footprint, vram_budget_bytes_);
1193 // gh#142: a bare refusal leaves the operator with nothing to act on.
1194 // Say what WOULD fit, so the answer is a setting they can apply rather
1195 // than a wall. 0 means even an empty context does not fit, in which
1196 // case the context length is not the lever and saying so is honest.
1197 log_fit_recommendation(tier_name);
1198 last_residency_error_ = ENTROPIC_ERROR_TIER_MODEL_TOO_LARGE;
1199 return false;
1200 }
1201 return true;
1202}
1203
1223GenerationResult ModelOrchestrator::build_no_model_error(
1224 const std::string& tier_name) {
1225 GenerationResult err;
1226 err.finish_reason = "error";
1227 if (last_residency_error_ != ENTROPIC_OK) {
1228 err.error_code = last_residency_error_;
1229 err.error_message = "Tier '" + tier_name + "' model exceeds the "
1230 "engine's VRAM budget (gh#57)";
1231 last_residency_error_ = ENTROPIC_OK;
1232 } else {
1233 err.error_code = ENTROPIC_ERROR_GENERATE_FAILED;
1234 err.error_message = "No model available for tier: " + tier_name;
1235 }
1236 return err;
1237}
1238
1252InferenceBackend* ModelOrchestrator::activate_and_track(
1253 const std::string& tier_name,
1254 const std::shared_ptr<InferenceBackend>& backend) {
1255 auto tier_it = config_.models.tiers.find(tier_name);
1256 bool activated = tier_it != config_.models.tiers.end()
1257 && backend->load_and_activate(tier_it->second);
1258 if (!activated) {
1259 logger->error("Failed to activate tier: {}", tier_name);
1260 return nullptr;
1261 }
1262 loaded_main_tier_ = tier_name;
1263 last_routing_result_.swap_action = "loaded";
1264 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
1265 std::chrono::steady_clock::now() - start_time_).count();
1266 tier_last_activation_ms_[tier_name] = now_ms;
1267 size_t footprint = tier_footprint_bytes_.count(tier_name)
1268 ? tier_footprint_bytes_[tier_name] : 0;
1269 fire_residency_observer(ResidencyEvent::Loaded,
1270 tier_name, tier_it->second.path.string(),
1271 footprint);
1272 return backend.get();
1273}
1274
1288InferenceBackend* ModelOrchestrator::get_model(const std::string& tier_name) {
1289 std::lock_guard<std::mutex> lock(swap_mutex_);
1290
1291 auto it = tiers_.find(tier_name);
1292 std::string effective_tier = tier_name;
1293 if (it == tiers_.end()) {
1294 it = tiers_.find(config_.routing.fallback_tier);
1295 if (it != tiers_.end()) {
1296 effective_tier = config_.routing.fallback_tier;
1297 }
1298 }
1299
1300 InferenceBackend* result = nullptr;
1301 if (it != tiers_.end() && it->second->is_active()) {
1302 last_routing_result_.swap_action = "reused";
1303 record_activation_reuse(effective_tier);
1304 result = it->second.get();
1305 } else if (it != tiers_.end() && residency_admits(effective_tier)) {
1306 deactivate_current_if_needed(it->second.get());
1307 result = activate_and_track(effective_tier, it->second);
1308 }
1309
1310 // Ensure correct LoRA adapter for this tier (v1.9.2)
1311 if (result) {
1312 ensure_tier_lora(tier_name, result);
1313 }
1314
1315 return result;
1316}
1317
1325void ModelOrchestrator::ensure_tier_lora(const std::string& tier_name,
1326 InferenceBackend* result) {
1327 auto* llama_backend = dynamic_cast<LlamaCppBackend*>(result);
1328 llama_context* ctx = llama_backend
1329 ? llama_backend->llama_context_ptr() : nullptr;
1330 double adapter_ms = ensure_adapter_for_tier(tier_name, ctx);
1331 last_routing_result_.adapter_swap_ms = adapter_ms;
1332 last_routing_result_.adapter_name = lora_manager_.active_adapter();
1333}
1334
1346void ModelOrchestrator::deactivate_current_if_needed(InferenceBackend* incoming) {
1347 auto it = loaded_main_tier_.empty()
1348 ? tiers_.end() : tiers_.find(loaded_main_tier_);
1349
1350 bool should_swap = it != tiers_.end()
1351 && it->second.get() != incoming
1352 && it->second->is_loaded();
1353
1354 if (!should_swap) {
1355 return;
1356 }
1357
1358 // Cascade: unload adapters for this base model (v1.9.2)
1359 auto* llama_backend = dynamic_cast<LlamaCppBackend*>(it->second.get());
1360 if (llama_backend) {
1361 lora_manager_.unload_all_for_model(
1362 llama_backend->llama_model_ptr(),
1363 llama_backend->llama_context_ptr());
1364 }
1365
1366 unload_or_warm_current(it->second.get());
1367}
1368
1375void ModelOrchestrator::unload_or_warm_current(InferenceBackend* current) {
1376 auto cfg_it = config_.models.tiers.find(loaded_main_tier_);
1377 bool keep_warm = cfg_it != config_.models.tiers.end()
1378 && cfg_it->second.keep_warm;
1379
1380 if (keep_warm) {
1381 logger->info("Deactivating {} (keep_warm=true)", loaded_main_tier_);
1382 current->deactivate();
1383 return;
1384 }
1385 logger->info("Unloading {} (keep_warm=false)", loaded_main_tier_);
1386 std::string path = cfg_it != config_.models.tiers.end()
1387 ? cfg_it->second.path.string() : "";
1388 size_t footprint = tier_footprint_bytes_.count(loaded_main_tier_)
1389 ? tier_footprint_bytes_[loaded_main_tier_] : 0;
1390 std::string evicted_tier = loaded_main_tier_;
1391 current->unload();
1392 fire_residency_observer(ResidencyEvent::Evicted,
1393 evicted_tier, path, footprint);
1394}
1395
1396// ── Queries ────────────────────────────────────────────────
1397
1407 return last_routing_result_;
1408}
1409
1416 return loaded_main_tier_;
1417}
1418
1431std::vector<std::string> ModelOrchestrator::loaded_models() const {
1432 std::vector<std::string> result;
1433 for (const auto& [name, backend] : tiers_) {
1434 if (backend->is_loaded()) {
1435 result.push_back(name);
1436 }
1437 }
1438 if (secondary_loader_.is_loaded("router")) {
1439 result.push_back("router");
1440 }
1441 return result;
1442}
1443
1449std::vector<std::string> ModelOrchestrator::available_models() const {
1450 std::vector<std::string> result;
1451 for (const auto& [name, _] : tiers_) {
1452 result.push_back(name);
1453 }
1454 if (config_.models.router.has_value()) {
1455 result.push_back("router");
1456 }
1457 return result;
1458}
1459
1468 const std::string& tier_name) const {
1469 auto it = tiers_.find(tier_name);
1470 if (it == tiers_.end()) { return nullptr; }
1471 return it->second.get();
1472}
1473
1484 const std::string& from, const std::string& to) const
1485{
1486 auto it = handoff_rules_.find(from);
1487 if (it == handoff_rules_.end()) {
1488 return false;
1489 }
1490 return it->second.count(to) > 0;
1491}
1492
1498ChatAdapter* ModelOrchestrator::get_adapter(const std::string& tier_name) const {
1499 auto it = adapters_.find(tier_name);
1500 if (it != adapters_.end()) {
1501 return it->second.get();
1502 }
1503 return nullptr;
1504}
1505
1506// ── LoRA adapter management (v1.9.2) ──────────────────────
1507
1528bool ModelOrchestrator::deactivate_if_active(llama_context* ctx) {
1529 if (lora_manager_.active_adapter().empty()) {
1530 return false;
1531 }
1532 lora_manager_.deactivate(ctx);
1533 return true;
1534}
1535
1544double ModelOrchestrator::ensure_adapter_for_tier(
1545 const std::string& tier_name, llama_context* ctx)
1546{
1547 auto tier_it = config_.models.tiers.find(tier_name);
1548 if (tier_it == config_.models.tiers.end()) {
1549 return 0.0;
1550 }
1551
1552 const auto& tier_cfg = tier_it->second;
1553 auto t_start = now();
1554 bool needs_kv_clear = false;
1555
1556 if (!tier_cfg.adapter_path) {
1557 needs_kv_clear = deactivate_if_active(ctx);
1558 } else if (lora_manager_.active_adapter() != tier_name) {
1559 needs_kv_clear = lora_manager_.swap(tier_name, ctx);
1560 if (!needs_kv_clear) {
1561 logger->warn("Adapter swap to '{}' failed", tier_name);
1562 }
1563 }
1564
1565 if (needs_kv_clear && ctx) {
1566 llama_memory_clear(llama_get_memory(ctx), true);
1567 logger->info("Adapter swap for tier '{}' in {:.1f}ms",
1568 tier_name, elapsed_ms(t_start, now()));
1569 }
1570
1571 return elapsed_ms(t_start, now());
1572}
1573
1583void ModelOrchestrator::preload_adapters() {
1584 int loaded = 0;
1585
1586 for (const auto& [name, tier_cfg] : config_.models.tiers) {
1587 if (!tier_cfg.adapter_path) {
1588 continue;
1589 }
1590
1591 auto tier_it = tiers_.find(name);
1592 if (tier_it == tiers_.end()) {
1593 continue;
1594 }
1595
1596 auto* llama_backend = dynamic_cast<LlamaCppBackend*>(
1597 tier_it->second.get());
1598 if (!llama_backend || !llama_backend->llama_model_ptr()) {
1599 logger->warn("Cannot preload adapter for '{}' — model not loaded",
1600 name);
1601 continue;
1602 }
1603
1604 bool ok = lora_manager_.load(
1605 name,
1606 *tier_cfg.adapter_path,
1607 llama_backend->llama_model_ptr(),
1608 tier_cfg.adapter_scale);
1609
1610 if (ok) {
1611 ++loaded;
1612 }
1613 }
1614
1615 if (loaded > 0) {
1616 logger->info("Preloaded {} LoRA adapter(s) to WARM", loaded);
1617 }
1618}
1619
1620// ── Grammar registry (v1.9.3) ──────────────────────────────
1621
1631void ModelOrchestrator::load_bundled_grammars() {
1632 std::filesystem::path grammar_dir;
1633 if (!config_.config_dir.empty()) {
1634 grammar_dir = config_.config_dir / "grammars";
1635 }
1636 if (grammar_dir.empty() || !std::filesystem::is_directory(grammar_dir)) {
1637 // Fallback set by facade via load_grammars_from() if config_dir
1638 // doesn't have a grammars subdir. Check if already loaded.
1639 logger->info("No bundled grammar directory found, skipping");
1640 return;
1641 }
1642
1643 size_t count = grammar_registry_.load_bundled(grammar_dir);
1644 logger->info("Grammar registry: {} grammar(s) loaded from {}",
1645 count, grammar_dir.string());
1646}
1647
1661 const std::filesystem::path& grammar_dir) {
1662 if (!std::filesystem::is_directory(grammar_dir)) {
1663 return 0;
1664 }
1665 auto count = grammar_registry_.load_bundled(grammar_dir);
1666 logger->info("Grammar registry: {} grammar(s) loaded from {}",
1667 count, grammar_dir.string());
1668 return count;
1669}
1670
1682 for (auto& [_, backend] : model_pool_) {
1683 if (backend) { backend->clear_prompt_cache(); }
1684 }
1685 secondary_loader_.clear_all_prompt_caches();
1686 logger->info("Prompt caches invalidated across all backends "
1687 "(identity change)");
1688}
1689
1699 for (const auto& [_, tier] : config_.models.tiers) {
1700 if (tier.has_capability("vision")) { return true; }
1701 }
1702 return false;
1703}
1704
1713 for (const auto& [name, tier] : config_.models.tiers) {
1714 if (tier.has_capability("vision")) { return name; }
1715 }
1716 return "";
1717}
1718
1727static llama_model* resolve_target_model(
1728 const std::shared_ptr<InferenceBackend>& tier_backend) {
1729 if (!tier_backend || !tier_backend->is_loaded()) {
1730 return nullptr;
1731 }
1732 auto* llama_be = dynamic_cast<LlamaCppBackend*>(tier_backend.get());
1733 return (llama_be == nullptr) ? nullptr : llama_be->llama_model_ptr();
1734}
1735
1746std::string ModelOrchestrator::resolve_speculative_pair(
1747 llama_model*& target_out, llama_model*& draft_out) const {
1748 target_out = nullptr;
1749 draft_out = nullptr;
1750 std::string err;
1751
1752 auto tier_it = tiers_.find(loaded_main_tier_);
1753 if (tier_it == tiers_.end()) {
1754 err = "no main tier loaded";
1755 } else {
1756 target_out = resolve_target_model(tier_it->second);
1757 if (target_out == nullptr) {
1758 err = "main tier backend is not a llama.cpp backend or "
1759 "is not loaded";
1760 } else {
1761 auto* draft_backend = secondary_loader_.get("draft");
1762 if (draft_backend == nullptr || !draft_backend->is_loaded()) {
1763 err = "no draft model configured for speculative "
1764 "decoding "
1765 "(set inference.speculative.draft_model)";
1766 } else {
1767 auto* d = dynamic_cast<LlamaCppBackend*>(draft_backend);
1768 draft_out = (d == nullptr) ? nullptr : d->llama_model_ptr();
1769 if (draft_out == nullptr) {
1770 err = "draft backend is not a llama.cpp backend";
1771 }
1772 }
1773 }
1774 }
1775 return err;
1776}
1777
1789ModelOrchestrator::SpeculativeCompatInfo
1792 llama_model* target_model = nullptr;
1793 llama_model* draft_model = nullptr;
1794 info.diagnostic = resolve_speculative_pair(target_model, draft_model);
1795 if (info.diagnostic.empty()) {
1796 auto result = entropic::speculative::check_compat(
1797 target_model, draft_model);
1798 info.compatible = result.compatible;
1799 info.diagnostic = std::move(result.diagnostic);
1800 }
1801 return info;
1802}
1803
1815static std::string normalize_grammar_key(const std::string& grammar_value) {
1816 std::filesystem::path p(grammar_value);
1817 if (p.extension() == ".gbnf") {
1818 return p.stem().string();
1819 }
1820 return grammar_value;
1821}
1822
1840void ModelOrchestrator::resolve_grammar_key(
1841 GenerationParams& params, const std::string& tier_name)
1842{
1843 if (!params.grammar.empty()) {
1844 return;
1845 }
1846
1847 // Try explicit grammar_key
1848 std::string key = params.grammar_key;
1849
1850 // Fall back to tier config grammar field (frontmatter)
1851 if (key.empty()) {
1852 auto it = config_.models.tiers.find(tier_name);
1853 if (it != config_.models.tiers.end() && it->second.grammar) {
1854 key = normalize_grammar_key(it->second.grammar->string());
1855 }
1856 }
1857
1858 if (key.empty()) {
1859 return;
1860 }
1861
1862 std::string content = grammar_registry_.get(key);
1863 if (content.empty()) {
1864 logger->warn("Grammar key '{}' not found in registry", key);
1865 return;
1866 }
1867
1868 logger->info("Grammar resolved: key='{}', {} bytes",
1869 key, content.size());
1870 params.grammar = std::move(content);
1871}
1872
1873namespace {
1880template <typename T>
1881inline void apply_if_default(T& field, const std::optional<T>& ov, T dflt) {
1882 if (ov.has_value() && field == dflt) { field = *ov; }
1883}
1884} // namespace
1885
1899 GenerationParams& params, const TierSamplerOverrides& ov)
1900{
1901 // GenerationParams struct defaults (see types/config.h).
1902 apply_if_default(params.temperature, ov.temperature, 0.7f);
1903 apply_if_default(params.max_tokens, ov.max_output_tokens, 4096);
1904 apply_if_default(params.top_p, ov.top_p, 0.9f);
1905 apply_if_default(params.top_k, ov.top_k, 40);
1906 apply_if_default(params.min_p, ov.min_p, 0.0f);
1907 apply_if_default(params.presence_penalty, ov.presence_penalty, 0.0f);
1908 apply_if_default(params.frequency_penalty, ov.frequency_penalty, 0.0f);
1909 apply_if_default(params.repeat_penalty, ov.repeat_penalty, 1.1f); // gh#86
1910 apply_if_default(params.enable_thinking, ov.enable_thinking, true); // gh#86
1911 apply_if_default(params.tool_call_mode, ov.tool_call_mode, std::string{}); // gh#103
1912}
1913
1928void ModelOrchestrator::apply_tier_sampler_defaults(
1929 GenerationParams& params, const std::string& tier_name)
1930{
1931 auto it = config_.models.tiers.find(tier_name);
1932 if (it == config_.models.tiers.end()) { return; }
1933 const auto& tier = it->second;
1934 TierSamplerOverrides ov;
1935 ov.temperature = tier.temperature;
1936 ov.max_output_tokens = tier.max_output_tokens;
1937 ov.top_p = tier.top_p; // gh#85
1938 ov.top_k = tier.top_k; // gh#85
1939 ov.min_p = tier.min_p; // gh#85
1940 ov.presence_penalty = tier.presence_penalty; // gh#85
1941 ov.frequency_penalty = tier.frequency_penalty; // gh#85
1942 ov.repeat_penalty = tier.repeat_penalty; // gh#86
1943 ov.enable_thinking = tier.enable_thinking; // gh#86
1944 ov.tool_call_mode = tier.tool_call_mode; // gh#103
1945 float before_temp = params.temperature;
1946 int before_max = params.max_tokens;
1947 apply_tier_sampler_overrides(params, ov);
1948 if (params.temperature != before_temp) {
1949 logger->info("Tier '{}' temperature applied: {}",
1950 tier_name, params.temperature);
1951 }
1952 if (params.max_tokens != before_max) {
1953 logger->info("Tier '{}' max_output_tokens applied: {}",
1954 tier_name, params.max_tokens);
1955 }
1956}
1957
1958// ── VRAM-aware tier residency (v2.2.4, gh#57) ──────────────
1959
1974size_t ModelOrchestrator::resolve_vram_budget_bytes() {
1975 const char* env = std::getenv("ENTROPIC_VRAM_BUDGET_BYTES");
1976 if (env == nullptr) {
1977 // Not set at all: fall through to the device.
1978 return static_cast<size_t>(query_device_free_vram_bytes());
1979 }
1980 // SET, so it takes control — including when it is empty or unparseable,
1981 // which resolve to 0 and therefore DISABLE the gate. That is the escape
1982 // hatch for an operator who wants the pre-gh#142 behaviour back, and it is
1983 // what the v2.3.10 contract already specified; the device fallback must not
1984 // quietly override an explicit setting.
1985 size_t budget = 0;
1986 if (*env != '\0') {
1987 try {
1988 long long v = std::stoll(env);
1989 budget = (v < 0) ? 0 : static_cast<size_t>(v);
1990 } catch (...) {
1991 budget = 0;
1992 }
1993 }
1994 return budget;
1995}
1996
1997
2013 const TierConfig& tier_cfg, uint64_t weights_bytes, int vram_reserve_mb) {
2014 FootprintInputs in;
2015 in.weights_bytes = weights_bytes;
2016 in.gpu_layers = tier_cfg.gpu_layers;
2017 in.context_length = tier_cfg.context_length;
2018 in.cache_type_k = tier_cfg.cache_type_k;
2019 in.cache_type_v = tier_cfg.cache_type_v;
2020 in.vram_reserve_mb = vram_reserve_mb;
2021 if (!tier_cfg.mmproj_path.empty()) {
2022 std::error_code proj_ec;
2023 auto proj = std::filesystem::file_size(tier_cfg.mmproj_path, proj_ec);
2024 if (!proj_ec) { in.mmproj_bytes = proj; }
2025 }
2026 return in;
2027}
2028
2042size_t ModelOrchestrator::estimate_footprint_bytes(
2043 const std::string& tier_name) const {
2044 auto tier_it = config_.models.tiers.find(tier_name);
2045 if (tier_it == config_.models.tiers.end()) { return 0; }
2046 const auto& tier_cfg = tier_it->second;
2047 std::error_code ec;
2048 auto weights = std::filesystem::file_size(tier_cfg.path, ec);
2049 if (ec) { return 0; }
2050 FootprintInputs in = footprint_inputs_for(
2051 tier_cfg, weights, config_.vram_reserve_mb);
2052 // gh#142: an unpriceable placement returns 0 = "unknown", which leaves the
2053 // gate open. Guessing here would refuse working configurations — a 13 GB
2054 // model at gpu_layers=15 runs fine on an 11 GB card.
2055 return static_cast<size_t>(estimate_vram_footprint(in).bytes);
2056}
2057
2064 const std::string& tier_name) const {
2065 std::lock_guard<std::mutex> lock(swap_mutex_);
2066 auto it = tier_footprint_bytes_.find(tier_name);
2067 if (it != tier_footprint_bytes_.end()) { return it->second; }
2068 size_t v = estimate_footprint_bytes(tier_name);
2069 if (v > 0) {
2070 tier_footprint_bytes_[tier_name] = v;
2071 }
2072 return v;
2073}
2074
2081 std::lock_guard<std::mutex> lock(swap_mutex_);
2082 residency_observer_ = std::move(cb);
2083}
2084
2090void ModelOrchestrator::fire_residency_observer(
2091 ResidencyEvent event,
2092 const std::string& tier_name,
2093 const std::string& model_path,
2094 size_t footprint) {
2095 const char* event_name = "unknown";
2096 switch (event) {
2097 case ResidencyEvent::Loaded: event_name = "loaded"; break;
2098 case ResidencyEvent::Evicted: event_name = "evicted"; break;
2099 case ResidencyEvent::ActivationSwap: event_name = "activation_swap"; break;
2100 }
2101 logger->info("[residency] {} tier='{}' path='{}' footprint={} bytes",
2102 event_name, tier_name, model_path, footprint);
2103 if (residency_observer_) {
2104 residency_observer_(event, tier_name, model_path, footprint);
2105 }
2106}
2107
2125static nlohmann::json make_residency_entry(
2126 const std::string& name, const std::filesystem::path& path,
2127 int context_length, size_t footprint, int vram_reserve_mb,
2128 long long last_ms) {
2129 std::error_code ec;
2130 auto weights = std::filesystem::file_size(path, ec);
2131 size_t weights_b = ec ? 0u : static_cast<size_t>(weights);
2132 size_t kv = static_cast<size_t>(context_length) * 16ull * 1024ull;
2133 size_t headroom = static_cast<size_t>(vram_reserve_mb)
2134 * 1024ull * 1024ull;
2135 return {
2136 {"tier", name},
2137 {"model_path", path.string()},
2138 {"footprint_bytes", footprint},
2139 {"weights_bytes", weights_b},
2140 {"kv_cache_bytes", kv},
2141 {"headroom_bytes", headroom},
2142 {"last_activation_ms", last_ms}
2143 };
2144}
2145
2152 std::lock_guard<std::mutex> lock(swap_mutex_);
2153 nlohmann::json j;
2154 j["vram_total_bytes"] = vram_budget_bytes_;
2155 j["vram_budget_bytes"] = vram_budget_bytes_;
2156 size_t in_use = 0;
2157 nlohmann::json arr = nlohmann::json::array();
2158 for (const auto& [name, backend] : tiers_) {
2159 if (!backend || !backend->is_loaded()) { continue; }
2160 auto tier_it = config_.models.tiers.find(name);
2161 if (tier_it == config_.models.tiers.end()) { continue; }
2162 auto fp_it = tier_footprint_bytes_.find(name);
2163 size_t footprint = (fp_it != tier_footprint_bytes_.end())
2164 ? fp_it->second : estimate_footprint_bytes(name);
2165 in_use += footprint;
2166 auto la = tier_last_activation_ms_.find(name);
2167 long long last_ms = (la != tier_last_activation_ms_.end())
2168 ? la->second : 0;
2169 arr.push_back(make_residency_entry(
2170 name, tier_it->second.path, tier_it->second.context_length,
2171 footprint, config_.vram_reserve_mb, last_ms));
2172 }
2173 j["residency"] = std::move(arr);
2174 j["vram_headroom_bytes"] = vram_budget_bytes_ > in_use
2175 ? vram_budget_bytes_ - in_use
2176 : 0u;
2177 j["backend"] = vram_budget_bytes_ > 0 ? "configured" : "unknown";
2178 return j.dump();
2179}
2180
2181} // namespace entropic
ChatAdapter concrete base class.
Adapter factory — create adapters by name.
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 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).
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:563
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:246
GenerationResult generate(const std::vector< Message > &messages, const GenerationParams &params)
Generate a complete response.
Definition backend.cpp:187
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:278
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.
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).
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::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.
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.
~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).
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.
void on_token(const char *chunk, size_t len)
Process a chunk of tokens.
void flush()
Flush any buffered partial tag content.
gh#142: ask the GPU how much VRAM is actually free.
gh#137: explain an empty turn without misattributing its cause.
@ 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_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
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...
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.
FootprintEstimate estimate_vram_footprint(const FootprintInputs &in)
Estimate the VRAM a tier will occupy, or report that it cannot.
@ ok
Tool dispatched, returned non-empty content.
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.
@ 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.
static std::string normalize_grammar_key(const std::string &grammar_value)
Normalize a frontmatter grammar value to a registry key.
@ not_empty
Content survived; nothing to explain.
static void stream_token_trampoline(const char *data, std::size_t len, void *ud)
Trampoline: bridges TokenCallback C signature to std::function.
EmptyContentCause diagnose_empty_content(bool content_empty, bool produced_tokens, const std::string &finish_reason)
Classify an empty-content turn from what the decode reported.
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.
One template-first / adapter-second parse rule for raw model output.
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.
int vram_reserve_mb
Configured headroom to leave free.
uint64_t mmproj_bytes
Size of the vision projector, 0 if none.
int context_length
Tier's requested context window.
std::string cache_type_k
KV key cache quantization.
uint64_t weights_bytes
Size of the tier's GGUF on disk.
std::string cache_type_v
KV value cache quantization.
int gpu_layers
Tier's requested offload (-1 = all).
Generation parameters for a single inference call.
Definition config.h:313
std::string grammar
GBNF grammar string (empty = unconstrained)
Definition config.h:370
std::string tool_call_mode
Per-call tool-call generation mode (gh#103).
Definition config.h:383
int top_k
Top-K sampling.
Definition config.h:316
float repeat_penalty
Repetition penalty.
Definition config.h:317
std::string tools
Active tool definitions for this turn, as an MCP tool-list JSON array ([{name, description,...
Definition config.h:422
float temperature
Sampling temperature.
Definition config.h:314
std::string grammar_key
Grammar registry key.
Definition config.h:375
float frequency_penalty
Frequency-penalty term in llama.cpp's penalties sampler (gh#23 MVP item 3).
Definition config.h:360
float presence_penalty
Presence-penalty term in llama.cpp's penalties sampler (gh#23 MVP item 2).
Definition config.h:333
bool enable_thinking
Enable <think> blocks (false if reasoning_budget == 0)
Definition config.h:369
float min_p
Min-p nucleus sampling threshold (gh#23 MVP item 1).
Definition config.h:326
int max_tokens
Maximum tokens to generate.
Definition config.h:362
float top_p
Nucleus sampling threshold.
Definition config.h:315
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.
SpeculativeConfig speculative
Speculative decoding (gh#36)
Definition config.h:974
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 context_length
Context window size (512–131072)
Definition config.h:157
std::filesystem::path path
Resolved model file path.
Definition config.h:155
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
Result of a speculative-decoding compatibility check.
std::optional< ModelConfig > router
Router model (separate from tiers)
Definition config.h:574
std::unordered_map< std::string, TierConfig > tiers
Tier name → config.
Definition config.h:573
std::string default_tier
Default tier name.
Definition config.h:575
Full parsed configuration.
Definition config.h:985
int vram_reserve_mb
Reserved VRAM headroom (MB, 0–65536)
Definition config.h:1016
RoutingConfig routing
Routing rules.
Definition config.h:987
InferenceConfig inference
Inference-side knobs (currently speculative decoding only).
Definition config.h:1051
ModelsConfig models
Tiers + router.
Definition config.h:986
std::filesystem::path log_dir
Session log directory (session.log + session_model.log).
Definition config.h:1023
bool ggml_logging
Enable ggml/llama.cpp logging to llama_ggml.log in log_dir.
Definition config.h:1027
std::filesystem::path llama_log_path
Override path for ggml/llama log when ggml_logging == true (gh#23 MVP item 12, v2....
Definition config.h:1037
std::filesystem::path config_dir
Config dir — base for bundled data discovery.
Definition config.h:1019
std::string fallback_tier
Fallback when routing fails.
Definition config.h:609
bool enabled
Enable routing.
Definition config.h:608
std::optional< std::string > classification_prompt
Custom prompt (nullopt = auto)
Definition config.h:610
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:933
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:940
int n_draft
Window size (proposed tokens).
Definition config.h:934
ModelConfig draft
Full ModelConfig for the draft model.
Definition config.h:961
The reasoning-block delimiters a model family emits (gh#108).
Tier-specific model configuration.
Definition config.h:442
Per-tier sampler overrides parsed from identity frontmatter.
std::optional< float > top_p
gh#85
std::optional< float > temperature
gh#82
std::optional< float > min_p
gh#85
std::optional< float > presence_penalty
gh#85
std::optional< std::string > tool_call_mode
gh#103
std::optional< float > frequency_penalty
gh#85
std::optional< int > top_k
gh#85
std::optional< bool > enable_thinking
gh#86
std::optional< float > repeat_penalty
gh#86
std::optional< int > max_output_tokens
gh#82
gh#142: a VRAM footprint estimate honest enough to refuse a load on.