use serde_json::Value;
use crate::config::load::Environment;
use crate::config::schema::AgentConfig;
pub const STORE_FILENAME: &str = "models-store.json";
#[derive(Debug, Clone, PartialEq)]
pub struct ModelInfo {
pub id: String,
pub base_url: Option<String>,
pub input: Vec<String>,
pub cost_in: f64,
}
pub fn agent_directories(env: &Environment) -> Vec<String> {
let override_dir = env
.get("PI_CODING_AGENT_DIR")
.map(|value| value.trim())
.filter(|value| !value.is_empty());
let home = env.get("HOME").map_or("", |value| value.trim());
let config_root = env
.get("XDG_CONFIG_HOME")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map_or_else(|| format!("{home}/.config"), str::to_owned);
let mut directories = Vec::new();
if let Some(override_dir) = override_dir {
directories.push(override_dir.to_owned());
}
directories.push(format!("{home}/.pi/agent"));
directories.push(format!("{config_root}/pi"));
directories
}
pub fn agent_directory(env: &Environment) -> Option<String> {
for directory in agent_directories(env) {
let path = std::path::Path::new(&directory).join(STORE_FILENAME);
if std::fs::metadata(&path).is_ok() {
return Some(directory);
}
}
None
}
pub fn sees_images(model: Option<&ModelInfo>) -> bool {
model.is_some_and(|model| model.input.iter().any(|kind| kind == "image"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AvailableModel {
pub provider: String,
pub id: String,
pub default_level: Option<String>,
}
impl AvailableModel {
pub fn qualified(&self) -> String {
format!("{}/{}", self.provider, self.id)
}
pub fn with_level(&self, asked: &str) -> String {
if !asked.is_empty() {
return format!("{}{asked}", self.id);
}
match &self.default_level {
Some(level) => format!("{}{level}", self.id),
None => self.id.clone(),
}
}
}
fn level_suffix(from: Option<&Value>) -> Option<String> {
let named = from?.get("defaultThinkingLevel")?.as_str()?.trim();
(!named.is_empty()).then(|| format!(":{}", named.to_lowercase()))
}
pub fn available_models(agent: &AgentConfig, directory: Option<&str>) -> Vec<AvailableModel> {
let configured = level_suffix(agent.providers.get(&agent.provider));
let mut found: Vec<AvailableModel> = read_models(directory, &agent.provider)
.into_iter()
.map(|model| AvailableModel {
provider: agent.provider.clone(),
id: model.id,
default_level: configured.clone(),
})
.collect();
for (provider, definition) in &agent.providers {
let across = level_suffix(Some(definition));
let listed = definition
.get("models")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
for model in listed {
let Some(id) = model.get("id").and_then(Value::as_str) else {
continue;
};
let level = level_suffix(Some(model)).or_else(|| across.clone());
if let Some(known) = found
.iter_mut()
.find(|known| known.provider == *provider && known.id == id)
{
known.default_level = level;
continue;
}
found.push(AvailableModel {
provider: provider.clone(),
id: id.to_owned(),
default_level: level,
});
}
}
found
}
pub struct StoreContents {
pub models: Vec<ModelInfo>,
pub skipped: usize,
}
pub fn read_models(directory: Option<&str>, provider: &str) -> Vec<ModelInfo> {
read_store(directory, provider).models
}
pub fn read_store(directory: Option<&str>, provider: &str) -> StoreContents {
let nothing = || StoreContents {
models: Vec::new(),
skipped: 0,
};
let Some(directory) = directory else {
return nothing();
};
let Ok(text) = std::fs::read_to_string(std::path::Path::new(directory).join(STORE_FILENAME))
else {
return nothing();
};
let parsed: Value = match serde_json::from_str(&text) {
Ok(parsed) => parsed,
Err(_) => return nothing(),
};
let models = parsed
.get(provider)
.and_then(|entry| entry.get("models"))
.and_then(Value::as_array);
let Some(models) = models else {
return nothing();
};
let mut found = Vec::new();
let mut skipped = 0;
for raw in models {
let Some(model) = raw.as_object() else {
skipped += 1;
continue;
};
let Some(Value::String(id)) = model.get("id") else {
continue;
};
let cost = model.get("cost");
found.push(ModelInfo {
id: id.clone(),
base_url: match model.get("baseUrl") {
Some(Value::String(base_url)) => Some(base_url.clone()),
_ => None,
},
input: match model.get("input") {
Some(Value::Array(kinds)) => kinds
.iter()
.filter_map(|kind| kind.as_str())
.map(str::to_owned)
.collect(),
_ => Vec::new(),
},
cost_in: cost
.and_then(|cost| cost.get("input"))
.and_then(Value::as_f64)
.unwrap_or(f64::INFINITY),
});
}
StoreContents {
models: found,
skipped,
}
}
pub fn model_by_id<'a>(models: &'a [ModelInfo], id: Option<&str>) -> Option<&'a ModelInfo> {
let id = id?;
models.iter().find(|model| model.id == id)
}
pub fn vision_model(models: &[ModelInfo], preferred: Option<&str>) -> Option<ModelInfo> {
if let Some(preferred) = preferred {
let named = model_by_id(models, Some(preferred));
return match named {
Some(named) if sees_images(Some(named)) && named.base_url.is_some() => {
Some(named.clone())
}
_ => None,
};
}
let mut candidates: Vec<&ModelInfo> = models
.iter()
.filter(|model| sees_images(Some(model)) && model.base_url.is_some())
.collect();
candidates.sort_by(|left, right| {
left.cost_in
.partial_cmp(&right.cost_in)
.unwrap_or(std::cmp::Ordering::Equal)
});
candidates.first().map(|model| (*model).clone())
}