//! Every model the app can run, across providers, in one catalog the pickers //! read: Claude's from the CLI's `initialize` reply, Pi's from asking `pi` for //! the models its configured providers offer. Also the user's favorites. use std::{ io::{BufRead, BufReader, Write}, process::{Command, Stdio}, sync::mpsc, time::Duration, }; use gpui_kit::*; use serde_json::{Value, json}; use crate::claude::{Effort, find_pi_binary}; use crate::settings; /// The agent behind a thread. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum Provider { #[default] Claude, Pi, } impl Provider { pub fn label(self) -> &'static str { match self { Provider::Claude => "Claude", Provider::Pi => "Pi", } } pub fn key(self) -> &'static str { match self { Provider::Claude => "claude", Provider::Pi => "pi", } } pub fn from_key(key: &str) -> Option { [Provider::Claude, Provider::Pi].into_iter().find(|provider| provider.key() == key) } } #[derive(Clone, Debug, PartialEq)] pub struct ModelEntry { pub provider: Provider, /// What the agent is launched with: a Claude alias or model id, or Pi's /// `provider/model`. pub id: String, /// The concrete model, e.g. `claude-opus-5-5` behind the `opus` alias. pub resolved: String, pub name: String, pub detail: String, /// Older Claude models, listed under "Legacy models". pub legacy: bool, /// Reasoning levels the model accepts. pub efforts: Vec, pub fast_mode: bool, pub auto_mode: bool, pub context_window: Option, } impl ModelEntry { /// The key favorites are stored under. pub fn key(&self) -> String { format!("{}:{}", self.provider.key(), self.id) } } #[derive(Clone, Debug, PartialEq)] pub enum PiStatus { Loading, Ready, /// `pi` isn't installed (or isn't on a path we look at). Missing, Failed(String), } pub struct Catalog { pub claude: Vec, /// Claude's `default` entry, which the picker shows as the model it resolves to. pub claude_default: Option, pub pi: Vec, pub pi_status: PiStatus, /// The signed-in Claude account, once a Claude session reported it. pub claude_account: Option, favorites: Vec, } impl Global for Catalog {} impl Catalog { pub fn init(cx: &mut App) { cx.set_global(Catalog { claude: Vec::new(), claude_default: None, pi: Vec::new(), pi_status: PiStatus::Loading, claude_account: None, favorites: settings::load().favorites, }); Self::refresh_pi(cx); } /// Asks Pi (again) for the models its providers offer. pub fn refresh_pi(cx: &mut App) { cx.global_mut::().pi_status = PiStatus::Loading; cx.refresh_windows(); let probe = cx.background_executor().spawn(async { probe_pi_models() }); cx.spawn(async move |cx| { let result = probe.await; cx.update(|cx| { let catalog = cx.global_mut::(); match result { Ok(models) => { catalog.pi = models; catalog.pi_status = PiStatus::Ready; } Err(status) => { catalog.pi.clear(); catalog.pi_status = status; } } cx.refresh_windows(); }) }) .detach(); } pub fn global(cx: &App) -> &Catalog { cx.global::() } /// Takes Claude's model list and account from a session's `initialize` reply. pub fn set_claude_models(info: &Value, cx: &mut App) { if let Some(account) = info.pointer("/account/email").and_then(Value::as_str) { cx.global_mut::().claude_account = Some(account.to_string()); } let (default, models) = claude_models_from(info); if models.is_empty() { return; } let catalog = cx.global_mut::(); if catalog.claude != models { catalog.claude = models; catalog.claude_default = default; cx.refresh_windows(); } } pub fn models(&self, provider: Provider) -> &[ModelEntry] { match provider { Provider::Claude => &self.claude, Provider::Pi => &self.pi, } } /// The entry for a thread's model; `None` means the provider's default. pub fn find(&self, provider: Provider, id: Option<&str>) -> Option<&ModelEntry> { match (provider, id) { (Provider::Claude, None) => self.claude_default.as_ref(), (provider, Some(id)) => self.models(provider).iter().find(|model| model.id == id), (Provider::Pi, None) => None, } } /// The entry stored under `key` (see [`ModelEntry::key`]). pub fn find_key(&self, key: &str) -> Option<&ModelEntry> { self.claude.iter().chain(&self.pi).find(|model| model.key() == key) } pub fn is_favorite(&self, model: &ModelEntry) -> bool { self.favorites.contains(&model.key()) } pub fn favorites(&self) -> Vec<&ModelEntry> { self.favorites .iter() .filter_map(|key| self.claude.iter().chain(&self.pi).find(|model| &model.key() == key)) .collect() } pub fn toggle_favorite(model: &ModelEntry, cx: &mut App) { let key = model.key(); let catalog = cx.global_mut::(); match catalog.favorites.iter().position(|favorite| *favorite == key) { Some(ix) => { catalog.favorites.remove(ix); } None => catalog.favorites.push(key), } let favorites = catalog.favorites.clone(); settings::update(|settings| settings.favorites = favorites); cx.refresh_windows(); } } /// Parses the `models` of Claude's `initialize` reply into the default entry /// and the pickable ones. fn claude_models_from(info: &Value) -> (Option, Vec) { let mut default = None; let mut models = Vec::new(); for model in info.get("models").and_then(Value::as_array).into_iter().flatten() { let Some(id) = model.get("value").and_then(Value::as_str) else { continue }; let text = |key: &str| model.get(key).and_then(Value::as_str).unwrap_or_default().to_string(); let flag = |key: &str| model.get(key).and_then(Value::as_bool).unwrap_or(false); let display = text("displayName"); let entry = ModelEntry { provider: Provider::Claude, id: id.to_string(), resolved: Some(text("resolvedModel")).filter(|model| !model.is_empty()).unwrap_or_else(|| id.into()), name: if display.starts_with("Claude") { display.clone() } else { format!("Claude {display}") }, detail: text("description"), // Aliases (`opus`, `sonnet`) track the current models; full ids are older ones. legacy: id.starts_with("claude-"), efforts: model .get("supportedEffortLevels") .and_then(Value::as_array) .map(|levels| levels.iter().filter_map(Value::as_str).filter_map(Effort::from_cli).collect()) .unwrap_or_default(), fast_mode: flag("supportsFastMode"), auto_mode: flag("supportsAutoMode"), context_window: None, }; if id == "default" { default = Some(entry); } else { models.push(entry); } } // Present the default as the model it resolves to. if let Some(default) = default.as_mut() && let Some(current) = models.iter().find(|model| !model.legacy && model.resolved == default.resolved) { default.name = current.name.clone(); } (default, models) } /// Starts `pi` just long enough to list the models its providers offer. fn probe_pi_models() -> Result, PiStatus> { let binary = find_pi_binary().ok_or(PiStatus::Missing)?; let mut command = Command::new(&binary); command .args(["--mode", "rpc", "--no-session"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); if let Some(dir) = binary.parent() { let path = std::env::var_os("PATH").unwrap_or_default(); let paths = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&path)); if let Ok(joined) = std::env::join_paths(paths) { command.env("PATH", joined); } } let failed = |err: &dyn std::fmt::Display| PiStatus::Failed(err.to_string()); let mut child = command.spawn().map_err(|err| failed(&err))?; let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?; let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?; let request = json!({"id": "models", "type": "get_available_models"}); writeln!(stdin, "{request}").map_err(|err| failed(&err))?; let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { for line in BufReader::new(stdout).lines().map_while(Result::ok) { if let Ok(value) = serde_json::from_str::(&line) && value.get("id").and_then(Value::as_str) == Some("models") { let _ = tx.send(value); return; } } }); let reply = rx.recv_timeout(Duration::from_secs(30)); drop(stdin); let _ = child.kill(); let _ = child.wait(); let reply = reply.map_err(|_| failed(&"pi didn't list its models"))?; if reply.get("success").and_then(Value::as_bool) != Some(true) { let error = reply.get("error").and_then(Value::as_str).unwrap_or("unknown error"); return Err(failed(&error)); } Ok(reply .pointer("/data/models") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(pi_model_from) .collect()) } pub fn pi_model_from(model: &Value) -> Option { let id = model.get("id")?.as_str()?; let provider = model.get("provider")?.as_str()?; let reasoning = model.get("reasoning").and_then(Value::as_bool).unwrap_or(false); Some(ModelEntry { provider: Provider::Pi, id: format!("{provider}/{id}"), resolved: id.to_string(), name: model.get("name").and_then(Value::as_str).unwrap_or(id).to_string(), detail: provider_label(provider), legacy: false, efforts: if reasoning { vec![Effort::Off, Effort::Minimal, Effort::Low, Effort::Medium, Effort::High] } else { vec![Effort::Off] }, fast_mode: false, auto_mode: false, context_window: model.get("contextWindow").and_then(Value::as_u64), }) } /// `openai-codex` → `OpenAI Codex`. fn provider_label(provider: &str) -> String { provider .split(['-', '_']) .map(|word| match word { "openai" => "OpenAI".to_string(), "openrouter" => "OpenRouter".to_string(), "xai" => "xAI".to_string(), "ai" => "AI".to_string(), word => { let mut chars = word.chars(); chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default() } }) .collect::>() .join(" ") } #[cfg(test)] mod tests { use super::{claude_models_from, provider_label}; use serde_json::json; #[::core::prelude::v1::test] fn provider_labels() { assert_eq!(provider_label("openai-codex"), "OpenAI Codex"); assert_eq!(provider_label("anthropic"), "Anthropic"); assert_eq!(provider_label("openrouter"), "OpenRouter"); assert_eq!(provider_label("google_vertex"), "Google Vertex"); } #[::core::prelude::v1::test] fn claude_default_takes_the_current_name() { let info = json!({"models": [ {"value": "default", "resolvedModel": "claude-opus-5-5", "displayName": "Default (recommended)"}, {"value": "opus", "resolvedModel": "claude-opus-5-5", "displayName": "Opus 5.5", "supportedEffortLevels": ["low", "high"], "supportsFastMode": true}, {"value": "claude-haiku-4-5", "displayName": "Haiku 4.5"}, ]}); let (default, models) = claude_models_from(&info); assert_eq!(default.unwrap().name, "Claude Opus 5.5"); assert_eq!(models.len(), 2); assert!(models[1].legacy); assert_eq!(models[0].efforts.len(), 2); } }