From 2b75d09b75a089d18f416555a24f0d9844b80ec0 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:04:17 +0530 Subject: [PATCH 1/3] auto-detect models in /local add Fetch {url}/models once url + key are filled and open a searchable picker. On failure show the reason (HTTP status, could not connect, timed out, invalid URL) and fall back to typing the id. Fields reordered to URL, API, API Key, Model ID so detection has what it needs. Enter on an empty model jumps to the field instead of erroring the form closed. API Key placeholder now says optional. --- CHANGELOG.md | 4 + crates/alan/src/local_model_overlay.rs | 218 +++++++++++++++++++++---- crates/alan/src/views/mod.rs | 2 +- crates/providers/src/lib.rs | 2 +- crates/providers/src/local.rs | 160 ++++++++++++++++++ 5 files changed, 351 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bcbfab..c5f3dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `/local add` fetches the server's `/models` once URL and key are filled and opens a searchable picker. If detection fails it shows why (e.g. `HTTP 401`, `could not connect`) and falls back to typing the model ID. Fields are now ordered URL, API, API Key, Model ID. + ## 0.1.4 - 2026-10-4 ### Added diff --git a/crates/alan/src/local_model_overlay.rs b/crates/alan/src/local_model_overlay.rs index 32575c2..26e6a6a 100644 --- a/crates/alan/src/local_model_overlay.rs +++ b/crates/alan/src/local_model_overlay.rs @@ -1,9 +1,10 @@ use crate::root::AlanAction; use crate::views::theme; +use crate::views::{SearchListEvent, SearchListOverlay}; use alan_tui::context::Context; -use alan_tui::{ActionStatus, Component, RenderContext}; +use alan_tui::{ActionStatus, Component, RenderContext, TaskError}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; -use providers::{LocalApi, LocalModelEntry, LocalProvider}; +use providers::{LocalApi, LocalModelEntry, LocalProvider, list_local_models}; use ratatui::Frame; use ratatui::layout::Alignment; use ratatui::layout::Rect; @@ -13,7 +14,12 @@ use ratatui::widgets::{Block, BorderType, Paragraph}; use std::sync::Arc; const FIELD_COUNT: usize = 4; -const FIELD_LABELS: [&str; FIELD_COUNT] = ["Model ID", "URL", "API", "API Key"]; +// model last so url + key are known when we auto-detect it. +const FIELD_LABELS: [&str; FIELD_COUNT] = ["URL", "API", "API Key", "Model ID"]; +const URL: usize = 0; +const API: usize = 1; +const API_KEY: usize = 2; +const MODEL: usize = 3; #[derive(Debug, Clone)] struct FormFields { @@ -25,18 +31,18 @@ struct FormFields { impl FormFields { fn field_mut(&mut self, index: usize) -> Option<&mut String> { match index { - 0 => Some(&mut self.model_id), - 1 => Some(&mut self.url), - 3 => Some(&mut self.api_key), + URL => Some(&mut self.url), + API_KEY => Some(&mut self.api_key), + MODEL => Some(&mut self.model_id), _ => None, } } fn field(&self, index: usize) -> &str { match index { - 0 => &self.model_id, - 1 => &self.url, - 3 => &self.api_key, + URL => &self.url, + API_KEY => &self.api_key, + MODEL => &self.model_id, _ => "", } } @@ -73,12 +79,24 @@ enum OverlayState { Error(String), } +/// `GET /models` result for `detect_source`. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Detect { + Idle, + Loading, + Found(Vec), + Failed(String), +} + pub struct LocalModelOverlay { provider: Arc, fields: FormFields, focused: usize, state: OverlayState, edit_mode: bool, + detect: Detect, + // (url, key) the current `detect` belongs to. + detect_source: (String, String), } impl LocalModelOverlay { @@ -107,11 +125,106 @@ impl LocalModelOverlay { focused: 0, state: OverlayState::Editing, edit_mode, + detect: Detect::Idle, + detect_source: (String::new(), String::new()), + } + } + + fn source(&self) -> (String, String) { + ( + self.fields.url.trim().to_owned(), + self.fields.api_key.trim().to_owned(), + ) + } + + /// Fetch the model list when focus lands on Model ID. Reuses a result for + /// the same url/key; a failed one retries. + fn maybe_detect(&mut self, cx: &mut Context<'_, Self, AlanAction>) { + if self.edit_mode || self.focused != MODEL { + return; + } + let source = self.source(); + if source.0.is_empty() + || (source == self.detect_source + && matches!(self.detect, Detect::Loading | Detect::Found(_))) + { + return; + } + self.detect_source = source.clone(); + self.detect = Detect::Loading; + let requested = source.clone(); + cx.spawn( + async move { + let (url, key) = source; + list_local_models(&url, Some(&key)) + .await + .map_err(|e| match e { + // drop the "failed to fetch models:" prefix, hint says it. + providers::ProviderError::Fetch(reason) => TaskError(reason.into()), + e => TaskError(e.to_string().into()), + }) + }, + move |result, overlay, cx| { + // url/key changed mid-flight. + if overlay.detect_source != requested { + return; + } + match result { + Ok(ids) => { + overlay.detect = Detect::Found(ids); + // don't yank focus if they already typed or moved on. + if overlay.focused == MODEL + && overlay.state == OverlayState::Editing + && overlay.fields.model_id.trim().is_empty() + { + overlay.open_picker(cx); + } + } + Err(e) => overlay.detect = Detect::Failed(e.to_string()), + } + cx.notify(); + }, + ); + } + + fn open_picker(&mut self, cx: &mut Context<'_, Self, AlanAction>) { + let Detect::Found(ids) = &self.detect else { + return; + }; + let ids = ids.clone(); + let picker = cx.open_overlay(SearchListOverlay::new("Pick Model", ids.clone())); + cx.subscribe_once::( + picker, + move |event, overlay, _picker, cx| { + if let SearchListEvent::Chosen(index) = event + && let Some(id) = ids.get(*index) + { + overlay.fields.model_id = id.clone(); + cx.notify(); + } + }, + ); + } + + fn on_enter(&mut self, cx: &mut Context<'_, Self, AlanAction>) { + if !self.fields.model_id.trim().is_empty() || self.fields.url.trim().is_empty() { + self.submit(cx); + return; + } + // url set, model missing: detect/pick instead of erroring out the form. + if self.focused != MODEL { + self.focused = MODEL; + self.maybe_detect(cx); + cx.notify(); + } else if matches!(self.detect, Detect::Found(_)) { + self.open_picker(cx); } } - fn move_focus(&mut self, delta: isize) { + fn move_focus(&mut self, delta: isize, cx: &mut Context<'_, Self, AlanAction>) { self.focused = ((self.focused as isize + delta).rem_euclid(FIELD_COUNT as isize)) as usize; + self.maybe_detect(cx); + cx.notify(); } fn submit(&mut self, cx: &mut Context<'_, Self, AlanAction>) { @@ -189,25 +302,11 @@ impl Component for LocalModelOverlay { KeyCode::Esc => { self.dismiss(cx); } - KeyCode::Tab => { - self.move_focus(1); - cx.notify(); - } - KeyCode::BackTab => { - self.move_focus(-1); - cx.notify(); - } - KeyCode::Down => { - self.move_focus(1); - cx.notify(); - } - KeyCode::Up => { - self.move_focus(-1); - cx.notify(); - } - KeyCode::Enter => { - self.submit(cx); - } + KeyCode::Tab => self.move_focus(1, cx), + KeyCode::BackTab => self.move_focus(-1, cx), + KeyCode::Down => self.move_focus(1, cx), + KeyCode::Up => self.move_focus(-1, cx), + KeyCode::Enter => self.on_enter(cx), KeyCode::Backspace => { if let Some(field) = self.fields.field_mut(self.focused) { field.pop(); @@ -248,6 +347,7 @@ impl Component for LocalModelOverlay { self.focused, &self.state, self.edit_mode, + &self.detect, ); } } @@ -259,6 +359,7 @@ fn render_local_model_overlay( focused: usize, state: &OverlayState, edit_mode: bool, + detect: &Detect, ) { let width = area.width.saturating_sub(4).min(88); let height = area.height.saturating_sub(2).min(18); @@ -327,9 +428,9 @@ fn render_local_model_overlay( Style::default() }; - // Value (field 2 is a fixed read-only label so the user + // Value (API is a fixed read-only label so the user // knows local models use the chat-completions API). - let value = if i == 2 { + let value = if i == API { "Chat Completions" } else { fields.field(i) @@ -341,7 +442,9 @@ fn render_local_model_overlay( } else { Style::default().fg(theme::EDITOR_FG) }; - let display = if value.is_empty() && i != 2 { + let display = if value.is_empty() && i == API_KEY { + Span::styled("optional", muted) + } else if value.is_empty() && i != API { Span::styled("required", muted) } else { Span::styled(value, value_style) @@ -360,8 +463,13 @@ fn render_local_model_overlay( Paragraph::new(line).style(line_style), Rect::new(inner.x, y, inner.width, 1), ); + let hint = if i == MODEL { + detect_hint(detect, fields.model_id.trim().is_empty()) + } else { + String::new() + }; frame.render_widget( - Paragraph::new(Span::raw("")).style(Style::default()), + Paragraph::new(Span::styled(hint, muted)), Rect::new(inner.x, y + 1, inner.width, 1), ); } @@ -386,6 +494,21 @@ fn render_local_model_overlay( } } +fn detect_hint(detect: &Detect, model_empty: bool) -> String { + let indent = " ".repeat(5 + FIELD_LABELS.iter().map(|l| l.len()).max().unwrap_or(0) + 2); + match detect { + Detect::Idle => String::new(), + Detect::Loading => format!("{indent}detecting models…"), + Detect::Found(ids) if model_empty => { + format!("{indent}{} found, Enter to pick", ids.len()) + } + Detect::Found(_) => String::new(), + Detect::Failed(reason) => { + format!("{indent}auto-detect failed ({reason}), type the model ID") + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -407,6 +530,7 @@ mod tests { 0, &OverlayState::Editing, false, + &Detect::Idle, ); }) .unwrap(); @@ -444,6 +568,34 @@ mod tests { ); } + #[test] + fn model_field_comes_last() { + let rows = render_grid(80, 18); + let labels: Vec<&str> = rows + .iter() + .filter_map(|row| row.split(':').next()) + .map(str::trim) + .filter(|label| FIELD_LABELS.iter().any(|l| label.ends_with(l))) + .collect(); + assert_eq!(labels.len(), FIELD_COUNT); + assert!(labels[MODEL].ends_with("Model ID"), "{labels:?}"); + } + + #[test] + fn detect_hint_per_state() { + assert_eq!(detect_hint(&Detect::Idle, true), ""); + assert!(detect_hint(&Detect::Loading, true).ends_with("detecting models…")); + let found = Detect::Found(vec!["a".into(), "b".into()]); + assert!(detect_hint(&found, true).ends_with("2 found, Enter to pick")); + // already typed: nothing to nag about. + assert_eq!(detect_hint(&found, false), ""); + let failed = Detect::Failed("HTTP 401 Unauthorized".into()); + assert!( + detect_hint(&failed, false) + .ends_with("auto-detect failed (HTTP 401 Unauthorized), type the model ID") + ); + } + #[test] fn to_entry_rejects_empty_model_id() { let fields = FormFields { diff --git a/crates/alan/src/views/mod.rs b/crates/alan/src/views/mod.rs index 7f57709..19c8eed 100644 --- a/crates/alan/src/views/mod.rs +++ b/crates/alan/src/views/mod.rs @@ -1,4 +1,4 @@ mod components; pub mod theme; -pub(crate) use components::{ChatView, Header, LoginRequested}; +pub(crate) use components::{ChatView, Header, LoginRequested, SearchListEvent, SearchListOverlay}; diff --git a/crates/providers/src/lib.rs b/crates/providers/src/lib.rs index fff7e37..e0894dc 100644 --- a/crates/providers/src/lib.rs +++ b/crates/providers/src/lib.rs @@ -18,7 +18,7 @@ pub use credentials::{ FileCredentialStore, InMemoryCredentialStore, SharedCredentialStore, }; pub use google::{GoogleBuilder, GoogleProvider}; -pub use local::{LocalApi, LocalModelEntry, LocalProvider, bind_local_model}; +pub use local::{LocalApi, LocalModelEntry, LocalProvider, bind_local_model, list_local_models}; pub use model::{Model, ModelError, ModelOptions}; pub use openrouter::{OpenRouterBuilder, OpenRouterProvider}; pub use provider::{Provider, ProviderError, ProviderRegistry, bind_model}; diff --git a/crates/providers/src/local.rs b/crates/providers/src/local.rs index 69b7dcc..989992a 100644 --- a/crates/providers/src/local.rs +++ b/crates/providers/src/local.rs @@ -200,6 +200,67 @@ pub fn bind_local_model( Ok(crate::Model::new_with_options(info, api, auth, options)) } +/// `GET {url}/models` on an OpenAI-compatible server. Sorted, deduped ids. +pub async fn list_local_models( + url: &str, + api_key: Option<&str>, +) -> Result, ProviderError> { + let mut request = reqwest::Client::new() + .get(format!("{}/models", url.trim().trim_end_matches('/'))) + .timeout(std::time::Duration::from_secs(10)); + if let Some(key) = api_key.filter(|key| !key.is_empty()) { + request = request.bearer_auth(key); + } + // reqwest's own message ("error sending request") hides why. + let response = request.send().await.map_err(|error| { + ProviderError::Fetch(if error.is_timeout() { + "timed out".into() + } else if error.is_connect() { + "could not connect".into() + } else if error.is_builder() { + "invalid URL".into() + } else { + format!("request failed: {error}") + }) + })?; + let status = response.status(); + if !status.is_success() { + return Err(ProviderError::Fetch(format!("HTTP {status}"))); + } + let payload = response + .text() + .await + .map_err(|error| ProviderError::Fetch(format!("request failed: {error}")))?; + parse_model_ids(&payload) +} + +fn parse_model_ids(payload: &str) -> Result, ProviderError> { + #[derive(Deserialize)] + struct Listing { + data: Vec, + } + + #[derive(Deserialize)] + struct Listed { + id: String, + } + + let listing: Listing = serde_json::from_str(payload) + .map_err(|_| ProviderError::Fetch("not an OpenAI-style model list".into()))?; + let mut ids: Vec = listing + .data + .into_iter() + .map(|model| model.id) + .filter(|id| !id.trim().is_empty()) + .collect(); + ids.sort(); + ids.dedup(); + if ids.is_empty() { + return Err(ProviderError::Fetch("server listed no models".into())); + } + Ok(ids) +} + #[cfg(test)] mod tests { use super::*; @@ -425,6 +486,105 @@ mod tests { assert_eq!(provider.id(), ProviderId::new("local")); } + // one-shot server: replies `response`, hands back the raw request. + async fn serve_once(response: &'static str) -> (String, tokio::task::JoinHandle) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = socket.read(&mut buffer).await.unwrap(); + assert!(read > 0); + request.extend_from_slice(&buffer[..read]); + } + socket.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + }); + (format!("http://{address}/v1/"), server) + } + + #[test] + fn parse_model_ids_sorts_and_dedupes() { + let ids = parse_model_ids( + r#"{"data":[{"id":"b"},{"id":"a","owned_by":"x"},{"id":"b"},{"id":" "}]}"#, + ) + .unwrap(); + assert_eq!(ids, ["a", "b"]); + } + + #[test] + fn parse_model_ids_rejects_empty_and_junk() { + assert!(parse_model_ids(r#"{"data":[]}"#).is_err()); + assert!(parse_model_ids(r#"{"models":[{"name":"x"}]}"#).is_err()); + assert!(parse_model_ids("404").is_err()); + } + + #[tokio::test] + async fn list_local_models_sends_key_to_models_path() { + let (url, server) = serve_once( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"m1\"}]}", + ) + .await; + let ids = list_local_models(&url, Some("sk-test")).await.unwrap(); + assert_eq!(ids, ["m1"]); + let request = server.await.unwrap(); + assert!(request.starts_with("GET /v1/models HTTP/1.1"), "{request}"); + assert!( + request + .to_lowercase() + .contains("authorization: bearer sk-test") + ); + } + + #[tokio::test] + async fn list_local_models_skips_empty_key() { + let (url, server) = + serve_once("HTTP/1.1 200 OK\r\nconnection: close\r\n\r\n{\"data\":[{\"id\":\"m1\"}]}") + .await; + list_local_models(&url, Some("")).await.unwrap(); + assert!( + !server + .await + .unwrap() + .to_lowercase() + .contains("authorization") + ); + } + + #[tokio::test] + async fn list_local_models_reports_http_status() { + let (url, _server) = serve_once( + "HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n{\"error\":\"Invalid API key\"}", + ) + .await; + let error = list_local_models(&url, Some("bad")).await.unwrap_err(); + assert!(error.to_string().contains("401"), "{error}"); + } + + #[tokio::test] + async fn list_local_models_reports_unreachable() { + // bind then drop: port is closed. + let address = std::net::TcpListener::bind(("127.0.0.1", 0)) + .unwrap() + .local_addr() + .unwrap(); + let error = list_local_models(&format!("http://{address}/v1"), None) + .await + .unwrap_err(); + assert!(error.to_string().contains("could not connect"), "{error}"); + } + + #[tokio::test] + async fn list_local_models_reports_invalid_url() { + let error = list_local_models("not a url", None).await.unwrap_err(); + assert!(error.to_string().contains("invalid URL"), "{error}"); + } + #[tokio::test] async fn empty_provider_has_no_models() { let store = MockStore::shared(); From accd8f6201e6816951b33e4b96b53a9a78f5f9b1 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:14:51 +0530 Subject: [PATCH 2/3] re-pick detected model with left/right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model ID renders as ◀ id ▶ once the server's model list is detected; Left/Right reopen the picker so a wrong pick can be changed. --- crates/alan/src/local_model_overlay.rs | 47 +++++++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/alan/src/local_model_overlay.rs b/crates/alan/src/local_model_overlay.rs index 26e6a6a..667cca3 100644 --- a/crates/alan/src/local_model_overlay.rs +++ b/crates/alan/src/local_model_overlay.rs @@ -306,6 +306,12 @@ impl Component for LocalModelOverlay { KeyCode::BackTab => self.move_focus(-1, cx), KeyCode::Down => self.move_focus(1, cx), KeyCode::Up => self.move_focus(-1, cx), + // re-pick after a wrong choice. + KeyCode::Left | KeyCode::Right + if self.focused == MODEL && matches!(self.detect, Detect::Found(_)) => + { + self.open_picker(cx) + } KeyCode::Enter => self.on_enter(cx), KeyCode::Backspace => { if let Some(field) = self.fields.field_mut(self.focused) { @@ -450,15 +456,25 @@ fn render_local_model_overlay( Span::styled(value, value_style) }; - let line = Line::from(vec![ + let mut spans = vec![ Span::styled(if is_focused { " › " } else { " " }, accent), Span::styled( format!("{:>width$} ", FormFields::label(i), width = max_label), label_style, ), Span::styled(": ", muted), - display, - ]); + ]; + // detected list available: ◀ ▶ reopen the picker. + if i == MODEL && matches!(detect, Detect::Found(_)) { + spans.extend([ + Span::styled("◀ ", accent), + display, + Span::styled(" ▶", accent), + ]); + } else { + spans.push(display); + } + let line = Line::from(spans); frame.render_widget( Paragraph::new(line).style(line_style), Rect::new(inner.x, y, inner.width, 1), @@ -500,7 +516,7 @@ fn detect_hint(detect: &Detect, model_empty: bool) -> String { Detect::Idle => String::new(), Detect::Loading => format!("{indent}detecting models…"), Detect::Found(ids) if model_empty => { - format!("{indent}{} found, Enter to pick", ids.len()) + format!("{indent}{} found, Enter or ◀ ▶ to pick", ids.len()) } Detect::Found(_) => String::new(), Detect::Failed(reason) => { @@ -514,7 +530,7 @@ mod tests { use super::*; use ratatui::{Terminal, backend::TestBackend}; - fn render_grid(width: u16, height: u16) -> Vec { + fn render_grid(width: u16, height: u16, detect: &Detect) -> Vec { let fields = FormFields { model_id: "gpt-4o-mini".to_owned(), url: "http://localhost:11434".to_owned(), @@ -530,7 +546,7 @@ mod tests { 0, &OverlayState::Editing, false, - &Detect::Idle, + detect, ); }) .unwrap(); @@ -549,7 +565,7 @@ mod tests { #[test] fn form_field_labels_align_to_a_fixed_column() { - let rows = render_grid(80, 18); + let rows = render_grid(80, 18, &Detect::Idle); let mut colon_cols = Vec::new(); for row in &rows { if let Some(col) = row.chars().position(|character| character == ':') { @@ -570,7 +586,7 @@ mod tests { #[test] fn model_field_comes_last() { - let rows = render_grid(80, 18); + let rows = render_grid(80, 18, &Detect::Idle); let labels: Vec<&str> = rows .iter() .filter_map(|row| row.split(':').next()) @@ -581,12 +597,25 @@ mod tests { assert!(labels[MODEL].ends_with("Model ID"), "{labels:?}"); } + #[test] + fn model_arrows_only_when_detected() { + let model_row = |detect: &Detect| { + render_grid(80, 18, detect) + .into_iter() + .find(|row| row.contains("Model ID")) + .unwrap() + }; + assert!(!model_row(&Detect::Idle).contains('◀')); + assert!(!model_row(&Detect::Failed("x".into())).contains('◀')); + assert!(model_row(&Detect::Found(vec!["gpt-4o-mini".into()])).contains("◀ gpt-4o-mini ▶")); + } + #[test] fn detect_hint_per_state() { assert_eq!(detect_hint(&Detect::Idle, true), ""); assert!(detect_hint(&Detect::Loading, true).ends_with("detecting models…")); let found = Detect::Found(vec!["a".into(), "b".into()]); - assert!(detect_hint(&found, true).ends_with("2 found, Enter to pick")); + assert!(detect_hint(&found, true).ends_with("2 found, Enter or ◀ ▶ to pick")); // already typed: nothing to nag about. assert_eq!(detect_hint(&found, false), ""); let failed = Detect::Failed("HTTP 401 Unauthorized".into()); From 591b3a60d7889d64ef10e664c65a08b0b4c6e957 Mon Sep 17 00:00:00 2001 From: Revantark Date: Mon, 5 Oct 2026 20:28:32 +0530 Subject: [PATCH 3/3] Update CHANGELOG.md --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28490f0..7088c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- `/local add` fetches the server's `/models` once URL and key are filled and opens a searchable picker; once detected, ◀ ▶ on Model ID reopen it to change the pick. If detection fails it shows why (e.g. `HTTP 401`, `could not connect`) and falls back to typing the model ID. Fields are now ordered URL, API, API Key, Model ID. - ### Changed +- `/local add` auto-detects models via `/models` and provides a searchable picker for model selection. - Provider-specific request/response handling moved out of the generic chat completions codec into per-provider codecs. - Remove google from active providers.