use std::collections::BTreeMap;
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Tiers {
pub task_tier: BTreeMap<String, String>,
#[serde(deserialize_with = "de_model_lists")]
pub tiers: BTreeMap<String, BTreeMap<String, Vec<String>>>,
}
fn de_model_lists<'de, D>(
d: D,
) -> std::result::Result<BTreeMap<String, BTreeMap<String, Vec<String>>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
let raw: BTreeMap<String, BTreeMap<String, OneOrMany>> =
serde::Deserialize::deserialize(d)?;
Ok(raw
.into_iter()
.map(|(provider, tier_map)| {
let tier_map = tier_map
.into_iter()
.map(|(tier, v)| {
(tier, match v {
OneOrMany::One(s) => vec![s],
OneOrMany::Many(list) => list,
})
})
.collect();
(provider, tier_map)
})
.collect())
}
const DEFAULT_RING_JSON: &str = include_str!("tiers_default.json");
impl Default for Tiers {
fn default() -> Self {
serde_json::from_str(DEFAULT_RING_JSON)
.expect("tiers_default.json is malformed — fix the embedded ring")
}
}
fn tiers_path() -> Option<std::path::PathBuf> {
crate::config::state_path().map(|p| p.with_file_name("tiers.json"))
}
pub fn load() -> Tiers {
let mut merged = Tiers::default();
let Some(path) = tiers_path() else { return merged };
if let Ok(s) = std::fs::read_to_string(&path) {
if let Ok(t) = serde_json::from_str::<Tiers>(&s) {
for (task, tier) in t.task_tier {
merged.task_tier.insert(task, tier);
}
for (provider, tier_map) in t.tiers {
let entry = merged.tiers.entry(provider).or_default();
for (tier, models) in tier_map {
entry.insert(tier, models);
}
}
return merged;
}
}
if !path.exists() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(&merged) {
let _ = std::fs::write(&path, json);
}
}
merged
}
pub fn models_for(provider: &str, task: &str, default_model: &str) -> Vec<String> {
if std::env::var("MARS_LLM_MODEL").is_ok() || std::env::var("ARES_LLM_MODEL").is_ok() {
return vec![default_model.to_string()];
}
let t = load();
let Some(tier) = t.task_tier.get(task) else { return vec![default_model.to_string()] };
t.tiers
.get(provider)
.and_then(|m| m.get(tier))
.filter(|v| !v.is_empty())
.cloned()
.unwrap_or_else(|| vec![default_model.to_string()])
}
pub fn model_for(provider: &str, task: &str, default_model: &str) -> String {
models_for(provider, task, default_model)
.into_iter()
.next()
.unwrap_or_else(|| default_model.to_string())
}
const TIER_ORDER: [&str; 3] = ["low", "mid", "high"];
fn model_above_in(t: &Tiers, provider: &str, task: &str) -> Option<String> {
let tier = t.task_tier.get(task)?;
let models = t.tiers.get(provider)?;
let current = models.get(tier).and_then(|v| v.first())?;
let start = TIER_ORDER.iter().position(|x| *x == tier.as_str())?;
for next in &TIER_ORDER[start + 1..] {
if let Some(m) = models.get(*next).and_then(|v| v.first()) {
if m != current {
return Some(m.clone());
}
}
}
None
}
pub fn model_above(provider: &str, task: &str) -> Option<String> {
if std::env::var("MARS_LLM_MODEL").is_ok() || std::env::var("ARES_LLM_MODEL").is_ok() {
return None;
}
model_above_in(&load(), provider, task)
}
#[cfg_attr(not(feature = "memory"), allow(dead_code))] pub fn tier_descriptions() -> Vec<String> {
let t = Tiers::default();
let mut out = vec![
"The model tier ring routes each agent task to a tier (low/mid/high); edit \
~/.config/mars/tiers.json to move a task between tiers or re-point a tier to \
a different model. An explicit MARS_LLM_MODEL overrides the ring."
.to_string(),
];
for (task, tier) in &t.task_tier {
out.push(format!(
"The `{task}` task runs on the `{tier}` tier by default; change it under \
`task_tier` in ~/.config/mars/tiers.json."
));
}
out.push(
"Each tier lists several models in priority order: if the first is retired \
(HTTP 404 / 'model does not exist') or rate-limited (HTTP 429), the agent \
falls through to the next model in the SAME tier, then rotates to another \
configured provider's tier — set more than one API key (e.g. GROQ_API_KEY \
and GEMINI_API_KEY) to enable cross-provider rotation. An explicit \
MARS_LLM_MODEL disables both."
.to_string(),
);
out.push(
"If an ask reply proposes an action that fails the registry check, the \
question is retried once on the model one tier up (logged as task \
`ask_escalated`); an explicit MARS_LLM_MODEL disables escalation."
.to_string(),
);
out
}