feat(api): unify fit envelope serializers, exposing parity fields on REST/MCP

`llmfit fit --json` and the REST/MCP envelope were built by two hand-maintained
`fit_to_json` functions that had drifted — same keys carrying different
vocabularies (`fit_level: "Perfect"` vs `"perfect"`) and each missing fields the
other had. #758 was the third field-by-field patch of that gap.

Collapse them onto one canonical serializer in `serve_shared`:

- `serve_shared::fit_to_json` now emits the superset. REST and MCP gain the
  seven fields that were CLI-only: `installed`, `disk_size_gb`, `capability_ids`,
  `ollama_name`, `estimate_basis` (the #292 reproducibility data), `verify_command`,
  and `measured_tps`. Purely additive — no existing API key changes.
- `display::fit_to_json` becomes a thin overlay over the shared function,
  re-applying only the CLI's legacy human-string keys (`fit_level`, `run_mode`,
  `runtime`, `capabilities`). Those values are load-bearing for existing scripts,
  so they stay byte-identical; the CLI additionally gains `fit_label`,
  `run_mode_label`, and `supports_tp`.
- `generate_llamabench_command` moves to `serve_shared` alongside its only
  remaining consumers.

Deliberately out of scope: flipping any existing key's value. The CLI's human
vocabulary and the API's machine codes still diverge by design — the `*_label`
keys are now present on both sides so a later PR can deprecate the CLI overload.

Closes #759.
This commit is contained in:
Adam Ramsis
2026-07-18 21:49:14 +02:00
parent 77a0c04a05
commit c6bdccc2cb
3 changed files with 154 additions and 70 deletions
+39 -1
View File
@@ -155,7 +155,24 @@ Envelope shape:
"memory_available_gb": 12.0,
"utilization_pct": 48.3,
"notes": [],
"gguf_sources": []
"gguf_sources": [],
"capabilities": ["tool_use"],
"capability_ids": ["tool_use"],
"license": "apache-2.0",
"supports_tp": [1, 2, 4],
"installed": false,
"disk_size_gb": 5.1,
"ollama_name": "qwen2.5-coder:7b-instruct",
"estimate_basis": {
"method": "roofline",
"gpu_bandwidth_gbps": 320.0,
"ddr_bandwidth_gbps": null,
"local_calibration": null,
"efficiency": 0.85,
"assumed_context": 8192
},
"verify_command": "llama-bench -m <path-to-Q5_K_M-gguf> -ngl 99 -p 512 -n 128",
"measured_tps": null
}
]
}
@@ -170,6 +187,27 @@ The three context fields answer different questions:
`estimated_tps` figures on this row were computed at. Defaults to
`min(context_length, 8192)`; set by `max_context` when supplied.
The envelope also carries these fields, now at parity with `llmfit fit --json`
(both frontends serialize through one shared function):
- `installed` — whether the model was found in a local runtime provider.
- `disk_size_gb` — estimated on-disk size at `best_quant`.
- `capability_ids` — machine-readable capability ids (snake_case); mirrors
`capabilities` here. Note `llmfit fit --json` overloads its `capabilities`
key with human labels (e.g. `"Tool Use"`) — that overload is CLI-only and
slated for deprecation.
- `ollama_name` — the `ollama pull` tag for this model, when derivable.
- `estimate_basis` — how `memory_required_gb`/`estimated_tps` were derived
(bandwidths, efficiency, assumed context), for reproducibility.
- `verify_command` — a `llama-bench` invocation measuring the same throughput
this row estimates (llama.cpp GPU / CPU-only runs; `null` otherwise).
- `measured_tps` — a recorded benchmark result if one exists, else `null`.
Note on vocabulary: `fit_level`, `run_mode`, and `runtime` here are stable
machine codes (e.g. `"good"`, `"gpu"`, `"llamacpp"`), with the human string
under the paired `*_label` key. `llmfit fit --json` emits the human string
directly under those same keys — a CLI-only legacy overload.
---
### `GET /api/v1/models/top`
+54 -69
View File
@@ -2,11 +2,10 @@ use std::path::PathBuf;
use std::sync::OnceLock;
use colored::*;
use llmfit_core::fit::{FitLevel, InferenceRuntime, ModelFit, RunMode, SortColumn};
use llmfit_core::fit::{FitLevel, ModelFit, RunMode, SortColumn};
use llmfit_core::hardware::SystemSpecs;
use llmfit_core::models::LlmModel;
use llmfit_core::plan::PlanEstimate;
use llmfit_core::providers::ollama_pull_tag as ollama_name_for;
use tabled::{Table, Tabled, settings::Style};
#[derive(Tabled)]
@@ -630,7 +629,7 @@ fn display_estimate_basis(fit: &ModelFit) {
" llmfit bench \"{}\" (against a running provider)",
fit.model.name
);
if let Some(bench_cmd) = generate_llamabench_command(fit) {
if let Some(bench_cmd) = crate::serve_shared::generate_llamabench_command(fit) {
println!(
" {} (compare the tg128 row; get the path via `llmfit download`)",
bench_cmd
@@ -669,30 +668,6 @@ fn generate_llamacpp_command(fit: &ModelFit) -> Option<String> {
})
}
/// llama-bench invocation that measures the same quantity `estimated_tps`
/// models: single-request generation throughput (the `tg128` row). Prompt
/// processing (`pp512`) is deliberately not what llmfit estimates.
///
/// Only emitted for pure-GPU and CPU-only runs — offload splits depend on
/// llama.cpp's layer placement, which llama-bench can't express with a fixed
/// `-ngl`, so a benchmark there wouldn't be comparable to the estimate.
fn generate_llamabench_command(fit: &ModelFit) -> Option<String> {
if fit.runtime != InferenceRuntime::LlamaCpp {
return None;
}
let ngl = match fit.run_mode {
RunMode::Gpu => "99",
RunMode::CpuOnly => "0",
_ => return None,
};
// llama-bench needs a local GGUF path (no -hf support); point users at
// `llmfit download`, which prints the destination path.
Some(format!(
"llama-bench -m <path-to-{}-gguf> -ngl {} -p 512 -n 128",
fit.best_quant, ngl
))
}
fn llamacpp_ngl_args(run_mode: RunMode) -> Option<&'static str> {
match run_mode {
RunMode::CpuOffload | RunMode::MoeOffload => {
@@ -803,49 +778,33 @@ fn system_json(specs: &SystemSpecs) -> serde_json::Value {
crate::serve_shared::system_json(specs)
}
/// CLI `fit --json` envelope: the shared serializer plus this frontend's legacy
/// overlays. The overlaid keys carry human-readable strings the API/MCP side
/// expresses as machine codes (with the human string under a `*_label` key);
/// the CLI's overloaded values are load-bearing for existing scripts, so they
/// stay put here until a future PR deprecates them (see #759).
fn fit_to_json(fit: &ModelFit) -> serde_json::Value {
serde_json::json!({
"name": fit.model.name,
"provider": fit.model.provider,
"parameter_count": fit.model.parameter_count,
"params_b": round2(fit.model.params_b()),
"context_length": fit.model.context_length,
"effective_context_length": fit.effective_context_length,
"usable_context": fit.usable_context,
"use_case": fit.model.use_case,
"category": fit.use_case.label(),
"release_date": fit.model.release_date,
"license": fit.model.license,
"is_moe": fit.model.is_moe,
"fit_level": fit.fit_text(),
"run_mode": fit.run_mode_text(),
"score": round1(fit.score),
"score_components": {
"quality": round1(fit.score_components.quality),
"speed": round1(fit.score_components.speed),
"fit": round1(fit.score_components.fit),
"context": round1(fit.score_components.context),
},
"estimated_tps": round1(fit.estimated_tps),
"runtime": fit.runtime_text(),
"runtime_label": fit.runtime.label(),
"best_quant": fit.best_quant,
"disk_size_gb": round2(fit.model.estimate_disk_gb(&fit.best_quant)),
"memory_required_gb": round2(fit.memory_required_gb),
"memory_available_gb": round2(fit.memory_available_gb),
"moe_offloaded_gb": fit.moe_offloaded_gb.map(round2),
"total_memory_gb": round2(fit.memory_required_gb + fit.moe_offloaded_gb.unwrap_or(0.0)),
"utilization_pct": round1(fit.utilization_pct),
"notes": fit.notes,
"gguf_sources": fit.model.gguf_sources,
"installed": fit.installed,
"capabilities": fit.model.capabilities.iter().map(|c| c.label()).collect::<Vec<_>>(),
"capability_ids": serde_json::to_value(&fit.model.capabilities).unwrap(),
"ollama_name": ollama_name_for(&fit.model.name),
"estimate_basis": serde_json::to_value(&fit.estimate_basis).unwrap(),
"verify_command": generate_llamabench_command(fit),
"measured_tps": serde_json::to_value(&fit.measured_tps).unwrap(),
})
let mut value = crate::serve_shared::fit_to_json(fit);
let obj = value
.as_object_mut()
.expect("fit_to_json returns an object");
obj.insert("fit_level".to_string(), serde_json::json!(fit.fit_text()));
obj.insert(
"run_mode".to_string(),
serde_json::json!(fit.run_mode_text()),
);
obj.insert("runtime".to_string(), serde_json::json!(fit.runtime_text()));
obj.insert(
"capabilities".to_string(),
serde_json::json!(
fit.model
.capabilities
.iter()
.map(|c| c.label())
.collect::<Vec<_>>()
),
);
value
}
fn round1(v: f64) -> f64 {
@@ -1102,6 +1061,32 @@ mod tests {
}
}
#[test]
fn cli_json_keeps_legacy_vocab_while_shared_uses_codes() {
let mut fit = mock_fit(RunMode::Gpu, UseCase::Chat, "chat");
fit.model.capabilities = vec![Capability::ToolUse];
let cli = fit_to_json(&fit);
let shared = crate::serve_shared::fit_to_json(&fit);
// The CLI overlay preserves the human-readable values existing
// `fit --json` scripts depend on — unchanged from before the unification.
assert_eq!(cli["fit_level"], "Good");
assert_eq!(cli["run_mode"], "GPU");
assert_eq!(cli["runtime"], "llama.cpp");
assert_eq!(cli["capabilities"], serde_json::json!(["Tool Use"]));
// The shared (REST/MCP) envelope emits stable machine codes under the
// same keys, with the human string relocated to a `*_label` key.
assert_eq!(shared["fit_level"], "good");
assert_eq!(shared["fit_label"], "Good");
assert_eq!(shared["run_mode"], "gpu");
assert_eq!(shared["run_mode_label"], "GPU");
assert_eq!(shared["runtime"], "llamacpp");
assert_eq!(shared["runtime_label"], "llama.cpp");
assert_eq!(shared["capability_ids"], serde_json::json!(["tool_use"]));
}
#[test]
fn llamacpp_command_uses_effective_context() {
let fit = mock_fit(RunMode::Gpu, UseCase::Chat, "chat");
+61
View File
@@ -69,8 +69,15 @@ pub fn fit_to_json(fit: &ModelFit) -> serde_json::Value {
"notes": fit.notes,
"gguf_sources": fit.model.gguf_sources,
"capabilities": fit.model.capabilities,
"capability_ids": fit.model.capabilities,
"license": fit.model.license,
"supports_tp": fit.model.valid_tp_sizes(),
"installed": fit.installed,
"disk_size_gb": round2(fit.model.estimate_disk_gb(&fit.best_quant)),
"ollama_name": llmfit_core::providers::ollama_pull_tag(&fit.model.name),
"estimate_basis": fit.estimate_basis,
"verify_command": generate_llamabench_command(fit),
"measured_tps": fit.measured_tps,
})
}
@@ -102,6 +109,30 @@ pub fn runtime_code(runtime: InferenceRuntime) -> &'static str {
}
}
/// llama-bench invocation that measures the same quantity `estimated_tps`
/// models: single-request generation throughput (the `tg128` row). Prompt
/// processing (`pp512`) is deliberately not what llmfit estimates.
///
/// Only emitted for pure-GPU and CPU-only runs — offload splits depend on
/// llama.cpp's layer placement, which llama-bench can't express with a fixed
/// `-ngl`, so a benchmark there wouldn't be comparable to the estimate.
pub(crate) fn generate_llamabench_command(fit: &ModelFit) -> Option<String> {
if fit.runtime != InferenceRuntime::LlamaCpp {
return None;
}
let ngl = match fit.run_mode {
RunMode::Gpu => "99",
RunMode::CpuOnly => "0",
_ => return None,
};
// llama-bench needs a local GGUF path (no -hf support); point users at
// `llmfit download`, which prints the destination path.
Some(format!(
"llama-bench -m <path-to-{}-gguf> -ngl {} -p 512 -n 128",
fit.best_quant, ngl
))
}
pub fn round1(v: f64) -> f64 {
(v * 10.0).round() / 10.0
}
@@ -174,4 +205,34 @@ mod tests {
);
assert!(fit.usable_context <= model.context_length);
}
#[test]
fn fit_json_carries_formerly_cli_only_fields() {
let db = llmfit_core::models::ModelDatabase::new();
let model = db
.get_all_models()
.iter()
.next()
.expect("catalog is non-empty");
let fit = ModelFit::analyze(model, &specs_with_gpu("Tesla T4"));
let json = fit_to_json(&fit);
// Fields that used to live only in the CLI serializer now reach REST/MCP
// consumers through the shared envelope (issue #759).
for key in [
"installed",
"disk_size_gb",
"capability_ids",
"ollama_name",
"estimate_basis",
"verify_command",
"measured_tps",
] {
assert!(
json.get(key).is_some(),
"shared envelope is missing `{key}`"
);
}
}
}