33#include <nlohmann/json.hpp>
53 for (
auto it = messages.rbegin();
it != messages.rend(); ++
it) {
54 if (
it->role ==
"user") {
78bool ModelOrchestrator::create_tier_backends(
const ParsedConfig& config) {
82 logger->error(
"Model file not found for tier '{}': {}",
84 logger->error(
"Place a GGUF file at the path above, or set "
85 "ENTROPIC_MODEL_DIR to a directory containing "
86 "it. Run `entropic download --list` to see "
87 "bundled model keys, then "
88 "`entropic download <key>` to fetch one.");
91 if (model_pool_.find(
path_key) == model_pool_.end()) {
92 model_pool_[
path_key] = std::make_shared<LlamaCppBackend>();
94 tiers_[name] = model_pool_[
path_key];
101 logger->info(
"Created {} unique backend(s) for {} tier(s)",
102 model_pool_.size(), tiers_.size());
116void ModelOrchestrator::build_routing_tables(
const ParsedConfig& config) {
117 for (
const auto& [
digit, tier_name] :
config.routing.tier_map) {
118 tier_map_[
digit] = tier_name;
121 handoff_rules_[
src] = std::unordered_set<std::string>(
145bool ModelOrchestrator::activate_default_tier(
const ParsedConfig& config) {
150 || tiers_.find(default_tier_) == tiers_.end();
152 if (
config.models.defer_load) {
153 logger->info(
"[residency] models.defer_load=true — default tier "
154 "'{}' loads on first use", default_tier_);
158 if (get_model(default_tier_) ==
nullptr) {
159 logger->error(
"Failed to activate default tier: {}", default_tier_);
162 logger->info(
"Activated default tier: {}", default_tier_);
177void ModelOrchestrator::activate_router(
const ParsedConfig& config) {
178 if (!
config.models.router) {
return; }
197void ModelOrchestrator::activate_draft(
const ParsedConfig& config) {
198 const auto& spec =
config.inference.speculative;
199 if (!spec.enabled || spec.draft.path.empty()) {
return; }
205 logger->info(
"Speculative MTP: head '{}' is target-owned; skipping "
206 "separate draft activation", spec.draft.path.string());
223void ModelOrchestrator::ensure_secondary_roles() {
224 activate_router(config_);
225 activate_draft(config_);
240void ModelOrchestrator::route_ggml_logs(
const ParsedConfig& config) {
241 if (!
config.ggml_logging) {
return; }
243 if (!
config.llama_log_path.empty()) {
244 path =
config.llama_log_path.string();
245 }
else if (!
config.log_dir.empty()) {
246 path = (
config.log_dir /
"llama_ggml.log").
string();
250 logger->info(
"ggml logging: {}", path);
270 default_tier_ =
config.models.default_tier;
272 if (budget_resolves_at_init(
config)) {
273 ensure_vram_budget();
275 vram_budget_deferred_ =
true;
276 logger->info(
"[residency] defer_load: VRAM budget will be measured "
277 "at first admission, not now");
282 logger->info(
"Initializing model orchestrator");
284 if (!create_tier_backends(
config)) {
return false; }
285 build_routing_tables(
config);
287 if (!activate_default_tier(
config)) {
return false; }
292 if (!
config.models.defer_load) {
302 load_bundled_grammars();
317 logger->info(
"Shutting down model orchestrator");
319 for (
auto& [path,
backend] : model_pool_) {
358bool ModelOrchestrator::resolve_mtp_effective(
const std::string& tier_name)
const {
361 return *
it->second.speculative_mtp;
372GenerationResult ModelOrchestrator::run_generate_dispatch(
373 InferenceBackend* model,
374 const std::vector<Message>& messages,
375 const GenerationParams&
params,
376 const std::string& tier_name) {
377 GenerationResult result;
379 && try_speculative_route(model, messages,
params, tier_name, result);
381 result = model->generate(messages,
params);
410bool ModelOrchestrator::try_mtp_route(
411 InferenceBackend* model,
412 const std::vector<Message>& messages,
413 const GenerationParams&
params,
414 std::function<
void(std::string_view)> on_token,
415 std::atomic<bool>&
cancel,
416 GenerationResult& result)
418 auto*
llama_target =
dynamic_cast<LlamaCppBackend*
>(model);
421 result = GenerationResult{};
423 result.error_message =
"speculative.mtp enabled but the target backend "
424 "is not llama.cpp; disable speculative.mtp";
425 result.finish_reason =
"error";
426 logger->error(
"{}", result.error_message);
455 if (
dm ==
nullptr) {
return false; }
485bool ModelOrchestrator::try_speculative_route_streaming(
486 InferenceBackend* model,
487 const std::vector<Message>& messages,
488 const GenerationParams&
params,
489 const std::string& tier_name,
490 std::function<
void(std::string_view)> on_token,
491 std::atomic<bool>&
cancel,
492 GenerationResult& result)
497 if (resolve_mtp_effective(tier_name)) {
498 return try_mtp_route(model, messages,
params, on_token,
cancel,
504 logger->info(
"Speculative requested but pair incompatible "
505 "({}); using plain decode",
compat.diagnostic);
507 auto*
llama_target =
dynamic_cast<LlamaCppBackend*
>(model);
511 logger->
info(
"Speculative compat passed but target/draft "
512 "is not llama.cpp; using plain decode");
516 logger->error(
"{}", result.error_message);
519 auto spec =
llama_target->generate_speculative_with_draft(
524 logger->info(
"Speculative kernel returned NOT_SUPPORTED "
525 "({}); falling back", spec.error_message);
527 result = std::move(spec);
544bool ModelOrchestrator::try_speculative_route(
545 InferenceBackend* model,
546 const std::vector<Message>& messages,
547 const GenerationParams&
params,
548 const std::string& tier_name,
549 GenerationResult& result)
555 return try_speculative_route_streaming(
556 model, messages,
params, tier_name,
557 std::function<
void(std::string_view)>{},
local_cancel, result);
578 bool require_tool_call) {
581 llama->set_require_tool_call(require_tool_call);
604 if (result.
content.empty()) {
return; }
648 logger->info(
"Turn delivered {} tool call(s) and no prose. {}",
652 logger->warn(
"Turn produced {} raw chars but delivered no content. {}",
680 const std::string& tier_name,
681 const std::unordered_map<std::string, TierConfig>& tiers) {
683 auto it = tiers.find(tier_name);
684 if (
it == tiers.end()
685 || !
it->second.require_tool_call.value_or(
false)) {
return; }
688 "Tier '{}' requires a tool call but hit its token budget before "
689 "emitting one (finish_reason=length, 0 tool calls). The tool-call "
690 "grammar permits unbounded text BEFORE the mandated call, so the call "
691 "must fit inside max_tokens. Raise max_tokens, or disable "
692 "enable_thinking on this tier — the thinking channel is what consumes "
693 "the preamble. This is a budget misconfiguration, not a model failure.",
712 const std::string& tier_name,
713 const std::unordered_map<std::string, TierConfig>& tiers) {
742GenerationParams ModelOrchestrator::resolve_and_stage(
743 InferenceBackend* model,
744 const GenerationParams&
params,
745 const std::string& tier_name) {
746 GenerationParams resolved =
params;
747 apply_tier_sampler_defaults(resolved, tier_name);
753 require_tc =
it->second.require_tool_call.value_or(
false);
772 const std::string& adapter_name,
774 double routing_ms,
double swap_ms) {
775 logger->info(
"Orchestration: tier={}, adapter={}, grammar={}",
777 params.grammar.empty() ?
"unconstrained"
779 logger->info(
"Total: {:.0f}ms (route={:.0f}ms, swap={:.0f}ms, "
781 result.
total_ms, routing_ms, swap_ms,
809 const std::vector<Message>& messages,
811 const std::string& tier_name)
815 std::lock_guard<std::recursive_mutex>
gen_lock(generation_mutex_);
820 double routing_ms = 0.0;
838 if (!model) {
return build_no_model_error(
selected); }
867 const std::vector<Message>& messages,
869 std::atomic<bool>&
cancel,
870 const std::string& tier_name)
874 std::lock_guard<std::recursive_mutex>
gen_lock(generation_mutex_);
878 double routing_ms = 0.0;
895 if (!model) {
return build_no_model_error(
selected); }
923const std::string&
batch_arm_tier(
const std::vector<std::string>& tiers,
925 const std::string&
lead) {
926 return (
i < tiers.size() && !tiers[
i].empty()) ? tiers[
i] :
lead;
951 const std::vector<std::string>& tiers,
952 std::atomic<bool>&
cancel)
956 std::lock_guard<std::recursive_mutex>
gen_lock(generation_mutex_);
958 const std::string
lead =
959 (tiers.empty() || tiers[0].empty()) ?
"default" : tiers[0];
964 std::vector<GenerationParams> resolved;
965 auto refusal = refuse_unresolved_batch_grammars(
968 return std::vector<GenerationResult>(
n, *
refusal);
972 if (model ==
nullptr) {
973 return std::vector<GenerationResult>(
n, build_no_model_error(
lead));
975 stage_batch_arms(model, tiers,
lead, resolved);
978 for (std::size_t
i = 0;
i <
results.size(); ++
i) {
984 record_generation(
results[
i], resolved[
i], model);
1003void ModelOrchestrator::stage_batch_arms(
1005 const std::vector<std::string>& tiers,
1006 const std::string&
lead,
1007 std::vector<GenerationParams>& resolved)
1009 for (std::size_t
i = 0;
i < resolved.size(); ++
i) {
1010 resolved[
i] = resolve_and_stage(
1025 (*
static_cast<std::function<
void(std::string_view)
>*>(
ud))(
1026 std::string_view(data,
len));
1068 const std::vector<Message>& messages,
1070 std::function<
void(std::string_view)> on_token,
1071 std::atomic<bool>&
cancel,
1072 const std::string& tier_name)
1076 std::lock_guard<std::recursive_mutex>
gen_lock(generation_mutex_);
1077 std::string
selected = tier_name.empty() ?
route(messages) : tier_name;
1090 err.error_message =
"No model for tier: " +
selected;
1091 err.finish_reason =
"error";
1119 && try_speculative_route_streaming(
1160 logger->info(
"Route: routing disabled, using default '{}'",
1162 last_routing_result_ = {default_tier_,
"",
"",
"none", 0.0};
1163 return default_tier_;
1166 auto [
tier,
raw] = classify_task(messages);
1167 last_routing_result_ = {
tier, loaded_main_tier_,
raw,
"none", 0.0};
1170 tier_history_.push_back(
tier);
1171 if (tier_history_.size() > 5) {
1172 tier_history_.erase(tier_history_.begin());
1175 logger->info(
"[ROUTER] {} | raw='{}'",
tier,
raw);
1202std::pair<std::string, std::string> ModelOrchestrator::classify_task(
1203 const std::vector<Message>& messages)
1209 std::lock_guard<std::recursive_mutex>
gen_lock(generation_mutex_);
1219 activate_router(config_);
1222 logger->warn(
"classify_task: router not loaded; returning empty");
1244 logger->info(
"classify_task: using configured classification_prompt "
1245 "(router instructed; max_tokens widened to 4)");
1248 std::string
raw = result.content;
1251 auto start =
raw.find_first_not_of(
" \t\n\r");
1252 if (start != std::string::npos) {
1257 for (
char c :
raw) {
1259 auto it = tier_map_.find(
digit);
1260 if (
it != tier_map_.end()) {
1261 logger->info(
"Route: digit='{}' -> tier='{}'",
1267 logger->warn(
"Route: no valid digit in '{}', defaulting to {}",
1268 raw, default_tier_);
1269 return {default_tier_,
""};
1292void ModelOrchestrator::record_activation_reuse(
1293 const std::string& tier_name) {
1294 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
1295 std::chrono::steady_clock::now() - start_time_).count();
1296 bool tier_changed = (loaded_main_tier_ != tier_name);
1297 tier_last_activation_ms_[tier_name] =
now_ms;
1298 if (!tier_changed) {
return; }
1301 ?
tier_it->second.path.string() :
"";
1302 size_t footprint = tier_footprint_bytes_.count(tier_name)
1303 ? tier_footprint_bytes_[tier_name]
1304 : estimate_footprint_bytes(tier_name);
1305 tier_footprint_bytes_[tier_name] =
footprint;
1306 loaded_main_tier_ = tier_name;
1307 fire_residency_observer(ResidencyEvent::ActivationSwap,
1327void ModelOrchestrator::log_fit_recommendation(
1328 const std::string& tier_name)
const {
1339 logger->error(
"[residency] tier '{}' fits at context_length={} "
1340 "(requested {}) — lower it, or reduce gpu_layers to "
1341 "keep the requested context",
1344 logger->error(
"[residency] tier '{}' does not fit at ANY context "
1345 "length: weights{} alone exceed the {} MiB budget. "
1346 "Reduce gpu_layers, or use a smaller quantization.",
1348 in.mmproj_bytes > 0 ?
" + vision projector" :
"",
1349 vram_budget_bytes_ / (1024 * 1024));
1372void ModelOrchestrator::resolve_auto_gpu_layers(
const std::string& tier_name) {
1374 if (
it == config_.
models.
tiers.end() || !
it->second.gpu_layers_auto) {
1378 auto file_bytes = std::filesystem::file_size(
it->second.path,
ec);
1379 if (
ec || vram_budget_bytes_ == 0) {
1380 logger->warn(
"[residency] tier '{}': gpu_layers=auto cannot be "
1381 "resolved ({}) — keeping gpu_layers={}", tier_name,
1382 ec ?
"model file unreadable" :
"free VRAM unknown",
1394 base.draft_bytes = resident_draft_bytes();
1398 it->second.n_ubatch);
1406 it->second.cpu_moe_layers =
placement.cpu_moe_layers;
1424uint64_t ModelOrchestrator::resident_draft_bytes()
const {
1427 const auto bytes = std::filesystem::file_size(
1445void ModelOrchestrator::fall_back_to_weights_estimate(
1450 logger->warn(
"[residency] tier '{}': gpu_layers=auto could not read the "
1451 "model's shape ({}) — falling back to the weights-only "
1452 "estimate, {} layers", tier_name,
why,
layers);
1470void ModelOrchestrator::log_auto_placement(
1471 const std::string& tier_name,
const GgufShape&
shape,
1474 ? fmt::format(
", {} layers' experts host-side",
1481 ? fmt::format(
", n_ubatch lowered to {}",
placement.n_ubatch)
1483 logger->info(
"[residency] tier '{}': gpu_layers=auto -> {} of {} layers{}{} "
1484 "— {} ({} MiB estimated of {} MiB free; {} MiB model, "
1491 vram_budget_bytes_ / (1024 * 1024),
1496 logger->info(
"[residency] tier '{}': {} MiB of VRAM left free after this "
1497 "placement{}", tier_name,
1499 placement.margin_bytes < 256ull * 1024 * 1024
1500 ?
" — thin; growth in prompt or tool schemas may push"
1501 " this tier off the card"
1527bool ModelOrchestrator::config_admits(
const std::string& tier_name) {
1530 const auto&
cfg =
it->second;
1533 if (
ec) {
return true; }
1544 "use_mlock would pin " + std::to_string(
pinned)
1545 +
" bytes against an RLIMIT_MEMLOCK of "
1547 +
" bytes. Pinned pages cannot be reclaimed under pressure, so "
1548 "a partially offloaded model of this size is killed rather "
1549 "than paged. Set use_mlock: false for this tier.");
1551 vram_budget_bytes_)) {
1553 "gpu_layers=" + std::to_string(
cfg.gpu_layers) +
" cannot fit: "
1554 + std::to_string(vram_budget_bytes_ / (1024 * 1024))
1555 +
" MiB of free VRAM is not enough for the compute buffers "
1556 "alone, so no positive layer count can work. Free VRAM, or "
1557 "set gpu_layers: 0 to run on the CPU.");
1573void ModelOrchestrator::refuse_residency(
const std::string& tier_name,
1575 const std::string&
why) {
1576 logger->error(
"[residency] tier '{}' refused: {}", tier_name,
why);
1577 last_residency_error_ =
code;
1578 last_residency_message_ =
"Tier '" + tier_name +
"': " +
why;
1594uint64_t ModelOrchestrator::host_expert_bytes_for(
const ModelConfig&
cfg) {
1595 if (
cfg.cpu_moe_layers <= 0) {
return 0; }
1597 if (!
shape.known) {
return 0; }
1598 return shape.expert_bytes_per_block()
1612bool ModelOrchestrator::residency_admits(
const std::string& tier_name) {
1614 ensure_vram_budget();
1618 resolve_auto_gpu_layers(tier_name);
1619 if (!config_admits(tier_name)) {
return false; }
1621 size_t footprint = estimate_footprint_bytes(tier_name);
1623 tier_footprint_bytes_[tier_name] =
footprint;
1625 if (vram_budget_bytes_ > 0 &&
footprint > vram_budget_bytes_) {
1626 logger->error(
"[residency] tier '{}' footprint {} bytes "
1627 "exceeds VRAM budget {} bytes — "
1628 "TIER_MODEL_TOO_LARGE (gh#57)",
1629 tier_name,
footprint, vram_budget_bytes_);
1634 log_fit_recommendation(tier_name);
1660GenerationResult ModelOrchestrator::build_no_model_error(
1661 const std::string& tier_name) {
1662 GenerationResult
err;
1663 err.finish_reason =
"error";
1665 err.error_code = last_residency_error_;
1668 err.error_message = !last_residency_message_.empty()
1669 ? last_residency_message_
1670 :
"Tier '" + tier_name +
"' model exceeds the "
1671 "engine's VRAM budget (gh#57)";
1673 last_residency_message_.clear();
1676 err.error_message =
"No model available for tier: " + tier_name;
1694InferenceBackend* ModelOrchestrator::activate_and_track(
1695 const std::string& tier_name,
1696 const std::shared_ptr<InferenceBackend>&
backend) {
1701 logger->error(
"Failed to activate tier: {}", tier_name);
1704 loaded_main_tier_ = tier_name;
1706 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
1707 std::chrono::steady_clock::now() - start_time_).count();
1708 tier_last_activation_ms_[tier_name] =
now_ms;
1709 size_t footprint = tier_footprint_bytes_.count(tier_name)
1710 ? tier_footprint_bytes_[tier_name] : 0;
1713 preload_adapters_for_model(
backend.get());
1714 fire_residency_observer(ResidencyEvent::Loaded,
1715 tier_name,
tier_it->second.path.string(),
1733InferenceBackend* ModelOrchestrator::get_model(
const std::string& tier_name) {
1734 std::lock_guard<std::mutex> lock(swap_mutex_);
1736 auto it = tiers_.find(tier_name);
1738 if (
it == tiers_.end()) {
1740 if (
it != tiers_.end()) {
1745 InferenceBackend* result =
nullptr;
1746 if (
it != tiers_.end() &&
it->second->is_active()) {
1749 result =
it->second.get();
1751 deactivate_current_if_needed(
it->second.get());
1757 ensure_tier_lora(tier_name, result);
1761 ensure_secondary_roles();
1774void ModelOrchestrator::ensure_tier_lora(
const std::string& tier_name,
1775 InferenceBackend* result) {
1776 auto*
llama_backend =
dynamic_cast<LlamaCppBackend*
>(result);
1779 double adapter_ms = ensure_adapter_for_tier(tier_name,
ctx);
1795void ModelOrchestrator::deactivate_current_if_needed(InferenceBackend*
incoming) {
1796 auto it = loaded_main_tier_.empty()
1797 ? tiers_.end() : tiers_.find(loaded_main_tier_);
1801 &&
it->second->is_loaded();
1808 auto*
llama_backend =
dynamic_cast<LlamaCppBackend*
>(
it->second.get());
1815 unload_or_warm_current(
it->second.get());
1824void ModelOrchestrator::unload_or_warm_current(InferenceBackend*
current) {
1827 &&
cfg_it->second.keep_warm;
1830 logger->info(
"Deactivating {} (keep_warm=true)", loaded_main_tier_);
1834 logger->info(
"Unloading {} (keep_warm=false)", loaded_main_tier_);
1836 ?
cfg_it->second.path.string() :
"";
1837 size_t footprint = tier_footprint_bytes_.count(loaded_main_tier_)
1838 ? tier_footprint_bytes_[loaded_main_tier_] : 0;
1841 fire_residency_observer(ResidencyEvent::Evicted,
1856 return last_routing_result_;
1865 return loaded_main_tier_;
1881 std::vector<std::string> result;
1882 for (
const auto& [name,
backend] : tiers_) {
1884 result.push_back(name);
1887 if (secondary_loader_.
is_loaded(
"router")) {
1888 result.push_back(
"router");
1899 std::vector<std::string> result;
1900 for (
const auto& [name,
_] : tiers_) {
1901 result.push_back(name);
1904 result.push_back(
"router");
1917 const std::string& tier_name)
const {
1918 auto it = tiers_.find(tier_name);
1919 if (
it == tiers_.end()) {
return nullptr; }
1920 return it->second.get();
1932 const std::string& tier_name) {
1933 return get_model(tier_name);
1945 const std::string& tier_name) {
1946 std::lock_guard<std::mutex> lock(swap_mutex_);
1948 if (!tier_name.empty()) {
1949 auto it = tiers_.find(tier_name);
1950 if (
it == tiers_.end()) {
1951 logger->error(
"[residency] release: unknown tier '{}'",
1955 release_backend(
it->second.get());
1959 std::unordered_set<const InferenceBackend*>
seen;
1960 for (
const auto& [name,
backend] : tiers_) {
1963 release_backend(
backend.get());
2011void ModelOrchestrator::announce_eviction(
const InferenceBackend*
backend) {
2012 for (
const auto& [name,
bound] : tiers_) {
2016 ?
cfg_it->second.path.string() :
"";
2017 auto fp_it = tier_footprint_bytes_.find(name);
2019 ?
fp_it->second : 0;
2022 if (loaded_main_tier_ == name) { loaded_main_tier_.clear(); }
2023 fire_residency_observer(ResidencyEvent::Evicted, name, path,
2037 const std::string& tier_name)
const {
2040 return it->second.has_capability(
"vision")
2041 || !
it->second.mmproj_path.empty();
2054 const std::string&
from,
const std::string&
to)
const
2056 auto it = handoff_rules_.find(
from);
2057 if (
it == handoff_rules_.end()) {
2060 return it->second.count(
to) > 0;
2069 auto it = adapters_.find(tier_name);
2070 if (
it != adapters_.end()) {
2071 return it->second.get();
2114double ModelOrchestrator::ensure_adapter_for_tier(
2131 logger->warn(
"Adapter swap to '{}' failed", tier_name);
2137 logger->info(
"Adapter swap for tier '{}' in {:.1f}ms",
2163void ModelOrchestrator::preload_adapters_for_model(
2169 for (
const auto& [name,
tier_cfg] : config_.models.tiers) {
2170 auto tier_it = tiers_.find(name);
2183 logger->info(
"Preloaded {} LoRA adapter(s) to WARM",
loaded);
2198void ModelOrchestrator::load_bundled_grammars() {
2206 logger->info(
"No bundled grammar directory found, skipping");
2211 logger->info(
"Grammar registry: {} grammar(s) loaded from {}",
2229 if (!std::filesystem::is_directory(
grammar_dir)) {
2233 logger->info(
"Grammar registry: {} grammar(s) loaded from {}",
2249 for (
auto& [
_,
backend] : model_pool_) {
2253 logger->info(
"Prompt caches invalidated across all backends "
2254 "(identity change)");
2267 if (
tier.has_capability(
"vision")) {
return true; }
2281 if (
tier.has_capability(
"vision")) {
return name; }
2295 const std::shared_ptr<InferenceBackend>&
tier_backend) {
2313std::string ModelOrchestrator::resolve_speculative_pair(
2319 auto tier_it = tiers_.find(loaded_main_tier_);
2320 if (
tier_it == tiers_.end()) {
2321 err =
"no main tier loaded";
2325 err =
"main tier backend is not a llama.cpp backend or "
2330 err =
"no draft model configured for speculative "
2332 "(set inference.speculative.draft_model)";
2335 draft_out = (
d ==
nullptr) ?
nullptr :
d->llama_model_ptr();
2337 err =
"draft backend is not a llama.cpp backend";
2356ModelOrchestrator::SpeculativeCompatInfo
2362 if (info.diagnostic.empty()) {
2363 auto result = entropic::speculative::check_compat(
2365 info.compatible = result.compatible;
2366 info.diagnostic = std::move(result.diagnostic);
2383void ModelOrchestrator::finish_generation(
2390 std::chrono::steady_clock::time_point
t_start)
2414void ModelOrchestrator::record_generation(
2415 GenerationResult& result,
2417 const InferenceBackend* model)
2420 model !=
nullptr ? model->active_tool_grammar() : std::string{};
2423 std::lock_guard<std::mutex> lock(records_mutex_);
2425 generation_records_.erase(generation_records_.begin());
2438 std::lock_guard<std::mutex> lock(records_mutex_);
2439 return generation_records_;
2455 if (
p.extension() ==
".gbnf") {
2456 return p.stem().string();
2490void ModelOrchestrator::resolve_grammar_key(
2491 GenerationParams&
params,
const std::string& tier_name)
2493 if (!
params.grammar.empty()) {
2498 std::string key =
params.grammar_key;
2509 params.grammar_from_tier =
true;
2521 params.resolved_grammar_key = key;
2523 std::string content = grammar_registry_.
get(key);
2524 if (content.empty()) {
2525 logger->warn(
"Grammar key '{}' not found in registry — this decode "
2526 "is UNCONSTRAINED; read generations[].grammar.resolved "
2527 "from entropic_metrics_json to detect it", key);
2531 logger->info(
"Grammar resolved: key='{}', {} bytes",
2532 key, content.size());
2533 params.grammar = std::move(content);
2558std::optional<GenerationResult>
2559ModelOrchestrator::refuse_unresolved_tier_grammar(
2560 GenerationParams&
params,
const std::string& tier_name)
2562 resolve_grammar_key(
params, tier_name);
2564 return std::nullopt;
2567 GenerationResult
err;
2568 err.finish_reason =
"error";
2571 "Tier '" + tier_name +
"' declares grammar '"
2572 +
params.resolved_grammar_key +
"' and no grammar is registered "
2573 "under that key. Register it with entropic_grammar_register_file() "
2574 "or entropic_grammar_register() before running this tier, or place "
2575 +
params.resolved_grammar_key +
".gbnf in a grammar search path. "
2576 "Refused rather than decoded unconstrained (gh#154): an "
2577 "unconstrained decode is indistinguishable from a constrained one "
2579 logger->error(
"{}",
err.error_message);
2597std::optional<GenerationResult>
2598ModelOrchestrator::refuse_unresolved_batch_grammars(
2600 const std::vector<std::string>& tiers,
2601 const std::string&
lead,
2602 std::vector<GenerationParams>& out)
2608 auto refusal = refuse_unresolved_tier_grammar(
2613 out.push_back(std::move(resolved));
2615 return std::nullopt;
2625template <
typename T>
2673void ModelOrchestrator::apply_tier_sampler_defaults(
2674 GenerationParams&
params,
const std::string& tier_name)
2678 const auto&
tier =
it->second;
2679 TierSamplerOverrides
ov;
2680 ov.temperature =
tier.temperature;
2681 ov.max_output_tokens =
tier.max_output_tokens;
2685 ov.presence_penalty =
tier.presence_penalty;
2686 ov.frequency_penalty =
tier.frequency_penalty;
2687 ov.repeat_penalty =
tier.repeat_penalty;
2688 ov.enable_thinking =
tier.enable_thinking;
2689 ov.tool_call_mode =
tier.tool_call_mode;
2694 logger->info(
"Tier '{}' temperature applied: {}",
2695 tier_name,
params.temperature);
2698 logger->info(
"Tier '{}' max_output_tokens applied: {}",
2699 tier_name,
params.max_tokens);
2719size_t ModelOrchestrator::resolve_vram_budget_bytes() {
2720 const char* env = std::getenv(
"ENTROPIC_VRAM_BUDGET_BYTES");
2721 if (env ==
nullptr) {
2733 long long v = std::stoll(env);
2748void ModelOrchestrator::ensure_vram_budget() {
2749 if (vram_budget_resolved_) {
return; }
2750 const bool from_env = std::getenv(
"ENTROPIC_VRAM_BUDGET_BYTES") !=
nullptr;
2751 vram_budget_bytes_ = resolve_vram_budget_bytes();
2752 vram_budget_resolved_ =
true;
2755 logger->info(
"[residency] VRAM budget: {} bytes ({})", vram_budget_bytes_,
2756 from_env ?
"ENTROPIC_VRAM_BUDGET_BYTES" :
"device free VRAM");
2766bool ModelOrchestrator::budget_resolves_at_init(
const ParsedConfig& config) {
2767 return !
config.models.defer_load
2768 || std::getenv(
"ENTROPIC_VRAM_BUDGET_BYTES") !=
nullptr;
2794 in.vram_reserve_mb = vram_reserve_mb;
2799 if (!
tier_cfg.mmproj_path.empty()) {
2813 in.block_count =
shape.block_count;
2814 in.block_bytes =
shape.block_bytes;
2815 in.non_block_bytes =
shape.non_block_bytes;
2817 in.host_expert_bytes =
2818 shape.expert_bytes_per_block()
2838size_t ModelOrchestrator::estimate_footprint_bytes(
2839 const std::string& tier_name)
const {
2845 if (
ec) {
return 0; }
2860 const std::string& tier_name)
const {
2861 std::lock_guard<std::mutex> lock(swap_mutex_);
2862 auto it = tier_footprint_bytes_.find(tier_name);
2863 if (
it != tier_footprint_bytes_.end()) {
return it->second; }
2864 size_t v = estimate_footprint_bytes(tier_name);
2866 tier_footprint_bytes_[tier_name] =
v;
2877 std::lock_guard<std::mutex> lock(swap_mutex_);
2878 residency_observer_ = std::move(cb);
2886void ModelOrchestrator::fire_residency_observer(
2887 ResidencyEvent
event,
2888 const std::string& tier_name,
2893 case ResidencyEvent::Loaded:
event_name =
"loaded";
break;
2894 case ResidencyEvent::Evicted:
event_name =
"evicted";
break;
2895 case ResidencyEvent::ActivationSwap:
event_name =
"activation_swap";
break;
2897 logger->info(
"[residency] {} tier='{}' path='{}' footprint={} bytes",
2899 if (residency_observer_) {
2922 const std::string& name,
const std::filesystem::path& path,
2923 int context_length,
size_t footprint,
int vram_reserve_mb,
2926 auto weights = std::filesystem::file_size(path,
ec);
2928 size_t kv =
static_cast<size_t>(context_length) * 16ull * 1024ull;
2929 size_t headroom =
static_cast<size_t>(vram_reserve_mb)
2930 * 1024ull * 1024ull;
2933 {
"model_path", path.string()},
2936 {
"kv_cache_bytes",
kv},
2938 {
"last_activation_ms",
last_ms}
2955 std::lock_guard<std::recursive_mutex>
gen_lock(generation_mutex_);
2956 std::unordered_set<const InferenceBackend*>
seen;
2957 for (
const auto& [name,
backend] : tiers_) {
2961 backend->forget_session_kv(session_key);
2972 std::lock_guard<std::mutex> lock(swap_mutex_);
2974 j[
"vram_total_bytes"] = vram_budget_bytes_;
2975 j[
"vram_budget_bytes"] = vram_budget_bytes_;
2977 nlohmann::json arr = nlohmann::json::array();
2978 for (
const auto& [name,
backend] : tiers_) {
2982 auto fp_it = tier_footprint_bytes_.find(name);
2984 ?
fp_it->second : estimate_footprint_bytes(name);
2986 auto la = tier_last_activation_ms_.find(name);
2987 long long last_ms = (
la != tier_last_activation_ms_.end())
2993 j[
"residency"] = std::move(arr);
2994 j[
"vram_headroom_bytes"] = vram_budget_bytes_ >
in_use
2995 ? vram_budget_bytes_ -
in_use
2999 const bool deferred = vram_budget_deferred_ && !vram_budget_resolved_;
3001 : (vram_budget_bytes_ > 0 ?
"configured" :
"unknown");
ChatAdapter concrete base class.
Adapter factory — create adapters by name.
Derive a model's placement from its shape and the free VRAM.
long long elapsed_ms
Wall time, spawn to reap.
bool swap(const std::string &name, llama_context *ctx)
Swap to a different adapter atomically.
std::string active_adapter() const
Get the currently HOT adapter name.
void unload_all_for_model(llama_model *model, llama_context *ctx)
Unload all adapters for a given base model.
void release_handles_for_model(llama_model *model, llama_context *ctx)
Free a base model's adapter handles, KEEPING the registrations (gh#164).
void deactivate(llama_context *ctx)
Deactivate current HOT adapter (HOT -> WARM).
bool load(const std::string &name, const std::filesystem::path &adapter_path, llama_model *model, float scale=1.0f)
Load a LoRA adapter into RAM (COLD -> WARM).
void unload_all()
Free every loaded adapter handle (gh#58 close-out, v2.3.0).
AdapterState state(const std::string &name) const
Get adapter state.
Concrete base class for chat format adapters (80% logic).
size_t load_bundled(const std::filesystem::path &grammar_dir)
Load all bundled grammars from a directory.
std::string get(const std::string &key) const
Get GBNF content string for a grammar key.
Concrete base class for inference backends (80% logic).
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.
entropic_error_t release_models(const std::string &tier_name)
Release resident model(s), keeping every registration (gh#164).
std::vector< std::string > loaded_models() const
Currently loaded model tier names.
bool initialize(const ParsedConfig &config)
Initialize from parsed config.
bool has_vision_capable_tier() const
Return true if any configured tier declares the "vision" capability (gh#41, v2.1.8).
void forget_session_kv(const std::string &session_key)
Drop one session's resident KV on every loaded tier (gh#165).
size_t tier_footprint_bytes(const std::string &tier_name) const
Estimated VRAM footprint for a given tier in bytes.
void shutdown()
Shutdown — unload all models.
RoutingResult last_routing_result() const
Last routing result.
std::vector< GenerationRecord > generation_records() const
Per-generation metric records, oldest first (gh#154).
std::function< void(ResidencyEvent event, const std::string &tier_name, const std::string &model_path, size_t footprint)> ResidencyObserverFn
Residency observer callback type (internal C++ form).
GenerationResult generate(const std::vector< Message > &messages, const GenerationParams ¶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.
static constexpr size_t kMaxGenerationRecords
Ring capacity for generation_records().
std::string route(const std::vector< Message > &messages)
Route to tier using router model.
ChatAdapter * get_adapter(const std::string &tier_name) const
Get adapter for a tier.
void set_residency_observer(ResidencyObserverFn cb)
Register a residency observer.
std::string last_used_tier() const
Last used tier name.
bool tier_declares_vision(const std::string &tier_name) const
Whether a tier is configured for vision, per CONFIG (gh#157).
~ModelOrchestrator()
Destructor — invokes shutdown() and AdapterManager::unload_all().
std::vector< GenerationResult > generate_batch(const std::vector< std::vector< Message > > &messages_list, const std::vector< GenerationParams > ¶ms_list, const std::vector< std::string > &tiers, std::atomic< bool > &cancel)
Same-prefix batch generation on a shared resident model (gh#98).
InferenceBackend * ensure_model(const std::string &tier_name)
Get a tier's backend, loading it if it is not resident (gh#157).
std::string select_vision_tier() const
Pick the canonical vision-capable tier name (gh#41).
bool can_handoff(const std::string &from, const std::string &to) const
Check if handoff is permitted.
std::string residency_snapshot_json() const
Serialize the current residency set as a JSON string.
InferenceBackend * get_backend(const std::string &tier_name) const
Get the inference backend for a tier (for evaluation APIs).
void clear_all_prompt_caches()
Fanout: clear prompt/KV cache on every loaded backend.
bool is_loaded(const std::string &role) const
Check whether a role is currently loaded and active.
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.
gh#142: ask the GPU how much VRAM is actually free.
gh#137: explain an empty turn without misattributing its cause.
int * cancel
Consumer's cancel flag, nullable.
StreamThinkFilter * filter
Consumer-facing filter.
entropic_error_t
Error codes returned by all C API functions.
@ 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_MLOCK_LIMIT_EXCEEDED
use_mlock would pin more than RLIMIT_MEMLOCK allows for a model too large for the card; the pinned pa...
@ ENTROPIC_ERROR_GRAMMAR_NOT_FOUND
Grammar key not in registry (v1.9.3)
@ 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)
@ ENTROPIC_ERROR_MODEL_NOT_FOUND
Model file does not exist at resolved path.
std::string err
size_exceeded, when set
long budget
Max content bytes; <= 0 = unbounded.
long bytes
Content bytes taken.
Which grammar source constrains a decode (gh#134).
Pure C interface contract for inference backends.
void entropic_inference_log_to_file(const char *path)
Redirect llama/ggml logs to a file.
LlamaCppBackend — llama.cpp C API integration.
spdlog initialization and logger access.
auto now()
Get current time for timing measurements.
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...
bool tier_grammar_unresolved(const GenerationParams ¶ms)
A TIER named a grammar and nothing resolved it (gh#154).
static nlohmann::json make_residency_entry(const std::string &name, const std::filesystem::path &path, int context_length, size_t footprint, int vram_reserve_mb, long long last_ms)
JSON serialization of the current residency set.
bool gpu_offload_refused(uint64_t file_bytes, int gpu_layers, uint64_t free_vram_bytes)
Whether a requested GPU offload provably cannot fit free VRAM.
FootprintEstimate estimate_vram_footprint(const FootprintInputs &in)
Estimate the VRAM a tier will occupy, or report that it cannot.
uint64_t host_memlock_limit_bytes()
This process's RLIMIT_MEMLOCK soft limit (gh#148).
@ optional
optional: every escape asks the host's path approver
int partial_gpu_layers_for(uint64_t file_bytes, uint64_t free_vram_bytes, int n_layers=30)
Layers to place on the GPU for an oversized model.
EmptyContentCause diagnose_empty_content(bool content_empty, bool produced_tokens, const std::string &finish_reason, bool has_tool_calls)
Classify an empty-content turn from what the decode reported.
static void log_orchestration(const GenerationResult &result, const std::string &selected, const std::string &adapter_name, const GenerationParams ¶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.
@ tier
The TIER's identity frontmatter grammar: stem (gh#154).
@ count
Sentinel — MUST remain last.
static void warn_if_content_vanished(const GenerationResult &result)
Explain a turn that produced tokens but delivered no content (gh#137).
std::unique_ptr< ChatAdapter > create_adapter(const std::string &name, const std::string &tier_name, const std::string &identity_prompt)
Create adapter by name (gh#87 Phase D hybrid).
std::string mtp_head_classical_path_error(int n_layer)
Actionable error message for gh#107: MTP head on classical path.
ENTROPIC_EXPORT void apply_tier_sampler_overrides(GenerationParams ¶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.
bool mlock_refused(bool use_mlock, uint64_t file_bytes, int gpu_layers, uint64_t memlock_limit_bytes, uint64_t host_expert_bytes=0)
Whether an explicit use_mlock would be fatal for this model.
static std::string normalize_grammar_key(const std::string &grammar_value)
Normalize a frontmatter grammar value to a registry key.
GenerationRecord make_generation_record(const GenerationResult &r)
Project a result down to the record kept for metrics.
GrammarProvenance describe_grammar(const GenerationParams ¶ms, const std::string &tool_grammar)
Describe what constrained one decode, for the result record (gh#154).
@ not_empty
Content survived; nothing to explain.
@ tool_call_only
The whole turn was tool calls — normal (gh#159).
AutoPlacement derive_auto_placement(const GgufShape &shape, const FootprintInputs &base, uint64_t free_vram_bytes, int n_ubatch=0)
Choose the placement that uses the card best without overcommitting.
static void stream_token_trampoline(const char *data, std::size_t len, void *ud)
Trampoline: bridges TokenCallback C signature to std::function.
GgufShape read_gguf_shape(const std::string &path)
Read a GGUF's shape from its metadata, without loading tensor data.
@ COLD
Not loaded. No resources consumed.
ParsedModelResponse parse_model_response(LlamaCppBackend *llama, ChatAdapter *adapter, const std::string &raw)
Parse a raw emission: template first, adapter second.
int recommend_context_length(const FootprintInputs &in, uint64_t available_bytes)
Largest context length that fits, for the "won't fit" recommendation.
static void stage_active_tools(InferenceBackend *model, const GenerationParams ¶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.
How many layers of an oversized GGUF fit the free VRAM, purely.
One template-first / adapter-second parse rule for raw model output.
std::string config
–config FILE
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.
int max_tokens
Maximum tokens to generate.
Result of a single generation call.
entropic_error_t error_code
Error code (ENTROPIC_OK if no error)
double swap_ms
Model swap time.
double routing_ms
Router classification time.
double generation_time_ms
Wall-clock generation time.
std::string raw_content
Raw model output before adapter processing.
std::string finish_reason
Finish reason: "stop", "length", "error".
std::string content
Generated text (cleaned by adapter)
std::vector< ToolCall > tool_calls
Tool calls parsed from content.
std::string error_message
Error description (empty if no error)
double total_ms
Total end-to-end time.
A GGUF's shape, as far as its metadata states it.
SpeculativeConfig speculative
Speculative decoding (gh#36)
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.
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 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.