use std::collections::BTreeMap;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const LISTING: Duration = Duration::from_secs(20);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Model {
pub profile: String,
pub model: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Catalog {
pub profile: String,
pub models: Vec<String>,
pub note: Option<String>,
}
#[derive(Debug, Default, serde::Deserialize)]
struct Table {
#[serde(default)]
args: Option<Vec<String>>,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
separator: Option<String>,
#[serde(default)]
known: Vec<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ProfileFile {
id: String,
command: String,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default)]
models: Option<Table>,
}
pub fn catalogs(adapters: &Path) -> Vec<Catalog> {
let Ok(entries) = std::fs::read_dir(adapters) else {
return Vec::new();
};
let mut paths: Vec<_> = entries
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
paths
.iter()
.filter_map(|path| std::fs::read_to_string(path).ok())
.filter_map(|text| catalog(&text))
.collect()
}
pub fn catalog(text: &str) -> Option<Catalog> {
let file: ProfileFile = toml::from_str(text).ok()?;
let table = file.models?;
let mut models = table.known.clone();
let mut note = None;
if let Some(args) = &table.args {
match list(&file.command, args, &file.env) {
Ok(output) => {
let found = read(&output, table.prefix.as_deref(), table.separator.as_deref());
if found.is_empty() {
note = Some(format!(
"`{} {}` listed nothing this profile can run",
file.command,
args.join(" ")
));
}
models.extend(found);
}
Err(why) => note = Some(why),
}
}
let mut seen = std::collections::BTreeSet::new();
models.retain(|m| seen.insert(m.clone()));
Some(Catalog {
profile: file.id,
models,
note,
})
}
pub fn rows(catalogs: &[Catalog]) -> Vec<Model> {
catalogs
.iter()
.flat_map(|c| {
std::iter::once(Model {
profile: c.profile.clone(),
model: None,
})
.chain(c.models.iter().map(|m| Model {
profile: c.profile.clone(),
model: Some(m.clone()),
}))
})
.collect()
}
fn read(output: &str, prefix: Option<&str>, separator: Option<&str>) -> Vec<String> {
output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter_map(|line| {
let line = match separator {
Some(sep) => line.split_once(sep)?.0.trim(),
None => line,
};
match prefix {
Some(prefix) => line.strip_prefix(prefix).map(str::to_string),
None => Some(line.to_string()),
}
})
.filter(|m| !m.is_empty())
.collect()
}
fn list(command: &str, args: &[String], env: &BTreeMap<String, String>) -> Result<String, String> {
let mut child = Command::new(command)
.args(args)
.envs(env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("`{command}` could not be run: {e}"))?;
let mut stdout = child.stdout.take().expect("stdout is piped");
let (sent, received) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = std::io::Read::read_to_end(&mut stdout, &mut bytes);
let _ = sent.send(bytes);
});
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() >= LISTING => {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"`{command} {}` took longer than {}s",
args.join(" "),
LISTING.as_secs()
));
}
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
Err(e) => return Err(e.to_string()),
}
};
let bytes = received
.recv_timeout(Duration::from_secs(2))
.map_err(|_| format!("`{command} {}` left its output open", args.join(" ")))?;
if !status.success() {
return Err(format!(
"`{command} {}` exited with {status}",
args.join(" ")
));
}
Ok(String::from_utf8_lossy(&bytes).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_prefix_keeps_the_lines_that_carry_it_and_drops_it() {
let listed = "lister 1.0\nlocal/model-one\n\nlocal/model-two:7b\n";
assert_eq!(
read(listed, Some("local/"), None),
["model-one", "model-two:7b"]
);
}
#[test]
fn a_separator_keeps_the_lines_that_hold_it_up_to_it() {
let listed = "Fetching available models...\nfast-high\tFast (High)\nslow\tSlow\n";
assert_eq!(read(listed, None, Some("\t")), ["fast-high", "slow"]);
}
#[test]
fn a_fixed_list_needs_no_command_and_a_profile_without_a_table_is_left_out() {
let fixed = catalog(
"id = \"fixed\"\ncommand = \"nothing-to-run\"\nargs = [\"{{prompt}}\"]\n\
[models]\nknown = [\"alias-a\", \"alias-b\", \"alias-a\"]\n",
)
.expect("a catalog");
assert_eq!(
fixed.models,
["alias-a", "alias-b"],
"the duplicate was kept"
);
assert_eq!(fixed.note, None);
assert_eq!(
catalog("id = \"bare\"\ncommand = \"x\"\nargs = [\"{{prompt}}\"]\n"),
None
);
}
#[cfg(unix)]
#[test]
fn a_listing_runs_with_the_profile_environment_and_says_why_when_it_fails() {
let dir = std::env::temp_dir().join(format!("ostraka-models-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("dir");
let script = dir.join("lister.sh");
std::fs::write(
&script,
"#!/bin/sh\n[ \"$1\" = models ] || exit 3\necho \"p/$DECLARED\"\necho p/other\n",
)
.expect("script");
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let listed = catalog(&format!(
"id = \"lister\"\ncommand = \"{}\"\nargs = [\"{{{{prompt}}}}\"]\n\
[env]\nDECLARED = \"from-env\"\n\
[models]\nargs = [\"models\"]\nprefix = \"p/\"\n",
script.display()
))
.expect("a catalog");
assert_eq!(listed.models, ["from-env", "other"]);
let failing = catalog(&format!(
"id = \"lister\"\ncommand = \"{}\"\nargs = [\"{{{{prompt}}}}\"]\n\
[models]\nargs = [\"nope\"]\n",
script.display()
))
.expect("a catalog");
assert!(failing.models.is_empty());
assert!(
failing
.note
.as_deref()
.is_some_and(|n| n.contains("exited")),
"{failing:?}"
);
let rows = rows(&[listed]);
assert_eq!(rows[0].model, None, "the profile's own default comes first");
assert_eq!(rows.len(), 3);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_listing_longer_than_a_pipe_holds_comes_back_whole() {
let script = "i=0; while [ $i -lt 20000 ]; do echo \"p/model-$i\"; i=$((i+1)); done";
let started = Instant::now();
let output = list(
"sh",
&["-c".to_string(), script.to_string()],
&BTreeMap::new(),
)
.expect("the listing");
assert_eq!(read(&output, Some("p/"), None).len(), 20000);
assert!(
started.elapsed() < LISTING,
"the writer blocked on a full pipe"
);
}
}