diff --git a/src/commands/up/harness_setup.rs b/src/commands/up/harness_setup.rs index 588352ee4..bfb288b33 100644 --- a/src/commands/up/harness_setup.rs +++ b/src/commands/up/harness_setup.rs @@ -535,7 +535,7 @@ async fn managed_setup( follow_up: &mut FollowUp, opencode_host: &crate::local::opencode::AgentHost, ) -> Result, String> { - let _setup = managed::lock_installations().map_err(|error| error.to_string())?; + let _setup = wait_for_installations(socket).await?; let harness = request.harness.as_str(); let user = managed::user_install(harness).await; let copy = managed::installed(harness); @@ -707,6 +707,33 @@ async fn install_with_progress( } } +/// The installations lock, once another install, removal or background update lets go of it. +async fn wait_for_installations( + socket: &mut WebSocket, +) -> Result { + let error = |error: crate::error::Error| error.to_string(); + if let Some(lock) = managed::try_lock_installations().map_err(error)? { + return Ok(lock); + } + write_terminal(socket, "Waiting for another agent install to finish…\r\n") + .await + .map_err(|error| error.to_string())?; + loop { + tokio::select! { + _ = tokio::time::sleep(crate::config::paced(std::time::Duration::from_millis(250))) => { + if let Some(lock) = managed::try_lock_installations().map_err(error)? { + return Ok(lock); + } + } + message = socket.recv() => { + if matches!(message, None | Some(Err(_)) | Some(Ok(Message::Close(_)))) { + return Err("The install was cancelled.".into()); + } + } + } + } +} + /// The same progress as data, for a client that draws a progress bar in place of the terminal. async fn write_progress( socket: &mut WebSocket, diff --git a/src/local/harness/managed.rs b/src/local/harness/managed.rs index 0a0a0e239..f53759292 100644 --- a/src/local/harness/managed.rs +++ b/src/local/harness/managed.rs @@ -59,20 +59,29 @@ fn root() -> PathBuf { /// Hold across install decisions and removal so dashboards cannot race shared runtime cleanup. pub(crate) fn lock_installations() -> Result { + try_lock_installations()?.ok_or_else(|| { + anyhow!("Another agent install or removal is in progress. Try again when it finishes.") + }) +} + +/// [`lock_installations`], or `None` while another install or removal holds it. +pub(crate) fn try_lock_installations() -> Result> { let root = crate::config::config_dir(); std::fs::create_dir_all(&root)?; + let path = root.join("managed-agents.lock"); let lock = std::fs::OpenOptions::new() .create(true) .truncate(false) .read(true) .write(true) - .open(root.join("managed-agents.lock"))?; - lock.try_lock().map_err(|error| { - anyhow!( - "Another agent install or removal is in progress. Try again when it finishes: {error}" - ) - })?; - Ok(crate::file_lock::FileLock::new(lock)) + .open(&path)?; + match lock.try_lock() { + Ok(()) => Ok(Some(crate::file_lock::FileLock::new(lock))), + Err(std::fs::TryLockError::WouldBlock) => Ok(None), + Err(std::fs::TryLockError::Error(error)) => { + Err(anyhow!("Couldn't lock {}: {error}", path.display())) + } + } } fn executable(harness: &str, release: &Path) -> PathBuf { @@ -819,6 +828,18 @@ mod tests { } } + #[test] + fn an_install_in_progress_reads_as_busy_rather_than_failing() { + let _held = lock_installations().unwrap(); + assert!(try_lock_installations().unwrap().is_none()); + let Err(busy) = lock_installations() else { + panic!("a second install took the lock"); + }; + assert!(busy + .to_string() + .starts_with("Another agent install or removal is in progress")); + } + #[tokio::test] async fn uninstall_validates_before_touching_any_path() { let directory = diff --git a/src/local/harness/opencode_v2.rs b/src/local/harness/opencode_v2.rs index ff3f58159..3686a0eec 100644 --- a/src/local/harness/opencode_v2.rs +++ b/src/local/harness/opencode_v2.rs @@ -945,6 +945,11 @@ async fn discover_models_within( post(endpoint, "/api/plugin/await-activation", &json!({})).await?; } loop { + if !endpoint.legacy_v2_api && !endpoint.plugins_active().await? { + plugins_pending = true; + tokio::time::sleep(retry).await; + continue; + } let models = catalog_get(endpoint, "/api/model").await?; plugins_pending = models.is_none(); if let Some(models) = models { diff --git a/src/local/model_routes.rs b/src/local/model_routes.rs index 03d9a199b..39ee46181 100644 --- a/src/local/model_routes.rs +++ b/src/local/model_routes.rs @@ -104,15 +104,37 @@ const TIER_WORDS: &[&str] = &[ "reasoner", ]; -/// What an offer adds to its model: Cursor's and Antigravity's effort, speed and thinking, Claude Code's long context. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +/// Speed tiers catalogs list as models of their own, slowest first. +const SPEEDS: &[&str] = &["flex", "fast", "ultrafast"]; + +/// What an offer adds to its model: Cursor's and Antigravity's effort, any catalog's speed and thinking tiers, Claude +/// Code's long context. +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Variant { pub effort: Option, - pub fast: bool, + /// One of [`SPEEDS`]; none for the standard tier. + pub speed: Option, pub thinking: bool, pub context: Option, } +impl Serialize for Variant { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + use serde::ser::SerializeStruct; + let mut variant = serializer.serialize_struct("Variant", 5)?; + variant.serialize_field("effort", &self.effort)?; + variant.serialize_field("speed", &self.speed)?; + // What dashboards from before `speed` read. + variant.serialize_field("fast", &(self.speed.as_deref() == Some("fast")))?; + variant.serialize_field("thinking", &self.thinking)?; + variant.serialize_field("context", &self.context)?; + variant.end() + } +} + /// One catalog entry read as a model. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] @@ -130,6 +152,9 @@ pub struct ModelName { pub variant: Variant, /// How far the catalog's name is from its model's plain variant; the lowest runs by default. pub variant_rank: usize, + /// A moving alias (`mistral-large-latest`) whose catalog also lists the versions it moves between, so pickers + /// leave it out. + pub superseded: bool, } /// Cursor and Antigravity list each effort, speed and thinking mode as a model of its own. @@ -212,11 +237,16 @@ fn read(name: &str, spells: bool, variant: &mut Variant) -> Reading { let lower = word.to_lowercase(); if spells && (EFFORTS.contains(&lower.as_str()) - || matches!(lower.as_str(), "extra" | "fast" | "thinking" | "no") + || SPEEDS.contains(&lower.as_str()) + || matches!(lower.as_str(), "extra" | "thinking" | "no") || is_context(&lower)) { if is_context(&lower) { variant.context = Some(lower); + } else if SPEEDS.contains(&lower.as_str()) { + variant.speed.get_or_insert(lower); + } else if lower == "thinking" { + variant.thinking = true; } reading.variant_words += 1; continue; @@ -263,7 +293,9 @@ fn id_variant(harness: &str, id: &str) -> Variant { let mut parts: Vec<&str> = lower.split('-').collect(); while parts.len() > 1 { match *parts.last().unwrap() { - "fast" => variant.fast = true, + speed if SPEEDS.contains(&speed) && variant.speed.is_none() => { + variant.speed = Some(speed.to_string()) + } "thinking" => variant.thinking = true, effort if EFFORTS.contains(&effort) && variant.effort.is_none() => { let extra = effort == "high" && parts[parts.len() - 2] == "extra"; @@ -322,6 +354,32 @@ fn plain_name(name: &str, free: bool) -> String { name } +/// `name` less the trailing speed and `thinking` words that make a tier of one model, each recorded on `variant`. +/// Only words after a version count, so `Grok Code Fast 1` keeps its word. +fn peel_switches(name: &str, variant: &mut Variant) -> String { + let mut name = name.trim_end(); + // A hyphen separates words only in a slug, so `Flash 1017 Ultra-fast` stays one word. + let slug = !name.contains(' '); + let separator = |c: char| c == ' ' || slug && matches!(c, '-' | '_' | ':'); + loop { + let start = name.rfind(separator).map_or(0, |index| index + 1); + let rest = name[..start].trim_end_matches(separator); + if !rest.contains(|c: char| c.is_ascii_digit()) { + break; + } + let word = name[start..].to_ascii_lowercase().replace('-', ""); + if SPEEDS.contains(&word.as_str()) { + variant.speed = Some(word); + } else if word == "thinking" { + variant.thinking = true; + } else { + break; + } + name = rest; + } + name.to_string() +} + /// One catalog entry read as a model: its key across catalogs, its version, and its variant. An alias is keyed /// by `resolved`, the versioned model it runs. pub fn parse( @@ -340,7 +398,12 @@ pub fn parse( let name = display_name .filter(|name| !name.is_empty()) .map_or_else(|| tail(id), str::to_string); - let reading = read(&plain_name(&name, free), spells, &mut variant); + let mut name = plain_name(&name, free); + if !spells { + peel_switches(&plain_name(&tail(id), free), &mut variant); + name = peel_switches(&name, &mut variant); + } + let reading = read(&name, spells, &mut variant); let resolved_reading = resolved.map(|resolved| { read( &plain_name(&tail(resolved), false), @@ -392,8 +455,18 @@ pub fn parse( } None => family.clone(), }; - let variant_words = - reading.variant_words + usize::from(harness == "claude-code" && variant.context.is_some()); + let variant_words = if spells { + reading.variant_words + } else { + [ + variant.speed.is_some(), + variant.thinking, + variant.context.is_some(), + ] + .into_iter() + .filter(|&flag| flag) + .count() + }; ModelName { key: if free { format!("{versioned_key}:free") @@ -406,19 +479,58 @@ pub fn parse( label: reading.kept.join(" "), variant_rank: variant_words * 2 + usize::from(variant.effort.as_deref() != Some("medium")), variant, + superseded: false, } } /// Adds each wire model's [`ModelName`] as `parsed`, which the picker groups by and Settings hides by. pub fn name_models(harness: &str, models: &mut Value) { - for model in models.as_array_mut().into_iter().flatten() { - let parsed = parse( - harness, - model["id"].as_str().unwrap_or_default(), - model["displayName"].as_str(), - model["resolvedModel"].as_str(), - ); - model["parsed"] = serde_json::json!(parsed); + let Some(models) = models.as_array_mut() else { + return; + }; + let text = |model: &Value, field: &str| model[field].as_str().map(str::to_owned); + let entries: Vec<(String, Option, ModelName)> = models + .iter() + .map(|model| { + let id = text(model, "id").unwrap_or_default(); + let display = text(model, "displayName"); + let parsed = parse( + harness, + &id, + display.as_deref(), + model["resolvedModel"].as_str(), + ); + (id, display, parsed) + }) + .collect(); + + // OpenCode's ids lead with their provider; each provider is a catalog of its own. + let catalog = |id: &str| { + id.split_once('/') + .map_or("", |(catalog, _)| catalog) + .to_owned() + }; + let unaliased = |family: &str| { + let words: Vec<&str> = family.split(' ').filter(|word| *word != "latest").collect(); + words.join(" ") + }; + let versioned: std::collections::HashSet<(String, String)> = entries + .iter() + .filter(|(_, _, name)| name.version.is_some()) + .map(|(id, _, name)| (catalog(id), unaliased(&name.family))) + .collect(); + let says_latest = |text: &str| { + text.to_ascii_lowercase() + .split(|c: char| !c.is_ascii_alphanumeric()) + .any(|word| word == "latest") + }; + + for (model, (id, display, mut name)) in models.iter_mut().zip(entries) { + let alias = name.version.is_none() + && (says_latest(id.rsplit('/').next().unwrap_or(&id)) + || display.as_deref().is_some_and(says_latest)); + name.superseded = alias && versioned.contains(&(catalog(&id), unaliased(&name.family))); + model["parsed"] = serde_json::json!(name); } } @@ -877,6 +989,11 @@ mod tests { ..Variant::default() } ); + let named = parse("cursor", "opaque-id", Some("GPT 5 Flex Thinking"), None); + assert_eq!( + (named.variant.speed.as_deref(), named.variant.thinking), + (Some("flex"), true) + ); let grok = parse( "cursor", "grok-4.7-low-fast", @@ -887,9 +1004,9 @@ mod tests { ( grok.label.as_str(), grok.variant.effort.as_deref(), - grok.variant.fast + grok.variant.speed.as_deref() ), - ("Grok 4.7", Some("low"), true) + ("Grok 4.7", Some("low"), Some("fast")) ); let short = parse("claude-code", "claude-opus-5-5", Some("Opus 5.5"), None); @@ -907,6 +1024,107 @@ mod tests { assert!(long.variant_rank > short.variant_rank); } + #[test] + fn any_catalogs_speed_and_thinking_tiers_are_variants_of_one_model() { + let name = |id, display: Option<&str>| parse("opencode", id, display, None); + let plain = name("anthropic/claude-fable-5-2", Some("Claude Fable 5.2")); + let tiers = [ + name( + "anthropic/claude-fable-5-2-fast", + Some("Claude Fable 5.2 Fast"), + ), + name( + "vercel/anthropic/claude-fable-5.2-fast", + Some("Claude Fable 5.2 (Fast)"), + ), + ]; + for tier in &tiers { + assert_eq!( + ( + tier.key.as_str(), + tier.label.as_str(), + tier.variant.speed.as_deref() + ), + (plain.key.as_str(), "Claude Fable 5.2", Some("fast")), + "{tier:?}" + ); + assert!(tier.variant_rank > plain.variant_rank); + } + + let unnamed = name("anthropic/claude-fable-5-2-fast", None); + assert_eq!( + (unnamed.key.as_str(), unnamed.label.as_str()), + (plain.key.as_str(), "claude-fable-5-2") + ); + + let both = name( + "vercel/openai/gpt-5.1-thinking-fast", + Some("GPT 5.1 Thinking (Fast)"), + ); + assert_eq!( + ( + both.key.as_str(), + both.variant.speed.as_deref(), + both.variant.thinking + ), + ( + name("vercel/openai/gpt-5.1", Some("GPT 5.1")).key.as_str(), + Some("fast"), + true + ) + ); + + let ultra = name( + "fireworks-ai/routers/deepseek-v4p1-flash-1017-ultrafast", + Some("DeepSeek V4.1 Flash 1017 Ultra-fast"), + ); + assert_eq!( + (ultra.variant.speed.as_deref(), ultra.label.as_str()), + (Some("ultrafast"), "DeepSeek V4.1 Flash") + ); + + let named = name("grok-code-fast-1", Some("Grok Code Fast 1")); + assert!(named.variant.speed.is_none() && named.label == "Grok Code Fast 1"); + } + + #[test] + fn every_speed_tier_is_a_variant_and_older_dashboards_still_read_fast() { + let speed = |id| parse("opencode", id, None, None).variant.speed; + assert_eq!(speed("openai/gpt-5-flex").as_deref(), Some("flex")); + assert_eq!( + speed("openai/gpt-6-astra-ultrafast").as_deref(), + Some("ultrafast") + ); + assert_eq!( + parse("opencode", "openai/gpt-6-astra-ultrafast", None, None).key, + parse("opencode", "openai/gpt-6-astra", None, None).key + ); + + let wire = |id| serde_json::json!(parse("opencode", id, None, None).variant); + assert_eq!(wire("openai/gpt-5.5-fast")["fast"], true); + assert_eq!(wire("openai/gpt-5-flex")["fast"], false); + } + + #[test] + fn a_latest_alias_steps_aside_for_the_versions_its_catalog_lists() { + let mut models = serde_json::json!([ + {"id": "mistral/mistral-large-latest", "displayName": "Mistral Large (latest)"}, + {"id": "mistral/mistral-large-2512", "displayName": "Mistral Large 3"}, + {"id": "google/gemini-pro-latest", "displayName": "Gemini Pro Latest"}, + {"id": "google/gemini-2.5-pro", "displayName": "Gemini 2.5 Pro"}, + {"id": "openai/chat-latest", "displayName": "Chat Latest"}, + {"id": "other/gemini-pro-latest", "displayName": "Gemini Pro Latest"}, + ]); + name_models("opencode", &mut models); + let superseded: Vec = models + .as_array() + .unwrap() + .iter() + .map(|model| model["parsed"]["superseded"].as_bool().unwrap()) + .collect(); + assert_eq!(superseded, [true, false, true, false, false, false]); + } + #[test] fn a_series_spans_the_tier_words_a_lab_reuses_across_versions() { let name = |id, display| parse("opencode", id, Some(display), None); diff --git a/src/local/opencode_runtime.rs b/src/local/opencode_runtime.rs index b6352ccdb..c819c125f 100644 --- a/src/local/opencode_runtime.rs +++ b/src/local/opencode_runtime.rs @@ -263,6 +263,23 @@ impl AgentEndpoint { Ok(false) } + /// Whether OpenCode's plugins have activated. Until then its integration list lacks the sign-ins plugins add, + /// such as GitHub Copilot's, and shows generic API keys instead. A version with no plugin list counts as active. + pub async fn plugins_active(&self) -> Result { + let response = self.get("/api/plugin").send().await?; + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Ok(true); + } + if status == reqwest::StatusCode::SERVICE_UNAVAILABLE { + return Ok(false); + } + let body: serde_json::Value = response.error_for_status()?.json().await?; + Ok(body["data"] + .as_array() + .is_some_and(|plugins| !plugins.is_empty())) + } + fn get(&self, path: &str) -> reqwest::RequestBuilder { self.client .get(format!("{}{path}", self.base_url)) diff --git a/src/local/opencode_signin.rs b/src/local/opencode_signin.rs index b60095a1f..2dd8afaf3 100644 --- a/src/local/opencode_signin.rs +++ b/src/local/opencode_signin.rs @@ -92,6 +92,15 @@ impl Drop for Server { async fn catalog(server: &Server, resource: &str) -> Result { let deadline = tokio::time::Instant::now() + Duration::from_secs(30); loop { + if !server.endpoint.plugins_active().await? { + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!( + "OpenCode's plugins didn't start within 30 seconds, so its sign-ins are incomplete. Re-check OpenCode." + )); + } + tokio::time::sleep(Duration::from_millis(200)).await; + continue; + } let response = server .request(Method::GET, &["api", resource]) .send() @@ -534,7 +543,7 @@ mod tests { use axum::extract::{Query, State}; use axum::routing::{get, post}; use axum::{Json, Router}; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; #[derive(Default)] @@ -542,6 +551,8 @@ mod tests { complete: AtomicBool, cancelled: AtomicBool, requests: Mutex>, + /// Plugin list reads left before OpenCode's plugins activate. + inactive_reads: AtomicUsize, } async fn fixture(root: &std::path::Path) -> (Server, Arc, tokio::task::JoinHandle<()>) { @@ -549,7 +560,17 @@ mod tests { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let router = Router::new() - .route("/api/integration", get(|| async { Json(json!({"data":[{"id":"xai","methods":[{"type":"oauth","id":"device","label":"SuperGrok"}],"connections":[{"type":"credential","id":"cred_oauth","method":"oauth"},{"type":"credential","id":"cred_key","method":"key"},{"type":"env","name":"XAI_API_KEY"}]}]})) })) + .route("/api/plugin", get(|State(mock): State>| async move { + let inactive = mock.inactive_reads.try_update(Ordering::SeqCst, Ordering::SeqCst, |left| left.checked_sub(1)).is_ok(); + Json(if inactive { json!({"data":[]}) } else { json!({"data":[{"id":"opencode.provider.xai","state":{"status":"active"}}]}) }) + })) + .route("/api/integration", get(|State(mock): State>| async move { + // Before plugins activate, OpenCode lists only a generic key. + if mock.inactive_reads.load(Ordering::SeqCst) > 0 { + return Json(json!({"data":[{"id":"xai","methods":[{"type":"key"}],"connections":[]}]})); + } + Json(json!({"data":[{"id":"xai","methods":[{"type":"oauth","id":"device","label":"SuperGrok"}],"connections":[{"type":"credential","id":"cred_oauth","method":"oauth"},{"type":"credential","id":"cred_key","method":"key"},{"type":"env","name":"XAI_API_KEY"}]}]})) + })) .route("/api/credential/{id}", axum::routing::delete(|State(mock): State>, axum::extract::Path(id): axum::extract::Path| async move { mock.requests.lock().unwrap().push(json!({"removed":id})); axum::http::StatusCode::NO_CONTENT @@ -697,6 +718,18 @@ mod tests { } } + #[tokio::test] + async fn integrations_wait_for_the_sign_ins_plugins_add() { + let root = crate::local::git::TemporaryDirectory::new("orx-signin-test").unwrap(); + let (mut server, mock, task) = fixture(root.path()).await; + mock.inactive_reads.store(2, Ordering::SeqCst); + let integrations = catalog(&server, "integration").await.unwrap(); + assert_eq!(integrations["data"][0]["methods"][0]["type"], "oauth"); + server.stop().await; + task.abort(); + let _ = task.await; + } + #[tokio::test] async fn sign_out_removes_the_integrations_stored_credentials_through_the_api() { let root = crate::local::git::TemporaryDirectory::new("orx-signin-test").unwrap(); diff --git a/ui/messages/ar.json b/ui/messages/ar.json index 262be5dd7..6a7b38b18 100644 --- a/ui/messages/ar.json +++ b/ui/messages/ar.json @@ -1012,6 +1012,9 @@ "model_picker_mode": "الوضع", "model_picker_permissions": "الأذونات", "model_picker_fast_mode": "الوضع السريع", + "model_picker_speed_fast": "سريع", + "model_picker_speed_flex": "مرن", + "model_picker_speed_ultrafast": "فائق السرعة", "model_picker_ultracode_description": "جهد عالٍ جدًا مع مسارات عمل ديناميكية.", "model_picker_autonomy": "الاستقلالية", "model_picker_autonomy_agentic": "مستقل", diff --git a/ui/messages/en.json b/ui/messages/en.json index bbfcf6c95..bbe753fc6 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -1013,6 +1013,9 @@ "model_picker_mode": "Mode", "model_picker_permissions": "Permissions", "model_picker_fast_mode": "Fast mode", + "model_picker_speed_fast": "Fast", + "model_picker_speed_flex": "Flex", + "model_picker_speed_ultrafast": "Ultrafast", "model_picker_ultracode_description": "Extra high effort with dynamic workflows.", "model_picker_autonomy": "Autonomy", "model_picker_autonomy_agentic": "Independent", diff --git a/ui/messages/es.json b/ui/messages/es.json index 05d688a87..d1f4e8b53 100644 --- a/ui/messages/es.json +++ b/ui/messages/es.json @@ -1013,6 +1013,9 @@ "model_picker_mode": "Modo", "model_picker_permissions": "Permisos", "model_picker_fast_mode": "Modo rápido", + "model_picker_speed_fast": "Rápido", + "model_picker_speed_flex": "Flex", + "model_picker_speed_ultrafast": "Ultrarrápido", "model_picker_ultracode_description": "Esfuerzo extra alto con flujos de trabajo dinámicos.", "model_picker_autonomy": "Autonomía", "model_picker_autonomy_agentic": "Independiente", diff --git a/ui/messages/fa.json b/ui/messages/fa.json index afe02690b..ccbb2e1b6 100644 --- a/ui/messages/fa.json +++ b/ui/messages/fa.json @@ -1007,6 +1007,9 @@ "model_picker_mode": "حالت", "model_picker_permissions": "مجوزها", "model_picker_fast_mode": "حالت سریع", + "model_picker_speed_fast": "سریع", + "model_picker_speed_flex": "منعطف", + "model_picker_speed_ultrafast": "فوق‌سریع", "model_picker_ultracode_description": "استدلال بسیار بالا با گردش‌کارهای پویا.", "model_picker_autonomy": "خودمختاری", "model_picker_autonomy_agentic": "مستقل", diff --git a/ui/messages/hi.json b/ui/messages/hi.json index 4cfad7066..bf789d31c 100644 --- a/ui/messages/hi.json +++ b/ui/messages/hi.json @@ -1013,6 +1013,9 @@ "model_picker_mode": "मोड", "model_picker_permissions": "अनुमतियाँ", "model_picker_fast_mode": "तेज़ मोड", + "model_picker_speed_fast": "तेज़", + "model_picker_speed_flex": "फ़्लेक्स", + "model_picker_speed_ultrafast": "अति तेज़", "model_picker_ultracode_description": "अतिरिक्त उच्च प्रयास स्तर, गतिशील वर्कफ़्लो के साथ।", "model_picker_autonomy": "स्वायत्तता", "model_picker_autonomy_agentic": "स्वतंत्र", diff --git a/ui/messages/ja.json b/ui/messages/ja.json index ec960c2b2..d51c68247 100644 --- a/ui/messages/ja.json +++ b/ui/messages/ja.json @@ -1013,6 +1013,9 @@ "model_picker_mode": "モード", "model_picker_permissions": "権限", "model_picker_fast_mode": "高速モード", + "model_picker_speed_fast": "高速", + "model_picker_speed_flex": "Flex", + "model_picker_speed_ultrafast": "超高速", "model_picker_ultracode_description": "非常に高い推論レベルと動的ワークフロー。", "model_picker_autonomy": "自律性", "model_picker_autonomy_agentic": "自律型", diff --git a/ui/messages/ko.json b/ui/messages/ko.json index 025f0d302..4e1dd058f 100644 --- a/ui/messages/ko.json +++ b/ui/messages/ko.json @@ -1013,6 +1013,9 @@ "model_picker_mode": "모드", "model_picker_permissions": "권한", "model_picker_fast_mode": "빠른 모드", + "model_picker_speed_fast": "빠름", + "model_picker_speed_flex": "플렉스", + "model_picker_speed_ultrafast": "초고속", "model_picker_ultracode_description": "매우 높은 추론 수준과 동적 워크플로.", "model_picker_autonomy": "자율성", "model_picker_autonomy_agentic": "독립 실행", diff --git a/ui/messages/pl.json b/ui/messages/pl.json index cb0b86c9b..59fa65638 100644 --- a/ui/messages/pl.json +++ b/ui/messages/pl.json @@ -1013,6 +1013,9 @@ "model_picker_mode": "Tryb", "model_picker_permissions": "Uprawnienia", "model_picker_fast_mode": "Tryb szybki", + "model_picker_speed_fast": "Szybki", + "model_picker_speed_flex": "Flex", + "model_picker_speed_ultrafast": "Ultraszybki", "model_picker_ultracode_description": "Bardzo wysoki poziom rozumowania z dynamicznymi przepływami pracy.", "model_picker_autonomy": "Samodzielność", "model_picker_autonomy_agentic": "Samodzielny", diff --git a/ui/messages/ru.json b/ui/messages/ru.json index 80f0c2932..db2884310 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -1013,6 +1013,9 @@ "model_picker_mode": "Режим", "model_picker_permissions": "Разрешения", "model_picker_fast_mode": "Быстрый режим", + "model_picker_speed_fast": "Быстрая", + "model_picker_speed_flex": "Flex", + "model_picker_speed_ultrafast": "Сверхбыстрая", "model_picker_ultracode_description": "Очень высокое усилие с динамическими рабочими процессами.", "model_picker_autonomy": "Самостоятельность", "model_picker_autonomy_agentic": "Самостоятельно", diff --git a/ui/messages/tr.json b/ui/messages/tr.json index 0590f0d79..cdcc9f044 100644 --- a/ui/messages/tr.json +++ b/ui/messages/tr.json @@ -1039,6 +1039,9 @@ "model_picker_mode": "Mod", "model_picker_permissions": "İzinler", "model_picker_fast_mode": "Hızlı mod", + "model_picker_speed_fast": "Hızlı", + "model_picker_speed_flex": "Esnek", + "model_picker_speed_ultrafast": "Ultra hızlı", "model_picker_ultracode_description": "Dinamik iş akışlarıyla ekstra yüksek çaba.", "model_picker_autonomy": "Özerklik", "model_picker_autonomy_agentic": "Bağımsız", diff --git a/ui/messages/zh-CN.json b/ui/messages/zh-CN.json index b7ef4a1ee..f9d7b30ea 100644 --- a/ui/messages/zh-CN.json +++ b/ui/messages/zh-CN.json @@ -1007,6 +1007,9 @@ "model_picker_mode": "模式", "model_picker_permissions": "权限", "model_picker_fast_mode": "快速模式", + "model_picker_speed_fast": "快速", + "model_picker_speed_flex": "弹性", + "model_picker_speed_ultrafast": "超快", "model_picker_ultracode_description": "超高推理强度,并启用动态工作流。", "model_picker_autonomy": "自主程度", "model_picker_autonomy_agentic": "独立", diff --git a/ui/src/api.ts b/ui/src/api.ts index 2edd71892..21c105af1 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1111,9 +1111,13 @@ export interface HarnessModel { parsed: ModelName; } -/** What an offer adds to its model: Cursor's and Antigravity's effort, speed and thinking, Claude Code's long context. */ +/** What an offer adds to its model: Cursor's and Antigravity's effort, any catalog's speed and thinking tiers, Claude + * Code's long context. */ export interface ModelVariant { effort: string | null; + /** `flex`, `fast` or `ultrafast`; null for the standard tier. */ + speed: string | null; + /** `speed` is `fast`, as servers before `speed` say it. */ fast: boolean; thinking: boolean; context: string | null; @@ -1133,6 +1137,8 @@ export interface ModelName { variant: ModelVariant; /** How far the catalog's name is from its model's plain variant; a tile runs the lowest by default. */ variantRank: number; + /** A moving alias (`mistral-large-latest`) whose catalog also lists the versions it moves between. */ + superseded?: boolean; } /** Display label for a harness model: the catalog's own name when it has one, @@ -2765,7 +2771,11 @@ export function createApi( if (retryRejected) params.set("retry", "1"); if (harness) params.set("harness", harness); const query = params.size > 0 ? `?${params.toString()}` : ""; - return get<{ harnesses: Harness[] }>(`/api/harnesses${query}`, signal).then((r) => r.harnesses); + return get<{ harnesses: Harness[] }>(`/api/harnesses${query}`, signal).then((r) => { + // A server from before speed tiers says only whether a model is fast. + for (const model of r.harnesses.flatMap((h) => h.models)) model.parsed.variant.speed ??= model.parsed.variant.fast ? "fast" : null; + return r.harnesses; + }); }; const listAcpHarnesses = () => get("/api/harnesses/acp"); diff --git a/ui/src/components/AddAgentDialog.tsx b/ui/src/components/AddAgentDialog.tsx index 2eddea732..ac1f88124 100644 --- a/ui/src/components/AddAgentDialog.tsx +++ b/ui/src/components/AddAgentDialog.tsx @@ -67,7 +67,7 @@ type OAuthSignIn = { kind: "oauth"; integration: string; method?: string; plan?: type SignIn = | KeySignIn | OAuthSignIn - | { kind: "opencode"; id: string } + | { kind: "opencode"; id: string; keyUrl?: string } | { kind: "profile"; harness: "claude-code" | "codex" } | { kind: "agent"; harness: HarnessId; plan?: string; via?: string } | { kind: "native"; logo: string; key?: true } @@ -145,25 +145,25 @@ const OPENROUTER: AgentTile = { id: "openrouter", name: "OpenRouter", mark: { brand: "openrouter" }, blurb: m.add_agent_openrouter_blurb, methods: [{ kind: "key", id: "openrouter", platform: "OpenRouter", keyUrl: "https://openrouter.ai/keys" }], }; -/** A provider from OpenCode's list, by its id there. */ -const listedTile = (id: string, name: string, mark: AgentTile["mark"]): AgentTile => ({ id, name, mark, methods: [{ kind: "opencode", id }] }); +/** A provider from OpenCode's list, by its id there, with where to get a key when OpenCode takes one. */ +const listedTile = (id: string, name: string, mark: AgentTile["mark"], keyUrl?: string): AgentTile => ({ id, name, mark, methods: [{ kind: "opencode", id, keyUrl }] }); /** Providers people look for by name, in the order offered; search finds the rest of OpenCode's list. */ const MORE_PROVIDERS = [ listedTile("github-copilot", "GitHub Copilot", { brand: "github-copilot" }), - listedTile("openai", "OpenAI", { harness: "codex" }), - listedTile("anthropic", "Anthropic", { brand: "anthropic" }), - listedTile("huggingface", "Hugging Face", { brand: "huggingface" }), - listedTile("amazon-bedrock", "Amazon Bedrock", { brand: "bedrock" }), - listedTile("groq", "Groq", { brand: "groq" }), - listedTile("cerebras", "Cerebras", { brand: "cerebras" }), - listedTile("togetherai", "Together AI", { brand: "together" }), - listedTile("fireworks-ai", "Fireworks AI", { brand: "fireworks" }), - listedTile("mistral", "Mistral", { brand: "mistral" }), - listedTile("nvidia", "NVIDIA", { brand: "nvidia" }), - listedTile("vercel", "Vercel AI Gateway", { brand: "vercel" }), - listedTile("ollama-cloud", "Ollama Cloud", { brand: "ollama" }), - listedTile("deepinfra", "DeepInfra", { brand: "deepinfra" }), - listedTile("baseten", "Baseten", { brand: "baseten" }), + listedTile("openai", "OpenAI", { harness: "codex" }, "https://platform.openai.com/api-keys"), + listedTile("anthropic", "Anthropic", { brand: "anthropic" }, "https://platform.claude.com/settings/keys"), + listedTile("huggingface", "Hugging Face", { brand: "huggingface" }, "https://huggingface.co/settings/tokens"), + listedTile("amazon-bedrock", "Amazon Bedrock", { brand: "bedrock" }, "https://console.aws.amazon.com/bedrock/home#/api-keys"), + listedTile("groq", "Groq", { brand: "groq" }, "https://console.groq.com/keys"), + listedTile("cerebras", "Cerebras", { brand: "cerebras" }, "https://cloud.cerebras.ai"), + listedTile("togetherai", "Together AI", { brand: "together" }, "https://api.together.ai/settings/api-keys"), + listedTile("fireworks-ai", "Fireworks AI", { brand: "fireworks" }, "https://app.fireworks.ai/settings/users/api-keys"), + listedTile("mistral", "Mistral", { brand: "mistral" }, "https://console.mistral.ai/api-keys"), + listedTile("nvidia", "NVIDIA", { brand: "nvidia" }, "https://build.nvidia.com/settings/api-keys"), + listedTile("vercel", "Vercel AI Gateway", { brand: "vercel" }, "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys"), + listedTile("ollama-cloud", "Ollama Cloud", { brand: "ollama" }, "https://ollama.com/settings/keys"), + listedTile("deepinfra", "DeepInfra", { brand: "deepinfra" }, "https://deepinfra.com/dash/keys"), + listedTile("baseten", "Baseten", { brand: "baseten" }, "https://app.baseten.co/settings/api_keys"), ]; const PROVIDER_TILES = [OPENCODE_GO, ...LABS, OPENROUTER, ...MORE_PROVIDERS]; /** What the wizard recommends first. */ @@ -188,7 +188,10 @@ export function providerTile(provider: Pick id === base || id.startsWith(`${base}-`)); return { tile: listedTile(id, provider.name, variant?.[1] ?? { letter: provider.name.slice(0, 1).toUpperCase() }) }; } - return { tile, key: provider.kind === "key" ? tile.methods.find((method): method is KeySignIn => method.kind === "key" && method.id === id) : undefined }; + if (provider.kind !== "key") return { tile }; + const signIn = tile.methods.find((method) => (method.kind === "key" || method.kind === "opencode") && method.id === id); + if (signIn?.kind === "opencode") return { tile, key: { kind: "key", id, platform: tile.name, keyUrl: signIn.keyUrl } }; + return { tile, key: signIn?.kind === "key" ? signIn : undefined }; } /** Where saved providers sort: the order the add dialog offers them. */ @@ -331,7 +334,7 @@ export function AddAgentDialog({ remote, host, tile: opening, hideRecommended = // How OpenCode signs in to a provider from its list, sign-ins before keys. const ways: (OAuthSignIn | KeySignIn)[] = entry && tile ? [ ...entry.methods.flatMap((item): OAuthSignIn[] => item.type === "oauth" && !item.label.endsWith("(legacy)") ? [{ kind: "oauth", integration: entry.id, method: item.id, label: item.label }] : []), - ...entry.methods.flatMap((item): KeySignIn[] => item.type === "key" ? [{ kind: "key", id: entry.id, platform: tile.name }] : []), + ...entry.methods.flatMap((item): KeySignIn[] => item.type === "key" ? [{ kind: "key", id: entry.id, platform: tile.name, keyUrl: listing?.keyUrl }] : []), ] : []; const harnessFor = (signIn: SignIn) => @@ -373,8 +376,9 @@ export function AddAgentDialog({ remote, host, tile: opening, hideRecommended = target?.focus(); }, [step, method, ways.length, needsOpenCode2]); - // Saved keys and endpoints reach OpenCode's catalog only once it re-checks. - useEffect(() => { + // Saved keys and endpoints reach OpenCode's catalog only once it re-checks. Before paint, so the step never + // shows the catalog from before the sign-in. + useLayoutEffect(() => { if (step !== "name" || !backedByOpenCode || !opencodeReady) return; let current = true; setChecking(true); diff --git a/ui/src/components/ModelPicker.tsx b/ui/src/components/ModelPicker.tsx index bd5351840..2f10b1425 100644 --- a/ui/src/components/ModelPicker.tsx +++ b/ui/src/components/ModelPicker.tsx @@ -19,13 +19,14 @@ import { Popover } from "@base-ui/react/popover"; import { DirectionProvider } from "@base-ui/react/direction-provider"; import { m } from "../paraglide/messages.js"; -import { Bot, Brain, Check, ChevronDown, ChevronRight, Handshake, ListChecks, Lock, LockOpen, Pencil, Plus, Ruler, Settings, ShieldCheck, Workflow, Zap, type LucideIcon } from "lucide-react"; +import { Bot, Brain, Check, ChevronDown, ChevronRight, Handshake, ListChecks, Lock, LockOpen, Minus, Pencil, Plus, Rocket, Ruler, Settings, ShieldCheck, Turtle, Workflow, Zap, type LucideIcon } from "lucide-react"; import { modelLabel, reasoningFor, reconcileReasoning, reconcileServiceTier, REASONING_DEFAULT_ID, + SERVICE_TIER_DEFAULT_ID, serviceTiersFor, type Harness, type HarnessId, @@ -193,6 +194,25 @@ function DescribedRadioItem({ choice, icon, isDefault }: { choice: OptionChoice; ); } +/** Speed tiers slowest first, as the Speed control offers them; null is the standard tier. */ +const SPEEDS = ["flex", null, "fast", "ultrafast"] as const; +const SPEED_ICONS: Record = { flex: Turtle, fast: Zap, ultrafast: Rocket }; + +function speedName(speed: string | null) { + return speed === "fast" ? m.model_picker_speed_fast() + : speed === "ultrafast" ? m.model_picker_speed_ultrafast() + : speed === "flex" ? m.model_picker_speed_flex() + : speed ?? m.service_tier_standard(); +} + +function SpeedIcon({ speed }: { speed: string | null }) { + const Icon = speed ? SPEED_ICONS[speed] ?? Zap : Minus; + return