29#include <nlohmann/json.hpp>
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") {
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 '{}': {}",
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.");
87 if (model_pool_.find(path_key) == model_pool_.end()) {
88 model_pool_[path_key] = std::make_shared<LlamaCppBackend>();
90 tiers_[name] = model_pool_[path_key];
92 tier_config.adapter, name,
"" );
97 logger->info(
"Created {} unique backend(s) for {} tier(s)",
98 model_pool_.size(), tiers_.size());
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;
116 for (
const auto& [src, targets] : config.routing.handoff_rules) {
117 handoff_rules_[src] = std::unordered_set<std::string>(
118 targets.begin(), targets.end());
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_);
137 loaded_main_tier_ = default_tier_;
138 logger->info(
"Activated default tier: {}", default_tier_);
153void ModelOrchestrator::activate_router(
const ParsedConfig& config) {
154 if (!config.models.router) {
return; }
157 secondary_loader_.
ensure_loaded(
"router", *config.models.router);
173void ModelOrchestrator::activate_draft(
const ParsedConfig& config) {
174 const auto& spec = config.inference.speculative;
175 if (!spec.enabled || spec.draft.path.empty()) {
return; }
181 logger->info(
"Speculative MTP: head '{}' is target-owned; skipping "
182 "separate draft activation", spec.draft.path.string());
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)",
226 }
else if (!config.
log_dir.empty()) {
227 path = (config.
log_dir /
"llama_ggml.log").
string();
231 logger->info(
"ggml logging: {}", path);
235 logger->info(
"Initializing model orchestrator");
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);
244 load_bundled_grammars();
259 logger->info(
"Shutting down model orchestrator");
261 for (
auto& [path, backend] : model_pool_) {
262 if (backend->is_loaded()) {
300bool ModelOrchestrator::resolve_mtp_effective(
const std::string& tier_name)
const {
302 if (it != config_.
models.
tiers.end() && it->second.speculative_mtp) {
303 return *it->second.speculative_mtp;
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;
321 && try_speculative_route(model, messages, params, tier_name, result);
323 result = model->generate(messages, params);
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)
360 auto* llama_target =
dynamic_cast<LlamaCppBackend*
>(model);
361 if (llama_target ==
nullptr) {
363 result = GenerationResult{};
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);
370 result = llama_target->generate_mtp(
371 messages, params, on_token, cancel,
397 if (dm ==
nullptr) {
return false; }
398 int n = llama_model_n_layer(dm);
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)
439 if (resolve_mtp_effective(tier_name)) {
440 return try_mtp_route(model, messages, params, on_token, cancel,
444 bool kernel_ran =
false;
445 if (!compat.compatible) {
446 logger->info(
"Speculative requested but pair incompatible "
447 "({}); using plain decode", compat.diagnostic);
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");
458 logger->error(
"{}", result.error_message);
461 auto spec = llama_target->generate_speculative_with_draft(
462 messages, params, on_token, cancel, *llama_draft,
466 logger->info(
"Speculative kernel returned NOT_SUPPORTED "
467 "({}); falling back", spec.error_message);
469 result = std::move(spec);
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)
493 std::atomic<bool> local_cancel{
false};
497 return try_speculative_route_streaming(
498 model, messages, params, tier_name,
499 std::function<
void(std::string_view)>{}, local_cancel, result);
520 bool require_tool_call) {
522 llama->set_active_tools(params.
tools);
523 llama->set_require_tool_call(require_tool_call);
546 if (result.
content.empty()) {
return; }
553 result.
content = std::move(parsed.content);
554 result.
tool_calls = std::move(parsed.tool_calls);
580 logger->warn(
"Turn produced {} raw chars but delivered no content. {}",
608 const std::string& tier_name,
609 const std::unordered_map<std::string, TierConfig>& tiers) {
611 auto it = tiers.find(tier_name);
612 if (it == tiers.end()
613 || !it->second.require_tool_call.value_or(
false)) {
return; }
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.",
640 const std::string& tier_name,
641 const std::unordered_map<std::string, TierConfig>& tiers) {
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);
670 apply_tier_sampler_defaults(resolved, tier_name);
673 bool require_tc =
false;
674 if (
auto it = config_.
models.
tiers.find(tier_name);
676 require_tc = it->second.require_tool_call.value_or(
false);
694 const std::string& selected,
695 const std::string& adapter_name,
697 double routing_ms,
double swap_ms) {
698 logger->info(
"Orchestration: tier={}, adapter={}, grammar={}",
699 selected, adapter_name,
700 params.
grammar.empty() ?
"unconstrained"
702 logger->info(
"Total: {:.0f}ms (route={:.0f}ms, swap={:.0f}ms, "
704 result.
total_ms, routing_ms, swap_ms,
728 const std::vector<Message>& messages,
730 const std::string& tier_name)
732 auto t_start = now();
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());
746 double swap_ms = elapsed_ms(t_swap, now());
748 if (!model) {
return build_no_model_error(selected); }
751 resolve_and_stage(model, params, selected);
755 model, messages, resolved_params, selected);
764 result.
total_ms = elapsed_ms(t_start, now());
766 resolved_params, routing_ms, swap_ms);
782 const std::vector<Message>& messages,
784 std::atomic<bool>& cancel,
785 const std::string& tier_name)
787 auto t_start = now();
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());
799 double swap_ms = elapsed_ms(t_swap, now());
801 if (!model) {
return build_no_model_error(selected); }
804 resolve_and_stage(model, params, selected);
807 messages, resolved_params, cancel);
816 result.
total_ms = elapsed_ms(t_start, now());
818 resolved_params, routing_ms, swap_ms);
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)
841 const std::size_t n = messages_list.size();
842 const std::string lead =
843 (tiers.empty() || tiers[0].empty()) ?
"default" : tiers[0];
845 if (model ==
nullptr) {
846 return std::vector<GenerationResult>(n, build_no_model_error(lead));
849 std::vector<GenerationParams> resolved;
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));
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];
874 (*
static_cast<std::function<
void(std::string_view)
>*>(ud))(
875 std::string_view(data, len));
911 const std::vector<Message>& messages,
913 std::function<
void(std::string_view)> on_token,
914 std::atomic<bool>& cancel,
915 const std::string& tier_name)
917 std::string selected = tier_name.empty() ?
route(messages) : tier_name;
929 resolve_and_stage(model, params, selected);
942 const auto markers = (stream_adapter !=
nullptr)
945 markers.open, markers.close);
946 auto filtered = [&filter](std::string_view sv) {
947 filter.
on_token(sv.data(), sv.size());
952 && try_speculative_route_streaming(
953 model, messages, resolved_params, selected, filtered, cancel,
957 messages, resolved_params, filtered, cancel);
989 logger->info(
"Route: routing disabled, using default '{}'",
991 last_routing_result_ = {default_tier_,
"",
"",
"none", 0.0};
992 return default_tier_;
995 auto [tier, raw] = classify_task(messages);
996 last_routing_result_ = {tier, loaded_main_tier_, raw,
"none", 0.0};
999 tier_history_.push_back(tier);
1000 if (tier_history_.size() > 5) {
1001 tier_history_.erase(tier_history_.begin());
1004 logger->info(
"[ROUTER] {} | raw='{}'", tier, raw);
1031std::pair<std::string, std::string> ModelOrchestrator::classify_task(
1032 const std::vector<Message>& messages)
1034 std::string user_msg = extract_latest_user_message(messages);
1040 auto* router_backend = secondary_loader_.
get(
"router");
1041 if (router_backend ==
nullptr) {
1042 logger->warn(
"classify_task: router not loaded; returning empty");
1051 std::string router_prompt = user_msg +
" ->";
1053 if (cprompt.has_value() && !cprompt->empty()) {
1054 router_prompt = *cprompt +
"\n" + user_msg +
" ->";
1064 logger->info(
"classify_task: using configured classification_prompt "
1065 "(router instructed; max_tokens widened to 4)");
1067 auto result = router_backend->complete(router_prompt, router_params);
1068 std::string raw = result.content;
1071 auto start = raw.find_first_not_of(
" \t\n\r");
1072 if (start != std::string::npos) {
1073 raw = raw.substr(start);
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='{}'",
1083 return {it->second, digit};
1087 logger->warn(
"Route: no valid digit in '{}', defaulting to {}",
1088 raw, default_tier_);
1089 return {default_tier_,
""};
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);
1133 const TierConfig& tier_cfg, uint64_t weights_bytes,
int vram_reserve_mb);
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;
1153 auto weights = std::filesystem::file_size(tier_cfg.path, ec);
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);
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.",
1168 in.mmproj_bytes > 0 ?
" + vision projector" :
"",
1169 vram_budget_bytes_ / (1024 * 1024));
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;
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_);
1197 log_fit_recommendation(tier_name);
1223GenerationResult ModelOrchestrator::build_no_model_error(
1224 const std::string& tier_name) {
1225 GenerationResult err;
1226 err.finish_reason =
"error";
1228 err.error_code = last_residency_error_;
1229 err.error_message =
"Tier '" + tier_name +
"' model exceeds the "
1230 "engine's VRAM budget (gh#57)";
1234 err.error_message =
"No model available for tier: " + tier_name;
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);
1259 logger->error(
"Failed to activate tier: {}", tier_name);
1262 loaded_main_tier_ = tier_name;
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(),
1272 return backend.get();
1288InferenceBackend* ModelOrchestrator::get_model(
const std::string& tier_name) {
1289 std::lock_guard<std::mutex> lock(swap_mutex_);
1291 auto it = tiers_.find(tier_name);
1292 std::string effective_tier = tier_name;
1293 if (it == tiers_.end()) {
1295 if (it != tiers_.end()) {
1300 InferenceBackend* result =
nullptr;
1301 if (it != tiers_.end() && it->second->is_active()) {
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);
1312 ensure_tier_lora(tier_name, result);
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);
1346void ModelOrchestrator::deactivate_current_if_needed(InferenceBackend* incoming) {
1347 auto it = loaded_main_tier_.empty()
1348 ? tiers_.end() : tiers_.find(loaded_main_tier_);
1350 bool should_swap = it != tiers_.end()
1351 && it->second.get() != incoming
1352 && it->second->is_loaded();
1359 auto* llama_backend =
dynamic_cast<LlamaCppBackend*
>(it->second.get());
1360 if (llama_backend) {
1362 llama_backend->llama_model_ptr(),
1363 llama_backend->llama_context_ptr());
1366 unload_or_warm_current(it->second.get());
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;
1381 logger->info(
"Deactivating {} (keep_warm=true)", loaded_main_tier_);
1382 current->deactivate();
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_;
1392 fire_residency_observer(ResidencyEvent::Evicted,
1393 evicted_tier, path, footprint);
1407 return last_routing_result_;
1416 return loaded_main_tier_;
1432 std::vector<std::string> result;
1433 for (
const auto& [name, backend] : tiers_) {
1434 if (backend->is_loaded()) {
1435 result.push_back(name);
1438 if (secondary_loader_.
is_loaded(
"router")) {
1439 result.push_back(
"router");
1450 std::vector<std::string> result;
1451 for (
const auto& [name, _] : tiers_) {
1452 result.push_back(name);
1455 result.push_back(
"router");
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();
1484 const std::string& from,
const std::string& to)
const
1486 auto it = handoff_rules_.find(from);
1487 if (it == handoff_rules_.end()) {
1490 return it->second.count(to) > 0;
1499 auto it = adapters_.find(tier_name);
1500 if (it != adapters_.end()) {
1501 return it->second.get();
1528bool ModelOrchestrator::deactivate_if_active(llama_context* ctx) {
1544double ModelOrchestrator::ensure_adapter_for_tier(
1545 const std::string& tier_name, llama_context* ctx)
1547 auto tier_it = config_.
models.
tiers.find(tier_name);
1552 const auto& tier_cfg = tier_it->second;
1553 auto t_start =
now();
1554 bool needs_kv_clear =
false;
1556 if (!tier_cfg.adapter_path) {
1557 needs_kv_clear = deactivate_if_active(ctx);
1559 needs_kv_clear = lora_manager_.
swap(tier_name, ctx);
1560 if (!needs_kv_clear) {
1561 logger->warn(
"Adapter swap to '{}' failed", tier_name);
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",
1583void ModelOrchestrator::preload_adapters() {
1586 for (
const auto& [name, tier_cfg] : config_.models.tiers) {
1587 if (!tier_cfg.adapter_path) {
1591 auto tier_it = tiers_.find(name);
1592 if (tier_it == tiers_.end()) {
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",
1604 bool ok = lora_manager_.
load(
1606 *tier_cfg.adapter_path,
1607 llama_backend->llama_model_ptr(),
1608 tier_cfg.adapter_scale);
1616 logger->info(
"Preloaded {} LoRA adapter(s) to WARM", loaded);
1631void ModelOrchestrator::load_bundled_grammars() {
1632 std::filesystem::path grammar_dir;
1634 grammar_dir = config_.
config_dir /
"grammars";
1636 if (grammar_dir.empty() || !std::filesystem::is_directory(grammar_dir)) {
1639 logger->info(
"No bundled grammar directory found, skipping");
1644 logger->info(
"Grammar registry: {} grammar(s) loaded from {}",
1645 count, grammar_dir.string());
1661 const std::filesystem::path& grammar_dir) {
1662 if (!std::filesystem::is_directory(grammar_dir)) {
1666 logger->info(
"Grammar registry: {} grammar(s) loaded from {}",
1667 count, grammar_dir.string());
1682 for (
auto& [_, backend] : model_pool_) {
1683 if (backend) { backend->clear_prompt_cache(); }
1686 logger->info(
"Prompt caches invalidated across all backends "
1687 "(identity change)");
1699 for (
const auto& [_, tier] : config_.
models.
tiers) {
1700 if (tier.has_capability(
"vision")) {
return true; }
1713 for (
const auto& [name, tier] : config_.
models.
tiers) {
1714 if (tier.has_capability(
"vision")) {
return name; }
1728 const std::shared_ptr<InferenceBackend>& tier_backend) {
1729 if (!tier_backend || !tier_backend->is_loaded()) {
1746std::string ModelOrchestrator::resolve_speculative_pair(
1747 llama_model*& target_out, llama_model*& draft_out)
const {
1748 target_out =
nullptr;
1749 draft_out =
nullptr;
1752 auto tier_it = tiers_.find(loaded_main_tier_);
1753 if (tier_it == tiers_.end()) {
1754 err =
"no main tier loaded";
1757 if (target_out ==
nullptr) {
1758 err =
"main tier backend is not a llama.cpp backend or "
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 "
1765 "(set inference.speculative.draft_model)";
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";
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);
1816 std::filesystem::path p(grammar_value);
1817 if (p.extension() ==
".gbnf") {
1818 return p.stem().string();
1820 return grammar_value;
1840void ModelOrchestrator::resolve_grammar_key(
1841 GenerationParams& params,
const std::string& tier_name)
1843 if (!params.grammar.empty()) {
1848 std::string key = params.grammar_key;
1853 if (it != config_.
models.
tiers.end() && it->second.grammar) {
1862 std::string content = grammar_registry_.
get(key);
1863 if (content.empty()) {
1864 logger->warn(
"Grammar key '{}' not found in registry", key);
1868 logger->info(
"Grammar resolved: key='{}', {} bytes",
1869 key, content.size());
1870 params.grammar = std::move(content);
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; }
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);
1928void ModelOrchestrator::apply_tier_sampler_defaults(
1929 GenerationParams& params,
const std::string& tier_name)
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;
1938 ov.top_k = tier.top_k;
1939 ov.min_p = tier.min_p;
1940 ov.presence_penalty = tier.presence_penalty;
1941 ov.frequency_penalty = tier.frequency_penalty;
1942 ov.repeat_penalty = tier.repeat_penalty;
1943 ov.enable_thinking = tier.enable_thinking;
1944 ov.tool_call_mode = tier.tool_call_mode;
1945 float before_temp = params.temperature;
1946 int before_max = params.max_tokens;
1948 if (params.temperature != before_temp) {
1949 logger->info(
"Tier '{}' temperature applied: {}",
1950 tier_name, params.temperature);
1952 if (params.max_tokens != before_max) {
1953 logger->info(
"Tier '{}' max_output_tokens applied: {}",
1954 tier_name, params.max_tokens);
1974size_t ModelOrchestrator::resolve_vram_budget_bytes() {
1975 const char* env = std::getenv(
"ENTROPIC_VRAM_BUDGET_BYTES");
1976 if (env ==
nullptr) {
1988 long long v = std::stoll(env);
1989 budget = (v < 0) ? 0 : static_cast<size_t>(v);
2013 const TierConfig& tier_cfg, uint64_t weights_bytes,
int vram_reserve_mb) {
2022 std::error_code proj_ec;
2023 auto proj = std::filesystem::file_size(tier_cfg.
mmproj_path, proj_ec);
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;
2048 auto weights = std::filesystem::file_size(tier_cfg.path, ec);
2049 if (ec) {
return 0; }
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);
2070 tier_footprint_bytes_[tier_name] = v;
2081 std::lock_guard<std::mutex> lock(swap_mutex_);
2082 residency_observer_ = std::move(cb);
2090void ModelOrchestrator::fire_residency_observer(
2091 ResidencyEvent event,
2092 const std::string& tier_name,
2093 const std::string& model_path,
2095 const char* event_name =
"unknown";
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;
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);
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) {
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;
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}
2152 std::lock_guard<std::mutex> lock(swap_mutex_);
2154 j[
"vram_total_bytes"] = vram_budget_bytes_;
2155 j[
"vram_budget_bytes"] = vram_budget_bytes_;
2157 nlohmann::json arr = nlohmann::json::array();
2158 for (
const auto& [name, backend] : tiers_) {
2159 if (!backend || !backend->is_loaded()) {
continue; }
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())
2170 name, tier_it->second.path, tier_it->second.context_length,
2173 j[
"residency"] = std::move(arr);
2174 j[
"vram_headroom_bytes"] = vram_budget_bytes_ > in_use
2175 ? vram_budget_bytes_ - in_use
2177 j[
"backend"] = vram_budget_bytes_ > 0 ?
"configured" :
"unknown";
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).
BackendInfo info() const
Get backend metadata.
std::vector< GenerationResult > generate_batch(const std::vector< std::vector< Message > > &requests, const std::vector< GenerationParams > ¶ms, std::atomic< bool > &cancel)
Generate N independent same-prefix requests together.
GenerationResult generate(const std::vector< Message > &messages, const GenerationParams ¶ms)
Generate a complete response.
GenerationResult generate_streaming(const std::vector< Message > &messages, const GenerationParams ¶ms, std::function< void(std::string_view token)> on_token, std::atomic< bool > &cancel)
Generate with per-token streaming callback.
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 ¶ms, 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 ¶ms, 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 > ¶ms_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.
void shutdown()
Unload every role.
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_ERROR_TIER_MODEL_TOO_LARGE
A single tier's model weights+KV exceed the engine's VRAM budget; eviction cannot help (v2....
@ ENTROPIC_ERROR_SPECULATIVE_INCOMPATIBLE_CONFIG
MTP/speculative enabled but the request can't run correctly (temp>0, grammar, tools,...
@ ENTROPIC_ERROR_NOT_SUPPORTED
Capability not supported by this backend (v1.9.13)
@ ENTROPIC_ERROR_GENERATE_FAILED
Generation failed (context overflow, model error)
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.
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
double elapsed_ms(std::chrono::steady_clock::time_point start, std::chrono::steady_clock::time_point end)
Compute elapsed milliseconds between two time points.
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 ¶ms, 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 ¶ms, 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 ¶ms, 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.
Generation parameters for a single inference call.
std::string grammar
GBNF grammar string (empty = unconstrained)
std::string tool_call_mode
Per-call tool-call generation mode (gh#103).
float repeat_penalty
Repetition penalty.
std::string tools
Active tool definitions for this turn, as an MCP tool-list JSON array ([{name, description,...
float temperature
Sampling temperature.
std::string grammar_key
Grammar registry key.
float frequency_penalty
Frequency-penalty term in llama.cpp's penalties sampler (gh#23 MVP item 3).
float presence_penalty
Presence-penalty term in llama.cpp's penalties sampler (gh#23 MVP item 2).
bool enable_thinking
Enable <think> blocks (false if reasoning_budget == 0)
float min_p
Min-p nucleus sampling threshold (gh#23 MVP item 1).
int max_tokens
Maximum tokens to generate.
float top_p
Nucleus sampling threshold.
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)
std::filesystem::path mmproj_path
Vision projector GGUF path.
int gpu_layers
GPU offload layers (-1 = all)
int context_length
Context window size (512–131072)
std::filesystem::path path
Resolved model file path.
std::string cache_type_k
KV cache key quantization type.
std::string cache_type_v
KV cache value quantization type.
Result of a speculative-decoding compatibility check.
std::optional< ModelConfig > router
Router model (separate from tiers)
std::unordered_map< std::string, TierConfig > tiers
Tier name → config.
std::string default_tier
Default tier name.
Full parsed configuration.
int vram_reserve_mb
Reserved VRAM headroom (MB, 0–65536)
RoutingConfig routing
Routing rules.
InferenceConfig inference
Inference-side knobs (currently speculative decoding only).
ModelsConfig models
Tiers + router.
std::filesystem::path log_dir
Session log directory (session.log + session_model.log).
bool ggml_logging
Enable ggml/llama.cpp logging to llama_ggml.log in log_dir.
std::filesystem::path llama_log_path
Override path for ggml/llama log when ggml_logging == true (gh#23 MVP item 12, v2....
std::filesystem::path config_dir
Config dir — base for bundled data discovery.
std::string fallback_tier
Fallback when routing fails.
bool enabled
Enable routing.
std::optional< std::string > classification_prompt
Custom prompt (nullopt = auto)
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)
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...
int n_draft
Window size (proposed tokens).
ModelConfig draft
Full ModelConfig for the draft model.
The reasoning-block delimiters a model family emits (gh#108).
Tier-specific model configuration.
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