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:
@@ -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
@@ -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");
|
||||
|
||||
@@ -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}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user