From bdd5f9d17294e399465d934b80f7043f79e864af Mon Sep 17 00:00:00 2001 From: clover <24465214+small-angel@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:30:24 -0800 Subject: [PATCH 1/2] fix: group model tiers and fix provider sign-in OpenCode lists speed tiers as their own models (claude-fable-5-2-fast, gpt-5-flex, gpt-6-astra-ultrafast), so the picker showed each twice. A trailing fast, flex, ultrafast or thinking word after the version now marks a tier of the plain model, and the Effort menu offers speed as one choice. A latest alias (mistral-large-latest) is left out when its catalog lists the versions. OpenCode 2 lists GitHub Copilot with only an API key until its plugins activate, so orx now waits for them before reading sign-ins and models. Listed providers link to their key pages, an agent install waits for another install instead of failing on the lock, and the Name step no longer flashes "no models" before it re-checks. Assisted-by: claude-opus-5.5 --- src/commands/up/harness_setup.rs | 29 ++- src/local/harness/managed.rs | 35 +++- src/local/harness/opencode_v2.rs | 5 + src/local/model_routes.rs | 254 +++++++++++++++++++++++++-- src/local/opencode_runtime.rs | 17 ++ src/local/opencode_signin.rs | 37 +++- ui/messages/ar.json | 3 + ui/messages/en.json | 3 + ui/messages/es.json | 3 + ui/messages/fa.json | 3 + ui/messages/hi.json | 3 + ui/messages/ja.json | 3 + ui/messages/ko.json | 3 + ui/messages/pl.json | 3 + ui/messages/ru.json | 3 + ui/messages/tr.json | 3 + ui/messages/zh-CN.json | 3 + ui/src/api.ts | 14 +- ui/src/components/AddAgentDialog.tsx | 46 ++--- ui/src/components/ModelPicker.tsx | 88 +++++++--- ui/src/components/ModelSettings.tsx | 2 +- ui/src/components/pickerAgents.tsx | 9 +- ui/tests/addAgentDialog.test.mjs | 24 +++ ui/tests/agentSwitches.test.mjs | 30 ++++ ui/tests/modelLines.test.mjs | 6 +- ui/tests/models.mjs | 2 +- 26 files changed, 545 insertions(+), 86 deletions(-) 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