1use std::collections::{BTreeMap, BTreeSet};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use serde_json::{json, Map, Value};
16
17use crate::capture::{drop_nulls, locate_model, render_config, VendorFile, VENDOR_DIR};
18use crate::error::{Error, Result};
19use crate::launch::mcp_entry;
20use crate::lockfile::{ConfigFile, Evidence, Harness, Lockfile, Runtime, LLM};
21use crate::manifest::{HarnessCandidate, LLMCandidate, Manifest};
22use crate::profiles::{probe_harness, probe_runtime, run_capture, CatalogModel, Ctx, HarnessProfile, Registry, RuntimeProfile};
23use crate::sync::set_pointer;
24
25pub const DEFAULT_HARNESSES: &[&str] = &["opencode", "pi", "codex", "hermes", "goose"];
27pub const DEFAULT_RUNTIMES: &[&str] = &["ollama", "lmstudio", "llama-cpp", "mlx-lm", "vllm", "sglang", "localai"];
29
30#[derive(Debug, Clone, Default)]
31pub struct LocalizeOptions {
32 pub harness: Option<String>,
33 pub runtime: Option<String>,
34 pub models: Vec<String>,
36 pub memory_gb: Option<f64>,
38 pub manifest: Option<Manifest>,
40 pub strict: bool,
42 pub allow_network: bool,
44 pub workdir: PathBuf,
46}
47
48#[derive(Debug, Clone)]
49pub struct LocalizeOutcome {
50 pub lock: Lockfile,
51 pub vendor: Vec<VendorFile>,
52 pub report: Value,
53}
54
55pub fn machine_memory(ctx: &Ctx) -> Option<(f64, f64, String)> {
57 let t = Duration::from_secs(5);
58 let gb = |b: f64| (b / 1073741824.0 * 10.0).round() / 10.0; if let Some(smi) = ctx.which("nvidia-smi") {
60 let args = ["--query-gpu=memory.total", "--format=csv,noheader,nounits"].map(String::from);
61 if let Some(out) = run_capture(&smi, &args, ctx, t) {
62 let mib: f64 = out.lines().filter_map(|l| l.trim().parse::<f64>().ok()).sum();
63 if mib > 0.0 {
64 return Some((gb(mib * 1048576.0), gb(mib * 1048576.0 * 0.9), "NVIDIA GPU memory".into()));
65 }
66 }
67 }
68 if cfg!(target_os = "macos") {
69 let sysctl = ctx.which("sysctl").unwrap_or_else(|| PathBuf::from("/usr/sbin/sysctl"));
70 let bytes: f64 = run_capture(&sysctl, &["-n".into(), "hw.memsize".into()], ctx, t)?.lines().next()?.trim().parse().ok()?;
71 let unified = cfg!(target_arch = "aarch64");
72 let share = if unified { 0.75 } else { 0.5 };
73 return Some((gb(bytes), gb(bytes * share), if unified { "unified memory (75%)" } else { "RAM (50%)" }.into()));
74 }
75 let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
76 let kb: f64 = meminfo.lines().find(|l| l.starts_with("MemTotal:"))?.split_whitespace().nth(1)?.parse().ok()?;
77 Some((gb(kb * 1024.0), gb(kb * 1024.0 * 0.5), "RAM (50%)".into()))
78}
79
80fn params_from_id(id: &str) -> Option<f64> {
82 let lower = id.to_ascii_lowercase();
83 let b = lower.as_bytes();
84 let mut best = None;
85 for (i, &c) in b.iter().enumerate() {
86 if c != b'b' || i == 0 || !b[i - 1].is_ascii_digit() {
87 continue;
88 }
89 if b.get(i + 1).is_some_and(|x| x.is_ascii_alphanumeric()) {
90 continue;
91 }
92 let mut j = i;
93 while j > 0 && (b[j - 1].is_ascii_digit() || b[j - 1] == b'.') {
94 j -= 1;
95 }
96 if j > 0 && b[j - 1].is_ascii_alphabetic() && b[j - 1] != b'-' && b[j - 1] != b':' && b[j - 1] != b'_' {
97 if b[j - 1] == b'a' {
99 continue;
100 }
101 }
102 if let Ok(v) = lower[j..i].parse::<f64>() {
103 best = Some(best.map_or(v, |x: f64| x.max(v)));
104 }
105 }
106 best
107}
108
109fn tier_for(params: Option<f64>) -> u8 {
110 match params {
111 Some(p) if p >= 25.0 => 3,
112 Some(p) if p >= 12.0 => 2,
113 _ => 1,
114 }
115}
116
117#[derive(Debug, Clone, serde::Serialize)]
119#[serde(rename_all = "kebab-case")]
120struct Candidate {
121 id: String,
122 tier: u8,
123 params_b: Option<f64>,
124 min_memory_gb: Option<f64>,
125 present: bool,
126 tools: bool,
127 roles: Vec<String>,
128 from: &'static str,
129}
130
131fn ollama_models_on_disk(rt: &RuntimeProfile, ctx: &Ctx) -> Vec<(String, u64)> {
133 let Some(dir) = rt.models_dir(ctx) else { return vec![] };
134 let lib = dir.join("manifests/registry.ollama.ai/library");
135 let mut out = Vec::new();
136 for name in std::fs::read_dir(&lib).into_iter().flatten().flatten() {
137 for tag in std::fs::read_dir(name.path()).into_iter().flatten().flatten() {
138 let id = format!("{}:{}", name.file_name().to_string_lossy(), tag.file_name().to_string_lossy());
139 let m = locate_model(rt, &id, ctx);
140 out.push((id, m.size_bytes.unwrap_or(0)));
141 }
142 }
143 out.sort();
144 out
145}
146
147fn candidates(rt: &RuntimeProfile, opts: &LocalizeOptions, usable_gb: Option<f64>, ctx: &Ctx) -> Vec<Candidate> {
148 let fits = |need: Option<f64>| match (need, usable_gb) {
149 (Some(n), Some(u)) => n <= u,
150 _ => true,
151 };
152 let mut out: Vec<Candidate> = Vec::new();
153 if !opts.models.is_empty() {
154 for id in &opts.models {
155 let cat = rt.catalog.iter().find(|c| &c.id == id);
156 let params = cat.and_then(|c| c.params_b).or_else(|| params_from_id(id));
157 out.push(Candidate {
158 id: id.clone(),
159 tier: cat.map(|c| c.tier).unwrap_or_else(|| tier_for(params)),
160 params_b: params,
161 min_memory_gb: cat.and_then(|c| c.min_memory_gb),
162 present: locate_model(rt, id, ctx).present,
163 tools: cat.map(|c| c.tools).unwrap_or(true),
164 roles: cat.map(|c| c.roles.clone()).unwrap_or_default(),
165 from: "--models",
166 });
167 }
168 return out;
169 }
170 for c in rt.catalog.iter().filter(|c| fits(c.min_memory_gb)) {
171 let CatalogModel { id, params_b, min_memory_gb, tier, tools, roles } = c.clone();
172 let present = locate_model(rt, &id, ctx).present;
173 out.push(Candidate { id, tier, params_b, min_memory_gb, present, tools, roles, from: "catalog" });
174 }
175 if rt.models.layout == "ollama" {
176 for (id, size) in ollama_models_on_disk(rt, ctx) {
177 if out.iter().any(|c| c.id == id) {
178 continue;
179 }
180 let need = (size > 0).then(|| size as f64 * 1.25 / 1e9);
181 if !fits(need) {
182 continue;
183 }
184 let params = params_from_id(&id);
185 out.push(Candidate { id, tier: tier_for(params), params_b: params, min_memory_gb: need, present: true, tools: true, roles: vec![], from: "on disk" });
186 }
187 }
188 out
189}
190
191fn rank_default(c: &[Candidate]) -> Vec<Candidate> {
193 let mut v: Vec<Candidate> = c.iter().filter(|x| x.tools && (x.roles.is_empty() || x.roles.iter().any(|r| r == "default"))).cloned().collect();
194 if v.is_empty() {
195 v = c.to_vec();
196 }
197 v.sort_by(|a, b| {
198 b.tier.cmp(&a.tier).then(b.present.cmp(&a.present)).then(b.params_b.unwrap_or(0.0).partial_cmp(&a.params_b.unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal))
199 });
200 v
201}
202
203fn pick_small(c: &[Candidate]) -> Option<Candidate> {
205 let mut v: Vec<Candidate> = c.iter().filter(|x| x.tools && x.tier == 1).cloned().collect();
206 v.sort_by(|a, b| {
207 let ra = a.roles.iter().any(|r| r == "small" || r == "decision");
208 let rb = b.roles.iter().any(|r| r == "small" || r == "decision");
209 rb.cmp(&ra).then(b.present.cmp(&a.present)).then(a.params_b.unwrap_or(99.0).partial_cmp(&b.params_b.unwrap_or(99.0)).unwrap_or(std::cmp::Ordering::Equal))
210 });
211 v.into_iter().next()
212}
213
214fn is_local_harness(p: &HarnessProfile) -> bool {
215 p.kind == "custom" || (p.open_source == Some(true) && p.local_models == Some(true))
216}
217
218pub(crate) fn subst_value(v: &Value, vars: &[(&str, &str)]) -> Value {
219 let s = |x: &str| vars.iter().fold(x.to_string(), |acc, (k, val)| acc.replace(&format!("{{{k}}}"), val));
220 match v {
221 Value::String(x) => Value::String(s(x)),
222 Value::Array(a) => Value::Array(a.iter().map(|x| subst_value(x, vars)).collect()),
223 Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (s(k), subst_value(x, vars))).collect()),
224 other => other.clone(),
225 }
226}
227
228#[derive(Debug, Clone, serde::Serialize)]
230pub struct LocalChoice {
231 pub runtime: String,
232 pub runtime_name: String,
233 pub installed: bool,
234 pub base_url: String,
235 pub host: String,
236 pub model: String,
237 pub present: bool,
239 pub pull: Option<String>,
241 pub usable_gb: Option<f64>,
242}
243
244pub fn choose_local(reg: &Registry, ctx: &Ctx, runtime: Option<&str>, model: Option<&str>, memory_gb: Option<f64>) -> Result<LocalChoice> {
247 let rt = match runtime {
248 Some(r) => reg.runtime(r).ok_or_else(|| Error::Profile(format!("unknown runtime {r:?} (see hlock runtime list)")))?,
249 None => DEFAULT_RUNTIMES
250 .iter()
251 .filter_map(|r| reg.runtime(r))
252 .find(|r| probe_runtime(r, ctx).installed)
253 .or_else(|| reg.runtime("ollama"))
254 .ok_or_else(|| Error::Profile("no runtime profiles".into()))?,
255 };
256 let usable = memory_gb.or_else(|| machine_memory(ctx).map(|m| m.1));
257 let opts = LocalizeOptions { models: model.map(|m| vec![m.to_string()]).unwrap_or_default(), ..Default::default() };
258 let ranked = rank_default(&candidates(rt, &opts, usable, ctx));
259 let top = ranked.first().ok_or_else(|| {
260 Error::Profile(format!("no {} model fits {} GB; pass --model", rt.name, usable.map(|u| u.to_string()).unwrap_or_else(|| "?".into())))
261 })?;
262 let best = ranked.iter().find(|c| c.present && c.tier >= 2 && c.from == "catalog").unwrap_or_else(|| {
264 ranked
265 .iter()
266 .filter(|c| c.tier == top.tier)
267 .min_by(|a, b| a.min_memory_gb.or(a.params_b).unwrap_or(f64::MAX).partial_cmp(&b.min_memory_gb.or(b.params_b).unwrap_or(f64::MAX)).unwrap_or(std::cmp::Ordering::Equal))
268 .unwrap_or(top)
269 });
270 let port = rt.ports.first().copied().unwrap_or(8080);
271 Ok(LocalChoice {
272 runtime: rt.id.clone(),
273 runtime_name: rt.name.clone(),
274 installed: probe_runtime(rt, ctx).installed,
275 base_url: format!("http://localhost:{port}/v1"),
276 host: format!("http://localhost:{port}"),
277 model: best.id.clone(),
278 present: best.present,
279 pull: (!best.present).then(|| rt.pull.as_ref().map(|p| p.replace("{model}", &best.id))).flatten(),
280 usable_gb: usable,
281 })
282}
283
284pub fn localize(source: &Lockfile, source_path: &Path, reg: &Registry, ctx: &Ctx, opts: &LocalizeOptions) -> Result<LocalizeOutcome> {
286 let mut notes: Vec<String> = Vec::new();
287 let mut replaced: Vec<String> = Vec::new();
288 let mem = machine_memory(ctx);
289 let usable = opts.memory_gb.or(mem.as_ref().map(|m| m.1));
290
291 let src_profile = reg.harness(&source.harness.name);
293 let (target, why) = if let Some(h) = &opts.harness {
294 let p = reg.require_harness(h)?;
295 if !(p.kind == "custom" || p.local_models == Some(true)) {
296 return Err(Error::Profile(format!("{} can't use a local model endpoint", p.name)));
297 }
298 if p.open_source == Some(false) {
299 notes.push(format!("{} is proprietary; it runs locally but isn't open source", p.name));
300 }
301 (p, "--harness".to_string())
302 } else if let Some(p) = src_profile.filter(|p| is_local_harness(p)) {
303 (p, "already local-capable".to_string())
304 } else {
305 let options: Vec<&HarnessProfile> = DEFAULT_HARNESSES.iter().filter_map(|h| reg.harness(h)).filter(|p| is_local_harness(p)).collect();
306 let installed = options.iter().find(|p| probe_harness(p, ctx).installed).copied();
307 match installed {
308 Some(p) => (p, "the most preferred local-capable harness installed".to_string()),
309 None => {
310 let p = options.first().copied().ok_or_else(|| Error::Profile("no local-capable harness profile".into()))?;
311 notes.push(format!("{} isn't installed; `hlock sync --install` installs it", p.name));
312 (p, "the default local-capable harness".to_string())
313 }
314 }
315 };
316 let harness_changed = src_profile.map(|p| p.id != target.id).unwrap_or(true);
317 if harness_changed {
318 replaced.push(format!("harness {} → {}", source.harness.name, target.id));
319 }
320
321 let runtime = match &opts.runtime {
323 Some(r) => reg.runtime(r).ok_or_else(|| Error::Profile(format!("unknown runtime {r:?} (see hlock runtime list)")))?,
324 None => DEFAULT_RUNTIMES
325 .iter()
326 .filter_map(|r| reg.runtime(r))
327 .find(|r| probe_runtime(r, ctx).installed)
328 .or_else(|| reg.runtime("ollama"))
329 .ok_or_else(|| Error::Profile("no runtime profiles".into()))?,
330 };
331 let rprobe = probe_runtime(runtime, ctx);
332 if !rprobe.installed {
333 notes.push(format!("{} isn't installed; `hlock sync` will show how to install it", runtime.name));
334 }
335 let port = runtime.ports.first().copied().unwrap_or(8080);
336 let base_url = format!("http://localhost:{port}/v1");
337
338 let cands = candidates(runtime, opts, usable, ctx);
340 let ranked = rank_default(&cands);
341 if ranked.is_empty() {
342 return Err(Error::Profile(format!(
343 "no {} model fits {} GB; pass --models or --memory-gb",
344 runtime.name,
345 usable.map(|u| u.to_string()).unwrap_or_else(|| "?".into())
346 )));
347 }
348 let small = pick_small(&cands);
349
350 let is_local_llm = |l: &LLM| -> bool {
352 match l.runtime.as_deref().and_then(|r| source.runtime.iter().find(|x| x.name == r)) {
353 Some(rt) => rt.location == "local" || (opts.allow_network && rt.location == "network"),
354 None => l.provider == "reference",
355 }
356 };
357
358 let mut default_pick = ranked[0].clone();
360 let mut evaluated = false;
361 let mut evidence = Evidence::default();
362 let mut eval_harness = target.id.clone();
363 let tasks = opts.manifest.as_ref().filter(|m| !m.eval_packs().is_empty());
364 if let Some(m) = tasks {
365 let mut man = m.clone();
366 man.target.clear();
367 let top: Vec<String> = ranked.iter().take(4).map(|c| format!("{}:{}", runtime.id, c.id)).collect();
368 man.candidates.llm = BTreeMap::from([("planner".to_string(), LLMCandidate { any_of: top })]);
369 man.candidates.harness = vec![HarnessCandidate { name: target.id.clone(), ..Default::default() }];
370 let mut resolver = crate::Resolver::new(man, &opts.workdir);
371 let lock = resolver.resolve_lock()?;
372 if let Some(win) = lock.llm.first() {
373 if let Some(c) = ranked.iter().find(|c| c.id == win.model) {
374 default_pick = c.clone();
375 }
376 }
377 evaluated = true;
378 evidence = lock.evidence.clone();
379 eval_harness = lock.harness.adapter.clone().unwrap_or_else(|| target.id.clone());
380 if eval_harness != target.id {
381 notes.push(format!("trials ran on the {eval_harness} adapter, so the evidence is a stand-in"));
382 }
383 } else {
384 notes.push("no eval packs to evaluate with; candidates were ranked by fit, tier, and what's on disk".into());
385 }
386
387 let mut lock = source.clone();
389 lock.kind = Some("localized".into());
390 lock.base_lock = Some(source_path.to_string_lossy().into_owned());
391 lock.stack.insert("selection".into(), json!("localize"));
392 lock.stack.insert("harness".into(), json!(target.id));
393 lock.provenance.backend = "localize".into();
394 let hp = probe_harness(target, ctx);
395 if harness_changed {
396 lock.harness = Harness { name: target.id.clone(), version: hp.version.clone(), source: hp.source.clone(), pattern: source.harness.pattern.clone(), ..Default::default() };
397 }
398
399 let mut used_models: BTreeSet<String> = BTreeSet::new();
400 let mut new_llms = Vec::new();
401 for l in &source.llm {
402 if is_local_llm(l) {
403 new_llms.push(l.clone());
404 continue;
405 }
406 let pick = if l.role == "small" { small.clone().unwrap_or_else(|| default_pick.clone()) } else { default_pick.clone() };
407 replaced.push(format!("llm {}: {}:{} → {}:{}", l.role, l.provider, l.model, runtime.id, pick.id));
408 used_models.insert(pick.id.clone());
409 new_llms.push(LLM {
410 role: l.role.clone(),
411 provider: runtime.id.clone(),
412 model: pick.id.clone(),
413 resolved_from: Some(format!("localize:{}:{}", l.provider, l.model)),
414 params: l.params.clone(),
415 endpoint: Some(base_url.clone()),
416 runtime: Some(runtime.id.clone()),
417 ..Default::default()
418 });
419 }
420 if new_llms.is_empty() {
421 used_models.insert(default_pick.id.clone());
422 new_llms.push(LLM { role: "default".into(), provider: runtime.id.clone(), model: default_pick.id.clone(), endpoint: Some(base_url.clone()), runtime: Some(runtime.id.clone()), ..Default::default() });
423 }
424 lock.llm = new_llms;
425
426 if source.decision.kind.starts_with("llm") {
428 let api = source.decision.model.as_deref().map(|m| !m.starts_with(&runtime.id)).unwrap_or(true);
429 if api {
430 if let Some(s) = &small {
431 replaced.push(format!("decision {} → llm:{}:{}", source.decision.model.as_deref().unwrap_or(&source.decision.kind), runtime.id, s.id));
432 lock.decision.kind = "llm".into();
433 lock.decision.model = Some(format!("{}:{}", runtime.id, s.id));
434 used_models.insert(s.id.clone());
435 }
436 }
437 }
438
439 let still_used: BTreeSet<String> = lock.llm.iter().filter_map(|l| l.runtime.clone()).collect();
441 lock.runtime.retain(|r| still_used.contains(&r.name) && r.name != runtime.id);
442 if !used_models.is_empty() {
443 lock.runtime.push(Runtime {
444 name: runtime.id.clone(),
445 location: "local".into(),
446 version: rprobe.version.clone(),
447 source: rprobe.source.clone(),
448 endpoint: Some(base_url.clone()),
449 model: used_models.iter().map(|m| locate_model(runtime, m, ctx)).collect(),
450 });
451 }
452
453 let remote: Vec<String> = lock.tool.iter().filter(|t| t.kind == "mcp" && t.url.is_some()).map(|t| t.name.clone()).collect();
455 if !remote.is_empty() {
456 if opts.strict {
457 lock.tool.retain(|t| !remote.contains(&t.name));
458 replaced.push(format!("dropped remote MCP servers: {}", remote.join(", ")));
459 } else {
460 notes.push(format!("remote MCP servers still need the network: {} (--strict drops them)", remote.join(", ")));
461 }
462 }
463
464 let mut vendor = Vec::new();
466 if harness_changed {
467 let plugins: Vec<String> = lock.native.iter().filter(|n| n.kind != "package").map(|n| n.name.clone()).collect();
468 if !plugins.is_empty() {
469 let shown: Vec<&str> = plugins.iter().take(4).map(String::as_str).collect();
470 let more = if plugins.len() > 4 { format!(" and {} more", plugins.len() - 4) } else { String::new() };
471 notes.push(format!("{}'s {} plugins don't carry over: {}{more}", source.harness.name, plugins.len(), shown.join(", ")));
472 }
473 lock.native.retain(|n| n.kind == "package");
474 lock.config.clear();
475 lock.skill.retain(|s| !matches!(s.origin.as_str(), "harness-builtin" | "bundled"));
476 if let Some(tpl) = target.instructions.iter().find(|i| i.starts_with("{root}/")) {
477 for i in lock.instructions.iter_mut().filter(|i| i.scope == "user") {
478 i.name = tpl.clone();
479 }
480 }
481 }
482
483 let default_model = lock.llm.iter().find(|l| l.role == "default").or_else(|| lock.llm.first()).map(|l| l.model.clone()).unwrap_or_default();
485 let host = format!("http://localhost:{port}");
486 let vars = [("provider", runtime.id.as_str()), ("model", default_model.as_str()), ("base_url", base_url.as_str()), ("host", host.as_str())];
487 let mut per_file: BTreeMap<String, Map<String, Value>> = BTreeMap::new();
488 if target.kind == "cli" && !target.local_provider.is_empty() {
489 for lp in &target.local_provider {
490 let entry = per_file.entry(lp.file.clone()).or_default();
491 for (ptr, v) in &lp.set {
492 let ptr = subst_value(&json!(ptr), &vars).as_str().unwrap_or_default().to_string();
493 entry.insert(ptr, subst_value(v, &vars));
494 }
495 }
496 } else if target.kind == "cli" {
497 notes.push(format!("the {} profile doesn't say how to configure a local model; point it at {base_url} yourself", target.name));
498 }
499 if harness_changed {
500 if let Some((file, ptr)) = target.mcp.write.as_deref().and_then(|w| w.split_once('#')) {
501 let shape = target.mcp.shape.as_deref().unwrap_or("standard");
502 let servers: Map<String, Value> = lock.tool.iter().filter(|t| t.kind == "mcp").map(|t| (t.name.clone(), mcp_entry(t, shape))).collect();
503 if !servers.is_empty() {
504 per_file.entry(file.to_string()).or_default().insert(ptr.to_string(), Value::Object(servers));
505 }
506 }
507 }
508 let vroot = format!("{VENDOR_DIR}/{}/localize", target.id);
509 for (file, sets) in per_file {
510 let Some(spec) = target.files.get(&file) else {
511 notes.push(format!("the {} profile has no file {file:?}", target.id));
512 continue;
513 };
514 let body = Value::Object(sets.clone());
515 let rel = format!("{vroot}/{file}.pointers.json");
516 vendor.push(VendorFile { rel: rel.clone(), bytes: render_config(&body, "json").map_err(Error::Profile)?.into_bytes(), executable: false });
517 let mut check = json!({});
518 for (p, v) in &sets {
519 set_pointer(&mut check, p, v.clone());
520 }
521 lock.config.push(ConfigFile {
522 id: file.clone(),
523 path: spec.path.clone(),
524 format: spec.format.clone(),
525 restore: "pointers".into(),
526 pointers: sets.keys().cloned().collect(),
527 digest: Some(crate::hash::content_hash(&check)),
528 vendored: Some(rel),
529 });
530 }
531
532 lock.evidence = evidence;
533 let report = json!({
534 "source": source_path,
535 "evaluated": evaluated,
536 "evaluated-with": evaluated.then_some(eval_harness),
537 "memory": mem.as_ref().map(|(total, use_, how)| json!({"total-gb": total, "usable-gb": opts.memory_gb.unwrap_or(*use_), "how": how})),
538 "harness": {"from": source.harness.name, "to": target.id, "why": why, "installed": hp.installed},
539 "runtime": {"name": runtime.id, "installed": rprobe.installed, "version": rprobe.version, "endpoint": base_url},
540 "models": {
541 "default": default_pick.id,
542 "small": small.as_ref().map(|s| s.id.clone()),
543 "candidates": ranked.iter().chain(cands.iter().filter(|c| !ranked.iter().any(|r| r.id == c.id))).collect::<Vec<_>>(),
544 },
545 "replaced": replaced,
546 "notes": notes,
547 "baseline": {
548 "success-rate": source.evidence.success_rate.get("mean"),
549 "cost-usd-per-task": source.evidence.cost_usd_per_task.get("mean"),
550 "trials": source.evidence.trials,
551 },
552 });
553 let report = drop_nulls(&report); lock.localize = Some(report.clone());
555 Ok(LocalizeOutcome { lock, vendor, report })
556}
557
558pub fn localize_to(source_path: &Path, out: &Path, reg: &Registry, ctx: &Ctx, opts: &LocalizeOptions) -> Result<LocalizeOutcome> {
560 let source = Lockfile::load(source_path)?;
561 let mut o = localize(&source, source_path, reg, ctx, opts)?;
562 if source_path.parent() == out.parent() {
564 if let Some(name) = source_path.file_name() {
565 o.lock.base_lock = Some(name.to_string_lossy().into_owned());
566 }
567 }
568 let base = out.parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf).unwrap_or_else(|| ctx.cwd.clone());
569 let _ = std::fs::remove_dir_all(base.join(VENDOR_DIR).join(&o.lock.harness.name).join("localize"));
570 for f in &o.vendor {
571 let dest = base.join(&f.rel);
572 if let Some(p) = dest.parent() {
573 std::fs::create_dir_all(p)?;
574 }
575 std::fs::write(&dest, &f.bytes)?;
576 }
577 o.lock.save(out)?;
578 Ok(o)
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn params_parsing() {
587 assert_eq!(params_from_id("qwen3:8b"), Some(8.0));
588 assert_eq!(params_from_id("qwen3-coder:30b"), Some(30.0));
589 assert_eq!(params_from_id("Qwen3-30B-A3B-Instruct"), Some(30.0));
590 assert_eq!(params_from_id("qwen3:1.7b"), Some(1.7));
591 assert_eq!(params_from_id("llama3.2"), None);
592 assert_eq!(tier_for(Some(30.0)), 3);
593 assert_eq!(tier_for(Some(20.0)), 2);
594 assert_eq!(tier_for(None), 1);
595 }
596
597 #[test]
598 fn templates() {
599 let v = json!({"/provider/{provider}": {"options": {"baseURL": "{base_url}"}, "models": {"{model}": {}}}});
600 let out = subst_value(&v, &[("provider", "ollama"), ("model", "qwen3:8b"), ("base_url", "http://localhost:11434/v1")]);
601 assert_eq!(out, json!({"/provider/ollama": {"options": {"baseURL": "http://localhost:11434/v1"}, "models": {"qwen3:8b": {}}}}));
602 }
603
604 #[test]
605 fn ranking() {
606 let c = |id: &str, tier: u8, present: bool, params: f64| Candidate { id: id.into(), tier, params_b: Some(params), min_memory_gb: None, present, tools: true, roles: vec![], from: "catalog" };
607 let cands = vec![c("a", 2, false, 20.0), c("b", 3, false, 30.0), c("c", 3, true, 24.0), c("tiny", 1, false, 1.7), c("small", 1, true, 4.0)];
608 assert_eq!(rank_default(&cands)[0].id, "c"); assert_eq!(pick_small(&cands).unwrap().id, "small"); let r = Registry::builtin();
611 let ollama = r.runtime("ollama").unwrap();
612 assert!(ollama.catalog.iter().any(|m| m.tier == 3) && ollama.catalog.iter().any(|m| m.roles.iter().any(|x| x == "decision")));
613 assert!(r.harness("opencode").unwrap().local_provider.len() == 1 && r.harness("pi").unwrap().local_provider.len() == 2);
614 assert!(is_local_harness(r.harness("opencode").unwrap()) && !is_local_harness(r.harness("claude-code").unwrap()));
615 }
616}