//! Every agent the app can run and what it offers, in one catalog the //! pickers and settings read. Each configured provider instance gets a //! background health check: Claude's models, account, slash commands and //! usage limits come from a short-lived `claude` asked over its control //! channel, Pi's models from asking `pi` for what its providers offer, //! jcode's from the model routes its daemon reports. Also the user's //! favorite models. use std::{ io::{BufRead, BufReader, Write}, path::Path, process::Stdio, sync::mpsc, time::{Duration, Instant, SystemTime}, }; use gpui_kit::*; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::claude::{Effort, base_command}; use crate::settings::{self, ProviderInstance}; /// The kind of agent behind a thread. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Provider { #[default] Claude, Pi, Jcode, } impl Provider { pub const ALL: [Provider; 3] = [Provider::Claude, Provider::Pi, Provider::Jcode]; pub fn label(self) -> &'static str { match self { Provider::Claude => "Claude", Provider::Pi => "Pi", Provider::Jcode => "jcode", } } /// The agent's full name, for menus and messages. pub fn agent_name(self) -> &'static str { match self { Provider::Claude => "Claude Code", Provider::Pi => "Pi", Provider::Jcode => "jcode", } } pub fn key(self) -> &'static str { match self { Provider::Claude => "claude", Provider::Pi => "pi", Provider::Jcode => "jcode", } } /// Whether the agent can answer a one-off prompt, for text generation, /// without keeping a session around. Every jcode run is saved. pub fn answers_one_off(self) -> bool { self != Provider::Jcode } /// Whether the agent asks before running tools. Pi and jcode just run them. pub fn asks_permission(self) -> bool { self == Provider::Claude } pub fn from_key(key: &str) -> Option { Self::ALL.into_iter().find(|provider| provider.key() == key) } } #[derive(Clone, Debug, PartialEq)] pub struct ModelEntry { pub provider: Provider, /// The provider instance offering it (`claude`, `claude-2`, `pi`). pub instance: String, /// What the agent is launched with: a Claude alias or model id, Pi's /// `provider/model`, or a jcode model spec like `claude-oauth:claude-opus-5`. 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 and defaults are stored under. pub fn key(&self) -> String { format!("{}:{}", self.instance, self.id) } } /// A `/` command or skill an agent offers. #[derive(Clone, Debug, PartialEq)] pub struct SlashCommand { pub name: String, pub description: String, pub argument_hint: String, /// Installed skills, as opposed to the agent's own commands. pub skill: bool, } #[derive(Clone, Debug, PartialEq)] pub enum ProbeStatus { /// Turned off in the settings. Disabled, Checking, Ready, /// The binary isn't installed (or isn't on a path we look at). Missing, Failed(String), } /// What the last health check found out about one provider instance. #[derive(Clone, Debug)] pub struct InstanceState { pub config: ProviderInstance, pub status: ProbeStatus, /// A check is running; the fields keep the previous one's findings. pub checking: bool, /// The models the agent offers plus the custom ones, in the order the /// settings put them. pub models: Vec, /// The models as the agent reported them. reported: Vec, /// Claude's `default` entry, which the picker shows as the model it resolves to. pub default: Option, /// The signed-in account (Claude's email). pub account: Option, /// The subscription or login (`Claude Team`). pub plan: Option, pub version: Option, /// The newest published version, once an update check ran. pub latest: Option, /// Claude's `get_usage` reply: plan limits and how much of them is used. pub usage: Option, pub commands: Vec, pub checked: Option, } impl InstanceState { fn new(config: ProviderInstance) -> Self { Self { status: if config.enabled { ProbeStatus::Checking } else { ProbeStatus::Disabled }, config, checking: false, models: Vec::new(), reported: Vec::new(), default: None, account: None, plan: None, version: None, latest: None, usage: None, commands: Vec::new(), checked: None, } } pub fn id(&self) -> &str { &self.config.id } pub fn label(&self) -> String { self.config.label() } pub fn kind(&self) -> Provider { self.config.kind } /// Whether a newer version than the installed one is out. pub fn update_available(&self) -> bool { match (&self.version, &self.latest) { (Some(version), Some(latest)) => version_newer(latest, version), _ => false, } } /// Whether a model was added by hand rather than reported by the agent. pub fn is_custom(&self, model: &ModelEntry) -> bool { self.config.custom_models.iter().any(|custom| custom.slug == model.id) && !self.reported.iter().any(|reported| reported.id == model.id) } /// Takes the agent's model list, adding the custom models and putting /// them in the settings' order. fn set_reported(&mut self, models: Vec) { self.reported = models; self.arrange(); } fn arrange(&mut self) { let mut models = self.reported.clone(); for custom in &self.config.custom_models { if custom.slug.trim().is_empty() || models.iter().any(|model| model.id == custom.slug) { continue; } models.push(ModelEntry { provider: self.config.kind, instance: self.config.id.clone(), id: custom.slug.clone(), resolved: custom.slug.clone(), name: if custom.name.trim().is_empty() { custom.slug.clone() } else { custom.name.trim().to_string() }, detail: "Custom".into(), legacy: false, efforts: Vec::new(), fast_mode: false, auto_mode: false, context_window: None, }); } let order = settings::get(|settings| settings.model_order.clone()); models.sort_by_key(|model| order.iter().position(|key| *key == model.key()).unwrap_or(usize::MAX)); self.models = models; } } pub struct Catalog { pub instances: Vec, favorites: Vec, } impl Global for Catalog {} /// What one health check of an instance found. #[derive(Default)] struct Probe { models: Vec, default: Option, account: Option, plan: Option, version: Option, usage: Option, commands: Vec, } impl Catalog { pub fn init(cx: &mut App) { let saved = settings::load(); cx.set_global(Catalog { instances: saved.providers.into_iter().map(InstanceState::new).collect(), favorites: saved.favorites, }); Self::refresh_all(cx); } pub fn global(cx: &App) -> &Catalog { cx.global::() } /// Picks up added, removed and edited instances from the settings, and /// checks the ones that changed. pub fn sync_instances(cx: &mut App) { let configs = settings::instances(); let catalog = cx.global_mut::(); let mut changed = Vec::new(); let mut instances = Vec::new(); for config in configs { match catalog.instances.iter().position(|state| state.config.id == config.id) { Some(ix) => { let mut state = catalog.instances[ix].clone(); if state.config != config { let enabled = config.enabled; state.config = config; state.arrange(); if !enabled { state.status = ProbeStatus::Disabled; } changed.push(state.config.id.clone()); } instances.push(state); } None => { changed.push(config.id.clone()); instances.push(InstanceState::new(config)); } } } catalog.instances = instances; for id in changed { Self::refresh(&id, cx); } cx.refresh_windows(); } /// Checks every enabled instance again. pub fn refresh_all(cx: &mut App) { let ids: Vec = Self::global(cx).instances.iter().map(|state| state.config.id.clone()).collect(); for id in ids { Self::refresh(&id, cx); } } /// Checks one instance in the background: its version, account, models, /// commands and limits. pub fn refresh(id: &str, cx: &mut App) { let update_checks = settings::get(|settings| settings.update_checks); let Some(state) = cx.global_mut::().instances.iter_mut().find(|state| state.config.id == id) else { return; }; if !state.config.enabled { state.status = ProbeStatus::Disabled; cx.refresh_windows(); return; } if state.checking { return; } state.checking = true; if state.checked.is_none() { state.status = ProbeStatus::Checking; } let config = state.config.clone(); cx.refresh_windows(); let id = id.to_string(); let probe = cx.background_executor().spawn(async move { let probe = match config.kind { Provider::Claude => probe_claude(&config), Provider::Pi => probe_pi(&config), Provider::Jcode => probe_jcode(&config), }; let latest = if update_checks { latest_version(config.kind) } else { None }; (probe, latest) }); cx.spawn(async move |cx| { let (result, latest) = probe.await; cx.update(|cx| { let Some(state) = cx.global_mut::().instances.iter_mut().find(|state| state.config.id == id) else { return; }; state.checking = false; state.checked = Some(SystemTime::now()); if latest.is_some() { state.latest = latest; } match result { Ok(probe) => { state.status = ProbeStatus::Ready; if !probe.models.is_empty() || state.config.kind != Provider::Claude { state.set_reported(probe.models); state.default = probe.default; } state.account = probe.account.or(state.account.take()); state.plan = probe.plan.or(state.plan.take()); state.version = probe.version.or(state.version.take()); if probe.usage.is_some() { state.usage = probe.usage; } if !probe.commands.is_empty() { state.commands = probe.commands; } } Err(status) => { if state.config.enabled { state.status = status; } if state.config.kind != Provider::Claude { state.set_reported(Vec::new()); } } } cx.refresh_windows(); }) }) .detach(); } pub fn instance(&self, id: &str) -> Option<&InstanceState> { self.instances.iter().find(|state| state.config.id == id) } /// The enabled instances, in the order the settings list them. pub fn enabled(&self) -> impl Iterator { self.instances.iter().filter(|state| state.config.enabled) } pub fn label(&self, id: &str) -> String { self.instance(id).map(InstanceState::label).unwrap_or_else(|| id.to_string()) } /// The first enabled instance of a kind. pub fn first_of(&self, kind: Provider) -> Option<&InstanceState> { self.enabled().find(|state| state.config.kind == kind) } /// Takes Claude's model list, account and commands from a session's /// `initialize` reply. pub fn set_claude_info(instance: &str, info: &Value, cx: &mut App) { let probe = claude_probe_from(instance, info); let Some(state) = cx.global_mut::().instances.iter_mut().find(|state| state.config.id == instance) else { return; }; let mut changed = false; if probe.account.is_some() && state.account != probe.account { state.account = probe.account; state.plan = probe.plan; changed = true; } if !probe.models.is_empty() && state.reported != probe.models { state.set_reported(probe.models); state.default = probe.default; changed = true; } if !probe.commands.is_empty() && state.commands != probe.commands { state.commands = probe.commands; changed = true; } if state.status != ProbeStatus::Ready { state.status = ProbeStatus::Ready; changed = true; } if changed { cx.refresh_windows(); } } pub fn models(&self, instance: &str) -> &[ModelEntry] { self.instance(instance).map(|state| state.models.as_slice()).unwrap_or_default() } /// Every enabled instance's models. pub fn all_models(&self) -> impl Iterator { self.enabled().flat_map(|state| &state.models) } /// The entry for a thread's model; `None` means the provider's default. pub fn find(&self, instance: &str, id: Option<&str>) -> Option<&ModelEntry> { let state = self.instance(instance)?; match id { None => state.default.as_ref(), Some(id) => state.models.iter().find(|model| model.id == id), } } /// The entry stored under `key` (see [`ModelEntry::key`]). pub fn find_key(&self, key: &str) -> Option<&ModelEntry> { self.instances.iter().flat_map(|state| &state.models).find(|model| model.key() == key) } pub fn commands(&self, instance: &str) -> &[SlashCommand] { self.instance(instance).map(|state| state.commands.as_slice()).unwrap_or_default() } /// Puts every instance's models in the order the settings give them, /// after that order changed. pub fn rearrange(cx: &mut App) { for state in &mut cx.global_mut::().instances { state.arrange(); } cx.refresh_windows(); } /// Whether the settings hide a model from the model picker. pub fn is_hidden(&self, model: &ModelEntry) -> bool { let key = model.key(); settings::get(|settings| settings.hidden_models.contains(&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.all_models().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(); } } /// Splits a model key into its instance and model id. Keys from before /// instances existed name the kind, which is also its first instance's id. pub fn split_key(key: &str) -> Option<(&str, &str)> { key.split_once(':') } /// Parses Claude's `initialize` reply: the models (the default entry apart), /// the account and the commands. fn claude_probe_from(instance: &str, info: &Value) -> Probe { 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, instance: instance.to_string(), 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(); } let commands = info .get("commands") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|command| { let name = command.get("name")?.as_str()?; // Internal commands start with underscores. (!name.starts_with('_')).then(|| SlashCommand { name: name.to_string(), description: command.get("description").and_then(Value::as_str).unwrap_or_default().to_string(), argument_hint: command.get("argumentHint").and_then(Value::as_str).unwrap_or_default().to_string(), // Claude's own commands are marked builtin; skills from // plugins and skill folders aren't. skill: command.get("builtin").and_then(Value::as_bool) != Some(true), }) }) .collect(); Probe { models, default, account: info.pointer("/account/email").and_then(Value::as_str).map(str::to_string), plan: info .pointer("/account/subscriptionType") .and_then(Value::as_str) .map(|plan| if plan.to_lowercase().contains("subscription") { plan.to_string() } else { format!("{plan} Subscription") }), version: info.get("claude_code_version").and_then(Value::as_str).map(str::to_string), usage: None, commands, } } /// Starts `claude` just long enough to ask what it offers and how much of /// the plan's limits is used. Nothing is sent to a model. fn probe_claude(config: &ProviderInstance) -> Result { let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string()); let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?; command .args(["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"]) .arg("--no-session-persistence") .current_dir(std::env::temp_dir()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); 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 stderr = child.stderr.take().ok_or_else(|| failed(&"no stderr"))?; let request = |id: &str, subtype: &str| { json!({"type": "control_request", "request_id": id, "request": {"subtype": subtype}}).to_string() }; writeln!(stdin, "{}", request("probe-init", "initialize")).map_err(|err| failed(&err))?; writeln!(stdin, "{}", request("probe-usage", "get_usage")).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("type").and_then(Value::as_str) == Some("control_response") && tx.send(value["response"].clone()).is_err() { return; } } }); let stderr_tail = std::thread::spawn(move || { BufReader::new(stderr).lines().map_while(Result::ok).filter(|line| !line.trim().is_empty()).last() }); let deadline = Instant::now() + Duration::from_secs(25); let (mut init, mut usage) = (None, None); while init.is_none() || usage.is_none() { let Some(left) = deadline.checked_duration_since(Instant::now()) else { break }; let Ok(response) = rx.recv_timeout(left) else { break }; let ok = response.get("subtype").and_then(Value::as_str) == Some("success"); match response.get("request_id").and_then(Value::as_str) { Some("probe-init") => init = Some(ok.then(|| response["response"].clone())), Some("probe-usage") => usage = Some(ok.then(|| response["response"].clone())), _ => {} } } drop(stdin); let _ = child.kill(); let _ = child.wait(); let Some(Some(info)) = init else { let detail = stderr_tail.join().ok().flatten().unwrap_or_else(|| "Claude Code didn't answer".into()); return Err(failed(&detail)); }; let mut probe = claude_probe_from(&config.id, &info); probe.usage = usage.flatten().filter(|usage| usage.get("rate_limits_available").and_then(Value::as_bool) != Some(false)); Ok(probe) } /// Starts `pi` just long enough to list the models its providers offer, and /// asks its version. fn probe_pi(config: &ProviderInstance) -> Result { let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string()); let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?; command .args(["--mode", "rpc", "--no-session"]) .current_dir(std::env::temp_dir()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); 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)); } let models = reply .pointer("/data/models") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|model| pi_model_from(&config.id, model)) .collect(); let version = base_command(config).ok().and_then(|(_, mut command)| { let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; first_version(&String::from_utf8_lossy(&output.stdout)) }); Ok(Probe { models, account: None, plan: None, version, ..Probe::default() }) } pub fn pi_model_from(instance: &str, 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, instance: instance.to_string(), 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), }) } /// Starts jcode's API bridge just long enough to open a throwaway session /// and read the model routes its daemon offers. jcode only keeps sessions /// that got a message, so the check leaves nothing behind. fn probe_jcode(config: &ProviderInstance) -> Result { let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string()); let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?; command .args(["--no-update", "api", "--stdio"]) .current_dir(std::env::temp_dir()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); 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 (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) && tx.send(value).is_err() { return; } } }); let mut send = |id: u64, request: Value| { let mut request = request; request["v"] = 1.into(); request["id"] = id.into(); writeln!(stdin, "{request}") }; let hello = json!({"req": "hello", "min_version": 1, "max_version": 1, "client": crate::session::JCODE_CLIENT}); let working_dir = std::env::temp_dir().display().to_string(); send(1, hello) .and_then(|_| send(2, json!({"req": "create_session", "working_dir": working_dir}))) .map_err(|err| failed(&err))?; // The daemon may need a while to start the first time. let deadline = Instant::now() + Duration::from_secs(45); let mut info = None; while info.is_none() { let Some(left) = deadline.checked_duration_since(Instant::now()) else { break }; let Ok(event) = rx.recv_timeout(left) else { break }; match event.get("ev").and_then(Value::as_str) { Some("attached") => { let session = event.pointer("/session/session_id").cloned().unwrap_or_default(); send(3, json!({"req": "get_runtime_info", "session_id": session})).map_err(|err| failed(&err))?; } Some("runtime_info") if event.get("reply_to").and_then(Value::as_u64) == Some(3) => info = Some(event), Some("error") => { let message = event.get("message").and_then(Value::as_str).unwrap_or("unknown error").to_string(); let _ = child.kill(); let _ = child.wait(); return Err(failed(&message)); } _ => {} } } let _ = child.kill(); let _ = child.wait(); let info = info.ok_or_else(|| failed(&"jcode didn't list its models"))?; let mut models: Vec = Vec::new(); for route in info.get("routes").and_then(Value::as_array).into_iter().flatten() { if let Some(model) = jcode_model_from(&config.id, route) && !models.iter().any(|known| known.id == model.id) { models.push(model); } } let version = base_command(config).ok().and_then(|(_, mut command)| { let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; first_version(&String::from_utf8_lossy(&output.stdout)) }); // What new sessions start on, e.g. `Default claude-opus-5 via Anthropic`. let plan = match (info.get("provider").and_then(Value::as_str), info.get("model").and_then(Value::as_str)) { (Some(provider), Some(model)) => Some(format!("Default {model} via {provider}")), _ => None, }; Ok(Probe { models, plan, version, ..Probe::default() }) } /// One of jcode's model routes as a picker entry, if it's usable. pub fn jcode_model_from(instance: &str, route: &Value) -> Option { if route.get("available").and_then(Value::as_bool) != Some(true) { return None; } let model = route.get("model")?.as_str()?; let provider = route.get("provider").and_then(Value::as_str).unwrap_or_default(); let method = route.get("api_method").and_then(Value::as_str).unwrap_or_default(); // Anthropic and OpenAI can be reached with a login or an API key. let detail = match method { "claude-oauth" | "openai-oauth" => format!("{provider} · Subscription"), "claude-api" | "openai-api-key" => format!("{provider} · API key"), _ => provider.to_string(), }; let efforts = match method { "claude-oauth" | "claude-api" => vec![Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max], "openai-oauth" | "openai-api-key" => vec![Effort::Low, Effort::Medium, Effort::High, Effort::XHigh], "openrouter" => vec![Effort::Low, Effort::Medium, Effort::High], _ => Vec::new(), }; Some(ModelEntry { provider: Provider::Jcode, instance: instance.to_string(), id: jcode_model_spec(model, provider, method), resolved: model.to_string(), name: model.to_string(), detail, legacy: false, efforts, fast_mode: false, auto_mode: false, context_window: None, }) } /// What jcode's `set_model` takes to pick a model on a particular route, as /// its own model picker builds it. A bare name stays on the current provider. fn jcode_model_spec(model: &str, provider: &str, method: &str) -> String { let prefix = match method { "claude-oauth" => Some("claude-oauth"), "claude-api" => Some("claude-api"), "openai-oauth" => Some("openai-oauth"), "openai-api-key" => Some("openai-api"), "copilot" => Some("copilot"), "cursor" => Some("cursor"), "bedrock" => Some("bedrock"), _ if provider == "Antigravity" => Some("antigravity"), _ => method.strip_prefix("openai-compatible:"), }; match prefix { Some(prefix) => format!("{prefix}:{model}"), None if method == "openrouter" && !matches!(provider, "auto" | "OpenRouter" | "") => { format!("{model}@{provider}") } None => model.to_string(), } } /// The newest published version of an agent, from the npm registry (or /// GitHub, for jcode). fn latest_version(kind: Provider) -> Option { let package = match kind { Provider::Claude => "@anthropic-ai/claude-code", Provider::Pi => "@earendil-works/pi-coding-agent", Provider::Jcode => { let url = "https://api.github.com/repos/1jehuang/jcode/releases/latest"; let body = crate::http::get(url, &[("Accept", "application/vnd.github+json")], Duration::from_secs(10)).ok()?; let tag = serde_json::from_str::(&body).ok()?.get("tag_name")?.as_str()?.to_string(); return Some(tag.trim_start_matches('v').to_string()); } }; let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?; serde_json::from_str::(&body).ok()?.get("version")?.as_str().map(str::to_string) } /// The command that updates an agent in place, and how to describe it. pub fn update_command(config: &ProviderInstance) -> Option<(std::process::Command, &'static str)> { let (binary, mut command) = base_command(config).ok()?; match config.kind { Provider::Claude => { command.arg("update"); Some((command, "claude update")) } Provider::Pi => { // Pi is an npm package; update it with the npm next to it. let npm = binary.parent().map(|dir| dir.join("npm")).filter(|npm| npm.exists()); let mut command = std::process::Command::new(npm.as_deref().unwrap_or(Path::new("npm"))); command.args(["install", "-g", "@earendil-works/pi-coding-agent@latest"]); Some((command, "npm install -g @earendil-works/pi-coding-agent")) } Provider::Jcode => { command.args(["update"]); Some((command, "jcode update")) } } } /// The first `1.2.3`-looking word in `text`. fn first_version(text: &str) -> Option { text.split_whitespace() .map(|word| word.trim_start_matches('v')) .find(|word| word.split('.').count() >= 2 && word.split('.').all(|part| part.parse::().is_ok())) .map(str::to_string) } /// Whether version `a` is newer than `b`, comparing numerically. fn version_newer(a: &str, b: &str) -> bool { let parse = |version: &str| -> Vec { version.trim_start_matches('v').split(['.', '-', '+']).map_while(|part| part.parse().ok()).collect() }; parse(a) > parse(b) } /// `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_probe_from, first_version, jcode_model_from, provider_label, version_newer}; 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"}, ], "account": {"email": "me@example.com", "subscriptionType": "Claude Team"}, "commands": [ {"name": "compact", "description": "Compact", "argumentHint": "", "builtin": true}, {"name": "pdf", "description": "PDF skill", "argumentHint": ""}, {"name": "__internal", "description": "", "argumentHint": "", "builtin": true}, ], "claude_code_version": "2.1.296", }); let probe = claude_probe_from("claude", &info); assert_eq!(probe.default.unwrap().name, "Claude Opus 5.5"); assert_eq!(probe.models.len(), 2); assert!(probe.models[1].legacy); assert_eq!(probe.models[0].efforts.len(), 2); assert_eq!(probe.models[0].key(), "claude:opus"); assert_eq!(probe.plan.as_deref(), Some("Claude Team Subscription")); assert_eq!(probe.commands.len(), 2); assert!(!probe.commands[0].skill && probe.commands[1].skill); assert_eq!(probe.version.as_deref(), Some("2.1.296")); } #[::core::prelude::v1::test] fn compares_versions() { assert!(version_newer("2.1.300", "2.1.296")); assert!(!version_newer("2.1.296", "2.1.296")); assert!(version_newer("1.10.0", "1.9.9")); assert_eq!(first_version("pi v1.1.0\n").as_deref(), Some("1.1.0")); assert_eq!(first_version("2.1.296 (Claude Code)").as_deref(), Some("2.1.296")); } #[::core::prelude::v1::test] fn jcode_routes() { let route = |model: &str, provider: &str, method: &str, available: bool| { json!({"model": model, "provider": provider, "api_method": method, "available": available, "detail": ""}) }; let model = jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-oauth", true)).unwrap(); assert_eq!(model.id, "claude-oauth:claude-opus-5"); assert_eq!(model.detail, "Anthropic · Subscription"); assert_eq!(model.key(), "jcode:claude-oauth:claude-opus-5"); assert!(model.efforts.contains(&crate::claude::Effort::Max)); let model = jcode_model_from("jcode", &route("glm-5", "Z.AI", "openai-compatible:zai", true)).unwrap(); assert_eq!(model.id, "zai:glm-5"); assert!(model.efforts.is_empty()); let model = jcode_model_from("jcode", &route("gpt-5.5", "OpenAI", "openai-api-key", true)).unwrap(); assert_eq!(model.id, "openai-api:gpt-5.5"); let model = jcode_model_from("jcode", &route("anthropic/claude-sonnet-5", "Anthropic", "openrouter", true)); assert_eq!(model.unwrap().id, "anthropic/claude-sonnet-5@Anthropic"); assert_eq!(jcode_model_from("jcode", &route("auto", "auto", "openrouter", true)).unwrap().id, "auto"); assert!(jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-api", false)).is_none()); } }