23#include <nlohmann/json.hpp>
42std::string extract_latest_user_message(
const std::vector<Message>& messages) {
43 for (
auto it = messages.rbegin(); it != messages.rend(); ++it) {
44 if (it->role ==
"user") {
68bool ModelOrchestrator::create_tier_backends(
const ParsedConfig& config) {
69 for (
const auto& [name, tier_config] : config.models.tiers) {
70 std::string path_key = tier_config.path.string();
71 if (!std::filesystem::exists(tier_config.path)) {
72 logger->error(
"Model file not found for tier '{}': {}",
74 logger->error(
"Place a GGUF file at the path above, or set "
75 "ENTROPIC_MODEL_DIR to a directory containing "
76 "it. Run `entropic download --list` to see "
77 "bundled model keys, then "
78 "`entropic download <key>` to fetch one.");
81 if (model_pool_.find(path_key) == model_pool_.end()) {
82 model_pool_[path_key] = std::make_shared<LlamaCppBackend>();
84 tiers_[name] = model_pool_[path_key];
86 tier_config.adapter, name,
"" );
91 logger->info(
"Created {} unique backend(s) for {} tier(s)",
92 model_pool_.size(), tiers_.size());
102void ModelOrchestrator::build_routing_tables(
const ParsedConfig& config) {
103 for (
const auto& [digit, tier_name] : config.routing.tier_map) {
104 tier_map_[digit] = tier_name;
106 for (
const auto& [src, targets] : config.routing.handoff_rules) {
107 handoff_rules_[src] = std::unordered_set<std::string>(
108 targets.begin(), targets.end());
119bool ModelOrchestrator::activate_default_tier(
const ParsedConfig& config) {
120 if (tiers_.find(default_tier_) == tiers_.end()) {
return true; }
121 auto& backend = tiers_[default_tier_];
122 auto& tier_cfg = config.models.tiers.at(default_tier_);
123 if (!backend->load_and_activate(tier_cfg)) {
124 logger->error(
"Failed to activate default tier: {}", default_tier_);
127 loaded_main_tier_ = default_tier_;
128 logger->info(
"Activated default tier: {}", default_tier_);
143void ModelOrchestrator::activate_router(
const ParsedConfig& config) {
144 if (!config.models.router) {
return; }
147 secondary_loader_.
ensure_loaded(
"router", *config.models.router);
163void ModelOrchestrator::activate_draft(
const ParsedConfig& config) {
164 const auto& spec = config.inference.speculative;
165 if (!spec.enabled || spec.draft.path.empty()) {
return; }
171 logger->info(
"Speculative MTP: head '{}' is target-owned; skipping "
172 "separate draft activation", spec.draft.path.string());
200 vram_budget_bytes_ = resolve_vram_budget_bytes();
201 if (vram_budget_bytes_ > 0) {
202 logger->info(
"[residency] VRAM budget: {} bytes "
203 "(ENTROPIC_VRAM_BUDGET_BYTES)",
216 }
else if (!config.
log_dir.empty()) {
217 path = (config.
log_dir /
"llama_ggml.log").
string();
221 logger->info(
"ggml logging: {}", path);
225 logger->info(
"Initializing model orchestrator");
227 if (!create_tier_backends(config)) {
return false; }
228 build_routing_tables(config);
229 if (!activate_default_tier(config)) {
return false; }
230 activate_router(config);
231 activate_draft(config);
234 load_bundled_grammars();
248 logger->info(
"Shutting down model orchestrator");
250 for (
auto& [path, backend] : model_pool_) {
251 if (backend->is_loaded()) {
280bool ModelOrchestrator::resolve_mtp_effective(
const std::string& tier_name)
const {
282 if (it != config_.
models.
tiers.end() && it->second.speculative_mtp) {
283 return *it->second.speculative_mtp;
294GenerationResult ModelOrchestrator::run_generate_dispatch(
295 InferenceBackend* model,
296 const std::vector<Message>& messages,
297 const GenerationParams& params,
298 const std::string& tier_name) {
299 GenerationResult result;
301 && try_speculative_route(model, messages, params, tier_name, result);
303 result = model->generate(messages, params);
322bool ModelOrchestrator::try_mtp_route(
323 InferenceBackend* model,
324 const std::vector<Message>& messages,
325 const GenerationParams& params,
326 std::function<
void(std::string_view)> on_token,
327 std::atomic<bool>& cancel,
328 GenerationResult& result)
330 auto* llama_target =
dynamic_cast<LlamaCppBackend*
>(model);
331 if (llama_target ==
nullptr) {
333 result = GenerationResult{};
335 result.error_message =
"speculative.mtp enabled but the target backend "
336 "is not llama.cpp; disable speculative.mtp";
337 result.finish_reason =
"error";
338 logger->error(
"{}", result.error_message);
340 result = llama_target->generate_mtp(
341 messages, params, on_token, cancel,
372bool ModelOrchestrator::try_speculative_route_streaming(
373 InferenceBackend* model,
374 const std::vector<Message>& messages,
375 const GenerationParams& params,
376 const std::string& tier_name,
377 std::function<
void(std::string_view)> on_token,
378 std::atomic<bool>& cancel,
379 GenerationResult& result)
384 if (resolve_mtp_effective(tier_name) && params.grammar.empty()) {
385 return try_mtp_route(model, messages, params, on_token, cancel,
389 bool kernel_ran =
false;
390 if (!compat.compatible) {
391 logger->info(
"Speculative requested but pair incompatible "
392 "({}); using plain decode", compat.diagnostic);
394 auto* llama_target =
dynamic_cast<LlamaCppBackend*
>(model);
395 auto* draft_be = secondary_loader_.
get(
"draft");
396 auto* llama_draft =
dynamic_cast<LlamaCppBackend*
>(draft_be);
397 if (llama_target ==
nullptr || llama_draft ==
nullptr) {
398 logger->
info(
"Speculative compat passed but target/draft "
399 "is not llama.cpp; using plain decode");
401 auto spec = llama_target->generate_speculative_with_draft(
402 messages, params, on_token, cancel, *llama_draft,
406 logger->info(
"Speculative kernel returned NOT_SUPPORTED "
407 "({}); falling back", spec.error_message);
409 result = std::move(spec);
426bool ModelOrchestrator::try_speculative_route(
427 InferenceBackend* model,
428 const std::vector<Message>& messages,
429 const GenerationParams& params,
430 const std::string& tier_name,
431 GenerationResult& result)
433 std::atomic<bool> local_cancel{
false};
437 return try_speculative_route_streaming(
438 model, messages, params, tier_name,
439 std::function<
void(std::string_view)>{}, local_cancel, result);
459 llama->set_active_tools(params.
tools);
482 if (result.
content.empty()) {
return; }
485 if (llama !=
nullptr && llama->common_chat_parse_reliable()) {
486 auto parsed = llama->parse_response(result.
content);
489 result.
content = parsed.content;
490 result.
tool_calls = std::move(parsed.tool_calls);
491 }
else if (adapter !=
nullptr) {
493 result.
content = parsed.cleaned_content;
494 result.
tool_calls = std::move(parsed.tool_calls);
514GenerationParams ModelOrchestrator::resolve_and_stage(
515 InferenceBackend* model,
516 const GenerationParams& params,
517 const std::string& tier_name) {
518 GenerationParams resolved = params;
519 resolve_grammar_key(resolved, tier_name);
520 apply_tier_sampler_defaults(resolved, tier_name);
537 const std::string& selected,
538 const std::string& adapter_name,
540 double routing_ms,
double swap_ms) {
541 logger->info(
"Orchestration: tier={}, adapter={}, grammar={}",
542 selected, adapter_name,
543 params.
grammar.empty() ?
"unconstrained"
545 logger->info(
"Total: {:.0f}ms (route={:.0f}ms, swap={:.0f}ms, "
547 result.
total_ms, routing_ms, swap_ms,
571 const std::vector<Message>& messages,
573 const std::string& tier_name)
575 auto t_start = now();
578 std::string selected = tier_name;
579 double routing_ms = 0.0;
580 if (selected.empty()) {
581 auto t_route = now();
582 selected =
route(messages);
583 routing_ms = elapsed_ms(t_route, now());
589 double swap_ms = elapsed_ms(t_swap, now());
591 if (!model) {
return build_no_model_error(selected); }
594 resolve_and_stage(model, params, selected);
598 model, messages, resolved_params, selected);
604 result.
total_ms = elapsed_ms(t_start, now());
606 resolved_params, routing_ms, swap_ms);
622 const std::vector<Message>& messages,
624 std::atomic<bool>& cancel,
625 const std::string& tier_name)
627 auto t_start = now();
629 std::string selected = tier_name;
630 double routing_ms = 0.0;
631 if (selected.empty()) {
632 auto t_route = now();
633 selected =
route(messages);
634 routing_ms = elapsed_ms(t_route, now());
639 double swap_ms = elapsed_ms(t_swap, now());
641 if (!model) {
return build_no_model_error(selected); }
644 resolve_and_stage(model, params, selected);
647 messages, resolved_params, cancel);
653 result.
total_ms = elapsed_ms(t_start, now());
655 resolved_params, routing_ms, swap_ms);
673 const std::vector<std::vector<Message>>& messages_list,
674 const std::vector<GenerationParams>& params_list,
675 const std::vector<std::string>& tiers,
676 std::atomic<bool>& cancel)
678 const std::size_t n = messages_list.size();
679 const std::string lead =
680 (tiers.empty() || tiers[0].empty()) ?
"default" : tiers[0];
682 if (model ==
nullptr) {
683 return std::vector<GenerationResult>(n, build_no_model_error(lead));
686 std::vector<GenerationParams> resolved;
688 for (std::size_t i = 0; i < n; ++i) {
689 const std::string& t = tiers[i].empty() ? lead : tiers[i];
690 resolved.push_back(resolve_and_stage(model, params_list[i], t));
693 auto results = model->
generate_batch(messages_list, resolved, cancel);
694 for (std::size_t i = 0; i < results.size() && i < tiers.size(); ++i) {
695 const std::string& t = tiers[i].empty() ? lead : tiers[i];
715 const std::vector<Message>& messages,
717 std::function<
void(std::string_view)> on_token,
718 std::atomic<bool>& cancel,
719 const std::string& tier_name)
721 std::string selected = tier_name.empty() ?
route(messages) : tier_name;
733 resolve_and_stage(model, params, selected);
743 && try_speculative_route_streaming(
744 model, messages, resolved_params, selected, on_token, cancel,
746 return spec_streaming;
769 logger->info(
"Route: routing disabled, using default '{}'",
771 last_routing_result_ = {default_tier_,
"",
"",
"none", 0.0};
772 return default_tier_;
775 auto [tier, raw] = classify_task(messages);
776 last_routing_result_ = {tier, loaded_main_tier_, raw,
"none", 0.0};
779 tier_history_.push_back(tier);
780 if (tier_history_.size() > 5) {
781 tier_history_.erase(tier_history_.begin());
784 logger->info(
"[ROUTER] {} | raw='{}'", tier, raw);
809std::pair<std::string, std::string> ModelOrchestrator::classify_task(
810 const std::vector<Message>& messages)
812 std::string user_msg = extract_latest_user_message(messages);
818 auto* router_backend = secondary_loader_.
get(
"router");
819 if (router_backend ==
nullptr) {
820 logger->warn(
"classify_task: router not loaded; returning empty");
829 std::string router_prompt = user_msg +
" ->";
831 if (cprompt.has_value() && !cprompt->empty()) {
832 router_prompt = *cprompt +
"\n" + user_msg +
" ->";
842 logger->info(
"classify_task: using configured classification_prompt "
843 "(router instructed; max_tokens widened to 4)");
845 auto result = router_backend->complete(router_prompt, router_params);
846 std::string raw = result.content;
849 auto start = raw.find_first_not_of(
" \t\n\r");
850 if (start != std::string::npos) {
851 raw = raw.substr(start);
856 std::string digit(1, c);
857 auto it = tier_map_.find(digit);
858 if (it != tier_map_.end()) {
859 logger->info(
"Route: digit='{}' -> tier='{}'",
861 return {it->second, digit};
865 logger->warn(
"Route: no valid digit in '{}', defaulting to {}",
867 return {default_tier_,
""};
890void ModelOrchestrator::record_activation_reuse(
891 const std::string& tier_name) {
892 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
893 std::chrono::steady_clock::now() - start_time_).count();
894 bool tier_changed = (loaded_main_tier_ != tier_name);
895 tier_last_activation_ms_[tier_name] = now_ms;
896 if (!tier_changed) {
return; }
897 auto tier_it = config_.
models.
tiers.find(tier_name);
898 std::string path = tier_it != config_.
models.
tiers.end()
899 ? tier_it->second.path.string() :
"";
900 size_t footprint = tier_footprint_bytes_.count(tier_name)
901 ? tier_footprint_bytes_[tier_name]
902 : estimate_footprint_bytes(tier_name);
903 tier_footprint_bytes_[tier_name] = footprint;
904 loaded_main_tier_ = tier_name;
905 fire_residency_observer(ResidencyEvent::ActivationSwap,
906 tier_name, path, footprint);
919bool ModelOrchestrator::residency_admits(
const std::string& tier_name) {
920 size_t footprint = estimate_footprint_bytes(tier_name);
922 tier_footprint_bytes_[tier_name] = footprint;
924 if (vram_budget_bytes_ > 0 && footprint > vram_budget_bytes_) {
925 logger->error(
"[residency] tier '{}' footprint {} bytes "
926 "exceeds VRAM budget {} bytes — "
927 "TIER_MODEL_TOO_LARGE (gh#57)",
928 tier_name, footprint, vram_budget_bytes_);
954GenerationResult ModelOrchestrator::build_no_model_error(
955 const std::string& tier_name) {
956 GenerationResult err;
957 err.finish_reason =
"error";
959 err.error_code = last_residency_error_;
960 err.error_message =
"Tier '" + tier_name +
"' model exceeds the "
961 "engine's VRAM budget (gh#57)";
965 err.error_message =
"No model available for tier: " + tier_name;
983InferenceBackend* ModelOrchestrator::activate_and_track(
984 const std::string& tier_name,
985 const std::shared_ptr<InferenceBackend>& backend) {
986 auto tier_it = config_.
models.
tiers.find(tier_name);
987 bool activated = tier_it != config_.
models.
tiers.end()
988 && backend->load_and_activate(tier_it->second);
990 logger->error(
"Failed to activate tier: {}", tier_name);
993 loaded_main_tier_ = tier_name;
995 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
996 std::chrono::steady_clock::now() - start_time_).count();
997 tier_last_activation_ms_[tier_name] = now_ms;
998 size_t footprint = tier_footprint_bytes_.count(tier_name)
999 ? tier_footprint_bytes_[tier_name] : 0;
1000 fire_residency_observer(ResidencyEvent::Loaded,
1001 tier_name, tier_it->second.path.string(),
1003 return backend.get();
1019InferenceBackend* ModelOrchestrator::get_model(
const std::string& tier_name) {
1020 std::lock_guard<std::mutex> lock(swap_mutex_);
1022 auto it = tiers_.find(tier_name);
1023 std::string effective_tier = tier_name;
1024 if (it == tiers_.end()) {
1026 if (it != tiers_.end()) {
1031 InferenceBackend* result =
nullptr;
1032 if (it != tiers_.end() && it->second->is_active()) {
1034 record_activation_reuse(effective_tier);
1035 result = it->second.get();
1036 }
else if (it != tiers_.end() && residency_admits(effective_tier)) {
1037 deactivate_current_if_needed(it->second.get());
1038 result = activate_and_track(effective_tier, it->second);
1043 ensure_tier_lora(tier_name, result);
1056void ModelOrchestrator::ensure_tier_lora(
const std::string& tier_name,
1057 InferenceBackend* result) {
1058 auto* llama_backend =
dynamic_cast<LlamaCppBackend*
>(result);
1059 llama_context* ctx = llama_backend
1060 ? llama_backend->llama_context_ptr() :
nullptr;
1061 double adapter_ms = ensure_adapter_for_tier(tier_name, ctx);
1077void ModelOrchestrator::deactivate_current_if_needed(InferenceBackend* incoming) {
1078 auto it = loaded_main_tier_.empty()
1079 ? tiers_.end() : tiers_.find(loaded_main_tier_);
1081 bool should_swap = it != tiers_.end()
1082 && it->second.get() != incoming
1083 && it->second->is_loaded();
1090 auto* llama_backend =
dynamic_cast<LlamaCppBackend*
>(it->second.get());
1091 if (llama_backend) {
1093 llama_backend->llama_model_ptr(),
1094 llama_backend->llama_context_ptr());
1097 unload_or_warm_current(it->second.get());
1106void ModelOrchestrator::unload_or_warm_current(InferenceBackend* current) {
1107 auto cfg_it = config_.
models.
tiers.find(loaded_main_tier_);
1108 bool keep_warm = cfg_it != config_.
models.
tiers.end()
1109 && cfg_it->second.keep_warm;
1112 logger->info(
"Deactivating {} (keep_warm=true)", loaded_main_tier_);
1113 current->deactivate();
1116 logger->info(
"Unloading {} (keep_warm=false)", loaded_main_tier_);
1117 std::string path = cfg_it != config_.
models.
tiers.end()
1118 ? cfg_it->second.path.string() :
"";
1119 size_t footprint = tier_footprint_bytes_.count(loaded_main_tier_)
1120 ? tier_footprint_bytes_[loaded_main_tier_] : 0;
1121 std::string evicted_tier = loaded_main_tier_;
1123 fire_residency_observer(ResidencyEvent::Evicted,
1124 evicted_tier, path, footprint);
1135 return last_routing_result_;
1144 return loaded_main_tier_;
1157 std::vector<std::string> result;
1158 for (
const auto& [name, backend] : tiers_) {
1159 if (backend->is_loaded()) {
1160 result.push_back(name);
1163 if (secondary_loader_.
is_loaded(
"router")) {
1164 result.push_back(
"router");
1175 std::vector<std::string> result;
1176 for (
const auto& [name, _] : tiers_) {
1177 result.push_back(name);
1180 result.push_back(
"router");
1193 const std::string& tier_name)
const {
1194 auto it = tiers_.find(tier_name);
1195 if (it == tiers_.end()) {
return nullptr; }
1196 return it->second.get();
1205 const std::string& from,
const std::string& to)
const
1207 auto it = handoff_rules_.find(from);
1208 if (it == handoff_rules_.end()) {
1211 return it->second.count(to) > 0;
1220 auto it = adapters_.find(tier_name);
1221 if (it != adapters_.end()) {
1222 return it->second.get();
1249bool ModelOrchestrator::deactivate_if_active(llama_context* ctx) {
1265double ModelOrchestrator::ensure_adapter_for_tier(
1266 const std::string& tier_name, llama_context* ctx)
1268 auto tier_it = config_.
models.
tiers.find(tier_name);
1273 const auto& tier_cfg = tier_it->second;
1274 auto t_start =
now();
1275 bool needs_kv_clear =
false;
1277 if (!tier_cfg.adapter_path) {
1278 needs_kv_clear = deactivate_if_active(ctx);
1280 needs_kv_clear = lora_manager_.
swap(tier_name, ctx);
1281 if (!needs_kv_clear) {
1282 logger->warn(
"Adapter swap to '{}' failed", tier_name);
1286 if (needs_kv_clear && ctx) {
1287 llama_memory_clear(llama_get_memory(ctx),
true);
1288 logger->info(
"Adapter swap for tier '{}' in {:.1f}ms",
1304void ModelOrchestrator::preload_adapters() {
1307 for (
const auto& [name, tier_cfg] : config_.models.tiers) {
1308 if (!tier_cfg.adapter_path) {
1312 auto tier_it = tiers_.find(name);
1313 if (tier_it == tiers_.end()) {
1317 auto* llama_backend =
dynamic_cast<LlamaCppBackend*
>(
1318 tier_it->second.get());
1319 if (!llama_backend || !llama_backend->llama_model_ptr()) {
1320 logger->warn(
"Cannot preload adapter for '{}' — model not loaded",
1325 bool ok = lora_manager_.
load(
1327 *tier_cfg.adapter_path,
1328 llama_backend->llama_model_ptr(),
1329 tier_cfg.adapter_scale);
1337 logger->info(
"Preloaded {} LoRA adapter(s) to WARM", loaded);
1352void ModelOrchestrator::load_bundled_grammars() {
1353 std::filesystem::path grammar_dir;
1355 grammar_dir = config_.
config_dir /
"grammars";
1357 if (grammar_dir.empty() || !std::filesystem::is_directory(grammar_dir)) {
1360 logger->info(
"No bundled grammar directory found, skipping");
1364 size_t count = grammar_registry_.
load_bundled(grammar_dir);
1365 logger->info(
"Grammar registry: {} grammar(s) loaded from {}",
1366 count, grammar_dir.string());
1382 const std::filesystem::path& grammar_dir) {
1383 if (!std::filesystem::is_directory(grammar_dir)) {
1386 auto count = grammar_registry_.
load_bundled(grammar_dir);
1387 logger->info(
"Grammar registry: {} grammar(s) loaded from {}",
1388 count, grammar_dir.string());
1403 for (
auto& [_, backend] : model_pool_) {
1404 if (backend) { backend->clear_prompt_cache(); }
1407 logger->info(
"Prompt caches invalidated across all backends "
1408 "(identity change)");
1418 for (
const auto& [_, tier] : config_.
models.
tiers) {
1419 if (tier.has_capability(
"vision")) {
return true; }
1431 for (
const auto& [name, tier] : config_.
models.
tiers) {
1432 if (tier.has_capability(
"vision")) {
return name; }
1446 const std::shared_ptr<InferenceBackend>& tier_backend) {
1447 if (!tier_backend || !tier_backend->is_loaded()) {
1464std::string ModelOrchestrator::resolve_speculative_pair(
1465 llama_model*& target_out, llama_model*& draft_out)
const {
1466 target_out =
nullptr;
1467 draft_out =
nullptr;
1470 auto tier_it = tiers_.find(loaded_main_tier_);
1471 if (tier_it == tiers_.end()) {
1472 err =
"no main tier loaded";
1475 if (target_out ==
nullptr) {
1476 err =
"main tier backend is not a llama.cpp backend or "
1479 auto* draft_backend = secondary_loader_.
get(
"draft");
1480 if (draft_backend ==
nullptr || !draft_backend->is_loaded()) {
1481 err =
"no draft model configured for speculative "
1483 "(set inference.speculative.draft_model)";
1485 auto* d =
dynamic_cast<LlamaCppBackend*
>(draft_backend);
1486 draft_out = (d ==
nullptr) ?
nullptr : d->llama_model_ptr();
1487 if (draft_out ==
nullptr) {
1488 err =
"draft backend is not a llama.cpp backend";
1507ModelOrchestrator::SpeculativeCompatInfo
1510 llama_model* target_model =
nullptr;
1511 llama_model* draft_model =
nullptr;
1512 info.diagnostic = resolve_speculative_pair(target_model, draft_model);
1513 if (info.diagnostic.empty()) {
1514 auto result = entropic::speculative::check_compat(
1515 target_model, draft_model);
1516 info.compatible = result.compatible;
1517 info.diagnostic = std::move(result.diagnostic);
1534 std::filesystem::path p(grammar_value);
1535 if (p.extension() ==
".gbnf") {
1536 return p.stem().string();
1538 return grammar_value;
1555void ModelOrchestrator::resolve_grammar_key(
1556 GenerationParams& params,
const std::string& tier_name)
1558 if (!params.grammar.empty()) {
1563 std::string key = params.grammar_key;
1568 if (it != config_.
models.
tiers.end() && it->second.grammar) {
1577 std::string content = grammar_registry_.
get(key);
1578 if (content.empty()) {
1579 logger->warn(
"Grammar key '{}' not found in registry", key);
1583 logger->info(
"Grammar resolved: key='{}', {} bytes",
1584 key, content.size());
1585 params.grammar = std::move(content);
1595template <
typename T>
1596inline void apply_if_default(T& field,
const std::optional<T>& ov, T dflt) {
1597 if (ov.has_value() && field == dflt) { field = *ov; }
1612 apply_if_default(params.
top_p, ov.
top_p, 0.9f);
1613 apply_if_default(params.
top_k, ov.
top_k, 40);
1614 apply_if_default(params.
min_p, ov.
min_p, 0.0f);
1631void ModelOrchestrator::apply_tier_sampler_defaults(
1632 GenerationParams& params,
const std::string& tier_name)
1636 const auto& tier = it->second;
1637 TierSamplerOverrides ov;
1638 ov.temperature = tier.temperature;
1639 ov.max_output_tokens = tier.max_output_tokens;
1640 ov.top_p = tier.top_p;
1641 ov.top_k = tier.top_k;
1642 ov.min_p = tier.min_p;
1643 ov.presence_penalty = tier.presence_penalty;
1644 ov.frequency_penalty = tier.frequency_penalty;
1645 ov.repeat_penalty = tier.repeat_penalty;
1646 ov.enable_thinking = tier.enable_thinking;
1647 ov.tool_call_mode = tier.tool_call_mode;
1648 float before_temp = params.temperature;
1649 int before_max = params.max_tokens;
1651 if (params.temperature != before_temp) {
1652 logger->info(
"Tier '{}' temperature applied: {}",
1653 tier_name, params.temperature);
1655 if (params.max_tokens != before_max) {
1656 logger->info(
"Tier '{}' max_output_tokens applied: {}",
1657 tier_name, params.max_tokens);
1673size_t ModelOrchestrator::resolve_vram_budget_bytes() {
1674 const char* env = std::getenv(
"ENTROPIC_VRAM_BUDGET_BYTES");
1675 if (env ==
nullptr || *env ==
'\0') {
return 0; }
1677 long long v = std::stoll(env);
1678 return (v < 0) ? 0 :
static_cast<size_t>(v);
1694size_t ModelOrchestrator::estimate_footprint_bytes(
1695 const std::string& tier_name)
const {
1696 auto tier_it = config_.
models.
tiers.find(tier_name);
1697 if (tier_it == config_.
models.
tiers.end()) {
return 0; }
1698 const auto& tier_cfg = tier_it->second;
1700 auto weights = std::filesystem::file_size(tier_cfg.path, ec);
1701 if (ec) {
return 0; }
1702 const size_t kv_per_token = 16ull * 1024ull;
1703 size_t kv =
static_cast<size_t>(tier_cfg.context_length) * kv_per_token;
1705 * 1024ull * 1024ull;
1706 return static_cast<size_t>(weights) + kv + headroom;
1715 const std::string& tier_name)
const {
1716 std::lock_guard<std::mutex> lock(swap_mutex_);
1717 auto it = tier_footprint_bytes_.find(tier_name);
1718 if (it != tier_footprint_bytes_.end()) {
return it->second; }
1719 size_t v = estimate_footprint_bytes(tier_name);
1721 tier_footprint_bytes_[tier_name] = v;
1732 std::lock_guard<std::mutex> lock(swap_mutex_);
1733 residency_observer_ = std::move(cb);
1741void ModelOrchestrator::fire_residency_observer(
1742 ResidencyEvent event,
1743 const std::string& tier_name,
1744 const std::string& model_path,
1746 const char* event_name =
"unknown";
1748 case ResidencyEvent::Loaded: event_name =
"loaded";
break;
1749 case ResidencyEvent::Evicted: event_name =
"evicted";
break;
1750 case ResidencyEvent::ActivationSwap: event_name =
"activation_swap";
break;
1752 logger->info(
"[residency] {} tier='{}' path='{}' footprint={} bytes",
1753 event_name, tier_name, model_path, footprint);
1754 if (residency_observer_) {
1755 residency_observer_(event, tier_name, model_path, footprint);
1777 const std::string& name,
const std::filesystem::path& path,
1778 int context_length,
size_t footprint,
int vram_reserve_mb,
1779 long long last_ms) {
1781 auto weights = std::filesystem::file_size(path, ec);
1782 size_t weights_b = ec ? 0u :
static_cast<size_t>(weights);
1783 size_t kv =
static_cast<size_t>(context_length) * 16ull * 1024ull;
1784 size_t headroom =
static_cast<size_t>(vram_reserve_mb)
1785 * 1024ull * 1024ull;
1788 {
"model_path", path.string()},
1789 {
"footprint_bytes", footprint},
1790 {
"weights_bytes", weights_b},
1791 {
"kv_cache_bytes", kv},
1792 {
"headroom_bytes", headroom},
1793 {
"last_activation_ms", last_ms}
1803 std::lock_guard<std::mutex> lock(swap_mutex_);
1805 j[
"vram_total_bytes"] = vram_budget_bytes_;
1806 j[
"vram_budget_bytes"] = vram_budget_bytes_;
1808 nlohmann::json arr = nlohmann::json::array();
1809 for (
const auto& [name, backend] : tiers_) {
1810 if (!backend || !backend->is_loaded()) {
continue; }
1812 if (tier_it == config_.
models.
tiers.end()) {
continue; }
1813 auto fp_it = tier_footprint_bytes_.find(name);
1814 size_t footprint = (fp_it != tier_footprint_bytes_.end())
1815 ? fp_it->second : estimate_footprint_bytes(name);
1816 in_use += footprint;
1817 auto la = tier_last_activation_ms_.find(name);
1818 long long last_ms = (la != tier_last_activation_ms_.end())
1821 name, tier_it->second.path, tier_it->second.context_length,
1824 j[
"residency"] = std::move(arr);
1825 j[
"vram_headroom_bytes"] = vram_budget_bytes_ > in_use
1826 ? vram_budget_bytes_ - in_use
1828 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).
virtual ParseResult parse_tool_calls(const std::string &content) const =0
Parse tool calls from model output.
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.
@ 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_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.
Activate model on GPU (WARM → ACTIVE).
static void stage_active_tools(InferenceBackend *model, const GenerationParams ¶ms)
Stage the turn's tool defs on the backend for common_chat (gh#87).
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.
void apply_action_envelope_recovery(std::vector< ToolCall > &calls, const std::string &raw)
gh#88: substitute recovered bare-JSON calls when a reliable (PEG_GEMMA4 / gemma) parse produced none;...
@ 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.
static llama_model * resolve_target_model(const std::shared_ptr< InferenceBackend > &tier_backend)
Resolve the active main-tier llama_model* for compat lookup.
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).
ENTROPIC_EXPORT void apply_tier_sampler_overrides(GenerationParams ¶ms, const TierSamplerOverrides &ov)
Apply per-tier sampler overrides to params.
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 std::string normalize_grammar_key(const std::string &grammar_value)
Normalize a frontmatter grammar value to a registry key.
ModelOrchestrator — multi-model lifecycle and routing.
Tokenizer/architecture compatibility check for speculative decoding draft pairing.
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 path
Resolved model file path.
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.
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