#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::cast_sign_loss
)]
use anyhow::Result;
use ratatui::widgets::ListState;
use super::{App, KeyTarget, LoginMsg, ModelPanel, ModelPickTarget, Popup};
use crate::config;
use crate::provider::openrouter::OpenRouter;
use crate::provider::{BackendTag, Model};
use chrono::Utc;
impl App {
pub(crate) fn rebuild_all_backends(&mut self) {
self.backends = super::Backends::default();
if let Some(k) = self.saved.openrouter_key.clone() {
self.backends
.set(BackendTag::OpenRouter, OpenRouter::openrouter_flavor(k));
}
if let Some(k) = self.saved.openai_key.clone() {
self.backends.set(BackendTag::OpenAi, OpenRouter::openai(k));
}
if let Some(k) = self.saved.opencode_key.clone() {
self.backends
.set(BackendTag::OpencodeGo, OpenRouter::opencode_go(k));
}
if let Some(c) = self.saved.codex.clone() {
self.backends
.set(BackendTag::Codex, OpenRouter::openai_codex(c.access));
}
}
pub(crate) fn resolve_model_backend(&self, id: &str) -> Option<(OpenRouter, String)> {
self.backends.resolve(id)
}
pub(crate) fn resolve_utility_model_backend(
&self,
configured_id: &str,
) -> Option<(OpenRouter, String)> {
self.resolve_feature_model_backend(configured_id, OpenRouter::default_utility_model)
}
pub(crate) fn resolve_feature_model_backend(
&self,
configured_id: &str,
default: fn(&OpenRouter) -> &'static str,
) -> Option<(OpenRouter, String)> {
let configured_id = configured_id.trim();
if !configured_id.is_empty()
&& let Some((provider, raw)) = self.resolve_model_backend(configured_id)
&& self.resolved_model_looks_valid(configured_id, provider.backend_tag(), &raw)
{
return Some((provider, raw));
}
let provider = self
.current_model
.as_deref()
.and_then(|id| self.resolve_model_backend(id).map(|(provider, _)| provider))
.or_else(|| {
self.backends
.configured_tags()
.first()
.and_then(|tag| self.backends.get(*tag).cloned())
})?;
Some((provider.clone(), default(&provider).to_string()))
}
fn resolved_model_looks_valid(
&self,
original_id: &str,
backend: BackendTag,
raw: &str,
) -> bool {
if self.models.is_empty() {
return backend == BackendTag::OpenRouter || !original_id.contains('/');
}
self.models
.iter()
.any(|m| m.backend == backend && m.id == raw)
}
pub(crate) fn model_backend_filter_label(&self) -> &'static str {
self.model_backend_filter
.map_or("all backends", BackendTag::display_name)
}
pub(crate) fn cycle_model_backend_filter(&mut self) {
let tags = self.backends.configured_tags();
if tags.is_empty() {
self.model_backend_filter = None;
return;
}
self.model_backend_filter = match self.model_backend_filter {
None => Some(tags[0]),
Some(cur) => {
let next = tags.iter().position(|t| *t == cur).map(|i| i + 1);
next.and_then(|i| tags.get(i).copied())
}
};
self.reset_model_selection();
}
pub(super) fn open_model_picker(&mut self) {
self.model_pick_target = ModelPickTarget::Session;
self.open_model_picker_impl();
}
pub(crate) fn open_model_picker_for_memory(&mut self) {
self.model_pick_target = ModelPickTarget::Memory;
self.open_model_picker_impl();
}
pub(crate) fn open_model_picker_for_transcriber(&mut self) {
self.model_pick_target = ModelPickTarget::Transcriber;
self.open_model_picker_impl();
}
pub(crate) fn open_model_picker_for_image_gen(&mut self) {
self.model_pick_target = ModelPickTarget::ImageGen;
self.open_model_picker_impl();
}
pub(crate) fn open_model_picker_for_video_gen(&mut self) {
self.model_pick_target = ModelPickTarget::VideoGen;
self.open_model_picker_impl();
}
pub(crate) fn open_model_picker_for_ocr(&mut self) {
self.model_pick_target = ModelPickTarget::Ocr;
self.open_model_picker_impl();
}
pub(crate) fn open_model_picker_for_swarm_persona(&mut self, row: usize) {
self.model_pick_target = ModelPickTarget::SwarmPersona(row);
self.open_model_picker_impl();
}
fn open_model_picker_impl(&mut self) {
if !self.backends.any() {
self.open_login_popup();
return;
}
if self.models.is_empty() {
self.status = "loading models…".to_string();
self.fetch_models();
self.popup = Popup::Model;
return;
}
self.model_filter.clear();
self.model_focus = if self.favorite_models().is_empty() {
ModelPanel::Available
} else {
ModelPanel::Favorites
};
self.reset_model_selection();
self.popup = Popup::Model;
}
pub(crate) fn reset_model_selection(&mut self) {
self.fav_state
.select((!self.favorite_models().is_empty()).then_some(0));
self.avail_state
.select((!self.available_models().is_empty()).then_some(0));
}
pub(super) const fn open_login_popup(&mut self) {
self.login_selected = 0;
self.popup = Popup::Login;
}
pub(crate) fn move_login_selection(&mut self, delta: i32) {
self.login_selected = super::clamp_cursor(self.login_selected, 4, delta);
}
pub(crate) fn confirm_login_selection(&mut self) {
match self.login_selected {
0 => self.start_key_login(
KeyTarget::OpenRouter,
config::OPENROUTER_ENV_KEY,
"openrouter",
"OpenRouter",
),
1 => self.start_key_login(
KeyTarget::OpencodeGo,
config::OPENCODE_ENV_KEY,
"opencode",
"OpenCode Go",
),
2 => self.start_key_login(
KeyTarget::OpenAi,
config::OPENAI_ENV_KEY,
"openai",
"OpenAI",
),
_ => self.start_codex_login(),
}
}
fn start_key_login(&mut self, target: KeyTarget, env_var: &str, tag: &str, label: &str) {
if let Ok(v) = std::env::var(env_var) {
let v = v.trim().to_string();
if !v.is_empty() {
self.activate_key(target, tag, v);
return;
}
}
self.key_target = target;
self.key_input.clear();
self.status = format!("paste your {label} API key, then Enter");
self.popup = Popup::Key;
}
pub(crate) const fn key_target_label(&self) -> &'static str {
match self.key_target {
KeyTarget::OpenRouter => "OpenRouter",
KeyTarget::OpenAi => "OpenAI",
KeyTarget::OpencodeGo => "OpenCode Go",
}
}
fn activate_key(&mut self, target: KeyTarget, tag: &str, key: String) {
if let Err(e) = config::save_provider_key(tag, &key) {
self.status = format!("could not save key: {e}");
}
let (backend_tag, provider) = match target {
KeyTarget::OpenRouter => (
BackendTag::OpenRouter,
OpenRouter::openrouter_flavor(key.clone()),
),
KeyTarget::OpenAi => (BackendTag::OpenAi, OpenRouter::openai(key.clone())),
KeyTarget::OpencodeGo => (BackendTag::OpencodeGo, OpenRouter::opencode_go(key.clone())),
};
match target {
KeyTarget::OpenRouter => self.saved.openrouter_key = Some(key),
KeyTarget::OpenAi => self.saved.openai_key = Some(key),
KeyTarget::OpencodeGo => self.saved.opencode_key = Some(key),
}
self.backends.set(backend_tag, provider);
self.popup = Popup::None;
self.status = "key saved, loading models…".to_string();
self.fetch_models();
}
pub(crate) fn start_codex_login(&mut self) {
self.login_rx = None;
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
self.login_rx = Some(rx);
self.status = "starting OpenAI Codex login…".to_string();
tokio::spawn(async move {
let (status_tx, mut status_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let forward = tx.clone();
tokio::spawn(async move {
while let Some(s) = status_rx.recv().await {
let _ = forward.send(LoginMsg::Status(s));
}
});
let result = crate::config::login_openai_codex_device(status_tx)
.await
.map_err(|e| e.to_string());
let _ = tx.send(LoginMsg::Done(result));
});
}
pub(crate) fn on_login_result(&mut self, msg: Option<LoginMsg>) {
match msg {
Some(LoginMsg::Status(s)) => self.status = s,
Some(LoginMsg::Done(Ok(creds))) => {
self.login_rx = None;
self.backends.set(
BackendTag::Codex,
OpenRouter::openai_codex(creds.access.clone()),
);
self.saved.codex = Some(creds);
self.status = "OpenAI Codex login saved, loading models…".to_string();
self.fetch_models();
self.refresh_toolbox();
}
Some(LoginMsg::Done(Err(e))) => {
self.login_rx = None;
self.status = format!("OpenAI Codex login failed: {e}");
}
None => self.login_rx = None,
}
}
pub(crate) fn confirm_key(&mut self) {
let key = std::mem::take(&mut self.key_input);
let key = key.trim().to_string();
if key.is_empty() {
self.status = "no key entered".to_string();
self.popup = Popup::None;
return;
}
let (target, tag) = match self.key_target {
KeyTarget::OpenRouter => (KeyTarget::OpenRouter, "openrouter"),
KeyTarget::OpenAi => (KeyTarget::OpenAi, "openai"),
KeyTarget::OpencodeGo => (KeyTarget::OpencodeGo, "opencode"),
};
self.activate_key(target, tag, key);
}
pub(crate) fn favorite_models(&self) -> Vec<&Model> {
self.filtered_panel(true)
}
pub(crate) fn available_models(&self) -> Vec<&Model> {
self.filtered_panel(false)
}
fn filtered_panel(&self, want_fav: bool) -> Vec<&Model> {
let f = self.model_filter.to_lowercase();
let mut out: Vec<&Model> = self
.models
.iter()
.filter(|m| self.model_backend_filter.is_none_or(|t| m.backend == t))
.filter(|m| {
if self.model_pick_target == ModelPickTarget::ImageGen {
m.supports_image_generation
} else {
true
}
})
.filter(|m| {
if self.model_pick_target == ModelPickTarget::VideoGen {
m.supports_video_generation
} else {
true
}
})
.filter(|m| self.favorites.contains(&super::composite_id(m)) == want_fav)
.filter(|m| {
f.is_empty()
|| m.id.to_lowercase().contains(&f)
|| m.name.to_lowercase().contains(&f)
})
.collect();
out.sort_by(|a, b| {
let (ca, cb) = (super::composite_id(a), super::composite_id(b));
let ra = self.last_used.get(&ca).cloned().unwrap_or_default();
let rb = self.last_used.get(&cb).cloned().unwrap_or_default();
rb.cmp(&ra).then_with(|| ca.cmp(&cb))
});
out
}
fn panel_len(&self, panel: ModelPanel) -> usize {
match panel {
ModelPanel::Favorites => self.favorite_models().len(),
ModelPanel::Available => self.available_models().len(),
}
}
const fn state_mut(&mut self, panel: ModelPanel) -> &mut ListState {
match panel {
ModelPanel::Favorites => &mut self.fav_state,
ModelPanel::Available => &mut self.avail_state,
}
}
fn id_at(&self, panel: ModelPanel, index: usize) -> Option<String> {
let list = match panel {
ModelPanel::Favorites => self.favorite_models(),
ModelPanel::Available => self.available_models(),
};
list.get(index).map(|m| super::composite_id(m))
}
pub(crate) fn context_limit(&self) -> Option<u64> {
let id = self.current_model.as_deref()?;
self.models
.iter()
.find(|m| super::composite_id(m) == id)
.and_then(|m| m.context_length)
}
pub(crate) fn context_used(&self) -> u64 {
if !self.is_streaming()
&& let Some(total) = self.context_total
{
return total;
}
let mut chars = self.system_prompt().chars().count();
if let Some(s) = self
.session
.as_ref()
.and_then(|s| s.compact_summary.as_deref())
{
chars += s.chars().count();
}
if let Some(name) = &self.forced_skill
&& let Some(skill) = self.skills.iter().find(|s| &s.name == name)
{
chars += std::fs::read_to_string(skill.dir.join("SKILL.md"))
.map_or(0, |md| crate::skills::skill_body(&md).chars().count());
}
chars += self
.effective_messages()
.iter()
.filter(|m| m.role != "compaction")
.map(|m| m.content.chars().count())
.sum::<usize>();
if let Some(buf) = self.active_streaming_text() {
chars += buf.chars().count();
}
(chars / 4) as u64
}
pub(crate) fn current_model_supports_images(&self) -> bool {
self.current_model.as_deref().is_some_and(|id| {
self.models
.iter()
.any(|m| super::composite_id(m) == id && m.supports_images)
})
}
pub(crate) fn reasoning_of(&self, id: &str) -> Option<&str> {
let effort = self.reasoning.get(id)?.as_str();
self.effort_accepted(id, effort).then_some(effort)
}
pub(crate) fn cycle_reasoning_focused(&mut self) -> Result<()> {
let selected = self.state_mut(self.model_focus).selected().unwrap_or(0);
let Some(id) = self.id_at(self.model_focus, selected) else {
return Ok(());
};
let efforts = self
.models
.iter()
.find(|m| super::composite_id(m) == id)
.map(|m| m.reasoning_efforts.clone())
.unwrap_or_default();
let enabled: Vec<_> = efforts
.iter()
.copied()
.filter(|effort| *effort != crate::provider::ReasoningEffort::None)
.collect();
if enabled.is_empty() {
if self.reasoning.contains_key(&id) {
self.db.set_reasoning(&id, None)?;
self.reasoning.remove(&id);
self.status = format!("cleared stale reasoning setting: {id}");
} else {
self.status = format!("{id} has no reasoning setting");
}
return Ok(());
}
let stored = self.reasoning.get(&id).map(String::as_str);
let pos = stored.and_then(|s| enabled.iter().position(|e| e.as_str() == s));
let next = match pos {
Some(i) if i + 1 < enabled.len() => Some(enabled[i + 1]),
Some(_) if efforts.contains(&crate::provider::ReasoningEffort::None) => {
Some(crate::provider::ReasoningEffort::None)
}
Some(_) => None,
None => Some(enabled[0]),
};
let next = next.map(super::super::provider::ReasoningEffort::as_str);
let accepted = efforts
.iter()
.map(|effort| {
if *effort == crate::provider::ReasoningEffort::None {
"off"
} else {
effort.as_str()
}
})
.collect::<Vec<_>>()
.join("/");
self.db.set_reasoning(&id, next)?;
match next {
Some("none") => {
self.reasoning.insert(id.clone(), "none".to_string());
self.status = format!("reasoning off (accepts {accepted}): {id}");
}
Some(effort) => {
self.reasoning.insert(id.clone(), effort.to_string());
self.status = format!("reasoning {effort} (accepts {accepted}): {id}");
}
None => {
self.reasoning.remove(&id);
self.status = format!("reasoning off (accepts {accepted}): {id}");
}
}
Ok(())
}
pub(crate) fn effort_accepted(&self, model: &str, effort: &str) -> bool {
self.models
.iter()
.find(|m| super::composite_id(m) == model)
.is_none_or(|m| m.reasoning_efforts.iter().any(|e| e.as_str() == effort))
}
pub(crate) fn focused_reasoning_hint(&self) -> Option<String> {
let selected = match self.model_focus {
ModelPanel::Favorites => self.fav_state.selected().unwrap_or(0),
ModelPanel::Available => self.avail_state.selected().unwrap_or(0),
};
let id = self.id_at(self.model_focus, selected)?;
let efforts = self
.models
.iter()
.find(|m| super::composite_id(m) == id)?
.reasoning_efforts
.as_slice();
if efforts.is_empty() {
return None;
}
Some(format!(
"accepts {}",
efforts
.iter()
.map(|effort| {
if *effort == crate::provider::ReasoningEffort::None {
"off"
} else {
effort.as_str()
}
})
.collect::<Vec<_>>()
.join("/")
))
}
pub(crate) fn move_model_selection(&mut self, delta: i32) {
let len = self.panel_len(self.model_focus);
if len == 0 {
return;
}
let state = self.state_mut(self.model_focus);
let cur = state.selected().unwrap_or(0) as i32;
let next = (cur + delta).clamp(0, len as i32 - 1) as usize;
state.select(Some(next));
}
pub(crate) const fn toggle_model_focus(&mut self) {
self.model_focus = match self.model_focus {
ModelPanel::Favorites => ModelPanel::Available,
ModelPanel::Available => ModelPanel::Favorites,
};
}
pub(crate) fn toggle_favorite_focused(&mut self) -> Result<()> {
let selected = self.state_mut(self.model_focus).selected().unwrap_or(0);
let Some(id) = self.id_at(self.model_focus, selected) else {
return Ok(());
};
let now_fav = self.db.toggle_favorite(&id)?;
if now_fav {
self.favorites.insert(id.clone());
self.status = format!("★ favorited {id}");
} else {
self.favorites.remove(&id);
self.status = format!("unfavorited {id}");
}
self.clamp_selection(ModelPanel::Favorites);
self.clamp_selection(ModelPanel::Available);
Ok(())
}
fn clamp_selection(&mut self, panel: ModelPanel) {
let len = self.panel_len(panel);
let state = self.state_mut(panel);
if len == 0 {
state.select(None);
} else {
let cur = state.selected().unwrap_or(0).min(len - 1);
state.select(Some(cur));
}
}
pub(crate) fn confirm_model(&mut self) -> Result<()> {
let selected = self.state_mut(self.model_focus).selected().unwrap_or(0);
if let Some(id) = self.id_at(self.model_focus, selected) {
self.pick_model(&id)?;
}
Ok(())
}
pub(crate) fn pick_model_at(&mut self, panel: ModelPanel, index: usize) -> Result<()> {
self.model_focus = panel;
if index >= self.panel_len(panel) {
return Ok(());
}
self.state_mut(panel).select(Some(index));
if let Some(id) = self.id_at(panel, index) {
self.pick_model(&id)?;
}
Ok(())
}
fn pick_model(&mut self, id: &str) -> Result<()> {
match self.model_pick_target {
ModelPickTarget::Session => {
self.current_model = Some(id.to_string());
if let Some(session) = &self.session {
self.db.set_session_model(&session.id, id)?;
}
self.db.mark_model_used(id)?;
self.last_used
.insert(id.to_string(), Utc::now().to_rfc3339());
self.status = format!("model: {id}");
self.popup = Popup::None;
}
ModelPickTarget::Memory => {
self.memory_model = id.to_string();
self.db.set_setting("memory_model", id)?;
self.status = format!("memory model: {id}");
self.popup = Popup::Settings;
}
ModelPickTarget::Transcriber => {
self.transcriber_model = id.to_string();
self.db.set_setting("transcriber_model", id)?;
self.status = format!("image model: {id}");
self.popup = Popup::Settings;
}
ModelPickTarget::Ocr => {
self.ocr_model = id.to_string();
self.db.set_setting("ocr_model", id)?;
self.status = format!("OCR model: {id}");
self.popup = Popup::Settings;
}
ModelPickTarget::ImageGen => {
self.image_gen_model = id.to_string();
self.db.set_setting("image_gen_model", id)?;
self.status = format!("image gen model: {id}");
self.popup = Popup::Settings;
}
ModelPickTarget::VideoGen => {
self.video_gen_model = id.to_string();
self.db.set_setting("video_gen_model", id)?;
self.status = format!("video gen model: {id}");
self.popup = Popup::Settings;
}
ModelPickTarget::SwarmPersona(row) => {
if let Some(p) = self.swarm_cache.get_mut(row) {
p.model = id.to_string();
}
if let Some(session) = &self.session {
let _ = self.db.save_swarm_personas(&session.id, &self.swarm_cache);
}
self.status = format!("persona model: {id}");
self.popup = Popup::Swarm;
}
}
Ok(())
}
pub(crate) fn clear_memory_model(&mut self) -> Result<()> {
self.memory_model.clear();
self.db.set_setting("memory_model", "")?;
self.status = "memory model cleared — extraction disabled".to_string();
Ok(())
}
pub(crate) fn clear_transcriber_model(&mut self) -> Result<()> {
self.transcriber_model.clear();
self.db.set_setting("transcriber_model", "")?;
self.status = "image model cleared — image descriptions disabled".to_string();
Ok(())
}
pub(crate) fn clear_ocr_model(&mut self) -> Result<()> {
self.ocr_model.clear();
self.db.set_setting("ocr_model", "")?;
self.status = "OCR model cleared — scanned PDFs use tesseract".to_string();
Ok(())
}
pub(crate) fn clear_image_gen_model(&mut self) -> Result<()> {
self.image_gen_model.clear();
self.db.set_setting("image_gen_model", "")?;
self.status = "image gen model cleared — generation disabled".to_string();
Ok(())
}
pub(crate) fn clear_video_gen_model(&mut self) -> Result<()> {
self.video_gen_model.clear();
self.db.set_setting("video_gen_model", "")?;
self.status = "video gen model cleared — generation disabled".to_string();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_space() -> crate::space::Space {
crate::space::Space {
root: std::env::temp_dir().join(format!("nexus-test-{}", uuid::Uuid::new_v4())),
}
}
fn model(id: &str, backend: BackendTag) -> Model {
Model {
id: id.into(),
name: id.into(),
reasoning_efforts: Vec::new(),
context_length: None,
supports_images: false,
supports_image_generation: false,
supports_video_generation: false,
backend,
pricing: None,
}
}
fn app_with_two_backends() -> App {
let mut a = App::new(
crate::db::Db::open_in_memory().unwrap(),
Some("sk-or-test-key"),
test_space(),
);
a.backends
.set(BackendTag::OpenAi, OpenRouter::openai("oa".into()));
a.backends.set(
BackendTag::OpenRouter,
OpenRouter::openrouter_flavor("or".into()),
);
a
}
#[test]
fn resolve_model_backend_picks_the_backend_owning_the_model() {
let mut a = app_with_two_backends();
a.models = vec![
model("gpt-4.1-mini", BackendTag::OpenAi),
model("anthropic/claude-sonnet-4.5", BackendTag::OpenRouter),
];
let (p, raw) = a.resolve_model_backend("openai:gpt-4.1-mini").unwrap();
assert_eq!(p.backend_tag(), BackendTag::OpenAi);
assert_eq!(raw, "gpt-4.1-mini");
let (p, raw) = a
.resolve_model_backend("anthropic/claude-sonnet-4.5")
.unwrap();
assert_eq!(p.backend_tag(), BackendTag::OpenRouter);
assert_eq!(raw, "anthropic/claude-sonnet-4.5");
}
#[test]
fn resolve_model_backend_strips_the_backend_prefix() {
let mut a = app_with_two_backends();
a.models = vec![model("gpt-4.1-mini", BackendTag::OpenAi)];
let (p, raw) = a.resolve_model_backend("openai:gpt-4.1-mini").unwrap();
assert_eq!(p.backend_tag(), BackendTag::OpenAi);
assert_eq!(raw, "gpt-4.1-mini");
}
#[test]
fn resolved_model_looks_valid_restricts_slashy_bare_ids_to_openrouter() {
let a = App::new(
crate::db::Db::open_in_memory().unwrap(),
Some("k"),
test_space(),
);
assert!(a.resolved_model_looks_valid(
"google/gemini-2.5-flash",
BackendTag::OpenRouter,
"google/gemini-2.5-flash"
));
assert!(!a.resolved_model_looks_valid(
"google/gemini-2.5-flash",
BackendTag::OpenAi,
"google/gemini-2.5-flash"
));
assert!(a.resolved_model_looks_valid("gpt-4.1-mini", BackendTag::OpenAi, "gpt-4.1-mini"));
}
#[test]
fn backend_filter_cycles_only_through_configured_backends() {
let mut a = app_with_two_backends();
assert_eq!(a.model_backend_filter_label(), "all backends");
a.cycle_model_backend_filter();
let first = a.model_backend_filter;
assert!(first.is_some());
assert!(!a.model_backend_filter_label().contains("all"));
a.cycle_model_backend_filter();
a.cycle_model_backend_filter();
a.cycle_model_backend_filter();
assert_eq!(a.model_backend_filter, first); }
#[test]
fn favorites_sort_first_in_available_models() {
let mut a = app_with_two_backends();
a.models = vec![
model("a/one", BackendTag::OpenRouter),
model("b/two", BackendTag::OpenRouter),
model("c/three", BackendTag::OpenRouter),
];
a.db.toggle_favorite("b/two").unwrap();
a.load_prefs();
a.last_used
.insert("a/one".into(), "2026-01-01T00:00:00Z".into());
a.last_used
.insert("c/three".into(), "2026-01-02T00:00:00Z".into());
let favs: Vec<&str> = a.favorite_models().iter().map(|m| m.id.as_str()).collect();
assert_eq!(favs, vec!["b/two"]);
let avail: Vec<&str> = a.available_models().iter().map(|m| m.id.as_str()).collect();
assert_eq!(avail, vec!["c/three", "a/one"]);
}
}