use argonauts_core::display_names::DisplayNames;
use argonauts_core::model_catalog::display_name_from_id;
use argonauts_core::model_picker::{Fit, MachineMemory};
use argonauts_core::names::ModelRef;
pub(crate) const LISTED: usize = 4;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct LocalModel {
pub runtime: String,
pub base_url: String,
pub model: String,
pub bytes: Option<u64>,
pub fit: Option<Fit>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HostedModel {
pub worker: ModelRef,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Place {
ThisMachine(LocalModel),
Hosted(HostedModel),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Candidate {
pub model: ModelRef,
pub name: String,
pub short: String,
pub place: Place,
}
impl Candidate {
pub fn local(&self) -> bool {
matches!(self.place, Place::ThisMachine(_))
}
}
pub(crate) fn candidates(
locals: &[LocalModel],
hosted: &[HostedModel],
names: &DisplayNames,
) -> Vec<Candidate> {
let mut fitting: Vec<&LocalModel> = Vec::new();
let mut tight: Vec<&LocalModel> = Vec::new();
for local in locals.iter().filter(|local| !embeds(&local.model)) {
match local.fit {
None | Some(Fit::Fits | Fit::Loaded) => fitting.push(local),
Some(_) => tight.push(local),
}
}
fitting.sort_by_key(|local| std::cmp::Reverse(local.bytes));
tight.sort_by_key(|local| local.bytes);
let mut named: Vec<(String, &LocalModel)> = Vec::new();
for local in fitting.into_iter().chain(tight) {
let name = local_name(&local.model);
let same = |(known, kept): &&mut (String, &LocalModel)| {
*known == name && kept.runtime == local.runtime
};
match named.iter_mut().find(same) {
Some(kept) if local.fit == Some(Fit::Loaded) && kept.1.fit != Some(Fit::Loaded) => {
kept.1 = local;
}
Some(_) => {}
None => named.push((name, local)),
}
}
let locals = named
.into_iter()
.map(|(_, local)| local_candidate(local, names));
let hosted = hosted.iter().map(|hosted| Candidate {
model: hosted.worker.clone(),
name: names.worker(&hosted.worker),
short: names
.model(&hosted.worker.agent, hosted.worker.model.as_deref())
.unwrap_or_else(|| names.cli(&hosted.worker.agent)),
place: Place::Hosted(hosted.clone()),
});
locals.chain(hosted).collect()
}
pub(crate) fn named(setting: &str, candidates: &[Candidate]) -> Option<ModelRef> {
let setting = setting.trim();
if setting.is_empty() || setting == argonauts_core::settings::behaviors::AUTO {
return None;
}
let wanted: ModelRef = setting.parse().ok()?;
Some(
candidates
.iter()
.find(|candidate| candidate.model == wanted)
.map_or(wanted, |candidate| candidate.model.clone()),
)
}
pub(crate) fn local_candidate(local: &LocalModel, names: &DisplayNames) -> Candidate {
let short = local_name(&local.model);
Candidate {
model: ModelRef::new(local.runtime.clone(), Some(local.model.clone())),
name: format!("{short} on {}", names.cli(&local.runtime)),
short,
place: Place::ThisMachine(local.clone()),
}
}
pub(crate) fn local_name(model: &str) -> String {
let without_window = match model.rsplit_once('-') {
Some((base, window))
if base.contains(':')
&& window
.strip_suffix(['k', 'K'])
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) =>
{
base
}
_ => model,
};
display_name_from_id(without_window)
}
pub(crate) fn embeds(model: &str) -> bool {
model.to_ascii_lowercase().contains("embed")
}
pub(crate) fn fit(
machine: Option<&MachineMemory>,
bytes: Option<u64>,
loaded: bool,
) -> Option<Fit> {
if loaded {
return Some(Fit::Loaded);
}
Some(machine?.fit(bytes?))
}
#[cfg(test)]
pub(crate) mod sample {
use super::*;
const GB: u64 = 1_000_000_000;
pub fn page_models() -> (Vec<LocalModel>, Vec<HostedModel>) {
let local = |model: &str, gb: u64| LocalModel {
runtime: "ollama".to_owned(),
base_url: "http://127.0.0.1:11434/v1".to_owned(),
model: model.to_owned(),
bytes: Some(gb * GB),
fit: Some(Fit::Fits),
};
let locals = vec![
local("gemma3:12b-32k", 8),
local("nomic-embed-text:latest", 0),
local("qwen3-coder:30b-64k", 18),
];
let hosted = ["claude-code/haiku", "codex/gpt-6-luna"]
.iter()
.map(|worker| HostedModel {
worker: worker.parse().unwrap(),
})
.collect();
(locals, hosted)
}
}
#[cfg(test)]
mod tests {
use super::sample::page_models;
use super::*;
#[test]
fn auto_picks_the_largest_local_model_that_fits_and_hosted_ones_come_after() {
let (locals, hosted) = page_models();
let names = DisplayNames::default();
let offered = candidates(&locals, &hosted, &names);
let listed: Vec<&str> = offered.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
listed,
[
"Qwen3 Coder 30B on Ollama",
"Gemma3 12B on Ollama",
"Haiku on Claude Code",
"GPT 6 Luna on Codex",
]
);
let picked = offered.first().unwrap();
assert!(picked.local());
assert_eq!(picked.model.to_string(), "ollama/qwen3-coder:30b-64k");
}
#[test]
fn with_no_local_model_auto_is_the_main_clis_cheapest() {
let (_, hosted) = page_models();
let offered = candidates(&[], &hosted, &DisplayNames::default());
let picked = offered.first().unwrap();
assert!(!picked.local());
assert_eq!(picked.model.to_string(), "claude-code/haiku");
}
#[test]
fn a_model_that_doesnt_fit_now_comes_after_the_ones_that_do() {
let (mut locals, hosted) = page_models();
locals[2].fit = Some(Fit::Tight { at: None });
let offered = candidates(&locals, &hosted, &DisplayNames::default());
assert_eq!(offered[0].model.to_string(), "ollama/gemma3:12b-32k");
assert_eq!(offered[1].model.to_string(), "ollama/qwen3-coder:30b-64k");
}
#[test]
fn a_named_summarizer_wins_over_auto() {
let (locals, hosted) = page_models();
let offered = candidates(&locals, &hosted, &DisplayNames::default());
assert_eq!(named("auto", &offered), None);
assert_eq!(named(" ", &offered), None);
assert_eq!(
named("codex/gpt-6-luna", &offered).map(|m| m.to_string()),
Some("codex/gpt-6-luna".to_owned())
);
}
#[test]
fn a_model_served_twice_under_one_name_is_offered_once_the_one_in_memory() {
let (mut locals, hosted) = page_models();
let mut base = locals[0].clone();
base.model = "qwen3-coder:30b".to_owned();
locals.push(base.clone());
let offered = candidates(&locals, &hosted, &DisplayNames::default());
let qwen: Vec<String> = offered
.iter()
.filter(|c| c.short == "Qwen3 Coder 30B")
.map(|c| c.model.to_string())
.collect();
assert_eq!(qwen, ["ollama/qwen3-coder:30b-64k"]);
base.fit = Some(Fit::Loaded);
locals.push(base);
let offered = candidates(&locals, &hosted, &DisplayNames::default());
let qwen: Vec<String> = offered
.iter()
.filter(|c| c.short == "Qwen3 Coder 30B")
.map(|c| c.model.to_string())
.collect();
assert_eq!(qwen, ["ollama/qwen3-coder:30b"]);
}
#[test]
fn local_names_leave_out_the_window() {
assert_eq!(local_name("qwen3-coder:30b-64k"), "Qwen3 Coder 30B");
assert_eq!(local_name("qwen3-coder:30b"), "Qwen3 Coder 30B");
assert!(embeds("nomic-embed-text:latest"));
}
}